Volver al Blog

Cómo solucionar problemas de Save Roaming de Xbox en builds de GDK y Windows Store con Unreal Engine

Publicado el 7 de julio de 2026
Cómo solucionar problemas de Save Roaming de Xbox en builds de GDK y Windows Store con Unreal Engine

En resumen

Esta guía analiza el problema común de sincronización que impide el funcionamiento del Save Roaming de Xbox en builds de GDK y Windows Store con Unreal Engine. Explica detalladamente cómo la race condition del login asíncrono asigna datos locales antes de resolver la identidad del usuario en Connected Storage. Incluye una implementación en C++ para mapear correctamente el UserIndex al XUserHandle del jugador, así como la configuración de DefaultEngine.ini y MicrosoftGame.config. Por último, se presenta horizOn como una alternativa para implementar cloud saves multiplataforma sin depender de las APIs nativas de Microsoft.

La pesadilla del Save Roaming en GDK

Empaquetas tus builds de GDK para Xbox Series X y Windows Store, verificas que los logros se estén sincronizando, ves el icono de carga de sincronización en la nube girar al arrancar y luego observas con horror cómo los archivos de guardado de tus jugadores quedan completamente aislados en cada plataforma. Todo parece correcto desde la perspectiva del usuario: el inicio de sesión tiene éxito, las notificaciones se activan y ambas builds muestran que los archivos de guardado persisten localmente entre lanzamientos del juego. Sin embargo, el progreso en Xbox nunca hace roaming hacia la build de PC, y viceversa.

Lidiar con las discrepancias en los datos de guardado es tan frustrante como solucionar multiplayer desyncs durante el gameplay en vivo; ambos problemas se deben a una falta de coincidencia en las autoridades de estado. En el caso de Xbox Play Anywhere (XPA), este problema rara vez es un fallo a nivel de consola. Casi siempre se trata de una race condition en la forma en que Unreal Engine mapea sus índices de usuario abstractos basados en enteros a los sistemas de identidad específicos de GDK de Microsoft. Veamos por qué ocurre esto y cómo solucionarlo tanto en C++ como en Blueprints.

La anatomía de GDK Connected Storage

Para entender por qué falla el save roaming, primero debemos comprender cómo maneja GDK de Microsoft los archivos de guardado de los usuarios bajo el capó. A diferencia de Steam o de los backends de juego personalizados, que normalmente tratan los archivos de guardado como archivos sin procesar subidos a un servidor remoto, el Microsoft Game Development Kit (GDK) utiliza Connected Storage (la API XGameSave). Bajo esta arquitectura, los guardados no son rutas de archivos sin procesar, sino sistemas de contenedores estructurados. Estos contenedores contienen blobs individuales de archivos de guardado que se sincronizan de forma segura con los servidores de Xbox Live.

Para que el save roaming en la nube funcione entre las builds de la consola Xbox y Windows Store (PC), el GDK depende de una matriz de coincidencia estricta. Ambas builds deben compartir exactamente el mismo Title ID, Service Configuration ID (SCID) y Sandbox (como RETAIL o XDKS.1). Además, el jugador debe haber iniciado sesión en la misma cuenta de Microsoft (MSA) en ambos dispositivos. Por último, los nombres de los contenedores generados por tu sistema de guardado deben coincidir carácter por carácter; de lo contrario, el sistema de sincronización en la nube los tratará como conjuntos de datos independientes.

Además, Connected Storage asigna un máximo de 256 megabytes de almacenamiento por usuario y por título, con un límite de contenedor individual de 16 megabytes. Si tu sistema de guardado supera estos límites estrictos, la API falla silenciosamente o devuelve códigos de error que el sistema de guardado predeterminado de Unreal Engine no propaga a la superficie. Es fundamental diseñar tus sistemas de guardado teniendo en cuenta estas restricciones, asegurando que los tamaños de tus archivos se mantengan pequeños y estructurados.

La causa raíz: la race condition del inicio de sesión asíncrono

La razón más común por la que falla el save roaming de GDK en proyectos de Unreal Engine es una sutil race condition en la forma en que el juego determina quién está guardando. En una consola Xbox Series X, el sistema operativo está construido en torno a perfiles de control activos. Cuando el juego arranca, ya hay un usuario seleccionado a nivel del sistema operativo. La consola asigna automáticamente un XUserHandle al contexto de ejecución del juego, y Unreal Engine mapea este perfil activo a UserIndex 0 casi de inmediato.

