#!/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 "" print(f"{p.split('/')[-1]:22} ring y = {y} -> {nm}")