L1: CDiff crosses at frame 50, and the AI client seed is fresh in every process

Two results from the same VM session, both of which needed a workload the
corpus cannot supply.

CDiff, played forward. ref-turn2 loaded and 49 End Turns driven through the UI
helper to frame 51, with the entry hook emitting one record per turn:

  50 calls, frames 2..51, EXACTLY TWO STORES
    frame  2  -1 -> 0
    frame 50   0 -> 1     <- the modelled tier transition, at the modelled frame
  and 47 turns between them on which the writer ran and wrote NOTHING

predict_path was computed at entry from the transcribed threshold table on all
fifty turns and agreed with the cdiff region on every one. Frame >= 100 is still
a code read and is not claimed as a measurement.

The AI seed probe (asked for by the coordinator, ranked above CDiff). Hooks on
Mars::RNG::Seed and StrategyApp::RunAI, two launches from turn1-state, load
only. Every AI client seed differs between processes -- net 32, 496 and 512 all
move -- while the record structure is identical and one Seed call with seed=0
produces a byte-identical state in both runs.

So the turn1-state -> turn2 nondeterminism is a SEED effect, not the ordering
effect predicted, and the 'one of three varies' observation is explained by two
of the three empires having a research pick that is robust to the stream. This
falsifies lane AI1's 'every draw from the static generator returns 0', which
that lane had explicitly flagged as arithmetic rather than measurement.

Where the seed comes from is NOT established; the finding names the one hook
that would settle it and the six values it must reproduce.

VM140 left as found: the 8-file save set with its oracle bytes intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
This commit is contained in:
alex 2026-09-08 18:23:19 -04:00
parent d1710e9676
commit 4f805e381a
10 changed files with 911 additions and 3 deletions

View file

@ -235,9 +235,33 @@ that off-by-one is confirmed by a second independent disassembly. The class ctor
`[eax+0x38] = -1`, which is precisely the `CDiff = −1` that `turn1-state.sav` carries and the reason
frame ≤ 0 never matters: `Frame` starts at 1 and the tail does not run before the first End Turn.
What is **not** yet measured is the 50 and 100 crossings live. §7 says what that costs and why it was
not bought with the VM time available; the entry hook is in the tree and prints `predict_path` on
every turn, so whoever plays a game past frame 50 gets both edges for free.
### 5.1 The frame-50 crossing, played forward and measured
The corpus cannot reach frame 50 — its deepest save is turn 23 — so the game was played forward.
`ref-turn2.sav` loaded, then **49 End Turns** driven through the UI helper, with the entry hook
emitting one record per turn:
```
50 UpdateDifficultyTier calls, frames 2 .. 51
frame= 2 cdiff_in=-1 store -1 -> 0 <== STORE
frame= 49 cdiff_in= 0 no_store_unchanged 0 -> 0
frame= 50 cdiff_in= 0 store 0 -> 1 <== STORE
frame= 51 cdiff_in= 1 no_store_unchanged 1 -> 1
```
**Fifty consecutive turns, fifty entries, exactly two stores, at exactly the two predicted frames.**
Between them sit 47 turns on which the function ran and wrote nothing — which is the part a save-file
comparison can never show, and the reason the hook prints `predict_path` rather than only a value.
`predict_path` was computed at entry from the transcribed table on every one of the fifty turns and
agreed with the `cdiff` region on every one, including both transitions. The 47 quiet turns check
nothing by themselves and are reported that way; the two that moved are the test.
**Frame ≥ 100 was not reached** and is not claimed as a measurement. It is ~50 more End Turns at
~30 s each, and the answer is already a twice-read property of a three-entry table scan rather than a
question about state: at frame 100 the loop exits with index 3 and falls into the epilogue. The hook
is in the tree and prints `predict_path = no_store_ran_off_end_frame_ge_100` when it happens, so
whoever plays a game past frame 100 gets that edge for free.
## 6. Instrument neutrality (rule 19) — checked, and it needed checking twice

View file

