Compare commits

..

No commits in common. "c15a45d0c69525598c5c4c4279047ffd6bbc9673" and "1d7c6ef63edabd3f3484ac614c659d296b4d6922" have entirely different histories.

13 changed files with 4 additions and 1430 deletions

View file

@ -171,9 +171,3 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`).
| combat resolver mapped | control-flow | verified | high | 85% | 2026-09-08 | Lane J read all 7,641 bytes. THE RESOLVER DRAWS NOTHING ITSELF - exactly three sites in its subtree, each behind a function whose callerCount is 1 (the resolver), so nothing else in the image can trigger them: R1 NextInt @0x007bb69b (node cannon, once per battle, gated on something having been flung AND a non-empty candidate list) mean 4/3 words; R2 an INLINED NextFloat @0x007a84bd, one per back-engineering candidate per combatant with a non-zero salvage slot, exactly 1 each; R3 NextInt @0x00852ec7, one per successful R2 roll if the project list is non-empty, mean 2^ceil(log2 n)/n. Structure: only EIGHT non-stack stores in the whole function and EXACTLY ONE indirect call (in a _CxxThrowException path) - it composes and posts events and delegates every mutation to its 70 callees. Also: the subtree DOES write the turn-results accumulator at S->+0x2f4[PlyrIdx*0x11c]+0x90 on the surrender arm (a DIFFERENT member from the +0x24 lane K found, and that writer has SEVEN callers); 23 EVENT_* keys in the closure vs lane K's 7, five new, INCLUDING PLAGUE BEING DECIDED INSIDE COMBAT; sizeof(Game::TacReport)=0x94 enumerated twice |
| resolver formula is UNTESTED (stated) | verify | backlog | — | 0% | 2026-09-08 | Lane J's honest limit: every encounter in lane Z's workload had res->+0x4 set, so ApplyEncounterResult was a no-op and THE RESOLVER HAS NEVER EXECUTED UNDER AN INSTRUMENT. The formula is untested. combat-resolver.md 10.3 specifies the workload; the first cheap PREDICTION is that a plain fleet battle with no node cannon and no salvage should cost the SAME 18-20 words as a peaceful turn, because the resolver has no unconditional draw |
| lane J errors caught before publishing | meta | verified | high | 100% | 2026-09-08 | Two, both corrected pre-publication and both worth keeping: (1) read a callee as straight-line from a truncated Ghidra range, hiding the outer loop that sets R2's count - now rule 17; (2) drafted a 0x11c site as a false positive after converting its address BY HAND, wrongly - re-disassembling from a known boundary showed a real SETurnResults write. THE SCANNER WAS RIGHT AND THE READER WAS NOT |
| inlined-draw inventory COMPLETE | verify | verified | high | 100% | 2026-09-08 | Lane I re-ran the tempering-immediate scan ITSELF at real instruction boundaries (all 41,089 functions decoded TO THE NEXT FUNCTION START, never to Ghidra's size). SIXTEEN functions carry the masks inside a decoded instruction, 67 occurrences - and RECALL IS PROVED COMPLETE: a brute byte scan over the executable sections finds the SAME 67 and ZERO ORPHANS. 70 functions desync mid-decode, every one inside int3 padding, none hiding a site. **LANE J'S 14 WAS WRONG IN TWO WAYS**: one is a FALSE POSITIVE (FUN_008cca30's four bytes are the rel32 displacement of a call, not a temper chain) and FOUR are RNG ENTRY POINTS, not game code - including 0x004f7670 which lane J counted as a game function with an inlined draw and which is actually Mars::RNG::NextUInt, a primitive. CORRECTED FIGURE: **eleven game functions, 28 sites**. Only TWO are reachable from the turn drivers by direct edges: FUN_007aa240 (StrategyServer::ProcessTurn, depth 4) and FUN_007a7f30 (OnAllCombatDone_Tail depth 3 / CombatResolver_Run depth 1). The nine others top out at vtable slots with no direct caller - and lane I states AS LOUDLY AS THE RESULT that "not in the closure" is proved FOR DIRECT EDGES ONLY |
| Mars::RNG::NextUInt read completely | objects | verified | high | 100% | 2026-09-08 | 0x004f7670, 84 bytes, EVERY INSTRUCTION READ: `if (left==0) Twist(&mt); y=*next++; --left; temper(y); return y`. EXACTLY ONE WORD, unconditional, no loop, no branch but the lazy twist, no scaling with game state. Ghidra's 84 is CORRECT here (body ends 0x004f76c3, 12 int3 to 0x004f76d0) - worth stating, since rule 17 is about not TRUSTING the size, not about it always being wrong. CONVENTION TRAP: ECX is the RNG OBJECT, not &mt - it does `lea esi,[ecx+4]` itself, where NextFloat/NextInt are entered at object+4, and ProbabilisticJump uses BOTH conventions 0x6b bytes apart |
| EncounterDetect_AssignContacts + "a doomed roll still moves the generator" | verify | verified | high | 95% | 2026-09-08 | FUN_007aa240 (Ghidra says 944; REAL BODY IS 953 - Ghidra's end lands inside a `push` at 0x007aa5ee, real end 0x007aa5f9; nine hidden bytes, only a throw stub, no draw-count change, but the range is wrong). Draws ONE INLINED NextFloat PER (contact, detector) TRIAL, BEFORE THE ACCEPT TEST. Threshold 0.25f if the detector's TechTree has tech 0x2728/0x2729 else 0.0f; accept iff thresh >= r, derived from fcompp/test ah,5/jp - EQUALITY ACCEPTS. words = sum over contacts of min(trials-to-first-accept, |detectors|); with no detector teched that is |contacts| x |detectors| EXACTLY and NOTHING IS EVER ASSIGNED - **a roll that can never succeed still advances the generator**. The outer repeat-until-no-progress back-edge (which a truncated dump HIDES) cannot redraw a pair: the tried-bitset is filled ABOVE the back-edge target and never cleared, so at most two passes |
| RNG entry points: SEVEN, not three | objects | verified | high | 100% | 2026-09-08 | Lane I: NextFloat, NextInt, Chance, plus NextUInt, FloatRange 0x0047d8a0 (1 word, NARROWS TWICE, in ProcessTurn's closure at depth 3 with two call sites), IntRangeBell 0x008e6d80 (triangular, >=2 words) and GaussianRange 0x008e6e30 (2 words PER ATTEMPT, UNBOUNDED, both draws inlined). AND IT SCALES BY 2^-32 WHERE NextFloat SCALES BY 1/(2^32-1) - TWO DIVISORS IN ONE IMAGE. GaussianRange documented but deliberately NOT modelled |
| RNG residual: 18-20 words STILL UNEXPLAINED | verify | backlog | — | 0% | 2026-09-08 | THE HONEST HEADLINE. Lane J predicted the two inlined functions would explain lane Z's per-turn gap. **NOT CONFIRMED.** Both new sources are gated and neither has been measured: 0x007aa240 contributes 0..(|contacts|x|detectors|) and whether its +0xfc gate passed is not knowable statically; ProbabilisticJump's second word is 0 unless a type-5 waypoint fails its arrival test. RESIDUAL: 18-20 words, essentially ALL of it. WHAT IS NOW PROVABLE IS THE NEGATIVE: there is NO TWENTY-THIRD MECHANISM - the complete draw-site inventory of the ProcessTurn closure (1,426 functions) is 22 sites (21 entry-point calls + 1 inlined), so the 18-20 words are distributed among exactly those. THE SEARCH SPACE CLOSES; THE COUNT DOES NOT. One extra bracket on EncounterDetect_ProcessTeamRecord with |contacts|/|detectors| in the argument record turns the formula into a one-turn test |
| CORRECTION: no Mars MT19937 variant | objects | verified | high | 100% | 2026-09-08 | Lane I correcting ITSELF (rule 11). It had written into Ghidra that 0xff3a58ad/0xffffdf8c are a Mars variant of MT19937. THEY ARE NOT - they are the textbook masks applied BEFORE the shift: (y & 0xff3a58ad) << 7 == (y << 7) & 0x9d2c5680, verified over 200k words. `mars::rng` in sots-engine was NEVER WRONG. Corrected in place in Ghidra and in the fragment. NOTE FOR FUTURE SCANS: a scan for the TEXTBOOK constants finds NOTHING in this image, which is exactly why rule 16's scan must use these pre-shift values |

View file

@ -276,18 +276,8 @@ will still diverge on the first turn a node line expires or a node cannon fires.
Downstream of a successful roll (`FUN_007a92e0` 690 B then `FUN_007a4700` 2244 B) fleets are destroyed or
halted and three events are posted: `EVENT_NODEDECAY_FLEET_DESTROYED_VIANODE`, `EVENT_NODEDECAY_FLEET_HALTED`,
`EVENT_NODEDECAY_FLEET_HALTED_VIANODE`. ~~The roll is skipped for a line if any fleet with flag `0x20000` is
targeting it.~~
> **CORRECTED by lane Z, 2026-09-08 — the fleet check does not gate the roll.** The `0x20000`-fleet scan
> begins at 0x007ae0b2, which is 0x1d bytes **after** the `Chance(0.5f)` call at 0x007ae0a5 and is reached
> only when the roll *succeeded* (`test al,al; je 0x007ae1e2` at 0x007ae0aa). The straight-line order in the
> loop is **expiry test → draw → roll gate → fleet gate → collect**, so the fleet scan suppresses only the
> collapse (`FUN_007a92e0` / `FUN_007a4700`), never the draw. The headline claim above — one `NextFloat` per
> expired node line per turn — is unaffected, and the expiry test is now a read formula:
> `NodePath::RemainingLife` 0x006e2130 returns `npdtn − nptf/npdtf − (turn − npctm)` clamped at 0, with two
> never-expire early-outs (`npt == 0`, `npdtn == INT_MAX`). See `findings/control-flow/tail-rng-ledger.md`
> §6 and §6.1.
`EVENT_NODEDECAY_FLEET_HALTED_VIANODE`. The roll is skipped for a line if any fleet with flag `0x20000` is
targeting it.
*Source: the `Chance(0.5f)` call site and its operand are instruction-verified; the surrounding record layout
and the downstream event names are from a ReVa sweep.*
@ -509,15 +499,8 @@ tail returns. The handler, read from the instruction stream (0x00784cf8..0x00784
### 6.1 `StrategyHost::Autosave` 0x00895210
`std::string* __thiscall (StrategyHost* this /* the global at 0x00b29f98 */, std::string* outName,
bool endTurn)`, **`ret 8`**. 2364 B. Two call sites in the whole image:
> **CORRECTED by lane Z, 2026-09-08 — the return type.** The epilogue is `c2 08 00` and 0x00895b5c is
> `mov eax,esi`, which puts `outName` back in EAX: this is MSVC's named-return-value slot, not a `void`.
> A hook or reimplementation declaring it `void` drops EAX at both call sites. Neither site passes `this`
> as an argument — both hardcode `mov ecx,0xb29f98`. Lane Z also observed live that `[global+0x54]` is
> **not** the `StrategyServer`, so the global this function runs on is a different object from the
> `StrategyHost` whose `+0x54` `OnMessage` reads.
`void __thiscall (StrategyHost* this /* the global at 0x00b29f98 */, std::string* outName, bool endTurn)`.
2364 B. Two call sites in the whole image:
| caller | `endTurn` | file |
|---|---|---|
@ -596,19 +579,6 @@ this handler is the **only** reachable caller of `StrategyHost::Autosave(…, 0)
but it is an inference from the oracle, not from the instruction stream. **A path you cannot exercise is a
hypothesis** — this one is exercisable by lane O and worth one check.
> **CHECKED by lane Z, 2026-09-08 — half settled, and the other half narrowed.** A live hook on
> `OnAllCombatDone_Tail` recorded **exactly one call per End Turn on 8 of 8 End Turns**, at depth 0, between
> the two autosaves, with the post-turn autosave following it. So the handler **is** delivered every turn
> and the inference above was right. What is *not* settled is the stronger reading: on both saves played,
> the encounter vector is empty at `ProcessTurn` entry and holds **exactly one** encounter by the time the
> tail runs (detection creates it, phase 7 clears it), so what was observed is "the tail runs on a turn with
> **no battle**", not "on a turn with no encounter at all". Every encounter seen had `res->+0x4 != 0`, the
> flag that makes `ApplyEncounterResult` a whole-function no-op — measured cost 0 RNG words, exactly as the
> gate predicts. `findings/control-flow/tail-rng-ledger.md` §4.
>
> The same records confirm §2 behaviourally: `encounters` reads 1 at the phase-6 call and 0 at the phase-11
> call, on every turn. Phase 7 really is a wholesale `clear()`.
---
## 7. Corrections
@ -623,18 +593,6 @@ same word. Given §6's inference that the message is delivered every turn, `S+0x
per turn while `S+0xc` (`ModCount`) advances once. They are not in lockstep, and a reimplementation must not
treat `S+0x8` as a turn number. It reads as a **server-phase / driver-invocation counter**.
> **NAMED and MEASURED by lane Z, 2026-09-08 — the word is `ModCount`, and it moves 12 to 44 times a
> turn.** `StrategyServer::Write` tags it itself: `0x0079fb2f lea edx,[edi+0x08]; push "ModCount"` and
> `0x0079fb40 lea eax,[edi+0x0c]; push "Frame"`, with `edi = S`. So **`S+0x8` is `ModCount`** — the word
> this section says "has never been named" — **and `S+0xc` is `Frame`**, the turn. (`addresses.json` has
> `StrategyServer_off_ModCount` on the wrong word; the name belongs to lane T's
> `StrategyServer_off_PhaseCounter`.) Measured live over eight turns on two saves, `S+0x8` advances **12–14
> times per turn** on an early two-colony game and **16–44** on a turn-19 Zuul one, of which the two drivers
> account for 2 — and the saves agree independently (`ModCount` 0 → 12 → 24 across turn1/2/3-state, 241 → 412
> across Zuul turns 16 → 23). A modification counter is exactly what that looks like, so "which writer bumps
> the rest" has no single answer. `S+0x8` is not a turn number, not a driver-invocation counter and not a
> constant per turn. `findings/control-flow/tail-rng-ledger.md` §5.
### 7.2 Turn results and turn events are not where lane T said
`turn-driver.md` §5 says: *"no bankruptcy, no turn-results build, no turn-events build, no autosave. Those

