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.
This commit is contained in:
alex 2026-09-08 10:03:33 -04:00
parent 5618483abe
commit 888f318d8c
2 changed files with 695 additions and 0 deletions

View file

@ -0,0 +1,563 @@
# 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.

View file

@ -0,0 +1,132 @@
{
"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)"
}
]
}