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
+52 -62
View File
@@ -59,8 +59,7 @@ fn questgiver_show_hide_plays_open_and_close_kits() {
s.fire_event("QUEST_DETAIL", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return BenillaQuestFrame:IsVisible()")
.unwrap(),
s.eval::<bool>("return QuestFrame:IsVisible()").unwrap(),
"the questgiver window opened onto the left slot"
);
assert_eq!(
@@ -73,8 +72,7 @@ fn questgiver_show_hide_plays_open_and_close_kits() {
s.fire_event("QUEST_FINISHED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaQuestFrame:IsVisible()")
.unwrap(),
!s.eval::<bool>("return QuestFrame:IsVisible()").unwrap(),
"QUEST_FINISHED hid the questgiver window"
);
assert_eq!(
@@ -100,10 +98,10 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
load_xml(&s, "QuestFrame.xml");
let panels = [
"BenillaQuestGreetingPanel",
"BenillaQuestDetailPanel",
"BenillaQuestProgressPanel",
"BenillaQuestRewardPanel",
"QuestGreetingPanel",
"QuestDetailPanel",
"QuestProgressPanel",
"QuestRewardPanel",
];
let is_shown =
|s: &UiScript, name: &str| s.eval::<bool>(&format!("return {name}:IsShown()")).unwrap();
@@ -122,12 +120,12 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
for name in panels {
assert_eq!(
is_shown(&s, name),
name == "BenillaQuestGreetingPanel",
name == "QuestGreetingPanel",
"only the greeting panel is shown after QUEST_GREETING"
);
}
assert_eq!(
s.eval::<String>("return BenillaQuestNpcNameText:GetText()")
s.eval::<String>("return QuestNpcNameText:GetText()")
.unwrap(),
"Deputy Willem"
);
@@ -148,7 +146,7 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
for name in panels {
assert_eq!(
is_shown(&s, name),
name == "BenillaQuestDetailPanel",
name == "QuestDetailPanel",
"only the detail panel is shown after QUEST_DETAIL"
);
}
@@ -160,7 +158,7 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
/// The reward/choice grid, ported from the ref's shared `QuestFrameItems_Update` (the SAME idiom the
/// quest log's own reward rows already prove out) — now on REAL per-panel widgets
/// (`BenillaQuestDetailChoice*`/`BenillaQuestDetailReward*`), exercised on the Detail panel with the
/// (`QuestDetailChoice*`/`QuestDetailReward*`), exercised on the Detail panel with the
/// fixture shape the task's capture drives: 2 choices + 1 fixed reward + money.
#[test]
fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
@@ -198,56 +196,55 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaQuestDetailChoice1:IsShown()")
.eval::<bool>("return QuestDetailChoice1:IsShown()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaQuestDetailChoice2:IsShown()")
.eval::<bool>("return QuestDetailChoice2:IsShown()")
.unwrap());
assert!(!s
.eval::<bool>("return BenillaQuestDetailChoice3:IsShown()")
.eval::<bool>("return QuestDetailChoice3:IsShown()")
.unwrap());
assert_eq!(
s.eval::<String>("return BenillaQuestDetailChoice1Name:GetText()")
s.eval::<String>("return QuestDetailChoice1Name:GetText()")
.unwrap(),
"Worn Sword"
);
assert_eq!(
s.eval::<String>("return BenillaQuestDetailItemChooseText:GetText()")
s.eval::<String>("return QuestDetailItemChooseText:GetText()")
.unwrap(),
"You may choose one of these rewards:"
);
assert_eq!(
s.eval::<String>("return BenillaQuestDetailItemReceiveText:GetText()")
s.eval::<String>("return QuestDetailItemReceiveText:GetText()")
.unwrap(),
"You will also receive:",
"choices present -> the ref's \"also\" wording (QuestFrame.lua:461-462)"
);
assert!(s
.eval::<bool>("return BenillaQuestDetailReward1:IsShown()")
.eval::<bool>("return QuestDetailReward1:IsShown()")
.unwrap());
assert!(!s
.eval::<bool>("return BenillaQuestDetailReward2:IsShown()")
.eval::<bool>("return QuestDetailReward2:IsShown()")
.unwrap());
assert_eq!(
s.eval::<String>("return BenillaQuestDetailReward1Name:GetText()")
s.eval::<String>("return QuestDetailReward1Name:GetText()")
.unwrap(),
"Militia Hammer"
);
assert_eq!(
s.eval::<String>("return BenillaQuestDetailRewardTitleText:GetText()")
s.eval::<String>("return QuestDetailRewardTitleText:GetText()")
.unwrap(),
"Rewards"
);
// The detail panel's choice rows are informational only (ref: only the REWARD panel's rows
// select). They DO carry an OnClick now (`BenillaQuestItem_OnClick`, the ref's own
// select). They DO carry an OnClick now (`QuestItem_OnClick`, the ref's own
// QuestItemTemplate script — the ctrl/shift fork, decisions 1059/1060), but it has no select
// arm at all: an unmodified click must not raise and must not set an itemChoice.
s.run("BenillaQuestDetailChoice1:Click()").ok();
s.run("QuestDetailChoice1:Click()").ok();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<i64>("return BenillaQuestFrame.itemChoice")
.unwrap(),
s.eval::<i64>("return QuestFrame.itemChoice").unwrap(),
0,
"a detail-panel row never selects a reward"
);
@@ -288,26 +285,26 @@ fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaQuestRewardChoiceHighlight:IsShown()")
!s.eval::<bool>("return QuestRewardChoiceHighlight:IsShown()")
.unwrap(),
"no row picked yet"
);
// Completing without a choice picked is a no-op (ref: QuestChooseRewardError guard).
s.run("BenillaQuestRewardCompleteButton:Click()").unwrap();
s.run("QuestRewardCompleteButton:Click()").unwrap();
assert!(
s.take_quest_actions().is_empty(),
"must pick a choice first"
);
s.run("BenillaQuestRewardChoice2:Click()").unwrap();
s.run("QuestRewardChoice2:Click()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return BenillaQuestRewardChoiceHighlight:IsShown()")
s.eval::<bool>("return QuestRewardChoiceHighlight:IsShown()")
.unwrap(),
"picking a choice row shows the highlight"
);
s.run("BenillaQuestRewardCompleteButton:Click()").unwrap();
s.run("QuestRewardCompleteButton:Click()").unwrap();
assert_eq!(
s.take_quest_actions(),
vec![benilla_ui::script::QuestAction::Reward(1)],
@@ -337,15 +334,12 @@ fn greeting_goodbye_button_closes_the_window() {
vec![ScriptValue::Str("Deputy Willem".into())],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaQuestFrame:IsVisible()")
.unwrap());
assert!(s.eval::<bool>("return QuestFrame:IsVisible()").unwrap());
s.run("BenillaQuestGreetingGoodbyeButton:Click()").unwrap();
s.run("QuestGreetingGoodbyeButton:Click()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaQuestFrame:IsVisible()")
.unwrap(),
!s.eval::<bool>("return QuestFrame:IsVisible()").unwrap(),
"Goodbye closes the questgiver window"
);
}
@@ -375,10 +369,10 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaQuestAcceptButton:IsShown()")
.eval::<bool>("return QuestAcceptButton:IsShown()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaQuestDeclineButton:IsShown()")
.eval::<bool>("return QuestDeclineButton:IsShown()")
.unwrap());
s.resolve();
@@ -434,10 +428,10 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
"instant text keeps the writing sound: one scratch on the wake tick"
);
assert!(s
.eval::<bool>("return BenillaQuestAcceptButton:IsEnabled()")
.eval::<bool>("return QuestAcceptButton:IsEnabled()")
.unwrap());
assert_eq!(
s.eval::<f32>("return BenillaQuestDetailTextAlphaFrame:GetAlpha()")
s.eval::<f32>("return QuestDetailTextAlphaFrame:GetAlpha()")
.unwrap(),
1.0,
"the block snaps straight to opaque — no QUESTINFO_FADE_IN ramp in instant mode"
@@ -493,7 +487,7 @@ fn write_on_still_fades_when_instant_text_is_off() {
"the write-on scratches the WriteQuest quill each tick"
);
assert!(!s
.eval::<bool>("return BenillaQuestAcceptButton:IsEnabled()")
.eval::<bool>("return QuestAcceptButton:IsEnabled()")
.unwrap());
// Half a second more runs the 13-char gradient off (40 chars/s): Accept wakes and the block
@@ -501,10 +495,10 @@ fn write_on_still_fades_when_instant_text_is_off() {
s.tick(0.5);
assert!(s.errors().is_empty(), "tick errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaQuestAcceptButton:IsEnabled()")
.eval::<bool>("return QuestAcceptButton:IsEnabled()")
.unwrap());
let alpha = s
.eval::<f32>("return BenillaQuestDetailTextAlphaFrame:GetAlpha()")
.eval::<f32>("return QuestDetailTextAlphaFrame:GetAlpha()")
.unwrap();
assert!(
alpha < 1.0,
@@ -512,7 +506,7 @@ fn write_on_still_fades_when_instant_text_is_off() {
);
s.tick(1.1);
assert_eq!(
s.eval::<f32>("return BenillaQuestDetailTextAlphaFrame:GetAlpha()")
s.eval::<f32>("return QuestDetailTextAlphaFrame:GetAlpha()")
.unwrap(),
1.0,
"the fade completes at opaque"
@@ -544,7 +538,7 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<String>("return getglobal('BenillaQuestNpcNameText'):GetText() or ''")
s.eval::<String>("return getglobal('QuestNpcNameText'):GetText() or ''")
.unwrap(),
"Marshal McBride",
"the panel-open event's arg1 lands in the title bar"
@@ -566,14 +560,13 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
);
// The late-name path: open with an empty name, the refresh brings it.
s.run("getglobal('BenillaQuestNpcNameText'):SetText('')")
.unwrap();
s.run("getglobal('QuestNpcNameText'):SetText('')").unwrap();
s.fire_event(
"QUEST_ITEM_UPDATE",
vec![ScriptValue::Str("Marshal McBride".into())],
);
assert_eq!(
s.eval::<String>("return getglobal('BenillaQuestNpcNameText'):GetText() or ''")
s.eval::<String>("return getglobal('QuestNpcNameText'):GetText() or ''")
.unwrap(),
"Marshal McBride",
"the QUEST_ITEM_UPDATE refresh also carries the name"
@@ -633,7 +626,7 @@ fn greeting_panel_title_rows_grow_to_their_wrapped_titles() {
let row = |i: u32| -> (f32, f32, f32) {
s.eval::<(f32, f32, f32)>(&format!(
"local b = getglobal('BenillaQuestTitleButton{i}')\n\
"local b = getglobal('QuestTitleButton{i}')\n\
return b:GetTop(), b:GetBottom(), b:GetFontString():GetStringHeight()"
))
.unwrap()
@@ -715,15 +708,14 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
// The regression this fork could break: a PLAIN click still picks the choice (the ref's third
// arm). Row 2 is picked here so the modified clicks below — aimed at row 1 — would visibly
// move it if they leaked into the select arm.
s.run("BenillaQuestRewardChoice2:Click()").unwrap();
s.run("QuestRewardChoice2:Click()").unwrap();
assert_eq!(
s.eval::<i64>("return BenillaQuestFrame.itemChoice")
.unwrap(),
s.eval::<i64>("return QuestFrame.itemChoice").unwrap(),
2,
"a plain click still selects the reward choice"
);
assert!(s
.eval::<bool>("return BenillaQuestRewardChoiceHighlight:IsShown()")
.eval::<bool>("return QuestRewardChoiceHighlight:IsShown()")
.unwrap());
assert!(
s.take_dressup_intents().is_empty(),
@@ -733,7 +725,7 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
// SHIFT + chat open → the row's full escaped link, and the selection is untouched.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.run("BenillaQuestRewardChoice1:Click()").unwrap();
s.run("QuestRewardChoice1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
@@ -742,16 +734,15 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
"shift-click posted choice 1's link"
);
assert_eq!(
s.eval::<i64>("return BenillaQuestFrame.itemChoice")
.unwrap(),
s.eval::<i64>("return QuestFrame.itemChoice").unwrap(),
2,
"the shift arm returns — it must NOT also select the clicked choice"
);
// A FIXED reward row (plain BenillaQuestItemTemplate → BenillaQuestItem_OnClick) posts too.
// A FIXED reward row (plain QuestItemTemplate → QuestItem_OnClick) posts too.
s.run("ChatFrameEditBox:SetText(\"\")").unwrap();
s.set_modifiers(true, false, false);
s.run("BenillaQuestRewardReward1:Click()").unwrap();
s.run("QuestRewardReward1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
@@ -762,7 +753,7 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
// CTRL → the dressing room wearing the clicked reward, and STILL no reselect. The intent pair
// is ordered: Dress (the room was closed, so it re-dresses in the player's own gear) then TryOn.
s.set_modifiers(false, true, false);
s.run("BenillaQuestRewardChoice1:Click()").unwrap();
s.run("QuestRewardChoice1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.take_dressup_intents(),
@@ -773,8 +764,7 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
"ctrl-click opened the room wearing choice 1"
);
assert_eq!(
s.eval::<i64>("return BenillaQuestFrame.itemChoice")
.unwrap(),
s.eval::<i64>("return QuestFrame.itemChoice").unwrap(),
2,
"the ctrl arm returns — it must NOT also select the clicked choice"
);