diff --git a/crates/benilla-app/src/sound/kit.rs b/crates/benilla-app/src/sound/kit.rs index 3c5e6ceb..6a61104a 100644 --- a/crates/benilla-app/src/sound/kit.rs +++ b/crates/benilla-app/src/sound/kit.rs @@ -732,10 +732,14 @@ fn near_field(d_sq: f32, cutoff: f32) -> f32 { } impl SoundKits { - /// A kit id by its `PlaySoundByName` key (the `0x458030` name registry), such as the ghost - /// tracks "Ghost" and "GhostMusic". - pub(crate) fn id_by_name(&self, name: &str) -> Option { - self.catalog.by_name(name).map(|k| k.id) + /// The ambience kit a ghost hears (`[0xb06d48]`, resolved at init by `0x4609b0`). + pub(super) fn ghost_bed(&self) -> Option { + self.catalog.ghost_bed() + } + + /// The ambience kit a submerged listener hears (`[0xb06d4c]`, resolved at init by `0x4609b0`). + pub(super) fn underwater_bed(&self) -> Option { + self.catalog.underwater_bed() } pub(crate) fn new(catalog: SoundKitCatalog) -> Self { diff --git a/crates/benilla-app/src/sound/zone.rs b/crates/benilla-app/src/sound/zone.rs index 1dc20d6f..57f759db 100644 --- a/crates/benilla-app/src/sound/zone.rs +++ b/crates/benilla-app/src/sound/zone.rs @@ -63,8 +63,19 @@ fn phase(clock: &GameClock) -> usize { } } -/// SoundEntries 4123 `UnderWaterLoop`, the submerged ambience bed. -const UNDERWATER_LOOP_KIT: u32 = 4123; +/// The selector's two forced beds (`0x460bd0`), ahead of the weather and zone beds: a ghost's +/// `[0xb06d48]`, else a submerged listener's `[0xb06d4c]`. The kit is 0 when the row was not +/// found, and the selector then returns silence rather than falling through, as `0x460bd9` and +/// `0x460be8` return the global unchecked. +fn forced_bed(kits: &SoundKits, ghost: bool, submerged: bool) -> Option { + if ghost { + Some(kits.ghost_bed().unwrap_or(0)) + } else if submerged { + Some(kits.underwater_bed().unwrap_or(0)) + } else { + None + } +} /// The outgoing music fade, the only fade on the music slot: `0x4602e0` → /// `0x7a5a10(0x40800000 = 4.0f)`. @@ -325,16 +336,13 @@ fn zone_audio( } // ---- ambience: the reference's selector (`0x460bd0`) ranks ghost > submerged > weather > - // interior/zone day/night; the ghost bed is the kit named "Ghost", its swap the 5.0 s - // crossfade (`0x458680` → `0x460c20`) ---- + // interior/zone day/night; a ghost's swap is the 5.0 s crossfade (`0x458680` → `0x460c20`) ---- reap_stopped_bed(zone); let ghost = self_store .single() .is_ok_and(|store| store.0.player_is_ghost()); - let desired = if ghost { - kits.id_by_name("Ghost").unwrap_or(0) - } else if world.submersion().is_water() { - UNDERWATER_LOOP_KIT + let desired = if let Some(bed) = forced_bed(&kits, ghost, world.submersion().is_water()) { + bed } else if weather.0 != 0 && world.area_interior().is_none() { // `0x460bf7`–`0x460c17`: with the zonetext indoor bit `[0xb06d44]` clear, the weather's // SoundEntries is the bed; indoors the selector ignores it, and the swap crossfades. @@ -1114,12 +1122,14 @@ pub(super) fn plugin(app: &mut App) { #[cfg(test)] mod tests { use super::{ - apply_music_suppression, begin_bed_open, begin_zone_music, first_unplayable, open_bed, - poll_bed_open, pump_zone_music, reap_stopped_bed, slot_holds, start_bed, + apply_music_suppression, begin_bed_open, begin_zone_music, first_unplayable, forced_bed, + open_bed, poll_bed_open, pump_zone_music, reap_stopped_bed, slot_holds, start_bed, stop_world_soundscape, take_lua_music_slot, OpenedBed, ZoneAudio, CINEMATIC_MUSIC_RESUME_SECS, LUA_MUSIC_SCHEDULE_SECS, }; + use crate::sound::kit::SoundKits; use crate::sound::mixer; + use benilla_formats::SoundKitCatalog; use kira::sound::streaming::StreamingSoundData; use kira::sound::{FromFileError, PlaybackState}; use std::sync::{mpsc, Arc, Mutex}; @@ -1400,6 +1410,61 @@ mod tests { assert!(!slot_holds(8440, 4123, Some(PlaybackState::Playing))); } + /// A ghost hears its own bed whatever the water does, a swimmer the underwater one, and a + /// living dry listener neither. A row named plain `Ghost` or `UnderWaterLoop`, which the + /// selector does not look for, is never the bed. + #[test] + fn a_ghost_selects_the_ghost_bed_ahead_of_the_water_bed() { + let kits = SoundKits::new(SoundKitCatalog::named_for_tests(&[ + (7, "Ghost"), + (4123, "UnderWaterLoop"), + (4160, "Ghost (DONOTRENAME)"), + (4209, "Underwater (DONOTRENAME)"), + ])); + assert_eq!(forced_bed(&kits, true, false), Some(4160)); + assert_eq!( + forced_bed(&kits, true, true), + Some(4160), + "ghost outranks water" + ); + assert_eq!(forced_bed(&kits, false, true), Some(4209)); + assert_eq!( + forced_bed(&kits, false, false), + None, + "the weather and zone beds follow" + ); + } + + /// `0x460bd9` and `0x460be8` return the global unchecked, so an unresolved row is silence + /// (kit 0), not the next bed in the ranking. + #[test] + fn an_unresolved_forced_bed_is_silence() { + let kits = SoundKits::new(SoundKitCatalog::named_for_tests(&[( + 4123, + "UnderWaterLoop", + )])); + assert_eq!(forced_bed(&kits, true, false), Some(0)); + assert_eq!(forced_bed(&kits, false, true), Some(0)); + assert_eq!(forced_bed(&kits, false, false), None); + } + + /// With the install: the ghost bed is `GhostState.wav` and the water bed the underwater loop + /// at its own row's volume, each a file the chain opens. + #[test] + fn the_install_s_ghost_and_water_beds_are_the_donotrename_rows() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("chain"); + let catalog = benilla_formats::load_sound_kit_catalog(&mut chain).expect("SoundEntries"); + let mut kits = SoundKits::new(catalog); + assert_eq!(forced_bed(&kits, true, false), Some(4160)); + assert_eq!(forced_bed(&kits, false, true), Some(4209)); + for (kit, file) in [(4160, "GhostState.wav"), (4209, "UndwaterLoop.wav")] { + let (path, _) = kits.pick_stream(kit).expect("the kit has a bed"); + assert!(path.ends_with(file), "kit {kit} plays {path}"); + assert!(chain.contains(&path), "{path} is in the archives"); + } + } + fn io_pool() { bevy::tasks::IoTaskPool::get_or_init(bevy::tasks::TaskPool::new); } diff --git a/crates/benilla-formats/src/light.rs b/crates/benilla-formats/src/light.rs index 3edc847e..57c08052 100644 --- a/crates/benilla-formats/src/light.rs +++ b/crates/benilla-formats/src/light.rs @@ -81,7 +81,7 @@ impl Submersion { self != Submersion::Dry } - /// A water kind (water, ocean, rapids), for the `UnderWaterLoop` bed and the underwater reverb; + /// A water kind (water, ocean, rapids), for the underwater ambience bed and the reverb; /// whether the reference swaps those for magma and slime too is untraced. pub fn is_water(self) -> bool { matches!(self, Submersion::Water | Submersion::Ocean) diff --git a/crates/benilla-formats/src/sound_entries.rs b/crates/benilla-formats/src/sound_entries.rs index df1a691b..45aa737e 100644 --- a/crates/benilla-formats/src/sound_entries.rs +++ b/crates/benilla-formats/src/sound_entries.rs @@ -50,11 +50,17 @@ pub struct SoundKit { pub eax_def: u32, } +/// The names `0x4609b0` finds the two forced ambience beds by (`0x836444`, `0x836428`). +const GHOST_BED_NAME: &str = "Ghost (DONOTRENAME)"; +const UNDERWATER_BED_NAME: &str = "Underwater (DONOTRENAME)"; + /// All kits, resolvable by id or, ignoring case, by name. pub struct SoundKitCatalog { kits: HashMap, /// Lowercased `Name` to id: the reference's name hash ignores case. by_name: HashMap, + ghost_bed: Option, + underwater_bed: Option, } impl SoundKitCatalog { @@ -69,6 +75,16 @@ impl SoundKitCatalog { .and_then(|id| self.kits.get(id)) } + /// The ambience bed a ghost hears, the row named `"Ghost (DONOTRENAME)"`: `[0xb06d48]`. + pub fn ghost_bed(&self) -> Option { + self.ghost_bed + } + + /// The ambience bed under water, the row named `"Underwater (DONOTRENAME)"`: `[0xb06d4c]`. + pub fn underwater_bed(&self) -> Option { + self.underwater_bed + } + pub fn len(&self) -> usize { self.kits.len() } @@ -79,13 +95,69 @@ impl SoundKitCatalog { /// An empty catalog for consumers' unit tests. pub fn empty_for_tests() -> Self { + Self::from_kits(Vec::new()) + } + + /// A catalog of file-less kits, `(id, name)` in row order, for consumers' unit tests. + pub fn named_for_tests(rows: &[(u32, &str)]) -> Self { + Self::from_kits( + rows.iter() + .map(|&(id, name)| SoundKit { + id, + sound_type: 0, + name: name.to_owned(), + files: Vec::new(), + volume: 1.0, + flags: 0, + min_distance: 0.0, + distance_cutoff: 0.0, + eax_def: 0, + }) + .collect(), + ) + } + + /// The catalog of `rows` in file order, with the two forced beds resolved once, as the + /// reference does at init. + fn from_kits(rows: Vec) -> Self { + let (ghost_bed, underwater_bed) = resolve_forced_beds(&rows); + let mut kits = HashMap::with_capacity(rows.len()); + let mut by_name = HashMap::with_capacity(rows.len()); + for kit in rows { + if !kit.name.is_empty() { + by_name.insert(kit.name.to_ascii_lowercase(), kit.id); + } + kits.insert(kit.id, kit); + } Self { - kits: HashMap::new(), - by_name: HashMap::new(), + kits, + by_name, + ghost_bed, + underwater_bed, } } } +/// `0x4609b0`: the ghost and underwater ambience rows, `(ghost, underwater)`. It walks the table +/// from the last row down, comparing each name with `0x64a480` at length `0x7fffffff`, which is +/// `strncmp` (`0x40de80`): exact, case-sensitive, the whole string with its terminator, so neither +/// a case variant nor a prefix matches (the ignore-case `0x64a4c0` is not used here). A match +/// stores the row's id, later rows down overwriting, and the walk ends when both are set. +fn resolve_forced_beds(rows: &[SoundKit]) -> (Option, Option) { + let (mut ghost, mut underwater) = (None, None); + for kit in rows.iter().rev() { + if ghost.is_some() && underwater.is_some() { + break; + } + if kit.name == GHOST_BED_NAME { + ghost = Some(kit.id); + } else if kit.name == UNDERWATER_BED_NAME { + underwater = Some(kit.id); + } + } + (ghost, underwater) +} + fn sound_entries_schema() -> Schema { let mut s = Schema::new("SoundEntries"); s.add_field(SchemaField::new("ID", FieldType::UInt32)); @@ -129,8 +201,7 @@ pub fn load_sound_kit_catalog(chain: &mut Chain) -> Result { .read_file(SOUND_ENTRIES) .with_context(|| format!("reading {SOUND_ENTRIES}"))?; let rs = parse(&bytes, sound_entries_schema(), "SoundEntries")?; - let mut kits = HashMap::with_capacity(rs.records().len()); - let mut by_name = HashMap::with_capacity(rs.records().len()); + let mut rows = Vec::with_capacity(rs.records().len()); for r in rs.records() { let Some(id) = u32_at(r, 0) else { continue }; let name = str_at(&rs, r, 2).unwrap_or_default(); @@ -144,25 +215,19 @@ pub fn load_sound_kit_catalog(chain: &mut Chain) -> Result { let path = join_variation(&dir, &file); files.push((path, weight)); } - if !name.is_empty() { - by_name.insert(name.to_ascii_lowercase(), id); - } - kits.insert( + rows.push(SoundKit { id, - SoundKit { - id, - sound_type: u32_at(r, 1).unwrap_or(0), - name, - files, - volume: f32_at(r, 24).unwrap_or(1.0), - flags: u32_at(r, 25).unwrap_or(0), - min_distance: f32_at(r, 26).unwrap_or(0.0), - distance_cutoff: f32_at(r, 27).unwrap_or(0.0), - eax_def: u32_at(r, 28).unwrap_or(0), - }, - ); + sound_type: u32_at(r, 1).unwrap_or(0), + name, + files, + volume: f32_at(r, 24).unwrap_or(1.0), + flags: u32_at(r, 25).unwrap_or(0), + min_distance: f32_at(r, 26).unwrap_or(0.0), + distance_cutoff: f32_at(r, 27).unwrap_or(0.0), + eax_def: u32_at(r, 28).unwrap_or(0), + }); } - Ok(SoundKitCatalog { kits, by_name }) + Ok(SoundKitCatalog::from_kits(rows)) } #[cfg(test)] @@ -210,6 +275,87 @@ mod tests { ); } + /// The two forced ambience rows, by the exact names `0x4609b0` compares: row 4160 and row 4209, + /// where no row is named plain `Ghost` and 4123 `UnderWaterLoop` is a different row of the + /// same file. + #[test] + fn real_sound_entries_resolve_the_forced_ambience_beds() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_sound_kit_catalog(&mut chain).expect("load sound kits"); + + assert_eq!(cat.ghost_bed(), Some(4160)); + assert_eq!(cat.underwater_bed(), Some(4209)); + let file = |id: u32| cat.get(id).expect("row").files[0].0.clone(); + assert!(file(4160).ends_with("GhostState.wav")); + assert!(file(4209).ends_with("UndwaterLoop.wav")); + assert_eq!(cat.get(4123).expect("row").name, "UnderWaterLoop"); + assert_eq!(file(4123), file(4209), "the same file, its own row"); + + // One row of each name, and none named plain `Ghost`. + let count = |name: &str| cat.kits.values().filter(|k| k.name == name).count(); + assert_eq!(count("Ghost (DONOTRENAME)"), 1); + assert_eq!(count("Underwater (DONOTRENAME)"), 1); + assert!(cat.by_name("Ghost").is_none()); + } + + /// `0x64a480` at length `0x7fffffff` is `strncmp` over the whole string: a case variant, a + /// prefix or an extension of the name is another row, and the ignore-case name registry does + /// not decide it. + #[test] + fn the_forced_beds_match_the_exact_name_only() { + let cat = SoundKitCatalog::named_for_tests(&[ + (1, "ghost (donotrename)"), + (2, "Ghost (DONOTRENAME) "), + (3, "Ghost"), + (4, "Ghost (DONOTRENAM"), + (5, "UNDERWATER (DONOTRENAME)"), + (6, "Underwater (DONOTRENAME)x"), + (7, ""), + ]); + assert_eq!((cat.ghost_bed(), cat.underwater_bed()), (None, None)); + assert_eq!( + cat.by_name("GHOST").map(|k| k.id), + Some(3), + "PlaySoundByName folds case" + ); + + let cat = SoundKitCatalog::named_for_tests(&[ + (1, "ghost (donotrename)"), + (9, "Ghost (DONOTRENAME)"), + (10, "Underwater (DONOTRENAME)"), + ]); + assert_eq!((cat.ghost_bed(), cat.underwater_bed()), (Some(9), Some(10))); + } + + /// The walk runs from the last row down and stops once both are set: a duplicate name is + /// overwritten by earlier rows only while the other bed is still unfound. + #[test] + fn the_forced_bed_walk_runs_from_the_last_row_down() { + let cat = SoundKitCatalog::named_for_tests(&[ + (1, "Ghost (DONOTRENAME)"), + (2, "Underwater (DONOTRENAME)"), + (3, "Ghost (DONOTRENAME)"), + (4, "Underwater (DONOTRENAME)"), + ]); + assert_eq!( + (cat.ghost_bed(), cat.underwater_bed()), + (Some(3), Some(4)), + "both set at rows 4 and 3, the walk ends" + ); + + let cat = SoundKitCatalog::named_for_tests(&[ + (1, "Underwater (DONOTRENAME)"), + (2, "Ghost (DONOTRENAME)"), + (3, "Ghost (DONOTRENAME)"), + ]); + assert_eq!( + (cat.ghost_bed(), cat.underwater_bed()), + (Some(2), Some(1)), + "row 2 overwrites row 3 while the underwater bed is still unfound" + ); + } + /// The `EAXDef` census the reverb send is gated on: 0 is the reference's null slot, a channel /// that never gets reverb. #[test]