219 lines
16 KiB
Markdown
219 lines
16 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.
|
||
|
||
> **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.
|