#!/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.
#
# 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/verify-capture` for the correctness question.
#
#   tools/verify-screen                 # every screen in the manifest
#   tools/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.
#  * `--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; }
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

  godot --path port --resolution 1280x720 -- \
    "--screen=$name" --pose=rest "--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:)

  # 3/255 is what integer-truncating compositing in the CLI and float rounding
  # in a GPU differ by. Anything above that is a placement, order or colour
  # disagreement and needs a reason, not a threshold.
  verdict=OK
  awk "BEGIN{exit !($max > 3)}" && { verdict=DIFFERS; status=1; }
  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
