Voltar ao Blog

Como Corrigir o Save Roaming de Xbox na Unreal Engine em Builds do GDK e da Windows Store

Publicado em 7 de julho de 2026
Como Corrigir o Save Roaming de Xbox na Unreal Engine em Builds do GDK e da Windows Store

Em resumo

Este artigo aborda a causa raiz e a solução para falhas de save roaming entre as builds de Xbox Series X e Windows Store utilizando o GDK da Microsoft na Unreal Engine. O problema geralmente decorre de uma race condition durante o login assíncrono do usuário no PC, fazendo com que o jogo utilize um perfil de convidado local em vez do container na nuvem. Apresentamos um guia prático em C++ para mapear corretamente o índice de usuário e alinhar os arquivos de configuração necessários. Como alternativa, discutimos como serviços de backend agnósticos de plataforma, como o horizOn, simplificam esse fluxo de trabalho eliminando a dependência de APIs específicas.

O Pesadelo do Save Roaming no GDK

Você empacota suas builds de Xbox Series X e Windows Store GDK, verifica que as conquistas estão sincronizando, vê o ícone de sincronização em nuvem girar no boot e, em seguida, assiste com horror enquanto os arquivos de save do jogador permanecem completamente isolados em cada plataforma. Tudo parece correto sob a perspectiva do usuário: o login é bem-sucedido, as notificações são acionadas e ambas as builds mostram arquivos de save persistindo localmente entre as inicializações do jogo. No entanto, o progresso no Xbox nunca passa para a build de PC, e vice-versa.

Lidar com discrepâncias de dados de save é tão frustrante quanto depurar multiplayer desyncs durante a gameplay ativa; ambos os problemas surgem de uma incompatibilidade de autoridade de estado. No caso do Xbox Play Anywhere (XPA), este problema raramente é uma falha ao nível do console. Quase sempre é uma race condition na forma como a Unreal Engine mapeia seus índices de usuário abstratos baseados em inteiros para os sistemas de identidade específicos do GDK da Microsoft. Vamos ver por que isso acontece e como corrigir isso tanto em C++ quanto em Blueprints.

A Anatomia do Connected Storage do GDK

Para entender por que o save roaming falha, precisamos primeiro compreender como o GDK da Microsoft lida com os arquivos de save do usuário por baixo dos panos. Ao contrário do Steam ou de backends de jogos customizados, que normalmente tratam arquivos de save como arquivos puros (raw files) enviados para um servidor remoto, o Microsoft Game Development Kit (GDK) usa o Connected Storage (a API XGameSave). Sob essa arquitetura, os saves não são caminhos de arquivos puros, mas sistemas estruturados de containers. Esses containers guardam blobs individuais de arquivos de save que são sincronizados com segurança com os servidores da Xbox Live.

Para que o cloud save roaming funcione entre as builds de console Xbox e Windows Store (PC), o GDK depende de uma matriz de correspondência estrita. Ambas as builds devem compartilhar exatamente o mesmo Title ID, Service Configuration ID (SCID) e Sandbox (como RETAIL ou XDKS.1). Além disso, o jogador deve estar logado na mesma Microsoft Account (MSA) em ambos os dispositivos. Finalmente, os nomes dos containers gerados pelo seu sistema de save devem corresponder caractere por caractere; caso contrário, o sistema de sincronização em nuvem os tratará como conjuntos de dados independentes.

Além disso, Connected Storage aloca um máximo de 256 megabytes de armazenamento por usuário, por título, com um limite de container individual de 16 megabytes. Se o seu sistema de save exceder esses limites estritos (hard limits), a API falha silenciosamente ou retorna códigos de erro que o sistema de save padrão da Unreal Engine não propaga para a superfície. É crítico projetar seus sistemas de save com essas restrições em mente, garantindo que o tamanho dos arquivos permaneça pequeno e estruturado.

A Causa Raiz: A Race Condition de Login Assíncrono

A razão mais comum para a falha de save roaming do GDK em projetos da Unreal Engine é uma race condition sutil na forma como o jogo determina quem está salvando. Em um console Xbox Series X, o sistema operacional é construído em torno de perfis de controle ativos. Quando o jogo inicializa (boots), um usuário já está selecionado no nível do SO. O console atribui automaticamente um XUserHandle ao contexto de execução do jogo, e a Unreal Engine mapeia esse perfil ativo para o UserIndex 0 quase imediatamente.

