From 2c7c6c255eef5f79db164c1cfd3d5fef31473a5a Mon Sep 17 00:00:00 2001 From: sylph-decoder Date: Mon, 31 Aug 2026 06:02:12 +0000 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v --- tools/re-capture/ui_blend_map.py | 111 +++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100755 tools/re-capture/ui_blend_map.py diff --git a/tools/re-capture/ui_blend_map.py b/tools/re-capture/ui_blend_map.py new file mode 100755 index 00000000..bb97c2ba --- /dev/null +++ b/tools/re-capture/ui_blend_map.py @@ -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 [--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()