ブログに戻る

Unreal EngineにおけるGDKおよびWindows StoreビルドのXbox Save Roaming不具合の修正方法

公開日 2026年7月7日
Unreal EngineにおけるGDKおよびWindows StoreビルドのXbox Save Roaming不具合の修正方法

要点まとめ

Unreal EngineのGDKおよびWindows StoreビルドにおけるXbox Save Roaming不具合の原因と、その具体的な修正方法を技術的に解説します。非同期ログイン処理に起因するRace Conditionによって、PC側で正しい `UserIndex` が解決されずにセーブデータがローカルのゲストプロファイルへフォールバックされる問題に対処します。C++およびBlueprintsを用いたOnline Subsystem経由での適切なユーザー認証とインデックス解決フローの実装手順を紹介し、さらにプラットフォームに依存しない [horizOn](https://horizon.pm) を用いたクラウドセーブの実装も提案します。

GDK Save Roamingの悪夢

Xbox Series XおよびWindows StoreのGDKビルドをパッケージングし、実績の同期を確認し、起動時にクラウド同期スピナーが回転するのを見て安心したのも束の間、プレイヤーのセーブファイルが各プラットフォームで完全に孤立しているのを目にして愕然とすることになります。ユーザーの視点からは、サインインは成功し、通知は実行され、両方のビルドでゲーム起動間でセーブファイルがローカルに保持されているなど、すべてが正常に見えます。しかし、Xboxでの進行状況がPCビルドに同期(roam)されることはなく、その逆も同様です。

セーブデータの不整合に対処することは、実際のゲームプレイ中に multiplayer desyncs(同期ズレ)をデバッグするのと同じくらいストレスが溜まるものです。どちらの問題も、ステートオーソリティ(状態権限)の不一致に起因しています。Xbox Play Anywhere(XPA)の場合、この問題がコンソールレベルの障害であることは稀です。ほぼすべての場合において、Unreal Engineが抽象化された整数ベースのユーザーインデックスをMicrosoftのGDK固有のアイデンティティシステムにマッピングする際、Race Conditionが発生していることが原因です。なぜこれが起こるのか、そしてC++とBlueprintsの両方でどのように修正するのかを見ていきましょう。

GDK Connected Storageの構造

Save Roamingが失敗する理由を理解するには、まずMicrosoftのGDKが内部でユーザーのセーブファイルをどのように処理しているかを理解する必要があります。セーブファイルをリモートサーバーにアップロードされる生のファイルとして扱う通常のSteamやカスタムゲームのBackendとは異なり、Microsoft Game Development Kit (GDK) は Connected StorageXGameSave API)を使用します。このアーキテクチャでは、セーブデータは生のファイルパスではなく、構造化されたコンテナシステムになります。これらのコンテナには、Xbox Liveサーバーと安全に同期される個々のセーブファイルのBlobが保持されます。

XboxコンソールとWindows Store (PC) ビルド間でクラウドによるSave Roamingを機能させるには、GDKの厳格な一致マトリックスに依存します。両方のビルドで、全く同じTitle ID、Service Configuration ID (SCID)、およびSandbox(RETAILXDKS.1 など)を共有している必要があります。さらに、プレイヤーは両方のデバイスで同じMicrosoft Account (MSA) にサインインしている必要があります。最後に、セーブシステムによって生成されるコンテナ名が1文字ずつ完全に一致していなければなりません。そうでなければ、クラウド同期システムはそれらを別々のデータセットとして処理します。

さらに、Connected Storageはユーザーごと、タイトルごとに最大256メガバイトのストレージを割り当て、個々のコンテナの制限は16メガバイトです。セーブシステムがこれらのハード制限を超えると、APIは警告なしで(silently)失敗するか、Unreal Engineのデフォルトのセーブシステムが検知しないエラーコードを返します。ファイルサイズを小さく構造化した状態に保つよう、これらの制約を念頭に置いてセーブシステムを設計することが極めて重要です。

