Files
Sylpheed/tools/re-capture/extras_focus_persistence.py
sylph-decoder e35c56a5ed re: refutation attempt on Q2's 'shipped twice' -- it survives, with one real caveat
The doubt was my own artefact: the entry dump printed only the first two sprite
names in HashMap order, making 11 and 14 look like different studios. Full sets
are identical.

7 of 8 pairs declare identical sprite sets, control included. 4/7 does not: entry
7 carries nine sprites entry 4 lacks, including ptlogo_jp and ptlogo_jpeff, so the
Japanese title is a different element inventory rather than the same screen
localised. That matches the JP capture work from the other side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-30 22:05:03 +00:00

183 lines
7.2 KiB
Python

#!/usr/bin/env python3
"""Does the EXTRAS submenu remember its cursor across leave -> re-enter?
sylpheed-port's contract-check asserts that EXTRAS does NOT persist. Nothing
measured that: the corpus has EXTRAS' initial focus 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. The main menu was measured to PERSIST, so
the question is live in both directions, and their `kind: "measured"` label on
`initial_focus: ptbtn11` turns on it.
🔴 RUN 1 (2026-08-30) WAS VOID AND ITS FAILURES SHAPE THIS FILE:
* it navigated to OPTIONS believing it was EXTRAS -- the focus reader used
design-space rows against x11grab frames (menu-focus-reader-offset.txt);
* its screen detector could not tell the main menu from a submenu, because
both sit inside the glyph 250..420 window (main menu 327, OPTIONS 317);
* its control checked only that the ring MOVED, which a wrong origin passes.
So this run: absolute row checks against a measured calibration, screen identity
against a REFERENCE FRAME captured in this same run, and raw ring ROWS compared
inside the submenu -- no submenu geometry is assumed or needed.
extras_focus_persistence.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, ROW0, SPACING
LOG, OUT = sys.argv[1], sys.argv[2]
WAIT = float(sys.argv[3]) if len(sys.argv) > 3 else 600
W, H = 1280, 720
PAD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pad.py")
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} 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):
print(f"[{time.time()-T0:7.1f}s] {what}", flush=True)
return a
print(f"[{time.time()-T0:7.1f}s] 🔴 TIMEOUT waiting for {what}", flush=True)
return None
os.makedirs(OUT, exist_ok=True)
# ---- 1. main menu: reference frame + calibrated focus ------------------------
MAIN = wait_until(lambda a: main_menu_item(ring_row(img(a))) is not None
and 250 <= glyph(a) <= 420, "MAIN MENU reference captured", 120)
if MAIN is None:
sys.exit("never identified the main menu")
img(MAIN).save(f"{OUT}/0-main-ref.png")
f = main_menu_item(ring_row(img(MAIN)))
print(f" focus = {NAMES[f]} (ring y {ring_row(img(MAIN))})", flush=True)
# ---- 2. walk to EXTRAS, checking the ABSOLUTE row after every press ----------
for step in range((4 - f) % 5):
if not press("DOWN", "5811"):
sys.exit("a DOWN was never delivered")
time.sleep(1.2)
a = fresh(); y = ring_row(img(a)); i = main_menu_item(y)
want = (f + 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:
sys.exit(f"🔴 CONTROL FAILED: after {step+1} DOWN the ring is not on {NAMES[want]}")
print("✅ on EXTRAS, verified by absolute row after every press", flush=True)
# ---- 3. enter EXTRAS -- and prove we LEFT the main menu ----------------------
if not press("A", "5800"):
sys.exit("A never delivered")
E1 = wait_until(lambda a: differs(a, MAIN) > 0.20, "left the main menu", 40)
if E1 is None:
sys.exit("A did not change the screen")
time.sleep(3.0)
E1 = fresh(); img(E1).save(f"{OUT}/E1.png")
y1 = ring_row(img(E1))
print(f"[{time.time()-T0:7.1f}s] E1 in the submenu: ring y = {y1}, "
f"glyph {glyph(E1)}, {100*differs(E1, MAIN):.1f}% from main", flush=True)
# ---- 4. move the cursor, control on an ABSOLUTE change ----------------------
if not press("DOWN", "5811"):
sys.exit("a DOWN was never delivered inside the submenu")
time.sleep(2.5)
E2 = fresh(); img(E2).save(f"{OUT}/E2.png")
y2 = ring_row(img(E2))
print(f"[{time.time()-T0:7.1f}s] E2 after 1 DOWN: ring y = {y2}", flush=True)
if y1 is None or y2 is None:
sys.exit("🔴 CONTROL FAILED: no ring found in the submenu — this reader does not work here")
if abs(y2 - y1) < 20:
sys.exit(f"🔴 CONTROL FAILED: the ring did not move ({y1} -> {y2})")
print(f"✅ CONTROL PASSED: the ring moved {y1} -> {y2} ({abs(y2-y1):.1f} px)", flush=True)
# ---- 5. leave, prove we are back on the main menu, re-enter -----------------
if not press("B", "5801"):
sys.exit("B never delivered")
back = wait_until(lambda a: differs(a, MAIN) < 0.15, "back on the MAIN MENU (vs reference)", 60)
if back is None:
sys.exit("🔴 B did not return to the main menu — refusing to read E3")
if not press("A", "5800"):
sys.exit("A never delivered on re-entry")
E3 = wait_until(lambda a: differs(a, MAIN) > 0.20, "left the main menu again", 40)
if E3 is None:
sys.exit("re-entry did not change the screen")
time.sleep(3.0)
E3 = fresh(); img(E3).save(f"{OUT}/E3.png")
y3 = ring_row(img(E3))
same_screen = differs(E3, E1) < 0.15
print(f"[{time.time()-T0:7.1f}s] E3 on re-entry: ring y = {y3}, "
f"{100*differs(E3, E1):.1f}% from E1 — same screen: {same_screen}", flush=True)
# ---- 6. decide ------------------------------------------------------------
print(f"\n E1 (opened on) ring y = {y1}")
print(f" E2 (left it on) ring y = {y2}")
print(f" E3 (re-entered) ring y = {y3}")
if not same_screen:
print("\n=> VOID: re-entry is not the same screen; nothing measured")
elif y3 is None:
print("\n=> VOID: no ring on re-entry")
elif abs(y3 - y2) < 20 and abs(y3 - y1) >= 20:
print("\n=> EXTRAS PERSISTS: re-entry is where I left the cursor")
elif abs(y3 - y1) < 20 and abs(y3 - y2) >= 20:
print("\n=> EXTRAS RESETS: re-entry is where it first opened")
else:
print(f"\n=> UNDECIDED: |E3-E1|={abs(y3-y1):.1f} |E3-E2|={abs(y3-y2):.1f}")
print("EXTRAS FOCUS RUN DONE", flush=True)