Files
Sylpheed/tools/re-capture/flight_probe.py
Sylpheed RE agent 26421d5266 docs+tools: the per-entity OB flag is refuted, and the counter decrements when the player kills
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
2026-08-23 23:21:08 +00:00

183 lines
6.7 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 350735 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()