port: generalise the frame finding, refute my own premultiplied-alpha hypothesis

tools/port/element-residual turns last iteration's one-off suppression into a tool:
pose a screen as verify-capture does, shadow each sprite with a transparent PNG,
rank elements by residual on the pixels they actually paint. Two controls, both
mandatory -- the metric's zero on identity, and a mod that shadows nothing must
move zero pixels, or a footprint is the harness rather than the element.

It generalises. Frames rank 1 and 2 on both screens with captures: ptframe1 9.47x
and ptframe2 5.46x on main_menu, ptframe3 14.23x and ptframe4 10.46x on extras,
all four body-dominated and all four negative. The sign splits, so it is not global
darkness: ptmsg +5.02, ptmsg2 +8.83, pttitle +7.88 and every button positive.

Refuted my own next hypothesis. Premultiplied alpha decoded as straight alpha
predicts error proportional to partial-alpha fraction; the frames are 6.7-7.3 %
partial and the most darkened, while pteff10 is 100 % partial and nearly exact.

What the frames are: neither has a single fully-opaque pixel, against ptbase's
99.1 %. For a wholly semi-transparent overlay the blend equation decides the
result, and alpha-over's shortfall against a background-scaling blend is a*bg. So
the shortfall should track the BACKGROUND, not the frame's own contribution.
Measured on render, frame-suppressed render and capture, needing no placement:
ptframe1 +0.772 vs +0.244, ptframe3 +0.797 vs +0.237.

Not adopting anything. The Decoder has established no blend mode is on the disc
for .t32 and both frames are kind 0, declared identically to elements drawn almost
exactly right, so any blend I pick is authored and that is not mine to take alone.
Recorded as an extension of their negative, not a challenge: the behaviour is real
and replicated, so if nothing in the data selects it, something in the code does --
the draw path they named as untaken.

BLOCKED.md updated: the ask is answered, as a negative.
This commit is contained in:
Sylpheed port agent
2026-08-31 05:18:37 +00:00
parent cce31cb592
commit b6a4a079f9
3 changed files with 243 additions and 2 deletions

144
tools/port/element-residual Executable file
View File

