Compare commits
2 commits
d1710e9676
...
940aecad0e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
940aecad0e | ||
|
|
4f805e381a |
11 changed files with 913 additions and 4 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
||||
|
|
|
|||
113
findings/subsystems/ai-client-seed-is-per-process.md
Normal file
113
findings/subsystems/ai-client-seed-is-per-process.md
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
6
verify/results/shim/l1/cdiff-frames-2-51.txt
Normal file
6
verify/results/shim/l1/cdiff-frames-2-51.txt
Normal 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
|
||||
23
verify/results/shim/l1/cdiff.py
Normal file
23
verify/results/shim/l1/cdiff.py
Normal 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])
|
||||
20
verify/results/shim/l1/seed-two-processes.txt
Normal file
20
verify/results/shim/l1/seed-two-processes.txt
Normal 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)
|
||||
22
verify/results/shim/l1/seedrep.py
Normal file
22
verify/results/shim/l1/seedrep.py
Normal 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)")
|
||||
667
verify/results/shim/l1/trace-playforward-f51.jsonl
Normal file
667
verify/results/shim/l1/trace-playforward-f51.jsonl
Normal file
File diff suppressed because one or more lines are too long
9
verify/results/shim/l1/trace-seed-proc1.jsonl
Normal file
9
verify/results/shim/l1/trace-seed-proc1.jsonl
Normal file
File diff suppressed because one or more lines are too long
9
verify/results/shim/l1/trace-seed-proc2.jsonl
Normal file
9
verify/results/shim/l1/trace-seed-proc2.jsonl
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue