Files
Sylpheed/docs/re/nav-guards.md
Sylpheed RE agent a25ddabb11 tools: add SYLPH_WEAKEST target scoring; record that assert_stage != in flight
pilot.py gains SYLPH_WEAKEST=1, which scales a target's score by its remaining
hull (pos+0x154) so the pilot finishes what is already hurt instead of
re-engaging whatever is nearest.  Motivated by 00dc016: over ~8 minutes the pilot
damaged 14 of 16 e010 attackers (hulls 360..500) and killed none, because 500 HP
spread across a squadron kills nobody.

STATUS: the flag is implemented and its targeting works -- 3105 of 3105 target
samples selected e010 -- but it is UNVERIFIED in combat, because the run it was
written for was lost.

That loss is the second half of this commit.  The run printed "READY ROOM / >>>
HUD / Stage 02 OK" and I began the experiment; there was no FLIGHT: line, because
the flight check failed three times and fell through silently while the next line
read like success.  The game was frozen on a near-black screen (screen_id `other`,
mean 10.8/2.8/2.1, frozen.py max_pixel_delta=0) and the pilot's every sample from
t=0.0 to t=406.1 is byte-identical with speed 0.

assert_stage.py could not have caught it: it reads the DEFINITION table, which is
populated when the STAGE loads, independently of whether the mission is running.
Recorded in nav-guards.md with the rule -- enforce the flight gate with a non-zero
exit, and run the three-crop TIME liveness check before any experiment.
2026-08-27 03:27:35 +00:00

140 lines
6.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
---
## ✅ Both ends fixed, and the route now verifies end-to-end
The sliver had to be stopped at **both** ends, because either alone leaves the
failure reachable:
* **Producer** (`bin/screenshot`, commit `290cbe3`) — the app really does own two
windows of class `xenia_canary`, listed in the tree right now as
`10x10+10+10` and `1280x745+1+20`. Largest-by-area picks the game window while
both are present, but during a load the game window is briefly *absent* and the
10×10 helper wins by default; cropping 720 rows at its `+10` gives exactly the
`10x710` seen. Candidates narrower than 640 are now ignored, so nothing is
selected and the existing fall-through hands back the raw root grab — itself a
valid full frame. Verified on the selection logic directly: both windows listed
→ unchanged `1280x745+1+45`; only the helper listed → old logic returned
`10x10+10+10`, new logic selects nothing.
* **Consumer** (`screen_id.py`, commit `30e53f5`) — captures below 640×360 return
`none` instead of a screen name.
A full guarded run then went through cleanly:
```
guard: menu confirmed (1279x675)
loaded ... READYROOM ... armed ... >>> HUD
FLIGHT: flight green=0.0143
=== GUARD 2 ===
OK: UN_f101_TCAF_Acropolis is in the 14 definitions
exit=0
```
**Stage 02, asserted rather than assumed.**
## ✅ The menu order is confirmed by looking at it
![main menu](captures/main-menu-items.png)
`NEW GAME · LOAD GAME · TUTORIAL · OPTIONS · EXTRAS`, cursor on **NEW GAME**.
So `dpad down` ×1 → `LOAD GAME` is right, and `dpad down` ×2 → **TUTORIAL**
one press further. That is exactly the observed failure: a single extra or
mistimed press, issued while the guard was looking at a sliver, lands on the
tutorial. (`structures/ui-paint-order-key.md` already recorded this item list;
this confirms it visually and pins the cursor's start position.)
## ❔ Still open
* **n = 1.** One clean run after the fix. The mechanism is understood and
unit-tested, but "the route is now reproducible" rests on a single success.
* Why the game window leaves the window tree during a load at all was not
investigated — only guarded against.
---
## ⚠️ `assert_stage` passing does NOT mean the run is in flight
Measured the hard way 2026-08-27. A run printed:
```
MENU confirmed
loaded
READYROOM
>>> HUD
Stage 02 OK
```
…and I started an experiment against it. There is no `FLIGHT:` line in that
output. The flight check ran, failed to match three times, and fell through
**silently**; the very next line said `Stage 02 OK`, which read like success.
It was not. The game was frozen on a near-black screen — `screen_id` `other`,
mean `(10.8, 2.8, 2.1)`, `frozen.py max_pixel_delta=0` — and the whole 460 s
experiment ran against a static world. The pilot's own log is the clearest
evidence: **every sample from t=0.0 to t=406.1 is byte-identical**, speed `0`,
the ship never even started moving.
**Why the stage assertion did not catch it:** `assert_stage.py` reads the
DEFINITION table, and definitions are loaded when the *stage* loads — before,
and independently of, the mission actually starting. It answers "is the right
stage loaded", never "is the mission running".
**Rule:** the flight gate must be *enforced*, not attempted — exit non-zero when
`screen_id` never reads `flight`, and take the three-crop TIME liveness check
before any experiment. Both were skipped here, and the run was lost.