diff --git a/.gitignore b/.gitignore index 6045be58..38263b8f 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,8 @@ __pycache__/ *.abk *.bnk *.csi +# The audio content test mod's own Csis project (self-made by its synthesize.py, doc 16 L4). +!/mods/audio-content-test/audio/dev.csi *.ems *.grain *.mus diff --git a/crates/skate-audio/src/eval/mod.rs b/crates/skate-audio/src/eval/mod.rs index 950eaffe..c1d12977 100644 --- a/crates/skate-audio/src/eval/mod.rs +++ b/crates/skate-audio/src/eval/mod.rs @@ -207,8 +207,36 @@ impl Evaluator { self.tick } - pub fn install_project(&mut self, project: &crate::formats::Project) { - self.registry.install(project); + /// Install a Csis project; returns its token ([`Evaluator::uninstall_project`]). + pub fn install_project(&mut self, project: &crate::formats::Project) -> u64 { + self.registry.install(project) + } + + /// The loaded banks whose modules bind a symbol of the installed project `token` (a class or a + /// handle): the banks a host must replace or unload before / after taking the project out. + pub fn banks_using_project(&self, token: u64) -> Vec { + let Some(ids) = self.registry.records_of(token) else { return Vec::new() }; + self.banks + .iter() + .enumerate() + .filter_map(|(b, lb)| { + let lb = lb.as_ref()?; + let uses = lb.modules.iter().any(|m| { + m.class.is_some_and(|c| ids[1].contains(&c)) + || m.handles.values().any(|s| match *s { + SymRef::Function(f) => ids[0].contains(&f), + SymRef::Class(c) => ids[1].contains(&c), + SymRef::Global(g) => ids[2].contains(&g), + }) + }); + uses.then_some(b) + }) + .collect() + } + + /// Take an installed project out (see `Registry::uninstall`); false for an unknown token. + pub fn uninstall_project(&mut self, token: u64) -> bool { + self.registry.uninstall(token).is_some() } /// Install a bank: resolve its exports and register its modules on their classes. Projects @@ -219,6 +247,23 @@ impl Evaluator { // ever). Safe to reuse: unloading destroyed the bank's instances and dropped its // constructors; ids are only keys (posts reach banks in constructor order, not id order). let id = self.banks.iter().position(Option::is_none).unwrap_or(self.banks.len()); + let modules = self.bind(&bank); + for (m, module) in modules.iter().enumerate() { + if let Some(c) = module.class { + self.registry.classes[c].constructors.push((id, m)); + } + } + let loaded = Some(LoadedBank { bank, modules }); + match self.banks.get_mut(id) { + Some(slot) => *slot = loaded, + None => self.banks.push(loaded), + } + id + } + + /// Resolve a bank's exports against the installed projects: each module's class and the + /// handles inside its template. + fn bind(&self, bank: &Bank) -> Vec { let mut modules: Vec = bank .modules .iter() @@ -236,17 +281,98 @@ impl Evaluator { } } } - for (m, module) in modules.iter().enumerate() { - if let Some(c) = module.class { - self.registry.classes[c].constructors.push((id, m)); + modules + } + + /// Replace a loaded bank in place (an audio content hot swap, no restart): its live instances + /// are destroyed (voices released, ControlClass children released), the new bank takes the + /// same id and, in every class's constructor list, the place the old bank's modules had (a + /// class it newly binds gets it last, as a load would). Every post still held by its poster + /// whose class the new bank binds gets the new bank's instances at once, with the post's + /// payload (its last words: those of the destroyed instance, else of another instance of the + /// post), as if the bank had been loaded with this content when the post was made. Creating + /// instances draws nothing from the random generator; the new instances join the walk as the + /// newest. Posts are re-instanced in post-id order (deterministic). Returns the posts that got + /// instances. + pub fn replace_bank(&mut self, id: usize, bank: Bank, host: &mut dyn VoiceHost) -> Vec { + // The posts' payloads before anything goes: every ClassData client of a post holds the + // post's last words up to its own count, so the longest is the most complete. + let mut payloads: HashMap> = HashMap::new(); + for (&node, n) in &self.nodes { + let best = n.class_data.iter().filter_map(|&(inst, _)| self.payload_of(inst)).max_by_key(Vec::len); + if let Some(words) = best { + payloads.insert(node, words); } } - let loaded = Some(LoadedBank { bank, modules }); + let doomed: Vec = self.order.iter().copied().filter(|&i| self.instance(i).is_some_and(|x| x.bank == id)).collect(); + for i in doomed { + self.destroy(i, host); + } + // Where the old bank sat in each class's constructor list. + let mut places: HashMap = HashMap::new(); + for (c, class) in self.registry.classes.iter_mut().enumerate() { + if let Some(at) = class.constructors.iter().position(|&(b, _)| b == id) { + places.insert(c, at); + } + class.constructors.retain(|&(b, _)| b != id); + } + let modules = self.bind(&bank); + let mut bound: Vec = Vec::new(); + for (m, module) in modules.iter().enumerate() { + let Some(c) = module.class else { continue }; + let list = &mut self.registry.classes[c].constructors; + match places.get_mut(&c) { + Some(at) => { + let at_now = (*at).min(list.len()); + list.insert(at_now, (id, m)); + *at = at_now + 1; + } + None => list.push((id, m)), + } + if !bound.contains(&c) { + bound.push(c); + } + } + let loaded = Some(LoadedBank { bank: Arc::new(bank), modules }); match self.banks.get_mut(id) { Some(slot) => *slot = loaded, - None => self.banks.push(loaded), + None => { + self.banks.resize_with(id, || None); + self.banks.push(loaded); + } } - id + // Held posts of the bound classes get the new bank's instances (post-id order). + let mut held: Vec = self + .nodes + .iter() + .filter(|(_, n)| bound.contains(&n.class) && n.refcount as usize > n.class_data.len() + n.destructors.len()) + .map(|(&k, _)| k) + .collect(); + held.sort_unstable(); + for &node in &held { + let payload = payloads.remove(&node); + let class = self.nodes[&node].class; + let count = self.registry.classes[class].constructors.len(); + for k in (0..count).rev() { + let (b, m) = self.registry.classes[class].constructors[k]; + if b == id { + self.create_instance(node, b, m); + } + } + if let Some(words) = payload { + self.redeliver(NodeId(node), &words); + } + } + held.into_iter().map(NodeId).collect() + } + + /// An instance's ClassData words (the post's last payload), when its module has that state. + fn payload_of(&self, inst: u32) -> Option> { + let i = self.instance(inst)?; + let lb = self.banks.get(i.bank)?.as_ref()?; + let off = lb.bank.modules[i.module].class_data_state? as usize; + let count = u8_at(&i.mem, off + 16) as usize; + Some((0..count).map(|k| i32_at(&i.mem, off + 20 + 4 * k)).collect()) } /// Remove a bank: its live instances are destroyed (voices released) and its modules stop diff --git a/crates/skate-audio/src/eval/symbols.rs b/crates/skate-audio/src/eval/symbols.rs index b17704e2..40844504 100644 --- a/crates/skate-audio/src/eval/symbols.rs +++ b/crates/skate-audio/src/eval/symbols.rs @@ -40,6 +40,8 @@ pub struct GlobalRec { struct Installed { id: u16, + /// The install's token ([`Registry::install`]), for [`Registry::uninstall`]. + token: u64, /// Per table: (name id, name, record index). tables: [Vec<(u16, String, usize)>; 3], } @@ -51,10 +53,12 @@ pub struct Registry { pub functions: Vec, pub classes: Vec, pub globals: Vec, + installs: u64, } impl Registry { - pub fn install(&mut self, project: &Project) { + /// Install a project (newest first in lookups). Returns its token for [`Registry::uninstall`]. + pub fn install(&mut self, project: &Project) -> u64 { let mut tables: [Vec<(u16, String, usize)>; 3] = Default::default(); for s in &project.tables[0] { tables[0].push((s.name_id, s.name.clone(), self.functions.len())); @@ -68,7 +72,36 @@ impl Registry { tables[2].push((s.name_id, s.name.clone(), self.globals.len())); self.globals.push(GlobalRec { name: s.name.clone(), value: s.default, subscribers: Vec::new() }); } - self.projects.push(Installed { id: project.id, tables }); + self.installs += 1; + self.projects.push(Installed { id: project.id, token: self.installs, tables }); + self.installs + } + + /// The record indices (functions, classes, globals) an installed project holds. + pub fn records_of(&self, token: u64) -> Option<[Vec; 3]> { + let p = self.projects.iter().find(|p| p.token == token)?; + Some(std::array::from_fn(|t| p.tables[t].iter().map(|e| e.2).collect())) + } + + /// Take an installed project out of the lookups (an audio content hot swap: a mod's project + /// goes): no reference resolves to its symbols and no game-side name finds them any more, so + /// every lookup answers as if it had never been installed. Its records stay (ids are indices) + /// but are emptied: the host unloads or replaces the banks bound to them first. Returns the + /// record ranges it held (functions, classes, globals) or None for an unknown token. + pub fn uninstall(&mut self, token: u64) -> Option<[Vec; 3]> { + let at = self.projects.iter().position(|p| p.token == token)?; + let p = self.projects.remove(at); + let ids: [Vec; 3] = std::array::from_fn(|t| p.tables[t].iter().map(|e| e.2).collect()); + for &f in &ids[0] { + self.functions[f].subscribers.clear(); + } + for &c in &ids[1] { + self.classes[c].constructors.clear(); + } + for &g in &ids[2] { + self.globals[g].subscribers.clear(); + } + Some(ids) } /// Resolve an interface reference; None = retail's −5 (not found). diff --git a/crates/skate-audio/src/eval/tests.rs b/crates/skate-audio/src/eval/tests.rs index 7121e943..ede007f2 100644 --- a/crates/skate-audio/src/eval/tests.rs +++ b/crates/skate-audio/src/eval/tests.rs @@ -261,3 +261,47 @@ fn unload_bank_destroys_its_instances_and_stops_answering_posts() { assert!(e.instances().is_empty()); } + +/// An audio content hot swap (`replace_bank`): the bank keeps its id and its place among the +/// class's constructors; its instances go (voices released); a post its poster still holds gets +/// the new bank's instance with the post's payload and plays it at the next walk; a released post +/// gets nothing; the other bank's instance is untouched; nothing is drawn from the generator. +#[test] +fn a_replaced_bank_keeps_its_place_and_re_instances_held_posts() { + let ex = || vec![Ex { module: 0, kind: 1, name_id: 1, name: "c_test", at: None }]; + let mut e = Evaluator::new(); + e.install_project(&project()); + let a = e.load_bank(bank(&[player_module(4)], &ex(), &[(48000, false), (24000, false)])); + let b = e.load_bank(bank(&[player_module(4)], &ex(), &[(48000, false), (24000, false)])); + let class = e.class_id("c_test").unwrap(); + let mut log = Log::default(); + let held = e.post(class, &[1, 4096, 1]); + let gone = e.post(class, &[1, 4096, 0]); + walk(&mut e, &mut log); + e.release(gone); + walk(&mut e, &mut log); + assert_eq!(e.instances().len(), 2, "the held post's two instances"); + let rng = e.rng; + log.calls.clear(); + let again = e.replace_bank(a, bank(&[player_module(4)], &ex(), &[(12000, false), (6000, false)]), &mut log); + assert_eq!(again, [held], "only the held post"); + assert_eq!(e.registry.classes[class].constructors, [(a, 0), (b, 0)], "the same place"); + assert!(log.calls.iter().all(|c| c.starts_with("release")) && log.calls.len() == 1, "{:?}", log.calls); + assert_eq!(e.instances().len(), 2); + assert_eq!(e.instances()[0].1, a, "the new instance is the newest"); + walk(&mut e, &mut log); + assert!(log.calls.iter().any(|c| c.starts_with("open") && c.ends_with("slot 1")), "the payload came along: {:?}", log.calls); + assert_eq!(e.rng, rng); + // The project of a mod: its banks are found, and once it is out nothing resolves to it. + let s = |name: &str, id: u16| crate::formats::csi::Symbol { name: name.into(), name_id: id, default: 5 }; + let modp = crate::formats::Project { name: "mod.csi".into(), id: 0x4D4F, tables: [vec![], vec![s("c_mod", 1)], vec![s("g_mod", 1)]] }; + let token = e.install_project(&modp); + let m = e.load_bank(bank(&[player_module(1)], &[Ex { module: 0, kind: 1, name_id: 1, name: "c_mod", at: None }], &[(1000, false)])); + assert!(e.class_id("c_mod").is_some() && e.global_id("g_mod").is_some()); + assert_eq!(e.banks_using_project(token), [m]); + e.unload_bank(m, &mut log); + assert!(e.uninstall_project(token)); + assert!(e.class_id("c_mod").is_none() && e.global_id("g_mod").is_none()); + assert_eq!(e.class_id("c_test"), Some(class), "retail lookups unchanged"); + assert!(!e.uninstall_project(token)); +} diff --git a/crates/skate-audio/src/formats/csi.rs b/crates/skate-audio/src/formats/csi.rs index 7b2cfaa4..a92dcf97 100644 --- a/crates/skate-audio/src/formats/csi.rs +++ b/crates/skate-audio/src/formats/csi.rs @@ -49,4 +49,52 @@ impl Project { } Ok(Self { name: name.to_string(), id, tables }) } + + /// The project as a `.csi` file (the layout [`Project::parse`] reads: a 0x28-byte header with + /// the three counts at 0x0A / 0x0C / 0x0E and the id at 0x10, the tables, then the names). + /// For tools and tests that make their own projects (doc 16 "Mod Csis projects"). + pub fn to_bytes(&self) -> Vec { + let mut d = vec![0u8; 0x28]; + d[..4].copy_from_slice(b"MOIR"); + for (t, at) in [0x0A, 0x0C, 0x0E].into_iter().enumerate() { + d[at..at + 2].copy_from_slice(&(self.tables[t].len() as u16).to_be_bytes()); + } + d[0x10..0x12].copy_from_slice(&self.id.to_be_bytes()); + let records: usize = self.tables.iter().enumerate().map(|(t, s)| s.len() * if t == 2 { 16 } else { 12 }).sum(); + let mut names = Vec::new(); + let mut rec = Vec::with_capacity(records); + for (t, syms) in self.tables.iter().enumerate() { + for s in syms { + let off = (0x28 + records + names.len()) as u32; + names.extend_from_slice(s.name.as_bytes()); + names.push(0); + let mut r = vec![0u8; if t == 2 { 16 } else { 12 }]; + if t == 2 { + r[4..8].copy_from_slice(&s.default.to_be_bytes()); + r[8..12].copy_from_slice(&off.to_be_bytes()); + r[12..14].copy_from_slice(&s.name_id.to_be_bytes()); + } else { + r[4..8].copy_from_slice(&off.to_be_bytes()); + r[8..10].copy_from_slice(&s.name_id.to_be_bytes()); + } + rec.extend(r); + } + } + d.extend(rec); + d.extend(names); + d + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_written_project_reads_back() { + let s = |name: &str, name_id: u16, default: i32| Symbol { name: name.into(), name_id, default }; + let p = Project { name: "mod.csi".into(), id: 0x4D4F, tables: [vec![s("f_mod_msg", 1, 0)], vec![s("c_mod_a", 1, 0), s("c_mod_b", 2, 0)], vec![s("g_mod", 1, -7)]] }; + let back = Project::parse("mod.csi", &p.to_bytes()).unwrap(); + assert_eq!((back.id, &back.tables), (p.id, &p.tables)); + } } diff --git a/crates/skate-audio/src/mixer.rs b/crates/skate-audio/src/mixer.rs index 5f77a3f2..8d469c03 100644 --- a/crates/skate-audio/src/mixer.rs +++ b/crates/skate-audio/src/mixer.rs @@ -424,6 +424,11 @@ impl Mixer { } } + /// A registered bank's volume group. + pub fn bank_group(&self, bank: usize) -> Option { + self.banks.get(&bank).map(|b| b.group) + } + pub fn remove_bank(&mut self, bank: usize) { self.banks.remove(&bank); } diff --git a/crates/skate-audio/src/runtime.rs b/crates/skate-audio/src/runtime.rs index 9824b762..46bbb0b1 100644 --- a/crates/skate-audio/src/runtime.rs +++ b/crates/skate-audio/src/runtime.rs @@ -71,8 +71,9 @@ impl Runtime { } } - pub fn install_project(&mut self, project: &Project) { - self.eval.install_project(project); + /// Install a Csis project; returns its token (`Evaluator::uninstall_project`). + pub fn install_project(&mut self, project: &Project) -> u64 { + self.eval.install_project(project) } /// Install a bank and its decoded samples (by S10A slot; None plays silence of the right @@ -84,6 +85,20 @@ impl Runtime { id } + /// Replace a loaded bank in place (an audio content hot swap; `Evaluator::replace_bank`): the + /// same id, its place in the class constructor lists, its volume group; the new samples. + /// Voices of the old bank keep their own copy of the samples until their release ends. + pub fn replace_bank(&mut self, id: usize, bank: Bank, pcm: Vec>>) -> Vec { + let headers = bank.samples.iter().map(|s| s.1).collect(); + let group = self.mixer.bank_group(id); + let held = self.eval.replace_bank(id, bank, &mut self.mixer); + self.mixer.add_bank(id, headers, pcm); + if let Some(g) = group { + self.mixer.set_bank_group(id, g); + } + held + } + pub fn unload_bank(&mut self, id: usize) { self.eval.unload_bank(id, &mut self.mixer); self.mixer.remove_bank(id); diff --git a/crates/skate-audio/src/splice/mod.rs b/crates/skate-audio/src/splice/mod.rs index 15c59d48..619ec7a9 100644 --- a/crates/skate-audio/src/splice/mod.rs +++ b/crates/skate-audio/src/splice/mod.rs @@ -224,6 +224,35 @@ impl SplicePlayer { index } + /// Replace a loaded bank's patch tree and samples in place (an audio content hot swap): its + /// sounding sounds stop (their owners' later updates find nothing, as after a release), the + /// bank keeps its index and mixer bank. None when the bank is not loaded (it loads on use). + pub fn replace_bank(&mut self, name: &str, bank: SpliceBank, pcm: Vec>>, mixer: &mut Mixer) -> Option { + let index = self.banks.iter().position(|b| b.name == name)?; + for id in 0..self.sounds.len() { + if self.sounds[id].as_ref().is_some_and(|s| s.bank == index) { + self.release(id, mixer); + } + } + let headers = pcm + .iter() + .map(|p| { + p.as_ref().map(|p| crate::formats::SampleHeader { + codec: 3, + channels: p.channels.len() as u8, + rate: p.rate, + frames: p.channels.first().map_or(0, |c| c.len() as u32), + loop_start: None, + }) + }) + .collect(); + let mixer_bank = self.banks[index].mixer_bank; + mixer.add_bank(mixer_bank, headers, pcm); + mixer.set_bank_group(mixer_bank, crate::mixer::GROUP_PLAYER); + self.banks[index].bank = bank; + Some(index) + } + pub fn bank_index(&self, name: &str) -> Option { self.banks.iter().position(|b| b.name == name) } diff --git a/crates/skate-game/src/game_audio/ambience.rs b/crates/skate-game/src/game_audio/ambience.rs index 17959c96..d785f738 100644 --- a/crates/skate-game/src/game_audio/ambience.rs +++ b/crates/skate-game/src/game_audio/ambience.rs @@ -108,7 +108,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, content.generation); + let identity = (map.name.clone(), map.generation, content.world_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 index 440dae2f..d045da73 100644 --- a/crates/skate-game/src/game_audio/content.rs +++ b/crates/skate-game/src/game_audio/content.rs @@ -64,6 +64,15 @@ pub(crate) struct AudioContent { /// Bumped when a running overlay with `rules` comes or goes (`mod_rules` recompiles; rules /// alone never restart the sound). pub(crate) rules_generation: u64, + /// Bumped by every runtime restart only (not by a hot swap): what holds runtime handles (post + /// ids, mixer voices, private MixMap instances) forgets them when it changes. + pub(crate) runtime_generation: u64, + /// Bumped by a restart and by a hot swap that changed the world layer (beds, zones, records, + /// sets, map audio: `Library::world_key`): the map-keyed state rebuilds when it changes. + pub(crate) world_generation: u64, + /// Hot swaps done (doc 16 L1), and the last content change: "swap" or "restart: ". + pub(crate) swaps: u64, + pub(crate) last_change: Option, } impl AudioContent { @@ -177,8 +186,15 @@ impl AudioContent { } /// Rebuild the library from the install and the registered overlays (the install alone when -/// the merge fails). None when the install has no audio. +/// the merge fails) and make it the resource. None when the install has no audio. fn rebuild(world: &mut World) -> Option { + let (library, report) = merged(world)?; + world.insert_resource(library); + Some(report) +} + +/// The library of the install and the registered overlays (see [`rebuild`]), not inserted. +fn merged(world: &mut World) -> Option<(Library, ContentReport)> { let root = world.get_resource::()?.asset_root.clone(); let content = world.resource::(); let result = Library::load_with(&root, &content.sources()); @@ -198,8 +214,7 @@ fn rebuild(world: &mut World) -> Option { for m in report.warnings.iter().chain(&report.rejected) { warn!("Game audio: mod {}: {}", m.owner, m.text); } - world.insert_resource(library); - Some(report) + Some((library, report)) } /// Replace the hosts that hold runtime handles (node ids, Splice sounds, MixMap instances, @@ -233,24 +248,81 @@ pub(super) fn frame(world: &mut World) { // startup library is used untouched. let report = if overlays { rebuild(world) } else { None }; let started = super::native::launch(world, None); + super::seed::from_env(world); let mut content = world.resource_mut::(); if let Some(report) = report { content.report = report; content.generation += 1; + content.world_generation += 1; } content.pending = false; content.runtime = if started { Runtime::Started } else { Runtime::Failed }; } - Runtime::Started | Runtime::Failed if pending => restart(world), + Runtime::Started if pending => swap_or_restart(world), + Runtime::Failed if pending => restart(world), _ => {} } - // Tuning writes apply here, between passes (after a restart: onto the new Library). + // Tuning writes apply here, between passes (after a restart: onto the new Library); then a + // seed of the random state (doc 16 L5; nothing while unseeded). super::tuning::apply(world); + super::seed::apply(world); +} + +/// The running mods' audio content changed: swap the new library in without a restart where that +/// is exact (`swap.rs`, doc 16 L1), else restart the runtime (the fallback, with its reasons). +pub(crate) fn swap_or_restart(world: &mut World) { + let Some((library, report)) = merged(world) else { return }; + let plan = match (world.get_resource::(), world.get_resource::()) { + (Some(old), Some(native)) => super::swap::plan(old, &library, native), + _ => { + let mut p = super::swap::Plan::default(); + p.restart.push("no running runtime".into()); + p + } + }; + if !plan.restart.is_empty() { + info!("Game audio: the audio content change needs a restart: {}", plan.restart.join("; ")); + world.insert_resource(library); + restart_with(world, report, Some(plan.restart.join("; "))); + return; + } + if let Err(e) = super::swap::apply(world, &plan, &library) { + warn!("Game audio: the hot swap failed ({e}); restarting the audio instead"); + world.insert_resource(library); + restart_with(world, report, Some(format!("the swap failed: {e}"))); + return; + } + if !plan.tuning.is_empty() { + let changed = plan.tuning.clone(); + world.insert_resource(library); + super::tuning::retune(world, &changed); + } else { + world.insert_resource(library); + } + let mut content = world.resource_mut::(); + content.report = report; + content.generation += 1; + content.swaps += 1; + if plan.world { + content.world_generation += 1; + } + content.pending = false; + content.last_change = Some("swap".into()); + info!( + "Game audio: swapped the mods' audio content in place (generation {}, overlays {:?}; banks replaced {:?}, unloaded {:?}, Splice {:?}, wheels {}, projects in {:?} out {:?}, tuning {:?}, speech {}, world {})", + content.generation, content.overlays.keys().collect::>(), plan.replace, plan.unload, plan.splice, plan.wheels, plan.projects_in, plan.projects_out.iter().map(|p| &p.0).collect::>(), + plan.tuning.iter().map(|t| t.0).collect::>(), plan.speech, plan.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 }; + restart_with(world, report, None); +} + +/// Restart the runtime on the library in place (`report`: its merge report). +fn restart_with(world: &mut World, report: ContentReport, why: Option) { let carry = super::native::shutdown(world); reset_hosts(world); let started = super::native::launch(world, carry); @@ -266,7 +338,10 @@ pub(crate) fn restart(world: &mut World) { let mut content = world.resource_mut::(); content.report = report; content.generation += 1; + content.runtime_generation += 1; + content.world_generation += 1; content.restarts += 1; + content.last_change = Some(format!("restart{}", why.map_or(String::new(), |w| format!(": {w}")))); 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::>()); @@ -460,7 +535,7 @@ mod tests { 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); + restart(&mut world); assert_eq!(world.resource::().restarts, 1); assert_eq!(world.resource::().generation, 1); { @@ -496,14 +571,14 @@ mod tests { 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); + restart(&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); + restart(&mut world); assert_eq!(world.resource::().restarts, 3); let restored = scenario(&mut world, &bank, patch); let fresh = { @@ -516,4 +591,190 @@ mod tests { assert!(same(&restored, &fresh), "removed mod = retail"); let _ = std::fs::remove_dir_all(&dir); } + + /// L4 (data-gated): a mod's Csis project brings a class and a global; its preloaded bank binds + /// to the class. Swapped in (no restart): the class and global resolve, a post to the class + /// plays the mod bank. Swapped out: the names resolve no more and the bank is gone; retail's + /// lookups never changed. A project reusing a retail name is left out (first owner wins); a + /// restart installs the mod project after the install's. + #[test] + #[ignore = "needs the private install data"] + fn a_mod_csis_project_is_installed_and_taken_out_without_a_restart() { + use super::super::native::Native; + use skate_audio::eval::synthetic; + 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 retail_emitter = world.resource::().shared.lock().unwrap().eval.class_id("c_emitter"); + let dir = std::env::temp_dir().join(format!("skate-audio-csi-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("audio")).unwrap(); + let sym = |name: &str, name_id: u16, default: i32| skate_audio::formats::csi::Symbol { name: name.into(), name_id, default }; + let project = |class: &str| skate_audio::formats::Project { name: "mod.csi".into(), id: synthetic::PROJECT, tables: [vec![], vec![sym(class, 1, 0)], vec![sym("g_dev_mod", 2, 41)]] }; + std::fs::write(dir.join("audio/mod.csi"), project("c_dev_mod").to_bytes()).unwrap(); + let abk = synthetic::bank(&[synthetic::player_module(2)], &[synthetic::Ex { module: 0, kind: 1, name_id: 1, name: "c_dev_mod", at: None }], &[(4800, false)]).data; + std::fs::write(dir.join("audio/mod.abk"), &abk).unwrap(); + std::fs::write(dir.join("audio/a.wav"), super::super::library::tests::test_wav(4800, 48000, 9000)).unwrap(); + std::fs::write(dir.join("audio.json"), serde_json::json!({"version": 1, "add": { + "projects": ["audio/mod.csi"], + "banks": {"MOD_csi": {"abk": "audio/mod.abk", "samples": ["audio/a.wav"], "preload": true, "group": "world"}}}}).to_string()).unwrap(); + world.resource_mut::().sync([("dev.csi", dir.as_path(), 1)].into_iter()); + frame(&mut world); + { + let c = world.resource::(); + assert_eq!((c.restarts, c.swaps), (0, 1), "{:?} {:?}", c.last_change, c.report.rejected); + } + { + let n = world.resource::(); + assert!(n.bank_loaded("MOD_csi")); + assert_eq!(n.mod_projects.len(), 1); + let mut rt = n.shared.lock().unwrap(); + let class = rt.eval.class_id("c_dev_mod").expect("the mod's class"); + let g = rt.eval.global_id("g_dev_mod").expect("the mod's global"); + assert_eq!(rt.eval.global(g), Some(41)); + let before = rt.eval.instance_count(); + rt.post(class, &[1, 4096, 0]); + assert_eq!(rt.eval.instance_count(), before + 1, "the mod bank answers the mod class"); + assert_eq!(rt.eval.class_id("c_emitter"), retail_emitter); + } + world.resource_mut::().sync(std::iter::empty()); + frame(&mut world); + { + assert_eq!((world.resource::().restarts, world.resource::().swaps), (0, 2)); + let n = world.resource::(); + assert!(!n.bank_loaded("MOD_csi") && n.mod_projects.is_empty()); + let rt = n.shared.lock().unwrap(); + assert!(rt.eval.class_id("c_dev_mod").is_none() && rt.eval.global_id("g_dev_mod").is_none()); + assert_eq!(rt.eval.class_id("c_emitter"), retail_emitter); + } + // A project that reuses a retail class name is left out. + std::fs::write(dir.join("audio/mod.csi"), project("c_emitter").to_bytes()).unwrap(); + world.resource_mut::().sync([("dev.csi", dir.as_path(), 2)].into_iter()); + frame(&mut world); + assert!(world.resource::().report.rejected.iter().any(|m| m.owner == "dev.csi" && m.text.contains("c_emitter")), "{:?}", world.resource::().report); + // A restart with the mod: its project after the install's. + std::fs::write(dir.join("audio/mod.csi"), project("c_dev_mod").to_bytes()).unwrap(); + world.resource_mut::().sync([("dev.csi", dir.as_path(), 3)].into_iter()); + restart(&mut world); + let n = world.resource::(); + assert_eq!(n.mod_projects.len(), 1); + assert!(n.shared.lock().unwrap().eval.class_id("c_dev_mod").is_some()); + let _ = std::fs::remove_dir_all(&dir); + } + + pub(crate) fn render(world: &World, blocks: usize) -> Vec { + let n = world.resource::(); + let mut out = vec![0.0f32; 2 * skate_audio::BLOCK]; + let mut all = Vec::with_capacity(blocks * out.len()); + for _ in 0..blocks { + n.shared.lock().unwrap().fill_stereo(&mut out); + all.extend_from_slice(&out); + } + all + } + + /// The bank the runtime holds under `stem` equals what loading it from `library` gives (its + /// samples' headers, its program bytes): what a restart would have loaded. + fn holds_library_bank(world: &World, library: &Library, stem: &str) -> bool { + let n = world.resource::(); + let id = n.bank_id(stem).expect("loaded"); + let (fresh, _) = library.bank_source(stem).unwrap().load().unwrap(); + let rt = n.shared.lock().unwrap(); + let held = rt.eval.bank(id).unwrap(); + held.data == fresh.data && held.samples == fresh.samples + } + + /// L1 (data-gated): a mod's audio content coming and going is swapped in place, no restart: + /// the runtime (and its stream) stays, the replaced bank keeps its runtime id and its place + /// among its class's constructors and holds exactly the new library's content, a held post of + /// it keeps sounding on the new samples (re-bound with its payload), an unrelated held post + /// keeps its instance. Removing the mod swaps the install's bank back. A MixMap replacement + /// cannot be swapped exactly: it restarts (the fallback, with its reason). + #[test] + #[ignore = "needs the private install data"] + fn a_mod_is_swapped_in_and_out_without_a_restart() { + 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 (mut a, mut b) = (game_world(root), game_world(root)); + let (bank, patch) = emitter_bank(a.resource::()); + let mut nodes = Vec::new(); + for w in [&mut a, &mut b] { + let library = w.remove_resource::().unwrap(); + let mut native = w.resource_mut::(); + native.ensure_bank(&library, &bank).unwrap(); + let payload = native.emitter_payload(None, 0.8, 9000, patch); + nodes.push(native.post_emitter(&payload).expect("c_emitter")); + drop(native); + w.insert_resource(library); + } + assert!(same(&render(&a, 200), &render(&b, 200)), "two equal worlds"); + let shared = std::sync::Arc::as_ptr(&a.resource::().shared); + let id = a.resource::().bank_id(&bank).unwrap(); + let utility = a.resource::().bank_id("emitter_utility").unwrap(); + let (class, constructors, utility_instances) = { + let rt = a.resource::().shared.lock().unwrap(); + let class = rt.eval.node_class(nodes[0]).unwrap(); + (class, rt.eval.registry.classes[class].constructors.clone(), rt.eval.instances().into_iter().filter(|i| i.1 == utility).collect::>()) + }; + let dir = std::env::temp_dir().join(format!("skate-audio-swap-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("audio")).unwrap(); + let samples = a.resource::().bank_len(&bank); + std::fs::write(dir.join("audio/tone.wav"), super::super::library::tests::test_wav(22050, 22050, 6000)).unwrap(); + std::fs::write(dir.join("audio.json"), serde_json::json!({"version": 1, "replace": {"banks": {bank.clone(): {"samples": vec!["audio/tone.wav"; samples]}}}}).to_string()).unwrap(); + a.resource_mut::().sync([("dev.swap", dir.as_path(), 1)].into_iter()); + frame(&mut a); + { + let c = a.resource::(); + assert_eq!((c.restarts, c.swaps, c.runtime_generation, c.last_change.as_deref()), (0, 1, 0, Some("swap")), "{:?}", c.last_change); + assert_eq!(c.report.applied, ["dev.swap"]); + } + assert_eq!(std::sync::Arc::as_ptr(&a.resource::().shared), shared, "the same runtime"); + assert_eq!(super::super::native::NativeOutputCount::of(&mut a), 1, "the same stream"); + assert_eq!(a.resource::().bank_id(&bank), Some(id), "the same bank id"); + assert!(holds_library_bank(&a, a.resource::(), &bank), "the new library's bank"); + { + let rt = a.resource::().shared.lock().unwrap(); + assert_eq!(rt.eval.registry.classes[class].constructors, constructors, "the same constructor places"); + assert_eq!(rt.eval.node_class(nodes[0]), Some(class), "the post is still held"); + assert!(rt.eval.instances().iter().any(|i| i.1 == id), "and re-bound to the new bank"); + assert_eq!(rt.eval.instances().into_iter().filter(|i| i.1 == utility).collect::>(), utility_instances, "an unrelated post keeps its instance"); + } + let (modded, retail) = (render(&a, 600), render(&b, 600)); + assert!(modded.iter().any(|s| *s != 0.0) && !same(&modded, &retail), "the mod's samples play on the held post"); + // L7: the WAV edited in place while the mod runs (same path, new content; the package's + // fingerprint changes): swapped again, its new header in the runtime. + let header = |w: &World| { + let n = w.resource::(); + let rt = n.shared.lock().unwrap(); + rt.eval.bank(id).unwrap().samples[0].1 + }; + let before = header(&a); + std::fs::write(dir.join("audio/tone.wav"), super::super::library::tests::test_wav(11025, 22050, 3000)).unwrap(); + a.resource_mut::().sync([("dev.swap", dir.as_path(), 2)].into_iter()); + frame(&mut a); + assert_eq!((a.resource::().restarts, a.resource::().swaps), (0, 2)); + assert!(holds_library_bank(&a, a.resource::(), &bank) && header(&a) != before, "the edited WAV"); + // Out again: the install's bank is swapped back. + a.resource_mut::().sync(std::iter::empty()); + frame(&mut a); + assert_eq!((a.resource::().restarts, a.resource::().swaps), (0, 3)); + assert!(holds_library_bank(&a, b.resource::(), &bank), "the install's bank again"); + assert!(render(&a, 300).iter().any(|s| *s != 0.0), "still sounding"); + // A MixMap replacement restarts (the fallback). + let mxb = a.resource::().aems().mixmap.clone().expect("a MixMap"); + std::fs::copy(a.resource::().path(&mxb), dir.join("audio/mix.mxb")).unwrap(); + std::fs::write(dir.join("audio.json"), serde_json::json!({"version": 1, "replace": {"mixmap": "audio/mix.mxb"}}).to_string()).unwrap(); + a.resource_mut::().sync([("dev.swap", dir.as_path(), 3)].into_iter()); + frame(&mut a); + let c = a.resource::(); + assert_eq!((c.restarts, c.swaps), (1, 3)); + assert!(c.last_change.as_deref().is_some_and(|l| l.starts_with("restart: the MixMap file changed")), "{:?}", c.last_change); + 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 463862b1..95c0abb3 100644 --- a/crates/skate-game/src/game_audio/emitters.rs +++ b/crates/skate-game/src/game_audio/emitters.rs @@ -88,7 +88,7 @@ pub(super) fn reverb_zones( ) { let (Some(library), Some(_)) = (library, native) else { return }; let state = &mut *state; - let identity = (map.name.clone(), map.generation, content.generation); + let identity = (map.name.clone(), map.generation, content.world_generation); if state.map.as_ref() != Some(&identity) { state.records = zone_records(&library, &audio); state.active.clear(); @@ -286,6 +286,8 @@ pub(super) struct State { /// after them, in `dynamic` order. map_len: usize, dynamic: Vec, + /// `AudioContent::runtime_generation` the nodes belong to. + runtime: u64, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -356,13 +358,19 @@ pub(super) fn update( 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, content.generation); - if state.map.as_ref().is_some_and(|m| m.2 != content.generation) { + let identity = (map.name.clone(), map.generation, content.world_generation); + let runtime_changed = state.runtime != content.runtime_generation; + if runtime_changed { // 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(); + state.runtime = content.runtime_generation; } if state.map.as_ref() != Some(&identity) { + // A new map or a restart unloads the map's banks (the map-change path); a hot swap that + // changed the world layer (`content::swap_or_restart`) only rebuilds the records: the + // runtime and its banks stay (doc 16 L1). + let map_changed = runtime_changed || state.map.as_ref().is_none_or(|m| m.0 != map.name || m.1 != map.generation); for node in state.nodes.drain(..) { if let Some(post) = node.post { native.release(post); @@ -376,7 +384,9 @@ pub(super) fn update( } } } - native.unload_map_banks(); + if map_changed { + native.unload_map_banks(); + } let stem = audio.stem.as_str(); // Retail's emitter system loads every file of the map's database entry and dispatches by // the attribute's eVolumeType: 1 = looping emitter (here), 5 = reverb zone @@ -416,7 +426,7 @@ pub(super) fn update( if !dynamic.is_empty() || !state.dynamic.is_empty() { sync_dynamic(state, native, &library, &mut mix, &dynamic, &mut api); if extra { - mix.ensure(&library, native, content.generation); + mix.ensure(&library, native, content.runtime_generation); } } let Ok(listener) = listener.single() else { return }; @@ -498,7 +508,7 @@ pub(super) fn update( // sound placed at the owner plays at the record, with the record's reach. let muted = rules.set.as_deref().is_some_and(|r| { let site = (e.shape.position, super::mod_voices::Reach { extent: e.shape.extent, forward: e.shape.forward, core: e.shape.core, curve: e.falloff }); - r.mutes_at(&super::mod_audio::EventRow { kind: super::mod_audio::EventKind::EmitterStart, source: super::mod_audio::Source::Emitter, class: super::mod_audio::intern(&e.bank), slot: "", id: e.patch, owner: node.owner() }, Some(site)) + r.mutes_at_published(&super::mod_audio::EventRow { kind: super::mod_audio::EventKind::EmitterStart, source: super::mod_audio::Source::Emitter, class: super::mod_audio::intern(&e.bank), slot: "", id: e.patch, owner: node.owner() }, Some(site), node.entity.map(Entity::to_bits)) }); node.post = if muted { None } else { native.post_emitter(&payload) }; if api.events.on() && (node.post.is_some() || muted) { @@ -911,6 +921,37 @@ mod tests { assert!(world.resource::().playing.is_empty()); } + /// A published emitter that moves after its sound started (data-gated, doc 16 "moving + /// emitters"): the same post keeps playing and its instance's 3-D input follows the entity + /// (the camera distance it writes), and leaving the reach as it moves away releases it. + #[test] + #[ignore = "needs the private install data"] + fn a_moving_published_emitter_is_followed() { + let (mut world, _) = published_world(true); + let update = world.register_system(update); + let (bank, patch) = downtown_emitter(&world); + let e = publish(&mut world, &bank, patch, Vec3::new(3.0, 0.0, 0.0)); + world.run_system(update).unwrap(); + assert_eq!(world.resource::().playing, [e]); + let dist = |w: &mut World| { + let mut mix = w.resource_mut::(); + let m = mix.mixmap().unwrap(); + (0..super::super::mod_voices::MIX_INSTANCES as u32).map(|g| f32::from_bits(m.input(skate_audio::mixmap::keys::emitter_pos(g), skate_audio::mixmap::keys::pos::DIST_CAMERA) as u32)).find(|d| *d > 0.0) + }; + let near = dist(&mut world).expect("an instance with a position"); + let posts = world.resource::().next_node(); + let t = Transform::from_xyz(8.0, 0.0, 0.0); + world.entity_mut(e).insert((t, GlobalTransform::from(t))); + world.run_system(update).unwrap(); + let far = dist(&mut world).unwrap(); + assert!((near - 3.0).abs() < 1e-3 && (far - 8.0).abs() < 1e-3, "followed: {near} → {far}"); + assert_eq!(world.resource::().next_node(), posts, "the same post, not a new one"); + let t = Transform::from_xyz(500.0, 0.0, 0.0); + world.entity_mut(e).insert((t, GlobalTransform::from(t))); + world.run_system(update).unwrap(); + assert!(world.resource::().playing.is_empty(), "out of reach"); + } + /// A published reverb zone (data-gated): the listener inside it lists the zone (after the /// map's, with its preset and a published id), the retail selector fades to its preset and /// raises its Reverb input; outside or despawned it is gone. diff --git a/crates/skate-game/src/game_audio/library.rs b/crates/skate-game/src/game_audio/library.rs index 1eb0d92f..47b79c3f 100644 --- a/crates/skate-game/src/game_audio/library.rs +++ b/crates/skate-game/src/game_audio/library.rs @@ -899,6 +899,8 @@ pub(crate) struct Library { tuning_raw: std::sync::OnceLock, String>>, /// The loaded tuning sections a runtime tuning write replaced, kept to put back exactly. tuning_saved: TuningSaved, + /// Mod files' stamps (size, modification time) taken at load (`stamp`). + stamps: HashMap, } /// The tuning sections the runtime tuning writes replace (`Library::set_tuning`). @@ -974,6 +976,90 @@ impl Library { } } + /// A manifest file's identity for the hot swap (`swap.rs`): its reference, and for a mod file + /// also its size and modification time (a mod file edited in place is new content; install + /// files do not change while the game runs). + pub(crate) fn stamp(&self, file: &str) -> String { + if skate_mods::audio_merge::split_mod_ref(file).is_none() { + return file.to_owned(); + } + match self.stamps.get(file) { + Some(s) => s.clone(), + None => self.file_stamp(file), + } + } + + fn file_stamp(&self, file: &str) -> String { + let meta = std::fs::metadata(self.path(file)).ok(); + let modified = meta.as_ref().and_then(|m| m.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos()); + format!("{file}|{}|{modified}", meta.map_or(0, |m| m.len())) + } + + /// An AEMS bank's content key (its `.abk`, WAVs by slot, rebuilt headers' loop starts, the + /// overlay's volume group); None when the bank is not in the audio. + pub(crate) fn bank_key(&self, stem: &str) -> Option { + let abk = self.manifest.aems.banks.get(stem)?; + let wavs: Vec = self.manifest.banks.get(stem).map_or(Vec::new(), |e| e.iter().map(|e| self.stamp(&e.file)).collect()); + Some(format!("{}|{:?}|{:?}|{:?}", self.stamp(abk), wavs, self.manifest.mod_sample_loops.get(stem), self.bank_group(stem))) + } + + /// A Splice bank's content key (its patch tree and its WAVs). + pub(crate) fn splice_key(&self, stem: &str) -> Option { + let tree = self.manifest.aems.splice.get(stem)?; + let wavs: Vec = self.manifest.banks.get(stem).map_or(Vec::new(), |e| e.iter().map(|e| self.stamp(&e.file)).collect()); + Some(format!("{}|{wavs:?}", self.stamp(tree))) + } + + /// The wheel-spin streams' content key. + pub(crate) fn wheels_key(&self) -> String { + format!("{:?}", self.manifest.wheels.iter().map(|(k, e)| (k, self.stamp(&e.file))).collect::>()) + } + + /// The MixMap file's content key. + pub(crate) fn mixmap_key(&self) -> Option { + self.manifest.aems.mixmap.as_deref().map(|f| self.stamp(f)) + } + + /// The rolling bed's content key (its grain recordings and the grain player's tuning: the bed + /// is built at the runtime's start). + pub(crate) fn grain_key(&self) -> String { + let grains: Vec<(String, Option, Option)> = self.manifest.grains.iter().map(|(k, g)| (k.clone(), g.file.as_deref().map(|f| self.stamp(f)), g.grain.as_deref().map(|f| self.stamp(f)))).collect(); + format!("{grains:?}|{:?}", self.manifest.grain_player) + } + + /// A speech archive's content key (its index and the overlays' takes). + pub(crate) fn speech_key(&self, archive: &str) -> String { + let e = self.manifest.speech.get(archive).map(|e| (e.index.clone(), e.audio.clone())); + let mods = self.manifest.mod_speech.get(archive).map(|s| { + let takes: Vec<(String, String, String)> = s.takes.iter().flat_map(|(c, t)| t.iter().map(move |(k, f)| (c.clone(), k.clone(), f.clone()))).map(|(c, k, f)| (c, k, self.stamp(&f))).collect(); + let extra: Vec<(String, Vec)> = s.extra.iter().map(|(c, fs)| (c.clone(), fs.iter().map(|f| self.stamp(f)).collect())).collect(); + format!("{takes:?}|{extra:?}") + }); + format!("{e:?}|{mods:?}") + } + + /// The Csis project files in install order, split into the install's and the overlays' (`mod:` + /// files, each with its stamp). + pub(crate) fn project_files(&self) -> (Vec, Vec<(String, String)>) { + let (mods, retail): (Vec<&String>, Vec<&String>) = self.manifest.aems.projects.iter().partition(|f| skate_mods::audio_merge::split_mod_ref(f).is_some()); + (retail.into_iter().cloned().collect(), mods.into_iter().map(|f| (f.clone(), self.stamp(f))).collect()) + } + + /// The world layer's content key: zone beds (with their files), zones, crossfades, regions, + /// `.ems` records, location sets, location programs, crossfade layouts and map audio. When it + /// changes, a hot swap rebuilds the map-keyed world state (emitters, reverb zones, zone + /// ambience); when it does not, they keep playing through the swap. + pub(crate) fn world_key(&self) -> String { + let m = &self.manifest; + let beds: Vec<(&String, String)> = m.ambience.iter().map(|(k, e)| (k, self.stamp(&e.file))).collect(); + format!("{beds:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}", m.zones, m.crossfades, m.regions, m.emitters, m.random_sets, m.mod_location_programs, m.mod_crossfade_layouts, m.mod_maps) + } + + /// The tuning sections as loaded (JSON; `Null` when the install's manifest cannot be read). + pub(crate) fn tuning_sections(&self) -> serde_json::Map { + self.tuning_base().cloned().unwrap_or_default() + } + /// 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)> { @@ -1179,7 +1265,29 @@ impl Library { let speech = overlays.iter().any(|o| skate_mods::audio_content::SpeechClips::needed(o.overlay)) .then(|| skate_mods::audio_content::SpeechClips::load(&root, &value)); let mut owners = Owners::default(); + // Doc 16 L4: mod Csis projects may not reuse a symbol name of the install's projects or of + // an earlier mod's (the game and other mods post by name). Read only when a project is added. + let mut taken: Option> = None; for o in overlays { + let mut symbols = Vec::new(); + if !o.overlay.add.projects.is_empty() { + let taken = taken.get_or_insert_with(|| { + let files: Vec = value["aems"]["projects"].as_array().map_or(Vec::new(), |a| a.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()); + files.iter().filter_map(|f| std::fs::read(root.join(f)).ok().and_then(|b| skate_mods::audio_content::project_symbols(&b, f).ok())).flatten().collect() + }); + let read: Result, String> = o.overlay.add.projects.iter().try_fold(Vec::new(), |mut acc, f| { + let bytes = skate_mods::read_bounded(o.root, f, skate_mods::audio_content::MAX_BINARY_BYTES)?; + acc.extend(skate_mods::audio_content::project_symbols(&bytes, f)?); + Ok(acc) + }); + match read.map(|s| (skate_mods::audio_content::project_clash(taken, &s), s)) { + Ok((None, s)) => symbols = s, + Ok((Some(clash), _)) | Err(clash) => { + report.rejected.push(Message { owner: o.id.to_owned(), text: clash }); + continue; + } + } + } let (mut trial, mut trial_owners, mut r) = (value.clone(), owners.clone(), Report::default()); merge_one_with(&mut trial, &Source { id: o.id, overlay: o.overlay }, &mut trial_owners, &mut r, speech.as_ref()); match serde_json::from_value::(trial.clone()) { @@ -1190,6 +1298,9 @@ impl Library { report.conflicts.extend(r.conflicts); report.warnings.extend(r.warnings); mods.insert(o.id.to_owned(), o.root.to_owned()); + if let Some(t) = taken.as_mut() { + t.extend(symbols); + } } Err(e) => report.rejected.push(Message { owner: o.id.to_owned(), text: format!("audio.json does not fit this install: {e}") }), } @@ -1213,8 +1324,14 @@ impl Library { 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())); + let mut mod_files: Vec = Vec::new(); + for f in files.map(|e| &e.file).chain(aems).chain(speech) { + if !valid(f) { + return Err(format!("{}: invalid file path {f:?}", path.display())); + } + if skate_mods::audio_merge::split_mod_ref(f).is_some() { + mod_files.push(f.clone()); + } } info!( "Game audio: {} ambience beds, {} rolling grains, {} sample banks", @@ -1223,7 +1340,14 @@ impl Library { 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(), tuning_raw: raw, tuning_saved: TuningSaved::default() }, report)) + let mut library = Self { root, manifest, mods, loaded: HashMap::new(), failed: Default::default(), tuning_raw: raw, tuning_saved: TuningSaved::default(), stamps: HashMap::new() }; + // The mod files' stamps as loaded (the hot swap compares them: a file edited later is new + // content of the next library, `stamp`). + for f in mod_files { + let s = library.file_stamp(&f); + library.stamps.insert(f, s); + } + Ok((library, report)) } fn clip(&mut self, assets: &mut Assets, file: &str) -> Option { diff --git a/crates/skate-game/src/game_audio/mixmap_inputs.rs b/crates/skate-game/src/game_audio/mixmap_inputs.rs new file mode 100644 index 00000000..b60ad403 --- /dev/null +++ b/crates/skate-game/src/game_audio/mixmap_inputs.rs @@ -0,0 +1,213 @@ +//! Writable MixMap inputs (doc 16 "L2"; `sdk.audio.set_mixmap_input`, capability `audio` = 4): mods +//! (and engine systems, [`MixMapInputs::set`]) drive retail's own MixMap controllers — the Master +//! category gains, the NIS / menu / speech duck flags, any instance's inputs — instead of only a +//! multiplier on a volume group, so a duck goes through retail's curves, envelopes and ducks. +//! +//! - A write names a controller (`slot`, `object`, `instance`; the same keys as the read-only +//! watch) and one of its 16 inputs, with an integer word (or an f32 for the distance inputs: +//! `float`). Checked when it is set: the slot name, the ranges, the controller must exist in the +//! game's MixMap (an unknown one is a command error). +//! - Owned: the first mod to write an input owns it; 16 inputs per mod, 64 in all. +//! - Applied every pass right before the MixMap's evaluations (`native::mixmap_tick`), after the +//! host's own writes, so a mod value holds against an input the host writes every pass (the +//! Master gains) as well as one it never writes (the duck flags). +//! - Released by `nil`, the owner stopping / failing / reloading: the input gets back the value it +//! had before the first write (an input the host writes takes the host's value again at the next +//! pass anyway). Writes are kept across map changes; after a runtime restart (a new MixMap) the +//! values are written again over the new MixMap's. +//! - With no writes nothing runs (one branch in the pass): the MixMap evaluates exactly as before. +use std::collections::BTreeMap; + +use bevy::prelude::*; +use serde_json::{Value, json}; +use skate_audio::mixmap::MixMap; + +pub(crate) const MAX_PER_MOD: usize = 16; +pub(crate) const MAX_INPUTS: usize = 64; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum InputValue { + Word(i32), + Float(f32), +} + +#[derive(Clone, Debug)] +struct Write { + owner: String, + name: (String, u32, u32), + value: InputValue, + /// The input's value before the first write, in the runtime it was taken in. + original: Option<(u64, i32)>, +} + +#[derive(Resource, Default)] +pub(crate) struct MixMapInputs { + writes: BTreeMap<(u32, usize), Write>, + /// Released inputs to put back at the next pass: (key, input, value, runtime). + restore: Vec<(u32, usize, i32, u64)>, +} + +impl MixMapInputs { + /// Write (`Some`) or release (`None`) `owner`'s value of one MixMap input. + #[allow(clippy::too_many_arguments)] + pub(crate) fn set(&mut self, mixmap: Option<&MixMap>, owner: &str, slot: &str, object: u32, instance: u32, input: u32, value: Option) -> Result<(), String> { + let s = super::mod_audio::slot_number(slot).ok_or_else(|| format!("mixmap input: no MixMap slot {slot}"))?; + if object > 127 || instance > 31 || input > 15 { + return Err("mixmap input: object 0..127, instance 0..31, input 0..15".into()); + } + let key = skate_audio::mixmap::keys::obj(s, object, instance); + let id = (key, input as usize); + if let Some(w) = self.writes.get(&id).filter(|w| w.owner != owner) { + return Err(format!("mixmap input {slot}/{object}/{instance}/{input} is owned by another mod ({})", w.owner)); + } + let Some(value) = value else { + if let Some(w) = self.writes.remove(&id) { + if let Some((runtime, original)) = w.original { + self.restore.push((key, input as usize, original, runtime)); + } + } + return Ok(()); + }; + match value { + InputValue::Float(f) if !f.is_finite() => return Err("mixmap input: a finite value".into()), + _ => {} + } + let m = mixmap.ok_or("native audio is not running")?; + if !m.has_controller(key) { + return Err(format!("mixmap input: the MixMap has no controller {slot}/{object}/{instance}")); + } + if !self.writes.contains_key(&id) { + if self.writes.values().filter(|w| w.owner == owner).count() >= MAX_PER_MOD { + return Err(format!("mixmap input: {MAX_PER_MOD} inputs per mod maximum")); + } + if self.writes.len() >= MAX_INPUTS { + return Err(format!("mixmap input: {MAX_INPUTS} inputs in all maximum")); + } + } + let original = self.writes.get(&id).and_then(|w| w.original); + self.writes.insert(id, Write { owner: owner.to_owned(), name: (slot.to_owned(), object, instance), value, original }); + Ok(()) + } + + /// Every input `owner` writes is released. + pub(crate) fn clear_owner(&mut self, owner: &str) { + let ids: Vec<_> = self.writes.iter().filter(|(_, w)| w.owner == owner).map(|(k, _)| *k).collect(); + for id in ids { + if let Some(w) = self.writes.remove(&id) { + if let Some((runtime, original)) = w.original { + self.restore.push((id.0, id.1, original, runtime)); + } + } + } + } + + /// The pass, before the evaluations (`native::mixmap_tick`): released inputs back, then every + /// write (the original taken first in this runtime). + pub(crate) fn apply(&mut self, m: &mut MixMap, runtime: u64) { + if self.writes.is_empty() && self.restore.is_empty() { + return; + } + for (key, input, value, at) in self.restore.drain(..) { + if at == runtime { + m.set_input(key, input, value); + } + } + for (&(key, input), w) in &mut self.writes { + if w.original.is_none_or(|(at, _)| at != runtime) { + w.original = Some((runtime, m.input(key, input))); + } + match w.value { + InputValue::Word(v) => m.set_input(key, input, v), + InputValue::Float(f) => m.set_input_f32(key, input, f), + } + } + } + + pub(crate) fn count(&self) -> usize { + self.writes.len() + } + + /// One mod's writes for its snapshot (`audio.inputs`). + pub(crate) fn snapshot(&self, owner: &str) -> Option { + let rows: Vec = self + .writes + .iter() + .filter(|(_, w)| w.owner == owner) + .map(|((_, input), w)| { + let value = match w.value { + InputValue::Word(v) => json!(v), + InputValue::Float(f) => json!(f), + }; + json!({"slot": w.name.0, "object": w.name.1, "instance": w.name.2, "input": input, "value": value}) + }) + .collect(); + (!rows.is_empty()).then(|| json!(rows)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Checks, ownership, limits and the release (data-gated: the install's MixMap): a write holds + /// against the host's own value of the input every pass, comes back on release, and drives the + /// controller's outputs (the Master gains duck an emitter's level through retail's curves). + #[test] + #[ignore = "needs the private install data"] + fn mixmap_inputs_drive_retail_controllers_and_release_exactly() { + use skate_audio::mixmap::keys; + let root = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets")); + let Ok(library) = super::super::Library::load(root) else { panic!("missing private data: no audio install") }; + let Ok(native) = super::super::Native::start(&library) else { panic!("missing private data: no AEMS install") }; + let mut m = native.mixmap.expect("a MixMap"); + let host = |m: &mut MixMap| { + for id in 1..=4 { + m.set_input(keys::MASTER, id, 32767); + } + for id in [1, 2, 5] { + m.set_input(keys::MUSIC, id, 32767); + } + m.set_input(keys::REVERB, 5, 32767); + m.set_input(keys::emitter_pos(0), keys::pos::DIST_CAMERA, 0); + m.set_input_f32(keys::emitter_pos(0), keys::pos::DIST_CAMERA, 5.0); + m.set_input_f32(keys::emitter_pos(0), keys::pos::DIST_SKATER, 5.0); + m.set_input(keys::emitter_pos(0), keys::pos::FLAGS, 1); + }; + let mut inputs = MixMapInputs::default(); + // Checks. + assert!(inputs.set(Some(&m), "dev.a", "nope", 2, 0, 1, Some(InputValue::Word(0))).is_err()); + assert!(inputs.set(Some(&m), "dev.a", "global", 2, 0, 16, Some(InputValue::Word(0))).is_err()); + assert!(inputs.set(Some(&m), "dev.a", "global", 120, 0, 1, Some(InputValue::Word(0))).is_err(), "no such controller"); + assert!(inputs.set(None, "dev.a", "global", 2, 0, 1, Some(InputValue::Word(0))).is_err()); + assert!(inputs.set(Some(&m), "dev.a", "global", 2, 0, 1, Some(InputValue::Float(f32::NAN))).is_err()); + let level = |m: &mut MixMap, inputs: &mut MixMapInputs| { + host(m); + inputs.apply(m, 0); + for _ in 0..30 { + m.tick(skate_audio::mixmap::cadence::CONSOLE_DT); + } + m.level(keys::emitter(0), 4) + }; + let retail = level(&mut m, &mut inputs); + assert!(retail > 0, "an emitter at 5 m sounds"); + // The Master category gains to 0: retail's controllers duck the emitter. + for id in 1..=4 { + inputs.set(Some(&m), "dev.a", "global", 2, 0, id, Some(InputValue::Word(0))).unwrap(); + } + assert!(inputs.set(Some(&m), "dev.b", "global", 2, 0, 1, Some(InputValue::Word(5))).is_err(), "first owner wins"); + let ducked = level(&mut m, &mut inputs); + assert!(ducked < retail, "ducked {ducked} < {retail}"); + assert_eq!(m.input(keys::MASTER, 1), 0, "held against the host's write"); + assert_eq!(inputs.snapshot("dev.a").unwrap().as_array().unwrap().len(), 4); + // Release: the inputs come back, and the level with them. + inputs.clear_owner("dev.a"); + assert_eq!(inputs.count(), 0); + let back = level(&mut m, &mut inputs); + assert_eq!(back, retail, "released = retail"); + // Limits. + for i in 0..MAX_PER_MOD as u32 { + inputs.set(Some(&m), "dev.c", "global", 13, 0, i, Some(InputValue::Word(1))).unwrap(); + } + assert!(inputs.set(Some(&m), "dev.c", "global", 6, 0, 0, Some(InputValue::Word(1))).is_err(), "16 per mod"); + } +} diff --git a/crates/skate-game/src/game_audio/mod.rs b/crates/skate-game/src/game_audio/mod.rs index 613278b3..810d11b7 100644 --- a/crates/skate-game/src/game_audio/mod.rs +++ b/crates/skate-game/src/game_audio/mod.rs @@ -23,9 +23,11 @@ mod emitters; mod grain_bed; mod library; mod map_audio; +pub(crate) mod mixmap_inputs; pub(crate) mod mod_audio; pub(crate) mod mod_rules; pub(crate) mod mod_voices; +pub(crate) mod mod_world; mod native; mod npc_skaters; mod player_audio; @@ -33,7 +35,9 @@ mod random_programs; mod random_sets; pub(crate) mod skate_events; mod state_log; +mod seed; mod state_replay; +mod swap; mod timing; pub(crate) mod tuning; mod voices; @@ -211,6 +215,10 @@ impl Plugin for GameAudioPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() .init_resource::() .add_systems(Startup, setup) .add_systems(FixedUpdate, skate_events::observe.after(crate::app::SimulationSet::Physics)) @@ -218,7 +226,7 @@ impl Plugin for GameAudioPlugin { 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). - (content::frame, map_audio::update, mod_audio::events_frame, mod_rules::frame, mod_audio::drain, native::mixmap_frame, world_sources::frame, npc_skaters::frame_pre, native::mixmap_tick, mod_audio::readback, mod_voices::frame, grain_bed::update, emitters::reverb_zones, native::reverb_frame) + (content::frame, map_audio::update, mod_audio::events_frame, mod_rules::frame, mod_audio::drain, native::mixmap_frame, world_sources::frame, npc_skaters::frame_pre, native::mixmap_tick, mod_world::frame, mod_audio::readback, mod_voices::frame, grain_bed::update, emitters::reverb_zones, native::reverb_frame) .chain() .before(CueSet) .after(crate::app::FrameSet::Animation), @@ -250,7 +258,8 @@ fn setup(mut commands: Commands, config: Res) { /// Run `f` on the runtime audio API with the native runtime (if running) and the audio content /// generation: the mod command handlers' entry (`modding`), the same calls engine systems make. pub(crate) fn with_api(world: &mut World, f: impl FnOnce(&mut mod_audio::AudioApi, Option<&native::Native>, u64) -> R) -> Option { - let generation = world.get_resource::().map_or(0, |c| c.generation); + // Handles live as long as the runtime: a hot swap keeps them (`AudioContent::runtime_generation`). + let generation = world.get_resource::().map_or(0, |c| c.runtime_generation); world.get_resource::()?; Some(world.resource_scope(|world, mut api: Mut| f(&mut api, world.get_resource::(), generation))) } @@ -265,6 +274,26 @@ pub(crate) fn clear_mod(world: &mut World, owner: &str) { if let Some(mut r) = world.get_resource_mut::() { r.clear_owner(owner); } + if let Some(mut s) = world.get_resource_mut::() { + s.clear_owner(owner); + } + if let Some(mut i) = world.get_resource_mut::() { + i.clear_owner(owner); + } +} + +/// `sdk.audio.set_mixmap_input`: write (or with `None` release) one MixMap input (doc 16 L2). +#[allow(clippy::too_many_arguments)] +pub(crate) fn set_mixmap_input(world: &mut World, owner: &str, slot: &str, object: u32, instance: u32, input: u32, value: Option) -> Result<(), String> { + world.resource_scope(|world, mut inputs: Mut| { + let mixmap = world.get_resource::().and_then(|n| n.mixmap.as_ref()); + inputs.set(mixmap, owner, slot, object, instance, input, value) + }) +} + +/// `sdk.audio.seed`: seed (or with `None` release) the audio random state (doc 16 L5). +pub(crate) fn set_seed(world: &mut World, owner: &str, seed: Option) -> Result<(), String> { + world.get_resource_mut::().ok_or("game audio is unavailable")?.set(owner, seed) } /// `sdk.audio.rule`: set (or with `None` remove) a mod's rule; a replace / layer rule's WAV is @@ -310,8 +339,15 @@ pub(crate) fn clear_mods_runtime(world: &mut World) { /// The `audio` (per mod) and `audio_info` snapshot sections. pub(crate) fn mod_snapshot(world: &World, owner: &str) -> serde_json::Value { - let generation = world.get_resource::().map_or(0, |c| c.generation); + let generation = world.get_resource::().map_or(0, |c| c.runtime_generation); let mut v = world.get_resource::().map_or(serde_json::Value::Null, |api| api.snapshot(world.get_resource::(), generation, owner)); + // The MixMap inputs this mod writes (doc 16 L2). + if let Some(rows) = world.get_resource::().and_then(|i| i.snapshot(owner)) { + if v.is_null() { + v = serde_json::json!({}); + } + v["inputs"] = rows; + } // The tuning fields this mod owns (as of the last audio pass). let owned = world.get_resource::().map(|t| t.owned(owner)).unwrap_or_default(); if !owned.is_empty() { @@ -328,6 +364,8 @@ pub(crate) fn mod_info(world: &World) -> serde_json::Value { v["rules"] = serde_json::json!(world.get_resource::().map_or(0, mod_rules::AudioRules::count)); v["native_voices"] = serde_json::json!(world.get_resource::().map_or(0, |m| m.voice_usage("").1)); v["native_voices_max"] = serde_json::json!(mod_voices::MAX_NATIVE_VOICES); + v["mixmap_inputs"] = serde_json::json!(world.get_resource::().map_or(0, mixmap_inputs::MixMapInputs::count)); + v["seed"] = world.get_resource::().and_then(|s| s.current()).map_or(serde_json::Value::Null, |(o, n)| serde_json::json!({"owner": o, "seed": n})); v["mod_emitter_slots"] = serde_json::json!(if world.get_resource::().is_some_and(AudioSettings::extra_mod_emitter_slots) { "extra" } else { "shared" }); v } diff --git a/crates/skate-game/src/game_audio/mod_audio.rs b/crates/skate-game/src/game_audio/mod_audio.rs index 8180601e..46dd348c 100644 --- a/crates/skate-game/src/game_audio/mod_audio.rs +++ b/crates/skate-game/src/game_audio/mod_audio.rs @@ -492,7 +492,7 @@ pub(super) fn drain(native: Option>, content: Res api.apply_queue(&native, content.generation), + Some(native) => api.apply_queue(&native, content.runtime_generation), None => api.queue.clear(), } } @@ -750,6 +750,9 @@ pub(crate) fn info(native: Option<&Native>, content: Option<&super::AudioContent "map_epoch": native.map(|n| n.map_epoch), "generation": content.map(|c| c.generation), "restarts": content.map(|c| c.restarts), + // Doc 16 L1: content changes swapped in place, and the last change ("swap" / "restart: …"). + "swaps": content.map(|c| c.swaps), + "last_change": content.and_then(|c| c.last_change.clone()), "overlays": content.map(|c| c.overlays.keys().cloned().collect::>()), "conflicts": content.map(|c| c.report.conflicts.len()), "map": map.map(|m| json!({"stem": m.stem, "district": m.district, "ems": m.ems, "sources": m.sources})), diff --git a/crates/skate-game/src/game_audio/mod_rules.rs b/crates/skate-game/src/game_audio/mod_rules.rs index 610cf372..b622154c 100644 --- a/crates/skate-game/src/game_audio/mod_rules.rs +++ b/crates/skate-game/src/game_audio/mod_rules.rs @@ -38,8 +38,9 @@ const VOICES_PER_RULE: usize = 4; /// Where a rule's sound plays (`skate_mods::audio_rules::RuleAt`). #[derive(Clone, Copy, Debug, PartialEq)] enum Placement { - /// At the owner plus this offset (world axes), followed. - Owner(Vec3), + /// At the owner plus this offset, followed; the offset in the owner's axes when the flag is set + /// (`play.frame = 'owner'`), else world axes. + Owner(Vec3, bool), /// At a fixed world position. World(Vec3), /// Non-positional, centred. @@ -59,6 +60,8 @@ pub(crate) struct Origin { source: Source, owner: u64, site: Option<(Vec3, Reach)>, + /// A published emitter's entity (`WorldEmitter`): the sound follows it (it can move). + entity: Option, } impl Origin { @@ -70,7 +73,10 @@ impl Origin { Source::Npc => Anchor::Npc(self.owner), Source::Emitter => { let (p, r) = self.site?; - Anchor::Fixed(p, r) + match self.entity { + Some(e) => Anchor::Emitter(e, p, r), + None => Anchor::Fixed(p, r), + } } // Not rule sites (zone changes, speech rows). Source::Ambience | Source::Speech => return None, @@ -85,16 +91,16 @@ impl Play { let mut v = self.voice.clone(); let anchor = origin.anchor(); match (self.at, anchor) { - (Placement::Owner(offset), Some(anchor)) => { + (Placement::Owner(offset, local), Some(anchor)) => { // The position comes from the owner at the voices' frame, before any word is read. v.position = Some(Vec3::ZERO); - v.follow = Some(Follow { anchor, offset, track: true }); + v.follow = Some(Follow { anchor, offset, track: true, local }); } (Placement::World(p), anchor) => { v.position = Some(p); - v.follow = anchor.map(|anchor| Follow { anchor, offset: Vec3::ZERO, track: false }); + v.follow = anchor.map(|anchor| Follow { anchor, offset: Vec3::ZERO, track: false, local: false }); } - (Placement::Centre, _) | (Placement::Owner(_), None) => { + (Placement::Centre, _) | (Placement::Owner(..), None) => { v.position = None; v.reach = None; } @@ -154,6 +160,11 @@ impl RuleSet { /// [`Self::mutes`] with the site's own position and reach (an emitter start: its record). pub(crate) fn mutes_at(&self, row: &EventRow, site: Option<(Vec3, Reach)>) -> bool { + self.mutes_at_published(row, site, None) + } + + /// [`Self::mutes_at`] for a published emitter (`WorldEmitter` entity): its sound follows it. + pub(crate) fn mutes_at_published(&self, row: &EventRow, site: Option<(Vec3, Reach)>, entity: Option) -> bool { let tag = self.tags.tag(row); let Some((i, r)) = self.rules.iter().enumerate().find(|(_, r)| r.matches(row, tag)) else { return false }; if r.play.is_some() { @@ -162,7 +173,7 @@ impl RuleSet { if let Some(last) = c.last.get_mut(i) { if now - *last >= r.min_interval { *last = now; - c.plays.push((i, Origin { source: row.source, owner: row.owner, site })); + c.plays.push((i, Origin { source: row.source, owner: row.owner, site, entity })); self.pending.store(true, Ordering::Relaxed); } } @@ -257,7 +268,7 @@ fn play(p: &skate_mods::audio_rules::RulePlay, group: u8) -> Play { use skate_mods::audio_rules::RuleAt; let v3 = |a: Option<[f32; 3]>| a.map_or(Vec3::ZERO, Vec3::from_array); let at = match p.at { - RuleAt::Owner => Placement::Owner(v3(p.offset)), + RuleAt::Owner => Placement::Owner(v3(p.offset), p.frame == Some(skate_mods::audio_rules::RuleFrame::Owner)), RuleAt::World => Placement::World(v3(p.position)), RuleAt::Centre => Placement::Centre, }; @@ -492,16 +503,16 @@ mod tests { }; // The local player's pop: at the skater, followed, its default reach found at play time. let pop = one(row(EventKind::Splice, Source::Player, bank, "", 12), None); - assert_eq!(pop.follow, Some(Follow { anchor: Anchor::Player, offset: Vec3::ZERO, track: true })); + assert_eq!(pop.follow, Some(Follow { anchor: Anchor::Player, offset: Vec3::ZERO, track: true, local: false })); assert!(pop.position.is_some() && pop.reach.is_none()); // A car's horn: at the car (owner 7) 1.5 m up, the mod's reach. let horn = one(EventRow { kind: EventKind::Post, source: Source::World, class: skate_audio::world::traffic::HORN_CLASS, slot: "horn", id: 0, owner: 7 }, None); - assert_eq!(horn.follow, Some(Follow { anchor: Anchor::World(7), offset: Vec3::new(0.0, 1.5, 0.0), track: true })); + assert_eq!(horn.follow, Some(Follow { anchor: Anchor::World(7), offset: Vec3::new(0.0, 1.5, 0.0), track: true, local: false })); assert_eq!(horn.reach, Some(Reach::sphere(12.0, 0.0, 1))); // An NPC skater's post at a fixed world position: not followed; the skater gives the reach. let npc = one(EventRow { kind: EventKind::Post, source: Source::Npc, class: "Class_grind", slot: "grind", id: 0, owner: 9 }, None); assert_eq!(npc.position, Some(Vec3::new(10.0, 0.0, -4.0))); - assert_eq!(npc.follow, Some(Follow { anchor: Anchor::Npc(9), offset: Vec3::ZERO, track: false })); + assert_eq!(npc.follow, Some(Follow { anchor: Anchor::Npc(9), offset: Vec3::ZERO, track: false, local: false })); // Centred: no position, no follow. let land = one(row(EventKind::Splice, Source::Player, bank, "", 20), None); assert_eq!((land.position, land.follow, land.reach), (None, None, None)); @@ -509,7 +520,11 @@ mod tests { let reach = Reach { extent: Vec3::new(20.0, 5.0, 8.0), forward: Vec3::Z, core: 0.2, curve: 1 }; let at = Vec3::new(3.0, 1.0, -7.0); let emit = one(EventRow { kind: EventKind::EmitterStart, source: Source::Emitter, class: "Baby_Cry_1", slot: "", id: 22, owner: 4 }, Some((at, reach))); - assert_eq!(emit.follow, Some(Follow { anchor: Anchor::Fixed(at, reach), offset: Vec3::ZERO, track: true })); + assert_eq!(emit.follow, Some(Follow { anchor: Anchor::Fixed(at, reach), offset: Vec3::ZERO, track: true, local: false })); + // A published emitter's start: its entity travels with the sound (it may move). + set.mutes_at_published(&EventRow { kind: EventKind::EmitterStart, source: Source::Emitter, class: "Baby_Cry_1", slot: "", id: 22, owner: 77 }, Some((at, reach)), Some(77)); + let moving = set.take_plays(&mut 100).remove(0).2; + assert_eq!(moving.follow, Some(Follow { anchor: Anchor::Emitter(77, at, reach), offset: Vec3::ZERO, track: true, local: false })); // An emitter request without its record cannot be placed: centred. let lost = one(EventRow { kind: EventKind::EmitterStart, source: Source::Emitter, class: "Baby_Cry_1", slot: "", id: 22, owner: 4 }, None); assert_eq!((lost.position, lost.follow), (None, None)); diff --git a/crates/skate-game/src/game_audio/mod_voices.rs b/crates/skate-game/src/game_audio/mod_voices.rs index 37b8ff8b..2da059b5 100644 --- a/crates/skate-game/src/game_audio/mod_voices.rs +++ b/crates/skate-game/src/game_audio/mod_voices.rs @@ -207,6 +207,9 @@ pub(crate) enum Anchor { Npc(u64), /// A fixed place and its reach (an emitter record: records do not move). Fixed(Vec3, Reach), + /// A published emitter (`WorldEmitter` entity bits), its position and reach at the start: it + /// can move, the sound follows it (its record's reach turned with it). + Emitter(u64, Vec3, Reach), } /// A rule sound's placement relative to its owner. @@ -218,25 +221,52 @@ pub(crate) struct Follow { /// The sound follows the owner (`at = 'owner'`); false: a fixed position, the owner only gives /// the default reach (`at = 'world'`). pub track: bool, + /// `offset` is in the owner's axes (x right, y up, z its facing; `play.frame = 'owner'`). + pub local: bool, +} + +/// An offset in an owner's axes as a world offset: x = right, y = up, z = `facing` (horizontal; +/// world +Z when the owner has none). +pub(crate) fn owner_offset(offset: Vec3, facing: Vec3) -> Vec3 { + let f = Vec3::new(facing.x, 0.0, facing.z).normalize_or(Vec3::Z); + let right = f.cross(Vec3::Y); + right * offset.x + Vec3::Y * offset.y + f * offset.z +} + +/// A skater's facing from its wheels (front pair minus back pair; FL, FR, BL, BR), else its +/// centre-of-mass velocity. +fn board_facing(s: &skate_audio::player::AudioState) -> Vec3 { + let w = s.wheel_position.map(Vec3::from_array); + let along = (w[0] + w[1] - w[2] - w[3]) * 0.5; + if along.length_squared() > 1e-6 { along } else { Vec3::from_array(s.com_velocity) } } /// The owner's position now and its kind's retail reach: the skater audio radius (30 m, the local /// player too: its sounds sit in the same Player MixMap slot), the traffic list (40 m), the ped /// list (50 m), all with the squared curve; an emitter record's own shape and curve. +#[cfg(test)] pub(crate) fn locate(a: &Anchor, cues: &super::skate_events::Cues, owners: Option<&super::world_sources::WorldOwners>, npcs: Option<&super::npc_skaters::NpcSkaters>) -> Option<(Vec3, Reach)> { + locate_facing(a, cues, owners, npcs, &|_| None).map(|(p, r, _)| (p, r)) +} + +/// [`locate`] with the owner's facing (`Follow::local`), and published emitters found through +/// `emitters` (entity bits → position, forward). +pub(crate) fn locate_facing(a: &Anchor, cues: &super::skate_events::Cues, owners: Option<&super::world_sources::WorldOwners>, npcs: Option<&super::npc_skaters::NpcSkaters>, emitters: &dyn Fn(u64) -> Option<(Vec3, Vec3)>) -> Option<(Vec3, Reach, Vec3)> { use skate_audio::world::skaters::AUDIO_RADIUS; match a { - Anchor::Player => Some((Vec3::from_array(cues.riding.audio.com_position), Reach::sphere(AUDIO_RADIUS, 0.0, 0))), + Anchor::Player => Some((Vec3::from_array(cues.riding.audio.com_position), Reach::sphere(AUDIO_RADIUS, 0.0, 0), board_facing(&cues.riding.audio))), Anchor::World(id) => { let o = owners?; - let (p, r) = o.vehicles.get(id).map(|v| (v.position, super::world_sources::TRAFFIC_LIST_RADIUS)).or_else(|| o.peds.get(id).map(|p| (p.position, super::world_sources::PED_LIST_RADIUS)))?; - Some((Vec3::from_array(p), Reach::sphere(r, 0.0, 0))) + let (p, r, f) = o.vehicles.get(id).map(|v| (v.position, super::world_sources::TRAFFIC_LIST_RADIUS, v.direction)).or_else(|| o.peds.get(id).map(|p| (p.position, super::world_sources::PED_LIST_RADIUS, p.velocity)))?; + Some((Vec3::from_array(p), Reach::sphere(r, 0.0, 0), Vec3::from_array(f))) } - Anchor::Npc(id) => npcs?.skaters.iter().find(|s| s.id == *id).map(|s| (Vec3::from_array(s.state.com_position), Reach::sphere(AUDIO_RADIUS, 0.0, 0))), - Anchor::Fixed(p, r) => Some((*p, *r)), + Anchor::Npc(id) => npcs?.skaters.iter().find(|s| s.id == *id).map(|s| (Vec3::from_array(s.state.com_position), Reach::sphere(AUDIO_RADIUS, 0.0, 0), board_facing(&s.state))), + Anchor::Fixed(p, r) => Some((*p, *r, r.forward)), + Anchor::Emitter(e, _, r) => emitters(*e).map(|(p, forward)| (p, Reach { forward, ..*r }, forward)), } } + /// What `sdk.audio.play` (native) or a rule's sound asks for (validated by `skate_mods`). #[derive(Clone, Debug, PartialEq)] pub(crate) struct VoiceSpec { @@ -434,6 +464,38 @@ impl ModVoices { } } + /// An audio-only reload changed these files of `owner` (doc 16 L7): their clips are dropped + /// (the next play or rule compile reads the new file) and the voices playing them stop. Their + /// old slots stay registered in the mod's bank until the mod stops (a later play takes a new + /// slot). Returns whether anything was dropped. + pub(crate) fn forget_clips(&mut self, owner: &str, paths: &[String]) -> bool { + let mut any = false; + let mut slots: Vec<(usize, u16)> = Vec::new(); + for path in paths { + any |= self.clips.remove(&(owner.to_owned(), path.clone())).is_some(); + for group in 0..2u8 { + if !self.banks.contains_key(&(owner.to_owned(), group)) { + continue; + } + let id = self.bank_id(owner, group); + if let Some(bank) = self.banks.get_mut(&(owner.to_owned(), group)) { + for looping in [false, true] { + if let Some(slot) = bank.slots.remove(&(path.clone(), looping)) { + slots.push((id, slot)); + } + } + } + } + } + let keys: Vec<_> = self.voices.iter().filter(|((o, _), v)| o == owner && slots.contains(&(v.bank, v.slot))).map(|(k, _)| k.clone()).collect(); + for k in keys { + if let Some(v) = self.voices.remove(&k) { + self.forget(v); + } + } + any || !slots.is_empty() + } + /// Everything (a map change: the mod system clears every mod's runtime state). pub(crate) fn clear(&mut self) { let owners: Vec = self.owners.keys().cloned().collect(); @@ -465,6 +527,7 @@ pub(super) fn frame( rules: Option>, owners: Option>, npcs: Option>, + published: Query<(&GlobalTransform, &crate::world_audio::WorldEmitter)>, ) { let plays = rules.as_deref().and_then(|r| r.set.as_ref()).filter(|s| s.has_plays()); if voices.idle() && !mix.in_use() && plays.is_none() { @@ -483,7 +546,7 @@ pub(super) fn frame( } mv.rule_plays = counter; } - let runtime = (Arc::as_ptr(&native.shared) as usize, content.generation); + let runtime = (Arc::as_ptr(&native.shared) as usize, content.runtime_generation); if mv.runtime != Some(runtime) { // A new runtime (first use, or a restart): the old one's mixer ids are forgotten, never // released into this one; the banks are registered again. @@ -501,7 +564,7 @@ pub(super) fn frame( } mv.runtime = Some(runtime); } - mix.ensure(&library, &native, content.generation); + mix.ensure(&library, &native, content.runtime_generation); for (g, build) in std::mem::take(&mut mv.free_instances) { if build == mix.build { mix.release(g); @@ -562,10 +625,15 @@ pub(super) fn frame( // A rule sound's owner: its position now (when followed) and its default reach. An owner // gone after it was found leaves the sound where it was; one never found plays it centred. if let Some(f) = v.spec.follow { - match locate(&f.anchor, &cues, owners.as_deref(), npcs.as_deref()) { - Some((p, reach)) => { + let emitter = |bits: u64| { + let (t, e) = published.get(Entity::try_from_bits(bits)?).ok()?; + let (_, rotation, position) = t.to_scale_rotation_translation(); + Some((position, (rotation * e.forward).normalize_or(Vec3::X))) + }; + match locate_facing(&f.anchor, &cues, owners.as_deref(), npcs.as_deref(), &emitter) { + Some((p, reach, facing)) => { if f.track { - v.spec.position = Some(p + f.offset); + v.spec.position = Some(p + if f.local { owner_offset(f.offset, facing) } else { f.offset }); } v.spec.reach.get_or_insert(reach); v.placed = true; @@ -681,6 +749,29 @@ pub(crate) mod tests { VoiceSpec { path: path.into(), looping: true, volume: 1.0, pitch: 1.0, paused: false, fade_in: 0.0, position, reach: position.map(|_| Reach::sphere(30.0, 0.0, 0)), follow: None, reverb: true, group: 0 } } + /// `play.frame = 'owner'`: an offset in the owner's axes (x right, y up, z facing, the facing + /// flattened to the ground), world +Z without a facing; the owners' facings (a car's direction, + /// a ped's walk, a skater's board from its wheels, an emitter's forward). + #[test] + fn offsets_turn_with_the_owner() { + let o = Vec3::new(1.0, 0.5, 2.0); + assert!(owner_offset(o, Vec3::Z).abs_diff_eq(Vec3::new(-1.0, 0.5, 2.0), 1e-6), "facing +Z: right is -X"); + assert!(owner_offset(o, Vec3::X).abs_diff_eq(Vec3::new(2.0, 0.5, 1.0), 1e-6)); + assert!(owner_offset(o, Vec3::new(0.0, -3.0, 0.0)).abs_diff_eq(owner_offset(o, Vec3::Z), 1e-6), "no horizontal facing: +Z"); + assert!(owner_offset(o, Vec3::new(0.0, 5.0, -2.0)).abs_diff_eq(Vec3::new(1.0, 0.5, -2.0), 1e-6), "pitched facing flattened"); + let mut cues = super::super::skate_events::Cues::default(); + cues.riding.audio.wheel_position = [[1.0, 0.0, 0.5], [1.0, 0.0, -0.5], [0.0, 0.0, 0.5], [0.0, 0.0, -0.5]]; + let none = |_: u64| None; + assert_eq!(locate_facing(&Anchor::Player, &cues, None, None, &none).unwrap().2, Vec3::new(1.0, 0.0, 0.0), "the board's nose"); + let mut owners = super::super::world_sources::WorldOwners::default(); + owners.vehicles.insert(7, skate_audio::world::traffic::VehicleState { direction: [0.0, 0.0, -1.0], ..Default::default() }); + assert_eq!(locate_facing(&Anchor::World(7), &cues, Some(&owners), None, &none).unwrap().2, Vec3::new(0.0, 0.0, -1.0)); + let r = Reach::sphere(5.0, 0.0, 0); + let moved = |_: u64| Some((Vec3::new(9.0, 0.0, 9.0), Vec3::X)); + assert_eq!(locate_facing(&Anchor::Emitter(3, Vec3::ZERO, r), &cues, None, None, &moved).unwrap().0, Vec3::new(9.0, 0.0, 9.0), "a published emitter where it is now"); + assert_eq!(locate_facing(&Anchor::Emitter(3, Vec3::ZERO, r), &cues, None, None, &none), None, "gone"); + } + /// The per-mod banks: a clip gets one slot per play mode (once / looping) in its group's bank; /// every mod has its own banks; a key's play replaces its voice; stopping an owner with its /// clips drops its banks (released in the runtime at the next pass). @@ -993,7 +1084,7 @@ pub(crate) mod tests { // The restart: a new runtime, the content generation bumped. let fresh = Native::start(world.resource::()).unwrap(); world.insert_resource(fresh); - world.resource_mut::().generation += 1; + world.resource_mut::().runtime_generation += 1; for _ in 0..3 { step(&mut world, &mut out); } diff --git a/crates/skate-game/src/game_audio/mod_world.rs b/crates/skate-game/src/game_audio/mod_world.rs new file mode 100644 index 00000000..f9d50a92 --- /dev/null +++ b/crates/skate-game/src/game_audio/mod_world.rs @@ -0,0 +1,200 @@ +//! Extra MixMap instances for published world objects (doc 16 "L3"; world audio extension 3: +//! `sdk.world_audio.spawn(key, 'traffic' | 'ped', {slots = 'own', …})`, engine side the +//! `world_audio::OwnAudioInstance` component). +//! +//! Retail gives the living world 4 traffic and 15 pedestrian MixMap instances (the more-audible +//! setting 8 / 24); every published car and ped competes for them, the nearest win. An object with +//! its own instance does not: it is played by a second world host ([`ModWorld`], the same code as +//! `world_sources`: retail's traffic and ped objects, posts, Splice steps, speech requests) on a +//! **private MixMap** built from the install's MixMap file with the Global slot and +//! [`TRAFFIC`] / [`PEDS`] instances of the Traffic and Pedestrian slots. Its Global inputs are +//! copied from the game's MixMap before each evaluation, so an object's words are what a retail +//! instance gives for the same position (the instances are the MixMap's own controllers). +//! +//! - The retail pools never see these objects (the bridge publishes them to [`OwnWorldOwners`]), +//! so the map's cars and peds keep retail's instances; own objects within the list radii (40 m / +//! 50 m) all play, the nearest [`TRAFFIC`] / [`PEDS`]. +//! - Read back: `WorldAudioInstance { own: true, … }` (`read(key).own`). +//! - Lifecycle as the retail host: a map change (the epoch) resets it, a runtime restart builds the +//! private MixMap again (the host's handles are forgotten with the old runtime); a hot swap +//! keeps it. Its posts draw from the evaluator's random generator like every post (mod-only). +//! - Nothing runs while no object asks for its own instance: the game sounds exactly as before. +//! - NPC skaters stay in retail's Player slot (their host holds a whole skater's components and a +//! grain bed per instance; open question in doc 16). +use std::sync::Arc; + +use bevy::prelude::*; +use skate_audio::mixmap::{MixMap, MixMapFile, keys}; + +use super::world_sources::{WorldHeld, WorldHost, WorldOwners}; +use super::{Library, Native}; + +/// Own Traffic / Pedestrian instances (the MixMap's group field allows 32 per slot). +pub(crate) const TRAFFIC: usize = 16; +pub(crate) const PEDS: usize = 16; + +/// The published objects with their own instance (`world_audio::OwnAudioInstance`); the bridge +/// fills it instead of [`WorldOwners`]. +#[derive(Resource, Default)] +pub(crate) struct OwnWorldOwners(pub(crate) WorldOwners); + +/// The second world host and its private MixMap. +#[derive(Resource, Default)] +pub(crate) struct ModWorld { + host: WorldHost, + mix: Option, + globals: Vec, + /// The runtime it was built for (address, `AudioContent::runtime_generation`). + built: Option<(usize, u64)>, +} + +fn instances() -> [usize; 14] { + let mut n = [0; 14]; + n[keys::slot::GLOBAL as usize] = 1; + n[keys::slot::TRAFFIC as usize] = TRAFFIC; + n[keys::slot::PEDESTRIAN as usize] = PEDS; + n +} + +impl ModWorld { + /// Build for this runtime when needed; false without a MixMap. + fn ensure(&mut self, library: &Library, native: &Native, runtime: u64) -> bool { + let id = (Arc::as_ptr(&native.shared) as usize, runtime); + if self.built == Some(id) { + return self.mix.is_some(); + } + // A new runtime: the old host's handles belong to the old one (forgotten, never released). + self.built = Some(id); + self.host = WorldHost::default(); + self.mix = None; + self.globals.clear(); + let (Some(retail), Some(file)) = (&native.mixmap, &library.aems().mixmap) else { return false }; + let Some(parsed) = library.read(file).ok().and_then(|b| MixMapFile::parse(&b).ok()) else { return false }; + self.globals = retail.controller_keys().filter(|k| (k >> 16) & 0xFF == keys::slot::GLOBAL).collect(); + self.mix = Some(MixMap::new(&parsed, &instances())); + info!("Game audio: private MixMap for own-instance world objects ({TRAFFIC} traffic, {PEDS} pedestrian instances)"); + true + } +} + +/// The pass, after the game's MixMap ticked (`native::mixmap_tick`): the own objects' process on +/// the private MixMap, its evaluations (the Global inputs copied from the game's first), their +/// update. Returns at once while nothing asks for an own instance. +#[allow(clippy::too_many_arguments)] +pub(super) fn frame( + native: Option>, + library: Option>, + owners: Res, + mut world: ResMut, + mut retail: ResMut, + mut held: ResMut, + mut speech: ResMut, + content: Res, + cues: Res, + listener: Query<&GlobalTransform, With>, +) { + let owners = &owners.0; + let idle = owners.vehicles.is_empty() && owners.peds.is_empty() && world.built.is_none(); + if idle { + return; + } + let (Some(mut native), Some(library)) = (native, library) else { return }; + let world = &mut *world; + if !world.ensure(&library, &native, content.runtime_generation) { + return; + } + let calls = native.pending.map_or(0, |p| p.calls); + let camera = listener.single().ok().map(|t| (t.translation().to_array(), t.forward().as_vec3().to_array())); + // The rules and the event rows follow the game's host. + world.host.rules = retail.rules.clone(); + if retail.events.is_some() && world.host.events.is_none() { + world.host.events = Some(Vec::new()); + } else if retail.events.is_none() { + world.host.events = None; + } + let mut mix = world.mix.take(); + super::world_sources::pre_in(&mut world.host, owners, &mut native, mix.as_mut(), (TRAFFIC, PEDS), &library, camera, &cues.riding.audio, calls); + if let (Some(m), Some(game)) = (mix.as_mut(), native.mixmap.as_ref()) { + if world.host.has_pass() { + for &k in &world.globals { + for id in 0..16 { + m.set_input(k, id, game.input(k, id)); + } + } + for _ in 0..calls { + m.tick(skate_audio::mixmap::cadence::CONSOLE_DT); + } + } + } + super::world_sources::post_in(&mut world.host, owners, &mut native, mix.as_mut()); + world.mix = mix; + speech.peds.append(&mut world.host.speech_requests); + for (id, _) in world.host.held().1 { + if let Some(p) = owners.peds.get(&id) { + speech.ped_positions.insert(id, p.position); + } + } + if let (Some(rows), Some(into)) = (world.host.events.as_mut(), retail.events.as_mut()) { + into.append(rows); + } + let (traffic, peds) = world.host.held(); + if held.own_traffic != traffic || held.own_peds != peds { + held.own_traffic = traffic; + held.own_peds = peds; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy::ecs::system::RunSystemOnce; + + /// L3 (data-gated): 6 cars near the listener with their own instance all hold one (retail's 4 + /// stay free for the map's cars), post the engine class and play; the game's host holds + /// nothing; when they go the instances come back. + #[test] + #[ignore = "needs the private install data"] + fn own_instance_cars_all_play_beside_retails_pool() { + let root = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets")); + let Ok(library) = Library::load(root) else { panic!("missing private data: no audio install") }; + let Ok(mut native) = Native::start(&library) else { panic!("missing private data: no AEMS install") }; + native.test_pass(1); + let m = native.mixmap.as_mut().expect("a MixMap"); + for id in 1..=4 { + m.set_input(keys::MASTER, id, 32767); + } + for id in [1, 2, 5] { + m.set_input(keys::MUSIC, id, 32767); + } + m.set_input(keys::REVERB, 5, 32767); + let mut w = World::new(); + w.insert_resource(native); + w.insert_resource(library); + w.init_resource::(); + w.init_resource::(); + w.init_resource::(); + w.init_resource::(); + w.init_resource::(); + w.init_resource::(); + w.init_resource::(); + w.spawn((super::super::GameAudioListener, GlobalTransform::default())); + let record = w.resource::().world_tuning().engine("c04_taxi01").unwrap_or_else(|| panic!("missing private data: no traffic engine records")); + for i in 0..6u64 { + let v = skate_audio::world::traffic::VehicleState { position: [3.0 * i as f32 - 8.0, 0.0, -6.0], direction: [1.0, 0.0, 0.0], speed: 8.0, velocity: [8.0, 0.0, 0.0], engine: record, ..Default::default() }; + w.resource_mut::().0.vehicles.insert(100 + i, v); + } + let posts = w.resource::().next_node(); + for _ in 0..3 { + w.resource_mut::().test_pass(1); + w.run_system_once(frame).unwrap(); + } + let held = w.resource::(); + assert_eq!(held.own_traffic.len(), 6, "all six hold an own instance: {:?}", held.own_traffic); + assert!(held.traffic.is_empty(), "the game's pool is untouched"); + assert!(w.resource::().next_node() > posts, "they posted"); + w.resource_mut::().0.vehicles.clear(); + w.resource_mut::().test_pass(1); + w.run_system_once(frame).unwrap(); + assert!(w.resource::().own_traffic.is_empty(), "released"); + } +} diff --git a/crates/skate-game/src/game_audio/native.rs b/crates/skate-game/src/game_audio/native.rs index ad6c2b6b..7b9c97a0 100644 --- a/crates/skate-game/src/game_audio/native.rs +++ b/crates/skate-game/src/game_audio/native.rs @@ -211,6 +211,8 @@ pub(crate) struct Native { pub(crate) pending: Option, /// Banks audio content overlays load at start and keep across map changes (`preload`). resident: Vec, + /// The overlays' Csis projects installed in the runtime: (file, content stamp, registry token). + pub(crate) mod_projects: Vec<(String, String, u64)>, } /// A pass [`mixmap_frame`] began and [`mixmap_tick`] finishes. @@ -273,10 +275,15 @@ impl Native { } let mut runtime = Runtime::new(); runtime.eval.continue_nodes(first_node); + let mut mod_projects = Vec::new(); 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())?; - runtime.install_project(&project); + let token = runtime.install_project(&project); + // An overlay's project (doc 16 "Mod Csis projects"): after the install's, in mod-id order. + if skate_mods::audio_merge::split_mod_ref(file).is_some() { + mod_projects.push((file.clone(), library.stamp(file), token)); + } } let mixmap = match &files.mixmap { Some(file) => { @@ -320,6 +327,7 @@ impl Native { world, pending: None, resident: Vec::new(), + mod_projects, }; // The environment (reverb) network and the eEQChain buses (optional install data). let (presets, eq) = library.bus_tuning(); @@ -389,6 +397,48 @@ impl Native { } } + /// Replace a loaded bank in place from the library (an audio content hot swap, `swap.rs`): the + /// same runtime id and constructor places, the new program and samples, held posts re-bound + /// (`Runtime::replace_bank`); its volume group from the overlay, else the one it was loaded + /// with (the player's banks: the player group). False when the bank is not loaded (it loads + /// on use). + pub(crate) fn replace_bank(&mut self, library: &Library, stem: &str) -> Result { + let Some(&id) = self.banks.get(stem) else { return Ok(false) }; + self.prefetch.drop_bank(stem); + let (bank, pcm) = library.bank_source(stem)?.load()?; + let player = super::player_audio::BANKS.contains(&stem) || super::player_audio::OPTIONAL_BANKS.iter().any(|b| b.contains(&stem)); + let mut runtime = self.shared.lock().map_err(|_| "audio lock poisoned")?; + runtime.replace_bank(id, bank, pcm); + let group = library.bank_group(stem).unwrap_or(player); + runtime.mixer.set_bank_group(id, if group { skate_audio::mixer::GROUP_PLAYER } else { skate_audio::mixer::GROUP_WORLD }); + Ok(true) + } + + /// Unload one bank (an audio content hot swap: the bank left the audio). + pub(crate) fn unload_bank(&mut self, stem: &str) { + self.prefetch.drop_bank(stem); + self.resident.retain(|r| r != stem); + if let Some(id) = self.banks.remove(stem) { + if let Ok(mut runtime) = self.shared.lock() { + runtime.unload_bank(id); + } + } + } + + /// The overlays' preloaded banks of a new library (an audio content hot swap): the new ones + /// load now; ones no longer preloaded go at the next map change like any map bank. + pub(crate) fn set_resident(&mut self, library: &Library) { + self.resident.clear(); + self.load_resident_banks(library); + } + + /// The banks the runtime holds: (stem, runtime id), sorted by stem. + pub(crate) fn bank_ids(&self) -> Vec<(String, usize)> { + let mut v: Vec<(String, usize)> = self.banks.iter().map(|(s, &id)| (s.clone(), id)).collect(); + v.sort(); + v + } + /// 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()) @@ -550,6 +600,7 @@ impl Native { world: WorldInstances::RETAIL, pending: None, resident: Vec::new(), + mod_projects: Vec::new(), } } @@ -811,12 +862,17 @@ pub(super) fn mixmap_frame( /// The second half of [`mixmap_frame`]'s pass, after the world / NPC owners' process: the console /// evaluations (MixMap ticks), the local player's update and SFXObj_Reverb's update. The world / /// NPC owners' update follows (`world_sources::post`, `npc_skaters::post`). -pub(super) fn mixmap_tick(native: Option>) { +pub(super) fn mixmap_tick(native: Option>, inputs: Option>, content: Option>) { let _timing = super::timing::scope(&super::timing::MIXMAP_FRAME); let Some(mut native) = native else { return }; let native = &mut *native; let Some(PendingPass { calls, s, speed_scale, loose, reverb }) = native.pending else { return }; let Some(m) = &mut native.mixmap else { return }; + // Mods' MixMap input writes (doc 16 L2), after every host write of the pass, before the + // evaluations (nothing without writes). + if let Some(mut inputs) = inputs { + inputs.apply(m, content.map_or(0, |c| c.runtime_generation)); + } for _ in 0..calls { m.tick(skate_audio::mixmap::cadence::CONSOLE_DT); } @@ -1024,6 +1080,7 @@ mod tests { world: WorldInstances::RETAIL, pending: None, resident: Vec::new(), + mod_projects: Vec::new(), } } diff --git a/crates/skate-game/src/game_audio/player_audio.rs b/crates/skate-game/src/game_audio/player_audio.rs index b6eb6b67..dee457c7 100644 --- a/crates/skate-game/src/game_audio/player_audio.rs +++ b/crates/skate-game/src/game_audio/player_audio.rs @@ -113,6 +113,14 @@ pub(crate) struct PlayerAudio { } impl PlayerAudio { + /// SFXObj_Jitter's generator (`seed.rs`, doc 16 L5). + pub(crate) fn jitter_rng(&self) -> skate_audio::eval::rng::Rng { + self.jitter.rng + } + pub(crate) fn jitter_rng_mut(&mut self) -> &mut skate_audio::eval::rng::Rng { + &mut self.jitter.rng + } + /// A runtime tuning write changed the player tuning (`tuning.rs`): the vault tuning, the /// Contacts posters' values and the footstep materials follow. SFXObj_Jitter keeps the walk it /// was built with (its parameters change at the next start). diff --git a/crates/skate-game/src/game_audio/seed.rs b/crates/skate-game/src/game_audio/seed.rs new file mode 100644 index 00000000..72e082d9 --- /dev/null +++ b/crates/skate-game/src/game_audio/seed.rs @@ -0,0 +1,291 @@ +//! A seedable audio random state for reproducible mod tests (doc 16 "L5"; `sdk.audio.seed(n)`, +//! capability `audio` = 4; `SKATE_AUDIO_SEED=` for a whole run). +//! +//! The audio draws from several generators, each started at a fixed state as retail's is: the +//! evaluator's (every program's ops 7–9), the Splice player's picks, the rolling bed's grain picks, +//! the eEQChain buses' rolls, the Jitter walk, the world host's (`Lcg(0x5EED)`: traffic and peds) +//! and the speech host's (`Lcg(0x5EEC)`). They are deterministic from the start, but a mod test +//! cannot start them from a known point mid-session. A seed sets **all of them** from one number +//! (splitmix64 per generator), at the start of the next audio pass, so the same seed at the same +//! point gives the same draws. +//! +//! - Owned: the first mod to seed owns it (another mod's seed is an error); the same mod may seed +//! again (a new starting point). +//! - `nil` (or the owner stopping) puts back the states the generators had at the first seed (the +//! retail sequence continues from where it was then); the draws made while seeded are not undone. +//! - A runtime restart starts every generator at its retail state again: a seed in force is applied +//! again after it. +//! - Unseeded nothing here runs ([`apply`] returns at once): the draws are retail's, unchanged. +use bevy::prelude::*; + +use super::Native; + +/// Every audio generator's state. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Generators { + pub eval: [u32; 6], + pub splice: u32, + pub grains: [u32; 6], + pub eq: [u32; 6], + pub jitter: Option<[u32; 6]>, + pub world: u32, + pub speech: u32, +} + +fn splitmix(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +impl Generators { + /// The states a seed gives (each generator its own words from one splitmix64 stream). + pub(crate) fn from_seed(seed: u64) -> Self { + let mut s = seed; + let mut word = || splitmix(&mut s) as u32; + let mut six = || std::array::from_fn(|_| word()); + let (eval, grains, eq, jitter) = (six(), six(), six(), six()); + let mut s2 = seed ^ 0x5EED_5EED_5EED_5EED; + let mut more = || splitmix(&mut s2) as u32; + Self { eval, splice: more(), grains, eq, jitter: Some(jitter), world: more(), speech: more() } + } + + /// The running audio's generators (None without the runtime). + pub(crate) fn read(world: &World) -> Option { + let native = world.get_resource::()?; + let jitter = native.player.as_ref().map(|p| p.jitter_rng().w); + let rt = native.shared.lock().ok()?; + Some(Self { + eval: rt.eval.rng.w, + splice: rt.splice.rng.0, + grains: rt.grains.rng.w, + eq: rt.mixer.buses.eq.rng.w, + jitter, + world: world.get_resource::().map_or(0, |h| h.rng_state()), + speech: world.get_resource::().map_or(0, |s| s.rng_state()), + }) + } + + pub(crate) fn write(&self, world: &mut World) { + if let Some(mut native) = world.get_resource_mut::() { + if let (Some(p), Some(j)) = (native.player.as_mut(), self.jitter) { + p.jitter_rng_mut().w = j; + } + if let Ok(mut rt) = native.shared.lock() { + rt.eval.rng.w = self.eval; + rt.splice.rng.0 = self.splice; + rt.grains.rng.w = self.grains; + rt.mixer.buses.eq.rng.w = self.eq; + } + } + if let Some(mut h) = world.get_resource_mut::() { + h.set_rng_state(self.world); + } + if let Some(mut s) = world.get_resource_mut::() { + s.set_rng_state(self.speech); + } + } +} + +#[derive(Resource, Default)] +pub(crate) struct AudioSeed { + owner: Option, + seed: Option, + /// A change waits for the next pass. + pending: bool, + /// The states at the first seed (restored by `nil`). + saved: Option, + /// `AudioContent::runtime_generation` the seed was applied in. + applied: Option, + /// Seeds applied (the info readout; tests). + pub(crate) count: u64, +} + +impl AudioSeed { + /// Seed (`Some`) or release (`None`) for `owner`; the first owner wins. + pub(crate) fn set(&mut self, owner: &str, seed: Option) -> Result<(), String> { + if let Some(o) = self.owner.as_deref().filter(|o| *o != owner) { + return Err(format!("audio seed is owned by another mod ({o})")); + } + match seed { + Some(s) => { + self.owner = Some(owner.to_owned()); + self.seed = Some(s); + } + None => { + if self.owner.is_none() { + return Ok(()); + } + self.owner = None; + self.seed = None; + } + } + self.pending = true; + Ok(()) + } + + /// The owner stopped, failed or reloaded. + pub(crate) fn clear_owner(&mut self, owner: &str) { + if self.owner.as_deref() == Some(owner) { + let _ = self.set(owner, None); + } + } + + /// (owner, seed) in force. + pub(crate) fn current(&self) -> Option<(&str, u64)> { + Some((self.owner.as_deref()?, self.seed?)) + } +} + +/// The start of the audio pass (`content::frame`, after a restart): apply a new seed, a release, +/// or a seed in force again in a restarted runtime. Returns at once while unseeded. +pub(super) fn apply(world: &mut World) { + let runtime = world.get_resource::().map_or(0, |c| c.runtime_generation); + let Some(s) = world.get_resource::() else { return }; + let again = s.seed.is_some() && s.applied != Some(runtime); + if !s.pending && !again { + return; + } + if world.get_resource::().is_none() { + return; + } + let (seed, restarted) = (s.seed, s.applied != Some(runtime)); + match seed { + Some(seed) => { + let now = Generators::read(world); + let mut s = world.resource_mut::(); + if s.saved.is_none() || restarted { + s.saved = now; + } + s.applied = Some(runtime); + s.pending = false; + s.count += 1; + Generators::from_seed(seed).write(world); + info!("Game audio: audio random state seeded ({seed})"); + } + None => { + let mut s = world.resource_mut::(); + let saved = s.saved.take().filter(|_| s.applied == Some(runtime)); + s.applied = None; + s.pending = false; + if let Some(g) = saved { + g.write(world); + info!("Game audio: audio random state restored (unseeded)"); + } + } + } +} + +/// `SKATE_AUDIO_SEED=` (a whole run's tests): seeds at the first start, owned by "env". +pub(super) fn from_env(world: &mut World) { + let Some(seed) = std::env::var("SKATE_AUDIO_SEED").ok().and_then(|v| v.trim().parse::().ok()) else { return }; + if let Some(mut s) = world.get_resource_mut::() { + let _ = s.set("env", Some(seed)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_seed_gives_its_own_states_and_ownership_holds() { + let a = Generators::from_seed(7); + assert_eq!(a, Generators::from_seed(7)); + assert_ne!(a, Generators::from_seed(8)); + assert_ne!(a.eval, a.grains, "each generator its own words"); + let mut s = AudioSeed::default(); + s.set("dev.a", Some(1)).unwrap(); + assert!(s.set("dev.b", Some(2)).is_err(), "first owner wins"); + s.set("dev.a", Some(3)).unwrap(); + assert_eq!(s.current(), Some(("dev.a", 3))); + s.clear_owner("dev.b"); + assert_eq!(s.current(), Some(("dev.a", 3))); + s.clear_owner("dev.a"); + assert_eq!(s.current(), None); + s.set("dev.b", Some(2)).unwrap(); + assert_eq!(s.current(), Some(("dev.b", 2))); + } + + fn world(library: &super::super::Library) -> World { + let mut w = World::new(); + w.insert_resource(Native::start(library).unwrap_or_else(|e| panic!("missing private data: {e}"))); + w.init_resource::(); + w.init_resource::(); + w.init_resource::(); + w.init_resource::(); + w + } + + /// Posts of the `k`-th DownTown emitter bank (released and posted again), rendered. + fn scenario(w: &mut World, library: &super::super::Library, k: usize) -> Vec { + let r = library.emitters("sfx_downtown").iter().filter(|r| r.kind == 1 && r.bank.as_ref().is_some_and(|b| library.aems().banks.contains_key(b))).nth(k).expect("an emitter"); + let mut native = w.resource_mut::(); + native.ensure_bank(library, r.bank.as_ref().unwrap()).unwrap(); + let payload = native.emitter_payload(None, 0.8, 9000, r.patch); + let mut node = native.post_emitter(&payload).unwrap(); + let mut out = vec![0.0f32; 2 * skate_audio::BLOCK]; + let mut all = Vec::new(); + for b in 0..900 { + if b % 300 == 299 { + native.release(node); + node = native.post_emitter(&payload).unwrap(); + } + native.shared.lock().unwrap().fill_stereo(&mut out); + all.extend_from_slice(&out); + } + native.release(node); + all + } + + /// L5 (data-gated): unseeded, `apply` changes nothing (every generator keeps retail's state); + /// the same seed at the same point gives the same output from equal worlds whose random + /// states were scrambled just before it, another seed other draws (on an emitter bank whose program draws); + /// `nil` puts back the states from the first seed; a restarted runtime is seeded again. + #[test] + #[ignore = "needs the private install data"] + fn a_seed_makes_runs_reproducible_and_unseeded_is_retail() { + let root = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets")); + let Ok(library) = super::super::Library::load(root) else { panic!("missing private data: no audio install") }; + let same = |p: &[f32], q: &[f32]| p.len() == q.len() && p.iter().zip(q).all(|(i, j)| i.to_bits() == j.to_bits()); + let eligible = library.emitters("sfx_downtown").iter().filter(|r| r.kind == 1 && r.bank.as_ref().is_some_and(|b| library.aems().banks.contains_key(b))).count(); + let mut drew = None; + for k in 0..eligible.min(40) { + let (mut a, mut b, mut c) = (world(&library), world(&library), world(&library)); + let retail = Generators::read(&a).unwrap(); + apply(&mut a); + assert_eq!(Generators::read(&a).unwrap(), retail, "unseeded: nothing changes"); + for w in [&mut a, &mut b, &mut c] { + scenario(w, &library, k); + } + // a's generators scrambled just before the seed point: the seed sets every one. + Generators::from_seed(99).write(&mut a); + let before = Generators::read(&b).unwrap(); + for (w, seed) in [(&mut a, 5), (&mut b, 5), (&mut c, 6)] { + w.resource_mut::().set("dev.t", Some(seed)).unwrap(); + apply(w); + } + assert_eq!(Generators::read(&a), Generators::read(&b)); + let (x, y, z) = (scenario(&mut a, &library, k), scenario(&mut b, &library, k), scenario(&mut c, &library, k)); + assert!(same(&x, &y), "emitter {k}: the same seed, the same output"); + if !same(&x, &z) { + drew = Some((k, a, b, before)); + break; + } + } + let Some((k, mut a, mut b, before)) = drew else { panic!("no DownTown emitter program draws") }; + eprintln!("emitter {k} draws: another seed, other output"); + // Release: b's generators get back their states from the first seed. + b.resource_mut::().set("dev.t", None).unwrap(); + apply(&mut b); + assert_eq!(Generators::read(&b).unwrap(), before); + // A restart (new runtime generation) is seeded again. + a.insert_resource(Native::start(&library).unwrap()); + a.resource_mut::().runtime_generation += 1; + apply(&mut a); + assert_eq!(Generators::read(&a).unwrap().eval, Generators::from_seed(5).eval); + assert_eq!(a.resource::().count, 2); + } +} diff --git a/crates/skate-game/src/game_audio/swap.rs b/crates/skate-game/src/game_audio/swap.rs new file mode 100644 index 00000000..0042a60a --- /dev/null +++ b/crates/skate-game/src/game_audio/swap.rs @@ -0,0 +1,170 @@ +//! Audio content hot swap (doc 16 "L1"): when the running mods' audio content changes (a mod +//! starts, stops, fails, reloads, or its `audio.json` / files change while it runs), the new +//! [`Library`] is swapped in **without restarting the native runtime**: only what changed is +//! replaced, in place, and everything else keeps playing. +//! +//! What is swapped in place, and why it is exact (the runtime then holds exactly what a restart +//! would have loaded for it): +//! - **AEMS banks** the runtime holds whose content changed (program, WAVs by slot, rebuilt +//! headers, volume group): `Runtime::replace_bank` keeps the bank's id and its place among each +//! class's constructors; its instances go (their voices released) and every post its poster +//! still holds is re-bound to the new bank with the post's payload, so a held sound (a +//! traffic engine, an emitter, the player's rolling) continues on the new content. A bank that +//! left the audio is unloaded. Banks not loaded load from the new library on use. +//! - **Overlay-preloaded banks**: the new set loads now. +//! - **Splice trees** (pops, landings, foley, the ped ring) and **wheel streams**: replaced in place +//! (sounding Splice sounds of a replaced tree stop, as after a release). +//! - **Mod Csis projects** (L4): a new one is installed, one that went is taken out of the lookups +//! (`Registry::uninstall`), a changed one both; the banks bound to them are replaced (re-bound) +//! or unloaded. +//! - **Tuning sections** from overlays: handed to the systems that cache them (as a tuning write). +//! - **Speech** takes / index: the speech host reloads its data (the lines speaking stop). +//! - **The world layer** (beds, zones, crossfades, regions, `.ems` records, sets, map audio): the +//! map-keyed state rebuilds only when it changed (`AudioContent::world_generation`). +//! +//! What still restarts the runtime (the fallback: it cannot be swapped exactly in place): +//! - the **MixMap** file (its controller graph and every instance's state are built from it); +//! - the **rolling bed's** grain recordings or grain tuning (the bed is built at start); +//! - the **install's own projects** (never changed by overlays; a guard); +//! - a Splice tree a library lost (a guard) and any failure while swapping (logged). +use bevy::prelude::*; +use serde_json::Value; + +use super::{Library, Native}; + +/// What a swap changes, or why it cannot be done in place. +#[derive(Debug, Default)] +pub(crate) struct Plan { + /// Reasons for the restart fallback (empty: swap in place). + pub restart: Vec, + pub replace: Vec, + pub unload: Vec, + pub splice: Vec, + pub wheels: bool, + /// Mod projects to take out (file, token) and to install (file). + pub projects_out: Vec<(String, u64)>, + pub projects_in: Vec, + pub resident: bool, + pub tuning: Vec<(&'static str, Value, Value)>, + pub speech: bool, + pub world: bool, +} + +/// Compare the library the runtime runs on with the new one. +pub(crate) fn plan(old: &Library, new: &Library, native: &Native) -> Plan { + let mut p = Plan::default(); + if old.mixmap_key() != new.mixmap_key() { + p.restart.push("the MixMap file changed".into()); + } + if old.grain_key() != new.grain_key() { + p.restart.push("the rolling bed's grain recordings or tuning changed".into()); + } + let (old_retail, _) = old.project_files(); + let (new_retail, new_mods) = new.project_files(); + if old_retail != new_retail { + p.restart.push("the install's Csis projects changed".into()); + } + // Mod projects: the runtime's installed list against the new one. + for (file, stamp, token) in &native.mod_projects { + if !new_mods.iter().any(|(f, s)| f == file && s == stamp) { + p.projects_out.push((file.clone(), *token)); + } + } + for (file, stamp) in &new_mods { + if !native.mod_projects.iter().any(|(f, s, _)| f == file && s == stamp) { + p.projects_in.push(file.clone()); + } + } + let project_banks: Vec = { + let rt = native.shared.lock(); + let ids: Vec = rt.as_ref().map_or(Vec::new(), |rt| p.projects_out.iter().flat_map(|(_, t)| rt.eval.banks_using_project(*t)).collect()); + native.bank_ids().into_iter().filter(|(_, id)| ids.contains(id)).map(|(s, _)| s).collect() + }; + for (stem, _) in native.bank_ids() { + match (old.bank_key(&stem), new.bank_key(&stem)) { + (_, None) => p.unload.push(stem), + (a, Some(b)) if a.as_ref() != Some(&b) || project_banks.contains(&stem) || !p.projects_in.is_empty() && new.is_mod_bank(&stem) => p.replace.push(stem), + _ => {} + } + } + if let Ok(rt) = native.shared.lock() { + for b in &rt.splice.banks { + match (old.splice_key(&b.name), new.splice_key(&b.name)) { + (_, None) => p.restart.push(format!("the Splice tree {} left the audio", b.name)), + (a, Some(n)) if a.as_ref() != Some(&n) => p.splice.push(b.name.clone()), + _ => {} + } + } + } + p.wheels = old.wheels_key() != new.wheels_key(); + p.resident = old.resident_banks() != new.resident_banks(); + let (a, b) = (old.tuning_sections(), new.tuning_sections()); + for section in super::library::TUNING_SECTIONS { + let (x, y) = (a.get(section).cloned().unwrap_or(Value::Null), b.get(section).cloned().unwrap_or(Value::Null)); + if x != y { + p.tuning.push((section, x, y)); + } + } + p.speech = ["livingworld", "maincast"].iter().any(|a| old.speech_key(a) != new.speech_key(a)); + p.world = old.world_key() != new.world_key(); + p +} + +/// Apply a plan without restart reasons to the running runtime (the new library is not yet a +/// resource). Errors are logged; the caller restarts on `Err`. +pub(crate) fn apply(world: &mut World, plan: &Plan, new: &Library) -> Result<(), String> { + let mut native = world.remove_resource::().ok_or("no native runtime")?; + let result = apply_native(&mut native, plan, new); + world.insert_resource(native); + result?; + if plan.speech { + if let Some(native) = world.get_resource::().map(|n| n.shared.clone()) { + if let (Some(mut speech), Ok(mut rt)) = (world.get_resource_mut::(), native.lock()) { + speech.reload_content(&mut rt); + } + } + } + Ok(()) +} + +fn apply_native(native: &mut Native, plan: &Plan, new: &Library) -> Result<(), String> { + // Banks bound to a project that goes come out first; the project after them. + for stem in &plan.unload { + native.unload_bank(stem); + } + { + let mut rt = native.shared.lock().map_err(|_| "audio lock poisoned")?; + for (file, token) in &plan.projects_out { + rt.eval.uninstall_project(*token); + info!("Game audio: swap: Csis project {file} out"); + } + } + native.mod_projects.retain(|(_, _, t)| !plan.projects_out.iter().any(|(_, x)| x == t)); + for file in &plan.projects_in { + let bytes = new.read(file).map_err(|e| format!("{file}: {e}"))?; + let project = skate_audio::formats::Project::parse(file, &bytes).map_err(|e| e.to_string())?; + let token = native.shared.lock().map_err(|_| "audio lock poisoned")?.install_project(&project); + native.mod_projects.push((file.clone(), new.stamp(file), token)); + info!("Game audio: swap: Csis project {file} in"); + } + for stem in &plan.replace { + native.replace_bank(new, stem)?; + } + if plan.resident { + native.set_resident(new); + } + if !plan.splice.is_empty() || plan.wheels { + let mut rt = native.shared.lock().map_err(|_| "audio lock poisoned")?; + let rt = &mut *rt; + for stem in &plan.splice { + let (bank, pcm) = new.splice_bank(stem).ok_or_else(|| format!("Splice tree {stem} does not load"))?; + rt.splice.replace_bank(stem, bank, pcm, &mut rt.mixer); + } + if plan.wheels { + rt.load_streams(super::player_audio::WHEEL_STREAMS.iter().map(|n| new.wheels_pcm(n)).collect()); + } + } + // A decoded bank the prefetch worker holds may be of the old content. + native.prefetch.clear(); + Ok(()) +} diff --git a/crates/skate-game/src/game_audio/tuning.rs b/crates/skate-game/src/game_audio/tuning.rs index 684ce866..a4f2d484 100644 --- a/crates/skate-game/src/game_audio/tuning.rs +++ b/crates/skate-game/src/game_audio/tuning.rs @@ -223,7 +223,7 @@ pub(super) fn apply(world: &mut World) { /// Hand changed sections (section, before, after) to the systems that cache them; within the /// bus section only the parts that changed (a FlangeSub preset re-applied restarts its LFOs). -fn retune(world: &mut World, changed: &[(&'static str, Value, Value)]) { +pub(super) fn retune(world: &mut World, changed: &[(&'static str, Value, Value)]) { let part = |section: &str, field: &str| changed.iter().any(|(s, a, b)| *s == section && (field.is_empty() || a.get(field) != b.get(field))); world.resource_scope(|world, library: Mut| { let library = &*library; diff --git a/crates/skate-game/src/game_audio/world_bridge.rs b/crates/skate-game/src/game_audio/world_bridge.rs index c3d5cc9e..c677ce6c 100644 --- a/crates/skate-game/src/game_audio/world_bridge.rs +++ b/crates/skate-game/src/game_audio/world_bridge.rs @@ -86,6 +86,7 @@ pub(crate) fn register(app: &mut App) { app.init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_message::() .add_message::() .add_message::() @@ -203,15 +204,15 @@ fn tag_remote_players(mut commands: Commands, remotes: Query, - mut owners: ResMut, + (mut owners, mut own): (ResMut, ResMut), mut skaters: ResMut, living: Res, library: Option>, physics: Option>, time: Res