The cost model for 0x006ad700 read from the instruction stream: nine live draw
sites, an exit taxonomy saying what each of the eight bail-outs has already
drawn, and a closed form for the loop-carried draw's trip count
f = 1.00 / 0.75 / 0.50 by request flags, hull size and one 0.3 coin
M = (int)(N * f)
D' = max(1, (N + 1) / M)
L = #{ qualifying mounts j : PointDefence section, or j mod D' == 0 }
with the falsifiable corollary that the 0.3 coin can only move the word count
at N in {1,2,3,5}, and that N=1 with f<1 enters the loop and draws nothing.
Also predicts client 32's seven turn-1 words as two composer calls -- a
costOnly=1 price query (A+B+C) and a costOnly=0 build (A+B+C+F) -- which is
read statically off 0x006cda40's two push sites, not fitted to the number.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
11 KiB
Lane SD — predictions, written before the shim build
Target: AIComposeShipBlueprint 0x006ad700, the AI ship-design composer. Lane PAR localised the
AI's whole RNG variance to it: on turn1-state, six of client 32's seven words are drawn inside it
and the seventh is drawn by a helper it calls. It holds nine live draw sites, including the only
loop-carried one.
Everything below is derived from the instruction stream only (0x006ad700–0x006ae61a, swept
to the next function start per rule 17; the body ends with a ret at 0x006ae61a and int3
padding to 0x006ae620, so Ghidra's 3864-byte size is right on this one). Nothing here has been
run yet. The instrument is sots-engine wip/sd, aidesign=on in src/shim/shim.cfg.sd*.
The model
One call is int AIComposeShipBlueprint(ECX agent, EDX parts[3], STACK req*, STACK costOnly).
req = {int hullSize; float budget; int role; uint flags}. It returns 0 on success and 1..8 for
eight bail-outs. Its only caller 0x006ae620 runs it once per candidate hull size — 1, 2 or 3
sizes depending on flags & 1 and flags & 2 — and breaks on the first success, so one design
request costs one to three composer calls and every failed attempt has already spent whatever it
drew before it failed.
Draw sites, in execution order, named by the return address the airng tables know them by:
| tag | ret | primitive | words | fires when |
|---|---|---|---|---|
| A | 0x006ad878 |
cl_RandFloat → NextFloat |
1 | tech latch still ok and (flags & 0x40) == 0 |
| B | 0x006ad94c |
cl_RandRange(0, nH-1) → NextInt |
E=(mask+1)/nH |
parts[0].section == 0 and nH > 0 (nH capped at 0x32) |
| C | 0x00691ea0 |
cl_RandRange(0, nC-1) → NextInt |
E=(mask+1)/nC |
command slot empty, section not fixed, nC > 0 |
| D | 0x006adf35 |
cl_Chance(0.5f) |
1 | K > 0, where K = mounts with kind ∈ {0, 0xc, 0x17} and size 3 |
| E | 0x006adf44 |
cl_RandRange(0, K-1) |
E=(mask+1)/K |
D accepted |
| — | 0x006adf62, 0x006adf71 |
dead | xor esi,esi / cmp esi,ebx / jle with ebx = 0 — confirmed by Ghidra removing both blocks |
|
| F | 0x006adfca |
cl_Chance(0.8f) |
1 | unconditional once the call gets this far |
| G | 0x006ae418 |
cl_Chance(0.3f) |
1 | (flags & 0x20000) == 0 and req.hullSize > 0 |
| H | 0x006ae57a |
cl_Chance(0.2f) |
L | once per qualifying mount, see below |
RNG_Chance costs exactly one word at every one of these p values (0.2/0.3/0.5/0.8 are all
strictly inside (0,1), so neither of its zero-word early-outs is reachable here). Only B, C and E
can cost more than one word per call, and only through NextInt's rejection loop.
The loop-carried draw's trip count
The tail is for part in 0..2 { for mount in 0..part.mountCount }. The outer count is a literal
3 and never varies. The inner draw fires for a mount iff:
mount.kind == 0 && mount.size == 1(call these the qualifying mounts,Nof them across all three sections), and- the section's name is
DEPointDefenceorCRPointDefence(case-insensitive) — those take every qualifying mount — or the mount's global qualifying indexjsatisfiesj mod D' == 0, and - the restricted weapon lookup at
0x006ae3c1returned non-null (else the site short-circuits with no call), and the default weapon lookup at0x006ae3dcreturned non-null (else the whole tail block is skipped), andM > 0.
with
f = 1.00 if (flags & 0x20000) == 0 and req.hullSize > 0 and site G accepted
= 0.75 if (flags & 0x20000) != 0, or G was drawn and refused
= 0.50 if (flags & 0x20000) == 0 and req.hullSize <= 0
M = (int)(N * f) float32 multiply, floor, truncate — non-negative, so one cast
D' = max(1, (N + 1) / M) integer division
L = #{ j in [0,N) : section(j) is *PointDefence, or j mod D' == 0 }
Consequences that make this falsifiable rather than decorative:
f = 1.00⇒D' = 1⇒L = N.f = 0.75⇒L = 0, 1, 2, 4, 3, 6, 7, 8forN = 1..8— note it is not monotone:N = 5costs 3 andN = 4costs 4, becauseD'jumps.f = 0.50⇒L = 0, 1, 1, 2, 2, 3, 4, 4forN = 1..8.N = 1withf < 1givesM = 0and zero draws — a whole loop that is entered and costs nothing (method rule 20's shape, and the reason the probe logsNnext toH_obs).- The 0.3 coin at G therefore changes the word count only when
N = 1,N = 2,N = 3orN = 5. ForN = 4andN ≥ 6bothf = 0.75andf = 1.00giveD' = 1and the sameL.
Exit taxonomy — what a failed attempt has already paid
| rc | where | drawn before it returns |
|---|---|---|
| 5 / 6 / 7 | a forced-tech lookup for flag bit 4 / 8 / 0x10 | nothing |
| 2 | no hull-section candidate | A |
| 3 | no command section | A + B |
| 4 | no engine section | A + B + C |
| 1 | over budget | A + B + C |
| — | costOnly != 0 returns here |
A + B + C |
| 8 | no weapon matched a mount | A + B + C + D + E + F |
| 0 | success | everything |
Predictions
P1 — the model holds, per call
On every composer row the probe emits, H_pred == H_obs, where H_pred is computed by the
probe from N, the three per-section point-defence flags and f (with f taken from the request
flags, the hull size, and G's observed accept/refuse, which the extended cl_Chance detour now
records).
Falsified by: any row printing model=WRONG. A row printing model=GATED-OR-WRONG (H_pred > 0
but H_obs == 0) is not a falsification on its own — it is the weapon-lookup gate — but it is
also not a confirmation, and if every row is GATED-OR-WRONG the model is untested and I will say
so rather than claim it held.
P2 — one design request, two composer calls, and the first is a price query
0x006cda40 calls the driver twice: once with costOnly = 1 (statically read at 0x006cda9a,
push 0x1) and, if that succeeded, once with costOnly = 0 (0x006cdb17, push 0x0). So a turn
in which the AI designs one ship should show two composer rows: one with dry=1 that returns
rc=0 having drawn A, B, C and nothing else, and one with dry=0 that goes further.
This is the arithmetic of client 32's seven turn-1 words, and it is the prediction I most want checked because it was derived to explain a number rather than measured:
call 1 (dry=1): A 1 + B 1 + C 1 = 3 words
call 2 (dry=0): A 1 + B 1 + C 1 + F 1 = 4 words
total 7 <- PAR's measured 7
with D, E, G, H all zero: K = 0 (no size-3 mounts on a turn-1 hull), and G silent because
req.hullSize <= 0 on the smallest hull.
Falsified by: one composer row, or three; or dry = 0 on both; or F firing on the dry call.
P3 — pinned seeds reproduce exactly
Two runs of turn1-state → turn2 with airng.pin_seed=5A17C0DE and aidesign=on, in two fresh
processes, produce byte-identical aidesign* rows — every field, every row, in order.
Falsified by: any differing field. That would mean the composer's path depends on something the bracket-entry re-seed does not pin, which would be a bigger finding than the model.
P4 — a different pinned seed moves the count, and moves it through the path
A run with airng.pin_seed=B16B00B5 differs from P3's runs in at least one of:
- site A's coin (
RandFloat() >= 0.5) landing the other way, which sets request-flag bit0x40, forces a tech section and therefore changes the sections,N, and possiblyH; - the number of words
BorCspends,NextInt's rejection loop resolving differently.
The total for client 32 is not required to differ — PAR's §3.4 already showed a pinned run
holding the count at 3 while replacing the whole stream. What is required is that if the path
changes, H_pred tracks it: model=HOLDS must survive the seed change.
Falsified by: model=WRONG appearing only under the second seed, which would mean the model is
fitted to one design rather than derived.
P5 — the instrument is armed, and says so
shim.airng.txt must contain
aidesign: composer 0x006ad700 rva=0x002ad700 va=... create=MH_OK enable=MH_OK.
Lane L3 found a configuration that printed watch=on and armed nothing; a missing or failed hook
here would produce no aidesign rows at all, which is indistinguishable from "the composer was
never called" unless the arming line is read. If that line is absent or not MH_OK, every zero in
this lane's output is void and the run is discarded, not interpreted.
Second arming check, independent of the log line: the airngcall/airngsite totals for the
bracket must equal the sum of the aidesign rows' sub_words. If the composer hook silently failed
the sub-bracket would be zero while the turn total stayed 7.
P6 — rule 19: the composer detour does not change the game
With aidesign=off (and airng=on, unpinned), two fresh processes on turn1-state must both
produce the published post-turn autosave d59bb9f2fd0eb535. With aidesign=on, unpinned, two more
fresh processes must produce the same file.
The composer detour patches five bytes at a clean prologue boundary (push ebp; mov ebp,esp; push -1 is exactly 5 bytes, and the function has one caller and no internal branch target below
0x006ad705), but lane H's finding is that a correctly placed patch changed the autosave anyway
and the mechanism is still unknown. So this is measured, not argued.
Falsified by: any of the four runs producing a different file. If the aidesign=off pair
disagrees with each other, the workload is the problem and no control exists (rule 26); I will
say so and fall back to pinned comparisons only.
How this could be wrong
Nis read after the call, from the design the call produced. The composer countsNduring its weapon-assignment pass over slots that received a weapon; if a slot was skipped, my post-hoc count is high andH_predis too big. Symptom:H_pred > H_obsby a small amount on rows withrc = 8. This is why the rc is in the row.part.mountCountismin(hull mounts, 0x32). I clamp to the hull's own vector length as well; if the two disagree the probe prints both and I will notice.ffor theflags & 0x20000case is an inference from onefldat0x006ae3f8. If bit0x20000is never set on this board the term is untested, and I will label it rule-6 rather than claim it.- The dead-code claim on
0x006adf62/0x006adf71is static. Ghidra independently removed both blocks as unreachable, which is a second reading of the same instructions and not a second instrument. If either ever appears in anairngcallrow, the claim is wrong. - The measurement could double-count. PAR's instrument reported one word twice because
ChancecallsNextFloatand both were detoured; it caught that only because it took two independent measurements. This lane's sub-bracket takes two as well —leftread off the generator object (words) and the observer sum (sub_words) — and prints both on every row. They must agree.