Files
goldeneye-pc-port/tools_pc/crash_brief.py
T
James Dansereau 884cfdb9b7 tools(verify): add verify.sh + crash_brief.py (speed-ups plan Step 2, 9)
verify.sh wraps the manual render-verify recipe (build-if-stale, 640x480
ini pin/restore, headless GE_PCDUMP capture, pixcount, framediff, crash
symbolication) into one command with a machine-readable PASS/CRASH/
NO-FRAMES/REGRESSION verdict + --json, for a single level or a sweep
subset. Live-tested on Windows: single-level and subset-sweep runs both
produce correct verdicts and leave the tree clean.

crash_brief.py turns a ge007.crash.log into a symbolicated, dispatch-ready
investigation brief: greps porting-notes.md/findings-index.csv for
sections covering the crashing function/file, and flags known
parked-crash signatures (D188 va_list, D189 stack overrun) so agents stop
re-investigating them. verify.sh calls it automatically on a CRASH
verdict. Live-tested against a synthetic-but-real crash log built from an
actual built symbol; caught and fixed 3 bugs before trusting it (addr2line
unresolvable under native Windows Python's PATH handling, a substring
keyword-match false-positive, a Windows drive-letter path-split bug).

Claude-Session: https://claude.ai/code/session_01P3WrUHAFQZUuhrHhiJgTUr
2026-09-04 22:09:58 -04:00

321 lines
13 KiB
Python

