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

173 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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