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