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.
46 KiB
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
- 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.
- The node-cannon site does not consume an integral number of words.
RNG_NextInttakes the bound by pointer and is inclusive on[0, n]; heren = min(3, candidates) − 1, which in any galaxy with ≥ 4 valid systems is2. The rejection mask is3, so ay & 3 == 3draw 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. - Every back-engineering candidate costs exactly one word whether it succeeds or fails.
NextFloathas no rejection loop. So the R2 count is the number of candidates, not the number of unlocks — an observer countingEVENT_SPRJBACKENG_UNLOCKEDwill undercount badly. - The salvage step is per combatant, not per battle. A three-way battle rolls three candidate lists.
- A
Twistoccurs 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:
- 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)— ifk == -1the whole member is skipped. Then a linear scan ofres->+0x98 .. +0x9c(4-byte stride) forp->+0x04; if the id is absent the whole member is skipped. So a combatant present in theEncounterbut not in the result's participant list contributes nothing. - 0x007d5ca9–0x007d5d76 — four empty
std::stringlocals at[ebp-0x274],[ebp-0x228],[ebp-0x1f0],[ebp-0x20c]: the event key, the summary, the message, and a spare. - 0x007d5d76–0x007d6443 — the event-key composition. If
FUN_0080b250(enc)→ theEVENT_TRADERAIDERSarm. Otherwise, ifenc->+0x38 != 0,PickDominantEncounterType(enc->+0x38)(0x004f4c40, already named by lane K) thenFUN_004f4970turns 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 byFUN_00785ad0(ctx, encType)returning 2 / 1 / 0. Each key goes throughFUN_008c9690(a string-table lookup that returns a bool) and, on a hit,FUN_008c8eb0(a_snprintfinto a 0x100 buffer with five spare arguments); on a miss it logs at level 2. - 0x007d6443–0x007d6790 — outcome selection. A chain of
FUN_00787600/FUN_00787550tests picks one ofEVENT_COMBAT_OBSERVED,EVENT_DEFEAT,EVENT_VICTORY,EVENT_ENGAGED, all converging at 0x007d6784. - 0x007d6790–0x007d68cb — outcome write-back.
if ([ebp-0x3f4]) record->+0x10 = outcome— one of only eight non-stack stores in the function. Thenif (ctx->+0xa34 == PlyrIdx)the winner arm. - 0x007d68cb–0x007d6dee — loss accounting. Per player,
(ctx->+0x430[p] − ctx->+0x290[p]) − ctx->+0x330[p]as a 64-bitcdq/sub/sbbclamped at 0, plus a dozen more terms, into a twelve-slot, 0x10-stride stack array at[ebp-0x1d4]. - 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 byFUN_008cea10. This builds the human-readable damage summary. - Inner loop B, 0x007d6f40–0x007d7034 — over
res->+0x18/+0x1c[i].TacReports.res->+0x18is avector<Game::CombatPlayerStats>of stride 0x24 (lea edx,[eax+eax*8]; lea ebx,[eax+edx*4]), indexed by the member indexi, not byPlyrIdx; the block is skipped when(res->+0x1c − res->+0x18)/0x24 <= i. The inner container is reached throughCombatPlayerStats_TacReportCount0x00456c20 andCombatPlayerStats_TacReportAt0x00456c40 — see §5. Per entry: gate onentry->+0x48,HandleMap::Resolve(S+0x84, entry->+0x04), then more string work. - 0x007d7040–0x007d7208 — a second statistics pass using
FUN_00786dd0,FUN_007437e0,FUN_00786ec0,FUN_00785930,FUN_00820680,FUN_00790dc0,FUN_00857440,FUN_0079d040. - 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. - Inner loops C and D, 0x007d72e8–0x007d749d. C (0x007d7340) builds an MSVC
_Tree(operator newfor the head node, self-pointers at +0x00/+0x04/+0x08,+0x10 = +0x11 = 1) and inserts every element of a 4-byte-stride vector viaFUN_004a0950/FUN_0054dc60; D (0x007d73b0) walks the tree in order withFUN_0080ceb0(_Tree::_Inc). - 0x007d74a3–0x007d7773 —
EVENT_STATION_KILLED, posted through the sameFUN_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:
- reserve a
vector<StarSystem*>at[ebp-0xf0]forS->Systems.size()(FUN_00483410); - push back every system with
sys->+0xc5 == 0andsys != enc->+0xc(FUN_0059f1a0); 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 withsub esp,0x10;if (count > 3) FUN_00459f70(&cand, 3)— truncate to the three nearest;- 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.
- per flung handle,
HandleMap::Resolve(S+0x84, h)thenFUN_007bb420(&out, chosenSystem, obj)groups by(destination, obj->+0x10)into a 0x18-stride vector; the tail postsEVENT_NODECANNON_FLINGSandEVENT_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) andCombatResolve_SalvageBackEng(Twist×1, which §0.1 shows is the lazy twist of an inlinedNextFloat).RNG_NextFloat,RNG_ChanceandRNG_Seedare 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).sizeofis settled at 0x94; the field list is not. Game::EncounterResultsbeyond+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.jsonlists 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,+0x430indexed byPlyrIdx. 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_0078bcf0andFUN_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'scarr<int>— the stride and use are verified, the type identification is a labelled hypothesis and is filed as one inghidra/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) andFUN_007aa240(944 B) both contain inlined MT draws and both are inProcessTurn'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, andFUN_004f7670is 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:
- A real battle.
ApplyEncounterResultis a whole-function no-op whileres->+0x4 != 0, which lane Z measured as set on every encounter ofref-turn2. The resolver runs only onres->+0x4 == 0. - 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 survivingFUN_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_Tailruns 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.