#!/usr/bin/env python3 """Drive a scripted input sequence in flight while sampling guest RAM, so the player craft's live transform can be *derived* rather than guessed. The point is correlation: each sample is tagged with the stick/trigger state that produced it, so afterwards we can ask "which floats move only while the yaw axis is deflected?" and "which triple integrates to the velocity?". Writes a .npz-ish flat binary: a header of (va, offset, name) per instance, then per frame a timestamp, the input vector, and every instance's raw window. Usage: flight_probe.py [seconds] """ import atexit import os import struct import sys import time sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import gworld # noqa: E402 # (the old vgamepad FIFO constant is gone with the server it named) class Pad: """Drive Canary's `--hid=file` pad by rewriting its state file. 🔴 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.state = {"LX": 0.0, "LY": 0.0, "RX": 0.0, "RY": 0.0, "LT": 0.0, "RT": 0.0} self.buttons = set() self._write() # A file-backed pad has a failure mode the FIFO did not: the state # PERSISTS after the writer dies. A tool killed mid-press leaves the # button held forever, and the game then walks through menus on its own # with nobody touching it. Release everything on exit. atexit.register(self.reset) 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._write() def trig(self, name, v): self.state[name] = v self._write() def press(self, b): self.buttons.add(b.upper()) self._write() def release(self, b): self.buttons.discard(b.upper()) self._write() def reset(self): for k in self.state: self.state[k] = 0.0 self.buttons.clear() self._write() def tap(self, b, secs=0.09): """Press and release, blocking for `secs`. The old FIFO server slept on the *server* side, so callers wrote `tap A 90` and carried on; `pilot.py` still did that through `pad.f`, which this class no longer has. Here the caller pays the 90 ms. That is acceptable only because the one caller — the double-tap that selects a target — is rate-limited; do not put this in a per-tick path. """ self.press(b) time.sleep(secs) self.release(b) def dpad(self, direction, secs=0.06): """One d-pad step; `center` releases all four.""" for d in ("UP", "DOWN", "LEFT", "RIGHT"): self.buttons.discard(d) if direction.lower() == "center": self._write() return self.tap(direction.upper(), secs) 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()), (4.0, "pitch-up", lambda p: p.axis("LY", -0.9)), (2.0, "idle2", lambda p: p.reset()), (4.0, "yaw-right", lambda p: p.axis("LX", 0.9)), (2.0, "idle3", lambda p: p.reset()), (4.0, "yaw-left", lambda p: p.axis("LX", -0.9)), (2.0, "idle4", lambda p: p.reset()), (5.0, "boost", lambda p: p.trig("RT", 1.0)), (3.0, "idle5", lambda p: p.reset()), ] def main(): out = sys.argv[1] w = gworld.World() inst = w.refresh() print(f"# {len(inst)} instances", flush=True) if not inst: sys.exit("no instances — not in a mission?") pad = Pad() hz = 10.0 with open(out, "wb") as f: f.write(b"SYLPHPRB") f.write(struct.pack("