sots-re/findings/control-flow/combat-resolver.md
alex 8dda49cfae lane J: the combat resolver, read from the instruction stream
Closes lane K's #1 ranked gap: FUN_007d5af0 under phase 6 of
OnAllCombatDone_Tail, the second RNG source in the strategic tail.

RNG inventory (the deliverable that matters). The resolver draws nothing
itself. Three sites in its subtree, each behind a function with exactly
one caller:

  R1  RNG_NextInt @0x007bb69b (node cannon)   1 word w.p. 3/4, mean 4/3
  R2  an INLINED RNG_NextFloat @0x007a84bd    1 word per back-eng candidate
  R3  RNG_NextInt @0x00852ec7 (project pick)  >=1 word per successful roll

R2 corrects combat-done-tail.md 3, which reported no NextFloat in the
subtree: the draw is inlined, so the only call-graph edge it leaves is
FUN_007a7f30 -> RNG_Twist, which reads as a bare Twist and is not one.
An image-wide instruction-boundary scan for the MT tempering immediates
finds 14 game functions with inlined draws; two of them, FUN_004f7670
(84 B) and FUN_007aa240, are reachable from StrategyServer::ProcessTurn
and are candidate mechanism for part of lane Z's unexplained 18-20
words per turn.

Also: the real body is 7641 B, not Ghidra's 7499 (which ends
mid-instruction); only 8 non-stack stores and 1 indirect call in the
whole function; 23 EVENT_* keys in the closure, five of them new to the
campaign's combat picture (plague is decided inside combat); the
resolver's subtree DOES write the SETurnResults accumulator at
S+0x2f4[PlyrIdx]+0x90 via FUN_007baef0 -> FUN_007b9df0; and
sizeof(Game::TacReport) = 0x94, enumerated twice.

Two structural errors made and corrected before publication are recorded
in 7.3 and 4 rather than quietly fixed.

Gates run separately: clean-room check OK; host ctest 36/36.
2026-09-08 09:37:02 -04:00

