#!/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 candidates(builds, pak="GP_TITLE"): """Every size a UI draw could legitimately have, with a label and a basis. Two bases, because neither alone names every element: declared the declaration's `pivot * 2` scaled by the RESTING keyframe's scale_x/scale_y. This is the only thing that names `pteff10`, which ships as 409x144 and is drawn at 200 % x 500 % = 816x720 -- a scale-guessing matcher called it "no match" and offered a near miss against something else instead. texture the decoded `.t32`'s own pixel size, at 1x and 2x. Needed because the pivot is NOT always half the texture (`ptmsg2` declares 384x38 for a 354x38 sprite) and because a button's focused `f` variant has a texture and no declaration of its own. `builds` is a list: the live title is TWO builds composited, 4 for the art and 2 for the `PRESS (A)` plate. """ env = {**__import__("os").environ, "SYLPHEED_DISC": "/disc"} def run(example): return subprocess.run( ["cargo", "run", "--release", "-q", "-p", "sylpheed-formats", "--example", example, "--", pak] + [str(b) for b in builds], capture_output=True, text=True, cwd="/work", env=env).stdout out = [] for line in run("rest_scale_of").splitlines(): m = re.match(r"(\S+\.(?:t32|prm|rat))\s+\d+,\d+\s+\d+\s+\d+\s+([\d.]+)x([\d.]+)", line) if m: out.append((m.group(1), "declared", float(m.group(2)), float(m.group(3)))) for line in run("frame_alpha_census").splitlines(): m = re.match(r"(\S+\.t32)\s+(\d+)x(\d+)", line) if m: w, h = float(m.group(2)), float(m.group(3)) out.append((m.group(1), "texture", w, h)) out.append((m.group(1), "texture@2x", w * 2, h * 2)) return out def main(): path = sys.argv[1] spec = sys.argv[sys.argv.index("--build") + 1] if "--build" in sys.argv else "5" builds = [int(b) for b in spec.split(",")] pak = sys.argv[sys.argv.index("--pak") + 1] if "--pak" in sys.argv else "GP_TITLE" cands = candidates(builds, pak) 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) # The log prints NDC to TWO DECIMALS, so a width is quantised to # 0.01 * 1280 / 2 = 6.4 px and a height to 3.6 px. The tolerance # below is that quantisation, not a fudge factor: a match inside it # is as close as this instrument can report. best, bd, bb = "-", 1e9, "" for n, basis, sw, sh in cands: d = abs(sw - w) / 6.4 + abs(sh - h) / 3.6 if d < bd: best, bd, bb = n, d, basis tag = ("%s [%s]" % (best, bb)) if bd <= 2.0 else \ "(no match, nearest %s [%s] off %.1f quanta)" % (best, bb, 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()