Files
Sylpheed/tools/port/verify-capture
Sylpheed port agent 4e249d9d63 port: the capture has NEW GAME focused, and my harness had been rendering no focus
The Decoder attached an honest caveat to its reproduction of the tone-curve
refutation -- its bins included the focus-state mismatch, so it was "not a clean
second opinion". Removing that caveat found a defect of mine.

IDENTIFYING THE FOCUSED BUTTON, with a known-answer control. Walking focus
through all five buttons and comparing each against a capture:

                    vs -options-focused    vs live-main-menu
  ptbtn01 NEW GAME        6351                   531
  ptbtn02 LOAD GAME       7087                  7094
  ptbtn03 TUTORIAL        6230                  6237
  ptbtn04 OPTIONS         1292                  6364
  ptbtn05 EXTRAS          6073                  6080

The control picks OPTIONS by 4.7x -- the answer its own filename gives -- so the
test is trustworthy, and live-main-menu.png has NEW GAME focused by 11.5x. That
is what the port already focuses. Corroboration, NOT a decode: Q5's instability
stands (four boots gave TUTORIAL, TUTORIAL, NEW GAME, NEW GAME) and this
identifies one frame rather than a rule. Recorded in the authored entry.

THE DEFECT: `verify-capture` rendered menus with `--screen=`, which draws no
focus record, so it had been comparing main_menu to the oracle in a state the
oracle was never in.

  main_menu  0.25% differing, RMSE 14.79  ->  0.06%, RMSE 13.21
  extras     0.46%, RMSE 15.29            ->  0.20%, RMSE 13.38

A 4x improvement that was entirely my harness posing the port wrong rather than
the port drawing wrong. The first run of a new correctness harness reported a
discrepancy and three quarters of it was the harness.

AND THE DECODER'S CAVEAT RESOLVES TO NOTHING, measured rather than assumed:
re-deriving the transfer curve on the correctly-posed pair gives 1.20 / 1.26 /
1.18 / 1.10 / 1.03 / 0.94 at render 8..48, unchanged from the mismatched run. Its
reproduction stands as a second opinion after all.

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

153 lines
7.8 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"
"title:$CAPS/live-title-build4-no-plate.png:screen"
"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"
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="ptloop sweeps never stop -- one moment vs another" ;;
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"