Files
BFM-decomp/tools/ghidra_annotations_delta.py
T
Drew T 936d7d741c feat(phase-33): B5 Ghidra regenerability PROVEN — the RE work as text (config/ghidra/*.jsonl + ROSTER.md), six programs rebuilt from disc + symbol files + that file with PROOF PASS
- ImportAnnotations.java: the S86 OSGi-bundle blocker was 3 javac errors (Long->int unboxing x2, a nonexistent
  LocalVariableImpl ctor -> VariableStorage); "/undefined" resolves to DataType.DEFAULT (it lives in neither type
  manager — main's first proof passed the cmp with failed=13 because the plate-comment rows had set the same function
  comments); ghidra_rebuild.sh now dies unless the import printed failed=0 (R49), writes .proof markers
- ghidra_annotations_delta.py: analysis drift measured and encoded as three counted classes — Error/Analysis bookmarks;
  auto-named DEFAULT functions the rebuild did not create (29 in main's LINKED regions); auto-named rows lagging the curated
  symbol file (10 sep8 + 5 aug31, R15). Result: main 38 hand-authored rows (13 annotated fns incl. 3 the ELF does not
  define, 22 comments, 3 labels); resident/overlays/protos container rows only; the DB holds no hand-authored types
- controls (R39): mutated block row -> PROOF FAIL; synthetic comment/bookmark/label/signature round-trip -> PROOF PASS
  twice (idempotent); the filter keeps the synthetic rows and a hand-renamed name-only diff; fake failed=2 refused,
  resident re-proven; roster --check controls both ways
- proofs, all PASS failed=0: resident 65s, ov_SC01_077 169s, ov_SC06_018 173s, SLUS_007.26 210s, sep8 202s, aug31 206s
- tools/ghidra_roster.py -> config/ghidra/ROSTER.md (--check in tools-health, ignores the per-machine proof column)
- .claude/settings.json hooks $CLAUDE_PROJECT_DIR-relative; ghidra_mcp_start.sh is a silent exit 0 without Ghidra or
  the project (both controlled); SETUP P33 B5 section + 5 inventory rows + §2.8 (R21); CURRENT_PHASE log + checkpoint
2026-09-06 20:32:35 -06:00

100 lines
4.8 KiB
Python

#!/usr/bin/env python3
"""ghidra_annotations_delta.py — the HAND-AUTHORED part of a Ghidra program, as a byte-stable file (P33 B5).
ghidra_annotations_delta.py <live.jsonl> <baseline.jsonl> <out.jsonl> [--census]
`live.jsonl` is ExportAnnotations.java's dump of the program as it is; `baseline.jsonl` is the same
dump of a FRESH rebuild of that program (import + auto-analysis + DefineFunctions + ApplySymbols, no
annotations imported). Every row auto-analysis produces on its own appears in both and subtracts
itself out — minus three counted analysis-drift classes (Error/Analysis bookmarks; auto-named DEFAULT functions the
rebuild did not create; auto-named rows that lag the curated symbol file, R15 — see the rules below); what remains — types, retyped/renamed signatures and locals, typed data, comments,
bookmarks, equates, labels that no symbol file carries — is the RE work worth committing:
config/ghidra/<program>.jsonl. The container rows (`program`, `block`, `archive`) are always kept
from `live` so an image-base or memory-map drift shows as a diff rather than a silent skip.
The proof (tools/ghidra_rebuild.sh --proof) rebuilds, imports config/ghidra/<program>.jsonl, exports,
takes the delta against the SAME rebuild's pre-import baseline, and `cmp`s it against the committed file.
`--census` prints per-kind counts of the delta (the roster decision: a program whose delta holds no
hand-authored rows need not be kept regenerable — it regenerates on demand).
"""
import collections
import json
import sys
KEEP_ALWAYS = ("program", "block", "archive")
# Analysis-origin rows that differ between the live import (Phase 1/10) and today's rebuild — measured on
# SLUS_007.26 (S87): 29 FUN_* functions inside the LINKED PsyQ regions that the live analysis created and the
# rebuild's did not (DEFAULT signature, no comment, auto name), and one "Unable to resolve constructor" Error
# bookmark. They carry no RE; a proof that kept them could never pass on a fresh rebuild.
ANALYSIS_BOOKMARKS = ("Error", "Analysis")
AUTO_NAME = ("FUN_", "func_", "thunk_FUN_")
def is_symbol_file_lag(o, b):
"""The live DB was never re-mirrored from the symbol file: it differs from the rebuilt (ApplySymbols) row ONLY
by carrying the auto name. Importing it would overwrite a curated name with FUN_* — the symbol file wins (R15).
Measured on the two prototypes (S87): sep8 10 rows, aug31 5 rows, nothing else."""
if not str(o.get("name", "")).startswith(AUTO_NAME):
return False
return all(o.get(k) == b.get(k) for k in o if k != "name")
def is_function_set_drift(o):
if o.get("sigsrc") != "DEFAULT" or o.get("comment") or o.get("custom"):
return False
if not str(o.get("name", "")).startswith(AUTO_NAME):
return False
return all(p.get("src") == "DEFAULT" for p in o.get("params", []) + o.get("locals", []))
def rows(path):
with open(path, encoding="utf-8") as f:
return [ln.rstrip("\n") for ln in f if ln.strip()]
def kind(line):
return json.loads(line)["k"]
def main(argv):
census = "--census" in argv
argv = [a for a in argv if a != "--census"]
if len(argv) != 4:
sys.exit(__doc__)
live, base, out = rows(argv[1]), set(rows(argv[2])), argv[3]
base_func_rows = {json.loads(ln)["addr"]: json.loads(ln) for ln in base if kind(ln) == "func"}
base_funcs = set(base_func_rows)
dropped = collections.Counter()
kept = []
for ln in live:
k = kind(ln)
if k in KEEP_ALWAYS or ln not in base:
o = json.loads(ln)
if k == "bookmark" and o.get("type") in ANALYSIS_BOOKMARKS:
dropped["bookmark:analysis"] += 1 # Ghidra's own Error/Analysis bookmarks — never hand-authored
continue
if k == "func" and o["addr"] not in base_funcs and is_function_set_drift(o):
dropped["func:set-drift"] += 1 # a boundary the live analysis found and the rebuild did not
continue
if k == "func" and o["addr"] in base_funcs and is_symbol_file_lag(o, base_func_rows[o["addr"]]):
dropped["func:name-lag"] += 1 # the DB still says FUN_*; the curated symbol file names it (R15)
continue
kept.append(ln)
with open(out, "w", encoding="utf-8", newline="\n") as f:
for ln in kept:
f.write(ln + "\n")
c = collections.Counter(kind(ln) for ln in kept)
hand = sum(v for k, v in c.items() if k not in KEEP_ALWAYS)
drop = ", ".join(f"{k} {v}" for k, v in sorted(dropped.items())) or "none"
print(f"delta: {len(live)} live - {len(base)} baseline -> {len(kept)} rows ({hand} hand-authored; "
f"analysis drift dropped: {drop}) -> {out}", file=sys.stderr)
if census:
for k in sorted(c):
print(f" {k:9} {c[k]}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))