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.
This commit is contained in:
2026-11-04 20:31:41 +03:00
parent abf3d76fdb
commit ead87343d2
14 changed files with 2476 additions and 815 deletions
+28
View File
@@ -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());
}
}
-8
View File
@@ -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,
+17 -418
View File
@@ -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::<Vec<_>>().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<f64>,
/// 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> {
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
}
}
+524
View File
@@ -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"),
];
+424
View File
@@ -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"),
];
+4 -368
View File
@@ -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
}
+1 -14
View File
@@ -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 {
+851
View File
@@ -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]
}
}
}
+6
View File
@@ -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/обратного вывоза
+27
View File
@@ -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**,
+1 -1
View File
@@ -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-приёмка остаются |
+3 -1
View File
@@ -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/`
+322 -3
View File
@@ -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
+268 -2
View File
@@ -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: