diff --git a/docs/re/captures/kill-counters-all-runs.png b/docs/re/captures/kill-counters-all-runs.png new file mode 100644 index 0000000..b27c15a Binary files /dev/null and b/docs/re/captures/kill-counters-all-runs.png differ diff --git a/docs/re/mission-escort-state.md b/docs/re/mission-escort-state.md index 5b784d8..f0c4184 100644 --- a/docs/re/mission-escort-state.md +++ b/docs/re/mission-escort-state.md @@ -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` 20–30 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. 400–2000 u/s instead of 8000 — every shot led **4–16× 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 diff --git a/tools/re-capture/pilot.py b/tools/re-capture/pilot.py index dd7eec7..d1863d5 100644 --- a/tools/re-capture/pilot.py +++ b/tools/re-capture/pilot.py @@ -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 20–30. +# Both numbers were previously wrong in this loop, and both mattered: +# * flight time was computed as d / OUR speed (400–2000 u/s), so every shot +# was led 4–16× 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 ~10–30°, 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: