Files
Sylpheed/tools/re-capture/ui_blend_map.py
sylph-decoder bbd85e9202 re: T8aD +0x04 bit 0x02 predicts the MEASURED blend, 35/35 -- and a prediction to test it
REFUTED.md kills this claim: 'T8aD +0x04 bit 0x02 selects an additive blend ->
mine, and refuted. Blending those sprites additively worsens every measure
against the capture.' That refutation rests entirely on our renderer, which the
corpus's own rule calls a hypothesis under test. The blend is now measured off
the GPU, so the claim can be tested against the oracle.

35 elements over three screens, every label an RB_BLENDCONTROL0 value read from
the command stream: 16 bit-set and additive, 19 bit-clear and alpha-over, zero
false positives, zero false negatives.

The control that makes it a decode rather than a coincidence: of every bit of
the first 12 header words, EXACTLY ONE separates those 35 elements without
error. Nothing ties with it. A perfect partition on a small sample is worthless
if half the header partitions equally well, which is the mistake +0x08 = 0x8050
was.

And the pair no confound survives: ptbtn00 = 0x0110, ptbtn00f = 0x0112 -- the
PRESS (A) plate and its own highlight, same screen, differing in exactly this
bit, drawn alpha-over and additive respectively.

Committed alongside is a PREDICTION for GP_OPTIONS, written before the capture
that tests it: a different archive, a different element set, and a MIXED
prediction -- po_menu_eff01/02/03 additive, 592 elements alpha-over. Falsified
if those three draw alpha-over or anything else draws additive. The developer
splash was considered first and rejected as a test: both its elements predict
alpha-over, so it can fail but cannot discriminate.

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

138 lines
6.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 candidates(builds, pak="GP_TITLE"):
"""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, "--", pak] + [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(",")]
pak = sys.argv[sys.argv.index("--pak") + 1] if "--pak" in sys.argv else "GP_TITLE"
cands = candidates(builds, pak)
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()