re: the focus reader was two items out -- initial focus is NEW GAME, and my labels were wrong
menu_focus.py's row centres are design-space rows from screenshot output; my probes fed it whole-display x11grab frames carrying Xenia's chrome and a surface scaled 1.060. Caught by ground truth, not by a control: the probe announced 'on EXTRAS', pressed A, and opened OPTIONS. Measured directly with ring_row.py: initial focus on a fresh boot is NEW GAME, 2/2 fresh boots, both the first menu entry. That agrees with boot_menu.sh's own line and menu-state-in-memory.md's four-downs, and withdraws this page's 'TUTORIAL 2/2' as the outlier. Persistence stands and is now geometry-free -- 384.0 vs 385.5, 1.5 px apart. An equality test is immune to a constant offset, which is why the conclusion survived a broken reader when the published item names did not. The control was structurally blind: 'two DOWNs move two items' tests relative motion, and a constant offset preserves it exactly. EXTRAS remains unmeasured; that run navigated to OPTIONS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
This commit is contained in:
@@ -11,6 +11,36 @@ delivered.
|
||||
|
||||
|
||||
|
||||
|
||||
## 🔴 2026-08-30 (later) — correcting today's focus delivery: the names were wrong
|
||||
|
||||
**Read this before the persistence section above.** Two things change, one of
|
||||
them a value you may have taken.
|
||||
|
||||
* 🔴 **The item names I gave for the persistence run were two positions out.**
|
||||
Reported `TUTORIAL → EXTRAS → EXTRAS`; the truth is
|
||||
**`NEW GAME → TUTORIAL → TUTORIAL`**. My focus reader used design-space rows
|
||||
against whole-display captures that carry Xenia's window chrome and a surface
|
||||
scaled 1.060.
|
||||
* ✅ **The persistence conclusion is unchanged and is now geometry-free** — the
|
||||
ring sits at y 384.0 before the round trip and 385.5 after, 1.5 px apart. An
|
||||
equality test is immune to a constant offset, which is exactly why the
|
||||
conclusion survived a reader the labels did not.
|
||||
* ✅ **Initial focus on a fresh boot is `NEW GAME`** — measured directly, 2/2
|
||||
fresh boots, both the *first* menu entry. **Your authored `NEW GAME` is now a
|
||||
measured value.** I said earlier today that nothing confirmed it; that is no
|
||||
longer true.
|
||||
* ⚠️ **My control could not have caught this.** "Two DOWNs move two items" tests
|
||||
relative motion, which a constant offset preserves. It passed on a reader two
|
||||
items wrong.
|
||||
|
||||
❔ **EXTRAS is still unmeasured** — the run that was to settle it navigated to
|
||||
OPTIONS believing it was EXTRAS, so nothing about EXTRAS was observed and your
|
||||
`initial_focus: ptbtn11` label remains undecided.
|
||||
|
||||
[data](../re/data/menu-focus-reader-offset.txt)
|
||||
|
||||
|
||||
## ✅ 2026-08-30 — the main menu remembers its cursor; re-entry is not a reset
|
||||
|
||||
**measured, and it changes what `on_cancel` should do.** Ⓑ from the menu to the
|
||||
|
||||
60
docs/re/data/menu-focus-reader-offset.txt
Normal file
60
docs/re/data/menu-focus-reader-offset.txt
Normal file
@@ -0,0 +1,60 @@
|
||||
# The focus reader was wrong on x11grab frames -- and what that changes.
|
||||
# MEASURED 2026-08-30.
|
||||
#
|
||||
# menu_focus.py's row centres YS = [166,241,315,390,465] are DESIGN-SPACE rows,
|
||||
# derived from `screenshot` captures. My probes feed it ffmpeg x11grab frames of
|
||||
# the whole X display, which include Xenia's title bar and menu bar and show the
|
||||
# game surface SCALED. Same numbers, different coordinate system.
|
||||
#
|
||||
# CAUGHT BY GROUND TRUTH, not by a control: the probe announced "on EXTRAS",
|
||||
# pressed A, and opened OPTIONS. I looked at the frame.
|
||||
#
|
||||
# THE OFFSET, measured directly rather than assumed -- brightest gutter cluster
|
||||
# (x 500:542), excluding the y<50 window-decoration band and the footer:
|
||||
#
|
||||
# reach/1-F1.png ring y = 225.5 (first menu entry of a fresh boot)
|
||||
# reach/2-F2.png ring y = 384.0 (after 2 delivery-confirmed DOWNs)
|
||||
# reach/4-F3.png ring y = 385.5 (after B -> title -> A -> menu)
|
||||
#
|
||||
# spacing = (384.0 - 225.5) / 2 = 79.25 px per item
|
||||
# design spacing is 74.75, so the surface is scaled by 1.060 -- UP, not down
|
||||
# capture_y = 49.5 + 1.060 * design_y (checks: 241->305 vs 304.75 seen,
|
||||
# 315->383.4 vs 384.0 seen)
|
||||
#
|
||||
# ⚠️ MY FIRST CORRECTION WAS ALSO WRONG. I assumed the 1280x675-inside-1280x720
|
||||
# note meant the surface was scaled DOWN by 0.9375. Applying that gave F2 =
|
||||
# OPTIONS, i.e. three rows below F1 where only two DOWNs were delivered -- the
|
||||
# arithmetic refused it. Measuring the spacing settled it in one step.
|
||||
#
|
||||
################################################################################
|
||||
# WHAT THIS CHANGES
|
||||
#
|
||||
# ✅ INITIAL FOCUS ON A FRESH BOOT IS **NEW GAME**, not TUTORIAL.
|
||||
# F1's ring at y=225.5 is design row 166 = item 0. Two fresh boots, both the
|
||||
# FIRST menu entry of the boot, both NEW GAME (this run, and the earlier run
|
||||
# whose old-reader value of LOAD GAME is the same frame misread).
|
||||
#
|
||||
# That AGREES with boot_menu.sh's own closing line and with
|
||||
# menu-state-in-memory.md's four-downs-to-EXTRAS, which only counts from NEW
|
||||
# GAME. menu-navigation-semantics.md's "TUTORIAL, 2/2" is the outlier.
|
||||
#
|
||||
# ✅ THE PERSISTENCE FINDING STANDS, and is now confirmed WITHOUT any geometry:
|
||||
# F2 ring y = 384.0, F3 = 385.5. 1.5 px apart. The cursor is where it was
|
||||
# left. An equality test is immune to a constant offset, which is exactly why
|
||||
# that conclusion survived a broken reader.
|
||||
#
|
||||
# 🔴 BUT THE ITEM NAMES I PUBLISHED FOR IT WERE WRONG.
|
||||
# Reported: F1 TUTORIAL -> F2 EXTRAS -> F3 EXTRAS.
|
||||
# Truth: F1 NEW GAME -> F2 TUTORIAL -> F3 TUTORIAL.
|
||||
# Two positions out. The conclusion does not change; the labels do.
|
||||
#
|
||||
# 🔴 AND MY CONTROL COULD NOT HAVE CAUGHT IT. "Two DOWNs must move the cursor
|
||||
# exactly two items" tests RELATIVE motion, and a constant offset preserves
|
||||
# relative motion exactly. A control that only checks differences is blind to
|
||||
# every error in the origin. It passed on a reader that was two items out.
|
||||
#
|
||||
# ❔ THE EXTRAS QUESTION IS STILL UNANSWERED. The probe navigated to OPTIONS
|
||||
# believing it was EXTRAS, so E1/E2/E3 are the OPTIONS screen and a different
|
||||
# screen respectively (E3 glyph 1776 is the title's plate band -- A on the
|
||||
# wrong screen went somewhere else entirely). Nothing about EXTRAS was
|
||||
# measured. sylpheed-port's `initial_focus: ptbtn11` label is still undecided.
|
||||
@@ -30,6 +30,40 @@ with [`tools/re-capture/menu_focus.py`](../../tools/re-capture/menu_focus.py).
|
||||
Wrap holds on both screens tested — the 5-item main menu and the 3-item `EXTRAS`
|
||||
submenu — so it is a menu rule, not a per-screen table.
|
||||
|
||||
## 🔴 2026-08-30 (later) — the item NAMES below were wrong, and initial focus is NEW GAME
|
||||
|
||||
**The reader was broken and my control could not see it.** `menu_focus.py`'s row
|
||||
centres are **design-space** rows read off `screenshot` output; my probes fed it
|
||||
whole-display `x11grab` frames, which carry Xenia's title bar and menu bar and
|
||||
show the game surface scaled by **1.060**. Caught by ground truth, not by a
|
||||
control: the probe announced *"on EXTRAS"*, pressed Ⓐ, and opened **OPTIONS**.
|
||||
|
||||
Ring rows measured directly (`tools/re-capture/ring_row.py`), no assumed geometry:
|
||||
|
||||
| frame | ring y | true item |
|
||||
|---|---|---|
|
||||
| first menu entry, fresh boot | **225.5** | **`NEW GAME`** |
|
||||
| after 2 delivery-confirmed DOWNs | 384.0 | `TUTORIAL` |
|
||||
| after Ⓑ → title → Ⓐ → menu | **385.5** | `TUTORIAL` |
|
||||
|
||||
* ✅ **Initial focus on a fresh boot is `NEW GAME`** — 2/2 fresh boots, both the
|
||||
*first* menu entry. That agrees with `boot_menu.sh`'s own closing line and with
|
||||
[`menu-state-in-memory.md`](menu-state-in-memory.md)'s four-downs-to-`EXTRAS`,
|
||||
which only counts from `NEW GAME`. ~~"initial focus, main menu — TUTORIAL"~~ in
|
||||
the Q5 table below is **withdrawn**: it is the outlier, and this reading is the
|
||||
direct one.
|
||||
* ✅ **Persistence stands, and is now geometry-free**: 384.0 vs 385.5, 1.5 px
|
||||
apart. An equality test is immune to a constant offset, which is why that
|
||||
conclusion survived a broken reader when the labels did not.
|
||||
* 🔴 **The names I published for it were two positions out** — reported
|
||||
`TUTORIAL → EXTRAS → EXTRAS`, truth `NEW GAME → TUTORIAL → TUTORIAL`.
|
||||
* ⚠️ **The control was structurally blind to this.** "Two DOWNs must move the
|
||||
cursor exactly two items" tests *relative* motion, and a constant offset
|
||||
preserves relative motion exactly. **A control that only checks differences
|
||||
cannot see an error in the origin.**
|
||||
|
||||
[data](data/menu-focus-reader-offset.txt)
|
||||
|
||||
## ✅ 2026-08-30 — the menu REMEMBERS its cursor across menu → title → menu
|
||||
|
||||
**measured** — [`data/focus-persists-across-title.txt`](data/focus-persists-across-title.txt),
|
||||
|
||||
185
tools/re-capture/extras_focus_persistence.py
Normal file
185
tools/re-capture/extras_focus_persistence.py
Normal file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Does the EXTRAS submenu remember its cursor across leave -> re-enter?
|
||||
|
||||
sylpheed-port's contract-check asserted that EXTRAS does NOT persist. Nothing
|
||||
measured that. The corpus has EXTRAS' initial focus as MISSION SELECT from a
|
||||
SINGLE entry, and Ⓑ-from-a-submenu restoring the PARENT's focus 4/4 -- neither
|
||||
says what a submenu's own cursor does on re-entry. Their label
|
||||
`initial_focus: ptbtn11, kind: "measured"` is exposed to the same history problem
|
||||
raised for the main menu: taken on one entry, it may be reading history.
|
||||
|
||||
The main menu was measured to PERSIST (focus-persists-across-title.txt), so the
|
||||
question is live in both directions.
|
||||
|
||||
⚠️ NO HAND-GUESSED GEOMETRY. menu_focus.py's ys are the five-item main menu and
|
||||
do not transfer to a three-item submenu, and the only EXTRAS capture in the
|
||||
corpus is a 562x182 crop. So the ring is located by DIFFERENCE instead: the
|
||||
pixels that change when the cursor moves ARE the ring's footprint. Persistence is
|
||||
then decided inside that mask, which needs no coordinates at all.
|
||||
|
||||
🔴 CONTROL, and the run stops on it: the E1->E2 difference must be ONE COMPACT
|
||||
CLUSTER. If moving the cursor changes the whole frame, the mask is not the ring
|
||||
and nothing after it may be read. (sylpheed-port measured one 46x44 cluster,
|
||||
0.155 % of frame, across a full ring cycle on their renderer -- an independent
|
||||
expectation for what this should look like.)
|
||||
|
||||
extras_focus_persistence.py LOG OUTDIR [wait_s]
|
||||
"""
|
||||
import os, re, subprocess, sys, time
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
LOG, OUT = sys.argv[1], sys.argv[2]
|
||||
WAIT = float(sys.argv[3]) if len(sys.argv) > 3 else 600
|
||||
W, H = 1280, 720
|
||||
MENU_LO, MENU_HI, MENU_HOLD = 250, 420, 6
|
||||
PAD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pad.py")
|
||||
NAMES = ["NEW GAME", "LOAD GAME", "TUTORIAL", "OPTIONS", "EXTRAS"]
|
||||
YS = [166, 241, 315, 390, 465]
|
||||
T0 = time.time()
|
||||
|
||||
|
||||
def deliveries(vk):
|
||||
pat = re.compile((r"RE-INPUT\] XamInputGetKeystrokeEx -> user=\d+ vk=%s flags=0001" % vk).encode())
|
||||
try:
|
||||
return len(pat.findall(open(LOG, "rb").read()))
|
||||
except FileNotFoundError:
|
||||
return 0
|
||||
|
||||
|
||||
def press(btn, vk, tries=5):
|
||||
for k in range(tries):
|
||||
before = deliveries(vk)
|
||||
subprocess.run([sys.executable, PAD, "tap", btn, "0.5"], check=False)
|
||||
for _ in range(20):
|
||||
time.sleep(0.25)
|
||||
if deliveries(vk) > before:
|
||||
print(f"[{time.time()-T0:7.1f}s] {btn} delivered", flush=True)
|
||||
return True
|
||||
print(f"[{time.time()-T0:7.1f}s] {btn} NOT delivered (attempt {k+1})", flush=True)
|
||||
return False
|
||||
|
||||
|
||||
def _open():
|
||||
return subprocess.Popen(
|
||||
["ffmpeg", "-loglevel", "error", "-f", "x11grab", "-draw_mouse", "0",
|
||||
"-video_size", f"{W}x{H}", "-i", ":98", "-r", "4",
|
||||
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
|
||||
stdout=subprocess.PIPE, bufsize=W * H * 3 * 2)
|
||||
|
||||
|
||||
def fresh():
|
||||
"""A CURRENT frame. Reading the pipe after a sleep returns a buffered one."""
|
||||
q = _open(); a = None
|
||||
for _ in range(3):
|
||||
buf = q.stdout.read(W * H * 3)
|
||||
if len(buf) == W * H * 3:
|
||||
a = np.frombuffer(buf, np.uint8).reshape(H, W, 3).astype(int)
|
||||
q.kill()
|
||||
return a
|
||||
|
||||
|
||||
def glyph(a):
|
||||
r, g, b = a[:, :, 0], a[:, :, 1], a[:, :, 2]
|
||||
return int(((g > 130) & (g - r > 45) & (g - b > 45)).sum())
|
||||
|
||||
|
||||
def mainfocus(a):
|
||||
g = np.asarray(Image.fromarray(a.astype(np.uint8)).convert("L"), dtype=float)
|
||||
v = [g[y - 20:y + 20, 500:542].max() for y in YS]
|
||||
return int(np.argmax(v)), v
|
||||
|
||||
|
||||
def wait_menu(limit=90):
|
||||
t = time.time(); streak = 0
|
||||
while time.time() - t < limit:
|
||||
a = fresh()
|
||||
if a is None:
|
||||
continue
|
||||
if MENU_LO <= glyph(a) <= MENU_HI:
|
||||
streak += 1
|
||||
if streak >= 2:
|
||||
return a
|
||||
else:
|
||||
streak = 0
|
||||
return None
|
||||
|
||||
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
# ---- 1. on the main menu, walk to EXTRAS ------------------------------------
|
||||
a = wait_menu()
|
||||
if a is None:
|
||||
sys.exit("never saw the main menu")
|
||||
f, v = mainfocus(a)
|
||||
print(f"[{time.time()-T0:7.1f}s] MAIN MENU focus = {NAMES[f]} ring "
|
||||
+ " ".join(f"{x:5.0f}" for x in v), flush=True)
|
||||
need = (4 - f) % 5
|
||||
print(f" {need} DOWN to reach EXTRAS", flush=True)
|
||||
for _ in range(need):
|
||||
if not press("DOWN", "5811"):
|
||||
sys.exit("a DOWN was never delivered")
|
||||
time.sleep(0.8)
|
||||
time.sleep(1.5)
|
||||
a = fresh(); f2, v2 = mainfocus(a)
|
||||
print(f" now on {NAMES[f2]} ring " + " ".join(f"{x:5.0f}" for x in v2), flush=True)
|
||||
if f2 != 4:
|
||||
sys.exit(f"CONTROL FAILED: wanted EXTRAS, on {NAMES[f2]}")
|
||||
print("✅ on EXTRAS", flush=True)
|
||||
|
||||
# ---- 2. enter EXTRAS, capture E1, move, capture E2 --------------------------
|
||||
if not press("A", "5800"):
|
||||
sys.exit("A never delivered")
|
||||
time.sleep(6.0)
|
||||
E1 = fresh(); Image.fromarray(E1.astype(np.uint8)).save(f"{OUT}/E1.png")
|
||||
print(f"[{time.time()-T0:7.1f}s] IN EXTRAS (glyph {glyph(E1)}) — E1 captured", flush=True)
|
||||
|
||||
if not press("DOWN", "5811"):
|
||||
sys.exit("a DOWN was never delivered inside EXTRAS")
|
||||
time.sleep(2.5)
|
||||
E2 = fresh(); Image.fromarray(E2.astype(np.uint8)).save(f"{OUT}/E2.png")
|
||||
print(f"[{time.time()-T0:7.1f}s] E2 captured after 1 DOWN", flush=True)
|
||||
|
||||
# ---- 3. CONTROL: the difference must be one compact cluster -----------------
|
||||
d = (np.abs(E1 - E2).max(axis=2) > 24)
|
||||
frac = d.mean()
|
||||
ys, xs = np.nonzero(d)
|
||||
if len(ys) == 0:
|
||||
sys.exit("🔴 CONTROL FAILED: the cursor did not move at all (E1 == E2)")
|
||||
bbox = (int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max()))
|
||||
bw, bh = bbox[2] - bbox[0] + 1, bbox[3] - bbox[1] + 1
|
||||
dens = len(ys) / (bw * bh)
|
||||
print(f" diff: {100*frac:.3f}% of frame, bbox {bw}x{bh} at ({bbox[0]},{bbox[1]}), "
|
||||
f"fill {100*dens:.0f}%", flush=True)
|
||||
if frac > 0.05:
|
||||
sys.exit(f"🔴 CONTROL FAILED: {100*frac:.1f}% of the frame changed — that is not a ring")
|
||||
print("✅ CONTROL PASSED: the move changed one localised region", flush=True)
|
||||
|
||||
# ---- 4. leave, re-enter, capture E3 ----------------------------------------
|
||||
if not press("B", "5801"):
|
||||
sys.exit("B never delivered")
|
||||
a = wait_menu()
|
||||
if a is None:
|
||||
sys.exit("B did not return to the main menu")
|
||||
f3, _ = mainfocus(a)
|
||||
print(f"[{time.time()-T0:7.1f}s] back on the MAIN MENU, focus = {NAMES[f3]}", flush=True)
|
||||
if not press("A", "5800"):
|
||||
sys.exit("A never delivered on re-entry")
|
||||
time.sleep(6.0)
|
||||
E3 = fresh(); Image.fromarray(E3.astype(np.uint8)).save(f"{OUT}/E3.png")
|
||||
print(f"[{time.time()-T0:7.1f}s] RE-ENTERED EXTRAS — E3 captured", flush=True)
|
||||
|
||||
# ---- 5. decide, inside the ring mask only ----------------------------------
|
||||
m = d
|
||||
d1 = float(np.abs(E3 - E1).max(axis=2)[m].mean())
|
||||
d2 = float(np.abs(E3 - E2).max(axis=2)[m].mean())
|
||||
print(f"\n inside the ring mask ({int(m.sum())} px):")
|
||||
print(f" mean |E3 - E1| = {d1:6.2f} (E1 = where EXTRAS opened)")
|
||||
print(f" mean |E3 - E2| = {d2:6.2f} (E2 = where I left it)")
|
||||
if d2 < d1 * 0.5:
|
||||
print("\n=> EXTRAS PERSISTS: re-entry matches where I left the cursor")
|
||||
elif d1 < d2 * 0.5:
|
||||
print("\n=> EXTRAS RESETS: re-entry matches where it first opened")
|
||||
else:
|
||||
print(f"\n=> UNDECIDED: {d1:.2f} vs {d2:.2f} are too close to separate")
|
||||
print("EXTRAS FOCUS RUN DONE", flush=True)
|
||||
25
tools/re-capture/extras_focus_session.sh
Executable file
25
tools/re-capture/extras_focus_session.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# Boot and run extras_focus_persistence.py. Plate-pulse path, not skip_intro.
|
||||
set -u
|
||||
export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98
|
||||
SD="$(cd "$(dirname "$0")" && pwd)"
|
||||
OUT="${OUT:-/sylph-home/re/extrasfocus}"; mkdir -p "$OUT"
|
||||
LOG="$OUT/canary.stdout"
|
||||
bash "$SD/ensure_single_emulator.sh"
|
||||
if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then
|
||||
rm -f "/tmp/.X${DISPLAY#:}-lock" 2>/dev/null || true
|
||||
nohup bash -c 'Xvfb "$0" -screen 0 1280x720x24 -ac -nolisten tcp \
|
||||
+extension GLX +extension RANDR >/tmp/xvfb98.log 2>&1' "$DISPLAY" </dev/null >/dev/null 2>&1 &
|
||||
for _ in $(seq 1 50); do xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 && break; done
|
||||
nohup env DISPLAY="$DISPLAY" HOME=/sylph-home openbox </dev/null >/tmp/openbox98.log 2>&1 &
|
||||
fi
|
||||
XUID="${SYLPH_XUID:-$(ls "${XENIA_CONTENT:-$HOME/.local/share/Xenia/content}" 2>/dev/null | head -1)}"
|
||||
[ -n "$XUID" ] || { echo "NO PROFILE"; exit 2; }
|
||||
echo "── EFFECTIVE CONFIG ──"; echo " out=$OUT profile=$XUID gate=plate pulse"
|
||||
cd /sylph-home/re
|
||||
nohup run-canary --apu=sdl --log_mask=13 --log_level=2 \
|
||||
--logged_profile_slot_0_xuid="$XUID" </dev/null >"$LOG" 2>&1 &
|
||||
# reach the title and the menu with the probe that already does it
|
||||
python3 "$SD/focus_persistence.py" "$LOG" "$OUT/reach" 900 >"$OUT/reach.log" 2>&1
|
||||
echo "-- reached the menu; now the EXTRAS question --"
|
||||
python3 "$SD/extras_focus_persistence.py" "$LOG" "$OUT" 600
|
||||
78
tools/re-capture/ring_row.py
Executable file
78
tools/re-capture/ring_row.py
Executable file
@@ -0,0 +1,78 @@
|
||||
#!/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 (design spacing 74.75 -> surface scaled 1.060)
|
||||
capture_y = 49.5 + 1.060 * design_y
|
||||
Checks: design 241 -> 305 predicted vs 304.75 measured; 315 -> 383.4 vs 384.0.
|
||||
|
||||
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}")
|
||||
Reference in New Issue
Block a user