diff --git a/crates/benilla-app/src/sound/kit.rs b/crates/benilla-app/src/sound/kit.rs index cb72b5a3..9fbc22e5 100644 --- a/crates/benilla-app/src/sound/kit.rs +++ b/crates/benilla-app/src/sound/kit.rs @@ -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, /// 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, @@ -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, ) -> 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 = 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() { diff --git a/crates/benilla-app/src/sound/ui.rs b/crates/benilla-app/src/sound/ui.rs index c648627d..9197c006 100644 --- a/crates/benilla-app/src/sound/ui.rs +++ b/crates/benilla-app/src/sound/ui.rs @@ -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:#}"); } diff --git a/crates/benilla-ui/src/script/cvars.rs b/crates/benilla-ui/src/script/cvars.rs index 8f87d7ab..46bd4580 100644 --- a/crates/benilla-ui/src/script/cvars.rs +++ b/crates/benilla-ui/src/script/cvars.rs @@ -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 { +fn store(model: &mut Model, key: &str, value: String, owner: Option) -> Option { 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 { 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, +) -> 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, + owner: Option, ) -> 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::().expect("model app_data"); - write_cvar(&mut model, &name, value, token); + write_cvar_owned(&mut model, &name, value, token, owner); Ok(()) })?, )?; diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 85a39db7..777d489b 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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\\`, +/// 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 { + 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 { + 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. /// diff --git a/crates/benilla-ui/src/script/sound.rs b/crates/benilla-ui/src/script/sound.rs index 3b4c69b0..09bfbf9a 100644 --- a/crates/benilla-ui/src/script/sound.rs +++ b/crates/benilla-ui/src/script/sound.rs @@ -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 }, /// 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 }, } /// 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::().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" ); } diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index 270446f7..9c63821b 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -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\\`). 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.