CB pinned the seeds and got d59bb9f2 three times; this lane left the seeds free, watched client 512's single research tie-break draw resolve with a different number of rejections in each process (1, 3, 1 words), and still got d59bb9f2 three times. Different experiments, agreeing in a way that constrains the mechanism: whatever maps that drawn word onto a research target is NOT a uniform index into a six-member shortlist, or three unpinned runs agreeing would be 1-in-36 on top of CB's 1-in-36. One line resolves it -- log the chosen tech id beside the draw in a probe on 0x006a8390, whose cl_RandRange bound IS the shortlist size the k question is about.
31 KiB
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-enginewip/par, new shim modulesrc/shim/hooks/ai_rng.cpp, configsshim.cfg.par{ctl,br,pin,orders}, buildspar-……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 (thecl_*façade has three RNG members, not two, and tencl_RandRangecall sites, not eight);ai-task-system.md§10.1's six directNextIntsites
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 helper0x00694820costs 1 word for species 0/4/5/6 and 1-or-2 for species 1/2/3; - empty-container gates — nearly every
cl_RandRangesits behindcmp 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 —
0x006ae575is onecl_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_RandRangethat would break a tie is behindcmp eax,[edi+4] / je returnand 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 callersRunAIandCreateGameare both one-shot):0x006cfa06,0x0069620e, and three draws in0x0069dbb0—0x0069dbfd,0x0069dc29and0x0069dcff, which lane AI1's six-site list does not have.0x0069dbb0's guardif (agent->+0x36c == 0)is a true one-shot because the third draw re-arms the field toturn + 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) and0x006ca11c(slot 4) are dispatched only fromStrategyNetworkClient::OnMessage 0x00784640. (This also correctsai-turn-logic.md§2, which lists0x0069de50as theAIObjectpacket sink: it reads[pkt+4]as a 0/1/2 selector, while the four realAIObjectsinks 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
- No inlined draw runs in an AI turn — and that is now measured, not assumed. The
left_deltavsobservedresidual 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) areStrategyNetworkServerand sit above0x006e0000. Lane AI1's rule-16 caveat is discharged with the band bounds it asked for. - The site inventory is a lower bound past two indirect hops.
IAITask::Execute(slot 5, all 31 classes) and theAIObjectpacket sink (slot 3, all four) were resolved; a virtual call inside a task'sExecutesubtree is unmodelled. Weak evidence that it is close to saturated: adding all 31Executeroots to the 31 phase roots added exactly one new draw function. - 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.
- 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.
0x0068bdc0's[ebp-0x1]— what sets it was not read.- Whether
agent+0x36cis serialised in theStrategyAIAgent::Streamableblock. If it is, the Prepare-Turn cost differs between a new game and a loaded save and the whole stream is offset. - The number of ship-design requests per turn is unquantified. The chain
phase 20/25 →
0x006ce190→0x006cda40→0x006ae620→0x006ad700was 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 on0x006ad700loggingarg[3]and0x006ae620'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/rngis 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 = 0on every bracket of every run — a live confirmation of whatai-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:
-
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 alongsidecl_SetResearchTarget 0x00578f60andcl_EndTurn 0x00579310(whose+0x12ctest 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 noTurnCommandselement — but it is interface the engine must implement separately, andcl_RandFloatwas not previously known to exist. -
A live confirmation that the human client has no client-side RNG at all. Every human
OnResumePlayingbracket measured 0 words withagent = 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+0x134exists 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-enginewip/par—src/shim/hooks/ai_rng.{h,cpp}, thedraw_sitesobserver 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), plusparse_airng.py, the reader - Addresses:
ghidra/addresses.d/lane-par.json