With the pilot finally shooting, the experiment completed both halves. Sample A at counter 12 over 120 entities gave 2 offsets where exactly 12 entities share a value; the counter then went 12 -> 11 and NEITHER survived. So within +-0x400 of an entity's position triple there is no 4-byte word whose shared-value population tracks REMAINING OB. The limits are recorded as part of the result, because they bound it: the test asks which entities share an EXACT 32-bit value, so a single bit ORed into a word that also carries health or a timer would never show up - a bit-level version of the same differential is the follow-on. Anything outside the window, or on entities that entities2 cannot see (it types by position CHANGING, so stationary objectives are invisible), is untested too, and the populations differed a lot between samples - 120 against 194. Separately: REMAINING OB went 12 -> 11, the first decrement of this whole investigation, while pilot.py logged 411 fire=1 samples and the HUD reached YOU KILLED WARPLANES 0003. Stated carefully - it does NOT show the counter counts kills, since an earlier run had the hostile population fall by a third with no movement; it shows some kills close something the counter tracks. Two robustness fixes: ob_flag retries an empty entity sample (one void run was caused by exactly that), and Pad releases everything on interpreter exit - a file-backed pad PERSISTS after its writer dies, so a tool killed mid-press would leave a button held and the game would walk through menus on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
183 lines
6.7 KiB
Python
Executable File
183 lines
6.7 KiB
Python
Executable File
#!/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 <out.bin> [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("<III", len(inst), gworld.WINDOW, 6))
|
||
for va, off, nm in inst:
|
||
f.write(struct.pack("<IQ", va, off) + nm.encode()[:63].ljust(64, b"\0"))
|
||
t_start = time.time()
|
||
for dur, label, act in SCRIPT:
|
||
act(pad)
|
||
print(f"# {label} ({dur}s)", flush=True)
|
||
n = int(dur * hz)
|
||
for _ in range(n):
|
||
t = time.time()
|
||
f.write(struct.pack("<d", t - t_start))
|
||
f.write(struct.pack("<6f", *pad.vector()))
|
||
f.write(struct.pack("<32s", label.encode()[:32]))
|
||
for va, off, nm in inst:
|
||
f.write(w.read_off(off, gworld.WINDOW).ljust(gworld.WINDOW, b"\0"))
|
||
time.sleep(max(0, 1.0 / hz - (time.time() - t)))
|
||
pad.reset()
|
||
print(f"# wrote {out} ({os.path.getsize(out)/1e6:.1f} MB)", flush=True)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|