Files
Sylpheed/tools/port/verify-capture
Sylpheed port agent e5be03709a port: pin formats-pin-2026-08-29c -- the knob I tested last iteration was retired
I tested the wrong switch. SYLPHEED_KF_TIME_SHIFT is a superseded partial fix: it
got the association right but LEFT POSE 0 UNTIMED, which is exactly why the
untimed keyframe appeared to move from last to first. The real correction is the
DEFAULT in the tagged crate, with the old reading behind SYLPHEED_KF_TIME_LEGACY.
So last iteration's five rows measured a mismatch against a knob nobody should
use -- I suspected they were not decisive, I did not suspect the knob was retired.

THE CONSEQUENCE IS MUCH SMALLER THAN I BUDGETED. A placement group is an 8-byte
header then frames x {u32 time; 36-byte pose}, so pose 0's time is the group's
lead-in word and every pose is timed. Measured on the re-export: 866 keyframes,
0 untimed. `pose_at`'s "the final keyframe carries no t, so give it a synthetic
time" premise does not invert, it DISAPPEARS -- dead code rather than wrong code,
which is why nothing needed re-deriving. And the leaf now reads t=0 x=-639,
t=150 x=-39, t=540 x=1521, giving x=781 at t=355: the Decoder's predicted
top-left, and the 1300 px discrepancy is gone.

Pinned by tag, which is what MISSION section 2's tagging rule is for. BLOCKED was
wrong in both directions -- "cannot be taken yet" AND "only when that branch lands
on main". It arrives when the tag is pinned.

COST STATED: sylpheed-cli builds from the workspace crate, so until this reaches
main the exporter and the reference renderer read different decoders and
verify-screen compares two eras. verify-capture is unaffected -- it compares
against oracle captures and never touches the CLI. Revert to the path dependency
when the tag is an ancestor of main.

Oracle: publisher_logo 1.00% -> 0.75%, developer_logos 0.39% -> 0.33%, and
extras' differing region COLLAPSING from 736x525 to 398x295 at the sweep position
-- the residual localised onto the one element still in question. title unchanged
at 1.82%, now posed at t=355, the Decoder's FITTED sweep time. t=390 measures
1.65% and picking it would be fitting the pose to the score.

REFUTED, MINE: "ptlogo_eff2 is the single drawn element at a scale that is not a
whole multiple of 100%". That census was parents-only; the 45 leaves hold
thirteen distinct non-whole-multiple scales and 125% is among the rarest at two.
The claim's real content was "the only one the port draws" -- about my element
set, not the disc.

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

167 lines
8.7 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=355 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. The Decoder fitted t=355 from the
# game's own composed alpha -- per-draw vertex colours 195 and 182 against the
# two leaf ramps -- and the same t then predicted the quad centres to ~11 px.
#
# ⚠️ 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:t355"
"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" = t355 ]; then
godot --path port --resolution 1280x720 -- "--screen=$name" --time=5.9167 \
"--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=355, the Decoder's fitted sweep time" ;;
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"