The MasterSoundEffects cut has to ride the play queue, not an observer a frame later
grom, 2026-11-05: *"softspoken was repeating zone readouts over and over unless I manually
removed them from queue."*
1.12 has no verb that stops a playing sound, so every voice-over addon uses the one
primitive there is: `SetCVar("MasterSoundEffects", 0)` then `..., 1)`, whose ENABLING edge
walks the live instance list and stops each node (`0x457500` -> `0x7a6570` -> `0x7a67a0`).
SoftSpoken's `SoundUtils:StopSound` is exactly those two lines, and its `SoundQueue` spends
them twice over: to duck the game's NPC dialogue while it speaks, and — this report — to
stop its OWN clip before replaying one, which `StopKeeping` does and comments on ("it
replays from the start when Advance next reaches it, since the client has no seek").
`bb9eb084` had to exempt `PlaySoundFile` audio from the cut because stopping it silenced
SoftSpoken outright: the addon spends the toggle and THEN plays its own file, so a cut that
reached files took the very line the toggle made room for. Its doc said what that cost —
*"if the overlapping-replay symptom returns, this is why"*. It returned.
The ordering was the thing, not the exemption. `cut_effects_on_reenable` is already
`.after(UiInput).before(drain_ui_sounds)`, which looks like the right gap and is not: the
flag is armed by the `CvarChanged` observer, `CvarChanged` goes out through
`commands.trigger`, and so the observer fires at a command flush a frame AFTER the Lua pass
that wrote it — by which time the drain has started the new clip. The consumer's ordering
cannot fix a producer that is a frame late.
So the cut now rides the sound queue, in call order with the plays:
* `SoundRequest::CutEffects`, pushed from `script::cvars::store` — the one write path
every `SetCVar`, `ConsoleExec` and engine write goes through, and the only place that
holds both the old value and the new, which an edge needs.
* `drain_ui_sounds` walks the queue in order and spends it where the addon wrote it:
stop, then play. Its catalog guard moved inside the loop, because the cut needs neither
a catalog nor assets and a stop dropped for want of one leaves the clip it meant to
stop playing under the next one.
* `cut_effects` is now shared by both paths with a `files` flag. In order with the
addon's calls it stops files, because the stop was written before the play; a frame
late, from the observer, it still spares them, because by then the only file alive is
the one the toggle made room for. Both answers were paid for by a report, and
`stopped_by_reenable`'s doc says which is which.
Verified: `the_master_sound_toggle_queues_its_cut_ahead_of_the_file_that_follows_it` writes
SoftSpoken's own two calls and asserts the queue is `[CutEffects, File(...)]` — the stop
keeping its place in front of the play — with no cut for the disabling edge and none for a
re-assert. `only_the_in_order_cut_reaches_an_addons_own_file` pins both answers.
benilla-ui 2128/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -1009,21 +1009,59 @@ pub(super) fn arms_effect_cut(was: bool, now: bool) -> bool {
|
||||
/// of the toggle is to cut a bark, not to restart the score.
|
||||
///
|
||||
/// `kit == 0`, the `PlaySoundFile` path ([`play_file`], whose doc records that id as its own
|
||||
/// marker), is **measured, not derived**, and the distinction is worth keeping honest. Stopping
|
||||
/// those too is what the first revision of this did, and it silenced SoftSpoken completely:
|
||||
/// grom's session carries ten `MasterSoundEffects re-enabled` lines and he heard none of the ten
|
||||
/// voice lines that produced them. It has to be excluded, because **every 1.12 voice-over addon
|
||||
/// toggles this CVar and then plays its own file** — a cut that reaches file playback cuts the
|
||||
/// very thing the toggle was spent to make room for.
|
||||
/// marker), depends on **where the cut comes from**, and that is the whole subtlety of this CVar.
|
||||
///
|
||||
/// A cut that arrives in order with the addon's own calls — [`cut_effects_in_order`], driven by
|
||||
/// `SoundRequest::CutEffects` — stops files too, because the addon wrote the stop *before* the
|
||||
/// play and the queue preserves that: `SoundQueue.StopKeeping` spends the toggle to stop its own
|
||||
/// clip before replaying one, and that is a file.
|
||||
///
|
||||
/// A cut that arrives from the deferred observer ([`cut_effects_on_reenable`]) spares them. That
|
||||
/// path is a frame behind the Lua pass that armed it, so by the time it runs the drain has
|
||||
/// already started the clip the toggle was spent to make room for; stopping files there is what
|
||||
/// the first revision of this did, and it silenced SoftSpoken completely — grom's session carried
|
||||
/// ten `MasterSoundEffects re-enabled` lines and he heard none of the ten voice lines that
|
||||
/// produced them.
|
||||
///
|
||||
/// Which of the reference's two flag classes a streamed file falls in is not settled here;
|
||||
/// `0x4000008`'s meaning is unreversed, so this is a player's measurement standing in for a fact
|
||||
/// we do not have. What it costs is the other half of SoftSpoken's use of the toggle:
|
||||
/// `SoundQueue.StopKeeping` spends it to stop its *own* clip before replaying one, and that does
|
||||
/// nothing again. If the overlapping-replay symptom returns, this is why — and the per-kit line
|
||||
/// the stop now logs is what will say so.
|
||||
fn stopped_by_reenable(kit: u32, category: SoundCategory) -> bool {
|
||||
matches!(category, SoundCategory::Sfx) && kit != 0
|
||||
/// `0x4000008`'s meaning is unreversed. What is settled is the ordering, and the ordering is what
|
||||
/// decides whether stopping a file is a stop or a self-inflicted silence.
|
||||
fn stopped_by_reenable(kit: u32, category: SoundCategory, files: bool) -> bool {
|
||||
matches!(category, SoundCategory::Sfx) && (files || kit != 0)
|
||||
}
|
||||
|
||||
/// Stop every playing effect and say what went, shared by the two cut paths.
|
||||
fn cut_effects(out: &mut SoundOutput, files: bool, how: &str) {
|
||||
let before = out.channels.len();
|
||||
// Named, not merely counted: which kits a toggle cuts is the only evidence separating "the
|
||||
// bark died, as intended" from "we cut the line the addon was about to speak".
|
||||
let mut stopped: Vec<u32> = Vec::new();
|
||||
let mut spared_files = 0usize;
|
||||
out.channels.retain_mut(|c| {
|
||||
if !stopped_by_reenable(c.kit, c.category, files) {
|
||||
spared_files += usize::from(c.kit == 0 && c.category == SoundCategory::Sfx);
|
||||
return true;
|
||||
}
|
||||
stopped.push(c.kit);
|
||||
c.handle.stop(mixer::declick());
|
||||
false
|
||||
});
|
||||
if !stopped.is_empty() || spared_files > 0 {
|
||||
info!(
|
||||
"sound: MasterSoundEffects re-enabled ({how}) — stopped {} of {before} channel(s), \
|
||||
kits {:?}; spared {spared_files} PlaySoundFile channel(s) (0x7a67a0)",
|
||||
stopped.len(),
|
||||
stopped
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The in-order cut: `SoundRequest::CutEffects`, drained by [`super::ui::drain_ui_sounds`] from
|
||||
/// the same queue as the plays, so a toggle the addon wrote before a `PlaySoundFile` is spent
|
||||
/// before that file starts — which is where the reference's own stop lands (`0x457500`, inside
|
||||
/// the `SetCVar`). This is the path that lets a voice addon stop its own clip.
|
||||
pub(super) fn cut_effects_in_order(out: &mut SoundOutput) {
|
||||
cut_effects(out, true, "in order with the addon's own calls");
|
||||
}
|
||||
|
||||
/// Spend an armed `MasterSoundEffects` enabling edge: stop every playing effect, as the
|
||||
@@ -1060,28 +1098,7 @@ pub(super) fn cut_effects_on_reenable(
|
||||
if !std::mem::take(&mut armed.0) {
|
||||
return;
|
||||
}
|
||||
let before = out.channels.len();
|
||||
// Named, not merely counted: which kits a toggle cuts is the only evidence separating "the
|
||||
// bark died, as intended" from "we cut the line the addon was about to speak".
|
||||
let mut stopped: Vec<u32> = Vec::new();
|
||||
let mut spared_files = 0usize;
|
||||
out.channels.retain_mut(|c| {
|
||||
if !stopped_by_reenable(c.kit, c.category) {
|
||||
spared_files += usize::from(c.kit == 0 && c.category == SoundCategory::Sfx);
|
||||
return true;
|
||||
}
|
||||
stopped.push(c.kit);
|
||||
c.handle.stop(mixer::declick());
|
||||
false
|
||||
});
|
||||
if !stopped.is_empty() || spared_files > 0 {
|
||||
info!(
|
||||
"sound: MasterSoundEffects re-enabled — stopped {} of {before} channel(s), kits {:?}; \
|
||||
spared {spared_files} PlaySoundFile channel(s) (0x7a67a0)",
|
||||
stopped.len(),
|
||||
stopped
|
||||
);
|
||||
}
|
||||
cut_effects(&mut out, false, "deferred, from the CVar observer");
|
||||
}
|
||||
|
||||
/// Registration hook for [`super::SoundPlugin`].
|
||||
@@ -1157,24 +1174,46 @@ mod tests {
|
||||
assert!(enabled, "and leave sound effects on");
|
||||
}
|
||||
|
||||
/// The toggle cuts barks, leaves the score alone, and — the one grom's silence paid for —
|
||||
/// never touches an addon's own `PlaySoundFile` audio.
|
||||
/// The toggle cuts barks and leaves the score alone, on either path.
|
||||
#[test]
|
||||
fn the_reenabling_edge_spares_music_ambience_and_addon_files() {
|
||||
fn the_reenabling_edge_spares_music_and_ambience() {
|
||||
for files in [false, true] {
|
||||
assert!(
|
||||
stopped_by_reenable(1, SoundCategory::Sfx, files),
|
||||
"a kit-played effect is the bark the toggle exists to cut"
|
||||
);
|
||||
assert!(
|
||||
!stopped_by_reenable(1, SoundCategory::Music, files),
|
||||
"music is the reference's mute-not-stop class (`flags & 0x4000008`, `0x7a65a0`)"
|
||||
);
|
||||
assert!(!stopped_by_reenable(1, SoundCategory::Ambience, files));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the cut reaches an addon's own `PlaySoundFile` audio is decided by **when** the
|
||||
/// cut arrives, and both answers were paid for by a report.
|
||||
///
|
||||
/// In order with the addon's calls it must: `SoundQueue.StopKeeping` spends the toggle to
|
||||
/// stop its own clip before replaying it, and the clip is a file. grom, 2026-11-05:
|
||||
/// *"softspoken was repeating zone readouts over and over unless I manually removed them
|
||||
/// from queue."* That is the silenced stop — the old line never stopped, so each replay
|
||||
/// layered over it.
|
||||
///
|
||||
/// A frame late, from the deferred observer, it must not: by then the drain has already
|
||||
/// started the clip the toggle made room for. That cut silenced SoftSpoken outright — ten
|
||||
/// `MasterSoundEffects re-enabled` lines in grom's 2026-10-30 session and none of the ten
|
||||
/// voice lines heard.
|
||||
#[test]
|
||||
fn only_the_in_order_cut_reaches_an_addons_own_file() {
|
||||
assert!(
|
||||
stopped_by_reenable(1, SoundCategory::Sfx),
|
||||
"a kit-played effect is the bark the toggle exists to cut"
|
||||
stopped_by_reenable(0, SoundCategory::Sfx, true),
|
||||
"in order: the stop the addon wrote BEFORE its play, so the file it stops is the \
|
||||
old clip and not the new one"
|
||||
);
|
||||
assert!(
|
||||
!stopped_by_reenable(1, SoundCategory::Music),
|
||||
"music is the reference's mute-not-stop class (`flags & 0x4000008`, `0x7a65a0`)"
|
||||
);
|
||||
assert!(!stopped_by_reenable(1, SoundCategory::Ambience));
|
||||
assert!(
|
||||
!stopped_by_reenable(0, SoundCategory::Sfx),
|
||||
"kit 0 is `play_file`'s marker, so this is an addon's own `PlaySoundFile`. Cutting \
|
||||
it silenced SoftSpoken outright: every 1.12 voice-over addon toggles this CVar and \
|
||||
then plays its own file, so the cut would take the line the toggle made room for"
|
||||
!stopped_by_reenable(0, SoundCategory::Sfx, false),
|
||||
"a frame late: kit 0 is `play_file`'s marker, and the only file alive by then is \
|
||||
the one the toggle was spent to make room for"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,11 @@ use super::{SoundConfig, SoundOutput};
|
||||
|
||||
/// Drain the VM's queued `PlaySound` intents into the kit player; drained even with no catalog,
|
||||
/// so the queue cannot grow unbounded.
|
||||
///
|
||||
/// The queue is walked **in call order** and the catalog is checked per play rather than once up
|
||||
/// front, because one of the requests is not a play: `SoundRequest::CutEffects` is an addon's
|
||||
/// stop, it needs no catalog, and a stop dropped for want of one leaves the clip it meant to
|
||||
/// stop playing underneath the next one.
|
||||
pub(super) fn drain_ui_sounds(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
kits: Option<ResMut<SoundKits>>,
|
||||
@@ -39,22 +44,25 @@ pub(super) fn drain_ui_sounds(
|
||||
if requests.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (Some(mut kits), Some(assets)) = (kits, assets) else {
|
||||
debug!(
|
||||
"sound(ui): {} play(s) dropped — no kit catalog",
|
||||
requests.len()
|
||||
);
|
||||
return;
|
||||
};
|
||||
let mut catalog = kits.zip(assets);
|
||||
let mut dropped = 0usize;
|
||||
for req in requests {
|
||||
let kit_ref = match &req {
|
||||
SoundRequest::CutEffects => {
|
||||
kit::cut_effects_in_order(&mut out);
|
||||
continue;
|
||||
}
|
||||
SoundRequest::KitId(id) => KitRef::Id(*id),
|
||||
SoundRequest::KitName(name) => KitRef::Name(name),
|
||||
SoundRequest::File(path) => {
|
||||
let Some((kits, assets)) = catalog.as_mut() else {
|
||||
dropped += 1;
|
||||
continue;
|
||||
};
|
||||
// `PlaySoundFile`: by path, with no kit gates or variation.
|
||||
if let Err(e) = kit::play_file(
|
||||
&mut kits,
|
||||
&assets,
|
||||
kits,
|
||||
assets,
|
||||
&mut out,
|
||||
&config,
|
||||
path,
|
||||
@@ -65,10 +73,14 @@ pub(super) fn drain_ui_sounds(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some((kits, assets)) = catalog.as_mut() else {
|
||||
dropped += 1;
|
||||
continue;
|
||||
};
|
||||
// 2D, on the SFX slider (the client's SoundVolume bucket).
|
||||
if let Err(e) = kit::play_kit(
|
||||
&mut kits,
|
||||
&assets,
|
||||
kits,
|
||||
assets,
|
||||
&mut out,
|
||||
&config,
|
||||
Vec3::ZERO,
|
||||
@@ -79,6 +91,9 @@ pub(super) fn drain_ui_sounds(
|
||||
debug!("sound(ui): {req:?} — {e:#}");
|
||||
}
|
||||
}
|
||||
if dropped > 0 {
|
||||
debug!("sound(ui): {dropped} play(s) dropped — no kit catalog");
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ItemGroupSounds.dbc` catalog: the pickup, put-down and use kits per item sound group.
|
||||
|
||||
@@ -275,8 +275,32 @@ pub(super) fn set_from_engine(model: &mut Model, name: &str, value: String) {
|
||||
/// The one store every write goes through; returns the registered spelling when the value moved
|
||||
/// and the change was queued. A latched slot stages the write, as the reference's `Set 0x63df50`
|
||||
/// stores `latchedValue` and skips `InternalSet`; writing back the applied value unstages it.
|
||||
/// The three registered spellings of the master sound enable, which is the one CVar an addon
|
||||
/// writes for its *effect* rather than for its value: 1.12's `MasterSoundEffects` and the
|
||||
/// later-era `Sound_EnableAllSound`/`Sound_EnableSFX` that the ported addons ask for. Each is its
|
||||
/// own registered row, so the edge is per row, which is what an addon toggling one of them means.
|
||||
fn is_master_sound_enable(key: &str) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
"mastersoundeffects" | "sound_enableallsound" | "sound_enablesfx"
|
||||
)
|
||||
}
|
||||
|
||||
/// Does this write turn the master sound enable back **on**? The reference's flag store is
|
||||
/// edge-triggered and only this edge reaches the stop (`0x7a67eb`); the disabling edge stops
|
||||
/// nothing. A value that does not parse reads as 0, as `atoi` does.
|
||||
fn is_sound_reenable(key: &str, was: &str, now: &str) -> bool {
|
||||
let num = |s: &str| s.trim().parse::<f64>().unwrap_or(0.0);
|
||||
is_master_sound_enable(key) && num(was) == 0.0 && num(now) != 0.0
|
||||
}
|
||||
|
||||
fn store(model: &mut Model, key: &str, value: String) -> Option<String> {
|
||||
let slot = model.cvars.get_mut(key)?;
|
||||
// Decided here, where both the old and the new value are in hand, and queued below onto the
|
||||
// *sound* queue rather than left to the `CvarChanged` observer — see
|
||||
// [`crate::script::SoundRequest::CutEffects`] for why the observer is a frame too late to be
|
||||
// the one that cuts.
|
||||
let mut reenabled = false;
|
||||
if slot.latched {
|
||||
let staged = (value != slot.value).then_some(value.clone());
|
||||
if slot.pending == staged && staged.is_some() {
|
||||
@@ -290,10 +314,16 @@ fn store(model: &mut Model, key: &str, value: String) -> Option<String> {
|
||||
if slot.value == value {
|
||||
return None;
|
||||
}
|
||||
reenabled = is_sound_reenable(key, &slot.value, &value);
|
||||
slot.value = value.clone();
|
||||
}
|
||||
let registered = slot.name.clone();
|
||||
model.cvar_changes.push((registered.clone(), value));
|
||||
if reenabled {
|
||||
model
|
||||
.sound_queue
|
||||
.push(super::sound::SoundRequest::CutEffects);
|
||||
}
|
||||
Some(registered)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,18 @@ pub enum SoundRequest {
|
||||
KitName(String),
|
||||
/// `PlaySoundFile("path")`: a file path, no kit.
|
||||
File(String),
|
||||
/// The enabling edge of `MasterSoundEffects` (and its two later-era spellings): stop every
|
||||
/// playing effect, the reference's `0x457500` -> `0x7a6570` -> `0x7a67a0`.
|
||||
///
|
||||
/// **Why it is a queued request and not just the `CvarChanged` observer's job.** The pair
|
||||
/// `SetCVar("MasterSoundEffects", 0)` then `..., 1)` is the only way a 1.12 addon can stop a
|
||||
/// sound that is already playing, and every voice-over addon spends it and then calls
|
||||
/// `PlaySoundFile` for the line it is about to read. The reference stops *inside* the second
|
||||
/// write, so the cut lands between the two calls. The observer cannot: it fires at a command
|
||||
/// flush after the Lua pass, by which time the drain has already started the new clip, and a
|
||||
/// cut there kills the very thing the toggle was spent to make room for. Riding this queue
|
||||
/// puts the cut and the play in one stream, in the order the addon wrote them.
|
||||
CutEffects,
|
||||
}
|
||||
|
||||
/// One queued `PlayMusic` or `StopMusic`, for the app's Lua music slot.
|
||||
@@ -160,6 +172,50 @@ mod tests {
|
||||
assert!(s.take_sounds().is_empty());
|
||||
}
|
||||
|
||||
/// `SetCVar("MasterSoundEffects", 0)` then `..., 1)` is the only way a 1.12 addon can stop a
|
||||
/// sound already playing, and it rides this queue so that it keeps its place **in front of
|
||||
/// the `PlaySoundFile` the addon calls next**. This is SoftSpoken's `SoundUtils:StopSound`
|
||||
/// followed by its `PlaySound`, written out: the stop must be request 1 and the play request
|
||||
/// 2, because the app spends them in that order and a stop spent afterwards kills the new
|
||||
/// clip instead of the old one.
|
||||
#[test]
|
||||
fn the_master_sound_toggle_queues_its_cut_ahead_of_the_file_that_follows_it() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
// The row as the host registers it (`0x45737a`): on by default, so an addon's first
|
||||
// write is the disabling one.
|
||||
s.register_cvars([("MasterSoundEffects", "1")]);
|
||||
s.run(
|
||||
"SetCVar(\"MasterSoundEffects\", 0)\n\
|
||||
SetCVar(\"MasterSoundEffects\", 1)\n\
|
||||
PlaySoundFile(\"Interface\\\\AddOns\\\\SpokenZonesAudio\\\\Sounds\\\\dm.mp3\")",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.take_sounds(),
|
||||
vec![
|
||||
SoundRequest::CutEffects,
|
||||
SoundRequest::File("Interface\\AddOns\\SpokenZonesAudio\\Sounds\\dm.mp3".into()),
|
||||
],
|
||||
"the stop keeps its place ahead of the play"
|
||||
);
|
||||
|
||||
// Only the enabling edge, and only on a move: the disabling write stops nothing in the
|
||||
// reference (`0x7a67eb`), and a re-assert of the value it already holds is not an edge.
|
||||
s.run("SetCVar(\"MasterSoundEffects\", 0)").unwrap();
|
||||
s.run("SetCVar(\"MasterSoundEffects\", 0)").unwrap();
|
||||
assert!(
|
||||
s.take_sounds().is_empty(),
|
||||
"switching sound off cuts nothing"
|
||||
);
|
||||
s.run("SetCVar(\"MasterSoundEffects\", 1)").unwrap();
|
||||
s.run("SetCVar(\"MasterSoundEffects\", 1)").unwrap();
|
||||
assert_eq!(
|
||||
s.take_sounds(),
|
||||
vec![SoundRequest::CutEffects],
|
||||
"one cut for the edge, none for the re-assert"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_music_pair_queues_in_call_order_and_answers_nothing() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user