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
112 lines
4.4 KiB
Python
Executable File
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)
|