View file

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

@ -1,412 +0,0 @@
# The RNG ledger for one strategic turn — measured, not inferred
Lane Z, 2026-09-08. Program `sots` / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs.
Engine worktree `wip/tailrng`, `sots-engine/docs/Z-tail-rng.md` (the prediction, committed before the build).
**The question.** The milestone is *a standalone that loads a save, runs one strategic turn, and writes an
autosave that byte-matches the original's*. Generator state is part of the saved state. Lane K
(`combat-done-tail.md` §3) found two draw sites in `StrategyServer::OnAllCombatDone_Tail` that nothing models
and that both run **before** the autosave, and concluded that a reimplementation reproducing both
`ProcessTurn` functions exactly would still diverge. Nobody had measured what a turn actually costs.
**The answer, up front.** Across eight measured End Turns on two saves, a strategic turn advances the
strategic generator by **18–22 words**, *all* of it inside `StrategyServer::ProcessTurn`, and the residual
outside the two turn drivers is **exactly zero**. The tail's cost on these turns is **0**. The defect lane K found is real and
**latent**: it will bite the first turn a node line expires or a real battle resolves, and our saves reach
neither.
---
## 0. Where to start if you are building the standalone
Three sentences, then the evidence.
1. A turn costs **18–22 generator words**, all of it inside
`StrategyServer::ProcessTurn`; the two autosave files bracket exactly that interval and nothing draws
between them outside the two turn drivers.
2. `verify/results/shim/tailrng/z-t6-endturn.sav` → `z-t6-autosave.sav` is a **byte-identical oracle pair
with a known RNG cost of 18 words**, verified from the file bytes independently of the live hook. Test
against that pair before any other.
3. Lane K's warning stands and is now quantified: the tail's cost is 0 **today** because no save is within
~40 turns of a node-line expiry (§9) and no encounter has ever produced a battle (§8). Both terms are
real; both are latent.
## 1. The instrument, and why it is not an RNG hook
Counting draws by hooking the primitives would have undercounted, and the campaign now knows by exactly how
much. This lane's scan of the tempering immediates found **four** draw entry points where every prior lane's
primitive set had three, and twelve functions with inlined draws. Lane J then found fourteen inlined-draw
functions; lane I re-ran the scan at real instruction boundaries and audited it site by site, and its
numbers are the ones to use: **seven entry points** and **eleven game functions with inlined draws over 28
sites**, with a brute byte scan finding zero orphans (`findings/control-flow/inlined-draws.md`).
So the honest sequence is: three → four (this lane) → seven (lane I). The fourth,
`Mars::RNG::NextUInt` 0x004f7670, is the one this lane's static work surfaced; the fifth, sixth and seventh
— including `Mars_RNG_GaussianRange` 0x008e6e30, the only **unbounded** entry point at two words per
attempt — are lane I's. Exactly **one** of the eleven inlined-draw functions is reachable from
`StrategyServer::ProcessTurn` (0x007aa240, depth 4) and one more from the tail.
**None of that changes a single number in this document**, and that is the point worth taking away. The
instrument was never built from the primitive set or from the call graph: it reads the generator's **state**
before and after a boundary and reports the difference. An inlined draw, a draw through an entry point
nobody had named, a draw through a vtable — all of them move `left`, and all of them are counted. That is why method rule 16 (*any RNG accounting built from the call graph alone is a
lower bound*) does not apply to it, and why three revisions of the primitive inventory landed underneath
this measurement without disturbing it.
The state it reads: `Mars::RNG` is
`{void* vptr; uint32 mt[624]; uint32* next; int32 left}`, `sizeof 0x9cc` — `next` at +0x9c4 is a **pointer**
into the block and `left` at +0x9c8 is the counter, and the inner primitives are entered with
`ECX = &mt[0] = RNG+4` while the outer helpers take the object base and do the `add ecx,4` themselves —
which is what lane T's "the generator is entered at `rng+4`" note was seeing. The
draw is `if (left == 0) Twist(); y = *next++; --left;` — a pre-check against **0**, never −1.
The block transform is a pure function, so the blocks a generator visits form a forward-only chain.
`RngLedger` (`sots-engine/src/shim/hooks/rng_ledger.{h,cpp}`) indexes that chain from the first block it
sees and positions any state exactly:
```
words(block, left) = block * 624 + (624 - left)
```
Differences between positions are then exact **across twists, across rejection loops, and across draws
nobody hooked**. Nine host tests pin the arithmetic, including the block boundary (`left == 0` is a real
state and must not be off by 624), a rejection loop counted against a shadow generator, and the
out-of-order case below.
**One ordering subtlety, and it is load-bearing.** `Hook<>` takes the `before` snapshot at entry but renders
it (calls the region's `describe`) only *after* the original returns — so for nested calls the inner
snapshots are rendered first, and by the time an outer `before` is rendered the chain has moved past it.
Walking a Mersenne Twister backwards is not possible. Every hook therefore **observes at entry from
`describe_args`**, which indexes the entry block while it is still current; the render then resolves it from
the memo. Without that, every outer-call `before` would read `words: null`.
Six nested trace hooks, all watching the object at `S+0x16c`:
`StrategyHost::Autosave` (both markers) › `StrategyServer::ProcessTurn` › `OnAllCombatDone_Tail` ›
`ApplyEncounterResult` (phase 6) › node-line decay (phase 11) › `ProcessNodeSpaceTravel` (runs twice a turn).
---
## 2. The ledger
Save `ref-turn2` (2-player Morrigi vs AI), four consecutive End Turns, build `z-tailrng-20260908T1314Z`,
`hooks=trace`. Words are 32-bit MT outputs consumed by the generator at `S+0x16c`.
| turn | `ProcessTurn` | tail | `ApplyEncounterResult` | node-line decay | `ProcessNodeSpaceTravel` ×2 | **bracket total** | **residual** |
|---|---|---|---|---|---|---|---|
| 3 | **19** | 0 | 0 | 0 | 0, 0 | (incomplete — see below) | — |
| 4 | **18** | 0 | 0 | 0 | 0, 0 | **18** | **0** |
| 5 | **20** | 0 | 0 | 0 | 0, 0 | **20** | **0** |
| 6 | **18** | 0 | 0 | 0 | 0, 0 | **18** | **0** |
"Bracket" is `Autosave(endTurn=1)` → `Autosave(endTurn=0)`: the pre-turn save file to the post-turn save
file, which is exactly the interval a standalone has to reproduce. The turn-3 bracket is incomplete **by
construction** and is reported rather than dropped: the pre-turn autosave of the first End Turn after a load
runs before any turn driver, so the hook has no server pointer yet and its record carries `words: null`.
A second save, `zuul-turn16-noderoute` (Zuul vs Zuul), build `z-tailrng2-20260908T1328Z`:
| turn | `ProcessTurn` | tail | `ApplyEncounterResult` | node-line decay | `ProcessNodeSpaceTravel` ×2 | **bracket total** | **residual** |
|---|---|---|---|---|---|---|---|
| 17 | **20** | 0 | 0 | 0 | 0, 0 | (incomplete) | — |
| 18 | **20** | 0 | 0 | 0 | 0, 0 | **20** | **0** |
| 19 | **22** | 0 | 0 | 0 | 0, 0 | **22** | **0** |
| 20 | **20** | 0 | 0 | 0 | 0, 0 | **20** | **0** |
**So: we consume 18–22 words per turn and model 0 of them.**
Not 0 because the modelling is bad — because *nothing in the repo models any part of a turn's RNG
consumption as a count*. B1/B3/B4 compare the generator's state around three specific functions and get it
right; no lane has ever stated a turn's total. This table is that statement.
### 2.1 The generator does not move outside the turn pipeline
Every turn's `ProcessTurn` entry position equals the previous turn's post-turn autosave position, exactly:
211 → 211, 229 → 229, 249 → 249. The UI, the renderer and the per-frame tick draw **nothing** from the
strategic generator between turns. For the standalone this is worth as much as the total: the interval it
must reproduce is closed.
---
## 3. An independent check, from the files rather than from memory
The two autosaves of the turn-6 bracket were pulled off the VM and their `Sim.RNG` blobs parsed by
`verify/save-reader/save_reader.py` (the frame's payload is 2503 bytes: `mt[624]`, then `left` as int32 at
+2496). Twisting the pre-turn block forward until it matches the post-turn block, and applying the same
position formula:
```
z-t6-endturn.sav (pre-turn) left = 375
z-t6-autosave.sav (post-turn) left = 357
twists = 0 -> words consumed between the two files = 18
```
The live ledger recorded 18 for that turn, `left` 375 → 357. **Two instruments that share no code path — one
reading process memory through a hook, one reading gzip-compressed file bytes through the save reader —
agree exactly.** Files and the checker in `verify/results/shim/tailrng/`.
**And the two do not share a hidden assumption** (the trap of method rule 8, which is live here because both
sides know how to twist an MT block). `twists = 0`: the block is byte-identical in the two files, so the
file-side number is `left_before − left_after` and involves the twist implementation **not at all**. The
agreement is therefore about the game's behaviour, not about two copies of the same algorithm agreeing with
each other.
This is the pair a standalone should be tested against first: it is a byte-identical oracle *with a known
RNG cost attached*, which none of the eleven corpus saves has.
---
## 4. Lane K's inference, settled
> **§6, labelled hypothesis:** "I did not prove that `SNMAllCombatDone` is delivered on turns with no
> combat."
**The handler runs on every End Turn.** Eight out of eight, across two unrelated saves,
`OnAllCombatDone_Tail` recorded exactly one call per End Turn, at depth 0, between the two autosaves, with
the post-turn autosave following it. The determinism-note inference was right.
The stronger claim — that it runs with an *empty encounter vector* — is **not** settled by this workload and
must not be reported as though it were. On **both** saves the encounter vector is **empty at `ProcessTurn`
entry and holds exactly one encounter by the time the tail runs**, on every one of the eight turns:
detection (`ProcessTurn` phase 31) creates it, and the tail's phase 7 clears it. So what is proved is "the
tail runs on a turn with **no battle**", not "on a turn with no encounter at all". See §8 for what closing
the remaining gap needs.
Two things fall out of the same records and are worth more than the phrasing:
* **Phase 7 really is a wholesale `clear()`.** `encounters` reads **1** at the phase-6
`ApplyEncounterResult` call and **0** at the phase-11 node-line-decay call, on every turn. Lane K read
that off the instruction stream against a decompile that reads as a conditional prune; it is now also a
behavioural fact.
* **Every encounter on these turns has `res->+0x4 != 0`** (`res_no_battle = 1`), the flag that makes
`ApplyEncounterResult` a whole-function no-op. Its measured cost is 0 words, which is what that gate
predicts, and which is why the combat resolver has never run under any instrument this campaign has built.
---
## 5. `S+0x8` has a name, and it is `ModCount` — correcting `combat-done-tail.md` §7.1, lane T §0.1, `addresses.json`, and this lane's own prediction
Lane K wrote that `S+0x8` "advances **at least twice** per turn" and that "the word at `S+0x8` has never been
named" (lane T §0.1). The first is right and this lane's prediction that it advances **exactly** twice is
**wrong**. The second is now answered — by `StrategyServer::Write`'s own wire tags:
```
0079fb2f lea edx,[edi+0x08] ; push "ModCount" ; edi = S -- the same edi that indexes
0079fb40 lea eax,[edi+0x0c] ; push "Frame" ; the players vector at +0x54
```
So **`S+0x8` is `ModCount` and `S+0xc` is `Frame`**, the turn number. `ghidra/addresses.json` has the name on
the wrong word: its `StrategyServer_off_ModCount = 0x8` is the stored-frame offset of `S+0xc`, which the wire
calls `Frame` — `turn-spine.md` was right to call it that and lane T flagged the clash without being able to
settle it. The name `ModCount` belongs to the word lane T recorded as `StrategyServer_off_PhaseCounter = 0x4`.
Confirmed three ways, and the third is the satisfying one. **From the saves:**
| save | `Frame` | `ModCount` |
|---|---|---|
| turn1-state / turn2-state / turn3-state | 1 / 2 / 3 | 0 / 12 / 24 |
| z-t6-endturn / z-t6-autosave (this lane's bracket) | 5 / 6 | 50 / 62 |
| zuul-turn16-noderoute / zuul-turn23-fleet23 | 16 / 23 | 241 / 412 |
`Frame` is the turn; `ModCount` moves **+12 per turn** on the early Human game and averages **+24** on the
Zuul one. **From the live trace**, `S+0x8` at hook entry:
| save | turn | `ProcessTurn` entry | tail entry | tail's callees | increments to the next turn |
|---|---|---|---|---|---|
| ref-turn2 | 3 | 22 | 23 | 24 | **12** |
| ref-turn2 | 4 | 34 | 35 | 36 | **14** |
| ref-turn2 | 5 | 48 | 49 | 50 | **12** |
| ref-turn2 | 6 | 60 | 61 | 62 | — |
| zuul-noderoute | 17 | 253 | 254 | 255 | **16** |
| zuul-noderoute | 18 | 269 | 270 | 271 | **21** |
| zuul-noderoute | 19 | 290 | 291 | 292 | **44** |
| zuul-noderoute | 20 | 334 | 335 | 336 | — |
The live deltas (12, 14, 12 on the Human game; 16, 21, 44 on the Zuul one) sit exactly where the saves'
`ModCount` deltas say they should. The two turn drivers account for **2 of 12 to 44** increments; the rest
are spread across the turn and mostly fall between the post-turn autosave and the next `ProcessTurn`.
That is no longer a mystery to be chased — **it is what a modification counter is for**. `S+0x8` is not a
turn number, not a driver-invocation counter and not a constant per turn: it counts state mutations, so it
scales with the size of the empire, and asking "which writer is responsible" has no single answer. Lane K's
operational conclusion stands and is now explained rather than merely observed. `S+0xc` (`Frame`) reads 3, 4,
5, 6 and 17, 18, 19, 20 over the same records and is the turn counter.
**For the integrator:** this is a name collision to reconcile, not a new entry. `StrategyServer_off_ModCount`
(0x8, stored frame) and `StrategyServer_off_PhaseCounter` (0x4, stored frame) are the two words above with
their names swapped; `ghidra/addresses.d/lane-z.json` records the evidence under
`StrategyServer_wire_ModCount_vs_Frame` rather than adding a third name for either word.
## 6. Corrections to `combat-done-tail.md` §3 and §6.1
Both from the instruction stream, both load-bearing for anyone reimplementing these functions.
**§3 — the node-line fleet check does not gate the roll.** Lane K: *"The roll is skipped for a line if any
fleet with flag `0x20000` is targeting it."* The straight-line order in node-line decay's first loop is
```
0x007ae088 call NodePath::RemainingLife ; expiry test
0x007ae08f jg 0x007ae1e2 ; not expired -> next record, NO DRAW
0x007ae095 mov ecx,[esi+0x16c] ; THE DRAW
0x007ae0a5 call 0x008e6dd0 ; Mars::RNG::Chance(0.5f)
0x007ae0aa test al,al ; je 0x007ae1e2 ; roll failed -> next record
0x007ae0b2 ... ; THE 0x20000-FLEET SCAN STARTS HERE
```
The scan begins 0x1d bytes **after** the `Chance` call and is reached only when the roll *succeeded*. It
suppresses the collapse (`0x007a92e0` / `0x007a4700`), never the draw. Lane K's headline — one `NextFloat`
per expired node line per turn — survives intact and is now pinned to a formula.
**§6.1 — `StrategyHost::Autosave` is `ret 8` and returns a value.** Its epilogue is `c2 08 00`, and
`0x00895b5c mov eax,esi` puts the `std::string*` (the MSVC named-return slot) in EAX. A hook declaring it
`void` drops EAX at both call sites. Neither call site passes `this`: both hardcode `mov ecx,0xb29f98`.
(The `+0x54` candidate on that global was recorded live and is **not** the `StrategyServer` — `server_agrees`
is false on all eight autosave records, so the global that the autosave uses is a different object from the
`StrategyHost` whose `+0x54` `OnMessage` reads.)
### 6.1 The expiry predicate, now concrete
`NodePath::RemainingLife` 0x006e2130, `__thiscall(NodePath*, int turn)`, `ret 4`, whole 122-byte body read:
```c
if (npt(+0x04) == 0) return INT_MAX; // permanent line, never expires
if (npdtn(+0x1c) == INT_MAX) return INT_MAX; // immortal line
aged = (npctm(+0x14) >= 0 && turn >= npctm) ? turn - npctm : 0;
wear = (npdtf(+0x20) != INT_MAX && npdtf > 0) ? nptf(+0x24) / npdtf : 0; // SIGNED idiv
rem = npdtn - wear - aged;
return rem > 0 ? rem : 0; // callee-side clamp
```
A line is expired exactly when this returns 0. **The lifetime is derived, never ticked** — the function
writes nothing, and neither does the loop around it — so there is no decrement-ordering question and a
snapshot at function entry is a valid prediction basis. `nptf` is never sign-checked, so the division's
signed truncation must be reproduced literally.
`Chance` 0x008e6dd0 returns **false with no draw** when `p <= 0` and **true with no draw** when `p >= 1`;
`0.5f` takes neither, so it is exactly one word, and the comparison is `p > r` (equality returns false).
A NaN `p` falls through both early-outs and *does* draw — irrelevant here, noted because it is the kind of
edge a reimplementation gets wrong.
---
## 7. One generator, confirmed twice
Static: the image has one persistent strategic `Mars::RNG`, at `S+0x16c`, constructed by the
`StrategyServer` ctor 0x007d78d0 (`push 0x9cc` + `Seed(0)` at 0x007d7d25/0x007d7d3d) and reseeded only from
`Read` and `LoadGame`. `StrategyClient+0x134` and `Mars::CombatSim+0x108` exist but are unreachable from the
turn roots; three more are stack temporaries in map generation. **The combat resolver draws from the same
`S+0x16c` object** — all three RNG entry points in its 750-node direct-call closure load `[reg+0x16c]`.
Behavioural: across **64 ledger observations over eight turns on two saves**, every state resolved on a
single forward chain — no `words: null`, no second chain. If a second generator had been in play, the ledger
would have said so by construction rather than by anyone noticing.
---
## 8. What is not settled, listed as loudly as the results
* **The combat resolver has still never run under an instrument.** Every encounter this workload produced
had the no-battle flag set, so `ApplyEncounterResult` was a no-op every time; its measured 0 words says
nothing whatever about combat's RNG cost, and the residual-0 result above holds only for turns with no
battle.
Lane J read all 7,641 bytes of it in parallel with this run (`combat-resolver.md`; and note **7,641**, not
the 7,499 Ghidra reports — method rule 17). Its conclusion pairs with this one exactly: **the resolver has
no unconditional draw.** All three sites in its subtree are conditional — a node-cannon `NextInt`, an
inlined `NextFloat` per back-engineering candidate, and a `NextInt` per successful roll of that. So lane
J's cheap first prediction is directly testable with this instrument: **a plain fleet battle with no node
cannon and no salvage should cost the same 18–22 words as a peaceful turn.** That is the next run this
hook family should do, and it needs a workload nobody has built yet: a save where two hostile fleets
actually meet.
* **Node-line expiry did not fire.** See §9 for the quantified distance rather than an absence.
* **A turn with a genuinely empty encounter vector was not observed** (§4). Both saves produce exactly one
sighting encounter on every turn. The tail-runs-every-turn claim is settled; the no-encounters variant is
still an inference, now a much narrower one.
* ~~`players` reads 8 on a 2-player save, and may be the `S+0x64` bug again.~~ **Resolved, and the flag was
my own error.** The offset is right and so is the count. `StrategyServer`'s base-class ctor 0x0085b120
(entered with `ecx = S+4`) zero-initialises four consecutive vectors as three-word triples with the fourth
word skipped — `+0x40/+0x50/+0x60/+0x70` raw, 0x10 apart, allocator-last — which enumerates the players
triple as literally `{S+0x54, S+0x58, S+0x5c}` with no frame arithmetic at all, and puts the fleets vector
at `S+0x64` exactly where B4 measured it. Five NPC accessors at 0x00788de0ff bounds-check an index against
`([S+0x58] − [S+0x54]) >> 2` and then index `_Myfirst`, which is a third confirmation.
**The vector is not the lobby's player list.** It is `#empires + one rebel-AI per distinct empire species
+ 4 NPC pseudo-players` (Alien Menace, Peacekeeper Enforcer, Von Neumann, Independent Colony — all species
4). `Sim.NumPlrs` reads **8** in the Human saves (two species) and **7** in every Zuul save (one species),
against a `Summary.Players` array with **2** entries in both. Both numbers are right; they count different
things.
**And my draft of this document was wrong about my own data.** It said the hook read 8 "on both saves". It
did not: the trace reads 8 on `ref-turn2` and **7** on `zuul-turn16-noderoute`, matching each save's
`NumPlrs` exactly. I generalised from one run without re-reading the other, and it took a check aimed at
something else to catch it.
* **Which of the 12-to-44 `S+0x8` increments per turn come from where** (§5), and what they scale with.
* The direct-call sweeps behind "node-line decay's only RNG site is the `Chance(0.5f)`", "its downstream
pair draws nothing" and "`ProcessNodeSpaceTravel` draws nothing" are **worth less than they look**, and
method rule 16 (landed by lane J while this run was in flight) says why: an inlined draw leaves no
call-graph edge at all, so a call sweep is a lower bound. Rule 17 applies too — those sweeps clipped at
Ghidra's reported function sizes.
**The behavioural measurement is what carries these claims, not the sweeps.** `ProcessNodeSpaceTravel`
moved the generator by 0 words on **16** observations (twice per turn, eight turns) and node-line decay by
0 on **8**. That evidence is immune to both rules, because it does not ask which function drew — it asks
whether the generator moved.
* **No Guard region is declared by any hook in this family, so nothing here can report an undeclared
write.** That is deliberate — these hooks make no claim about game state at all, and a guard over the
generator would only duplicate the Result region that already covers the whole object — but it means the
usual harness-audit safety net is absent by design. Both traces show `err = 0`, `undeclared = 0` on 32
records each; the second number is vacuous and should be read that way.
* **The one record with no ledger position is the first pre-turn autosave of each session**, which runs
before any turn driver and therefore before the hooks know the server pointer. It declares no region at
all rather than declaring one it cannot fill. Every record that *did* declare the region resolved: 0
`words: null` across 64 records.
* **The ledger's block-chain machinery has never run live.** Every observation in both runs sat inside a
single MT block — `left` walked 432 → 413 → 395 → 375 → 357 on `ref-turn2` and 263 → 243 → 223 → 201 → 181
on the Zuul save, never reaching 0. So every live word count reduces to `left_before − left_after`, and the
twist-and-index path that makes the instrument correct across block boundaries is exercised **only by the
host tests**. A turn that crosses a boundary (any turn spending more words than `left`) is the first real
test of it. This is the thinnest part of the instrument and the one to watch.
* Everything here is one game state per save, two saves, eight turns, with **158 words** of generator
movement in total (192 → 267 and 361 → 443). It is a thin workload measured precisely, not a broad one.
In particular the per-turn total moved only between 18 and 22 across eight turns: the *variation* is barely
sampled, and nothing here says what makes it 18 rather than 22.
## 9. Node-line expiry — a distance, not an absence
Lane O's `zuul-turn16-noderoute.sav` was pushed to the VM and played forward. Phase 11 draws one word per
**expired** node line; rather than report "we ran N turns and it never fired", the hook was extended to
classify the whole `NodePath` population at entry, using the same `RemainingLife` formula the original
tests. The classification is what makes the negative result usable:
| turn | node paths | permanent (`npt == 0`) | immortal (`npdtn == INT_MAX`) | **mortal** | min remaining life | ≤ 5 | expired → words |
|---|---|---|---|---|---|---|---|
| 17 | 53 | 51 | 0 | **2** | 40 | 0 | 0 |
| 18 | 54 | 51 | 0 | **3** | 42 | 0 | 0 |
| 19 | 56 | 51 | 0 | **5** | 41 | 0 | 0 |
| 20 | 57 | 51 | 0 | **6** | 43 | 0 | 0 |
Three things follow, none of which was knowable before:
1. **51 of the 53 node lines on this map can never expire** — `npt == 0` takes `RemainingLife`'s first
early-out. The static map's node network is not a decay candidate at all. Only *dug* lines are, which is
why this is a Zuul save: the mortal count rises by roughly one per turn as the Zuul dig.
2. **Every mortal line is ~40 turns from expiry**, and the population's minimum stays in a 40–43 band while
new lines are added at full life. So the first phase-11 draw on this save is **tens of turns away**, not
one or two — and it is reachable, which "we saw nothing" would not have told anyone.
3. It explains why the campaign never noticed: no save in the corpus is within 40 turns of a decay event,
and the ones that could get there are the newest saves in it.
**Phase 11's draw therefore remains a path no save exercises — a hypothesis, and labelled one.** What *is*
now instruction-verified is the predicate that decides it (§6.1), and what is measured is the distance.
### 9.1 The model is stated but not yet tested
`predict_words` is computed at hook entry, before the original runs, and recorded on every node-line-decay
record. It read **0** on every call and the measured delta was **0** on every call. That agreement is worth
exactly nothing as a test of the model — it is the "0 diverged while comparing nothing" shape this campaign
has already paid for — and it is reported that way rather than as a green tick.
Compare mode was **not** run on this hook for the same reason: with a prediction of 0 and a measurement of 0,
`ours` would advance the scratch generator by nothing, diff clean, and prove only that the harness works.
The model becomes checkable the first time a line expires, and the descriptor is ready for that day.

View file

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

View file

@ -1,116 +0,0 @@
{
"entries": [
{
"name": "StrategyServer_NodeLineDecay",
"addr": "0x007ae010",
"convention": "thiscall",
"prototype": "void (StrategyServer* this /*base S*/) // phase 11 of OnAllCombatDone_Tail, called at 0x007d9714 as `mov ecx,esi; call`. 1117 B, three loops. LOOP 1 (0x007ae07a..0x007ae1e2) walks the 0x30-stride Game::NodePath records in the vector at (*(S+0x154))+0x8/+0xc, re-reading _Myfirst/_Mylast every iteration, and per record: (1) NodePath::RemainingLife(r, S->Frame) 0x006e2130, `test eax,eax; jg` -> not expired, NEXT RECORD, NO DRAW; (2) THE DRAW, `mov ecx,[esi+0x16c]; fld [0x009e2ea0] /*0.5f*/; call 0x008e6dd0` = Mars::RNG::Chance(0.5f), exactly one MT word; (3) `test al,al; je` -> roll failed, next record; (4) the 0x20000-fleet scan over S->Fleets (S+0x64/+0x68) calling 0x00703500(fleet,0x20000,0) then 0x0078c360(fleet,npid) and dropping the record when that returns 3; (5) push_back npid into a scratch vector<int>. LOOP 2 collapses each collected line via 0x007a92e0(690 B) then 0x007a4700(2244 B); LOOP 3 posts the decay-stage events through NodePath::DecayStage 0x006e21b0. THE ONLY RNG SITE IN THE WHOLE 1117 BYTES: direct-call sweep to depth 5 over 140 functions from 0x007ae010 against {NextFloat 0x0047d830, NextInt 0x004271c0, Chance 0x008e6dd0, Twist 0x00426e00, Seed 0x0049fdf0} yields exactly one hit, 0x007ae010 -> 0x008e6dd0. Neither downstream function draws (138 and 49 functions reached, zero hits) -- caveat: direct calls only, their subtrees contain unresolved indirect sites",
"status": "verified",
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08, loop 1 read from the instruction stream)"
},
{
"name": "StrategyServer_NodeLineDecay_FleetSkipIsPostDraw",
"addr": "0x007ae0b2",
"convention": "site",
"prototype": "site, and a CORRECTION to findings/control-flow/combat-done-tail.md \u00a73, which says \"the roll is skipped for a line if any fleet with flag 0x20000 is targeting it\". IT IS NOT: the fleet scan begins HERE, at 0x007ae0b2, which is 0x1d bytes AFTER the Chance(0.5f) call at 0x007ae0a5 and is reached only when the roll SUCCEEDED (`test al,al; je 0x007ae1e2` at 0x007ae0aa). The scan therefore cannot change the draw count -- it suppresses only the collapse (the 0x007a92e0 / 0x007a4700 pair), never the draw. The straight-line order in loop 1 is: expiry test -> DRAW -> roll gate -> fleet gate -> collect. Lane K's headline claim, one NextFloat per expired node line per turn, survives intact and is now pinned to a concrete expiry formula (NodePath_RemainingLife)",
"status": "verified",
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08)"
},
{
"name": "NodePath_RemainingLife",
"addr": "0x006e2130",
"convention": "thiscall",
"prototype": "int (NodePath* this, int turn) // RET 4, whole 122-byte body read as instructions. THE EXPIRY PREDICATE for node-line decay. `if (npt(+0x4) == 0) return INT_MAX; if (npdtn(+0x1c) == INT_MAX) return INT_MAX; aged = (npctm(+0x14) >= 0 && turn >= npctm) ? turn - npctm : 0; wear = (npdtf(+0x20) != INT_MAX && npdtf > 0) ? nptf(+0x24) / npdtf : 0; /* SIGNED idiv, nptf is never sign-checked */ rem = npdtn - wear - aged; return rem > 0 ? rem : 0;`. Writes nothing -- the lifetime is DERIVED from a creation stamp and a traffic accumulator, never ticked, so there is no decrement-ordering question. Two never-expire escape hatches (npt == 0, npdtn == INT_MAX). A line is expired exactly when this returns 0",
"status": "verified",
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08, whole body from the instruction stream)"
},
{
"name": "NodePath_DecayStage",
"addr": "0x006e21b0",
"convention": "thiscall",
"prototype": "int (NodePath* this, int turn) // 52 B. Wraps NodePath::RemainingLife and buckets it: <=2 -> 0, <=5 -> 1, <=10 -> 2, else 3. Called TWICE per record by loop 3 of node-line decay (once with turn-1, once with turn) to detect a stage transition and post the two decay-stage events. Draw-free -- but note that instrumenting node-line decay by counting RemainingLife CALLS rather than Chance calls over-counts badly because of this wrapper",
"status": "verified",
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08)"
},
{
"name": "StrategyServer_ProcessNodeSpaceTravel",
"addr": "0x007a0e20",
"convention": "thiscall",
"prototype": "void (StrategyServer* this /*base S*/) // 2945 B. Called TWICE per turn: StrategyServer::ProcessTurn phase 7 (0x007dc93a) and OnAllCombatDone_Tail phase 10 (0x007d970b). Body unread; hooked by lane Z only to measure whether it advances the strategic generator, because a draw inside it would be double-counted by anyone who modelled it as running once",
"status": "mapped",
"source": "findings/control-flow/turn-driver.md phase 7 + combat-done-tail.md phase 10; call shapes instruction-verified by those lanes"
},
{
"name": "StrategyServer_off_NodeGraph",
"offset": "0x00000150",
"convention": "offset",
"prototype": "Game::ServerNodeGraph* -- the node-line graph. Stored frame (S+4), so it is S+0x154 in the frame OnAllCombatDone_Tail and ProcessTurn receive; node-line decay reads it as `mov eax,[esi+0x154]` at 0x007ae03a with esi = S. Dereferenced without a null check by the original",
"status": "verified",
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08)"
},
{
"name": "ServerNodeGraph_off_Paths",
"offset": "0x00000008",
"convention": "offset",
"prototype": "std::vector<Game::NodePath> (_Myfirst @+0x8, _Mylast @+0xc). Element stride 0x30, confirmed four ways: the reciprocal 0x2aaaaaab / sar 3 at four sites in node-line decay, `add [ebp-0x14],0x30` in its loop 1, `add [ebp-0x18],0x30` in its loop 3, and `add eax,0x30` in ServerNodeGraph::FindPathById 0x006e23d0. Loop 1 of node-line decay re-reads both words every iteration but writes neither, so an entry snapshot is a valid prediction basis",
"status": "verified",
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08)"
},
{
"name": "ServerNodeGraph_FindPathById",
"addr": "0x006e23d0",
"convention": "thiscall",
"prototype": "NodePath* (ServerNodeGraph* this, int npid) // 59 B, linear scan of the 0x30-stride paths vector comparing npid(+0x8). Node-line decay collects npid HANDLES rather than record pointers in loop 1 and re-resolves them here in loop 2, which is how the original hedges against the collapse functions mutating the vector under it",
"status": "verified",
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08)"
},
{
"name": "StarFleet_HasFlagShips",
"addr": "0x00703500",
"convention": "thiscall",
"prototype": "bool (StarFleet* this, uint maskA, uint maskB) // RET 8, a 29-byte thunk onto 0x00702d70(this, maskA, maskB, out = 0). Returns count > 0 where count is the number of ships in the fleet's NShips vector (+0xa4/+0xa8) passing the two-mask ship-flag predicate 0x00814da0, itself gated on (fleet->+0xb8 & maskA) == maskA. Called from node-line decay's post-draw fleet scan with maskA = 0x20000",
"status": "verified",
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08)"
},
{
"name": "StarFleet_PathRelation",
"addr": "0x0078c360",
"convention": "cdecl",
"prototype": "int (StarFleet* fleet, int npid) // 234 B. Returns 3 exactly when the fleet's FRONT waypoint (the deque at fleet+0xc4, element +0x10) names this npid -- i.e. the fleet is currently riding this line; 0/1/2/4 otherwise. Node-line decay drops a rolled line when any 0x20000-flagged fleet returns 3 for it",
"status": "verified",
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08)"
},
{
"name": "StrategyServer_wire_ModCount_vs_Frame",
"addr": "0x0079fb2f",
"convention": "note",
"prototype": "NAME CORRECTION, from StrategyServer::Write's own wire tags. At 0x0079fb2f `lea edx,[edi+0x08]; push \"ModCount\"` and at 0x0079fb40 `lea eax,[edi+0x0c]; push \"Frame\"`, with edi = S (the same edi that indexes the players vector at +0x54). So in the S frame **S+0x8 is ModCount and S+0xc is Frame**, i.e. in the stored (S+4) frame +0x4 is ModCount and +0x8 is Frame. `StrategyServer_off_ModCount = 0x8` therefore carries the WRONG NAME: that word is Frame, the turn number. The word it names is the one lane T recorded as StrategyServer_off_PhaseCounter = 0x4 and lane K called 'never named' -- it has a name, and it is ModCount. CONFIRMED FROM THE SAVES, which is an independent instrument: Frame reads 1/2/3 on turn1/2/3-state, 16 on zuul-turn16, 23 on zuul-turn23, while ModCount reads 0/12/24/241/412. And CONFIRMED LIVE: lane Z measured S+0x8 advancing 12, 14, 12 per turn on the early Human game (the saves say +12/turn) and 16, 21, 44 on the Zuul one (the saves say ~24/turn average). A modification counter is exactly what those numbers look like, and it explains why only 2 of the 12-44 increments come from the two turn drivers. Integrator: reconcile StrategyServer_off_ModCount / StrategyServer_off_PhaseCounter rather than adding a third name",
"status": "verified",
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08; wire tags from the instruction stream, values from save_reader over five saves, deltas from the live trace)"
},
{
"name": "StrategyServer_ctor_VectorBlock",
"addr": "0x0085b120",
"convention": "thiscall",
"prototype": "void (void* rawBase /* = S+4 */) // the StrategyServer base-class ctor, called from StrategyServer::StrategyServer 0x007d78d0 at 0x007d7905 as `lea ecx,[esi+0x4]`. It zero-initialises FOUR CONSECUTIVE std::vectors as three-word triples with the fourth word skipped: raw +0x40/+0x44/+0x48, +0x50/+0x54/+0x58, +0x60/+0x64/+0x68, +0x70/+0x74/+0x78, then `lea ecx,[esi+0x80]` for the entity hash. That is the campaign's `{_Myfirst,_Mylast,_Myend,_Alval}` = 0x10 allocator-last shape (method rule 5) enumerated four times in a row, and it independently pins StrategyServer_off_Players = 0x50 and _off_Fleets = 0x60 in the raw frame WITHOUT any frame arithmetic -- the ctor is entered with ecx = S+4, so the players triple is literally {S+0x54, S+0x58, S+0x5c}. This is the enumeration that closes the 0x60-vs-0x64 question the campaign paid for once",
"status": "verified",
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08)"
},
{
"name": "StrategyServer_NPCPlayerAccessors",
"addr": "0x00788de0",
"convention": "thiscall",
"prototype": "ServerPlayer* (StrategyServer* this /*S frame*/) // five sibling accessors at 0x00788de0, 0x00788e10, 0x00788e40, 0x00788e70, 0x00788ea0, one per NPC pseudo-player index word at S+0x1b8/0x1bc/0x1c0/0x1c4/0x1c8 (the five words the ctor sets to -1 at 0x007d79fe..0x007d7a16, and the save's NPCm/NPCo/NPCi/NPCv/NPCa). Each is `idx = this->+0x1b8; if (idx < 0) return 0; first = [this+0x54]; last = [this+0x58]; if (idx >= (last-first)>>2) return 0; return first[idx];` -- a bounds check against the players vector's size followed by an index off _Myfirst, which is a third independent confirmation that S+0x54/S+0x58 are _Myfirst/_Mylast. THE PLAYER VECTOR IS NOT THE LOBBY'S PLAYER LIST: it is #empires + one rebel-AI per distinct empire species + 4 NPC pseudo-players (Alien Menace, Peacekeeper Enforcer, Von Neumann, Independent Colony, all Species 4). Hence NumPlrs 8 on the Human saves (two species) and 7 on the Zuul ones (one species), against a lobby that says '2 Players' in both -- Summary.Players counts EMPIRE SLOTS and is also right",
"status": "verified",
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08; counts cross-checked against Sim.PlayerIDs in seven saves and against the live trace)"
},
{
"name": "sizeof_Game_ServerPlayer",
"offset": "0x000003e0",
"convention": "offset",
"prototype": "sizeof(Game::ServerPlayer) = 0x3e0, from the two `push 0x3e0` + operator new sites that precede the ctor call: 0x007865b3 (the bare factory reached through the class-registry word at 0x00a26078) and 0x0078a2f5 (the save loader, which also sets +0x8 = S+4 and inserts into the entity hash at S+0x84). The ctor itself is 0x008803d0 -- NOTE that findings/control-flow/turn-driver.md \u00a73 cites 0x00880474 as 'the ServerPlayer constructor', which is an address INSIDE it; the instruction there is `mov WORD [esi+0x3b4],0x100`, a 16-bit store, so it sets ResErrRoll(+0x3b4) = 0 and cta(+0x3b5) = 1, not '+0x3b4 = 1' as that note reads",
"status": "verified",
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08)"
}
]
}

