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.
35 KiB
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_008cca30has no temper chain; the four bytes at 0x008cca90 are the rel32 displacement ofcall 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::NextUIntandFUN_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 fromOnAllCombatDone_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:
- ECX is the
Mars::RNGobject, not&mt. It doeslea esi,[ecx+4]itself and handsesitoRNG_Twist.RNG_NextFloat0x0047d830 andRNG_NextInt0x004271c0 are entered with ECX already equal to&mt, i.e. object + 4. Two conventions for the same generator, andProbabilisticJumpuses both, 0x6b bytes apart (§3.1). Rule 1's "two bases 4 bytes apart" is live here in a second place. - 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 by0x0050e6a0(0, |contacts|, &0)at 0x007aa296. Bitdof wordcis "pair (contact c, detector d) has been tried".[ebp-0x4c]—std::vector<bool>,|contacts|bits,resize(|contacts|, false)at 0x007aa2c8. Bitcis "contact c has been assigned". Theshr eax,5/and ecx,0x1f/shl esi,claddressing at 0x007aa2ea–0x007aa320 is what identifies it asvector<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.0fand the test0.0f >= rcan only pass on the word0x00000000. 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:
0x007892d0(rec)at 0x007ca671 — false unless some entry ofrec->(+0x28 .. +0x2c)(stride 0x44) hasentry[0]->+0xfc != 0. False → the function returns without building anything.detectors = 0x007949b0(rec)— the entries whose object has+0xfc == 0and+0xfb == 0. Empty → return.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+0xfcmember. Lane Z observed the encounter vector go 0 → 1 insideDetectEncounterson every one of four turns, so this path was entered every turn; whether the+0xfcgate 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-turn2is 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/+0xfcon 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_ProcessTeamRecordwith|contacts|and|detectors|in the argument record — which turns §2.2's formula into a testable prediction in one turn. Mars_RNG_GaussianRangeis not modelled inoursand should not be until something reaches it: its stream position depends onlog,sqrtandcosmatching 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:
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 inProcessTurn's closure at depth 4" both stand.combat-resolver.md§0.1:FUN_004f7670andFUN_007aa240are "the leading candidate mechanism" for lane Z's per-turn gap. Read in full,FUN_004f7670is a one-word primitive on a conditional fleet-movement branch andFUN_007aa240is 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.- 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::rnginsots-enginewas never wrong. addresses.json'sProbabilisticJumpprototype describes oneNextFloat. It draws a second word on the non-arrival branch. Recorded atProbabilisticJump_NextUIntDrawrather than edited into the shared file, per the fragment convention.RNG_size/ the primitive set inaddresses.jsonlists 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, theProbabilisticJumpsecond 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-enginewip/inlined(dc43f93):MT19937::float_range/range_from/int_range_bellwith tests, and the entry-point table indocs/mars-rng.md.tools/clean_room_check.sh→ OK and hostctest→ 36/36, run as separate commands. Nosrc/shimchange, so no cross-build is implicated.