diff --git a/crates/skate-game/src/game_audio/mod_audio.rs b/crates/skate-game/src/game_audio/mod_audio.rs index f2958466..350a13ca 100644 --- a/crates/skate-game/src/game_audio/mod_audio.rs +++ b/crates/skate-game/src/game_audio/mod_audio.rs @@ -175,9 +175,13 @@ pub(crate) fn record(buf: &mut EventBuf, row: EventRow) { /// - `horn` / `alarm`: a traffic horn / car alarm post; /// - `tazer`: a ped's `c_tazer` post; `body_fall`: a ped's body-fall Splice start; /// - `emitter`: a world emitter start; `zone_change`: a zone ambience change; `speech`: a line. -#[derive(Clone, Debug, Default)] +/// +/// The pop / land ids are the running player's (`events_frame` copies them every frame a mod +/// subscribes, so they follow a native start, restart or tuning change); 0 is "unset" and never +/// tags a row (`Tags::default` tags no pop or landing). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) struct Tags { - pub pop: Vec, + pub pop: [i32; 6], pub land: i32, } @@ -185,15 +189,19 @@ pub(crate) const TAGS: [&str; 12] = ["pop", "land", "grind_start", "grind_end", impl Tags { pub(crate) fn from_tuning(c: &skate_audio::player::contacts::ContactsTuning) -> Self { - Self { pop: c.pop_ids.iter().chain(&c.pop_ids_hollow).map(|&i| i as i32).collect(), land: c.landing_id as i32 } + let mut pop = [0; 6]; + for (p, &i) in pop.iter_mut().zip(c.pop_ids.iter().chain(&c.pop_ids_hollow)) { + *p = i as i32; + } + Self { pop, land: c.landing_id as i32 } } pub(crate) fn tag(&self, r: &EventRow) -> Option<&'static str> { use skate_audio::player::contacts::BANK; use skate_audio::world::traffic::{ALARM_CLASS, HORN_CLASS}; match (r.kind, r.source) { - (EventKind::Splice, Source::Player) if r.class == BANK && self.pop.contains(&r.id) => Some("pop"), - (EventKind::Splice, Source::Player) if r.class == BANK && r.id == self.land => Some("land"), + (EventKind::Splice, Source::Player) if r.class == BANK && r.id != 0 && self.pop.contains(&r.id) => Some("pop"), + (EventKind::Splice, Source::Player) if r.class == BANK && r.id != 0 && r.id == self.land => Some("land"), (EventKind::Splice, _) if r.class.starts_with("fstep_") => Some("footstep"), (EventKind::Post, Source::Player) if r.slot == "grind" && r.id == 0 => Some("grind_start"), (EventKind::Release, Source::Player) if r.slot == "grind" && r.id == 0 => Some("grind_end"), @@ -386,6 +394,7 @@ impl AudioApi { if let Some(bad) = tags.iter().find(|t| !TAGS.contains(&t.as_str())) { return Err(format!("audio subscribe: unknown tag {bad} ({})", TAGS.join(", "))); } + // Early copy only: `events_frame` refreshes the ids every frame (native may start later). if let Some(p) = native.and_then(|n| n.player.as_ref()) { self.events.tags = Tags::from_tuning(&p.contact_tuning); } @@ -563,6 +572,9 @@ pub(super) fn events_frame( } return; } + // The pop / land ids of the player whose rows this frame collects (a copy, no allocation): + // right however the subscription and the native start / restart / tuning change are ordered. + api.events.tags = native.as_deref().and_then(|n| n.player.as_ref()).map_or_else(Tags::default, |p| Tags::from_tuning(&p.contact_tuning)); if let Some(p) = native.as_deref_mut().and_then(|n| n.player.as_mut()) { collect(&mut p.events, on, &mut api.events); } @@ -958,6 +970,69 @@ mod tests { assert!(world.resource::().snapshot(None, 0, "dev.a").is_null()); } + /// Found by the in-game autotest: a mod that subscribes in `on_load`, before native audio + /// starts, kept unset pop / land ids for the session (pops untagged, a Splice row with id 0 + /// tagged `land`). The ids now follow the running player, whenever it starts or changes. + #[test] + fn event_tags_follow_the_player_whatever_the_subscription_order() { + use skate_audio::player::contacts::BANK; + let splice = |id| EventRow { kind: EventKind::Splice, source: Source::Player, class: BANK, slot: "", id, owner: 0 }; + let tags_of = |world: &World, owner: &str| -> Vec<(i32, Option)> { + let s = world.resource::().snapshot(None, 0, owner); + s["events"]["rows"].as_array().unwrap().iter().map(|r| (r["id"].as_i64().unwrap() as i32, r["tag"].as_str().map(str::to_owned))).collect() + }; + let frame_with = |world: &mut World, ids: &[i32]| { + for &id in ids { + record(&mut world.resource_mut::().player.as_mut().unwrap().events, splice(id)); + } + world.run_system_once(events_frame).unwrap(); + }; + let tag = |s: &str| Some(s.to_owned()); + assert_eq!(Tags::default().tag(&splice(0)), None, "unset ids tag nothing"); + + // Subscribe at load: no native audio yet. + let mut world = World::new(); + world.init_resource::(); + world.resource_mut::().subscribe("dev.a", Some(vec!["pop".into(), "land".into()]), None).unwrap(); + world.resource_mut::().subscribe("dev.all", Some(vec![]), None).unwrap(); + world.run_system_once(events_frame).unwrap(); + // Native audio starts later (the player's Contacts tuning: pops 1097.. / 1103.., landing 1095). + let mut player = super::super::player_audio::PlayerAudio::new(Default::default(), true); + player.events = None; + let (pop, hollow, land) = (player.contact_tuning.pop_ids[0] as i32, player.contact_tuning.pop_ids_hollow[2] as i32, player.contact_tuning.landing_id as i32); + assert!(pop != 0 && hollow != 0 && land != 0); + let mut n = native(); + n.player = Some(player); + world.insert_resource(n); + world.run_system_once(events_frame).unwrap(); + frame_with(&mut world, &[pop, land, 0, hollow]); + assert_eq!(tags_of(&world, "dev.a"), vec![(pop, tag("pop")), (land, tag("land")), (hollow, tag("pop"))]); + assert_eq!(tags_of(&world, "dev.all"), vec![(pop, tag("pop")), (land, tag("land")), (0, None), (hollow, tag("pop"))], "id 0 is never `land`"); + + // Re-subscribing keeps the ids. + world.resource_mut::().subscribe("dev.a", None, None).unwrap(); + world.run_system_once(events_frame).unwrap(); + world.resource_scope(|world, mut api: Mut| api.subscribe("dev.a", Some(vec!["land".into()]), world.get_resource::())).unwrap(); + frame_with(&mut world, &[pop, land]); + assert_eq!(tags_of(&world, "dev.a"), vec![(land, tag("land"))]); + + // A tuning change (or a restarted player with other ids) retags from the next frame. + world.resource_mut::().player.as_mut().unwrap().contact_tuning.landing_id = 2000; + frame_with(&mut world, &[land, 2000]); + assert_eq!(tags_of(&world, "dev.all"), vec![(land, None), (2000, tag("land"))]); + let mut player = super::super::player_audio::PlayerAudio::new(Default::default(), true); + player.contact_tuning.pop_ids[0] = 3000; + world.resource_mut::().player = Some(player); + world.run_system_once(events_frame).unwrap(); + frame_with(&mut world, &[3000, pop, land]); + assert_eq!(tags_of(&world, "dev.all"), vec![(3000, tag("pop")), (pop, None), (land, tag("land"))]); + + // Native audio stops: back to unset ids. + world.remove_resource::(); + world.run_system_once(events_frame).unwrap(); + assert_eq!(world.resource::().events.tags, Tags::default()); + } + /// A mod's post of a retail class plays (data-gated): `c_emitter` with a DownTown emitter's /// bank loaded sounds through the native mixer, as the emitter system's own post does; /// without the mod's post the output is silent. diff --git a/docs/hails-additions/16-audio-modding.md b/docs/hails-additions/16-audio-modding.md index b3f6050e..4430d9d5 100644 --- a/docs/hails-additions/16-audio-modding.md +++ b/docs/hails-additions/16-audio-modding.md @@ -180,7 +180,7 @@ end Rows come from the post sites themselves: the local player's component posts / releases and Splice starts, the world and NPC hosts (posts, releases, ped Splice steps, ped body falls and phone rings, the ped tazer, the NPC loose-board slide), world emitter start / stop, zone ambience changes and speech line starts (class `speech` for the living world, `maincast` -for the main cast). Tags: `pop`, `land` (the board contacts' Splice starts with the Contacts tuning's pop and landing ids), +for the main cast). Tags: `pop`, `land` (the board contacts' Splice starts with the running player's Contacts tuning pop and landing ids, whenever the mod subscribed), `grind_start`, `grind_end` (the grind slot's post / release), `footstep`, `horn`, `alarm`, `tazer` (a ped's `c_tazer` post), `body_fall` (a ped's body-fall Splice start), `emitter`, `zone_change`, `speech`. Rows are one frame late; at most 256 a frame (`truncated` says when more happened). Nothing is recorded while no mod subscribes. Muting or replacing a retail sound from an event is not possible yet (later: declarative rules). @@ -255,7 +255,7 @@ declarative mute / replace / layer rules, a bank-level hot swap instead of the r ### Closed after review (2026-10-04) -Two gaps the first version left open: +Gaps the first version left open: - **Mod crossfade banks played no crossfade.** Problem: a mod (or custom map) could name its own crossfade bank, but the interim player looked the zone pair's group up in a measured table that knew only retail's three banks, so a mod @@ -304,6 +304,21 @@ Two gaps the first version left open: (gaps, 0, negative, fractional and named keys refused; JSON unchanged). The upstream test `mesh_buffer_write_rejects_empty_uv_table` now checks the write is refused by validation (it still is, by `_submit`) instead of by deserialisation. No-mod e2e `m5` = `m4` (84/84 identical). +- **Found by the in-game autotest: `pop` / `land` were wrong for a mod that subscribed at load.** Problem: a mod + calling `sdk.audio.subscribe` in `on_load` (the normal case: before native audio starts) got pops untagged for the + whole session, and any player `Skate_Collisions` Splice row with id 0 tagged `land` (the `land` rows of the DownTown + log check above were most likely these). Root cause: `AudioApi::subscribe` copied the pop / landing ids from the + native player only if it was already running; otherwise the tags kept `Tags::default` (no pops, landing 0) until the + mod re-subscribed. Rules were not affected (they would read the player directly). Change (`mod_audio.rs`): + `events_frame` copies the running player's ids into the tags every frame a mod subscribes (before it collects that + frame's rows), so they follow a native start, restart, map change or tuning change whatever the order; no player = + unset ids. `Tags` holds fixed arrays (`[i32; 6]` pops, the landing id) and is `Copy`: the refresh allocates nothing, + and with no subscriber `events_frame` returns before it as before. Id 0 (unset) never tags `pop` or `land`. The + copy at subscribe stays as an early value. Mod API unchanged. Test: + `event_tags_follow_the_player_whatever_the_subscription_order` (subscribe with no native audio, start it: a pop, a + hollow pop and a landing tagged, an id-0 row untagged; unsubscribe / re-subscribe; a landing-id change and a + replaced player retag from the next frame; native stopped → unset). No-mod e2e `b1` = `b0` (this branch's head + before the fix, itself = `m5`). Proofs for these changes: the headless e2e bench (84 renders and voice logs) is byte-identical to the previous reference (`m2`) after them (`m3`, and `m4` with the final tree); all suites pass (skate-audio with ignored, the