Implements the 2026-09-09 fleet-id-order resolution, section 3 item 1. Given the
pre-turn save, compute the ids new in each post-turn save, match the client-minted
(node nibble != 0) new fleets by a key that does not mention the id -- (LocID or
FPlan destination, sorted ship-id set) -- build the bijection pi, rewrite every
fleet reference, compare the master id lists as sets, mask /Summary/Checksum with
its reason on the line, and print pi.
Acceptance, both halves:
bp-pinA vs bp-pinB IDENTICAL modulo pi = {1970<->1986} (35 leaves -> 0)
ad-oracle-A vs -B REFUSED, then DIVERGED: 94 leaves (unchanged)
Five guards, every one refusing rather than degrading: only ids absent from the
pre-turn save; only non-zero node nibbles; pi must permute one set; content keys
must correspond one-to-one and be unique per side; and no leaf anywhere may hold a
permuted id at an unmodelled site (matched on raw bytes, not the reader's typed
value). A refusal rewrites nothing and falls back to the ordinary comparison.
Three corrections to the specification from contact with the data, in
findings/subsystems/relabel-new-ids.md section 4: FtName is an id-attached label
and needs the same treatment as the id; relabelling the Flt[] keys is the wrong
operation (exchange the bodies -- the fleet table is id-ordered and identical in
both saves); a node's new-id set spans object kinds.
Default path proven unchanged: pre- and post-change modules agree on the root
digest, coverage, mask hits and every (path, digest) in the tree over all 43 saves
under two policies, and on 5,602 lines of CLI stdout across every mode.
Also fixes a pre-existing, unrelated test failure: the re-save localisation test
enumerated pairs over sorted filenames and hard-coded the direction 4 -> 0, which
a later corpus addition reversed. Suite 38 -> 62 tests, all passing.
643 lines
35 KiB
Markdown
643 lines
35 KiB
Markdown
# 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 62 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.
|
||
|
||
### 2.6 `--relabel-new-ids` — comparing modulo the labelling of this turn's new ids
|
||
|
||
Added 2026-09-09 (lane BT) to the specification in
|
||
`findings/resolutions/2026-09-09-fleet-id-order-residue.md` §3 item 1. Full write-up, with the
|
||
acceptance output and the guard tests, in `findings/subsystems/relabel-new-ids.md`.
|
||
|
||
```
|
||
state_checksum.py POST-A.sav POST-B.sav --relabel-new-ids PRE-TURN.sav
|
||
```
|
||
|
||
Two processes with **identical pinned AI seeds** can still write different autosaves, because
|
||
the order in which the AI visits the ship groups that need a new fleet is per-process. The ids
|
||
are not the variable — they decode (`id-allocation.md`: `id = (counter << 4) | node`) to one
|
||
client's own counters, minted in that order in every process. The *same group* gets a
|
||
*different* id. This mode quotients that labelling out and nothing else.
|
||
|
||
Given the pre-turn save it computes the ids **new** in each post-turn save, matches the
|
||
non-zero-node (client-minted) new fleets by a key that does not mention the id — `(LocID or
|
||
`FPlan` destination, sorted ship-id set)` — builds the bijection π, exchanges the matched
|
||
fleets' bodies between their slots, rewrites every fleet reference under π, compares the master
|
||
id lists as sets, masks `/Summary/Checksum` **with its reason on the line** (§4.6: derived, and
|
||
its inputs are unmodelled), and prints π:
|
||
|
||
```
|
||
relabel: pi = {1970<->1986}
|
||
IDENTICAL modulo pi = {1970<->1986}
|
||
```
|
||
|
||
**Five guards, and every one refuses rather than degrades.** Only ids absent from the pre-turn
|
||
save (G1); only non-zero node nibbles (G2); π must permute one set, so the two saves must have
|
||
minted the *same* ids (G3); the content keys must correspond one-to-one and be unique on each
|
||
side (G4); and no leaf anywhere in either save may hold a permuted id at a site this tool does
|
||
not model as a fleet reference (G5, matched on raw bytes so a reader typing slip cannot empty
|
||
it). On a refusal **nothing is rewritten** and the ordinary comparison runs, so a real
|
||
divergence still reports; the reason is printed on its own line.
|
||
|
||
The relabelled root is domain-separated (`relabel-new-ids` is folded into the root preimage), so
|
||
a "modulo π" root can never be mistaken for a strict one, and every root recorded before this
|
||
flag existed is bit-for-bit unchanged.
|
||
|
||
---
|
||
|
||
## 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/.: '<raw 2503 B ef4d678696ed4c53>' -> '<raw 2503 B a80459bfd63a006b>'
|
||
/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
|
||
|
||
62 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, **62 pass, 0 skipped**.
|
||
24 of them cover `--relabel-new-ids` (§2.6), and 7 of those 24 assert a **refusal** — the guards
|
||
are the part worth testing.
|
||
|
||
Three of the tests earn their keep by having caught real defects during development: the
|
||
sibling-index cascade in §2.1, 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), and the raw-byte
|
||
completeness scan of §2.6, which caught the reader typing a fleet reference as a **float** where
|
||
a value-based scan saw nothing.
|
||
|
||
**2026-09-09 correction, not a defect in the tool.** `test_known_resave_delta_localises_to_five_
|
||
named_leaves` enumerates candidate pairs over *sorted filenames*, and asserted the re-save
|
||
direction as `4 -> 0`. Adding `cb-turn2to3-endturn.sav` to the corpus produced a pair whose
|
||
re-saved member sorts first, so the observed direction was `0 -> 4` and the test failed on a
|
||
corpus fact. The claim is symmetric; the assertion now is too (and still requires all four
|
||
`Status` leaves to move the same way, with `Checksum` following by ∓16).
|
||
|
||
### 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.
|