lane V2: vtable inversion — resolve indirect call edges image-wide
Every call-graph result in this repo was computed over direct (E8) edges.
5,045 of the 5,207 functions named by a vftable slot have zero direct call
sites, so all of those results were lower bounds. Lane Z's dominant RNG
consumer hung off exactly such an edge.
tools/vtable_map.py builds, from the RTTI walk plus a full sweep to the next
function start (rule 17):
* vftable -> class -> sub-object offset -> slot -> target, and its inverse
* the class hierarchy from the RTTI base lists, so an abstract interface
with one concrete override resolves uniquely
* constructor-derived member typing (ctor result -> [this+d])
* the slot index at every indirect call site, with a backward register
resolver that refuses to cross a branch target rather than guess
* `this`-carrier spans and this/member call-graph propagation of class
Validation (12/12): rediscovers ServerTradeManagerImpl slot 10 ->
GenerateTradeRaidEncounters from the dispatch at 0x007d8469 with nothing
hand-fed, and re-derives the *Impl rule for both managers. Receiver-class
pinning reaches only 2.5% of the 6,398 virtual sites, at 0.6% out-of-range
against a 70% chance baseline; the displacement-only route measured worse
than random (81% vs 58%) and is rejected outright.
Closes lane K's tier-4 blind spot: all nine phase-23 calls and both phase-33
calls named. Four of the eleven reach a draw on the strategic generator
(StrategyServer+0x16c) at eight instruction-verified sites, none ever
observed firing — so "the tail draws nothing" is a property of eight turns,
not of the code. Also resolves the nine parked inlined-draw functions to
their vtable roots (correcting how that was recorded: none is itself in a
vftable; their topmost ancestors are), and finds 14,958 inter-function tail
jump edges without which three of them look like dead code.
This commit is contained in:
parent
7645399f97
commit
648028db67
5 changed files with 1961 additions and 0 deletions
380
findings/control-flow/indirect-edges.md
Normal file
380
findings/control-flow/indirect-edges.md
Normal file
|
|
@ -0,0 +1,380 @@
|
||||||
|
# Indirect call edges — the vtable inversion, and what it closes
|
||||||
|
|
||||||
|
Lane V2, 2026-09-08. Program `sots` / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs.
|
||||||
|
|
||||||
|
**Why this lane exists.** Lane Z's live hooking found that the single largest RNG consumer of a strategic
|
||||||
|
turn, `ServerTradeManager::GenerateTradeRaidEncounters` 0x00893290, has **zero direct call sites** in the
|
||||||
|
41,411-function image. Its only reference is the `Game::ServerTradeManagerImpl` vftable entry at 0x00a31b9c,
|
||||||
|
dispatched by `call edx` at 0x007d8469 — **one instruction before** a direct call lane I's closure did
|
||||||
|
follow. Lane I said plainly that its closure was direct-edge only. That caveat turned out to be
|
||||||
|
load-bearing, and it is systemic, not a one-off.
|
||||||
|
|
||||||
|
**How systemic.** Measured here:
|
||||||
|
|
||||||
|
| | count |
|
||||||
|
|---|---|
|
||||||
|
| functions named by at least one vftable slot | **5,207** |
|
||||||
|
| of those, functions with **zero** direct (`E8 rel32`) call sites | **5,045** |
|
||||||
|
| indirect call sites image-wide | 17,577 |
|
||||||
|
| of those, proven virtual dispatches (a vptr load then a slot load) | **6,398** |
|
||||||
|
|
||||||
|
Every reachability claim, closure size and "no caller" result this campaign has published was computed over
|
||||||
|
direct edges. **All of them are lower bounds.** This document is the tool that lifts that, the honest
|
||||||
|
measurement of how far it lifts it, and the three questions it answers.
|
||||||
|
|
||||||
|
Tool: `tools/vtable_map.py` (build → `dumps/vtables.json`). Ghidra writeback:
|
||||||
|
`scripts/lane_v2_writeback.py`. Addresses: `ghidra/addresses.d/lane-v2.json`. Curated receiver assertions:
|
||||||
|
`ghidra/vtable-owners.json`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Validation first — the known case, rediscovered blind
|
||||||
|
|
||||||
|
`uv run python3 tools/vtable_map.py validate`, 12 checks, 12 pass. Nothing below was given to the tool; the
|
||||||
|
only inputs are the PE, Ghidra's 41,089 function starts, and the RTTI walk `tools/rtti_map.py` already
|
||||||
|
produced.
|
||||||
|
|
||||||
|
```
|
||||||
|
V1 the known case -- lane Z's virtual edge, rediscovered blind
|
||||||
|
PASS site 0x007d8469 classified virtual virtual
|
||||||
|
PASS slot recovered = 10 10
|
||||||
|
PASS receiver = member +0x158 {'k':'field','base':'esi','disp':344,'this':True}
|
||||||
|
PASS receiver class = Game::ServerTradeManagerImpl
|
||||||
|
PASS target = 0x00893290 GenerateTradeRaidEncounters
|
||||||
|
PASS 0x00893290 named by exactly one vtable slot [0x00a31b74, ServerTradeManagerImpl, +0, slot 10]
|
||||||
|
PASS 0x00893290 has zero direct call sites 0
|
||||||
|
|
||||||
|
V2 the *Impl rule -- abstract interface, one concrete override
|
||||||
|
PASS Game::ServerTradeManager is abstract 21/22 purecall
|
||||||
|
PASS exactly one derived class ['Game::ServerTradeManagerImpl']
|
||||||
|
PASS Impl overrides every slot 22 slots
|
||||||
|
PASS Game::ServerSpyManager is itself concrete (no *Impl) 18 slots, derived=None
|
||||||
|
PASS ServerSpyManager derives IServerSpyManager/ISpyManager/IStreamable
|
||||||
|
```
|
||||||
|
|
||||||
|
The chain the tool walks, with nothing hand-fed:
|
||||||
|
|
||||||
|
```
|
||||||
|
0x007d845d mov ecx,[esi+0x158] <- receiver: member +0x158 of a proven `this` carrier
|
||||||
|
0x007d8463 mov eax,[ecx] <- vptr load: this is what makes it a virtual dispatch and not
|
||||||
|
a function pointer
|
||||||
|
0x007d8465 mov edx,[eax+0x28] <- slot 0x28/4 = 10
|
||||||
|
0x007d8469 call edx
|
||||||
|
```
|
||||||
|
|
||||||
|
and then the *member* typing, which is the part that turns a slot number into a function:
|
||||||
|
|
||||||
|
```
|
||||||
|
StrategyServer ctor 0x007d78d0 (identified by its vptr stores of 0x00a26084 at +0 and 0x00a26034 at +4)
|
||||||
|
0x007d7d81 call 0x00858f70 ; mov [esi+0x158],eax 0x00858f70 installs vftable 0x00a31b74
|
||||||
|
-> +0x158 : Game::ServerTradeManagerImpl*
|
||||||
|
0x007d7d8e call 0x00832a30 ; mov [esi+0x15c],eax 0x00832a30 installs vftable 0x00a3073c
|
||||||
|
-> +0x15c : Game::ServerSpyManager*
|
||||||
|
```
|
||||||
|
|
||||||
|
**Second, independent witness, under a non-trivial transform.** `0x007dcf90` is, per the RTTI inverse map,
|
||||||
|
`Game::StrategyServer` vftable 0x00a26034 slot 14 at sub-object **+4**. It calls the same two constructors
|
||||||
|
and stores their results at `[esi+0x154]` and `[esi+0x158]` — exactly four bytes lower, which is what a
|
||||||
|
`this` of `obj+4` requires. Two constructions, two frames, one answer. This also re-derives lane T's
|
||||||
|
`StrategyServer_off_TradeManager = 0x154` (S+4 frame) from a completely different direction.
|
||||||
|
|
||||||
|
**The `*Impl` rule, re-derived.** `Game::ServerTradeManager` (vftable 0x00a311a4, 22 slots) has 21 slots
|
||||||
|
pointing at `purecall` 0x00924fb0 — an abstract interface. The RTTI base lists give it exactly one derived
|
||||||
|
class, `Game::ServerTradeManagerImpl`, whose vftable 0x00a31b74 overrides all 22. So slot *N* of that
|
||||||
|
interface resolves **uniquely**. `Game::ServerSpyManager` is the opposite shape and is worth stating because
|
||||||
|
the naming misleads: there is no `ServerSpyManagerImpl`. `ServerSpyManager` is itself concrete (18 slots,
|
||||||
|
none purecall) over `IServerSpyManager` / `ISpyManager` / `Mars::IStreamable`; the `?$StreamableHelper@V`**`IServerSpyManager`**`@Game@@` template is the tell that the interface exists but the implementation is
|
||||||
|
not separately named.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. False-positive rate, stated the way lane X stated its scanner's
|
||||||
|
|
||||||
|
Two things are being measured and they have very different precision. **Say which one a claim rests on.**
|
||||||
|
|
||||||
|
### 2a. The map (exact, no inference)
|
||||||
|
|
||||||
|
vftable → class → sub-object offset → slot → target, and its inverse, come from walking
|
||||||
|
`TypeDescriptor ← COL ← vftable[-1]`. 2,172 vftables, 1,924 type descriptors. **No false positives are
|
||||||
|
possible here** — a vftable is only recognised when a Complete Object Locator sits at `[vftable-4]` and its
|
||||||
|
`pTypeDescriptor` lands on a real mangled name. This half is complete and I make no hedged claims about it.
|
||||||
|
|
||||||
|
### 2b. Slot recovery at call sites (exact, but with declared refusals)
|
||||||
|
|
||||||
|
Of 17,577 indirect call sites:
|
||||||
|
|
||||||
|
| kind | n | what it is |
|
||||||
|
|---|---|---|
|
||||||
|
| `call-abs` | 7,415 | `call [disp32]` — import thunks and global function pointers, **not** vtable dispatch |
|
||||||
|
| **`virtual`** | **6,398** | vptr load proven, **slot index exact** |
|
||||||
|
| `vptr-unresolved` | 1,653 | the register holding the vptr has no provable definition (a branch target intervenes) |
|
||||||
|
| `call-reg-unresolved` | 1,295 | same, for `call reg` |
|
||||||
|
| `not-vptr` | 545 | the "vptr" register was not loaded from `[obj+0]` — a function-pointer member, not a vtable |
|
||||||
|
| `call-reg-nonmem`, `non-slot-disp`, `vptr-unmodelled`, `vptr-global` | 271 | declared refusals |
|
||||||
|
|
||||||
|
The backward resolver **refuses to cross an intra-function branch target** and stops at any opcode not in
|
||||||
|
its write-set model. That is why 2,948 sites report "unresolved" rather than a guess: a definition separated
|
||||||
|
from its use by a label is not a definition proven to reach it (rule 4's discipline applied to registers
|
||||||
|
instead of to `if`s).
|
||||||
|
|
||||||
|
### 2c. Receiver typing (hard; only 2.5% of virtual sites, at ~99% precision)
|
||||||
|
|
||||||
|
This is the genuinely difficult half — it is the devirtualization problem, and **it is not solved here.**
|
||||||
|
Of the 6,398 virtual sites:
|
||||||
|
|
||||||
|
| route | sites | slot out of the pinned class's vtable range | same test against a random vtable |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `self` — receiver is a proven `this` carrier, enclosing class known | 144 | **0.7 %** (1) | 70.3 % |
|
||||||
|
| `field` — member of a proven `this` carrier, both classes known | 19 | **0.0 %** | 43.3 % |
|
||||||
|
| `field-nonthis` — member of a register not proven to be `this` | 850 | *not pinned* | |
|
||||||
|
| `stack` — receiver is `[ebp±d]`, an argument or local | 1,027 | *not pinned* | |
|
||||||
|
| `disp-only` — displacement matched image-wide, no owner class | 403 | **81.1 %** | 58.1 % |
|
||||||
|
| `unpinned` | 3,955 | | |
|
||||||
|
|
||||||
|
**163 of 6,398 sites (2.5%) get a receiver class.** On those the out-of-range falsification test fires on 1
|
||||||
|
site in 163 (**0.6%**) against a 70% chance baseline, so the routes that do resolve are trustworthy. The
|
||||||
|
`disp-only` route was **measured worse than picking a vtable at random** (81% vs 58% out-of-range) and is
|
||||||
|
therefore **rejected outright**, not merely flagged — this is rule 9 with the ranker turned off entirely
|
||||||
|
rather than used as a filter. Its 403 sites are counted and discarded.
|
||||||
|
|
||||||
|
Three bugs the falsification test caught, each of which had produced confident wrong answers before it ran:
|
||||||
|
|
||||||
|
* treating `[ebp+8]` as a member of `this` (it is argument 1) — accounted for **every** out-of-range result
|
||||||
|
in the first `field` run;
|
||||||
|
* giving a constructor the sub-object offset of whichever vftable it installs, when a ctor always receives
|
||||||
|
the **complete** object — this registered every member at both `+d` and `+d+4` and made the class's own
|
||||||
|
member typings ambiguous one slot away;
|
||||||
|
* ending a `this`-carrier's span at the function's end rather than at the `pop esi` in the epilogue, which
|
||||||
|
silently deleted every carrier in every function with a standard epilogue.
|
||||||
|
|
||||||
|
**Honest summary: the call-site side stays partial.** The complete vftable→slot→target map is delivered and
|
||||||
|
exact; the slot index at 6,398 sites is delivered and exact; the receiver's *class* is delivered for 163 of
|
||||||
|
them. `ghidra/vtable-owners.json` exists so that one hand-verified assertion propagates through the
|
||||||
|
`this`-passing call graph and lights up a whole subtree — two entries were enough for everything in §3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `ServerTradeManager` phase 23 — lane K's tier-4 blind spot, closed
|
||||||
|
|
||||||
|
Lane K: *"Phase 23 is nine virtual calls in a row and not one of them is identified … the largest blind spot
|
||||||
|
in the map."* The tool reproduces lane K's byte-level transcription exactly and independently — same sites,
|
||||||
|
same order, same slot numbers, same two receivers — and then names the targets.
|
||||||
|
|
||||||
|
`uv run python3 tools/vtable_map.py resolve 0x007d92a0`:
|
||||||
|
|
||||||
|
| # | site | recv | slot | class | target |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 0x007d97a7 | S+0x158 | 14 (+0x38) | ServerTradeManagerImpl | **0x008590d0** |
|
||||||
|
| 2 | 0x007d97b6 | S+0x158 | 8 (+0x20) | ServerTradeManagerImpl | **0x0088e8d0** (one pushed arg) |
|
||||||
|
| 3 | 0x007d97c3 | S+0x158 | 12 (+0x30) | ServerTradeManagerImpl | **0x0088e920** |
|
||||||
|
| 4 | 0x007d97d0 | S+0x158 | 11 (+0x2c) | ServerTradeManagerImpl | **0x00848570** |
|
||||||
|
| 5 | 0x007d97dd | S+0x158 | 9 (+0x24) | ServerTradeManagerImpl | **0x00868060** |
|
||||||
|
| 6 | 0x007d97ea | S+0x158 | 7 (+0x1c) | ServerTradeManagerImpl | **0x0088ad60** |
|
||||||
|
| 7 | 0x007d97f7 | S+0x158 | 13 (+0x34) | ServerTradeManagerImpl | **0x0088ef80** |
|
||||||
|
| 8 | 0x007d9804 | S+0x158 | 15 (+0x3c) | ServerTradeManagerImpl | **0x0082cca0** |
|
||||||
|
| 9 | 0x007d9811 | **S+0x15c** | 13 (+0x34) | **ServerSpyManager** | **0x008877b0** |
|
||||||
|
|
||||||
|
and phase 33, the two lane K also listed:
|
||||||
|
|
||||||
|
| | site | recv | slot | class | target |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 10 | 0x007d989b | S+0x15c | 14 (+0x38) | ServerSpyManager | **0x0088db80** |
|
||||||
|
| 11 | 0x007d98a8 | S+0x15c | 15 (+0x3c) | ServerSpyManager | **0x00887f30** |
|
||||||
|
|
||||||
|
All eleven are now labelled and plate-commented in Ghidra (`ServerTradeManagerImpl_vslotN`,
|
||||||
|
`ServerSpyManager_vslotN`) with their provenance and their dispatch site.
|
||||||
|
|
||||||
|
**All eleven have zero direct call sites.** They were unreachable to every sweep this campaign has run.
|
||||||
|
|
||||||
|
### 3.1 Four of them can draw on the strategic generator
|
||||||
|
|
||||||
|
This is the part that matters beyond bookkeeping. Closures below are direct calls **plus inter-function tail
|
||||||
|
jumps** (see §5); "draws" means an RNG entry point or one of lane I's eleven inlined-draw functions is in the
|
||||||
|
closure.
|
||||||
|
|
||||||
|
| target | slot | closure | draws |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0x008590d0 | trade 14 | 143 | — |
|
||||||
|
| 0x0088e8d0 | trade 8 | 86 | — |
|
||||||
|
| 0x0088e920 | trade 12 | 193 | — |
|
||||||
|
| 0x00848570 | trade 11 | 188 | — |
|
||||||
|
| 0x00868060 | trade 9 | 185 | — |
|
||||||
|
| 0x0088ad60 | trade 7 | 189 | — |
|
||||||
|
| **0x0088ef80** | **trade 13** | 84 | **NextFloat, NextInt** |
|
||||||
|
| **0x0082cca0** | **trade 15** | 54 | **NextFloat, Chance** |
|
||||||
|
| **0x008877b0** | **spy 13** | 69 | **NextFloat, NextInt, Chance** |
|
||||||
|
| **0x0088db80** | **spy 14 (ph 33)** | 64 | **NextFloat, Chance** |
|
||||||
|
| 0x00887f30 | spy 15 (ph 33) | 230 | — |
|
||||||
|
|
||||||
|
And the draws are on **the strategic generator**, `StrategyServer+0x16c` — verified at the instruction, not
|
||||||
|
inferred from the call graph:
|
||||||
|
|
||||||
|
| draw site | in | generator load |
|
||||||
|
|---|---|---|
|
||||||
|
| 0x00887c8a `Chance` | 0x008877b0 (spy 13) | `mov ecx,[ecx+0x16c]` |
|
||||||
|
| 0x00840929 `Chance` | 0x008408e0 ← spy 13 | `mov ecx,[eax+0x16c]` |
|
||||||
|
| 0x00840a3c `Chance` | 0x008408e0 ← spy 13 | `mov ecx,[edx+0x16c]` |
|
||||||
|
| 0x008409c7 `NextInt` | 0x008408e0 ← spy 13 | (arg) |
|
||||||
|
| 0x0088dc43 `Chance` | 0x0088db80 (spy 14) | `mov ecx,[eax+0x16c]` |
|
||||||
|
| 0x0082cdb8 `Chance` | 0x0082cca0 (trade 15) | `mov eax,[eax+0x16c]` at 0x0082cda4, then `mov ecx,eax` |
|
||||||
|
| 0x00820e18 `NextFloat` | 0x00820ca0 ← trade 13 | `mov ecx,[reg+0x16c]`, `lea ecx,[ecx+4]` |
|
||||||
|
| 0x0088b613 `NextInt` | 0x0088b440 ← trade 13 | `mov ecx,[ecx+0x16c]` at 0x0088b5fc, `add ecx,4` |
|
||||||
|
|
||||||
|
**None of these has ever been observed firing.** Lane Z's boundary instrument measured **0 tail words on
|
||||||
|
every one of 8 turns**, so on those workloads every one of the four is gated off. That does not make them
|
||||||
|
absent; it makes them rule 6 — a path no save exercises is a hypothesis, and this is now a *named* one with
|
||||||
|
a hook site.
|
||||||
|
|
||||||
|
**This is the correction the tail ledger needs.** `tail-rng-ledger.md`'s "the tail draws nothing" is a
|
||||||
|
measured property of eight turns, not a property of the code. The tail contains at least **eight** draw
|
||||||
|
sites on the strategic generator behind four virtual slots, plus the node-line decay `Chance` lane Z already
|
||||||
|
found. Rule 18 applies directly: the next move is a hook on those four callees, not more reading — a word
|
||||||
|
count cannot distinguish "the trade/spy end-of-turn work found nothing to do" from "it never runs".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The RNG re-check over indirect edges
|
||||||
|
|
||||||
|
### 4.1 What did *not* change
|
||||||
|
|
||||||
|
Lane I's 22-site inventory and lane Z's zero-residual ledger are untouched. Nothing here adds a draw to the
|
||||||
|
turns lane Z measured; the residual is still zero and `GenerateTradeRaidEncounters` is still 16 of ~20 words.
|
||||||
|
The tool re-derives lane Z's edge from scratch (§1) rather than contradicting it.
|
||||||
|
|
||||||
|
### 4.2 The nine "unreachable" inlined-draw functions, resolved
|
||||||
|
|
||||||
|
Lane I parked nine of its eleven inlined-draw functions as reachable only through a vtable slot with no
|
||||||
|
direct caller. **Correction to how that was recorded:** none of the eleven is itself in a vftable — no dword
|
||||||
|
equal to any of those eleven addresses exists anywhere in the image. What is in a vftable is each one's
|
||||||
|
*topmost direct-call ancestor*. Resolved:
|
||||||
|
|
||||||
|
| inlined-draw fn | topmost ancestor | vtable slot it sits in |
|
||||||
|
|---|---|---|
|
||||||
|
| 0x004b1f20 | 0x00453e80 | `Game::CombatNetworkClient` slot 3 |
|
||||||
|
| 0x00507ac0 | 0x005348b0 | `Game::CrowRuinsEncounter` slot 11 |
|
||||||
|
| 0x005232a0 | 0x00533e10 | `Game::SwarmEncounter` slot 11 |
|
||||||
|
| 0x006ec720 / 0x006f65f0 / 0x006f7890 | 0x007cda40 / 0x007cf260 / 0x007cfd00 | `Game::StrategyNetworkServer` slots 4 / 7 / 3 |
|
||||||
|
| 0x0079f7d0 | 0x007612b0, 0x00784640 | `Game::StrategyNetworkClient` slots 3 and 6 |
|
||||||
|
| 0x007c2fa0 | 0x00664320 | `Game::StrategyLobbyScreen` slot 3 |
|
||||||
|
| 0x007c4140 | 0x007612b0 | `Game::StrategyNetworkClient` slot 3 |
|
||||||
|
| 0x007a7f30 | 0x00784640 | `Game::StrategyNetworkClient` slot 6 — **and in the tail's closure directly** |
|
||||||
|
| 0x007aa240 | 0x00784640 | — **in `ProcessTurn`'s closure directly** (lane I, depth 4) |
|
||||||
|
|
||||||
|
Three of these ancestries only exist once **inter-function jump edges** are included (§5); 0x007c2fa0 and
|
||||||
|
the `StrategyNetworkServer` group have no `E8` caller at all and are entered by a tail `jmp`.
|
||||||
|
|
||||||
|
`CrowRuinsEncounter` and `SwarmEncounter` are **not** `SVScriptObject`s — they derive from
|
||||||
|
`Game::CombatEncounterBase` and their offset-0 vtables have 16 slots. Their slot 11 is reachable only from a
|
||||||
|
dispatch on a `CombatEncounterBase`-family receiver, which is a different family from the scripted-scenario
|
||||||
|
hooks in §4.3. The slot-11 dispatch inside `ProcessTurn` is **not** a route to them.
|
||||||
|
|
||||||
|
### 4.3 The one genuinely new draw surface: the `SVScriptObject` hooks
|
||||||
|
|
||||||
|
`StrategyServer+0x1b4` (lane T's `StrategyServer_off_ScriptObject`, 0x1b0 in the S+4 frame) is dispatched:
|
||||||
|
|
||||||
|
* in `ProcessTurn`: 0x007dcb8e slot 4 (with `push 6` — a hook **id**), 0x007dcb97 slot 11, then
|
||||||
|
0x007dcbb6 slot 4, 0x007dcbbf slot 30;
|
||||||
|
* in `OnAllCombatDone_Tail`: 0x007d9767/0x007d9770 slots 4/25, 0x007d9783/0x007d978c slots 4/27,
|
||||||
|
0x007d9838/0x007d9841 slots 4/30.
|
||||||
|
|
||||||
|
Each block is guarded by `mov edi,[esi+0x1b4]; cmp edi,ebx; je` — the whole surface is optional, which is
|
||||||
|
why lane T and lane K both recorded it as null in a normal game.
|
||||||
|
|
||||||
|
30 classes derive from `Game::SVScriptObject`. The base's empty body is 0x0080c5a0, which makes the real
|
||||||
|
overrides countable:
|
||||||
|
|
||||||
|
| slot | non-stub overrides (of 30) | overrides whose closure reaches a draw |
|
||||||
|
|---|---|---|
|
||||||
|
| 4 | 14 | **11** — SVSOSlaversRefuel, SVSOPuppetMaster, SVSOSystemKiller, SVSOSwarm, SVSOTournament, SVSOJewelsOfTheCrown, SVSOCivilWar, SVSOUpstartApes, SVSOHolyLands, SVSOHiverInvasion, SVSOSots |
|
||||||
|
| 11 | 7 | **4** — SVSOCrowRuins, SVSOOrtgay, SVSOVonNeumann, SVSOCrowDefenders |
|
||||||
|
| 25 | 8 | **2** — SVSOSwarmQueen, SVSOProgressionWars |
|
||||||
|
| 27 | 0 | — |
|
||||||
|
| 30 | 1 | 0 |
|
||||||
|
|
||||||
|
Example path: `SVSOCrowRuins` slot 11 = 0x00518340 → 0x004f4210 → `Mars_RNG_IntRangeBell` 0x008e6d80 →
|
||||||
|
`RNG_NextInt` 0x004271c0.
|
||||||
|
|
||||||
|
**Two limits on this, stated plainly.** (a) The draws are at depth 2–4 and the generator arrives as an
|
||||||
|
argument (`lea ecx,[obj+4]`), so **which** generator these use is not established — it may be the strategic
|
||||||
|
one at `S+0x16c` or a different instance. (b) `StrategyServer+0x1b4`'s type is a hypothesis: the shape
|
||||||
|
(slot 30 exists, so ≥31 slots; the `push <id>` gate; lane T's independent reading) all point at
|
||||||
|
`SVScriptObject`, but **no constructor store into +0x1b4 was found**, so the class is inferred from the
|
||||||
|
dispatch, not proven. Lane T recorded it as `verified`; this lane could not re-derive that from the
|
||||||
|
constructor and records it as the weaker claim.
|
||||||
|
|
||||||
|
### 4.4 So what is the strengthened statement?
|
||||||
|
|
||||||
|
Not "there is no twenty-third mechanism". Honestly:
|
||||||
|
|
||||||
|
> Over direct edges **plus every indirect edge whose receiver this lane could pin**, `ProcessTurn`'s closure
|
||||||
|
> grows from 1,430 to 1,486 functions and `OnAllCombatDone_Tail`'s from 1,424 to 1,668, and the only draw
|
||||||
|
> sites those 300 added functions contribute are the eight in §3.1 — all inside the trade/spy end-of-turn
|
||||||
|
> block, all gated, none observed firing on the eleven turns lane Z instrumented.
|
||||||
|
|
||||||
|
The maximal over-approximation — every indirect site dispatching to every slot-matching target image-wide —
|
||||||
|
puts 19,697 of 41,089 functions in `ProcessTurn`'s closure. **That bound is useless and I am not going to
|
||||||
|
dress it up**: at half the image it says "maybe", not "yes". The tractable statement is the pinned one
|
||||||
|
above, plus the one-hop enumeration in §4.2/§4.3 of exactly which vtable slots could bridge into the parked
|
||||||
|
subtrees.
|
||||||
|
|
||||||
|
**Rule 18 stands.** The right instrument for "does the trade/spy tail block ever draw?" is a hook on
|
||||||
|
0x0088ef80 / 0x0082cca0 / 0x008877b0 / 0x0088db80 and a save with active trade routes and an active spy
|
||||||
|
program. That is ten minutes of the lab against another lane of reading.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. A second lower bound found on the way: tail-jump edges
|
||||||
|
|
||||||
|
Every call graph in the repo is built from `E8 rel32`. Sweeping for jumps that land on **another function's
|
||||||
|
start** finds **14,958** more control-flow edges — tail calls, and Ghidra function splits. They matter:
|
||||||
|
|
||||||
|
| closure | E8 only | + tail jumps | + pinned indirect |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `StrategyServer::ProcessTurn` 0x007dc6c0 | 1,395 | 1,430 | 1,486 |
|
||||||
|
| `OnAllCombatDone_Tail` 0x007d92a0 | 1,377 | 1,424 | 1,668 |
|
||||||
|
|
||||||
|
Lane I's closure was 1,426; with tail jumps this lane gets 1,430, which is the same closure to within edge
|
||||||
|
bookkeeping. Three of the nine parked inlined-draw functions in §4.2 have **no** `E8` caller anywhere and
|
||||||
|
are reachable only over these edges — 0x007c2fa0 is entered by a jump from 0x00898a50, and the whole
|
||||||
|
`StrategyNetworkServer` group through 0x006fcc60 from 0x006fde40. A sweep that had stopped at `E8` would
|
||||||
|
have called all four dead code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. What this lane did **not** do
|
||||||
|
|
||||||
|
* **Receiver type inference is not solved.** 2.5% of virtual sites get a class. The other 97.5% break down
|
||||||
|
into 1,027 stack receivers, 850 members of a register not proven to be `this`, and 3,955 where the object
|
||||||
|
pointer's definition could not be proven to reach the use. All are enumerated in `dumps/vtables.json` with
|
||||||
|
the reason; none is guessed at.
|
||||||
|
* **No callee bodies were read.** Every "draws / draw-free" verdict in §3.1 and §4.3 is a closure computation
|
||||||
|
over the call graph, not a reading. Rule 16 applies to the negative half of that: a function whose closure
|
||||||
|
contains no RNG *call* can still contain an **inlined** draw. The eleven functions lane I's tempering-
|
||||||
|
immediate scan found are checked for explicitly, so the verdict is "no call-graph draw and not one of the
|
||||||
|
eleven known inlined-draw functions" — which is a lower bound on drawing, not a proof of not drawing.
|
||||||
|
* **The `disp-only` route is discarded, not fixed.** 403 virtual sites whose receiver is a member of an
|
||||||
|
unknown object would resolve if their enclosing classes were typed. `ghidra/vtable-owners.json` is the
|
||||||
|
mechanism; it currently has two entries.
|
||||||
|
* **`StrategyServer+0x1b4`'s class was not proven** (§4.3b), and consequently the SVSO hook analysis is
|
||||||
|
conditional on lane T's identification being right.
|
||||||
|
* **Nothing was measured.** Every claim here is static. The four gated tail draw paths are the obvious next
|
||||||
|
hook, and until one runs, "never observed firing" means eight turns of one workload.
|
||||||
|
|
||||||
|
## 7. Reproducing
|
||||||
|
|
||||||
|
```
|
||||||
|
uv run python3 tools/rtti_map.py build # -> dumps/rtti.json (2,172 vftables)
|
||||||
|
uv run python3 tools/vtable_map.py build # -> dumps/vtables.json (~5 s)
|
||||||
|
uv run python3 tools/vtable_map.py validate # 12 checks, and the false-positive tables of 2c
|
||||||
|
uv run python3 tools/vtable_map.py who 0x00893290
|
||||||
|
uv run python3 tools/vtable_map.py vt 0x00a31b74
|
||||||
|
uv run python3 tools/vtable_map.py sites 0x007d92a0
|
||||||
|
uv run python3 tools/vtable_map.py resolve 0x007d92a0
|
||||||
|
uv run python3 tools/vtable_map.py field 0x158
|
||||||
|
uv run python3 tools/vtable_map.py impls Game::ServerTradeManager
|
||||||
|
```
|
||||||
|
|
||||||
|
`owner <funcVA> <reg>` also exists — an IDF-weighted ranker of candidate owner classes by overlap with each
|
||||||
|
class constructor's member-write footprint. It put `Game::StrategyServer` at rank 2 for
|
||||||
|
`OnAllCombatDone_Tail`'s `ebx` and nowhere useful for `ProcessTurn`'s `esi`. **It is a weak ranker and no
|
||||||
|
claim in this document rests on it** — the receiver typings in §3 come from constructors, not from it.
|
||||||
102
ghidra/addresses.d/lane-v2.json
Normal file
102
ghidra/addresses.d/lane-v2.json
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
{
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"name": "StrategyServer_off_SpyManager",
|
||||||
|
"offset": "0x158",
|
||||||
|
"convention": "member",
|
||||||
|
"prototype": "Game::ServerSpyManager* StrategyServer::SpyManager, in lane T's S+4 frame (absolute StrategyServer+0x15c). StrategyServer ctor 0x007d78d0: `call 0x00832a30` (the ServerSpyManager ctor, identified by its store of vftable 0x00a3073c) then `mov [esi+0x15c],eax` at 0x007d7d8e. Corroborated independently by 0x007dcf90, which the RTTI inverse map shows is Game::StrategyServer vftable 0x00a26034 slot 14 at sub-object +4: it calls the same two ctors and stores at [esi+0x154] and [esi+0x158], exactly 4 lower than the base-frame 0x158/0x15c, as a +4 `this` requires. Sits immediately after StrategyServer_off_TradeManager (lane T, 0x154 in the same frame)",
|
||||||
|
"status": "verified"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ServerSpyManager_vftable",
|
||||||
|
"addr": "0x00a3073c",
|
||||||
|
"convention": "data",
|
||||||
|
"prototype": "void* Game::ServerSpyManager::vftable[18] // sub-object +0, COL 0x00a87ed4, bases Game::IServerSpyManager / Game::ISpyManager / Mars::IStreamable. Unlike the trade manager there is no *Impl: ServerSpyManager is itself concrete (no purecall slots) and has no derived class. A second vftable 0x00a30728 sits at sub-object +4 with 3 slots",
|
||||||
|
"status": "verified"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ServerTradeManagerImpl_vslot7",
|
||||||
|
"addr": "0x0088ad60",
|
||||||
|
"convention": "thiscall",
|
||||||
|
"prototype": "void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 7 (+0x1c). Reached ONLY virtually, from 0x007d97ea in StrategyServer::OnAllCombatDone_Tail phase 23 (call 6 of 8). Zero direct call sites. Body not read; its direct+tail-jump closure is 189 functions and contains no RNG entry point and none of the eleven inlined-draw functions",
|
||||||
|
"status": "mapped"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ServerTradeManagerImpl_vslot8",
|
||||||
|
"addr": "0x0088e8d0",
|
||||||
|
"convention": "thiscall",
|
||||||
|
"prototype": "void (Game::ServerTradeManagerImpl* this, int arg1) // vftable 0x00a31b74 slot 8 (+0x20). Reached ONLY virtually, from 0x007d97b6 in OnAllCombatDone_Tail phase 23 (call 2 of 8); the site pushes one argument. Closure 86 functions, draw-free",
|
||||||
|
"status": "mapped"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ServerTradeManagerImpl_vslot9",
|
||||||
|
"addr": "0x00868060",
|
||||||
|
"convention": "thiscall",
|
||||||
|
"prototype": "void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 9 (+0x24). Reached ONLY virtually, from 0x007d97dd (phase 23, call 5 of 8). Closure 185 functions, draw-free",
|
||||||
|
"status": "mapped"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ServerTradeManagerImpl_vslot11",
|
||||||
|
"addr": "0x00848570",
|
||||||
|
"convention": "thiscall",
|
||||||
|
"prototype": "void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 11 (+0x2c). Reached ONLY virtually, from 0x007d97d0 (phase 23, call 4 of 8). Closure 188 functions, draw-free",
|
||||||
|
"status": "mapped"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ServerTradeManagerImpl_vslot12",
|
||||||
|
"addr": "0x0088e920",
|
||||||
|
"convention": "thiscall",
|
||||||
|
"prototype": "void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 12 (+0x30). Reached ONLY virtually, from 0x007d97c3 (phase 23, call 3 of 8). Closure 193 functions, draw-free",
|
||||||
|
"status": "mapped"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ServerTradeManagerImpl_vslot13",
|
||||||
|
"addr": "0x0088ef80",
|
||||||
|
"convention": "thiscall",
|
||||||
|
"prototype": "void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 13 (+0x34). Reached ONLY virtually, from 0x007d97f7 (phase 23, call 7 of 8). REACHES THE STRATEGIC GENERATOR: -> 0x00820ca0, NextFloat at 0x00820e18 with the generator loaded as [reg+0x16c] then `lea ecx,[ecx+4]`; and -> 0x0088b440, NextInt at 0x0088b613 with `mov ecx,[ecx+0x16c]; add ecx,4`. Neither was ever observed firing: lane Z measured 0 tail words on 8 turns",
|
||||||
|
"status": "mapped"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ServerTradeManagerImpl_vslot14",
|
||||||
|
"addr": "0x008590d0",
|
||||||
|
"convention": "thiscall",
|
||||||
|
"prototype": "void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 14 (+0x38). Reached ONLY virtually, from 0x007d97a7 (phase 23, call 1 of 8). Closure 143 functions, draw-free",
|
||||||
|
"status": "mapped"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ServerTradeManagerImpl_vslot15",
|
||||||
|
"addr": "0x0082cca0",
|
||||||
|
"convention": "thiscall",
|
||||||
|
"prototype": "void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 15 (+0x3c). Reached ONLY virtually, from 0x007d9804 (phase 23, call 8 of 8). REACHES THE STRATEGIC GENERATOR: Chance at 0x0082cdb8, generator loaded at 0x0082cda4 as `mov eax,[eax+0x16c]` then `mov ecx,eax`. Never observed firing",
|
||||||
|
"status": "mapped"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ServerSpyManager_vslot13",
|
||||||
|
"addr": "0x008877b0",
|
||||||
|
"convention": "thiscall",
|
||||||
|
"prototype": "void (Game::ServerSpyManager* this) // vftable 0x00a3073c slot 13 (+0x34). Reached ONLY virtually, from 0x007d9811 in OnAllCombatDone_Tail phase 23 -- the ninth call of the block, and the only one whose receiver is StrategyServer+0x15c rather than +0x158. REACHES THE STRATEGIC GENERATOR: Chance at 0x00887c8a on `mov ecx,[ecx+0x16c]`, and through 0x008408e0 Chance at 0x00840929 and 0x00840a3c plus NextInt at 0x008409c7, all on [reg+0x16c]. Never observed firing",
|
||||||
|
"status": "mapped"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ServerSpyManager_vslot14",
|
||||||
|
"addr": "0x0088db80",
|
||||||
|
"convention": "thiscall",
|
||||||
|
"prototype": "void (Game::ServerSpyManager* this) // vftable 0x00a3073c slot 14 (+0x38). Reached ONLY virtually, from 0x007d989b in OnAllCombatDone_Tail phase 33 (call 1 of 2). REACHES THE STRATEGIC GENERATOR: Chance at 0x0088dc43 on `mov ecx,[eax+0x16c]`. Never observed firing",
|
||||||
|
"status": "mapped"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ServerSpyManager_vslot15",
|
||||||
|
"addr": "0x00887f30",
|
||||||
|
"convention": "thiscall",
|
||||||
|
"prototype": "void (Game::ServerSpyManager* this) // vftable 0x00a3073c slot 15 (+0x3c). Reached ONLY virtually, from 0x007d98a8 in OnAllCombatDone_Tail phase 33 (call 2 of 2). Closure 230 functions, draw-free",
|
||||||
|
"status": "mapped"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SVScriptObject_EmptyOverride",
|
||||||
|
"addr": "0x0080c5a0",
|
||||||
|
"convention": "thiscall",
|
||||||
|
"prototype": "void (void* this) // the do-nothing body every Game::SVScriptObject-derived class inherits in the hook slots it does not override. Occupies most of slots 4/11/25/27/30 across the 30 SVSO classes, which is what makes the non-stub overrides countable: 14 at slot 4, 7 at slot 11, 8 at slot 25, 1 at slot 30, 0 at slot 27",
|
||||||
|
"status": "mapped"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
38
ghidra/vtable-owners.json
Normal file
38
ghidra/vtable-owners.json
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"_readme": [
|
||||||
|
"Hand-asserted receiver typings for functions that appear in NO vftable and",
|
||||||
|
"are not constructors, so tools/vtable_map.py's automatic seeds cannot reach",
|
||||||
|
"them. Every entry must carry evidence a reader can re-derive. These are",
|
||||||
|
"seeds for the `this`-propagation fixpoint, so one entry types a whole",
|
||||||
|
"subtree -- which is also why a wrong entry is expensive. Keep it short."
|
||||||
|
],
|
||||||
|
"owners": [
|
||||||
|
{
|
||||||
|
"func": "0x007dc6c0",
|
||||||
|
"name": "StrategyServer::ProcessTurn",
|
||||||
|
"class": "Game::StrategyServer",
|
||||||
|
"offset": 0,
|
||||||
|
"evidence": [
|
||||||
|
"first member op is `inc [esi+0x8]` = ModCount; lane Z read S+0x8 as",
|
||||||
|
"ModCount from StrategyServer::Write's wire tag at 0x0079fb2f",
|
||||||
|
"hands its own `this` to 0x007d7f70, which dispatches on [this+0x158]",
|
||||||
|
"and [this+0x15c]; the StrategyServer ctor 0x007d78d0 constructs a",
|
||||||
|
"ServerTradeManagerImpl into +0x158 and a ServerSpyManager into +0x15c",
|
||||||
|
"(vtable_map field 0x158 / field 0x15c)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"func": "0x007d92a0",
|
||||||
|
"name": "StrategyServer::OnAllCombatDone_Tail",
|
||||||
|
"class": "Game::StrategyServer",
|
||||||
|
"offset": 0,
|
||||||
|
"evidence": [
|
||||||
|
"lane K read [ebx+0x54]/[ebx+0x58] as vector<ServerPlayer*> Players",
|
||||||
|
"dispatches on [ebx+0x158] and [ebx+0x15c] in one straight-line block,",
|
||||||
|
"matching the two managers the StrategyServer ctor 0x007d78d0 builds",
|
||||||
|
"there; slot 15 needs >=16 slots and slot 13 needs >=14, which the two",
|
||||||
|
"concrete vtables 0x00a31b74 (22) and 0x00a3073c (18) supply"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
184
scripts/lane_v2_writeback.py
Normal file
184
scripts/lane_v2_writeback.py
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Lane V2 -- write the resolved indirect edges back into Ghidra.
|
||||||
|
|
||||||
|
Everything here comes from tools/vtable_map.py (RTTI vftable map + slot recovery
|
||||||
|
+ constructor-derived member typing). Nothing is decompiler-derived.
|
||||||
|
|
||||||
|
uv run python3 scripts/lane_v2_writeback.py [--dry]
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
REPO = os.path.dirname(HERE)
|
||||||
|
PROG = "/Sword of the Stars.exe"
|
||||||
|
DRY = "--dry" in sys.argv
|
||||||
|
|
||||||
|
|
||||||
|
def call(tool, args):
|
||||||
|
if DRY:
|
||||||
|
print(f" [dry] {tool} {json.dumps(args)[:150]}")
|
||||||
|
return
|
||||||
|
p = subprocess.run(
|
||||||
|
["uv", "run", "python3", os.path.join(REPO, "tools", "reva_call.py"),
|
||||||
|
tool, json.dumps(args)],
|
||||||
|
cwd=REPO, capture_output=True, text=True, timeout=300)
|
||||||
|
ok = '"success":true' in p.stdout or '"success": true' in p.stdout
|
||||||
|
print(f" {'ok ' if ok else 'ERR'} {tool} {args.get('addressOrSymbol', args.get('address', ''))}"
|
||||||
|
f" {'' if ok else p.stdout.strip()[:200] + p.stderr.strip()[:200]}")
|
||||||
|
|
||||||
|
|
||||||
|
def comment(addr, text):
|
||||||
|
call("set-comment", {"programPath": PROG, "addressOrSymbol": addr,
|
||||||
|
"comment": text, "commentType": "pre"})
|
||||||
|
|
||||||
|
|
||||||
|
def plate(addr, text):
|
||||||
|
call("set-comment", {"programPath": PROG, "addressOrSymbol": addr,
|
||||||
|
"comment": text, "commentType": "plate"})
|
||||||
|
|
||||||
|
|
||||||
|
def label(addr, name):
|
||||||
|
call("create-label", {"programPath": PROG, "addressOrSymbol": addr,
|
||||||
|
"labelName": name})
|
||||||
|
|
||||||
|
|
||||||
|
TRADE_VT = "0x00a31b74"
|
||||||
|
SPY_VT = "0x00a3073c"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- the targets
|
||||||
|
# vtable, class, slot, target, phase note, RNG note
|
||||||
|
TARGETS = [
|
||||||
|
(TRADE_VT, "ServerTradeManagerImpl", 10, "0x00893290",
|
||||||
|
"dispatched at 0x007d8469 (StrategyServer::DetectEncounters)",
|
||||||
|
"DRAWS: Chance x3 at 0x00893426 / 0x00893513 / 0x008935ce on the "
|
||||||
|
"strategic generator; 16 of ~20 words of a strategic turn (lane Z)"),
|
||||||
|
(TRADE_VT, "ServerTradeManagerImpl", 14, "0x008590d0",
|
||||||
|
"OnAllCombatDone_Tail phase 23, call 1 of 8, at 0x007d97a7", ""),
|
||||||
|
(TRADE_VT, "ServerTradeManagerImpl", 8, "0x0088e8d0",
|
||||||
|
"OnAllCombatDone_Tail phase 23, call 2 of 8, at 0x007d97b6 (arg 1)", ""),
|
||||||
|
(TRADE_VT, "ServerTradeManagerImpl", 12, "0x0088e920",
|
||||||
|
"OnAllCombatDone_Tail phase 23, call 3 of 8, at 0x007d97c3", ""),
|
||||||
|
(TRADE_VT, "ServerTradeManagerImpl", 11, "0x00848570",
|
||||||
|
"OnAllCombatDone_Tail phase 23, call 4 of 8, at 0x007d97d0", ""),
|
||||||
|
(TRADE_VT, "ServerTradeManagerImpl", 9, "0x00868060",
|
||||||
|
"OnAllCombatDone_Tail phase 23, call 5 of 8, at 0x007d97dd", ""),
|
||||||
|
(TRADE_VT, "ServerTradeManagerImpl", 7, "0x0088ad60",
|
||||||
|
"OnAllCombatDone_Tail phase 23, call 6 of 8, at 0x007d97ea", ""),
|
||||||
|
(TRADE_VT, "ServerTradeManagerImpl", 13, "0x0088ef80",
|
||||||
|
"OnAllCombatDone_Tail phase 23, call 7 of 8, at 0x007d97f7",
|
||||||
|
"REACHES A DRAW: -> 0x00820ca0 NextFloat at 0x00820e18 and "
|
||||||
|
"-> 0x0088b440 NextInt at 0x0088b613, both on [obj+0x16c] = the "
|
||||||
|
"StrategyServer strategic generator. Never observed firing (lane Z "
|
||||||
|
"measured 0 tail words on 8 turns)"),
|
||||||
|
(TRADE_VT, "ServerTradeManagerImpl", 15, "0x0082cca0",
|
||||||
|
"OnAllCombatDone_Tail phase 23, call 8 of 8, at 0x007d9804",
|
||||||
|
"REACHES A DRAW: Chance at 0x0082cdb8, generator loaded at 0x0082cda4 "
|
||||||
|
"as [eax+0x16c] = the strategic generator. Never observed firing"),
|
||||||
|
(SPY_VT, "ServerSpyManager", 13, "0x008877b0",
|
||||||
|
"OnAllCombatDone_Tail phase 23, the ninth call, at 0x007d9811 "
|
||||||
|
"(receiver is StrategyServer+0x15c, not +0x158)",
|
||||||
|
"REACHES A DRAW: Chance at 0x00887c8a on [ecx+0x16c]; and via "
|
||||||
|
"0x008408e0 Chance at 0x00840929 / 0x00840a3c and NextInt at "
|
||||||
|
"0x008409c7, all on [reg+0x16c] = the strategic generator. "
|
||||||
|
"Never observed firing"),
|
||||||
|
(SPY_VT, "ServerSpyManager", 14, "0x0088db80",
|
||||||
|
"OnAllCombatDone_Tail phase 33, call 1 of 2, at 0x007d989b",
|
||||||
|
"REACHES A DRAW: Chance at 0x0088dc43 on [eax+0x16c] = the strategic "
|
||||||
|
"generator. Never observed firing"),
|
||||||
|
(SPY_VT, "ServerSpyManager", 15, "0x00887f30",
|
||||||
|
"OnAllCombatDone_Tail phase 33, call 2 of 2, at 0x007d98a8", ""),
|
||||||
|
]
|
||||||
|
|
||||||
|
# dispatch site -> (class, slot, target)
|
||||||
|
SITES = [
|
||||||
|
("0x007d8469", "ServerTradeManagerImpl", 10, "0x00893290", TRADE_VT),
|
||||||
|
("0x007d97a7", "ServerTradeManagerImpl", 14, "0x008590d0", TRADE_VT),
|
||||||
|
("0x007d97b6", "ServerTradeManagerImpl", 8, "0x0088e8d0", TRADE_VT),
|
||||||
|
("0x007d97c3", "ServerTradeManagerImpl", 12, "0x0088e920", TRADE_VT),
|
||||||
|
("0x007d97d0", "ServerTradeManagerImpl", 11, "0x00848570", TRADE_VT),
|
||||||
|
("0x007d97dd", "ServerTradeManagerImpl", 9, "0x00868060", TRADE_VT),
|
||||||
|
("0x007d97ea", "ServerTradeManagerImpl", 7, "0x0088ad60", TRADE_VT),
|
||||||
|
("0x007d97f7", "ServerTradeManagerImpl", 13, "0x0088ef80", TRADE_VT),
|
||||||
|
("0x007d9804", "ServerTradeManagerImpl", 15, "0x0082cca0", TRADE_VT),
|
||||||
|
("0x007d9811", "ServerSpyManager", 13, "0x008877b0", SPY_VT),
|
||||||
|
("0x007d989b", "ServerSpyManager", 14, "0x0088db80", SPY_VT),
|
||||||
|
("0x007d98a8", "ServerSpyManager", 15, "0x00887f30", SPY_VT),
|
||||||
|
]
|
||||||
|
|
||||||
|
# the SVScriptObject hook block on StrategyServer+0x1b4
|
||||||
|
SVSO = [
|
||||||
|
("0x007dcb8e", 4, "ProcessTurn, hook id 6 pushed at 0x007dcb8c"),
|
||||||
|
("0x007dcb97", 11, "ProcessTurn, the hook body paired with the id-6 gate"),
|
||||||
|
("0x007dcbb6", 4, "ProcessTurn, second gate"),
|
||||||
|
("0x007dcbbf", 30, "ProcessTurn, the hook body paired with it"),
|
||||||
|
("0x007d9767", 4, "OnAllCombatDone_Tail, gate"),
|
||||||
|
("0x007d9770", 25, "OnAllCombatDone_Tail, hook body"),
|
||||||
|
("0x007d9783", 4, "OnAllCombatDone_Tail, gate"),
|
||||||
|
("0x007d978c", 27, "OnAllCombatDone_Tail, hook body"),
|
||||||
|
("0x007d9838", 4, "OnAllCombatDone_Tail, gate"),
|
||||||
|
("0x007d9841", 30, "OnAllCombatDone_Tail, hook body"),
|
||||||
|
]
|
||||||
|
|
||||||
|
SVSO_NOTE = (
|
||||||
|
"SVScriptObject hook: receiver is StrategyServer+0x1b4, null-checked "
|
||||||
|
"before the block (`cmp reg,ebx; je`). 30 classes derive from "
|
||||||
|
"Game::SVScriptObject; the base's empty override is 0x0080c5a0. "
|
||||||
|
"Lane V2: slot 4 has 14 non-stub overrides, slot 11 has 7, slot 25 has 8, "
|
||||||
|
"slot 30 has 1, slot 27 has 0. Thirteen of those overrides reach an RNG "
|
||||||
|
"draw at depth 2-4 (e.g. SVSOCrowRuins slot 11 = 0x00518340 -> "
|
||||||
|
"0x004f4210 -> IntRangeBell 0x008e6d80). Which generator object those "
|
||||||
|
"draws use is NOT established -- it arrives as an argument. "
|
||||||
|
"Scripted-scenario only; presumed inactive in a normal game and still "
|
||||||
|
"not proven so."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("== vtable-slot plate comments and labels ==")
|
||||||
|
for vt, cls, slot, tgt, where, rng in TARGETS:
|
||||||
|
txt = (f"Game::{cls} vftable {vt} slot {slot} (+0x{4 * slot:x}).\n"
|
||||||
|
f"Reached ONLY through a virtual dispatch: {where}.\n"
|
||||||
|
f"Receiver typed from the constructor: StrategyServer ctor "
|
||||||
|
f"0x007d78d0 stores the {cls} constructor's result into "
|
||||||
|
f"StrategyServer+0x{'158' if cls.startswith('ServerTrade') else '15c'}"
|
||||||
|
f"; corroborated by 0x007dcf90 (a StrategyServer method entered "
|
||||||
|
f"on the +4 sub-object) storing the same two constructors 4 "
|
||||||
|
f"bytes lower. [lane V2]")
|
||||||
|
if rng:
|
||||||
|
txt += "\n" + rng
|
||||||
|
plate(tgt, txt)
|
||||||
|
label(tgt, f"{cls}_vslot{slot}")
|
||||||
|
|
||||||
|
print("== dispatch-site comments ==")
|
||||||
|
for site, cls, slot, tgt, vt in SITES:
|
||||||
|
comment(site, f"-> Game::{cls}::vslot{slot} = {tgt} "
|
||||||
|
f"(vftable {vt} + 0x{4 * slot:x}) [lane V2]")
|
||||||
|
|
||||||
|
print("== SVScriptObject hook block ==")
|
||||||
|
for site, slot, where in SVSO:
|
||||||
|
comment(site, f"SVScriptObject slot {slot} (+0x{4 * slot:x}) -- {where}."
|
||||||
|
f"\n{SVSO_NOTE} [lane V2]")
|
||||||
|
|
||||||
|
print("== the two managers ==")
|
||||||
|
plate("0x007d78d0",
|
||||||
|
"Game::StrategyServer constructor (vptr installs: 0x00a26084 at +0, "
|
||||||
|
"0x00a26034 at +4, Mars::IStreamable 0x009e22bc at +0).\n"
|
||||||
|
"Lane V2 member typings taken from here:\n"
|
||||||
|
" +0x158 = Game::ServerTradeManagerImpl* (ctor 0x00858f70, "
|
||||||
|
"stored 0x007d7d81)\n"
|
||||||
|
" +0x15c = Game::ServerSpyManager* (ctor 0x00832a30, "
|
||||||
|
"stored 0x007d7d8e)\n"
|
||||||
|
"Game::ServerTradeManager (vftable 0x00a311a4) is abstract -- 21 of "
|
||||||
|
"its 22 slots are purecall -- and ServerTradeManagerImpl is its "
|
||||||
|
"ONLY derived class, so slot N of that interface resolves uniquely. "
|
||||||
|
"Game::ServerSpyManager has no *Impl: it is itself the concrete "
|
||||||
|
"class (18 slots, none purecall) over IServerSpyManager / "
|
||||||
|
"ISpyManager / IStreamable. [lane V2]")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
1257
tools/vtable_map.py
Normal file
1257
tools/vtable_map.py
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue