Files
Sylpheed/tools/re-capture/focus_persistence.py
sylph-decoder 1e0d819108 tools: add --reach-only, so a caller that just needs the menu does not run the round trip
The ja DIFFICULTY capture failed because focus_persistence.py's round trip --
menu, B to title, A back -- did not return, leaving the game off-menu, and the
sweep that followed timed out with nothing to work with. Arriving at the menu is
the cheap part; the round trip is that probe's own experiment and is not every
caller's.

--reach-only stops once the menu is reached, and the session script passes it
through REACH_ONLY.

Committed before running.

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

220 lines
9.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""Does the main menu REMEMBER its cursor across menu -> title -> menu?
menu-navigation-semantics.md carries this 🟡: "Initial focus is reproducible but
not established as invariant. Both of my boots opened on TUTORIAL, and both used
boot_menu.sh." Two runs through one harness are not two samples. And the sources
DISAGREE: boot_menu.sh's own closing line says NEW GAME, and
menu-state-in-memory.md reaches EXTRAS in four downs, which only counts from NEW
GAME. Two say NEW GAME, one says TUTORIAL.
⚠️ THIS DELIBERATELY DOES NOT USE boot_menu.sh. Its title gate admits a "static"
screen at d <= 1500 between grabs 0.6 s apart, and the title never stills -- the
sweep leaves free-run. Minimum observed 1551 over 72 samples, 0 able to pass.
See harness-title-gate-assumes-a-static-title.md. Everything here is instead the
harness b_from_menu.py validated: plate-pulse title detector, glyph-327 menu
detector, delivery confirmed from [RE-INPUT] rather than from the pad.
SEQUENCE
F1 focus when the menu first appears <- re-measures initial focus
F2 focus after 2x DOWN <- CONTROL for the focus reader
F3 focus after B (to title) then A (back) <- persist or reset?
F3 == F2 => the menu restores where you were. F3 == F1 => it resets.
🔴 CONTROL GATE: if F2 is not exactly two items below F1 (with wrap), the reader
is not tracking the cursor and NOTHING after it may be read. The run says so and
stops rather than reporting a number it cannot justify.
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 900
W, H = 1280, 720
NEED, CEIL, HOLD = 500, 2500, 12 # title plate pulse band
MENU_LO, MENU_HI, MENU_HOLD = 250, 420, 6 # glyph-327 menu detector
PAD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pad.py")
# 🔴 This used menu_focus.py's DESIGN-SPACE rows [166,241,315,390,465] against
# x11grab frames, which carry Xenia's window chrome and a surface scaled 1.060 --
# and reported item names TWO POSITIONS OUT for a whole session. The control
# ("2 DOWNs move 2 items") could not catch it, because a constant offset
# preserves relative motion exactly. See data/menu-focus-reader-offset.txt.
# Now measured, via the shared reader, which REFUSES to name an out-of-range row.
import os as _os
sys.path.insert(0, _os.path.dirname(_os.path.abspath(__file__)))
from ring_row import ring_row as _ring_row, main_menu_item as _mmi, NAMES
def focus(a):
y = _ring_row(Image.fromarray(a.astype(np.uint8)))
i = _mmi(y)
if i is None:
return None, [y]
return i, [y]
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 (attempt {k+1})", 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(proc):
"""A CURRENT frame, not a buffered one.
⚠️ Reading one frame from the pipe after a sleep returns whatever ffmpeg
buffered while we were not reading. Reopening the stream is the only cheap
way to be sure the frame is now.
"""
proc.kill()
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)
return q, a
def glyph(a):
r, g, b = a[:, :, 0], a[:, :, 1], a[:, :, 2]
return int(((g > 130) & (g - r > 45) & (g - b > 45)).sum())
T0 = time.time()
p, n, seg = _open(), W * H * 3, time.time()
os.makedirs(OUT, exist_ok=True)
log = open(f"{OUT}/series.tsv", "w"); log.write("# t_s\tglyph\tphase\n")
phase, streak, mark = ("frommenu" if "--from-menu" in sys.argv else "wait"), 0, None
F1 = F2 = F3 = None
while True:
el = time.time() - T0
if el > WAIT:
print(f"TIMEOUT in phase {phase}", flush=True); break
if time.time() - seg > 30:
p.kill(); p = _open(); seg = time.time()
buf = p.stdout.read(n)
if len(buf) < n:
p.kill(); p = _open(); seg = time.time(); continue
a = np.frombuffer(buf, np.uint8).reshape(H, W, 3).astype(int)
c = glyph(a)
log.write(f"{el:.3f}\t{c}\t{phase}\n"); log.flush()
if phase == "frommenu":
# already sitting on the menu: fall straight into the menu handler
streak = MENU_HOLD; phase = "tomenu"
elif phase == "wait":
streak = streak + 1 if NEED <= c <= CEIL else 0
if streak >= HOLD:
print(f"[{el:7.1f}s] TITLE (glyph {c})", flush=True)
press("A", "5800"); phase, streak = "tomenu", 0
elif phase == "tomenu":
streak = streak + 1 if MENU_LO <= c <= MENU_HI else 0
if streak >= MENU_HOLD:
time.sleep(2.0) # let the menu settle
p, a = fresh(p)
Image.fromarray(a.astype(np.uint8)).save(f"{OUT}/1-F1.png")
F1, v = focus(a)
if F1 is None:
print(f"🔴 no main-menu ring row in the F1 frame (y={v[0]}) — stopping", flush=True); break
print(f"[{el:7.1f}s] MENU (glyph {c}) F1 = {NAMES[F1]} ring "
+ " ".join(f"{x:5.0f}" for x in v), flush=True)
# --reach-only: stop here. A caller that just needs the game SITTING on
# the menu should not run the round trip -- on 2026-08-31 a ja capture
# of DIFFICULTY failed because this probe's B->title->A leg did not come
# back, leaving the game off-menu, and the sweep that followed had
# nothing to work with. Arriving is the cheap part; the round trip is
# this probe's own experiment and is not every caller's.
if "--reach-only" in sys.argv:
print("REACHED THE MENU (--reach-only, no round trip)", flush=True)
p.kill()
sys.exit(0)
# 🔴 Run 1 pressed DOWN twice through pad.py with NO delivery
# confirmation and the guest logged vk=5811 exactly ONCE. A and B
# were confirmed; the d-pad was not, so the run was unreadable.
# Confirm every press the same way.
ok = all(press("DOWN", "5811") for _ in range(2))
if not ok:
print("🔴 a DOWN was never delivered — refusing to read F2", flush=True)
break
time.sleep(1.5)
p, a = fresh(p)
Image.fromarray(a.astype(np.uint8)).save(f"{OUT}/2-F2.png")
F2, v = focus(a)
if F2 is None:
print(f"🔴 no main-menu ring row in the F2 frame (y={v[0]}) — stopping", flush=True); break
print(f"[{el:7.1f}s] after 2x DOWN F2 = {NAMES[F2]} ring "
+ " ".join(f"{x:5.0f}" for x in v), flush=True)
want = (F1 + 2) % len(NAMES)
if F2 != want:
print(f"🔴 CONTROL FAILED: 2x DOWN from {NAMES[F1]} should give "
f"{NAMES[want]}, read {NAMES[F2]}. The reader is not tracking "
f"the cursor; refusing to report F3.", flush=True)
break
print(f"✅ CONTROL PASSED: 2x DOWN moved {NAMES[F1]} -> {NAMES[F2]}", flush=True)
press("B", "5801"); mark = time.time(); phase, streak = "backtitle", 0
elif phase == "backtitle":
streak = streak + 1 if NEED <= c <= CEIL else 0
if streak >= HOLD:
Image.fromarray(a.astype(np.uint8)).save(f"{OUT}/3-title.png")
print(f"[{el:7.1f}s] BACK AT TITLE (glyph {c})", flush=True)
press("A", "5800"); phase, streak = "remenu", 0
elif time.time() - mark > 60:
print(f"[{el:7.1f}s] B did not reach the title in 60 s (glyph {c})", flush=True)
break
elif phase == "remenu":
streak = streak + 1 if MENU_LO <= c <= MENU_HI else 0
if streak >= MENU_HOLD:
time.sleep(2.0)
p, a = fresh(p)
Image.fromarray(a.astype(np.uint8)).save(f"{OUT}/4-F3.png")
F3, v = focus(a)
if F3 is None:
print(f"🔴 no main-menu ring row in the F3 frame (y={v[0]}) — stopping", flush=True); break
print(f"[{el:7.1f}s] MENU AGAIN F3 = {NAMES[F3]} ring "
+ " ".join(f"{x:5.0f}" for x in v), flush=True)
print(f"\nF1={NAMES[F1]} F2={NAMES[F2]} F3={NAMES[F3]}")
if F3 == F2:
print("=> FOCUS PERSISTS across menu -> title -> menu")
elif F3 == F1:
print("=> FOCUS RESETS to its initial item")
else:
print("=> NEITHER — F3 matches neither F1 nor F2; unexplained")
break
p.kill()
print("FOCUS PERSISTENCE RUN DONE", flush=True)