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:
111
tools/re-capture/ui_blend_map.py
Executable file
111
tools/re-capture/ui_blend_map.py
Executable 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()
|
||||
Reference in New Issue
Block a user