From 9489d8c9ad06b2e7d100d55f6834ecbcf6da2c16 Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 19 Sep 2026 17:11:24 +0700 Subject: [PATCH] PlayMusic and StopMusic are in, and an addon's own audio plays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PlayMusic and StopMusic are in, one engine verb with a NULL arm as the reference has it: the track loops on the Music slider, the zone music fades out under it over 4 s and comes back 6 s after a stop (2309) - a PlayMusic whose file does not open leaves the zone track alone, and the server-pushed music slot is named — three fades the reference keeps apart that our one slot folds together, the next move (2315) - an addon's own sound file plays: every by-path asset read walks the patch chain, then the loose AddOns folder — the rule art and fonts already had, now one function that audio shares Co-Authored-By: Claude Fable 5.1 --- crates/benilla-app/src/sound/kit.rs | 7 +- crates/benilla-app/src/sound/zone.rs | 444 +++++++++++++++++- crates/benilla-app/src/ui_text/engine/mod.rs | 17 +- crates/benilla-assets/src/world_assets/mod.rs | 76 +++ crates/benilla-ui/src/script/mod.rs | 2 +- crates/benilla-ui/src/script/model.rs | 11 +- crates/benilla-ui/src/script/sound.rs | 124 ++++- 7 files changed, 657 insertions(+), 24 deletions(-) diff --git a/crates/benilla-app/src/sound/kit.rs b/crates/benilla-app/src/sound/kit.rs index 618861c8..7d955baf 100644 --- a/crates/benilla-app/src/sound/kit.rs +++ b/crates/benilla-app/src/sound/kit.rs @@ -1104,10 +1104,11 @@ impl SoundKits { if let Some(d) = self.cache.get(&key) { return Ok(d.clone()); } + // **Two stores, one rule** — the chain, then an addon's own loose file + // ([`benilla_assets::read_chain_or_loose`], decision 1322's resolver): `PlaySoundFile` is + // a by-path verb, and the audio an addon ships lives on disk, never in an MPQ. let bytes = assets - .chain - .lock_recover() - .read_file(path) + .read_file_or_loose(path) .with_context(|| format!("reading {path}"))?; let data = mixer::sfx_from_bytes(bytes)?; self.cache.insert(key, data.clone()); diff --git a/crates/benilla-app/src/sound/zone.rs b/crates/benilla-app/src/sound/zone.rs index 79d5e1e8..3811f16a 100644 --- a/crates/benilla-app/src/sound/zone.rs +++ b/crates/benilla-app/src/sound/zone.rs @@ -104,6 +104,26 @@ const UNDERWATER_LOOP_KIT: u32 = 4123; /// thread (verified device-free by `mixer::tests::stop_fade_ramps_after_handle_drop`). const MUSIC_FADE_OUT_MS: u64 = 4000; +/// The Lua `PlayMusic` slot's own base volume: **1.0**, set instantly. `0x460450` puts it through +/// `0x7a5dc0` with no fade and at 1.0f — where the glue theme uses 0.8f (wow-re §5c +/// `glue-music-world-entry.md`) — as a plain scalar under the MusicVolume slider, exactly like a +/// kit's volume, so the slider still rescales it live. +const LUA_MUSIC_VOLUME: f32 = 1.0; + +/// What **either** Lua music verb leaves on the zone pump's clock: **now + 6.000 s** +/// (`0x46049b`, `[0x836400] = 0x42c010() + 0x1770`). +/// +/// A fourth number beside the three the zone slot already has, and the only one written by a verb +/// rather than by the soundscape: not the `-1` "start now" a zone change and a world entry arm, +/// not the cinematic's 3.000 s ([`CINEMATIC_MUSIC_RESUME_SECS`]), and not `0x4601f0`'s randomized +/// `SilenceInterval` (whose only caller is the natural-end reap, unreachable from here). +/// +/// **Unconditional, on both verbs, even from cold** — the write sits between `0x460499`'s +/// `test esi,esi` and its `je`, ahead of the NULL branch — so `StopMusic()` with nothing playing +/// still moves the pump's next start, and can push out a deadline that was already due. That is +/// the side effect a re-implementation drops, and it is why it has a name here. +const LUA_MUSIC_SCHEDULE_SECS: f64 = 6.0; + /// The zone-ambience crossfade: **5.0 s** for an area / interior / day↔night / ghost swap — /// `0x460b00` fades the old bed out over 5.0 s (`0x7a5a10(0x40a00000 = 5.0f)`) and fades the new /// bed in from silence over the same 5.0 s (`0x7a5dc0(0)` → `0x7a57b0(5.0f, kit vol)`). Byte-exact @@ -166,6 +186,21 @@ pub(super) struct ZoneAudio { music_kit: u32, /// The playing track's kit base volume (multiplied under the category slider + fade). music_kit_vol: f32, + /// **The Lua `PlayMusic` slot** — the reference's `[0xb06ccc]`, a *second* music stream beside + /// the zone track's `[0xb06cc4]` (`0x460450` opens it at `0x4604a7`, stores the handle at + /// `0x4604b8`), driven by the `PlayMusic`/`StopMusic` pair and nothing else. Held rather than + /// dropped: a dropped kira handle keeps playing, and the loop below has to be able to see it. + lua_music: Option>, + /// The file on that slot, kept because the stream **loops forever**: the reference opens it + /// through `0x7a5620`'s `a1 != 1` arm, whose `SetLoopCount(-1)` (`0x7a5592`, one call site + /// image-wide) makes this and the glue theme the client's only two infinite streams. kira + /// cannot loop a decode-stream (`mixer::loop_from_bytes` docs: an mp3's *estimated* duration + /// puts `loop_region` past EOF and the stream dies mid-file), so the loop is a restart at the + /// natural end — the same shape [`super::glue`] gives the theme. + lua_music_path: Option, + /// Starvation watch over that slot (decision 1109), like the zone track's — it is a streamed + /// track of exactly the same class, and an addon's music is the one nothing else reports on. + lua_music_watch: mixer::StreamWatch, /// When the next zone track starts (Time::elapsed_secs_f64; None = a track is playing or /// no music in this zone). next_track_at: Option, @@ -198,6 +233,9 @@ impl Default for ZoneAudio { music_watch: mixer::StreamWatch::new("zone music"), music_kit: 0, music_kit_vol: 1.0, + lua_music: None, + lua_music_path: None, + lua_music_watch: mixer::StreamWatch::new("Lua music"), next_track_at: None, music_suppressed: false, ambience_kit: 0, @@ -223,6 +261,14 @@ impl ZoneAudio { x } + /// Whether a **Lua `PlayMusic` track is on the override slot** — the reference's + /// `mov ecx,[0xb06ccc]; test` (`0x4603d6`), asked of the slot's *record* rather than of the + /// live handle so it is also true across the restart gap between a stream's natural end and + /// its reopen: that gap is still the caller's track. + fn lua_slot_live(&self) -> bool { + self.lua_music_path.is_some() + } + /// Uniform silence interval (ms) in `[min, max]` for the phase. fn silence_ms(&mut self, min: u32, max: u32) -> u32 { if max > min { @@ -452,10 +498,17 @@ fn zone_audio( next_track_time(zone, &areas.0, zm, phase, now, config.zone_music_no_delay); } } - // Silence elapsed → start the next track (same-zone cycle, the cold-start first track, or the - // 3.0 s resume after a cinematic). The pump is dead while suppressed, which is the reference's - // own shape — `0x460040` bails at its first instruction on the flag, so no track is selected, - // opened or scheduled for the duration. + // Silence elapsed → start the next track (same-zone cycle, the cold-start first track, the + // 3.0 s resume after a cinematic, or the 6.0 s one a Lua music verb left). The pump is dead + // while suppressed, which is the reference's own shape — `0x460040` bails at its first + // instruction on the flag, so no track is selected, opened or scheduled for the duration. + // + // It bails at its SECOND test on a live Lua override slot (`0x460057`), i.e. without reading + // the deadline at all, where this block reads it, clears it and is then refused at the slot + // by [`start_music_stream`]. The audible outcome is the same and cannot come apart, because + // the only thing that ends an override is the verb itself and the verb always rewrites the + // deadline on its way through ([`LUA_MUSIC_SCHEDULE_SECS`]): whatever this block consumed + // meanwhile, zone music comes back 6.0 s after the `StopMusic`. if zone.next_track_at.is_some_and(|t| now >= t) && zone.music.is_none() { zone.next_track_at = None; if let Some(m) = zone_music_row(&areas.0, zone.zone_music) { @@ -506,6 +559,25 @@ fn apply_music_suppression(zone: &mut ZoneAudio, suppressed: bool, now: f64) { return; } zone.music_suppressed = suppressed; + // **A live Lua `PlayMusic` track is PAUSED and RESUMED, and nothing else happens on either + // edge.** `0x4603b0` writes the disabled flag first (`0x4603ba`, so the pump dies here too), + // then reads the Lua slot at `0x4603d6` and — finding it live — hands the stream `0x7a5ac0` + // with that same flag and **returns** (`0x4603e0`): the zone stream is not stopped, the intro + // layer is not killed, and `[0x836400]` is never rearmed, on the way in or out (wow-re + // `sound/scratch/cinematic-audio-law.md` §1; `sound.md` §"the two handle slots"). A cinematic + // borrows the caller's track; it does not take it. + if zone.lua_slot_live() { + if let Some(h) = zone.lua_music.as_mut() { + if suppressed { + h.pause(mixer::declick()); + info!("Lua music: paused for a cinematic"); + } else { + h.resume(mixer::declick()); + info!("Lua music: resumed"); + } + } + return; + } if suppressed { if let Some(mut h) = zone.music.take() { h.stop(mixer::fade(0)); @@ -600,6 +672,19 @@ fn start_music_stream( if config.music_suppressed { return false; } + // **And nothing opens it while a Lua `PlayMusic` track holds the override slot** — the + // reference gates on the slot ITSELF, not on a schedule, in five separate places: the pump's + // own second test (`0x460050 mov eax,[0xb06ccc]; test eax,eax; jne 0x4600f8`), the + // zone-music change `0x4602e0`, the music-enable toggle `0x460420`, the play-by-id + // `0x460520` (which is where a server push lands there) and `0x4603b0`'s pause leg. Five + // gates on one condition is the same argument the cinematic gate above makes: the refusal + // belongs at the slot, whatever asked for it — and here that covers all four of this + // function's callers, the zone change, the intro fanfare, the silence-timer pump and + // `SMSG_PLAY_MUSIC`. With loop count `-1` the override is indefinite: it ends when the + // caller ends it. + if zone.lua_slot_live() { + return false; + } let Some(mixer_ref) = out.mixer.as_mut() else { return false; }; @@ -639,7 +724,157 @@ fn start_music_stream( } } -/// Crossfade the looping ambience bed to a new kit (0 = stop), over `fade_ms`: the old bed fades +/// **The Lua music verb — `PlayMusic(name)` and `StopMusic()` are ONE function with one branch.** +/// +/// `StopMusic 0x458770` is four instructions, three of which are `0x460450(NULL)` — the very +/// function `PlayMusic 0x458720` calls. So *stopping is playing a NULL name*, and this is that +/// function (wow-re `sound/scratch/lua-music-bindings.md`, the §5 round dispatched for B391; +/// `0x460450` has two callers image-wide and no address-takes, so nothing else in the client can +/// start or stop this slot). The order below is its order: +/// +/// 1. **[`take_lua_music_slot`]** — the shared head, which is also the whole of the NULL arm. +/// 2. `0x4604a0` — the branch. A stop is done; everything after this is the name arm. +/// 3. **[`open_lua_music`]** — the stream, looping, at full volume. +/// 4. `0x4604d4`–`0x4604e8` — **if the stream opened**, the zone track fades out over 4.0 s and +/// its slot is cleared. The incoming stream is already at full (the client has no music +/// fade-in), so this is an overlap, not a gap — the same 4.0 s overlap a zone-music change +/// makes. +/// +/// **A name that does not resolve stops at step 3**, and that is a confirmed branch rather than a +/// reading: `0x4604bd je 0x460517` returns the moment `0x7a5620` answers NULL, above the volume +/// set and everything after it. What makes it look linear from a distance is that the handle +/// *store* (`0x4604b8`) IS unconditional — it is wedged between `test eax,eax` and that `jcc`. So +/// a mistyped `PlayMusic` still ends whatever the caller had playing and still pushes the pump out +/// 6 s, because both of those are in the head; it leaves a live zone track exactly where it was. +/// +/// `assets`/`path` are both `None` for the NULL arm. A *name* arm the caller cannot serve — no +/// store, or the loading cover up — is dropped before it gets here ([`lua_music`]), because a +/// start held behind a cover would arrive in the zone after the one that asked for it. +fn set_lua_music( + zone: &mut ZoneAudio, + out: &mut SoundOutput, + assets: Option<&WorldAssets>, + config: &SoundConfig, + path: Option<&str>, + now: f64, +) { + take_lua_music_slot(zone, now); + let (Some(path), Some(assets)) = (path, assets) else { + return; + }; + // Only a stream that actually opened takes the zone track down (`0x4604bd`'s return — the + // doc above), and the reference then re-reads its own slot (`0x4604cb`/`0x4604d2`) and the + // zone's (`0x4604da`) before the call, which is what this pair of conditions is. + if open_lua_music(zone, out, assets, config, path) { + // The zone track's own 4.0 s fade-stop — handed to the backend, handle dropped, exactly + // as a zone-music change arms it (`0x7a5a10(4.0f)` there and here are the same call). + if let Some(mut h) = zone.music.take() { + h.stop(mixer::fade(MUSIC_FADE_OUT_MS)); + info!("zone music: faded out under a Lua PlayMusic"); + } + } +} + +/// `0x460450`'s **shared head**, which is also the whole of its NULL arm: fade this slot's own +/// stream out over **4.0 s** (`0x460480` — a fade, never the `0x7a5700` hard cut, so a second +/// `PlayMusic` crossfades), clear the slot (`0x460485`), and rearm the zone pump +/// ([`LUA_MUSIC_SCHEDULE_SECS`]). +/// +/// Split out for the reason [`apply_music_suppression`] is: its two numbers are byte-shaped and +/// this way they can be asserted without standing up a device. +/// +/// **It does not touch the zone track.** `[0xb06cc4]`'s only mentions in the reference's function +/// sit *past* the NULL branch, which is exactly what the reporter said from the outside: *"not +/// used to stop normal music played by being in a zone"*. +fn take_lua_music_slot(zone: &mut ZoneAudio, now: f64) { + if let Some(mut h) = zone.lua_music.take() { + h.stop(mixer::fade(MUSIC_FADE_OUT_MS)); + info!("Lua music: stopped"); + } + zone.lua_music_path = None; + zone.lua_music_watch.reset(); + zone.next_track_at = Some(now + LUA_MUSIC_SCHEDULE_SECS); +} + +/// Open a Lua music stream on the override slot — by path, not by kit, and looping. +/// +/// `0x4604a7`–`0x4604bf`: the stream through `0x7a5620`'s looping arm, the handle stored +/// unconditionally, volume **1.0f** recorded instantly (an immediate in `.text`, not a `.data` +/// cell) on the MusicVolume category. Also the **loop**: the reference's `SetLoopCount(-1)` makes +/// the natural end unreachable, so [`lua_music`] reopens here instead. +/// +/// **Opened paused under a cinematic.** With music disabled the reference still opens the file, +/// stores the handle and records the volume — only the deferred `_FSOUND_Stream_PlayEx` is gated +/// (`0x4604f2`/`0x4604f9`), and the stream starts the moment music comes back, mid-nothing rather +/// than from the top. A paused stream is that, and the falling edge in +/// [`apply_music_suppression`] is what resumes it. (The `EnableMusic` CVar's own half of that byte +/// needs nothing here: benilla zeroes the category instead of pausing the stream, the divergence +/// [`SoundConfig::music_enabled`] already discloses — and for *this* slot that divergence is the +/// faithful behaviour, because the reference resumes this very stream too.) +fn open_lua_music( + zone: &mut ZoneAudio, + out: &mut SoundOutput, + assets: &WorldAssets, + config: &SoundConfig, + path: &str, +) -> bool { + let Some(mixer_ref) = out.mixer.as_mut() else { + return false; + }; + // The chain, then the addon folder — an addon that ships its own track names it by a virtual + // `Interface\AddOns\…` path no MPQ carries ([`benilla_assets::read_chain_or_loose`]). + let Some(bytes) = assets.read_file_or_loose(path) else { + // A file that does not resolve is the client's own silent nothing: the binding has already + // returned, so there is nobody left to raise at, and the name went to the file layer + // verbatim (no extension appended, no normalisation — `0x458720` does neither). + debug!("Lua music: {path} — not in the patch chain or the AddOns folder"); + return false; + }; + let data = match mixer::stream_from_bytes(bytes) { + Ok(d) => d, + Err(e) => { + debug!("Lua music: {path} — {e:#}"); + return false; + } + }; + let start_amp = config.category_amp(SoundCategory::Music) * LUA_MUSIC_VOLUME; + match mixer_ref.play_stream(data.volume(mixer::amp_to_db(start_amp))) { + Ok(mut h) => { + info!("Lua music: {path}"); + if config.music_suppressed { + h.pause(mixer::fade(0)); + info!("Lua music: held — a cinematic has music disabled"); + } + // One stream per slot. The verb has already faded the outgoing one, so this only + // catches the loop's own reopen — and a dropped kira handle keeps playing, so it can + // never just be overwritten. + if let Some(mut outgoing) = zone.lua_music.replace(h) { + outgoing.stop(mixer::declick()); + } + zone.lua_music_path = Some(path.to_owned()); + zone.lua_music_watch.reset(); + true + } + Err(e) => { + warn!("Lua music: {path} — {e:#}"); + false + } + } +} + +/// Drop the Lua stream on a **teardown** — the world's audio dying, not the verb. A short declick +/// rather than the verb's 4.0 s musical fade (the same distinction +/// [`stop_world_soundscape`] draws for the zone slot), and no schedule rearm: nothing is coming +/// back. Idempotent. +fn drop_lua_music(zone: &mut ZoneAudio) { + if let Some(mut h) = zone.lua_music.take() { + h.stop(mixer::fade(250)); + } + zone.lua_music_path = None; + zone.lua_music_watch.reset(); +} + +/// Crossfade the looping ambience bed to a new kit (0 = stop), over `fade_ms`:/// Crossfade the looping ambience bed to a new kit (0 = stop), over `fade_ms`: the old bed fades /// out on the backend (drop the handle — the ramp finishes on kira's thread) while the new bed /// starts silent and fades in under the per-frame envelope. This mirrors the client's `0x460b00` /// (a 5.0 s crossfade — out `0x7a5a10(5.0f)`, in `0x7a5dc0(0)` → `0x7a57b0(5.0f)`). @@ -701,6 +936,83 @@ fn swap_ambience( } } +/// **The Lua music slot, start to finish** — the `PlayMusic`/`StopMusic` intents the VM queued +/// this frame ([`MusicRequest`]), then the slot's own upkeep: the infinite loop, the live slider, +/// the starvation watch. +/// +/// Unconditional, unlike the zone scheduler beside it, and that is the slot's nature rather than a +/// convenience: this stream belongs to the *caller*, not to the world's soundscape. It is a second +/// handle in the reference too (`[0xb06ccc]`, not `[0xb06cc4]`), it is not what a zone change, +/// an area's silence interval or the day/night phase acts on, and the one thing the reference is +/// recorded doing to it — a cinematic pausing and resuming it (`0x4603b0`'s `0x7a5ac0` early +/// return, wow-re `cinematic-audio-law.md` §1) — is precisely *not* what that edge does to the +/// zone track, which it stops and reschedules. So it rides its own system, on no run condition +/// but the VM existing. +fn lua_music( + script: Option>, + mut zone: NonSendMut, + mut out: NonSendMut, + assets: Option>, + config: Res, + // The zone scheduler's own clock — the verb writes `next_track_at`, so it must be the same + // clock [`zone_audio`] reads it against. + time: Res