Files
Syplheed-Reborn/tools/re-capture/pilot.py
claude-re 4821ba7fea pilot: target select is A pressed TWICE — from the HUD tutorial, not from probing
HEADS-UP DISPLAY tutorial, verbatim: 'Press A twice to target the enemy closest
to the center of the screen.' A double tap, which is exactly why every button
sweep in flight-controls-runtime.md found nothing and why I concluded targeting
was automatic — each sweep tapped once. It also explains the missiles:
GuidanceType 5 guides to the GAME's selection and the loop had never made one,
so 98 launches guided to nothing.

Wired in: double-tap A when the committed contact is already within 14 deg of
the nose, so the game's choice and ours are the same object. One run: 8 kills
from 66 missiles (12% per missile) against the previous 9 from 101 (8.9%). The
absolute count is inside run variance and the efficiency gain is one sample, so
neither is claimed as decisive — it needs repeat runs.

Also documents that expository tutorials self-advance while interactive ones
stall (BASIC CONTROLS waits forever on 'Go to the box'), and that captions need
cropping across many frames because they type out.
2026-07-30 20:47:54 +00:00

658 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
DEFEND the escorted asset is being attacked — go kill what is attacking IT
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.
**Why DEFEND exists, and why it is not simply "always guard the asset".** Stage
02 is an escort: a 240 s run ended in GAME OVER with our own hull at 1500/1500
because the ACROPOLIS sank while the pilot chased the nearest fighter 2 km away.
But the measurement in docs/re/mission-escort-state.md says the loss is *slow* —
a few hundred to ~1400 HP/min against 25000, i.e. tens of minutes to sink. (When
it starts varies: t≈170 s in one run, t≈70 s in another, so do not schedule on
it — react to the hull.) So permanently orbiting it would throw away most of the
mission for nothing. The policy that fits the
measurement is: **fight freely until the asset is actually being hurt, then
switch to killing its attackers specifically.** Both the trigger and the target
choice are read live — every entity's hull is `position + 0x154`, confirmed for
seven classes, so "is the asset losing hull" and "which hostiles are closing on
it" are both observable rather than inferred.
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 gmem # noqa: E402
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
DEF_HP = 0x054 # unit-struct-runtime.md
# The player Delta Saber's guns, from the solved Shell records
# (docs/re/captures/weapon-runtime-fields.csv, all ✅ CONFIRMED):
# Shell_TCAF_DeltaSaber_{NoseGun,Gun,Beam}_P Velocity 8000, LifeTime 0.5 s,
# MaximumRange 4000 (= 8000 × 0.5, self-consistent), shell Radius 2030.
# Both numbers were previously wrong in this loop, and both mattered:
# * flight time was computed as d / OUR speed (4002000 u/s), so every shot
# was led 416× too far ahead of the target;
# * FIRE_RANGE was 5000, i.e. a quarter of the shots were fired at targets
# the shells expire before reaching.
SHELL_VELOCITY = 8000.0
SHELL_MAX_RANGE = 4000.0
SHELL_RADIUS = 20.0
# The MAIN weapon, fired with Y — measured, not assumed: a hold-each-input probe
# (fire_probe.sh) moved NOSE BM 06000 -> 05956 under RB and MAIN MPM 00300 ->
# 00299 under Y, so RB is the nose gun (~11 rounds/s) and Y is the main mount.
# That probe also settled the lethality question the other way round: we DO
# shoot, so the kill counters reading 0000 mean we shoot and MISS.
#
# Which is exactly what the disc data says to stop doing. Shell_TCAF_DeltaSaber_
# Missile_P is Power 200 with GuidanceType 5 (guided) and MaximumRange 5000,
# against the nose gun's Power 15 unguided — one missile is worth ~14 gun hits
# on a 500 HP fighter, and it steers itself, which is the accuracy problem
# solved rather than tuned. Range is held under the confirmed 5000 because which
# main weapon is actually loaded is not read from RAM yet.
MISSILE_RANGE = 4000.0
MISSILE_CONE = math.radians(20.0)
MISSILE_PERIOD = 2.0 # s between launches; 300 rounds is not unlimited
# Stage 02's protected asset. Named rather than derived: "the biggest friendly"
# picks the f105 cruiser (30000 HP > the Acropolis's 25000), and "the friendly
# with the most HP" picks it too, so neither rule finds the right ship. The
# per-stage asset is mission script, not a property of the entity, so it is
# configuration here — override with $SYLPH_ASSET for another stage.
ASSET_NAME = os.environ.get("SYLPH_ASSET", "Acropolis")
class Pilot:
KP, KD = 2.2, 0.45
FIRE_CONE = math.radians(9) # fallback only; the real gate is angular size
FIRE_RANGE = SHELL_MAX_RANGE # the shells simply do not arrive past this
CONE_MIN = math.radians(2.0)
CONE_MAX = math.radians(25.0) # close-in the target subtends a lot; let it
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
# --- escort ---
ASSET_GUARD = 9000.0 # hostiles this close to the asset count as its attackers
ASSET_QUIET = 20.0 # s of no asset damage before dropping out of DEFEND
ASSET_ALERT = 0.5 # HP of asset damage that counts as "under attack"
ASSET_STANDOFF = 3500.0 # loiter this far out when guarding with no target
HULL_CLEARANCE = 800.0 # clearance ON TOP of a capital ship's own radius
CLOSING_WEIGHT = 4.0 # s of closing-rate credit when ranking attackers
MY_RANGE_WEIGHT = 0.35 # how much our own distance discounts a target
# --- target commitment ---
# The loop re-scored every contact every tick, so the nose chased whichever
# fighter was momentarily best and the aim error wandered 10-40 deg through
# a pass. A missile lock is time-on-target (the OPTIONS screen calls it
# Padlock), so switching targets constantly is the one thing guaranteed to
# prevent a kill. Stay on the chosen contact until it dies, leaves range, or
# sits behind us long enough that chasing it is pointless.
COMMIT_MAX = 14.0 # s before we are allowed to reconsider anyway
COMMIT_DROP = 6000.0 # ...or it gets this far away
COMMIT_BEHIND = 2.5 # ...or stays >90 deg off the nose this long
# --- moves the ADVANCED CONTROLS tutorial teaches (tutorial_capture.sh) ---
# "Target an enemy and pull LT and RT [together]. This sets your fighter's
# speed to that of the target. This works well when you are trying to get
# behind an enemy. Once behind an enemy, this also helps you attack them."
# That is the overshoot problem solved by the game itself: matching speed
# holds us in the target's rear hemisphere instead of flying through it,
# which is the only way a time-on-target lock ever completes.
# Scope matters, and a measured regression proved it: applying the match at
# 4500 with a 70 deg cone dropped kills 9 -> 0. Matching a target's speed
# while still 5 km behind it means never closing — the pilot sat at 272 u/s
# and fired 28 frames all run. The tutorial's own wording scopes it: "when
# you are trying to get BEHIND an enemy... ONCE BEHIND an enemy, this also
# helps you attack them". So it is station-keeping in the saddle, not an
# approach throttle. Only match when we are already there.
# MEASURED: both tutorial moves are a NET REGRESSION as applied here, so
# both ship DISABLED. One run each, same everything else:
# commitment only ............ 101 missiles, 364 fire frames, 9 kills
# + match(4500) + snap-face .... 9 missiles, 28 fire frames, 0 kills
# + match(1200) + snap-face ... 57 missiles, 225 fire frames, 2 kills
# The moves are real and the tutorial is right about them; the loop just
# cannot use them yet. Snap-face (B+A) reorients the craft mid-pursuit and
# destroys the very dwell that commitment buys, and speed-match needs to be
# entered from the saddle rather than commanded at range. Set MATCH_RANGE
# and lower FACE_MIN to re-enable, and A/B them over SEVERAL runs — one run
# per config is inside this stage's spawn variance.
MATCH_RANGE = 0.0 # 1200.0 to re-enable
MATCH_CONE = math.radians(25)
# "Press B and A together to face [the target]" — a snap turn, far quicker
# than winding the PD controller around for a contact behind us.
FACE_MIN = math.radians(999) # 50 deg to re-enable the B+A snap turn
# HEADS-UP DISPLAY tutorial, verbatim: "Press A twice to target the enemy
# closest to the center of the screen." A DOUBLE tap — which is why every
# single-tap button sweep found nothing and concluded targeting was
# automatic. It also explains the missiles: GuidanceType 5 needs the GAME's
# selection, and we had never made one, so 98 launches guided to nothing.
# Select only when our committed contact is already near screen centre, so
# the game's choice and ours are the same object.
SELECT_CONE = math.radians(14)
SELECT_PERIOD = 3.0
FACE_PERIOD = 4.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
# escort bookkeeping
self.def_hp = {} # def_va -> definition HP
for va in W.defs:
self.def_hp[va] = self.f32(gmem.va_to_off(va) + DEF_HP)
self.asset_hist = deque(maxlen=64)
self.asset_hp0 = None
self.asset_last_hit = -1e9
self.missile_down = False
self.missile_t = -1e9
self.missiles = 0
self.commit_off = None # entity we are committed to
self.commit_t = -1e9
self.behind_since = None
self.matching = False
self.face_t = -1e9
self.face_down = None
self.faces = 0
self.select_t = -1e9
self.selects = 0
def f32(self, off):
b = os.pread(self.W.fd, 4, off)
if len(b) < 4:
return float("nan")
return struct.unpack(">f", b)[0]
# ---------------------------------------------------------------- escort
def asset(self, ents):
"""The protected ship, and its live hull — same anchor as everyone's."""
for off, nm, p, v, r in ents:
if ASSET_NAME in nm:
hull = self.f32(off + HULL_OFF)
return off, nm, p, v, r, hull
return None
def asset_attackers(self, hos, a_p):
"""Hostile fighters near the asset, ranked by how hard they press it.
Ranking is distance to the asset *minus* credit for closing on it, so a
fighter 4 km out and running in outranks one sitting at 2 km drifting
away. Turrets and hulls are excluded for the same reason as everywhere
else: they are not killable objectives, they are keep-out zones.
"""
out = []
for off, nm, p, v, r, hard in hos:
if hard:
continue
rel = a_p - p
d = float(np.linalg.norm(rel))
if d > self.ASSET_GUARD:
continue
closing = float(np.dot(norm(rel), v)) # +ve = moving at the asset
out.append((d - self.CLOSING_WEIGHT * max(closing, 0.0), off, nm, p, v, d, r))
out.sort(key=lambda e: e[0])
return out
# ------------------------------------------------------------ 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.
`want` is +1 accelerate, -1 brake, 0 coast, or the string "match" for
the tutorial's both-triggers speed-match onto the current target.
"""
if want == self.throttle or self.dry:
return
if want == "match":
self.pad.trig("RT", 1.0)
self.pad.trig("LT", 1.0)
else:
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
def select_target(self, t):
"""A, twice: make the GAME target what we are already pointing at."""
if self.dry or t - self.select_t < self.SELECT_PERIOD:
return
self.pad.f.write("tap A 90\n")
self.pad.f.write("tap A 90\n")
self.select_t = t
self.selects += 1
def face_target(self, t):
"""B + A: snap the nose onto the selected target."""
if self.dry or t - self.face_t < self.FACE_PERIOD:
return
self.pad.press("B")
self.pad.press("A")
self.face_down = t
self.face_t = t
self.faces += 1
# -------------------------------------------------------------- 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 lead_point(self, p, v, d):
"""Where to aim: the target moved on by the shell's real flight time."""
return p + v * (d / SHELL_VELOCITY)
def fire_cone(self, d, r):
"""How far off the nose we will still pull the trigger.
The target's angular half-size, atan((r_target + r_shell) / range), is
the angle that can actually *hit* — but gating on it alone was measured
to be much worse than the old fixed 9°: at 2584 units a fighter subtends
2.7°, the steering loop holds the nose to ~1030°, and firing collapsed
to 1 frame in 2639. Ammunition is free and the guns are continuous, so
the angular size belongs here as a **floor** that opens the gate wider
up close, never as a cap that closes it far out.
"""
if d < 1.0:
return self.CONE_MAX
return min(self.CONE_MAX, max(self.FIRE_CONE, math.atan2(r + SHELL_RADIUS, d)))
def pick_committed(self, t, me_p, me_v, fwd, hos):
"""pick(), but stay on the same contact long enough to actually kill it."""
cur = None
for off, nm, p, v, r, hard in hos:
if off == self.commit_off and not hard:
cur = (off, nm, p, v, r)
break
if cur is not None:
off, nm, p, v, r = cur
rel = p - me_p
d = float(np.linalg.norm(rel))
behind = ang(rel, fwd) > math.pi / 2
self.behind_since = (self.behind_since if behind else None) or (t if behind else None)
stale = (t - self.commit_t > self.COMMIT_MAX
or d > self.COMMIT_DROP
or (self.behind_since is not None
and t - self.behind_since > self.COMMIT_BEHIND))
if not stale:
lead = self.lead_point(p, v, d)
return (off, nm, lead, lead - me_p, d, r)
# commit to a fresh one
tgt = self.pick(me_p, me_v, fwd, hos)
self.commit_off = tgt[0] if tgt else None
self.commit_t = t
self.behind_since = None
return tgt
def pick(self, me_p, me_v, fwd, hos):
"""Nearest *fighter*, weighted by how far off the nose it is."""
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 = self.lead_point(p, v, d)
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, r), 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
# ---- the escorted asset, read exactly like our own hull
ast = self.asset(ents)
a_frac, a_dmg = 1.0, 0.0
if ast is not None:
a_hull = ast[5]
if self.asset_hp0 is None and math.isfinite(a_hull) and a_hull > 0:
self.asset_hp0 = a_hull
self.asset_hist.append((t, a_hull))
recent = [h for (ts, h) in self.asset_hist if t - ts <= 4.0]
a_dmg = (max(recent) - a_hull) if recent else 0.0
if a_dmg > self.ASSET_ALERT:
self.asset_last_hit = t
a_frac = a_hull / self.asset_hp0 if self.asset_hp0 else 1.0
# ---- mode. Our own survival still outranks the escort: a dead pilot
# defends nothing, and RETIRE/EVADE are what stopped us being shot down.
if frac <= self.RETIRE_FRAC:
self.mode = "RETIRE"
elif t - self.last_hit < self.EVADE_QUIET:
self.mode = "EVADE"
elif ast is not None and t - self.asset_last_hit < self.ASSET_QUIET:
self.mode = "DEFEND"
else:
self.mode = "ENGAGE"
tgt = self.pick_committed(t, 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 == "DEFEND":
# Kill what is hitting the ship, not what is nearest to us. Among
# the asset's attackers prefer the one pressing it hardest, with a
# modest discount for being closer to us so the loop does not fly
# past three targets to reach a marginally worse fourth.
atk = self.asset_attackers(hos, ast[2])
best = None
for score, off, nm, p, v, d_a, r in atk:
d_me = float(np.linalg.norm(p - me_p))
total = score + self.MY_RANGE_WEIGHT * d_me
if best is None or total < best[0]:
best = (total, off, nm, p, v, d_me, r)
if best is not None:
_, off, nm, p, v, d_me, r = best
lead = self.lead_point(p, v, d_me)
tgt = (off, nm, lead, lead - me_p, d_me, r)
want = norm(tgt[3])
self.set_throttle(+1 if d_me > 2500.0 else 0)
else:
# Nothing on it right now: hold station near the ship instead of
# wandering off, so the next wave is met at the asset.
rel = ast[2] - me_p
d = float(np.linalg.norm(rel))
want = (norm(rel) if d > ast[4] + self.ASSET_STANDOFF
else norm(np.cross(rel, up)))
self.set_throttle(+1 if d > ast[4] + self.ASSET_STANDOFF else 0)
fire = True
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] < self.MATCH_RANGE and ang(tgt[3], fwd) < self.MATCH_CONE:
self.set_throttle("match") # sit in its rear hemisphere
elif tgt and tgt[4] > 2500.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
# A capital ship is a wall, whatever its faction. DEFEND flies at the
# asset — which sits in the middle of the friendly formation — and the
# first escort run ended with hull 1500 -> DEAD in a single tick at
# 2026 units/s, 0.6 s from a friendly destroyer that the avoidance
# thought it would clear by 365 units. A destroyer's own radius is
# 2000. Closest-point-of-approach with a fighter-sized margin cannot
# keep us out of something that big, so give every large entity a hard
# physical keep-out scaled by ITS radius and brake inside it.
for off, nm, p, v, r in ents:
if off == me_off or r < navigator.Navigator.BIG_RADIUS:
continue
rel = me_p - p
d = float(np.linalg.norm(rel))
keep = r + self.HULL_CLEARANCE
if d < keep:
want = norm(want + norm(rel) * (2.5 * (1.0 - d / keep)))
if speed > 900.0:
self.set_throttle(-1)
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)
cone = self.fire_cone(tgt[4], tgt[5]) if tgt else self.FIRE_CONE
aim_ok = abs(aim[0]) < cone and abs(aim[1]) < cone
fire = bool(fire and tgt and aim_ok and tgt[4] < self.FIRE_RANGE and pn < 1.2)
# The main mount is a discrete launch, not a continuous stream: press Y
# and let go a tick later, then wait out MISSILE_PERIOD. Holding it
# would empty 300 rounds in half a minute.
msl = bool(tgt and self.mode in ("ENGAGE", "DEFEND")
and tgt[4] < MISSILE_RANGE
and abs(aim[0]) < MISSILE_CONE and abs(aim[1]) < MISSILE_CONE
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
if (tgt and self.mode in ("ENGAGE", "DEFEND")
and abs(aim[0]) < self.SELECT_CONE
and abs(aim[1]) < self.SELECT_CONE
and tgt[4] < MISSILE_RANGE and pn < 1.2):
self.select_target(t)
if self.face_down is not None and t - self.face_down > 0.2:
self.pad.release("B")
self.pad.release("A")
self.face_down = None
elif (tgt and self.mode in ("ENGAGE", "DEFEND")
and abs(aim[0]) > self.FACE_MIN and pn < 1.2):
self.face_target(t)
if self.missile_down and t - self.missile_t > 0.15:
self.pad.release("Y")
self.missile_down = False
elif (not self.missile_down and msl
and t - self.missile_t > MISSILE_PERIOD):
self.pad.press("Y")
self.missile_down = True
self.missile_t = t
self.missiles += 1
msg = (f"{self.mode:<7} hull={hull:6.0f} shd={shield:6.0f} spd={speed:6.0f} "
f"thr={str(self.throttle):>5} 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)} msl={self.missiles}"
f" fc={self.faces} sel={self.selects}")
if ast is not None:
msg += f" ast={a_frac*100:5.1f}%"
if a_dmg > self.ASSET_ALERT:
msg += f" ASSET-HIT -{a_dmg:.0f}"
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()