# Primal Rage (DOS, 1995) — fully playable SDL3 port + reverse engineering.
# Play: make deps build run.
# `make help` is the quick target list; `make help-all` lists every target.
# Pipelines: RE (info -> gra -> symbols -> decompile -> oracle) and PORT
# (build -> check -> run -> verify).
# The measured N/pin constants (`DEMO_FIGHT_MIN_FIRST`, `ATTRACT2_MIN_FIRST`,
# `GP_*`) are load-bearing: each has its provenance in the comment above it;
# raise, never lower, when the port improves — do not "tidy" it.
SERVICE = Primal Rage (DOS)

# Variables
PORT_DIR = port
BUILD_DIR = build
GAME_DIR = data/game/C
RUNNER = data/game/run-window.sh
SMK_CAPTURES = data/smk-captures
SMK_DUMP = /tmp/pr_smk_dump
TITLE_CAPTURES = data/title-captures
TITLE_DUMP = /tmp/pr_title_dump
ATTRACT_DUMP = /tmp/pr_attract_dump
FRONTEND_DUMP = /tmp/pr_frontend_dump
K11_CAPTURES = data/k11-captures
K11_DUMP = /tmp/pr_k11_dump
GP_DUMP = /tmp/pr_gp_dump
# K11_DUMP and GP_DUMP are fixed shared /tmp paths: oracle-replay or capture-compare runs across
# worktrees must be serialized (one user at a time), or given a per-worktree command-line override
# (`make ... GP_DUMP=/tmp/pr_gp_dump_<lane>`; a command-line variable wins over this file).
DIFF_IMAGE ?= /tmp/pr_diff_image.bin
DIFF_TABLE ?= /tmp/pr_diff_table.md
E2_IMAGE ?= /tmp/pr_e2_image.bin
E2_TABLE = docs/superpowers/plans/2026-10-01-reverse-e2-triage.md
E2_LIVE = docs/superpowers/plans/2026-10-01-reverse-e2-live-functions.txt
scenario ?= walk
K11_ARGS ?=
TITLE_PIN_DIR = /tmp/pr_title_pin
DECOMP_DIR = port/decomp
SCRIPTS_DIR = _tools/ghidra_scripts
PROJ_DIR = _tools/ghidra_proj
PROJECT_NAME = prage
PYTHON = python3

# Strict verify preflights every oracle input, not a glob of the capture roots
# (which also hold evidence and superseded captures no oracle target consumes).
# `GATE_STRICT=0 make verify` is the documented partial mode: undeclared capture
# absences are listed and tolerated, while a declared gap that appears is stale.
GATE_STRICT ?= 1
GATE_NAMED_GAPS ?= $(K11_CAPTURES)/gp-u14-win-live
PR_PINS_EXACT ?= $(GATE_STRICT)
export GATE_STRICT
export PR_PINS_EXACT
CAPTURE_DIRS = \
	$(K11_CAPTURES)/gp-idle-loss \
	$(K11_CAPTURES)/gp-u5-charsel \
	$(K11_CAPTURES)/gp-u6-moves-b \
	$(K11_CAPTURES)/gp-keys-fight \
	$(K11_CAPTURES)/gp-twop \
	$(K11_CAPTURES)/gp-twop-ko \
	$(K11_CAPTURES)/gp-u8-attract-start \
	$(K11_CAPTURES)/gp-u8-endurance \
	$(K11_CAPTURES)/gp-u8-handicap \
	$(K11_CAPTURES)/gp-u8-left-training \
	$(K11_CAPTURES)/gp-u8-right-arcade \
	$(K11_CAPTURES)/gp-u8-right-training \
	$(K11_CAPTURES)/gp-u8-tug-of-war \
	$(K11_CAPTURES)/gp-u9-win \
	$(K11_CAPTURES)/gp-u10-ending \
	$(K11_CAPTURES)/gp-u12-endurance \
	$(K11_CAPTURES)/gp-u13-ending2 \
	$(K11_CAPTURES)/gp-u14-win-live \
	$(K11_CAPTURES)/gp-u14-twop-win \
	$(K11_CAPTURES)/gp-u15-join \
	$(K11_CAPTURES)/gp-u16-twop2 \
	$(K11_CAPTURES)/gp-u17-device2 \
	$(K11_CAPTURES)/gp-u18-talon-ending \
	$(K11_CAPTURES)/gp-u19-vertigo-ending \
	$(K11_CAPTURES)/gp-u20-chaos-ending \
	$(K11_CAPTURES)/gp-u21-armadon-ending \
	$(K11_CAPTURES)/gp-u22-diablo-ending \
	$(K11_CAPTURES)/gp-u23-u16-finisher \
	$(K11_CAPTURES)/gp-u25-endurance-end \
	$(K11_CAPTURES)/gp-u26-side1-ending \
	$(K11_CAPTURES)/gp-u27-name-entry \
	$(K11_CAPTURES)/gp-draw \
	$(K11_CAPTURES)/gp-draw-timeout \
	$(K11_CAPTURES)/gp-u28-continue \
	$(K11_CAPTURES)/gp-u29-continue-taken \
	$(K11_CAPTURES)/gp-bowling-2 \
	$(K11_CAPTURES)/gp-u30-volleyball \
	$(K11_CAPTURES)/walk \
	$(K11_CAPTURES)/menuesc \
	$(K11_CAPTURES)/k11-lang \
	$(K11_CAPTURES)/save-reload \
	$(K11_CAPTURES)/gp-pads \
	$(SMK_CAPTURES)/twi5 \
	$(SMK_CAPTURES)/twg \
	$(TITLE_CAPTURES)/title \
	$(TITLE_CAPTURES)/title2 \
	$(TITLE_CAPTURES)/frontend

# Only the three capture-oracle invocations are oracle-required in strict mode.
# Partial mode must also override a caller-exported PR_ORACLE_REQUIRED. The
# front-end family stays compare-if-present in strict mode and is unset in partial.
ifeq ($(GATE_STRICT),1)
GATE_ORACLE_ENV = PR_ORACLE_REQUIRED=1
GATE_FRONTEND_ENV =
else
GATE_ORACLE_ENV = env -u PR_ORACLE_REQUIRED
GATE_FRONTEND_ENV = env -u PR_ORACLE_REQUIRED
endif
UNIT_ENV = env -u PR_ATTRACT_DUMP -u PR_TITLE_DUMP -u PR_FRONTEND_DUMP \
	-u PR_FRONTEND_DET -u PR_K11_DUMP -u PR_GP_DUMP -u PR_RESTART

# Ghidra needs JDK 25 (12.2 refuses older runtimes).
JAVA_HOME_DIR ?= /Library/Java/JavaVirtualMachines/temurin-25.jdk/Contents/Home
GHIDRA_HOME ?= $(HOME)/ghidra_12.2_DEV
HEADLESS = $(GHIDRA_HOME)/support/analyzeHeadless
GHIDRA_ENV = JAVA_HOME=$(JAVA_HOME_DIR)

# Optional args: make check frames=120 / make re-render gra=S16CAGE.GRA chunk=1
frames ?= 60
# Boot enters state 0 (attract, ~690 frames) before the title, so a verify run
# must cross the attract to exercise the attract/title sample and music
# assertions. The short `frames` default still drives `make check`'s
# attract-only smoke render.
# Coupling: main.c's CHECK_TITLE_REACH_FRAMES (700) makes any --check run at or
# above it FAIL if the title was not reached. So verify_frames must stay above
# the attract length; if a future attract grows past 700, raise both the attract
# bound and verify_frames together or `make verify` fails loudly.
verify_frames ?= 820
gra ?= S16TITLE.GRA
chunk ?= 0

.PHONY: help help-all deps build gate-check test verify check run clean coverage audio-render \
        smk-oracle title-pin title-capture title-oracle attract-oracle \
        audio-one audio-oracle audio-determinism \
        frontend-capture frontend-oracle demo-oracle demo-fight-oracle \
        attract2-oracle attract2-compare k11-capture k11-oracle k11-report \
        save-reload-oracle \
        gp-capture gp-replay gp-oracle gp-report gp-charsel-oracle \
        gp-moves-oracle gp-keys-oracle gp-twop-oracle gp-twop-ko-oracle gp-modes-oracle gp-modes-one \
        gp-win-oracle gp-ending-oracle gp-win-one gp-u12-oracle gp-u13-oracle gp-u14-oracle gp-u14-twop-win-oracle gp-u15-oracle gp-u16-oracle \
        gp-u17-oracle gp-u23-oracle gp-u18-oracle gp-u19-oracle gp-u20-oracle gp-u21-oracle gp-u22-oracle gp-u25-oracle \
        gp-u26-oracle gp-u27-oracle gp-draw-oracle gp-draw-timeout-oracle gp-u28-continue-oracle \
        gp-u29-continue-taken-oracle gp-bowling-oracle gp-u30-volleyball-oracle vb-search \
        diff-verify entry-triage \
        re-info re-gra re-render re-symbols re-cluster re-extract re-extract-test \
        re-decompile re-analyze re-oracle re-original

# ── Environment ──────────────────────────────────────────────────────────────

help: ## Print the quick start and main targets (help-all lists every target)
	@printf '\033[01;32m${SERVICE} — fully playable SDL3 port + reverse engineering\033[00;37m\n\n'
	@printf "\033[33mPlay:\033[0m  (game files in $(GAME_DIR)/, see README.md)\n"
	@printf "  make deps      # check the toolchain and game files\n"
	@printf "  make build     # build the port into $(BUILD_DIR)/\n"
	@printf "  make run       # play (windowed; ESC quits)\n\n"
	@printf "\033[33mDevelop:\033[0m\n"
	@printf "  make check frames=60   # headless run -> frames/\n"
	@printf "  make verify            # the full gate (tests, oracles, differential, audio)\n"
	@printf "  make diff-verify       # original x86 bytes vs the C port\n"
	@printf "  make clean             # remove build outputs\n\n"
	@printf "\033[33mOracles:\033[0m  smk-* title-* attract-* frontend-* demo-* k11-* save-reload-* gp-* audio-*\n"
	@printf "\033[33mRE:\033[0m       re-info re-gra re-render re-extract re-symbols re-decompile re-analyze re-oracle re-original\n\n"
	@printf "Every target: make help-all\n"

help-all: ## Print every target with its description
	@printf '\033[01;32m${SERVICE} — every target\033[00;37m\n\n'
	@grep -E '^[-a-zA-Z0-9_\.\/]+:.*?## .*$$' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*?## "}; \
		{printf "  \033[36m%-26s\033[0m %s\n", $$1, $$2}'

deps: ## Check the toolchain and the (untracked) game assets
	@echo "$(SERVICE) — toolchain"
	@for t in cmake $(PYTHON) git; do \
		command -v $$t >/dev/null 2>&1 || { echo "missing: $$t"; exit 1; }; \
	done
	@pkg-config --exists sdl3 2>/dev/null || brew list --versions sdl3 >/dev/null 2>&1 || \
		echo "warning: SDL3 not visible to pkg-config/brew — CMake needs its config package"
	@[ -x "$(HEADLESS)" ] || \
		echo "warning: Ghidra analyzeHeadless not at $(HEADLESS) (only the re-* targets need it)"
	@[ -d "$(JAVA_HOME_DIR)" ] || \
		echo "warning: JDK 25 not at $(JAVA_HOME_DIR) — override with JAVA_HOME_DIR=..."
	@command -v dosbox-x >/dev/null 2>&1 || \
		echo "warning: dosbox-x not found (only re-original needs it)"
	@[ -f "$(GAME_DIR)/PRAGE.EXE" ] || \
		echo "warning: $(GAME_DIR)/PRAGE.EXE missing — assets are deliberately untracked"
	@if [ -f "$(PORT_DIR)/tests/ghidra_data.bin" ]; then \
		echo "ok: $(PORT_DIR)/tests/ghidra_data.bin present"; \
	else \
		echo "warning: $(PORT_DIR)/tests/ghidra_data.bin missing — PR_ORACLE_REQUIRED runs need it; make re-oracle regenerates it"; \
	fi
	@for module in unicorn capstone; do \
		if $(PYTHON) -c "import $$module" >/dev/null 2>&1; then \
			echo "ok: python $$module importable"; \
		else \
			echo "warning: python $$module not importable — make diff-verify needs tools/requirements-diff.txt"; \
		fi; \
	done
	@cmos="$(GAME_DIR)/CMOS"; \
	if [ ! -e "$$cmos" ] || { [ "$$(wc -c < "$$cmos" | tr -d ' ')" = 2040 ] && [ "$$(tr -d '\000' < "$$cmos" | wc -c | tr -d ' ')" = 0 ]; }; then \
		echo "ok: $$cmos absent or 2040 zero bytes"; \
	else \
		echo "warning: $$cmos must be absent or 2040 all-zero bytes — make verify requires it"; \
	fi
	@echo "ok"

# ── Port · build and verify ──────────────────────────────────────────────────

# `cmake --build` recompiles edited sources, including port/tests/*.c, and
# `verify`/every oracle target depends on this one — so none can run stale.
build: ## Configure and build the SDL3 port (CMake → build/)
	@echo "Configuring $(PORT_DIR)/ ..."
	cmake -S $(PORT_DIR) -B $(BUILD_DIR)
	cmake --build $(BUILD_DIR)

gate-check: ## Inventory every oracle capture and validate the manifest, the pin baseline and the verify preflight (GATE_STRICT=0 tolerates undeclared absences)
	@present=0; missing=0; declared=""; undeclared=""; stale=""; \
	for d in $(CAPTURE_DIRS); do \
		if [ -d "$$d" ]; then \
			present=$$((present + 1)); \
			case " $(GATE_NAMED_GAPS) " in *" $$d "*) stale="$$stale $$d" ;; esac; \
		else \
			missing=$$((missing + 1)); \
			case " $(GATE_NAMED_GAPS) " in \
				*" $$d "*) declared="$$declared $$d" ;; \
				*) undeclared="$$undeclared $$d" ;; \
			esac; \
		fi; \
	done; \
	echo "gate: captures $$present/$(words $(CAPTURE_DIRS)) present, $$missing missing (strict=$(GATE_STRICT), pins_exact=$(PR_PINS_EXACT))"; \
	if [ $$((present + missing)) -ne $(words $(CAPTURE_DIRS)) ]; then \
		echo "gate: FAIL: CAPTURE_DIRS holds $$((present + missing)) paths, expected $(words $(CAPTURE_DIRS))"; exit 1; \
	fi; \
	for d in $$declared; do echo "gate: declared gap (absent): $$d"; done; \
	for d in $$undeclared; do echo "gate: undeclared absence: $$d"; done; \
	for d in $$stale; do echo "gate: FAIL: declared gap is present (stale): $$d"; done; \
	if [ -n "$$stale" ]; then exit 1; fi; \
	if [ "$(GATE_STRICT)" = 1 ] && [ -n "$$undeclared" ]; then \
		echo "gate: FAIL: undeclared capture absence(s) in strict mode"; exit 1; \
	fi; \
	if [ -n "$$undeclared" ]; then echo "gate: partial mode tolerates the undeclared absence(s)"; fi
	@$(PYTHON) tools/capture_manifest.py --check
	@$(PYTHON) tools/capture_manifest.py --check-pins
	@$(PYTHON) tools/capture_manifest.py --preflight-verify

# The oracle fixtures are git-ignored, so the suite SKIPS those checks unless
# asked. Pass oracle=1 (or use `verify`); pass nothing otherwise — the test
# treats even an empty PR_ORACLE_REQUIRED as "required".
test: build ## Run the assertion suite (oracle=1 requires the byte-exact oracles)
	@if [ -n "$(oracle)" ]; then \
		$(UNIT_ENV) PR_ORACLE_REQUIRED=1 PR_GAME_DIR=$(GAME_DIR) ./$(BUILD_DIR)/run_tests; \
	else \
		$(UNIT_ENV) PR_GAME_DIR=$(GAME_DIR) ./$(BUILD_DIR)/run_tests; \
	fi

check: build ## Run N frames headless, writing frames/frame_*.ppm/.pal/.idx (frames=60)
	@echo "Running $(frames) frames headless ..."
	./$(BUILD_DIR)/prageport --game-dir $(GAME_DIR) --check $(frames)
	@ls -1 frames/frame_*.ppm 2>/dev/null | head -3
	@echo "The reference that is apples-to-apples is the Python decoder on the same GRA frames:"
	@echo "  python3 tools/gra_render.py data/game/C/S16TITLE.GRA 2 out.ppm --frame N --indices out.idx"
	@echo "An emulator cannot drive the port's chosen full-screen title frames (Task 15 report)."

# Smacker frame oracle: the test dumps every decoded frame as 320x200 RGB24
# (PR_SMK_DUMP), then smk_compare.py checks them pixel-exact against the capture.
# Captures are git-ignored; absent capture skips (or fails under
# PR_ORACLE_REQUIRED=1, which smk_compare.py enforces itself).
# >>> PIN-BASELINE-BEGIN smk
# The pinned SMK comparison lengths, measured from the captures: twi5 holds 120
# frame files, twg 41.
SMK_TWI5_FRAMES = 120
SMK_TWG_FRAMES = 41
# <<< PIN-BASELINE-END smk
smk-oracle: build ## Pixel-exact Smacker frame oracle (skips without data/smk-captures)
	@echo "== smacker frame oracle (pixel-exact) =="
	@if [ -d $(SMK_CAPTURES)/twi5 ] || [ -d $(SMK_CAPTURES)/twg ]; then \
		rm -rf $(SMK_DUMP); \
		PR_SMK_DUMP=$(SMK_DUMP) PR_GAME_DIR=$(GAME_DIR) ./$(BUILD_DIR)/run_tests; \
	else \
		echo "smk-oracle: no capture at $(SMK_CAPTURES)/, frames not compared"; \
	fi
	@$(PYTHON) tools/smk_compare.py --capture $(SMK_CAPTURES)/twi5 --port $(SMK_DUMP)/twi5 --frames $(SMK_TWI5_FRAMES)
	@$(PYTHON) tools/smk_compare.py --capture $(SMK_CAPTURES)/twg --port $(SMK_DUMP)/twg --frames $(SMK_TWG_FRAMES)

# Title frame oracle: the PR_TITLE_DUMP driver runs game_init() and the 96-frame
# pinned title window headless, one RGB24 frame per presented frame;
# title_compare.py aligns the dump into each capture by content and requires a
# zero-byte match. Two captures prove determinism (the second is optional). The
# driver runs alone, since game_init() may run once per process.
title-oracle: build ## Pixel-exact title oracle (skips without data/title-captures)
	@echo "== title oracle (pixel-exact, presented window) =="
	@if [ -d $(TITLE_CAPTURES)/title ]; then \
		rm -rf $(TITLE_DUMP); \
		PR_TITLE_DUMP=$(TITLE_DUMP) PR_GAME_DIR=$(GAME_DIR) ./$(BUILD_DIR)/run_tests; \
	else \
		echo "title-oracle: no capture at $(TITLE_CAPTURES)/, frames not compared"; \
	fi
	@$(PYTHON) tools/title_compare.py --capture $(TITLE_CAPTURES)/title \
		$(if $(wildcard $(TITLE_CAPTURES)/title2),--capture $(TITLE_CAPTURES)/title2,) \
		--port $(TITLE_DUMP)/title

# Attract-prefix pixel oracle: the PR_ATTRACT_DUMP run dumps every presented
# state-0 frame plus the post-attract title window; attract_compare.py derives
# the boundary from the title frames, checks every capture frame in the matched
# prefix, and (with --expect-first 215) requires the FIRST divergence to be the
# last attract capture frame (raw 2180 on `title`, 2175 on `title2`) — so a
# regression anywhere in frames 0..214 fails. Captures are git-ignored; an
# absent capture skips (or fails under PR_ORACLE_REQUIRED=1).
# The port-exhibited pin (sub-project A A1c): ATTRACT_MIN_PORT_EXH is a lower
# bound on the number of port attract frames the capture exhibits (a statistic
# the oracle only printed). A port that renders fewer attract frames exhibits
# fewer, which would otherwise pass; a measured count below the pin FAILs. The
# pin is MEASURED from both captures: `attract_compare.py --min-port-exh 99999`
# on data/title-captures/title and .../title2 prints
# "port attract frames exhibited 173/690" for each, so the pin is 173. Raise it
# when the number grows (the tool prints "improved"; PR_PINS_EXACT=1 makes that
# a re-pin failure). A measured count below the pin fails. Both captures also
# measure 74 canonical non-black
# missing-occurrence classes up to reach (proof-completion record §P10).
# >>> PIN-BASELINE-BEGIN attract
ATTRACT_MIN_PORT_EXH ?= 173
ATTRACT_MAX_MISSING = 74
# <<< PIN-BASELINE-END attract
attract-oracle: build ## Pixel-exact attract-prefix oracle (skips without data/title-captures)
	@echo "== attract prefix oracle (pixel-exact) =="
	@if [ -d $(TITLE_CAPTURES)/title ]; then \
		rm -rf $(ATTRACT_DUMP); \
		PR_ATTRACT_DUMP=$(ATTRACT_DUMP) PR_GAME_DIR=$(GAME_DIR) ./$(BUILD_DIR)/run_tests; \
	else \
		echo "attract-oracle: no capture at $(TITLE_CAPTURES)/, frames not compared"; \
	fi
	@$(PYTHON) tools/attract_compare.py --capture $(TITLE_CAPTURES)/title \
		$(if $(wildcard $(TITLE_CAPTURES)/title2),--capture $(TITLE_CAPTURES)/title2,) \
		--expect-first 215 --min-port-exh $(ATTRACT_MIN_PORT_EXH) \
		--max-missing $(ATTRACT_MAX_MISSING) --port $(ATTRACT_DUMP)

# Front-end oracle (the enforced one): the PR_FRONTEND_DUMP driver drives state
# 2 into states 3/4, one RGB24 frame per presented frame plus a per-frame hash
# log. Two gates stand: the PR_FRONTEND_DET determinism check (run_tests
# re-invokes itself twice and requires the two hash logs byte-identical — the
# whole first recipe line, so a failure fails the ladder), and the pixel
# comparison, which exits non-zero on any unexplained frame except the two named
# in tools/title_compare.py's FRONTEND_ALLOWED_UNEXPLAINED. All-black capture
# frames are dropped as documented artifacts (port/spec/game_flow.md); a
# content-bearing frame that disagrees still fails. The window is derived from
# the port's own dump; A1b (below) pins its END so a port that renders fewer
# frames — which would shrink the window and leave the unexplained set empty —
# fails the gate.
# Captures are git-ignored; an absent capture skips both.
# The window END pin (sub-project A A1b): the front-end window is the capture
# frames a port frame is exhibited by, and its end b is where the port's last
# exhibited frame lands. A port that renders fewer frames stops the window
# earlier, but the frames it drops fall OUTSIDE the shrunken window, so the
# unexplained set stays empty and only the end pin catches them. b is MEASURED
# from the enforced capture: `title_compare.py --frontend` on
# data/title-captures/frontend against the PR_FRONTEND_DET dump prints
# "window distinct [560..1884]" (raw 3108..4791), so the pin is 1884. Raise it
# when the window grows (the tool warns "improved: raise the pin"); a measured
# b below it FAILS the gate. The retained dump also measures START 560 and 30
# canonical non-black missing-occurrence classes up to reach; both are pinned.
# This is bounded-missing, not full coverage (proof-completion record §P10).
# >>> PIN-BASELINE-BEGIN frontend
FRONTEND_END ?= 1884
FRONTEND_START = 560
FRONTEND_MAX_MISSING = 30
# <<< PIN-BASELINE-END frontend
frontend-oracle: build ## Front-end oracle (states 3/4; skips without data/title-captures/frontend)
	@echo "== front-end oracle (pixel-exact, states 3/4) =="
	@if [ -d $(TITLE_CAPTURES)/frontend ]; then \
		rm -rf $(FRONTEND_DUMP); \
		PR_FRONTEND_DET=$(FRONTEND_DUMP) PR_GAME_DIR=$(GAME_DIR) ./$(BUILD_DIR)/run_tests; \
	else \
		echo "frontend-oracle: no capture at $(TITLE_CAPTURES)/frontend, frames not compared"; \
	fi
	@$(PYTHON) tools/title_compare.py --frontend --frontend-end $(FRONTEND_END) \
		--frontend-start $(FRONTEND_START) --max-missing $(FRONTEND_MAX_MISSING) \
		--capture $(TITLE_CAPTURES)/frontend \
		--port $(FRONTEND_DUMP)/run1

# Demo window report: the same PR_FRONTEND_DUMP run as frontend-oracle (its frame
# count covers the state-6 entry and the 900-frame state-7 demo). title_compare.py
# --demo locates the front-end window with the same content alignment, then
# classifies the capture region after it against the port frames after the last
# frame that window exhibits — the same clean/splice/transition/unexplained model
# — and reports the window, the counts and the first unexplained frame. It is
# report-only (always exits 0) and is NOT in verify's sequence. Captures are
# git-ignored; an absent capture skips both lines.
demo-oracle: build ## Demo window report, states 9/6/7 (skips without data/title-captures/frontend)
	@echo "== demo window (report-only, states 9/6/7) =="
	@if [ -d $(TITLE_CAPTURES)/frontend ]; then \
		rm -rf $(FRONTEND_DUMP); \
		PR_FRONTEND_DET=$(FRONTEND_DUMP) PR_GAME_DIR=$(GAME_DIR) ./$(BUILD_DIR)/run_tests; \
	else \
		echo "demo-oracle: no capture at $(TITLE_CAPTURES)/frontend, demo window not compared"; \
	fi
	@$(PYTHON) tools/title_compare.py --demo --capture $(TITLE_CAPTURES)/frontend \
		--port $(FRONTEND_DUMP)/run1

# Demo-fight oracle (a RATCHET, enforced in verify): the same dump and classification
# as demo-oracle, restricted to the fight window [fe_b+1 .. first all-black capture
# frame). Claim: no captured frame below DEMO_FIGHT_MIN_FIRST is unexplained, and the
# first unexplained frame is >= it. N = 1886 was measured on 8d538d5 (the commit that
# raised it, on 9abbdd0): 0x34E2C's reaction callback 0x3E3A8 (the T-rex's reaction
# 0x2A at f = 962: the 0xC8950 stream at hold 2.0, state 9/7/0 and the +0x0C callback
# 0x3E328), and the front-end driver's dump of loop frame 1970, the frame presented in
# the iteration that starts in state 7 and exits it (0x11BCC's timer exit drops the
# state to 0 inside it; the driver now credits a presented frame to the state its
# iteration started in, as the title/attract hooks do; 1382 frames, 0..1381). Together
# they explain captures 1881..1884: the front-end window reaches [560..1884], the
# frame before 1885, the capture's first all-black frame after the demo. That is the
# end of the demo-fight capture, so the fight window is empty and title_compare prints
# "fight window empty ... 0 unexplained in the fight window", "fully explained". N =
# 1886 = end + 1 is the exact pin: the claim is now "every content-bearing capture
# frame up to 1885 is explained". It can still fail: a shrink of the front-end window
# (which the front-end oracle, whose window is derived from the port's own dump,
# cannot see) reopens the fight window, and a frame there that the port frames after
# the window do not explain is below N. The capture holds no later fight, so N cannot
# rise further with this capture. Re-measured unchanged on ec8e132 (the slot +0x18
# hook 0x19020 with 0x3E484/0x3E1D0 and 0x18C14, record §35: the dump is
# byte-identical).
# (Before it, N = 1881, measured on bcce10b; N = 1763 on c7320b2; N = 1750 on 2287114;
# N = 1715 on 1268371; N = 1659 on cc38a38; N = 1563 on 27c95c0; N = 1546 on 219691e;
# N = 1481 on 3d64c61; N = 1478 on 4065c1d; N = 1411 on c78dc97; N = 1358 on b5a48a0;
# N = 998 on ff38dcc; N = 992 on 3fee8d0; N = 950 on a51685d; N = 892 on 6a48972; N =
# 891 on cfff063; N = 880 on 0a8346b; N = 870 on a76414d; N = 867 on 2137bce; N = 866
# on 7147288; N = 864 on b915712; N = 860 on afa47b3; N = 859 on b2cb490; N = 858 on
# 594e4b9; N = 851 on dad2712.)
# `make demo-oracle` now reports the region after the front-end window, [1885..3616],
# with no port frame left (the dump ends with the demo): first unexplained 1886, the
# capture's next cycle (the top-level dump ends with the demo; attract2-oracle below
# classifies that region against the cycle2/ dump). The tool also fails if N > window
# end + 1, or if the window collapses with an unexplained front-end frame outside the
# two allowed by name.
# >>> PIN-BASELINE-BEGIN demo-fight
DEMO_FIGHT_MIN_FIRST = 1886
# <<< PIN-BASELINE-END demo-fight
demo-fight-oracle: build ## Demo-fight ratchet, states 6/7 (skips without data/title-captures/frontend)
	@echo "== demo-fight oracle (ratchet on the first unexplained frame, N=$(DEMO_FIGHT_MIN_FIRST)) =="
	@if [ -d $(TITLE_CAPTURES)/frontend ]; then \
		rm -rf $(FRONTEND_DUMP); \
		PR_FRONTEND_DET=$(FRONTEND_DUMP) PR_GAME_DIR=$(GAME_DIR) ./$(BUILD_DIR)/run_tests; \
	else \
		echo "demo-fight-oracle: no capture at $(TITLE_CAPTURES)/frontend, fight window not compared"; \
	fi
	@$(PYTHON) tools/title_compare.py --demo-fight --demo-fight-min-first $(DEMO_FIGHT_MIN_FIRST) \
		--capture $(TITLE_CAPTURES)/frontend --port $(FRONTEND_DUMP)/run1

# Attract cycle-2 oracle (a RATCHET, enforced in verify; roar-timing Task 26, record
# §36): the same PR_FRONTEND_DUMP run. After the demo's exit frame (loop 1970) the
# driver writes every presented frame to run1/cycle2 (loops 1971..4099, and inside
# loop 1971 the 166 screens the logo player 0x1C740 writes: its 0x52106 blanks and
# every TWI5/TWG frame), a separate dump so the front-end and demo-fight windows do
# not change. title_compare --attract2 classifies the capture from the first all-black
# frame after the front-end window (1885) to its end against those frames only. Claim:
# no captured frame below ATTRACT2_MIN_FIRST is unexplained, and the first unexplained
# frame is >= it. The driver also writes each `- LOADING -` screen the loader draws
# over the held frame inside an iteration (res.c's seam, record §45-A). Capture 2384,
# the `- LOADING -` frame before the second demo, was allowed by name (record §37)
# until those screens explained it; ATTRACT2_ALLOWED_UNEXPLAINED is now empty.
# N = 3617 (§48-A, branch frame-3593): the capture's end (3616) + 1, the exact pin,
# since no unexplained frame is left in the region (3545 stays allowed by name).
# At f = 4914 (loop 4027) the third demo's left fighter, character 4 (s16spi),
# takes reaction 0x25, whose callback 0x45AD0 (*(u32*)0xA4C0C) the port did not
# have: it curls into the spiked ball (stream 0xEB64E, state 9/7/1), its +0x18
# hook 0x459F4 finds the hit at f = 4917 and its +0x1C callback 0x45A34 knocks
# the tyrannosaur back (0x3B714) and uncurls it (0xEB692). Porting 0x45AD0,
# 0x45A70, 0x459F4, 0x45A34 and the stream target 0x459D0 explained 3593..3616.
# (Before it, N = 3593 (§47-A, branch frame-3545): FE_LOOPS 3900 -> 4100 brings the high-score
# screen's end (state 9 -> 6 after loop 3984) and the third demo (s16dia against
# s16spi in s16caves; its state-6 entry reads six files in loop 3985) into the dump.
# 3544 is now the port's own loader screen (3543 is all-black, excluded), 3546..3592
# its third demo fight, and 3545
# (raw 8338) is allowed by name as a three-frame splice (ATTRACT2_SPLICE3_ALLOWED,
# cycle-2 2191/2192/2193: the last loader screen, the load frame's present and the
# next, which the read's tick re-sync 0x1B45F/0x1B464 lets fall inside one capture
# scan; they were 2192/2193/2194, and the dump 2308 frames, not 2307, until track I's
# master init read s16title at boot as the original does, 0x2F9E9/0x2F9FC, which took
# the driver's s16title loader screen out of the dump: record 2026-10-13-reverse-i §I.4).
# The driver lists the loader screens in cycle2/loader.txt for that check.
# 3593 (raw 8386) was the third demo's first fight frame the port did not match.)
# (Before it, N = 3545 was measured on 1251af7 (§46-A): the high-score tables 0x2DB58/0x2DBC4/
# 0x2DCA0, their boot fill 0x1E824 (0x1E918's ten factory records and the champion
# 0xA7D74) and the rest of 0x1EA08 (the rows through 0x2F4D0/0x2F4BC and the
# champion's figure 0x2AE14/0x2A17C) draw the attract's high-score screen and explain
# 3408..3542. 3543/3544 (black, `- LOADING -` on black) match earlier port screens,
# not the port's hand-off: the dump ends at loop 3899 inside the high-score screen
# (loops 3685..3899), about 85 ticks before the original leaves it. 3545 (raw 8338)
# is the third demo fight, past the driver's window. Before that, N = 3408, measured on b05adcc (on 54394e9; §45-A): the voice
# dispatcher 0x2C3FC with the sound module's sample path, 0x1543C's voice 0x4D
# (its first read of s16spisd.gra draws the loader's text over the game frame at
# loop 3557), the fighter spawns' sound-bank reads (0x33E51), the DIG driver
# handle DS_001028C8 (0x1CF8E) and the loader-screen seam explain capture 3257.
# 3408 is the attract's third cycle: the high-score table after the second demo,
# which the port did not draw.) The driver's loop (FE_LOOPS, a measurement window)
# runs to 4100 so that the capture's last frame lies inside the dump. It fails if
# an unexplained frame appears below N, N exceeds the capture's end + 1, or N is
# at or below the region's start.
# Like the front-end oracle, its window comes from the port's own dump, so it cannot
# detect an under-rendering port. (Before it, N = 3257, measured on b947895:
# character 3's reaction callbacks 0x15350 and 0x151C0 with their callbacks and
# stream targets, the process-table entry 0x2910C and the opcode-0x0C spawn's a5
# explained 3099..3256; before that N = 3099, measured on fcce893: the
# raptor's reaction stream's 0xD500 target 0x3C32C returned it to its stance at
# loop 3293 and explained 2950..3098; before that N = 2950, measured on ce5f295:
# character 3's reaction-0x23 callback 0x14E44 (the raptor's grab at loop 3116)
# and its +0x18 hook 0x14CC4 (the miss at loop 3133) explained 2763..2949;
# before that N = 2763, measured on c9875d1:
# 0x3B298's call to 0x1A734 (0x3B443), the ape's block restart at loop 3040,
# explained 2674..2762; before that N = 2674, measured on 9469a30: the
# raptor's block, 0x1AB5C's arm 0x1A7CC with 0x1A6AC/0x1A8F4 and the wired 0x1A640
# in the +0x52 == 6 handler 0x1A978, explained 2461..2673; before that N = 2461,
# measured on 5448e09: state 6's 0x34978 reset of the live-fighter count
# DS_001078FA explained 2386..2460; before that N = 2386, measured on c0edb4c and
# re-measured unchanged on a7ccc86 and 65f4084; before that N = 2384, measured on
# fc8e775.) The retained dump measures 126 canonical missing classes up to
# reach, with 3545's splice3 frames credited and classes beyond reach excluded
# (proof-completion record §P10).
# >>> PIN-BASELINE-BEGIN attract2
ATTRACT2_MIN_FIRST = 3617
ATTRACT2_MAX_MISSING = 126
# <<< PIN-BASELINE-END attract2
attract2-oracle: build ## Attract cycle-2 ratchet after the demo (skips without data/title-captures/frontend)
	@echo "== attract cycle-2 oracle (ratchet on the first unexplained frame, N=$(ATTRACT2_MIN_FIRST)) =="
	@if [ -d $(TITLE_CAPTURES)/frontend ]; then \
		rm -rf $(FRONTEND_DUMP); \
		PR_FRONTEND_DET=$(FRONTEND_DUMP) PR_GAME_DIR=$(GAME_DIR) ./$(BUILD_DIR)/run_tests; \
	else \
		echo "attract2-oracle: no capture at $(TITLE_CAPTURES)/frontend, cycle 2 not compared"; \
	fi
	@$(MAKE) --no-print-directory attract2-compare

