GamerServices behaviour contract

CNA snapshot 009d40f5  ·  Deep Dives › Input, audio, media & services  ·  source links pinned to 009d40f5

✓

Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Checked by reading modules/gamer-services, the dispatcher harness and its regression test at 009d40f5; the fragments were syntax-checked with g++ -fsyntax-only (sibling sharp-runtime headers, not pinned by TARGET). Nothing was built or executed; the precision figures are IEEE-754 arithmetic over the source, not measurements.

XNA tied Microsoft::Xna::Framework::GamerServices to Xbox LIVE; FNA keeps most of the surface on PC and leaves the service-dependent paths inert. CNA goes further than FNA without pretending to be a service: it has offline local identities, achievements and leaderboards that persist to local files, and two Guide dialogs it draws itself, but no authentication, friends, matchmaking or remote account. This page is the behavioural contract, family by family: what each call returns, what is persisted and how exactly, what is inert and why. It is for porters deciding what to keep; Tutorial 123 teaches the task and the gamer services internals trace ownership for maintainers.

Three kinds of behaviour

Every member falls into one of three categories at this snapshot. Local: the API does real work in memory, on this machine. Locally persisted: the state survives the process in CNA's file store, under CNA-defined policy wherever Xbox LIVE used to supply the policy. Inert: the API shape exists, but no local source or service can perform the operation, so it does nothing, returns an empty or synthetic value, or throws. None of these is a claim of Xbox LIVE compatibility.

FamilyCategoryWhat that means in practice
Signed-in gamersLocalFour fixed identities created by the dispatcher; sign-in events at initialisation
AchievementsLocally persistedKey and earned time only; no catalogue metadata, no pictures
LeaderboardsLocally persistedCNA-defined sorting, ranking and paging over local records
Guide message box and keyboard inputLocalReal dialogs, drawn by the game through CNAEXT hooks
Other Guide::Show*, DelayNotificationsInertEmpty bodies
Presence, privileges, profileLocal (synthetic)Values exist; nothing publishes or enforces them
Friends, gamertag lookup, partner tokensInertEmpty collections, false, or NotSupportedException
Avatar XNA APIInert (faithfully)See Avatars

The whole namespace, with the network module, is built only when CNA_ENABLE_NET is on (its default), and a game links CNA_GamerServices explicitly.

Local identities

GamerServicesDispatcher::Initialize (GamerServicesDispatcher.cpp) creates four SignedInGamer objects named Stub Gamer, Stub Gamer (1), Stub Gamer (2) and Stub Gamer (3) for PlayerIndex::One to Four. The first is not a guest; the other three are. All four report IsSignedInToLive as true, because the property copies the dispatcher's initialised flag; nothing signs in to anything. SignedIn is raised four times inside Initialize, so a handler subscribed afterwards never sees those events and must read Gamer::getSignedInGamersProperty() instead; SignedOut has no production caller. A game that wants its own profiles publishes them with Gamer::setSignedInGamersProperty, and a second Initialize deletes whatever gamers the installed collection holds before creating four new ones.

The social surface is empty by construction: IsFriend returns false, GetFriends() returns an empty FriendCollection (the friend types work as containers; there is simply no source to fill them), and Gamer::GetFromGamertag, GetPartnerToken and their Begin/End pairs throw NotSupportedException.

⚠

Keep gamers at stable addresses. Every Gamer owns a LeaderboardWriter constructed with the gamer's own address, and the default copy and move operations copy that pointer unchanged. A gamer that is copied or moved after construction leaves its writer pointing at the old object. Heap-allocate gamers you create and pass pointers, as the leaderboard demo does.

Presence, privileges and profile

GamerPresence

XNA exposes SignedInGamer.Presence as a get-only property whose object is mutable. C++ needs a non-const accessor for the same idiom, so CNA adds the CNAEXT overload GamerPresence& getPresenceProperty() next to the XNA-shaped const one. GamerPresence (GamerPresence.cpp) holds a 60-entry table of XNA's display strings, including the parameterised templates such as "Level {0}", "Score {0}" and "Versus: Score {0}", and one deliberately empty entry. Setting PresenceMode stores the mode, indexes the table with the enum's ordinal and passes the resulting string to SetPresenceModeStringEXT (only when it differs from the previously selected one); setting PresenceValue stores the value and passes the current string again. The hook's body is empty. Nothing substitutes PresenceValue into {0}, and the selected string is private: no getter exposes it. The lookup is also wrong for almost every mode: the table is sorted alphabetically while GamerPresenceMode follows XNA's declaration order, so None selects "Arcade Mode", SinglePlayer selects "At Menu" and Winning selects the empty entry (CNA-BUG-173; read from the source, not executed). Nothing can observe that today, so the observable contract is just the two stored properties. There is no service to publish presence to, and a publisher attached to the hook would receive the wrong text until the table is fixed.

