sots-re/ghidra/addresses.d/lane-i.json
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

132 lines
14 KiB
JSON

{
"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<std::vector<void*>>* outBuckets, std::vector<void*>* detectors, std::vector<void*>* 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<int> 'tried' bitset at [ebp-0x38] (|contacts| words, bit = detector index) and a vector<bool> '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<vector<void*>>(|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<TeamRecord>* 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)"
}
]
}