返回博客

如何修复 GDK 和 Windows Store 构建版本中 Unreal Engine Xbox 的 Save Roaming 问题

发布于 2026年7月7日
如何修复 GDK 和 Windows Store 构建版本中 Unreal Engine Xbox 的 Save Roaming 问题

概要

本文探讨了在 Unreal Engine 项目中使用 GDK 打包 Xbox 和 Windows Store 构建版本时,云存档漫游(Save Roaming)失效的常见 race condition 问题。分析指出该问题的根源在于 PC 端异步登录延迟导致存档系统错误地映射了本地 guest 用户索引。文章提供了在 C++ 和 Blueprints 中利用 Online Subsystem 接口解析正确用户索引的解决方案与配置指南,并探讨了使用 horizOn 这一跨平台 backend 服务作为简化方案的优势。

GDK Save Roaming 的噩梦

当你打包好 Xbox Series X 和 Windows Store 的 GDK 构建版本,确认了成就系统正常同步,并在启动游戏时看到云同步的加载动画旋转时,却惊恐地发现玩家的存档文件在各个平台上依然处于完全孤立的状态。从用户的角度来看,一切似乎都很正常:登录成功、通知弹出,而且两个构建版本在游戏启动之间都能在本地保存存档。然而,Xbox 上的游戏进度却永远无法漫游到 PC 版本,反之亦然。

处理存档数据不一致的问题,与在游戏实机运行期间排查 multiplayer desyncs 同样令人抓狂;这两个问题都源于状态主控权(state authorities)的不匹配。在 Xbox Play Anywhere (XPA) 的情况下,这个问题很少是主机层面的故障。它几乎总是源于一个 race condition——即 Unreal Engine 在将基于整数的抽象用户索引映射到 Microsoft 特定的 GDK 身份验证系统时发生了冲突。让我们来看看为什么会发生这种情况,以及如何在 C++ 和 Blueprints 中进行修复。

GDK Connected Storage 的底层剖析

要理解为什么 save roaming 会失败,我们首先必须了解 Microsoft GDK 在底层是如何处理用户存档文件的。与 Steam 或自定义游戏 backend(它们通常将存档文件视为上传到远程服务器的原始文件)不同,Microsoft Game Development Kit (GDK) 使用了 Connected Storage(即 XGameSave API)。在这种架构下,存档并不是原始的文件路径,而是结构化的容器系统。这些容器包含着与 Xbox Live 服务器安全同步的单个存档文件 blob。

为了在 Xbox 主机和 Windows Store (PC) 构建版本之间实现云端 save roaming,GDK 依赖于严格的匹配矩阵。这两个构建版本必须共享完全相同的 Title ID、Service Configuration ID (SCID) 以及 Sandbox(例如 RETAILXDKS.1)。此外,玩家在两台设备上必须登录同一个 Microsoft Account (MSA)。最后,您的存档系统生成的容器名称必须字符级一致;否则,云同步系统会将其视为独立的数据集。

此外,Connected Storage 为每个用户、每个 title 分配最多 256 MB 的存储空间,单个容器的限制为 16 MB。如果您的存档系统超出了这些硬性限制,API 将会静默失败,或者返回 Unreal Engine 默认存档系统不会向上抛出的错误代码。在设计存档系统时,务必牢记这些限制,确保您的文件大小保持精简且结构化。

根本原因:异步登录的 Race Condition

在 Unreal Engine 项目中,导致 GDK save roaming 失败最常见的原因是:游戏在判定“谁在进行存档”时存在一个微妙的 race condition。在 Xbox Series X 主机上,操作系统是围绕活跃的控制器配置文件(active controller profiles)构建的。当游戏启动时,系统已经在操作系统层面选择了一个用户。主机会自动向游戏的执行上下文分配一个 XUserHandle,并且 Unreal Engine 几乎立即将该活跃配置文件映射到 UserIndex 0

而在 Windows PC 上,流程完全不同。游戏启动时非常迅速,不会提示选择活跃的配置文件。Xbox App 或 Windows Store 的登录流程在后台异步运行。如果您的游戏代码在启动时立即调用 LoadGameFromSlotDoesSaveGameExist(例如在 GameInstance::Init 调用或主菜单的 BeginPlay 中),此时 GDK Online Subsystem 尚未完成用户登录。

当在用户登录之前使用 UserIndex = 0 调用 Unreal Engine 的 ISaveGameSystem(具体为 GDK 的实现 FGameSaveSystemGDK)时,它会回退到本地的 guest 用户。随后,GDK 会将该会话映射到 PC 上的本地离线存储,绕过云同步的容器。一旦几秒钟后异步登录终于完成,游戏已经在使用本地数据运行了。如果玩家写入新的存档,引擎可能会将其写入本地 guest 配置文件,或者尝试写入新解析的 MSA 容器,从而导致状态分裂——即主机上的云端配置文件和 PC 上的本地配置文件拥有完全不同的文件。

