the addon arc: the AddOns folder opens

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-11 18:18:31 +07:00
co-authored by Claude Fable 5
parent bfb3e3addc
commit 44ff435304
231 changed files with 31316 additions and 5594 deletions
+319 -12
View File
@@ -1,15 +1,21 @@
//! The player buff bar (`assets/ui/BuffFrame.xml`, decisions 0255/0257) against its reference
//! behaviour. The XML/Lua is the unit under test; the app-side feed (`crate::ui_aura`) is stubbed by
//! pushing an [`AuraState`] list straight through [`UiScript::set_auras`] and firing `UNIT_AURA`, so
//! these exercise the *button* handlers — the row/filter wiring, the dispel-tinted border, the
//! stack count, the GetTime()-based countdown, the warning flash, and the right-click cancel — the
//! way the reference's own `BuffButton_*` did, but over our Era `UnitAura` bindings.
//! pushing an [`AuraState`] list straight through [`UiScript::set_auras`] and firing
//! `PLAYER_AURAS_CHANGED`, so these exercise the *button* handlers — the row/filter wiring, the
//! dispel-tinted border, the stack count, the countdown, the warning flash, and the right-click
//! cancel — the way the reference's own `BuffButton_*` do.
//!
//! **The window runs on the 1.12 verbs and the reference's index space** (b2ede294 landed the
//! `GetPlayerBuff` family): a button's `id` is a 0-based ordinal within its own filter,
//! `GetPlayerBuff` turns it into a CACHE POSITION, and every read below — texture, dispel class,
//! stacks, time left, cancel, tooltip — consumes that position. The tests drive it the way the
//! corpus does, including the `while GetPlayerBuff(i) >= 0 do` walk and a cancel by position.
//!
//! State is read back through the widget (`IsShown`/`GetText`/`GetAlpha`) and the paint through the
//! [`draw list`](UiScript::extract) (icon path + border tint), the same two-lens split the cast-bar
//! tests use — Lua-visible state is blind to what actually renders.
use benilla_ui::script::{AuraState, ExtractedQuad, QuadContent, ScriptValue, UiScript};
use benilla_ui::script::{AuraState, ExtractedQuad, QuadContent, UiScript};
/// Load one shipped `assets/ui/<file>`, panicking on any loader error.
fn load_xml(s: &UiScript, file: &str) {
@@ -61,13 +67,23 @@ fn aura(
expiration_time,
helpful,
cancelable,
// What the feed derives for a permanent aura: no finite duration to display. Here the two
// agree by construction, but they are different questions in the real feed — the flag is
// DBC-derived and correct on the frame the aura appears, before any duration packet lands
// (`benilla::ui_aura::until_cancelled`).
until_cancelled: expiration_time == 0.0,
channeled: false,
}
}
/// Push the list, then fire the discrete-change event the app's feed fires — the buttons repaint.
///
/// `PLAYER_AURAS_CHANGED`, no args: the reference's own event, which `ui_aura` fires beside the
/// Era-shaped `UNIT_AURA` on the same rebuild. It is what the buttons register for now, and it is
/// what every corpus aura addon registers for.
fn push(s: &mut UiScript, auras: Vec<AuraState>) {
s.set_auras("player", Some(auras));
s.fire_event("UNIT_AURA", vec![ScriptValue::Str("player".into())]);
s.fire_event("PLAYER_AURAS_CHANGED", vec![]);
}
/// One tick of the app's real order (OnUpdate → resolve → draw list), returning the quads.
@@ -289,8 +305,9 @@ fn right_clicking_a_cancelable_buff_queues_its_spell_cancel() {
s.take_cancel_aura_requests().is_empty(),
"nothing queued yet"
);
// The RightButtonUp handler -> CancelUnitBuff -> the app drains one CMSG_CANCEL_AURA by spell id.
s.eval::<()>("BenillaBuffButton_OnClick(BuffButton0)")
// The RightButtonUp handler -> CancelPlayerBuff(this.buffIndex) -> the app drains one
// CMSG_CANCEL_AURA by spell id.
s.eval::<()>("this = BuffButton0; BuffButton_OnClick()")
.unwrap();
assert_eq!(s.take_cancel_aura_requests(), vec![1126]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -374,9 +391,24 @@ fn a_refreshed_duration_reaches_the_bar_with_no_aura_event() {
// The same poll is what shows a timer that arrived AFTER the icon did — the fresh-apply case,
// where the duration packet's stamp lands a frame later than the descriptor delta.
s.set_auras("player", Some(vec![mark(0.0)])); // icon up, no duration joined yet
//
// **What the icon-first frame looks like MOVED when the bar came onto the 1.12 verbs.** The
// permanence test used to be `expirationTime > 0`, which cannot tell "no duration, ever" from
// "the duration has not landed yet" and so drew nothing for both. It is now `untilCancelled` —
// GetPlayerBuff's second return, derived from the SPELL (`ui_aura::until_cancelled`) — so a
// timed aura awaiting its stamp is known to be timed, and the reference counts it from `0 s`
// and pulses it for the frame or two until the packet joins. `mark` is a timed spell, so that
// is what it does here; the permanent case (a stance) is
// [`a_timed_aura_counts_down_and_a_permanent_one_shows_no_timer`], and it still shows nothing.
let mut pending = mark(0.0);
pending.until_cancelled = false; // a timed spell, stamp not yet arrived
s.set_auras("player", Some(vec![pending]));
frame(&mut s, 0.1);
assert!(!shown(&s, "BuffButton0Duration"), "no timer yet");
assert_eq!(
text(&s, "BuffButton0Duration"),
"0 s",
"the reference draws the floor of a timed aura it has no stamp for, not a blank"
);
s.set_auras("player", Some(vec![mark(60.0)])); // the stamp joins, still no event
frame(&mut s, 0.1);
assert_eq!(
@@ -419,7 +451,7 @@ fn the_duration_switch_hides_the_timers_and_closes_their_gutter() {
assert!((shown_gap - 15.0).abs() < 1e-3, "gutter: {shown_gap}");
// The switch, applied the way the options row applies it.
s.run("SHOW_BUFF_DURATIONS = \"0\"; BenillaBuffButtons_UpdatePositions()")
s.run("SHOW_BUFF_DURATIONS = \"0\"; BuffButtons_UpdatePositions()")
.unwrap();
frame(&mut s, 0.1);
assert!(!shown(&s, "BuffButton0Duration"), "no timer drawn");
@@ -435,10 +467,285 @@ fn the_duration_switch_hides_the_timers_and_closes_their_gutter() {
);
// And back: the text is re-written from the poll, not from anything it kept.
s.run("SHOW_BUFF_DURATIONS = \"1\"; BenillaBuffButtons_UpdatePositions()")
s.run("SHOW_BUFF_DURATIONS = \"1\"; BuffButtons_UpdatePositions()")
.unwrap();
frame(&mut s, 0.1);
assert!(shown(&s, "BuffButton0Duration"));
assert_eq!(text(&s, "BuffButton0Duration"), "19 s");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A mixed bar: three buffs and two debuffs in one insertion-ordered cache.
fn mixed_bar() -> Vec<AuraState> {
vec![
// pos 0 — buff, cancelable, timed
aura(
1126,
"Mark of the Wild",
"Interface\\Icons\\A",
true,
1,
None,
120.0,
true,
),
// pos 1 — debuff, Magic, 3 stacks
aura(
589,
"Shadow Word: Pain",
"Interface\\Icons\\B",
false,
3,
Some("Magic"),
18.0,
false,
),
// pos 2 — buff, cancelable
aura(
1459,
"Arcane Intellect",
"Interface\\Icons\\C",
true,
1,
None,
600.0,
true,
),
// pos 3 — debuff, Poison
aura(
2818,
"Deadly Poison",
"Interface\\Icons\\D",
false,
5,
Some("Poison"),
12.0,
false,
),
// pos 4 — buff, NOT cancelable (a stance): right-click must be refused
aura(
2457,
"Battle Stance",
"Interface\\Icons\\E",
true,
1,
None,
0.0,
false,
),
]
}
/// **The two index spaces, end to end.** The button `id` is a 0-based ordinal within its own
/// filter; `GetPlayerBuff` turns it into a CACHE POSITION that is absolute across filters. The bar
/// is only correct if the two agree: button N of a row must draw the aura the corpus's own walk
/// finds at that ordinal, and the position it cached must be the one an unfiltered walk reports.
///
/// The walk is spelled the way the corpus spells it — `while GetPlayerBuff(i) >= 0 do`
/// (`_LazyPig/LazyPig.lua:1174`, `Zorlen/Zorlen.lua:2797`) — because `-1`, not nil, is the
/// terminator, and because the default filter is `HELPFUL|HARMFUL`: the walk must see the debuffs
/// too or the whole thing silently halves.
#[test]
fn the_buttons_and_the_corpus_walk_agree_on_the_cache_positions() {
let mut s = harness();
push(&mut s, mixed_bar());
frame(&mut s, 0.1);
// The corpus walk: five auras, buffs AND debuffs, positions identical to the ordinals under
// the default filter — and it terminates.
let visited = s
.eval::<i64>(
r#"local i = 0
while GetPlayerBuff(i) >= 0 do
assert((GetPlayerBuff(i)) == i, "unfiltered walk is position-identical")
i = i + 1
assert(i < 100, "GetPlayerBuff walk did not terminate")
end
return i"#,
)
.unwrap();
assert_eq!(visited, 5, "three buffs and two debuffs, one cache");
// Each row filled from its own filter, in cache order — and each button's CACHED position is
// the absolute one, not its ordinal.
for (button, position) in [
("BuffButton0", 0), // helpful ordinal 0 -> position 0
("BuffButton1", 2), // helpful ordinal 1 -> position 2 (the debuff at 1 is skipped)
("BuffButton2", 4), // helpful ordinal 2 -> position 4
("BuffButton16", 1), // harmful ordinal 0 -> position 1
("BuffButton17", 3), // harmful ordinal 1 -> position 3
] {
assert!(shown(&s, button), "{button} draws its aura");
assert_eq!(
s.eval::<i64>(&format!("return {button}.buffIndex"))
.unwrap(),
position,
"{button} caches the CACHE POSITION, not its per-filter ordinal"
);
}
// The rows stop where the filter runs out; nothing bleeds across.
assert!(!shown(&s, "BuffButton3"), "only three buffs");
assert!(!shown(&s, "BuffButton18"), "only two debuffs");
assert_eq!(
s.eval::<i64>("return BuffButton3.buffIndex").unwrap(),
-1,
"a miss is -1, the sentinel the corpus terminates on — never nil"
);
// The reads that hang off the position land on the right aura: the 3-stack Magic debuff and
// the 5-stack Poison one are in the right buttons, tinted from their own dispel classes.
assert_eq!(text(&s, "BuffButton16Count"), "3");
assert_eq!(text(&s, "BuffButton17Count"), "5");
assert_eq!(
text(&s, "BuffButton0Count"),
"",
"a single stack shows nothing"
);
let quads = frame(&mut s, 0.1);
let poison = quads
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture {
path: Some(p),
color: Some(c),
..
} if p.ends_with("UI-Debuff-Overlays") => Some(*c),
_ => None,
})
// DebuffTypeColor["Poison"] = 0.00, 0.60, 0.00
.any(|c| c[0].abs() < 1e-3 && (c[1] - 0.60).abs() < 1e-3 && c[2].abs() < 1e-3);
assert!(poison, "the second debuff button wears the Poison tint");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Right-click cancels by cache position.** `BuffButton_OnClick` is
/// `CancelPlayerBuff(this.buffIndex)` — the reference's own line — so the button that gets
/// cancelled is the one under the cursor even when its ordinal and its position differ, which is
/// the case for every buff after the first debuff.
///
/// The gate is the app's, unchanged: a cancelable buff queues its SPELL id, a stance and a debuff
/// are silent no-ops. That last part is what a per-filter id could not guarantee on its own — here
/// it is structural, because a harmful button's handle simply names a harmful record.
#[test]
fn right_clicking_cancels_the_aura_under_the_cursor_by_its_cache_position() {
let mut s = harness();
push(&mut s, mixed_bar());
frame(&mut s, 0.1);
assert!(s.take_cancel_aura_requests().is_empty());
// The SECOND buff button: helpful ordinal 1, cache position 2, spell 1459. A bar that cancelled
// by ordinal would send 589 — the debuff sitting at position 1.
s.eval::<()>("this = BuffButton1; BuffButton_OnClick()")
.unwrap();
assert_eq!(
s.take_cancel_aura_requests(),
vec![1459],
"the aura under the cursor, not the record at the ordinal"
);
// A stance (helpful, not cancelable), both debuff buttons, and an empty slot: all silent.
for button in ["BuffButton2", "BuffButton16", "BuffButton17", "BuffButton7"] {
s.eval::<()>(&format!("this = {button}; BuffButton_OnClick()"))
.unwrap();
}
assert!(
s.take_cancel_aura_requests().is_empty(),
"a stance, a debuff and an empty button are all no-ops"
);
// The first buff still cancels normally.
s.eval::<()>("this = BuffButton0; BuffButton_OnClick()")
.unwrap();
assert_eq!(s.take_cancel_aura_requests(), vec![1126]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The temporary-enchant row** (ref `BuffFrame_Enchant_OnUpdate`, l.162-233). With nothing
/// enchanted it hides both slots and parks the bar on its resting point; with a weapon enchanted it
/// shows the icon, counts the expiry down from MILLISECONDS, and slides the top buff row left to
/// clear it — while rows 2 and 3, which hang off TempEnchant1 and TemporaryEnchantFrame, stay put.
#[test]
fn the_temporary_enchant_row_shows_a_weapon_enchant_and_moves_the_top_row_aside() {
let mut s = harness();
// A LOADED bar, deliberately: the enchant slots must be driven by GetWeaponEnchantInfo and by
// nothing else. The reference builds them from `BuffButtonTemplate` and blanks the four aura
// handlers with empty `<OnLoad/>`/`<OnEvent/>`/`<OnClick/>` elements; our loader treats an
// empty handler body as "none given" and so KEEPS the inherited one (`compile_handler` returns
// None and `apply_scripts` never calls SetScript). Under that idiom TempEnchant1 would take
// `BuffButton_OnLoad`, register PLAYER_AURAS_CHANGED, and paint the first buff in the corner —
// and a right-click on it would cancel that aura. Hence the standalone template, and hence a
// populated bar here: an empty aura list cannot tell the two apart.
push(&mut s, mixed_bar());
frame(&mut s, 0.1);
// Nothing enchanted: both slots hidden, and the bar sits where it has always sat.
assert!(
!shown(&s, "TempEnchant1"),
"the enchant slots are not buff buttons: no aura may reach them"
);
assert!(!shown(&s, "TempEnchant2"));
// The direct probe: none of the three aura handlers is even REGISTERED on an enchant slot.
// This is what the reference's empty-element idiom is supposed to achieve and what our loader
// does not do, so it is asserted rather than assumed.
for handler in ["OnLoad", "OnEvent", "OnClick"] {
assert!(
s.eval::<bool>(&format!(
"return TempEnchant1:GetScript(\"{handler}\") == nil"
))
.unwrap(),
"an enchant slot must carry no {handler} — it is not a buff button"
);
}
s.resolve();
let resting = s.eval::<f64>("return BuffFrame:GetRight()").unwrap();
let row2 = s.eval::<f64>("return BuffButton16:GetRight()").unwrap();
assert!(
(resting - (1024.0 - 175.0)).abs() < 1e-3,
"UIParent TOPRIGHT -175: {resting}"
);
// A main-hand enchant, 8 minutes out. The app feeds it the way `ui_char` does.
s.set_weapon_enchants(
Some(benilla_ui::script::WeaponEnchant {
remaining_ms: Some(480_000),
charges: 0,
}),
None,
);
frame(&mut s, 0.1);
assert!(
shown(&s, "TempEnchant1"),
"the enchanted weapon takes slot 1"
);
assert!(!shown(&s, "TempEnchant2"));
assert_eq!(
s.eval::<i64>("return TempEnchant1:GetID()").unwrap(),
16,
"the live-API MainHandSlot id, which is what its item tooltip hover needs"
);
assert_eq!(
text(&s, "TempEnchant1Duration"),
"8 m",
"480000 ms / 1000 -> 480 s -> SecondsToTimeAbbrev"
);
// The top buff row slid left by one icon plus the 5px gutter; the debuff row did not move.
s.resolve();
let shifted = s.eval::<f64>("return BuffFrame:GetRight()").unwrap();
assert!(
(resting - shifted - 37.0).abs() < 1e-3,
"one 32px enchant column + the 5px gutter: {resting} -> {shifted}"
);
assert!(
(s.eval::<f64>("return BuffButton16:GetRight()").unwrap() - row2).abs() < 1e-3,
"the debuff row hangs off TemporaryEnchantFrame and stays put"
);
// And back: the enchant drops, the row hides and the bar returns to its resting point.
s.set_weapon_enchants(None, None);
frame(&mut s, 0.1);
assert!(!shown(&s, "TempEnchant1"));
s.resolve();
assert!((s.eval::<f64>("return BuffFrame:GetRight()").unwrap() - resting).abs() < 1e-3);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}