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"