From 648028db674c1b09a2477bdb995a180301f4f64f Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 12:05:05 -0400 Subject: [PATCH] =?UTF-8?q?lane=20V2:=20vtable=20inversion=20=E2=80=94=20r?= =?UTF-8?q?esolve=20indirect=20call=20edges=20image-wide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- findings/control-flow/indirect-edges.md | 380 +++++++ ghidra/addresses.d/lane-v2.json | 102 ++ ghidra/vtable-owners.json | 38 + scripts/lane_v2_writeback.py | 184 ++++ tools/vtable_map.py | 1257 +++++++++++++++++++++++ 5 files changed, 1961 insertions(+) create mode 100644 findings/control-flow/indirect-edges.md create mode 100644 ghidra/addresses.d/lane-v2.json create mode 100644 ghidra/vtable-owners.json create mode 100644 scripts/lane_v2_writeback.py create mode 100644 tools/vtable_map.py diff --git a/findings/control-flow/indirect-edges.md b/findings/control-flow/indirect-edges.md new file mode 100644 index 0000000..434d73f --- /dev/null +++ b/findings/control-flow/indirect-edges.md @@ -0,0 +1,380 @@ +# 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. diff --git a/ghidra/addresses.d/lane-v2.json b/ghidra/addresses.d/lane-v2.json new file mode 100644 index 0000000..d29e89b --- /dev/null +++ b/ghidra/addresses.d/lane-v2.json @@ -0,0 +1,102 @@ +{ + "entries": [ + { + "name": "StrategyServer_off_SpyManager", + "offset": "0x158", + "convention": "member", + "prototype": "Game::ServerSpyManager* StrategyServer::SpyManager, in lane T's S+4 frame (absolute StrategyServer+0x15c). StrategyServer ctor 0x007d78d0: `call 0x00832a30` (the ServerSpyManager ctor, identified by its store of vftable 0x00a3073c) then `mov [esi+0x15c],eax` at 0x007d7d8e. Corroborated independently by 0x007dcf90, which the RTTI inverse map shows is Game::StrategyServer vftable 0x00a26034 slot 14 at sub-object +4: it calls the same two ctors and stores at [esi+0x154] and [esi+0x158], exactly 4 lower than the base-frame 0x158/0x15c, as a +4 `this` requires. Sits immediately after StrategyServer_off_TradeManager (lane T, 0x154 in the same frame)", + "status": "verified" + }, + { + "name": "ServerSpyManager_vftable", + "addr": "0x00a3073c", + "convention": "data", + "prototype": "void* Game::ServerSpyManager::vftable[18] // sub-object +0, COL 0x00a87ed4, bases Game::IServerSpyManager / Game::ISpyManager / Mars::IStreamable. Unlike the trade manager there is no *Impl: ServerSpyManager is itself concrete (no purecall slots) and has no derived class. A second vftable 0x00a30728 sits at sub-object +4 with 3 slots", + "status": "verified" + }, + { + "name": "ServerTradeManagerImpl_vslot7", + "addr": "0x0088ad60", + "convention": "thiscall", + "prototype": "void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 7 (+0x1c). Reached ONLY virtually, from 0x007d97ea in StrategyServer::OnAllCombatDone_Tail phase 23 (call 6 of 8). Zero direct call sites. Body not read; its direct+tail-jump closure is 189 functions and contains no RNG entry point and none of the eleven inlined-draw functions", + "status": "mapped" + }, + { + "name": "ServerTradeManagerImpl_vslot8", + "addr": "0x0088e8d0", + "convention": "thiscall", + "prototype": "void (Game::ServerTradeManagerImpl* this, int arg1) // vftable 0x00a31b74 slot 8 (+0x20). Reached ONLY virtually, from 0x007d97b6 in OnAllCombatDone_Tail phase 23 (call 2 of 8); the site pushes one argument. Closure 86 functions, draw-free", + "status": "mapped" + }, + { + "name": "ServerTradeManagerImpl_vslot9", + "addr": "0x00868060", + "convention": "thiscall", + "prototype": "void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 9 (+0x24). Reached ONLY virtually, from 0x007d97dd (phase 23, call 5 of 8). Closure 185 functions, draw-free", + "status": "mapped" + }, + { + "name": "ServerTradeManagerImpl_vslot11", + "addr": "0x00848570", + "convention": "thiscall", + "prototype": "void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 11 (+0x2c). Reached ONLY virtually, from 0x007d97d0 (phase 23, call 4 of 8). Closure 188 functions, draw-free", + "status": "mapped" + }, + { + "name": "ServerTradeManagerImpl_vslot12", + "addr": "0x0088e920", + "convention": "thiscall", + "prototype": "void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 12 (+0x30). Reached ONLY virtually, from 0x007d97c3 (phase 23, call 3 of 8). Closure 193 functions, draw-free", + "status": "mapped" + }, + { + "name": "ServerTradeManagerImpl_vslot13", + "addr": "0x0088ef80", + "convention": "thiscall", + "prototype": "void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 13 (+0x34). Reached ONLY virtually, from 0x007d97f7 (phase 23, call 7 of 8). REACHES THE STRATEGIC GENERATOR: -> 0x00820ca0, NextFloat at 0x00820e18 with the generator loaded as [reg+0x16c] then `lea ecx,[ecx+4]`; and -> 0x0088b440, NextInt at 0x0088b613 with `mov ecx,[ecx+0x16c]; add ecx,4`. Neither was ever observed firing: lane Z measured 0 tail words on 8 turns", + "status": "mapped" + }, + { + "name": "ServerTradeManagerImpl_vslot14", + "addr": "0x008590d0", + "convention": "thiscall", + "prototype": "void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 14 (+0x38). Reached ONLY virtually, from 0x007d97a7 (phase 23, call 1 of 8). Closure 143 functions, draw-free", + "status": "mapped" + }, + { + "name": "ServerTradeManagerImpl_vslot15", + "addr": "0x0082cca0", + "convention": "thiscall", + "prototype": "void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 15 (+0x3c). Reached ONLY virtually, from 0x007d9804 (phase 23, call 8 of 8). REACHES THE STRATEGIC GENERATOR: Chance at 0x0082cdb8, generator loaded at 0x0082cda4 as `mov eax,[eax+0x16c]` then `mov ecx,eax`. Never observed firing", + "status": "mapped" + }, + { + "name": "ServerSpyManager_vslot13", + "addr": "0x008877b0", + "convention": "thiscall", + "prototype": "void (Game::ServerSpyManager* this) // vftable 0x00a3073c slot 13 (+0x34). Reached ONLY virtually, from 0x007d9811 in OnAllCombatDone_Tail phase 23 -- the ninth call of the block, and the only one whose receiver is StrategyServer+0x15c rather than +0x158. REACHES THE STRATEGIC GENERATOR: Chance at 0x00887c8a on `mov ecx,[ecx+0x16c]`, and through 0x008408e0 Chance at 0x00840929 and 0x00840a3c plus NextInt at 0x008409c7, all on [reg+0x16c]. Never observed firing", + "status": "mapped" + }, + { + "name": "ServerSpyManager_vslot14", + "addr": "0x0088db80", + "convention": "thiscall", + "prototype": "void (Game::ServerSpyManager* this) // vftable 0x00a3073c slot 14 (+0x38). Reached ONLY virtually, from 0x007d989b in OnAllCombatDone_Tail phase 33 (call 1 of 2). REACHES THE STRATEGIC GENERATOR: Chance at 0x0088dc43 on `mov ecx,[eax+0x16c]`. Never observed firing", + "status": "mapped" + }, + { + "name": "ServerSpyManager_vslot15", + "addr": "0x00887f30", + "convention": "thiscall", + "prototype": "void (Game::ServerSpyManager* this) // vftable 0x00a3073c slot 15 (+0x3c). Reached ONLY virtually, from 0x007d98a8 in OnAllCombatDone_Tail phase 33 (call 2 of 2). Closure 230 functions, draw-free", + "status": "mapped" + }, + { + "name": "SVScriptObject_EmptyOverride", + "addr": "0x0080c5a0", + "convention": "thiscall", + "prototype": "void (void* this) // the do-nothing body every Game::SVScriptObject-derived class inherits in the hook slots it does not override. Occupies most of slots 4/11/25/27/30 across the 30 SVSO classes, which is what makes the non-stub overrides countable: 14 at slot 4, 7 at slot 11, 8 at slot 25, 1 at slot 30, 0 at slot 27", + "status": "mapped" + } + ] +} \ No newline at end of file diff --git a/ghidra/vtable-owners.json b/ghidra/vtable-owners.json new file mode 100644 index 0000000..bea918c --- /dev/null +++ b/ghidra/vtable-owners.json @@ -0,0 +1,38 @@ +{ + "_readme": [ + "Hand-asserted receiver typings for functions that appear in NO vftable and", + "are not constructors, so tools/vtable_map.py's automatic seeds cannot reach", + "them. Every entry must carry evidence a reader can re-derive. These are", + "seeds for the `this`-propagation fixpoint, so one entry types a whole", + "subtree -- which is also why a wrong entry is expensive. Keep it short." + ], + "owners": [ + { + "func": "0x007dc6c0", + "name": "StrategyServer::ProcessTurn", + "class": "Game::StrategyServer", + "offset": 0, + "evidence": [ + "first member op is `inc [esi+0x8]` = ModCount; lane Z read S+0x8 as", + "ModCount from StrategyServer::Write's wire tag at 0x0079fb2f", + "hands its own `this` to 0x007d7f70, which dispatches on [this+0x158]", + "and [this+0x15c]; the StrategyServer ctor 0x007d78d0 constructs a", + "ServerTradeManagerImpl into +0x158 and a ServerSpyManager into +0x15c", + "(vtable_map field 0x158 / field 0x15c)" + ] + }, + { + "func": "0x007d92a0", + "name": "StrategyServer::OnAllCombatDone_Tail", + "class": "Game::StrategyServer", + "offset": 0, + "evidence": [ + "lane K read [ebx+0x54]/[ebx+0x58] as vector Players", + "dispatches on [ebx+0x158] and [ebx+0x15c] in one straight-line block,", + "matching the two managers the StrategyServer ctor 0x007d78d0 builds", + "there; slot 15 needs >=16 slots and slot 13 needs >=14, which the two", + "concrete vtables 0x00a31b74 (22) and 0x00a3073c (18) supply" + ] + } + ] +} diff --git a/scripts/lane_v2_writeback.py b/scripts/lane_v2_writeback.py new file mode 100644 index 0000000..19466b3 --- /dev/null +++ b/scripts/lane_v2_writeback.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Lane V2 -- write the resolved indirect edges back into Ghidra. + +Everything here comes from tools/vtable_map.py (RTTI vftable map + slot recovery ++ constructor-derived member typing). Nothing is decompiler-derived. + + uv run python3 scripts/lane_v2_writeback.py [--dry] +""" +import json +import os +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.dirname(HERE) +PROG = "/Sword of the Stars.exe" +DRY = "--dry" in sys.argv + + +def call(tool, args): + if DRY: + print(f" [dry] {tool} {json.dumps(args)[:150]}") + return + p = subprocess.run( + ["uv", "run", "python3", os.path.join(REPO, "tools", "reva_call.py"), + tool, json.dumps(args)], + cwd=REPO, capture_output=True, text=True, timeout=300) + ok = '"success":true' in p.stdout or '"success": true' in p.stdout + print(f" {'ok ' if ok else 'ERR'} {tool} {args.get('addressOrSymbol', args.get('address', ''))}" + f" {'' if ok else p.stdout.strip()[:200] + p.stderr.strip()[:200]}") + + +def comment(addr, text): + call("set-comment", {"programPath": PROG, "addressOrSymbol": addr, + "comment": text, "commentType": "pre"}) + + +def plate(addr, text): + call("set-comment", {"programPath": PROG, "addressOrSymbol": addr, + "comment": text, "commentType": "plate"}) + + +def label(addr, name): + call("create-label", {"programPath": PROG, "addressOrSymbol": addr, + "labelName": name}) + + +TRADE_VT = "0x00a31b74" +SPY_VT = "0x00a3073c" + +# --------------------------------------------------------------- the targets +# vtable, class, slot, target, phase note, RNG note +TARGETS = [ + (TRADE_VT, "ServerTradeManagerImpl", 10, "0x00893290", + "dispatched at 0x007d8469 (StrategyServer::DetectEncounters)", + "DRAWS: Chance x3 at 0x00893426 / 0x00893513 / 0x008935ce on the " + "strategic generator; 16 of ~20 words of a strategic turn (lane Z)"), + (TRADE_VT, "ServerTradeManagerImpl", 14, "0x008590d0", + "OnAllCombatDone_Tail phase 23, call 1 of 8, at 0x007d97a7", ""), + (TRADE_VT, "ServerTradeManagerImpl", 8, "0x0088e8d0", + "OnAllCombatDone_Tail phase 23, call 2 of 8, at 0x007d97b6 (arg 1)", ""), + (TRADE_VT, "ServerTradeManagerImpl", 12, "0x0088e920", + "OnAllCombatDone_Tail phase 23, call 3 of 8, at 0x007d97c3", ""), + (TRADE_VT, "ServerTradeManagerImpl", 11, "0x00848570", + "OnAllCombatDone_Tail phase 23, call 4 of 8, at 0x007d97d0", ""), + (TRADE_VT, "ServerTradeManagerImpl", 9, "0x00868060", + "OnAllCombatDone_Tail phase 23, call 5 of 8, at 0x007d97dd", ""), + (TRADE_VT, "ServerTradeManagerImpl", 7, "0x0088ad60", + "OnAllCombatDone_Tail phase 23, call 6 of 8, at 0x007d97ea", ""), + (TRADE_VT, "ServerTradeManagerImpl", 13, "0x0088ef80", + "OnAllCombatDone_Tail phase 23, call 7 of 8, at 0x007d97f7", + "REACHES A DRAW: -> 0x00820ca0 NextFloat at 0x00820e18 and " + "-> 0x0088b440 NextInt at 0x0088b613, both on [obj+0x16c] = the " + "StrategyServer strategic generator. Never observed firing (lane Z " + "measured 0 tail words on 8 turns)"), + (TRADE_VT, "ServerTradeManagerImpl", 15, "0x0082cca0", + "OnAllCombatDone_Tail phase 23, call 8 of 8, at 0x007d9804", + "REACHES A DRAW: Chance at 0x0082cdb8, generator loaded at 0x0082cda4 " + "as [eax+0x16c] = the strategic generator. Never observed firing"), + (SPY_VT, "ServerSpyManager", 13, "0x008877b0", + "OnAllCombatDone_Tail phase 23, the ninth call, at 0x007d9811 " + "(receiver is StrategyServer+0x15c, not +0x158)", + "REACHES A DRAW: Chance at 0x00887c8a on [ecx+0x16c]; and via " + "0x008408e0 Chance at 0x00840929 / 0x00840a3c and NextInt at " + "0x008409c7, all on [reg+0x16c] = the strategic generator. " + "Never observed firing"), + (SPY_VT, "ServerSpyManager", 14, "0x0088db80", + "OnAllCombatDone_Tail phase 33, call 1 of 2, at 0x007d989b", + "REACHES A DRAW: Chance at 0x0088dc43 on [eax+0x16c] = the strategic " + "generator. Never observed firing"), + (SPY_VT, "ServerSpyManager", 15, "0x00887f30", + "OnAllCombatDone_Tail phase 33, call 2 of 2, at 0x007d98a8", ""), +] + +# dispatch site -> (class, slot, target) +SITES = [ + ("0x007d8469", "ServerTradeManagerImpl", 10, "0x00893290", TRADE_VT), + ("0x007d97a7", "ServerTradeManagerImpl", 14, "0x008590d0", TRADE_VT), + ("0x007d97b6", "ServerTradeManagerImpl", 8, "0x0088e8d0", TRADE_VT), + ("0x007d97c3", "ServerTradeManagerImpl", 12, "0x0088e920", TRADE_VT), + ("0x007d97d0", "ServerTradeManagerImpl", 11, "0x00848570", TRADE_VT), + ("0x007d97dd", "ServerTradeManagerImpl", 9, "0x00868060", TRADE_VT), + ("0x007d97ea", "ServerTradeManagerImpl", 7, "0x0088ad60", TRADE_VT), + ("0x007d97f7", "ServerTradeManagerImpl", 13, "0x0088ef80", TRADE_VT), + ("0x007d9804", "ServerTradeManagerImpl", 15, "0x0082cca0", TRADE_VT), + ("0x007d9811", "ServerSpyManager", 13, "0x008877b0", SPY_VT), + ("0x007d989b", "ServerSpyManager", 14, "0x0088db80", SPY_VT), + ("0x007d98a8", "ServerSpyManager", 15, "0x00887f30", SPY_VT), +] + +# the SVScriptObject hook block on StrategyServer+0x1b4 +SVSO = [ + ("0x007dcb8e", 4, "ProcessTurn, hook id 6 pushed at 0x007dcb8c"), + ("0x007dcb97", 11, "ProcessTurn, the hook body paired with the id-6 gate"), + ("0x007dcbb6", 4, "ProcessTurn, second gate"), + ("0x007dcbbf", 30, "ProcessTurn, the hook body paired with it"), + ("0x007d9767", 4, "OnAllCombatDone_Tail, gate"), + ("0x007d9770", 25, "OnAllCombatDone_Tail, hook body"), + ("0x007d9783", 4, "OnAllCombatDone_Tail, gate"), + ("0x007d978c", 27, "OnAllCombatDone_Tail, hook body"), + ("0x007d9838", 4, "OnAllCombatDone_Tail, gate"), + ("0x007d9841", 30, "OnAllCombatDone_Tail, hook body"), +] + +SVSO_NOTE = ( + "SVScriptObject hook: receiver is StrategyServer+0x1b4, null-checked " + "before the block (`cmp reg,ebx; je`). 30 classes derive from " + "Game::SVScriptObject; the base's empty override is 0x0080c5a0. " + "Lane V2: slot 4 has 14 non-stub overrides, slot 11 has 7, slot 25 has 8, " + "slot 30 has 1, slot 27 has 0. Thirteen of those overrides reach an RNG " + "draw at depth 2-4 (e.g. SVSOCrowRuins slot 11 = 0x00518340 -> " + "0x004f4210 -> IntRangeBell 0x008e6d80). Which generator object those " + "draws use is NOT established -- it arrives as an argument. " + "Scripted-scenario only; presumed inactive in a normal game and still " + "not proven so." +) + + +def main(): + print("== vtable-slot plate comments and labels ==") + for vt, cls, slot, tgt, where, rng in TARGETS: + txt = (f"Game::{cls} vftable {vt} slot {slot} (+0x{4 * slot:x}).\n" + f"Reached ONLY through a virtual dispatch: {where}.\n" + f"Receiver typed from the constructor: StrategyServer ctor " + f"0x007d78d0 stores the {cls} constructor's result into " + f"StrategyServer+0x{'158' if cls.startswith('ServerTrade') else '15c'}" + f"; corroborated by 0x007dcf90 (a StrategyServer method entered " + f"on the +4 sub-object) storing the same two constructors 4 " + f"bytes lower. [lane V2]") + if rng: + txt += "\n" + rng + plate(tgt, txt) + label(tgt, f"{cls}_vslot{slot}") + + print("== dispatch-site comments ==") + for site, cls, slot, tgt, vt in SITES: + comment(site, f"-> Game::{cls}::vslot{slot} = {tgt} " + f"(vftable {vt} + 0x{4 * slot:x}) [lane V2]") + + print("== SVScriptObject hook block ==") + for site, slot, where in SVSO: + comment(site, f"SVScriptObject slot {slot} (+0x{4 * slot:x}) -- {where}." + f"\n{SVSO_NOTE} [lane V2]") + + print("== the two managers ==") + plate("0x007d78d0", + "Game::StrategyServer constructor (vptr installs: 0x00a26084 at +0, " + "0x00a26034 at +4, Mars::IStreamable 0x009e22bc at +0).\n" + "Lane V2 member typings taken from here:\n" + " +0x158 = Game::ServerTradeManagerImpl* (ctor 0x00858f70, " + "stored 0x007d7d81)\n" + " +0x15c = Game::ServerSpyManager* (ctor 0x00832a30, " + "stored 0x007d7d8e)\n" + "Game::ServerTradeManager (vftable 0x00a311a4) is abstract -- 21 of " + "its 22 slots are purecall -- and ServerTradeManagerImpl is its " + "ONLY derived class, so slot N of that interface resolves uniquely. " + "Game::ServerSpyManager has no *Impl: it is itself the concrete " + "class (18 slots, none purecall) over IServerSpyManager / " + "ISpyManager / IStreamable. [lane V2]") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/vtable_map.py b/tools/vtable_map.py new file mode 100644 index 0000000..41102e1 --- /dev/null +++ b/tools/vtable_map.py @@ -0,0 +1,1257 @@ +#!/usr/bin/env python3 +"""Indirect-call-edge resolver for the SOTS1 exe (lane V2). + +Why this exists +--------------- +Every call-graph result this campaign has produced -- reachability, "no +caller", closure sizes -- was computed over **direct** (E8 rel32) edges. In a +1,598-class C++ binary every `virtual` method is reached through +`call [reg+disp]` / `call reg` instead, so all of those results are lower +bounds. Lane Z's live hooking proved the cost: the single largest RNG consumer +of a strategic turn, `GenerateTradeRaidEncounters` 0x00893290, has **zero** +direct call sites in the 41,411-function image. Its only reference is +`Game::ServerTradeManagerImpl` vftable 0x00a31b74 slot 10, dispatched from +`call edx` at 0x007d8469 -- one instruction before a direct call the existing +closure did follow. + +What this tool does +------------------- + vtables vftable VA -> class, sub-object offset, slot -> target + inverse function VA -> [(vftable, class, offset, slot)] + hierarchy class -> derived classes (from RTTI base lists) + ctors function VA -> class it installs a vptr for + sites every indirect call site, with its **slot index** recovered + resolve receiver typing where it is pinnable; honest UNPINNED else + +Method notes that matter +------------------------ + * Bodies are swept to the **next function start**, never `fva + sizeInBytes` + (rule 17: Ghidra's size understates ~11% of bodies and sometimes ends + mid-instruction). + * Backward register resolution refuses to cross an intra-function branch + **target**. A def separated from its use by a label is not a def we can + prove reaches the use, and it is reported as unresolved rather than + guessed. Every unmodelled opcode also stops the walk. + * `call [reg+disp]` with `reg` never loaded from `[obj+0]` is not a virtual + dispatch (import thunks, function pointers in tables). Those are reported + separately, not folded into the vtable answer. + +Usage +----- + uv run python3 tools/vtable_map.py build # -> dumps/vtables.json + uv run python3 tools/vtable_map.py who 0x00893290 # vtables containing fn + uv run python3 tools/vtable_map.py vt 0x00a31b74 # dump one vtable + uv run python3 tools/vtable_map.py site 0x007d8469 + uv run python3 tools/vtable_map.py sites 0x007d92a0 # all in a function + uv run python3 tools/vtable_map.py impls Game::ServerTradeManager + uv run python3 tools/vtable_map.py callers 0x00893290 # indirect callers + uv run python3 tools/vtable_map.py stats +""" +import bisect +import json +import os +import struct +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.dirname(HERE) +sys.path.insert(0, HERE) + +import x86disp as X # noqa: E402 + +EXE = os.path.join(REPO, "dumps", "sots.exe") +FUNCS = os.path.join(REPO, "dumps", "functions.json") +RTTI = os.path.join(REPO, "dumps", "rtti.json") +OUT = os.path.join(REPO, "dumps", "vtables.json") + +R32 = X.R32 + + +# ------------------------------------------------------------- reg write sets +CLOB_CALL = frozenset(("eax", "ecx", "edx")) + + +def writes(raw): + """Registers written by one instruction. + + Returns (set, modelled). `modelled=False` means "this opcode is not in the + table" -- the caller must treat it as clobbering everything and stop. + Only the forms MSVC 7.1 actually emits are modelled; the rest stop the walk + rather than being guessed at, which is what keeps the backward resolver + sound. + """ + i = 0 + o66 = False + while i < len(raw) and raw[i] in (0x66, 0x67, 0xF0, 0xF2, 0xF3, + 0x2E, 0x36, 0x3E, 0x26, 0x64, 0x65): + if raw[i] == 0x66: + o66 = True + i += 1 + if i >= len(raw): + return set(), False + rep = 0xF3 in raw[:i] or 0xF2 in raw[:i] + op = raw[i] + i += 1 + + def modrm(): + if i >= len(raw): + return None, None, None + m = raw[i] + return m >> 6, (m >> 3) & 7, m & 7 + + if op == 0x0F: + if i >= len(raw): + return set(), False + op2 = raw[i] + i += 1 + mod, reg, rm = modrm() + if 0x80 <= op2 <= 0x8F: # jcc rel32 + return set(), True + if 0x90 <= op2 <= 0x9F: # setcc r/m8 + return ({R32[rm]} if mod == 3 else set()), True + if 0x40 <= op2 <= 0x4F: # cmovcc r32, r/m32 + return {R32[reg]}, True + if op2 in (0xAF, 0xB6, 0xB7, 0xBE, 0xBF, 0xBC, 0xBD, + 0x2C, 0x2D, 0x5A, 0x5B): + # imul / movzx / movsx / bsf / bsr / cvttss2si / cvtss2si + if op2 in (0x5A, 0x5B): + return set(), True # cvt*ps*, xmm dest + return {R32[reg]}, True + if op2 == 0x7E: # movd r/m32, mm/xmm + return ({R32[rm]} if mod == 3 else set()), True + if op2 in (0x6E, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, + 0x28, 0x29, 0x2A, 0x2E, 0x2F, 0x51, 0x54, 0x55, 0x56, + 0x57, 0x58, 0x59, 0x5C, 0x5D, 0x5E, 0x5F, 0x6F, 0x7F, + 0xD6, 0xEF, 0xC6, 0x12 | 0): + return set(), True # SSE/MMX, no GP dest + if op2 in (0xA2,): # cpuid + return {"eax", "ebx", "ecx", "edx"}, True + if op2 in (0xA3, 0xAB, 0xB3, 0xBB): # bt/bts/btr/btc + return ({R32[rm]} if mod == 3 else set()), True + if op2 in (0xC0, 0xC1): # xadd + return ({R32[rm], R32[reg]} if mod == 3 else {R32[reg]}), True + if op2 in (0xB0, 0xB1): # cmpxchg + return {"eax"} | ({R32[rm]} if mod == 3 else set()), True + if op2 == 0x31: # rdtsc + return {"eax", "edx"}, True + if op2 == 0x0B or op2 == 0x1F: # ud2 / nop + return set(), True + return set(), False + + mod, reg, rm = modrm() + + if op in (0x88, 0x89): # mov r/m, r + return ({R32[rm]} if mod == 3 else set()), True + if op in (0x8A, 0x8B): # mov r, r/m + return {R32[reg]}, True + if op == 0x8D: # lea + return {R32[reg]}, True + if op in (0xC6, 0xC7): # mov r/m, imm + return ({R32[rm]} if mod == 3 else set()), True + if 0xB0 <= op <= 0xB7: + return {R32[op - 0xB0]}, True # mov r8, imm8 + if 0xB8 <= op <= 0xBF: + return {R32[op - 0xB8]}, True # mov r32, imm32 + if op < 0x40 and (op & 7) < 6 and (op & 0x38) != 0x38: + # add/or/adc/sbb/and/sub/xor family (0x38..0x3D is cmp -> excluded) + lo = op & 7 + if lo in (0, 1): + return ({R32[rm]} if mod == 3 else set()), True + if lo in (2, 3): + return {R32[reg]}, True + return {"eax"}, True + if 0x38 <= op <= 0x3D: # cmp + return set(), True + if 0x40 <= op <= 0x47: + return {R32[op - 0x40]}, True # inc + if 0x48 <= op <= 0x4F: + return {R32[op - 0x48]}, True # dec + if 0x50 <= op <= 0x57: + return set(), True # push + if 0x58 <= op <= 0x5F: + return {R32[op - 0x58]}, True # pop + if op in (0x68, 0x6A): + return set(), True # push imm + if op in (0x69, 0x6B): # imul r, r/m, imm + return {R32[reg]}, True + if 0x70 <= op <= 0x7F: + return set(), True # jcc rel8 + if op in (0x80, 0x81, 0x83): # group1 r/m, imm + if reg == 7: + return set(), True # cmp + return ({R32[rm]} if mod == 3 else set()), True + if op in (0x84, 0x85): + return set(), True # test + if op in (0x86, 0x87): # xchg + return ({R32[rm], R32[reg]} if mod == 3 else {R32[reg]}), True + if op == 0x8F: # pop r/m + return ({R32[rm]} if mod == 3 else set()), True + if 0x90 <= op <= 0x97: + return ({"eax", R32[op - 0x90]} if op != 0x90 else set()), True + if op == 0x98: + return {"eax"}, True # cwde + if op == 0x99: + return {"edx"}, True # cdq + if op == 0x9C or op == 0x9D: + return set(), True # pushfd/popfd + if 0xA0 <= op <= 0xA1: + return {"eax"}, True # mov eax, moffs + if 0xA2 <= op <= 0xA3: + return set(), True # mov moffs, eax + if 0xA4 <= op <= 0xA7: # movs/cmps + return {"esi", "edi"} | ({"ecx"} if rep else set()), True + if op in (0xA8, 0xA9): + return set(), True # test eax, imm + if 0xAA <= op <= 0xAF: # stos/lods/scas + s = {"edi"} if op in (0xAA, 0xAB, 0xAE, 0xAF) else {"esi"} + if op in (0xAC, 0xAD): + s |= {"eax"} + return s | ({"ecx"} if rep else set()), True + if op in (0xC0, 0xC1, 0xD0, 0xD1, 0xD2, 0xD3): # shifts + return ({R32[rm]} if mod == 3 else set()), True + if op in (0xC2, 0xC3, 0xC9, 0xCC, 0xCD): + return set(), True # ret / leave / int + if op == 0xE8: + return set(CLOB_CALL), True # call rel32 + if op in (0xE9, 0xEB): + return set(), True # jmp + if 0xD8 <= op <= 0xDF: # x87 + if op == 0xDF and i < len(raw) and raw[i] == 0xE0: + return {"eax"}, True # fnstsw ax + return set(), True + if op in (0xF6, 0xF7): # group3 + if reg in (0, 1): + return set(), True # test + if reg in (2, 3): + return ({R32[rm]} if mod == 3 else set()), True + return {"eax", "edx"}, True # mul/imul/div/idiv + if op in (0xF8, 0xF9, 0xFC, 0xFD): + return set(), True # clc/stc/cld/std + if op == 0xFE: + return ({R32[rm]} if mod == 3 else set()), True + if op == 0xFF: # group5 + if reg in (0, 1): + return ({R32[rm]} if mod == 3 else set()), True + if reg in (2, 3): + return set(CLOB_CALL), True # call r/m32 + return set(), True # jmp / push + return set(), False + + +# ------------------------------------------------------------ function sweeps +class Code: + def __init__(self): + _, self.secs = X.load_pe(EXE) + self.funcs = X.load_funcs() + self.starts = [f[0] for f in self.funcs] + self.name = {f[0]: f[1] for f in self.funcs} + self.fstart = set(self.starts) + + def sec(self, va): + return next((s for s in self.secs if s[0] <= va < s[1]), None) + + def owner(self, va): + j = bisect.bisect_right(self.starts, va) - 1 + return self.starts[j] if j >= 0 else None + + def body(self, fva): + """Decode a function to the NEXT function start (rule 17). + + Returns (instrs, targets, desync) where instrs is a list of + [va, length, raw] and targets is the set of intra-body branch targets. + """ + sec = self.sec(fva) + if sec is None: + return [], set(), "no section" + sva, eva, buf, _ = sec + j = bisect.bisect_right(self.starts, fva) + limit = min(self.starts[j] if j < len(self.starts) else eva, eva) + i = fva - sva + end = limit - sva + ins = [] + tgts = set() + desync = None + while i < end: + try: + ln, _ = X.decode(buf, i, end) + except X.Desync as e: + desync = str(e) + break + va = sva + i + raw = buf[i:i + ln] + ins.append([va, ln, raw]) + t = branch_target(va, ln, raw) + if t is not None and fva <= t < limit: + tgts.add(t) + i += ln + return ins, tgts, desync + + +def branch_target(va, ln, raw): + """Target of a direct jump, or None. Calls are excluded on purpose: a + `call` returns to the next instruction, so it is not a label.""" + k = 0 + while k < len(raw) and raw[k] in (0x66, 0x67, 0xF0, 0xF2, 0xF3, + 0x2E, 0x36, 0x3E, 0x26, 0x64, 0x65): + k += 1 + op = raw[k] + if op == 0xEB or 0x70 <= op <= 0x7F: + return va + ln + struct.unpack_from("> 6, (m >> 3) & 7, m & 7 + k += 1 + base = index = None + scale = 1 + if mod != 3: + if rm == 4: + sib = raw[k] + k += 1 + scale = 1 << (sib >> 6) + idx = (sib >> 3) & 7 + bse = sib & 7 + index = None if idx == 4 else R32[idx] + base = None if (bse == 5 and mod == 0) else R32[bse] + elif rm == 5 and mod == 0: + base = None + else: + base = R32[rm] + disp = 0 + if mod == 1: + disp = struct.unpack_from(" the vtable slots that name it + inverse = {} + for vf, v in vfts.items(): + for i, s in enumerate(v["slots"]): + inverse.setdefault(s, []).append([vf, v["class"], v["offset"], i]) + + # --- class hierarchy: base -> derived + derived = {} + classes = {} + for vf, v in vfts.items(): + classes.setdefault(v["class"], []).append(vf) + for b in v["bases"]: + if b != v["class"]: + derived.setdefault(b, set()).add(v["class"]) + derived = {k: sorted(v) for k, v in derived.items()} + + # --- abstract classes: every non-dtor slot is purecall + abstract = {} + for vf, v in vfts.items(): + n = len(v["slots"]) + p = sum(1 for s in v["slots"] if s == PURECALL) + if p and p >= n - 1: + abstract.setdefault(v["class"], []).append([vf, v["offset"], n, p]) + + vtset = set(vfts) + + # --- sweep: indirect call sites, vptr stores, direct calls + sites = [] + ctor_installs = {} # func -> [[vftable, disp]] + direct = {} # caller -> [callee] (E8 rel32) + jumped = {} # caller -> [callee] (tail jump / split) + fn_desync = 0 + swept = 0 + stores = [] + fieldstores = [] # [store va, func, basereg, disp, callee] + thisedges = {} # caller -> [callees that receive caller's `this`] + memberedges = {} # caller -> [[callee, member disp]] + footprints = {} # vftable -> member displacements its ctor writes + carriers = {} # function -> regs that hold `this` throughout + for fva, fname, fsz in code.funcs: + ins, tgts, desync = code.body(fva) + if not ins: + continue + swept += 1 + if desync: + fn_desync += 1 + kinds = [instr_kind(r) for _, _, r in ins] + wr = [writes(r) for _, _, r in ins] + _te, _me, carr = this_edges(ins, kinds, wr, tgts) + carrier = carr + dcs = [] + jmps = [] + for n, (va, ln, raw) in enumerate(ins): + t = call_rel32(va, ln, raw) + if t is not None: + dcs.append(t) + jt = branch_target(va, ln, raw) + if jt is not None and jt != fva and jt in code.fstart: + # a jump that lands on another function's start: a tail call, + # or a Ghidra split. Either way it is a real control-flow + # edge, and a closure built from E8 alone misses it. + jmps.append(jt) + k = kinds[n] + if k is None: + continue + if k[0] in ("movimm", "regimm") and k[7] in vtset: + # vptr installation (C7 /0) or the `mov reg,vftable` half of one + if k[0] == "movimm" and k[2] != 3: + ctor_installs.setdefault(fva, []).append([k[7], k[6]]) + elif k[0] == "regimm": + ctor_installs.setdefault(fva, []).append([k[7], None]) + if k[0] == "icall": + sites.append(resolve_site(code, fva, ins, kinds, wr, tgts, + n, k, carrier)) + if k[0] == "movstore" and k[2] != 3 and k[3] is not None \ + and k[4] is None: + stores.append((fva, n, k)) + if dcs: + direct[fva] = sorted(set(dcs)) + if jmps: + jumped[fva] = sorted(set(jmps)) + te, me = _te, _me + if carr: + carriers[hex(fva)] = [[r, hex(a), hex(b)] for r, a, b in carr] + for t in te: + thisedges.setdefault(hex(fva), []).append(hex(t)) + for t, dd in me: + memberedges.setdefault(hex(fva), []).append([hex(t), dd]) + # field typing needs the whole body decoded, so it runs here + # constructor member-write footprint: every displacement written off + # the same register the vptr was installed through. A class's ctor + # *enumerates* its members (rule 5), which makes the footprint a far + # sharper owner signature than any single displacement. + for vf, dd in ctor_installs.get(fva, ()): + if dd != 0: + continue + vreg = None + for (_, n, k) in stores: + pass + for n2, k2 in enumerate(kinds): + if k2 and k2[0] == "movimm" and k2[2] != 3 and k2[6] == 0 \ + and k2[7] == vf: + vreg = k2[3] + break + if vreg is None: + continue + foot = sorted({k2[6] for k2 in kinds + if k2 and k2[0] in ("movstore", "movimm") + and k2[2] != 3 and k2[3] == vreg and k2[4] is None + and 0 <= k2[6] < 0x4000}) + footprints.setdefault(hex(fva), {})["cls"] = vfts[vf]["class"] + footprints[hex(fva)].setdefault("d", []).extend(foot) + for (_, n, k) in stores: + src = k[1] + p, why = back_def(ins, kinds, wr, tgts, n, src) + if p is None: + continue + t = None + if src == "eax": + t = call_rel32(*ins[p]) + if t is None: + kp = kinds[p] + if kp and kp[0] == "movload" and kp[2] == 3 and kp[3] == "eax": + q, _ = back_def(ins, kinds, wr, tgts, p, "eax") + if q is not None: + t = call_rel32(*ins[q]) + if t is None: + continue + fieldstores.append([hex(ins[n][0]), hex(fva), k[3], k[6], hex(t)]) + stores.clear() + + out = { + "inverse": {hex(k): v for k, v in inverse.items()}, + "derived": derived, + "abstract": abstract, + "classVtables": {k: [hex(x) for x in v] for k, v in classes.items()}, + "ctorInstalls": {hex(k): v for k, v in ctor_installs.items()}, + "fieldStores": fieldstores, + "thisEdges": thisedges, + "memberEdges": memberedges, + "carriers": carriers, + "footprints": {k: {"cls": v["cls"], "d": sorted(set(v.get("d", [])))} + for k, v in footprints.items()}, + "sites": sites, + "direct": {hex(k): [hex(x) for x in v] for k, v in direct.items()}, + "jumped": {hex(k): [hex(x) for x in v] for k, v in jumped.items()}, + "stats": {"functionsSwept": swept, "desyncFunctions": fn_desync, + "vftables": len(vfts), "indirectSites": len(sites)}, + } + with open(OUT, "w") as fh: + json.dump(out, fh) + kinds = {} + for s in sites: + kinds[s["kind"]] = kinds.get(s["kind"], 0) + 1 + print(f"functions swept : {swept}") + print(f"desync functions : {fn_desync}") + print(f"vftables : {len(vfts)}") + print(f"indirect sites : {len(sites)}") + for k, v in sorted(kinds.items(), key=lambda x: -x[1]): + print(f" {k:<22}: {v}") + print(f"ctor vptr installs: {len(ctor_installs)} functions") + + +def this_edges(ins, kinds, wr, tgts): + """Direct callees that are handed the caller's own `this` pointer. + + MSVC parks `this` in a callee-saved register or an `ebp` slot in the + prologue and reloads it into `ecx` before each member call, so a member + function's class propagates down the direct call graph. That is the only + way to type the receiver inside a **non-virtual** method -- and the + functions this campaign cares about (`DetectEncounters`, + `OnAllCombatDone_Tail`) are exactly that: StrategyServer methods that + appear in no vftable. + + Carriers are only *created* before the first intra-function label, where + the prologue lives and no branch has yet joined; they are killed anywhere + they are written. A carrier that is never killed therefore holds `this` + on every path, which is what makes the edge sound. + """ + regs = {"ecx"} + slots = set() + live = {"ecx": ins[0][0] if ins else 0} + spans = [] # [reg, first va, va it stops being `this`] + first_label = min(tgts) if tgts else None + out = set() + mem = set() + + def kill(r, va): + if r in live: + spans.append([r, live.pop(r), va]) + + for n, (va, ln, raw) in enumerate(ins): + k = kinds[n] + t = call_rel32(va, ln, raw) + if t is not None: + m, why = back_def(ins, kinds, wr, tgts, n, "ecx") + if m is None: + if why == "no-def-in-body" and "ecx" in regs: + out.add(t) + else: + km = kinds[m] + if km and km[0] == "movload" and km[1] == "ecx": + if km[2] == 3 and km[3] in regs: + out.add(t) + elif km[2] != 3 and km[3] == "ebp" and km[6] in slots \ + and km[4] is None: + out.add(t) + elif km[2] != 3 and km[4] is None and km[6] \ + and km[3] in regs: + # ecx = [this + d]: the callee is a method of whatever + # class the *member* at +d holds + mem.add((t, km[6])) + # kills first -- an instruction that *defines* a carrier must not be + # seen as destroying it + w, modelled = wr[n] + if not modelled: + for r in list(regs): + kill(r, va) + regs.clear() + slots.clear() + else: + for r in regs & w: + kill(r, va) + regs -= w + if k and k[0] == "movstore" and k[2] != 3 and k[3] == "ebp" \ + and k[4] is None and k[6] in slots: + slots.discard(k[6]) + # Creation. A copy *from* a register that is provably `this` at this + # address makes the destination `this` too, wherever it sits -- MSVC + # reloads `this` from its stack home all over a large body. Creating + # a carrier out of thin air is still restricted to the prologue, where + # no branch has joined yet. + if k: + src = k[3] if k[0] in ("movload", "movstore") else None + fresh = (first_label is None or va < first_label) + if not (fresh or (src in regs and k[0] == "movload" and k[2] == 3) + or (k[0] == "movload" and k[2] != 3 and src == "ebp" + and k[4] is None and k[6] in slots)): + k = None + if k: + new = None + if k[0] == "movload" and k[2] == 3 and k[3] in regs: + new = k[1] + elif k[0] == "movstore" and k[2] != 3 and k[3] == "ebp" \ + and k[4] is None and k[1] in regs: + slots.add(k[6]) + elif k[0] == "movload" and k[2] != 3 and k[3] == "ebp" \ + and k[4] is None and k[6] in slots: + new = k[1] + if new: + regs.add(new) + live.setdefault(new, va) + end = ins[-1][0] + 1 if ins else 0 + for r in list(live): + kill(r, end) + return out, mem, spans + + +def back_def(ins, kinds, wr, tgts, n, reg): + """Last definition of `reg` strictly before index n, or a reason it is not + provable. Refuses to cross a branch target or an unmodelled opcode.""" + for m in range(n - 1, -1, -1): + if ins[m][0] in tgts: + return None, "crosses-label" + s, modelled = wr[m] + if not modelled: + return None, "unmodelled-opcode" + if reg in s: + return m, None + # a call clobbers eax/ecx/edx; already covered by wr + return None, "no-def-in-body" + + +def is_this(carrier, reg, va): + """Was `reg` provably holding the incoming `this` at address va? + + Spans end where the register is written -- including the `pop esi` of the + epilogue, which is why the carrier set has to be a span and not a single + set for the whole body. + """ + return any(r == reg and a <= va < b for r, a, b in carrier) + + +def resolve_site(code, fva, ins, kinds, wr, tgts, n, k, carrier=()): + """Recover the vtable slot index and, if possible, the receiver expression + for one indirect call site.""" + va = ins[n][0] + _, _, mod, base, idx, sc, disp = k + site = {"va": hex(va), "func": hex(fva), "name": code.name.get(fva, ""), + "kind": "unknown", "slot": None, "recv": None, "note": None} + + if mod == 3: + # `call reg` -- the slot came from an earlier `mov reg,[vptr+disp]` + m, why = back_def(ins, kinds, wr, tgts, n, base) + if m is None: + site["kind"] = "call-reg-unresolved" + site["note"] = why + return site + km = kinds[m] + if km is None or km[0] != "movload" or km[2] == 3 or km[3] is None: + site["kind"] = "call-reg-nonmem" + return site + vreg, vdisp = km[3], km[6] + return _from_vptr(code, ins, kinds, wr, tgts, m, site, vreg, vdisp, + carrier) + + if base is None: + # absolute [disp32] -- an import thunk or a global function pointer + site["kind"] = "call-abs" + site["note"] = hex(disp & 0xFFFFFFFF) + return site + if idx is not None: + site["kind"] = "call-indexed" + site["note"] = f"[{base}+{idx}*{sc}+0x{disp:x}]" + return site + # `call [reg+disp]` -- reg should be the vptr + return _from_vptr(code, ins, kinds, wr, tgts, n, site, base, disp, + carrier) + + +def _from_vptr(code, ins, kinds, wr, tgts, n, site, vreg, vdisp, carrier=()): + """`vreg` is believed to hold a vptr; `vdisp` is the byte offset of the + slot. Prove the vptr by finding `mov vreg,[obj+0]`.""" + if vdisp < 0 or vdisp % 4: + site["kind"] = "non-slot-disp" + site["note"] = hex(vdisp) + return site + site["slot"] = vdisp // 4 + m, why = back_def(ins, kinds, wr, tgts, n, vreg) + if m is None: + site["kind"] = "vptr-unresolved" + site["note"] = why + return site + km = kinds[m] + if km is None: + site["kind"] = "vptr-unmodelled" + return site + if km[0] == "movload" and km[2] != 3 and km[3] is not None and km[6] == 0 \ + and km[4] is None: + # mov vreg, [obj] -- a genuine vptr load + site["kind"] = "virtual" + site["recv"] = recv_expr(code, ins, kinds, wr, tgts, m, km[3], carrier) + return site + if km[0] == "movload" and km[2] != 3 and km[3] is None: + # mov vreg, [abs] -- vptr from a global object, or a global fn table + site["kind"] = "vptr-global" + site["note"] = hex(km[6] & 0xFFFFFFFF) + return site + site["kind"] = "not-vptr" + site["slot"] = None + site["note"] = km[0] + return site + + +def recv_expr(code, ins, kinds, wr, tgts, m, oreg, carrier=()): + """Describe where the object pointer came from, one level up.""" + if is_this(carrier, oreg, ins[m][0]): + return {"k": "this", "reg": oreg} + p, why = back_def(ins, kinds, wr, tgts, m, oreg) + if p is None: + # Only "no definition anywhere in the body" proves the value is the + # incoming register. "crosses-label" means a def may exist on a path + # we cannot see, and must not be read as `this`. + if why == "no-def-in-body": + return {"k": "entryreg", "reg": oreg} + return {"k": "unpinned", "reg": oreg, "note": why} + kp = kinds[p] + if kp is None: + return {"k": "unpinned", "reg": oreg, "note": "unmodelled"} + if kp[0] == "movload" and kp[2] != 3 and kp[3] is not None \ + and kp[4] is None: + return {"k": "field", "base": kp[3], "disp": kp[6], + "this": is_this(carrier, kp[3], ins[p][0]), + "at": hex(ins[p][0])} + if kp[0] == "movload" and kp[2] != 3 and kp[3] is None: + return {"k": "global", "va": hex(kp[6] & 0xFFFFFFFF)} + if kp[0] == "movload" and kp[2] == 3: + if is_this(carrier, kp[3], ins[p][0]): + return {"k": "this", "reg": kp[3]} + return {"k": "reg", "reg": kp[3]} + if kp[0] == "lea": + return {"k": "lea", "base": kp[3], "disp": kp[6]} + t = call_rel32(*ins[p]) if oreg == "eax" else None + if t is not None: + return {"k": "callret", "target": hex(t), + "name": code.name.get(t, "")} + return {"k": "other", "form": kp[0], "at": hex(ins[p][0])} + + +# ---------------------------------------------------------------- resolution +def func_classes(d, vfts): + """function VA -> {(class, sub-object offset)} the function is a method of, + plus the member-type index that falls out of the same fixpoint. + + Four sources, all exact, iterated to a fixpoint because member typing and + method typing feed each other: + + seed a function that *is* slot k of class C's vftable at sub-object +o + is a C method entered with `this` = obj+o; + seed a function that stores a C vftable pointer into [reg+d], d >= 0, + is a C constructor. (Negative displacements are inlined EH frames + parking a `std::bad_alloc` vptr on the stack, not construction.) + edge `mov ecx,; call F` -- F is a method of the same class; + edge `mov ecx,[+d]; call F` -- F is a method of whatever + class the member at +d holds, which the member index supplies. + + The member index itself comes from `ctor result -> [this+d]` stores inside + functions whose own class is known, so every new method typing can add new + member typings and vice versa. Returns (fc, memberIndex, dispOnlyIndex). + """ + fc = {} + for f, rows in d["inverse"].items(): + for vf, cls, off, slot in rows: + fc.setdefault(int(f, 16), set()).add((cls, off)) + for f in d["ctorInstalls"]: + # A constructor receives the COMPLETE object, and installs each of its + # vptrs at [this + that vftable's sub-object offset]. So its `this` + # offset is 0, not the offset of whichever vftable it happens to + # install -- getting that wrong registers every member of the class at + # both +d and +d+4 and makes its own member typings ambiguous. + c = ctor_class(d, vfts, f) + if c is None: + continue + if not any(dd is not None and vf in vfts and dd == vfts[vf]["offset"] + for vf, dd in d["ctorInstalls"][f]): + continue + fc.setdefault(int(f, 16), set()).add((c, 0)) + ap = os.path.join(REPO, "ghidra", "vtable-owners.json") + if os.path.exists(ap): + with open(ap) as fh: + for e in json.load(fh)["owners"]: + fc.setdefault(int(e["func"], 16), set()).add( + (e["class"], e.get("offset", 0))) + auth = {f: {c for c, o in v} for f, v in fc.items()} + te = {int(a, 16): [int(b, 16) for b in v] for a, v in d["thisEdges"].items()} + me = {int(a, 16): [(int(b, 16), dd) for b, dd in v] + for a, v in d["memberEdges"].items()} + ctorcls = {} + for callee in {x[4] for x in d["fieldStores"]}: + c = ctor_class(d, vfts, callee) + if c: + ctorcls[callee] = c + + idx = anon = None + for _ in range(12): + idx, anon = {}, {} + for sva, fva, base, disp, callee in d["fieldStores"]: + c = ctorcls.get(callee) + if c is None: + continue + if base in ("ebp", "esp"): + continue + owners = fc.get(int(fva, 16)) + if not owners: + anon.setdefault(disp, {}).setdefault(c, []).append(sva) + continue + for ocls, ooff in owners: + idx.setdefault((ocls, disp + ooff), {}) \ + .setdefault(c, []).append(sva) + changed = False + def merge(b, new): + # An authoritative typing (vftable slot or vptr install) fixes the + # sub-object offset exactly. Propagation must not add a *second* + # offset for a class already fixed that way: a method entered on + # the +4 sub-object reads its members 4 lower, and letting both + # offsets stand turns every one of its member typings into a + # spurious ambiguity one slot away. + fixed = auth.get(b, set()) + add = {(c, o) for c, o in new if c not in fixed} + n0 = len(fc.get(b, ())) + fc.setdefault(b, set()).update(add) + return len(fc[b]) != n0 + + for a, bs in te.items(): + if a not in fc: + continue + for b in bs: + changed |= merge(b, fc[a]) + for a, bs in me.items(): + if a not in fc: + continue + for b, dd in bs: + got = set() + for ocls, ooff in fc[a]: + hits = idx.get((ocls, dd + ooff), {}) + if len(hits) == 1: + got.add((next(iter(hits)), 0)) + if not got: + continue + changed |= merge(b, got) + if not changed: + break + return fc, idx, anon + + +def ctor_class(d, vfts, callee): + """The class a constructor constructs, or None. + + A ctor installs its own vptrs *and* those of any base whose constructor the + compiler inlined, so several classes can appear at sub-object +0. The + most-derived one is the single candidate whose RTTI base list contains all + the others -- that is exactly what a base list is for. If no candidate + dominates, the function is not a constructor we can name and returns None + rather than a guess. + """ + inst = d["ctorInstalls"].get(callee, []) + cs = {vfts[v]["class"] for v, dd in inst + if v in vfts and dd is not None and dd >= 0 and vfts[v]["offset"] == 0} + if not cs: + return None + if len(cs) == 1: + return next(iter(cs)) + bases = {} + for v, dd in inst: + if v in vfts and vfts[v]["offset"] == 0: + bases[vfts[v]["class"]] = set(vfts[v]["bases"]) + for c in cs: + if cs - {c} <= bases.get(c, set()): + return c + return None + + +def field_index(d, vfts): + fc, idx, anon = func_classes(d, vfts) + return idx, anon, fc + + +def vtable_for(vfts, cls, off): + for vf, v in vfts.items(): + if v["class"] == cls and v["offset"] == off: + return vf, v + return None, None + + +def resolve_all(d, vfts): + """Attach a receiver class and a target function to every `virtual` site + we can pin. Returns (rows, counters).""" + fidx, anon, fc = field_index(d, vfts) + rows = [] + ctr = {} + for s in d["sites"]: + if s["kind"] != "virtual": + continue + r = s["recv"] or {} + fva = int(s["func"], 16) + mine = fc.get(fva, set()) + cand = None + how = None + if r.get("k") == "field" and r["base"] in ("ebp", "esp"): + # [ebp+8] is argument 1, [ebp-0x30] a local -- neither is a member + # of `this`, and typing them as one produced every out-of-range + # result the first version of V4 found. + how = "stack" + elif r.get("k") == "field" and not r.get("this"): + # the base register is not a proven `this` carrier, so which + # object's member this is cannot be established + how = "field-nonthis" + elif r.get("k") == "field": + hits = {} + for ocls, ooff in mine: + for cls, where in fidx.get((ocls, r["disp"] + ooff), {}).items(): + hits.setdefault(cls, []).extend(where) + if len(hits) == 1: + cand, how = next(iter(hits)), "field" + elif len(hits) > 1: + how = "field-ambiguous" + elif not mine: + # no owning class for the *calling* function: fall back to a + # bare displacement match, which V4 shows is barely better + # than chance. Kept separate and never merged into `field`. + # A bare displacement match across 1,598 classes is not + # evidence: V4 measures it at ~81% out-of-range, *worse* than + # picking a vtable at random. Counted, never used. + how = "disp-only-rejected" + elif (r.get("k") == "this" + or (r.get("k") == "entryreg" and r.get("reg") == "ecx")) \ + and len(mine) == 1: + cand, how = next(iter(mine))[0], "self" + elif r.get("k") == "callret": + c = ctor_class(d, vfts, r["target"]) + if c: + cand, how = c, "callret" + ctr[how or "unpinned"] = ctr.get(how or "unpinned", 0) + 1 + tgts = [] + if cand: + # the receiver's static type may itself be abstract; the callable + # set is that class plus every class derived from it + fam = [cand] + list(d["derived"].get(cand, [])) + for c in fam: + vf, v = vtable_for(vfts, c, 0) + if v and s["slot"] is not None and s["slot"] < len(v["slots"]): + t = v["slots"][s["slot"]] + if t != PURECALL: + tgts.append([c, hex(vf), hex(t)]) + rows.append({**s, "cls": cand, "how": how, "targets": tgts}) + return rows, ctr + + +# ------------------------------------------------------------------ validate +def validate(d, vfts, code): + ok = fail = 0 + + def check(label, cond, detail=""): + nonlocal ok, fail + if cond: + ok += 1 + print(f" PASS {label} {detail}") + else: + fail += 1 + print(f" FAIL {label} {detail}") + + print("V1 the known case -- lane Z's virtual edge, rediscovered blind") + s = next((x for x in d["sites"] if x["va"] == hex(0x007d8469)), None) + check("site 0x007d8469 classified virtual", s and s["kind"] == "virtual", + str(s and s["kind"])) + check("slot recovered = 10", s and s["slot"] == 10, str(s and s["slot"])) + check("receiver = member +0x158", s and s["recv"]["k"] == "field" + and s["recv"]["disp"] == 0x158, str(s and s["recv"])) + rows, _ = resolve_all(d, vfts) + r = next((x for x in rows if x["va"] == hex(0x007d8469)), None) + check("receiver class = Game::ServerTradeManagerImpl", + r and r["cls"] == "Game::ServerTradeManagerImpl", str(r and r["cls"])) + check("target = 0x00893290 GenerateTradeRaidEncounters", + r and [t[2] for t in r["targets"]] == [hex(0x00893290)], + str(r and r["targets"])) + inv = d["inverse"].get(hex(0x00893290), []) + check("0x00893290 named by exactly one vtable slot", len(inv) == 1, str(inv)) + ndirect = sum(1 for c in d["direct"].values() if hex(0x00893290) in c) + check("0x00893290 has zero direct call sites", ndirect == 0, str(ndirect)) + + print("\nV2 the *Impl rule -- abstract interface, one concrete override") + vf, v = vtable_for(vfts, "Game::ServerTradeManager", 0) + p = sum(1 for x in v["slots"] if x == PURECALL) + check("Game::ServerTradeManager is abstract", + p == len(v["slots"]) - 1, f"{p}/{len(v['slots'])} purecall") + check("exactly one derived class", + d["derived"].get("Game::ServerTradeManager") == + ["Game::ServerTradeManagerImpl"], + str(d["derived"].get("Game::ServerTradeManager"))) + vf2, v2 = vtable_for(vfts, "Game::ServerTradeManagerImpl", 0) + check("Impl overrides every slot", + all(x != PURECALL for x in v2["slots"]) and + len(v2["slots"]) == len(v["slots"]), f"{len(v2['slots'])} slots") + vfs, vs = vtable_for(vfts, "Game::ServerSpyManager", 0) + check("Game::ServerSpyManager is itself concrete (no *Impl)", + all(x != PURECALL for x in vs["slots"]) and + not d["derived"].get("Game::ServerSpyManager"), + f"{len(vs['slots'])} slots, derived=" + f"{d['derived'].get('Game::ServerSpyManager')}") + check("ServerSpyManager derives IServerSpyManager/ISpyManager/IStreamable", + "Game::IServerSpyManager" in vs["bases"], str(vs["bases"])) + + print("\nV3 slot-index recovery -- out-of-range test on self-dispatch") + fc, _, _ = func_classes(d, vfts) + n = bad = 0 + for x in d["sites"]: + if x["kind"] != "virtual" or not x["recv"]: + continue + if x["recv"]["k"] not in ("entryreg", "this"): + continue + cs = fc.get(int(x["func"], 16), set()) + if len(cs) != 1: + continue + cls, off = next(iter(cs)) + _, v = vtable_for(vfts, cls, off) + if not v: + continue + n += 1 + if x["slot"] >= len(v["slots"]): + bad += 1 + print(f" self-dispatch sites with a uniquely known receiver class: {n}") + print(f" slot index out of that class's vtable range : {bad}" + f" ({100.0 * bad / max(n, 1):.2f}%)") + + print("\nV4 receiver pinning -- out-of-range test, with a random baseline") + sizes = sorted(len(v["slots"]) for v in vfts.values()) + byhow = {} + for x in rows: + if not x["cls"]: + continue + _, v = vtable_for(vfts, x["cls"], 0) + b = (not v) or x["slot"] >= len(v["slots"]) + rnd = 1 - sum(1 for z in sizes if z > x["slot"]) / len(sizes) + e = byhow.setdefault(x["how"], [0, 0, 0.0]) + e[0] += 1 + e[1] += 1 if b else 0 + e[2] += rnd + print(f" {'pinning route':<18} {'sites':>6} {'out-of-range':>14} " + f"{'random':>9}") + for h, (n4, b4, rb) in sorted(byhow.items()): + print(f" {h:<18} {n4:>6} {b4:>6} ({100.0*b4/n4:5.1f}%) " + f"{100.0*rb/n4:8.1f}%") + + print("\nV5 member typing -- do independent stores of one member agree?") + fidx, _, _ = field_index(d, vfts) + multi = agree = 0 + for disp, m in fidx.items(): + tot = sum(len(x) for x in m.values()) + if tot < 2: + continue + multi += 1 + if len(m) == 1: + agree += 1 + print(f" members with >=2 independent construction stores: {multi}") + print(f" those where every store names the same class : {agree}" + f" ({100.0 * agree / max(multi, 1):.1f}%)") + + print(f"\n{ok} pass, {fail} fail") + return 1 if fail else 0 + + +# --------------------------------------------------------------------- query +def load(): + if not os.path.exists(OUT): + sys.exit("no index; run: uv run python3 tools/vtable_map.py build") + with open(OUT) as fh: + return json.load(fh) + + +def main(): + argv = sys.argv[1:] + if not argv or argv[0] == "build": + return build() + cmd = argv[0] + d = load() + cols, vfts = load_rtti() + code = Code() + + if cmd == "who": + va = int(argv[1], 0) + rows = d["inverse"].get(hex(va), []) + if not rows: + print(f"0x{va:08x} is in no vftable") + for vf, cls, off, slot in rows: + print(f" vftable 0x{vf:08x} +0x{off:x} slot {slot:<3} {cls}") + elif cmd == "vt": + va = int(argv[1], 0) + v = vfts.get(va) + if not v: + return print("not a vftable") + print(f"0x{va:08x} {v['class']} sub-object +0x{v['offset']:x} " + f"{len(v['slots'])} slots bases={v['bases']}") + for i, s in enumerate(v["slots"]): + print(f" [{i:>2}] +0x{4 * i:<3x} 0x{s:08x} " + f"{code.name.get(s, '')}{' PURECALL' if s == PURECALL else ''}") + elif cmd == "impls": + cls = argv[1] + print(f"{cls}: derived = {d['derived'].get(cls, [])}") + if cls in d["abstract"]: + print(f" abstract vtables: {d['abstract'][cls]}") + elif cmd == "site": + va = int(argv[1], 0) + for s in d["sites"]: + if s["va"] == hex(va): + print(json.dumps(s, indent=2)) + elif cmd == "sites": + fva = int(argv[1], 0) + for s in d["sites"]: + if s["func"] == hex(fva): + print(f" {s['va']} slot={s['slot']} {s['kind']} " + f"recv={s['recv']} {s['note'] or ''}") + elif cmd == "callers": + va = int(argv[1], 0) + rows = d["inverse"].get(hex(va), []) + slots = {r[3] for r in rows} + clss = {r[1] for r in rows} + print(f"target in vtables of {sorted(clss)} at slots {sorted(slots)}") + for s in d["sites"]: + if s["slot"] in slots and s["kind"] == "virtual": + print(f" {s['va']} slot {s['slot']} in {s['name']} " + f"({s['func']}) recv={s['recv']}") + elif cmd == "field": + # `field 0x158 [funcVA]` -- who stores a constructed object into +disp, + # and what class did the constructor install a vptr for? + disp = int(argv[1], 0) + only = int(argv[2], 0) if len(argv) > 2 else None + ci = d["ctorInstalls"] + for sva, fva, base, dsp, callee in d["fieldStores"]: + if dsp != disp: + continue + if only is not None and int(fva, 16) != only: + continue + inst = ci.get(callee, []) + cs = sorted({vfts[v]["class"] for v, _ in inst if v in vfts}) + if not cs: + continue + print(f" {sva} in {code.name.get(int(fva, 16), '')} ({fva}) " + f"[{base}+0x{disp:x}] <- {callee} " + f"{code.name.get(int(callee, 16), '')} installs {cs}") + elif cmd == "owner": + # `owner ` -- rank candidate classes for the object a + # register points at, by overlap with each class ctor's member-write + # footprint. A RANKER, never a filter (rule 9). + fva = int(argv[1], 0) + reg = argv[2] if len(argv) > 2 else None + ins, tgts, _ = code.body(fva) + kinds = [instr_kind(r) for _, _, r in ins] + touched = {} + for k in kinds: + if k and k[0] in ("movload", "movstore", "movimm", "lea") \ + and k[2] != 3 and k[3] and k[4] is None and k[6] > 0: + touched.setdefault(k[3], set()).add(k[6]) + for r in ([reg] if reg else sorted(touched)): + t = touched.get(r, set()) + if len(t) < 3: + continue + # IDF weighting: +0x4 and +0x8 are members of nearly every class + # and carry no information; a rare offset like +0x158 does. + import math + df = {} + for rec in d["footprints"].values(): + for x in set(rec["d"]): + df[x] = df.get(x, 0) + 1 + nf = len(d["footprints"]) or 1 + w = {x: math.log(nf / (1 + df.get(x, 0))) for x in t} + tot = sum(w.values()) or 1.0 + sc = [] + for cf, rec in d["footprints"].items(): + f = set(rec["d"]) + if not f: + continue + hit = t & f + sc.append((sum(w[x] for x in hit) / tot, len(hit), + rec["cls"], cf)) + sc.sort(reverse=True) + print(f" {r}: {len(t)} distinct member offsets touched") + seen = set() + for frac, n, cls, cf in sc: + if cls in seen: + continue + seen.add(cls) + print(f" {frac * 100:5.1f}% {n:>3}/{len(t)} {cls}" + f" (ctor {cf})") + if len(seen) >= 5: + break + elif cmd == "resolve": + rows, ctr = resolve_all(d, vfts) + want = int(argv[1], 0) if len(argv) > 1 else None + for r in rows: + if want is not None and int(r["func"], 16) != want \ + and int(r["va"], 16) != want: + continue + t = ", ".join(f"{c}::[{s['slot'] if False else ''}]{tv}" + for c, _, tv in r["targets"]) or "-" + print(f" {r['va']} slot {str(r['slot']):>3} " + f"{r['cls'] or 'UNPINNED':<38} {r['how'] or '':<16} -> {t}") + if want is None: + print(json.dumps(ctr, indent=2)) + elif cmd == "validate": + validate(d, vfts, code) + elif cmd == "stats": + print(json.dumps(d["stats"], indent=2)) + kinds = {} + for s in d["sites"]: + kinds[s["kind"]] = kinds.get(s["kind"], 0) + 1 + for k, v in sorted(kinds.items(), key=lambda x: -x[1]): + print(f" {k:<22}: {v}") + return 0 + + +if __name__ == "__main__": + sys.exit(main() or 0)