From ead87343d2999e695c015adc844936a935873bc9 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 4 Nov 2026 20:31:41 +0300 Subject: [PATCH] feat: generate simulation numeric profiles from catalog/specs.json catalog/specs.json is now the single copy of the numeric rows for DroneSpec, MissileSpec and SamSpec: speed, guidance, warheads, models' parts, RCS, seekers, launch and motor sounds, mount drives, gravity, the Oreshnik split height and logistics limits, plus the named part sets. tools/gen_catalog.py generates airdef-mod/src/sim/specs_generated.rs (included by mod.rs, so the mod and airdef-core compile the same rows) and unit_specs_generated.rs (included by units.rs). The hand-written spec matches are gone; DroneKind::spec, MissileKind::spec and SamKind::spec read the generated constants. load_specs validates the schema, sound references, kind coverage against content.json, named-part references and unused rows. gen_catalog.py --check already runs in the CI generated-assets step, so a JSON/Rust drift fails the build. Every value was compared with the literals at 632061d field by field. The only mismatch was the P1-SUN motor voice pitch in JSON (1.6 instead of the actual 1.7, the flame == 0 branch in voices.rs); the JSON was corrected and behaviour is unchanged. Save keys, raid order, RNG and gameplay traces are untouched. Verified: core release 194 passed + 8 scenarios (7 ignored), adapter 64 unit + 1 boundary + 5 scenarios, WASM contract 27 steps, catalog and model generators fresh, asset/sound/model-bound/report tests pass. --- airdef-mod/src/sim/catalog.rs | 28 + airdef-mod/src/sim/logistics.rs | 8 - airdef-mod/src/sim/mod.rs | 435 +---------- airdef-mod/src/sim/specs_generated.rs | 524 +++++++++++++ airdef-mod/src/sim/unit_specs_generated.rs | 424 ++++++++++ airdef-mod/src/sim/units.rs | 372 +-------- airdef-mod/src/sim/voices.rs | 15 +- catalog/specs.json | 851 +++++++++++++++++++++ docs/CODEX-HANDOFF.md | 6 + docs/CONTINUATION-2026-11-04.md | 27 + docs/IMPLEMENTATION-STATUS.md | 2 +- docs/REPO-MAP.md | 4 +- tools/airdef_assets/catalog.py | 325 +++++++- tools/gen_catalog.py | 270 ++++++- 14 files changed, 2476 insertions(+), 815 deletions(-) create mode 100644 airdef-mod/src/sim/specs_generated.rs create mode 100644 airdef-mod/src/sim/unit_specs_generated.rs create mode 100644 catalog/specs.json diff --git a/airdef-mod/src/sim/catalog.rs b/airdef-mod/src/sim/catalog.rs index eea435e..ff2aaab 100644 --- a/airdef-mod/src/sim/catalog.rs +++ b/airdef-mod/src/sim/catalog.rs @@ -172,4 +172,32 @@ mod tests { assert_eq!(kinds, SamKind::ALL.into_iter().collect()); assert_eq!(&RAID_KINDS[..11], &[Gerbera, Shahed, Geran3, Lancet, Kh101, Kalibr, Kh22, Zircon, Iskander, Kinzhal, Oreshnik]); } + + /// The catalog is the only copy of the numbers: a kind without a row, or a row under + /// another kind, would be a silent balance change. + #[test] + fn generated_specs_cover_every_kind_and_keep_their_keys() { + let mut drones = HashSet::new(); + let mut drone_keys = HashSet::new(); + for (kind, key) in super::super::DRONE_SPEC_KEYS { + assert_eq!(kind.spec().key, key, "profile/key mismatch for {kind:?}"); + assert!(drones.insert(kind), "duplicate drone profile {kind:?}"); + assert!(drone_keys.insert(key), "duplicate drone save key {key}"); + assert!(kind.spec().rcs > 0.0 && kind.spec().launch_sound.starts_with("airdef:")); + } + assert_eq!(drones, DroneKind::ALL.into_iter().collect()); + let mut missiles = HashSet::new(); + for (kind, model) in super::super::MISSILE_SPEC_MODELS { + assert_eq!(kind.spec().model, model, "profile/model mismatch for {kind:?}"); + assert!(missiles.insert(kind), "duplicate missile profile {kind:?}"); + } + let mut units = HashSet::new(); + let mut unit_keys = HashSet::new(); + for (kind, key) in super::super::units::UNIT_SPEC_KEYS { + assert_eq!(kind.spec().key, key, "profile/key mismatch for {kind:?}"); + assert!(units.insert(kind), "duplicate unit profile {kind:?}"); + assert!(unit_keys.insert(key), "duplicate unit save key {key}"); + } + assert_eq!(units, SamKind::ALL.into_iter().collect()); + } } diff --git a/airdef-mod/src/sim/logistics.rs b/airdef-mod/src/sim/logistics.rs index dd7a82b..2df706a 100644 --- a/airdef-mod/src/sim/logistics.rs +++ b/airdef-mod/src/sim/logistics.rs @@ -4,14 +4,6 @@ //! reloads to the systems that wait, nearest first, and back. use super::*; -/// How near a depot a system reloads straight from it. -const DEPOT_REACH: f64 = 60.0; -/// How near a truck must come to hand a reload over, or to load up at a depot. -const HANDOVER: f64 = 10.0; -/// What a truck carries. -const TRUCK_LOAD: u32 = 2; -const STOCK_LIMIT: u32 = 1_000_000; - impl Sim { /// Whether ammunition is counted: a depot was set up (without one, magazines refill as /// before). A destroyed depot still counts: losing the last one leaves the systems short, diff --git a/airdef-mod/src/sim/mod.rs b/airdef-mod/src/sim/mod.rs index 4914126..4fc1621 100644 --- a/airdef-mod/src/sim/mod.rs +++ b/airdef-mod/src/sim/mod.rs @@ -592,9 +592,6 @@ pub fn roles_text(mask: u8) -> String { Class::ALL.iter().filter(|c| mask & c.bit() != 0).map(|c| c.letter()).collect::>().join("") } -/// Ballistic flight's gravity, blocks a tick². -const GRAVITY: f64 = 0.05; - #[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] pub enum DroneKind { Shahed, @@ -645,11 +642,15 @@ pub struct DroneSpec { pub decoy: bool, /// How hard it is to hit (1 normal). stealth: f64, + /// Its radar cross-section (a Shahed's 0.3). + rcs: f64, /// Its sound: event, pitch, every n ticks. sound: (&'static str, f32, u32), pub class: Class, /// Ballistic: launched this far away on a raid, flying an arc. pub ballistic: Option, + /// The sound of its launch from the ground. + launch_sound: &'static str, } impl DroneSpec { @@ -680,280 +681,17 @@ impl DroneKind { ]; /// The sound of a launch from the ground: a light catapult shot, a rocket booster, a heavy ballistic launch. pub fn launch_sound(self) -> &'static str { - match self { - DroneKind::Shahed | DroneKind::Geran3 | DroneKind::Lancet | DroneKind::Gerbera => sound::LAUNCH_S400_EJECT, - DroneKind::Kh101 | DroneKind::Kalibr | DroneKind::Kh22 => sound::LAUNCH_PATRIOT, - DroneKind::Zircon => sound::LAUNCH_S400, - DroneKind::Iskander | DroneKind::Kinzhal | DroneKind::Oreshnik | DroneKind::OreshnikBlock | DroneKind::OreshnikSub => { - sound::LAUNCH_S500 - } - } - } - pub fn spec(self) -> &'static DroneSpec { - match self { - DroneKind::Shahed => &DroneSpec { - name: "Шахед-136", - key: "shahed", - model: "airdef:shahed", - speed: 0.6, - turn: 0.035, - cruise: 45.0, - clearance: 18.0, - dive: 32.0, - warhead: 3, - scale: 1.3, - engine: Engine::Prop(1.34, 1.0), - decoy: false, - stealth: 1.0, - sound: (sound::DRONE_SHAHED, 1.0, 48), - class: Class::Drone, - ballistic: None, - }, - DroneKind::Geran3 => &DroneSpec { - name: "Герань-3", - key: "geran3", - model: "airdef:geran3", - speed: 1.6, - turn: 0.03, - cruise: 60.0, - clearance: 18.0, - dive: 50.0, - warhead: 2, - scale: 1.15, - engine: Engine::Jet(0.34, 1.55, 1.0), - decoy: false, - stealth: 1.1, - sound: (sound::DRONE_GERAN3, 1.0, 40), - class: Class::Jet, - ballistic: None, - }, - DroneKind::Lancet => &DroneSpec { - name: "Ланцет-3", - key: "lancet", - model: "airdef:lancet", - speed: 1.1, - turn: 0.07, - cruise: 25.0, - clearance: 10.0, - dive: 38.0, - warhead: 1, - scale: 0.9, - engine: Engine::Prop(0.93, 0.55), - decoy: false, - stealth: 1.4, - sound: (sound::DRONE_LANCET, 1.0, 32), - class: Class::Drone, - ballistic: None, - }, - DroneKind::Gerbera => &DroneSpec { - name: "Гербера", - key: "gerbera", - model: "airdef:gerbera", - speed: 0.5, - turn: 0.04, - cruise: 40.0, - clearance: 15.0, - dive: 30.0, - warhead: 0, - scale: 1.1, - engine: Engine::Prop(1.03, 0.9), - decoy: true, - stealth: 0.9, - sound: (sound::DRONE_SHAHED, 1.3, 40), - class: Class::Drone, - ballistic: None, - }, - DroneKind::Kh101 => &DroneSpec { - name: "Х-101", - key: "kh101", - model: "airdef:kh101", - speed: 2.4, - turn: 0.025, - cruise: 0.0, - clearance: 12.0, - dive: 26.0, - warhead: 6, - scale: 1.6, - engine: Engine::Jet(-0.27, 1.4, 0.6), - decoy: false, - stealth: 1.5, - sound: (sound::DRONE_CRUISE, 1.0, 50), - class: Class::Cruise, - ballistic: None, - }, - DroneKind::Kalibr => &DroneSpec { - name: "Калибр", - key: "kalibr", - model: "airdef:kalibr", - speed: 2.1, - turn: 0.025, - cruise: 0.0, - clearance: 15.0, - dive: 26.0, - warhead: 5, - scale: 1.5, - engine: Engine::Jet(-0.2, 1.5, 0.6), - decoy: false, - stealth: 1.3, - sound: (sound::DRONE_CRUISE, 1.2, 50), - class: Class::Cruise, - ballistic: None, - }, - DroneKind::Kh22 => &DroneSpec { - name: "Х-22", - key: "kh22", - model: "airdef:kh22", - speed: 4.0, - turn: 0.03, - cruise: 140.0, - clearance: 40.0, - dive: 150.0, - warhead: 7, - scale: 1.8, - engine: Engine::Rocket(1.5, 1.3), - decoy: false, - stealth: 1.4, - sound: (sound::DRONE_ROCKET, 0.8, 46), - class: Class::Supersonic, - ballistic: None, - }, - DroneKind::Zircon => &DroneSpec { - name: "Циркон", - key: "zircon", - model: "airdef:zircon", - speed: 5.5, - turn: 0.025, - cruise: 90.0, - clearance: 30.0, - dive: 110.0, - warhead: 6, - scale: 1.4, - engine: Engine::Rocket(1.5, 1.1), - decoy: false, - stealth: 2.0, - sound: (sound::DRONE_ROCKET, 1.35, 46), - class: Class::Supersonic, - ballistic: None, - }, - DroneKind::Iskander => &DroneSpec { - name: "Искандер-М", - key: "iskander", - model: "airdef:iskander", - speed: 5.0, - turn: 0.04, - cruise: 0.0, - clearance: 0.0, - dive: 0.0, - warhead: 8, - scale: 1.8, - engine: Engine::Rocket(1.5, 1.6), - decoy: false, - stealth: 1.6, - sound: (sound::DRONE_ROCKET, 0.7, 46), - class: Class::Ballistic, - ballistic: Some(1000.0), - }, - DroneKind::Kinzhal => &DroneSpec { - name: "Кинжал", - key: "kinzhal", - model: "airdef:kinzhal", - speed: 7.5, - turn: 0.03, - cruise: 0.0, - clearance: 0.0, - dive: 0.0, - warhead: 8, - scale: 1.7, - engine: Engine::Rocket(1.5, 1.5), - decoy: false, - stealth: 2.2, - sound: (sound::DRONE_ROCKET, 0.6, 46), - class: Class::Ballistic, - ballistic: Some(1500.0), - }, - DroneKind::Oreshnik => &DroneSpec { - name: "Орешник", - key: "oreshnik", - model: "airdef:iskander", - speed: 9.0, - turn: 0.0, - cruise: 0.0, - clearance: 0.0, - dive: 0.0, - warhead: 0, - scale: 2.6, - engine: Engine::Rocket(1.5, 2.4), - decoy: false, - stealth: 3.0, - sound: (sound::DRONE_ROCKET, 0.5, 46), - class: Class::Ballistic, - ballistic: Some(2600.0), - }, - DroneKind::OreshnikBlock => &DroneSpec { - name: "Орешник: блок", - key: "oreshnik_block", - model: "airdef:reentry", - speed: 9.0, - turn: 0.0, - cruise: 0.0, - clearance: 0.0, - dive: 0.0, - warhead: 4, - scale: 1.6, - engine: Engine::Rocket(1.0, 1.0), - decoy: false, - stealth: 3.5, - sound: (sound::DRONE_BALLISTIC, 0.8, 9999), - class: Class::Ballistic, - ballistic: None, - }, - DroneKind::OreshnikSub => &DroneSpec { - name: "Орешник: суббоеприпас", - key: "oreshnik_sub", - model: "airdef:reentry", - speed: 9.0, - turn: 0.05, - cruise: 0.0, - clearance: 0.0, - dive: 0.0, - warhead: 3, - scale: 1.1, - engine: Engine::Rocket(1.0, 1.0), - decoy: false, - stealth: 4.0, - sound: (sound::DRONE_BALLISTIC, 1.0, 9999), - class: Class::Ballistic, - ballistic: None, - }, - } + self.spec().launch_sound } pub fn from_key(key: &str) -> Option { Self::ALL.into_iter().find(|k| k.spec().key == key) } /// Its radar cross-section, a Shahed's 0.3: radars see it from as far as its fourth root allows. pub fn rcs(self) -> f64 { - match self { - DroneKind::Shahed => 0.3, - DroneKind::Geran3 => 0.4, - DroneKind::Lancet => 0.08, - // Corner reflectors make the decoy look as big as a Shahed. - DroneKind::Gerbera => 0.4, - DroneKind::Kh101 => 0.05, - DroneKind::Kalibr => 0.15, - DroneKind::Kh22 => 1.0, - DroneKind::Zircon => 0.4, - DroneKind::Iskander => 0.6, - DroneKind::Kinzhal => 0.5, - DroneKind::Oreshnik => 2.0, - DroneKind::OreshnikBlock => 0.4, - DroneKind::OreshnikSub => 0.08, - } + self.spec().rcs } } -/// Height over the target where the Oreshnik's blocks release their submunitions. -const BLOCK_SPLIT: f64 = 300.0; - #[derive(PartialEq, Clone, Copy)] enum Flight { Cruise, @@ -1066,7 +804,7 @@ impl Drone { // ---------------------------------------------------------------- interceptors -#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] pub enum MissileKind { Pac3, M57e6, @@ -1096,6 +834,12 @@ struct MissileSpec { flame: f64, /// The nozzle, model units behind the centre (z). rear: f64, + /// How far its own seeker holds a target (blocks); 0: command guidance only. + seeker: f64, + /// The sound of its launch. + launch_sound: &'static str, + /// Its motor's voice: event, gain and pitch. + voice: (&'static str, f32, f32), } impl MissileKind { @@ -1106,149 +850,12 @@ impl MissileKind { } /// The sound of its launch. pub fn launch_sound(self) -> &'static str { - match self { - MissileKind::Pac3 => sound::LAUNCH_PATRIOT, - MissileKind::M57e6 => sound::LAUNCH_PANTSIR, - MissileKind::M48n6 => sound::LAUNCH_S400_EJECT, - MissileKind::M77n6 => sound::LAUNCH_S500_EJECT, - MissileKind::IrisT | MissileKind::M9m338 => sound::LAUNCH_S400_EJECT, - MissileKind::Amraam | MissileKind::M9m317 => sound::LAUNCH_PATRIOT, - MissileKind::P1sun => sound::DRONE_LANCET, - } - } - fn spec(self) -> &'static MissileSpec { - match self { - MissileKind::Pac3 => &MissileSpec { - model: "airdef:pac3", - speed: 5.5, - accel: 0.35, - turn: 0.3, - boost: 8, - cold: 0, - pk: 0.9, - fuse: 4.0, - life: 240, - scale: 1.0, - flame: 1.0, - rear: 28.0, - }, - MissileKind::M57e6 => &MissileSpec { - model: "airdef:m57e6", - speed: 5.0, - accel: 0.5, - turn: 0.32, - boost: 5, - cold: 0, - pk: 0.82, - fuse: 3.0, - life: 160, - scale: 0.85, - flame: 0.65, - rear: 28.0, - }, - MissileKind::M48n6 => &MissileSpec { - model: "airdef:m48n6", - speed: 7.0, - accel: 0.5, - turn: 0.22, - boost: 8, - cold: 5, - pk: 0.9, - fuse: 5.0, - life: 300, - scale: 1.5, - flame: 1.4, - rear: 30.0, - }, - MissileKind::M77n6 => &MissileSpec { - model: "airdef:m77n6", - speed: 9.0, - accel: 0.6, - turn: 0.22, - boost: 8, - cold: 5, - pk: 0.95, - fuse: 6.0, - life: 320, - scale: 1.7, - flame: 1.6, - rear: 32.0, - }, - MissileKind::IrisT => &MissileSpec { - model: "airdef:irist_missile", - speed: 7.0, - accel: 0.5, - turn: 0.35, - boost: 6, - cold: 4, - pk: 0.9, - fuse: 4.0, - life: 260, - scale: 0.95, - flame: 0.9, - rear: 28.0, - }, - MissileKind::Amraam => &MissileSpec { - model: "airdef:amraam_missile", - speed: 6.0, - accel: 0.45, - turn: 0.3, - boost: 6, - cold: 0, - pk: 0.85, - fuse: 4.0, - life: 260, - scale: 0.9, - flame: 0.9, - rear: 28.0, - }, - MissileKind::M9m317 => &MissileSpec { - model: "airdef:m48n6", - speed: 6.5, - accel: 0.5, - turn: 0.25, - boost: 8, - cold: 0, - pk: 0.85, - fuse: 5.0, - life: 300, - scale: 1.1, - flame: 1.2, - rear: 32.0, - }, - MissileKind::M9m338 => &MissileSpec { - model: "airdef:m57e6", - speed: 5.5, - accel: 0.6, - turn: 0.4, - boost: 4, - cold: 4, - pk: 0.9, - fuse: 3.0, - life: 160, - scale: 1.0, - flame: 0.8, - rear: 28.0, - }, - MissileKind::P1sun => &MissileSpec { - model: "airdef:p1sun", - speed: 2.8, - accel: 0.12, - turn: 0.3, - boost: 4, - cold: 0, - pk: 0.8, - fuse: 3.0, - life: 900, - scale: 0.8, - // Electric: no flame. - flame: 0.0, - rear: 10.0, - }, - } + self.spec().launch_sound } } +include!("specs_generated.rs"); + pub struct Missile { pub id: u32, pub kind: MissileKind, @@ -1308,15 +915,7 @@ impl Missile { impl MissileKind { /// How far its own seeker holds a target of average size (blocks); 0: command guidance only. pub fn seeker(self) -> f64 { - match self { - MissileKind::M57e6 => 0.0, - MissileKind::Pac3 => 110.0, - MissileKind::M48n6 | MissileKind::M77n6 => 120.0, - MissileKind::IrisT | MissileKind::M9m317 => 90.0, - MissileKind::Amraam => 130.0, - MissileKind::M9m338 => 70.0, - MissileKind::P1sun => 60.0, - } + self.spec().seeker } } diff --git a/airdef-mod/src/sim/specs_generated.rs b/airdef-mod/src/sim/specs_generated.rs new file mode 100644 index 0000000..527d53c --- /dev/null +++ b/airdef-mod/src/sim/specs_generated.rs @@ -0,0 +1,524 @@ +// Generated by tools/gen_catalog.py from catalog/specs.json. Do not edit. +// Game-scale values, not real weapon data; the catalog is the only copy. + +/// Share of the old slew speed a launcher keeps when it has no rate of its own. +const DRIVE_SCALE: f64 = 0.4; +/// Ticks a drive takes to reach its top speed (and to stop from it). +const DRIVE_RAMP: f64 = 8.0; +const DRIVE_TRAINABLE: (f64, f64) = (0.12, 0.0); +const DRIVE_VERTICAL: (f64, f64) = (0.15, 0.0); +const DRIVE_OTHER: (f64, f64) = (0.05, 0.05); +/// Ballistic flight's gravity, blocks a tick². +const GRAVITY: f64 = 0.05; +/// Height over the target where the Oreshnik's blocks release their submunitions. +const BLOCK_SPLIT: f64 = 300.0; +/// How near a depot a system reloads straight from it. +const DEPOT_REACH: f64 = 60.0; +/// How near a truck must come to hand a reload over, or to load up at a depot. +const HANDOVER: f64 = 10.0; +/// What a truck carries. +const TRUCK_LOAD: u32 = 2; +const STOCK_LIMIT: u32 = 1000000; + +const DRONE_SPEC_SHAHED: DroneSpec = DroneSpec { + name: "Шахед-136", + key: "shahed", + model: "airdef:shahed", + speed: 0.6, + turn: 0.035, + cruise: 45.0, + clearance: 18.0, + dive: 32.0, + warhead: 3, + scale: 1.3, + engine: Engine::Prop(1.34, 1.0), + decoy: false, + stealth: 1.0, + rcs: 0.3, + sound: (sound::DRONE_SHAHED, 1.0, 48), + class: Class::Drone, + ballistic: None, + launch_sound: sound::LAUNCH_S400_EJECT, +}; + +const DRONE_SPEC_GERAN3: DroneSpec = DroneSpec { + name: "Герань-3", + key: "geran3", + model: "airdef:geran3", + speed: 1.6, + turn: 0.03, + cruise: 60.0, + clearance: 18.0, + dive: 50.0, + warhead: 2, + scale: 1.15, + engine: Engine::Jet(0.34, 1.55, 1.0), + decoy: false, + stealth: 1.1, + rcs: 0.4, + sound: (sound::DRONE_GERAN3, 1.0, 40), + class: Class::Jet, + ballistic: None, + launch_sound: sound::LAUNCH_S400_EJECT, +}; + +const DRONE_SPEC_LANCET: DroneSpec = DroneSpec { + name: "Ланцет-3", + key: "lancet", + model: "airdef:lancet", + speed: 1.1, + turn: 0.07, + cruise: 25.0, + clearance: 10.0, + dive: 38.0, + warhead: 1, + scale: 0.9, + engine: Engine::Prop(0.93, 0.55), + decoy: false, + stealth: 1.4, + rcs: 0.08, + sound: (sound::DRONE_LANCET, 1.0, 32), + class: Class::Drone, + ballistic: None, + launch_sound: sound::LAUNCH_S400_EJECT, +}; + +const DRONE_SPEC_GERBERA: DroneSpec = DroneSpec { + name: "Гербера", + key: "gerbera", + model: "airdef:gerbera", + speed: 0.5, + turn: 0.04, + cruise: 40.0, + clearance: 15.0, + dive: 30.0, + warhead: 0, + scale: 1.1, + engine: Engine::Prop(1.03, 0.9), + decoy: true, + stealth: 0.9, + rcs: 0.4, + sound: (sound::DRONE_SHAHED, 1.3, 40), + class: Class::Drone, + ballistic: None, + launch_sound: sound::LAUNCH_S400_EJECT, +}; + +const DRONE_SPEC_KH101: DroneSpec = DroneSpec { + name: "Х-101", + key: "kh101", + model: "airdef:kh101", + speed: 2.4, + turn: 0.025, + cruise: 0.0, + clearance: 12.0, + dive: 26.0, + warhead: 6, + scale: 1.6, + engine: Engine::Jet(-0.27, 1.4, 0.6), + decoy: false, + stealth: 1.5, + rcs: 0.05, + sound: (sound::DRONE_CRUISE, 1.0, 50), + class: Class::Cruise, + ballistic: None, + launch_sound: sound::LAUNCH_PATRIOT, +}; + +const DRONE_SPEC_KALIBR: DroneSpec = DroneSpec { + name: "Калибр", + key: "kalibr", + model: "airdef:kalibr", + speed: 2.1, + turn: 0.025, + cruise: 0.0, + clearance: 15.0, + dive: 26.0, + warhead: 5, + scale: 1.5, + engine: Engine::Jet(-0.2, 1.5, 0.6), + decoy: false, + stealth: 1.3, + rcs: 0.15, + sound: (sound::DRONE_CRUISE, 1.2, 50), + class: Class::Cruise, + ballistic: None, + launch_sound: sound::LAUNCH_PATRIOT, +}; + +const DRONE_SPEC_KH22: DroneSpec = DroneSpec { + name: "Х-22", + key: "kh22", + model: "airdef:kh22", + speed: 4.0, + turn: 0.03, + cruise: 140.0, + clearance: 40.0, + dive: 150.0, + warhead: 7, + scale: 1.8, + engine: Engine::Rocket(1.5, 1.3), + decoy: false, + stealth: 1.4, + rcs: 1.0, + sound: (sound::DRONE_ROCKET, 0.8, 46), + class: Class::Supersonic, + ballistic: None, + launch_sound: sound::LAUNCH_PATRIOT, +}; + +const DRONE_SPEC_ZIRCON: DroneSpec = DroneSpec { + name: "Циркон", + key: "zircon", + model: "airdef:zircon", + speed: 5.5, + turn: 0.025, + cruise: 90.0, + clearance: 30.0, + dive: 110.0, + warhead: 6, + scale: 1.4, + engine: Engine::Rocket(1.5, 1.1), + decoy: false, + stealth: 2.0, + rcs: 0.4, + sound: (sound::DRONE_ROCKET, 1.35, 46), + class: Class::Supersonic, + ballistic: None, + launch_sound: sound::LAUNCH_S400, +}; + +const DRONE_SPEC_ISKANDER: DroneSpec = DroneSpec { + name: "Искандер-М", + key: "iskander", + model: "airdef:iskander", + speed: 5.0, + turn: 0.04, + cruise: 0.0, + clearance: 0.0, + dive: 0.0, + warhead: 8, + scale: 1.8, + engine: Engine::Rocket(1.5, 1.6), + decoy: false, + stealth: 1.6, + rcs: 0.6, + sound: (sound::DRONE_ROCKET, 0.7, 46), + class: Class::Ballistic, + ballistic: Some(1000.0), + launch_sound: sound::LAUNCH_S500, +}; + +const DRONE_SPEC_KINZHAL: DroneSpec = DroneSpec { + name: "Кинжал", + key: "kinzhal", + model: "airdef:kinzhal", + speed: 7.5, + turn: 0.03, + cruise: 0.0, + clearance: 0.0, + dive: 0.0, + warhead: 8, + scale: 1.7, + engine: Engine::Rocket(1.5, 1.5), + decoy: false, + stealth: 2.2, + rcs: 0.5, + sound: (sound::DRONE_ROCKET, 0.6, 46), + class: Class::Ballistic, + ballistic: Some(1500.0), + launch_sound: sound::LAUNCH_S500, +}; + +const DRONE_SPEC_ORESHNIK: DroneSpec = DroneSpec { + name: "Орешник", + key: "oreshnik", + model: "airdef:iskander", + speed: 9.0, + turn: 0.0, + cruise: 0.0, + clearance: 0.0, + dive: 0.0, + warhead: 0, + scale: 2.6, + engine: Engine::Rocket(1.5, 2.4), + decoy: false, + stealth: 3.0, + rcs: 2.0, + sound: (sound::DRONE_ROCKET, 0.5, 46), + class: Class::Ballistic, + ballistic: Some(2600.0), + launch_sound: sound::LAUNCH_S500, +}; + +const DRONE_SPEC_ORESHNIKBLOCK: DroneSpec = DroneSpec { + name: "Орешник: блок", + key: "oreshnik_block", + model: "airdef:reentry", + speed: 9.0, + turn: 0.0, + cruise: 0.0, + clearance: 0.0, + dive: 0.0, + warhead: 4, + scale: 1.6, + engine: Engine::Rocket(1.0, 1.0), + decoy: false, + stealth: 3.5, + rcs: 0.4, + sound: (sound::DRONE_BALLISTIC, 0.8, 9999), + class: Class::Ballistic, + ballistic: None, + launch_sound: sound::LAUNCH_S500, +}; + +const DRONE_SPEC_ORESHNIKSUB: DroneSpec = DroneSpec { + name: "Орешник: суббоеприпас", + key: "oreshnik_sub", + model: "airdef:reentry", + speed: 9.0, + turn: 0.05, + cruise: 0.0, + clearance: 0.0, + dive: 0.0, + warhead: 3, + scale: 1.1, + engine: Engine::Rocket(1.0, 1.0), + decoy: false, + stealth: 4.0, + rcs: 0.08, + sound: (sound::DRONE_BALLISTIC, 1.0, 9999), + class: Class::Ballistic, + ballistic: None, + launch_sound: sound::LAUNCH_S500, +}; + +const MISSILE_SPEC_PAC3: MissileSpec = MissileSpec { + model: "airdef:pac3", + speed: 5.5, + accel: 0.35, + turn: 0.3, + boost: 8, + cold: 0, + pk: 0.9, + fuse: 4.0, + life: 240, + scale: 1.0, + flame: 1.0, + rear: 28.0, + seeker: 110.0, + launch_sound: sound::LAUNCH_PATRIOT, + voice: (sound::MISSILE_FLIGHT, 1.0, 1.0), +}; + +const MISSILE_SPEC_M57E6: MissileSpec = MissileSpec { + model: "airdef:m57e6", + speed: 5.0, + accel: 0.5, + turn: 0.32, + boost: 5, + cold: 0, + pk: 0.82, + fuse: 3.0, + life: 160, + scale: 0.85, + flame: 0.65, + rear: 28.0, + seeker: 0.0, + launch_sound: sound::LAUNCH_PANTSIR, + voice: (sound::MISSILE_FLIGHT, 1.0, 1.12), +}; + +const MISSILE_SPEC_M48N6: MissileSpec = MissileSpec { + model: "airdef:m48n6", + speed: 7.0, + accel: 0.5, + turn: 0.22, + boost: 8, + cold: 5, + pk: 0.9, + fuse: 5.0, + life: 300, + scale: 1.5, + flame: 1.4, + rear: 30.0, + seeker: 120.0, + launch_sound: sound::LAUNCH_S400_EJECT, + voice: (sound::MISSILE_FLIGHT, 1.0, 0.88), +}; + +const MISSILE_SPEC_M77N6: MissileSpec = MissileSpec { + model: "airdef:m77n6", + speed: 9.0, + accel: 0.6, + turn: 0.22, + boost: 8, + cold: 5, + pk: 0.95, + fuse: 6.0, + life: 320, + scale: 1.7, + flame: 1.6, + rear: 32.0, + seeker: 120.0, + launch_sound: sound::LAUNCH_S500_EJECT, + voice: (sound::MISSILE_FLIGHT, 1.0, 0.8), +}; + +const MISSILE_SPEC_IRIST: MissileSpec = MissileSpec { + model: "airdef:irist_missile", + speed: 7.0, + accel: 0.5, + turn: 0.35, + boost: 6, + cold: 4, + pk: 0.9, + fuse: 4.0, + life: 260, + scale: 0.95, + flame: 0.9, + rear: 28.0, + seeker: 90.0, + launch_sound: sound::LAUNCH_S400_EJECT, + voice: (sound::MISSILE_FLIGHT, 1.0, 1.05), +}; + +const MISSILE_SPEC_AMRAAM: MissileSpec = MissileSpec { + model: "airdef:amraam_missile", + speed: 6.0, + accel: 0.45, + turn: 0.3, + boost: 6, + cold: 0, + pk: 0.85, + fuse: 4.0, + life: 260, + scale: 0.9, + flame: 0.9, + rear: 28.0, + seeker: 130.0, + launch_sound: sound::LAUNCH_PATRIOT, + voice: (sound::MISSILE_FLIGHT, 1.0, 1.05), +}; + +const MISSILE_SPEC_M9M317: MissileSpec = MissileSpec { + model: "airdef:m48n6", + speed: 6.5, + accel: 0.5, + turn: 0.25, + boost: 8, + cold: 0, + pk: 0.85, + fuse: 5.0, + life: 300, + scale: 1.1, + flame: 1.2, + rear: 32.0, + seeker: 90.0, + launch_sound: sound::LAUNCH_PATRIOT, + voice: (sound::MISSILE_FLIGHT, 1.0, 0.9), +}; + +const MISSILE_SPEC_M9M338: MissileSpec = MissileSpec { + model: "airdef:m57e6", + speed: 5.5, + accel: 0.6, + turn: 0.4, + boost: 4, + cold: 4, + pk: 0.9, + fuse: 3.0, + life: 160, + scale: 1.0, + flame: 0.8, + rear: 28.0, + seeker: 70.0, + launch_sound: sound::LAUNCH_S400_EJECT, + voice: (sound::MISSILE_FLIGHT, 1.0, 1.15), +}; + +const MISSILE_SPEC_P1SUN: MissileSpec = MissileSpec { + model: "airdef:p1sun", + speed: 2.8, + accel: 0.12, + turn: 0.3, + boost: 4, + cold: 0, + pk: 0.8, + fuse: 3.0, + life: 900, + scale: 0.8, + flame: 0.0, + rear: 10.0, + seeker: 60.0, + launch_sound: sound::DRONE_LANCET, + voice: (sound::DRONE_LANCET, 0.8, 1.7), +}; + +impl DroneKind { + /// Its row of numbers from `catalog/specs.json`. + pub fn spec(self) -> &'static DroneSpec { + match self { + DroneKind::Shahed => &DRONE_SPEC_SHAHED, + DroneKind::Geran3 => &DRONE_SPEC_GERAN3, + DroneKind::Lancet => &DRONE_SPEC_LANCET, + DroneKind::Gerbera => &DRONE_SPEC_GERBERA, + DroneKind::Kh101 => &DRONE_SPEC_KH101, + DroneKind::Kalibr => &DRONE_SPEC_KALIBR, + DroneKind::Kh22 => &DRONE_SPEC_KH22, + DroneKind::Zircon => &DRONE_SPEC_ZIRCON, + DroneKind::Iskander => &DRONE_SPEC_ISKANDER, + DroneKind::Kinzhal => &DRONE_SPEC_KINZHAL, + DroneKind::Oreshnik => &DRONE_SPEC_ORESHNIK, + DroneKind::OreshnikBlock => &DRONE_SPEC_ORESHNIKBLOCK, + DroneKind::OreshnikSub => &DRONE_SPEC_ORESHNIKSUB, + } + } +} + +impl MissileKind { + fn spec(self) -> &'static MissileSpec { + match self { + MissileKind::Pac3 => &MISSILE_SPEC_PAC3, + MissileKind::M57e6 => &MISSILE_SPEC_M57E6, + MissileKind::M48n6 => &MISSILE_SPEC_M48N6, + MissileKind::M77n6 => &MISSILE_SPEC_M77N6, + MissileKind::IrisT => &MISSILE_SPEC_IRIST, + MissileKind::Amraam => &MISSILE_SPEC_AMRAAM, + MissileKind::M9m317 => &MISSILE_SPEC_M9M317, + MissileKind::M9m338 => &MISSILE_SPEC_M9M338, + MissileKind::P1sun => &MISSILE_SPEC_P1SUN, + } + } +} + +/// Save keys by kind; a rename needs an explicit migration. +#[cfg(test)] +pub(super) const DRONE_SPEC_KEYS: [(DroneKind, &str); 13] = [ + (DroneKind::Shahed, "shahed"), + (DroneKind::Geran3, "geran3"), + (DroneKind::Lancet, "lancet"), + (DroneKind::Gerbera, "gerbera"), + (DroneKind::Kh101, "kh101"), + (DroneKind::Kalibr, "kalibr"), + (DroneKind::Kh22, "kh22"), + (DroneKind::Zircon, "zircon"), + (DroneKind::Iskander, "iskander"), + (DroneKind::Kinzhal, "kinzhal"), + (DroneKind::Oreshnik, "oreshnik"), + (DroneKind::OreshnikBlock, "oreshnik_block"), + (DroneKind::OreshnikSub, "oreshnik_sub"), +]; + +#[cfg(test)] +pub(super) const MISSILE_SPEC_MODELS: [(MissileKind, &str); 9] = [ + (MissileKind::Pac3, "airdef:pac3"), + (MissileKind::M57e6, "airdef:m57e6"), + (MissileKind::M48n6, "airdef:m48n6"), + (MissileKind::M77n6, "airdef:m77n6"), + (MissileKind::IrisT, "airdef:irist_missile"), + (MissileKind::Amraam, "airdef:amraam_missile"), + (MissileKind::M9m317, "airdef:m48n6"), + (MissileKind::M9m338, "airdef:m57e6"), + (MissileKind::P1sun, "airdef:p1sun"), +]; diff --git a/airdef-mod/src/sim/unit_specs_generated.rs b/airdef-mod/src/sim/unit_specs_generated.rs new file mode 100644 index 0000000..1581692 --- /dev/null +++ b/airdef-mod/src/sim/unit_specs_generated.rs @@ -0,0 +1,424 @@ +// Generated by tools/gen_catalog.py from catalog/specs.json. Do not edit. +// Game-scale values, not real weapon data; the catalog is the only copy. + +const NAMED_BATTERY_RADAR: &[(&str, f64, f64)] = &[("airdef:radar_trailer", 0.0, 4.2)]; +const NAMED_BATTERY_SPINNER: Option<(&str, f64, f64, f64, f64)> = Some(("airdef:radar_panel", 0.9, 4.2, 0.9, -0.3)); +const NAMED_TUBE_PIVOT: DVec3 = DVec3::new(0.0, -0.5, 1.5); + +const UNIT_SPEC_PATRIOT: SamSpec = SamSpec { + name: "Patriot", + key: "patriot", + missile: Some(MissileLoad { kind: MissileKind::Pac3, magazine: 4, min: 10.0, max: 450.0, interval: 16 }), + gun: None, + reload: 220, + scale: 1.3, + rest_pitch: 0.66, + roles: 30, + family: Family::Trainable, + parts: Parts { + body: "airdef:patriot_trailer", + launcher: Some("airdef:patriot_launcher"), + pack: None, + dish: None, + fixed: &[("airdef:patriot_truck", 0.0, -3.0), ("airdef:patriot_radar", 0.0, 3.6)], + spinner: None, + }, + mount: (0.69, 1.2), + pivot: DVec3::new(0.0, -0.5, 1.375), + muzzle: Muzzle::Patriot, + sensor: 585.0, + mast: 5.0, +}; + +const UNIT_SPEC_GEPARD: SamSpec = SamSpec { + name: "Гепард", + key: "gepard", + missile: None, + gun: Some(GunSpec { range: 135.0, track: 195.0, p: 0.3, bursts: 160, shell: 10.0, slew: (0.22, 0.16) }), + reload: 160, + scale: 1.25, + rest_pitch: 0.0, + roles: 7, + family: Family::Gun, + parts: Parts { + body: "airdef:gepard_hull", + launcher: Some("airdef:gepard_turret"), + pack: Some("airdef:gepard_guns"), + dish: Some(("airdef:gepard_dish", 0.95, 0.6, 0.0)), + fixed: &[], + spinner: None, + }, + mount: (1.55, 0.25), + pivot: DVec3::ZERO, + muzzle: Muzzle::None, + sensor: 255.0, + mast: 3.0, +}; + +const UNIT_SPEC_PANTSIR: SamSpec = SamSpec { + name: "Панцирь-С1", + key: "pantsir", + missile: Some(MissileLoad { kind: MissileKind::M57e6, magazine: 12, min: 25.0, max: 225.0, interval: 14 }), + gun: Some(GunSpec { range: 90.0, track: 135.0, p: 0.25, bursts: 140, shell: 9.0, slew: (0.25, 0.18) }), + reload: 280, + scale: 1.3, + rest_pitch: 0.0, + roles: 15, + family: Family::Turret, + parts: Parts { + body: "airdef:pantsir_truck", + launcher: Some("airdef:pantsir_turret"), + pack: Some("airdef:pantsir_pack"), + dish: Some(("airdef:pantsir_dish", 0.85, 0.3, 0.3)), + fixed: &[], + spinner: None, + }, + mount: (1.35, 0.69), + pivot: DVec3::ZERO, + muzzle: Muzzle::Pantsir, + sensor: 292.0, + mast: 4.0, +}; + +const UNIT_SPEC_S400: SamSpec = SamSpec { + name: "С-400", + key: "s400", + missile: Some(MissileLoad { kind: MissileKind::M48n6, magazine: 4, min: 30.0, max: 720.0, interval: 20 }), + gun: None, + reload: 420, + scale: 1.5, + rest_pitch: HALF_PI - 0.02, + roles: 30, + family: Family::Vertical, + parts: Parts { + body: "airdef:s400_chassis", + launcher: Some("airdef:s400_tubes"), + pack: None, + dish: None, + fixed: NAMED_BATTERY_RADAR, + spinner: NAMED_BATTERY_SPINNER, + }, + mount: (0.7, 1.45), + pivot: NAMED_TUBE_PIVOT, + muzzle: Muzzle::Grid(2, 2, 7.0), + sensor: 936.0, + mast: 5.0, +}; + +const UNIT_SPEC_S500: SamSpec = SamSpec { + name: "С-500", + key: "s500", + missile: Some(MissileLoad { kind: MissileKind::M77n6, magazine: 2, min: 40.0, max: 1050.0, interval: 20 }), + gun: None, + reload: 520, + scale: 1.6, + rest_pitch: HALF_PI - 0.02, + roles: 24, + family: Family::Vertical, + parts: Parts { + body: "airdef:s500_chassis", + launcher: Some("airdef:s500_tubes"), + pack: None, + dish: None, + fixed: NAMED_BATTERY_RADAR, + spinner: NAMED_BATTERY_SPINNER, + }, + mount: (0.7, 1.45), + pivot: NAMED_TUBE_PIVOT, + muzzle: Muzzle::Grid(2, 1, 9.5), + sensor: 1365.0, + mast: 5.0, +}; + +const UNIT_SPEC_IRIST: SamSpec = SamSpec { + name: "IRIS-T SLM", + key: "irist", + missile: Some(MissileLoad { kind: MissileKind::IrisT, magazine: 8, min: 15.0, max: 600.0, interval: 10 }), + gun: None, + reload: 300, + scale: 1.4, + rest_pitch: HALF_PI - 0.02, + roles: 15, + family: Family::Vertical, + parts: Parts { + body: "airdef:irist_chassis", + launcher: Some("airdef:irist_tubes"), + pack: None, + dish: None, + fixed: NAMED_BATTERY_RADAR, + spinner: NAMED_BATTERY_SPINNER, + }, + mount: (0.7, 1.45), + pivot: NAMED_TUBE_PIVOT, + muzzle: Muzzle::Grid(4, 2, 4.0), + sensor: 780.0, + mast: 5.0, +}; + +const UNIT_SPEC_NASAMS: SamSpec = SamSpec { + name: "NASAMS", + key: "nasams", + missile: Some(MissileLoad { kind: MissileKind::Amraam, magazine: 6, min: 10.0, max: 450.0, interval: 14 }), + gun: None, + reload: 260, + scale: 1.3, + rest_pitch: 0.5, + roles: 14, + family: Family::Trainable, + parts: Parts { + body: "airdef:nasams_trailer", + launcher: Some("airdef:nasams_launcher"), + pack: None, + dish: None, + fixed: &[("airdef:radar_trailer", 0.0, 3.4)], + spinner: Some(("airdef:radar_panel", 0.9, 3.4, 0.6, -0.3)), + }, + mount: (0.62, 0.4), + pivot: NAMED_TUBE_PIVOT, + muzzle: Muzzle::Grid(3, 2, 4.0), + sensor: 585.0, + mast: 5.0, +}; + +const UNIT_SPEC_BUK: SamSpec = SamSpec { + name: "Бук-М2", + key: "buk", + missile: Some(MissileLoad { kind: MissileKind::M9m317, magazine: 4, min: 20.0, max: 675.0, interval: 18 }), + gun: None, + reload: 400, + scale: 1.4, + rest_pitch: 0.0, + roles: 14, + family: Family::Turret, + parts: Parts { + body: "airdef:buk_chassis", + launcher: Some("airdef:buk_turret"), + pack: Some("airdef:buk_rails"), + dish: None, + fixed: &[], + spinner: None, + }, + mount: (1.55, 0.2), + pivot: DVec3::ZERO, + muzzle: Muzzle::Rails(&[-9.0, -3.0, 13.0, 19.0]), + sensor: 878.0, + mast: 4.0, +}; + +const UNIT_SPEC_TOR: SamSpec = SamSpec { + name: "Тор-М2", + key: "tor", + missile: Some(MissileLoad { kind: MissileKind::M9m338, magazine: 8, min: 8.0, max: 240.0, interval: 8 }), + gun: None, + reload: 300, + scale: 1.3, + rest_pitch: 0.0, + roles: 15, + family: Family::Vertical, + parts: Parts { + body: "airdef:tor_chassis", + launcher: Some("airdef:tor_turret"), + pack: None, + dish: None, + fixed: &[], + spinner: None, + }, + mount: (1.5, 0.0), + pivot: DVec3::ZERO, + muzzle: Muzzle::Cells, + sensor: 315.0, + mast: 3.5, +}; + +const UNIT_SPEC_P1SUN: SamSpec = SamSpec { + name: "P1-SUN", + key: "p1sun", + missile: Some(MissileLoad { kind: MissileKind::P1sun, magazine: 8, min: 5.0, max: 675.0, interval: 12 }), + gun: None, + reload: 240, + scale: 1.1, + rest_pitch: 0.35, + roles: 1, + family: Family::Trainable, + parts: Parts { + body: "airdef:nasams_trailer", + launcher: Some("airdef:p1sun_launcher"), + pack: None, + dish: None, + fixed: &[], + spinner: None, + }, + mount: (0.6, 0.3), + pivot: NAMED_TUBE_PIVOT, + muzzle: Muzzle::Grid(4, 2, 3.0), + sensor: 0.0, + mast: 2.0, +}; + +const UNIT_SPEC_RADAR: SamSpec = SamSpec { + name: "РЛС дальнего обнаружения", + key: "radar", + missile: None, + gun: None, + reload: 0, + scale: 1.5, + rest_pitch: 0.0, + roles: 0, + family: Family::Static, + parts: Parts { + body: "airdef:radar_truck", + launcher: None, + pack: None, + dish: None, + fixed: &[], + spinner: Some(("airdef:radar_panel", 1.2, 1.2, 1.5, -0.25)), + }, + mount: (0.0, 0.0), + pivot: DVec3::ZERO, + muzzle: Muzzle::None, + sensor: 2550.0, + mast: 16.0, +}; + +const UNIT_SPEC_DEPOT: SamSpec = SamSpec { + name: "Склад боеприпасов", + key: "depot", + missile: None, + gun: None, + reload: 0, + scale: 1.5, + rest_pitch: 0.0, + roles: 0, + family: Family::Static, + parts: Parts { + body: "airdef:depot", + launcher: None, + pack: None, + dish: None, + fixed: &[], + spinner: None, + }, + mount: (0.0, 0.0), + pivot: DVec3::ZERO, + muzzle: Muzzle::None, + sensor: 0.0, + mast: 1.0, +}; + +const UNIT_SPEC_TZM: SamSpec = SamSpec { + name: "ТЗМ", + key: "tzm", + missile: None, + gun: None, + reload: 0, + scale: 1.3, + rest_pitch: 0.0, + roles: 0, + family: Family::Truck, + parts: Parts { + body: "airdef:tzm_truck", + launcher: None, + pack: None, + dish: None, + fixed: &[], + spinner: None, + }, + mount: (0.0, 0.0), + pivot: DVec3::ZERO, + muzzle: Muzzle::None, + sensor: 0.0, + mast: 2.0, +}; + +const UNIT_SPEC_EW: SamSpec = SamSpec { + name: "Станция РЭБ", + key: "ew", + missile: None, + gun: None, + reload: 0, + scale: 1.3, + rest_pitch: 0.0, + roles: 0, + family: Family::Static, + parts: Parts { + body: "airdef:ew_truck", + launcher: None, + pack: None, + dish: None, + fixed: &[], + spinner: None, + }, + mount: (0.0, 0.0), + pivot: DVec3::ZERO, + muzzle: Muzzle::None, + sensor: 0.0, + mast: 3.0, +}; + +const UNIT_SPEC_LIGHT: SamSpec = SamSpec { + name: "Прожектор", + key: "light", + missile: None, + gun: None, + reload: 0, + scale: 1.2, + rest_pitch: 0.6, + roles: 0, + family: Family::Light, + parts: Parts { + body: "airdef:searchlight", + launcher: Some("airdef:searchlight_head"), + pack: None, + dish: None, + fixed: &[], + spinner: None, + }, + mount: (0.85, 0.0), + pivot: DVec3::ZERO, + muzzle: Muzzle::None, + sensor: 0.0, + mast: 1.5, +}; + +impl SamKind { + /// Its row of numbers from `catalog/specs.json`. + pub fn spec(self) -> &'static SamSpec { + match self { + SamKind::Patriot => &UNIT_SPEC_PATRIOT, + SamKind::Gepard => &UNIT_SPEC_GEPARD, + SamKind::Pantsir => &UNIT_SPEC_PANTSIR, + SamKind::S400 => &UNIT_SPEC_S400, + SamKind::S500 => &UNIT_SPEC_S500, + SamKind::IrisT => &UNIT_SPEC_IRIST, + SamKind::Nasams => &UNIT_SPEC_NASAMS, + SamKind::Buk => &UNIT_SPEC_BUK, + SamKind::Tor => &UNIT_SPEC_TOR, + SamKind::P1sun => &UNIT_SPEC_P1SUN, + SamKind::Radar => &UNIT_SPEC_RADAR, + SamKind::Depot => &UNIT_SPEC_DEPOT, + SamKind::Tzm => &UNIT_SPEC_TZM, + SamKind::Ew => &UNIT_SPEC_EW, + SamKind::Light => &UNIT_SPEC_LIGHT, + } + } +} + +/// Save keys by kind; a rename needs an explicit migration. +#[cfg(test)] +pub(super) const UNIT_SPEC_KEYS: [(SamKind, &str); 15] = [ + (SamKind::Patriot, "patriot"), + (SamKind::Gepard, "gepard"), + (SamKind::Pantsir, "pantsir"), + (SamKind::S400, "s400"), + (SamKind::S500, "s500"), + (SamKind::IrisT, "irist"), + (SamKind::Nasams, "nasams"), + (SamKind::Buk, "buk"), + (SamKind::Tor, "tor"), + (SamKind::P1sun, "p1sun"), + (SamKind::Radar, "radar"), + (SamKind::Depot, "depot"), + (SamKind::Tzm, "tzm"), + (SamKind::Ew, "ew"), + (SamKind::Light, "light"), +]; diff --git a/airdef-mod/src/sim/units.rs b/airdef-mod/src/sim/units.rs index 25d04eb..3bf3c26 100644 --- a/airdef-mod/src/sim/units.rs +++ b/airdef-mod/src/sim/units.rs @@ -107,11 +107,6 @@ pub(super) struct Drive { pub pitch: (f64, f64), } -/// The drives are this share of the speeds they had before they had a speed of their own. -const DRIVE_SCALE: f64 = 0.4; -/// Ticks a drive takes to reach its top speed (and to stop from it). -const DRIVE_RAMP: f64 = 8.0; - pub struct SamSpec { pub name: &'static str, pub key: &'static str, @@ -140,20 +135,16 @@ impl SamSpec { pub(super) fn drive(&self) -> Drive { let (yaw, pitch) = match (&self.gun, self.family) { (Some(g), _) => g.slew, - (None, Family::Trainable) => (0.12, 0.0), - (None, Family::Vertical) => (0.15, 0.0), - (None, _) => (0.05, 0.05), + (None, Family::Trainable) => DRIVE_TRAINABLE, + (None, Family::Vertical) => DRIVE_VERTICAL, + (None, _) => DRIVE_OTHER, }; let axis = |speed: f64| (speed * DRIVE_SCALE, speed * DRIVE_SCALE / DRIVE_RAMP); Drive { yaw: axis(yaw), pitch: axis(pitch) } } } -const NO_PARTS: Parts = Parts { body: "", launcher: None, pack: None, dish: None, fixed: &[], spinner: None }; -/// The battery radar trailer behind a vertical launcher. -const BATTERY_RADAR: &[(&str, f64, f64)] = &[("airdef:radar_trailer", 0.0, 4.2)]; -const BATTERY_SPINNER: Option<(&str, f64, f64, f64, f64)> = Some(("airdef:radar_panel", 0.9, 4.2, 0.9, -0.3)); -const TUBE_PIVOT: DVec3 = DVec3::new(0.0, -0.5, 1.5); +include!("unit_specs_generated.rs"); impl SamKind { pub const ALL: [SamKind; super::catalog::UNIT_COUNT] = super::catalog::UNIT_KINDS; @@ -170,361 +161,6 @@ impl SamKind { SamKind::Tor, SamKind::P1sun, ]; - pub fn spec(self) -> &'static SamSpec { - match self { - SamKind::Patriot => { - const S: SamSpec = SamSpec { - name: "Patriot", - key: "patriot", - missile: Some(MissileLoad { kind: MissileKind::Pac3, magazine: 4, min: 10.0, max: 450.0, interval: 16 }), - gun: None, - reload: 220, - scale: 1.3, - rest_pitch: 0.66, - roles: 30, - family: Family::Trainable, - parts: Parts { - body: "airdef:patriot_trailer", - launcher: Some("airdef:patriot_launcher"), - fixed: &[("airdef:patriot_truck", 0.0, -3.0), ("airdef:patriot_radar", 0.0, 3.6)], - ..NO_PARTS - }, - mount: (0.69, 1.2), - pivot: DVec3::new(0.0, -0.5, 1.375), - muzzle: Muzzle::Patriot, - sensor: 585.0, - mast: 5.0, - }; - &S - } - SamKind::Gepard => { - const S: SamSpec = SamSpec { - name: "Гепард", - key: "gepard", - missile: None, - gun: Some(GunSpec { range: 135.0, track: 195.0, p: 0.3, bursts: 160, shell: 10.0, slew: (0.22, 0.16) }), - reload: 160, - scale: 1.25, - rest_pitch: 0.0, - roles: 7, - family: Family::Gun, - parts: Parts { - body: "airdef:gepard_hull", - launcher: Some("airdef:gepard_turret"), - pack: Some("airdef:gepard_guns"), - dish: Some(("airdef:gepard_dish", 0.95, 0.6, 0.0)), - ..NO_PARTS - }, - mount: (1.55, 0.25), - pivot: DVec3::ZERO, - muzzle: Muzzle::None, - sensor: 255.0, - mast: 3.0, - }; - &S - } - SamKind::Pantsir => { - const S: SamSpec = SamSpec { - name: "Панцирь-С1", - key: "pantsir", - missile: Some(MissileLoad { kind: MissileKind::M57e6, magazine: 12, min: 25.0, max: 225.0, interval: 14 }), - gun: Some(GunSpec { range: 90.0, track: 135.0, p: 0.25, bursts: 140, shell: 9.0, slew: (0.25, 0.18) }), - reload: 280, - scale: 1.3, - rest_pitch: 0.0, - roles: 15, - family: Family::Turret, - parts: Parts { - body: "airdef:pantsir_truck", - launcher: Some("airdef:pantsir_turret"), - pack: Some("airdef:pantsir_pack"), - dish: Some(("airdef:pantsir_dish", 0.85, 0.3, 0.3)), - ..NO_PARTS - }, - mount: (1.35, 0.69), - pivot: DVec3::ZERO, - muzzle: Muzzle::Pantsir, - sensor: 292.0, - mast: 4.0, - }; - &S - } - SamKind::S400 => { - const S: SamSpec = SamSpec { - name: "С-400", - key: "s400", - missile: Some(MissileLoad { kind: MissileKind::M48n6, magazine: 4, min: 30.0, max: 720.0, interval: 20 }), - gun: None, - reload: 420, - scale: 1.5, - rest_pitch: HALF_PI - 0.02, - roles: 30, - family: Family::Vertical, - parts: Parts { - body: "airdef:s400_chassis", - launcher: Some("airdef:s400_tubes"), - fixed: BATTERY_RADAR, - spinner: BATTERY_SPINNER, - ..NO_PARTS - }, - mount: (0.7, 1.45), - pivot: TUBE_PIVOT, - muzzle: Muzzle::Grid(2, 2, 7.0), - sensor: 936.0, - mast: 5.0, - }; - &S - } - SamKind::S500 => { - const S: SamSpec = SamSpec { - name: "С-500", - key: "s500", - missile: Some(MissileLoad { kind: MissileKind::M77n6, magazine: 2, min: 40.0, max: 1050.0, interval: 20 }), - gun: None, - reload: 520, - scale: 1.6, - rest_pitch: HALF_PI - 0.02, - roles: 24, - family: Family::Vertical, - parts: Parts { - body: "airdef:s500_chassis", - launcher: Some("airdef:s500_tubes"), - fixed: BATTERY_RADAR, - spinner: BATTERY_SPINNER, - ..NO_PARTS - }, - mount: (0.7, 1.45), - pivot: TUBE_PIVOT, - muzzle: Muzzle::Grid(2, 1, 9.5), - sensor: 1365.0, - mast: 5.0, - }; - &S - } - SamKind::IrisT => { - const S: SamSpec = SamSpec { - name: "IRIS-T SLM", - key: "irist", - missile: Some(MissileLoad { kind: MissileKind::IrisT, magazine: 8, min: 15.0, max: 600.0, interval: 10 }), - gun: None, - reload: 300, - scale: 1.4, - rest_pitch: HALF_PI - 0.02, - roles: 15, - family: Family::Vertical, - parts: Parts { - body: "airdef:irist_chassis", - launcher: Some("airdef:irist_tubes"), - fixed: BATTERY_RADAR, - spinner: BATTERY_SPINNER, - ..NO_PARTS - }, - mount: (0.7, 1.45), - pivot: TUBE_PIVOT, - muzzle: Muzzle::Grid(4, 2, 4.0), - sensor: 780.0, - mast: 5.0, - }; - &S - } - SamKind::Nasams => { - const S: SamSpec = SamSpec { - name: "NASAMS", - key: "nasams", - missile: Some(MissileLoad { kind: MissileKind::Amraam, magazine: 6, min: 10.0, max: 450.0, interval: 14 }), - gun: None, - reload: 260, - scale: 1.3, - rest_pitch: 0.5, - roles: 14, - family: Family::Trainable, - parts: Parts { - body: "airdef:nasams_trailer", - launcher: Some("airdef:nasams_launcher"), - fixed: &[("airdef:radar_trailer", 0.0, 3.4)], - spinner: Some(("airdef:radar_panel", 0.9, 3.4, 0.6, -0.3)), - ..NO_PARTS - }, - mount: (0.62, 0.4), - pivot: TUBE_PIVOT, - muzzle: Muzzle::Grid(3, 2, 4.0), - sensor: 585.0, - mast: 5.0, - }; - &S - } - SamKind::Buk => { - const S: SamSpec = SamSpec { - name: "Бук-М2", - key: "buk", - missile: Some(MissileLoad { kind: MissileKind::M9m317, magazine: 4, min: 20.0, max: 675.0, interval: 18 }), - gun: None, - reload: 400, - scale: 1.4, - rest_pitch: 0.0, - roles: 14, - family: Family::Turret, - parts: Parts { - body: "airdef:buk_chassis", - launcher: Some("airdef:buk_turret"), - pack: Some("airdef:buk_rails"), - ..NO_PARTS - }, - mount: (1.55, 0.2), - pivot: DVec3::ZERO, - muzzle: Muzzle::Rails(&[-9.0, -3.0, 13.0, 19.0]), - sensor: 878.0, - mast: 4.0, - }; - &S - } - SamKind::Tor => { - const S: SamSpec = SamSpec { - name: "Тор-М2", - key: "tor", - missile: Some(MissileLoad { kind: MissileKind::M9m338, magazine: 8, min: 8.0, max: 240.0, interval: 8 }), - gun: None, - reload: 300, - scale: 1.3, - rest_pitch: 0.0, - roles: 15, - family: Family::Vertical, - parts: Parts { body: "airdef:tor_chassis", launcher: Some("airdef:tor_turret"), ..NO_PARTS }, - mount: (1.5, 0.0), - pivot: DVec3::ZERO, - muzzle: Muzzle::Cells, - sensor: 315.0, - mast: 3.5, - }; - &S - } - SamKind::P1sun => { - const S: SamSpec = SamSpec { - name: "P1-SUN", - key: "p1sun", - missile: Some(MissileLoad { kind: MissileKind::P1sun, magazine: 8, min: 5.0, max: 675.0, interval: 12 }), - gun: None, - reload: 240, - scale: 1.1, - rest_pitch: 0.35, - roles: 1, - family: Family::Trainable, - parts: Parts { body: "airdef:nasams_trailer", launcher: Some("airdef:p1sun_launcher"), ..NO_PARTS }, - mount: (0.6, 0.3), - pivot: TUBE_PIVOT, - muzzle: Muzzle::Grid(4, 2, 3.0), - // No radar of its own: it fires on the network's tracks. - sensor: 0.0, - mast: 2.0, - }; - &S - } - SamKind::Radar => { - const S: SamSpec = SamSpec { - name: "РЛС дальнего обнаружения", - key: "radar", - missile: None, - gun: None, - reload: 0, - scale: 1.5, - rest_pitch: 0.0, - roles: 0, - family: Family::Static, - parts: Parts { - body: "airdef:radar_truck", - spinner: Some(("airdef:radar_panel", 1.2, 1.2, 1.5, -0.25)), - ..NO_PARTS - }, - mount: (0.0, 0.0), - pivot: DVec3::ZERO, - muzzle: Muzzle::None, - sensor: 2550.0, - mast: 16.0, - }; - &S - } - SamKind::Depot => { - const S: SamSpec = SamSpec { - name: "Склад боеприпасов", - key: "depot", - missile: None, - gun: None, - reload: 0, - scale: 1.5, - rest_pitch: 0.0, - roles: 0, - family: Family::Static, - parts: Parts { body: "airdef:depot", ..NO_PARTS }, - mount: (0.0, 0.0), - pivot: DVec3::ZERO, - muzzle: Muzzle::None, - sensor: 0.0, - mast: 1.0, - }; - &S - } - SamKind::Tzm => { - const S: SamSpec = SamSpec { - name: "ТЗМ", - key: "tzm", - missile: None, - gun: None, - reload: 0, - scale: 1.3, - rest_pitch: 0.0, - roles: 0, - family: Family::Truck, - parts: Parts { body: "airdef:tzm_truck", ..NO_PARTS }, - mount: (0.0, 0.0), - pivot: DVec3::ZERO, - muzzle: Muzzle::None, - sensor: 0.0, - mast: 2.0, - }; - &S - } - SamKind::Ew => { - const S: SamSpec = SamSpec { - name: "Станция РЭБ", - key: "ew", - missile: None, - gun: None, - reload: 0, - scale: 1.3, - rest_pitch: 0.0, - roles: 0, - family: Family::Static, - parts: Parts { body: "airdef:ew_truck", ..NO_PARTS }, - mount: (0.0, 0.0), - pivot: DVec3::ZERO, - muzzle: Muzzle::None, - sensor: 0.0, - mast: 3.0, - }; - &S - } - SamKind::Light => { - const S: SamSpec = SamSpec { - name: "Прожектор", - key: "light", - missile: None, - gun: None, - reload: 0, - scale: 1.2, - rest_pitch: 0.6, - roles: 0, - family: Family::Light, - parts: Parts { body: "airdef:searchlight", launcher: Some("airdef:searchlight_head"), ..NO_PARTS }, - mount: (0.85, 0.0), - pivot: DVec3::ZERO, - muzzle: Muzzle::None, - sensor: 0.0, - mast: 1.5, - }; - &S - } - } - } pub fn name(self) -> &'static str { self.spec().name } diff --git a/airdef-mod/src/sim/voices.rs b/airdef-mod/src/sim/voices.rs index 735c9bc..d15d6dd 100644 --- a/airdef-mod/src/sim/voices.rs +++ b/airdef-mod/src/sim/voices.rs @@ -18,20 +18,7 @@ impl MissileKind { /// The sound of its motor, its gain and its pitch: a rocket's roar, or the electric whine of /// a motor without a flame. pub fn engine_voice(self) -> (&'static str, f32, f32) { - if self.spec().flame == 0.0 { - return (super::catalog::sound::DRONE_LANCET, 0.8, 1.6); - } - let pitch = match self { - MissileKind::Pac3 => 1.0, - MissileKind::M57e6 => 1.12, - MissileKind::M48n6 => 0.88, - MissileKind::M77n6 => 0.8, - MissileKind::IrisT | MissileKind::Amraam => 1.05, - MissileKind::M9m317 => 0.9, - MissileKind::M9m338 => 1.15, - MissileKind::P1sun => 1.7, - }; - (super::catalog::sound::MISSILE_FLIGHT, 1.0, pitch) + self.spec().voice } /// Ticks from the launch until the motor lights (a cold launch throws it out of the tube first). pub fn lit_after(self) -> u32 { diff --git a/catalog/specs.json b/catalog/specs.json new file mode 100644 index 0000000..38c3450 --- /dev/null +++ b/catalog/specs.json @@ -0,0 +1,851 @@ +{ + "shared": { + "mobility": { + "drive_scale": 0.4, + "drive_ramp": 8.0, + "trainable": [0.12, 0.0], + "vertical": [0.15, 0.0], + "other": [0.05, 0.05] + }, + "flight": { + "gravity": 0.05, + "block_split": 300.0 + }, + "logistics": { + "depot_reach": 60.0, + "handover": 10.0, + "truck_load": 2, + "stock_limit": 1000000 + } + }, + "drones": [ + { + "kind": "Shahed", + "key": "shahed", + "name": "Шахед-136", + "model": "airdef:shahed", + "speed": 0.6, + "turn": 0.035, + "cruise": 45.0, + "clearance": 18.0, + "dive": 32.0, + "warhead": 3, + "scale": 1.3, + "engine": {"type": "prop", "back": 1.34, "size": 1.0}, + "decoy": false, + "stealth": 1.0, + "sound": {"event": "drone.shahed", "pitch": 1.0, "interval": 48}, + "class": "drone", + "ballistic": null, + "rcs": 0.3, + "launch_sound": "launch.s400_eject" + }, + { + "kind": "Geran3", + "key": "geran3", + "name": "Герань-3", + "model": "airdef:geran3", + "speed": 1.6, + "turn": 0.03, + "cruise": 60.0, + "clearance": 18.0, + "dive": 50.0, + "warhead": 2, + "scale": 1.15, + "engine": {"type": "jet", "up": 0.34, "back": 1.55, "size": 1.0}, + "decoy": false, + "stealth": 1.1, + "sound": {"event": "drone.geran3", "pitch": 1.0, "interval": 40}, + "class": "jet", + "ballistic": null, + "rcs": 0.4, + "launch_sound": "launch.s400_eject" + }, + { + "kind": "Lancet", + "key": "lancet", + "name": "Ланцет-3", + "model": "airdef:lancet", + "speed": 1.1, + "turn": 0.07, + "cruise": 25.0, + "clearance": 10.0, + "dive": 38.0, + "warhead": 1, + "scale": 0.9, + "engine": {"type": "prop", "back": 0.93, "size": 0.55}, + "decoy": false, + "stealth": 1.4, + "sound": {"event": "drone.lancet", "pitch": 1.0, "interval": 32}, + "class": "drone", + "ballistic": null, + "rcs": 0.08, + "launch_sound": "launch.s400_eject" + }, + { + "kind": "Gerbera", + "key": "gerbera", + "name": "Гербера", + "model": "airdef:gerbera", + "speed": 0.5, + "turn": 0.04, + "cruise": 40.0, + "clearance": 15.0, + "dive": 30.0, + "warhead": 0, + "scale": 1.1, + "engine": {"type": "prop", "back": 1.03, "size": 0.9}, + "decoy": true, + "stealth": 0.9, + "sound": {"event": "drone.shahed", "pitch": 1.3, "interval": 40}, + "class": "drone", + "ballistic": null, + "rcs": 0.4, + "launch_sound": "launch.s400_eject" + }, + { + "kind": "Kh101", + "key": "kh101", + "name": "Х-101", + "model": "airdef:kh101", + "speed": 2.4, + "turn": 0.025, + "cruise": 0.0, + "clearance": 12.0, + "dive": 26.0, + "warhead": 6, + "scale": 1.6, + "engine": {"type": "jet", "up": -0.27, "back": 1.4, "size": 0.6}, + "decoy": false, + "stealth": 1.5, + "sound": {"event": "drone.cruise", "pitch": 1.0, "interval": 50}, + "class": "cruise", + "ballistic": null, + "rcs": 0.05, + "launch_sound": "launch.patriot" + }, + { + "kind": "Kalibr", + "key": "kalibr", + "name": "Калибр", + "model": "airdef:kalibr", + "speed": 2.1, + "turn": 0.025, + "cruise": 0.0, + "clearance": 15.0, + "dive": 26.0, + "warhead": 5, + "scale": 1.5, + "engine": {"type": "jet", "up": -0.2, "back": 1.5, "size": 0.6}, + "decoy": false, + "stealth": 1.3, + "sound": {"event": "drone.cruise", "pitch": 1.2, "interval": 50}, + "class": "cruise", + "ballistic": null, + "rcs": 0.15, + "launch_sound": "launch.patriot" + }, + { + "kind": "Kh22", + "key": "kh22", + "name": "Х-22", + "model": "airdef:kh22", + "speed": 4.0, + "turn": 0.03, + "cruise": 140.0, + "clearance": 40.0, + "dive": 150.0, + "warhead": 7, + "scale": 1.8, + "engine": {"type": "rocket", "back": 1.5, "size": 1.3}, + "decoy": false, + "stealth": 1.4, + "sound": {"event": "drone.rocket", "pitch": 0.8, "interval": 46}, + "class": "supersonic", + "ballistic": null, + "rcs": 1.0, + "launch_sound": "launch.patriot" + }, + { + "kind": "Zircon", + "key": "zircon", + "name": "Циркон", + "model": "airdef:zircon", + "speed": 5.5, + "turn": 0.025, + "cruise": 90.0, + "clearance": 30.0, + "dive": 110.0, + "warhead": 6, + "scale": 1.4, + "engine": {"type": "rocket", "back": 1.5, "size": 1.1}, + "decoy": false, + "stealth": 2.0, + "sound": {"event": "drone.rocket", "pitch": 1.35, "interval": 46}, + "class": "supersonic", + "ballistic": null, + "rcs": 0.4, + "launch_sound": "launch.s400" + }, + { + "kind": "Iskander", + "key": "iskander", + "name": "Искандер-М", + "model": "airdef:iskander", + "speed": 5.0, + "turn": 0.04, + "cruise": 0.0, + "clearance": 0.0, + "dive": 0.0, + "warhead": 8, + "scale": 1.8, + "engine": {"type": "rocket", "back": 1.5, "size": 1.6}, + "decoy": false, + "stealth": 1.6, + "sound": {"event": "drone.rocket", "pitch": 0.7, "interval": 46}, + "class": "ballistic", + "ballistic": 1000.0, + "rcs": 0.6, + "launch_sound": "launch.s500" + }, + { + "kind": "Kinzhal", + "key": "kinzhal", + "name": "Кинжал", + "model": "airdef:kinzhal", + "speed": 7.5, + "turn": 0.03, + "cruise": 0.0, + "clearance": 0.0, + "dive": 0.0, + "warhead": 8, + "scale": 1.7, + "engine": {"type": "rocket", "back": 1.5, "size": 1.5}, + "decoy": false, + "stealth": 2.2, + "sound": {"event": "drone.rocket", "pitch": 0.6, "interval": 46}, + "class": "ballistic", + "ballistic": 1500.0, + "rcs": 0.5, + "launch_sound": "launch.s500" + }, + { + "kind": "Oreshnik", + "key": "oreshnik", + "name": "Орешник", + "model": "airdef:iskander", + "speed": 9.0, + "turn": 0.0, + "cruise": 0.0, + "clearance": 0.0, + "dive": 0.0, + "warhead": 0, + "scale": 2.6, + "engine": {"type": "rocket", "back": 1.5, "size": 2.4}, + "decoy": false, + "stealth": 3.0, + "sound": {"event": "drone.rocket", "pitch": 0.5, "interval": 46}, + "class": "ballistic", + "ballistic": 2600.0, + "rcs": 2.0, + "launch_sound": "launch.s500" + }, + { + "kind": "OreshnikBlock", + "key": "oreshnik_block", + "name": "Орешник: блок", + "model": "airdef:reentry", + "speed": 9.0, + "turn": 0.0, + "cruise": 0.0, + "clearance": 0.0, + "dive": 0.0, + "warhead": 4, + "scale": 1.6, + "engine": {"type": "rocket", "back": 1.0, "size": 1.0}, + "decoy": false, + "stealth": 3.5, + "sound": {"event": "drone.ballistic", "pitch": 0.8, "interval": 9999}, + "class": "ballistic", + "ballistic": null, + "rcs": 0.4, + "launch_sound": "launch.s500" + }, + { + "kind": "OreshnikSub", + "key": "oreshnik_sub", + "name": "Орешник: суббоеприпас", + "model": "airdef:reentry", + "speed": 9.0, + "turn": 0.05, + "cruise": 0.0, + "clearance": 0.0, + "dive": 0.0, + "warhead": 3, + "scale": 1.1, + "engine": {"type": "rocket", "back": 1.0, "size": 1.0}, + "decoy": false, + "stealth": 4.0, + "sound": {"event": "drone.ballistic", "pitch": 1.0, "interval": 9999}, + "class": "ballistic", + "ballistic": null, + "rcs": 0.08, + "launch_sound": "launch.s500" + } + ], + "units": [ + { + "kind": "Patriot", + "key": "patriot", + "name": "Patriot", + "missile": {"kind": "Pac3", "magazine": 4, "min": 10.0, "max": 450.0, "interval": 16}, + "gun": null, + "reload": 220, + "scale": 1.3, + "rest_pitch": 0.66, + "roles": 30, + "family": "trainable", + "parts": { + "body": "airdef:patriot_trailer", + "launcher": "airdef:patriot_launcher", + "pack": null, + "dish": null, + "fixed": [["airdef:patriot_truck", 0.0, -3.0], ["airdef:patriot_radar", 0.0, 3.6]], + "spinner": null + }, + "mount": [0.69, 1.2], + "pivot": [0.0, -0.5, 1.375], + "muzzle": {"type": "patriot"}, + "sensor": 585.0, + "mast": 5.0 + }, + { + "kind": "Gepard", + "key": "gepard", + "name": "Гепард", + "missile": null, + "gun": {"range": 135.0, "track": 195.0, "p": 0.3, "bursts": 160, "shell": 10.0, "slew": [0.22, 0.16]}, + "reload": 160, + "scale": 1.25, + "rest_pitch": 0.0, + "roles": 7, + "family": "gun", + "parts": { + "body": "airdef:gepard_hull", + "launcher": "airdef:gepard_turret", + "pack": "airdef:gepard_guns", + "dish": ["airdef:gepard_dish", 0.95, 0.6, 0.0], + "fixed": [], + "spinner": null + }, + "mount": [1.55, 0.25], + "pivot": [0.0, 0.0, 0.0], + "muzzle": {"type": "none"}, + "sensor": 255.0, + "mast": 3.0 + }, + { + "kind": "Pantsir", + "key": "pantsir", + "name": "Панцирь-С1", + "missile": {"kind": "M57e6", "magazine": 12, "min": 25.0, "max": 225.0, "interval": 14}, + "gun": {"range": 90.0, "track": 135.0, "p": 0.25, "bursts": 140, "shell": 9.0, "slew": [0.25, 0.18]}, + "reload": 280, + "scale": 1.3, + "rest_pitch": 0.0, + "roles": 15, + "family": "turret", + "parts": { + "body": "airdef:pantsir_truck", + "launcher": "airdef:pantsir_turret", + "pack": "airdef:pantsir_pack", + "dish": ["airdef:pantsir_dish", 0.85, 0.3, 0.3], + "fixed": [], + "spinner": null + }, + "mount": [1.35, 0.69], + "pivot": [0.0, 0.0, 0.0], + "muzzle": {"type": "pantsir"}, + "sensor": 292.0, + "mast": 4.0 + }, + { + "kind": "S400", + "key": "s400", + "name": "С-400", + "missile": {"kind": "M48n6", "magazine": 4, "min": 30.0, "max": 720.0, "interval": 20}, + "gun": null, + "reload": 420, + "scale": 1.5, + "rest_pitch": {"half_pi": -0.02}, + "roles": 30, + "family": "vertical", + "parts": { + "body": "airdef:s400_chassis", + "launcher": "airdef:s400_tubes", + "pack": null, + "dish": null, + "fixed": {"ref": "battery_radar"}, + "spinner": {"ref": "battery_spinner"} + }, + "mount": [0.7, 1.45], + "pivot": {"ref": "tube_pivot"}, + "muzzle": {"type": "grid", "cols": 2, "rows": 2, "size": 7.0}, + "sensor": 936.0, + "mast": 5.0 + }, + { + "kind": "S500", + "key": "s500", + "name": "С-500", + "missile": {"kind": "M77n6", "magazine": 2, "min": 40.0, "max": 1050.0, "interval": 20}, + "gun": null, + "reload": 520, + "scale": 1.6, + "rest_pitch": {"half_pi": -0.02}, + "roles": 24, + "family": "vertical", + "parts": { + "body": "airdef:s500_chassis", + "launcher": "airdef:s500_tubes", + "pack": null, + "dish": null, + "fixed": {"ref": "battery_radar"}, + "spinner": {"ref": "battery_spinner"} + }, + "mount": [0.7, 1.45], + "pivot": {"ref": "tube_pivot"}, + "muzzle": {"type": "grid", "cols": 2, "rows": 1, "size": 9.5}, + "sensor": 1365.0, + "mast": 5.0 + }, + { + "kind": "IrisT", + "key": "irist", + "name": "IRIS-T SLM", + "missile": {"kind": "IrisT", "magazine": 8, "min": 15.0, "max": 600.0, "interval": 10}, + "gun": null, + "reload": 300, + "scale": 1.4, + "rest_pitch": {"half_pi": -0.02}, + "roles": 15, + "family": "vertical", + "parts": { + "body": "airdef:irist_chassis", + "launcher": "airdef:irist_tubes", + "pack": null, + "dish": null, + "fixed": {"ref": "battery_radar"}, + "spinner": {"ref": "battery_spinner"} + }, + "mount": [0.7, 1.45], + "pivot": {"ref": "tube_pivot"}, + "muzzle": {"type": "grid", "cols": 4, "rows": 2, "size": 4.0}, + "sensor": 780.0, + "mast": 5.0 + }, + { + "kind": "Nasams", + "key": "nasams", + "name": "NASAMS", + "missile": {"kind": "Amraam", "magazine": 6, "min": 10.0, "max": 450.0, "interval": 14}, + "gun": null, + "reload": 260, + "scale": 1.3, + "rest_pitch": 0.5, + "roles": 14, + "family": "trainable", + "parts": { + "body": "airdef:nasams_trailer", + "launcher": "airdef:nasams_launcher", + "pack": null, + "dish": null, + "fixed": [["airdef:radar_trailer", 0.0, 3.4]], + "spinner": ["airdef:radar_panel", 0.9, 3.4, 0.6, -0.3] + }, + "mount": [0.62, 0.4], + "pivot": {"ref": "tube_pivot"}, + "muzzle": {"type": "grid", "cols": 3, "rows": 2, "size": 4.0}, + "sensor": 585.0, + "mast": 5.0 + }, + { + "kind": "Buk", + "key": "buk", + "name": "Бук-М2", + "missile": {"kind": "M9m317", "magazine": 4, "min": 20.0, "max": 675.0, "interval": 18}, + "gun": null, + "reload": 400, + "scale": 1.4, + "rest_pitch": 0.0, + "roles": 14, + "family": "turret", + "parts": { + "body": "airdef:buk_chassis", + "launcher": "airdef:buk_turret", + "pack": "airdef:buk_rails", + "dish": null, + "fixed": [], + "spinner": null + }, + "mount": [1.55, 0.2], + "pivot": [0.0, 0.0, 0.0], + "muzzle": {"type": "rails", "xs": [-9.0, -3.0, 13.0, 19.0]}, + "sensor": 878.0, + "mast": 4.0 + }, + { + "kind": "Tor", + "key": "tor", + "name": "Тор-М2", + "missile": {"kind": "M9m338", "magazine": 8, "min": 8.0, "max": 240.0, "interval": 8}, + "gun": null, + "reload": 300, + "scale": 1.3, + "rest_pitch": 0.0, + "roles": 15, + "family": "vertical", + "parts": { + "body": "airdef:tor_chassis", + "launcher": "airdef:tor_turret", + "pack": null, + "dish": null, + "fixed": [], + "spinner": null + }, + "mount": [1.5, 0.0], + "pivot": [0.0, 0.0, 0.0], + "muzzle": {"type": "cells"}, + "sensor": 315.0, + "mast": 3.5 + }, + { + "kind": "P1sun", + "key": "p1sun", + "name": "P1-SUN", + "missile": {"kind": "P1sun", "magazine": 8, "min": 5.0, "max": 675.0, "interval": 12}, + "gun": null, + "reload": 240, + "scale": 1.1, + "rest_pitch": 0.35, + "roles": 1, + "family": "trainable", + "parts": { + "body": "airdef:nasams_trailer", + "launcher": "airdef:p1sun_launcher", + "pack": null, + "dish": null, + "fixed": [], + "spinner": null + }, + "mount": [0.6, 0.3], + "pivot": {"ref": "tube_pivot"}, + "muzzle": {"type": "grid", "cols": 4, "rows": 2, "size": 3.0}, + "sensor": 0.0, + "mast": 2.0 + }, + { + "kind": "Radar", + "key": "radar", + "name": "РЛС дальнего обнаружения", + "missile": null, + "gun": null, + "reload": 0, + "scale": 1.5, + "rest_pitch": 0.0, + "roles": 0, + "family": "static", + "parts": { + "body": "airdef:radar_truck", + "launcher": null, + "pack": null, + "dish": null, + "fixed": [], + "spinner": ["airdef:radar_panel", 1.2, 1.2, 1.5, -0.25] + }, + "mount": [0.0, 0.0], + "pivot": [0.0, 0.0, 0.0], + "muzzle": {"type": "none"}, + "sensor": 2550.0, + "mast": 16.0 + }, + { + "kind": "Depot", + "key": "depot", + "name": "Склад боеприпасов", + "missile": null, + "gun": null, + "reload": 0, + "scale": 1.5, + "rest_pitch": 0.0, + "roles": 0, + "family": "static", + "parts": { + "body": "airdef:depot", + "launcher": null, + "pack": null, + "dish": null, + "fixed": [], + "spinner": null + }, + "mount": [0.0, 0.0], + "pivot": [0.0, 0.0, 0.0], + "muzzle": {"type": "none"}, + "sensor": 0.0, + "mast": 1.0 + }, + { + "kind": "Tzm", + "key": "tzm", + "name": "ТЗМ", + "missile": null, + "gun": null, + "reload": 0, + "scale": 1.3, + "rest_pitch": 0.0, + "roles": 0, + "family": "truck", + "parts": { + "body": "airdef:tzm_truck", + "launcher": null, + "pack": null, + "dish": null, + "fixed": [], + "spinner": null + }, + "mount": [0.0, 0.0], + "pivot": [0.0, 0.0, 0.0], + "muzzle": {"type": "none"}, + "sensor": 0.0, + "mast": 2.0 + }, + { + "kind": "Ew", + "key": "ew", + "name": "Станция РЭБ", + "missile": null, + "gun": null, + "reload": 0, + "scale": 1.3, + "rest_pitch": 0.0, + "roles": 0, + "family": "static", + "parts": { + "body": "airdef:ew_truck", + "launcher": null, + "pack": null, + "dish": null, + "fixed": [], + "spinner": null + }, + "mount": [0.0, 0.0], + "pivot": [0.0, 0.0, 0.0], + "muzzle": {"type": "none"}, + "sensor": 0.0, + "mast": 3.0 + }, + { + "kind": "Light", + "key": "light", + "name": "Прожектор", + "missile": null, + "gun": null, + "reload": 0, + "scale": 1.2, + "rest_pitch": 0.6, + "roles": 0, + "family": "light", + "parts": { + "body": "airdef:searchlight", + "launcher": "airdef:searchlight_head", + "pack": null, + "dish": null, + "fixed": [], + "spinner": null + }, + "mount": [0.85, 0.0], + "pivot": [0.0, 0.0, 0.0], + "muzzle": {"type": "none"}, + "sensor": 0.0, + "mast": 1.5 + } + ], + "missiles": [ + { + "kind": "Pac3", + "model": "airdef:pac3", + "speed": 5.5, + "accel": 0.35, + "turn": 0.3, + "boost": 8, + "cold": 0, + "pk": 0.9, + "fuse": 4.0, + "life": 240, + "scale": 1.0, + "flame": 1.0, + "rear": 28.0, + "seeker": 110.0, + "launch_sound": "launch.patriot", + "voice": {"event": "missile.flight", "gain": 1.0, "pitch": 1.0} + }, + { + "kind": "M57e6", + "model": "airdef:m57e6", + "speed": 5.0, + "accel": 0.5, + "turn": 0.32, + "boost": 5, + "cold": 0, + "pk": 0.82, + "fuse": 3.0, + "life": 160, + "scale": 0.85, + "flame": 0.65, + "rear": 28.0, + "seeker": 0.0, + "launch_sound": "launch.pantsir", + "voice": {"event": "missile.flight", "gain": 1.0, "pitch": 1.12} + }, + { + "kind": "M48n6", + "model": "airdef:m48n6", + "speed": 7.0, + "accel": 0.5, + "turn": 0.22, + "boost": 8, + "cold": 5, + "pk": 0.9, + "fuse": 5.0, + "life": 300, + "scale": 1.5, + "flame": 1.4, + "rear": 30.0, + "seeker": 120.0, + "launch_sound": "launch.s400_eject", + "voice": {"event": "missile.flight", "gain": 1.0, "pitch": 0.88} + }, + { + "kind": "M77n6", + "model": "airdef:m77n6", + "speed": 9.0, + "accel": 0.6, + "turn": 0.22, + "boost": 8, + "cold": 5, + "pk": 0.95, + "fuse": 6.0, + "life": 320, + "scale": 1.7, + "flame": 1.6, + "rear": 32.0, + "seeker": 120.0, + "launch_sound": "launch.s500_eject", + "voice": {"event": "missile.flight", "gain": 1.0, "pitch": 0.8} + }, + { + "kind": "IrisT", + "model": "airdef:irist_missile", + "speed": 7.0, + "accel": 0.5, + "turn": 0.35, + "boost": 6, + "cold": 4, + "pk": 0.9, + "fuse": 4.0, + "life": 260, + "scale": 0.95, + "flame": 0.9, + "rear": 28.0, + "seeker": 90.0, + "launch_sound": "launch.s400_eject", + "voice": {"event": "missile.flight", "gain": 1.0, "pitch": 1.05} + }, + { + "kind": "Amraam", + "model": "airdef:amraam_missile", + "speed": 6.0, + "accel": 0.45, + "turn": 0.3, + "boost": 6, + "cold": 0, + "pk": 0.85, + "fuse": 4.0, + "life": 260, + "scale": 0.9, + "flame": 0.9, + "rear": 28.0, + "seeker": 130.0, + "launch_sound": "launch.patriot", + "voice": {"event": "missile.flight", "gain": 1.0, "pitch": 1.05} + }, + { + "kind": "M9m317", + "model": "airdef:m48n6", + "speed": 6.5, + "accel": 0.5, + "turn": 0.25, + "boost": 8, + "cold": 0, + "pk": 0.85, + "fuse": 5.0, + "life": 300, + "scale": 1.1, + "flame": 1.2, + "rear": 32.0, + "seeker": 90.0, + "launch_sound": "launch.patriot", + "voice": {"event": "missile.flight", "gain": 1.0, "pitch": 0.9} + }, + { + "kind": "M9m338", + "model": "airdef:m57e6", + "speed": 5.5, + "accel": 0.6, + "turn": 0.4, + "boost": 4, + "cold": 4, + "pk": 0.9, + "fuse": 3.0, + "life": 160, + "scale": 1.0, + "flame": 0.8, + "rear": 28.0, + "seeker": 70.0, + "launch_sound": "launch.s400_eject", + "voice": {"event": "missile.flight", "gain": 1.0, "pitch": 1.15} + }, + { + "kind": "P1sun", + "model": "airdef:p1sun", + "speed": 2.8, + "accel": 0.12, + "turn": 0.3, + "boost": 4, + "cold": 0, + "pk": 0.8, + "fuse": 3.0, + "life": 900, + "scale": 0.8, + "flame": 0.0, + "rear": 10.0, + "seeker": 60.0, + "launch_sound": "drone.lancet", + "voice": {"event": "drone.lancet", "gain": 0.8, "pitch": 1.7} + } + ], + "named": { + "battery_radar": { + "kind": "fixed", + "value": [["airdef:radar_trailer", 0.0, 4.2]] + }, + "battery_spinner": { + "kind": "spinner", + "value": ["airdef:radar_panel", 0.9, 4.2, 0.9, -0.3] + }, + "tube_pivot": { + "kind": "pivot", + "value": [0.0, -0.5, 1.5] + } + } +} diff --git a/docs/CODEX-HANDOFF.md b/docs/CODEX-HANDOFF.md index 1cf2b92..4a8421e 100644 --- a/docs/CODEX-HANDOFF.md +++ b/docs/CODEX-HANDOFF.md @@ -10,6 +10,12 @@ Движок `57f8b2853c4443f7e3ff5701600cbb262b53118f`, API29. В этом файле ниже сохранён исторический журнал предыдущих серий; старые SHA и числа тестов не заменяют новую контрольную точку. +След #91 продолжен: `catalog/specs.json` — единственная копия числовых строк дронов, ракет и +комплексов; `python3 tools/gen_catalog.py` генерирует `specs_generated.rs` и +`unit_specs_generated.rs`, `--check` (CI) валит расхождение. Числа не менялись; при сверке с +литералами найден и исправлен только голос P1-SUN в JSON (1.6 → 1.7). Остаток: варианты +комплексов и игровая приёмка. + ### Контрольная точка 3 ноября Код предыдущих четырёх задач интегрирован до `6e2d661`. Продолжение #102/обратного вывоза diff --git a/docs/CONTINUATION-2026-11-04.md b/docs/CONTINUATION-2026-11-04.md index 7acc75a..153161b 100644 --- a/docs/CONTINUATION-2026-11-04.md +++ b/docs/CONTINUATION-2026-11-04.md @@ -22,6 +22,7 @@ CI проверяет актуальность. Сохранены порядок позиционных сейвов, все 35 звуковых событий, OGG и порядок общего RNG синтезатора. Числовые `DroneSpec`/`SamSpec` остаются типизированным источником в Rust; полного переноса всех характеристик в JSON не было. + Продолжение переноса чисел — ниже. - **#102:** `63aefc7`: `test-budgets` включает настоящий `--fuel-profile` и сохраняет `measured_fuel.callbacks`, включая ошибки. Отсутствие запрошенных счётчиков запрещает success. В движке `6230ec703`/`d8fa77b00` добавлены API28 point-lights-status и API29 sound-sources-status. @@ -41,6 +42,32 @@ [Отчёт и SHA дорожки](measurements/mass-acoustics-2026-11-04.json). Это не замер полного игрового CPU/GPU, не Windows FPS и не готовый MP4. +## Продолжение #91: один источник чисел + +`catalog/specs.json` теперь единственная копия числовых строк симуляции: 13 дронов +(`DroneKind::spec`), 9 ракет (включая `seeker`, звук пуска и голос мотора), 15 комплексов +(`SamKind::spec`, включая части моделей и именованные наборы), общие константы гравитации, +высоты раскрытия «Орешника», приводов и логистики. `tools/gen_catalog.py` генерирует +`airdef-mod/src/sim/specs_generated.rs` и `unit_specs_generated.rs`; `include!` встраивает их в +`mod.rs` и `units.rs`, поэтому мод и `airdef-core` компилируют одни и те же строки. Прежние +`match`-литералы удалены; `DroneKind::spec`, `MissileKind::spec`, `SamKind::spec` и их производные +(RCS, seeker, звуки, голос) читают сгенерированные константы. + +`load_specs` проверяет схему, звуковые ссылки, совпадение набора kinds с `content.json`, ссылки на +именованные части и то, что каждая строка кем-то используется; `gen_catalog.py --check` уже в +CI-шаге «Проверка generated assets», поэтому расхождение JSON и Rust валит сборку. + +Сверка перед переносом: JSON сравнивался с литералами `632061d` по всем полям. Найдено одно +расхождение — голос P1-SUN был записан с pitch 1.6 вместо фактического 1.7 (ветка `flame == 0` +в `voices.rs`); JSON исправлен, поведение не менялось. После генерации строки сверены с прежними +литералами поле-в-поле (включая части моделей и именованные refs) — расхождений нет. Порядок +raid/save, ключи `sam`/дронов, RNG и тексты не менялись. + +Проверки этого этапа: `cargo test --release` ядра — **194 passed, 7 ignored** + **8 сценариев**; +адаптер — **64 unit**, **1 boundary**, **5 сценариев**; `gen_catalog.py --check`, `gen_models.py --check`, +тесты генераторов/звука/границ моделей; настоящий WASM `test-wasm` — **27 шагов**, 1 скрипт. +GUI/MP4 и варианты комплексов (разные ракеты/профили по батареям) в этот этап не входили. + ## Проверки и продолжение Объединённый core release: **193 unit**, 7 ignored, **8 сценариев**; адаптер: **64 unit**, diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index f941f1d..38a2c0b 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -16,7 +16,7 @@ | #87 | Каноническое SDK-free ядро `airdef-core`, используется модом и сценариями | Дальнейшее отделение текстового/визуального вывода домена от адаптера | | #88 | Типизированный журнал измеренных переходов; read-only Snapshot для Smoke/Shown; записанные пожары, позы, пуски и зажигание | Перевести оставшиеся model/audio adapters и режиссёра на общий снимок; замер полной 10-минутной записи | | #89 | Session владеет Live/Replay/Export; явные часы/поколения; единый cleanup; задержанный звук следует часам показа; выход в заголовок завершает показ одним cleanup (хук `frame`, `Session::world_left`); движок освобождает temp/лог ошибки энкодера и не трогает готовый MP4 | Реальный короткий MP4 проверен 4 ноября (звук/видео, отмена, ошибка SDK и энкодера, повторный запуск, совпадение кадров и хвоста очереди, сохранность прежнего MP4). Остаётся: 10-минутный экспорт; приёмка Windows/Linux | -| #91 | Общий каталог доступности и потребители меню/инвентаря; явный RM48U-профиль; корректные подписи очередей | Единый источник всех чисел, звуков и Python-регистраций; проверка вариантов комплексов | +| #91 | Общий каталог доступности и потребители меню/инвентаря; явный RM48U-профиль; корректные подписи очередей; `catalog/specs.json` — единственный источник числовых строк, из него генерируются Rust-таблицы для мода и `airdef-core`, CI проверяет свежесть | Варианты комплексов (разные ракеты/профили по батареям) и игровая приёмка; каталог остаётся статичными данными, не динамическим ECS | | #92 | Authored overrides, проверка ссылок/PNG/границ, manifest, безопасная транзакционная генерация; registry/texture/icon modules | Дальнейшее ручное улучшение контента относится к #115 | | #96 | Акустические пути; измеренные синтетические occlusion/echo/reverb fixtures; лицензии и исходные notices в архивах; настоящий MP4 с акустикой: пары on/off, статусы ready, окклюзия/поглощение и диффузный wet-остаток в замкнутой геометрии | Прослушивание, игровой массовый звук/CPU, чистый хвост/RT60 в MP4; точность RT60 пока по допуску 35%, не 15% | | #102 | Pinned engine/API29, packer/build identity; smoke/acoustics/capture-owner/light/source milestones; fallback/владение; WASM27 и flat stress271; измеренный callback fuel; JSON provenance/failure tests; Metal/no-audio smoke; URL/Git-регрессия; owner-scoped camera targets | Старое69-шаговое утверждение о перекрытии отозвано; статус камер, полные query/voice/peak budgets, GPU/готовый MP4 и Windows-приёмка остаются | diff --git a/docs/REPO-MAP.md b/docs/REPO-MAP.md index ab22097..b46b24e 100644 --- a/docs/REPO-MAP.md +++ b/docs/REPO-MAP.md @@ -34,10 +34,12 @@ Команды: - `python3 -m pip install -r tools/requirements.txt` — зависимости генератора (закреплён Pillow). -- `python3 tools/gen_catalog.py` — обновить Rust-константы после изменений `catalog/content.json`; `--check` проверяет актуальность. +- `python3 tools/gen_catalog.py` — обновить Rust-константы после изменений `catalog/content.json`, `catalog/specs.json` или `catalog/sounds.json`; `--check` проверяет актуальность. - `python3 tools/gen_models.py` — перегенерировать. - `python3 tools/gen_models.py --check` — только сравнить, выход 1 при различиях. +`catalog/specs.json` — единственный источник числовых профилей симуляции: строки `DroneSpec`, `MissileSpec`/`MissileKind` (включая RCS, seeker, звук пуска и голос мотора), `SamSpec` (оружие, части моделей, приводы, датчик), общие константы (гравитация, высота раскрытия «Орешника», логистика) и именованные наборы частей. `tools/gen_catalog.py` создаёт из него `airdef-mod/src/sim/specs_generated.rs` (включается в `mod.rs`; тот же файл компилирует `airdef-core`) и `airdef-mod/src/sim/unit_specs_generated.rs` (включается в `units.rs`). Эти два файла не редактировать вручную. `tools/airdef_assets/catalog.py::load_specs` проверяет схему, звуковые ссылки, наборы kinds против `content.json`, ссылки на именованные части и заполненность всех строк; `gen_catalog.py --check` в CI ловит расхождение. Значения — игровой масштаб, не реальные характеристики техники. + `catalog/sounds.json` хранит звуковые ID, дальности и наличие дальнего варианта; `tools/gen_catalog.py` экспортирует ID для Rust, а `tools/gen_sounds.py` создаёт `airdef-mod/assets/airdef/sounds/*.ogg` и `sounds.json`. Этот генератор не использует manifest и не входит в проверку `gen_models.py`. ## Архив: `legacy/` diff --git a/tools/airdef_assets/catalog.py b/tools/airdef_assets/catalog.py index 4094f91..ddb9463 100644 --- a/tools/airdef_assets/catalog.py +++ b/tools/airdef_assets/catalog.py @@ -1,8 +1,18 @@ """Shared content identities and item presentation used by runtime and assets.""" import json +import math +import re from pathlib import Path PATH = Path(__file__).resolve().parents[2] / "catalog" / "content.json" +SPECS = PATH.parent / "specs.json" + +FAMILIES = {"trainable", "gun", "turret", "vertical", "static", "truck", "light"} +CLASSES = {"drone", "jet", "cruise", "supersonic", "ballistic"} +MUZZLES = {"none", "patriot", "pantsir", "grid", "rails", "cells"} +ENGINES = {"prop": {"type", "back", "size"}, "jet": {"type", "up", "back", "size"}, + "rocket": {"type", "back", "size"}} +MODEL = r"airdef:[a-z0-9_]+" def load(): @@ -27,8 +37,6 @@ def load(): def load_sounds(): """Load stable event IDs and engine attenuation metadata.""" - import re - entries = json.loads((PATH.parent / "sounds.json").read_text(encoding="utf-8")) seen = set() for entry in entries: @@ -47,6 +55,317 @@ def load_sounds(): return entries +def _exact(entry, keys, where): + if not isinstance(entry, dict) or set(entry) != set(keys): + got = sorted(entry) if isinstance(entry, dict) else type(entry).__name__ + raise ValueError(f"{where}: expected keys {sorted(keys)}, got {got}") + + +def _number(value, where, minimum=None): + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise ValueError(f"{where}: expected a finite number, got {value!r}") + if minimum is not None and value < minimum: + raise ValueError(f"{where}: {value!r} is below {minimum}") + return value + + +def _integer(value, where, minimum=0): + if isinstance(value, bool) or not isinstance(value, int) or value < minimum: + raise ValueError(f"{where}: expected an integer >= {minimum}, got {value!r}") + return value + + +def _text(value, where, pattern=None): + if not isinstance(value, str) or not value: + raise ValueError(f"{where}: expected a non-empty string") + if pattern and not re.fullmatch(pattern, value): + raise ValueError(f"{where}: {value!r} does not match {pattern}") + return value + + +def _sound(value, where, events): + _text(value, where, r"[a-z0-9_]+\.[a-z0-9_]+") + if value not in events: + raise ValueError(f"{where}: sound event {value!r} is not registered in sounds.json") + + +def _named_ref(value, where, named, kind): + _exact(value, {"ref"}, where) + name = value["ref"] + if name not in named or named[name]["kind"] != kind: + raise ValueError(f"{where}: unknown {kind} reference {name!r}") + return name + + +def _model_ref(value, where, named, kind): + return f"NAMED_{_named_ref(value, where, named, kind).upper()}" + + +def _parts(entry, where, named): + _exact(entry, {"body", "launcher", "pack", "dish", "fixed", "spinner"}, where) + _text(entry["body"], f"{where}.body", MODEL) + for slot in ("launcher", "pack"): + if entry[slot] is not None: + _text(entry[slot], f"{where}.{slot}", MODEL) + dish = entry["dish"] + if dish is not None: + if not isinstance(dish, list) or len(dish) != 4: + raise ValueError(f"{where}.dish: expected [model, height, forward, tilt]") + _text(dish[0], f"{where}.dish.model", MODEL) + for i, v in enumerate(dish[1:], 1): + _number(v, f"{where}.dish[{i}]") + fixed = entry["fixed"] + if isinstance(fixed, dict): + _named_ref(fixed, f"{where}.fixed", named, "fixed") + elif not isinstance(fixed, list): + raise ValueError(f"{where}.fixed: expected a list or a named reference") + else: + for i, piece in enumerate(fixed): + if not isinstance(piece, list) or len(piece) != 3: + raise ValueError(f"{where}.fixed[{i}]: expected [model, height, back]") + _text(piece[0], f"{where}.fixed[{i}].model", MODEL) + _number(piece[1], f"{where}.fixed[{i}].height") + _number(piece[2], f"{where}.fixed[{i}].back") + spinner = entry["spinner"] + if isinstance(spinner, dict): + _named_ref(spinner, f"{where}.spinner", named, "spinner") + elif spinner is not None: + if not isinstance(spinner, list) or len(spinner) != 5: + raise ValueError(f"{where}.spinner: expected [model, height, back, size, tilt]") + _text(spinner[0], f"{where}.spinner.model", MODEL) + for i, v in enumerate(spinner[1:], 1): + _number(v, f"{where}.spinner[{i}]") + + +def _muzzle(entry, where): + if not isinstance(entry, dict) or entry.get("type") not in MUZZLES: + raise ValueError(f"{where}: expected one of {sorted(MUZZLES)}") + kind = entry["type"] + keys = {"grid": {"type", "cols", "rows", "size"}, "rails": {"type", "xs"}}.get(kind, {"type"}) + _exact(entry, keys, where) + if kind == "grid": + _integer(entry["cols"], f"{where}.cols", 1) + _integer(entry["rows"], f"{where}.rows", 1) + _number(entry["size"], f"{where}.size", 0) + elif kind == "rails": + xs = entry["xs"] + if not isinstance(xs, list) or not xs: + raise ValueError(f"{where}.xs: expected a non-empty list") + for i, v in enumerate(xs): + _number(v, f"{where}.xs[{i}]") + + +def _rest_pitch(entry, where): + if isinstance(entry, dict): + _exact(entry, {"half_pi"}, where) + _number(entry["half_pi"], f"{where}.half_pi") + else: + _number(entry, where) + + +def load_specs(): + """Load the numeric profiles shared by Rust and Python, validating every reference.""" + data = json.loads(SPECS.read_text(encoding="utf-8")) + _exact(data, {"shared", "drones", "units", "missiles", "named"}, "specs.json") + events = {entry["event"] for entry in load_sounds()} + content = load() + + shared = data["shared"] + _exact(shared, {"mobility", "flight", "logistics"}, "shared") + _exact(shared["mobility"], {"drive_scale", "drive_ramp", "trainable", "vertical", "other"}, "shared.mobility") + _number(shared["mobility"]["drive_scale"], "shared.mobility.drive_scale", 0) + _number(shared["mobility"]["drive_ramp"], "shared.mobility.drive_ramp", 0) + for family in ("trainable", "vertical", "other"): + axes = shared["mobility"][family] + if not isinstance(axes, list) or len(axes) != 2: + raise ValueError(f"shared.mobility.{family}: expected [yaw, pitch]") + for i, v in enumerate(axes): + _number(v, f"shared.mobility.{family}[{i}]") + _exact(shared["flight"], {"gravity", "block_split"}, "shared.flight") + _number(shared["flight"]["gravity"], "shared.flight.gravity", 0) + _number(shared["flight"]["block_split"], "shared.flight.block_split", 0) + _exact(shared["logistics"], {"depot_reach", "handover", "truck_load", "stock_limit"}, "shared.logistics") + _number(shared["logistics"]["depot_reach"], "shared.logistics.depot_reach", 0) + _number(shared["logistics"]["handover"], "shared.logistics.handover", 0) + _integer(shared["logistics"]["truck_load"], "shared.logistics.truck_load", 1) + _integer(shared["logistics"]["stock_limit"], "shared.logistics.stock_limit", 1) + + named = data["named"] + if not isinstance(named, dict): + raise ValueError("named: expected an object") + referenced = set() + for name, entry in named.items(): + where = f"named.{name}" + _exact(entry, {"kind", "value"}, where) + value = entry["value"] + if not isinstance(value, list): + raise ValueError(f"{where}.value: expected a list") + if entry["kind"] == "fixed": + for i, piece in enumerate(value): + if not isinstance(piece, list) or len(piece) != 3: + raise ValueError(f"{where}.value[{i}]: expected [model, height, back]") + _text(piece[0], f"{where}.value[{i}].model", MODEL) + _number(piece[1], f"{where}.value[{i}].height") + _number(piece[2], f"{where}.value[{i}].back") + elif entry["kind"] == "spinner": + if len(value) != 5: + raise ValueError(f"{where}: expected [model, height, back, size, tilt]") + _text(value[0], f"{where}.model", MODEL) + for i, v in enumerate(value[1:], 1): + _number(v, f"{where}[{i}]") + elif entry["kind"] == "pivot": + if len(value) != 3: + raise ValueError(f"{where}: expected [x, y, z]") + for i, v in enumerate(value): + _number(v, f"{where}[{i}]") + else: + raise ValueError(f"{where}.kind: expected fixed, spinner or pivot") + + 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) + 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}") + missile_kinds.add(kind) + _text(entry["model"], f"{where}.model", MODEL) + for field in ("speed", "accel", "turn", "pk", "fuse", "scale", "flame", "rear", "seeker"): + _number(entry[field], f"{where}.{field}", 0) + _integer(entry["boost"], f"{where}.boost") + _integer(entry["cold"], f"{where}.cold") + _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") + _sound(entry["voice"]["event"], f"{where}.voice.event", events) + _number(entry["voice"]["gain"], f"{where}.voice.gain", 0) + _number(entry["voice"]["pitch"], f"{where}.voice.pitch", 0) + + drone_kinds = [] + keys = set() + for i, entry in enumerate(data["drones"]): + where = f"drones[{i}]" + _exact(entry, {"kind", "key", "name", "model", "speed", "turn", "cruise", "clearance", "dive", + "warhead", "scale", "engine", "decoy", "stealth", "sound", "class", "ballistic", + "rcs", "launch_sound"}, where) + kind = _text(entry["kind"], f"{where}.kind", r"[A-Z][A-Za-z0-9]*") + drone_kinds.append(kind) + key = _text(entry["key"], f"{where}.key", r"[a-z0-9_]+") + if key in keys: + raise ValueError(f"{where}: duplicate key {key}") + keys.add(key) + _text(entry["name"], f"{where}.name") + _text(entry["model"], f"{where}.model", MODEL) + for field in ("speed", "turn", "cruise", "clearance", "dive", "scale", "stealth", "rcs"): + _number(entry[field], f"{where}.{field}", 0) + _integer(entry["warhead"], f"{where}.warhead") + if not isinstance(entry["decoy"], bool): + raise ValueError(f"{where}.decoy: expected a boolean") + engine = entry["engine"] + if not isinstance(engine, dict) or engine.get("type") not in ENGINES: + raise ValueError(f"{where}.engine: expected one of {sorted(ENGINES)}") + _exact(engine, ENGINES[engine["type"]], f"{where}.engine") + for field in ENGINES[engine["type"]] - {"type"}: + _number(engine[field], f"{where}.engine.{field}") + _exact(entry["sound"], {"event", "pitch", "interval"}, f"{where}.sound") + _sound(entry["sound"]["event"], f"{where}.sound.event", events) + _number(entry["sound"]["pitch"], f"{where}.sound.pitch", 0) + _integer(entry["sound"]["interval"], f"{where}.sound.interval", 1) + if entry["class"] not in CLASSES: + raise ValueError(f"{where}.class: expected one of {sorted(CLASSES)}") + if entry["ballistic"] is not None: + _number(entry["ballistic"], f"{where}.ballistic", 0) + _sound(entry["launch_sound"], f"{where}.launch_sound", events) + + unit_kinds = [] + used_missiles = set() + armed = {"Patriot", "Gepard", "Pantsir", "S400", "S500", "IrisT", "Nasams", "Buk", "Tor", "P1sun"} + for i, entry in enumerate(data["units"]): + where = f"units[{i}]" + _exact(entry, {"kind", "key", "name", "missile", "gun", "reload", "scale", "rest_pitch", + "roles", "family", "parts", "mount", "pivot", "muzzle", "sensor", "mast"}, where) + kind = _text(entry["kind"], f"{where}.kind", r"[A-Z][A-Za-z0-9]*") + unit_kinds.append(kind) + key = _text(entry["key"], f"{where}.key", r"[a-z0-9_]+") + if key in keys: + raise ValueError(f"{where}: duplicate key {key}") + keys.add(key) + _text(entry["name"], f"{where}.name") + _integer(entry["reload"], f"{where}.reload") + _number(entry["scale"], f"{where}.scale", 0) + _rest_pitch(entry["rest_pitch"], f"{where}.rest_pitch") + _integer(entry["roles"], f"{where}.roles") + if entry["roles"] > 0x1F: + raise ValueError(f"{where}.roles: mask out of range") + if entry["family"] not in FAMILIES: + raise ValueError(f"{where}.family: expected one of {sorted(FAMILIES)}") + _parts(entry["parts"], f"{where}.parts", named) + if not isinstance(entry["mount"], list) or len(entry["mount"]) != 2: + raise ValueError(f"{where}.mount: expected [up, forward]") + for j, v in enumerate(entry["mount"]): + _number(v, f"{where}.mount[{j}]") + pivot = entry["pivot"] + if isinstance(pivot, dict): + _model_ref(pivot, f"{where}.pivot", named, "pivot") + elif not isinstance(pivot, list) or len(pivot) != 3: + raise ValueError(f"{where}.pivot: expected [x, y, z] or a named reference") + else: + for j, v in enumerate(pivot): + _number(v, f"{where}.pivot[{j}]") + _muzzle(entry["muzzle"], f"{where}.muzzle") + _number(entry["sensor"], f"{where}.sensor", 0) + _number(entry["mast"], f"{where}.mast", 0) + missile = entry["missile"] + if missile is not None: + _exact(missile, {"kind", "magazine", "min", "max", "interval"}, f"{where}.missile") + used_missiles.add(_text(missile["kind"], f"{where}.missile.kind", r"[A-Z][A-Za-z0-9]*")) + _integer(missile["magazine"], f"{where}.missile.magazine", 1) + _number(missile["min"], f"{where}.missile.min", 0) + _number(missile["max"], f"{where}.missile.max", 0) + _integer(missile["interval"], f"{where}.missile.interval", 1) + gun = entry["gun"] + if gun is not None: + _exact(gun, {"range", "track", "p", "bursts", "shell", "slew"}, f"{where}.gun") + for field in ("range", "track", "p", "shell"): + _number(gun[field], f"{where}.gun.{field}", 0) + _integer(gun["bursts"], f"{where}.gun.bursts", 1) + if not isinstance(gun["slew"], list) or len(gun["slew"]) != 2: + raise ValueError(f"{where}.gun.slew: expected [yaw, pitch]") + for j, v in enumerate(gun["slew"]): + _number(v, f"{where}.gun.slew[{j}]", 0) + if kind in armed and missile is None and gun is None: + raise ValueError(f"{where}: an armed unit needs missiles or a gun") + if kind not in armed and (missile is not None or gun is not None): + raise ValueError(f"{where}: a support unit has no weapons") + + if set(drone_kinds) != {entry["kind"] for entry in content["aircraft"]}: + raise ValueError("specs.json drones and content.json aircraft disagree") + if len(drone_kinds) != len(content["aircraft"]): + raise ValueError("specs.json drones and content.json aircraft differ in count") + if set(unit_kinds) != {entry["kind"] for entry in content["units"]}: + raise ValueError("specs.json units and content.json units disagree") + if len(unit_kinds) != len(content["units"]): + raise ValueError("specs.json units and content.json units differ in count") + missing = used_missiles - missile_kinds + if missing: + raise ValueError(f"units reference missiles without a profile: {sorted(missing)}") + unused = missile_kinds - used_missiles + if unused: + raise ValueError(f"missile profiles are not carried by any unit: {sorted(unused)}") + for entry in data["units"]: + parts = entry["parts"] + for slot in ("fixed", "spinner"): + if isinstance(parts[slot], dict): + referenced.add(parts[slot]["ref"]) + if isinstance(entry["pivot"], dict): + referenced.add(entry["pivot"]["ref"]) + idle = set(named) - referenced + if idle: + raise ValueError(f"named part profiles are not referenced: {sorted(idle)}") + return data + + def sound_events(entries): """Build the Minecraft sound event registry without generating audio.""" events = {} @@ -56,4 +375,4 @@ def sound_events(entries): events[event] = {"sounds": [{"name": f"airdef:{name}", "attenuation_distance": distance}]} if entry["far"]: events[event + "_far"] = {"sounds": [{"name": f"airdef:{name}_far", "attenuation_distance": int(distance * 1.6)}]} - return events + return events \ No newline at end of file diff --git a/tools/gen_catalog.py b/tools/gen_catalog.py index 655e962..681b1a2 100644 --- a/tools/gen_catalog.py +++ b/tools/gen_catalog.py @@ -4,9 +4,17 @@ import argparse from pathlib import Path import re -from airdef_assets.catalog import load, load_sounds +from airdef_assets.catalog import load, load_specs, load_sounds OUTPUT = Path(__file__).resolve().parents[1] / "airdef-mod/src/sim/catalog_entries.rs" +SPEC_OUTPUT = OUTPUT.with_name("specs_generated.rs") +UNIT_SPEC_OUTPUT = OUTPUT.with_name("unit_specs_generated.rs") + +CLASSES = {"drone": "Drone", "jet": "Jet", "cruise": "Cruise", "supersonic": "Supersonic", + "ballistic": "Ballistic"} +FAMILIES = {"trainable": "Trainable", "gun": "Gun", "turret": "Turret", "vertical": "Vertical", + "static": "Static", "truck": "Truck", "light": "Light"} +SOUND_PREFIX = "sound::" def generate(data): @@ -40,12 +48,270 @@ def generate_sound_ids(entries): return "\n".join(lines + [""]) +def num(value): + """A Rust float literal that round-trips the catalog value exactly.""" + value = float(value) + text = repr(value) + return text.replace("e+", "e") + + +def integer(value): + return str(int(value)) + + +def text(value): + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def sound_ref(event): + return SOUND_PREFIX + event.replace(".", "_").upper() + + +def parts_value(parts, indent): + pad = " " * indent + lines = [pad + "parts: Parts {"] + inner = " " * (indent + 4) + lines.append(f"{inner}body: {text(parts['body'])},") + for slot in ("launcher", "pack"): + value = parts[slot] + lines.append(f"{inner}{slot}: " + (f"Some({text(value)})" if value is not None else "None") + ",") + dish = parts["dish"] + if dish is None: + lines.append(f"{inner}dish: None,") + else: + lines.append(f"{inner}dish: Some(({text(dish[0])}, {', '.join(num(v) for v in dish[1:])})),") + fixed = parts["fixed"] + if isinstance(fixed, dict): + lines.append(f"{inner}fixed: NAMED_{fixed['ref'].upper()},") + else: + pieces = ", ".join(f"({text(piece[0])}, {num(piece[1])}, {num(piece[2])})" for piece in fixed) + lines.append(f"{inner}fixed: &[{pieces}],") + spinner = parts["spinner"] + if isinstance(spinner, dict): + lines.append(f"{inner}spinner: NAMED_{spinner['ref'].upper()},") + elif spinner is None: + lines.append(f"{inner}spinner: None,") + else: + lines.append(f"{inner}spinner: Some(({text(spinner[0])}, {', '.join(num(v) for v in spinner[1:])})),") + lines.append(pad + "},") + return lines + + +def pivot_value(pivot): + if isinstance(pivot, dict): + return f"NAMED_{pivot['ref'].upper()}" + if all(float(v) == 0.0 for v in pivot): + return "DVec3::ZERO" + return f"DVec3::new({', '.join(num(v) for v in pivot)})" + + +def muzzle_value(muzzle): + kind = muzzle["type"] + if kind == "grid": + return f"Muzzle::Grid({muzzle['cols']}, {muzzle['rows']}, {num(muzzle['size'])})" + if kind == "rails": + return f"Muzzle::Rails(&[{', '.join(num(v) for v in muzzle['xs'])}])" + return "Muzzle::" + {"none": "None", "patriot": "Patriot", "pantsir": "Pantsir", "cells": "Cells"}[kind] + + +def rest_pitch_value(pitch): + if isinstance(pitch, dict): + offset = float(pitch["half_pi"]) + if offset == 0.0: + return "HALF_PI" + if offset < 0.0: + return f"HALF_PI - {num(-offset)}" + return f"HALF_PI + {num(offset)}" + return num(pitch) + + +def drone_profile(entry): + engine = entry["engine"] + if engine["type"] == "prop": + motor = f"Engine::Prop({num(engine['back'])}, {num(engine['size'])})" + elif engine["type"] == "jet": + motor = f"Engine::Jet({num(engine['up'])}, {num(engine['back'])}, {num(engine['size'])})" + else: + motor = f"Engine::Rocket({num(engine['back'])}, {num(engine['size'])})" + sound = entry["sound"] + ballistic = "None" if entry["ballistic"] is None else f"Some({num(entry['ballistic'])})" + lines = [f"const DRONE_SPEC_{entry['kind'].upper()}: DroneSpec = DroneSpec {{", + f" name: {text(entry['name'])},", + f" key: {text(entry['key'])},", + f" model: {text(entry['model'])},", + f" speed: {num(entry['speed'])},", + f" turn: {num(entry['turn'])},", + f" cruise: {num(entry['cruise'])},", + f" clearance: {num(entry['clearance'])},", + f" dive: {num(entry['dive'])},", + f" warhead: {integer(entry['warhead'])},", + f" scale: {num(entry['scale'])},", + f" engine: {motor},", + f" decoy: {str(entry['decoy']).lower()},", + f" stealth: {num(entry['stealth'])},", + f" rcs: {num(entry['rcs'])},", + f" sound: ({sound_ref(sound['event'])}, {num(sound['pitch'])}, {integer(sound['interval'])}),", + f" class: Class::{CLASSES[entry['class']]},", + f" ballistic: {ballistic},", + f" launch_sound: {sound_ref(entry['launch_sound'])},", + "};"] + return "\n".join(lines) + + +def missile_profile(entry): + voice = entry["voice"] + lines = [f"const MISSILE_SPEC_{entry['kind'].upper()}: MissileSpec = MissileSpec {{", + f" model: {text(entry['model'])},", + f" speed: {num(entry['speed'])},", + f" accel: {num(entry['accel'])},", + f" turn: {num(entry['turn'])},", + f" boost: {integer(entry['boost'])},", + f" cold: {integer(entry['cold'])},", + f" pk: {num(entry['pk'])},", + f" fuse: {num(entry['fuse'])},", + f" life: {integer(entry['life'])},", + f" scale: {num(entry['scale'])},", + f" flame: {num(entry['flame'])},", + f" rear: {num(entry['rear'])},", + f" seeker: {num(entry['seeker'])},", + f" launch_sound: {sound_ref(entry['launch_sound'])},", + f" voice: ({sound_ref(voice['event'])}, {num(voice['gain'])}, {num(voice['pitch'])}),", + "};"] + return "\n".join(lines) + + +def unit_profile(entry): + missile = entry["missile"] + gun = entry["gun"] + if missile is None: + missile_expr = "None" + else: + missile_expr = (f"Some(MissileLoad {{ kind: MissileKind::{missile['kind']}, magazine: {integer(missile['magazine'])}, " + f"min: {num(missile['min'])}, max: {num(missile['max'])}, interval: {integer(missile['interval'])} }})") + if gun is None: + gun_expr = "None" + else: + gun_expr = (f"Some(GunSpec {{ range: {num(gun['range'])}, track: {num(gun['track'])}, p: {num(gun['p'])}, " + f"bursts: {integer(gun['bursts'])}, shell: {num(gun['shell'])}, " + f"slew: ({num(gun['slew'][0])}, {num(gun['slew'][1])}) }})") + lines = [f"const UNIT_SPEC_{entry['kind'].upper()}: SamSpec = SamSpec {{", + f" name: {text(entry['name'])},", + f" key: {text(entry['key'])},", + f" missile: {missile_expr},", + f" gun: {gun_expr},", + f" reload: {integer(entry['reload'])},", + f" scale: {num(entry['scale'])},", + f" rest_pitch: {rest_pitch_value(entry['rest_pitch'])},", + f" roles: {integer(entry['roles'])},", + f" family: Family::{FAMILIES[entry['family']]},"] + lines += parts_value(entry["parts"], 4) + lines += [f" mount: ({num(entry['mount'][0])}, {num(entry['mount'][1])}),", + f" pivot: {pivot_value(entry['pivot'])},", + f" muzzle: {muzzle_value(entry['muzzle'])},", + f" sensor: {num(entry['sensor'])},", + f" mast: {num(entry['mast'])},", + "};"] + return "\n".join(lines) + + +def generate_specs(data): + """The simulation's numeric rows, generated for both the mod and airdef-core.""" + shared = data["shared"] + lines = ["// Generated by tools/gen_catalog.py from catalog/specs.json. Do not edit.", + "// Game-scale values, not real weapon data; the catalog is the only copy.", + "", + "/// Share of the old slew speed a launcher keeps when it has no rate of its own.", + f"const DRIVE_SCALE: f64 = {num(shared['mobility']['drive_scale'])};", + "/// Ticks a drive takes to reach its top speed (and to stop from it).", + f"const DRIVE_RAMP: f64 = {num(shared['mobility']['drive_ramp'])};"] + for family in ("trainable", "vertical", "other"): + axes = shared["mobility"][family] + lines.append(f"const DRIVE_{family.upper()}: (f64, f64) = ({num(axes[0])}, {num(axes[1])});") + lines += ["/// Ballistic flight's gravity, blocks a tick².", + f"const GRAVITY: f64 = {num(shared['flight']['gravity'])};", + "/// Height over the target where the Oreshnik's blocks release their submunitions.", + f"const BLOCK_SPLIT: f64 = {num(shared['flight']['block_split'])};", + "/// How near a depot a system reloads straight from it.", + f"const DEPOT_REACH: f64 = {num(shared['logistics']['depot_reach'])};", + "/// How near a truck must come to hand a reload over, or to load up at a depot.", + f"const HANDOVER: f64 = {num(shared['logistics']['handover'])};", + "/// What a truck carries.", + f"const TRUCK_LOAD: u32 = {integer(shared['logistics']['truck_load'])};", + f"const STOCK_LIMIT: u32 = {integer(shared['logistics']['stock_limit'])};", + ""] + for entry in data["drones"]: + lines += [drone_profile(entry), ""] + for entry in data["missiles"]: + lines += [missile_profile(entry), ""] + lines += ["impl DroneKind {", + " /// Its row of numbers from `catalog/specs.json`.", + " pub fn spec(self) -> &'static DroneSpec {", + " match self {"] + for entry in data["drones"]: + lines.append(f" DroneKind::{entry['kind']} => &DRONE_SPEC_{entry['kind'].upper()},") + lines += [" }", " }", "}", "", + "impl MissileKind {", + " fn spec(self) -> &'static MissileSpec {", + " match self {"] + for entry in data["missiles"]: + lines.append(f" MissileKind::{entry['kind']} => &MISSILE_SPEC_{entry['kind'].upper()},") + lines += [" }", " }", "}", "", + "/// Save keys by kind; a rename needs an explicit migration.", + "#[cfg(test)]", + f"pub(super) const DRONE_SPEC_KEYS: [(DroneKind, &str); {len(data['drones'])}] = ["] + for entry in data["drones"]: + lines.append(f" (DroneKind::{entry['kind']}, {text(entry['key'])}),") + lines += ["];", "", + "#[cfg(test)]", + f"pub(super) const MISSILE_SPEC_MODELS: [(MissileKind, &str); {len(data['missiles'])}] = ["] + for entry in data["missiles"]: + lines.append(f" (MissileKind::{entry['kind']}, {text(entry['model'])}),") + return "\n".join(lines + ["];", ""]) + + +def generate_unit_specs(data): + """The deployed units' rows, generated where the unit types live.""" + lines = ["// Generated by tools/gen_catalog.py from catalog/specs.json. Do not edit.", + "// Game-scale values, not real weapon data; the catalog is the only copy.", + ""] + for name, entry in data["named"].items(): + value = entry["value"] + if entry["kind"] == "fixed": + pieces = ", ".join(f"({text(piece[0])}, {num(piece[1])}, {num(piece[2])})" for piece in value) + lines.append(f"const NAMED_{name.upper()}: &[(&str, f64, f64)] = &[{pieces}];") + elif entry["kind"] == "spinner": + lines.append(f"const NAMED_{name.upper()}: Option<(&str, f64, f64, f64, f64)> = " + f"Some(({text(value[0])}, {', '.join(num(v) for v in value[1:])}));") + else: + lines.append(f"const NAMED_{name.upper()}: DVec3 = DVec3::new({', '.join(num(v) for v in value)});") + lines.append("") + for entry in data["units"]: + lines += [unit_profile(entry), ""] + lines += ["impl SamKind {", + " /// Its row of numbers from `catalog/specs.json`.", + " pub fn spec(self) -> &'static SamSpec {", + " match self {"] + for entry in data["units"]: + lines.append(f" SamKind::{entry['kind']} => &UNIT_SPEC_{entry['kind'].upper()},") + lines += [" }", " }", "}", "", + "/// Save keys by kind; a rename needs an explicit migration.", + "#[cfg(test)]", + f"pub(super) const UNIT_SPEC_KEYS: [(SamKind, &str); {len(data['units'])}] = ["] + for entry in data["units"]: + lines.append(f" (SamKind::{entry['kind']}, {text(entry['key'])}),") + return "\n".join(lines + ["];", ""]) + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--check", action="store_true") args = parser.parse_args() sound_output = OUTPUT.with_name("catalog_sound_ids.rs") - outputs = ((OUTPUT, generate(load())), (sound_output, generate_sound_ids(load_sounds()))) + specs = load_specs() + outputs = ((OUTPUT, generate(load())), + (sound_output, generate_sound_ids(load_sounds())), + (SPEC_OUTPUT, generate_specs(specs)), + (UNIT_SPEC_OUTPUT, generate_unit_specs(specs))) for path, generated in outputs: if args.check: if not path.exists() or path.read_text(encoding="utf-8") != generated: