Files
Sylpheed/tools/re-capture/menu_loop_probe.py
sylph-decoder cce20b83ca re: WITHDRAW "ptloop01/02 do not free-run" -- I measured a pivot, not an extent
sylpheed-port noted that build 5's ptloop parent can be static while the leaf
record animates, and asked me to check it against my table. My own corpus
refutes my claim outright.

ptloop-leaf-sweep-positions.txt -- written earlier in this same corpus -- records
ptloop01.rat's nested record at loop length 600, whose leaf pteff03.t32 sweeps a
400 px-wide quad with its centre running x~921->1041 over t=340..370. The
parent's declared rect is (441,270) 200x90. The leaf draws 300 px outside it: the
parent rect is a PIVOT ANCHOR, not the drawn extent.

Checked against the two JP captures: my measured rect differs by 0 px -- and so
does the whole dead region y 270..450 x 480..960 around it -- while the band the
sweep actually occupies (x 721..1241) differs by 44 025 px. The zero was measured
where nothing happens.

So the port's reading is right and now confirmed from the disc: parent static,
leaf animates, and the two nested records cycle at DIFFERENT lengths, 600 and
720. My "single static keyframe" described the parent only. The era adjudication
is unaffected -- its box overlaps the sweep band only at x 721..776, which shows
no between-session differences.

The menu-loop question is still unsettled after a second attempt, and the second
attempt's failure REFUTES my diagnosis of the first. menu_loop_probe.py gated on
the plate pulse (glyph in [500,2500] held 12 samples), fired at t=484.5 s with
glyph 1723 -- a verified settled BOOT title, not the attract one -- pressed A,
and the press was delivered ([file-pad] keystroke vk=5800 down/up, 8 RE-INPUT
lines). Twenty seconds later all five frames still classified as the title
(rmse ~67-70, margins 0.06-0.16, the "neither" signature; screen_id says title).
So "the attract title accepts nothing" does not explain attempt 1, and the
corpus's "the boot title accepts a single A, 2 of 2 runs" is no longer 2 of 2.

METHOD: a declared rect can be an anchor, not an extent -- confirm an element
draws in a region before diffing that region to ask whether it moves.
navigation.md: confirm the screen changed, do not infer it from a delivered
press.

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

112 lines
4.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""Do `ptloop01/02` animate on the SETTLED main menu?
`sylpheed-port` found those two leaves free-running in their renderer on the menu
path and pinned them, noting that pinning picks one pose rather than the game's --
"a capture question, not a harness one". On the title it is answered: two captures
from different sessions are byte-identical over the loop rect (0 of 18 000). The
MENU is a different bundle and is where their row actually drifted.
⚠️ A first attempt used `screen_id.py`'s single `title` classification as the cue
to press Ⓐ, hit a title at t=146 s, and Ⓐ never took across six tries -- that was
the ATTRACT loop's title, which accepts nothing, and the classifier cannot tell it
from the boot title. This gates on the same signal `jp_title_capture.py` uses: the
green Ⓐ-plate glyph count inside a band, HELD for 12 consecutive samples (~3 s).
menu_loop_probe.py OUTDIR [wait_s]
"""
import subprocess
import sys
import time
import numpy as np
from PIL import Image
OUT = sys.argv[1]
WAIT = float(sys.argv[2]) if len(sys.argv) > 2 else 700
W, H = 1280, 720
SURF_Y = 45
NEED, CEIL, HOLD = 500, 2500, 12
LOOP = (441, 270, 200, 90) # ptloop01/02 rest rect, design space
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 glyph(a):
r, g, b = a[:, :, 0], a[:, :, 1], a[:, :, 2]
return int(((g > 130) & (g - r > 45) & (g - b > 45)).sum())
def tap(btn, secs=0.5):
import os
for state in (f"press={btn}", ""):
with open("/tmp/xenia_pad.txt.tmp", "w") as f:
f.write(state)
os.replace("/tmp/xenia_pad.txt.tmp", "/tmp/xenia_pad.txt")
if state:
time.sleep(secs)
T0 = time.time()
p, n, seg, streak = _open(), W * H * 3, time.time(), 0
state, pressed_at, frames = "wait_title", None, []
while True:
el = time.time() - T0
if el > WAIT:
print("TIMEOUT in state " + state, 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(np.uint8)
c = glyph(a.astype(int))
if state == "wait_title":
streak = streak + 1 if NEED <= c <= CEIL else 0
if streak >= HOLD:
print(f"[{el:7.1f}s] BOOT TITLE SETTLED (plate pulse, glyph {c}) — pressing A",
flush=True)
tap("A", 0.5)
pressed_at, state, streak = time.time(), "wait_menu", 0
elif state == "wait_menu":
# the menu has no green plate; wait for the glyph count to fall and stay
# down, which is the plate leaving, then let the build-in finish.
streak = streak + 1 if c < NEED else 0
if streak >= HOLD and time.time() - pressed_at > 8:
print(f"[{el:7.1f}s] MENU (glyph {c}) — taking 5 frames 2 s apart", flush=True)
for k in range(5):
t0 = time.time()
while time.time() - t0 < 2.0:
b2 = p.stdout.read(n)
if len(b2) < n:
p.kill(); p = _open(); break
a = np.frombuffer(b2, np.uint8).reshape(H, W, 3).astype(np.uint8)
Image.fromarray(a).save(f"{OUT}/menu-{k}.png")
frames.append(a.astype(int))
print(f" frame {k}: glyph {glyph(a.astype(int))}", flush=True)
break
p.kill()
if len(frames) == 5:
x, y, w, h = LOOP
rois = [f[SURF_Y + y:SURF_Y + y + h, x:x + w] for f in frames]
print("\nptloop01/02 rect, 200x90 at design (441,270) — do they move?")
for i in range(1, 5):
d = np.abs(rois[i] - rois[0])
print(f" frame {i} vs 0: max |d| {d.max():3d}, px differing >8: "
f"{int((d.max(axis=2) > 8).sum()):5d} / {d.shape[0]*d.shape[1]}", flush=True)
print(" whole frame, as the CONTRAST CONTROL (if this is 0 too, the")
print(" instrument is blind and the rect's 0 means nothing):")
for i in range(1, 5):
d = np.abs(frames[i] - frames[0]).max(axis=2)
print(f" frame {i} vs 0: px differing >8: {int((d > 8).sum()):7d}", flush=True)
else:
print("NO FRAMES — nothing measured", flush=True)