"""Measure Canary's PRESENTATION RATE without perturbing it, and time the boot. Why this exists: two pages of the corpus measure the same declared 120 keyframe units during a static hold and disagree by 2 % -- settle->plate 2.135 s (28.10 fps implied) and one focus-ring revolution 2.177 s (27.56 implied). The port challenged it. Either the rate differed between the sessions, or one interval is not 120 units. Both were WALL-CLOCK, so nothing in either can tell them apart. The obvious instrument is Canary's own `[UI-CAP]` frame counter, and it is the one the corpus used for "28.5 fps". 🔴 **It perturbs badly.** Measured here: armed on the title with a concurrent 8 fps grab, 300 frames took 16.567 s = **18.11 fps** against the ~28 the same screen gives without it. A frame counter that costs a third of the frame rate cannot measure the frame rate. So: count DISTINCT FRAMES in an oversampled crop of something that moves every frame (the spinning focus ring). Sampling at 60 fps a source presenting at R, the fraction of consecutive samples that differ is R/60. Its controls, all in one session and all required to believe a number: * a STATIC crop must read ~0 -- if it does not, the counter is seeing noise; * two sampling rates (45 and 60) must agree -- if the estimate tracks the sampler it is measuring the sampler; * and the decisive one: while `[UI-CAP]` runs, this counter and the emulator's own frame count must AGREE. Both are perturbed in that window, but agreeing there is what licenses using this counter alone outside it. present_rate_probe.py --run SECONDS OUT.json CANARY_STDOUT """ import json import os import subprocess import sys import time import numpy as np SD = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, SD) import title_timing_probe as T # noqa: E402 import boot_timeline_probe as B # noqa: E402 # The button column, from ring_period.py: game coords (480,130)-(570,530), and # the game surface sits at +1,+45 in the root. RX, RY, RW, RH = 481, 175, 90, 400 # A crop that must NOT move: the top-left of the menu's background. SX, SY, SW, SH = 60, 120, 90, 120 def crop_stream(x, y, w, h, rate): return subprocess.Popen( ["ffmpeg", "-loglevel", "error", "-f", "x11grab", "-draw_mouse", "0", "-video_size", f"{w}x{h}", "-i", f"{T.DISPLAY}+{x},{y}", "-r", str(rate), "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], stdout=subprocess.PIPE, bufsize=w * h * 3 * 4) def count_distinct(x, y, w, h, rate, secs, thresh=0.02): """Sample a crop and count how many frames differ from their predecessor. Returns (samples, distinct, seconds, implied_fps, frames_list, times_list). `thresh` is a mean-abs-difference floor; a capture path with no noise makes an identical frame differ by exactly 0, so this only has to reject dither. """ p = crop_stream(x, y, w, h, rate) n = w * h * 3 t0 = time.time() prev = None samples = distinct = 0 prof, ts = [], [] while time.time() - t0 < secs: b = p.stdout.read(n) if len(b) < n: break a = np.frombuffer(b, np.uint8).reshape(h, w, 3) g = (0.299 * a[..., 0] + 0.587 * a[..., 1] + 0.114 * a[..., 2]).astype(np.float32) samples += 1 if prev is not None and float(np.abs(g - prev).mean()) > thresh: distinct += 1 prev = g prof.append(float(g.mean())) ts.append(time.time() - t0) p.kill() dt = time.time() - t0 return dict(samples=samples, distinct=distinct, seconds=dt, sample_fps=samples / dt, implied_fps=distinct / dt), prof, ts def wait_for(pred, limit, rate=8): """Classify a full-frame stream until `pred(label, glyph, mean)` or timeout.""" p = T.open_stream() n = T.W * T.H * 3 t0 = time.time() trail = [] while time.time() - t0 < limit: buf = p.stdout.read(n) if len(buf) < n: p.kill(); p = T.open_stream(); continue rgb = np.frombuffer(buf, np.uint8).reshape(T.H, T.W, 3) g = T.gray_of(rgb) lb = T.label(T.scores(g)) gl = T.glyph(rgb) mn = float(T.surface(g).mean()) trail.append((round(time.time() - t0, 3), lb, gl, round(mn, 2))) if pred(lb, gl, mn): p.kill() return time.time() - t0, trail p.kill() return None, trail def main(limit, out_path, log_path): res = {} t0 = time.time() # --- reach the plate, then press A promptly: the title's idle window is short hit, trail = wait_for(lambda lb, gl, mn: lb in ("title_plate", "title_noplate") and gl >= T.PLATE_GLYPH, limit) res["trail_to_plate"] = trail[-40:] if hit is None: res["error"] = "never reached the plate" json.dump(res, open(out_path, "w"), indent=1) return 1 res["plate_at"] = round(hit, 3) print(f"plate at {hit:.1f}s -> A", flush=True) T.tap("A") hit, trail = wait_for(lambda lb, gl, mn: lb == "menu", 120) if hit is None: res["error"] = "never reached the menu" res["trail_to_menu"] = trail[-40:] json.dump(res, open(out_path, "w"), indent=1) return 1 print(f"menu at +{hit:.1f}s; settling", flush=True) time.sleep(8) # --- CONTROL 1: a crop that must not move st, _, _ = count_distinct(SX, SY, SW, SH, 60, 6) res["control_static"] = st print(f"static control: {st['distinct']}/{st['samples']} distinct " f"({st['implied_fps']:.2f} implied)", flush=True) # --- CONTROL 2: the same ring at two sampling rates for r in (45, 60): d, prof, ts = count_distinct(RX, RY, RW, RH, r, 12) res[f"ring_free_{r}"] = d res[f"ring_profile_{r}"] = [round(v, 4) for v in prof] res[f"ring_times_{r}"] = [round(v, 4) for v in ts] print(f"ring @{r}fps: {d['distinct']}/{d['samples']} -> " f"{d['implied_fps']:.2f} fps (sampled {d['sample_fps']:.1f})", flush=True) # --- CONTROL 3 (the decisive one): agree with the game's own counter seen = os.path.getsize(log_path) if os.path.exists(log_path) else 0 import threading box = {} def _arm(): box["uicap"], box["seen"] = B.arm_capture(log_path, seen, timeout=120) th = threading.Thread(target=_arm) th.start() d, prof, ts = count_distinct(RX, RY, RW, RH, 60, 30) th.join(timeout=60) res["ring_during_capture"] = d res["uicap"] = box.get("uicap") print(f"during UI-CAP: distinct-frame {d['implied_fps']:.2f} fps; " f"UI-CAP {box.get('uicap')}", flush=True) # --- and back to unperturbed d, prof, ts = count_distinct(RX, RY, RW, RH, 60, 12) res["ring_after"] = d res["ring_profile_after"] = [round(v, 4) for v in prof] res["ring_times_after"] = [round(v, 4) for v in ts] print(f"after: {d['implied_fps']:.2f} fps", flush=True) res["elapsed"] = round(time.time() - t0, 2) json.dump(res, open(out_path, "w"), indent=1) return 0 if __name__ == "__main__": if len(sys.argv) > 4 and sys.argv[1] == "--run": sys.exit(main(float(sys.argv[2]), sys.argv[3], sys.argv[4])) print(__doc__) sys.exit(2)