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
192 lines
7.7 KiB
Python
Executable File
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)
|