Files
Sylpheed/tools/re-capture/ui_blend_map.py
sylph-decoder e61c60029a re: the UI blend mode is MEASURED -- the frames are drawn ADDITIVE
Closes the one route t32-blend-mode-not-on-disc.md left open: the executable's
draw path. Canary's UI draw capture now logs RB_BLENDCONTROL0 per draw, and the
game was driven to the main menu and to EXTRAS with F10 at each.

The title-side UI uses two blend states and ONE pixel shader:

  0x07010701  src=ONE dst=1-SRC_ALPHA  alpha-over (premultiplied)
              ptbase, pteff05, the fade quad, ptmsg, ptmsg2, pttitle, buttons
  0x01010101  src=ONE dst=ONE          ADDITIVE
              ptframe1, ptframe2, ptframe3, pteff20, both rotated sweep strips

Two controls, both run before the result was read:

* the NDC->pixel conversion that identifies a draw by its quad size reproduces
  1134 and 1303 px for the two rotated sweep strips -- numbers measured by a
  different tool in a different session -- on BOTH screens. The tool prints
  PASS/FAIL and disclaims its own output on FAIL.
* pixel shader 0xE59B2B3DA4AA9008 is used with BOTH states, 12 draws additive
  and 18 alpha-over. ptframe1 and ptbase run the same shader; only the blend
  register differs. So this is a blend result, not a shader result.

This confirms the port's independent measurement -- it solved the composite per
pixel from two backgrounds and found additive halves alpha-over's error on both
frames -- by a route with nothing in common with it.

So the blend is no longer authored: 'any blend you choose is authored' was true
of the disc and is not true of the game. What is still unknown is which field
selects it; elements sharing a mode are batched into one draw call, so the
selection happens before the draw.

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

119 lines
5.1 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(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)
# 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()