#!/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()