GamerPrivileges

GamerPrivileges is a fixed, fully permissive value: AllowCommunication, AllowProfileViewing and AllowUserCreatedContent are GamerPrivilegeSetting::Everyone, and AllowOnlineSessions, AllowPremiumContent, AllowPurchaseContent and AllowTradeContent are true. Nothing in the runtime consults them (only a demo and the C API routes read them back), so nothing is ever refused on their account. A game that honours parental-control privileges must supply its own policy.

GamerProfile

A GamerProfile is synthetic except for one field: GamerScore 0, GamerZone::Pro, Reputation 5.0, TitlesPlayed 1, TotalAchievements 0, an empty motto, and a GetGamerPicture() that returns nullptr; only Region is real, taken from the host through RegionInfo::getCurrentRegionProperty(). Gamer::BeginGetProfile completes before it returns and hands back a new, caller-owned profile. These three objects carry plausible local state; they are not an authentication source or a policy authority.

// gamer is a SignedInGamer* from Gamer::getSignedInGamersProperty()
gamer->AwardAchievement("ACH_FIRST_BOSS_DEFEATED");          // written to disk now
AchievementCollection earned = gamer->GetAchievements();

gamer->getPresenceProperty().setPresenceModeProperty(       // CNAEXT non-const overload
    GamerPresenceMode::SinglePlayer);                        // stored; published nowhere

This and the other fragments on the page were syntax-checked with g++ -std=c++23 -fsyntax-only against the snapshot's headers (sibling sharp-runtime headers for System); none was built or run.

The persistence contract

Achievements and leaderboards are the only persistent state in the namespace. LocalGamerServicesStore.cpp writes below StorageDevice::GetStorageRootEXT(), in a GamerServices directory with achievements/ and leaderboards/ beneath it. That root is the storage module's environment-variable chain, not the SDL preference path (see where the root is), so StorageDevice::SetAppNameEXT relocates saved achievements and leaderboards together with save files; call it once, early, before any award. The store recomputes the root on every call, so renaming the application later silently starts a second, empty store. On the web nothing persists across a page load.

  • Files. One achievements file per gamertag, {"achievements":[{"key":…,"earnedTicks":…}]}, and one leaderboard file per (key)_(gameMode) holding each entry's gamertag, rating and typed columns. File-name components keep letters, digits, ., _ and -; every other byte becomes _, and an empty name becomes _. The mapping is many-to-one: it keeps a hostile gamertag inside the directory, it does not make identities unique.
  • Writes. Each save rewrites the whole file through an adjacent .tmp file and a rename, so a crash cannot leave a half-written file in place of the old one; if the rename fails (a temporary file on another filesystem, for instance) it falls back to writing the target directly. Stream errors are not reported, and there is no multi-process protocol.
  • Reads. A missing or malformed file yields an empty store rather than an exception; a record without the expected members is skipped. The next save then overwrites the unreadable file.

Numbers are doubles

The JSON layer stores every number as an IEEE-754 double. Achievement ticks, leaderboard ratings and 64-bit column values therefore lose their low bits above 253, although the store's header comment describes earned ticks as an exact round trip. The effect on timestamps is concrete: a System::DateTime in 2026 is about 6.39 × 1017 ticks, between 259 and 260, where adjacent doubles are 128 ticks apart, so an earned time is rounded to a 12.8 µs step. The writer emits an integer whenever the already-rounded value is integral and below 1018, so the stored number is stable from the first save on; what is lost is the original tick value, not stability. This is arithmetic over the source (MakeNumber(static_cast<double>(…))); no test asserts an exact 64-bit round trip. Keep ratings below 253 if exact comparison matters.

Achievements

