Files
Sylpheed/tools/port/which-focus
MechaCat02 c3758e3850 port: land the play-tested work, and only that
Takes the port branch up to 77320d5e -- the state the human play-tested on
2026-09-02 -- for SOURCE paths only. Not a branch merge: `auto/port-p6-audio`
is 366 commits and 938 files, and most of that must not land.

WHAT COMES IN (76 files, all human-confirmed working):
  * the logo splash animation. 08ed3dd1 found it: `pose_at` ASSIGNED the settle
    instant instead of clamping to it, so the splash never animated at all --
    and the same bug manufactured a passing harness result, because the harness
    photographed t past the settle. Confirmed by play-test: "cannot notice any
    obvious difference from the actual game."
  * gamepad input -- (A)/(B) bound additively (`ui_accept` ships with NO joypad
    binding), stick latched with hysteresis at the game's own 61% digitise
    threshold. This is what made (A), video-skip and Extras work at all.
  * menu navigation and flow, menu audio, the exporter, the authored
    declarations, and 23 verification tools under tools/port/.

WHAT IS DELIBERATELY LEFT ON THE BRANCH:
  * everything after c0ae460a -- the F5/F6 title-timing investigation, whose own
    tip commit calls itself a "hand-off for one-minute human checks". Unchecked
    by definition; it goes through the new review gate like anything else.
  * the OPTIONS menu work of 2026-09-03. Real, probably good, NOT play-tested.
  * the F1 repeat mechanism, which its own commit calls "deliberately inert".

WHAT MUST NOT LAND, AND WHY THE .gitignore CHANGED:
  545 MB of extracted game content was committed on that branch -- 850 sprite,
  audio and transcoded video files under `export-probe/` and `export-probe2/`,
  plus 246 MB of loose .wav and .tsv at the repo root. This repository's own
  rule, in this file, is "never game content".

  The rule was not missing. It was written, and it was tightened on that very
  branch, with a careful comment explaining why BOTH `export/` and `data/base/`
  had to be listed -- while the exporter was writing to a third name that
  nobody had thought to list. Enumerating names is the thing that failed. So
  the ignore rules now describe the SHAPE: any top-level `export*/`, game media
  by extension, and loose capture output at the root. Verified both ways -- it
  catches all four offenders and ignores nothing currently tracked.

Verified: `cargo check --workspace` clean; all nine GDScript files parse in
project context, with a positive control (an injected syntax error is detected,
3 lines) so the clean result means something. `tools/port/check-all` was NOT
run -- it needs the container, the export tree and a display.
2026-09-04 16:17:14 +02:00

117 lines
5.5 KiB
Bash
Executable File

