Ghidra does not index ModRM displacements, so `lea reg,[reg+disp]` -- the MSVC idiom for taking a member's address -- is invisible to find-constant-uses. That blind spot parked ServerPlayer+0x274 and covers every non-trivial member of the ~1,600 classes still to map. tools/x86disp.py: full x86-32 length decoder (prefixes, 1/2/3-byte opcodes, ModRM, SIB, sign-extended disp8, disp32, every immediate form) swept from Ghidra's 41,089 function starts so decodes begin on real instruction boundaries. 2,174,504 instructions, 612,166 displacement sites, 100.0% code coverage, 70 desyncs (0.17%), zero unknown opcodes. Excludes no-base disp32 forms (mod=0/rm=5, sib.base=5) which are absolute globals, not member offsets. Commands: build/query/cohort/func/dis/stats/brute. Works off a gitignored local cache in dumps/ rather than hammering CT111. Validated before use: re-finds lea eax,[ecx+0x29c] in ServerPlayer::GetEventStorage (0x0080db00) and both known OnTechResearched +0x29c sites, plus a new one in ProcessTurn. Positive control: the ServerPlayer serializer scores 50/50 known offsets. sizeof(Game::ObservedTech) = 0x2c (44), proven three ways: the exact magic divide 0x2e8ba2e9 sar 3 at 0x0087239f, imul reg,reg,0x2c at 0x0087243a and 0x007b735b, and the search stride add edi,0x2c at 0x007ba257. Append site: RecordObservedTech+0xdf (0x007ba27f) -- lea ecx,[player+0x274]; call vector_ObservedTech_push_back 0x007b7320 RecordObservedTech (0x007ba1a0) is a direct callee of OnTechResearched and de-duplicates by tech name before appending. The realloc through 0x007b5820 is why lane R's guard saw all three vector words move. Element carries a vptr (RTTI .?AVObservedTech@Game@@) at +0 and a 0x18-byte std::string at +0x0c; the four on-disk ints map onto +0x04/+0x06/+0x08/+0x24/+0x28 in an order this read does NOT determine, and is not guessed. Also corrects harness-audit row 11: ComputeBudget has no store to Budget+0x64 (its only +0x64 accesses are loads off a different base), and ProcessResearch's int* overbudget arg is a ProcessTurn stack local, not Budget+0x64. Agrees with lane R's guard seeing 0 changes in 4284 calls. Honest limits are recorded in the note and the board: this is a recall tool, not an oracle. Class-level precision at 0x274 is ~13% by function, i.e. a ~900x search-space cut that still needs one call-graph check. Cohort ranking must not be used as a hard filter -- it would have discarded the correct answer here. Ghidra writeback: labels + plate comments on RecordObservedTech, vector_ObservedTech_push_back, ObservedTech_ctor, vector_ObservedTech_assign, vector_44B_grow, vftable_ObservedTech.
57 lines
1.8 KiB
Python
57 lines
1.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Cache the full Ghidra function list (address, size, name) to dumps/functions.json.
|
|
|
|
Ghidra/ReVa is a shared resource -- pull the list once, then work offline.
|
|
Usage: uv run python3 tools/cache_functions.py [--refresh]
|
|
"""
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
REPO = os.path.dirname(HERE)
|
|
OUT = os.path.join(REPO, "dumps", "functions.json")
|
|
PROG = "/Sword of the Stars.exe"
|
|
|
|
|
|
def call(tool, args):
|
|
r = subprocess.run(
|
|
["uv", "run", "python3", os.path.join(HERE, "reva_call.py"), tool, json.dumps(args)],
|
|
capture_output=True, text=True, cwd=REPO, timeout=300)
|
|
if r.returncode != 0:
|
|
sys.exit(f"reva_call {tool} failed: {r.stderr[:400]}")
|
|
return r.stdout
|
|
|
|
|
|
def main():
|
|
if os.path.exists(OUT) and "--refresh" not in sys.argv:
|
|
print(f"{OUT} exists; use --refresh to re-pull")
|
|
return
|
|
funcs = {}
|
|
start = 0
|
|
while True:
|
|
raw = call("get-functions", {
|
|
"programPath": PROG, "startIndex": start, "filterDefaultNames": False})
|
|
lines = [ln for ln in raw.splitlines() if ln.strip()]
|
|
header = json.loads(lines[0])
|
|
for ln in lines[1:]:
|
|
try:
|
|
f = json.loads(ln)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
funcs[f["address"]] = [f["name"], f.get("sizeInBytes", 0)]
|
|
nxt = header.get("nextStartIndex")
|
|
total = header.get("totalCount")
|
|
print(f" {len(funcs)}/{total}", file=sys.stderr)
|
|
if nxt is None or nxt <= start or len(funcs) >= total:
|
|
break
|
|
start = nxt
|
|
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
|
with open(OUT, "w") as fh:
|
|
json.dump(funcs, fh)
|
|
print(f"wrote {len(funcs)} functions to {OUT}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|