Measured, not argued: command a 45-degree-off-the-nose error and watch whether it
shrinks, both axes, both sides, two pulse widths, with the opposite sign as a
control. Yaw's sign is correct (45 -> 11.3/32.7 at 0.6 s, 41.6/33.8 at 1.2 s).
Pitch's is inverted - the pilot's own sign GREW the error every time
(48.3/55.6/70.7/91.7) and the opposite shrank it every time (30.7/41.5/15.7/9.6).
A method artefact is recorded because it gave the opposite answer first: a 3 s
full-deflection pulse overshoots a 45-degree error so far that BOTH signs look
wrong (45 -> 164 and 45 -> 178). A long pulse cannot answer a sign question.
Verified against the game rather than by inspection. Before: fire=1 in 0 of 13521
samples, |aim yaw| pinned at 90.0, target 36-43 km away. After: 43 of 1732, aim
down to 2.3 degrees, range median 6.3 km, and the HUD's own ammunition counters
moving - NOSE BM 06000 -> 05723, MAIN MPM 00300 -> 00298.
Also fixed a leftover of the same FIFO era: pilot.py called pad.f.write("tap A
90") for the target-select double tap, which raised AttributeError once Pad
stopped having an `f`. Pad gained tap()/dpad(); ctrl_probe.py and target_probe.py
still use pad.f and now say so in place.
Still open: YOU KILLED is 0000 after 250 s of firing and REMAINING OB is still
012. The craft shoots, closes and selects; whether it destroys anything is next.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
279 lines
11 KiB
Python
279 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Which control is the throttle, and where does the craft keep its own state?
|
|
|
|
Two questions, one flight. Both are answered by *consequence* rather than by
|
|
reading a field we hope is the right one:
|
|
|
|
* **Throttle.** The craft's speed is measured from its own position — a finite
|
|
difference on the position triple in guest RAM — so no speed field has to be
|
|
found first. The probe then holds each candidate input in turn and asks which
|
|
one changes that measured speed. (`findspeed.py` failed the other way round:
|
|
it assumed `RT` was the throttle and went looking for a field that rose.)
|
|
|
|
* **Own state.** Every tick also copies a window of the player entity object.
|
|
Afterwards, offsets whose float value tracks the measured speed are candidate
|
|
speed/throttle fields, and offsets that only ever *fall* are candidate
|
|
hull/shield/ammo — the numbers survival needs.
|
|
|
|
Flying straight into a firefight for 90 s is how earlier runs died, so the loop
|
|
keeps the collision avoidance from navigator.py armed the whole time and marks
|
|
any sample where it had to intervene: a phase that had to dodge is not a clean
|
|
speed measurement, and says so rather than being quietly averaged in.
|
|
|
|
Usage: ctrl_probe.py <config.json> <out-prefix> [hold_s] [settle_s]
|
|
"""
|
|
import json
|
|
import math
|
|
import os
|
|
import struct
|
|
import sys
|
|
|
|
# 🔴 DEAD FIFO HANDLE: this file uses `pad.f.write(...)`, the old vgamepad
|
|
# server protocol. flight_probe.Pad no longer has an `f` — it writes Canary's
|
|
# --hid=file pad file instead — so these calls now raise AttributeError. Use
|
|
# pad.tap()/pad.dpad(). Not converted here: neither tool has been re-run since.
|
|
# See docs/re/pilot-never-fires.md.
|
|
import time
|
|
|
|
import numpy as np
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import gmem # noqa: E402
|
|
import navigator # noqa: E402
|
|
from flight_probe import Pad # noqa: E402
|
|
|
|
# 0x800 each way: the shield lives at pos+0x430 (own_state.py), which a
|
|
# 0x400 window silently cropped out of the first capture.
|
|
WIN_BACK = 0x800 # bytes of the player object kept before the position
|
|
WIN_FWD = 0x800 # ...and after
|
|
WIN = WIN_BACK + WIN_FWD
|
|
HZ = 20.0
|
|
|
|
# (label, [pad commands]) — everything the pad can do that might be a throttle.
|
|
# RB is left out: it is the fire button (autopilot-memory-driven.md) and firing
|
|
# during a speed measurement only invites return fire.
|
|
CANDIDATES = [
|
|
("RT", [("trig", "RT", 1.0)]),
|
|
("LT", [("trig", "LT", 1.0)]),
|
|
("RT+LT", [("trig", "RT", 1.0), ("trig", "LT", 1.0)]),
|
|
("A", [("press", "A")]),
|
|
("B", [("press", "B")]),
|
|
("X", [("press", "X")]),
|
|
("Y", [("press", "Y")]),
|
|
("LB", [("press", "LB")]),
|
|
("LS", [("press", "LS")]),
|
|
("RS", [("press", "RS")]),
|
|
("RY_up", [("axis", "RY", -1.0)]),
|
|
("RY_down", [("axis", "RY", 1.0)]),
|
|
("RX_right", [("axis", "RX", 1.0)]),
|
|
("dpad_up", [("dpad", "up")]),
|
|
("dpad_down", [("dpad", "down")]),
|
|
("dpad_left", [("dpad", "left")]),
|
|
("dpad_right", [("dpad", "right")]),
|
|
]
|
|
|
|
|
|
def apply(pad, cmds):
|
|
for c in cmds:
|
|
if c[0] == "trig":
|
|
pad.trig(c[1], c[2])
|
|
elif c[0] == "axis":
|
|
pad.axis(c[1], c[2])
|
|
elif c[0] == "press":
|
|
pad.press(c[1])
|
|
elif c[0] == "dpad":
|
|
pad.f.write(f"dpad {c[1]}\n")
|
|
|
|
|
|
def clear(pad):
|
|
pad.reset()
|
|
pad.f.write("dpad center\n")
|
|
|
|
|
|
class Run:
|
|
def __init__(self, cfg, prefix, hold, settle):
|
|
self.W = navigator.World(cfg)
|
|
self.nav = navigator.Navigator(self.W, None, dry=True)
|
|
self.prefix = prefix
|
|
self.hold = hold
|
|
self.settle = settle
|
|
self.pad = Pad()
|
|
self.rows = [] # (t, phase, pos, speed, dodged)
|
|
self.win = [] # raw window bytes per tick
|
|
ents = self.W.scan()
|
|
me = [(off, va) for off, va in ents if "Player" in self.W.defs[va]]
|
|
if not me:
|
|
sys.exit("player entity not in the scan — not in flight?")
|
|
self.me_off = me[0][0]
|
|
self.me_name = self.W.defs[me[0][1]]
|
|
print(f"# player {self.me_name} pos off {self.me_off:#x} "
|
|
f"va {gmem.primary_va(self.me_off):#x} | {len(ents)} entities",
|
|
flush=True)
|
|
|
|
# ------------------------------------------------------------- helpers
|
|
def pos(self):
|
|
return self.W.pos(self.me_off)
|
|
|
|
def sticks_for(self, vec):
|
|
"""Stick deflections that point the nose along `vec` (escape steering)."""
|
|
M = self.W.rot(self.me_off)
|
|
if M is None:
|
|
return 0.0, 0.0
|
|
fwd = M[self.W.fwd_row] * self.W.fwd_sign
|
|
right = M[(self.W.fwd_row + 1) % 3]
|
|
up = np.cross(fwd, right)
|
|
ez = float(np.dot(vec, fwd))
|
|
yaw = math.atan2(float(np.dot(vec, right)), ez if abs(ez) > 1e-3 else 1e-3)
|
|
pitch = math.atan2(float(np.dot(vec, up)), ez if abs(ez) > 1e-3 else 1e-3)
|
|
return (max(-1.0, min(1.0, 2.0 * yaw)), max(-1.0, min(1.0, -2.0 * pitch)))
|
|
|
|
# ---------------------------------------------------------------- phase
|
|
def phase(self, label, cmds, secs):
|
|
clear(self.pad)
|
|
apply(self.pad, cmds)
|
|
t_end = time.time() + secs
|
|
dodged = False
|
|
while time.time() < t_end:
|
|
t = time.time()
|
|
p = self.pos()
|
|
if p is None:
|
|
break
|
|
self.rows.append([t, label, p, 0.0, 0])
|
|
self.win.append(os.pread(self.W.fd, WIN, self.me_off - WIN_BACK))
|
|
|
|
# avoidance runs at 5 Hz; a real threat overrides the phase and the
|
|
# samples from here on are flagged
|
|
if len(self.rows) % 4 == 0:
|
|
ents = self.W.sample(t)
|
|
me = [e for e in ents if e[0] == self.me_off]
|
|
if me:
|
|
_, _, mp, mv, mr = me[0]
|
|
push, worst = self.nav.avoidance(mp, mv, mr, ents, self.me_off)
|
|
if float(np.linalg.norm(push)) > 0.6:
|
|
sx, sy = self.sticks_for(navigator.norm(push))
|
|
self.pad.axis("LX", sx)
|
|
self.pad.axis("LY", sy)
|
|
dodged = True
|
|
self.rows[-1][4] = 1
|
|
elif dodged:
|
|
self.pad.axis("LX", 0.0)
|
|
self.pad.axis("LY", 0.0)
|
|
time.sleep(max(0.0, 1.0 / HZ - (time.time() - t)))
|
|
return dodged
|
|
|
|
def run(self):
|
|
t0 = time.time()
|
|
self.phase("base", [], self.settle * 2)
|
|
for label, cmds in CANDIDATES:
|
|
d = self.phase(label, cmds, self.hold)
|
|
self.phase(f"rest_{label}", [], self.settle)
|
|
sp = self.phase_speed(label)
|
|
print(f"[{time.time()-t0:6.1f}] {label:<10} "
|
|
f"v0={sp[0]:7.1f} v1={sp[1]:7.1f} d={sp[1]-sp[0]:+7.1f}"
|
|
f"{' (dodged)' if d else ''}", flush=True)
|
|
clear(self.pad)
|
|
self.speeds()
|
|
self.dump()
|
|
|
|
# ------------------------------------------------------------ analysis
|
|
def speeds(self):
|
|
"""Fill in per-tick speed by central difference on position."""
|
|
for i, r in enumerate(self.rows):
|
|
j, k = max(0, i - 2), min(len(self.rows) - 1, i + 2)
|
|
dt = self.rows[k][0] - self.rows[j][0]
|
|
if dt > 1e-3:
|
|
r[3] = float(np.linalg.norm(self.rows[k][2] - self.rows[j][2])) / dt
|
|
|
|
def phase_speed(self, label):
|
|
"""(speed early, speed late) within a phase — needs speeds() first."""
|
|
self.speeds()
|
|
v = [r[3] for r in self.rows if r[1] == label]
|
|
if len(v) < 6:
|
|
return (0.0, 0.0)
|
|
n = max(2, len(v) // 4)
|
|
return (float(np.mean(v[:n])), float(np.mean(v[-n:])))
|
|
|
|
def dump(self):
|
|
with open(self.prefix + ".csv", "w") as f:
|
|
f.write("t,phase,x,y,z,speed,dodged\n")
|
|
t0 = self.rows[0][0]
|
|
for t, ph, p, sp, dg in self.rows:
|
|
f.write(f"{t-t0:.3f},{ph},{p[0]:.3f},{p[1]:.3f},{p[2]:.3f},"
|
|
f"{sp:.3f},{dg}\n")
|
|
with open(self.prefix + ".bin", "wb") as f:
|
|
f.write(b"SYLPHCTR")
|
|
f.write(struct.pack("<III", len(self.rows), WIN, WIN_BACK))
|
|
t0 = self.rows[0][0]
|
|
for (t, ph, p, sp, dg), w in zip(self.rows, self.win):
|
|
f.write(struct.pack("<d32sff3f", t - t0, ph.encode()[:32], sp,
|
|
float(dg), *[float(c) for c in p]))
|
|
f.write(w.ljust(WIN, b"\0"))
|
|
print(f"# wrote {self.prefix}.csv and {self.prefix}.bin "
|
|
f"({len(self.rows)} ticks)", flush=True)
|
|
|
|
# ---- per-phase summary
|
|
print("\n# phase n v_early v_late delta dodged")
|
|
order, seen = [], set()
|
|
for r in self.rows:
|
|
if r[1] not in seen:
|
|
seen.add(r[1])
|
|
order.append(r[1])
|
|
for ph in order:
|
|
v = [r[3] for r in self.rows if r[1] == ph]
|
|
dg = sum(r[4] for r in self.rows if r[1] == ph)
|
|
if len(v) < 6:
|
|
continue
|
|
n = max(2, len(v) // 4)
|
|
a, b = float(np.mean(v[:n])), float(np.mean(v[-n:]))
|
|
print(f" {ph:<12} {len(v):4d} {a:9.1f} {b:9.1f} {b-a:+9.1f} {dg:5d}")
|
|
|
|
# ---- which words in the object track speed, and which only fall
|
|
W_ = np.frombuffer(b"".join(x.ljust(WIN, b"\0") for x in self.win),
|
|
dtype=">f4").reshape(len(self.win), WIN // 4)
|
|
sp = np.array([r[3] for r in self.rows], dtype=np.float64)
|
|
with np.errstate(invalid="ignore", over="ignore"):
|
|
A = W_.astype(np.float64)
|
|
ok = np.all(np.isfinite(A), axis=0) & (np.max(np.abs(A), axis=0) < 1e9)
|
|
var = np.std(A, axis=0)
|
|
cand = np.flatnonzero(ok & (var > 1e-6))
|
|
cor = []
|
|
for i in cand:
|
|
c = np.corrcoef(A[:, i], sp)[0, 1]
|
|
if np.isfinite(c):
|
|
cor.append((abs(c), c, i))
|
|
cor.sort(reverse=True)
|
|
print("\n# object words correlating with measured speed "
|
|
"(offset relative to the position triple)")
|
|
for ac, c, i in cor[:12]:
|
|
off = i * 4 - WIN_BACK
|
|
print(f" pos{off:+#07x} r={c:+.3f} "
|
|
f"range {A[:, i].min():.3f} .. {A[:, i].max():.3f}")
|
|
|
|
print("\n# words that never rise (candidate hull / shield / ammo)")
|
|
shown = 0
|
|
for i in cand:
|
|
col = A[:, i]
|
|
if col[-1] >= col[0] - 1e-6:
|
|
continue
|
|
if np.max(np.diff(col)) > 1e-6:
|
|
continue
|
|
off = i * 4 - WIN_BACK
|
|
print(f" pos{off:+#07x} {col[0]:.3f} -> {col[-1]:.3f}")
|
|
shown += 1
|
|
if shown >= 12:
|
|
break
|
|
if not shown:
|
|
print(" (none — nothing in the window decreased monotonically)")
|
|
|
|
|
|
def main():
|
|
cfg = json.load(open(sys.argv[1]))
|
|
prefix = sys.argv[2]
|
|
hold = float(sys.argv[3]) if len(sys.argv) > 3 else 4.0
|
|
settle = float(sys.argv[4]) if len(sys.argv) > 4 else 2.5
|
|
Run(cfg, prefix, hold, settle).run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|