addons come alive: Bagnon draws, hovers and clicks

- Lua 5.0's generic-for and nested long strings are restored in a forked VM — 60 corpus addons stopped dying on one deleted opcode
- a day of demand-driven carving takes the 218-addon corpus from 24 addons surviving a session start to 114, with 144 loading without a single error
- Bagnon draws a real bag and answers real gestures — hover tooltip, pickup, use, drag — because our frames wear the reference's names, and the reference's own ContainerFrame.lua runs off the player's install instead of being transcribed
- the physics broad phase leaves: nothing in the client reads a contact, and it was measured at up to 41% of main-thread samples in real sessions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-11 18:19:08 +07:00
co-authored by Claude Fable 5
parent b5bc569156
commit 251daa14c5
104 changed files with 14356 additions and 2306 deletions
@@ -107,3 +107,131 @@ fn setpoint_unresolved_name_warns() {
.unwrap();
assert!(s.take_warnings().is_empty());
}
/// **A region whose OWNER has no rect still resolves from its own anchors.**
///
/// `layout.rs`'s region pass used to `continue` when `resolved` held nothing for the owner frame.
/// But the owner's rect is only the *fallback* for axes the region's own anchors do not pin — a
/// region anchored fully to some other frame needs nothing from its owner, and the reference
/// resolves it. Skipping made an ordinary addon shape silently invisible: a bare container frame
/// (`CreateFrame("Frame", n, parent)` with no size and no `SetPoint`) holding a region anchored
/// elsewhere.
///
/// Found by reproducing the director's MapCoords report. Its world-map readout computed the right
/// string every single frame — `Cursor Coords: … Player Coords: …` — and was never given a
/// position, with no error raised anywhere. Three of its four frames are this shape.
///
/// The control matters as much as the subject: a SIZED owner must be unaffected, or the fix has
/// traded one wrong layout for another.
#[test]
fn a_region_resolves_even_when_its_owner_frame_has_no_rect() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
host = CreateFrame("Frame", "AnchorHost")
host:SetWidth(400) host:SetHeight(200)
host:SetPoint("BOTTOMLEFT", 100, 50)
-- the shape that was invisible: owner with NO size and NO SetPoint
bare = CreateFrame("Frame", "BareOwner")
mark = bare:CreateTexture("BareMark", "ARTWORK")
mark:SetWidth(20) mark:SetHeight(10)
mark:SetPoint("BOTTOMLEFT", host, "BOTTOMLEFT", 5, 7)
-- control: identical region, owner that DOES resolve
sized = CreateFrame("Frame", "SizedOwner")
sized:SetWidth(10) sized:SetHeight(10) sized:SetPoint("CENTER")
ctl = sized:CreateTexture("SizedMark", "ARTWORK")
ctl:SetWidth(20) ctl:SetHeight(10)
ctl:SetPoint("BOTTOMLEFT", host, "BOTTOMLEFT", 5, 7)
"#,
)
.unwrap();
s.resolve();
// The owner genuinely has no rect — that part is correct and must stay true.
assert_eq!(
s.eval::<Option<f32>>("return BareOwner:GetLeft()").unwrap(),
None,
"an unpositioned frame has no rect; the fix must not invent one for it"
);
// ...and its region resolves anyway, from its own anchor: host's BOTTOMLEFT (100,50) + (5,7).
assert_eq!(
s.eval::<Option<f32>>("return BareMark:GetLeft()").unwrap(),
Some(105.0),
"a fully-anchored region needs nothing from its owner"
);
assert_eq!(
s.eval::<Option<f32>>("return BareMark:GetBottom()")
.unwrap(),
Some(57.0)
);
// The control is unmoved.
assert_eq!(
s.eval::<Option<f32>>("return SizedMark:GetLeft()").unwrap(),
Some(105.0),
"a sized owner's region must be unaffected by the fix"
);
}
/// **Geometry answers inside the same call stack that moved it** — the seam that killed 97 addons.
///
/// A handler creates a frame, anchors it, shows it and then measures it, all before any resolve
/// pass can run. `Dewdrop-2.0.lua` — embedded in ~65 corpus addons — does exactly this in its menu
/// `Open`: `local left = frame:GetLeft()` (l.1942) then `curX - left - width/2` (l.1960). Our
/// getters read a cache filled by the per-frame `resolve()`, so `left` was nil and the arithmetic
/// died. **97 of the 108 addons that drew and then raised on being touched died on that one line**,
/// with no missing verb anywhere.
///
/// The readers settle the graph on demand now. The second half of the test is the one that keeps
/// it honest: a *further* move inside the same stack must also be visible, or the fix is just a
/// one-shot warm-up.
#[test]
fn geometry_answers_within_the_call_stack_that_moved_it() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// Dewdrop's shape, in one eval — no resolve() between the writes and the read.
let (left, bottom): (Option<f32>, Option<f32>) = s
.eval(
r#"
local f = CreateFrame("Frame", "MenuLike", UIParent)
f:SetWidth(100) f:SetHeight(50)
f:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", 40, 60)
f:Show()
return f:GetLeft(), f:GetBottom()
"#,
)
.unwrap();
assert_eq!(
(left, bottom),
(Some(40.0), Some(60.0)),
"a frame must report its geometry in the stack that anchored it"
);
// ...and a LATER move in the same stack is visible too — not a stale first answer.
let moved: Option<f32> = s
.eval(
r#"
MenuLike:ClearAllPoints()
MenuLike:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", 200, 60)
return MenuLike:GetLeft()
"#,
)
.unwrap();
assert_eq!(moved, Some(200.0), "the settle must re-run after each move");
// Width/height and the centre reader take the same path.
assert_eq!(
s.eval::<(f32, f32)>("return MenuLike:GetWidth(), MenuLike:GetHeight()")
.unwrap(),
(100.0, 50.0)
);
assert_eq!(
s.eval::<(Option<f32>, Option<f32>)>("return MenuLike:GetCenter()")
.unwrap(),
(Some(250.0), Some(85.0))
);
}
@@ -44,6 +44,114 @@ fn backdrop_installs_and_extracts_pieces_with_colors() {
.all(|(p, c)| p == "edge" && *c == [1.0, 1.0, 1.0, 1.0]));
}
// `GetBackdrop()` — the four traps of `0x777370` (wow-re `widget-api-batch-benilla.md` Q5), pinned
// where an addon can observe them. The corpus caller is `BuffCheck2.lua:448`, which reads the table
// back, edits `insets`, and feeds it straight to `SetBackdrop` — so a wrong `tile` type or a missing
// ctor default does not just read wrong, it round-trips wrong.
#[test]
fn get_backdrop_reconstructs_from_the_struct() {
let s = script();
s.run(
r#"
local f = CreateFrame("Frame", "GB")
-- TRAP 2: no backdrop => ZERO values, not nil. `select('#')` is the only way to see it.
assert(select('#', f:GetBackdrop()) == 0, "unset backdrop must return no values at all")
-- TRAP 1: the result is rebuilt from the struct, not the caller's table. The alien key is
-- the proof: SetBackdrop never accepted it, so it cannot come back out.
local passed = { bgFile = "bg", edgeFile = "edge", tile = true,
tileSize = 16, edgeSize = 24,
insets = { left = 1, right = 2, top = 3, bottom = 4 },
alien = "must not survive" }
f:SetBackdrop(passed)
local b = f:GetBackdrop()
assert(select('#', f:GetBackdrop()) == 1, "a set backdrop is exactly one value")
assert(b ~= passed, "must not hand back the caller's own table")
assert(b.alien == nil, "keys SetBackdrop never read cannot reappear")
assert(b.insets ~= passed.insets, "the insets subtable is rebuilt too")
assert(b.bgFile == "bg" and b.edgeFile == "edge", "files round-trip")
assert(b.tileSize == 16 and b.edgeSize == 24, "sizes round-trip")
assert(b.insets.left == 1 and b.insets.right == 2
and b.insets.top == 3 and b.insets.bottom == 4, "insets round-trip")
-- TRAP 4: `tile` is the NUMBER 1, never the boolean the caller passed.
assert(type(b.tile) == "number", "tile must be a number, got " .. type(b.tile))
assert(b.tile == 1, "tile must be 1")
-- Mutating the caller's table afterwards cannot reach the frame (no reference is kept).
passed.bgFile = "clobbered"
assert(f:GetBackdrop().bgFile == "bg", "no live reference to the caller's table")
-- TRAP 3: a partial SetBackdrop omits NOTHING — a fresh struct is allocated every call, so
-- the keys left out come back as CTOR DEFAULTS, and none of the old backdrop survives.
f:SetBackdrop({ edgeFile = "onlyedge" })
local p = f:GetBackdrop()
assert(p.bgFile == "", "an omitted bgFile is the empty string, not nil")
assert(p.edgeSize == 32, "an omitted edgeSize is the ctor's 32, not the previous 24")
assert(p.tileSize == 0, "an omitted tileSize is 0, not the previous 16")
assert(p.insets.left == 0 and p.insets.right == 0
and p.insets.top == 0 and p.insets.bottom == 0, "omitted insets are 0, not 1/2/3/4")
-- TRAP 4, the other half: tile false pushes nil, so the key is ABSENT — never `false`.
assert(p.tile == nil, "tile false means the key is absent, not `false`")
-- The undocumented in-place form: fills and returns YOUR table, recycling `insets`, and
-- erases a stale `tile` on the way (the nil push is a real `lua_settable`).
local ins = {}
local mine = { insets = ins, tile = true, stale = "kept" }
local got = f:GetBackdrop(mine)
assert(got == mine, "the in-place form returns the table it was given")
assert(mine.insets == ins, "an existing insets subtable is reused, not replaced")
assert(mine.tile == nil, "a stale tile key is erased from a recycled table")
assert(mine.stale == "kept", "keys it does not write are left alone")
assert(mine.edgeSize == 32 and ins.bottom == 0, "the recycled table is filled")
-- SetBackdrop(nil) is indistinguishable from never having set one: back to zero values.
f:SetBackdrop(nil)
assert(select('#', f:GetBackdrop()) == 0, "SetBackdrop(nil) returns to the zero-value shape")
"#,
)
.unwrap();
}
// BuffCheck2.lua:448's exact shape: read the plate back, edit `insets`, push it straight back in.
// It is the whole reason this method exists in our client, so it is the test that must not rot.
#[test]
fn get_backdrop_round_trips_through_set_backdrop() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "BC2Plate")
f:SetPoint("CENTER")
f:SetSize(54, 54)
f:SetBackdrop({ bgFile = "bg", edgeFile = "edge", tile = true,
tileSize = 32, edgeSize = 32,
insets = { left = 11, right = 12, top = 12, bottom = 11 } })
local backdrop = f:GetBackdrop()
backdrop["insets"] = { top = 12, bottom = 11, right = 12, left = 11 }
backdrop["tile"] = true
backdrop["tileSize"] = 32
backdrop["edgeSize"] = 32
f:SetBackdrop(backdrop)
local b = f:GetBackdrop()
assert(b.bgFile == "bg" and b.edgeFile == "edge", "files survive the round trip")
assert(b.tile == 1 and b.tileSize == 32 and b.edgeSize == 32, "tiling survives")
assert(b.insets.left == 11 and b.insets.bottom == 11, "insets survive")
"#,
)
.unwrap();
s.resolve();
// The plate still draws: bg + 8 border pieces, unchanged by the round trip.
assert_eq!(
s.extract()
.iter()
.filter(|q| matches!(q.content, QuadContent::Backdrop { .. }))
.count(),
9
);
}
// SetBackdrop(nil) tears the plate down (no pieces after).
#[test]
fn set_backdrop_nil_tears_down() {
@@ -458,3 +458,66 @@ fn button_label_repaints_by_state_font_object() {
let c = label_color(&mut s).expect("colored");
assert!((c[0] - 0.1).abs() < 1e-6 && (c[1] - 0.2).abs() < 1e-6);
}
/// **`Button:GetTextWidth` / `GetTextHeight`** — the reference's own Button text-extent readers
/// (`0x782290` / `0x782390`; wow-re `widget-api-batch-benilla.md` Q8 carves them present on Button
/// and `GetStringWidth` **absent**). Both forward to the label FontString's own extent slots, which
/// is what this asserts: answer a host measure for the label, and the BUTTON must report it.
///
/// `Bagnon_Forever/database/ui.lua:61` sizes its character-switch dropdown from
/// `button:GetTextWidth() + 40`, so a nil method took the whole dropdown down — the director could
/// not switch characters in Bagnon at all.
#[test]
fn a_button_reports_its_labels_extent() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
b = CreateFrame("Button", "WidthBtn", UIParent)
b:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
b:SetWidth(120) b:SetHeight(24)
b:SetText("A Label")
plain = CreateFrame("Button", "LabellessBtn", UIParent)
"#,
)
.unwrap();
s.resolve();
// Unmeasured is 0 — the same "converges next frame" contract every other metric read has.
assert_eq!(
s.eval::<f64>("return WidthBtn:GetTextWidth()").unwrap(),
0.0
);
let req = s
.fontstrings_needing_measure()
.into_iter()
.find(|r| r.text == "A Label")
.expect("the label asks the host for its extent");
s.set_measured_text_unwrapped(&[(req.id, 47.0, 14.0, req.key)]);
assert_eq!(
s.eval::<f64>("return WidthBtn:GetTextWidth()").unwrap(),
47.0,
"the Button forwards to its own label"
);
assert_eq!(
s.eval::<f64>("return WidthBtn:GetTextHeight()").unwrap(),
14.0
);
// A Button with no label at all answers 0 rather than raising: the reference dereferences a
// FontString pointer (`+0x338`) here that a bare CreateFrame("Button") leaves null, and what it
// does then is not byte-read — so this takes the harmless number instead of guessing a crash.
assert_eq!(
s.eval::<f64>("return LabellessBtn:GetTextWidth()").unwrap(),
0.0
);
// …and it is a BUTTON method: a plain Frame must not have grown one.
assert!(
s.eval::<bool>(r#"return CreateFrame("Frame").GetTextWidth == nil"#)
.unwrap(),
"GetTextWidth is Button's, not Region's (wow-re Q8's own split)"
);
}
@@ -0,0 +1,110 @@
//! `GetChannelName` — the joined-channel lookup ([`crate::script::channel`]).
//!
//! The verb is small; the contract around it is not, and every test here is named after one claim.
//! The load-bearing one is [`a_channel_that_is_not_joined_answers_the_number_zero_never_nil`]: the
//! reference's own callers and the corpus both compare the first return numerically, so a nil there
//! does not degrade — it raises, at four call sites that work today.
use super::common::script;
/// Two channels joined in order, the way `ui_chat::feed` mirrors them after the server's
/// YOU_JOINED. Join ORDER is the whole numbering law, so the fixture is deliberately not sorted.
fn joined() -> crate::script::UiScript {
let mut s = script();
s.set_joined_channels(vec!["World".into(), "Trade - City".into()]);
s
}
/// **A joined channel answers its 1-based slot, its name, and an instanceID.** The slot is the
/// position in JOIN order — not a DBC id, not an alphabetical rank.
#[test]
fn a_joined_channel_answers_its_slot_name_and_instance() {
let s = joined();
let (id, name, instance): (i64, String, i64) = s.eval("return GetChannelName(2)").unwrap();
assert_eq!(id, 2, "the 1-based slot in join order");
assert_eq!(name, "Trade - City");
assert_eq!(
instance, 0,
"instanceID is 0 on every vanilla emulator, and a NUMBER so a caller can compare it"
);
}
/// **The name form resolves, case-insensitively, and yields the same slot as the index form.**
/// Half the corpus sites look up by name (`GetChannelName("world")`, `GetChannelName("Trade -
/// City")`), and `_LazyPig` passes `"world"` for a channel the server names `"World"`.
#[test]
fn the_name_form_resolves_case_insensitively_to_the_same_slot() {
let s = joined();
let by_name: i64 = s.eval("return GetChannelName('world')").unwrap();
let by_index: i64 = s.eval("return GetChannelName(1)").unwrap();
assert_eq!(by_name, 1);
assert_eq!(by_name, by_index, "both directions must agree on the slot");
let name: String = s
.eval("local _, n = GetChannelName('TRADE - CITY') return n")
.unwrap();
assert_eq!(
name, "Trade - City",
"the answer is the JOINED spelling, not the caller's"
);
}
/// **Not joined ⇒ the NUMBER 0. Never nil.** This is the trap, and it is the only way this verb can
/// break working code rather than merely fail to help it.
///
/// Verified from both sides. The reference compares it numerically at `ChatFrame.lua:2114`
/// (`if ( channelNum > 0 )`) and `l.2232` (`if ( channelNum <= 0 ) then return end`); so does the
/// corpus at `_LazyPig/LazyPig.lua:1996` (`if id > 0 then`). A nil first return turns every one of
/// those into "attempt to compare nil with number" — a loud failure in code that works today.
///
/// Asserted by DOING the comparison, not by inspecting the type, because the comparison is the
/// thing that must not raise.
#[test]
fn a_channel_that_is_not_joined_answers_the_number_zero_never_nil() {
let s = joined();
let id: i64 = s.eval("return GetChannelName('NoSuchChannel')").unwrap();
assert_eq!(id, 0);
// The reference's own guard shape, run for real: it must evaluate, not raise.
let guard: bool = s
.eval("local id = GetChannelName('NoSuchChannel') return id > 0")
.unwrap();
assert!(
!guard,
"the reference's `if ( channelNum > 0 )` must run and be false"
);
let out_of_range: bool = s
.eval("local id = GetChannelName(99) return id > 0")
.unwrap();
assert!(!out_of_range, "an out-of-range index takes the same branch");
let zero: bool = s
.eval("local id = GetChannelName(0) return id > 0")
.unwrap();
assert!(
!zero,
"the client bounds-checks 1 <= n <= count, so 0 is not a slot"
);
}
/// **A numeric STRING resolves as a number**, because `ChatFrame.lua:2113` hands one straight in:
/// it `gsub`s `/1` down to `"1"` and calls `GetChannelName(channel)`. On the real client Lua's own
/// coercion makes that work; ours must not treat it as a channel literally named "1".
#[test]
fn a_numeric_string_resolves_as_an_index_not_as_a_name() {
let s = joined();
let id: i64 = s.eval("return GetChannelName('2')").unwrap();
assert_eq!(id, 2, "the `/2` slash-command path depends on this");
}
/// **Empty until the server confirms a join.** `ui_chat::feed` appends on the server's YOU_JOINED
/// notice, never on the request, so a session that has asked and not been answered has no slots —
/// and the verb must still answer 0 rather than raise on an empty list.
#[test]
fn nothing_is_joined_before_the_server_confirms_it() {
let s = script();
let id: i64 = s.eval("return GetChannelName('World')").unwrap();
assert_eq!(id, 0);
}
@@ -112,3 +112,63 @@ fn events_fire_in_registration_order_fifo() {
s.fire_event("E", vec![]);
assert_eq!(s.eval::<String>("return order").unwrap(), "ACB");
}
/// **`HasScript` answers "can this widget CARRY that kind", not "does it have one set".**
///
/// That distinction is the verb's whole purpose, and every corpus caller depends on it: they ask
/// before hooking, precisely when nothing is set yet.
///
/// ```lua
/// if parent:HasScript("OnMouseDown") then -- Tablet-2.0.lua:2409
/// local script = parent:GetScript("OnMouseDown")
/// parent:SetScript("OnMouseDown", function() … end)
/// end
/// ```
///
/// It was the top session-start blocker — 32 of 39 `attempt to call method` failures were this one
/// name — and implementing it took survivors from 41 to 69.
///
/// The known over-permission is asserted too, so it is a recorded divergence rather than a
/// discovery: our table is flat where the reference's is per widget type, so a plain Frame answers
/// true for a Button-only kind. Exact for the base kinds, which is what the corpus asks about.
#[test]
fn has_script_reports_the_kind_is_supported_not_that_one_is_set() {
let s = script();
s.run(r#"f = CreateFrame("Frame", "HasScriptProbe")"#)
.unwrap();
// True with NOTHING set — the case every caller is actually in.
assert!(
s.eval::<bool>(r#"return f:HasScript("OnMouseDown")"#)
.unwrap(),
"a frame must report it can carry OnMouseDown before one is set"
);
assert!(
!s.eval::<bool>(r#"return f:HasScript("OnNotARealScript")"#)
.unwrap(),
"an unknown kind is false, not true"
);
// Tablet's exact idiom, run end to end: guard, read the (absent) handler, install one, fire it.
let fired: bool = s
.eval(
r#"
RAN = false
if f:HasScript("OnMouseDown") then
local prev = f:GetScript("OnMouseDown")
f:SetScript("OnMouseDown", function() RAN = true end)
end
f:GetScript("OnMouseDown")()
return RAN
"#,
)
.unwrap();
assert!(fired, "the guarded hook must install and run");
// The recorded divergence: flat table, so a Frame says true for a Button-only kind. The
// reference says false. Pinned so making SCRIPT_KINDS per-type has to come here and decide.
assert!(
s.eval::<bool>(r#"return f:HasScript("OnClick")"#).unwrap(),
"over-permissive by design today — see the comment at the binding"
);
}
@@ -0,0 +1,223 @@
//! Lua 5.0's iterator-less generic-for, restored in the vendored VM (decision 1215).
//!
//! `for k, v in someTable do` is Lua 5.0 syntax that 5.1 removed at the OPCODE level. 183 of 218
//! corpus addons are reached by it and it is the first session-start error for 60 of them — three
//! quarters of everything that breaks in a running session. The fix is 5.0's `OP_TFORPREP` folded
//! into `OP_TFORLOOP` in `third_party/lua-src/lua-5.1.5/lvm.c`.
//!
//! Each test below pins one detail that was byte-verified in the real 1.12.1 client (wow-5875-re
//! `system/ui/scratch/lua-generic-for.md`), because each is a place where a *reasonable* guess
//! diverges from what the client does. The `__call` case is the sharpest: two of the three
//! conditions somebody would naturally write for this behave differently there.
use super::common::script;
/// **The construct works at all** — the whole point, in the shape the corpus uses.
///
/// `Ace.lua`'s is the canonical one and Ace is embedded across half the ecosystem:
/// `for _, lang in self.langs do … end`, with no `pairs` and no iterator function.
#[test]
fn a_table_generator_iterates_like_lua_50() {
let s = script();
let seen: i64 = s
.eval(
r#"
local t = { a = 1, b = 2, c = 3 }
local sum = 0
for k, v in t do sum = sum + v end
return sum
"#,
)
.unwrap();
assert_eq!(seen, 6, "every pair must be visited exactly once");
// The array shape too, and the KEY must be the real key rather than a counter.
let keys: String = s
.eval(
r#"
local t = { "x", "y" }
local out = ""
for i, v in t do out = out .. i .. v end
return out
"#,
)
.unwrap();
assert_eq!(keys, "1x2y");
}
/// **The condition is a bare type-tag test, so a table carrying `__call` STILL gets `next`.**
///
/// This is the discriminating case, and the reason the patch does not test "is not callable" or
/// "has no `__call`" — both of those are reasonable readings and both diverge from the client here.
/// The 1.12 handler makes **no** metatable access whatsoever (no `luaT_gettmbyobj`, no
/// `tryfuncTM`), so the `__call` is never consulted and the table is iterated.
#[test]
fn a_table_with_a_call_metamethod_is_still_iterated_not_called() {
let s = script();
let out: String = s
.eval(
r#"
local called = false
local t = { only = "pair" }
setmetatable(t, { __call = function() called = true return nil end })
local keys = ""
for k, v in t do keys = keys .. k end
return keys .. "|" .. tostring(called)
"#,
)
.unwrap();
assert_eq!(
out, "only|false",
"the table must be ITERATED and its __call never invoked"
);
}
/// **The callee is the global `next`, read fresh at each loop entry — so an addon can replace it.**
///
/// Observable behaviour of the real client, not an accident: the lookup is a raw read of the
/// thread's globals table, performed once per loop entry. An addon that assigns `next = myfn`
/// changes every later generic-for in the session. Reproduced rather than improved on.
#[test]
fn the_substituted_generator_is_the_live_global_next() {
let s = script();
let out: String = s
.eval(
r#"
local realnext = next
local calls = 0
next = function(t, k) calls = calls + 1 return realnext(t, k) end
local t = { a = 1, b = 2 }
local n = 0
for k, v in t do n = n + 1 end
next = realnext
return n .. "|" .. (calls > 0 and "hooked" or "not hooked")
"#,
)
.unwrap();
assert_eq!(
out, "2|hooked",
"a replaced global `next` must drive the loop, as it does on the client"
);
}
/// **Only a table is substituted.** A function generator is untouched (the ordinary 5.1 path), and
/// a non-table, non-function generator still raises on the FIRST ITERATION rather than at entry —
/// the client's tag mismatch falls through to the normal call path.
#[test]
fn only_a_table_is_substituted_and_other_types_still_raise() {
let s = script();
// The ordinary iterator form is completely unaffected.
let via_pairs: i64 = s
.eval(
r#"
local t = { a = 1, b = 2, c = 3 }
local n = 0
for k, v in pairs(t) do n = n + 1 end
return n
"#,
)
.unwrap();
assert_eq!(via_pairs, 3);
// A custom iterator function still drives its own loop.
let custom: i64 = s
.eval(
r#"
local function upto(state, i)
i = i + 1
if i <= state then return i end
end
local sum = 0
for i in upto, 3, 0 do sum = sum + i end
return sum
"#,
)
.unwrap();
assert_eq!(
custom, 6,
"an explicit generator/state/control triple is untouched"
);
// A number generator is not a table: it must still raise, and say so.
let err = s
.eval::<i64>("local n = 0 for k, v in 42 do n = n + 1 end return n")
.unwrap_err()
.to_string();
assert!(
err.contains("attempt to call a number value"),
"a non-table, non-function generator must still raise: {err}"
);
}
/// **An empty table terminates immediately** rather than looping — `next(t, nil)` on an empty table
/// returns nil, and the loop's own continue test is unchanged by the substitution.
#[test]
fn an_empty_table_generator_terminates() {
let s = script();
let n: i64 = s
.eval("local n = 0 for k, v in {} do n = n + 1 end return n")
.unwrap();
assert_eq!(n, 0);
}
/// **Nested and repeated loops each get their own substitution.** The patch rewrites the loop's own
/// registers, so an inner loop must not disturb an outer one, and a loop entered twice must
/// substitute twice (the registers are reloaded from the expression each entry).
#[test]
fn nested_and_repeated_loops_each_substitute_independently() {
let s = script();
let out: i64 = s
.eval(
r#"
local outer = { a = 1, b = 2 }
local inner = { x = 10, y = 20 }
local total = 0
for k, v in outer do
for k2, v2 in inner do total = total + v2 end
total = total + v
end
return total
"#,
)
.unwrap();
// Each of the 2 outer pairs runs the full inner loop (30) plus its own value.
assert_eq!(out, 30 + 30 + 1 + 2);
// Entering the same loop a second time must work identically.
let twice: i64 = s
.eval(
r#"
local t = { a = 1, b = 2 }
local function count() local n = 0 for k, v in t do n = n + 1 end return n end
return count() + count()
"#,
)
.unwrap();
assert_eq!(twice, 4);
}
/// **5.0's long-string NESTING, restored** — the second dialect divergence in this file's family.
///
/// `[[ ... [[ ... ]] ... ]]` nests in Lua 5.0, which is what 1.12.1 ships. 5.1 kept the machinery
/// (`llex.c` `read_long_string`'s `cont` counter, guarded by `LUA_COMPAT_LSTR == 2`) and put an
/// advisory error in front of it — *"nesting of [[...]] is deprecated"* — which is a 5.1 opinion
/// about a dialect we are not targeting. Two corpus addons stopped LOADING on it.
///
/// The fix is Lua's own documented switch, `LUA_COMPAT_LSTR` 1 -> 2 in our vendored `luaconf.h`,
/// not a patch to the lexer: the code path was already there and already correct.
#[test]
fn a_nested_long_string_parses_as_lua_50_does() {
let s = crate::script::UiScript::new().unwrap();
// The shape an addon writes: a long comment containing a long string.
s.run("--[[ outer [[ inner ]] still comment ]]\nNESTED_OK = 1")
.unwrap();
assert_eq!(s.eval::<i64>("return NESTED_OK").unwrap(), 1);
// And a nested long STRING keeps its inner brackets, 5.0's own semantics: the outer runs to
// the matching close, so the inner `]]` does not terminate it.
let v = s
.eval::<String>("return [[a [[b]] c]]")
.expect("nested long string must parse");
assert_eq!(v, "a [[b]] c");
}
@@ -7,6 +7,7 @@
mod anchors;
mod backdrop;
mod button;
mod channel;
mod common;
mod cooldown;
mod create_frame_template;
@@ -14,6 +15,7 @@ mod end_to_end;
mod events;
mod font_object;
mod frame_api;
mod generic_for;
mod input;
mod layout_gate;
mod measure;
@@ -177,3 +177,794 @@ fn the_exception_list_has_no_dead_entries() {
wrong.join(" ")
);
}
/// **`Texture:GetTexture()` — the three contract details that a plausible implementation gets
/// silently wrong.** Verified in wow-re's widget-method batch (`0x79ba70`/`0x79baf0`/`0x835708`,
/// `system/ui/scratch/widget-api-batch-benilla.md`).
///
/// Four corpus addons reach it: `AtlasQuest.lua:228` is `AQATLASMAP = AtlasMap:GetTexture()` and
/// `FuBarPlugin-2.0.lua:343` is `return self.iconFrame:GetTexture()`, each behind two addons.
///
/// The colour case is the one worth a test of its own. `SetTexture(r,g,b)` synthesizes an 8x8 solid
/// and the getter reports the literal name `"Solid Texture"` — so an addon's `if not tex then`
/// guard passes straight through. Returning nil there would read as tidier and would be wrong in
/// exactly the direction callers test for.
#[test]
fn get_texture_returns_the_stripped_path_and_solid_texture_for_a_fill() {
let s = crate::script::UiScript::new().unwrap();
s.run(
r#"
f = CreateFrame("Frame", "GTF")
tex = f:CreateTexture("GTFTex", "ARTWORK")
fill = f:CreateTexture("GTFFill", "ARTWORK")
"#,
)
.unwrap();
// Never set: nil, and exactly one value.
assert_eq!(
s.eval::<Option<String>>("return GTFTex:GetTexture()")
.unwrap(),
None
);
assert_eq!(
s.eval::<i64>("local a,b = GTFTex:GetTexture() return select('#', GTFTex:GetTexture())")
.unwrap_or(1),
1,
"exactly one return value"
);
// A path with no extension comes back unchanged.
s.run(r#"GTFTex:SetTexture("Interface\\Icons\\Spell_Fire_FlameBolt")"#)
.unwrap();
assert_eq!(
s.eval::<String>("return GTFTex:GetTexture()").unwrap(),
"Interface\\Icons\\Spell_Fire_FlameBolt"
);
// ...and one WITH an extension is stripped at the last `.` — the loader appends it, the getter
// takes it back off.
s.run(r#"GTFTex:SetTexture("Interface\\Icons\\Foo.blp")"#)
.unwrap();
assert_eq!(
s.eval::<String>("return GTFTex:GetTexture()").unwrap(),
"Interface\\Icons\\Foo"
);
// The colour form: the literal name, NOT nil.
s.run("GTFFill:SetTexture(1, 0, 0, 1)").unwrap();
assert_eq!(
s.eval::<String>("return GTFFill:GetTexture()").unwrap(),
"Solid Texture",
"a colour-filled region reports a NAME — `if not tex then` must not fire"
);
// And a plain SetTexture makes it ordinary again.
s.run(r#"GTFFill:SetTexture("Interface\\Buttons\\UI-Quickslot2")"#)
.unwrap();
assert_eq!(
s.eval::<String>("return GTFFill:GetTexture()").unwrap(),
"Interface\\Buttons\\UI-Quickslot2"
);
}
/// **The shadow accessors on a REGION, and `GetShadowColor`'s four values.**
///
/// They existed on the font-object table; a FontString from `CreateFontString` had none, which is
/// exactly where the corpus calls them. `FuBar_NavigatorFu/NavigatorFu.lua:31` is
/// `coordText:SetShadowColor(GameFontNormal:GetShadowColor())` — getter on a font object, setter on
/// a fresh region, in one line — and `KLHThreatMeter/.../KTM_Gui.lua:404` is
/// `fontstring:SetShadowColor(0,0,0,0.3)`.
///
/// **Four values, not three** (`0x79dd2f`, `mov eax,0x4` — wow-re's widget-method batch). Three is
/// the plausible wrong answer and it silently drops the alpha NavigatorFu round-trips: the whole
/// point of its line is that whatever `GameFontNormal` carries arrives intact.
#[test]
fn region_shadow_accessors_round_trip_four_values() {
let s = crate::script::UiScript::new().unwrap();
s.run(r#"f = CreateFrame("Frame", "SHF") fs = f:CreateFontString("SHFText", "ARTWORK")"#)
.unwrap();
assert_eq!(
s.eval::<i64>("return select('#', SHFText:GetShadowColor())")
.unwrap(),
4,
"GetShadowColor returns FOUR values — three drops the alpha"
);
assert_eq!(
s.eval::<i64>("return select('#', SHFText:GetShadowOffset())")
.unwrap(),
2
);
// NavigatorFu's line, in shape: a font object's shadow piped straight into a region's.
s.run("SHFText:SetShadowColor(0, 0, 0, 0.3) SHFText:SetShadowOffset(1, -1)")
.unwrap();
let (r, g, b, a) = s
.eval::<(f32, f32, f32, f32)>("return SHFText:GetShadowColor()")
.unwrap();
assert_eq!((r, g, b, a), (0.0, 0.0, 0.0, 0.3), "the alpha survives");
assert_eq!(
s.eval::<(f32, f32)>("return SHFText:GetShadowOffset()")
.unwrap(),
(1.0, -1.0)
);
// Either half may be set first: setting only the offset must not blank the colour.
s.run("SHFText:SetShadowOffset(2, -2)").unwrap();
assert_eq!(
s.eval::<f32>("local _,_,_,a = SHFText:GetShadowColor() return a")
.unwrap(),
0.3,
"setting the offset alone keeps the colour"
);
}
/// **`Region:SetParent(frame)` — it exists on a Texture/FontString, and we were the ones missing
/// it.** `SetParent` is in the Region method table (`0x7a1550`) and both leaf lookups fall back to
/// Region's (`0x79c650` / `0x79ee50`), so `FuBar_FuXPFu.lua:210`'s
/// `self.Spark:SetParent(self.XPBar)` — a texture from `XPBar:CreateTexture` — is a working line on
/// the real client (wow-re `system/ui/scratch/widget-api-batch-benilla.md` Q7).
///
/// The two traps pinned here are the ones a plausible implementation gets wrong in opposite
/// directions. **A non-Frame argument RAISES** (`IsA(FrameTag)` at `0x7a16ea`, message
/// `"…Wrong parent object type, expected frame"`) rather than no-opping; and **anchors are NOT
/// touched** by the re-link, which moves draw-layer/region-list membership only — so a re-parented
/// texture keeps resolving against whatever `SetPoint` named, and FuXPFu's re-point on the next
/// line is the correct pattern rather than a workaround.
#[test]
fn region_set_parent_relinks_the_draw_owner_and_leaves_anchors_alone() {
/// The resolved rect of the one texture quad whose path contains `needle`, if it draws at all.
fn tex_rect(s: &crate::script::UiScript, needle: &str) -> Option<crate::layout::Rect> {
s.extract().iter().find_map(|q| match &q.content {
crate::script::QuadContent::Texture { path: Some(p), .. } if p.contains(needle) => {
Some(q.rect.expect("resolved rect"))
}
_ => None,
})
}
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
A = CreateFrame("Frame", "SPOwnerA")
A:SetPoint("BOTTOMLEFT", 0, 0) A:SetSize(100, 50)
B = CreateFrame("Frame", "SPOwnerB")
B:SetPoint("BOTTOMLEFT", 300, 0) B:SetSize(100, 50)
Spark = A:CreateTexture("SPSpark", "ARTWORK")
Spark:SetTexture("Interface\\SPGlow")
Spark:SetSize(24, 24)
Spark:SetPoint("TOPLEFT", 4, -4)
"#,
)
.unwrap();
s.resolve();
// A is [bottom 0, left 0, top 50, right 100]: TOPLEFT +(4,-4), 24x24 ⇒ 4..28 x 22..46.
let before = tex_rect(&s, "SPGlow").expect("the spark draws under A");
assert_eq!(before, crate::layout::Rect::new(22.0, 4.0, 46.0, 28.0));
// FuXPFu's line, and it returns nothing at all.
assert_eq!(
s.eval::<i64>("return select('#', Spark:SetParent(B))")
.unwrap(),
0,
"SetParent returns zero values on every path"
);
s.resolve();
assert_eq!(
tex_rect(&s, "SPGlow"),
Some(before),
"the anchors are untouched — the spark still resolves against A, 300px from B"
);
// …and the re-link is real, not merely recorded: the spark now draws with B. Hiding B takes it
// off the screen; hiding A (which it is still anchored to) does not.
s.run("B:Hide()").unwrap();
assert_eq!(
tex_rect(&s, "SPGlow"),
None,
"the spark hides with its NEW owner"
);
s.run("B:Show() A:Hide()").unwrap();
s.resolve();
assert_eq!(
tex_rect(&s, "SPGlow"),
Some(before),
"and not with the frame it is merely anchored to"
);
// A non-Frame argument raises. A texture is the case the reference names, and it is the one an
// addon actually hits (a mixed table of frames and textures walked in a loop).
for bad in ["Spark:SetParent(Spark)", "Spark:SetParent(5)"] {
let e = s
.run(bad)
.expect_err("a non-frame parent must raise")
.to_string();
assert!(
e.contains("expected frame"),
"wanted the reference's 'expected frame' rejection, got: {e}"
);
}
// A MISSING argument is not the nil form: TNONE never reaches the nil branch.
assert!(s.run("Spark:SetParent()").is_err(), "no argument raises");
// The nil form detaches without error: orphaned and unrendered, not destroyed — a later
// re-parent brings the same region back.
s.run("A:Show() Spark:SetParent(nil)").unwrap();
s.resolve();
assert_eq!(
tex_rect(&s, "SPGlow"),
None,
"a detached region draws nothing"
);
s.run("Spark:SetParent(A)").unwrap();
s.resolve();
assert_eq!(
tex_rect(&s, "SPGlow"),
Some(before),
"and re-parenting restores it"
);
}
/// **`Button:SetFont(file, height [, flags])` — it exists, it returns NOTHING, and it never
/// touches the label.** `0x780880`, wow-re's widget-method batch Q8 (§5-verified).
/// `_LazyPig/LazyPigMenu.lua:214` calls it straight on a `CreateFrame("Button", …)` and is blocked
/// on it today.
///
/// Three traps, each pinned here because each is a plausible implementation's silent divergence.
/// **Zero return values**: the shared impl pushes `1`/nil (a real font-load probe on a Font object)
/// and Button *discards* it (`xor eax,eax`), so answering `true` would hand an addon a probe the
/// client does not have. **`GetFont` returns three values**, off the normal embedded font — which
/// resolves through what that state inherits when nothing set it locally. And **a bare Button with
/// no `<ButtonText>` is a silent no-op**: `SetFont`/`GetFont` never dereference the FontString
/// pointer `+0x338`, so there is no error and — the observable half — **no lazy label creation**,
/// which `GetFontString()` would report.
#[test]
fn button_set_font_returns_nothing_and_is_a_no_op_without_a_label() {
let s = crate::script::UiScript::new().unwrap();
s.run(
r#"
Bare = CreateFrame("Button", "SFBare")
Labelled = CreateFrame("Button", "SFLabelled")
Labelled:SetText("Show Keybinds")
"#,
)
.unwrap();
// _LazyPig's line, on a Button that has no label at all.
assert_eq!(
s.eval::<i64>(r#"return select('#', SFBare:SetFont("Fonts\\FRIZQT__.TTF", 8))"#)
.unwrap(),
0,
"SetFont returns ZERO values — the delegate's 1/nil is discarded by a Button"
);
assert!(
s.eval::<bool>("return SFBare:GetFontString() == nil")
.unwrap(),
"no label existed and none was lazily created"
);
assert_eq!(
s.eval::<i64>("return select('#', SFBare:GetFont())")
.unwrap(),
3,
"GetFont returns THREE values"
);
assert_eq!(
s.eval::<(String, f32, String)>("return SFBare:GetFont()")
.unwrap(),
("Fonts\\FRIZQT__.TTF".to_string(), 8.0, String::new())
);
// Still three when nothing has ever set a font — path and height are nil, not absent.
assert_eq!(
s.eval::<i64>("return select('#', SFLabelled:GetFont())")
.unwrap(),
3
);
assert!(s
.eval::<bool>("local f = SFLabelled:GetFont() return f == nil")
.unwrap());
// The flags argument is normalized to its OUTLINETYPE token, like every other GetFont.
s.run(r#"SFBare:SetFont("Fonts\\SKURRI.TTF", 12, "THICKOUTLINE")"#)
.unwrap();
assert_eq!(
s.eval::<String>("local _, _, flags = SFBare:GetFont() return flags")
.unwrap(),
"THICKOUTLINE"
);
// Both arguments are required (`lua_isstring` + `lua_isnumber`, else the usage error).
assert!(s.run(r#"SFBare:SetFont("Fonts\\SKURRI.TTF")"#).is_err());
// Unset locally, GetFont reads through what the NORMAL state inherits — how a
// GameMenuButtonTemplate button answers its template's face before anything calls SetFont.
s.run(
r#"
CreateFont("SFInherited")
SFInherited:SetFont("Fonts\\MORPHEUS.TTF", 14)
SFLabelled:SetTextFontObject(SFInherited)
"#,
)
.unwrap();
assert_eq!(
s.eval::<(String, f32)>("local p, h = SFLabelled:GetFont() return p, h")
.unwrap(),
("Fonts\\MORPHEUS.TTF".to_string(), 14.0)
);
// …and a local SetFont outranks that inherited object on the label's PAINT, not only in the
// getter — the whole point of the call `_LazyPig` makes.
s.run(r#"SFLabelled:SetFont("Fonts\\SKURRI.TTF", 9)"#)
.unwrap();
let (font, height) = s
.extract()
.iter()
.find_map(|q| match &q.content {
crate::script::QuadContent::Text {
text: Some(t),
font,
font_height,
..
} if t == "Show Keybinds" => Some((font.clone(), *font_height)),
_ => None,
})
.expect("the labelled button draws its text");
assert_eq!(
(font.as_deref(), height),
(Some("Fonts\\SKURRI.TTF"), Some(9.0)),
"the button's own font repaints the label over the inherited font object"
);
}
/// **`SetNonSpaceWrap` / `CanNonSpaceWrap`** — FontString only (`0x79e9f0`/`0x79ead0`, wow-re's
/// widget-method batch). `oRA2/Leader/Item.lua:561` is `f.textname:SetNonSpaceWrap(false)`, reached
/// by two addons.
///
/// Two contract details, both easy to get wrong and both pinned here: the getter is
/// **`CanNonSpaceWrap`**, not `GetNonSpaceWrap`, and it answers **`1` or nil** rather than a
/// boolean — 1.12 predates that convention and an addon may compare against 1. And a **no-argument
/// call ENABLES** it; the default is on, so it is not a query.
#[test]
fn non_space_wrap_defaults_on_and_answers_one_or_nil() {
let s = crate::script::UiScript::new().unwrap();
s.run(r#"f = CreateFrame("Frame", "NSF") fs = f:CreateFontString("NSFText", "ARTWORK")"#)
.unwrap();
// Default is ON, and the answer is the NUMBER 1 — not `true`.
assert_eq!(
s.eval::<Option<i64>>("return NSFText:CanNonSpaceWrap()")
.unwrap(),
Some(1),
"default on, answered as 1"
);
assert!(
!s.eval::<bool>("return NSFText:CanNonSpaceWrap() == true")
.unwrap(),
"it must NOT be a boolean — an addon comparing against 1 would break"
);
// oRA2's line.
s.run("NSFText:SetNonSpaceWrap(false)").unwrap();
assert_eq!(
s.eval::<Option<i64>>("return NSFText:CanNonSpaceWrap()")
.unwrap(),
None,
"off answers nil, not 0 and not false"
);
// A no-argument call ENABLES rather than querying.
s.run("NSFText:SetNonSpaceWrap()").unwrap();
assert_eq!(
s.eval::<Option<i64>>("return NSFText:CanNonSpaceWrap()")
.unwrap(),
Some(1),
"a bare SetNonSpaceWrap() turns it ON"
);
}
/// **The EditBox font block — the first sixteen entries of its own registrar table, and the largest
/// single gap the per-kind widget-method census found** (decision 1229, whose ranking opens
/// `63 EditBox:SetFontObject (on Texture, FontString)`).
///
/// `EditBox`'s table is `.data 0x87bb68`, **48 entries** — the count read from the `mov edx,0x30` at
/// the registering site `0x799ab5`, never from a run-length scan. There is no `FontInstance` class
/// in the 1.12 Lua chain (wow-re `widget-api-batch-benilla.md`): each of the six text-bearing types
/// re-declares the block in its own flat table, so membership is a per-table fact and **a name we
/// add that the table does not carry is exactly as wrong as one we miss**.
///
/// The two halves pinned here are therefore both directions. Present: the fourteen we wire.
/// Absent: `SetSpacing`/`GetSpacing`, which the table *does* carry (#10–#11) and we deliberately
/// withhold because nothing here models line spacing — a stored-but-undrawn setter is the silent
/// divergence of 1203/1205/1211, while a nil-value call names itself, and corpus demand is zero.
/// Absent too: the names that belong to *other* tables and must not leak onto an EditBox through
/// ours — `CopyFontObject` (Font object only), `SetNonSpaceWrap`/`CanNonSpaceWrap` and
/// `SetTextHeight`/`GetStringWidth` (FontString only).
#[test]
fn editbox_carries_the_font_block_its_registrar_table_declares() {
let s = crate::script::UiScript::new().unwrap();
s.run(r#"EB = CreateFrame("EditBox", "EBFont")"#).unwrap();
for name in [
"SetFontObject",
"GetFontObject",
"SetFont",
"GetFont",
"SetTextColor",
"GetTextColor",
"SetShadowColor",
"GetShadowColor",
"SetShadowOffset",
"GetShadowOffset",
"SetJustifyH",
"GetJustifyH",
"SetJustifyV",
"GetJustifyV",
] {
assert_eq!(
s.eval::<String>(&format!("return type(EBFont.{name})"))
.unwrap(),
"function",
"EditBox table entry '{name}' is missing"
);
}
for name in [
// On EditBox's real table, withheld on purpose (nothing models spacing).
"SetSpacing",
"GetSpacing",
// Not on EditBox's table at all — adding one is the same class of error as missing one.
"CopyFontObject",
"SetNonSpaceWrap",
"CanNonSpaceWrap",
"SetTextHeight",
"GetStringWidth",
] {
assert!(
s.eval::<bool>(&format!("return EBFont.{name} == nil"))
.unwrap(),
"'{name}' must NOT answer on an EditBox"
);
}
}
/// **Every EditBox font binding is a type-guard shim that returns whatever the shared implementation
/// returned**, so the arities below are the shared ones verbatim — verified at the bytes: each
/// binding ends `call <shared>` then `pop edi; pop esi; pop ebx; ret`, with **no `mov eax,N` and no
/// `xor eax,eax`** after the call.
///
/// The trap is `SetFont`, and it points the opposite way to the one already pinned for Button.
/// `Button:SetFont` (`0x780880`) ends `xor eax,eax` and returns **nothing** —
/// [`button_set_font_returns_nothing_and_is_a_no_op_without_a_label`]. `EditBox:SetFont`
/// (`0x797210` → `0x79f210`) does not discard, so the shared `lua_pushnumber(1.0)` (`0x79f345`) /
/// `lua_pushnil` (`0x79f361`) passes straight through: **one value, the number `1`, not `true` and
/// not zero values**. A probe written `if not eb:SetFont(f, s) then` needs the nil half; a probe
/// written `== 1` needs the number half.
///
/// `GetShadowColor` returns **four** values (`0x79f9b3 mov eax,0x4`); three is the plausible wrong
/// answer and silently drops the alpha.
#[test]
fn editbox_font_block_return_shapes_are_the_shared_implementations() {
let s = crate::script::UiScript::new().unwrap();
s.run(
r#"
EB = CreateFrame("EditBox", "EBShape")
CreateFont("EBProbeFont")
EBProbeFont:SetFont("Fonts\\FRIZQT__.TTF", 14)
"#,
)
.unwrap();
// The mutators push nothing at all.
for call in [
"SetFontObject(EBProbeFont)",
"SetTextColor(1, 0, 0)",
"SetShadowColor(0, 0, 0, 0.3)",
"SetShadowOffset(1, -1)",
r#"SetJustifyH("CENTER")"#,
r#"SetJustifyV("TOP")"#,
] {
assert_eq!(
s.eval::<i64>(&format!("return select('#', EBShape:{call})"))
.unwrap(),
0,
"EditBox:{call} must return ZERO values"
);
}
// The getters' exact widths.
for (call, want) in [
("GetFontObject()", 1),
("GetFont()", 3),
("GetTextColor()", 4),
("GetShadowColor()", 4),
("GetShadowOffset()", 2),
("GetJustifyH()", 1),
("GetJustifyV()", 1),
] {
assert_eq!(
s.eval::<i64>(&format!("return select('#', EBShape:{call})"))
.unwrap(),
want,
"EditBox:{call} must return {want} value(s)"
);
}
// SetFont: ONE value, and it is the number 1 — not `true`, and not nothing (the Button shape).
assert_eq!(
s.eval::<i64>(r#"return select('#', EBShape:SetFont("Fonts\\SKURRI.TTF", 12))"#)
.unwrap(),
1,
"EditBox:SetFont returns one value, unlike Button:SetFont"
);
assert_eq!(
s.eval::<String>(r#"return type(EBShape:SetFont("Fonts\\SKURRI.TTF", 12))"#)
.unwrap(),
"number",
"the number 1, never the boolean true"
);
assert!(s
.eval::<bool>(r#"return EBShape:SetFont("Fonts\\SKURRI.TTF", 12) == 1"#)
.unwrap());
// An unloadable (empty) path is the FALSEY answer, not an error — `!OmniCC`'s probe idiom.
assert!(s
.eval::<bool>(r#"return EBShape:SetFont("", 12) == nil"#)
.unwrap());
// Both arguments are gated (`lua_isstring` + `lua_isnumber`, else the usage error).
assert!(s.run(r#"EBShape:SetFont("Fonts\\SKURRI.TTF")"#).is_err());
// The shadow round-trips all four channels — three would drop the alpha.
assert_eq!(
s.eval::<(f32, f32, f32, f32)>("return EBShape:GetShadowColor()")
.unwrap(),
(0.0, 0.0, 0.0, 0.3)
);
assert_eq!(
s.eval::<(f32, f32)>("return EBShape:GetShadowOffset()")
.unwrap(),
(1.0, -1.0)
);
// GetFontObject answers the OBJECT, never its name — the corpus indexes the result immediately.
assert_eq!(
s.eval::<String>("return type(EBShape:GetFontObject())")
.unwrap(),
"table"
);
}
/// **The real `Dewdrop-2.0` shape, which is the whole reason this landed.**
///
/// `Dewdrop-2.0.lua:1673-1675` is verbatim:
/// ```lua
/// local editBox = CreateFrame("EditBox", nil, editBoxFrame)
/// editBoxFrame.editBox = editBox
/// editBox:SetFontObject(ChatFontNormal)
/// ```
/// — an **anonymous** EditBox parented to a frame, then `SetFontObject` on it two lines later. That
/// file is vendored into **63 addon folders** of the 218-addon corpus (65 copies: `FuBar` plus ~50
/// FuBar plugins, `BigWigs`, `AtlasLoot`, `oRA2`, `SnaFu`, …), so the census's `63` is **one library
/// replicated**, not 63 independent addons (decision 1207).
///
/// `AceGUIWidget-Slider.lua:204-210` is the same shape with `SetJustifyH("CENTER")` on the end.
#[test]
fn the_dewdrop_editbox_font_shape_resolves_and_paints() {
let s = crate::script::UiScript::new().unwrap();
s.run(
r#"
CreateFont("ChatFontNormal")
ChatFontNormal:SetFont("Fonts\\ARIALN.TTF", 11)
ChatFontNormal:SetTextColor(0.9, 0.9, 0.9)
editBoxFrame = CreateFrame("Frame", "DewdropEBFrame")
local editBox = CreateFrame("EditBox", nil, editBoxFrame)
editBoxFrame.editBox = editBox
editBox:SetFontObject(ChatFontNormal)
editBox:SetWidth(160)
editBox:SetHeight(13)
"#,
)
.unwrap();
// The link is live and readable back as the object.
assert!(s
.eval::<bool>("return DewdropEBFrame.editBox:GetFontObject() == ChatFontNormal")
.unwrap());
// …and the object's paint really reached the box's implicit FontString (the `[this+0x324]` the
// reference's shim hands the shared implementation).
assert_eq!(
s.eval::<(String, f32)>("local p, h = DewdropEBFrame.editBox:GetFont() return p, h")
.unwrap(),
("Fonts\\ARIALN.TTF".to_string(), 11.0)
);
let (r, g, b, _) = s
.eval::<(f32, f32, f32, f32)>("return DewdropEBFrame.editBox:GetTextColor()")
.unwrap();
assert_eq!((r, g, b), (0.9, 0.9, 0.9));
// The AceGUI slider's tail, on the same box.
s.run(r#"DewdropEBFrame.editBox:SetJustifyH("CENTER")"#)
.unwrap();
}
/// **`SetJustifyH`/`SetJustifyV` mask to their own axis, and a token from the other axis silently
/// clears rather than erroring.** The enum is `.rdata 0x811ad0`, `{bits, token}`: `LEFT` 0x01,
/// `CENTER` 0x02, `RIGHT` 0x04, `TOP` 0x08, `MIDDLE` 0x10, `BOTTOM` 0x20, stored in one dword with
/// bits 0–2 horizontal and 3–5 vertical.
///
/// Two traps, both of which our older FontString/Font copies of these verbs get wrong by coercing
/// anything unrecognised to CENTER/MIDDLE:
/// · **an unknown token RAISES** `Usage: %s:SetJustifyH("justify")` (`0x87c77c`);
/// · **a valid token from the wrong axis parses and then masks to nothing** — `SetJustifyH("TOP")`
/// yields 0x08 and `0x08 & 0x07 == 0`, so justifyH is CLEARED and `GetJustifyH()` answers the
/// literal `"UNKNOWN"` (`0x6f1a00`, `.data 0x838044`). No error is raised either way.
#[test]
fn editbox_justify_masks_to_its_axis_and_answers_unknown() {
let s = crate::script::UiScript::new().unwrap();
s.run(r#"EB = CreateFrame("EditBox", "EBJustify")"#)
.unwrap();
// **LEFT, not the generic font default CENTER.** The `CSimpleFont` ctor really does default to
// `0x212` (CENTER | MIDDLE | 0x200), but the EditBox ctor overrides the horizontal axis right
// after linking its font instance (`0x779bcd … and eax,~6; or eax,1;` stored at `0x779be4`), so
// a fresh box starts at `0x211`. Taking the generic default is the plausible wrong answer, and
// it is exactly what this test asserted until wow-re's §5 trio read that ctor
// (`system/ui/scratch/editbox-font-surface.md` §6.2).
assert_eq!(
s.eval::<String>("return EBJustify:GetJustifyH()").unwrap(),
"LEFT"
);
assert_eq!(
s.eval::<String>("return EBJustify:GetJustifyV()").unwrap(),
"MIDDLE"
);
// Each axis is replaced independently — setting V leaves H alone.
s.run(r#"EBJustify:SetJustifyH("LEFT") EBJustify:SetJustifyV("BOTTOM")"#)
.unwrap();
assert_eq!(
s.eval::<(String, String)>("return EBJustify:GetJustifyH(), EBJustify:GetJustifyV()")
.unwrap(),
("LEFT".to_string(), "BOTTOM".to_string())
);
// The cross-axis trap: "TOP" parses, masks to 0, and CLEARS justifyH.
s.run(r#"EBJustify:SetJustifyH("TOP")"#).unwrap();
assert_eq!(
s.eval::<String>("return EBJustify:GetJustifyH()").unwrap(),
"UNKNOWN",
"a vertical token on SetJustifyH clears the axis rather than centering it"
);
assert_eq!(
s.eval::<String>("return EBJustify:GetJustifyV()").unwrap(),
"BOTTOM",
"and it leaves the vertical axis untouched"
);
// An unrecognised token is the usage error, never a silent fallback to CENTER.
assert!(s.run(r#"EBJustify:SetJustifyH("SIDEWAYS")"#).is_err());
}
/// **`EditBox:SetJustifyV` echoes through its getter and never reaches the pixels — on the real
/// client too, permanently and by construction.** wow-re `editbox-font-surface.md` §6 (§5 trio +
/// byte arbitration): `CSimpleFontString+0x124` is a per-bit *inherit* mask over the rendered
/// justify `+0x120`, and `SetMultiLine 0x77a4a0` clears the whole vertical group `0x38` from it on
/// **both** legs while writing the V bits locally — multi-line → TOP, single-line → MIDDLE. The
/// EditBox ctor calls `SetMultiLine` unconditionally at birth (`0x779c2f`, with the ctor's zero
/// register), and a census of all 256 `+0x124` operands image-wide found every `CSimpleFontString`
/// writer to be an AND: **nothing ever ORs an inherit bit back in.** So the value written by
/// `SetJustifyV` is masked out at `0x77086e`, while `GetJustifyV` reads the font *instance*
/// (`0x79fd73`) and answers it faithfully.
///
/// This pins the shape rather than the accident: a future change that "fixes" the getter by wiring
/// V justify through to the region would match neither the client nor our own draw law.
#[test]
fn editbox_justify_v_echoes_but_multiline_alone_decides_the_pixels() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
EB = CreateFrame("EditBox", "EBJV")
EBJV:SetPoint("CENTER", 0, 0) EBJV:SetWidth(120) EBJV:SetHeight(40)
EBJV:SetText("typed")
"#,
)
.unwrap();
/// The vertical justification the box's text actually draws with.
fn drawn_v(s: &crate::script::UiScript) -> crate::script::JustifyV {
s.extract()
.iter()
.find_map(|q| match &q.content {
crate::script::QuadContent::Text {
text: Some(t),
justify_v,
..
} if t == "typed" => Some(*justify_v),
_ => None,
})
.expect("the box draws its text")
}
// A single-line box renders MIDDLE, the `0x77a599` leg.
assert_eq!(drawn_v(&s), crate::script::JustifyV::Middle);
// SetJustifyV("TOP") is echoed by the getter…
s.run(r#"EBJV:SetJustifyV("TOP")"#).unwrap();
assert_eq!(
s.eval::<String>("return EBJV:GetJustifyV()").unwrap(),
"TOP",
"the getter reads the font instance and echoes faithfully"
);
// …and does NOT move the pixels. This is the disagreement, and it is faithful.
assert_eq!(
drawn_v(&s),
crate::script::JustifyV::Middle,
"SetJustifyV is masked out of the rendered justify — multiLine alone decides it"
);
// multiLine is what actually moves it, on the `0x77a509` leg.
s.run("EBJV:SetMultiLine(true)").unwrap();
assert_eq!(drawn_v(&s), crate::script::JustifyV::Top);
// …and the getter still echoes whatever was last set, unaffected.
assert_eq!(
s.eval::<String>("return EBJV:GetJustifyV()").unwrap(),
"TOP"
);
}
/// **`Texture:SetDesaturated(flag)` answers `shaderSupported`, and ours says nil.**
///
/// `0x79c1e0` (wow-re ledger). The reference's own `ItemButtonTemplate.lua:69` is
/// `local shaderSupported = icon:SetDesaturated(desaturated)`, and lines 70-78 fall back to a 0.5
/// grey vertex tint when that answer is falsy — 1.12 shipped on cards without the shader, so the
/// verb reporting "no" is a real machine's answer, not a stub.
///
/// We have no desaturating shader, so nil is the honest reply: claiming support would suppress
/// FrameXML's grey fallback and leave disabled icons at full colour, which looks *more* wrong.
/// The test pins the return shape rather than the state, because the return is what callers branch
/// on — and `nil`, not `false`, because the C answer is `1|nil` and the reference writes
/// `not shaderSupported`.
///
/// 98 of the 109 draw-then-raise addons in the corpus die on this one verb, via
/// `FuBar_Panel.lua:43` → Dewdrop `AddLine` → `button.arrow:SetDesaturated(true)`, unguarded.
#[test]
fn set_desaturated_reports_no_shader_support_and_does_not_raise() {
let s = crate::script::UiScript::new().unwrap();
s.run(r#"f = CreateFrame("Frame", "DsF") tex = f:CreateTexture("DsTex", "ARTWORK")"#)
.unwrap();
// Dewdrop's exact call — the one 98 addons reach. It must not raise.
s.run("DsTex:SetDesaturated(true)").unwrap();
// ...and it answers nil, so the reference's `not shaderSupported` branch is taken.
assert!(
s.eval::<bool>("return DsTex:SetDesaturated(true) == nil")
.unwrap(),
"shaderSupported must be nil (1|nil C shape), not false"
);
assert_eq!(
s.eval::<i64>("return select('#', DsTex:SetDesaturated(true))")
.unwrap(),
1,
"one return value"
);
// The reference's own consumer, transcribed: a falsy answer greys via vertex colour.
s.run(
r#"
local shaderSupported = DsTex:SetDesaturated(true)
local r, g, b = 1, 1, 1
if not shaderSupported then r, g, b = 0.5, 0.5, 0.5 end
DsTex:SetVertexColor(r, g, b)
"#,
)
.unwrap();
let (r, g, b) = s
.eval::<(f32, f32, f32)>("return DsTex:GetVertexColor()")
.unwrap();
assert_eq!((r, g, b), (0.5, 0.5, 0.5), "the no-shader fallback greys");
// Both spellings of "off" are off — nil and false.
s.run("DsTex:SetDesaturated(nil) DsTex:SetDesaturated(false)")
.unwrap();
}
@@ -574,3 +574,91 @@ fn a_vertex_colour_without_a_texture_draws_nothing() {
"no texture at +0xcc -> emit NOTHING, never a solid plate of the surviving tint"
);
}
/// **`SetTexture` ignores arguments past the path**, because the client's does.
///
/// The path form reads ONE argument (`0x770200`); only the colour form (`0x770360`) reads up to
/// four, and a C function takes what it wants off the Lua stack and ignores the rest. We typed the
/// trailing three as `Option<f32>` and so RAISED on a stray extra — `bad argument #3: error
/// converting Lua boolean to f32` — where the client silently accepts.
///
/// The live case is `_LazyPig/LazyPigMenu.lua:182`:
/// `texture_title:SetTexture("Interface\DialogFrame\UI-DialogBox-Header", true)`. The `true` is
/// meaningless in 1.12; the addon reached us only once the survey began seating the addon registry.
///
/// Asserted as "does not raise", which is the whole claim — there is no `GetTexture` binding to read
/// the path back through, and inventing one to satisfy a test would be the tail wagging the dog.
/// The colour form is exercised alongside so the fix cannot have made it lax: a non-numeric channel
/// must take the same default a missing one does, exactly as `lua_tonumber` of a non-number is 0.
#[test]
fn set_texture_ignores_arguments_past_the_path_like_the_client_does() {
let s = script();
s.run(
r#"
f = CreateFrame("Frame", "SetTexArgs")
t = f:CreateTexture(nil, "ARTWORK")
t:SetTexture("Interface\\DialogFrame\\UI-DialogBox-Header", true)
"#,
)
.expect("a stray extra argument must not raise — the client ignores it");
// The colour form, with a boolean and a numeric string among the channels.
s.run("t:SetTexture(0.25, '0.5', true)")
.expect("the colour form must tolerate what lua_tonumber tolerates");
// And the ordinary shapes still work.
s.run("t:SetTexture(nil) t:SetTexture('') t:SetTexture(1, 0, 0, 1)")
.expect("clear, blank and the plain colour form are unaffected");
}
/// **`SetGradientAlpha` / `SetGradient` exist, store both stops, and paint.**
///
/// These were the single wall in front of the corpus's largest family: `FuBar\FuBar_Panel.lua:144`
/// calls `SetGradientAlpha` while building the bar, so all 20 FuBar plugins died there — right after
/// the chunk-name/`debugstack` fix got them that far.
///
/// Asserted on the region PAINTING (a gradient-only region must emit a quad, because the client
/// generates the gradient into the same texture slot the colour form fills) and on the midpoint
/// being what a one-tint quad shows. The full gradient stays on the region for a renderer that
/// grows a second stop.
#[test]
fn a_gradient_is_stored_whole_and_painted_as_its_midpoint() {
let mut s = script();
s.run(
r#"
f = CreateFrame("Frame", "GradProbe")
f:SetWidth(100) f:SetHeight(20)
f:SetPoint("TOPLEFT", 0, 0)
t = f:CreateTexture(nil, "ARTWORK")
t:SetAllPoints(f)
-- FuBar's own call: white at both stops, ALPHA only, vertical.
t:SetGradientAlpha("VERTICAL", 1, 1, 1, 0, 1, 1, 1, 0.5)
"#,
)
.expect("SetGradientAlpha must exist and accept the client's argument shape");
s.resolve();
let painted = s.extract().iter().any(|q| {
matches!(&q.content, crate::script::QuadContent::Texture { color: Some(c), .. }
// midpoint of alpha 0.0 and 0.5
if (c[3] - 0.25).abs() < 1e-6 && c[0] == 1.0)
});
assert!(
painted,
"a region carrying only a gradient must paint, at the midpoint of its two stops"
);
// The alpha-less twin: both stops opaque, and a non-"VERTICAL" token is horizontal.
s.run("t:SetGradient('HORIZONTAL', 1, 0, 0, 0, 0, 1)")
.expect("SetGradient takes six colour arguments and no alpha");
s.resolve();
let mid = s.extract().iter().find_map(|q| match &q.content {
crate::script::QuadContent::Texture { color: Some(c), .. } => Some(*c),
_ => None,
});
assert_eq!(
mid.map(|c| [c[0], c[1], c[2], c[3]]),
Some([0.5, 0.0, 0.5, 1.0]),
"SetGradient's stops are opaque, so the midpoint alpha is 1"
);
}
+111 -2
View File
@@ -92,8 +92,60 @@ fn sandbox_removes_dangerous_globals() {
)
.unwrap();
assert!(all_nil);
// debugstack stub survives and returns "".
assert_eq!(s.eval::<String>("return debugstack()").unwrap(), "");
// `debugstack` survives the sandbox — and now returns a REAL traceback, not the `""` this
// used to assert. The stub was fine for addons that only DISPLAY it and wrong for the ones
// that PARSE it: `FuBarPlugin-2.0.lua:752` finds each plugin's own folder in
// `debugstack(6, 1, 0)`, and against `""` that returned nil and killed 20 corpus addons.
let trace = s.eval::<String>("return debugstack()").unwrap();
assert!(
trace.contains("traceback"),
"debugstack must return a real traceback: {trace:?}"
);
// A level far past the top of the stack yields the bare header and never raises — a caller
// that guesses too deep still gets a string it can `string.find` against, which is exactly how
// every corpus caller uses it.
assert_eq!(
s.eval::<String>("return debugstack(99)").unwrap().trim(),
"stack traceback:"
);
}
/// **An addon's chunk is named the way the CLIENT names it**, because addons parse that name.
///
/// `FuBarPlugin-2.0.lua:752` is `string.find(debugstack(6, 1, 0), "\\AddOns\\(.*)\\")`, and it
/// feeds the capture into `format("Interface\\AddOns\\%s\\icon", folderName)`. Without a name
/// mlua defaults the chunk to the RUST caller location, that pattern misses, and every FuBar plugin
/// dies formatting a nil three frames from the cause.
///
/// The greedy `(.*)` is why the file has to be in the name too: it captures to the LAST backslash.
#[test]
fn an_addon_chunk_is_named_the_way_the_client_names_it() {
let name = crate::script::addon_chunk_name("FuBar_BagFu", "FuBar_BagFu.lua");
assert_eq!(name, "@Interface\\AddOns\\FuBar_BagFu\\FuBar_BagFu.lua");
// FuBar's own pattern, run for real against a traceback from a chunk loaded under that name.
let s = script();
s.run_chunk_named(
b"function BenillaProbeFolder() return debugstack(1, 1, 0) end",
&name,
)
.unwrap();
let folder: String = s
.eval(
"local _, _, f = string.find(BenillaProbeFolder(), \"\\\\AddOns\\\\(.*)\\\\\") \
return f or '<no match>'",
)
.unwrap();
assert_eq!(
folder, "FuBar_BagFu",
"FuBarPlugin's own capture must yield the folder name"
);
// A nested path in the manifest keeps its separators as backslashes.
assert_eq!(
crate::script::addon_chunk_name("Big", "libs/Thing/Thing.lua"),
"@Interface\\AddOns\\Big\\libs\\Thing\\Thing.lua"
);
}
#[test]
@@ -211,3 +263,60 @@ fn the_lua_5_0_dialect_vanilla_addons_are_written_in_runs_here() {
)
.unwrap();
}
/// **`time()` is real epoch seconds and `date()` formats them** — engine globals in the 1.12
/// client's own `_G` (slots 34/33 of its base registry) that we lacked entirely, because the
/// sandbox strips `os`.
///
/// `time` was the top name in the session-start `attempt to call global` row. Every corpus site
/// persists it into SavedVariables and compares across sessions
/// (`FTC_Save[k].LastCheck = time()`), so a session-relative clock like `GetTime` would be wrong in
/// a way that only shows up on the SECOND login.
///
/// The formats asserted are the ones read off real call sites, against a FIXED epoch so the
/// conversion is checked rather than the machine's clock: 2001-09-09 01:46:40 UTC, a Sunday.
#[test]
fn time_is_epoch_seconds_and_date_formats_them() {
let s = script();
// A plausible wall clock: after 2020, before 2100. Pins that this is not GetTime's 0-based one.
let now: i64 = s.eval("return time()").unwrap();
assert!(
(1_577_836_800..4_102_444_800).contains(&now),
"time() must be wall-clock epoch seconds, got {now}"
);
// 1_000_000_000 = 2001-09-09 01:46:40 UTC, a Sunday, day 252 of the year.
for (fmt, want) in [
("%Y-%m-%d", "2001-09-09"),
("%H:%M:%S", "01:46:40"),
(
"%A, %B %d, %Y - %H:%M",
"Sunday, September 09, 2001 - 01:46",
),
("%a %b %y", "Sun Sep 01"),
("%I:%M %p", "01:46 AM"),
("%j", "252"),
("%w", "0"),
("%c", "Sun Sep 9 01:46:40 2001"),
("100%%", "100%"),
// An unknown specifier is emitted verbatim rather than swallowed.
("%Q", "%Q"),
] {
let got: String = s
.eval(&format!(r#"return date("{fmt}", 1000000000)"#))
.unwrap();
assert_eq!(got, want, "date({fmt:?})");
}
// Bare `date()` is `%c` (Recap.lua:2690 calls it with no arguments) and must not raise.
let bare: String = s.eval("return date()").unwrap();
assert!(
bare.len() > 10,
"bare date() must format something: {bare:?}"
);
// A leap day, because the civil conversion is where a date implementation goes wrong.
let leap: String = s.eval(r#"return date("%Y-%m-%d %A", 951782400)"#).unwrap();
assert_eq!(leap, "2000-02-29 Tuesday");
}