# The --attract2 comparison alone, on the dump the last front-end run left (verify
# runs it right after demo-fight-oracle, which made that dump).
attract2-compare: ## Print the attract cycle-2 comparison report (used by attract2-oracle)
	@$(PYTHON) tools/title_compare.py --attract2 --attract2-min-first $(ATTRACT2_MIN_FIRST) \
		--max-missing $(ATTRACT2_MAX_MISSING) \
		--capture $(TITLE_CAPTURES)/frontend --port $(FRONTEND_DUMP)/run1

# K11 service-menu capture (named-gaps A): the pinned original under DOSBox-X,
# driven into mode 0x27, with a live-RAM poll log. Writes only
# data/k11-captures/<scenario>/ (tools/k11_capture.py guards the path).
k11-capture: title-pin ## Capture the pinned original's service menu (scenario=walk|idle|menuesc|diags|de; writes data/k11-captures/)
	$(PYTHON) tools/k11_capture.py --scenario $(scenario) --out $(K11_CAPTURES)/$(scenario) --exe $(TITLE_PIN_DIR)/PRAGE.EXE $(K11_ARGS)

# K11 oracle (enforced in verify like the front-end oracle: it skips without
# the capture and fails on any mismatch with it; k11_compare.py ignores an
# inherited PR_ORACLE_REQUIRED and fails on an absent capture only with
# --required, which this target does not pass). The port script comes from the capture's poll log; the
# PR_K11_DUMP driver runs alone (game_init once per process). The second
# scenario, menuesc, is the soft restart (record named-gaps-b §B.12): the MAIN
# MENU Esc, the 0x2520B longjmp, the black frame and the reboot, compared
# byte-exact with the same narrow claims except that the capture may run on
# past the port's final screen (k11_compare K11_OPEN_END). idle stays in
# k11-report: its stock script ends 180 ticks after the Enter, before the
# 0x4B1-tick timeout.
# The menuesc window END must reach K11_OPEN_END['menuesc'] = 388 (tools/k11_compare.py): a
# measured value like the demo-fight N (measured at 14134c9); raise it if the window grows.
# The port-frame coverage pins (sub-project A A1c): K11_<SCENARIO>_MIN_EXH is a lower
# bound on the distinct port frames the capture exhibits (the `--min-exh` pin); a port
# that renders fewer frames — which only shrinks its port-derived window and settled
# list — exhibits fewer and FAILs. The same comparison also now fails a non-black port
# frame up to the final settled screen that no capture frame exhibits. Both are
# MEASURED from the captures: `k11_compare.py --min-exh 99999` on the walk dump prints
# "port frames exhibited 36 < pinned" (so 36) and on the menuesc dump "… exhibited 184"
# (so 184). Raise a pin when its count grows; a measured count below the pin fails.
# poll.log sha256 + the frame count pin each capture's identity (not every
# pixel); the shared existential order check is enforced in k11_compare too.
# k11-lang (completion truth Task 2, R28) is the same comparison for the
# service-menu language reload: the CONFIG OPTIONS language row 0 -> 1 changes
# field 0x29's bits 24..26, 0x336AE reloads FRENCH.TXT, and the OPTIONS MENU and
# MAIN MENU redraw in French. Its END is open by name (K11_OPEN_END in
# tools/k11_compare.py) because the original runs on to the capture's 60 s limit
# after the port script's 180-tick end.
# >>> PIN-BASELINE-BEGIN k11
K11_WALK_MIN_EXH ?= 36
K11_MENUESC_MIN_EXH ?= 184
K11_WALK_CAPTURE_SHA256 = aea00a7d523763d1ac4de19f0c5a65d663a8f9409378fde9c1bdf6f4adef391f
K11_WALK_CAPTURE_FRAMES = 171
K11_MENUESC_CAPTURE_SHA256 = aacd32423ac3879739c2f93bf8d87b44c5fb14803b7c69ad712173f5f70f161e
K11_MENUESC_CAPTURE_FRAMES = 766
# Completion truth Task 2 (R28): the service-menu language reload. MIN_EXH 200
# is the port's distinct exhibited frames (k11_compare --min-exh 99999 prints
# "port frames exhibited 200"); the capture is data/k11-captures/k11-lang
# (460 frames, poll.log sha256 5aeb8266..a893b96e, every session check ok).
# The END 408 pin lives in tools/k11_compare.py's K11_OPEN_END (the original runs
# on to the 60 s capture limit after the script's 180-tick end).
K11_LANG_MIN_EXH ?= 200
K11_LANG_CAPTURE_SHA256 = 5aeb8266fe48fa0039f67e43e70d893a418a35dc770c1b0720103438a893b96e
K11_LANG_CAPTURE_FRAMES = 460
# <<< PIN-BASELINE-END k11
k11-oracle: build ## K11 oracles: the walk, the menuesc restart and the k11-lang language reload (each skips without its data/k11-captures/<scenario>)
	@echo "== K11 service-menu oracle (pixel-exact, the walk) =="
	@if [ -d $(K11_CAPTURES)/walk ]; then \
		rm -rf $(K11_DUMP)/walk; mkdir -p $(K11_DUMP); \
		$(PYTHON) tools/k11_session.py port-script --scenario walk --capture $(K11_CAPTURES)/walk --out $(K11_DUMP)/walk.script && \
		PR_K11_DUMP=$(K11_DUMP)/walk PR_K11_SCRIPT=$(K11_DUMP)/walk.script PR_GAME_DIR=$(GAME_DIR) ./$(BUILD_DIR)/run_tests; \
	else \
		echo "k11-oracle: no capture at $(K11_CAPTURES)/walk, frames not compared"; \
	fi
	@$(PYTHON) tools/k11_compare.py --capture $(K11_CAPTURES)/walk --port $(K11_DUMP)/walk \
		--min-exh $(K11_WALK_MIN_EXH) \
		--capture-sha256 $(K11_WALK_CAPTURE_SHA256) --capture-frames $(K11_WALK_CAPTURE_FRAMES)
	@echo "== K11 soft-restart oracle (pixel-exact, menuesc: the MAIN MENU Esc 0x2520B restart) =="
	@if [ -d $(K11_CAPTURES)/menuesc ]; then \
		rm -rf $(K11_DUMP)/menuesc; mkdir -p $(K11_DUMP); \
		$(PYTHON) tools/k11_session.py port-script --scenario menuesc --capture $(K11_CAPTURES)/menuesc --out $(K11_DUMP)/menuesc.script && \
		PR_K11_DUMP=$(K11_DUMP)/menuesc PR_K11_SCRIPT=$(K11_DUMP)/menuesc.script PR_GAME_DIR=$(GAME_DIR) ./$(BUILD_DIR)/run_tests; \
	else \
		echo "k11-oracle: no capture at $(K11_CAPTURES)/menuesc, frames not compared"; \
	fi
	@$(PYTHON) tools/k11_compare.py --scenario menuesc --capture $(K11_CAPTURES)/menuesc --port $(K11_DUMP)/menuesc \
		--min-exh $(K11_MENUESC_MIN_EXH) \
		--capture-sha256 $(K11_MENUESC_CAPTURE_SHA256) --capture-frames $(K11_MENUESC_CAPTURE_FRAMES)
	@echo "== K11 language-reload oracle (pixel-exact, k11-lang: CONFIG OPTIONS' language 0 -> 1) =="
	@$(PYTHON) -m unittest tools.tests.test_k11_lang
	@if [ -d $(K11_CAPTURES)/k11-lang ]; then \
		$(PYTHON) tools/k11_lang.py check --capture $(K11_CAPTURES)/k11-lang --exe $(GAME_DIR)/PRAGE.EXE || exit 1; \
		rm -rf $(K11_DUMP)/k11-lang; mkdir -p $(K11_DUMP); \
		$(PYTHON) tools/k11_session.py port-script --scenario k11-lang --capture $(K11_CAPTURES)/k11-lang --out $(K11_DUMP)/k11-lang.script && \
		PR_K11_DUMP=$(K11_DUMP)/k11-lang PR_K11_SCRIPT=$(K11_DUMP)/k11-lang.script PR_GAME_DIR=$(GAME_DIR) ./$(BUILD_DIR)/run_tests; \
	else \
		echo "k11-oracle: no capture at $(K11_CAPTURES)/k11-lang, frames not compared"; \
	fi
	@$(PYTHON) tools/k11_compare.py --scenario k11-lang --capture $(K11_CAPTURES)/k11-lang --port $(K11_DUMP)/k11-lang \
		--min-exh $(K11_LANG_MIN_EXH) \
		--capture-sha256 $(K11_LANG_CAPTURE_SHA256) --capture-frames $(K11_LANG_CAPTURE_FRAMES)

# The evidence scenarios (G1/G2/G3): the same driver and comparison, report-only
# (menuesc is also enforced by k11-oracle).
k11-report: build ## Report-only K11 comparison of the evidence captures (idle, menuesc, diags, de)
	@for s in idle menuesc diags de; do \
		if [ -d $(K11_CAPTURES)/$$s ]; then \
			rm -rf $(K11_DUMP)/$$s; mkdir -p $(K11_DUMP); \
			$(PYTHON) tools/k11_session.py port-script --scenario $$s --capture $(K11_CAPTURES)/$$s --out $(K11_DUMP)/$$s.script && \
			PR_K11_DUMP=$(K11_DUMP)/$$s PR_K11_SCRIPT=$(K11_DUMP)/$$s.script PR_GAME_DIR=$(GAME_DIR) ./$(BUILD_DIR)/run_tests; \
			$(PYTHON) tools/k11_compare.py --report --scenario $$s --capture $(K11_CAPTURES)/$$s --port $(K11_DUMP)/$$s; \
		else \
			echo "k11-report: no capture at $(K11_CAPTURES)/$$s"; \
		fi; \
	done

# Save-reload oracle (plan 2026-10-15-reverse-j-closeout.md task 7; record
# 2026-10-15-reverse-j-derivations.md §J3). data/k11-captures/save-reload is two
# boots of the pinned original: boot 1 (the one writable mount, the 11 keys ending
# in CONFIG OPTIONS' `right` tap and two Escs) leaves the OPTIONS MENU, whose
# menu_run return calls config_file_save (0x2CBAF); boot 2 (read-only, one Enter)
# starts from the file boot 1 wrote. The two claims (tools/save_reload.py):
# write — the port's boot-1 replay with PR_K11_SAVE=<scratch> writes a file
# byte-identical to the capture's CMOS; load — the port booted from a copy of that
# CMOS holds, at the pinned frame and in every later dumped frame, the five
# storage ranges of §J3.3 that boot 2's poll.log holds at the pin. Both narrow:
# no display claim, no coverage of the other save sites, the kinds outside the
# ranges, or anything past the pin. Enforced like k11-oracle: skips without the
# capture (PR_ORACLE_REQUIRED is not read) and fails on a broken claim once it
# exists; a difference is a finding, never a re-pin towards the port.
# Pins, all measured on the one capture (2026-10-15, DOSBox-X 2026.08.31 at
# /usr/local/bin/dosbox-x, session.txt):
#   CMOS_SHA256: data/k11-captures/save-reload/CMOS (2040 bytes, the XOR last
#     byte); POLL_SHA256: .../poll.log, boot 2's (boot 1's is .../boot1/poll.log,
#     sha cfc05e51af655c0ab59f7e15b0253eef75e660cc92f83d9844c03a7eb3875bfc);
#   ENTER_FRAME 491: boot 2's first P record with mode 0x27 after its K
#     (f=0x1EB; boot 1's is 304) — the port dump frame the load claim judges from;
#   FRAMES 162: boot 2's frame_*.raw count.
# >>> PIN-BASELINE-BEGIN save-reload
SAVE_RELOAD_CMOS_SHA256 = dad3b1f6f84bd851eee675281a7a3310dcf967d42a375b1b9919fb1a41f104f9
SAVE_RELOAD_POLL_SHA256 = fb9ab5aa5f833e4decd680c55358e3df25b4dda887e1837d1033e25cb19a05f5
SAVE_RELOAD_ENTER_FRAME = 491
SAVE_RELOAD_FRAMES = 162
# <<< PIN-BASELINE-END save-reload
save-reload-oracle: build ## Save-reload oracle: boot 1's replay writes the captured CMOS, boot 2 loads it (skips without data/k11-captures/save-reload)
	@echo "== save-reload oracle (the write claim, then the load claim) =="
	@if [ -d $(K11_CAPTURES)/save-reload ]; then \
		rm -rf $(K11_DUMP)/save-reload; mkdir -p $(K11_DUMP)/save-reload/boot1-scratch; \
		rm -f $(K11_DUMP)/save-reload/boot1-scratch/cmos; \
		$(PYTHON) tools/k11_session.py port-script --scenario save-reload --boot 1 --capture $(K11_CAPTURES)/save-reload/boot1 --out $(K11_DUMP)/save-reload/boot1.script && \
		PR_K11_DUMP=$(K11_DUMP)/save-reload/boot1 PR_K11_SCRIPT=$(K11_DUMP)/save-reload/boot1.script \
			PR_K11_SAVE=$(K11_DUMP)/save-reload/boot1-scratch/cmos PR_GAME_DIR=$(GAME_DIR) ./$(BUILD_DIR)/run_tests && \
		$(PYTHON) tools/save_reload.py write --capture $(K11_CAPTURES)/save-reload \
			--scratch $(K11_DUMP)/save-reload/boot1-scratch/cmos --game-dir $(GAME_DIR) \
			--cmos-sha256 "$(SAVE_RELOAD_CMOS_SHA256)" && \
		cp -f $(K11_CAPTURES)/save-reload/CMOS $(K11_DUMP)/save-reload/boot2-cmos && \
		cmp $(K11_CAPTURES)/save-reload/CMOS $(K11_DUMP)/save-reload/boot2-cmos && \
		$(PYTHON) tools/k11_session.py port-script --scenario save-reload --boot 2 --capture $(K11_CAPTURES)/save-reload --out $(K11_DUMP)/save-reload/boot2.script && \
		PR_K11_DUMP=$(K11_DUMP)/save-reload/boot2 PR_K11_SCRIPT=$(K11_DUMP)/save-reload/boot2.script \
			PR_K11_SAVE=$(K11_DUMP)/save-reload/boot2-cmos PR_GAME_DIR=$(GAME_DIR) ./$(BUILD_DIR)/run_tests && \
		$(PYTHON) tools/save_reload.py load --capture $(K11_CAPTURES)/save-reload \
			--port $(K11_DUMP)/save-reload/boot2 --enter-frame "$(SAVE_RELOAD_ENTER_FRAME)" \
			--poll-sha256 "$(SAVE_RELOAD_POLL_SHA256)" --capture-frames "$(SAVE_RELOAD_FRAMES)"; \
	else echo "save-reload-oracle: no capture at $(K11_CAPTURES)/save-reload (skipped)"; fi

# Gameplay capture (spec 2026-09-30-gameplay-ground-truth-design.md §4.1): the
# pinned original under DOSBox-X with frame-keyed injection and a per-frame
# snapshot log. Writes only data/k11-captures/gp-<scenario>/ (gp_capture.guard_gp).
GP_ARGS ?=
GP_REPORT_MAX ?= 5
# Canonical non-black missing-occurrence classes up to each replay's exhibited
# reach, measured by gp_compare --max-missing on the retained Task 8 dumps
# (proof-completion record §P10). This is a bounded-missing claim, not full
# coverage: unsampled states and same-count class substitutions stay unclaimed.
# gp-u14 (absent), gp-u17 (no frame replay) and gp-pads (smoke) have no pin.
# >>> PIN-BASELINE-BEGIN gp-max-missing
GP_IDLE_LOSS_MAX_MISSING = 29
GP_CHARSEL_MAX_MISSING = 4
GP_MOVES_MAX_MISSING = 7
GP_KEYS_MAX_MISSING = 9
GP_TWOP_MAX_MISSING = 5
GP_TWOP_KO_MAX_MISSING = 7
GP_MODES_RA_MAX_MISSING = 7
GP_MODES_LT_MAX_MISSING = 8
GP_MODES_RT_MAX_MISSING = 5
GP_MODES_TW_MAX_MISSING = 6
GP_MODES_HC_MAX_MISSING = 6
GP_MODES_EN_MAX_MISSING = 7
GP_MODES_AS_MAX_MISSING = 7
GP_U12_MAX_MISSING = 6
GP_WIN_MAX_MISSING = 12
GP_ENDING_MAX_MISSING = 63
GP_U13_MAX_MISSING = 57
GP_U15_MAX_MISSING = 5
GP_U16_MAX_MISSING = 23
GP_U18_MAX_MISSING = 55
GP_U19_MAX_MISSING = 49
GP_U20_MAX_MISSING = 70
GP_U21_MAX_MISSING = 53
GP_U22_MAX_MISSING = 52
GP_U23_MAX_MISSING = 9
GP_U25_MAX_MISSING = 12
GP_U26_MAX_MISSING = 11
GP_U27_MAX_MISSING = 38
GP_DRAW_MAX_MISSING = 11
GP_DRAW_TIMEOUT_MAX_MISSING = 5
GP_U28_MAX_MISSING = 17
GP_U29_MAX_MISSING = 12
# <<< PIN-BASELINE-END gp-max-missing
gp-capture: title-pin ## Capture a gameplay scenario (scenario=gp-…; writes data/k11-captures/)
	$(PYTHON) tools/gp_capture.py --scenario $(scenario) --out $(K11_CAPTURES)/$(scenario) --exe $(TITLE_PIN_DIR)/PRAGE.EXE $(GP_ARGS)

# Gameplay replay (spec 2026-09-30-gameplay-ground-truth-design.md §4.2): the
# v2 port script from the capture's poll.log, then the PR_GP_DUMP driver alone
# (game_init once per process). An absent capture skips, or fails under
# PR_ORACLE_REQUIRED unless GP_OPTIONAL=1. make verify passes GP_OPTIONAL=1:
# a gp capture skips there like the K11 oracles (spec §4.3, record §G.11), so
# a checkout without data/k11-captures/gp-pads still passes verify.
GP_OPTIONAL ?=
GP_SCRIPT_ARGS ?=
gp-replay: build ## Replay a gameplay capture in the port (scenario=gp-…; dump in $(GP_DUMP)/<scenario>)
	@case "$(scenario)" in gp-?*) ;; *) \
		echo "usage: make gp-replay scenario=gp-<name> (got scenario=$(scenario))"; exit 2;; esac
	@if [ -d $(K11_CAPTURES)/$(scenario) ]; then \
		rm -rf $(GP_DUMP)/$(scenario); mkdir -p $(GP_DUMP); \
		$(PYTHON) tools/gp_session.py port-script --scenario $(scenario) --capture $(K11_CAPTURES)/$(scenario) --out $(GP_DUMP)/$(scenario).script $(GP_SCRIPT_ARGS) && \
		PR_GP_DUMP=$(GP_DUMP)/$(scenario) PR_GP_SCRIPT=$(GP_DUMP)/$(scenario).script PR_GAME_DIR=$(GAME_DIR) ./$(BUILD_DIR)/run_tests; \
	elif [ -z "$(GP_OPTIONAL)" ] && [ -n "$${PR_ORACLE_REQUIRED+x}" ]; then \
		echo "gp-replay: no capture at $(K11_CAPTURES)/$(scenario) (PR_ORACLE_REQUIRED)"; exit 1; \
	else \
		echo "gp-replay: no capture at $(K11_CAPTURES)/$(scenario)"; \
	fi

# Gameplay oracle (spec 2026-09-30-gameplay-ground-truth-design.md §4.3): the
# capture against the port's replay, a frame ratchet (title_compare's explain
# model) and a trace ratchet (S against T by the frame counter). Enforced like
# the K11 oracle: it skips without the capture (gp-replay gets GP_OPTIONAL=1,
# so PR_ORACLE_REQUIRED does not fail it either, record §H.2 item 3) and fails
# on a broken claim or an unpinned N once the capture exists. Both claims are
# narrow (record §G.16): neither says the frames or state after the first
# unexplained/differing one are right. U4 pinned the values below from the
# measured first unexplained capture frame / first differing f / window start
# (MAX_START: the capture frame where the window begins; a later start fails, so a
# port regression cannot slide the window past the frame that set MIN_FIRST).
# All re-measured on main c6cac22 (U0's 40 functions) + U3 + U4 (record §G.24): unchanged
# from the first measurement on the pre-U0 base (MIN_FIRST re-pinned by U5 and U6a,
# TRACE_MIN_FIRST by U6a, below).
# MIN_FIRST: first unexplained capture frame 2064 (raw 5190), measured after U6a ported 0x3640C,
# 0x23208, 0x37DCC and 0x3A588 (record gameplay-u6 §U6.21): gp_compare --report prints "nearest
# port 1670, rows 0..97, x 0..319 (13832 px)". Port 1670 is f=0xC75, mode 8 (frames.txt); the
# capture enters mode 8 (game_mode_08_step 0x28468) at f=0xC71 (poll.log P record) from the
# round-1 fight (mode 6), and capture 2063 is port 1666 (f=0xC71) clean, so 2064 lies in mode 8
# between f=0xC71 and f=0xC75, on the fight screen. Every row of it equals the same row of a port
# frame: rows 0..2 equal in port 1665/1666 (f=0xC70/0xC71), rows 3..97 port 1669 (f=0xC74),
# rows 98..199 port 1670 (f=0xC75). A three-frame scan-out is outside the explain model (clean,
# a splice of two adjacent frames, one transition row), and port 1667/1668 (f=0xC72/0xC73)
# appear in no capture frame; the capture has no S snapshot for f=0xC71..0xC73 (as at several
# other mode changes, not all: record §U6.21). Why the original's scan-out shows three frames
# is not isolated: a named gap with that evidence.
# Track G g6 RE-MEASURED and RAISED this pin (record 2026-10-14-reverse-g §G6): the three-frame
# scan-out is now isolated (the master loop's own gate 0x25643 / catch-up 0x256C5: mode-8 entry
# iterations whose compute overruns an ISR tick skip their presents, and the re-opened presents
# land sub-tick apart inside one 70.09 Hz scan; the loader re-sync hypothesis 0x1B45F/0x1B464
# was tested and rejected on the tick 1:1 cadence and the byte-identical held raws) and capture
# 2064 is allowed by name in SCAN3_ALLOWED (top rows 0..2 of port 1666/1665 held across raws
# 5187..5189, pair (1669, 1670) rows 3..97/98..199, the 6301-frame dump's unique adjacent bind,
# top sha 7bdf2c56.., §G6's table; per-frame per-bytes, fails on any mutation of a bound frame).
# With it active every capture frame 90..7558 is explained and the first unexplained is 7559
# (raw 12207): the first frame of content past the replayed script — the port's replay ends with
# "0 restart(s) landed" and its last presented frame is f=0x207D, while the capture from 7557
# (all-black, skipped) / 7558 (six non-black rows, still matched) goes on to the soft restart's
# boot screens; the X record is ms=174412 f=0x207F step=4 end. How far the port got, not a
# divergence (the gp-keys 1136 class, record §G5). Raise only when the script is lengthened
# (gp_compare prints "improved: raise N").
# Provenance: U4 pinned 203; U5 raised it to 787 (record 2026-10-01-gameplay-u5 §C5.12/§C5.13:
# the 0x37A58 top wrap compares the frame byte rec+0x52, raw 0x37B03/0x37B08); 787 was the miss of
# the then-unregistered move callback 0x23208 (divergence 2, record §G.24), ported by U6a; U6a
# pinned 2064 (record gameplay-u6 §U6.21); track G g6 raised it to 7559 with §G6's allowance.
# Raise it when the frame claim improves (gp_compare prints "improved: raise N").
# >>> PIN-BASELINE-BEGIN gp-idle-loss
GP_IDLE_LOSS_MIN_FIRST = 7559
# TRACE_MIN_FIRST: no traced difference over the whole replay, port against the capture:
# gp_compare prints "0 differing through 8319 ... N = 8320 is the exact pin" (7973 frames compared,
# f = 0x141..0x207F, i.e. decimal 321..8319; 26 f without a capture snapshot are not compared).
# N = end + 1 = 8320 is the exact pin (gp_compare's ratchet: an N above the end fails as
# unreachable), so a traced difference at any compared f fails it. Raised from 2088 by U6a's
# four ports (record gameplay-u6 §U6.21); 2088 = 0x828 was the rng difference four frames after
# the miss of 0x23208 (record §G.24). No run-to-run bound (record §G.19).
GP_IDLE_LOSS_TRACE_MIN_FIRST = 8320
# MAX_START: the window begins at capture frame 90 (raw 1744), the first capture frame that
# shows the port's first frame (gp_compare prints "window from capture 90"); it must be < MIN_FIRST.
GP_IDLE_LOSS_MAX_START = 90
# MIN_REACH (sub-project A §A1): the highest port frame any classified capture frame
# exhibits ("coverage ... up to port 6300"), the replay's last presented frame (f=0x207D);
# below it both the window and the coverage shrink, so a port that renders fewer screens
# fails. Measured 2026-10-16 on data/k11-captures/gp-idle-loss via gp_compare --report.
# Raise it when gp_compare prints "improved: raise the pin".
GP_IDLE_LOSS_MIN_REACH = 6300
# The capture the three values belong to: the poll.log sha256 and the frame count of
# data/k11-captures/gp-idle-loss (record §G.18; run 1 of the two captures, the oracle reads this
# one). gp-oracle FAILS (never skips) on a present capture with another poll.log: a re-capture
# invalidates the pins above, because the pick time-out steps in 64-frame units of the absolute
# frame counter (f & 0x3F == 0, record §G.24), so a re-capture whose character select starts at
# f <= 0x280 moves everything after it by 64 frames. Re-measure all three, then re-pin these.
GP_IDLE_LOSS_CAPTURE_SHA256 = 773e264731ea83a23623c6ec6cc5547165628d8adcf96f5a7c99c1c2b88c8447
GP_IDLE_LOSS_CAPTURE_FRAMES = 8173
# <<< PIN-BASELINE-END gp-idle-loss
gp-oracle: build ## Gameplay oracle: gp-idle-loss frame and trace ratchets (skips without data/k11-captures/gp-idle-loss)
	@echo "== gameplay oracle: gp-idle-loss (frame and trace ratchets) =="
	@$(MAKE) --no-print-directory gp-replay scenario=gp-idle-loss GP_OPTIONAL=1
	@$(PYTHON) tools/gp_compare.py --scenario gp-idle-loss --capture $(K11_CAPTURES)/gp-idle-loss \
		--port $(GP_DUMP)/gp-idle-loss --min-first "$(GP_IDLE_LOSS_MIN_FIRST)" \
		--trace-min-first "$(GP_IDLE_LOSS_TRACE_MIN_FIRST)" --max-start "$(GP_IDLE_LOSS_MAX_START)" \
		--min-reach "$(GP_IDLE_LOSS_MIN_REACH)" --max-missing "$(GP_IDLE_LOSS_MAX_MISSING)" \
		--capture-sha256 "$(GP_IDLE_LOSS_CAPTURE_SHA256)" --capture-frames "$(GP_IDLE_LOSS_CAPTURE_FRAMES)"

# U5 character-select walk (record 2026-10-01-gameplay-u5 §C5.16-§C5.18): the gp-u5-charsel capture
# against its port replay, both ratchets, enforced like gp-oracle (skips without the capture,
# fails on a present capture with another poll.log). Values measured in U5 Task 7 (report lines in
# §C5.17), pinned by U5 Task 8 Step 1 (pins.sh); raise N/F when the claims improve.
# Provenance (U5 Task 7, record §C5.17): the report's FIRST UNEXPLAINED capture line names the frame after
# the port's last one. The port's script ends at the capture's mode-6 frame (f = 0x5E8 = 1512), while
# the capture's poll.log runs on to about f = 0x925 (round 1 until the 60 s limit); the port's last
# frame is byte-identical to the capture frame before N, so N is how far the port got, not a defect.
# The trace line reports no differing frame through the script's last f, so TRACE_MIN_FIRST is that
# f plus one, the exact pin (one more fails as unreachable).
# >>> PIN-BASELINE-BEGIN gp-charsel
GP_CHARSEL_MIN_FIRST = 516
GP_CHARSEL_TRACE_MIN_FIRST = 1513
GP_CHARSEL_MAX_START = 100
GP_CHARSEL_CAPTURE_SHA256 = 138fb537cd8c364bab0ccc68fc8fdd9079de19f3b0fc789d427f6d9652a18d7e
GP_CHARSEL_CAPTURE_FRAMES = 1464
# MIN_REACH (sub-project A §A1): the highest port frame any classified capture frame
# exhibits ("coverage ... up to port 358"), the replay's last presented frame (the script
# ends at the capture's mode-6 frame). Measured 2026-10-16 on data/k11-captures/gp-u5-charsel
# via gp_compare --report. Raise it when gp_compare prints "improved: raise the pin".
GP_CHARSEL_MIN_REACH = 358
# <<< PIN-BASELINE-END gp-charsel
gp-charsel-oracle: build ## Gameplay oracle: gp-u5-charsel frame and trace ratchets (skips without data/k11-captures/gp-u5-charsel)
	@echo "== gameplay oracle: gp-u5-charsel (frame and trace ratchets) =="
	@$(MAKE) --no-print-directory gp-replay scenario=gp-u5-charsel GP_OPTIONAL=1
	@$(PYTHON) tools/gp_compare.py --scenario gp-u5-charsel --capture $(K11_CAPTURES)/gp-u5-charsel \
		--port $(GP_DUMP)/gp-u5-charsel --min-first "$(GP_CHARSEL_MIN_FIRST)" \
		--trace-min-first "$(GP_CHARSEL_TRACE_MIN_FIRST)" --max-start "$(GP_CHARSEL_MAX_START)" \
		--min-reach "$(GP_CHARSEL_MIN_REACH)" --max-missing "$(GP_CHARSEL_MAX_MISSING)" \
		--capture-sha256 "$(GP_CHARSEL_CAPTURE_SHA256)" --capture-frames "$(GP_CHARSEL_CAPTURE_FRAMES)"

