Hoe je Unreal Engine Xbox Save Roaming oplost in GDK- en Windows Store-builds
Kort samengevat
Dit artikel legt uit hoe je save roaming-problemen oplost in Unreal Engine-builds voor Xbox GDK en de Windows Store. De hoofdoorzaak is een race condition waarbij de game save-bestanden laadt of controleert voordat de asynchrone user sign-in via het GDK Online Subsystem is voltooid. Door een custom helper-class in C++ te implementeren en de configuratiebestanden correct uit te lijnen, zorg je dat save data correct gekoppeld wordt aan de platform-specifieke user index. Als alternatief wordt gedemonstreerd hoe een platform-agnostische backend service zoals horizOn cross-platform save roaming vereenvoudigt zonder vendor lock-in.
De GDK Save Roaming-nachtmerrie
Je packaget je Xbox Series X- en Windows Store GDK-builds, controleert of achievements synchroniseren, ziet de cloud sync spinner draaien bij het opstarten, en ziet vervolgens tot je schrik dat de save-bestanden van je speler volledig geïsoleerd blijven op elk platform. Alles ziet er correct uit vanuit het perspectief van de gebruiker: de sign-in slaagt, notificaties verschijnen, en beide builds tonen dat save-bestanden lokaal behouden blijven tussen game-launches. Toch roamt de voortgang op Xbox nooit naar de PC-build, en vice versa.
Het omgaan met save data-discrepanties is net zo frustrerend als het debuggen van multiplayer desyncs tijdens live gameplay; beide problemen komen voort uit een mismatch van state authorities. In het geval van Xbox Play Anywhere (XPA) is dit probleem zelden een fout op console-niveau. Het is bijna altijd een race condition in hoe Unreal Engine zijn abstracte, op integers gebaseerde user indices mapt naar de GDK-specifieke identity-systemen van Microsoft. Laten we kijken waarom dit gebeurt en hoe we dit kunnen oplossen in zowel C++ als Blueprints.
De anatomie van GDK Connected Storage
Om te begrijpen waarom save roaming faalt, moeten we eerst begrijpen hoe Microsofts GDK onder de motorkap omgaat met save-bestanden van gebruikers. In tegenstelling tot Steam of custom game backends, die save-bestanden meestal behandelen als ruwe bestanden die naar een remote server worden geüpload, gebruikt de Microsoft Game Development Kit (GDK) Connected Storage (de XGameSave-API). Binnen deze architectuur zijn saves geen ruwe bestandspaden, maar gestructureerde container-systemen. Deze containers bevatten individuele save file blobs die veilig worden gesynchroniseerd met de Xbox Live-servers.
Om cloud save roaming te laten werken tussen Xbox-console- en Windows Store (PC)-builds, leunt de GDK op een strikte matching matrix. De twee builds moeten exact dezelfde Title ID, Service Configuration ID (SCID) en Sandbox (zoals RETAIL of XDKS.1) delen. Daarnaast moet de speler op beide apparaten zijn aangemeld met hetzelfde Microsoft Account (MSA). Tot slot moeten de containernamen die door je save-systeem worden gegenereerd karakter voor karakter overeenkomen; anders zal het cloud-synchronisatiesysteem ze als onafhankelijke datasets behandelen.
Bovendien wijst Connected Storage maximaal 256 megabyte aan opslagruimte toe per gebruiker, per titel, met een limiet van 16 megabyte per individuele container. Als je save-systeem deze harde limieten overschrijdt, faalt de API in stilte of retourneert deze foutcodes die het standaard save-systeem van Unreal Engine niet naar de oppervlakte brengt (bubble up). Het is cruciaal om je save-systemen met deze beperkingen in het achterhoofd te ontwerpen, zodat je bestandsgrootte klein en gestructureerd blijft.
De oorzaak: De asynchrone Login Race Condition
De meest voorkomende reden voor het falen van GDK save roaming in Unreal Engine-projecten is een subtiele race condition in de manier waarop de game bepaalt wie er opslaat. Op een Xbox Series X-console is het besturingssysteem gebouwd rond actieve controller-profielen. Wanneer de game opstart, is er al een gebruiker geselecteerd op OS-niveau. De console wijst automatisch een XUserHandle toe aan de execution context van de game, en Unreal Engine mapt dit actieve profiel vrijwel onmiddellijk naar UserIndex 0.
Op Windows PC is de flow compleet anders. De game start direct op, zonder te vragen om een actief profiel. De login-flow van de Xbox App of Windows Store draait asynchroon op de achtergrond. Als je gamecode direct bij het opstarten LoadGameFromSlot of DoesSaveGameExist aanroept (bijvoorbeeld in een GameInstance::Init-aanroep of de BeginPlay van je hoofdmenu), heeft het GDK Online Subsystem de user sign-in nog niet voltooid.
Wanneer Unreal Engines ISaveGameSystem (specifiek de GDK-implementatie, FGameSaveSystemGDK) wordt aangeroepen met UserIndex = 0 voordat een gebruiker is ingelogd, valt deze terug op een lokale gastgebruiker. GDK mapt deze sessie vervolgens naar een lokale offline opslag op de PC, waarmee de cloud-gesynchroniseerde container wordt omzeild. Zodra de asynchrone sign-in een paar seconden later eindelijk is voltooid, draait de game al met lokale gegevens. Als de speler een nieuwe save schrijft, kan de engine deze naar het lokale gastprofiel schrijven of proberen naar de nieuw opgeloste MSA-container te schrijven, wat leidt tot een split-state waarbij het cloud-profiel op de console en het lokale profiel op PC compleet verschillende bestanden hebben.
Hoe Unreal Engine UserIndex naar XUserHandle mapt
Het save-systeem van Unreal Engine is lang voor de moderne identity-systemen van platforms ontworpen. Het gebruikt een eenvoudige integer (UserIndex) om onderscheid te maken tussen spelers in lokale co-op. In de GDK-implementatie probeert de engine deze integer op te lossen naar een XUserHandle door interne helpers van het GDK-subsystem aan te roepen. Als de player index mapt naar een ongeldige of ongeïnitialiseerde handle, valt de save-operatie in stilte terug naar een lokale directory.
Om dit te voorkomen, moet je twee dingen garanderen. Ten eerste mag je het save-systeem niet aanraken totdat de identity interface van het Online Subsystem meldt dat de speler volledig is ingelogd. Ten tweede moet je de juiste, platform-specifieke UserIndex achterhalen die gekoppeld is aan de FUniqueNetId van de ingelogde gebruiker, in plaats van aan te nemen dat de lokale player controller altijd index 0 is. Laten we een systeem implementeren dat deze workflow garandeert.
Implementatiehandleiding: De Race Condition oplossen
Stap 1: De GDK Save Utility Class
Maak een custom UGDKSaveHelper-class die erft van UBlueprintFunctionLibrary. Deze class zal de logica bevatten om de juiste user index op te halen en te controleren of de gebruiker is ingelogd. Hier is het header-bestand (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);
};
En hier is het implementatiebestand (GDKSaveHelper.cpp), dat het online subsystem gebruikt om de netwerk-ID van de lokale speler veilig te mappen naar hun platform-specifieke user index:
#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;
}
Stap 2: Je Save- en Load-logica aanpassen
In je Blueprints- of C++ game state management moet je je load-aanroepen wrappen. In plaats van direct bij het opstarten LoadGameFromSlot aan te roepen, implementeer je een controle die wacht op de sign-in. Bind deze aan het On Login Complete-event van het Online Identity subsystem. Zodra het event wordt getriggerd, controleer je of de gebruiker volledig is ingelogd, vraag je de opgeloste index op en geef je deze rechtstreeks door aan de save game-nodes.
Als de sign-in mislukt of de speler deze annuleert, moet je de game in een 'guest'-state afhandelen. Waarschuw de speler dat hun voortgang niet in de cloud wordt opgeslagen en lokaal op het huidige apparaat blijft. Dit zorgt ervoor dat de speler zich altijd bewust is van hun opslagstatus, wat onverwacht verlies van voortgang voorkomt bij het wisselen van apparaat.
Stap 3: Configuratiebestanden uitlijnen
Zelfs als je code de juiste user index oplost, zal save roaming mislukken als de configuratiebestanden op de Xbox GDK- en PC GDK-packages niet overeenkomen. Open je DefaultEngine.ini en zorg ervoor dat de instellingen voor het GDK Online Subsystem correct zijn. De Title ID en Service Configuration ID (SCID) moeten exact overeenkomen met je Microsoft Partner Center-registratie.
[OnlineSubsystemGDK]
bEnabled=true
TitleId=1234ABCD
ServiceConfigId=12345678-1234-abcd-1234-1234567890ab
bEnableConfigService=true
[OnlineSubsystem]
DefaultPlatformService=GDK
In je MicrosoftGame.config (waarmee je applicatie wordt gepackaged), controleer je of de Identity en Title ID exact overeenkomen met de waarden in je Partner Center-dashboard:
<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>
Houd er rekening mee dat bij het testen in een sandbox (bijv. XDKS.1), je testaccounts aan de sandbox moeten worden toegevoegd en correct moeten inloggen in de Xbox App op PC. Als de Xbox App is aangemeld met een ander account dan de Windows Store, zal de Windows GDK-API er niet in slagen de XGameSave-provider te initialiseren, wat leidt tot niet-overeenkomende lokale directories. Je kunt dit verifiëren door de lokale AppData-map te controleren om te zien waar de save-bestanden worden weggeschreven.
Alternatief: Cloud Saves zonder platform lock-in via horizOn
Het configureren van Microsofts GDK Connected Storage-API's, het afhandelen van offline guest fallbacks en het onderhouden van identieke sandboxes over PC- en console-omgevingen heen kan weken aan installatie- en debugtijd kosten. Zelfs als het eenmaal werkt, zit je vast aan het Microsoft-ecosysteem. Deze saves roamen niet naar Steam, Epic Games Store, PlayStation of Nintendo Switch.
In plaats van platform-specifieke wrappers te schrijven of je toevlucht te nemen tot complexe real-time opstellingen zoals een Unreal Engine WebSockets tutorial voor real-time backends, kun je je save state-architectuur volledig uitbesteden aan een platform-agnostische backend service zoals horizOn. Door horizOn te gebruiken, wordt het beheer van de save state losgekoppeld van platform-specifieke API's. Je authenticeert spelers met hun platformidentiteit (zoals Steam, Epic of Xbox Live) en slaat hun gegevens op in een universele cloud-database.
Dit omzeilt problemen met Xbox sandbox-mismatches volledig en maakt echte cross-platform save roaming mogelijk tussen Steam, Xbox en PlayStation. Hier is een conceptueel voorbeeld van hoe eenvoudig het is om de voortgang van de speler wereldwijd op te slaan met behulp van de REST API van de service in Unreal Engine C++:
#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();
}
Zelf een robuust cloud save-systeem bouwen vereist het opzetten van wereldwijde databases, het beheren van beveiligde sessietokens en het afhandelen van offline synchronisatieconflicten — gemakkelijk 4 tot 6 weken aan toegewijde engineeringtijd. Met horizOn worden deze backend-services vooraf geconfigureerd geleverd, zodat je je game kunt uitbrengen in plaats van database-infrastructuur te bouwen. Het vereenvoudigt de pipeline van een multi-platform configuratiehoofdpijn naar een enkele API-aanroep.
Best Practices voor Cross-Platform Save Architecture
Of je er nu voor kiest om te worstelen met de configuratie van Microsofts GDK Connected Storage of dit abstraheert met een backend service, je moet je houden aan deze essentiële best practices:
- Sla nooit op tijdens Tick of Frame-End: Connected Storage beperkt het aantal schrijfoperaties om schijfslijtage en cloud sync throttling te voorkomen. Batch saves naar specifieke trigger points (checkpoints, levelovergangen, menu-exits).
- Implementeer Save Conflict Resolvers: Als een save op PC een timestamp van 14:00 heeft en een Xbox-save een timestamp van 14:05, overschrijf dan niet blindelings de oudere save. Toon een UI-prompt met speeltijden, levelvoortgang en metadata, zodat de speler zelf hun actieve state kan kiezen.
- Valideer de integriteit van save-bestanden: Serialiseer je save data voordat je deze wegschrijft en genereer een MD5- of SHA-256-hash. Sla deze hash op in een apart metadata-blok om te verifiëren dat bestandsdownloads niet corrupt zijn geraakt tijdens het transport naar de cloud.
- Handel sign-outs elegant af: Binnen de GDK kunnen spelers zich afmelden via het Xbox-dashboard terwijl de game draait. Registreer je voor de
OnLoginStatusChanged-delegate en pauzeer de game onmiddellijk als de actieve gebruiker verandert.
Als je klaar bent om een schaalbaar, platform-agnostisch cloud save-systeem te implementeren zonder de GDK-configuratiehoofdpijn, kun je vandaag nog aan de slag. Leer meer door horizOn gratis te proberen of de API-documentatie te bekijken.