From 724e06b13480ce2317bf395e2ee6964cfae14c83 Mon Sep 17 00:00:00 2001 From: sylph-decoder Date: Sat, 29 Aug 2026 11:50:32 +0000 Subject: [PATCH] re: the main menu's focus ring spins continuously -- period 2.18 s, measured 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) Claude-Session: https://claude.ai/code/session_01KNR5Y79D1T4bBr6gJQaWFP --- docs/re/data/focus-ring-period-corr.npy | Bin 0 -> 8744 bytes docs/re/focus-ring-spin-measured.md | 119 +++++++++++++++ tools/re-capture/focus_ring_probe.py | 159 ++++++++++++++++++++ tools/re-capture/focus_ring_report.py | 109 ++++++++++++++ tools/re-capture/quad_rects.py | 191 ++++++++++++++++++++++++ tools/re-capture/ring_angular.py | 138 +++++++++++++++++ tools/re-capture/ring_period.py | 105 +++++++++++++ 7 files changed, 821 insertions(+) create mode 100644 docs/re/data/focus-ring-period-corr.npy create mode 100644 docs/re/focus-ring-spin-measured.md create mode 100755 tools/re-capture/focus_ring_probe.py create mode 100644 tools/re-capture/focus_ring_report.py create mode 100755 tools/re-capture/quad_rects.py create mode 100644 tools/re-capture/ring_angular.py create mode 100644 tools/re-capture/ring_period.py diff --git a/docs/re/data/focus-ring-period-corr.npy b/docs/re/data/focus-ring-period-corr.npy new file mode 100644 index 0000000000000000000000000000000000000000..a42e9313cc46269327ba7338cdeaef4ca05e3c3e GIT binary patch literal 8744 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

