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>
145 lines
5.3 KiB
Python
145 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Ask questions of a ctrl_probe.py capture: which words answer to which input?
|
|
|
|
The capture is a window of the player entity object sampled every tick, tagged
|
|
with the pad state that produced it. That makes the interesting question
|
|
mechanical: for each 4-byte offset, does its value during phase X differ from
|
|
its value during the rest phases either side? A word that only moves while `RT`
|
|
is held is that input's state — a throttle setting, an afterburner tank, a heat
|
|
gauge — and one that moves in *every* phase is just live physics.
|
|
|
|
Sub-commands
|
|
phases per-phase mean of every offset that moves at all
|
|
respond <phase> offsets that move during <phase> and not at rest
|
|
near <value> [tol] offsets whose first sample is ~= value (HUD anchor)
|
|
trace <off> [off...] full time series of specific offsets (pos-relative hex)
|
|
"""
|
|
import os
|
|
import struct
|
|
import sys
|
|
|
|
import numpy as np
|
|
|
|
HDR = b"SYLPHCTR"
|
|
REC = struct.Struct("<d32sff3f")
|
|
|
|
|
|
def load(path):
|
|
with open(path, "rb") as f:
|
|
blob = f.read()
|
|
assert blob[:8] == HDR, "not a ctrl_probe capture"
|
|
n, win, back = struct.unpack_from("<III", blob, 8)
|
|
stride = REC.size + win
|
|
base = 8 + 12
|
|
ts, phase, sp, dodge, pos, wins = [], [], [], [], [], []
|
|
for i in range(n):
|
|
o = base + i * stride
|
|
t, ph, s, dg, x, y, z = REC.unpack_from(blob, o)
|
|
ts.append(t)
|
|
phase.append(ph.split(b"\0")[0].decode())
|
|
sp.append(s)
|
|
dodge.append(dg)
|
|
pos.append((x, y, z))
|
|
wins.append(blob[o + REC.size:o + REC.size + win])
|
|
# A run that ended in GAME OVER keeps sampling a dead object, and those
|
|
# frames dominate every statistic. $BINQ_TMAX truncates the capture to the
|
|
# part that was still flying.
|
|
tmax = float(os.environ.get("BINQ_TMAX", "inf"))
|
|
if tmax < float("inf"):
|
|
keep = [i for i, t in enumerate(ts) if t <= tmax]
|
|
n = len(keep)
|
|
ts = [ts[i] for i in keep]
|
|
phase = [phase[i] for i in keep]
|
|
sp = [sp[i] for i in keep]
|
|
dodge = [dodge[i] for i in keep]
|
|
pos = [pos[i] for i in keep]
|
|
wins = [wins[i] for i in keep]
|
|
A = np.frombuffer(b"".join(wins), dtype=">f4").reshape(n, win // 4).astype(np.float64)
|
|
U = np.frombuffer(b"".join(wins), dtype=">u4").reshape(n, win // 4)
|
|
return dict(n=n, win=win, back=back, t=np.array(ts), phase=phase,
|
|
speed=np.array(sp), dodge=np.array(dodge),
|
|
pos=np.array(pos), F=A, U=U)
|
|
|
|
|
|
def label(d, i):
|
|
return f"pos{i * 4 - d['back']:+#07x}"
|
|
|
|
|
|
def finite(d):
|
|
F = d["F"]
|
|
return np.all(np.isfinite(F), axis=0) & (np.max(np.abs(F), axis=0) < 1e12)
|
|
|
|
|
|
def cmd_phases(d, args):
|
|
ok = finite(d)
|
|
phases = []
|
|
for p in d["phase"]:
|
|
if p not in phases:
|
|
phases.append(p)
|
|
F = d["F"]
|
|
mv = np.zeros(F.shape[1])
|
|
means = {}
|
|
for p in phases:
|
|
m = np.array([x == p for x in d["phase"]])
|
|
means[p] = F[m].mean(axis=0)
|
|
for p in phases:
|
|
mv = np.maximum(mv, np.abs(means[p] - means[phases[0]]))
|
|
idx = np.flatnonzero(ok & (mv > 1e-3))
|
|
order = idx[np.argsort(-mv[idx])][:int(args[0]) if args else 25]
|
|
print("offset " + "".join(f"{p[:8]:>10}" for p in phases))
|
|
for i in order:
|
|
print(f"{label(d, i):<10}" + "".join(f"{means[p][i]:10.2f}" for p in phases))
|
|
|
|
|
|
def cmd_respond(d, args):
|
|
want = args[0]
|
|
F, ok = d["F"], finite(d)
|
|
inp = np.array([p == want for p in d["phase"]])
|
|
rest = np.array([p.startswith("rest") or p == "base" for p in d["phase"]])
|
|
if not inp.any():
|
|
sys.exit(f"no phase {want!r}")
|
|
# A word answering to this input must move *while it is held* and be quiet
|
|
# at rest; a word that also moves at rest is live physics, not the input.
|
|
a = F[inp]
|
|
r = F[rest]
|
|
d_in = a.max(axis=0) - a.min(axis=0)
|
|
d_rest = r.max(axis=0) - r.min(axis=0)
|
|
score = d_in - 2.0 * d_rest
|
|
idx = np.flatnonzero(ok & (d_in > 1e-3) & (score > 0))
|
|
for i in idx[np.argsort(-score[idx])][:20]:
|
|
print(f"{label(d, i):<10} in-phase {a[:, i].min():12.3f}..{a[:, i].max():12.3f}"
|
|
f" at-rest {r[:, i].min():12.3f}..{r[:, i].max():12.3f}")
|
|
if not len(idx):
|
|
print("(nothing moves under this input that is quiet at rest)")
|
|
|
|
|
|
def cmd_near(d, args):
|
|
v = float(args[0])
|
|
tol = float(args[1]) if len(args) > 1 else max(1e-3, abs(v) * 1e-3)
|
|
F, U = d["F"], d["U"]
|
|
for i in np.flatnonzero(np.abs(F[0] - v) <= tol):
|
|
print(f"{label(d, i):<10} f32 {F[0, i]:.4f} -> {F[-1, i]:.4f}")
|
|
for i in np.flatnonzero(np.abs(U[0].astype(np.float64) - v) <= tol):
|
|
print(f"{label(d, i):<10} u32 {U[0, i]} -> {U[-1, i]}")
|
|
|
|
|
|
def cmd_trace(d, args):
|
|
offs = [int(a, 0) for a in args]
|
|
idx = [(o + d["back"]) // 4 for o in offs]
|
|
print("t phase speed " + " ".join(f"{o:+#07x}" for o in offs))
|
|
for k in range(d["n"]):
|
|
print(f"{d['t'][k]:6.2f} {d['phase'][k]:<12} {d['speed'][k]:6.0f} "
|
|
+ " ".join(f"{d['F'][k, i]:8.2f}" for i in idx))
|
|
|
|
|
|
def main():
|
|
d = load(sys.argv[1])
|
|
print(f"# {d['n']} ticks, window {d['win']:#x} bytes, back {d['back']:#x}")
|
|
cmd = sys.argv[2] if len(sys.argv) > 2 else "phases"
|
|
{"phases": cmd_phases, "respond": cmd_respond, "near": cmd_near,
|
|
"trace": cmd_trace}[cmd](d, sys.argv[3:])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|