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
186 lines
7.0 KiB
Python
186 lines
7.0 KiB
Python
#!/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)
|