Bloga Dön

GDK ve Windows Store Build'lerinde Unreal Engine Xbox Save Roaming Nasıl Düzeltilir

Yayınlanma tarihi 7 Temmuz 2026
GDK ve Windows Store Build'lerinde Unreal Engine Xbox Save Roaming Nasıl Düzeltilir

Özet olarak

Bu makale, Unreal Engine GDK ve Windows Store build'lerinde sıkça karşılaşılan Xbox save roaming senkronizasyon sorunlarını ve bunun arkasındaki asenkron giriş (race condition) nedenlerini ele almaktadır. Sorunun çözümü için doğru platform kullanıcı indeksini (UserIndex) çözümleyen C++ tabanlı bir yardımcı sınıf (utility class) ve yapılandırma adımları sunulmaktadır. Ayrıca, GDK yapılandırma karmaşasını aşmak ve platform bağımsız bulut save mimarisi kurmak için horizOn gibi alternatif backend çözümleri ile dikkat edilmesi gereken en iyi pratikler listelenmiştir.

GDK Save Roaming Kabusu

Xbox Series X ve Windows Store GDK build'lerinizi paketler, achievement'ların senkronize olduğunu doğrular, boot sırasında cloud sync ikonunun döndüğünü görür ve ardından oyuncunuzun save dosyalarının her iki platformda da tamamen izole kaldığını dehşetle izlersiniz. Kullanıcı perspektifinden her şey doğru görünür: Giriş işlemi (sign-in) başarıyla tamamlanır, bildirimler tetiklenir ve her iki build da oyunun her açılışında save dosyalarının lokal olarak saklandığını gösterir. Ancak Xbox üzerindeki ilerleme (progress) hiçbir zaman PC build'ine aktarılmaz (roam etmez) ve bunun tersi de geçerlidir.

Save verisi tutarsızlıklarıyla uğraşmak, canlı oyun sırasında multiplayer desync'lerini gidermeye çalışmak kadar sinir bozucudur; her iki sorun da state authority uyumsuzluğundan kaynaklanır. Xbox Play Anywhere (XPA) durumunda bu sorun nadiren konsol düzeyinde bir hatadır. Neredeyse her zaman, Unreal Engine'in soyut, tam sayı tabanlı user index'lerini Microsoft'un GDK'ye özgü identity sistemleriyle nasıl eşleştirdiğindeki bir race condition'dan kaynaklanır. Gelin bunun neden olduğuna ve C++ ile Blueprints üzerinde nasıl düzeltileceğine göz atalım.

GDK Connected Storage'ın Anatomisi

Save roaming'in neden başarısız olduğunu anlamak için öncelikle Microsoft GDK'in arka planda (under the hood) kullanıcı save dosyalarını nasıl işlediğini anlamamız gerekir. Save dosyalarını genellikle uzak bir sunucuya yüklenen ham dosyalar olarak ele alan Steam veya özel oyun backend'lerinin aksine, Microsoft Game Development Kit (GDK) Connected Storage (XGameSave API'si) kullanır. Bu mimari altında save'ler ham dosya yolları değil, yapılandırılmış konteyner (container) sistemleridir. Bu konteynerler, Xbox Live sunucularıyla güvenli bir şekilde senkronize edilen bağımsız save dosyası bloblarını barındırır.

Bulut save roaming'in Xbox konsolu ile Windows Store (PC) build'leri arasında çalışabilmesi için GDK, katı bir eşleşme matrisine güvenir. Her iki build'ın da birebir aynı Title ID, Service Configuration ID (SCID) ve Sandbox'ı (örneğin RETAIL veya XDKS.1) paylaşması gerekir. Ek olarak oyuncunun, her iki cihazda da aynı Microsoft Account (MSA) hesabıyla giriş yapmış olması şarttır. Son olarak, save sisteminiz tarafından oluşturulan konteyner isimleri karakteri karakterine eşleşmelidir; aksi takdirde cloud syncing sistemi bunları birbirinden bağımsız veri kümeleri olarak ele alacaktır.

