Files
Sylpheed/tools/re-capture/ui_blend_map.py
sylph-decoder 698409fdfe tools: walk to EXTRAS by cursor movement, and size-match against several builds
menu_blend_capture.sh counted two DOWNs to reach EXTRAS, which is wrong twice
over -- EXTRAS is the fifth item, and on 2026-08-31 four DOWNs landed on OPTIONS
because one press was dropped. It now presses until the cursor stops moving,
which needs no item count and no row calibration. Its title deadline follows the
same change as title_blend_capture.sh, 1200 s not 420.

ui_blend_map.py takes a comma-separated build list, because the live title is
TWO builds composited -- 4 draws the art, 2 draws the PRESS (A) plate -- and a
one-build size table cannot name the elements of a title capture.
frame_alpha_census takes its builds from argv for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-31 06:22:53 +00:00

122 lines
5.3 KiB
Python
Executable File

#!/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(builds):
"""name -> (w, h), straight off the disc via the formats crate.
`builds` is a list because the live title is TWO builds composited -- 4 draws
the art and 2 draws the `PRESS (A)` plate over it -- so a capture of the
title contains elements from both and a one-build size table cannot name
them all.
"""
out = subprocess.run(
["cargo", "run", "--release", "-q", "-p", "sylpheed-formats",
"--example", "frame_alpha_census", "--"] + [str(b) for b in builds],
capture_output=True, text=True, cwd="/work",
env={**__import__("os").environ, "SYLPHEED_DISC": "/disc"}).stdout
sizes = {}
for line in out.splitlines():
m = re.match(r"(\S+\.t32)\s+(\d+)x(\d+)", line)
if m:
sizes[m.group(1)] = (int(m.group(2)), int(m.group(3)))
return sizes
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(",")]
sizes = sprite_sizes(builds)
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)
# A sprite is drawn at its native size or at 2x: the screen is
# 1280x720 and the design space is 640x360, so `ptbase` (640x360)
# covers the screen while `pteff05` (1280x720) is 1:1. Both are
# tried and the scale is reported, because guessing one would
# silently mis-name half the draws.
best, bd, bs = "-", 1e9, 1
for n, (sw, sh) in sizes.items():
for sc in (1, 2):
d = abs(sw * sc - w) + abs(sh * sc - h)
if d < bd:
best, bd, bs = n, d, sc
tag = ("%s @%dx" % (best, bs)) if bd <= 8 else \
"(no match, nearest %s @%dx off %.0f)" % (best, bs, 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()