mirror of
https://github.com/jkdansereau/goldeneye-pc-port.git
synced 2026-10-23 15:52:42 +02:00
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
This commit is contained in:
1 parent
dbea5a30fe
commit
884cfdb9b7
3 files changed
+648
-1
No files matched your search
+3
-1
@@ -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 <level>`, `verify.sh sweep [subset]`, `verify.sh parity <level> --against <dir>`. |
|
||||
| `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/`). |
|
||||
|
||||
@@ -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: <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))
|
||||
@@ -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 <level> 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 <level> --against <dir>
|
||||
# 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/<level>/<platform>/, 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 <level> --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
|
||||
Reference in new issue
Block a user