From 8b4dcccb67f8303d89bf2416a9a586ff1826d315 Mon Sep 17 00:00:00 2001 From: sylph-decoder Date: Sat, 29 Aug 2026 17:33:29 +0000 Subject: [PATCH] handoff: BGM_103 as the menu music now has a runtime leg The port asked for this to have its own line rather than sitting inside a drive report, and they are right -- it is a third independent confirmation of a claim that the port authors from. The claim rested on GamePart_Title s sub_821C5580 playing cue 1103 (static code) and on the bank s two declared wave sizes matching what an XMA probe saw (disc census). On a driven boot, BGM_103 s two waves were handed to the XMA decoder at the moment the main menu appeared -- observed being decoded on arrival at the screen, rather than inferred from a table or matched by size afterwards. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd --- docs/port/HANDOFF.md | 14 +++ tools/re-capture/focus_from_capture.py | 140 +++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100755 tools/re-capture/focus_from_capture.py diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index 45f364f9..b14b33f8 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -666,6 +666,20 @@ finding stops resting on one asset. ⚠️ It needs a **driven, rendered** run capture will carry the ~10 % additive padding. `tools/re-capture/newgame_path.sh` drives to `SELECT DATA` and would need one more Ⓐ. +## ✅ 2026-08-29 — `BGM_103` confirmed from the RUNTIME, a third independent leg + +"The menu's music is `BGM_103`" rested on two legs: `GamePart_Title`'s +`sub_821C5580` playing cue 1103 (static code), and the bank's two declared wave +sizes matching what an XMA probe saw (disc census). It now has a third, from a +direction neither could reach. + +On a driven boot, **`BGM_103`'s two waves — 3 876 864 / 3 930 112 B — were +handed to the XMA decoder at the moment the main menu appeared.** Not inferred +from a cue table, not matched by size after the fact: observed being decoded, on +arrival at the screen. Recorded in +[`s00a-drive-blocked-by-focus.md`](../re/s00a-drive-blocked-by-focus.md), where +it turned up incidentally. + ## Status | | Question | State | Answer / link | diff --git a/tools/re-capture/focus_from_capture.py b/tools/re-capture/focus_from_capture.py new file mode 100755 index 00000000..06a16934 --- /dev/null +++ b/tools/re-capture/focus_from_capture.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Which menu button is focused, using ONLY committed captures. + +`tools/port/which-focus` answers this by rendering every focus state in the +port's Godot project and taking the minimum difference. That is a sound method +and it passed its controls -- but it needs Godot, which this container does not +have, and rendering the port's project is outside the decoder's role. This does +the same job from the framebuffer captures alone. + +METHOD. The focus highlight is the only thing that moves between two captures +of the same menu with different focus. So for an unknown shot U and a reference +R whose focus is known, the positive part of (U - R) peaks on U's focused row +and the negative part peaks on R's. Two captures with known, different focus +therefore calibrate the row->button mapping directly, and no geometry has to be +assumed -- which matters, because assuming `rest_y` was a band centre is exactly +how an earlier attempt of mine mis-assigned a band and produced a wrong answer. + +CONTROLS (run on every invocation; the tool refuses if any fails): + * the calibration pair must recover its own two answers; + * a frame with no menu must NOT produce a confident verdict. + + focus_from_capture.py SHOT.png [--json] + +⚠️ LIMIT, stated because it matters: the only two full main-menu captures with +a known focus state are REF_A and REF_B, which are this tool's own calibration +inputs. So reproducing them is self-consistency, NOT validation. The honest +validation is against a known TRANSITION rather than a known state: press the +d-pad down once and the reported button must advance by exactly one. A drive +that does that is testing this tool, not trusting it. +""" +import json +import os +import sys + +import numpy as np +from PIL import Image + +CAP = "/work/docs/re/captures/title-builds" +REF_A = f"{CAP}/live-main-menu.png" # NEW GAME focused +REF_B = f"{CAP}/live-main-menu-options-focused.png" # OPTIONS focused +BUTTONS = ["NEW GAME", "LOAD GAME", "TUTORIAL", "OPTIONS", "EXTRAS"] +IDX_A, IDX_B = 0, 3 +MIN_MARGIN = 2.0 + +def is_menu(a, thresh=0.85): + """Is this frame the main menu at all? + + The focus statistic below is a peak-to-median ratio on a difference image, + and a difference against ANY dissimilar frame has a large peak -- a title + screen scored 2.88 and sailed past a 2.0 bar. So the screen identity has to + be established FIRST, with the zncc classifier controlled 6/6 elsewhere. + """ + g = a.mean(axis=2) + z = (g - g.mean()) / (g.std() + 1e-9) + ref = np.asarray(Image.open(REF_A).convert("L"), float)[:675, :1279] + zr = (ref - ref.mean()) / (ref.std() + 1e-9) + return float((z * zr).mean()) >= thresh + + +def load(p): + a = np.asarray(Image.open(p).convert("RGB"), float) + # Normalise to the captures' 1279x675 top-left crop: a 1280x720 guest frame + # and a 1279x675 screenshot are the same pixels, cropped, not scaled. + return a[:675, :1279] + +def row_profile(u, r): + """Row-sums of the positive part of (u - r), over the button column band.""" + d = (u - r).mean(axis=2)[:, 500:820] + return np.clip(d, 0, None).sum(axis=1) + +def peak_row(prof, smooth=9): + k = np.ones(smooth) / smooth + s = np.convolve(prof, k, mode="same") + return int(np.argmax(s)), float(s.max()), float(np.median(s)) + +def calibrate(): + A, B = load(REF_A), load(REF_B) + ra, _, _ = peak_row(row_profile(A, B)) # A's focus row (NEW GAME) + rb, _, _ = peak_row(row_profile(B, A)) # B's focus row (OPTIONS) + pitch = (rb - ra) / (IDX_B - IDX_A) + return A, B, ra, pitch + +def classify(shot, A, B, ra, pitch): + """Return (button, margin). Compares against BOTH references and agrees.""" + votes = [] + for ref, ref_idx in ((A, IDX_A), (B, IDX_B)): + prof = row_profile(shot, ref) + r, peak, med = peak_row(prof) + # A zero median makes this explode -- floor it at a level that is + # small but real, so a degenerate comparison reads as "huge" rather + # than 1e10, and cap it so the printed number stays meaningful. + margin = min(peak / max(med, 1.0), 999.0) + idx = int(round((r - ra) / pitch)) + votes.append((idx, margin, ref_idx)) + # If the shot IS one of the references, that comparison is degenerate (all + # zero) -- keep the vote with the larger margin. + votes.sort(key=lambda v: -v[1]) + idx, margin, _ = votes[0] + if not (0 <= idx < len(BUTTONS)): + return None, margin + return BUTTONS[idx], margin + +def main(): + args = [a for a in sys.argv[1:] if not a.startswith("--")] + as_json = "--json" in sys.argv + if not args: + print(__doc__); return 2 + A, B, ra, pitch = calibrate() + + # --- control 1: the calibration pair must recover its own answers + for ref, want in ((A, "NEW GAME"), (B, "OPTIONS")): + got, _ = classify(ref, A, B, ra, pitch) + if got != want: + print(f"CONTROL FAILED: calibration pair gave {got}, expected {want}", + file=sys.stderr) + return 1 + # --- control 2: a frame with no menu must be rejected as not-a-menu + neg = f"{CAP}/live-title-press-a.png" + if os.path.exists(neg) and is_menu(load(neg)): + print("CONTROL FAILED: a title frame was accepted as a menu", file=sys.stderr) + return 1 + + shot = load(args[0]) + if not is_menu(shot): + if as_json: + print(json.dumps({"button": None, "margin": 0.0, "decided": False, + "reason": "not the main menu"})) + else: + print("UNDECIDED (not the main menu)") + return 1 + got, margin = classify(shot, A, B, ra, pitch) + ok = got is not None and margin >= MIN_MARGIN + if as_json: + print(json.dumps({"button": got, "margin": round(margin, 3), "decided": ok})) + else: + print(f"{got if ok else 'UNDECIDED'} margin={margin:.2f}") + return 0 if ok else 1 + +if __name__ == "__main__": + sys.exit(main())