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.
140 lines
6.1 KiB
Markdown
140 lines
6.1 KiB
Markdown
# 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
|
||
|
||

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

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