# `pilot.py` never pulls the trigger — the proximal cause, measured **Status: ✅ ROOT CAUSE FOUND AND FIXED (2026-08-23, later the same day) — the flight tools' pad wrote to a FIFO nothing has read since the uinput pad was removed, so every stick, trigger and button they sent went nowhere.** The gate measured below is real; it was open because the craft was never being flown at all. 🟡 The pilot still does not converge on a target in 150 s with the input working — see the last section. ## The observation Over a 900 s Stage 02 run, `fire=1` appears in **0 of 13 521** logged samples. The loop flies, chases, evades and retires; the guns are never fired. Every "kill something and watch X" experiment in this corpus has therefore been leaning entirely on the wingmen. ## Which gate closes `pilot.py`'s trigger needs all of: mode `ENGAGE`/`DEFEND`, a target, the aim error inside the firing cone, range < `FIRE_RANGE` (`SHELL_MAX_RANGE` = 4 000), and a small avoidance push. Over the 3 004 samples that had a target: | quantity | median | min | inside its gate | |---|---|---|---| | `\|aim yaw\|` | **90.0°** | **90.0°** | 0 of 3 004 below 25° | | `\|aim pitch\|` | 179.2° | 166.4° | 0 of 3 004 below 25° | | range | 49 634 | 45 078 | 0 below 4 000, 0 below 10 000 | `|aim yaw|` being **exactly** 90.0 in every sample is not a coincidence, it is a branch: `sticks()` contains ```python if ez < 0: # target behind: commit to a full turn yaw = math.copysign(math.pi / 2, ex if ex else 1.0) ``` So the committed target was **behind the ship in 100 % of samples**, with a pitch error near 180° — directly astern — and the range to it grew monotonically: ``` t= 0 s 22 265 t= 231 s 33 632 t=510 s 49 475 t= 884 s 48 200 (plateau) ``` The craft flies *away* from the thing it is chasing for fifteen minutes and the turn never completes. Nothing else needs to be wrong for the trigger to stay cold. ## What is NOT established Three explanations fit that shape and this pass did not separate them: 1. the attitude matrix the pilot reads is **stale**, so the ship turns and the controller cannot see it; 2. the matrix is live but `fwd_row`/`fwd_sign` name the wrong axis; 3. both are fine and the **yaw stick sign** is inverted, so the loop turns away from the error it is nulling. 🔴 **An attempt to separate them was invalidated and is withdrawn.** `aim_probe.py` watched the forward vector under neutral, full-left and full-right stick and reported it pinned at `[-1, 0, 0]` with `|turn| = 0.00 °/s` in every phase — which looks like explanation (1). It is not evidence: **the guest had frozen** partway through, confirmed immediately afterwards by `frozen.py` (max pixel delta 0) and by the player's position being byte-identical across 3 s. A dead world holds every matrix still. The probe is committed because it is the right experiment; its result is not. ## One confusion resolved on the way Today's repeated `entities2.py self` failures — `# 0 moving triples`, "player entity not found" — are **the freeze**, not a tool defect. `moving()` types entities by their position *changing* between two samples, so a frozen world yields nothing by construction. Runs where the bind failed were runs that had already stopped. ## Next step Re-run `aim_probe.py` on a run confirmed to be animating **at the end of the probe as well as the start** — the tool should check `frozen.py` itself and discard the phase otherwise. If the matrix does move under stick, the question becomes the sign; if it does not, the binding is what to chase. --- # ✅ ROOT CAUSE — the flight tools' pad was writing into a dead file `flight_probe.Pad` opened **`/tmp/sylph-vgamepad.fifo`** and wrote `axis LX 0.850`-style lines to it, described in its own docstring as "the vgamepad server's FIFO". That server does not exist any anymore: the uinput pad was removed because a uinput device is **not namespaced** and every scripted press leaked to the *host's* desktop, and it was replaced by Canary's `--hid=file` driver. The FIFO is now an ordinary 91-byte file that nothing reads. Meanwhile `/tmp/xenia_pad.txt` — the file the emulator actually polls — was **0 bytes** while the "autopilot" was supposedly flying. So every axis, trigger and button from `pilot.py`, `autopilot3.py`, `aim_probe.py` and `flight_probe.py` itself went into a dead file, silently, and the craft flew on its own for the whole of every run. That is why the target sat astern at 90.0° forever and the range grew to 49 km: **the ship was never being turned.** **The corpus already carried this trap — for the other half of the toolkit.** [`canary-scripted-input-traps.md`](canary-scripted-input-traps.md) records "pad.py, NOT `vgamepad`: that command no longer exists … and every call here failed silently, so the whole scripted route pressed nothing". The **shell** scripts were fixed then. This class was not, and every flight tool imports it. ## Measured, before and after Same probe, on runs confirmed animating at both ends of every phase ([`aim_probe.py`](../../tools/re-capture/aim_probe.py) brackets each phase with `frozen.py` now, after a freeze voided the first attempt): | | heading change under full stick | attitude matrix `pos-0x70` | |---|---|---| | **before** | `0.00°` in every phase, over 4 s, while travelling 350–735 units | `d 0.0000` — unchanged | | **after** | `12.72°` on `LX=-1`; `LX=+1` swings the flight direction from `[1,0,0]` to `[0.13,-0.14,-0.98]` | `d 0.4438` / `d 0.3108` under stick, `0.0000` at neutral | 🔴 **So the "stale attitude matrix" reading is refuted**, and with it the earlier suspicion that the binding was wrong: `pos-0x70` is live and tracks the ship. It only looked dead because nothing was turning the ship. ## ✅ AND THE SECOND HALF: the PITCH stick sign was inverted (The section below is what was open before this was measured; it is kept because the sequence matters.) With the pad reaching the game, the sign question became testable and was settled by commanding a **45°-off-the-nose error** and watching whether it shrank — both axes, both sides, two pulse widths, plus the opposite sign as a control: | pulse | axis | pilot's own sign | opposite sign | |---|---|---|---| | 0.6 s | yaw | 45° → **11.3** / **32.7** ✅ reduces | — | | 1.2 s | yaw | 45° → **41.6** / **33.8** ✅ reduces | — | | 0.6 s | pitch | 45° → 48.3 / 55.6 🔴 grows | 45° → **30.7** / **41.5** reduces | | 1.2 s | pitch | 45° → 70.7 / 91.7 🔴 grows | 45° → **15.7** / **9.6** reduces | So `sticks()`'s `sy = -(KP*pitch - KD*…)` had the **wrong sign**; yaw's is right. 🔴 **A method artefact worth recording, because it gave the opposite answer first.** The first attempt used a **3 s** full-deflection pulse and reported pitch growing under *both* signs (45° → 164° and 45° → 178°). That is overshoot: three seconds of full pitch swings the ship far past a 45° error, so the frozen world-direction ends up behind it whichever way it turns. Pulse short, or measure the initial rotation direction — a long pulse cannot answer a sign question. ### Verified: the pilot fires Same mission, same binding, sign flipped: | | before | after | |---|---|---| | `fire=1` samples | **0** of 13 521 | **43** of 1 732 | | `\|aim yaw\|` | median 90.0°, min 90.0° | min **2.3°**, 7 of 14 inside 25° in the first pass | | range to target | median 43 km, min 36 km | median **6.3 km**, min 5.3 km | | ammunition | untouched | `NOSE BM 06000 → 05723`, `MAIN MPM 00300 → 00298` | ![the pilot firing](captures/stage02-pilot-firing-after-sign-fix.png) One more leftover of the same FIFO era was fixed on the way: `pilot.py` called `self.pad.f.write("tap A 90")` for the target-select double tap, which raised `AttributeError` the moment the class stopped having an `f`. `Pad` gained `tap()`/`dpad()`; `ctrl_probe.py` and `target_probe.py` still use `pad.f` and now say so in place. 🟡 **Still open even so:** `YOU KILLED WARSHIPS/WARPLANES` is still `0000` after 250 s of firing, and `REMAINING OB` is still `012`. The craft shoots, closes and selects; whether it actually *destroys* anything is the next thing to measure, and it is what the objective-counter work is waiting on. ## 🟡 The state before that measurement — the pilot does not converge With the pad fixed, a 150 s `pilot.py` run still fired **0** times: over its 86 targeted samples `|aim yaw|` is *still* exactly 90.0° and the range is 36–43 km. Steering now works, so what remains is one of: * the **sign** of the yaw stick against the pilot's own error convention — a controller that turns away from the error would hold `ez < 0` forever, which is exactly the observed signature; * target selection that keeps committing to something 40 km away, which at ~300 units/s cannot be closed inside the window. Both are now testable, because the input path finally works. The measurement to make is the direct one: command the pilot's own `sx` for a known error and see whether `|yaw|` falls or rises. ## Not fixed, and flagged in place `findrot_global.py`, `findself.py`, `findspeed.py` and `selfstate.py` still write to the dead FIFO. Each now carries a comment saying so and pointing here. They are not repaired because none of them has been re-run since, and quietly changing the input plumbing under a tool whose results are unverified would only manufacture new unverified results. ## Which earlier results this puts at risk — dated, so it can be checked rather than worried about The uinput pad was replaced on **2026-08-13** (canary `cbfad1ab3`), which is the day `flight_probe.Pad` stopped reaching the game. So: * ✅ **Safe:** anything measured before that. `flight-controls-runtime.md` was last touched **2026-07-30**, comfortably on the working side. * ⚠️ **Ambiguous, and worth re-checking:** `flight-speed-law.md` was last touched **2026-08-13** — the same day — so whether its numbers were taken before or after the FIFO went dead cannot be told from the dates alone. * 🔴 **Anything that used `flight_probe.Pad` on or after 2026-08-13 measured a craft that was not being flown.** Stated as a rule for whoever picks this up: a flight result is only as good as a demonstration that the stick moved the ship in the same session. `aim_probe.py` is that demonstration, and it now brackets itself with a liveness check.