العودة إلى المدونة

كيفية إصلاح مشكلة Xbox Save Roaming في Unreal Engine مع إصدارات GDK و Windows Store

نُشر في 7 يوليو 2026
كيفية إصلاح مشكلة Xbox Save Roaming في Unreal Engine مع إصدارات GDK و Windows Store

باختصار

يوضح هذا المقال كيفية حل مشكلة فشل مزامنة ملفات الحفظ (save roaming) بين كونسول Xbox وأجهزة Windows PC عند استخدام حزمة GDK في Unreal Engine. يعود السبب الرئيسي للمشكلة إلى race condition أثناء عملية تسجيل الدخول غير المتزامن (asynchronous login) للمستخدم، مما يؤدي إلى استخدام ملفات تعريف ضيف محلي وحفظ البيانات خارج السحابة. يقدم المقال حلاً برمجيًا باستخدام C++ لاستدعاء مؤشر المستخدم الصحيح من Online Subsystem وتحديث ملفات التكوين لتطابق لوحة تحكم Partner Center، بالإضافة إلى استعراض خدمة [horizOn](https://horizon.pm) كبديل سحابي مستقل عن المنصة لتسهيل هذه العملية وتجنب تعقيدات GDK.

كابوس GDK Save Roaming

تقوم بحزم إصدارات GDK الخاصة بك لأجهزة Xbox Series X و Windows Store، وتتحقق من مزامنة الإنجازات (achievements)، وترى مؤشر مزامنة السحابة يدور عند بدء التشغيل، ثم تراقب برعب كيف تظل ملفات الحفظ الخاصة بلاعبك معزولة تمامًا على كل منصة. يبدو كل شيء صحيحًا من منظور المستخدم: ينجح تسجيل الدخول، وتظهر الإشعارات، ويعرض كلا الإصدارين حفظ الملفات محليًا بين عمليات إطلاق اللعبة. ومع ذلك، لا ينتقل التقدم (roam) من Xbox إلى نسخة الـ PC على الإطلاق، والعكس صحيح.

إن التعامل مع تضارب بيانات الحفظ (save data) لا يقل إحباطًا عن استكشاف أخطاء multiplayer desyncs وإصلاحها أثناء أسلوب اللعب المباشر؛ فكلا المشكلتين تنبعان من عدم تطابق صلاحيات الحالة (state authorities). في حالة Xbox Play Anywhere (XPA)، نادرًا ما تكون هذه المشكلة فشلًا على مستوى الكونسول. بل هي دائمًا تقريبًا race condition في كيفية ربط Unreal Engine لمؤشرات المستخدم المجردة والقائمة على الأرقام الصحيحة (integer-based user indices) بأنظمة الهوية الخاصة بـ GDK من Microsoft. دعونا نلقي نظرة على سبب حدوث ذلك وكيفية إصلاحه في كل من C++ و Blueprints.

تشريح GDK Connected Storage

لفهم سبب فشل save roaming، يجب علينا أولاً فهم كيفية تعامل GDK من Microsoft مع ملفات حفظ المستخدم خلف الكواليس. على عكس Steam أو أنظمة الـ Backend المخصصة للألعاب، والتي تتعامل عادةً مع ملفات الحفظ كملفات خام يتم رفعها إلى خادم بعيد، يستخدم Microsoft Game Development Kit (GDK) ميزة Connected Storage (واجهة برمجة تطبيقات XGameSave API). بموجب هذه البنية، لا تكون عمليات الحفظ عبارة عن مسارات ملفات خام بل أنظمة حاويات مهيكلة (container systems). تحتوي هذه الحاويات على ملفات حفظ فردية بصيغة blobs تتم مزامنتها بشكل آمن مع خوادم Xbox Live.

لكي تعمل ميزة cloud save roaming بين كونسول Xbox وإصدارات Windows Store (PC)، يعتمد GDK على مصفوفة مطابقة صارمة. يجب أن يتشارك كلا الإصدارين نفس الـ Title ID، و Service Configuration ID (SCID)، والـ Sandbox (مثل RETAIL أو XDKS.1) تمامًا. بالإضافة إلى ذلك، يجب أن يقوم اللاعب بتسجيل الدخول إلى نفس حساب Microsoft (MSA) على كلا الجهازين. أخيرًا، يجب أن تتطابق أسماء الحاويات (container names) الناتجة عن نظام الحفظ لديك حرفًا بحرف؛ وإلا، سيتعامل نظام المزامنة السحابية معها كبيانات مستقلة تمامًا.

علاوة على ذلك، يخصص Connected Storage بحد أقصى 256 ميجابايت من التخزين لكل مستخدم، لكل لعبة، مع حد أقصى للحاوية الواحدة يبلغ 16 ميجابايت. إذا تجاوز نظام الحفظ الخاص بك هذه الحدود الصارمة، فستفشل واجهة برمجة التطبيقات (API) بصمت أو تعيد رموز خطأ لا يقوم نظام حفظ Unreal Engine الافتراضي بإظهارها للسطح. من الضروري تصميم أنظمة الحفظ الخاصة بك مع مراعاة هذه القيود، للتأكد من أن أحجام ملفاتك تظل صغيرة ومنظمة.

السبب الرئيسي: الـ Race Condition في تسجيل الدخول غير المتزامن (Asynchronous Login)

السبب الأكثر شيوعًا لفشل GDK save roaming في مشاريع Unreal Engine هو وجود race condition خفي في كيفية تحديد اللعبة لهوية من يقوم بالحفظ. على كونسول Xbox Series X، تم بناء نظام التشغيل حول ملفات تعريف الكنترولر النشطة (active controller profiles). عندما تبدأ اللعبة، يكون المستخدم قد تم اختياره بالفعل على مستوى نظام التشغيل. يقوم الكونسول تلقائيًا بتعيين XUserHandle لسياق تنفيذ اللعبة، ويقوم Unreal Engine بربط ملف التعريف النشط هذا بـ UserIndex 0 على الفور تقريبًا.

على أجهزة Windows PC، يختلف مسار العمل تمامًا. تبدأ اللعبة في العمل على الفور دون المطالبة بملف تعريف نشط. يعمل تدفق تسجيل الدخول الخاص بـ Xbox App أو Windows Store بشكل غير متزامن (asynchronously) في الخلفية. إذا قام كود لعبتك باستدعاء LoadGameFromSlot أو DoesSaveGameExist فور بدء التشغيل مباشرة (على سبيل المثال، في استدعاء GameInstance::Init أو BeginPlay للقائمة الرئيسية)، فلن يكون GDK Online Subsystem قد أكمل تسجيل دخول المستخدم بعد.

عندما يتم استدعاء نظام ISaveGameSystem في Unreal Engine (وتحديدًا تطبيق GDK، وهو FGameSaveSystemGDK) باستخدام UserIndex = 0 قبل تسجيل دخول المستخدم، فإنه يتراجع إلى مستخدم ضيف محلي (local guest user). بعد ذلك، يقوم GDK بربط هذه الجلسة بالتخزين المحلي غير المتصل بالإنترنت على الكمبيوتر الشخصي، متجاوزًا الحاوية المتزامنة مع السحاب. وبمجرد اكتمال تسجيل الدخول غير المتزامن أخيرًا بعد بضع ثوانٍ، تكون اللعبة قيد التشغيل بالفعل بالبيانات المحلية. إذا كتب اللاعب حفظًا جديدًا، فقد يقوم المحرك بكتابته في ملف تعريف الضيف المحلي أو يحاول الكتابة في حاوية MSA التي تم حلها حديثًا، مما يؤدي إلى انقسام الحالة (state split) حيث يحتوي الملف الشخصي السحابي على الكونسول والملف الشخصي المحلي على الـ PC على ملفات مختلفة تمامًا.

كيف يقوم Unreal Engine بربط UserIndex بـ XUserHandle

تم تصميم نظام الحفظ الخاص بـ Unreal Engine قبل وقت طويل من ظهور أنظمة هوية المنصات الحديثة. فهو يستخدم عددًا صحيحًا بسيطًا (UserIndex) للتمييز بين اللاعبين في اللعب التعاوني المحلي (local co-op). في تطبيق GDK، يحاول المحرك تحويل هذا العدد الصحيح إلى XUserHandle عن طريق استدعاء أدوات مساعدة داخلية لـ GDK subsystem. إذا تم ربط مؤشر اللاعب بمقبض (handle) غير صالح أو غير مهيأ، فإن عملية الحفظ تتراجع بصمت إلى دليل محلي.

لمنع ذلك، يجب عليك التأكد من أمرين. أولاً، يجب ألا تلمس نظام الحفظ حتى تبلغ واجهة هوية الـ Online Subsystem أن اللاعب قد سجل دخوله بالكامل. ثانياً، يجب عليك تحديد الـ UserIndex الصحيح الخاص بالمنصة والمصاحب لمعرّف FUniqueNetId الخاص باللاعب الذي قام بتسجيل الدخول، بدلاً من افتراض أن متحكم اللاعب المحلي (local player controller) هو دائمًا المؤشر 0. دعونا ننفذ نظامًا يضمن مسار العمل هذا.

دليل التنفيذ: حل الـ Race Condition

الخطوة 1: فئة أدوات الحفظ المساعدة لـ GDK (GDK Save Utility Class)

أنشئ فئة مخصصة 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 لربط معرّف الشبكة الخاص باللاعب المحلي بأمان بمؤشر المستخدم الخاص بالمنصة:

#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 أو إدارة حالة اللعبة (game state management) في C++، يجب عليك تغليف استدعاءات التحميل الخاصة بك. بدلاً من استدعاء LoadGameFromSlot مباشرة عند بدء التشغيل، قم بتنفيذ فحص ينتظر تسجيل الدخول. اربط بالحدث On Login Complete من Online Identity subsystem. بمجرد إطلاق الحدث، تحقق مما إذا كان المستخدم قد سجل دخوله بالكامل، واطلب مؤشره الذي تم حله، ومرره مباشرة إلى عقد (nodes) حفظ اللعبة.

إذا فشل تسجيل الدخول أو ألغاه اللاعب، فيجب عليك التعامل مع اللعبة في حالة "ضيف" (guest). حذر اللاعب من أن تقدمه لن يتم حفظه في السحابة وسيظل محليًا على الجهاز الحالي. يضمن ذلك أن يكون اللاعب دائمًا على دراية بحالة الحفظ الخاصة به، مما يمنع فقدان التقدم بشكل غير متوقع عند تبديل الأجهزة.

الخطوة 3: مطابقة ملفات التكوين (Configuration Files)

حتى لو قام الكود الخاص بك بحل مؤشر المستخدم الصحيح، فإن عملية save roaming ستفشل إذا لم تتطابق ملفات التكوين (configuration files) في حزم Xbox GDK و PC GDK. افتح ملف DefaultEngine.ini وتأكد من صحة إعدادات GDK online subsystem. يجب أن يتطابق الـ 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 على الـ PC. إذا كان تطبيق Xbox App مسجلاً الدخول بحساب مختلف عن حساب Windows Store، فستفشل واجهة برمجة تطبيقات Windows GDK API في تهيئة موفر XGameSave (أي XGameSave provider)، مما يؤدي إلى عدم تطابق الأدلة المحلية. يمكنك التحقق من ذلك عن طريق مراجعة مجلد AppData المحلي لمعرفة أين يتم كتابة ملفات الحفظ.

بديل: الحفظ السحابي دون التقيد بمنصة معينة عبر horizOn

قد يستغرق إعداد وتصحيح أخطاء واجهات برمجة تطبيقات GDK Connected Storage من Microsoft، والتعامل مع حالات التراجع للاعبين الضيوف دون اتصال بالإنترنت (offline guest fallbacks)، والحفاظ على بيئات sandbox متطابقة عبر الـ PC والكونسول عدة أسابيع. وحتى بعد تشغيلها بنجاح، ستكون مقيدًا بالنظام البيئي لشركة Microsoft. لن تنتقل ملفات الحفظ هذه إلى Steam، أو Epic Games Store، أو Playstation، أو Nintendo Switch.

بدلاً من كتابة تغليفات برمجية خاصة بالمنصة (platform-specific wrappers)، أو اللجوء إلى إعدادات معقدة في الوقت الفعلي مثل Unreal Engine WebSockets tutorial لأنظمة الـ backend في الوقت الفعلي، يمكنك نقل بنية حالة الحفظ (save state architecture) الخاصة بك بالكامل إلى خدمة Backend مستقلة عن المنصة مثل horizOn. باستخدام horizOn، يتم فصل إدارة حالة الحفظ عن واجهات برمجة التطبيقات الخاصة بمنصة معينة. ستقوم بمصادقة اللاعبين باستخدام هويتهم على المنصة (مثل Steam، أو Epic، أو Xbox Live) وحفظ بياناتهم في قاعدة بيانات سحابية موحدة.

يتجاوز هذا مشكلات عدم تطابق Xbox sandbox تمامًا، مما يسمح بميزة cross-platform 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)، والتعامل مع تعارضات المزامنة دون اتصال بالإنترنت (offline sync conflicts) - وهو ما يستغرق بسهولة من 4 إلى 6 أسابيع من العمل الهندسي المخصص. ولكن مع horizOn، تأتي خدمات الـ Backend هذه مهيأة مسبقًا، مما يتيح لك شحن لعبتك بدلاً من بناء البنية التحتية لقاعدة البيانات. كما أنه يبسط مسار العمل من صداع تكوين منصات متعددة إلى استدعاء API واحد فقط.