@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Which ELEMENT carries the disagreement with the capture? Rank them by suppression.
tools/port/element-residual [screen] # default main_menu
`edge-residual-map` gives hot COORDINATES, and turning those into elements needs
the design-space -> capture transform, which is a convention I would have to
assume. This needs no transform: the port has a mod tree, so shadow an element's
sprite with a transparent PNG, render, and diff the port's OWN two renders. The
pixels that change ARE the element, already in the comparison frame.
Reports, per element, on the pixels it actually paints:
* mean |residual| against the capture, after ONE global tone LUT
* the SIGN -- is the port drawing this element too dark or too bright
* edge versus flat -- an outline problem or a body problem
⚠️ SUPPRESSION IS BY SPRITE PATH, so elements sharing a sprite are suppressed
together and are reported as one row. `ptloop01` draws `pteff03.png`; the id and
the file are not the same thing.
EXIT 0 the report is trustworthy, 2 a control failed. No 1: this ranks, it does
not judge. A brightness difference here is NOT licence to brighten the element --
blend mode is undecoded (`screen.rs`), and tuning until the two agree is exactly
what the mission forbids.
"""
import json, os, subprocess, sys, tempfile
CAPS = "docs/re/captures/title-builds"
POSE = { # same poses as verify-capture
"main_menu": (f"{CAPS}/live-main-menu.png", ["--menu=main_menu"]),
"extras": (f"{CAPS}/live-extras.png", ["--menu=extras"]),
"main_menu_options": (f"{CAPS}/live-main-menu-options-focused.png",
["--menu=main_menu_options", "--focus=ptbtn04"]),
}
SCREEN = sys.argv[1] if len(sys.argv) > 1 else "main_menu"
if SCREEN not in POSE:
print(f" 🔴 no pose for {SCREEN}; known: {', '.join(POSE)}"); sys.exit(2)
CAP, ARGS = POSE[SCREEN]
W, H = 1279, 675
BASE = ["--loop-phase=0", "--leaf-time=0", "--script=wait"]
tmp = tempfile.mkdtemp()
def render(png, mods=None):
env = dict(os.environ)
if mods: env["SYLPHEED_MODS"] = mods
else: env.pop("SYLPHEED_MODS", None)
r = subprocess.run(["xvfb-run", "-a", "timeout", "300", "godot", "--path", "port",
"--"] + BASE + ARGS + [f"--capture={png}"],
env=env, capture_output=True, text=True)
return r.stdout + r.stderr
def gray(png, out):
subprocess.run(["convert", png, "-crop", f"{W}x{H}+0+0", "+repage",
"-colorspace", "Gray", "-depth", "8", "gray:" + out], check=True)
return open(out, "rb").read()
sd = json.load(open(f"export/screens/{'title'}/{SCREEN}.json")) if os.path.exists(
f"export/screens/title/{SCREEN}.json") else None
if sd is None:
for root, _, files in os.walk("export/screens"):
if f"{SCREEN}.json" in files:
sd = json.load(open(os.path.join(root, f"{SCREEN}.json"))); break
sprites = {}
for e in sd["elements"]:
s = e.get("sprite", "")
if s: sprites.setdefault(s, []).append(e["id"])
render(f"{tmp}/base.png")
base = gray(f"{tmp}/base.png", f"{tmp}/base.gray")
cap = gray(CAP, f"{tmp}/cap.gray")
# CONTROL 1 -- the metric's own zero. The render against ITSELF must be exactly 0.
tot = [0] * 256; cnt = [0] * 256
for i in range(len(base)): tot[base[i]] += base[i]; cnt[base[i]] += 1
idlut = [(tot[v] // cnt[v]) if cnt[v] else v for v in range(256)]
z = max(abs(idlut[base[i]] - base[i]) for i in range(0, len(base), 97))
# CONTROL 2 -- a mod that shadows NOTHING must move no pixels, or a footprint
# below is the harness rather than the element.
noop = f"{tmp}/noop"; os.makedirs(noop + "/sprites/title", exist_ok=True)
subprocess.run(["convert", "-size", "8x8", "xc:red", f"{noop}/sprites/title/zzz-not-an-asset.png"],
check=True)
render(f"{tmp}/noop.png", noop)
nb = gray(f"{tmp}/noop.png", f"{tmp}/noop.gray")
moved = sum(1 for i in range(len(base)) if base[i] != nb[i])
print(f" control -- metric zero on identity : {z} (must be 0)")
print(f" control -- mod shadowing nothing : {moved} px moved (must be 0)")
if z != 0 or moved != 0:
print("\n 🔴 CONTROL FAILED. Every row below would be unattributable. Suppressed.")
sys.exit(2)
print(" ✅ controls pass\n")
tot = [0] * 256; cnt = [0] * 256
for i in range(len(base)): tot[base[i]] += cap[i]; cnt[base[i]] += 1
lut = [(tot[v] // cnt[v]) if cnt[v] else v for v in range(256)]
resid = [abs(lut[base[i]] - cap[i]) for i in range(len(base))]
N = len(base); frame_mean = sum(resid) / N
def isedge(i):
x, y = i % W, i // W
if x < 1 or y < 1 or x >= W - 1 or y >= H - 1: return False
return abs(base[i + 1] - base[i - 1]) + abs(base[i + W] - base[i - W]) >= 12
rows = []
for rel, ids in sprites.items():
d = f"{tmp}/m_{len(rows)}"; os.makedirs(os.path.dirname(f"{d}/{rel}"), exist_ok=True)
src = f"export/{rel}"
if not os.path.exists(src): continue
dim = subprocess.run(["identify", "-format", "%wx%h", src],
capture_output=True, text=True).stdout
subprocess.run(["convert", "-size", dim, "xc:none", f"PNG32:{d}/{rel}"], check=True)
log = render(f"{tmp}/o.png", d)
if "mod: " + rel not in log:
print(f" ⚠️ {rel}: the override was never read -- skipped rather than "
f"reported as an empty footprint"); continue
o = gray(f"{tmp}/o.png", f"{tmp}/o.gray")
m = [i for i in range(N) if abs(base[i] - o[i]) > 2]
if not m:
rows.append((",".join(ids), rel, 0, 0.0, 0.0, 0.0, 0.0)); continue
mi = sum(resid[i] for i in m) / len(m)
sg = sum(lut[base[i]] - cap[i] for i in m) / len(m)
ed = [resid[i] for i in m if isedge(i)]; fl = [resid[i] for i in m if not isedge(i)]
rows.append((",".join(ids), rel, len(m), mi,
sum(ed) / len(ed) if ed else 0.0, sum(fl) / len(fl) if fl else 0.0, sg))
print(f"{SCREEN}: frame mean |resid| {frame_mean:.2f}\n")
print(f" {'element(s)':<22} {'foot %':>7} {'|resid|':>8} {'xmean':>6} "
f"{'edge':>7} {'flat':>7} {'signed':>8}")
for ids, rel, n, mi, ed, fl, sg in sorted(rows, key=lambda r: -r[3]):
if n == 0:
print(f" {ids:<22} {'0.00':>7} {'--':>8} {'--':>6} {'--':>7} {'--':>7} "
f"{'--':>8} paints nothing at this pose")
continue
flag = " <- BODY" if fl > ed else ""
print(f" {ids:<22} {100*n/N:7.2f} {mi:8.2f} {mi/frame_mean:6.2f} "
f"{ed:7.2f} {fl:7.2f} {sg:+8.2f}{flag}")
print("\n signed = render - capture after the LUT; NEGATIVE means the port draws it")
print(" DARKER than the game. 'BODY' marks flat residual above edge residual --")
print(" an intensity difference rather than an outline one.")
print(" ⚠️ This is not licence to brighten anything: blend mode is undecoded.")