Three measurements, then a pilot built on them. * Hull is position+0x154. Found by anchoring on a field the definition already had solved (HP = 1500) rather than scanning for a value that falls: an undamaged craft must contain its own definition's number. Confirmed by the trace across a death -- 30/60/90 per hit, negative at 0, GAME OVER on screen. * RT accelerates, LT brakes, and the throttle is a persistent setting (488 -> 1510 -> 174 units/s, measured as displacement per second of the craft's own position, so no speed field was needed). This overturns the earlier "RT is not the throttle", which came from assuming the control and hunting for a field. * Shield is probably position+0x430 (== definition MaxValue 400), not yet confirmed live -- nothing had damaged it. pilot.py is a state machine on damage (ENGAGE / EVADE / RETIRE) that treats turrets as keep-out zones instead of targets. It flew Stage 02 for 300 s with the hull untouched at 1500/1500 and took the first confirmed kill (WARPLANES 0001); every run the day before was dead inside 35 s. Two bugs the live run exposed and this fixes: gating the guns on the commanded direction keeps them cold whenever avoidance is steering (gate on the target instead), and an orbit-plus-brake rule made it circle one attacker for 40 s outside its own firing cone. Also: boot to in-flight is now ~100 s unattended, because wait_flight.sh waits for the HUD's own shield bar instead of a fixed 75 s sleep that lavapipe does not honour; entities2.py picks the attitude block by matching the measured flight path (taking the first orthonormal block gave a bone/camera frame); and the entity-heap scan is numpy instead of a per-word Python loop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
273 lines
11 KiB
Python
273 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
|
|
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()
|