sots-re/findings/subsystems/determinism-oracle.md
alex 8ad9971f6e lane H: five live probes, the oracle restored, and a harness perturbation bisected to one hook
- determinism oracle regenerated and byte-identical (bb4fd9ac / 978041ac)
- the four phase-23/33 draw-bearing tail callees run EVERY turn; the three inner
  functions holding the draws run zero times -- the gate is inside each outer body
- CreateRaidEncounter is called (2 on one turn) and draws nothing: candidate list empty
- Zuul: 7 calls / 7 words per trade-raid Chance site, 14 not 16, as predicted
- EncounterDetect_Run receives an EMPTY record vector, so ProcessTeamRecord and
  AssignContacts never run; the 2-word detection residual is in 0x007d5150's subtree
- a MinHook detour on 0x00893290 changes the game's output; bisected over six runs.
  The un-instrumented game and lane Z's instrument agree, so lane Z's numbers stand
- lane AI1 insert: P2 held across two fresh processes, Rung B stays as written
2026-09-08 13:53:36 -04:00

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

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