View file

@ -1,92 +0,0 @@
#!/usr/bin/env python3
"""Per-turn RNG ledger from a lane-Z trace (docs: findings/control-flow/tail-rng-ledger.md).
The lane-Z hooks declare the strategic Mars::RNG as a region whose `describe` reports an ABSOLUTE
WORD POSITION (see sots-engine/src/shim/hooks/rng_ledger.h). This tool turns those positions into
the three numbers a reimplementation needs:
* how many 32-bit words the generator consumed inside each hooked call;
* the same, attributed by phase, using the fact that the hooks nest;
* the bracket total between the pre-turn autosave and the post-turn autosave -- the interval a
standalone has to reproduce -- and the RESIDUAL left over after the attributed subtotals.
A residual > 0 is a draw site outside every hooked function. A `words: null` is a position the
ledger could not place and MUST NOT be read as zero.
uv run python3 tools/rng_ledger_report.py <trace.jsonl>
"""
import json, sys
def val(tv):
if tv is None: return None
return tv.get('v')
def sv(struct_tv, key):
if struct_tv is None or struct_tv.get('t') != 'struct': return None
return val(struct_tv['v'].get(key))
rows = []
for i, line in enumerate(open(sys.argv[1])):
d = json.loads(line)
if 'meta' in d and 'hook' not in d: continue
if 'hook' not in d: continue
args = {a.get('n'): val(a) for a in d.get('args', []) if a.get('n')}
side = d.get('side', {})
rng = side.get('rng', {})
b, a2 = rng.get('before'), rng.get('after')
wb, wa = sv(b, 'words'), sv(a2, 'words')
rows.append(dict(cid=d['call_id'], depth=d.get('depth'), hook=d['hook'].split('::')[-1],
turn=args.get('turn'), pc=args.get('phase_counter'),
enc=args.get('encounters'), end_turn=args.get('end_turn'),
paths=args.get('node_paths'),
perm=args.get('np_permanent'), imm=args.get('np_immortal'),
mortal=args.get('np_mortal'), minlife=args.get('np_min_life'),
within5=args.get('np_within5'), res_nb=args.get('res_no_battle'),
predict=args.get('predict_words', args.get('predict_nodeline_words')),
wb=wb, wa=wa, lb=sv(b,'left'), la=sv(a2,'left'),
bb=sv(b,'block'), ba=sv(a2,'block'),
words=(wa - wb) if (wa is not None and wb is not None) else None,
err=d.get('err'), undecl=d.get('undeclared_total')))
hdr = f"{'cid':>4} {'d':>1} {'hook':<28} {'turn':>4} {'S+8':>4} {'enc':>4} {'paths':>5} {'pred':>4} {'w_in':>8} {'w_out':>8} {'WORDS':>6} {'left':>10}"
print(hdr); print('-'*len(hdr))
for r in rows:
print(f"{r['cid']:>4} {r['depth']:>1} {r['hook']:<28} {str(r['turn']):>4} {str(r['pc']):>4} "
f"{str(r['enc']):>4} {str(r['paths']):>5} {str(r['predict']):>4} {str(r['wb']):>8} "
f"{str(r['wa']):>8} {str(r['words']):>6} {str(r['lb'])+'->'+str(r['la']):>10}"
+ (f" ERR={r['err']}" if r['err'] else ''))
# Brackets: Autosave(end_turn=1) .. Autosave(end_turn=0)
nl = [r for r in rows if r['hook'] == 'NodeLineDecay']
if nl:
print()
print("node-line population (phase 11's draw is one word per EXPIRED line):")
print(f"{'turn':>5} {'paths':>6} {'permanent':>10} {'immortal':>9} {'mortal':>7} {'min_life':>9} {'<=5':>4} {'expired':>8} {'words':>6}")
for r in nl:
print(f"{str(r['turn']):>5} {str(r['paths']):>6} {str(r['perm']):>10} {str(r['imm']):>9} "
f"{str(r['mortal']):>7} {str(r['minlife']):>9} {str(r['within5']):>4} "
f"{str(r['predict']):>8} {str(r['words']):>6}")
enc = [r for r in rows if r['hook'] == 'OnAllCombatDone_Tail']
if enc:
print()
print("tail invocations (P1: does it run on every End Turn?):")
for r in enc:
print(f" turn {r['turn']}: encounters={r['enc']} words={r['words']}")
print()
marks = [r for r in rows if r['hook'] == 'Autosave']
for k in range(len(marks) - 1):
lo, hi = marks[k], marks[k+1]
if not (lo['end_turn'] is True and hi['end_turn'] is False): continue
if lo['wb'] is None or hi['wb'] is None:
print(f"bracket {k}: INCOMPLETE (pre-turn marker has no ledger position)"); continue
total = hi['wb'] - lo['wb']
inner = [r for r in rows if lo['cid'] < r['cid'] < hi['cid'] and r['depth'] == 0 and r['words'] is not None]
acc = sum(r['words'] for r in inner)
# The autosave hook's `this` is a StrategyHost, so its record carries no turn; take the turn
# from the drivers the bracket encloses.
turn = next((r['turn'] for r in inner if r['turn'] is not None), None)
print(f"BRACKET turn {turn}: total={total} words attributed={acc} residual={total-acc}")
for r in inner:
print(f" {r['hook']:<30} {r['words']:>6}")

