sots-re/tools/cache_functions.py
alex 460cb7ca2b lane X: x86 displacement xref scanner; pin sizeof(ObservedTech) and its append site
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.
2026-09-08 04:44:18 -04:00

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