From 8dda49cfae5eedb95510f06e03dbde971c79025f Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 09:37:02 -0400 Subject: [PATCH] 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. --- findings/control-flow/combat-resolver.md | 683 +++++++++++++++++++++++ ghidra/addresses.d/lane-j.json | 124 ++++ 2 files changed, 807 insertions(+) create mode 100644 findings/control-flow/combat-resolver.md create mode 100644 ghidra/addresses.d/lane-j.json diff --git a/findings/control-flow/combat-resolver.md b/findings/control-flow/combat-resolver.md new file mode 100644 index 0000000..628097d --- /dev/null +++ b/findings/control-flow/combat-resolver.md @@ -0,0 +1,683 @@ +# 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 +// +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` 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 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` 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 = ** 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`** — 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` — 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. diff --git a/ghidra/addresses.d/lane-j.json b/ghidra/addresses.d/lane-j.json new file mode 100644 index 0000000..8b149ca --- /dev/null +++ b/ghidra/addresses.d/lane-j.json @@ -0,0 +1,124 @@ +{ + "entries": [ + { + "name": "CombatResolver_Run", + "addr": "0x007d5af0", + "convention": "thiscall", + "prototype": "void (CombatResolveContext* this) // PLAIN RET, no stack args. THE COMBAT RESOLVER under phase 6 of StrategyServer::OnAllCombatDone_Tail. Exactly one caller: StrategyServer::ApplyEncounterResult 0x007d8920 at 0x007d8d24, on the full-battle path only (res->+0x4 == 0). REAL BODY IS 0x007d5af0..0x007d78c8 = 7641 B; Ghidra's 7499 stops mid-instruction at 0x007d783b. SHAPE, read from the instruction stream: (1) prologue + 16 unconditional this-calls 0x007d5b1e..0x007d5c02; (2) ONE loop over enc->members, stride 0x44, 0x007d5c30..0x007d779d -- 7021 of the 7641 bytes, with four inner loops and no other outer control flow; (3) five more unconditional this-calls 0x007d77a3..0x007d77cd; (4) a victor block gated on ctx->+0xa34 != -1; (5) FUN_0079c740 and the epilogue. ONLY EIGHT NON-STACK STORES IN THE WHOLE BODY and exactly ONE indirect call (inside a _CxxThrowException path): it composes and posts per-player events and delegates every state mutation to callees. Strings it composes: EVENT__FIGHT, EVENTSUM_, EVENTMSG_<...>, EVENT_TRADERAIDERS, EVENT_COMBAT_OBSERVED, EVENT_DEFEAT, EVENT_VICTORY, EVENT_ENGAGED, EVENT_STATION_KILLED, and the ENTITYVICTORY/ENTITYDEFEAT/UNRESOLVED outcome tokens. DRAWS NO RNG ITSELF -- see CombatResolve_NodeCannon and CombatResolve_SalvageBackEng", + "status": "verified", + "source": "findings/control-flow/combat-resolver.md" + }, + { + "name": "CombatResolveContext_Ctor", + "addr": "0x007b8460", + "convention": "thiscall", + "prototype": "CombatResolveContext* (CombatResolveContext* this, StrategyServer* S /*the S frame*/, Encounter* enc, Game::EncounterResults* res) // built as a ~0xea0-byte STACK local at [ebp-0xea0] in StrategyServer::ApplyEncounterResult, immediately before the resolver call. Field assignment read from the instruction stream: this->+0x00 = S; this->+0x04 = operator new(0x5c) then FUN_005a13f0 (a per-player lookup object); this->+0x08 = enc; this->+0x0c = res; this->+0x10 = 0; this->+0x14 = 0 (byte); this->+0x18 = an empty std::string; this->+0x38 = FUN_00536890. Further fields the resolver uses: +0x290/+0x330/+0x430 per-player int arrays indexed by PlyrIdx*4; +0x7b0 + PlyrIdx*0x10 a per-player vector; +0x9b0 + PlyrIdx*4 the posted-event pointer; +0xa34 the winner PlyrIdx (-1 = none); +0xe7c = FUN_00787690(enc), set by the resolver's first act", + "status": "verified", + "source": "findings/control-flow/combat-resolver.md" + }, + { + "name": "CombatResolve_NodeCannon", + "addr": "0x007bb530", + "convention": "thiscall", + "prototype": "void (CombatResolveContext* this) // RNG SITE 1 OF 3 in the combat resolver. Exactly one caller (the resolver, unconditionally, at 0x007d5be2), so it runs ONCE PER RESOLVED BATTLE. Body: return with NO DRAW if res(ctx+0xc)->+0xa8 == +0xac (the flung-entity vector is empty); else build a candidate destination list = every StarSystem in S->Systems (S+0x44/+0x48) with sys->+0xc5 == 0 and sys != enc->+0xc, sort it by distance from enc->+0x1c..+0x24 (FUN_00796590 with a 16-byte functor), TRUNCATE TO 3 (FUN_00459f70); if the list is non-empty draw EXACTLY ONE RNG_NextInt at 0x007bb69b with ecx = S->+0x16c + 4 and bound n = count-1 passed BY POINTER. NextInt is inclusive on [0,n], so a normal galaxy gives n = 2, mask = 3, and a y&3 == 3 draw is rejected: 1 MT word with p = 3/4, 2 with p = 3/16, mean 4/3 words. Then per flung entity HandleMap::Resolve(S+0x84, h) and FUN_007bb420 groups by (destination, obj->+0x10) into a 0x18-stride vector; the tail posts EVENT_NODECANNON_FLINGS and EVENT_NODECANNON_KILLS with no further draw", + "status": "verified", + "source": "findings/control-flow/combat-resolver.md" + }, + { + "name": "CombatResolve_SalvageBackEng", + "addr": "0x007a7f30", + "convention": "thiscall", + "prototype": "void (CombatResolveContext* this) // RNG SITES 2 AND 3 in the combat resolver. Exactly one caller (the resolver, unconditionally, at 0x007d77c8, AFTER the per-member loop), so it runs once per resolved battle -- but ITS OWN BODY IS A LOOP OVER THE ENCOUNTER MEMBERS, so the rolls are per combatant. WARNING: Ghidra sizes it 2670 B, ending at 0x007a89be, which CUTS OFF the outer back-edge at 0x007a89ab; the real body ends at 0x007a89cd and a dump that stops at Ghidra's size makes the whole outer loop read as straight-line code operating on members[0]. Structure: (1) 0x007a7f79..0x007a7f88 zero 32 slots of 0x10 at [ebp-0x310], a per-PlyrIdx 3-float salvage stat; (2) 0x007a7fc0..0x007a8071 per member, gate FUN_00787350(player), fill that player's slot via FUN_0078bcf0(ctx, player, slot), sticky flag [ebp-0x39d]; return with ZERO draws if the flag is clear (0x007a807e) or the member vector is empty (0x007a80a6); (3) OUTER LOOP 0x007a80ac..0x007a89ab over members, counter [ebp-0x3c4] -- per member: player = S->Players[m->+0x28], slot = [ebp-0x310] + PlyrIdx*0x10, skip with no draw if all three floats are 0.0 (0x007a8105), RESET the {void* def; float p} candidate vector [ebp-0x3d4]/[ebp-0x3d0]/[ebp-0x3cc] at 0x007a810d, rebuild it in the inner loop 0x007a8150..0x007a8406, then (4) ROLL LOOP 0x007a8452..0x007a8693 -- one INLINED Mars::RNG::NextFloat (see CombatResolve_SalvageBackEng_RollSite) per candidate whose def != 0, whose p > 0.0f, and for which FUN_0078f530(player, def, &out) is true; success is p >= roll (fcom + test ah,1, so equality succeeds); on success it composes \"SPRJ_BACKENG_\" + def->+0x20 and calls SpecialProject_UnlockRandomForPlayer, which draws one more word. No other RNG in the body: no direct call to RNG_NextInt/NextFloat/Chance and no second inlined draw anywhere in the 2782 real bytes", + "status": "verified", + "source": "findings/control-flow/combat-resolver.md" + }, + { + "name": "CombatResolve_SalvageBackEng_RollSite", + "addr": "0x007a84bd", + "convention": "site", + "prototype": "AN INLINED Mars::RNG::NextFloat, byte-for-byte the body of RNG_NextFloat 0x0047d830: esi = S->+0x16c; if (esi->+0x9c8 /*left*/ == 0) RNG_Twist(esi+4) at 0x007a84cf; y = *esi->+0x9c4 /*next*/, next += 4, left--; temper with 0xff3a58ad and 0xffffdf8c; fild with the +2^32 fixup at 0x009e61b8, multiply by the 1/(2^32-1) double at 0x009e61b0, store as float32. THE CAMPAIGN'S RNG SWEEPS CANNOT SEE THIS: 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 two tempering immediates finds FOURTEEN game functions with inlined MT draws besides the four RNG primitives: 0x004b1f20 (x4), 0x004f7670, 0x00507ac0 (x12), 0x005232a0, 0x006ec720, 0x006f65f0, 0x006f7890, 0x0079f7d0, 0x007a7f30, 0x007aa240, 0x007c2fa0 (x4), 0x007c4140, 0x008cca30, 0x008e6e30 (x2). Of those, 0x004f7670 and 0x007aa240 are in the direct-call closure of StrategyServer::ProcessTurn and 0x007a7f30 is in the closure of OnAllCombatDone_Tail -- three strategic-turn RNG sources that no call-graph accounting has counted", + "status": "verified", + "source": "findings/control-flow/combat-resolver.md" + }, + { + "name": "SpecialProject_UnlockRandomForPlayer", + "addr": "0x007a0540", + "convention": "thiscall", + "prototype": "std::string* (StrategyServer* S /*ecx, the S frame*/, std::string* outName, std::string* keyPrefix, ServerPlayer* player) // RET 0xc. If player == 0 it returns an empty string with NO DRAW; otherwise it forwards to SpecialProject_PickRandomAvailable(S->+0x160, outName, keyPrefix, player->PlyrIdx(+0x28), S->+0x16c). Reached only from CombatResolve_SalvageBackEng on a successful back-engineering roll, with keyPrefix = \"SPRJ_BACKENG_\" + the destroyed design's tag", + "status": "verified", + "source": "findings/control-flow/combat-resolver.md" + }, + { + "name": "SpecialProject_PickRandomAvailable", + "addr": "0x00852d30", + "convention": "thiscall", + "prototype": "std::string* (void* projectMgr /*= StrategyServer+0x160*/, std::string* outName, std::string* keyPrefix, int plyrIdx, Mars::RNG* rng) // RET 0x10. RNG SITE 3 in the combat resolver's subtree. Builds a candidate vector via FUN_0059ec00 from keyPrefix; IF IT IS EMPTY it returns an empty string with NO DRAW; otherwise draws EXACTLY ONE RNG_NextInt at 0x00852ec7 (ecx = rng+4, bound n = count-1 by pointer, inclusive), marks the chosen element's per-player byte at elem[plyrIdx]++ and returns its name via FUN_008c97a0. Because the bound is inclusive and NextInt rejects on (y & mask) > n, the expected MT-word cost is 2^ceil(log2(count)) / count -- exactly 1 only when count is a power of two", + "status": "verified", + "source": "findings/control-flow/combat-resolver.md" + }, + { + "name": "CombatPlayerStats_TacReportAt", + "addr": "0x00456c40", + "convention": "thiscall", + "prototype": "Game::TacReport* (Game::CombatPlayerStats* this, int i) // RET 4. return (Game::TacReport*)(this->+0x04 + i * 0x94). Called by the combat resolver's inner loop B at 0x007d6f43", + "status": "verified", + "source": "findings/control-flow/combat-resolver.md" + }, + { + "name": "CombatPlayerStats_TacReportCount", + "addr": "0x00456c20", + "convention": "thiscall", + "prototype": "int (Game::CombatPlayerStats* this) // plain RET. return (this->+0x08 - this->+0x04) / 0x94, by the 0xdd67c8a7 add-back / sar 7 reciprocal. Called by the combat resolver at 0x007d6f2b and 0x007d702d", + "status": "verified", + "source": "findings/control-flow/combat-resolver.md" + }, + { + "name": "sizeof_Game_TacReport", + "convention": "layout", + "offset": "0x94", + "prototype": "sizeof(Game::TacReport) -- container stride, ENUMERATED TWICE and independently: the `imul eax,eax,0x94` in CombatPlayerStats_TacReportAt 0x00456c46, and the 0xdd67c8a7 add-back / sar 7 reciprocal divide in CombatPlayerStats_TacReportCount 0x00456c27. NOT sized by what the code touches. FLAG: objects/streams.json gives Game::TacReport 20 fields (two embedded Game::TacReportEvents of 0x20 each plus 18 scalars) which with a vptr accounts for at most 0x8c, so roughly 8 bytes are members the serializer never names -- carried, not named, in the sense of earned-rule 7. Not resolved here", + "status": "verified", + "source": "findings/control-flow/combat-resolver.md" + }, + { + "name": "Game_CombatPlayerStats_off_TacReports", + "convention": "layout", + "offset": "0x04", + "prototype": "Game::CombatPlayerStats+0x04/+0x08/+0x0c = std::vector (stride 0x94), read off both accessors 0x00456c20 and 0x00456c40. sizeof(Game::CombatPlayerStats) is already verified at 0x24, and its stream schema writes RPBon/RPBonT/SavBonus/MaintHF BEFORE TacReports -- so this vector sits at +0x04, ahead of every scalar the serializer emits first. A live instance of lane Q's rule: OFFSET ORDER IS NOT WRITE ORDER; align against objects/streams.json, never against an offset-sorted view", + "status": "verified", + "source": "findings/control-flow/combat-resolver.md" + }, + { + "name": "CombatResolve_TurnResultsWriteSite", + "addr": "0x007ba140", + "convention": "site", + "prototype": "site in FUN_007b9df0, reached from the combat resolver as 0x007d5af0 -> FUN_007baef0 -> FUN_007b9df0 (depth 2), on the arm that also posts EVENT_INDSYS_SURRENDERS_COMBAT and writes the winner index. `mov ecx,[esi+0x28]; imul ecx,ecx,0x11c; mov edx,[ebp-0x14]; mov eax,[edx+0x2f4]; lea ecx,[ecx+eax*1+0x90]; call 0x007a6630` -- a push_back of a 0x20-byte record (vptr 0x00a23c54) into the SETurnResults ACCUMULATOR at StrategyServer+0x2f4, member +0x90, indexed by PlyrIdx*0x11c. COMBAT WRITES TURN RESULTS: lane K's combat-done-tail.md 5A attributes the phase-6 write to ApplyEncounterResult 0x007d8f9e at member +0x24; this is a SECOND member written from inside the resolver's subtree. FUN_007b9df0 has seven direct callers (0x007baef0, 0x007bd490, 0x007bd520, 0x007bd930, 0x007be870, 0x007d0580, StrategyServer::ProcessTurn), so it is not only a ProcessTurn-phase-1 function. Not serialized (SETurnResults has no Read/Write pair) but live in memory at autosave time and dispatched to the client as strategy-event 0x25", + "status": "verified", + "source": "findings/control-flow/combat-resolver.md" + }, + { + "name": "Game_EncounterResults_off_Participants", + "convention": "layout", + "offset": "0x98", + "prototype": "Game::EncounterResults+0x98/+0x9c = a 4-byte-stride vector of participant handle ids. The combat resolver's per-member loop linear-scans it for the member's ServerPlayer->+0x04 at 0x007d5c96 and SKIPS THE WHOLE MEMBER when absent -- so a combatant present in the Encounter but not in this vector contributes nothing to the resolver. LABELLED HYPOTHESIS on the element type: it is a 4-byte scalar and matches the schema's `carr`, but the schema is in write order and no save observed here fixes the offset", + "status": "hypothesis", + "source": "findings/control-flow/combat-resolver.md" + }, + { + "name": "Game_EncounterResults_off_PlayerStats", + "convention": "layout", + "offset": "0x18", + "prototype": "Game::EncounterResults+0x18/+0x1c = std::vector, stride 0x24 (verified sizeof), INDEXED BY THE ENCOUNTER MEMBER INDEX, not by PlyrIdx: the resolver computes base + i*0x24 with `lea edx,[eax+eax*8]; lea ebx,[eax+edx*4]` at 0x007d6f11 using the same i that drives the member loop, and skips the block when (+0x1c - +0x18)/0x24 <= i", + "status": "verified", + "source": "findings/control-flow/combat-resolver.md" + }, + { + "name": "Game_EncounterResults_off_NodeCannonFlung", + "convention": "layout", + "offset": "0xa8", + "prototype": "Game::EncounterResults+0xa8/+0xac = a 4-byte-stride vector of entity handles flung by a node cannon. Emptiness of THIS vector is the sole first gate on RNG site 1 of the combat resolver: CombatResolve_NodeCannon returns at 0x007bb575 with no draw when +0xa8 == +0xac. Each handle is resolved through the global HandleMap at GetGame()+0x84 (here reached as S+0x84)", + "status": "verified", + "source": "findings/control-flow/combat-resolver.md" + } + ] +}