The Decoder's refined sweep fit had never been testable: verify-capture passed it as a whole-screen --time that pose_at discarded, and asking for it honestly poses past the title's group end. --leaf-time separates the leaf's clock from the screen's. Controls: the renderer is deterministic (3 runs bit-identical) and the sweeps move 0.40% of the frame between phases, so the comparison can see them. Sweeping the full 600-unit span gives a sharp basin at 390-415 units (0.0124%) against 0.2532% at t=357.7 -- 20x. So the title's 0.21% residual is the sweep phase, not structure: at the fitted phase it matches the capture as well as the splashes do. NOT adopted: the port loops the leaf freely and re-posing the harness to the fitted value would be tuning until they match. Filed instead, with the question of whether 357.7 and this are even the same quantity. Also verified last iteration's settle-window change was surgical: only press_start and its twin moved, 14 screens unchanged including title's Decoder-confirmed [160,236]. Settle-window ties exist on 4 screens but all sit under the 30-unit bar, so the arbitrary tie-break never reaches the runtime. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
204 lines
11 KiB
Bash
Executable File
204 lines
11 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
|
|
# 🔴 NO `--time` HERE EITHER, and the row's note used to claim otherwise.
|
|
#
|
|
# It passed `--time=5.9617` (t=357.7 units, the Decoder's refined sweep fit)
|
|
# and that value was NEVER APPLIED: `pose_at` replaced it with the screen's
|
|
# settle instant, t=198, on every run. Every title figure this tool has ever
|
|
# printed -- including the 0.26 % the port has quoted repeatedly -- was
|
|
# measured at the SETTLE, under a note saying t=357.7.
|
|
#
|
|
# Honouring it now makes that visible: t=357.7 is PAST the title's own group,
|
|
# which ends at t=269, so the whole screen poses at its faded-out final
|
|
# keyframes and the disagreement goes to 30.97 %. The instant was only ever
|
|
# meant for the `ptloop` LEAF, which runs to t=600 and is looped separately
|
|
# by `loop_leaf` (authored/rendering.json). Applying it to the whole screen
|
|
# was always wrong; it was harmless only while it was ignored.
|
|
#
|
|
# So: pose at the settle, which is what was actually being measured, and let
|
|
# the leaf loop carry the sweeps' phase.
|
|
godot --path port --resolution 1280x720 -- "--screen=$name" \
|
|
"--capture=$OUT/$name.render.png" >"$OUT/$name.log" 2>&1 || true
|
|
else
|
|
# NO `--time`. It used to pass `--time=99` as an idiom for "settled", and
|
|
# that worked only because `--time` was SILENTLY IGNORED on a screen with a
|
|
# settle window: `pose_at` overwrote the requested instant with
|
|
# `settle_instant` whenever `holding` was true. The tool asked for t=5940
|
|
# units and was handed the settle instant, which is the pose it actually
|
|
# wants -- and the 0.01 % agreements on both splashes were measured through
|
|
# that accident. Now that `--time` is honoured, asking for it explicitly
|
|
# would pose past the end of every group, so the request is simply dropped
|
|
# and the settled pose asked for by omission.
|
|
godot --path port --resolution 1280x720 -- "--screen=$name" \
|
|
"--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="settle t=198; residual is the SWEEP PHASE -- see DECISIONS.md" ;;
|
|
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"
|