#!/usr/bin/env python3 """Does EACH submenu remember its cursor across leave -> re-enter? The main menu PERSISTS (focus-persists-across-title.txt); EXTRAS RESETS (extras-focus-resets.txt). Two screens, two behaviours, so there is no menu-wide rule and every screen has to be measured. This sweeps the three that are left: LOAD GAME, TUTORIAL, OPTIONS. ❔ NEW GAME is deliberately NOT entered -- the corpus has held it untested because it starts a game, and nothing here is worth breaking that for. Controls, each of which exists because an earlier run failed without it: * ABSOLUTE row check after EVERY navigation press, not just the total -- a constant offset passes a differential control exactly (menu-focus-reader-offset.txt); * SCREEN IDENTITY against a reference frame captured in this same run -- the glyph window cannot separate the main menu from a submenu (327 / 324 / 317); * 🔴 NO RING READER INSIDE A SUBMENU. ring_row.py scans x 500:542, which is the MAIN MENU's gutter. EXTRAS happened to put its ring in that column; LOAD GAME, TUTORIAL and OPTIONS do not (their cursors move at x 97..231, 338..1099 and 153..479), so the first sweep read a STATIC element and all three voided on "the ring did not move". They had moved. The decision here needs no ring at all: S1 and S2 differ ONLY by cursor position, so compare S3 to each of them over the whole frame; * every press confirmed from the guest's own [RE-INPUT] log. Any control failing SKIPS that screen and moves on; it never reports a number it cannot justify. submenu_focus_sweep.py LOG OUTDIR [wait_s] """ import os, re, subprocess, sys, time import numpy as np from PIL import Image sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from ring_row import ring_row, main_menu_item, is_main_menu, NAMES LOG, OUT = sys.argv[1], sys.argv[2] W, H = 1280, 720 PAD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pad.py") # LOAD GAME, TUTORIAL, OPTIONS by default. Overridable so NEW GAME (0) can be # driven for the DIFFICULTY question. # # ⚠️ NEW GAME IS SAFE FOR *THIS* PROBE AND ONLY THIS ONE. Its forward path -- # Ⓐ on a difficulty -> SELECT DATA -> guest throw at PC 0x82307128 -- crashes the # game. This probe presses Ⓐ to ENTER, one DOWN, then Ⓑ to leave, and never # presses Ⓐ inside a submenu, so it cannot reach SELECT DATA. Do not add an Ⓐ. TARGETS = [int(x) for x in os.environ.get("SWEEP_TARGETS", "1,2,3").split(",")] 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 _ 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: return True print(f"[{time.time()-T0:7.1f}s] 🔴 {btn} NEVER delivered", 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(): q = _open(); a = None for _ in range(3): b = q.stdout.read(W * H * 3) if len(b) == W * H * 3: a = np.frombuffer(b, np.uint8).reshape(H, W, 3).astype(int) q.kill() return a def img(a): return Image.fromarray(a.astype(np.uint8)) def differs(a, b): return float((np.abs(a - b).max(axis=2) > 24).mean()) def glyph(a): r, g, bl = a[:, :, 0], a[:, :, 1], a[:, :, 2] return int(((g > 130) & (g - r > 45) & (g - bl > 45)).sum()) def wait_until(pred, what, limit=60): t = time.time() while time.time() - t < limit: a = fresh() if a is not None and pred(a): return a print(f"[{time.time()-T0:7.1f}s] 🔴 TIMEOUT waiting for {what}", flush=True) return None os.makedirs(OUT, exist_ok=True) # ── SELF-TEST: the decision rule must CONSTRUCT both of its verdicts ────────── # sylpheed-port's rule, after a control of theirs carried the right NAME over the # wrong filter: a control must construct the failure it is named after. Mine was # one-sided -- I checked only that the rule reports RESETS on a known-RESETS # triple, so a rule biased entirely to RESETS would have passed. Both directions # are constructed here from the SAME frames, and the EXIT CODE is the assertion: # printing a verdict is not asserting it. def _verdict(a, b, c): cur = np.abs(a - b).max(axis=2) > 24 if cur.sum() == 0: return "NO-CURSOR" d1 = float(np.abs(c - a).max(axis=2)[cur].mean()) d2 = float(np.abs(c - b).max(axis=2)[cur].mean()) return "RESETS" if d1 < d2 * 0.5 else "PERSISTS" if d2 < d1 * 0.5 else "UNDECIDED" def _self_test(): ref = os.environ.get("SWEEP_SELFTEST_DIR", "/sylph-home/re/extrasfocus") try: A, B, C = [np.asarray(Image.open(f"{ref}/{f}.png").convert("RGB"), dtype=int) for f in ("E1", "E2", "E3")] except Exception as e: print(f"🔴 SELF-TEST UNAVAILABLE ({e}) — refusing to run", flush=True) sys.exit(3) bad = 0 for nm, args, want in (("known RESETS (real EXTRAS triple)", (A, B, C), "RESETS"), ("constructed PERSISTS", (A, B, B), "PERSISTS"), ("constructed RESETS", (A, B, A), "RESETS")): got = _verdict(*args); ok = got == want; bad += not ok print(f" {'✅' if ok else '🔴'} {nm:36} -> {got:9} (want {want})", flush=True) if bad: print("🔴 SELF-TEST FAILED — the rule cannot produce both verdicts.", flush=True) sys.exit(3) print(" ✅ self-test passed: the rule constructs both verdicts", flush=True) print("── decision-rule self-test ──", flush=True) _self_test() # Verifiable WITHOUT starting a run. Without this the only way to check the # self-test was to launch the script, which then proceeds to wait ~150 s for a # main menu and opens x11grab captures -- so "did my self-test pass?" could not be # answered without disturbing whatever else was using the display. if "--selftest-only" in sys.argv: sys.exit(0) MAIN = wait_until(lambda a: is_main_menu(img(a)) and 250 <= glyph(a) <= 420, "the main menu", 150) if MAIN is None: sys.exit("never identified the main menu") img(MAIN).save(f"{OUT}/main-ref.png") print(f"[{time.time()-T0:7.1f}s] MAIN MENU reference, focus = " f"{NAMES[main_menu_item(ring_row(img(MAIN)))]}", flush=True) results = {} for tgt in TARGETS: name = NAMES[tgt] print(f"\n=========== {name} ===========", flush=True) # 🔴 NARROW test, not whole-frame. A crash dialog covering the screen centre # made a whole-frame identity test unable to match ever again, while the ring # column the dialog did not cover read correctly throughout. a = wait_until(lambda a: is_main_menu(img(a)), "the main menu (by ring row)", 60) if a is None: print(f" SKIP {name}: no main-menu ring row"); continue cur = main_menu_item(ring_row(img(a))) if cur is None: print(f" SKIP {name}: no main-menu ring row"); continue ok = True for step in range((tgt - cur) % 5): if not press("DOWN", "5811"): ok = False; break time.sleep(1.2) y = ring_row(img(fresh())); i = main_menu_item(y) want = (cur + step + 1) % 5 print(f" step {step+1}: ring y {y} -> {NAMES[i] if i is not None else '??'} " f"(want {NAMES[want]})", flush=True) if i != want: print(f" 🔴 CONTROL FAILED walking to {name}"); ok = False; break if not ok: results[name] = "skipped (navigation control failed)"; continue if not press("A", "5800"): results[name] = "skipped (A not delivered)"; continue if wait_until(lambda x: differs(x, MAIN) > 0.20, f"{name} to open", 40) is None: results[name] = "skipped (A did not change the screen)"; continue time.sleep(3.0) S1 = fresh(); img(S1).save(f"{OUT}/{name.replace(' ','_')}-S1.png") print(f" S1 opened: glyph {glyph(S1)}, " f"{100*differs(S1, MAIN):.1f}% from main", flush=True) if not press("DOWN", "5811"): results[name] = "skipped (DOWN not delivered inside)"; continue time.sleep(2.5) S2 = fresh(); img(S2).save(f"{OUT}/{name.replace(' ','_')}-S2.png") moved = differs(S1, S2) print(f" S2 after 1 DOWN: {100*moved:.2f}% of the frame changed", flush=True) # CONTROL: the cursor must have moved, and by a LOCALISED amount -- a whole # screen changing means the DOWN left the screen, not moved a cursor. if moved < 0.0005: results[name] = f"VOID (control): nothing changed on DOWN ({100*moved:.3f}%)" press("B", "5801"); time.sleep(3); continue if moved > 0.20: results[name] = f"VOID (control): {100*moved:.1f}% changed — DOWN left the screen" press("B", "5801"); time.sleep(3); continue print(f" ✅ CONTROL PASSED: a localised change ({100*moved:.2f}%)", flush=True) if not press("B", "5801"): results[name] = "skipped (B not delivered)"; continue if wait_until(lambda x: is_main_menu(img(x)), "the main menu (by ring row)", 60) is None: results[name] = "VOID: B did not return to the main menu"; continue if not press("A", "5800"): results[name] = "skipped (A not delivered on re-entry)"; continue if wait_until(lambda x: differs(x, MAIN) > 0.20, f"{name} to reopen", 40) is None: results[name] = "VOID: re-entry did not change the screen"; continue time.sleep(3.0) S3 = fresh(); img(S3).save(f"{OUT}/{name.replace(' ','_')}-S3.png") # The pixels that changed when the cursor moved ARE the cursor's region -- no # per-screen geometry, which is what defeated sweep 1 (ring_row scans the MAIN # MENU's gutter; these screens put cursors at x 97..231, 338..1099, 153..479). cur = np.abs(S1 - S2).max(axis=2) > 24 same_p95 = float(np.percentile(np.abs(S3 - S1).max(axis=2)[~cur], 95)) d1 = float(np.abs(S3 - S1).max(axis=2)[cur].mean()) d2 = float(np.abs(S3 - S2).max(axis=2)[cur].mean()) print(f" S3 re-entered: off-cursor p95 {same_p95:.1f}; in-cursor " f"|S3-S1| {d1:.1f}, |S3-S2| {d2:.1f}", flush=True) if same_p95 > 40: results[name] = f"VOID: re-entry is not the same screen (off-cursor p95 {same_p95:.0f})" elif d2 < d1 * 0.5: results[name] = f"PERSISTS (in-cursor {d2:.1f} from where left vs {d1:.1f} from opened)" elif d1 < d2 * 0.5: results[name] = f"RESETS (in-cursor {d1:.1f} from opened vs {d2:.1f} from where left)" else: results[name] = f"UNDECIDED (in-cursor {d1:.1f} / {d2:.1f})" print(f" => {results[name]}", flush=True) press("B", "5801"); time.sleep(3) print("\n================ SUMMARY ================") for k, v in results.items(): print(f" {k:12} {v}") print("SUBMENU FOCUS SWEEP DONE", flush=True)