Pw|Idg;<)7`+%8)`+%v%_hfuAZ@Fq; znc=y>tik-5RLom48S|DItHJ%gy9w(f4;1_RKK75y5bLN?8i&tgUBv#Mjdfr}nNrM~ z+lBJQcd;(sW&crDisu6}8|%O(kAe63t}UK}tf*`(u0PY;4*rkJ!#+^S>WJs{Oc7QGNyM!2N>X31%PW&rZjFU@nXKH~xdyj}q5)k_oPN#Qgur zF2Fi)mpx&e$;R)4Y!#jloF?YUHsJelzu^1I7mD{CCR!kVFWc}OkPJkn|HJ;^TE#l7 a6UPbGgEztZN?zmsQz=crak(SyhyMfRcl$m7 literal 0 HcmV?d00001 diff --git a/docs/re/focus-ring-spin-measured.md b/docs/re/focus-ring-spin-measured.md new file mode 100644 index 00000000..c34375dd --- /dev/null +++ b/docs/re/focus-ring-spin-measured.md @@ -0,0 +1,119 @@ +# ✅ The main menu's focus ring spins continuously — period **2.18 s**, measured + +**Status:** ✅ **measured** (not on the disc as a period; the disc declares the +ramp, the running game supplies the rate). Taken 2026-08-29 against Xenia Canary +with the disc mounted at `/disc`. + +**Question this closes:** the port asked whether `ptbtneff01` — the 42×46 ring on +the focused button — is *animated* while a button sits focused, or drawn once and +held. It had shipped the ring at 0° and marked that known-wrong. +[`structures/ui-button-focus-record.md`](structures/ui-button-focus-record.md) +already said "the ring SPINS" from **one** frame showing it at a large angle; +that is consistent with a continuous spin *and* with a static draw at a fixed +angle, so it did not answer the question asked. + +## What the ring actually does + +Five single frames from one run, 4 s apart, focus held on `TUTORIAL` throughout: + +![five frames](captures/focus-ring/ring-single-frames-4s-apart.png) + +The ring carries a bright head, and the head is at a different angular position +in every frame. It is still moving 16 s in, so it does **not** ramp once and +stop. + +⚠️ **The 20 s mean of the same run is a uniform circle** +([`ring-20s-mean-uniform.png`](captures/focus-ring/ring-20s-mean-uniform.png)) — +that is the spin smearing itself out, and it is why an averaged frame must never +be read as a single frame. A human looking at the live game sees the head; the +average does not have one. + +## The measurement, and why it is not an angle + +🔴 **No angle is estimated anywhere.** The corpus's centroid estimator fails its +own control by up to 19.8°, and a 360-bin angular cross-correlation written for +this measurement **also failed its control** — a synthetic 30° rotation of a live +frame came back as 0° (peak 0.596), while 90/180/270° came back exactly (peak +1.000), i.e. the estimator only resolves the exact pixel permutations. It was +therefore not used. + +What was used needs no angle. Two observables separate *rotation* from a +*brightness pulse*, and both were taken in the same run: + +| observable | rotation predicts | pulse predicts | **measured** | +|---|---|---|---| +| total annulus brightness | conserved | varies | **0.4 % spread over 16 s** (5 frames); **0.53 %** over 359 frames | +| per-angular-bin brightness | varies (a travelling feature) | varies together | **per-bin sd 24.2**, max 80.3, against a per-frame angular sd of 42.2 | + +Brightness moves *around* the annulus while the total holds. A pulse is excluded. + +The temporal standard deviation over 103 frames is **an annulus** and nothing +else — dark inside, dark outside, peaking exactly on the ring's stroke +(radial std: r 0–4 → 1.08, r 10–13 → **36.75**, r 20–26 → 1.18): + +![std annulus](captures/focus-ring/ring-temporal-std-annulus.png) + +⚠️ A positional *jitter* would smear variation outside the stroke. It does not: +variation falls to ~1 both inside and outside, so the ring is not moving, it is +turning. + +### The period + +A dense 359-frame filmstrip (24 s at **15.03 fps against a requested 15 fps** — +the consumer kept up exactly, so these timestamps are not backlogged) gives the +annulus's 360-bin profile per frame, correlated against frame 0. A rotating ring +returns to itself once per revolution, so the trace's period **is** the spin +period — again with no angle estimated. + +Autocorrelation local maxima, in seconds: + +``` +2.18 4.36 6.52 8.70 10.86 13.02 15.22 17.42 +spacings: 2.18 2.16 2.18 2.16 2.16 2.20 2.20 mean 2.177 s +``` + +**Eight consecutive evenly-spaced peaks over nine revolutions.** A drifting +instrument cannot produce even spacing, which is the internal check on the +number. + +Raw trace committed at [`data/focus-ring-period-corr.npy`](data/focus-ring-period-corr.npy) +(rows: t, correlation-with-frame-0, annulus mean). + +### What the period is in the game's own units + +⚠️ **2.18 s is wall-clock under this emulator, and the emulator is not running +the game at 30 Hz.** The corpus measures 27.6–28.8 fps here. `ptbtneff01` +declares its first keyframe at **t = 120**, and under the settled reading +(1 unit = 1/60 s, 2 units per rendered frame) 120 units is **60 rendered +frames** — which at 27.6–28.8 fps spans **2.08–2.17 s**. The measurement sits at +the top of that band. + +**So the spin is one revolution per 120 units = 60 frames = 2.00 s at a true +30 Hz**, and no new constant is needed to account for it. 🟡 The 2.18 s is +consistent with the declared 120 rather than a re-derivation of it: the guest +frame rate was not measured in this same run, so the agreement is +consistency, not closure. + +## Two other things the same run measured + +* ✅ **The focus ring is the ONLY moving thing on the settled main menu.** Over + 103 frames / 20 s untouched, temporal std is **exactly 0.000** on every + unfocused button box, on the `NEW GAME` label, and on the `ptmsg` footer. Only + the focused button's box moves (std 4.46 against a background noise floor of + 0.906). A port that draws the main menu statically plus a spinning ring is + drawing everything that moves. +* ✅ **The ring is `ptbtneff01`, positionally confirmed.** Its centre was located + from the temporal-std map at game **(520.7, 339.7)**. The declared leaf offset + applied to button 3's rest position (542, 322) predicts **(521, 340)**. That + is a sub-pixel agreement between a decoded declaration and a live measurement, + and it is what ties the annulus to the record rather than to "a circle near the + cursor". + +## Reach + +* One run, one emulator, English locale, `GP_TITLE` build 5. +* The period is measured on **one** focused button (`OPTIONS`, button 4) and the + spin is shown on a second (`TUTORIAL`, button 3). Not checked on all five, and + not checked on `EXTRAS`. +* Says nothing about the direction of rotation — the estimator that would give a + signed angle failed its control and was not used. diff --git a/tools/re-capture/focus_ring_probe.py b/tools/re-capture/focus_ring_probe.py new file mode 100755 index 00000000..8e421ef5 --- /dev/null +++ b/tools/re-capture/focus_ring_probe.py @@ -0,0 +1,159 @@ +#!/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()) diff --git a/tools/re-capture/focus_ring_report.py b/tools/re-capture/focus_ring_report.py new file mode 100644 index 00000000..51df8a1d --- /dev/null +++ b/tools/re-capture/focus_ring_report.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Read focus_ring_probe.py's accumulators, after ALIGNING them to game space. + +A grab is the whole root window; game coordinates only address it once the +window chrome offset is measured. This script measures that offset by +correlating the run's own mean frame against the committed `live-main-menu.png` +over a +/-12 px search, and refuses to report anything if the alignment is poor. + +Then, in game coordinates: + ring boxes -- 80x80 around each button's declared rest position; the ring + `ptbtneff01` is 42x46 and sits left of the label + static boxes -- `ptmsg` (one untimed keyframe) and a background corner: + the NEGATIVE controls, which must read sensor noise + positive ctrl -- |mean(A) - mean(C)| across the d-pad press must fire at the + two rings that changed state, or a null in A is a dead + instrument rather than a finding. +""" +import os, sys +import numpy as np +from PIL import Image + +OUT = sys.argv[1] if len(sys.argv) > 1 else "/sylph-home/re/ringcap" +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +REF = os.path.join(REPO, "docs/re/captures/title-builds/live-main-menu.png") + +BTN_Y = [162, 242, 322, 401, 482] +LABEL = ["NEW GAME", "LOAD GAME", "TUTORIAL", "OPTIONS", "EXTRAS"] +BOXES = {} +for i, y in enumerate(BTN_Y): + BOXES[f"ring{i+1} ({LABEL[i]})"] = (480, y - 20, 560, y + 60) +BOXES["ptmsg footer [static ctl]"] = (527, 595, 773, 633) +BOXES["bg corner [static ctl]"] = (10, 10, 130, 130) +BOXES["button1 label [same row]"] = (560, 142, 760, 202) + + +def gray(a): + return (0.299 * a[..., 0] + 0.587 * a[..., 1] + 0.114 * a[..., 2]).astype(np.float32) + + +def zncc(x, y): + x = x - x.mean(); y = y - y.mean() + d = np.sqrt((x * x).sum() * (y * y).sum()) + return float((x * y).sum() / d) if d else 0.0 + + +def align(mean_rgb, ref_rgb): + """Measure (dy,dx) taking GAME coords -> GRAB coords. Returns (dy,dx,corr).""" + g = gray(mean_rgb); r = gray(ref_rgb) + rh, rw = r.shape + best = (None, None, -1.0) + for dy in range(30, 60): # chrome is ~45 rows + for dx in range(-12, 13): + if dy + rh > g.shape[0] or dx < 0 or dx + rw > g.shape[1]: + continue + c = zncc(g[dy:dy + rh, dx:dx + rw], r) + if c > best[2]: + best = (dy, dx, c) + return best + + +def main(): + mA = np.load(f"{OUT}/A-mean.npy"); sA = np.load(f"{OUT}/A-std.npy") + mC = np.load(f"{OUT}/C-mean.npy"); sC = np.load(f"{OUT}/C-std.npy") + ref = np.array(Image.open(REF).convert("RGB")).astype(np.float32) + dy, dx, corr = align(mA, ref) + print(f"alignment: game(0,0) sits at grab({dx},{dy}); ZNCC {corr:+.4f}") + if corr < 0.80: + print("ALIGNMENT TOO POOR — refusing to report boxes"); return 1 + print(f" (independent check: the window chrome measured 45 rows)\n") + + def box(arr, b): + x0, y0, x1, y1 = b + return arr[y0 + dy:y1 + dy, x0 + dx:x1 + dx, :] + + d = np.abs(mA - mC) + print(f"{'box':<30} {'A std':>9} {'A p99.9':>9} {'C std':>9} " + f"{'|A-C| mean':>11} {'|A-C| max':>10}") + print("-" * 84) + rows = {} + for k, b in BOXES.items(): + a_s = box(sA, b); c_s = box(sC, b); dd = box(d, b) + rows[k] = (float(a_s.mean()), float(np.percentile(a_s, 99.9)), + float(c_s.mean()), float(dd.mean()), float(dd.max())) + print(f"{k:<30} {rows[k][0]:9.3f} {rows[k][1]:9.3f} {rows[k][2]:9.3f} " + f"{rows[k][3]:11.3f} {rows[k][4]:10.2f}") + + noise = max(rows["ptmsg footer [static ctl]"][0], + rows["bg corner [static ctl]"][0]) + print(f"\nnegative-control noise floor (max of the two static boxes): {noise:.3f}") + print("A box only counts as MOVING if its phase-A std clears that floor.\n") + for k in BOXES: + if "ctl" in k: + continue + v = rows[k][0] + print(f" {k:<30} A std {v:7.3f} = {v/noise:6.2f}x the noise floor" + f" {'MOVING' if v > 3*noise else 'static'}") + + # visual artefacts, cropped to the game surface + for tag, arr, sc in (("A-std", sA, 8), ("C-std", sC, 8), ("AC-absdiff", d, 4)): + g = arr[dy:dy + 675, dx:dx + 1279, :] + Image.fromarray(np.clip(g * sc, 0, 255).astype(np.uint8)).save(f"{OUT}/{tag}-game.png") + Image.fromarray(mA[dy:dy + 675, dx:dx + 1279, :].astype(np.uint8)).save(f"{OUT}/A-mean-game.png") + Image.fromarray(mC[dy:dy + 675, dx:dx + 1279, :].astype(np.uint8)).save(f"{OUT}/C-mean-game.png") + print(f"\nwrote game-space artefacts to {OUT}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/re-capture/quad_rects.py b/tools/re-capture/quad_rects.py new file mode 100755 index 00000000..534feb01 --- /dev/null +++ b/tools/re-capture/quad_rects.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Screen-space rectangles for every textured quad in a xenia draw log. + +The draw logs under docs/re/captures/ record vertex positions in NDC, printed +to **two decimals**. That is the whole point of this script: it converts the +quads to screen space *and* carries the quantisation with them, so a +measurement taken off one of these logs cannot quietly claim more precision +than the log has. + + NDC step 0.01 -> half-step 0.005 -> a single edge is +/- 3.2 px in X + and +/- 1.8 px in Y; a WIDTH or HEIGHT is a difference of two edges, so it + carries twice that: +/- 6.4 px and +/- 3.6 px. Getting this wrong is not + academic -- at the per-edge figure the control below fails 2 of 6. + +Usage: + quad_rects.py LOG [LOG ...] # every textured quad, per frame + quad_rects.py --control LOG # check recovered sizes against + # known texture dimensions + +The control is not optional in spirit. Any claim made from these numbers +should quote the control first: four sprites of known size are recovered from +the same log, and the residuals bound what the instrument can see. +""" + +import math +import re +import sys + +# Screen is 1280x720; NDC x in [-1,1] maps to [0,1280], y in [1,-1] to [0,720]. +W, H = 1280.0, 720.0 +NDC_HALF_STEP = 0.005 +EDGE_X = NDC_HALF_STEP * W / 2.0 # 3.2 px on one edge +EDGE_Y = NDC_HALF_STEP * H / 2.0 # 1.8 px on one edge +SIZE_X = 2 * EDGE_X # 6.4 px on a width (two edges) +SIZE_Y = 2 * EDGE_Y # 3.6 px on a height (two edges) + +# Decoded texture sizes for build 4 of GP_TITLE, from +# docs/re/ui-title-paint-order-capture.md and docs/re/ui-title-build-map.md. +# These are the known-positives the control checks against. +CONTROL_SIZES = { + "ptlogo1.t32": (919, 113), + "ptlogo2.t32": (992, 104), + "ptlogo_back2.t32": (1118, 262), + "ptlogo_back2eff.t32": (1133, 280), + "ptcopyright.t32": (694, 20), + "ptbtn00.t32": (513, 50), + "ptbtn00f.t32": (537, 76), +} + +FRAME_RE = re.compile(r"--- frame (\d+) ---") +DRAW_RE = re.compile(r"\s*(\d+) prim=(\d+) indices=(\d+)") +TEX_RE = re.compile(r"tex\[base=(0x[0-9A-Fa-f]+) (\d+)x(\d+)") +VERT_RE = re.compile(r"\[(-?\d+\.\d+),(-?\d+\.\d+),z=") + + +def parse(path): + """Yield dicts: frame, draw, tex base, and the quad's screen-space rect.""" + frame, cur = 0, None + for line in open(path): + m = FRAME_RE.match(line) + if m: + frame = int(m.group(1)) + continue + m = DRAW_RE.match(line) + if m: + t = TEX_RE.search(line) + cur = {"frame": frame, "draw": int(m.group(1)), + "tex": t.group(1) if t else None} + continue + if "v:" in line and cur is not None: + verts = [(float(a), float(b)) for a, b in VERT_RE.findall(line)] + # A draw can carry several quads; four vertices each. + for i in range(0, len(verts) - 3, 4): + q = verts[i:i + 4] + xs = [(x + 1.0) * W / 2.0 for x, _ in q] + ys = [(1.0 - y) * H / 2.0 for _, y in q] + # Vertex order is TL, TR, BR, BL, so edge 0->1 is the drawn + # width and 1->2 the drawn height. For a ROTATED quad the + # bounding box is not the sprite; the edges are. + e0 = math.hypot(xs[1] - xs[0], ys[1] - ys[0]) + e1 = math.hypot(xs[2] - xs[1], ys[2] - ys[1]) + ang = math.degrees(math.atan2(ys[1] - ys[0], xs[1] - xs[0])) + yield {**cur, + "left": min(xs), "top": min(ys), + "w": max(xs) - min(xs), "h": max(ys) - min(ys), + "ew": e0, "eh": e1, "rot": ang, + "cx": sum(xs) / 4.0, "cy": sum(ys) / 4.0} + cur = None + + +def dump(path): + print(f"# {path}") + print(f"# NDC printed to 2 dp -> edge +/- {EDGE_X:.1f}/{EDGE_Y:.1f} px, " + f"size +/- {SIZE_X:.1f}/{SIZE_Y:.1f} px (X/Y)") + print(f"{'frame':>5} {'draw':>5} {'tex':>12} " + f"{'left':>8} {'top':>8} {'bboxW':>8} {'bboxH':>8} " + f"{'edgeW':>8} {'edgeH':>8} {'rot':>7} {'cx':>8} {'cy':>8}") + for q in parse(path): + if q["tex"] is None: + continue + print(f"{q['frame']:>5} {q['draw']:>5} {q['tex']:>12} " + f"{q['left']:>8.1f} {q['top']:>8.1f} {q['w']:>8.1f} {q['h']:>8.1f} " + f"{q['ew']:>8.1f} {q['eh']:>8.1f} {q['rot']:>7.2f} " + f"{q['cx']:>8.1f} {q['cy']:>8.1f}") + + +def control(path): + """Recover the known-positive sprites by size and report the residual.""" + rects = [q for q in parse(path) if q["tex"] is not None] + print(f"# control: {path}") + print(f"{'sprite':<22} {'decoded':>11} {'measured':>13} " + f"{'dx':>6} {'dy':>6} verdict") + ok = True + for name, (tw, th) in CONTROL_SIZES.items(): + best = min(rects, key=lambda q: abs(q["w"] - tw) + abs(q["h"] - th)) + dx, dy = best["w"] - tw, best["h"] - th + good = abs(dx) <= SIZE_X and abs(dy) <= SIZE_Y + ok &= good + print(f"{name:<22} {tw:>5}x{th:<5} {best['w']:>6.1f}x{best['h']:<6.1f} " + f"{dx:>6.1f} {dy:>6.1f} {'PASS' if good else 'FAIL'}") + print(f"# {'CONTROL PASSES' if ok else 'CONTROL FAILS'} — " + f"every known size recovered inside the log's own quantisation" + if ok else "# CONTROL FAILS — do not measure anything with this") + return 0 if ok else 1 + + +# Every sprite the title's build-4 capture can draw, by decoded size. The two +# pteff03 entries are the nested ptloop leaves, whose declared vertical scales +# are 600 % and 800 %. +TITLE_SPRITES = { + (919, 113): "ptlogo1.t32", + (992, 104): "ptlogo2.t32", + (1118, 262): "ptlogo_back2.t32", + (1133, 280): "ptlogo_back2eff.t32", + (694, 20): "ptcopyright.t32", + (513, 50): "ptbtn00.t32", + (38, 18): "ptlogo_tm.t32", + (399, 180): "pteff03/pteff03a.t32", + (537, 76): "ptbtn00f.t32", # build 2's focus plate +} + + +def scales(path): + """For each quad, the drawn size over the nearest decoded sprite size. + + The question this answers: which elements are drawn at a scale other than + 100 %? Only those can say anything about what scale is anchored on. + """ + print(f"# scale census: {path}") + print(f"{'frame':>5} {'sprite':<22} {'edgeW':>8} {'edgeH':>8} " + f"{'sx%':>7} {'sy%':>7} {'rot':>7}") + seen = set() + for q in parse(path): + if q["tex"] is None: + continue + if abs(q["ew"] - W) < SIZE_X and abs(q["eh"] - H) < SIZE_Y: + name, sx, sy = "full-screen layer", 1.0, 1.0 + key = (name, 1.0, 1.0) + if key not in seen: + seen.add(key) + print(f"{q['frame']:>5} {name:<22} {q['ew']:>8.1f} " + f"{q['eh']:>8.1f} {100.0:>7.1f} {100.0:>7.1f} " + f"{q['rot']:>7.2f}") + continue + # Match on the edge lengths, allowing any uniform-ish scale factor. + best, bestcost = None, None + for (tw, th), name in TITLE_SPRITES.items(): + sx, sy = q["ew"] / tw, q["eh"] / th + cost = abs(math.log(sx)) + abs(math.log(sy)) + if bestcost is None or cost < bestcost: + best, bestcost = (name, tw, th, sx, sy), cost + name, tw, th, sx, sy = best + key = (name, round(sx, 2), round(sy, 2)) + if key in seen: + continue + seen.add(key) + print(f"{q['frame']:>5} {name:<22} {q['ew']:>8.1f} {q['eh']:>8.1f} " + f"{100 * sx:>7.1f} {100 * sy:>7.1f} {q['rot']:>7.2f}") + return 0 + + +if __name__ == "__main__": + args = sys.argv[1:] + if not args: + sys.exit(__doc__) + if args[0] == "--scales": + sys.exit(max(scales(p) for p in args[1:])) + if args[0] == "--control": + sys.exit(max(control(p) for p in args[1:])) + for p in args: + dump(p) diff --git a/tools/re-capture/ring_angular.py b/tools/re-capture/ring_angular.py new file mode 100644 index 00000000..29cc4ec9 --- /dev/null +++ b/tools/re-capture/ring_angular.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Is the focus ring ROTATING, or just pulsing in brightness? + +The temporal-std map of a focused button is an annulus, which both hypotheses +predict: a travelling bright feature varies every annulus pixel, and so does a +uniform fade. Two observables separate them, and this script reports both. + + (1) TOTAL annulus brightness per frame. A rotation moves brightness around + the annulus and conserves the sum; an alpha pulse does not. + (2) The 360-bin ANGULAR PROFILE, cross-correlated between frames. A rotation + shifts the profile by a lag; a pulse scales it in place. + +CONTROL FIRST. The angular estimator is run over a known synthetic rotation of +the run's own first frame (30/90/180/270 deg) and must recover it; the corpus +already has a centroid estimator that fails this by up to 19.8 deg, and that is +why one is not used here. + +Usage: ring_angular.py CX CY [FRAME ...] (CX,CY in GAME coordinates) +""" +import os, sys +import numpy as np +from PIL import Image + +DY, DX = 45, 1 # game(0,0) -> grab, measured by focus_ring_report.py +R_IN, R_OUT = 8.0, 18.0 # annulus radii, in px, read off the std map +NBINS = 360 + + +def ndrotate(img, deg): + """Bilinear rotation about the patch centre -- the control's known-positive.""" + h, w = img.shape + cy, cx = (h - 1) / 2.0, (w - 1) / 2.0 + yy, xx = np.mgrid[0:h, 0:w] + t = np.radians(deg) + ys = (yy - cy) * np.cos(t) - (xx - cx) * np.sin(t) + cy + xs = (yy - cy) * np.sin(t) + (xx - cx) * np.cos(t) + cx + y0 = np.floor(ys).astype(int); x0 = np.floor(xs).astype(int) + fy = ys - y0; fx = xs - x0 + out = np.zeros_like(img) + for dy_, dx_, wgt in ((0, 0, (1 - fy) * (1 - fx)), (0, 1, (1 - fy) * fx), + (1, 0, fy * (1 - fx)), (1, 1, fy * fx)): + yi = np.clip(y0 + dy_, 0, h - 1); xi = np.clip(x0 + dx_, 0, w - 1) + ok = (y0 + dy_ >= 0) & (y0 + dy_ < h) & (x0 + dx_ >= 0) & (x0 + dx_ < w) + out += np.where(ok, img[yi, xi] * wgt, 0.0) + return out + + +def ndrotate(img, deg): + """Bilinear rotation about the patch centre -- the control's known-positive.""" + h, w = img.shape + cy, cx = (h - 1) / 2.0, (w - 1) / 2.0 + yy, xx = np.mgrid[0:h, 0:w] + t = np.radians(deg) + ys = (yy - cy) * np.cos(t) - (xx - cx) * np.sin(t) + cy + xs = (yy - cy) * np.sin(t) + (xx - cx) * np.cos(t) + cx + y0 = np.floor(ys).astype(int); x0 = np.floor(xs).astype(int) + fy = ys - y0; fx = xs - x0 + out = np.zeros_like(img) + for dy_, dx_, wgt in ((0, 0, (1 - fy) * (1 - fx)), (0, 1, (1 - fy) * fx), + (1, 0, fy * (1 - fx)), (1, 1, fy * fx)): + yi = np.clip(y0 + dy_, 0, h - 1); xi = np.clip(x0 + dx_, 0, w - 1) + ok = (y0 + dy_ >= 0) & (y0 + dy_ < h) & (x0 + dx_ >= 0) & (x0 + dx_ < w) + out += np.where(ok, img[yi, xi] * wgt, 0.0) + return out + + +def patch(path, cx, cy, half=28): + a = np.array(Image.open(path).convert("RGB")).astype(np.float32) + g = 0.299 * a[..., 0] + 0.587 * a[..., 1] + 0.114 * a[..., 2] + return g[cy + DY - half:cy + DY + half, cx + DX - half:cx + DX + half] + + +def polar(p): + """(total annulus brightness, 360-bin mean profile) of one patch.""" + h, w = p.shape + yy, xx = np.mgrid[0:h, 0:w] + cy, cx = (h - 1) / 2.0, (w - 1) / 2.0 + r = np.hypot(yy - cy, xx - cx) + m = (r >= R_IN) & (r <= R_OUT) + th = (np.degrees(np.arctan2(yy - cy, xx - cx)) + 360.0) % 360.0 + idx = np.clip((th[m] / 360.0 * NBINS).astype(int), 0, NBINS - 1) + v = p[m] + prof = np.zeros(NBINS); cnt = np.zeros(NBINS) + np.add.at(prof, idx, v); np.add.at(cnt, idx, 1.0) + prof = np.where(cnt > 0, prof / np.maximum(cnt, 1), np.nan) + prof = np.nan_to_num(prof, nan=np.nanmean(prof)) + return float(v.sum()), prof + + +def lag(p0, p1): + """Circular cross-correlation lag in degrees taking p0 -> p1.""" + a = p0 - p0.mean(); b = p1 - p1.mean() + c = np.fft.irfft(np.fft.rfft(b) * np.conj(np.fft.rfft(a)), NBINS) + k = int(np.argmax(c)) + peak = c[k] / np.sqrt((a * a).sum() * (b * b).sum()) + return (k if k <= 180 else k - 360), float(peak) + + +def main(): + cx, cy = int(sys.argv[1]), int(sys.argv[2]) + frames = sys.argv[3:] + p0 = patch(frames[0], cx, cy) + + print("=== CONTROL: recover a known synthetic rotation of frame 0 ===") + ok = True + for deg in (30, 90, 180, 270): + rot = ndrotate(p0, -deg) + _, pr = polar(rot); _, pa = polar(p0) + d, pk = lag(pa, pr) + err = ((d - deg + 180) % 360) - 180 + flag = "ok " if abs(err) <= 3 else "FAIL" + if abs(err) > 3: + ok = False + print(f" {flag} applied {deg:4d} deg -> recovered {d:5d} deg " + f"(err {err:+4d}, peak {pk:.3f})") + # negative control: a ring-free patch of the same frame must not correlate + off = patch(frames[0], cx + 160, cy) + _, po = polar(off); _, pa = polar(p0) + _, pk = lag(pa, po) + print(f" ring-free patch of the same frame: peak {pk:.3f} (must be low)") + if not ok: + print("\nCONTROL FAILED — the estimator cannot measure this; stopping.") + return 1 + print(" CONTROL PASSED\n") + + print("=== MEASUREMENT: successive live frames of the same focused ring ===") + print(f"{'frame':<24} {'annulus sum':>12} {'vs f0 %':>9} {'lag vs f0':>10} {'peak':>7}") + base_s, base_p = polar(p0) + for f in frames: + s, pr = polar(patch(f, cx, cy)) + d, pk = lag(base_p, pr) + print(f"{os.path.basename(f):<24} {s:12.1f} {100*s/base_s:8.1f}% " + f"{d:9d}d {pk:7.3f}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/re-capture/ring_period.py b/tools/re-capture/ring_period.py new file mode 100644 index 00000000..7a38d28d --- /dev/null +++ b/tools/re-capture/ring_period.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Measure the focus ring's SPIN PERIOD from a dense live filmstrip. + +No absolute angle is estimated. The corpus's centroid estimator fails its own +control by up to 19.8 deg, and a 360-bin angular cross-correlation also FAILED +the control written for it here (a synthetic 30 deg rotation of a live frame +came back as 0 deg, peak 0.596), so neither is trusted. + +What is used instead needs no angle: the annulus's 360-bin brightness profile, +correlated against frame 0. A rotating ring's profile returns to itself once +per revolution, so the correlation trace is periodic and its first return to a +maximum IS the period. The ring is located from the data (the peak of the +temporal-std map over the button column), not from a declared coordinate. + +Usage: ring_period.py SECONDS OUTDIR +""" +import os, subprocess, sys, time +import numpy as np +from PIL import Image + +W, H, DY, DX = 1280, 720, 45, 1 +R_IN, R_OUT, NB = 8.0, 18.0, 360 +SECS = float(sys.argv[1]) if len(sys.argv) > 1 else 30.0 +OUT = sys.argv[2] if len(sys.argv) > 2 else "/sylph-home/re/ringcap" +COL = (480, 130, 570, 530) # x0,y0,x1,y1 in GAME coords: the button column + + +def grab_stream(secs): + p = subprocess.Popen( + ["ffmpeg", "-loglevel", "error", "-f", "x11grab", "-draw_mouse", "0", + "-video_size", f"{W}x{H}", "-i", ":98", "-r", "15", + "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], + stdout=subprocess.PIPE, bufsize=W * H * 3 * 2) + n = W * H * 3 + t0 = time.time(); frames = []; ts = [] + x0, y0, x1, y1 = COL + 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) + frames.append(g[y0 + DY:y1 + DY, x0 + DX:x1 + DX].copy()) + ts.append(time.time() - t0) + p.kill() + return np.array(frames), np.array(ts) + + +def annulus_profile(patch, cy, cx): + h, w = patch.shape + yy, xx = np.mgrid[0:h, 0:w] + r = np.hypot(yy - cy, xx - cx) + m = (r >= R_IN) & (r <= R_OUT) + th = (np.degrees(np.arctan2(yy - cy, xx - cx)) + 360) % 360 + idx = np.clip((th[m] / 360 * NB).astype(int), 0, NB - 1) + v = patch[m] + prof = np.zeros(NB); cnt = np.zeros(NB) + np.add.at(prof, idx, v); np.add.at(cnt, idx, 1.0) + prof = np.where(cnt > 0, prof / np.maximum(cnt, 1), np.nan) + return np.nan_to_num(prof, nan=np.nanmean(prof)), float(v.mean()) + + +def main(): + F, T = grab_stream(SECS) + if len(F) < 10: + print("too few frames"); return 1 + fps = len(F) / (T[-1] - T[0]) + print(f"{len(F)} frames over {T[-1]-T[0]:.1f}s = {fps:.2f} fps", flush=True) + + std = F.std(0) + cy, cx = np.unravel_index(np.argmax( + np.array([[std[max(0, i-14):i+14, max(0, j-14):j+14].mean() + for j in range(std.shape[1])] for i in range(std.shape[0])])), std.shape) + print(f"ring located from the data at patch({cx},{cy}) = " + f"GAME({COL[0]+cx},{COL[1]+cy}); local std {std[cy, cx]:.2f}", flush=True) + + profs = []; means = [] + for f in F: + p, m = annulus_profile(f, cy, cx) + profs.append(p); means.append(m) + P = np.array(profs); M = np.array(means) + print(f"annulus mean brightness: {M.mean():.2f} +/- {M.std():.3f} " + f"({100*M.std()/M.mean():.2f}% -- a PULSE would move this)", flush=True) + + a = P[0] - P[0].mean() + corr = np.array([float(((p - p.mean()) * a).sum() / + np.sqrt(((p - p.mean())**2).sum() * (a * a).sum())) + for p in P]) + np.save(f"{OUT}/period-corr.npy", np.vstack([T, corr, M])) + print("\n t(s) corr-with-frame0 annulus mean") + for t, c, m in zip(T, corr, M): + bar = "#" * max(0, int((c + 1) * 25)) + print(f"{t:6.2f} {c:+.3f} {bar:<50} {m:7.2f}") + + # first return to a local maximum after the trace has dipped + dip = np.argmax(corr < 0.3) if (corr < 0.3).any() else None + if dip: + after = corr[dip:] + k = dip + int(np.argmax(after)) + print(f"\nfirst return to max after the dip: t = {T[k]:.2f}s (corr {corr[k]:+.3f})") + return 0 + + +if __name__ == "__main__": + sys.exit(main())