sots-re/findings/control-flow/indirect-edges.md
alex 648028db67 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.
2026-09-08 12:05:05 -04:00

380 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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