Nine sites are in the function's body, two of them provably dead; the eighth live one is in the helper 0x00691e90 the composer calls. Four have fired.
613 lines
35 KiB
Markdown
613 lines
35 KiB
Markdown
# 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 -- <that one path>` 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)
|