CNA-BUG-167: LocalGamerServicesStore rounds 64-bit values through double and renames an unchecked temporary write over the store file
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
modules/gamer-services/include/CNA/Internal/GamerServices/LocalGamerServicesStore.hpp— PersistedAchievement::EarnedTicks documented as an exact round tripmodules/gamer-services/src/Internal/LocalGamerServicesStore.cpp— WriteJsonFile (unchecked stream, unconditional rename, fallback write); SaveEarnedAchievementEXT; ColumnsToJson; leaderboard rating savemodules/content/include/CNA/Internal/Json.hpp— JsonValue::MakeNumber takes a double; the writer's integer formatting
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.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- Known issues
- Bug index