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:
2026-11-08 12:58:33 -07:00
co-authored by Claude Opus 5
parent b394d39481
commit a9acfbf420
6 changed files with 386 additions and 28 deletions
+121 -12
View File
@@ -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() {
+4 -3
View File
@@ -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:#}");
}
+21 -5
View File
@@ -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(())
})?,
)?;
+45
View File
@@ -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.
///
+98 -8
View File
@@ -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"
);
}
+97
View File
@@ -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.