Ayrıca Connected Storage, kullanıcı ve oyun başına maksimum 256 megabayt depolama alanı ayırır ve her bir konteyner için 16 megabayt limit uygular. Eğer save sisteminiz bu katı limitleri aşarsa, API sessizce başarısız olur veya Unreal Engine'in varsayılan save sisteminin yüzeye çıkarmadığı (bubble up yapmadığı) hata kodları döndürür. Save sistemlerinizi bu kısıtlamaları göz önünde bulundurarak tasarlamanız, dosya boyutlarınızın küçük ve yapılandırılmış kalmasını sağlamanız kritik önem taşır.

Asıl Sebep: Asenkron Login Race Condition Sorunu

Unreal Engine projelerinde GDK save roaming hatasının en yaygın nedeni, oyunun kimin save aldığını belirleme yöntemindeki ince bir race condition durumudur. Xbox Series X konsolunda işletim sistemi aktif kontrolcü profilleri üzerine kuruludur. Oyun boot olduğunda, OS düzeyinde bir kullanıcı zaten seçilmiştir. Konsol, oyunun execution context'ine otomatik olarak bir XUserHandle atar ve Unreal Engine bu aktif profili neredeyse anında UserIndex 0 ile eşleştirir.

Windows PC'de ise akış tamamen farklıdır. Oyun, aktif bir profil istemeden hemen boot olur. Xbox App veya Windows Store login akışı arka planda asenkron olarak çalışır. Eğer oyun kodunuz boot sırasında (örneğin bir GameInstance::Init çağrısında veya ana menünüzün BeginPlay fonksiyonunda) hemen LoadGameFromSlot veya DoesSaveGameExist çağırırsa, GDK Online Subsystem henüz kullanıcı sign-in işlemini tamamlamamış olur.

Unreal Engine'in ISaveGameSystem arayüzü (özellikle GDK entegrasyonu olan FGameSaveSystemGDK), bir kullanıcı sign-in yapmadan önce UserIndex = 0 ile çağrıldığında, lokal bir guest user'a fallback yapar. GDK daha sonra bu oturumu PC'deki lokal offline depolamaya yönlendirerek bulutla senkronize (cloud-synced) konteynerı bypass eder. Birkaç saniye sonra asenkron sign-in nihayet tamamlandığında, oyun zaten lokal verilerle çalışmaktadır. Eğer oyuncu yeni bir save yazarsa, oyun motoru bunu lokal guest profiline yazabilir veya yeni çözümlenen MSA konteynerına yazmaya çalışabilir; bu da konsoldaki bulut profili ile PC'deki lokal profilin tamamen farklı dosyalara sahip olduğu bir state split durumuna yol açar.

Unreal Engine, UserIndex'i XUserHandle ile Nasıl Eşleştirir?

Unreal Engine'in save sistemi, modern platform identity sistemlerinden çok önce tasarlanmıştı. Lokal co-op modundaki oyuncuları birbirinden ayırt etmek için basit bir tam sayı (UserIndex) kullanır. GDK entegrasyonunda motor, dahili GDK subsystem yardımcılarını çağırarak bu tam sayıyı bir XUserHandle değerine çözümlemeye çalışır. Eğer oyuncu indeksi geçersiz veya başlatılmamış (uninitialized) bir handle ile eşleşirse, save işlemi sessizce lokal bir dizine fallback yapar.

Bunu önlemek için iki şeyi garanti altına almalısınız. İlk olarak, Online Subsystem identity arayüzü oyuncunun tamamen log in olduğunu bildirene kadar save sistemine dokunmamalısınız. İkinci olarak, lokal player controller'ın her zaman 0 indeksine sahip olduğunu varsaymak yerine, giriş yapmış kullanıcının FUniqueNetId değeriyle ilişkili doğru ve platforma özgü UserIndex değerini çözümlemelisiniz. Gelin bu iş akışını (workflow) garanti eden bir sistemi uygulayalım.

