The hypothesis under test was the lockstep discipline: that each run of an AI client consumes a
fixed number of draws regardless of the path it takes, so a reimplementation could keep the
generator aligned with the right COUNT and order of draws while getting the decisions wrong.
It is false, and it fails at four scopes. Measured with a new bracket on
StrategyClient::OnResumePlaying over the per-client generator at +0x134, six runs across VM140 and
VM145, five fresh processes; every unpinned run's autosaves are byte-identical to the published
oracle, so the instrument is behaviour-neutral (rules 19 and 26 both discharged).
across clients turn2->turn3: 3 / 0 / 0 words for AI players 32 / 496 / 512; human 0
across turns client 32: 3 words on turn 2, 7 on turn 1
across processes client 512 makes ONE cl_RandRange call on turn 1 -- the research-target
tie-break at 0x006a8495, phase 18 -- and it cost 1 word in one process and
3 in another, because RNG_NextInt is an unbounded rejection loop
per site RNG_Chance costs ZERO words at p<=0 and p>=1
Twenty-one live draw sites in an AI turn, in twelve functions (plus two provably dead ones); two
fired on the reference turn, three on turn 1. Only three are unconditional, and all three only
given that their enclosing function was called. Six of client 32's seven turn-1 words come from
the ship-design composer 0x006ad700, which is also where the only loop-carried draw lives.
Also: cl_RandFloat 0x00579c70, a third cl_* RNG facade, found twice independently. It reaches
RNG_NextFloat by a TAIL JUMP, so no rel32 sweep for the entry points can see it -- which is why
ai-turn-logic.md 5's 'zero NextFloat calls from the AI module' reads as true and is not. All 29
call sites of the three facades are inside the AI band: the cl_* RNG facade is AI-only surface.
Positives for the engine: the AI draws from nothing but its own client's generator (foreign_words
0 on every bracket), the human client draws nothing at all, and the per-turn cost is single digits.
Rung B is unaffected. Rung C needs the decisions.
484 lines
29 KiB
Markdown
484 lines
29 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, four AI clients, five 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 four 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 and at the turn.** 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** |
|
||
|
||
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 An observation this lane could not resolve
|
||
|
||
All three `turn1-state` runs produced the same post-turn autosave, `d59bb9f2fd0eb535`, even though
|
||
client 512's stream demonstrably differed between them (1 word, 3 words, 1 word). Lane L5 reports
|
||
**three different files** from three runs of this pair on VM146, and `campaign/board.md` records
|
||
`turn1-state → turn2` as non-deterministic on that basis.
|
||
|
||
Both cannot be a complete description. Three identical outcomes from a k = 6 tie set (lane L4) is a
|
||
1-in-36 coincidence, so "it is deterministic after all" is not supported either. The honest
|
||
statement is that **the draw differs and the outcome did not**, on this guest, three times — and
|
||
that whatever maps the drawn word onto the chosen tech is not a plain uniform index into the
|
||
shortlist. Flagged, not resolved; it wants the tech id logged next to the draw, which is one line in
|
||
a probe on `0x006a8390`.
|
||
|
||
---
|
||
|
||
## 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 and asymmetric. Measured with the lifetime census on the canonical pair:
|
||
the three AI clients arrive at their first turn already **17, 424 and 427** words into their
|
||
streams, and the human client's generator is at 0. `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.
|
||
|
||
---
|
||
|
||
## 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`
|