unitscan's horn cut whatever SoftSpoken was reading, because the only stop 1.12 has stops everything
`SetCVar("MasterSoundEffects", 0)` then `..., 1)` is the only way a 1.12 addon
can stop a sound that is already playing, and the reference's stop is
indiscriminate: `0x7a67a0` walks the live instance list and stops every node in
it, whoever started them. That is fine while one addon at a time uses the toggle,
and this install has two that use it for opposite ends — SoftSpoken to stop its
own clip before replaying it and to duck the game's NPC bark, unitscan to make
sure of a channel for its alert (`unitscan.lua:55-59` is the pair, then
`PlaySoundFile`). It is not aimed at anybody.
grom: "unitscan sound will interupt softspoken reading something, this is really
annoying, I still want the unitscan sound but for softspoken to not be silenced."
Nothing in the toggle separates the two uses, so the caller does.
`PlaySoundFile` and the toggle each now carry the addon whose file made the call
— attribution by chunk name, since an addon's files load as
`@Interface\AddOns\<Name>\<file>`. A cut still stops every kit-played bark, which
is the game's own audio and what an addon ducking "the sound" means by it; among
`PlaySoundFile` audio it stops only the calling addon's own. Both of SoftSpoken's
uses survive intact: stopping its own clip is cutter == owner, and ducking NPC
dialogue is a kit.
A deviation from the reference, written down in `docs/ADDON_COMPAT.md` with the
two reports on either side of it, together with the Who-list font colours and the
73%-opaque tooltip art behind the unreadable map tooltip.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -80,6 +80,10 @@ impl SoundKits {
|
||||
/// One playing channel the pump owns, the client's channel struct.
|
||||
pub(crate) struct ActiveChannel {
|
||||
pub(crate) kit: u32,
|
||||
/// For a `PlaySoundFile` channel (`kit == 0`), the addon whose file called it; `None` for
|
||||
/// everything the client plays itself. Read only by [`stopped_by_reenable`], and only to
|
||||
/// keep one addon's `MasterSoundEffects` toggle from cutting another addon's voice line.
|
||||
owner: Option<String>,
|
||||
/// The entity this channel belongs to; [`Latch`] says whether it also holds a latch, which
|
||||
/// the pump's reap releases when the sound stops.
|
||||
source: Option<Entity>,
|
||||
@@ -544,6 +548,8 @@ pub(super) fn play_kit_ext(
|
||||
}
|
||||
out.channels.push(ActiveChannel {
|
||||
kit: id,
|
||||
// A kit play is the client's own; only `PlaySoundFile` has an addon behind it.
|
||||
owner: None,
|
||||
source,
|
||||
tracked: looping && source.is_some(),
|
||||
handle,
|
||||
@@ -683,6 +689,7 @@ pub(crate) fn play_file(
|
||||
config: &SoundConfig,
|
||||
path: &str,
|
||||
category: SoundCategory,
|
||||
owner: Option<String>,
|
||||
) -> Result<()> {
|
||||
// The loading-screen hold, as in [`play_kit_ext`].
|
||||
if config.world_hold {
|
||||
@@ -704,6 +711,7 @@ pub(crate) fn play_file(
|
||||
}
|
||||
out.channels.push(ActiveChannel {
|
||||
kit: 0,
|
||||
owner,
|
||||
source: None,
|
||||
tracked: false,
|
||||
handle,
|
||||
@@ -929,6 +937,7 @@ pub(super) fn apply_kit_debug(
|
||||
&config,
|
||||
&query,
|
||||
SoundCategory::Sfx,
|
||||
None,
|
||||
) {
|
||||
Ok(()) => info!("sound debug: file \"{query}\" played"),
|
||||
Err(e) => warn!("sound debug: file \"{query}\" — {e:#}"),
|
||||
@@ -1026,19 +1035,40 @@ pub(super) fn arms_effect_cut(was: bool, now: bool) -> bool {
|
||||
/// Which of the reference's two flag classes a streamed file falls in is not settled here;
|
||||
/// `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)
|
||||
///
|
||||
/// `cutter` and `owner` are the third exclusion, and the only one that is **not** the
|
||||
/// reference's. The reference stops every live node whoever started it; this spares a
|
||||
/// `PlaySoundFile` channel that a *different* addon started. See
|
||||
/// [`benilla_ui::script::SoundRequest::CutEffects`] for the pair of addons that made it
|
||||
/// necessary — one spends the toggle to stop its own clip, the other to free a channel, and with
|
||||
/// the reference's rule the second silences the first. A kit bark is still cut by anyone: that is
|
||||
/// the game's own audio, which is what an addon ducking "the sound" means by it.
|
||||
fn stopped_by_reenable(
|
||||
kit: u32,
|
||||
category: SoundCategory,
|
||||
files: bool,
|
||||
cutter: Option<&str>,
|
||||
owner: Option<&str>,
|
||||
) -> bool {
|
||||
if !matches!(category, SoundCategory::Sfx) {
|
||||
return false;
|
||||
}
|
||||
if kit != 0 {
|
||||
return true;
|
||||
}
|
||||
// A file, so only the in-order cut reaches it at all, and then only its own addon's.
|
||||
files && cutter.is_some() && cutter == owner
|
||||
}
|
||||
|
||||
/// Stop every playing effect and say what went, shared by the two cut paths.
|
||||
fn cut_effects(out: &mut SoundOutput, files: bool, how: &str) {
|
||||
fn cut_effects(out: &mut SoundOutput, files: bool, cutter: Option<&str>, 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) {
|
||||
if !stopped_by_reenable(c.kit, c.category, files, cutter, c.owner.as_deref()) {
|
||||
spared_files += usize::from(c.kit == 0 && c.category == SoundCategory::Sfx);
|
||||
return true;
|
||||
}
|
||||
@@ -1060,8 +1090,16 @@ fn cut_effects(out: &mut SoundOutput, files: bool, how: &str) {
|
||||
/// 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");
|
||||
pub(super) fn cut_effects_in_order(out: &mut SoundOutput, cutter: Option<&str>) {
|
||||
cut_effects(
|
||||
out,
|
||||
true,
|
||||
cutter,
|
||||
&format!(
|
||||
"in order with the addon's own calls, from {}",
|
||||
cutter.unwrap_or("outside any addon")
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Spend an armed `MasterSoundEffects` enabling edge: stop every playing effect, as the
|
||||
@@ -1098,7 +1136,7 @@ pub(super) fn cut_effects_on_reenable(
|
||||
if !std::mem::take(&mut armed.0) {
|
||||
return;
|
||||
}
|
||||
cut_effects(&mut out, false, "deferred, from the CVar observer");
|
||||
cut_effects(&mut out, false, None, "deferred, from the CVar observer");
|
||||
}
|
||||
|
||||
/// Registration hook for [`super::SoundPlugin`].
|
||||
@@ -1179,14 +1217,20 @@ mod tests {
|
||||
fn the_reenabling_edge_spares_music_and_ambience() {
|
||||
for files in [false, true] {
|
||||
assert!(
|
||||
stopped_by_reenable(1, SoundCategory::Sfx, files),
|
||||
stopped_by_reenable(1, SoundCategory::Sfx, files, Some("unitscan"), None),
|
||||
"a kit-played effect is the bark the toggle exists to cut"
|
||||
);
|
||||
assert!(
|
||||
!stopped_by_reenable(1, SoundCategory::Music, files),
|
||||
!stopped_by_reenable(1, SoundCategory::Music, files, Some("unitscan"), None),
|
||||
"music is the reference's mute-not-stop class (`flags & 0x4000008`, `0x7a65a0`)"
|
||||
);
|
||||
assert!(!stopped_by_reenable(1, SoundCategory::Ambience, files));
|
||||
assert!(!stopped_by_reenable(
|
||||
1,
|
||||
SoundCategory::Ambience,
|
||||
files,
|
||||
Some("unitscan"),
|
||||
None
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1206,17 +1250,82 @@ mod tests {
|
||||
#[test]
|
||||
fn only_the_in_order_cut_reaches_an_addons_own_file() {
|
||||
assert!(
|
||||
stopped_by_reenable(0, SoundCategory::Sfx, true),
|
||||
stopped_by_reenable(
|
||||
0,
|
||||
SoundCategory::Sfx,
|
||||
true,
|
||||
Some("SoftSpoken"),
|
||||
Some("SoftSpoken")
|
||||
),
|
||||
"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(0, SoundCategory::Sfx, false),
|
||||
!stopped_by_reenable(
|
||||
0,
|
||||
SoundCategory::Sfx,
|
||||
false,
|
||||
Some("SoftSpoken"),
|
||||
Some("SoftSpoken")
|
||||
),
|
||||
"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"
|
||||
);
|
||||
}
|
||||
|
||||
/// The deviation, and the report that bought it. unitscan's alert is
|
||||
/// `SetCVar('MasterSoundEffects', 0)` / `1` and then `PlaySoundFile` — the toggle is how it
|
||||
/// makes sure of a channel, not something it means at anybody. On the reference that stop is
|
||||
/// indiscriminate, so it also killed whatever SoftSpoken was reading. grom, 2026-11-08:
|
||||
/// *"I still want the unitscan sound but for softspoken to not be silenced."*
|
||||
///
|
||||
/// So a cut still takes every kit bark, and among `PlaySoundFile` audio only the cutter's
|
||||
/// own. SoftSpoken's two uses of the toggle both survive intact: stopping its own clip is
|
||||
/// cutter == owner, and ducking the game's NPC dialogue is a kit.
|
||||
#[test]
|
||||
fn a_cut_spares_another_addons_voice_line_and_still_takes_the_game_s_barks() {
|
||||
assert!(
|
||||
!stopped_by_reenable(
|
||||
0,
|
||||
SoundCategory::Sfx,
|
||||
true,
|
||||
Some("unitscan-turtle-hc"),
|
||||
Some("SoftSpoken")
|
||||
),
|
||||
"unitscan freeing itself a channel must not cut the line SoftSpoken is reading"
|
||||
);
|
||||
assert!(
|
||||
stopped_by_reenable(
|
||||
0,
|
||||
SoundCategory::Sfx,
|
||||
true,
|
||||
Some("unitscan-turtle-hc"),
|
||||
Some("unitscan-turtle-hc")
|
||||
),
|
||||
"its own previous horn is still its to stop"
|
||||
);
|
||||
assert!(
|
||||
stopped_by_reenable(
|
||||
1,
|
||||
SoundCategory::Sfx,
|
||||
true,
|
||||
Some("unitscan-turtle-hc"),
|
||||
None
|
||||
),
|
||||
"and the game's own barks are cut by anyone — that is what ducking the sound means"
|
||||
);
|
||||
// A toggle from outside any addon (the options window, the host) owns no file audio, so
|
||||
// it takes the barks and leaves every addon's voice alone.
|
||||
assert!(!stopped_by_reenable(
|
||||
0,
|
||||
SoundCategory::Sfx,
|
||||
true,
|
||||
None,
|
||||
None
|
||||
));
|
||||
assert!(stopped_by_reenable(1, SoundCategory::Sfx, true, None, None));
|
||||
}
|
||||
|
||||
/// Pinned as the 13 dwords at `.data` `0x87ce60`.
|
||||
#[test]
|
||||
fn the_cap_table_is_the_bytes_at_0x87ce60() {
|
||||
|
||||
@@ -48,13 +48,13 @@ pub(super) fn drain_ui_sounds(
|
||||
let mut dropped = 0usize;
|
||||
for req in requests {
|
||||
let kit_ref = match &req {
|
||||
SoundRequest::CutEffects => {
|
||||
kit::cut_effects_in_order(&mut out);
|
||||
SoundRequest::CutEffects { owner } => {
|
||||
kit::cut_effects_in_order(&mut out, owner.as_deref());
|
||||
continue;
|
||||
}
|
||||
SoundRequest::KitId(id) => KitRef::Id(*id),
|
||||
SoundRequest::KitName(name) => KitRef::Name(name),
|
||||
SoundRequest::File(path) => {
|
||||
SoundRequest::File { path, owner } => {
|
||||
let Some((kits, assets)) = catalog.as_mut() else {
|
||||
dropped += 1;
|
||||
continue;
|
||||
@@ -67,6 +67,7 @@ pub(super) fn drain_ui_sounds(
|
||||
&config,
|
||||
path,
|
||||
kit::SoundCategory::Sfx,
|
||||
owner.clone(),
|
||||
) {
|
||||
debug!("sound(ui): {req:?} — {e:#}");
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ impl super::UiScript {
|
||||
/// file is dirtied, as the reference's minimap `set_zoom` (`0x6da8e0`) also `CVar::Set`s
|
||||
/// `minimapZoom`/`minimapInsideZoom`. An unregistered name is a silent no-op (a bare VM).
|
||||
pub(super) fn set_from_engine(model: &mut Model, name: &str, value: String) {
|
||||
store(model, &name.to_ascii_lowercase(), value);
|
||||
store(model, &name.to_ascii_lowercase(), value, None);
|
||||
}
|
||||
|
||||
/// The one store every write goes through; returns the registered spelling when the value moved
|
||||
@@ -294,7 +294,7 @@ fn is_sound_reenable(key: &str, was: &str, now: &str) -> bool {
|
||||
is_master_sound_enable(key) && num(was) == 0.0 && num(now) != 0.0
|
||||
}
|
||||
|
||||
fn store(model: &mut Model, key: &str, value: String) -> Option<String> {
|
||||
fn store(model: &mut Model, key: &str, value: String, owner: Option<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
|
||||
@@ -322,7 +322,7 @@ fn store(model: &mut Model, key: &str, value: String) -> Option<String> {
|
||||
if reenabled {
|
||||
model
|
||||
.sound_queue
|
||||
.push(super::sound::SoundRequest::CutEffects);
|
||||
.push(super::sound::SoundRequest::CutEffects { owner });
|
||||
}
|
||||
Some(registered)
|
||||
}
|
||||
@@ -334,13 +334,27 @@ pub(in crate::script) fn write_cvar(
|
||||
name: &str,
|
||||
value: String,
|
||||
token: Option<String>,
|
||||
) -> bool {
|
||||
write_cvar_owned(model, name, value, token, None)
|
||||
}
|
||||
|
||||
/// [`write_cvar`], plus the addon whose file wrote it, for the one CVar whose effect depends on
|
||||
/// who asked: `MasterSoundEffects`, whose re-enabling edge stops playing sound. Only the
|
||||
/// `SetCVar` binding passes an owner; every other writer is the host or the options window, and
|
||||
/// `None` there means "not an addon", which is exactly right.
|
||||
pub(in crate::script) fn write_cvar_owned(
|
||||
model: &mut Model,
|
||||
name: &str,
|
||||
value: String,
|
||||
token: Option<String>,
|
||||
owner: Option<String>,
|
||||
) -> bool {
|
||||
let key = name.to_ascii_lowercase();
|
||||
if !model.cvars.contains_key(&key) {
|
||||
warn_unknown(model, name);
|
||||
return false;
|
||||
}
|
||||
if store(model, &key, value.clone()).is_some() {
|
||||
if store(model, &key, value.clone(), owner).is_some() {
|
||||
if let Some(token) = token {
|
||||
model.pending_events.push((
|
||||
"CVAR_UPDATE".to_string(),
|
||||
@@ -549,8 +563,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// "STATUS_BAR_TEXT" (`UIOptionsFrame.lua` l.335, `OptionsFrame.lua` l.192), which is
|
||||
// how `UIOptionsFrame_OnEvent` finds its row.
|
||||
let token = it.next().and_then(value_to_string);
|
||||
// Before the app-data borrow: the stack walk re-enters Lua.
|
||||
let owner = super::calling_addon(lua);
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
write_cvar(&mut model, &name, value, token);
|
||||
write_cvar_owned(&mut model, &name, value, token, owner);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -615,6 +615,51 @@ pub fn addon_chunk_name(folder: &str, file: &str) -> String {
|
||||
/// buries the log, which is what these diagnostics replaced.
|
||||
const CALLER_FRAMES: usize = 2;
|
||||
|
||||
/// How deep [`calling_addon`] looks for a file it can name. An addon that calls a binding through
|
||||
/// two or three helpers of its own is ordinary; past that the frames belong to a library, and a
|
||||
/// library's folder is a worse answer than none.
|
||||
const ADDON_SCAN_DEPTH: usize = 8;
|
||||
|
||||
/// Which addon's file the running Lua call came from, or `None` for FrameXML, a `/script` line,
|
||||
/// a string chunk or the host.
|
||||
///
|
||||
/// Attribution by chunk name: an addon's files are loaded as `@Interface\AddOns\<Name>\<file>`,
|
||||
/// so the first stack frame carrying that prefix names the addon. A call that reaches a binding
|
||||
/// through a library in *another* addon's folder is attributed to the library, which is the
|
||||
/// honest answer — that is whose code is running.
|
||||
///
|
||||
/// Paid only where a binding has to tell two addons apart. The only such place today is the
|
||||
/// `MasterSoundEffects` cut ([`sound::SoundRequest::CutEffects`]), where one addon's stop was
|
||||
/// silencing another addon's voice line.
|
||||
pub(crate) fn calling_addon(lua: &Lua) -> Option<String> {
|
||||
for level in 1..=ADDON_SCAN_DEPTH {
|
||||
let frame = lua.inspect_stack(level, |d| d.source().source.as_deref().map(str::to_owned));
|
||||
match frame {
|
||||
// Past the top of the stack: nothing more to look at.
|
||||
None => return None,
|
||||
// A C function or a chunk with no name: keep walking.
|
||||
Some(None) => continue,
|
||||
Some(Some(src)) => {
|
||||
if let Some(name) = addon_of(&src) {
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The addon folder inside a chunk name, case-insensitively: `@Interface\AddOns\pfUI\x.lua`
|
||||
/// is `pfUI`. Both separators are accepted because a `.toc` may spell either and the loader keeps
|
||||
/// what it was given.
|
||||
fn addon_of(source: &str) -> Option<String> {
|
||||
let path = source.strip_prefix('@').unwrap_or(source);
|
||||
let lower = path.to_ascii_lowercase().replace('/', "\\");
|
||||
let rest = &path[lower.find("interface\\addons\\")? + "interface\\addons\\".len()..];
|
||||
let end = rest.find(['\\', '/'])?;
|
||||
Some(rest[..end].to_string())
|
||||
}
|
||||
|
||||
/// The Lua line that called the binding we are inside, as `debugstack` renders it, for a
|
||||
/// diagnostic that has to name its own caller.
|
||||
///
|
||||
|
||||
@@ -23,8 +23,10 @@ pub enum SoundRequest {
|
||||
KitId(u32),
|
||||
/// `PlaySound("name")`: a kit name, the 1.12 form.
|
||||
KitName(String),
|
||||
/// `PlaySoundFile("path")`: a file path, no kit.
|
||||
File(String),
|
||||
/// `PlaySoundFile("path")`: a file path, no kit, and the addon whose file called it
|
||||
/// ([`super::calling_addon`]) so that [`Self::CutEffects`] can tell one addon's audio from
|
||||
/// another's.
|
||||
File { path: String, owner: Option<String> },
|
||||
/// The enabling edge of `MasterSoundEffects` (and its two later-era spellings): stop every
|
||||
/// playing effect, the reference's `0x457500` -> `0x7a6570` -> `0x7a67a0`.
|
||||
///
|
||||
@@ -36,7 +38,25 @@ pub enum SoundRequest {
|
||||
/// 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,
|
||||
///
|
||||
/// `owner` is the addon whose file wrote the toggle. **Deviation, and a deliberate one.** The
|
||||
/// reference's stop is indiscriminate: `0x7a67a0` walks the live instance list and stops
|
||||
/// every node in it, whoever started them. That is fine while one addon at a time uses the
|
||||
/// toggle, and this install has two who use it for opposite ends:
|
||||
///
|
||||
/// - SoftSpoken spends it to stop **its own** clip before replaying it, and to duck the
|
||||
/// game's own NPC bark while it reads.
|
||||
/// - unitscan spends it to free a channel so its horn is certain to play
|
||||
/// (`unitscan.lua:55-59`: the pair, then `PlaySoundFile`).
|
||||
///
|
||||
/// With the reference's rule, unitscan's horn cuts whatever SoftSpoken is in the middle of
|
||||
/// reading. grom, 2026-11-08: *"unitscan sound will interupt softspoken reading something,
|
||||
/// this is really annoying, I still want the unitscan sound but for softspoken to not be
|
||||
/// silenced."* Nothing in the toggle itself separates the two uses, so the caller does: a cut
|
||||
/// still stops every kit-played bark, and among `PlaySoundFile` audio it stops only the
|
||||
/// calling addon's own. An addon stopping its own clip is unaffected; an addon stopping
|
||||
/// somebody else's never has a reason to.
|
||||
CutEffects { owner: Option<String> },
|
||||
}
|
||||
|
||||
/// One queued `PlayMusic` or `StopMusic`, for the app's Lua music slot.
|
||||
@@ -78,8 +98,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
return Err(mlua::Error::runtime("Usage: PlaySoundFile(\"filePath\")"));
|
||||
};
|
||||
let path = s.to_str()?.to_owned();
|
||||
// Before the app-data borrow: the stack walk re-enters Lua.
|
||||
let owner = super::calling_addon(lua);
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.sound_queue.push(SoundRequest::File(path));
|
||||
model.sound_queue.push(SoundRequest::File { path, owner });
|
||||
drop(model);
|
||||
Ok((true, Value::Nil))
|
||||
})?,
|
||||
@@ -166,12 +188,77 @@ mod tests {
|
||||
SoundRequest::KitId(1234),
|
||||
SoundRequest::KitId(841),
|
||||
SoundRequest::KitName("GAMEOBJECT_DOOROPEN".into()),
|
||||
SoundRequest::File("Sound\\Doodad\\BellTollHorde.wav".into()),
|
||||
SoundRequest::File {
|
||||
path: "Sound\\Doodad\\BellTollHorde.wav".into(),
|
||||
owner: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
assert!(s.take_sounds().is_empty());
|
||||
}
|
||||
|
||||
/// The toggle and the play both carry the addon whose file made the call, which is the only
|
||||
/// thing that separates unitscan freeing itself a channel from SoftSpoken stopping its own
|
||||
/// clip — the two write the identical pair of lines.
|
||||
///
|
||||
/// The chunks run under the names the loader gives an addon file
|
||||
/// ([`crate::script::addon_chunk_name`]), because that name is the whole attribution.
|
||||
#[test]
|
||||
fn the_cut_and_the_file_carry_the_addon_that_called_them() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.register_cvars([("MasterSoundEffects", "1")]);
|
||||
|
||||
// unitscan.lua:55-59, verbatim in shape.
|
||||
s.run_chunk_named(
|
||||
b"SetCVar('MasterSoundEffects', 0)\n\
|
||||
SetCVar('MasterSoundEffects', 1)\n\
|
||||
PlaySoundFile[[Interface\\AddOns\\unitscan-turtle-hc\\horn.ogg]]",
|
||||
&crate::script::addon_chunk_name("unitscan-turtle-hc", "unitscan.lua"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
s.take_sounds(),
|
||||
vec![
|
||||
SoundRequest::CutEffects {
|
||||
owner: Some("unitscan-turtle-hc".into())
|
||||
},
|
||||
SoundRequest::File {
|
||||
path: "Interface\\AddOns\\unitscan-turtle-hc\\horn.ogg".into(),
|
||||
owner: Some("unitscan-turtle-hc".into()),
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
// A call made through a function the addon installed is still the addon's: the function's
|
||||
// chunk is the addon file it was defined in, however it is reached.
|
||||
s.run_chunk_named(
|
||||
b"SoftSpokenPlay = function() \
|
||||
PlaySoundFile[[Interface\\AddOns\\SoftSpoken\\line.mp3]] end",
|
||||
&crate::script::addon_chunk_name("SoftSpoken", "Sound/Utils.lua"),
|
||||
)
|
||||
.unwrap();
|
||||
s.run("SoftSpokenPlay()").unwrap();
|
||||
assert_eq!(
|
||||
s.take_sounds(),
|
||||
vec![SoundRequest::File {
|
||||
path: "Interface\\AddOns\\SoftSpoken\\line.mp3".into(),
|
||||
owner: Some("SoftSpoken".into()),
|
||||
}]
|
||||
);
|
||||
|
||||
// The options window, a `/script` line and the host itself own nothing.
|
||||
s.run(r#"PlaySoundFile("Sound\\Doodad\\BellTollHorde.wav")"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.take_sounds(),
|
||||
vec![SoundRequest::File {
|
||||
path: "Sound\\Doodad\\BellTollHorde.wav".into(),
|
||||
owner: None,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
/// `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`
|
||||
@@ -193,8 +280,11 @@ mod tests {
|
||||
assert_eq!(
|
||||
s.take_sounds(),
|
||||
vec![
|
||||
SoundRequest::CutEffects,
|
||||
SoundRequest::File("Interface\\AddOns\\SpokenZonesAudio\\Sounds\\dm.mp3".into()),
|
||||
SoundRequest::CutEffects { owner: None },
|
||||
SoundRequest::File {
|
||||
path: "Interface\\AddOns\\SpokenZonesAudio\\Sounds\\dm.mp3".into(),
|
||||
owner: None,
|
||||
},
|
||||
],
|
||||
"the stop keeps its place ahead of the play"
|
||||
);
|
||||
@@ -211,7 +301,7 @@ mod tests {
|
||||
s.run("SetCVar(\"MasterSoundEffects\", 1)").unwrap();
|
||||
assert_eq!(
|
||||
s.take_sounds(),
|
||||
vec![SoundRequest::CutEffects],
|
||||
vec![SoundRequest::CutEffects { owner: None }],
|
||||
"one cut for the edge, none for the re-assert"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5133,3 +5133,100 @@ caller is not an addon at all — it is the realm's own FrameXML:
|
||||
`SetHyperlink`, to force each item into the cache. The tooltip is a hidden scanner that is never
|
||||
positioned for anyone to read, so the reference's own fallback to mode 0 / `ANCHOR_LEFT` is all
|
||||
that code ever got from it either. A true report of sloppy realm code, not a gap here.
|
||||
|
||||
## `NORMAL_FONT_COLOR:GetRGB()` — the `/who` list, and a global 1.12 never had
|
||||
|
||||
`ui_script/manifest.rs`'s `install_font_colour_objects`, beside the other load-time repairs.
|
||||
|
||||
1.12 ships its font colours as loose `{ r, g, b }` tables (`Fonts.xml`); every client from Legion
|
||||
on makes them `ColorMixin` objects, and addon code written in that dialect calls methods on them
|
||||
rather than reading the fields. pfUI does, in both directions at once:
|
||||
|
||||
| call | where | what it did here |
|
||||
| --- | --- | --- |
|
||||
| `NORMAL_FONT_COLOR:GetRGB()` | `modules/socialmod.lua:145`, `modules/loot.lua` ×5 | **26 raises in one of grom's sessions**, every one from opening the Who tab. The list drew uncoloured and each row cost a traceback; the master-loot menu has the same five lines waiting for a raid |
|
||||
| `WHITE_FONT_COLOR:GetRGB()` / `:GetRGBA()` | `modules/totems.lua:22-23`, `modules/minimap.lua:198` | indexes a nil: 1.12 has no such global at all |
|
||||
|
||||
The six `*_FONT_COLOR` tables 1.12 declares (`reference/1.12-globals.tsv`) get `ColorMixin` as a
|
||||
**metatable**, not a `Mixin` copy. Upstream copies the methods in as fields, which is right for an
|
||||
object it owns; these tables are FrameXML's, FrameXML reads them, and a 1.12 addon is free to walk
|
||||
one with `pairs`. `__index` leaves `pairs(NORMAL_FONT_COLOR)` answering exactly the keys it answers
|
||||
on the reference while the methods still resolve. `a` is the one field added, because `GetRGBA` has
|
||||
to answer something and modern WoW's own `NORMAL_FONT_COLOR` is opaque. A table that already
|
||||
carries a metatable is left exactly as found. `WHITE_FONT_COLOR` is created only if absent.
|
||||
|
||||
It lives in `manifest.rs` rather than beside `ColorMixin` in `compat/classic_api/color_mixin.rs`
|
||||
for the same mechanical reason as `/focus`: the tables do not exist until `Fonts.xml` has run, and
|
||||
the compat layer installs before FrameXML loads. Named one by one rather than swept out of `_G`,
|
||||
so an addon's own table of the same name never gets a metatable it did not ask for.
|
||||
|
||||
## `MasterSoundEffects`: whose sound a cut may stop
|
||||
|
||||
`script/sound.rs`'s `SoundRequest::CutEffects`, spent by `sound/kit.rs`'s `stopped_by_reenable`.
|
||||
**A deviation, and a deliberate one.**
|
||||
|
||||
`SetCVar("MasterSoundEffects", 0)` then `..., 1)` is the only way a 1.12 addon can stop a sound
|
||||
that is already playing, and the reference's stop is indiscriminate: `0x7a67a0` walks the live
|
||||
instance list and stops every node in it, whoever started them. That is fine while one addon at a
|
||||
time uses the toggle, and this install has two that use it for opposite ends:
|
||||
|
||||
* **SoftSpoken** spends it to stop *its own* clip before replaying it, and to duck the game's own
|
||||
NPC bark while it reads.
|
||||
* **unitscan** spends it to make sure of a channel for its alert — `unitscan.lua:55-59` is the
|
||||
pair, then `PlaySoundFile`. It is not aimed at anybody.
|
||||
|
||||
With the reference's rule, unitscan's horn cuts whatever SoftSpoken is in the middle of reading.
|
||||
grom, 2026-11-08: *"unitscan sound will interupt softspoken reading something, this is really
|
||||
annoying, I still want the unitscan sound but for softspoken to not be silenced."*
|
||||
|
||||
Nothing in the toggle separates the two uses, so the **caller** does. `PlaySoundFile` and the
|
||||
toggle each carry the addon whose file made the call (`script/mod.rs`'s `calling_addon`, attribution
|
||||
by chunk name: an addon's files load as `@Interface\AddOns\<Name>\<file>`). A cut still stops every
|
||||
kit-played bark — that is the game's own audio, which is what an addon ducking "the sound" means by
|
||||
it — and among `PlaySoundFile` audio it stops only the calling addon's own. Both of SoftSpoken's
|
||||
uses survive intact: stopping its own clip is cutter == owner, and ducking NPC dialogue is a kit.
|
||||
|
||||
## An item template nobody answered
|
||||
|
||||
`items.rs`'s `retry_unanswered_templates`, over `query_cache.rs`'s `sweep_pending`.
|
||||
|
||||
The ask-once rule is the reference's `DBCache` and it is right for everything it covers: one query
|
||||
per entry per connection, a negative cached as a negative. What it has no arm for is a query that
|
||||
goes out and is **never answered**, and the whole cost of that falls on the player —
|
||||
`GetItemInfo` keeps returning nil, so the item draws with no name, no icon and no tooltip, in the
|
||||
bag, in the quest frame's required-items box and in every addon that reads it, for the rest of the
|
||||
session, and nothing ever asks again.
|
||||
|
||||
grom, 2026-11-08: *"on the quest Deeprun Rat Roundup, I'm not recieving the flute item needed for
|
||||
the quest"*. He had it the whole time. Entry **17117**, Rat Catcher's Flute, bag 0 slot 2 — the
|
||||
only trace of it in a three-hour log is `GetNumJunkItems` reporting one carried slot *"with no
|
||||
item template yet"*, twice.
|
||||
|
||||
An unanswered ask is now re-sent after 5 s, three sends in all, and then reported once. Bounded on
|
||||
purpose: an entry the server genuinely does not know is answered with the high-bit miss and never
|
||||
reaches the sweep, so a key that survives three sends is one nothing is going to answer. The same
|
||||
gap exists for creature names and gameobject templates and is left alone until one of them is seen
|
||||
to drop an answer, since a nameless mob is at least self-correcting on the next
|
||||
`SMSG_UPDATE_OBJECT`.
|
||||
|
||||
## The map tooltip that read as invisible — 73% opaque art, not a draw order
|
||||
|
||||
Not a client change: `OpenMapClassic/Map/Extras.lua` (the port), `BackTooltip`.
|
||||
|
||||
grom sent two screenshots of a pfQuest quest-node tooltip he could not read on the world map, and
|
||||
it looks exactly like a strata fault — so that is what was measured first, and it is not one.
|
||||
`WorldMapTooltip` reports `strata=TOOLTIP lvl=1`, the stitched art is `FULLSCREEN` at levels 3..190,
|
||||
`ZKey` puts the stratum in its top four bits, alphas are 1.0 on both sides, and the frame trees of
|
||||
`WorldMapTooltip` and `ItemRefTooltip` are identical down to pfUI's backdrop child.
|
||||
|
||||
Four panels over the same stitched map settled it: `WorldMapTooltip` and a **fresh frame with a
|
||||
stock backdrop** both read through; the same frame with a flat `ARTWORK` texture instead of a
|
||||
backdrop is solid, on `WorldMapFrame` and on `UIParent` alike. So it is the backdrop art, and
|
||||
decoding it says so outright — every texel of `Interface\Tooltips\UI-Tooltip-Background.blp` is
|
||||
RGBA(142, 140, 142, **187**), 73% opaque.
|
||||
|
||||
Which is faithful. On Blizzard's muted parchment zone map the quarter that comes through is
|
||||
invisible; on OpenMap's bright, busy stitched map it is all you see. `GameTooltip` does not have
|
||||
the problem because pfUI puts a solid panel behind it, and nothing skins `WorldMapTooltip`. The
|
||||
port now does the same: one opaque texture on the tooltip's `BACKGROUND` layer, inset past the
|
||||
border so it still looks like a tooltip, shown only while the stitched map is what is underneath.
|
||||
|
||||
Reference in New Issue
Block a user