Skip to content

Make character rollbacks impossible - #50

Open
jaenster wants to merge 3 commits into
mainfrom
fix/character-save-rollbacks
Open

Make character rollbacks impossible#50
jaenster wants to merge 3 commits into
mainfrom
fix/character-save-rollbacks

Conversation

@jaenster

@jaenster jaenster commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Players reported characters being rolled back to an older state. The cause was not a race — no deployed game server was writing character saves at all, and the one server that did wrote them under the wrong key. Every character on the live realm read charver=1: created, and never saved since.

The starting state

server engines save path
d2gs-native 1.14d (deployed) cleared the engine's own -nosave flag, nothing replaced it; store.putChar had zero callers
d2host 1.06b–1.10f (deployed) fpSaveDatabaseCharacter was a stack-balancing stub
d2gs 1.14d wine implemented — but filed saves under the wrong account whenever its 16-slot join ring had recycled the player

realmd only ever wrote a save on create/copy/import/upgrade, so a login always restored whatever it had last written itself.

The structural fix

Rather than keep making every path above the store careful, the store now refuses to go backwards. realmd:charver becomes a compare-and-set token:

  • a load returns the bytes and the version they were at (version read first — the other order lets a save landing between the two reads be overwritten by bytes built from it);
  • a save is accepted only while the store is still at that version, with the whole compare-set-increment in one Lua script;
  • a version of 0 is accepted whatever the caller expected — that means redis lost its state and was refilled from postgres, where a live session's bytes are the newest thing in existence;
  • a refused save is never retried. It is obsolete by definition. Only an unreachable store parks a save.

Character names are realm-unique

Names were unique only within an account, while the seat tables, the retry queue, a departing player's seat release, and the Mac engine's own <charname>.d2s all key on the name alone. Two accounts with a "Bob" is not a rollback but a character swap. Claimed atomically in a charnames table — a table rather than an index on chars, because an index would be built over data that may already contain duplicates and would fail inside the startup schema bootstrap.

Other rollback paths closed

  • deleting a character that is in a game — the server holds it and writes it back, resurrecting it, or landing old bytes on a recreated one of the same name;
  • releasing a seat by matching a /name suffix over an unordered SMEMBERS, which could free a lock belonging to somebody still playing;
  • a leave now carries the account, so the seat is named rather than guessed;
  • the save-retry queue keyed by name alone, carried no fence, and had no age bound;
  • d2gs-native uploaded the previous session's .d2s on rejoin (clearFile only ran in file mode);
  • patch.zig dropped poisoned in skip()/rewind(), so a chain that had already refused an expect() still wrote.

Continuous saving

1.14d drops from 8192 game frames (~5.5 min) to 512 (~20s) by patching two immediates in UpdateClients. The pre-1.14 engines find the same site by its constant rather than by address — 0x80001fff is the signed-modulo-8192 idiom and nothing else in a game engine holds that number — and require exactly one match, refusing rather than guessing. All ten shipped D2Game.dll files have exactly one, in both encodings, with the fixup always at +7.

joinctx moves to packages/gs-seats, shared by all three servers, which previously had one table, another table, and none.

Verification

Run on the real engines under wine, not argued from source:

  • the interval patch applies on all six engines (D2Game.dll autosaves every 512 frames (~20s), was 8192);
  • a real client reaches a world on 1.14d and its charver climbs 1 → 2 → 3, the save landing ~21s after the fetch;
  • a save replayed at a superseded version is refused (-4), version unchanged, character bytes intact.

282 unit tests (from 226) and 43/43 e2e, including new save_fence, realm_unique_names, save_account_key and delete_in_game scenarios.

The e2e suite used to fail about one run in two: pg_isready answers from the temporary server postgres runs during first-boot initdb, which listens on a unix socket while docker has already published the port. It now waits for a query over TCP — 27 consecutive clean runs.

Note on the first commit

