A snapshot of the non-game files as of0148cb8("port: F5/F6 hand-off -- one-minute human checks, and a refutation attempt that survived", 2026-09-04), the tip of auto/port-p6-audio. The branch was deleted from the server on 2026-09-17 during the consolidation cleanup; issue #7 asks for this work as a reviewable PR, so it is recovered here before the commits are garbage collected. Contents: the 84 files the branch changed relative to its fork pointb305aa4, which is this commit's parent. The tree is therefore 0148cb8's tree with the 854 exported game assets left out -- export-probe/, export-probe2/, three .wav renders of game audio and adv-v2-screenlog.tsv. Game data stays out of git; the exporter regenerates those from the disc. docs/port/DECISIONS.md still refers to them by name. Not recovered: the branch's own 366 commits. Keeping them would make those assets reachable again, so this is one snapshot instead. The original commits stay unreferenced in the server's object store, and in this clone under the local branch archive/port-p6-audio, until either is garbage collected. Refs #7. The OPTIONS work that issue #6 asks for is a subset of this branch, also recovered as recover/options-menu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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
|