根本原因:非同期ログインのRace Condition

Unreal EngineプロジェクトでGDKのSave Roamingが失敗する最も一般的な原因は、ゲームが誰がセーブしているかを判断する方法における微妙なRace Conditionです。Xbox Series Xコンソールでは、オペレーティングシステムがアクティブなコントローラープロファイルを中心に構築されています。ゲームが起動すると、すでにOSレベルでユーザーが選択されています。コンソールは自動的にゲームの実行コンテキストに XUserHandle を割り当て、Unreal Engineはほぼ即座にこのアクティブなプロファイルを UserIndex 0 にマッピングします。

Windows PCでは、このフローはまったく異なります。ゲームはアクティブなプロファイルを求めることなく即座に起動します。XboxアプリまたはWindows Storeのログインフローは、バックグラウンドで非同期に実行されます。ゲームコードが起動直後(例えば、GameInstance::Init 呼び出しやメインメニューの BeginPlay 内など)に LoadGameFromSlotDoesSaveGameExist を呼び出すと、GDKのOnline Subsystemはユーザーのサインインをまだ完了していません。

ユーザーがサインインする前に、Unreal Engineの ISaveGameSystem(特にGDK実装である FGameSaveSystemGDK)が UserIndex = 0 で呼び出されると、ローカルのゲストユーザーへとフォールバックします。するとGDKはこのセッションをPCのローカルオフラインストレージにマッピングし、クラウド同期されたコンテナをバイパスします。数秒後に非同期サインインがようやく完了したときには、ゲームはすでにローカルデータで動作してしまっています。プレイヤーが新しいセーブデータを書き込むと、エンジンはそれをローカルのゲストプロファイルに書き込むか、新しく解決されたMSAコンテナへの書き込みを試みることになり、コンソール上のクラウドプロファイルとPC上のローカルプロファイルが完全に異なるファイルを持つというステートの分裂が発生します。

Unreal EngineにおけるUserIndexからXUserHandleへのマッピング

Unreal Engineのセーブシステムは、現代のプラットフォームアイデンティティシステムが登場するはるか前に設計されました。これは、ローカルの協力プレイでプレイヤーを区別するために単純な整数(UserIndex)を使用しています。GDK実装において、エンジンは内部のGDKサブシステムヘルパーを呼び出すことで、この整数を XUserHandle に解決しようと試みます。プレイヤーインデックスが無効または未初期化のハンドルにマッピングされた場合、セーブ処理は暗黙的に(silently)ローカルディレクトリにフォールバックします。

これを防ぐには、2つのことを確認する必要があります。まず、Online Subsystemのアイデンティティインターフェースがプレイヤーのログイン完了を報告するまで、セーブシステムにアクセスしてはなりません。次に、ローカルプレイヤーコントローラーが常にインデックス 0 であると仮定するのではなく、サインインしているユーザーの FUniqueNetId に関連付けられた正しいプラットフォーム固有の UserIndex を解決する必要があります。このワークフローを保証するシステムを実装してみましょう。

実装ガイド:Race Conditionの解決

Step 1: GDKセーブユーティリティクラス

UBlueprintFunctionLibrary を継承するカスタム UGDKSaveHelper クラスを作成します。このクラスには、正しいユーザーインデックスを取得し、ユーザーがログインしていることを確認するロジックが含まれます。以下がヘッダーファイル(GDKSaveHelper.h)です。

#pragma once

#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "Interfaces/OnlineIdentityInterface.h"
#include "GDKSaveHelper.generated.h"

UCLASS()
class HORIZON_API UGDKSaveHelper : public UBlueprintFunctionLibrary
{
    GENERATED_BODY()

public:
    UFUNCTION(BlueprintCallable, Category = "GDK Save System")
    static int32 GetGDKUserIndex(const APlayerController* PlayerController);

    UFUNCTION(BlueprintPure, Category = "GDK Save System")
    static bool IsPlayerLoggedInGDK(const APlayerController* PlayerController);
};

そして、以下がOnline Subsystemを使用してローカルプレイヤーのネットワークIDをプラットフォーム固有のユーザーインデックスに安全にマッピングする実装ファイル(GDKSaveHelper.cpp)です。

#include "GDKSaveHelper.h"
#include "GameFramework/PlayerController.h"
#include "OnlineSubsystem.h"
#include "OnlineSubsystemUtils.h"

int32 UGDKSaveHelper::GetGDKUserIndex(const APlayerController* PlayerController)
{
    if (!PlayerController)
    {
        UE_LOG(LogTemp, Warning, TEXT("GetGDKUserIndex: PlayerController is null."));
        return -1;
    }

    IOnlineSubsystem* OnlineSub = IOnlineSubsystem::Get();
    if (!OnlineSub)
    {
        UE_LOG(LogTemp, Warning, TEXT("GetGDKUserIndex: OnlineSubsystem is null."));
        return -1;
    }

    IOnlineIdentityPtr IdentityInterface = OnlineSub->GetIdentityInterface();
    if (!IdentityInterface.IsValid())
    {
        UE_LOG(LogTemp, Warning, TEXT("GetGDKUserIndex: IdentityInterface is invalid."));
        return -1;
    }

    ULocalPlayer* LocalPlayer = PlayerController->GetLocalPlayer();
    if (!LocalPlayer)
    {
        UE_LOG(LogTemp, Warning, TEXT("GetGDKUserIndex: LocalPlayer is null."));
        return -1;
    }

    FUniqueNetIdRepl UniqueId = LocalPlayer->GetPreferredUniqueNetId();
    if (!UniqueId.IsValid() || !UniqueId.GetUniqueNetId()->IsValid())
    {
        UE_LOG(LogTemp, Warning, TEXT("GetGDKUserIndex: Unique ID is not valid yet."));
        return -1;
    }

    int32 PlatformUserIndex = IdentityInterface->GetPlatformUserIndexFromUniqueNetId(*UniqueId.GetUniqueNetId());
    
    if (PlatformUserIndex == PLATFORMUSERID_NONE)
    {
        UE_LOG(LogTemp, Warning, TEXT("GetGDKUserIndex: PlatformUserIndex mapped to PLATFORMUSERID_NONE."));
        return -1;
    }

    UE_LOG(LogTemp, Log, TEXT("GetGDKUserIndex: Successfully resolved UserIndex to %d"), PlatformUserIndex);
    return PlatformUserIndex;
}

bool UGDKSaveHelper::IsPlayerLoggedInGDK(const APlayerController* PlayerController)
{
    if (!PlayerController)
    {
        return false;
    }

    IOnlineSubsystem* OnlineSub = IOnlineSubsystem::Get();
    if (OnlineSub)
    {
        IOnlineIdentityPtr IdentityInterface = OnlineSub->GetIdentityInterface();
        if (IdentityInterface.IsValid())
        {
            ULocalPlayer* LocalPlayer = PlayerController->GetLocalPlayer();
            if (LocalPlayer)
            {
                FUniqueNetIdRepl UniqueId = LocalPlayer->GetPreferredUniqueNetId();
                if (UniqueId.IsValid() && UniqueId.GetUniqueNetId()->IsValid())
                { 
                    ELoginStatus::Type LoginStatus = IdentityInterface->GetLoginStatus(*UniqueId.GetUniqueNetId());
                    return LoginStatus == ELoginStatus::LoggedIn;
                }
            }
        }
    }
    return false;
}

Step 2: セーブおよびロードロジックの変更

BlueprintsまたはC++のゲームステート管理において、ロード呼び出しをラップする必要があります。起動時に即座に LoadGameFromSlot を呼び出すのではなく、サインインを待機するチェックを実装してください。Online Identityサブシステムからの On Login Complete イベントにバインドします。イベントがトリガーされたら、ユーザーが完全にログインしているかどうかを確認し、解決されたインデックスを照会して、それをセーブゲームノードに直接渡します。

