the survey drops its silent caps, and the Region map is the unit
- every ranked list now states what it dropped and `--deep` reads the whole tail — a silent top-16 over 1,946 rows had already produced one wrong "nothing left" conclusion - the backpack and equipped gear take their seats beside the spellbook, and a rankless spell's rank comes back "" rather than nil - a FontString answers the full Region method map as one set — a text region's GetParent works — and RaidWarningFrame and PanelTemplates_Tab_OnClick arrive for the addons that name them - SetRaidTargetIconTexture lands: the half of a raid mark an addon draws with Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -65,4 +65,39 @@
|
||||
Fonts.xml:258), which is the face the hand-rolled version used too. -->
|
||||
<FontString inherits="ErrorFont" justifyH="CENTER"/>
|
||||
</MessageFrame>
|
||||
|
||||
<!-- RaidWarningFrame (ref RaidWarning.xml:4-24), declared here beside the frame it anchors to.
|
||||
|
||||
Two corpus addons push into it directly — `Participant/RaidWarn.lua:133` and
|
||||
`oRA2/RaidWarn.lua:133` are the same code in two distributions, plus their MainTank.lua's
|
||||
"Tank %s has died!" — and both call
|
||||
`RaidWarningFrame:AddMessage(text, r, g, b, 1.0, UIERRORS_HOLD_TIME)`. That is the plain
|
||||
MessageFrame arity we already ship: the 6th argument is read and DROPPED, because the
|
||||
reference's own `0x795590` reads five and stops (messageframe/plain.rs's first law).
|
||||
Absent, that line is `attempt to index global` and takes the handler with it.
|
||||
(`BigWigs` also names it — in a LOCALISATION STRING, "Suppressing RaidWarningFrame". Not a
|
||||
use; 1207.)
|
||||
|
||||
Geometry quoted: 512x40, insertMode TOP, HIGH + toplevel, TOP anchored to UIErrorsFrame's
|
||||
BOTTOM at (0,-10), and the direct-child FontString the class draws its lines through —
|
||||
GameFontNormalHuge, CENTER, exactly as the sibling above takes ErrorFont.
|
||||
|
||||
**No <Scripts>, deliberately.** The reference's OnLoad/OnEvent call into RaidWarning.lua to
|
||||
register CHAT_MSG_RAID_WARNING and route it here. We already deliver that chat type through
|
||||
`ui_chat::feed`, so wiring a second consumer would print every raid warning TWICE; and
|
||||
declaring handlers whose functions we do not ship would raise at load. The frame exists and
|
||||
accepts messages, which is what the corpus asks of it — the automatic routing is the
|
||||
ChatFrame_OnEvent architecture question, not this frame's.
|
||||
|
||||
RaidBossEmoteFrame, its sibling in the same file, is NOT declared: zero corpus addons name
|
||||
it, and a shadowed name with no caller is building against a guess (1222's rejected clause). -->
|
||||
<MessageFrame name="RaidWarningFrame" insertMode="TOP" frameStrata="HIGH" toplevel="true">
|
||||
<Size><AbsDimension x="512" y="40"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOP" relativeTo="UIErrorsFrame" relativePoint="BOTTOM">
|
||||
<Offset><AbsDimension x="0" y="-10"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<FontString inherits="GameFontNormalHuge" justifyH="CENTER"/>
|
||||
</MessageFrame>
|
||||
</Ui>
|
||||
|
||||
@@ -1277,6 +1277,30 @@
|
||||
-- isDisabled/SetDisabledTabState arm (no window has a disabled tab yet) and the SelectTab
|
||||
-- tab-tooltip hide (our tabs set no tooltip). First consumer: the merchant's
|
||||
-- Merchant/Buyback pair.
|
||||
-- The kit's XML-facing entry point (ref l.2-4), and the one member of it this client never
|
||||
-- had. Not an omission anyone decided: our own windows wire each tab's OnClick to their own
|
||||
-- handler (the merchant's Merchant/Buyback pair, CharacterFrame's pages), so nothing here
|
||||
-- ever reached for the generic one. An ADDON does the opposite — it puts the reference's
|
||||
-- name straight in its <OnClick> and lets the kit do the work:
|
||||
--
|
||||
-- Enchantrix EnchantrixBarker.xml:61 (Enchantrix_BarkerOptions_Frame)
|
||||
-- Outfitter Outfitter.xml:7 (OutfitterFrame)
|
||||
-- SimpleActionSets SimpleActionSets.xml:342 (this:GetParent())
|
||||
-- TheoryCraft TheoryCraft.xml:163 (TheoryCraft)
|
||||
--
|
||||
-- Four addons, four separate files, so 1207's one-library caveat does not apply. A raw grep
|
||||
-- says FIVE: QuestItemTemplates.xml:17 has the call commented out. Reading the line is what
|
||||
-- tells those apart, and the survey's own count of 4 was the right one.
|
||||
--
|
||||
-- Missing, every one of them raises on each click of the tab, so the addon's options window
|
||||
-- stays on whatever page it opened with and cannot be navigated at all. `this` is the
|
||||
-- clicked tab button, whose id is its slot in the row; the frame is passed in separately
|
||||
-- because the tab's parent is not always the frame owning the row — SimpleActionSets is the
|
||||
-- case that proves it, passing `this:GetParent()` explicitly.
|
||||
function PanelTemplates_Tab_OnClick(frame)
|
||||
PanelTemplates_SetTab(frame, this:GetID())
|
||||
end
|
||||
|
||||
function PanelTemplates_SetNumTabs(frame, numTabs)
|
||||
frame.numTabs = numTabs
|
||||
end
|
||||
|
||||
@@ -112,6 +112,41 @@
|
||||
HAPPINESS = { r = 0.0, g = 1.0, b = 1.0 },
|
||||
}
|
||||
|
||||
-- ── The raid-target mark atlas (ref TargetFrame.lua l.460-463 + l.475-484) ──────────────
|
||||
-- `Interface\TargetingFrame\UI-RaidTargetingIcons` is one 256x256 sheet of 4x4 64px cells,
|
||||
-- in mark order: star, circle, diamond, triangle, moon, square, cross, skull. The helper
|
||||
-- turns a 1..8 mark index into that cell's texcoords on a CALLER-SUPPLIED texture — it owns
|
||||
-- no frame and reads no unit, which is why an addon can hand it a texture of its own.
|
||||
--
|
||||
-- Three corpus addons do, in two codebases (`Optional` and `oRA2` ship the same two files):
|
||||
-- CustomNameplates options.lua:363 (its options preview)
|
||||
-- Optional / oRA2 MainTank.lua:1216, PlayerTarget.lua:1472
|
||||
-- We answer `GetRaidTargetIndex(unit)` already (0434 §6); this is the other half — what an
|
||||
-- addon DRAWS with that number — and without it those lines raise while redrawing a mark.
|
||||
--
|
||||
-- TRANSCRIBED VERBATIM, INCLUDING THE REFERENCE'S OWN SLIP: the row is derived with
|
||||
-- `/ RAID_TARGET_TEXTURE_ROWS` while the column uses `mod(.., RAID_TARGET_TEXTURE_COLUMNS)`.
|
||||
-- Rows and columns are both 4, so the two agree for every index this sheet holds and the
|
||||
-- output is right; "fixing" it to divide by COLUMNS computes the same coords today and would
|
||||
-- quietly diverge from the client the day a caller changes a constant. These are globals in
|
||||
-- the reference for exactly that reason — addons read them, and two of ours are addons that
|
||||
-- draw their own marks.
|
||||
RAID_TARGET_ICON_DIMENSION = 64
|
||||
RAID_TARGET_TEXTURE_DIMENSION = 256
|
||||
RAID_TARGET_TEXTURE_COLUMNS = 4
|
||||
RAID_TARGET_TEXTURE_ROWS = 4
|
||||
|
||||
function SetRaidTargetIconTexture(texture, raidTargetIconIndex)
|
||||
raidTargetIconIndex = raidTargetIconIndex - 1
|
||||
local left, right, top, bottom
|
||||
local coordIncrement = RAID_TARGET_ICON_DIMENSION / RAID_TARGET_TEXTURE_DIMENSION
|
||||
left = mod(raidTargetIconIndex, RAID_TARGET_TEXTURE_COLUMNS) * coordIncrement
|
||||
right = left + coordIncrement
|
||||
top = floor(raidTargetIconIndex / RAID_TARGET_TEXTURE_ROWS) * coordIncrement
|
||||
bottom = top + coordIncrement
|
||||
texture:SetTexCoord(left, right, top, bottom)
|
||||
end
|
||||
|
||||
-- The client's UnitReactionColor table (ref-TargetFrame.lua l.6-14), indexed by
|
||||
-- UnitReaction 1..8 (hated..exalted): 1-2 red, 3 orange, 4 neutral yellow, 5-7 green. The
|
||||
-- name-plate reaction tint (TargetFrame_CheckFaction) reads it — this is what makes a neutral
|
||||
@@ -1056,7 +1091,29 @@
|
||||
of its own layer — the ref hides the number while resting by putting
|
||||
it in a LOWER layer than the badge. Shipped in OVERLAY by 1082, the
|
||||
number rode ON the badge (director report, 2026-08-07); decision 1093. -->
|
||||
<!-- Name 100×10 CENTER (50,19) (ref l.58-69). -->
|
||||
<!-- Name 100×10 CENTER (50,19) (ref l.58-69).
|
||||
|
||||
A DIVERGENCE, measured and deliberately kept. The reference names the
|
||||
three unit-frame name FontStrings LITERALLY — `PlayerName`
|
||||
(PlayerFrame.xml:58), `TargetName` (TargetFrame.xml:152), `PetName`
|
||||
(PetFrame.xml:69) — where ours derive from `$parent`. That is 1222's
|
||||
shadowed-global class on a region, and it was tried: the rename is
|
||||
NOT free and it buys nothing.
|
||||
· Not free — `UnitFrame_OnLoad` binds the region by COMPOSING the
|
||||
name (`getglobal(tf .. "NameText")`, l.174), so a text search for
|
||||
the derived global finds zero hits and the rename still breaks
|
||||
every unit frame. It broke 40 tests. A name built inside
|
||||
`getglobal` is invisible to a grep — the same trap that once left
|
||||
CleanMinimap in the tail for three rounds.
|
||||
· Buys nothing — the survey's "why" read-back matches ZERO
|
||||
error rows for `TargetName` across
|
||||
the 218-addon corpus. The one syntactic caller,
|
||||
`CT_UnitFrames/CT_TargetFrame.xml:181`'s
|
||||
`<Anchor relativeTo="TargetName">`, is not what stops that addon:
|
||||
it dies at its own `CT_PlayerFrame.lua:206`.
|
||||
So: 1222's rejected clause applies (a shadowed name with no observable
|
||||
caller is building against a guess), and the cost lands on the frames
|
||||
the director looks at every session. Revisit if a row ever names it. -->
|
||||
<FontString name="$parentNameText" inherits="GameFontNormalSmall" text="Unknown">
|
||||
<Size><AbsDimension x="100" y="10"/></Size>
|
||||
<Anchors>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! `addon_harness` — load a folder of addons, one per VM, and print what happened.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -q -p benilla-app --example addon_harness -- <folder> [--verbose] [--why <substr>] [--status <file>] [--diff <file>]
|
||||
//! cargo run -q -p benilla-app --example addon_harness -- <folder> [--verbose] [--why <substr>] [--deep [n]] [--status <file>] [--diff <file>]
|
||||
//! ```
|
||||
//!
|
||||
//! The instrument decision 1188 phase 6 asks for: *"which addons work" is a number that can be
|
||||
@@ -17,6 +17,42 @@ use benilla_app::addon_harness;
|
||||
/// failure to its call site needed the frames below it, and the addon/file/line only appear there.
|
||||
const WHY_TRACEBACK_LINES: usize = 8;
|
||||
|
||||
/// Print a ranked demand table — and **say what was dropped**.
|
||||
///
|
||||
/// Every one of these lists is a queue, and each was printed as a silent top-N. A silent cut reads
|
||||
/// as "that is the whole list", and this arc has one expensive instance of exactly that: the
|
||||
/// "a reference frame we do not build" class was swept, found clean, and recorded CLOSED — while
|
||||
/// `ShapeshiftBarLeft` sat below the cut with two addons behind it, and stayed there until an
|
||||
/// addon's first error named it (1219's class, first regions). The sweep was honest; the list it
|
||||
/// swept was not complete and did not say so.
|
||||
///
|
||||
/// So the tail is now stated: how many rows, and how many addon-mentions, were not shown. The
|
||||
/// caller keeps its own `take` — this only refuses to hide the remainder.
|
||||
///
|
||||
/// `--deep [n]` overrides every caller's `take` (see [`DEEP`]). 1242 made the cut VISIBLE and
|
||||
/// rejected raising it — an unreadable report helps nobody. But "visible" only tells a sweep that
|
||||
/// rows exist; it still cannot read them, and `--why` opens one name at a time, which is no way to
|
||||
/// walk 1930 of them. So the cut stays where it is for the report, and a sweep asks for the rest.
|
||||
fn ranked(rows: Vec<(String, usize)>, take: usize) {
|
||||
let take = DEEP.get().copied().flatten().unwrap_or(take);
|
||||
let total = rows.len();
|
||||
for (name, count) in rows.iter().take(take) {
|
||||
println!(" {count:>4} {name}");
|
||||
}
|
||||
if total > take {
|
||||
let tail: usize = rows[take..].iter().map(|(_, c)| c).sum();
|
||||
println!(
|
||||
" …{} more rows below the cut ({tail} addon-mentions) — TRUNCATED, not exhausted; \
|
||||
`--why <name>` opens any row.",
|
||||
total - take
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `--deep` override, set once in `main`. `Some(n)` shows `n` rows of EVERY ranked list;
|
||||
/// `--deep` with no number means all of them. Absent, each list keeps the `take` its caller chose.
|
||||
static DEEP: std::sync::OnceLock<Option<usize>> = std::sync::OnceLock::new();
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let Some(root) = args.next() else {
|
||||
@@ -65,6 +101,13 @@ fn main() {
|
||||
.position(|a| a == "--diff")
|
||||
.and_then(|i| rest.get(i + 1))
|
||||
.cloned();
|
||||
// `--deep [n]`: open the tails. A bare `--deep` (or one followed by the next flag) means all.
|
||||
let deep = rest.iter().position(|a| a == "--deep").map(|i| {
|
||||
rest.get(i + 1)
|
||||
.and_then(|n| n.parse::<usize>().ok())
|
||||
.unwrap_or(usize::MAX)
|
||||
});
|
||||
let _ = DEEP.set(deep);
|
||||
let root = std::path::PathBuf::from(root);
|
||||
|
||||
let reports = addon_harness::survey(&root);
|
||||
@@ -322,9 +365,7 @@ fn main() {
|
||||
let templates = addon_harness::template_demand(&reports);
|
||||
if !templates.is_empty() {
|
||||
println!("\n most-wanted missing TEMPLATES (addons naming each in CreateFrame):");
|
||||
for (name, count) in templates.into_iter().take(12) {
|
||||
println!(" {count:>4} {name}");
|
||||
}
|
||||
ranked(templates, 12);
|
||||
}
|
||||
|
||||
// The same question over the OTHER axis, and the one that actually moves the headline: a
|
||||
@@ -336,9 +377,7 @@ fn main() {
|
||||
let inherits = addon_harness::inherits_demand(&reports);
|
||||
if !inherits.is_empty() {
|
||||
println!("\n most-wanted missing TEMPLATES (addons naming each in an XML inherits=):");
|
||||
for (name, count) in inherits.into_iter().take(12) {
|
||||
println!(" {count:>4} {name}");
|
||||
}
|
||||
ranked(inherits, 12);
|
||||
}
|
||||
|
||||
// Frames and tables, ranked separately: a missing function is a Rust verb to write, a missing
|
||||
@@ -347,9 +386,7 @@ fn main() {
|
||||
let tables = addon_harness::table_demand(&reports);
|
||||
if !tables.is_empty() {
|
||||
println!("\n most-wanted missing FRAMES/TABLES (addons indexing each):");
|
||||
for (name, count) in tables.into_iter().take(16) {
|
||||
println!(" {count:>4} {name}");
|
||||
}
|
||||
ranked(tables, 16);
|
||||
}
|
||||
|
||||
// **Widget METHODS** — the third queue, and the one this report was blind to while the arc
|
||||
@@ -379,9 +416,7 @@ fn main() {
|
||||
" · a third-party library's own method on its own object is not ours to write."
|
||||
);
|
||||
println!(" Open the call site before ranking it (decision 1240).");
|
||||
for (name, count) in methods.into_iter().take(16) {
|
||||
println!(" {count:>4} {name}");
|
||||
}
|
||||
ranked(methods, 16);
|
||||
}
|
||||
|
||||
// **Per KIND** — the question the table above structurally cannot ask. It resolves a name
|
||||
@@ -397,9 +432,7 @@ fn main() {
|
||||
"\n most-wanted missing METHODS BY KIND (receiver typed from the call site; \"on X\" \
|
||||
= the sibling that does answer it):"
|
||||
);
|
||||
for (name, count) in by_kind.into_iter().take(16) {
|
||||
println!(" {count:>4} {name}");
|
||||
}
|
||||
ranked(by_kind, 16);
|
||||
}
|
||||
|
||||
// ...and the residue: a receiver nothing could type, on a name whose answer DEPENDS on the
|
||||
@@ -415,9 +448,7 @@ fn main() {
|
||||
is the AddMessage shape and one every kind but the two regions answers is not):",
|
||||
ambiguous.len()
|
||||
);
|
||||
for (name, count) in ambiguous.into_iter().take(12) {
|
||||
println!(" {count:>4} {name}");
|
||||
}
|
||||
ranked(ambiguous, 12);
|
||||
}
|
||||
|
||||
// The other half of the same scan, and never merged into it: methods addons call **behind a
|
||||
@@ -428,15 +459,11 @@ fn main() {
|
||||
let optional = addon_harness::optional_method_demand(&reports);
|
||||
if !optional.is_empty() {
|
||||
println!("\n ...and methods they FEATURE-TEST and work around (not blockers):");
|
||||
for (name, count) in optional.into_iter().take(8) {
|
||||
println!(" {count:>4} {name}");
|
||||
}
|
||||
ranked(optional, 8);
|
||||
}
|
||||
|
||||
println!("\n most-wanted missing globals (addons wanting each):");
|
||||
for (name, count) in addon_harness::demand(&reports).into_iter().take(30) {
|
||||
println!(" {count:>4} {name}");
|
||||
}
|
||||
ranked(addon_harness::demand(&reports), 30);
|
||||
|
||||
if let Some(pattern) = &why {
|
||||
let hits = addon_harness::blocked_by(&reports, pattern);
|
||||
|
||||
@@ -1323,6 +1323,43 @@ fn seat_a_session(script: &mut UiScript) {
|
||||
// resembling a session anyone has, and every row it lights up is one nobody can attribute. Two
|
||||
// tabs and four spells is a Tuesday. `Attack` is slot 1 because it is on every warrior's, and
|
||||
// three corpus addons look for exactly that name.
|
||||
// EQUIPPED GEAR. Every `GetInventoryItem*("player", slot)` answered the empty shape, and a
|
||||
// level-60 with no equipment at all is a state no character reaches — the same argument the
|
||||
// spellbook and the backpack landed on.
|
||||
//
|
||||
// Three slots, not nineteen: head, chest and main hand. A fully-geared doll would be a state a
|
||||
// real session presents, but it would also light up every path at once, and 1209's rule is
|
||||
// that a row nobody can attribute is worth less than one nobody lit. Three is enough for an
|
||||
// addon that WALKS the slots to reach real items, empty ones, and the ammo slot's absence.
|
||||
{
|
||||
let mut slots: benilla_ui::script::InventorySlots = Default::default();
|
||||
slots[1] = Some(equip_slot(12640, "Lionheart Helm"));
|
||||
slots[5] = Some(equip_slot(11726, "Bloodmail Hauberk"));
|
||||
slots[16] = Some(equip_slot(871, "Flurry Axe"));
|
||||
script.set_inventory_slots(slots);
|
||||
}
|
||||
// THE BACKPACK. `GetContainerNumSlots(0)` answered 0 — and a character with no backpack has
|
||||
// never existed: bag 0 is 16 slots from level 1, before a single bag is bought. Same argument
|
||||
// as the spellbook above and the nil `UnitFactionGroup` below it, and the same expectation:
|
||||
// seating a state every character is in should LOSE rows, because a path nothing walked is a
|
||||
// path nothing tested.
|
||||
//
|
||||
// Backpack only, and deliberately: bags 1..4 are equipped bags, which a fresh character does
|
||||
// NOT have, so seating them would manufacture a state rather than expose one. Two items in
|
||||
// sixteen slots — the rest empty, which is itself a shape bag addons must handle.
|
||||
{
|
||||
let mut slots = std::collections::HashMap::new();
|
||||
slots.insert(1, bag_slot(6948, "Hearthstone", 1));
|
||||
slots.insert(5, bag_slot(2589, "Linen Cloth", 12));
|
||||
script.set_container(
|
||||
0,
|
||||
Some(benilla_ui::script::ContainerState {
|
||||
name: Some("Backpack".into()),
|
||||
num_slots: 16,
|
||||
slots,
|
||||
}),
|
||||
);
|
||||
}
|
||||
script.set_spellbook(benilla_ui::script::SpellBookState {
|
||||
tabs: vec![
|
||||
benilla_ui::script::SpellTabView {
|
||||
@@ -1347,6 +1384,36 @@ fn seat_a_session(script: &mut UiScript) {
|
||||
});
|
||||
}
|
||||
|
||||
/// One seated equipment slot — full durability, so no alert region lights up.
|
||||
fn equip_slot(item_id: u32, name: &str) -> benilla_ui::script::InvSlotView {
|
||||
benilla_ui::script::InvSlotView {
|
||||
item_id,
|
||||
icon: Some("Interface\\Icons\\INV_Misc_QuestionMark".into()),
|
||||
count: 1,
|
||||
quality: 2,
|
||||
name: Some(name.to_string()),
|
||||
// FULL durability on purpose. The setter recomputes the eleven alert statuses and fires
|
||||
// UPDATE_INVENTORY_ALERTS, so a worn item would light DurabilityFrame's regions — a
|
||||
// VISIBLE change, and this fixture's job is to present a plausible session, not to drive
|
||||
// the alert law. Undamaged gear is the ordinary case anyway.
|
||||
durability: Some((100, 100)),
|
||||
link: Some(format!("|cff1eff00|Hitem:{item_id}:0:0:0|h[{name}]|h|r")),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// One seated backpack slot.
|
||||
fn bag_slot(item_id: u32, name: &str, count: u32) -> benilla_ui::script::ContainerSlot {
|
||||
benilla_ui::script::ContainerSlot {
|
||||
texture: Some("Interface\\Icons\\INV_Misc_QuestionMark".into()),
|
||||
count,
|
||||
quality: Some(1),
|
||||
item_id,
|
||||
link: Some(format!("|cffffffff|Hitem:{item_id}:0:0:0|h[{name}]|h|r")),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// One seated spellbook slot — the fields a book reader actually reads, defaulted otherwise.
|
||||
fn spell_slot(spell_id: u32, name: &str, rank: Option<&str>) -> benilla_ui::script::SpellSlotView {
|
||||
benilla_ui::script::SpellSlotView {
|
||||
@@ -3538,6 +3605,79 @@ mod dependency_tests {
|
||||
script
|
||||
}
|
||||
|
||||
/// **The session fixture is LIVE**, asserted through the same Lua surface an addon sees.
|
||||
///
|
||||
/// A fixture that silently fails to seat reads EXACTLY like a fixture that exposed nothing —
|
||||
/// both are "net +0" — and this arc has already been fooled once by a measurement that
|
||||
/// returned an empty answer for a tooling reason (1242's note on `grep`). The backpack seat
|
||||
/// produced no corpus delta at all; this is what makes that a finding rather than a
|
||||
/// possibility.
|
||||
///
|
||||
/// It is also a guard: delete a seat and this fails, instead of the columns quietly shifting
|
||||
/// and the next A/B attributing the move to whatever landed beside it.
|
||||
#[test]
|
||||
fn the_seated_session_is_visible_from_lua() {
|
||||
let s = seated_vm();
|
||||
|
||||
// The backpack — 16 slots from level 1, two of them filled. Exposed nothing, verifiably.
|
||||
assert_eq!(s.eval::<i64>("return GetContainerNumSlots(0)").unwrap(), 16);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetBagName(0)").unwrap(),
|
||||
"Backpack"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("local _, c = GetContainerItemInfo(0, 5) return c")
|
||||
.unwrap(),
|
||||
12,
|
||||
"the Linen Cloth stack"
|
||||
);
|
||||
// Bags 1..4 are deliberately NOT seated: a fresh character has no equipped bags, and
|
||||
// seating them would manufacture a state rather than expose one.
|
||||
assert_eq!(s.eval::<i64>("return GetContainerNumSlots(1)").unwrap(), 0);
|
||||
|
||||
// Equipped gear — head, chest, main hand, with the rest empty. Also exposed nothing.
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return GetInventoryItemTexture("player", 16)"#)
|
||||
.unwrap(),
|
||||
"Interface\\Icons\\INV_Misc_QuestionMark"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<String>(r#"return GetInventoryItemLink("player", 5)"#)
|
||||
.unwrap()
|
||||
.contains("Bloodmail Hauberk"),
|
||||
"the chest slot's link"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return GetInventoryItemQuality("player", 1)"#)
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
// An EMPTY slot still answers the absent shape — the case an addon walking 1..19 hits most.
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return GetInventoryItemLink("player", 10) == nil"#)
|
||||
.unwrap());
|
||||
// The slot ids come from the same table `GetInventorySlotInfo` serves, so a walk that
|
||||
// resolves names to ids lands on the seated items rather than past them.
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return GetInventorySlotInfo("MainHandSlot")"#)
|
||||
.unwrap(),
|
||||
16
|
||||
);
|
||||
|
||||
// The spellbook, as the positive control — this seat DID move the columns (+2).
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return GetSpellName(1, "spell")"#)
|
||||
.unwrap(),
|
||||
"Attack"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"local _, r = GetSpellName(1, "spell") return r"#)
|
||||
.unwrap(),
|
||||
"",
|
||||
"and its rank is the empty string, not nil"
|
||||
);
|
||||
}
|
||||
|
||||
fn wanted(names: &[&str]) -> BTreeSet<String> {
|
||||
names.iter().map(|n| (*n).to_string()).collect()
|
||||
}
|
||||
|
||||
@@ -1013,3 +1013,68 @@ fn a_keybind_page_switch_moves_the_tab_row_with_it() {
|
||||
assert!(shown(&mut s, "PaperDollFrame"), "…or change the page");
|
||||
assert!(s.errors().is_empty(), "no handler errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The tab kit's XML-facing entry point, driven the way an addon drives it.**
|
||||
///
|
||||
/// Four corpus addons (Enchantrix, Outfitter, SimpleActionSets, TheoryCraft) put
|
||||
/// `PanelTemplates_Tab_OnClick(<frame>)` straight into a tab's `<OnClick>` and let the kit do the
|
||||
/// rest; this client had every other member of the kit and not that one, because our own windows
|
||||
/// wire their tabs to their own handlers and never reached for the generic entry point.
|
||||
///
|
||||
/// **It is built on a row of this test's own, and that is the finding, not a convenience.** The
|
||||
/// reference's tab buttons carry `id="1".."4"` (ref `CharacterFrame.xml:79-133`) and ours carry
|
||||
/// none: our row is id-based through `BenillaCharacterFrameTab_OnClick(id)`, which closes over the
|
||||
/// number instead of reading it off the widget. So `CharacterFrameTab2:GetID()` is **0** here, and
|
||||
/// driving OUR row through the generic entry point would select tab 0 — correct code meeting a
|
||||
/// window that does not obey the contract it reads. An addon's own tabs do carry ids, which is why
|
||||
/// the four callers above work; this builds a row that obeys the reference's contract and drives
|
||||
/// that.
|
||||
///
|
||||
/// The `this`/frame split is the whole reason the function exists: `this` is the clicked TAB and
|
||||
/// the owning FRAME is a separate argument, which is the shape `SimpleActionSets.xml:342` relies on
|
||||
/// when it passes `this:GetParent()`.
|
||||
#[test]
|
||||
fn an_addons_tab_click_selects_through_the_generic_entry_point() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Fonts.xml");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
|
||||
// A conforming row: tabs named `<frame>Tab1..N` (what `PanelTemplates_UpdateTabs` getglobals)
|
||||
// and each carrying its own id, exactly as an addon's XML declares them.
|
||||
s.run(
|
||||
r#"
|
||||
TabKitFrame = CreateFrame("Frame", "TabKitFrame", UIParent)
|
||||
PanelTemplates_SetNumTabs(TabKitFrame, 2)
|
||||
for i = 1, 2 do
|
||||
local t = CreateFrame("Button", "TabKitFrameTab" .. i, TabKitFrame,
|
||||
"CharacterFrameTabButtonTemplate")
|
||||
t:SetID(i)
|
||||
end
|
||||
PanelTemplates_SetTab(TabKitFrame, 1)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let selected = |s: &mut UiScript| {
|
||||
s.eval::<i64>("return PanelTemplates_GetSelectedTab(TabKitFrame)")
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(selected(&mut s), 1);
|
||||
|
||||
// The addon idiom, verbatim: `this` is the tab, the frame is the argument.
|
||||
s.run("this = TabKitFrameTab2; PanelTemplates_Tab_OnClick(TabKitFrame); this = nil")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
selected(&mut s),
|
||||
2,
|
||||
"the kit takes the tab's OWN id from `this`, not from the frame it was handed"
|
||||
);
|
||||
// The row repainted, not just the number — the same visible half the keybind test asserts.
|
||||
assert!(
|
||||
s.eval::<bool>("return TabKitFrameTab2MiddleDisabled:IsVisible()")
|
||||
.unwrap(),
|
||||
"the newly selected tab wears the Active art"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "no handler errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -166,7 +166,18 @@ fn an_error_toast_draws_over_an_open_panel_window() {
|
||||
let above: Vec<&ExtractedQuad> = quads
|
||||
.iter()
|
||||
.filter(|q| q.z > toast)
|
||||
.filter(|q| s.quad_owner_name(q.target).as_deref() != Some("UIErrorsFrame"))
|
||||
// `RaidWarningFrame` is excluded on exactly the same grounds, and it is the reference's
|
||||
// own arrangement rather than ours: it is HIGH + toplevel too (ref RaidWarning.xml:4), so
|
||||
// it raises above the toast in z — but it sits BELOW UIErrorsFrame on screen (anchored to
|
||||
// its BOTTOM at -10) and its declared `<FontString>` carries no text, so nothing of it is
|
||||
// ever painted over the toast. Excluding it keeps this test about a PANEL WINDOW drawing
|
||||
// over the toast, which is what it is named for.
|
||||
.filter(|q| {
|
||||
!matches!(
|
||||
s.quad_owner_name(q.target).as_deref(),
|
||||
Some("UIErrorsFrame") | Some("RaidWarningFrame")
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
above.is_empty(),
|
||||
|
||||
@@ -1702,3 +1702,44 @@ fn the_unit_frame_hover_hooks_carry_the_references_names() {
|
||||
s.errors()
|
||||
);
|
||||
}
|
||||
|
||||
/// **`SetRaidTargetIconTexture` lands each mark on its own cell of the 4×4 sheet.**
|
||||
///
|
||||
/// The helper an addon uses to draw a raid mark it got from `GetRaidTargetIndex` — three corpus
|
||||
/// addons in two codebases (CustomNameplates; Optional/oRA2's shared `MainTank.lua:1216` and
|
||||
/// `PlayerTarget.lua:1472`). It owns no frame and reads no unit: the texture is the caller's.
|
||||
///
|
||||
/// The coordinates are the point, so all eight are checked rather than one. Star is the top-left
|
||||
/// cell and skull the bottom-right of the first two rows — which is also what pins the reference's
|
||||
/// own `/ ROWS` row derivation as harmless here: with 4 rows and 4 columns the wrap lands where the
|
||||
/// sheet's mark order says it should.
|
||||
#[test]
|
||||
fn the_raid_mark_helper_maps_each_index_to_its_cell() {
|
||||
let s = UiScript::new().unwrap();
|
||||
load_unit_frames(&s);
|
||||
s.run(r#"RTMark = UIParent:CreateTexture("RTMark", "OVERLAY")"#)
|
||||
.unwrap();
|
||||
|
||||
// (index, left, right, top, bottom) — 0.25 per cell, four across then down a row.
|
||||
let cells = [
|
||||
(1, 0.00, 0.25, 0.00, 0.25), // star
|
||||
(2, 0.25, 0.50, 0.00, 0.25), // circle
|
||||
(3, 0.50, 0.75, 0.00, 0.25), // diamond
|
||||
(4, 0.75, 1.00, 0.00, 0.25), // triangle
|
||||
(5, 0.00, 0.25, 0.25, 0.50), // moon — the wrap onto row 2
|
||||
(6, 0.25, 0.50, 0.25, 0.50), // square
|
||||
(7, 0.50, 0.75, 0.25, 0.50), // cross
|
||||
(8, 0.75, 1.00, 0.25, 0.50), // skull
|
||||
];
|
||||
for (i, l, r, t, b) in cells {
|
||||
s.run(&format!("SetRaidTargetIconTexture(RTMark, {i})"))
|
||||
.unwrap();
|
||||
let got: (f64, f64, f64, f64) = s.eval("return RTMark:GetTexCoord()").unwrap();
|
||||
assert_eq!(
|
||||
got,
|
||||
(l, r, t, b),
|
||||
"mark {i} must sample the cell at ({l}, {r}, {t}, {b})"
|
||||
);
|
||||
}
|
||||
assert!(s.errors().is_empty(), "no errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -123,7 +123,10 @@ pub(super) fn point_from_str(s: &str) -> Option<Point> {
|
||||
}
|
||||
|
||||
/// The inverse of [`point_from_str`] (the client's point-id table `0x811a38`, names back out).
|
||||
fn point_name(p: Point) -> &'static str {
|
||||
///
|
||||
/// `pub(super)` because the REGION cluster reports the same names from the same table — one
|
||||
/// spelling of the point set, not two (`region/layout.rs`'s `GetPoint`).
|
||||
pub(super) fn point_name(p: Point) -> &'static str {
|
||||
match p {
|
||||
Point::TopLeft => "TOPLEFT",
|
||||
Point::Top => "TOP",
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
use mlua::{Lua, Table, Value};
|
||||
|
||||
use crate::layout::{Anchor, Point};
|
||||
use crate::script::object::anchor_bits_eq;
|
||||
use crate::script::Model;
|
||||
use crate::script::object::{anchor_bits_eq, frame_wrapper, point_name};
|
||||
use crate::script::{Model, SCREEN};
|
||||
|
||||
/// Resolve `self` (a region wrapper) to its live [`RegionHandle`].
|
||||
use super::{
|
||||
@@ -151,5 +151,111 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// ── The rest of the Region map `0xcf54b4` (wow-re `font-object-lua-surface.md`) ──────────────
|
||||
//
|
||||
// These four landed together because the MAP is the unit, not the name. `SetParent` shipped
|
||||
// alone when one addon line named it, and its own getter stayed missing for months — which is
|
||||
// how `TheoryCraft\TheoryCraftUI.lua:720` (`buttontext:GetParent()`, a FontString) died every
|
||||
// session, and how the per-kind census came to read `115 GetParent (missing on Texture,
|
||||
// FontString)`. The set is closed and byte-verified, so it is asserted as a set in
|
||||
// `tests/reference_surface.rs` rather than grown a name at a time.
|
||||
|
||||
// GetParent() → the OWNER frame's wrapper. A region always has one (`region_owner_id` falls
|
||||
// back to the owner for every unresolved case), so unlike the frame twin this never answers nil.
|
||||
m.set(
|
||||
"GetParent",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let owner = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
region_owner_id(&mut model, rh)
|
||||
};
|
||||
frame_wrapper(lua, owner)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetCenter() → the resolved rect's midpoint, or a nil PAIR before the first resolve — the same
|
||||
// contract, and the same source, as the GetLeft/GetRight/GetTop/GetBottom readers above.
|
||||
//
|
||||
// **Deliberately unscaled, where the frame twin divides by GetEffectiveScale.** The region edge
|
||||
// readers report raw resolved units; scaling only the centre would make `GetCenter()` disagree
|
||||
// with `(GetLeft() + GetRight()) / 2` on any scaled subtree — a contradiction inside one method
|
||||
// table is worse than a missing division, and regions have no scale of their own to divide by.
|
||||
m.set(
|
||||
"GetCenter",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
Ok(match model.region_resolved.get(&rh) {
|
||||
Some(r) => (
|
||||
Value::Number(f64::from((r.left + r.right) * 0.5)),
|
||||
Value::Number(f64::from((r.bottom + r.top) * 0.5)),
|
||||
),
|
||||
None => (Value::Nil, Value::Nil),
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetNumPoints() → how many anchors this region carries. Absent on our FRAMES too, which is the
|
||||
// same drift one table up; this side is what the corpus named.
|
||||
m.set(
|
||||
"GetNumPoints",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
Ok(model
|
||||
.region_data
|
||||
.get(&rh)
|
||||
.map_or(0, |d| d.anchors.len() as i64))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetPoint([n]) → point, relativeTo, relativePoint, xOfs, yOfs — the n-th (1-based, default
|
||||
// first) anchor, mirroring the frame twin including its out-of-range answer (five nils).
|
||||
//
|
||||
// **The relativeTo dispatch is the part a frame-shaped copy gets wrong.** Region anchors live in
|
||||
// ONE id space with frames and may target a sibling REGION — the real XML does it everywhere
|
||||
// (`region.rs`'s `resolve_target`: frames first, then the region-name registry). So the id is
|
||||
// matched against both tables and answered with the matching wrapper kind; handing back a frame
|
||||
// wrapper for a region id would be a working-looking handle onto the wrong object.
|
||||
m.set(
|
||||
"GetPoint",
|
||||
lua.create_function(|lua, (this, n): (Table, Option<i64>)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let anchor = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
let idx = (n.unwrap_or(1).max(1) - 1) as usize;
|
||||
model
|
||||
.region_data
|
||||
.get(&rh)
|
||||
.and_then(|d| d.anchors.get(idx))
|
||||
.cloned()
|
||||
};
|
||||
let Some(a) = anchor else {
|
||||
return Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil, Value::Nil));
|
||||
};
|
||||
let rel = {
|
||||
let is_frame = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
model.id_to_frame.contains_key(&a.relative_to)
|
||||
};
|
||||
if a.relative_to == SCREEN {
|
||||
Value::Nil
|
||||
} else if is_frame {
|
||||
Value::Table(frame_wrapper(lua, a.relative_to)?)
|
||||
} else {
|
||||
Value::Table(super::region_wrapper(lua, a.relative_to)?)
|
||||
}
|
||||
};
|
||||
Ok((
|
||||
Value::String(lua.create_string(point_name(a.point))?),
|
||||
rel,
|
||||
Value::String(lua.create_string(point_name(a.relative_point))?),
|
||||
Value::Number(f64::from(a.x_off)),
|
||||
Value::Number(f64::from(a.y_off)),
|
||||
))
|
||||
})?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -426,22 +426,53 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetSpellName(id, bookType) -> name, rank (the Era tuple, `subSpellName` in the ref); pet or
|
||||
// out-of-range -> a single nil.
|
||||
// GetSpellName(id, bookType) -> name, rank. **Always TWO values, never one, and the rank of a
|
||||
// rankless spell is the EMPTY STRING, never nil** (wow-re
|
||||
// `scratch/getspellname-return-contract.md`, §5-derived from the bytes alone).
|
||||
//
|
||||
// Both returns go through the same push helper with **no rank-specific branch anywhere in the
|
||||
// binding** — a ranked and a rankless spell run byte-identical code: `0x4b4063 call 0x6f3890`
|
||||
// pushes `SpellRec.Name[locale]`, `0x4b4076` pushes `NameSubtext[locale]`, and the single
|
||||
// DBC-resolved exit is `mov eax,0x2` at `0x4b407c`. `0x6f3890` decides on **pointer nullity
|
||||
// alone** (`test edx,edx`), and the DBC pointer is never NULL: `SpellRec::Read 0x583750` fixes
|
||||
// up every string column with an unconditional `add offset, stringBlockBase`, so an on-disk
|
||||
// offset of 0 materializes as a pointer to the string block's own byte 0 — a NUL. `0x6f3840`
|
||||
// then writes tag 4 (`LUA_TSTRING`) unconditionally; `len == 0` is not a special case. So the
|
||||
// rankless rank is a real, interned, zero-length Lua string.
|
||||
//
|
||||
// This is not an edge case: **14,403 of the shipped Spell.dbc's 22,357 rows carry NameSubtext
|
||||
// offset 0** — 64%, `Attack` (6603) among them. Our `Option<String>::None` pushed nil there,
|
||||
// and two 1.12 corpus addons walking the whole book die on it in two different idioms:
|
||||
// `Roid-Macros/Generic.lua:35` uses the rank as a TABLE KEY (nil is illegal), and
|
||||
// `CT_MasterMod/CT_Master.lua:16` passes it straight to `string.find` (nil raises). Both were
|
||||
// invisible until the survey seated a spellbook.
|
||||
//
|
||||
// The `Option` is still the right shape for the MODEL — it records "this spell has no
|
||||
// NameSubtext", which is a fact about the DBC row. What was wrong was rendering that absence
|
||||
// as nil at the Lua boundary; the reference renders it as the empty string the pointer targets.
|
||||
g.set(
|
||||
"GetSpellName",
|
||||
lua.create_function(|lua, (id, book_type): (u32, String)| {
|
||||
// An out-of-range index RAISES rather than answering nil. `0x4b3ec0` gates the index to
|
||||
// `[0, 0x400)` (`0x4b3f0a`/`0x4b3f11`) before the binding proper runs, and the miss arm
|
||||
// is `luaL_error("Invalid spell slot in GetSpellName")`, which longjmps and abandons
|
||||
// the caller's statement (`super::binding_abi`). The in-binding bound check at
|
||||
// `0x4b4018` is unreachable-as-taken. This file previously assumed the bound was
|
||||
// "enforced by our slot lists being shorter than that anyway" — a short list answers
|
||||
// nil, which is a different thing entirely.
|
||||
if id >= 0x400 {
|
||||
return Err(mlua::Error::runtime("Invalid spell slot in GetSpellName"));
|
||||
}
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(slot) = book_slot(&model, id, &book_type) else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
};
|
||||
let rank = match &slot.rank {
|
||||
Some(r) => Value::String(lua.create_string(r)?),
|
||||
None => Value::Nil,
|
||||
// An unfilled slot inside the range answers **two** nils (`0x4b4086` → `mov eax,0x2`
|
||||
// at `0x4b4095`), not one — which `select('#', …)` and a two-name assignment can
|
||||
// both tell apart.
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil, Value::Nil]));
|
||||
};
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
Value::String(lua.create_string(&slot.name)?),
|
||||
rank,
|
||||
Value::String(lua.create_string(slot.rank.as_deref().unwrap_or(""))?),
|
||||
]))
|
||||
})?,
|
||||
)?;
|
||||
@@ -1279,4 +1310,87 @@ mod tests {
|
||||
(0.0, 0.0, 1)
|
||||
);
|
||||
}
|
||||
|
||||
/// **A rankless spell's rank is the empty string, not nil** — and the arity is always two.
|
||||
///
|
||||
/// Both returns go through one push helper with no rank-specific branch (`0x4b4063`/`0x4b4076`,
|
||||
/// single exit `mov eax,0x2` at `0x4b407c`), and the DBC pointer is never NULL, so an on-disk
|
||||
/// NameSubtext offset of 0 becomes a pointer to the string block's byte 0 — a real zero-length
|
||||
/// Lua string. 64% of the shipped Spell.dbc's rows are in that state, `Attack` among them.
|
||||
///
|
||||
/// The two corpus idioms this was breaking are both asserted here, because "falsey either way"
|
||||
/// is exactly the reasoning that made nil look acceptable: a nil is an ILLEGAL TABLE KEY
|
||||
/// (Roid-Macros) and an ILLEGAL `string.find` argument (CT_MasterMod), while `""` is neither.
|
||||
#[test]
|
||||
fn a_rankless_spell_answers_an_empty_rank_and_still_two_values() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_spellbook(SpellBookState {
|
||||
tabs: Vec::new(),
|
||||
slots: vec![
|
||||
SpellSlotView {
|
||||
spell_id: 6603,
|
||||
name: "Attack".into(),
|
||||
rank: None,
|
||||
..Default::default()
|
||||
},
|
||||
SpellSlotView {
|
||||
spell_id: 78,
|
||||
name: "Heroic Strike".into(),
|
||||
rank: Some("Rank 1".into()),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return select('#', GetSpellName(1, "spell"))"#)
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
let (name, rank) = s
|
||||
.eval::<(String, String)>(r#"return GetSpellName(1, "spell")"#)
|
||||
.unwrap();
|
||||
assert_eq!((name.as_str(), rank.as_str()), ("Attack", ""));
|
||||
assert!(
|
||||
s.eval::<bool>(r#"local _, r = GetSpellName(1, "spell") return r ~= nil"#)
|
||||
.unwrap(),
|
||||
"the rankless rank must be a STRING, not nil"
|
||||
);
|
||||
// Roid-Macros/Generic.lua:35 and CT_MasterMod/CT_Master.lua:16, in miniature.
|
||||
s.run(r#"local _, r = GetSpellName(1, "spell") local t = {} t[r] = 1"#)
|
||||
.expect("a rankless rank must be a legal table key");
|
||||
s.run(r#"local _, r = GetSpellName(1, "spell") string.find(r, "(%d+)")"#)
|
||||
.expect("a rankless rank must be a legal string.find argument");
|
||||
// A ranked spell is unchanged.
|
||||
assert_eq!(
|
||||
s.eval::<(String, String)>(r#"return GetSpellName(2, "spell")"#)
|
||||
.unwrap(),
|
||||
("Heroic Strike".to_string(), "Rank 1".to_string())
|
||||
);
|
||||
|
||||
// An unfilled slot INSIDE the range is two nils, not one — distinguishable by select('#').
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return select('#', GetSpellName(9, "spell"))"#)
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>(r#"local a, b = GetSpellName(9, "spell") return a == nil and b == nil"#)
|
||||
.unwrap());
|
||||
|
||||
// Past the reference's [0, 0x400) gate it RAISES rather than answering nil.
|
||||
let err = s
|
||||
.run(r#"GetSpellName(1024, "spell")"#)
|
||||
.expect_err("an out-of-range slot must raise");
|
||||
assert!(
|
||||
format!("{err}").contains("Invalid spell slot in GetSpellName"),
|
||||
"got {err}"
|
||||
);
|
||||
// ...and 1023 is inside it, so it answers rather than raising.
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return select('#', GetSpellName(1023, "spell"))"#)
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1119,3 +1119,154 @@ fn get_inventory_slot_info_folds_case_and_flags_only_the_ranged_slot() {
|
||||
"the reference does not interpolate the offending name: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The whole Region method map, on both leaves that chain to it.**
|
||||
///
|
||||
/// `SetParent` above landed as one name because one addon line named it. That is how this table has
|
||||
/// always grown, and it is why `GetParent` — its own getter — was still absent while `SetParent`
|
||||
/// worked. wow-re carves the map as a SET, not as names: FontString's lookup `0x79ee20` chains its
|
||||
/// own map `0xcf5400` to the Region map `0xcf54b4`, whose 19 entries are
|
||||
///
|
||||
/// ```text
|
||||
/// GetObjectType IsObjectType GetName GetParent SetParent GetCenter GetLeft GetRight GetTop
|
||||
/// GetBottom GetWidth SetWidth GetHeight SetHeight GetNumPoints GetPoint SetPoint SetAllPoints
|
||||
/// ClearAllPoints
|
||||
/// ```
|
||||
///
|
||||
/// (`system/ui/scratch/font-object-lua-surface.md` — the same note whose point is that a `<Font>`
|
||||
/// object does NOT chain and so has none of these. Texture reaches the identical map through its
|
||||
/// own leaf lookup, which is why both are asserted here.)
|
||||
///
|
||||
/// So this asserts membership rather than behaviour: each name is *present and callable* on a
|
||||
/// Texture and on a FontString. Behaviour belongs in the focused tests around it — what is pinned
|
||||
/// here is that the set cannot quietly lose a member again, which is the failure `GetParent` was.
|
||||
#[test]
|
||||
fn every_region_map_method_is_callable_on_a_texture_and_a_fontstring() {
|
||||
/// The map minus `GetObjectType`/`IsObjectType`, which are **dispatched, not forgotten**: both
|
||||
/// are in `0xcf54b4`, but a faithful `IsObjectType` needs facts a reimplementation cannot
|
||||
/// guess — whether it matches the leaf name only or walks a class chain, the exact strings in
|
||||
/// that chain, whether it answers `1`/`nil` or `true`/`false`, and whether a bad argument
|
||||
/// raises. Shipping a guess is how 1203/1205/1211 happened. They join this list with the
|
||||
/// verified answer; until then their absence is a raise, which is loud and correct.
|
||||
const REGION_MAP: &[&str] = &[
|
||||
"GetName",
|
||||
"GetParent",
|
||||
"SetParent",
|
||||
"GetCenter",
|
||||
"GetLeft",
|
||||
"GetRight",
|
||||
"GetTop",
|
||||
"GetBottom",
|
||||
"GetWidth",
|
||||
"SetWidth",
|
||||
"GetHeight",
|
||||
"SetHeight",
|
||||
"GetNumPoints",
|
||||
"GetPoint",
|
||||
"SetPoint",
|
||||
"SetAllPoints",
|
||||
"ClearAllPoints",
|
||||
];
|
||||
let mut s = crate::script::UiScript::new().unwrap();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
RMOwner = CreateFrame("Frame", "RMOwner")
|
||||
RMOwner:SetPoint("BOTTOMLEFT", 0, 0) RMOwner:SetSize(100, 50)
|
||||
RMTex = RMOwner:CreateTexture("RMTex", "ARTWORK")
|
||||
RMStr = RMOwner:CreateFontString("RMStr", "ARTWORK")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut absent: Vec<String> = Vec::new();
|
||||
for kind in ["RMTex", "RMStr"] {
|
||||
for name in REGION_MAP {
|
||||
if !s
|
||||
.eval::<bool>(&format!("return type({kind}.{name}) == 'function'"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
absent.push(format!("{kind}:{name}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
absent.is_empty(),
|
||||
"the Region map 0xcf54b4 is missing from our regions: {absent:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The four Region-map readers, on the cases a frame-shaped copy gets wrong.**
|
||||
///
|
||||
/// `TheoryCraftUI.lua:720` is the line that found them: `buttontext:GetParent():GetID()`, where
|
||||
/// `buttontext` is a FontString — a working line on the real client, and `attempt to call method
|
||||
/// 'GetParent' (a nil value)` here, every session.
|
||||
#[test]
|
||||
fn the_region_map_readers_answer_the_way_the_edges_do() {
|
||||
let mut s = crate::script::UiScript::new().unwrap();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
RRFrame = CreateFrame("Frame", "RRFrame")
|
||||
RRFrame:SetPoint("BOTTOMLEFT", 100, 200) RRFrame:SetSize(200, 100)
|
||||
RRPlate = RRFrame:CreateTexture("RRPlate", "ARTWORK")
|
||||
RRPlate:SetPoint("TOPLEFT", 10, -10) RRPlate:SetSize(50, 20)
|
||||
-- the sibling-region anchor the real XML uses everywhere
|
||||
RRLabel = RRFrame:CreateFontString("RRLabel", "OVERLAY")
|
||||
RRLabel:SetPoint("LEFT", RRPlate, "RIGHT", 4, 0)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// GetParent is the OWNER frame — the identity TheoryCraft then calls :GetID() on.
|
||||
assert!(
|
||||
s.eval::<bool>("return RRPlate:GetParent() == RRFrame")
|
||||
.unwrap(),
|
||||
"a region's parent is the frame that created it"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return RRLabel:GetParent():GetName() == 'RRFrame'")
|
||||
.unwrap(),
|
||||
"…and it is a real frame handle, not a bare id"
|
||||
);
|
||||
|
||||
// GetCenter agrees with the edge readers BY CONSTRUCTION — the invariant that forbids scaling
|
||||
// one and not the others.
|
||||
let (cx, cy): (f64, f64) = s.eval("return RRPlate:GetCenter()").unwrap();
|
||||
let (l, r, t, b): (f64, f64, f64, f64) = s
|
||||
.eval("return RRPlate:GetLeft(), RRPlate:GetRight(), RRPlate:GetTop(), RRPlate:GetBottom()")
|
||||
.unwrap();
|
||||
assert_eq!((cx, cy), ((l + r) * 0.5, (t + b) * 0.5));
|
||||
|
||||
// GetNumPoints counts what SetPoint wrote, and ClearAllPoints takes it back to 0.
|
||||
assert_eq!(s.eval::<i64>("return RRPlate:GetNumPoints()").unwrap(), 1);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return RRLabel:GetNumPoints()").unwrap(),
|
||||
1,
|
||||
"the sibling-anchored label carries its one point"
|
||||
);
|
||||
|
||||
// GetPoint's relativeTo must come back as the SIBLING REGION, not a frame wrapper onto the same
|
||||
// id — the one place regions genuinely differ from frames, since both share an id space.
|
||||
let (p, rp, x, y): (String, String, f64, f64) = s
|
||||
.eval("local p, _, rp, x, y = RRLabel:GetPoint(1) return p, rp, x, y")
|
||||
.unwrap();
|
||||
assert_eq!((p.as_str(), rp.as_str(), x, y), ("LEFT", "RIGHT", 4.0, 0.0));
|
||||
assert!(
|
||||
s.eval::<bool>("local _, rel = RRLabel:GetPoint(1) return rel == RRPlate")
|
||||
.unwrap(),
|
||||
"relativeTo is the sibling REGION handle itself"
|
||||
);
|
||||
// Out of range is five nils, like the frame twin.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', RRPlate:GetPoint(7))")
|
||||
.unwrap(),
|
||||
5,
|
||||
"an out-of-range index still answers five values, all nil"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return RRPlate:GetPoint(7) == nil").unwrap(),
|
||||
"…and the first of them is nil"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user