683 lines
46 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# The combat resolver, read from the instruction stream — `FUN_007d5af0` / `CombatResolver_Run`
Lane J, 2026-09-08. Program `sots` / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs.
**Method.** The whole body was disassembled byte for byte with `objdump -b binary -m i386 -M intel` over the raw
image (file offsets from the PE section table, call targets from `dumps/functions.json`, immediates resolved
against `.rdata`). No claim about *control flow* below comes from the decompiler. The call graph, the RNG
inventory, the inlined-draw scan and the `EVENT_*` inventory were computed by decoding every function in the
image from its real instruction boundaries with `tools/x86disp.py`'s length decoder — never a naive byte scan.
Callee **bodies** were read partly by me and partly by delegated sweeps; every such claim is marked.
Closes the **#1 ranked gap** of `findings/control-flow/combat-done-tail.md` (lane K), which read the whole
1,587-byte tail and named this function as the boundary it did not cross. It **corrects** lane K §3 and §2A.1 —
see §7.
---
## 0. Lead: the RNG draw inventory
This is what lane Z's measured per-turn ledger has to meet.
`CombatResolver_Run` runs **once per encounter that produced a real battle** — `ApplyEncounterResult`
0x007d8920 calls it at 0x007d8d24, on the full path only, i.e. when `res->+0x4 == 0`. It **draws nothing
itself**. There are exactly **three** draw sites in its subtree, and each is reached through a function whose
`callerCount` is **1** — the resolver. Nothing else in the image can trigger them.
| # | site | reached by | condition | cost, in **MT words** |
|---|---|---|---|---|
| **R1** | `RNG_NextInt` @ 0x007bb69b, inside `CombatResolve_NodeCannon` 0x007bb530 | resolver 0x007d5be2, **unconditional**, once per battle | `res->+0xa8 != +0xac` (something was flung by a node cannon) **and** the candidate destination list is non-empty | **1 with p=3/4, 2 with p=3/16, …; mean 4/3.** See §3.1 |
| **R2** | an **inlined `RNG_NextFloat`** @ 0x007a84bd, inside `CombatResolve_SalvageBackEng` 0x007a7f30 | resolver 0x007d77c8, **unconditional**, once per battle — but the callee loops over combatants | **one per back-engineering candidate** that passes three gates, per combatant with a non-zero salvage-stat slot | **exactly 1 each** |
| **R3** | `RNG_NextInt` @ 0x00852ec7, inside `SpecialProject_PickRandomAvailable` 0x00852d30 | R2 → `SpecialProject_UnlockRandomForPlayer` 0x007a0540 | **one per R2 roll that succeeds** (`p >= roll`), and only when the available-project list is non-empty | **≥ 1**, mean `2^ceil(log2 n) / n` for `n` available projects |
So the resolver's per-battle cost is
```
words(battle) = [nodeCannonFired] * NextInt(2)
+ SUM over combatants c with a non-zero salvage slot of
( |candidates(c)| + SUM over successful rolls of NextInt(|projects|-1) )
```
**`RNG_NextFloat` and `RNG_Chance` are not called anywhere in the closure.** The only `NextFloat` is the
inlined one at R2.
### 0.1 The finding that matters most: an inlined `NextFloat` that no RNG sweep can see
R2 is not a call. At 0x007a84bd the compiler emitted `RNG_NextFloat` 0x0047d830 **inline**, byte for byte:
```
007a84b5 mov eax,[ebp-0x3ac]
007a84bb mov ecx,[eax] ; ecx = ctx->+0x00 = StrategyServer S
007a84bd mov esi,[ecx+0x16c] ; the strategic RNG object
007a84c3 cmp DWORD PTR [esi+0x9c8],0x0 ; left == 0 ?
007a84ca jne 0x7a84d4
007a84cc lea ecx,[esi+0x4]
007a84cf call 0x426e00 ; RNG_Twist(&mt) -- the LAZY TWIST, not a bare Twist
007a84d4 mov ecx,[esi+0x9c4] ; next
007a84da dec DWORD PTR [esi+0x9c8] ; left--
007a84e0 mov eax,[ecx]
007a84e2 add ecx,0x4
... shr 0xb / and 0xff3a58ad shl 7 / and 0xffffdf8c shl 0xf / shr 0x12 ...
007a8519 fild DWORD PTR [ebp-0x3b8]
007a851f jns 0x7a8527
007a8521 fadd QWORD PTR ds:0x9e61b8 ; +2^32 unsigned fix-up
007a8527 fmul QWORD PTR ds:0x9e61b0 ; * 1/(2^32-1)
```
The tempering masks, the `1/(2^32-1)` multiplier at 0x009e61b0 and the `+2^32` fix-up at 0x009e61b8 are the
exact constants `addresses.json` already records for `RNG_NextFloat`. **The only edge this leaves in a call
graph is `FUN_007a7f30 → RNG_Twist`, which reads as a bare `Twist` and is not one.** That is why lane K's
callee sweep concluded "`RNG_Twist` plus … `RNG_NextInt`. No `NextFloat` and no `Chance` in that subtree."
An image-wide scan at real instruction boundaries for the two tempering immediates finds **fourteen game
functions with inlined MT draws**, besides the four RNG primitives:
```
004b1f20 (x4) 004f7670 00507ac0 (x12) 005232a0 006ec720 006f65f0
006f7890 0079f7d0 007a7f30 007aa240 007c2fa0 (x4) 007c4140
008cca30 008e6e30 (x2)
```
Three of those sit in the strategic turn and **no RNG accounting in this repo has counted them**:
| function | in the direct-call closure of | note |
|---|---|---|
| `FUN_007a7f30` | `CombatResolver_Run` (d1) and `OnAllCombatDone_Tail` (d3) | R2 above |
| `FUN_004f7670` (84 B) | `StrategyServer::ProcessTurn` (d4) | **not read here — handed to lane Z / the next lane** |
| `FUN_007aa240` (944 B) | `StrategyServer::ProcessTurn` (d4) | **not read here** |
**Prediction to falsify.** Any per-turn word ledger built by counting calls to `RNG_NextFloat` /
`RNG_NextInt` / `RNG_Chance` will be **short** on turns where combat happens, by the R2 count — and short on
every turn by whatever `FUN_004f7670` and `FUN_007aa240` spend. If lane Z's measured ledger exceeds the
call-site prediction, these are where the difference lives.
### 0.2 Predictions lane Z's ledger can confirm or falsify
1. **A battle with no node cannon and no salvage candidates costs ZERO words in the resolver.** The resolver
has no unconditional draw. A turn with combat is not automatically a turn with extra RNG.
2. **The node-cannon site does not consume an integral number of words.** `RNG_NextInt` takes the bound **by
pointer** and is **inclusive on `[0, n]`**; here `n = min(3, candidates) − 1`, which in any galaxy with ≥ 4
valid systems is `2`. The rejection mask is `3`, so a `y & 3 == 3` draw is rejected and redrawn: the site
costs 1 word with probability 3/4, 2 with 3/16, 3 with 3/64 … If lane Z sees a node-cannon battle cost
exactly one word every time, my reading of the truncation-to-3 is wrong.
3. **Every back-engineering candidate costs exactly one word whether it succeeds or fails.** `NextFloat` has
no rejection loop. So the R2 count is the number of *candidates*, not the number of *unlocks* — an
observer counting `EVENT_SPRJBACKENG_UNLOCKED` will undercount badly.
4. **The salvage step is per combatant, not per battle.** A three-way battle rolls three candidate lists.
5. **A `Twist` occurs on the 625th word and every 624th thereafter**, from any of these three sites; it is not
an independent event and must not be double-counted in a ledger that counts both calls and words.
---
## 1. Where it sits, what its `this` is, and how big it really is
```
StrategyServer::OnAllCombatDone_Tail 0x007d92a0, phase 6
-> StrategyServer::ApplyEncounterResult 0x007d8920 (res->+0x4 == 0 path only)
0x007d8d12 CombatResolveContext_Ctor(&ctx, S, enc, res) ; ~0xea0 bytes of stack
0x007d8d24 CombatResolver_Run(&ctx) <-- THIS DOC
0x007d8d2f ... ; the publication tail lane K mapped
```
**Exactly one caller**, confirmed by Ghidra (`callerCount: 1`) and by a whole-image direct-call scan.
**The size is wrong in Ghidra.** The real body is **0x007d5af0 .. 0x007d78c8 = 7,641 bytes**, ending in
`__security_check_cookie` / `mov esp,ebp` / `pop ebp` / `ret` (a **plain `ret`** — no stack arguments).
Ghidra's 7,499 stops at 0x007d783b, **mid-instruction**, in the middle of the `call 0x81dcf0` at 0x007d7837.
A dump taken at Ghidra's size loses the last 142 bytes including the victor block's tail and the epilogue.
Lane K's brief quoted 7,499; the honest number is 7,641.
**The base was checked before anything was trusted.** `this` is **not** a `StrategyServer` at all — it is a
stack-built context. Four independent reads settle its shape, all against the `S` frame lane T established:
| read | what it proves |
|---|---|
| `CombatResolveContext_Ctor` 0x007b8460 stores `[ebp+8]→+0x00`, `[ebp+0xc]→+0x08`, `[ebp+0x10]→+0x0c` | field assignment |
| the call site pushes `res`, `enc`, `S` in that order | argument identity — `ApplyEncounterResult`'s own `[ebp+8]` is `enc`, `[ebp+0xc]` is `res`, `ecx` is `S` |
| resolver 0x007d5c03: `[ctx+0x08]->+0x2c − ->+0x28` divided by `0x44` | `ctx+0x08` is the `Encounter` (lane K's 0x44 member stride) |
| `CombatResolve_NodeCannon` 0x007bb58f: `[ctx+0x00]->+0x44/+0x48` walked as `Systems`, and 0x007bb681 `[ctx+0x00]->+0x16c` used as the RNG | `ctx+0x00` is **`S`, not `S+4`** |
```c
// CombatResolveContext -- a ~0xea0-byte STACK local, never heap, never serialized
// +0x000 StrategyServer* S (the S frame)
// +0x004 void* new(0x5c) per-player lookup object (ctor FUN_005a13f0)
// +0x008 Encounter* enc
// +0x00c Game::EncounterResults* res (0x178)
// +0x010 void* bio-weapon hit list (0 when absent)
// +0x014 BYTE
// +0x018 std::string
// +0x038 0x40 bytes copied wholesale to a stack scratch at 0x007d68b6
// +0x290 + PlyrIdx*4 int
// +0x330 + PlyrIdx*4 int
// +0x430 + PlyrIdx*4 int
// +0x7b0 + PlyrIdx*0x10 vector<T*>
// +0x9b0 + PlyrIdx*4 the event object posted for that player
// +0xa30, +0xa34 written by FUN_007baef0; +0xa34 is the WINNER PlyrIdx, -1 = none
// +0xa40, +0xa44[race], +0xa5c[race], +0xa62, +0xa63, +0xa64, +0xa68, +0xa6c/+0xa70/+0xa74
// +0xa80 + PlyrIdx*0x10 an intrusive list head (the salvage "loot pool")
// +0xc7c + role*8 per-role handle buckets
// +0xe7c = FUN_00787690(enc), the resolver's first act
// +0xe80 a 0x50-stride intercept record array (+0xe84 its end)
```
---
## 2. The block map
`this = ebx = ctx` throughout. "verified" = read from the instruction stream by me in this lane.
| # | VA range | what runs | evidence |
|---|---|---|---|
| — | 0x007d5af0–0x007d5b1e | prologue: SEH frame `0x0098f945`, `sub esp,0x484`, cookie | verified |
| **A** | 0x007d5b1e–0x007d5c02 | **sixteen unconditional `mov ecx,ebx; call` member calls.** In order: `ctx->+0xe7c = FUN_00787690(enc)`; `FUN_007c1c80`; `FUN_00790280`; `FUN_007c9e00`; `FUN_007905e0`; `FUN_007bad00`; `FUN_0078bba0(&ctx->+0x90)`; `FUN_0079ab90`; `FUN_007baef0`; `FUN_0078b6e0`; `FUN_0079b700`; `FUN_0078b9a0`; `FUN_0079b980`; `FUN_0079c1b0`; `FUN_007d5a00`; `FUN_0079be80`; **`CombatResolve_NodeCannon` (R1)**; `FUN_0078ba10`; `FUN_0079c270`; `FUN_007874b0`; `FUN_0078f250` | verified. Not one conditional jump between them except the inlined `std::string` destructor at 0x007d5b97, whose arms converge at 0x007d5ba8 |
| **B** | 0x007d5c03–0x007d779d | **ONE loop over `enc->members`, stride 0x44** — index `i` in `[ebp-0x404]`, `edi = i*0x44` computed as `((i<<4)+i)*4`, bound `(enc->+0x2c − enc->+0x28)/0x44` recomputed at both ends (magic `0x78787879 / sar 5`). **7,021 of the 7,641 bytes.** Body detailed in §2.1 | verified: back-edge `jl 0x7d5c30` at 0x007d779d |
| **C** | 0x007d77a3–0x007d77cd | five more unconditional member calls: `FUN_00790990`, `FUN_0079ae80`, `FUN_007b0650`, `FUN_0078bba0(&ctx->+0x190)`, **`CombatResolve_SalvageBackEng` (R2/R3)** | verified |
| **D** | 0x007d77cd–0x007d78a5 | **victor block**, gated on `ctx->+0xa34 != -1` **and** `enc->+0xc != 0` **and** the member vector non-empty: `winner = S->Players[ctx->+0xa34]`; loop over members looking for one whose `player->+0x5c == 5`, and if `FUN_0081dcf0(winner, thatPlayer) > 0` bail out; otherwise `FUN_00799270(ctx->+0x00, enc->+0xc)` | verified |
| **E** | 0x007d78a6–0x007d78c8 | `FUN_0079c740(ctx)`; SEH unlink; `__security_check_cookie`; plain `ret` | verified |
### 2.1 Inside the per-member loop
Body order, all verified from the instruction stream:
1. **0x007d5c30–0x007d5ca9 — the two skips.** `p = *(ServerPlayer**)(enc->members + i*0x44)`;
`[ebp-0x408] = p->PlyrIdx(+0x28)`; `FUN_0059bd70(ctx->+0x04, p)` → a per-player record in `[ebp-0x3f4]`;
`k = FUN_0081dbd0(enc, p)` — **if `k == -1` the whole member is skipped**. Then a linear scan of
`res->+0x98 .. +0x9c` (4-byte stride) for `p->+0x04`; **if the id is absent the whole member is skipped.**
So a combatant present in the `Encounter` but not in the result's participant list contributes nothing.
2. **0x007d5ca9–0x007d5d76 — four empty `std::string` locals** at `[ebp-0x274]`, `[ebp-0x228]`, `[ebp-0x1f0]`,
`[ebp-0x20c]`: the event key, the summary, the message, and a spare.
3. **0x007d5d76–0x007d6443 — the event-key composition.** If `FUN_0080b250(enc)` → the
`EVENT_TRADERAIDERS` arm. Otherwise, if `enc->+0x38 != 0`, `PickDominantEncounterType(enc->+0x38)`
(0x004f4c40, already named by lane K) then `FUN_004f4970` turns the id into a name, and the code builds
the literal `"EVENT_"` + name + `"_FIGHT"`, `"EVENTSUM_"` + name, `"EVENTMSG_"` + token, where the token is
`"ENTITYVICTORY"` / `"ENTITYDEFEAT"` / `"UNRESOLVED"` selected by `FUN_00785ad0(ctx, encType)` returning
2 / 1 / 0. Each key goes through `FUN_008c9690` (a string-table lookup that returns a bool) and, on a hit,
`FUN_008c8eb0` (a `_snprintf` into a 0x100 buffer with five spare arguments); on a miss it logs at level 2.
4. **0x007d6443–0x007d6790 — outcome selection.** A chain of `FUN_00787600` / `FUN_00787550` tests picks one
of `EVENT_COMBAT_OBSERVED`, `EVENT_DEFEAT`, `EVENT_VICTORY`, `EVENT_ENGAGED`, all converging at 0x007d6784.
5. **0x007d6790–0x007d68cb — outcome write-back.** `if ([ebp-0x3f4]) record->+0x10 = outcome` — one of only
eight non-stack stores in the function. Then `if (ctx->+0xa34 == PlyrIdx)` the winner arm.
6. **0x007d68cb–0x007d6dee — loss accounting.** Per player, `(ctx->+0x430[p] − ctx->+0x290[p]) − ctx->+0x330[p]`
as a 64-bit `cdq`/`sub`/`sbb` clamped at 0, plus a dozen more terms, into a **twelve-slot, 0x10-stride
stack array** at `[ebp-0x1d4]`.
7. **Inner loop A, 0x007d6df0–0x007d6edd — a fixed 12 iterations** (`[ebp-0x3ec] = 0xc`, `dec`, `jne`) over
that array: skip any slot whose int64 is 0, else append `", "` (0x009e4588) and the number rendered by
`FUN_008cea10`. This builds the human-readable damage summary.
8. **Inner loop B, 0x007d6f40–0x007d7034 — over `res->+0x18/+0x1c[i].TacReports`.**
`res->+0x18` is a `vector<Game::CombatPlayerStats>` of **stride 0x24** (`lea edx,[eax+eax*8]; lea ebx,[eax+edx*4]`),
indexed by **the member index `i`**, not by `PlyrIdx`; the block is skipped when
`(res->+0x1c − res->+0x18)/0x24 <= i`. The inner container is reached through
`CombatPlayerStats_TacReportCount` 0x00456c20 and `CombatPlayerStats_TacReportAt` 0x00456c40 — see §5.
Per entry: gate on `entry->+0x48`, `HandleMap::Resolve(S+0x84, entry->+0x04)`, then more string work.
9. **0x007d7040–0x007d7208 — a second statistics pass** using `FUN_00786dd0`, `FUN_007437e0`, `FUN_00786ec0`,
`FUN_00785930`, `FUN_00820680`, `FUN_00790dc0`, `FUN_00857440`, `FUN_0079d040`.
10. **0x007d7208–0x007d7280 — the post.** `ctx->+0x9b0[PlyrIdx] = FUN_00797700(ctx, PlyrIdx, key, summary,
message, 1)`. This is the second real object store in the function and the only one that leaves anything
per-player behind.
11. **Inner loops C and D, 0x007d72e8–0x007d749d.** C (0x007d7340) builds an MSVC `_Tree` (`operator new`
for the head node, self-pointers at +0x00/+0x04/+0x08, `+0x10 = +0x11 = 1`) and inserts every element of a
4-byte-stride vector via `FUN_004a0950` / `FUN_0054dc60`; D (0x007d73b0) walks the tree in order with
`FUN_0080ceb0` (`_Tree::_Inc`).
12. **0x007d74a3–0x007d7773 — `EVENT_STATION_KILLED`**, posted through the same `FUN_00797700`, then the
string destructors and the loop increment.
### 2.2 The `if` that is not a branch, settled the way lane K settled its own
Every one of the six `jb`/`jae` pairs around 0x007d5b97, 0x007d5dfa, 0x007d5fc1, 0x007d76eb, 0x007d7715,
0x007d7746 is the **inlined `std::string` destructor**: `cmp DWORD PTR [ebp-N],0x10; jb over;
mov eax,[ebp-M]; push eax; call operator_delete; add esp,4`, converging **three to five instructions later** on
the `_Myres = 0xf; _Mysize = 0; buf[0] = 0` reset. The capacity word compared against `0x10` is the MSVC
short-string-optimisation threshold, and 0x009e100c is the empty-string literal every one of them is
constructed from. **None of them gates a phase.** The identical shape appears eight times in
`CombatResolve_SalvageBackEng` and once inside `SpecialProject_PickRandomAvailable`.
### 2.3 Two structural facts worth more than the block list
**Only EIGHT non-stack stores exist in the entire 7,641-byte body**, found by decoding every instruction and
filtering memory writes whose base register is not `ebp`/`esp`:
```
007d5b34 mov DWORD PTR [ebx+0xe7c],eax ; ctx->+0xe7c = FUN_00787690(enc)
007d684d mov BYTE PTR [eax+0x10],cl ; the per-player record's outcome byte
007d7272 mov DWORD PTR [edi+esi*4+0x9b0],eax ; ctx->+0x9b0[PlyrIdx] = posted event
007d72fe mov DWORD PTR [eax],eax ; _Tree head node
007d7306 mov DWORD PTR [eax+0x4],eax ; "
007d730f mov DWORD PTR [eax+0x8],eax ; "
007d7318 mov BYTE PTR [ecx+0x10],0x1 ; "
007d7322 mov BYTE PTR [edx+0x11],0x1 ; "
```
**And exactly ONE indirect call**, at 0x007d7877, inside the `_CxxThrowException` path for
`"vector<T> too long"`. The resolver has **no virtual dispatch at all**.
Together those say what the function *is*: it composes and posts per-player combat events and delegates every
state mutation to its 70 direct callees. **The resolver's own body writes nothing outside its stack context**
— but its callees write plenty, including the turn-results accumulator. See §4.
---
## 3. The three RNG sites, read in full
### 3.1 R1 — `CombatResolve_NodeCannon` 0x007bb530 (1,991 B), one caller
```
007bb560 mov eax,[ebx+0xc] ; res
007bb563 mov ecx,[eax+0xa8]
007bb56f cmp ecx,[eax+0xac]
007bb575 je 0x7bbcf8 ; EMPTY -> return, NO DRAW
```
Then:
1. reserve a `vector<StarSystem*>` at `[ebp-0xf0]` for `S->Systems.size()` (`FUN_00483410`);
2. push back every system with `sys->+0xc5 == 0` and `sys != enc->+0xc` (`FUN_0059f1a0`);
3. `FUN_00796590(first, last, count, {enc->+0x1c, enc->+0x20, enc->+0x24, true})` — a sort by distance from
the encounter's position, the functor built on the stack with `sub esp,0x10`;
4. `if (count > 3) FUN_00459f70(&cand, 3)` — **truncate to the three nearest**;
5. **the draw**, and only if the list is non-empty:
```
007bb679 cmp ecx,eax
007bb67b je 0x7bb6af ; EMPTY -> skip, NO DRAW
007bb67d sub eax,ecx
007bb67f mov ecx,[ebx] ; ctx->+0x00 = S
007bb681 mov ecx,[ecx+0x16c] ; the strategic RNG object
007bb687 sar eax,0x2
007bb68a lea edx,[ebp-0xbc]
007bb690 dec eax ; n = count - 1
007bb691 push edx ; the bound, BY POINTER
007bb692 add ecx,0x4 ; &mt
007bb695 mov [ebp-0xbc],eax
007bb69b call 0x4271c0 ; RNG_NextInt
007bb6a0 mov ecx,[ebp-0xf0]
007bb6a6 mov edx,[ecx+eax*4] ; the chosen destination system
```
`RNG_NextInt`'s loop head is at 0x004271f0 and the exit test is at the **bottom** (`ja 0x4271f0`), so **every
call consumes at least one word**, including `n == 0`. With `n == 2` the or/shift cascade gives `mask = 3` and
`y & 3 == 3` is rejected — the geometric tail in §0.
6. per flung handle, `HandleMap::Resolve(S+0x84, h)` then `FUN_007bb420(&out, chosenSystem, obj)` groups by
`(destination, obj->+0x10)` into a 0x18-stride vector; the tail posts **`EVENT_NODECANNON_FLINGS`** and
**`EVENT_NODECANNON_KILLS`**, with no further draw.
*Every line of this section is instruction-verified.*
### 3.2 R2/R3 — `CombatResolve_SalvageBackEng` 0x007a7f30, one caller
**This function's Ghidra size is a trap and it caught me first.** Ghidra says 2,670 bytes, ending at
0x007a89be. The **outer loop's back-edge is at 0x007a89ab → 0x007a80ac**, and the real body ends at
0x007a89cd. A dump taken at Ghidra's size shows no back-edge and the whole outer loop reads as straight-line
code operating on `members[0]` for one designated player. It is a loop. Corrected here before publication;
recorded because it is the same class of error as reading a `std::vector` destructor as a branch.
```
L1 0x007a7f79..0x007a7f88 zero 32 slots of 0x10 at [ebp-0x310] -- a per-PlyrIdx 3-float salvage stat
L2 0x007a7fc0..0x007a8071 per Encounter member: player = S->Players[m->+0x28];
gate FUN_00787350(player); FUN_0078bcf0(ctx, player, slot) fills the
three floats; sticky byte [ebp-0x39d] records "any slot non-zero"
0x007a807e if (!sticky) return <-- WHOLE-FUNCTION ZERO-DRAW EXIT
0x007a80a6 if (members.empty()) return <-- WHOLE-FUNCTION ZERO-DRAW EXIT
L3 0x007a80ac..0x007a89ab OUTER LOOP over members, counter [ebp-0x3c4]
idx = members[i]->+0x28
player = S->Players[idx] -> [ebp-0x3bc]
slot = [ebp-0x310] + idx*0x10 -> [ebp-0x3a4]
0x007a8105 if (slot[+4]==0 && slot[+8]==0 && slot[+0xc]==0) continue <-- no draw
0x007a810d candidates = {} ([ebp-0x3d4]/[ebp-0x3d0]/[ebp-0x3cc])
L4 0x007a8150..0x007a8406 per member j: append {void* def; float p} candidates
0x007a844c if (candidates.empty()) skip <-- no draw
L5 0x007a8452..0x007a8693 PER CANDIDATE:
if (def == 0) continue no draw
if (!(p > 0.0f)) continue (fldz/fcomp/test ah,5/jp) no draw
if (!FUN_0078f530(player, def, &out)) continue no draw
roll = <INLINED NextFloat> ** ONE WORD **
success = !(p < roll) (fcom st(1)/test ah,1 -- equality succeeds)
log "%s ... SUCCESS|FAILED" with two _ftol2 percentages
if (success) {
name = "SPRJ_BACKENG_" + def->+0x20
SpecialProject_UnlockRandomForPlayer(&out, name, player) ** >= 1 WORD (R3) **
if (out.empty()) log level 2
else post EVENT_SPRJBACKENG_UNLOCKED
}
```
`FUN_0078f530` (356 B) is a **deterministic** gate — already-researched, prerequisite, blacklist, then a
difficulty-indexed float table at `subentry + player->+0x5c*4 + 0x20` compared against 0.0. **No RNG in it.**
*(delegated read, spot-checked by me for RNG signatures — clean.)*
R3, `SpecialProject_PickRandomAvailable` 0x00852d30, verified by me:
```
00852e59 call 0x59ec00 ; build the candidate project vector
00852e67 cmp ecx,eax
00852e69 jne 0x852eb4
... ; EMPTY -> return empty string, NO DRAW
00852eb4 sub eax,ecx
00852eb9 sar eax,0x2
00852ebf dec eax ; n = count - 1
00852ec0 push edx ; by pointer
00852ec1 add ecx,0x4 ; &mt
00852ec7 call 0x4271c0 ; RNG_NextInt
00852ed9 inc BYTE PTR [eax+ebx*1] ; mark it taken for this player
```
*L1–L5, the two whole-function exits, the inlined draw and R3 are instruction-verified by me. The contents of
`FUN_0078bcf0` (which fills the stat slot) and the three sources L4 draws candidates from are a delegated
read: a per-player intrusive list at `ctx+0xa80+PlyrIdx*0x10` supplying up to two entries per node depending
on a per-`PlyrIdx` bit at `node+0x14` and a byte at `node+0x18`, plus — for the one member whose `PlyrIdx`
equals `ctx->+0xe7c` — a category-filtered registry query `FUN_00584e50(&tmp, 0x7fffffff, 0, cat)` whose
`+0x3c == 1` elements all share one probability. **That is the part of the draw count I have NOT verified
myself**, and it is what determines `|candidates(c)|`.*
### 3.3 Nothing else draws
Computed twice, independently:
* **Direct-call closure.** BFS from 0x007d5af0 over a call graph built by decoding all 41,089 functions from
their real instruction boundaries (122,624 edges): **754 functions reachable**. The only members of that set
that call an RNG primitive are `CombatResolve_NodeCannon` (`NextInt` ×1),
`SpecialProject_PickRandomAvailable` (`NextInt` ×1) and `CombatResolve_SalvageBackEng` (`Twist` ×1, which
§0.1 shows is the lazy twist of an inlined `NextFloat`). `RNG_NextFloat`, `RNG_Chance` and `RNG_Seed` are
**not reachable at all**.
* **Inlined-draw scan.** Image-wide, at real instruction boundaries, for the tempering immediates: of the 14
game functions carrying them, exactly one — `FUN_007a7f30` — is in the closure.
**The honest caveat.** That is the *direct-call* closure. The 754 functions contain **199 functions with 352
indirect call sites**. Image-wide there are 148 functions that call an RNG primitive and 14 more with inlined
draws; none of the other 158 is in the direct-call closure, so reaching one would require an indirect call.
I did not resolve the 352. **This is a bound, not a proof.**
---
## 4. What it reads and what it writes
**Nothing the resolver writes directly reaches saved state.** The eight stores of §2.3 all land in the stack
context, in a per-player lookup record's outcome byte, or in an `operator new` tree head that is destroyed
before return.
**Turn results — and this corrects a claim I had already drafted.** A scan of the whole closure for the
`SETurnResults` stride (`imul r32,r32,0x11c` / `add r32,0x11c` at real instruction boundaries) returns three
sites in two functions. Two are false positives — `FUN_007904d0` at 0x00790512 and 0x007905ad are both
`add ecx,0x11c` taking the *address* of a **0xc-stride** vector living at `+0x11c/+0x120` (magic `0x2aaaaaab`,
`sar 1`), nothing to do with a 0x11c index. I drafted the third as a false positive too, on a bad address
conversion. It is not. Read from a correct instruction boundary:
```
007ba134 mov ecx,[ebx+0x4]
007ba137 mov edx,[ebp+0xc]
007ba13a mov [ebp-0x1c],ecx
007ba13d mov ecx,[esi+0x28] ; PlyrIdx
007ba140 imul ecx,ecx,0x11c ; * sizeof(SETurnResults)
007ba146 mov [ebp-0x18],edx
007ba149 mov edx,[ebp-0x14] ; the StrategyServer
007ba14c lea eax,[ebp-0x20] ; a 0x20-byte record, vptr 0x00a23c54
007ba14f push eax
007ba150 mov eax,[edx+0x2f4] ; S->+0x2f4._Myfirst
007ba156 lea ecx,[ecx+eax*1+0x90] ; &accumulator[PlyrIdx] + 0x90
007ba164 call 0x7a6630 ; push_back
```
**So the combat resolver's subtree DOES write the turn-results accumulator**, through
`FUN_007baef0` → `FUN_007b9df0` (path `0x007d5af0 → 0x007baef0 → 0x007b9df0`, depth 2), on the
independent-system-surrenders arm — the same arm that posts `EVENT_INDSYS_SURRENDERS_COMBAT` and that writes
the winner index `ctx->+0xa34` at 0x007bb236. The member written is **`SETurnResults + 0x90`**, a *different*
member from the `+0x24` one lane K found `ApplyEncounterResult` writing at 0x007d8f9e.
Lane K's §5A listed `FUN_007b9df0` as reachable from `ProcessTurn` phase 1. It has **seven** direct callers
(`FUN_007baef0`, `FUN_007bd490`, `FUN_007bd520`, `FUN_007bd930`, `FUN_007be870`, `FUN_007d0580`,
`ProcessTurn`), and the first of those is inside combat. So lane K's phase-6 turn-results row should read
*"`ApplyEncounterResult` 0x007d8f9e writes `+0x24`, **and** the resolver's `FUN_007baef0 → FUN_007b9df0`
writes `+0x90` on the surrender arm"*.
This does not reach the save file — lane K established `SETurnResults` has no `Read`/`Write` pair and appears
in no save schema — but it does reach the client, and it is live in memory when the autosave runs.
*Method note, recorded because it nearly produced a wrong published finding: my stride scanner reported hit
addresses in decimal and I converted two of them by hand, wrongly, then disassembled the wrong addresses and
saw garbage that looked like a false positive. What caught it was re-disassembling from a known instruction
boundary instead of trusting the arithmetic. The scanner was right; the reader was not.*
**Events.** The closure references **23 distinct `EVENT_*` / `EVENTSUM_*` / `EVENTMSG_*` keys**, found by
scanning every reachable function for `.rdata` string immediates at real instruction boundaries. Lane K
predicted seven from the string table; the real list is:
| key | posted by | depth |
|---|---|---|
| `EVENT_` + type name, `EVENTSUM_` + name, `EVENTMSG_` + token | **the resolver itself** | 0 |
| `EVENT_TRADERAIDERS`, `EVENT_COMBAT_OBSERVED`, `EVENT_DEFEAT`, `EVENT_VICTORY`, `EVENT_ENGAGED`, `EVENT_STATION_KILLED` | **the resolver itself** | 0 |
| `EVENT_%s`, `EVENTSUM_%s_ELIMINATED`, `EVENTMSG_%s_ELIMINATED` | `FUN_00790990` | 1 |
| `EVENT_NODECANNON_FLINGS`, `EVENT_NODECANNON_KILLS` | `CombatResolve_NodeCannon` | 1 |
| `EVENT_SPRJBACKENG_UNLOCKED` | `CombatResolve_SalvageBackEng` | 1 |
| **`EVENT_PLAGUE_OUTBREAK`**, **`EVENT_PLAGUE_CURED`** | `FUN_0079c270` | 1 |
| **`EVENT_INDSYS_SURRENDERS_COMBAT`** | `FUN_007baef0` | 1 |
| **`EVENT_BETRAYAL`** | `FUN_007b0650` | 1 |
| `EVENT_FLEET_INTERCEPT_COMPLETE` | `FUN_0079ae80` | 1 |
| **`EVENT_MINE_REFINE_NEGNEWS`** | `FUN_00888c40` | 2 |
| **`EVENT_FLEET_RETREATED_VIA_TELEPORT`** | `FUN_007d5650` | 2 |
| `EVENT_FLEET_INTERCEPT_ABORTED` | `FUN_0088b980` | 3 |
The five in bold are new to the campaign's combat picture. **Plague is resolved inside combat** — bio-weapon
hits are accumulated by `FUN_0078ba10` into `ctx->+0xa40 / +0xa44[race] / +0xa5c[race]` and turned into
`EVENT_PLAGUE_OUTBREAK` or `EVENT_PLAGUE_CURED` by `FUN_0079c270`, both at depth 1 of the resolver, on the
strategic side of the client/server split. *(String identities verified by me from the raw `push imm32`
operands; the surrounding logic is a delegated read.)*
---
## 5. Two object facts recovered on the way
**`sizeof(Game::TacReport) = 0x94`, enumerated twice.** `CombatPlayerStats_TacReportAt` 0x00456c40 is
`return (TacReport*)(this->+0x04 + i * 0x94)` — `imul eax,eax,0x94` — and its sibling
`CombatPlayerStats_TacReportCount` 0x00456c20 is `(this->+0x08 − this->+0x04) / 0x94` by the `0xdd67c8a7`
add-back / `sar 7` reciprocal. Two independent enumerations, neither of them a touch-scan.
**Flag against it, per earned-rule 7:** `objects/streams.json` gives `Game::TacReport` twenty fields — two
embedded `Game::TacReportEvents` (verified 0x20 each) plus eighteen scalars — which with a vptr accounts for
at most 0x8c. **About 8 bytes are members the serializer never names.** Carried, not named. Not resolved here.
**`Game::CombatPlayerStats+0x04` is its `vector<TacReport>`** — and that is a live instance of lane Q's rule.
`sizeof(Game::CombatPlayerStats)` is already `verified` at 0x24, and its stream schema writes
`RPBon, RPBonT, SavBonus, MaintHF` **before** `TacReports`. An offset-sorted reading of that schema puts the
vector at +0x14 and the scalars at +0x04..+0x13. The instruction stream says the opposite: the vector is at
+0x04 and the scalars follow it. **Offset order is not write order.** Aligned against `streams.json`, never
against `layouts.md`.
---
## 6. Callee inventory
**Read as instructions by me:** `CombatResolver_Run` in full, `CombatResolveContext_Ctor`,
`CombatResolve_NodeCannon`, `CombatResolve_SalvageBackEng` (structure, the two whole-function exits, the
inlined draw, the roll loop), `SpecialProject_UnlockRandomForPlayer`, `SpecialProject_PickRandomAvailable`,
`RNG_NextInt`, `CombatPlayerStats_TacReportAt`, `CombatPlayerStats_TacReportCount`, `FUN_00456c20/40`.
**Delegated reads — call shapes and argument orders are mine from the resolver's bytes; contents are the
sweep's.** Two agents, both instructed to work from the instruction stream, both asked to state what they
skimmed. One of the two got the salvage loop structure wrong in the same way I did, from the same truncated
Ghidra size; I found it and corrected it in §3.2. Treat everything below as **inferred**:
| addr | size | what the sweep found |
|---|---|---|
| 0x00787690 | 211 | takes `enc`; resolves the node and returns `node->+0x28`, else scans members for flag 0x400. Sets `ctx->+0xe7c`. No RNG |
| 0x007c1c80 | 276 | walks `res->+0xe0/+0xe4` handles, registers distinct participants into `res->+0xfc/+0x100` via `FUN_007be340` |
| 0x00790280 | 585 | builds the credit-eligible handle list at `ctx->+0xa6c/+0xa70/+0xa74`, consumed by `FUN_0078f250` |
| 0x007c9e00 | 2093 | the largest: copies encounter position/type into `ctx->+0x04`, builds a role bitmask, computes interception distances. **Skimmed** |
| 0x007905e0 | 429 | per-role handle buckets at `ctx->+0xc7c + role*8` |
| 0x007bad00 | 475 | formats "intercepted by" text into `ctx->+0xe80` |
| 0x0078bba0 | 321 | zeroes a caller-supplied 0x100 buffer and sums per-role values as doubles |
| 0x0079ab90 | 119 | a string-builder helper |
| 0x007baef0 | 1300 | interception geometry; **writes `ctx->+0xa34 = player->PlyrIdx` (the winner index) and `ctx->+0xa38` at 0x007bb236/0x007bb249** — instruction-verified by me; posts `EVENT_INDSYS_SURRENDERS_COMBAT`; then calls `FUN_007b9df0(S, ctx->+0x10, 2, …)` at 0x007bb264, which is the turn-results write of §4 |
| 0x0078b6e0 | 702 | participant cleanup over `enc->+0x78/+0x7c` (0x14 stride) through an interface vtable |
| 0x0079b700 | 96 | per `res->+0x28/+0x2c` handle: `FUN_0079b140` + `FUN_00793d60` |
| 0x0078b9a0 | 111 | links `res->+0x38/+0x3c` (0x20 stride) records back into live objects |
| 0x0079b980 | 511 | applies four stat dwords from `res->+0x48/+0x4c` (0x28 stride); checks tech 0x276e |
| 0x0079c1b0 | 188 | attacker/target pairs from `res->+0x58/+0x5c` (0xc stride) |
| **0x007d5a00** | **225** | **a six-stage sub-dispatcher**: builds a 0x18-byte temp from `ctx->+0x00/+0x08/+0x0c` and runs `FUN_0079bb90`, `FUN_0079bcd0`, `FUN_007b0320`, `FUN_00790790`, `FUN_007d5650`, `FUN_007a7cd0`. **The real per-phase combat pipeline is under here.** Its whole subtree is inside the 754-function closure and draws no RNG |
| 0x0079be80 | 799 | order/colonization statistics. **Skimmed** |
| 0x0078ba10 | 386 | bio-weapon hit accumulation into `ctx->+0xa40/+0xa44/+0xa5c` |
| 0x0079c270 | 1219 | **posts `EVENT_PLAGUE_OUTBREAK` / `EVENT_PLAGUE_CURED`** |
| 0x007874b0 | 158 | scales `ctx->+0xa64` by a count; `FUN_00925220` here is `_ftol2`, **not** RNG |
| 0x0078f250 | 295 | distributes per-participant credit using `ctx->+0xa68` |
| 0x00790990 | 1040 | 24 elimination-condition bits → `EVENTSUM_%s_ELIMINATED` / `EVENTMSG_%s_ELIMINATED` / `EVENT_%s` |
| 0x0079ae80 | 680 | `ctx->+0xe80/+0xe84` (0x50 stride) → `EVENT_FLEET_INTERCEPT_COMPLETE` |
| 0x007b0650 | 2591 | ally-fire detection → `EVENT_BETRAYAL`. **Skimmed.** Its `[player+0x16c]` bit test is a `ServerPlayer` field, **not** `S+0x16c`'s RNG |
| 0x00799270 | 64 | find-and-erase on a container at `S->+0x314` |
| 0x0079c740 | 2270 | the last call; per-member prize/message text pulled from the node's string table, not from a literal. **Skimmed**. No RNG |
| 0x0081dcf0 | 280 | the relationship/eligibility comparator used by the victor block and by the salvage step |
---
## 7. Corrections
### 7.1 To lane K §3 — the resolver's subtree *does* draw `NextFloat`
`combat-done-tail.md` §3 says of phase 6's subtree: *"No `NextFloat` and no `Chance` in that subtree to depth
4."* The `Chance` half is right and now stronger (none in the full closure, not just depth 4). The `NextFloat`
half is **wrong**, and wrong for a reason worth keeping: the draw is **inlined** (§0.1), so it is invisible to
any call-graph sweep. Lane K's own note that the edge was `FUN_007a7f30 → RNG_Twist` is the tell — a game
function has no business calling `Twist` directly, and it isn't.
Lane K's *conclusion* — that the tail advances the generator before the autosave and that nothing models it —
is unaffected and, if anything, understated.
### 7.2 To lane K §2A.1 — the resolver's size and the shape of the call
`ApplyEncounterResult` "builds a ~0xea0-byte combat report and runs the real resolver `FUN_007d5af0`
(7499 B)". Two refinements: the object is a **context**, not a report (the `Game::CombatReport` that reaches
the `std::list` at `S+0x1fc` is a *different* local, constructed at 0x007d8d2f **after** the resolver
returns); and the resolver is **7,641 bytes**, not 7,499 — Ghidra's figure ends mid-instruction.
### 7.3 To my own first reading
I initially read `CombatResolve_SalvageBackEng` as straight-line code that resolved back-engineering for one
designated player, because I dumped it at Ghidra's size and the outer back-edge at 0x007a89ab fell outside the
dump. It is a loop over combatants. Recorded rather than quietly fixed: the failure mode is *"a truncated
range makes a loop look like a sequence"*, which is the same family as *"an inlined destructor looks like a
branch"* and deserves its own line in the rules.
---
## 8. Gap list, ranked
### Tier 1 — closes an RNG question the campaign is actively measuring
| target | size | why |
|---|---|---|
| **`FUN_004f7670` (84 B) and `FUN_007aa240` (944 B)** | 84 + 944 | **inlined MT draws reachable from `StrategyServer::ProcessTurn` at depth 4.** Nothing in the repo counts them. Eighty-four bytes is the cheapest RNG fact left in the campaign, and lane Z's ledger cannot be explained without both |
| **`FUN_0078bcf0` (641 B)** — fills the per-player salvage stat slot | 641 | it decides *whether* a combatant rolls at all, and its three floats become the probabilities R2 tests. Without it `\|candidates(c)\|` stays unquantified |
| **`FUN_00584e50` + the `ctx+0xa80` loot-pool list** | ? | the other half of `\|candidates(c)\|` — the actual number of R2 draws per combatant |
| **`FUN_007bb420` (268 B) + `FUN_00459f70`/`FUN_00796590`** | 268 + 131 + 374 | confirms the truncate-to-3 that fixes R1's bound at 2. One sort and one resize; if the truncation is not unconditional, prediction 2 in §0.2 changes |
### Tier 2 — the actual combat mathematics, still unread
| target | size | why |
|---|---|---|
| **`FUN_007d5a00`'s six callees** — `FUN_0079bb90`, `FUN_0079bcd0`, `FUN_007b0320`, `FUN_00790790`, `FUN_007d5650`, `FUN_007a7cd0` | ? | **this is where the per-phase combat pipeline lives.** The resolver is a reporting layer; the sub-dispatcher is the machine. `FUN_007d5650` already carries `EVENT_FLEET_RETREATED_VIA_TELEPORT`. Draw-free by the closure scan, which makes them tractable and deterministic — the best reimplementation target in the whole combat path |
| `FUN_007c9e00` | 2093 | interception geometry; the largest single unread block reachable in one hop |
| `FUN_0079c740` | 2270 | prize/reward resolution, the last thing the resolver does |
| `FUN_007b0650` | 2591 | `EVENT_BETRAYAL` — a diplomacy rule fired from inside combat, nowhere in `findings/subsystems/` |
| `FUN_0079c270` + `FUN_0078ba10` | 1219 + 386 | **plague is decided inside combat.** `EVENT_PLAGUE_OUTBREAK` / `EVENT_PLAGUE_CURED` at depth 1, and `ServerSystem::ProcessPlague` is separately known to be draw-free — so this may be the *only* place plague starts |
| `FUN_00790990` | 1040 | 24 elimination conditions and the `%s_ELIMINATED` event family |
### Tier 3 — object work this lane opened but did not close
* The ~8 unnamed bytes of `Game::TacReport` (§5). `sizeof` is settled at 0x94; the field list is not.
* `Game::EncounterResults` beyond `+0x04`, `+0x05`, `+0x06`, `+0x07`, `+0x18/+0x1c`, `+0x28/+0x2c`,
`+0x38/+0x3c`, `+0x48/+0x4c`, `+0x58/+0x5c`, `+0x98/+0x9c`, `+0xa8/+0xac`, `+0xe0/+0xe4`, `+0xfc/+0x100`,
`+0x10c`, `+0x120`, `+0x150`. That is 16 of 0x178 bytes named; `streams.json` lists 28 anonymous fields, so
the offsets above must be matched to them **in write order**, not sorted.
* `CombatResolveContext` — I named 20 offsets; the object is 0xea0 bytes.
### Tier 4 — the honest blind spot
**352 indirect call sites in 199 of the 754 reachable functions.** The RNG inventory in §0 is a bound derived
from the direct-call closure plus an image-wide inlined-draw scan. It is strong — 158 of the image's 162
RNG-touching functions are outside the closure entirely — but it is not a proof, and if lane Z measures a
combat-turn cost that §0's formula cannot produce, this is the first place to look.
---
## 9. What this lane did **not** read
Stated plainly, in the shape lane K used, because it is the most useful section.
* **The per-member loop's arithmetic.** I read its control flow, its bounds, its calls, its strings and every
memory write. I did **not** decode the ~1,400 bytes of float and int64 accounting between 0x007d68cb and
0x007d6dee that produce the twelve summary counters — only that there are twelve, that they are int64, and
that the terms come from `ctx->+0x290`, `+0x330`, `+0x430` indexed by `PlyrIdx`.
* **`FUN_007d5a00`'s six callees** — the real combat pipeline. Confirmed inside the closure and confirmed
draw-free; nothing more.
* **Twenty-six callee bodies** were characterised by delegated sweeps (§6). Their call shapes and argument
orders are instruction-verified from the resolver's own bytes; their contents are not mine. Two of them were
explicitly skimmed by the sweep and are marked.
* **`FUN_0078bcf0` and `FUN_00584e50`**, which together determine the R2 draw count. This is the single
biggest hole in the deliverable that matters most, and it is Tier 1 above.
* **The 352 indirect call sites.**
* Whether `res->+0x98/+0x9c`'s elements are the schema's `carr<int>` — the stride and use are verified, the
type identification is **a labelled hypothesis** and is filed as one in `ghidra/addresses.d/lane-j.json`.
---
## 10. Meeting lane Z's measured ledger
`findings/control-flow/tail-rng-ledger.md` landed while this lane was reading. The two results are
complementary and they agree where they touch, but the honest headline is that **they do not yet overlap**.
### 10.1 Where we agree
Lane Z, §6: *"all three RNG entry points in its 750-node direct-call closure load `[reg+0x16c]`."* I found
**three** sites in a **754**-node closure, all on `S+0x16c`, computed from an independently built call graph.
Two lanes, two graphs, same three sites and the same generator. (The 750/754 difference is four functions and
is not worth chasing; neither of us claims the closure is the whole truth — see §3.3 and lane Z's §7.)
### 10.2 Where we do not yet overlap, and that is the point
Lane Z, §7: *"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."*
That is exactly right, and it is the shape of the joint result: **lane Z has a measurement with no mechanism
for the 18–20 words it sees, and I have a mechanism with no measurement for the words I predict.** Neither
half is worth much alone.
* **My §0 formula is untested.** It predicts the cost of a *battle*, and no battle has occurred under an
instrument. It is a hypothesis in exactly lane K's sense.
* **Lane Z's 18–20 words are all inside `ProcessTurn`**, and nothing models any of them. **§0.1 offers a
candidate mechanism for part of that gap that is not in anyone's model**: `FUN_004f7670` (84 B) and
`FUN_007aa240` (944 B) both contain **inlined** MT draws and both are in `ProcessTurn`'s direct-call
closure at depth 4. Any accounting built by scanning for calls to the four RNG primitives has never counted
them. **Reading those two is the cheapest way to convert part of lane Z's 18–20 into an explained number,
and `FUN_004f7670` is 84 bytes.**
### 10.3 The workload that would test §0
Lane Z's instrument is already in place; what is missing is a save that reaches the resolver. From this
lane's reading, the gates are known exactly, so the workload can be specified rather than guessed:
1. **A real battle.** `ApplyEncounterResult` is a whole-function no-op while `res->+0x4 != 0`, which lane Z
measured as set on every encounter of `ref-turn2`. The resolver runs only on `res->+0x4 == 0`.
2. **Then the resolver's own gates.** A battle alone predicts **zero** words: R1 needs `res->+0xa8 != +0xac`
(a node cannon actually flung something) and R2/R3 need at least one combatant with a non-zero
salvage-stat slot and at least one candidate surviving `FUN_0078f530`.
So the first measurable prediction is the cheap one: **a plain fleet battle with no node cannon and no
salvageable wrecks should move the generator by exactly the same 18–20 words as a peaceful turn.** If lane Z
measures a battle turn costing more than a peaceful one *without* a node cannon or a back-engineering
candidate present, §0 is wrong and the missing draw is somewhere in `FUN_007d5a00`'s six unread callees
(§8, tier 2) — reached, per §3.3, only through one of the 352 indirect call sites I did not resolve.
### 10.4 A correction to lane Z's §7
Lane Z carries the resolver as **7499 B**, taken from lane K's brief. It is **7,641 bytes** —
0x007d5af0..0x007d78c8. Ghidra's 7,499 ends mid-instruction (§1). Nothing in lane Z's result depends on the
number; recorded so it stops propagating.
### 10.5 What lane Z settles for me
Two of its results remove uncertainty from this doc rather than adding it:
* **The generator does not move outside the turn pipeline** (§1.1: entry position equals the previous
post-turn autosave position, exactly, three times). So any words §0 predicts will show up in the bracket
and cannot be lost to a renderer or a UI poll.
* **`OnAllCombatDone_Tail` runs on every End Turn** (§3), which was lane K's labelled hypothesis. That makes
the resolver's *reachability* per turn a settled fact and reduces §0 to a question purely about its gates.