Unreal Engine 如何将 UserIndex 映射到 XUserHandle

Unreal Engine 的存档系统设计远早于现代的平台身份识别系统。它使用一个简单的整数 (UserIndex) 来区分本地合作模式(local co-op)中的玩家。在 GDK 的实现中,引擎尝试通过调用内部 GDK 子系统辅助函数来将此整数解析为 XUserHandle。如果玩家索引映射到无效或未初始化的 handle,存档操作将静默回退到本地目录。

为了防止这种情况,您必须确保两点。首先,在 Online Subsystem 身份接口报告玩家已完全登录之前,绝对不能触碰存档系统。其次,您必须解析与已登录用户的 FUniqueNetId 关联的、特定于平台的正确 UserIndex,而不是盲目假定本地 player controller 始终是索引 0。让我们来实现一个能够保障该工作流的系统。

实现指南:解决 Race Condition

Step 1: The GDK Save Utility Class

创建一个继承自 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);
};

以下是实现文件 (GDKSaveHelper.cpp),它使用 online subsystem 安全地将本地玩家的网络 ID 映射到他们特定于平台的用户索引:

#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: Modifying Your Save and Load Logic

在您的 Blueprints 或 C++ 游戏状态管理中,您必须包装加载调用。不要在启动时立即调用 LoadGameFromSlot,而是实现一个等待登录的检查。绑定到 Online Identity 子系统的 On Login Complete 事件。一旦事件触发,检查用户是否已完全登录,查询解析出的索引,并将其直接传递给存档游戏节点。

如果登录失败或玩家取消登录,您必须在“游客(guest)”状态下处理游戏。警告玩家他们的进度不会保存到云端,而会保留在当前机器本地。这能确保玩家始终知晓其存档状态,防止在切换设备时发生意外的进度丢失。

Step 3: Aligning Configuration Files

即使您的代码解析出了正确的用户索引,如果 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 App 内正常登录。如果 Xbox App 登录的账号与 Windows Store 登录的账号不同,Windows GDK API 将无法初始化 XGameSave 提供程序,从而导致本地目录不匹配。您可以通过检查本地 AppData 文件夹来查看存档文件的写入位置,以此进行验证。

替代方案:通过 horizOn 实现无平台绑定的云存档

配置 Microsoft 的 GDK Connected Storage API、处理离线 guest 回退,以及在 PC 和主机环境之间维护完全相同的 sandbox,可能会花费数周的时间进行设置和调试。即使搞定了,你也会被锁定在 Microsoft 生态系统中。这些存档将无法漫游到 Steam、Epic Games Store、PlayStation 或 Nintendo Switch。

与其编写特定于平台的 wrapper,或者求助于像 Unreal Engine WebSockets 教程 这样针对实时 backend 的复杂实时设置,不如将您的存档状态架构完全卸载到像 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 服务已经预先配置好了,让您可以专注于发布游戏,而不是构建数据库基础设施。它将繁琐的多平台配置难题简化为了一次简单的 API 调用。

跨平台存档架构的最佳实践

无论您是选择硬啃 Microsoft 的 GDK Connected Storage 配置,还是使用 backend 服务将其抽象化,都应遵循以下核心最佳实践:

  1. 切勿在 Tick 或帧末尾进行存档 (Never Save on Tick or Frame-End):Connected Storage 限制了写入操作,以防止磁盘老化和云同步限流。应将存档批量处理并绑定到特定的触发点(如存档点/Checkpoint、关卡切换、退出菜单)。
  2. 实现存档冲突解决机制 (Implement Save Conflict Resolvers):如果 PC 上的存档时间戳为 14:00,而 Xbox 上的存档时间戳为 14:05,请勿盲目覆盖较旧的存档。展示一个 UI 弹窗,显示游玩时间、关卡进度 and 元数据,让玩家自行选择其活跃状态。
  3. 验证存档文件完整性 (Validate Save File Integrity):在写入之前,对您的存档数据进行序列化并生成 MD5 或 SHA-256 哈希值。将此哈希值保存在单独的元数据块中,以验证文件下载在云端传输过程中未被损坏。
  4. 优雅地处理登出 (Gracefully Handle Sign-Outs):在 GDK 中,玩家可以在游戏运行期间通过 Xbox 仪表板登出。请注册 OnLoginStatusChanged 委托,并在活跃用户发生更改时立即暂停游戏。

如果您已准备好实现一个可扩展、与平台无关的云存档系统,且免受 GDK 配置之苦,今天就可以开始行动。您可以通过免费试用 horizOn 或查阅 API docs 来了解更多信息。


来源:Xbox/GDK XPA cross platform save roaming not working