Files
Sylpheed/tools/re-capture/ui_draw_order.py
Sylpheed RE agent ceb8c8b572 tools: capture targets, and a Z-aware draw-order decoder
`ui_draw_capture.sh` grows three knobs the second iteration needed:

* ARM=early presses F10 before the title exists, so a long window contains the
  frames in which a screen is BUILT (it turns out none are — the title screen
  submits the same 11 draws every frame and never rebuilds);
* TARGET=menu taps A once on the title and arms on the main menu, skipping
  attract movies on the way — it does not get there, but the blocker it hits is
  documented rather than worked around;
* EXTRA_FLAGS passes emulator cvars through (--create_profile_if_none,
  --mem_watch=false, --log_level).

`ui_draw_order.py` follows the capture's new vertex format (x, y, z) and reports
the Z it now has.
2026-08-18 20:15:48 +00:00

117 lines
4.5 KiB
Python
Executable File

#!/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 <capture.log> <texture-dir> [frame]
`texture-dir` is the output of `sylpheed-cli pak textures`, whose filenames
carry `<name>_<W>x<H>.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())