menu_blend_capture.sh counted two DOWNs to reach EXTRAS, which is wrong twice over -- EXTRAS is the fifth item, and on 2026-08-31 four DOWNs landed on OPTIONS because one press was dropped. It now presses until the cursor stops moving, which needs no item count and no row calibration. Its title deadline follows the same change as title_blend_capture.sh, 1200 s not 420. ui_blend_map.py takes a comma-separated build list, because the live title is TWO builds composited -- 4 draws the art, 2 draws the PRESS (A) plate -- and a one-build size table cannot name the elements of a title capture. frame_alpha_census takes its builds from argv for the same reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
122 lines
5.3 KiB
Python
Executable File
122 lines
5.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 sprite_sizes(builds):
|
|
"""name -> (w, h), straight off the disc via the formats crate.
|
|
|
|
`builds` is a list because the live title is TWO builds composited -- 4 draws
|
|
the art and 2 draws the `PRESS (A)` plate over it -- so a capture of the
|
|
title contains elements from both and a one-build size table cannot name
|
|
them all.
|
|
"""
|
|
out = subprocess.run(
|
|
["cargo", "run", "--release", "-q", "-p", "sylpheed-formats",
|
|
"--example", "frame_alpha_census", "--"] + [str(b) for b in builds],
|
|
capture_output=True, text=True, cwd="/work",
|
|
env={**__import__("os").environ, "SYLPHEED_DISC": "/disc"}).stdout
|
|
sizes = {}
|
|
for line in out.splitlines():
|
|
m = re.match(r"(\S+\.t32)\s+(\d+)x(\d+)", line)
|
|
if m:
|
|
sizes[m.group(1)] = (int(m.group(2)), int(m.group(3)))
|
|
return sizes
|
|
|
|
|
|
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(",")]
|
|
sizes = sprite_sizes(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)
|
|
# 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()
|