sots-re/findings/subsystems/ship-design-composer.md
alex 1914c92f70 SD: correct the site count in the header -- eight live sites in the composer's subtree, not nine
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.
2026-09-08 20:51:48 -04:00

35 KiB
Raw Blame History

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)