Files
Sylpheed/tools/port/verify-capture
Sylpheed port agent 16ab24480d port: the 11.5 px was the fit's resolution -- and the lesson inverts
The Decoder closed it by ADDING OBSERVABLES, not by tuning. The vertex buffer
carries positions and colours at the same instant, so all four quantities must
agree on one t: quad A x solves to 357.88 and quad B x to 357.58, both +/-0.12
units, against 355.75 +/-1.54 and 354.09 +/-1.89 from the alphas. Alpha moves
only 0.27-0.33 levels per unit, so one byte of quantisation is worth 1.5-1.9
units -- 6-8 px of sweep at 4 px/unit. That is the whole of the 11.5 px. At
t=357.7 the centres land within 0.70 px and both alphas inside one level.

THE LESSON IS THE EARLIER ONE INVERTED AND IT IS THE HALF WORTH KEEPING.
Checking a wrong rule against alpha made it look confirmed; here the same
insensitivity MANUFACTURED a residual that did not exist. An insensitive quantity
does not merely fail to falsify -- it invents error. Solve on the fastest-moving
field, check the slow one, never the reverse. I was already looking for a pivot
rule to explain 11.5 px when they wrote; there was nothing to find.

REFUTATION ATTEMPT, survived with a nuance: they state the leaf pivot is (200,90)
on a 399x180 sprite, "the pivot is the centre, so rotation displaces it by
nothing". Checked against my export -- pivot [200,90], sprite 399x180, true
centre 199.5,90. It survives, but the sprite is ODD-WIDTH so the pivot is the
centre to within half a pixel rather than exactly. No consequence against their
0.70 px agreement; worth stating because "displaces it by nothing" is the kind of
sentence that later gets leaned on for a sub-pixel claim.

verify-capture now poses the title at t=357.7 rather than 355: RMSE 21.07 ->
20.92, differing 1.82% -> 1.81%. Marginal, and it is the right pose for a stated
reason rather than a better number.

AND ptlogo_eff2 IS WITHHELD FOR A BETTER REASON THAN MINE. I had it on caution
about untested generalisation; the Decoder points out it is on title_jp and
MISSION section 7 scopes out "localisation beyond English", so it is not a
question this port has to answer and the parked Japanese capture does not need
reviving for it. authored/rendering.json now gives scope first and undecidability
second. Widening scope to close a residual would have been the wrong trade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
2026-08-29 18:25:04 +00:00

178 lines
9.3 KiB
Bash
Executable File

