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
117 lines
5.5 KiB
Bash
Executable File
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
|