tools: map a Canary UI draw log's blend states onto named sprites

Identifies each draw by the pixel size of its quad -- NDC extents times the
1280x720 surface -- matched against sprite dimensions read off the disc, since
the log names no elements.

Carries its own control: build 5 draws two rotated sweep strips whose heights
were measured independently at 1134 and 1303 px in
docs/re/data/title-sweep-drawn-at-rest.txt. If the conversion does not reproduce
those, every size it prints is wrong and it says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
This commit is contained in:
sylph-decoder
2026-08-31 06:02:12 +00:00
parent 6f53c2cc9f
commit 2c7c6c255e

111
tools/re-capture/ui_blend_map.py Executable file
View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Which UI element is drawn with which BLEND state — from a Canary UI draw log.
ui_blend_map.py <xenia_re_ui_draws_NN.log> [--build 5]
Canary's `CaptureUiDrawForRE` (patched 2026-08-31) logs RB_BLENDCONTROL0 per
draw. The log does not name elements, so a draw is identified by the PIXEL SIZE
of its quad: the vertices are in NDC, and (x_max-x_min)/2*1280 by
(y_max-y_min)/2*720 is the on-screen size, which is matched against the sprite
dimensions read straight off the disc.
CONTROL, and it is not optional: GP_TITLE build 5 draws two rotated sweep
strips whose heights were measured independently in
docs/re/data/title-sweep-drawn-at-rest.txt as 1134 and 1303 px. If this script's
NDC->pixel conversion does not reproduce those two numbers, its sizes are wrong
and every identification below them is worthless. It prints the check.
Blend states seen on this title's UI, decoded from the Xenos enum
(xenos.h: kZero=0 kOne=1 kSrcAlpha=6 kOneMinusSrcAlpha=7; BlendOp kAdd=0):
0x00010001 src=ONE dst=ZERO opaque, blending effectively off
0x07010701 src=ONE dst=ONE_MINUS_SRC_A premultiplied alpha-over
0x01010101 src=ONE dst=ONE ADDITIVE
"""
import re
import subprocess
import sys
W, H = 1280.0, 720.0
FACTOR = {0: "ZERO", 1: "ONE", 4: "SRC_COLOR", 5: "1-SRC_COLOR", 6: "SRC_ALPHA",
7: "1-SRC_ALPHA", 8: "DST_COLOR", 9: "1-DST_COLOR", 10: "DST_ALPHA",
11: "1-DST_ALPHA"}
def blend_name(raw):
src, op, dst = raw & 0x1F, (raw >> 5) & 7, (raw >> 8) & 0x1F
if (src, op, dst) == (1, 0, 0):
return "opaque"
if (src, op, dst) == (1, 0, 7):
return "alpha-over(premul)"
if (src, op, dst) == (1, 0, 1):
return "ADDITIVE"
if (src, op, dst) == (6, 0, 7):
return "alpha-over(straight)"
return "%s+%s" % (FACTOR.get(src, src), FACTOR.get(dst, dst))
def sprite_sizes(build):
"""name -> (w, h), straight off the disc via the formats crate."""
out = subprocess.run(
["cargo", "run", "--release", "-q", "-p", "sylpheed-formats",
"--example", "frame_alpha_census"],
capture_output=True, text=True, cwd="/work",
env={**__import__("os").environ, "SYLPHEED_DISC": "/disc"}).stdout
sizes, cur = {}, None
for line in out.splitlines():
m = re.match(r"=== GP_TITLE build (\d+) ===", line)
if m:
cur = int(m.group(1))
continue
m = re.match(r"(\S+\.t32)\s+(\d+)x(\d+)", line)
if m and cur == build:
sizes[m.group(1)] = (int(m.group(2)), int(m.group(3)))
return sizes
def main():
path = sys.argv[1]
build = int(sys.argv[sys.argv.index("--build") + 1]) if "--build" in sys.argv else 5
sizes = sprite_sizes(build)
lines = open(path).read().splitlines()
rows = []
for i, line in enumerate(lines):
m = re.search(r"^\s*(\d+) prim=(\d+) indices=(\d+).* blend=0x([0-9A-F]+)", line)
if not m:
continue
idx, prim, nidx, raw = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4), 16)
tex = re.search(r"tex\[base=0x([0-9A-F]+) (\d+)x(\d+)", line)
verts = []
if i + 1 < len(lines):
for vm in re.finditer(r"\[(-?\d+\.\d+),(-?\d+\.\d+),z=", lines[i + 1]):
verts.append((float(vm.group(1)), float(vm.group(2))))
quads = [verts[k:k + 4] for k in range(0, len(verts) - 3, 4)]
rows.append((idx, prim, nidx, raw, tex.group(1) if tex else None, quads))
print("draw prim idx blend state quad px (w x h) best name match")
heights = []
for idx, prim, nidx, raw, tex, quads in rows:
for q in quads:
xs = [p[0] for p in q]
ys = [p[1] for p in q]
w = (max(xs) - min(xs)) / 2 * W
h = (max(ys) - min(ys)) / 2 * H
heights.append(h)
best, bd = "-", 1e9
for n, (sw, sh) in sizes.items():
d = abs(sw - w) + abs(sh - h)
if d < bd:
best, bd = n, d
tag = best if bd <= 6 else "(no match, nearest %s off %.0f)" % (best, bd)
print("%4d %4d %4d 0x%08X %-20s %7.1f x %7.1f %s"
% (idx, prim, nidx, raw, blend_name(raw), w, h, tag))
tall = sorted(h for h in heights if h > 900)
print("\nCONTROL — the two rotated sweep strips measured independently at 1134 and 1303 px:")
print(" tall quads found: %s" % ["%.0f" % t for t in sorted(set(round(t) for t in tall))])
print(" PASS" if any(abs(t - 1134) < 3 for t in tall) and any(abs(t - 1303) < 4 for t in tall)
else " FAIL — the NDC->pixel conversion is wrong; ignore every size above")
if __name__ == "__main__":
main()