Files
Sylpheed/tools/re-capture/quad_rects.py
sylph-decoder 4fa3099249 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
2026-08-29 11:50:32 +00:00

192 lines
7.7 KiB
Python
Executable File

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