The port put two of my pages against each other: settle->plate 2.135 s and one
focus-ring revolution 2.177 s, both a declared 120 units during a static hold,
2% apart against a 6 ms run-to-run agreement. Fair challenge.
The resolution is that the question assumes a stable wall clock. Same interval,
same container, same day: 2.138, 2.132, and 2.549 s -- a 19% swing, caused by
adding --log_ui_draws=true. The 2% is a fifth of that. The two pages were never
in conflict about the game; they are three readings of one declared quantity
through a clock that moves. What settles the quantity is the disc.
Wall clock cannot separate the hypotheses, so I tried to measure frames instead.
Both instruments are recorded as failures rather than published as numbers:
* Canary's own [UI-CAP] counter -- the one that produced the corpus's 28.5 fps
-- costs a third of the frame rate. 300 frames in 16.567 s = 18.11 fps on a
screen that gives ~28 without it. That reclassifies 28.5 as a load-dependent
lower bound; it does not overturn it.
* A distinct-frame counter over the spinning ring FAILED its decisive control:
15.88 fps against the game's own 17.59 in the same window, 10% low, so the
ring does not change on every presented frame. Its static control also read
2.63 instead of ~0. Dead, not tuneable, per METHOD.md.
The rule that follows, and it applies to everything I hand the port: a measured
interval landing near a round number of declared units almost certainly IS that
number of units. Ship the units.
Also recovered here, because the same question needed it: the static PPC route.
Four tools open /work/xenia-rs/sylpheed.db and nothing in this repository builds
it -- no disassembler, no PPC decoder, and default.xex is encrypted (zero
plaintext "GamePart"). Xenia decompresses the image at load, so dump_image.py
reads it out of guest memory and validates it against the corpus's own landmarks:
the 29-entry GamePart id table at 0x820A1630 and the Xbox 360 D3D runtime
strings. String search and table dumps work again; instruction-level work does
not, and the present interval I wanted is an immediate, not a string.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014voBspJ6kFncNErZJuZcLw
182 lines
7.0 KiB
Python
182 lines
7.0 KiB
Python
"""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)
|