#!/usr/bin/env python3
"""crash_brief.py — crash log -> pre-filled investigation brief (speed-ups
plan Step 9 / R5).
Symbolicates every faulting PC in a ge007.crash.log with addr2line, then
greps docs/porting-notes.md and docs/dev/findings-index.csv (Step 4/R8) for
sections that already cover that function/file, and checks a small known
parked-crash signature list (Cuba credits, D188 SysV va_list, D189
stack-protector) so agents stop re-investigating crashes that are already
understood-but-not-worth-fixing. Prints a dev-process.md-shaped brief ready
to hand to a subagent, filling in what's derivable and leaving the rest as
placeholders.
Usage:
crash_brief.py [--crash-log PATH] [--exe PATH] [--platform win|linux]
[--level NAME] [--json]
Defaults: --crash-log ge007.crash.log (repo root), --exe autodetected from
--platform (build-pc/ge007.x86_64.exe or build-linux/ge007.x86_64).
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_ADDR2LINE = None
def find_addr2line():
"""shutil.which() alone is not enough here: this repo's PATH convention
for the win toolchain is the MSYS2 POSIX form (/c/msys64/mingw64/bin),
which a native Windows Python (`python`, as opposed to an MSYS-flavored
`python3`) cannot resolve -- os.path.isfile("/c/...") is not a valid
Windows path, so the PATH search silently finds nothing and every
address comes back unresolved. Fall back to the documented install
location before giving up."""
global _ADDR2LINE
if _ADDR2LINE:
return _ADDR2LINE
p = shutil.which("addr2line")
if not p:
for c in (r"C:\msys64\mingw64\bin\addr2line.exe",
r"C:\msys64\usr\bin\addr2line.exe",
"/usr/bin/addr2line"):
if os.path.exists(c):
p = c
break
_ADDR2LINE = p or "addr2line"
return _ADDR2LINE
# --------------------------------------------------------------------------
# Known parked-crash signatures. Each entry: (regex over the raw crash-log
# text or a resolved symbol, label, one-line note). Matching one of these
# means "this is understood, don't re-derive it — see the label".
KNOWN_SIGNATURES = [
(re.compile(r"credits", re.I), "D129/D76",
"Cuba end-credits crash -- parked, not a fresh bug."),
(re.compile(r"_Printf|_Putfld|va_list", re.I), "D188 (porting-notes D7)",
"va_list-by-value ABI misuse on x86-64 SysV -- thread a va_list* instead."),
(re.compile(r"tileStack|pointbuf|stack.protector|stack.smash", re.I),
"D189 (porting-notes D8)",
"latent fixed-size stack-buffer over/under-run, fatal only under "
"-fstack-protector-strong (Linux/Ubuntu default)."),
]
# Known parked crashes by resolved symbol -- a regex over the crash-log text
# alone misses this class: the tell (a local var like tileStack[39]) lives
# inside the function body, not its symbol name, so the KNOWN_SIGNATURES
# text-regex never fires even though the crash *is* the named finding.
FUNC_LABELS = {
"sub_GAME_7F0B1DDC": ("D189 (porting-notes D8)",
"stan.c tileStack[39] overrun -- the exact D189 repro function."),
"add_ptr_to_objective": ("D190",
"objective.c:64 propDef-stride family, Linux -level_45 SIGSEGV at load."),
}
BACKTRACE_LINE = re.compile(r"^#\d+:\s*(0x[0-9a-fA-F]+)")
PC_LINE = re.compile(r"^PC:\s*(0x[0-9a-fA-F]+)")
def find_addresses(text, max_frames=8):
"""Pull the crash PC + up to max_frames backtrace addresses, in order,
deduplicated. Deliberately generic across the Windows (EBP-chain) and
Linux (backtrace_symbols) crash.c formats -- both print '#NN: 0xADDR...'
backtrace lines and a leading 'PC: 0xADDR' line (see port/src/crash.c)."""
addrs = []
for line in text.splitlines():
m = PC_LINE.match(line) or BACKTRACE_LINE.match(line)
if m and m.group(1) not in addrs:
addrs.append(m.group(1))
if len(addrs) >= max_frames:
break
return addrs
def symbolicate(exe, addr):
"""addr2line -f -C -> (function, file:line), or (None, None) if it
can't resolve (stripped exe, address outside any module, ...)."""
try:
out = subprocess.run([find_addr2line(), "-e", exe, "-f", "-C", addr],
capture_output=True, text=True, timeout=10)
except (OSError, subprocess.TimeoutExpired):
return None, None
lines = out.stdout.strip().splitlines()
if len(lines) < 2:
return None, None
func, loc = lines[0], lines[1]
if func in ("??", "") or loc.startswith("??"):
return (func if func not in ("??", "") else None,
None if loc.startswith("??") else loc)
return func, loc
def load_porting_notes_sections():
"""[(header_text, anchor, body_text), ...] from the '## ' sections."""
path = os.path.join(ROOT, "docs", "porting-notes.md")
if not os.path.exists(path):
return []
text = open(path, encoding="utf-8").read()
parts = re.split(r"^(## .+)$", text, flags=re.M)
sections = []
for i in range(1, len(parts), 2):
header = parts[i][3:].strip()
body = parts[i + 1] if i + 1 < len(parts) else ""
sections.append((header, body))
return sections
def load_findings_index():
path = os.path.join(ROOT, "docs", "dev", "findings-index.csv")
rows = []
if not os.path.exists(path):
return rows
import csv
with open(path, encoding="utf-8") as f:
for row in csv.DictReader(l for l in f if not l.startswith("#")):
rows.append(row)
return rows
def keyword_hits(func, fileloc, sections, findings, cap=6):
"""Grep porting-notes sections + findings-index one-liners for the
resolved function name / file basename.
Word-boundary match, not substring: a short file basename like "stan"
(stan.c) is a substring of "distance"/"instant"/"understand" and a naive
`needle in haystack` test matched nearly every finding in the log --
caught live testing this script. Also drops needles under 5 chars (too
common to be distinctive) and caps the result so a hit list is a lead,
not the whole findings log pasted into the brief."""
needles = []
if func:
# split CamelCase / snake_case into tokens; keep the full name too
needles += re.split(r"[_A-Z]", func)
needles.append(func)
if fileloc:
# rsplit, not split: a Windows debug path is "C:/.../stan.c:2163" --
# a plain split(":")[0] grabs the drive letter "C", not the path
# (caught live-testing this script).
base = os.path.basename(fileloc.rsplit(":", 1)[0])
needles.append(os.path.splitext(base)[0])
needles = sorted({n.lower() for n in needles if len(n) >= 5})
patterns = [re.compile(r"\b%s\b" % re.escape(n)) for n in needles]
if not patterns:
return [], []
porting_hits, finding_hits = [], []
for header, body in sections:
blob = (header + " " + body).lower()
if any(p.search(blob) for p in patterns):
porting_hits.append(header)
for row in findings:
blob = (row.get("one_liner", "") + " " + row.get("label", "")).lower()
if any(p.search(blob) for p in patterns):
finding_hits.append("%s (%s)" % (row["label"], row["one_liner"]))
return porting_hits[:cap], finding_hits[:cap]
def check_known_signatures(crash_text, frames):
hits = []
for f in frames:
func = f.get("func")
if func in FUNC_LABELS:
hits.append(FUNC_LABELS[func])
haystack = crash_text + " " + " ".join(f.get("func") or "" for f in frames)
for pattern, label, note in KNOWN_SIGNATURES:
if pattern.search(haystack):
hits.append((label, note))
# dedupe, keep order
seen = set()
out = []
for h in hits:
if h not in seen:
seen.add(h)
out.append(h)
return out
def render_brief(args, frames, known_hits, porting_hits, finding_hits):
top = frames[0] if frames else {}
task_sym = top.get("func") or "(unresolved)"
task_loc = top.get("loc") or top.get("addr", "?")
lines = []
lines.append("TASK: crash in %s (%s)%s."
% (task_sym, task_loc,
" level=%s" % args.level if args.level else ""))
read_first = ["docs/porting-notes.md"]
if porting_hits:
read_first.append("§" + ", §".join(dict.fromkeys(porting_hits)))
if finding_hits:
read_first.append("docs/dev/findings.md: " +
"; ".join(dict.fromkeys(finding_hits)))
lines.append("READ FIRST: " + "; ".join(read_first) + ".")
lines.append("FILES YOU MAY TOUCH: <fill in -- likely %s; disjoint from "
"any other in-flight work>."
% (os.path.basename(task_loc.rsplit(":", 1)[0])
if task_loc != "?" else "<unknown -- addr2line couldn't "
"resolve the crash PC; is the exe built with -g?>"))
if known_hits:
lines.append("KNOWN-GOOD / RULED OUT: matches a PARKED signature -- "
+ "; ".join("%s: %s" % (l, n) for l, n in known_hits)
+ " -- confirm it's the same instance before spending "
"a budget re-deriving a known crash.")
else:
lines.append("KNOWN-GOOD / RULED OUT: <no known-signature match -- "
"novel crash, no shortcut>.")
lines.append("PRE-FLIGHT ATTACHED: %s (%d frame%s symbolicated)."
% (args.crash_log, len(frames), "" if len(frames) == 1 else "s"))
for i, f in enumerate(frames):
lines.append(" #%02d %s %s %s" % (i, f["addr"], f.get("func") or "??",
f.get("loc") or "??"))
lines.append("BUDGET: <fill in -- tight for a cheap-class bug, more room "
"for a structural one>. On expiry: revert probes, write up "
"with confidence.")
lines.append("CONSTRAINTS: no game-logic changes; ABI/layout/format only; "
"#ifdef PORT; documented in the finding log.")
verify = ("tools_pc/verify.sh %s" % args.level) if args.level else \
"tools_pc/verify.sh <level> (fill in which level reproduces this)"
lines.append("VERIFY: %s." % verify)
lines.append("REPORT: (a) root cause + file:line evidence (b) fix diff, "
"or why not (c) probes left in tree (d) confidence.\n"
" Append any generalisable quirk to "
"docs/porting-notes.md.")
return "\n".join(lines)
def main(argv):
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--crash-log", default=os.path.join(ROOT, "ge007.crash.log"))
ap.add_argument("--exe")
ap.add_argument("--platform", choices=["win", "linux"])
ap.add_argument("--level", help="level name/number this crash reproduces on "
"(fills VERIFY:)")
ap.add_argument("--json", action="store_true")
args = ap.parse_args(argv[1:])
# Content pulled in from findings.md/porting-notes.md (arrows, em-dashes,
# etc.) can contain non-cp1252 characters; native Windows Python's stdout
# defaults to the console codepage, not UTF-8, and raises on them.
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if not os.path.exists(args.crash_log):
print("error: no crash log at %s" % args.crash_log, file=sys.stderr)
return 2
platform = args.platform or ("linux" if sys.platform.startswith("linux") else "win")
exe = args.exe or (os.path.join(ROOT, "build-linux", "ge007.x86_64")
if platform == "linux"
else os.path.join(ROOT, "build-pc", "ge007.x86_64.exe"))
if not os.path.exists(exe):
print("error: exe not found at %s (pass --exe)" % exe, file=sys.stderr)
return 2
crash_text = open(args.crash_log, encoding="utf-8", errors="replace").read()
addrs = find_addresses(crash_text)
if not addrs:
print("error: no PC/backtrace addresses found in %s" % args.crash_log,
file=sys.stderr)
return 2
frames = []
for addr in addrs:
func, loc = symbolicate(exe, addr)
frames.append({"addr": addr, "func": func, "loc": loc})
sections = load_porting_notes_sections()
findings = load_findings_index()
porting_hits, finding_hits = [], []
for f in frames:
p, fi = keyword_hits(f.get("func"), f.get("loc"), sections, findings)
porting_hits += p
finding_hits += fi
porting_hits = list(dict.fromkeys(porting_hits))[:6]
finding_hits = list(dict.fromkeys(finding_hits))[:6]
known_hits = check_known_signatures(crash_text, frames)
brief = render_brief(args, frames, known_hits, porting_hits, finding_hits)
print(brief)
if args.json:
print(json.dumps({
"crash_log": args.crash_log, "exe": exe, "platform": platform,
"frames": frames,
"known_signature_matches": [{"label": l, "note": n} for l, n in known_hits],
"porting_notes_hits": list(dict.fromkeys(porting_hits)),
"findings_hits": list(dict.fromkeys(finding_hits)),
}, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))