No Windows PC, o fluxo é totalmente diferente. O jogo inicializa imediatamente, sem solicitar um perfil ativo. O fluxo de login da Xbox App ou da Windows Store roda assincronamente em segundo plano. Se o código do seu jogo chama LoadGameFromSlot ou DoesSaveGameExist imediatamente após o boot (por exemplo, em uma chamada GameInstance::Init ou no BeginPlay do seu menu principal), o GDK Online Subsystem ainda não concluiu o sign-in do usuário.

Quando o ISaveGameSystem da Unreal Engine (especificamente a implementação de GDK, FGameSaveSystemGDK) é chamado com UserIndex = 0 antes de um usuário estar logado, ele faz um fallback para um usuário convidado (guest) local. O GDK então mapeia essa sessão para o armazenamento offline local no PC, ignorando o container sincronizado na nuvem. Assim que o login assíncrono finalmente é concluído alguns segundos depois, o jogo já está rodando com dados locais. Se o jogador realizar um novo save, a engine pode gravá-lo no perfil do convidado local ou tentar gravar no container da MSA recém-resolvido, criando uma divisão de estado onde o perfil de nuvem no console e o perfil local no PC possuem arquivos totalmente diferentes.

Como a Unreal Engine Mapeia UserIndex para XUserHandle

O sistema de save da Unreal Engine foi projetado muito antes dos sistemas modernos de identidade de plataforma. Ele usa um inteiro simples (UserIndex) para diferenciar os jogadores em co-op local. Na implementação do GDK, a engine tenta resolver esse inteiro para um XUserHandle chamando helpers internos do subsystem do GDK. Se o índice do jogador apontar para um handle inválido ou não inicializado, a operação de save faz um fallback silencioso para um diretório local.

Para evitar isso, você deve garantir duas coisas. Primeiro, você não deve tocar no sistema de save até que a interface de identidade do Online Subsystem reporte que o jogador está totalmente logado. Segundo, você deve resolver o UserIndex correto e específico da plataforma associado ao FUniqueNetId do usuário logado, em vez de assumir que o controller do jogador local é sempre o índice 0. Vamos implementar um sistema que garanta esse fluxo de trabalho.

Guia de Implementação: Resolvendo a Race Condition

Passo 1: A Classe Utilitária de Save do GDK

Crie uma classe customizada UGDKSaveHelper que herda de UBlueprintFunctionLibrary. Essa classe conterá a lógica para obter o índice de usuário correto e garantir que o usuário esteja logado. Aqui está o arquivo de cabeçalho (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);
};

E aqui está o arquivo de implementação (GDKSaveHelper.cpp), que usa o online subsystem para mapear com segurança o ID de rede do jogador local para o seu índice de usuário específico da plataforma:

#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;
}

Passo 2: Modificando Sua Lógica de Save e Load

No gerenciamento de estado do seu jogo em Blueprints ou C++, você deve encapsular suas chamadas de load. Em vez de chamar LoadGameFromSlot imediatamente na inicialização, implemente uma verificação que aguarde o sign-in. Faça o bind com o evento On Login Complete do Online Identity subsystem. Assim que o evento for disparado, verifique se o usuário está totalmente logado, consulte seu índice resolvido e passe-o diretamente para os nós de save game.

Se o sign-in falhar ou o jogador cancelá-lo, você deve tratar o jogo em um estado de convidado ('guest'). Avise o jogador de que seu progresso não será salvo na nuvem e permanecerá local na máquina atual. Isso garante que o jogador esteja sempre ciente do status de save do seu progresso, evitando perdas inesperadas ao trocar de dispositivo.

Passo 3: Alinhando os Arquivos de Configuração

Mesmo se seu código resolver o índice de usuário correto, o save roaming falhará se os arquivos de configuração nos pacotes do Xbox GDK e PC GDK não corresponderem. Abra o seu DefaultEngine.ini e garanta que as configurações do GDK online subsystem estejam corretas. O Title ID e o Service Configuration ID (SCID) devem corresponder precisamente ao seu registro no Microsoft Partner Center.

[OnlineSubsystemGDK]
bEnabled=true
TitleId=1234ABCD
ServiceConfigId=12345678-1234-abcd-1234-1234567890ab
bEnableConfigService=true

