#!/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) # 🔴 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)