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