AwardAchievement(key) records the key and the current time for the gamer's gamertag; awarding an already-earned key updates its timestamp. GetAchievements() rebuilds one Achievement per stored record with the persisted Key, IsEarned true and EarnedDateTime (rounded as above). Name and Description are empty, GamerScore is 0, DisplayBeforeEarned is true, and EarnedOnline keeps its class default: Xbox LIVE supplied that catalogue metadata outside the AwardAchievement(string) call, and CNA has no equivalent catalogue, so it leaves defaults rather than inventing values. A game that shows names, descriptions or scores keeps its own catalogue keyed by achievement key. Achievement::GetPicture() throws NotImplementedException: artwork is another service-owned field with no local source. BeginGetAchievements refuses a second request while one is outstanding.

Leaderboards

LeaderboardWriter, LeaderboardReader and LeaderboardEntry are disk-backed, and their policy is CNA's own, because the FNA code CNA ports from has no functioning PC implementation to inherit sorting or paging from (its reader throws everywhere):

  • Entries sort by rating, descending; ties have no defined order.
  • Ranks (getRankingEXTProperty(), 1-based) are assigned over the visible entries only.
  • A persisted entry whose gamertag matches no currently signed-in Gamer is omitted and takes no rank; with the stub dispatcher only the four stub gamertags can appear.
  • Reading centred on a pivot gamer centres the page on that gamer; if the pivot is absent, the page starts at the top.
  • The Begin/End pairs complete before Begin returns.
  • There is no submit call, because XNA's LeaderboardWriter has none: assigning Rating on an entry obtained from the writer persists it immediately, together with the entry's current columns. Column edits alone are therefore persisted only at the next rating assignment.
  • An entry read through LeaderboardReader has no persistence hook. Changing its rating changes the object in memory only; the file is untouched.
LeaderboardWriter& writer = gamer->getLeaderboardWriterProperty();
LeaderboardIdentity board = LeaderboardIdentity::Create(LeaderboardKey::BestScoreLifeTime);

LeaderboardEntry* entry = writer.GetLeaderboard(board);   // owned by the writer
entry->setRatingProperty(newHighScore);                   // the assignment is the commit

The entry pointer stays valid while the writer (and so the gamer) lives. Entries returned by a reader hold borrowed Gamer* pointers, so a second Initialize or a replaced signed-in collection leaves them dangling.

The Guide dialogs

Two asynchronous Guide families are real, and unlike every other Begin call in the namespace they stay pending until the player answers: BeginShowMessageBox/EndShowMessageBox and BeginShowKeyboardInput/EndShowKeyboardInput (Guide.cpp). CNA has no system shell to host them, so the game draws them: once per frame, from its own Draw, between SpriteBatch::Begin and End, it calls the CNAEXT hook RenderPendingKeyboardInputEXT or RenderPendingMessageBoxEXT with the device, a SpriteBatch, a SpriteFont and a white-pixel texture. The hook draws a translucent box with the title, description and content, and polls the keyboard or mouse with edge detection.

  • Two slots, not one. There is one pending keyboard request and one pending message box, held in separate statics; each Begin refuses a second request of its own kind with InvalidOperationException, but a message box and a keyboard request can be pending at the same time. Guide::IsVisible is true exactly while either slot is occupied; its setter does nothing.
  • Keyboard input. Typed text arrives through TextInputEXT from the engine's event pump, so capture works even on a frame that draws nothing; Enter completes the request. Escape cancels only through the render hook, which is what polls for it. Text is kept as UTF-16 and backspace removes a whole surrogate pair. Password mode masks each displayed UTF-16 code unit with *, while EndShowKeyboardInput returns the real text. A cancel clears the text, and because a std::string cannot be null the way XNA's cancelled result is, call WasKeyboardInputCanceledEXT to tell a cancel from a confirmed empty string.
  • Message box. At least one button is required (ArgumentException); a click inside a button completes it, and EndShowMessageBox returns the chosen index as std::optional<int>.
  • Ownership and order. Calling End before the request completes throws InvalidOperationException. The caller owns the returned IAsyncResult* and deletes it after End, never while it is pending. While a dialog is pending, touch input is withheld from the game, and the click that answers a message box is not delivered as a tap on whatever was behind it.
System::IAsyncResult* pending = Guide::BeginShowKeyboardInput(
    PlayerIndex::One, "Enter passphrase", "Protects this save file",
    "", nullptr, std::any{}, /*usePasswordMode=*/true);

// In Draw(), every frame while pending != nullptr:
spriteBatch.Begin();
Guide::RenderPendingKeyboardInputEXT(device, spriteBatch, uiFont, whitePixel);
spriteBatch.End();

