Files
Sylpheed/tools/re-capture/submenu_focus_sweep.py
sylph-decoder 308d89c215 tools: let the submenu sweep target NEW GAME, for the DIFFICULTY reset question
DIFFICULTY opens on NORMAL, the second of four, so it is the screen that separates
'resets to the named item' from 'resets to the top item' -- the question
sylpheed-port has had open for several iterations.

The probe is safe on this path without modification: it presses A to enter, one
DOWN, then B to leave, and never presses A inside a submenu, so it cannot reach
SELECT DATA and the guest throw at PC 0x82307128. Noted in the source so nobody
adds one.

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

257 lines
11 KiB
Python

#!/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, 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: main_menu_item(ring_row(img(a))) is not None
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: main_menu_item(ring_row(img(a))) is not None,
"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: main_menu_item(ring_row(img(x))) is not None,
"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)