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
178 lines
9.3 KiB
Bash
Executable File
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"
|