Files
Sylpheed/tools/port/which-focus
Sylpheed port agent ffb54429fb port: S00A is closed as not obtainable, and Q5's shape is tighter than "unstable"
Three things folded in from the Decoder, none of which change the export.

S00A IS NOT OBTAINABLE HERE and it is the game that stops it, not the drive. The
drive works end to end -- main menu +0.999, newgame-difficulty +0.999,
newgame-selectdata-crash +0.997, with the focus detector validated live against a
known transition rather than trusted -- and then the guest throws at
PC: 0x82307128 x349 with no S00A stream ever decoding. Recorded in BLOCKED as a
route finding, with the consequence for me stated: THE CENTRE-CHANNEL RESULT
RESTS ON ADV ALONE. S00A was wanted precisely because its second stream is
digital silence where ADV's is a 0.60x copy.

Q5 GAINED TWO POINTS and the shape is sharper than "it varies": TUTORIAL x3, NEW
GAME x3 over six boots, and no other item ever observed. That does not change the
port's authored initial focus -- NEW GAME is still one of exactly two observed
states and is the state of the committed capture -- but it changes what would
REFUTE it, so the authored `why` now says so: a boot opening on LOAD GAME,
OPTIONS or EXTRAS breaks the two-way shape, and a rule predicting the split
deletes the entry outright.

`which-focus` NEEDS GODOT AND THE EXPORT TREE, so it cannot run in the RE
container. Recorded in its own header rather than left for the next agent to
discover, along with the pointer to the capture-only alternative and that tool's
own caveat: its offline controls are its calibration inputs, which is
self-consistency and not validation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
2026-08-29 17:54:12 +00: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