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.