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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
This commit is contained in:
sylph-decoder
2026-08-29 17:33:29 +00:00
parent b7930e6616
commit 8b4dcccb67
2 changed files with 154 additions and 0 deletions

View File

@@ -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 |

View File

@@ -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())