PlayMusic and StopMusic are in, and an addon's own audio plays

- 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 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-19 17:11:24 +07:00
co-authored by Claude Fable 5.1
parent 84cd2336ab
commit 9489d8c9ad
7 changed files with 657 additions and 24 deletions
+4 -3
View File
@@ -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());
+438 -6
View File
@@ -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<StreamingSoundHandle<kira::sound::FromFileError>>,
/// 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<String>,
/// 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<f64>,
@@ -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<NonSendMut<benilla_ui::script::UiScript>>,
mut zone: NonSendMut<ZoneAudio>,
mut out: NonSendMut<SoundOutput>,
assets: Option<Res<WorldAssets>>,
config: Res<SoundConfig>,
// 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<Time>,
// WALL time, not the paced virtual clock — the watch compares elapsed time against the
// stream's own position (the reason spelled out on [`zone_audio`]'s own `real` parameter).
real: Res<Time<bevy::time::Real>>,
) {
let zone = &mut *zone;
let now = time.elapsed_secs_f64();
let requests = script.map(|mut s| s.take_music()).unwrap_or_default();
for req in requests {
match req {
// The NULL arm. It always lands: it needs no file, no device and no catalog — and it
// is *not* a no-op even from cold, because the pump's clock moves either way.
benilla_ui::script::MusicRequest::Stop => {
set_lua_music(zone, &mut out, None, &config, None, now)
}
benilla_ui::script::MusicRequest::Play(path) => {
// Under the loading cover a start is DROPPED, not deferred — the same posture as
// a server push ([`server_sounds`]) and as the kit player's own gate: the
// reference's blocking load plays nothing, and a track queued behind the cover
// would arrive in the destination zone instead of the one that asked for it.
let Some(assets) = assets.as_deref().filter(|_| !config.world_hold) else {
debug!("Lua music: {path} dropped — no assets, or the loading cover is up");
continue;
};
set_lua_music(zone, &mut out, Some(assets), &config, Some(&path), now);
}
}
}
// **The loop.** The reference opens this slot with `SetLoopCount(-1)`, so the natural end is a
// restart from the top rather than a stop; kira cannot loop a decode-stream, so the restart is
// explicit (the field's own docs, and the same shape [`super::glue`] gives the theme).
if zone
.lua_music
.as_ref()
.is_some_and(|h| h.state() == kira::sound::PlaybackState::Stopped)
{
zone.lua_music = None;
if let (Some(path), Some(assets)) = (zone.lua_music_path.clone(), assets.as_deref()) {
open_lua_music(zone, &mut out, assets, &config, &path);
}
}
// The Music slider is live on this stream, as it is on every other music stream in the client
// (`0x7a6660(ecx=2)`, the MusicVolume re-apply walker) — and `glide`, not a snap, for the same
// reason the zone track's feed glides (decision 1026).
if let Some(h) = zone.lua_music.as_mut() {
h.set_volume(
mixer::amp_to_db(config.category_amp(SoundCategory::Music) * LUA_MUSIC_VOLUME),
mixer::glide(),
);
}
if let Some(h) = zone.lua_music.as_ref() {
zone.lua_music_watch.feed(h, f64::from(real.delta_secs()));
}
}
/// Server-pushed sounds (`SMSG_PLAY_SOUND`/`PLAY_MUSIC`/`PLAY_OBJECT_SOUND`, bridged by
/// `net::apply_net_updates`): 2D kits and object-positioned 3D kits go through the kit player;
/// music takes the zone music slot (interrupting the current track — the zone scheduler resumes
@@ -796,6 +1108,10 @@ fn server_sounds(
/// statics, not per-session state (the intro throttle survives a relog).
fn leave_world(mut zone: NonSendMut<ZoneAudio>) {
stop_world_soundscape(&mut zone, "left world");
// The Lua slot too — and only here, not under the loading cover: an addon's track is the
// caller's, not the world's (see [`lua_music`]), so a teleport's cover leaves it playing,
// while a logout takes the VM that owns it with it.
drop_lua_music(&mut zone);
}
/// Stop the beds and reset the scheduler to cold — shared by the two edges where the world's
@@ -847,6 +1163,7 @@ fn stop_world_soundscape(zone: &mut ZoneAudio, reason: &str) {
fn report_stream_voices(zone: NonSend<ZoneAudio>, mut out: NonSendMut<super::SoundOutput>) {
let live = |s: kira::sound::PlaybackState| s != kira::sound::PlaybackState::Stopped;
out.zone_streams = usize::from(zone.music.as_ref().is_some_and(|h| live(h.state())))
+ usize::from(zone.lua_music.as_ref().is_some_and(|h| live(h.state())))
+ usize::from(zone.ambience.as_ref().is_some_and(|h| live(h.state())));
}
@@ -860,6 +1177,23 @@ pub(super) fn plugin(app: &mut App) {
.run_if(super::world_audio_live)
.in_set(WorldStage::Present),
)
// Before them, and unconditional — the reasons are on [`lua_music`]. Ordered first so the
// slot this frame's `PlayMusic` takes is already taken when the zone scheduler runs, and
// **after the tick** because that is the side of it this system is on: it drains what the
// VM produced this frame, exactly as [`super::ui`]'s `PlaySound` drain does (2304's rule
// for a VM holder — `.after(UiInput)` for a drain, `UiFeed` for a push).
.add_systems(
Update,
lua_music
.before(server_sounds)
.after(crate::ui_script::UiInput)
// And after the dev mute chord, for the reason `apply_master_volume` already
// declares that same order: this system's per-frame gain feed reads
// `SoundConfig`, so reading it after the flip is what makes the chord audible on
// the frame it is pressed instead of the next one.
.after(super::toggle_mute)
.in_set(WorldStage::Present),
)
// Unconditional: the budget must fall back to zero when the soundscape stops, and a
// system gated on `world_audio_live` would freeze the last count instead.
.add_systems(Update, report_stream_voices)
@@ -871,7 +1205,10 @@ pub(super) fn plugin(app: &mut App) {
#[cfg(test)]
mod tests {
use super::{apply_music_suppression, slot_holds, ZoneAudio, CINEMATIC_MUSIC_RESUME_SECS};
use super::{
apply_music_suppression, slot_holds, take_lua_music_slot, ZoneAudio,
CINEMATIC_MUSIC_RESUME_SECS, LUA_MUSIC_SCHEDULE_SECS,
};
use kira::sound::PlaybackState;
/// **The cinematic's music stop, both edges** — wow-re `cinematic-audio-law.md`, VERIFIED.
@@ -908,6 +1245,46 @@ mod tests {
assert_eq!(zone.zone_music, 42);
}
/// **A cinematic BORROWS a Lua `PlayMusic` track** — wow-re `cinematic-audio-law.md` §1,
/// VERIFIED, and the one thing the reference is recorded doing to this slot. `0x4603b0` writes
/// the disabled flag, sees the slot live at `0x4603d6`, pauses the stream and returns: the
/// pump still dies (that flag is written *before* the early return) and nothing else moves —
/// no stop, no clear, and `[0x836400]` never rearmed on the way back out. The zone track's own
/// edges (a cut, then +3.000 s) are the test above; these are those same two edges with a
/// caller's track on the slot, and they must do neither of those things.
#[test]
fn a_cinematic_borrows_a_lua_music_track_and_leaves_the_schedule_alone() {
let mut zone = ZoneAudio {
zone_music: 42,
next_track_at: Some(100.0),
lua_music_path: Some("Sound\\Music\\x.mp3".into()),
..ZoneAudio::default()
};
apply_music_suppression(&mut zone, true, 10.0);
assert!(
zone.music_suppressed,
"the flag is written before the early return, so the pump dies either way"
);
assert_eq!(
zone.next_track_at,
Some(100.0),
"the zone schedule is neither cleared nor rearmed"
);
apply_music_suppression(&mut zone, false, 112.5);
assert!(!zone.music_suppressed);
assert_eq!(
zone.next_track_at,
Some(100.0),
"the +3.000 s resume belongs to the other edge — `[0x836400]` is never rearmed here"
);
assert!(
zone.lua_slot_live(),
"and the track is still the caller's, across both edges"
);
}
/// The resume is its own number and must not be confused with the two neighbours it sits
/// between — a zone change's immediate start, and the natural-end silence interval.
#[test]
@@ -915,6 +1292,61 @@ mod tests {
assert!((CINEMATIC_MUSIC_RESUME_SECS - 3.0).abs() < f64::EPSILON);
}
/// **`StopMusic()` is `PlayMusic(NULL)`, and this is the whole of it** — wow-re
/// `sound/scratch/lua-music-bindings.md`, the round dispatched for B391. `0x458770` is four
/// instructions, three of which are `0x460450(NULL)`, and that shared head does exactly
/// three things before the branch: fade this slot over 4.0 s (`0x460480`), clear it
/// (`0x460485`), and write the zone pump's next start (`0x46049b`).
///
/// Two claims worth a test each, because both are the kind that gets dropped:
///
/// * **The pump write is unconditional** — it sits between `0x460499`'s `test` and its `je`,
/// ahead of the NULL branch — so a stop from **cold** still moves the clock, and pushes out
/// a start that was already due.
/// * **The zone track is not what it stops.** `[0xb06cc4]` is mentioned only past that
/// branch. This is the reporter's own sentence from the outside — *"not used to stop normal
/// music played by being in a zone"* — and it is the half a "stop the music" verb would
/// most naturally get wrong.
#[test]
fn the_lua_music_verb_rearms_the_zone_pump_at_six_seconds_even_from_cold() {
// Its own number, and neither of the two it sits between: not the cinematic's 3.000 s,
// and not the randomized `SilenceInterval` the natural-end reap draws. The second is a
// `const` assertion because it compares two constants — it holds at compile time, which
// is the right time for "these are two numbers, not one".
assert!((LUA_MUSIC_SCHEDULE_SECS - 6.0).abs() < f64::EPSILON);
const { assert!(LUA_MUSIC_SCHEDULE_SECS != CINEMATIC_MUSIC_RESUME_SECS) };
// From cold: nothing on the slot, a zone track due this very tick.
let mut zone = ZoneAudio {
zone_music: 42,
next_track_at: Some(10.0),
..ZoneAudio::default()
};
take_lua_music_slot(&mut zone, 10.0);
assert_eq!(
zone.next_track_at,
Some(16.0),
"a stop from cold still moves the pump — and clobbers a start that was due now"
);
assert!(!zone.lua_slot_live());
// With a track on the slot: the slot is cleared, the zone's own state is not.
let mut zone = ZoneAudio {
zone_music: 42,
music_kit: 8440,
lua_music_path: Some("Sound\\Music\\x.mp3".into()),
..ZoneAudio::default()
};
take_lua_music_slot(&mut zone, 100.0);
assert!(!zone.lua_slot_live(), "the caller's track is over");
assert_eq!(zone.next_track_at, Some(106.0));
assert_eq!(
(zone.music_kit, zone.zone_music),
(8440, 42),
"zone music is not what this verb stops"
);
}
/// The repeat-push guard. The server keeps an event track looping by re-pushing its id every
/// few seconds (the Darkmoon Faire emitter: `SMSG_PLAY_MUSIC` 8440 every 5 s), so a push for
/// the track *currently playing* must do nothing — without this the faire stacked a new
+6 -11
View File
@@ -822,20 +822,15 @@ impl UiFontAtlas {
}
}
/// Read one font path from the two stores, chain first.
/// Read one font path from the two stores, chain first —
/// [`benilla_assets::read_chain_or_loose`], the one rule every by-path addon asset resolves by.
///
/// The loose leg goes through the same [`benilla_assets::loose_addon_file`] the sprite decoder
/// uses, so it inherits its rules whole: only `Interface\AddOns\` paths reach the folder, the
/// component walk is case-insensitive (MSBT names its own files `Interface\Addons\…` with a
/// lowercase `d`, and ships `mailrays.TTF` while its table says `mailrays.ttf`), and a
/// It inherits that rule whole rather than restating it: only `Interface\AddOns\` paths reach the
/// folder, the component walk is case-insensitive (MSBT names its own files `Interface\Addons\…`
/// with a lowercase `d`, and ships `mailrays.TTF` while its table says `mailrays.ttf`), and a
/// dot-component is refused before any filesystem call.
fn read_font_bytes(source: &FontSource, path: &str) -> Option<Vec<u8>> {
if let Ok(bytes) = source.chain.lock_recover().read(path) {
return Some(bytes);
}
let root = source.loose_root.as_deref()?;
let file = benilla_assets::loose_addon_file(root, &benilla_assets::normalize_path(path))?;
std::fs::read(file).ok()
benilla_assets::read_chain_or_loose(&source.chain, source.loose_root.as_deref(), path)
}
/// A real-font engine for a test: the client faces, read through the app's own patch chain. `None`
@@ -292,6 +292,30 @@ fn decode_sprite_bytes(bytes: &[u8]) -> anyhow::Result<(u32, u32, Vec<u8>)> {
/// it is not an `Interface\AddOns\` path or nothing is there.
///
/// **Not sprite-specific, and named for that** (decision 2103): 1322 built this for addon-shipped
/// Read one file from the **two stores the client's file layer has**: the patch chain, then — for
/// an `Interface\AddOns\` path — the loose addon folder ([`loose_addon_file`]).
///
/// The rule shared by every by-path asset an addon can ship: art (the sprite decoder), fonts (the
/// face loader) and **audio** (`PlayMusic`/`PlaySoundFile`) all reach a file the same way, and
/// three copies of that would be three things to keep in step — the font leg was already a
/// hand-rolled second copy of the sprite leg's order when audio became the third caller.
///
/// The reference asks the install tree *before* the archive (`0x647e60`'s attempt #4 is the MPQ —
/// wow-re `ui/scratch/include-lua-dispatch.md` §4.1); the order is flipped here for the same
/// reason [`decode_sprite`] flips it, and it is unobservable: the chain carries no `AddOns\` path,
/// so every non-addon read stays on exactly the code it always ran.
pub fn read_chain_or_loose(
chain: &Mutex<Chain>,
loose_root: Option<&Path>,
path: &str,
) -> Option<Vec<u8>> {
if let Ok(bytes) = chain.lock_recover().read(path) {
return Some(bytes);
}
let file = loose_addon_file(loose_root?, &normalize_path(path))?;
std::fs::read(file).ok()
}
/// BLP/TGA art, and an addon's own TTFs reach the client by exactly the same route — a loose file
/// under the one AddOns root, named by a virtual `Interface\AddOns\…` path no MPQ carries. One
/// resolver, one prefix rule, one sandbox.
@@ -409,6 +433,12 @@ impl WorldAssets {
/// [`Self::loose_root`] field doc. Called by the app once it knows the AddOns folder; evicts
/// cached **misses** so a path asked before the root existed gets a second look (a cached hit
/// can only have come from the chain and stays right).
/// [`read_chain_or_loose`] over this store's own two halves — the by-path read for an asset
/// class with no decoder of its own here (audio: `PlayMusic`, `PlaySoundFile`).
pub fn read_file_or_loose(&self, path: &str) -> Option<Vec<u8>> {
read_chain_or_loose(&self.chain, self.loose_root.as_deref(), path)
}
pub fn set_loose_addon_root(&mut self, root: Option<PathBuf>) {
if self.loose_root == root {
return;
@@ -829,4 +859,50 @@ mod tests {
std::fs::remove_dir_all(&root).ok();
}
/// **The by-path read every addon-shipped asset resolves by** — chain first, then the one
/// loose folder ([`read_chain_or_loose`]). Audio is that rule's third caller, after art and
/// fonts, and an addon's own track is exactly the file no MPQ can hold: this leg is the whole
/// reason `PlayMusic("Interface\\AddOns\\…")` can make a sound at all.
///
/// Against the **real** chain, because both legs matter — a client file must still come from
/// the archive, and only a real chain can show the loose folder is a *fallback* rather than a
/// shadow over it.
#[test]
fn read_chain_or_loose_asks_the_chain_then_an_addons_own_file_on_disk() {
let data = benilla_formats::wow_data_or_skip!();
let chain = Mutex::new(Chain::open(&data).expect("open the chain"));
let root =
std::env::temp_dir().join(format!("benilla-loose-audio-test-{}", std::process::id()));
let addons = root.join("AddOns");
std::fs::create_dir_all(addons.join("Jukebox")).unwrap();
std::fs::write(addons.join("Jukebox").join("Track.mp3"), b"ID3!").unwrap();
// The archive leg: a client track the chain does carry.
let theme = read_chain_or_loose(
&chain,
Some(&addons),
"Sound\\Music\\GlueScreenMusic\\wow_main_theme.mp3",
)
.expect("the glue theme lives in sound.MPQ");
assert!(theme.len() > 1_000_000, "the theme is a ~3 MB stored MP3");
// The loose leg: an addon's own track, named by the virtual path, spelt in its own case.
assert_eq!(
read_chain_or_loose(
&chain,
Some(&addons),
"Interface\\AddOns\\Jukebox\\Track.mp3"
)
.as_deref(),
Some(&b"ID3!"[..])
);
// In neither store: a plain miss, which every caller renders as silence.
assert!(read_chain_or_loose(&chain, Some(&addons), "Sound\\Music\\nope.mp3").is_none());
// No folder configured (a capture run — `local_state` is hermetic there): chain only.
assert!(
read_chain_or_loose(&chain, None, "Interface\\AddOns\\Jukebox\\Track.mp3").is_none()
);
std::fs::remove_dir_all(&root).ok();
}
}
+1 -1
View File
@@ -257,7 +257,7 @@ pub(crate) use simplehtml::{
};
pub use skills::{SkillEntry, SkillsState};
pub use social::{FriendInfo, SocialRequest, SocialState, WhoInfo};
pub use sound::SoundRequest;
pub use sound::{MusicRequest, SoundRequest};
pub use spellbook::{
resolve_spell_by_name, PetBookState, SpellBookState, SpellSlotView, SpellTabView,
};
+9 -2
View File
@@ -8,8 +8,8 @@ use super::{
container, craft, cursor, death, duel, follow, gossip, guild, inspect, item_text, loot,
loot_roll, macros, mail, merchant, party, petition, pvp, quest, quest_log, reputation, session,
simplehtml, skills, slider, social, spellbook, stable, taxi, trade, tradeskill, trainer,
weapon_enchant, ActionSlot, AuraState, FontObject, ItemTemplateView, PlayerReqState,
RegionData, ScriptValue, SoundRequest, UnitState,
weapon_enchant, ActionSlot, AuraState, FontObject, ItemTemplateView, MusicRequest,
PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState,
};
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -756,6 +756,12 @@ pub(crate) struct Model {
/// [`UiScript::take_sounds`] drain — the outbound Lua→app intent seam ([`sound`]).
pub(crate) sound_queue: Vec<SoundRequest>,
/// `PlayMusic`/`StopMusic` intents queued since the app's last
/// [`UiScript::take_music`] drain — the same seam, a different *slot*: these drive the music
/// stream the reference gives the Lua caller (`[0xb06ccc]`), not the kit player ([`sound`]).
/// Order-bearing, so a start and a stop in one frame settle the slot the way they were called.
pub(crate) music_queue: Vec<MusicRequest>,
/// **The UI-load sound-suppression depth** — `[0xb05fa0]` in the reference, a counted scope
/// with exactly three references image-wide: `0x458f50` (`inc`), `0x458f60` (`dec`), and one
/// reader at `0x458046`, inside `PlaySoundByName 0x458030`. The whole-UI load `0x48fbf0`
@@ -2019,6 +2025,7 @@ impl Model {
action_bar_toggles: None,
action_bar_toggle_sends: Vec::new(),
sound_queue: Vec::new(),
music_queue: Vec::new(),
sound_suppression: 0,
cvars: HashMap::new(),
cvars_saved_base: HashMap::new(),
+123 -1
View File
@@ -20,6 +20,26 @@
//! `PlaySoundFile("Sound\\...\\file.wav")` is the sibling by-path form (the 1.12 client resolves
//! it through the same file layer as kit entries — MPQ chain, loose files shadowing): no kit, so
//! no gates and no variation; same queue, same returns.
//!
//! `PlayMusic("Sound\\Music\\...\\track.mp3")` / `StopMusic()` are the **music** pair, and what
//! makes them their own queue ([`MusicRequest`]) rather than two more [`SoundRequest`]s is that
//! they are a different *slot*, not a different sound: the reference hands the Lua caller a music
//! stream of its own (`[0xb06ccc]`, opened inside `0x460450` at `0x4604a7`) beside the zone
//! track's (`[0xb06cc4]`), and it is one of the client's only two infinitely-looping streams
//! (`SetLoopCount(-1)`, the single call site `0x7a5592`; the other is the glue theme). The app
//! drains this queue into that slot, never into the kit player. Both bindings answer **nothing**
//! — `reference/1.12-shapes.tsv` types the pair at arity 0, `exact` — so neither returns the
//! `willPlay, soundHandle` pair `PlaySound` does.
//!
//! **They are one engine verb with one branch, which is why this queue carries an `Option`.**
//! `StopMusic 0x458770` is four instructions — `xor ecx,ecx; call 0x460450; xor eax,eax; ret` —
//! so *stopping is playing a NULL name*, and `0x460450` (two callers image-wide, no
//! address-takes) is the whole of both verbs. What that shared head does before the branch, and
//! what only the play arm does after it, is the app side's business (`sound::zone`'s
//! `set_lua_music`) — the point here is that the two bindings are not independent, and a
//! `MusicRequest` is the argument, not the verb (wow-re `sound/scratch/lua-music-bindings.md`,
//! the §5 round dispatched for this report; it corrected wow-re's own table, which had recorded
//! `StopMusic`'s callee as "—").
use mlua::{Lua, Value};
@@ -36,6 +56,16 @@ pub enum SoundRequest {
File(String),
}
/// One queued Lua **music** intent — the `PlayMusic`/`StopMusic` pair, drained by the app's music
/// slot (module docs: a different slot, not a different sound, and **one verb with a NULL arm**).
#[derive(Clone, Debug, PartialEq)]
pub enum MusicRequest {
/// `PlayMusic("path")` — the name arm: start the caller's own looping stream on the slot.
Play(String),
/// `StopMusic()` — `0x460450`'s NULL arm, the same call with no name.
Stop,
}
impl super::UiScript {
/// Drain the sounds queued by `PlaySound` since the last call. The app plays each through its
/// kit player (2D — UI sounds have no world position).
@@ -51,6 +81,14 @@ impl super::UiScript {
pub fn take_sounds(&mut self) -> Vec<SoundRequest> {
std::mem::take(&mut self.model_mut().sound_queue)
}
/// Drain the `PlayMusic`/`StopMusic` intents queued since the last call, **in call order** —
/// which this queue has to preserve in a way the kit one does not: two calls in a frame are a
/// start and a stop of the same slot, and the order is the difference between a track and
/// silence.
pub fn take_music(&mut self) -> Vec<MusicRequest> {
std::mem::take(&mut self.model_mut().music_queue)
}
}
/// Register the `PlaySound` and `PlaySoundFile` globals.
@@ -99,12 +137,44 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// willPlay (queued), soundHandle (nil — module docs).
Ok((true, Value::Nil))
})?,
)?;
lua.globals().set(
"PlayMusic",
lua.create_function(|lua, args: mlua::MultiValue| {
// `0x458720`'s own argument shape, byte for byte: `lua_isstring 0x6f3510` then
// `lua_tostring 0x6f3690` — so a **number is accepted and stringified**
// (`PlayMusic(42)` asks for the file `"42"`), and everything else, *absent included*,
// raises this exact literal (`0x835f7c`) through `lua_error 0x458758`. No
// normalisation, no extension appended: the pointer goes verbatim to the file layer.
let path = super::binding_abi::string_arg(
lua,
args.front().cloned().unwrap_or(Value::Nil),
"Usage: PlayMusic(\"music\")",
)?;
lua.app_data_mut::<Model>()
.expect("model app_data")
.music_queue
.push(MusicRequest::Play(path));
Ok(())
})?,
)?;
lua.globals().set(
"StopMusic",
// Takes no argument at all (`0x458770`, arity and argc both 0), so an extra one is
// ignored rather than refused — `()` reads a bare call and any call alike.
lua.create_function(|lua, ()| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.music_queue
.push(MusicRequest::Stop);
Ok(())
})?,
)
}
#[cfg(test)]
mod tests {
use super::SoundRequest;
use super::{MusicRequest, SoundRequest};
use crate::script::UiScript;
#[test]
@@ -133,6 +203,58 @@ mod tests {
assert!(s.take_sounds().is_empty());
}
#[test]
fn the_music_pair_queues_in_call_order_and_answers_nothing() {
let mut s = UiScript::new().unwrap();
// Arity 0 for both (`reference/1.12-shapes.tsv`, `exact`) — not `PlaySound`'s pair.
// Counted with `table.getn` over a table constructor: this VM is 1.12's Lua **5.0**
// surface, which has no `select`.
let (played, stopped): (usize, usize) = s
.eval(
r#"return table.getn({PlayMusic("Sound\\Music\\x.mp3")}),
table.getn({StopMusic()})"#,
)
.unwrap();
assert_eq!((played, stopped), (0, 0));
s.run(r#"PlayMusic("Sound\\Music\\ZoneMusic\\Elwynn\\DayElwynn01.mp3")"#)
.unwrap();
s.run("StopMusic()").unwrap();
assert_eq!(
s.take_music(),
vec![
MusicRequest::Play("Sound\\Music\\x.mp3".into()),
MusicRequest::Stop,
MusicRequest::Play("Sound\\Music\\ZoneMusic\\Elwynn\\DayElwynn01.mp3".into()),
MusicRequest::Stop,
]
);
// Drained: the queue is empty until the next call.
assert!(s.take_music().is_empty());
// And it is its own queue — the kit player never sees a music intent.
assert!(s.take_sounds().is_empty());
}
/// `PlayMusic`'s argument is `lua_isstring` → `lua_tostring` (`0x6f3510`/`0x6f3690`), so a
/// **number is a filename** and everything else — absent included — raises the binding's own
/// literal. `StopMusic` reads no argument at all, so an extra one cannot be a usage error.
#[test]
fn playmusic_takes_a_string_or_a_number_and_stopmusic_takes_anything() {
let mut s = UiScript::new().unwrap();
assert!(s.run("PlayMusic()").is_err());
assert!(s.run("PlayMusic(nil)").is_err());
assert!(s.run("PlayMusic(true)").is_err());
assert!(s.run("PlayMusic({})").is_err());
assert!(s.take_music().is_empty(), "a raise queues nothing");
// Coerced, not refused: the reference asks the file layer for `"42"` and gets silence.
s.run("PlayMusic(42)").unwrap();
s.run("StopMusic(1, 2)").unwrap();
assert_eq!(
s.take_music(),
vec![MusicRequest::Play("42".into()), MusicRequest::Stop]
);
}
#[test]
fn playsound_with_a_bad_argument_is_a_usage_error() {
let mut s = UiScript::new().unwrap();