Answers the port's ask: ptbtneff01 is ANIMATED while a button is focused, not drawn once and held. The existing page said 'the ring SPINS' from one frame at a large angle, which is equally consistent with a static draw at a fixed angle. No angle is quoted anywhere. The 360-bin angular estimator written for this FAILED its own control -- a synthetic 30 deg came back as 0 deg (peak 0.596) while 90/180/270 came back exactly -- so it was not used. What settles it needs no angle: total annulus brightness is conserved to 0.4 % while individual angular bins swing by 24, i.e. brightness moving AROUND the ring, which excludes a pulse. The temporal-std map is a clean annulus, falling to ~1 both inside and outside the stroke, which excludes positional jitter. Period from the profile's autocorrelation: eight evenly spaced peaks, mean 2.177 s over nine revolutions. Even spacing is the internal check a drifting instrument cannot pass. That is 120 units = 60 frames = 2.00 s at a true 30 Hz. Also measured, same run: the ring is the ONLY moving thing on the settled main menu -- temporal std is exactly 0.000 on every unfocused button, the labels and the footer. And the ring's centre, located from the std map at game (520.7, 339.7), matches the declared leaf offset's prediction of (521, 340). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KNR5Y79D1T4bBr6gJQaWFP
160 lines
6.1 KiB
Python
Executable File
160 lines
6.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Does the main menu's focus ring KEEP spinning, or is it drawn once and held?
|
|
|
|
The question is not "is the ring rotated" -- one oracle frame already showed it
|
|
at a large angle (docs/re/structures/ui-button-focus-record.md). It is whether
|
|
that rotation is ANIMATED while a button sits focused, which is what decides
|
|
whether a port draws a static ring or runs a loop.
|
|
|
|
Instrument: a live x11grab filmstrip and the per-pixel TEMPORAL standard
|
|
deviation of the frames while nothing is touched. A spinning ring makes its
|
|
own box vary; a held one does not. No angle is estimated anywhere -- the
|
|
centroid estimator that would do that fails its own control by up to 19.8 deg
|
|
(same page), so this probe measures presence-of-change instead, which is the
|
|
question actually asked.
|
|
|
|
NO FIXED PIXEL BOXES. xenia's window has a menu bar and the game surface is
|
|
1279x675 inside a 1280x720 root, so game coordinates do not address grab
|
|
coordinates. This probe saves whole-frame accumulators; `focus_ring_report.py`
|
|
aligns them against a committed capture first and only then reads boxes.
|
|
|
|
Phases: A = 20 s untouched, then d-pad DOWN, then C = 12 s untouched.
|
|
The d-pad press is the POSITIVE CONTROL: |mean(A) - mean(C)| must fire at the
|
|
two ring locations, or a null in phase A is a dead instrument, not a finding.
|
|
|
|
Usage: focus_ring_probe.py OUTDIR
|
|
"""
|
|
import os, subprocess, sys, time
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
W, H = 1280, 720
|
|
SD = os.path.dirname(os.path.abspath(__file__))
|
|
OUT = sys.argv[1] if len(sys.argv) > 1 else "/sylph-home/re/ringcap"
|
|
os.makedirs(OUT, exist_ok=True)
|
|
RESTART_S = 25 # a long-lived x11grab stream stalls and repeats frames
|
|
|
|
|
|
def open_stream():
|
|
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)
|
|
|
|
|
|
class Stream:
|
|
def __init__(self):
|
|
self.p = open_stream(); self.seg = time.time()
|
|
def read(self):
|
|
if time.time() - self.seg > RESTART_S:
|
|
self.p.kill(); self.p = open_stream(); self.seg = time.time()
|
|
buf = self.p.stdout.read(W * H * 3)
|
|
if len(buf) < W * H * 3:
|
|
self.p.kill(); self.p = open_stream(); self.seg = time.time()
|
|
return None
|
|
return np.frombuffer(buf, np.uint8).reshape(H, W, 3)
|
|
def close(self):
|
|
try: self.p.kill()
|
|
except Exception: pass
|
|
|
|
|
|
sys.path.insert(0, SD)
|
|
from screen_match import classify_array # controlled: 8/8, incl. the movie
|
|
# frames that broke the old oracle
|
|
|
|
|
|
def collect(st, secs, tag):
|
|
"""Whole-frame temporal mean and std over `secs`, plus a PNG filmstrip."""
|
|
t0 = time.time(); n = 0
|
|
acc = acc2 = None
|
|
next_shot = 0.0
|
|
while True:
|
|
el = time.time() - t0
|
|
if el >= secs:
|
|
break
|
|
a = st.read()
|
|
if a is None:
|
|
continue
|
|
f = a.astype(np.float64)
|
|
acc = f.copy() if acc is None else acc + f
|
|
acc2 = f * f if acc2 is None else acc2 + f * f
|
|
if el >= next_shot:
|
|
Image.fromarray(a).save(f"{OUT}/{tag}-t{el:05.1f}.png")
|
|
next_shot = el + 4.0
|
|
n += 1
|
|
mean = acc / n
|
|
std = np.sqrt(np.maximum(acc2 / n - mean * mean, 0))
|
|
np.save(f"{OUT}/{tag}-mean.npy", mean.astype(np.float32))
|
|
np.save(f"{OUT}/{tag}-std.npy", std.astype(np.float32))
|
|
Image.fromarray(mean.astype(np.uint8)).save(f"{OUT}/{tag}-mean.png")
|
|
# a visible std map, scaled x8 and clipped -- an artefact a human can look at
|
|
Image.fromarray(np.clip(std * 8, 0, 255).astype(np.uint8)).save(f"{OUT}/{tag}-std8.png")
|
|
print(f"[{tag}] {n} frames in {secs:.0f}s = {n/secs:.2f} fps; "
|
|
f"whole-frame std mean {std.mean():.4f} max {std.max():.2f}", flush=True)
|
|
return mean, std, n
|
|
|
|
|
|
def main():
|
|
st = Stream()
|
|
t0 = time.time(); seen = None; last = None; skipped = False
|
|
# ONE (A) ~45 s in skips the intro movie: measured, title at ~57 s against a
|
|
# ~193 s no-input baseline (HANDOFF, movie-binding.md). HAMMERING is what
|
|
# breaks the boot -- 88 presses left a permanent black screen -- so exactly
|
|
# one, and only once.
|
|
while time.time() - t0 < 620:
|
|
a = st.read()
|
|
if a is None:
|
|
continue
|
|
last = a
|
|
el = time.time() - t0
|
|
if not skipped and el > 45:
|
|
subprocess.run(["python3", f"{SD}/pad.py", "tap", "A", "0.3"], check=False)
|
|
skipped = True
|
|
print(f"t={el:6.1f}s one (A) to skip the intro movie", flush=True)
|
|
continue
|
|
c, sc = classify_array(a)
|
|
if c != seen:
|
|
print(f"t={el:6.1f}s screen={c} " +
|
|
" ".join(f"{k}={v:+.3f}" for k, v in sc.items()), flush=True)
|
|
seen = c
|
|
if c == "title":
|
|
break
|
|
if seen != "title":
|
|
print("NEVER REACHED THE TITLE"); st.close(); return 1
|
|
Image.fromarray(last).save(f"{OUT}/00-title.png")
|
|
subprocess.run(["python3", f"{SD}/pad.py", "tap", "A", "0.3"], check=False)
|
|
print("(A) on the title", flush=True)
|
|
t1 = time.time(); got = False
|
|
while time.time() - t1 < 150:
|
|
a = st.read()
|
|
if a is None:
|
|
continue
|
|
c, sc = classify_array(a)
|
|
if c == "menu":
|
|
got = True; break
|
|
if not got:
|
|
print("NO MENU AFTER A"); st.close(); return 2
|
|
time.sleep(4) # let the menu's ~1 s fade-in and element ramps settle
|
|
a = st.read()
|
|
if a is not None:
|
|
Image.fromarray(a).save(f"{OUT}/01-menu.png")
|
|
print("AT MAIN MENU", flush=True)
|
|
|
|
mA, sA, nA = collect(st, 20, "A")
|
|
subprocess.run(["python3", f"{SD}/pad.py", "dpad", "down"], check=False)
|
|
print(">>> d-pad DOWN pressed", flush=True)
|
|
time.sleep(2.0)
|
|
mC, sC, nC = collect(st, 12, "C")
|
|
|
|
d = np.abs(mA - mC)
|
|
np.save(f"{OUT}/AC-absdiff.npy", d.astype(np.float32))
|
|
Image.fromarray(np.clip(d * 4, 0, 255).astype(np.uint8)).save(f"{OUT}/AC-absdiff4.png")
|
|
print(f"[A-vs-C] absdiff mean {d.mean():.4f} max {d.max():.2f}", flush=True)
|
|
st.close()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|