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