1261c30724
The interceptor's launch is one sequence: the cap opens, the round is thrown out unlit and slows under linear gas drag (`cold` ticks), the motor lights where it has reached, it turns with a growing rate while thrust spools up (`boost` ticks), and full guidance starts only at `cold + boost`. A rail launch lights at once but spools over the same `boost` ticks; the electric P1-SUN has no rocket spool or plume. S-400/500 now hold the unlit ejection for 20/24 ticks (1.00/1.20 s at 20 Hz); IRIS-T/Tor keep shorter 8-tick throws. Ignition, its sound, flame and recorded MotorIgnition event all use the missile's current position. The RM48U strike follows the same 20-tick choreography, counts the throw in its flight time and starts its recorded smoke at ignition. Maximum speed, turn, accel, pk and every collision rule are unchanged. Co-Authored-By: Kimi Code <noreply@kimi.com>
327 lines
15 KiB
Python
327 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate Rust content entries from the shared catalog; --check detects drift."""
|
|
import argparse
|
|
from pathlib import Path
|
|
import re
|
|
|
|
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):
|
|
lines = ["// Generated by tools/gen_catalog.py from catalog/content.json. Do not edit.",
|
|
"pub const AIRCRAFT: &[Aircraft] = &["]
|
|
for entry in data["aircraft"]:
|
|
kind, item = entry["kind"], entry["item"]
|
|
if not re.fullmatch(r"[A-Z][A-Za-z0-9]*", kind):
|
|
raise ValueError(f"invalid Rust kind {kind!r}")
|
|
if item is not None and not re.fullmatch(r"[a-z0-9_]+", item):
|
|
raise ValueError(f"invalid item ID {item!r}")
|
|
item_expr = f'Some("airdef:{item}")' if item is not None else "None"
|
|
lines.append(f' Aircraft {{ kind: {kind}, launch_item: {item_expr}, raid: {str(entry["raid"]).lower()} }},')
|
|
lines += ["];", "", "pub const UNITS: &[Unit] = &["]
|
|
for entry in data["units"]:
|
|
kind, item = entry["kind"], entry["item"]
|
|
if not re.fullmatch(r"[A-Z][A-Za-z0-9]*", kind) or not re.fullmatch(r"[a-z0-9_]+", item):
|
|
raise ValueError(f"invalid unit {entry!r}")
|
|
lines.append(f' Unit {{ kind: SamKind::{kind}, deploy_item: "airdef:{item}" }},')
|
|
return "\n".join(lines + ["];", ""])
|
|
|
|
|
|
def generate_sound_ids(entries):
|
|
lines = ["// Generated by tools/gen_catalog.py from catalog/sounds.json. Do not edit."]
|
|
for entry in entries:
|
|
event = entry["event"]
|
|
name = event.replace(".", "_").upper()
|
|
lines.append(f'pub const {name}: &str = "airdef:{event}";')
|
|
if entry["far"]:
|
|
lines.append(f'pub const {name}_FAR: &str = "airdef:{event}_far";')
|
|
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" eject: {num(entry['eject'])},",
|
|
f" eject_drag: {num(entry['eject_drag'])},",
|
|
f" pk: {num(entry['pk'])},",
|
|
f" fuse: {num(entry['fuse'])},",
|
|
f" life: {integer(entry['life'])},",
|
|
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")
|
|
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:
|
|
parser.exit(1, f"{path} is stale; run python3 tools/gen_catalog.py\n")
|
|
else:
|
|
path.write_text(generated, encoding="utf-8")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|