#!/usr/bin/env bash
# Which button is focused in a screenshot of the real game?
#
# tools/port/which-focus SHOT.png # main_menu (5 buttons)
# tools/port/which-focus SHOT.png extras # extras (3 buttons)
#
# Renders the port's own screen with each button focused in turn and reports
# which one differs least from the shot. Answers a question the Decoder needs to
# drive the game -- `newgame_path.sh` assumed NEW GAME is focused at boot, drove
# on that, and landed in a tutorial mission, because HANDOFF Q5 measured focus as
# UNSTABLE across boots. Counting presses cannot substitute: up from the first
# item wraps to the last, so no fixed number of presses lands on a known item
# from an unknown start.
#
# ⚠️ IT RUNS ITS OWN CONTROL FIRST AND REFUSES TO ANSWER IF THE CONTROL FAILS.
# `docs/re/captures/title-builds/live-main-menu-options-focused.png` has the
# answer in its filename, so the method can be tested on every invocation rather
# than once when it was written. A brightness-per-row detector was tried for this
# job and picked NEW GAME on that capture; this method picks OPTIONS by 4.7x.
# A control that does not execute is not a control.
#
# 🔴 IT NEEDS GODOT AND THE PORT'S EXPORT TREE, so it does NOT run in the RE
# container -- no engine there, and rendering this project is outside that
# agent's role. It reads a capture, but it answers by RENDERING the candidates.
# `tools/re-capture/focus_from_capture.py` is the capture-only alternative; note
# that its offline controls are its own calibration inputs, which is
# self-consistency rather than validation, so it is the live transition test
# (NEW GAME -> down -> LOAD GAME, expected LOAD GAME) that validates it.
#
# WHAT IT IS NOT. It identifies the focus in ONE FRAME. It says nothing about
# what selects focus -- Q5's four boots gave TUTORIAL, TUTORIAL, NEW GAME, NEW
# GAME and that instability stands.
set -euo pipefail
cd "${PROJECT_DIR:-/work}"
export DISPLAY="${DISPLAY:-:97}"
shot="${1:?usage: which-focus SHOT.png [screen]}"
screen="${2:-main_menu}"
OUT="${OUT:-$(mktemp -d)}"; mkdir -p "$OUT"
CAPS=docs/re/captures/title-builds
# Buttons, in the order ui_down walks them.
case "$screen" in
main_menu) BUTTONS=(ptbtn01:NEW_GAME ptbtn02:LOAD_GAME ptbtn03:TUTORIAL ptbtn04:OPTIONS ptbtn05:EXTRAS) ;;
extras) BUTTONS=(ptbtn11:MISSION_SELECT ptbtn12:MOVIE_THEATER ptbtn13:THIRD) ;;
*) echo "which-focus: no button list for $screen" >&2; exit 2 ;;
esac
n=${#BUTTONS[@]}
downs=$(python3 -c "print(','.join(['down']*($n-1)))")
render_all() { # render_all <tag>
godot --path port --resolution 1280x720 -- "--menu=$screen" "--script=$downs" \
"--shots=$OUT/$1" >"$OUT/$1.log" 2>&1 || true
}
# Normalise any input to the captures' 1279x675 top-left crop. A 1280x720 guest
# frame and a 1279x675 screenshot are the same pixels; the difference is the
# crop the screenshot tool applies, not a scale.
norm() { convert "$1" -crop 1279x675+0+0 +repage "$2"; }
score() { # score <shot> ; prints "<idx> <label> <pixels>" per candidate
local s="$1" i=0 f
for f in "$OUT"/r_*.png; do
[ -f "$f" ] || continue
norm "$f" "$OUT/cand.png"
local d
d=$(convert "$OUT/cand.png" "$s" -compose difference -composite \
-colorspace Gray -threshold 25% -format "%[fx:mean*w*h]" info:)
echo "$i ${BUTTONS[$i]#*:} $d"
i=$((i+1))
done
}
render_all r
# Godot names the shots `<tag>_00_start.png`, `<tag>_01_down.png`, ... -- rename
# to a sortable form so the candidate order is the ui_down order and not glob luck.
i=0
for f in "$OUT"/r_0*.png; do mv "$f" "$OUT/r_$(printf '%02d' $i).png"; i=$((i+1)); done
[ "$i" = "$n" ] || { echo "which-focus: rendered $i of $n focus states -- see $OUT" >&2; exit 3; }
verdict() { # verdict <shot> <expected-or-empty>
local s="$1" expect="${2:-}"
norm "$s" "$OUT/shot.png"
mapfile -t rows < <(score "$OUT/shot.png" | sort -k3 -n)
local best_lbl best_px second_px
best_lbl=$(echo "${rows[0]}" | awk '{print $2}')
best_px=$(echo "${rows[0]}" | awk '{print $3}')
second_px=$(echo "${rows[1]}" | awk '{print $3}')
local margin
margin=$(python3 -c "print('%.1f' % ($second_px/max($best_px,1)))")
for r in "${rows[@]}"; do printf ' %-16s %8s\n' "$(echo "$r"|awk '{print $2}')" "$(echo "$r"|awk '{print $3}')"; done
echo " -> $best_lbl, margin ${margin}x"
if [ -n "$expect" ]; then
if [ "$best_lbl" = "$expect" ]; then echo " CONTROL PASSED (expected $expect)"; return 0
else echo " 🔴 CONTROL FAILED: expected $expect, got $best_lbl"; return 1; fi
fi
# A thin margin means the frame does not decide it. 2x is below the 4.7x the
# control achieves and well above 1.0; a shot that cannot beat it should be
# re-taken rather than guessed at.
python3 -c "import sys; sys.exit(0 if $margin >= 2.0 else 1)" || {
echo " ⚠️ margin under 2x -- this frame does not decide it. Do not act on this."; return 1; }
}
if [ "$screen" = main_menu ]; then
echo "control -- $CAPS/live-main-menu-options-focused.png (answer is in the filename):"
verdict "$CAPS/live-main-menu-options-focused.png" OPTIONS || {
echo "refusing to report a result from a method that just failed its control." >&2; exit 1; }
echo
fi
# The exit code must carry the refusal. An earlier version printed "do not act on
# this" and exited 0, so a caller scripting this -- which is the entire point,
# the Decoder runs it between drive steps -- would have read a refusal as an
# answer. That is the same defect as a checker claiming a check it skipped.
echo "$shot:"
rc=0
verdict "$shot" || rc=$?
echo "artifacts in $OUT"
exit $rc