The complement to the per-function compare harness. Instead of "did this function's declared outputs match", it asks "is the entire simulation state still identical" -- so no region-declaration mistake can hide from it. Coverage is PROVED, not declared: the digest tree is re-serialised and compared byte-for-byte against the inflated save on every run. When that reconstruction reproduces the stream, the whole file is a function of the digest's inputs. A run that cannot account for the file says so and exits non-zero. This is the direct answer to B4's three hooks that printed "0 diverged" over an empty region set. It localises. The root is the fold of a per-subsystem / per-object tree with named objects, so the known load->re-save delta reports as exactly five leaves -- /Summary/Checksum and four /Sim/players/Player[...]/Status 4->0 -- naming the two Singularity players by id where the raw byte diff could only say "1st of two". One real End Turn reports as 108 fully attributed differences. Float-parity policy is explicit and strict by default (STATE_CHECKSUM.md 3): raw IEEE-754 bits; a `canonical` policy for signed zero and NaN payloads only; and deliberately NO tolerant hashing mode, because quantisation moves the cliff rather than removing it and destroys the roll-up. Tolerance lives in the differ as --ulps, applied after localisation. Corpus census: 0 NaN, 0 -0.0, 0 subnormals across 4,474 float leaves, so the strict default costs nothing today and a test fails the day that changes. Validated on the real saves (verify/results/state-checksum/): 10 files, 4 distinct contents, all STABLE + COVERED; chain record/verify works on the real turn1-3 saves. The VM-driven replay loop is designed (section 5) but UNRUN. Section 3.5 names the one question the host side cannot settle -- whether the turn pipeline depends on x87 intermediate precision -- and the experiment that would: force fpu_cw to 0x027f / 0x127f / 0x137f across End Turn and checksum the three autosaves. Also recorded: Summary.Checksum is NOT a byte sum over the inflated stream nor a sum over the int leaves (both ruled out), so nobody repeats those two. 38 tests; sots-engine untouched, clean_room_check.sh OK.
577 lines
31 KiB
Markdown
577 lines
31 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 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, and the one thing this cannot settle
|
||
|
||
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.
|
||
|
||
**What I cannot settle from the host side:** whether the turn pipeline's results depend on the
|
||
x87 intermediate precision at all. Every save on this host was produced with `fpu_cw = 0x127f`,
|
||
so the corpus is one point, not a curve. It is possible that every value in the turn pipeline
|
||
is computed in a way that gives the same float32 under 24-bit, 53-bit and 64-bit precision
|
||
control, in which case an SSE port has **no** double-rounding budget to spend and the strict
|
||
policy is free forever. It is equally possible that a handful of fields are precision-sensitive.
|
||
|
||
**The experiment that would settle it** (VM140, lane R):
|
||
|
||
> Load `ref-turn2.sav`, press End Turn, and capture `(Autosave).sav` three times, with the shim
|
||
> forcing `fpu_cw` to `0x027f` (24-bit), `0x127f` (53-bit, the baseline) and `0x137f` (64-bit)
|
||
> across the turn call. Then run
|
||
> `state_checksum.py baseline.sav variant.sav` on each pair.
|
||
>
|
||
> * All three roots equal → no turn-pipeline value depends on x87 intermediate precision. The
|
||
> strict policy costs the SSE port nothing, and §3.5 can be closed.
|
||
> * Roots differ → the diff *is* the answer: it names every precision-sensitive field, and that
|
||
> list becomes the port's work item and the only place a `--ulps` budget is ever justified.
|
||
>
|
||
> Cheap, because the oracle is already byte-identical and the tool already localises. It needs
|
||
> nothing from this lane; it needs a shim knob that sets the control word around the turn call
|
||
> and the existing End-Turn click path from §5.
|
||
|
||
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
|
||
|
||
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.
|