View file

@ -1,63 +0,0 @@
"""Independent check of the live ledger: pull the RNG blob out of two save files and
compute the word delta from the FILES, with no reference to the shim's numbers."""
import sys, os
sys.path.insert(0, os.path.expanduser('~/sots-re/verify/save-reader'))
import save_reader as sr
N, M = 624, 397
def twist(mt):
mt = list(mt)
for kk in range(N - M):
y = (mt[kk] & 0x80000000) | (mt[kk+1] & 0x7fffffff)
mt[kk] = mt[kk+M] ^ (y >> 1) ^ (0x9908b0df if y & 1 else 0)
for kk in range(N - M, N - 1):
y = (mt[kk] & 0x80000000) | (mt[kk+1] & 0x7fffffff)
mt[kk] = mt[kk+(M-N)] ^ (y >> 1) ^ (0x9908b0df if y & 1 else 0)
y = (mt[N-1] & 0x80000000) | (mt[0] & 0x7fffffff)
mt[N-1] = mt[M-1] ^ (y >> 1) ^ (0x9908b0df if y & 1 else 0)
return mt
def find_rng(node, out):
name = getattr(node, 'name', None)
if name == 'RNG':
out.append(node)
for c in getattr(node, 'children', []) or []:
find_rng(c, out)
def rng_of(path):
res = sr.read_save(path)
hits = []
find_rng(res.tree, hits)
if not hits:
raise SystemExit(f"no RNG frame in {path}")
n = hits[0]
raw = n.raw
if not raw and n.children:
raw = b"".join(c.raw for c in n.children)
if not raw:
raise SystemExit(f"RNG frame in {path} carries no bytes")
return raw
def parse(raw):
# the blob is mt[624] then left, little-endian; tolerate a leading/trailing frame byte
for off in range(0, len(raw) - 2500 + 1):
if len(raw) - off < 2500: continue
mt = [int.from_bytes(raw[off+4*i:off+4*i+4], 'little') for i in range(N)]
left = int.from_bytes(raw[off+2496:off+2500], 'little', signed=True)
if 0 <= left <= N:
return mt, left, off, len(raw)
raise SystemExit(f"cannot parse RNG blob of {len(raw)} bytes")
a, b = sys.argv[1], sys.argv[2]
ra, rb = rng_of(a), rng_of(b)
ma, la, oa, na = parse(ra)
mb, lb, ob, nb = parse(rb)
print(f"{os.path.basename(a)}: blob {na} B, offset {oa}, left={la}")
print(f"{os.path.basename(b)}: blob {nb} B, offset {ob}, left={lb}")
cur, tw = ma, 0
while tw <= 64 and cur != mb:
cur = twist(cur); tw += 1
if cur != mb:
raise SystemExit("second block is not on the first block's chain within 64 twists")
words = 624*tw + (la - lb)
print(f"twists={tw} words consumed between the two files = {words}")