# Plan gameplay-u6b (record gameplay-u6 §U6.22): the gp-u6-moves-b capture (P1 Sauron's twelve
# scripted attempts in round 1, record §U6.12; 8 performed, all six distinct moves) against its
# port replay. gp-u6-moves-b is the clean re-capture: the first capture, gp-u6-moves, recorded
# unscripted keyboard input from f=0x9CB and is not pinned (record §U6.22). Three ratchets, each
# the measured first unexplained/differing item (raise it when it improves): the frames, the trace
# (gp_session.TRACE_FIELDS) and the moves claim (gp_session.MOVE_FIELDS: c0 c1 r0 r1 s0_43,
# record §U6.11); the window start; the capture identity (a re-capture fails: re-measure, then
# re-pin). Skips without data/k11-captures/gp-u6-moves-b, like gp-oracle.
# Provenance (U6b Task 15, `make gp-report scenario=gp-u6-moves-b` on 9fa9ce1 plus the test
# comment fold-ins; record §U6.22; re-measured at the C3e closure, log /tmp/pr_c3e_final.log):
#   MIN_FIRST: "frames: FIRST UNEXPLAINED capture 2139 (raw 5260): nearest port 1737, rows
#     64..197, x 1..319 (4279 px)"; U6b Task 15 measured 1005 (the health-bar rows) and the pin
#     had stayed there while the port's replay advanced the first unexplained frame.
#     Track G g7 (record 2026-10-14-reverse-g §G7) names that class: 2139 is the first frame of
#     the capture's post-script tail. The port's script ends at the capture's X record (f=0xCAF,
#     ms=75222 step=38 end) and the dump's last presented frame is 1737 (f=0xCAF): capture 2139
#     holds port 1737's rows 0..63 and 198..199 (the still-scanned last screen) and rows 64..197
#     (the 4279 px) the original's next screen (f>=0xCB0), which no replay of this script can
#     present. How far the port got, not a divergence (the gp-keys 1136 / gp-idle-loss 7559
#     class); N=2139 is exact for this capture (a longer script is a re-capture). The window
#     starts at 88 (GP_MOVES_MAX_START), unchanged.
#   TRACE_MIN_FIRST: "trace: 0 differing through 3247; ratchet N 2262 ok (every item is
#     explained: N = 3248 is the exact pin)"; U6b Task 15's first difference was f=8D6 (2262) in
#     s1_5a: capture 16, port 2D, six frames after P1's 0x24 move at f=0x8D0, with no
#     unregistered callback left (distinct=4), so the cause was not a miss (a named gap, record
#     §U6.22); the replay now matches the field through the compared range's end.
#   MOVES_MIN_FIRST: "moves: 0 differing through 3247; ratchet N 2949 ok (every item is
#     explained: N = 3248 is the exact pin)"; U6b Task 15's first difference was f=B85 (2949) in
#     r1: capture 0, port 15; the replay now matches those bytes through the compared range's end.
#   MAX_START: "frames: window from capture 88 (raw 1741)".
#   CAPTURE_SHA256/FRAMES: data/k11-captures/gp-u6-moves-b/poll.log and its frame_*.raw.gz count
#     (U6b Task 6 re-run, re-checked on disk at Task 15).
# >>> PIN-BASELINE-BEGIN gp-moves
GP_MOVES_MIN_FIRST = 2139
GP_MOVES_TRACE_MIN_FIRST = 3248
GP_MOVES_MOVES_MIN_FIRST = 3248
GP_MOVES_MAX_START = 88
GP_MOVES_CAPTURE_SHA256 = dcf242da915c41f4b4e381babdabdc30cd04ce89ed83e25f5178601e4878b2e3
GP_MOVES_CAPTURE_FRAMES = 2205
# MIN_REACH (sub-project A §A1): the highest port frame any classified capture frame
# exhibits ("coverage ... up to port 1737"), the replay's last presented frame (the script
# ends at the capture's X record, f=0xCAF). Measured 2026-10-16 on
# data/k11-captures/gp-u6-moves-b via gp_compare --report. Raise it when gp_compare prints
# "improved: raise the pin".
GP_MOVES_MIN_REACH = 1737
# <<< PIN-BASELINE-END gp-moves
gp-moves-oracle: build ## Gameplay oracle: gp-u6-moves-b frame, trace and moves ratchets (skips without data/k11-captures/gp-u6-moves-b)
	@echo "== gameplay oracle: gp-u6-moves-b (frame, trace and moves ratchets) =="
	@$(MAKE) --no-print-directory gp-replay scenario=gp-u6-moves-b GP_OPTIONAL=1
	@$(PYTHON) tools/gp_compare.py --scenario gp-u6-moves-b --capture $(K11_CAPTURES)/gp-u6-moves-b \
		--port $(GP_DUMP)/gp-u6-moves-b --min-first "$(GP_MOVES_MIN_FIRST)" \
		--trace-min-first "$(GP_MOVES_TRACE_MIN_FIRST)" --max-start "$(GP_MOVES_MAX_START)" \
		--moves-min-first "$(GP_MOVES_MOVES_MIN_FIRST)" \
		--min-reach "$(GP_MOVES_MIN_REACH)" --max-missing "$(GP_MOVES_MAX_MISSING)" \
		--capture-sha256 "$(GP_MOVES_CAPTURE_SHA256)" --capture-frames "$(GP_MOVES_CAPTURE_FRAMES)"

# U11 in-match keys (plan 2026-10-01-gameplay-u11-in-match-keys.md, record
# 2026-10-01-gameplay-u11-derivations.md §K.6/§K.12): tools/gp_keys.py judges
# each key event of data/k11-captures/gp-keys-fight twice: `evidence` (the
# capture shows the raw-derived effect) and `effects` (the port's PR_GP_DUMP
# replay shows it at the same frames; a ratchet: the events before the first
# the port does not reproduce must number >= GP_KEYS_MIN_EFFECTS). Skips without
# the capture; with it, an unpinned value or another poll.log fails. Narrow: an
# event is judged on the latch, the pause bytes, the pad words, mode, b1f, cred
# and rng only. Track G task 5 (record 2026-10-14-reverse-g §G5) added the frame
# ratchet below: the pause and prompt frames are now compared, with capture 835
# the one frame allowed by name (its band is the never-dumped present state, the
# §K.10 gap).
# MIN_EFFECTS: the port reproduces the first 11 of the 11 events of record §K.6 on
# data/k11-captures/gp-keys-fight (Task 6, record §K.12: "effects: first not reproduced 11,
# ratchet N 0 ok (improved: raise N)"); raise it when gp_keys prints "improved: raise N".
# CAPTURE_SHA256: that capture's poll.log; another capture fails until it is re-measured
# (Task 5 Step 1) and both values re-pinned.
# >>> PIN-BASELINE-BEGIN gp-keys
GP_KEYS_MIN_EFFECTS = 11
GP_KEYS_CAPTURE_SHA256 = 8425afbc46d51173532f6f4c27a8f16c594e2bc572b4273e056bdbf51a9678ae
# The frame + trace ratchet (track G task 5, plan 2026-10-14-reverse-g-widening, record
# 2026-10-14-reverse-g-derivations.md §G5): tools/gp_compare.py over the same replay.
# Provenance, measured on 5f874678 (the replay's 789-frame dump at $(GP_DUMP)/gp-keys-fight):
#   MIN_FIRST: the allowance (gp_compare.BANDS_ALLOWED['gp-keys-fight']: capture 835 = raw
#     3934, rows 0..80 byte-bound to port 592 (f=0x832), rows 92..199 to port 593 (f=0x833),
#     band rows 81..91 pinned by sha256 d9c0ac62f7e0c0d4082fba8d195eae4f767c56a7887bdf7346450511f998cad8,
#     the dump's unique matching adjacent pair; §G5: 2442 band bytes equal neither frame, no
#     splice point, no port frame holds the band) explains 835, leaving ZERO unexplained
#     frames inside the replay's range: "first unexplained 1136, ratchet N 1136 ok".
#     1136 (raw 5027)
#     is the end of the port's script (the capture's X record f=0873, step=19 end; captures
#     1136..1165 are the restart's boot movies the port never ran) — like gp-twop's 612, how
#     far the port got, not a divergence. Raise only if the script is lengthened.
#   TRACE_MIN_FIRST: "trace: 0 differing through 2163; ratchet N 2164 ok (every item is
#     explained: N = 2164 is the exact pin)" — 0x873 + 1, the replay's last traced f.
#   MAX_START: "frames: window from capture 100 (raw 1747)".
#   CAPTURE_FRAMES: 1166 frame_*.raw.gz (§K.14); CAPTURE_SHA256 above is unchanged (the
#     same capture — track G pinned no new hash). No moves pin: the capture has the move
#     fields and the report pass shows 0 differing, but the moves claim is out of this
#     task's scope (§G5).
GP_KEYS_MIN_FIRST = 1136
GP_KEYS_TRACE_MIN_FIRST = 2164
GP_KEYS_MAX_START = 100
GP_KEYS_CAPTURE_FRAMES = 1166
# MIN_REACH (sub-project A §A1): the highest port frame any classified capture frame
# exhibits ("coverage ... up to port 788"), the replay's last presented frame (the script
# ends at the capture's X record, f=0x873). Measured 2026-10-16 on
# data/k11-captures/gp-keys-fight via gp_compare --report. Raise it when gp_compare prints
# "improved: raise the pin".
GP_KEYS_MIN_REACH = 788
# <<< PIN-BASELINE-END gp-keys
gp-keys-oracle: build ## In-match keys: evidence + effects ratchet and the frame ratchet on gp-keys-fight (skips without data/k11-captures/gp-keys-fight)
	@echo "== in-match keys oracle: gp-keys-fight (evidence, effects ratchet, frames ratchet) =="
	@$(MAKE) --no-print-directory gp-replay scenario=gp-keys-fight GP_OPTIONAL=1
	@$(PYTHON) tools/gp_keys.py evidence --capture $(K11_CAPTURES)/gp-keys-fight \
		--capture-sha256 "$(GP_KEYS_CAPTURE_SHA256)"
	@$(PYTHON) tools/gp_keys.py effects --capture $(K11_CAPTURES)/gp-keys-fight --port $(GP_DUMP)/gp-keys-fight \
		--min-effects "$(GP_KEYS_MIN_EFFECTS)" --capture-sha256 "$(GP_KEYS_CAPTURE_SHA256)"
	@$(PYTHON) tools/gp_compare.py --scenario gp-keys-fight --capture $(K11_CAPTURES)/gp-keys-fight \
		--port $(GP_DUMP)/gp-keys-fight --min-first "$(GP_KEYS_MIN_FIRST)" \
		--trace-min-first "$(GP_KEYS_TRACE_MIN_FIRST)" --max-start "$(GP_KEYS_MAX_START)" \
		--min-reach "$(GP_KEYS_MIN_REACH)" --max-missing "$(GP_KEYS_MAX_MISSING)" \
		--capture-sha256 "$(GP_KEYS_CAPTURE_SHA256)" --capture-frames "$(GP_KEYS_CAPTURE_FRAMES)"

# Gameplay U7 oracle (plan docs/superpowers/plans/2026-10-01-gameplay-u7-two-players.md, record
# docs/superpowers/plans/2026-10-01-gameplay-u7-derivations.md): data/k11-captures/gp-twop, LEFT
# PLAYER ARCADE with P2 joining in the character select and both sides pressing keys in a short
# fight. The tool tests always run; with the capture present it must first be a two-human match
# (tools/gp_twop.py check, record §T.1.6), then the frame, trace and moves ratchets as gp-moves-oracle's. Skips
# without the capture, even under PR_ORACLE_REQUIRED (spec §4.3). An empty pin with the capture
# present FAILS (gp_compare: "not pinned"). Pinned by U7 Task 6 (record §T.10) and the final review
# (the moves claim, below); all three ratchets are exact pins at the end of the port's script.
# MIN_FIRST: measured at 597c78c (record §T.10): first unexplained capture frame 612 (raw 3223),
# nearest port 463 = f=0x5E1, the port's last frame. 612 is how far the port got, not a divergence:
# the port's script ends at the capture's X record (f=0x5E1) and capture 612..679 are the capture's
# STOP_AT_END tail (the 60 game frames after X; the report lists the first 5) that the port never
# ran, like gp-u5-charsel's 516. Capture
# 83..611 are all explained (the START MENU, the P2 join, both cursors and confirms, the wipes, the
# versus screen, the fight to X). Raise it only if the port's script is lengthened.
# >>> PIN-BASELINE-BEGIN gp-twop
GP_TWOP_MIN_FIRST = 612
# TRACE_MIN_FIRST: no traced difference over the whole replay (gp_compare: "0 differing through
# 1505", f = 0x134..0x5E1; 8 f without a capture snapshot are not compared). N = end + 1 = 1506 is
# the exact pin (an N above the end fails as unreachable), so a traced difference at any compared
# f fails it. No run-to-run bound (one capture only, Decision 3; record §G.19).
GP_TWOP_TRACE_MIN_FIRST = 1506
# MOVES_MIN_FIRST: the moves claim (gp_session.MOVE_FIELDS: c0 c1 r0 r1 s0_43, record gameplay-u6
# §U6.11) has no differing frame either: gp_compare prints "moves: 0 differing through 1505"
# (f = 0x134..0x5E1; 8 f without a snapshot not compared). N = end + 1 = 1506, the exact pin as
# TRACE_MIN_FIRST (1507 fails as unreachable), like gp-moves-oracle's. Pinned by the final review
# (record §T.10): the plan's "reported, not pinned" predates U6b's oracle.
GP_TWOP_MOVES_MIN_FIRST = 1506
# MAX_START: the window begins at capture frame 83 (raw 1742), the first capture frame that shows
# the port's first frame (gp_compare: "window from capture 83"); it must be < MIN_FIRST.
GP_TWOP_MAX_START = 83
# The capture the values belong to (record §T.8): the poll.log sha256 and the frame count of
# data/k11-captures/gp-twop; another capture FAILS until the four values are re-measured.
GP_TWOP_CAPTURE_SHA256 = 9c01a73bfb04be19f794316e80b784a86b782b65f06592f2091d1544edf2e22c
GP_TWOP_CAPTURE_FRAMES = 680
# MIN_REACH (sub-project A §A1): the highest port frame any classified capture frame
# exhibits ("coverage ... up to port 463"), the replay's last presented frame (the script
# ends at the capture's X record, f=0x5E1). Measured 2026-10-16 on data/k11-captures/gp-twop
# via gp_compare --report. Raise it when gp_compare prints "improved: raise the pin".
GP_TWOP_MIN_REACH = 463
# END: empty = the replay runs the whole port script (to X, f=0x5E1; record §T.9); a value cuts it
# (gp_session.py port-script --end), for a replay that stalls.
GP_TWOP_END =
# <<< PIN-BASELINE-END gp-twop
gp-twop-oracle: build ## Gameplay U7 oracle: two-human check, then frame, trace and moves ratchets on data/k11-captures/gp-twop (skips without it)
	@echo "== gameplay oracle: gp-twop (two humans; frame, trace and moves ratchets; record U7) =="
	$(PYTHON) -m unittest tools.tests.test_gp_twop
	@if [ -d $(K11_CAPTURES)/gp-twop ]; then $(PYTHON) tools/gp_twop.py check --capture $(K11_CAPTURES)/gp-twop; \
		else echo "gp-twop-oracle: no capture at $(K11_CAPTURES)/gp-twop (skipped)"; fi
	@$(MAKE) --no-print-directory gp-replay scenario=gp-twop GP_OPTIONAL=1 GP_SCRIPT_ARGS="$(if $(GP_TWOP_END),--end $(GP_TWOP_END))"
	@$(PYTHON) tools/gp_compare.py --scenario gp-twop --capture $(K11_CAPTURES)/gp-twop \
		--port $(GP_DUMP)/gp-twop --min-first "$(GP_TWOP_MIN_FIRST)" \
		--trace-min-first "$(GP_TWOP_TRACE_MIN_FIRST)" --max-start "$(GP_TWOP_MAX_START)" \
		--moves-min-first "$(GP_TWOP_MOVES_MIN_FIRST)" \
		--min-reach "$(GP_TWOP_MIN_REACH)" --max-missing "$(GP_TWOP_MAX_MISSING)" \
		--capture-sha256 "$(GP_TWOP_CAPTURE_SHA256)" --capture-frames "$(GP_TWOP_CAPTURE_FRAMES)"

# Live truth Task 3 (record §L3): gp-twop-ko's hand-dealt two-human KO, with no
# KO/death pokes. The replay reaches the capture's X record (f=0xC50), beyond
# the KO/round-result record (f=0xB63) and the result window. The oracle asserts
# the KO and round result; it does not claim a fought match win or that the
# result naming is a fought win. U14's fought live win stays a gap.
# >>> PIN-BASELINE-BEGIN gp-twop-ko
# First unexplained capture 2525 (raw 5184), the STOP_AT_END tail after the
# shared script end; captures 76..2524 are explained, with the four named
# scan-out recipes in gp_compare. The KO window is below this pin.
GP_TWOP_KO_MIN_FIRST = 2525
# Trace and moves have no differing frame through f=0xC50 (3152); 22 frames
# without a capture snapshot are not compared. N=end+1 is the exact pin.
GP_TWOP_KO_TRACE_MIN_FIRST = 3153
GP_TWOP_KO_MOVES_MIN_FIRST = 3153
# The window starts at capture 76 (raw 1727), the first exhibition of port 0.
GP_TWOP_KO_MAX_START = 76
GP_TWOP_KO_CAPTURE_SHA256 = 15705a3f95c9c9333abcc8ca3c751a2fb0d5525b1556132bdee740eb2807a0ee
GP_TWOP_KO_CAPTURE_FRAMES = 2565
# The highest exhibited port frame is index 2103, the last presented frame
# (f=0xC4F); f=0xC50 is the trace/X record.
GP_TWOP_KO_MIN_REACH = 2103
# <<< PIN-BASELINE-END gp-twop-ko
gp-twop-ko-oracle: build ## Live truth Task 3 oracle: the no-poke two-human KO window on gp-twop-ko (skips without its capture)
	@echo "== gameplay oracle: gp-twop-ko (hand-dealt two-human KO; frame, trace and moves ratchets; record L3) =="
	$(PYTHON) -m unittest tools.tests.test_gp_twop
	@if [ -d $(K11_CAPTURES)/gp-twop-ko ]; then $(PYTHON) tools/gp_twop.py check --ko --capture $(K11_CAPTURES)/gp-twop-ko; \
		else echo "gp-twop-ko-oracle: no capture at $(K11_CAPTURES)/gp-twop-ko (skipped)"; fi
	@$(MAKE) --no-print-directory gp-replay scenario=gp-twop-ko GP_OPTIONAL=1
	@$(PYTHON) tools/gp_compare.py --scenario gp-twop-ko --capture $(K11_CAPTURES)/gp-twop-ko \
		--port $(GP_DUMP)/gp-twop-ko --min-first "$(GP_TWOP_KO_MIN_FIRST)" \
		--trace-min-first "$(GP_TWOP_KO_TRACE_MIN_FIRST)" --max-start "$(GP_TWOP_KO_MAX_START)" \
		--moves-min-first "$(GP_TWOP_KO_MOVES_MIN_FIRST)" \
		--min-reach "$(GP_TWOP_KO_MIN_REACH)" --max-missing "$(GP_TWOP_KO_MAX_MISSING)" \
		--capture-sha256 "$(GP_TWOP_KO_CAPTURE_SHA256)" --capture-frames "$(GP_TWOP_KO_CAPTURE_FRAMES)"

