sots-re/findings/subsystems/determinism-oracle.md
alex a3e6e1d415 L1: hive creation draws inside BeginProcessTurn -- lane Z's zero was the workload
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
2026-09-08 17:59:32 -04:00

12 KiB
Raw Blame History

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.)

  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).