Uygulama Rehberi: Race Condition Sorununu Çözmek

Adım 1: GDK Save Yardımcı Sınıfı (Utility Class)

UBlueprintFunctionLibrary sınıfından miras alan özel bir UGDKSaveHelper sınıfı oluşturun. Bu sınıf, doğru kullanıcı indeksini almak ve kullanıcının log in olmasını sağlamak için gerekli mantığı içerecektir. İşte başlık dosyası (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);
};

Lokal oyuncunun network ID'sini platforma özgü kullanıcı indeksine güvenli bir şekilde eşlemek için online subsystem'i kullanan uygulama dosyası (GDKSaveHelper.cpp) ise şöyledir:

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

Adım 2: Save ve Load Mantığınızı Düzenlemek

Blueprints veya C++ oyun durumu yönetiminizde (game state management), load çağrılarınızı sarmalamanız (wrap) gerekir. Açılışta hemen LoadGameFromSlot çağırmak yerine, sign-in işlemini bekleyen bir kontrol mekanizması uygulayın. Online Identity alt sisteminden gelen On Login Complete event'ine bind olun. Event tetiklendiğinde, kullanıcının tamamen log in olup olmadığını kontrol edin, çözümlenen indeksini sorgulayın ve bunu doğrudan save game node'larına aktarın.

Sign-in başarısız olursa veya oyuncu işlemi iptal ederse, oyunu bir 'guest' (misafir) durumunda yönetmelisiniz. Oyuncuyu, ilerlemesinin buluta kaydedilmeyeceği ve mevcut makinede lokal kalacağı konusunda uyarın. Bu sayede oyuncu her zaman kayıt durumundan (saving status) haberdar olur ve cihaz değiştirirken beklenmedik ilerleme kayıplarının önüne geçilir.

Adım 3: Konfigürasyon Dosyalarını Eşitlemek

Kodunuz doğru kullanıcı indeksini çözümlese bile, Xbox GDK ve PC GDK paketlerindeki konfigürasyon dosyaları eşleşmezse save roaming yine de başarısız olacaktır. DefaultEngine.ini dosyanızı açın ve GDK online subsystem ayarlarının doğru olduğundan emin olun. Title ID ve Service Configuration ID (SCID), Microsoft Partner Center kaydınızla birebir eşleşmelidir.

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

[OnlineSubsystem]
DefaultPlatformService=GDK

Uygulamanızı paketleyen MicrosoftGame.config dosyanızda, Identity ve Title ID değerlerinin Partner Center panelinizdeki değerlerle birebir uyuştuğunu doğrulayın:

<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>

Bir sandbox (örneğin XDKS.1) içinde test yaparken, test hesaplarınızın sandbox'a eklenmesi ve PC üzerindeki Xbox App uygulamasında düzgün bir şekilde sign in olması gerektiğini unutmayın. Eğer Xbox App, Windows Store'dan farklı bir hesaba giriş yapmışsa, Windows GDK API'si XGameSave sağlayıcısını (provider) başlatamaz (initialize edemez) ve bu da eşleşmeyen lokal dizinlere yol açar. Bunu, save dosyalarının nereye yazıldığını görmek için lokal AppData klasörünü kontrol ederek doğrulayabilirsiniz.

Alternatif: horizOn ile Platforma Bağımlı Kalmadan Cloud Save

Microsoft GDK Connected Storage API'lerini yapılandırmak, çevrimdışı guest fallback işlemlerini yönetmek ve PC ile konsol ortamlarında aynı sandbox'ları korumak haftalarca süren kurulum ve debugging gerektirebilir. Çalıştığında bile Microsoft ekosistemine bağımlı kalırsınız. Bu save'ler Steam, Epic Games Store, Playstation veya Nintendo Switch'e roam etmeyecektir.