# Gameplay U8 (plan 2026-10-01-gameplay-u8-other-modes.md, record 2026-10-01-gameplay-u8-
# derivations.md): the other START MENU rows and the attract start, one capture each. For every
# <ID>:<scenario> in GP_MODES_SCENARIOS whose data/k11-captures/<scenario> exists: the evidence
# check that the capture reached its row (tools/gp_modes.py), the port replay (cut at
# GP_MODES_<ID>_END when set) and both ratchets with the capture identity pin; an absent capture
# skips (exit 0), even under PR_ORACLE_REQUIRED (spec §4.3). A scenario is listed only once its
# values are pinned (record §U8.16-§U8.22), each value's provenance in its comment. The port dump
# is removed after its comparison unless GP_MODES_KEEP=1 (record §U8.6: 52-89 MB of /tmp each).
# gp-u8-right-arcade (record §U8.16; re-measured by track P batch 2, record 2026-10-02-reverse-p2
# §P2.4, once 0x14EF8/0x14F50 and their streams' 0xD000 targets 0x14FA8/0x14FF8/0x150AC are ported):
# measured at 835db45 (rebased onto b16922d as dfd8367) (+ its miss set) on the capture below.
# MIN_FIRST: first unexplained capture frame 1072 (raw 4116): capture 1071 equals port 825 (f=0x8E1,
# the port's last frame, 0 px), so 1072 is the next game frame, which the port never ran (its script
# ends at the capture's X record): how far the port got, not a divergence (gp_compare's row-hash
# "nearest port 824"). TRACE_MIN_FIRST: "0 differing through 2273", end + 1 = 2274, the exact pin.
# Before P2 (U8, at bc51fd0) both stopped at the reaction 0x20 of f=0x7B7 (N 726, F 1978).
# MAX_START: the window starts at capture frame 88 (raw 1745). Raise N/F when they improve.
# >>> PIN-BASELINE-BEGIN gp-modes
GP_MODES_RA_MIN_FIRST = 1072
GP_MODES_RA_TRACE_MIN_FIRST = 2274
GP_MODES_RA_MAX_START = 88
GP_MODES_RA_CAPTURE_SHA256 = b72dbaa7486b651bd1acfddffe886aeb515eed3f916220d44375f0f67227906f
GP_MODES_RA_CAPTURE_FRAMES = 1140
# MIN_REACH (sub-project A §A1): the replay's last exhibited port frame ("coverage ... up
# to port 825", script ends at the capture's X record). Measured 2026-10-16 on
# data/k11-captures/gp-u8-right-arcade via gp_compare --report. Raise when it improves.
GP_MODES_RA_MIN_REACH = 825
# gp-u8-left-training (record §U8.17): measured at b7f13c6 (+ its miss set) on the capture below.
# MIN_FIRST: first unexplained capture frame 1076 (raw 4207), nearest port 847 = f=0x921, the
# port's last frame (the script ends at the capture's X record): how far the port got, not a
# divergence; capture 1076.. is the STOP_AT_END tail. TRACE_MIN_FIRST: no traced difference
# ("0 differing through 2337"), so end + 1 = 2338 is the exact pin (2339 fails as unreachable);
# MAX_START: the window starts at capture frame 83 (raw 1735). Raise N/F when they improve.
GP_MODES_LT_MIN_FIRST = 1076
GP_MODES_LT_TRACE_MIN_FIRST = 2338
GP_MODES_LT_MAX_START = 83
GP_MODES_LT_CAPTURE_SHA256 = 90eeef83f77dab25d9ed7be30fcadf278bfd0413c068c12bd700ff183ab09917
GP_MODES_LT_CAPTURE_FRAMES = 1141
# MIN_REACH (sub-project A §A1): "coverage ... up to port 847", the replay's last exhibited
# frame (script ends at the capture's X record). Measured 2026-10-16 on
# data/k11-captures/gp-u8-left-training via gp_compare --report. Raise when it improves.
GP_MODES_LT_MIN_REACH = 847
# gp-u8-right-training (record §U8.18): measured at 9170f5c (+ its miss set) on the capture below.
# MIN_FIRST: first unexplained capture frame 1098 (raw 4272): capture 1097 equals port 845 (f=0x961,
# the port's last frame, 0 px), so 1098 is the next game frame (f=0x962), which the port never ran
# (its script ends at the capture's X record): how far the port got, not a divergence (gp_compare's
# row-hash "nearest port 844"). TRACE_MIN_FIRST: "0 differing through 2401", end + 1 = 2402, the
# exact pin; MAX_START: the window starts at capture frame 90 (raw 1745). Raise N/F when they improve.
GP_MODES_RT_MIN_FIRST = 1098
GP_MODES_RT_TRACE_MIN_FIRST = 2402
GP_MODES_RT_MAX_START = 90
GP_MODES_RT_CAPTURE_SHA256 = 496964928c537f3b428414e21d02f254b399af4b2f39b393d10d9855c74f1ea5
GP_MODES_RT_CAPTURE_FRAMES = 1167
# MIN_REACH (sub-project A §A1): "coverage ... up to port 845", the replay's last exhibited
# frame (script ends at the capture's X record). Measured 2026-10-16 on
# data/k11-captures/gp-u8-right-training via gp_compare --report. Raise when it improves.
GP_MODES_RT_MIN_REACH = 845
# gp-u8-tug-of-war (record §U8.19): measured at c212a83 (+ its miss set) on the capture below.
# MIN_FIRST: first unexplained capture frame 1107 (raw 4343): capture 1106 equals port 826 (f=0x9A1,
# the port's last frame, 0 px), so 1107 is the next game frame, which the port never ran (its script
# ends at the capture's X record): how far the port got, not a divergence (gp_compare's row-hash
# "nearest port 825"). TRACE_MIN_FIRST: "0 differing through 2465", end + 1 = 2466, the exact pin;
# MAX_START: the window starts at capture frame 104 (raw 1741). Raise N/F when they improve.
GP_MODES_TW_MIN_FIRST = 1107
GP_MODES_TW_TRACE_MIN_FIRST = 2466
GP_MODES_TW_MAX_START = 104
GP_MODES_TW_CAPTURE_SHA256 = 30cd09b8d24a51a41cc37c0ffebd433a08e5a741b248f28e8112c28ff04d1439
GP_MODES_TW_CAPTURE_FRAMES = 1176
# MIN_REACH (sub-project A §A1): "coverage ... up to port 826", the replay's last exhibited
# frame (script ends at the capture's X record). Measured 2026-10-16 on
# data/k11-captures/gp-u8-tug-of-war via gp_compare --report. Raise when it improves.
GP_MODES_TW_MIN_REACH = 826
# gp-u8-handicap (record §U8.20): measured at 1380841 (+ its miss set) on the capture below.
# MIN_FIRST: first unexplained capture frame 1022 (raw 4155): capture 1021 equals port 789 (f=0x8E1,
# the port's last frame, 0 px), so 1022 is the next game frame, which the port never ran (its script
# ends at the capture's X record): how far the port got, not a divergence (gp_compare's row-hash
# "nearest port 652"). TRACE_MIN_FIRST: "0 differing through 2273", end + 1 = 2274, the exact pin;
# MAX_START: the window starts at capture frame 80 (raw 1739). Raise N/F when they improve.
GP_MODES_HC_MIN_FIRST = 1022
GP_MODES_HC_TRACE_MIN_FIRST = 2274
GP_MODES_HC_MAX_START = 80
GP_MODES_HC_CAPTURE_SHA256 = 8f35fd3abd3a5c527e0f72984ed8ba419cadbcefa4e53b349d5a1ed7b69cdfa3
GP_MODES_HC_CAPTURE_FRAMES = 1081
# MIN_REACH (sub-project A §A1): "coverage ... up to port 789", the replay's last exhibited
# frame (script ends at the capture's X record). Measured 2026-10-16 on
# data/k11-captures/gp-u8-handicap via gp_compare --report. Raise when it improves.
GP_MODES_HC_MIN_REACH = 789
# gp-u8-endurance (record §U8.21): measured at 20c379e (+ its miss set) on the capture below. The
# scenario ends 300 frames into the team select 0x44798 (decision D3: idle, ENDURANCE never leaves
# mode 0x10; its fight is a named gap, record §U8.9), so N is where that team-select tail ends.
# MIN_FIRST: first unexplained capture frame 278 (raw 2759): capture 277 equals port 163 (f=0x494,
# the port's last presented frame; the team select presents every third f), so 278 shows the next
# present, which the port never ran (its script ends at the capture's X record, f=0x495): how far the
# port got, not a divergence. TRACE_MIN_FIRST: "0 differing through 1173", end + 1 = 1174, the exact
# pin; MAX_START: the window starts at capture frame 90 (raw 1744). Raise N/F when they improve.
GP_MODES_EN_MIN_FIRST = 278
GP_MODES_EN_TRACE_MIN_FIRST = 1174
GP_MODES_EN_MAX_START = 90
GP_MODES_EN_CAPTURE_SHA256 = 01c9069076ce231d652dbe1d97f0a80155d5e73bef932b0254816a79ec746204
GP_MODES_EN_CAPTURE_FRAMES = 308
# MIN_REACH (sub-project A §A1): "coverage ... up to port 163", the replay's last exhibited
# frame (script ends at the capture's X record, f=0x495; the team select presents every
# third f). Measured 2026-10-16 on data/k11-captures/gp-u8-endurance via gp_compare
# --report. Raise when it improves.
GP_MODES_EN_MIN_REACH = 163
# gp-u8-attract-start (record §U8.22): measured at 2f0eeed (+ its miss set) on the capture below
# (the pad arm: P1's F1 in mode 3, arm frame f=0x125, mode 0x1A at f=0x126). MIN_FIRST: first
# unexplained capture frame 1087 (raw 3854): capture 1086 equals port 823 (f=0x7E1, the port's last
# frame, 0 px), so 1087 is the next game frame, which the port never ran (its script ends at the
# capture's X record): how far the port got, not a divergence (gp_compare's row-hash "nearest port
# 822"). TRACE_MIN_FIRST: "0 differing through 2017", end + 1 = 2018, the exact pin; MAX_START: the
# window starts at capture frame 80 (raw 1742). Raise N/F when they improve.
GP_MODES_AS_MIN_FIRST = 1087
GP_MODES_AS_TRACE_MIN_FIRST = 2018
GP_MODES_AS_MAX_START = 80
GP_MODES_AS_CAPTURE_SHA256 = d6b0cf6b210992de4e7cda211f02cbe5653e21a22d3fbec3dd02e7e30e0351fd
GP_MODES_AS_CAPTURE_FRAMES = 1156
# MIN_REACH (sub-project A §A1): "coverage ... up to port 823", the replay's last exhibited
# frame (script ends at the capture's X record). Measured 2026-10-16 on
# data/k11-captures/gp-u8-attract-start via gp_compare --report. Raise when it improves.
GP_MODES_AS_MIN_REACH = 823
# <<< PIN-BASELINE-END gp-modes
GP_MODES_SCENARIOS =
GP_MODES_SCENARIOS += RA:gp-u8-right-arcade
GP_MODES_SCENARIOS += LT:gp-u8-left-training
GP_MODES_SCENARIOS += RT:gp-u8-right-training
GP_MODES_SCENARIOS += TW:gp-u8-tug-of-war
GP_MODES_SCENARIOS += HC:gp-u8-handicap
GP_MODES_SCENARIOS += EN:gp-u8-endurance
GP_MODES_SCENARIOS += AS:gp-u8-attract-start
GP_MODES_KEEP ?=
gp-modes-oracle: build ## Gameplay U8 oracle: the other START MENU rows and the attract start (each skips without its capture)
	@echo "== gameplay U8: other modes (frame and trace ratchets; each skips without its capture) =="
	@$(PYTHON) -m unittest tools.tests.test_gp_modes
	@for p in $(GP_MODES_SCENARIOS); do \
		$(MAKE) --no-print-directory gp-modes-one GP_MODES_ID=$${p%%:*} scenario=$${p#*:} || exit 1; \
	done

gp-modes-one: build ## Run one U8 mode capture/replay (used by gp-modes-oracle)
	@if [ -d $(K11_CAPTURES)/$(scenario) ]; then \
		$(PYTHON) tools/gp_modes.py check --scenario $(scenario) --capture $(K11_CAPTURES)/$(scenario) && \
		$(MAKE) --no-print-directory gp-replay scenario=$(scenario) GP_OPTIONAL=1 \
			GP_SCRIPT_ARGS="$(if $(GP_MODES_$(GP_MODES_ID)_END),--end $(GP_MODES_$(GP_MODES_ID)_END))" && \
		$(PYTHON) tools/gp_compare.py --scenario $(scenario) --capture $(K11_CAPTURES)/$(scenario) \
			--port $(GP_DUMP)/$(scenario) --min-first "$(GP_MODES_$(GP_MODES_ID)_MIN_FIRST)" \
			--trace-min-first "$(GP_MODES_$(GP_MODES_ID)_TRACE_MIN_FIRST)" \
			--max-start "$(GP_MODES_$(GP_MODES_ID)_MAX_START)" \
			--min-reach "$(GP_MODES_$(GP_MODES_ID)_MIN_REACH)" \
			--max-missing "$(GP_MODES_$(GP_MODES_ID)_MAX_MISSING)" \
			--capture-sha256 "$(GP_MODES_$(GP_MODES_ID)_CAPTURE_SHA256)" \
			--capture-frames "$(GP_MODES_$(GP_MODES_ID)_CAPTURE_FRAMES)"; \
		rc=$$?; [ -n "$(GP_MODES_KEEP)" ] || rm -rf $(GP_DUMP)/$(scenario); exit $$rc; \
	else \
		echo "gp-modes-oracle: no capture at $(K11_CAPTURES)/$(scenario), skipped"; \
	fi

# Gameplay truth II U12 (plan docs/superpowers/plans/2026-10-20-gameplay-truth-ii.md Task 4;
# record §J4): gp-u12-endurance, the ENDURANCE team pass 0x44798's eight picks and the fight
# they open (modes 0x30/0x31), the named gap record 2026-10-01-gameplay-u8-derivations.md
# §U8.9 leaves past gp-u8-endurance's 300-frame team-select cut (D3; that capture and its
# GP_MODES_EN_* pins stay as they are). Measured on data/k11-captures/gp-u12-endurance (evidence
# closeout Task 6; an absent capture skips the target, gp-replay's GP_OPTIONAL=1 and gp_compare's
# own skip, while a present capture with an empty pin FAILS: "not pinned").
# MIN_FIRST = 732 is the first unexplained capture frame: the replay's script ends at its X record
# (f=0x78A, port frame 490, the last of 491 dumped) and the capture runs 60 game frames on, so
# capture 732..771 (40 frames) are the post-script tail, how far the port got, not a divergence;
# every capture frame of the window below it is explained (400 clean, 222 splice, 10 all-black),
# through the whole mode-0x30/0x31 span (0x31 from f=0x6D9, port frame 418). TRACE_MIN_FIRST = 1931
# is the exact pin (`0 differing through 1930`, the X record's f), MAX_START the window's first
# capture frame, MIN_REACH the replay's last exhibited port frame (the report's "port 490"), the
# capture identity the poll.log sha256 and the frame_*.raw.gz count. Raise MIN_FIRST/MIN_REACH
# when the script is extended.
# >>> PIN-BASELINE-BEGIN gp-u12
GP_U12_MIN_FIRST = 732
GP_U12_TRACE_MIN_FIRST = 1931
GP_U12_MAX_START = 100
GP_U12_MIN_REACH = 490
GP_U12_CAPTURE_SHA256 = 2edcc088d26162d0fd288c8a5caf17500ac4b7f4a974f57b6f780be0236a9014
GP_U12_CAPTURE_FRAMES = 772
# <<< PIN-BASELINE-END gp-u12
gp-u12-oracle: build ## Gameplay U12 oracle: gp-u12-endurance's team pass and fight (skips without data/k11-captures/gp-u12-endurance)
	@echo "== gameplay oracle: gp-u12-endurance (team pass and modes 0x30/0x31) =="
	@$(PYTHON) -m unittest tools.tests.test_gp_modes
	@if [ ! -d $(K11_CAPTURES)/gp-u12-endurance ]; then echo "gp-u12-oracle: no capture at $(K11_CAPTURES)/gp-u12-endurance (skipped)"; else \
		$(PYTHON) tools/gp_modes.py check --scenario gp-u12-endurance --capture $(K11_CAPTURES)/gp-u12-endurance && \
		$(MAKE) --no-print-directory gp-replay scenario=gp-u12-endurance GP_OPTIONAL=1 && \
		$(PYTHON) tools/gp_compare.py --scenario gp-u12-endurance --capture $(K11_CAPTURES)/gp-u12-endurance \
		--port $(GP_DUMP)/gp-u12-endurance --min-first "$(GP_U12_MIN_FIRST)" \
		--trace-min-first "$(GP_U12_TRACE_MIN_FIRST)" --max-start "$(GP_U12_MAX_START)" \
		--min-reach "$(GP_U12_MIN_REACH)" --max-missing "$(GP_U12_MAX_MISSING)" \
		--capture-sha256 "$(GP_U12_CAPTURE_SHA256)" --capture-frames "$(GP_U12_CAPTURE_FRAMES)"; \
	fi

# State coverage Task 5 (plan docs/superpowers/plans/2026-10-28-state-coverage.md §G3, record §D5):
# gp-u25-endurance-end, the ENDURANCE match end. gp-u12-endurance's team pass and fight,
# continued with four poked KOs of P2 (side 1) and the mode-0x33 600-frame wait into the
# results screen (mode 0x17, the hook 0x25AE8). The loser is side 1, so the counter that
# reaches 4 is af1 (DS_00104AF1), not af0; c1 walks side 1's roster 04 -> 06 -> 01 -> 03.
# memsize=64 is the §W.13 harness value (the original OOMs at 16 MB on the fourth KO cycle).
# Measured on data/k11-captures/gp-u25-endurance-end (an absent capture skips the target).
# MIN_FIRST = 598 is the first unexplained capture frame (nearest port 440, rows 4..83), the
# enforced compare's stop; MAX_START = 92 the window's first capture frame; MIN_REACH = 437 the
# highest port frame a classified capture frame exhibits before 598; MAX_MISSING = 12 the
# canonical non-black missing classes up to that reach. TRACE_MIN_FIRST/MOVES_MIN_FIRST = 2415
# (0 differing through 2414, the replay's end f=0x96E), the MILESTONES ratchet 7 (gp_endurance's
# seven rows all reproduced at the capture's frame). Raise MIN_FIRST/MIN_REACH when they improve.
# >>> PIN-BASELINE-BEGIN gp-u25
GP_U25_MIN_FIRST = 598
GP_U25_TRACE_MIN_FIRST = 2415
GP_U25_MOVES_MIN_FIRST = 2415
GP_U25_MAX_START = 92
GP_U25_MIN_REACH = 437
GP_U25_CAPTURE_SHA256 = 21fc6a739e22a71b659f746fd1d7ed6bcdca66d8348556283785b70e52808062
GP_U25_CAPTURE_FRAMES = 1360
GP_U25_MILESTONES = 7
# <<< PIN-BASELINE-END gp-u25
gp-u25-oracle: build ## State coverage U25 oracle: gp-u25-endurance-end's four-KO roster walk and match end (skips without data/k11-captures/gp-u25-endurance-end)
	@echo "== gameplay oracle: gp-u25-endurance-end (the ENDURANCE match end; modes 0x32/0x33) =="
	@$(PYTHON) -m unittest tools.tests.test_gp_endurance
	@if [ ! -d $(K11_CAPTURES)/gp-u25-endurance-end ]; then echo "gp-u25-oracle: no capture at $(K11_CAPTURES)/gp-u25-endurance-end (skipped)"; else \
		$(PYTHON) tools/gp_endurance.py check --scenario gp-u25-endurance-end --capture $(K11_CAPTURES)/gp-u25-endurance-end \
			--capture-sha256 "$(GP_U25_CAPTURE_SHA256)" && \
		$(MAKE) --no-print-directory gp-replay scenario=gp-u25-endurance-end GP_OPTIONAL=1 && \
		$(PYTHON) tools/gp_compare.py --scenario gp-u25-endurance-end --capture $(K11_CAPTURES)/gp-u25-endurance-end \
		--port $(GP_DUMP)/gp-u25-endurance-end --min-first "$(GP_U25_MIN_FIRST)" \
		--trace-min-first "$(GP_U25_TRACE_MIN_FIRST)" --moves-min-first "$(GP_U25_MOVES_MIN_FIRST)" \
		--max-start "$(GP_U25_MAX_START)" --min-reach "$(GP_U25_MIN_REACH)" --max-missing "$(GP_U25_MAX_MISSING)" \
		--capture-sha256 "$(GP_U25_CAPTURE_SHA256)" --capture-frames "$(GP_U25_CAPTURE_FRAMES)" && \
		$(PYTHON) tools/gp_endurance.py path --scenario gp-u25-endurance-end --capture $(K11_CAPTURES)/gp-u25-endurance-end \
			--port $(GP_DUMP)/gp-u25-endurance-end --min-milestones "$(GP_U25_MILESTONES)" \
			--capture-sha256 "$(GP_U25_CAPTURE_SHA256)"; \
		rc=$$?; [ -n "$(GP_WIN_KEEP)" ] || rm -rf $(GP_DUMP)/gp-u25-endurance-end; exit $$rc; fi

# State coverage Task 6 (plan docs/superpowers/plans/2026-10-28-state-coverage.md Task 6): the side-1
# tour and ending. gp-u8-right-arcade's START MENU row-1 route (p1.down, RIGHT PLAYER ARCADE,
# mode 0x2E -> game_coin_divert(2) -> b1f=2) into the character select, where P2 confirms its
# default cell 5 (ARMADON, class 4) with p2.start; then U10's body with the KOs reversed (KO_P1
# pokes P1's +0x5A, so P2 wins and the final's seven opponents are P1-KO'd). The ending is the
# mode-0x1F handler 0x208F8 reading DS_0010782A[DS_00104AD4 * 0x94] = c1 = 4 (ARMADON),
# side-independent by the 0x208F8 differential row. The cross-check (tools/gp_side1.py, wired
# into the oracle) is byte-identity of the mode-0x1F rendered RGB with gp-u21-armadon-ending's
# replay: both replays produce 268 mode-0x1F frames (the pin below) and all 268 are byte-identical
# in rendered RGB (concat sha256 97568493..). The first frame's raw index encoding differs in
# 55 021 bytes (a state-0 reset transient: that frame's DAC maps all 256 entries to one colour, so
# every differing index renders identically) and the DACs are identical -- a measured, not fitted,
# reading, and the rendered-content claim is unaffected. gp_side1.py fails on any rendered-pixel
# difference and on an empty/wrong count (--expect-count), so a side-independence regression or a
# truncated mode-0x1F window fails instead of passing.
# Measured on the 2026-10-29 capture (5,814 frames, raw 1386..14296, wall 204.5 s, memsize=64).
# MIN_FIRST = 2358 is the first unexplained capture frame (raw 5568, f=0xDA0 mode 0x1B): the
# versus -> REPLENISH transition's scan-out, a 3-present tear (rows 0..58 = port 1943, rows
# 59..190 a 1944/1945 splice, rows 191..199 = 1944) that no BANDS/SCAN3 recipe expresses (its
# band is clean 1944/1945 content, and its SCAN3 pair is non-unique because the mode-0x23 screen
# is static); capture 4135 (raw 7660, f=0x147F mode 0x0C, the final's entry) is the same class
# (a double tear: rows 7..19 = port 3444..3446, row 193 = port 3447, the rest 3448). Both are
# recorded as named gaps in the task report (every pixel is a port frame within +-4, so they are
# scan-outs, not divergences); nothing past 2358 is claimed by the frame ratchet, so the
# mode-0x1F frames (capture 5073..5375) are claimed by the cross-check and the trace/moves
# ratchets instead. The window starts at capture 86 (raw 1749); because the frame ratchet stops
# at 2358, the window reach is port 1943 (not the dump's end) with 11 non-black missing classes
# (the unexhibited port-frame canonical classes up to reach 1943, title_compare.canonical_missing),
# one per line, measured on the replay's 4 897-frame dump (port index, f, mode, rendered-RGB sha256):
#   10 f=024C mode=001A 3ef11f7c110f6c54e9684717b3040ee473233975650a7ec788e3e304d540374c
#   12 f=024C mode=001A 386be86694a89a74c5e9d524ab3ad6d992f1612db745e0cd419f8436599714ca
#   132 f=03D5 mode=001A 4918d7e577ec1cfb64ff8a96981c5a2157b33919cf3e5c3bf4956198aa7959c7
#   219 f=046D mode=0008 8fb7339e90ae4f1180e8000871a8605d3e7e9f58355c994591cd883db09eabe6
#   220 f=046E mode=0008 e2e4a6d7e2e485c4f0786f892d727f6027e59436d7532fce7eac8eebdb9ae141
#   818 f=06C5 mode=0016 d227ab6f5b21d7650b47cf8cbe76404cec6b14dcdb09f96ad9995144203ae1ba
#   1026 f=07B5 mode=0005 3ab7c2e56603268d4276c73df923d28d87a7ea155785f4c4678cde3e3a97f470
#   1049 f=07F3 mode=0005 02899bf8d813d5c29a93e58b563c06616fa6aa9265668558a2c16b5c49c95d4b
#   1080 f=0830 mode=0006 557c5723ffe559a41da2f3a7985a76e7dcab185bcfbd6360b60201cf68aec8ff
#   1091 f=083B mode=0009 f8e46ad30c40a6a8d0cf5c84760540b786c7a04f14504d0c8c6498c3c3227a23
#   1925 f=0D93 mode=001B ed343e30f214405dd534b196735d53a863accc5662ffb50d5b9878ef57de906e
# the post-script tail (capture 5801..5803, at/past the X record f=0x26B0) is not claimed.
# Trace, moves and WIN fields agree through f=0x26B0 (9904), so their end pins are 9905;
# MILESTONES 30 (all reproduced at the capture's frame).
# An absent capture skips (gp-replay's GP_OPTIONAL=1, gp_win.py's no-poll.log skip); a present
# capture with empty/changed pins fails. Raise each pin when it improves.
# >>> PIN-BASELINE-BEGIN gp-u26
GP_U26_MIN_FIRST = 2358
GP_U26_TRACE_MIN_FIRST = 9905
GP_U26_MOVES_MIN_FIRST = 9905
GP_U26_MAX_START = 86
GP_U26_MIN_REACH = 1943
GP_U26_CAPTURE_SHA256 = 19b00d8d7721c0b927f35a5c816e1adcd062b74fd56160283e5ca5d87f9bddea
GP_U26_CAPTURE_FRAMES = 5814
GP_U26_MILESTONES = 30
GP_U26_WIN_MIN_FIRST = 9905
GP_U26_MODE1F_COUNT = 268
# <<< PIN-BASELINE-END gp-u26
gp-u26-oracle: build ## State coverage U26 oracle: the side-1 (RIGHT PLAYER ARCADE) tour and ending (skips without data/k11-captures/gp-u26-side1-ending)
	@echo "== gameplay oracle: gp-u26-side1-ending (the side-1 tour and ARMADON ending under pokes) =="
	@$(PYTHON) -m unittest tools.tests.test_gp_win
	@if [ ! -d $(K11_CAPTURES)/gp-u26-side1-ending ]; then echo "gp-u26-oracle: no capture at $(K11_CAPTURES)/gp-u26-side1-ending (skipped)"; else \
		$(PYTHON) tools/gp_win.py check --scenario gp-u26-side1-ending --capture $(K11_CAPTURES)/gp-u26-side1-ending \
			--capture-sha256 "$(GP_U26_CAPTURE_SHA256)" && \
		$(MAKE) --no-print-directory gp-replay scenario=gp-u26-side1-ending GP_OPTIONAL=1 && \
		$(PYTHON) tools/gp_compare.py --scenario gp-u26-side1-ending --capture $(K11_CAPTURES)/gp-u26-side1-ending \
			--port $(GP_DUMP)/gp-u26-side1-ending --min-first "$(GP_U26_MIN_FIRST)" \
			--trace-min-first "$(GP_U26_TRACE_MIN_FIRST)" --moves-min-first "$(GP_U26_MOVES_MIN_FIRST)" \
			--max-start "$(GP_U26_MAX_START)" --min-reach "$(GP_U26_MIN_REACH)" --max-missing "$(GP_U26_MAX_MISSING)" \
			--capture-sha256 "$(GP_U26_CAPTURE_SHA256)" --capture-frames "$(GP_U26_CAPTURE_FRAMES)" && \
		$(PYTHON) tools/gp_win.py path --scenario gp-u26-side1-ending --capture $(K11_CAPTURES)/gp-u26-side1-ending \
			--port $(GP_DUMP)/gp-u26-side1-ending --min-milestones "$(GP_U26_MILESTONES)" \
			--win-min-first "$(GP_U26_WIN_MIN_FIRST)" --capture-sha256 "$(GP_U26_CAPTURE_SHA256)" && \
		$(MAKE) --no-print-directory gp-replay scenario=gp-u21-armadon-ending GP_OPTIONAL=1 && \
		$(PYTHON) tools/gp_side1.py --side1 $(GP_DUMP)/gp-u26-side1-ending --p1 $(GP_DUMP)/gp-u21-armadon-ending \
			--expect-count "$(GP_U26_MODE1F_COUNT)"; \
		rc=$$?; [ -n "$(GP_WIN_KEEP)" ] || rm -rf $(GP_DUMP)/gp-u26-side1-ending $(GP_DUMP)/gp-u21-armadon-ending; exit $$rc; fi

# Live truth Task 7 (record 2026-10-29-live-truth-derivations.md §L7): the high-score
# name-entry path. gp_u27.py's capture check pins mode 0x1E's six sub-state records, the
# three letter counts and name-byte stages, the finaliser's space-padded "ABD ", the score
# and the return to mode 3; its path check reproduces the six b25 values (the port trace has
# no ncnt/cell0/name fields). Capture 334 is the mode-8 entry's byte-bound three-present
# scan-out in SCAN3_ALLOWED. The frame window covers the entry and finaliser before the
# post-script tail's first unexplained frame 5871; the 4991-frame replay reaches port 4990,
# with 38 canonical missing classes. Trace and moves agree through f=0x24B9 (9401), the
# replay's stop mode 3. An absent capture skips; a present capture with changed pins fails.
# >>> PIN-BASELINE-BEGIN gp-u27
GP_U27_MIN_FIRST = 5871
GP_U27_TRACE_MIN_FIRST = 9402
GP_U27_MOVES_MIN_FIRST = 9402
GP_U27_MAX_START = 81
GP_U27_MIN_REACH = 4990
GP_U27_CAPTURE_SHA256 = 5e2c5824ee009d814c1929663110c22b24d658e4b0ea0c440a4b3327eeb79d04
GP_U27_CAPTURE_FRAMES = 5885
# <<< PIN-BASELINE-END gp-u27
gp-u27-oracle: build ## Live truth U27 oracle: the high-score name-entry letters and finaliser (skips without data/k11-captures/gp-u27-name-entry)
	@echo "== gameplay oracle: gp-u27-name-entry (the name-entry letters and finaliser) =="
	@$(PYTHON) -m unittest tools.tests.test_gp_u27
	@if [ ! -d $(K11_CAPTURES)/gp-u27-name-entry ]; then echo "gp-u27-oracle: no capture at $(K11_CAPTURES)/gp-u27-name-entry (skipped)"; else \
		$(PYTHON) tools/gp_u27.py check --scenario gp-u27-name-entry --capture $(K11_CAPTURES)/gp-u27-name-entry \
			--capture-sha256 "$(GP_U27_CAPTURE_SHA256)" && \
		$(MAKE) --no-print-directory gp-replay scenario=gp-u27-name-entry GP_OPTIONAL=1 && \
		$(PYTHON) tools/gp_compare.py --scenario gp-u27-name-entry --capture $(K11_CAPTURES)/gp-u27-name-entry \
		--port $(GP_DUMP)/gp-u27-name-entry --min-first "$(GP_U27_MIN_FIRST)" \
		--trace-min-first "$(GP_U27_TRACE_MIN_FIRST)" --moves-min-first "$(GP_U27_MOVES_MIN_FIRST)" \
		--max-start "$(GP_U27_MAX_START)" --min-reach "$(GP_U27_MIN_REACH)" --max-missing "$(GP_U27_MAX_MISSING)" \
		--capture-sha256 "$(GP_U27_CAPTURE_SHA256)" --capture-frames "$(GP_U27_CAPTURE_FRAMES)" && \
		$(PYTHON) tools/gp_u27.py path --scenario gp-u27-name-entry --capture $(K11_CAPTURES)/gp-u27-name-entry \
			--port $(GP_DUMP)/gp-u27-name-entry --capture-sha256 "$(GP_U27_CAPTURE_SHA256)"; \
		rc=$$?; [ -n "$(GP_WIN_KEEP)" ] || rm -rf $(GP_DUMP)/gp-u27-name-entry; exit $$rc; fi

# Live truth Task 2 (record 2026-10-29-live-truth-derivations.md §L2): the KO-branch
# drawn match. Three poked KOs (four writes) leave round 3 at b1e=adc=3 and w2=w3=1,
# so 0x27BA4 stores ad4=2 and flow_round_over reaches modes 0x0A then 0x0B. The poked
# KOs are harness writes, not a fought-match claim. Capture 346 is the mode-8 entry's
# byte-bound two-segment scan-out in SCAN3_ALLOWED. N=2064 is the first unexplained
# capture frame, in the harness's STOP_TAIL=60 poll after the shared script end: the
# capture's X record and the replay's script both end at f=0xB62 (2914); f=0x0B9E is
# the later SIGTERM, and the replay has no frames for the 68 tail captures. The window
# covers both modes (port 1589 is
# mode 0x0A, port 1681 mode 0x0B; captures 2058..2063 exhibit ports 1681..1685), and
# reach 1685 is the dump's last frame. Trace and moves agree through 2914; 11 canonical
# non-black classes are missing through the reach. An absent capture skips; a present
# capture with changed pins fails. The claim is narrow like gp-draw-timeout's.
# >>> PIN-BASELINE-BEGIN gp-draw
GP_DRAW_MIN_FIRST = 2064
GP_DRAW_TRACE_MIN_FIRST = 2915
GP_DRAW_MOVES_MIN_FIRST = 2915
GP_DRAW_MAX_START = 100
GP_DRAW_MIN_REACH = 1685
GP_DRAW_CAPTURE_SHA256 = d99571d3db0bf2135ad10d3a0f08d5c5bbdebdea392fad2821e7ce0901299885
GP_DRAW_CAPTURE_FRAMES = 2132
# <<< PIN-BASELINE-END gp-draw
gp-draw-oracle: build ## Live truth Task 2 oracle: gp-draw's KO-branch drawn match (skips without data/k11-captures/gp-draw)
	@echo "== gameplay oracle: gp-draw (the KO-branch drawn match 0x0A/0x0B under poked KOs) =="
	@$(PYTHON) -m unittest tools.tests.test_gp_draw
	@if [ ! -d $(K11_CAPTURES)/gp-draw ]; then echo "gp-draw-oracle: no capture at $(K11_CAPTURES)/gp-draw (skipped)"; else \
		$(PYTHON) tools/gp_draw.py check --capture $(K11_CAPTURES)/gp-draw && \
		$(MAKE) --no-print-directory gp-replay scenario=gp-draw GP_OPTIONAL=1 && \
		$(PYTHON) tools/gp_compare.py --scenario gp-draw --capture $(K11_CAPTURES)/gp-draw \
			--port $(GP_DUMP)/gp-draw --min-first "$(GP_DRAW_MIN_FIRST)" \
			--trace-min-first "$(GP_DRAW_TRACE_MIN_FIRST)" --moves-min-first "$(GP_DRAW_MOVES_MIN_FIRST)" \
			--max-start "$(GP_DRAW_MAX_START)" --min-reach "$(GP_DRAW_MIN_REACH)" --max-missing "$(GP_DRAW_MAX_MISSING)" \
			--capture-sha256 "$(GP_DRAW_CAPTURE_SHA256)" --capture-frames "$(GP_DRAW_CAPTURE_FRAMES)"; \
		rc=$$?; [ -n "$(GP_WIN_KEEP)" ] || rm -rf $(GP_DUMP)/gp-draw; exit $$rc; fi

# State coverage Task 7 oracle (plan 2026-10-28-state-coverage §G5, record §D7): the
# timeout/health drawn round (0x27FA8's arm at 0x280BA). The capture gp-draw-timeout pokes
# two KOs (1-1) then round 3's clock 0x1088F2 = 0 and both +0x5A health bytes = 0x30 (equal,
# below the KO 0x78), so the arm re-reads |A-B| = 0 <= 3 and calls flow_round_over -> mode
# 0xA -> mode 0xB with AD4 = 0xFFFFFFFF (the 1-1 match is undecided; the KO draw's is 2).
# gp_draw.py check --timeout proves the predicate (ad4=0xFFFFFFFF, b1e=adc=3, |A-B|<=3, the
# clock poke, modes 0x0A then 0x0B) and its disjointness from the KO draw; gp_compare's
# ratchets prove the frames, trace and moves. Skips without the capture, even under
# PR_ORACLE_REQUIRED (spec §4.3). The claim is narrow like gp-oracle's: N and the reach are
# the first unexplained frame's measured values; nothing past them is claimed.
# >>> PIN-BASELINE-BEGIN gp-draw-timeout
GP_DRAW_TIMEOUT_MIN_FIRST = 346
GP_DRAW_TIMEOUT_TRACE_MIN_FIRST = 2915
GP_DRAW_TIMEOUT_MOVES_MIN_FIRST = 2915
GP_DRAW_TIMEOUT_MAX_START = 100
GP_DRAW_TIMEOUT_MIN_REACH = 218
GP_DRAW_TIMEOUT_CAPTURE_SHA256 = bb3e1b8ba888e4f9aed9728f4cbe94570c5f6493c6815aec57ef7e4613674290
GP_DRAW_TIMEOUT_CAPTURE_FRAMES = 2132
# <<< PIN-BASELINE-END gp-draw-timeout
gp-draw-timeout-oracle: build ## State coverage Task 7 oracle: gp-draw-timeout's timeout/health drawn round (skips without data/k11-captures/gp-draw-timeout)
	@echo "== gameplay oracle: gp-draw-timeout (the timeout/health drawn round 0x280BA; skips without its capture) =="
	@$(PYTHON) -m unittest tools.tests.test_gp_draw
	@if [ ! -d $(K11_CAPTURES)/gp-draw-timeout ]; then echo "gp-draw-timeout-oracle: no capture at $(K11_CAPTURES)/gp-draw-timeout (skipped)"; else \
		$(PYTHON) tools/gp_draw.py check --timeout --capture $(K11_CAPTURES)/gp-draw-timeout && \
		$(MAKE) --no-print-directory gp-replay scenario=gp-draw-timeout GP_OPTIONAL=1 && \
		$(PYTHON) tools/gp_compare.py --scenario gp-draw-timeout --capture $(K11_CAPTURES)/gp-draw-timeout \
			--port $(GP_DUMP)/gp-draw-timeout --min-first "$(GP_DRAW_TIMEOUT_MIN_FIRST)" \
			--trace-min-first "$(GP_DRAW_TIMEOUT_TRACE_MIN_FIRST)" --moves-min-first "$(GP_DRAW_TIMEOUT_MOVES_MIN_FIRST)" \
			--max-start "$(GP_DRAW_TIMEOUT_MAX_START)" --min-reach "$(GP_DRAW_TIMEOUT_MIN_REACH)" --max-missing "$(GP_DRAW_TIMEOUT_MAX_MISSING)" \
			--capture-sha256 "$(GP_DRAW_TIMEOUT_CAPTURE_SHA256)" --capture-frames "$(GP_DRAW_TIMEOUT_CAPTURE_FRAMES)"; \
		rc=$$?; [ -n "$(GP_WIN_KEEP)" ] || rm -rf $(GP_DUMP)/gp-draw-timeout; exit $$rc; fi

# Live truth Task 5 oracle (record §L5): the continue screen 0x0E, its credit-ready
# PRESS START prompt, the complete 15->0 countdown and the mode-0x15/b25=0 expiry.
# The route reuses gp-u10-ending through match 1, then pokes the incumbent side's
# 0x10780A=0x78 at the first mode-0x0C entry. All three KOs (four writes) are harness
# writes: neither a fought KO nor a taken continue is claimed. The checker proves
# w10d=0, cred=4 throughout, b1f=0 and every countdown value; path reproduces all 17
# milestones on fields the port trace carries. Capture 336 is the round-1 mode-8
# entry's byte-bound three-present scan-out in SCAN3_ALLOWED. N=4596 is the first
# unexplained capture frame in the harness's STOP_TAIL after the shared script end
# (X f=0x18C2 = 6338; SIGTERM f=0x18FC), so the whole prompt/countdown and expiry are
# below it. The 3844-frame dump's last presented frame is f=0x18C0 (mode 0x15), reach
# 3843; trace and moves agree through 6338; 17 canonical non-black classes are missing.
# >>> PIN-BASELINE-BEGIN gp-u28-continue
GP_U28_MIN_FIRST = 4596
GP_U28_TRACE_MIN_FIRST = 6339
GP_U28_MOVES_MIN_FIRST = 6339
GP_U28_MAX_START = 96
GP_U28_MIN_REACH = 3843
GP_U28_CAPTURE_SHA256 = 96645e99a3718f5c387c6613d2faddaa8221ad936ea8c80f82a3dd602e51246e
GP_U28_CAPTURE_FRAMES = 4621
# <<< PIN-BASELINE-END gp-u28-continue
gp-u28-continue-oracle: build ## Live truth Task 5 oracle: gp-u28-continue's prompt, countdown and expiry (skips without its capture)
	@echo "== gameplay oracle: gp-u28-continue (the continue screen 0x0E, countdown and expiry under a labelled KO poke) =="
	@$(PYTHON) -m unittest tools.tests.test_gp_continue
	@if [ ! -d $(K11_CAPTURES)/gp-u28-continue ]; then echo "gp-u28-continue-oracle: no capture at $(K11_CAPTURES)/gp-u28-continue (skipped)"; else \
		$(PYTHON) tools/gp_continue.py check --scenario gp-u28-continue --capture $(K11_CAPTURES)/gp-u28-continue \
			--capture-sha256 "$(GP_U28_CAPTURE_SHA256)" && \
		$(MAKE) --no-print-directory gp-replay scenario=gp-u28-continue GP_OPTIONAL=1 && \
		$(PYTHON) tools/gp_compare.py --scenario gp-u28-continue --capture $(K11_CAPTURES)/gp-u28-continue \
			--port $(GP_DUMP)/gp-u28-continue --min-first "$(GP_U28_MIN_FIRST)" \
			--trace-min-first "$(GP_U28_TRACE_MIN_FIRST)" --moves-min-first "$(GP_U28_MOVES_MIN_FIRST)" \
			--max-start "$(GP_U28_MAX_START)" --min-reach "$(GP_U28_MIN_REACH)" --max-missing "$(GP_U28_MAX_MISSING)" \
			--capture-sha256 "$(GP_U28_CAPTURE_SHA256)" --capture-frames "$(GP_U28_CAPTURE_FRAMES)" && \
		$(PYTHON) tools/gp_continue.py path --scenario gp-u28-continue --capture $(K11_CAPTURES)/gp-u28-continue \
			--port $(GP_DUMP)/gp-u28-continue --capture-sha256 "$(GP_U28_CAPTURE_SHA256)"; \
		rc=$$?; [ -n "$(GP_WIN_KEEP)" ] || rm -rf $(GP_DUMP)/gp-u28-continue; exit $$rc; fi

# Final truth Task 4 oracle (design §3.6, record §F4): the taken-continue branch
# gp-u28-continue deliberately did not reach. The route is gp-u28-continue's (U10's
# ARCADE through the two match-1 KOs and the labelled incumbent 0x10780A=0x78 poke)
# plus one p1.start at the PRESS START prompt: the prompt arm is cnt < 0x0E (0x27B59),
# cnt=0x0D from f=0x1540, the press is injected in the spin of f=0x155F (iteration
# 0x1560 samples the key down) and 0x500C4's debounce makes the newly-pressed bit
# reach 0x4F644 at f=0x1561. The measured take: 0x42F60 spends one credit (cred 4 -> 3,
# 0x2CA9C debits only while b1f is 0), 0x27A40 sets b1f=(w10d != 0)+1=1, 0x2791C
# clears the prompt/countdown cells, restores both snapshots, 0x1D764 clears the
# incumbent +0x5A 0x78 -> 0 and stores mode 0x0C. `gp_continue.py check --taken` proves
# w10d=0, the countdown 0F -> 0E -> 0D to the prompt arm, the take/spend/b1f/+0x5A,
# 155 post-take mode-0x0C records with cnt frozen at 0x0D, and that the W rows are the
# route's four existing harness writes only: no credit write and no poke at or after
# the screen opens (the KOs are harness writes, not a fought-KO or win claim). `path
# --taken` reproduces 4/4 milestones on fields the port trace carries.
# N=4380 is the first unexplained capture frame (raw 7984), in the harness's STOP_TAIL
# after the shared script end (X f=0x15C0=5568; SIGTERM f=0x15FC=5628; the replay has
# no frames for the 60 tail captures), so the whole prompt/countdown/take and the
# post-take round are below it. Capture 346 (raw 2823) is the round-1 mode-8 entry's
# two-segment scan-out in SCAN3_ALLOWED (rows 0..1 port 218; rows 2..199 port 221
# through the vacuous pair 220/221; ports 219/220 unexhibited). The trace and moves
# agree through 5568 (zero differences to the script's end); 12 canonical non-black
# classes are missing through the reach. The window start 100 (raw 1737) and the reach
# 3649 (the dump's last presented frame) are measured; an absent capture skips.
# >>> PIN-BASELINE-BEGIN gp-u29-continue-taken
GP_U29_MIN_FIRST = 4380
GP_U29_TRACE_MIN_FIRST = 5569
GP_U29_MOVES_MIN_FIRST = 5569
GP_U29_MAX_START = 100
GP_U29_MIN_REACH = 3649
GP_U29_CAPTURE_SHA256 = cb0347c50cd257408b1c447c23b667991dedbf2c9ee4ce64fb9638dcc1136b9c
GP_U29_CAPTURE_FRAMES = 4446
# <<< PIN-BASELINE-END gp-u29-continue-taken

# Final truth Task 5 (record §F5): the mode-0x25 bowling entry, reached by real
# two-human input with no poke. gp-bowling-2 plays character 4's move 0 five
# times on both sides 150 frames apart; the fourth attempt's +2 puts
# DS_00108892 at 7, and fight_mode25_enter (0x4E11C) opens the mode at f=0x832,
# the first frame the get-up's +0x53 = 0 meets the >= 6 gate (the fifth attempt
# never fires: 28 of 32 steps). All ten session checks pass (stopped on mode
# 0x25, 1,491/1,491 frames, no unscripted input, 0/0 pokes). The evidence
# checker (`gp_bowling.py check --staggered`) proves the counter's 1/3/5/7
# growth, the 7-long +0x53 windows, the signed camera -606 inside
# [-0x3300,0x3300], the entry stores and the absence of W records. Capture 443
# (raw 2896) is the wipe-in's two-present scan-out in SCAN3_ALLOWED (rows 19..32
# port 301, rows 33..199 port 302, the measured pair 301 pinned because the
# layout repeats at 31/255). The port replay dumps 1,138 presented frames; trace
# and moves agree through 2158 (the capture's X record is f=0x86E) with 0
# differences, so both ratchets pin at 2,159; the first unexplained capture
# frame is 1,422 (raw 3962), the port dump's post-script tail, so the frame
# claim reaches capture 1,421 (the entry is capture 1,352, +70; capture 1,370 is
# port 1,093 at f=0x842). The window
# start 97 (raw 1735), the reach 1,137 (the dump's last presented frame) and the
# four missing non-black classes [9, 11, 68, 255] are measured; an absent
# capture skips.
# >>> PIN-BASELINE-BEGIN gp-bowling-2
GP_BOWLING_MIN_FIRST = 1422
GP_BOWLING_TRACE_MIN_FIRST = 2159
GP_BOWLING_MOVES_MIN_FIRST = 2159
GP_BOWLING_MAX_START = 97
GP_BOWLING_MIN_REACH = 1137
GP_BOWLING_MAX_MISSING = 4
GP_BOWLING_CAPTURE_SHA256 = 54d4c7d506c0835f8f94e1270029348cbb529d6313ba5759c0f69fe3134ad3b3
GP_BOWLING_CAPTURE_FRAMES = 1491
# <<< PIN-BASELINE-END gp-bowling-2
gp-bowling-oracle: build ## Final truth Task 5 oracle: gp-bowling-2's mode-0x25 entry (skips without its capture)
	@echo "== final truth Task 5: the mode-0x25 bowling entry (skips without its capture; record §F5) =="
	@$(PYTHON) -m unittest tools.tests.test_gp_bowling
	@if [ ! -d $(K11_CAPTURES)/gp-bowling-2 ]; then echo "gp-bowling-oracle: no capture at $(K11_CAPTURES)/gp-bowling-2 (skipped)"; else \
		$(PYTHON) tools/gp_bowling.py check --staggered --scenario gp-bowling-2 --capture $(K11_CAPTURES)/gp-bowling-2 \
			--capture-sha256 "$(GP_BOWLING_CAPTURE_SHA256)" && \
		$(MAKE) --no-print-directory gp-replay scenario=gp-bowling-2 GP_OPTIONAL=1 && \
		$(PYTHON) tools/gp_compare.py --scenario gp-bowling-2 --capture $(K11_CAPTURES)/gp-bowling-2 \
			--port $(GP_DUMP)/gp-bowling-2 --min-first "$(GP_BOWLING_MIN_FIRST)" \
			--trace-min-first "$(GP_BOWLING_TRACE_MIN_FIRST)" --moves-min-first "$(GP_BOWLING_MOVES_MIN_FIRST)" \
			--max-start "$(GP_BOWLING_MAX_START)" --min-reach "$(GP_BOWLING_MIN_REACH)" --max-missing "$(GP_BOWLING_MAX_MISSING)" \
			--capture-sha256 "$(GP_BOWLING_CAPTURE_SHA256)" --capture-frames "$(GP_BOWLING_CAPTURE_FRAMES)"; \
		rc=$$?; [ -n "$(GP_WIN_KEEP)" ] || rm -rf $(GP_DUMP)/gp-bowling-2; exit $$rc; fi

# Volleyball truth Task 4 (plan 2026-11-01-volleyball-truth, design §5, record
# 2026-11-01-volleyball-truth-derivations §2): the mode-0x21 entry on
# gp-u30-volleyball, reached by real two-human input with no poke. The scenario
# (gp_session.VOLLEY_ROUTE) is the Task 3 route (route.script) translated to the
# step scheduler: the 1P select confirms character 2, the P2 joins at f=2097
# (the capture's S mode 0x06 at 0x4BF -> 882 frames; the port's first 1P-fight
# T at 0x444 -> 1,005 frames; record §2.1), the 2-hit combo lies the side-0
# crowd, and eight tramples take
# entry+0x1F 1..8 (the eighth at f=0x0F04 enters mode 0x21). The boot is
# `at_frame 320`, the measured gp-u15-join boot press spin (f=319), because the
# route's fight phase is a function of the boot's absolute frame (the 1P CPU's
# RNG state at the join); the generated port script's `enter_frame` 321 (the H
# record at f=321) pins it, so the wall-boot variance cannot move it (`late=0`,
# no PinGuard gap). All eleven session checks pass (stopped on mode 0x21,
# 4,704/4,704 frames, no unscripted input, 0/0 pokes). The checker
# (`gp_volley.py check`) proves the entry stores
# (b1f=3, afc=0 stage 0, abc=2, a live ball, clk=0x1E, C1=C5=1, 0-0), the clock
# 0x1E -> 0 over the mode's 1,717 snapshots, the point (scores peak 0-1) and the
# return to mode 6 at f=0x15B9 (clk=0, ball=0, C5=0, scores 0-0), with no W
# record and real pad input on both sides; with --trace it also checks the
# measured ball entry 0x1083F0's +0x1F=8 (the 0x4B579 > 7 gate) and the port
# T/Vg entry and return stores. The port replay dumps 3,938
# presented frames; trace and moves agree through the capture's X record
# (f=0x160C, 5,644), so both ratchets pin at 5,645. The first unexplained
# capture frame is 4,640 (raw 8,089), the port dump's post-script tail, so the
# frame claim reaches capture 4,639; the window start 104 (raw 1,745), the reach
# 3,936 (the dump's last exhibited frame) and the 13 missing non-black classes
# are measured. An absent capture skips.
# >>> PIN-BASELINE-BEGIN gp-u30-volleyball
GP_U30_MIN_FIRST = 4640
GP_U30_TRACE_MIN_FIRST = 5645
GP_U30_MOVES_MIN_FIRST = 5645
GP_U30_MAX_START = 104
GP_U30_MIN_REACH = 3936
GP_U30_MAX_MISSING = 13
GP_U30_CAPTURE_SHA256 = 42e131d1753c59e5a3420297a5efe977cfb28fed2eee8617f92a4d2d8ab8307c
GP_U30_CAPTURE_FRAMES = 4704
# <<< PIN-BASELINE-END gp-u30-volleyball
gp-u30-volleyball-oracle: build ## Volleyball truth Task 4 oracle: gp-u30-volleyball's mode-0x21 entry, point and return (skips without its capture)
	@echo "== volleyball truth Task 4: the mode-0x21 entry, a real point and the return to mode 6 (skips without its capture; record §2) =="
	@$(PYTHON) -m unittest tools.tests.test_gp_volley
	@if [ ! -d $(K11_CAPTURES)/gp-u30-volleyball ]; then echo "gp-u30-volleyball-oracle: no capture at $(K11_CAPTURES)/gp-u30-volleyball (skipped)"; else \
		PR_GP_VOLLEY=1 $(MAKE) --no-print-directory gp-replay scenario=gp-u30-volleyball GP_OPTIONAL=1 && \
		$(PYTHON) tools/gp_volley.py check --capture $(K11_CAPTURES)/gp-u30-volleyball \
			--trace $(GP_DUMP)/gp-u30-volleyball/trace.txt \
			--capture-sha256 "$(GP_U30_CAPTURE_SHA256)" && \
		$(PYTHON) tools/gp_compare.py --scenario gp-u30-volleyball --capture $(K11_CAPTURES)/gp-u30-volleyball \
			--port $(GP_DUMP)/gp-u30-volleyball --min-first "$(GP_U30_MIN_FIRST)" \
			--trace-min-first "$(GP_U30_TRACE_MIN_FIRST)" --moves-min-first "$(GP_U30_MOVES_MIN_FIRST)" \
			--max-start "$(GP_U30_MAX_START)" --min-reach "$(GP_U30_MIN_REACH)" --max-missing "$(GP_U30_MAX_MISSING)" \
			--capture-sha256 "$(GP_U30_CAPTURE_SHA256)" --capture-frames "$(GP_U30_CAPTURE_FRAMES)"; \
		rc=$$?; [ -n "$(GP_WIN_KEEP)" ] || rm -rf $(GP_DUMP)/gp-u30-volleyball; exit $$rc; fi
gp-u29-continue-taken-oracle: build ## Final truth Task 4 oracle: gp-u29-continue-taken's take of the continue screen (skips without its capture)
	@echo "== final truth Task 4: the taken continue (0x0E -> 0x2791C -> mode 0x0C; skips without its capture) =="
	@$(PYTHON) -m unittest tools.tests.test_gp_continue
	@if [ ! -d $(K11_CAPTURES)/gp-u29-continue-taken ]; then echo "gp-u29-continue-taken-oracle: no capture at $(K11_CAPTURES)/gp-u29-continue-taken (skipped)"; else \
		$(PYTHON) tools/gp_continue.py check --taken --scenario gp-u29-continue-taken --capture $(K11_CAPTURES)/gp-u29-continue-taken \
			--capture-sha256 "$(GP_U29_CAPTURE_SHA256)" && \
		$(MAKE) --no-print-directory gp-replay scenario=gp-u29-continue-taken GP_OPTIONAL=1 && \
		$(PYTHON) tools/gp_compare.py --scenario gp-u29-continue-taken --capture $(K11_CAPTURES)/gp-u29-continue-taken \
			--port $(GP_DUMP)/gp-u29-continue-taken --min-first "$(GP_U29_MIN_FIRST)" \
			--trace-min-first "$(GP_U29_TRACE_MIN_FIRST)" --moves-min-first "$(GP_U29_MOVES_MIN_FIRST)" \
			--max-start "$(GP_U29_MAX_START)" --min-reach "$(GP_U29_MIN_REACH)" --max-missing "$(GP_U29_MAX_MISSING)" \
			--capture-sha256 "$(GP_U29_CAPTURE_SHA256)" --capture-frames "$(GP_U29_CAPTURE_FRAMES)" && \
		$(PYTHON) tools/gp_continue.py path --taken --scenario gp-u29-continue-taken --capture $(K11_CAPTURES)/gp-u29-continue-taken \
			--port $(GP_DUMP)/gp-u29-continue-taken --capture-sha256 "$(GP_U29_CAPTURE_SHA256)"; \
		rc=$$?; [ -n "$(GP_WIN_KEEP)" ] || rm -rf $(GP_DUMP)/gp-u29-continue-taken; exit $$rc; fi

# Gameplay U9/U10 oracles (plan docs/superpowers/plans/2026-10-02-gameplay-u9-u10-win-and-endings.md,
# record docs/superpowers/plans/2026-10-02-gameplay-u9-u10-derivations.md): the win path
# (data/k11-captures/gp-u9-win) and the ending (data/k11-captures/gp-u10-ending), each reached
# under memory pokes (gp_session's ('poke', ...) steps; the port replays the capture's W records
# as `poke` lines at W.f + 1, the iteration that reads them, record §W.8). The tool tests always run; with a capture present it must
# first show the raw-derived path (tools/gp_win.py check, record §W.9), then the frame and trace
# ratchets (gp_compare), the milestone ratchet (the leading milestones the port reaches at the
# capture's frame) and the win-fields trace ratchet (gp_session.WIN_FIELDS). Skips without the
# capture, even under PR_ORACLE_REQUIRED (spec §4.3); with it, an empty pin FAILS. The claims are
# narrow like gp-oracle's: what a poke replaced (the hits that would have KO'd P2, the death
# animation that would have set DS_00104B0C) is not claimed (record §W.9).
# The values below are pinned by the plan's Tasks 9 and 11 from the measured report lines
# (record §W.12/§W.14); raise each when it improves.
# gp-u9-win (record §W.12): measured at de44e7f (+ its miss set) on the capture below
# (poll.log sha256 and its 2521 stored frames pinned together). Report lines, verbatim:
#   "frames: FIRST UNEXPLAINED capture 346 (raw 2830): nearest port 221, rows 0..4, x 0..319 (933 px)"
#   "frames: window from capture 100 (raw 1744)"
#   "trace: first difference f=866 (2150) in rng: capture 9BE3F91A, port 8D1ACCD4"
#   "path: 0 not reproduced through 7; ratchet N 0 ok (every item is explained: N = 8 is the exact pin)"
#   "win: first difference f=C5A (3162) in afc: capture 6, port 3"
# MIN_FIRST 346: a long frame at the mode-8 entry, then the catch-up (window.txt: capture 345 is
# raw 2827, 346 is raw 2830; raws 2828-2829 repeat 2827, so the screen held port 218, f=0x48A,
# the first mode-8 frame, for three capture frames, ~43 ms). Capture 346 is rows 0..4 of port 218
# and rows 5..199 of port 221 (f=0x48D), 0 px each: a splice of ports 218 and 221, which the
# two-adjacent-frame model cannot express; ports 219/220 (f=0x48B/0x48C) appear in no capture
# frame. Not a port divergence, and not the poke's: the same scan-out is at gp-idle-loss's
# unpoked mode-8 entry (capture 2064, record §U6.21), and S records are missed at many unpoked
# mode entries here too. The next unexplained, 356, is a partial-draw composite of ports
# 229/230/231 (no pixel without a port source); the first that can be real content is 1374, at
# the 0x21044 miss (named in §W.12). TRACE_MIN_FIRST 2150: the original
# draws rng at f=0x866 in mode 9, 6 frames after the port's miss of the unported animation
# target 0x21044 (f=0x860; P track). WIN_MIN_FIRST 3162: match 2's stage, 0x25848's rng pick,
# follows that rng. MILESTONES 8: all reproduced. Raise each when it improves.
# Re-measured by track P batches 4+5 (record 2026-10-03-reverse-p4-p5 §P4.7/§P5.7) once 0x400E0,
# 0x21044 and 0x21084 are ported: the set loses those three rows and gains the targets the replay
# then reaches (0x213F0 P7 at f=0x8ED, 0x213F4 P7 at f=0x91F, 0x2BDA0 P6 at f=0xD0C, all skipped
# by anim_indirect), and TRACE_MIN_FIRST rises 2150 -> 2364 (the first trace difference; the WIN
# ratchet stays 3162, MIN_FIRST 346 and MAX_START 100).
# Re-measured by track P batch 6 (record 2026-10-03-reverse-p6 §P6.12) once 0x2BDA0 is ported:
# the set loses that row alone (the replay still reaches f=0xDAE and now runs the target, so
# fn-miss PR_GP_DUMP distinct=6 dropped=0) and every pin above is unchanged and exact, because the
# trace's first difference at f=0x93C precedes 0x2BDA0's first frame 0xD0C: every pin is exact
# (frames +1 347, trace +1 2365, win +1 3163, path +1 9 unreachable, max-start -1 99 fail).
# Re-measured by track P batch 7 (record 2026-10-04-reverse-p7 §P7.8) once 0x213F0 and 0x213F4 are
# ported, on the batch's task-2 tree: the set loses both rows and gains 0x36114 (Task 3's D000
# target, which the replay now reaches; fn-miss PR_GP_DUMP distinct=5 dropped=0: 0x5D812
# actor_spawn and set_dead, 0x29D60 and 0x5D812 from frontend_mode_1b_step, 0x36114
# anim_indirect hits=1). TRACE_MIN_FIRST rises 2364 -> 2377 (the first trace difference, f=0x949;
# N 2378 is unreachable); WIN_MIN_FIRST stays 3162 (f=0xC5A; 3163 unreachable). MIN_FIRST stays
# 346 (the mode-8 long frame; 347 fails), MAX_START 100 (99 fails), MILESTONES 8 (9 unreachable).
# Re-measured by track P batch 7 (record 2026-10-04-reverse-p7 §P7.8) once task 3 ports 0x36114: the
# set loses that row (fn-miss PR_GP_DUMP distinct=4 dropped=0: 0x5D812 actor_spawn and set_dead,
# 0x29D60 and 0x5D812 from frontend_mode_1b_step) and TRACE_MIN_FIRST and WIN_MIN_FIRST rise
# 2377/3162 -> 3503 (0 differing through 3502; N 3504 is unreachable, the replay's end).
# MIN_FIRST stays 346 (the mode-8 long frame; 347 fails), MAX_START 100 (99 fails) and
# MILESTONES 8 (9 unreachable).
# Re-measured by track G g6 (record 2026-10-14-reverse-g §G6): the mode-8 three-frame scan-out at
# capture 346 is ISOLATED (the 0x25643 gate's catch-up on the mode-8 entry's over-tick iterations;
# the loader re-sync hypothesis tested and rejected on the tick 1:1 cadence and the byte-identical
# held raws 2828/2829) and ALLOWED BY NAME in SCAN3_ALLOWED (top rows 0..4 = port 218's f=0x48A,
# the screen held across raws 2827..2829; rows 5..199 = port 221's f=0x48D; the 1977-frame dump's
# unique adjacent pair (220, 221); top sha 703828af..; ports 219/220 never blitted). With it active
# every capture frame 100..2208 is explained (356 and 1374, §W.12's next two, are explained on the
# current dump's replay — the P batches ported animation targets on that path since §W.12 measured;
# per-frame re-attribution is not done here) and the first unexplained is 2209
# (raw 4953): nearest port 1777 (f=0xBA1), rows 192..197, x 0..85 (166 px) — a 6-row strip whose
# bytes (sha256 a886c0f3..) are IDENTICAL to gp-u10-ending's 2062 strip, and NO frame of the
# 1977-frame dump holds them — including the loader/pump-hook samples of the mode-0x17→0x12 entry
# (dumps 1775..1777 all at f=0xBA1 with its tick advancing 0xBFA→0xC00 inside the iteration — the
# res.c stall shape — then one static image through f=0xBBF). The strip lands in the (0,192)
# text-row region, but the port did dump that entry's sampled states and none matches: the cause
# is NOT attributed here — a named gap, not §G6's scan-out class and not allowed. Pin 2210 fails, MAX_START 100 unchanged and
# exact (99 fails), TRACE/WIN/MILESTONES unchanged (3503/3503/8).
# SUPERSEDED (track G g7 below attributes the strip to the loader's `- LOADING -` overlay and allows it by name; MIN_FIRST 2209 -> 2452).
# Re-measured by track G g7 (record 2026-10-14-reverse-g §G7); the overlay name was RETIRED by
# the music truth phase (§M2.7: 0x1C930 is ported, the entry's own resolve presents the loader
# and capture 2209 is a CLEAN match). The strip was ATTRIBUTED and ALLOWED BY NAME in
# OVERLAY_ALLOWED — the loader's `- LOADING -` presentation (string 489, drawn
# by 0x1B3EA/0x1B3EF at (0,0xE6) = rows 192..197 x 0..85; its 166 glyph px are port 1776's and
# the mask's source port 1775 is the same indices under the stale black palette) over the
# mode-0x12 screen (port 1777, the f=0xBA1 entry frame): a composition of two frames the port
# itself dumped, which the two-frame model cannot express. The port's own presentation of that
# text is over the black screen (capture 2208 = port 1776) and its next one is dump frame 1805
# (f=0xC0E, over the later screen): the resolve presenting it is s16snd2's first, and the original's
# is the same resource one call earlier — the entry's voice 0x32 (case 1) is a music request whose
# song handle 0x03021BFA is index 6, and the original's sequencer reads that song through the
# resource layer in the entry's window (the strip's background is the entry's screen, not the
# later one; the capture's S records show no stall at f=0xC0E and one extra tick in the entry
# window), while the port's music path was then host-owned and never resolved (platform/audio/ has no
# res_resolve), so the replay's first s16snd2 resolve is the *sample* queue of voice 0x3A at
# f=0xC0E. The frame of that presentation is thus the host-owned audio model's, not the ported
# game code's — a model artifact (§G7); the sequencer's exact read frame inside the entry's window
# is not isolated. With the allowance active before §M2.7 (the clean match since) every
# content-bearing capture frame 100..2451 is explained and the first unexplained is 2452 (raw 5603): nearest port
# 1976, rows 62..169, x 94..218 (1905 px) — the replay's post-script tail (port 1976 is f=0xDAE,
# the X record; rows 0..61/170..199 are its still-scanned screen). MIN_FIRST 2209 -> 2452; 2453
# fails (the exact pin). MAX_START 100 unchanged and exact (99 fails), TRACE/WIN/MILESTONES
# unchanged (3503/3503/8). The pixel claim now reaches the replay's end (f=0xDAE) instead of
# stopping at the strip.
# MUSIC TRUTH §M2.7 (2026-10-25): 0x1C930's game-side resolve is ported, so the mode-0x12
# entry's own music request (voice 0x32, song 0x03021BFA, s16snd2) reads the bank through the
# resource layer in the entry's window. The loader text now lands in the replay's own frame and
# capture 2209 is a CLEAN match, so the OVERLAY_ALLOWED name is retired (the table is empty).
# MIN_FIRST/START/TRACE/WIN/MILESTONES and the reach (1976) are unchanged.
# >>> PIN-BASELINE-BEGIN gp-win
GP_WIN_MIN_FIRST = 2452
GP_WIN_TRACE_MIN_FIRST = 3503
GP_WIN_MAX_START = 100
GP_WIN_MILESTONES = 8
GP_WIN_WIN_MIN_FIRST = 3503
GP_WIN_CAPTURE_SHA256 = 7dcea0f16403c0fa52b6ef690edbcccb5340d9b9d1ec96c66ca4d11770708ba8
GP_WIN_CAPTURE_FRAMES = 2521
# MIN_REACH (sub-project A §A1): the highest port frame any classified capture frame
# exhibits ("coverage ... up to port 1976"), the replay's last presented frame (f=0xDAE,
# the X record). Measured 2026-10-16 on data/k11-captures/gp-u9-win via gp_compare --report.
# Raise it when gp_compare prints "improved: raise the pin".
GP_WIN_MIN_REACH = 1976
# <<< PIN-BASELINE-END gp-win
# gp-u10-ending (record §W.14; re-measured by track P batch 2, plan reverse-p2 Task 9, record §W.16,
# once 0x2381C is ported): measured at 20e8f4a (P2 rebased onto b16922d, + its new miss set) on the
# capture below (poll.log sha256 and its 5704 stored frames pinned together). Report lines, verbatim:
#   "frames: FIRST UNEXPLAINED capture 331 (raw 2831): nearest port 219, rows 0..63, x 0..319 (10336 px)"
#   "frames: window from capture 83 (raw 1745)"
#   "trace: 0 differing through 9953; ratchet N 9954 ok"
#   "path: 0 not reproduced through 29; ratchet N 30 ok"
#   "win: 0 differing through 9953; ratchet N 9954 ok"
# memsize: gp-u10-ending runs DOSBox-X with memsize=64 (a harness value, record §W.13); the original's
# memory growth over the poked final is not root-caused, so memsize=64 claims nothing about the game's
# memory need.
# MIN_FIRST 331 (unchanged by P2): a long frame at the mode-8 entry, then the catch-up, as gp-u9-win's 346
# (window.txt: capture 330 is raw 2828, 331 is raw 2831; raws 2829-2830 repeat 2828, so the
# screen held port 216, f=0x483, the first mode-8 frame, for three capture frames, ~43 ms).
# Capture 331 is rows 0..1 of port 216, rows 2..63 of port 218 (f=0x485) and rows 64..199 of
# port 219 (f=0x486), 0 px each: a three-frame composite the two-adjacent-frame model cannot
# express; not a port divergence, and not the poke's (the same at gp-idle-loss's unpoked mode-8
# entry, record §U6.21). The next unexplained, 341/342, are partial-draw composites of ports
# 227/228/229 and 228/229 (no pixel without a port source). Since P2 the former 1274/1275 (the
# 0x2381C miss) are explained; --report's next two, 2062 (port 1686, f=0xB9A, mode 0x12, 166 px)
# and 3686 (port 3071, f=0x12EA, mode 0x24, 379 px), are not examined (record §W.16).
# TRACE_MIN_FIRST 9954: 0 differing through f=0x26E1 (9953), the X record: the end of the port's
# replay, not a divergence. Before P2 (at 0ea42d0) it stopped at f=0x849 (2121), the frame after
# the port's 0x2381C miss (CHAOS's reaction 0x25 at f=0x848); with 0x2381C ported the rng is shared
# to the end and the final's opponent order (1,4,3,0,6,5,2) equals the capture's.
# WIN_MIN_FIRST 9954: 0 differing through 9953, the replay's end (before P2: 5634, f=0x1602, b0c set
# by 0x37EA0 29 frames late in the port). The port then still missed 0x37DD4/0x29C78 (batch 6 drops
# 0x37DD4, below) and b0c now agrees at every frame, so that delay followed the rng and the opponent
# order (record §W.16).
# MILESTONES 30: all reproduced. SUPERSEDED (track P batch 7 ported 0x29C78 and track G g7 raised
# the frame ratchet to 4113; see both re-measures below): at this measurement the replay still
# missed 0x29C78 (33 hits; batch 6 dropped 0x37DD4),
# whose effects the traced fields cannot see: the clean trace to f=0x26E1 does
# not claim the death streams or the mode-0xF content are reproduced, and is no evidence of
# correctness past the frame ratchet's 331.
# Every pin + 1 fails (record §W.16). Raise each when it improves.
# Re-measured by track P batch 3 (record 2026-10-03-reverse-p3 §P3.9) once 0x475EC is ported: the
# set loses that row alone and every pin above is unchanged (each + 1 fails).
# Re-measured by track P batches 4+5 (record 2026-10-03-reverse-p4-p5 §P5.7) once 0x3DA50 is
# ported: the set loses that row alone, the replay reaches no new target and every pin above is
# unchanged (each + 1 fails).
# Re-measured by track P batch 6 (record 2026-10-03-reverse-p6 §P6.12) once 0x37DD4 is
# ported: the set loses that row alone and every pin above is unchanged (each + 1 fails).
# Re-measured by track P batch 7 (record 2026-10-04-reverse-p7 §P7.8) once 0x29C78 is ported: the set
# loses that row (fn-miss PR_GP_DUMP distinct=4 dropped=0: 0x5D812 actor_spawn and set_dead, 0x29D60
# and 0x5D812 from frontend_mode_1b_step) and every pin above is exact and unchanged: frames 331
# (332 fails), trace/win 9954 (9955 is unreachable, the replay's end), path 30 (31 unreachable),
# MAX_START 83. TRACE/WIN are at the replay's end, so they cannot rise.
# Re-measured by track G g6 (record 2026-10-14-reverse-g §G6): the mode-8 three-frame scan-out at
# capture 331 is ISOLATED (the 0x25643 gate's catch-up, like idle-loss's 2064) and ALLOWED BY NAME
# in SCAN3_ALLOWED (top rows 0..1 = port 216's f=0x483, the screen held across raws 2828..2830, the
# dump's earlier holders of those rows being {216, 243, 1127}; rows 2..63 = port 218's f=0x485 and
# rows 64..199 = port 219's f=0x486, the 4664-frame dump's unique adjacent pair (218, 219);
# top sha 2d6fb6ad..; port 217 never blitted). With it active every capture frame 83..2061 is
# explained and the first unexplained is 2062 (raw 4954) — §W.16's already-named next-one (port 1686,
# f=0xB9A, mode 0x12, 166 px, rows 192..197 x 0..85): the SAME strip bytes as gp-u9-win's 2209
# (sha256 a886c0f3..), at the same mode-0x17→0x12 entry, and no frame of the 4664-frame dump holds
# them — including the loader/pump-hook samples of that entry (dumps 1684..1686 all at f=0xB9A, its
# tick 0xBF3→0xBF9 inside the iteration, then one static image through f=0xBB8). Cause not
# attributed here — not §G6's class and not allowed. Pin 2063
# fails; 3686 (§W.16's other next-one) is explained on the current dump since the P4-P7 batches
# (the report's next unexplained after 2062 are 4113..4116).
# SUPERSEDED (track G g7 below attributes the same strip to the loader's `- LOADING -` overlay and allows it by name; MIN_FIRST 2062 -> 4113).
# MAX_START 83 unchanged and exact (82 prints the improved line), TRACE/WIN/MILESTONES unchanged
# (9954/9954/30; TRACE/WIN are at the replay's end, so they cannot rise).
# Re-measured by track G g7 (record 2026-10-14-reverse-g §G7): the strip is the SAME presentation
# as gp-u9-win's 2209 — the same glyph fingerprint (de1c1bb2..) — at U10's entry: port 1685's 166
# px over the mode-0x12 screen (port 1686, the f=0xB9A entry frame), the mask from port 1684 (the
# palette pair is swapped against U9's) — and was ALLOWED BY NAME in OVERLAY_ALLOWED until the
# music truth phase retired it (§M2.7: the entry's resolve presents the loader and 2062 is a
# CLEAN match). With the allowance active before that (the clean match since) every
# content-bearing capture frame 83..4112 is explained and the first unexplained is 4113
# (raw 7858): nearest port 3444 (f=0x1502, mode 0x0D), rows 166..199, x 0..319 (9732 px) — the
# poked final's death animation (the DEATH_DONE pokes' mode-0x0D region, §W.9's unclaimed death
# streams), NOT allowed: a genuine unexplained frame the ratchet stops at. MIN_FIRST 2062 -> 4113;
# 4114 fails (the exact pin). MAX_START 83 unchanged and exact (82 prints the improved line),
# TRACE/WIN/MILESTONES unchanged (9954/9954/30; TRACE/WIN are at the replay's end, so they cannot
# rise). The pixel claim now reaches f=0x1502 (the final's 4th death) instead of stopping at the
# strip (f=0xB9A).
# BEFORE Task 3 (superseded by the next block): 4113 was the first unexplained until the span-writer
# fix explained it; 4114 no longer fails, and the pixel claim now reaches f=0x26E1.
# Re-measured by gameplay truth II Task 3 (record 2026-10-20-gameplay-truth-ii §W1): 4113..4134 were
# a REAL port divergence in the RLE span writers — the bank is added as a dword (0x81110/0x81010),
# so a byte sum above 0xFF carries into the next pixel of the four-pixel group, and a run colour's
# 2/3-byte tail is a carried word for runs of 14+ pixels (0x585F0 n=14 is the first word-tail
# writer, 0x58600 n=15; n=13 is one dl) but dl repeated below that (0x5855C..0x585C0), with the
# same threshold in the mirror fill table (0x52284/0x52294). The port added the bank byte-wise
# (sprite.c rle_row / copy_run) and the mirrored span wrote one byte for every pixel; the fix also
# reproduces the hflip tables 0x80D0C/0x80E0C (high-end dwords, low-end dl tail, bswapped literal).
# With the fix the two dead fighters' flash-palette frames (port 3444..3463) match and
# every capture frame 83..5436 is explained; the first unexplained is 5437 (raw 13279) — the
# capture's post-script tail (§W1 run 2, the port's replay ends at f=0x26E1), NOT allowed:
# MIN_FIRST 4113 -> 5437 and 5438 fails (the exact pin). The window REACH rises 3444 -> 4661
# (the highest port frame the capture exhibits; the dump's last frame index is 4663, f=0x26E1,
# and is not exhibited) because the enforced run no longer stops at 4113; 4662 fails. Music
# truth §M2.7 re-measured 4662 -> 4661 after the entry loader became a clean match.
# MAX_START 83 unchanged and exact (82 prints the improved line), TRACE/WIN/MILESTONES unchanged.
# MUSIC TRUTH §M2.7 (2026-10-25): as gp-u9-win, capture 2062 is a CLEAN match now (the entry's
# music resolve presents the loader in the replay's own frame; OVERLAY_ALLOWED retired).
# Re-measured: MAX_MISSING 65 -> 63 and MIN_REACH 4662 -> 4661; MIN_FIRST/START/TRACE/WIN are
# exact and unchanged.
# >>> PIN-BASELINE-BEGIN gp-ending
GP_ENDING_MIN_FIRST = 5437
GP_ENDING_TRACE_MIN_FIRST = 9954
GP_ENDING_MAX_START = 83
GP_ENDING_MILESTONES = 30
GP_ENDING_WIN_MIN_FIRST = 9954
GP_ENDING_CAPTURE_SHA256 = a88de48ad39e90df1e3d3329fbd5aa876c69a08484fa22c66db8deebd3feff38
GP_ENDING_CAPTURE_FRAMES = 5704
# MIN_REACH (sub-project A §A1): the highest port frame any classified capture frame
# exhibits. Measured 2026-10-21 by gameplay truth II Task 3 after the §W1 span-writer fix:
# 4661 = the highest port frame the capture exhibits (the dump's last frame is index 4663,
# f=0x26E1, and is not exhibited) — the enforced run no longer stops at capture 4113.
# Before it: 3444 (4113's nearest port, the first unexplained).
# Raise it when gp_compare prints "improved: raise the pin".
GP_ENDING_MIN_REACH = 4661
# <<< PIN-BASELINE-END gp-ending
# The port dump is removed after its comparison unless GP_WIN_KEEP=1 (as gp-modes-one's
# GP_MODES_KEEP; record §W.10: 129 MB and 302 MB of /tmp).
GP_WIN_KEEP ?=
gp-win-oracle: build ## Gameplay U9 oracle: win-path evidence, frame/trace/milestone/win ratchets on data/k11-captures/gp-u9-win (skips without it)
	@echo "== gameplay oracle: gp-u9-win (the win path under pokes; plan U9/U10) =="
	@$(MAKE) --no-print-directory gp-win-one scenario=gp-u9-win GP_WIN_ID=WIN
gp-ending-oracle: build ## Gameplay U10 oracle: ending evidence, frame/trace/milestone/win ratchets on data/k11-captures/gp-u10-ending (skips without it)
	@echo "== gameplay oracle: gp-u10-ending (the ending under pokes; plan U9/U10) =="
	$(PYTHON) -m unittest tools.tests.test_gp_win
	@$(MAKE) --no-print-directory gp-win-one scenario=gp-u10-ending GP_WIN_ID=ENDING
gp-win-one: build ## Run one win/ending replay (used by gp-win-oracle/gp-ending-oracle/gp-u14-twop-win-oracle)
	@if [ ! -d $(K11_CAPTURES)/$(scenario) ]; then echo "gp-win-one: no capture at $(K11_CAPTURES)/$(scenario) (skipped)"; else \
		$(PYTHON) tools/gp_win.py check --scenario $(scenario) --capture $(K11_CAPTURES)/$(scenario) \
			--capture-sha256 "$(GP_$(GP_WIN_ID)_CAPTURE_SHA256)" && \
		$(MAKE) --no-print-directory gp-replay scenario=$(scenario) GP_OPTIONAL=1 && \
		$(PYTHON) tools/gp_compare.py --scenario $(scenario) --capture $(K11_CAPTURES)/$(scenario) \
			--port $(GP_DUMP)/$(scenario) --min-first "$(GP_$(GP_WIN_ID)_MIN_FIRST)" \
			--trace-min-first "$(GP_$(GP_WIN_ID)_TRACE_MIN_FIRST)" --max-start "$(GP_$(GP_WIN_ID)_MAX_START)" \
			--min-reach "$(GP_$(GP_WIN_ID)_MIN_REACH)" --max-missing "$(GP_$(GP_WIN_ID)_MAX_MISSING)" \
			$(if $(GP_$(GP_WIN_ID)_MOVES_MIN_FIRST),--moves-min-first "$(GP_$(GP_WIN_ID)_MOVES_MIN_FIRST)") \
			--capture-sha256 "$(GP_$(GP_WIN_ID)_CAPTURE_SHA256)" --capture-frames "$(GP_$(GP_WIN_ID)_CAPTURE_FRAMES)" && \
		$(PYTHON) tools/gp_win.py path --scenario $(scenario) --capture $(K11_CAPTURES)/$(scenario) \
			--port $(GP_DUMP)/$(scenario) --min-milestones "$(GP_$(GP_WIN_ID)_MILESTONES)" \
			--win-min-first "$(GP_$(GP_WIN_ID)_WIN_MIN_FIRST)" --capture-sha256 "$(GP_$(GP_WIN_ID)_CAPTURE_SHA256)"; \
		rc=$$?; [ -n "$(GP_WIN_KEEP)" ] || rm -rf $(GP_DUMP)/$(scenario); exit $$rc; fi

# Gameplay truth II U13 (plan docs/superpowers/plans/2026-10-20-gameplay-truth-ii.md Task 5;
# gp_session.WIN_MENU1 and gp_win.MILESTONES['gp-u13-ending2']): gp-u13-ending2, the ending of
# a character other than 0. P1 confirms character-select cell 3 (class 1, BLIZZARD): the plan's
# one p1.right is corrected by the raw (0xC8882 = 00 02 03 01 06 04 05, indexed by the cursor at
# 0x43D6E/0x43D74), so cell 1 is class 2 TALON and BLIZZARD needs three (record gameplay-u5
# §C5.4/§C5.7). Its first poke writes BLIZZARD's land marks (0x81, not u10's SAURON 0x80:
# 0x41C28 state 1 tallies (mark & 0x7F) == DS_0010782A[side] | side << 6, 0x41D39/0x41D5C), and
# the rest are gp-u10-ending's from its second poke on; the ending row is therefore
# _all(ad4=0, c0=1) rather than SAURON's c0 = 0. Measured on the clean Task 7 capture after
# its own loader-overlay recipe (tools/gp_compare.py): the window starts at capture 96
# (raw 1739), and every content-bearing capture frame through 5890 is explained —
# 2188 by the loader's own frame (the music resolve; its OVERLAY_ALLOWED name is retired,
# §M2.7), 4248 (raw 7887) and 4320 (raw 8019) as three-present
# scan-outs, 5120 (raw 8839) as a four-row band (record 2026-10-22-pixel-and-proof-closure
# §P1, §P2, §P3). Endgame truth Task 9 (§E9) extends the claim over the capture's tail:
# the last S record is f=0x2754 (10068), 55 S records past the X record's f=0x271D (10013),
# so gp-u13-oracle's gp-replay opts in to the script's last-S end
# (GP_SCRIPT_ARGS=--end-to-last-s; gp_session.port_script's default still ends at the X).
# The replay now dumps 4954 frames (0..4953) and its trace/win comparisons run through
# f=10068, so capture 5891..5903 are each byte-identical to port frames 4940..4952,
# consecutively: MIN_FIRST = 5904 (N = 5904 is
# the exact pin) and 5905 fails. The window reach is port 4951 (port 4952 is exhibited by
# no content-bearing capture frame; music truth §M2.7 re-measured it 4952 -> 4951 after the
# entry loader became a clean match), so MIN_REACH = 4951 and 4952 fails. Trace and win
# fields agree through f=10068, so their end pins are 10069 and 10070 fails. The window's
# claim is the same narrow one: no content-bearing capture frame is unexplained (each is a
# clean, splice or named-allowance match against the replay's own states), not that the
# ending's animation content is reproduced. gp_win.py finds 30/30 milestones including BLIZZARD
# c0=1, and the capture has 5904 frames with this poll.log sha256 (unchanged since the
# capture: e581916f..2b69c6).
# An absent capture skips (gp-replay's GP_OPTIONAL=1, gp_win.py's no-poll.log skip); a present
# capture with empty/changed pins fails. Raise MIN_FIRST/TRACE_MIN_FIRST/MIN_REACH/MILESTONES/
# WIN_MIN_FIRST when they improve.
# MUSIC TRUTH §M2.7 (2026-10-25): as gp-u9-win, capture 2188 is a CLEAN match now (the entry's
# music resolve presents the loader in the replay's own frame; OVERLAY_ALLOWED retired).
# Re-measured: MAX_MISSING 59 -> 57 and MIN_REACH 4952 -> 4951; MIN_FIRST/START/TRACE/WIN are
# exact and unchanged.
# >>> PIN-BASELINE-BEGIN gp-u13
GP_U13_MIN_FIRST = 5904
GP_U13_TRACE_MIN_FIRST = 10069
GP_U13_MAX_START = 96
GP_U13_MIN_REACH = 4951
GP_U13_CAPTURE_SHA256 = e581916fa0a8e23a2ea52519aa0b358e4bc63084ce9defa0e1f733f3d12b69c6
GP_U13_CAPTURE_FRAMES = 5904
GP_U13_MILESTONES = 30
GP_U13_WIN_MIN_FIRST = 10069
# <<< PIN-BASELINE-END gp-u13
gp-u13-oracle: build ## Gameplay U13 oracle: gp-u13-ending2's non-character-0 ending (skips without data/k11-captures/gp-u13-ending2)
	@echo "== gameplay oracle: gp-u13-ending2 (BLIZZARD's ending under pokes) =="
	@$(PYTHON) -m unittest tools.tests.test_gp_win
	@if [ ! -d $(K11_CAPTURES)/gp-u13-ending2 ]; then echo "gp-u13-oracle: no capture at $(K11_CAPTURES)/gp-u13-ending2 (skipped)"; else \
		$(PYTHON) tools/gp_win.py check --scenario gp-u13-ending2 --capture $(K11_CAPTURES)/gp-u13-ending2 \
			--capture-sha256 "$(GP_U13_CAPTURE_SHA256)" && \
		$(MAKE) --no-print-directory gp-replay scenario=gp-u13-ending2 GP_OPTIONAL=1 GP_SCRIPT_ARGS=--end-to-last-s && \
		$(PYTHON) tools/gp_compare.py --scenario gp-u13-ending2 --capture $(K11_CAPTURES)/gp-u13-ending2 \
			--port $(GP_DUMP)/gp-u13-ending2 --min-first "$(GP_U13_MIN_FIRST)" \
			--trace-min-first "$(GP_U13_TRACE_MIN_FIRST)" --max-start "$(GP_U13_MAX_START)" \
			--min-reach "$(GP_U13_MIN_REACH)" --max-missing "$(GP_U13_MAX_MISSING)" \
			--capture-sha256 "$(GP_U13_CAPTURE_SHA256)" --capture-frames "$(GP_U13_CAPTURE_FRAMES)" && \
		$(PYTHON) tools/gp_win.py path --scenario gp-u13-ending2 --capture $(K11_CAPTURES)/gp-u13-ending2 \
			--port $(GP_DUMP)/gp-u13-ending2 --min-milestones "$(GP_U13_MILESTONES)" \
			--win-min-first "$(GP_U13_WIN_MIN_FIRST)" --capture-sha256 "$(GP_U13_CAPTURE_SHA256)"; \
		rc=$$?; [ -n "$(GP_WIN_KEEP)" ] || rm -rf $(GP_DUMP)/gp-u13-ending2; exit $$rc; fi

# Gameplay truth II U14 (plan docs/superpowers/plans/2026-10-20-gameplay-truth-ii.md Task 6;
# gp_session.SCENARIOS['gp-u14-win-live'] and gp_win.MILESTONES['gp-u14-win-live']): a match
# won by fought hits, without the WIN_MENU pokes (spec W5). The menu path is gp-idle-loss's
# with the character-select confirm of character 0. The script is the measured 160-attempt
# form (plan docs/superpowers/plans/2026-10-26-fought-match-truth.md §7.1; record
# 2026-10-23-endgame-truth §E8 and 2026-10-20-gameplay-truth-ii §J6): two rounds of the U16
# builder's `_u16_round(160, 10, anchor=(0x06, 1/2))` — the two 1A/1B 1-frame normals
# (tools/gp_moves.py's rows) 16 frames apart, held 4, with a 12-frame p1.right walk every 8
# attempts — `time_limit=260`, a whole-capture stop on mode 0x12 (`stop_mode`, STOP_AT_END)
# and `skip_on_mode=(0x07, 0x08, 0x09)`: a round's queued presses are dropped at its round,
# match or time-up result and the scheduler resumes at the next anchor (without it the ~72
# presses the U23 run's attempt-88 KO left over fired onto the result screen and overran the
# end, record §E2; mode 7 is the fix round's addition, since a timed-out round is a failed
# run but its presses must not overrun the end either).
# Its claim is the poked wins' narrow one plus what a poke could not claim: the game itself
# raises P2's +0x5A to 0x78, reaches the same mode-8/mode-9 entries and the land mark. Final
# truth Task 3 corrected the rule (record 2026-10-30-final-truth-derivations.md §F3): a
# match-1 win has NO b0c clause, because the byte's only consumer is the final's
# 0x0C -> 0x0D chain (game_mode_0d_step 0x274FC; flow_arena_ko_check 0x272DC /
# game_mode_0c_step 0x27380), and a 0x09 -> 0x17 -> 0x12 match win never enters it (mode
# 0x17's setup clears the byte at 0x27200). gp_win.live_match
# requires the live KO and the 2-0 match result with no poke and no time-out; the final-only
# rule (gp_win.live_final) keeps b0c = 1 at/after mode 0x0F and has no scenario yet. The
# U9/U10 DEATH_DONE pokes stay the poked wins' writes. spec W5's shared-code limit still
# applies — the same path's
# gp-u9-win (first mode-6 S f=0x480) and gp-u10-ending (f=0x479) captures have c1 = 6 (CHAOS),
# afc = 5, while the failed 2026-10-26 run measured c0 = 0, c1 = 3 (VERTIGO), afc = 3 (record
# 2026-10-26-fought-match-truth §F2.6): the KO'd fighter's death stream is not U10's final
# SAURON's, and a later capture's own S fields decide it.
# The GP_U14_* pins are EMPTY: the 2026-10-26 arcade run lost 0-2 (P1 KO'd in both rounds,
# mode 0x12 never opened; record 2026-10-26-fought-match-truth §F2). Final truth Task 3's one
# authorized two-human gp-u14-twop-win run reached the game's 2-0 win (round-1 KO f=0xB1B,
# match result f=0x13A8, mode 0x12 f=0x1693, no W, b0c=0); its only failed check was the
# port-script model's S(f-1) bitmap pin after the 3-snapshot gap S(0xB18..0xB1A).
# Completion truth Task 1 fixed the model and promoted it to data/k11-captures/gp-u14-twop-win
# with the gp-u14-twop-win-oracle/GP_U14_TWOP_WIN_* pins; the .failed artifact is preserved
# (record 2026-10-30-final-truth-derivations.md §F3.5 and completion truth Task 1). R1 (the
# arcade fought win) is terminal, so gp-u14-win-live stays absent and GATE_NAMED_GAPS keeps it
# declared. The failed arcade
# artifact is preserved as data/k11-captures/gp-u14-win-live.failed (the published target stays
# absent); the run also failed the port-script/unscripted-input/end-frame checks after a
# BIOS-ring burst. The
# phase's gp-u24-u16-state scenario stays diagnostic and outside CAPTURE_DIRS. gp-twop-ko
# failed that phase's harness pin but is now published by live truth Task 3 above; both
# scenarios' .dro traces and the failed U14 run's (data/k11-captures/gp-u14-win-live.failed)
# stay outside tools/audio_compare.py's 29-trace inventory (unattributed; no audio claim). The target
# skips on the absent directory. The nine GP_U14_* pins belong to this gp-u14-win-live
# scenario; R1 is terminal, and a two-human gp-u14-twop-win capture is a different scenario
# with its own gp-u14-twop-win-oracle/GP_U14_TWOP_WIN_* pins (completion truth Task 1). A later
# authorized capture of this scenario would fill the nine
# from tools/gp_compare.py --report's and gp_win.py path's output, not arithmetic (MAX_MISSING
# is the report's missing-class count; coverage_check fails closed without it).
# MIN_FIRST the first unexplained capture frame,
# TRACE_MIN_FIRST the first differing traced f, MAX_START the window's first capture frame,
# MIN_REACH the replay's last exhibited port frame, MILESTONES/WIN_MIN_FIRST gp_win.py path's
# two ratchets, and the capture identity (poll.log sha256 and the frame_*.raw.gz count). Raise
# MIN_FIRST/TRACE_MIN_FIRST/MIN_REACH/MILESTONES/WIN_MIN_FIRST when they improve.
# >>> PIN-BASELINE-BEGIN gp-u14
GP_U14_MIN_FIRST =
GP_U14_TRACE_MIN_FIRST =
GP_U14_MAX_START =
GP_U14_MIN_REACH =
GP_U14_MAX_MISSING =
GP_U14_CAPTURE_SHA256 =
GP_U14_CAPTURE_FRAMES =
GP_U14_MILESTONES =
GP_U14_WIN_MIN_FIRST =
# <<< PIN-BASELINE-END gp-u14
# Completion truth Task 1 (R03): the two-human fought win's pins, measured on the
# promoted gp-u14-twop-win capture and its full port replay (record
# docs/superpowers/plans/2026-11-02-completion-100.md Task 1): MIN_FIRST 5072
# (the capture's last two post-script tail frames are the first unexplained),
# TRACE/MOVES 5780 (0 differing through the replay's end f=0x1693=5779), MAX_START
# 100 (gp_compare's window start), MIN_REACH 4269 (the replay's last exhibited
# frame), MAX_MISSING 10 (gp_compare's measured missing classes), and the capture
# identity (poll.log sha256 + 5,074 frame_*.raw.gz). MILESTONES 6 and WIN 5780
# are gp_win.py path's two ratchets (start_char(2,1)+_MATCH1; WIN_FIELDS 0
# differing through 5779). Raise them when they improve.
# >>> PIN-BASELINE-BEGIN gp-u14-twop-win
GP_U14_TWOP_WIN_MIN_FIRST = 5072
GP_U14_TWOP_WIN_TRACE_MIN_FIRST = 5780
GP_U14_TWOP_WIN_MOVES_MIN_FIRST = 5780
GP_U14_TWOP_WIN_MAX_START = 100
GP_U14_TWOP_WIN_MIN_REACH = 4269
GP_U14_TWOP_WIN_MAX_MISSING = 10
GP_U14_TWOP_WIN_CAPTURE_SHA256 = a96ca6f5de662159f43d61ae939c65be64d01dbed5441b324a12e8bfcfe728d9
GP_U14_TWOP_WIN_CAPTURE_FRAMES = 5074
GP_U14_TWOP_WIN_MILESTONES = 6
GP_U14_TWOP_WIN_WIN_MIN_FIRST = 5780
# <<< PIN-BASELINE-END gp-u14-twop-win
gp-u14-oracle: build ## Gameplay U14 oracle: gp-u14-win-live's fought win (skips without data/k11-captures/gp-u14-win-live)
	@echo "== gameplay oracle: gp-u14-win-live (a match won by fought hits) =="
	@$(PYTHON) -m unittest tools.tests.test_gp_win
	@if [ ! -d $(K11_CAPTURES)/gp-u14-win-live ]; then echo "gp-u14-oracle: no capture at $(K11_CAPTURES)/gp-u14-win-live (skipped)"; else \
		$(PYTHON) tools/gp_win.py check --scenario gp-u14-win-live --capture $(K11_CAPTURES)/gp-u14-win-live \
			--capture-sha256 "$(GP_U14_CAPTURE_SHA256)" && \
		$(MAKE) --no-print-directory gp-replay scenario=gp-u14-win-live GP_OPTIONAL=1 && \
		$(PYTHON) tools/gp_compare.py --scenario gp-u14-win-live --capture $(K11_CAPTURES)/gp-u14-win-live \
			--port $(GP_DUMP)/gp-u14-win-live --min-first "$(GP_U14_MIN_FIRST)" \
			--trace-min-first "$(GP_U14_TRACE_MIN_FIRST)" --max-start "$(GP_U14_MAX_START)" \
			--min-reach "$(GP_U14_MIN_REACH)" --max-missing "$(GP_U14_MAX_MISSING)" \
			--capture-sha256 "$(GP_U14_CAPTURE_SHA256)" --capture-frames "$(GP_U14_CAPTURE_FRAMES)" && \
		$(PYTHON) tools/gp_win.py path --scenario gp-u14-win-live --capture $(K11_CAPTURES)/gp-u14-win-live \
			--port $(GP_DUMP)/gp-u14-win-live --min-milestones "$(GP_U14_MILESTONES)" \
			--win-min-first "$(GP_U14_WIN_MIN_FIRST)" --capture-sha256 "$(GP_U14_CAPTURE_SHA256)"; \
		rc=$$?; [ -n "$(GP_WIN_KEEP)" ] || rm -rf $(GP_DUMP)/gp-u14-win-live; exit $$rc; fi

# Completion truth Task 1 (R03): the two-human fought win. The capture is the
# 2026-10-30 artifact promoted after the port-script model fix (plan
# docs/superpowers/plans/2026-11-02-completion-100.md Task 1): its 11 session
# checks were re-issued from the fixed model and read ok, the poll.log sha256 and
# the 5,074 frames are unchanged. gp_twop.py check --win is the two-human game
# evidence (b1f = 3, no W, both live KOs, the 1-0 round and 2-0 match results, the
# X); gp_win.py check/path the match rule, the six milestones (start_char(2,1) +
# _MATCH1) and the WIN_FIELDS ratchet; gp_compare the frame/trace/moves ratchets.
# The claim is the game's own 2-0 two-human win, not an arcade win (R04 terminal).
gp-u14-twop-win-oracle: build ## Gameplay U14 two-human win oracle (skips without data/k11-captures/gp-u14-twop-win)
	@echo "== gameplay oracle: gp-u14-twop-win (the game's own 2-0 two-human win) =="
	@$(if $(GP_U14_TWOP_WIN_MOVES_MIN_FIRST),:,$(error gp-u14-twop-win-oracle: GP_U14_TWOP_WIN_MOVES_MIN_FIRST is not set))
	@$(PYTHON) -m unittest tools.tests.test_gp_win tools.tests.test_gp_twop tools.tests.test_gp_session
	@if [ ! -d $(K11_CAPTURES)/gp-u14-twop-win ]; then echo "gp-u14-twop-win-oracle: no capture at $(K11_CAPTURES)/gp-u14-twop-win (skipped)"; else \
		$(PYTHON) tools/gp_twop.py check --win --capture $(K11_CAPTURES)/gp-u14-twop-win && \
		$(MAKE) --no-print-directory gp-win-one scenario=gp-u14-twop-win GP_WIN_ID=U14_TWOP_WIN; \
	fi

# Gameplay truth II U15 (plan docs/superpowers/plans/2026-10-20-gameplay-truth-ii.md Task 7;
# gp_session.SCENARIOS['gp-u15-join']): P2 joins the gp-idle-loss fight mid-round with F2.
# Mode 6's caller 0x2525F runs the join poll 0x28CC8: side 1's start mask DS_0009ACBC[1]
# is newly pressed in DS_001088E4, 0x28D13 sets b1f and 0x28D19 calls the credit spend
# 0x2CA7C, which 0x2CA93 skips while b1f != 0 (the free-credit join), and 0x28DA4 stores
# the join's own mode 0x17 for 0x78 frames (0x28E75). The scenario ends 60 frames into
# that window, so gp_twop.py check --join judges the join's evidence (the record before
# it mode 6, the join record mode 0x17, the joining side's start mask in the newly-
# pressed word, b1f 1 -> 3, no debit) instead of the two-human fight claim: no fight
# frame follows the join, and the CPU command block still writes the joining side's
# word in 0x17 (gp-keys-fight f=0x85A..0x85C: e2 = 0 while the pad holds 0x100). The
# pins below are measured on data/k11-captures/gp-u15-join (record gameplay-truth-ii §J7):
# the target skips on the absent capture directory (`[ ! -d ]`), and a capture whose
# poll.log sha256 differs fails in gp_compare's sha check. The capture missed game frames
# 0x832/0x833 (harness, spec §3.7), where F2's start bit rose, so the join check reads the
# press as the held edge across them (record §J7: the raw `new` bit was not sampled).
# Measured report (gp_compare --report --max-unexplained 0, replay of the capture's script
# to its X record f=0x86D): window from capture 90 (raw 1745), 707 classified (492 clean,
# 214 splice, 1 transition, 0 unexplained, 8 all-black), "0 unexplained through 804";
# trace/moves "0 differing through 2157". The replay has no first unexplained and no first
# trace difference, so MIN_FIRST and TRACE_MIN_FIRST are the window bounds (gp_compare's
# "exact pin": the end of the compared range, N unreachable above it), not a divergence:
# MIN_FIRST = 805 (frames through 804 compared) and TRACE_MIN_FIRST = 2158 (traced f
# through 2157 compared; the controller's 2157 passes too, and 2158 is gp_compare's
# own "N = 2158 is the exact pin"). Their +1 fails as "N is unreachable"; the claim is
# that the scripted end was reached with nothing unexplained inside the window.
# MAX_START = 90 is
# the window's first capture frame. MIN_REACH = 590 is the replay's last exhibited port
# frame (the report's "up to port 590"; it is not the capture's 805: the port's frame
# numbering differs and the window's interior and order are not claimed). The capture
# identity is the poll.log sha256 and the 805 frame_*.raw.gz.
GP_U15_MIN_FIRST = 805
GP_U15_TRACE_MIN_FIRST = 2158
GP_U15_MAX_START = 90
GP_U15_MIN_REACH = 590
GP_U15_CAPTURE_SHA256 = 5f7ab1d72c7893e6fd66e8db143b615e6d3b385122ae0a93b9037ad1ffe3664c
GP_U15_CAPTURE_FRAMES = 805
gp-u15-oracle: build ## Gameplay U15 oracle: gp-u15-join's mid-round join (skips without data/k11-captures/gp-u15-join)
	@echo "== gameplay oracle: gp-u15-join (a mid-round join) =="
	@$(PYTHON) -m unittest tools.tests.test_gp_twop
	@if [ ! -d $(K11_CAPTURES)/gp-u15-join ]; then echo "gp-u15-oracle: no capture at $(K11_CAPTURES)/gp-u15-join (skipped)"; else \
		$(PYTHON) tools/gp_twop.py check --join --capture $(K11_CAPTURES)/gp-u15-join && \
		$(MAKE) --no-print-directory gp-replay scenario=gp-u15-join GP_OPTIONAL=1 && \
		$(PYTHON) tools/gp_compare.py --scenario gp-u15-join --capture $(K11_CAPTURES)/gp-u15-join \
			--port $(GP_DUMP)/gp-u15-join --min-first "$(GP_U15_MIN_FIRST)" \
			--trace-min-first "$(GP_U15_TRACE_MIN_FIRST)" --max-start "$(GP_U15_MAX_START)" \
			--min-reach "$(GP_U15_MIN_REACH)" --max-missing "$(GP_U15_MAX_MISSING)" \
			--capture-sha256 "$(GP_U15_CAPTURE_SHA256)" --capture-frames "$(GP_U15_CAPTURE_FRAMES)"; \
		rc=$$?; [ -n "$(GP_WIN_KEEP)" ] || rm -rf $(GP_DUMP)/gp-u15-join; exit $$rc; fi

# Gameplay truth II U16 (plan docs/superpowers/plans/2026-10-20-gameplay-truth-ii.md Task 8;
# gp_session.SCENARIOS['gp-u16-twop2']): gp-twop's two-human start played past its script
# end to the match result (mode 0x09; mode 8 is the undecided round's result) and on to the
# conquered-lands screen (mode 0x12), with TALON's three finisher entries (keyboard
# 0x15/0x16/0x17 -> reactions 0x32/0x33/0x34, the starters 0x37640/0x37774/0x37898; 0x17..0x19
# are characters 0/1/3/4/5/6's entries) attempted on the defeated P2 in mode 9. Each starter's
# gate is the other slot's +0x42 bit 5 and +0x43 bit 3 (0x3766E/0x37678); 0x36F10's arm A
# sets both (`word [slot+0x42] |= 0x820`, 0x36FD4) for the side the match result
# DS_00104AD4 beat (0x36F71..0x36F9A), reached from the health sync 0x34BE8 and its
# position gate 0x36E2C, and DS_00105B3A (the config word's bit 8, 0x20C9F) is 0 on the
# all-zero CMOS. The capture reaches modes 0x09 and 0x12, but `gp_twop.py check --finisher`
# found no 0x32..0x34 reaction after the three attempted TALON entries. R2: the finisher
# reaction remains a named gap; `--finisher --allow-missing-reaction` enforces the two-human
# match and both end modes without claiming a reaction. The S records carry c0/c1/r0/r1/s0_43
# and all five fields agree through the replay end, so pin the moves claim too.
# Provenance (Task 8, record gameplay-truth-ii §J8):
#   MIN_FIRST 8069: after the byte-pinned scan-out at capture 4089 and the loader overlay at
#     8066 (clean since §M2.7; the overlay name is retired), the first unexplained frame is 8069 (raw 11594), nearest port 6816. The capture
#     tail from this point is not claimed; this is the first frame outside the reproduced
#     window, not a claim that its screen is correct.
#   TRACE_MIN_FIRST / MOVES_MIN_FIRST 8637: 0 differences through f=8636; 8637 is the
#     compared end + 1 and an exact reach pin.
#   MAX_START 100: the report's window starts at capture 100 (raw 1743).
#   MIN_REACH 6818 (music truth §M2.7: 6817 -> 6818): highest port frame exhibited before the
#   first unexplained capture frame.
#   CAPTURE_SHA256 / CAPTURE_FRAMES: this capture's poll.log and 8091 raw.gz frame files.
# Raising either N pin, the reach or frame count by one, lowering MAX_START by one, or
# changing the sha must fail; remeasure all values for a replacement capture.
# MUSIC TRUTH §M2.7 (2026-10-25): as gp-u9-win, capture 8066 is a CLEAN match now (the entry's
# music resolve presents the loader in the replay's own frame; OVERLAY_ALLOWED retired).
# Re-measured: MIN_REACH 6817 -> 6818 (the comparison's alignment moved one port frame; the
# content claim improved); MAX_MISSING 23 and every N/START/TRACE/MOVES pin are exact and
# unchanged.
GP_U16_MIN_FIRST = 8069
GP_U16_TRACE_MIN_FIRST = 8637
GP_U16_MOVES_MIN_FIRST = 8637
GP_U16_MAX_START = 100
GP_U16_MIN_REACH = 6818
GP_U16_CAPTURE_SHA256 = 5d38ca1fc3a52bcd02d91df3d7f0ae94a0275f9531f8bd81ffacee5343d31513
GP_U16_CAPTURE_FRAMES = 8091
gp-u16-oracle: build ## Gameplay U16 oracle: gp-u16-twop2's extended two-human match and frame evidence (finisher reaction gap; skips without data/k11-captures/gp-u16-twop2)
	@echo "== gameplay oracle: gp-u16-twop2 (extended two-human match and frame evidence; finisher reaction gap) =="
	@$(PYTHON) -m unittest tools.tests.test_gp_twop
	@if [ ! -d $(K11_CAPTURES)/gp-u16-twop2 ]; then echo "gp-u16-oracle: no capture at $(K11_CAPTURES)/gp-u16-twop2 (skipped)"; else \
		$(PYTHON) tools/gp_twop.py check --finisher --allow-missing-reaction --capture $(K11_CAPTURES)/gp-u16-twop2 && \
		$(MAKE) --no-print-directory gp-replay scenario=gp-u16-twop2 GP_OPTIONAL=1 && \
		$(PYTHON) tools/gp_compare.py --scenario gp-u16-twop2 --capture $(K11_CAPTURES)/gp-u16-twop2 \
			--port $(GP_DUMP)/gp-u16-twop2 --min-first "$(GP_U16_MIN_FIRST)" \
			--trace-min-first "$(GP_U16_TRACE_MIN_FIRST)" --max-start "$(GP_U16_MAX_START)" \
			--moves-min-first "$(GP_U16_MOVES_MIN_FIRST)" \
			--min-reach "$(GP_U16_MIN_REACH)" --max-missing "$(GP_U16_MAX_MISSING)" \
			--capture-sha256 "$(GP_U16_CAPTURE_SHA256)" --capture-frames "$(GP_U16_CAPTURE_FRAMES)"; \
		rc=$$?; [ -n "$(GP_WIN_KEEP)" ] || rm -rf $(GP_DUMP)/gp-u16-twop2; exit $$rc; fi

# Endgame truth U23 (plan docs/superpowers/plans/2026-10-23-endgame-truth.md Task 2;
# gp_session.SCENARIOS['gp-u23-u16-finisher'], record §E2): the U16 finisher reaction captured
# under the plan's LABELLED FALLBACK. gp-twop's two-human start and a 32-attempt first round are
# gp-u16-twop2's; the second round pokes P2's damage byte 0x10789E to 0x77 at the second mode-6
# entry and lands 12 real 1A/1B attempts (the game's own damage takes it to the KO value 0x78). The
# poked damage is not the claim; the claim is at least one of the three finisher reactions 0x32..0x34
# in an S record's r0/r1, which `gp_twop.py check --finisher` (WITHOUT --allow-missing-reaction)
# enforces. The live KO was reached in six attempts (record §E2): 160 attempts/anchor 10 overran the
# result (ring full, no end frame); 88/anchor 10 applied 0x33 but collided one scan; 96/anchor 200
# KO'd but missed the gate window (0x27, no 0x33) and had an unpinned pad change; 88/anchor 100 had
# no KO; 92/anchor 100 applied 0x33 but collided one scan; 92/anchor 160 reached no end frame. The KO
# attempt is RNG-dependent (85-88), so the leftover attempts (up to 11 x 16 = 176 frames at
# 96-85) and the gate window both cannot be met by one anchor; the fallback is the remaining path.
# TALON's three finisher entries (0x15/0x16/0x17 -> 0x32/0x33/0x34, the starters
# 0x37640/0x37774/0x37898) are pressed at an anchor of 100 frames: the capture's second entry applies
# 0x33 at mode 9 + 213, so that is the measured gate window; the anchor is a harness value tuned to
# it, not a claim that the 176-frame leftover bound is met (the group can start after the leftovers).
# The raw +0x42/+0x43 gate bits are decomp evidence (record §E1.2), not S fields, so they are not
# claimed. The capture stops at the conquered-lands screen (mode 0x12, STOP_AT_END).
# Provenance (2026-10-23 fallback re-capture, 5464 frames, raw 1371..8555, wall 122.8 s):
#   MIN_FIRST 4095: the report's first unexplained capture frame (raw 6783, nearest port 3442,
#     rows 0..2, 510 px). It is not a loader overlay or a scan-out (the loader's 166-px six-row
#     strip is capture 5461, after the pin), so no allowance is added; the tail is not claimed.
#   TRACE_MIN_FIRST 6050: the state-coverage Task 10 fix (the reaction's record-motion
#     y now matches the original), so the trace fields agree through the replay's end
#     (0 differing through 6049); the exact pin is the end + 1 (record §D3.7). It was
#     5449, the first differing trace f in rng, before the fix.
#   MOVES_MIN_FIRST 6050: the same fix makes r0 reach 0x33 (f=0x155B), so the move
#     fields agree through the replay's end; the exact pin is the end + 1. It was 5467,
#     the first differing move frame (f=0x155B in r0: capture 0x33, port 0x27) before
#     the fix. The capture-side reaction claim is gp_twop's; this pin says only that
#     the move fields agree below 6050.
#   MAX_START 106: the report's window start (raw 1749).
#   MIN_REACH 3439: the highest port frame exhibited before the first unexplained capture frame.
#   CAPTURE_SHA256 / CAPTURE_FRAMES: this capture's poll.log sha256 and its 5464 frame_*.raw.gz.
# Raising a ratchet by one, lowering MAX_START by one, or changing the sha must fail; a
# re-capture invalidates every pin (record §G.24).
GP_U23_MIN_FIRST = 4095
GP_U23_TRACE_MIN_FIRST = 6050
GP_U23_MOVES_MIN_FIRST = 6050
GP_U23_MAX_START = 106
GP_U23_MIN_REACH = 3439
GP_U23_CAPTURE_SHA256 = 6684d3cbb8cfabeed190b1139fa9cc93d69944c6d2b3ef9f9f56db2e0cb34446
GP_U23_CAPTURE_FRAMES = 5464
gp-u23-oracle: build ## Endgame truth U23 oracle: the U16 finisher reaction (labelled poked-damage fallback) on gp-u23-u16-finisher (skips without its capture)
	@echo "== gameplay oracle: gp-u23-u16-finisher (the U16 finisher reaction; labelled fallback) =="
	@$(PYTHON) -m unittest tools.tests.test_gp_twop
	@if [ ! -d $(K11_CAPTURES)/gp-u23-u16-finisher ]; then echo "gp-u23-oracle: no capture at $(K11_CAPTURES)/gp-u23-u16-finisher (skipped)"; else \
		$(PYTHON) tools/gp_twop.py check --finisher --capture $(K11_CAPTURES)/gp-u23-u16-finisher && \
		$(MAKE) --no-print-directory gp-replay scenario=gp-u23-u16-finisher GP_OPTIONAL=1 && \
		$(PYTHON) tools/gp_compare.py --scenario gp-u23-u16-finisher --capture $(K11_CAPTURES)/gp-u23-u16-finisher \
			--port $(GP_DUMP)/gp-u23-u16-finisher --min-first "$(GP_U23_MIN_FIRST)" \
			--trace-min-first "$(GP_U23_TRACE_MIN_FIRST)" --max-start "$(GP_U23_MAX_START)" \
			--moves-min-first "$(GP_U23_MOVES_MIN_FIRST)" \
			--min-reach "$(GP_U23_MIN_REACH)" --max-missing "$(GP_U23_MAX_MISSING)" \
			--capture-sha256 "$(GP_U23_CAPTURE_SHA256)" --capture-frames "$(GP_U23_CAPTURE_FRAMES)"; \
		rc=$$?; [ -n "$(GP_WIN_KEEP)" ] || rm -rf $(GP_DUMP)/gp-u23-u16-finisher; exit $$rc; fi

# Endgame truth Task 3 (plan docs/superpowers/plans/2026-10-23-endgame-truth.md; record §E1.1,
# §E3): gp-u18-talon-ending is the ending of a character other than 0 or 1. P1 confirms
# character-select cell 1 (ending_menu(1): one p1.right), which 0xC8882 = 00 02 03 01 06 04 05
# maps to class 2 TALON (0x43D6E/0x43D74), whose ending is DS_0010782A[DS_00104AD4 * 0x94] = 2
# (title id 0xE0, TALON'S DREAM — a 0xA80E4 fact and the compared frames' content, not a WIN/S
# field). Its first poke writes TALON's seven land marks (0x82, not u10's SAURON 0x80: 0x41C28
# state 1 tallies (mark & 0x7F) == DS_0010782A[side] | side << 6, 0x41D39/0x41D5C), and the
# rest are gp-u10-ending's from its second poke on. `gp_win.py check` finds 30/30 milestones
# including the mode-0x1F row's ad4=0,c0=2 under the label "TALON's ending".
# Measured on the 2026-10-23 capture (5,749 frames, raw 1389..14354, wall 205.2 s, memsize=64):
# the window starts at capture 85 (raw 1748); every content-bearing capture frame through 5734
# is explained (3,682 clean, 1,935 splice, 9 transition) plus two named per-frame allowances,
# each its own byte recipe (tools/gp_compare.py): 4099/4147 are the final's mode-0x0D -> 0x0C
# transitions with three presents
# inside one capture scan (SCAN3_ALLOWED, the §G6 shape U13's 4248/4320 have). The first
# unexplained is 5735 (raw 14294): 5735..5748 are the post-script tail (the replay's dump ends
# at f=0x26E4, its trace and the capture's X at f=0x26E6) and are NOT claimed; MIN_FIRST = 5735,
# and 5736 fails (the ratchet fails only when the first unexplained precedes N). The
# comparison's window reach is port 4863, the highest exhibited port frame; the dump's last
# frame is index 4864 and is not exhibited (music truth §M2.7 re-measured 4864 -> 4863 after the
# entry loader became a clean match), so MIN_REACH = 4863; a higher measured reach is an
# improvement to re-pin. Trace and WIN fields
# agree through f=0x26E6 (9958), so their end pins are 9959; the moves comparison is
# reported by gp_compare (0 differing through 9958) and is not pinned. The capture has
# 5,749 frames with this poll.log sha256 (b3279414..d2fcf1).
# An absent capture skips (gp-replay's GP_OPTIONAL=1, gp_win.py's no-poll.log skip); a present
# capture with empty/changed pins fails. Raise MIN_FIRST/TRACE_MIN_FIRST/MIN_REACH/MILESTONES/
# WIN_MIN_FIRST when they improve.
# MUSIC TRUTH §M2.7 (2026-10-25): as gp-u9-win, capture 2227 is a CLEAN match now (the entry's
# music resolve presents the loader in the replay's own frame; OVERLAY_ALLOWED retired).
# Re-measured: MAX_MISSING 57 -> 55 and MIN_REACH 4864 -> 4863; MIN_FIRST/START/TRACE/WIN are
# exact and unchanged.
GP_U18_MIN_FIRST = 5735
GP_U18_TRACE_MIN_FIRST = 9959
GP_U18_MAX_START = 85
GP_U18_MIN_REACH = 4863
GP_U18_CAPTURE_SHA256 = b3279414514aee07d2d4cddada0b63c7ec90f68d905f6509deb3e5c955d2fcf1
GP_U18_CAPTURE_FRAMES = 5749
GP_U18_MILESTONES = 30
GP_U18_WIN_MIN_FIRST = 9959
gp-u18-oracle: build ## Endgame truth U18 oracle: TALON's ending (character 2, cell 1; skips without data/k11-captures/gp-u18-talon-ending)
	@echo "== gameplay oracle: gp-u18-talon-ending (TALON's ending under pokes) =="
	@$(PYTHON) -m unittest tools.tests.test_gp_win
	@if [ ! -d $(K11_CAPTURES)/gp-u18-talon-ending ]; then echo "gp-u18-oracle: no capture at $(K11_CAPTURES)/gp-u18-talon-ending (skipped)"; else \
		$(PYTHON) tools/gp_win.py check --scenario gp-u18-talon-ending --capture $(K11_CAPTURES)/gp-u18-talon-ending \
			--capture-sha256 "$(GP_U18_CAPTURE_SHA256)" && \
		$(MAKE) --no-print-directory gp-replay scenario=gp-u18-talon-ending GP_OPTIONAL=1 && \
		$(PYTHON) tools/gp_compare.py --scenario gp-u18-talon-ending --capture $(K11_CAPTURES)/gp-u18-talon-ending \
			--port $(GP_DUMP)/gp-u18-talon-ending --min-first "$(GP_U18_MIN_FIRST)" \
			--trace-min-first "$(GP_U18_TRACE_MIN_FIRST)" --max-start "$(GP_U18_MAX_START)" \
			--min-reach "$(GP_U18_MIN_REACH)" --max-missing "$(GP_U18_MAX_MISSING)" \
			--capture-sha256 "$(GP_U18_CAPTURE_SHA256)" --capture-frames "$(GP_U18_CAPTURE_FRAMES)" && \
		$(PYTHON) tools/gp_win.py path --scenario gp-u18-talon-ending --capture $(K11_CAPTURES)/gp-u18-talon-ending \
			--port $(GP_DUMP)/gp-u18-talon-ending --min-milestones "$(GP_U18_MILESTONES)" \
			--win-min-first "$(GP_U18_WIN_MIN_FIRST)" --capture-sha256 "$(GP_U18_CAPTURE_SHA256)"; \
		rc=$$?; [ -n "$(GP_WIN_KEEP)" ] || rm -rf $(GP_DUMP)/gp-u18-talon-ending; exit $$rc; fi

# Endgame truth Task 4 (plan docs/superpowers/plans/2026-10-23-endgame-truth.md; record §E1.1,
# §E4): gp-u19-vertigo-ending is VERTIGO's ending. P1 confirms character-select cell 2
# (ending_menu(2): two p1.right presses), which 0xC8882 = 00 02 03 01 06 04 05 maps to class 3
# VERTIGO (0x43D6E/0x43D74), whose ending is DS_0010782A[DS_00104AD4 * 0x94] = 3 (title id 0xE1,
# THE PALACE OF VERTIGO — a 0xA80E4 fact and the compared frames' content, not a WIN/S field).
# Its first poke writes VERTIGO's seven land marks (0x83, not u10's SAURON 0x80: 0x41C28 state 1
# tallies (mark & 0x7F) == DS_0010782A[side] | side << 6, 0x41D39/0x41D5C), and the rest are
# gp-u10-ending's from its second poke on. `gp_win.py check` finds 30/30 milestones including the
# mode-0x1F row's ad4=0,c0=3 under the label "VERTIGO's ending".
# Measured on the 2026-10-23 capture (6,004 frames, raw 1391..14418, wall 206.1 s, memsize=64):
# the window starts at capture 85 (raw 1750); every content-bearing capture frame through 5990 is
# explained (3,385 clean, 2,487 splice, 9 transition) plus three named per-frame allowances, each
# its own byte recipe (tools/gp_compare.py): 2478 is the health-bonus screen's (mode 0x23)
# three-present scan (port
# 2022/2023/2024) and 4362/4430 are the final's three-present scans (ports 3577/3578/3579 and
# 3640/3641/3642; SCAN3_ALLOWED, the §G6 shape U13's 4248/4320 have). The first unexplained is
# 5991 (raw 14362): 5991..6003 are the post-script tail (the replay's dump ends at port frame 4989,
# f=0x271A, and its trace at f=0x271B, where the capture's X record sits) and are NOT claimed;
# MIN_FIRST = 5991, and 5992 fails (the ratchet fails only when the first unexplained precedes N).
# The comparison's window reach is port 4988, the highest exhibited port frame; the dump's last
# frame is index 4989 and is not exhibited (music truth §M2.7 re-measured 4989 -> 4988 after the
# entry loader became a clean match), so MIN_REACH = 4988; a higher measured reach is an
# improvement to re-pin. Trace and WIN fields agree through f=0x271B (10011), so
# their end pins are 10012; the moves comparison is reported by gp_compare (0 differing through
# 10011) and is not pinned. The capture has 6,004 frames with this poll.log sha256
# (31da347f..c730fd69).
# An absent capture skips (gp-replay's GP_OPTIONAL=1, gp_win.py's no-poll.log skip); a present
# capture with empty/changed pins fails. Raise MIN_FIRST/TRACE_MIN_FIRST/MIN_REACH/MILESTONES/
# WIN_MIN_FIRST when they improve.
# MUSIC TRUTH §M2.7 (2026-10-25): as gp-u9-win, capture 2312 is a CLEAN match now (the entry's
# music resolve presents the loader in the replay's own frame; OVERLAY_ALLOWED retired).
# Re-measured: MAX_MISSING 51 -> 49 and MIN_REACH 4989 -> 4988; MIN_FIRST/START/TRACE/WIN are
# exact and unchanged.
GP_U19_MIN_FIRST = 5991
GP_U19_TRACE_MIN_FIRST = 10012
GP_U19_MAX_START = 85
GP_U19_MIN_REACH = 4988
GP_U19_CAPTURE_SHA256 = 31da347f1e637e3d384936e7b9473cfba7734f8a4216af00236e74d0c730fd69
GP_U19_CAPTURE_FRAMES = 6004
GP_U19_MILESTONES = 30
GP_U19_WIN_MIN_FIRST = 10012
gp-u19-oracle: build ## Endgame truth U19 oracle: VERTIGO's ending (character 3, cell 2; skips without data/k11-captures/gp-u19-vertigo-ending)
	@echo "== gameplay oracle: gp-u19-vertigo-ending (VERTIGO's ending under pokes) =="
	@$(PYTHON) -m unittest tools.tests.test_gp_win
	@if [ ! -d $(K11_CAPTURES)/gp-u19-vertigo-ending ]; then echo "gp-u19-oracle: no capture at $(K11_CAPTURES)/gp-u19-vertigo-ending (skipped)"; else \
		$(PYTHON) tools/gp_win.py check --scenario gp-u19-vertigo-ending --capture $(K11_CAPTURES)/gp-u19-vertigo-ending \
			--capture-sha256 "$(GP_U19_CAPTURE_SHA256)" && \
		$(MAKE) --no-print-directory gp-replay scenario=gp-u19-vertigo-ending GP_OPTIONAL=1 && \
		$(PYTHON) tools/gp_compare.py --scenario gp-u19-vertigo-ending --capture $(K11_CAPTURES)/gp-u19-vertigo-ending \
			--port $(GP_DUMP)/gp-u19-vertigo-ending --min-first "$(GP_U19_MIN_FIRST)" \
			--trace-min-first "$(GP_U19_TRACE_MIN_FIRST)" --max-start "$(GP_U19_MAX_START)" \
			--min-reach "$(GP_U19_MIN_REACH)" --max-missing "$(GP_U19_MAX_MISSING)" \
			--capture-sha256 "$(GP_U19_CAPTURE_SHA256)" --capture-frames "$(GP_U19_CAPTURE_FRAMES)" && \
		$(PYTHON) tools/gp_win.py path --scenario gp-u19-vertigo-ending --capture $(K11_CAPTURES)/gp-u19-vertigo-ending \
			--port $(GP_DUMP)/gp-u19-vertigo-ending --min-milestones "$(GP_U19_MILESTONES)" \
			--win-min-first "$(GP_U19_WIN_MIN_FIRST)" --capture-sha256 "$(GP_U19_CAPTURE_SHA256)"; \
		rc=$$?; [ -n "$(GP_WIN_KEEP)" ] || rm -rf $(GP_DUMP)/gp-u19-vertigo-ending; exit $$rc; fi

# Endgame truth Task 5 (plan docs/superpowers/plans/2026-10-23-endgame-truth.md; record §E1.1,
# §E5): gp-u20-chaos-ending is CHAOS's ending. P1 confirms character-select cell 4
# (ending_menu(4): four p1.right presses), which 0xC8882 = 00 02 03 01 06 04 05 maps to class 6
# CHAOS (0x43D6E/0x43D74), whose ending is DS_0010782A[DS_00104AD4 * 0x94] = 6 (title id 0xE4,
# CHAOS' REDEMPTION — a 0xA80E4 fact and the compared frames' content, not a WIN/S field).
# Its first poke writes CHAOS's seven land marks (0x86, not u10's SAURON 0x80: 0x41C28 state 1
# tallies (mark & 0x7F) == DS_0010782A[side] | side << 6, 0x41D39/0x41D5C), and the rest are
# gp-u10-ending's from its second poke on. `gp_win.py check` finds 30/30 milestones including the
# mode-0x1F row's ad4=0,c0=6 under the label "CHAOS's ending".
# Measured on the 2026-10-23 capture (5,790 frames, raw 1396..14493, wall 207.2 s, memsize=64):
# the window starts at capture 83 (raw 1750); every content-bearing capture frame through 5775 is
# explained (3,400 clean, 2,259 splice, 8 transition) plus three named per-frame allowances, each
# its own byte recipe (tools/gp_compare.py): 381 is round 1's mode-8 three-present scan (row 0 port
# 260's, rows 1..157 port 263's, 158..199 port 264's), 2350 is the health-bonus screen's (mode 0x23)
# three-present scan (port 1932/1933) and 4272 is the final's mode-0x0D -> 0x0C three-present scan
# (ports 3566/3567/3568; SCAN3_ALLOWED, the §G6 shape). The first unexplained is 5776 (raw
# 14433): 5776..5789 are the post-script tail (the replay's dump ends at port frame 4890, f=0x2753,
# and its trace at f=0x2755, where the capture's X record sits) and are NOT claimed; MIN_FIRST =
# 5776, and 5777 fails (the ratchet fails only when the first unexplained precedes N). The
# comparison's window reach is port 4889, the highest exhibited port frame; the dump's last frame
# is index 4890 and is not exhibited (music truth §M2.7 re-measured 4890 -> 4889 after the entry
# loader became a clean match), so MIN_REACH = 4889; a higher measured reach is an improvement to
# re-pin. Trace and WIN fields agree through f=0x2755 (10069), so
# their end pins are 10070; the moves comparison is reported by gp_compare (0 differing through
# 10069) and is not pinned. The capture has 5,790 frames with this poll.log sha256
# (73b5bb80..ca7cb).
# An absent capture skips (gp-replay's GP_OPTIONAL=1, gp_win.py's no-poll.log skip); a present
# capture with empty/changed pins fails. Raise MIN_FIRST/TRACE_MIN_FIRST/MIN_REACH/MILESTONES/
# WIN_MIN_FIRST when they improve.
# MUSIC TRUTH §M2.7 (2026-10-25): as gp-u9-win, capture 2180 is a CLEAN match now (the entry's
# music resolve presents the loader in the replay's own frame; OVERLAY_ALLOWED retired).
# Re-measured: MAX_MISSING 72 -> 70 and MIN_REACH 4890 -> 4889; MIN_FIRST/START/TRACE/WIN are
# exact and unchanged.
GP_U20_MIN_FIRST = 5776
GP_U20_TRACE_MIN_FIRST = 10070
GP_U20_MAX_START = 83
GP_U20_MIN_REACH = 4889
GP_U20_CAPTURE_SHA256 = 73b5bb808dee603c1e5a359e2716c9654db945737a799cee30c00762699ca7cb
GP_U20_CAPTURE_FRAMES = 5790
GP_U20_MILESTONES = 30
GP_U20_WIN_MIN_FIRST = 10070
gp-u20-oracle: build ## Endgame truth U20 oracle: CHAOS's ending (character 6, cell 4; skips without data/k11-captures/gp-u20-chaos-ending)
	@echo "== gameplay oracle: gp-u20-chaos-ending (CHAOS's ending under pokes) =="
	@$(PYTHON) -m unittest tools.tests.test_gp_win
	@if [ ! -d $(K11_CAPTURES)/gp-u20-chaos-ending ]; then echo "gp-u20-oracle: no capture at $(K11_CAPTURES)/gp-u20-chaos-ending (skipped)"; else \
		$(PYTHON) tools/gp_win.py check --scenario gp-u20-chaos-ending --capture $(K11_CAPTURES)/gp-u20-chaos-ending \
			--capture-sha256 "$(GP_U20_CAPTURE_SHA256)" && \
		$(MAKE) --no-print-directory gp-replay scenario=gp-u20-chaos-ending GP_OPTIONAL=1 && \
		$(PYTHON) tools/gp_compare.py --scenario gp-u20-chaos-ending --capture $(K11_CAPTURES)/gp-u20-chaos-ending \
			--port $(GP_DUMP)/gp-u20-chaos-ending --min-first "$(GP_U20_MIN_FIRST)" \
			--trace-min-first "$(GP_U20_TRACE_MIN_FIRST)" --max-start "$(GP_U20_MAX_START)" \
			--min-reach "$(GP_U20_MIN_REACH)" --max-missing "$(GP_U20_MAX_MISSING)" \
			--capture-sha256 "$(GP_U20_CAPTURE_SHA256)" --capture-frames "$(GP_U20_CAPTURE_FRAMES)" && \
		$(PYTHON) tools/gp_win.py path --scenario gp-u20-chaos-ending --capture $(K11_CAPTURES)/gp-u20-chaos-ending \
			--port $(GP_DUMP)/gp-u20-chaos-ending --min-milestones "$(GP_U20_MILESTONES)" \
			--win-min-first "$(GP_U20_WIN_MIN_FIRST)" --capture-sha256 "$(GP_U20_CAPTURE_SHA256)"; \
		rc=$$?; [ -n "$(GP_WIN_KEEP)" ] || rm -rf $(GP_DUMP)/gp-u20-chaos-ending; exit $$rc; fi

# Endgame truth Task 6 (plan docs/superpowers/plans/2026-10-23-endgame-truth.md; record §E1.1,
# §E6): gp-u21-armadon-ending is ARMADON's ending. P1 confirms character-select cell 5
# (ending_menu(5): five p1.right presses), which 0xC8882 = 00 02 03 01 06 04 05 maps to class 4
# ARMADON (0x43D6E/0x43D74), whose ending is DS_0010782A[DS_00104AD4 * 0x94] = 4 (title id 0xE2,
# THE MEDITATION OF ARMADON — a 0xA80E4 fact and the compared frames' content, not a WIN/S field).
# Its first poke writes ARMADON's seven land marks (0x84, not u10's SAURON 0x80: 0x41C28 state 1
# tallies (mark & 0x7F) == DS_0010782A[side] | side << 6, 0x41D39/0x41D5C), and the rest are
# gp-u10-ending's from its second poke on. `gp_win.py check` finds 30/30 milestones including the
# mode-0x1F row's ad4=0,c0=4 under the label "ARMADON's ending".
# Measured on the 2026-10-23 capture (5,946 frames, raw 1414..14516, wall 208.0 s, memsize=64):
# the window starts at capture 90 (raw 1743); every content-bearing capture frame through 5931 is
# explained (3,599 clean, 2,149 splice, 59 transition) plus fifteen named per-frame allowances,
# each its own byte recipe (tools/gp_compare.py): fifteen BANDS_ALLOWED present-copy scan-outs —
# 426 and 756 on round 1's mode-8 screen (ports 291/292, 577/578), 4317 the final's mode-0x0D ->
# 0x0C entry (3610/3611), the eleven 4470..4560 the mode-0xF master screen (3740/3741 ..
# 3816/3817) and 5119 the ending's mode-0x15 fade (4300/4301, the U13 5120 class). The first unexplained is 5932 (raw 14456): 5932..5945 are the post-script tail (the
# replay's dump ends at port frame 4973, f=0x2753, and its trace at f=0x2755, where the capture's
# X record sits) and are NOT claimed; MIN_FIRST = 5932, and 5933 fails (the ratchet fails only when
# the first unexplained precedes N). The comparison's window reach is port 4972, the highest
# exhibited port frame; the dump's last frame is index 4973 and is not exhibited (music truth
# §M2.7 re-measured 4973 -> 4972 after the entry loader became a clean match), so MIN_REACH = 4972;
# a higher measured reach is an improvement to re-pin. Trace and WIN fields
# agree through f=0x2755 (10069), so their end pins are 10070; the moves comparison is reported by
# gp_compare (0 differing through 10069) and is not pinned. The capture has 5,946 frames with this
# poll.log sha256 (41e384a9..b1b42f).
# An absent capture skips (gp-replay's GP_OPTIONAL=1, gp_win.py's no-poll.log skip); a present
# capture with empty/changed pins fails. Raise MIN_FIRST/TRACE_MIN_FIRST/MIN_REACH/MILESTONES/
# WIN_MIN_FIRST when they improve.
# MUSIC TRUTH §M2.7 (2026-10-25): as gp-u9-win, capture 2217 is a CLEAN match now (the entry's
# music resolve presents the loader in the replay's own frame; OVERLAY_ALLOWED retired).
# Re-measured: MAX_MISSING 55 -> 53 and MIN_REACH 4973 -> 4972; MIN_FIRST/START/TRACE/WIN are
# exact and unchanged.
GP_U21_MIN_FIRST = 5932
GP_U21_TRACE_MIN_FIRST = 10070
GP_U21_MAX_START = 90
GP_U21_MIN_REACH = 4972
GP_U21_CAPTURE_SHA256 = 41e384a94049fc5c1c74934c802153dcdee230631d44522ef03f27935db1b42f
GP_U21_CAPTURE_FRAMES = 5946
GP_U21_MILESTONES = 30
GP_U21_WIN_MIN_FIRST = 10070
gp-u21-oracle: build ## Endgame truth U21 oracle: ARMADON's ending (character 4, cell 5; skips without data/k11-captures/gp-u21-armadon-ending)
	@echo "== gameplay oracle: gp-u21-armadon-ending (ARMADON's ending under pokes) =="
	@$(PYTHON) -m unittest tools.tests.test_gp_win
	@if [ ! -d $(K11_CAPTURES)/gp-u21-armadon-ending ]; then echo "gp-u21-oracle: no capture at $(K11_CAPTURES)/gp-u21-armadon-ending (skipped)"; else \
		$(PYTHON) tools/gp_win.py check --scenario gp-u21-armadon-ending --capture $(K11_CAPTURES)/gp-u21-armadon-ending \
			--capture-sha256 "$(GP_U21_CAPTURE_SHA256)" && \
		$(MAKE) --no-print-directory gp-replay scenario=gp-u21-armadon-ending GP_OPTIONAL=1 && \
		$(PYTHON) tools/gp_compare.py --scenario gp-u21-armadon-ending --capture $(K11_CAPTURES)/gp-u21-armadon-ending \
			--port $(GP_DUMP)/gp-u21-armadon-ending --min-first "$(GP_U21_MIN_FIRST)" \
			--trace-min-first "$(GP_U21_TRACE_MIN_FIRST)" --max-start "$(GP_U21_MAX_START)" \
			--min-reach "$(GP_U21_MIN_REACH)" --max-missing "$(GP_U21_MAX_MISSING)" \
			--capture-sha256 "$(GP_U21_CAPTURE_SHA256)" --capture-frames "$(GP_U21_CAPTURE_FRAMES)" && \
		$(PYTHON) tools/gp_win.py path --scenario gp-u21-armadon-ending --capture $(K11_CAPTURES)/gp-u21-armadon-ending \
			--port $(GP_DUMP)/gp-u21-armadon-ending --min-milestones "$(GP_U21_MILESTONES)" \
			--win-min-first "$(GP_U21_WIN_MIN_FIRST)" --capture-sha256 "$(GP_U21_CAPTURE_SHA256)"; \
		rc=$$?; [ -n "$(GP_WIN_KEEP)" ] || rm -rf $(GP_DUMP)/gp-u21-armadon-ending; exit $$rc; fi

# Endgame truth Task 7 (plan docs/superpowers/plans/2026-10-23-endgame-truth.md; record §E1.1,
# §E7): gp-u22-diablo-ending is DIABLO's ending. P1 confirms character-select cell 6
# (ending_menu(6): six p1.right presses), which 0xC8882 = 00 02 03 01 06 04 05 maps to class 5
# DIABLO (0x43D6E/0x43D74), whose ending is DS_0010782A[DS_00104AD4 * 0x94] = 5 (title id 0xE3,
# DIABLO'S HELL — a 0xA80E4 fact and the compared frames' content, not a WIN/S field).
# Its first poke writes DIABLO's seven land marks (0x85, not u10's SAURON 0x80: 0x41C28 state 1
# tallies (mark & 0x7F) == DS_0010782A[side] | side << 6, 0x41D39/0x41D5C), and the rest are
# gp-u10-ending's from its second poke on. `gp_win.py check` finds 30/30 milestones including the
# mode-0x1F row's ad4=0,c0=5 under the label "DIABLO's ending".
# Measured on the 2026-10-23 capture (5,811 frames, raw 1450..14592, wall 210.6 s, memsize=64):
# the window starts at capture 74 (raw 1737); every content-bearing capture frame through 5797 is
# explained (3,562 clean, 2,064 splice, 62 transition) plus fourteen named per-frame allowances,
# each its own byte recipe (tools/gp_compare.py): eight BANDS_ALLOWED present-copy scan-outs
# (1988/2014/2020 on the match-result screen, 5008/5013/5015/5016/5024 at the ending's mode-0x15
# fade) and six SCAN3_ALLOWED three-present scans (380 on round 1's mode-8 screen, 1267 on mode 5,
# 4136/4228 on the final and 5027/5033 at the fade — the recipe binding all 200 rows is used
# where one is unique). The first unexplained is 5798 (raw 14536): 5798..5810 are the
# post-script tail (the replay's dump ends at port frame 4929 and its trace at f=0x2769, where the
# capture's X record sits) and are NOT claimed; MIN_FIRST = 5798, and 5799 fails (the ratchet
# fails only when the first unexplained precedes N). The comparison's window reach is port 4928,
# the highest exhibited port frame; the dump's last frame is index 4929 and is not exhibited
# (music truth §M2.7 re-measured 4929 -> 4928 after the entry loader became a clean match), so
# MIN_REACH = 4928; a higher measured reach is an improvement to re-pin.
# Trace and WIN fields agree through f=0x2769 (10089), so their end pins are 10090; the moves
# comparison is reported by gp_compare (0 differing through 10089) and is not pinned. The
# capture has 5,811 frames with this poll.log sha256 (e53bef4f..be0355).
# An absent capture skips (gp-replay's GP_OPTIONAL=1, gp_win.py's no-poll.log skip); a present
# capture with empty/changed pins fails. Raise MIN_FIRST/TRACE_MIN_FIRST/MIN_REACH/MILESTONES/
# WIN_MIN_FIRST when they improve.
# MUSIC TRUTH §M2.7 (2026-10-25): as gp-u9-win, capture 2191 is a CLEAN match now (the entry's
# music resolve presents the loader in the replay's own frame; OVERLAY_ALLOWED retired).
# Re-measured: MAX_MISSING 54 -> 52 and MIN_REACH 4929 -> 4928; MIN_FIRST/START/TRACE/WIN are
# exact and unchanged.
GP_U22_MIN_FIRST = 5798
GP_U22_TRACE_MIN_FIRST = 10090
GP_U22_MAX_START = 74
GP_U22_MIN_REACH = 4928
GP_U22_CAPTURE_SHA256 = e53bef4f6f4ef23e1c6046c453236d51b54575916cebd71259a3b9b8dbbe0355
GP_U22_CAPTURE_FRAMES = 5811
GP_U22_MILESTONES = 30
GP_U22_WIN_MIN_FIRST = 10090
gp-u22-oracle: build ## Endgame truth U22 oracle: DIABLO's ending (character 5, cell 6; skips without data/k11-captures/gp-u22-diablo-ending)
	@echo "== gameplay oracle: gp-u22-diablo-ending (DIABLO's ending under pokes) =="
	@$(PYTHON) -m unittest tools.tests.test_gp_win
	@if [ ! -d $(K11_CAPTURES)/gp-u22-diablo-ending ]; then echo "gp-u22-oracle: no capture at $(K11_CAPTURES)/gp-u22-diablo-ending (skipped)"; else \
		$(PYTHON) tools/gp_win.py check --scenario gp-u22-diablo-ending --capture $(K11_CAPTURES)/gp-u22-diablo-ending \
			--capture-sha256 "$(GP_U22_CAPTURE_SHA256)" && \
		$(MAKE) --no-print-directory gp-replay scenario=gp-u22-diablo-ending GP_OPTIONAL=1 && \
		$(PYTHON) tools/gp_compare.py --scenario gp-u22-diablo-ending --capture $(K11_CAPTURES)/gp-u22-diablo-ending \
			--port $(GP_DUMP)/gp-u22-diablo-ending --min-first "$(GP_U22_MIN_FIRST)" \
			--trace-min-first "$(GP_U22_TRACE_MIN_FIRST)" --max-start "$(GP_U22_MAX_START)" \
			--min-reach "$(GP_U22_MIN_REACH)" --max-missing "$(GP_U22_MAX_MISSING)" \
			--capture-sha256 "$(GP_U22_CAPTURE_SHA256)" --capture-frames "$(GP_U22_CAPTURE_FRAMES)" && \
		$(PYTHON) tools/gp_win.py path --scenario gp-u22-diablo-ending --capture $(K11_CAPTURES)/gp-u22-diablo-ending \
			--port $(GP_DUMP)/gp-u22-diablo-ending --min-milestones "$(GP_U22_MILESTONES)" \
			--win-min-first "$(GP_U22_WIN_MIN_FIRST)" --capture-sha256 "$(GP_U22_CAPTURE_SHA256)"; \
		rc=$$?; [ -n "$(GP_WIN_KEEP)" ] || rm -rf $(GP_DUMP)/gp-u22-diablo-ending; exit $$rc; fi

# Host input truth U17 (plan docs/superpowers/plans/2026-10-21-host-input-truth.md Task 6;
# gp_session.SCENARIOS['gp-u17-device2'], record §H6.6/§H6.9): the K11 service-menu walk to MODIFY
# CONTROLS (0x31F24) with the K11 walk's own keys, one 5-frame Down, Esc (leave MODIFY CONTROLS) and
# Esc (leave the OPTIONS MENU, which resumes the master loop so the cleared word is in an S record).
# The Down arm (0x320CF -> 0x320ED `dev + 2`) is gated by `until = [0x101500] + 0x0C`, so the 83 ms
# hold is one step, 0 -> 2. The device write is MODIFY CONTROLS' own return path (0x32345
# config_keys_apply writes the record's device into BIOS+0x2D4, 0x1AE3B) -- i.e. on the FIRST Esc,
# not the OPTIONS MENU leave; that second Esc runs 0x2CBAF config_file_save (a no-op on the
# capture's read-only mount) and resumes the master loop. No pad answered the probe, so the ISR
# sampler's presence block (0x1BC4F..0x1BCE6) probes 0x4FBBB(3), gets 0 and clears the word on its
# next tick: P1 is the only selected side, so the P1-only arm's 0x1BCBF clears it (dev2 == 0;
# 0x1BC6F is the both-player arm's clear).
#
# The steps are WALL-paced ('after_wall'): a service-menu sub-loop presents frames through
# 0x2EA78 inside one game_frame, so the master frame counter and the spin predicate freeze and a
# frame-keyed step never comes due (the 2026-10-22 failed run, whose diagnosis is record §H6.9;
# the successful publish removed its staging directory). Only the wall clock reaches those
# screens. The frame-keyed replay cannot follow wall steps yet, so this oracle pins the measured
# capture-side evidence, the selection's screen evidence and the capture identity, and the
# frame/trace ratchets stay empty and unused until the driver's tick-based path lands (the K11
# driver's pump-hook model) -- a named gap, not a passing claim.
#
# The claim is narrow: no game-port axis or button is injected (the harness cannot), so the arms'
# evidence is the unit tests and the differential rows; this capture pins the clear, not the pad's
# answer.
#
# The measured pins (capture 2026-10-22, 155 frames, raw 1373..3669, wall 55.5 s; the target skips
# on the absent capture directory and every pin below fails when moved):
#   CLEAR_FRAME 761 (f=0x2F9): the first S record after the last injected key (the OPTIONS MENU
#                 leave at f=0x2F7) whose dev1 is 0 -- the master loop resumes on that leave and
#                 the sampler's no-pad clear has already run, so dev1/dev2 read 0 in every S record
#   DEVICE_BEFORE/DEVICE_FRAME 149/150 + their band sha256 + the band rows 40:46: the MODIFY
#                 CONTROLS device-label band (text row 6, `config_option_row`'s
#                 `text_cursor_set(base-8, 6, ...)`) before and after the step -- KEYBOARD's band
#                 (9dfc4df1..) then the 4 BUTTON JOYSTICK one (87141468..), with more ink after
#                 (1028 -> 1096 px). This is how `--device` is pinned on a wall-paced capture: the
#                 poll has no S record while a menu is open, so the VALUE comes from the screen,
#                 and an explicit `--device` without --unseen-selection-ok fails
#   SHA256       5c0a3b56.. of poll.log; FRAMES 155 (frame_*.raw.gz)
# GP_U17_DEVICE (2) is the scenario's DERIVED expectation (the one +2 step), not a measured pin:
# it is passed with --unseen-selection-ok so the tool records that the poll cannot confirm it, and
# the screen bands above are its evidence. A re-capture invalidates every pin (record §G.24).
GP_U17_DEVICE = 2
GP_U17_CLEAR_FRAME = 761
GP_U17_DEVICE_BEFORE = 149
GP_U17_DEVICE_FRAME = 150
GP_U17_DEVICE_ROWS = 40:46
GP_U17_DEVICE_BEFORE_SHA256 = 9dfc4df15b4b797073449209cb1c1d3fb721319ba8fa2f135c21ab1b5f335528
GP_U17_DEVICE_FRAME_SHA256 = 871414689bd920a87b68bec299889bc2c20667b62a9c4980cea1daf3cb930fff
GP_U17_MIN_FIRST =
GP_U17_TRACE_MIN_FIRST =
GP_U17_MAX_START =
GP_U17_MIN_REACH =
GP_U17_CAPTURE_SHA256 = 5c0a3b5610535f9b28739ea7a436104aabc3e2c740c97d40400080b5c41d6d5d
GP_U17_CAPTURE_FRAMES = 155
gp-u17-oracle: build ## Host input truth U17 oracle: the device-2 selection's no-pad clear, its screen evidence and the capture identity on gp-u17-device2 (skips without it)
	@echo "== gameplay oracle: gp-u17-device2 (the device-2 selection and the no-pad clear) =="
	@$(PYTHON) -m unittest tools.tests.test_gp_u17
	@if [ ! -d $(K11_CAPTURES)/gp-u17-device2 ]; then echo "gp-u17-oracle: no capture at $(K11_CAPTURES)/gp-u17-device2 (skipped)"; else \
		$(PYTHON) tools/gp_u17.py check --capture $(K11_CAPTURES)/gp-u17-device2 \
			--device "$(GP_U17_DEVICE)" --unseen-selection-ok \
			--clear-frame "$(GP_U17_CLEAR_FRAME)" \
			--device-before "$(GP_U17_DEVICE_BEFORE)" --device-frame "$(GP_U17_DEVICE_FRAME)" \
			--device-rows "$(GP_U17_DEVICE_ROWS)" \
			--device-before-sha "$(GP_U17_DEVICE_BEFORE_SHA256)" \
			--device-frame-sha "$(GP_U17_DEVICE_FRAME_SHA256)" \
			--capture-sha256 "$(GP_U17_CAPTURE_SHA256)" \
			--capture-frames "$(GP_U17_CAPTURE_FRAMES)"; \
		rc=$$?; \
		echo "gp-u17-oracle: the frame/trace ratchets wait for the tick-based replay path (wall-paced menu; named gap, record §H6.9)"; \
		exit $$rc; fi

gp-report: build ## Report-only gameplay comparison (scenario=gp-…): counts and first differences, no ratchet, exit 0
	@$(MAKE) --no-print-directory gp-replay scenario=$(scenario) GP_OPTIONAL=1
	@$(PYTHON) tools/gp_compare.py --report --scenario $(scenario) --capture $(K11_CAPTURES)/$(scenario) --port $(GP_DUMP)/$(scenario) --max-unexplained "$(GP_REPORT_MAX)"

# The volleyball route search (plan 2026-11-01-volleyball-truth Task 2): usage
# only. The tool inserts a key/bits grid into a gp port script v2, replays each
# variant through the GP driver with PR_GP_FAST=1/PR_GP_VOLLEY=1 into a /tmp
# workdir, and ranks the traces by stage afc, b1f, the lie frame, the entry
# +0x1F count and mode 0x21. It is exploratory: not part of make verify.
vb-search: ## Print the volleyball search tool's usage (tools/vb_search.py; not in make verify)
	@$(PYTHON) tools/vb_search.py --help

# Differential verification (spec 2026-09-30-reverse-completion-design §5): the original's own
# bytes run in an emulator against the port's C functions from the same image; compares every
# changed byte, the return register, the ordered list of calls into the call set with the memory changed
# so far at each (record E3: callees stubbed or run on both sides, the port through its PR_SEAM lines; a
# stub poisons the registers its callee does not preserve) and the block coverage. Skips
# cleanly without unicorn or capstone (spec §5.5); tools/diff_verify.py skips without PRAGE.EXE. The
# claim is narrow: equivalence on the exercised blocks and inputs only.
diff-verify: build ## Differential verification: original x86 bytes vs the port's C functions (skips without unicorn or capstone)
	@echo "== differential verification (original bytes vs the port's C; records E1, E3) =="
	@if $(PYTHON) -c "import unicorn, capstone" 2>/dev/null; then \
		$(PYTHON) -m unittest tools.tests.test_diff_emu tools.tests.test_diff_verify && \
		$(PYTHON) tools/diff_verify.py --diffrun $(BUILD_DIR)/diffrun --exe $(GAME_DIR)/PRAGE.EXE \
			--image $(DIFF_IMAGE) --table $(DIFF_TABLE) --self-check; \
	else echo "diff-verify: skipped: unicorn or capstone is not installed (pip install -r tools/requirements-diff.txt)"; fi

# E2 (record docs/superpowers/plans/2026-10-01-reverse-e2-derivations.md): triage of the entry
# candidates Ghidra never listed. The committed table must equal a fresh run on the image the port's
# loader dumps, so a port change that ports a target regenerates it in the same commit. Skips without
# capstone or PRAGE.EXE, and fails instead when the caller sets PR_ORACLE_REQUIRED=1.
entry-triage: build ## E2 triage of the non-Ghidra entry candidates: unit tests + the committed table must equal a fresh run (skips without capstone or PRAGE.EXE; fails under PR_ORACLE_REQUIRED=1)
	@echo "== entry triage (the non-Ghidra entry candidates; record E2) =="
	@if ! $(PYTHON) -c "import capstone" 2>/dev/null; then \
		echo "entry-triage: skipped: capstone is not installed (pip install -r tools/requirements-diff.txt)"; \
		[ "$${PR_ORACLE_REQUIRED}" != 1 ] || exit 1; \
	elif [ ! -f $(GAME_DIR)/PRAGE.EXE ]; then echo "entry-triage: skipped: $(GAME_DIR)/PRAGE.EXE is absent"; \
		[ "$${PR_ORACLE_REQUIRED}" != 1 ] || exit 1; \
	else $(PYTHON) -m unittest tools.tests.test_entry_triage && \
		./$(BUILD_DIR)/diffrun --exe $(GAME_DIR)/PRAGE.EXE --image-out $(E2_IMAGE) && \
		$(PYTHON) tools/entry_triage.py --image $(E2_IMAGE) --live $(E2_LIVE) --check $(E2_TABLE) \
			--expect 736 --expect-u0 732; fi

# Headless FM render: on hosts where SDL audio cannot open, the windowed run is
# silent, so this plays the title bank through the sequencer + OPL core + mixer
# and writes a 16-bit stereo WAV at the OPL rate for listening in any player.
# The selected-song render (s16selmo, the character-select/menu song) is the
# Task-5 determinism check: it plays at the game path's sequence volume
# (DS_000A2CB8 = 0x50) and the test fails on a silent render and pins the WAV's
# peak and sha256 (record §M5). That is a determinism claim, not audibility.
AUDIO_WAV ?= /tmp/pr_title_fm.wav
AUDIO_SONG_WAV ?= /tmp/pr_selmo_fm.wav
AUDIO_SECONDS ?= 12
audio-render: build ## Render the title FM music and the selected menu song headlessly to WAVs (AUDIO_WAV, AUDIO_SONG_WAV, AUDIO_SECONDS)
	PR_AUDIO_WAV=$(AUDIO_WAV) PR_AUDIO_SONG_WAV=$(AUDIO_SONG_WAV) PR_AUDIO_WAV_SECONDS=$(AUDIO_SECONDS) ./$(BUILD_DIR)/run_tests

# ── audio · the register-stream oracle ───────────────────────────────────────
# The 28 gameplay capture directories hold the 29 `gp-*` `.dro` traces of the
# measured inventory (record 2026-10-25-music-truth-derivations §M1.6.2;
# gp-pads holds prage_000 and prage_001). Each is the original's OPL register
# stream for that scenario. The port replays the capture's script cut at
# AUDIO_END frames (or the capture's X record, whichever is earlier: the
# comparison only needs the music sections the cut reaches) and writes its own
# stream with PR_AUDIO_DUMP. `tools/audio_compare.py` anchors each song at its
# first key-on and compares the per-tick register multisets with no fitted
# window: the declared models are the intra-tick write order (the port's
# ascending OPL-channel key-off loop) and a one-tick capture-label slack (the
# DRO's accumulated ms jitter: the same selmo note-off is at 479..484 ms in
# three captures). Each trace's measured minimum is pinned in the tool's
# TRACE_PINS table with its allowed named gaps; a section outside that table
# must anchor and match, and a one-write mutation fails. A missing capture is
# skipped by name; gp-u17-device2 has no frame-keyed port script (wall-paced,
# record §H6.9) and its `.dro` is the 248 ms boot fragment, so it is skipped by
# name too. The 12 other k11 `.dro` files (9 directories, `gp-u30-volleyball`,
# `k11-lang` and `gp-u14-twop-win` among them, which are outside this inventory)
# and the 2 title
# captures are named by the summary as outside the 29-trace gameplay inventory.
# AUDIO_DUMP is a fixed shared /tmp path like GP_DUMP; a parallel lane must
# override it (`make ... AUDIO_DUMP=/tmp/pr_audio_dump_<lane>`).
AUDIO_DUMP ?= /tmp/pr_audio_dump
# One above the maximum X record of the 29 gp-* captures (record claim-coverage
# §C3): the largest X is 10089 (gp-u22-diablo-ending), so 10090 makes
# capture-end return every capture's own X (`min(cap, X)`) and the replay cover
# the whole captured window instead of the first 1400 frames.
AUDIO_END ?= 10090
AUDIO_JOBS ?= 4
AUDIO_TRACES = gp-idle-loss gp-idle-loss-run2 gp-keys-fight gp-pads gp-twop \
	gp-u10-ending gp-u12-endurance gp-u13-ending2 gp-u15-join gp-u16-twop2 \
	gp-u17-device2 gp-u18-talon-ending gp-u19-vertigo-ending gp-u20-chaos-ending \
	gp-u21-armadon-ending gp-u22-diablo-ending gp-u23-u16-finisher gp-u5-charsel \
	gp-u6-moves gp-u6-moves-b gp-u8-attract-start gp-u8-endurance gp-u8-handicap \
	gp-u8-left-training gp-u8-right-arcade gp-u8-right-training gp-u8-tug-of-war \
	gp-u9-win

# One trace: the cut port script, the PR_AUDIO_DUMP replay, the comparison.
# The script uses the default injector-derived, fail-closed model (it
# cross-checks every accepted S). Only gp-u6-moves, the one contaminated audio
# trace, is replayed with `--trust-bitmap` (the capture's recorded key-state
# bitmap): its known unscripted 0xA52 0x0200 bit cannot reach a
# capture-validation path (gp-u6-moves is not in CAPTURE_DIRS; its published
# re-capture gp-u6-moves-b is a clean default-model trace), but its `.dro` audio
# trace still replays. The two derivations agree on every clean capture (0/5,820
# S mismatches, completion truth Task 1).
# No build dependency: `audio-oracle` builds once and the parallel workers only
# read the binary (a parallel `make build` would race on the build directory).
audio-one: ## Replay and compare one gameplay trace (scenario=gp-…; used by audio-oracle)
	@case "$(scenario)" in gp-?*) ;; *) echo "usage: make audio-one scenario=gp-<name>"; exit 2;; esac
	@[ -n "$(AUDIO_DUMP)" ] || { echo "audio-one: AUDIO_DUMP is empty"; exit 2; }; \
	cap=$(K11_CAPTURES)/$(scenario); \
	dir=$(AUDIO_DUMP)/$(scenario); \
	if [ ! -d $$cap ]; then \
		mkdir -p $$dir; \
		printf '{"name":"%s","ok":true,"total_ticks":0,"min_ticks":0,"named_gaps":[],"skipped":"no capture"}\n' $(scenario) > $$dir/report.json; \
		echo "audio-oracle: SKIP $(scenario) (no capture at $$cap)"; exit 0; fi; \
	rm -rf $$dir; mkdir -p $$dir/gp; \
	end=$$($(PYTHON) tools/audio_compare.py capture-end --capture $$cap --max $(AUDIO_END)); \
	if [ "$$end" = "0" ]; then \
		echo "audio-oracle: $(scenario) FAIL: no X record in $$cap/poll.log"; exit 1; fi; \
	trust='$(if $(filter gp-u6-moves,$(scenario)),--trust-bitmap)'; \
	if ! $(PYTHON) tools/gp_session.py port-script --scenario $(scenario) --capture $$cap --out $$dir/script --end $$end $$trust > $$dir/script.log 2>&1; then \
		if [ "$(scenario)" = "gp-u17-device2" ] && grep -q "wall-paced steps" $$dir/script.log; then \
			printf '{"name":"%s","ok":true,"total_ticks":0,"min_ticks":0,"named_gaps":[],"skipped":"no frame-keyed port script (wall-paced; record §H6.9)"}\n' $(scenario) > $$dir/report.json; \
			echo "audio-oracle: SKIP $(scenario) (the declared wall-paced scenario; record §H6.9)"; exit 0; \
		fi; \
		echo "audio-oracle: $(scenario) FAIL: port script generation failed:"; cat $$dir/script.log; exit 1; fi; \
	PR_GP_DUMP=$$dir/gp PR_GP_SCRIPT=$$dir/script PR_GAME_DIR=$(GAME_DIR) \
		PR_AUDIO_DUMP=$$dir/audio.txt ./$(BUILD_DIR)/run_tests > $$dir/replay.log 2>&1 || { \
		echo "audio-oracle: $(scenario) replay FAILED ($$dir/replay.log)"; exit 1; }; \
	files=$$($(PYTHON) tools/audio_compare.py expected-files --scenario $(scenario)) || { \
		echo "audio-oracle: $(scenario) has no expected-file inventory"; exit 1; }; \
	caps=""; for f in $$files; do caps="$$caps --capture $$cap/$$f"; done; \
	$(PYTHON) tools/audio_compare.py compare --name $(scenario) --port $$dir/audio.txt $$caps \
		--game-dir $(GAME_DIR) --json-out $$dir/report.json

