Skip to content

fix(godot_gdk): run game-save quota query off the main thread (#145) - #152

Merged
James Lenell (jameslen-atg) merged 5 commits into
mainfrom
fix/gamesave-issue-145
Aug 13, 2026
Merged

fix(godot_gdk): run game-save quota query off the main thread (#145)#152
James Lenell (jameslen-atg) merged 5 commits into
mainfrom
fix/gamesave-issue-145

Conversation

@jameslen-atg

Copy link
Copy Markdown
Member

Summary

Fixes the first of two independent problems reported in #145: GDK.game_save.get_remaining_quota() always failed with HRESULT 0x8083000E.

XGameSaveFilesGetRemainingQuota is a synchronous GDK entry point with no async variant in the XGameSaveFiles family. The GDK rejects it with E_GS_ASYNC_FUNCTION_REQUIRED (0x8083000E) when it is called from a time-sensitive thread. Because the wrapper bound it via ClassDB::bind_method as 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 custom XAsyncProvider, matching how the rest of the addon handles GDK async work.

Breaking change

get_remaining_quota() is replaced by get_remaining_quota_async(), which returns a Signal instead of a GDKResult. The old signature is removed rather than deprecated, because it had no working code path.

Before

var result = GDK.game_save.get_remaining_quota(user)   # always 0x8083000E

After

var result = await GDK.game_save.get_remaining_quota_async(user)
if result.ok:
    print("Remaining game save quota: %d bytes" % result.data["bytes"])
else:
    push_error("Quota query failed: %s (0x%08X)" % [result.message, result.hresult & 0xFFFFFFFF])

C# (godot_gdk_csharp):

GdkResult result = await Gdk.GameSave.GetRemainingQuotaAsync(user);
if (result.Ok)
{
    GD.Print($"Remaining quota: {result.Data["bytes"]} bytes");
}

Verification

Verified live against a signed-in Xbox user on two independently provisioned ATG titles, in two different sandboxes:

Title Sandbox get_remaining_quota_async get_folder_async
600D1FB3 (GameSaveFilesCombo) XDKS.1 S_OK — 268435456 bytes S_OK — real path
76B1590E (NetRumble2) LYKHVW.0 S_OK — 268435456 bytes S_OK — real path

268435456 bytes is exactly the documented 256 MB default per-user quota. Before this change the same call returned 0x8083000E on every title.

A regression guard in tests/godot/gdk/tests/test_game_save.gd asserts 0x8083000E can never reach a caller again. It compares hresult & 0xFFFFFFFF because GDKResult::m_hresult is int64_t and a negative HRESULT sign-extends in GDScript.

Documentation corrections

The investigation surfaced two prerequisite errors in already-touched Game Save docs, corrected here:

  1. Docs claimed GDK.game_save requires a SaveFolder/CloudSaves declaration in MicrosoftGame.config. No such requirement exists — the real element is <SaveGameStorage>, which configures the separate PC no-code cloud-saves feature and is not used by XGameSaveFiles.
  2. Docs framed Game Saves as backed by Title Storage. Game Saves is backed by Connected Storage; Xbox Services Title Storage (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 with 0x80830002 (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):

  • Titles 600D1FB3 and 76B1590E — game saves succeed.
  • Title 6184102E — game saves fail 0x80830002, while achievements and Title Storage both return S_OK using the same SCID, sandbox, and user.

Ruled out by direct experiment: the wrapper code, SCID, TitleId, package identity, MSAAppId, and AdvancedUserModel. 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_ACCESS message is worth doing separately.

Validation

  • tools\check_gd_scripts_headless.ps1 — passed
  • tools\run_all_tests.ps1overall 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 via doc_source.cpp)
  • Live game-save calls verified manually against provisioned titles (see table above)
  • Live test tier not run — no -Live, no -AllowLiveWrites

Refs #145

Copilot AI added 3 commits August 13, 2026 13:45
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() with get_remaining_quota_async() (Signal-based) implemented via a custom XAsyncProvider running 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.

Comment thread addons/godot_gdk/src/gdk_game_save.cpp
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>
Copilot AI review requested due to automatic review settings August 13, 2026 21:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::GetResult path always returns S_OK, even when the result buffer is null or too small. That can allow XAsyncGetResult to “succeed” without writing a value (and doesn’t report the required size via bufferUsed). It’s safer to set bufferUsed and return an insufficient-buffer HRESULT when the caller’s buffer can’t hold the int64_t result.
        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;

Copilot AI review requested due to automatic review settings August 13, 2026 21:17
@jameslen-atg
James Lenell (jameslen-atg) merged commit f145dde into main Aug 13, 2026
5 checks passed
@jameslen-atg
James Lenell (jameslen-atg) deleted the fix/gamesave-issue-145 branch August 13, 2026 21:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

James Lenell (jameslen-atg) pushed a commit that referenced this pull request Aug 14, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants