Files
Sylpheed/tools/re-capture/ui_blend_map.py
sylph-decoder fa887b4e5f re: EXTRAS complete -- ptframe4, pteff21/22/23 and pteff10 are all ADDITIVE
The four elements the port measured as the worst on EXTRAS, and which appeared
in no draw, were in a draw all along: the 24-index additive batch holds six
quads and Canary printed the first two. Cap raised to 64, screen re-captured,
all six named. Same draw as ptframe3, whose state was already measured -- the
one-way implication doing real work.

pteff10 is identified too, and it needed the resting SCALE: it ships as 409x144
and is drawn at 200 % x 500 % = 816x720. The matcher's 'try 1x and 2x' rule
could not name it at any scale and reported a near miss against something else,
which is a failure wearing the clothes of an answer. Candidates are now the
declaration's pivot*2 scaled by the resting keyframe as well as the texture at
1x and 2x, and the tolerance is the log's own NDC print quantisation rather than
a chosen number.

Flagged rather than buried: pteff10 measuring additive is in tension with the
port measuring it nearly exact under alpha-over. Both can be true for a dim
semi-transparent glow over a dark background, and it is the one row a rendering
check does not corroborate.

Also stated: the three full-screen alpha-over draws are NOT individually
identified -- four elements declare 1280x720 -- so the label on those rows is a
candidate, not an identification.

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

137 lines
6.2 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 candidates(builds):
"""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, "--"] + [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(",")]
cands = candidates(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)
# 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()