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