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:
@@ -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 |
|
||||
|
||||
140
tools/re-capture/focus_from_capture.py
Executable file
140
tools/re-capture/focus_from_capture.py
Executable 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())
|
||||
Reference in New Issue
Block a user