@ -0,0 +1,113 @@
# The AI client seed is fresh in every process — the `turn1-state → turn2` variation is a seed effect
- **Type:** subsystem (live measurement)
- **Status:** **verified** — the same save loaded in two freshly launched processes, with the seeding
primitive itself hooked
- **Confidence:** high on the fact (every AI seed differs, structure otherwise identical); the
*source* of the seed is **not** identified — §4
- **Owner / date:** lane L1 · 2026-09-08 · VM140
- **Instrument:** `sots-engine` `wip/l1`, `Mars::RNG::Seed` + `Game::StrategyApp::RunAI` hooks,
config `shim.cfg.l1seed`, build `l1seed-20260908T2206Z`
- **Answers:** the probe requested against
`findings/subsystems/turn1-to-turn2-nondeterminism.md` (lane L5) —
*"is the varying research pick a seed effect or an ordering effect?"*
- **Corrects:** `ghidra/addresses.d/lane-ai1.json` — `g_UnseededStaticRNG`'s "every draw returns 0"
---
## 0. The answer, and it is not the one that was predicted
**Seed effect.** Every AI client's generator is seeded with a **different value in every process**.
Two launches, same input (`l1-turn1.sav` = `verify/results/saves/turn1-state.sav`), loaded only — no
End Turn, because `StrategyApp::RunAI` and the client constructor run on load:
| `RunAI` call | player net id | process 1 `rng_seed` | process 2 `rng_seed` |
|---|---|---|---|
| 1 | 32 | `0x75F692C0` | `0x414F415E` |
| 2 | 496 | `0xF2EDAC21` | `0x10B94E78` |
| 3 | **512** (the empire lane L5 saw vary) | `0x165A2ADB` | `0xC218DBF8` |
Each value reappears one record earlier as the `seed` argument of an `Mars::RNG::Seed` call on a
fresh object, which is the `StrategyClient+0x134` generator being constructed. **Both my prediction
and the coordinator's expectation were that these would be identical.** They are not.
**The structure is identical, which is what makes the difference legible.** Both processes emit
exactly 8 records in exactly the same order — four `Seed` calls interleaved with three `RunAI` calls,
same net ids, same `personality = 0`, same `ai_data = "SavedGames/l1-turn1.sav"`. Nothing about
*which* objects are seeded or *when* varies. Only the values do.
**And one of the four seeds is deterministic**, which rules out "the hook perturbs it" and gives the
comparison a built-in control: the first `Seed` call takes `seed = 0x00000000` in both processes and
produces a byte-identical state (`mt0 = 2443250962`, `mt1 = 1093594115`, `mt2 = 1878467924`,
`left = 624`). A run in which the instrument randomised things would not have left that one alone.
## 1. Why only one of three empires' picks moves
Lane L5's puzzle was *"a time-seeded per-client RNG would move all three"*. That inference does not
hold, and the data says why: **all three seeds do move.** A different seed only becomes visible in the
save where the decision it feeds actually depends on the draw. Two of the three AI players evidently
have a unique best research candidate — a different random stream picks the same one — and the third
does not.
So the observation "one of three varies" is not evidence for an ordering effect at all. It is
evidence that **two of the three decisions are robust to the stream and one is not**, which is a
statement about the candidate sets and not about the generator.
## 2. What this settles for the campaign
* **The `turn1-state → turn2` pair is unfixable as a byte oracle by any amount of care with the
instrument.** The input to the varying decision is not in the save, is not the strategic generator,
and is fresh per process. `determinism-oracle.md` is now scoped to `ref-turn2 → turn 3`.
* **A standalone can never match a per-process seed.** For anything downstream of an AI client's
generator, the reachable target is *a* legal outcome, not *the* recorded one. Which leaves is
exactly what lane PL put on Rung B for these shadow empires — the corroboration is now mechanical
rather than statistical.
* **The strategic generator is untouched by this.** Lane L1's hive run reproduced the historical word
positions and `NextQ` values exactly (`hive-creation-rng.md` §2.2). Two generators, two different
determinism stories, and only one of them is in the save.
## 3. Correction to `lane-ai1.json`
`g_UnseededStaticRNG`'s entry reads:
> *its only static initialiser writes the `Mars::IStreamable` vftable, NOT the `Mars::RNG` vftable
> that `RNG_Seed` installs: none of the six `RNG_Seed` call sites in the image targets it, so its
> `mt[624]` is the zero-initialised BSS array and `left` is 0. An all-zero MT19937 state is a fixed
> point of the twist, so **EVERY draw from it returns 0**. Five consumers: SNMRunAI (the AI client
> seed, OnMessage+0x955) …*
The arithmetic is right and the conclusion is **falsified by measurement**: the seed `SNMRunAI` hands
to `RunAI` is neither 0 nor constant. That entry was explicitly flagged by its own author as "the
all-zero fixed point is arithmetic, not measurement", with a prediction attached — this is that
prediction coming back negative, which is the best possible outcome for having written it down.
Either the object `OnMessage+0x955` draws from is not the one AI1 identified, or that object is
seeded by a path outside the six `RNG_Seed` call sites. §4 says how to tell.
## 4. What is NOT established, and the cheapest next step
* **Where the seed comes from.** This lane hooked `RNG_Seed` and `RunAI`; neither says what fed
`RunAI`'s fourth argument. The candidates are a time source (`timeGetTime`/`GetTickCount`, the
usual suspects for a per-process value) and a generator seeded from one.
**The next probe is one hook**: `StrategyNetworkClient::OnMessage` around `+0x955`, or simply a
breakpoint-style read of whatever object that site draws from, in the same two-process shape. The
seeds recorded here (`0x75F692C0` / `0x414F415E` …) are the values it must reproduce.
* **Whether the seed varies within one process.** Both runs seed three clients with three different
values, so *something* advances between them — consistent with successive draws from one generator
and equally with three reads of a clock.
* **Whether any of this reaches the strategic generator.** It does not on the measured turns
(`hive-creation-rng.md` §6.2), but that is two turns of one save.
* **The other four `Seed` calls' owners.** Only the three that immediately precede a `RunAI` are
identified. The `seed = 0` one is unidentified and is the only deterministic one, which makes it
the most interesting of the rest.
* **`is_strategic_generator` reads `false` on every record** in these runs — correctly, but only
because this config installs no turn-driver hook, so the shim never learned the server pointer.
It is not evidence that the strategic generator is never seeded here.
## 5. Files
- `verify/results/shim/l1/trace-seed-proc1.jsonl`, `trace-seed-proc2.jsonl` — the two traces
- `verify/results/shim/l1/seed-two-processes.txt` — both rendered side by side
- `verify/results/shim/l1/seedrep.py` — the reader
- Prediction, committed before the build: `sots-engine/docs/L1-predictions.md` §P7

