From 15e83f2483cc03850ba55f10e893659307f5d7ed Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:43:09 +0700 Subject: [PATCH] Ghost and underwater ambience beds resolve by their reference names (#731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ghost heard no ambience. The ghost bed was looked up as a SoundEntries row named "Ghost", which does not exist, so it fell back to kit 0. The underwater bed was a hard-coded row. At init, 1.12.1's `0x4609b0` walks SoundEntries from the last row down. It keeps the rows whose names match `"Ghost (DONOTRENAME)"` (`0x836444`) and `"Underwater (DONOTRENAME)"` (`0x836428`) exactly and case-sensitively (`0x64a480`, a `strncmp` through the NUL). In the install those are rows 4160 (`GhostState.wav`) and 4209. The catalog now resolves both beds once, the same way, and the ambience selector takes them from it, with order and fades unchanged. Tests cover the install's two rows, the exact-name match, the walk order, a ghost selecting its bed over water, and an unresolved bed staying silent. Fixes #721 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 5.5 --- crates/benilla-app/src/sound/kit.rs | 12 +- crates/benilla-app/src/sound/zone.rs | 85 +++++++-- crates/benilla-formats/src/light.rs | 2 +- crates/benilla-formats/src/sound_entries.rs | 188 +++++++++++++++++--- 4 files changed, 251 insertions(+), 36 deletions(-) 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]