Files
Sylpheed/tools/re-capture/submenu_focus_sweep.py
sylph-decoder 1dfcdd81ee tools: ring_row gets a self-test, which found that a TITLE frame reads as NEW GAME
sylpheed-port closed their last harness gap on a control that never asked whether
the measurement was LIVE -- an empty band list made identity and the real pair both
pass. Applying that to ring_row.py, which underpins every focus finding I have made
and had no self-test at all.

It found a real defect immediately: main_menu_item(ring_row(f)) is not None was
being used as a main-menu test, and on a TITLE frame the gutter carries a bright
cluster at y=243, inside tolerance of row 0, so the title reads as NEW GAME. Glyph
714 against the menu's 327 separates them cleanly; the ring row alone does not.

It never misfired in the sweeps, because B from a submenu goes to the menu rather
than the title -- the test was simply weaker than it was being trusted to be. Added
is_main_menu(), which requires the row AND the glyph signature, and the sweep's two
menu tests now use it.

The self-test asserts the defect it guards, and includes a liveness case: a blanked
frame must return None rather than a number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-31 02:00:07 +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, 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)