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.
522 lines
32 KiB
Markdown
522 lines
32 KiB
Markdown
# 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).
|