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.
22 KiB
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@VIServerSpyManager@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 ifs).
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 ofthis(it is argument 1) — accounted for every out-of-range result in the firstfieldrun; - 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
+dand+d+4and 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 thepop esiin 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 SVScriptObjects — 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 (withpush 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 andOnAllCombatDone_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 indumps/vtables.jsonwith 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-onlyroute 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.jsonis 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.