#!/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 [hold_s] [settle_s] """ import json import math import os import struct import sys 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(" 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()