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.
This commit is contained in:
parent
729cfc014c
commit
8dda49cfae
2 changed files with 807 additions and 0 deletions
683
findings/control-flow/combat-resolver.md
Normal file
683
findings/control-flow/combat-resolver.md
Normal file
|
|
@ -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<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.
|
||||
124
ghidra/addresses.d/lane-j.json
Normal file
124
ghidra/addresses.d/lane-j.json
Normal file
|
|
@ -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_<TYPE>_FIGHT, EVENTSUM_<TYPE>, 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<T*> 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<Game::TacReport> (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<int>`, 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<Game::CombatPlayerStats>, 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue