Share sound identifiers and metadata with resource generation
build / mod (push) Failing after 18s

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
2026-11-04 15:56:58 +03:00
parent 0c870c10d5
commit 1553976484
12 changed files with 262 additions and 42 deletions
+1
View File
@@ -36,6 +36,7 @@ jobs:
run: |
python3 -m pip install --user -r tools/requirements.txt
python3 tools/gen_catalog.py --check
python3 -m unittest discover -s tools -p test_sound_catalog.py
python3 tools/gen_models.py --check
python3 -m unittest discover -s tools -p test_model_bounds.py
- name: Тесты ядра без SDK
+5
View File
@@ -52,6 +52,11 @@ impl Unit {
}
use DroneKind::*;
/// Stable sound IDs shared with the audio resource generator.
pub mod sound {
include!("catalog_sound_ids.rs");
}
// Generated entries keep the historical raid and unit ordering.
include!("catalog_entries.rs");
+22
View File
@@ -0,0 +1,22 @@
// Generated by tools/gen_catalog.py from catalog/sounds.json. Do not edit.
pub const DRONE_SHAHED: &str = "airdef:drone.shahed";
pub const DRONE_GERAN3: &str = "airdef:drone.geran3";
pub const DRONE_LANCET: &str = "airdef:drone.lancet";
pub const DRONE_CRUISE: &str = "airdef:drone.cruise";
pub const DRONE_ROCKET: &str = "airdef:drone.rocket";
pub const MISSILE_FLIGHT: &str = "airdef:missile.flight";
pub const DRONE_BALLISTIC: &str = "airdef:drone.ballistic";
pub const BOOM_SONIC: &str = "airdef:boom.sonic";
pub const LAUNCH_PATRIOT: &str = "airdef:launch.patriot";
pub const LAUNCH_S400_EJECT: &str = "airdef:launch.s400_eject";
pub const LAUNCH_S400: &str = "airdef:launch.s400";
pub const LAUNCH_S500_EJECT: &str = "airdef:launch.s500_eject";
pub const LAUNCH_S500: &str = "airdef:launch.s500";
pub const LAUNCH_PANTSIR: &str = "airdef:launch.pantsir";
pub const GUN_GEPARD: &str = "airdef:gun.gepard";
pub const GUN_PANTSIR: &str = "airdef:gun.pantsir";
pub const BLAST_BIG: &str = "airdef:blast.big";
pub const BLAST_HEAVY: &str = "airdef:blast.heavy";
pub const BLAST_HUGE: &str = "airdef:blast.huge";
pub const BLAST_MEDIUM: &str = "airdef:blast.medium";
pub const BLAST_AIR: &str = "airdef:blast.air";
+3 -3
View File
@@ -123,9 +123,9 @@ pub fn look(kind: DroneKind, outcome: Outcome) -> (f32, u32, f32, &'static str)
let life = (8 + (w as u32) / 2) * 9;
let gain = 1.0 + 0.06 * w;
match outcome {
Outcome::DisabledFalling => (size, life, gain, "airdef:blast.air"),
Outcome::Breakup => (size * 0.9, life, gain * 0.9, "airdef:blast.medium"),
Outcome::AirDetonation => (size * 1.9, life * 8 / 5, gain * 1.5, "airdef:blast.big"),
Outcome::DisabledFalling => (size, life, gain, super::catalog::sound::BLAST_AIR),
Outcome::Breakup => (size * 0.9, life, gain * 0.9, super::catalog::sound::BLAST_MEDIUM),
Outcome::AirDetonation => (size * 1.9, life * 8 / 5, gain * 1.5, super::catalog::sound::BLAST_BIG),
}
}
+2 -2
View File
@@ -660,7 +660,7 @@ impl Sim {
out.model(id + 1, "airdef:geran3_flame", pos + rot * DVec3::new(0.0, up * spec.scale, back * spec.scale), rot, spec.scale * size * flicker, true);
}
if f.carrier.is_some() {
if age == EJECT_TICKS { out.sound("airdef:launch.s400", pos, 1.0, 1.0); }
if age == EJECT_TICKS { out.sound(super::catalog::sound::LAUNCH_S400, pos, 1.0, 1.0); }
if age >= EJECT_TICKS {
let (event, gain, pitch) = MissileKind::M48n6.engine_voice();
out.engine(id, event, pos, pos - from, gain, pitch, false);
@@ -759,7 +759,7 @@ impl Sim {
land: p.aim + DVec2::new(gx, gz) * TUNE.cep, start: self.tick, arrive: self.tick + ticks, snap, hold: 0 });
if let Some(k) = self.strikes.known.iter_mut().find(|k| k.site == p.site) { k.aimed = true; }
self.record_strike_launch(self.strikes.next, p.carrier, start);
out.sound("airdef:launch.s400_eject", start, 1.0, 1.0);
out.sound(super::catalog::sound::LAUNCH_S400_EJECT, start, 1.0, 1.0);
out.particles("minecraft:cloud", start, 0.6, DVec3::Y * 0.05, 0.05, 30);
}
}
+2 -2
View File
@@ -19,7 +19,7 @@ impl MissileKind {
/// a motor without a flame.
pub fn engine_voice(self) -> (&'static str, f32, f32) {
if self.spec().flame == 0.0 {
return ("airdef:drone.lancet", 0.8, 1.6);
return (super::catalog::sound::DRONE_LANCET, 0.8, 1.6);
}
let pitch = match self {
MissileKind::Pac3 => 1.0,
@@ -31,7 +31,7 @@ impl MissileKind {
MissileKind::M9m338 => 1.15,
MissileKind::P1sun => 1.7,
};
("airdef:missile.flight", 1.0, pitch)
(super::catalog::sound::MISSILE_FLIGHT, 1.0, pitch)
}
/// Ticks from the launch until the motor lights (a cold launch throws it out of the tube first).
pub fn lit_after(self) -> u32 {
+107
View File
@@ -0,0 +1,107 @@
[
{
"event": "drone.shahed",
"distance": 220,
"far": false
},
{
"event": "drone.geran3",
"distance": 260,
"far": false
},
{
"event": "drone.lancet",
"distance": 120,
"far": false
},
{
"event": "drone.cruise",
"distance": 320,
"far": false
},
{
"event": "drone.rocket",
"distance": 450,
"far": false
},
{
"event": "missile.flight",
"distance": 380,
"far": false
},
{
"event": "drone.ballistic",
"distance": 700,
"far": false
},
{
"event": "boom.sonic",
"distance": 900,
"far": true
},
{
"event": "launch.patriot",
"distance": 650,
"far": true
},
{
"event": "launch.s400_eject",
"distance": 400,
"far": true
},
{
"event": "launch.s400",
"distance": 900,
"far": true
},
{
"event": "launch.s500_eject",
"distance": 500,
"far": true
},
{
"event": "launch.s500",
"distance": 1100,
"far": true
},
{
"event": "launch.pantsir",
"distance": 500,
"far": true
},
{
"event": "gun.gepard",
"distance": 400,
"far": true
},
{
"event": "gun.pantsir",
"distance": 380,
"far": true
},
{
"event": "blast.big",
"distance": 1000,
"far": true
},
{
"event": "blast.heavy",
"distance": 1300,
"far": true
},
{
"event": "blast.huge",
"distance": 1700,
"far": true
},
{
"event": "blast.medium",
"distance": 700,
"far": true
},
{
"event": "blast.air",
"distance": 800,
"far": true
}
]
+1 -1
View File
@@ -38,7 +38,7 @@
- `python3 tools/gen_models.py` — перегенерировать.
- `python3 tools/gen_models.py --check` — только сравнить, выход 1 при различиях.
`tools/gen_sounds.py` создаёт `airdef-mod/assets/airdef/sounds/*.ogg` и `sounds.json`. Этот генератор не использует manifest и не входит в проверку `gen_models.py`.
`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/`
+34
View File
@@ -23,3 +23,37 @@ def load():
if len(item["color"]) != 3 or any(not isinstance(c, int) or not 0 <= c <= 255 for c in item["color"]):
raise ValueError(f"invalid icon color for {item['id']}")
return data
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:
event, distance, far = entry["event"], entry["distance"], entry["far"]
if not re.fullmatch(r"[a-z0-9_]+\.[a-z0-9_]+", event) or event in seen:
raise ValueError(f"invalid or duplicate sound event {event!r}")
if type(distance) is not int or distance <= 0 or type(far) is not bool:
raise ValueError(f"invalid sound metadata for {event}")
seen.add(event)
names = [entry["event"].replace(".", "_") for entry in entries]
if len(names) != len(set(names)):
raise ValueError("sound event IDs collide as resource names")
registered = [name for entry in entries for name in ([entry["event"], entry["event"] + "_far"] if entry["far"] else [entry["event"]])]
if len(registered) != len(set(registered)):
raise ValueError("sound event IDs collide with far variants")
return entries
def sound_events(entries):
"""Build the Minecraft sound event registry without generating audio."""
events = {}
for entry in entries:
event, distance = entry["event"], entry["distance"]
name = event.replace(".", "_")
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
+18 -7
View File
@@ -4,7 +4,7 @@ import argparse
from pathlib import Path
import re
from airdef_assets.catalog import load
from airdef_assets.catalog import load, load_sounds
OUTPUT = Path(__file__).resolve().parents[1] / "airdef-mod/src/sim/catalog_entries.rs"
@@ -29,16 +29,27 @@ def generate(data):
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}";')
return "\n".join(lines + [""])
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
generated = generate(load())
if args.check:
if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != generated:
parser.exit(1, f"{OUTPUT} is stale; run python3 tools/gen_catalog.py\n")
else:
OUTPUT.write_text(generated, encoding="utf-8")
sound_output = OUTPUT.with_name("catalog_sound_ids.rs")
outputs = ((OUTPUT, generate(load())), (sound_output, generate_sound_ids(load_sounds())))
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__":
+32 -27
View File
@@ -19,6 +19,8 @@ import wave
import numpy as np
from airdef_assets.catalog import load_sounds, sound_events
SR = 44100
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "airdef-mod", "assets", "airdef"))
rng = np.random.default_rng(1945)
@@ -367,29 +369,29 @@ def blast_air():
# ---------------------------------------------------------------- запись
SOUNDS = {
# событие: (звук, дальность слышимости в блоках, есть ли дальний вариант)
"drone.shahed": (shahed, 220, False),
"drone.geran3": (geran3, 260, False),
"drone.lancet": (lancet, 120, False),
"drone.cruise": (cruise, 320, False),
"drone.rocket": (rocket, 450, False),
"missile.flight": (interceptor, 380, False),
"drone.ballistic": (ballistic, 700, False),
"boom.sonic": (sonic, 900, True),
"launch.patriot": (launch_patriot, 650, True),
"launch.s400_eject": (lambda: eject(42), 400, True),
"launch.s400": (lambda: ignite(34, 6.5), 900, True),
"launch.s500_eject": (lambda: eject(30), 500, True),
"launch.s500": (lambda: ignite(26, 8.0), 1100, True),
"launch.pantsir": (launch_pantsir, 500, True),
"gun.gepard": (lambda: gun(3, 0.056, 55, 1500, 1.6), 400, True),
"gun.pantsir": (lambda: gun(6, 0.034, 75, 2200, 1.4), 380, True),
"blast.big": (lambda: blast(1.0), 1000, True),
"blast.heavy": (lambda: blast(1.35), 1300, True),
"blast.huge": (lambda: blast(1.75), 1700, True),
"blast.medium": (lambda: blast(0.5), 700, True),
"blast.air": (blast_air, 800, True),
SYNTHESIZERS = {
# Only waveform construction belongs here; IDs and distances come from the catalog.
"drone.shahed": shahed,
"drone.geran3": geran3,
"drone.lancet": lancet,
"drone.cruise": cruise,
"drone.rocket": rocket,
"missile.flight": interceptor,
"drone.ballistic": ballistic,
"boom.sonic": sonic,
"launch.patriot": launch_patriot,
"launch.s400_eject": lambda: eject(42),
"launch.s400": lambda: ignite(34, 6.5),
"launch.s500_eject": lambda: eject(30),
"launch.s500": lambda: ignite(26, 8.0),
"launch.pantsir": launch_pantsir,
"gun.gepard": lambda: gun(3, 0.056, 55, 1500, 1.6),
"gun.pantsir": lambda: gun(6, 0.034, 75, 2200, 1.4),
"blast.big": lambda: blast(1.0),
"blast.heavy": lambda: blast(1.35),
"blast.huge": lambda: blast(1.75),
"blast.medium": lambda: blast(0.5),
"blast.air": blast_air,
}
@@ -408,19 +410,22 @@ def write_ogg(path, x):
def main():
catalog = load_sounds()
if set(SYNTHESIZERS) != {entry["event"] for entry in catalog}:
raise ValueError("sound synthesizers and catalog IDs differ")
events = sound_events(catalog)
out = os.path.join(ROOT, "sounds")
os.makedirs(out, exist_ok=True)
for f in os.listdir(out):
os.remove(os.path.join(out, f))
events = {}
for event, (fn, distance, has_far) in SOUNDS.items():
for entry in catalog:
event, has_far = entry["event"], entry["far"]
fn = SYNTHESIZERS[event]
name = event.replace(".", "_")
x = fn()
write_ogg(os.path.join(out, name + ".ogg"), x)
events[event] = {"sounds": [{"name": f"airdef:{name}", "attenuation_distance": distance}]}
if has_far:
write_ogg(os.path.join(out, name + "_far.ogg"), master(far(x), rms=0.2))
events[event + "_far"] = {"sounds": [{"name": f"airdef:{name}_far", "attenuation_distance": int(distance * 1.6)}]}
with open(os.path.join(ROOT, "sounds.json"), "w") as f:
json.dump(events, f, indent=1)
print(f"{len(events)} звуков -> {out}")
+35
View File
@@ -0,0 +1,35 @@
"""Check sound metadata against shipped resources without regenerating audio."""
import ast
import json
from pathlib import Path
import unittest
from airdef_assets.catalog import load_sounds, sound_events
ROOT = Path(__file__).resolve().parents[1]
class SoundCatalogTests(unittest.TestCase):
def test_registry_and_audio_files_match_catalog(self):
assets = ROOT / "airdef-mod/assets/airdef"
expected = sound_events(load_sounds())
actual = json.loads((assets / "sounds.json").read_text(encoding="utf-8"))
self.assertEqual(actual, expected)
files = {sound["name"].split(":", 1)[1] + ".ogg"
for event in expected.values() for sound in event["sounds"]}
self.assertEqual(files, {path.name for path in (assets / "sounds").glob("*.ogg")})
def test_every_catalog_event_has_one_synthesizer(self):
source = (ROOT / "tools/gen_sounds.py").read_text(encoding="utf-8")
tree = ast.parse(source)
mapping = next(node.value for node in tree.body if isinstance(node, ast.Assign)
and any(isinstance(target, ast.Name) and target.id == "SYNTHESIZERS"
for target in node.targets))
keys = [ast.literal_eval(key) for key in mapping.keys]
self.assertEqual(len(keys), len(set(keys)))
# Synthesis uses a shared random stream, so changing order changes the audio.
self.assertEqual(keys, [entry["event"] for entry in load_sounds()])
if __name__ == "__main__":
unittest.main()