sots-re/findings/subsystems/roll-parity.md
alex e966a72fd9 PAR: record the gates, and the one difference between the deployed and committed builds
clean_room_check OK, host ctest 55/55, CT111 shim cross-build OK on a fresh build directory, all
three as separate commands. The deployed instrument (par4) differs from the committed source only
by two RVA literals that the named constants resolve to identically -- said here rather than left
for a reader to find. Also flags that main moved under the branch, so the header must be
regenerated at merge, not hand-resolved.
2026-09-08 19:37:56 -04:00

32 KiB
Raw Permalink Blame History

Roll parity — does an AI client's turn cost a fixed number of RNG words?

  • Type: subsystem (live measurement + static classification)
  • Status: verified — the answer is NO, at every scope the question can be asked at
  • Confidence: high on the negative (measured on two boards, three AI clients, six runs in six fresh processes, with the instrument's autosaves byte-identical to the published oracle); medium on the completeness of the site inventory (§6 says what is not covered)
  • Owner / date: lane PAR · 2026-09-08 · VM140 (reference) + VM145
  • Instrument: sots-engine wip/par, new shim module src/shim/hooks/ai_rng.cpp, configs shim.cfg.par{ctl,br,pin,orders}, builds par-… … par4-6ba2c79-20260908T2322Z
  • Answers: the coordinator's redirect — "each run of an AI client consumes a fixed number of RNG draws regardless of the path it takes", the lockstep-discipline hypothesis
  • Corrects: ai-turn-logic.md §5 (zero NextFloat calls from the AI module), §3.1 (the phase list is not flat), §4.3 (the cl_* façade has three RNG members, not two, and ten cl_RandRange call sites, not eight); ai-task-system.md §10.1's six direct NextInt sites

0. The verdict, at five scopes

The hypothesis is false, and it is false in the strongest available way: it fails at the primitive, at the site, at the client, at the turn, and even at the point where the turn begins. There is no scope at which an AI client's word count is fixed.

scope claim measured
per turn, across clients all AI clients of one turn cost the same 3 / 0 / 0 on turn2→turn3; 7 / 0 / 1 on turn1→turn2
per client, across turns one client costs the same each turn client 32: 3 on turn 2, 7 on turn 1
per client, across processes (path fixed) one client's count is a property of the board client 512, turn 1, one and the same cl_RandRange call (the research tie-break, 0x006a8495): 1 word in one process, 3 words in another
per site a site that is reached always costs the same RNG_Chance costs 0 words at p ≤ 0 and p ≥ 1; RNG_NextInt is an unbounded rejection loop
at the start of the turn a client's stream position is a function of the save client 32 began its turn at block index 9 in one process and 11 in another, from the same file

The two shadow empires (496, 512) own nothing at all — no colonies, no fleets, no systems — and they still do not agree with each other: on turn 1, 496 spends 0 and 512 spends 1 call costing 1 or 3 words. So the count is not even a function of "what the empire owns".

What this means for game/ai, in one line: keeping the generator aligned requires the AI's decisions to be right, not merely its draw count. C-exact does not get cheaper. §7.


1. The instrument

StrategyClient::OnResumePlaying 0x00777480 is an exact bracket around one client's whole turn: if (this->+0x12c) agent->vt[1](0x26, ev) at +0x9d runs the entire AI Process Turn synchronously, and this names the client, its player net id (+0x148), its agent (+0x12c, non-null only on an AI client) and its private generator (+0x134).

One MinHook detour on that function, plus an observer registered on lane H's existing seven draw-site detours rather than a second set of hooks on the same targets (MinHook allows one hook per target, and those detours are installed unconditionally whenever hooks != off). Two independent measurements of the same number, per bracket:

  • observed — the sum over the entry points, with per-return-address attribution;
  • left_delta — left (RNG+0x9c8) read straight off the object at entry and exit.

They are printed together with their difference on every bracket, including when it is zero. Also printed unconditionally: agent=, so "this client has no AI" is distinguishable from "the AI ran and drew nothing" (method rule 20); and foreign_words, the count of draws that happened inside the bracket on some other generator.

Two further hooks, on cl_Chance 0x00578cf0 and cl_RandRange 0x005798e0, record __builtin_return_address(0) so a draw is attributed to the AI call site and not to the one address inside the façade where every façade draw would otherwise pile up. cl_RandFloat 0x00579c70 is deliberately not hooked — §2.

airng.pin_seed=<hex> re-seeds the client generator at bracket entry, so two runs start from an identical generator state. It changes the game's behaviour on purpose; a pinned run's autosave is never compared against the oracle.

1.1 Rule 19 and rule 26, discharged

The instrumented, unpinned runs on the canonical pair produce (Autosave EndTurn).sav = bb4fd9ac89f41e3b and (Autosave).sav = 978041acd168b56e — byte-identical to the published oracle, which was produced with hooks=off in a different process on a different day. That is a stronger control than a same-key airng=off pair, and it was reproduced in two fresh process launches (rule 26). The detour is behaviour-neutral.

1.2 The instrument caught its own defect, which is the point of having two measurements

The first bracket containing a cl_Chance printed observed=8 against left_delta=7. RNG_Chance's own body calls RNG_NextFloat, and both are detoured, so one drawn word was reported twice — once against the game's call site and once against 0x008e6e09, the instruction after Chance's internal call. This is exactly the nested-entry-point double count draw_sites.h warned about for IntRangeBell/GaussianRange and had never seen. Fixed by rejecting the inner row's words while keeping its call count visible, so the nesting stays legible instead of silently disappearing. left_delta was never affected — which is why there are two measurements.


2. cl_RandFloat 0x00579c70 — a third façade, found twice, and why nobody saw it

ai-turn-logic.md §5 states, from a rel32 scan of the AI band for the seven RNG entry points: "Zero calls from the module to NextFloat, Chance, NextUInt, Twist, Seed or GaussianRange." The very first live bracket recorded a NextFloat at return address 0x006ad878 — inside the AI band, inside the ship-design composer 0x006ad700.

The mechanism is a tail jump:

00579c70  mov eax,ds:0xae4808               ; g_CurrentClientIndex
00579c75  fldz
00579c77  mov eax,[eax*4+0xae47e4]          ; g_StrategyClients[idx]
00579c7e  test eax,eax
00579c80  je  0x579c92                      ; no current client -> return 0.0f, NO DRAW
00579c82  mov ecx,[eax+0x134]               ; the per-client generator
00579c88  fstp st(0)
00579c8a  lea ecx,[ecx+0x4]                 ; &mt
00579c8d  jmp 0x47d830                      ; JMP, not CALL -> RNG_NextFloat

A scan that follows only E8 edges to the entry points cannot see it, and a scan that follows only E8 edges from the AI band sees a call to 0x00579c70, an ordinary-looking façade. It has exactly one caller in the image, 0x006ad873.

Because it tail-jumps, the draw-site detour's return address is the AI call site itself, which is why this is the one façade whose consumer shows up directly in a draw-site table — and why hooking it would have destroyed that attribution rather than adding to it. It is left alone.

Independently re-derived by a boundary-accurate image-wide scan that follows E9 as well as E8.

2.1 The cl_* RNG façade is AI-only surface

Image-wide, at real instruction boundaries:

façade call sites in the AI band 0x00680000–0x006e0000
cl_Chance 0x00578cf0 18 18
cl_RandRange 0x005798e0 10 10
cl_RandFloat 0x00579c70 1 1

Every call site of all three is in the AI module. Nothing else in the image draws through them. (ai-turn-logic.md §4.3 says eight cl_RandRange sites; there are ten.)


3. The measurements

Every run: load the save, one End Turn, hooks=trace with every template hook off. Autosave hashes in each row. words is left_delta, the measurement taken on the generator object.

3.1 turn2-state → turn3-state — the canonical deterministic pair, VM140

Two fresh process launches, unpinned (each process seeds its AI clients differently — lane L1).

bracket player AI? run p1 run p2
1 16 re (human) no 0 0
2 32 Fane Lao yes 3 3
3 496 Singularity yes 0 0
4 512 Singularity yes 0 0
5 16 (again, new turn) no 0 0

Both runs: (Autosave EndTurn).sav bb4fd9ac…, (Autosave).sav 978041ac… — the oracle.

Client 32's three words, both runs, same breakdown:

site entry calls words
0x006ad878 — cl_RandFloat in AIComposeShipBlueprint 0x006ad700 NextFloat 1 1
0x00579915 — cl_RandRange's NextInt NextInt 2 2

residual = 0 and foreign_words = 0 on every bracket in both runs.

3.2 turn1-state → turn2 — a different board for the same players, VM145

Two fresh process launches, unpinned.

bracket player AI? run p1 run p2
1 16 (human) no 0 0
2 32 yes 7 7
3 496 yes 0 0
4 512 yes 1 3
5 16 no 0 0

Client 32's seven words, identical breakdown in both runs: cl_RandFloat ×2 = 2, cl_RandRange ×4 = 4, cl_Chance ×1 = 1.

Client 512 is the result. In both runs it made exactly one cl_RandRange call. That one call cost 1 word in the first process and 3 words in the second. Same board, same save, same code path, same number of calls — a different number of MT words, because RNG_NextInt is a rejection loop and the two processes seeded that client differently.

3.3 Third process, with façade call-site attribution — which sites those words are

A third fresh process on turn1-state, with the two hooked façades recording their AI call sites rather than the one address inside the façade:

player words AI call sites (airngcall)
32 7 0x006ad94c cl_RandRange ×2, 0x00691ea0 cl_RandRange ×2, 0x006adfca cl_Chance ×1, plus 0x006ad878 cl_RandFloat ×2
496 0 —
512 1 0x006a849a cl_RandRange ×1

Two things fall out.

Six of client 32's seven words come from the ship-design composer 0x006ad700 — sites 13, 14 and 19 of §5. The seventh is a cl_RandRange in 0x00691e90. So on this board the AI's entire random consumption is ship design plus one other pick; nothing in the task system drew at all.

Client 512's one word is the research-target tie-break. 0x006a849a is the return of site 10, 0x006a8495 in 0x006a8390, which is reached from Process Turn phase 18 — the research-target selection — and which is guarded by cmp eax,[edi+4] / je return: the shortlist is empty or has one member. 496 takes that branch and draws nothing; 512 does not and draws once.

That is the mechanism behind lane L5's "only one of three empires' research pick varies between runs" and lane L1's "all three seeds move but only one empire's pick does", and it is now read off the running game rather than inferred from outcomes: two of the three empires never reach the seed-sensitive branch at all.

3.4 Pinning the seed: the same three words, and the oracle bytes unchanged

airng.pin_seed=5A17C0DE re-seeds each AI client's generator at bracket entry (base + seq-1, so the three clients do not share a stream). On the canonical pair:

player idx in→out words sites
32 0 → 3 3 cl_RandFloat ×1, cl_RandRange ×2 (0x006ad94c, 0x00691ea0)
496 0 → 0 0 —
512 0 → 0 0 —

And the autosaves are still bb4fd9ac… / 978041ac… — the oracle. The AI's entire random stream was replaced with a different one and the turn's output did not move by a byte. So on this board every one of those three words is a draw whose result never reaches the save: two of them pick among candidates that are equivalent as far as the recorded state is concerned, and one is the composer's cl_RandFloat acceptance test. This is the first direct measurement of that phenomenon — previous lanes could only observe that a different seed sometimes produced a different save.

It is also the sharpest available warning against the wrong conclusion: "the count did not change when the seed changed" is not evidence for roll parity. The count is stable here because the path was stable. §3.2 holds the path fixed and moves the seed, and the count moves.

3.5 The turn1-state outcome set is not being sampled the way the campaign assumes

All three turn1-state runs produced the same post-turn autosave, d59bb9f2fd0eb535 — and they were unpinned, each process drawing its own AI seeds, with client 512's stream demonstrably different between them (1 word, 3 words, 1 word: the same single cl_RandRange call resolving with different numbers of rejections, so different generator values reached the decision).

Lane CB, concurrently and on a different guest, reports the same file from three pinned runs, and notes that lane L5's own hooks=off run produced it too. So the tally on that workload now stands at roughly seven observations of d59bb9f2 against lane L5's report of three different files, and campaign/board.md's "turn1-state → turn2 is non-deterministic" is true but much narrower than it reads.

The new thing this lane can add is that the draw differed and the outcome did not. Lane CB pinned the seed and removed the variation; this lane left the seed free, watched the variation happen inside the generator, and still got one file. Those are different experiments and they agree in a way that constrains the mechanism: whatever maps client 512's drawn word onto a research target is not a uniform index into a six-member shortlist. If it were, three unpinned runs agreeing would be a 1-in-36 coincidence on top of lane CB's 1-in-36.

Not resolved, and it is one line to resolve: log the chosen tech id beside the draw in a probe on 0x006a8390, whose cl_RandRange(0, n-1) bound n is the shortlist size the whole k question is about. Lane CB's own honest note — that player 512's target 282 is outside the named set and "k = 6, all six nameable" is not closed — is the same open item seen from the outcome side.


4. Why the count moves — the mechanism, read from the instruction stream

4.1 The primitives are not fixed-cost

RNG_NextInt 0x004271c0, whole body, ret 4:

4271c9  edi = *bound
4271cb  edi |= edi>>1 |= edi>>2 |= edi>>4 |= edi>>8 |= edi>>16    ; mask, computed ONCE
4271f0  LOOP: lazy twist if left==0; y = *next++; left--; temper
42723e        and eax,edi
427240        cmp eax,DWORD PTR [ebx]        ; re-reads *bound every pass
427242        ja  0x4271f0                   ; reject iff (y & mask) > bound   -- UNSIGNED

One back-edge, no iteration counter and no bail-out. E[words] = (mask+1)/(bound+1), in [1, 2), and exactly 1 only when bound+1 is a power of two. Almost every bound in the AI turn is a container size minus one, so extra words are the ordinary case, not the exception. Measured: one call costing 3 words (§3.2).

RNG_Chance 0x008e6dd0 returns without a draw at p <= 0 and at p >= 1; otherwise exactly one word, no rejection. Two details for a reimplementation: the unit is stored to float32 before the comparison, and the accept test is strict p > r — the opposite convention from the inlined site in EncounterDetect_AssignContacts, where equality accepts. Two conventions in one image.

RNG_NextFloat 0x0047d830 is exactly one word, always.

This alone refutes the hypothesis. A design that drew unconditionally to stay in lockstep would not use a rejection loop, because a rejection loop's cost is not knowable in advance.

4.2 The draws are conditional, and mostly do not fire

Twenty-three draw call sites are reachable inside an AI turn, in twelve AI functions; two of them are provably dead code (0x006adf5d, 0x006adf6c: the block is guarded by cmp esi,ebx / jle with esi and ebx both zeroed on every inbound edge), leaving 21 live sites. On the reference turn, two fired. On turn 1, three.

Sites and their guards (full table in §5). The shapes that matter:

  • species gates — Process Turn phase 2 returns immediately unless ClientPlayer+0x5c == 1 (Hiver); phase 20's task creation is a seven-way species switch in which species 4 creates nothing at all; the helper 0x00694820 costs 1 word for species 0/4/5/6 and 1-or-2 for species 1/2/3;
  • empty-container gates — nearly every cl_RandRange sits behind cmp begin,end / je skip. This is the dominant shape;
  • success gates — the surrender roll at phase 12 gates phases "13" and "14", which are not sequential phases at all but the body of its success arm (a correction to ai-turn-logic.md §3.1). Six further draw sites live in there and only run on a turn where an AI actually rolls to surrender;
  • loop-carried draws — 0x006ae575 is one cl_Chance(0.2f) per qualifying empty section slot of a ship design, inside a composer that is itself called 1–3 times per design request inside two outer loops. This is unbounded and it is where most of the variance lives.

4.3 The single most legible instance: 496 versus 512 on turn 1

ai-order-emission.md §2 establishes that all three AI players set a research target on turn 1. 496 and 512 own nothing — same Sav = 0, no colonies, no fleets, no systems. Yet:

  • 496 draws zero words. Its research pick is forced; the shortlist has one member, the cl_RandRange that would break a tie is behind cmp eax,[edi+4] / je return and never runs.
  • 512 draws exactly one cl_RandRange. Its shortlist has more than one member, so the tie is broken by a draw.

That is the mechanism behind lane L5's "one of three empires' research pick varies between runs" and lane L1's "all three seeds move but only one empire's pick does", and it is now a read of the code rather than an inference from outcomes: two of the three empires never reach the seed-sensitive branch at all — precisely the situation method rule 26's corollary warns about.


5. The draw-site table

Every RNG draw reachable inside OnResumePlaying for an AI client. U = unconditional once its enclosing function is entered; C = guarded. Phase numbers follow ai-turn-logic.md §3.1.

# draw VA enclosing fn entry reached from U/C guard
1 0x006c592a 0x006c57e0 cl_Chance(p) phase 2 C Species == 1 (Hiver), an agent latch +0x104, and an empty-candidate test; p is live, so Chance's early-outs apply
2 0x006aeeed 0x006aee70 cl_RandRange phases 2, 14 via 0x006b23b0 C message-list vector empty
3 0x006cc57e 0x006cb570 cl_Chance(clamp01(r)) phase 7 C turn delta ≤ 15 returns; p clamped to [0,1], so both zero-word early-outs are reachable
4 0x006cfc24 0x006cf8a0 cl_Chance(min(1/T,0.5)) phase 12 C survival outlook false, or T > 30, or p <= 0
5 0x00694830 0x00694820 cl_Chance(0.75f) phase 14 U first instructions of the function
6–8 0x00694865/87/a6 0x00694820 cl_Chance(0.8f) phase 14 C species jump table: 1 / 3 / 2 respectively; species 0/4/5/6 draw nothing here
9 0x006c3447 0x006c33e0 cl_RandRange phase 14 C switch(arg) — only arm 2 reaches it, and arg is the return of #5–8
10 0x006a8495 0x006a8390 cl_RandRange phase 18 (research target) U past 2 gates this == 0, or the shortlist vector is empty
11 0x006817ae 0x00681750 cl_RandRange(0,5) phase 20 C the fallback arm: only when no category scored
12 0x0069086a 0x00690860 cl_Chance(0.5f) phase 20 U none — 4 instructions, return chance ? 18 : 19
13 0x006ad873 0x006ad700 cl_RandFloat phases 20, 25 C an "ok" latch cleared by three tech lookups, and request-flag bit 0x40
14 0x006ad947 0x006ad700 cl_RandRange " C hull slot already filled, or zero candidates
15 0x006adf30 0x006ad700 cl_Chance(0.5f) " C matching-section count ≤ 0
16 0x006adf3f 0x006ad700 cl_RandRange " C nested under #15's success
17 0x006adf5d 0x006ad700 cl_Chance(0.8f) " DEAD cmp esi,ebx / jle with both zero on every inbound edge
18 0x006adf6c 0x006ad700 cl_RandRange " DEAD same block
19 0x006adfc5 0x006ad700 cl_Chance(0.8f) " U in its block both arms above converge before it
20 0x006ae413 0x006ad700 cl_Chance(0.3f) " C flag bit 0x20000, or a counter ≤ 0
21 0x006ae575 0x006ad700 cl_Chance(0.2f) " C, LOOP once per qualifying empty section slot
22 0x006cce84 0x006ccdb0 RNG_NextInt phases 20, 25 (design names) C no name-gen object, a cached name hit, or an empty name pool
23 0x0068bf03 0x0068bdc0 cl_RandRange(2,5) phase 20, only via AITEscortGate::Execute vtable slot 5 C a local bool; what sets it was not read

Three of twenty-one live sites are unconditional, and all three are unconditional only given that their function was called — which is itself gated. Not one draw in the AI turn is unconditional with respect to the turn.

5.1 Sites on the same generator that are outside the turn

Listed so a later lane does not double-count them:

  • Prepare Turn only (packet code 2, raised by RaiseAIPrepareTurn 0x00815f20, whose two callers RunAI and CreateGame are both one-shot): 0x006cfa06, 0x0069620e, and three draws in 0x0069dbb0 — 0x0069dbfd, 0x0069dc29 and 0x0069dcff, which lane AI1's six-site list does not have. 0x0069dbb0's guard if (agent->+0x36c == 0) is a true one-shot because the third draw re-arms the field to turn + 20 + r, which is never 0. These land at construction and never again — verified live: none of those return addresses ever appears in a bracket.
  • Net-message paths, not the turn: 0x0069df9d (agent vtable slot 3) and 0x006ca11c (slot 4) are dispatched only from StrategyNetworkClient::OnMessage 0x00784640. (This also corrects ai-turn-logic.md §2, which lists 0x0069de50 as the AIObject packet sink: it reads [pkt+4] as a 0/1/2 selector, while the four real AIObject sinks read [pkt+0] as the code — and none of those four draws anything.)

The load-time cost is large, asymmetric, and itself not fixed. With the lifetime census on the canonical pair, the three AI clients arrive at their first turn already 9, 411 and 421 words into their streams (build par4, in which the nested double count is corrected), while the human client's generator is at word 0. And the figure moves between processes: client 32 began its turn at block index 9 in one run and 11 in another, because the rejection loops it runs during Prepare Turn resolve differently under different seeds.

So the AI's generator position at the start of a turn is not a function of the save. Two loads of the same file put the same client at two different stream offsets. That is a second, independent reason a reimplementation cannot reach a byte match on an AI empire by counting draws.

g_GlobalRNG 0x00af6e58 shows 3 words drawn, one per AI client construction — a second, independent falsification of ai-turn-logic.md §5.1's "every draw from it returns 0", after lane L1's, and from a different instrument.


6. What is not established

  1. No inlined draw runs in an AI turn — and that is now measured, not assumed. The left_delta vs observed residual was 0 on every bracket of every run. Statically: an image-wide scan for the MT tempering immediates at real instruction boundaries reproduces lane I's 15 functions exactly, and none is in the AI band; the three nearest (0x006ec720, 0x006f65f0, 0x006f7890) are StrategyNetworkServer and sit above 0x006e0000. Lane AI1's rule-16 caveat is discharged with the band bounds it asked for.
  2. The site inventory is a lower bound past two indirect hops. IAITask::Execute (slot 5, all 31 classes) and the AIObject packet sink (slot 3, all four) were resolved; a virtual call inside a task's Execute subtree is unmodelled. Weak evidence that it is close to saturated: adding all 31 Execute roots to the 31 phase roots added exactly one new draw function.
  3. Only two boards were measured, both from the same 2-player-plus-shadows game, and no species 5 or Hiver AI turn was measured. The species terms in §5 are read from the instruction stream and are rule-6 hypotheses live. A Hiver AI would exercise sites 1 and 6 and is the cheapest remaining workload.
  4. The dead-code claim on sites 17–18 is a static read. No live hit has been observed, but no run has exercised the enclosing arm heavily either.
  5. 0x0068bdc0's [ebp-0x1] — what sets it was not read.
  6. Whether agent+0x36c is serialised in the StrategyAIAgent::Streamable block. If it is, the Prepare-Turn cost differs between a new game and a loaded save and the whole stream is offset.
  7. The number of ship-design requests per turn is unquantified. The chain phase 20/25 → 0x006ce190 → 0x006cda40 → 0x006ae620 → 0x006ad700 was confirmed as edges and two enclosing loops, but the intermediate bodies were not read. Since 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 (entry probe on 0x006ad700 logging arg[3] and 0x006ae620's loop counter).

7. What this means for sots-engine

The cheap version of game/ai does not exist. The redirect's hope was that a reimplementation could stay in lockstep by reproducing the count and order of draws while getting the decisions wrong. It cannot:

  • every draw in the AI turn is behind a branch on game state, so a wrong decision changes whether a draw happens, not only its result;
  • RNG_NextInt's rejection loop makes the cost of a single call depend on the stream, so even a correct call sequence cannot be costed in advance — the engine must reproduce the rejection loop bit-for-bit (it already does; mars/rng is closed);
  • RNG_Chance's two zero-word early-outs mean a probability computed slightly wrong can change the word count from 1 to 0 and desynchronise everything after it.

The positives, which are real:

  • the AI draws from nothing but its own client's generator. foreign_words = 0 on every bracket of every run — a live confirmation of what ai-turn-logic.md §5 could only assert from a direct-call scan. The strategic generator and the AI are genuinely independent, so an engine can get the server's stream exactly right while the AI's is still approximate.
  • the human client draws nothing. Every human bracket measured 0 words with agent = 0. There is no client-side RNG on the human path at all.
  • the per-turn cost is tiny — single digits — so the AI's generator is not a large unknown; it is a small, sharp one.
  • the cl_* façade is a clean, complete, AI-only API: three functions, 29 call sites, all in the AI band. An engine implements exactly those three and has covered every strategic-AI draw.

The consequence for the roadmap is the one lane L1 already reached from the seed side, now with a mechanism: Rung B (replay a recorded command stream) is unaffected — it does not run an AI. Rung C (behavioural AI) needs the decisions, and the decisions live in the ship-design composer and the task system, neither of which is modelled.


8. Appendix — the call-parity question, and what this lane can say about it

This lane was briefed on call parity ("does the AI reach the simulation through the same interface a human client does?") and redirected mid-flight to roll parity. The redirect is recorded because the call-parity deliverable is not complete: the ~22 hypothesis rows in lane Q's list table were not converted by driving the UI-reachable order methods, and no new order method was found. What follows is only what this lane's own work establishes.

Parity holds at the wire and apply layers, and it breaks at the call layer — the reading the brief proposed, and nothing measured here contradicts it. Two things are added:

  1. A new, clean piece of AI-only call surface: the cl_* RNG façade (§2.1). Three functions — cl_Chance 0x00578cf0, cl_RandRange 0x005798e0, cl_RandFloat 0x00579c70 — with 29 call sites between them, all 29 inside the AI module. Nothing else in the image draws through them. This sits alongside cl_SetResearchTarget 0x00578f60 and cl_EndTurn 0x00579310 (whose +0x12c test makes it AI-only by construction) as part of a coherent AI-only façade over "the current client". It is not order surface — it emits no TurnCommands element — but it is interface the engine must implement separately, and cl_RandFloat was not previously known to exist.

  2. A live confirmation that the human client has no client-side RNG at all. Every human OnResumePlaying bracket measured 0 words with agent = 0. So the asymmetry at the call layer is not merely "the AI calls some methods the UI does not" — the AI has an entire stateful facility, a private seeded generator, that the human path never touches. StrategyClient+0x134 exists on the human client too, and stays at word 0 for the whole game.

For sots-engine the answer to the original question is therefore: one order path, two client APIs. The order methods and everything downstream of StrategyClient+0x160 are shared and need implementing once; the AI-only surface the engine must implement separately is the cl_* façade (three RNG entries plus cl_SetResearchTarget and cl_EndTurn) and the four order methods with no UI caller that lane AI1 P4 named (0x00762ca0, 0x00763670, 0x00763720, 0x00763990) — with lane AI2's caveat that its own sample was incomplete still standing, because this lane did not extend it.


9. Files

  • Instrument: sots-engine wip/par — src/shim/hooks/ai_rng.{h,cpp}, the draw_sites observer hook, src/shim/shim.cfg.par{ctl,br,pin,orders}
  • Predictions, committed before the build: sots-engine/docs/PAR-predictions.md
  • Raw brackets and autosave hashes: verify/results/shim/airng/ — six runs, par-{br-p1,br-p2} (canonical pair, unpinned, two fresh processes), par-pin-a1 (canonical pair, pinned), par-{t1-br-p1,t1-br-p2,t1-br-p3} (turn1-state, three fresh processes, the third with façade attribution), plus parse_airng.py, the reader
  • Addresses: ghidra/addresses.d/lane-par.json (2 entries; validated to a scratch path, 1,219 → 1,221, no duplicate names and no same-address-different-name)

Gates, run as separate commands on a fresh build directory (rules 13, 24)

  • tools/clean_room_check.sh — OK
  • host ctest — 55/55
  • CT111 shim cross-build — OK, build-shim removed first, par-gate-20260908T2330Z

The build deployed to the guests was par4-6ba2c79-20260908T2322Z, which differs from the committed source only in that the two façade hook targets were written as RVA literals rather than sots::addr::cl_Chance / cl_RandRange. Those constants resolve to the same two RVAs (0x00178cf0, 0x001798e0), so the gate build and the measured build are the same instrument; the rename is recorded here rather than left for a reader to notice.

sots-engine main moved to lane CB's merge while this lane ran, so the generated header on wip/par was regenerated against the pre-merge base. It must be regenerated, never hand-resolved, at merge time (rule 14).