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.
380 lines
22 KiB
Markdown
380 lines
22 KiB
Markdown
# 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.
|