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.
35 KiB
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-enginewip/sd,src/shim/hooks/ai_rng.cppextended with a composer sub-bracket, configsshim.cfg.sd{off,br,pin,pin2}. All four reported runs use buildsd-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:
- Forced sections from the request flags. Bit 4 → tech
0x2768, bit 8 →0x277f, bit 0x10 →0x2710; each is resolved by0x006941b0(hullSize, techId), which scans the agent's section pool for aShipSectionDefwhose 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. - The optional special section — draw A. If
flags & 0x40is already set the branch is taken for free; otherwisecl_RandFloat()is compared against the double0.5at0x009e20a0(fcomp qword,test ah,1), so it is a 50 % coin, and on heads the composer forces tech0x2763(falling back to0x2764) into whichever slot that section belongs to. - The mission section — draw B. If
parts[0].sectionis still null,0x0069ce50fills a local array with up to0x32candidates and returns the countnH;cl_RandRange(0, nH-1)picks one. Zero candidates → rc 2. - The command section — draw C. If the hull does not fix it,
0x0069cd20(twice, with a fallback) returns a candidate count and0x00691e90picks one at random — that helper is the draw site, and it is the oneroll-parity.md's inventory missed. - The engine section. No draw; either a fixed lookup or an index into the agent's per-size table.
- 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 stringAIComposeShipBlueprint: SectionBlueprint::MAX_OPTIONSnames. - Price it —
0x00699160(parts). Ifreq.budget > 0andbudget < cost, rc 1. costOnlyreturns here.- Bank counts and the large-bank pick — draws D and E. Each section's bank count is clamped to
0x32and stored atparts[i]+0xcc;Kcounts 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 theKis chosen. - Draw F,
cl_Chance(0.8f), whose result is a flag consulted much later for one particular section/role combination. - 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. - 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:
Lis not monotone inN. Atf = 0.75,N = 4costs 4 words andN = 5costs 3, becauseD'steps from 1 to 2. A model that assumed "more banks, more words" would be wrong at exactly that point.N = 1withf < 1costs zero, becauseM = 0skips the block. A loop that is entered and draws nothing — method rule 20's shape, which is why the probe printsNbeside 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 selectsf, 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
SectionContextentries on the way out, soN, the per-section bank counts and the PointDefence flags are read off the design the call actually produced — and the probe computesLfrom them and prints it next to the measurement. The row saysmodel=HOLDS,model=WRONG, ormodel=GATED-OR-WRONGwhen 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) meansreq.hullSize > 0is false, so site G is not drawn andf = 0.50— the row showsG_chance=0;K = 0(a Tarkas destroyer's three sections carry no large banks), so D and E are not drawn;Fis 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=0on all three sections becauseparts[i]+0xccis written after the price gate. That is the probe reading what was actually there, not a defect, and it is why the dry row'sNis 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-statethe calls reach the point-defence pass and it is gated by the weapon lookups (H_pred = 3,H_obs = 0, row readsGATED-OR-WRONG); - on
turn15all three calls are price queries and return nine steps earlier, so the row readsHOLDSvacuously (H_pred = 0as 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 == 0at0x006ae42fskips the whole block, so the loop never runs;W_alt == 0at0x006ae562short-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.
-
Built, not run. The probe now also hooks
0x006ad2a0and records the two return values asw_alt/w_defon every composer row, which separates the two gates and turns aGATED-OR-WRONGrow into a definite statement. It is in the committed source and in the buildsd2-dd105da-20260909T0039Z; no run has used it. The four runs reported here all use the earlier buildsd-b67362b-20260909T0008Z, which is the same instrument minus that one hook. -
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 andfcan be 1.00) and the AI owns a point-defence weapon. The cheapest route is probably a later-turn save of the sameMyGamel3tradegame, 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(thef = 0.75branch),& 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 arc = 1budget 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 = 0and thecostOnlyreturn; - 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 = 0on a destroyer, so the large-bank pick has never fired; - draw G.
req.hullSize > 0is false on every measured call, so the 0.3 coin has never been drawn and thef = 1.00branch 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:
-
The price query is not free.
0x006cda40runs the whole composer twice per design request — once withcostOnly = 1to get a number and once withcostOnly = 0to 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. -
The retry loop compounds it.
0x006ae620tries 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. -
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
Hranges 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 essentiallyH, andHis 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-enginewip/sd—src/shim/hooks/ai_rng.cpp(composer sub-bracket, the two hand-written convention thunks,cl_Chanceresult recording, the weapon-lookup gate probe), configssrc/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), plusparse_aidesign.py - Addresses:
ghidra/addresses.d/lane-sd.json(7 entries; scratch-validated, no duplicate name and no same-address-different-name)