#!/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/port/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. # # 🔴 WHAT THE `diff` COLUMN DOES NOT SAY. It counts pixels surviving # `-threshold 25%` -- differing by more than ~64 levels. That is deliberate: it # detects a missing or MISPLACED element, which is a large connected blob. It is # blind to sub-threshold spatial error -- a one-pixel offset, a soft edge in a # slightly wrong place, an antialiasing difference -- because none of that moves # a pixel 64 levels. # # So `main_menu 0.06%` means NO GROSS DISPLACEMENT. It does NOT mean the # geometry is right, and it has already been read that way by another agent: # `docs/re/structures/title-residual-tone-vs-geometry.md` uses this screen as a # tone-only positive control, citing this number as "geometry is essentially # right". Measured 2026-08-31 against that capture: after fitting a per-level LUT # -- the most general tone model there is -- the remaining residual is 6.94 on # edge pixels against 2.20 on flat ones, a 3.2x concentration. A purely tonal # residual leaves a per-level LUT exactly 0.00 (checked, by construction). The # menu carries spatial error this column cannot see. # # ⚠️ 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 only capture of a MEASURED focus state, and it was unusable until # `--focus=` was made to work on the `--menu` path (it parsed, was stored, and # was overwritten by the authored initial focus on every `_menu_enter`). # # It discriminates: rendering each of the five buttons focused against this # capture gives 0.1355 % for `ptbtn04` and 0.70-0.82 % for the other four. The # port's focus rendering identifies the right button by a factor of five. "main_menu_options:$CAPS/live-main-menu-options-focused.png:focus:ptbtn04" # ⚠️ 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" # The Japanese title at rest, Decoder 310bf86. Settled pose by omission -- # the capture is demonstrated at rest (five frames over 6 s, 0 px change in # the logo block while 5-8 % of the frame moves). # # 🔴 THIS ROW EXISTS BECAUSE SCORING THE WRONG FRAME COST A WRONG CONCLUSION. # `verify-screen` poses at `--pose=rest`, which for this screen lights every # `ptlogo_back2eff*` sparkle at its own peak simultaneously -- `rest` for those # elements IS the peak of a 4-unit flash. That frame is fine for the # consistency check it was built for and must never be scored against a # capture: doing so put the port at r +0.7462 against the reference's +0.8727 # and I wrote up that the port had moved away from the game. Posed as it # SHIPS, the same block scores **+0.9994**. "title_jp:$CAPS/live-title-jp-at-rest.png:settled::1279x675+1+45" # A SECOND title comparison, and the most sensitive row this tool has. # # `live-title-press-a.png` is the title WITH the plate. Posed at t=237 -- inside # the plate's own 8-unit opaque window, t=236-238 -- the port matches it at # **0.00093 %**, two orders below every other row. That makes it the best # regression detector here: anything structural that moves will show. # # ⚠️ The instant is FITTED, not measured: 237 is where this capture's content # places it, found by sweeping. That is legitimate for choosing which frame to # compare against -- every row does it -- but it is not a claim about the game, # and the 0.00093 % is therefore a floor for THIS pose, not a general accuracy. # # It also closes the systematic-error question the leaf sweep left open. Two # independent captures fit at two DIFFERENT phases -- this one at 237, the # no-plate one at ~400 -- and both to 0.01 % or better. A geometry error in how # the sweeps are drawn would leave a floor in both. Neither has one. # 🔴 THIS ROW IS POSED AT THE PLATE'S BLIND PHASE, and it cannot see the plate's # highlight at all. `--loop-phase=0` pins the looping-focus clock, and # `ptbtn00f` -- the plate's own highlight, which the GAME draws ADDITIVE # (docs/re/data/blend-bit-vs-oracle.txt, entry 2) -- contributes EXACTLY 0 px at # phase 0 and 22 000-29 000 px at phases 20..100. Measured 2026-08-31 by # shadowing its sprite and diffing. # # So switching that element to its measured additive blend moved 26 319 px at # phase 20 and reported ZERO here. This row's 0.09 % is real and unaffected; it # simply says nothing about the pulse. A capture at a NON-ZERO loop phase is # what would let this row see it, and none exists -- filed in BLOCKED.md. "title_plate:$CAPS/live-title-press-a.png:plate" # A BANDED row -- the capture is 1279x120, not a full frame, and the harness # could not compare one until now. That was the only reason this capture sat # unused; nothing about it was unusable. # # Its y offset is MEASURED, not guessed: sliding it down the render, the # structural difference is 0.354 % at y=520 against 8.9-9.1 % five pixels # either side and 17-52 % further out. A 25x drop over five pixels. # # ⚠️ Its residual is NOT the port's error. The port reproduces the same band of # `live-title-press-a.png` EXACTLY (0.000 %), and the two captures differ from # each other by 0.301 % -- two thin horizontal strips, 248x5 px and 206x1 px, # the shape of a sub-pixel edge difference rather than a state difference. So # 0.354 % is very nearly the oracle-to-oracle gap and this row's job is to stay # near it, not to reach zero. "title_band:$CAPS/live-attract-title-press-a-band.png:band" "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 # 🔴 THE METRIC'S OWN ZERO, asserted before any row is printed. # # Every number below is "small is good", and this file already says the RMSE has # a floor from the game's tone ramp. What was never established is the floor of # the COMPARISON ITSELF. A control that only bounds error from above cannot tell # an exact instrument from a slightly wrong one -- and slightly wrong is the # failure that passes. The Decoder reached that form of it after their coherence # estimator's positive control read 0.94 for two reasons at once. # # Measured here rather than assumed: a capture against itself, and against a PNG # round-trip of itself, must both be EXACTLY 0. If they are not, the metric has a # bias and no row below means what it says. _ctl="" for row in "${MAP[@]}"; do IFS=: read -r _n _c _rest <<<"$row"; [ -f "$_c" ] && { _ctl="$_c"; break; } done if [ -n "$_ctl" ]; then _rt="${TMPDIR:-/tmp}/verify-capture-rt.png"; convert "$_ctl" -quality 100 "$_rt" for _pair in "$_ctl|$_ctl|identity" "$_ctl|$_rt|PNG round-trip"; do IFS='|' read -r _a _b _lab <<<"$_pair" _d=$(convert "$_a" "$_b" -metric RMSE -compare -format "%[distortion]" info: 2>&1 | tail -1) _v=$(python3 -c "print('%.4f' % (float('$_d')*255))" 2>/dev/null || echo "?") if [ "$_v" = "0.0000" ]; then printf ' metric control, %-16s RMSE %s -- exact\n' "$_lab:" "$_v" else printf ' 🔴 metric control, %-13s RMSE %s -- NOT ZERO. The comparison is\n' "$_lab:" "$_v" echo " biased and every row below is unreadable. Refusing." exit 3 fi done echo fi printf '%-17s %-9s %-7s %-22s %s\n' screen raw-rmse diff region note for row in "${MAP[@]}"; do IFS=: read -r name cap pose forced capcrop <<<"$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" = band ]; then godot --path port --resolution 1280x720 -- --loop-phase=0 --leaf-time=0 "--screen=title" --overlay=press_start \ --time=3.95 "--capture=$OUT/$name.full.png" >"$OUT/$name.log" 2>&1 || true [ -f "$OUT/$name.full.png" ] && convert "$OUT/$name.full.png" \ -crop 1279x120+0+520 +repage "$OUT/$name.render.png" elif [ "$pose" = focus ]; then godot --path port --resolution 1280x720 -- --loop-phase=0 --leaf-time=0 "--menu=main_menu" "--focus=$forced" \ --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" = menu ]; then godot --path port --resolution 1280x720 -- --loop-phase=0 --leaf-time=0 "--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" = plate ]; then godot --path port --resolution 1280x720 -- --loop-phase=0 --leaf-time=0 "--screen=title" --overlay=press_start \ --time=3.95 "--capture=$OUT/$name.render.png" >"$OUT/$name.log" 2>&1 || true 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 -- --loop-phase=0 --leaf-time=0 "--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 -- --loop-phase=0 --leaf-time=0 "--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. if [ "$pose" = band ]; then cp "$OUT/$name.render.png" "$OUT/$name.crop.png" else convert "$OUT/$name.render.png" -crop 1279x675+0+0 +repage "$OUT/$name.crop.png" fi # ⚠️ A FIFTH FIELD, because not every capture is pre-cropped to the game # surface. Every capture in `$CAPS` until now was already 1279x675, so the # render was cropped and the capture used as-is. The JP title capture is a # full 1280x720 DISPLAY frame with the surface at +0+45 -- comparing it whole # would score the port against a 45px shift and report a catastrophe. # # The offset is MEASURED, not inherited from the earlier submenu capture: # row/column profile correlation against the port, with the English pair as a # control, gives (0,0) for the control at r 0.994 and dy=-45 for this frame. if [ -n "$capcrop" ]; then convert "$cap" -crop "$capcrop" +repage "$OUT/$name.cap.png" cap="$OUT/$name.cap.png" fi 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="" # 🔴 EVERY ROW WITH A SWEEPING LEAF CARRIES A CAPTURE-PHASE TERM, AND THIS # TOOL USED TO PRINT THE NUMBER WITHOUT IT. # # `ptloop01`/`ptloop02` free-run on a settled screen -- a settled screen is not # a static screen -- so a capture froze them wherever the shutter fell, and the # render is pinned at `--leaf-time=0` by CONVENTION, not because 0 is the # game's phase. Measured by sweeping the phase against each capture: # # title 5.56 main_menu 3.78 extras 3.73 splashes 0.00 # # Those are RMSE, in this tool's own metric, and larger than most margins # anyone has quoted from these rows. So: usable for REGRESSION at a fixed pin, # run to run; NOT usable as an absolute against anything measured differently. # # ✅ The two splash rows carry no free-running element at all. They are the # only absolutes here that mean what they say. case "$name" in title) note="settle t=198; +/-5.56 capture-phase term -- regression only" ;; title_jp) note="+/-5.6 capture-phase term (same leaves as build 4)" ;; # 🔴 The old note said "rendered with AUTHORED initial focus", and it was # stale twice over. The value became MEASURED on 2026-08-31 (NEW GAME, 2/2 # fresh boots, first entry) -- and the capture's OWN focus state, which had # never been established, is now identified by exclusion: rendering all five # candidates against this capture gives ptbtn01 13.06 and every alternative # 15.96-16.59, ~22 % worse. So the residual below is NOT a focus mismatch. # # ⚠️ It does not re-establish "the menu opens on NEW GAME". Focus persists on # this screen, so a capture of the running menu could show any item; what is # established is that THIS capture shows NEW GAME and the port renders the # same state. main_menu) note="focus ptbtn01 confirmed by exclusion (next best +22%); +/-3.78 capture-phase term" ;; extras) note="rendered with authored initial focus; +/-3.73 capture-phase term" ;; publisher_logo|developer_logos) note="no free-running element -- absolute, means what it says" ;; 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"