ui: 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. Its `ScrollContainer` is a plain
Frame with `SetClipsChildren(true)`, and everything below it is cropped to that rect.
`assets/ui/MapCanvas.lua` substituted a real ScrollFrame, because this client crops a ScrollFrame's
scroll child and every descendant of it — which covers the canvas and nothing parented to the
container beside it. On Era that distinction does not exist, so an addon has no reason to respect
it.

OpenMapClassic parents its pin layer exactly there and asks for the verb by name, behind an `if`
(`Map/Stitch.lua:1070`), so the absence was not a raise. Measured on grom's own config with the map
open: 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, cropped by nothing.

Implemented as a frame flag with `SetClipsChildren`/`DoesClipChildren` and the `clipChildren` XML
attribute; `effective_clip` now intersects the rect of every ancestor that sets it alongside the
ScrollFrame rule, so the two nest and compose. An ancestor rather than the frame itself: the verb
clips a frame's children, and stock art overhangs its own frame on purpose. `MapCanvas.lua` sets it
on the container too, so a layer parked there is cropped whether or not its addon thought to ask.

Not claimed as the whole of grom's map report: the screenshot behind it has not been reproduced
offline, because a capture's view is not the view his session was in. This is the part that was
provably drawing uncropped.

Also here, both from the same play-test:

- `C_Spell.CancelSpellByID` answered with `SpellStopCasting` only. pfUI's buff bar cancels with it
  on a right click where 1.12's calls `CancelPlayerBuff`, so with nothing in flight the click did
  nothing and said nothing — grom had never been able to right-click a buff off on this client. It
  now finds the id among the player's own auras and queues it on the same CMSG_CANCEL_AURA path,
  behind the same `cancel_authorized` gate, and falls through to the cast only when there is no
  such aura.

- `$WOW_CAPTURE_SAVED=<Realm>-<Character>` points a capture at a player's own saved variables. A
  capture is hermetic by design, but with addons and no saved variables it renders an interface
  nobody is configured to be looking at — which is why the world map came out correct in a capture
  while being wrong on his screen. It doubles as the identity because a capture never logs in and
  `pfUI_config` is SavedVariablesPerCharacter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-11-03 09:46:32 -07:00
co-authored by Claude Opus 5
parent c9756de381
commit 5e2f1f3ac8
12 changed files with 426 additions and 26 deletions
@@ -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())
+37 -5
View File
@@ -28,9 +28,10 @@ pub(crate) fn home() -> Option<PathBuf> {
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<PathBuf> {
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<PathBuf>
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<PathBuf> {
if std::env::var_os("WOW_CAPTURE_SAVED").is_some_and(|v| !v.is_empty()) {
return home_ignoring_capture();
}
home()
}
/// The `<Realm>-<Character>` 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 `<Addon>.lua` per addon
/// declaring `## SavedVariables` (the reference's `WTF/Account/<ACC>/SavedVariables/<Addon>.lua`).
pub(crate) fn addon_saved_account_dir() -> Option<PathBuf> {
home().map(|h| h.join("saved"))
saved_home().map(|h| h.join("saved"))
}
/// `benilla-config/saved/<Realm>-<Character>/`: 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<PathBuf> {
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/<realm>-<character>.txt`: the third-person camera pose (the
@@ -794,6 +794,12 @@ pub(super) fn load_third_party(
roster: &[String],
version_check: bool,
) -> Vec<String> {
// 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();
+6
View File
@@ -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);
}
+30 -7
View File
@@ -30,23 +30,46 @@ pub(super) fn scroll_clip_sources(model: &Model) -> HashMap<FrameHandle, Rect> {
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<FrameHandle, Rect>,
mut h: FrameHandle,
) -> Option<Rect> {
let mut clip: Option<Rect> = None;
let add = |clip: &mut Option<Rect>, 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,
}
}
@@ -539,21 +539,53 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<Table> {
})?,
)?;
// 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::<Model>().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<u32>| {
let mut model = lua.app_data_mut::<Model>().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::<Model>().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
@@ -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::<Model>().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::<Model>().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",
@@ -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::<Option<i64>>(
r#"local f = CreateFrame("Frame", "ClipProbe") return f:DoesClipChildren()"#
)
.unwrap(),
None,
"off by default"
);
assert_eq!(
s.eval::<Option<i64>>(
r#"ClipProbe:SetClipsChildren(true) return ClipProbe:DoesClipChildren()"#
)
.unwrap(),
Some(1)
);
}
#[test]
fn nested_scrollframes_intersect_their_clip_rects() {
let mut s = script();
@@ -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",
+11
View File
@@ -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` / `<Frame clipChildren="true">`: 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 `<HitRectInsets>`: 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,
@@ -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) {
+100
View File
@@ -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.