No new boot was spent: six runs had already captured the first menu entry of a fresh boot, and all six read NEW GAME. Three of them follow a session that ended with the cursor on EXTRAS or OPTIONS, which is what makes it a test of persistence rather than a repeated observation. Reach stated rather than implied: every session ends with the emulator KILLED, so a game that writes menu state on a clean shutdown would never get the chance. This measures 'does not survive a killed session'. Refutation attempt on the port's extras/initial_focus: ptbtn11 -- it SURVIVES. ptbtn11 is the top button on the EXTRAS build, with the main menu as a control where ptbtn01 is top and is known to be NEW GAME. Incidentally corrects ring_row.py's stated calibration. It cited capture_y = 49.5 + 1.060*design_y, fitted against menu_focus.py's row centres, which are NOT the disc's button rows -- the disc says 162/242/322/401/482, spacing 80, and menu_focus.py drifts up to 17 px against them. Re-fitted: 64.82 + 0.9919*design_y, residuals under 0.7 px. No item assignment changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
92 lines
3.5 KiB
Python
Executable File
92 lines
3.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Locate the menu focus ring by MEASUREMENT, in whatever frame you have.
|
|
|
|
🔴 Why this exists. `menu_focus.py`'s row centres [166,241,315,390,465] are
|
|
DESIGN-SPACE rows read off `screenshot` output. Feeding it an ffmpeg x11grab
|
|
frame of the whole X display silently reads the wrong rows: the frame carries
|
|
Xenia's title bar and menu bar, and the game surface is scaled. On 2026-08-30 a
|
|
probe announced "on EXTRAS", pressed Ⓐ, and opened OPTIONS -- two items out.
|
|
|
|
⚠️ AND THE CONTROL COULD NOT CATCH IT. "Two DOWNs must move the cursor two
|
|
items" tests RELATIVE motion, which a constant offset preserves exactly. It
|
|
passed on a reader that was two items wrong. So this returns the ring's measured
|
|
ROW, and callers compare rows; naming an item needs a calibration, below.
|
|
|
|
Measured on the x11grab frames of 2026-08-30:
|
|
spacing 79.25 px per item, ROW0 225.5 (both read off captures directly)
|
|
|
|
🔴 CALIBRATION CORRECTED 2026-08-31. This said "design spacing 74.75 -> surface
|
|
scaled 1.060, capture_y = 49.5 + 1.060 * design_y". That was fitted against
|
|
menu_focus.py's row centres [166,241,315,390,465], which are NOT the disc's button
|
|
rows. The disc says the main menu's five buttons sit at y 162/242/322/401/482 --
|
|
spacing 80, not 75 -- and menu_focus.py's values drift from +4 to -17 px against
|
|
them across the five rows (examples/extras_button_order.rs).
|
|
|
|
Re-fitting against the DISC rows:
|
|
capture_y = 64.82 + 0.9919 * design_y residuals all < 0.7 px
|
|
i.e. the surface is offset ~65 px in the capture and essentially NOT scaled. The
|
|
old 1.060 was an artefact of the wrong reference rows.
|
|
|
|
⚠️ No item assignment changes: ROW0 and SPACING below are measured from captures
|
|
directly and never used the bad fit.
|
|
|
|
ring_row.py FRAME.png [FRAME.png ...]
|
|
"""
|
|
import sys
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
GUTTER = (500, 542)
|
|
DECOR_ROWS = 50 # window title bar + menu bar live above this
|
|
FOOTER_Y = 620 # the button-legend strip is bright in the gutter too
|
|
NAMES = ["NEW GAME", "LOAD GAME", "TUTORIAL", "OPTIONS", "EXTRAS"]
|
|
ROW0, SPACING = 225.5, 79.25 # measured, main menu, x11grab
|
|
|
|
|
|
def ring_row(img):
|
|
"""The ring's y centre in THIS frame's own pixels, or None."""
|
|
g = np.asarray(img.convert("L"), dtype=float)
|
|
col = g[:, GUTTER[0]:GUTTER[1]].max(axis=1)
|
|
col[:DECOR_ROWS] = 0
|
|
ys = np.nonzero(col > 150)[0]
|
|
if len(ys) == 0:
|
|
return None
|
|
groups, cur = [], [int(ys[0])]
|
|
for y in ys[1:]:
|
|
if y - cur[-1] <= 4:
|
|
cur.append(int(y))
|
|
else:
|
|
groups.append(cur)
|
|
cur = [int(y)]
|
|
groups.append(cur)
|
|
groups = [g_ for g_ in groups if len(g_) >= 5 and g_[0] < FOOTER_Y]
|
|
if not groups:
|
|
return None
|
|
b = max(groups, key=len)
|
|
return (b[0] + b[-1]) / 2
|
|
|
|
|
|
def main_menu_item(y):
|
|
"""Item index on the MAIN MENU, from the measured calibration.
|
|
|
|
Only valid for the five-item main menu in an x11grab frame. Returns None if
|
|
the row is not within half a step of a row centre -- refusing is the point,
|
|
since a wrong name is what this file exists to prevent.
|
|
"""
|
|
if y is None:
|
|
return None
|
|
i = round((y - ROW0) / SPACING)
|
|
if not (0 <= i < len(NAMES)):
|
|
return None
|
|
if abs(y - (ROW0 + i * SPACING)) > SPACING * 0.4:
|
|
return None
|
|
return i
|
|
|
|
|
|
if __name__ == "__main__":
|
|
for p in sys.argv[1:]:
|
|
y = ring_row(Image.open(p))
|
|
i = main_menu_item(y)
|
|
nm = NAMES[i] if i is not None else "<not a main-menu row>"
|
|
print(f"{p.split('/')[-1]:22} ring y = {y} -> {nm}")
|