diff --git a/crates/benilla-app/assets/ui/MapCanvas.lua b/crates/benilla-app/assets/ui/MapCanvas.lua index f0a2d1cd..24dc0795 100644 --- a/crates/benilla-app/assets/ui/MapCanvas.lua +++ b/crates/benilla-app/assets/ui/MapCanvas.lua @@ -1624,6 +1624,14 @@ local function BuildCanvas() -- a press anyway. The hooks below drive the controller from that button instead. sc:EnableMouse(false) + -- ...and the container crops everything below it as well, which the ScrollFrame alone does + -- not: that crops the scroll child's subtree, and an addon is free to parent a layer to the + -- CONTAINER beside the canvas. Era's own container is a plain Frame with nothing but this, so + -- that is where such a layer expects to be cropped -- OpenMapClassic's pin layer sits exactly + -- there and asks for `SetClipsChildren` itself, behind an `if` that was false on this client + -- until the verb existed. Both mechanisms now hold, and they intersect. + sc:SetClipsChildren(true) + local canvas = CreateFrame("Frame", "WorldMapCanvas", sc) canvas:SetWidth(detail:GetWidth()) canvas:SetHeight(detail:GetHeight()) diff --git a/crates/benilla-app/src/local_state.rs b/crates/benilla-app/src/local_state.rs index 3fef3d28..dbf4b07d 100644 --- a/crates/benilla-app/src/local_state.rs +++ b/crates/benilla-app/src/local_state.rs @@ -28,9 +28,10 @@ pub(crate) fn home() -> Option { home_ignoring_capture() } -/// [`home`] without the capture gate, for the one caller that may opt back in: -/// `ui_script::addons::root` under `$WOW_CAPTURE_ADDONS`. Nothing else should use it — the -/// hermetic rule is what makes a baseline comparable between machines. +/// [`home`] without the capture gate, for the two callers that may opt back in: +/// `ui_script::addons::root` under `$WOW_CAPTURE_ADDONS`, and [`saved_home`] under +/// `$WOW_CAPTURE_SAVED`. Nothing else should use it — the hermetic rule is what makes a baseline +/// comparable between machines. pub(crate) fn home_ignoring_capture() -> Option { if let Some(over) = std::env::var_os("BENILLA_HOME") { return Some(PathBuf::from(over)); @@ -118,17 +119,48 @@ pub(crate) fn addons_state_path(realm: &str, character: &str) -> Option home().map(|h| h.join("addons").join(format!("{key}.txt"))) } +/// [`home`] for the saved-variable readers, opened back up for a capture by +/// `$WOW_CAPTURE_SAVED=1`. +/// +/// A capture is hermetic so that a baseline compares between machines, and a player's saved +/// variables are the loudest thing that would break that — so this is off by default and stays +/// off for every baseline. It exists for one job: reproducing a *player's* interface, where the +/// defect lives in what their own config asks for and a default-config render shows nothing wrong +/// at all. The world map was exactly that — correct in a capture, wrong on their screen, because +/// the capture was drawing a map no addon had been configured to take over. +/// +/// Pair it with `$WOW_CAPTURE_ADDONS=1`; on its own it has no addon to feed. +fn saved_home() -> Option { + if std::env::var_os("WOW_CAPTURE_SAVED").is_some_and(|v| !v.is_empty()) { + return home_ignoring_capture(); + } + home() +} + +/// The `-` a capture should load saved variables for, read from +/// `$WOW_CAPTURE_SAVED`. +/// +/// A capture never logs in, so it has no identity of its own and would reach the account-scope +/// files only. That is not enough for an addon declaring `## SavedVariablesPerCharacter` — pfUI's +/// whole config is per-character — so the variable doubles as the name: `WOW_CAPTURE_SAVED=1` +/// opts in without one, `WOW_CAPTURE_SAVED=C_Thun-Gromgosh` opts in and says whose. +pub(crate) fn capture_saved_identity() -> Option<(String, String)> { + let value = std::env::var("WOW_CAPTURE_SAVED").ok()?; + let (realm, character) = value.split_once('-')?; + (!realm.is_empty() && !character.is_empty()).then(|| (realm.to_string(), character.to_string())) +} + /// `benilla-config/saved/`: per-addon saved variables, account scope, one `.lua` per addon /// declaring `## SavedVariables` (the reference's `WTF/Account//SavedVariables/.lua`). pub(crate) fn addon_saved_account_dir() -> Option { - home().map(|h| h.join("saved")) + saved_home().map(|h| h.join("saved")) } /// `benilla-config/saved/-/`: per-addon saved variables, character scope /// (`## SavedVariablesPerCharacter`), loaded after the account file so it wins. pub(crate) fn addon_saved_character_dir(realm: &str, character: &str) -> Option { let key = format!("{}-{}", file_token(realm), file_token(character)); - home().map(|h| h.join("saved").join(key)) + saved_home().map(|h| h.join("saved").join(key)) } /// `benilla-config/camera/-.txt`: the third-person camera pose (the diff --git a/crates/benilla-app/src/ui_script/addons.rs b/crates/benilla-app/src/ui_script/addons.rs index bbc5b162..493c95f6 100644 --- a/crates/benilla-app/src/ui_script/addons.rs +++ b/crates/benilla-app/src/ui_script/addons.rs @@ -794,6 +794,12 @@ pub(super) fn load_third_party( roster: &[String], version_check: bool, ) -> Vec { + // A capture has no identity of its own, because it never logs in. `$WOW_CAPTURE_SAVED` can + // name one, which is how a capture is pointed at a *player's* per-character saved variables — + // the only way to render the interface they are actually looking at. Outside a capture the + // variable is unset and this is the identity the session already had. + let captured = crate::local_state::capture_saved_identity(); + let identity = identity.or(captured.as_ref()); let addons = discover(); // Registered even when empty: `GetNumAddOns()` must answer 0, not a previous session's list. let mut infos: Vec<_> = addons.iter().map(info_for).collect(); diff --git a/crates/benilla-ui/src/loader/geometry.rs b/crates/benilla-ui/src/loader/geometry.rs index 84d3a756..9d4f3e91 100644 --- a/crates/benilla-ui/src/loader/geometry.rs +++ b/crates/benilla-ui/src/loader/geometry.rs @@ -88,6 +88,12 @@ impl Loader<'_> { if el.attr_bool("clampedToScreen") { self.call(wrapper, "SetClampedToScreen", true, dbg); } + // `clipChildren`: crop everything below this frame to its rect. Not a 1.12 attribute -- + // it arrived with the frames the Era map canvas is written in, and it is the XML half of + // `SetClipsChildren`. + if el.attr_bool("clipChildren") { + self.call(wrapper, "SetClipsChildren", true, dbg); + } if el.attr_bool("enableMouse") { self.call(wrapper, "EnableMouse", true, dbg); } diff --git a/crates/benilla-ui/src/script/clip.rs b/crates/benilla-ui/src/script/clip.rs index 9284fc9f..84520a6e 100644 --- a/crates/benilla-ui/src/script/clip.rs +++ b/crates/benilla-ui/src/script/clip.rs @@ -30,23 +30,46 @@ pub(super) fn scroll_clip_sources(model: &Model) -> HashMap { sources } -/// The clip `h` draws and hits within: the intersection of every ScrollFrame rect whose scroll -/// child is `h` or an ancestor of it, so nested ScrollFrames intersect; `None` is unclipped. +/// The clip `h` draws and hits within: the intersection of +/// +/// * every ScrollFrame rect whose scroll child is `h` or an ancestor of it, and +/// * every **ancestor** of `h` that sets `SetClipsChildren`, +/// +/// so the two nest and intersect freely; `None` is unclipped. +/// +/// The second rule is why a frame parented *beside* a ScrollFrame's scroll child can be cropped +/// at all. Era's map canvas is a plain Frame with `SetClipsChildren(true)` and everything under it +/// is cropped, wherever it hangs; ours is a ScrollFrame, which crops the scroll child's subtree +/// and nothing else — so OpenMapClassic's pin layer, a sibling of that child, drew across the +/// whole screen. It asks for the verb itself and was refused in silence. +/// +/// An ancestor, not `h` itself: the verb clips a frame's *children*, so a frame's own regions are +/// still free to overhang its rect, which plenty of stock art does. pub(super) fn effective_clip( model: &Model, sources: &HashMap, mut h: FrameHandle, ) -> Option { let mut clip: Option = None; + let add = |clip: &mut Option, r: Rect| { + *clip = Some(match *clip { + Some(c) => intersect_rect(c, r), + None => r, + }); + }; loop { if let Some(&r) = sources.get(&h) { - clip = Some(match clip { - Some(c) => intersect_rect(c, r), - None => r, - }); + add(&mut clip, r); } match model.arena.frame(h).and_then(|f| f.parent) { - Some(p) => h = p, + Some(p) => { + if model.arena.does_clip_children(p) { + if let Some(&r) = model.resolved.get(&p) { + add(&mut clip, r); + } + } + h = p; + } None => break, } } diff --git a/crates/benilla-ui/src/script/compat/classic_api/spell.rs b/crates/benilla-ui/src/script/compat/classic_api/spell.rs index c6340f9c..2638190d 100644 --- a/crates/benilla-ui/src/script/compat/classic_api/spell.rs +++ b/crates/benilla-ui/src/script/compat/classic_api/spell.rs @@ -539,21 +539,53 @@ pub(super) fn install(lua: &Lua) -> mlua::Result { })?, )?; - // CancelSpellByID/CancelSpellByName: both fall through to the same unconditional - // `SpellStopCasting` path the stock binding uses — there is no spell id attached to - // `model.casting`, so neither verifies the id actually matches what's in flight. - for name in ["CancelSpellByID", "CancelSpellByName"] { - t.set( - name, - lua.create_function(|lua, _: Value| { - let mut model = lua.app_data_mut::().expect("model app_data"); - if model.casting { - model.spell_stop = true; + // `CancelSpellByID(id)`: an aura of the player's by that id first, the cast in flight second. + // + // Stopping the cast is all this used to do, and for a *cast* that is right. But the call an + // addon actually makes is on a buff button: pfUI's buff bar cancels with + // `C_Spell.CancelSpellByID(this.spellId)` on a right click (`modules/buff.lua:153`), and the + // 1.12 buff bar it replaces calls `CancelPlayerBuff`. With only the cast path there was + // nothing in flight, nothing happened, and right-clicking a buff on this client had never + // once removed it. + // + // So the id is looked for among the player's own auras and cancelled through the same queue + // `CancelPlayerBuff` and `CancelUnitBuff` use — one `u32` on `CMSG_CANCEL_AURA` — behind the + // same `cancel_authorized` gate, so a buff the reference refuses to drop is still refused. + // Only with no such aura does it fall through to stopping the cast. + t.set( + "CancelSpellByID", + lua.create_function(|lua, spell_id: Option| { + let mut model = lua.app_data_mut::().expect("model app_data"); + let hit = spell_id.and_then(|id| { + model.auras.get("player").and_then(|list| { + list.iter() + .find(|a| a.spell_id == id && crate::script::aura::cancel_authorized(a)) + .map(|a| a.spell_id) + }) + }); + match hit { + Some(id) => model.cancel_aura_requests.push(id), + None => { + if model.casting { + model.spell_stop = true; + } } - Ok(()) - })?, - )?; - } + } + Ok(()) + })?, + )?; + // `CancelSpellByName` keeps the cast-only path: it is handed no id, and `model.casting` + // carries none either, so there is nothing to match an aura against. + t.set( + "CancelSpellByName", + lua.create_function(|lua, _: Value| { + let mut model = lua.app_data_mut::().expect("model app_data"); + if model.casting { + model.spell_stop = true; + } + Ok(()) + })?, + )?; // The three melee-timing predicates and the melee auto-attack test. Every one reads a named // boolean off the spell lookup rather than an attribute word (see `SpellLookup`), so the bits @@ -721,6 +753,59 @@ pub(super) fn install_globals(lua: &Lua, t: &Table) -> mlua::Result<()> { mod tests { use crate::script::{SpellBookState, SpellSlotView, SpellTabView, UiScript}; + /// Right-clicking a buff has to take it off, which on this client it never did. + /// + /// pfUI's buff bar replaces 1.12's, and where the stock one calls `CancelPlayerBuff` its + /// buttons call `C_Spell.CancelSpellByID(this.spellId)` (`modules/buff.lua:153`). That verb + /// only stopped a cast in flight here, so with nothing being cast the right click did + /// nothing at all — silently, since the verb exists and returns. + #[test] + fn cancel_spell_by_id_cancels_the_players_own_buff() { + let mut s = UiScript::new().unwrap(); + let aura = |spell_id: u32, helpful: bool, cancelable: bool| crate::script::AuraState { + spell_id, + name: Some(format!("Spell {spell_id}")), + icon: None, + count: 1, + debuff_type: None, + duration: 0.0, + expiration_time: 0.0, + helpful, + slot: if helpful { 0 } else { 32 }, + cancelable, + until_cancelled: false, + channeled: false, + }; + s.set_auras( + "player", + Some(vec![ + aura(6673, true, true), // Battle Shout, droppable + aura(1243, true, false), // a buff the reference refuses to cancel + aura(11196, false, true), // a debuff: not a buff, never cancelled this way + ]), + ); + + s.run("C_Spell.CancelSpellByID(6673)").unwrap(); + assert_eq!( + s.take_cancel_aura_requests(), + vec![6673], + "the player's own cancellable buff goes on the CMSG_CANCEL_AURA queue" + ); + + // The gate still holds: a buff the reference will not drop is still not dropped, and a + // debuff is not a buff. + s.run("C_Spell.CancelSpellByID(1243)").unwrap(); + s.run("C_Spell.CancelSpellByID(11196)").unwrap(); + assert!( + s.take_cancel_aura_requests().is_empty(), + "cancel_authorized still decides, exactly as CancelPlayerBuff leaves it" + ); + + // An id the player carries no aura for is still the cast-stopping path, not a no-op. + s.run("C_Spell.CancelSpellByID(999999)").unwrap(); + assert!(s.take_cancel_aura_requests().is_empty()); + } + /// `C_Spell.IsSpellUsable` answers the usable walk rather than "off cooldown", and takes the /// name pfUI's reactive indicator passes it (`modules/nampower.lua:130`). Overpower is the case /// that separates the two: the walk greys it outside its proc window, which is the whole point diff --git a/crates/benilla-ui/src/script/object/frame_state.rs b/crates/benilla-ui/src/script/object/frame_state.rs index 79864066..ccc7b002 100644 --- a/crates/benilla-ui/src/script/object/frame_state.rs +++ b/crates/benilla-ui/src/script/object/frame_state.rs @@ -713,6 +713,29 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { )) })?, )?; + // `SetClipsChildren` / `DoesClipChildren`: crop everything below this frame to its own rect. + // Not a 1.12 verb -- it arrived with the Era map canvas, whose `ScrollContainer` is a plain + // Frame that leans on it entirely. Addons written against that canvas call it behind an `if`, + // so without it they ran on and drew a canvas nothing cropped. + m.set( + "SetClipsChildren", + lua.create_function(|lua, (this, clip): (Table, bool)| { + let h = frame_handle_of(lua, &this)?; + let mut model = lua.app_data_mut::().expect("model"); + model.arena.set_clips_children(h, clip); + Ok(()) + })?, + )?; + m.set( + "DoesClipChildren", + lua.create_function(|lua, this: Table| { + let h = frame_handle_of(lua, &this)?; + let model = lua.app_data_ref::().expect("model"); + Ok(crate::script::binding_abi::flag( + model.arena.does_clip_children(h), + )) + })?, + )?; // Hit-rect insets shrink only the mouse hit rect; geometry, drawing and anchors ignore them. m.set( "SetHitRectInsets", diff --git a/crates/benilla-ui/src/script/tests/scrollframe.rs b/crates/benilla-ui/src/script/tests/scrollframe.rs index 638d434b..3b5576ba 100644 --- a/crates/benilla-ui/src/script/tests/scrollframe.rs +++ b/crates/benilla-ui/src/script/tests/scrollframe.rs @@ -545,6 +545,85 @@ fn extract_clips_the_scroll_childs_whole_subtree_and_leaves_a_sibling_unclipped( ); } +/// `SetClipsChildren` crops a frame's whole subtree, wherever it hangs — which is the one thing +/// the ScrollFrame substitution could not do. +/// +/// This is the world map's own shape. Era's `ScrollContainer` is a plain Frame that leans on this +/// verb; ours is a real ScrollFrame, so the *scroll child* was cropped and a frame parented to the +/// container beside it was cropped by nothing. OpenMapClassic's pin layer is exactly that frame — +/// `pinRoot` is created on `parent:GetParent()`, the view, with a guarded +/// `SetClipsChildren(true)` (`Map/Stitch.lua:1070`) — so on this client it drew its pins across +/// the whole screen and the guard never said why. +#[test] +fn clips_children_crops_a_sibling_of_the_scroll_child_too() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + -- the view: Era's ScrollContainer, which crops everything under it + local view = CreateFrame("Frame", "View") + view:SetPoint("TOPLEFT", 0, -100) -- top 500 + view:SetWidth(300); view:SetHeight(200) -- bottom 300, right 300 + + -- the canvas, the scroll child, cropped by the ScrollFrame as before + local sf = CreateFrame("ScrollFrame", "SF", view) + sf:SetAllPoints(view) + local canvas = CreateFrame("Frame", "Canvas") + canvas:SetWidth(300); canvas:SetHeight(600) + sf:SetScrollChild(canvas) + + -- the pin layer: a child of the VIEW, beside the canvas, overhanging the view wildly + local pins = CreateFrame("Frame", "Pins", view) + pins:SetPoint("TOPLEFT", view, "TOPLEFT", -400, 0) + pins:SetWidth(50); pins:SetHeight(50) + local pm = pins:CreateTexture(nil, "ARTWORK") + pm:SetTexture("marker:pin") + + -- and a region of the view itself, which the verb must NOT crop: it clips children + local om = view:CreateTexture(nil, "ARTWORK") + om:SetTexture("marker:own") + + view:SetClipsChildren(true) + "#, + ) + .unwrap(); + s.resolve(); + let quads = s.extract(); + + let view_rect = Rect::new(300.0, 0.0, 500.0, 300.0); + assert_eq!( + marker_clip(&quads, "marker:pin"), + Some(Some(view_rect)), + "a child of a clipsChildren frame crops to it, scroll child or not" + ); + assert_eq!( + marker_clip(&quads, "marker:own"), + Some(None), + "the frame's own regions are not cropped: the verb clips children" + ); +} + +/// The verb answers what it was told, and is off until asked. +#[test] +fn does_clip_children_reports_the_flag() { + let s = script(); + assert_eq!( + s.eval::>( + r#"local f = CreateFrame("Frame", "ClipProbe") return f:DoesClipChildren()"# + ) + .unwrap(), + None, + "off by default" + ); + assert_eq!( + s.eval::>( + r#"ClipProbe:SetClipsChildren(true) return ClipProbe:DoesClipChildren()"# + ) + .unwrap(), + Some(1) + ); +} + #[test] fn nested_scrollframes_intersect_their_clip_rects() { let mut s = script(); diff --git a/crates/benilla-ui/src/script/tests/widget_surface.rs b/crates/benilla-ui/src/script/tests/widget_surface.rs index 24a826b8..d0f50e35 100644 --- a/crates/benilla-ui/src/script/tests/widget_surface.rs +++ b/crates/benilla-ui/src/script/tests/widget_surface.rs @@ -164,6 +164,21 @@ const NOT_YET_ANSWERED: &[(&str, &str, &str)] = &[ /// Methods answered here that 1.12 does not register on that class, each with why it stays. const ALLOWED_BEYOND: &[(&str, &str, &str)] = &[ + ( + "SetClipsChildren", + "Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame", + "Era's map canvas is built on it: its ScrollContainer is a plain Frame that crops \ + everything below it with this and nothing else. A canvas addon parents its pin layer \ + to that container and asks for the verb by name, behind an `if` — OpenMapClassic's \ + `Map/Stitch.lua:1070` — so its absence was not a raise, it was an uncropped layer \ + drawing across the whole screen. Feature-detecting it is exactly right: with it, crop; \ + without, do not.", + ), + ( + "DoesClipChildren", + "Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame", + "The reader half of SetClipsChildren; an addon that sets it reads it back.", + ), ( "GetItem", "GameTooltip", diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index 5051eb9e..622a5a5f 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -181,6 +181,16 @@ pub struct Frame { /// Every GameTooltip is born with it here; the stock tooltips get it from their template, /// `GameTooltipTemplate.xml:3`. pub clamped_to_screen: bool, + /// `SetClipsChildren` / ``: everything the frame's **descendants** + /// draw is cut to the frame's own rect, and so is the mouse. Its own regions are not cut -- + /// the verb clips children, which is what the name says and what the one consumer wants. + /// + /// Era's map canvas is built on it: `ScrollContainer` is a plain Frame that relies on this to + /// crop a canvas far larger than the window. Ours is a real ScrollFrame instead, which crops + /// the *scroll child*, and a frame parented to the container beside that child was cropped by + /// nothing at all -- OpenMapClassic's pin layer is exactly that, and it asks for this verb by + /// name (`Map/Stitch.lua:1070`, behind an `if`, so its absence was silent). + pub clips_children: bool, /// `SetHitRectInsets` or ``: the mouse rect's `[left, right, top, bottom]` /// inset. Only the hit test reads it; drawing, anchoring and `GetLeft`/`GetWidth` do not. pub hit_rect_insets: [f32; 4], @@ -487,6 +497,7 @@ impl WidgetArena { // (`0x779ced`/`0x779cf8`) and WorldFrame (`0x481b09`) ctors enable key input. keyboard_enabled: false, clamped_to_screen: matches!(kind, FrameKind::GameTooltip), + clips_children: false, hit_rect_insets: [0.0; 4], movable: false, resizable: false, diff --git a/crates/benilla-ui/src/widget/propagation.rs b/crates/benilla-ui/src/widget/propagation.rs index 9ac43f9a..779203c1 100644 --- a/crates/benilla-ui/src/widget/propagation.rs +++ b/crates/benilla-ui/src/widget/propagation.rs @@ -388,6 +388,18 @@ impl WidgetArena { self.frame(h).map(|f| f.clamped_to_screen).unwrap_or(false) } + /// `SetClipsChildren`: cut this frame's descendants to its own rect. + pub fn set_clips_children(&mut self, h: FrameHandle, clip: bool) { + if let Some(f) = self.frame_mut(h) { + f.clips_children = clip; + } + } + + /// `DoesClipChildren`. + pub fn does_clip_children(&self, h: FrameHandle) -> bool { + self.frame(h).map(|f| f.clips_children).unwrap_or(false) + } + /// `SetHitRectInsets`, `[left, right, top, bottom]`. pub fn set_hit_rect_insets(&mut self, h: FrameHandle, insets: [f32; 4]) { if let Some(f) = self.frame_mut(h) { diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index e6383395..7a627946 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -2384,3 +2384,103 @@ write one alpha.** pfUI dims a unit frame to 0.25 for an out-of-range target, an on. That is inherent in fading a frame whose alpha already means something — the same shape as the two window managers on the world map — and it is why the switch is per-target rather than one master toggle. + +## `SetClipsChildren`: the verb an addon asked for by name and was refused in silence + +Era's map canvas is built on one verb this client did not have. `WorldMapFrame.ScrollContainer` +there is a plain `Frame` with `SetClipsChildren(true)`, and everything below it — the canvas, the +pins, whatever an addon hangs off it — is cropped to its rect. + +`assets/ui/MapCanvas.lua` substituted a real `ScrollFrame` for it, because this client crops a +ScrollFrame's **scroll child and every descendant of that child** (`script/clip.rs`), in the draw +pass and the hit-test from one shared source. That covers the canvas. It does not cover a frame +parented to the *container*, beside the canvas — and that is a normal thing to do, because on Era +the container crops everything either way. + +OpenMapClassic does exactly that, and says so: + +```lua +-- (on the map's view, which clips them as the canvas does, not on the canvas: see Pin sets) +local view = parent:GetParent() or parent +pinRoot = CreateFrame("Frame", nil, view) +pinRoot:SetAllPoints(view) +if pinRoot.SetClipsChildren then pinRoot:SetClipsChildren(true) end +``` + +The `if` is the whole problem: the verb was absent, the call was skipped, and the pin layer was +cropped by nothing at all. Measured on grom's own config, map open on Dun Morogh — the container +sits at `L=1151 R=2153`, and its seventeen direct children span **`L=-765 R=2153`**, about 1900 +units off the left edge with nothing to stop them. + +Now implemented as a real frame flag (`clips_children`), with `SetClipsChildren` / +`DoesClipChildren` and the `clipChildren` XML attribute, and `effective_clip` intersects the rect +of every **ancestor** that sets it, alongside the ScrollFrame rule it already had — so the two nest +and compose. An ancestor and not the frame itself: the verb clips a frame's *children*, and plenty +of stock art overhangs its own frame on purpose. + +`MapCanvas.lua` now also sets it on the container, so a layer parked there is cropped whether or +not its addon thought to ask. + +**Not yet shown end to end.** This is a measured gap with a measured fix and a test whose control +fails, but the screenshot that started it has not been reproduced offline: a capture renders this +map correctly with and without the change, because the capture's view is not the view grom's +session was in. The pin layer was definitely drawing uncropped; whether it is the whole of what he +sees is still open. + +## A capture can now be pointed at a player's own saved variables + +A capture is hermetic — `local_state::home()` answers `None` under `$WOW_CAPTURE`, so no config, no +saved variables, no addons — and that is what makes a baseline comparable between machines. +`$WOW_CAPTURE_ADDONS=1` already opened the addon folder back up. + +The gap that cost a long detour here: with addons but no saved variables, a capture renders an +interface **nobody is configured to be looking at**. The world map came out correct in a capture +while being wrong on grom's screen, because no addon had been told to take the window over. + +`$WOW_CAPTURE_SAVED` closes it, and doubles as the identity, because a capture never logs in and +`pfUI_config` is `SavedVariablesPerCharacter`: + +``` +WOW_CAPTURE_SAVED=1 account-scope files only +WOW_CAPTURE_SAVED=C_Thun-Gromgosh account and that character's +``` + +Off by default and never on for a baseline. + +## `C_Spell.CancelSpellByID` stopped a cast, so right-clicking a buff never took it off + +1.12's buff bar cancels with `CancelPlayerBuff(index)`. pfUI replaces that bar, and its buttons +cancel with `C_Spell.CancelSpellByID(this.spellId)` on a right click (`modules/buff.lua:153`, +behind `RegisterForClicks("RightButtonUp")`). + +This client answered that verb with `SpellStopCasting` and nothing else — right for a cast, and +for a buff button it meant nothing was in flight, nothing happened, and no error either. Grom had +never once been able to right-click a buff off on this client. + +It now looks for the id among the player's own auras first and queues it on the same +`CMSG_CANCEL_AURA` path `CancelPlayerBuff` and `CancelUnitBuff` use, behind the same +`cancel_authorized` gate — so a buff the reference refuses to drop is still refused, and a debuff +is still not cancellable this way. Only with no such aura does it fall through to stopping the +cast. `CancelSpellByName` keeps the cast-only path: it is handed no id, and `model.casting` carries +none either, so there is nothing to match against. + +## Two writers on one alpha, again: the unit frame that strobed + +The first cut of `modules/autofade.lua` pointed `EnableAutohide` at the unit frames. The player +frame then flickered several times a second. + +`pfUI.uf:RefreshUnitState` is the authority on a unit frame's alpha — `alpha_visible`, +`alpha_offline`, `alpha_outrange` — and it reasserts that value the moment anything else moves it. +The autohide's own `OnUpdate` writes alpha every 0.05 s. Two writers, one value, each undoing the +other: the same shape as pfUI and OpenMapClassic both owning the world map window. + +The fix is not to win the race but to stop having one. The fade now goes **through** that +authority: a factor per frame, applied by scaling the three `alpha_*` fields for the duration of +one refresh, with pfUI doing the `SetAlpha` as it always did. Measured: forty back-to-back +refreshes hold at one value instead of oscillating. It also composes, which the first cut could +not — an out-of-range target still dims *and* fades, where a second writer could only overwrite. + +The four info panels moved onto the same page at the same time, the bottom-right one +(`pfUI.panel.right`, over the right chat frame) included. Those have had a fixed two-second +autohide on pfUI's Panel page for years; the Auto Fade page drives the same frames on the user's +own timeout, and hands one back rather than switching it off when the Panel page still wants it.