Wie man Unreal Engine Xbox Save Roaming in GDK- und Windows Store-Builds behebt
Kurz und knapp
Dieser Guide beschreibt, wie man Synchronisationsprobleme beim Xbox Save Roaming in Unreal Engine GDK- und Windows Store-Builds löst. Die Hauptursache liegt in einer asynchronen Race Condition beim Login-Flow auf dem PC, wodurch Savegames oft fälschlicherweise lokal statt in der Cloud gespeichert werden. Der Artikel bietet eine C++-Implementierung zur korrekten Zuordnung des UserIndex zur Network-ID des Spielers sowie Best Practices für plattformübergreifende Speicher-Architekturen.
Der GDK-Save-Roaming-Albtraum
Sie packagen Ihre GDK-Builds für Xbox Series X und den Windows Store, verifizieren das Syncing der Achievements, sehen den Cloud-Sync-Spinner beim Booten rotieren und müssen dann entsetzt feststellen, dass die Savegames Ihrer Spieler auf der jeweiligen Plattform komplett isoliert bleiben. Aus User-Perspektive sieht alles korrekt aus: Das Sign-in ist erfolgreich, Benachrichtigungen triggern und beide Builds zeigen an, dass Savegames lokal über Spielstarts hinweg persistieren. Dennoch wird der Fortschritt auf der Xbox niemals auf den PC-Build übertragen und umgekehrt.
Die Fehlerbehebung bei Save-Data-Diskrepanzen ist genauso frustrierend wie das Troubleshooting von multiplayer desyncs während des Live-Gameplays; beide Probleme resultieren aus abweichenden State Authorities. Im Fall von Xbox Play Anywhere (XPA) liegt die Ursache selten an einem Fehler auf Konsolenebene. Es handelt sich fast immer um eine Race Condition bei der Zuweisung von Unreal Engines abstrakten, Integer-basierten User-Indizes zu den GDK-spezifischen Identity-Systemen von Microsoft. Sehen wir uns an, warum das passiert und wie wir es sowohl in C++ als auch in Blueprints beheben.
Die Anatomie von GDK Connected Storage
Um zu verstehen, warum Save Roaming fehlschlägt, müssen wir zuerst begreifen, wie das GDK von Microsoft Savegames unter der Haube verwaltet. Im Gegensatz zu Steam oder Custom-Game-Backends, die Save-Files typischerweise als Raw-Dateien behandeln, die auf einen Remote-Server hochgeladen werden, nutzt das Microsoft Game Development Kit (GDK) Connected Storage (die XGameSave-API). Bei dieser Architektur sind Spielstände keine einfachen Dateipfade, sondern strukturierte Container-Systeme. Diese Container enthalten einzelne Save-File-Blobs, die sicher mit den Xbox Live-Servern synchronisiert werden.
Damit das Cloud Save Roaming zwischen Xbox-Konsole und Windows Store-Builds (PC) funktioniert, setzt das GDK eine strikte Matching-Matrix voraus. Beide Builds müssen exakt dieselbe Title ID, Service Configuration ID (SCID) und Sandbox (wie RETAIL oder XDKS.1) teilen. Zudem muss der Spieler auf beiden Geräten im selben Microsoft-Konto (MSA) angemeldet sein. Zuletzt müssen die von Ihrem Save-System generierten Containernamen Zeichen für Zeichen übereinstimmen; andernfalls behandelt das Cloud-Syncing-System sie als unabhängige Datensätze.
Darüber hinaus weist Connected Storage maximal 256 Megabyte Speicherplatz pro User und Titel zu, bei einem Limit von 16 Megabyte pro einzelnem Container. Wenn Ihr Save-System diese Hard Limits überschreitet, schlägt die API stillschweigend fehl oder gibt Error-Codes zurück, die das Standard-Save-System der Unreal Engine nicht nach oben durchreicht (bubble up). Es ist entscheidend, Ihr Save-System unter Berücksichtigung dieser Einschränkungen zu entwerfen und sicherzustellen, dass Ihre Dateigrößen gering und gut strukturiert bleiben.
Die Ursache: Die asynchrone Login-Race-Condition
Die häufigste Ursache für das Scheitern von GDK Save Roaming in Unreal Engine-Projekten ist eine subtile Race Condition bei der Bestimmung, welcher User gerade speichert. Auf einer Xbox Series X-Konsole ist das Betriebssystem um aktive Controller-Profile herum aufgebaut. Beim Booten des Spiels ist bereits ein User auf OS-Ebene ausgewählt. Die Konsole weist dem Execution Context des Spiels automatisch ein XUserHandle zu, und die Unreal Engine verknüpft dieses aktive Profil fast sofort mit dem UserIndex 0.
Auf Windows-PCs läuft dieser Flow völlig anders ab. Das Spiel bootet sofort, ohne nach einem aktiven Profil zu fragen. Der Login-Flow der Xbox App oder des Windows Store läuft asynchron im Hintergrund. Wenn Ihr Game-Code sofort beim Booten (z. B. in einem GameInstance::Init-Aufruf oder im BeginPlay Ihres Hauptmenüs) LoadGameFromSlot oder DoesSaveGameExist aufruft, hat das GDK Online Subsystem das User-Sign-in noch nicht abgeschlossen.
Wenn das ISaveGameSystem der Unreal Engine (speziell die GDK-Implementierung FGameSaveSystemGDK) mit UserIndex = 0 aufgerufen wird, bevor ein User angemeldet ist, fällt es auf einen lokalen Gast-User zurück. Das GDK leitet diese Session dann in den lokalen Offline-Speicher des PCs um und umgeht den Cloud-synchronisierten Container. Sobald das asynchrone Sign-in einige Sekunden später abgeschlossen ist, läuft das Spiel bereits mit lokalen Daten. Schreibt der Spieler nun ein neues Savegame, speichert die Engine dieses möglicherweise im lokalen Gast-Profil oder versucht, in den neu aufgelösten MSA-Container zu schreiben. Dies führt zu einem State Split, bei dem das Cloud-Profil auf der Konsole und das lokale Profil auf dem PC völlig unterschiedliche Dateien aufweisen.
Wie die Unreal Engine den UserIndex auf das XUserHandle abbildet
Das Save-System der Unreal Engine wurde lange vor modernen Plattform-Identity-Systemen entworfen. Es verwendet einen einfachen Integer (UserIndex), um zwischen Spielern im lokalen Koop-Modus zu unterscheiden. In der GDK-Implementierung versucht die Engine, diesen Integer durch Aufruf interner GDK-Subsystem-Helper in ein XUserHandle aufzulösen. Wenn der Player-Index auf ein ungültiges oder nicht initialisiertes Handle verweist, fällt die Save-Operation stillschweigend auf ein lokales Verzeichnis zurück.
Um dies zu verhindern, müssen Sie zwei Dinge sicherstellen: Erstens dürfen Sie das Save-System nicht anrühren, bis das Identity Interface des Online Subsystems meldet, dass der Spieler vollständig angemeldet ist. Zweitens müssen Sie den korrekten, plattformspezifischen UserIndex auflösen, der mit der FUniqueNetId des angemeldeten Benutzers verknüpft ist, anstatt davon auszugehen, dass der lokale Player Controller immer den Index 0 hat. Lassen Sie uns ein System implementieren, das diesen Workflow garantiert.
Implementierungs-Guide: Behebung der Race Condition
Schritt 1: Die GDK-Save-Utility-Klasse
Erstellen Sie eine benutzerdefinierte Klasse UGDKSaveHelper, die von UBlueprintFunctionLibrary erbt. Diese Klasse wird die Logik enthalten, um den korrekten User-Index abzurufen und sicherzustellen, dass der User eingeloggt ist. Hier ist die Header-Datei (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);
};
Und hier ist die Implementierungsdatei (GDKSaveHelper.cpp), die das Online Subsystem nutzt, um die Network-ID des lokalen Spielers sicher auf seinen plattformspezifischen User-Index abzubilden:
#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;
}
Schritt 2: Anpassung der Save- und Load-Logik
In Ihrem Blueprint- oder C++-Gamestate-Management müssen Sie Ihre Load-Aufrufe kapseln (wrappen). Anstatt LoadGameFromSlot sofort beim Start aufzurufen, implementieren Sie eine Prüfung, die auf das Sign-in wartet. Binden Sie sich an das Event On Login Complete des Online Identity Subsystems. Sobald das Event triggert, prüfen Sie, ob der User vollständig angemeldet ist, rufen dessen aufgelösten Index ab und übergeben ihn direkt an die Savegame-Nodes.
Wenn das Sign-in fehlschlägt oder der Spieler es abbricht, müssen Sie das Spiel in einem „Gast“-State behandeln. Warnen Sie den Spieler, dass sein Fortschritt nicht in der Cloud gespeichert wird und lokal auf dem aktuellen Gerät verbleibt. Dadurch wird sichergestellt, dass der Spieler immer über seinen Speicherstatus informiert ist, was unerwartete Datenverluste beim Gerätewechsel verhindert.
Schritt 3: Konfigurationsdateien abgleichen
Selbst wenn Ihr Code den korrekten User-Index auflöst, schlägt das Save Roaming fehl, wenn die Konfigurationsdateien in den Xbox GDK- und PC GDK-Packages nicht übereinstimmen. Öffnen Sie Ihre DefaultEngine.ini und stellen Sie sicher, dass die Einstellungen für das GDK Online Subsystem korrekt sind. Die Title ID und die Service Configuration ID (SCID) müssen exakt mit Ihrer Registrierung im Microsoft Partner Center übereinstimmen.
[OnlineSubsystemGDK]
bEnabled=true
TitleId=1234ABCD
ServiceConfigId=12345678-1234-abcd-1234-1234567890ab
bEnableConfigService=true
[OnlineSubsystem]
DefaultPlatformService=GDK
Überprüfen Sie in Ihrer MicrosoftGame.config (die Ihre Anwendung packt), ob die Identity und Title ID exakt mit den Werten in Ihrem Partner Center-Dashboard übereinstimmen:
<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>
Denken Sie daran, dass beim Testen in einer Sandbox (z. B. XDKS.1) Ihre Test-Accounts der Sandbox hinzugefügt sein müssen und in der Xbox App auf dem PC korrekt angemeldet sein müssen. Wenn die Xbox App mit einem anderen Account angemeldet ist als der Windows Store, kann die Windows GDK-API den XGameSave-Provider nicht initialisieren, was zu abweichenden lokalen Verzeichnissen führt. Sie können dies überprüfen, indem Sie den lokalen AppData-Ordner kontrollieren, um zu sehen, wohin die Save-Files geschrieben werden.
Alternative: Plattformunabhängige Cloud-Saves via horizOn
Die Konfiguration der GDK Connected Storage-APIs von Microsoft, das Handling von Offline-Gast-Fallbacks und die Verwaltung identischer Sandboxes über PC- und Konsolenumgebungen hinweg können Wochen für Setup und Debugging in Anspruch nehmen. Selbst wenn es schließlich funktioniert, sind Sie an das Microsoft-Ökosystem gebunden. Diese Spielstände werden nicht auf Steam, den Epic Games Store, PlayStation oder Nintendo Switch übertragen.
Anstatt plattformspezifische Wrapper zu schreiben oder auf komplexe Echtzeit-Setups wie ein Unreal Engine WebSockets-Tutorial für Echtzeit-Backends zurückzugreifen, können Sie Ihre Savegame-Architektur vollständig auf einen plattformunabhängigen Backend-Service wie horizOn auslagern. Durch den Einsatz von horizOn wird die Verwaltung des Save-States von plattformspezifischen APIs entkoppelt. Sie authentifizieren Spieler über deren Plattform-Identity (wie Steam, Epic oder Xbox Live) und speichern ihre Daten in einer einheitlichen Cloud-Datenbank.
Dies umgeht Probleme mit abweichenden Xbox-Sandboxes vollständig und ermöglicht echtes plattformübergreifendes Save Roaming zwischen Steam, Xbox und PlayStation. Hier ist ein konzeptionelles Beispiel dafür, wie einfach es ist, den Spielerfortschritt global über die REST-API des Dienstes in Unreal Engine C++ zu speichern:
#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();
}
Ein robustes Cloud-Save-System selbst zu entwickeln, erfordert das Aufsetzen globaler Datenbanken, die Verwaltung sicherer Session-Token und das Lösen von Offline-Sync-Konflikten – locker 4 bis 6 Wochen reine Entwicklungszeit. Mit horizOn sind diese Backend-Services vorkonfiguriert, sodass Sie Ihr Spiel releasen können, anstatt Datenbank-Infrastruktur aufzubauen. Es vereinfacht die Pipeline von einem Kopfschmerz erzeugenden Multi-Plattform-Konfigurationsaufwand zu einem einzigen API-Aufruf.
Best Practices für plattformübergreifende Save-Architekturen
Egal, ob Sie sich durch die Konfiguration von Microsofts GDK Connected Storage kämpfen oder diese mit einem Backend-Service abstrahieren, Sie sollten sich an diese Kern-Best-Practices halten:
- Speichern Sie niemals per Tick oder Frame-End: Connected Storage limitiert Schreibvorgänge, um Festplattenverschleiß und Cloud-Sync-Throttling zu vermeiden. Bündeln (batchen) Sie Saves an bestimmten Trigger-Punkten (Checkpoints, Levelübergänge, Menü-Exits).
- Implementieren Sie Save-Conflict-Resolver: Wenn ein Spielstand auf dem PC den Zeitstempel 14:00 Uhr hat und ein Xbox-Spielstand 14:05 Uhr, überschreiben Sie das ältere Savegame nicht blind. Zeigen Sie ein UI-Overlay mit Spielzeit, Levelfortschritt und Metadaten an, damit der Spieler seinen aktiven State selbst wählen kann.
- Validieren Sie die Integrität der Savegames: Serialisieren Sie Ihre Save-Daten vor dem Schreiben und generieren Sie einen MD5- oder SHA-256-Hash. Speichern Sie diesen Hash in einem separaten Metadaten-Block, um zu verifizieren, dass die Datei beim Cloud-Transfer nicht beschädigt wurde.
- Fangen Sie Sign-outs sauber ab: Im GDK können sich Spieler während des laufenden Spiels über das Xbox-Dashboard abmelden. Registrieren Sie sich für den
OnLoginStatusChanged-Delegaten und pausieren Sie das Spiel sofort, wenn sich der aktive User ändert.
Wenn Sie bereit sind, ein skalierbares, plattformunabhängiges Cloud-Save-System ohne den GDK-Konfigurationsaufwand zu implementieren, können Sie noch heute starten. Erfahren Sie mehr, indem Sie horizOn kostenlos testen oder die API-Dokumentation lesen.
Quelle: Xbox/GDK XPA cross platform save roaming not working