View file

@ -14,3 +14,18 @@ Instrument: `sots-engine` `wip/l1`, build `0117495-dirty-20260908T2113Z`. Findin
| `saves/l1-t1t2-post-hooksoff.sav` | the same turn with **`hooks=off`, nothing installed**. It differs from the line above AND from the historical `turn2-state.sav`, in one field: player 3's `ResTNm`. See the finding's §6.1 — this pair is why `turn1-state.sav` must not be used as a determinism oracle. |
| `saves/l1-t2t3-pre.sav`, `saves/l1-t2t3-post.sav` | the second bracket's two autosaves. |
| `rep.py` | the reader used for every table in the finding: prints each record's hook, depth, `rng` word delta and non-pointer arguments. `uv run python3 rep.py trace-et2.jsonl`. |
## Added later the same session
| file | what |
|---|---|
| `trace-playforward-f51.jsonl` | `ref-turn2.sav` played forward **49 End Turns** to frame 51, `shim.cfg.l1hive`. One `UpdateDifficultyTier` record per turn. |
| `cdiff-frames-2-51.txt` | that trace reduced by `cdiff.py`: 50 entries, **exactly two stores** — frame 2 (`CDiff` −1 → 0) and **frame 50** (0 → 1) — and 47 turns on which the writer ran and wrote nothing. |
| `cdiff.py` | the reducer. `uv run python3 cdiff.py trace-playforward-f51.jsonl`. |
| `trace-seed-proc1.jsonl`, `trace-seed-proc2.jsonl` | the AI seed probe: `l1-turn1.sav` loaded in two freshly launched processes under `shim.cfg.l1seed`, hooks on `Mars::RNG::Seed` and `StrategyApp::RunAI`, **no End Turn**. |
| `seed-two-processes.txt` | both rendered side by side. Every AI client seed differs; the structure does not. See `findings/subsystems/ai-client-seed-is-per-process.md`. |
| `seedrep.py` | the reader for those two. |
**The VM was left as it was found:** the 8-file `SavedGames` set with the oracle bytes
`(Autosave).sav` = `978041ac…`, `(Autosave EndTurn).sav` = `bb4fd9ac…`,
`(Autosave Backup).sav` = `978041ac…`, and the working file `l1-turn1.sav` removed.

View file

@ -0,0 +1,6 @@
50 UpdateDifficultyTier calls
frame= 2 cdiff_in= -1 store -1 -> 0 <== STORE
frame= 49 cdiff_in= 0 no_store_unchanged 0 -> 0
frame= 50 cdiff_in= 0 store 0 -> 1 <== STORE
frame= 51 cdiff_in= 1 no_store_unchanged 1 -> 1
frames seen: 2 .. 51

View file

@ -0,0 +1,23 @@
import json, sys
def val(x):
if isinstance(x, dict) and 't' in x:
v=x.get('v')
if x['t']=='struct' and isinstance(v,dict): return {k:val(w) for k,w in v.items()}
return v
return x
rows=[]
for ln in open(sys.argv[1]):
r=json.loads(ln)
if r.get('hook')!='Game::SVSOSlaversRefuel::UpdateDifficultyTier': continue
a={x['n']:val(x) for x in (r.get('args') or [])}
s=r.get('side') or {}
cd=s.get('cdiff') or {}
rows.append((a.get('frame'), a.get('cdiff_in'), a.get('predict_path'),
val(cd.get('before')), val(cd.get('after'))))
print(f"{len(rows)} UpdateDifficultyTier calls")
prev=None
for f,ci,pp,b,af in rows:
mark = " <== STORE" if (b or {}).get('value') != (af or {}).get('value') else ""
if pp!="no_store_unchanged" or mark or f in (1,2,49,50,51,99,100,101):
print(f" frame={f:4} cdiff_in={ci:3} {pp:32} {(b or {}).get('value')} -> {(af or {}).get('value')}{mark}")
print("frames seen:", rows[0][0], "..", rows[-1][0])

