Gamer services internals

CNA snapshot 009d40f5  ·  Development › Module internals  ·  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 the CMake gates at the snapshot; the 14 test files (382 test-macro definitions) were located, none executed. Avatar rendering correctness, live-host validation on Windows or macOS and the Guide against a real windowing platform remain unverified; there is no online service to validate.

modules/gamer-services/ combines XNA-compatible signed-in gamer, Guide, achievement and leaderboard types with CNA's own local, JSON-backed persistence. It is built together with the network module under CNA_ENABLE_NET, but that build association must not be mistaken for a remote identity or an Xbox Live integration: nothing in the module talks to a service. This page is for maintainers who change sign-in lifetime, the local store, the Guide overlays or the fake-async operations, and who need to know which objects own which pointers and which callbacks run inline. The user-facing view is Tutorial 123 and the GamerServices row of XNA compatibility.

Composition and runtime entry

CNA_ENABLE_NET is declared in CMakeLists.txt with the description “Build CNA networking (GamerServices + Net, requires ENet)” and defaults to ON. modules/CMakeLists.txt enters gamer-services and net only inside that option, so the module is optional but present in an ordinary configure. Several presets in CMakePresets.json turn it off: dev, unit (and the presets that inherit it), release-modules, ios and ios-simulator; tests, web, macos, multi-renderer and cnaext leave the default. The C API is the one consumer that cannot do without it: the root file refuses CNA_BUILD_C_API=ON with networking off, because the C adapter links CNA_GamerServices and includes one of its headers (CMake options reference).

