#!/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())