From 1261c3072465e2b0bacb8fc74cf2110b3f9b7dec Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 6 Nov 2026 13:18:01 +0300 Subject: [PATCH] Stage the cold launch and spool every interceptor's motor (#129) The interceptor's launch is one sequence: the cap opens, the round is thrown out unlit and slows under linear gas drag (`cold` ticks), the motor lights where it has reached, it turns with a growing rate while thrust spools up (`boost` ticks), and full guidance starts only at `cold + boost`. A rail launch lights at once but spools over the same `boost` ticks; the electric P1-SUN has no rocket spool or plume. S-400/500 now hold the unlit ejection for 20/24 ticks (1.00/1.20 s at 20 Hz); IRIS-T/Tor keep shorter 8-tick throws. Ignition, its sound, flame and recorded MotorIgnition event all use the missile's current position. The RM48U strike follows the same 20-tick choreography, counts the throw in its flight time and starts its recorded smoke at ignition. Maximum speed, turn, accel, pk and every collision rule are unchanged. Co-Authored-By: Kimi Code --- airdef-mod/src/replay.rs | 2 +- airdef-mod/src/sim/mod.rs | 61 ++++-- airdef-mod/src/sim/regress.rs | 268 +++++++++++++++++++++++++- airdef-mod/src/sim/specs_generated.rs | 26 ++- airdef-mod/src/sim/strike.rs | 30 ++- airdef-mod/src/smoke.rs | 14 +- catalog/specs.json | 26 ++- tools/airdef_assets/catalog.py | 6 +- tools/gen_catalog.py | 2 + 9 files changed, 399 insertions(+), 36 deletions(-) diff --git a/airdef-mod/src/replay.rs b/airdef-mod/src/replay.rs index 6575438..25742b4 100644 --- a/airdef-mod/src/replay.rs +++ b/airdef-mod/src/replay.rs @@ -1210,7 +1210,7 @@ mod engine_tests { assert_eq!(out.sounds.iter().map(|s| s.0).collect::>(), vec!["airdef:launch.s400_eject"]); assert!(out.engines.is_empty()); out = Out::default(); - p.t = 16.0; + p.t = 10.0 + crate::sim::strike::EJECT_TICKS as f64 + 1.0; p.sounds(&rec, DVec3::ZERO, &mut out); p.engines(&rec, DVec3::ZERO, false, &mut out); assert_eq!(out.sounds.iter().map(|s| s.0).collect::>(), vec!["airdef:launch.s400"]); diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index 191ec3f..565b1d7 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -824,10 +824,15 @@ struct MissileSpec { speed: f64, accel: f64, turn: f64, - /// Ticks of straight flight before guidance. + /// Departure: ticks from ignition to full guidance (spool-up; a cold launch also captures + /// its course); full guidance starts at `cold + boost`. boost: u32, - /// Thrown out of the tube by gas first; the motor lights this many ticks later. + /// Thrown out of the tube by gas first; the motor lights this many ticks later (#129). cold: u32, + /// Gas-throw speed out of the tube, blocks a tick (cold-launched kinds only). + eject: f64, + /// The ejection's linear drag, blocks a tick² (cold-launched kinds only). + eject_drag: f64, pk: f64, fuse: f64, life: u32, @@ -843,6 +848,31 @@ struct MissileSpec { voice: (&'static str, f32, f32), } +impl MissileSpec { + /// The launch choreography is one sequence of stages (#129): the cap opens on the ground + /// (`launch::OPEN_TICKS`), the round is thrown out of the tube unlit and slows under drag + /// (`age <= cold`), the motor lights where it has reached (`age == cold + 1`), it tips onto + /// its course with a growing rate while the motor spools up (`cold < age < cold + boost`), + /// and full guidance — terrain lofting, the seeker, full turn — runs from `age >= cold + boost`. + /// Rail-launched kinds (`cold == 0`) light at once but spool up over the same `boost` ticks: + /// their stages are ignition, departure, guidance. + fn guided(&self, age: u32) -> bool { + age >= self.cold + self.boost + } + /// The departure's course-capture rate as a share of the full turn (0..=1). + fn departure_ramp(&self, age: u32) -> f64 { + (age.saturating_sub(self.cold) as f64 / self.boost.max(1) as f64).clamp(0.0, 1.0) + } + /// The motor's spool-up as a share of the full acceleration (0..=1). A rail launch spools over + /// the same `boost` ticks its departure lasts; an electric kind has no rocket motor to light. + fn spool(&self, age: u32, guided: bool) -> f64 { + if guided || self.flame <= 0.0 { + return 1.0; + } + 0.45 + 0.55 * self.departure_ramp(age) + } +} + impl MissileKind { /// How thick its motor smoke is (its size); `None` for an electric one. pub fn trail_scale(self) -> Option { @@ -2543,7 +2573,7 @@ impl Sim { .map(|tr| Fix { pos: tr.pos, vel: tr.vel, tick: tr.tick, seeker: false }); let spec = kind.spec(); let id = self.id(); - let v0 = if spec.cold > 0 { 0.7 } else { 0.8 }; + let v0 = if spec.cold > 0 { spec.eject } else { 0.8 }; self.missiles.push(Missile { id, kind, pos: muzzle, vel: dir * v0, target, sam, age: 0, hold: 0, fix, seen: 0 }); if spec.cold > 0 { // Thrown out by gas: a puff, a thump; the motor lights later. @@ -2612,10 +2642,15 @@ impl Sim { if m.age == spec.cold + m.kind.burn_ticks() + 1 { burnouts.push((m.pos, m.id, m.kind)); } + // The launch's stages (#129): ejection (unlit, gas drag), ignition (the motor lights + // where the round has reached), departure (spool-up and course capture) and guidance. + // Guidance never runs before ignition: it starts at `cold + boost`, the motor at `cold`. + let guided = spec.guided(m.age); let speed = if m.age <= spec.cold { - (m.vel.length() * 0.97).max(0.4) + // The gas throw bleeds off linearly; the round is never left hanging still. + (m.vel.length() - spec.eject_drag).max(spec.eject * 0.25) } else if lit { - (m.vel.length() + spec.accel).min(spec.speed) + (m.vel.length() + spec.accel * spec.spool(m.age, guided)).min(spec.speed) } else { m.vel.length() * 0.995 }; @@ -2627,8 +2662,7 @@ impl Sim { m.fix = Some(Fix { pos: tr.pos, vel: tr.vel, tick: tr.tick, seeker: false }); } if let Some(t) = target - && m.age > spec.cold - && m.age >= spec.boost + && guided && m.kind.seeker() > 0.0 { let range = m.kind.seeker() * t.kind.rcs().powf(0.25).clamp(0.6, 1.6); @@ -2643,14 +2677,14 @@ impl Sim { } } // Nothing new for too long and no seeker on it: the interceptor has lost its target. - if target.is_some() && m.fix.is_none_or(|f| now.saturating_sub(f.tick) > MAX_COAST) && m.age > spec.boost + 20 { + if target.is_some() && m.fix.is_none_or(|f| now.saturating_sub(f.tick) > MAX_COAST) && m.age > spec.cold + spec.boost + 20 { expired += 1; done.push((m.id, None, false, m.sam)); continue; } let aim = m.fix.map(|f| (f.est(now), f.vel)); let new = match aim { - Some((at, vel)) if m.age >= spec.boost => { + Some((at, vel)) if guided => { // Time to go by the closing speed: head-on targets come to meet it. let los = (at - m.pos).normalize_or_zero(); let closing = (speed - vel.dot(los)).max(1.0); @@ -2674,8 +2708,13 @@ impl Sim { } turn(dir, want, spec.turn * if coast { (speed / spec.speed).clamp(0.15, 1.0) } else { 1.0 }) } - // After a cold launch it tips over towards the target while the motor burns. - Some((at, _)) if lit && spec.cold > 0 => turn(dir, at - m.pos, spec.turn * 0.5), + // Departure: after a cold launch it tips onto the target's course while the motor + // spools up, at a rate that grows into the full one — no snap from the vertical to + // the horizontal (#129). A rail launch already points along its rail; it keeps that + // course until guidance can loft it over the land, as before. + Some((at, _)) if lit && spec.cold > 0 => { + turn(dir, at - m.pos, spec.turn * (0.2 + 0.8 * spec.departure_ramp(m.age))) + } _ => dir, }; let before = m.pos; diff --git a/airdef-mod/src/sim/regress.rs b/airdef-mod/src/sim/regress.rs index 6018090..8672a87 100644 --- a/airdef-mod/src/sim/regress.rs +++ b/airdef-mod/src/sim/regress.rs @@ -2213,6 +2213,8 @@ fn spent_motor_has_less_maneuver_at_low_speed() { assert!(powered.x > coast.x && coast.x > 0.0, "{powered:?} {coast:?}"); } +// #129: the staged launch's exact speed rules: gas-drag ejection with a floor, a spooled +// departure that grows into full acceleration, full power at guidance, the coast drag after. #[test] fn cold_ejection_power_and_coast_use_their_own_speed_rules() { let kind = MissileKind::M48n6; @@ -2228,10 +2230,270 @@ fn cold_ejection_power_and_coast_use_their_own_speed_rules() { sim.step_missiles(&mut Ground::default(), &mut Out::default()); sim.missiles[0].vel.length() }; - assert!((fly(0, 2.0) - 2.0 * 0.97).abs() < 1e-9, "cold ejection drag"); - assert!((fly(spec.cold, 2.0) - (2.0 + spec.accel)).abs() < 1e-9, "powered acceleration"); + assert!((fly(0, spec.eject) - (spec.eject - spec.eject_drag)).abs() < 1e-9, "cold ejection drag"); + assert!((fly(spec.cold - 1, 0.1) - spec.eject * 0.25).abs() < 1e-9, "cold ejection floor near ignition"); + assert!((fly(0, 0.2) - spec.eject * 0.25).abs() < 1e-9, "cold ejection floor"); + let spool0 = 0.45 + 0.55 / f64::from(spec.boost); + assert!((fly(spec.cold, 2.0) - (2.0 + spec.accel * spool0)).abs() < 1e-9, "ignition spool-up"); + assert!((fly(spec.cold + spec.boost - 1, 2.0) - (2.0 + spec.accel)).abs() < 1e-9, "powered acceleration"); assert!((fly(spec.cold + kind.burn_ticks(), 2.0) - 2.0 * 0.995).abs() < 1e-9, "coast drag"); - assert!((fly(0, 0.2) - 0.4).abs() < 1e-9, "cold ejection minimum"); +} + +// ---- #129: the staged cold launch. Ejection (unlit gas throw) → ignition where the round has +// reached → departure (spool-up and course capture at a growing rate) → guidance at cold + boost. + +/// Every cold-launched kind walks its stages in order; before ignition there is no motor voice, +/// no flame and no steering, the speed is continuous across ignition, and the turn each tick +/// never exceeds what the stage allows. +#[test] +fn cold_launch_stages_run_in_order_and_guidance_follows_ignition() { + for &kind in &[MissileKind::M48n6, MissileKind::M77n6, MissileKind::IrisT, MissileKind::M9m338] { + let spec = kind.spec(); + let mut sim = Sim::new(120); + // A still target well off the vertical: the tip-over has somewhere to turn. + let target_pos = DVec3::new(300.0, 120.0, 300.0); + sim.add_drone(DroneKind::Shahed, target_pos, DVec3::ZERO, target_pos); + let (target, id) = (sim.drones[0].id, sim.id()); + sim.missiles.push(Missile { id, kind, pos: DVec3::new(0.0, 64.0, 0.0), vel: DVec3::Y * spec.eject, target, sam: 999, age: 0, hold: 0, + fix: Some(Fix { pos: target_pos, vel: DVec3::ZERO, tick: 0, seeker: false }), seen: 0 }); + sim.begin_recording_if_active(); + let mut previous_speed = spec.eject; + let mut previous_dir = DVec3::Y; + let mut saw_ignition = false; + for age in 1..=(spec.cold + spec.boost + 2) { + sim.tick += 1; + let mut out = Out::default(); + sim.step_missiles(&mut Ground::default(), &mut out); + let m = sim.missiles.iter().find(|m| m.id == id).unwrap_or_else(|| panic!("{kind:?} age {age}")); + let speed = m.vel.length(); + let dir = m.vel.normalize_or(DVec3::Y); + let voice = out.engines.iter().any(|e| e.key == (0x4000_0000 | id)); + let flame = out.particles.iter().any(|p| p.kind == "minecraft:flame"); + assert_eq!(voice, m.engine_lit(), "{kind:?} age {age}: the motor voice runs only when lit"); + if !m.engine_lit() { + assert!(!voice && !flame, "{kind:?} age {age}: powered effects before ignition"); + assert!((speed - (previous_speed - spec.eject_drag)).abs() < 1e-9, "{kind:?} age {age}: ejection drag"); + assert!(dir.angle_between(DVec3::Y) < 1e-9, "{kind:?} age {age}: steering before ignition"); + } else if !saw_ignition { + saw_ignition = true; + assert_eq!(m.age, spec.cold + 1, "{kind:?}: ignition one tick after the ejection ends"); + assert!(speed > previous_speed - 1e-9, "{kind:?}: speed jumps down across ignition ({previous_speed} -> {speed})"); + assert!(out.sounds.iter().any(|s| s.0 == sound::LAUNCH_S400 || s.0 == sound::LAUNCH_S500), "{kind:?}: ignition sound"); + let events = sim.rec.as_ref().unwrap().scene.as_ref().unwrap().events(); + assert!(events.iter().any(|e| matches!(e.kind, record::SceneKind::MotorIgnition { missile, .. } if missile == id)), "{kind:?}: ignition recorded"); + } + let allowed = if spec.guided(m.age) { + spec.turn + } else if m.engine_lit() { + // Departure: the rate grows into the full one; no snap at the boundary. + spec.turn * (0.2 + 0.8 * spec.departure_ramp(m.age)) + } else { + 0.0 + }; + assert!(previous_dir.angle_between(dir) <= allowed + 1e-9, "{kind:?} age {age}: turned past the stage's rate"); + previous_speed = speed; + previous_dir = dir; + } + assert!(saw_ignition, "{kind:?}: never lit"); + let m = sim.missiles.iter().find(|m| m.id == id).unwrap(); + assert!(m.vel.normalize().angle_between(DVec3::Y) > 0.3, "{kind:?}: still vertical after guidance started"); + } +} + +/// The invariant the issue is about: with a longer ignition delay the old `age >= boost` guidance +/// would have switched on before the motor existed. Now guidance implies ignition, for every +/// profile and every age, and the seeker opens with guidance, not with the raw boost counter. +#[test] +fn guidance_never_starts_before_ignition_for_any_profile() { + for kind in [ + MissileKind::Pac3, MissileKind::M57e6, MissileKind::M48n6, MissileKind::M77n6, MissileKind::IrisT, + MissileKind::Amraam, MissileKind::M9m317, MissileKind::M9m338, MissileKind::P1sun, + ] { + let spec = kind.spec(); + for age in 0..=spec.life.min(2000) { + assert!(!spec.guided(age) || age > spec.cold, "{kind:?} age {age}: guidance before ignition (cold {})", spec.cold); + } + } +} + +/// Across the whole staging the speed changes smoothly (no jumps beyond the stage's own drag or +/// full acceleration) and the per-tick turn never exceeds the profile's rate, for every kind. +/// A rocket's first powered tick is a throttled one for rail and cold launches alike (#129). +#[test] +fn launch_accel_and_turn_stay_continuous_for_every_interceptor() { + for kind in [ + MissileKind::Pac3, MissileKind::M57e6, MissileKind::M48n6, MissileKind::M77n6, MissileKind::IrisT, + MissileKind::Amraam, MissileKind::M9m317, MissileKind::M9m338, MissileKind::P1sun, + ] { + let spec = kind.spec(); + let mut sim = Sim::new(120); + let target_pos = DVec3::new(400.0, 140.0, 400.0); + sim.add_drone(DroneKind::Shahed, target_pos, DVec3::ZERO, target_pos); + let (target, id) = (sim.drones[0].id, sim.id()); + let v0 = if spec.cold > 0 { spec.eject } else { 0.8 }; + sim.missiles.push(Missile { id, kind, pos: DVec3::new(0.0, 64.0, 0.0), vel: DVec3::Y * v0, target, sam: 999, age: 0, hold: 0, + fix: Some(Fix { pos: target_pos, vel: DVec3::ZERO, tick: 0, seeker: false }), seen: 0 }); + let mut previous_speed = v0; + let mut previous_dir = DVec3::Y; + let mut first_power: Option = None; + for _ in 0..120 { + sim.tick += 1; + sim.step_missiles(&mut Ground::default(), &mut Out::default()); + let Some(m) = sim.missiles.iter().find(|m| m.id == id) else { break }; + let speed = m.vel.length(); + let dir = m.vel.normalize_or(DVec3::Y); + assert!(speed - previous_speed <= spec.accel + 1e-9, "{kind:?}: speed jumped up {previous_speed} -> {speed}"); + let allowed_down = spec.eject_drag + previous_speed * 0.005; + assert!(speed >= previous_speed - allowed_down - 1e-9, "{kind:?}: speed jumped down {previous_speed} -> {speed}"); + assert!(previous_dir.angle_between(dir) <= spec.turn + 1e-9, "{kind:?}: turned past the profile's rate"); + if m.engine_lit() && first_power.is_none() { + first_power = Some(speed - previous_speed); + } + previous_speed = speed; + previous_dir = dir; + } + if spec.flame > 0.0 { + let gain = first_power.unwrap_or_else(|| panic!("{kind:?}: the motor never lit")); + let spooled = spec.accel * (0.45 + 0.55 / f64::from(spec.boost.max(1))); + assert!(gain > 0.0 && gain <= spooled + 1e-9, "{kind:?}: the motor reached full thrust at once ({gain} > {spooled})"); + } + } +} + +/// The electric P1-SUN is not a rocket: it keeps no flame, no powered smoke and its own voice. +#[test] +fn p1sun_stays_a_ramming_drone_without_a_plume() { + let kind = MissileKind::P1sun; + let spec = kind.spec(); + assert_eq!(spec.flame, 0.0); + assert_eq!(spec.cold, 0); + let mut sim = Sim::new(120); + let target_pos = DVec3::new(200.0, 64.0, 0.0); + sim.add_drone(DroneKind::Shahed, target_pos, DVec3::ZERO, target_pos); + let (target, id) = (sim.drones[0].id, sim.id()); + sim.missiles.push(Missile { id, kind, pos: DVec3::ZERO, vel: DVec3::X * 0.8, target, sam: 999, age: 0, hold: 0, + fix: Some(Fix { pos: target_pos, vel: DVec3::ZERO, tick: 0, seeker: false }), seen: 0 }); + for _ in 0..40 { + sim.tick += 1; + let mut out = Out::default(); + sim.step_missiles(&mut Ground::default(), &mut out); + assert!(!out.particles.iter().any(|p| p.kind == "minecraft:flame"), "P1-SUN grew a rocket plume"); + if let Some(m) = sim.missiles.iter().find(|m| m.id == id) { + assert!(m.engine_lit(), "the electric interceptor's motor state is wrong"); + let (event, _, _) = kind.engine_voice(); + assert!(out.engines.iter().any(|e| e.key == (0x4000_0000 | id) && e.event == event)); + } else { + break; + } + } +} + +/// A real S-400 launch records the ejection and the ignition `cold` ticks apart; the replay's lit +/// state follows the recorded ignition, so a seek across it shows one flame and one voice sequence. +#[test] +fn recorded_ignition_is_cold_ticks_after_ejection_and_replay_follows_it() { + let mut sim = Sim::new(0x129); + let sam = sim.id(); + sim.sams.push(Sam::new(sam, SamKind::S400, DVec3::new(0.0, 64.0, 0.0), 0.0)); + sim.sams[0].cooldown = 0; + let mut out = Out::default(); + sim.launch(DroneKind::Kh101, DVec3::new(0.0, 160.0, -600.0), DVec3::Z, DVec3::new(0.0, 64.0, 0.0), &mut out); + let mut ground = Ground::default(); + for _ in 0..200 { + // The firing system's own measurement, fresh this tick (as `why::radar_sees` feeds it): + // the test is about the launch stages, not about the scan's chance. + let now = sim.tick; + let tracks: Vec = sim.drones.iter().filter(|d| d.alive()) + .map(|d| sight::Track { id: d.id, kind: d.kind, pos: d.pos, vel: d.vel, tick: now, own_tick: now, ttl: 400, hits: 3, locked: false }) + .collect(); + sim.sams[0].tracks.retain(|t| !tracks.iter().any(|n| n.id == t.id)); + sim.sams[0].tracks.extend(tracks); + let wanted = ground.take_wanted(); + ground.answer(wanted.into_iter().map(|(x, z)| (x, z, 64)).collect(), sim.tick); + out = Out::default(); + sim.step(&mut ground, None, &mut out); + if sim.replays.iter().chain(sim.rec.iter()).flat_map(|r| r.scene.iter().flat_map(|s| s.events())).any(|e| matches!(e.kind, record::SceneKind::MotorIgnition { .. })) { + break; + } + } + let rec = sim.rec.as_ref().or_else(|| sim.replays.last()).expect("a recording"); + let scene = rec.scene.as_ref().unwrap(); + let launch = scene.events().iter().find(|e| matches!(e.kind, record::SceneKind::MissileLaunch { .. })).expect("the ejection is recorded"); + let missile = match launch.kind { record::SceneKind::MissileLaunch { missile, .. } => missile, _ => unreachable!() }; + let spec = MissileKind::M48n6.spec(); + let ignition = rec.missile_ignition(missile).expect("the ignition is recorded"); + assert_eq!(ignition.at - launch.at, spec.cold, "ignition {} ticks after the ejection", spec.cold); + let track = rec.track(missile).expect("the interceptor's track"); + for t in [ignition.at as f64 - 1.0, ignition.at as f64 - 0.1] { + assert!(!rec.missile_is_lit(track, t, spec.cold), "lit before the recorded ignition at {t}"); + } + for t in [ignition.at as f64, ignition.at as f64 + 5.0] { + assert!(rec.missile_is_lit(track, t, spec.cold), "not lit at the recorded ignition ({t})"); + } + assert!(!rec.missile_is_lit(track, ignition.at as f64 + spec.cold as f64 + f64::from(MissileKind::M48n6.burn_ticks()) + 2.0, spec.cold), "lit past the recorded burnout"); +} + +/// The physical RM48U strike ejects unlit and slow, then lights at `EJECT_TICKS` where it has +/// reached: no motor voice or flame before, one ignition sound at the hand-over. +#[test] +fn rm48u_strike_ejects_before_ignition_on_its_own_path() { + use super::sites::testland::{Land, serve}; + use super::sites::{LaunchSite, SiteClass}; + use super::strike::{Estimate, Known, SiteState}; + use glam::DVec2; + let land = Land(|_, _| 64); + let mut sim = Sim::new(11); + // A fixed site the strike needs no scouting for; its launches are queued far ahead, so a wave + // boundary does not retire it while the test runs. + let id = sim.sites.add(LaunchSite { + id: 0, side: 0, sector: 0, bearing: 0.0, class: SiteClass::Ramp, + pos: DVec3::new(0.0, 64.0, 400.0), range: 400.0, water: false, + stock: 4, planned: 4, alive: true, next_free: 0, offmap: false, + }); + for i in 0..4 { + let site = sim.sites.get(id).unwrap().clone(); + let d = sim.make_drone(DroneKind::Shahed, site.muzzle(i), -DVec3::Z, DVec3::new(0.0, 70.0, 0.0)); + sim.sites.job(d.id, id); + sim.queue.push((20_000 + u64::from(i), d)); + } + sim.strikes.known.push(Known { + site: id, class: SiteClass::Ramp, obs: Vec::new(), + est: Some(Estimate { pos: DVec2::new(0.0, 400.0), radius: 5.0, fix: true, bearing: 0.0, bearing_err: 0.01, origin: DVec2::ZERO }), + launches: 1, last: 0, state: SiteState::Active, aimed: false, + }); + let carrier = sim.id(); + let mut sam = Sam::new(carrier, SamKind::S400, DVec3::new(0.0, 64.0, 0.0), 0.0); + sam.missiles = 2; + sam.ground_rounds = 2; + sam.cooldown = 0; + sim.sams.push(sam); + let mut out = Out::default(); + sim.order_strike(id, DVec3::ZERO, &mut out).unwrap(); + let (mut ground, mut heard_ignition) = (Ground::default(), 0); + let mut saw_voice_before = false; + let mut saw_flame_before = false; + let mut rise = 0.0f64; + let mut all_messages = Vec::new(); + for _ in 0..(launch::OPEN_TICKS as usize + strike::EJECT_TICKS as usize + 6) { + serve(&mut ground, &land, sim.tick); + out = Out::default(); + sim.step(&mut ground, None, &mut out); + all_messages.append(&mut out.messages); + if let Some(f) = sim.strikes.flights.first() { + let age = sim.tick - f.start; + if age < strike::EJECT_TICKS { + saw_voice_before |= out.engines.iter().any(|e| e.key == 0x7500_0000 + f.id * PARTS); + saw_flame_before |= out.models.iter().any(|m| m.name == "airdef:flame" && m.id == 0x7500_0000 + f.id * PARTS + 1); + rise = rise.max(f.pos.y - f.from.y); + } + if age == strike::EJECT_TICKS { + heard_ignition = out.sounds.iter().filter(|s| s.0 == sound::LAUNCH_S400).count(); + } + } + } + assert!(rise > 10.0, "the ejection rose only {rise} blocks"); + assert!(!saw_voice_before && !saw_flame_before, "powered effects during the unlit ejection"); + assert_eq!(heard_ignition, 1, "exactly one ignition sound at the hand-over: {all_messages:?}"); } #[test] diff --git a/airdef-mod/src/sim/specs_generated.rs b/airdef-mod/src/sim/specs_generated.rs index 7822424..d2347a2 100644 --- a/airdef-mod/src/sim/specs_generated.rs +++ b/airdef-mod/src/sim/specs_generated.rs @@ -300,6 +300,8 @@ const MISSILE_SPEC_PAC3: MissileSpec = MissileSpec { turn: 0.3, boost: 8, cold: 0, + eject: 0.0, + eject_drag: 0.0, pk: 0.9, fuse: 4.0, life: 240, @@ -318,6 +320,8 @@ const MISSILE_SPEC_M57E6: MissileSpec = MissileSpec { turn: 0.32, boost: 5, cold: 0, + eject: 0.0, + eject_drag: 0.0, pk: 0.82, fuse: 3.0, life: 160, @@ -335,7 +339,9 @@ const MISSILE_SPEC_M48N6: MissileSpec = MissileSpec { accel: 0.5, turn: 0.22, boost: 8, - cold: 5, + cold: 20, + eject: 2.4, + eject_drag: 0.07, pk: 0.9, fuse: 5.0, life: 300, @@ -353,7 +359,9 @@ const MISSILE_SPEC_M77N6: MissileSpec = MissileSpec { accel: 0.6, turn: 0.22, boost: 8, - cold: 5, + cold: 24, + eject: 2.6, + eject_drag: 0.07, pk: 0.95, fuse: 6.0, life: 320, @@ -371,7 +379,9 @@ const MISSILE_SPEC_IRIST: MissileSpec = MissileSpec { accel: 0.5, turn: 0.35, boost: 6, - cold: 4, + cold: 8, + eject: 1.8, + eject_drag: 0.1, pk: 0.9, fuse: 4.0, life: 260, @@ -390,6 +400,8 @@ const MISSILE_SPEC_AMRAAM: MissileSpec = MissileSpec { turn: 0.3, boost: 6, cold: 0, + eject: 0.0, + eject_drag: 0.0, pk: 0.85, fuse: 4.0, life: 260, @@ -408,6 +420,8 @@ const MISSILE_SPEC_M9M317: MissileSpec = MissileSpec { turn: 0.25, boost: 8, cold: 0, + eject: 0.0, + eject_drag: 0.0, pk: 0.85, fuse: 5.0, life: 300, @@ -425,7 +439,9 @@ const MISSILE_SPEC_M9M338: MissileSpec = MissileSpec { accel: 0.6, turn: 0.4, boost: 4, - cold: 4, + cold: 8, + eject: 1.6, + eject_drag: 0.1, pk: 0.9, fuse: 3.0, life: 160, @@ -444,6 +460,8 @@ const MISSILE_SPEC_P1SUN: MissileSpec = MissileSpec { turn: 0.3, boost: 4, cold: 0, + eject: 0.0, + eject_drag: 0.0, pk: 0.8, fuse: 3.0, life: 900, diff --git a/airdef-mod/src/sim/strike.rs b/airdef-mod/src/sim/strike.rs index 7329cda..4e268f0 100644 --- a/airdef-mod/src/sim/strike.rs +++ b/airdef-mod/src/sim/strike.rs @@ -25,7 +25,16 @@ use glam::DVec2; /// Minecraft presentation profile; neither dimensions nor timing reproduce a real weapon. pub const ROUND_MODEL: &str = "airdef:rm48u"; pub const ROUND_SCALE: f64 = 1.5; -pub const EJECT_TICKS: u64 = 5; +/// Ticks the gas throw takes before the motor lights: the same staged cold launch as the +/// interceptor's `cold` (#129), so a physical strike shows the unlit ejection too. +pub const EJECT_TICKS: u64 = 20; + +/// The unlit ejection's height at flight age `age`: a gas throw of 2.2 blocks a tick with a +/// linear drag of 0.09 a tick², integrated (game scale, like the interceptor's `eject`). +fn eject_rise(age: u64) -> f64 { + let a = age as f64; + (2.2 * a - 0.045 * a * a).max(0.0) +} /// Shared live/replay appearance, including the unlit cold-ejection phase. pub(super) fn round_visual(out: &mut Out, id: u32, pos: DVec3, rot: DQuat, lit: bool) { @@ -581,10 +590,18 @@ impl Sim { let end = DVec3::new(f.land.x, below.unwrap_or(f.from.y), f.land.y); let t = ((now - f.start) as f64 / (f.arrive - f.start).max(1) as f64).clamp(0.0, 1.0); let age = now.saturating_sub(f.start); + let arc = |t: f64| TUNE.height * (std::f64::consts::PI * t).sin().max(0.0).sqrt(); let planned = if f.carrier.is_some() && age < EJECT_TICKS { - f.from + DVec3::Y * (age as f64 * 0.7) + // The unlit gas throw: it rises and slows, no motor yet (#129). + f.from + DVec3::Y * eject_rise(age) + } else if f.carrier.is_some() { + // From where the ejection reached, onto the cruise arc: the plan is continuous + // at ignition, and the flight time counted at the order includes the ejection. + let top = f.from + DVec3::Y * eject_rise(EJECT_TICKS); + let t = ((age - EJECT_TICKS) as f64 / (f.arrive - f.start).saturating_sub(EJECT_TICKS).max(1) as f64).clamp(0.0, 1.0); + top.lerp(end, t) + DVec3::Y * arc(t) } else { - f.from.lerp(end, t) + DVec3::Y * (TUNE.height * (std::f64::consts::PI * t).sin().max(0.0).sqrt()) + f.from.lerp(end, t) + DVec3::Y * arc(t) }; let from = f.pos; // Time spent waiting for world data must not accumulate into a faster step. @@ -753,7 +770,9 @@ impl Sim { self.intel.revealed.push((p.carrier, sam.pos, self.tick)); self.strikes.fired += 1; self.strikes.next += 1; - let ticks = (dist / TUNE.speed).ceil().max(20.0) as u64; + // The counted flight includes the unlit ejection: the missile is honest about + // arriving a second later (#129). + let ticks = (dist / TUNE.speed).ceil().max(20.0) as u64 + EJECT_TICKS; let (gx, gz) = (gauss(self.seed, self.strikes.next as u64, p.site as u64, 7), gauss(self.seed, self.strikes.next as u64, p.site as u64, 8)); self.strikes.flights.push(Flight { id: self.strikes.next, site: p.site, carrier: Some(p.carrier), from: start, pos: start, aim: p.aim, land: p.aim + DVec2::new(gx, gz) * TUNE.cep, start: self.tick, arrive: self.tick + ticks, snap, hold: 0 }); @@ -790,7 +809,8 @@ impl Sim { self.strikes.stock -= 1; self.strikes.fired += 1; self.strikes.next += 1; - let ticks = (dist / TUNE.speed).ceil().max(20.0) as u64; + // The counted flight includes the unlit ejection (#129). + let ticks = (dist / TUNE.speed).ceil().max(20.0) as u64 + EJECT_TICKS; let start = from + DVec3::Y * 1.5; // Its own scatter at the aim, rolled once here: the flight and its outcome name one point. let (gx, gz) = (gauss(self.seed, self.strikes.next as u64, id as u64, 7), gauss(self.seed, self.strikes.next as u64, id as u64, 8)); diff --git a/airdef-mod/src/smoke.rs b/airdef-mod/src/smoke.rs index 9459acb..64ae252 100644 --- a/airdef-mod/src/smoke.rs +++ b/airdef-mod/src/smoke.rs @@ -608,13 +608,15 @@ mod tests { #[test] fn recorded_physical_strike_trail_starts_at_ignition_not_at_the_muzzle() { use crate::sim::record::{Recording, What}; - let mut rec = Recording::blank(40); - rec.straight(1, What::StrikeCarrier(77), 10, 30, DVec3::ZERO, DVec3::Z * 20.0); + let ignite = 10.0 + crate::sim::strike::EJECT_TICKS as f64; + let mut rec = Recording::blank(120); + rec.straight(1, What::StrikeCarrier(77), 10, 90, DVec3::ZERO, DVec3::Z * 80.0); assert!(replay_smoke(&rec, 14.0).is_empty()); - let segments = replay_smoke(&rec, 18.0); - assert_eq!(segments.len(), 1); - assert!((segments[0].az - 5.0).abs() < 1e-6); - assert!((segments[0].bz - 8.0).abs() < 1e-6); + assert!(replay_smoke(&rec, (ignite - 2.0).max(30.0)).is_empty(), "no trail before the ignition"); + let segments = replay_smoke(&rec, ignite + 12.0); + assert_eq!(segments.len(), 2); + assert!((segments[0].az - 20.0).abs() < 1e-6 && (segments[0].bz - 24.0).abs() < 1e-6, "the trail begins at the recorded ignition"); + assert!((segments[1].az - 24.0).abs() < 1e-6 && (segments[1].bz - 32.0).abs() < 1e-6); } #[test] diff --git a/catalog/specs.json b/catalog/specs.json index 356285a..5731175 100644 --- a/catalog/specs.json +++ b/catalog/specs.json @@ -679,6 +679,8 @@ "turn": 0.3, "boost": 8, "cold": 0, + "eject": 0.0, + "eject_drag": 0.0, "pk": 0.9, "fuse": 4.0, "life": 240, @@ -697,6 +699,8 @@ "turn": 0.32, "boost": 5, "cold": 0, + "eject": 0.0, + "eject_drag": 0.0, "pk": 0.82, "fuse": 3.0, "life": 160, @@ -714,7 +718,9 @@ "accel": 0.5, "turn": 0.22, "boost": 8, - "cold": 5, + "cold": 20, + "eject": 2.4, + "eject_drag": 0.07, "pk": 0.9, "fuse": 5.0, "life": 300, @@ -732,7 +738,9 @@ "accel": 0.6, "turn": 0.22, "boost": 8, - "cold": 5, + "cold": 24, + "eject": 2.6, + "eject_drag": 0.07, "pk": 0.95, "fuse": 6.0, "life": 320, @@ -750,7 +758,9 @@ "accel": 0.5, "turn": 0.35, "boost": 6, - "cold": 4, + "cold": 8, + "eject": 1.8, + "eject_drag": 0.1, "pk": 0.9, "fuse": 4.0, "life": 260, @@ -769,6 +779,8 @@ "turn": 0.3, "boost": 6, "cold": 0, + "eject": 0.0, + "eject_drag": 0.0, "pk": 0.85, "fuse": 4.0, "life": 260, @@ -787,6 +799,8 @@ "turn": 0.25, "boost": 8, "cold": 0, + "eject": 0.0, + "eject_drag": 0.0, "pk": 0.85, "fuse": 5.0, "life": 300, @@ -804,7 +818,9 @@ "accel": 0.6, "turn": 0.4, "boost": 4, - "cold": 4, + "cold": 8, + "eject": 1.6, + "eject_drag": 0.1, "pk": 0.9, "fuse": 3.0, "life": 160, @@ -823,6 +839,8 @@ "turn": 0.3, "boost": 4, "cold": 0, + "eject": 0.0, + "eject_drag": 0.0, "pk": 0.8, "fuse": 3.0, "life": 900, diff --git a/tools/airdef_assets/catalog.py b/tools/airdef_assets/catalog.py index ae3f05f..afb0e2a 100644 --- a/tools/airdef_assets/catalog.py +++ b/tools/airdef_assets/catalog.py @@ -230,8 +230,8 @@ def validate_specs(data, content, events): missile_kinds = set() for i, entry in enumerate(data["missiles"]): where = f"missiles[{i}]" - _exact(entry, {"kind", "model", "speed", "accel", "turn", "boost", "cold", "pk", "fuse", - "life", "scale", "flame", "rear", "seeker", "launch_sound", "voice"}, where) + _exact(entry, {"kind", "model", "speed", "accel", "turn", "boost", "cold", "eject", "eject_drag", + "pk", "fuse", "life", "scale", "flame", "rear", "seeker", "launch_sound", "voice"}, where) kind = _text(entry["kind"], f"{where}.kind", r"[A-Z][A-Za-z0-9]*") if kind in missile_kinds: raise ValueError(f"{where}: duplicate missile kind {kind}") @@ -241,6 +241,8 @@ def validate_specs(data, content, events): _number(entry[field], f"{where}.{field}", 0) _integer(entry["boost"], f"{where}.boost") _integer(entry["cold"], f"{where}.cold") + _number(entry["eject"], f"{where}.eject", 0) + _number(entry["eject_drag"], f"{where}.eject_drag", 0) _integer(entry["life"], f"{where}.life", 1) _sound(entry["launch_sound"], f"{where}.launch_sound", events) _exact(entry["voice"], {"event", "gain", "pitch"}, f"{where}.voice") diff --git a/tools/gen_catalog.py b/tools/gen_catalog.py index 681b1a2..323330a 100644 --- a/tools/gen_catalog.py +++ b/tools/gen_catalog.py @@ -167,6 +167,8 @@ def missile_profile(entry): f" turn: {num(entry['turn'])},", f" boost: {integer(entry['boost'])},", f" cold: {integer(entry['cold'])},", + f" eject: {num(entry['eject'])},", + f" eject_drag: {num(entry['eject_drag'])},", f" pk: {num(entry['pk'])},", f" fuse: {num(entry['fuse'])},", f" life: {integer(entry['life'])},",