From 915ebc0119032d221b92fa94c05aa3d92d84dd53 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 25 Aug 2026 18:04:59 +0700 Subject: [PATCH] the login theme rides the loading screen, and the mix gets a limiter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the login theme no longer cuts off the instant the loading screen appears: it plays through the whole load and hands over on a 3 s fade once the world is ready, as the reference does — the stop sat on the same edge that raised the cover, and the reference never calls its music-stop on the enter-world path at all - the mix had no headroom: every WoW sound is mastered to full scale, so five party-wide Fortitudes in one frame asked for 5× and clipped hard ("dirty, like a speaker breaking") — a stereo-linked look-ahead limiter now holds the output, a meter reports the peak the mix asked for, `SoundOutputLimiter 0` A/Bs it, and the 3D voice ceiling is named at 512 instead of inherited from the backend at 128 - a shaman's Rockbiter / Flametongue / Frostbrand / Windfury Weapon casts straight onto the equipped weapon on the press — no item cursor, no click — the reference's own `Attributes & 0x200` read; stones, oils and poisons still raise the cursor - the nameplate category's player arm is pinned to the bytes (CanCooperate is faction-group-mask equality) and `/reaction` prints the one number it turns on: a GM-mode character plates as an enemy on a test realm because faction 35's mask is zero, exactly as 1.12 does Co-Authored-By: Claude Fable 5 --- crates/benilla-app/src/cvars.rs | 11 +- crates/benilla-app/src/debug_panel/mod.rs | 5 + crates/benilla-app/src/sound/glue.rs | 119 +++++- crates/benilla-app/src/sound/kit.rs | 35 +- crates/benilla-app/src/sound/limiter.rs | 400 ++++++++++++++++++ crates/benilla-app/src/sound/meter.rs | 178 ++++++++ crates/benilla-app/src/sound/mix_tap.rs | 8 +- crates/benilla-app/src/sound/mixer.rs | 311 ++++++++++++-- crates/benilla-app/src/sound/mod.rs | 69 ++- crates/benilla-app/src/sound/zone.rs | 54 +-- crates/benilla-app/src/target/ring.rs | 109 ++++- crates/benilla-app/src/ui_action/cast_send.rs | 89 ++-- .../benilla-app/src/ui_action/cast_target.rs | 209 +++++++-- crates/benilla-app/src/ui_chat/input/mod.rs | 62 ++- crates/benilla-app/src/vplates.rs | 7 +- .../src/spells/catalog_tests.rs | 70 +++ crates/benilla-formats/src/spells/display.rs | 27 +- crates/benilla-formats/src/spells/mod.rs | 6 + crates/benilla-world/src/dev_state.rs | 9 + 19 files changed, 1610 insertions(+), 168 deletions(-) create mode 100644 crates/benilla-app/src/sound/limiter.rs create mode 100644 crates/benilla-app/src/sound/meter.rs diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index c9f03ae0..504118ea 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -80,6 +80,11 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[ // `"1"` would ship audio the real client has never actually produced (bug B236). // `SoundConfig::reverb` carries the evidence. ("SoundReverb", "0"), + // The output limiter (1551) — benilla's own, not a 1.12 CVar. The reference needs no such DSP + // (its mix is FMOD 3's and its headroom lives in the SFX-bus auto-duck); benilla sums into f32 + // behind a hard clamp, and every WoW SFX is mastered to full scale, so two overlapping kits + // clip. Registered so the fix can be A/B'd live against the defect it fixes. + ("SoundOutputLimiter", "1"), ("uiScale", "0.9"), ("farclip", "777"), // The Controls-page trio (0961). `deselectOnClick`/`mouseInvertPitch` are 1.12's own @@ -349,6 +354,7 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool { "enableambience" => knobs.sound.ambience_enabled = v != 0.0, // The client's own parse for this one is literally `!= 0` too (`0x4574d0`: `setne al`). "soundreverb" => knobs.sound.reverb = v != 0.0, + "soundoutputlimiter" => knobs.sound.limiter = v != 0.0, "uiscale" => knobs.scale.0 = v.clamp(0.5, 1.5), "farclip" => knobs.view.farclip = v.clamp(*FARCLIP_RANGE.start(), *FARCLIP_RANGE.end()), "deselectonclick" => knobs.click.deselect_on_click = v != 0.0, @@ -518,7 +524,7 @@ fn sync_cvars( ); script.register_cvars(REGISTERED.iter().copied()); let flag = |b: bool| if b { "1" } else { "0" }.to_string(); - let session: [(&str, String); 30] = [ + let session: [(&str, String); 31] = [ ("MasterVolume", sound.master.to_string()), ("SoundVolume", sound.sfx.to_string()), ("MusicVolume", sound.music.to_string()), @@ -527,6 +533,7 @@ fn sync_cvars( ("EnableMusic", flag(sound.music_enabled)), ("EnableAmbience", flag(sound.ambience_enabled)), ("SoundReverb", flag(sound.reverb)), + ("SoundOutputLimiter", flag(sound.limiter)), ("uiScale", scale.0.to_string()), ("farclip", view.farclip.to_string()), ("deselectOnClick", flag(click.deselect_on_click)), @@ -737,6 +744,8 @@ mod tests { assert_eq!(d["EnableAmbience"] != 0.0, sound.ambience_enabled); // Welded like the rest — and deliberately NOT the binary's registrar "1" (1153). assert_eq!(d["SoundReverb"] != 0.0, sound.reverb); + assert_eq!(d["SoundOutputLimiter"] != 0.0, sound.limiter); + assert!(sound.limiter, "the output limiter ships on (decision 1551)"); assert!(!sound.reverb, "zone reverb ships off (decision 1153)"); assert_eq!(d["uiScale"], DEFAULT_UI_SCALE); // ViewDistance::default() reads $WOW_FARCLIP; the registered default mirrors the diff --git a/crates/benilla-app/src/debug_panel/mod.rs b/crates/benilla-app/src/debug_panel/mod.rs index f6739486..61d14c6e 100644 --- a/crates/benilla-app/src/debug_panel/mod.rs +++ b/crates/benilla-app/src/debug_panel/mod.rs @@ -594,9 +594,14 @@ fn debug_panel_ui( egui::Slider::new(&mut sound_cfg.ambience, 0.0..=1.0) .text("ambience"), ); + ui.checkbox(&mut sound_cfg.limiter, "output limiter (1551)"); ui.separator(); ui.label("kit probe: a SoundEntries id or name"); ui.text_edit_singleline(&mut s.kit_query); + ui.add( + egui::Slider::new(&mut s.play_copies, 1..=16) + .text("copies, one frame"), + ); if ui.button("Play kit").clicked() { s.play_kit = true; } diff --git a/crates/benilla-app/src/sound/glue.rs b/crates/benilla-app/src/sound/glue.rs index 9341dc68..c5d9acce 100644 --- a/crates/benilla-app/src/sound/glue.rs +++ b/crates/benilla-app/src/sound/glue.rs @@ -6,9 +6,10 @@ //! [`super::ui`]'s `PlaySound` seam, but from the glue screens (which have no Lua VM). //! - **Music**: the glue title theme (`GlueParent.lua`'s `CurrentGlueMusic`, //! `Sound\Music\GlueScreenMusic\wow_main_theme.mp3`), streamed from the login screen on -//! (decision 0539) and kept across the glue screens (the ref keeps it through select ⇄ create); entering the world -//! fades it out on the backend (drop the handle — the ramp finishes on kira's thread, the -//! [`super::zone`] music-stop shape). Re-entering the glue after a logout starts it again. +//! (decision 0539) and kept across the glue screens (the ref keeps it through select ⇄ create). +//! **The click into the world does not end it** (1550/1553, wow-re §5): it plays unbroken through +//! the whole map load, and the stop is armed by the *load draining* — a 3.0 s fade, still behind +//! the loading screen. Re-entering the glue after a logout starts it again. use bevy::prelude::*; @@ -20,8 +21,20 @@ use super::{mixer, SoundConfig, SoundOutput}; /// The glue music file — a frozen fact of the 1.12 GlueXML (`GlueParent.lua`, `CurrentGlueMusic`). const GLUE_MUSIC: &str = "Sound\\Music\\GlueScreenMusic\\wow_main_theme.mp3"; -/// The out-fade on entering the world. -const GLUE_MUSIC_FADE_OUT_MS: u64 = 2000; + +/// The theme's own volume, under the Music slider: **0.8** — `0x45aeb0` starts the glue stream at +/// `0x3f4ccccd` on both its arms, where the city-intro playlist and the in-world Lua `PlayMusic` +/// use 1.0f (wow-re §5 `glue-music-world-entry.md` §5c). It is a plain scalar on the MusicVolume +/// category (the stream's flag word is 2, bit `0x2` = `[0x87cef8]`), exactly like a zone track's +/// kit volume, so it multiplies rather than replaces the slider. +const GLUE_MUSIC_VOLUME: f32 = 0.8; + +/// The stop-fade armed when the world's load drains: **3.0 s**, `[0x803248]` in `.rdata` +/// (`0x40400000`) — the constant `0x45aeb0`'s NULL arm hands `0x45b050`, and the fade is **linear +/// in amplitude**, not in dB (`0x7a5a50` ramps FMOD's 0–255 level; §5 §2). Not 1.0 s: that is +/// `StopGlueMusic`'s literal, and `StopGlueMusic` is a `"movie"`-screen call the world entry never +/// makes. +const GLUE_MUSIC_FADE_OUT_MS: u64 = 3000; /// A glue-screen `PlaySound` — the SoundEntries kit name the 1.12 GlueXML plays for this click. #[derive(Message)] @@ -29,11 +42,16 @@ pub(crate) struct GlueSound(pub(crate) &'static str); /// The held glue-music stream (non-`Sync` handle — non-Send state, like [`super::zone`]'s), plus /// its starvation watch (decision 1109). The handle is held through the stop-fade, not dropped at -/// it: the fade's 2 s ride through the world-entry load burst is exactly the crackle-prone window, -/// and [`watch_glue_music`] can only see a stream it still holds. +/// it: the fade rides out under the world-entry load burst, which is exactly the crackle-prone +/// window, and [`watch_glue_music`] can only see a stream it still holds. Since 1550 the theme +/// itself rides that whole burst too — all the more reason for the watch to be able to see it. struct GlueMusic { handle: Option>, watch: mixer::StreamWatch, + /// The stop-fade is armed and running. The per-frame slider feed stands off while it is set, + /// so a live `MusicVolume` change cannot stomp the ramp back up to full mid-fade — the hazard + /// [`super::zone`]'s ambience crossfade documents from the other side. + fading: bool, } impl Default for GlueMusic { @@ -41,6 +59,7 @@ impl Default for GlueMusic { Self { handle: None, watch: mixer::StreamWatch::new("glue music"), + fading: false, } } } @@ -82,9 +101,15 @@ fn start_glue_music( assets: Option>, config: Res, ) { - if music.handle.is_some() { + if music.handle.is_some() && !music.fading { return; // still playing (select ⇄ create hops re-enter CharSelect) } + // A theme caught mid-fade is NOT "still playing": returning to the glue inside the 3 s ramp + // must bring it back at once. The reference gets there by bookkeeping — the enter-world stop + // clears the cached track name (`0x45afec`), so `SetGlueScreen`'s next `PlayGlueMusic` misses + // the same-name early-out, stops the outgoing stream and opens a fresh one. Same outcome here: + // the old handle is dropped below with its ramp already armed, so it finishes fading on the + // backend while the new stream starts at full. let (Some(assets), Some(mixer_ref)) = (assets, out.mixer.as_mut()) else { return; }; @@ -99,38 +124,85 @@ fn start_glue_music( return; } }; - let amp = config.category_amp(SoundCategory::Music); + let amp = config.category_amp(SoundCategory::Music) * GLUE_MUSIC_VOLUME; match mixer_ref.play_stream(data.volume(mixer::amp_to_db(amp))) { Ok(h) => { info!("glue music: {GLUE_MUSIC}"); music.handle = Some(h); + music.fading = false; + music.watch.reset(); } Err(e) => debug!("glue music: {e:#}"), } } -/// Entering the world: fade the theme out on the backend. The handle is deliberately kept — the -/// [`GlueMusic`] docs say why — and reaped by [`watch_glue_music`] once the fade lands on -/// `Stopped`. -fn stop_glue_music(mut music: NonSendMut) { +/// **The world's load drained — arm the theme's fade** (decision 1553, correcting 1550's trigger). +/// +/// This is the reference's own trigger, and it is not a music event at all: `CGlueMgr::Update` +/// state 8 runs a *second* pass every frame after the entry (`[0xb41d94] == 1`), spinning on the +/// AsyncFileLoader (`0x443e20`) while any read is outstanding; the frame the queue drains it calls +/// `0x45aeb0(NULL)` → `0x45b050(3.0f)` and then sends `CMSG_PLAYER_LOGIN`. So the theme sounds +/// through the click and the whole map load, and dies on a 3.0 s fade armed *behind* the loading +/// screen — never on the click (1550's own `stop_glue_music`), and never on the first world track +/// (1550's replacement, which was the right *feel* found from a misread of the branch). +/// +/// benilla's analogue of "the async queue drained" is the cover's own clear condition — every +/// wanted tile spawned, placements up, colliders quiet ([`crate::loading_screen`]) — so the fade is +/// armed on `world_hold`'s **falling edge**. The edge is tracked every frame but only acted on +/// **in the world**, which is what excludes the logout blackout: that cover drops on the frame the +/// state leaves `InWorld` (0738), and firing there would fade the theme the glue had just restarted. +/// +/// The handle is deliberately kept through the fade — the [`GlueMusic`] docs say why — and reaped +/// by [`watch_glue_music`] once the fade lands on `Stopped`. +fn hand_off_glue_music( + config: Res, + state: Res>, + mut music: NonSendMut, + mut was_covered: Local, +) { + let covered = config.world_hold; + let fell = std::mem::replace(&mut *was_covered, covered) && !covered; + if !fell || *state.get() != ClientState::InWorld { + return; + } if let Some(h) = music.handle.as_mut() { + info!("glue music: the world's load drained — {GLUE_MUSIC_FADE_OUT_MS} ms fade"); h.stop(mixer::fade(GLUE_MUSIC_FADE_OUT_MS)); + music.fading = true; } } /// Per-frame stream health + handle reaping: the starvation watch (decision 1109) over the held -/// theme, and the drop once it reaches `Stopped` — after the entry fade lands, or at the theme's +/// theme, and the drop once it reaches `Stopped` — after the handoff fade lands, or at the theme's /// natural end. On the glue screens that end re-arms [`start_glue_music`], which brings the theme -/// back from the top — the login screen no longer falls silent for good after one play-through -/// (the reference's theme keeps going; it rides the same music machinery as zone tracks, -/// `PlayGlueMusic` → `0x45aeb0`, which re-selects when a track ends). -fn watch_glue_music(mut music: NonSendMut, time: Res