サインインが失敗するか、プレイヤーがそれをキャンセルした場合は、ゲームを「ゲスト」状態として処理する必要があります。プレイヤーに対して、進行状況がクラウドに保存されず、現在のマシンにローカルで保存されたままになることを警告します。これにより、プレイヤーは常に自身のセーブ状況を把握できるようになり、デバイスを切り替える際の予期せぬ進行状況の消失を防ぐことができます。

Step 3: 設定ファイルの整合

コードが正しいユーザーインデックスを解決したとしても、Xbox GDKおよびPC GDKパッケージの設定ファイルが一致していないと、Save Roamingは失敗します。DefaultEngine.ini を開き、GDKのOnline Subsystem設定が正しいことを確認してください。Title IDおよびService Configuration ID (SCID) は、Microsoft Partner Centerの登録情報と正確に一致している必要があります。

[OnlineSubsystemGDK]
bEnabled=true
TitleId=1234ABCD
ServiceConfigId=12345678-1234-abcd-1234-1234567890ab
bEnableConfigService=true

[OnlineSubsystem]
DefaultPlatformService=GDK

アプリケーションをパッケージングする MicrosoftGame.config において、IdentityおよびTitle IDがPartner Centerのダッシュボード内の値と正確に一致していることを確認します。

<GameConfig xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <Identity Name="YourPublisher.YourGameName"
            Publisher="CN=12345678-1234-abcd-1234-1234567890ab"
            Version="1.0.0.0"/>
  
  <TitleId>1234ABCD</TitleId>
  <MSAAppId>0000000012345678</MSAAppId>
  
  <DevelopmentServer>RETAIL</DevelopmentServer>
</GameConfig>

Sandbox(例: XDKS.1)でテストする際、テストアカウントがそのSandboxに追加されており、PC上のXboxアプリに適切にサインインしている必要があることに注意してください。XboxアプリがWindows Storeとは異なるアカウントでサインインしている場合、WindowsのGDK APIは XGameSave プロバイダーの初期化に失敗し、ローカルディレクトリの不一致につながります。これは、ローカルのAppDataフォルダをチェックして、セーブファイルがどこに書き込まれているかを確認することで検証できます。

代替案:horizOn によるプラットフォームに依存しないクラウドセーブ

MicrosoftのGDK Connected Storage APIを設定し、オフラインのゲストフォールバックを処理し、PCとコンソール環境間で同一のSandboxを維持することは、セットアップとデバッグに数週間かかる場合があります。また、正常に動作したとしても、Microsoftのエコシステムにロックインされてしまいます。これらのセーブデータがSteam、Epic Games Store、PlayStation、Nintendo Switchに同期されることはありません。

プラットフォーム固有のラッパーを作成したり、リアルタイムのBackendのために Unreal Engine WebSockets tutorial のような複雑なリアルタイムセットアップに頼ったりする代わりに、セーブデータの状態アーキテクチャを完全に horizOn のようなプラットフォームに依存しないBackendサービスへとオフロードすることができます。horizOn を使用すると、セーブデータの状態管理はプラットフォーム固有のAPIから切り離されます。プレイヤーは、プラットフォームのアイデンティティ(Steam、Epic、Xbox Liveなど)を使用して認証され、データは統合されたクラウドデータベースに保存されます。

これにより、XboxのSandbox不一致の問題が完全に回避され、Steam、Xbox、PlayStation間での真のクロスプラットフォームにおけるSave Roamingが可能になります。以下は、Unreal Engine C++において、このサービスのREST APIを使用してプレイヤーの進行状況をグローバルに保存する概念的な例です。

#include "HttpModule.h"
#include "Interfaces/IHttpRequest.h"
#include "Interfaces/IHttpResponse.h"
#include "Dom/JsonObject.h"
#include "Serialization/JsonWriter.h"
#include "Serialization/JsonSerializer.h"