#!/usr/bin/env bash
# Diff the port's render against a CAPTURE OF THE REAL GAME.
#
# tools/port/verify-capture main_menu
# tools/port/verify-capture # every screen with a capture
#
# THIS IS THE CORRECTNESS CHECK. `verify-screen` is the consistency one, and its
# own header has pointed at this file since P1 -- `tools/verify-capture` -- while
# this file did not exist. The port has had a harness comparing two renderers
# that share its assumptions, and none comparing it to the game.
#
# ⚠️ That gap is not academic. `docs/re/captures/ORACLE-CAPTURES.md`: two
# renderers agreeing proves nothing, and this corpus has been bitten three times
# -- the dropped `pteff05` background, the scale-0 rect, and `rest()`. Every one
# was invisible to a render-vs-render diff and obvious against a capture.
#
# WHAT IT CAN CONCLUDE, and what it cannot:
#
# * ✅ STRUCTURE. Something drawn that should not be, or missing that should be,
# shows as a large connected region of difference. That is the failure mode
# the three above were, and it is what this tool is for.
# * 🔴 NOT a pixel score. The captures are NOT gamma-neutral:
# `capture ~= 255*(render/255)^g` with g ~ 1.34-1.49, and that ramp is THE
# GAME'S, not the capture path's (`docs/re/structures/ui-render-tone-curve.md`).
# So RMSE has a floor and driving it lower is fitting the ramp. This reports
# the raw difference AND the gamma-compensated one, and neither is a target.
# * ⚠️ A capture is ONE MOMENT. Several screens are still animating -- the
# title's two `ptloop` sweeps never stop -- and the focused button in a
# capture may not be the one the port focuses. Differences confined to a
# button or a moving element are expected; say which before calling anything.
#
# Geometry needs no correction: the corpus cross-correlated a render against
# `live-main-menu.png` over +/-6 px and the best alignment is exactly (0,0) at
# 0.9466. The captures are a 1279x675 top-left crop of the 1280x720 guest
# surface, so the render is cropped to match and nothing is scaled.
set -euo pipefail
cd "${PROJECT_DIR:-/work}"
export DISPLAY="${DISPLAY:-:97}"
OUT="${OUT:-$(mktemp -d)}"; mkdir -p "$OUT"
CAPS=docs/re/captures/title-builds
# screen : capture : how to pose it
# 🔴 A MENU CAPTURE HAS A BUTTON FOCUSED, AND THE FIRST VERSION OF THIS TOOL
# RENDERED WITH NONE. `--screen=` draws no focus record at all, so `main_menu`
# was being compared to the oracle in a state the oracle was never in: 2 159
# differing pixels, of which 74 % sat inside the focus signature. Rendered with
# focus it is 531 -- 0.06 % of the frame, a 4x improvement that was entirely my
# harness posing the port wrong.
#
# `--menu=` applies `authored/flow.json`'s initial focus and `--script=wait`
# shoots one settled frame and exits.
MAP=(
"main_menu:$CAPS/live-main-menu.png:menu"
"extras:$CAPS/live-extras.png:menu"
# ⚠️ THE TITLE IS POSED AT t=357.7 UNITS, NOT AT ITS SETTLE, and the time is
# MEASURED rather than chosen. The two `ptloop` sweeps are a continuous
# animation whose leaf group ends at t=600 with the quads parked off-screen at
# x=1521, so posing at the settle compares a still frame against a capture
# taken mid-sweep and simply omits them.
#
# t=357.7 is the Decoder's REFINED fit, and the refinement is worth knowing.
# Its first value, 355, came from the two per-draw alpha bytes alone and left
# an 11.5 px residual that looked like a pivot problem. Solving the same
# instant on the vertex POSITIONS instead gives t=357.88 and 357.58 to
# +/-0.12 units, against +/-1.54 and +/-1.89 from the alphas -- alpha moves
# only 0.27-0.33 levels per unit, so one byte of quantisation is worth 6-8 px
# of sweep. At 357.7 the centres land within 0.70 px and both alphas inside one
# level. THE 11.5 px WAS THE FIT'S RESOLUTION, NOT GEOMETRY.
#
# ⚠️ And there is no pivot correction: the leaf pivot is (200, 90) on a 399x180
# sprite, so it is the centre to within half a pixel -- checked here against
# the export rather than taken.
#
# ⚠️ It is NOT the time that minimises the difference: t=390 measures 1.65 %
# against 1.82 % here. Picking that one would be fitting the pose to the
# score, which is the thing this harness exists not to do.
"title:$CAPS/live-title-build4-no-plate.png:t357"
"publisher_logo:$CAPS/live-splash-publisher.png:screen"
"developer_logos:$CAPS/live-splash-developer.png:screen"
)
CURVE=""
if [ "${1:-}" = "--curve" ]; then CURVE=1; shift; fi
want=("$@")
echo "RMSE is reported and is NOT a target: the capture carries the game's own"
echo "tone ramp, so it has a floor. What finds a real defect is the DIFFERING"
echo "REGION -- a missing or misplaced element is a large connected blob."
echo
printf '%-17s %-9s %-7s %-22s %s\n' screen raw-rmse diff region note
for row in "${MAP[@]}"; do
IFS=: read -r name cap pose <<<"$row"
if [ ${#want[@]} -gt 0 ] && ! printf '%s\n' "${want[@]}" | grep -qx "$name"; then continue; fi
[ -f "$cap" ] || { printf '%-17s %s\n' "$name" "no capture"; continue; }
if [ "$pose" = menu ]; then
godot --path port --resolution 1280x720 -- "--menu=$name" --script=wait \
"--shots=$OUT/$name" >"$OUT/$name.log" 2>&1 || true
[ -f "$OUT/${name}_00_start.png" ] && cp "$OUT/${name}_00_start.png" "$OUT/$name.render.png"
elif [ "$pose" = t357 ]; then
godot --path port --resolution 1280x720 -- "--screen=$name" --time=5.9617 \
"--capture=$OUT/$name.render.png" >"$OUT/$name.log" 2>&1 || true
else
godot --path port --resolution 1280x720 -- "--screen=$name" --time=99 \
"--capture=$OUT/$name.render.png" >"$OUT/$name.log" 2>&1 || true
fi
[ -f "$OUT/$name.render.png" ] || { printf '%-17s %s\n' "$name" "render failed"; continue; }
# Crop the render to the capture's frame. The capture is the crop, not a scale.
convert "$OUT/$name.render.png" -crop 1279x675+0+0 +repage "$OUT/$name.crop.png"
raw=$(convert "$OUT/$name.crop.png" "$cap" -metric RMSE -compare -format "%[distortion]" info: 2>&1 | tail -1)
raw=$(python3 -c "print('%.2f' % (float('$raw')*255))" 2>/dev/null || echo "?")
note=""
case "$name" in
main_menu|extras) note="rendered with authored initial focus" ;;
title) note="posed at t=357.7, the Decoder's refined sweep fit" ;;
esac
# Where the difference lives. This comes FIRST because it is what the gamma
# sweep has to be protected from.
convert "$OUT/$name.crop.png" "$cap" -compose difference -composite \
-colorspace Gray -threshold 25% "$OUT/$name.mask.png"
# THE TONE RELATIONSHIP IS REPORTED AS A CURVE, NOT AS A BEST EXPONENT, and
# two earlier versions of this tool reported an exponent and were wrong twice.
#
# `docs/re/structures/ui-render-tone-curve.md` models it as
# `capture = 255*(render/255)^g`, g ~ 1.34-1.49, measured on dark flat patches
# and explicitly not constrained above render ~60. Binning every structurally
# matched pixel of `main_menu` by render level and averaging the capture gives:
#
# render capture implied g pixels
# 8 4.04 1.20 183 026
# 16 7.89 1.26 227 630
# 24 15.57 1.18 100 945
# 32 26.15 1.10 87 474
# 40 38.07 1.03 86 094
# 48 53.96 0.93 85 255
# 64 78.52 0.85 6 509
# 96 130.44 0.69 1 682
#
# ⚠️ **The implied exponent is not constant. It falls monotonically and crosses
# 1.0 near render ~44**, so the capture is DARKER than the render in the darks
# and BRIGHTER in the midtones. A single power law cannot express that, which
# is exactly why a whole-frame fit returns 1.00: the two halves cancel. The
# corpus's reach -- "nothing constrains midtones or highlights" -- was a real
# limit and this is what lies past it.
#
# So: no best-g is printed. The table above is the instrument that can actually
# be argued with; `tools/port/verify-capture --curve SCREEN` regenerates it.
frac=$(convert "$OUT/$name.mask.png" -format "%[fx:mean*100]" info:)
box=$(convert "$OUT/$name.mask.png" -trim -format "%wx%h%X%Y" info: 2>/dev/null || echo "-")
printf '%-17s %-9s %6.2f%% %-22s %s\n' "$name" "$raw" "$frac" "$box" "$note"
done
if [ -n "$CURVE" ]; then
for row in "${MAP[@]}"; do
IFS=: read -r name cap pose <<<"$row"
if [ ${#want[@]} -gt 0 ] && ! printf '%s\n' "${want[@]}" | grep -qx "$name"; then continue; fi
[ -f "$OUT/$name.mask.png" ] || continue
convert "$OUT/$name.crop.png" -colorspace Gray -depth 8 "gray:$OUT/$name.r.gray"
convert "$cap" -colorspace Gray -depth 8 "gray:$OUT/$name.c.gray"
convert "$OUT/$name.mask.png" -colorspace Gray -depth 8 "gray:$OUT/$name.m.gray"
echo; echo "transfer curve, $name -- structurally matched pixels only"
python3 - "$OUT/$name" <<'PYEOF'
import sys, math
b = sys.argv[1]
r = open(b+".r.gray","rb").read(); c = open(b+".c.gray","rb").read(); m = open(b+".m.gray","rb").read()
n = min(len(r), len(c), len(m)); bins = {}
for i in range(n):
if m[i]: continue
s = bins.setdefault(r[i]//8*8, [0,0]); s[0] += c[i]; s[1] += 1
print(" %-8s %-9s %-9s %s" % ("render","capture","implied g","pixels"))
for k in sorted(bins):
tot, cnt = bins[k]
if cnt < 500 or k < 8: continue
cap = tot/cnt
g = math.log(max(cap,0.5)/255.0)/math.log(k/255.0)
print(" %-8d %-9.2f %-9.2f %d" % (k, cap, g, cnt))
PYEOF
done
fi
echo "artifacts in $OUT"