Jak naprawić Xbox Save Roaming w Unreal Engine w kompilacjach GDK i Windows Store
W skrócie
Artykuł omawia problem z brakiem synchronizacji zapisów gry (save roaming) między konsolami Xbox a wersjami na PC (Windows Store) w Unreal Engine przy użyciu GDK. Główną przyczyną jest race condition podczas asynchronicznego logowania gracza na PC, co prowadzi do przypisania zapisu do profilu lokalnego gościa zamiast kontenera MSA. Rozwiązaniem jest opóźnienie wywołań systemu zapisu do czasu ukończenia logowania i poprawne mapowanie identyfikatora sieciowego użytkownika. Jako alternatywę przedstawiono platformowo-niezależną usługę chmurową horizOn, która upraszcza całą architekturę zapisów.
Koszmar związany z GDK Save Roaming
Pakujesz swoje buildy Xbox Series X i Windows Store GDK, weryfikujesz synchronizację osiągnięć, widzisz kręcącą się przy uruchomieniu ikonę synchronizacji z chmurą, a potem z przerażeniem obserwujesz, jak pliki zapisu gracza pozostają całkowicie odizolowane na każdej z platform. Z perspektywy użytkownika wszystko wygląda poprawnie: logowanie się udaje, powiadomienia są wysyłane, a oba buildy bez problemu przechowują pliki zapisu lokalnie między uruchomieniami gry. Mimo to postęp z konsoli Xbox nigdy nie roamuje do builda na PC i na odwrót.
Radzenie sobie z rozbieżnościami w plikach zapisu jest tak samo frustrujące jak debugowanie desynchronizacji w multiplayerze podczas rozgrywki na żywo; oba te problemy wynikają z konfliktu uprawnień do stanu (state authority). W przypadku Xbox Play Anywhere (XPA) problem ten rzadko jest awarią na poziomie samej konsoli. Prawie zawsze jest to race condition wynikający z tego, jak Unreal Engine mapuje swoje abstrakcyjne, oparte na liczbach całkowitych indeksy użytkowników (user indices) na specyficzne dla Microsoft GDK systemy tożsamości. Przyjrzyjmy się, dlaczego tak się dzieje i jak to naprawić zarówno w C++, jak i w Blueprintach.
Anatomia GDK Connected Storage
Aby zrozumieć, dlaczego save roaming kończy się niepowodzeniem, musimy najpierw pojąć, jak GDK od Microsoft obsługuje pliki zapisu użytkownika pod maską. W przeciwieństwie do platformy Steam czy własnych backendów gier, które zazwyczaj traktują save'y jako surowe pliki przesyłane na zdalny serwer, Microsoft Game Development Kit (GDK) wykorzystuje Connected Storage (API XGameSave). W tej architekturze save'y nie są surowymi ścieżkami plików, lecz ustrukturyzowanymi systemami kontenerów. Kontenery te przechowują poszczególne bloby plików zapisu, które są bezpiecznie synchronizowane z serwerami Xbox Live.
Aby chmurowy save roaming działał między konsolą Xbox a buildami z Windows Store (PC), GDK opiera się na rygorystycznej macierzy dopasowań. Oba buildy muszą współdzielić dokładnie ten sam Title ID, Service Configuration ID (SCID) oraz Sandbox (taki jak RETAIL lub XDKS.1). Dodatkowo gracz must być zalogowany na to samo konto Microsoft Account (MSA) na obu urządzeniach. Na koniec, nazwy kontenerów generowane przez Twój system zapisów muszą zgadzać się co do znaku – w przeciwnym razie system synchronizacji chmurowej potraktuje je jako niezależne zestawy danych.
Co więcej, Connected Storage przydziela maksymalnie 256 megabajtów przestrzeni dyskowej na użytkownika i na dany tytuł, z limitem 16 megabajtów na pojedynczy kontener. Jeśli Twój system zapisów przekroczy te sztywne limity, API po cichu zawiedzie lub zwróci kody błędów, których domyślny system zapisu Unreal Engine nie przekaże na powierzchnię. Kluczowe jest projektowanie systemów zapisu z uwzględnieniem tych ograniczeń, aby rozmiary plików pozostały małe i ustrukturyzowane.
Przyczyna źródłowa: Race Condition przy asynchronicznym logowaniu
Najczęstszą przyczyną problemów z GDK save roaming w projektach Unreal Engine jest subtelny race condition w sposobie, w jaki gra określa, kto dokonuje zapisu. Na konsoli Xbox Series X system operacyjny opiera się na aktywnych profilach kontrolerów. Gdy gra się uruchamia, użytkownik jest już wybrany na poziomie systemu. Konsola automatycznie przypisuje XUserHandle do kontekstu wykonawczego gry, a Unreal Engine niemal natychmiast mapuje ten aktywny profil na UserIndex 0.
Na komputerach z systemem Windows proces ten wygląda zupełnie inaczej. Gra uruchamia się natychmiast, bez pytania o aktywny profil. Proces logowania do aplikacji Xbox lub Windows Store przebiega asynchronicznie w tle. Jeśli kod Twojej gry wywołuje LoadGameFromSlot lub DoesSaveGameExist natychmiast po uruchomieniu (np. w funkcji GameInstance::Init lub w BeginPlay menu głównego), GDK Online Subsystem nie zdążył jeszcze ukończyć procesu logowania użytkownika.
Gdy ISaveGameSystem w Unreal Engine (a dokładnie implementacja GDK, FGameSaveSystemGDK) zostanie wywołany z wartością UserIndex = 0 zanim użytkownik zostanie zalogowany, system domyślnie wybiera lokalnego użytkownika-gościa (guest user). GDK mapuje wtedy tę sesję na lokalny magazyn offline na PC, omijając zsynchronizowany z chmurą kontener. Gdy kilka sekund później asynchroniczne logowanie w końcu się zakończy, gra działa już na danych lokalnych. Jeśli gracz zapisze grę, silnik może zapisać stan do lokalnego profilu gościa lub spróbować zapisać go do nowo przypisanego kontenera MSA, tworząc rozbieżność stanów (state split), w której profil w chmurze na konsoli i profil lokalny na PC zawierają zupełnie inne pliki.
Jak Unreal Engine mapuje UserIndex na XUserHandle
System zapisów Unreal Engine powstał na długo przed nowoczesnymi systemami tożsamości na poszczególnych platformach. Używa on zwykłej liczby całkowitej (UserIndex) do rozróżniania graczy w lokalnej kooperacji. W implementacji GDK silnik próbuje powiązać tę liczbę z XUserHandle, odpytując wewnętrzne funkcje pomocnicze podsystemu GDK. Jeśli indeks gracza wskazuje na nieprawidłowy lub niezainicjalizowany uchwyt (handle), operacja zapisu po cichu fallbackuje do lokalnego katalogu.
Aby temu zapobiec, musisz upewnić się co do dwóch rzeczy. Po pierwsze, nie możesz odpytywać systemu zapisów, dopóki interfejs tożsamości Online Subsystem nie zgłosi, że gracz jest w pełni zalogowany. Po drugie, musisz pobrać poprawny, specyficzny dla platformy UserIndex powiązany z FUniqueNetId zalogowanego użytkownika, zamiast zakładać, że lokalny kontroler gracza ma zawsze indeks 0. Zaimplementujmy system, który zagwarantuje taki proces.
Instrukcja wdrożenia: Rozwiązanie problemu Race Condition
Krok 1: Klasa pomocnicza GDK Save
Stwórz własną klasę UGDKSaveHelper dziedziczącą po UBlueprintFunctionLibrary. Klasa ta będzie zawierać logikę pobierania poprawnego indeksu użytkownika oraz weryfikacji, czy użytkownik jest zalogowany. Oto plik nagłówkowy (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);
};
A oto plik implementacji (GDKSaveHelper.cpp), który wykorzystuje subsystem sieciowy do bezpiecznego mapowania identyfikatora sieciowego (network ID) lokalnego gracza na jego specyficzny dla danej platformy indeks użytkownika:
#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;
}
Krok 2: Modyfikacja logiki zapisu i wczytywania
W mechanizmie zarządzania stanem gry w Blueprintach lub w C++ musisz opakować wywołania wczytywania danych. Zamiast wywoływać LoadGameFromSlot natychmiast przy starcie, zaimplementuj logikę oczekującą na logowanie. Podepnij się pod zdarzenie On Login Complete z podsystemu Online Identity. Gdy zdarzenie zostanie wywołane, upewnij się, że użytkownik jest w pełni zalogowany, pobierz jego przypisany indeks i przekaż go bezpośrednio do węzłów (nodes) zapisu gry.
Jeśli logowanie się nie powiedzie lub gracz je anuluje, musisz obsłużyć grę w trybie „gościa”. Ostrzeż gracza, że jego postęp nie zostanie zapisany w chmurze i pozostanie lokalnie na bieżącym urządzeniu. Dzięki temu gracz będzie zawsze świadomy statusu swoich zapisów, co zapobiegnie nieoczekiwanej utracie postępów podczas zmiany urządzeń.
Krok 3: Spójność plików konfiguracyjnych
Nawet jeśli Twój kod poprawnie ustali właściwy indeks użytkownika, save roaming zakończy się niepowodzeniem, jeśli pliki konfiguracyjne w pakietach Xbox GDK i PC GDK nie będą zgodne. Otwórz swój DefaultEngine.ini i upewnij się, że ustawienia GDK Online Subsystem są prawidłowe. Title ID oraz Service Configuration ID (SCID) muszą precyzyjnie odpowiadać rejestracji w Microsoft Partner Center.
[OnlineSubsystemGDK]
bEnabled=true
TitleId=1234ABCD
ServiceConfigId=12345678-1234-abcd-1234-1234567890ab
bEnableConfigService=true
[OnlineSubsystem]
DefaultPlatformService=GDK
W pliku MicrosoftGame.config (odpowiedzialnym za pakowanie aplikacji) zweryfikuj, czy Identity i Title ID są dokładnie zgodne z wartościami w panelu 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>
Pamiętaj, że podczas testowania w piaskownicy (sandbox, np. XDKS.1) Twoje konta testowe must być do niej dodane i muszą prawidłowo zalogować się w aplikacji Xbox App na PC. Jeśli aplikacja Xbox App jest zalogowana na inne konto niż Windows Store, Windows GDK API nie zdoła zainicjalizować dostawcy XGameSave, co doprowadzi do rozbieżności w lokalnych katalogach zapisu. Możesz to zweryfikować, sprawdzając lokalny folder AppData w celu ustalenia, gdzie dokładnie są zapisywane pliki.
Alternatywa: Zapisy w chmurze bez lock-inu platformy z horizOn
Konfiguracja interfejsów API Microsoft GDK Connected Storage, obsługa fallbacków dla offline'owych gości oraz utrzymywanie identycznych środowisk piaskownicy (sandboxes) na PC i konsolach mogą zająć długie tygodnie konfiguracji i debugowania. Nawet kiedy wszystko już zadziała, zostajesz z lock-inem na ekosystem Microsoftu. Takie save'y nie przeniosą się na Steam, Epic Games Store, PlayStation czy Nintendo Switch.
Zamiast pisać dedykowane dla danej platformy wrappery lub uciekać się do skomplikowanych rozwiązań czasu rzeczywistego (jak te opisane w poradniku o WebSockets w Unreal Engine dla backendów działających w czasie rzeczywistym), możesz całkowicie przenieść architekturę zarządzania save state do niezależnej od platformy usługi backendowej, takiej jak horizOn. Dzięki horizOn zarządzanie stanem zapisu jest odizolowane od platformowych API. Uwierzytelniasz graczy za pomocą ich tożsamości platformowej (jak Steam, Epic czy Xbox Live) i zapisujesz ich dane w spójnej chmurowej bazie danych.
To całkowicie omija problemy z niezgodnością piaskownic na konsoli Xbox, umożliwiając prawdziwy cross-platform save roaming między Steam, Xbox i PlayStation. Oto koncepcyjny przykład pokazujący, jak proste jest globalne zapisywanie postępów gracza przy użyciu REST API tej usługi w 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();
}
Samodzielne zbudowanie niezawodnego systemu zapisu w chmurze wymaga wdrożenia globalnych baz danych, obsługi bezpiecznych tokenów sesji oraz rozwiązywania konfliktów synchronizacji offline – to z łatwością od 4 do 6 tygodni pracy dedykowanego programisty. Z horizOn te usługi backendowe są gotowe i prekonfigurowane, dzięki czemu możesz skupić się na wydaniu gry zamiast stawiać infrastrukturę bazodanową. Cała ścieżka (pipeline) zmienia się z wieloplatformowego koszmaru konfiguracyjnego w pojedyncze wywołanie API.
Najlepsze praktyki dla architektury cross-platform save
Niezależnie od tego, czy zdecydujesz się zmagać z konfiguracją Microsoft GDK Connected Storage, czy odizolować się od niej za pomocą zewnętrznego backendu, powinieneś przestrzegać poniższych kluczowych dobrych praktyk:
- Nigdy nie zapisuj gry w Ticku ani na koniec ramki: Connected Storage ogranicza operacje zapisu, aby zapobiec zużyciu dysku i dławieniu (throttlingowi) synchronizacji chmurowej. Zgrupuj (batchuj) zapisy do określonych punktów wyzwalania (checkpoints, przejścia między poziomami, wyjście z menu).
- Zaimplementuj mechanizmy rozwiązywania konfliktów zapisów: Jeśli save na PC ma znacznik czasu 14:00, a ten na konsoli Xbox 14:05, nie nadpisuj starszego zapisu w ciemno. Wyświetl monit UI z czasem gry, postępem poziomu i metadanymi, dając graczowi możliwość wyboru aktywnego stanu.
- Weryfikuj spójność plików zapisu: Przed dokonaniem zapisu zserializuj swoje dane zapisu i wygeneruj skrót MD5 lub SHA-256. Zapisz ten hash w osobnym bloku metadanych, aby upewnić się, że pobierany plik nie uległ uszkodzeniu podczas przesyłu chmurowego.
- Bezpiecznie obsługuj wylogowania: W GDK gracze mogą wylogować się przez dashboard konsoli Xbox podczas działania gry. Zarejestruj się w delegacie
OnLoginStatusChangedi natychmiast wstrzymaj (zapauzuj) grę, jeśli aktywny użytkownik ulegnie zmianie.
Jeśli chcesz wdrożyć skalowalny i niezależny od platformy system chmurowych zapisów bez problemów związanych z konfiguracją GDK, możesz zacząć już dziś. Dowiedz się więcej, wypróbowując horizOn za darmo lub zapoznając się z dokumentacją API.
Źródło: Xbox/GDK XPA cross platform save roaming not working