# Per-turn state checksum Status: **validated on the real saves** (`verify/results/state-checksum/`). The replay loop in §5 is **designed but not run** — it needs VM140, which lane R holds. `state_checksum.py` computes a whole-state checksum of a `.sav` as a *tree* of per-subsystem and per-object digests that roll up to one root, and diffs two such trees to name the object that moved. ``` verify/state-checksum/ state_checksum.py the tool + library float_census.py classifies every float leaf (evidence for §3) stability_check.py identical bytes -> identical roots, coverage proved run_validation.sh regenerates verify/results/state-checksum/ test_state_checksum.py 38 tests ``` --- ## 1. Why a whole-state checksum, next to the per-function harness Every verification the project has today is **per-function**: hook a routine, compare the regions it declares, print a verdict. That verdict is bounded by the region declaration, and region declarations have been wrong in both directions: * B4 found three hooks that printed "0 diverged" while their region set was **empty**. * The harness audit (`sots-engine/docs/harness-audit.md`) found **23 undeclared side effects**. * B3's `TechTree::ProcessResearch` passed replace-mode by 40,300 items and was still wrong — by one unposted `EVENT_RESEARCH_OVERBUDGET` that no declared region covered (`findings/subsystems/events.md`). Those are three different failures of the same kind: the verdict was green because the evidence was narrow, not because the state matched. **Coverage is the evidence; the verdict is not.** A whole-state checksum is the complement. It never asks what a function declared. It asks whether the entire simulation state is still identical, using the strongest oracle this project has: End-Turn autosaves are **byte-identical across runs and across processes** (`findings/subsystems/determinism-oracle.md`). The two are not redundant. The per-function harness says *where in the code* a divergence started; the state checksum says *that* one exists and *which object* it landed on, and no region-declaration mistake can hide from it. ### 1.1 What makes this evidence rather than a comforting number **Coverage is proved, not declared.** After building the digest tree, the tool re-serialises the parse back into bytes and compares that with the inflated save, byte for byte (`audit_coverage`, on by default). When the reconstruction reproduces the stream, the whole file is a function of the digest's inputs — every tag, every value, every frame boundary — so no state change can be invisible to it. This is the one property that a hand-maintained region list can never have, and it is the direct answer to the empty-region-set failure: a `state_checksum.py` run that could not account for the file says `coverage: FAILED at inflated offset 0x…` and exits non-zero, instead of printing a clean root. Observed on every save on this host: ``` coverage: PROVED (603360 bytes rebuilt == inflated); 35031 leaves, 190807 value bytes ``` **It localises.** The root is the fold of a tree, and objects carry names, so a divergence is reported as `/Sim/players/Player[496 "Singularity"]/Status`, not "the hash moved". This is the same principle that made the harness guards useful — name `player+0x2b0`, do not just say something changed. --- ## 2. The digest tree ### 2.1 Shape The tree follows the save's own frame nesting (`SAVE_FORMAT.md` §3), with two foldings applied so that it reads as a subsystem tree rather than a flat item list: | fold | what it does | why | |---|---|---| | **object tables** | a run of `(PlayerID, Player{})` sibling pairs becomes one `players` group whose children are `Player[16 "re"]`, `Player[32 "Fane Lao"]`, … | `Sim` has 259 flat children; without this there is no "players subsystem" to point at | | **inline id lists** | `PlayerIDs` + n × `.` becomes one `PlayerIDs[]` node holding the values | one inserted id used to shift every following sibling and produce ~30 spurious "moves" per turn | Both foldings preserve order and byte span exactly; nothing is dropped, so the reconstruction audit still proves total coverage. The tables are `PAIR_GROUPS`, `NAME_FIELDS`, `ELEM_KEYS` and `INLINE_ID_LISTS` at the top of the module. ### 2.2 Naming * Object frames get their id **and** their in-file name: `Player[16 "re"]`, `Sys[112 "Gamma Cephei"]`, `Flt[1744 "Alpha Fleet"]`, `Des[592 "Armor"]`. The id is folded into the object's digest, so two objects with identical bodies and different ids do not collide. (The determinism-oracle byte diff could only say "1st of two Singularity records"; the tree says `Player[496 …]` and `Player[512 …]`.) `Ship` frames carry no name field on disk, so a ship is labelled by id alone — `Ship[1728]`. * NULL-named (`"."`) element frames are keyed by the first identifying child they carry: `Events/.[EvTurn=3]`, `ords/.[ordID=…]`. * A uniquely-named field gets no index at all (`/Sim/players/Player[16 "re"]/Status`). * Where an index is unavoidable it is the ordinal **among same-named siblings**, so an insertion elsewhere in the frame does not renumber everything after it. ### 2.3 Digest construction `blake2b-128`, length-prefixed and domain-separated at every level: ``` leaf = H("leaf", tag, kind, value_key) object = H("obj", H("id", id_tag, id_bytes), frame_digest) frame = H("frame", tag, child_digest...) group = H("group", group_name, member_digest...) list = H("list", tag, count_bytes, element_key...) root = H("save", float_policy, mask_preset, top_level_digest...) ``` Order is part of the state, so children are folded in file order. The float policy and the mask preset are folded into the **root** so a strict root and a lenient root can never be compared by accident. Note what is *not* hashed: the human labels of §2.2. Digests consume the **on-disk tag** and the id bytes; the `"re"` / `"Gamma Cephei"` part of a label is diagnostic metadata only. So improving the naming tables never invalidates a recorded root — verified in practice when `Flt`'s name tag was corrected from a guess to the on-disk `FtName` and every root stayed the same. ### 2.4 The digest depends on the reader, not only on the bytes Each leaf hashes its **inferred kind** alongside its bytes, and that kind comes from `save_reader.py`'s schema and its int/float classifier (`SAVE_FORMAT.md` §2). The same four bytes typed `int` and typed `float` produce different digests. That is correct for comparing two saves parsed by one reader, and dangerous for a chain recorded months earlier, so every run prints and every chain records a `readerFingerprint` (blake2b-64 of `save_reader.py`), and `verify_chain` says so loudly when it does not match: ``` !! chain was recorded under save_reader 3f1e…, this is fe5a6f7cd4ae7910 -- re-record before trusting a DIVERGE ``` It is deliberately *not* folded into the digest: a cosmetic reader edit should raise a warning, not invalidate every recorded root. ### 2.5 Masking is opt-in and audited Default is **no masking**, so the one known non-idempotent field set (§4) is *localised* rather than absorbed. `--mask resave` applies the canonicalisation `determinism-oracle.md` prescribes. Each rule is scoped to a path prefix, not just a tag name, and the run always reports what it hit: ``` policy: floats=bits mask=resave reader=fe5a6f7cd4ae7910 [masked: Checksumx1, Statusx8] ``` A mask that matches nothing prints `[mask matched NOTHING -- check the rule paths]`. A mask nobody audits is a hiding place, and this project has already been bitten once by a comparison that quietly covered nothing. --- ## 3. Float-parity policy This is the subtle part, and it has to be settled now rather than at port time, because the policy decides what a future x64/SSE standalone is *allowed* to differ by. ### 3.1 What the engine actually does From `findings/subsystems/formula-gaps.md`: * State is stored in **float32**. Literals in the code are float32 values widened to double (`0.05000000074505806`), so the constants themselves are exactly representable as singles. * `fnstcw` inside a hooked turn-pipeline call returns **`0x127f`**: precision control = 53-bit (double), rounding = round-to-nearest-even. The FPU is *not* left in 24-bit single precision by the D3D9 device. So an x87 intermediate rounds to **double** and the caller then narrows to float32 — two roundings, not one. * Integer rounding is `fistp`/`fild`, i.e. **ties-to-even**, not truncation and not C's round-half-away-from-zero. ### 3.2 The save is a narrowing boundary Everything the checksum sees is a 4-byte IEEE-754 single. Whatever precision the FPU carried internally, the values that reach the save have already been narrowed to float32. That is a useful property: the checksum compares state at exactly the level where an x87-vs-SSE difference either survived the narrowing or vanished in it. It also means the checksum cannot see a precision difference that got rounded away — which is the right behaviour, because a difference that does not survive into state is not a state difference. ### 3.3 The policies | policy | float leaf hashes | separates | default | |---|---|---|---| | `bits` | the raw 4 bytes, unchanged | everything, including `-0.0` vs `+0.0` and distinct NaN payloads | **yes** | | `canonical` | raw bytes, with `-0.0 → +0.0` and every NaN → one quiet NaN (`0x7fc00000`) | everything except those two | no | `canonical` also normalises a `bool` byte to 0/1. Nothing else is ever normalised. **Why `bits` is the default.** It is the only policy under which "the roots match" means "the state is identical". Everything else is a claim about which differences we have decided not to care about, and this project's whole lesson is that such claims must be earned, stated, and audited rather than assumed. **What `bits` catches:** any state difference at all, down to one ULP of one float32 in one object, plus every non-float change. **What it over-reports:** exactly two cases where a value-equal result can carry different bits — signed zero and NaN payload. x87 `FLD`/`FSTP` quiets a signalling NaN where SSE may not; a zero result can pick up a sign from a different rounding path. `canonical` exists for precisely those two, and for nothing else. **Corpus evidence** (`verify/results/state-checksum/float-census.txt`, 4 distinct saves, 4,474 float leaves): ``` 2337 ordinary 1114 positive zero 791 exact integer 232 FLT_MAX (0x7f7fffff) 'canonical' would change 0 leaf/leaves across the corpus (a no-op today). subnormals: 0 (each one is an x87/SSE parity risk) ``` No negative zero, no NaN, no infinity, no subnormals. So `canonical` is a **no-op below the root** on everything we have — verified by a test that fails the day that stops being true. The strict default therefore costs nothing today, and the lenient policy is available the moment a save contains a case that needs it. The 232 `FLT_MAX` leaves (58 per save) are worth flagging: `FLT_MAX` is `0x7f7fffff`, a finite normal value, **not** infinity. `events.md` corrected `formula-gaps.md` on exactly this point for the default `EvPos`. A checksum that treated "very large" as "infinite" would merge distinct states; `bits` cannot, and a test pins it. ### 3.4 Why there is no tolerant hashing policy A tolerant hash is a contradiction, and it is worth writing down rather than rediscovering: 1. **Quantisation moves the cliff, it does not remove it.** Round to *k* bits and two values one ULP apart still hash differently whenever they straddle a bucket boundary, while two values 2^k ULPs apart inside one bucket hash the same. You get both false positives and false negatives, with the boundaries in arbitrary places. 2. **It destroys the roll-up.** The point of the tree is that a differing parent digest lets you descend to the object. Under quantisation a parent can differ while every child is "close enough", and you cannot tell from digests alone. 3. **It makes the root uninterpretable.** "Roots match" would mean "match to within a tolerance nobody recorded". So the digest is always exact and **tolerance lives in the differ**. `--ulps N` classifies each leaf difference *after* it has been localised: ``` $ state_checksum.py A B --ulps 2 DIVERGED: 3 leaf difference(s) /Sim/players/Player[16 "re"]/IdealSuit: 11.106206893920898 -> 11.106207847595215 [1 ulp] <= 2 ULP ... floats: 3 differ, 3 within 2 ULP ``` The root stays strict; a human or a CI rule decides whether "three leaves, all ≤ 1 ULP" is an acceptable port artefact. That decision is then visible in the log, which is the whole point. This mirrors the existing harness vocabulary (`verify/harness/compare/TRACE_FORMAT.md`, per-hook `ftol`, default 0), rather than inventing a second one. Real output, from the turn 2 → turn 3 transition (§4.3): ``` /Sim/systems/Sys[112 "Gamma Cephei"]/RepCur: 336360.0 -> 336840.0 [15360 ulp] /Sim/systems/Sys[112 "Gamma Cephei"]/RepMax: 336360.0 -> 336840.0 [15360 ulp] /Sim/systems/Sys[288 "Ke'Dolarra"]/RepCur: 370000.0 -> 370520.0 [16640 ulp] /Sim/systems/Sys[288 "Ke'Dolarra"]/RepMax: 370000.0 -> 370520.0 [16640 ulp] floats: 4 differ, 0 within 2 ULP ``` That is the mode working as intended: four float leaves moved, and the ULP column says at a glance that all four are genuine simulation changes (tens of thousands of ULPs — resource pools growing over a turn), not float-path noise. Had a port produced `[1 ulp]` on these instead, the same line would say so and the judgement call would be an explicit one. ### 3.5 The x64/SSE budget — MEASURED 2026-09-08, no budget needed x87 with PC=53 rounds an intermediate to double and then to float32 — **double rounding**. SSE `mulss`/`addss` rounds once, directly to float32. For a minority of inputs those differ by one ULP, and one ULP in a float32 that feeds an `fistp` can cross a tie and change an integer. OpenRCT2 hit exactly this: "replays on x64 and x86 platforms will generate different sprite checksums" (`guides/re-windows-2000s-howto.md` §1.1). Under the strict default, a port that changes the float path **will** be reported as diverged. That is deliberate: it is a real state difference. The port's job is either to reproduce the double rounding (compute in double, narrow explicitly at each store — which is what `fpu_cw = 0x127f` makes the original do) or to accept a documented `--ulps` budget on a named list of fields. **SETTLED on the VM, 2026-09-08 (lane F).** It was not settleable from the host side — every save in the corpus was made at `fpu_cw = 0x127f`, one point rather than a curve — so lane F ran the End Turn seven times from `ref-turn2.sav` with the shim forcing the control word, and checksummed the results with this tool. Full write-up and the evidence chain that the setting actually *held* (38 independent in-pipeline samples per run): `findings/subsystems/fpu-precision-sensitivity.md`; artefacts in `verify/results/fpu-cw/`. | forced `fpu_cw` | precision / rounding | root vs baseline | |---|---|---| | stock, `0x027f`, `0x127f`, `0x137f` | 53-bit and **64-bit**, nearest | **identical**, all 35,394 leaves | | `0x007f` | **24-bit**, nearest | 1 leaf + derived `Summary/Checksum` | | `0x1a7f` | 53-bit, **round-up** | 2 leaves, 1 ULP each | **The answer for §3, in one line: 53-bit and 64-bit x87 give the same state, so an SSE port computing in IEEE `double` has no double-rounding budget to preserve and `floats=bits` is free.** What is *not* free is the two things the other two runs found: ``` 0x007f (24-bit): /Sim/systems/Sys[112 "Gamma Cephei"]/Pop2/PopG/PopC : 540000000 -> 540000002 0x1a7f (round-up):/Sim/fleets/Flt[34 "Beta Fleet"]/Pos/.[0] and /Pos/.[2] : 1 ulp each ``` So the port must (a) hold intermediates at 53 bits and narrow to float32 only where the original stores to a `dword` — never compute a chain in `float` — and (b) use round-to-nearest. Both are SSE defaults; they are now measured requirements rather than assumptions, each with a named regression witness on turn 2 of `ref-turn2.sav`. **Correction to the experiment as it was written here.** The three values proposed above span only *two* FPU modes. `0x027f` is 53-bit — it differs from `0x127f` only in bit 12 (infinity control), which every x87 since the 387 ignores — and `0x137f` is 64-bit extended, not a rounding change. Precision control is bits 8–9 (`00`=24, `10`=53, `11`=64) and rounding control is bits 10–11. The genuine single-precision word is `0x007f` and a genuine rounding change is `0x1a7f`; run as originally specified, all three settings come back identical and the tempting conclusion — "nothing depends on the control word" — would have been wrong on both of the axes that actually matter. Until that runs, "the checksum is exact and the port must match bit-for-bit" is a *policy*, not a measured requirement. It is the right default either way — it fails loudly rather than quietly — but it should not be described as validated. --- ## 4. What was validated, on which saves Regenerate with `verify/state-checksum/run_validation.sh`; outputs land in `verify/results/state-checksum/`. Saves are read from `$SOTS_SAVES_DIR` plus the in-repo `verify/results/saves/`, and the script skips cleanly when neither has anything. No `.sav` is copied into either repo. The corpus on this host is 10 files with **4 distinct contents** (the four the determinism work produced): `a3f9dc4b` turn-1 pre-turn, `ab4ac2d7` turn-2 post-turn, `bb4fd9ac` turn-2 pre-turn/manual (the loaded-and-re-saved form of `ab4ac2d7`), `978041ac` turn-3 post-turn. ### 4.1 Identical saves produce identical checksums, and every save is covered ``` reader fingerprint: fe5a6f7cd4ae7910 10 file(s), 4 distinct content(s) sha256:978041acd168b56e 2 file(s) STABLE + COVERED root 5ac4a24197e82de49f3077cd8dd25fad Autosave - turn3.sav, turn3-state.sav sha256:a3f9dc4b49fc669c 2 file(s) STABLE + COVERED root 9bbcbd4945cd8319b65d7d7958772ee0 Autosave EndTurn - turn2.sav, turn1-state.sav sha256:ab4ac2d7e2977260 3 file(s) STABLE + COVERED root aa85fe76d412cdb3a0e7e52c8f0ca9e2 Autosave - turn2.sav, Autosave Backup - turn2.sav, turn2-state.sav sha256:bb4fd9ac89f41e3b 3 file(s) STABLE + COVERED root a1448e6c1867fc53708810b3a2f9ec77 Autosave EndTurn - turn3.sav, MyGameverify1verify1.sav, verify1.sav VERDICT: all stable and fully covered ``` "COVERED" is the byte-for-byte reconstruction (§1.1), so this is a stronger statement than sha256 equality: the *parse* is deterministic and total, not just the bytes. ### 4.2 The known re-save difference is localised, not reported as a whole-state mismatch This is the deliverable that matters. `determinism-oracle.md` established that loading a post-turn autosave and re-saving it changes exactly five things. The tree names all five and nothing else: ``` $ state_checksum.py turn2-state.sav "Autosave EndTurn - turn3.sav" A aa85fe76d412cdb3a0e7e52c8f0ca9e2 verify/results/saves/turn2-state.sav B a1448e6c1867fc53708810b3a2f9ec77 $SOTS_SAVES_DIR/Autosave EndTurn - turn3.sav policy: floats=bits mask=none reader=fe5a6f7cd4ae7910 DIVERGED: 5 leaf difference(s) /Summary/Checksum: -1205790620 -> -1205790636 /Sim/players/Player[16 "re"]/Status: 4 -> 0 /Sim/players/Player[32 "Fane Lao"]/Status: 4 -> 0 /Sim/players/Player[496 "Singularity"]/Status: 4 -> 0 /Sim/players/Player[512 "Singularity"]/Status: 4 -> 0 $ state_checksum.py turn2-state.sav "Autosave EndTurn - turn3.sav" --mask resave A a55e433687fff0e380de2caabf964a31 B a55e433687fff0e380de2caabf964a31 policy: floats=bits mask=resave reader=fe5a6f7cd4ae7910 [masked: Checksumx1, Statusx8] IDENTICAL ``` Two things to note. The tree distinguishes the two `Singularity` players by id (496 and 512) where the raw byte diff could only say "1st of two Singularity records". And the masked run reports `Statusx8` — it replaced all eight `Player.Status` fields, of which four were already 0; that number is printed so the mask's reach is visible rather than assumed. ### 4.3 A real turn transition is fully attributed One real End Turn (`verify/results/state-checksum/turn2-to-turn3.txt`), 108 leaf differences, every one carrying a path: ``` /Summary/Turn: 2 -> 3 /Sim/FleetIDs[]: removed [1744], added [34, 1776] (7 -> 8 entries) /Sim/ShipIDs[]: removed [], added [1760] (16 -> 17 entries) /Sim/RNG/.: '' -> '' /Sim/players/Player[16 "re"]/Sav: 289688 -> 532369 /Sim/players/Player[16 "re"]/Events/EvNxID: 2 -> 3 /Sim/players/Player[16 "re"]/Events/Events/.[EvTurn=3]: only-in-B /Sim/players/Player[32 "Fane Lao"]/TechTree/TResDone[106]: 2879 -> 5768 /Sim/players/Player[32 "Fane Lao"]/Maint: 500 -> 1000 /Sim/players/Player[496 "Singularity"]/dipstats/.[1]/lastally: 2 -> 3 ... ``` That reads as a turn report: economy, research, the new fleet and ship ids, the advanced RNG state, and the new event bucket at `EvTurn=3` — which is the turn-bucketed layout `events.md` established, walked correctly by the tree. Of the 108 differences, 93 are scalar value changes, 13 are structural (12 nodes only in the turn-3 save, 1 only in turn 2), and 2 are id lists. **Only 4 are floats**, and all four are far outside any plausible tolerance: ``` /Sim/systems/Sys[112 "Gamma Cephei"]/RepCur: 336360.0 -> 336840.0 [15360 ulp] /Sim/systems/Sys[288 "Ke'Dolarra"]/RepMax: 370000.0 -> 370520.0 [16640 ulp] floats: 4 differ, 0 within 2 ULP ``` Worth knowing before budgeting float-parity work: on a two-player 28-system turn the float state barely moves, and what moves, moves a long way. Nothing in this corpus sits near a rounding boundary, so §3's question is about paths this corpus does not yet exercise. ### 4.4 Chain record and verify ``` recorded 3 turns -> chain-turn1-3.json turn 1 9bbcbd4945cd8319 cov-ok turn1-state.sav turn 2 aa85fe76d412cdb3 cov-ok turn2-state.sav turn 3 5ac4a24197e82de4 cov-ok turn3-state.sav $ state_checksum.py --chain chain-turn1-3.json turn1 turn2 turn3 turn 1 MATCH 9bbcbd4945cd8319 turn1-state.sav turn 2 MATCH aa85fe76d412cdb3 turn2-state.sav turn 3 MATCH 5ac4a24197e82de4 turn3-state.sav ``` and, substituting a wrong save at turn 2, the desync-log behaviour — stop at the first divergent turn, name the subsystems: ``` turn 1 MATCH 9bbcbd4945cd8319 turn1-state.sav turn 2 DIVERGE a1448e6c1867fc53 != aa85fe76d412cdb3 MyGameverify1verify1.sav subsystem /Summary: 42b2094efe6e285a -> 8c7e33a33dc76b3d subsystem /Sim/players: fa7f5ad40afed208 -> 3416ca2128671b1e ``` The saves in this chain are the ones the game actually wrote across two End Turns, so the chain *mechanics* are validated on real turn data. What is not validated is generating a fresh chain from the VM — see §5. ### 4.5 Tests 38 tests, `uv run python3 -m unittest discover -s verify/state-checksum -t verify/state-checksum`. All pass. With only the in-repo saves, two real-save tests skip (the `bb4fd9ac` re-save form is not in the repo); with `SOTS_SAVES_DIR` pointed at the full corpus, **38 pass, 0 skipped**. Two of the tests earn their keep by having caught real defects during development: the sibling-index cascade in §2.1, and a `string` value's length prefix missing from the reconstruction (which made the coverage audit fail loudly at offset `0x1c` instead of silently under-covering — the audit working exactly as intended, on its author). ### 4.6 A negative result on `Summary.Checksum` `determinism-oracle.md` observed that the top-level `Checksum` moves by exactly −16 when four `Player.Status` ints go 4 → 0, and concluded it is additive and derived. Two candidate derivations were tried here and **both are ruled out**: it is not a byte sum over the inflated stream, and it is not a sum over the int leaves. Both are consistent with the −16 on the re-save pair (both change by −16 there), but neither leaves a constant residual across turns 1/2/3, so neither is the function. Most likely it is an additive sum over some traversal of the *in-memory* state — a desync check of the same family as this tool — which would explain why it tracks the four `Status` ints and not the file. Unresolved, and it does not need resolving: it is derived, so it is masked or localised, never trusted as evidence. Recorded here so nobody re-runs the same two experiments. --- ## 5. The replay loop — designed, NOT run VM140 is held by one lane at a time under the lab exclusivity rule (`campaign/board.md`; holder was R-recapture, is M-movefleet as of 2026-09-08). Nothing in this section has been executed by this lane. It is written to be handed to whoever holds the VM. ### 5.1 The loop ``` record: verify: seed.sav ──┐ seed.sav ──┐ │ load │ load ▼ ▼ [game: End Turn] ──> (Autosave).sav [game: End Turn] ──> (Autosave).sav │ │ │ │ │ ▼ │ ▼ │ state_checksum root_N │ state_checksum root_N' │ │ │ │ └── feed back ─────┘ └── feed back ─────┘ │ │ ▼ ▼ chain.json (turn, root, compare: first N where per-subsystem digests) root_N' != root_N is the divergent turn; diff the two saves to name the object ``` Per turn, on the host: 1. Push the current save to `C:\SOTS\SavedGames\` (`scp` to `re@192.168.10.139`). 2. Drive the UI: Load Game → Single Player → OK → pick row → OK → Launch → wait for the strategy map → **End Turn** → wait for the new turn → Quit to Main Menu. 3. Pull `(Autosave).sav` and `(Autosave EndTurn).sav` back. 4. `state_checksum.py` the post-turn autosave; append `{turn, root, subsystems}` to the chain. 5. The post-turn autosave becomes the next iteration's input. Comparison against a recorded chain is `state_checksum.py --chain chain.json S1 S2 …`, already implemented and validated on the three real saves (§4.4). It stops at the **first** divergent turn — the OpenRCT2 desync-log discipline: only the first divergence is diagnostic, everything after it is downstream noise. ### 5.2 The one canonicalisation the loop needs Step 5 feeds a **loaded post-turn autosave** back in. That is precisely the case `determinism-oracle.md` flagged: `Player.Status` 4 → 0 and the derived `Summary.Checksum` move on the round trip. So: * compare **post-turn autosave against post-turn autosave** with `--mask none` (they are byte-identical run to run; no canonicalisation is needed and none should be applied); * use `--mask resave` **only** when comparing across a load boundary — a re-implementation's output against a loaded autosave, or a pre-turn save against a post-turn one of the same turn. The chain records which mask it was built under and `verify_chain` refuses to compare across policies, so this cannot be got wrong silently. ### 5.3 What it needs from lane R's recipe Everything below already exists in `findings/subsystems/running-the-game.md` and `findings/subsystems/determinism-oracle.md`; this is the list of what the loop consumes, so the VM holder can say which parts are still true. | need | where it is today | note | |---|---|---| | launch the game non-interactively | `schtasks /Run /TN SOTS` (task created `/IT /RL HIGHEST`, runs `C:\SOTS\launch.cmd`) | nominally ~30 s to the main menu, but the board records it taking **>60 s**; screenshot and verify before the first click or the path lands in Credits. 3× `qm sendkey 140 esc` skips the Bink intros | | click driver | task `SOTSUI` running `recipe/click_helper.ps1`, reading `C:\SOTS\ui\cmd.txt` (`click X Y \| move \| key \| type \| sleep ms \| fg`) | QEMU `mouse_move`/`mouse_button` do **not** register (no USB tablet); this is the only working path | | the End-Turn click path | `determinism-oracle.md`: Load Game (512,536) → Single Player (512,290) → OK (551,523) → row → OK (682,624) → Launch (511,663) → ~30 s → **End Turn (100,714)** → ~5 s → menu (1000,714) → Quit to Main Menu (938,699) → OK (537,377) | 1024×768 windowed at 0,0; `display.cfg` must pin `windowed 1 / 1024 / 768` | | where saves live | `C:\SOTS\SavedGames\`; End Turn writes `(Autosave EndTurn).sav` (pre-turn), `(Autosave).sav` (post-turn), rotates `(Autosave Backup).sav` | nothing is written on Load or on Launch | | a clean Load dialog | move pre-existing autosaves aside, as `determinism-oracle.md` did with `pre-existing\` | the dialog lists by filename; a stale row shifts the row click | | file transport | `scp` over `re@192.168.10.139` | the earlier work also used a `Z:\saves` share | | failure triage | `ssh spicy 'echo "screendump /tmp/x.ppm" \| qm monitor 140'` + `convert` | the loop should screenshot on any step that times out, since a mis-click looks like a divergence | | shim state | `binkw32.dll` proxy with `shim.cfg hooks=trace` was loaded during the determinism runs and did not perturb the bytes | the chain must record the shim build id; a `hooks=replace` run is a *different* chain, not a continuation | Two hazards worth stating before anyone runs it: * **Text fields ignore Backspace and Esc** (`running-the-game.md`), which is why the existing save names are concatenated. The loop should never need to type, but if it does, it cannot correct a typo. * **A mis-click is indistinguishable from a divergence** at the checksum layer. The loop must assert the expected turn number from the parsed save (`Summary.Turn`) before recording a root, and screenshot when it does not match. Without that, the harness can report a confident DIVERGE that is really a missed button — which would be exactly the same class of error this whole tool exists to prevent. The board's >60 s main-menu gotcha is this hazard already happening once; a blind click landed in Credits. Every wait in the loop must be a *wait-for-condition*, never a fixed sleep. ### 5.4 What the loop would buy The three-turn chain in §4.4 is real but tiny. A 50-turn chain from a fixed seed would be the project's first *end-to-end* regression: any change to the shim, to a replaced function, or to the standalone engine either reproduces 50 roots or names the turn and the object where it stopped. That is the OpenRCT2 replay test, with a stronger oracle than they had (byte-identical saves rather than reconstructed command streams) and a finer diagnostic (a named object rather than a sprite-checksum delta). --- ## 6. Costs and limits * **~6 s per save** on this host, dominated by `save_reader.py` (37k items). `--no-audit` saves roughly a fifth of that and gives up the coverage proof; do not use it in a gate. * **The digest is only as good as the parse.** The reconstruction audit closes the gap between "the reader read something" and "the reader read everything", but a reader that mis-*types* a field still produces a self-consistent, total, deterministic digest. That is why §2.4 records the reader fingerprint. * **Opaque frames** (`TechTree` body, `spy2`, `civr`, `comms`, `Ojvs`, `Attrib`, `sprjs`, `SvSctOb`, `trdmgr`, `spymgr`, `CD`) are covered byte-for-byte but not *named* internally, so a divergence inside one localises to the frame, not to a field. Improving that is schema work in `save_reader.py`, not checksum work. * **The corpus is one game**: 2 real players, 28 systems, 3 turns. Every claim in §3 and §4 is bounded by that. In particular §4.3's "zero float leaves differ across a turn" is a fact about this game, not about the engine.