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
138 lines
6.3 KiB
Python
Executable File
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()
|