# The AI ship-design composer `0x006ad700` — what it draws, and how many words that costs - **Type:** subsystem (instruction-stream read + live sub-bracket) - **Status:** **the per-call word arithmetic is verified exactly, on two boards, in four runs**; four of the **eight** live draw sites in its subtree have fired, and they account for every word measured. The other four — including the **loop-carried** point-defence draw, the one this lane was sent for — have **never fired**, because every composer call so far was either a price query or hit a weapon-lookup gate. Their formulas are **read from the instruction stream, not measured.** §6.1 and §6.3 say exactly where that line falls. - **Confidence:** high on the structure and on the per-call word arithmetic (two independent measurements agreeing on every row, in four runs across three configurations, with the instrument proven behaviour-neutral against the published autosave); **medium** on the exit taxonomy, which is a static read with two of nine exits observed; **none** on the loop-carried term's live behaviour. - **Owner / date:** lane SD · 2026-09-08 · VM145 - **Instrument:** `sots-engine` `wip/sd`, `src/shim/hooks/ai_rng.cpp` extended with a composer sub-bracket, configs `shim.cfg.sd{off,br,pin,pin2}`. **All four reported runs use build `sd-b67362b-20260909T0008Z`**; the committed source is one hook further on (`sd2-dd105da-20260909T0039Z`, the weapon-lookup gate probe of §6.1), which no run has used. - **Continues:** `roll-parity.md` §6.7 — *"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"* - **Corrects:** `roll-parity.md` §5's site table (it omits a live site, `0x00691e9b`, that the same lane measured; the AI turn has **22** live sites, not 21) and its §3.3 arithmetic (*"six of client 32's seven turn-1 words come from the composer"* — **five** come from its own body and the other **two** from a helper it calls, so **all seven** are in its subtree) --- ## 0. The answer, in one table One call to the composer costs ``` W = A + B + C + D + E + F + G + H ``` | tag | draw site (return address) | primitive | words per call | fires when | |---|---|---|---:|---| | A | `0x006ad878` | `cl_RandFloat` → `NextFloat` | **1** | no forced-tech lookup failed, and `(req.flags & 0x40) == 0` | | B | `0x006ad94c` | `cl_RandRange(0, nH-1)` → `NextInt` | `(mask(nH-1)+1)/nH` | the mission-section slot is empty and `nH > 0` | | C | `0x00691ea0` | `cl_RandRange(0, nC-1)` → `NextInt` | `(mask(nC-1)+1)/nC` | the command-section slot is empty, the hull does not fix it, `nC > 0` | | D | `0x006adf35` | `cl_Chance(0.5f)` | **1** | `K > 0`, and the call got past the budget gate and is not a price query | | E | `0x006adf44` | `cl_RandRange(0, K-1)` | `(mask(K-1)+1)/K` | D accepted | | F | `0x006adfca` | `cl_Chance(0.8f)` | **1** | unconditional once the call gets this far | | G | `0x006ae418` | `cl_Chance(0.3f)` | **1** | `(req.flags & 0x20000) == 0` **and** `req.hullSize > 0` | | H | `0x006ae57a` | `cl_Chance(0.2f)` × L | **L** | once per selected small standard bank — §3 | `RNG_Chance` costs exactly one word at all four of these probabilities: 0.2, 0.3, 0.5 and 0.8 are strictly inside `(0,1)`, so neither of its zero-word early-outs (`p ≤ 0`, `p ≥ 1`) is reachable anywhere in this function. **Only B, C and E can cost more than one word per call**, and only through `RNG_NextInt`'s rejection loop, for which `roll-parity.md` §4.1's `E[words] = (mask+1)/(bound+1)` is the model. Two more sites exist in the body, `0x006adf62` and `0x006adf71`, and both are **dead**: the block is guarded by `xor esi,esi / cmp esi,ebx / jle` with `ebx = 0`, so `0 ≤ 0` always takes the branch. Ghidra reaches the same conclusion independently and deletes both blocks (`WARNING: Removing unreachable block (ram,0x006adf53)` / `(ram,0x006adf69)`). Two readings of the same instructions, not two instruments — no live hit has ever been observed either. **And one call is not one design request.** §2. --- ## 1. What the function is ``` int AIComposeShipBlueprint(ECX StrategyAIAgent* agent, EDX SectionContext parts[3], STACK DesignRequest* req, STACK char costOnly) ``` `req = { int hullSize; float budget; int role; uint flags }` — four dwords read at `0x006ad72e`–`0x006ad73d`. **The calling convention is not one a compiler can spell.** The single call site `0x006ae714` passes two arguments in ECX and EDX, two on the stack, and then the **caller** cleans them (`add esp,0x8` at `0x006ae719`). That is neither `cdecl` (no register arguments) nor MSVC `__fastcall` (callee cleans) — it is the private convention MSVC gives a static function whose address never escapes. GCC has no attribute for it, so the shim enters the detour through a hand-written thunk. This is worth recording because it is a shape the campaign will meet again in this module. **Extent.** `0x006ad700` to `0x006ae61a` inclusive (`ret` at `0x006ae61a`, `int3` padding to `0x006ae620`). Ghidra's 3,864-byte size is **right on this one** — checked because rule 17 says to check, not because it was doubted. **Return value** is the failure code, `0` on success: | rc | cause | draws it has already paid for | |---:|---|---| | 0 | success | everything | | 1 | cost over `req.budget` | A + B + C | | 2 | no mission-section candidate | A | | 3 | no command section | A + B | | 4 | no engine section | A + B + C | | 5 / 6 / 7 | the forced-tech lookup for `flags` bit 4 / 8 / 0x10 failed | **nothing** | | 8 | no weapon matched a bank | A + B + C + D + E + F | | — | `costOnly != 0` returns at `0x006add96` | A + B + C | That last row is not a failure and it is the most consequential line in the table: **a price query costs three RNG words.** §2. ### 1.1 What it composes In order: 1. **Forced sections from the request flags.** Bit 4 → tech `0x2768`, bit 8 → `0x277f`, bit 0x10 → `0x2710`; each is resolved by `0x006941b0(hullSize, techId)`, which scans the agent's section pool for a `ShipSectionDef` whose hull size (`def+0x260`) matches and whose option list (`def+0x264`) carries that tech. A section whose slot class (`def+0x25c`) is 2 (engine) is refused. A failure here clears the "ok" latch and the call returns 0 words spent. 2. **The optional special section — draw A.** If `flags & 0x40` is already set the branch is taken for free; otherwise `cl_RandFloat()` is compared against the **double** `0.5` at `0x009e20a0` (`fcomp qword`, `test ah,1`), so it is a 50 % coin, and on heads the composer forces tech `0x2763` (falling back to `0x2764`) into whichever slot that section belongs to. 3. **The mission section — draw B.** If `parts[0].section` is still null, `0x0069ce50` fills a local array with up to `0x32` candidates and returns the count `nH`; `cl_RandRange(0, nH-1)` picks one. Zero candidates → rc 2. 4. **The command section — draw C.** If the hull does not fix it, `0x0069cd20` (twice, with a fallback) returns a candidate count and `0x00691e90` picks one at random — that helper *is* the draw site, and it is the one `roll-parity.md`'s inventory missed. 5. **The engine section.** No draw; either a fixed lookup or an index into the agent's per-size table. 6. **Weapon candidates per section.** For each of the three sections, walk its bank groups and collect up to **20** usable weapon ids into `parts[i]+0xd0`, count at `+0x120`. The cap is the one the string `AIComposeShipBlueprint: SectionBlueprint::MAX_OPTIONS` names. 7. **Price it** — `0x00699160(parts)`. If `req.budget > 0` and `budget < cost`, rc 1. 8. **`costOnly` returns here.** 9. **Bank counts and the large-bank pick — draws D and E.** Each section's bank count is clamped to `0x32` and stored at `parts[i]+0xcc`; `K` counts banks with class ∈ {0, 0xc, 0x17} and size 3 across all three sections (over the **full** bank vector, not the clamped count). With probability 0.5, one of the `K` is chosen. 10. **Draw F**, `cl_Chance(0.8f)`, whose result is a flag consulted much later for one particular section/role combination. 11. **Assign a weapon to every bank** — the main loop, no draws. A bank that cannot be filled logs `While AI for %s was designing a ship: Did not find any weapon to match…` and returns rc 8. `N`, the count of **small standard banks** (class 0, size 1) that got a weapon, is accumulated here. 12. **The point-defence pass — draws G and H.** §3. ### 1.2 `SectionContext`, 0x124 bytes Recovered from the instruction stream and confirmed live. This is the same array lane D2 found on the *other* side of the pipeline, at `Game::ShipDesign::AggregateSectionStats 0x00826af0`. | offset | what | |---|---| | `+0x000` | `ShipSectionDef*`, null for an empty slot | | `+0x004`..`+0x0cb` | one weapon id per **bank**, up to `0x32` — exactly the `DGbnk2` list the design record carries on the wire (`SHIP_DESIGN_RULES` B1) | | `+0x0cc` | bank count, `min((def+0x320 − def+0x31c)/0x30, 0x32)` | | `+0x0d0`..`+0x11f` | up to 20 candidate weapon ids for this section | | `+0x120` | that candidate count, capped at 20 | Slot order in memory is **mission, command, engine** — not the on-disk order (`SHIP_DESIGN_RULES` §1). And on `ShipSectionDef`: `+0x31c`/`+0x320` are the bank vector's begin/end, stride `0x30`, with `bank+0x00` the turret **class** index and `bank+0x04` the turret **size** index. Class 0 is `standard` and size 1 is `small` — the combination 1,533 of the 3,721 shipped banks carry, by far the most common (`SHIP_DESIGN_RULES` §3). `+0x1b4` is the section's name (`Mars::String`, capacity at `+0x1c8`). --- ## 2. One design request is one to three composer calls, and the first one is a price query `0x006ae620` is the composer's **only** caller. It builds a list of candidate hull sizes from `req.hullSize` — plus one size smaller if `flags & 1`, plus one size larger if `flags & 2` — clears those two bits, and then calls the composer once per size, **breaking on the first success**. So a failed attempt is not free: it has already spent whatever the table in §1 says. `0x006ae620` in turn has three call sites, and their `costOnly` arguments are literals in the image: | caller | site | `costOnly` | |---|---|---:| | `0x006cda40` | `0x006cdaa6` | **1** (`push 0x1` at `0x006cda9a`) | | `0x006cda40` | `0x006cdb1c` | **0** (`push 0x0` at `0x006cdb17`) | | `0x006b7e40` | `0x006b7ecb` | **1** (`push 0x1` at `0x006b7ebc`) | So the ordinary design pass **prices the design first and then builds it**, and both halves run the whole random front end. That is the arithmetic of client 32's seven turn-1 words, which `roll-parity.md` measured and could not decompose: ``` call 1 costOnly=1 A 1 + B 1 + C 1 = 3 words call 2 costOnly=0 A 1 + B 1 + C 1 + F 1 = 4 words ---------- 7 words ``` This was written down as a prediction from the two `push` literals **before** the probe was built (`sots-engine/docs/SD-predictions.md`, committed at `b67362b`), and it is exactly what the probe measured. §5. --- ## 3. The loop-carried draw, and what governs its trip count The tail of the function is ``` n = 0 ; a GLOBAL index over qualifying banks, not per section for part in 0..2: ; the outer count is a LITERAL 3 and never varies if part.section == 0: continue isPD = stricmp(name,"DEPointDefence")==0 || stricmp(name,"CRPointDefence")==0 for bank in 0..part.bankCount-1: if bank.class != 0 or bank.size != 1: continue ; not a small standard bank if not isPD and (n % D') != 0: n++; continue ; NO DRAW if W_alt == 0: assign W_def; n++; continue ; NO DRAW (short circuit) if cl_Chance(0.2f): assign W_alt ; <-- THE DRAW, one word else: assign W_def n++ ``` with, computed once just above the loop: ``` W_alt = 0x006ad2a0(agent, hullClass, {tech 0x25}, 0, 1, …) the point-defence weapon W_def = 0x006ad2a0(agent, hullClass, 0, 0, 1, …) the default weapon if W_def == 0: the WHOLE tail block is skipped <- gate 1 f = 1.00 if (flags & 0x20000)==0 and req.hullSize > 0 and cl_Chance(0.3f) accepted [draw G] = 0.75 if (flags & 0x20000)!=0, or G was drawn and refused = 0.50 if (flags & 0x20000)==0 and req.hullSize <= 0 [G is NOT drawn] M = (int)(N * f) float32 multiply, floor, truncate; N = the small-standard-bank count if M <= 0: the whole tail block is skipped <- gate 2 D' = max(1, (N + 1) / M) integer division ``` so ``` L = #{ qualifying bank j in [0,N) : section(j) is a *PointDefence section, or j mod D' == 0 } ... provided W_alt != 0 and W_def != 0 and M > 0; otherwise L = 0. ``` **What this means in words.** The AI sprinkles point-defence weapons into a fraction `f` of a design's small standard banks — all of them on a dedicated PointDefence section — and pays one MT word per bank it *considers*, not per bank it changes. The trip count is a property of the hull it picked, and the hull was picked by draw B. That is the mechanism by which a single random choice early in the function changes the word cost of the rest of it. Worked values (no PointDefence section): | `N` | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | |---|---:|---:|---:|---:|---:|---:|---:|---:|---:| | `L` at `f = 1.00` | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | | `L` at `f = 0.75` | **0** | 1 | 2 | 4 | 3 | 6 | 7 | 8 | 12 | | `L` at `f = 0.50` | **0** | 1 | 1 | 2 | 2 | 3 | 4 | 4 | 6 | Two things in that table are worth keeping: * **`L` is not monotone in `N`.** At `f = 0.75`, `N = 4` costs 4 words and `N = 5` costs 3, because `D'` steps from 1 to 2. A model that assumed "more banks, more words" would be wrong at exactly that point. * **`N = 1` with `f < 1` costs zero**, because `M = 0` skips the block. A loop that is entered and draws nothing — method rule 20's shape, which is why the probe prints `N` beside the observed count rather than the count alone. **This section is inferred.** Every workload measured so far hits one of the two gates, so `L` has never been observed non-zero. §6. --- ## 4. The instrument The lane PAR bracket on `StrategyClient::OnResumePlaying`, extended with a **sub-bracket** on the composer: `left` (RNG+0x9c8) is read off the client's generator at composer entry and exit, and the draws seen in between are re-tallied by return address. Two independent numbers per composer row — `words` from the generator object and `sub_words` from the observer — and they are printed together on every row, because PAR's own instrument double-counted a word and only its second measurement caught it. Two additions to PAR's module: * `cl_Chance`'s detour now takes its note **after** the trampoline, so a row carries the **decision** as well as the call. Site G's accept/refuse is what selects `f`, and a call count alone cannot say which way it went. * the composer detour reads the request on the way in and walks the three `SectionContext` entries on the way out, so `N`, the per-section bank counts and the PointDefence flags are read off the design the call actually produced — and the probe computes `L` from them and prints it next to the measurement. The row says `model=HOLDS`, `model=WRONG`, or `model=GATED-OR-WRONG` when the prediction is positive and the measurement is zero, which is the weapon-lookup gate and is **not** counted as a confirmation. Arming, checked in every log (lane L3's `hooks=off watch=on` printed `watch=on` and armed nothing): ``` aidesign: composer 0x006ad700 rva=0x002ad700 va=011ed700 create=MH_OK enable=MH_OK ``` A second, independent arming check that does not rely on that line: the composer rows' `sub_words` must sum to the bracket's `left_delta`. If the hook had silently failed, the bracket would still read 7 and there would be no rows to sum. --- ## 5. Measurements All on VM145, `turn1-state → turn2`, build `sd-b67362b-20260909T0008Z`, one End Turn per run, every template hook off. The AI client that designs anything on this board is **32, "Fane Lao", Tarkas**. ### 5.1 The seven turn-1 words, decomposed — and the price query is real Run `sd-pin-p1`, `airng.pin_seed=5A17C0DE`: ``` aidesign dseq=1 rc=0 dry=1 size=0 budget=20.000 role=17 flags=0x00000000 words=3 sub_words=3 N=0 q=0/0/0 pd=000 f=0.50 M=0 D=1 H_pred=0 H_obs=0 model=HOLDS A_randfloat=1 B_hull=1 C_cmd=1 D=0 E=0 F=0 G=0 H=0 aidesign dseq=2 rc=0 dry=0 size=0 budget=20.000 role=17 flags=0x00000000 words=4 sub_words=4 N=6 q=3/1/2 pd=000 f=0.50 M=3 D=2 H_pred=3 H_obs=0 model=GATED-OR-WRONG A_randfloat=1 B_hull=1 C_cmd=1 D=0 E=0 F=1(accepted) G=0 H=0 part 0 DEExtendedRange banks=3 qual=3 part 1 DECommand banks=1 qual=1 part 2 DEFission banks=2 qual=2 airng seq=2 pid=32 left_delta=7 observed=7 residual=0 foreign_words=0 ``` **Two composer calls, the first a price query, 3 + 4 = 7 words — the prediction, to the site.** The decomposition was committed at `b67362b` from two `push` literals in the image, before the probe existed; nothing about it was fitted to the number it explains. Everything the model says about this row is in it: * `size=0` (destroyer) means `req.hullSize > 0` is false, so **site G is not drawn** and `f = 0.50` — the row shows `G_chance=0`; * `K = 0` (a Tarkas destroyer's three sections carry no large banks), so **D and E are not drawn**; * `F` is unconditional once the call gets past the price gate, and it fires exactly once, on the non-dry call only; * the dry call reports `banks=0` on all three sections because `parts[i]+0xcc` is written *after* the price gate. That is the probe reading what was actually there, not a defect, and it is why the dry row's `N` is 0 while the real row's is 6. ### 5.2 The bank counts check out against the shipped data, from the other side The probe read bank counts **3 / 1 / 2** for `DEExtendedRange` / `DECommand` / `DEFission`, all six qualifying. The normalised section catalog (`verify/results/data-catalogs/shipsections.json`) gives, for **Tarkas**: | section | `bank{}` blocks | `mount{}` nodes | small standard banks | |---|---:|---:|---:| | `DEExtendedRange` | **3** | 3 | 3 | | `DECommand` | **1** | 2 | 1 | | `DEFission` | **2** | 3 | 2 | The live figures match the **bank** column and not the mount column — `DEFission` has 2 banks and 3 mounts, and the probe read 2. So `SectionContext+0xcc` is a count of `bank{}` blocks, which makes the array at `+0x004` exactly the `DGbnk2` list the design record carries on the wire (`SHIP_DESIGN_RULES` B1, one weapon per bank). Two independent readings — a hooked pointer walk and a parsed data file — agreeing on 3/1/2 and on all six being small standard. The same check identifies the AI: the Human catalog gives 1 / 1 / 1 for those three sections, the Tarkas catalog gives 3 / 1 / 2, and the save says player 32 is Tarkas. ### 5.3 Pinned seeds reproduce exactly (P3) `sd-pin-p1` and `sd-pin-p2`, same seed, two fresh process launches. **Every `aidesign`, `aidesignsite` and `aidesignpart` row is identical field for field**, and every bracket's word count is identical (32 → 7, 496 → 0, 512 → 2). The only differences in the whole log are heap addresses and the pre-turn lifetime counters. Those lifetime counters are the interesting part. `life_in` for the three AI clients was **10 / 435 / 422** in one process and **9 / 412 / 437** in the other. So: **Pinning at bracket entry does not pin the AI.** The Prepare-Turn draws happen at load, before the bracket exists, and they still differ per process — which is `roll-parity.md` §5's "the AI's generator position at the start of a turn is not a function of the save", reconfirmed from a second instrument. The visible consequence is that the two pinned runs' post-turn autosaves **differ** (`e43ec1d2b443c101` vs `ecf724b356de0c4e`, both 66,740 bytes) even though every drawn word inside every bracket was identical. Lane CB's three pinned runs *did* agree, because CB pins the **construction** seed through the `l1seed` detour at `0x0078304b` and therefore pins Prepare Turn as well. Two different pins, two different scopes; a lane wanting a byte-identical AI run wants CB's. ### 5.4 Player 512's one call, a third value `0x006a849a`, the research tie-break, one `cl_RandRange` call: **2 words** in both pinned runs. `roll-parity.md` §3.2 measured the same single call at **1** word in one process and **3** in another. Three distinct costs for one call site are now on record — the rejection loop, exactly as `E[words] = (mask+1)/(bound+1)` predicts, and a useful reminder that a per-site word count is not a constant even when the call count is. ### 5.5 Rule 19 — the composer detour does not change the game `sd-br-p1`: `aidesign=on`, **unpinned**, `turn1-state → turn2`, one End Turn. ``` post.sav 66,746 bytes d59bb9f2fd0eb535 ``` That is the published result for this workload — the file lane PAR produced three times unpinned, lane CB three times pinned, and lane L5's `hooks=off` run once. **The composer detour is behaviour-neutral**, measured against a control that has been reproduced about eight times by three lanes rather than against a single run of my own (rule 26: a control must agree with itself before it exonerates anything, and this one has, elsewhere). Its composer rows are **identical to the pinned pair's** — same two calls, same `dry` flags, same `words=3` and `words=4`, same sections, same `N=6`. The path on this board does not depend on the seed, which is `roll-parity.md` §3.4's finding seen from inside the function: the seed changes what the draws *return*, and on turn 1 nothing downstream of them branches. The one thing that did move is player 512's single `cl_RandRange`: **1 word** unpinned here, **2** in both pinned runs, against PAR's 1 and 3. Four observations, three values, one call site. ### 5.6 A deeper board: turn 15, and the price query is the *only* thing that happens `sd-t15-p1`: `human-turn15-spyprogram.sav` (game `MyGamel3trade`, turn 15, `re` vs "The Eternal Empire", a **Tarkas AI with 45 of 200 techs and 30 designs**), unpinned, one End Turn. Pushed to the guest for this run because the corpus's other saves are all turn 1–5 and none of them has an AI with weapons. Row 4 of the Load dialog, verified by screenshot before the click. **Client 32 spends 16 words, and 10 of them are the composer's:** | composer call | `dry` | `rc` | `role` | `budget` | words | sections | |---:|---:|---:|---:|---:|---:|---| | 1 | **1** | 0 | 3 | 0.000 | 3 | DEColonizer / DEStrafe / DEPulsedFission | | 2 | **1** | 0 | 3 | 0.000 | 3 | DEColonizer / DEHammerHead / DEPulsedFission | | 3 | **1** | 0 | 5 | 9.000 | **4** | DETanker / DEHammerHead / DEPulsedFission | and the other six words are, by façade attribution: | site | what | calls | words | |---|---|---:|---:| | `0x006a849a` | the research-target tie-break (`roll-parity.md` site 10) | 1 | **2** | | `0x0069086f` | `cl_Chance(0.5f)` in `0x00690860`, the task system's `return chance ? 18 : 19` | 4 | 4 | 14 calls, 16 words, `residual = 0`, `foreign_words = 0`. Every word is accounted for. Three things this adds. **The composer's dominance is partly a turn-1 artefact.** On turn 1 it was 7 of 7; here it is 10 of 16 — still the largest single consumer, but the task system's coin at `0x0069086a` fires four times and never fired at all on turn 1. An engine that implements only the composer covers 100 % of a turn-1 board and 62 % of a turn-15 one. **The first observed multi-word `NextInt` inside the composer.** Call 3 spends 4 words across exactly 3 calls (A, B, C — one each), so one of B or C ran the rejection loop an extra time. That is `E[words] = (mask+1)/(bound+1)` firing inside this function for the first time; on turn 1 all six B/C calls happened to cost one word each. **Every composer call on this turn is a price query.** All three have `dry = 1`. `0x006cda40` calls the driver with `costOnly = 1` first and only calls it again with `costOnly = 0` if that succeeded *and* a second check passed; here the AI priced three candidate designs and built none. So the composer's entire random cost on a mature board can be three throw-away price queries — **10 words the game computes and discards.** **And the loop-carried draw is still gated shut, for a second and different reason.** On turn 1 it was the weapon lookups; here the calls never reach the point-defence pass at all, because they return at the `costOnly` gate nine steps earlier. `H_obs = 0` on all three rows, and this time `H_pred = 0` too (the bank counts are zero on a dry call), so those rows read `HOLDS` vacuously. **Six composer calls across four runs, and the loop has not run once.** --- ## 6. What is **not** established ### 6.1 The loop-carried draw has never been observed firing — this is the big one `H_obs = 0` on **all six composer calls of all four runs**, and it is zero for **two different reasons**, neither of which is the model being wrong: * on `turn1-state` the calls reach the point-defence pass and it is gated by the weapon lookups (`H_pred = 3`, `H_obs = 0`, row reads `GATED-OR-WRONG`); * on `turn15` all three calls are price queries and return nine steps earlier, so the row reads `HOLDS` **vacuously** (`H_pred = 0` as well). Either way the trip-count formula of §3 is **derived from the instruction stream and not confirmed by measurement**, and I would rather say that plainly than let six `HOLDS`/`GATED` rows read as support. What is known about *why* the turn-1 zero happens: the tail block has two gates that the probe cannot see from outside, both fed by the weapon chooser `0x006ad2a0`: * `W_def == 0` at `0x006ae42f` skips the whole block, so **the loop never runs**; * `W_alt == 0` at `0x006ae562` short-circuits every iteration, so **the loop runs and never draws**. Both produce `H_obs = 0` and the current instrument cannot separate them — which is exactly method rule 20's failure mode, one level down from the one the probe was built to avoid. `W_alt` is looked up against a one-element list containing the literal `0x25`, which I read as the point-defence weapon family from the section names (`DEPointDefence`, `CRPointDefence`) and the 0.2 probability; that identification is **inferred**. The mechanism is very likely simply that the AI has no point-defence weapon on turn 1: player 32 has 22 of 217 techs researched. But "very likely" is not a measurement. **Two things would close it. One is built and unrun; the other is a workload the corpus lacks.** 1. **Built, not run.** The probe now also hooks `0x006ad2a0` and records the two return values as `w_alt` / `w_def` on every composer row, which separates the two gates and turns a `GATED-OR-WRONG` row into a definite statement. It is in the committed source and in the build `sd2-dd105da-20260909T0039Z`; **no run has used it.** The four runs reported here all use the earlier build `sd-b67362b-20260909T0008Z`, which is the same instrument minus that one hook. 2. **A workload where the AI actually BUILDS a design.** This is a **rule 6** request and it is sharper than "a later turn". §5.6 shows that a turn-15 AI can make three composer calls and have every one of them be a price query. What the loop needs is a turn on which `0x006cda40`'s second call fires — `costOnly = 0` — **and** the design is on a hull size > 0 (so site G is drawn and `f` can be 1.00) **and** the AI owns a point-defence weapon. The cheapest route is probably a later-turn save of the same `MyGamel3trade` game, played forward until the AI commits a cruiser design; `verify/results/saves/` currently tops out at turn 23 (Zuul) and turn 15 (this one), and neither was captured with the AI's build pass in mind. ### 6.2 The bound of every `NextInt` in this function is unrecorded `nH`, `nC` and `K` are the candidate counts that set B's, C's and E's bounds, and the probe does not log them — so the `E[words] = (mask+1)/(bound+1)` term of the model is **not** checked against a known bound anywhere. On `turn1-state` B and C each cost exactly one word, which is consistent with a power-of-two `bound+1` and equally consistent with luck; on `turn15` one of the six B/C calls cost two, so the rejection loop is live inside this function but its bound is still unknown. Logging the three counts is another one-line change and it is the obvious next tightening. ### 6.3 One hull size, and only two of the eight exits Every composer call observed — six of them, across two boards — has `size = 0` and `flags = 0x00000000`. Roles 3, 5 and 17 and budgets 0.000, 9.000 and 20.000 have been seen; hull sizes other than 0 have not. That means, untested: * **every request-flag term.** `flags & 1` / `& 2` (the caller's 1-to-3-hull-size retry), `& 4`, `& 8`, `& 0x10` (the forced-tech sections and rc 5/6/7), `& 0x40` (A's free path), `& 0x10000`, `& 0x20000` (the `f = 0.75` branch), `& 0x10000000` — **all zero on every call measured**; * **every failure exit.** All six observed calls returned `rc = 0`. The eight-row taxonomy in §1 is a read of the instruction stream, not an observation, and its most load-bearing row — that a `rc = 1` budget failure has already spent A + B + C — has never happened in front of the instrument. Only two of the nine exits have been observed at all: `rc = 0` and the `costOnly` return; * **the 1-to-3-call retry loop in `0x006ae620`.** Only single-attempt requests were seen, so the claim that a failed hull size still costs words is inferred; * **draws D and E.** `K = 0` on a destroyer, so the large-bank pick has never fired; * **draw G.** `req.hullSize > 0` is false on every measured call, so the 0.3 coin has never been drawn and the `f = 1.00` branch is unreached. That is four of the eight live sites never seen to fire. The four that did — A, B, C, F — account for every word measured, exactly, on both boards. (F fired only on the one non-dry call there has been.) ### 6.4 The dead-code claim is still only static `0x006adf62` and `0x006adf71` have never been reached in any run, but no run has exercised the enclosing arm heavily either, so "never observed" adds almost nothing to the static argument. The static argument is strong (`ebx` is provably 0 on every inbound edge, and Ghidra deletes both blocks independently), but it is one reading of one set of instructions. ### 6.5 The probe reads `N` after the fact `N` is counted by the game during its weapon-assignment pass, over banks that *received* a weapon; the probe recounts it afterwards from the section definitions. On a successful call those agree, because a bank that fails aborts the call with `rc = 8`. On an `rc = 8` call they need not, and no `rc = 8` call has been seen. Symptom if it ever matters: `H_pred > H_obs` by a small amount on a row with `rc = 8`. ### 6.6 One process-hygiene error, recorded Twice I ran `verify/design-rules/stock_designs.py` inside the shared `sots-re` clone to identify a save's players; it writes `stock_designs.json` in the repo as a side effect, and I restored it with `git checkout -- ` both times. The file was clean afterwards, but this is precisely the shape rule 21 exists for and the right move was to copy the tool's output directory, or the tool, out of the shared tree first. No other lane's work was in flight on that path, and I would not have known if it had been. --- ## 7. What this means for `sots-engine` Lane PAR's conclusion was that **Rung C-exact does not get cheaper**, because keeping the generator aligned requires the AI's decisions to be right rather than merely its draw count. Nothing here overturns that. What this lane adds is that the decisions in question are, on the boards we have, **almost entirely this one function's**, and that it is small enough to implement. Three things a reimplementation needs that were not previously written down: 1. **The price query is not free.** `0x006cda40` runs the whole composer twice per design request — once with `costOnly = 1` to get a number and once with `costOnly = 0` to build the thing — and the first pass draws three words it then throws away. An engine that computes a design's cost without walking the same random path is off by three words *per design request* and every subsequent draw in the turn is misaligned. This is the single most likely way a plausible reimplementation silently desynchronises. 2. **The retry loop compounds it.** `0x006ae620` tries up to three hull sizes and breaks on the first success, so a request that fails its first size pays that size's draws and then pays again. The per-exit cost table in §1 is what an engine must reproduce, and the exits are ordinary data-driven failures (no candidate, over budget), not exceptional ones. 3. **The loop-carried draw is the only unbounded term, and it is bounded by data.** Across the player races' 875 shipped sections, one section carries at most **12** small standard banks, and a three-section design can therefore present roughly 20–29 of them. So `H` ranges from 0 to about 29 words — **an order of magnitude more than the 3–4 words the rest of the function costs**. On a late-game dreadnought the composer's cost is essentially `H`, and `H` is a function of the hull the composer picked one draw earlier. That is the shape of the variance lane PAR localised, now with a formula and a range. A fourth, from §5.6, which qualifies the lane's own premise: **the composer is not the whole story on a mature board.** It is 7 of 7 words on turn 1 and 10 of 16 on turn 15, with the task system's coin at `0x0069086a` taking four of the rest. Implementing the composer alone gets an engine to 100 % of a turn-1 AI and about 62 % of a turn-15 one. The positive, unchanged: the composer draws only through the three `cl_*` façades, on the client's own generator, and `foreign_words = 0` and `residual = 0` on every bracket of every run here as well. An engine that implements `cl_Chance`, `cl_RandRange` and `cl_RandFloat` correctly, plus this function's control flow and `0x00690860`'s four-instruction coin, has covered every word measured on both boards the corpus offers. --- ## 8. Files - Instrument: `sots-engine` `wip/sd` — `src/shim/hooks/ai_rng.cpp` (composer sub-bracket, the two hand-written convention thunks, `cl_Chance` result recording, the weapon-lookup gate probe), configs `src/shim/shim.cfg.sd{off,br,pin,pin2}` - Predictions, committed before the build: `sots-engine/docs/SD-predictions.md` (`b67362b`) - Raw brackets, autosave hashes and the reader: `verify/results/shim/aidesign/` — four runs, `sd-pin-p1` / `sd-pin-p2` (`turn1-state`, `pin_seed=5A17C0DE`, two fresh processes), `sd-br-p1` (`turn1-state`, unpinned, the rule-19 control), `sd-t15-p1` (`human-turn15-spyprogram.sav`, unpinned), plus `parse_aidesign.py` - Addresses: `ghidra/addresses.d/lane-sd.json` (7 entries; scratch-validated, no duplicate name and no same-address-different-name)