En Windows PC, el flujo es completamente diferente. El juego arranca inmediatamente, sin solicitar un perfil activo. El flujo de inicio de sesión de la Xbox App o Windows Store se ejecuta de forma asíncrona en segundo plano. Si el código de tu juego llama a LoadGameFromSlot o DoesSaveGameExist inmediatamente al arrancar (por ejemplo, en una llamada a GameInstance::Init o en el BeginPlay de tu menú principal), el Online Subsystem de GDK aún no ha completado el inicio de sesión del usuario.

Cuando se llama a ISaveGameSystem de Unreal Engine (específicamente a la implementación de GDK, FGameSaveSystemGDK) con UserIndex = 0 antes de que el usuario haya iniciado sesión, se recurre a un usuario invitado local. GDK mapea entonces esta sesión al almacenamiento local offline de la PC, omitiendo el contenedor sincronizado con la nube. Una vez que el inicio de sesión asíncrono se completa por fin unos segundos más tarde, el juego ya se está ejecutando con datos locales. Si el jugador escribe un nuevo guardado, el motor puede guardarlo en el perfil de invitado local o intentar escribir en el contenedor MSA recién resuelto, creando una división de estado (state split) en la que el perfil de la nube en la consola y el perfil local en la PC tienen archivos completamente diferentes.

Cómo mapea Unreal Engine el UserIndex a XUserHandle

El sistema de guardado de Unreal Engine fue diseñado mucho antes que los sistemas modernos de identidad de plataforma. Utiliza un simple entero (UserIndex) para diferenciar a los jugadores en el modo co-op local. En la implementación de GDK, el motor intenta resolver este entero a un XUserHandle llamando a funciones auxiliares internas del subsistema de GDK. Si el índice del jugador mapea a un handle no válido o no inicializado, la operación de guardado recurre silenciosamente a un directorio local.

Para evitar esto, debes asegurarte de dos cosas. En primer lugar, no debes tocar el sistema de guardado hasta que la interfaz de identidad del Online Subsystem informe que el jugador ha iniciado sesión por completo. En segundo lugar, debes resolver el UserIndex correcto y específico de la plataforma asociado al FUniqueNetId del usuario que inició sesión, en lugar de asumir que el controlador del jugador local es siempre el índice 0. Implementemos un sistema que garantice este flujo de trabajo.

Guía de implementación: cómo resolver la race condition

Paso 1: La clase de utilidad de guardado de GDK

Crea una clase personalizada UGDKSaveHelper que herede de UBlueprintFunctionLibrary. Esta clase contendrá la lógica para obtener el índice de usuario correcto y asegurar que el usuario haya iniciado sesión. Aquí tienes el archivo de cabecera (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);
};

Y aquí está el archivo de implementación (GDKSaveHelper.cpp), que utiliza el online subsystem para mapear de forma segura el ID de red del jugador local a su índice de usuario específico de la 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;
}

Paso 2: Modificación de tu lógica de guardado y carga

En la gestión del estado del juego mediante Blueprints o C++, debes envolver tus llamadas de carga. En lugar de llamar a LoadGameFromSlot inmediatamente al iniciar, implementa una comprobación que espere al inicio de sesión. Vincula esto al evento On Login Complete del subsistema de Online Identity. Una vez que el evento se active, verifica si el usuario ha iniciado sesión por completo, consulta su índice resuelto y pásalo directamente a los nodos de guardado de partida (save game nodes).

Si el inicio de sesión falla o el jugador lo cancela, debes gestionar el juego en un estado de 'invitado'. Advierte al jugador que su progreso no se guardará en la nube y permanecerá local en la máquina actual. Esto garantiza que el jugador esté siempre al tanto del estado de su guardado, evitando pérdidas inesperadas de progreso al cambiar de dispositivo.

Paso 3: Alinear los archivos de configuración

Incluso si tu código resuelve el índice de usuario correcto, el save roaming fallará si los archivos de configuración de los paquetes GDK de Xbox y GDK de PC no coinciden. Abre tu DefaultEngine.ini y asegúrate de que los ajustes del online subsystem de GDK sean correctos. El Title ID y el Service Configuration ID (SCID) deben coincidir exactamente con tu registro en el Microsoft Partner Center.

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

[OnlineSubsystem]
DefaultPlatformService=GDK

