Comment résoudre les problèmes de Save Roaming Xbox d'Unreal Engine dans les builds GDK et Windows Store
En bref
Ce guide technique explique comment résoudre les problèmes de save roaming entre les versions Xbox et PC (Windows Store) développées avec le GDK dans Unreal Engine. Il détaille la race condition asynchrone qui perturbe le Connected Storage lors de la connexion de l'utilisateur. Enfin, il fournit une implémentation C++ pour associer correctement le UserIndex au XUserHandle et présente des alternatives agnostiques comme horizOn pour éviter le verrouillage propriétaire.
Le cauchemar du Save Roaming GDK
Vous packagez vos builds GDK pour Xbox Series X et Windows Store, vous vérifiez que les succès se synchronisent, vous observez l'indicateur de synchronisation cloud tourner au démarrage, puis vous constatez avec horreur que les fichiers de sauvegarde de vos joueurs restent complètement isolés sur chaque plateforme. Du point de vue de l'utilisateur, tout semble correct : la connexion réussit, les notifications se déclenchent et les deux builds conservent les sauvegardes localement d'un lancement à l'autre. Pourtant, la progression sur Xbox ne fait jamais de roaming vers le build PC, et vice-versa.
Gérer les divergences de données de sauvegarde est tout aussi frustrant que de troubleshooter des multiplayer desyncs lors du gameplay live ; ces deux problèmes découlent d'un conflit d'autorité sur l'état. Dans le cas d'Xbox Play Anywhere (XPA), ce problème provient rarement d'une défaillance au niveau de la console. Il s'agit presque toujours d'une race condition dans la manière dont Unreal Engine associe ses index utilisateurs abstraits, basés sur des entiers, aux systèmes d'identité spécifiques au GDK de Microsoft. Voyons pourquoi cela se produit et comment y remédier en C++ et en Blueprints.
L'anatomie de GDK Connected Storage
Pour comprendre pourquoi le save roaming échoue, nous devons d'abord comprendre comment le GDK de Microsoft gère les fichiers de sauvegarde utilisateur sous le capot. Contrairement à Steam ou aux backends de jeu personnalisés, qui traitent généralement les fichiers de sauvegarde comme des fichiers bruts téléversés sur un serveur distant, le Microsoft Game Development Kit (GDK) utilise Connected Storage (l'API XGameSave). Sous cette architecture, les sauvegardes ne sont pas des chemins de fichiers bruts, mais des systèmes de conteneurs structurés. Ces conteneurs contiennent des blobs de fichiers de sauvegarde individuels qui sont synchronisés de manière sécurisée avec les serveurs Xbox Live.
Pour que le cloud save roaming fonctionne entre la console Xbox et les builds Windows Store (PC), le GDK s'appuie sur une matrice de correspondance stricte. Les deux builds doivent partager exactement le même Title ID, Service Configuration ID (SCID) et la même Sandbox (telle que RETAIL ou XDKS.1). De plus, le joueur doit être connecté au même Microsoft Account (MSA) sur les deux appareils. Enfin, les noms de conteneurs générés par votre système de sauvegarde doivent correspondre caractère par caractère ; sinon, le système de synchronisation cloud les traitera comme des ensembles de données indépendants.
De plus, Connected Storage alloue un maximum de 256 mégaoctets de stockage par utilisateur et par titre, avec une limite par conteneur individuel de 16 mégaoctets. Si votre système de sauvegarde dépasse ces limites strictes, l'API échoue silencieusement ou renvoie des codes d'erreur que le système de sauvegarde par défaut d'Unreal Engine ne fait pas remonter à la surface. Il est donc crucial de concevoir vos systèmes de sauvegarde en gardant ces contraintes à l'esprit, afin de s'assurer que la taille de vos fichiers reste limitée et structurée.
La cause profonde : la race condition de connexion asynchrone
La raison la plus fréquente de l'échec du save roaming GDK dans les projets Unreal Engine est une race condition subtile dans la façon dont le jeu détermine qui effectue la sauvegarde. Sur une console Xbox Series X, le système d'exploitation est structuré autour de profils de manette actifs. Au démarrage du jeu, un utilisateur est déjà sélectionné au niveau de l'OS. La console attribue automatiquement un XUserHandle au contexte d'exécution du jeu, et Unreal Engine mappe ce profil actif sur UserIndex 0 presque immédiatement.
Sur PC Windows, le flow est entièrement différent. Le jeu démarre immédiatement, sans demander de profil actif. Le flow de connexion de l'application Xbox ou du Windows Store s'exécute de manière asynchrone en arrière-plan. Si le code de votre jeu appelle LoadGameFromSlot ou DoesSaveGameExist dès le démarrage (par exemple dans un appel GameInstance::Init ou le BeginPlay de votre menu principal), l'online subsystem GDK n'a pas encore terminé la connexion de l'utilisateur.
Lorsque l'interface ISaveGameSystem d'Unreal Engine (plus précisément l'implémentation GDK, FGameSaveSystemGDK) est appelée avec UserIndex = 0 avant qu'un utilisateur ne soit connecté, elle retombe sur un local guest user. Le GDK mappe ensuite cette session sur le stockage local hors ligne du PC, contournant ainsi le conteneur synchronisé dans le cloud. Une fois que la connexion asynchrone se termine enfin quelques secondes plus tard, le jeu s'exécute déjà avec des données locales. Si le joueur effectue une nouvelle sauvegarde, le moteur peut l'écrire sur le profil guest local ou tenter d'écrire dans le conteneur MSA nouvellement résolu, créant ainsi un state split où le profil cloud sur console et le profil local sur PC possèdent des fichiers totalement différents.
Comment Unreal Engine mappe le UserIndex vers le XUserHandle
Le système de sauvegarde d'Unreal Engine a été conçu bien avant les systèmes d'identité de plateforme modernes. Il utilise un simple entier (UserIndex) pour différencier les joueurs en co-op locale. Dans l'implémentation GDK, le moteur tente de résoudre cet entier en un XUserHandle en appelant des helpers internes du sous-système GDK. Si l'index du joueur correspond à un handle invalide ou non initialisé, l'opération de sauvegarde retombe silencieusement sur un répertoire local.
Pour éviter cela, vous devez vous assurer de deux choses. Premièrement, vous ne devez pas toucher au système de sauvegarde tant que l'interface d'identité de l'online subsystem n'indique pas que le joueur est complètement connecté. Deuxièmement, vous devez résoudre le bon UserIndex spécifique à la plateforme associé au FUniqueNetId de l'utilisateur connecté, au lieu de supposer que le player controller local correspond toujours à l'index 0. Implémentons un système qui garantit ce workflow.
Guide d'implémentation : résoudre la race condition
Step 1: La classe utilitaire GDK Save
Créez une classe personnalisée UGDKSaveHelper qui hérite de UBlueprintFunctionLibrary. Cette classe contiendra la logique pour obtenir le bon index utilisateur et s'assurer que l'utilisateur est connecté. Voici le fichier d'en-tête (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);
};
Et voici le fichier d'implémentation (GDKSaveHelper.cpp) qui utilise l'online subsystem pour associer en toute sécurité l'ID réseau du joueur local à son index utilisateur spécifique à la plateforme :
#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: Modification de votre logique de sauvegarde et de chargement
Dans votre gestion des états de jeu en Blueprints ou en C++, vous devez encapsuler vos appels de chargement. Au lieu d'appeler LoadGameFromSlot immédiatement au démarrage, implémentez une vérification qui attend la connexion du joueur. Abonnez-vous à l'événement On Login Complete de l'online subsystem d'identité. Une fois l'événement déclenché, vérifiez si l'utilisateur est complètement connecté, récupérez l'index résolu et passez-le directement aux nœuds de sauvegarde du jeu.
Si la connexion échoue ou si le joueur l'annule, vous devez gérer le jeu dans un état de « guest ». Avertissez le joueur que sa progression ne sera pas sauvegardée dans le cloud et restera locale sur la machine actuelle. Cela garantit que le joueur est toujours informé du statut de sa sauvegarde, évitant ainsi des pertes de progression inattendues lors d'un changement d'appareil.
Step 3: Alignement des fichiers de configuration
Même si votre code résout le bon index utilisateur, le save roaming échouera si les fichiers de configuration des packages GDK Xbox et GDK PC ne correspondent pas. Ouvrez votre fichier DefaultEngine.ini et assurez-vous que les paramètres de l'online subsystem GDK sont corrects. Le Title ID et le Service Configuration ID (SCID) doivent correspondre précisément à votre enregistrement sur le Microsoft Partner Center.
[OnlineSubsystemGDK]
bEnabled=true
TitleId=1234ABCD
ServiceConfigId=12345678-1234-abcd-1234-1234567890ab
bEnableConfigService=true
[OnlineSubsystem]
DefaultPlatformService=GDK
Dans votre fichier MicrosoftGame.config (qui package votre application), vérifiez que l'Identity et le Title ID correspondent exactement aux valeurs de votre tableau de bord 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>
Gardez à l'esprit que lors des tests dans une sandbox (par exemple XDKS.1), vos comptes de test doivent être ajoutés à la sandbox et doivent se connecter correctement dans l'application Xbox sur PC. Si l'application Xbox est connectée à un compte différent de celui du Windows Store, l'API Windows GDK échouera à initialiser le fournisseur XGameSave, entraînant des divergences de répertoires locaux. Vous pouvez le vérifier en inspectant le dossier local AppData pour voir où les fichiers de sauvegarde sont écrits.
Alternative : Des sauvegardes cloud sans dépendance de plateforme via horizOn
Configurer les API Connected Storage du GDK de Microsoft, gérer les offline guest fallbacks et maintenir des sandboxes identiques entre les environnements PC et console peut demander des semaines de configuration et de debugging. Même une fois opérationnel, vous restez verrouillé dans l'écosystème Microsoft. Ces sauvegardes ne feront pas l'objet de roaming vers Steam, l'Epic Games Store, la Playstation ou la Nintendo Switch.
Plutôt que d'écrire des wrappers spécifiques à la plateforme, ou de recourir à des configurations complexes en temps réel comme un tutoriel WebSockets pour Unreal Engine pour les backends temps réel, vous pouvez décharger entièrement votre architecture de save state sur un service backend agnostique de la plateforme tel que horizOn. En utilisant horizOn, la gestion du save state est découplée des API spécifiques à la plateforme. Vous authentifiez les joueurs en utilisant leur identité de plateforme (comme Steam, Epic ou Xbox Live) et sauvegardez leurs données dans une base de données cloud unifiée.
Cela contourne complètement les problèmes de sandbox Xbox mismatch, permettant un véritable cross-platform save roaming entre Steam, Xbox et PlayStation. Voici un exemple conceptuel de la simplicité de sauvegarde globale de la progression d'un joueur à l'aide de la REST API du service en C++ Unreal Engine :
#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();
}
Construire soi-même un système robuste de cloud save nécessite de configurer des bases de données globales, de gérer des jetons de session sécurisés et de résoudre les conflits de synchronisation hors ligne, ce qui représente facilement 4 à 6 semaines de travail d'ingénierie dédié. Avec horizOn, ces services backend sont préconfigurés, vous permettant de shipper votre jeu plutôt que de bâtir une infrastructure de base de données. Cela simplifie le pipeline, transformant un casse-tête de configuration multiplateforme en un simple appel d'API.
Best Practices pour une architecture de sauvegarde Cross-Platform
Que vous choisissiez de vous débattre avec la configuration du Connected Storage du GDK de Microsoft ou de l'abstraire avec un service backend, vous devriez respecter ces best practices fondamentales :
- Ne sauvegardez jamais sur le Tick ou en fin de frame : Connected Storage limite les opérations d'écriture pour éviter la dégradation du disque et le throttling de la synchronisation cloud. Regroupez vos sauvegardes (batch) à des moments spécifiques (checkpoints, transitions de niveau, fermeture de menu).
- Implémentez des résolveurs de conflits de sauvegarde : Si une sauvegarde sur PC affiche un timestamp de 14:00 et une sauvegarde Xbox un timestamp de 14:05, n'écrasez pas aveuglément la plus ancienne. Présentez un prompt UI affichant les temps de jeu, la progression dans les niveaux et les métadonnées pour permettre au joueur de choisir l'état actif.
- Valisez l'intégrité des fichiers de sauvegarde : Avant l'écriture, sérialisez vos données de sauvegarde et générez un hash MD5 ou SHA-256. Enregistrez ce hash dans un bloc de métadonnées distinct pour vérifier que les fichiers téléchargés n'ont pas subi de corruption pendant le transit cloud.
- Gerez proprement les déconnexions (Sign-Outs) : Dans le GDK, les joueurs peuvent se déconnecter via le dashboard Xbox alors que le jeu est en cours d'exécution. Enregistrez-vous (register) au délégué
OnLoginStatusChangedet mettez immédiatement le jeu en pause si l'utilisateur actif change.
Si vous êtes prêt à implémenter un système de cloud save scalable et agnostique de la plateforme sans les tracas de configuration du GDK, vous pouvez commencer dès aujourd'hui. En savoir plus en essayant gratuitement horizOn ou en consultant la documentation API.
Source : Xbox/GDK XPA cross platform save roaming not working