Files
airdef/tools/gen_catalog.py
T
chexit ead87343d2 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.
2026-11-04 21:00:35 +03:00

325 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" 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()