From 540eefeae8760abe53c825931786da79d7bbc1e5 Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Wed, 26 Aug 2026 22:56:22 +0000 Subject: [PATCH] tools: add assert_stage.py and a menu guard; record that the guard was not enough assert_stage.py checks the DEFINITION table against an expected stage marker and earned its keep immediately: its first live run reported MISMATCH -- the capture had a live flight HUD and would have been filed as Stage 02, but was the S01 tutorial. That is exactly the failure that silently invalidated an earlier cross-run comparison. require_menu (launch_mission.sh) refuses to press until screen_id reads `menu`. It is NOT sufficient, and this refutes my previous explanation: the run DID confirm the menu and still loaded the tutorial. The real cause was that the guard's own capture was a 10x710 sliver which classified as `menu` -- fixed separately in 30e53f5. Left open: whether the menu guard suffices now that slivers are rejected (not re-run), and why the capture was a sliver at all when the other shots in the same run were 1279x675. --- docs/re/nav-guards.md | 55 ++++++++++++++++++++++++++++++ tools/re-capture/assert_stage.py | 46 +++++++++++++++++++++++++ tools/re-capture/launch_mission.sh | 19 +++++++++++ 3 files changed, 120 insertions(+) create mode 100644 docs/re/nav-guards.md create mode 100755 tools/re-capture/assert_stage.py diff --git a/docs/re/nav-guards.md b/docs/re/nav-guards.md new file mode 100644 index 0000000..3ba415d --- /dev/null +++ b/docs/re/nav-guards.md @@ -0,0 +1,55 @@ +# Nav guards — one works, one was not enough, and the real cause was a sliver + +Added and exercised 2026-08-26 against a live run. + +## ✅ Guard 2 works, and earned its keep on the first run + +`tools/re-capture/assert_stage.py` states the expected stage and checks it against +the **definition table** (definitions are the stage's cast and are present from +load; instances arrive in waves). Its first live run: + +``` +MISMATCH: UN_f101_TCAF_Acropolis is NOT in the 13 definitions + UN_S01_Asteroid_cmesh_01a ... UN_e106_ADAN_Destroyer +``` + +The run had reached a live flight HUD and would have been recorded as a valid +Stage 02 capture. It was the **S01 tutorial**. Without this check the wrong-stage +roster looks exactly like a right one — which is how the earlier cross-run +comparison was silently invalidated. + +Use it as the precondition for any runtime measurement: +`./assert_stage.py UN_f101_TCAF_Acropolis` (exit 0 iff Stage 02 is loaded). + +## ❌ Guard 1 was necessary but NOT sufficient — my explanation was wrong + +`require_menu` in `launch_mission.sh` refuses to press until `screen_id.py` reads +`menu`. [The previous note](stage-drift-is-navigation-not-save.md) said the +tutorial was selected because the d-pad went out "from a state never confirmed to +be the main menu". **That explanation is refuted:** this run confirmed the menu +first, and *still* loaded the tutorial. + +## ✅ The actual cause: the guard was shown a 10×710 sliver + +![sliver](captures/sliver-classified-as-menu.png) + +The guard's own capture was **10 × 710** — not a frame at all. Every statistic in +`screen_id.py` is an area fraction, so the sliver classified cleanly as `menu` +(`green 0.0000, white 0.0157`). The guard passed on garbage, the fixed key +sequence went out blind, and the selection landed on a tutorial entry. + +🔑 **`bin/screenshot`'s own header already records this failure mode** from +2026-08-18 — a second window of class `xenia_canary` meant grabs came back as +slivers, and "a whole session's screen ids were noise". That fix hardened the +**capture** side. It did not harden the **consumer**, so the same failure walked +straight back in through a different path. Fixed now in `screen_id.py`: captures +below 640×360 return `none` rather than a screen name (commit `30e53f5`). + +## ❔ Still open + +* Whether the menu guard is sufficient **now that the classifier rejects + slivers** — not re-run. The sliver explains this run, but it does not prove the + cursor/item-order assumption behind `dpad down` = LOAD GAME is correct. +* Why the capture came back as a sliver at all when `bin/screenshot` is supposed + to take the largest `xenia_canary` window by area. It succeeded for the other + shots in the same run (1279×675), so it is intermittent, not systematic. diff --git a/tools/re-capture/assert_stage.py b/tools/re-capture/assert_stage.py new file mode 100755 index 0000000..0b31172 --- /dev/null +++ b/tools/re-capture/assert_stage.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Assert the running mission is the one we meant to load. + +A fixed key sequence can select the wrong menu entry, and the resulting capture +looks perfectly valid -- it is simply of the wrong stage. That happened once +(a TUTORIAL, "Glasner Training Area", instead of the save's Stage 02) and it +silently invalidated a cross-run roster comparison. So state the expectation and +check it, rather than trusting the key sequence. + +The check is the DEFINITION table, not the instance list: definitions are the +stage's cast and are present from load, while instances arrive in waves. + + ./assert_stage.py # print the definition table + ./assert_stage.py UN_f101_TCAF_Acropolis # exit 0 iff that type is defined + +Stage 02 (the save's stage, see docs/re/stage-drift-is-navigation-not-save.md) +is identified by UN_f101_TCAF_Acropolis; the tutorials by UN_S01_Asteroid_cmesh_*. +""" +import sys +sys.path.insert(0, __file__.rsplit("/", 1)[0]) +import gworld +import entities2 as E + + +def main(): + w = gworld.World() + names = sorted(set(E.definitions(w).values())) + if not names: + print("NO DEFINITIONS -- not in a mission (or the scan found nothing)") + return 2 + want = sys.argv[1] if len(sys.argv) > 1 else None + if want is None: + for n in names: + print(" " + n) + return 0 + ok = want in names + print(f"{'OK' if ok else 'MISMATCH'}: {want} {'is' if ok else 'is NOT'} " + f"in the {len(names)} definitions") + if not ok: + for n in names: + print(" " + n) + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/re-capture/launch_mission.sh b/tools/re-capture/launch_mission.sh index e0eecb5..70b9cb6 100755 --- a/tools/re-capture/launch_mission.sh +++ b/tools/re-capture/launch_mission.sh @@ -74,6 +74,25 @@ sleep 5 || { echo "BOOT FAILED (skip_intro exit $?)"; exit 1; } sleep 14 # main menu is not input-ready before this +# GUARD (2026-08-26): the sequence below is fixed, so it must not be issued from +# a screen that is not the main menu. One run's probe read `other` while the +# attract movie was still up, the d-pad + (A) went to a TUTORIAL entry instead of +# LOAD GAME, and the whole capture was of "Glasner Training Area" rather than the +# save's Stage 02 -- a wrong-stage roster that looked like a valid one. See +# docs/re/stage-drift-is-navigation-not-save.md. +require_menu(){ + local deadline=$(( SECONDS + ${1:-120} )) + while [ $SECONDS -lt $deadline ]; do + shot "lm-menuguard.png" + if [ "$(python3 "$SD/screen_id.py" "$SHOTS/lm-menuguard.png" | awk '{print $1}')" = menu ]; then + return 0 + fi + sleep 2 + done + return 1 +} +require_menu 120 || { echo "NOT ON THE MAIN MENU -- refusing to press blind"; exit 7; } + step down # NEW GAME -> LOAD GAME tap A; sleep 8 # save list, slot 01 preselected # Open "Load game?" and answer YES, VERIFYING the dialog is actually up first.