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.
184 lines
8.5 KiB
Python
184 lines
8.5 KiB
Python
#!/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())
|