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:
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user