diff --git a/Cargo.lock b/Cargo.lock index 16766772..8d34d49e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4907,6 +4907,7 @@ dependencies = [ "mlua", "serde", "serde_json", + "skate-audio", "skate-dynamics", "zip", ] diff --git a/crates/skate-audio/src/eval/mod.rs b/crates/skate-audio/src/eval/mod.rs index dec3dc88..e7365489 100644 --- a/crates/skate-audio/src/eval/mod.rs +++ b/crates/skate-audio/src/eval/mod.rs @@ -181,6 +181,17 @@ impl Evaluator { } } + /// The id the next post gets. + pub fn next_node(&self) -> u32 { + self.next_node + } + + /// Continue another evaluator's post ids (a host that replaces its runtime: node ids the old + /// one handed out must never name a post of the new one). 0 is never an id. + pub fn continue_nodes(&mut self, next: u32) { + self.next_node = next.max(1); + } + pub fn tick_scale(&self) -> f32 { self.tick } diff --git a/crates/skate-game/src/game_audio/ambience.rs b/crates/skate-game/src/game_audio/ambience.rs index 440fdfa6..ed4cde72 100644 --- a/crates/skate-game/src/game_audio/ambience.rs +++ b/crates/skate-game/src/game_audio/ambience.rs @@ -71,7 +71,8 @@ fn attenuation(phase: Phase, t: f32, fade_in: f32, fade_out: f32) -> f32 { #[derive(Default)] pub(super) struct State { - map: Option<(String, u64)>, + /// Map name, map generation, audio content generation. + map: Option<(String, u64, u64)>, /// Zone of the bed that plays (retail obj+48), its phase and timer. current: u64, phase: Option, @@ -95,6 +96,7 @@ pub(super) fn update( listener: Query<&GlobalTransform, With>, cues: Res, time: Res>, + content: Res, ) { let Some(mut library) = library else { return }; let state = &mut *state; @@ -105,7 +107,7 @@ pub(super) fn update( for clip in fading { if voices.uses(&clip) { state.fading.push(clip); } else { library.release(&mut assets, &clip); } } - let identity = (map.name.clone(), map.generation); + let identity = (map.name.clone(), map.generation, content.generation); let new_map = state.map.as_ref() != Some(&identity); if new_map { state.map = Some(identity); diff --git a/crates/skate-game/src/game_audio/content.rs b/crates/skate-game/src/game_audio/content.rs new file mode 100644 index 00000000..06a177a8 --- /dev/null +++ b/crates/skate-game/src/game_audio/content.rs @@ -0,0 +1,479 @@ +//! Audio content overlays at run time (doc 16 "Audio modding"): which running mods ship an +//! `audio.json` (`skate_mods::audio_content`), the merged [`Library`] and the native runtime's +//! start and restarts. +//! +//! - An overlay is active only while its mod runs: the mod manager's running set is diffed every +//! frame ([`AudioContent::sync`], called by the mod system after its scan and after applying +//! commands, so a failed mod's overlay goes at once). Changes are coalesced into one restart. +//! - The native runtime starts after the first mod scan ([`frame`], the first system of the audio +//! pass), so an audio mod enabled at boot costs no second start. Without overlays the startup +//! [`Library`] is used as it is: no merge, no rebuild. +//! - A restart rebuilds the [`Library`] (install + overlays in mod-id order: the first owner of an +//! identity wins), takes the runtime and its stream down and starts a new one that continues +//! the old one's post ids with `map_epoch` + 1, and replaces the world / NPC / speech hosts with +//! fresh ones: old node ids and Splice sounds are forgotten, never released into the new +//! runtime. The map-keyed state (emitters, reverb zones, zone ambience, location sets, map +//! audio) keys on [`AudioContent::generation`] and rebuilds. A short cut in the sound, at mod +//! enable / disable / failure only (user decision 2026-10-03). +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use bevy::prelude::*; +use skate_mods::audio_content::{AudioOverlay, Loaded, MAX_PCM_BYTES_TOTAL}; + +use super::Library; +use super::library::{ContentReport, OverlaySource}; + +/// A running mod's checked overlay. +pub(crate) struct Registered { + pub root: PathBuf, + fingerprint: u64, + pub overlay: AudioOverlay, + pcm_bytes: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +enum Runtime { + /// Waiting for the first mod scan. + #[default] + NotStarted, + Started, + /// The start failed (no AEMS data): retried only when the content changes. + Failed, +} + +#[derive(Resource, Default)] +pub(crate) struct AudioContent { + /// The running mods' overlays by mod id (the merge order). + pub(crate) overlays: BTreeMap, + /// Package fingerprint whose `audio.json` was looked at, by mod id (no overlay, or one that + /// failed: not read again until the package changes). + checked: BTreeMap, + /// Mods whose `audio.json` failed its checks (shown in the mod menu until the mod changes). + pub(crate) load_errors: BTreeMap, + /// The overlay set changed: [`frame`] rebuilds at the next audio pass. + pending: bool, + /// Bumped by every rebuild: the map-keyed audio state keys on it. + pub(crate) generation: u64, + /// The last rebuild's conflicts, warnings and rejections (kept until the next rebuild). + pub(crate) report: ContentReport, + /// The mod manager scanned at least once (set by the mod system). + pub(crate) scanned: bool, + runtime: Runtime, + /// Restarts done. + pub(crate) restarts: u64, +} + +impl AudioContent { + /// Diff the running packages (id, root, content fingerprint) against the registered + /// overlays: a newly running or changed package's `audio.json` is read and checked, a stopped + /// one's overlay is dropped. Cheap when nothing changed (a map lookup per running package). + pub(crate) fn sync<'a>(&mut self, running: impl Iterator) { + let mut alive = Vec::new(); + for (id, root, fingerprint) in running { + alive.push(id); + if self.checked.get(id) == Some(&fingerprint) { + continue; + } + self.checked.insert(id.to_owned(), fingerprint); + let was = self.overlays.remove(id).is_some(); + self.load_errors.remove(id); + match skate_mods::audio_content::load(root) { + Ok(Some(loaded)) => match self.admit(id, &loaded) { + Ok(()) => { + info!("Game audio: mod {id} audio content: {}", loaded.overlay.summary().join(", ")); + self.overlays.insert(id.to_owned(), Registered { root: root.to_owned(), fingerprint, overlay: loaded.overlay, pcm_bytes: loaded.pcm_bytes }); + self.pending = true; + } + Err(e) => self.refuse(id, e), + }, + Ok(None) => {} + Err(e) => self.refuse(id, e), + } + self.pending |= was; + } + let stopped: Vec = self.overlays.keys().filter(|id| !alive.contains(&id.as_str())).cloned().collect(); + for id in stopped { + info!("Game audio: mod {id} stopped: its audio content is removed"); + self.overlays.remove(&id); + self.pending = true; + } + self.checked.retain(|id, _| alive.contains(&id.as_str())); + self.load_errors.retain(|id, _| alive.contains(&id.as_str())); + } + + /// The total PCM budget across running overlays. + fn admit(&self, id: &str, loaded: &Loaded) -> Result<(), String> { + let others: u64 = self.overlays.iter().filter(|(k, _)| *k != id).map(|(_, r)| r.pcm_bytes).sum(); + if others + loaded.pcm_bytes > MAX_PCM_BYTES_TOTAL { + return Err(format!("audio.json: all audio mods together exceed {} MiB of PCM; this one is left out", MAX_PCM_BYTES_TOTAL / (1024 * 1024))); + } + Ok(()) + } + + fn refuse(&mut self, id: &str, e: String) { + warn!("Game audio: mod {id}: {e}"); + self.load_errors.insert(id.to_owned(), e); + } + + /// Request a rebuild at the next audio pass (tests; an engine importer that changed data). + pub(crate) fn invalidate(&mut self) { + self.pending = true; + } + + /// The menu lines about one mod's audio content: its load error, conflicts, rejection and + /// warnings (the first ones; the log has all). + pub(crate) fn messages_for(&self, id: &str) -> Vec { + let mut out = Vec::new(); + if let Some(e) = self.load_errors.get(id) { + out.push(format!("Audio content not loaded: {e}")); + } + let r = &self.report; + for m in r.rejected.iter().filter(|m| m.owner == id) { + out.push(format!("Audio content left out: {}", m.text)); + } + let conflicts: Vec<_> = r.conflicts.iter().filter(|m| m.owner == id).collect(); + if let Some(first) = conflicts.first() { + out.push(format!("Audio conflict ({}): {}", conflicts.len(), first.text)); + } + let warnings = r.warnings.iter().filter(|m| m.owner == id).count(); + if warnings > 0 { + out.push(format!("Audio content: {warnings} entries this install does not have were skipped (see the log)")); + } + out + } + + /// One line for the mod list when nothing is selected: how many audio conflicts there are. + pub(crate) fn summary(&self) -> Option { + let n = self.report.conflicts.len() + self.report.rejected.len() + self.load_errors.len(); + (n > 0).then(|| format!("Audio mods: {n} problems (two mods changing one sound: the first by mod id wins). Select a mod for details.")) + } + + /// The overlays in merge order. + fn sources(&self) -> Vec> { + self.overlays.iter().map(|(id, r)| OverlaySource { id, root: &r.root, overlay: &r.overlay }).collect() + } +} + +/// Rebuild the library from the install and the registered overlays (the install alone when +/// the merge fails). None when the install has no audio. +fn rebuild(world: &mut World) -> Option { + let root = world.get_resource::()?.asset_root.clone(); + let content = world.resource::(); + let result = Library::load_with(&root, &content.sources()); + let (library, report) = match result { + Ok(v) => v, + Err(e) => { + error!("Game audio: the audio content overlays could not be merged ({e}); playing the install's audio"); + let library = Library::load(&root).ok()?; + let mut report = ContentReport::default(); + report.rejected = content.overlays.keys().map(|id| skate_mods::audio_merge::Message { owner: id.clone(), text: e.clone() }).collect(); + (library, report) + } + }; + for m in &report.conflicts { + warn!("Game audio: mod {}: {}", m.owner, m.text); + } + for m in report.warnings.iter().chain(&report.rejected) { + warn!("Game audio: mod {}: {}", m.owner, m.text); + } + world.insert_resource(library); + Some(report) +} + +/// Replace the hosts that hold runtime handles (node ids, Splice sounds, MixMap instances, +/// loaded speech takes) with fresh ones: the old handles are forgotten, never released into the +/// new runtime. +fn reset_hosts(world: &mut World) { + world.insert_resource(super::world_sources::WorldHost::default()); + world.insert_resource(super::npc_skaters::NpcHost::default()); + world.insert_resource(super::world_speech::WorldSpeech::default()); +} + +/// The first system of the audio pass: the native runtime's first start (after the first mod +/// scan, or at once without the mod system) and the restart after an overlay change. +pub(super) fn frame(world: &mut World) { + let mods = world.contains_resource::(); + let Some(content) = world.get_resource::() else { return }; + let (runtime, pending, scanned, overlays) = (content.runtime, content.pending, content.scanned, !content.overlays.is_empty()); + if !world.contains_resource::() { + // No audio install: nothing to start or rebuild. + if pending { + world.resource_mut::().pending = false; + } + return; + } + match runtime { + Runtime::NotStarted => { + if mods && !scanned { + return; + } + // Overlays found by the first scan are merged before the first start; without any the + // startup library is used untouched. + let report = if overlays { rebuild(world) } else { None }; + let started = super::native::launch(world, None); + let mut content = world.resource_mut::(); + if let Some(report) = report { + content.report = report; + content.generation += 1; + } + content.pending = false; + content.runtime = if started { Runtime::Started } else { Runtime::Failed }; + } + Runtime::Started | Runtime::Failed if pending => restart(world), + _ => {} + } +} + +/// Rebuild the library and restart the native runtime (see the module docs). +pub(crate) fn restart(world: &mut World) { + let Some(report) = rebuild(world) else { return }; + let carry = super::native::shutdown(world); + reset_hosts(world); + let started = super::native::launch(world, carry); + let mut content = world.resource_mut::(); + content.report = report; + content.generation += 1; + content.restarts += 1; + content.pending = false; + content.runtime = if started { Runtime::Started } else { Runtime::Failed }; + info!("Game audio: restarted for the mods' audio content (generation {}, overlays {:?})", content.generation, content.overlays.keys().collect::>()); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn package(dir: &Path, id: &str, overlay: Option) -> PathBuf { + let root = dir.join(id); + std::fs::create_dir_all(root.join("audio")).unwrap(); + std::fs::write(root.join("audio/a.wav"), super::super::library::tests::test_wav(100, 48000, 9)).unwrap(); + match overlay { + Some(o) => std::fs::write(root.join("audio.json"), o.to_string()).unwrap(), + None => { + let _ = std::fs::remove_file(root.join("audio.json")); + } + } + root + } + + /// The running-set diff: an overlay registers while its mod runs, is re-read when the package + /// changes, goes when the mod stops; a broken `audio.json` is a menu message, never a crash; + /// nothing is re-read while nothing changes. + #[test] + fn overlays_follow_the_running_mods() { + let dir = std::env::temp_dir().join(format!("skate-audio-content-sync-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let a = package(&dir, "dev.a", Some(serde_json::json!({"version": 1, "replace": {"samples": {"x": {"0": "audio/a.wav"}}}}))); + let b = package(&dir, "dev.b", None); + let mut c = AudioContent::default(); + c.sync([("dev.a", a.as_path(), 1), ("dev.b", b.as_path(), 1)].into_iter()); + assert!(c.pending && c.overlays.contains_key("dev.a") && !c.overlays.contains_key("dev.b")); + c.pending = false; + // Unchanged: nothing is read (even a now-broken file is not noticed until the package + // fingerprint changes, as the manager reloads it then). + std::fs::write(a.join("audio.json"), "{broken").unwrap(); + c.sync([("dev.a", a.as_path(), 1), ("dev.b", b.as_path(), 1)].into_iter()); + assert!(!c.pending && c.overlays.contains_key("dev.a")); + // Changed and broken: the overlay goes, the error is kept for the menu. + c.sync([("dev.a", a.as_path(), 2), ("dev.b", b.as_path(), 1)].into_iter()); + assert!(c.pending && c.overlays.is_empty()); + assert!(c.messages_for("dev.a")[0].starts_with("Audio content not loaded")); + assert!(c.summary().is_some()); + c.pending = false; + // Fixed, then the mod stops (disabled or its script failed): removed. + package(&dir, "dev.a", Some(serde_json::json!({"version": 1}))); + c.sync([("dev.a", a.as_path(), 3)].into_iter()); + assert!(c.pending && c.overlays.contains_key("dev.a") && c.load_errors.is_empty()); + c.pending = false; + c.sync(std::iter::empty()); + assert!(c.pending && c.overlays.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + fn config(root: &Path) -> crate::config::Config { + crate::config::Config { + asset_root: root.to_owned(), verification_capture: None, map: None, map_path: None, + difficulty: Default::default(), check_assets: false, validate_maps: false, start_paused: false, + multiplayer: Default::default(), map_fingerprint: 0, teleport: None, mute: true, + } + } + + /// A world with what [`frame`] / [`restart`] use, the install's library and the runtime + /// started as the game starts it. + fn game_world(root: &Path) -> World { + let mut world = World::new(); + let config = config(root); + world.insert_resource(super::super::AudioSettings::load(&config)); + world.insert_resource(config); + world.insert_resource(Library::load(root).unwrap()); + world.init_resource::>(); + world.init_resource::(); + reset_hosts(&mut world); + world.resource_mut::().scanned = true; + frame(&mut world); + assert!(world.contains_resource::(), "the runtime starts at the first pass after the scan"); + world + } + + /// A first emitter bank of DownTown's `.ems` that the install has, and its patch. + fn emitter_bank(library: &Library) -> (String, i32) { + let r = library.emitters("sfx_downtown").iter().find(|r| r.kind == 1 && r.bank.as_ref().is_some_and(|b| library.aems().banks.contains_key(b))) + .expect("a DownTown emitter with a bank"); + (r.bank.clone().unwrap(), r.patch) + } + + /// The scenario after the restart point: the emitter bank loaded, one `c_emitter` post held + /// and redelivered every 8 blocks, 600 blocks rendered (6.4 s). Returns the stereo output. + fn scenario(world: &mut World, bank: &str, patch: i32) -> Vec { + let library = world.remove_resource::().unwrap(); + let mut native = world.resource_mut::(); + native.ensure_bank(&library, bank).unwrap(); + let payload = native.emitter_payload(None, 0.8, 9000, patch); + let node = native.post_emitter(&payload).expect("c_emitter"); + let mut out = vec![0.0f32; 2 * skate_audio::BLOCK]; + let mut all = Vec::new(); + for b in 0..600 { + if b % 8 == 0 { + native.redeliver(node, &payload); + } + native.shared.lock().unwrap().fill_stereo(&mut out); + all.extend_from_slice(&out); + } + drop(native); + world.insert_resource(library); + all + } + + /// R1e (data-gated): an audio mod found by the first scan is merged before the first start + /// (no restart); its `preload` bank loads at start and survives a map change; its other bank + /// loads on use and goes with the map like any map bank. + #[test] + #[ignore = "needs the private install data"] + fn preloaded_mod_banks_survive_map_changes() { + use super::super::native::Native; + let root = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets")); + if Library::load(root).is_err() { + panic!("missing private data: no audio install"); + } + let dir = std::env::temp_dir().join(format!("skate-audio-resident-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("audio")).unwrap(); + use skate_audio::eval::synthetic; + let abk = synthetic::bank(&[synthetic::player_module(2)], &[], &[(1000, false)]).data; + std::fs::write(dir.join("audio/k.abk"), &abk).unwrap(); + std::fs::write(dir.join("audio/a.wav"), super::super::library::tests::test_wav(1000, 48000, 100)).unwrap(); + std::fs::write(dir.join("audio.json"), serde_json::json!({"version": 1, "add": {"banks": { + "MOD_keep": {"abk": "audio/k.abk", "samples": ["audio/a.wav"], "preload": true, "group": "player"}, + "MOD_map": {"abk": "audio/k.abk", "samples": ["audio/a.wav"]}}}}).to_string()).unwrap(); + let mut world = World::new(); + let config = config(root); + world.insert_resource(super::super::AudioSettings::load(&config)); + world.insert_resource(config); + world.insert_resource(Library::load(root).unwrap()); + world.init_resource::>(); + world.init_resource::(); + reset_hosts(&mut world); + { + let mut c = world.resource_mut::(); + c.scanned = true; + c.sync([("dev.resident", dir.as_path(), 1)].into_iter()); + } + frame(&mut world); + let c = world.resource::(); + assert_eq!((c.restarts, c.generation), (0, 1), "merged before the first start"); + assert_eq!(c.report.applied, ["dev.resident"]); + let library = world.remove_resource::().unwrap(); + let mut native = world.resource_mut::(); + assert!(native.bank_loaded("MOD_keep") && !native.bank_loaded("MOD_map")); + native.ensure_bank(&library, "MOD_map").unwrap(); + native.unload_map_banks(); + assert!(native.bank_loaded("MOD_keep"), "the preloaded bank stays"); + assert!(!native.bank_loaded("MOD_map"), "a map bank goes"); + assert!(native.bank_loaded("emitter_utility")); + drop(native); + let _ = std::fs::remove_dir_all(&dir); + } + + fn same(a: &[f32], b: &[f32]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.to_bits() == y.to_bits()) + } + + /// R1d (data-gated): a restart mid-scenario (a held emitter post, blocks rendered) continues + /// the old runtime's post ids, bumps the map epoch, respawns the stream and resets the hosts; + /// from the restart point the output equals a fresh runtime's (same first post id) bit for + /// bit. A mod replacing the emitter bank's samples changes the output; removing the mod + /// (another restart) gives the retail output again. + #[test] + #[ignore = "needs the private install data"] + fn a_restart_mid_scenario_plays_like_a_fresh_start_and_a_removed_mod_restores_retail() { + use super::super::native::{Native, NativeOutputCount, WorldInstances}; + let root = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets")); + if Library::load(root).is_err() { + panic!("missing private data: no audio install"); + } + let mut world = game_world(root); + let (bank, patch) = emitter_bank(world.resource::()); + // Before the restart: the scenario runs on the first runtime. + let before = scenario(&mut world, &bank, patch); + assert!(before.iter().any(|s| *s != 0.0), "{bank} sounds"); + let held = world.resource::().next_node() - 1; + let (next, epoch) = (world.resource::().next_node(), world.resource::().map_epoch); + world.resource_mut::().invalidate(); + frame(&mut world); + assert_eq!(world.resource::().restarts, 1); + assert_eq!(world.resource::().generation, 1); + { + let n = world.resource::(); + assert_eq!(n.map_epoch, epoch + 1); + let rt = n.shared.lock().unwrap(); + assert!(rt.eval.node_class(skate_audio::eval::NodeId(held)).is_none(), "an old id is not a post of the new runtime"); + assert!(rt.eval.next_node() > next, "the boot posts took ids after the old ones"); + } + assert_eq!(NativeOutputCount::of(&mut world), 1, "one stream"); + // Releasing an old id (a host that missed the reset) touches nothing in the new runtime. + let live = world.resource::().shared.lock().unwrap().eval.node_count(); + world.resource::().release(skate_audio::eval::NodeId(held)); + assert_eq!(world.resource::().shared.lock().unwrap().eval.node_count(), live); + // From the restart point: the same as a fresh runtime with the same first id. + let restarted = scenario(&mut world, &bank, patch); + let fresh = { + let mut w = World::new(); + w.insert_resource(Library::load(root).unwrap()); + let library = w.resource::(); + let native = Native::start_from(library, WorldInstances::RETAIL, next).unwrap(); + w.insert_resource(native); + scenario(&mut w, &bank, patch) + }; + assert!(same(&restarted, &fresh), "restart = fresh start from that point"); + // A mod replacing the bank's samples (a tone of another rate and length) is heard... + let dir = std::env::temp_dir().join(format!("skate-audio-restart-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("audio")).unwrap(); + let samples = world.resource::().bank_len(&bank); + let tone: Vec = super::super::library::tests::test_wav(22050, 22050, 6000); + std::fs::write(dir.join("audio/tone.wav"), &tone).unwrap(); + let overlay = serde_json::json!({"version": 1, "replace": {"banks": {bank.clone(): {"samples": vec!["audio/tone.wav"; samples]}}}}); + std::fs::write(dir.join("audio.json"), overlay.to_string()).unwrap(); + world.resource_mut::().sync([("dev.restart", dir.as_path(), 1)].into_iter()); + frame(&mut world); + assert!(world.resource::().report.applied == ["dev.restart"], "{:?}", world.resource::().report); + let modded = scenario(&mut world, &bank, patch); + assert!(!same(&modded, &fresh), "the mod's samples play"); + // ...and removing it restores retail: the output equals a fresh no-mod runtime's. + world.resource_mut::().sync(std::iter::empty()); + let next = world.resource::().next_node(); + frame(&mut world); + assert_eq!(world.resource::().restarts, 3); + let restored = scenario(&mut world, &bank, patch); + let fresh = { + let mut w = World::new(); + w.insert_resource(Library::load(root).unwrap()); + let native = Native::start_from(w.resource::(), WorldInstances::RETAIL, next).unwrap(); + w.insert_resource(native); + scenario(&mut w, &bank, patch) + }; + assert!(same(&restored, &fresh), "removed mod = retail"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/skate-game/src/game_audio/emitters.rs b/crates/skate-game/src/game_audio/emitters.rs index f76f19ff..e869864d 100644 --- a/crates/skate-game/src/game_audio/emitters.rs +++ b/crates/skate-game/src/game_audio/emitters.rs @@ -90,7 +90,8 @@ struct ZoneRecord { #[derive(Default)] pub(super) struct ZoneState { - map: Option<(String, u64)>, + /// Map name, map generation, audio content generation. + map: Option<(String, u64, u64)>, records: Vec, /// Reached records in discovery order. active: Vec, @@ -107,10 +108,11 @@ pub(super) fn reverb_zones( native: Option>, listener: Query<&GlobalTransform, With>, mut out: ResMut, + content: Res, ) { let (Some(library), Some(_)) = (library, native) else { return }; let state = &mut *state; - let identity = (map.name.clone(), map.generation); + let identity = (map.name.clone(), map.generation, content.generation); if state.map.as_ref() != Some(&identity) { let stem = map.path.as_deref().and_then(|p| p.file_stem()).and_then(|s| s.to_str()).unwrap_or(""); state.records = zone_records(&library, stem); @@ -238,7 +240,8 @@ struct Node { #[derive(Default)] pub(super) struct State { - map: Option<(String, u64)>, + /// Map name, map generation, audio content generation. + map: Option<(String, u64, u64)>, emitters: Vec, /// Reached records in discovery order (retail's node list). nodes: Vec, @@ -307,13 +310,19 @@ pub(super) fn update( replay: Res, native: Option>, cues: Res, + content: Res, ) { let _timing = super::timing::scope(&super::timing::EMITTERS); // No native runtime: the emitters are silent (its start logged why). let (Some(library), Some(mut native)) = (library, native) else { return }; let native = &mut *native; let state = &mut *state; - let identity = (map.name.clone(), map.generation); + let identity = (map.name.clone(), map.generation, content.generation); + if state.map.as_ref().is_some_and(|m| m.2 != content.generation) { + // The runtime restarted (`content::restart`): its posts and emitter states are the old + // runtime's. Forget them; never release old ids into the new runtime. + state.nodes.clear(); + } if state.map.as_ref() != Some(&identity) { for node in state.nodes.drain(..) { if let Some(post) = node.post { diff --git a/crates/skate-game/src/game_audio/library.rs b/crates/skate-game/src/game_audio/library.rs index 06e23811..8d2d51e4 100644 --- a/crates/skate-game/src/game_audio/library.rs +++ b/crates/skate-game/src/game_audio/library.rs @@ -334,6 +334,79 @@ struct Manifest { /// `SKATE_SETUP_SPEECH=1`, the decoded takes; optional). #[serde(default)] speech: BTreeMap, + // Audio content overlays only (`skate_mods::audio_merge`; never in an install's manifest). + /// Bank → S10A slot → the loop start (frames) of a mod WAV. + #[serde(default)] + mod_sample_loops: BTreeMap>, + /// Mod banks' load policy and volume group. + #[serde(default)] + mod_banks: BTreeMap, + /// Speech archive → replaced takes and extra takes. + #[serde(default)] + mod_speech: BTreeMap, + /// Bank → the interim location-set layers of a mod bank. + #[serde(default)] + mod_location_programs: BTreeMap>, + /// Map stem → map audio from overlays. + #[serde(default)] + mod_maps: BTreeMap, +} + +#[derive(Debug, Default, Clone, Deserialize)] +struct ModBankJson { + #[serde(default)] + preload: bool, + #[serde(default)] + group: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct ModSpeechJson { + /// Clip (without `.dat`) → take → file. + #[serde(default)] + takes: BTreeMap>, + /// Clip → extra takes after the clip's own. + #[serde(default)] + extra: BTreeMap>, +} + +/// One layer of a mod bank's location-set post (`random_sets` interim player). +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct LayerJson { + #[serde(default)] + pub delay: f32, + /// A slot, or `"shuffle"`. + pub sample: serde_json::Value, + #[serde(default = "one")] + pub level: f32, + #[serde(default)] + pub pan_sweep: f32, + #[serde(default)] + pub looping: bool, +} + +/// A map's audio from a mod overlay, a `.audio.json` sidecar or a `.skate` `AUDO` tag +/// (`skate_mods::audio_content::MapAudioDef`; records with their index). +#[derive(Debug, Default, Clone, Deserialize)] +pub(crate) struct MapJson { + #[serde(default)] + pub district: Option, + #[serde(default)] + pub ems: Option>, + #[serde(default)] + pub crossfade_bank: Option, + #[serde(default)] + pub fallback_bed: Option, + #[serde(default)] + pub emitters: Vec, + #[serde(default)] + pub regions: BTreeMap>, +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct BoxJson { + pub r#box: [f32; 4], + pub key: String, } /// One speech archive's export: the index JSON and the folder of decoded takes (None: not decoded). @@ -727,16 +800,31 @@ pub(crate) fn wav_pcm(bytes: &[u8]) -> Option { pub(crate) type BankPcm = Vec>>; /// Read and decode the WAVs in order (`Library::bank_pcm` and `BankSource::load`). -fn decode_wavs<'a>(root: &Path, files: impl Iterator) -> BankPcm { +fn decode_wavs<'a>(files: impl Iterator) -> BankPcm { files .map(|file| { #[cfg(test)] WAV_DECODES.with(|n| n.set(n.get() + 1)); - std::fs::read(root.join(file)).ok().and_then(|bytes| wav_pcm(&bytes)).map(Arc::new) + std::fs::read(file).ok().and_then(|bytes| wav_pcm(&bytes)).map(Arc::new) }) .collect() } +/// The sample header a mod WAV gets in an AEMS bank: voices play by the header (frames, rate, +/// channels, loop), so a replacement of another length or rate needs its own. The codec stays the +/// slot's; a slot that looped loops from frame 0 unless the overlay gives `loop_start`. +pub(crate) fn mod_header(old: Option, pcm: &skate_audio::mixer::Pcm, loop_start: Option) -> skate_audio::formats::SampleHeader { + let frames = pcm.channels.first().map_or(0, |c| c.len() as u32); + let looped = loop_start.or(old.and_then(|h| h.loop_start).map(|_| 0)); + skate_audio::formats::SampleHeader { + codec: old.map_or(0, |h| h.codec), + channels: pcm.channels.len().clamp(1, 6) as u8, + rate: pcm.rate, + frames, + loop_start: looped.filter(|&l| l < frames), + } +} + #[cfg(test)] thread_local! { /// WAVs read and decoded on this thread (tests: no decode on the game thread at emitter start). @@ -747,29 +835,39 @@ thread_local! { /// reference to the library: `native::prefetch` runs [`BankSource::load`] on its worker thread. /// The game thread's own load (`Native::ensure_bank`) runs the same function, so both give the /// same bank and PCM. +/// Each file is an absolute path (a retail bank with a replaced WAV mixes the install and a mod); +/// `rebuild` lists the slots whose header comes from their (mod) WAV, with the loop start. #[derive(Clone, Debug)] pub(crate) struct BankSource { - root: PathBuf, stem: String, file: String, - wavs: Vec, + abk: PathBuf, + wavs: Vec, + rebuild: Vec<(usize, Option)>, } impl BankSource { #[cfg(test)] pub(crate) fn for_test(root: PathBuf, stem: &str, file: &str, wavs: Vec) -> Self { - Self { root, stem: stem.to_owned(), file: file.to_owned(), wavs } + Self { stem: stem.to_owned(), file: file.to_owned(), abk: root.join(file), wavs: wavs.iter().map(|w| root.join(w)).collect(), rebuild: Vec::new() } } pub(crate) fn stem(&self) -> &str { &self.stem } - /// The `.abk` parsed and its WAVs decoded; the errors are `ensure_bank`'s. + /// The `.abk` parsed and its WAVs decoded; the errors are `ensure_bank`'s. Mod WAVs get their + /// headers from the PCM ([`mod_header`]). pub(crate) fn load(&self) -> Result<(skate_audio::formats::Bank, BankPcm), String> { - let bytes = std::fs::read(self.root.join(&self.file)).map_err(|e| format!("{}: {e}", self.file))?; - let bank = skate_audio::formats::Bank::parse(&self.stem, bytes).map_err(|e| e.to_string())?; - Ok((bank, decode_wavs(&self.root, self.wavs.iter().map(String::as_str)))) + let bytes = std::fs::read(&self.abk).map_err(|e| format!("{}: {e}", self.file))?; + let mut bank = skate_audio::formats::Bank::parse(&self.stem, bytes).map_err(|e| e.to_string())?; + let pcm = decode_wavs(self.wavs.iter().map(PathBuf::as_path)); + for &(slot, loop_start) in &self.rebuild { + if let (Some(entry), Some(Some(p))) = (bank.samples.get_mut(slot), pcm.get(slot)) { + entry.1 = Some(mod_header(entry.1, p, loop_start)); + } + } + Ok((bank, pcm)) } } @@ -777,6 +875,8 @@ impl BankSource { pub(crate) struct Library { root: PathBuf, manifest: Manifest, + /// The merged audio content overlays' roots by mod id (`mod:/` files). + mods: BTreeMap, loaded: HashMap, failed: std::collections::HashSet, } @@ -787,6 +887,27 @@ fn safe_relative(file: &str) -> bool { !file.is_empty() && !file.contains(':') && path.components().all(|c| matches!(c, Component::Normal(_))) } +/// One running mod's audio content overlay for [`Library::load_with`]. +pub(crate) struct OverlaySource<'a> { + pub id: &'a str, + /// The mod's root folder (its files are read below it). + pub root: &'a Path, + pub overlay: &'a skate_mods::audio_content::AudioOverlay, +} + +/// What merging the overlays did: per mod, conflicts (another mod owns the identity), warnings +/// (unknown identities, tuning fields the install lacks) and rejections (the merged manifest did +/// not read back: the whole overlay is left out). +#[derive(Debug, Default, Clone)] +pub(crate) struct ContentReport { + pub applied: Vec, + pub conflicts: Vec, + pub warnings: Vec, + pub rejected: Vec, + /// Identity → owning mod. + pub owners: BTreeMap, +} + impl Library { /// A speech archive's export (`livingworld`): the index file and the decoded takes' folder /// (None when the takes were not decoded). None without an index. @@ -799,6 +920,60 @@ impl Library { Some((self.root.join(&e.index), audio)) } + /// A speech archive's mod takes: replaced (clip without `.dat`, take) → file, and extra takes + /// by clip after the clip's own (empty without overlays). + pub(crate) fn speech_mods(&self, archive: &str) -> (HashMap<(String, u32), PathBuf>, HashMap>) { + let Some(s) = self.manifest.mod_speech.get(archive) else { return Default::default() }; + let takes = s.takes.iter().flat_map(|(clip, takes)| { + takes.iter().filter_map(move |(take, file)| Some(((clip.clone(), take.parse().ok()?), self.path(file)))) + }).collect(); + let extra = s.extra.iter().map(|(clip, files)| (clip.clone(), files.iter().map(|f| self.path(f)).collect())).collect(); + (takes, extra) + } + + /// Where a manifest file is: under the install's audio folder, or under a mod's root for a + /// `mod:/` reference. + pub(crate) fn path(&self, file: &str) -> PathBuf { + match skate_mods::audio_merge::split_mod_ref(file).and_then(|(id, rel)| Some(self.mods.get(id)?.join(rel))) { + Some(p) => p, + None => self.root.join(file), + } + } + + /// Banks overlays load at audio start and keep across map changes, with their group + /// (`true` = the player group). + pub(crate) fn resident_banks(&self) -> Vec<(String, bool)> { + self.manifest.mod_banks.iter().filter(|(_, b)| b.preload).map(|(s, b)| (s.clone(), b.group.as_deref() == Some("player"))).collect() + } + + /// An overlay's volume group for a bank (`Some(true)` = player, `Some(false)` = world). + pub(crate) fn bank_group(&self, stem: &str) -> Option { + self.manifest.mod_banks.get(stem).and_then(|b| b.group.as_deref()).map(|g| g == "player") + } + + /// Whether a bank comes from an audio content overlay (all its files are mod files). + pub(crate) fn is_mod_bank(&self, stem: &str) -> bool { + let is_mod = |f: &str| skate_mods::audio_merge::split_mod_ref(f).is_some(); + let wavs = self.manifest.banks.get(stem); + let abk = self.manifest.aems.banks.get(stem); + (wavs.is_some() || abk.is_some()) && wavs.is_none_or(|w| w.iter().all(|e| is_mod(&e.file))) && abk.is_none_or(|a| is_mod(a)) + } + + /// A mod bank's interim location-set layers. + pub(crate) fn location_program(&self, bank: &str) -> Option<&[LayerJson]> { + self.manifest.mod_location_programs.get(bank).map(Vec::as_slice) + } + + /// An overlay's map audio for a map stem. + pub(crate) fn mod_map(&self, stem: &str) -> Option<&MapJson> { + self.manifest.mod_maps.get(stem) + } + + /// A zone's key by its name. + pub(crate) fn zone_named(&self, name: &str) -> Option { + self.manifest.zones.iter().find(|(_, z)| z.name.as_deref() == Some(name)).and_then(|(k, _)| u64::from_str_radix(k, 16).ok()) + } + /// The world sources' tuning (`world_sources`; defaults on installs without it). pub(crate) fn world_tuning(&self) -> &super::world_sources::WorldTuningJson { &self.manifest.world_tuning @@ -858,11 +1033,45 @@ impl Library { self.read(&entry.file).ok().and_then(|bytes| wav_pcm(&bytes)).map(Arc::new) } + /// The install's audio, no overlays. pub(crate) fn load(asset_root: &Path) -> Result { + Self::load_with(asset_root, &[]).map(|(library, _)| library) + } + + /// The install's audio with the running mods' content overlays merged over it, in the order + /// given (mod-id order: the first owner of an identity wins; `skate_mods::audio_merge`). With + /// no overlays the manifest is read exactly as before (no JSON tree, no merge). An overlay + /// whose merged manifest does not read back is left out whole (reported). + pub(crate) fn load_with(asset_root: &Path, overlays: &[OverlaySource]) -> Result<(Self, ContentReport), String> { let root = asset_root.join("private/audio"); let path = root.join("audio_manifest.json"); let bytes = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?; - let manifest: Manifest = serde_json::from_slice(&bytes).map_err(|e| format!("{}: {e}", path.display()))?; + let mut report = ContentReport::default(); + let mut mods = BTreeMap::new(); + let manifest: Manifest = if overlays.is_empty() { + serde_json::from_slice(&bytes).map_err(|e| format!("{}: {e}", path.display()))? + } else { + use skate_mods::audio_merge::{Message, Owners, Report, Source, merge_one}; + let mut value: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| format!("{}: {e}", path.display()))?; + let mut owners = Owners::default(); + for o in overlays { + let (mut trial, mut trial_owners, mut r) = (value.clone(), owners.clone(), Report::default()); + merge_one(&mut trial, &Source { id: o.id, overlay: o.overlay }, &mut trial_owners, &mut r); + match serde_json::from_value::(trial.clone()) { + Ok(_) => { + value = trial; + owners = trial_owners; + report.applied.push(o.id.to_owned()); + report.conflicts.extend(r.conflicts); + report.warnings.extend(r.warnings); + mods.insert(o.id.to_owned(), o.root.to_owned()); + } + Err(e) => report.rejected.push(Message { owner: o.id.to_owned(), text: format!("audio.json does not fit this install: {e}") }), + } + } + report.owners = owners.all().clone(); + serde_json::from_value(value).map_err(|e| format!("{}: {e}", path.display()))? + }; if !MANIFEST_VERSIONS.contains(&manifest.version) { return Err(format!("{}: unsupported version {}", path.display(), manifest.version)); } @@ -871,14 +1080,24 @@ impl Library { let aems = manifest.aems.projects.iter().chain(manifest.aems.banks.values()).chain(manifest.aems.mixmap.iter()) .chain(manifest.aems.splice.values()) .chain(manifest.grains.values().flat_map(|g| g.file.iter().chain(g.grain.iter()))); - if let Some(bad) = files.map(|e| &e.file).chain(aems).find(|f| !safe_relative(f)) { + let speech = manifest.mod_speech.values().flat_map(|s| s.takes.values().flat_map(|t| t.values()).chain(s.extra.values().flatten())); + // Install files are plain relative paths; mod files are `mod:/` of a + // merged overlay. + let valid = |f: &String| match skate_mods::audio_merge::split_mod_ref(f) { + Some((id, rel)) => mods.contains_key(id) && safe_relative(rel), + None => safe_relative(f), + }; + if let Some(bad) = files.map(|e| &e.file).chain(aems).chain(speech).find(|f| !valid(f)) { return Err(format!("{}: invalid file path {bad:?}", path.display())); } info!( "Game audio: {} ambience beds, {} rolling grains, {} sample banks", manifest.ambience.len(), manifest.grains.len(), manifest.banks.len() ); - Ok(Self { root, manifest, loaded: HashMap::new(), failed: Default::default() }) + if !overlays.is_empty() { + info!("Game audio: content overlays {:?} ({} conflicts, {} warnings, {} rejected)", report.applied, report.conflicts.len(), report.warnings.len(), report.rejected.len()); + } + Ok((Self { root, manifest, mods, loaded: HashMap::new(), failed: Default::default() }, report)) } fn clip(&mut self, assets: &mut Assets, file: &str) -> Option { @@ -888,7 +1107,7 @@ impl Library { if self.failed.contains(file) { return None; } - match std::fs::read(self.root.join(file)) { + match std::fs::read(self.path(file)) { Ok(bytes) => { let peak = wav_peak(&bytes); let channels = wav_channels(&bytes); @@ -1027,7 +1246,7 @@ impl Library { /// Read a manifest-listed file (paths were validated at load). pub(crate) fn read(&self, file: &str) -> std::io::Result> { - std::fs::read(self.root.join(file)) + std::fs::read(self.path(file)) } /// A Splice bank for the native player: its patch tree and its samples (stream n = WAV n), or @@ -1043,18 +1262,23 @@ impl Library { /// is missing or unreadable (the native runtime then plays silence of the right length). pub(crate) fn bank_pcm(&self, bank: &str) -> BankPcm { let Some(entries) = self.manifest.banks.get(bank) else { return Vec::new() }; - decode_wavs(&self.root, entries.iter().map(|e| e.file.as_str())) + let paths: Vec = entries.iter().map(|e| self.path(&e.file)).collect(); + decode_wavs(paths.iter().map(PathBuf::as_path)) } /// What reading and decoding an AEMS bank needs, detached from the library so a background /// thread can do it (`native::prefetch`). Err = the bank is not in the install. pub(crate) fn bank_source(&self, stem: &str) -> Result { let file = self.manifest.aems.banks.get(stem).ok_or_else(|| format!("bank {stem} is not in the install"))?; + let entries = self.manifest.banks.get(stem).map_or(&[][..], Vec::as_slice); + let loops = self.manifest.mod_sample_loops.get(stem); Ok(BankSource { - root: self.root.clone(), stem: stem.to_owned(), file: file.clone(), - wavs: self.manifest.banks.get(stem).map_or_else(Vec::new, |e| e.iter().map(|e| e.file.clone()).collect()), + abk: self.path(file), + wavs: entries.iter().map(|e| self.path(&e.file)).collect(), + rebuild: entries.iter().enumerate().filter(|(_, e)| skate_mods::audio_merge::split_mod_ref(&e.file).is_some()) + .map(|(i, _)| (i, loops.and_then(|l| l.get(&i.to_string()).copied()))).collect(), }) } @@ -1067,7 +1291,7 @@ impl Library { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; /// The bridge's speed graph: the exported posters override the default, and the default is @@ -1097,6 +1321,269 @@ mod tests { assert_eq!(vault, SpeedGraph8::BODY_SPEED); } + pub(crate) fn test_wav(frames: usize, rate: u32, value: i16) -> Vec { + let data = frames * 2; + let mut b = b"RIFF".to_vec(); + b.extend((36 + data as u32).to_le_bytes()); + b.extend(b"WAVEfmt "); + b.extend(16u32.to_le_bytes()); + b.extend([1, 0, 1, 0]); + b.extend(rate.to_le_bytes()); + b.extend((rate * 2).to_le_bytes()); + b.extend([2, 0, 16, 0]); + b.extend(b"data"); + b.extend((data as u32).to_le_bytes()); + for _ in 0..frames { + b.extend(value.to_le_bytes()); + } + b + } + + /// A temp install with a sample bank `x` (2 WAVs), an AEMS bank `T` (two S10A slots, the + /// second looping) with its 2 WAVs, an ambience bed and a speech index; and a mod folder. + pub(crate) fn content_fixture(name: &str) -> (PathBuf, PathBuf) { + let dir = std::env::temp_dir().join(format!("skate-audio-content-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let audio = dir.join("private/audio"); + for d in ["banks/x", "banks/T", "aems", "ambience"] { + std::fs::create_dir_all(audio.join(d)).unwrap(); + } + std::fs::write(audio.join("banks/x/0.wav"), test_wav(100, 48000, 100)).unwrap(); + std::fs::write(audio.join("banks/x/1.wav"), test_wav(100, 48000, 200)).unwrap(); + std::fs::write(audio.join("banks/T/0.wav"), test_wav(300, 48000, 300)).unwrap(); + std::fs::write(audio.join("banks/T/1.wav"), test_wav(400, 48000, 400)).unwrap(); + std::fs::write(audio.join("ambience/bed.wav"), test_wav(1000, 48000, 500)).unwrap(); + use skate_audio::eval::synthetic; + let bank = synthetic::bank(&[synthetic::player_module(4)], &[], &[(300, false), (400, true)]); + std::fs::write(audio.join("aems/T.abk"), &bank.data).unwrap(); + std::fs::write(audio.join("audio_manifest.json"), serde_json::json!({ + "version": 5, + "ambience": {"bed": {"file": "ambience/bed.wav"}}, + "grains": {}, "wheels": {}, + "banks": {"x": [{"file": "banks/x/0.wav"}, {"file": "banks/x/1.wav"}], "T": [{"file": "banks/T/0.wav"}, {"file": "banks/T/1.wav"}]}, + "zones": {"00000000000000AB": {"name": "plaza", "bed": "bed"}}, + "aems": {"projects": [], "banks": {"T": "aems/T.abk"}}, + "player_tuning": {"wheel_bucket_high": 3.0, "surface_table": [[1, 2]]} + }).to_string()).unwrap(); + let mods = dir.join("mods/dev.a"); + std::fs::create_dir_all(mods.join("audio")).unwrap(); + (dir, mods) + } + + fn overlay(v: serde_json::Value) -> skate_mods::audio_content::AudioOverlay { + let o: skate_mods::audio_content::AudioOverlay = serde_json::from_value(v).unwrap(); + o.validate().unwrap(); + o + } + + /// The no-mod identity (R1b): `load` is `load_with(root, &[])`, which reads the manifest + /// straight from the bytes as before; and the merge path with an overlay that changes nothing + /// gives the same manifest field for field (Debug of every field: f32s print exactly). + #[test] + fn an_empty_overlay_gives_the_install_manifest_field_for_field() { + let (dir, mods) = content_fixture("identity"); + let plain = Library::load(&dir).unwrap(); + let (none, report) = Library::load_with(&dir, &[]).unwrap(); + assert!(report.applied.is_empty()); + let empty = overlay(serde_json::json!({"version": 1})); + let (merged, report) = Library::load_with(&dir, &[OverlaySource { id: "dev.a", root: &mods, overlay: &empty }]).unwrap(); + assert_eq!(report.applied, ["dev.a"]); + assert_eq!(format!("{:?}", plain.manifest), format!("{:?}", none.manifest)); + assert_eq!(format!("{:?}", plain.manifest), format!("{:?}", merged.manifest)); + let (a, b) = (plain.bank_source("T").unwrap(), merged.bank_source("T").unwrap()); + assert_eq!(format!("{a:?}"), format!("{b:?}"), "same files, no header rebuilds"); + assert_eq!(a.wavs[1], dir.join("private/audio").join("banks/T/1.wav"), "install files resolve under the audio folder as before"); + let _ = std::fs::remove_dir_all(dir); + } + + /// The same on the real install (data-gated): the merge path with an empty overlay reproduces + /// the install manifest exactly. + #[test] + #[ignore = "needs the private install data"] + fn an_empty_overlay_gives_the_real_install_manifest() { + let root = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets")); + let Ok(plain) = Library::load(root) else { panic!("missing private data: no audio install") }; + let empty = overlay(serde_json::json!({"version": 1})); + let (merged, _) = Library::load_with(root, &[OverlaySource { id: "dev.a", root, overlay: &empty }]).unwrap(); + // The world tuning holds HashMaps (no order of their own; each instance iterates + // differently), so compare the pretty Debug's lines as a sorted list: every field and + // value, wherever its map put it. + let lines = |m: &Manifest| { + let mut v: Vec = format!("{m:#?}").lines().map(|l| l.trim_end_matches(',').to_owned()).collect(); + v.sort_unstable(); + v + }; + let (a, b) = (lines(&plain.manifest), lines(&merged.manifest)); + assert_eq!(a.len(), b.len()); + assert!(a == b, "the merged manifest differs"); + // And the merge itself leaves the install's JSON as it was. + let bytes = std::fs::read(root.join("private/audio/audio_manifest.json")).unwrap(); + let install: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let mut m = install.clone(); + skate_mods::audio_merge::merge(&mut m, &[skate_mods::audio_merge::Source { id: "dev.a", overlay: &empty }]); + assert!(m == install); + } + + /// A replaced AEMS sample plays from the mod's WAV with a header rebuilt from it (rate, + /// frames, channels; the looping slot keeps looping from 0 unless the overlay says where); + /// the bank's other slot and the install's files are untouched; Bevy clips and plain reads + /// resolve the mod path, and the clip cache keys mod files apart from install files. + #[test] + fn a_replaced_sample_gets_its_own_header_and_the_mod_file() { + let (dir, mods) = content_fixture("sample"); + std::fs::write(mods.join("audio/long.wav"), test_wav(2205, 22050, 1234)).unwrap(); + std::fs::write(mods.join("audio/bed.wav"), test_wav(50, 48000, 7)).unwrap(); + let plain = Library::load(&dir).unwrap(); + let (want_bank, want_pcm) = plain.bank_source("T").unwrap().load().unwrap(); + let o = overlay(serde_json::json!({"version": 1, "replace": { + "samples": {"T": {"1": "audio/long.wav"}, "x": {"0": {"file": "audio/long.wav", "loop_start": 100}}}, + "ambience": {"bed": "audio/bed.wav"}}})); + let (mut library, report) = Library::load_with(&dir, &[OverlaySource { id: "dev.a", root: &mods, overlay: &o }]).unwrap(); + assert!(report.warnings.is_empty() && report.conflicts.is_empty(), "{report:?}"); + let (bank, pcm) = library.bank_source("T").unwrap().load().unwrap(); + assert_eq!(bank.samples[0], want_bank.samples[0], "slot 0 keeps the install's header"); + assert_eq!(pcm[0].as_ref().unwrap().channels, want_pcm[0].as_ref().unwrap().channels); + let h = bank.samples[1].1.unwrap(); + let old = want_bank.samples[1].1.unwrap(); + assert_eq!((h.rate, h.frames, h.channels, h.codec), (22050, 2205, 1, old.codec)); + assert!(old.loop_start.is_some()); + assert_eq!(h.loop_start, Some(0), "a looping slot loops the replacement from 0"); + assert_eq!(pcm[1].as_ref().unwrap().channels[0][0], 1234.0 / 32768.0); + // An overlay loop start is kept (and dropped when it lies past the end). + let p = wav_pcm(&test_wav(2205, 22050, 1)).unwrap(); + assert_eq!(mod_header(None, &p, Some(100)).loop_start, Some(100)); + assert_eq!(mod_header(None, &p, Some(5000)).loop_start, None); + assert_eq!(mod_header(None, &p, None).loop_start, None); + // Bevy clips: the mod's bed, keyed by its mod reference. + let mut assets = Assets::::default(); + let clip = library.ambience(&mut assets, "bed").unwrap(); + assert_eq!(&*clip.key, "mod:dev.a/audio/bed.wav"); + assert_eq!(library.sample(&mut assets, "x", 1).unwrap().key.as_ref(), "banks/x/1.wav"); + assert_eq!(library.read("mod:dev.a/audio/bed.wav").unwrap(), test_wav(50, 48000, 7)); + assert_eq!(library.bank_pcm("x")[0].as_ref().unwrap().rate, 22050); + let _ = std::fs::remove_dir_all(dir); + } + + /// Two mods on one identity: the first by mod id wins, the second's claim is a conflict; an + /// overlay whose merged manifest does not read back is left out whole; a removed overlay + /// gives the install again. + #[test] + fn overlays_merge_in_order_and_a_bad_one_is_left_out() { + let (dir, mods) = content_fixture("order"); + let mods_b = dir.join("mods/dev.b"); + std::fs::create_dir_all(mods_b.join("audio")).unwrap(); + std::fs::write(mods.join("audio/a.wav"), test_wav(10, 48000, 1)).unwrap(); + std::fs::write(mods_b.join("audio/b.wav"), test_wav(10, 48000, 2)).unwrap(); + let a = overlay(serde_json::json!({"version": 1, "replace": {"samples": {"x": {"1": "audio/a.wav"}}}})); + let b = overlay(serde_json::json!({"version": 1, "replace": {"samples": {"x": {"1": "audio/b.wav", "0": "audio/b.wav"}}}})); + let (library, report) = Library::load_with(&dir, &[ + OverlaySource { id: "dev.a", root: &mods, overlay: &a }, + OverlaySource { id: "dev.b", root: &mods_b, overlay: &b }, + ]).unwrap(); + assert_eq!(library.manifest.banks["x"][1].file, "mod:dev.a/audio/a.wav"); + assert_eq!(library.manifest.banks["x"][0].file, "mod:dev.b/audio/b.wav"); + assert_eq!(report.conflicts.len(), 1); + assert_eq!(report.conflicts[0].owner, "dev.b"); + assert_eq!(report.owners["sample:x:1"], "dev.a"); + // Tuning and zone fields merge. + let good = overlay(serde_json::json!({"version": 1, "tuning": {"player": {"wheel_bucket_high": 4.5}}, "replace": {"zones": {"plaza": {"volume": 0.5}}}})); + let (library, report) = Library::load_with(&dir, &[OverlaySource { id: "dev.a", root: &mods, overlay: &good }]).unwrap(); + assert_eq!(report.applied, ["dev.a"]); + assert_eq!(library.manifest.player_tuning.tuning().wheel_bucket_high, 4.5); + assert_eq!(library.zone(0xAB).unwrap().volume, 0.5); + // A number of the wrong kind for the typed manifest (the surface table holds integers) + // passes the JSON merge but not the typed read: that whole overlay is left out, the next + // one still applies. + let bad = overlay(serde_json::json!({"version": 1, "tuning": {"player": {"surface_table": {"0": {"0": 0.5}}}}, "replace": {"zones": {"plaza": {"volume": 0.25}}}})); + let (library, report) = Library::load_with(&dir, &[OverlaySource { id: "dev.a", root: &mods, overlay: &bad }, OverlaySource { id: "dev.b", root: &mods_b, overlay: &good }]).unwrap(); + assert_eq!(report.applied, ["dev.b"]); + assert_eq!(report.rejected.len(), 1); + assert_eq!(library.zone(0xAB).unwrap().volume, 0.5, "dev.a's zone change left out with the rest of it"); + let (back, _) = Library::load_with(&dir, &[]).unwrap(); + assert_eq!(format!("{:?}", back.manifest), format!("{:?}", Library::load(&dir).unwrap().manifest), "removed overlay = retail"); + let _ = std::fs::remove_dir_all(dir); + } + + /// The keyed sections an overlay changes reach the systems that read them: location sets (by + /// name), zones, emitter records (replaced and added files), crossfades, a mod bank's + /// location program, its group and preload. + #[test] + fn sets_zones_emitters_and_programs_come_from_the_overlay() { + let (dir, mods) = content_fixture("keyed"); + let audio = dir.join("private/audio/audio_manifest.json"); + let mut m: serde_json::Value = serde_json::from_slice(&std::fs::read(&audio).unwrap()).unwrap(); + m["random_sets"] = serde_json::json!({"7EE1991E4909C50E": {"name": "brewery", "sounds": [{"bank": "x", "volume": 1.0, "seconds": 5.0, "weight": 1}]}}); + m["emitters"] = serde_json::json!({"sfx_t": [{"index": 3, "flags": 0, "position": [0, 0, 0], "extent": [1, 1, 1], "scalars": [0, 1, 0, 0], "bank": "T", "kind": 1, "volume": 1.0}]}); + m["crossfades"] = serde_json::json!([{"from": "00000000000000AB", "to": "00000000000000CD", "group": 1, "level": 1.0}]); + std::fs::write(&audio, m.to_string()).unwrap(); + std::fs::write(mods.join("audio/s.wav"), test_wav(10, 48000, 3)).unwrap(); + let o = overlay(serde_json::json!({"version": 1, + "replace": { + "random_sets": {"brewery": {"sounds": [{"bank": "MOD_s", "weight": 2}], "min_interval": 1, "max_interval": 2}}, + "emitters": {"sfx_t": {"3": {"position": [5, 0, 5], "extent": [9, 9, 9], "bank": "T", "patch": 4}}}, + "crossfades": [{"from": "00000000000000CD", "to": "00000000000000AB", "group": 2}] + }, + "add": { + "banks": {"MOD_s": {"samples": ["audio/s.wav"], "group": "world"}}, + "emitters": {"sfx_t": [{"position": [1, 1, 1], "extent": [2, 2, 2], "bank": "MOD_s"}], "sfx_mod": [{"position": [1, 1, 1], "extent": [2, 2, 2], "kind": 5, "reverb": "BEEFC8E3DE04FBAE"}]}, + "zones": {"00000000000000EE": {"name": "mod_zone", "bed": "bed", "time_b": 4}}, + "location_programs": {"MOD_s": [{"sample": 0, "level": 0.5, "delay": 0.25}]} + }})); + let (library, report) = Library::load_with(&dir, &[OverlaySource { id: "dev.a", root: &mods, overlay: &o }]).unwrap(); + assert!(report.warnings.is_empty() && report.conflicts.is_empty(), "{report:?}"); + let set = library.random_set(0x7EE1_991E_4909_C50E).unwrap(); + assert_eq!((set.sounds[0].bank.as_str(), set.sounds[0].weight, set.min_interval), ("MOD_s", 2, 1.0)); + assert_eq!(library.random_set_named("brewery").unwrap().0, 0x7EE1_991E_4909_C50E); + let recs = library.emitters("sfx_t"); + assert_eq!((recs.len(), recs[0].index, recs[0].patch, recs[0].position), (2, 3, 4, [5.0, 0.0, 5.0])); + assert_eq!((recs[1].index, recs[1].bank.as_deref()), (4, Some("MOD_s"))); + assert_eq!((library.emitters("sfx_mod")[0].kind, library.emitters("sfx_mod")[0].reverb.as_deref()), (5, Some("BEEFC8E3DE04FBAE"))); + assert_eq!(library.crossfade(0xAB, 0xCD).unwrap().group, 2); + assert_eq!(library.zone(0xEE).unwrap().time_b, 4.0); + assert_eq!(library.zone_named("mod_zone"), Some(0xEE)); + let layers = library.location_program("MOD_s").unwrap(); + assert_eq!((layers[0].sample.as_u64(), layers[0].level, layers[0].delay), (Some(0), 0.5, 0.25)); + assert!(library.is_mod_bank("MOD_s") && !library.is_mod_bank("x") && !library.is_mod_bank("nope")); + assert_eq!(library.bank_group("MOD_s"), Some(false)); + assert!(library.resident_banks().is_empty(), "not preloaded"); + let _ = std::fs::remove_dir_all(dir); + } + + /// Splice members and grain members (data-gated, the real install): a replaced sample of a + /// Splice bank reaches the Splice player's PCM (Splice builds its headers from the PCM); a + /// replaced grain member's recording and `.grain` are read from the mod. + #[test] + #[ignore = "needs the private install data"] + fn splice_and_grain_members_come_from_the_overlay() { + let root = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets")); + let Ok(plain) = Library::load(root) else { panic!("missing private data: no audio install") }; + let stem = super::super::player_audio::SPLICE_BANKS[0]; + let (_, want) = plain.splice_bank(stem).expect("a Splice tree"); + let grain = plain.manifest.grains.iter().find(|(_, g)| g.grain.is_some() && g.file.is_some()).map(|(k, _)| k.clone()).expect("a grain member"); + let (wav, raw) = plain.grain_whole(&grain).map(|(a, b)| (a.to_owned(), b.to_owned())).unwrap(); + let mods = std::env::temp_dir().join(format!("skate-audio-splice-grain-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&mods); + std::fs::create_dir_all(mods.join("audio")).unwrap(); + std::fs::write(mods.join("audio/pop.wav"), test_wav(4800, 44100, 4321)).unwrap(); + // The test copies the install's own member into the temp mod (never committed anywhere). + std::fs::copy(plain.path(&raw), mods.join("audio/g.grain")).unwrap(); + std::fs::copy(plain.path(&wav), mods.join("audio/g.wav")).unwrap(); + let o = overlay(serde_json::json!({"version": 1, "replace": { + "samples": {stem: {"0": "audio/pop.wav"}}, + "grains": {grain.clone(): {"file": "audio/g.wav", "grain": "audio/g.grain"}}}})); + let (library, report) = Library::load_with(root, &[OverlaySource { id: "dev.a", root: &mods, overlay: &o }]).unwrap(); + assert!(report.warnings.is_empty(), "{report:?}"); + let (_, pcm) = library.splice_bank(stem).expect("still a Splice bank"); + assert_eq!(pcm.len(), want.len()); + let p = pcm[0].as_ref().unwrap(); + assert_eq!((p.rate, p.channels[0].len(), p.channels[0][0]), (44100, 4800, 4321.0 / 32768.0)); + assert_eq!(pcm[1].as_ref().unwrap().channels, want[1].as_ref().unwrap().channels, "other members stay"); + let (w, r) = library.grain_whole(&grain).unwrap(); + assert_eq!((w, r), ("mod:dev.a/audio/g.wav", "mod:dev.a/audio/g.grain")); + assert_eq!(library.read(r).unwrap(), plain.read(&raw).unwrap()); + let _ = std::fs::remove_dir_all(&mods); + } + #[test] fn wav_peak_reads_the_data_chunk() { let mut wav = b"RIFF\x00\x00\x00\x00WAVEfmt \x10\x00\x00\x00".to_vec(); diff --git a/crates/skate-game/src/game_audio/mod.rs b/crates/skate-game/src/game_audio/mod.rs index 9443050b..e36f456a 100644 --- a/crates/skate-game/src/game_audio/mod.rs +++ b/crates/skate-game/src/game_audio/mod.rs @@ -13,6 +13,7 @@ //! scale it down (voices.rs), sounds fade in, voice counts are capped, and nothing plays while //! the menu is open or a replay runs. `--mute` silences game and mod audio. mod ambience; +mod content; mod crossfade_groups; #[cfg(test)] mod e2e; @@ -37,6 +38,7 @@ use bevy::{audio::Volume, prelude::*}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +pub(crate) use content::AudioContent; pub(crate) use library::Library; pub(crate) use voices::{Category, Play, Voices}; @@ -162,13 +164,14 @@ impl Plugin for GameAudioPlugin { app.init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_systems(Startup, setup) .add_systems(FixedUpdate, skate_events::observe.after(crate::app::SimulationSet::Physics)) .add_systems( Update, // The pass: inputs and the local player's process, the world / NPC owners' process, // the ticks and the local update, then the beds (retail's process / tick / update). - (native::mixmap_frame, world_sources::frame, npc_skaters::frame_pre, native::mixmap_tick, grain_bed::update, emitters::reverb_zones, native::reverb_frame) + (content::frame, native::mixmap_frame, world_sources::frame, npc_skaters::frame_pre, native::mixmap_tick, grain_bed::update, emitters::reverb_zones, native::reverb_frame) .chain() .before(CueSet) .after(crate::app::FrameSet::Animation), diff --git a/crates/skate-game/src/game_audio/native.rs b/crates/skate-game/src/game_audio/native.rs index 1ce83ca2..a6210293 100644 --- a/crates/skate-game/src/game_audio/native.rs +++ b/crates/skate-game/src/game_audio/native.rs @@ -155,6 +155,16 @@ impl Decodable for NativeStream { #[derive(Component)] struct NativeOutput; +/// Tests: how many stream entities exist. +#[cfg(test)] +pub(crate) struct NativeOutputCount; +#[cfg(test)] +impl NativeOutputCount { + pub(crate) fn of(world: &mut World) -> usize { + world.query_filtered::>().iter(world).count() + } +} + /// The running native runtime and what the game has loaded into it. #[derive(Resource)] pub(crate) struct Native { @@ -195,6 +205,8 @@ pub(crate) struct Native { /// [`mixmap_tick`] (the ticks, the update): the world / NPC hosts' process runs in between, as /// retail runs every owner's process before its tick and the update after it. pub(crate) pending: Option, + /// Banks audio content overlays load at start and keep across map changes (`preload`). + resident: Vec, } /// A pass [`mixmap_frame`] began and [`mixmap_tick`] finishes. @@ -245,11 +257,18 @@ impl Native { } pub(super) fn start_with(library: &Library, world: WorldInstances) -> Result { + Self::start_from(library, world, 1) + } + + /// Start, the evaluator handing out post ids from `first_node` on (1 = a fresh runtime; a + /// restart continues the old runtime's counter, `content::restart`). + pub(super) fn start_from(library: &Library, world: WorldInstances, first_node: u32) -> Result { let files = library.aems(); if files.projects.is_empty() { return Err("this install has no AEMS banks (run setup to refresh the audio)".into()); } let mut runtime = Runtime::new(); + runtime.eval.continue_nodes(first_node); for file in &files.projects { let bytes = library.read(file).map_err(|e| format!("{file}: {e}"))?; let project = Project::parse(file, &bytes).map_err(|e| e.to_string())?; @@ -296,6 +315,7 @@ impl Native { map_epoch: 0, world, pending: None, + resident: Vec::new(), }; // The environment (reverb) network and the eEQChain buses (optional install data). let (presets, eq) = library.bus_tuning(); @@ -345,9 +365,31 @@ impl Native { let runtime = native.shared.lock().map_err(|_| "audio lock poisoned")?; native.emitter_class = runtime.eval.class_id("c_emitter"); drop(runtime); + native.load_resident_banks(library); Ok(native) } + /// The banks audio content overlays mark `preload`, after the retail boot (none without + /// overlays): loaded now, in their volume group, and kept across map changes. + fn load_resident_banks(&mut self, library: &Library) { + for (stem, player) in library.resident_banks() { + match self.ensure_bank(library, &stem) { + Ok(id) => { + if let Ok(mut runtime) = self.shared.lock() { + runtime.mixer.set_bank_group(id, if player { skate_audio::mixer::GROUP_PLAYER } else { skate_audio::mixer::GROUP_WORLD }); + } + self.resident.push(stem); + } + Err(e) => warn!("Game audio: mod bank {stem}: {e}"), + } + } + } + + /// The id the runtime's next post gets (a restart continues from it). + pub(crate) fn next_node(&self) -> u32 { + self.shared.lock().map_or(1, |r| r.eval.next_node()) + } + /// Load a bank (and its samples) unless it is loaded already. A bank the prefetch worker has /// read and decoded is taken from it (waiting if it is mid-decode); otherwise it is read and /// decoded here, by the same `BankSource::load`. Either way `load_bank` runs now. @@ -359,7 +401,13 @@ impl Native { Some(loaded) => loaded, None => library.bank_source(stem)?.load()?, }; - let id = self.shared.lock().map_err(|_| "audio lock poisoned")?.load_bank(bank, pcm); + let mut runtime = self.shared.lock().map_err(|_| "audio lock poisoned")?; + let id = runtime.load_bank(bank, pcm); + // An overlay can put a bank in a volume group (none without overlays). + if let Some(player) = library.bank_group(stem) { + runtime.mixer.set_bank_group(id, if player { skate_audio::mixer::GROUP_PLAYER } else { skate_audio::mixer::GROUP_WORLD }); + } + drop(runtime); self.banks.insert(stem.to_owned(), id); Ok(id) } @@ -472,8 +520,10 @@ impl Native { self.map_epoch += 1; self.prefetch.clear(); let Ok(mut runtime) = self.shared.lock() else { return }; + let resident = &self.resident; self.banks.retain(|stem, id| { - let keep = stem == "emitter_utility" || stem == skate_audio::player::seams::UTILITY_BANK || super::player_audio::BANKS.contains(&stem.as_str()) || super::player_audio::OPTIONAL_BANKS.iter().any(|b| b.contains(&stem.as_str())); + let keep = stem == "emitter_utility" || stem == skate_audio::player::seams::UTILITY_BANK || super::player_audio::BANKS.contains(&stem.as_str()) || super::player_audio::OPTIONAL_BANKS.iter().any(|b| b.contains(&stem.as_str())) + || resident.iter().any(|r| r == stem); if !keep { runtime.unload_bank(*id); } @@ -547,8 +597,9 @@ impl Native { } pub(crate) fn register(app: &mut App) { + // The runtime starts in `content::frame` (after the first mod scan, so an audio mod enabled at + // boot costs no second start), and restarts there when the running mods' audio content changes. app.add_audio_source::() - .add_systems(PostStartup, start) .add_systems(PostUpdate, follow_volume); } @@ -731,34 +782,66 @@ pub(super) fn mixmap_tick(native: Option>) { } } -fn start( - mut commands: Commands, - settings: Option>, - library: Option>, - mut streams: ResMut>, -) { - let (Some(settings), Some(library)) = (settings, library) else { return }; - let settings = Some(settings); +/// What a (re)start carries over from the runtime it replaces. +#[derive(Clone, Copy, Debug)] +pub(super) struct Carry { + /// The old evaluator's next post id: ids it handed out never name a post of the new one. + pub first_node: u32, + /// The new runtime's `map_epoch` (old + 1: every host holding nodes resets). + pub epoch: u64, + pub world: WorldInstances, +} + +/// Start the runtime and its output stream from the current [`Library`] (`content::frame`: the +/// first start, and every restart with the old runtime's [`Carry`]). False when it could not +/// start (the error is logged; those sounds stay silent). +pub(super) fn launch(world: &mut World, carry: Option) -> bool { + static DSP: std::sync::Once = std::sync::Once::new(); + let Some(settings) = world.get_resource::() else { return false }; + let instances = carry.map_or_else(|| if settings.more_audible_world() { WorldInstances::MORE_AUDIBLE } else { WorldInstances::RETAIL }, |c| c.world); + let Some(library) = world.get_resource::() else { return false }; // Which compiled copy of the DSP loops runs (hardware FMA or plain; same output bits): chosen - // once, here, before the first render (doc 11 "Hardware FMA dispatch"). - info!("AUDIO_DSP {}", skate_audio::dsp::init_fma()); - let world = if settings.as_deref().is_some_and(|s| s.more_audible_world()) { WorldInstances::MORE_AUDIBLE } else { WorldInstances::RETAIL }; - match Native::start_with(&library, world) { - Ok(native) => { - info!("Game audio: native AEMS runtime on ({} projects)", library.aems().projects.len()); - let handle = streams.add(NativeStream { shared: native.shared.clone() }); + // once, before the first render (doc 11 "Hardware FMA dispatch"). + DSP.call_once(|| info!("AUDIO_DSP {}", skate_audio::dsp::init_fma())); + let (started, projects) = (Native::start_from(library, instances, carry.map_or(1, |c| c.first_node)), library.aems().projects.len()); + match started { + Ok(mut native) => { + info!("Game audio: native AEMS runtime on ({projects} projects)"); + if let Some(c) = carry { + native.map_epoch = c.epoch; + } + let handle = world.resource_mut::>().add(NativeStream { shared: native.shared.clone() }); // ONCE, not LOOP: the stream never ends, and Bevy's LOOP wraps it in rodio's // `repeat_infinite`, i.e. `Buffered`, which renders 32768 samples (64 blocks, 341 ms) // at a time inside the device callback and keeps every chunk forever: a lock burst of // 64 renders every 341 ms (game-thread stalls = the 19:01 stutter), up to 341 ms of // event-to-sound latency and ~23 MB/min of memory growth. - commands.spawn((NativeOutput, AudioPlayer(handle), PlaybackSettings::ONCE.with_volume(Volume::Linear(0.0)))); - commands.insert_resource(native); + world.spawn((NativeOutput, AudioPlayer(handle), PlaybackSettings::ONCE.with_volume(Volume::Linear(0.0)))); + world.insert_resource(native); + true + } + Err(error) => { + error!("Game audio: the native AEMS runtime could not start, so the skater's sounds, the world emitters and rolling are silent: {error}"); + false } - Err(error) => error!("Game audio: the native AEMS runtime could not start, so the skater's sounds, the world emitters and rolling are silent: {error}"), } } +/// Take the running runtime down for a restart: its stream entity is despawned (the sink and +/// rodio's copy of the stream drop with it; a block mid-render finishes on the old runtime), the +/// resource removed and dropped (the prefetch worker's sender with it: the worker ends after its +/// current decode, its result discarded). Returns what the new runtime carries over. +pub(super) fn shutdown(world: &mut World) -> Option { + let outputs: Vec = world.query_filtered::>().iter(world).collect(); + for e in outputs { + world.despawn(e); + } + let native = world.remove_resource::()?; + let carry = Carry { first_node: native.next_node(), epoch: native.map_epoch + 1, world: native.world }; + drop(native); + Some(carry) +} + /// Master volume on the stream; inside it the AEMS voices (world emitters: ambience) and the /// rolling bed (effects) take their category volumes. Pause while silenced. fn follow_volume( @@ -890,6 +973,7 @@ mod tests { map_epoch: 0, world: WorldInstances::RETAIL, pending: None, + resident: Vec::new(), } } diff --git a/crates/skate-game/src/game_audio/random_sets.rs b/crates/skate-game/src/game_audio/random_sets.rs index e0b9ef0d..c849250f 100644 --- a/crates/skate-game/src/game_audio/random_sets.rs +++ b/crates/skate-game/src/game_audio/random_sets.rs @@ -42,6 +42,26 @@ fn program(bank: &str) -> &'static [Layer] { super::random_programs::PROGRAMS.iter().find(|(b, _)| *b == bank).map_or(&[], |(_, l)| *l) } +/// The layers a post of `bank` plays: an audio content overlay's `location_programs` row, else +/// the measured retail row; a mod bank without either plays one shuffle layer at level 1 (a +/// retail bank without a row stays silent, as retail). +fn layers_for(library: &Library, bank: &str) -> std::borrow::Cow<'static, [Layer]> { + if let Some(rows) = library.location_program(bank) { + return rows.iter().map(|l| Layer { + delay: l.delay, + sample: l.sample.as_u64().map_or(SHUFFLE, |s| s as usize), + level: l.level, + pan_sweep: l.pan_sweep, + looping: l.looping, + }).collect::>().into(); + } + let retail = program(bank); + if retail.is_empty() && library.is_mod_bank(bank) { + return vec![Layer { delay: 0.0, sample: SHUFFLE, level: 1.0, pan_sweep: 0.0, looping: false }].into(); + } + retail.into() +} + #[derive(Clone, Copy, Debug, PartialEq)] enum Slot { Unloaded, @@ -61,6 +81,8 @@ struct Post { #[derive(Default)] pub(super) struct State { + /// The audio content generation the entries were built from. + content: u64, key: Option, slots: Vec, timer: f32, @@ -142,10 +164,25 @@ pub(super) fn update( time: Res>, menu: Option>, replay: Res, + content: Res, ) { let Some(mut library) = library else { return }; let state = &mut *state; let Ok(ear) = listener.single() else { return }; + if state.content != content.generation { + // New audio content: the set's entries may differ; rebuild from scratch. + state.content = content.generation; + for post in state.posts.drain(..) { + for (_, voice, ..) in post.layers { + if let Some((id, _)) = voice { + voices.stop(id, 0.3); + } + } + } + state.key = None; + state.slots.clear(); + state.bags.clear(); + } let now = time.elapsed_secs_f64(); let dt = time.delta_secs().clamp(0.0, 0.25); let mut rng = state.rng; @@ -190,7 +227,7 @@ pub(super) fn update( .min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i); if let Some(entry) = oldest { let sound = &set.sounds[entry]; - let layers = program(&sound.bank); + let layers = layers_for(&library, &sound.bank); if layers.is_empty() { // The bank's program does not answer this sound's selector: retail is silent too. state.slots[entry] = Slot::Unloaded; diff --git a/crates/skate-game/src/game_audio/world_speech.rs b/crates/skate-game/src/game_audio/world_speech.rs index 61684a0f..b5c01ba5 100644 --- a/crates/skate-game/src/game_audio/world_speech.rs +++ b/crates/skate-game/src/game_audio/world_speech.rs @@ -71,6 +71,16 @@ struct Data { library: SpeechLibrary, slots: SpeechSlots, audio: Option, + /// Audio content overlays' takes by (clip, take): replacements and extra takes. + mod_takes: HashMap<(usize, usize), PathBuf>, +} + +/// An overlay take's rate and length from its WAV header (0 when unreadable: the take still plays +/// from its PCM when picked). +fn take_of(path: &std::path::Path) -> Take { + let pcm = std::fs::read(path).ok().and_then(|b| super::library::wav_pcm(&b)); + let (rate, samples) = pcm.map_or((0, 0), |p| (p.rate, p.channels.first().map_or(0, Vec::len) as u32)); + Take { offset: 0, size: 0, rate, samples } } #[derive(Resource)] @@ -187,21 +197,51 @@ struct RecordJson { clips: Vec, } -fn load(index_path: &std::path::Path, audio: Option) -> Result { +/// `mods`: the overlays' replaced takes by (clip name without `.dat`, take) and extra takes by +/// clip (`Library::speech_mods`; empty without overlays). +fn load(index_path: &std::path::Path, audio: Option, mods: &(HashMap<(String, u32), PathBuf>, HashMap>)) -> Result { let text = std::fs::read_to_string(index_path).map_err(|e| format!("{}: {e}", index_path.display()))?; let json: IndexJson = serde_json::from_str(&text).map_err(|e| format!("{}: {e}", index_path.display()))?; let mut clips = Vec::with_capacity(json.clips.len()); let mut ids = Vec::with_capacity(json.clips.len()); let mut headers = Vec::new(); + let mut mod_takes = HashMap::new(); + let (replaced, extra) = mods; + let mut known = std::collections::HashSet::new(); for c in json.clips { let Some((event, voice, voice_name, line)) = skate_audio::world::speech::parse_name(&c.name) else { continue }; + let stem = c.name.strip_suffix(".dat").unwrap_or(&c.name).to_owned(); + let mut takes: Vec = c.takes.iter().map(|t| Take { offset: t.offset, size: t.size, rate: t.rate, samples: t.samples }).collect(); + if !replaced.is_empty() || !extra.is_empty() { + let index = clips.len(); + for (t, take) in takes.iter_mut().enumerate() { + if let Some(path) = replaced.get(&(stem.clone(), t as u32)) { + *take = take_of(path); + mod_takes.insert((index, t), path.clone()); + } + } + for path in extra.get(&stem).into_iter().flatten() { + if takes.len() < 255 { + mod_takes.insert((index, takes.len()), path.clone()); + takes.push(take_of(path)); + } + } + known.insert(stem); + } if let Some(id) = c.id { - headers.push(ClipHeader { id, takes: c.takes.len().min(255) as u8, history: c.history, flags: 0 }); + headers.push(ClipHeader { id, takes: takes.len().min(255) as u8, history: c.history, flags: 0 }); } ids.push(c.id); - let takes = c.takes.iter().map(|t| Take { offset: t.offset, size: t.size, rate: t.rate, samples: t.samples }).collect(); clips.push(Clip { name: c.name, event, voice, voice_name, line, takes }); } + for (clip, take) in replaced.keys() { + if !known.contains(clip) { + warn!("AUDIO_WORLD speech: an audio mod replaces take {take} of {clip}, which the speech index does not have"); + } + } + for clip in extra.keys().filter(|c| !known.contains(*c)) { + warn!("AUDIO_WORLD speech: an audio mod adds takes to {clip}, which the speech index does not have"); + } let mut index = SpeechIndex::new(clips); index.set_ids(&ids); let events = json @@ -234,7 +274,7 @@ fn load(index_path: &std::path::Path, audio: Option) -> Result { if self.loaded.contains(&slot) { return Some(slot); } - let audio = self.data.audio.as_ref()?; - let clip = self.data.index.clips.get(line.clip)?; - let stem = clip.name.strip_suffix(".dat").unwrap_or(&clip.name); - let path = audio.join(stem).join(format!("{:02}.wav", line.take)); + let path = match self.data.mod_takes.get(&(line.clip, line.take)) { + Some(p) => p.clone(), + None => { + let audio = self.data.audio.as_ref()?; + let clip = self.data.index.clips.get(line.clip)?; + let stem = clip.name.strip_suffix(".dat").unwrap_or(&clip.name); + audio.join(stem).join(format!("{:02}.wav", line.take)) + } + }; let pcm = match std::fs::read(&path).ok().and_then(|b| super::library::wav_pcm(&b)) { Some(p) => p, None => { @@ -360,7 +405,7 @@ pub(crate) fn run(speech: &mut WorldSpeech, peds: &[(u64, u32)], native: &mut Na speech.manager = SpeechManager::new(library.world_tuning().speech_tuning()); match library.speech("livingworld") { None => info!("AUDIO_WORLD speech off: the install has no speech index (rerun setup)"), - Some((index, audio)) => match load(&index, audio.clone()) { + Some((index, audio)) => match load(&index, audio.clone(), &library.speech_mods("livingworld")) { Ok(data) => { if audio.is_none() { info!("AUDIO_WORLD speech: lines are chosen and logged but silent: the takes are not decoded (setup with SKATE_SETUP_SPEECH=1)"); @@ -499,6 +544,45 @@ mod tests { use skate_audio::mixmap::cadence::CONSOLE_DT; use skate_audio::world::peds::PedState; + /// Audio content overlays on speech: a replaced take plays from the mod's WAV (with its rate + /// and length), extra takes join the clip (the rules' take count grows, so the manager can + /// pick them), the other clips and takes stay the export's. Without overlays the index loads + /// as before (no mod takes). + #[test] + fn mod_takes_replace_and_extend_a_clip() { + let dir = std::env::temp_dir().join(format!("skate-speech-mods-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let index = dir.join("livingworld.json"); + let take = serde_json::json!({"offset": 0, "size": 10, "rate": 32000, "samples": 100}); + std::fs::write(&index, serde_json::json!({ + "clips": [ + {"name": "501_59_busm1_Warn_n.dat", "id": 7, "takes": [take, take]}, + {"name": "502_59_busm1_Other_n.dat", "id": 8, "takes": [take]} + ], + "rules": {"bank": 1, "sub_bank": 2, "events": []} + }).to_string()).unwrap(); + let wav = crate::game_audio::library::tests::test_wav(2205, 22050, 99); + std::fs::write(dir.join("r.wav"), &wav).unwrap(); + std::fs::write(dir.join("e.wav"), &wav).unwrap(); + let plain = load(&index, Some(dir.clone()), &Default::default()).unwrap(); + assert!(plain.mod_takes.is_empty()); + assert_eq!(plain.index.clips[0].takes.len(), 2); + let mut replaced = HashMap::new(); + replaced.insert(("501_59_busm1_Warn_n".to_owned(), 1u32), dir.join("r.wav")); + let mut extra = HashMap::new(); + extra.insert("501_59_busm1_Warn_n".to_owned(), vec![dir.join("e.wav")]); + let data = load(&index, Some(dir.clone()), &(replaced, extra)).unwrap(); + let clip = &data.index.clips[0]; + assert_eq!(clip.takes.len(), 3, "one extra take"); + assert_eq!((clip.takes[1].rate, clip.takes[1].samples), (22050, 2205), "the replacement's own header"); + assert_eq!(clip.takes[0].rate, 32000, "other takes stay"); + assert_eq!(data.mod_takes.get(&(0, 1)), Some(&dir.join("r.wav"))); + assert_eq!(data.mod_takes.get(&(0, 2)), Some(&dir.join("e.wav"))); + assert_eq!(data.index.clips[1].takes.len(), 1, "other clips stay"); + let _ = std::fs::remove_dir_all(&dir); + } + /// As `native::mixmap_frame`: the category gains. fn globals(m: &mut skate_audio::mixmap::MixMap) { for id in 1..=4 { diff --git a/crates/skate-game/src/modding/menu.rs b/crates/skate-game/src/modding/menu.rs index 32e419b4..0a07c77e 100644 --- a/crates/skate-game/src/modding/menu.rs +++ b/crates/skate-game/src/modding/menu.rs @@ -358,6 +358,7 @@ fn draw( mut title: Single<&mut Text, (With,Without<Label>,Without<Badge>,Without<Detail>,Without<Page>)>, mut page: Single<&mut Text, (With<Page>,Without<Label>,Without<Badge>,Without<Title>,Without<Detail>)>, mut logged: Local<String>, + audio: Option<Res<crate::game_audio::AudioContent>>, ) { root.display = if menu.open { Display::Flex } else { Display::None }; if !menu.open { return; } @@ -388,9 +389,16 @@ fn draw( if let Some(package) = selected_package { description += &format!("\n{} / {}\n{}",package.manifest.name,package.manifest.author,package.manifest.description); if let Some(error) = &package.error { description += &format!("\n{}",friendly_error(error)); } + // Audio content: the overlay's load error, conflicts with other mods (the first by mod id + // wins), entries the install lacks. Kept until the overlays change. + for line in audio.as_deref().map(|a| a.messages_for(&package.manifest.id)).unwrap_or_default().into_iter().take(2) { + description += &format!("\n{line}"); + } if let Action::Setting(_,key) = &entries[selected].1 { description += &format!("\n{}",package.manifest.settings[key].description); } } else if !mods.manager.diagnostics.is_empty() { description += "\nSome mod files could not load. Restore missing files, then refresh. Details are in the session log."; + } else if let Some(line) = audio.as_deref().and_then(|a| a.summary()) { + description += &format!("\n{line}"); } else if mods.manager.packages.is_empty() { description += "\nNo mods installed yet. Open the mods folder to add a mod, then refresh."; } diff --git a/crates/skate-game/src/modding/mod.rs b/crates/skate-game/src/modding/mod.rs index a7c206e6..11528ea3 100644 --- a/crates/skate-game/src/modding/mod.rs +++ b/crates/skate-game/src/modding/mod.rs @@ -435,9 +435,11 @@ fn maintenance(world: &mut World) { .dispatch("on_event", json!({"name":"world_changed","map":map})); } mods.manager.scan(false); + sync_audio_content(world, &mods); return; } mods.manager.scan(false); + sync_audio_content(world, &mods); if !mods.runtime_busy() { return; } @@ -447,6 +449,15 @@ fn maintenance(world: &mut World) { }); } +/// Audio content overlays follow the running mods (`game_audio::AudioContent`): a mod that +/// starts, changes, stops or fails changes the set, and the audio restarts once at the next pass. +fn sync_audio_content(world: &mut World, mods: &Mods) { + let Some(mut content) = world.get_resource_mut::<crate::game_audio::AudioContent>() else { return }; + let content = content.bypass_change_detection(); + content.scanned = true; + content.sync(mods.manager.packages.iter().filter(|(_, p)| p.running()).map(|(id, p)| (id.as_str(), p.root.as_path(), p.content_fingerprint()))); +} + fn camera_position(world: &mut World) -> Option<[f32; 3]> { world .query_filtered::<&Transform, With<crate::camera::GameplayCamera>>() @@ -908,6 +919,7 @@ fn apply(world: &mut World, mods: &mut Mods) { mods.custom_menus.retain(|(owner,_),_|owner!=&id); } } + sync_audio_content(world, mods); let mut row = 0; for entity in mods.overlays.values() { if let Some(mut node) = world.get_mut::<Node>(*entity) { diff --git a/crates/skate-mods/Cargo.toml b/crates/skate-mods/Cargo.toml index e3d89d37..38071824 100644 --- a/crates/skate-mods/Cargo.toml +++ b/crates/skate-mods/Cargo.toml @@ -10,6 +10,7 @@ mlua = { version = "=0.12.1", features = ["lua54", "vendored", "serde", "send"] serde.workspace = true serde_json.workspace = true skate-dynamics = { path = "../skate-dynamics" } +skate-audio = { path = "../skate-audio" } gltf = { version = "=1.4.1", default-features = true } zip = { version = "6.0.0", default-features = false, features = ["deflate"] } diff --git a/crates/skate-mods/examples/check_mod.rs b/crates/skate-mods/examples/check_mod.rs index fd01f8a9..3ddfad4a 100644 --- a/crates/skate-mods/examples/check_mod.rs +++ b/crates/skate-mods/examples/check_mod.rs @@ -1,14 +1,66 @@ +//! Validate a mod package before shipping it: +//! +//! check_mod <package-folder-or-zip> [--install <assets folder>] [--with <other package>…] +//! +//! Checks `mod.json`, the Lua entry's syntax and, when the package has an `audio.json`, the audio +//! content overlay in depth (schema, every WAV / bank / Splice tree / MixMap / grain member it +//! names). With `--install` (the game's `assets` folder) the overlay is also merged over the +//! install's audio manifest, as the game does, to list identities the install does not have and +//! conflicts with the `--with` packages (merged in mod-id order, the first owner wins). fn main() { - let path = std::env::args() - .nth(1) - .expect("usage: check_mod <package-folder-or-zip>"); - match skate_mods::validate_package(std::path::Path::new(&path)) { - Ok(m) => { - println!("OK {} api={} entry={}", m.id, m.api, m.entry); + let mut args = std::env::args().skip(1); + let mut path = None; + let mut install = None; + let mut others = Vec::new(); + while let Some(a) = args.next() { + match a.as_str() { + "--install" => install = args.next(), + "--with" => others.extend(args.next()), + _ => path = Some(a), } + } + let path = path.expect("usage: check_mod <package-folder-or-zip> [--install <assets>] [--with <package>…]"); + let (manifest, audio) = match skate_mods::validate_package_content(std::path::Path::new(&path)) { + Ok(v) => v, Err(e) => { eprintln!("FAIL: {e}"); std::process::exit(1); } + }; + println!("OK {} api={} entry={}", manifest.id, manifest.api, manifest.entry); + let Some(audio) = audio else { return }; + println!("audio.json: {} MiB of PCM", audio.pcm_bytes as f64 / (1024.0 * 1024.0)); + for line in audio.overlay.summary() { + println!(" {line}"); + } + let Some(install) = install else { return }; + let file = std::path::Path::new(&install).join("private/audio/audio_manifest.json"); + let mut m: serde_json::Value = match std::fs::read(&file).map_err(|e| e.to_string()).and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string())) { + Ok(m) => m, + Err(e) => { + eprintln!("FAIL: {}: {e}", file.display()); + std::process::exit(1); + } + }; + let mut overlays = vec![(manifest.id.clone(), audio.overlay)]; + for other in &others { + match skate_mods::validate_package_content(std::path::Path::new(other)) { + Ok((m, Some(a))) => overlays.push((m.id, a.overlay)), + Ok((m, None)) => println!(" ({} has no audio.json)", m.id), + Err(e) => println!(" ({other}: {e})"), + } + } + overlays.sort_by(|a, b| a.0.cmp(&b.0)); + let sources: Vec<_> = overlays.iter().map(|(id, o)| skate_mods::audio_merge::Source { id, overlay: o }).collect(); + let report = skate_mods::audio_merge::merge(&mut m, &sources); + let mine = |owner: &str| owner == manifest.id; + let warnings: Vec<_> = report.warnings.iter().filter(|w| mine(&w.owner)).collect(); + let conflicts: Vec<_> = report.conflicts.iter().filter(|c| mine(&c.owner)).collect(); + println!("install: {} identities changed, {} warnings, {} conflicts", report.claimed.values().filter(|o| mine(o)).count(), warnings.len(), conflicts.len()); + for w in warnings { + println!(" warning: {}", w.text); + } + for c in conflicts { + println!(" conflict: {}", c.text); } } diff --git a/crates/skate-mods/src/archive.rs b/crates/skate-mods/src/archive.rs index 9fbb7ccf..8cd8bf58 100644 --- a/crates/skate-mods/src/archive.rs +++ b/crates/skate-mods/src/archive.rs @@ -122,6 +122,12 @@ impl Drop for Cache { } pub fn validate_package(path: &Path) -> Result<Manifest, String> { + validate_package_content(path).map(|(manifest, _)| manifest) +} + +/// [`validate_package`] plus the deep check of the package's audio content overlay +/// (`audio.json`: schema and every file it names; None without one). +pub fn validate_package_content(path: &Path) -> Result<(Manifest, Option<crate::audio_content::Loaded>), String> { let source = path.canonicalize().map_err(|e| e.to_string())?; let mut cache = Cache::default(); let root = if source.is_dir() { @@ -144,7 +150,8 @@ pub fn validate_package(path: &Path) -> Result<Manifest, String> { .set_mode(mlua::chunk::ChunkMode::Text) .into_function() .map_err(|e| e.to_string())?; - Ok(manifest) + let audio = crate::audio_content::load(&root)?; + Ok((manifest, audio)) } pub fn read_bounded(root: &Path, relative: &str, limit: u64) -> Result<Vec<u8>, String> { diff --git a/crates/skate-mods/src/audio.rs b/crates/skate-mods/src/audio.rs index 57ed5efe..2cbdb9ed 100644 --- a/crates/skate-mods/src/audio.rs +++ b/crates/skate-mods/src/audio.rs @@ -74,8 +74,14 @@ pub struct WavInfo { pub seconds: f64, pub channels: u16, pub sample_rate: u32 } /// Validate bounded, uncompressed PCM16 and rebuild a minimal, canonical WAV. /// Unknown metadata chunks never reach the backend decoder. No resampling here. pub fn canonical_pcm_wav(bytes: &[u8]) -> Result<(Vec<u8>, WavInfo), String> { - let bad = || "Audio requires a valid PCM16 WAV: 1-2 channels, 8-48 kHz, 0-30 seconds".to_owned(); - if bytes.len() < 44 || bytes.len() as u64 > MAX_WAV_BYTES + canonical_pcm_wav_limited(bytes, MAX_WAV_BYTES, 30.0) +} + +/// [`canonical_pcm_wav`] with other limits (audio content overlays: ambience beds and streams are +/// longer than a sample). +pub fn canonical_pcm_wav_limited(bytes: &[u8], max_bytes: u64, max_seconds: f64) -> Result<(Vec<u8>, WavInfo), String> { + let bad = || format!("Audio requires a valid PCM16 WAV: 1-2 channels, 8-48 kHz, 0-{max_seconds} seconds"); + if bytes.len() < 44 || bytes.len() as u64 > max_bytes || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WAVE" { return Err(bad()); } @@ -120,7 +126,7 @@ pub fn canonical_pcm_wav(bytes: &[u8]) -> Result<(Vec<u8>, WavInfo), String> { let block = usize::from(channels) * 2; if data.is_empty() || data.len() % block != 0 { return Err(bad()); } let seconds = (data.len() / block) as f64 / f64::from(sample_rate); - if seconds > 30.0 { return Err(bad()); } + if seconds > max_seconds { return Err(bad()); } let mut out = Vec::with_capacity(data.len() + 44); out.extend_from_slice(b"RIFF"); out.extend_from_slice(&(data.len() as u32 + 36).to_le_bytes()); diff --git a/crates/skate-mods/src/audio_content.rs b/crates/skate-mods/src/audio_content.rs new file mode 100644 index 00000000..72696639 --- /dev/null +++ b/crates/skate-mods/src/audio_content.rs @@ -0,0 +1,971 @@ +//! Audio content overlays (capability `audio_content` = 1): a mod's `audio.json` replaces or adds +//! retail audio content by identity (bank, sample slot, Splice tree, grain member, wheel stream, +//! ambience bed, emitter record, location set, zone, crossfade, speech take, tuning field, map +//! audio) with files the mod ships. The engine merges overlays over the install's audio manifest, +//! mod first, then the install; with no overlay nothing changes. +//! +//! Everything here is checked before the engine sees it: the schema (`deny_unknown_fields`), +//! counts, numbers, key and path syntax, and every referenced file (PCM16 WAVs, `.abk` banks, +//! `.splc` trees, `.mxb` MixMaps, `.grain` members) is read inside the mod root and parsed. +//! Identities (does the bank / slot / set exist?) need the install and are checked by the engine; +//! an unknown identity is a warning there, never an error. +use crate::archive::read_bounded; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::Path; + +/// The overlay file at a mod's root. +pub const FILE: &str = "audio.json"; +/// The overlay schema this build reads. +pub const VERSION: u32 = 1; +/// `audio.json` itself. +pub const MAX_JSON_BYTES: u64 = 1024 * 1024; +/// Files one overlay may reference (all kinds together). +pub const MAX_FILES: usize = 1024; +/// Sample slot replacements per overlay (all banks together). +pub const MAX_SAMPLE_REPLACEMENTS: usize = 512; +/// Banks one overlay may replace or add. +pub const MAX_BANKS: usize = 64; +/// WAVs per replaced / added bank. +pub const MAX_BANK_SAMPLES: usize = 512; +/// Records (emitters, sets, zones, crossfades, programs, regions) per overlay, all kinds together. +pub const MAX_RECORDS: usize = 4096; +/// Total PCM16 bytes of one overlay's WAVs, and of all running overlays together (the engine). +pub const MAX_PCM_BYTES_PER_MOD: u64 = 64 * 1024 * 1024; +pub const MAX_PCM_BYTES_TOTAL: u64 = 256 * 1024 * 1024; +/// A sample / speech take WAV (bytes, seconds) and an ambience bed WAV. +pub const SAMPLE_LIMITS: (u64, f64) = (crate::audio::MAX_WAV_BYTES, 30.0); +pub const BED_LIMITS: (u64, f64) = (48 * 1024 * 1024, 600.0); +/// The wheel spin recordings are ~15 s streams. +pub const STREAM_LIMITS: (u64, f64) = (16 * 1024 * 1024, 60.0); +/// Binary content files (`.abk`, `.splc`, `.mxb`, `.grain`). +pub const MAX_BINARY_BYTES: u64 = 16 * 1024 * 1024; +/// Payload words of a [`crate::Command::AudioPost`] and handles per mod (the engine also caps them). +pub const MAX_POST_WORDS: usize = 32; + +fn one() -> f32 { 1.0 } +fn ten() -> f32 { 10.0 } +fn one_i() -> i32 { 1 } +fn kind_emitter() -> i32 { 1 } +fn forward() -> [f32; 4] { [0.0, 1.0, 0.0, 0.0] } +fn default_min_level() -> f32 { 0.2 } +fn default_min_interval() -> f32 { 10.0 } +fn default_max_interval() -> f32 { 20.0 } + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AudioOverlay { + pub version: u32, + #[serde(default)] + pub replace: Replace, + #[serde(default)] + pub add: Add, + #[serde(default)] + pub tuning: Tuning, + /// Map stem → that map's audio (custom maps; also a retail map's extras). + #[serde(default)] + pub maps: BTreeMap<String, MapAudioDef>, +} + +/// A WAV: a path, or a path with the loop start (frames) for a slot that loops. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum SampleFile { + Path(String), + Detailed(SampleDetail), +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SampleDetail { + pub file: String, + #[serde(default)] + pub loop_start: Option<u32>, +} + +impl SampleFile { + pub fn file(&self) -> &str { + match self { + Self::Path(p) => p, + Self::Detailed(d) => &d.file, + } + } + pub fn loop_start(&self) -> Option<u32> { + match self { + Self::Path(_) => None, + Self::Detailed(d) => d.loop_start, + } + } +} + +/// The volume group a mod bank's voices play in (the menu's Effects or Ambience volume). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Group { + Player, + World, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BankDef { + /// The AEMS module bank (patch programs); without it a replaced bank keeps the install's. + #[serde(default)] + pub abk: Option<String>, + /// The bank's WAVs by S10A slot. + #[serde(default)] + pub samples: Vec<SampleFile>, + /// Load at audio start and keep across map changes (banks bound to always-present classes). + #[serde(default)] + pub preload: bool, + #[serde(default)] + pub group: Option<Group>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GrainDef { + /// The whole recording (WAV) and its `.grain` member (seek table, durations). + pub file: String, + pub grain: String, +} + +/// An `.ems` record (the manifest's emitter row): a sound emitter (`kind` 1) or a reverb zone +/// (`kind` 5). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EmitterDef { + #[serde(default)] + pub flags: u32, + pub position: [f32; 3], + pub extent: [f32; 3], + /// Inner core, then the forward axis. + #[serde(default = "forward")] + pub scalars: [f32; 4], + #[serde(default = "kind_emitter")] + pub kind: i32, + #[serde(default = "one")] + pub volume: f32, + /// 0 = (1 − d)², 1 = 1 − d, 2 = flat. + #[serde(default)] + pub falloff: i32, + #[serde(default)] + pub bank: Option<String>, + #[serde(default)] + pub patch: i32, + /// The reverb preset key (16 hex digits) of a reverb zone. + #[serde(default)] + pub reverb: Option<String>, + #[serde(default)] + pub sound_id: Option<String>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SoundDef { + pub bank: String, + #[serde(default = "one")] + pub volume: f32, + /// The post's timeout (seconds). + #[serde(default = "ten")] + pub seconds: f32, + #[serde(default = "one_i")] + pub weight: i32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RandomSetDef { + #[serde(default)] + pub name: Option<String>, + pub sounds: Vec<SoundDef>, + #[serde(default = "default_min_level")] + pub min_level: f32, + #[serde(default = "one")] + pub max_level: f32, + #[serde(default = "default_min_interval")] + pub min_interval: f32, + #[serde(default = "default_max_interval")] + pub max_interval: f32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ZoneDef { + #[serde(default)] + pub name: Option<String>, + #[serde(default)] + pub bed: Option<String>, + #[serde(default = "one")] + pub volume: f32, + /// Fade-out when leaving, fade-in when entering (seconds). + #[serde(default = "one")] + pub time_a: f32, + #[serde(default = "one")] + pub time_b: f32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CrossfadeDef { + pub from: String, + pub to: String, + pub group: u32, + #[serde(default = "one")] + pub level: f32, +} + +/// A layer of a location-set post as the interim Bevy-voice player plays it (until the location +/// sets run on the native evaluator): `sample` = a slot or `"shuffle"`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LayerDef { + #[serde(default)] + pub delay: f32, + pub sample: LayerSample, + #[serde(default = "one")] + pub level: f32, + #[serde(default)] + pub pan_sweep: f32, + #[serde(default)] + pub looping: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum LayerSample { + Slot(u32), + Named(String), +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Replace { + /// Bank stem → S10A slot (decimal) → WAV. + #[serde(default)] + pub samples: BTreeMap<String, BTreeMap<String, SampleFile>>, + #[serde(default)] + pub banks: BTreeMap<String, BankDef>, + /// Bank stem → `.splc` patch tree (expert). + #[serde(default)] + pub splice: BTreeMap<String, String>, + #[serde(default)] + pub grains: BTreeMap<String, GrainDef>, + #[serde(default)] + pub wheels: BTreeMap<String, String>, + #[serde(default)] + pub ambience: BTreeMap<String, String>, + /// The whole MixMap (expert). + #[serde(default)] + pub mixmap: Option<String>, + /// `.ems` file stem → record index (decimal) → record. + #[serde(default)] + pub emitters: BTreeMap<String, BTreeMap<String, EmitterDef>>, + /// Location set by key (16 hex digits) or name. + #[serde(default)] + pub random_sets: BTreeMap<String, RandomSetDef>, + /// Zone ambience by key (16 hex digits) or name. + #[serde(default)] + pub zones: BTreeMap<String, ZoneDef>, + /// By zone pair (either order). + #[serde(default)] + pub crossfades: Vec<CrossfadeDef>, + /// Speech archive → clip name → take (decimal) → WAV. + #[serde(default)] + pub speech: BTreeMap<String, BTreeMap<String, BTreeMap<String, String>>>, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Add { + #[serde(default)] + pub banks: BTreeMap<String, BankDef>, + /// `.ems` file stem → records appended (a new stem makes a new file a map can list). + #[serde(default)] + pub emitters: BTreeMap<String, Vec<EmitterDef>>, + /// New location sets / zones by key (16 hex digits). + #[serde(default)] + pub random_sets: BTreeMap<String, RandomSetDef>, + #[serde(default)] + pub zones: BTreeMap<String, ZoneDef>, + #[serde(default)] + pub crossfades: Vec<CrossfadeDef>, + /// Speech archive → clip name → extra takes (WAVs) after the clip's own. + #[serde(default)] + pub speech: BTreeMap<String, BTreeMap<String, Vec<String>>>, + /// Bank stem → the layers a location-set post of the bank plays (interim player). + #[serde(default)] + pub location_programs: BTreeMap<String, Vec<LayerDef>>, +} + +/// Field merges onto the install's tuning sections (only fields the install has; arrays by index +/// as decimal keys). The engine checks every field against the install. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Tuning { + #[serde(default)] + pub player: Option<Value>, + #[serde(default)] + pub world: Option<Value>, + #[serde(default)] + pub bus: Option<Value>, + #[serde(default)] + pub grain: Option<Value>, +} + +/// A map's audio: which `.ems` files (retail names), extra emitter / reverb-zone records, box +/// regions for the zone ambience (`audio_ambience`), location sets (`audio_emitters`) and reverb +/// (`audio_reverb`), the crossfade bank, and whose retail regions it uses (`district`). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MapAudioDef { + #[serde(default)] + pub district: Option<String>, + #[serde(default)] + pub ems: Option<Vec<String>>, + #[serde(default)] + pub crossfade_bank: Option<String>, + /// A bed played when the map has no zone data (not retail). + #[serde(default)] + pub fallback_bed: Option<String>, + #[serde(default)] + pub emitters: Vec<EmitterDef>, + #[serde(default)] + pub regions: BTreeMap<String, Vec<BoxRegion>>, +} + +/// An axis-aligned box on the ground plane: centre x, z and half sizes x, z; `key` = a zone / +/// location set / reverb preset key (16 hex digits) or a zone / set name. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BoxRegion { + pub r#box: [f32; 4], + pub key: String, +} + +/// The region layers a map can carry. +pub const REGION_LAYERS: [&str; 3] = ["audio_ambience", "audio_emitters", "audio_reverb"]; + +/// A 16-hex-digit identity key. +pub fn hex_key(s: &str) -> bool { + s.len() == 16 && s.bytes().all(|b| b.is_ascii_hexdigit()) +} + +/// A retail-style name (bank / clip / emitter file / set / zone / map stem). +pub fn valid_name(s: &str) -> bool { + !s.is_empty() && s.len() <= 128 && s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b' ')) + && !s.starts_with('.') && !s.contains("..") +} + +/// A mod-relative content path with one of `exts` (lower case, without the dot). +pub fn valid_content_path(path: &str, exts: &[&str]) -> bool { + let lower = path.to_ascii_lowercase(); + !path.is_empty() && path.len() <= 256 + && exts.iter().any(|e| lower.ends_with(&format!(".{e}"))) + && !path.chars().any(|c| matches!(c, '\\' | ':' | '#' | '?') || c.is_control()) + && path.split('/').all(|s| !s.is_empty() && s != "." && s != "..") +} + +fn index(s: &str, max: u32) -> bool { + !s.is_empty() && s.len() <= 6 && s.bytes().all(|b| b.is_ascii_digit()) && s.parse::<u32>().is_ok_and(|v| v <= max) +} + +fn finite(v: &[f32]) -> bool { + v.iter().all(|x| x.is_finite() && x.abs() <= 1.0e6) +} + +/// What kind of file a path is, for its checks. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FileKind { + Sample, + Bed, + Stream, + Speech, + Abk, + Splice, + MixMap, + Grain, +} + +impl FileKind { + fn exts(self) -> &'static [&'static str] { + match self { + Self::Sample | Self::Bed | Self::Stream | Self::Speech => &["wav"], + Self::Abk => &["abk"], + Self::Splice => &["splc"], + Self::MixMap => &["mxb"], + Self::Grain => &["grain"], + } + } +} + +impl EmitterDef { + fn check(&self, at: &str) -> Result<(), String> { + if !finite(&self.position) || !finite(&self.extent) || !finite(&self.scalars) || !self.volume.is_finite() { + return Err(format!("{at}: numbers must be finite")); + } + if self.extent.iter().any(|e| *e <= 0.0) { + return Err(format!("{at}: extents must be > 0")); + } + if !(0.0..=4.0).contains(&self.volume) || !(0..=2).contains(&self.falloff) || !(0..=500).contains(&self.patch) { + return Err(format!("{at}: volume 0..4, falloff 0..2, patch 0..500")); + } + if !matches!(self.kind, 1 | 5) { + return Err(format!("{at}: kind is 1 (sound emitter) or 5 (reverb zone)")); + } + if self.kind == 1 && !self.bank.as_deref().is_some_and(valid_name) { + return Err(format!("{at}: a sound emitter needs its bank")); + } + if self.kind == 5 && !self.reverb.as_deref().is_some_and(hex_key) { + return Err(format!("{at}: a reverb zone needs its reverb preset key (16 hex digits)")); + } + if self.bank.as_deref().is_some_and(|b| !valid_name(b)) || self.sound_id.as_deref().is_some_and(|k| !hex_key(k)) { + return Err(format!("{at}: bad bank name or sound_id")); + } + Ok(()) + } +} + +impl RandomSetDef { + fn check(&self, at: &str) -> Result<(), String> { + if self.sounds.len() > 64 || self.sounds.iter().any(|s| !valid_name(&s.bank) || !(0.0..=4.0).contains(&s.volume) + || !(0.0..=600.0).contains(&s.seconds) || !(0..=10_000).contains(&s.weight)) { + return Err(format!("{at}: up to 64 sounds with a bank, volume 0..4, seconds 0..600, weight 0..10000")); + } + if !finite(&[self.min_level, self.max_level, self.min_interval, self.max_interval]) + || !(0.0..=4.0).contains(&self.min_level) || self.max_level < self.min_level || self.max_level > 4.0 + || !(0.0..=3600.0).contains(&self.min_interval) || self.max_interval < self.min_interval || self.max_interval > 3600.0 { + return Err(format!("{at}: levels 0..4 and intervals 0..3600 s, min ≤ max")); + } + if self.name.as_deref().is_some_and(|n| !valid_name(n)) { + return Err(format!("{at}: bad name")); + } + Ok(()) + } +} + +impl ZoneDef { + fn check(&self, at: &str) -> Result<(), String> { + if !finite(&[self.volume, self.time_a, self.time_b]) || !(0.0..=4.0).contains(&self.volume) + || !(0.0..=60.0).contains(&self.time_a) || !(0.0..=60.0).contains(&self.time_b) { + return Err(format!("{at}: volume 0..4, fades 0..60 s")); + } + if self.name.as_deref().is_some_and(|n| !valid_name(n)) || self.bed.as_deref().is_some_and(|n| !valid_name(n)) { + return Err(format!("{at}: bad name or bed")); + } + Ok(()) + } +} + +impl CrossfadeDef { + fn check(&self, at: &str) -> Result<(), String> { + if !hex_key(&self.from) || !hex_key(&self.to) || self.from == self.to || self.group > 64 || !self.level.is_finite() || !(0.0..=4.0).contains(&self.level) { + return Err(format!("{at}: two different zone keys (16 hex digits), group ≤ 64, level 0..4")); + } + Ok(()) + } +} + +impl LayerDef { + fn check(&self, at: &str) -> Result<(), String> { + if !finite(&[self.delay, self.level, self.pan_sweep]) || !(0.0..=60.0).contains(&self.delay) || !(0.0..=4.0).contains(&self.level) + || self.pan_sweep.abs() > 3600.0 { + return Err(format!("{at}: delay 0..60 s, level 0..4, pan sweep ±3600 °/s")); + } + match &self.sample { + LayerSample::Slot(s) if *s < MAX_BANK_SAMPLES as u32 => Ok(()), + LayerSample::Named(n) if n == "shuffle" => Ok(()), + _ => Err(format!("{at}: sample is a slot number or \"shuffle\"")), + } + } +} + +impl MapAudioDef { + /// The map section's own checks (also used for `<map>.audio.json` sidecars and `.skate` tags). + pub fn check(&self, at: &str) -> Result<usize, String> { + if self.district.as_deref().is_some_and(|d| !valid_name(d)) || self.crossfade_bank.as_deref().is_some_and(|d| !valid_name(d)) + || self.fallback_bed.as_deref().is_some_and(|d| !valid_name(d)) { + return Err(format!("{at}: bad district / crossfade_bank / fallback_bed name")); + } + if let Some(ems) = &self.ems { + if ems.len() > 16 || ems.iter().any(|e| !valid_name(e)) { + return Err(format!("{at}: up to 16 .ems file names")); + } + } + let mut records = self.emitters.len(); + for (i, e) in self.emitters.iter().enumerate() { + e.check(&format!("{at}.emitters[{i}]"))?; + } + for (layer, boxes) in &self.regions { + if !REGION_LAYERS.contains(&layer.as_str()) { + return Err(format!("{at}.regions: unknown layer {layer:?} (audio_ambience, audio_emitters, audio_reverb)")); + } + records += boxes.len(); + for (i, b) in boxes.iter().enumerate() { + if !finite(&b.r#box) || b.r#box[2] <= 0.0 || b.r#box[3] <= 0.0 || !(hex_key(&b.key) || valid_name(&b.key)) { + return Err(format!("{at}.regions.{layer}[{i}]: box = [x, z, half x, half z] (half sizes > 0) and a key or name")); + } + } + } + Ok(records) + } + + /// Parse and check a map's own audio definition (sidecar / `.skate` tag). + pub fn parse(bytes: &[u8], at: &str) -> Result<Self, String> { + let def: Self = serde_json::from_slice(bytes).map_err(|e| format!("{at}: {e}"))?; + let records = def.check(at)?; + if records > MAX_RECORDS { + return Err(format!("{at}: more than {MAX_RECORDS} records")); + } + Ok(def) + } +} + +fn object(v: &Option<Value>, at: &str) -> Result<(), String> { + match v { + None => Ok(()), + Some(Value::Object(_)) => { + fn depth(v: &Value, d: usize) -> bool { + d <= 8 && match v { + Value::Object(m) => m.len() <= 4096 && m.values().all(|x| depth(x, d + 1)), + Value::Array(a) => a.len() <= 4096 && a.iter().all(|x| depth(x, d + 1)), + Value::Number(n) => n.as_f64().is_some_and(f64::is_finite), + _ => true, + } + } + if depth(v.as_ref().unwrap(), 0) { Ok(()) } else { Err(format!("{at}: tuning nests at most 8 deep with finite numbers")) } + } + Some(_) => Err(format!("{at}: tuning sections are objects of fields")), + } +} + +impl AudioOverlay { + /// The file list (path, kind) in a stable order: what [`load`] reads and checks. + pub fn files(&self) -> Vec<(&str, FileKind)> { + let mut out = Vec::new(); + for slots in self.replace.samples.values() { + out.extend(slots.values().map(|s| (s.file(), FileKind::Sample))); + } + for b in self.replace.banks.values().chain(self.add.banks.values()) { + out.extend(b.abk.as_deref().map(|a| (a, FileKind::Abk))); + out.extend(b.samples.iter().map(|s| (s.file(), FileKind::Sample))); + } + out.extend(self.replace.splice.values().map(|f| (f.as_str(), FileKind::Splice))); + for g in self.replace.grains.values() { + out.push((g.file.as_str(), FileKind::Stream)); + out.push((g.grain.as_str(), FileKind::Grain)); + } + out.extend(self.replace.wheels.values().map(|f| (f.as_str(), FileKind::Stream))); + out.extend(self.replace.ambience.values().map(|f| (f.as_str(), FileKind::Bed))); + out.extend(self.replace.mixmap.as_deref().map(|f| (f, FileKind::MixMap))); + for clips in self.replace.speech.values() { + for takes in clips.values() { + out.extend(takes.values().map(|f| (f.as_str(), FileKind::Speech))); + } + } + for clips in self.add.speech.values() { + for takes in clips.values() { + out.extend(takes.iter().map(|f| (f.as_str(), FileKind::Speech))); + } + } + out + } + + /// Schema-level checks: version, counts, numbers, key and path syntax. + pub fn validate(&self) -> Result<(), String> { + if self.version != VERSION { + return Err(format!("audio.json version {} (this build reads {VERSION})", self.version)); + } + let r = &self.replace; + let a = &self.add; + let samples: usize = r.samples.values().map(BTreeMap::len).sum(); + if samples > MAX_SAMPLE_REPLACEMENTS { + return Err(format!("more than {MAX_SAMPLE_REPLACEMENTS} sample replacements")); + } + if r.banks.len() + a.banks.len() > MAX_BANKS { + return Err(format!("more than {MAX_BANKS} banks")); + } + for (bank, slots) in &r.samples { + if !valid_name(bank) { + return Err(format!("replace.samples: bad bank name {bank:?}")); + } + for (slot, file) in slots { + if !index(slot, MAX_BANK_SAMPLES as u32 * 8) { + return Err(format!("replace.samples.{bank}: slot {slot:?} is not a slot number")); + } + if file.loop_start().is_some_and(|l| l > 48_000 * 600) { + return Err(format!("replace.samples.{bank}.{slot}: loop_start out of range")); + } + } + } + for (section, banks) in [("replace.banks", &r.banks), ("add.banks", &a.banks)] { + for (stem, b) in banks { + if !valid_name(stem) { + return Err(format!("{section}: bad bank name {stem:?}")); + } + if b.samples.len() > MAX_BANK_SAMPLES { + return Err(format!("{section}.{stem}: more than {MAX_BANK_SAMPLES} samples")); + } + if b.abk.is_none() && b.samples.is_empty() { + return Err(format!("{section}.{stem}: give an abk and / or samples")); + } + } + } + for (section, names) in [("replace.splice", r.splice.keys()), ("replace.wheels", r.wheels.keys()), ("replace.ambience", r.ambience.keys())] { + for n in names { + if !valid_name(n) { + return Err(format!("{section}: bad name {n:?}")); + } + } + } + for n in r.grains.keys() { + if !valid_name(n) { + return Err(format!("replace.grains: bad name {n:?}")); + } + } + let mut records = 0usize; + for (file, recs) in &r.emitters { + if !valid_name(file) { + return Err(format!("replace.emitters: bad file name {file:?}")); + } + for (i, e) in recs { + if !index(i, 1 << 20) { + return Err(format!("replace.emitters.{file}: {i:?} is not a record index")); + } + e.check(&format!("replace.emitters.{file}.{i}"))?; + } + records += recs.len(); + } + for (file, recs) in &a.emitters { + if !valid_name(file) { + return Err(format!("add.emitters: bad file name {file:?}")); + } + for (i, e) in recs.iter().enumerate() { + e.check(&format!("add.emitters.{file}[{i}]"))?; + } + records += recs.len(); + } + for (key, set) in &r.random_sets { + if !(hex_key(key) || valid_name(key)) { + return Err(format!("replace.random_sets: {key:?} is not a key or name")); + } + set.check(&format!("replace.random_sets.{key}"))?; + } + for (key, set) in &a.random_sets { + if !hex_key(key) { + return Err(format!("add.random_sets: a new set's key is 16 hex digits ({key:?})")); + } + set.check(&format!("add.random_sets.{key}"))?; + } + for (key, zone) in &r.zones { + if !(hex_key(key) || valid_name(key)) { + return Err(format!("replace.zones: {key:?} is not a key or name")); + } + zone.check(&format!("replace.zones.{key}"))?; + } + for (key, zone) in &a.zones { + if !hex_key(key) { + return Err(format!("add.zones: a new zone's key is 16 hex digits ({key:?})")); + } + zone.check(&format!("add.zones.{key}"))?; + } + for (i, c) in r.crossfades.iter().enumerate() { + c.check(&format!("replace.crossfades[{i}]"))?; + } + for (i, c) in a.crossfades.iter().enumerate() { + c.check(&format!("add.crossfades[{i}]"))?; + } + for (bank, layers) in &a.location_programs { + if !valid_name(bank) || layers.is_empty() || layers.len() > 16 { + return Err(format!("add.location_programs.{bank}: a bank name and 1..16 layers")); + } + for (i, l) in layers.iter().enumerate() { + l.check(&format!("add.location_programs.{bank}[{i}]"))?; + } + records += layers.len(); + } + for (archive, clips) in &r.speech { + if !valid_name(archive) { + return Err(format!("replace.speech: bad archive {archive:?}")); + } + for (clip, takes) in clips { + if !valid_name(clip) || takes.keys().any(|t| !index(t, 254)) { + return Err(format!("replace.speech.{archive}: clip {clip:?} with takes 0..254")); + } + } + } + for (archive, clips) in &a.speech { + if !valid_name(archive) { + return Err(format!("add.speech: bad archive {archive:?}")); + } + for (clip, takes) in clips { + if !valid_name(clip) || takes.is_empty() || takes.len() > 64 { + return Err(format!("add.speech.{archive}: clip {clip:?} with 1..64 takes")); + } + } + } + object(&self.tuning.player, "tuning.player")?; + object(&self.tuning.world, "tuning.world")?; + object(&self.tuning.bus, "tuning.bus")?; + object(&self.tuning.grain, "tuning.grain")?; + for (stem, map) in &self.maps { + if !valid_name(stem) { + return Err(format!("maps: bad map stem {stem:?}")); + } + records += map.check(&format!("maps.{stem}"))?; + } + records += r.random_sets.len() + a.random_sets.len() + r.zones.len() + a.zones.len() + r.crossfades.len() + a.crossfades.len(); + if records > MAX_RECORDS { + return Err(format!("more than {MAX_RECORDS} records")); + } + let files = self.files(); + if files.len() > MAX_FILES { + return Err(format!("more than {MAX_FILES} files")); + } + for (path, kind) in &files { + if !valid_content_path(path, kind.exts()) { + return Err(format!("bad {:?} path {path:?} (mod-relative, /-separated, .{})", kind, kind.exts()[0])); + } + } + Ok(()) + } + + /// One line per kind of change, for check_mod and the log. + pub fn summary(&self) -> Vec<String> { + let r = &self.replace; + let a = &self.add; + let mut out = Vec::new(); + let mut line = |n: usize, what: &str| { + if n > 0 { + out.push(format!("{what}: {n}")); + } + }; + line(r.samples.values().map(BTreeMap::len).sum(), "replaced samples"); + line(r.banks.len(), "replaced banks"); + line(a.banks.len(), "added banks"); + line(r.splice.len(), "replaced Splice trees"); + line(r.grains.len(), "replaced grain members"); + line(r.wheels.len(), "replaced wheel streams"); + line(r.ambience.len(), "replaced ambience beds"); + line(usize::from(r.mixmap.is_some()), "replaced MixMap"); + line(r.emitters.values().map(BTreeMap::len).sum(), "replaced emitter records"); + line(a.emitters.values().map(Vec::len).sum(), "added emitter records"); + line(r.random_sets.len(), "replaced location sets"); + line(a.random_sets.len(), "added location sets"); + line(r.zones.len(), "replaced zones"); + line(a.zones.len(), "added zones"); + line(r.crossfades.len(), "replaced crossfades"); + line(a.crossfades.len(), "added crossfades"); + line(r.speech.values().flat_map(|c| c.values()).map(BTreeMap::len).sum(), "replaced speech takes"); + line(a.speech.values().flat_map(|c| c.values()).map(Vec::len).sum(), "added speech takes"); + line(a.location_programs.len(), "location programs"); + let t = &self.tuning; + line([&t.player, &t.world, &t.bus, &t.grain].iter().filter(|v| v.is_some()).count(), "tuning sections"); + line(self.maps.len(), "maps"); + out + } +} + +/// A checked overlay and what its files cost. +#[derive(Clone, Debug)] +pub struct Loaded { + pub overlay: AudioOverlay, + /// PCM16 bytes of its WAVs (the budget). + pub pcm_bytes: u64, +} + +/// Check one content file inside the mod root: WAVs through the canonical PCM16 check (with the +/// kind's limits), banks / trees / MixMaps / grain members through their parsers. Returns the +/// PCM bytes of a WAV (0 for other kinds). +pub fn check_file(root: &Path, path: &str, kind: FileKind) -> Result<u64, String> { + let (bytes_limit, seconds) = match kind { + FileKind::Sample | FileKind::Speech => SAMPLE_LIMITS, + FileKind::Bed => BED_LIMITS, + FileKind::Stream => STREAM_LIMITS, + _ => (MAX_BINARY_BYTES, 0.0), + }; + let bytes = read_bounded(root, path, bytes_limit).map_err(|e| format!("{path}: {e}"))?; + match kind { + FileKind::Sample | FileKind::Bed | FileKind::Stream | FileKind::Speech => { + let (canonical, _) = crate::audio::canonical_pcm_wav_limited(&bytes, bytes_limit, seconds).map_err(|e| format!("{path}: {e}"))?; + Ok(canonical.len().saturating_sub(44) as u64) + } + FileKind::Abk => skate_audio::formats::Bank::parse(path, bytes).map(|_| 0).map_err(|e| format!("{path}: {e}")), + FileKind::Splice => skate_audio::splice::SpliceBank::parse(&bytes).map(|_| 0).map_err(|e| format!("{path}: {e}")), + FileKind::MixMap => skate_audio::mixmap::MixMapFile::parse(&bytes).map(|_| 0).map_err(|e| format!("{path}: {e}")), + FileKind::Grain => skate_audio::grain::GrainFile::parse(&bytes).map(|_| 0).map_err(|e| format!("{path}: {e}")), + } +} + +/// Read, parse and check a mod's `audio.json` and every file it names. `Ok(None)`: the mod has +/// no overlay. +pub fn load(root: &Path) -> Result<Option<Loaded>, String> { + if !root.join(FILE).is_file() { + return Ok(None); + } + let bytes = read_bounded(root, FILE, MAX_JSON_BYTES)?; + let overlay: AudioOverlay = serde_json::from_slice(&bytes).map_err(|e| format!("{FILE}: {e}"))?; + overlay.validate().map_err(|e| format!("{FILE}: {e}"))?; + let mut pcm_bytes = 0u64; + let mut seen = std::collections::BTreeSet::new(); + for (path, kind) in overlay.files() { + if !seen.insert((path, kind as u8)) { + continue; + } + pcm_bytes += check_file(root, path, kind).map_err(|e| format!("{FILE}: {e}"))?; + if pcm_bytes > MAX_PCM_BYTES_PER_MOD { + return Err(format!("{FILE}: the WAVs exceed {} MiB of PCM", MAX_PCM_BYTES_PER_MOD / (1024 * 1024))); + } + } + Ok(Some(Loaded { overlay, pcm_bytes })) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + pub(crate) fn wav(frames: usize, rate: u32, channels: u16) -> Vec<u8> { + let data = frames * 2 * usize::from(channels); + let mut b = Vec::new(); + b.extend_from_slice(b"RIFF"); + b.extend_from_slice(&(36 + data as u32).to_le_bytes()); + b.extend_from_slice(b"WAVEfmt "); + b.extend_from_slice(&16u32.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&channels.to_le_bytes()); + b.extend_from_slice(&rate.to_le_bytes()); + b.extend_from_slice(&(rate * u32::from(channels) * 2).to_le_bytes()); + b.extend_from_slice(&(channels * 2).to_le_bytes()); + b.extend_from_slice(&16u16.to_le_bytes()); + b.extend_from_slice(b"data"); + b.extend_from_slice(&(data as u32).to_le_bytes()); + for i in 0..frames * usize::from(channels) { + b.extend_from_slice(&(((i as f32 * 0.1).sin() * 8000.0) as i16).to_le_bytes()); + } + b + } + + fn dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("skate-audio-content-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(d.join("audio")).unwrap(); + d + } + + #[test] + fn a_full_overlay_parses_validates_and_summarises() { + let v = json!({ + "version": 1, + "replace": { + "samples": {"Skate_Collisions": {"3": "audio/pop.wav", "4": {"file": "audio/loop.wav", "loop_start": 10}}}, + "banks": {"C04_taxi01": {"abk": "audio/taxi.abk", "samples": ["audio/t0.wav"]}}, + "splice": {"Skate_Collisions": "audio/c.splc"}, + "grains": {"wood_ramp_hard": {"file": "audio/wood.wav", "grain": "audio/wood.grain"}}, + "wheels": {"Whls_spins_Jump_1": "audio/spin.wav"}, + "ambience": {"04_dt_main": "audio/dt.wav"}, + "mixmap": "audio/MixMapSK8.mxb", + "emitters": {"sfx_downtown": {"9": {"position": [0, 0, 0], "extent": [5, 5, 5], "bank": "Baby_Cry_1"}}}, + "random_sets": {"e_dwtn_spillway_brewery": {"sounds": [{"bank": "Siren_city_8"}]}}, + "zones": {"int_tunnel": {"bed": "22_interior_tunnel_amb"}}, + "crossfades": [{"from": "1F741D87EB58E84F", "to": "B6CE4BDEB63B6639", "group": 2}], + "speech": {"livingworld": {"501_59_busm1_Warn_n.dat": {"3": "audio/warn3.wav"}}} + }, + "add": { + "banks": {"MOD_siren": {"abk": "audio/MOD_siren.abk", "samples": ["audio/s0.wav"], "preload": true, "group": "world"}}, + "emitters": {"sfx_mymap": [{"position": [1, 2, 3], "extent": [4, 4, 4], "bank": "MOD_siren", "patch": 1}, + {"position": [1, 2, 3], "extent": [9, 9, 9], "kind": 5, "reverb": "BEEFC8E3DE04FBAE"}]}, + "random_sets": {"00000000000000AA": {"name": "my_set", "sounds": [{"bank": "MOD_siren", "weight": 3}]}}, + "zones": {"00000000000000BB": {"name": "my_zone", "bed": "04_dt_main"}}, + "speech": {"livingworld": {"501_59_busm1_Warn_n.dat": ["audio/w.wav"]}}, + "location_programs": {"MOD_siren": [{"sample": "shuffle"}, {"delay": 0.5, "sample": 0, "level": 0.5}]} + }, + "tuning": {"player": {"grind": {"3": {"v": {"0": 0.8}}}}, "world": {"traffic_engine": {"c04_taxi01": {"idle_rpm": 900}}}}, + "maps": {"MyMap": {"district": "DownTown", "ems": ["sfx_mymap"], "regions": {"audio_ambience": [{"box": [0, 0, 50, 50], "key": "my_zone"}]}}} + }); + let o: AudioOverlay = serde_json::from_value(v.clone()).unwrap(); + o.validate().unwrap(); + assert_eq!(o.replace.samples["Skate_Collisions"]["4"].loop_start(), Some(10)); + assert_eq!(o.add.banks["MOD_siren"].group, Some(Group::World)); + let s = o.summary().join("; "); + assert!(s.contains("replaced samples: 2") && s.contains("added banks: 1") && s.contains("maps: 1"), "{s}"); + assert_eq!(o.files().len(), 14); + // The serialized form reads back the same (the engine takes it as data). + let back: AudioOverlay = serde_json::from_value(serde_json::to_value(&o).unwrap()).unwrap(); + assert_eq!(back, o); + } + + #[test] + fn bad_overlays_are_rejected() { + let base = || json!({"version": 1}); + let cases = [ + json!({"version": 2}), + json!({"version": 1, "typo": {}}), + json!({"version": 1, "replace": {"sample": {}}}), + json!({"version": 1, "replace": {"samples": {"x": {"a": "s.wav"}}}}), + json!({"version": 1, "replace": {"samples": {"x": {"1": "../s.wav"}}}}), + json!({"version": 1, "replace": {"samples": {"x": {"1": "C:/s.wav"}}}}), + json!({"version": 1, "replace": {"samples": {"x": {"1": "s.ogg"}}}}), + json!({"version": 1, "replace": {"samples": {"x": {"1": {"file": "s.wav", "extra": 1}}}}}), + json!({"version": 1, "replace": {"banks": {"x": {}}}}), + json!({"version": 1, "replace": {"mixmap": "m.wav"}}), + json!({"version": 1, "add": {"random_sets": {"name": {"sounds": []}}}}), + json!({"version": 1, "add": {"emitters": {"f": [{"position": [0, 0, 0], "extent": [0, 1, 1], "bank": "b"}]}}}), + json!({"version": 1, "add": {"emitters": {"f": [{"position": [0, 0, 0], "extent": [1, 1, 1]}]}}}), + json!({"version": 1, "add": {"emitters": {"f": [{"position": [0, 0, 0], "extent": [1, 1, 1], "kind": 5}]}}}), + json!({"version": 1, "add": {"crossfades": [{"from": "00000000000000AA", "to": "00000000000000AA", "group": 1}]}}), + json!({"version": 1, "add": {"location_programs": {"b": [{"sample": "every"}]}}}), + json!({"version": 1, "tuning": {"player": 3}}), + json!({"version": 1, "maps": {"m": {"regions": {"audio_music": []}}}}), + json!({"version": 1, "maps": {"m": {"regions": {"audio_reverb": [{"box": [0, 0, 0, 1], "key": "x"}]}}}}), + json!({"version": 1, "replace": {"speech": {"livingworld": {"c": {"300": "a.wav"}}}}}), + ]; + assert!(serde_json::from_value::<AudioOverlay>(base()).unwrap().validate().is_ok()); + for case in cases { + let ok = serde_json::from_value::<AudioOverlay>(case.clone()).map_err(|e| e.to_string()).and_then(|o| o.validate()); + assert!(ok.is_err(), "accepted {case}"); + } + } + + #[test] + fn load_checks_every_file_inside_the_mod() { + let d = dir("load"); + std::fs::write(d.join("audio/pop.wav"), wav(4800, 48000, 1)).unwrap(); + std::fs::write(d.join(FILE), json!({"version": 1, "replace": {"samples": {"Skate_Collisions": {"3": "audio/pop.wav"}}}}).to_string()).unwrap(); + let loaded = load(&d).unwrap().unwrap(); + assert_eq!(loaded.pcm_bytes, 9600); + // A missing file, a corrupt WAV, a non-bank `.abk` and a path escaping the root fail. + for (overlay, why) in [ + (json!({"version": 1, "replace": {"samples": {"b": {"1": "audio/missing.wav"}}}}), "missing"), + (json!({"version": 1, "replace": {"samples": {"b": {"1": "audio/bad.wav"}}}}), "corrupt"), + (json!({"version": 1, "add": {"banks": {"b": {"abk": "audio/bad.abk"}}}}), "bank"), + (json!({"version": 1, "replace": {"mixmap": "audio/bad.mxb"}}), "mixmap"), + ] { + std::fs::write(d.join("audio/bad.wav"), b"RIFF0000WAVEjunk").unwrap(); + std::fs::write(d.join("audio/bad.abk"), b"not a bank").unwrap(); + std::fs::write(d.join("audio/bad.mxb"), b"not a mixmap").unwrap(); + std::fs::write(d.join(FILE), overlay.to_string()).unwrap(); + assert!(load(&d).is_err(), "{why}"); + } + // A bed may be longer than a sample. + std::fs::write(d.join("audio/bed.wav"), wav(48000 * 40, 48000, 1)).unwrap(); + std::fs::write(d.join(FILE), json!({"version": 1, "replace": {"ambience": {"04_dt_main": "audio/bed.wav"}}}).to_string()).unwrap(); + assert!(load(&d).is_ok(), "a 40 s bed"); + std::fs::write(d.join(FILE), json!({"version": 1, "replace": {"samples": {"b": {"0": "audio/bed.wav"}}}}).to_string()).unwrap(); + assert!(load(&d).is_err(), "a 40 s sample"); + std::fs::remove_file(d.join(FILE)).unwrap(); + assert!(load(&d).unwrap().is_none(), "no overlay"); + let _ = std::fs::remove_dir_all(d); + } + + #[test] + fn map_definitions_parse_on_their_own() { + let def = MapAudioDef::parse(br#"{"ems": ["sfx_downtown"], "crossfade_bank": "Main_Ambience_Crossfade_DT", + "emitters": [{"position": [0, 0, 0], "extent": [3, 3, 3], "bank": "Baby_Cry_1"}], + "regions": {"audio_emitters": [{"box": [10, 10, 5, 5], "key": "e_dwtn_spillway_brewery"}]}}"#, "x.audio.json").unwrap(); + assert_eq!(def.ems.as_deref(), Some(&["sfx_downtown".to_owned()][..])); + assert!(MapAudioDef::parse(br#"{"ems": "sfx"}"#, "x").is_err()); + assert!(MapAudioDef::parse(br#"{"unknown": 1}"#, "x").is_err()); + } +} diff --git a/crates/skate-mods/src/audio_merge.rs b/crates/skate-mods/src/audio_merge.rs new file mode 100644 index 00000000..0ab6495b --- /dev/null +++ b/crates/skate-mods/src/audio_merge.rs @@ -0,0 +1,641 @@ +//! Merging audio content overlays into the install's audio manifest (JSON), by retail identity: +//! overlays in the order given (the engine passes mod-id order), the first owner of an identity +//! wins and later claims are reported as conflicts. Pure data: the engine merges, then reads the +//! result as its typed manifest; `check_mod --install` runs the same merge to report unknown +//! identities and conflicts at authoring time. +//! +//! Mod files become `mod:<id>/<path>` references (the install's own paths never contain `:`). +//! Overlay-only data goes into sections the install never has: `mod_sample_loops`, `mod_banks`, +//! `mod_speech`, `mod_location_programs`, `mod_maps`. +use crate::audio_content::{AudioOverlay, BankDef, EmitterDef, LayerSample, SampleFile, hex_key}; +use serde_json::{Map, Value, json}; +use std::collections::BTreeMap; + +/// The prefix of a mod file reference in a merged manifest. +pub const MOD_REF: &str = "mod:"; + +pub fn mod_ref(id: &str, path: &str) -> String { + format!("{MOD_REF}{id}/{path}") +} + +/// `mod:<id>/<path>` → (id, path). +pub fn split_mod_ref(file: &str) -> Option<(&str, &str)> { + file.strip_prefix(MOD_REF)?.split_once('/') +} + +/// One overlay to merge. +pub struct Source<'a> { + pub id: &'a str, + pub overlay: &'a AudioOverlay, +} + +/// A conflict or warning about one mod's overlay. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Message { + pub owner: String, + pub text: String, +} + +#[derive(Clone, Debug, Default)] +pub struct Report { + /// Another mod already owns the identity: this claim is ignored (shown in the mod menu). + pub conflicts: Vec<Message>, + /// Unknown identities, adds of existing ones, tuning fields the install lacks: ignored. + pub warnings: Vec<Message>, + /// Identities each mod changed. + pub claimed: BTreeMap<String, String>, +} + +/// Identity → owning mod, across the overlays merged so far. +#[derive(Clone, Debug, Default)] +pub struct Owners { + owners: BTreeMap<String, String>, +} + +impl Owners { + fn owner(&self, identity: &str) -> Option<&str> { + self.owners.get(identity).map(String::as_str) + } + + /// Claim an identity. Err = the other owner. Banks and their samples overlap: a sample is + /// taken while another mod replaces its whole bank, and a bank while another mod replaces one + /// of its samples. + fn claim(&mut self, identity: &str, id: &str) -> Result<(), String> { + let other = self.owner(identity).filter(|o| *o != id).map(str::to_owned).or_else(|| { + if let Some(rest) = identity.strip_prefix("sample:") { + let bank = rest.rsplit_once(':').map_or(rest, |(b, _)| b); + return self.owner(&format!("bank:{bank}")).filter(|o| *o != id).map(str::to_owned); + } + if let Some(bank) = identity.strip_prefix("bank:") { + let prefix = format!("sample:{bank}:"); + return self.owners.range(prefix.clone()..).take_while(|(k, _)| k.starts_with(&prefix)).find(|(_, o)| *o != id).map(|(_, o)| o.clone()); + } + None + }); + if let Some(other) = other { + return Err(other); + } + self.owners.insert(identity.to_owned(), id.to_owned()); + Ok(()) + } + + pub fn all(&self) -> &BTreeMap<String, String> { + &self.owners + } +} + +struct Ctx<'a, 'r> { + id: &'a str, + owners: &'a mut Owners, + report: &'r mut Report, +} + +impl Ctx<'_, '_> { + fn claim(&mut self, identity: &str) -> bool { + match self.owners.claim(identity, self.id) { + Ok(()) => { + self.report.claimed.insert(identity.to_owned(), self.id.to_owned()); + true + } + Err(other) => { + self.report.conflicts.push(Message { + owner: self.id.to_owned(), + text: format!("{identity} is already changed by {other}; ignored"), + }); + false + } + } + } + fn warn(&mut self, text: String) { + self.report.warnings.push(Message { owner: self.id.to_owned(), text }); + } + fn file(&self, path: &str) -> Value { + Value::String(mod_ref(self.id, path)) + } +} + +fn section<'v>(m: &'v mut Value, key: &str) -> &'v mut Map<String, Value> { + let root = m.as_object_mut().expect("the manifest is an object"); + let v = root.entry(key.to_owned()).or_insert_with(|| json!({})); + if !v.is_object() { + *v = json!({}); + } + v.as_object_mut().unwrap() +} + +fn sub<'v>(m: &'v mut Map<String, Value>, key: &str) -> &'v mut Map<String, Value> { + let v = m.entry(key.to_owned()).or_insert_with(|| json!({})); + if !v.is_object() { + *v = json!({}); + } + v.as_object_mut().unwrap() +} + +fn has(m: &Value, path: &[&str]) -> bool { + let mut v = m; + for k in path { + match v.get(*k) { + Some(x) if !x.is_null() => v = x, + _ => return false, + } + } + true +} + +fn emitter_json(e: &EmitterDef, index: u64, sound_id: Option<&str>) -> Value { + let mut v = serde_json::to_value(e).expect("records serialize"); + let o = v.as_object_mut().unwrap(); + o.insert("index".into(), json!(index)); + o.insert("sound_id".into(), json!(e.sound_id.as_deref().or(sound_id).unwrap_or(""))); + o.retain(|_, x| !x.is_null()); + v +} + +fn samples_json(ctx: &Ctx, samples: &[SampleFile]) -> Value { + Value::Array(samples.iter().map(|s| json!({"file": ctx.file(s.file())})).collect()) +} + +fn loops(m: &mut Value, bank: &str, entries: impl Iterator<Item = (usize, u32)>) { + let mut entries = entries.peekable(); + if entries.peek().is_none() { + return; + } + let l = sub(section(m, "mod_sample_loops"), bank); + for (slot, start) in entries { + l.insert(slot.to_string(), json!(start)); + } +} + +fn bank_meta(m: &mut Value, stem: &str, b: &BankDef) { + if b.preload || b.group.is_some() { + section(m, "mod_banks").insert(stem.to_owned(), json!({"preload": b.preload, "group": b.group})); + } +} + +/// A location set / zone by key (16 hex digits, any case) or name, in a keyed section. +fn resolve_key(m: &Value, section: &str, key: &str) -> Option<String> { + let rows = m.get(section)?.as_object()?; + if hex_key(key) { + let upper = key.to_ascii_uppercase(); + return rows.contains_key(&upper).then_some(upper); + } + rows.iter().find(|(_, v)| v.get("name").and_then(Value::as_str) == Some(key)).map(|(k, _)| k.clone()) +} + +fn pair(a: &str, b: &str) -> String { + let (a, b) = (a.to_ascii_uppercase(), b.to_ascii_uppercase()); + if a <= b { format!("{a}:{b}") } else { format!("{b}:{a}") } +} + +fn crossfade_at(m: &Value, from: &str, to: &str) -> Option<usize> { + let want = pair(from, to); + m.get("crossfades")?.as_array()?.iter().position(|c| { + let f = c.get("from").and_then(Value::as_str).unwrap_or(""); + let t = c.get("to").and_then(Value::as_str).unwrap_or(""); + pair(f, t) == want + }) +} + +/// The tuning merge: only fields the install has; arrays take decimal index keys; numbers replace +/// numbers, booleans booleans, strings strings, arrays of numbers arrays of numbers. +fn merge_tuning(ctx: &mut Ctx, target: &mut Value, patch: &Value, path: &str) { + match (target, patch) { + (Value::Object(t), Value::Object(p)) => { + for (k, v) in p { + let at = format!("{path}/{k}"); + match t.get_mut(k) { + Some(x) => merge_tuning(ctx, x, v, &at), + None => ctx.warn(format!("tuning field {at} is not in the install; ignored")), + } + } + } + (Value::Array(t), Value::Object(p)) => { + for (k, v) in p { + let at = format!("{path}/{k}"); + match k.parse::<usize>().ok().and_then(|i| t.get_mut(i)) { + Some(x) => merge_tuning(ctx, x, v, &at), + None => ctx.warn(format!("tuning index {at} is not in the install; ignored")), + } + } + } + (t, p) => { + let same = matches!((&*t, p), (Value::Number(_), Value::Number(_)) | (Value::Bool(_), Value::Bool(_)) | (Value::String(_), Value::String(_))) + || matches!((&*t, p), (Value::Array(a), Value::Array(b)) if a.iter().chain(b).all(Value::is_number) && (a.is_empty() || a.len() == b.len())); + if !same { + ctx.warn(format!("tuning field {path}: {p} does not fit the install's {t}; ignored")); + return; + } + if ctx.claim(&format!("tuning:{path}")) { + *t = p.clone(); + } + } + } +} + +/// Merge one overlay into `m` (the manifest JSON). +pub fn merge_one(m: &mut Value, source: &Source, owners: &mut Owners, report: &mut Report) { + let mut ctx = Ctx { id: source.id, owners, report }; + let o = source.overlay; + let r = &o.replace; + // Samples by slot. + for (bank, slots) in &r.samples { + let len = m.get("banks").and_then(|b| b.get(bank)).and_then(Value::as_array).map_or(0, Vec::len); + if len == 0 { + ctx.warn(format!("replace.samples: bank {bank} is not in the install; ignored")); + continue; + } + let mut loop_rows = Vec::new(); + for (slot, file) in slots { + let i: usize = slot.parse().unwrap_or(usize::MAX); + if i >= len { + ctx.warn(format!("replace.samples: {bank} has no slot {slot} ({len} samples); ignored")); + continue; + } + if !ctx.claim(&format!("sample:{bank}:{i}")) { + continue; + } + m["banks"][bank][i] = json!({"file": ctx.file(file.file())}); + if let Some(l) = file.loop_start() { + loop_rows.push((i, l)); + } + } + loops(m, bank, loop_rows.into_iter()); + } + // Whole banks. + for (stem, b) in &r.banks { + if !has(m, &["banks", stem]) && !has(m, &["aems", "banks", stem]) { + ctx.warn(format!("replace.banks: {stem} is not in the install (use add.banks); ignored")); + continue; + } + if !ctx.claim(&format!("bank:{stem}")) { + continue; + } + if !b.samples.is_empty() { + let v = samples_json(&ctx, &b.samples); + section(m, "banks").insert(stem.clone(), v); + loops(m, stem, b.samples.iter().enumerate().filter_map(|(i, s)| Some((i, s.loop_start()?)))); + } + if let Some(abk) = &b.abk { + let v = ctx.file(abk); + sub(section(m, "aems"), "banks").insert(stem.clone(), v); + } + bank_meta(m, stem, b); + } + for (stem, b) in &o.add.banks { + if has(m, &["banks", stem]) || has(m, &["aems", "banks", stem]) { + ctx.warn(format!("add.banks: {stem} is already in the install (use replace.banks); ignored")); + continue; + } + if !ctx.claim(&format!("bank:{stem}")) { + continue; + } + let v = samples_json(&ctx, &b.samples); + section(m, "banks").insert(stem.clone(), v); + loops(m, stem, b.samples.iter().enumerate().filter_map(|(i, s)| Some((i, s.loop_start()?)))); + if let Some(abk) = &b.abk { + let v = ctx.file(abk); + sub(section(m, "aems"), "banks").insert(stem.clone(), v); + } + bank_meta(m, stem, b); + } + // Files by name. + for (stem, file) in &r.splice { + if !has(m, &["aems", "splice", stem]) { + ctx.warn(format!("replace.splice: {stem} has no Splice tree in the install; ignored")); + } else if ctx.claim(&format!("splice:{stem}")) { + m["aems"]["splice"][stem] = ctx.file(file); + } + } + for (name, g) in &r.grains { + if !has(m, &["grains", name]) { + ctx.warn(format!("replace.grains: {name} is not in the install; ignored")); + } else if ctx.claim(&format!("grain:{name}")) { + m["grains"][name]["file"] = ctx.file(&g.file); + m["grains"][name]["grain"] = ctx.file(&g.grain); + } + } + for (kind, rows) in [("wheels", &r.wheels), ("ambience", &r.ambience)] { + for (name, file) in rows { + if !has(m, &[kind, name]) { + ctx.warn(format!("replace.{kind}: {name} is not in the install; ignored")); + } else if ctx.claim(&format!("{kind}:{name}")) { + m[kind][name]["file"] = ctx.file(file); + } + } + } + if let Some(file) = &r.mixmap { + if !has(m, &["aems", "mixmap"]) { + ctx.warn("replace.mixmap: the install has no MixMap; ignored".into()); + } else if ctx.claim("mixmap") { + m["aems"]["mixmap"] = ctx.file(file); + } + } + // Emitter records. + for (file, recs) in &r.emitters { + for (idx, e) in recs { + let index: u64 = idx.parse().unwrap_or(u64::MAX); + let at = m.get("emitters").and_then(|x| x.get(file)).and_then(Value::as_array) + .and_then(|rows| rows.iter().position(|row| row.get("index").and_then(Value::as_u64) == Some(index))); + let Some(at) = at else { + ctx.warn(format!("replace.emitters: {file} has no record {idx}; ignored")); + continue; + }; + if !ctx.claim(&format!("emitter:{file}:{index}")) { + continue; + } + let old = m["emitters"][file][at].get("sound_id").and_then(Value::as_str).map(str::to_owned); + m["emitters"][file][at] = emitter_json(e, index, old.as_deref()); + } + } + for (file, recs) in &o.add.emitters { + let rows = section(m, "emitters").entry(file.clone()).or_insert_with(|| json!([])); + if !rows.is_array() { + *rows = json!([]); + } + let rows = rows.as_array_mut().unwrap(); + let mut next = rows.iter().filter_map(|r| r.get("index").and_then(Value::as_u64)).max().map_or(0, |i| i + 1); + for e in recs { + rows.push(emitter_json(e, next, None)); + next += 1; + } + } + // Location sets and zones. + for (sec, rows, add) in [("random_sets", &serde_json::to_value(&r.random_sets).unwrap(), false), ("zones", &serde_json::to_value(&r.zones).unwrap(), false), + ("random_sets", &serde_json::to_value(&o.add.random_sets).unwrap(), true), ("zones", &serde_json::to_value(&o.add.zones).unwrap(), true)] { + let what = if add { "add" } else { "replace" }; + for (key, def) in rows.as_object().into_iter().flatten() { + let found = resolve_key(m, sec, key); + let key = match (found, add) { + (Some(k), false) => k, + (None, true) => key.to_ascii_uppercase(), + (None, false) => { + ctx.warn(format!("{what}.{sec}: {key} is not in the install; ignored")); + continue; + } + (Some(k), true) => { + ctx.warn(format!("{what}.{sec}: {k} is already in the install (use replace); ignored")); + continue; + } + }; + if ctx.claim(&format!("{sec}:{key}")) { + let mut def = def.clone(); + def.as_object_mut().unwrap().retain(|_, x| !x.is_null()); + // A replacement keeps the record's name unless it gives one (the name is how + // maps and other mods find it). + if let Some(name) = m.get(sec).and_then(|s| s.get(&key)).and_then(|r| r.get("name")).cloned() { + def.as_object_mut().unwrap().entry("name").or_insert(name); + } + section(m, sec).insert(key, def); + } + } + } + // Crossfades by zone pair. + for (c, add) in r.crossfades.iter().map(|c| (c, false)).chain(o.add.crossfades.iter().map(|c| (c, true))) { + let at = crossfade_at(m, &c.from, &c.to); + let row = json!({"from": c.from.to_ascii_uppercase(), "to": c.to.to_ascii_uppercase(), "group": c.group, "level": c.level}); + match (at, add) { + (Some(i), false) => { + if ctx.claim(&format!("crossfade:{}", pair(&c.from, &c.to))) { + m["crossfades"][i] = row; + } + } + (None, true) => { + if ctx.claim(&format!("crossfade:{}", pair(&c.from, &c.to))) { + let rows = m.as_object_mut().unwrap().entry("crossfades").or_insert_with(|| json!([])); + if let Some(a) = rows.as_array_mut() { + a.push(row); + } + } + } + (None, false) => ctx.warn(format!("replace.crossfades: no crossfade {} ↔ {} in the install; ignored", c.from, c.to)), + (Some(_), true) => ctx.warn(format!("add.crossfades: {} ↔ {} is already in the install (use replace); ignored", c.from, c.to)), + } + } + // Speech takes (the engine checks the clips against the speech index). + for (archive, clips) in &r.speech { + for (clip, takes) in clips { + let clip = clip.strip_suffix(".dat").unwrap_or(clip); + for (take, file) in takes { + if !ctx.claim(&format!("speech:{archive}:{clip}:{take}")) { + continue; + } + let v = ctx.file(file); + sub(sub(sub(section(m, "mod_speech"), archive), "takes"), clip).insert(take.clone(), v); + } + } + } + for (archive, clips) in &o.add.speech { + for (clip, takes) in clips { + let clip = clip.strip_suffix(".dat").unwrap_or(clip); + let files: Vec<Value> = takes.iter().map(|f| ctx.file(f)).collect(); + let extra = sub(sub(section(m, "mod_speech"), archive), "extra"); + let row = extra.entry(clip.to_owned()).or_insert_with(|| json!([])); + if let Some(a) = row.as_array_mut() { + a.extend(files); + } + } + } + for (bank, layers) in &o.add.location_programs { + if ctx.claim(&format!("program:{bank}")) { + let rows: Vec<Value> = layers.iter().map(|l| json!({ + "delay": l.delay, + "sample": match &l.sample { LayerSample::Slot(s) => json!(s), LayerSample::Named(_) => json!("shuffle") }, + "level": l.level, "pan_sweep": l.pan_sweep, "looping": l.looping, + })).collect(); + section(m, "mod_location_programs").insert(bank.clone(), Value::Array(rows)); + } + } + // Tuning field merges. + for (name, patch) in [("player_tuning", &o.tuning.player), ("world_tuning", &o.tuning.world), ("bus_tuning", &o.tuning.bus), ("grain_player", &o.tuning.grain)] { + let Some(patch) = patch else { continue }; + let Some(target) = m.get_mut(name) else { + ctx.warn(format!("tuning: the install has no {name}; ignored")); + continue; + }; + let mut target = std::mem::take(target); + merge_tuning(&mut ctx, &mut target, patch, name); + m[name] = target; + } + // Map audio. + for (stem, def) in &o.maps { + let fields: [(&str, Option<Value>); 4] = [ + ("district", def.district.as_ref().map(|v| json!(v))), + ("ems", def.ems.as_ref().map(|v| json!(v))), + ("crossfade_bank", def.crossfade_bank.as_ref().map(|v| json!(v))), + ("fallback_bed", def.fallback_bed.as_ref().map(|v| json!(v))), + ]; + for (field, value) in fields { + if let Some(value) = value { + if ctx.claim(&format!("map:{stem}:{field}")) { + sub(section(m, "mod_maps"), stem).insert(field.to_owned(), value); + } + } + } + let map = sub(section(m, "mod_maps"), stem); + if !def.emitters.is_empty() { + let rows = map.entry("emitters").or_insert_with(|| json!([])); + let start = rows.as_array().map_or(0, Vec::len) as u64; + if let Some(a) = rows.as_array_mut() { + a.extend(def.emitters.iter().enumerate().map(|(i, e)| emitter_json(e, start + i as u64, None))); + } + } + if !def.regions.is_empty() { + let regions = sub(map, "regions"); + for (layer, boxes) in &def.regions { + let rows = regions.entry(layer.clone()).or_insert_with(|| json!([])); + if let Some(a) = rows.as_array_mut() { + a.extend(boxes.iter().map(|b| serde_json::to_value(b).unwrap())); + } + } + } + } +} + +/// Merge every overlay in order. +pub fn merge(m: &mut Value, sources: &[Source]) -> Report { + let mut owners = Owners::default(); + let mut report = Report::default(); + for s in sources { + merge_one(m, s, &mut owners, &mut report); + } + report +} + +#[cfg(test)] +mod tests { + use super::*; + + fn install() -> Value { + json!({ + "version": 5, + "ambience": {"04_dt_main": {"file": "ambience/04_dt_main.wav", "seconds": 100.0}}, + "grains": {"wood_ramp_hard": {"file": "grains/w.wav", "grain": "grains/w.grain"}}, + "wheels": {"Whls_spins_Jump_1": {"file": "wheels/j.wav"}}, + "banks": {"Skate_Collisions": [{"file": "banks/c/0.wav"}, {"file": "banks/c/1.wav"}], "C04_taxi01": [{"file": "banks/t/0.wav"}]}, + "emitters": {"sfx_downtown": [{"index": 9, "flags": 0, "position": [0, 0, 0], "extent": [1, 1, 1], "scalars": [0, 1, 0, 0], "sound_id": "A0C12188AFCC33E0", "bank": "Baby_Cry_1"}]}, + "random_sets": {"7EE1991E4909C50E": {"name": "e_dwtn_spillway_brewery", "sounds": []}}, + "zones": {"DB21C7A69325F3DF": {"name": "int_tunnel", "bed": "22_interior_tunnel_amb"}}, + "crossfades": [{"from": "1F741D87EB58E84F", "to": "B6CE4BDEB63B6639", "group": 1, "level": 1.5}], + "aems": {"projects": ["aems/a.csi"], "banks": {"C04_taxi01": "aems/C04_taxi01.abk"}, "mixmap": "aems/MixMapSK8.mxb", "splice": {"Skate_Collisions": "aems/c.splc"}}, + "player_tuning": {"grind": [{"v": [1, 1, 1, 1], "f": [1, 1, 1, 1]}], "wheel_bucket_high": 3.0}, + "world_tuning": {"traffic_engine": {"c04_taxi01": {"idle_rpm": 850.0, "patch": 2}}} + }) + } + + fn overlay(v: Value) -> AudioOverlay { + let o: AudioOverlay = serde_json::from_value(v).unwrap(); + o.validate().unwrap(); + o + } + + #[test] + fn no_overlays_leave_the_manifest_unchanged() { + let mut m = install(); + let r = merge(&mut m, &[]); + assert_eq!(m, install()); + assert!(r.conflicts.is_empty() && r.warnings.is_empty()); + let empty = overlay(json!({"version": 1})); + merge(&mut m, &[Source { id: "a", overlay: &empty }]); + assert_eq!(m, install(), "an empty overlay changes nothing"); + } + + #[test] + fn every_section_merges_by_identity() { + let o = overlay(json!({"version": 1, + "replace": { + "samples": {"Skate_Collisions": {"1": {"file": "audio/pop.wav", "loop_start": 5}}}, + "banks": {"C04_taxi01": {"abk": "audio/taxi.abk", "samples": ["audio/t0.wav"], "group": "world"}}, + "splice": {"Skate_Collisions": "audio/c.splc"}, + "grains": {"wood_ramp_hard": {"file": "audio/w.wav", "grain": "audio/w.grain"}}, + "wheels": {"Whls_spins_Jump_1": "audio/j.wav"}, + "ambience": {"04_dt_main": "audio/bed.wav"}, + "mixmap": "audio/m.mxb", + "emitters": {"sfx_downtown": {"9": {"position": [1, 2, 3], "extent": [4, 4, 4], "bank": "MOD_x"}}}, + "random_sets": {"e_dwtn_spillway_brewery": {"sounds": [{"bank": "MOD_x"}]}}, + "zones": {"db21c7a69325f3df": {"bed": "04_dt_main"}}, + "crossfades": [{"from": "B6CE4BDEB63B6639", "to": "1F741D87EB58E84F", "group": 3}], + "speech": {"livingworld": {"c1.dat": {"2": "audio/s.wav"}}} + }, + "add": { + "banks": {"MOD_x": {"abk": "audio/x.abk", "samples": ["audio/x0.wav"], "preload": true}}, + "emitters": {"sfx_downtown": [{"position": [0, 0, 0], "extent": [2, 2, 2], "bank": "MOD_x"}], "sfx_new": [{"position": [0, 0, 0], "extent": [2, 2, 2], "bank": "MOD_x"}]}, + "random_sets": {"00000000000000AA": {"sounds": [{"bank": "MOD_x"}]}}, + "crossfades": [{"from": "00000000000000AA", "to": "00000000000000BB", "group": 2}], + "speech": {"livingworld": {"c1": ["audio/e.wav"]}}, + "location_programs": {"MOD_x": [{"sample": "shuffle"}]} + }, + "tuning": {"player": {"grind": {"0": {"v": [0.5, 0.5, 0.5, 0.5]}}, "wheel_bucket_high": 4}, "world": {"traffic_engine": {"c04_taxi01": {"idle_rpm": 900}}}}, + "maps": {"MyMap": {"district": "DownTown", "emitters": [{"position": [0, 0, 0], "extent": [2, 2, 2], "bank": "MOD_x"}], + "regions": {"audio_ambience": [{"box": [0, 0, 5, 5], "key": "int_tunnel"}]}}} + })); + let mut m = install(); + let r = merge(&mut m, &[Source { id: "me", overlay: &o }]); + assert!(r.conflicts.is_empty(), "{:?}", r.conflicts); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert_eq!(m["banks"]["Skate_Collisions"][1]["file"], "mod:me/audio/pop.wav"); + assert_eq!(m["banks"]["Skate_Collisions"][0]["file"], "banks/c/0.wav", "other slots stay"); + assert_eq!(m["mod_sample_loops"]["Skate_Collisions"]["1"], 5); + assert_eq!(m["aems"]["banks"]["C04_taxi01"], "mod:me/audio/taxi.abk"); + assert_eq!(m["mod_banks"]["C04_taxi01"]["group"], "world"); + assert_eq!(m["aems"]["banks"]["MOD_x"], "mod:me/audio/x.abk"); + assert_eq!(m["mod_banks"]["MOD_x"]["preload"], true); + assert_eq!(m["aems"]["splice"]["Skate_Collisions"], "mod:me/audio/c.splc"); + assert_eq!(m["grains"]["wood_ramp_hard"]["grain"], "mod:me/audio/w.grain"); + assert_eq!(m["wheels"]["Whls_spins_Jump_1"]["file"], "mod:me/audio/j.wav"); + assert_eq!(m["ambience"]["04_dt_main"]["file"], "mod:me/audio/bed.wav"); + assert_eq!(m["ambience"]["04_dt_main"]["seconds"], 100.0, "other fields stay"); + assert_eq!(m["aems"]["mixmap"], "mod:me/audio/m.mxb"); + let rows = m["emitters"]["sfx_downtown"].as_array().unwrap(); + assert_eq!((rows[0]["index"].as_u64(), rows[0]["bank"].as_str(), rows[0]["sound_id"].as_str()), (Some(9), Some("MOD_x"), Some("A0C12188AFCC33E0"))); + assert_eq!(rows[1]["index"], 10, "appended after the last index"); + assert_eq!(m["emitters"]["sfx_new"][0]["index"], 0); + assert_eq!(m["random_sets"]["7EE1991E4909C50E"]["sounds"][0]["bank"], "MOD_x", "by name"); + assert_eq!(m["zones"]["DB21C7A69325F3DF"]["bed"], "04_dt_main", "by key, any case"); + assert!(m["random_sets"]["00000000000000AA"].is_object()); + assert_eq!(m["crossfades"][0]["group"], 3, "either order"); + assert_eq!(m["crossfades"].as_array().unwrap().len(), 2); + assert_eq!(m["mod_speech"]["livingworld"]["takes"]["c1"]["2"], "mod:me/audio/s.wav"); + assert_eq!(m["mod_speech"]["livingworld"]["extra"]["c1"][0], "mod:me/audio/e.wav"); + assert_eq!(m["mod_location_programs"]["MOD_x"][0]["sample"], "shuffle"); + assert_eq!(m["player_tuning"]["grind"][0]["v"], json!([0.5, 0.5, 0.5, 0.5])); + assert_eq!(m["player_tuning"]["grind"][0]["f"], json!([1, 1, 1, 1])); + assert_eq!(m["player_tuning"]["wheel_bucket_high"], 4); + assert_eq!(m["world_tuning"]["traffic_engine"]["c04_taxi01"]["idle_rpm"], 900); + assert_eq!(m["mod_maps"]["MyMap"]["district"], "DownTown"); + assert_eq!(m["mod_maps"]["MyMap"]["regions"]["audio_ambience"][0]["key"], "int_tunnel"); + assert!(r.claimed.keys().any(|k| k == "sample:Skate_Collisions:1")); + } + + #[test] + fn the_first_mod_wins_and_later_claims_are_conflicts() { + let a = overlay(json!({"version": 1, "replace": {"samples": {"Skate_Collisions": {"1": "a.wav"}}, "ambience": {"04_dt_main": "a.wav"}}})); + let b = overlay(json!({"version": 1, "replace": {"samples": {"Skate_Collisions": {"1": "b.wav", "0": "b.wav"}}, "banks": {"Skate_Collisions": {"samples": ["b.wav"]}}}})); + let mut m = install(); + let r = merge(&mut m, &[Source { id: "a.first", overlay: &a }, Source { id: "b.second", overlay: &b }]); + assert_eq!(m["banks"]["Skate_Collisions"][1]["file"], "mod:a.first/a.wav"); + assert_eq!(m["banks"]["Skate_Collisions"][0]["file"], "mod:b.second/b.wav", "an unclaimed slot goes to the second mod"); + assert_eq!(m["banks"]["Skate_Collisions"].as_array().unwrap().len(), 2, "the whole-bank replace lost to a's slot"); + assert_eq!(r.conflicts.len(), 2, "{:?}", r.conflicts); + assert!(r.conflicts.iter().all(|c| c.owner == "b.second" && c.text.contains("a.first"))); + } + + #[test] + fn unknown_identities_are_warnings_never_errors() { + let o = overlay(json!({"version": 1, + "replace": {"samples": {"Nope": {"0": "x.wav"}, "Skate_Collisions": {"7": "x.wav"}}, "banks": {"Nope": {"samples": ["x.wav"]}}, + "zones": {"no_zone": {}}, "emitters": {"sfx_downtown": {"3": {"position": [0, 0, 0], "extent": [1, 1, 1], "bank": "b"}}}}, + "add": {"banks": {"C04_taxi01": {"samples": ["x.wav"]}}, "random_sets": {"7EE1991E4909C50E": {"sounds": []}}}, + "tuning": {"player": {"nope": 1, "wheel_bucket_high": "loud", "grind": {"5": {"v": [1, 1, 1, 1]}}}}})); + let mut m = install(); + let r = merge(&mut m, &[Source { id: "x", overlay: &o }]); + assert_eq!(r.warnings.len(), 10, "{:#?}", r.warnings); + assert!(r.conflicts.is_empty()); + assert_eq!(m, install(), "nothing applied"); + } + + #[test] + fn mod_references_round_trip() { + assert_eq!(split_mod_ref(&mod_ref("dev.x", "audio/a.wav")), Some(("dev.x", "audio/a.wav"))); + assert_eq!(split_mod_ref("banks/a.wav"), None); + } +} diff --git a/crates/skate-mods/src/lib.rs b/crates/skate-mods/src/lib.rs index 52dc1855..f5941e75 100644 --- a/crates/skate-mods/src/lib.rs +++ b/crates/skate-mods/src/lib.rs @@ -1,6 +1,8 @@ //! Lua mod packages (API 2): low-level physics/graphics hooks, no Vehicle class. mod archive; pub mod audio; +pub mod audio_content; +pub mod audio_merge; pub mod world_audio; pub mod graphics_dynamic; pub mod presentation; @@ -12,7 +14,7 @@ mod schema; mod vm; pub mod extensions; -pub use archive::{read_bounded, validate_package, Cache}; +pub use archive::{read_bounded, validate_package, validate_package_content, Cache}; pub use assets::convex_points_file; pub use model::model_shape_file; pub use query::{with_host, DynamicsHost, RaycastFilter, RaycastOptions}; diff --git a/crates/skate-mods/src/vm.rs b/crates/skate-mods/src/vm.rs index 8de90190..cc4dce13 100644 --- a/crates/skate-mods/src/vm.rs +++ b/crates/skate-mods/src/vm.rs @@ -950,6 +950,12 @@ impl Vm { capabilities.set("capture", 1)?; capabilities.set("multiplayer_debug", 1)?; capabilities.set("world_audio", 1)?; + // Audio extension 1: the mod's own WAVs (`sdk.audio.preload / play / update / stop / + // stop_all`); before 2026-10-04 only `sdk.audio.version` advertised it. + capabilities.set("audio", 1)?; + // Audio content overlays (`audio.json`: replace / add retail audio content by identity; + // `audio_content.rs`), applied while the mod runs. + capabilities.set("audio_content", 1)?; sdk.set("_native_capabilities", capabilities)?; sdk.set("mod_id", manifest.id.clone())?; sdk.set( @@ -1745,6 +1751,29 @@ mod world_audio_tests { use super::*; use serde_json::json; + /// Both audio extensions are advertised in `sdk.capabilities` (feature discovery, as for the + /// other extensions), and `sdk.audio.version` still reads 1. + #[test] + fn audio_capabilities_are_advertised() { + let root = std::env::temp_dir().join(format!("skate-audio-capabilities-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("main.lua"), r#" + assert(sdk.capabilities.audio == 1, 'audio capability') + assert(sdk.capabilities.world_audio == 1, 'world_audio capability') + assert(sdk.capabilities.audio_content == 1, 'audio_content capability') + assert(sdk.audio.version == 1 and sdk.world_audio.version == 1, 'versions') + return {} + "#).unwrap(); + let manifest: Manifest = serde_json::from_value(json!({ + "id":"tests.audio-capabilities","api":2,"name":"Audio capabilities","version":"1.0.0", + "author":"test","description":"test","entry":"main.lua","settings":{} + })).unwrap(); + let loaded = Vm::new(&root, &manifest, &BTreeMap::new(), &Value::Null).map(|_| ()); + std::fs::remove_file(root.join("main.lua")).unwrap(); + std::fs::remove_dir(&root).unwrap(); + loaded.unwrap(); + } + /// The world audio wrappers cross the serde boundary, and the bundled dev test publisher /// (`mods/world-audio-test`) runs frames without a Lua error or an invalid command, within the /// 128-commands-per-callback limit. diff --git a/docs/hails-additions/16-audio-modding.md b/docs/hails-additions/16-audio-modding.md index a4effc99..718128f8 100644 --- a/docs/hails-additions/16-audio-modding.md +++ b/docs/hails-additions/16-audio-modding.md @@ -1,13 +1,13 @@ -# Audio moddability pass (PR #32 final pass): design +# Audio modding: design and research -Status: DESIGN (2026-10-03; nothing implemented yet). This is the plan for the audio modding PR. Builds on doc 15 / -`audio-specs/world-audio-hookin-spec.md` §8 (the world-object surface, done) -and `aems-evaluator.md` "Modular and moddable" (the 2026-10-02 goals). Standing rule (memory audio-moddable): -every audio feature must be moddable with the rest of the engine (Lua SDK, content layer), engine-hookable and -data-driven. +Status: RESEARCH DONE, R0 in progress (2026-10-04); nothing else implemented yet. This is the plan for the audio +modding PR (draft upstream #36), which builds on #32 (the native audio engine) and doc 15 (the world-object surface, +done). Goal: every audio feature is moddable with the rest of the engine (Lua SDK, content layer), hookable by +engine systems and data-driven, while retail stays the default and byte-identical without mods. -Line numbers are from the working tree on 2026-10-03, while `game_audio` was being edited; they drift, so -each reference also names the item. +Sections 0–3 are the design (2026-10-03). Section 4 holds the research for the scope chosen on 2026-10-03: prior art, +the engine facts each phase needs (file:line on this branch, base a4ec831), answers to the open questions and the +refined plan. Line numbers drift; each reference also names the item. --- @@ -42,7 +42,7 @@ Mod surface today (all features): - `sdk.world_audio.spawn / update / event / remove / read / info` (capability `world_audio` = 1, `skate-mods/src/world_audio.rs`, `modding/world_audio.rs`): traffic, peds, skaters (lite or ghost), horn / alarm / speech events. 48 objects per mod, 128 in all. -- **Gap found:** `sdk/skate.lua` (the language-server declarations) has **no `sdk.audio.*` entries**; they exist +- **Gap found (fixed in R0, 2026-10-04):** `sdk/skate.lua` (the language-server declarations) had **no `sdk.audio.*` entries**; they exist only in `skate-mods/src/api.lua:266–288`. `sdk.audio` also has no capability entry (only `sdk.audio.version = 1`). `sdk/AGENTS.md`, `GENERAL_API.md` and `ENGINE_API.md` don't mention audio. - No mod can replace or add retail content, post retail sounds, read or write globals or the MixMap, change tuning, @@ -400,16 +400,206 @@ emitters, native WAV routing and rules would follow. ### Risks (overall) - **Retail parity stays the default:** the overlay and rules are empty without mods, and every new path is gated on - "some mod uses it". The byte-identical e2e proof is required per phase (memory: optimise / change only with proof). + "some mod uses it". The byte-identical e2e proof is required per phase. - **The runtime restart** is the riskiest new mechanism. It reuses the map-change epoch reset (already tested) but also restarts the stream and the bed; R1 needs a test that a restart with an empty overlay gives byte-identical output from that point on. - **Mod posts shift the shared RNG:** documented, mod-only. - **Content licensing:** overlays reference retail identities, not retail data. Mods ship their own audio, and - nothing copied from the game goes into the repo (memory: no copied game content). Example mods use self-made + nothing copied from the game goes into the repo. Example mods use self-made sounds. - **Concurrent edits:** the files are the same as the ongoing world / speech work and the optimisation pass, so this - runs sequentially after them (pr32-ready order). + runs after them (#32 first). + +## 4. Research for this PR (2026-10-04) + +File:line references are this branch (code base a4ec831). + +### 4.1 Prior art + +- **Upstream and forks:** there is no earlier mod content-override or audio-modding work upstream (PRs and issues up + to #36) or in the active forks. The closest precedent in the engine is `custom_difficulty.rs`, which overlays + user values onto collections by hash identity. +- **Other games** (design ideas only, from their public documentation): + +| project | mechanism | what this design takes from it | +|---|---|---| +| Minecraft resource packs | `sounds.json`: packs merge per sound event; `"replace": true` drops lower packs' entries | `replace` / `add` sections merged per identity | +| Source engine | `maps/<map>_level_sounds.txt` overrides sound scripts for one map | a per-map sidecar for custom maps | +| Garry's Mod | the `EntityEmitSound` hook sees every sound; it can suppress or modify it | observe in Lua now; suppress / replace later, as declarative native rules (Lua can't run inside the audio pass) | +| Factorio | data stage vs control stage; changing mods needs a restart | data overlay ≠ run-time API; a restart on a content change is accepted practice | +| Bethesda games, GZDoom | records by stable id or logical name, load order decides, conflict reports | stable identities; the winner and the loser are reported | +| RimWorld | patch operations on single fields of XML defs | field-level tuning merges | +| FMOD / Wwise mods, FiveM | extra banks registered beside the game's; data by hashed names | mod banks bound to an existing class; 16-hex keys | + +### 4.2 Engine facts per phase + +**R1 content overlay** +- *Shape:* `Library` (`game_audio/library.rs`) holds the install manifest (`Manifest` :298–333: ambience, grains, + wheels, banks = WAVs by S10A slot, emitters, random_sets, zones, crossfades, regions, `aems` (projects, banks, + mixmap, splice), grain / player / bus / world tuning). Unknown sections are ignored. Versions 3..=5 are accepted + (:15). +- *Readers:* + - every file is read through `Library::load` (:837), `clip` (:860, Bevy voices, cache keyed by relative path), + `read` (:1005), `wheels_pcm` (:832), `splice_bank` (:1011), `bank_pcm` (:1020) and `bank_source` (:1027 → + `BankSource`, one root, decoded on the prefetch worker); + - their consumers are `native::start_with` (native.rs:232–333), `ensure_bank` (:338), emitters, reverb zones, + ambience, location sets, the grain bed and the world / NPC hosts. +- *Merge point:* `Library::load_with(asset_root, overlays)`: the install manifest, then the overlays in mod-id order, + with the first owner of an identity winning. Each file becomes an absolute path from its root (install or mod). + This touches three places: + - `BankSource` needs per-file paths, since a retail bank with one replaced WAV mixes roots; + - the clip cache key must include the root; + - replaced AEMS samples need new S10A headers. Voices take frames, rate, channels and loop start from the `.abk` + header (mixer.rs:129–174, 385–391; `Runtime::load_bank` runtime.rs:80–85), so a replacement WAV of another + length or rate would play wrong. Splice banks and the wheel streams already build their headers from the PCM + (splice/mod.rs:209–221). +- *Validation:* + - `deny_unknown_fields` schema; + - mod paths through `read_bounded` (skate-mods archive.rs:150); + - WAVs through `canonical_pcm_wav` (skate-mods audio.rs:76–139: PCM16, 1–2 channels, 8–48 kHz, ≤ 30 s, ≤ 8 MiB). + Ambience beds need a larger per-kind cap; + - `.abk` / `.splc` / `.mxb` through the skate-audio parsers; + - unknown identities are a warning, never an error. +- *Conflicts:* + - The existing "owned by another mod" cases are run-time claims that fail the second mod (engine_access.rs:71, + modding/mod.rs:130 / 943, player_physics.rs:26 / 567, attachment.rs:19). + - Static overlay conflicts need a new persistent list that the mod menu shows. `Manager.diagnostics` is cleared on + every package scan (skate-mods lib.rs:106). +- *Lifecycle:* + - The overlay is package state: active while the mod runs. + - It changes on start, disable, reload (a package fingerprint change also reloads, so editing a file hot-reloads) + and failure (the `retired` list, modding/mod.rs:797ff). + - It survives map changes: `clear_runtime` (mod.rs:734) clears only run-time state. + - Several changes in one frame cause one restart. +- *Boot order:* `Library` loads at Startup and the native runtime at PostStartup, but mods are first scanned on frame + 1. Starting the native runtime after the first scan avoids a second start when an audio mod is on at boot. +- *Restart* (on a change of the active overlay set): + - rebuild `Library`; + - despawn the stream's player (the sink holds the runtime); + - start a new `Native` and spawn a new stream. + - Two details are required for correctness: + - The new evaluator continues the old node counter (`next_node` starts at 1, eval/mod.rs:174), and `map_epoch` + becomes old + 1 (a new `Native` starts at 0). The world and NPC hosts release their held nodes when the epoch + changes (world_sources.rs:309, npc_skaters.rs:105). With a restarted counter those stale ids would release + the new runtime's live posts (node 1 is the `c_emitter_utility` boot post). Without the epoch bump, hosts + that saw epoch 0 would never reset. + - The emitters, reverb zones, ambience and location sets rebuild only when `(map name, generation)` changes + (emitters.rs:113 / 316, ambience.rs:108), so an audio-content generation joins that identity. Nodes held + from the old runtime are forgotten, not released. +- *check_mod:* today it validates only `mod.json` and the Lua syntax (`validate_package`, archive.rs:124). It gains an + `audio.json` pass (schema, paths, WAV / format parsing, budgets) and a summary. With an install path, it also + reports unknown identities and conflicts with other mods. +- *Speech:* the speech index and takes are not on this branch yet (they come with #32's world speech). The speech + overlay and the speech event site follow when that lands here. + +**R2 map audio as data** +- Retail keeps it in the stock collections (`skater-collections.json`, present in every install): + - class `F4917ACACAFAF913`: 11 district records plus `default`; + - field `65FA976EF23A314E` = the `.ems` files, e.g. dist_university: music_ / sfx_ / reverb_ / speakers_ / + crowds_university; each park one `sfx_` file; skateschool `skateschool.ems`; + - field `33526BC9D1C36B4A` = the crossfade bank (only downtown, industrial, university). + - The `world` rows point at that record through field `99D6E51C9E20A663`. Park variants (full / empty / tutorial) + share their park's record. DLC rows point at the empty `default`. + - A map file `<stem>.skate` comes from `DIST_<stem>` (install.py:263–284). World rows carry `WorldStream = + DIST_<stem>`, and setup's spawn stage already joins them that way (map_starts.py:58–75). +- Two ways in: + - (A) a setup export: a `maps` table in the audio manifest (`audio_export.convert`). This takes one audio-group + refresh. Leave the manifest version at 5: the table is optional, and a version bump would make older engines + refuse the whole manifest. + - (B) a run-time lookup in the collections the game already loads (`skate_data::collections::Collections`). No + refresh. + - Either way the current code tables (`ems_file` / `ems_files` emitters.rs:35–74, `crossfade_bank` + ambience.rs:45) stay as the test oracle for the 10 maps. +- Custom maps today: + - `.skate` files in `<install>/maps` and `maps/private` (map_library.rs:10–29). + - Audio keys everything on the file stem, so a custom map gets no emitters, zones, location sets, regions or + crossfades. Surface audio (rolling, footsteps) works, since materials carry an audio surface id. + - SKATE v12+ files have tagged extensions (`{tag, schema, payload}`, skate_map.rs:119; `WMET`, `RWCM` in use), + so an embedded audio extension is an alternative to a sidecar. + - Mods cannot ship maps. + +**R3(a) posts, globals, MixMap read** +- *API:* + - `Runtime::post / redeliver / release` (runtime.rs:92–114); + - `Evaluator::class_id / function_id / global_id` (eval/mod.rs:249–262); + - `post` always succeeds, even with no bound bank; `release` is harmless on a dead node; + - `set_global` notifies subscribers only on a change (:458); `global` (:471). +- *Threading:* + - The runtime is `Arc<Mutex<Runtime>>`. The audio thread renders one block per lock and the evaluator ticks + there. The game thread posts between blocks. + - The MixMap is game-thread state in `Native`, without a lock. Reads: `level / filter_hz / raw / pitch_4096` + (mixmap/mod.rs:426–461). + - Mod commands apply in `modding::apply`, which is unordered against `native::mixmap_frame`. Posts are therefore + queued and drained at a fixed point of the host pass. +- *Shared RNG:* every program draws from one generator (eval/mod.rs:143). A mod post that draws shifts every later + retail draw. This is expected and only happens while a mod posts. +- *`c_emitter`:* its payload is MixMap-derived and needs an emitter state (`Native::emitter_payload` native.rs:507). + A raw post is possible but unpositioned; positional mod emitters are the later `world_audio` kinds. +- *Limits and cleanup:* + - handles per mod; + - released on retire (beside `world_audio::clear_owner`, modding/mod.rs:805 / 878 / 904) and on a map change + (`clear_runtime`); + - a handle is dead after a map change or a restart and is never released by its stale id; + - globals: original saved at the first write, restored on retire. +- *MixMap view:* the snapshot copies only the controllers mods asked for. A world object's MixMap instance comes from + `sdk.world_audio.read(key).instance`. + +**R5 observe-only events** +- The post sites are: + 1. `PlayerAudio::apply` (player_audio.rs:261, under the runtime lock); + 2. Splice starts through `rt.splice_host()` (player_audio.rs:207 / 397, npc_skaters.rs:241 / 268). A game-side + wrapper keeps skate-audio unchanged; + 3. the world host's apply (world_sources.rs:256); + 4. the NPC host's own apply (npc_skaters.rs:74); + 5. the world emitters' `c_emitter` post (emitters.rs:407), plus zone / set changes; + 6. speech, later (see R1). +- *Zero cost:* an optional sink that exists only while a running mod subscribes; each site checks it once. Proof: the + e2e hashes and bench timings. +- *Delivery:* the mod snapshot is built once a frame (`snapshot_ro`, modding/mod.rs:458), unordered against the audio + pass, so the rows are double-buffered and arrive one frame late. They are filtered per mod by its subscription, + ≤ 256 a frame with a truncation flag. + +**R6 examples and docs** +- The upstream Skyline mod commits self-made WAVs together with the `synthesize.py` that makes them. The examples + can do the same. +- `*.abk` is gitignored repo-wide, so examples prefer sample replacement and binding to existing classes. + +### 4.3 Open questions (options) + +1. **Mod emitters vs retail's 5 emitter slots** (a later phase): + - share them (retail's first-reached rule); + - or add extra MixMap Emitter instances only while a mod has emitters (instances 0–4 unchanged). +2. **Retail map list:** a setup export (one audio refresh) or the run-time collections lookup (no refresh). Both + reproduce today's tables. +3. **Custom-map audio:** + - Options: a sidecar `<map>.audio.json` next to the `.skate`, an audio extension inside the `.skate`, or both. + - Content either way: retail identities only (`.ems` files by name, emitter and reverb-zone records, simple box + regions for zone ambience / location sets / reverb, crossfade bank). WAVs come from mods. +4. **Example mods:** dev-only, or one small example shipped under `sdk/examples/` with synthesized sounds. +5. **When an overlay is active:** while the mod runs (a Lua failure removes it), or while it is enabled. +6. **Boot order:** start the native audio after the first mod scan, or accept one restart at boot. +7. **Speech:** ship the overlay without speech lines first, and add them when #32's speech lands. +8. **check_mod depth:** skate-mods may depend on skate-audio (pure Rust) so `check_mod` parses banks, Splice trees and + the MixMap. +9. **Mute / replace rules:** later; declarative native rules. + +### 4.4 Refined plan + +| step | content | +|---|---| +| R0 | `sdk.audio.*` declared in `sdk/skate.lua`, the `audio` capability, `GENERAL_API.md`. e2e identity reference: the branch's headless e2e bench (13 scenarios + 8 whole sessions, row and 300 fps modes) is byte-identical to the base commit's | +| R1a | overlay types and validation (skate-mods), `check_mod` pass | +| R1b | `Library::load_with`, per-file roots, clip cache key, header rebuild for replaced AEMS samples; test `load_with(root, &[]) == load(root)` | +| R1c | active-overlay tracking, conflict list in the mod menu | +| R1d | restart: continued node counter, epoch + 1, content generation in the map identities, stream respawn, prefetch shutdown, native start after the first scan; test: a restart with an empty overlay renders like a fresh run | +| R1e | overlay-owned banks survive map changes (the keep-list in `Native::unload_map_banks`) | +| R2 | `MapAudio` per map (collections or manifest), sidecar, code tables as the oracle | +| R3(a) | queued posts, epoch / restart-safe handles, globals restore, catalog, MixMap watch list | +| R5 | optional sink at the five sites, double buffer, tags tested against real posts | +| R6 | doc 16, SDK docs, `make-mod`, examples | + +Every step keeps the no-mod e2e output byte-identical to the R0 reference. ## Decisions (2026-10-03) @@ -417,4 +607,4 @@ emitters, native WAV routing and rules would follow. - **Turning an audio mod on or off** restarts the native audio (a short cut, as on a map change). - **Mod WAVs through the native mixer:** opt-in per sound (`native = true`); existing mods behave as before. - **Two mods replacing the same sound:** the first by mod id wins, with a warning in the mod menu. -- **Open:** mod emitters' slots, mute / replace rules, a `<map>.audio.json` sidecar for custom maps (and the one audio setup refresh the retail map list then needs), whether the example mods ship. +- **Open:** see §4.3 (mod emitters' slots, the retail map list's source, the custom-map audio format, example mods, when an overlay is active, boot order, speech timing, check_mod depth, mute / replace rules). diff --git a/docs/hails-additions/README.md b/docs/hails-additions/README.md index 1ac75e89..1ee817eb 100644 --- a/docs/hails-additions/README.md +++ b/docs/hails-additions/README.md @@ -22,7 +22,7 @@ Each document stands alone. File paths are relative to the repository root. | 14 | [Published development and research tools](14-published-tools.md) | Tooling (`tools/<name>/`, Python and shell) | Done (uncommitted); scripts compile and print their usage; the two `recomp-*` folders are for the recomp's research hooks, reference only | | 15 | [Hooking up world audio](15-world-audio.md) | Game audio (Rust), mod API (Lua) | Part of #32 (uncommitted). Engine-facing components (`TrafficAudio`, `PedAudio`, `NpcSkaterAudio`) and `sdk.world_audio` for mods feed the ported retail traffic / ped / NPC-skater audio with retail's limits; the map-change bug fixed; a dev test mod (cars, peds, a ghost skater) makes it audible now | | — | [Audio specs](audio-specs/README.md) | Reference for 11 and 15 (Markdown) | Published 2026-10-03: the behavioural specs (AEMS evaluator, voice graph, buses, MixMap, grain player, player components, world / NPC audio, prior work) that docs 11 / 15 and the code comments cite by section | -| 16 | [Audio modding](16-audio-modding.md) | Mods (Lua API 2), native audio (Rust) | Design for the audio modding PR (draft): content overlay, map audio as data, posting retail sounds and globals, audio events. Depends on #32. | +| 16 | [Audio modding](16-audio-modding.md) | Mods (Lua API 2), native audio (Rust) | Design and research for the audio modding PR (draft #36): content overlay, map audio as data, posting retail sounds and globals, audio events; prior art, engine facts per phase, open questions. R0: `sdk.audio` declared, `audio` capability. Depends on #32. | ## Environment used for verification diff --git a/sdk/GENERAL_API.md b/sdk/GENERAL_API.md index 14dbe444..6473fff4 100644 --- a/sdk/GENERAL_API.md +++ b/sdk/GENERAL_API.md @@ -2,7 +2,9 @@ Manifest API remains `2`. Feature discovery uses compiled `sdk.capabilities`: `engine_access=1`, `command_results=1`, `native_bodies=1`, -`input_override=1`, `player_physics=2`, `player_overlap=1`, `landed_details=1`. +`input_override=1`, `player_physics=2`, `player_overlap=1`, `landed_details=1`, +`audio=1` (the mod's own WAVs: `sdk.audio.preload / play / update / stop / stop_all`, declared in +`skate.lua`) and `world_audio=1` (publishing cars, peds and skaters to the game's world audio). The host has no injury, vehicle or challenge rules. Those live in Lua. `deformation=1` adds optional impact-driven mesh/collider deformation. diff --git a/sdk/skate.lua b/sdk/skate.lua index bc87fdd1..e67556c4 100644 --- a/sdk/skate.lua +++ b/sdk/skate.lua @@ -563,6 +563,61 @@ function sdk.graphs.read(graph) end ---@param enabled? boolean nil restores the original gate function sdk.graphs.set_enabled(graph, target, index, enabled) end +---Feature discovery: extension name → version, compiled into the engine (an older engine lacks +---newer keys, so test `(sdk.capabilities.audio or 0) >= 1` before relying on a feature). The +---manifest API stays 2. Includes among others `audio`, `world_audio`, `engine_access`, +---`command_results`, `player_physics`, `menus`, `camera`, `volumes`, `capture`. +---@type table<string, integer> +sdk.capabilities = {} + +-- Audio extension 1 (capability `audio`): the mod's own sounds. Keys and files belong to the calling +-- mod. Files: mod-relative PCM16 WAV (`.wav`, no `..`, `\`, `:`, `#`, `?`), 1–2 channels, 8–48 kHz, +-- at most 30 s and 8 MiB each; metadata chunks are dropped. Limits: 32 clips / 32 MiB / 32 voices per +-- mod, 128 / 128 MiB / 128 in all. The voices play outside the game's native audio engine: master +-- volume and `--mute` apply, but no retail reverb, distance curves or ducking. They pause while the +-- game is paused or a replay runs, and stop when the mod is disabled, reloaded or fails (clips are +-- released then too). A voice bound to a body stops when that body is removed. +---@class AudioPlayOptions +---@field path string mod-relative PCM16 WAV +---@field body? string follow one of this mod's physics bodies (exclusive with position) +---@field position? Vec3 world position of an unbound voice (default {0,0,0}) +---@field offset? Vec3 added to the body pose or the position, -100..100 m per axis (default {0,0,0}) +---@field loop? boolean default false +---@field volume? number 0..1 (default 1) +---@field pitch? number playback speed 0.25..4: changes pitch and duration (default 1) +---@field spatial? boolean positional (default true); false plays at the listener +---@field spatial_scale? number 0.001..1, world metres → spatial units (default 0.1) +---@field paused? boolean start paused (default false) +---@field fade_in? number seconds 0..2 (default 0.01) +---@class AudioUpdateOptions +---@field volume? number 0..1 +---@field pitch? number 0.25..4 +---@field paused? boolean +---@field position? Vec3 unbound voices only +---@field offset? Vec3 +---@type {version:integer} +sdk.audio = { version = 1 } +---Load and validate a WAV now so the first play doesn't read it. A bad file, an unknown body or a +---full limit fails the mod (as `play` does); wrap the command in `sdk.commands.request` to get the +---error as a result instead. +---@param path string mod-relative PCM16 WAV +function sdk.audio.preload(path) end +---Start a voice under `key`, replacing any voice this mod already plays under that key. +---@param key string +---@param opts AudioPlayOptions +function sdk.audio.play(key, opts) end +---Change a playing voice; never restarts it. Unknown or finished keys are ignored. Volume and pitch +---changes are smoothed (≈ 12 ms / 25 ms). +---@param key string +---@param opts AudioUpdateOptions +function sdk.audio.update(key, opts) end +---Stop a voice with a fade (seconds, default 0.03; 0 = at once). Repeated stops don't extend it. +---@param key string +---@param fade_out? number +function sdk.audio.stop(key, fade_out) end +---Stop every voice of this mod at once (the clips stay loaded). +function sdk.audio.stop_all() end + -- World audio extension 1 (capability `world_audio`): publish traffic vehicles, pedestrians and -- skaters to the game's retail world audio, exactly as an engine system would (doc -- docs/hails-additions/15-world-audio.md). Keys belong to this mod; 48 objects per mod, 128 in all;