From e3ca3fbd8cb163c72c9478c46a5b29676d70ec0d Mon Sep 17 00:00:00 2001 From: chexit Date: Mon, 2 Nov 2026 23:55:29 +0300 Subject: [PATCH] Release interceptor engine source at burnout Co-authored-by: Codex --- airdef-mod/src/engines.rs | 15 +++++++++++++++ airdef-mod/src/lib.rs | 13 ++++++++----- airdef-mod/src/replay.rs | 7 +++++++ airdef-mod/src/sim/mod.rs | 3 +++ airdef-mod/src/sim/regress.rs | 1 + 5 files changed, 34 insertions(+), 5 deletions(-) diff --git a/airdef-mod/src/engines.rs b/airdef-mod/src/engines.rs index 3418511..8093a59 100644 --- a/airdef-mod/src/engines.rs +++ b/airdef-mod/src/engines.rs @@ -82,6 +82,12 @@ impl Engines { self.live.is_empty() } + /// An explicitly unpowered entity releases its source without waiting for the missing-frame grace. + pub fn stop(&mut self, key: u32) -> Option { + self.dead.remove(&key); + self.live.remove(&key).map(|live| Op::Stop(live.id)) + } + /// The game released every source: forget them (nothing to stop). pub fn reset(&mut self) { self.live.clear(); @@ -197,6 +203,15 @@ mod tests { damaged: false, } } + #[test] + fn explicit_burnout_releases_the_source_immediately() { + let mut engines = Engines::default(); + let ops = engines.step(vec![d(42, 0.0)], false, DVec3::ZERO); + assert!(matches!(ops.as_slice(), [Op::Start(_, _)])); + assert!(matches!(engines.stop(42), Some(Op::Stop(_)))); + assert!(engines.is_empty()); + assert!(engines.stop(42).is_none()); + } fn starts(ops: &[Op]) -> usize { ops.iter().filter(|o| matches!(o, Op::Start(..))).count() } diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index b75e800..fcfef74 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -715,13 +715,14 @@ impl AirDef { self.engines.reset(); self.engine_gen = generation; } - if self.engines.is_empty() && out.engines.is_empty() && !cut { + if self.engines.is_empty() && out.engines.is_empty() && out.engine_stops.is_empty() && !cut { return; } - let ops: Vec = self - .engines - .step(std::mem::take(&mut out.engines), cut, ctx.listener) - .into_iter() + let mut changes = self.engines.step(std::mem::take(&mut out.engines), cut, ctx.listener); + for key in std::mem::take(&mut out.engine_stops) { + if let Some(op) = self.engines.stop(key) { changes.push(op); } + } + let ops: Vec = changes.into_iter() .map(|op| match op { engines::Op::Start(id, v) => source_op(render::SourceOpKind::Start, id, &v), engines::Op::Update(id, v) => source_op(render::SourceOpKind::Update, id, &v), @@ -1949,6 +1950,7 @@ impl Mod for AirDef { out.particles.clear(); out.sounds.clear(); out.engines.clear(); + out.engine_stops.clear(); out.shake = None; } playback.tick(&self.sim, &mut self.ground, tick_context.listener, &mut out); @@ -1968,6 +1970,7 @@ impl Mod for AirDef { out.particles.clear(); out.sounds.clear(); out.engines.clear(); + out.engine_stops.clear(); out.shake = None; } playback.tick(&self.sim, &mut self.ground, tick_context.listener, &mut out); diff --git a/airdef-mod/src/replay.rs b/airdef-mod/src/replay.rs index cec7e89..c707558 100644 --- a/airdef-mod/src/replay.rs +++ b/airdef-mod/src/replay.rs @@ -500,6 +500,8 @@ impl Player { if rec.missile_is_lit(tr, self.t, kind.lit_after()) { let (event, gain, pitch) = kind.engine_voice(); out.engine(key, event, at, vel, gain, pitch, false); + } else { + out.engine_stops.push(key); } } What::StrikeCarrier(_) => { @@ -1146,6 +1148,11 @@ mod engine_tests { assert!(!rec.missile_is_lit(&rec.tracks[0], 50.0, 5)); assert!(rec.missile_is_lit(&rec.tracks[0], 49.0, 5), "seek back restores the recorded state"); let mut p = player(&rec); + p.rate = 1.0; + p.t = 50.0; + let mut burnout = Out::default(); + p.engines(&rec, DVec3::ZERO, false, &mut burnout); + assert!(burnout.engine_stops.contains(&0x8000_0000)); let mut out = Out::default(); p.heard = 18.0; p.t = 21.0; diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index 44c003b..ae8bde0 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -113,6 +113,8 @@ pub struct Out { /// The engines sounding this tick (persistent sources, `crate::engines`), and whether the /// picture cut (a replay seek or a new shot): every engine then starts afresh. pub engines: Vec, + /// Sources explicitly stopped this tick (for example, motor burnout). + pub engine_stops: Vec, pub engine_cut: bool, pub commands: Vec, /// Camera jolt (strength, seconds), the strongest this tick. @@ -2983,6 +2985,7 @@ impl Sim { let spec = m.kind.spec(); let target = self.drones.iter().find(|d| d.id == m.target && d.alive()); let lit = m.engine_lit(); + if !lit { out.engine_stops.push(0x4000_0000 | m.id); } let coast = m.age > spec.cold + m.kind.burn_ticks(); if m.age == spec.cold + m.kind.burn_ticks() + 1 { burnouts.push((m.pos, m.id, m.kind)); diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs index b2958bc..c42f999 100644 --- a/airdef-mod/src/sim/regress.rs +++ b/airdef-mod/src/sim/regress.rs @@ -1877,6 +1877,7 @@ fn motor_burnout_reduces_speed_and_stops_powered_effects() { assert!(powered <= last && sim.missiles[0].vel.length() < last); assert!(!sim.missiles[0].engine_lit()); assert!(!out.engines.iter().any(|e| e.key == 0x4000_0000 | id)); + assert!(out.engine_stops.contains(&(0x4000_0000 | id))); } #[test]