[OnlineSubsystem]
DefaultPlatformService=GDK

Em seu MicrosoftGame.config (que empacota sua aplicação), verifique se a Identity e o Title ID correspondem exatamente aos valores dentro do dashboard do seu 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>

Lembre-se de que, ao testar em uma sandbox (por exemplo, XDKS.1), suas contas de teste devem ser adicionadas à sandbox e devem realizar o sign-in corretamente no Xbox App no PC. Se o Xbox App estiver logado em uma conta diferente da Windows Store, a API do Windows GDK falhará ao inicializar o provider do XGameSave, levando a diretórios locais incompatíveis. Você pode verificar isso checando a pasta local AppData para ver onde os arquivos de save estão sendo gravados.

Alternativa: Cloud Saves Sem Platform Lock-In via horizOn

Configurar as APIs de Connected Storage do GDK da Microsoft, lidar com fallbacks offline de convidados e manter sandboxes idênticas em ambientes de PC e console pode levar semanas de configuração e debugging. Mesmo quando funciona, você fica preso ao ecossistema da Microsoft. Esses saves não farão roaming para o Steam, Epic Games Store, PlayStation ou Nintendo Switch.

Em vez de escrever wrappers específicos de plataforma ou recorrer a arquiteturas complexas em tempo real, como um tutorial de WebSockets da Unreal Engine para backends em tempo real, você pode delegar a arquitetura de estado de save inteiramente a um serviço de backend agnóstico de plataforma como o horizOn. Ao usar o horizOn, o gerenciamento de estado de save é desacoplado de APIs específicas de plataforma. Você autentica os jogadores usando a identidade de plataforma deles (como Steam, Epic ou Xbox Live) e salva seus dados em um banco de dados unificado na nuvem.

Isso contorna completamente os problemas de incompatibilidade de sandbox do Xbox, permitindo um verdadeiro save roaming multiplataforma entre Steam, Xbox e PlayStation. Aqui está um exemplo conceitual de como é simples salvar o progresso do jogador globalmente usando a REST API do serviço em 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();
}

Construir um sistema robusto de cloud save por conta própria exige a configuração de bancos de dados globais, gerenciamento de tokens de sessão seguros e tratamento de conflitos de sincronização offline — facilmente 4 a 6 semanas de tempo dedicado de engenharia. Com o horizOn, esses serviços de backend vêm pré-configurados, permitindo que você lance seu jogo em vez de construir infraestrutura de banco de dados. Isso simplifica o pipeline, transformando uma dor de cabeça de configuração multiplataforma em uma única chamada de API.

Boas Práticas para Arquitetura de Save Multiplataforma

Quer você decida lutar com a configuração do Connected Storage do GDK da Microsoft ou abstraí-la com um serviço de backend, você deve aderir a estas boas práticas fundamentais:

  1. Nunca salve no Tick ou no Frame-End: O Connected Storage limita as operações de escrita para evitar a degradação do disco e o throttling de sincronização na nuvem. Agrupe seus saves em pontos de gatilho específicos (checkpoints, transições de nível, saídas de menu).
  2. Implemente Resolvedores de Conflito de Save: Se um save no PC tiver um timestamp de 14:00 e um save no Xbox tiver um timestamp de 14:05, não sobrescreva cegamente o save mais antigo. Apresente um prompt na UI mostrando os tempos de jogo, progresso de nível e metadados, permitindo que o jogador escolha o seu estado ativo.
  3. Valide a Integridade do Arquivo de Save: Antes de gravar, serialize os dados do seu save e gere um hash MD5 ou SHA-256. Salve esse hash dentro de um bloco de metadados separado para verificar se os downloads dos arquivos não foram corrompidos durante o trânsito na nuvem.
  4. Lide de Forma Elegante com Sign-Outs: No GDK, os jogadores podem deslogar através do dashboard do Xbox enquanto o jogo está rodando. Registre o delegate OnLoginStatusChanged e pause o jogo imediatamente se o usuário ativo mudar.

Se você está pronto para implementar um sistema de cloud save escalável e agnóstico de plataforma sem as dores de cabeça de configuração do GDK, você pode começar hoje mesmo. Saiba mais experimentando o horizOn gratuitamente ou revisando os documentos da API.


Fonte: Xbox/GDK XPA cross platform save roaming not working