CNA-BUG-167: LocalGamerServicesStore rounds 64-bit values through double and renames an unchecked temporary write over the store file

CNA snapshot 009d40f5  ·  Known Issues › Current bugs  ·  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. Nothing on this page was executed unless the Evidence section says so.

Achievement ticks, leaderboard ratings and Int64, DateTime and TimeSpan columns are stored as JSON doubles and lose low bits above 2^53, and WriteJsonFile ignores stream and directory errors, so a failed temporary write can replace a good store file.

Identifier
CNA-BUG-167
Category
Bug
Subsystem
Networking & gamer services
Status
Open
Verified against
CNA 009d40f5 (009d40f5dd085c4e674d3479675fac84b12b3e0a)
Severity
Low (a triage suggestion, not a project priority)
Evidence basis
Source-verified: read at TARGET, not executed
Tests touching this area
Yes: see Current tests
Affected contract
CNA::Internal::GamerServices::SaveEarnedAchievementEXT/LoadEarnedAchievementsEXT and the leaderboard entry and column persistence behind SignedInGamer::AwardAchievement, GetAchievements and LeaderboardEntry::setRatingProperty

Expected behaviour

LocalGamerServicesStore.hpp documents EarnedTicks as "The moment it was earned, as System::DateTime ticks (exact round-trip, no string parsing)". The temporary-file-and-rename write in LocalGamerServicesStore.cpp exists, by its own comment, so that "a crash/power-loss mid-write can never leave a half-written, unparseable store file behind".

Actual behaviour at TARGET

Precision. Earned ticks, ratings and Int64, DateTime and TimeSpan column values are stored with MakeNumber(static_cast<double>(...)), and JsonValue::numberValue is a double (Json.hpp). A 2026 DateTime is about 6.39e17 ticks, between 2^59 and 2^60, where adjacent doubles are 128 ticks apart: 639259364967890123 comes back as 639259364967890176. Ratings and Int64 columns above 2^53 are rounded the same way.

Write safety. WriteJsonFile ignores the error_code of create_directories, never checks the std::ofstream it writes the temporary file with, and renames the temporary file over the target whatever happened. If the write failed part-way (a full disk, say), the truncated file replaces the good one; the next read treats the unparseable file as empty, and the next save rewrites it with only the new record, so every earned achievement of that gamertag, or every entry on that board, is lost. The fallback taken when the rename fails writes the target directly, also unchecked.

Source locations

Evidence

Checked by reading at 009d40f5; no CNA code was run. The rounding figures are IEEE-754 double arithmetic, computed with Python floats (the same conversion as static_cast<double>), outside CNA. The full-disk sequence follows from the unchecked stream and the unconditional rename; it was not provoked. The gamer-services deep dive already states the precision loss, not the write hazard.

Independently observed as a separate finding (merged): EarnedTicks, ratings and Int64 leaderboard columns are written through JsonValue::MakeNumber(double), so present-day tick values are rounded to multiples of 128, although the header documents an exact round trip.

Focused reproduction

// Illustrative; not compiled or run for this entry.
using namespace CNA::Internal::GamerServices;
SaveEarnedAchievementEXT("tag", "first_boss", 639259364967890123LL);  // 2026-09-25 12:34:56.7890123
auto back = LoadEarnedAchievementsEXT("tag");
// back[0].EarnedTicks == 639259364967890176: rounded to the nearest double

Current tests

GamerServicesGamerTests.cpp checks that earned keys survive across objects and that a corrupt file reads as empty (GetAchievementsHandlesMissingOrCorruptStoreFileGracefully); GamerServicesDataTests.cpp compares DateTime values in memory only. No test asserts an exact 64-bit round trip or a failed write.

Regression test

Round-trip tests for a present-day tick value, a rating of 2^53 + 1 and an Int64 column near INT64_MAX (fixed by storing 64-bit integers as exact integer tokens or strings), and a test that makes the temporary write fail and asserts the previous file is unchanged (fixed by checking the stream before renaming).

Blast radius

Every local achievement timestamp (off by up to 64 ticks, 6.4 microseconds: harmless for display, wrong for exact comparison), leaderboard ratings and 64-bit columns above 2^53, and whole store files after a failed write. Int32, Single, Double, string and outcome columns are unaffected by the rounding.

Workaround

Keep ratings and Int64 columns below 2^53, and compare earned times with a tolerance.

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

Known issues
Bug index