Platforma özgü wrapper'lar yazmak veya gerçek zamanlı backend'ler için karmaşık Unreal Engine WebSockets eğitimi gibi yöntemlere başvurmak yerine, save state mimarinizi tamamen horizOn gibi platform bağımsız (platform-agnostic) bir backend servisine devredebilirsiniz. horizOn kullanarak, save state yönetimi platforma özgü API'lerden bağımsız hale gelir (decoupled olur). Oyuncuların kimlik doğrulamasını platform kimlikleriyle (Steam, Epic veya Xbox Live gibi) gerçekleştirir ve verilerini tek bir birleşik bulut veritabanına kaydedersiniz.

Bu, Xbox sandbox uyumsuzluğu sorunlarını tamamen bypass ederek Steam, Xbox ve PlayStation arasında gerçek bir cross-platform save roaming olanağı sağlar. İşte Unreal Engine C++ ortamında servisin REST API'sini kullanarak oyuncu ilerlemesini küresel olarak kaydetmenin ne kadar kolay olduğunu gösteren kavramsal bir örnek:

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

Kendi başınıza sağlam bir bulut save sistemi kurmak; küresel veritabanları kurmayı, güvenli oturum token'larını yönetmeyi ve çevrimdışı senkronizasyon çakışmalarını ele almayı gerektirir; bu da en az 4 ila 6 haftalık özel bir mühendislik süresi demektir. horizOn ile bu backend servisleri önceden yapılandırılmış olarak gelir, böylece veritabanı altyapısı inşa etmek yerine oyununuzu yayınlamaya (ship etmeye) odaklanabilirsiniz. Çoklu platform konfigürasyon baş ağrısını tek bir API çağrısına indirgeyerek süreci kolaylaştırır.

Platformlar Arası (Cross-Platform) Save Mimarisi İçin En İyi Pratikler

Microsoft GDK Connected Storage yapılandırmasıyla uğraşmayı seçseniz de, bunu bir backend servisi ile soyutlasanız da, şu temel en iyi pratiklere (best practice'ler) bağlı kalmalısınız:

  1. Asla Tick veya Frame Sonunda Kaydetmeyin (Never Save on Tick or Frame-End): Connected Storage, disk aşınmasını ve bulut senkronizasyonu sınırlamasını (throttling) önlemek için yazma işlemlerini sınırlandırır. Save işlemlerini belirli tetikleme noktalarında (checkpoint'ler, bölüm geçişleri, menü çıkışları) toplu (batch) olarak gerçekleştirin.
  2. Save Çakışma Çözümleyicileri (Save Conflict Resolvers) Uygulayın: PC'deki bir save 14:00 zaman damgasına (timestamp) sahipken Xbox'taki save 14:05 zaman damgasına sahipse, eski save'in üzerine körü körüne yazmayın. Oynama sürelerini, bölüm ilerlemesini ve metadata'ları gösteren bir UI ekranı sunarak oyuncunun aktif state'i seçmesine izin verin.
  3. Save Dosyası Bütünlüğünü Doğrulayın: Yazmadan önce save verinizi serialize edin ve bir MD5 veya SHA-256 hash'i oluşturun. Bulut aktarımı sırasında indirilen dosyaların bozulmadığını doğrulamak için bu hash'i ayrı bir metadata bloğu içinde saklayın.
  4. Oturum Kapatmaları (Sign-Out) Düzgün Yönetin: GDK'de oyuncular, oyun çalışırken Xbox dashboard üzerinden oturum kapatabilirler. OnLoginStatusChanged delegate'ine kaydolun (register olun) ve aktif kullanıcı değiştiğinde oyunu hemen duraklatın.

Eğer GDK yapılandırma baş ağrısıyla uğraşmadan, ölçeklenebilir ve platform bağımsız (platform-agnostic) bir bulut save sistemi kurmaya hazırsanız bugün başlayabilirsiniz. horizOn servisini ücretsiz deneyerek veya API belgelerini inceleyerek daha fazla bilgi edinebilirsiniz.


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