Как исправить Unreal Engine Xbox Save Roaming в GDK и Windows Store билдах
Коротко о главном
Статья посвящена устранению проблем со сбоями в механизме Xbox Save Roaming между консольными сборками Xbox Series X и ПК-версиями из Windows Store при работе с Microsoft GDK в Unreal Engine. Описывается race condition, возникающий из-за асинхронной авторизации на ПК, из-за которого игра преждевременно обращается к локальному профилю гостя вместо облачного контейнера. В качестве решения предложено пошаговое руководство по корректному сопоставлению сетевых идентификаторов с платформозависимым индексом пользователя на C++ и Blueprints, а также альтернативный вариант реализации кроссплатформенных сохранений с использованием сервиса horizOn.
Кошмар с GDK Save Roaming
Вы упаковываете свои GDK-билды для Xbox Series X и Windows Store, проверяете синхронизацию ачивок, видите вращающийся спиннер облачной синхронизации при запуске, а затем с ужасом наблюдаете, как файлы сохранений вашего игрока остаются полностью изолированными на каждой платформе. С точки зрения пользователя всё выглядит правильно: вход выполнен успешно, уведомления срабатывают, и оба билда сохраняют файлы локально между запусками игры. Тем не менее, прогресс на Xbox никогда не переносится на PC-билд, и наоборот.
Разбор несовпадений в данных сохранений вызывает столько же головной боли, сколько и отладка multiplayer desyncs во время живого геймплея; обе проблемы возникают из-за несовпадения источников состояния (state authorities). В случае с Xbox Play Anywhere (XPA) эта проблема редко связана с ошибкой на уровне консоли. Почти всегда это race condition в том, как Unreal Engine сопоставляет свои абстрактные целочисленные индексы пользователей с GDK-специфичными системами идентификации Microsoft. Давайте разберем, почему это происходит и как это исправить как в C++, так и в Blueprints.
Анатомия GDK Connected Storage
Чтобы понять, почему не работает save roaming, мы должны сначала разобраться, как GDK от Microsoft обрабатывает файлы сохранений пользователей под капотом. В отличие от Steam или кастомных игровых Backend-решений, которые обычно рассматривают файлы сохранений как необработанные файлы, загружаемые на удаленный сервер, Microsoft Game Development Kit (GDK) использует Connected Storage (API XGameSave). При такой архитектуре сохранения представляют собой не простые пути к файлам, а структурированные системы контейнеров. Эти контейнеры содержат отдельные блобы файлов сохранений, которые безопасно синхронизируются с серверами Xbox Live.
Чтобы облачный save roaming работал между консолью Xbox и Windows Store (PC) билдами, GDK опирается на строгую матрицу соответствия. Оба билда должны иметь абсолютно одинаковые Title ID, Service Configuration ID (SCID) и Sandbox (например, RETAIL или XDKS.1). Кроме того, игрок должен войти под одной и той же учетной записью Microsoft Account (MSA) на обоих устройствах. И наконец, имена контейнеров, генерируемые вашей системой сохранений, должны совпадать посимвольно; в противном случае система облачной синхронизации будет рассматривать их как независимые наборы данных.
Более того, Connected Storage выделяет максимум 256 мегабайт дискового пространства на одного пользователя для одного Title, а лимит на отдельный контейнер составляет 16 мегабайт. Если ваша система сохранений превышает эти жесткие лимиты, API молча завершается ошибкой или возвращает коды ошибок, которые стандартная система сохранений Unreal Engine не выводит наружу. Крайне важно проектировать системы сохранений с учетом этих ограничений, следя за тем, чтобы размеры файлов оставались небольшими и структурированными.
Первопричина: Race Condition при асинхронном входе
Самая распространенная причина сбоя GDK save roaming в проектах на Unreal Engine — это тонкий race condition в том, как игра определяет, кто совершает сохранение. На консоли Xbox Series X операционная система построена вокруг активных профилей контроллеров. При запуске игры пользователь уже выбран на уровне ОС. Консоль автоматически назначает XUserHandle контексту выполнения игры, и Unreal Engine почти сразу сопоставляет этот активный профиль с UserIndex 0.
На Windows PC этот процесс выглядит совершенно иначе. Игра запускается мгновенно, не запрашивая активный профиль. Процесс входа через Xbox App или Windows Store выполняется асинхронно в фоновом режиме. Если код вашей игры вызывает LoadGameFromSlot или DoesSaveGameExist сразу после загрузки (например, в вызове GameInstance::Init или в BeginPlay главного меню), Online Subsystem GDK еще не завершила вход пользователя.
Когда ISaveGameSystem в Unreal Engine (конкретно реализация для GDK, FGameSaveSystemGDK) вызывается с UserIndex = 0 до того, как пользователь вошел в систему, она откатывается к локальному гостевому пользователю. GDK затем сопоставляет эту сессию с локальным офлайн-хранилищем на ПК, минуя синхронизируемый с облаком контейнер. Как только асинхронный вход наконец завершается через несколько секунд, игра уже работает с локальными данными. Если игрок создает новое сохранение, движок может записать его в локальный гостевой профиль или попытаться записать в только что разрешенный MSA-контейнер, создавая разделение состояния (state split), при котором облачный профиль на консоли и локальный профиль на ПК имеют совершенно разные файлы.
Как Unreal Engine сопоставляет UserIndex с XUserHandle
Система сохранений Unreal Engine была разработана задолго до появления современных платформ идентификации. Она использует простое целое число (UserIndex) для различения игроков в локальном кооперативе. В реализации для GDK движок пытается сопоставить это целое число с XUserHandle, вызывая внутренние вспомогательные функции подсистемы GDK. Если индекс игрока указывает на невалидный или неинициализированный дескриптор, операция сохранения молча откатывается к локальной директории.
Чтобы предотвратить это, вы должны убедиться в двух вещах. Во-первых, вы не должны трогать систему сохранений до тех пор, пока интерфейс идентификации Online Subsystem не сообщит, что игрок полностью вошел в систему. Во-вторых, вы должны получить правильный, платформозависимый UserIndex, связанный с FUniqueNetId вошедшего пользователя, вместо того чтобы предполагать, что локальный контроллер игрока всегда имеет индекс 0. Давайте реализуем систему, которая гарантирует этот рабочий процесс.
Руководство по реализации: Разрешение Race Condition
Шаг 1: Класс-утилита для сохранения в GDK
Создайте собственный класс UGDKSaveHelper, который наследуется от UBlueprintFunctionLibrary. Этот класс будет содержать логику для получения правильного индекса пользователя и проверки того, что пользователь вошел в систему. Вот заголовочный файл (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;
}
Шаг 2: Изменение логики сохранения и загрузки
В ваших Blueprints или C++ системах управления состоянием игры вам необходимо обернуть вызовы загрузки. Вместо вызова LoadGameFromSlot сразу при запуске, реализуйте проверку, которая ожидает входа пользователя. Подпишитесь на событие On Login Complete подсистемы Online Identity. Как только событие сработает, проверьте, вошел ли пользователь полностью в систему, запросите полученный для него индекс и передайте его напрямую в ноды сохранения игры.
Если вход завершился неудачно или игрок отменил его, вы должны обрабатывать состояние игры как «гостевое». Предупредите игрока, что его прогресс не будет сохраняться в облаке и останется локальным на текущей машине. Это гарантирует, что игрок всегда будет знать о статусе своих сохранений, что предотвратит неожиданную потерю прогресса при смене устройств.
Шаг 3: Синхронизация конфигурационных файлов
Даже если ваш код определяет правильный индекс пользователя, save roaming не будет работать, если конфигурационные файлы в пакетах Xbox GDK и PC GDK не совпадают. Откройте ваш DefaultEngine.ini и убедитесь, что настройки Online Subsystem GDK указаны верно. 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 и должны успешно входить в систему внутри Xbox App на ПК. Если в Xbox App выполнен вход под другим аккаунтом, отличным от Windows Store, API Windows GDK не сможет инициализировать провайдер XGameSave, что приведет к несовпадению локальных директорий. Вы можете убедиться в этом, проверив локальную папку AppData, чтобы увидеть, куда именно записываются файлы сохранений.
Альтернатива: Облачные сохранения без привязки к платформе с помощью horizOn
Настройка API Microsoft GDK Connected Storage, обработка откатов в офлайн для гостей и поддержание идентичных Sandbox-окружений на ПК и консолях могут занять недели настройки и отладки. Даже когда всё заработает, вы останетесь привязаны к экосистеме Microsoft. Эти сохранения не будут переноситься в Steam, Epic Games Store, PlayStation или Nintendo Switch.
Вместо того чтобы писать платформозависимые обертки или прибегать к сложным архитектурным решениям реального времени, подобным описанным в туториалу по Unreal Engine WebSockets для real-time Backend-систем, вы можете полностью переложить архитектуру состояний сохранений на независимый от платформ Backend-сервис вроде horizOn. Благодаря horizOn управление состоянием сохранений отделено от платформенных API. Вы аутентифицируете игроков, используя их идентификаторы платформ (например, Steam, Epic или Xbox Live), и сохраняете их данные в единой облачной базе данных.
Это полностью решает проблему несоответствия Xbox Sandbox, обеспечивая настоящий кроссплатформенный save roaming между Steam, Xbox и PlayStation. Вот концептуальный пример того, как просто сохранить прогресс игрока глобально с помощью REST API сервиса на 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();
}
Создание надежной системы облачных сохранений своими силами требует развертывания глобальных баз данных, управления безопасными сессионными токенами (session tokens) и обработки конфликтов при офлайн-синхронизации, что легко может занять от 4 до 6 недель чистой разработки. С horizOn эти Backend-сервисы поставляются уже настроенными, позволяя вам выпускать игру, а не строить инфраструктуру баз данных. Это упрощает пайплайн от головной боли с мультиплатформенной конфигурацией до одного вызова API.
Best Practices для кроссплатформенной архитектуры сохранений
Независимо от того, решите ли вы бороться с конфигурацией Microsoft GDK Connected Storage или абстрагируетесь от неё с помощью Backend-сервиса, вам следует придерживаться следующих основных Best Practices:
- Никогда не сохраняйте в Tick или Frame-End: Connected Storage ограничивает количество операций записи для предотвращения износа диска и троттлинга облачной синхронизации. Группируйте сохранения в определенные триггерные точки (чекпоинты, переходы между уровнями, выход в меню).
- Реализуйте механизмы разрешения конфликтов сохранений: Если сохранение на ПК имеет метку времени 14:00, а сохранение на Xbox — 14:05, не перезаписывайте старое сохранение вслепую. Покажите UI-окно с игровым временем, прогрессом уровня и метаданными, позволяя игроку самому выбрать активное состояние.
- Проверяйте целостность файлов сохранений: Перед записью сериализуйте данные сохранения и сгенерируйте хэш MD5 или SHA-256. Сохраните этот хэш в отдельном блоке метаданных, чтобы убедиться, что файлы не были повреждены при скачивании из облака.
- Корректно обрабатывайте выход из системы: В GDK игроки могут выйти из аккаунта через дашборд Xbox прямо во время работы игры. Зарегистрируйтесь на делегат
OnLoginStatusChangedи немедленно ставьте игру на паузу при смене активного пользователя.
Если вы готовы внедрить масштабируемую, независимую от платформ систему облачных сохранений без головной боли по настройке GDK, вы можете начать уже сегодня. Узнайте больше, попробовав horizOn бесплатно или изучив API docs.
Source: Xbox/GDK XPA cross platform save roaming not working