Three findings, two of them defects in my own checkers. Changing the KIND of quantity answered the P4 fidelity question on the first attempt. Four attempts at sample-exact difference-signal alignment produced four failures and no verdict -- well past the Decoder's rule that two failed attempts at the same measurement are evidence the quantity is wrong, not the parsing. Band energies need no alignment at all: both transcodes match their sources to 0.66 dB worst-case across four bands, while an unrelated movie lands at 19-20 dB. Two populations an order of magnitude apart, so the 1.5 dB tolerance sits between measured values rather than being picked. Asserting in check-all with the known negative on every run, not behind a flag. It also diagnoses the failure it replaced: matching spectra mean same content at same level, so the difference signal's failure is my alignment, now by evidence rather than assumption. The difference path stays report-only. Band agreement cannot tell a faithful transcode from one that kept the spectrum and mangled the waveform -- weaker than P4 wanted, and what I can support. check-claims held 'no loop-point field has been identified' in its register the whole time and matched case-sensitively, so a capital N at the start of a sentence hid a registered dead claim in BLOCKED.md -- the one document whose job is to say what is still open. The correction had reached authored/audio.json and not the blocked list, which is exactly the failure that file's own why warns about. Matching is case-insensitive now and immediately surfaced five more unmarked sites, including a whole DECISIONS section still describing the refuted state. All six fixed: four tokened, two rewritten with the shipped values. Controlled with a planted capitalised revival. And --control caught its own harness: it perturbed only the first occurrence of an anchor, and the Decoder's delivery heading now appears twice, so the check read the untouched duplicate and passed a wrong contract. A perturbation that does not reach every copy makes a check untestable silently. First time a control has failed because of a change in someone else's document rather than my code. Not accepted from the same message: the (A)-skips-a-movie row is NOT stale. It reads (a) ANSWERED, cites Q9, and points at flow.json's skippable: true. Reported back rather than quietly 'fixed' -- marking a live row stale is the error their own message is about. Every asserting check passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
335 lines
18 KiB
Bash
Executable File
335 lines
18 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Diff Godot's drawing of an exported screen against `sylpheed-cli screen
|
|
# render` of the same build.
|
|
#
|
|
# WHAT THIS IS, AND WHAT IT IS NOT.
|
|
#
|
|
# It is a CONSISTENCY check between two decoders that share their assumptions,
|
|
# and a REGRESSION detector: "did anything move since last commit". It is NOT a
|
|
# correctness check and agreement here is NOT evidence of correctness.
|
|
#
|
|
# `sylpheed-cli` is not the oracle. The oracle is the Xenia Canary capture and
|
|
# the game. Reborn is an explorer and extraction CLI for verifying decodes, and
|
|
# it can be wrong -- this corpus has been bitten three times by both renderers
|
|
# agreeing and both being wrong: pteff05 (the menu background, missing from
|
|
# both), scale-0, and rest(). Each time the capture caught it and neither
|
|
# renderer could have.
|
|
#
|
|
# 🔴 AND ITS FRAMES MUST NEVER BE SCORED AGAINST A CAPTURE. This script poses
|
|
# `--pose=rest`, deliberately -- both renderers read `rest` through the same
|
|
# decoder, which is what makes it a test of the PORT against the REFERENCE. It
|
|
# is NOT the pose the port ships, and on some screens the two are very far
|
|
# apart: `rest` for each `ptlogo_back2eff*` sparkle is the peak of its own
|
|
# 4-unit flash, so `--pose=rest` lights all of them at once, a frame the game
|
|
# never shows.
|
|
#
|
|
# I scored this script's `title_jp` frame against the oracle capture and
|
|
# concluded the port had drifted away from the game -- r +0.7462 against the
|
|
# reference's +0.8727. Posed as it SHIPS, the same block scores **+0.9994**.
|
|
# The conclusion was an artefact of the pose, and it was written up as a finding.
|
|
# Correctness questions go to `tools/port/verify-capture`, which poses as shipped.
|
|
#
|
|
# So: a DIFFERS row means "we moved apart, go find out which of us moved". It
|
|
# does not mean the port is wrong. Where a capture and this tool disagree, the
|
|
# capture wins. Use `tools/port/verify-capture` for the correctness question.
|
|
#
|
|
# tools/port/verify-screen # every screen in the manifest
|
|
# tools/port/verify-screen main_menu title # named screens
|
|
#
|
|
# Writes <screen>.godot.png, <screen>.ref.png and <screen>.diff.png into
|
|
# $OUT (default: a directory under /tmp) and prints, per screen, the largest
|
|
# per-channel difference anywhere in the frame.
|
|
#
|
|
# The two renderers are held to the same inputs on purpose:
|
|
#
|
|
# * the COMPARISON CLI is the one built by `build-reference-cli`, from the same
|
|
# `sylpheed-formats` revision the exporter is pinned to. /reborn's own
|
|
# target/ is a live mount of the other agent's checkout and moves mid-run; a
|
|
# pixel disagreement against a moving decoder proves nothing.
|
|
# * `--black` because Godot clears to black and the screen carries its own
|
|
# background. The CLI's default dim slate stands in for a 3D scene behind an
|
|
# in-mission screen, which is not this screen.
|
|
#
|
|
# ⚠️ THAT PREMISE IS DECLARED ON 12 OF 16 SCREENS AND ASSUMED ON 4. Audited
|
|
# 2026-08-30: a screen "carries its own background" when it declares a
|
|
# full-screen untextured primitive at `t=0` with `fade_argb 0xff000000` --
|
|
# opaque black. Twelve do (`pteff00`, `palogo_eff0`, `pgloading_eff00`).
|
|
# Four do NOT. I first called those four "composited rather than standalone" [refuted];
|
|
# that reading is REFUTED disc-wide (see below) and what they share is only
|
|
# that they do not begin from black:
|
|
#
|
|
# press_start / press_start_jp -- one element, the plate, drawn OVER the
|
|
# title; its own `name_why` says so. The game never shows it on black.
|
|
# build_00 / build_01 -- loading variants carrying the `pgloading_*` set
|
|
# WITHOUT the `pgloading_eff00` backdrop that build_12/15 declare.
|
|
#
|
|
# ✅ Harmless HERE, because both renderers are given `--black` and the
|
|
# assumption cancels in a consistency check. It would NOT be harmless in an
|
|
# oracle comparison, and `verify-capture` already avoids it: the plate is
|
|
# scored as `--screen=title --overlay=press_start`, over the title, not on
|
|
# black.
|
|
#
|
|
# 📌 The audit is a rule worth having WITHIN THIS ARCHIVE, and its first
|
|
# reading was wrong. I called it "standalone versus composited"; the Decoder
|
|
# ran it disc-wide and it does not carry: **76 of 965 builds, 7.9 %**, with
|
|
# `GP_HANGAR_ARSENAL` **0 of 390**, `GP_OPTIONS` 0/14, `GP_PAUSE_MENU` 0/6 --
|
|
# screens a player plainly sees AS screens. Read as "composited", the rule
|
|
# makes 92 % of the game composited, which the archives do not support.
|
|
#
|
|
# ✅ What survives is narrower: it separates **screens that begin from black**
|
|
# from everything else. The negative class is heterogeneous -- a pause menu
|
|
# over gameplay, a hangar over a 3D scene and a plate over a title are not the
|
|
# same kind of thing -- which is exactly what a two-way rule cannot express.
|
|
#
|
|
# ⚠️ Within `GP_TITLE` it is exact and independently reproduced from the disc
|
|
# (12/4, the four being entries 0-3). That is the only archive it is claimed
|
|
# for. Do NOT carry it into `GP_READY_ROOM`, `GP_HANGAR_ARSENAL`,
|
|
# `GP_MISSION_SELECT` or `GP_OPTIONS`: in three of them it classifies every
|
|
# screen alike, so it would look like a clean answer and say nothing.
|
|
# * `--primitives --animated` because those are what make the CLI draw the same
|
|
# element set. `--focus` is NOT passed: nothing is focused at rest (HANDOFF
|
|
# Q5 measured initial focus as unstable boot to boot, so choosing one is
|
|
# P5's decision).
|
|
# * `--pose=rest` on the Godot side. Since P2 the port's DEFAULT is to play the
|
|
# timeline, and the settled timeline is deliberately NOT what `rest` says --
|
|
# the export's `rest` misses `ptframe1`/`ptframe2` on the main menu, and the
|
|
# running game shows them (docs/DECISIONS.md). Both renderers read `rest`
|
|
# through the same decoder, so asking for it here keeps this a test of the
|
|
# PORT against the reference. It is not the test of whether `rest` is right;
|
|
# that one is the oracle capture, and the port already departs from it.
|
|
#
|
|
# A difference here is not automatically the port's fault, and it is not
|
|
# automatically a fault at all. Say which renderer moved and why -- do not tune
|
|
# until they match.
|
|
set -euo pipefail
|
|
cd "${PROJECT_DIR:-/work}"
|
|
|
|
# THE REFERENCE IS THE WORKSPACE'S OWN `sylpheed-cli`, and that is a change.
|
|
#
|
|
# It used to be a binary built per PINNED REVISION into `reference-cli/<rev>/`,
|
|
# because `sylpheed-formats` was a git dependency and /reborn's target/ was a
|
|
# live mount of the other agent's checkout that moved mid-run. A pixel
|
|
# disagreement against a moving decoder has a free variable in it.
|
|
#
|
|
# The monorepo merge (`65cefa7`) removed that problem by construction:
|
|
# `crates/sylpheed-export/Cargo.toml` now says
|
|
# `sylpheed-formats = { path = "../sylpheed-formats" }`, so the exporter, this
|
|
# reference and the port all read ONE decoder -- the working tree's.
|
|
#
|
|
# 🔴 It also silently broke the old machinery, and this script did not notice.
|
|
# `build-reference-cli` greps Cargo.toml for `Syplheed-Reborn.git", rev = "..."`;
|
|
# that line no longer exists, so the script exits 1 and the binary at
|
|
# `reference-cli/sylpheed-cli` is whatever was last built before the merge --
|
|
# here, three hours older than the sources and from a revision nothing points
|
|
# at any more. Running the diff against it would have compared the port to a
|
|
# decoder from another era and called the result a regression check. This
|
|
# corpus has already been bitten by a stale reference renderer three times.
|
|
#
|
|
# So: build it from the workspace. `SYLPHEED_CLI` still overrides, for anyone
|
|
# who does want to pin one deliberately.
|
|
CLI="${SYLPHEED_CLI:-}"
|
|
if [ -z "$CLI" ]; then
|
|
CLI="${CARGO_TARGET_DIR:-/sylph-home/port/target-container}/release/sylpheed-cli"
|
|
cargo build --release -p sylpheed-cli >/dev/null 2>&1 || true
|
|
fi
|
|
DISC="${SYLPHEED_DISC:-/disc}"
|
|
OUT="${OUT:-${TMPDIR:-/tmp}/verify-screen}"
|
|
export DISPLAY="${DISPLAY:-:97}"
|
|
|
|
[ -x "$CLI" ] || { echo "no reference CLI at $CLI -- \`cargo build --release -p sylpheed-cli\` failed?" >&2; exit 2; }
|
|
[ -f export/manifest.json ] || { echo "no export/manifest.json -- run build-export --run" >&2; exit 2; }
|
|
|
|
# 🔴 THE REFERENCE BINARY IS NOT NECESSARILY THE ONE THIS SCRIPT BUILT.
|
|
#
|
|
# `CARGO_TARGET_DIR` is a SHARED `/sylph-home/port/target-container`. Two source
|
|
# trees -- this workspace and any worktree built with the same variable set --
|
|
# write one `release/sylpheed-cli`, and cargo fingerprints per source path, so
|
|
# each build reports "Finished" while the binary on disk belongs to whichever
|
|
# tree wrote last. `cargo build` here returns in 0.15 s and changes nothing.
|
|
#
|
|
# That is the hazard the header above says the monorepo removed. It did not; the
|
|
# shared target dir reintroduced it by another route. Measured 2026-08-30: a CLI
|
|
# built from this workspace is `rest t=70` (the stale record layout) while the
|
|
# binary actually sitting in the target dir was `rest t=12` (fixed) -- so this
|
|
# script was comparing the port against a decoder from a tree nobody had named.
|
|
#
|
|
# ⚠️ It happened to be the RIGHT era, which is worse than wrong: it agreed with
|
|
# the exporter's pin by luck, and one successful rebuild would have flipped it
|
|
# silently. `title_jp` differs by 74 507 px between the two eras.
|
|
#
|
|
# So the era is CHECKED, against the export the port actually reads, rather than
|
|
# assumed from having run `cargo build`.
|
|
ref_rest=$("$CLI" screen info "$DISC/dat/GP_TITLE.pak" --build 5 --all 2>/dev/null \
|
|
| grep -i 'pteff00' | head -1 | sed -n 's/.*rest (0,0) t=\([0-9]*\).*/\1/p')
|
|
exp_rest=$(python3 -c '
|
|
import json
|
|
m=json.load(open("export/manifest.json"))
|
|
f=next(s["file"] for s in m["screens"] if s["name"]=="main_menu")
|
|
d=json.load(open("export/"+f))
|
|
print(int(next(e for e in d["elements"] if e.get("id")=="pteff00")["rest"]["t"]))')
|
|
if [ -n "$ref_rest" ] && [ "$ref_rest" != "$exp_rest" ]; then
|
|
echo "🔴 the reference CLI and the export disagree on the decoder era:" >&2
|
|
echo " reference $CLI says pteff00 rest t=$ref_rest" >&2
|
|
echo " export/ (built by the pinned exporter) says rest t=$exp_rest" >&2
|
|
echo " Every row below would compare two decoder eras. Refusing." >&2
|
|
echo "" >&2
|
|
echo " REMEDY, verified both directions 2026-08-30: this workspace's" >&2
|
|
echo " ui_layout.rs is the STALE era and still carries the retired" >&2
|
|
echo " SYLPHEED_KF_TIME_SHIFT knob, which converts it to the corrected" >&2
|
|
echo " reading. Re-run with SYLPHEED_KF_TIME_SHIFT=1 and the reference" >&2
|
|
echo " reports rest t=12, matching the pinned exporter; without it, t=70." >&2
|
|
echo " The knob is absent from the pinned tag, so it cannot affect export/." >&2
|
|
exit 2
|
|
fi
|
|
mkdir -p "$OUT"
|
|
|
|
# Godot needs one scan to register the `class_name` globals; without it every
|
|
# script fails to parse and the run dies with no frame drawn.
|
|
[ -d port/.godot ] || godot --headless --path port --import >/dev/null 2>&1
|
|
|
|
screens=("$@")
|
|
if [ ${#screens[@]} -eq 0 ]; then
|
|
mapfile -t screens < <(python3 -c '
|
|
import json; print("\n".join(s["name"] for s in json.load(open("export/manifest.json"))["screens"]))')
|
|
fi
|
|
|
|
status=0
|
|
for name in "${screens[@]}"; do
|
|
build=$(python3 -c '
|
|
import json,sys
|
|
m=json.load(open("export/manifest.json"))
|
|
f=next(s["file"] for s in m["screens"] if s["name"]==sys.argv[1])
|
|
print(json.load(open("export/"+f))["source"]["build"])' "$name")
|
|
|
|
# `--all` because the exporter now addresses by PAK ENTRY INDEX, which is the
|
|
# numbering `--all` uses; without it the CLI enumerates only the 12 bundles
|
|
# `is_build` accepts and `--build 10` would land on entry 12. `--all` widens
|
|
# the list, it does not change how any one bundle composites.
|
|
"$CLI" screen render "$DISC/dat/GP_TITLE.pak" "$OUT/$name.ref.png" \
|
|
--build "$build" --all --black --primitives --animated >/dev/null
|
|
|
|
# 🔴 THE REFERENCE RENDERER SILENTLY OMITS A `.tbm` BACKGROUND.
|
|
#
|
|
# The Decoder reached and captured the TUTORIAL screen and found that
|
|
# `screen render` draws every OTHER element of a `.tbm`-bearing build and
|
|
# leaves the background out, with no diagnostic: their render of GP_TUTORIAL
|
|
# build 0 is the correct layout on pure black, 6.0-6.4 % inked against the
|
|
# game's 99.7 %. `docs/re/structures/tbm-submenu-not-reached.md`, their branch.
|
|
#
|
|
# I confirmed the shape of it here with both controls: `screen info` reports
|
|
# `pubase.tbm` on GP_TUTORIAL build 0 and no `.tbm` on any of the 16 builds in
|
|
# my manifest. So this trap CANNOT fire today.
|
|
#
|
|
# ⚠️ That is a fact about today's manifest, not a property of this script, and
|
|
# the failure it would cause is the expensive kind: the port draws a
|
|
# background the reference does not, the row reads DIFFERS, and the header
|
|
# above tells the reader to go find out which renderer moved. Neither did.
|
|
# The row would be a real disagreement caused by a KNOWN omission on the
|
|
# reference side, and nothing on screen would say so.
|
|
#
|
|
# So the row says so. This does not change the verdict or the bar -- it
|
|
# attaches the provenance to the one row that would otherwise mislead.
|
|
tbm=$("$CLI" screen info "$DISC/dat/GP_TITLE.pak" --build "$build" --all 2>/dev/null \
|
|
| grep -ioc '\.tbm' || true)
|
|
|
|
# 🔴 `--loop-phase=0` PINS THE PULSE, AND WITHOUT IT THIS SCRIPT WAS
|
|
# NONDETERMINISTIC. `press_start` returned `over3` **5021, 8919, 5021** on
|
|
# three identical runs: the plate's looping focus record rides `time_units`,
|
|
# so the captured frame lands wherever the grab fell, while the reference
|
|
# renderer cannot pulse at all.
|
|
#
|
|
# ⚠️ The port is NOT the thing that is wrong. A thing that pulses does not
|
|
# stop because the screen has arrived, and the pulse is measured. What was
|
|
# wrong is comparing a moving frame against a static one and calling the
|
|
# difference a regression -- a detector that answers differently each run
|
|
# teaches its reader to ignore it, which is worse than one that fails.
|
|
#
|
|
# So the phase is pinned HERE, in the harness, and nothing about playback
|
|
# changes: `loop_phase_units` defaults to free-running everywhere else.
|
|
# ⚠️ It is usually stable -- 3 of 4 control runs agreed -- which is exactly
|
|
# why this survived: it looks deterministic most of the time.
|
|
godot --path port --resolution 1280x720 -- \
|
|
"--screen=$name" --pose=rest --loop-phase=0 "--capture=$OUT/$name.godot.png" >"$OUT/$name.log" 2>&1
|
|
|
|
convert "$OUT/$name.godot.png" "$OUT/$name.ref.png" \
|
|
-compose difference -composite -colorspace Gray -auto-level "$OUT/$name.diff.png"
|
|
read -r max mean <<<"$(convert "$OUT/$name.godot.png" "$OUT/$name.ref.png" \
|
|
-compose difference -composite -format "%[fx:maxima*255] %[fx:mean*255]" info:)"
|
|
|
|
# HOW MANY pixels are over the bar, not just how far the worst one is. A
|
|
# single `max` cannot tell 2 pixels from 25 444, and this run produced both:
|
|
# `main_menu` trips the threshold on TWO pixels out of 921 600 while
|
|
# `title_jp` trips it on 2.8 % of the frame. Reporting only the max made those
|
|
# the same verdict, which is how a real disagreement hides behind a rounding
|
|
# one. The bar itself is NOT raised -- tuning a threshold until things match
|
|
# is the failure this script's own header warns about.
|
|
over=$(convert "$OUT/$name.godot.png" "$OUT/$name.ref.png" \
|
|
-compose difference -composite -colorspace Gray -threshold $((3*65535/255)) \
|
|
-format "%[fx:int(mean*w*h)]" info:)
|
|
|
|
# BOTH FRAMES BLANK IS NOT AGREEMENT, AND THIS SCRIPT USED TO SAY IT WAS.
|
|
#
|
|
# `build_12` and `build_15` -- the two dressed loading screens -- render as
|
|
# pure black in BOTH renderers, mean 0 and max 0, so the difference is 0 and
|
|
# the row read `max 0 over3 0 OK`. Two of the sixteen rows in the committed
|
|
# baseline were comparing nothing against nothing and reporting the strongest
|
|
# verdict this script has.
|
|
#
|
|
# That is worse than a missing test: it is a test that reports a pass. The
|
|
# screens are black because `pgloading_eff00` is a full-frame opaque black
|
|
# quad whose `rest.t` (38) sits inside its own opening black hold, and
|
|
# `--pose=rest` freezes it there -- see docs/port/DECISIONS.md. Whether that
|
|
# is the port's bug or the decoders' reading of `rest` is open; what is not
|
|
# open is that a blank pair may not be scored.
|
|
#
|
|
# ✅ RESOLVED 2026-08-30, AND THE PARAGRAPH ABOVE IS NOW HISTORY. It was the
|
|
# PAINT ORDER, not `rest`. `pgloading_eff00` carries `layer: null`,
|
|
# `layer_source: none` -- the only elements in the export with neither a read
|
|
# nor an implied key -- so without the forced-backdrop pass the first element
|
|
# becomes `pgloading_loop5` and the opaque quad paints over everything. With
|
|
# the pass, both screens render at max 214.5 in BOTH renderers (mean 1.949
|
|
# port, 1.918 reference) and the rows read `OK` on a real comparison.
|
|
#
|
|
# ⚠️ The guard STAYS. It is not firing today, which is exactly when a guard
|
|
# quietly rots -- and it was right when it was written: two of sixteen rows
|
|
# were comparing nothing against nothing and reporting this script's
|
|
# strongest verdict. Leaving the reasoning above intact is deliberate; a
|
|
# reader who hits a blank pair tomorrow needs it.
|
|
#
|
|
# So blankness is checked FIRST and reported as its own verdict. It is not a
|
|
# failure -- the port may legitimately have nothing to draw -- but it is not a
|
|
# pass either, and `status` is left alone so an unrelated screen's DIFFERS is
|
|
# still what fails the run.
|
|
ink=$(convert "$OUT/$name.godot.png" "$OUT/$name.ref.png" \
|
|
-evaluate-sequence max -colorspace Gray -format "%[fx:maxima*255]" info:)
|
|
verdict=OK
|
|
if awk "BEGIN{exit !($ink <= 0)}"; then
|
|
verdict="BLANK -- both renderers drew nothing; this row proves nothing"
|
|
else
|
|
# 🔴 THE VERDICT USES `over3`, NOT `max` ALONE, AND FOR YEARS IT DID NOT.
|
|
#
|
|
# This script computed `over3` precisely because "a single `max` cannot tell
|
|
# 2 pixels from 25 444" -- its own words, a few lines up -- and then decided
|
|
# the verdict on `max` regardless. So `main_menu` (max 4, over3 **0**) read
|
|
# DIFFERS while `extras` (max 3, over3 0) read OK: one unit on one pixel,
|
|
# separating two frames that are pixel-for-pixel equivalent at the bar.
|
|
#
|
|
# ⚠️ This is NOT raising the bar, which this file rightly warns against. The
|
|
# bar is still 3. What changes is that a frame with NO pixel over it gets a
|
|
# verdict of its own instead of being lumped in with a real disagreement --
|
|
# the distinction the statistic was added to make and was never given.
|
|
if awk "BEGIN{exit !($over > 0)}"; then
|
|
verdict=DIFFERS; status=1
|
|
elif awk "BEGIN{exit !($max > 3)}"; then
|
|
verdict="ROUNDING -- max $max but NO pixel over the bar"
|
|
fi
|
|
fi
|
|
if [ "${tbm:-0}" -gt 0 ]; then
|
|
verdict="$verdict [build carries a .tbm: the REFERENCE omits that background, so a DIFFERS here is likely theirs]"
|
|
fi
|
|
printf '%-17s build %-3s max %-5s mean %-8s over3 %-7s %s\n' \
|
|
"$name" "$build" "$max" "${mean:0:6}" "$over" "$verdict"
|
|
done
|
|
echo "artifacts in $OUT"
|
|
exit $status
|