Files
Sylpheed/tools/re-capture/ring_row.py
sylph-decoder 5a16ce5aba method: a refusing instrument, and a boot that outlasts its capture script
Two traps this run paid for.

ring_row.py's ROW0/SPACING are x11grab constants. On a /sylph-home/re/shots/shot-0001.png grab of the
same live main menu the rows read 180.5/419.5/502.0 -- ROW0 is 45 px out, 0.57
of a step. The module refused rather than naming the wrong item, which is the
good failure, and is_main_menu() therefore returned False ON A REAL MAIN MENU. A
run gated on it would conclude 'not the menu' while sitting on the menu. Not
recalibrated: three rows from one session are not a calibration and other tools
share the constants; the module now says so where the numbers are.

menu_draw_capture.sh's 420 s title deadline fired, and the emulator left running
was at the settled title minutes later, took one A, and reached the menu first
try. A timeout is a measurement of the timeout. Leaving the emulator up after a
failed script rescued this run for one minute against a twenty-minute reboot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-31 06:08:41 +00:00

175 lines
7.4 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
# 🔴 THESE ARE x11grab CONSTANTS AND THEY DO NOT FIT A `screenshot` GRAB
# (2026-08-31). Three rows read off `screenshot` frames of a live main menu,
# ground-truthed by eye against the rendered PNG:
#
# NEW GAME 180.5 OPTIONS 419.5 EXTRAS 502.0 (1279x675)
# -> ROW0 ~180.5, SPACING ~80.4, i.e. ROW0 is 45 px = 0.57 of a step out
#
# `main_menu_item()` therefore REFUSES on such a frame rather than returning a
# wrong item, and `is_main_menu()` returns False ON A REAL MAIN MENU. That is the
# safe failure and it is still a failure: a script that gates on
# `is_main_menu()` will conclude "not the menu" while sitting on the menu.
#
# Not recalibrated here on purpose: three rows from one session are not a
# calibration, other tools share these constants, and the x11grab numbers are
# correct for x11grab. Whoever needs the `screenshot` path should measure it
# properly and give the module TWO calibrations selected by frame size, rather
# than moving one set of numbers and silently breaking the other.
MENU_GLYPH_LO, MENU_GLYPH_HI = 250, 420 # the glyph-327 menu detector
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
def menu_glyph(img):
"""The green-(A)-glyph pixel count, is_title.py's counter."""
a = np.asarray(img.convert("RGB"), dtype=int)
r, g, b = a[:, :, 0], a[:, :, 1], a[:, :, 2]
return int(((g > 130) & (g - r > 45) & (g - b > 45)).sum())
def is_main_menu(img):
"""Is this frame the MAIN MENU? Row AND signature, not row alone.
🔴 `main_menu_item(ring_row(f)) is not None` is NOT a main-menu test, and I
used it as one. On a TITLE frame the gutter carries a bright cluster at
y=243, which is within tolerance of row 0, so the title reads as "NEW GAME"
(glyph 714 — the plate's pulse band — against the menu's 327). It never
misfired in the sweeps because Ⓑ from a submenu goes to the menu, not the
title; the test was simply weaker than it was being trusted to be.
Both conditions, so a screen must have a main-menu ROW and the menu's glyph
SIGNATURE.
"""
if main_menu_item(ring_row(img)) is None:
return False
return MENU_GLYPH_LO <= menu_glyph(img) <= MENU_GLYPH_HI
def _selftest():
"""The reader must find a ring where one is, refuse where none is, and REFUSE
when it is not measuring at all.
The last is sylpheed-port's: their band check passed identity and the real
pair with an EMPTY band list, because every comparison read 0.0 dB — three
controls running and none asking whether the measurement was live.
"""
import glob
ok = True
menu = sorted(glob.glob("/sylph-home/re/*/reach/1-F1.png"))
title = sorted(glob.glob("/sylph-home/re/*/reach/3-title.png"))
if not menu or not title:
print("🔴 SELFTEST UNAVAILABLE: no reference frames"); return 3
m, t = Image.open(menu[0]), Image.open(title[0])
cases = [("finds the ring on a main menu", ring_row(m) is not None, True),
("names it NEW GAME", main_menu_item(ring_row(m)) == 0, True),
("accepts the main menu", is_main_menu(m), True),
("REJECTS the title (row alone would accept)", is_main_menu(t), False),
("row-alone DOES accept the title — the defect this guards",
main_menu_item(ring_row(t)) is not None, True)]
for name, got, want in cases:
good = (got == want)
ok &= good
print(f" {'' if good else '🔴'} {name:52} {got} (want {want})")
# LIVENESS: a blanked gutter must return None, not a number.
import numpy as _np
blank = Image.fromarray(_np.zeros((720, 1280, 3), dtype=_np.uint8))
live = ring_row(blank) is None
ok &= live
print(f" {'' if live else '🔴'} refuses a blank frame (liveness) {not live}")
if not ok:
print("🔴 RING_ROW SELFTEST FAILED — nothing it reports can be trusted.")
return 2
print(" ✅ ring_row selftest passed")
return 0
if __name__ == "__main__":
if "--selftest" in sys.argv:
sys.exit(_selftest())
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}")