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
119 lines
5.1 KiB
Python
Executable File
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()
|