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

22 KiB
Raw Permalink Blame History

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