sots-re/findings/control-flow/inlined-draws.md
alex 888f318d8c lane I: the complete inlined-draw inventory, and the seven RNG entry points
Re-ran lane J's image-wide tempering-immediate scan at real instruction
boundaries and audited it site by site.  Sixteen functions carry the two masks
inside a decoded instruction, 67 occurrences, and a brute byte scan finds zero
orphans -- recall is complete.  Of the sixteen:

  * ONE IS A FALSE POSITIVE.  0x008cca30 has no temper chain at all; the four
    bytes read as the second mask are the rel32 displacement of a call.
  * FOUR ARE RNG ENTRY POINTS, not game code -- the tempering there is the
    primitive's own.  Two were known; 0x004f7670 and 0x008e6e30 were not.

So the figure is ELEVEN game functions with inlined draws over 28 sites, not
fourteen.  Exactly ONE of the eleven is reachable from StrategyServer::ProcessTurn
(0x007aa240, depth 4) and one more from OnAllCombatDone_Tail (lane J's R2).  The
other nine are map setup, the lobby, the network layer and two scripted
encounters, all reached through vtable slots with no direct caller -- so their
absence from the turn closure is proved for direct edges only, and that caveat
is stated as loudly as the result.

Both functions lane J handed over are read completely from the instruction
stream:

  0x004f7670 (84 B) is Mars::RNG::NextUInt -- ONE WORD, UNCONDITIONAL, no loop,
  no branch but the lazy twist.  ECX is the generator OBJECT, where NextFloat
  and NextInt take the object PLUS FOUR; ProbabilisticJump uses both conventions
  0x6b bytes apart.  Ghidra's size is right here, which is worth saying.

  0x007aa240 (Ghidra says 944; the body is 953 and ends past the reported range)
  is the encounter-detection roll.  ONE INLINED NextFloat PER (contact, detector)
  TRIAL, drawn BEFORE the accept test, so a detector holding neither of two
  specific techs still burns a word and can never succeed.  The accept test is
  `thresh >= r` -- equality accepts -- derived from the fcompp/test ah,5/jp
  encoding rather than the mnemonic.  The outer repeat-until-no-progress loop
  cannot redraw a pair: the "tried" bitset is filled above the back-edge target
  and never cleared, so the whole call is bounded by |contacts| x |detectors|.

Three more draw entry points nobody had listed: a float range (one word, and it
NARROWS TWICE), a triangular integer range (at least two words), and a
truncated-normal range whose two draws are BOTH inlined and which costs two
words per attempt with an unbounded attempt count -- and which scales by 2^-32
where NextFloat scales by 1/(2^32-1).  Two divisors, one image.

RECONCILIATION, stated honestly.  The complete draw-site inventory of the
ProcessTurn closure is 22 sites: 21 entry-point calls plus the one inlined site.
This lane adds two previously-uncounted sources to that list and accounts for
NONE of lane Z's 18-20 words per turn with certainty, because both new sources
are gated and neither has been measured.  Lane J's prediction that these two
functions would explain the gap is NOT confirmed.  What is now provable is the
negative: there is no twenty-third mechanism, so the 18-20 words are distributed
among exactly these 22 sites.  The search space closes; the count does not.

Also corrects, in place: my own Ghidra comment claiming the masks are a Mars
variant of MT19937.  They are the textbook masks applied before the shift
instead of after -- (y & 0xff3a58ad) << 7 == (y << 7) & 0x9d2c5680, verified
over 200k words -- so mars::rng was never wrong, but a scan for the textbook
constants finds nothing in this image.

Fragment validated by generating to a scratch path (797 entries, no duplicate
name); the tracked header is untouched while lane Z is in flight.  Eight
prototypes, nine plate comments and eight pre-comments written back to Ghidra.
2026-09-08 10:03:33 -04:00

35 KiB
Raw Blame History

The complete inlined-MT-draw inventory, and the two ProcessTurn functions read in full

Lane I, 2026-09-08. Program sots / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs.

Method. Every function in the image was decoded from its real instruction boundaries with tools/x86disp.py's length decoder, to the next function start, never to Ghidra's reported size (rule 17). Every claim about a function body below was read from objdump -b binary -m i386 -M intel over dumps/sots.exe, not from the decompiler (rule 4). No claim here comes from a delegated sweep — lane J's brief records that a delegated sweep repeated the truncation mistake independently, so this lane ran none.

Closes the top-ranked gap of findings/control-flow/combat-resolver.md §0.1 (lane J): "FUN_004f7670 and FUN_007aa240 … the cheapest RNG facts left." It corrects lane J's §0.1 in three places and confirms its central claim. Reconciles against lane Z's measured ledger (findings/control-flow/tail-rng-ledger.md).


0. Lead

Lane J found fourteen game functions with inlined MT draws and put two of them inside StrategyServer::ProcessTurn's closure at depth 4. Re-run at instruction boundaries and verified site by site:

  • Sixteen functions in the image contain the tempering immediates at a real instruction boundary, 67 immediate occurrences in total, and every one of the 67 falls inside a decoded instruction — a brute byte scan finds nothing the boundary decode misses, so recall is complete.
  • One of the sixteen is a false positive. FUN_008cca30 has no temper chain; the four bytes at 0x008cca90 are the rel32 displacement of call 0x008caa20. Strike it from lane J's list.
  • Four of the sixteen are RNG entry points, not game code — the tempering there is the primitive. Two were already known (RNG_NextFloat, RNG_NextInt); two were not: FUN_004f7670 = Mars::RNG::NextUInt and FUN_008e6e30 = a truncated-normal integer range.
  • That leaves eleven game functions with genuinely inlined draws, 28 sites between them, not fourteen.
  • Exactly one of the eleven is reachable from StrategyServer::ProcessTurn — FUN_007aa240, at depth 4 — and one more from OnAllCombatDone_Tail (FUN_007a7f30, lane J's R2, at depth 3). The other nine belong to map setup, the lobby screen, the strategy network client/server and two scripted encounters, and are reachable from no turn driver by a direct call.

Lane J's operational conclusion survives intact and its taxonomy does not: there really are two unmodelled draw sources at depth 4 under ProcessTurn, but only one of them is an inlined game draw. The other is a fourth draw primitive nobody had in their primitive set — which lane Z had already found from the other direction and which this lane confirms from the instruction stream.

And a fifth, sixth and seventh entry point exist. Three more functions take the generator and return a draw, none of them in any earlier lane's list: FUN_0047d8a0 (float range, 1 word), FUN_008e6d80 (triangular integer range, ≥2 words) and FUN_008e6e30 (truncated normal, 2 words per attempt, unbounded). One of them, FUN_0047d8a0, is inside ProcessTurn's closure at depth 3 with two call sites.

Correction to my own first reading, recorded per rule 11. The masks 0xff3a58ad / 0xffffdf8c are not a Mars variant of MT19937, as I wrote into Ghidra before checking. They are the textbook tempering with the mask applied before the shift instead of after: (y & 0xff3a58ad) << 7 == (y << 7) & 0x9d2c5680 and (y & 0xffffdf8c) << 15 == (y << 15) & 0xefc60000, because the mask bits that would shift past bit 31 are don't-cares (0x9d2c5680 >> 7 == 0x013a58ad == 0xff3a58ad & 0x01ffffff; 0xefc60000 >> 15 == 0x0001df8c == 0xffffdf8c & 0x0001ffff). Checked over 200,000 random words. sots-engine's mars::rng is not wrong. But a scan for the textbook constants finds nothing in this image, which is precisely why rule 16's scan has to use these two values.


1. The inventory

Scan definition, so it can be repeated: decode every one of the 41,089 functions from its start to the next function start; at each real instruction boundary, flag and r32, 0xff3a58ad (opcode 0x25 or 0x81 /4, mod=3) and require, within eight instructions forward, and r32, 0xffffdf8c, and within six instructions back, shr r32, 0xb. A site is classified float if an fmul QWORD ds:0x009e61b0 (the 1/(2^32-1) multiplier) follows within 26 instructions, otherwise uint.

Recall audit: a naive byte scan over the executable sections finds 67 occurrences of the two immediates; all 67 lie inside an instruction the boundary decode produced, and 0 are orphans. 70 functions desync during decode, every one of them inside the int3 padding after the body, so no desync hides a site.

# function body size sites kind in the closure of depth subsystem it belongs to
— RNG_NextInt 0x004271c0 139 1 uint S::ProcessTurn / Tail 3 / 4 entry point
— RNG_NextFloat 0x0047d830 109 1 float S::ProcessTurn / Tail 3 / 3 entry point
— Mars_RNG_NextUInt 0x004f7670 84 1 uint S::ProcessTurn 4 entry point — §2.1
— Mars_RNG_GaussianRange 0x008e6e30 410 2 uint — — entry point — §4.4
1 FUN_004b1f20 1349 4 float — — combat network client (vslot 3 of Game::CombatNetworkClient)
2 FUN_00507ac0 3073 12 float — — Game::CrowRuinsEncounter vslot 11
3 FUN_005232a0 562 1 uint — — Game::SwarmEncounter vslot 11
4 FUN_006ec720 483 1 float — — Game::StrategyNetworkServer vslots 3/4/7
5 FUN_006f65f0 1607 1 float — — same
6 FUN_006f7890 1649 1 float — — same
7 FUN_0079f7d0 656 1 uint — — Game::StrategyNetworkClient vslot 3 / OnMessage
8 FUN_007a7f30 CombatResolve_SalvageBackEng 2670 1 float OnAllCombatDone_Tail, CombatResolver_Run 3 / 1 lane J's R2
9 FUN_007aa240 EncounterDetect_AssignContacts 944→953 1 float StrategyServer::ProcessTurn 4 §2.2 — the one that matters
10 FUN_007c2fa0 4331 4 uint — — Game::StrategyLobbyScreen vslot 3
11 FUN_007c4140 2561 1 float — — Game::StrategyNetworkClient vslot 3
✗ FUN_008cca30 175 0 — — — FALSE POSITIVE, see below

28 sites in the eleven game functions; 5 more inside the four entry points; 33 and-pairs in total, which with the one stray displacement accounts for all 67 immediate occurrences.

Reachability caveat, stated as loudly as the result. The closure is a direct-call closure (E8 rel32 and tail E9 rel32 only). Nine of the eleven have their topmost caller in a vtable slot with no direct caller at all — Game::StrategyNetworkServer::Update, StrategyNetworkClient slot 3, StrategyLobbyScreen slot 3, CombatNetworkClient slot 3, and the two encounter classes' slot 11. Those are dispatched virtually. "Not reachable from ProcessTurn" therefore means not reachable by a direct call, which is what a call-graph sweep can prove and no more. The message-handler and encounter subtrees can and do run at other points of the frame; what is settled is that ProcessTurn does not call into them, and lane Z's independent measurement — the generator does not move at all between the post-turn autosave and the next ProcessTurn — is the stronger evidence for the same conclusion.

The false positive, in full

008cca8a  89 45 f8              mov  DWORD PTR [ebp-0x8],eax
008cca8d  8b ce                 mov  ecx,esi
008cca8f  e8 8c df ff ff        call 0x8caa20        <-- "8c df ff ff" is the rel32

No 0xff3a58ad anywhere in the function, no shr r32,0xb, no shl 7 / shl 0xf. Lane J's list has fourteen entries; this is one of them, and it should be thirteen minus the two primitives = eleven. Recorded at ghidra/addresses.d/lane-i.json under InlinedDrawScan_FalsePositive_008cca30 so nobody re-derives it. Requiring both masks plus the preceding shr 0xb is what separates 33 real chains from 34 candidate hits, and one hit in 67 is a 1.5% false-positive rate on a scan whose whole value is that it sees what the call graph cannot.


2. The two functions lane J handed over, read completely

2.1 FUN_004f7670 — 84 bytes, and it is not a game function

Whole body, every instruction:

004f7670  83 b9 c8 09 00 00 00   cmp  DWORD PTR [ecx+0x9c8],0x0     ; left == 0 ?
004f7677  56                     push esi
004f7678  8d 71 04               lea  esi,[ecx+0x4]                 ; esi = &mt[0]
004f767b  75 07                  jne  0x4f7684
004f767d  8b ce                  mov  ecx,esi
004f767f  e8 7c f7 f2 ff         call 0x426e00                      ; RNG_Twist(&mt)  -- LAZY
004f7684  8b 86 c0 09 00 00      mov  eax,DWORD PTR [esi+0x9c0]     ; next        (= RNG+0x9c4)
004f768a  ff 8e c4 09 00 00      dec  DWORD PTR [esi+0x9c4]         ; --left      (= RNG+0x9c8)
004f7690  8b 08                  mov  ecx,DWORD PTR [eax]           ; y = *next
004f7692  83 c0 04               add  eax,0x4
004f7695  89 86 c0 09 00 00      mov  DWORD PTR [esi+0x9c0],eax     ; next++
004f769b  8b c1                  mov  eax,ecx
004f769d  c1 e8 0b               shr  eax,0xb
004f76a0  33 c8                  xor  ecx,eax                       ; y ^= y >> 11
004f76a2  8b d1                  mov  edx,ecx
004f76a4  81 e2 ad 58 3a ff      and  edx,0xff3a58ad
004f76aa  c1 e2 07               shl  edx,0x7
004f76ad  33 ca                  xor  ecx,edx                       ; y ^= (y & M1) << 7
004f76af  8b c1                  mov  eax,ecx
004f76b1  25 8c df ff ff         and  eax,0xffffdf8c
004f76b6  c1 e0 0f               shl  eax,0xf
004f76b9  33 c8                  xor  ecx,eax                       ; y ^= (y & M2) << 15
004f76bb  8b c1                  mov  eax,ecx
004f76bd  c1 e8 12               shr  eax,0x12
004f76c0  33 c1                  xor  eax,ecx                       ; y ^= y >> 18
004f76c2  5e                     pop  esi
004f76c3  c3                     ret
004f76c4..004f76cf                int3 ×12

What it draws: one MT word. How many: exactly one. Under what condition: none — there is no early-out, no rejection loop, no branch at all except the lazy twist, which consumes nothing. How it scales with game state: it does not. words(call) = 1, always.

Ghidra's size of 84 is correct here (body 0x004f7670–0x004f76c3, then twelve int3 to the next function start 0x004f76d0) — which is worth saying out loud, because rule 17 is about never trusting the size, not about the size always being wrong.

Two facts that matter more than the count:

  1. ECX is the Mars::RNG object, not &mt. It does lea esi,[ecx+4] itself and hands esi to RNG_Twist. RNG_NextFloat 0x0047d830 and RNG_NextInt 0x004271c0 are entered with ECX already equal to &mt, i.e. object + 4. Two conventions for the same generator, and ProbabilisticJump uses both, 0x6b bytes apart (§3.1). Rule 1's "two bases 4 bytes apart" is live here in a second place.
  2. This is a draw primitive, and it was in nobody's primitive set until lane Z's ledger. Lane J listed it among "fourteen game functions with inlined draws". It is not a game function; the tempering in it is its own. Confirmed independently here from the instruction stream — lane Z reached the same conclusion from the caller side, which is two instruments agreeing.

Named Mars_RNG_NextUInt and written back to Ghidra with a plate comment.

2.2 FUN_007aa240 — EncounterDetect_AssignContacts, the one that matters

__thiscall, ret 0xc. Three stack arguments, resolved at the single call site 0x007ca73a (§2.3):

slot contents
this (ECX, spilled to [ebp-0x14]) { +0x00 StrategyServer* S; +0x04 TechDef* (id 0x2729); +0x08 TechDef* (id 0x2728) }
[ebp+0x08] out — std::vector<std::vector<void*>>, 16-byte elements, one bucket per detector
[ebp+0x0c] detectors — std::vector<void*>
[ebp+0x10] contacts — std::vector<void*>

Two locals do the bookkeeping and both are load-bearing for the draw count:

  • [ebp-0x38] — std::vector<int>, |contacts| words, filled with 0 by 0x0050e6a0(0, |contacts|, &0) at 0x007aa296. Bit d of word c is "pair (contact c, detector d) has been tried".
  • [ebp-0x4c] — std::vector<bool>, |contacts| bits, resize(|contacts|, false) at 0x007aa2c8. Bit c is "contact c has been assigned". The shr eax,5 / and ecx,0x1f / shl esi,cl addressing at 0x007aa2ea–0x007aa320 is what identifies it as vector<bool>, not a branch.

Control flow, from the instruction stream:

7aa2d0  PASS TOP  ------------------------------------- <- back edge from 7aa587
7aa2da    mov BYTE [ebp-0xd],1                          ; "no progress this pass"
7aa2de    if (|contacts| == 0) goto 7aa58d              ; whole-function exit
7aa2e6  OUTER TOP: ebx = contact index -----------------
7aa320    if (assigned[ebx]) goto 7aa56d                ; next contact, NO DRAW
7aa335    if (|detectors| == 0) goto 7aa56d             ; next contact, NO DRAW
7aa33b  INNER TOP: edi = detector index ----------------
7aa344    if (tried[ebx] & (1<<edi)) goto 7aa444        ; next detector, NO DRAW
7aa34d    tried[ebx] |= (1<<edi)
7aa350    thresh = 0.0f
7aa360    [ebp-0xd] = 0                                 ; progress
7aa364    if (this->+8 && det && HasTech(det->+0xf4, this->+8)) thresh = 0.25f
7aa37c    else if (this->+4 && det && HasTech(det->+0xf4, this->+4)) thresh = 0.25f
7aa3a3    scale = 1.0 - 0.0                             ; ds:0x009e1e68 is 0.0
7aa3b0    esi = S->rng                                  ; S->+0x16c
7aa3b6    ***** INLINED NextFloat -- ONE WORD *****
7aa435    fcompp / test ah,5 / jp 7aa45a                ; ACCEPT iff thresh >= r
7aa43e    (reject) -> 7aa444
7aa444    ++edi; if (edi < |detectors|) goto 7aa33b else goto 7aa56d
7aa45a  ACCEPT: assigned[ebx] = 1; out[edi].push_back(contacts[ebx]); -> 7aa56d
7aa56d    ++ebx; edi = 0; if (ebx < |contacts|) goto 7aa2e6
7aa583    if ([ebp-0xd] == 0) goto 7aa2d0               ; repeat the pass
7aa58d  EXIT: destroy the two locals, unwind, ret 0xc

The draw. At 0x007aa3b6 the compiler emitted RNG_NextFloat inline, byte for byte — the lazy-twist pre-check, the next++/left-- pair, the four temper steps, the fild / +2^32 / × 1/(2^32-1):

007aa3a8  mov  edx,[ecx]                     ; ecx = this  -> edx = StrategyServer S
007aa3b0  mov  esi,[edx+0x16c]               ; the strategic generator -- the SAME object
007aa3b6  cmp  DWORD PTR [esi+0x9c8],0x0
007aa3c0  jne  0x7aa3ca
007aa3c2  lea  ecx,[esi+0x4]
007aa3c5  call 0x426e00                      ; RNG_Twist -- THE LAZY TWIST INSIDE NextFloat
007aa3ca  mov  ecx,[esi+0x9c4] / dec [esi+0x9c8] / mov eax,[ecx] / add ecx,4 ...
007aa3ea  and  edx,0xff3a58ad ...  007aa3f7  and ecx,0xffffdf8c ...
007aa40c  fild DWORD PTR [ebp-0x20]
007aa40f  jns  0x7aa417
007aa411  fadd QWORD PTR ds:0x9e61b8         ; +2^32
007aa417  fmul QWORD PTR ds:0x9e61b0         ; × 1/(2^32-1)

The only call-graph edge this leaves is FUN_007aa240 → RNG_Twist, which reads as a bare twist and is not one. This is the site rule 16 exists for, and the second confirmed instance of the pattern after lane J's R2.

The accept test, derived from the ISA rather than the mnemonic (rule 10). fld [ebp-0x20] (r), fld [ebp-0x18] (thresh), fcompp compares st0 = thresh against st1 = r. test ah,5 isolates C0 and C2: ah&5 is 0 for thresh > r, 0 for thresh == r, 1 for thresh < r, 5 for unordered. PF is even for 0 and 5, so jp 0x7aa45a — the accept branch — is taken iff thresh >= r or unordered. Equality accepts. A reimplementation that writes thresh > r diverges on the exact-tie word and on any NaN.

The constants, read from .rdata: ds:0x009e1e68 is 0.0 (double) and ds:0x00a23a6c is 0.25f. So scale = 1.0 - 0.0 = 1.0 and r = (float)(0.0 + 1.0 × unit) = float(unit) — the fsub/fadd pair is the MSVC float-literal-zero idiom, not an offset. And the threshold is a flat 1-in-4.

Word cost. One word per (contact, detector) trial, drawn before the accept test and regardless of the threshold. Therefore:

words(call) = SUM over contacts c of  min( T_c , |detectors| )

   T_c = trials until the first detector accepts c
  • If every detector holds tech 0x2728 or 0x2729, each trial accepts with p = 1/4 and E[words] = |contacts| × 4 × (1 − 0.75^|detectors|) — 1 word for one detector, 1.75 for two, 2.3125 for three, 2.734 for four, → 4 asymptotically.
  • If no detector holds either tech, thresh = 0.0f and the test 0.0f >= r can only pass on the word 0x00000000. Every trial still burns a word, so the cost is exactly |contacts| × |detectors| and no contact is ever assigned. A detection roll that can never succeed still moves the generator. That asymmetry is the single most important thing about this function for a standalone.

The outer loop is not a redraw loop, and rule 17 is why I checked. 0x007aa583 cmp BYTE [ebp-0xd],0 / je 0x007aa2d0 is a repeat-until-no-progress back-edge that a truncated dump would hide. It cannot redraw a pair: tried[] is filled once before the loop (0x007aa296, i.e. above the back-edge target 0x007aa2d0) and never cleared, so the second pass finds every pair tried, evaluates nothing, clears nothing, and the flag survives at 1. At most two passes, and the whole-call cost is bounded by |contacts| × |detectors|. Total words never exceed that no matter how the passes interleave.

Rule 17, positively. Ghidra reports 944 bytes, i.e. an end of 0x007aa5f0 — which lands inside the five-byte push 0x9e1f90 at 0x007aa5ee. The real body ends at 0x007aa5f9, after the call ds:0x9dd150 (std::vector length_error throw) at 0x007aa5f3; padding runs to the next function start 0x007aa600. Real size 953. Here the nine hidden bytes are only a throw stub and change no draw count — but they are outside the range, the range is wrong, and the same defect one function over changed lane J's answer.

2.3 How it is reached, and what gates it

StrategyServer::ProcessTurn 0x007dc6c0
  @0x007dcc19 -> StrategyServer::DetectEncounters 0x007d7f70   (lane T: the LAST phase of the turn)
    @0x007d8470 -> EncounterDetect_Run 0x007cb080
      @0x007cb0f9 -> EncounterDetect_ProcessTeamRecord 0x007ca640   (once per 0x74-byte team record)
        @0x007ca73a -> EncounterDetect_AssignContacts 0x007aa240

EncounterDetect_Run builds the 12-byte context — {S, TechDef(0x2729), TechDef(0x2728)}, the two techs looked up by id through 0x0057d610(g_0x00b2d540->+0x110, id) — and then walks the team-record vector with the 0x8d3dcb09 / sar 6 divide-by-0x74 idiom. EncounterDetect_ProcessTeamRecord is ret 4 and gates the draw three times, each of which is a whole-call zero:

  1. 0x007892d0(rec) at 0x007ca671 — false unless some entry of rec->(+0x28 .. +0x2c) (stride 0x44) has entry[0]->+0xfc != 0. False → the function returns without building anything.
  2. detectors = 0x007949b0(rec) — the entries whose object has +0xfc == 0 and +0xfb == 0. Empty → return.
  3. contacts = 0x00791460(rec) — the entries whose object has +0xfc != 0. Empty → return.

0x0057d7e0, the predicate that chooses 0.25f over 0.0f, is 40 bytes and reads in full as bool TechTree::HasTechComplete(TechTree* this, TechDef* def) { if (!def) return false; node = this->+0x10[def->+0x00]; return node && node->+0x14 == 4; } — state 4 is researched. detector->+0xf4 is therefore a TechTree*.

So the mechanism is: a team record's +0xfc-flagged members are contacts to be spotted; its unflagged members are the detectors; each detector with one of two specific techs spots a given contact with probability 1/4 per trial; the first to succeed claims it — and every trial costs a word whether the detector could ever have succeeded or not. The semantic reading (which flag means what) is inferred; everything about the loop shape, the gates and the word cost is instruction-verified.


3. The other newly-visible draw under ProcessTurn

3.1 ProbabilisticJump 0x007b6700 draws two words, not one

addresses.json already carries this function with the note "v = float32(NextFloat() × player->CstE); arrives iff !(v > player->CstT…)". That is right and incomplete. The failure branch draws again:

007b677c  call 0x47d830           ; RNG_NextFloat, ECX = rng+4        <-- WORD 1, unconditional
007b6781  fmul ... fadd 0.0 ...   ; v = 0.0 + (X - 0.0) * unit  = float(unit * X)
007b67a8  fld  DWORD [ecx+0x158]
007b67ae  fcompp / fnstsw / test ah,1 / jne 0x7b67d5
007b67d5  ...                     ; the branch taken when [+0x158] < v
007b67db  mov  ecx,[edx+0x16c]    ; ECX = the RNG OBJECT this time, not rng+4
007b67e7  call 0x4f7670           ; Mars_RNG_NextUInt                 <-- WORD 2, conditional
007b67ec  mov  [ebp-0x194],eax
007b6800  call 0x8a6fa0           ; word -> pointer to three floats (a scatter direction)

On the arriving branch (0x007b67b7) it copies the destination's +0x18/+0x1c/+0x20 and never draws again. So:

words(ProbabilisticJump) = 1 + [ dest->+0x58->+0x158 < float(unit * dest->+0x58->+0x154) ]

and the call is reached once per fleet whose current waypoint is type 5, via ProcessFleetMovement 0x007da9a0 → MoveFleet 0x007d9ee0 (@0x007da0c2). MoveFleet is also self-recursive (0x007d9ee0 calls itself), which a per-call ledger must not double-count.

Note the convention flip inside one function: the NextFloat at 0x007b677c is entered with ECX = rng+4, the NextUInt at 0x007b67e7 with ECX = rng. 0x6b bytes apart, in one straight-line function.


4. The RNG entry-point set, corrected and completed

Seven functions in the image take a Mars::RNG and return a draw. Three were in ghidra/addresses.json; lane Z added a fourth from the caller side; this lane confirms that one from the instruction stream and adds three more.

VA name receives the generator as words per call
0x0047d830 RNG_NextFloat &mt = object + 4 exactly 1
0x004271c0 RNG_NextInt &mt, bound by pointer 1 + rejections
0x008e6dd0 RNG_Chance the object (add ecx,4 inside) 0 or 1 — p<=0 and p>=1 return with no draw
0x004f7670 Mars_RNG_NextUInt the object exactly 1, unconditional
0x0047d8a0 Mars_RNG_FloatRange the object exactly 1
0x008e6d80 Mars_RNG_IntRangeBell the object, as a stack arg ≥ 2
0x008e6e30 Mars_RNG_GaussianRange the object, as a stack arg 2 per attempt, unbounded

4.2 Mars_RNG_FloatRange 0x0047d8a0 — 41 bytes, ret 8, in ProcessTurn's closure at depth 3

0047d8a3  add  ecx,0x4
0047d8a6  call 0x47d830                  ; NextFloat -> st0 = unit
0047d8ab  fld [ebp+0xc] (hi) / fld [ebp+0x8] (lo) / fld st(0) / fsubp st(2),st  ; st1 = hi-lo
0047d8b5  fxch st(2) / fmulp st(1),st    ; (hi-lo) * unit
0047d8b9  fstp DWORD [ebp+0xc]           ; ***** ROUNDED TO FLOAT *****
0047d8bc  fadd DWORD [ebp+0xc]           ; lo + that
0047d8bf  fstp DWORD [ebp+0xc]           ; ***** ROUNDED AGAIN *****
0047d8c2  fld  DWORD [ebp+0xc] ; ret 8

lo + (float)((hi − lo) × unit), narrowed twice. Evaluating the expression in double and narrowing once disagrees on a measurable fraction of words — modelled and pinned in sots-engine (tests/mars_stream/test_rng.cpp asserts the two models are distinguishable, so the shortcut cannot creep back in unnoticed). Reached from ProcessTurn at depth 3 via ServerPlayer::ProcessTurn → 0x00889dc0, call sites 0x0088a1bd and 0x0088a20f.

4.3 Mars_RNG_IntRangeBell 0x008e6d80 — 70 bytes, cdecl, plain ret

h = hi − lo; half = h/2 truncated toward zero (the cdq / sub eax,edx / sar eax,1 idiom, not an arithmetic shift alone); returns lo + NextInt(half) + NextInt(h − half), the halves drawn in that order, both entered with ECX = rng + 4 and both taking the bound by pointer. Triangular, not uniform, and at least two words — each NextInt carries its own rejection loop. The bounds reach the draw as uint32, so an inverted range yields a huge first bound rather than an empty one. Modelled in sots-engine as MT19937::int_range_bell.

4.4 Mars_RNG_GaussianRange 0x008e6e30 — the only unbounded entry point

410 bytes, cdecl, plain ret, four stack args (rng, lo, hi, mode). Both of its draws are inlined (temper chains at 0x008e6eb8 and 0x008e6f34), so a call-graph sweep sees only two bare RNG_Twist edges. Box–Muller with rejection:

half = (hi - lo) / 2                       ; via fild/fmul 0.5
mean = 2.1 * ((mode - lo)/half - 1)
loop:
  y1 = <inlined draw>                      ; WORD 1
  z  = sqrt( -2 * ln( 1 - (y1 + 0.5) * 2^-32 ) )      ; 0x0092537c = log, 0x00924f52 = sqrt
  y2 = <inlined draw>                      ; WORD 2
  z  = z * cos( 2*pi * y2 * 2^-32 ) + mean            ; 0x00924f46 = cos
  if (z > 2.1 || z < -2.1) goto loop       ; 0x008e6f8d / 0x008e6fa0 -> 0x008e6e7f
return lo + ftol( ((z + 2.1) / 4.2) * (hi - lo) )     ; 0x00925220 = ftol

Two words per attempt, attempts unbounded. And note the divisor: this path scales by 2^-32 (ds:0x00a3b6f0) with a +0.5 offset on the word, not the 1/(2^32−1) at ds:0x009e61b0 that NextFloat uses. Two different divisors in one image; rule 3 records that the campaign has already been burned once by getting a divisor wrong in a way behavioural comparison could not see. Three callers (0x00786200, 0x00786230, 0x00798040); reachable from no turn driver by a direct call.


5. Reconciliation against lane Z's ledger — with the residual stated plainly

Lane Z measured 18 / 19 / 20 / 18 words for turns 3–6 of ref-turn2, all inside StrategyServer::ProcessTurn, residual outside the two turn drivers exactly zero, and confirmed turn 6's 18 independently from the two autosave files. docs/mars-rng.md records the same order of magnitude from a third instrument: left across three corpus saves runs 454 → 432 → 413, i.e. ~20 per turn.

Whatever those 18–20 words are, they come out of the sites below. The complete draw-site inventory of the ProcessTurn direct-call closure (1,426 functions) is 22 sites: 21 calls to an entry point, plus the one inlined site.

depth caller entry point site what it is
2 TechTree_ProcessResearch 0x005876c0 NextFloat ×2 0x00587888, 0x005878bb per player
2 ServerSystem_ProcessRebellion 0x007583b0 Chance 0x00758966 per system
2 0x00889dc0 NextFloat, NextInt ×3 0x0088a08f, 0x00889e40/f86/fd6 per player, all behind top-of-function gates
2 ServerPlayer_RollResearchEvent 0x0088df20 NextFloat 0x0088df4f per player, unconditional (lane T)
3 Mars_RNG_FloatRange 0x0047d8a0 NextFloat 0x0047d8a6 ← 0x00889dc0 @0x0088a1bd, @0x0088a20f
3 0x00747f50 NextInt ×2 0x00747fa8, 0x00747ffd ← MoveFleet
3 0x0074fbe0 / 0x00753c60 / 0x00756350 Chance ×3 — ← ProcessRebellion
3 0x00792750 NextInt 0x007929a4 ← DetectEncounters
3 ProbabilisticJump 0x007b6700 NextFloat + NextUInt 0x007b677c, 0x007b67e7 ← MoveFleet; §3.1
3 RNG_Chance 0x008e6dd0 NextFloat 0x008e6e04 the inside of Chance
4 0x00503200 NextFloat 0x0050329d ← DetectEncounters
4 0x008134e0 Chance 0x0081351c ← the end-of-turn tail
4 0x00889bb0 NextInt 0x00889c33 ← 0x00889dc0
4 EncounterDetect_AssignContacts 0x007aa240 inlined NextFloat 0x007aa3b6 §2.2 — invisible to every call-graph sweep

The arithmetic, honestly. My inventory adds two previously-uncounted draw sources to this list — the inlined site at 0x007aa3b6 and the NextUInt at 0x007b67e7 — plus one previously-unnamed entry point (FloatRange) at two existing call sites. It does not account for 18–20 words. It cannot: both new sources are gated, and neither has been measured.

  • EncounterDetect_AssignContacts: |contacts| × |detectors| at worst, |contacts| × 4(1−0.75^|detectors|) in expectation when the detectors are teched, 0 when the team record has no +0xfc member. Lane Z observed the encounter vector go 0 → 1 inside DetectEncounters on every one of four turns, so this path was entered every turn; whether the +0xfc gate passed is not knowable from statics. Live candidate, unmeasured incidence, contribution 0..(|contacts|×|detectors|).
  • ProbabilisticJump's second word: 0 on any turn with no type-5 waypoint whose arrival test fails. ref-turn2 is a two-player game in contact; nothing says a node jump occurred.
  • Everything else in the table was already visible to a call-graph sweep and none of it has a measured per-turn count either.

So the residual is: of lane Z's 18–20 words per turn, this lane explains between 0 and a state-dependent handful, and 18–20 minus that remains unattributed. Stating it the way lane P stated its "short by exactly 1": the inlined-draw inventory closes the search space — after this document there are 22 sites and no twenty-third — but it closes none of the count. What is now provable is the negative that matters:

There is no unaccounted-for draw mechanism left in StrategyServer::ProcessTurn. The 18–20 words are distributed among exactly these 22 sites (subject to the indirect-call caveat in §1), and the largest previously-unknown one is a per-(contact, detector) roll that costs a word even when it cannot succeed.

That is a smaller claim than "here are your 18 words" and a bigger one than lane J's prediction, which was that the two depth-4 functions would explain the gap. Lane J's prediction is not confirmed. FUN_004f7670 is a primitive that costs one conditional word on a fleet-movement path, and FUN_007aa240 costs a state-dependent number that may well be zero on lane Z's workload. If lane Z's next run brackets DetectEncounters the way it bracketed the tail, one measurement settles it.

5.1 What would falsify each claim here

claim how it would be shown wrong
0x004f7670 is exactly one word, always a ledger seeing ProbabilisticJump cost anything other than 1 or 2 words
0x007aa240 draws once per trial, not per assignment a bracket of DetectEncounters whose word count equals the number of contacts assigned rather than the number of pairs tested
the accept test is thresh >= r (equality accepts) a tie word producing no assignment — needs a forced state, not a normal run
a detector without either tech still burns a word a team record with untech'd detectors costing 0 words
the outer loop cannot redraw a pair any DetectEncounters bracket exceeding |contacts| × |detectors|
only one of eleven inlined sites is in the turn a nonzero residual reappearing inside ProcessTurn after all 22 sites are hooked

6. What this lane did not settle

  • Indirect calls are not modelled. §1's reachability is direct-call only, and nine of the eleven inlined-draw functions hang off vtable slots with no direct caller. Their subtrees are not in ProcessTurn, but "not in the closure" is proved only for direct edges.
  • The semantics of +0xfb / +0xfc on the team-record members — which is the contact and which is the detector in game terms — is inferred from the filters, not from a string, an RTTI name or a behavioural observation. The arithmetic does not depend on it; the story does.
  • Tech ids 0x2728 / 0x2729 (10024 / 10025) were not resolved to names against the tech data.
  • No draw here has been observed running. Every count in this document is static. Lane Z's instrument exists and brackets four functions; the useful next step is a fifth bracket on EncounterDetect_ProcessTeamRecord with |contacts| and |detectors| in the argument record — which turns §2.2's formula into a testable prediction in one turn.
  • Mars_RNG_GaussianRange is not modelled in ours and should not be until something reaches it: its stream position depends on log, sqrt and cos matching the original CRT bit for bit.
  • The eleven game functions' 28 sites were classified (float vs uint) and counted but only two of the eleven were read. The other nine are map setup, lobby, network and encounter code, and none of them is on the standalone's critical path — but their per-call costs are unknown.

7. Corrections to earlier findings

Per rule 11, plainly, in place:

  1. combat-resolver.md §0.1 (lane J): "fourteen game functions with inlined MT draws". The list of fourteen contains one false positive (FUN_008cca30, a call displacement) and two RNG primitives (FUN_004f7670, FUN_008e6e30). The correct figure is eleven game functions, 28 sites. The sixteen-function scan total and the "two in ProcessTurn's closure at depth 4" both stand.
  2. combat-resolver.md §0.1: FUN_004f7670 and FUN_007aa240 are "the leading candidate mechanism" for lane Z's per-turn gap. Read in full, FUN_004f7670 is a one-word primitive on a conditional fleet-movement branch and FUN_007aa240 is a gated per-pair roll. Neither is a plausible source of 18–20 words on a quiet turn. The gap is still unattributed; see §5.
  3. My own Ghidra plate comment at 0x004f76a4, written earlier today, claimed the tempering masks are a Mars variant of MT19937. They are the standard masks re-expressed; corrected in place, and mars::rng in sots-engine was never wrong.
  4. addresses.json's ProbabilisticJump prototype describes one NextFloat. It draws a second word on the non-arrival branch. Recorded at ProbabilisticJump_NextUIntDraw rather than edited into the shared file, per the fragment convention.
  5. RNG_size / the primitive set in addresses.json lists three draw entry points. There are seven; §4.

8. Artefacts

  • ghidra/addresses.d/lane-i.json — 16 entries: 4 entry points, 4 encounter-detection functions and sites, 3 named sites inside 0x007aa240 (draw, accept test, outer back-edge), the real-end marker, the ProbabilisticJump second draw, the false positive, and the two tempering masks as constants. Generated to a scratch path only; the tracked header is untouched while lane Z is in flight.
  • Ghidra: 8 prototypes, 9 plate comments and 8 pre-comments written back, all verified applied.
  • sots-engine wip/inlined (dc43f93): MT19937::float_range / range_from / int_range_bell with tests, and the entry-point table in docs/mars-rng.md. tools/clean_room_check.sh → OK and host ctest → 36/36, run as separate commands. No src/shim change, so no cross-build is implicated.