En tu MicrosoftGame.config (que empaqueta tu aplicación), verifica que la identidad (Identity) y el Title ID coincidan exactamente con los valores del panel de 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>

Ten en cuenta que al realizar pruebas en un sandbox (por ejemplo, XDKS.1), tus cuentas de prueba deben añadirse al sandbox e iniciar sesión correctamente dentro de la Xbox App en PC. Si se inicia sesión en la Xbox App con una cuenta diferente a la de Windows Store, la API de Windows GDK no podrá inicializar el proveedor XGameSave, lo que provocará una falta de correspondencia en los directorios locales. Puedes verificar esto revisando la carpeta local AppData para ver dónde se están escribiendo los archivos de guardado.

Alternativa: Cloud saves sin platform lock-in mediante horizOn

Configurar las APIs de Connected Storage del GDK de Microsoft, manejar las alternativas (fallbacks) offline para invitados y mantener sandboxes idénticos en entornos de PC y consola puede requerir semanas de configuración y depuración. Incluso una vez que funciona, te encuentras atrapado en el ecosistema de Microsoft. Estos archivos de guardado no harán roaming a Steam, Epic Games Store, PlayStation o Nintendo Switch.

En lugar de escribir wrappers específicos de la plataforma o recurrir a configuraciones complejas en tiempo real como en un Unreal Engine WebSockets tutorial para backends en tiempo real, puedes delegar tu arquitectura de estado de guardado por completo a un servicio de backend independiente de la plataforma como horizOn. Al utilizar horizOn, la gestión del estado de guardado se desacopla de las APIs específicas de la plataforma. Autenticas a los jugadores utilizando su identidad de plataforma (como Steam, Epic o Xbox Live) y guardas sus datos en una base de datos unificada en la nube.

Esto evita por completo los problemas de discordancia de sandbox en Xbox, lo que permite un verdadero save roaming multiplataforma entre Steam, Xbox y PlayStation. Aquí tienes un ejemplo conceptual de lo sencillo que es guardar el progreso del jugador globalmente utilizando la API REST del servicio en C++ de 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();
}

Construir tú mismo un sistema robusto de guardado en la nube requiere configurar bases de datos globales, gestionar tokens de sesión seguros y manejar conflictos de sincronización offline; lo que se traduce fácilmente en 4 a 6 semanas de tiempo de ingeniería dedicado. Con horizOn, estos servicios de backend vienen preconfigurados, lo que te permite lanzar tu juego en lugar de construir la infraestructura de bases de datos. Simplifica el pipeline, pasando de un dolor de cabeza de configuración multiplataforma a una sola llamada de API.

Buenas prácticas para una arquitectura de guardado multiplataforma

Ya sea que decidas abrirte paso a través de la configuración de Connected Storage del GDK de Microsoft o abstraerla con un servicio de backend, debes seguir estas buenas prácticas fundamentales:

  1. Nunca guardes en el Tick o al final del frame (Frame-End): Connected Storage limita las operaciones de escritura para evitar la degradación del disco y la ralentización (throttling) de la sincronización en la nube. Agrupa los guardados en lotes (batch) en puntos de activación específicos (checkpoints, transiciones de nivel, salidas de menús).
  2. Implementa resolutores de conflictos de guardado: Si un guardado en PC tiene una marca de tiempo de las 14:00 y un guardado en Xbox tiene una de las 14:05, no sobrescribas el archivo más antiguo a ciegas. Muestra un prompt en la interfaz de usuario (UI prompt) con el tiempo de juego, el progreso de nivel y los metadatos para que el jugador elija su estado activo.
  3. Valida la integridad de los archivos de guardado: Antes de escribir, serializa los datos de guardado y genera un hash MD5 o SHA-256. Guarda este hash dentro de un bloque de metadatos independiente para verificar que las descargas de archivos no se hayan corrompido durante el tránsito a la nube.
  4. Maneja los cierres de sesión de forma elegante: En GDK, los jugadores pueden cerrar sesión a través del dashboard de Xbox mientras el juego se está ejecutando. Regístrate en el delegado OnLoginStatusChanged y pausa el juego de inmediato si el usuario activo cambia.

Si estás listo para implementar un sistema de guardado en la nube escalable e independiente de la plataforma sin los dolores de cabeza de configuración de GDK, puedes empezar hoy mismo. Obtén más información probando horizOn gratis o revisando la API docs.


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