re(ui): settle 8AX vs ptbase statically -- the game draws the full-res one

I had parked this as "needs a per-draw capture recording texture base
addresses". It did not. 8AX (1280x720) and ptbase (640x360 at 200%) are
the SAME artwork at two resolutions, which is exactly why comparing
either against a capture is inconclusive -- and why comparing their
DIFFERENCE is not. Compute 8AX - upscale(ptbase), the detail only 8AX
has, and ask whether the capture contains it.

Both candidates are first mapped into the capture's tone domain with the
measured gamma; without that the residual is dominated by the tone
difference and the test is blind.

  main menu  corr +0.0475   controls +0.0032 shift, -0.0075 flip   68% of ceiling
  title      corr +0.0634   controls +0.0095 shift, +0.0086 flip   68% of ceiling

Two independent screens, both at 68% of the theoretical ceiling
(sd of the 8AX-only detail over sd of the capture residual), 7-15x their
matched controls. The controls preserve spatial correlation and destroy
only alignment, so they are what "no signal" looks like.

So the recommendation changes: resolve the name and draw 8AX at 1:1.
Upscaling ptbase 2x is wrong, not merely softer. Still do not draw both --
an opaque layer over an identical one costs fill and hides later changes,
and ptbase's element is the one carrying the keyframes, so a consumer
needs its timing with 8AX's pixels.

Also recorded and withdrawn: a cruder pixel-pair test gave 0.00-0.72 for
upscales, 0.98 native and 1.01 for the capture -- apparently decisive.
Additive noise raises both terms of that ratio equally and drives any
value toward 1; fitting a noise term, both "native + noise" and "bilinear
+ noise" reproduce the observed numbers. The conclusion is right, that
test does not establish it, and it is in REFUTED because the number looks
conclusive and is not.

Not shown: whether ptbase is also drawn underneath. 8AX is ~86% opaque
and carries the same art, so it would hide it either way.
This commit is contained in:
Sylpheed RE agent
2026-08-29 02:40:53 +00:00
parent aaaa08b164
commit 3490fba9e3
6 changed files with 140 additions and 15 deletions

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Does the running game draw the full-res `8AX` or the 640x360 `ptbase` upscaled?
Both carry the SAME artwork at two resolutions, so no pixel comparison of the
backgrounds can separate them. What CAN: the detail 8AX has and an upscale does
not. Compute `8AX - upscale(ptbase)` -- the 8AX-only detail -- and ask whether
the live capture contains it.
Both candidates are first mapped into the capture's tone domain with the
measured gamma (docs/re/structures/ui-render-tone-curve.md); without that, the
capture's residual is dominated by the tone difference and the test is blind.
CONTROLS are the point: the same correlation with the 8AX residual shifted 7 px
and flipped. Those preserve the spatial correlation structure while destroying
the alignment, so they are what "no signal" looks like here.
"""
import sys
import numpy as np
from PIL import Image
CASES = [
("main menu", 1.491, "a715f485_8AX_1280x720.png", "a715f485_ptbase.t32_640x360.png",
"docs/re/captures/title-builds/live-main-menu.png"),
("title", 1.338, "a60fcb85_8AX_1280x720.png", "a60fcb85_ptbase2.t32_640x360.png",
"docs/re/captures/title-builds/live-title-press-a.png"),
]
TEX = sys.argv[1] if len(sys.argv) > 1 else "/tmp/tex"
Y0, Y1, X0, X1 = 40, 300, 20, 600 # background, away from menu text
def main():
for label, gamma, ax_png, pb_png, cap_png in CASES:
ax = np.asarray(Image.open(f"{TEX}/{ax_png}").convert("L")).astype(float)
pb = np.asarray(Image.open(f"{TEX}/{pb_png}").convert("L")
.resize((1280, 720), Image.BILINEAR)).astype(float)
cap = np.asarray(Image.open(cap_png).convert("L")).astype(float)
h, w = cap.shape
ax, pb = ax[:h, :w], pb[:h, :w]
g = lambda a: 255 * np.power(np.clip(a, 0, 255) / 255.0, gamma)
axg, pbg = g(ax), g(pb)
E = (axg - pbg)[Y0:Y1, X0:X1] # detail only 8AX has
C = (cap - pbg)[Y0:Y1, X0:X1] # how the capture departs from the upscale
cc = lambda a, b: float(np.corrcoef(a.ravel(), b.ravel())[0, 1])
ceiling = E.std() / C.std()
r = cc(C, E)
print(f"{label}: corr {r:+.4f} controls {cc(C[:, :-7], E[:, 7:]):+.4f} (shift) "
f"{cc(C, E[::-1]):+.4f} (flip) ceiling {ceiling:.3f} -> {100*r/ceiling:.0f}% of it")
if __name__ == "__main__":
main()