C++ ときどき ごはん、わりとてぃーぶれいく☆

Wonder Rabbit Projectのなかのひとのブログ。主にC++。

UE4/C++/BP: UGameplayStatics::OpenLevel の Options が ? に化ける怪異、あるいは UEngine::SetClientTravel のパラメーターの本当の意味について

次のコードあるいは相当のBPは期待とは異なる動作を招くかもしれない。

UGameplayStatics::OpenLevel
( GetWorld()
, TEXT( "Sushi" )
, true
, TEXT( "#Tako" )
);

『このようなレベルの開き方をした場合、開かれたレベルでは AGameMode::OptionsString? に「化け」たように観測される現象が必ず発生する( ・`ω・´)』

ΩΩΩ<な、なんだってー!?

聡明な UE4/C++er の各位は、既に疑いの眼差しを Options として与えられる実引数が Tako ではなく #Tako とされている点へ向いていると思う。その通りだ。私がそれに気付くためにはユーザーが与えたパラメーターの収集とパターンの分析、 UGameplayStatics の実装詳細の確認、加えて UEngine::SetClientTravel の実装詳細を確認する必要があった。冒頭のコード例は原因が分かりやすい例に簡略化した後のものだ。

先ず、この現象はバグではない。 UE4 の設計に基づいた正しい挙動である事を明示しておく。 UE4OpenLevel には # 文字を含めてしまうとプログラマーあるいはユーザーが意図したであろう Options は開かれるレベルの AGameMode::OptionsString へ伝達されない。それはなぜか?

UGameplayStatics::OpenLevel を眺めてみよう。

この部分の実装詳細を見るとパラメーターの無駄な操作が気になるかもしれないが、やっている事はパラメーターの簡単な確認と enum への変換を行っているがこの現象を引き起こす原因は無く、最終的に UEngine::SetClientTravel を呼び出す実装になっている。

// Note: 要点だけにした擬似コード。あくまでも擬似コード注意。
void UGameplayStatics::OpenLevel(const UObject* WorldContextObject, FName LevelName, bool bAbsolute, FString Options)
{
  FString Cmd = FString( LevelName ) + TEXT( "?" ) + Options;
  const ETravelType TravelType = bAsbolute ? TRAVEL_Absolute : TRAVEL_Relative
  GEngine->SetClientTravel( World, *Cmd, TravelType );
}

UEngine::SetClientTravel の呼び出し時点では「化け」や「欠損」は起きていないが、聡明な UE4/C++er たちはちょっとした引っ掛かり、気付きのようなものを得たかもしれない。

  1. 「文字列で結合した」
  2. 「レベル名文字列とオプション群文字列の間に ? 文字をセパレーターとして挿入した」

その通りだ。問題の核心にだいぶ接近してきた。

続いて、 UEngine::SetClientTravel を見よう。

この関数は短く簡単だが、問題の本質に迫るヒントが大きい。

void UEngine::SetClientTravel( UPendingNetGame *PendingNetGame, const TCHAR* NextURL, ETravelType InTravelType )
{
    FWorldContext &Context = GetWorldContextFromPendingNetGameChecked(PendingNetGame);

    // set TravelURL.  Will be processed safely on the next tick in UGameEngine::Tick().
    Context.TravelURL    = NextURL;
    Context.TravelType   = InTravelType;

    // Prevent crashing the game by attempting to connect to own listen server
    if ( Context.LastURL.HasOption(TEXT("Listen")) )
    {
        Context.LastURL.RemoveOption(TEXT("Listen"));
    }
}

呼び出し元で Cmd として結合されていたパラメーターは NextURL 仮引数に渡っている。名前でもうわかったかもしれない。

UE4 のレベル間の遷移パラメーターは簡単には「レベル名文字列」と「オプション群文字列」にある意味で "偽装" されているが、実際には「URL」だったんだ( ・`ω・´)』

ΩΩΩ<な、なんだってー!?

一般的に URL のパターンを構成要素ごとの部品に分解すると、

// Note: 厳密にはちょっと違いますが…
{scheme}://{host}:{port}/{path}?{query}#{fragment}`
// scheme <- http とか ftp とか
// host <- 127.0.0.1 とか www.example.com とか
// port <- 80 とか 443 とか
// path <- /ja/docs/hoge とか
// query <- ?q=sushi&action=eat とか
// fragment <- #top とか

と、いうわけで、そもそも UE4 のレベル間の遷移パラメーターは URL 形式だったので # 文字があると「フラグメント」扱いで対象ホストには渡らないのでした。( URL 的にはフラグメント部分は呼び出し元にしか影響せず呼び出し先へは伝達しない)

おまけ

WorldContext::TravelURL も覗いてみよう。

 /** URL to travel to for pending client connect */
    FString TravelURL;

と、いうわけでここへ文字列値をセットしただけでは # 以降のフラグメント文字列の消失事件はまだ起っていない。その後 TravelURL が参照される際に FURL を介して ?# で分割、フィルターされフラグメント扱いの # 以降が消失して AGameMode::Init へ渡されているものと想像できた。冒頭の「消失」ではなく「化け」も UE4 が設計上 URL として予期していない Sushi?#Tako が与えられた事でオプション文字列の実装都合 ? 文字がオプション群文字列として渡ってしまったのだろう。

UEngine::Browse あたりを眺めると、どうやらそもそもレベルを開く際に渡す Options はユーザーが任意に与えて良い設計でもなかったようだ(事実上そうそうクエリーが意図せず UE4 で予約されたクエリー名と被ることもないのだろうけれど)。

ちょっとした文字列パラメーターをレベル間で渡すのに便利なのでついこの程度の実装詳細も追わないまま、ただの文字列パラメーターを渡せる機能だろうと使ってしまっていたが、レベル間のユーザーデータ、パラメーターの伝搬には別の手段、素直な選択肢としてはレベル間で値を伝搬する仕組みを実装したカスタム UGameInstance を使うとか、外部のストレージを経由させるとかするのが良さそうだ。

今回はこのあたりまで確認した段階で "探索" は終える事にした。

ちなみに、 FURLSushi?#Tako を与えると、

FString s( TEXT( "Sushi?#Tako" ) );
FURL url( *s );
UE_LOG( LogTemp, Log, TEXT( "Protocol=%s Map=%s Op.Num()=%d" ), *url.Protocol, *url.Map, url.Op.Num() )
Protocol=unreal Map=Sushi?#Tako Op.Num()=0

こんな結果が得られる。どうやら、この段階でフラグメントが消失したり化けたりしているわけではなかったらしい。 UEngineAGameMode か、これ以上を知りたい場合はソースコードのリーディングではなくエンジンにデバッガーを挿した方が手っ取り早そうだ。