diff --git a/campaign/board.md b/campaign/board.md index fa35f8c..4f0d414 100644 --- a/campaign/board.md +++ b/campaign/board.md @@ -74,6 +74,6 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`). | B1 replace double-run | verify | backlog | — | 0% | 2026-09-08 | ComputeBudget replace mode runs the original a second time to harvest budget slots; ComputeOutput repairs ships in orbit as a side effect, so this is a real per-turn double effect on objects no region covers. Needs a design fix (harvest without re-running, or declare+revert) | | ReVa MCP link drop (workaround) | meta | verified | high | 100% | 2026-09-08 | The ReVa MCP client link dropped mid-session while the CT111 server stayed healthy (systemd active, :8080 listening, valid key -> 200). `tools/reva_call.py ''` calls the same server over plain HTTP (initialize -> notifications/initialized -> tools/call; replies are SSE with a leading `id:` line, initialize is plain JSON). Key is NEVER stored in the repo: $REVA_KEY, else ~/.claude.json, else ssh to the CT properties file. Use this whenever mcp__plugin_ReVa_ReVa__* is unavailable | | event posting API | subsystem | mapped | high | 90% | 2026-09-08 | RECOVERED (lane E, `findings/subsystems/events.md`). Container: `EventStorage` embedded at `ServerPlayer+0x29c` (0x1c), `EvNxID` at +0x14 = player+0x2b0 — exactly the guard's byte run. Nested `vector}>`, record 0x74 B, tags `EvEID EvDsc EvMsg EvImg EvLoc EvPos EvAct EvCID`; layout confirmed field-by-field against turn3-state.sav, which CONTAINS the overbudget record. Entry point `int __thiscall EventStorage::PostEvent(this, string BYVAL, string BYVAL, obj*, Vector3*, turn, const char* img, int act)` 0x008862b0 RET 0x4c — **161 call sites in 113 functions, the whole sim's event API**. B3 defect fully explained: 0x00587b97, in the completion-roll-FAILED branch under `!wasDone && nowDone && owner`. 3 note corrections (EvPos is FLT_MAX not inf; the save array is turn-bucketed not flat; TECHS_UNLOCKED has no parent clause). 56 entries in addresses.json; 11 prototypes + 13 labels + 12 comments + 2 structs written back to Ghidra. Engine: `sots-engine` branch `wip/events` a7348be, `src/game/events` + 112 checks, ctest 32/32. NOT YET WIRED INTO A HOOK — see `docs/E-events.md` for the proposed region/Coverage change | -| state-checksum replay harness | verify | in-progress | — | 0% | 2026-09-08 | Lane C: whole-state per-turn checksum as the COMPLEMENT to per-function compares (no region-declaration mistake can hide from it). Diagnostic tree that localises WHICH object moved, explicit float-parity policy (float32 + fpu_cw 0x127f + fistp ties-to-even; matters for the future x64/SSE port). Replay loop designed but unrun - lane R holds VM140 | +| state-checksum replay harness | verify | verified | high | 90% | 2026-09-08 | Lane C: `verify/state-checksum/` (tool, 38 tests, `STATE_CHECKSUM.md`, evidence in `verify/results/state-checksum/`). Whole-state digest tree; **coverage is PROVED by byte-for-byte re-serialisation**, not declared - the answer to empty-region-set green verdicts. Localises: the known load->re-save delta reports as exactly 5 named leaves (`/Sim/players/Player[496 "Singularity"]/Status: 4 -> 0`, `/Summary/Checksum`), and one real End Turn as 108 attributed diffs. All 10 saves STABLE + COVERED. Float policy = exact bits by default, `canonical` for -0.0/NaN only, **tolerance deliberately not a hashing mode** (it lives in `--ulps` on the differ); corpus has 0 NaN/-0.0/subnormals so canonical is a no-op today. Chain record/verify validated on the real turn1-3 saves. REMAINING 10%: the VM-driven replay loop is designed (§5) but UNRUN - needs the VM holder. Open question named in §3.5 with the experiment that settles it (force `fpu_cw` 0x027f/0x127f/0x137f across End Turn, checksum the three autosaves) | | MoveFleet position ULP divergence | phase2 | in-progress | — | 0% | 2026-09-08 | Lane M. FIRST arithmetic divergence caught by BEHAVIOURAL compare rather than static reading: 8 of 45 calls differ by 1 ULP on a position component (worst 64 ULP at a near-zero result; absolute error ~1.2e-7 everywhere = half an ULP of the INPUTS) => one rounding too many/few in the position update. Step length and all ship ranges match. B4 saw only 1 moving call and it still matches 4 of 5 moves, which is exactly why 1-call coverage is not evidence. All 15 moving calls are the same straight-run waypoint type; types 2-5 never occurred | | ObservedTech append (undeclared) | verify | backlog | — | 0% | 2026-09-08 | Lane R's guards caught a vector append at `player+0x274` during SetResearched. It is SERIALIZED state and appears in NO coverage note anywhere - found only because guards localise rather than just flag a moved hash. Needs a declared region + a model in ours | diff --git a/verify/results/state-checksum/chain-turn1-3.json b/verify/results/state-checksum/chain-turn1-3.json new file mode 100644 index 0000000..0841867 --- /dev/null +++ b/verify/results/state-checksum/chain-turn1-3.json @@ -0,0 +1,74 @@ +{ + "format": "sots-state-chain/1", + "policy": { + "floats": "bits", + "mask": "none", + "digest": "blake2b-128", + "readerFingerprint": "fe5a6f7cd4ae7910" + }, + "turns": [ + { + "name": "turn1-state.sav", + "turn": 1, + "root": "9bbcbd4945cd8319b65d7d7958772ee0", + "coverage": true, + "subsystems": { + "/Summary": "c601faf8435ca9dd13f0d76f790d6447", + "/CreateParams": "68913c619976ba5f4fa719a7126156aa", + "/Sim/RNG": "0a82080734aaa2d4f3e986036f147963", + "/Sim/Attrib": "cea50f5077af4e42ee02fbbe7b2f8ffd", + "/Sim/turnstats": "b3e7593a4742ebc40a97000159063905", + "/Sim/players": "5c843fefbf911315288f059986c5f7e5", + "/Sim/systems": "8391940f1bb84af6ad3260fdf4390892", + "/Sim/fleets": "db0c730bfa7e21b0b449c1bb66d7fbf7", + "/Sim/NdGr2": "7771763e19ec2652c8c11864ad8e4a10", + "/Sim/trdmgr": "a1139ab953664ab1543ff42faa59aba6", + "/Sim/spymgr": "99c457b5334248e71d063f948ebd7f0c", + "/Sim/SvSctOb": "f9e074a56abb0d1ce82432fa2e1d2215", + "/CDT": "a3f7780ee36905117987374012d1586d" + } + }, + { + "name": "turn2-state.sav", + "turn": 2, + "root": "aa85fe76d412cdb3a0e7e52c8f0ca9e2", + "coverage": true, + "subsystems": { + "/Summary": "42b2094efe6e285a3eddc0518a842c69", + "/CreateParams": "68913c619976ba5f4fa719a7126156aa", + "/Sim/RNG": "e9625f2b86f93f8ccf84dcd1b1833d6a", + "/Sim/Attrib": "cea50f5077af4e42ee02fbbe7b2f8ffd", + "/Sim/turnstats": "c32bab57e99cdf797510872b170243f9", + "/Sim/players": "fa7f5ad40afed20848714e38cfd360fb", + "/Sim/systems": "1a36f0d18f0d8babfb3b48c0c273c3f4", + "/Sim/fleets": "666872a3ce06721807e0487e2801f0b7", + "/Sim/NdGr2": "7771763e19ec2652c8c11864ad8e4a10", + "/Sim/trdmgr": "a1139ab953664ab1543ff42faa59aba6", + "/Sim/spymgr": "99c457b5334248e71d063f948ebd7f0c", + "/Sim/SvSctOb": "6cd7196a38f53574292b3bde3ee424a8", + "/CDT": "a3f7780ee36905117987374012d1586d" + } + }, + { + "name": "turn3-state.sav", + "turn": 3, + "root": "5ac4a24197e82de49f3077cd8dd25fad", + "coverage": true, + "subsystems": { + "/Summary": "b375baaf6aab9da5202f30fd4dc85104", + "/CreateParams": "68913c619976ba5f4fa719a7126156aa", + "/Sim/RNG": "0978fdf34ff7962f76c2de810dc93e0a", + "/Sim/Attrib": "cea50f5077af4e42ee02fbbe7b2f8ffd", + "/Sim/turnstats": "f067f6b95f21ed0ea0d9844a48caa78f", + "/Sim/players": "8a0bf16b7166f7e30b170284ad26ae36", + "/Sim/systems": "ef4caa2237b49b2384bc9ee7cd733a96", + "/Sim/fleets": "0f65b7f9ccb245f6ff105be5d1f69dd1", + "/Sim/NdGr2": "7771763e19ec2652c8c11864ad8e4a10", + "/Sim/trdmgr": "a1139ab953664ab1543ff42faa59aba6", + "/Sim/spymgr": "99c457b5334248e71d063f948ebd7f0c", + "/Sim/SvSctOb": "d30210dd844215010d68c7d013fa4da0", + "/CDT": "a3f7780ee36905117987374012d1586d" + } + } + ] +} \ No newline at end of file diff --git a/verify/results/state-checksum/chain.txt b/verify/results/state-checksum/chain.txt new file mode 100644 index 0000000..5c46095 --- /dev/null +++ b/verify/results/state-checksum/chain.txt @@ -0,0 +1,9 @@ +recorded 3 turns -> /home/alex/sots-re/verify/results/state-checksum/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 diff --git a/verify/results/state-checksum/float-census.txt b/verify/results/state-checksum/float-census.txt new file mode 100644 index 0000000..a3f89d5 --- /dev/null +++ b/verify/results/state-checksum/float-census.txt @@ -0,0 +1,36 @@ +# float census -- classes that separate the 'bits' and 'canonical' +# policies are: negative zero, NaN. Classes an x87 -> SSE port is +# most likely to move are: subnormal, and anything near FLT_MAX. + +turn1-state.sav: 1101 float leaves + 576 ordinary e.g. /CreateParams/MapP/.[1]/.[1]/.[0]/.[0] + 275 positive zero e.g. /Summary/Session/TMRS/TQTLE + 192 exact integer e.g. /Summary/Session/TMRS/TCTL + 58 FLT_MAX (0x7f7fffff) e.g. /Summary/Session/TMRS/TSTL + +turn2-state.sav: 1116 float leaves + 581 ordinary e.g. /CreateParams/MapP/.[1]/.[1]/.[0]/.[0] + 279 positive zero e.g. /Summary/Session/TMRS/TQTLE + 198 exact integer e.g. /Summary/Session/TMRS/TCTL + 58 FLT_MAX (0x7f7fffff) e.g. /Summary/Session/TMRS/TSTL + +turn3-state.sav: 1141 float leaves + 599 ordinary e.g. /CreateParams/MapP/.[1]/.[1]/.[0]/.[0] + 281 positive zero e.g. /Summary/Session/TMRS/TQTLE + 203 exact integer e.g. /Summary/Session/TMRS/TCTL + 58 FLT_MAX (0x7f7fffff) e.g. /Summary/Session/TMRS/TSTL + +Autosave EndTurn - turn3.sav: 1116 float leaves + 581 ordinary e.g. /CreateParams/MapP/.[1]/.[1]/.[0]/.[0] + 279 positive zero e.g. /Summary/Session/TMRS/TQTLE + 198 exact integer e.g. /Summary/Session/TMRS/TCTL + 58 FLT_MAX (0x7f7fffff) e.g. /Summary/Session/TMRS/TSTL + +all distinct saves combined: + 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) diff --git a/verify/results/state-checksum/identical-roots.txt b/verify/results/state-checksum/identical-roots.txt new file mode 100644 index 0000000..bc840f9 --- /dev/null +++ b/verify/results/state-checksum/identical-roots.txt @@ -0,0 +1,13 @@ +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 diff --git a/verify/results/state-checksum/resave-localisation.txt b/verify/results/state-checksum/resave-localisation.txt new file mode 100644 index 0000000..10063b3 --- /dev/null +++ b/verify/results/state-checksum/resave-localisation.txt @@ -0,0 +1,20 @@ +# determinism-oracle.md: loading a post-turn autosave and re-saving it +# changes Player.Status (4->0) on the four turn-participating players +# plus the derived Summary.Checksum. Nothing else. + +$ state_checksum.py turn2-state.sav Autosave EndTurn - turn3.sav +A aa85fe76d412cdb3a0e7e52c8f0ca9e2 /home/alex/sots-re/verify/results/saves/turn2-state.sav +B a1448e6c1867fc53708810b3a2f9ec77 /tmp/saves/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 /home/alex/sots-re/verify/results/saves/turn2-state.sav +B a55e433687fff0e380de2caabf964a31 /tmp/saves/Autosave EndTurn - turn3.sav +policy: floats=bits mask=resave reader=fe5a6f7cd4ae7910 [masked: Checksumx1, Statusx8] +IDENTICAL diff --git a/verify/results/state-checksum/roots.txt b/verify/results/state-checksum/roots.txt new file mode 100644 index 0000000..9386ef4 --- /dev/null +++ b/verify/results/state-checksum/roots.txt @@ -0,0 +1,86 @@ +# state_checksum roots -- 2026-09-08T07:42:52Z + +--- sha256:a3f9dc4b49fc669c turn1-state.sav +file: /home/alex/sots-re/verify/results/saves/turn1-state.sav +root: 9bbcbd4945cd8319b65d7d7958772ee0 +policy: floats=bits mask=none digest=blake2b-128 reader=fe5a6f7cd4ae7910 +coverage: PROVED (591376 bytes rebuilt == inflated); 34355 leaves, 187545 value bytes +reader: 0 error, 0 warn +subsystems: + c601faf8435ca9dd /Summary 60 leaves 216 B + 68913c619976ba5f /CreateParams 228 leaves 907 B + 0a82080734aaa2d4 /Sim/RNG 1 leaves 2503 B + cea50f5077af4e42 /Sim/Attrib 1 leaves 4 B + b3e7593a4742ebc4 /Sim/turnstats 281 leaves 1156 B + 5c843fefbf911315 /Sim/players 28902 leaves 160989 B + 8391940f1bb84af6 /Sim/systems 2754 leaves 10482 B + db0c730bfa7e21b0 /Sim/fleets 513 leaves 1880 B + 7771763e19ec2652 /Sim/NdGr2 475 leaves 1900 B + a1139ab953664ab1 /Sim/trdmgr 166 leaves 664 B + 99c457b5334248e7 /Sim/spymgr 2 leaves 8 B + f9e074a56abb0d1c /Sim/SvSctOb 123 leaves 530 B + a3f7780ee3690511 /CDT 5 leaves 104 B + +--- sha256:ab4ac2d7e2977260 turn2-state.sav +file: /home/alex/sots-re/verify/results/saves/turn2-state.sav +root: aa85fe76d412cdb3a0e7e52c8f0ca9e2 +policy: floats=bits mask=none digest=blake2b-128 reader=fe5a6f7cd4ae7910 +coverage: PROVED (603360 bytes rebuilt == inflated); 35031 leaves, 190807 value bytes +reader: 0 error, 0 warn +subsystems: + 42b2094efe6e285a /Summary 60 leaves 216 B + 68913c619976ba5f /CreateParams 228 leaves 907 B + e9625f2b86f93f8c /Sim/RNG 1 leaves 2503 B + cea50f5077af4e42 /Sim/Attrib 1 leaves 4 B + c32bab57e99cdf79 /Sim/turnstats 545 leaves 2244 B + fa7f5ad40afed208 /Sim/players 29230 leaves 162841 B + 1a36f0d18f0d8bab /Sim/systems 2779 leaves 10582 B + 666872a3ce067218 /Sim/fleets 561 leaves 2058 B + 7771763e19ec2652 /Sim/NdGr2 475 leaves 1900 B + a1139ab953664ab1 /Sim/trdmgr 166 leaves 664 B + 99c457b5334248e7 /Sim/spymgr 2 leaves 8 B + 6cd7196a38f53574 /Sim/SvSctOb 130 leaves 558 B + a3f7780ee3690511 /CDT 5 leaves 104 B + +--- sha256:978041acd168b56e turn3-state.sav +file: /home/alex/sots-re/verify/results/saves/turn3-state.sav +root: 5ac4a24197e82de49f3077cd8dd25fad +policy: floats=bits mask=none digest=blake2b-128 reader=fe5a6f7cd4ae7910 +coverage: PROVED (609080 bytes rebuilt == inflated); 35394 leaves, 192486 value bytes +reader: 0 error, 0 warn +subsystems: + b375baaf6aab9da5 /Summary 60 leaves 216 B + 68913c619976ba5f /CreateParams 228 leaves 907 B + 0978fdf34ff7962f /Sim/RNG 1 leaves 2503 B + cea50f5077af4e42 /Sim/Attrib 1 leaves 4 B + f067f6b95f21ed0e /Sim/turnstats 809 leaves 3332 B + 8a0bf16b7166f7e3 /Sim/players 29264 leaves 163187 B + ef4caa2237b49b23 /Sim/systems 2779 leaves 10582 B + 0f65b7f9ccb245f6 /Sim/fleets 624 leaves 2295 B + 7771763e19ec2652 /Sim/NdGr2 475 leaves 1900 B + a1139ab953664ab1 /Sim/trdmgr 166 leaves 664 B + 99c457b5334248e7 /Sim/spymgr 2 leaves 8 B + d30210dd84421501 /Sim/SvSctOb 130 leaves 558 B + a3f7780ee3690511 /CDT 5 leaves 104 B + +--- sha256:bb4fd9ac89f41e3b Autosave EndTurn - turn3.sav +file: /tmp/saves/Autosave EndTurn - turn3.sav +root: a1448e6c1867fc53708810b3a2f9ec77 +policy: floats=bits mask=none digest=blake2b-128 reader=fe5a6f7cd4ae7910 +coverage: PROVED (603360 bytes rebuilt == inflated); 35031 leaves, 190807 value bytes +reader: 0 error, 0 warn +subsystems: + 8c7e33a33dc76b3d /Summary 60 leaves 216 B + 68913c619976ba5f /CreateParams 228 leaves 907 B + e9625f2b86f93f8c /Sim/RNG 1 leaves 2503 B + cea50f5077af4e42 /Sim/Attrib 1 leaves 4 B + c32bab57e99cdf79 /Sim/turnstats 545 leaves 2244 B + 3416ca2128671b1e /Sim/players 29230 leaves 162841 B + 1a36f0d18f0d8bab /Sim/systems 2779 leaves 10582 B + 666872a3ce067218 /Sim/fleets 561 leaves 2058 B + 7771763e19ec2652 /Sim/NdGr2 475 leaves 1900 B + a1139ab953664ab1 /Sim/trdmgr 166 leaves 664 B + 99c457b5334248e7 /Sim/spymgr 2 leaves 8 B + 6cd7196a38f53574 /Sim/SvSctOb 130 leaves 558 B + a3f7780ee3690511 /CDT 5 leaves 104 B + diff --git a/verify/results/state-checksum/turn2-to-turn3.txt b/verify/results/state-checksum/turn2-to-turn3.txt new file mode 100644 index 0000000..bd4e392 --- /dev/null +++ b/verify/results/state-checksum/turn2-to-turn3.txt @@ -0,0 +1,115 @@ +# one real End Turn (turn 2 -> turn 3), every difference named + +A aa85fe76d412cdb3a0e7e52c8f0ca9e2 /home/alex/sots-re/verify/results/saves/turn2-state.sav +B 5ac4a24197e82de49f3077cd8dd25fad /home/alex/sots-re/verify/results/saves/turn3-state.sav +policy: floats=bits mask=none reader=fe5a6f7cd4ae7910 +DIVERGED: 108 leaf difference(s) + /Summary/Turn: 2 -> 3 + /Summary/Checksum: -1205790620 -> -769976634 + /Sim/NMnx: 109 -> 111 + /Sim/FleetIDs[]: removed [1744], added [34, 1776] (7 -> 8 entries) + /Sim/ShipIDs[]: removed [], added [1760] (16 -> 17 entries) + /Sim/ModCount: 12 -> 24 + /Sim/Frame: 2 -> 3 + /Sim/RNG/.: '' -> '' + /Sim/cmbtid: 2 -> 3 + /Sim/turnstats/history/hist[0]/stats[2]: only-in-B + /Sim/turnstats/history/hist[1]/stats[2]: only-in-B + /Sim/turnstats/history/hist[2]/stats[2]: only-in-B + /Sim/turnstats/history/hist[3]/stats[2]: only-in-B + /Sim/turnstats/history/hist[4]/stats[2]: only-in-B + /Sim/turnstats/history/hist[5]/stats[2]: only-in-B + /Sim/turnstats/history/hist[6]/stats[2]: only-in-B + /Sim/turnstats/history/hist[7]/stats[2]: only-in-B + /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[16 "re"]/Events/Events/.[0]: 1 -> 2 + /Sim/players/Player[16 "re"]/PvSav: 50000 -> 289688 + /Sim/players/Player[16 "re"]/BnkPr: -789323 -> -791290 + /Sim/players/Player[16 "re"]/BnkEl: -1594593 -> -1598566 + /Sim/players/Player[32 "Fane Lao"]/TechTree/TResDone[106]: 2879 -> 5768 + /Sim/players/Player[32 "Fane Lao"]/TechTree/Tbd[106]: 1 -> 2 + /Sim/players/Player[32 "Fane Lao"]/Sav: 92651 -> 135486 + /Sim/players/Player[32 "Fane Lao"]/Maint: 500 -> 1000 + /Sim/players/Player[32 "Fane Lao"]/Events/EvNxID: 2 -> 4 + /Sim/players/Player[32 "Fane Lao"]/Events/Events/.[EvTurn=3]: only-in-B + /Sim/players/Player[32 "Fane Lao"]/Events/Events/.[0]: 1 -> 2 + /Sim/players/Player[32 "Fane Lao"]/FNG/FNGNum: 1 -> 3 + /Sim/players/Player[32 "Fane Lao"]/PvSav: 38100 -> 80751 + /Sim/players/Player[32 "Fane Lao"]/BnkPr: -898837 -> -901002 + /Sim/players/Player[32 "Fane Lao"]/BnkEl: -1815833 -> -1820206 + /Sim/players/Player[32 "Fane Lao"]/ShipRecs/srb[0]: 1 -> 2 + /Sim/players/Player[32 "Fane Lao"]/ShipRecs/sri[0]: 1 -> 2 + /Sim/players/Player[32 "Fane Lao"]/ShipRecs/srb[3]: 1 -> 2 + /Sim/players/Player[32 "Fane Lao"]/ShipRecs/sri[3]: 1 -> 2 + /Sim/players/Player[32 "Fane Lao"]/lboid: 1 -> 2 + /Sim/players/Player[32 "Fane Lao"]/odes/.[1]/otnL: 2 -> 3 + /Sim/players/Player[496 "Singularity"]/dipstats/.[1]/lastally: 2 -> 3 + /Sim/players/Player[512 "Singularity"]/dipstats/.[1]/lastally: 2 -> 3 + /Sim/players/Player[528 "Alien Menace"]/dipstats/.[1]/lastnap: 2 -> 3 + /Sim/players/Player[528 "Alien Menace"]/dipstats/.[2]/lastnap: 2 -> 3 + /Sim/players/Player[528 "Alien Menace"]/dipstats/.[3]/lastnap: 2 -> 3 + /Sim/players/Player[544 "Peacekeeper Enforcer"]/dipstats/.[1]/lastnap: 2 -> 3 + /Sim/players/Player[544 "Peacekeeper Enforcer"]/dipstats/.[2]/lastnap: 2 -> 3 + /Sim/players/Player[544 "Peacekeeper Enforcer"]/dipstats/.[3]/lastnap: 2 -> 3 + /Sim/players/Player[560 "Von Neumann"]/dipstats/.[1]/lastnap: 2 -> 3 + /Sim/players/Player[560 "Von Neumann"]/dipstats/.[2]/lastnap: 2 -> 3 + /Sim/players/Player[560 "Von Neumann"]/dipstats/.[3]/lastnap: 2 -> 3 + /Sim/players/Player[576 "Independent Colony"]/Sav: 98871 -> 198730 + /Sim/players/Player[576 "Independent Colony"]/PvSav: 0 -> 98871 + /Sim/players/Player[576 "Independent Colony"]/dipstats/.[1]/lastnap: 2 -> 3 + /Sim/players/Player[576 "Independent Colony"]/dipstats/.[2]/lastnap: 2 -> 3 + /Sim/players/Player[576 "Independent Colony"]/dipstats/.[3]/lastnap: 2 -> 3 + /Sim/systems/Sys[64 "Hyperion"]/ltis: 2 -> 3 + /Sim/systems/Sys[64 "Hyperion"]/rcex: 65536 -> 0 + /Sim/systems/Sys[64 "Hyperion"]/TShn: 2 -> 3 + /Sim/systems/Sys[64 "Hyperion"]/ETS: 2 -> 3 + /Sim/systems/Sys[112 "Gamma Cephei"]/Pop2/PopG/PopC: 520000000 -> 540000000 + /Sim/systems/Sys[112 "Gamma Cephei"]/PvPop2/PopG/PopC: 500000000 -> 520000000 + /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[112 "Gamma Cephei"]/ntdev: 1 -> 2 + /Sim/systems/Sys[112 "Gamma Cephei"]/ltis: 2 -> 3 + /Sim/systems/Sys[112 "Gamma Cephei"]/TShn: 2 -> 3 + /Sim/systems/Sys[112 "Gamma Cephei"]/ETS: 2 -> 3 + /Sim/systems/Sys[224 "Spica"]/TShn: 2 -> 3 + /Sim/systems/Sys[288 "Ke'Dolarra"]/Pop2/PopG/PopC: 520000000 -> 540000000 + /Sim/systems/Sys[288 "Ke'Dolarra"]/PvPop2/PopG/PopC: 500000000 -> 520000000 + /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] + /Sim/systems/Sys[288 "Ke'Dolarra"]/ntdev: 1 -> 2 + /Sim/systems/Sys[288 "Ke'Dolarra"]/ltis: 2 -> 3 + /Sim/systems/Sys[288 "Ke'Dolarra"]/DefF: 1744 -> 1776 + /Sim/systems/Sys[288 "Ke'Dolarra"]/Flt: 1744 -> 1776 + /Sim/systems/Sys[288 "Ke'Dolarra"]/TShn: 2 -> 3 + /Sim/systems/Sys[288 "Ke'Dolarra"]/ETS: 2 -> 3 + /Sim/systems/Sys[304 "Koa’Vo"]/ntdev: 1 -> 2 + /Sim/systems/Sys[304 "Koa’Vo"]/ltis: 2 -> 3 + /Sim/systems/Sys[304 "Koa’Vo"]/rcex: 268435456 -> 0 + /Sim/systems/Sys[304 "Koa’Vo"]/TShn: 2 -> 3 + /Sim/systems/Sys[304 "Koa’Vo"]/ETS: 2 -> 3 + /Sim/systems/Sys[336 "Kaa’Vaalu"]/ltis: 2 -> 3 + /Sim/systems/Sys[336 "Kaa’Vaalu"]/rcex: 65536 -> 0 + /Sim/systems/Sys[336 "Kaa’Vaalu"]/TShn: 2 -> 3 + /Sim/systems/Sys[336 "Kaa’Vaalu"]/ETS: 2 -> 3 + /Sim/systems/Sys[400 "Markab"]/ltis: 2 -> 3 + /Sim/systems/Sys[400 "Markab"]/rcex: 65536 -> 0 + /Sim/systems/Sys[400 "Markab"]/TShn: 2 -> 3 + /Sim/systems/Sys[400 "Markab"]/ETS: 2 -> 3 + /Sim/systems/Sys[448 "Kea’Pono"]/ltis: 2 -> 3 + /Sim/systems/Sys[448 "Kea’Pono"]/rcex: 65536 -> 0 + /Sim/systems/Sys[448 "Kea’Pono"]/TShn[0]: 2 -> 3 + /Sim/systems/Sys[448 "Kea’Pono"]/TShn[1]: 2 -> 3 + /Sim/systems/Sys[448 "Kea’Pono"]/ETS: 2 -> 3 + /Sim/systems/Sys[480 "Ko'Rorkor"]/ltis: 2 -> 3 + /Sim/systems/Sys[480 "Ko'Rorkor"]/rcex: 65536 -> 0 + /Sim/systems/Sys[480 "Ko'Rorkor"]/TShn: 2 -> 3 + /Sim/systems/Sys[480 "Ko'Rorkor"]/ETS: 2 -> 3 + /Sim/NumFlts: 7 -> 8 + /Sim/fleets/Flt[1744 "Alpha Fleet"]: only-in-A + /Sim/fleets/Flt[34 "Beta Fleet"]: only-in-B + /Sim/fleets/Flt[1776 "Gamma Fleet"]: only-in-B + /Sim/SvSctOb/EncObj[5]/Hives/.[1]/NextQ: 31 -> 32 + /Sim/SvSctOb/EncObj[5]/Hives/.[2]/NextQ: 29 -> 30 +floats: 4 differ, 0 within 2 ULP diff --git a/verify/state-checksum/STATE_CHECKSUM.md b/verify/state-checksum/STATE_CHECKSUM.md new file mode 100644 index 0000000..9ae76be --- /dev/null +++ b/verify/state-checksum/STATE_CHECKSUM.md @@ -0,0 +1,577 @@ +# 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/.: '' -> '' + /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. diff --git a/verify/state-checksum/float_census.py b/verify/state-checksum/float_census.py new file mode 100644 index 0000000..5c89250 --- /dev/null +++ b/verify/state-checksum/float_census.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""float_census.py -- classify every float leaf in a set of saves. + +This is the evidence behind the float-parity policy in STATE_CHECKSUM.md: it +says whether the corpus actually contains the values where a `bits` policy and +a `canonical` policy disagree (signed zero, NaN payloads), and whether it +contains the values where an x87 -> SSE port is most likely to drift +(subnormals, values at the edge of float32 range). + + uv run python3 float_census.py SAVE... +""" +from __future__ import annotations + +import collections +import math +import os +import struct +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +if _HERE not in sys.path: + sys.path.insert(0, _HERE) + +import state_checksum as sc # noqa: E402 + +FLT_MIN_NORMAL = 1.1754943508222875e-38 +FLT_MAX = 3.4028234663852886e38 + + +def classify(raw: bytes) -> str: + (bits,) = struct.unpack(" int: + argv = list(sys.argv[1:] if argv is None else argv) + if not argv: + print("usage: float_census.py SAVE...", file=sys.stderr) + return 2 + seen: set = set() + total = collections.Counter() + print("# float census -- classes that separate the 'bits' and 'canonical'") + print("# policies are: negative zero, NaN. Classes an x87 -> SSE port is") + print("# most likely to move are: subnormal, and anything near FLT_MAX.") + print() + for p in argv: + with open(p, "rb") as f: + data = f.read() + key = hash(data) + if key in seen: + continue + seen.add(key) + ck = sc.checksum_bytes(data, path=p, audit=False) + c = collections.Counter() + examples: dict = {} + for n in ck.root.walk(): + if n.kind != "float": + continue + k = classify(n.raw) + c[k] += 1 + examples.setdefault(k, n.path) + total += c + print(f"{os.path.basename(p)}: {sum(c.values())} float leaves") + for k, v in sorted(c.items(), key=lambda kv: -kv[1]): + print(f" {v:>6} {k:<24} e.g. {examples[k]}") + print() + print("all distinct saves combined:") + for k, v in sorted(total.items(), key=lambda kv: -kv[1]): + print(f" {v:>6} {k}") + print() + risky = total["negative zero"] + total["NaN"] + print(f"'canonical' would change {risky} leaf/leaves across the corpus " + f"({'a no-op today' if risky == 0 else 'NOT a no-op'}).") + print(f"subnormals: {total['subnormal']} (each one is an x87/SSE parity risk)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/verify/state-checksum/run_validation.sh b/verify/state-checksum/run_validation.sh new file mode 100755 index 0000000..fa5ba59 --- /dev/null +++ b/verify/state-checksum/run_validation.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Regenerate verify/results/state-checksum/ from the saves on this host. +# +# Saves are read from $SOTS_SAVES_DIR when set, plus the in-repo +# verify/results/saves/. No .sav is copied anywhere. Skips cleanly with a +# message when there is nothing to read. +# +# verify/state-checksum/run_validation.sh +# +# Each gate is run separately and its exit status reported; nothing is chained +# with && so that a failure cannot skip a later step. +set -u + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VERIFY="$(dirname "$HERE")" +OUT="$VERIFY/results/state-checksum" +SC="uv run python3 $HERE/state_checksum.py" + +mkdir -p "$OUT" + +REPO_SAVES="$VERIFY/results/saves" +EXTRA="${SOTS_SAVES_DIR:-}" +if [ -n "$EXTRA" ] && [ ! -d "$EXTRA" ]; then + echo "note: \$SOTS_SAVES_DIR=$EXTRA is not a directory; ignoring" + EXTRA="" +fi +if [ -z "$EXTRA" ]; then + echo "note: \$SOTS_SAVES_DIR unset; using in-repo saves only" +fi + +shopt -s nullglob +ALL=("$REPO_SAVES"/*.sav) +[ -n "$EXTRA" ] && ALL+=("$EXTRA"/*.sav) +if [ ${#ALL[@]} -eq 0 ]; then + echo "no saves available; nothing to validate" + exit 0 +fi + +rc=0 +note() { echo "== $1 -> exit $2"; [ "$2" -eq 0 ] || rc=1; } + +# 1. one root + subsystem digest set per distinct save content +{ + echo "# state_checksum roots -- $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo + seen="" + for f in "${ALL[@]}"; do + h=$(sha256sum "$f" | cut -c1-16) + case " $seen " in *" $h "*) continue;; esac + seen="$seen $h" + echo "--- sha256:$h $(basename "$f")" + $SC "$f" + echo + done +} > "$OUT/roots.txt" 2>&1 +note "roots" $? + +# 2. identical bytes must give identical roots, and every save must be covered +uv run python3 "$HERE/stability_check.py" "${ALL[@]}" > "$OUT/identical-roots.txt" 2>&1 +note "identical-roots" $? + +# 3. the known load->re-save delta must LOCALISE, not read as a whole-state miss +A="$REPO_SAVES/turn2-state.sav" +B="" +for f in "${ALL[@]}"; do + [ "$(sha256sum "$f" | cut -c1-8)" = "bb4fd9ac" ] && B="$f" && break +done +if [ -n "$B" ] && [ -f "$A" ]; then + { + echo "# determinism-oracle.md: loading a post-turn autosave and re-saving it" + echo "# changes Player.Status (4->0) on the four turn-participating players" + echo "# plus the derived Summary.Checksum. Nothing else." + echo + echo "\$ state_checksum.py turn2-state.sav $(basename "$B")" + $SC "$A" "$B" + echo + echo "\$ state_checksum.py turn2-state.sav $(basename "$B") --mask resave" + $SC "$A" "$B" --mask resave + } > "$OUT/resave-localisation.txt" 2>&1 + note "resave-localisation" 0 +else + echo "== resave-localisation -> SKIPPED (the bb4fd9ac re-save form is not on this host)" +fi + +# 4. a real turn transition, fully attributed +if [ -f "$REPO_SAVES/turn2-state.sav" ] && [ -f "$REPO_SAVES/turn3-state.sav" ]; then + { + echo "# one real End Turn (turn 2 -> turn 3), every difference named" + echo + $SC "$REPO_SAVES/turn2-state.sav" "$REPO_SAVES/turn3-state.sav" --ulps 2 --limit 500 + } > "$OUT/turn2-to-turn3.txt" 2>&1 + note "turn-transition" 0 +fi + +# 5. the recorded chain (host side of the replay loop; the VM side is unrun) +CH=("$REPO_SAVES/turn1-state.sav" "$REPO_SAVES/turn2-state.sav" "$REPO_SAVES/turn3-state.sav") +have=1 +for f in "${CH[@]}"; do [ -f "$f" ] || have=0; done +if [ "$have" = 1 ]; then + $SC "${CH[@]}" --record-chain "$OUT/chain-turn1-3.json" > "$OUT/chain.txt" 2>&1 + note "record-chain" $? + echo >> "$OUT/chain.txt" + echo "\$ state_checksum.py --chain chain-turn1-3.json turn1 turn2 turn3" >> "$OUT/chain.txt" + $SC --chain "$OUT/chain-turn1-3.json" "${CH[@]}" >> "$OUT/chain.txt" 2>&1 + note "verify-chain" $? +fi + +# 6. float census -- the evidence behind the float-parity policy +uv run python3 "$HERE/float_census.py" "${ALL[@]}" > "$OUT/float-census.txt" 2>&1 +note "float-census" $? + +echo +echo "wrote $OUT" +exit $rc diff --git a/verify/state-checksum/stability_check.py b/verify/state-checksum/stability_check.py new file mode 100644 index 0000000..c5ed3ca --- /dev/null +++ b/verify/state-checksum/stability_check.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""stability_check.py -- byte-identical saves must produce identical roots. + +Groups the given saves by file sha256 and checks that every file in a group +gets the same root digest, and that every file's coverage audit passes. This +is the direct machine check of the determinism-oracle claim, one level up from +sha256: it also proves the *parse* is deterministic, not just the bytes. + + uv run python3 stability_check.py SAVE... +""" +from __future__ import annotations + +import hashlib +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +if _HERE not in sys.path: + sys.path.insert(0, _HERE) + +import state_checksum as sc # noqa: E402 + + +def main(argv=None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + if not argv: + print("usage: stability_check.py SAVE...", file=sys.stderr) + return 2 + groups: dict = {} + for p in argv: + with open(p, "rb") as f: + groups.setdefault(hashlib.sha256(f.read()).hexdigest()[:16], []).append(p) + print(f"reader fingerprint: {sc.READER_FINGERPRINT}") + print(f"{len(argv)} file(s), {len(groups)} distinct content(s)") + print() + bad = 0 + for h, paths in sorted(groups.items()): + roots: dict = {} + cov_fail = [] + for p in paths: + ck = sc.checksum_save(p) + roots.setdefault(ck.digest, []).append(os.path.basename(p)) + if not ck.coverage["ok"]: + cov_fail.append((os.path.basename(p), ck.coverage["firstDiff"])) + ok = len(roots) == 1 and not cov_fail + bad += not ok + print(f"sha256:{h} {len(paths)} file(s) " + f"{'STABLE + COVERED' if ok else 'PROBLEM'}") + for r, names in roots.items(): + print(f" root {r} {', '.join(sorted(names))}") + for name, off in cov_fail: + print(f" !! coverage failed for {name} at inflated offset {off}") + print() + print("VERDICT:", "all stable and fully covered" if not bad + else f"{bad} group(s) unstable or uncovered") + return 1 if bad else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/verify/state-checksum/state_checksum.py b/verify/state-checksum/state_checksum.py index 3d22ee9..e9189c6 100644 --- a/verify/state-checksum/state_checksum.py +++ b/verify/state-checksum/state_checksum.py @@ -71,6 +71,26 @@ __all__ = [ DIGEST_BYTES = 16 # blake2b-128; 2**-64 collision floor at our object counts SHORT = 16 # hex chars shown in text output + +def _reader_fingerprint() -> str: + """Identity of the save_reader build the digests were computed under. + + The digest hashes each leaf's *inferred kind* alongside its bytes, so it is + a function of (save bytes, reader schema) -- not of the bytes alone. That + is fine for comparing two saves parsed by one reader, and dangerous for a + chain recorded months ago. So it is recorded and checked, never assumed. + It is deliberately NOT folded into the digest: a cosmetic reader edit + should not invalidate every recorded root, it should raise a warning. + """ + try: + with open(sr.__file__, "rb") as f: + return hashlib.blake2b(f.read(), digest_size=8).hexdigest() + except OSError: # pragma: no cover + return "unknown" + + +READER_FINGERPRINT = _reader_fingerprint() + BEGIN = struct.pack(" child tags to use as a human label, first match wins +#: frame tag -> child tags to use as a human label, first match wins. Tags are +#: as spelled on disk (SAVE_FORMAT.md section 10); `Ship` carries no name field, +#: so a ship is labelled by its id alone. NAME_FIELDS = { "Player": ("PlryName",), "Sys": ("Name",), - "Flt": ("FltNm", "Name", "FName"), - "Ship": ("ShpNm", "Name", "SName"), + "Flt": ("FtName",), "Des": ("DName",), } @@ -509,7 +530,8 @@ class SaveChecksum: "file": self.path, "root": self.digest, "policy": {"floats": self.policy, "mask": self.mask, - "digest": f"blake2b-{DIGEST_BYTES * 8}"}, + "digest": f"blake2b-{DIGEST_BYTES * 8}", + "readerFingerprint": READER_FINGERPRINT}, "coverage": self.coverage, "readerIssues": {lvl: self.res.count(lvl) for lvl in ("error", "warn", "info")}, "maskHits": self.mask_hits, @@ -647,12 +669,24 @@ def build_chain(files: list[str], **kw) -> dict: return {"format": "sots-state-chain/1", "policy": {"floats": kw.get("floats", "bits"), "mask": kw.get("mask", "none"), - "digest": f"blake2b-{DIGEST_BYTES * 8}"}, + "digest": f"blake2b-{DIGEST_BYTES * 8}", + "readerFingerprint": READER_FINGERPRINT}, "turns": entries} def verify_chain(chain: dict, files: list[str], **kw) -> tuple[bool, list[str]]: msgs, ok = [], True + pol = chain.get("policy") or {} + for field, got in (("floats", kw.get("floats", "bits")), + ("mask", kw.get("mask", "none"))): + if pol.get(field) not in (None, got): + msgs.append(f"!! chain was recorded with {field}={pol[field]!r}, " + f"verifying with {got!r} -- roots are not comparable") + ok = False + fp = pol.get("readerFingerprint") + if fp and fp != READER_FINGERPRINT: + msgs.append(f"!! chain was recorded under save_reader {fp}, this is " + f"{READER_FINGERPRINT} -- re-record before trusting a DIVERGE") rec = chain["turns"] if len(rec) != len(files): msgs.append(f"chain has {len(rec)} turns, {len(files)} saves given") @@ -756,7 +790,8 @@ def main(argv=None) -> int: "coverage: FAILED at inflated offset 0x%x" % (cov.get("firstDiff") or 0)) print(f"file: {ck.path}") print(f"root: {ck.digest}") - print(f"policy: floats={ck.policy} mask={ck.mask} digest=blake2b-{DIGEST_BYTES * 8}" + print(f"policy: floats={ck.policy} mask={ck.mask} " + f"digest=blake2b-{DIGEST_BYTES * 8} reader={READER_FINGERPRINT}" f"{_mask_note(ck)}") print(f"{covs}; {ck.root.leaves} leaves, {ck.root.value_bytes} value bytes") print(f"reader: {ck.res.count('error')} error, {ck.res.count('warn')} warn") @@ -787,7 +822,8 @@ def main(argv=None) -> int: return 0 if a.digest == b.digest else 1 print(f"A {a.digest} {a.path}") print(f"B {b.digest} {b.path}") - print(f"policy: floats={a.policy} mask={a.mask}{_mask_note(a)}") + print(f"policy: floats={a.policy} mask={a.mask} reader={READER_FINGERPRINT}" + f"{_mask_note(a)}") for ck, nm in ((a, "A"), (b, "B")): if ck.coverage.get("ok") is False: print(f"!! {nm}: coverage FAILED at 0x{ck.coverage.get('firstDiff') or 0:x} " diff --git a/verify/state-checksum/test_state_checksum.py b/verify/state-checksum/test_state_checksum.py index 960237a..2e6e01e 100644 --- a/verify/state-checksum/test_state_checksum.py +++ b/verify/state-checksum/test_state_checksum.py @@ -31,8 +31,15 @@ import state_checksum as sc # noqa: E402 # --- helpers ------------------------------------------------------------------ -def tiny(status_a=4, status_b=4, checksum=-1000, extra_ids=(), fval=1.5) -> bytes: - """A miniature save with the shapes the tool special-cases.""" +def tiny(status_a=4, status_b=4, checksum=-1000, extra_ids=(), + fval_a=1.5, fval_b=2.5) -> bytes: + """A miniature save with the shapes the tool special-cases. + + The `Sim` prefix fields (`KeyPath`..`NMnx`) are emitted because the reader + types `PlayerIDs` positionally off the Sim shape; without them the tag is + guessed and a 4-byte int is indistinguishable from a 2-byte string at the + same item size (SAVE_FORMAT.md section 2). + """ w = sw.SaveWriter("joint") w.begin("Summary") w.string("GameName", "T") @@ -40,17 +47,21 @@ def tiny(status_a=4, status_b=4, checksum=-1000, extra_ids=(), fval=1.5) -> byte w.int("Checksum", checksum) w.end() w.begin("Sim") + w.string("KeyPath", "") + w.int("NMSz", 16) + w.int("NMLc", 0) + w.int("NMnx", 109) ids = (16, 32) + tuple(extra_ids) w.int("PlayerIDs", len(ids)) for i in ids: w.int(".", i) w.int("NumPlrs", 2) - for pid, st in ((16, status_a), (32, status_b)): + for pid, st, fv in ((16, status_a, fval_a), (32, status_b, fval_b)): w.int("PlayerID", pid) w.begin("Player") w.string("PlryName", f"p{pid}") w.int("Status", st) - w.float("IdealSuit", fval) + w.float("IdealSuit", fv) w.end() w.end() return w.bytes() @@ -66,6 +77,16 @@ def find_saves() -> list[str]: REAL = find_saves() needs_saves = unittest.skipUnless(REAL, "no saves in $SOTS_SAVES_DIR or verify/results/saves") +_CACHE: dict = {} + + +def real_ck(path: str, **kw): + """Memoised checksum_save -- a real save costs ~6 s to parse.""" + key = (path, tuple(sorted(kw.items()))) + if key not in _CACHE: + _CACHE[key] = sc.checksum_save(path, **kw) + return _CACHE[key] + # --- coverage: the property that makes the digest evidence -------------------- @@ -115,7 +136,7 @@ class DigestTest(unittest.TestCase): base = sc.checksum_bytes(tiny()) seen = 0 for name, kw in (("status", dict(status_a=5)), ("checksum", dict(checksum=-1001)), - ("float", dict(fval=1.5000001))): + ("float", dict(fval_a=1.5000001))): with self.subTest(name): other = sc.checksum_bytes(tiny(**kw)) self.assertNotEqual(base.digest, other.digest) @@ -123,14 +144,36 @@ class DigestTest(unittest.TestCase): self.assertEqual(seen, 3) def test_a_one_bit_float_change_moves_the_root(self): - a = sc.checksum_bytes(tiny(fval=1.5)) + a = sc.checksum_bytes(tiny(fval_a=1.5)) (bits,) = struct.unpack("= b: continue - ca, cb = sc.checksum_save(a, audit=False), sc.checksum_save(b, audit=False) + ca, cb = real_ck(a, audit=False), real_ck(b, audit=False) d = sc.diff(ca.root, cb.root) if d and all(e.kind == "value" for e in d) and \ {e.path.rsplit("/", 1)[-1] for e in d} == {"Status", "Checksum"}: @@ -372,10 +430,10 @@ class RealSaveTest(unittest.TestCase): for b in REAL: if a >= b: continue - if sc.checksum_save(a, audit=False, mask="resave").digest == \ - sc.checksum_save(b, audit=False, mask="resave").digest and \ - sc.checksum_save(a, audit=False).digest != \ - sc.checksum_save(b, audit=False).digest: + if real_ck(a, audit=False, mask="resave").digest == \ + real_ck(b, audit=False, mask="resave").digest and \ + real_ck(a, audit=False).digest != \ + real_ck(b, audit=False).digest: found = True if not found: self.skipTest("no re-save pair among the available saves") @@ -384,7 +442,7 @@ class RealSaveTest(unittest.TestCase): def test_a_real_turn_transition_localises_to_named_objects(self): by_turn = {} for p in REAL: - ck = sc.checksum_save(p, audit=False) + ck = real_ck(p, audit=False) t = (ck.res.typed.get("summary") or {}).get("Turn") by_turn.setdefault(t, ck) if not {2, 3} <= set(by_turn):