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:
alex 2026-09-08 12:05:05 -04:00
parent 7645399f97
commit 648028db67
5 changed files with 1961 additions and 0 deletions

View 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.

View 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
View 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"
]
}
]
}

View 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

File diff suppressed because it is too large Load diff