fix(godot_gdk): run game-save quota query off the main thread (#145) - #152
Conversation
XGameSaveFilesGetRemainingQuota is a synchronous GDK entry point that the runtime rejects with E_GS_ASYNC_FUNCTION_REQUIRED (0x8083000E) when it is called from a time-sensitive thread. GDKGameSave::get_remaining_quota was bound as a plain synchronous method, so every GDScript/C# caller issued it from Godot's main thread and it could never succeed. XGameSaveFiles exposes no async quota entry point, so the call is now driven through a custom XAsyncProvider that executes it on the shared task queue's work port and completes the pending signal from the completion port on the main thread. The public surface becomes get_remaining_quota_async(user) -> Signal, matching get_folder_async and the rest of the addon's async model. Verified against a signed-in Xbox user: the call previously failed with 0x8083000E and now reaches the service, returning the same configuration-side HRESULT as get_folder_async on an unconfigured title. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The GDK.game_save docs claimed the title must declare "connected storage / a SaveFolder" in MicrosoftGame.config. That is not an XGameSaveFiles requirement, and it sent anyone debugging E_GS_NO_ACCESS (0x80830002) to the wrong place. MicrosoftGame.config has no CloudSaves element at all, and its SaveGameStorage element configures the separate no-code cloud-saves feature. The actual prerequisites for XGameSaveFiles are Partner Center Xbox services configuration - TitleId + MSAAppId in MicrosoftGame.config, a SCID matching Partner Center, Connected Storage enabled under Gameplay settings > Title Storage - plus registered package identity, since the per-user store lives under %LOCALAPPDATA%\Packages\<package>\SystemAppData\xgs\. These are GDK-side requirements only. PlayFab Game Saves (PFGameSaveFiles, PlayFab.game_saves) is a separate system with its own portal, backing store, and quota; its prerequisites do not apply here. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Game Saves is backed by Connected Storage, which is a different system from Xbox Services Title Storage (GDK.title_storage). The previous wording read as though the Partner Center Title Storage page governed Game Saves, inviting the same confusion the issue reporter hit when they enabled every title storage type and still saw E_GS_NO_ACCESS. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes GDK.game_save.get_remaining_quota() always failing on Godot’s main thread by replacing it with an async API (get_remaining_quota_async()) that dispatches the synchronous GDK quota query onto the shared XTaskQueue work port, aligning it with the addon’s async model. It also updates docs/specs/tests and the C# wrapper to reflect the breaking change and clarified Connected Storage requirements.
Changes:
- Replaces
get_remaining_quota()withget_remaining_quota_async()(Signal-based) implemented via a customXAsyncProviderrunning on the task queue’s work port. - Updates GDScript tests and C# bindings to the new async quota API.
- Corrects/clarifies Game Save documentation and spec language around Connected Storage vs Title Storage and required title configuration.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/godot/gdk/tests/test_game_save.gd | Updates tests to call/await get_remaining_quota_async() and adds a regression guard for 0x8083000E. |
| spec/gdext-gdk.md | Updates the public API/spec contract and behavior notes for the new async quota method and Connected Storage requirements. |
| docs/gdk/api-reference.md | Updates the public API reference and usage snippet to the async quota method and corrected configuration guidance. |
| addons/godot_gdk/src/gdk_game_save.h | Updates the service method signature to get_remaining_quota_async() -> Signal. |
| addons/godot_gdk/src/gdk_game_save.cpp | Implements the async quota query using a custom XAsyncProvider and updates method bindings/validation paths. |
| addons/godot_gdk/doc_classes/GDKGameSave.xml | Updates engine docs XML for the new async method and revised service description. |
| addons/godot_gdk_csharp/Services/GdkGameSave.cs | Switches the C# API from sync GetRemainingQuota to GetRemainingQuotaAsync. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Copilot review flagged that get_remaining_quota_async() never binds a cancel handler. That is deliberate: the request wraps a single blocking XGameSaveFilesGetRemainingQuota call on a work-port thread that the GDK offers no way to interrupt, so XAsyncOp::Cancel is a no-op and forwarding to XAsyncCancel() could only race DoWork's XAsyncComplete(). The asymmetry with get_folder_async() was undocumented, which is what made it look like an oversight. Record the reasoning at the call site and in the public docs, and note that it is not caller-visible: both methods return a bare Signal and GDKPendingSignal is an internal class, so there is no caller-facing cancel path. Runtime shutdown still cancels and synchronously completes every pending signal, so no await can hang. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
addons/godot_gdk/src/gdk_game_save.cpp:163
- The custom XAsync provider’s
XAsyncOp::GetResultpath always returnsS_OK, even when the result buffer is null or too small. That can allowXAsyncGetResultto “succeed” without writing a value (and doesn’t report the required size viabufferUsed). It’s safer to setbufferUsedand return an insufficient-buffer HRESULT when the caller’s buffer can’t hold theint64_tresult.
case XAsyncOp::GetResult:
if (p_data->buffer != nullptr && p_data->bufferSize >= sizeof(int64_t)) {
*static_cast<int64_t *>(p_data->buffer) = context->get_remaining_quota();
}
return S_OK;
Reconciles main's game-save quota threading fix (#145 / #152) and the XBOX-on-PC export delegation fix (#144 / #151) with the GDK* -> Xbox* class rename. Conflict resolutions: - xbox_game_save.{h,cpp}: took main's async XAsyncProvider-backed get_remaining_quota_async(), renamed to XboxGameSave/XboxResult/ XboxRuntime/XboxPendingSignal/XboxSignalXAsyncContext/XboxUser. The resurrected gdk_game_save.h was dropped. - XboxGameSave.cs: took main's Task<XboxResult> GetRemainingQuotaAsync(); the resurrected GdkGameSave.cs was dropped. - gdk_editor_plugin.gd / gdk_export_features_plugin.gd: kept main's new export-features plugin, renamed its preload consts to Xbox*. - README.md: kept both main's badges/tagline and the branch's breaking- change warning. - doc_classes/XboxGameSave.xml, docs/gdk/api-reference.md, spec/gdext-gdk.md, tests/godot/gdk/tests/test_game_save.gd: took main's updated Connected Storage / async-quota prose with Xbox* type names. Validation: check_gd_scripts_headless.ps1 clean; run_all_tests.ps1 overall pass (471 GUT tests, 0 failed; live tier skipped, no -Live/-AllowLiveWrites). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
Fixes the first of two independent problems reported in #145:
GDK.game_save.get_remaining_quota()always failed withHRESULT 0x8083000E.XGameSaveFilesGetRemainingQuotais a synchronous GDK entry point with no async variant in theXGameSaveFilesfamily. The GDK rejects it withE_GS_ASYNC_FUNCTION_REQUIRED(0x8083000E) when it is called from a time-sensitive thread. Because the wrapper bound it viaClassDB::bind_methodas a plain synchronous method, every GDScript/C# caller invoked it on Godot's main thread — so the call could never succeed, for anyone.The method is now async and dispatches the native call to the shared
XTaskQueue's work port via a customXAsyncProvider, matching how the rest of the addon handles GDK async work.Breaking change
get_remaining_quota()is replaced byget_remaining_quota_async(), which returns aSignalinstead of aGDKResult. The old signature is removed rather than deprecated, because it had no working code path.Before
After
C# (
godot_gdk_csharp):Verification
Verified live against a signed-in Xbox user on two independently provisioned ATG titles, in two different sandboxes:
get_remaining_quota_asyncget_folder_async600D1FB3(GameSaveFilesCombo)76B1590E(NetRumble2)268435456 bytes is exactly the documented 256 MB default per-user quota. Before this change the same call returned
0x8083000Eon every title.A regression guard in
tests/godot/gdk/tests/test_game_save.gdasserts0x8083000Ecan never reach a caller again. It compareshresult & 0xFFFFFFFFbecauseGDKResult::m_hresultisint64_tand a negativeHRESULTsign-extends in GDScript.Documentation corrections
The investigation surfaced two prerequisite errors in already-touched Game Save docs, corrected here:
GDK.game_saverequires aSaveFolder/CloudSavesdeclaration inMicrosoftGame.config. No such requirement exists — the real element is<SaveGameStorage>, which configures the separate PC no-code cloud-saves feature and is not used byXGameSaveFiles.GDK.title_storage) is a different system. Enabling title/global/universal storage types does nothing for Game Saves.What this PR does not fix
#145 also reports
get_folder_async()failing with0x80830002(E_GS_NO_ACCESS). That is a title configuration issue, not a code defect, and is left for the reporter to resolve on the Partner Center side.Measured on one machine, same binary, same user (XUID
0009FFE9CFFF4D57):600D1FB3and76B1590E— game saves succeed.6184102E— game saves fail0x80830002, while achievements and Title Storage both returnS_OKusing the same SCID, sandbox, and user.Ruled out by direct experiment: the wrapper code, SCID, TitleId, package identity,
MSAAppId, andAdvancedUserModel. The only remaining variable is per-title Connected Storage provisioning.This is a notable diagnostic trap: working achievements and Title Storage do not imply Connected Storage is enabled, and the GDK's own error table sends you to check the SCID first. A follow-up to special-case the
E_GS_NO_ACCESSmessage is worth doing separately.Validation
tools\check_gd_scripts_headless.ps1— passedtools\run_all_tests.ps1— overall pass, all 7 stages (C++ doctest; GUT: gdk 309, playfab 81, gameinput 60; 13 bootstrap mini-runners)cmake --build build --preset debug— clean (doc XML re-embedded viadoc_source.cpp)-Live, no-AllowLiveWritesRefs #145