void SaveToHorizonCloud(const FString& PlayerToken, const FString& SaveDataJson)
{
    TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = FHttpModule::Get().CreateRequest();
    Request->SetURL(TEXT("https://api.horizon.pm/v1/save-state"));
    Request->SetVerb(TEXT("POST"));
    Request->SetHeader(TEXT("Content-Type"), TEXT("application/json"));
    Request->SetHeader(TEXT("Authorization"), FString::Printf(TEXT("Bearer %s"), *PlayerToken));
    
    TSharedPtr<FJsonObject> Payload = MakeShareable(new FJsonObject());
    Payload->SetStringField(TEXT("slot_name"), TEXT("SaveSlot1"));
    Payload->SetStringField(TEXT("data"), SaveDataJson);
    
    FString OutputString;
    TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&OutputString);
    FJsonSerializer::Serialize(Payload.ToSharedRef(), Writer);
    
    Request->SetContentAsString(OutputString);
    Request->OnProcessRequestComplete().BindLambda([](FHttpRequestPtr Req, FHttpResponsePtr Res, bool bWasSuccessful)
    {
        if (bWasSuccessful && Res->GetResponseCode() == 200)
        {
            UE_LOG(LogTemp, Log, TEXT("Save state synchronized successfully."));
        }
        else
        {
            UE_LOG(LogTemp, Error, TEXT("Failed to sync save state to cloud."));
        }
    });
    
    Request->ProcessRequest();
}

堅牢なクラウドセーブシステムをご自身で構築するには、グローバルデータベースのセットアップ、安全なセッションTokenの管理、オフラインでの同期競合の処理などが必要になり、開発者の専任期間として容易に4〜6週間を要します。horizOn を使用すれば、これらのBackendサービスは事前設定済みであるため、データベースのインフラを構築する代わりにゲームのリリースに集中できます。これにより、マルチプラットフォームの設定で頭を抱える手間が、わずか1つのAPI呼び出しへと簡略化されます。

クロスプラットフォームセーブアーキテクチャのベストプラクティス

MicrosoftのGDK Connected Storageの設定に苦戦する道を選ぶか、それともBackendサービスによって抽象化する道を選ぶかにかかわらず、以下のコアとなるベストプラクティスを遵守する必要があります。

  1. Tickやフレーム終了時にセーブしない: Connected Storageはディスクの劣化防止やクラウド同期のThrottling(スロットリング)を防ぐために、書き込み操作を制限しています。セーブ処理は特定のトリガーポイント(チェックポイント、レベル遷移、メニュー終了時など)にバッチ化してください。
  2. Save Conflict Resolver(セーブ競合解決機構)の実装: PC側のセーブのタイムスタンプが14:00で、Xbox側のセーブのタイムスタンプが14:05である場合、古いセーブを盲目的に上書きしてはいけません。プレイ時間、レベルの進行状況、メタデータを示すUIプロンプトを表示し、プレイヤーにアクティブなステートを選択させてください。
  3. セーブファイルの完全性検証: 書き込みを行う前に、セーブデータをシリアライズしてMD5またはSHA-256のハッシュを生成します。クラウド転送中にファイルが破損していないかを検証するために、このハッシュを別のメタデータブロック内に保存してください。
  4. サインアウトの適切な処理: GDKでは、ゲームの実行中にプレイヤーがXboxダッシュボードからサインアウトできます。OnLoginStatusChanged デリゲートに登録し、アクティブなユーザーが変更された場合は即座にゲームを一時停止してください。

GDKの設定による頭痛の種なしに、スケーラブルでプラットフォームに依存しないクラウドセーブシステムを実装する準備ができているなら、今日からでも始められます。詳細については、horizOn を無料でお試しいただくか、API docs をご確認ください。


Source: Xbox/GDK XPA cross platform save roaming not working

このダッシュボードは以下のチームによって愛情を込めて作られています Projectmakers

© 2026 projectmakers.de

unknown-v1.99.2 / unknown-v--