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.
This commit is contained in:
Sylpheed RE agent
2026-08-26 22:56:22 +00:00
parent 30e53f599c
commit 540eefeae8
3 changed files with 120 additions and 0 deletions

55
docs/re/nav-guards.md Normal file
View File

@@ -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.

View File

@@ -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())

View File

@@ -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.