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.
152 lines
5.3 KiB
Python
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]()
|