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:
sylph-decoder
2026-08-30 21:54:32 +00:00
parent addf753c44
commit 8ebbfc70c1
6 changed files with 412 additions and 0 deletions

View File

@@ -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

View 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.

View File

@@ -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),

View 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)

View 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
View 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}")