Lane SV recovered the script-object subsystem statically and predicted that SVSOSwarmQueen::RegisterHives takes one strategic-generator word per new hive inside StrategyServer::BeginProcessTurn, which runs inside lane Z's autosave bracket and outside both turn drivers. Nothing had ever been hooked in that interval. Measured on VM140, and it is right. turn 1 -> 2 (hives created) BeginProcessTurn 2 ProcessTurn 20 residual 2 turn 2 -> 3 (hives exist) BeginProcessTurn 0 ProcessTurn 19 residual 0 Two instruments that share no code path agree: the region ledger reads 170 -> 172 across RegisterHives, and the return-address draw-site table gains a row at 0x00527714 (NextInt, 2 calls / 2 words) which is absent on the next turn. The draws produce NextQ 30/28, TickHives' slip takes them to 31/29 -- exactly turn2-state.sav -- and the next turn's slip gives 32/30, exactly turn3-state.sav. So tail-rng-ledger.md's "all of it inside ProcessTurn, residual exactly zero" is correct for every turn it measured and false as a statement about the code. The interval a standalone must reproduce starts at BeginProcessTurn. Also closed or corrected: * LO/HI read live rather than fitted: NextQ = frame + 20 + NextInt(10), inclusive; the TickHives gates are 10 (a frame floor), 5 and 3, so no queen can spawn before frame 11 on any save. * CDiff's threshold scan re-read independently: the tier can only ever be 0 or 1, and "entered and stored nothing" was observed live and distinguished from "did not run" (method rule 20). * SnLv measured for the first time: Spica reads AFlags 0 with SnLv 0x200, so the sensor branch is what refreshes it -- nvo-tshn-visible-owner.md §6 goes [H] to [V]. ComputeContactLevel's documented "else 1" names one of four return tails; the observed non-visible level is 2. * Rule 19: the oracle reproduced byte for byte with all five new detours live, and again with four watchpoints armed on top. * turn1-state -> turn2 is not deterministic (lane L5 owns this). Two more runs here make it four distinct outcomes over six runs, including two DIFFERENT hooks=off results, and the strategic generator is provably not what varies. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
173 lines
12 KiB
Markdown
173 lines
12 KiB
Markdown
# End-Turn determinism oracle — verified on the real game (2026-09-07)
|
||
|
||
Question: "load the same save, press End Turn, and the resulting autosave is byte-identical run to run"
|
||
(the Phase-2 verification oracle). Answer: **YES — byte-identical**, across five runs, in two separate
|
||
game processes, with and without an intervening manual save, and also identical to the autosave the
|
||
*original* (non-loaded, freshly created) game wrote in the earlier session.
|
||
|
||
> **SCOPED, 2026-09-08 — the oracle is a property of THIS PAIR, not of End Turns.** Every run below
|
||
> starts from the **turn-2** state. Lanes L5 and L1 independently ran the same experiment one turn
|
||
> earlier — load `turn1-state.sav` (`a3f9dc4b…`, this game's turn-1 pre-turn autosave), one End Turn
|
||
> — and across six runs got **four different post-turn autosaves**, including **two different results
|
||
> from `hooks=off` with nothing installed at all**. The whole difference is one shadow-empire
|
||
> player's research target `ResTNm` plus its two tech-status words and the derived `Checksum`;
|
||
> players 0–2 pick identically every time and the strategic generator's trajectory is unchanged.
|
||
>
|
||
> Nothing below is wrong. But "load the same save, press End Turn, and the autosave is
|
||
> byte-identical" is verified for `ref-turn2 → turn 3` and **falsified for `turn1-state → turn 2`**.
|
||
> Pick the pair deliberately: this document's pair and the two in
|
||
> `verify/results/shim/tailrng/` are the ones that hold. Full account:
|
||
> `findings/subsystems/turn1-to-turn2-nondeterminism.md` (lane L5, owner) and
|
||
> `findings/control-flow/hive-creation-rng.md` §6.1 (lane L1, the two extra runs).
|
||
|
||
## Verdict
|
||
|
||
**byte-identical.** No masking is needed for the End Turn oracle when both sides start from the same
|
||
save file. sha256 of `(Autosave).sav` after End Turn from `Autosave - turn2.sav` is
|
||
`978041acd168b56ed8eb3f5e42e78d5e70eae6e6517d75e659a5eb7ca3d60921` in every run (A–E) and equals the
|
||
`Autosave - turn3.sav` captured in the first session (different process, game created live, not loaded).
|
||
|
||
The only place bytes ever varied is **load → save round-trip of a *post-turn* autosave**: the game
|
||
resets a per-player `Status` int (4 → 0) on load, which also moves the derived top-level `Checksum`.
|
||
See "Round-trip stability" below for the canonicalization rule if a harness ever compares a loaded
|
||
`(Autosave).sav` against a re-save of it.
|
||
|
||
## Revalidated 2026-09-08 (lane H)
|
||
|
||
Lane N's End Turns overwrote the three autosave *contents* on VM140 (the file set was untouched).
|
||
Lane H regenerated the pair by the documented procedure — load `ref-turn2.sav` (`ab4ac2d7…`, verified
|
||
first), one End Turn, build `recap-7584bad-20260908T0615Z`, `hooks=trace` — and **both hashes below
|
||
reproduced exactly**: `(Autosave EndTurn).sav` 66,732 B `bb4fd9ac89f41e3b…`, `(Autosave).sav`
|
||
67,219 B `978041acd168b56e…`.
|
||
|
||
That is a stronger result than a repeat, because the process is no longer the one this document
|
||
measured: nineteen template hooks and seven RNG entry-point detours are now installed instead of a
|
||
single asm stub, an x87 control-word module is present, and `SavedGames\` holds eight files rather
|
||
than four. The oracle survives all of it. Copies kept at
|
||
`verify/results/shim/probes/oracle-restore/h-oracle-{endturn,autosave}.sav`, and the same bytes were
|
||
put back on the VM as the three autosaves.
|
||
|
||
**Related, and it belongs next to the oracle:** lane H found that a MinHook detour on
|
||
**0x00893290** changes the game's output — same input save, `(Autosave).sav` 4 bytes different and
|
||
one generator word short (`findings/control-flow/tail-probes.md` §2). The un-instrumented game and
|
||
the lane-Z instrument agree; that one hook does not. **A behavioural harness must be checked against
|
||
the oracle, not only against itself.** The cheap standing check is: load
|
||
`verify/results/shim/tailrng/z2-endturn.sav`, one End Turn, expect
|
||
`(Autosave).sav` = `b2124798470d85ea86b0e9e4abfd9e23f3be73caa1115dd7614021be76305939`.
|
||
|
||
## Environment
|
||
|
||
* VM140 `sots-re-win10`, `C:\SOTS\Sword of the Stars.exe` GOG v1.8.1 (`ver.1.8.1 Wed Dec 13 03:38:31 2017`),
|
||
DXVK 3.1 on Mesa lavapipe, windowed 1024x768 (see `Z:\saves\README.md` for the launch recipe).
|
||
* Phase-2 shim (`binkw32.dll` proxy, build `4a15301-20260907T2118Z`) deployed with `shim.cfg` `hooks=trace`
|
||
— only hooks `Mars_Application_Initialize` and logs; it was loaded in both processes used here
|
||
(`shim.log`: pid 3808 then pid 4384).
|
||
* Input save: `Z:\saves\Autosave - turn2.sav` (sha256 `ab4ac2d7…`, 66,739 B) copied to
|
||
`C:\SOTS\SavedGames\ref-turn2.sav`. It is the *post-turn* autosave of turn 2 (state after End Turn #1
|
||
completed): 2 players (`re` = Morrigi @ Gamma Cephei, `Fane Lao` = AI), 28 stars, game name `MyGameverify1`.
|
||
* The three autosaves the game had in `SavedGames\` before this work (turn 5-ish, 76 kB, from a later
|
||
session) were moved to `pre-existing\` so the Load dialog only showed known files.
|
||
* UI driven with `recipe/click_helper.ps1` via the `SOTSUI` scheduled task; screenshots via `qm screendump`.
|
||
|
||
Click path per run (1024x768 window at 0,0):
|
||
main menu `Load Game` (512,536) → `Single Player` (512,290) → `OK` (551,523) → pick row → `OK` (682,624)
|
||
→ lobby `Launch` (511,663) → ~30 s load → strategy map "Turn 2", Imperial Savings 289,688 →
|
||
`End Turn` (100,714) → ~5 s → "Turn 3", Savings 532,369 → in-game menu (1000,714) → `Quit to Main Menu` (938,699) → `OK` (537,377).
|
||
Manual save: menu → `Save` (938,679) → type suffix into the pre-filled name → `Save` (773,234) → `OK` → `Done` (773,542).
|
||
|
||
## Runs
|
||
|
||
| Run | Process | Procedure | `(Autosave EndTurn).sav` | `(Autosave).sav` |
|
||
|---|---|---|---|---|
|
||
| A | pid 3808 (already running) | load `ref-turn2` → End Turn | `bb4fd9ac89f41e3b` 66,732 B | `978041acd168b56e` 67,219 B |
|
||
| B | pid 3808 (same process) | quit to menu → load `ref-turn2` → End Turn | `bb4fd9ac89f41e3b` | `978041acd168b56e` |
|
||
| C | **pid 4384 (game exited with code 0, relaunched via task `SOTS`)** | load `ref-turn2` → End Turn | `bb4fd9ac89f41e3b` | `978041acd168b56e` |
|
||
| D | pid 4384 | load `ref-turn2` → **manual save `MyGameverify1rtD`** → End Turn | `bb4fd9ac89f41e3b` | `978041acd168b56e` |
|
||
| E | pid 4384 | load `MyGameverify1verify1` (the Status=0 form) → **manual save `MyGameverify1rtE`** → End Turn | `bb4fd9ac89f41e3b` | `978041acd168b56e` |
|
||
| ref (first session, 16:03) | pid 6396-era, game created live, never loaded | End Turn #2 of the live game | `Autosave EndTurn - turn3.sav` = `bb4fd9ac89f41e3b` | `Autosave - turn3.sav` = `978041acd168b56e` |
|
||
|
||
Full hashes (`runX/sha256.txt`):
|
||
|
||
```
|
||
978041acd168b56ed8eb3f5e42e78d5e70eae6e6517d75e659a5eb7ca3d60921 (Autosave).sav [turn-3 post-turn state] runs A B C D E + first session
|
||
bb4fd9ac89f41e3bc0db2af08b18ce83417521ac4bcee695fc9fa6ce16e30948 (Autosave EndTurn).sav [turn-2 pre-turn state] runs A B C D E + first session
|
||
bb4fd9ac89f41e3bc0db2af08b18ce83417521ac4bcee695fc9fa6ce16e30948 MyGameverify1rtD.sav manual save after loading ref-turn2 (run D)
|
||
bb4fd9ac89f41e3bc0db2af08b18ce83417521ac4bcee695fc9fa6ce16e30948 MyGameverify1rtE.sav manual save after loading MyGameverify1verify1 (run E)
|
||
bb4fd9ac89f41e3bc0db2af08b18ce83417521ac4bcee695fc9fa6ce16e30948 MyGameverify1verify1.sav manual save from the first session (turn 2)
|
||
ab4ac2d7e2977260a4de916f65c7ecc6dd5b0094dac4bb51241317232ea17ab4 ref-turn2.sav input (= Z:\saves\Autosave - turn2.sav)
|
||
```
|
||
|
||
Inflated (gunzipped) streams are equally identical: `(Autosave).sav` → 609,080 B `17db7ed455357c4d…`,
|
||
`(Autosave EndTurn).sav` → 603,360 B `29f194dfcd932b93…`, `ref-turn2.sav` → 603,360 B `2fcf34ce4296b160…`.
|
||
The gzip container is deterministic too (header `1f8b 0800 00000000 00 0b`: MTIME=0, XFL=0, OS=11/NTFS,
|
||
no name/comment) — the compressed bytes match, not just the payload.
|
||
|
||
## What End Turn writes
|
||
|
||
On the transition to the new turn the game writes, in `C:\SOTS\SavedGames\` (all at the same second):
|
||
|
||
* `(Autosave EndTurn).sav` — the **pre-turn** state, i.e. the state at the moment End Turn was pressed.
|
||
It is byte-identical to a manual save made on that turn.
|
||
* `(Autosave).sav` — the **post-turn** state (new turn number, RNG advanced, economy applied).
|
||
* `(Autosave Backup).sav` — the previous `(Autosave).sav`, rotated (only appears from the second End
|
||
Turn in a process's SavedGames history; it is a rename, hash unchanged).
|
||
|
||
Nothing is written on Load or on Launch from the lobby. The Load dialog lists files by filename with the
|
||
in-file `GameName`, player count and `Turn`. Files in the first-session `Z:\saves\` were renamed copies of
|
||
these (`Autosave - turnN.sav` = `(Autosave).sav`, etc.).
|
||
|
||
No timestamp, save name, machine id or session salt is stored anywhere in the file: `MyGameverify1verify1`,
|
||
`MyGameverify1rtD`, `MyGameverify1rtE` and `(Autosave EndTurn)` — four different file names, three of
|
||
them written minutes apart in two processes — are one and the same byte sequence.
|
||
|
||
## Round-trip stability (load → save with no End Turn)
|
||
|
||
* Loading the **pre-turn / manual form** and saving immediately reproduces the loaded bytes exactly:
|
||
`MyGameverify1verify1.sav` (`bb4fd9ac…`) → load → Save → `MyGameverify1rtE.sav` = `bb4fd9ac…`. **Stable.**
|
||
* Loading the **post-turn `(Autosave).sav` form** and saving immediately does *not* reproduce it:
|
||
`ref-turn2.sav` (`ab4ac2d7…`, 66,739 B) → load → Save → `MyGameverify1rtD.sav` = `bb4fd9ac…` (66,732 B).
|
||
The two inflated streams (603,360 B each) differ in exactly **5 bytes**:
|
||
|
||
```
|
||
inflated offset (1-based) ref-turn2 re-save field (save_reader --dump path)
|
||
89 0x64 0x54 Summary.Checksum int -1205790620 -> -1205790636 (delta 16 = 4 x 4)
|
||
66291 (0x102e8+4) 4 0 Player[PlryName="re"].Status
|
||
116067 (0x1c558+4) 4 0 Player[PlryName="Fane Lao"].Status
|
||
177739 (0x2b640+4) 4 0 Player[PlryName="Singularity"].Status (1st of two Singularity records)
|
||
235567 (0x39824+4) 4 0 Player[PlryName="Singularity"].Status (2nd)
|
||
```
|
||
|
||
The other four `Player.Status` fields (Alien Menace, Peacekeeper Enforcer, Von Neumann, Independent
|
||
Colony) are 0 in both. Interpretation: `Status=4` is the "turn ended / ready" flag the server leaves on the
|
||
four turn-participating player records when it writes the post-turn autosave; loading a game clears it.
|
||
`Summary.Checksum` moves by exactly the sum of the changed bytes (16), consistent with an additive
|
||
checksum over the payload — treat it as **derived**, never as state.
|
||
(`dumps/diff_ref-turn2_vs_EndTurn.txt`, `dumps/cmp_ref-turn2_vs_EndTurn.txt`.)
|
||
|
||
## Recommended canonicalization rule for the harness
|
||
|
||
1. **Oracle compare (End Turn):** compare `(Autosave).sav` and `(Autosave EndTurn).sav` **byte-for-byte**
|
||
(sha256 of the file is enough; no inflate, no masking). Expected for the reference run from
|
||
`Autosave - turn2.sav`: `(Autosave).sav` = `978041acd168b56e…`, `(Autosave EndTurn).sav` = `bb4fd9ac89f41e3b…`.
|
||
2. **If a re-implementation compares its output against a *loaded* post-turn autosave, or compares a
|
||
pre-turn save with a post-turn one of the same turn**, canonicalize by masking exactly:
|
||
* every `Player.Status` int (set to 0), and
|
||
* `Summary.Checksum` (recompute, or ignore).
|
||
Nothing else varies. Use `save_reader.py --dump` on the inflated stream; both fields are named as
|
||
above at fixed paths.
|
||
3. Do not mask anything in the gzip container — it is deterministic (MTIME 0), so a container-level
|
||
sha256 is a valid first-line check; inflate only to localize a mismatch.
|
||
|
||
## Files in this directory
|
||
|
||
* `runA/ … runE/` — the raw `SavedGames\` contents after each run, plus `sha256.txt`.
|
||
* `pre-existing/` — the three later-turn autosaves that were in `SavedGames\` before this work (moved aside).
|
||
* `dumps/` — `save_reader.py --dump` text of `ref-turn2` (input), `Autosave-EndTurn-turn2` and
|
||
`Autosave-turn3` (outputs), plus the field diff and `cmp -l` of the round-trip pair.
|
||
* `inflated/` — gunzipped streams of the same three files.
|
||
* `screenshots/` — console captures of each run's loaded state / result / save dialogs.
|
||
* `hashes.txt` — sha256 of every `.sav` across all runs.
|
||
|
||
State left behind: the game is still running on VM140 (pid 4384, task `SOTS`, shim `hooks=trace`), sitting
|
||
on the turn-3 strategy map after run E; `C:\SOTS\SavedGames\` holds the run-E set (`ref-turn2.sav`,
|
||
`MyGameverify1*.sav`, the three `(Autosave*)` files).
|