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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNR5Y79D1T4bBr6gJQaWFP
This commit is contained in:
sylph-decoder
2026-08-29 11:50:32 +00:00
parent e2d2dd34f0
commit 724e06b134
7 changed files with 821 additions and 0 deletions

Binary file not shown.

View File

@@ -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 04 → 1.08, r 1013 → **36.75**, r 2026 → 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.628.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.628.8 fps spans **2.082.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.

View File

@@ -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())

View File

@@ -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())

191
tools/re-capture/quad_rects.py Executable file
View File

@@ -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)

View File

@@ -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())

View File

@@ -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())