if (pending->getIsCompletedProperty()) {
    const bool canceled = Guide::WasKeyboardInputCanceledEXT(pending);
    std::string passphrase = Guide::EndShowKeyboardInput(pending);   // "" when canceled
    delete pending;                                                  // caller-owned
    pending = nullptr;
    if (!canceled) { /* use passphrase */ }
}

The service tests cover pending state, callback identity and re-entrant callbacks, Enter, Escape only through SimulateKeyboardInputCancelEXT (no test presses Escape through the keyboard poll inside RenderPendingKeyboardInputEXT; the one render-hook case only asserts that a frame with Escape up does not cancel), the prompt fields, password display, competing requests and touch suppression, driven through simulation helpers (SimulateKeyboardInputCancelEXT, SimulateMessageBoxClickEXT) and, for the message box's real click path, a canned platform mouse. Why the contract is spelled out this precisely is historical: an earlier implementation ignored the title and description, reported IsVisible as false, showed password text unmasked and had no cancel path, while its completed task record claimed the feature was done. The current code and tests cover all four, and the episode is the reason these behaviours are pinned individually.

The inert Guide surface

ShowSignIn, ShowFriends, ShowFriendRequest, ShowGamerCard, ShowComposeMessage, ShowMessages, both ShowGameInvite overloads, ShowParty, ShowPartySessions, ShowPlayers, ShowPlayerReview, ShowMarketplace, the CNAEXT ShowAchievementsEXT and DelayNotifications have empty bodies. A port that relied on ShowSignIn to create a profile must create or select one itself; an achievements screen must be drawn by the game from its own catalogue and the earned keys.

The dispatcher, the component and the hang

As on XNA 4.0 for Windows, adding a GamerServicesComponent to Game::Components is the normal way in: its Initialize passes the window handle to GamerServicesDispatcher and calls GamerServicesDispatcher::Initialize, which creates and frees the stub gamer set, and its Update forwards to the dispatcher; neither override calls the base, as in FNA. The component is what populates the signed-in gamers. The Guide dialogs and the storage-backed store do not depend on it, and a program without a Game can call GamerServicesDispatcher::Initialize directly (see Tutorial 97). GamerServicesDispatcher::Update() is empty: every local operation completes its own action.

That empty Update caused a real bug family. UpdateAsync() returns the initialised flag, so after initialisation it returns true forever. The XNA-idiomatic synchronous wrappers poll while (!result->IsCompleted) { if (!UpdateAsync()) … }, and in the ported code only a false return completed the action, so NetworkSession::Create, Find and Join, and once SignedInGamer::GetAchievements, spun forever at full CPU as soon as a GamerServicesComponent existed; the same hang reproduces against the FNA reference source. The fix is structural: every action those loops wait on is now marked complete when it is created, and the real work runs inside the matching End, so no loop needs the dispatcher to make progress (the network side is described in Network sessions). The dispatcher itself is unchanged, so any future Begin/End pair that does not complete its action at construction would reintroduce the hang.

Testing it needed a pattern of its own. The dispatcher's initialised flag is a process-lifetime static with no reset hook, and setting it inside the shared test binary would change UpdateAsync for every other test. So four regression cases in GamerServicesDispatcherHangRegressionTest.cpp spawn gamerservices_dispatcher_harness.cpp as a separate process under a ten-second watchdog, one mode each: session creation after initialisation does not hang, GetAchievements does not hang, a second initialisation frees exactly the previous four gamers, and initialisation creates the four named gamers with the right player indices and raises SignedIn four times. The harness uses posix_spawn, so on Windows, Emscripten, Android and iOS the cases are compiled out rather than skipped.

Evidence and what it cannot show

The public headers and implementations establish the API shapes, the fixed identities, the file layout, the Guide hooks and the inert members. Unit tests cover persistence (including a corrupt achievements file, per-gamertag isolation, sorting, pivots and paging), the Guide paths and the data types, and the out-of-process harness covers the dispatcher; all were located by reading and none was executed for this page. What no test here can establish is interoperation with Xbox LIVE, because CNA has no such transport, or how a console shell would present these dialogs. Persistence on Windows and macOS disks, and the stores' behaviour under concurrent writers, are likewise unverified.

The same subject is explained at several altitudes. These are the neighbouring pages at each one.