audio-oracle: build ## The register-stream oracle over the 29 gameplay gp-* traces (skips absent traces by name)
	@rm -rf $(AUDIO_DUMP); mkdir -p $(AUDIO_DUMP)
	@printf '%s\n' $(AUDIO_TRACES) | xargs -P $(AUDIO_JOBS) -I{} $(MAKE) --no-print-directory audio-one scenario={}
	@$(PYTHON) tools/audio_compare.py summary --captures $(K11_CAPTURES) --audio-captures data/audio-captures --reports $(AUDIO_DUMP)

# Determinism check (Task 2, record §C2): two headless replays of the same
# trace into two dump roots must write a byte-identical audio.txt (the W/S
# labels included). The audio-one comparison status is deliberately ignored —
# under load it fails before the fix while the dumps are still written — and
# cmp of the two dumps is the verdict. gp-u18-talon-ending is §C1.3's flake
# scenario; run it under the same load the controller generated for the
# RED/GREEN pair (a load-agnostic cmp cannot otherwise tell the two apart).
AUDIO_DET_DUMP ?= /tmp/pr_audio_determinism
audio-determinism: build ## Two gp-u18-talon-ending replays must be byte-identical (labels included; run under load)
	@[ -d "$(K11_CAPTURES)/gp-u18-talon-ending" ] || { echo "audio-determinism: SKIP (no capture)"; exit 0; }
	@rm -rf $(AUDIO_DET_DUMP)
	@$(MAKE) --no-print-directory audio-one scenario=gp-u18-talon-ending AUDIO_DUMP=$(AUDIO_DET_DUMP)/run1 >/dev/null 2>&1 || true
	@$(MAKE) --no-print-directory audio-one scenario=gp-u18-talon-ending AUDIO_DUMP=$(AUDIO_DET_DUMP)/run2 >/dev/null 2>&1 || true
	@if [ ! -f $(AUDIO_DET_DUMP)/run1/gp-u18-talon-ending/audio.txt ] || \
	    [ ! -f $(AUDIO_DET_DUMP)/run2/gp-u18-talon-ending/audio.txt ]; then \
		echo "audio-determinism: FAIL (a dump is missing)"; exit 1; fi
	@if cmp -s $(AUDIO_DET_DUMP)/run1/gp-u18-talon-ending/audio.txt \
	           $(AUDIO_DET_DUMP)/run2/gp-u18-talon-ending/audio.txt; then \
		echo "audio-determinism: PASS (the two audio.txt dumps are byte-identical)"; \
	else \
		echo "audio-determinism: FAIL (the two audio.txt dumps differ)"; \
		diff $(AUDIO_DET_DUMP)/run1/gp-u18-talon-ending/audio.txt \
		     $(AUDIO_DET_DUMP)/run2/gp-u18-talon-ending/audio.txt | head -n 20; \
		exit 1; \
	fi

# The --check run must come immediately before the unit suite: test_fn_misslog
# reads frames/fn_miss.txt from the CWD and verifies the run's pinned known-set.
verify: gate-check build ## Full ladder: capture preflight, --check frames, oracle-required tests, every pixel and ratchet oracle, differential verification, E2 triage, symbols.h idempotence
	@echo "== the game dir's cmos (every capture ran on 2040 zero bytes; the port's drivers read it since track S) =="
	@$(PYTHON) -c "import sys; sys.path.insert(0, 'tools'); import k11_capture as k; print('cmos=%s' % k.check_cmos('$(GAME_DIR)'))"
	@echo "== headless frames (must precede test_fn_misslog's frames/fn_miss.txt check) =="
	./$(BUILD_DIR)/prageport --game-dir $(GAME_DIR) --check $(verify_frames)
	@echo "== tests (oracles required; consume the captured frames) =="
	$(UNIT_ENV) PR_ORACLE_REQUIRED=1 PR_FN_MISS_REQUIRED=1 PR_GAME_DIR=$(GAME_DIR) ./$(BUILD_DIR)/run_tests
	@echo "== restart driver (the 0x65431 soft restart, record named-gaps-b §B.3) =="
	$(UNIT_ENV) PR_RESTART=1 PR_GAME_DIR=$(GAME_DIR) ./$(BUILD_DIR)/run_tests
	@$(GATE_ORACLE_ENV) $(MAKE) --no-print-directory smk-oracle
	@echo "== title oracle (pixel-exact) =="
	@$(GATE_ORACLE_ENV) $(MAKE) --no-print-directory title-oracle
	@echo "== front-end oracle (pixel-exact, states 3/4; enforced) =="
	@$(GATE_FRONTEND_ENV) $(MAKE) --no-print-directory frontend-oracle
	@echo "== demo-fight oracle (ratchet on the first unexplained frame) =="
	@$(GATE_FRONTEND_ENV) $(MAKE) --no-print-directory demo-fight-oracle
	@echo "== attract cycle-2 oracle (ratchet; the demo-fight run's dump) =="
	@$(GATE_FRONTEND_ENV) $(MAKE) --no-print-directory attract2-compare
	@echo "== attract prefix oracle (pixel-exact) =="
	@$(GATE_ORACLE_ENV) $(MAKE) --no-print-directory attract-oracle
	@echo "== K11 service-menu oracles (the walk and the menuesc restart; each skips without its capture) =="
	@$(MAKE) --no-print-directory k11-oracle
	@echo "== save-reload oracle (boot 1's write and boot 2's load; skips without its capture) =="
	@$(MAKE) --no-print-directory save-reload-oracle
	@echo "== gameplay replay driver (the gp-pads capture; skips without it; record §G.11) =="
	@$(MAKE) --no-print-directory gp-replay scenario=gp-pads GP_OPTIONAL=1
	@echo "== gameplay oracle (frame and trace ratchets; skips without its capture; record §G.16) =="
	@$(MAKE) --no-print-directory gp-oracle
	@$(MAKE) --no-print-directory gp-charsel-oracle
	@echo "== gameplay oracle: gp-u6-moves-b (skips without its capture; record gameplay-u6 §U6.22) =="
	@$(MAKE) --no-print-directory gp-moves-oracle
	@$(MAKE) --no-print-directory gp-keys-oracle
	@$(MAKE) --no-print-directory gp-twop-oracle
	@echo "== live truth Task 3: the no-poke two-human KO window (skips without its capture; record §L3) =="
	@$(MAKE) --no-print-directory gp-twop-ko-oracle
	@$(MAKE) --no-print-directory gp-modes-oracle
	@echo "== gameplay oracle: gp-u12-endurance (the team pass and fight; skips without its capture; record §J4) =="
	@$(MAKE) --no-print-directory gp-u12-oracle
	@echo "== state coverage U25: the ENDURANCE match end (four-KO roster walk, modes 0x32/0x33; skips without its capture; record §D5) =="
	@$(MAKE) --no-print-directory gp-u25-oracle
	@echo "== state coverage U26: the side-1 tour and ARMADON ending (ad4=1; skips without its capture; plan 2026-10-28-state-coverage Task 6) =="
	@$(MAKE) --no-print-directory gp-u26-oracle
	@echo "== live truth U27: the high-score name-entry letters and finaliser (skips without its capture; record §L7) =="
	@$(MAKE) --no-print-directory gp-u27-oracle
	@echo "== state coverage Task 7: the timeout/health drawn round (0x280BA; skips without its capture; record §D7) =="
	@$(MAKE) --no-print-directory gp-draw-timeout-oracle
	@echo "== live truth Task 5: the continue screen, countdown and expiry (skips without its capture; record §L5) =="
	@$(MAKE) --no-print-directory gp-u28-continue-oracle
	@echo "== final truth Task 4: the taken continue (0x0E -> 0x2791C; skips without its capture; record §F4) =="
	@$(MAKE) --no-print-directory gp-u29-continue-taken-oracle
	@echo "== final truth Task 5: the mode-0x25 bowling entry (0x455C3/0x263DE/0x4E11C; skips without its capture; record §F5) =="
	@$(MAKE) --no-print-directory gp-bowling-oracle
	@echo "== volleyball truth Task 4: the mode-0x21 entry, a real point and the return to mode 6 (skips without its capture; record §2) =="
	@$(MAKE) --no-print-directory gp-u30-volleyball-oracle
	@echo "== live truth Task 2: the KO-branch drawn match (0x0A/0x0B; skips without its capture; record §L2) =="
	@$(MAKE) --no-print-directory gp-draw-oracle
	@echo "== gameplay oracle: gp-u13-ending2 (a non-character-0 ending; skips without its capture) =="
	@$(MAKE) --no-print-directory gp-u13-oracle
	@echo "== gameplay oracle: gp-u14-win-live (a fought win; skips without its capture) =="
	@$(MAKE) --no-print-directory gp-u14-oracle
	@echo "== gameplay oracle: gp-u14-twop-win (the game's own 2-0 two-human win; skips without its capture) =="
	@$(MAKE) --no-print-directory gp-u14-twop-win-oracle
	@echo "== gameplay oracle: gp-u15-join (a mid-round join; skips without its capture) =="
	@$(MAKE) --no-print-directory gp-u15-oracle
	@echo "== gameplay oracle: gp-u16-twop2 (extended two-human match and frame evidence; finisher gap; skips without its capture) =="
	@$(MAKE) --no-print-directory gp-u16-oracle
	@echo "== host input truth U17: the device-2 selection and the no-pad clear; skips without its capture =="
	@$(MAKE) --no-print-directory gp-u17-oracle
	@echo "== endgame truth U23: the U16 finisher reaction (labelled fallback); skips without its capture =="
	@$(MAKE) --no-print-directory gp-u23-oracle
	@echo "== endgame truth U18: TALON's ending (character 2, cell 1); skips without its capture =="
	@$(MAKE) --no-print-directory gp-u18-oracle
	@echo "== endgame truth U19: VERTIGO's ending (character 3, cell 2); skips without its capture =="
	@$(MAKE) --no-print-directory gp-u19-oracle
	@echo "== endgame truth U20: CHAOS's ending (character 6, cell 4); skips without its capture =="
	@$(MAKE) --no-print-directory gp-u20-oracle
	@echo "== endgame truth U21: ARMADON's ending (character 4, cell 5); skips without its capture =="
	@$(MAKE) --no-print-directory gp-u21-oracle
	@echo "== endgame truth U22: DIABLO's ending (character 5, cell 6); skips without its capture =="
	@$(MAKE) --no-print-directory gp-u22-oracle
	@$(MAKE) --no-print-directory gp-win-oracle
	@$(MAKE) --no-print-directory gp-ending-oracle
	@echo "== register-stream oracle (the 29 gp-* traces; each skips without its capture) =="
	@$(MAKE) --no-print-directory audio-oracle
	@echo "== headless FM render (the PCM determinism pin; Task 2, record §C2) =="
	@$(MAKE) --no-print-directory audio-render
	@$(MAKE) --no-print-directory diff-verify
	@$(MAKE) --no-print-directory entry-triage
	@echo "== k11 and gp tool unit tests =="
	PR_ORACLE_REQUIRED=1 PR_GAME_DIR=$(GAME_DIR) $(PYTHON) -m unittest tools.tests.test_k11_fields tools.tests.test_k11_session tools.tests.test_k11_capture tools.tests.test_k11_compare tools.tests.test_save_reload tools.tests.test_gp_session tools.tests.test_gp_capture tools.tests.test_gp_band tools.tests.test_gp_bowling tools.tests.test_gp_volley tools.tests.test_gp_compare tools.tests.test_gp_moves tools.tests.test_gp_keys tools.tests.test_gp_u16_state tools.tests.test_gp_u17 tools.tests.test_gp_endurance tools.tests.test_gp_side1 tools.tests.test_title_pin tools.tests.test_tick_vector tools.tests.test_audio_compare tools.tests.test_capture_manifest tools.tests.test_port_progress tools.tests.test_coverage_census tools.tests.test_check_docs_claims tools.tests.test_final_claims
	@echo "== title_compare unit tests (splice3, record §47-A) =="
	$(PYTHON) -m unittest tools.tests.test_title_compare
	@echo "== gra_extract oracle tests (real assets required) =="
	@PR_ORACLE_REQUIRED=1 $(MAKE) --no-print-directory re-extract-test
	@echo "== docs claims guard (no ported function stated as a gap) =="
	$(PYTHON) tools/check_docs_claims.py
	@echo "== symbols.h must regenerate byte-identically =="
	$(PYTHON) tools/gen_symbols.py $(DECOMP_DIR) $(PORT_DIR)/src/symbols.h
	@git diff --quiet -- $(PORT_DIR)/src/symbols.h || { \
		echo "symbols.h is stale after regeneration — commit the regenerated header"; exit 1; }
	@echo "all checks passed"

