sots-re/tools/serializers_ghidra.py
alex d7ea0a048c lane D: automated struct recovery from the IStreamable serializers
Every serializable class carries an enumeration of its own fields -- its
Write(Stream&), walking the members in order with a 4-char tag. This decodes
that idiom mechanically for the whole binary in 0.35 s.

Validation first (tools/serializers.py validate), against answers the campaign
already had before the tool existed:
  A  305/307 field offsets+kinds exact across 17 classes, 0 WRONG, vs
     struct-recovery.md 1-4 and observedtech-append.md
  B  sizeof from the container-stride divides: ObservedTech 0x2c, MoraleEvent
     0x50, PlayerReport 0x30, DiplomacyStats 0x24 -- all matching
  C  22 of save_reader.py's shapes, tag order identical (Sys 78 tags,
     Player 104, CreateParams 25, Ship 22): 22 agree, 0 disagree
  D  Read/Write cross-check on every class: 437/437 field offsets agree

At scale: 386 classes with a Write, 1,682 member fields.
  verified 87 (542 fields) | clean 77 (328) | unnamed 176 (471)
  partial 31 (341) | empty 15
  58 classes with a sizeof corroborated by a second line of evidence
  (45 container stride, 13 enumeration meeting the embedding bound); the rest
  report a lower bound and say so.

Four things each worth 10-170 classes: the RTTI class hierarchy descriptor as
the only honest "is this an IStreamable" test (a 3-slot vftable also matches
TacAISquadRule_* and the row parsers); mod=0 memory operands, which x86disp.py
cannot index and which hide every field at offset 0; the member->id pointer
idiom behind every handle field; and sub-writers, both base-class and private
(StrategyServer's six id lists live in FUN_00794cd0).

Failure classes are enumerated in the finding -- 176 anonymous-tag classes are
a hard limit on names but not on layout, and the other 64 are bounded
mechanical fixes. Two fields lost to a value assembled across a branch were
left unrecovered rather than patched with an unverifiable heuristic.

Write-back: 288 structures + 328 labels into Ghidra (0 failures), +201
addresses.json entries, header regenerated with tools/gen_addresses.py.

Note: ghidra/addresses.json also carries lane V's already-written live
confirmation text on ObservedTech_sizeof and ServerPlayer_off_ObservedTechs --
their edit, swept in only because we share the file.
2026-09-08 05:51:37 -04:00

152 lines
5.3 KiB
Python

#!/usr/bin/env python3
"""Push the recovered layouts into Ghidra so later lanes inherit them.
Reads `objects/layouts.json` + `objects/layouts.h` (produced by
`tools/serializers.py all`) and, through `tools/reva_call.py`:
* `parse-c-structure` for every emittable struct, dependencies first
* `create-label` for every serializer `Write` / `Read` entry point
Idempotent: re-running replaces the structures and re-applies the labels.
Progress is written to `objects/.ghidra_pushed` so an interrupted run resumes.
uv run python3 tools/serializers_ghidra.py structs
uv run python3 tools/serializers_ghidra.py labels
"""
import json
import os
import re
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
OUT = os.path.join(REPO, "objects")
PROG = "/Sword of the Stars.exe"
STATE = os.path.join(OUT, ".ghidra_pushed")
def reva(tool, payload):
r = subprocess.run(
["uv", "run", "python3", os.path.join(HERE, "reva_call.py"), tool,
json.dumps(payload)],
cwd=REPO, capture_output=True, text=True, timeout=180)
return r.returncode, (r.stdout or r.stderr).strip()
def done_set():
if not os.path.exists(STATE):
return set()
return set(open(STATE).read().split("\n"))
def mark(key):
with open(STATE, "a") as fh:
fh.write(key + "\n")
def structs():
text = open(os.path.join(OUT, "layouts.h")).read()
defs = re.findall(r"struct \w+ \{.*?\};", text, re.S)
have = done_set()
ok = fail = skip = 0
for d in defs:
name = d.split()[1]
key = "struct:" + name
if key in have:
skip += 1
continue
rc, out = reva("parse-c-structure",
{"programPath": PROG, "cDefinition": d})
if rc == 0 and '"message"' in out:
ok += 1
mark(key)
else:
fail += 1
print(f" FAIL {name}: {out[:160]}")
if (ok + fail) % 25 == 0:
print(f" {ok} ok, {fail} failed, {skip} already there")
print(f"structures: {ok} pushed, {fail} failed, {skip} skipped")
def labels():
lay = json.load(open(os.path.join(OUT, "layouts.json")))
have = done_set()
ok = fail = skip = 0
for c, L in sorted(lay.items()):
if L["grade"] not in ("verified", "clean"):
continue
nm = c.replace("::", "_")
for kind in ("write", "read"):
va = L.get(kind)
if not va:
continue
label = f"{nm}_{kind.capitalize()}"
key = f"label:{label}:{va:x}"
if key in have:
skip += 1
continue
rc, out = reva("create-label",
{"programPath": PROG, "labelName": label,
"address": f"0x{va:08x}"})
if rc == 0:
ok += 1
mark(key)
else:
fail += 1
print(f" FAIL {label}: {out[:120]}")
print(f"labels: {ok} created, {fail} failed, {skip} skipped")
def addresses():
"""Merge the verified-tier serializers and corroborated sizeofs into
ghidra/addresses.json (then run tools/gen_addresses.py -- never hand-edit
the header)."""
path = os.path.join(REPO, "ghidra", "addresses.json")
j = json.load(open(path))
base = int(j["image_base"], 16)
have = {e["name"] for e in j["entries"]}
lay = json.load(open(os.path.join(OUT, "layouts.json")))
added = 0
for c, L in sorted(lay.items()):
if L["grade"] != "verified":
continue
nm = c.replace("::", "_")
cr = f"{L['read_agree']}/{L['read_comparable']} field offsets agree " \
f"between Read and Write"
for kind in ("write", "read"):
va = L.get(kind)
name = f"{nm}_{kind.capitalize()}"
if not va or name in have or not (base <= va < base + 0x1000000):
continue
j["entries"].append({
"name": name, "addr": f"0x{va:08x}", "convention": "thiscall",
"prototype": f"void ({nm}* this, Mars::Stream* s) "
f"/* IStreamable slot {2 if kind == 'write' else 1}"
f", vftable 0x{L['vftable']:08x}, COL offset "
f"+0x{L['col_offset']:x}; {len(L['fields'])} "
f"member fields; {cr} */",
"status": "verified",
"source": "findings/objects/serializer-struct-recovery.md"})
have.add(name)
added += 1
if L["sizeof"]:
name = f"sizeof_{nm}"
if name not in have:
j["entries"].append({
"name": name, "offset": f"0x{L['sizeof']:x}",
"convention": "layout",
"prototype": f"sizeof({c}) -- {L['sizeof_by']}",
"status": "verified",
"source": "findings/objects/serializer-struct-recovery.md"})
have.add(name)
added += 1
with open(path, "w") as fh:
json.dump(j, fh, indent=1, ensure_ascii=False)
fh.write("\n")
print(f"addresses.json: +{added} entries, {len(j['entries'])} total")
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "structs"
{"structs": structs, "labels": labels, "addresses": addresses}[cmd]()