pilot: real ballistics from the solved Shell records — and the metric that says it did not help

Shell_TCAF_DeltaSaber_*_P: Velocity 8000, LifeTime 0.5 s, MaximumRange 4000
(self-consistent: 8000 x 0.5 = 4000), all confirmed. Two things were wrong:
lead computed flight time as d / OUR speed (400-2000 u/s, so every shot was led
4-16x too far), and FIRE_RANGE was 5000 — past where the shells expire.

Both fixed. But the HUD's own kill counters read 0000/0000 at the end of EVERY
run including the nearest-fighter baseline, so the pilot kills nothing in any
configuration and 'fraction of frames firing' was never measuring lethality.
No improvement is claimed.

One clean negative result kept: gating on the target's angular half-size alone
(2.7 deg at 2584 units) is far tighter than the steering loop can hold the nose
— firing collapsed to 1 frame in 2639. Angular size is a floor on the firing
cone, never a cap.

Next: the HUD carries a live ammo count, so holding fire and watching it settles
'we never shoot' vs 'we shoot and miss' in a single run.
This commit is contained in:
claude-re
2026-07-30 18:16:26 +00:00
parent 402985adbf
commit 3f6efadf9e
3 changed files with 82 additions and 17 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

View File

@@ -121,6 +121,37 @@ destroyer the avoidance expected to clear by 365 units — against a hull of rad
`BIG_RADIUS` now gets a physical keep-out of **its own radius + 800**, with braking
inside it, whatever its faction; the next run survived its full 330 s untouched.
## Ballistics from the disc data — and the measurement that invalidates the metric
The solved `Shell` records give the player's guns exactly
(`Shell_TCAF_DeltaSaber_{NoseGun,Gun,Beam}_P`, all ✅ CONFIRMED):
**`Velocity` 8000**, **`LifeTime` 0.5 s**, **`MaximumRange` 4000** — self-consistent,
since 8000 × 0.5 = 4000 — plus shell `Radius` 2030 and `Power` 15/30/40.
Two things in `pilot.py` were plainly wrong against those numbers, and both are fixed:
- **Lead used our own speed as the shell speed.** Flight time was `d / max(our_speed,
300)`, i.e. 4002000 u/s instead of 8000 — every shot led **416× too far ahead**.
- **`FIRE_RANGE` was 5000**, past the range at which the shells expire.
**But the outcome metric says none of this has been shown to help.** The HUD's own
counters — `YOU KILLED: WARSHIPS` / `WARPLANES` — read **0000 / 0000 at the end of
every run**, including the nearest-fighter baseline. The pilot is not killing
anything in any configuration, so "fraction of frames with the guns on" (12 % → 5 % →
1 frame in 2639 as the firing gate was varied) was never measuring lethality. The
corrections above are right on the physics and fix demonstrably wrong code; **they are
not evidence of improvement**, and none is claimed.
The firing gate itself produced one clean result worth keeping: gating on the target's
angular half-size **alone** (2.7° at 2584 units for a fighter) is far tighter than the
steering loop can hold the nose, and firing collapsed to 1 frame in 2639. Angular size
belongs in the gate as a **floor** that opens it up close, never as a cap.
**Open (next):** find out why nothing dies. The cheap decisive test is whether the
`RB` command discharges the gun at all — the HUD carries a live ammo count
(`MAIN MPM 00300`, which the weapon RE matched to `LoadingCount`), so holding fire and
watching that number settles "we never shoot" vs "we shoot and miss" in one run.
## Reimplementation notes
- Defeat conditions for an escort stage are readable as: protected-asset

View File

@@ -31,10 +31,11 @@ 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 asset is *not*
in danger early — it sat at a full 25000 for the first ~170 s and then lost
~600 HP/min, i.e. ~40 minutes to sink from full. So permanently orbiting it
would throw away most of the mission for nothing. The policy that fits the
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
@@ -63,6 +64,19 @@ 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
# 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
@@ -73,8 +87,10 @@ ASSET_NAME = os.environ.get("SYLPH_ASSET", "Acropolis")
class Pilot:
KP, KD = 2.2, 0.45
FIRE_CONE = math.radians(9)
FIRE_RANGE = 5000.0
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
@@ -143,7 +159,7 @@ class Pilot:
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))
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
@@ -172,9 +188,27 @@ class Pilot:
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(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:
@@ -183,11 +217,11 @@ class Pilot:
d = float(np.linalg.norm(rel))
if d < 1e-3:
continue
lead = p + v * (d / max(speed, 300.0))
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), score
best, bestscore = (off, nm, lead, lead - me_p, d, r), score
return best
def threat_vector(self, me_p, hos):
@@ -308,16 +342,15 @@ class Pilot:
# 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 in atk:
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)
best = (total, off, nm, p, v, d_me, r)
if best is not None:
_, off, nm, p, v, d_me = best
speed_ref = max(speed, 300.0)
lead = p + v * (d_me / speed_ref)
tgt = (off, nm, lead, lead - me_p, d_me)
_, 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:
@@ -394,7 +427,8 @@ class Pilot:
# 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
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)
if not self.dry: