#!/usr/bin/env python3 """Turn a `log_ui_draws` capture into a per-frame PAINT ORDER, named by sprite. The capture records every draw of a frame in submission order with, for the UI shader, the quad's vertex positions. Those positions are NDC, so a quad's pixel rect is recoverable exactly; and a screen's sprites decode to near-unique sizes, so a rect's SIZE names the sprite it draws — no texture identity needed. ui_draw_order.py [frame] `texture-dir` is the output of `sylpheed-cli pak textures`, whose filenames carry `_x.png`. Matching is on size within a pixel of slack, so a size shared by two sprites reports BOTH rather than guessing. """ import re import sys import os import glob from collections import defaultdict W, H = 1280, 720 VERT = re.compile(r"\[(-?\d+\.\d+),(-?\d+\.\d+),z=(-?\d+\.\d+)\]") def sprite_index(texdir): idx = defaultdict(list) for f in glob.glob(os.path.join(texdir, "*.png")): m = re.match(r"[0-9a-f]{8}_(.+?)_(\d+)x(\d+)\.png$", os.path.basename(f)) if m: idx[(int(m.group(2)), int(m.group(3)))].append(m.group(1)) return idx # Slack, and why it is not 0: a quad is a few pixels smaller than the sprite it # draws (ptlogo1 919x113 comes back as 915x115, ptcopyright 694x20 as 691x18). # The cause is unmeasured — a scale just under 1, or a UV inset — so match on # nearest size within a few pixels and print the distance rather than pretend to # an exact hit. SLACK = 6 def name_for(idx, w, h): hits = [] for (sw, sh), names in idx.items(): d = max(abs(sw - w), abs(sh - h)) if d <= SLACK: for n in names: hits.append((d, f"{n} {sw}x{sh}" + (f" (±{d})" if d else ""))) hits.sort() # The same sprite appears once per language build of the pak, under a # different hash prefix — one name is one candidate. seen, out = set(), [] for _, t in hits: if t not in seen: seen.add(t) out.append(t) return out def main(): log, texdir = sys.argv[1], sys.argv[2] want = sys.argv[3] if len(sys.argv) > 3 else None idx = sprite_index(texdir) frame = None pending = None first = None for line in open(log): if line.startswith("# every draw"): first = line.rstrip().split()[-1].split("..")[0] frame = first continue if line.startswith("--- frame"): frame = line.split()[2] continue m = re.match(r"\s*(\d+) (prim=.*)", line) if m: pending = (m.group(1), m.group(2)) continue if "vb=0x" in line and pending: if want is None or frame == want or frame is None: verts = [(float(a), float(b)) for a, b, _z in VERT.findall(line)] if verts: # Two conventions in one log: the UI sprite shader emits NDC, # the full-screen pass emits pixels already (-0.5 .. 1279.5). if max(abs(v) for xy in verts for v in xy) > 4.0: xs = [x for x, _ in verts] ys = [y for _, y in verts] else: xs = [(x + 1) / 2 * W for x, _ in verts] ys = [(1 - y) / 2 * H for _, y in verts] n = len(verts) # A quad list stores 4 verts per quad; report each quad. per = 4 if "prim=13" in pending[1] else n for q in range(0, n, per): qx, qy = xs[q:q + per], ys[q:q + per] if not qx: continue x0, x1 = min(qx), max(qx) y0, y1 = min(qy), max(qy) w, h = round(x1 - x0), round(y1 - y0) # Axis-aligned iff every vertex sits on the bbox edge. rot = "" if all( (abs(x - x0) < 1.0 or abs(x - x1) < 1.0) and (abs(y - y0) < 1.0 or abs(y - y1) < 1.0) for x, y in zip(qx, qy) ) else " ROT" names = name_for(idx, w, h) print(f"frame {frame} draw {pending[0]:>4} " f"({round(x0):4},{round(y0):4}) {w:4}x{h:<4}{rot} " f"→ {', '.join(names) if names else '?'}") pending = None return 0 if __name__ == "__main__": raise SystemExit(main())