sots-re/findings/subsystems/roll-parity.md
alex e966a72fd9 PAR: record the gates, and the one difference between the deployed and committed builds
clean_room_check OK, host ctest 55/55, CT111 shim cross-build OK on a fresh build directory, all
three as separate commands. The deployed instrument (par4) differs from the committed source only
by two RVA literals that the named constants resolve to identically -- said here rather than left
for a reader to find. Also flags that main moved under the branch, so the header must be
regenerated at merge, not hand-resolved.
2026-09-08 19:37:56 -04:00

522 lines
32 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.

# Roll parity — does an AI client's turn cost a fixed number of RNG words?
- **Type:** subsystem (live measurement + static classification)
- **Status:** **verified — the answer is NO**, at every scope the question can be asked at
- **Confidence:** high on the negative (measured on two boards, three AI clients, six runs in six fresh
processes, with the instrument's autosaves byte-identical to the published oracle);
medium on the completeness of the site inventory (§6 says what is not covered)
- **Owner / date:** lane PAR · 2026-09-08 · VM140 (reference) + VM145
- **Instrument:** `sots-engine` `wip/par`, new shim module `src/shim/hooks/ai_rng.cpp`,
configs `shim.cfg.par{ctl,br,pin,orders}`, builds `par-…` … `par4-6ba2c79-20260908T2322Z`
- **Answers:** the coordinator's redirect — *"each run of an AI client consumes a fixed number of
RNG draws regardless of the path it takes"*, the lockstep-discipline hypothesis
- **Corrects:** `ai-turn-logic.md` §5 (zero NextFloat calls from the AI module), §3.1 (the phase
list is not flat), §4.3 (the `cl_*` façade has three RNG members, not two, and ten
`cl_RandRange` call sites, not eight); `ai-task-system.md` §10.1's six direct `NextInt` sites
---
## 0. The verdict, at five scopes
**The hypothesis is false, and it is false in the strongest available way: it fails at the
primitive, at the site, at the client, at the turn, and even at the point where the turn begins.**
There is no scope at which an AI client's word count is fixed.
| scope | claim | measured |
|---|---|---|
| **per turn, across clients** | all AI clients of one turn cost the same | **3 / 0 / 0** on `turn2→turn3`; **7 / 0 / 1** on `turn1→turn2` |
| **per client, across turns** | one client costs the same each turn | client 32: **3** on turn 2, **7** on turn 1 |
| **per client, across processes (path fixed)** | one client's count is a property of the board | client 512, turn 1, **one and the same `cl_RandRange` call** (the research tie-break, `0x006a8495`): **1 word** in one process, **3 words** in another |
| **per site** | a site that is reached always costs the same | `RNG_Chance` costs **0 words** at `p ≤ 0` and `p ≥ 1`; `RNG_NextInt` is an **unbounded rejection loop** |
| **at the start of the turn** | a client's stream position is a function of the save | client 32 began its turn at block index **9** in one process and **11** in another, from the same file |
The two shadow empires (496, 512) own nothing at all — no colonies, no fleets, no systems — and
they still do not agree with each other: on turn 1, 496 spends **0** and 512 spends **1 call
costing 1 or 3 words**. So the count is not even a function of "what the empire owns".
**What this means for `game/ai`, in one line: keeping the generator aligned requires the AI's
*decisions* to be right, not merely its draw count. C-exact does not get cheaper.** §7.
---
## 1. The instrument
`StrategyClient::OnResumePlaying 0x00777480` is an exact bracket around one client's whole turn:
`if (this->+0x12c) agent->vt[1](0x26, ev)` at +0x9d runs the entire AI Process Turn synchronously,
and `this` names the client, its player net id (`+0x148`), its agent (`+0x12c`, non-null only on an
AI client) and its private generator (`+0x134`).
One MinHook detour on that function, plus an **observer registered on lane H's existing seven
draw-site detours** rather than a second set of hooks on the same targets (MinHook allows one hook
per target, and those detours are installed unconditionally whenever `hooks != off`). Two
independent measurements of the same number, per bracket:
* `observed` — the sum over the entry points, with per-return-address attribution;
* `left_delta` — `left` (RNG+0x9c8) read straight off the object at entry and exit.
They are printed together with their difference on **every** bracket, including when it is zero.
Also printed unconditionally: `agent=`, so "this client has no AI" is distinguishable from "the AI
ran and drew nothing" (method rule 20); and `foreign_words`, the count of draws that happened inside
the bracket on some *other* generator.
Two further hooks, on `cl_Chance 0x00578cf0` and `cl_RandRange 0x005798e0`, record
`__builtin_return_address(0)` so a draw is attributed to the **AI call site** and not to the one
address inside the façade where every façade draw would otherwise pile up. `cl_RandFloat 0x00579c70`
is deliberately **not** hooked — §2.
`airng.pin_seed=<hex>` re-seeds the client generator at bracket entry, so two runs start from an
identical generator state. It changes the game's behaviour on purpose; a pinned run's autosave is
never compared against the oracle.
### 1.1 Rule 19 and rule 26, discharged
The instrumented, **unpinned** runs on the canonical pair produce
`(Autosave EndTurn).sav = bb4fd9ac89f41e3b` and `(Autosave).sav = 978041acd168b56e` —
**byte-identical to the published oracle**, which was produced with `hooks=off` in a different
process on a different day. That is a stronger control than a same-key `airng=off` pair, and it was
reproduced in **two fresh process launches** (rule 26). The detour is behaviour-neutral.
### 1.2 The instrument caught its own defect, which is the point of having two measurements
The first bracket containing a `cl_Chance` printed `observed=8` against `left_delta=7`.
`RNG_Chance`'s own body calls `RNG_NextFloat`, and **both are detoured**, so one drawn word was
reported twice — once against the game's call site and once against `0x008e6e09`, the instruction
after Chance's internal call. This is exactly the nested-entry-point double count `draw_sites.h`
warned about for `IntRangeBell`/`GaussianRange` and had never seen. Fixed by rejecting the inner
row's *words* while keeping its *call count* visible, so the nesting stays legible instead of
silently disappearing. `left_delta` was never affected — which is why there are two measurements.
---
## 2. `cl_RandFloat 0x00579c70` — a third façade, found twice, and why nobody saw it
`ai-turn-logic.md` §5 states, from a rel32 scan of the AI band for the seven RNG entry points:
*"Zero calls from the module to `NextFloat`, `Chance`, `NextUInt`, `Twist`, `Seed` or
`GaussianRange`."* The very first live bracket recorded a **`NextFloat` at return address
`0x006ad878`** — inside the AI band, inside the ship-design composer `0x006ad700`.
The mechanism is a **tail jump**:
```
00579c70 mov eax,ds:0xae4808 ; g_CurrentClientIndex
00579c75 fldz
00579c77 mov eax,[eax*4+0xae47e4] ; g_StrategyClients[idx]
00579c7e test eax,eax
00579c80 je 0x579c92 ; no current client -> return 0.0f, NO DRAW
00579c82 mov ecx,[eax+0x134] ; the per-client generator
00579c88 fstp st(0)
00579c8a lea ecx,[ecx+0x4] ; &mt
00579c8d jmp 0x47d830 ; JMP, not CALL -> RNG_NextFloat
```
A scan that follows only `E8` edges to the entry points cannot see it, and a scan that follows only
`E8` edges *from* the AI band sees a call to `0x00579c70`, an ordinary-looking façade. It has
**exactly one caller in the image**, `0x006ad873`.
Because it tail-jumps, the draw-site detour's return address is the **AI call site itself**, which
is why this is the one façade whose consumer shows up directly in a draw-site table — and why
hooking it would have *destroyed* that attribution rather than adding to it. It is left alone.
Independently re-derived by a boundary-accurate image-wide scan that follows `E9` as well as `E8`.
### 2.1 The `cl_*` RNG façade is AI-only surface
Image-wide, at real instruction boundaries:
| façade | call sites | in the AI band 0x00680000–0x006e0000 |
|---|---:|---:|
| `cl_Chance 0x00578cf0` | 18 | **18** |
| `cl_RandRange 0x005798e0` | 10 | **10** |
| `cl_RandFloat 0x00579c70` | 1 | **1** |
**Every call site of all three is in the AI module.** Nothing else in the image draws through them.
(`ai-turn-logic.md` §4.3 says eight `cl_RandRange` sites; there are ten.)
---
## 3. The measurements
Every run: load the save, one End Turn, `hooks=trace` with every template hook off. Autosave hashes
in each row. `words` is `left_delta`, the measurement taken on the generator object.
### 3.1 `turn2-state → turn3-state` — the canonical deterministic pair, VM140
Two fresh process launches, unpinned (each process seeds its AI clients differently — lane L1).
| bracket | player | AI? | run p1 | run p2 |
|---|---|---|---:|---:|
| 1 | 16 `re` (human) | no | 0 | 0 |
| 2 | 32 Fane Lao | yes | **3** | **3** |
| 3 | 496 Singularity | yes | **0** | **0** |
| 4 | 512 Singularity | yes | **0** | **0** |
| 5 | 16 (again, new turn) | no | 0 | 0 |
Both runs: `(Autosave EndTurn).sav bb4fd9ac…`, `(Autosave).sav 978041ac…` — the oracle.
Client 32's three words, both runs, same breakdown:
| site | entry | calls | words |
|---|---|---:|---:|
| `0x006ad878` — `cl_RandFloat` in `AIComposeShipBlueprint 0x006ad700` | NextFloat | 1 | 1 |
| `0x00579915` — `cl_RandRange`'s `NextInt` | NextInt | 2 | 2 |
`residual = 0` and `foreign_words = 0` on every bracket in both runs.
### 3.2 `turn1-state → turn2` — a different board for the same players, VM145
Two fresh process launches, unpinned.
| bracket | player | AI? | run p1 | run p2 |
|---|---|---|---:|---:|
| 1 | 16 (human) | no | 0 | 0 |
| 2 | 32 | yes | **7** | **7** |
| 3 | 496 | yes | **0** | **0** |
| 4 | 512 | yes | **1** | **3** |
| 5 | 16 | no | 0 | 0 |
Client 32's seven words, identical breakdown in both runs: `cl_RandFloat` ×2 = 2, `cl_RandRange`
×4 = 4, `cl_Chance` ×1 = 1.
**Client 512 is the result.** In both runs it made **exactly one `cl_RandRange` call**. That one
call cost **1 word** in the first process and **3 words** in the second. Same board, same save,
same code path, same number of calls — a different number of MT words, because `RNG_NextInt` is a
rejection loop and the two processes seeded that client differently.
### 3.3 Third process, with façade call-site attribution — which sites those words are
A third fresh process on `turn1-state`, with the two hooked façades recording their **AI call
sites** rather than the one address inside the façade:
| player | words | AI call sites (`airngcall`) |
|---|---:|---|
| 32 | 7 | `0x006ad94c` `cl_RandRange` ×2, `0x00691ea0` `cl_RandRange` ×2, `0x006adfca` `cl_Chance` ×1, plus `0x006ad878` `cl_RandFloat` ×2 |
| 496 | 0 | — |
| **512** | **1** | **`0x006a849a` `cl_RandRange` ×1** |
Two things fall out.
**Six of client 32's seven words come from the ship-design composer `0x006ad700`** — sites 13, 14
and 19 of §5. The seventh is a `cl_RandRange` in `0x00691e90`. So on this board the AI's entire
random consumption is *ship design* plus one other pick; nothing in the task system drew at all.
**Client 512's one word is the research-target tie-break.** `0x006a849a` is the return of site 10,
`0x006a8495` in `0x006a8390`, which is reached from **Process Turn phase 18** — the research-target
selection — and which is guarded by `cmp eax,[edi+4] / je return`: *the shortlist is empty or has
one member.* 496 takes that branch and draws nothing; 512 does not and draws once.
That is the mechanism behind lane L5's *"only one of three empires' research pick varies between
runs"* and lane L1's *"all three seeds move but only one empire's pick does"*, and it is now read
off the running game rather than inferred from outcomes: **two of the three empires never reach the
seed-sensitive branch at all.**
### 3.4 Pinning the seed: the same three words, and the oracle bytes unchanged
`airng.pin_seed=5A17C0DE` re-seeds each AI client's generator at bracket entry (`base + seq-1`, so
the three clients do not share a stream). On the canonical pair:
| player | idx in→out | words | sites |
|---|---|---:|---|
| 32 | 0 → 3 | **3** | `cl_RandFloat` ×1, `cl_RandRange` ×2 (`0x006ad94c`, `0x00691ea0`) |
| 496 | 0 → 0 | 0 | — |
| 512 | 0 → 0 | 0 | — |
**And the autosaves are still `bb4fd9ac…` / `978041ac…` — the oracle.** The AI's entire random
stream was replaced with a different one and the turn's output did not move by a byte. So on this
board every one of those three words is a draw *whose result never reaches the save*: two of them
pick among candidates that are equivalent as far as the recorded state is concerned, and one is the
composer's `cl_RandFloat` acceptance test. This is the first direct measurement of that phenomenon —
previous lanes could only observe that a *different* seed sometimes produced a different save.
It is also the sharpest available warning against the wrong conclusion: **"the count did not change
when the seed changed" is not evidence for roll parity.** The count is stable here because the
*path* was stable. §3.2 holds the path fixed and moves the seed, and the count moves.
### 3.5 The `turn1-state` outcome set is not being sampled the way the campaign assumes
All three `turn1-state` runs produced the same post-turn autosave, `d59bb9f2fd0eb535` — **and they
were unpinned**, each process drawing its own AI seeds, with client 512's stream demonstrably
different between them (1 word, 3 words, 1 word: the same single `cl_RandRange` call resolving with
different numbers of rejections, so different generator values reached the decision).
Lane CB, concurrently and on a different guest, reports the same file from **three pinned** runs,
and notes that lane L5's own `hooks=off` run produced it too. So the tally on that workload now
stands at roughly **seven observations of `d59bb9f2` against lane L5's report of three different
files**, and `campaign/board.md`'s "`turn1-state → turn2` is non-deterministic" is true but much
narrower than it reads.
The new thing this lane can add is that **the draw differed and the outcome did not**. Lane CB
pinned the seed and removed the variation; this lane left the seed free, watched the variation
happen *inside* the generator, and still got one file. Those are different experiments and they
agree in a way that constrains the mechanism: whatever maps client 512's drawn word onto a research
target is **not a uniform index into a six-member shortlist**. If it were, three unpinned runs
agreeing would be a 1-in-36 coincidence on top of lane CB's 1-in-36.
Not resolved, and it is one line to resolve: log the chosen tech id beside the draw in a probe on
`0x006a8390`, whose `cl_RandRange(0, n-1)` bound `n` is the shortlist size the whole `k` question is
about. Lane CB's own honest note — that player 512's target `282` is outside the named set and
"k = 6, all six nameable" is not closed — is the same open item seen from the outcome side.
---
## 4. Why the count moves — the mechanism, read from the instruction stream
### 4.1 The primitives are not fixed-cost
`RNG_NextInt 0x004271c0`, whole body, `ret 4`:
```
4271c9 edi = *bound
4271cb edi |= edi>>1 |= edi>>2 |= edi>>4 |= edi>>8 |= edi>>16 ; mask, computed ONCE
4271f0 LOOP: lazy twist if left==0; y = *next++; left--; temper
42723e and eax,edi
427240 cmp eax,DWORD PTR [ebx] ; re-reads *bound every pass
427242 ja 0x4271f0 ; reject iff (y & mask) > bound -- UNSIGNED
```
One back-edge, **no iteration counter and no bail-out**. `E[words] = (mask+1)/(bound+1)`, in
`[1, 2)`, and **exactly 1 only when `bound+1` is a power of two**. Almost every bound in the AI turn
is a container size minus one, so extra words are the ordinary case, not the exception. Measured:
one call costing 3 words (§3.2).
`RNG_Chance 0x008e6dd0` returns **without a draw** at `p <= 0` and at `p >= 1`; otherwise exactly
one word, no rejection. Two details for a reimplementation: the unit is stored to **float32** before
the comparison, and the accept test is **strict `p > r`** — the opposite convention from the inlined
site in `EncounterDetect_AssignContacts`, where equality accepts. Two conventions in one image.
`RNG_NextFloat 0x0047d830` is exactly one word, always.
**This alone refutes the hypothesis.** A design that drew unconditionally to stay in lockstep would
not use a rejection loop, because a rejection loop's cost is not knowable in advance.
### 4.2 The draws are conditional, and mostly do not fire
Twenty-three draw call sites are reachable inside an AI turn, in twelve AI functions; **two of them
are provably dead code** (`0x006adf5d`, `0x006adf6c`: the block is guarded by `cmp esi,ebx / jle`
with `esi` and `ebx` both zeroed on every inbound edge), leaving **21 live sites**. On the reference
turn, **two** fired. On turn 1, **three**.
Sites and their guards (full table in §5). The shapes that matter:
* **species gates** — Process Turn phase 2 returns immediately unless `ClientPlayer+0x5c == 1`
(Hiver); phase 20's task creation is a seven-way species switch in which **species 4 creates
nothing at all**; the helper `0x00694820` costs 1 word for species 0/4/5/6 and 1-or-2 for
species 1/2/3;
* **empty-container gates** — nearly every `cl_RandRange` sits behind
`cmp begin,end / je skip`. This is the dominant shape;
* **success gates** — the surrender roll at phase 12 gates phases "13" and "14", which are **not
sequential phases at all** but the body of its success arm (a correction to
`ai-turn-logic.md` §3.1). Six further draw sites live in there and only run on a turn where an AI
actually rolls to surrender;
* **loop-carried draws** — `0x006ae575` is one `cl_Chance(0.2f)` **per qualifying empty section
slot** of a ship design, inside a composer that is itself called 1–3 times per design request
inside two outer loops. This is unbounded and it is where most of the variance lives.
### 4.3 The single most legible instance: 496 versus 512 on turn 1
`ai-order-emission.md` §2 establishes that all three AI players set a research target on turn 1.
496 and 512 own nothing — same `Sav = 0`, no colonies, no fleets, no systems. Yet:
* **496 draws zero words.** Its research pick is forced; the shortlist has one member, the
`cl_RandRange` that would break a tie is behind `cmp eax,[edi+4] / je return` and never runs.
* **512 draws exactly one `cl_RandRange`.** Its shortlist has more than one member, so the tie is
broken by a draw.
That is the mechanism behind lane L5's *"one of three empires' research pick varies between runs"*
and lane L1's *"all three seeds move but only one empire's pick does"*, and it is now a read of the
code rather than an inference from outcomes: **two of the three empires never reach the
seed-sensitive branch at all** — precisely the situation method rule 26's corollary warns about.
---
## 5. The draw-site table
Every RNG draw reachable inside `OnResumePlaying` for an AI client. `U` = unconditional once its
enclosing function is entered; `C` = guarded. Phase numbers follow `ai-turn-logic.md` §3.1.
| # | draw VA | enclosing fn | entry | reached from | U/C | guard |
|---:|---|---|---|---|---|---|
| 1 | `0x006c592a` | `0x006c57e0` | `cl_Chance(p)` | phase 2 | **C** | `Species == 1` (Hiver), an agent latch `+0x104`, and an empty-candidate test; `p` is live, so `Chance`'s early-outs apply |
| 2 | `0x006aeeed` | `0x006aee70` | `cl_RandRange` | phases 2, 14 via `0x006b23b0` | **C** | message-list vector empty |
| 3 | `0x006cc57e` | `0x006cb570` | `cl_Chance(clamp01(r))` | phase 7 | **C** | turn delta ≤ 15 returns; `p` clamped to `[0,1]`, so both zero-word early-outs are reachable |
| 4 | `0x006cfc24` | `0x006cf8a0` | `cl_Chance(min(1/T,0.5))` | **phase 12** | **C** | survival outlook false, or `T > 30`, or `p <= 0` |
| 5 | `0x00694830` | `0x00694820` | `cl_Chance(0.75f)` | phase 14 | **U** | first instructions of the function |
| 6–8 | `0x00694865/87/a6` | `0x00694820` | `cl_Chance(0.8f)` | phase 14 | **C** | species jump table: 1 / 3 / 2 respectively; species 0/4/5/6 draw nothing here |
| 9 | `0x006c3447` | `0x006c33e0` | `cl_RandRange` | phase 14 | **C** | `switch(arg)` — only arm 2 reaches it, and `arg` is the return of #5–8 |
| 10 | `0x006a8495` | `0x006a8390` | `cl_RandRange` | **phase 18** (research target) | **U** past 2 gates | `this == 0`, or the shortlist vector is **empty** |
| 11 | `0x006817ae` | `0x00681750` | `cl_RandRange(0,5)` | phase 20 | **C** | the **fallback** arm: only when no category scored |
| 12 | `0x0069086a` | `0x00690860` | `cl_Chance(0.5f)` | phase 20 | **U** | none — 4 instructions, `return chance ? 18 : 19` |
| 13 | `0x006ad873` | `0x006ad700` | **`cl_RandFloat`** | phases 20, 25 | **C** | an "ok" latch cleared by three tech lookups, and request-flag bit `0x40` |
| 14 | `0x006ad947` | `0x006ad700` | `cl_RandRange` | " | **C** | hull slot already filled, or zero candidates |
| 15 | `0x006adf30` | `0x006ad700` | `cl_Chance(0.5f)` | " | **C** | matching-section count ≤ 0 |
| 16 | `0x006adf3f` | `0x006ad700` | `cl_RandRange` | " | **C** | nested under #15's success |
| 17 | `0x006adf5d` | `0x006ad700` | `cl_Chance(0.8f)` | " | **DEAD** | `cmp esi,ebx / jle` with both zero on every inbound edge |
| 18 | `0x006adf6c` | `0x006ad700` | `cl_RandRange` | " | **DEAD** | same block |
| 19 | `0x006adfc5` | `0x006ad700` | `cl_Chance(0.8f)` | " | **U** in its block | both arms above converge before it |
| 20 | `0x006ae413` | `0x006ad700` | `cl_Chance(0.3f)` | " | **C** | flag bit `0x20000`, or a counter ≤ 0 |
| 21 | `0x006ae575` | `0x006ad700` | `cl_Chance(0.2f)` | " | **C, LOOP** | once per qualifying empty section slot |
| 22 | `0x006cce84` | `0x006ccdb0` | `RNG_NextInt` | phases 20, 25 (design names) | **C** | no name-gen object, a **cached** name hit, or an empty name pool |
| 23 | `0x0068bf03` | `0x0068bdc0` | `cl_RandRange(2,5)` | phase 20, **only** via `AITEscortGate::Execute` vtable slot 5 | **C** | a local bool; what sets it was not read |
**Three of twenty-one live sites are unconditional, and all three are unconditional only *given that
their function was called*** — which is itself gated. Not one draw in the AI turn is unconditional
with respect to the turn.
### 5.1 Sites on the same generator that are **outside** the turn
Listed so a later lane does not double-count them:
* **Prepare Turn only** (packet code 2, raised by `RaiseAIPrepareTurn 0x00815f20`, whose two callers
`RunAI` and `CreateGame` are both one-shot): `0x006cfa06`, `0x0069620e`, and **three** draws in
`0x0069dbb0` — `0x0069dbfd`, `0x0069dc29` and **`0x0069dcff`, which lane AI1's six-site list does
not have**. `0x0069dbb0`'s guard `if (agent->+0x36c == 0)` is a true one-shot because the third
draw **re-arms** the field to `turn + 20 + r`, which is never 0. These land at construction and
never again — verified live: none of those return addresses ever appears in a bracket.
* **Net-message paths, not the turn:** `0x0069df9d` (agent vtable slot 3) and `0x006ca11c` (slot 4)
are dispatched only from `StrategyNetworkClient::OnMessage 0x00784640`. *(This also corrects
`ai-turn-logic.md` §2, which lists `0x0069de50` as the `AIObject` packet sink: it reads `[pkt+4]`
as a 0/1/2 selector, while the four real `AIObject` sinks read `[pkt+0]` as the code — and none of
those four draws anything.)*
**The load-time cost is large, asymmetric, and itself not fixed.** With the lifetime census on the
canonical pair, the three AI clients arrive at their first turn already **9, 411 and 421** words
into their streams (build `par4`, in which the nested double count is corrected), while the human
client's generator is at word 0. And the figure moves between processes: client 32 began its turn at
block index **9** in one run and **11** in another, because the rejection loops it runs during
Prepare Turn resolve differently under different seeds.
So the AI's generator position at the start of a turn is **not** a function of the save. Two loads of
the same file put the same client at two different stream offsets. That is a second, independent
reason a reimplementation cannot reach a byte match on an AI empire by counting draws.
`g_GlobalRNG 0x00af6e58` shows **3 words drawn, one per AI client construction** — a second,
independent falsification of `ai-turn-logic.md` §5.1's "every draw from it returns 0", after lane
L1's, and from a different instrument.
---
## 6. What is **not** established
1. **No inlined draw runs in an AI turn — and that is now measured, not assumed.** The
`left_delta` vs `observed` residual was **0 on every bracket of every run**. Statically: an
image-wide scan for the MT tempering immediates at real instruction boundaries reproduces lane
I's 15 functions exactly, and **none is in the AI band**; the three nearest
(`0x006ec720`, `0x006f65f0`, `0x006f7890`) are `StrategyNetworkServer` and sit above
`0x006e0000`. Lane AI1's rule-16 caveat is discharged with the band bounds it asked for.
2. **The site inventory is a lower bound past two indirect hops.** `IAITask::Execute` (slot 5, all
31 classes) and the `AIObject` packet sink (slot 3, all four) were resolved; a virtual call
*inside* a task's `Execute` subtree is unmodelled. Weak evidence that it is close to saturated:
adding all 31 `Execute` roots to the 31 phase roots added exactly **one** new draw function.
3. **Only two boards were measured**, both from the same 2-player-plus-shadows game, and no
**species 5** or **Hiver** AI turn was measured. The species terms in §5 are read from the
instruction stream and are rule-6 hypotheses live. A Hiver AI would exercise sites 1 and 6 and is
the cheapest remaining workload.
4. **The dead-code claim on sites 17–18 is a static read.** No live hit has been observed, but no
run has exercised the enclosing arm heavily either.
5. **`0x0068bdc0`'s `[ebp-0x1]`** — what sets it was not read.
6. **Whether `agent+0x36c` is serialised** in the `StrategyAIAgent::Streamable` block. If it is, the
Prepare-Turn cost differs between a new game and a loaded save and the whole stream is offset.
7. **The number of ship-design requests per turn is unquantified.** The chain
phase 20/25 → `0x006ce190` → `0x006cda40` → `0x006ae620` → `0x006ad700` was confirmed as edges
and two enclosing loops, but the intermediate bodies were not read. Since the composer holds 9 of
the 21 live sites including the only loop-carried one, **this is where the variance is**, and it
is the first thing a follow-up should instrument (entry probe on `0x006ad700` logging `arg[3]`
and `0x006ae620`'s loop counter).
---
## 7. What this means for `sots-engine`
**The cheap version of `game/ai` does not exist.** The redirect's hope was that a reimplementation
could stay in lockstep by reproducing the *count and order* of draws while getting the decisions
wrong. It cannot:
* every draw in the AI turn is behind a branch on game state, so a wrong decision changes **whether**
a draw happens, not only its result;
* `RNG_NextInt`'s rejection loop makes the cost of a *single call* depend on the stream, so even a
correct call sequence cannot be costed in advance — the engine must reproduce the rejection loop
bit-for-bit (it already does; `mars/rng` is closed);
* `RNG_Chance`'s two zero-word early-outs mean a probability computed slightly wrong can change the
word count from 1 to 0 and desynchronise everything after it.
The positives, which are real:
* **the AI draws from nothing but its own client's generator.** `foreign_words = 0` on every
bracket of every run — a live confirmation of what `ai-turn-logic.md` §5 could only assert from a
direct-call scan. The strategic generator and the AI are genuinely independent, so an engine can
get the server's stream exactly right while the AI's is still approximate.
* **the human client draws nothing.** Every human bracket measured 0 words with `agent = 0`. There
is no client-side RNG on the human path at all.
* **the per-turn cost is tiny** — single digits — so the AI's generator is not a large unknown; it
is a small, sharp one.
* **the `cl_*` façade is a clean, complete, AI-only API**: three functions, 29 call sites, all in
the AI band. An engine implements exactly those three and has covered every strategic-AI draw.
The consequence for the roadmap is the one lane L1 already reached from the seed side, now with a
mechanism: **Rung B (replay a recorded command stream) is unaffected — it does not run an AI.
Rung C (behavioural AI) needs the decisions, and the decisions live in the ship-design composer and
the task system**, neither of which is modelled.
---
## 8. Appendix — the *call*-parity question, and what this lane can say about it
This lane was briefed on call parity ("does the AI reach the simulation through the same interface a
human client does?") and redirected mid-flight to roll parity. The redirect is recorded because the
call-parity deliverable is **not complete**: the ~22 `hypothesis` rows in lane Q's list table were
**not** converted by driving the UI-reachable order methods, and no new order method was found. What
follows is only what this lane's own work establishes.
**Parity holds at the wire and apply layers, and it breaks at the call layer** — the reading the
brief proposed, and nothing measured here contradicts it. Two things are added:
1. **A new, clean piece of AI-only call surface: the `cl_*` RNG façade** (§2.1). Three functions —
`cl_Chance 0x00578cf0`, `cl_RandRange 0x005798e0`, `cl_RandFloat 0x00579c70` — with 29 call sites
between them, **all 29 inside the AI module**. Nothing else in the image draws through them. This
sits alongside `cl_SetResearchTarget 0x00578f60` and `cl_EndTurn 0x00579310` (whose `+0x12c` test
makes it AI-only by construction) as part of a coherent AI-only façade over "the current client".
It is not order surface — it emits no `TurnCommands` element — but it *is* interface the engine
must implement separately, and `cl_RandFloat` was not previously known to exist.
2. **A live confirmation that the human client has no client-side RNG at all.** Every human
`OnResumePlaying` bracket measured 0 words with `agent = 0`. So the asymmetry at the call layer is
not merely "the AI calls some methods the UI does not" — the AI has an entire *stateful* facility,
a private seeded generator, that the human path never touches. `StrategyClient+0x134` exists on
the human client too, and stays at word 0 for the whole game.
For `sots-engine` the answer to the original question is therefore: **one order path, two client
APIs.** The order methods and everything downstream of `StrategyClient+0x160` are shared and need
implementing once; the AI-only surface the engine must implement separately is the `cl_*` façade
(three RNG entries plus `cl_SetResearchTarget` and `cl_EndTurn`) and the four order methods with no
UI caller that lane AI1 P4 named (`0x00762ca0`, `0x00763670`, `0x00763720`, `0x00763990`) — with
lane AI2's caveat that its own sample was incomplete still standing, because this lane did not
extend it.
---
## 9. Files
- Instrument: `sots-engine` `wip/par` — `src/shim/hooks/ai_rng.{h,cpp}`, the `draw_sites` observer
hook, `src/shim/shim.cfg.par{ctl,br,pin,orders}`
- Predictions, committed before the build: `sots-engine/docs/PAR-predictions.md`
- Raw brackets and autosave hashes: `verify/results/shim/airng/` — six runs,
`par-{br-p1,br-p2}` (canonical pair, unpinned, two fresh processes),
`par-pin-a1` (canonical pair, pinned), `par-{t1-br-p1,t1-br-p2,t1-br-p3}` (`turn1-state`, three
fresh processes, the third with façade attribution), plus `parse_airng.py`, the reader
- Addresses: `ghidra/addresses.d/lane-par.json` (2 entries; validated to a scratch path,
1,219 → 1,221, no duplicate names and no same-address-different-name)
### Gates, run as separate commands on a fresh build directory (rules 13, 24)
* `tools/clean_room_check.sh` — **OK**
* host `ctest` — **55/55**
* CT111 shim cross-build — **OK**, `build-shim` removed first, `par-gate-20260908T2330Z`
The build **deployed to the guests** was `par4-6ba2c79-20260908T2322Z`, which differs from the
committed source only in that the two façade hook targets were written as RVA literals rather than
`sots::addr::cl_Chance` / `cl_RandRange`. Those constants resolve to the same two RVAs
(`0x00178cf0`, `0x001798e0`), so the gate build and the measured build are the same instrument;
the rename is recorded here rather than left for a reader to notice.
`sots-engine` `main` moved to lane CB's merge while this lane ran, so the generated header on
`wip/par` was regenerated against the pre-merge base. It must be **regenerated, never
hand-resolved**, at merge time (rule 14).