the addon arc: the AddOns folder opens

- benilla loads real 1.12 addons from Interface/AddOns — TOC, XML and Lua through one loader, with per-addon saved variables, Bindings.xml and the lifecycle events addons expect
- both AddOns screens are in: the character-select list with per-addon enable state, and the ESC menu's in-game list
- the API surface is measured, not guessed — a corpus harness runs hundreds of real 1.12 addons and ranks exactly which globals, templates and verbs they demand
- the Lua dialect is the client's own, read off the binary — 5.0 semantics with the 1.12 additions, and UI source files are bytes, not UTF-8

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-11 18:18:31 +07:00
co-authored by Claude Fable 5
parent bfb3e3addc
commit 44ff435304
231 changed files with 31316 additions and 5594 deletions
@@ -214,7 +214,7 @@ fn shapeshift_and_container_cooldowns_keep_their_anchors_too() {
.eval::<bool>("local st = GetShapeshiftFormCooldown(1); return math.abs(st - 50) < 1e-3")
.unwrap());
assert!(s
.eval::<bool>("local st = C_Container.GetContainerItemCooldown(0, 1); return math.abs(st - 50) < 1e-3")
.eval::<bool>("local st = GetContainerItemCooldown(0, 1); return math.abs(st - 50) < 1e-3")
.unwrap());
// Re-push both with the SAME raw triples 5 s later: the anchors hold at 50.
@@ -225,6 +225,6 @@ fn shapeshift_and_container_cooldowns_keep_their_anchors_too() {
.eval::<bool>("local st = GetShapeshiftFormCooldown(1); return math.abs(st - 50) < 1e-3")
.unwrap());
assert!(s
.eval::<bool>("local st = C_Container.GetContainerItemCooldown(0, 1); return math.abs(st - 50) < 1e-3")
.eval::<bool>("local st = GetContainerItemCooldown(0, 1); return math.abs(st - 50) < 1e-3")
.unwrap());
}
@@ -0,0 +1,381 @@
//! `CreateFrame(kind, name, parent, inherits)` — the **runtime** template path
//! ([`crate::loader::apply_template`]).
//!
//! `local b = CreateFrame("Button", "MyButton", UIParent, "UIPanelButtonTemplate")` is the single
//! most common line in the addon corpus, and until the loader's post-`CreateFrame` steps were
//! factored into `Loader::decorate` the fourth argument was dropped on the floor: the addon got a
//! bare frame with no art, no regions, no scripts, and a warning on a channel it never reads.
//!
//! The load-bearing property every test here turns on is that **the caller's own name wins** — a
//! template instantiated as `Mine` publishes `MineTexture`, never `ThatTemplateTexture`, because
//! `getglobal("MineTexture")` is what the addon's next line calls.
use super::common::script;
use crate::script::UiScript;
/// Register templates by loading a small FrameXML document — the same door an addon's own `.xml`
/// comes through, so nothing here is a test-only shortcut into the registry.
fn register(s: &UiScript, xml: &str) {
let doc = crate::framexml::parse(xml).expect("valid FrameXML");
let report = crate::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"fixture document failed to load: {:?}",
report.errors
);
}
/// The whole shape at once: a virtual template's `<Size>`, `<Anchors>`, `<Layers>` texture and
/// `<Scripts><OnLoad>` all reach a frame built by `CreateFrame`'s fourth argument — and the region
/// is named against **the instance**, not the template.
#[test]
fn a_runtime_template_brings_size_anchors_regions_and_a_fired_onload() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
register(
&s,
r#"<Ui>
<Frame name="Host"/>
<Frame name="ProbeTemplate" virtual="true">
<Size><AbsDimension x="160" y="40"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parent" relativePoint="BOTTOMLEFT">
<Offset><AbsDimension x="7" y="-3"/></Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentTexture" file="Interface\Probe\Art"/>
</Layer>
</Layers>
<Scripts>
<OnLoad>ProbeLoadedAs = self:GetName(); ProbeLoadWidth = self:GetWidth()</OnLoad>
</Scripts>
</Frame>
</Ui>"#,
);
s.run(r#"Mine = CreateFrame("Frame", "Mine", Host, "ProbeTemplate")"#)
.unwrap();
assert_eq!(
s.eval::<(f32, f32)>("return Mine:GetWidth(), Mine:GetHeight()")
.unwrap(),
(160.0, 40.0),
"the template's <Size>"
);
// The template's own <Anchors> substitute `$parent` against the frame's PARENT (rf27) — the
// instance's enclosing frame, not the instance itself.
let (point, rel, rel_point, x, y): (String, String, String, f32, f32) = s
.eval(
"local p, r, rp, ox, oy = Mine:GetPoint(1) \
return p, r:GetName(), rp, ox, oy",
)
.unwrap();
assert_eq!(
(point.as_str(), rel.as_str(), rel_point.as_str(), x, y),
("TOPLEFT", "Host", "BOTTOMLEFT", 7.0, -3.0)
);
// The region carries the INSTANCE's name. This is the whole point: `MineTexture` is what an
// addon reaches for, and `ProbeTemplateTexture` would be useless to it.
assert!(
s.eval::<bool>(r#"return getglobal("MineTexture") ~= nil"#)
.unwrap(),
"the template's $parentTexture resolved against the caller's name"
);
assert!(
s.eval::<bool>(r#"return getglobal("ProbeTemplateTexture") == nil"#)
.unwrap(),
"nothing may be named after the template"
);
// OnLoad fired inside CreateFrame, after the frame was decorated — the reference's ordering,
// and what `local f = CreateFrame(...); f:Foo()` on the next line assumes.
assert_eq!(s.eval::<String>("return ProbeLoadedAs").unwrap(), "Mine");
assert_eq!(s.eval::<f32>("return ProbeLoadWidth").unwrap(), 160.0);
assert!(s.take_errors().is_empty());
assert!(s.take_warnings().is_empty());
}
/// A template's nested `<Frames>` child is named against the caller too, and is really parented to
/// the new frame — the `$parent` chain composes through `CreateFrame` exactly as through XML.
#[test]
fn a_nested_frames_child_is_named_against_the_caller() {
let mut s = script();
register(
&s,
r#"<Ui>
<Frame name="NestTemplate" virtual="true">
<Frames>
<Frame name="$parentInner">
<Size><AbsDimension x="11" y="12"/></Size>
<Frames>
<Frame name="$parentDeep"/>
</Frames>
</Frame>
</Frames>
</Frame>
</Ui>"#,
);
s.run(r#"Outer = CreateFrame("Frame", "Outer", nil, "NestTemplate")"#)
.unwrap();
assert_eq!(
s.eval::<String>("return OuterInner:GetParent():GetName()")
.unwrap(),
"Outer"
);
assert_eq!(
s.eval::<(f32, f32)>("return OuterInner:GetWidth(), OuterInner:GetHeight()")
.unwrap(),
(11.0, 12.0)
);
// Two levels deep, so the chain is composing rather than substituting once.
assert_eq!(
s.eval::<String>("return OuterInnerDeep:GetParent():GetName()")
.unwrap(),
"OuterInner"
);
assert!(s
.eval::<bool>(r#"return getglobal("NestTemplateInner") == nil"#)
.unwrap());
assert!(s.take_errors().is_empty());
assert!(s.take_warnings().is_empty());
}
/// `inherits="A, B"` is a LIST, and the runtime path resolves it through the very same
/// [`crate::framexml::expand`] the XML path uses — including chains, splice order and the cycle
/// guard, none of which is written twice.
#[test]
fn an_inherits_list_picks_up_every_template_in_it() {
let mut s = script();
register(
&s,
r#"<Ui>
<Frame name="TemplRoot" virtual="true" alpha="0.25">
<Size><AbsDimension x="50" y="60"/></Size>
</Frame>
<Frame name="TemplA" virtual="true" inherits="TemplRoot"/>
<Frame name="TemplB" virtual="true">
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentBTex" file="Interface\Probe\B"/>
</Layer>
</Layers>
</Frame>
</Ui>"#,
);
s.run(r#"Both = CreateFrame("Frame", "Both", nil, "TemplA, TemplB")"#)
.unwrap();
// From A (itself inheriting TemplRoot — the chain resolves).
assert_eq!(
s.eval::<(f32, f32)>("return Both:GetWidth(), Both:GetHeight()")
.unwrap(),
(50.0, 60.0)
);
assert_eq!(s.eval::<f32>("return Both:GetAlpha()").unwrap(), 0.25);
// From B.
assert!(s
.eval::<bool>(r#"return getglobal("BothBTex") ~= nil"#)
.unwrap());
assert!(s.take_errors().is_empty());
assert!(s.take_warnings().is_empty());
}
/// An unusable template name never takes the frame with it — the reference creates the frame
/// anyway, and an addon's next line already assumes it did. Both flavours of "unusable" are here:
/// a name nothing declared, and a name declared **without `virtual="true"`** (only virtual
/// elements are ever registered as templates, so from the registry's side the two look alike).
#[test]
fn an_unusable_template_still_returns_a_usable_frame_and_says_so() {
let mut s = script();
register(&s, r#"<Ui><Frame name="PlainFrame"/></Ui>"#);
for (frame, template) in [("Orphan", "NoSuchTemplate"), ("Plainer", "PlainFrame")] {
s.run(&format!(
r#"{frame} = CreateFrame("Button", "{frame}", nil, "{template}")
{frame}:SetWidth(5)
{frame}:Show()"#
))
.unwrap_or_else(|e| panic!("{frame} must be usable: {e}"));
assert_eq!(
s.eval::<String>(&format!("return {frame}:GetName()"))
.unwrap(),
frame
);
assert_eq!(
s.eval::<f32>(&format!("return {frame}:GetWidth()"))
.unwrap(),
5.0
);
let warnings = s.take_warnings();
assert!(
warnings
.iter()
.any(|w| w.contains(template) && w.contains(frame)),
"a warning must name both the template and the frame it was asked for: {warnings:?}"
);
assert!(s.take_errors().is_empty(), "a bad template is not an error");
}
}
/// The two shape mismatches, neither of which may panic or drop the frame.
///
/// A **region** template (a virtual `<Texture>`) asked for as a frame: its frame-shaped content
/// still lands, its region-only content cannot, and the message says which.
///
/// A **kind that disagrees with the template's tag**: the frame keeps the kind `CreateFrame` was
/// given — no template can retype a frame that already exists — so the `<Button>`-only parts of a
/// Button template simply do not apply to a Frame, and, crucially, are not *attempted* (calling
/// `SetNormalTexture` on a plain Frame would be an error, not a warning).
#[test]
fn a_region_template_or_a_mismatched_kind_is_named_never_fatal() {
let mut s = script();
register(
&s,
r#"<Ui>
<Texture name="RegionTemplate" virtual="true" file="Interface\Probe\Art">
<Size><AbsDimension x="33" y="44"/></Size>
</Texture>
<Button name="ProbeButtonTemplate" virtual="true">
<Size><AbsDimension x="90" y="22"/></Size>
<NormalTexture file="Interface\Probe\Normal"/>
<ButtonText name="$parentText"/>
</Button>
</Ui>"#,
);
// 1 · a region template on a frame.
s.run(r#"FromRegion = CreateFrame("Frame", "FromRegion", nil, "RegionTemplate")"#)
.unwrap();
assert_eq!(
s.eval::<(f32, f32)>("return FromRegion:GetWidth(), FromRegion:GetHeight()")
.unwrap(),
(33.0, 44.0),
"the frame-shaped half of a region template still applies"
);
let warnings = s.take_warnings();
assert!(
warnings
.iter()
.any(|w| w.contains("RegionTemplate") && w.contains("REGION template")),
"the region/frame mismatch must be named: {warnings:?}"
);
assert!(s.take_errors().is_empty());
// 2 · a Button template asked for as a Frame.
s.run(r#"AsFrame = CreateFrame("Frame", "AsFrame", nil, "ProbeButtonTemplate")"#)
.unwrap();
assert_eq!(
s.eval::<f32>("return AsFrame:GetWidth()").unwrap(),
90.0,
"the generic half of the template still applies"
);
assert!(
s.eval::<bool>(r#"return getglobal("AsFrameText") == nil"#)
.unwrap(),
"a Frame has no ButtonText slot, so nothing was invented for it"
);
let warnings = s.take_warnings();
assert!(
warnings
.iter()
.any(|w| w.contains("ProbeButtonTemplate") && w.contains("<Button>")),
"the kind mismatch must be named: {warnings:?}"
);
assert!(
s.take_errors().is_empty(),
"the Button-only steps are SKIPPED, not attempted and failed"
);
// 3 · the same template, asked for as the kind it was written as — the control.
s.run(r#"AsButton = CreateFrame("Button", "AsButton", nil, "ProbeButtonTemplate")"#)
.unwrap();
assert_eq!(s.eval::<f32>("return AsButton:GetWidth()").unwrap(), 90.0);
assert!(
s.eval::<bool>(r#"return getglobal("AsButtonText") ~= nil"#)
.unwrap(),
"the <ButtonText name=\"$parentText\"> label, named against the caller"
);
assert!(s.take_errors().is_empty());
assert!(
s.take_warnings().is_empty(),
"a matching kind has nothing to report"
);
}
/// **`<ScrollChild>` gives a ScrollFrame its range** — the loader element that was missing, and
/// the whole remaining distance for an addon's scrolling list (decision 1205).
///
/// wow-5875-re `rf28-typed-widget-loadxml.md`: the single child is instantiated via the same
/// RF-0026 path `<Frames>` uses, then stored as the scroll child. Without it a `ScrollFrame` has
/// nothing to pan, so `GetVerticalScrollRange()` is 0, `SetVerticalScroll` clamps to 0, and
/// `OnVerticalScroll` fires with 0 forever — the list never moves and nothing errors, which is why
/// no instrument could see it.
#[test]
fn a_scroll_child_element_gives_the_frame_a_real_scroll_range() {
let mut s = script();
s.set_screen_size(1024.0, 768.0);
register(
&s,
r#"<Ui>
<ScrollFrame name="Roller">
<Size><AbsDimension x="200" y="100"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<ScrollChild>
<Frame name="$parentChild">
<Size><AbsDimension x="200" y="400"/></Size>
</Frame>
</ScrollChild>
</ScrollFrame>
</Ui>"#,
);
s.resolve();
// The child exists, is named against the FRAME (rf27's `$parent`), and is the scroll child.
assert!(s.eval::<bool>("return RollerChild ~= nil").unwrap());
assert!(s
.eval::<bool>("return Roller:GetScrollChild() == RollerChild")
.unwrap());
// ...and the range is the overhang: a 400-tall child in a 100-tall window scrolls 300.
assert_eq!(
s.eval::<f64>("return Roller:GetVerticalScrollRange()")
.unwrap(),
300.0,
"no child means range 0, which is the silent failure this element fixes"
);
// The scroll actually takes, rather than clamping to zero.
s.run("Roller:SetVerticalScroll(120)").unwrap();
assert_eq!(
s.eval::<f64>("return Roller:GetVerticalScroll()").unwrap(),
120.0
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// An empty `<ScrollChild>` is an error — the reference's own behaviour (`rf28`), and the honest
/// one: a ScrollFrame that declares a child and has none is a typo, not a design.
#[test]
fn an_empty_scroll_child_is_reported() {
let s = script();
let doc = crate::framexml::parse(
r#"<Ui><ScrollFrame name="Hollow"><ScrollChild/></ScrollFrame></Ui>"#,
)
.expect("valid FrameXML");
let report = crate::loader::load(&s, &doc, &|_| None);
assert!(
report
.errors
.iter()
.any(|e| e.contains("<ScrollChild> is empty")),
"errors: {:?}",
report.errors
);
// ...and the frame still exists — the client logs and carries on (decision 0068).
assert!(s.eval::<bool>("return Hollow ~= nil").unwrap());
}
@@ -0,0 +1,716 @@
//! Font objects as first-class Lua objects ([`crate::script::font`]).
//!
//! Every test here is driven **the way the 218-addon corpus drives it** — the bare-global argument,
//! the no-arg `CreateFontString`, `CreateFont` — rather than the way our own shipped XML does,
//! because the string-name form our XML uses was the only one we accepted and it is the form
//! almost nobody writes (6 corpus sites against 3,180).
use super::common::script;
use crate::script::{FontObject, JustifyH, Outline, QuadContent, UiScript};
/// Load a FrameXML fragment through the real loader, asserting it reported no errors.
fn load(s: &UiScript, xml: &str) {
let doc = crate::framexml::parse(xml).expect("valid FrameXML");
let report = crate::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"loader errors: {:?}",
report.errors
);
}
/// The resolved `(face, height, colour)` of the one text quad on screen.
fn painted(s: &UiScript, text: &str) -> (Option<String>, Option<f32>, Option<[f32; 4]>) {
s.extract()
.into_iter()
.find_map(|q| match q.content {
QuadContent::Text {
text: Some(ref t),
ref font,
font_height,
color,
..
} if t == text => Some((font.clone(), font_height, color)),
_ => None,
})
.unwrap_or_else(|| panic!("no text quad reading {text:?}"))
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Publication
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// A `<Font name="X">` in FrameXML becomes the **global `X`**, an object of type `Font`.
///
/// This is the whole gap: we registered the resolved record and never named it, so every
/// `SetFontObject(GameFontNormal)` in the ecosystem was handed nil.
#[test]
fn a_declared_font_is_published_as_a_global_object() {
let s = script();
load(
&s,
r#"<Ui>
<Font name="GameFontNormal" font="Fonts\FRIZQT__.TTF" justifyH="LEFT">
<FontHeight><AbsValue val="12"/></FontHeight>
<Color r="1" g="0.82" b="0"/>
<Shadow><Offset><AbsDimension x="1" y="-1"/></Offset><Color r="0" g="0" b="0"/></Shadow>
</Font>
</Ui>"#,
);
assert_eq!(
s.eval::<String>("return GameFontNormal:GetObjectType()")
.unwrap(),
"Font"
);
assert!(s
.eval::<bool>("return GameFontNormal:IsObjectType('Font')")
.unwrap());
assert_eq!(
s.eval::<String>("return GameFontNormal:GetName()").unwrap(),
"GameFontNormal"
);
// Object identity is stable: two reads of the global are the same object, which is what makes
// `if fs:GetFontObject() == GameFontNormal` work at all.
assert!(s
.eval::<bool>("return GameFontNormal == GameFontNormal")
.unwrap());
// The declared paint reads back through the FontInstance getters.
assert_eq!(
s.eval::<(String, f32, String)>("return GameFontNormal:GetFont()")
.unwrap(),
("Fonts\\FRIZQT__.TTF".to_string(), 12.0, String::new())
);
let (r, g, b, _) = s
.eval::<(f32, f32, f32, f32)>("return GameFontNormal:GetTextColor()")
.unwrap();
assert_eq!((r, g, b), (1.0, 0.82, 0.0));
assert_eq!(
s.eval::<(f32, f32)>("return GameFontNormal:GetShadowOffset()")
.unwrap(),
(1.0, -1.0)
);
assert_eq!(
s.eval::<(f32, f32, f32, f32)>("return GameFontNormal:GetShadowColor()")
.unwrap(),
(0.0, 0.0, 0.0, 1.0)
);
assert_eq!(
s.eval::<String>("return GameFontNormal:GetJustifyH()")
.unwrap(),
"LEFT"
);
}
/// **`Tablet-2.0.lua:289`, verbatim.** `_, headerSize = GameTooltipHeaderText:GetFont()` — the
/// single most-called font-object read in the corpus (268 sites across the four embedded Ace
/// libraries). Until the global existed, Tablet fell to its hardcoded 14/12 guard branch.
#[test]
fn tablet_reads_the_tooltip_header_size_off_the_global() {
let s = script();
load(
&s,
r#"<Ui>
<Font name="GameTooltipText" font="Fonts\FRIZQT__.TTF">
<FontHeight><AbsValue val="12"/></FontHeight>
</Font>
<Font name="GameTooltipHeaderText" inherits="GameTooltipText">
<FontHeight><AbsValue val="14"/></FontHeight>
</Font>
</Ui>"#,
);
let (header, normal) = s
.eval::<(f32, f32)>(
r#"
local headerSize, normalSize
if GameTooltipHeaderText then
_, headerSize = GameTooltipHeaderText:GetFont()
else
headerSize = 14
end
if GameTooltipText then
_, normalSize = GameTooltipText:GetFont()
else
normalSize = 12
end
return headerSize, normalSize
"#,
)
.unwrap();
assert_eq!((header, normal), (14.0, 12.0));
}
/// **Requirement 5: publishing must not change what `inherits=` resolves to.** The chain is still
/// flattened once, at load, and the published object carries the *flattened* values — the derived
/// font sees the parent's face and its own height override.
#[test]
fn an_inheriting_font_still_flattens_to_the_same_values() {
let s = script();
load(
&s,
r#"<Ui>
<Font name="MasterFont" font="Fonts\FRIZQT__.TTF" justifyH="CENTER">
<FontHeight><AbsValue val="10"/></FontHeight>
<Color r="1" g="1" b="1"/>
<Shadow><Offset><AbsDimension x="1" y="-1"/></Offset><Color r="0" g="0" b="0"/></Shadow>
</Font>
<Font name="DerivedFont" inherits="MasterFont" outline="NORMAL">
<FontHeight><AbsValue val="18"/></FontHeight>
</Font>
</Ui>"#,
);
// The Rust-side record — unchanged by publication.
let derived = s.font_object("DerivedFont").expect("registered");
assert_eq!(derived.font.as_deref(), Some("Fonts\\FRIZQT__.TTF"));
assert_eq!(derived.height, Some(18.0));
assert_eq!(derived.color, Some([1.0, 1.0, 1.0, 1.0]));
assert_eq!(derived.outline, Outline::Normal);
assert_eq!(derived.justify_h, Some(JustifyH::Center));
assert!(derived.shadow.is_some(), "the shadow inherits too");
// …and the same values through the published object.
assert_eq!(
s.eval::<(String, f32, String)>("return DerivedFont:GetFont()")
.unwrap(),
(
"Fonts\\FRIZQT__.TTF".to_string(),
18.0,
"OUTLINE".to_string()
)
);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// SetFontObject / GetFontObject
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// **`Gratuity-2.0.lua:47-59`, verbatim** — the block that took five corpus addons down at load
/// with `bad argument #2: error converting Lua nil to String`.
///
/// Three separate things have to hold for it: `CreateFontString()` with **no arguments**,
/// `SetFontObject` taking the **object**, and `AddFontStrings` existing at all (fixing only the
/// first two moves the death one line down).
#[test]
fn gratuity_builds_its_thirty_line_scan_tooltip() {
let s = script();
load(
&s,
r#"<Ui>
<Font name="GameFontNormal" font="Fonts\FRIZQT__.TTF">
<FontHeight><AbsValue val="12"/></FontHeight>
<Color r="1" g="0.82" b="0"/>
</Font>
</Ui>"#,
);
s.run(
r#"
vars = { Llines = {}, Rlines = {} }
local tt = CreateFrame("GameTooltip")
vars.tooltip = tt
tt:SetOwner(tt, "ANCHOR_NONE")
for i = 1, 30 do
vars.Llines[i], vars.Rlines[i] = tt:CreateFontString(), tt:CreateFontString()
vars.Llines[i]:SetFontObject(GameFontNormal)
vars.Rlines[i]:SetFontObject(GameFontNormal)
tt:AddFontStrings(vars.Llines[i], vars.Rlines[i])
end
"#,
)
.expect("Gratuity's CreateTooltip must not raise");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The font object actually landed on the lines it made.
assert_eq!(
s.eval::<(String, f32)>("return vars.Llines[7]:GetFont()")
.unwrap(),
("Fonts\\FRIZQT__.TTF".to_string(), 12.0)
);
assert_eq!(
s.eval::<String>("return vars.Rlines[30]:GetFontObject():GetName()")
.unwrap(),
"GameFontNormal"
);
// And Gratuity's own next move — `ClearLines` over the grown stack — still works.
s.run("vars.tooltip:ClearLines()").unwrap();
}
/// The object form and the string form must resolve to **the same paint**. Both are kept: the
/// object is what 3,180 of 3,186 corpus sites pass, the string is what our own `assets/ui` and 6
/// corpus sites pass.
#[test]
fn set_font_object_takes_the_object_or_the_name() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.register_font_object(
"GameFontHighlightSmall",
FontObject {
font: Some("Fonts\\ARIALN.TTF".into()),
height: Some(11.0),
color: Some([0.25, 0.5, 0.75, 1.0]),
outline: Outline::Thick,
justify_h: None,
justify_v: None,
shadow: None,
},
);
s.run(
r#"
f = CreateFrame("Frame", "TwoWays")
f:SetWidth(200); f:SetHeight(40); f:SetPoint("CENTER")
byObject = f:CreateFontString(nil, "ARTWORK")
byObject:SetText("obj")
byObject:SetFontObject(GameFontHighlightSmall)
byName = f:CreateFontString(nil, "ARTWORK")
byName:SetText("str")
byName:SetFontObject("GameFontHighlightSmall")
"#,
)
.unwrap();
s.resolve();
assert_eq!(painted(&s, "obj"), painted(&s, "str"));
assert_eq!(
painted(&s, "obj"),
(
Some("Fonts\\ARIALN.TTF".into()),
Some(11.0),
Some([0.25, 0.5, 0.75, 1.0])
)
);
// …and both name the same object back.
assert!(s
.eval::<bool>("return byObject:GetFontObject() == byName:GetFontObject()")
.unwrap());
// nil is the THIRD form the reference's own usage string names
// (`SetFontObject(font or "font" or nil)`, `.rdata 0x87c5cc`): it severs the link and leaves
// the paint standing.
s.run("byName:SetFontObject(nil)").unwrap();
assert!(s
.eval::<bool>("return byName:GetFontObject() == nil")
.unwrap());
s.resolve();
assert_eq!(
painted(&s, "str").1,
Some(11.0),
"unlinking must not repaint"
);
// A frame or an unknown name is still an ERROR, never a silent no-op (1203/1205/1211's class).
assert!(s.run("byName:SetFontObject(f)").is_err());
assert!(s.run("byName:SetFontObject('NoSuchFont')").is_err());
}
/// `GetFontObject` returns the **object**, not a name — because `Dewdrop-2.0.lua:2181` indexes the
/// result immediately: `button.text:SetTextColor(button.text:GetFontObject():GetTextColor())`.
/// 65 sites across 62 corpus addons do exactly this.
#[test]
fn dewdrop_recolors_a_row_from_its_own_font_object() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.register_font_object(
"GameFontHighlightSmall",
FontObject {
font: Some("Fonts\\FRIZQT__.TTF".into()),
height: Some(10.0),
color: Some([1.0, 1.0, 1.0, 1.0]),
outline: Outline::None,
justify_h: None,
justify_v: None,
shadow: None,
},
);
s.run(
r#"
f = CreateFrame("Frame", "DdRow")
f:SetWidth(120); f:SetHeight(16); f:SetPoint("CENTER")
button = { text = f:CreateFontString(nil, "ARTWORK") }
button.text:SetText("row")
button.text:SetFontObject(GameFontHighlightSmall)
button.text:SetTextColor(button.text:GetFontObject():GetTextColor())
"#,
)
.expect("Dewdrop's row recolor must not raise");
s.resolve();
assert_eq!(painted(&s, "row").2, Some([1.0, 1.0, 1.0, 1.0]));
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Mutability — the decision, pinned
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// **The mutability decision.** A font object is a shared record with a **live** link: mutating it
/// re-paints every FontString that inherits it, which is why addons mutate one at all. What a
/// FontString set *for itself* since its own `SetFontObject` survives that re-paint — our
/// `FONTINSTANCE+0x038 explicitlySetMask`.
///
/// If this ever regresses to a one-shot copy, `inherited` stops moving and the test says so; if the
/// mask is dropped, `overridden` loses its red.
#[test]
fn mutating_a_font_object_repaints_everything_that_inherits_it() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.register_font_object(
"ThemeFont",
FontObject {
font: Some("Fonts\\FRIZQT__.TTF".into()),
height: Some(12.0),
color: Some([1.0, 1.0, 1.0, 1.0]),
outline: Outline::None,
justify_h: None,
justify_v: None,
shadow: None,
},
);
s.run(
r#"
f = CreateFrame("Frame", "ThemeHost")
f:SetWidth(200); f:SetHeight(60); f:SetPoint("CENTER")
a = f:CreateFontString(nil, "ARTWORK"); a:SetText("inherited")
a:SetFontObject(ThemeFont)
b = f:CreateFontString(nil, "ARTWORK"); b:SetText("overridden")
b:SetFontObject(ThemeFont)
b:SetTextColor(1, 0, 0)
"#,
)
.unwrap();
s.resolve();
assert_eq!(painted(&s, "inherited").1, Some(12.0));
assert_eq!(painted(&s, "overridden").2, Some([1.0, 0.0, 0.0, 1.0]));
// The mutation the whole feature exists for.
s.run(
r#"
ThemeFont:SetFont("Fonts\\MORPHEUS.TTF", 20)
ThemeFont:SetTextColor(0, 1, 0)
"#,
)
.unwrap();
s.resolve();
// Both followed the face and size…
assert_eq!(
painted(&s, "inherited"),
(
Some("Fonts\\MORPHEUS.TTF".into()),
Some(20.0),
Some([0.0, 1.0, 0.0, 1.0])
)
);
assert_eq!(
painted(&s, "overridden").0,
Some("Fonts\\MORPHEUS.TTF".into())
);
assert_eq!(painted(&s, "overridden").1, Some(20.0));
// …but the one that had set its own colour kept it.
assert_eq!(
painted(&s, "overridden").2,
Some([1.0, 0.0, 0.0, 1.0]),
"an explicitly-set property must survive a font-object mutation"
);
// §5-verified: severance SURVIVES a re-point. The reference's inheritMask bit (`+0x2c`,
// FontString `+0xd4`) is cleared by the local setter and never restored, so "a FontString that
// set its own colour stays severed even across a later SetFontObject". Our first cut reset the
// mask here and this assertion was its inverse.
s.run("b:SetFontObject(ThemeFont)").unwrap();
s.resolve();
assert_eq!(
painted(&s, "overridden").2,
Some([1.0, 0.0, 0.0, 1.0]),
"a re-point must not restore inheritance of a severed property"
);
// Dewdrop still works, because it re-reads the colour off the new object explicitly every
// refresh (`Dewdrop-2.0.lua:2181`) rather than relying on inheritance.
s.run("b:SetTextColor(b:GetFontObject():GetTextColor())")
.unwrap();
s.resolve();
assert_eq!(painted(&s, "overridden").2, Some([0.0, 1.0, 0.0, 1.0]));
}
/// A mutation of a font object nobody inherits touches nothing, and a FontString that inherits a
/// *different* object is not caught in the sweep.
#[test]
fn propagation_is_scoped_to_the_object_that_changed() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
for (name, face) in [("FontOne", "Fonts\\ONE.TTF"), ("FontTwo", "Fonts\\TWO.TTF")] {
s.register_font_object(
name,
FontObject {
font: Some(face.into()),
height: Some(12.0),
color: Some([1.0, 1.0, 1.0, 1.0]),
..FontObject::default()
},
);
}
s.run(
r#"
f = CreateFrame("Frame", "ScopeHost")
f:SetWidth(200); f:SetHeight(60); f:SetPoint("CENTER")
one = f:CreateFontString(nil, "ARTWORK"); one:SetText("one"); one:SetFontObject(FontOne)
two = f:CreateFontString(nil, "ARTWORK"); two:SetText("two"); two:SetFontObject(FontTwo)
FontOne:SetFont("Fonts\\CHANGED.TTF", 30)
"#,
)
.unwrap();
s.resolve();
assert_eq!(painted(&s, "one").0, Some("Fonts\\CHANGED.TTF".into()));
assert_eq!(painted(&s, "two").0, Some("Fonts\\TWO.TTF".into()));
assert_eq!(painted(&s, "two").1, Some(12.0));
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// CreateFont
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// **`_Nameplates.lua:149` + `:129` + `:212`, end to end** — mint a font object at runtime, set it
/// up, then paint a FontString with it. Plus `!OmniCC/main.lua:40-41`'s half: the name is published
/// as a global even when the return value is thrown away, and `SetFont`'s return is the font-file
/// validity probe OmniCC uses it as.
#[test]
fn create_font_mints_publishes_and_paints() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
Nameplate = { Font = CreateFont("_NameplatesNameplateFont") }
Nameplate.Font:SetFont("Fonts\\SKURRI.TTF", 10)
Nameplate.Font:SetTextColor(0, 0.5, 1)
f = CreateFrame("Frame", "PlateHost")
f:SetWidth(120); f:SetHeight(20); f:SetPoint("CENTER")
Name = f:CreateFontString(nil, "ARTWORK")
Name:SetText("plate")
Name:SetFontObject(Nameplate.Font)
"#,
)
.expect("_Nameplates' font setup must not raise");
s.resolve();
assert_eq!(
painted(&s, "plate"),
(
Some("Fonts\\SKURRI.TTF".into()),
Some(10.0),
Some([0.0, 0.5, 1.0, 1.0])
)
);
// The return value and the published global are the SAME object — OmniCC reads only the global.
assert!(s
.eval::<bool>("return _NameplatesNameplateFont == Nameplate.Font")
.unwrap());
assert_eq!(
s.eval::<String>("return _NameplatesNameplateFont:GetObjectType()")
.unwrap(),
"Font"
);
// `!OmniCC/main.lua:40-41` verbatim: create, discard the return, use the global, and branch on
// SetFont's return to detect a saved font path that is no longer usable.
let reverted = s
.eval::<bool>(
r#"
local reverted = false
if not OmniCCFont then
CreateFont("OmniCCFont")
if not OmniCCFont:SetFont("", 20) then
reverted = true
OmniCCFont:SetFont("Fonts\\FRIZQT__.TTF", 20)
end
end
return reverted
"#,
)
.unwrap();
assert!(reverted, "SetFont must report an unusable path as false");
assert_eq!(
s.eval::<(String, f32, String)>("return OmniCCFont:GetFont()")
.unwrap(),
("Fonts\\FRIZQT__.TTF".to_string(), 20.0, String::new())
);
// A name that already names a font object hands the existing one back UNCHANGED — the
// non-destructive reading (see `create_font`'s doc). The alternative would let
// `CreateFont("GameFontNormal")` blank the shipped registry entry the whole UI inherits.
s.run("again = CreateFont('_NameplatesNameplateFont')")
.unwrap();
assert!(s.eval::<bool>("return again == Nameplate.Font").unwrap());
assert_eq!(
s.eval::<(String, f32, String)>("return again:GetFont()")
.unwrap(),
("Fonts\\SKURRI.TTF".to_string(), 10.0, String::new())
);
// A nameless CreateFont is an error: the name IS the publication. The EMPTY name is not —
// the reference's `lua_isstring` gate takes it (unlike the XML path), so we do too.
assert!(s.run("CreateFont()").is_err());
s.run("CreateFont('')").unwrap();
}
/// **`FonzAppraiser/mods/gui/gui.lua:27-30`** — `CreateFont` then `CopyFontObject(<a shipped
/// object>)` then override the face. The corpus's only Font-on-Font call.
#[test]
fn fonz_appraiser_copies_a_shipped_object_then_overrides_the_face() {
let s = script();
s.register_font_object(
"GameFontHighlightSmall",
FontObject {
font: Some("Fonts\\FRIZQT__.TTF".into()),
height: Some(10.0),
color: Some([1.0, 1.0, 1.0, 1.0]),
outline: Outline::None,
justify_h: Some(JustifyH::Right),
justify_v: None,
shadow: None,
},
);
s.run(
r#"
small_number_font = CreateFont("FonzAppraiser_NumberFontNormalSmall")
small_number_font:CopyFontObject(GameFontHighlightSmall)
small_number_font:SetFont("Fonts\\ARIALN.TTF", 12)
"#,
)
.expect("FonzAppraiser's font setup must not raise");
// The copy brought the colour and justification over; the SetFont replaced the face and size.
assert_eq!(
s.eval::<(String, f32, String)>("return small_number_font:GetFont()")
.unwrap(),
("Fonts\\ARIALN.TTF".to_string(), 12.0, String::new())
);
assert_eq!(
s.eval::<(f32, f32, f32, f32)>("return small_number_font:GetTextColor()")
.unwrap(),
(1.0, 1.0, 1.0, 1.0)
);
assert_eq!(
s.eval::<String>("return small_number_font:GetJustifyH()")
.unwrap(),
"RIGHT"
);
// …and the object it copied FROM is untouched.
assert_eq!(
s.eval::<(String, f32, String)>("return GameFontHighlightSmall:GetFont()")
.unwrap(),
("Fonts\\FRIZQT__.TTF".to_string(), 10.0, String::new())
);
}
/// The `Spacing` pair is **deliberately absent** (we model no line spacing, and a stored-but-never
/// drawn setter is this codebase's recurring silent-drop bug). It must fail LOUDLY rather than
/// quietly accept a number nobody honours — this test is the tripwire on that choice, and it flips
/// the day spacing becomes real.
#[test]
fn the_unmodelled_spacing_pair_fails_loudly() {
let s = script();
s.register_font_object("SomeFont", FontObject::default());
assert!(s.run("SomeFont:SetSpacing(4)").is_err());
assert!(s.eval::<bool>("return SomeFont.SetSpacing == nil").unwrap());
}
/// A button's per-state label fonts take the object or the name too (the corpus splits 5 to 4
/// across the trio), and — because they are stored as NAMES and re-resolved at every extract — a
/// later mutation of that font object reaches the label with no further call.
#[test]
fn button_state_fonts_take_the_object_and_follow_its_mutation() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.register_font_object(
"GameFontNormal",
FontObject {
font: Some("Fonts\\FRIZQT__.TTF".into()),
height: Some(12.0),
color: Some([1.0, 0.82, 0.0, 1.0]),
..FontObject::default()
},
);
s.run(
r#"
b = CreateFrame("Button", "StateFontButton")
b:SetWidth(80); b:SetHeight(22); b:SetPoint("CENTER")
b:SetText("go")
b:SetTextFontObject(GameFontNormal)
"#,
)
.expect("the object form must be accepted");
s.resolve();
assert_eq!(painted(&s, "go").1, Some(12.0));
s.run("GameFontNormal:SetFont('Fonts\\\\MORPHEUS.TTF', 22)")
.unwrap();
s.resolve();
assert_eq!(
painted(&s, "go"),
(
Some("Fonts\\MORPHEUS.TTF".into()),
Some(22.0),
Some([1.0, 0.82, 0.0, 1.0])
)
);
// The string form still works alongside it.
s.run("b:SetHighlightFontObject('GameFontNormal')").unwrap();
}
/// **A `CreateFont` object holds nothing, so pointing a FontString at it copies nothing.**
/// §5-verified: the reference gates every merge on the source's own has-a-value mask, and a fresh
/// font has `mask == 0` — the FontString keeps exactly what it had, no blanking and no fallback to
/// a default. Our first cut wrote face/height through unconditionally and would have wiped it.
#[test]
fn an_empty_font_object_copies_nothing_onto_a_fontstring() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.register_font_object(
"DressedFont",
FontObject {
font: Some("Fonts\\FRIZQT__.TTF".into()),
height: Some(14.0),
color: Some([0.2, 0.4, 0.6, 1.0]),
..FontObject::default()
},
);
s.run(
r#"
f = CreateFrame("Frame", "EmptyFontHost")
f:SetWidth(120); f:SetHeight(20); f:SetPoint("CENTER")
fs = f:CreateFontString(nil, "ARTWORK")
fs:SetText("keep")
fs:SetFontObject(DressedFont)
blank = CreateFont("ABlankFont")
fs:SetFontObject(blank)
"#,
)
.unwrap();
s.resolve();
assert_eq!(
painted(&s, "keep"),
(
Some("Fonts\\FRIZQT__.TTF".into()),
Some(14.0),
Some([0.2, 0.4, 0.6, 1.0])
),
"an unset property must copy nothing, not blank the region"
);
// The link still moved, so once the blank font is dressed the string follows it.
assert_eq!(
s.eval::<String>("return fs:GetFontObject():GetName()")
.unwrap(),
"ABlankFont"
);
s.run("blank:SetFont('Fonts\\\\MORPHEUS.TTF', 22)").unwrap();
s.resolve();
assert_eq!(painted(&s, "keep").0, Some("Fonts\\MORPHEUS.TTF".into()));
}
/// `SetFont` returns **1 or nil**, not a boolean — the reference's exact return shape, and what
/// `!OmniCC/main.lua:41` branches on.
#[test]
fn set_font_returns_one_or_nil() {
let s = script();
s.run("f = CreateFont('ReturnShapeFont')").unwrap();
assert_eq!(
s.eval::<f32>("return f:SetFont('Fonts\\\\FRIZQT__.TTF', 12)")
.unwrap(),
1.0
);
assert!(s.eval::<bool>("return f:SetFont('', 12) == nil").unwrap());
}
+362
View File
@@ -261,3 +261,365 @@ fn hit_rect_insets_default_to_zero() {
assert_eq!((l, r, t, b), (0.0, 0.0, 0.0, 0.0), "no inset by default");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `EnableMouseWheel`/`IsMouseWheelEnabled` round-trip, and the two kinds born wheel-enabled
/// (decision 1198).
///
/// The flag is real and settable; **the dispatch is deliberately not gated on it yet** — see
/// `object::frame_state`'s note for the 44 shipped `OnMouseWheel` sites that declare no
/// `enableMouseWheel` and the concrete condition for flipping it. This test pins the flag's own
/// behaviour so that flip is a one-line change with a test already standing behind it.
#[test]
fn the_mouse_wheel_flag_round_trips_and_the_scrolling_kinds_are_born_enabled() {
let s = script();
s.run(
r#"
Plain = CreateFrame("Frame", "WheelPlain")
Scroll = CreateFrame("ScrollFrame", "WheelScroll")
Msg = CreateFrame("ScrollingMessageFrame", "WheelMsg")
"#,
)
.unwrap();
// A plain frame is born wheel-deaf, like WoW's own default.
assert!(!s
.eval::<bool>("return Plain:IsMouseWheelEnabled()")
.unwrap());
// ...and the two kinds whose ctor takes the wheel are born enabled, the same by-construction
// argument `mouse_enabled` already makes for a Button.
assert!(s
.eval::<bool>("return Scroll:IsMouseWheelEnabled()")
.unwrap());
assert!(s.eval::<bool>("return Msg:IsMouseWheelEnabled()").unwrap());
s.run("Plain:EnableMouseWheel(true)").unwrap();
assert!(s
.eval::<bool>("return Plain:IsMouseWheelEnabled()")
.unwrap());
s.run("Plain:EnableMouseWheel(false)").unwrap();
assert!(!s
.eval::<bool>("return Plain:IsMouseWheelEnabled()")
.unwrap());
// Lua truthiness, like every other flag binding: `EnableMouseWheel(1)` is on.
s.run("Plain:EnableMouseWheel(1)").unwrap();
assert!(s
.eval::<bool>("return Plain:IsMouseWheelEnabled()")
.unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A closed-vocabulary attribute survives stray whitespace (decision 1204).
///
/// The corpus case: `zBar.xml:146` — a shipped, working 1.12 addon — declares
/// `frameStrata="BACKGROUND "` with a trailing space. The real client took it; we refused, and the
/// refusal took the frame's whole `<Frames>` subtree with it, so the addon never loaded at all.
///
/// The three parsers are covered together because the next one to meet a stray space should not
/// need its own bug report.
#[test]
fn an_enum_attribute_tolerates_the_whitespace_a_real_addon_ships() {
let s = script();
s.run(
r#"
Padded = CreateFrame(" Frame ", "PaddedKind")
Padded:SetFrameStrata("BACKGROUND ")
Trailing = Padded:CreateTexture("PaddedTex", " OVERLAY")
"#,
)
.unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert_eq!(
s.eval::<String>("return Padded:GetFrameStrata()").unwrap(),
"BACKGROUND",
"the frame took the strata, and reports it in the canonical spelling"
);
// ...and the padded frame KIND resolved too — an unknown kind raises from CreateFrame, so
// reaching this line at all is the assertion.
assert!(s.eval::<bool>("return PaddedKind ~= nil").unwrap());
assert!(s.eval::<bool>("return PaddedTex ~= nil").unwrap());
}
// ── OnDoubleClick — the corpus's biggest script gap (250 sites / 85 addons) ───────────────────
//
// Every rule below is byte-verified, from the §5 cross-check this work dispatched into wow-re
// (`system/ui/scratch/button-doubleclick-law.md`): the interval is a hardcoded **300 ms**
// (`0x77937b cmp ecx, 0x12c`), the fire site is the mouse-**UP** dispatcher `0x7792d0` alone, the
// double leg **replaces** the second `OnClick` (`0x77939d jmp` past `call [edx+0x94]`), a completed
// double **zeroes** the stamp so clicks pair up, the detector is armed only when the frame carries
// an `OnDoubleClick` script (`[+0x4d4] != 0`), and it carries **no button identity** — what
// normally confines it to the left button is `RegisterForClicks`, not the detector.
/// Two fast clicks fire `OnDoubleClick(self, button)` — and the second `OnClick` does **not** fire,
/// because the two legs are exclusive. (This was the interim implementation's mistake: it fired
/// both, on the press edge, at 500 ms. All three were corrected at the bytes.)
#[test]
fn a_second_fast_click_fires_on_double_click_instead_of_the_second_on_click() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
clicks, doubles, dblbtn = 0, 0, nil
local b = CreateFrame("Button", "DblB")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(200, 200); b:EnableMouse(true)
b:SetScript("OnClick", function(self, button) clicks = clicks + 1 end)
b:SetScript("OnDoubleClick", function(self, button) doubles = doubles + 1 dblbtn = button end)
"#,
)
.unwrap();
s.resolve();
// Click one. Note the press alone does nothing: the fire site is the mouse-UP dispatcher.
s.mouse_button(50.0, 50.0, "LeftButton", true);
assert_eq!(
s.eval::<i64>("return clicks").unwrap(),
0,
"the default RegisterForClicks is LeftButtonUp — a press fires no click at all"
);
s.mouse_button(50.0, 50.0, "LeftButton", false);
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 1);
assert_eq!(
s.eval::<i64>("return doubles").unwrap(),
0,
"one click is not a double click"
);
// Click two, inside the 300 ms.
s.tick(0.1);
s.mouse_button(50.0, 50.0, "LeftButton", true);
s.mouse_button(50.0, 50.0, "LeftButton", false);
assert_eq!(
s.eval::<i64>("return doubles").unwrap(),
1,
"the second click inside the interval is a double click"
);
assert_eq!(
s.eval::<String>("return dblbtn").unwrap(),
"LeftButton",
"OnDoubleClick(self, button) — the same single button-name arg OnClick gets"
);
assert_eq!(
s.eval::<i64>("return clicks").unwrap(),
1,
"…and it REPLACED the second OnClick — the two legs are exclusive"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **The gate that keeps rapid clicking from breaking every button in the UI.** The binary's chain
/// tests `[+0x4d4] != 0`, so a widget with no `OnDoubleClick` script takes the single leg every
/// time. Without that test, the exclusivity above would silently swallow every second `OnClick` on
/// every frame in the game.
#[test]
fn a_frame_with_no_double_click_handler_keeps_every_rapid_on_click() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
clicks = 0
local b = CreateFrame("Button", "NoDbl")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(200, 200); b:EnableMouse(true)
b:SetScript("OnClick", function() clicks = clicks + 1 end)
"#,
)
.unwrap();
s.resolve();
for _ in 0..4 {
s.mouse_button(50.0, 50.0, "LeftButton", true);
s.mouse_button(50.0, 50.0, "LeftButton", false);
}
assert_eq!(
s.eval::<i64>("return clicks").unwrap(),
4,
"no OnDoubleClick script ⇒ four rapid clicks are four clicks"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The boundary: too slow, a different frame, and the PAIRING rule (a completed double zeroes the
/// stamp, so four rapid clicks read Click · Double · Click · Double — there is no triple-click and
/// no run of doubles).
#[test]
fn the_double_click_boundary_is_time_and_frame_and_clicks_pair_up() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
clicks, doubles = 0, 0
local function watch(f)
f:SetScript("OnClick", function() clicks = clicks + 1 end)
f:SetScript("OnDoubleClick", function() doubles = doubles + 1 end)
end
local a = CreateFrame("Button", "DblA")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(100, 100); a:EnableMouse(true); watch(a)
local b = CreateFrame("Button", "DblBB")
b:SetPoint("BOTTOMLEFT", 300, 0); b:SetSize(100, 100); b:EnableMouse(true); watch(b)
"#,
)
.unwrap();
s.resolve();
let click = |s: &mut crate::script::UiScript, x: f32| {
s.mouse_button(x, 50.0, "LeftButton", true);
s.mouse_button(x, 50.0, "LeftButton", false);
};
// (1) TOO SLOW — 0.35 s > 300 ms, so the second click is a fresh first half.
click(&mut s, 50.0);
s.tick(0.35);
click(&mut s, 50.0);
assert_eq!(s.eval::<i64>("return doubles").unwrap(), 0);
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 2);
// (2) A DIFFERENT FRAME — fast, but the timestamp lives on the WIDGET, so B has its own.
s.run("clicks, doubles = 0, 0").unwrap();
s.tick(1.0);
click(&mut s, 50.0);
click(&mut s, 350.0);
assert_eq!(s.eval::<i64>("return doubles").unwrap(), 0);
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 2);
// (3) PAIRING — four rapid clicks on one frame are Click · Double · Click · Double.
s.run("clicks, doubles = 0, 0").unwrap();
s.tick(1.0);
for _ in 0..4 {
click(&mut s, 50.0);
}
assert_eq!(
(
s.eval::<i64>("return clicks").unwrap(),
s.eval::<i64>("return doubles").unwrap()
),
(2, 2),
"clicks pair up — the completed double zeroes the stamp, so there is no triple-click"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **The detector carries no button identity** — the surprising half of the law, and the one an
/// implementation keyed by mouse button would get wrong. `[CButton+0x334]` is a bare timestamp, so
/// a widget registered for two `…Up` types completes a double click across them, with `arg1` = the
/// button of the SECOND click. A stock button never sees this only because `RegisterForClicks`
/// defaults to `{"LeftButtonUp"}`.
#[test]
fn a_multi_registered_button_pairs_a_left_click_with_a_right_one() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
doubles, dblbtn = 0, nil
local b = CreateFrame("Button", "MixDbl")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(200, 200); b:EnableMouse(true)
b:RegisterForClicks("LeftButtonUp", "RightButtonUp")
b:SetScript("OnDoubleClick", function(self, button) doubles = doubles + 1 dblbtn = button end)
"#,
)
.unwrap();
s.resolve();
s.mouse_button(50.0, 50.0, "LeftButton", true);
s.mouse_button(50.0, 50.0, "LeftButton", false);
s.tick(0.1);
s.mouse_button(50.0, 50.0, "RightButton", true);
s.mouse_button(50.0, 50.0, "RightButton", false);
assert_eq!(s.eval::<i64>("return doubles").unwrap(), 1);
assert_eq!(
s.eval::<String>("return dblbtn").unwrap(),
"RightButton",
"arg1 is the button of the completing click, not of the one that armed it"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **Nothing clears a half-finished double click** — not hide, not disable, not the cursor leaving
/// the window. `[+0x334]` has three writers image-wide and none of them is any of those, so this
/// pins the *absence* of the tidy-up that looks like it belongs in
/// [`crate::script::UiScript::pointer_left_window`].
#[test]
fn a_half_finished_double_click_survives_the_cursor_leaving_the_window() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
doubles = 0
local b = CreateFrame("Button", "LeaveDbl")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(200, 200); b:EnableMouse(true)
b:SetScript("OnDoubleClick", function() doubles = doubles + 1 end)
"#,
)
.unwrap();
s.resolve();
s.mouse_button(50.0, 50.0, "LeftButton", true);
s.mouse_button(50.0, 50.0, "LeftButton", false);
s.pointer_left_window();
s.tick(0.1);
s.mouse_button(50.0, 50.0, "LeftButton", true);
s.mouse_button(50.0, 50.0, "LeftButton", false);
assert_eq!(
s.eval::<i64>("return doubles").unwrap(),
1,
"the pointer left and came back inside 300 ms — the reference still pairs them"
);
}
/// The load-bearing regression guard for the whole job: accepting `OnDoubleClick` in
/// `SCRIPT_KINDS` without wiring the detector would make this test's `SetScript` succeed and its
/// handler never run — the silent-capability trap. Asserting the *fire* is what makes the row real.
#[test]
fn set_script_on_double_click_is_accepted_because_something_fires_it() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
ran = false
Btn = CreateFrame("Button", "DblAccepted")
Btn:SetPoint("BOTTOMLEFT", 0, 0); Btn:SetSize(100, 100); Btn:EnableMouse(true)
Btn:SetScript("OnDoubleClick", function() ran = true end)
"#,
)
.unwrap();
assert!(
s.eval::<bool>(r#"return Btn:GetScript("OnDoubleClick") ~= nil"#)
.unwrap(),
"SetScript stored it"
);
s.resolve();
for _ in 0..2 {
s.mouse_button(50.0, 50.0, "LeftButton", true);
s.mouse_button(50.0, 50.0, "LeftButton", false);
}
assert!(s.eval::<bool>("return ran").unwrap(), "…and it FIRED");
}
/// The script names that deliberately still raise, and why each is out (the reasons live at
/// `object::events_regions::set_script`). This is the other half of the rule "a name is accepted
/// only once something fires it": a future widening has to delete a line here, which is exactly
/// the moment to check something fires it.
#[test]
fn the_unfired_script_kinds_still_raise_rather_than_silently_accepting() {
let s = script();
s.run(r#"Raiser = CreateFrame("EditBox", "RaiserBox")"#)
.unwrap();
for name in [
// No keyboard index / `EnableKeyboard` yet (wow-re `scripts-auto-enable.md` kinds 0/1).
"OnKeyDown",
"OnKeyUp",
"OnChar",
// Caret geometry is host-side; its four float args would all be zero.
"OnCursorChanged",
// 2.0's secure-frame system — no such slot exists in any 1.12 resolver.
"OnAttributeChanged",
// Real 1.12 slots we do not fire, and zero corpus call sites.
"OnHorizontalScroll",
"OnHyperlinkEnter",
"OnMessageScrollChanged",
"OnUpdateModel",
"OnAnimFinished",
"OnInputLanguageChanged",
] {
let err = s
.run(&format!(r#"Raiser:SetScript("{name}", function() end)"#))
.unwrap_err();
assert!(
err.to_string().contains(name),
"{name} must raise by name, got {err}"
);
}
}
@@ -9,16 +9,21 @@ mod backdrop;
mod button;
mod common;
mod cooldown;
mod create_frame_template;
mod end_to_end;
mod events;
mod font_object;
mod frame_api;
mod input;
mod layout_gate;
mod measure;
mod minimap;
mod movable;
mod object_model;
mod reference_surface;
mod regions;
mod scrollframe;
mod size_changed;
mod slider;
mod statusbar;
mod stdlib;
@@ -29,5 +34,6 @@ mod tooltip;
mod tooltip_item;
mod tooltip_spell;
mod tooltip_unit;
mod toplevel;
mod visibility;
mod worldmap;
@@ -0,0 +1,350 @@
//! The movable-frame family — `SetMovable`/`StartMoving`/`StopMovingOrSizing`/`SetUserPlaced`/
//! `SetResizable` (the mechanism and its wow-re addresses are in `script::object::movable`).
//!
//! Every test here drives the PRODUCTION path: the Lua bindings, and the real
//! `mouse_button`/`mouse_move` entry points, so the drag gesture that fires `OnDragStart` is the
//! same one an addon gets.
use super::common::script;
use crate::script::UiScript;
/// A frame wired the canonical way, laid out at (100, 100) 200×80 on an 800×600 screen.
fn movable_panel() -> UiScript {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
starts, stops = 0, 0
Panel = CreateFrame("Frame", "MovePanel")
Panel:SetPoint("BOTTOMLEFT", 100, 100)
Panel:SetSize(200, 80)
Panel:EnableMouse(true)
Panel:SetMovable(true)
Panel:RegisterForDrag("LeftButton")
Panel:SetScript("OnDragStart", function() starts = starts + 1; this:StartMoving() end)
Panel:SetScript("OnDragStop", function() stops = stops + 1; this:StopMovingOrSizing() end)
"#,
)
.unwrap();
s.resolve();
s
}
/// The frame's resolved bottom-left corner, after a resolve.
fn corner(s: &mut UiScript) -> (f32, f32) {
s.resolve();
let left = s.eval::<f64>("return MovePanel:GetLeft()").unwrap() as f32;
let bottom = s.eval::<f64>("return MovePanel:GetBottom()").unwrap() as f32;
(left, bottom)
}
/// The whole point of the family: the four-line addon idiom actually moves the frame, and the
/// position it was dragged to SURVIVES the stop — nothing snaps it back to the `SetPoint` it was
/// born with, and nothing keeps moving it after the button is up.
#[test]
fn the_canonical_addon_idiom_moves_a_frame_and_the_position_survives_the_stop() {
let mut s = movable_panel();
assert_eq!(corner(&mut s), (100.0, 100.0), "born where SetPoint put it");
s.mouse_button(150.0, 140.0, "LeftButton", true); // press inside the panel
s.mouse_move(152.0, 140.0); // 2 px — under the drag threshold, nothing yet
assert_eq!(s.eval::<i64>("return starts").unwrap(), 0);
assert_eq!(corner(&mut s), (100.0, 100.0));
s.mouse_move(160.0, 140.0); // crosses the threshold ⇒ OnDragStart ⇒ StartMoving
assert_eq!(s.eval::<i64>("return starts").unwrap(), 1);
s.mouse_move(260.0, 190.0); // +100, +50 from the grab
assert_eq!(
corner(&mut s),
(200.0, 150.0),
"the frame follows the cursor delta since StartMoving"
);
s.mouse_button(260.0, 190.0, "LeftButton", false); // OnDragStop ⇒ StopMovingOrSizing
assert_eq!(s.eval::<i64>("return stops").unwrap(), 1);
assert_eq!(corner(&mut s), (200.0, 150.0), "the position survives");
// And the move is really over: further cursor motion moves nothing.
s.mouse_move(500.0, 400.0);
assert_eq!(corner(&mut s), (200.0, 150.0), "no longer following");
assert!(s.errors().is_empty(), "{:?}", s.errors());
// GetPoint reads back the frame's OWN point with the dragged offsets — the reference writes
// the anchors in place rather than rewriting them, so what an addon saves is what it set.
let (point, x, y) = s
.eval::<(String, f64, f64)>("local p, _, _, x, y = MovePanel:GetPoint() return p, x, y")
.unwrap();
assert_eq!((point.as_str(), x, y), ("BOTTOMLEFT", 200.0, 150.0));
}
/// A moving frame follows SUCCESSIVE moves, each one applying only that step's delta (the pump
/// re-centers its sample — a bug that integrated from the grab instead would double the second
/// step, and one that forgot to re-center would freeze after the first).
#[test]
fn a_moving_frame_follows_successive_mouse_moves() {
let mut s = movable_panel();
s.mouse_button(150.0, 140.0, "LeftButton", true);
s.mouse_move(160.0, 140.0); // the move that STARTS the drag
assert_eq!(
corner(&mut s),
(100.0, 100.0),
"the frame follows from where StartMoving was called, so the starting move moves nothing"
);
for (step, want) in [
((170.0, 140.0), (110.0, 100.0)),
((170.0, 160.0), (110.0, 120.0)),
((120.0, 160.0), (60.0, 120.0)), // back left of where it started
((120.0, 160.0), (60.0, 120.0)), // a zero-delta move changes nothing
] {
s.mouse_move(step.0, step.1);
assert_eq!(corner(&mut s), want, "after moving to {step:?}");
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `StartMoving` on a frame that is not `SetMovable(true)` RAISES and moves nothing — the
/// reference's own guard (`0x776700`'s movable-bit test). The error is a real Lua error, so an
/// addon's `pcall` sees it.
#[test]
fn start_moving_on_a_frame_that_is_not_movable_raises_and_moves_nothing() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
Fixed = CreateFrame("Frame", "FixedPanel")
Fixed:SetPoint("BOTTOMLEFT", 100, 100)
Fixed:SetSize(200, 80)
ok, err = pcall(function() Fixed:StartMoving() end)
"#,
)
.unwrap();
s.resolve();
assert!(!s.eval::<bool>("return FixedPanel:IsMovable()").unwrap());
assert!(!s.eval::<bool>("return ok").unwrap(), "StartMoving raised");
assert!(
s.eval::<String>("return tostring(err)")
.unwrap()
.contains("not movable"),
"the refusal names why"
);
// Nothing is in flight, so the cursor moves nothing.
s.mouse_move(400.0, 400.0);
s.resolve();
let left = s.eval::<f64>("return FixedPanel:GetLeft()").unwrap();
assert_eq!(left, 100.0, "a refused StartMoving started no move");
// The same frame, once made movable, moves — proving the guard was the only thing stopping it.
s.run("FixedPanel:SetMovable(true) FixedPanel:StartMoving()")
.unwrap();
s.mouse_move(450.0, 400.0);
s.resolve();
assert_eq!(s.eval::<f64>("return FixedPanel:GetLeft()").unwrap(), 150.0);
}
/// `StopMovingOrSizing` with nothing moving is harmless — the double call, the OnDragStop that
/// never had a StartMoving, the addon that also wires it to OnMouseUp. And it only stops the
/// frame that is actually in the drag slot (the reference's `[root+0xcfc] == self` compare), so
/// one frame's stop cannot end another's move.
#[test]
fn stop_moving_or_sizing_is_harmless_with_nothing_moving_and_stops_only_its_own_frame() {
let mut s = movable_panel();
s.run(
r#"
Other = CreateFrame("Frame", "OtherPanel")
Other:SetPoint("BOTTOMLEFT", 400, 400)
Other:SetSize(50, 50)
MovePanel:StopMovingOrSizing() -- nothing is moving
MovePanel:StopMovingOrSizing() -- twice
OtherPanel:StopMovingOrSizing()
"#,
)
.unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert_eq!(corner(&mut s), (100.0, 100.0), "nothing moved");
// Now start a real move and have the OTHER frame try to stop it.
s.run("MovePanel:StartMoving()").unwrap();
s.mouse_move(50.0, 50.0); // cursor_pos was (0,0) ⇒ +50, +50
assert_eq!(corner(&mut s), (150.0, 150.0));
s.run("OtherPanel:StopMovingOrSizing()").unwrap();
s.mouse_move(60.0, 50.0);
assert_eq!(
corner(&mut s),
(160.0, 150.0),
"another frame's stop does not end this move"
);
s.run("MovePanel:StopMovingOrSizing()").unwrap();
s.mouse_move(200.0, 200.0);
assert_eq!(corner(&mut s), (160.0, 150.0), "its own stop does");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A frame stretched between two anchors — no explicit size at all — moves RIGIDLY and keeps its
/// derived size. This is the case a single-point rewrite gets wrong (it would drop the opposing
/// anchor and leave the frame under-constrained, i.e. gone), and the case that decides between
/// translating one anchor and translating the set.
#[test]
fn a_frame_stretched_between_two_anchors_moves_rigidly() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
Back = CreateFrame("Frame", "StretchBack")
Back:SetPoint("BOTTOMLEFT", 0, 0); Back:SetSize(800, 600)
Stretch = CreateFrame("Frame", "StretchPanel", Back)
Stretch:SetPoint("BOTTOMLEFT", Back, "BOTTOMLEFT", 100, 100)
Stretch:SetPoint("TOPRIGHT", Back, "BOTTOMLEFT", 300, 200)
Stretch:SetMovable(true)
"#,
)
.unwrap();
s.resolve();
let size = s
.eval::<(f64, f64)>("return StretchPanel:GetWidth(), StretchPanel:GetHeight()")
.unwrap();
assert_eq!(size, (200.0, 100.0), "size derived from the two anchors");
s.run("StretchPanel:StartMoving()").unwrap();
s.mouse_move(30.0, 40.0); // cursor_pos starts at (0,0)
s.run("StretchPanel:StopMovingOrSizing()").unwrap();
s.resolve();
let left = s.eval::<f64>("return StretchPanel:GetLeft()").unwrap();
let bottom = s.eval::<f64>("return StretchPanel:GetBottom()").unwrap();
let size_after = s
.eval::<(f64, f64)>("return StretchPanel:GetWidth(), StretchPanel:GetHeight()")
.unwrap();
assert_eq!((left, bottom), (130.0, 140.0), "both anchors translated");
assert_eq!(size_after, (200.0, 100.0), "and the frame kept its size");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A SCALED frame moves with the cursor 1:1 on screen — the anchor offsets it writes are in local
/// units, so they carry the inverse of the frame's own scale (`geo_768710`'s `dx/scale`).
#[test]
fn a_scaled_frame_tracks_the_cursor_one_to_one_on_screen() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
Scaled = CreateFrame("Frame", "ScaledPanel")
Scaled:SetPoint("BOTTOMLEFT", 100, 100)
Scaled:SetSize(100, 100)
Scaled:SetScale(2)
Scaled:SetMovable(true)
Scaled:StartMoving()
"#,
)
.unwrap();
s.resolve();
// Local 100 at scale 2 ⇒ 200 screen px.
assert_eq!(
s.eval::<f64>("return ScaledPanel:GetLeft()").unwrap(),
100.0
);
s.mouse_move(40.0, 0.0);
s.resolve();
// GetLeft reports LOCAL units: 40 screen px is 20 local, on top of the local 100.
assert_eq!(
s.eval::<f64>("return ScaledPanel:GetLeft()").unwrap(),
120.0
);
let x = s
.eval::<f64>("local _, _, _, x = ScaledPanel:GetPoint() return x")
.unwrap();
assert_eq!(x, 120.0, "the anchor offset is local too");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The flags: default off, round-trip through their setters, and `SetUserPlaced`'s guard — the
/// reference refuses it unless the frame is movable OR resizable (`776adb: test ah,0x3`).
/// `StartMoving` sets the bit itself, which is the reference's drag-start doing it, not us.
#[test]
fn the_three_flags_default_off_round_trip_and_user_placed_is_guarded() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
F = CreateFrame("Frame", "FlagPanel")
F:SetPoint("BOTTOMLEFT", 10, 10); F:SetSize(50, 50)
"#,
)
.unwrap();
let all = |s: &mut UiScript| {
s.eval::<(bool, bool, bool)>(
"return FlagPanel:IsMovable(), FlagPanel:IsResizable(), FlagPanel:IsUserPlaced()",
)
.unwrap()
};
assert_eq!(
all(&mut s),
(false, false, false),
"no frame is born flagged"
);
// SetUserPlaced before either flag: refused, exactly like the reference.
assert!(
!s.eval::<bool>("return (pcall(function() FlagPanel:SetUserPlaced(true) end))")
.unwrap(),
"SetUserPlaced on a frame that is neither movable nor resizable raises"
);
assert!(!s.eval::<bool>("return FlagPanel:IsUserPlaced()").unwrap());
s.run("FlagPanel:SetResizable(true) FlagPanel:SetUserPlaced(true)")
.unwrap();
assert_eq!(
all(&mut s),
(false, true, true),
"resizable satisfies it too"
);
s.run("FlagPanel:SetUserPlaced(false) FlagPanel:SetResizable(false) FlagPanel:SetMovable(1)")
.unwrap();
assert_eq!(
all(&mut s),
(true, false, false),
"truthy 1 sets, false clears"
);
// The drag start sets userPlaced itself (`0x7652b0`).
s.resolve();
s.run("FlagPanel:StartMoving()").unwrap();
assert!(
s.eval::<bool>("return FlagPanel:IsUserPlaced()").unwrap(),
"StartMoving sets the userPlaced bit"
);
s.run("FlagPanel:StopMovingOrSizing()").unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `movable="true"` / `resizable="true"` in XML land on the real methods — they were a warn-once
/// gap in the loader until this family existed, which left every reference window authored
/// movable undraggable while `SetMovable` worked fine from Lua.
#[test]
fn the_xml_movable_and_resizable_attributes_reach_the_methods() {
let s = script();
let doc = crate::framexml::parse(
r#"<Ui>
<Frame name="XmlMovable" movable="true" resizable="true">
<Size><AbsDimension x="100" y="50"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="10" y="10"/></Offset></Anchor>
</Anchors>
</Frame>
</Ui>"#,
)
.expect("valid FrameXML");
let report = crate::loader::load(&s, &doc, &|_| None);
assert!(
!report
.warnings
.iter()
.any(|w| w.contains("SetMovable") || w.contains("SetResizable")),
"no gap warning any more: {:?}",
report.warnings
);
assert_eq!(
s.eval::<(bool, bool)>("return XmlMovable:IsMovable(), XmlMovable:IsResizable()")
.unwrap(),
(true, true)
);
}
@@ -0,0 +1,179 @@
//! **The 1.12 surface is the contract** (decisions 1188 §2, 1189) — enforced here rather than
//! trusted to memory.
//!
//! benilla targets the 1.12.1 API. Anything beyond it is a *listed, justified* exception, and the
//! point of this file is that the list cannot go unknown: every global our VM exposes must either
//! be one 1.12 has, or appear below with a reason. A session that adds an Era-shaped verb has to
//! delete or extend an assertion that says why not — which is exactly what did not happen when
//! 1187 shipped eight Era globals on Era's authority and 1189 had to take them back out.
//!
//! **Why a superset is not free.** Lua branches on presence. An addon writing `if strmatch then`
//! takes a path we cannot honour, and the failure surfaces far from the cause. Extra functions are
//! harmless only if nothing feature-detects, which is not true of this ecosystem.
//!
//! The reference side is `reference/1.12-globals.tsv` — the running 1.12.1 client's own in-world
//! `_G` (see `reference/README.md`). This test reads the `engine` and `lua` rows: the surface a VM
//! is responsible for. It deliberately does **not** assert the converse — that we have everything
//! 1.12 has — because that is a multi-year backlog, not a regression gate;
//! `scripts/api-coverage.sh` is where that number is read.
use std::collections::HashSet;
use super::common::script;
/// `reference/1.12-globals.tsv`, as `name -> origin`.
fn reference() -> Vec<(String, String)> {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../reference/1.12-globals.tsv"
);
let text = std::fs::read_to_string(path).unwrap_or_else(|e| {
panic!("reading {path}: {e} — regenerate with scripts/gen-reference-globals.py")
});
text.lines()
.filter(|l| !l.starts_with('#'))
.filter_map(|l| {
let mut f = l.split('\t');
Some((f.next()?.to_string(), f.nth(1)?.to_string()))
})
.collect()
}
/// Names benilla exposes that 1.12 does not, each with the reason it is allowed to stay.
///
/// **`Benilla*` and `__benilla_*` are covered by prefix**, not enumerated: the first is our host
/// bridge (verbs only our own transcribed FrameXML calls — a paperdoll model's facing, an item's
/// stat block) and the second is the tick's pushed state. Neither is an API-target claim, and
/// neither is reachable by accident from an addon that means to call a WoW function.
fn allowed_beyond_1_12() -> HashSet<&'static str> {
[
// ── our Lua runtime is 5.1 where 1.12's is 5.0 ────────────────────────────────────────
// `select` is load-bearing and stays on purpose — our transcribed FrameXML uses it in 16
// files as the 5.1 spelling of 5.0's implicit `arg` table.
//
// `_G` is the last one inherited rather than chosen: 1.12's base library does not export
// it (an addon reaches the globals with `getfenv(0)`, which is what AceLibrary does), but
// our own `getglobal`/`setglobal` are written over it. Closing it means rewriting those
// against the registry first.
//
// **This list has been shrinking as the dialect got measured**: `coroutine` left in 1194
// with the 5.1-only members of `string`/`table`/`math`; `print` and `_VERSION` left in
// 1197, when the RE dispatch read the base library's 36-entry array and neither was in it.
// The list only ever covered globals — the members needed `dump_globals --members` before
// anyone could see them at all.
"_G",
"select",
// ── WoW API past 1.12 — 1188 phase 5's list, and the reason that phase exists ─────────
// Every one of these predates 1189 and is used by our own transcribed FrameXML today.
// Resolving each means either replacing it with its 1.12 equivalent (`UnitPower` →
// `UnitMana`, which 1.12 has and we do not) or recording why it stays.
"CancelUnitBuff",
"GetCursorInfo",
"GetGossipQuestInfo",
"GetInventoryItemID",
"GetNumGossipQuests",
"GetPlayerFacing",
"GetTradePartnerName",
"IsGossipOptionCoded",
"SelectGossipQuest",
"SubmitChatInput",
"UnitAura",
"UnitIsAFK",
"UnitIsDND",
"strconcat",
"strjoin",
"strsplit",
"strtrim",
"tostringall",
"wipe",
]
.into_iter()
.collect()
}
/// Every global benilla's VM exposes is one 1.12 has, or a listed exception.
///
/// The failure message is the point: it names what is new and tells you the two ways out, so the
/// next session cannot resolve it by deleting a bare assertion.
#[test]
fn our_globals_stay_inside_the_1_12_surface() {
let reference = reference();
let known: HashSet<&str> = reference.iter().map(|(n, _)| n.as_str()).collect();
assert!(
known.len() > 19_000,
"the reference table looks truncated ({} names) — regenerate it",
known.len()
);
let allowed = allowed_beyond_1_12();
let ours: Vec<String> = script()
.eval(
"local out = {} \
for k in pairs(_G) do if type(k) == 'string' then table.insert(out, k) end end \
return out",
)
.expect("dump _G");
let mut unlisted: Vec<&str> = ours
.iter()
.map(String::as_str)
.filter(|n| !known.contains(n))
.filter(|n| !allowed.contains(n))
.filter(|n| !n.starts_with("Benilla") && !n.starts_with("__benilla_"))
.collect();
unlisted.sort_unstable();
assert!(
unlisted.is_empty(),
"benilla exposes {} global(s) the 1.12.1 client does not, and they are not listed as \
exceptions:\n {}\n\n\
1.12 is the target (decision 1188). Either give it its 1.12 spelling, or add it to \
`allowed_beyond_1_12` in this file WITH the reason it has to stay — an unexplained \
superset is what 1189 had to roll back.",
unlisted.len(),
unlisted.join(" ")
);
}
/// The exception list is exact — no entry outlives the global it excuses.
///
/// Without this, a removed global leaves its excuse behind, and the list slowly stops describing
/// the code. That is how a "listed, justified exception" decays into residue nobody can explain.
#[test]
fn the_exception_list_has_no_dead_entries() {
let known: HashSet<String> = reference().into_iter().map(|(n, _)| n).collect();
let ours: HashSet<String> = script()
.eval::<Vec<String>>(
"local out = {} \
for k in pairs(_G) do if type(k) == 'string' then table.insert(out, k) end end \
return out",
)
.expect("dump _G")
.into_iter()
.collect();
let mut dead: Vec<&str> = allowed_beyond_1_12()
.into_iter()
.filter(|n| !ours.contains(*n))
.collect();
dead.sort_unstable();
assert!(
dead.is_empty(),
"these are excused as beyond-1.12 but benilla no longer exposes them — drop them from \
`allowed_beyond_1_12`:\n {}",
dead.join(" ")
);
// An entry that 1.12 turns out to have is a different bug: the excuse is wrong, not stale.
let mut wrong: Vec<&str> = allowed_beyond_1_12()
.into_iter()
.filter(|n| known.contains(*n))
.collect();
wrong.sort_unstable();
assert!(
wrong.is_empty(),
"these are excused as beyond-1.12 but the 1.12.1 client DOES have them — they need no \
excuse:\n {}",
wrong.join(" ")
);
}
@@ -0,0 +1,161 @@
//! `OnSizeChanged` — the base-map layout script (`0x76a0d0` `+0x120`), fired from the resolve pass.
//!
//! Two things are being pinned here, and they are different in kind. The **gate** is byte-verified:
//! `ApplyRect 0x76b580` fires iff `|Δwidth| ≥ ε ∨ |Δheight| ≥ ε` with `ε = _DAT_008029d4`
//! ([`crate::layout::SIZE_EPS`]), which is why a frame that merely MOVES fires nothing. The
//! **seam** is ours: the client applies rects one at a time and fires per application, benilla
//! solves the whole graph to a fixpoint, so we fire on the entry-vs-convergence diff — one fire per
//! resolve per frame whose size actually moved, never one per solver round.
use super::common::script;
/// The size moves ⇒ `OnSizeChanged(self, width, height)` fires with the NEW size; a pure move fires
/// nothing (the byte-verified gate is on width/height, not on the rect).
#[test]
fn a_resize_fires_on_size_changed_and_a_move_does_not() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
fires, w, h = 0, nil, nil
Panel = CreateFrame("Frame", "SizedPanel")
Panel:SetPoint("BOTTOMLEFT", 100, 100)
Panel:SetSize(200, 50)
Panel:SetScript("OnSizeChanged", function(self, aw, ah)
fires = fires + 1 w = aw h = ah
end)
"#,
)
.unwrap();
// The FIRST layout is a change too: the client's cached rect starts zeroed, so its first
// ApplyRect is 0×0 → 200×50 and does fire.
s.resolve();
assert_eq!(s.eval::<i64>("return fires").unwrap(), 1);
assert_eq!(s.eval::<f64>("return w").unwrap(), 200.0);
assert_eq!(s.eval::<f64>("return h").unwrap(), 50.0);
// A second resolve with nothing touched fires nothing — the size did not move.
s.resolve();
assert_eq!(s.eval::<i64>("return fires").unwrap(), 1);
// A MOVE is not a resize.
s.run(r#"Panel:SetPoint("BOTTOMLEFT", 400, 300)"#).unwrap();
s.resolve();
assert_eq!(
s.eval::<i64>("return fires").unwrap(),
1,
"the rect moved but width/height did not — ApplyRect's gate is on the size alone"
);
// A real resize does.
s.run("Panel:SetHeight(120)").unwrap();
s.resolve();
assert_eq!(s.eval::<i64>("return fires").unwrap(), 2);
assert_eq!(s.eval::<f64>("return w").unwrap(), 200.0);
assert_eq!(s.eval::<f64>("return h").unwrap(), 120.0);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// It fires for a size the frame did not set on itself — the case the corpus actually uses it for
/// (`MoveAnything`, `CleanMinimap`, `FuBar`, `Outfitter`, `AckisRecipeList` all watch a frame whose
/// size is decided by something else). Here the child is anchored to two edges of a parent that
/// resizes, so its own `LayoutInput` never changes at all and only the SOLVE knows it moved.
#[test]
fn an_anchor_driven_resize_fires_it_too() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
fires, w = 0, nil
Parent = CreateFrame("Frame", "SizeParent")
Parent:SetPoint("BOTTOMLEFT", 0, 0)
Parent:SetSize(300, 200)
Child = CreateFrame("Frame", "SizeChild", Parent)
Child:SetPoint("BOTTOMLEFT", Parent, "BOTTOMLEFT", 0, 0)
Child:SetPoint("TOPRIGHT", Parent, "TOPRIGHT", 0, 0)
Child:SetScript("OnSizeChanged", function(self, aw, ah) fires = fires + 1 w = aw end)
"#,
)
.unwrap();
s.resolve();
assert_eq!(s.eval::<f64>("return w").unwrap(), 300.0);
let first = s.eval::<i64>("return fires").unwrap();
s.run("Parent:SetWidth(500)").unwrap();
s.resolve();
assert_eq!(
s.eval::<i64>("return fires").unwrap(),
first + 1,
"the child's own inputs never changed — only the parent's rect did"
);
assert_eq!(s.eval::<f64>("return w").unwrap(), 500.0);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **The loop guard.** A handler that resizes its own frame is the classic `OnSizeChanged` hazard,
/// and it must not spin the engine. The drain takes ONE batch per resolve
/// ([`crate::script::event::fire_size_changes`]), so a handler that settles on a constant size
/// settles — one extra fire for the size it forced, then silence — and one that never settles costs
/// one fire per resolve rather than hanging inside a single call.
#[test]
fn a_handler_that_resizes_its_own_frame_settles_instead_of_spinning() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
fires = 0
Sq = CreateFrame("Frame", "SquarePanel")
Sq:SetPoint("BOTTOMLEFT", 0, 0)
Sq:SetSize(200, 50)
-- The idiom: "keep me square". It writes back into the very input that fired it.
Sq:SetScript("OnSizeChanged", function(self, aw, ah)
fires = fires + 1
if ah ~= aw then self:SetHeight(aw) end
end)
"#,
)
.unwrap();
// Resolve returns (does not hang) even though the handler dirties the layout from inside it.
s.resolve();
assert_eq!(s.eval::<i64>("return fires").unwrap(), 1);
// The next resolve sees the handler's own 200×200 and fires once for it…
s.resolve();
assert_eq!(s.eval::<i64>("return fires").unwrap(), 2);
assert_eq!(s.eval::<f64>("return Sq:GetHeight()").unwrap(), 200.0);
// …and then it is stable: no further fires, however many passes run.
for _ in 0..5 {
s.resolve();
}
assert_eq!(
s.eval::<i64>("return fires").unwrap(),
2,
"a settling handler settles — the fixpoint is reached, not re-entered forever"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The regression guard that makes the `SCRIPT_KINDS` row real: `SetScript("OnSizeChanged", …)`
/// stopped raising, and what replaced the error is a handler that actually runs — not a stored
/// closure nobody ever calls.
#[test]
fn set_script_on_size_changed_is_accepted_because_the_resolve_pass_fires_it() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
ran = false
Accepted = CreateFrame("Frame", "SizeAccepted")
Accepted:SetPoint("BOTTOMLEFT", 0, 0)
Accepted:SetSize(10, 10)
Accepted:SetScript("OnSizeChanged", function() ran = true end)
"#,
)
.unwrap();
assert!(s
.eval::<bool>(r#"return Accepted:GetScript("OnSizeChanged") ~= nil"#)
.unwrap());
s.resolve();
assert!(s.eval::<bool>("return ran").unwrap(), "…and it FIRED");
}
@@ -126,3 +126,88 @@ fn gettime_starts_at_zero_and_tracks_tick() {
let t = s.eval::<f64>("return GetTime()").unwrap();
assert!((t - 0.5).abs() < 1e-6, "two 0.25s ticks = 0.5 (got {t})");
}
/// The rest of the bare globals Blizzard's own restricted scope enumerates (decision 1187).
///
/// The list is not from memory: `Blizzard_RestrictedAddOnEnvironment/RestrictedEnvironment.lua`
/// copies exactly these out of the engine, which is what distinguishes them from the table helpers
/// (`tContains`, `tInvert`, …) that FrameXML defines in Lua and we must therefore NOT supply.
///
/// The measured cost of their absence: Bagnon + BagBrother produced 94 load failures, 81 of them
/// `Cannot find a library instance of "…"`, because `strmatch` was nil so every LibStub-registering
/// library aborted before it could register.
#[test]
fn the_rest_of_the_bare_globals() {
let s = script();
s.run(
r#"
-- string family
assert(strbyte("A") == 65 and strchar(65) == "A")
-- and the Era-only names 1187 added are GONE (decision 1189): the 5.0 client has no
-- string.match/gmatch, and claiming otherwise misleads an addon that feature-detects.
assert(strmatch == nil and gmatch == nil and strrev == nil)
assert(strlenutf8 == nil and strcmputf8i == nil)
assert(securecall == nil and hooksecurefunc == nil and issecure == nil)
-- math family
assert(exp(0) == 1 and log(1) == 0 and log10(100) == 2)
assert(frexp(8) == 0.5 and ldexp(0.5, 4) == 8)
-- the trig globals are DEGREE-based, inverses included — same family as the verified
-- sin/cos, and what every addon rotation helper assumes.
assert(math.abs(tan(45) - 1) < 1e-9)
assert(math.abs(asin(1) - 90) < 1e-9)
assert(math.abs(acos(1)) < 1e-9)
assert(math.abs(atan(1) - 45) < 1e-9)
assert(math.abs(atan2(1, 0) - 90) < 1e-9)
"#,
)
.unwrap();
}
/// **The Lua 5.0 dialect a vanilla addon is written in runs on our 5.1 VM** — measured, because
/// this question has been answered three different ways from memory.
///
/// 1.12 runs Lua 5.0 (byte-confirmed in wow-5875-re: `0x811b30 = "Lua: Lua 5.0 Copyright..."`);
/// we run 5.1 via mlua's `lua51`. Decision 1188 called that "the deepest divergence and it is
/// unresolved" and told the next session to test it; 1189 replied that 0068 had already closed it.
/// Meanwhile five of our own transcribed FrameXML files carried the opposite claim in a comment —
/// *"`LUA_COMPAT_VARARG` isn't shipped"* — each citing the others as precedent, so a false fact
/// propagated by citation without anyone re-running it.
///
/// It is shipped. This asserts the five idioms vanilla addon code actually uses, so the next
/// session reads a result instead of a recollection. **The implicit `arg` table is the one that
/// matters**: it is the difference between a vanilla addon's vararg functions working and every
/// one of them erroring at runtime, which is 1188's stated redirect-the-arc risk.
#[test]
fn the_lua_5_0_dialect_vanilla_addons_are_written_in_runs_here() {
let s = script();
s.run(
r##"
-- The implicit vararg table, 5.0's spelling of what 5.1 does with `...`.
local function varargs(...) return arg.n, arg[1], arg[2] end
local n, first, second = varargs("a", "b")
assert(n == 2 and first == "a" and second == "b")
-- **The one real edge**, and it is compat mode's rule rather than a gap: `arg` is
-- synthesized only for a vararg function that does NOT also mention `...` in its body.
-- Use both spellings in one function and `arg` is nil. Vanilla addon code is uniformly
-- 5.0 and never mixes them, so this costs an addon nothing — it only means one of OUR
-- transcriptions must pick a spelling per function and stay with it.
local function mixed(...) return arg == nil and select("#", ...) or -1 end
assert(mixed(1, 2, 3) == 3)
-- `...` alone is the 5.1 spelling and is unaffected.
local function modern(...) return select("#", ...) end
assert(modern("x", "y") == 2)
-- 5.0's table/string/math spellings, all of which 5.1 renamed.
assert(table.getn({ 1, 2, 3 }) == 3)
assert(string.gfind ~= nil) -- 5.1 renamed this to string.gmatch
assert(math.mod(7, 3) == 1)
assert(7 % 3 == 1) -- and the 5.1 operator 5.0 lacks also works
"##,
)
.unwrap();
}
@@ -71,8 +71,8 @@ fn set_tex_coord_changes_extracted_uv() {
}
/// `SetFontObject("Name")` copies a registered [`FontObject`]'s face/height/color onto the
/// FontString; `GetFontObject` reads the name back; a later call re-points it (the resolved paint on
/// the extracted quad follows). An unknown name errors.
/// FontString; `GetFontObject` hands back the OBJECT (whose `GetName` round-trips); a later call
/// re-points it (the resolved paint on the extracted quad follows). An unknown name errors.
#[test]
fn set_font_object_repoints_fontstring() {
let mut s = script();
@@ -112,7 +112,8 @@ fn set_font_object_repoints_fontstring() {
)
.unwrap();
assert_eq!(
s.eval::<String>("return fs:GetFontObject()").unwrap(),
s.eval::<String>("return fs:GetFontObject():GetName()")
.unwrap(),
"Big"
);
@@ -188,7 +189,16 @@ fn justify_v_defaults_middle_and_overrides() {
s.resolve();
assert_eq!(justify_v(&s), JustifyV::Bottom);
// A font object carrying justify_v re-points it.
// A font object carrying justify_v applies it — but ONLY on the axis this string has not
// already claimed for itself. `SetJustifyV` above severed the V axis, and §5-verified that
// severance is permanent: the reference clears the axis's inheritMask bit (`+0x124`, the
// per-axis justify mask — wow-re `system/ui/scratch/font-object-lua-surface.md`) and never
// restores it, so a later `SetFontObject` cannot take the axis back.
//
// This assertion used to read `Top`. That was our copy-everything model, not the client's; the
// shipped XML path is unaffected either way, because the loader applies `inherits=` BEFORE the
// element's own `justifyV=` (`Loader::apply_fontstring_font`), so the explicit attribute still
// wins.
s.register_font_object(
"TopFont",
FontObject {
@@ -203,5 +213,29 @@ fn justify_v_defaults_middle_and_overrides() {
);
s.run("fs:SetFontObject('TopFont')").unwrap();
s.resolve();
assert_eq!(justify_v(&s), JustifyV::Top);
assert_eq!(justify_v(&s), JustifyV::Bottom, "severance is permanent");
// A string that never claimed the axis DOES take the object's justification.
s.run(
r#"
fresh = f:CreateFontString(nil, "ARTWORK")
fresh:SetText("Fresh")
fresh:SetFontObject('TopFont')
"#,
)
.unwrap();
s.resolve();
let fresh = s
.extract()
.into_iter()
.find_map(|q| match q.content {
QuadContent::Text {
text: Some(ref t),
justify_v,
..
} if t == "Fresh" => Some(justify_v),
_ => None,
})
.expect("a text quad");
assert_eq!(fresh, JustifyV::Top);
}
@@ -247,7 +247,7 @@ fn player_buff_hover_is_the_aura_variant() {
name: Some("Arcane Intellect".into()),
duration: 1800.0,
expiration_time: 100.0, // 90 s left at now = 10
helpful: true, // the HELPFUL-default filter indexes buffs
helpful: true,
..Default::default()
}]),
);
@@ -256,7 +256,8 @@ fn player_buff_hover_is_the_aura_variant() {
local a = CreateFrame("Button", "BF1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetPlayerBuff(1)
-- 0, not 1: SetPlayerBuff takes a 1.12 CACHE POSITION (see `script::aura`'s header).
tt:SetPlayerBuff(0)
assert(tt:NumLines() == 3, "name + description + remaining, got " .. tt:NumLines())
assert(TTTextLeft2:GetText() == "Intellect increased by 2.", "the AURA description column")
assert(TTTextLeft3:GetText() == "2 minutes remaining", "got " .. TTTextLeft3:GetText())
@@ -313,7 +314,7 @@ fn player_buff_hover_names_the_dispel_class_in_gold() {
local a = CreateFrame("Button", "BF1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetPlayerBuff(1)
tt:SetPlayerBuff(0)
assert(TTTextRight1:GetText() == "Magic",
"the dispel class, got " .. tostring(TTTextRight1:GetText()))
"#,
@@ -458,6 +459,91 @@ fn unit_buff_and_debuff_hover_render_the_aura_variant_without_remaining() {
assert!(s.take_errors().is_empty());
}
/// **`SetPlayerBuff` reads a 1.12 cache position, not an Era ordinal** — the index space the whole
/// `GetPlayerBuff*` family shares (`script::aura`'s header; pinned by `ref-BuffFrame.lua:105`,
/// which passes `GetPlayerBuff`'s return straight in).
///
/// Every assertion here is a case the previous 1-based, sign-filtered reading got wrong, and got
/// wrong *silently* — the plate still rendered, just for the wrong aura:
///
/// - position 1 is the SECOND aura, not the first again;
/// - a debuff is reachable at all (the old sign filter defaulted to helpful, so no position ever
/// resolved to one);
/// - `-1` clears instead of showing the first buff. `BigWigs/Raids/Naxxramas/Loatheb.lua:260-271`
/// feeds an unchecked `GetPlayerBuff(i, "HARMFUL")` in and breaks its scan when line 1 goes nil,
/// so "shows something" there is an addon that never stops scanning;
/// - a surplus filter argument is ignored (`CT_BuffMod/CT_BuffFrame.lua:151` passes one).
#[test]
fn player_buff_hover_indexes_the_cache_position_not_a_filtered_ordinal() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
for (id, name, desc) in [
(1126, "Mark of the Wild", "Armor increased by 25."),
(2457, "Battle Stance", "A balanced combat stance."),
(589, "Shadow Word: Pain", "Suffering 12 Shadow damage."),
] {
s.set_spell_tooltip(
id,
SpellTooltipView {
name: name.into(),
aura_description: desc.into(),
..Default::default()
},
);
}
// The player's cache: two buffs then a debuff, ONE list, insertion-ordered (decision 0257).
s.set_auras(
"player",
Some(vec![
AuraState {
spell_id: 1126,
name: Some("Mark of the Wild".into()),
helpful: true,
..Default::default()
},
AuraState {
spell_id: 2457,
name: Some("Battle Stance".into()),
helpful: true,
..Default::default()
},
AuraState {
spell_id: 589,
name: Some("Shadow Word: Pain".into()),
helpful: false,
..Default::default()
},
]),
);
s.run(
r#"
local a = CreateFrame("Button", "BF1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
local function at(i, extra)
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetPlayerBuff(i, extra)
return TTTextLeft1 and TTTextLeft1:GetText()
end
assert(at(0) == "Mark of the Wild", "position 0 is the first aura, got " .. tostring(at(0)))
assert(at(1) == "Battle Stance", "position 1 is the SECOND, got " .. tostring(at(1)))
assert(at(2) == "Shadow Word: Pain", "the debuff shares the index space, got " .. tostring(at(2)))
-- The handle really is GetPlayerBuff's return, round-tripped.
assert(at(GetPlayerBuff(0, "HARMFUL")) == "Shadow Word: Pain", "round-trip through GetPlayerBuff")
-- A surplus filter argument is ignored, as the reference's binding ignores it.
assert(at(1, "HELPFUL|HARMFUL") == "Battle Stance", "surplus argument must be ignored")
-- Misses CLEAR (Loatheb's scan terminates on a nil first line) rather than leaving a plate.
tt:SetOwner(a, "ANCHOR_RIGHT"); tt:SetPlayerBuff(3)
assert(tt:NumLines() == 0 and not tt:IsShown(), "past the cache: no plate")
tt:SetOwner(a, "ANCHOR_RIGHT"); tt:SetPlayerBuff(-1)
assert(tt:NumLines() == 0 and not tt:IsShown(), "-1 must clear, not show the first buff")
tt:SetOwner(a, "ANCHOR_RIGHT"); tt:SetPlayerBuff(GetPlayerBuff(9, "HARMFUL"))
assert(tt:NumLines() == 0 and not tt:IsShown(), "an unguarded -1 from GetPlayerBuff clears")
"#,
)
.unwrap();
assert!(s.take_errors().is_empty(), "{:?}", s.take_errors());
}
/// SetAction delegates by payload kind: a SPELL slot renders the spell view; an empty slot shows
/// nothing; a miss records the ask.
#[test]
@@ -0,0 +1,499 @@
//! The `toplevel` flag and the raise law — `SetToplevel`/`IsToplevel`, `Raise`/`Lower`, and the
//! raise itself with its real trigger (Show) and its real gate (occlusion). The mechanism, the byte
//! addresses and the clause-by-clause mapping onto our order model are in `script::object::toplevel`.
//!
//! Every test drives the PRODUCTION path — the Lua bindings, the loader, and the real
//! `mouse_button`/`mouse_move` entry points — and asserts on the two things that are actually
//! observable: `GetFrameLevel()` (the arithmetic) and the extracted painter order (the pixels).
use super::common::script;
use crate::script::{QuadContent, UiScript};
/// The order textures actually paint in, by their texture path — the render list `extract` returns
/// IS the draw order (`order::traversal`'s ZKey sort).
fn painted(s: &mut UiScript) -> Vec<String> {
s.resolve();
s.extract()
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. } => Some(p.clone()),
_ => None,
})
.collect()
}
fn level(s: &mut UiScript, frame: &str) -> i64 {
s.eval::<i64>(&format!("return {frame}:GetFrameLevel()"))
.unwrap()
}
/// Two overlapping MEDIUM frames: `Board` — already on screen and sitting at frame level **5** —
/// and `Dialog`, born at level 0 and hidden.
///
/// The level split is the point. A frame's link stamp already lifts it within its bucket when it is
/// shown (`resequence_to_tail`, decision 0557), so a same-level sibling would prove nothing about
/// the raise; **level outranks the link stamp** in the draw key, so nothing but a real level bump
/// can get `Dialog` over `Board`. This is the shape of the complaint the law answers: a dialog
/// opening behind a window that happens to sit higher in its stratum.
fn board_and_dialog() -> UiScript {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
Board = CreateFrame("Frame", "Board")
Board:SetPoint("BOTTOMLEFT", 100, 100)
Board:SetSize(300, 300)
Board:SetFrameLevel(5)
Board:CreateTexture(nil, "ARTWORK"):SetTexture("Board.blp")
Dialog = CreateFrame("Frame", "Dialog")
Dialog:SetPoint("BOTTOMLEFT", 200, 200) -- overlaps Board's top-right quadrant
Dialog:SetSize(300, 300)
Dialog:CreateTexture(nil, "ARTWORK"):SetTexture("Dialog.blp")
Dialog:Hide()
"#,
)
.unwrap();
s.resolve();
s
}
// ── The flag ────────────────────────────────────────────────────────────────────────────────────
/// `SetToplevel`/`IsToplevel` round-trip, including the reference's **default-true** optional
/// argument (`0x775440` marshals an optional boolean defaulting to true, unlike `SetMovable`).
/// Nothing is born toplevel.
#[test]
fn the_flag_round_trips_and_defaults_off() {
let s = script();
s.run(r#"F = CreateFrame("Frame", "F")"#).unwrap();
assert!(
!s.eval::<bool>("return F:IsToplevel()").unwrap(),
"no frame is born toplevel"
);
s.run("F:SetToplevel(true)").unwrap();
assert!(s.eval::<bool>("return F:IsToplevel()").unwrap());
s.run("F:SetToplevel(false)").unwrap();
assert!(!s.eval::<bool>("return F:IsToplevel()").unwrap());
// The omitted argument is TRUE, not false — the reference's `SetToplevel()` turns the bit ON.
s.run("F:SetToplevel()").unwrap();
assert!(
s.eval::<bool>("return F:IsToplevel()").unwrap(),
"SetToplevel() with no argument sets the bit"
);
// Lua truthiness, like every other flag setter here: the corpus writes SetToplevel(1).
s.run("F:SetToplevel(nil); F:SetToplevel(1)").unwrap();
assert!(s.eval::<bool>("return F:IsToplevel()").unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `toplevel="true"` in XML lands on the real method — it was a warn-once gap in the loader until
/// this law existed, which left every window authored toplevel (thirteen of our own frames, 82
/// corpus addons) unable to come to the front while `SetToplevel` did not exist at all.
///
/// `enableKeyboard` is deliberately still warned: implementing one clause is not a licence to go
/// quiet about the other.
#[test]
fn the_xml_toplevel_attribute_reaches_the_method_and_enable_keyboard_still_warns() {
let s = script();
let doc = crate::framexml::parse(
r#"<Ui>
<Frame name="XmlTop" toplevel="true" enableKeyboard="true">
<Size><AbsDimension x="100" y="50"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="10" y="10"/></Offset></Anchor>
</Anchors>
</Frame>
</Ui>"#,
)
.expect("valid FrameXML");
let report = crate::loader::load(&s, &doc, &|_| None);
assert!(
!report.warnings.iter().any(|w| w.contains("SetToplevel")),
"no gap warning any more: {:?}",
report.warnings
);
assert!(
report.warnings.iter().any(|w| w.contains("EnableKeyboard")),
"the clause we did NOT build stays loud: {:?}",
report.warnings
);
assert!(s.eval::<bool>("return XmlTop:IsToplevel()").unwrap());
}
// ── The raise on Show ───────────────────────────────────────────────────────────────────────────
/// The control, and the behaviour before this landed: a **non-toplevel** frame is shown and moves
/// nowhere. `Dialog` paints under `Board` because its level is lower, exactly as the draw key says.
#[test]
fn a_non_toplevel_frame_does_not_move_when_shown() {
let mut s = board_and_dialog();
s.run("Dialog:Show()").unwrap();
assert_eq!(level(&mut s, "Dialog"), 0, "no raise, no level change");
assert_eq!(level(&mut s, "Board"), 5, "and no compaction either");
assert_eq!(
painted(&mut s),
vec!["Dialog.blp".to_string(), "Board.blp".to_string()],
"the dialog opens BEHIND the window it overlaps"
);
}
/// The headline: the same frame, marked `toplevel`, comes to the front on **Show** — the trigger
/// the law names (`effective_visible_show 0x76ae10` @`0x76aee0`), not on a click.
///
/// The arithmetic is pinned here because it is the whole mapping onto our order model. Visible
/// MEDIUM levels are `{0 (Dialog), 5 (Board)}`; `level_compact 0x764eb0` renumbers those occupied
/// levels contiguously into `[0, 2)` — Board 5 → 1 — and the raise then writes
/// `level := bucket->count` = **2**. Not a live max-scan (`5 + 1`), not a re-stamp: one above the
/// top *occupied* level, counted after the compaction.
#[test]
fn a_raise_is_top_occupied_level_plus_one_after_compaction() {
let mut s = board_and_dialog();
s.run("Dialog:SetToplevel(true)").unwrap();
s.run("Dialog:Show()").unwrap();
assert_eq!(
level(&mut s, "Board"),
1,
"compaction renumbered the stratum's occupied levels into [0, count)"
);
assert_eq!(
level(&mut s, "Dialog"),
2,
"the raise is bucket->count, read AFTER the compaction"
);
assert_eq!(
painted(&mut s),
vec!["Board.blp".to_string(), "Dialog.blp".to_string()],
"the dialog is now in front"
);
}
/// The trigger is the **`effectiveVisible` false→true transition**, not the `Show()` call: a frame
/// whose own `shown` bit is already set raises when an ancestor's Show finally makes it visible, and
/// a `Show()` that changes no effective visibility raises nothing.
///
/// The setup order is load-bearing and is worth reading as the law's own proof. `Holder` is hidden
/// *before* `Dialog:Show()`, so that Show is not a transition — leave it visible and `Dialog` raises
/// there instead, ends up above everything, and the transition under test then correctly declines
/// because nothing is left to raise over. (That mis-ordering is what the first draft of this test
/// did; the gate was right and the fixture was wrong.)
#[test]
fn the_trigger_is_the_effective_visibility_transition_not_the_show_call() {
let mut s = board_and_dialog();
s.run(
r#"
Holder = CreateFrame("Frame", "Holder")
Holder:SetPoint("BOTTOMLEFT", 200, 200)
Holder:SetSize(300, 300)
Holder:Hide()
Dialog:SetParent(Holder)
Dialog:SetToplevel(true)
Dialog:Show() -- own bit set, parent hidden: NOT effective-visible, no transition
"#,
)
.unwrap();
assert_eq!(
level(&mut s, "Dialog"),
0,
"a Show that moves no effective visibility raises nothing"
);
assert_eq!(level(&mut s, "Board"), 5, "and compacts nothing");
s.run("Holder:Show()").unwrap();
assert_eq!(
level(&mut s, "Dialog"),
2,
"Dialog became effective-visible through its parent's Show and raised itself"
);
assert_eq!(level(&mut s, "Board"), 1, "the compaction ran with it");
}
// ── The gate ────────────────────────────────────────────────────────────────────────────────────
/// **Occlusion-gated**: a toplevel frame that overlaps nothing changes nothing at all. The gate sits
/// *before* the compaction in `0x7650f0`, so a declined raise leaves the whole stratum's levels
/// untouched too — `Board` keeps its authored 5.
#[test]
fn a_raise_on_a_frame_that_overlaps_nothing_is_a_total_no_op() {
let mut s = board_and_dialog();
s.run(
r#"
Dialog:ClearAllPoints()
Dialog:SetPoint("BOTTOMLEFT", 600, 450) -- clear of Board's (100,100)-(400,400)
Dialog:SetSize(100, 100)
Dialog:SetToplevel(true)
Dialog:Show()
"#,
)
.unwrap();
assert_eq!(level(&mut s, "Dialog"), 0, "nothing to raise over");
assert_eq!(
level(&mut s, "Board"),
5,
"and the gate declined before compaction"
);
}
/// The scan runs from the frame's **own level upward** (`for lvl = T->+0xc4; lvl < bucket->count`).
/// A toplevel frame whose only overlap is with something *below* it is already in front of it, so
/// there is nothing to raise over.
#[test]
fn the_scan_ignores_frames_below_the_raised_frames_own_level() {
let mut s = board_and_dialog();
s.run(
r#"
Board:SetFrameLevel(3)
Dialog:SetFrameLevel(5)
Dialog:SetToplevel(true)
Dialog:Show()
"#,
)
.unwrap();
assert_eq!(
level(&mut s, "Dialog"),
5,
"the overlap is below it; no raise"
);
assert_eq!(level(&mut s, "Board"), 3);
}
/// A window overlaps its own children by construction, so the scan excludes the raised frame's
/// subtree (`is_descendant 0x767010`). Without that clause every toplevel frame with content in it
/// would raise on every trigger.
#[test]
fn the_scan_excludes_the_raised_frames_own_subtree() {
let mut s = board_and_dialog();
s.run(
r#"
Board:Hide() -- leave the dialog alone on screen
Dialog:SetToplevel(true)
Inner = CreateFrame("Frame", "Inner", Dialog)
Inner:SetAllPoints() -- exactly covers its parent
Dialog:Show()
"#,
)
.unwrap();
assert_eq!(
level(&mut s, "Dialog"),
0,
"its own child is not an occlusion"
);
}
/// A raise **never changes the stratum** — `0x7650f0` never writes `+0xc0`. A LOW toplevel frame
/// raises over its LOW neighbours and stays under every MEDIUM frame, however many times it is
/// raised. (This is the mechanism that could not have fixed the party-frame-through-loot-window
/// bug: wow-re's CLAIM 2, refuted.)
#[test]
fn a_raise_can_never_lift_a_frame_out_of_its_stratum() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
Med = CreateFrame("Frame", "Med")
Med:SetPoint("BOTTOMLEFT", 100, 100)
Med:SetSize(300, 300)
Med:CreateTexture(nil, "ARTWORK"):SetTexture("Med.blp")
LowOther = CreateFrame("Frame", "LowOther")
LowOther:SetFrameStrata("LOW")
LowOther:SetPoint("BOTTOMLEFT", 100, 100)
LowOther:SetSize(300, 300)
LowOther:SetFrameLevel(4)
LowOther:CreateTexture(nil, "ARTWORK"):SetTexture("LowOther.blp")
LowTop = CreateFrame("Frame", "LowTop")
LowTop:SetFrameStrata("LOW")
LowTop:SetPoint("BOTTOMLEFT", 150, 150)
LowTop:SetSize(300, 300)
LowTop:SetToplevel(true)
LowTop:CreateTexture(nil, "ARTWORK"):SetTexture("LowTop.blp")
LowTop:Hide()
"#,
)
.unwrap();
s.resolve();
s.run("LowTop:Show()").unwrap();
assert_eq!(
s.eval::<String>("return LowTop:GetFrameStrata()").unwrap(),
"LOW",
"the raise never writes the stratum"
);
assert!(
level(&mut s, "LowTop") > level(&mut s, "LowOther"),
"it did raise, within LOW"
);
assert_eq!(
painted(&mut s),
vec![
"LowOther.blp".to_string(),
"LowTop.blp".to_string(),
"Med.blp".to_string(),
],
"still under every MEDIUM frame — a stratum is not something a raise can cross"
);
}
// ── Propagation and compaction ──────────────────────────────────────────────────────────────────
/// `propagate = 1` shifts **same-strata** children by the same delta (`0x76a4f0` @`0x76a58a`), so
/// the raised subtree keeps its internal order; cross-strata children are untouched
/// (`0x76a582: cmp …; jne`).
///
/// The arithmetic in full. Visible MEDIUM levels before the raise are `{0 Dialog, 1 Kid, 2 Grandkid,
/// 5 Board}`; compaction maps them to `{0, 1, 2, 3}` (Board 5 → 3) and reports `count = 4`. The
/// raise writes `Dialog := 4`, i.e. `delta = +4`, and pushes that same +4 into Kid (1 → 5) and
/// Grandkid (2 → 6) — gaps and order preserved. `Cross`, moved to the DIALOG stratum, keeps its 1.
#[test]
fn the_raised_subtree_shifts_by_one_delta_and_keeps_its_internal_order() {
let mut s = board_and_dialog();
s.run(
r#"
Kid = CreateFrame("Frame", "Kid", Dialog) -- level 1 (parent + 1)
Grandkid = CreateFrame("Frame", "Grandkid", Kid) -- level 2
Cross = CreateFrame("Frame", "Cross", Dialog) -- level 1, then a different stratum
Cross:SetFrameStrata("DIALOG")
Dialog:SetToplevel(true)
Dialog:Show()
"#,
)
.unwrap();
assert_eq!(level(&mut s, "Board"), 3, "compacted 5 -> 3");
assert_eq!(level(&mut s, "Dialog"), 4, "raised to bucket->count");
assert_eq!(level(&mut s, "Kid"), 5, "same delta (+4) as its parent");
assert_eq!(level(&mut s, "Grandkid"), 6, "and so does the grandchild");
assert_eq!(
level(&mut s, "Cross"),
1,
"a cross-strata child is skipped by the propagate"
);
assert_eq!(
s.eval::<String>("return Cross:GetFrameStrata()").unwrap(),
"DIALOG"
);
}
/// Compaction is what keeps `level := top + 1` from ratcheting. Two overlapping toplevel windows
/// traded back and forth twenty times settle inside a two-level band instead of climbing one step
/// per show — and the frame shown last is on top every single time.
///
/// Without `level_compact 0x764eb0` this test ends at level 20, and a long session ends at
/// `u16::MAX`.
#[test]
fn compaction_bounds_the_raise_across_repeated_shows() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
A = CreateFrame("Frame", "A")
A:SetPoint("BOTTOMLEFT", 100, 100)
A:SetSize(300, 300)
A:SetToplevel(true)
B = CreateFrame("Frame", "B")
B:SetPoint("BOTTOMLEFT", 200, 200)
B:SetSize(300, 300)
B:SetToplevel(true)
"#,
)
.unwrap();
s.resolve();
for _ in 0..10 {
s.run("A:Hide(); A:Show()").unwrap();
assert!(
level(&mut s, "A") > level(&mut s, "B"),
"the window just shown is in front"
);
s.run("B:Hide(); B:Show()").unwrap();
assert!(level(&mut s, "B") > level(&mut s, "A"));
}
assert!(
level(&mut s, "A") <= 2 && level(&mut s, "B") <= 2,
"twenty raises stay inside the band compaction defines, not 20 levels up: A={} B={}",
level(&mut s, "A"),
level(&mut s, "B")
);
}
// ── The Lua verbs ───────────────────────────────────────────────────────────────────────────────
/// `Frame:Raise()` — the explicit script call (`0x775a50` → `0x76a5b0` → `0x7650f0(force = 1)`).
/// Called on a **non-toplevel** frame it acts on the nearest toplevel ancestor, and on nothing at
/// all when there is none: the gate lives in the worker, so no call site has to check.
#[test]
fn lua_raise_acts_on_the_nearest_toplevel_ancestor_and_is_silent_without_one() {
let mut s = board_and_dialog();
s.run(
r#"
Dialog:SetToplevel(true)
Dialog:Show()
Kid = CreateFrame("Frame", "Kid", Dialog)
Loose = CreateFrame("Frame", "Loose")
Loose:SetPoint("BOTTOMLEFT", 200, 200)
Loose:SetSize(300, 300)
"#,
)
.unwrap();
s.resolve();
// Re-lower the dialog under Board so there is something to raise over again.
s.run("Board:SetFrameLevel(9); Dialog:SetFrameLevel(0)")
.unwrap();
s.run("Kid:Raise()").unwrap();
assert!(
level(&mut s, "Dialog") > level(&mut s, "Board"),
"raising a child raised the toplevel window it lives in"
);
// A frame with no toplevel in its chain: a total no-op, and not an error.
let board_before = level(&mut s, "Board");
let loose_before = level(&mut s, "Loose");
s.run("Loose:Raise()").unwrap();
assert_eq!(level(&mut s, "Loose"), loose_before);
assert_eq!(level(&mut s, "Board"), board_before);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `Frame:Lower()` is a **verified no-op stub** in build 5875 (`0x7652a0` = `xor eax,eax; ret 4`,
/// its frame argument never read). It exists so a caller gets the reference's silence rather than a
/// nil-call error — and it must not become "the opposite of Raise".
#[test]
fn lua_lower_exists_and_does_nothing() {
let mut s = board_and_dialog();
s.run("Dialog:SetToplevel(true); Dialog:Show()").unwrap();
let (d, b) = (level(&mut s, "Dialog"), level(&mut s, "Board"));
s.run("Dialog:Lower(); Board:Lower()").unwrap();
assert_eq!((level(&mut s, "Dialog"), level(&mut s, "Board")), (d, b));
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The drag-start trigger (`0x7652b0` @`0x7652d7`): grabbing a movable toplevel window brings it
/// forward before the drag begins. This was the deferral `object::movable`'s doc carried ("benilla
/// has no raise law yet") and it is closed.
#[test]
fn starting_a_move_raises_the_dragged_window() {
let mut s = board_and_dialog();
s.run(
r#"
Dialog:SetToplevel(true)
Dialog:SetMovable(true)
Dialog:EnableMouse(true)
Dialog:Show()
Board:SetFrameLevel(9) -- put the dialog back underneath
Dialog:SetFrameLevel(0)
"#,
)
.unwrap();
s.resolve();
assert!(level(&mut s, "Dialog") < level(&mut s, "Board"));
s.run("Dialog:StartMoving()").unwrap();
assert!(
level(&mut s, "Dialog") > level(&mut s, "Board"),
"the grab brought it to the front"
);
s.run("Dialog:StopMovingOrSizing()").unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
}