sots-re/findings/subsystems/determinism-oracle.md

219 lines
16 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.
> **CORRECTED IN PLACE 2026-09-09 (lane BQ, rule 11).** "Mask `Player.Status` and `Summary.Checksum`;
> **nothing else varies**" is **false** on the late-game saves. `--mask resave` leaves one more leaf
> standing: **`/CD[1]/NPrvVa`, which advances by exactly +5.00 across a load** (and per turn
> boundary), and falls on a combat turn. The old rule was derived on three-player early-game saves
> whose `CD[1]` diplomacy block is empty, and it was true of those. **Any load → turn comparison must
> mask `NPrvVa` too, or no load → turn run can ever reproduce a continuation's hash** — lane BQ's
> probe 1b was unsatisfiable on that leaf alone before any AI behaviour entered the question.
>
> The canonicalisation itself is **exonerated**: the pre-turn round trip is byte-exact apart from
> that one leaf. What is *not* safe is assuming a continuation's hash is reachable by load at all —
> see the board's load-boundary scope row: an AI fleet was issued a move order in the continuation
> and none after a load, **both reproducibly**.
## 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).
## Qualified 2026-09-08 (lane L5; decision by the project)
**The byte-identical-autosave property holds on turns where no AI player has to choose a research
target. It does not hold on turns where one does.**
Lane L5 ran `turn1-state.sav → End Turn` three times on VM146 and got three different post-turn
autosaves, differing in exactly four leaves: one Singularity AI empire's research pick and
`Summary/Checksum` derived from it. The instrument is exonerated — two of the three runs carried
identical hooks and still differed, and the `hooks=off` run produced a third value.
Every earlier verification of this oracle used `ref-turn2.sav` (which lane AI4 showed is
byte-identical to `turn2-state.sav`). On that save all three AI players already hold a research
target, so the decision layer has nothing to decide; on `turn1-state` they must each choose (lane
AI4: all three set `ResRate 0.25→0.8` and pick a target on turn 1). The oracle was never wrong; it
was measured on the one turn where the non-deterministic layer was idle.
Why this does not break lockstep multiplayer: the AI is a `StrategyClient` (lane AI1), not part of
the sim. It decides once, on one machine, and its decisions are relayed as `TurnCommands` like any
player's. The sim — the two turn drivers, the tail, the strategic generator (lane Z, zero residual,
which the AI never draws from) — remains deterministic given the command stream. Peers reproduce
the response to a command, not the decision.
Consequences adopted:
- Canonical reference pair for the standalone metric is now **`turn2-state → turn3-state`**.
`turn1-state → turn2` is secondary, with the four leaves masked.
- Rung B is restated as *byte-match given a recorded command stream* (`--turn-commands`); a
behaviourally equivalent `game/ai` becomes Rung C, verified against the distribution of the
original's choices, not one run.
- Mechanism (inference, one probe pending with lane L1): only one of three AI players varies, which
suggests a tie broken by per-process iteration order over a pointer-keyed container under ASLR,
rather than a time seed. Reading the AI client's generator state after construction in two
processes separates the two.