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

46 KiB
Raw Permalink Blame History

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
// 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.

  1. 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.