From 0ddeffa9a63d62c18220fbb39f403735a072b7ce Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Sun, 23 Aug 2026 22:09:27 +0000 Subject: [PATCH] tools+docs: the flight pad was writing into a dead file - every autopilot input went nowhere flight_probe.Pad opened /tmp/sylph-vgamepad.fifo, "the vgamepad server's FIFO". That server was removed when the uinput pad was replaced by Canary's --hid=file driver, because a uinput device is not namespaced and scripted presses leaked to the host's desktop. The FIFO is now an ordinary 91-byte file nothing reads, and /tmp/xenia_pad.txt - the file the emulator polls - was 0 bytes while the "autopilot" was flying. So every axis, trigger and button from pilot.py, autopilot3.py, aim_probe.py and flight_probe.py went nowhere, silently. The corpus already carried this trap for the SHELL scripts (canary-scripted-input-traps.md, "every call here failed silently"). This class was missed, and every flight tool imports it. Verified against the oracle rather than by inspection, on runs confirmed animating at both ends of every phase: before, full stick produced 0.00 degrees of heading change over 4 s while the ship travelled 350-735 units, and the attitude matrix at pos-0x70 was byte-identical; after, LX=-1 turns 12.72 degrees and LX=+1 swings the flight direction from [1,0,0] to [0.13,-0.14,-0.98], with the matrix moving 0.44/0.31 under stick and 0.0000 at neutral. That also REFUTES the "stale attitude matrix" suspicion from the earlier pass - pos-0x70 is live and tracks the ship; it only looked dead because nothing was turning the ship. Still open and said plainly: with the pad fixed a 150 s pilot run still fires 0 times, with |aim yaw| still exactly 90.0 and the target 36-43 km away. Steering works now, so what is left is the stick SIGN against the pilot's error convention or a target selection that commits to something too far to close. Both are finally testable. findrot_global/findself/findspeed/selfstate still write to the dead FIFO and now say so in place; they are not repaired because none has been re-run since. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE --- docs/re/pilot-never-fires.md | 74 +++++++++++++++++++++++++++++- tools/re-capture/aim_probe.py | 54 +++++++++++++++++++--- tools/re-capture/findrot_global.py | 7 +++ tools/re-capture/findself.py | 7 +++ tools/re-capture/findspeed.py | 7 +++ tools/re-capture/flight_probe.py | 71 +++++++++++++++++++++++----- tools/re-capture/selfstate.py | 7 +++ 7 files changed, 207 insertions(+), 20 deletions(-) diff --git a/docs/re/pilot-never-fires.md b/docs/re/pilot-never-fires.md index 6a9773f..a63fa62 100644 --- a/docs/re/pilot-never-fires.md +++ b/docs/re/pilot-never-fires.md @@ -1,7 +1,11 @@ # `pilot.py` never pulls the trigger — the proximal cause, measured -**Status: ✅ the gate that blocks it is identified and measured; 🔴 the root cause -is not, and one attempt to find it was invalidated by a freeze.** +**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 @@ -75,3 +79,69 @@ 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. + +## 🟡 Still open — 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. diff --git a/tools/re-capture/aim_probe.py b/tools/re-capture/aim_probe.py index e260d53..87f7150 100755 --- a/tools/re-capture/aim_probe.py +++ b/tools/re-capture/aim_probe.py @@ -31,6 +31,7 @@ import time import numpy as np sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import frozen # noqa: E402 import navigator # noqa: E402 from flight_probe import Pad # noqa: E402 @@ -42,23 +43,56 @@ def main(): W.scan() pad = Pad() + # There is more than one orthonormal 3x3 block near the player object -- a + # scan finds them at pos-0x70 and pos-0x30, and `entities2.py self` has + # picked each on different runs. Watch BOTH, because "the one the config + # names did not move" is a much weaker statement than "neither of the two + # candidates moved". + deltas = [W.rot_delta] + if "--deltas" in sys.argv: + deltas = [int(x, 0) for x in sys.argv[sys.argv.index("--deltas") + 1].split(",")] + + def read_block(off, delta): + b = os.pread(W.fd, W.rot_stride * 3 + 12, off + delta) + rows = [] + for r in range(3): + row = np.frombuffer(b[W.rot_stride * r:W.rot_stride * r + 12], + dtype=">f4").astype(np.float64) + rows.append(row) + return np.array(rows) + def fwd_now(): ents = W.sample(time.time()) me = next((e for e in ents if "Player" in e[1]), None) if me is None: - return None, None + return None, None, None + mats = {} + for d in deltas: + try: + M = read_block(me[0], d) + except Exception: + continue + if np.all(np.isfinite(M)): + mats[d] = M M = W.rot(me[0]) if M is None: - return None, None - return M[W.fwd_row] * W.fwd_sign, me[2] + return None, None, mats + return M[W.fwd_row] * W.fwd_sign, me[2], mats def phase(name, lx): + # BRACKET the phase with a liveness check at BOTH ends. The first run of + # this probe reported the forward vector pinned and 0.00 deg/s under + # every stick, which reads as "the attitude matrix is dead" -- and the + # guest had simply FROZEN partway through. A dead world holds every + # matrix still, so a phase that ends frozen proves nothing and must be + # thrown away rather than reported. pad.axis("LX", lx) - f0, p0 = fwd_now() + f0, p0, m0 = fwd_now() t0 = time.time() time.sleep(secs) - f1, p1 = fwd_now() + f1, p1, m1 = fwd_now() pad.axis("LX", 0.0) + froze = frozen.frozen(3.0)[0] if f0 is None or f1 is None: print(f"{name:>12}: NO PLAYER/ORIENTATION") return @@ -66,10 +100,18 @@ def main(): turn = math.degrees(math.acos(dot)) / max(time.time() - t0, 1e-3) cross = np.cross(f0, f1) moved = float(np.linalg.norm(p1 - p0)) if p0 is not None else float("nan") + blocks = " ".join( + f"pos{d:+#06x} d{float(np.abs(m1[d]-m0[d]).max()):.4f}" + for d in deltas if d in m0 and d in m1) print(f"{name:>12}: |turn| {turn:6.2f} deg/s fwd {f0.round(3)} -> " - f"{f1.round(3)} cross {cross.round(3)} moved {moved:8.1f}") + f"{f1.round(3)} moved {moved:8.1f} [{blocks}]" + f"{' *** GUEST FROZE - DISCARD ***' if froze else ''}") + if frozen.frozen(3.0)[0]: + print("# GUEST IS ALREADY FROZEN — nothing to measure") + return 3 print("# phases: neutral, then full LEFT stick, then full RIGHT stick") + print("# (each phase is followed by a liveness check; a frozen tail voids it)") phase("neutral", 0.0) phase("LX=-1", -1.0) phase("neutral", 0.0) diff --git a/tools/re-capture/findrot_global.py b/tools/re-capture/findrot_global.py index ac516cb..0301808 100644 --- a/tools/re-capture/findrot_global.py +++ b/tools/re-capture/findrot_global.py @@ -20,6 +20,13 @@ import numpy as np sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import gmem # noqa: E402 +# 🔴 DEAD FIFO. The vgamepad server this names does not exist any more - the +# uinput pad was replaced by Canary's `--hid=file` driver - so every write here +# goes into an ordinary file that nothing reads, silently. See the rewritten +# `Pad` in flight_probe.py for the format the emulator actually polls, and +# docs/re/pilot-never-fires.md for what this cost. NOT fixed here: these scripts +# have not been re-run since, and changing input plumbing under a tool whose +# results are unverified would only produce new unverified results. FIFO = "/tmp/sylph-vgamepad.fifo" TOL = 2e-3 diff --git a/tools/re-capture/findself.py b/tools/re-capture/findself.py index dc6470d..9bf162c 100644 --- a/tools/re-capture/findself.py +++ b/tools/re-capture/findself.py @@ -23,6 +23,13 @@ import numpy as np sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import gmem # noqa: E402 +# 🔴 DEAD FIFO. The vgamepad server this names does not exist any more - the +# uinput pad was replaced by Canary's `--hid=file` driver - so every write here +# goes into an ordinary file that nothing reads, silently. See the rewritten +# `Pad` in flight_probe.py for the format the emulator actually polls, and +# docs/re/pilot-never-fires.md for what this cost. NOT fixed here: these scripts +# have not been re-run since, and changing input plumbing under a tool whose +# results are unverified would only produce new unverified results. FIFO = "/tmp/sylph-vgamepad.fifo" diff --git a/tools/re-capture/findspeed.py b/tools/re-capture/findspeed.py index 457f139..c6be6dd 100644 --- a/tools/re-capture/findspeed.py +++ b/tools/re-capture/findspeed.py @@ -25,6 +25,13 @@ import numpy as np sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import gmem # noqa: E402 +# 🔴 DEAD FIFO. The vgamepad server this names does not exist any more - the +# uinput pad was replaced by Canary's `--hid=file` driver - so every write here +# goes into an ordinary file that nothing reads, silently. See the rewritten +# `Pad` in flight_probe.py for the format the emulator actually polls, and +# docs/re/pilot-never-fires.md for what this cost. NOT fixed here: these scripts +# have not been re-run since, and changing input plumbing under a tool whose +# results are unverified would only produce new unverified results. FIFO = "/tmp/sylph-vgamepad.fifo" diff --git a/tools/re-capture/flight_probe.py b/tools/re-capture/flight_probe.py index e04ba71..def4050 100755 --- a/tools/re-capture/flight_probe.py +++ b/tools/re-capture/flight_probe.py @@ -19,44 +19,91 @@ import time sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import gworld # noqa: E402 -FIFO = "/tmp/sylph-vgamepad.fifo" +# (the old vgamepad FIFO constant is gone with the server it named) class Pad: - """Talk to the vgamepad server directly over its FIFO. + """Drive Canary's `--hid=file` pad by rewriting its state file. - The `vgamepad` CLI spawns a process per command (~20 ms); a control loop - cannot afford that, and `tap`/`hold` additionally sleep *inside* the server. - Writing lines to the FIFO ourselves keeps a tick under a millisecond. + 🔴 REWRITTEN 2026-08-23, and this is the whole reason `pilot.py` never fired. + This class used to write lines to `/tmp/sylph-vgamepad.fifo`, "the vgamepad + server's FIFO". That server does not exist any more — the uinput pad was + replaced by the `--hid=file` driver, because a uinput device is not + namespaced and every scripted press leaked to the HOST's desktop. The FIFO is + now an ordinary 91-byte file that **nothing reads**, so every axis, trigger + and button this class emitted went nowhere, silently, and the craft flew on + its own for the whole of every "autopilot" run. + + The corpus already carries this exact trap for the MENU path + (`canary-scripted-input-traps.md`: "pad.py, NOT vgamepad … every call here + failed silently"). The shell scripts were fixed then; this class was not, and + every flight tool imports it — `pilot.py`, `autopilot3.py`, `aim_probe.py`, + `flight_probe.py` itself. + + Measured symptom, on a run confirmed to be animating throughout: full left + and full right stick for four seconds each, and the ship's **velocity + direction did not move by 0.01°** — heading change 0.00° in every phase while + it travelled 350–735 units. With this rewrite the same probe is what checks + the fix. + + Format is the driver's own (`file_input_driver.h`): `key=value` pairs, with + `press=A,START`, `lt`/`rt` 0..255 and `lx`/`ly`/`rx`/`ry` -32768..32767. + Written through a temp file and renamed, so a poll can never see a partial + state; the driver re-parses on any nanosecond-mtime or size change. """ + PATH = os.environ.get("XENIA_PAD_FILE", "/tmp/xenia_pad.txt") + def __init__(self): - self.f = open(FIFO, "w", buffering=1) - self.state = {"LX": 0.0, "LY": 0.0, "RX": 0.0, "RY": 0.0, "LT": 0.0, "RT": 0.0} + self.state = {"LX": 0.0, "LY": 0.0, "RX": 0.0, "RY": 0.0, + "LT": 0.0, "RT": 0.0} + self.buttons = set() + self._write() + + def _write(self): + parts = [] + if self.buttons: + parts.append("press=" + ",".join(sorted(self.buttons))) + for k, key in (("LT", "lt"), ("RT", "rt")): + v = int(round(max(0.0, min(1.0, self.state[k])) * 255)) + if v: + parts.append(f"{key}={v}") + for k, key in (("LX", "lx"), ("LY", "ly"), ("RX", "rx"), ("RY", "ry")): + v = int(round(max(-1.0, min(1.0, self.state[k])) * 32767)) + if v: + parts.append(f"{key}={v}") + tmp = self.PATH + ".tmp" + with open(tmp, "w") as f: + f.write(" ".join(parts)) + os.replace(tmp, self.PATH) def axis(self, name, v): self.state[name] = v - self.f.write(f"axis {name} {v:.3f}\n") + self._write() def trig(self, name, v): self.state[name] = v - self.f.write(f"trig {name} {v:.3f}\n") + self._write() def press(self, b): - self.f.write(f"press {b}\n") + self.buttons.add(b.upper()) + self._write() def release(self, b): - self.f.write(f"release {b}\n") + self.buttons.discard(b.upper()) + self._write() def reset(self): - self.f.write("reset\n") for k in self.state: self.state[k] = 0.0 + self.buttons.clear() + self._write() def vector(self): return [self.state[k] for k in ("LX", "LY", "RX", "RY", "LT", "RT")] + # (duration_s, description, action) SCRIPT = [ (3.0, "idle", lambda p: p.reset()), diff --git a/tools/re-capture/selfstate.py b/tools/re-capture/selfstate.py index 27fa3b3..4925374 100644 --- a/tools/re-capture/selfstate.py +++ b/tools/re-capture/selfstate.py @@ -27,6 +27,13 @@ import gmem # noqa: E402 import gworld # noqa: E402 from findrot_global import scan as scan_rot # noqa: E402 +# 🔴 DEAD FIFO. The vgamepad server this names does not exist any more - the +# uinput pad was replaced by Canary's `--hid=file` driver - so every write here +# goes into an ordinary file that nothing reads, silently. See the rewritten +# `Pad` in flight_probe.py for the format the emulator actually polls, and +# docs/re/pilot-never-fires.md for what this cost. NOT fixed here: these scripts +# have not been re-run since, and changing input plumbing under a tool whose +# results are unverified would only produce new unverified results. FIFO = "/tmp/sylph-vgamepad.fifo"