أفضل الممارسات لبنية الحفظ متعددة المنصات (Cross-Platform Save Architecture)

سواء اخترت الكفاح لتكوين Connected Storage لـ GDK من Microsoft أو تبسيطه باستخدام خدمة Backend، يجب عليك الالتزام بأفضل الممارسات الأساسية التالية:

  1. لا تحفظ أبدًا عند كل Tick أو نهاية إطار (Frame-End): تفرض ميزة Connected Storage حدودًا على عمليات الكتابة لمنع تلف القرص وتقليل معدل مزامنة السحابة (throttling). قم بجمع الحفظ في دفعات (Batch) عند نقاط إطلاق معينة (نقاط الحفظ، انتقالات المراحل، أو الخروج من القائمة).
  2. قم بتنفيذ محللات تعارض الحفظ (Save Conflict Resolvers): إذا كان للحفظ على الـ PC طابع زمني (timestamp) عند الساعة 14:00 وحفظ Xbox طابع زمني عند الساعة 14:05، فلا تقم بالكتابة فوق الحفظ الأقدم بشكل أعمى. اعرض واجهة مستخدم (UI prompt) توضح أوقات اللعب، وتقدم المرحلة، والبيانات الوصفية (metadata)، مما يسمح للاعب باختيار حالته النشطة.
  3. التحقق من سلامة ملف الحفظ (Validate Save File Integrity): قبل الكتابة، قم بتسلسل بيانات الحفظ (serialize) وتوليد hash من نوع MD5 أو SHA-256. احفظ هذا الـ hash داخل كتلة بيانات وصفية (metadata) منفصلة للتحقق من أن تنزيلات الملفات لم تتلف أثناء الانتقال السحابي.
  4. التعامل بمرونة مع تسجيل الخروج (Gracefully Handle Sign-Outs): في GDK، يمكن للاعبين تسجيل الخروج عبر لوحة تحكم Xbox أثناء تشغيل اللعبة. قم بالتسجيل في مفوض OnLoginStatusChanged (delegate) وأوقف اللعبة مؤقتًا على الفور إذا تغير المستخدم النشط.

إذا كنت مستعدًا لتنفيذ نظام حفظ سحابي قابل للتوسع ومستقل عن المنصة بدون تعقيدات إعداد GDK، فيمكنك البدء اليوم. تعرف على المزيد من خلال تجربة horizOn مجانًا أو مراجعة API docs.


المصدر: Xbox/GDK XPA cross platform save roaming not working