modules/gamer-services/CMakeLists.txt globs src/*.cpp (36 translation units at this snapshot) into a STATIC library CNA_GamerServices with the alias CNA::GamerServices. It links cna_build_config, cna_runtime and cna_storage publicly, so Guide and SignedInGamer reach Game, graphics, input and audio through the runtime layer and reach StorageDevice for the local store, plus the Sharp Runtime components Core.Base, IO, Collections.Core, Globalization, Runtime and Threading. The module also adds its own examples/ directory. Neither CNA_GamerServices nor CNA_Net appears in the _cna_runtime_parts list that defines the CNA umbrella, so a game links CNA_GamerServices explicitly (the module layout table says “linked explicitly, built with CNA_ENABLE_NET”).

It is also not an automatic Game component. An application adds a GamerServicesComponent to Game::Components; the component is the whole runtime entry:

Game + GamerServicesComponent                        (application adds it; nothing else does)
  Initialize -> GamerServicesDispatcher::setWindowHandleProperty(Game window handle)
              -> GamerServicesDispatcher::Initialize(Game services)      [does not call base]
                   isInitialized_ = true                                  (process static, never reset)
                   delete every SignedInGamer* in the current collection  (freedGamerCount_ += 1 each)
                   new SignedInGamer x 4 ("Stub Gamer" .. "Stub Gamer (3)")
                   Gamer::setSignedInGamersProperty(new SignedInGamerCollection(4))
                   SignedInGamer::OnSignIn(gamer) x 4                     (SignedIn raised now, once)
  Update     -> GamerServicesDispatcher::Update()     [empty body at this snapshot; does not call base]

SignedInGamer::AwardAchievement / LeaderboardEntry::setRatingProperty
  -> CNA::Internal::GamerServices::Save*EXT -> StorageDevice::GetStorageRootEXT()/GamerServices/<json>

Guide::BeginShowKeyboardInput / BeginShowMessageBox
  -> translation-unit-static pending action -> TextInputEXT subscription, or explicit RenderPending*EXT (SpriteBatch)
  -> completion callback + End* result

Both component overrides deliberately skip the base implementation, matching the FNA source the module was ported from. Because the dispatcher's Update is empty, everything that Initialize does happens synchronously at that call; there is no background login service and no per-frame work. GamerServicesDispatcher::UpdateAsync calls Update only when initialised and returns the initialised flag; it exists so the fake-async wait loops elsewhere have something to call, and no current loop ever needs it to make progress (see the hang note below). InstallingTitleUpdate is never raised.

One consequence for callers: SignedIn fires four times inside Initialize, so a handler subscribed after the component has initialised never sees those sign-ins and must read Gamer::getSignedInGamersProperty() instead. Another concerns tests: the in-process GamerServicesDispatcherTest cases never call Initialize and assert getIsInitializedProperty() is false, because the flag cannot be reset and would change UpdateAsync for every other test in the binary; and no test constructs a GamerServicesComponent at all, since that needs a live Game (a comment in GamerServicesServiceTests.cpp records why a fake was judged not worth it). Initialize is exercised out of process; see the test section.

Signed-in collection ownership and fake sign-in

GamerServicesDispatcher::Initialize creates four stub gamers with the internal factory SignedInGamer::CreateInternal. Their identity is fixed by the source:

IndexGamertagPlayerIndexIsGuestIsSignedInToLive
0Stub GamerOnefalsetrue (the initialised flag)
1Stub Gamer (1)Twotruetrue
2Stub Gamer (2)Threetruetrue
3Stub Gamer (3)Fourtruetrue

Nothing is ever signed in to anything; IsSignedInToLive merely copies the flag. The guest flags matter downstream: NetworkSession's constructor skips guests when it builds its default local-gamer list, so a session created without an explicit gamer list has one local gamer, the first stub. A game that wants other identities publishes its own collection with Gamer::setSignedInGamersProperty.

Who frees a gamer

GamerCollection.hpp states the rule once for the whole GamerServices and Net family: every GamerCollection<T> and every type built on it (SignedInGamerCollection, FriendCollection, the NetworkSession views) is a non-owning view of T*. Destruction and Clear never delete an element. The creator of a gamer owns it through a registry of its own, and the header names the three that exist: the dispatcher's free-before-replace loop (for the four stubs), NetworkSession::ownedGamers_ and ENetBackend::SessionState::OwnedRemoteGamers. The collection type also has no virtual destructor and must never be deleted through a base pointer.

For the dispatcher this produces the following order, which a maintainer must not disturb. Initialize first walks the collection installed by the previous call (an empty one, created lazily, the first time), deletes each SignedInGamer and counts it in a process static exposed as the CNAEXT member GetFreedGamerCountForTesting. It then creates four new gamers and hands a new SignedInGamerCollection to Gamer::setSignedInGamersProperty, whose replacement logic deletes only the previous wrapper (and does nothing if handed the same pointer again). Making the collection owning without reworking that sequence would double-free. Every raw Gamer* obtained before a second Initialize is dangling afterwards.

Two further hazards follow from the same reading. First, Initialize deletes whatever the currently installed collection contains, not specifically the stubs: an application that published its own heap-allocated gamers through setSignedInGamersProperty and later triggers another Initialize would have them deleted. Second, a Gamer must never be copied or moved once its LeaderboardWriter may be used: Gamer's constructor initialises that writer with this, no copy or move constructor re-points it, and the header says such an object's address must stay fixed (heap allocation, as the leaderboard demo does for its synthetic gamers, or a stable container). A SignedInGamer held by value (the internal factory returns one, as the tests use it) must not be copied or moved afterwards.

The initialised flag has no process-exit reset hook in C++ (FNA's ProcessExit hook is intentionally omitted), and the module is not a session with reliable sign-out: SignedOut exists, but SignedInGamer::OnSignOut has no caller in production code; only the test-access struct raises it. SignedInGamer::IsFriend always returns false, GetFriends returns an empty FriendCollection, and Gamer::GetFromGamertag and GetPartnerToken (and their Begin/End forms) throw NotSupportedException, so the existence of a public type is not evidence of a live social graph. SignedInGamerCollection::operator[](PlayerIndex) returns null for an index beyond the collection rather than throwing, and it indexes by position: it returns the element at that ordinal, where XNA 4.0 returns the gamer whose PlayerIndex matches. The two agree only while the collection is in PlayerIndex order, as the four stubs are; a collection published with Gamer::setSignedInGamersProperty in another order, or without the lower indices, answers differently (the tests only use PlayerIndex-ordered collections).

Fake-async operations and who frees their result

Every Begin* in this module returns a raw System::IAsyncResult* allocated with new (the shared Gamer::GamerAction class for gamer operations, private action types for the leaderboard and Guide). That differs from StorageDevice, whose Begin* members return std::unique_ptr<IAsyncResult>; the smart-pointer convention belongs to the storage module, and the phone module has no async results at all. Ownership is not uniform inside GamerServices either, which is where reviews should look first:

Begin callCompletesWho frees the result
Gamer::BeginGetProfileBefore returning; callback run inlineCaller. Gamer::GetProfile deletes it after EndGetProfile; EndGetProfile returns a new, caller-owned GamerProfile*.
SignedInGamer::BeginAwardAchievementBefore returning; the achievement is saved first, then the callback runsCaller. EndAwardAchievement only nulls the gamer's statStoreAction_; no overlap guard exists (the FNA one is disabled).
SignedInGamer::BeginGetAchievementsBefore returningCaller; GetAchievements deletes it. A second Begin before EndGetAchievements throws InvalidOperationException, and a caller that never calls End leaves that guard set.
LeaderboardReader::BeginRead, BeginPageDown, BeginPageUpBefore returningCaller. See the gap note below for the synchronous wrappers.
Guide::BeginShowKeyboardInput, BeginShowMessageBoxLater, when the user answers (or a test simulates it)Caller, but never while the request is still pending: the module keeps a process-static pointer to it.
NetworkSession::BeginCreate and friendsBefore returningThe matching End* deletes it; see the net page.

Every re-entrancy fix in this module has the same shape: the action pointer is captured (and the pending pointer cleared) before the callback runs, so a callback that immediately calls the matching End* or begins a new request sees consistent state and the Begin* still returns the pointer it created. Tests named for this pattern include BeginAwardAchievementCallbackCanReentrantlyCallEndAwardAchievement, BeginGetAchievementsCallbackCanReentrantlyCallEndGetAchievements and the Guide's CallbackCanReentrantlyBeginANewMessageBox.

Awarding and reading achievements

SignedInGamer::AwardAchievement writes only the earned key and a timestamp (DateTime::getNowProperty().getTicksProperty()) to the local store, and awarding an already-earned key updates that record's timestamp in place rather than adding a second one. EndGetAchievements rebuilds an Achievement per stored record with Achievement::CreateInternal(key, "", "", true, true, DateTime(ticks)): only key, earned flag and earned time are real. Name, description and score have no local source and stay empty or zero; EarnedOnline keeps its class default rather than being invented. GetAchievements polls in a loop that calls UpdateAsync, but the action is marked complete at construction, so the loop body never runs. That was not always true: with a real GamerServicesComponent the flag is permanently set and an action that was never completed made the loop spin forever. The same defect made NetworkSession::Create hang, and both are pinned by out-of-process regression tests described below.

Local JSON store and its failure model

LocalGamerServicesStore.cpp (declared in LocalGamerServicesStore.hpp) is a set of free functions in CNA::Internal::GamerServices. Its root is StorageDevice::GetStorageRootEXT() plus /GamerServices, with two sub-directories:

DataPath under the rootContent
Earned achievementsachievements/<gamertag>.json{"achievements":[{"key":"...","earnedTicks":N}]}
Leaderboard entriesleaderboards/<key>_<gameMode>.json{"entries":[{"gamertag":"...","rating":N,"columns":[{"key":"...","type":"...","value":V}]}]}

File-name components go through SanitizeStoreFileNameComponent: letters and digits (as reported by std::isalnum), -, _ and . are kept and every other byte becomes _, an empty result becomes _. The mapping is many-to-one, so two gamertags that differ only in punctuation share one file, and the sanitiser is what keeps a hostile name from leaving the directory; it does not make identities unique. The leaderboard file key is the sanitised LeaderboardIdentity key, an underscore and the game mode. PropertyDictionary columns persist as one of eight tagged types (int32, int64, double, single, string, dateTime, timeSpan, outcome); a Stream* value is skipped on write, and an unknown type tag on read is skipped rather than raising.

The storage root comes from the storage module, not from the platform layer: the environment-variable chain in StorageDevice::EnsureStorageRoot (see Storage internals and the user-level storage-root chain) followed by the app folder, which is the literal name game unless the game called SetAppNameEXT. The store recomputes its root on every call and caches nothing, so changing the app name after data was written silently starts a second, empty store; call SetAppNameEXT once, early. On the web nothing persists across a page load (Platforms: Web).

Failure model

  • Reads are forgiving. A missing or unparseable file yields an empty list; a record without the expected member types is skipped. Tests cover a corrupt achievement file (GetAchievementsHandlesMissingOrCorruptStoreFileGracefully).
  • Writes are best effort. WriteJsonFile creates the directory (ignoring the error code), writes <name>.json.tmp next to the target and renames it over the target; if the rename reports an error it falls back to a direct write. Stream failures on either write are not checked, so a full disk or a read-only directory is silent. This is not a transactional database and has no multi-process protocol.
  • Every save is read-modify-write of the whole file. Concurrent writers can lose each other's updates, and a corrupt file is treated as empty and then overwritten by the next save, discarding whatever else it held.
  • Numbers travel as JSON doubles. Json.hpp writes a number as an integer when it equals its truncated long long and is below 1e18, otherwise with %.17g. Ratings and int64 columns above 253 cannot round-trip exactly through a double, and .NET-style tick counts for present-day dates (about 6.4e17) are already past that limit, so they are rounded before they are written (derived from IEEE-754 arithmetic, not measured). No test in the module asserts an exact 64-bit round trip; check existing records before changing precision or the file format.
  • Test isolation is the tests' job. ResetStoreForTestingEXT removes the entire GamerServices directory. The gamer tests wrap their cases in a guard that sets the app name CnaTestsGamerServices and resets the store before and after, which places the data under the running user's real storage root.

Leaderboards: writer, entry and reader

Persistence is triggered by the rating, not by an explicit commit call. LeaderboardWriter::GetLeaderboard returns a pointer to a LeaderboardEntry stored by value in a std::map owned by that writer (so it is stable while the writer lives); on first access it seeds the entry from the store (rating 0 and no columns if nothing was written) and installs a hook so that setRatingProperty saves the rating and the current columns for the owning gamertag. Column edits therefore persist only at the next rating set. The writer keeps the owning Gamer* it was constructed with.

LeaderboardReader.cpp builds its board from the store on every BeginRead: it loads the leaderboard's entries, sorts them by rating descending with std::sort (ties have no defined order), and matches each stored gamertag against the currently signed-in Gamer* collection. A record with no signed-in match is skipped and takes no rank, so ranks are contiguous over the visible entries only, which with the stub dispatcher means only the four stub gamertags can appear. The reader keeps a complete cache and slices a page from it, so its total is the number of visible entries. The pivot overload centres the page on the pivot gamer (start 0 when the pivot is absent); the gamer-list overload restricts the board to the given gamers and marks it a friends board. The sort order, pivot centring and friends restriction are CNA-original: the referenced FNA stub throws everywhere. The reader's constructor keeps FNA's slice bound (i < pageSize), which is only correct for a first page, so every Begin* that supplies a real start calls ResliceEntriesEXT afterwards. Entries hold borrowed Gamer* pointers, so a second Initialize, or replacing the signed-in collection, leaves every previously returned entry pointing at freed gamers.

Guide input is application-rendered; many shells are stubs

Guide.cpp keeps two request slots as translation-unit statics: one pending keyboard request and one pending message box. Each Begin* rejects a second request while one is pending (InvalidOperationException), and getIsVisibleProperty is true exactly while either slot is occupied (its setter is a no-op). The overlays are not a native OS dialog service; they are drawn by the game.

Keyboard input

BeginShowKeyboardInput starts TextInputEXT, allocates the request, seeds its UTF-16 buffer from the default text and subscribes a lambda to TextInputEXT::TextInput. The event fires from the engine's own event pump (Game::PollEvents), so typing works without any Guide call from the game. Enter (code 13 or 10) confirms; Backspace edits and, for a surrogate pair, removes both halves; Home, End, Tab, Delete and the synthesised paste code are deliberately ignored rather than inserted; everything else is appended. Completion unsubscribes from TextInput and calls StopTextInput at that moment, not when the game later calls End, so text capture stops immediately.

Two things need the game's help. To show the prompt the game must call RenderPendingKeyboardInputEXT(device, spriteBatch, font, whitePixel) from its own Draw; the function draws a translucent box, the title, description, the typed text (masked with one * per UTF-16 code unit in password mode, with the real text still returned by End) and a hint line, and it also polls the keyboard for an Escape edge, which cancels. Without that call there is a working text capture and no picture, and no Escape cancel. EndShowKeyboardInput throws if the request has not completed or the result is not a keyboard action; a cancelled request returns an empty string, so a caller must ask WasKeyboardInputCanceledEXT to tell cancel from an empty confirm. A test or headless run can call SimulateKeyboardInputCancelEXT or raise the text event with a carriage return.

Message box

BeginShowMessageBox requires at least one button (ArgumentException) and stores title, text, buttons, focus index and icon in the request. It completes when RenderPendingMessageBoxEXT sees a left-button down edge inside a button rectangle, or when SimulateMessageBoxClickEXT(index) is called (range-checked). EndShowMessageBox returns a std::optional<int> button index and throws if the box has not been answered. Layout is single-line per field, with no word wrap; long text overflows the box.

Touch suppression and lifetime

A real console shell owns the screen while the Guide is up, so the module withholds touch input from the game for as long as a request is pending: SyncTouchInputSuppression calls TouchPanel::setInputSuppressedEXT whenever either slot changes. The click that answers a message box is withheld until the mouse button is released (a separate flag, cleared by the next RenderPendingMessageBoxEXT call), so the release does not arrive as a tap on whatever was behind the box. Tests: AVisibleKeyboardPromptWithholdsTouchInputFromTheGame, AVisibleMessageBoxWithholdsTouchInputFromTheGame, TheClickThatAnswersAMessageBoxDoesNotAlsoReachTheGame and ARaisedOverlayDiscardsGesturesAlreadyQueued. Because input subscription, touch suppression, the static pending pointers and the caller-owned result all outlive a single call, a change to any Guide path needs cases for reentrant callbacks, cancel, subscription removal and the graphics objects the render call receives. The Reset*ForTestingEXT helpers clear the pending pointers (the keyboard one also removes the subscription) without deleting the action or stopping text input; they are not a cleanup path for production code.

Compatibility shells

The remaining surface is empty: ShowSignIn, ShowFriends, ShowFriendRequest, ShowGamerCard, ShowComposeMessage, ShowMessages, both ShowGameInvite overloads, ShowParty, ShowPartySessions, ShowPlayers, ShowPlayerReview, ShowMarketplace, the CNAEXT ShowAchievementsEXT and DelayNotifications have empty bodies (fourteen Show* members plus DelayNotifications, the count Tutorial 123 uses). IsTrialMode, SimulateTrialMode and NotificationPosition are plain static values, and IsScreenSaverEnabled goes through the platform layer's display service (true when the platform supplies none). Do not document any of these as working because the names exist.

A safe human modification route

CnaGamerServicesTests is the focused target for this module's 14 test files (382 test-macro definitions by a line-anchored TEST/TEST_F/TEST_P count; not executed for this page). It is an EXCLUDE_FROM_ALL executable produced by cmake/UnitTests.cmake, which groups every modules/<name>/tests tree into an object library and links the focused executable against CNA_GamerServices only; the same files also feed the aggregate CnaTests. With CNA_ENABLE_NET=OFF the script filters the GamerServices and Net test sources out of the aggregate and fails the configure if any survive, so a green NET=OFF run says nothing about this module. Note the preset consequence above: cmake --preset unit configures with networking off and therefore has neither target.

  • GamerServicesGamerTests.cpp (82 macros) covers Gamer, SignedInGamer, the local store through achievements, and LeaderboardWriter, LeaderboardEntry and LeaderboardReader, including persistence across fresh objects, per-gamertag and per-identity isolation, sorting, pivot, restriction and paging.
  • GamerServicesServiceTests.cpp (52 macros) covers the dispatcher's un-initialised behaviour and every Guide path described above, driven through simulation helpers and a canned platform mouse. Avatar, collection, data, enum, event-argument and exception suites live in the other files of the same directory.
  • Where Initialize itself is tested: four cases in GamerServicesDispatcherHangRegressionTest (net tests) spawn a separate process running gamerservices_dispatcher_harness.cpp in a mode each: session creation after Initialize does not hang, GetAchievements does not hang, a second Initialize frees exactly the previous four gamers, and Initialize creates the four named stubs with the right PlayerIndex values and fires SignedIn four times. They belong to CnaNetTests, and the harness executable is registered in cmake/Harnesses.cmake whenever networking and tests are on. The test source itself is filtered out of CnaTests on Windows, Emscripten, Android and iOS because it spawns processes with POSIX APIs, so on those hosts the four cases do not exist rather than being skipped.

Recipes. For a dispatcher lifetime change, extend the harness modes rather than adding an in-process test, then check the Guide and leaderboard consumers that hold borrowed pointers. For a persistence change, keep the test guard's isolated app name, exercise missing, corrupt and truncated files, and inspect both the achievement and the leaderboard formats. For Guide, test callback re-entrancy, cancel, input-subscription removal, touch suppression and the lifetime of the graphics objects passed to the render call. A green local suite cannot establish online-service behaviour or how a real shell would present these dialogs.

⚠

Source-observed gap (not measured). Unlike Gamer::GetProfile and SignedInGamer::GetAchievements, the synchronous LeaderboardReader::Read, PageDown and PageUp wrappers never delete the LeaderboardAction their Begin* call allocates. Each call therefore appears to leave one small heap object behind. The action type is private to the translation unit and has no instance counter, so no test observes it.

Curated source tour and remaining uncertainty

  1. gamer-services/CMakeLists.txt, the composition block of modules/CMakeLists.txt and the NET filter in UnitTests.cmake: establish the optional build and test gate.
  2. GamerServicesComponent.cpp, GamerServicesDispatcher.cpp and Gamer.cpp: trace initialisation, the borrowed collection pointers and their replacement, then read GamerCollection.hpp for the ownership rule.
  3. SignedInGamer.cpp, LocalGamerServicesStore.cpp, LeaderboardWriter.cpp and LeaderboardReader.cpp: connect the local facts to JSON and to the read-side gamer pointers.
  4. Guide.cpp, then TouchPanel.cpp for setInputSuppressedEXT, and the service tests: separate the implemented text and message flows from the empty compatibility shells.
  5. The dispatcher harness with its regression test: the only place Initialize runs under test.

What this page does not establish

This trace covers the sign-in, local-store and Guide boundaries. It does not audit the avatar family or every property format. For orientation only: the XNA-shaped AvatarRenderer in AvatarRenderer.cpp reports AvatarRendererState::Unavailable on every read and its Draw validates the 71-bone array and then does nothing, as XNA's does; the CNAEXT members EnableRealRenderingEXT and DrawRealEXT draw a caller-supplied skinned model with a SkinnedEffect and per-part tints. Whether that rendering is correct on any renderer, how the avatar description and animation formats behave, and how the Guide overlays behave against a real windowing platform were not verified here. Live-host validation of anything that involves the local disk on Windows or macOS is likewise outside this page: the store's tests were located, not run, and none targets those hosts. The user-level statement of what works is Tutorial 123; ownership across the whole tree is in the ownership map, callback and thread rules in the thread and callback map, and the module list in the module index. For a first regression test see I need to add a regression test; the Maintainer Handbook lists the other routes.

Evidence level: everything above was checked by reading the TARGET sources at 009d40f5; nothing was built or executed, and the test names and counts are source facts, not pass results.

The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.