run: build ## Run the port windowed, reading the original assets
	./$(BUILD_DIR)/prageport --game-dir $(GAME_DIR)

clean: ## Remove build outputs and locally generated oracles (keeps the SDD ledger)
	rm -rf $(BUILD_DIR) $(COV_BUILD_DIR) $(COV_HITS_DIR)
	rm -f $(PORT_DIR)/tests/ghidra_data.bin
	rm -rf frames/
	@echo "Cleanup complete. (.superpowers/ deliberately kept — it holds the plan ledger.)"

# The coverage census (plan 2026-10-28-state-coverage Task 3): a PR_COVERAGE
# build, every driver run with PR_HIT_TAG into coverage/, then
# tools/coverage_census.py. The gp scenarios are CAPTURE_DIRS's gp-* entries
# minus the two excluded by name (gp-pads is a smoke scenario, gp-u17-device2
# has no replay); an absent capture (gp-u14-win-live) is reported and excluded,
# never a NOT-HIT. The other non-CAPTURE_DIRS directories under data/k11-captures
# (gp-harness-smoke, gp-idle-loss-run2, gp-u6-moves, .failed*, de, diags, idle)
# are never globbed in the first place. `make coverage COV_REASON="..."` re-pins
# the baseline instead of checking it. Not in verify: it re-runs the drivers.
COV_BUILD_DIR = build-cov
COV_HITS_DIR = coverage
GP_COV_SCENARIOS = $(filter-out gp-pads gp-u17-device2,$(notdir $(filter $(K11_CAPTURES)/gp-%,$(CAPTURE_DIRS))))
COV_TAGS = check attract title frontend restart walk menuesc save-reload $(GP_COV_SCENARIOS)
COV_REASON ?=
ifeq ($(strip $(COV_REASON)),)
COV_CENSUS = --check
else
COV_CENSUS = --update-baseline --reason "$(COV_REASON)"
endif

