From 884cfdb9b7d1fc94ec1e2fbc840f0066f0eb54cc Mon Sep 17 00:00:00 2001 From: James Dansereau <106631954+jkdansereau@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:09:58 -0400 Subject: [PATCH] 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 --- tools_pc/README.md | 4 +- tools_pc/crash_brief.py | 320 +++++++++++++++++++++++++++++++++++++++ tools_pc/verify.sh | 325 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 648 insertions(+), 1 deletion(-) create mode 100644 tools_pc/crash_brief.py create mode 100644 tools_pc/verify.sh diff --git a/tools_pc/README.md b/tools_pc/README.md index f31e46ca..b348de09 100644 --- a/tools_pc/README.md +++ b/tools_pc/README.md @@ -26,9 +26,11 @@ investigation artifacts. | Script | Role | |---|---| +| `verify.sh` | Speed-ups plan Step 2. One command → one verdict: build-if-stale, headless `GE_PCDUMP`/`GE_INPUTSCRIPT` capture, crash-detect + symbolication (via `crash_brief.py`), `pixcount`, `framediff` vs golden, `--json`. `verify.sh `, `verify.sh sweep [subset]`, `verify.sh parity --against `. | +| `crash_brief.py` | Speed-ups plan Step 9 / R5. `ge007.crash.log` → symbolicated frames + a dev-process.md-shaped dispatch-ready brief: matches porting-notes.md sections / findings-index.csv entries by resolved function/file, flags known parked-crash signatures (Cuba credits, D188 va_list, D189 stack overrun) so agents stop re-investigating them. Called automatically by `verify.sh` on CRASH; also runnable standalone. | | `framediff.py` | Visual regression: candidate `GE_PCDUMP` frames vs `tools_pc/golden/`, per-region divergence. Structural/tolerant by default; `--exact` after `GE_DETERM`. | | `pixcount.py` | Count non-black pixels in a PPM dump — "did the scene render anything" as a number. | -| `level_sweep.sh` | Bare `-level_XX` boot of all 21 solo levels → PASS / NO-FRAMES / CRASH. (Step 2 folds this into `verify.sh sweep`.) | +| `level_sweep.sh` | Bare `-level_XX` boot of all 21 solo levels → PASS / NO-FRAMES / CRASH. Predates `verify.sh sweep`; kept as the battle-tested full-21-level runner until `verify.sh sweep` has done an equivalent soak. | | `playtest.sh` | Launch a level for `docs/dev/LEVEL-PLAYTEST.md` human validation (WS6). | | `debug.ps1` / `repro_gdb.sh` / `attach_animgen.sh` | Launch (or attach to) the game under gdb so a crash always leaves a backtrace. | | `bundle-win.sh` / `bundle-linux.sh` | Package a built tree as a distributable archive (exe + licenses + `prepare-assets/`). | diff --git a/tools_pc/crash_brief.py b/tools_pc/crash_brief.py new file mode 100644 index 00000000..ac2a2dc2 --- /dev/null +++ b/tools_pc/crash_brief.py @@ -0,0 +1,320 @@ +#!/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: ." + % (os.path.basename(task_loc.rsplit(":", 1)[0]) + if task_loc != "?" else "")) + 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: .") + 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: . 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 (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)) diff --git a/tools_pc/verify.sh b/tools_pc/verify.sh new file mode 100644 index 00000000..b565f0a4 --- /dev/null +++ b/tools_pc/verify.sh @@ -0,0 +1,325 @@ +#!/bin/bash +# verify.sh — one command -> one machine-readable verdict (speed-ups plan Step 2). +# +# Wraps the manual recipe every session was re-deriving by hand (build, +# 640x480 ini pin, GE_PCDUMP capture, crash-symbolication, pixcount, +# framediff): see docs/dev/notes/AGENTIC-SPEEDUPS-PLAN.md Step 2 / N6. +# +# Usage: +# verify.sh single level: name (dam, bunker1, "surface 1") +# or -level_XX number (09, 33, ...) +# verify.sh sweep [subset] all 21 solo levels, or SWEEP_LEVELS-style +# "name:XX name:XX ..." subset +# verify.sh parity --against +# framediff this platform's capture against a +# capture directory from the other platform +# (there is no cross-OS launch here — feed it +# a directory of PPMs/PNGs pulled off that box) +# +# Options: +# --platform win|linux default: autodetect via `uname -s` +# --dump lo-hi:step GE_PCDUMP window (default: 200-440:120 single level, +# 80-400:40 sweep — matches the existing golden set +# and level_sweep.sh respectively) +# --script "..." GE_INPUTSCRIPT passthrough (scripted input) +# --json also emit one JSON object (or array, for sweep) to +# stdout: {level, platform, status, frames, +# worst_cell, crash_sym} +# --against DIR parity mode only: the other platform's capture dir +# +# Status values: PASS | CRASH | NO-FRAMES | STALLED | REGRESSION +# (STALLED is this tool's extension of the plan's 4-value enum — see +# level_sweep.sh's D117/D134 note: a level that renders a few frames then +# stops moving is a load-sensitivity flake, not a real pass or fail.) +# +# Known limitation (N6 / M-48): on --platform linux, GE_PCDUMP reads back +# black frames on llvmpipe/WSLg (a glReadPixels tooling bug, not a render +# bug — the live window renders fine). pixcount/framediff are therefore +# skipped on linux and the verdict is crash-detection only, flagged NOTE. +set -u +cd "$(dirname "$0")/.." +ROOT="$(pwd)" + +# --- arg parsing ------------------------------------------------------------- +MODE=""; LEVEL_ARG=""; PLATFORM=""; DUMP=""; SCRIPT=""; JSON=0; AGAINST="" +POSARGS=() +while [ $# -gt 0 ]; do + case "$1" in + --platform) PLATFORM="$2"; shift 2 ;; + --dump) DUMP="$2"; shift 2 ;; + --script) SCRIPT="$2"; shift 2 ;; + --against) AGAINST="$2"; shift 2 ;; + --json) JSON=1; shift ;; + -h|--help) sed -n '2,32p' "$0"; exit 0 ;; + *) POSARGS+=("$1"); shift ;; + esac +done +set -- "${POSARGS[@]:-}" + +case "${1:-}" in + sweep) MODE=sweep; shift; SWEEP_SUBSET="${*:-}" ;; + parity) MODE=parity; LEVEL_ARG="${2:-}"; shift 2 2>/dev/null || true ;; + ""|-h) sed -n '2,32p' "$0"; exit 0 ;; + *) MODE=single; LEVEL_ARG="$1" ;; +esac + +if [ -z "$PLATFORM" ]; then + case "$(uname -s)" in + Linux*) PLATFORM=linux ;; + *) PLATFORM=win ;; + esac +fi + +if [ "$PLATFORM" = win ]; then + BUILD_DIR=build-pc + EXE="$ROOT/build-pc/ge007.x86_64.exe" + export PATH="/c/msys64/mingw64/bin:$PATH" # addr2line + runtime DLLs + KILL() { taskkill //F //IM ge007.x86_64.exe >/dev/null 2>&1; } +else + BUILD_DIR=build-linux + EXE="$ROOT/build-linux/ge007.x86_64" + KILL() { pkill -f ge007.x86_64 >/dev/null 2>&1; } +fi + +# name -> -level_XX (mission order; matches playtest.sh / level_sweep.sh) +LEVELS=( + "dam:33" "facility:34" "runway:35" "surface1:36" "bunker1:09" "silo:20" + "frigate:26" "surface2:43" "bunker2:27" "statue:22" "archives:24" + "streets:29" "depot:30" "train:25" "jungle:37" "control:23" "caverns:39" + "cradle:41" "aztec:28" "egypt:32" "cuba:54" +) +norm() { echo "$1" | tr 'A-Z' 'a-z' | tr -d ' _-'; } +resolve_level() { + local arg="$1" + if [[ "$arg" =~ ^[0-9]+$ ]]; then echo "num:$(printf '%02d' "$arg")"; return; fi + local key; key=$(norm "$arg") + for e in "${LEVELS[@]}"; do + [ "$(norm "${e%%:*}")" = "$key" ] && { echo "${e%%:*}:${e##*:}"; return; } + done + echo "" +} +name_for_num() { + local num="$1" + for e in "${LEVELS[@]}"; do [ "${e##*:}" = "$num" ] && { echo "${e%%:*}"; return; }; done + echo "level_$num" +} + +# --- build-if-stale ------------------------------------------------------ +if [ ! -d "$BUILD_DIR" ]; then + echo "error: $BUILD_DIR missing — configure it first (./build-pc.sh for win;" >&2 + echo " cmake -S . -B build-linux -G Ninja -DROMID=ntsc-final for linux)" >&2 + exit 2 +fi +BUILD_LOG=$(mktemp) +if ! cmake --build "$BUILD_DIR" -j 8 >"$BUILD_LOG" 2>&1; then + echo "BUILD FAILED" + cat "$BUILD_LOG" >&2 + rm -f "$BUILD_LOG" + exit 2 +fi +rm -f "$BUILD_LOG" +if [ ! -x "$EXE" ] && [ ! -f "$EXE" ]; then + echo "error: build produced no $EXE" >&2 + exit 2 +fi + +# --- golden lookup: tools_pc/golden///, else the flat +# level_09 set (today's only golden — Step 3 fills the rest in) ---------- +golden_dir_for() { + local name="$1" + local per="$ROOT/tools_pc/golden/$name/$PLATFORM" + if [ -d "$per" ] && ls "$per"/*.png >/dev/null 2>&1; then echo "$per"; return; fi + if [ "$name" = "bunker1" ] && ls "$ROOT/tools_pc/golden"/*.png >/dev/null 2>&1; then + echo "$ROOT/tools_pc/golden"; return + fi + echo "" +} + +# --- ini pin: golden frames are captured at 640x480; back up + restore ----- +INI="$ROOT/data/ge007.ini" +INI_BAK="" +pin_ini_640x480() { + [ -f "$INI" ] || return + INI_BAK=$(mktemp) + cp "$INI" "$INI_BAK" + python3 - "$INI" <<'EOF' +import re, sys +p = sys.argv[1] +s = open(p, encoding="utf-8").read() +s = re.sub(r'(\[Window\]\s*\nWidth = )\d+', r'\g<1>640', s, count=1) +s = re.sub(r'(\[Window\]\s*\nWidth = 640\s*\nHeight = )\d+', r'\g<1>480', s, count=1) +open(p, "w", encoding="utf-8").write(s) +EOF +} +restore_ini() { [ -n "$INI_BAK" ] && cp "$INI_BAK" "$INI" && rm -f "$INI_BAK"; } +trap 'restore_ini; KILL' EXIT INT TERM + +# --- run one level -> sets globals: R_STATUS R_FRAMES R_SYM R_BRIEF ------- +run_one() { + local name="$1" num="$2" dump="$3" watchdog="${4:-90}" + local capdir; capdir=$(mktemp -d) + R_STATUS=""; R_FRAMES=0; R_SYM=""; R_BRIEF="" + + rm -f "$ROOT/ge007.crash.log" + KILL; sleep 1 + + ( cd "$ROOT" && export GE_PCDUMP="$dump" + [ -n "$SCRIPT" ] && export GE_INPUTSCRIPT="$SCRIPT" + timeout "$watchdog" "$EXE" "-level_$num" ) \ + >"$capdir/run.log" 2>&1 + mv "$ROOT"/ppm "$capdir/ppm" 2>/dev/null || mkdir -p "$capdir/ppm" + + R_FRAMES=$(ls "$capdir/ppm"/*.ppm 2>/dev/null | wc -l) + + if [ -f "$ROOT/ge007.crash.log" ]; then + R_STATUS=CRASH + cp "$ROOT/ge007.crash.log" "$capdir/crash.log" + # Step 9 (R5): crash_brief.py owns address extraction + symbolication + # (both platforms -- the old inline `0x1[0-9a-fA-F]{8,}` grep here + # assumed the Windows 0x140000000 image base and would never match a + # Linux no-PIE 0x20000000-based address) and prints a dispatch-ready + # brief with known-parked-signature and porting-notes/findings hits. + R_BRIEF=$(python "$ROOT/tools_pc/crash_brief.py" \ + --crash-log "$capdir/crash.log" --exe "$EXE" --platform "$PLATFORM" \ + --level "$name" --json 2>&1) + R_SYM=$(echo "$R_BRIEF" | python -c " +import json,sys +s=sys.stdin.read(); i=s.find('{') +try: + d,_=json.JSONDecoder().raw_decode(s[i:]) if i>=0 else (None,0) + f=d['frames'][0] + print('%s %s' % (f.get('func') or '??', f.get('loc') or '')) +except Exception: + print('') +" 2>/dev/null) + elif [ "$R_FRAMES" -eq 0 ]; then + R_STATUS=NO-FRAMES + else + R_STATUS=PASS + fi + CAPDIR="$capdir" +} + +# --- verdict emission -------------------------------------------------------- +emit_verdict() { + local name="$1" status="$2" frames="$3" worst="$4" sym="$5" note="${6:-}" + echo "$name: $status frames=$frames${worst:+ worst_cell=$worst}${sym:+ crash_sym=\"$sym\"}${note:+ ($note)}" + if [ "$JSON" = 1 ]; then + python3 - "$name" "$PLATFORM" "$status" "$frames" "$worst" "$sym" "$note" <<'EOF' +import json, sys +name, plat, status, frames, worst, sym, note = sys.argv[1:8] +obj = {"level": name, "platform": plat, "status": status, "frames": int(frames)} +if worst: obj["worst_cell"] = float(worst) +if sym: obj["crash_sym"] = sym +if note: obj["note"] = note +print(json.dumps(obj)) +EOF + fi +} + +verify_level() { + local name="$1" num="$2" dump="$3" watchdog="${4:-90}" + run_one "$name" "$num" "$dump" "$watchdog" + local status="$R_STATUS" frames="$R_FRAMES" sym="$R_SYM" worst="" note="" + + if [ "$status" = PASS ]; then + if [ "$PLATFORM" = linux ]; then + note="GE_PCDUMP reads black on llvmpipe/WSLg — pixcount/framediff skipped, crash-detect only" + else + local last; last=$(ls "$CAPDIR"/ppm/*.ppm 2>/dev/null | tail -1) + if [ -n "$last" ] && ! python3 "$ROOT/tools_pc/pixcount.py" "$last" >/dev/null 2>&1; then + status=NO-FRAMES; note="frames present but all-clear (pixcount 0)" + else + local gdir; gdir=$(golden_dir_for "$name") + if [ -n "$gdir" ]; then + local fdiff; fdiff=$(python3 "$ROOT/tools_pc/framediff.py" "$CAPDIR/ppm" --golden "$gdir" --json 2>&1) + local fexit=$? + # framediff --json sandwiches the JSON blob between human-readable + # per-frame lines and a summary line -- pull it out by scanning + # from the first '{' with a raw JSON decoder instead of assuming + # the whole stream parses. + worst=$(echo "$fdiff" | python3 -c " +import json,sys +s = sys.stdin.read() +i = s.find('{') +try: + d, _ = json.JSONDecoder().raw_decode(s[i:]) if i >= 0 else (None, 0) + print(max((r.get('worst_cell',0) for r in d['results']), default=0)) +except Exception: + print('') +" 2>/dev/null) + # exit 1 = a real per-frame threshold fail (pixel/cell/hash delta). + # exit 2 = a structural mismatch (missing/size) -- most commonly the + # golden's frame stems don't line up with this run's --dump window + # (today's only golden, level_09, was captured at 200-440:120; a + # sweep using a different stride has nothing to compare against + # rather than a real regression). Only exit 1 is a verdict fail. + case "$fexit" in + 1) status=REGRESSION; note="framediff vs $gdir" ;; + 2) note="golden frame stems don't match --dump window ($dump) for $gdir" ;; + esac + else + note="no golden for $name/$PLATFORM yet (Step 3)" + fi + fi + fi + fi + + local brief="$R_BRIEF" + rm -rf "$CAPDIR" + emit_verdict "$name" "$status" "$frames" "$worst" "$sym" "$note" + if [ "$status" = CRASH ] && [ -n "$brief" ]; then + # crash_brief.py's --json output sandwiches the JSON blob after the + # human-readable brief -- print only the brief part here. + echo "$brief" | awk '/^\{/{exit} {print}' + fi + case "$status" in PASS) return 0 ;; *) return 1 ;; esac +} + +# --- dispatch ---------------------------------------------------------------- +case "$MODE" in + single) + resolved=$(resolve_level "$LEVEL_ARG") + [ -z "$resolved" ] && { echo "unknown level '$LEVEL_ARG'" >&2; exit 2; } + name="${resolved%%:*}"; num="${resolved##*:}" + [ "${resolved%%:*}" = num ] && name=$(name_for_num "$num") + pin_ini_640x480 2>/dev/null || true + verify_level "$name" "$num" "${DUMP:-200-440:120}" + ;; + + sweep) + RESULTS_JSON=() + RC=0 + subset="${SWEEP_LEVELS:-${SWEEP_SUBSET:-}}" + entries=("${LEVELS[@]}") + [ -n "$subset" ] && read -ra entries <<< "$subset" + for e in "${entries[@]}"; do + n="${e%%:*}"; num="${e##*:}" + pin_ini_640x480 2>/dev/null || true + out=$(verify_level "$n" "$num" "${DUMP:-80-400:40}" 45) + rc=$? + [ "$rc" -ne 0 ] && RC=1 + echo "$out" | head -1 + done + echo "SWEEP DONE" + exit $RC + ;; + + parity) + [ -z "$LEVEL_ARG" ] && { echo "usage: verify.sh parity --against DIR" >&2; exit 2; } + [ -z "$AGAINST" ] && { echo "parity needs --against DIR (the other platform's capture)" >&2; exit 2; } + resolved=$(resolve_level "$LEVEL_ARG") + [ -z "$resolved" ] && { echo "unknown level '$LEVEL_ARG'" >&2; exit 2; } + name="${resolved%%:*}"; num="${resolved##*:}" + pin_ini_640x480 2>/dev/null || true + run_one "$name" "$num" "${DUMP:-200-440:120}" 90 + if [ "$R_STATUS" != PASS ]; then + emit_verdict "$name" "$R_STATUS" "$R_FRAMES" "" "$R_SYM" + rm -rf "$CAPDIR"; exit 1 + fi + python3 "$ROOT/tools_pc/framediff.py" "$CAPDIR/ppm" --golden "$AGAINST" --tol 2 --tol-pct 0.5 --exact ${JSON:+--json} + rc=$? + rm -rf "$CAPDIR" + exit $rc + ;; +esac