diff --git a/crates/skate-game/src/game_audio/mod_world.rs b/crates/skate-game/src/game_audio/mod_world.rs index f9d50a92..384fa875 100644 --- a/crates/skate-game/src/game_audio/mod_world.rs +++ b/crates/skate-game/src/game_audio/mod_world.rs @@ -1,6 +1,7 @@ -//! 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). +//! Extra MixMap instances for published world objects (doc 16 "L3" / M3; world audio extension 3, +//! the default for a mod's cars and peds since extension 4: `sdk.world_audio.spawn(key, 'traffic' +//! | 'ped', …)` without `slots = 'shared'`; engine side the `world_audio::OwnAudioInstance` +//! component, which engine systems publishing the living world do not add). //! //! 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 @@ -14,6 +15,13 @@ //! - 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`]. +//! - **A full private pool** (more than [`TRAFFIC`] own cars or [`PEDS`] own peds of all mods in +//! reach): the nearest hold the instances (retail's own rule, `owners::Pool`), the others wait, +//! silent, and take an instance as soon as one is among the nearest again (a nearer one moves +//! away or goes). Never refused (only the ones in reach compete, a mod may publish 48) and never +//! spilled into retail's pools (the map's objects keep all of theirs). Read back: +//! `WorldAudioStats::own_waiting`, `read(key).waiting`, `info().own.waiting`; a warning is +//! logged when waiting starts. //! - 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 @@ -142,6 +150,21 @@ pub(super) fn frame( held.own_traffic = traffic; held.own_peds = peds; } + // A full private pool: the nearest own objects play, the others in reach wait (read back as + // `waiting`; logged when waiting starts, not every frame). + let (cars, people) = &world.host.waiting; + if held.own_waiting_traffic != *cars || held.own_waiting_peds != *people { + let was = (held.own_waiting_traffic.len(), held.own_waiting_peds.len()); + if (cars.len() > was.0 && was.0 == 0) || (people.len() > was.1 && was.1 == 0) { + warn!( + "AUDIO_WORLD own instances full: {} car(s) / {} ped(s) in reach wait ({TRAFFIC} own traffic / {PEDS} own pedestrian instances; the nearest play, the next nearest takes a freed one)", + cars.len(), + people.len() + ); + } + held.own_waiting_traffic.clone_from(cars); + held.own_waiting_peds.clone_from(people); + } } #[cfg(test)] @@ -197,4 +220,59 @@ mod tests { w.run_system_once(frame).unwrap(); assert!(w.resource::().own_traffic.is_empty(), "released"); } + + /// Doc 16 M3 (data-gated): a full private pool. 18 own cars in reach: the 16 nearest hold the + /// own instances, the 2 farthest wait (read back), retail's pool holds none (no spill), one + /// beyond the list radius is not waiting; when a near one goes, the nearest waiting one takes + /// its instance at the next pass, and with fewer than 16 in reach nobody waits. + #[test] + #[ignore = "needs the private install data"] + fn a_full_private_pool_lets_the_nearest_play_and_the_rest_wait() { + 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 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")); + // Car i at 2 + i m (horizontal): 0..15 nearest, 16 and 17 farther, 99 beyond 40 m. + let car = |d: f32| skate_audio::world::traffic::VehicleState { position: [d, 0.0, 0.0], direction: [0.0, 0.0, 1.0], speed: 5.0, velocity: [0.0, 0.0, 5.0], engine: record, ..Default::default() }; + for i in 0..18u64 { + w.resource_mut::().0.vehicles.insert(200 + i, car(2.0 + i as f32)); + } + w.resource_mut::().0.vehicles.insert(299, car(45.0)); + let pass = |w: &mut World| { + w.resource_mut::().test_pass(1); + w.run_system_once(frame).unwrap(); + }; + for _ in 0..2 { + pass(&mut w); + } + let held = w.resource::(); + let mut holders: Vec = held.own_traffic.iter().map(|h| h.0).collect(); + holders.sort_unstable(); + assert_eq!(holders, (200..216).collect::>(), "the 16 nearest hold the own instances"); + assert_eq!(held.own_waiting_traffic, vec![216, 217], "the 2 farther in reach wait; 299 is out of reach"); + assert!(held.traffic.is_empty() && held.waiting_traffic.is_empty(), "no spill into retail's pool"); + // A near one goes: the nearest waiting car takes its instance. + w.resource_mut::().0.vehicles.remove(&203); + pass(&mut w); + let held = w.resource::(); + assert!(held.own_traffic.iter().any(|h| h.0 == 216), "216 took the freed instance: {:?}", held.own_traffic); + assert_eq!(held.own_waiting_traffic, vec![217]); + w.resource_mut::().0.vehicles.remove(&204); + pass(&mut w); + let held = w.resource::(); + assert_eq!(held.own_traffic.len(), 16); + assert!(held.own_waiting_traffic.is_empty(), "16 in reach: nobody waits"); + } } diff --git a/crates/skate-game/src/game_audio/world_bridge.rs b/crates/skate-game/src/game_audio/world_bridge.rs index c677ce6c..a7396522 100644 --- a/crates/skate-game/src/game_audio/world_bridge.rs +++ b/crates/skate-game/src/game_audio/world_bridge.rs @@ -439,6 +439,7 @@ fn read_back( mut bridge: ResMut, held: Res, owners: Res, + own: Res, skaters: Res, native: Option>, mut stats: ResMut, @@ -480,6 +481,13 @@ fn read_back( instances: (world.traffic, world.peds, world.npc), more_audible: world != super::native::WorldInstances::RETAIL, speech_lines: held.speech_lines, + waiting: held.waiting_traffic.iter().chain(&held.waiting_peds).map(|&id| Entity::from_bits(id)).collect(), + own_vehicles: own.0.vehicles.len(), + own_peds: own.0.peds.len(), + own_traffic_held: held.own_traffic.len(), + own_peds_held: held.own_peds.len(), + own_instances: (super::mod_world::TRAFFIC, super::mod_world::PEDS), + own_waiting: held.own_waiting_traffic.iter().chain(&held.own_waiting_peds).map(|&id| Entity::from_bits(id)).collect(), }; // One summary line per second while anything is published (log-based checks; the test mod). if bridge.active { @@ -501,6 +509,18 @@ fn read_back( held.npc_posts.saturating_sub(npc), held.speech_lines.saturating_sub(speech) ); + if next.own_vehicles + next.own_peds > 0 { + info!( + "WORLD_AUDIO own instances: cars {}/audible {}, peds {}/{}, waiting {} (of {} / {})", + next.own_vehicles, + next.own_traffic_held, + next.own_peds, + next.own_peds_held, + next.own_waiting.len(), + next.own_instances.0, + next.own_instances.1 + ); + } bridge.log_counts = (held.posts, held.npc_posts, held.speech_lines); } } else { diff --git a/crates/skate-game/src/game_audio/world_sources.rs b/crates/skate-game/src/game_audio/world_sources.rs index 8f22ad86..9bae346b 100644 --- a/crates/skate-game/src/game_audio/world_sources.rs +++ b/crates/skate-game/src/game_audio/world_sources.rs @@ -422,6 +422,10 @@ pub(crate) struct WorldHost { pub(crate) events: super::mod_audio::EventBuf, /// The mods' mute / replace / layer rules (`mod_rules`); None without rules. pub(crate) rules: Option>, + /// The owners (cars, peds) inside the list radius that hold no instance after the last + /// assignment: every instance is held by a nearer one; each takes one as soon as it is among + /// the nearest (read back, doc 16 M3; bookkeeping only). + pub(crate) waiting: (Vec, Vec), } impl Default for WorldHost { @@ -447,6 +451,7 @@ impl Default for WorldHost { speech_requests: Vec::new(), events: None, rules: None, + waiting: (Vec::new(), Vec::new()), } } } @@ -497,6 +502,12 @@ pub(crate) struct WorldHeld { /// (owner, instance) of the objects with their own instance (`mod_world`, doc 16 L3). pub(crate) own_traffic: Vec<(u64, u32)>, pub(crate) own_peds: Vec<(u64, u32)>, + /// Owners inside the list radius waiting for an instance (all held by nearer ones): retail's + /// pools, and the own-instance pools (`mod_world`, doc 16 M3). + pub(crate) waiting_traffic: Vec, + pub(crate) waiting_peds: Vec, + pub(crate) own_waiting_traffic: Vec, + pub(crate) own_waiting_peds: Vec, /// Running counts for the summary log: packets the world host posted, packets / Splice starts /// of the NPC skater instances, and speech lines started. pub(crate) posts: u64, @@ -569,6 +580,14 @@ fn horizontal(a: [f32; 3], b: [f32; 3]) -> f32 { ((a[0] - b[0]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() } +/// The candidates inside the list radius that hold no instance (sorted by id). +fn waiting(into: &mut Vec, candidates: &[(u64, f32)], pool: &Pool) { + into.clear(); + into.extend(candidates.iter().filter(|c| c.1.is_finite() && pool.instance(c.0).is_none()).map(|c| c.0)); + into.sort_unstable(); + into.dedup(); +} + /// A candidate inside the list radius keeps its distance; one outside never gets an instance. fn within(d: f32, radius: f32) -> f32 { if d < radius { d } else { f32::INFINITY } @@ -645,6 +664,8 @@ impl WorldHost { self.banks = None; self.last_camera = None; self.speech_requests.clear(); + self.waiting.0.clear(); + self.waiting.1.clear(); } /// A pass began in `pre` and waits for `post`. @@ -697,6 +718,10 @@ pub(super) fn frame( held.traffic = traffic; held.peds = peds; } + if held.waiting_traffic != host.waiting.0 || held.waiting_peds != host.waiting.1 { + held.waiting_traffic.clone_from(&host.waiting.0); + held.waiting_peds.clone_from(&host.waiting.1); + } if held.posts != host.posts { held.posts = host.posts; } @@ -737,6 +762,8 @@ pub(crate) fn pre_in(host: &mut WorldHost, owners: &WorldOwners, native: &mut Na } let idle = owners.vehicles.is_empty() && owners.peds.is_empty() && host.vehicles.is_empty() && host.ped_objects.is_empty(); if idle { + host.waiting.0.clear(); + host.waiting.1.clear(); return; } // The world banks (cheap when loaded; a map change unloads them, `native::unload_map_banks`). @@ -804,6 +831,8 @@ pub(crate) fn pre_in(host: &mut WorldHost, owners: &WorldOwners, native: &mut Na let ped_candidates: Vec<(u64, f32)> = owners.peds.iter().map(|(&id, p)| (id, within(distance(p.position, cam), PED_LIST_RADIUS))).collect(); let traffic = host.traffic.assign(&traffic_candidates); let peds = host.peds.assign(&ped_candidates); + waiting(&mut host.waiting.0, &traffic_candidates, &host.traffic); + waiting(&mut host.waiting.1, &ped_candidates, &host.peds); for (owner, _) in traffic.released { if let Some((mut vehicle, mut pos)) = host.vehicles.remove(&owner) { pos.deactivate(m, &l); @@ -967,6 +996,23 @@ mod tests { assert_eq!(host.traffic.len(), 4); assert_eq!(host.peds.len(), 15); assert!(!owners.expected && host.prefetch.requested.is_empty(), "nothing expected: no prefetch either"); + assert!(host.waiting.0.is_empty() && host.waiting.1.is_empty()); + } + + /// Doc 16 M3: a full pool's nearest hold the instances; the others inside the list radius + /// wait (out of reach is not waiting), and a waiting one takes the instance a nearer one frees. + #[test] + fn a_full_pool_leaves_the_farther_ones_in_reach_waiting() { + let mut pool = Pool::new(2); + let mut out = Vec::new(); + let candidates = [(1, 5.0), (2, 1.0), (3, 3.0), (4, f32::INFINITY)]; + pool.assign(&candidates); + waiting(&mut out, &candidates, &pool); + assert_eq!(out, vec![1], "2 and 3 hold, 1 waits, 4 is out of reach"); + let candidates = [(1, 5.0), (3, 3.0), (4, f32::INFINITY)]; + pool.assign(&candidates); + waiting(&mut out, &candidates, &pool); + assert!(out.is_empty() && pool.instance(1).is_some(), "2 went: 1 takes its instance"); } /// The world banks on the prefetch worker (data-gated): nothing is asked for until the world diff --git a/crates/skate-game/src/modding/mod.rs b/crates/skate-game/src/modding/mod.rs index 2dc7783f..c22ec774 100644 --- a/crates/skate-game/src/modding/mod.rs +++ b/crates/skate-game/src/modding/mod.rs @@ -158,44 +158,7 @@ impl Plugin for ModdingPlugin { .unwrap_or_else(|| std::path::Path::new(".")) .join("settings/mods") }); - app.insert_resource(Mods { - manager: Manager::new(root, settings), - native_snapshot: None, - world: DynamicsWorld::default(), - bodies: BTreeMap::new(), - joints: BTreeMap::new(), - graphics: BTreeMap::new(), - overlays: BTreeMap::new(), - canvases: BTreeMap::new(), - attach: None, - detach_error: None, - detach_pending: None, - camera: CameraOverride::default(), - generation: u64::MAX, - graphics_serial: 0, - debug_owners: BTreeSet::new(), - ground_ready: false, - skater_proxies: BTreeMap::new(), - dyn_published: BTreeSet::new(), - replication: replication::State::default(), - net_states: BTreeMap::new(), - net_published: BTreeSet::new(), - net_remote: BTreeMap::new(), - net_remote_wire: BTreeMap::new(), - net_status: String::new(), - multiplayer_debug: BTreeMap::new(), - last_contacts: Vec::new(), - graph_gates: BTreeMap::new(), - command_results: BTreeMap::new(), input_overrides: BTreeMap::new(), - session: session::Runtime::default(), - skater_remote: BTreeMap::new(), - pending_remote_teleport: None, - volumes: BTreeMap::new(), - custom_menus: BTreeMap::new(), - suspended_by: BTreeSet::new(), - hidden_players: BTreeMap::new(), - remote_cameras: BTreeMap::new(), - }) + app.insert_resource(Mods::new(Manager::new(root, settings))) .init_resource::(); menu::install(app); audio::install(app); @@ -1743,6 +1706,48 @@ fn watch_pose(world: &mut World, peer: u64, local_id: u64) -> Option<(Vec3, Quat } impl Mods { + /// A fresh host for a mod manager (the plugin's; tests build one over a temporary folder). + pub(crate) fn new(manager: Manager) -> Self { + Self { + manager, + native_snapshot: None, + world: DynamicsWorld::default(), + bodies: BTreeMap::new(), + joints: BTreeMap::new(), + graphics: BTreeMap::new(), + overlays: BTreeMap::new(), + canvases: BTreeMap::new(), + attach: None, + detach_error: None, + detach_pending: None, + camera: CameraOverride::default(), + generation: u64::MAX, + graphics_serial: 0, + debug_owners: BTreeSet::new(), + ground_ready: false, + skater_proxies: BTreeMap::new(), + dyn_published: BTreeSet::new(), + replication: replication::State::default(), + net_states: BTreeMap::new(), + net_published: BTreeSet::new(), + net_remote: BTreeMap::new(), + net_remote_wire: BTreeMap::new(), + net_status: String::new(), + multiplayer_debug: BTreeMap::new(), + last_contacts: Vec::new(), + graph_gates: BTreeMap::new(), + command_results: BTreeMap::new(), input_overrides: BTreeMap::new(), + session: session::Runtime::default(), + skater_remote: BTreeMap::new(), + pending_remote_teleport: None, + volumes: BTreeMap::new(), + custom_menus: BTreeMap::new(), + suspended_by: BTreeSet::new(), + hidden_players: BTreeMap::new(), + remote_cameras: BTreeMap::new(), + } + } + pub(crate) fn multiplayer_debug_sections(&self) -> [String; 3] { let sync = format!("MOD REPLICATION\n{}\nLocal network keys: {} | Remote keys: {}\nPublished dynamics: {} | Local bodies: {} | Local scenes: {}", if self.net_status.is_empty() { "No active mod replication" } else { &self.net_status }, diff --git a/crates/skate-game/src/modding/world_audio.rs b/crates/skate-game/src/modding/world_audio.rs index d4ee97ea..590fc023 100644 --- a/crates/skate-game/src/modding/world_audio.rs +++ b/crates/skate-game/src/modding/world_audio.rs @@ -145,8 +145,10 @@ pub(super) fn spawn(world: &mut World, mods: &Mods, owner: &str, key: String, ki } _ => {} } - // Doc 16 L3: its own MixMap instance (`game_audio::mod_world`) instead of retail's pools. - if opts.slots == Some(skate_mods::world_audio::Slots::Own) { + // Doc 16 L3 / M3: a mod's car or ped takes its own MixMap instance (`game_audio::mod_world`) + // unless it asks for retail's pools (`slots = 'shared'`). Engine systems publishing the + // living world add no `OwnAudioInstance` and keep retail's pools. + if matches!(kind, ObjectKind::Traffic | ObjectKind::Ped) && skate_mods::world_audio::Slots::resolve(opts.slots) == skate_mods::world_audio::Slots::Own { e.insert(crate::world_audio::OwnAudioInstance); } match kind { @@ -267,6 +269,7 @@ pub(super) fn clear(world: &mut World) { pub(super) fn snapshot(world: &World, owner: &str) -> Value { let audio = world.resource::(); let stats = world.get_resource::(); + let objects = world.get_resource::(); let mut out = serde_json::Map::new(); for ((o, key), obj) in &audio.objects { if o != owner { @@ -280,6 +283,11 @@ pub(super) fn snapshot(world: &World, owner: &str) -> Value { "instance": held.map(|h| h.instance), // Doc 16 L3: the instance is the object's own (a private MixMap), not one of retail's. "own": held.is_some_and(|h| h.own), + // Doc 16 M3: a car's / ped's instances (`own`, the default, or `shared` = retail's + // pools; nil for other kinds), and whether it is in reach but waits for one (every + // instance of its pool is held by a nearer object). + "slots": matches!(obj.kind, ObjectKind::Traffic | ObjectKind::Ped).then(|| if world.get::(obj.entity).is_some() { "own" } else { "shared" }), + "waiting": objects.is_some_and(|s| s.waiting.contains(&obj.entity) || s.own_waiting.contains(&obj.entity)), "parked": world.resource::>().elapsed_secs_f64() - obj.updated > PARK_SECONDS && !obj.ghost && !matches!(obj.kind, ObjectKind::Emitter | ObjectKind::ReverbZone), })); } @@ -295,6 +303,14 @@ pub(super) fn info(world: &World) -> Value { "published": {"traffic": s.vehicles, "peds": s.peds, "skaters": s.skaters}, "audible": {"traffic": s.traffic_held, "peds": s.peds_held, "skaters": s.skaters_held}, "speech_lines": s.speech_lines, + "waiting": s.waiting.len(), + // Doc 16 M3: the objects with their own instance (a mod's cars and peds by default). + "own": { + "instances": {"traffic": s.own_instances.0, "peds": s.own_instances.1}, + "published": {"traffic": s.own_vehicles, "peds": s.own_peds}, + "audible": {"traffic": s.own_traffic_held, "peds": s.own_peds_held}, + "waiting": s.own_waiting.len(), + }, }) } @@ -434,3 +450,58 @@ fn sync( } } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn world() -> (World, Mods) { + let mut w = World::new(); + w.insert_resource(Time::::default()); + w.init_resource::(); + let dir = std::env::temp_dir().join(format!("skate-world-audio-slots-{}", std::process::id())); + (w, Mods::new(skate_mods::Manager::new(dir.clone(), dir))) + } + + fn spawn_with(w: &mut World, mods: &Mods, key: &str, kind: ObjectKind, options: Value) -> Entity { + let opts: WorldAudioOptions = serde_json::from_value(options).unwrap(); + spawn(w, mods, "m", key.into(), kind, opts).unwrap(); + w.resource::().objects[&("m".to_owned(), key.to_owned())].entity + } + + /// Doc 16 M3 (user decision 2026-10-04): a mod's car or ped takes its own MixMap instance by + /// default, `slots = 'shared'` (alias `retail`) puts it in retail's pools, `slots = 'own'` + /// stays valid; other kinds never take the marker; an engine-published car (the living + /// world's path: the components without the marker) is untouched, so it stays in retail's + /// pools (`world_bridge::tests::own_instance_objects_go_to_their_own_host` routes by the marker). + #[test] + fn mod_cars_and_peds_default_to_their_own_instance() { + let (mut w, mods) = world(); + let own = |w: &World, e: Entity| w.get::(e).is_some(); + let car = spawn_with(&mut w, &mods, "car", ObjectKind::Traffic, json!({"engine": "c04_taxi01"})); + let ped = spawn_with(&mut w, &mods, "ped", ObjectKind::Ped, json!({"voice": 59})); + assert!(own(&w, car) && own(&w, ped), "default: own"); + let shared_car = spawn_with(&mut w, &mods, "shared_car", ObjectKind::Traffic, json!({"slots": "shared"})); + let shared_ped = spawn_with(&mut w, &mods, "shared_ped", ObjectKind::Ped, json!({"slots": "retail"})); + assert!(!own(&w, shared_car) && !own(&w, shared_ped), "shared / retail: retail's pools"); + let own_car = spawn_with(&mut w, &mods, "own_car", ObjectKind::Traffic, json!({"slots": "own"})); + assert!(own(&w, own_car), "explicit own"); + let skater = spawn_with(&mut w, &mods, "skater", ObjectKind::Skater, json!({})); + assert!(!own(&w, skater), "NPC skaters stay in retail's Player slot"); + assert!(spawn(&mut w, &mods, "m", "bad".into(), ObjectKind::Skater, serde_json::from_value(json!({"slots": "own"})).unwrap()).is_err()); + // Respawning a key with `shared` drops the marker with the old entity. + let again = spawn_with(&mut w, &mods, "car", ObjectKind::Traffic, json!({"slots": "shared"})); + assert!(w.get_entity(car).is_err() && !own(&w, again)); + // The engine's path: the same components, no marker. + let engine = w.spawn((TrafficAudio::new("c04_taxi01"), Transform::default(), GlobalTransform::default())).id(); + assert!(!own(&w, engine), "engine objects keep retail's pools"); + // Read back: `slots` per car / ped, nil for other kinds. + let snap = snapshot(&w, "m"); + assert_eq!(snap["ped"]["slots"], "own"); + assert_eq!(snap["car"]["slots"], "shared"); + assert_eq!(snap["own_car"]["slots"], "own"); + assert!(snap["skater"]["slots"].is_null()); + assert_eq!(snap["ped"]["waiting"], false); + } +} diff --git a/crates/skate-game/src/world_audio.rs b/crates/skate-game/src/world_audio.rs index aea1b6af..212f38e7 100644 --- a/crates/skate-game/src/world_audio.rs +++ b/crates/skate-game/src/world_audio.rs @@ -218,9 +218,12 @@ pub struct WorldAudioInstance { /// A traffic vehicle or ped with this takes its own MixMap instance (doc 16 "L3", /// `game_audio::mod_world`) instead of competing for retail's pools (4 traffic / 15 pedestrian, the -/// nearest win): it plays whenever it is within retail's list radius (40 m / 50 m), up to 16 own -/// cars and 16 own peds. Not retail: for mod and engine objects that must be heard. Mods: -/// `sdk.world_audio.spawn(key, 'traffic' | 'ped', {slots = 'own', …})`. +/// nearest win): it plays whenever it is within retail's list radius (40 m / 50 m) and among the +/// 16 nearest own cars / 16 nearest own peds (the farther ones wait, `WorldAudioStats::own_waiting`). +/// Not retail. **Mods' cars and peds get it by default** (user decision 2026-10-04, doc 16 M3: +/// `sdk.world_audio.spawn(key, 'traffic' | 'ped', …)`; `slots = 'shared'` leaves it off); an +/// engine system publishing the living world does not add it (retail's pools) unless an object of +/// its own must be heard. #[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct OwnAudioInstance; @@ -299,6 +302,17 @@ pub struct WorldAudioStats { pub more_audible: bool, /// Speech lines started so far (peds and NPC skaters; `game_audio::world_speech`). pub speech_lines: u64, + /// Inside the list radius but holding none of retail's instances (all held by nearer ones). + pub waiting: Vec, + /// The objects with their own instance (`OwnAudioInstance`, doc 16 L3 / M3): published cars + /// and peds, holders, the private MixMap's instance counts (16 / 16) and the ones in reach + /// waiting for one of them (a full private pool: the nearest play). + pub own_vehicles: usize, + pub own_peds: usize, + pub own_traffic_held: usize, + pub own_peds_held: usize, + pub own_instances: (usize, usize), + pub own_waiting: Vec, } /// A speech value (`S+136`, the state graphs' `SendSpeechEvent speechvalue=N`). diff --git a/crates/skate-mods/src/api.lua b/crates/skate-mods/src/api.lua index fad43af1..205da936 100644 --- a/crates/skate-mods/src/api.lua +++ b/crates/skate-mods/src/api.lua @@ -371,7 +371,9 @@ function sdk.audio.tuned() return (audio_mine().tuning) or {} end -- to the calling mod; 48 objects per mod, 128 in all; an object not updated for 0.5 s is parked; -- everything is removed when the mod is disabled or reloaded. The retail limits decide which -- objects sound (4 nearest cars within 40 m, 15 nearest peds within 50 m, 1 skater within 30 m). -sdk.world_audio = { version = 3 } +-- Version 4: a mod's cars and peds take their own MixMap instance by default (16 + 16, the nearest +-- own ones play, the rest wait); `slots = 'shared'` puts one in retail's pools instead. +sdk.world_audio = { version = 4 } function sdk.world_audio.spawn(key, kind, opts) submit{kind="world_audio_spawn",key=key,object=kind,options=opts or {}} end diff --git a/crates/skate-mods/src/vm.rs b/crates/skate-mods/src/vm.rs index a2019b9c..cc5de8ee 100644 --- a/crates/skate-mods/src/vm.rs +++ b/crates/skate-mods/src/vm.rs @@ -1037,7 +1037,10 @@ impl Vm { capabilities.set("multiplayer_debug", 1)?; // 2 (2026-10-04, audio/moddability-2): the `emitter` and `reverb_zone` kinds. // 3 (doc 16 L3): `slots = 'own'` (a traffic / ped object on its own MixMap instance). - capabilities.set("world_audio", 3)?; + // 4 (doc 16 M3, user decision 2026-10-04): own instances are the default for a mod's + // cars and peds (as for its emitters); `slots = 'shared'` (alias `retail`) opts into + // retail's pools; `read(key).slots / waiting`, `info().own`. + capabilities.set("world_audio", 4)?; // Audio extension 1: the mod's own WAVs (`sdk.audio.preload / play / update / stop / // stop_all`); before 2026-10-04 only `sdk.audio.version` advertised it. // 2 (2026-10-04): retail posts by class, globals, MixMap / global watch, `sdk.audio.info`. @@ -1864,10 +1867,10 @@ mod world_audio_tests { std::fs::create_dir_all(&root).unwrap(); std::fs::write(root.join("main.lua"), r#" assert(sdk.capabilities.audio == 4, 'audio capability') - assert(sdk.capabilities.world_audio == 3, 'world_audio capability') + assert(sdk.capabilities.world_audio == 4, 'world_audio capability') assert(sdk.capabilities.audio_content == 3, 'audio_content capability') assert(sdk.capabilities.audio_events == 3, 'audio_events capability') - assert(sdk.audio.version == 4 and sdk.world_audio.version == 3, 'versions') + assert(sdk.audio.version == 4 and sdk.world_audio.version == 4, 'versions') return {} "#).unwrap(); let manifest: Manifest = serde_json::from_value(json!({ @@ -2222,6 +2225,9 @@ mod world_audio_tests { json!({"kind":"world_audio_spawn","key":"fountain","object":"emitter","options":{"bank":"water_fountain","patch":81,"position":[0,0,0],"extent":[6,6,6]}}), json!({"kind":"world_audio_spawn","key":"cave","object":"reverb_zone","options":{"preset":"BEEFC8E3DE04FBAE","position":[0,0,0],"extent":[20,8,12]}}), json!({"kind":"world_audio_update","key":"fountain","options":{"volume":0.3,"position":[1,0,0]}}), + json!({"kind":"world_audio_spawn","key":"car2","object":"traffic","options":{"slots":"shared"}}), + json!({"kind":"world_audio_spawn","key":"car3","object":"traffic","options":{"slots":"own"}}), + json!({"kind":"world_audio_spawn","key":"ped2","object":"ped","options":{"slots":"retail"}}), ] { let c: Command = serde_json::from_value(value.clone()).unwrap(); assert!(c.validate(), "{value}"); @@ -2235,6 +2241,8 @@ mod world_audio_tests { assert!(serde_json::from_value::(json!({"kind":"world_audio_spawn","key":"x","object":"bus"})).is_err()); let c: Command = serde_json::from_value(json!({"kind":"world_audio_update","key":"x","options":{"source":"lite"}})).unwrap(); assert!(!c.validate(), "the source is fixed at spawn"); + let c: Command = serde_json::from_value(json!({"kind":"world_audio_update","key":"x","options":{"slots":"shared"}})).unwrap(); + assert!(!c.validate(), "the slots are fixed at spawn"); } } diff --git a/crates/skate-mods/src/world_audio.rs b/crates/skate-mods/src/world_audio.rs index d2c33936..5d498b0a 100644 --- a/crates/skate-mods/src/world_audio.rs +++ b/crates/skate-mods/src/world_audio.rs @@ -31,16 +31,31 @@ pub enum ObjectKind { ReverbZone, } -/// Which MixMap instances a traffic vehicle or ped plays on (world audio extension 3, doc 16 L3). +/// Which MixMap instances a mod's traffic vehicle or ped plays on (world audio extension 3, doc 16 +/// L3; the default flipped to `own` in extension 4, user decision 2026-10-04: "yes I agree it +/// should match" the mod emitters, which have their own instances by default). #[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum Slots { - /// Retail's pools (4 traffic / 15 pedestrian instances, the nearest win): the default. + /// Its own instance of a private MixMap (the default; not retail): it plays whenever it is + /// within retail's list radius (40 m cars, 50 m peds) and among the 16 nearest own cars / 16 + /// nearest own peds of all mods; a farther one waits (silent, `read(key).waiting`) until it is + /// among them. It never takes one of retail's instances, so the map's objects keep retail's + /// pools. #[default] - Retail, - /// Its own instance of a private MixMap: it plays whenever it is within retail's list radius - /// (up to 16 own cars and 16 own peds), the map's objects keep retail's pools. Not retail. Own, + /// Retail's pools (4 traffic / 15 pedestrian instances, the more-audible setting 8 / 24), + /// shared with the map's objects: the nearest win. `retail` is accepted as well (the name + /// extension 3 used), so a mod passing `retail` gets the pools on 3 and 4 alike. + #[serde(alias = "retail")] + Shared, +} + +impl Slots { + /// Spawn `slots` → the instances the object takes (`None` = the default, [`Slots::Own`]). + pub fn resolve(slots: Option) -> Self { + slots.unwrap_or_default() + } } /// Every field a spawn or an update may carry; each kind reads its own and ignores none silently @@ -61,8 +76,8 @@ pub struct WorldAudioOptions { /// Follow one of this mod's physics bodies (position, rotation and velocity from it). #[serde(default)] pub body: Option, - /// Traffic and peds, spawn only (extension 3): `retail` (the default) or `own` (its own MixMap - /// instance). + /// Traffic and peds, spawn only: `own` (the default since extension 4: its own MixMap + /// instance) or `shared` (alias `retail`: retail's pools with the map's objects). #[serde(default)] pub slots: Option, // ---- traffic @@ -346,6 +361,26 @@ mod tests { assert!(serde_json::from_value::(json!({"falloff":"cubic"})).is_err()); } + /// World audio extension 4 (doc 16 M3): a mod's car / ped takes its own instance unless it + /// asks for retail's pools (`shared`, alias `retail`); `own` stays valid; only traffic and + /// peds take `slots`; anything else is a typo, not silence. + #[test] + fn slots_default_to_own_and_shared_opts_in() { + let none: WorldAudioOptions = serde_json::from_value(json!({"engine":"c04_taxi01"})).unwrap(); + assert_eq!(Slots::resolve(none.slots), Slots::Own, "no slots = its own instance"); + for (v, want) in [("own", Slots::Own), ("shared", Slots::Shared), ("retail", Slots::Shared)] { + let o: WorldAudioOptions = serde_json::from_value(json!({"slots": v})).unwrap(); + assert_eq!(Slots::resolve(o.slots), want, "{v}"); + assert!(o.validate_for(ObjectKind::Traffic) && o.validate_for(ObjectKind::Ped), "{v}"); + for kind in [ObjectKind::Skater, ObjectKind::Emitter, ObjectKind::ReverbZone] { + assert!(!o.validate_for(kind), "{v} on {kind:?}"); + } + } + for bad in ["extra", "Own", "pool", ""] { + assert!(serde_json::from_value::(json!({"slots": bad})).is_err(), "accepted {bad:?}"); + } + } + #[test] fn events_validate() { let horn: WorldAudioEventOptions = serde_json::from_value(json!({"kind":3,"seconds":1.2})).unwrap(); diff --git a/docs/hails-additions/16-audio-modding.md b/docs/hails-additions/16-audio-modding.md index c71cf51a..0411ebb6 100644 --- a/docs/hails-additions/16-audio-modding.md +++ b/docs/hails-additions/16-audio-modding.md @@ -36,7 +36,8 @@ The guide (sections A–G, and H–L for the follow-up) says what a mod can do a | add new Csis classes, functions, globals | `audio.json` `add.projects` (M4) | `audio_content` = 3 | | drive retail's MixMap controllers (a duck through the Master gains, a flag) | `sdk.audio.set_mixmap_input` (M2) | `audio` = 4 | | reproducible audio draws for tests | `sdk.audio.seed(n)`, `SKATE_AUDIO_SEED` (M5) | `audio` = 4 | -| a mod car / ped heard beside retail's pools | `sdk.world_audio.spawn(…, {slots = 'own'})` (M3) | `world_audio` = 3 | +| a mod car / ped heard beside retail's pools | the default: `sdk.world_audio.spawn(key, 'traffic' / 'ped', …)` (M3; on 3: `{slots = 'own'}`) | `world_audio` = 4 | +| a mod car / ped in retail's pools with the map's objects (the nearest win) | `sdk.world_audio.spawn(…, {slots = 'shared'})` (M3; alias `retail`, also on 3, where it is the default) | `world_audio` = 4 | Test `(sdk.capabilities.audio or 0) >= 2` (and so on) before relying on a feature; an older engine lacks the keys. @@ -703,7 +704,8 @@ Implementation of 1–3 (2026-10-04, on `audio/moddability-2`): Status: built on `audio/moddability-2` (2026-10-04, on top of H–K; uncommitted at the time of writing). With these the "Later" list of section 3 is done (L6, native routing by default, was done in H). Capabilities: `audio` = 4 (L2, L5), `audio_content` = 3 (L1, L4, L7), `audio_events` = 3 (the offset frame, moving emitters), `world_audio` = 3 -(L3). Without mods nothing here runs: the headless e2e bench is byte-identical (below). +(L3), then 4 (own instances the default for mod cars and peds, M3). Without mods nothing here runs: the headless e2e +bench is byte-identical (below). ### M1. Hot swap instead of the restart (L1) @@ -779,16 +781,55 @@ wins, the release gives the retail level back exactly, the limits); `skate-mods` Problem: published cars and peds compete with the map's for retail's 4 traffic / 15 pedestrian instances (8 / 24 more-audible): a mod's sixth car near the camera is silent. (Mod emitters and voices already had their own private -instances, H / J.) Change: `game_audio/mod_world.rs`: an object with `world_audio::OwnAudioInstance` (mods: -`sdk.world_audio.spawn(key, 'traffic' | 'ped', {slots = 'own'})`) is published by the bridge to -`OwnWorldOwners` instead of `WorldOwners` and played by a second `WorldHost` (the same retail traffic / ped objects, -posts, Splice steps, speech requests, rules and event rows) on a private MixMap (the install's file, Global + 16 -Traffic + 16 Pedestrian instances, Global inputs copied from the game's before each evaluation). The retail pools -never see them. `world_sources::pre_in / post_in` take the MixMap and pool sizes (the game's host passes its own: -unchanged). Read back: `WorldAudioInstance.own`, `read(key).own`. NPC skaters stay in retail's Player slot (open). +instances, H / J.) Change: `game_audio/mod_world.rs`: an object with `world_audio::OwnAudioInstance` is published by +the bridge to `OwnWorldOwners` instead of `WorldOwners` and played by a second `WorldHost` (the same retail traffic / +ped objects, posts, Splice steps, speech requests, rules and event rows) on a private MixMap (the install's file, +Global + 16 Traffic + 16 Pedestrian instances, Global inputs copied from the game's before each evaluation). The +retail pools never see them. `world_sources::pre_in / post_in` take the MixMap and pool sizes (the game's host passes +its own: unchanged). NPC skaters stay in retail's Player slot (open, M9 Q1). + +**The default for mods (user decision 2026-10-04, M9 Q2: "yes I agree it should match", after "modding isn't in +retails, so 'not like retail' isn't a thing. Yes default to seperate slots."; capability `world_audio` = 4):** a +mod's car or ped takes its own instance unless it asks otherwise, as its emitters do (J): + +| `slots` (spawn only; cars and peds) | instances | | +|---|---|---| +| none / `'own'` | its own instance of the private MixMap | the default since `world_audio` 4; `'own'` stays valid (the opt-in of 3) | +| `'shared'` (alias `'retail'`) | retail's pools, shared with the map's objects: the nearest win | the default of `world_audio` 3; `'retail'` works on 3 and 4 alike | + +Any other value, or `slots` on a skater, emitter or reverb zone or in an update, is a command error. + +- **Only mod objects change.** The mod command (`modding/world_audio.rs` `spawn`) adds `OwnAudioInstance` to a mod's + car or ped by default (`skate_mods::world_audio::Slots::resolve`). The engine-facing path is unchanged: an engine + system publishing the living world adds `TrafficAudio` / `PedAudio` without the marker and stays in retail's + pools; an engine object that must be heard adds the marker itself. +- **A full private pool** (more than 16 own cars or 16 own peds of all mods inside the list radius, 40 m horizontal / + 50 m 3-D): the 16 nearest hold the instances (retail's own rule, `owners::Pool`), the others **wait**, silent, and + take an instance as soon as they are among the 16 nearest (a nearer one moves away, goes or the listener moves). + Chosen over the alternatives because a mod may publish 48 objects and only the ones in reach compete: refusing the + 17th spawn would break a mod that publishes a street (the world-audio test mod spawns 20 peds), and spilling into + retail's pools would take the map's instances, which own instances exist to keep. It matches the emitters (J: past + the private instances the next waits). Readable: `read(key).waiting` (in reach, no instance: every one is held by + a nearer object; the same for `shared` objects in a full retail pool), `info().own = {instances = {traffic = 16, + peds = 16}, published, audible, waiting}`, `info().waiting` (retail's pools), `read(key).slots` (`'own'` / + `'shared'`, the object's setting; `own` stays "holds an own instance"); the log warns once when waiting starts + (`AUDIO_WORLD own instances full: …`) and the per-second `WORLD_AUDIO own instances: …` line while own objects exist. + Engine side: `WorldAudioStats { waiting, own_vehicles, own_peds, own_traffic_held, own_peds_held, own_instances, + own_waiting }` (the hosts' `WorldHost::waiting`, `WorldHeld::{waiting_*, own_waiting_*}`: bookkeeping only). +- Read back as before: `WorldAudioInstance.own`, `read(key).own`. + Verification: data-gated `mod_world::tests::own_instance_cars_all_play_beside_retails_pool` (six own cars all hold -an instance and post, the game's pool holds none, released when they go); -`world_bridge::tests::own_instance_objects_go_to_their_own_host`. +an instance and post, the game's pool holds none, released when they go), +`mod_world::tests::a_full_private_pool_lets_the_nearest_play_and_the_rest_wait` (18 own cars in reach and one beyond +40 m: the 16 nearest hold, the 2 farther wait, the far one does not, retail's pool holds none; one near car goes and +the nearest waiting car takes its instance at the next pass; 16 in reach: nobody waits); +`world_sources::tests::a_full_pool_leaves_the_farther_ones_in_reach_waiting`; +`modding::world_audio::tests::mod_cars_and_peds_default_to_their_own_instance` (default own for cars and peds, +`shared` / `retail` none, explicit `own`, skaters never, a respawn with `shared` drops it, an engine-published car has +none, `read(key).slots`); `world_bridge::tests::own_instance_objects_go_to_their_own_host` (the marker routes, an +unmarked car goes to retail's pool); `skate-mods` `world_audio::tests::slots_default_to_own_and_shared_opts_in`, the +`world_audio_commands_deserialize` cases (`shared`, `own`, `retail` at spawn; `slots` in an update refused), the +capability test (`world_audio` = 4). No-mod e2e bench: byte-identical to run `m2` (84 outputs). ### M4. Mod Csis projects (L4) @@ -864,13 +905,22 @@ Verification: `mod_voices::tests::offsets_turn_with_the_owner`, `mod_rules::test Digit1 duck (Master inputs), Digit2 seed, Digit3 six own-instance taxis, Digit4 the mod's class + global, Digit5 a beep 2 m ahead of the board's nose on pops, Digit6 an orbiting emitter; a second HUD line shows swaps / restarts / the last change. Not checked in game yet. +- **The own-instance default (M3, `world_audio` = 4, 2026-10-04):** no-mod bench run `s5` 84 of 84 byte-identical + to `q0` and `m2`; skate-audio with ignored all pass; `game_audio::` with ignored 135 pass, `modding::` 33 pass; + skate-mods 96 pass (the known Skyline failure only); `cargo build --locked` the 4 known warnings; `check_mod + --install`: `audio-content-test` and `audio-example` 0 warnings, 0 conflicts; the world-audio test mod and Skyline + OK. Dev mods: `audio-content-test` Digit3 spawns its taxis without `slots` on 4 (`'own'` on 3); `world-audio-test` + (which shows retail's limits) asks for `slots = 'shared'` on 4, and its new setting "Own instances" (`own_slots`) + shows the default instead. Not checked in game yet. ### M9. Open questions 1. **Own instances for NPC skaters** (L3): their host runs a whole skater's components and its own grain bed per Player-slot instance (the runtime has one NPC bed); left in retail's Player slot. Wanted? -2. **Own-instance default for mod cars / peds**: opt-in (`slots = 'own'`), unlike mod emitters (own by default): - doc 15 lets retail decide who is audible among published objects. Flip it for mod objects? +2. **Own-instance default for mod cars / peds**: answered 2026-10-04, the user: "yes I agree it should match" + (the mod emitters, own by default; before that: "modding isn't in retails, so 'not like retail' isn't a thing. + Yes default to seperate slots."). Own instances are the default for a mod's cars and peds, `slots = 'shared'` + opts into retail's pools; a full private pool lets the nearest play and the rest wait (M3). 3. **The restart fallback**: a mod replacing the MixMap file or a grain recording still restarts the sound. The bed could be rebuilt in place (a cut of the rolling only); worth it? 4. **Swapped content restarts its own sounds**: a replaced bank's held posts continue with new instances (their @@ -1236,7 +1286,8 @@ All done (2026-10-04, branch `audio/moddability-2`): L1–L5 and L7 in section M - **L1** bank-level hot swap instead of the runtime restart. Done: M1. - **L2** writable MixMap inputs (mod ducking through retail's controllers rather than a group multiplier). Done: M2. -- **L3** extra MixMap instances for mod objects beyond the more-audible setting. Done: M3 (cars, peds). +- **L3** extra MixMap instances for mod objects beyond the more-audible setting. Done: M3 (cars, peds; the default + for a mod's cars and peds since `world_audio` 4). - **L4** mod Csis projects (new classes, functions, globals). Done: M4. - **L5** a seedable world RNG for reproducible mod tests. Done: M5 (every audio generator). - **L6** flip `native=true` as the default for mod WAVs (Q2). Done: H. diff --git a/mods/audio-content-test/main.lua b/mods/audio-content-test/main.lua index d7461bf5..096408e4 100644 --- a/mods/audio-content-test/main.lua +++ b/mods/audio-content-test/main.lua @@ -161,15 +161,16 @@ return { end end if key("Digit3") and (sdk.capabilities.world_audio or 0) >= 3 then - -- L3: six idling taxis around the skater, each on its own MixMap instance (retail's 4 - -- traffic instances stay for the map's cars, and all six are heard). + -- L3 / M3: six idling taxis around the skater, each on its own MixMap instance (retail's + -- 4 traffic instances stay for the map's cars, and all six are heard): the default since + -- world audio 4 (no slots); on 3 they ask for it. own_cars = not own_cars local p = sdk.player.read().position for i = 1, 6 do if own_cars then local a = i * math.pi / 3 sdk.commands.request("own" .. i, {kind = "world_audio_spawn", key = "own_car" .. i, object = "traffic", options = { - engine = "c04_taxi01", slots = "own", speed = 0, position = {p[1] + 9 * math.cos(a), p[2], p[3] + 9 * math.sin(a)}}}) + engine = "c04_taxi01", slots = (sdk.capabilities.world_audio or 0) < 4 and "own" or nil, speed = 0, position = {p[1] + 9 * math.cos(a), p[2], p[3] + 9 * math.sin(a)}}}) else sdk.world_audio.remove("own_car" .. i) end diff --git a/mods/world-audio-test/main.lua b/mods/world-audio-test/main.lua index 7d67dc39..5dbe1cd0 100644 --- a/mods/world-audio-test/main.lua +++ b/mods/world-audio-test/main.lua @@ -3,6 +3,8 @@ -- where the mod starts (F9 re-centres on the skater). The game decides who is audible with -- retail's limits (4 nearest cars within 40 m, 15 nearest peds within 50 m with footsteps for -- the nearest 3, one NPC skater within 30 m); the boxes show it: green = audible, grey = not. +-- A mod's cars and peds take their own MixMap instances by default (world audio 4); this test asks +-- for retail's pools (slots = 'shared') unless the "own_slots" setting is on. -- Not retail behaviour: the motion, the gait clock, the honk timing and the reactions are this -- script's; only the sounds and their rules are the game's port of retail. @@ -49,6 +51,13 @@ local function setting(name, default) return v end +-- Retail's pools unless the setting asks for the default own instances; world audio 3 and older +-- default to the pools anyway. +local function slots() + if setting("own_slots", false) or (sdk.capabilities.world_audio or 0) < 4 then return nil end + return "shared" +end + local function dist(a, b) local dx, dy, dz = a[1] - b[1], a[2] - b[2], a[3] - b[3] return math.sqrt(dx * dx + dy * dy + dz * dz) @@ -107,7 +116,7 @@ local function spawn_all() car.engine = "c04_taxi01" end car.position, car.heading = p, h - sdk.world_audio.spawn(key, "traffic", { engine = car.engine, position = p, heading = h, speed = 0 }) + sdk.world_audio.spawn(key, "traffic", { engine = car.engine, position = p, heading = h, speed = 0, slots = slots() }) cars[#cars + 1] = car end end @@ -124,7 +133,7 @@ local function spawn_all() length = 6 + (i % 4) * 3, t = rand() * 10, speed = speed, step = ({ walk = 0.55, jog = 0.36, run = 0.27 })[kind], voice = VOICES[1 + (i * 7) % #VOICES], x = 0, sign = 1 } - sdk.world_audio.spawn(key, "ped", { voice = ped.voice, position = start }) + sdk.world_audio.spawn(key, "ped", { voice = ped.voice, position = start, slots = slots() }) peds[#peds + 1] = ped end end diff --git a/mods/world-audio-test/mod.json b/mods/world-audio-test/mod.json index 8c95a7ea..774ea4ef 100644 --- a/mods/world-audio-test/mod.json +++ b/mods/world-audio-test/mod.json @@ -20,6 +20,12 @@ "description": "Twenty pedestrians walking, jogging and running on lines.", "default": true }, + "own_slots": { + "type": "boolean", + "label": "Own instances", + "description": "Cars and peds on their own MixMap instances (the mod default since world audio 4) instead of retail's pools: all within reach are heard, up to 16 + 16. Off (this test's default) shows retail's limits.", + "default": false + }, "ghost": { "type": "boolean", "label": "Ghost skater", diff --git a/sdk/GENERAL_API.md b/sdk/GENERAL_API.md index 03e012b0..83324b8c 100644 --- a/sdk/GENERAL_API.md +++ b/sdk/GENERAL_API.md @@ -17,10 +17,13 @@ sound plays at the owner of the game's sound by default, following it; `play.at` `world` (a fixed `position`) or `centre`, `play.falloff` the reach; 3: `play.frame = 'owner'` puts the `offset` in the owner's axes (right, up, facing), and a rule sound at a published emitter follows it when it moves), `audio_tuning=1` (`sdk.audio.set_tuning / tuning / tuned`: player / world / bus / reverb tuning while the mod runs) -and `world_audio=3` (publishing cars, peds and skaters to the game's world audio; 2: `emitter` and `reverb_zone` +and `world_audio=4` (publishing cars, peds and skaters to the game's world audio; 2: `emitter` and `reverb_zone` objects; a mod emitter has its own emitter instance by default, `settings/audio.json` `"mod_emitter_slots": "shared"` makes it share retail's 5 with the map's emitters; 3: `slots = 'own'` gives a car or ped its own -MixMap instance instead of retail's pools); all declared in `skate.lua`, reference in `docs/hails-additions/16-audio-modding.md`. +MixMap instance instead of retail's pools; 4: that is the default for a mod's cars and peds, as for its emitters: +the 16 nearest own cars / 16 nearest own peds (all mods) within retail's list radii play, a farther one waits +(`read(key).waiting`) and is never refused or moved into retail's pools; `slots = 'shared'` (alias `retail`, +accepted by 3 too) puts one in retail's pools with the map's objects; `read(key).slots`, `info().own`); all declared in `skate.lua`, reference in `docs/hails-additions/16-audio-modding.md`. The host has no injury, vehicle or challenge rules. Those live in Lua. `deformation=1` adds optional impact-driven mesh/collider deformation. diff --git a/sdk/skate.lua b/sdk/skate.lua index 1e297d04..057a1826 100644 --- a/sdk/skate.lua +++ b/sdk/skate.lua @@ -832,10 +832,17 @@ function sdk.audio.tuned() end -- World audio extension 1 (capability `world_audio`): publish traffic vehicles, pedestrians and -- skaters to the game's retail world audio, exactly as an engine system would (doc -- docs/hails-additions/15-world-audio.md). Keys belong to this mod; 48 objects per mod, 128 in all; --- an object not updated for 0.5 s is parked; everything is removed on disable / reload. The game --- decides who is audible with retail's limits (4 nearest cars within 40 m, 15 nearest peds within --- 50 m, footsteps for the nearest 3, one skater within 30 m; 8 / 24 / 3 with the opt-in --- non-retail "more audible" setting). +-- an object not updated for 0.5 s is parked; everything is removed on disable / reload. +-- Cars and peds (capability `world_audio` >= 4): by default each takes its own instance of a +-- private MixMap (as mod emitters do; not retail): it plays whenever it is within retail's list +-- radius (40 m cars, 50 m peds) and among the 16 nearest own cars / 16 nearest own peds of all mods; +-- a farther one waits, silent (`read(key).waiting`), and takes an instance as soon as it is among +-- the nearest; it is never refused and never takes one of retail's instances, so the map's objects +-- keep retail's pools. `slots = 'shared'` (alias 'retail') puts a car / ped in retail's pools with +-- the map's objects instead, where the game decides who is audible with retail's limits (4 nearest +-- cars within 40 m, 15 nearest peds within 50 m, footsteps for the nearest 3; 8 / 24 / 3 with the +-- opt-in non-retail "more audible" setting). Skaters: one within 30 m (retail's Player slot). +-- (`world_audio` 3 had 'retail' as the default and `slots = 'own'` as the opt-in.) -- Extension 2 (capability `world_audio` >= 2): 'emitter' and 'reverb_zone' objects: `.ems`-style -- records added to the map's live lists. An emitter plays an AEMS bank bound to c_emitter (a retail -- bank or one a content overlay adds) through retail's reach test (sphere when the three extents are @@ -882,7 +889,7 @@ function sdk.audio.tuned() end ---@field forward? Vec3 emitter / reverb_zone: the ellipsoid's forward axis, turned by heading (default {1,0,0}) ---@field core? number emitter / reverb_zone: inner core fraction 0..1 (default 0) ---@field preset? string reverb_zone (spawn: required): the aud_reverb preset key, 16 hex digits, one the install has ----@field slots? 'retail'|'own' traffic / ped, spawn only (world_audio >= 3): 'retail' (default: retail's pools, the nearest win) or 'own' = its own MixMap instance (not retail): it plays whenever it is within retail's list radius (40 m cars, 50 m peds), up to 16 own cars and 16 own peds; the map's objects keep retail's pools +---@field slots? 'own'|'shared'|'retail' traffic / ped, spawn only: 'own' (the default since world_audio 4) = its own instance of a private MixMap (not retail): it plays within retail's list radius (40 m cars, 50 m peds) when among the 16 nearest own cars / 16 nearest own peds (all mods), else it waits (`read(key).waiting`); the map's objects keep retail's pools. 'shared' (alias 'retail', the world_audio 3 default) = retail's pools shared with the map's objects, the nearest win sdk.world_audio = {} ---@param key string ---@param kind 'traffic'|'ped'|'skater'|'emitter'|'reverb_zone' @@ -899,7 +906,9 @@ function sdk.world_audio.event(key, event, opts) end ---@param key string function sdk.world_audio.remove(key) end ---@param key string ----@return {kind:string, audible:boolean, instance?:integer, own:boolean, parked:boolean}|nil +---audible: holds an instance / plays; own: the instance is a private MixMap's; slots (cars, peds; world_audio >= 4): 'own' or 'shared', the object's setting; waiting (>= 4): in reach but every instance of its pool is held by a nearer object. +---@return {kind:string, audible:boolean, instance?:integer, own:boolean, slots?:'own'|'shared', waiting:boolean, parked:boolean}|nil function sdk.world_audio.read(key) end ----@return {more_audible:boolean, instances:{traffic:integer,peds:integer,skaters:integer}, published:table, audible:table, speech_lines:integer} +---The retail pools (instances, published, audible, waiting) and, under `own` (world_audio >= 4), the private MixMap's (instances 16 / 16, published, audible, waiting). +---@return {more_audible:boolean, instances:{traffic:integer,peds:integer,skaters:integer}, published:table, audible:table, waiting:integer, speech_lines:integer, own:{instances:{traffic:integer,peds:integer}, published:table, audible:table, waiting:integer}} function sdk.world_audio.info() end