6daf188 carries uncommitted 1.13c work that was already in the working tree (cooperative drive model, D2Net drain reporting). It is committed separately so this change is readable on its own, but apps/d2host/main.zig interleaves the two and could not be split cleanly. Drop that commit if it belongs elsewhere.

Uncommitted work that was already in the tree, committed separately so the
save-rollback changes that follow are readable on their own.

process_all_games / dispatch_cleanup are the drive model a real 1.13c server
uses: process_all_games is the only caller of ServerGameLoop, which populates
newly-activated rooms and reveals them as a client moves. The per-game worker
model still gets a character into a world — the join sends the initial view —
and then nothing else ever happens.

D2Net reports each of the three drain loops once. They differ only by the list
index they push, so an ordinal on the wrong one is invisible: packets routed to
the loop the engine never asks for accumulate and are never seen, which reads as
the engine ignoring the player while the connection stays healthy.
No deployed game server was writing character saves at all. d2gs-native cleared
the engine's own -nosave flag and never replaced it; d2host answered
fpSaveDatabaseCharacter with a stack-balancing stub. Only the wine-injected
1.14d DLL implemented the slot, and that one filed saves under the wrong key
whenever its 16-slot join ring had recycled the player's entry. Every character
on the realm read charver=1: created, and never saved since.

The store now refuses to go backwards, rather than every path above it being
careful. realmd:charver is a compare-and-set token: a load returns the bytes and
the version they were at, and a save is accepted only while the store is still
at that version. The whole compare-set-increment is one Lua script. A version of
zero is accepted whatever the caller expected, because that means redis lost its
state and was refilled from postgres, where a live session's bytes are the
newest thing in existence. A refused save is never retried; it is obsolete.

Character names are now claimed realm-wide instead of per account. Names were
unique only within an account, while the seat tables, the retry queue, a
departing player's seat release and the Mac engine's own <charname>.d2s all key
on the name alone — two accounts with a "Bob" is not a rollback but a character
swap. The claim is a table rather than an index on chars, so it cannot fail at
startup on data that already contains duplicates.

Also closed: deleting a character that is in a game (the server holds it and
writes it back, resurrecting it or landing old bytes on a recreated one);
releasing a seat by matching a /name suffix over an unordered SMEMBERS, which
freed a lock belonging to somebody still playing; a leave now carries the
account so the seat is named rather than guessed; and the save-retry queue keyed
by name alone, carried no fence, and had no age bound.

Saving is continuous everywhere. 1.14d's interval drops from 8192 game frames
(~5.5 min) to 512 (~20s) by patching two immediates in UpdateClients. The
pre-1.14 engines find the same site by its constant instead of by address —
0x80001fff is the signed-modulo-8192 idiom and nothing else holds that number —
and require exactly one match, refusing rather than guessing. All ten shipped
D2Game.dll files have exactly one, in both encodings, with the fixup at +7.

joinctx moves to packages/gs-seats and is shared by all three servers, which
previously had one table, another table, and none.

Verified on the real engines under wine: the interval patch applies on all six,
a client reaches a world on 1.14d and its charver climbs 1 -> 2 -> 3, and a save
replayed at a superseded version is refused with the character left intact.

The e2e suite failed about one run in two because pg_isready answers from the
temporary server postgres runs during first-boot initdb, which listens on a unix
socket while docker has already published the port. It now waits for a query
over TCP. 27 consecutive clean runs.
@jaenster jaenster added the run-e2e Run the seven-engine e2e gate on this PR label Sep 7, 2026
The harness clears its characters from both stores between engines, but a name
claim outlives a row deleted behind the realm's back — leaving a name owned by a
character that no longer exists.

Harmless today, because every run uses the same account per engine and an
account may re-claim its own name. It stops being harmless the moment the
account naming changes, and the symptom would be a create refused as "name
taken" with nothing holding it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-e2e Run the seven-engine e2e gate on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant