re: the autopilot now survives, and kills
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>
This commit is contained in:
319
tools/re-capture/pilot.py
Normal file
319
tools/re-capture/pilot.py
Normal file
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A pilot that tries to stay alive, not just to shoot.
|
||||
|
||||
Every earlier loop flew a straight pursuit and was shot down; the trace from
|
||||
ctrl_probe.py shows why — 1500 hull points gone in twelve seconds while sitting
|
||||
in a turret's line of fire, with no reaction of any kind. Three measured facts
|
||||
make a reaction possible:
|
||||
|
||||
* **Hull is `position + 0x154`** — at spawn it equals the unit definition's own
|
||||
`HP` (1500 for the Delta Saber), it steps down 30/60/90 per hit, and it goes
|
||||
negative at death. So damage is observable *as it happens*, not inferred.
|
||||
* **`RT` accelerates and `LT` brakes**, and the setting persists: measured
|
||||
ground speed went 488 → 1510 under RT, 488 → 174 under LT and then *stayed*
|
||||
near 130 with the sticks neutral. (The older note "RT is not the throttle" was
|
||||
drawn from a value-scan for a speed field, not from measuring the speed.)
|
||||
* **`RB` fires** (autopilot-memory-driven.md).
|
||||
|
||||
So the loop is a state machine on damage rather than a pure pursuit:
|
||||
|
||||
ENGAGE chase and shoot the nearest hostile fighter
|
||||
EVADE entered the moment the hull drops — turn away from the threats,
|
||||
full throttle, jink; leave only after several quiet seconds
|
||||
RETIRE hull below a floor: break for the friendly capital ship, which the
|
||||
mission's own hint says is where you resupply
|
||||
|
||||
Turrets are treated as threats to be *kept at a distance*, not as targets: the
|
||||
objective is the invading fighters, and the turret is what killed every previous
|
||||
run.
|
||||
|
||||
Usage: pilot.py <config.json> [seconds] [--dry]
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import navigator # noqa: E402
|
||||
from navigator import ang, norm # noqa: E402
|
||||
from flight_probe import Pad # noqa: E402
|
||||
|
||||
HULL_OFF = 0x154 # confirmed: == definition HP at spawn, falls when hit
|
||||
SHIELD_OFF = 0x430 # candidate: == definition Shield MaxValue at spawn
|
||||
|
||||
|
||||
class Pilot:
|
||||
KP, KD = 2.2, 0.45
|
||||
FIRE_CONE = math.radians(9)
|
||||
FIRE_RANGE = 5000.0
|
||||
TURRET_KEEPOUT = 2500.0 # ...and stay this far from things that shoot back
|
||||
EVADE_QUIET = 5.0 # seconds without damage before re-engaging
|
||||
RETIRE_FRAC = 0.30 # hull fraction that sends us home
|
||||
HZ = 8.0
|
||||
|
||||
def __init__(self, W, pad, dry=False, log=sys.stdout):
|
||||
self.W = W
|
||||
self.pad = pad
|
||||
self.dry = dry
|
||||
self.log = log
|
||||
# closest-point-of-approach avoidance is navigator.py's, reused as-is
|
||||
self.av = navigator.Navigator(W, pad, dry=True, log=log)
|
||||
self.prevM = None
|
||||
self.firing = False
|
||||
self.throttle = 0 # -1 brake, 0 coast, +1 accelerate
|
||||
self.mode = "ENGAGE"
|
||||
self.hp_hist = deque(maxlen=64)
|
||||
self.hp0 = None
|
||||
self.last_hit = -1e9
|
||||
self.threat_dir = None
|
||||
|
||||
# ------------------------------------------------------------ own state
|
||||
def own(self, off):
|
||||
b = os.pread(self.W.fd, 8, off + HULL_OFF)
|
||||
hull = struct.unpack_from(">f", b, 0)[0] if len(b) >= 4 else float("nan")
|
||||
b2 = os.pread(self.W.fd, 4, off + SHIELD_OFF)
|
||||
shield = struct.unpack(">f", b2)[0] if len(b2) == 4 else float("nan")
|
||||
return hull, shield
|
||||
|
||||
def set_throttle(self, want):
|
||||
"""RT / LT are a persistent setting, so only send the change."""
|
||||
if want == self.throttle or self.dry:
|
||||
return
|
||||
self.pad.trig("RT", 1.0 if want > 0 else 0.0)
|
||||
self.pad.trig("LT", 1.0 if want < 0 else 0.0)
|
||||
self.throttle = want
|
||||
|
||||
# -------------------------------------------------------------- targets
|
||||
def hostiles(self, ents, me_off):
|
||||
out = []
|
||||
for off, nm, p, v, r in ents:
|
||||
if off == me_off or navigator.faction(nm) != "ADAN":
|
||||
continue
|
||||
out.append((off, nm, p, v, r, "Turret" in nm or r >= navigator.Navigator.BIG_RADIUS))
|
||||
return out
|
||||
|
||||
def pick(self, me_p, me_v, fwd, hos):
|
||||
"""Nearest *fighter*, weighted by how far off the nose it is."""
|
||||
speed = max(float(np.linalg.norm(me_v)), 1.0)
|
||||
best, bestscore = None, 1e18
|
||||
for off, nm, p, v, r, hard in hos:
|
||||
if hard:
|
||||
continue # turrets and hulls are not the objective
|
||||
rel = p - me_p
|
||||
d = float(np.linalg.norm(rel))
|
||||
if d < 1e-3:
|
||||
continue
|
||||
lead = p + v * (d / max(speed, 300.0))
|
||||
theta = ang(lead - me_p, fwd)
|
||||
score = d * (1.0 + 3.0 * (theta / math.pi) ** 2)
|
||||
if score < bestscore:
|
||||
best, bestscore = (off, nm, lead, lead - me_p, d), score
|
||||
return best
|
||||
|
||||
def threat_vector(self, me_p, hos):
|
||||
"""Where the danger is: inverse-square weighted direction to shooters."""
|
||||
acc = np.zeros(3)
|
||||
for off, nm, p, v, r, hard in hos:
|
||||
rel = p - me_p
|
||||
d = float(np.linalg.norm(rel))
|
||||
if d < 1.0 or d > 8000.0:
|
||||
continue
|
||||
w = (1500.0 / d) ** 2 * (3.0 if hard else 1.0)
|
||||
acc += norm(rel) * w
|
||||
return norm(acc) if np.linalg.norm(acc) > 1e-6 else None
|
||||
|
||||
def friendly_base(self, ents, me_off):
|
||||
"""The biggest friendly — the carrier the briefing says to resupply at."""
|
||||
best = None
|
||||
for off, nm, p, v, r in ents:
|
||||
if off == me_off or navigator.faction(nm) != "TCAF":
|
||||
continue
|
||||
if best is None or r > best[4]:
|
||||
best = (off, nm, p, v, r)
|
||||
return best
|
||||
|
||||
# ------------------------------------------------------------ steering
|
||||
def sticks(self, want, M, w):
|
||||
fwd = M[self.W.fwd_row] * self.W.fwd_sign
|
||||
right = M[(self.W.fwd_row + 1) % 3]
|
||||
up = np.cross(fwd, right)
|
||||
ex, ey, ez = (float(np.dot(want, right)), float(np.dot(want, up)),
|
||||
float(np.dot(want, fwd)))
|
||||
yaw = math.atan2(ex, ez if abs(ez) > 1e-3 else 1e-3)
|
||||
pitch = math.atan2(ey, ez if abs(ez) > 1e-3 else 1e-3)
|
||||
if ez < 0: # target behind: commit to a full turn
|
||||
yaw = math.copysign(math.pi / 2, ex if ex else 1.0)
|
||||
sx = max(-1.0, min(1.0, self.KP * yaw - self.KD * float(np.dot(w, up))))
|
||||
sy = max(-1.0, min(1.0, -(self.KP * pitch - self.KD * float(np.dot(w, right)))))
|
||||
return sx, sy, yaw, pitch
|
||||
|
||||
# ---------------------------------------------------------------- step
|
||||
def step(self, t, dt):
|
||||
ents = self.W.sample(t)
|
||||
me = next((e for e in ents if "Player" in e[1]), None)
|
||||
if me is None:
|
||||
return None
|
||||
me_off, me_nm, me_p, me_v, me_r = me
|
||||
M = self.W.rot(me_off)
|
||||
if M is None:
|
||||
return "no-orientation"
|
||||
fwd = M[self.W.fwd_row] * self.W.fwd_sign
|
||||
right = M[(self.W.fwd_row + 1) % 3]
|
||||
up = np.cross(fwd, right)
|
||||
speed = float(np.linalg.norm(me_v))
|
||||
|
||||
hull, shield = self.own(me_off)
|
||||
if self.hp0 is None and math.isfinite(hull) and hull > 0:
|
||||
self.hp0 = hull
|
||||
self.hp_hist.append((t, hull))
|
||||
# damage over the last ~2 s; the hull only ever falls, so any drop is a hit
|
||||
recent = [h for (ts, h) in self.hp_hist if t - ts <= 2.0]
|
||||
dmg = (max(recent) - hull) if recent else 0.0
|
||||
if dmg > 0.5:
|
||||
self.last_hit = t
|
||||
if hull <= 0:
|
||||
return "DEAD"
|
||||
|
||||
# body angular velocity, for the damping term
|
||||
w = np.zeros(3)
|
||||
if self.prevM is not None and dt > 1e-3:
|
||||
D = self.prevM @ M.T
|
||||
w = np.array([D[2, 1] - D[1, 2], D[0, 2] - D[2, 0], D[1, 0] - D[0, 1]]) / (2 * dt)
|
||||
self.prevM = M
|
||||
|
||||
hos = self.hostiles(ents, me_off)
|
||||
self.threat_dir = self.threat_vector(me_p, hos)
|
||||
frac = hull / self.hp0 if self.hp0 else 1.0
|
||||
|
||||
# ---- mode
|
||||
if frac <= self.RETIRE_FRAC:
|
||||
self.mode = "RETIRE"
|
||||
elif t - self.last_hit < self.EVADE_QUIET:
|
||||
self.mode = "EVADE"
|
||||
else:
|
||||
self.mode = "ENGAGE"
|
||||
|
||||
tgt = self.pick(me_p, me_v, fwd, hos)
|
||||
push, worst = self.av.avoidance(me_p, me_v, me_r, ents, me_off)
|
||||
|
||||
if self.mode == "EVADE":
|
||||
# Away from the guns, plus a jink so a straight escape line is not
|
||||
# itself an easy solution for whatever is shooting.
|
||||
away = -self.threat_dir if self.threat_dir is not None else fwd
|
||||
jink = right * math.sin(t * 1.7) * 0.5 + up * math.cos(t * 2.3) * 0.35
|
||||
want = norm(away + jink)
|
||||
self.set_throttle(+1)
|
||||
fire = False
|
||||
elif self.mode == "RETIRE":
|
||||
base = self.friendly_base(ents, me_off)
|
||||
if base is not None:
|
||||
rel = base[2] - me_p
|
||||
d = float(np.linalg.norm(rel))
|
||||
want = norm(rel) if d > base[4] + 400.0 else norm(np.cross(rel, up))
|
||||
else:
|
||||
want = -self.threat_dir if self.threat_dir is not None else fwd
|
||||
self.set_throttle(+1)
|
||||
fire = False
|
||||
else:
|
||||
# Straight lead pursuit and fly *through*. An earlier version orbited
|
||||
# once inside a standoff radius and braked while doing it: it then
|
||||
# circled one attacker for 40 s at ~700 m, at 60-100 units/s, never
|
||||
# inside the firing cone. Overshooting and re-acquiring is better
|
||||
# than a stall in the middle of a battle; collision avoidance already
|
||||
# keeps a fighter-sized margin.
|
||||
want = norm(tgt[3]) if tgt else fwd
|
||||
if tgt and tgt[4] > 2500.0:
|
||||
self.set_throttle(+1)
|
||||
elif tgt and tgt[4] < 500.0 and speed > 900.0:
|
||||
self.set_throttle(-1)
|
||||
else:
|
||||
self.set_throttle(0)
|
||||
fire = True
|
||||
|
||||
# a turret inside its keep-out radius outranks the target
|
||||
for off, nm, p, v, r, hard in hos:
|
||||
if not hard:
|
||||
continue
|
||||
d = float(np.linalg.norm(p - me_p))
|
||||
if d < self.TURRET_KEEPOUT:
|
||||
want = norm(want + norm(me_p - p) * (2.0 * (1.0 - d / self.TURRET_KEEPOUT)))
|
||||
break
|
||||
|
||||
pn = float(np.linalg.norm(push))
|
||||
if pn > 1e-6:
|
||||
want = norm(want + push * (3.0 if pn > 0.6 else 1.5))
|
||||
|
||||
sx, sy, yaw, pitch = self.sticks(want, M, w)
|
||||
# The firing gate has to be measured against the TARGET, not against the
|
||||
# commanded direction: `want` carries the avoidance and keep-out terms,
|
||||
# so gating on it means the guns stay cold exactly when the loop is
|
||||
# manoeuvring — which is most of a dogfight.
|
||||
aim = self.sticks(norm(tgt[3]), M, w)[2:] if tgt else (math.pi, math.pi)
|
||||
aim_ok = abs(aim[0]) < self.FIRE_CONE and abs(aim[1]) < self.FIRE_CONE
|
||||
fire = bool(fire and tgt and aim_ok and tgt[4] < self.FIRE_RANGE and pn < 1.2)
|
||||
|
||||
if not self.dry:
|
||||
self.pad.axis("LX", sx)
|
||||
self.pad.axis("LY", sy)
|
||||
if fire != self.firing:
|
||||
(self.pad.press if fire else self.pad.release)("RB")
|
||||
self.firing = fire
|
||||
|
||||
msg = (f"{self.mode:<7} hull={hull:6.0f} shd={shield:6.0f} spd={speed:6.0f} "
|
||||
f"thr={self.throttle:+d} yaw={math.degrees(yaw):+6.1f} "
|
||||
f"pit={math.degrees(pitch):+6.1f} aim={math.degrees(aim[0]):+6.1f}"
|
||||
f"/{math.degrees(aim[1]):+6.1f} fire={int(fire)}")
|
||||
if dmg > 0.5:
|
||||
msg += f" HIT -{dmg:.0f}"
|
||||
if tgt:
|
||||
msg += f" tgt={tgt[1][3:24]:<21} d={tgt[4]:6.0f}"
|
||||
if worst:
|
||||
msg += f" | AVOID {worst[1][3:18]} miss={worst[3]:5.0f} t={worst[4]:4.1f}"
|
||||
return msg
|
||||
|
||||
def run(self, secs):
|
||||
self.W.scan()
|
||||
t0 = time.time()
|
||||
last, last_scan = t0, 0.0
|
||||
hostiles0 = None
|
||||
while time.time() - t0 < secs:
|
||||
t = time.time()
|
||||
if t - last_scan > 5.0:
|
||||
ents = self.W.scan()
|
||||
last_scan = t
|
||||
n_ad = sum(1 for _, va in ents
|
||||
if navigator.faction(self.W.defs[va]) == "ADAN")
|
||||
if hostiles0 is None:
|
||||
hostiles0 = n_ad
|
||||
print(f"[{t-t0:6.1f}] scan: {len(ents)} entities, {n_ad} ADAN "
|
||||
f"(start {hostiles0})", file=self.log, flush=True)
|
||||
msg = self.step(t, t - last)
|
||||
last = t
|
||||
if msg is None:
|
||||
print(f"[{t-t0:6.1f}] player object gone — stopping",
|
||||
file=self.log, flush=True)
|
||||
break
|
||||
print(f"[{t-t0:6.1f}] {msg}", file=self.log, flush=True)
|
||||
if msg == "DEAD":
|
||||
break
|
||||
time.sleep(max(0.0, 1.0 / self.HZ - (time.time() - t)))
|
||||
if not self.dry:
|
||||
self.pad.reset()
|
||||
print(f"# flew {time.time()-t0:.0f}s", file=self.log, flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
cfg = json.load(open(sys.argv[1]))
|
||||
secs = float(sys.argv[2]) if len(sys.argv) > 2 else 180.0
|
||||
W = navigator.World(cfg)
|
||||
Pilot(W, Pad(), dry="--dry" in sys.argv).run(secs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user