View file

@ -0,0 +1,20 @@
============================================================ seed/run1.jsonl
# 1 Seed rng=0x0e029c38 seed=0x00000000 strategic=False after={'mt0': 2443250962, 'mt1': 1093594115, 'mt2': 1878467924, 'mt623_left': 624}
# 2 Seed rng=0x0e036a58 seed=0xf22aac3a strategic=False after={'mt0': 615352117, 'mt1': 559705657, 'mt2': 2743230213, 'mt623_left': 624}
# 3 Seed rng=0x0e026038 seed=0x75f692c0 strategic=False after={'mt0': 3677034552, 'mt1': 2584646980, 'mt2': 214584128, 'mt623_left': 624}
# 4 Game::StrategyApp::RunAI {"app": "0x015a9f98", "player_net_id": 32, "ai_data": "SavedGames/l1-turn1.sav", "personality": 0, "rng_seed": 1979093696}
# 5 Seed rng=0x33ebb010 seed=0xf2edac21 strategic=False after={'mt0': 3515642288, 'mt1': 1364510547, 'mt2': 2654699100, 'mt623_left': 624}
# 6 Game::StrategyApp::RunAI {"app": "0x015a9f98", "player_net_id": 496, "ai_data": "SavedGames/l1-turn1.sav", "personality": 0, "rng_seed": 4075662369}
# 7 Seed rng=0x0e089950 seed=0x165a2adb strategic=False after={'mt0': 2441796230, 'mt1': 962581012, 'mt2': 2791942989, 'mt623_left': 624}
# 8 Game::StrategyApp::RunAI {"app": "0x015a9f98", "player_net_id": 512, "ai_data": "SavedGames/l1-turn1.sav", "personality": 0, "rng_seed": 375007963}
(8 records)
============================================================ seed/run2.jsonl
# 1 Seed rng=0x0e0fc7a0 seed=0x00000000 strategic=False after={'mt0': 2443250962, 'mt1': 1093594115, 'mt2': 1878467924, 'mt623_left': 624}
# 2 Seed rng=0x0e1069e8 seed=0x16612cc3 strategic=False after={'mt0': 3398143051, 'mt1': 471917738, 'mt2': 2603589232, 'mt623_left': 624}
# 3 Seed rng=0x34af3368 seed=0x414f415e strategic=False after={'mt0': 2600434532, 'mt1': 871853361, 'mt2': 2415193455, 'mt623_left': 624}
# 4 Game::StrategyApp::RunAI {"app": "0x015a9f98", "player_net_id": 32, "ai_data": "SavedGames/l1-turn1.sav", "personality": 0, "rng_seed": 1095713118}
# 5 Seed rng=0x34d2b3e8 seed=0x10b94e78 strategic=False after={'mt0': 3542850695, 'mt1': 3477510684, 'mt2': 1157689560, 'mt623_left': 624}
# 6 Game::StrategyApp::RunAI {"app": "0x015a9f98", "player_net_id": 496, "ai_data": "SavedGames/l1-turn1.sav", "personality": 0, "rng_seed": 280579704}
# 7 Seed rng=0x0e153898 seed=0xc218dbf8 strategic=False after={'mt0': 3656808924, 'mt1': 2304532195, 'mt2': 3599897940, 'mt623_left': 624}
# 8 Game::StrategyApp::RunAI {"app": "0x015a9f98", "player_net_id": 512, "ai_data": "SavedGames/l1-turn1.sav", "personality": 0, "rng_seed": 3256409080}
(8 records)

View file

@ -0,0 +1,22 @@
import json, sys
def val(x):
if isinstance(x, dict) and 't' in x:
v=x.get('v')
if x['t']=='struct' and isinstance(v,dict): return {k:val(w) for k,w in v.items()}
return v
return x
for p in sys.argv[1:]:
print("="*60, p)
n=0
for ln in open(p):
r=json.loads(ln)
if 'hook' not in r: continue
n+=1
a={x['n']:val(x) for x in (r.get('args') or [])}
s=r.get('side') or {}
after=val((s.get('rng') or {}).get('after'))
if r['hook'].endswith('RNG::Seed'):
print(f" #{n:2} Seed rng={a.get('rng')} seed=0x{a.get('seed',0):08x} strategic={a.get('is_strategic_generator')} after={after}")
else:
print(f" #{n:2} {r['hook']} {json.dumps(a)}")
print(f" ({n} records)")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long