compat: the secure-handler family and the animation surface

Both modules land together because `classic_api/mod.rs` and `widget_surface.rs` carry rows for
each: split, the first commit named a module the second added, and would not have compiled.

## The secure-handler family, because 1.12 has nothing to be secure from

`frame.rs` declined it with a stated reason — "modern restricted execution, which 1.12 has no
equivalent of" — and that is right about the *sandbox* and is the whole of why it is now built.
A secure handler exists so an untainted snippet may call **protected** functions; 1.12 has
neither taint nor protected functions, so the restriction has no counterpart here, while the
plumbing underneath (a named frame store, a snippet on a child's handler) is ordinary
behaviour the snippets need. The body runs as plain Lua with the names the secure environment
would bind.

**The per-header shared table is the part worth getting right.** `MenuWheel.lua:484` writes a
bare `held = false` inside a snippet, and in the real secure environment that lands in a table
per header, not `_G`. `setfenv` onto one table per frame, `__index` to `_G` so the rest of the
API resolves; the test asserts `held` is **not** a global afterwards. My first test asserted
through `_G` and failed — the implementation was right and the test measured the wrong thing.

**`SecureActionButtonTemplate` is not empty**: reading your own attributes on click is plain
dispatch. `type="click"` clicks the frame in `clickbutton` (how the wheels reach the game's
micro buttons), and the lookup follows upstream's order (`type-<button>`, `*type*`, `type`).

**That exposed a real bug of ours**: `SetAttribute` silently dropped a frame.
`attribute_value` kept bool/int/number/string and sent everything else to `ScriptValue::Nil`,
so `SetAttribute("clickbutton", SomeFrame)` stored nothing — and upstream's `clickbutton` is
routinely a frame, which `Cinematic/MenuWheel.lua` relies on. A widget table is now kept as
`ScriptValue::Frame(id)`, the variant `NAME_PLATE_CREATED`'s arg1 already uses, which
round-trips through `object::frame_wrapper`. A non-widget table is still nil, documented.

**Not built, named rather than left to be discovered**: the binding half (`ClearBindings`,
`SetBindingClick`, …) are no-ops — no override-binding layer here to write into — so a wheel
opened with the mouse works and hold-a-key-and-release does not; and the `_onshow`/`_onhide`
attribute snippets are not run, since `SetAttribute` is a plain store with nothing to drive
them.

Templates in `assets/ui/CompatTemplates.xml`, listed on that wall with the reason: no stock
file to migrate from, and XML rather than `compat/` only because `inherits=` is loader-resolved.
A trap caught before shipping: `BackdropTemplateMixin.GetBackdropColor` forwarding to
`self:GetBackdropColor()` calls **itself** once `Mixin()` copies it onto the frame — the stock
getters answer, so there are no getter rows, and a test mixes in and reads back.

## `CreateAnimationGroup`: the surface, with nothing animated

1.12 has no animation system and this UI pass has no interpolation to hang one on. The cost is
the **absent constructor**: `Cinematic/Options.lua:7106` builds a rotating spinner and the nil
method aborted the whole options panel. Measured first — that is the addon's only animation in
55,661 lines, so this is a surface for a decoration.

**A non-looping group finishes the moment it plays**, firing `OnFinished`, because nothing
moves and that is the reading that keeps an addon's flow right; one that chains its next step
off `OnFinished` would otherwise wait forever. A looping group plays until stopped. Stated in
the header, since the name implies motion: a spinner does not spin, a fade appears at its
destination, a sequence lands on its last frame. Setters and getters round-trip.

Corpus: all 100 of grom's addons surveyed before and after — 85 ok / 1 fail, rosters
identical. benilla-ui 2081 passed. Full chain green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-11-01 17:12:13 -07:00
co-authored by Claude Opus 5
parent 64f07d9864
commit af823dd708
8 changed files with 895 additions and 3 deletions
@@ -55,7 +55,89 @@
</Backdrop>
</Frame>
<!--
The secure-handler family. On a modern client these exist so an *untainted* snippet may
call protected functions; 1.12 has neither taint nor protected functions, so what the
template encodes is a restriction that does not exist here and the frame itself carries no
behaviour. Empty, therefore — the header methods the snippets need (`SetFrameRef`,
`WrapScript`, `Execute`) are composed in `script/compat/classic_api/secure.rs`, beside the
rest of the compat surface.
Cinematic's two wheels (`MenuWheel.lua:312`, `MarkerWheel.lua:205`) name the first two.
-->
<Frame name="SecureHandlerBaseTemplate" virtual="true"/>
<Frame name="SecureHandlerShowHideTemplate" virtual="true" inherits="SecureHandlerBaseTemplate"/>
<Frame name="SecureHandlerAttributeTemplate" virtual="true" inherits="SecureHandlerBaseTemplate"/>
<Frame name="SecureHandlerClickTemplate" virtual="true" inherits="SecureHandlerBaseTemplate"/>
<Frame name="SecureHandlerStateTemplate" virtual="true" inherits="SecureHandlerBaseTemplate"/>
<Frame name="SecureHandlerEnterLeaveTemplate" virtual="true" inherits="SecureHandlerBaseTemplate"/>
<Frame name="SecureHandlerDragTemplate" virtual="true" inherits="SecureHandlerBaseTemplate"/>
<Frame name="SecureHandlerMouseUpDownTemplate" virtual="true" inherits="SecureHandlerBaseTemplate"/>
<!--
`SecureActionButtonTemplate`, and this one is **not** empty: its whole job is to read its
own attributes on click and act on them, and that dispatch is plain behaviour rather than
anything protected. `type="click"` clicks the frame named by `clickbutton`, which is how
Cinematic's wheels reach the game's own menu buttons (`MenuWheel.lua:357`), and the three
cast-ish types forward to the stock verbs. The taint machinery is what 1.12 has no
counterpart for, and none of this needs it.
`RegisterForClicks` is the caller's to set, as upstream: the wheels ask for `"AnyUp"`.
-->
<Button name="SecureActionButtonTemplate" virtual="true">
<Scripts>
<OnClick>BenillaSecureAction_OnClick(this, arg1);</OnClick>
</Scripts>
</Button>
<Script><![CDATA[
-- `SecureActionButtonTemplate`'s dispatch. `self:GetAttribute(k)` first tries the
-- button-specific key the real template prefers (`<type>-<button>`, then `*<key>*`), then
-- the plain one, which is the lookup order upstream documents.
local function attr(self, key, button)
local v
if button then
v = self:GetAttribute(key .. "-" .. button)
if v ~= nil then return v end
v = self:GetAttribute("*" .. key .. "-" .. button)
if v ~= nil then return v end
end
v = self:GetAttribute("*" .. key .. "*")
if v ~= nil then return v end
return self:GetAttribute(key)
end
function BenillaSecureAction_OnClick(self, button)
local kind = attr(self, "type", button)
if not kind or kind == "none" then return end
if kind == "click" then
local target = attr(self, "clickbutton", button)
if type(target) == "string" then target = getglobal(target) end
if target and target.Click then target:Click(button) end
elseif kind == "spell" then
local spell = attr(self, "spell", button)
if spell then
local unit = attr(self, "unit", button)
if unit then
CastSpellByName(spell, unit)
else
CastSpellByName(spell)
end
end
elseif kind == "macro" then
local text = attr(self, "macrotext", button)
if text then
RunScript(text)
else
local name = attr(self, "macro", button)
if name then RunMacro(name) end
end
elseif kind == "item" then
local item = attr(self, "item", button)
if item then UseItemByName(item, attr(self, "unit", button)) end
end
end
-- The mixin 9.0 shipped so addons could call the backdrop methods it had removed. Here
-- the stock methods are the real ones, so each row forwards; the table exists for the
-- `BackdropTemplateMixin and "BackdropTemplate"` test and for `Mixin()`.
@@ -0,0 +1,149 @@
//! `assets/ui/CompatTemplates.xml`: the ClassicAPI-era templates addons inherit by name. The
//! methods behind the secure family live in `benilla-ui`'s `compat/classic_api/secure.rs` and are
//! tested there; what needs this crate is the templates themselves, since `assets/ui` is ours.
use benilla_ui::script::UiScript;
use super::test_ui::load_ui as load_xml;
fn harness() -> UiScript {
let s = UiScript::new().unwrap();
load_xml(&s, "CompatTemplates.xml");
s
}
/// Ace3 names `BackdropTemplate` and `DialogBorderOpaqueTemplate` unguarded
/// (`AceGUIContainer-Frame.lua:179`, `AceConfigDialog-3.0.lua:573`), so a missing one raises
/// inside the library. `BackdropTemplate` is deliberately empty: 1.12's backdrop methods are
/// native, so a frame inheriting it already answers all four.
#[test]
fn the_backdrop_and_dialog_templates_resolve_and_the_mixin_is_announced() {
let s = harness();
s.run(
r#"
plain = CreateFrame("Frame", "BdPlain", nil, "BackdropTemplate")
bordered = CreateFrame("Frame", "BdBordered", nil, "DialogBorderOpaqueTemplate")
opaque = CreateFrame("Frame", "BdSoft", nil, "DialogBorderTemplate")
"#,
)
.unwrap();
// The idiom every 9.0-aware addon writes, and the reason the mixin table has to exist.
assert_eq!(
s.eval::<String>(r#"return BackdropTemplateMixin and "BackdropTemplate" or "nil""#)
.unwrap(),
"BackdropTemplate"
);
for m in ["SetBackdrop", "GetBackdrop", "SetBackdropColor"] {
assert_eq!(
s.eval::<String>(&format!("return type(BdPlain.{m})"))
.unwrap(),
"function",
"{m} is native on 1.12, so the template needs no row for it"
);
}
// The border carries art; the plain one is empty by design.
assert!(
s.eval::<bool>("return BdBordered:GetBackdrop() ~= nil")
.unwrap(),
"DialogBorderOpaqueTemplate draws a backdrop"
);
assert!(
s.eval::<bool>("return BdPlain:GetBackdrop() == nil")
.unwrap(),
"BackdropTemplate paints nothing of its own"
);
// `Mixin()` must not shadow the native methods with recursing forwards — there are no getter
// rows for exactly that reason, so a mixed-in frame still answers its own colour. Asserted on
// the bordered frame, because a colour on `BackdropTemplate` has no backdrop to tint: 1.12's
// setter needs one, so the empty template reads back the default.
s.run(
r#"BdBordered:SetBackdropColor(0.25, 0.5, 0.75, 1)
Mixin(BdBordered, BackdropTemplateMixin)"#,
)
.unwrap();
let (r, g, b) = s
.eval::<(f32, f32, f32)>("local r, g, b = BdBordered:GetBackdropColor() return r, g, b")
.unwrap();
// Within a byte: the colour round-trips through 8 bits per channel, as 1.12 stores a vertex
// colour, so 0.25 comes back as 64/255. What is being asserted is that it answers *its own*
// colour at all — a forwarding getter row would have recursed here instead.
let within_a_byte = |got: f32, want: f32| (got - want).abs() <= 1.0 / 255.0;
assert!(
within_a_byte(r, 0.25) && within_a_byte(g, 0.5) && within_a_byte(b, 0.75),
"got ({r}, {g}, {b})"
);
}
/// `SecureActionButtonTemplate`'s one behaviour that is plain dispatch rather than anything
/// protected: `type="click"` clicks the frame named by `clickbutton`. That is how Cinematic's
/// wheels reach the game's own menu buttons (`MenuWheel.lua:357`).
#[test]
fn a_secure_action_button_of_type_click_clicks_the_frame_it_names() {
let s = harness();
s.run(
r#"
clicked = 0
target = CreateFrame("Button", "ClickTarget")
ClickTarget:SetScript("OnClick", function() clicked = clicked + 1 end)
act = CreateFrame("Button", "ActBtn", nil, "SecureActionButtonTemplate")
ActBtn:SetAttribute("type", "click")
ActBtn:SetAttribute("clickbutton", ClickTarget)
"#,
)
.unwrap();
s.run("ActBtn:Click()").unwrap();
assert_eq!(s.eval::<i64>("return clicked").unwrap(), 1);
// A name string resolves through `_G`, which the real template accepts too.
s.run(r#"ActBtn:SetAttribute("clickbutton", "ClickTarget")"#)
.unwrap();
s.run("ActBtn:Click()").unwrap();
assert_eq!(s.eval::<i64>("return clicked").unwrap(), 2);
// No type at all is a no-op, not a raise: an unconfigured secure button is inert upstream.
s.run(r#"ActBtn:SetAttribute("type", nil)"#).unwrap();
s.run("ActBtn:Click()").unwrap();
assert_eq!(s.eval::<i64>("return clicked").unwrap(), 2);
// The per-button key wins over the plain one, which is the lookup order upstream documents
// and how a wheel gives each mouse button its own action.
s.run(
r#"other = CreateFrame("Button", "OtherTarget")
othered = 0
OtherTarget:SetScript("OnClick", function() othered = othered + 1 end)
ActBtn:SetAttribute("type", "click")
ActBtn:SetAttribute("clickbutton", ClickTarget)
ActBtn:SetAttribute("clickbutton-RightButton", OtherTarget)"#,
)
.unwrap();
s.run(r#"ActBtn:Click("RightButton")"#).unwrap();
assert_eq!(
(
s.eval::<i64>("return clicked").unwrap(),
s.eval::<i64>("return othered").unwrap()
),
(2, 1),
"the RightButton key took precedence over the plain clickbutton"
);
}
/// The whole secure-handler family resolves, so `CreateFrame` with any of them does not raise.
/// The list is what the corpus names plus the siblings of the same family, since an addon reaching
/// for one of these reaches for the others.
#[test]
fn every_secure_handler_template_resolves() {
let s = harness();
for t in [
"SecureHandlerBaseTemplate",
"SecureHandlerShowHideTemplate",
"SecureHandlerAttributeTemplate",
"SecureHandlerClickTemplate",
"SecureHandlerStateTemplate",
"SecureHandlerEnterLeaveTemplate",
"SecureHandlerDragTemplate",
"SecureHandlerMouseUpDownTemplate",
] {
s.run(&format!(r#"h = CreateFrame("Frame", nil, nil, "{t}")"#))
.unwrap_or_else(|e| panic!("{t}: {e}"));
}
}
+4
View File
@@ -857,6 +857,10 @@ mod money_frame_tests;
#[cfg(test)]
mod faux_scroll_tests;
/// `assets/ui/CompatTemplates.xml`: the ClassicAPI-era templates addons inherit by name.
#[cfg(test)]
mod compat_templates_tests;
/// The stock UIPanelTemplates and OptionsFrameTemplates kit, driven the way an addon drives it.
#[cfg(test)]
mod panel_template_tests;
@@ -0,0 +1,315 @@
//! `CreateAnimationGroup` and the animation objects under it: the surface, with **nothing
//! animated**.
//!
//! 1.12 has no animation system at all — it arrived in 3.x — and this client's UI pass has no
//! per-frame interpolation to hang one on. What it does have is a lot of addons written for a
//! later client that build a group at load, so the *absence of the constructor* is what costs:
//! `Cinematic/Options.lua:7106` builds a rotating spinner beside its "Add mode" button, and the
//! nil method aborted the options panel there.
//!
//! So the objects exist, their setters and getters round-trip, and the group's play state is
//! tracked honestly. The one thing to understand before trusting it:
//!
//! **A non-looping group finishes the moment it is played.** Nothing moves, so the animation's end
//! state is reached at once, and that is the interpretation that keeps an addon's *flow* correct:
//! a group whose `OnFinished` hands over to the next step would otherwise wait forever. A looping
//! group stays playing until `Stop`/`Finish`, since that is what a loop means and the common use
//! (a spinner while something is on) reads `IsPlaying` rather than waiting for an end.
//!
//! What that costs, plainly: a spinner does not spin, a fade appears instantly at its destination
//! rather than fading, and a staged sequence lands on its last frame immediately. No addon is
//! stuck, nothing raises, and nothing pretends to be moving. The step that would make these real
//! is a UI-pass animator plus `Texture:SetRotation` — both absent, and both bigger than this file.
use mlua::Lua;
use crate::script::{REG_FONTSTRING_METHODS, REG_FRAME_METHODS, REG_TEXTURE_METHODS};
use super::frame::delegate;
/// The constructor and the two object shapes, in Lua: these are plain tables with closures, not
/// widgets, so there is nothing for the arena to hold and nothing for the host to tick.
const ANIMATION: &str = r#"
return function(m)
-- One animation under a group. Every setter stores and every getter answers what was stored, so
-- an addon that writes a duration and reads it back sees its own number.
local function newAnimation(group, kind, name)
local a = {
__kind = kind,
__group = group,
__order = 1,
__duration = 0,
__startDelay = 0,
__endDelay = 0,
__smoothing = "NONE",
__scripts = {},
}
function a.GetObjectType(self) return "Animation" end
function a.IsObjectType(self, t) return t == "Animation" end
function a.GetRegionParent(self) return group.__owner end
function a.GetAnimationGroup(self) return group end
function a.SetDuration(self, d) self.__duration = d end
function a.GetDuration(self) return self.__duration end
function a.SetStartDelay(self, d) self.__startDelay = d end
function a.GetStartDelay(self) return self.__startDelay end
function a.SetEndDelay(self, d) self.__endDelay = d end
function a.GetEndDelay(self) return self.__endDelay end
function a.SetOrder(self, o) self.__order = o end
function a.GetOrder(self) return self.__order end
function a.SetSmoothing(self, s) self.__smoothing = s end
function a.GetSmoothing(self) return self.__smoothing end
function a.SetParent(self, p) self.__group = p end
-- Rotation
function a.SetDegrees(self, d) self.__degrees = d end
function a.GetDegrees(self) return self.__degrees end
function a.SetRadians(self, r) self.__radians = r end
function a.GetRadians(self) return self.__radians end
function a.SetOrigin(self, point, x, y)
self.__origin = { point = point, x = x, y = y }
end
function a.GetOrigin(self)
local o = self.__origin
if o then return o.point, o.x, o.y end
end
-- Alpha
function a.SetFromAlpha(self, v) self.__fromAlpha = v end
function a.GetFromAlpha(self) return self.__fromAlpha end
function a.SetToAlpha(self, v) self.__toAlpha = v end
function a.GetToAlpha(self) return self.__toAlpha end
function a.SetChange(self, v) self.__change = v end
function a.GetChange(self) return self.__change end
-- Scale and Translation
function a.SetScale(self, x, y) self.__scaleX, self.__scaleY = x, y end
function a.GetScale(self) return self.__scaleX, self.__scaleY end
function a.SetFromScale(self, x, y) self.__fromScaleX, self.__fromScaleY = x, y end
function a.GetFromScale(self) return self.__fromScaleX, self.__fromScaleY end
function a.SetToScale(self, x, y) self.__toScaleX, self.__toScaleY = x, y end
function a.GetToScale(self) return self.__toScaleX, self.__toScaleY end
function a.SetOffset(self, x, y) self.__offsetX, self.__offsetY = x, y end
function a.GetOffset(self) return self.__offsetX, self.__offsetY end
function a.SetScript(self, kind, fn) self.__scripts[kind] = fn end
function a.GetScript(self, kind) return self.__scripts[kind] end
function a.HookScript(self, kind, fn)
local prev = self.__scripts[kind]
self.__scripts[kind] = function()
if prev then prev(self) end
fn(self)
end
end
-- Per-animation play state, which an addon reads to decide whether to restart.
function a.Play(self) self.__playing = true end
function a.Stop(self) self.__playing = false end
function a.Pause(self) self.__playing = false end
function a.IsPlaying(self) return self.__playing and 1 or nil end
function a.IsDelaying(self) return nil end
function a.IsDone(self) return not self.__playing and 1 or nil end
function a.GetProgress(self) return self.__playing and 0 or 1 end
function a.GetElapsed(self) return 0 end
if name then setglobal(name, a) end
table.insert(group.__animations, a)
return a
end
local function fire(obj, kind)
local fn = obj.__scripts[kind]
if fn then
local ok, err = pcall(fn, obj)
if not ok then
local h = geterrorhandler and geterrorhandler()
if h then h(err) end
end
end
end
local function newGroup(owner, name)
local g = { __owner = owner, __animations = {}, __scripts = {}, __looping = "NONE" }
function g.GetObjectType(self) return "AnimationGroup" end
function g.IsObjectType(self, t) return t == "AnimationGroup" end
function g.GetParent(self) return self.__owner end
function g.CreateAnimation(self, kind, animName, template)
return newAnimation(self, kind, animName)
end
function g.GetAnimations(self) return unpack(self.__animations) end
function g.HasScript(self, kind)
return kind == "OnPlay" or kind == "OnStop" or kind == "OnFinished" or kind == "OnLoop"
end
function g.SetScript(self, kind, fn) self.__scripts[kind] = fn end
function g.GetScript(self, kind) return self.__scripts[kind] end
function g.HookScript(self, kind, fn)
local prev = self.__scripts[kind]
self.__scripts[kind] = function()
if prev then prev(self) end
fn(self)
end
end
function g.SetLooping(self, mode) self.__looping = mode or "NONE" end
function g.GetLooping(self) return self.__looping end
function g.GetDuration(self)
local longest = 0
for _, a in ipairs(self.__animations) do
local total = (a.__duration or 0) + (a.__startDelay or 0) + (a.__endDelay or 0)
if total > longest then longest = total end
end
return longest
end
function g.Play(self)
self.__playing = true
for _, a in ipairs(self.__animations) do a:Play() end
fire(self, "OnPlay")
-- Nothing animates, so a one-shot is over at once; see this module's header for why that
-- is the safe reading rather than leaving the group playing forever.
if self.__looping == "NONE" then
self:Finish()
end
end
function g.Stop(self)
self.__playing = false
for _, a in ipairs(self.__animations) do a:Stop() end
fire(self, "OnStop")
end
function g.Pause(self)
self.__paused = true
self.__playing = false
end
function g.Resume(self)
self.__paused = false
self:Play()
end
function g.Finish(self)
self.__playing = false
for _, a in ipairs(self.__animations) do a:Stop() end
fire(self, "OnFinished")
end
function g.Restart(self)
self:Stop()
self:Play()
end
function g.IsPlaying(self) return self.__playing and 1 or nil end
function g.IsPaused(self) return self.__paused and 1 or nil end
function g.IsDone(self) return not self.__playing and 1 or nil end
function g.IsPendingFinish(self) return nil end
function g.GetProgress(self) return self.__playing and 0 or 1 end
if name then setglobal(name, g) end
return g
end
function m.CreateAnimationGroup(self, name, template)
return newGroup(self, name)
end
end
"#;
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// On the frame table and both region leaves: a Texture owns a group as readily as a Frame
// does, and `Cinematic/Options.lua:7106` builds its spinner's group on a Texture.
for key in [
REG_FRAME_METHODS,
REG_TEXTURE_METHODS,
REG_FONTSTRING_METHODS,
] {
delegate(lua, key, ANIMATION)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
/// The shape `Cinematic/Options.lua:7106` builds: a group on a **Texture**, one Rotation
/// animation, looping. A looping group keeps playing, which is what its caller reads.
#[test]
fn a_looping_group_on_a_texture_keeps_playing_until_stopped() {
let s = UiScript::new().unwrap();
s.run(
r#"
f = CreateFrame("Frame", "AnimHost")
tex = AnimHost:CreateTexture(nil, "OVERLAY")
spin = tex:CreateAnimationGroup()
turn = spin:CreateAnimation("Rotation")
turn:SetDegrees(-360)
turn:SetDuration(1)
spin:SetLooping("REPEAT")
spin:Play()
"#,
)
.unwrap();
assert_eq!(s.eval::<u32>("return spin:IsPlaying()").unwrap(), 1);
assert_eq!(s.eval::<f64>("return turn:GetDegrees()").unwrap(), -360.0);
assert_eq!(s.eval::<f64>("return spin:GetDuration()").unwrap(), 1.0);
assert_eq!(
s.eval::<String>("return spin:GetLooping()").unwrap(),
"REPEAT"
);
s.run("spin:Stop()").unwrap();
assert!(s.eval::<bool>("return spin:IsPlaying() == nil").unwrap());
}
/// A **one-shot** group finishes the moment it plays, and `OnFinished` fires — the property
/// the header argues for, because an addon that chains its next step off `OnFinished` would
/// otherwise wait forever.
#[test]
fn a_one_shot_group_finishes_at_once_so_a_chain_keeps_moving() {
let s = UiScript::new().unwrap();
s.run(
r#"
log = ""
f = CreateFrame("Frame", "FadeHost")
fade = FadeHost:CreateAnimationGroup()
a = fade:CreateAnimation("Alpha")
a:SetFromAlpha(1) a:SetToAlpha(0) a:SetDuration(0.3)
fade:SetScript("OnPlay", function() log = log .. "play," end)
fade:SetScript("OnFinished", function() log = log .. "finished," end)
fade:Play()
"#,
)
.unwrap();
assert_eq!(s.eval::<String>("return log").unwrap(), "play,finished,");
assert!(s.eval::<bool>("return fade:IsPlaying() == nil").unwrap());
assert!(s.eval::<bool>("return fade:IsDone() == 1").unwrap());
// The setters round-trip, so an addon reading back its own numbers is not lied to.
assert_eq!(s.eval::<f64>("return a:GetToAlpha()").unwrap(), 0.0);
assert_eq!(s.eval::<f64>("return a:GetDuration()").unwrap(), 0.3);
}
/// `GetAnimations` returns them all, and a named animation publishes its global, as upstream's
/// constructors do — AceGUI-style code reaches for both.
#[test]
fn the_group_hands_back_its_animations_and_a_named_one_is_global() {
let s = UiScript::new().unwrap();
s.run(
r#"
f = CreateFrame("Frame", "MultiHost")
g = MultiHost:CreateAnimationGroup("MultiGroup")
one = g:CreateAnimation("Alpha", "MultiAlpha")
two = g:CreateAnimation("Scale")
n = 0
for _ in pairs({ g:GetAnimations() }) do n = n + 1 end
"#,
)
.unwrap();
assert_eq!(s.eval::<i64>("return n").unwrap(), 2);
assert!(s
.eval::<bool>("return MultiAlpha == one and MultiGroup == g")
.unwrap());
assert_eq!(
s.eval::<String>("return g:GetObjectType()").unwrap(),
"AnimationGroup"
);
assert_eq!(
s.eval::<String>("return one:GetObjectType()").unwrap(),
"Animation"
);
}
}
@@ -105,7 +105,18 @@ end
/// An attribute value as [`ScriptValue`], so no Lua handle is held past the call. The four scalar
/// kinds round-trip; anything else (a table, a function) stores as nil, since this crate keeps no
/// reference to a Lua object and a half-stored table would read back wrong.
fn attribute_value(v: &Value) -> ScriptValue {
/// One attribute's value as the store keeps it.
///
/// A **widget** table is kept as its arena id, because the attribute that matters most in this
/// family is `SecureActionButtonTemplate`'s `clickbutton`, which upstream is routinely a frame
/// rather than a name — `Cinematic/MenuWheel.lua` sets it that way, and a dropped one is a wheel
/// slot that clicks nothing at all. `ScriptValue::Frame` round-trips through
/// `object::frame_wrapper`, so `GetAttribute` hands back the same wrapper the caller passed.
///
/// Any other table is still `Nil`: upstream's store holds arbitrary values, this one holds what
/// [`ScriptValue`] can carry, and a plain data table has no representation here. Nothing in the
/// corpus stores one.
fn attribute_value(lua: &Lua, v: &Value) -> ScriptValue {
match v {
Value::Boolean(b) => ScriptValue::Bool(*b),
Value::Integer(i) => ScriptValue::Int(*i),
@@ -114,6 +125,13 @@ fn attribute_value(v: &Value) -> ScriptValue {
.to_str()
.map(|s| ScriptValue::Str(s.to_string()))
.unwrap_or(ScriptValue::Nil),
Value::Table(t) => crate::script::object::frame_handle_of(lua, t)
.ok()
.map(|h| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
ScriptValue::Frame(model.frame_id(h))
})
.unwrap_or(ScriptValue::Nil),
_ => ScriptValue::Nil,
}
}
@@ -121,7 +139,7 @@ fn attribute_value(v: &Value) -> ScriptValue {
/// Run one delegate chunk against one method table. The chunk returns a function rather than
/// reading `...`: this VM is the 1.12 client's Lua 5.0 (`script/lua50.rs`), where `...` is a
/// parameter-list token and never an expression.
fn delegate(lua: &Lua, key: &str, chunk: &str) -> mlua::Result<()> {
pub(super) fn delegate(lua: &Lua, key: &str, chunk: &str) -> mlua::Result<()> {
let m: Table = lua.named_registry_value(key)?;
let install: mlua::Function = lua.load(chunk).call(())?;
install.call::<()>(m)
@@ -338,7 +356,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
name,
lua.create_function(|lua, (this, key, value): (Table, String, Value)| {
let h = frame_handle_of(lua, &this)?;
let value = attribute_value(&value);
let value = attribute_value(lua, &value);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.frame_attributes
@@ -32,6 +32,7 @@ use mlua::Lua;
mod action;
mod addons;
mod animation;
mod callback_registry;
mod chat_bubbles;
mod chat_info;
@@ -76,6 +77,7 @@ mod player_info;
mod player_util;
mod pools;
mod quest_log;
mod secure;
mod spell;
mod spellbook;
mod state;
@@ -131,6 +133,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
g.set("C_Container", container::install(lua)?)?;
g.set("C_CreatureInfo", creature_info::install(lua)?)?;
g.set("C_CVar", cvar::install(lua)?)?;
cvar::install_globals(lua)?;
g.set("C_NamePlate", nameplate::install(lua)?)?;
g.set("C_NewItems", new_items::install(lua)?)?;
g.set("C_Macro", macro_ext::install(lua)?)?;
@@ -178,6 +181,11 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
merchant_frame::install_mail_globals(lua)?;
hooks::install(lua)?;
frame::install(lua)?;
// After `frame::install`: the secure-handler methods go onto the same frame method table and
// use its `delegate`, and nothing in them is read at load time.
secure::install(lua)?;
// Likewise: `CreateAnimationGroup` goes onto the frame table and both region leaves.
animation::install(lua)?;
tooltip::install(lua)?;
stdlib_backport::install(lua)?;
string_methods::install(lua)?;
@@ -0,0 +1,210 @@
//! The secure-handler methods the `SecureHandler*Template` frames answer, which
//! `assets/ui/CompatTemplates.xml` declares as empty frames.
//!
//! ## Why these are pass-throughs rather than a sandbox
//!
//! On a modern client a secure handler exists so a snippet may call *protected* functions from an
//! environment the addon cannot taint. 1.12 has neither taint nor protected functions: any code may
//! click anything, which is the whole reason `script/compat/classic_api/frame.rs` declined the
//! secure-template family as "modern restricted execution, which 1.12 has no equivalent of". The
//! conclusion drawn here is the other half of that sentence — the *restriction* has no equivalent,
//! so emulating it buys nothing, while the **plumbing** (a named frame store, a snippet attached to
//! a child's handler) is ordinary behaviour the snippets genuinely need. So the snippet body runs
//! as plain Lua with the names the secure environment would have bound.
//!
//! What that does and does not get right:
//!
//! - A snippet reading `self`, `owner`, `button`, `down`, calling methods on either frame, reading
//! and writing attributes, and returning `false` to suppress the wrapped handler: correct.
//! - The per-header **shared table** a snippet writes a bare global into (`held = false` in
//! `Cinematic/MenuWheel.lua:484`): correct, and per header, via `setfenv` onto one table per
//! frame whose `__index` is `_G` so the rest of the API still resolves.
//! - The **binding** half (`ClearBindings`, `SetBindingClick`, …): **no-ops**, because this client
//! has no override-binding layer for them to write into. A wheel opened with the mouse works; the
//! hold-a-key-and-release path does not, and that is the one piece of those two files still
//! missing. Named here rather than silently absent so the next pass knows where to start.
//! - The attribute-driven snippets (`_onshow`, `_onhide`, `_onattributechanged`): **not run**.
//! `SetAttribute` is a plain store here, so nothing drives them. The only shipped use in this
//! corpus is the `_onhide` above, whose body is the two lines already covered by the bullet
//! before this one.
//!
//! A snippet that raises does so inside a handler, where the error is collected like any other
//! rather than taking the frame down.
use mlua::Lua;
use crate::script::REG_FRAME_METHODS;
use super::frame::delegate;
/// The header methods, composed over the stock verbs plus one table per frame.
///
/// `loadstring` wraps the body in a named-parameter function rather than reading `...`: this VM is
/// 1.12's Lua 5.0, where `...` is a parameter-list token and never an expression, so a chunk
/// cannot read it. The parameters are then copied into the environment table as well, because a
/// snippet refers to them as globals (`owner:GetFrameRef("area")`), not as arguments.
const SECURE_HANDLER: &str = r#"
return function(m)
local function state(self)
local s = self.__benilla_secure
if not s then
s = { refs = {}, env = setmetatable({}, { __index = getfenv(0) }) }
self.__benilla_secure = s
end
return s
end
-- A body compiled once, at WrapScript time, as the secure environment compiles it.
local function snippet(body)
if type(body) ~= "string" then return nil end
local chunk = loadstring("return function() " .. body .. " end")
if not chunk then return nil end
local ok, fn = pcall(chunk)
if ok then return fn end
return nil
end
local function run(self, fn, this_frame, button, down)
local s = state(self)
local env = s.env
env.self, env.owner = this_frame, self
env.button, env.down = button, down
setfenv(fn, env)
local ok, kept = pcall(fn)
if not ok then
-- The snippet's own error, through the VM's handler as a handler's own error goes.
local h = geterrorhandler and geterrorhandler()
if h then h(kept) end
return nil
end
return kept
end
function m.SetFrameRef(self, name, frame)
state(self).refs[name] = frame
end
function m.GetFrameRef(self, name)
return state(self).refs[name]
end
function m.Execute(self, body)
local fn = snippet(body)
if fn then run(self, fn, self) end
end
function m.WrapScript(self, child, handler, pre, post)
local preFn, postFn = snippet(pre), snippet(post)
local original = child:GetScript(handler)
child:SetScript(handler, function()
local button, down = arg1, arg2
local allow = true
if preFn and run(self, preFn, child, button, down) == false then allow = false end
if allow and original then original() end
if postFn then run(self, postFn, child, button, down) end
end)
end
function m.UnwrapScript(self, child, handler)
child:SetScript(handler, nil)
end
-- The binding half: no override-binding layer here to write into, so these answer rather than
-- raise and the module header says what that costs.
function m.ClearBindings(self) end
function m.SetBinding(self, key, action) end
function m.SetBindingClick(self, key, name, button) end
function m.SetBindingSpell(self, key, spell) end
function m.SetBindingItem(self, key, item) end
function m.SetBindingMacro(self, key, macro) end
function m.ClearBinding(self, key) end
end
"#;
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
delegate(lua, REG_FRAME_METHODS, SECURE_HANDLER)
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
/// The shape both of Cinematic's wheels are built on: a header frame, a child button, a
/// snippet on the child's click that reaches the header's frame refs and can suppress the
/// handler it wrapped.
#[test]
fn a_wrapped_snippet_sees_owner_and_refs_and_can_suppress_the_original() {
let s = UiScript::new().unwrap();
s.run(
r#"
log = ""
-- A plain frame: the header methods sit on the frame method table, so the
-- `SecureHandler*Template` rows are only how an addon asks for them. The template's
-- own test lives beside `assets/ui` in benilla-app, which is what loads it.
header = CreateFrame("Frame", "SecHeader")
area = CreateFrame("Frame", "SecArea", SecHeader)
slot = CreateFrame("Button", "SecSlot", SecHeader)
SecHeader:SetFrameRef("area", SecArea)
SecSlot:SetScript("OnClick", function() log = log .. "original," end)
-- The snippet's own effect has to be a method call: a bare assignment inside it is
-- NOT a global — it lands in the header's shared table, asserted below.
SecHeader:WrapScript(SecSlot, "OnClick", [[
sawRef = owner:GetFrameRef("area") == SecArea
sawSelf = self
held = "kept"
owner:Hide()
]])
"#,
)
.unwrap();
s.run("SecSlot:Click()").unwrap();
// The snippet ran — its one externally visible act is `owner:Hide()` — and the handler it
// wrapped still ran after it.
assert!(
!s.eval::<bool>("return SecHeader:IsShown() and true or false")
.unwrap(),
"the snippet's owner:Hide() took effect"
);
assert_eq!(
s.eval::<String>("return log").unwrap(),
"original,",
"the wrapped handler still ran"
);
// `self` is the wrapped child and `owner` the header, as the secure environment binds
// them, and the ref came back through `owner`.
assert!(s
.eval::<bool>("return SecHeader.__benilla_secure.env.sawRef")
.unwrap());
assert!(s
.eval::<bool>("return SecHeader.__benilla_secure.env.sawSelf == SecSlot")
.unwrap());
// A bare assignment inside a snippet lands in the header's own table, not in `_G`: the
// per-header shared environment the secure one gives a snippet, which
// `Cinematic/MenuWheel.lua` relies on (`held = false`).
assert!(
s.eval::<bool>("return held == nil").unwrap(),
"not a global"
);
assert_eq!(
s.eval::<String>("return SecHeader.__benilla_secure.env.held")
.unwrap(),
"kept"
);
// `false` from the snippet suppresses the handler it wrapped.
s.run(
r#"log = ""
SecSlot:SetScript("OnClick", function() log = log .. "original," end)
SecHeader:WrapScript(SecSlot, "OnClick", [[ return false ]])"#,
)
.unwrap();
s.run("SecSlot:Click()").unwrap();
assert_eq!(
s.eval::<String>("return log").unwrap(),
"",
"returning false kept the wrapped handler from running"
);
}
}
@@ -290,6 +290,112 @@ const ALLOWED_BEYOND: &[(&str, &str, &str)] = &[
"ClassicAPI: the laid-out wrapped height, the other half of GetStringWidth's measure — \
AceGUI-3.0's Label and CheckBox call it unguarded",
),
(
"Execute",
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame",
"ClassicAPI (compat/classic_api/secure.rs, which has the whole reasoning): runs a snippet against the header's own shared environment. The \
family exists so an untainted snippet may call protected functions, and 1.12 has \
neither taint nor protected functions — the restriction has no counterpart here, the \
plumbing does",
),
(
"GetFrameRef",
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame",
"ClassicAPI (compat/classic_api/secure.rs, which has the whole reasoning): reads the named frame store. The \
family exists so an untainted snippet may call protected functions, and 1.12 has \
neither taint nor protected functions — the restriction has no counterpart here, the \
plumbing does",
),
(
"SetFrameRef",
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame",
"ClassicAPI (compat/classic_api/secure.rs, which has the whole reasoning): writes it; Cinematic's wheels pass their click-catcher this way (MenuWheel.lua:352). The \
family exists so an untainted snippet may call protected functions, and 1.12 has \
neither taint nor protected functions — the restriction has no counterpart here, the \
plumbing does",
),
(
"WrapScript",
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame",
"ClassicAPI (compat/classic_api/secure.rs, which has the whole reasoning): attaches a snippet to a child's handler, which may suppress it by returning false (MenuWheel.lua:398). The \
family exists so an untainted snippet may call protected functions, and 1.12 has \
neither taint nor protected functions — the restriction has no counterpart here, the \
plumbing does",
),
(
"UnwrapScript",
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame",
"ClassicAPI (compat/classic_api/secure.rs, which has the whole reasoning): the pair's other half. The \
family exists so an untainted snippet may call protected functions, and 1.12 has \
neither taint nor protected functions — the restriction has no counterpart here, the \
plumbing does",
),
(
"ClearBindings",
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame",
"ClassicAPI (compat/classic_api/secure.rs, which has the whole reasoning): the binding half, a NO-OP: no override-binding layer here to write into. The \
family exists so an untainted snippet may call protected functions, and 1.12 has \
neither taint nor protected functions — the restriction has no counterpart here, the \
plumbing does",
),
(
"ClearBinding",
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame",
"ClassicAPI (compat/classic_api/secure.rs, which has the whole reasoning): as ClearBindings. The \
family exists so an untainted snippet may call protected functions, and 1.12 has \
neither taint nor protected functions — the restriction has no counterpart here, the \
plumbing does",
),
(
"SetBinding",
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame",
"ClassicAPI (compat/classic_api/secure.rs, which has the whole reasoning): as ClearBindings. The \
family exists so an untainted snippet may call protected functions, and 1.12 has \
neither taint nor protected functions — the restriction has no counterpart here, the \
plumbing does",
),
(
"SetBindingClick",
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame",
"ClassicAPI (compat/classic_api/secure.rs, which has the whole reasoning): as ClearBindings. The \
family exists so an untainted snippet may call protected functions, and 1.12 has \
neither taint nor protected functions — the restriction has no counterpart here, the \
plumbing does",
),
(
"SetBindingSpell",
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame",
"ClassicAPI (compat/classic_api/secure.rs, which has the whole reasoning): as ClearBindings. The \
family exists so an untainted snippet may call protected functions, and 1.12 has \
neither taint nor protected functions — the restriction has no counterpart here, the \
plumbing does",
),
(
"SetBindingItem",
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame",
"ClassicAPI (compat/classic_api/secure.rs, which has the whole reasoning): as ClearBindings. The \
family exists so an untainted snippet may call protected functions, and 1.12 has \
neither taint nor protected functions — the restriction has no counterpart here, the \
plumbing does",
),
(
"SetBindingMacro",
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame",
"ClassicAPI (compat/classic_api/secure.rs, which has the whole reasoning): as ClearBindings. The \
family exists so an untainted snippet may call protected functions, and 1.12 has \
neither taint nor protected functions — the restriction has no counterpart here, the \
plumbing does",
),
(
"CreateAnimationGroup",
"Button CheckButton ColorSelect DressUpModel EditBox Frame GameTooltip LootButton MessageFrame Minimap Model MovieFrame PlayerModel ScrollFrame ScrollingMessageFrame SimpleHTML Slider StatusBar TabardModel WorldFrame FontString Texture",
"ClassicAPI (compat/classic_api/animation.rs, which states what it does and does not do): \
the animation SURFACE with nothing animated — 1.12 has no animation system and this \
client's UI pass has no interpolation to hang one on, so a non-looping group finishes \
the moment it plays and a looping one keeps playing. What it buys is the constructor: a \
nil method aborted Cinematic's whole options panel at Options.lua:7106, over one \
spinner. On both region leaves too, because that spinner's group is on a Texture",
),
(
"CanWordWrap",
"FontString",