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:
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user