{ "entries": [ { "name": "Mars_RNG_NextUInt", "addr": "0x004f7670", "convention": "thiscall", "prototype": "uint32_t (Mars::RNG* this /*ecx = THE OBJECT, not &mt*/) // plain RET, no stack args. THE FOURTH DRAW ENTRY POINT. Whole 84-byte body read from the instruction stream: `cmp [ecx+0x9c8],0; push esi; lea esi,[ecx+4]; jne skip; mov ecx,esi; call RNG_Twist; skip: eax=[esi+0x9c0]; dec [esi+0x9c4]; ecx=*eax; eax+=4; [esi+0x9c0]=eax;` then the standard Mars temper (shr 11 / and 0xff3a58ad shl 7 / and 0xffffdf8c shl 15 / shr 18) and `ret`. EXACTLY ONE MT WORD, UNCONDITIONAL -- no rejection loop, no early-out, no branch except the lazy twist. Contrast RNG_NextFloat and RNG_NextInt, which are entered with ECX = &mt = obj+4; this one takes the object and does the +4 itself. Body ends 0x004f76c3 (Ghidra's 84 is correct here), then 12 int3 to 0x004f76d0. 11 callers image-wide; in StrategyServer::ProcessTurn's direct-call closure at DEPTH 4 via ProcessFleetMovement 0x007da9a0 -> MoveFleet 0x007d9ee0 -> ProbabilisticJump 0x007b6700 @0x007b67e7", "status": "verified", "source": "findings/control-flow/inlined-draws.md §2.1 (lane I 2026-09-08, objdump over dumps/sots.exe)" }, { "name": "Mars_RNG_FloatRange", "addr": "0x0047d8a0", "convention": "thiscall", "prototype": "float (Mars::RNG* this /*ecx = THE OBJECT*/, float lo, float hi) // RET 8. FIFTH DRAW ENTRY POINT, in no previous lane's primitive set. `add ecx,4; call RNG_NextFloat` then `lo + (float)((hi-lo) * unit)`, with the product STORED TO A FLOAT before the add and the sum stored to a float again -- two roundings, both must be reproduced. EXACTLY ONE MT WORD. In StrategyServer::ProcessTurn's closure at depth 3 via ServerPlayer::ProcessTurn -> 0x00889dc0 (call sites 0x0088a1bd, 0x0088a20f)", "status": "verified", "source": "findings/control-flow/inlined-draws.md §4.2 (lane I 2026-09-08)" }, { "name": "Mars_RNG_IntRangeBell", "addr": "0x008e6d80", "convention": "cdecl", "prototype": "int (Mars::RNG* rng /*STACK arg, the object*/, int lo, int hi) // plain RET. SIXTH DRAW ENTRY POINT. h = hi - lo; half = h/2 truncated toward zero (the `cdq; sub eax,edx; sar 1` idiom, so negative h rounds toward zero not down); returns lo + NextInt(half) + NextInt(h - half), both NextInt calls entered with ECX = rng+4 and both taking the bound BY POINTER. TRIANGULAR, not uniform. AT LEAST TWO MT WORDS -- each NextInt carries its own rejection loop. Not reachable from any turn driver by a direct call", "status": "verified", "source": "findings/control-flow/inlined-draws.md §4.3 (lane I 2026-09-08)" }, { "name": "Mars_RNG_GaussianRange", "addr": "0x008e6e30", "convention": "cdecl", "prototype": "int (Mars::RNG* rng /*STACK arg, the object*/, int lo, int hi, int mode) // plain RET. SEVENTH DRAW ENTRY POINT and the only one whose cost is UNBOUNDED. Box-Muller with rejection; BOTH draws are INLINED (temper chains at 0x008e6eb8 and 0x008e6f34), so no call-graph RNG sweep sees them and the only edges left are two bare RNG_Twist calls. half = (hi-lo)/2; mean = 2.1 * ((mode-lo)/half - 1). Per attempt: z = sqrt(-2 * ln(1 - (y1 + 0.5) * 2^-32)) * cos(2*pi * y2 * 2^-32) + mean; REJECT and redraw while z > 2.1 or z < -2.1 (back-edges 0x008e6f8d and 0x008e6fa0 -> 0x008e6e7f). Result = lo + ftol(((z + 2.1) / 4.2) * (hi - lo)). TWO MT WORDS PER ATTEMPT. NOTE THE DIVISOR: this path scales by 2^-32 (0x00a3b6f0), NOT the 1/(2^32-1) at 0x009e61b0 that RNG_NextFloat uses -- the two are different constants in the same image. Three callers (0x00786200, 0x00786230, 0x00798040); not reachable from any turn driver by a direct call", "status": "verified", "source": "findings/control-flow/inlined-draws.md §4.4 (lane I 2026-09-08)" }, { "name": "EncounterDetect_AssignContacts", "addr": "0x007aa240", "convention": "thiscall", "prototype": "void (EncounterDetectCtx* this, std::vector>* outBuckets, std::vector* detectors, std::vector* contacts) // RET 0xc. THE ONLY GAME FUNCTION WITH AN INLINED MT DRAW IN StrategyServer::ProcessTurn's CLOSURE (depth 4). this = {+0x00 StrategyServer* S, +0x04 TechDef* id 0x2729, +0x08 TechDef* id 0x2728}. For each contact (outer loop over `contacts`, index ebx) it walks `detectors` (inner loop, index edi) and draws ONE inlined NextFloat from S->rng (S+0x16c) PER (contact, detector) TRIAL, BEFORE the accept test. thresh = 0.25f (0x00a23a6c) if TechTree_HasTechComplete(detector->+0xf4, this->+0x8) or (detector->+0xf4, this->+0x4), else 0.0f; r = (float)unit; ACCEPT iff thresh >= r, and on accept the contact is pushed into outBuckets[detector] and the outer loop moves on. A detector with neither tech BURNS A WORD AND CAN NEVER ACCEPT (0.0f >= r only when the word is 0). WORDS PER CALL = sum over contacts of min(trials-to-first-accept, |detectors|); all-teched mean = |contacts| * 4 * (1 - 0.75^|detectors|), none-teched = |contacts| * |detectors| exactly. A vector 'tried' bitset at [ebp-0x38] (|contacts| words, bit = detector index) and a vector 'assigned' at [ebp-0x4c] are NEVER cleared, so the repeat-until-no-progress outer loop at 0x007aa583 cannot redraw a pair and always terminates after at most two passes. RULE 17: Ghidra reports 944 bytes (end 0x007aa5f0, mid-instruction); the real body ends at 0x007aa5f9 -- the std::vector length_error throw stub at 0x007aa5ee/0x007aa5f3 is outside the reported range", "status": "verified", "source": "findings/control-flow/inlined-draws.md §2.2 (lane I 2026-09-08, whole 953-byte body read from the instruction stream)" }, { "name": "EncounterDetect_AssignContacts_Draw", "addr": "0x007aa3b6", "convention": "site", "prototype": "site in EncounterDetect_AssignContacts: the INLINED Mars::RNG::NextFloat. `mov esi,[edx+0x16c]; cmp [esi+0x9c8],0; jne +8; lea ecx,[esi+4]; call RNG_Twist` -- the Twist call is the LAZY TWIST INSIDE NextFloat, not a bare Twist. The temper runs 0x007aa3e1..0x007aa407, the fild/+2^32/*1-over-(2^32-1) 0x007aa40c..0x007aa41d. THIS IS THE SITE RULE 16 WAS WRITTEN FOR: the only call-graph edge it leaves is EncounterDetect_AssignContacts -> RNG_Twist", "status": "verified", "source": "findings/control-flow/inlined-draws.md §2.2 (lane I 2026-09-08)" }, { "name": "EncounterDetect_AssignContacts_AcceptTest", "addr": "0x007aa435", "convention": "site", "prototype": "site in EncounterDetect_AssignContacts: `fcompp; fnstsw ax; test ah,5; jp 0x007aa45a`. st0 = thresh, st1 = r. ah&5 is 0 when thresh > r, 0 when thresh == r, 1 when thresh < r, 5 when unordered; PF is even for 0 and 5, so the jp is TAKEN (ACCEPT) iff thresh >= r or unordered, and falls through to the next-detector path iff thresh < r. EQUALITY ACCEPTS. Derive the branch from the ISA, not from the mnemonic (rule 10)", "status": "verified", "source": "findings/control-flow/inlined-draws.md §2.2 (lane I 2026-09-08)" }, { "name": "EncounterDetect_AssignContacts_OuterBackEdge", "addr": "0x007aa583", "convention": "site", "prototype": "site in EncounterDetect_AssignContacts: `cmp BYTE [ebp-0xd],0; je 0x007aa2d0` -- the repeat-until-no-progress back-edge that rule 17 exists to make you look for. [ebp-0xd] is set to 1 at 0x007aa2da at the top of each pass and cleared at 0x007aa360 by any (contact, detector) pair that is evaluated. It is NOT an unbounded loop: the 'tried' bitset it consults is never reset, so the second pass evaluates nothing, clears nothing, and the flag stays 1. At most two passes, and no pair is ever drawn for twice", "status": "verified", "source": "findings/control-flow/inlined-draws.md §2.2 (lane I 2026-09-08)" }, { "name": "EncounterDetect_AssignContacts_RealEnd", "addr": "0x007aa5f9", "convention": "site", "prototype": "the REAL end of EncounterDetect_AssignContacts. Ghidra reports sizeInBytes 944, i.e. an end of 0x007aa5f0, which falls inside the 5-byte `push 0x9e1f90` at 0x007aa5ee. The body's last instruction is the `call ds:0x9dd150` (std::vector length_error throw) at 0x007aa5f3, ending 0x007aa5f9; int3 padding runs to the next function start 0x007aa600. Real size 953. Rule 17", "status": "verified", "source": "findings/control-flow/inlined-draws.md §2.2 (lane I 2026-09-08)" }, { "name": "EncounterDetect_ProcessTeamRecord", "addr": "0x007ca640", "convention": "thiscall", "prototype": "void (EncounterDetectCtx* this, TeamRecord* rec /*the 0x74-byte record StrategyServer::DetectEncounters builds*/) // RET 4. THE ONLY CALLER OF EncounterDetect_AssignContacts. Gate at 0x007ca671: 0x007892d0(rec) is true iff some entry of rec->(+0x28..+0x2c) (stride 0x44) has entry[0]->+0xfc != 0; false -> whole function is a no-op and NO WORD IS DRAWN. Then `detectors` = 0x007949b0(rec) (entries whose object has +0xfc == 0 AND +0xfb == 0) and `contacts` = 0x00791460(rec) (entries whose object has +0xfc != 0); either empty -> return, still no draw. Otherwise buckets = vector>(|detectors|) via 0x007b77c0, then the draw call at 0x007ca73a", "status": "verified", "source": "findings/control-flow/inlined-draws.md §2.3 (lane I 2026-09-08)" }, { "name": "EncounterDetect_Run", "addr": "0x007cb080", "convention": "thiscall", "prototype": "EncounterDetectCtx* (EncounterDetectCtx* this, StrategyServer* S, std::vector* records) // RET 8, returns this in EAX. Constructs the 12-byte context {S, TechDef*(id 0x2729), TechDef*(id 0x2728)} -- the tech lookup is 0x0057d610(g_0x00b2d540->+0x110, id) -- then calls EncounterDetect_ProcessTeamRecord once per 0x74-byte record (the 0x8d3dcb09 / sar 6 divide-by-0x74 idiom). Called from StrategyServer::DetectEncounters 0x007d7f70 at 0x007d8470, i.e. inside the LAST phase of StrategyServer::ProcessTurn. This is the ONLY path by which a game-code inlined MT draw is reachable from ProcessTurn", "status": "verified", "source": "findings/control-flow/inlined-draws.md §2.3 (lane I 2026-09-08)" }, { "name": "TechTree_HasTechComplete", "addr": "0x0057d7e0", "convention": "thiscall", "prototype": "bool (TechTree* this, TechDef* def) // RET 4. `if (!def) return false; node = this->+0x10[def->+0x00]; return node != NULL && node->+0x14 == 4;` -- state 4 is 'researched'. 28 callers image-wide. EncounterDetect_AssignContacts uses it to choose between the 0.25f detection threshold and 0.0f", "status": "verified", "source": "findings/control-flow/inlined-draws.md §2.2 (lane I 2026-09-08)" }, { "name": "ProbabilisticJump_NextUIntDraw", "addr": "0x007b67e7", "convention": "site", "prototype": "site in ProbabilisticJump: the SECOND draw, `mov ecx,[edx+0x16c]; call Mars_RNG_NextUInt`. Note ECX is the RNG OBJECT here while the NextFloat at 0x007b677c three dozen bytes earlier is entered at rng+4 -- two conventions on the same generator in one function. It is reached only when the arrival test at 0x007b67ae..0x007b67b5 FAILS (i.e. fleet->+0x58->+0x158 < float(NextFloat() * fleet->+0x58->+0x154)); on the arriving branch the function copies the destination position and never draws again. ONE EXTRA MT WORD. The word is handed to 0x008a6fa0, which returns a pointer to three floats used to scatter the fleet's position around the destination", "status": "verified", "source": "findings/control-flow/inlined-draws.md §3.1 (lane I 2026-09-08)" }, { "name": "InlinedDrawScan_FalsePositive_008cca30", "addr": "0x008cca8f", "convention": "site", "prototype": "NOT AN RNG SITE, recorded so nobody re-derives it. An image-wide scan for the tempering immediates reports 0x008cca30 as containing 0xffffdf8c, but the four bytes at 0x008cca90 are the rel32 displacement of `call 0x008caa20` (e8 8c df ff ff), not an `and r32,imm32`. FUN_008cca30 has no temper chain: no 0xff3a58ad, no `shr r32,0xb`, no shl 7/15/18. It appears in combat-resolver.md §0.1's list of 14 inlined-draw functions and must be struck from it", "status": "verified", "source": "findings/control-flow/inlined-draws.md §1 (lane I 2026-09-08)" }, { "name": "MT_TemperMask1", "offset": "0xff3a58ad", "convention": "constant", "prototype": "0xff3a58ad -- the first tempering mask AS THE IMAGE SPELLS IT, `y ^= (y & 0xff3a58ad) << 7`. This is the TEXTBOOK MT19937 tempering with the mask applied BEFORE the shift rather than after: (y & 0xff3a58ad) << 7 == (y << 7) & 0x9d2c5680, because the mask bits above bit 24 shift past bit 31 and are dont-cares (0x9d2c5680 >> 7 == 0x013a58ad == 0xff3a58ad & 0x01ffffff). Verified over 200,000 random words. Mars::RNG is stock MT19937 -- our mars::rng model is NOT wrong -- but a scan for the textbook constants finds NOTHING in this image, which is why an inlined-draw sweep must scan for THIS value at real instruction boundaries (rule 16). 33 occurrences inside decoded instructions image-wide, all genuine `and r32,imm32` in a temper chain", "status": "verified", "source": "findings/control-flow/inlined-draws.md §1 (lane I 2026-09-08)" }, { "name": "MT_TemperMask2", "offset": "0xffffdf8c", "convention": "constant", "prototype": "0xffffdf8c -- the second tempering mask as the image spells it, `y ^= (y & 0xffffdf8c) << 15`, equal to the textbook `(y << 15) & 0xefc60000` (0xefc60000 >> 15 == 0x0001df8c == 0xffffdf8c & 0x0001ffff). Image-wide there are 34 occurrences of these bytes inside decoded instructions; 33 are genuine and ONE (0x008cca90) is the rel32 displacement of a call. Always require BOTH masks plus a preceding `shr r32,0xb` before calling a hit a draw", "status": "verified", "source": "findings/control-flow/inlined-draws.md §1 (lane I 2026-09-08)" } ] }