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 6aa31e5.

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 6aa31e52ba
commit f307c6f31b
3 changed files with 120 additions and 0 deletions

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.