Measured, not argued: command a 45-degree-off-the-nose error and watch whether it
shrinks, both axes, both sides, two pulse widths, with the opposite sign as a
control. Yaw's sign is correct (45 -> 11.3/32.7 at 0.6 s, 41.6/33.8 at 1.2 s).
Pitch's is inverted - the pilot's own sign GREW the error every time
(48.3/55.6/70.7/91.7) and the opposite shrank it every time (30.7/41.5/15.7/9.6).
A method artefact is recorded because it gave the opposite answer first: a 3 s
full-deflection pulse overshoots a 45-degree error so far that BOTH signs look
wrong (45 -> 164 and 45 -> 178). A long pulse cannot answer a sign question.
Verified against the game rather than by inspection. Before: fire=1 in 0 of 13521
samples, |aim yaw| pinned at 90.0, target 36-43 km away. After: 43 of 1732, aim
down to 2.3 degrees, range median 6.3 km, and the HUD's own ammunition counters
moving - NOSE BM 06000 -> 05723, MAIN MPM 00300 -> 00298.
Also fixed a leftover of the same FIFO era: pilot.py called pad.f.write("tap A
90") for the target-select double tap, which raised AttributeError once Pad
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: YOU KILLED is 0000 after 250 s of firing and REMAINING OB is still
012. The craft shoots, closes and selects; whether it destroys anything is next.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
217 lines
10 KiB
Markdown
217 lines
10 KiB
Markdown
# `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` |
|
||
|
||

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