coverage: ## Run every driver under PR_COVERAGE and the coverage census (COV_REASON="..." re-pins the baseline)
	@cmake -S $(PORT_DIR) -B $(COV_BUILD_DIR) -DPR_COVERAGE=ON
	@cmake --build $(COV_BUILD_DIR)
	@rm -rf $(COV_HITS_DIR); mkdir -p $(COV_HITS_DIR)
	@echo "== coverage: attract driver =="
	@rm -rf $(ATTRACT_DUMP); PR_ATTRACT_DUMP=$(ATTRACT_DUMP) PR_HIT_TAG=attract PR_GAME_DIR=$(GAME_DIR) ./$(COV_BUILD_DIR)/run_tests \
		|| { echo "coverage: FAILED attract driver"; exit 1; }
	@echo "== coverage: title driver =="
	@rm -rf $(TITLE_DUMP); PR_TITLE_DUMP=$(TITLE_DUMP) PR_HIT_TAG=title PR_GAME_DIR=$(GAME_DIR) ./$(COV_BUILD_DIR)/run_tests \
		|| { echo "coverage: FAILED title driver"; exit 1; }
	@echo "== coverage: frontend driver =="
	@rm -rf $(FRONTEND_DUMP); PR_FRONTEND_DUMP=$(FRONTEND_DUMP) PR_HIT_TAG=frontend PR_GAME_DIR=$(GAME_DIR) ./$(COV_BUILD_DIR)/run_tests \
		|| { echo "coverage: FAILED frontend driver"; exit 1; }
	@echo "== coverage: restart driver =="
	@PR_RESTART=1 PR_HIT_TAG=restart PR_GAME_DIR=$(GAME_DIR) ./$(COV_BUILD_DIR)/run_tests \
		|| { echo "coverage: FAILED restart driver"; exit 1; }
	@echo "== coverage: --check $(verify_frames) =="
	@PR_HIT_TAG=check ./$(COV_BUILD_DIR)/prageport --game-dir $(GAME_DIR) --check $(verify_frames) \
		|| { echo "coverage: FAILED check"; exit 1; }
	@echo "== coverage: k11 walk =="
	@if [ -d $(K11_CAPTURES)/walk ]; then \
		rm -rf $(K11_DUMP)/walk; mkdir -p $(K11_DUMP); \
		$(PYTHON) tools/k11_session.py port-script --scenario walk --capture $(K11_CAPTURES)/walk --out $(K11_DUMP)/walk.script && \
		PR_K11_DUMP=$(K11_DUMP)/walk PR_K11_SCRIPT=$(K11_DUMP)/walk.script PR_HIT_TAG=walk PR_GAME_DIR=$(GAME_DIR) ./$(COV_BUILD_DIR)/run_tests \
		|| { echo "coverage: FAILED k11 walk"; exit 1; }; \
	else echo "coverage: no capture at $(K11_CAPTURES)/walk (missing)"; fi
	@echo "== coverage: k11 menuesc =="
	@if [ -d $(K11_CAPTURES)/menuesc ]; then \
		rm -rf $(K11_DUMP)/menuesc; mkdir -p $(K11_DUMP); \
		$(PYTHON) tools/k11_session.py port-script --scenario menuesc --capture $(K11_CAPTURES)/menuesc --out $(K11_DUMP)/menuesc.script && \
		PR_K11_DUMP=$(K11_DUMP)/menuesc PR_K11_SCRIPT=$(K11_DUMP)/menuesc.script PR_HIT_TAG=menuesc PR_GAME_DIR=$(GAME_DIR) ./$(COV_BUILD_DIR)/run_tests \
		|| { echo "coverage: FAILED k11 menuesc"; exit 1; }; \
	else echo "coverage: no capture at $(K11_CAPTURES)/menuesc (missing)"; fi
	@echo "== coverage: k11 save-reload (boot 1) =="
	@if [ -d $(K11_CAPTURES)/save-reload ]; then \
		rm -rf $(K11_DUMP)/save-reload; mkdir -p $(K11_DUMP)/save-reload/boot1-scratch; \
		rm -f $(K11_DUMP)/save-reload/boot1-scratch/cmos; \
		$(PYTHON) tools/k11_session.py port-script --scenario save-reload --boot 1 --capture $(K11_CAPTURES)/save-reload/boot1 --out $(K11_DUMP)/save-reload/boot1.script && \
		PR_K11_DUMP=$(K11_DUMP)/save-reload/boot1 PR_K11_SCRIPT=$(K11_DUMP)/save-reload/boot1.script \
			PR_K11_SAVE=$(K11_DUMP)/save-reload/boot1-scratch/cmos PR_HIT_TAG=save-reload PR_GAME_DIR=$(GAME_DIR) ./$(COV_BUILD_DIR)/run_tests \
		|| { echo "coverage: FAILED k11 save-reload"; exit 1; }; \
	else echo "coverage: no capture at $(K11_CAPTURES)/save-reload (missing)"; fi
	@echo "== coverage: gp scenarios =="
	@for s in $(GP_COV_SCENARIOS); do \
		if [ -d $(K11_CAPTURES)/$$s ]; then \
			rm -rf $(GP_DUMP)/$$s; mkdir -p $(GP_DUMP); \
			$(PYTHON) tools/gp_session.py port-script --scenario $$s --capture $(K11_CAPTURES)/$$s --out $(GP_DUMP)/$$s.script && \
			PR_GP_DUMP=$(GP_DUMP)/$$s PR_GP_SCRIPT=$(GP_DUMP)/$$s.script PR_HIT_TAG=$$s PR_GAME_DIR=$(GAME_DIR) ./$(COV_BUILD_DIR)/run_tests \
			|| { echo "coverage: FAILED gp scenario $$s"; exit 1; }; \
		else echo "coverage: no capture at $(K11_CAPTURES)/$$s (missing)"; fi; \
	done
	@echo "== coverage: census =="
	@$(PYTHON) tools/coverage_census.py --tags "$(COV_TAGS)" $(COV_CENSUS)

# ── RE · static inspection ───────────────────────────────────────────────────

re-info: ## Dump the LE layout and decode the INDEX resource table
	$(PYTHON) tools/le_info.py $(GAME_DIR)/PRAGE.EXE
	$(PYTHON) tools/le_info.py --index $(GAME_DIR)/INDEX

re-gra: ## Dump GRA header fields and chunk chains for the installed S16 set
	$(PYTHON) tools/gra_headers.py $(GAME_DIR)
	$(PYTHON) tools/gra_chunks.py \
		$(GAME_DIR)/S16FONTS.GRA $(GAME_DIR)/S16CAGE.GRA \
		$(GAME_DIR)/S16TITLE.GRA $(GAME_DIR)/S16COBSD.GRA

re-render: ## Render one GRA chunk to PPM (gra=S16TITLE.GRA chunk=0 [frame=N] [palette=N])
	$(PYTHON) tools/gra_render.py $(GAME_DIR)/$(gra) $(chunk) /tmp/$$(basename $(gra) .GRA).ppm $(if $(palette),--palette $(palette)) $(if $(frame),--frame $(frame))

re-extract: ## Extract every S16 sprite to extracted/ (RGBA PNG per descriptor + manifest.json)
	$(PYTHON) tools/gra_extract.py $(GAME_DIR) extracted

re-extract-test: ## Unit + oracle tests for the extractor (oracle needs data/game/C)
	PR_GAME_DIR=$(GAME_DIR) $(PYTHON) -m unittest tools.tests.test_gra_extract -v

re-symbols: ## Regenerate port/src/symbols.h from the decompilation
	$(PYTHON) tools/gen_symbols.py $(DECOMP_DIR) $(PORT_DIR)/src/symbols.h

re-cluster: ## Recluster the call graph and rewrite prage.clusters.txt
	$(PYTHON) tools/callgraph.py $(DECOMP_DIR)

# ── RE · Ghidra headless (JDK 25 + the lx-loader extension) ──────────────────

re-decompile: ## Ghidra headless: import PRAGE.EXE and analyse (slow, one-shot)
	$(GHIDRA_ENV) $(HEADLESS) $(PROJ_DIR) $(PROJECT_NAME) \
		-import $(GAME_DIR)/PRAGE.EXE -overwrite \
		-scriptPath $(SCRIPTS_DIR)

re-analyze: ## Ghidra headless: fix up, then re-export prage.c and the index CSVs
	$(GHIDRA_ENV) $(HEADLESS) $(PROJ_DIR) $(PROJECT_NAME) \
		-process PRAGE.EXE \
		-scriptPath $(SCRIPTS_DIR) \
		-postScript FixupProgram.java \
		-postScript ExportDecomp.java $(DECOMP_DIR)/prage.c 90 \
		-postScript ExportMeta.java $(DECOMP_DIR)/

re-oracle: ## Ghidra headless: dump the fixup-applied data object to port/tests/ghidra_data.bin
	@$(GHIDRA_ENV) $(HEADLESS) $(PROJ_DIR) $(PROJECT_NAME) \
		-process PRAGE.EXE -noanalysis \
		-scriptPath $(SCRIPTS_DIR) \
		-postScript DumpBytes.java 80000 8B0D0 > /tmp/prage_dataobj.txt
	@$(PYTHON) -c 'import re; t = open("/tmp/prage_dataobj.txt").read(); \
		m = re.search(r"80000 ([0-9a-f]+)", t); \
		assert m, "DumpBytes produced no data — is the project imported?"; \
		open("$(PORT_DIR)/tests/ghidra_data.bin", "wb").write(bytes.fromhex(m.group(1))); \
		print("wrote", len(m.group(1)) // 2, "bytes to $(PORT_DIR)/tests/ghidra_data.bin")'
	@echo "Untracked on purpose: it is a copy of the game's own bytes."

re-original: ## Run the original game in DOSBox-X (interactive; ESC then y, twice, to quit)
	sh $(RUNNER)

title-pin: ## Build the pinned copy of PRAGE.EXE for the title oracle (writes /tmp only)
	$(PYTHON) tools/title_pin.py --src $(GAME_DIR)/PRAGE.EXE --out $(TITLE_PIN_DIR)/PRAGE.EXE

title-capture: title-pin ## Capture the pinned original run for the title oracle (writes data/title-captures/)
	$(PYTHON) tools/title_capture.py --out $(TITLE_CAPTURES)/title --time-limit 45

frontend-capture: title-pin ## Capture the pinned original's front-end region (Task 1 front-end chain)
	$(PYTHON) tools/title_capture.py --out $(TITLE_CAPTURES)/frontend --time-limit 120
