diff --git a/crates/benilla-app/assets/ui/CharacterFrameAdapters.xml b/crates/benilla-app/assets/ui/CharacterFrameAdapters.xml
deleted file mode 100644
index 7c4e2419..00000000
--- a/crates/benilla-app/assets/ui/CharacterFrameAdapters.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index 90009c46..e283e8e4 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -2672,7 +2672,9 @@
+ toplevel as 1.12's OptionsFrame.xml:5. The realm's rewrite of that file dropped the flag
+ and made its window `movable` instead; ours is pinned at CENTER, so it keeps the flag.
+ That difference is the one entry in `frame_flag_gate`'s KNOWN table. -->
diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc
index 4e035470..b077e478 100644
--- a/crates/benilla-app/assets/ui/benilla.toc
+++ b/crates/benilla-app/assets/ui/benilla.toc
@@ -159,8 +159,6 @@ Interface\FrameXML\ReputationFrame.xml
Interface\FrameXML\SkillFrame.xml
# Brings HonorFrameTemplates.xml, whose row templates `Blizzard_InspectUI` inherits.
Interface\FrameXML\HonorFrame.xml
-# Ours: the sixth tab page TurtleWoW's `CharacterFrame.lua` lists and 1.12 never shipped.
-CharacterFrameAdapters.xml
Interface\FrameXML\DressUpFrame.xml
# Deliberately absent, the chain's `StatsFrame.xml` would sit here (its `FrameXML.toc` 60): the
# debug-build frame-stat overlay, `hidden="true"` with an OnUpdate that is the only reader of
@@ -267,6 +265,11 @@ Interface\FrameXML\SpellChatLinks.lua
# reading `$parentText`, `$parentDetails` and nine `$parentDetails*` children by name — without
# that row the inspect window raised four times per inspect, three OnLoads and
# `InspectArenaFrame_OnShow`.
+#
+# `Turtle_ArenaFrame.xml` is also the sixth page the realm's `CharacterFrame.lua` appends to
+# `CHARACTERFRAME_SUBFRAMES` (`ArenaFrame`, `id="5"`, sharing the PvP tab), which the window's own
+# sweep hides unguarded. We shipped an empty stand-in for that name until 2026-10-31; the realm's
+# file defines it, so the stand-in only shadowed the real window.
Interface\FrameXML\Turtle_ArenaUI\Turtle_ArenaUI.lua
Interface\FrameXML\Turtle_ArenaUI\Turtle_ArenaUITemplates.xml
Interface\FrameXML\Turtle_ArenaUI\Turtle_ArenaFrame.xml
diff --git a/crates/benilla-app/src/ui_script/character_tests.rs b/crates/benilla-app/src/ui_script/character_tests.rs
index 121d4d7d..85b8fe05 100644
--- a/crates/benilla-app/src/ui_script/character_tests.rs
+++ b/crates/benilla-app/src/ui_script/character_tests.rs
@@ -113,6 +113,12 @@ fn backpack_with_fitting_helm() -> benilla_ui::script::ContainerState {
}
/// A frame count fingerprints the player's own file, not a target: it moves only when that does.
+///
+/// 77, not stock 1.12's 75: the realm added its player-titles control, which is two frames —
+/// `PaperDollFrameTitlesDropdown` (`PaperDollFrame.xml:761`, a `UIDropDownMenuTemplate` button) and
+/// the `$parentButton` that template brings. Read off the loader's own `-- Creating %s named %s`
+/// trace with `FrameXML_Debug(1)`, which also showed every one of the 77 created exactly once —
+/// the check that tells a grown file from a double load.
#[test]
fn shipped_character_frame_loads_clean() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -129,7 +135,7 @@ fn shipped_character_frame_loads_clean() {
);
assert_eq!(
counts.get("Interface\\FrameXML\\PaperDollFrame.xml"),
- Some(&75),
+ Some(&77),
"the reference's own PaperDollFrame.xml"
);
}
@@ -151,9 +157,15 @@ fn shipped_character_frame_drives_end_to_end() {
s.set_player_combat_stats(Some(combat_stats()));
s.set_inventory_slots(inventory_with_head_item());
- assert!(
- s.take_sounds().is_empty(),
- "no sound at load (never transitions)"
+ // One sound at load, and it is the reference's own: `PlayerFrameDropDown_OnLoad` calls
+ // `UIDropDownMenu_Initialize(this, …, "MENU")`, which runs the init function on the spot
+ // (`UIDropDownMenu.lua:51-54`, "Set the initialize function and call it"), and that function is
+ // `UnitPopup_ShowMenu`, whose last line is `PlaySound("igMainMenuOpen")`
+ // (`UnitPopup.lua:295`). The menu blip at UI load is 1.12's, not a transition of ours.
+ assert_eq!(
+ s.take_sounds(),
+ vec![SoundRequest::KitName("igMainMenuOpen".into())],
+ "the load-time menu blip, and nothing else (no panel transition)"
);
assert!(!s.eval::("return CharacterFrame:IsVisible()").unwrap());
@@ -393,10 +405,18 @@ fn level_line_reads_the_snapshot_and_repaints_on_unit_level() {
.unwrap(),
"Level 12 Night Elf Warrior"
);
- // Written by the same call, which is why `HonorFrame.xml` is in the load list.
+ // The honor copy of the line is NOT written: the realm commented that `SetText` out and
+ // replaced it with `HonorLevelText:Hide()` (`PaperDollFrame.lua:126-127`), so the string keeps
+ // `HonorFrame.xml:60`'s authored placeholder and stays down. `HonorFrame.xml` is still in the
+ // load list because `PaperDollFrame_SetLevel` indexes the global unguarded.
assert_eq!(
s.eval::("return HonorLevelText:GetText()").unwrap(),
- "Level 12 Night Elf Warrior"
+ "Level level race class",
+ "the authored placeholder, untouched — the realm no longer writes this line"
+ );
+ assert!(
+ !s.eval::("return HonorLevelText:IsShown()").unwrap(),
+ "and the realm's own `PaperDollFrame_SetLevel` hides it"
);
// A level-up as the app sends it: a new snapshot, then `UNIT_LEVEL` for the player.
@@ -931,11 +951,27 @@ fn a_keybind_page_switch_moves_the_tab_row_with_it() {
"{page} must be in CHARACTERFRAME_SUBFRAMES"
);
}
+ // The sixth name is the realm's own arena page, which has no tab of its own: it reuses
+ // Honor's `id="5"` (`Turtle_ArenaFrame.xml:3`), so `ToggleCharacter("ArenaFrame")` leaves the
+ // tab row on PvP, and it is in the list only so the sweep hides it with the rest.
+ assert_eq!(
+ s.eval::("return ArenaFrame:GetID()").unwrap(),
+ 5,
+ "the arena page shares the PvP tab's id"
+ );
+ assert!(
+ s.eval::(
+ "for _, v in CHARACTERFRAME_SUBFRAMES do if v == \"ArenaFrame\" then return true end \
+ end return false"
+ )
+ .unwrap(),
+ "ArenaFrame must be in CHARACTERFRAME_SUBFRAMES"
+ );
assert_eq!(
s.eval::("return getn(CHARACTERFRAME_SUBFRAMES)")
.unwrap(),
- 5,
- "…and there is nothing past the end for a sixth page to arrive into unnoticed"
+ 6,
+ "…and there is nothing past the end for a seventh page to arrive into unnoticed"
);
let selected = |s: &mut UiScript| {
diff --git a/crates/benilla-app/src/ui_script/dressup_tests.rs b/crates/benilla-app/src/ui_script/dressup_tests.rs
index d715b811..79a5afb2 100644
--- a/crates/benilla-app/src/ui_script/dressup_tests.rs
+++ b/crates/benilla-app/src/ui_script/dressup_tests.rs
@@ -100,6 +100,10 @@ fn shown_paper_doll() -> UiScript {
"player",
Some(UnitState {
exists: true,
+ // The name is not decoration here: `CharacterFrame_OnShow` runs
+ // `gsub(UnitPVPName("player"), "\n.+", "")` unguarded (`CharacterFrame.lua:85`), and
+ // `gsub` raises on a nil first argument. A player who exists always has one.
+ name: Some("Gromgosh".into()),
level: 60,
race: Some("Human".into()),
race_file: Some("Human".into()),
diff --git a/crates/benilla-app/src/ui_script/frame_flag_gate.rs b/crates/benilla-app/src/ui_script/frame_flag_gate.rs
index 5eec88f3..160aefa4 100644
--- a/crates/benilla-app/src/ui_script/frame_flag_gate.rs
+++ b/crates/benilla-app/src/ui_script/frame_flag_gate.rs
@@ -84,10 +84,15 @@ impl std::fmt::Display for Flag {
}
}
-/// The accepted differences, each with its reason; empty, so any new divergence fails the gate.
+/// The accepted differences, each with its reason; anything not named here fails the gate.
const KNOWN: &[Known] = &[
// A parent entry is acceptable only inside `UIParent`'s tree: a seat crossing between it and
// the top level is a defect, since `SetFullScreenFrame` hides `UIParent` and all below it.
+ Known {
+ frame: "BenillaOptionsFrame",
+ flag: Flag::Toplevel,
+ why: "The `Benilla` prefix pairs this with the chain's `OptionsFrame`, but the realm rewrote that file and its window took a different trade: `movable=\"true\"` and no `toplevel` (stock 1.12's `OptionsFrame.xml:5` had `toplevel=\"true\"` and no `movable`). Ours is pinned at CENTER and never dragged, so it keeps the 1.12 flag it was built from: clicking it raises it inside HIGH, which is what a window the game menu opens over everything else needs.",
+ },
];
/// Every stock FrameXML document off the player's chain, in `FrameXML.toc` order with each
diff --git a/crates/benilla-app/src/ui_script/inspect_tests.rs b/crates/benilla-app/src/ui_script/inspect_tests.rs
index 3494da2a..cd3d628f 100644
--- a/crates/benilla-app/src/ui_script/inspect_tests.rs
+++ b/crates/benilla-app/src/ui_script/inspect_tests.rs
@@ -114,6 +114,15 @@ fn armed() -> UiScript {
// `ItemButtonTemplate`, the honor page the honor row templates `HonorFrame.xml` brings.
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\HonorFrame.xml");
+ // `ArenaTeamTemplate`, which the realm's own `InspectArenaFrame.xml` inherits for its three
+ // team buttons. It lives in FrameXML (`Turtle_ArenaUI\Turtle_ArenaUITemplates.xml`, toc row
+ // 107), so the live client always has it before a LoadOnDemand addon runs; without it the
+ // `inherits=` resolves to nothing, `$parentText` is never built, and the button's own OnLoad
+ // raises on `_G[this:GetName().."Text"]`.
+ load_xml(
+ &s,
+ r"Interface\FrameXML\Turtle_ArenaUI\Turtle_ArenaUITemplates.xml",
+ );
// A LoadOnDemand addon, seated off the chain as a registry row and loaded by the stock
// `InspectFrame_LoadUI` (`UIParent.lua:170`), which `InspectUnit` calls (`UIParent.lua:223`).
super::test_ui::seat_chain_addon(&mut s, "Blizzard_InspectUI");
@@ -146,6 +155,15 @@ fn shipped_inspect_frame_loads_clean() {
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\HonorFrame.xml");
+ // `ArenaTeamTemplate`, which the realm's own `InspectArenaFrame.xml` inherits for its three
+ // team buttons. It lives in FrameXML (`Turtle_ArenaUI\Turtle_ArenaUITemplates.xml`, toc row
+ // 107), so the live client always has it before a LoadOnDemand addon runs; without it the
+ // `inherits=` resolves to nothing, `$parentText` is never built, and the button's own OnLoad
+ // raises on `_G[this:GetName().."Text"]`.
+ load_xml(
+ &s,
+ r"Interface\FrameXML\Turtle_ArenaUI\Turtle_ArenaUITemplates.xml",
+ );
super::test_ui::seat_chain_addon(&mut s, "Blizzard_InspectUI");
s.run("InspectFrame_LoadUI()").unwrap();
// Each slot carries its `GetInventorySlotInfo` id (1..=19, no ammo slot) as its frame ID, set
@@ -390,14 +408,16 @@ fn shipped_inspect_frame_drives_end_to_end() {
);
// Rotate: left subtracts 0.03 from the booth yaw and right adds it, each click with its sound.
- s.run("InspectModelRotateLeftButton:Click()").unwrap();
+ // The realm's own `InspectPaperDollFrame.xml:159` names these `InspectModelFrame…`, not stock
+ // 1.12's `InspectModel…` — it rewrote the file, so the stock name is nil here and live.
+ s.run("InspectModelFrameRotateLeftButton:Click()").unwrap();
assert!(
(s.model_pane_facing("InspectModelFrame") - 0.58).abs() < 0.0001,
"rotate-left subtracts 0.03, got {}",
s.model_pane_facing("InspectModelFrame")
);
- s.run("InspectModelRotateRightButton:Click()").unwrap();
- s.run("InspectModelRotateRightButton:Click()").unwrap();
+ s.run("InspectModelFrameRotateRightButton:Click()").unwrap();
+ s.run("InspectModelFrameRotateRightButton:Click()").unwrap();
assert!(
(s.model_pane_facing("InspectModelFrame") - 0.64).abs() < 0.0001,
"rotate-right adds 0.03, got {}",
diff --git a/crates/benilla-app/src/ui_script/loot_tests.rs b/crates/benilla-app/src/ui_script/loot_tests.rs
index 240d47f0..f209d108 100644
--- a/crates/benilla-app/src/ui_script/loot_tests.rs
+++ b/crates/benilla-app/src/ui_script/loot_tests.rs
@@ -254,6 +254,21 @@ fn shipped_loot_frame_drives_end_to_end() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+/// The load-time menu blips this file set makes, drained and checked.
+///
+/// Not ours and not a defect: every `UIDropDownMenu_Initialize(frame, init, "MENU")` runs its init
+/// function on the spot (`UIDropDownMenu.lua:51-54`, "Set the initialize function and call it"),
+/// and each unit-popup init ends in `PlaySound("igMainMenuOpen")` (`UnitPopup.lua:295`). The
+/// reference plays one per such dropdown as the UI loads, which is why a `/reload` blips.
+fn drain_load_blips(s: &mut UiScript) {
+ let blips = s.take_sounds();
+ let menu_open = SoundRequest::KitName("igMainMenuOpen".into());
+ assert!(
+ blips.iter().all(|k| *k == menu_open),
+ "only the load-time menu blips belong here, got {blips:?}"
+ );
+}
+
/// Only an empty loot plays a Lua sound, `LOOTWINDOWOPENEMPTY` (LootFrame.lua:135-136).
#[test]
fn loot_empty_roll_plays_the_empty_open_kit() {
@@ -264,6 +279,7 @@ fn loot_empty_roll_plays_the_empty_open_kit() {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
+ drain_load_blips(&mut s);
s.set_loot(Some(coin_and_two_items()));
s.fire_event("LOOT_OPENED", vec![]);
@@ -301,6 +317,7 @@ fn fishing_loot_open_plays_the_reel_and_swaps_the_portrait() {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
+ drain_load_blips(&mut s);
let has_icon = |quads: &[ExtractedQuad], needle: &str| {
quads.iter().any(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains(needle))
@@ -673,15 +690,54 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
/// Master loot: an item at or above `MASTER_LOOT_THREHOLD` (4, epic; the misspelling is the
/// reference's, LootFrame.lua:3) asks `CONFIRM_LOOT_DISTRIBUTION` before it is given.
+///
+/// The realm rewrote this menu. Its party arm does not simply list `GetMasterLootCandidate(i)`
+/// the way stock 1.12 did: it resolves each candidate back to `"player"` or a `"party"..j` token
+/// and **drops any candidate it cannot** (`LootFrame.lua:317-349`), because it colours the row by
+/// `RAID_CLASS_COLORS[class]` off that unit's own `UnitClass`. So a party with the candidates in
+/// it is not decoration here — without one the menu has no rows at all and never opens.
#[test]
fn shipped_loot_frame_hands_a_master_row_to_a_candidate() {
benilla_formats::wow_data_or_skip!();
+ use benilla_ui::script::{PartyMemberInfo, PartyState, UnitState};
+
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
+ drain_load_blips(&mut s);
+
+ // `GetNumPartyMembers() > 0` picks the party arm (`LootFrame.lua:317`).
+ s.set_party(PartyState {
+ members: vec![
+ PartyMemberInfo {
+ name: "Thrall".into(),
+ guid: 0x11,
+ },
+ PartyMemberInfo {
+ name: "Cairne".into(),
+ guid: 0x12,
+ },
+ ],
+ loot_method: "master".into(),
+ master_looter: Some(0),
+ loot_threshold: 2,
+ ..PartyState::default()
+ });
+ let member = |name: &str, class: &str, token: &str| UnitState {
+ exists: true,
+ name: Some(name.into()),
+ is_player: true,
+ player_controlled: true,
+ class: Some(class.into()),
+ class_file: Some(token.into()),
+ ..UnitState::default()
+ };
+ s.set_unit("player", Some(member("Gromgosh", "Warrior", "WARRIOR")));
+ s.set_unit("party1", Some(member("Thrall", "Shaman", "SHAMAN")));
+ s.set_unit("party2", Some(member("Cairne", "Druid", "DRUID")));
let row = |name: &str, quality: u32| {
Some(LootRow {
@@ -793,24 +849,60 @@ fn shipped_loot_frame_hands_a_master_row_to_a_candidate() {
"row 2 to candidate 1, only after the accept"
);
- // A row click hides a standing confirmation (LootFrame.lua:156).
+ // A row click hides a standing confirmation (LootFrame.lua:156). The confirmation needs the
+ // epic row, so the menu re-opens on row 2 first — and this runs while the menu is closed,
+ // because an open `DropDownList1` sits over the loot rows and takes the click itself.
+ click(&mut s, "LootButton2");
+ let _ = s.take_loot_picks();
s.fire_event("OPEN_MASTER_LOOT_LIST", vec![]);
s.resolve();
click(&mut s, "DropDownList1Button1");
assert!(s.eval::("return StaticPopup1:IsVisible()").unwrap());
+ let _ = s.take_loot_master_gives();
click(&mut s, "LootButton1");
assert!(
!s.eval::("return StaticPopup1:IsVisible()").unwrap(),
"clicking another row closes the standing confirmation"
);
+
+ // The realm made the event a toggle (`LootFrame.lua:102-109`): it remembers which row the
+ // open was for and, asked again for that same row, only hides the list. The memory is not
+ // sticky — `LootFrame_OnLoad` hooks `DropDownList1`'s own OnHide to clear it
+ // (`LootFrame.lua:38-42`) — so it closes only while the menu is still up, and the ask after
+ // any close opens again. Stock 1.12 had neither the memory nor the hook.
+ click(&mut s, "LootButton1");
+ let _ = s.take_loot_picks();
+ s.fire_event("OPEN_MASTER_LOOT_LIST", vec![]);
+ s.resolve();
+ assert!(
+ s.eval::("return DropDownList1:IsVisible()").unwrap(),
+ "the first ask for this row opens it"
+ );
+ s.fire_event("OPEN_MASTER_LOOT_LIST", vec![]);
+ s.resolve();
+ assert!(
+ !s.eval::("return DropDownList1:IsVisible()").unwrap(),
+ "the same row again, menu still up: the second ask only hides it"
+ );
+ s.fire_event("OPEN_MASTER_LOOT_LIST", vec![]);
+ s.resolve();
+ assert!(
+ s.eval::("return DropDownList1:IsVisible()").unwrap(),
+ "…and the hide cleared the memory, so the next ask opens again"
+ );
}
-/// In a raid each candidate sits in its subgroup's five-slot block, and the menu keeps a "Group N"
-/// row only for an occupied block (LootFrame.lua:197-213).
+/// In a raid the realm's menu is grouped by **class**, not by subgroup: a `GIVE_LOOT` title, then
+/// one arrowed row per class with anyone in it, in `DropDownSorted`'s fixed order, each opening a
+/// level-2 list of that class's players (`LootFrame.lua:268-316`).
+///
+/// Stock 1.12 listed a "Group N" row per occupied five-slot block; the realm replaced that whole
+/// arm, and `LootFrame_UpdateRoster` (`:120-153`) is what fills the rows — off `GetRaidRosterInfo`
+/// for the name and `UnitClass("raid"..i)` for the class, so both have to be seated.
#[test]
-fn the_master_loot_menu_groups_raid_candidates_by_subgroup() {
+fn the_master_loot_menu_groups_raid_candidates_by_class() {
benilla_formats::wow_data_or_skip!();
- use benilla_ui::script::{PartyState, RaidMemberInfo};
+ use benilla_ui::script::{PartyState, RaidMemberInfo, UnitState};
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -818,17 +910,52 @@ fn the_master_loot_menu_groups_raid_candidates_by_subgroup() {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
+ drain_load_blips(&mut s);
- // A raid: `GetNumRaidMembers() > 0` is the nested arm's whole gate (LootFrame.lua:189).
+ // A raid: `GetNumRaidMembers() > 0` is the raid arm's whole gate (`LootFrame.lua:293`), and
+ // the roster walk reads one row per member, so the vector's length is the member count.
+ let roster = [
+ ("Thrall", "Shaman", "SHAMAN"),
+ ("Cairne", "Druid", "DRUID"),
+ ("Sylvanas", "Rogue", "ROGUE"),
+ ];
s.set_party(PartyState {
- raid: vec![RaidMemberInfo::default(); 7],
+ raid: roster
+ .iter()
+ .enumerate()
+ .map(|(i, (name, class, token))| RaidMemberInfo {
+ name: (*name).into(),
+ guid: 0x10 + i as u64,
+ subgroup: 0,
+ level: 60,
+ class: Some((*class).into()),
+ class_file: Some((*token).into()),
+ online: true,
+ ..RaidMemberInfo::default()
+ })
+ .collect(),
loot_method: "master".into(),
master_looter: Some(0),
loot_threshold: 2,
..PartyState::default()
});
+ // `UnitClass("raid"..i)`, which is where the roster takes the class from — not the roster row.
+ for (i, (name, class, token)) in roster.iter().enumerate() {
+ s.set_unit(
+ &format!("raid{}", i + 1),
+ Some(UnitState {
+ exists: true,
+ name: Some((*name).into()),
+ is_player: true,
+ player_controlled: true,
+ class: Some((*class).into()),
+ class_file: Some((*token).into()),
+ ..UnitState::default()
+ }),
+ );
+ }
- // Subgroup 1 holds slots 1-2 and subgroup 3 slot 11, the rest holes, as the client files them.
+ // The candidate slots the server filled, with the holes the client files them into.
let mut candidates = vec![None; 12];
candidates[0] = Some("Thrall".to_string());
candidates[1] = Some("Cairne".to_string());
@@ -865,21 +992,67 @@ fn the_master_loot_menu_groups_raid_candidates_by_subgroup() {
"Give Loot To:",
"the raid arm opens with the title row the party arm has no such thing for"
);
- assert_eq!(
- s.eval::("return DropDownList1Button2:GetText()")
+ // `DropDownSorted`'s order (`LootFrame.lua:267`), not the roster's: Druid, Rogue, Shaman.
+ for (button, class) in [(2, "Druid"), (3, "Rogue"), (4, "Shaman")] {
+ assert_eq!(
+ s.eval::(&format!("return DropDownList1Button{button}:GetText()"))
+ .unwrap(),
+ class,
+ "button {button} is the localized class name, in DropDownSorted order"
+ );
+ assert!(
+ s.eval::(&format!(
+ "return DropDownList1Button{button}ExpandArrow:IsShown()"
+ ))
.unwrap(),
- "Group 1"
- );
- assert_eq!(
- s.eval::("return DropDownList1Button3:GetText()")
- .unwrap(),
- "Group 3",
- "the empty group 2 block contributes no row — and group 3 is NOT relabelled 2"
- );
+ "a class row carries the submenu arrow (`info.hasArrow`, shown at \
+ `UIDropDownMenu.lua:265-266`)"
+ );
+ }
assert_eq!(
s.eval::("return DropDownList1.numButtons").unwrap(),
- 3,
- "title + two occupied groups, out of eight blocks"
+ 4,
+ "title + one row per class with anyone in it, out of the nine DropDownSorted names"
+ );
+
+ // Level 2: the class row's own players, each carrying its candidate index as the value the
+ // give reads (`LootFrame.lua:271-291`).
+ // The submenu is the **arrow's** own OnEnter, not the row's
+ // (`UIDropDownMenuTemplates.xml:100-102`: `$parentExpandArrow` hovers into
+ // `ToggleDropDownMenu(level + 1, this:GetParent().value)`).
+ s.resolve();
+ let (ax, ay) = s
+ .eval::<(f64, f64)>("return DropDownList1Button4ExpandArrow:GetCenter()")
+ .unwrap();
+ s.mouse_move(ax as f32, ay as f32);
+ s.resolve();
+ assert!(
+ s.eval::("return DropDownList2:IsVisible()").unwrap(),
+ "hovering the class row's arrow opens level 2"
+ );
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+ assert_eq!(
+ s.eval::("return DropDownList2Button1:GetText()")
+ .unwrap(),
+ "Thrall"
+ );
+ assert_eq!(
+ s.eval::("return DropDownList2.numButtons").unwrap(),
+ 1,
+ "one shaman in the raid, one row"
+ );
+ // Thrall is candidate 1, so the click gives slot 1 to candidate 1.
+ let (bx, by) = s
+ .eval::<(f64, f64)>("return DropDownList2Button1:GetCenter()")
+ .unwrap();
+ s.mouse_button(bx as f32, by as f32, "LeftButton", true);
+ s.mouse_button(bx as f32, by as f32, "LeftButton", false);
+ s.resolve();
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+ assert_eq!(
+ s.take_loot_master_gives(),
+ vec![(1, 1)],
+ "row 1 to the candidate index the submenu row carried"
);
}
diff --git a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs
index ae02e069..ec401f6e 100644
--- a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs
+++ b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs
@@ -1957,10 +1957,6 @@ fn assets_ui_does_not_grow() {
// falls through into `attempt to call local 'handledata'`. There is no stock file to
// migrate — AIO is the realm's, not Blizzard's. The file's own header has the trace.
"AIOHandlers.lua",
- // An `ArenaFrame` stub, the one name TurtleWoW's patched `CharacterFrame.lua` appends to
- // `CHARACTERFRAME_SUBFRAMES` with no file anywhere in the chain to define it; without it
- // the stock tab loop raises on every tab change. The file's own header has the trace.
- "CharacterFrameAdapters.xml",
"ContainerFrameAdapters.xml",
"GameMenuFrame.xml",
"KeyBindingsPage.xml",
diff --git a/crates/benilla-app/src/ui_script/test_ui.rs b/crates/benilla-app/src/ui_script/test_ui.rs
index 44b1394c..cc971afa 100644
--- a/crates/benilla-app/src/ui_script/test_ui.rs
+++ b/crates/benilla-app/src/ui_script/test_ui.rs
@@ -123,7 +123,16 @@ const PREREQS: &[(&str, &[&str])] = &[
),
(
"lootframe.xml",
- &[r"Interface\FrameXML\ItemButtonTemplate.xml"],
+ &[
+ r"Interface\FrameXML\ItemButtonTemplate.xml",
+ // The `.lua` alone, for `MAX_RAID_MEMBERS` (`RaidFrame.lua:1`): the limit the realm's
+ // rewritten master-loot submenu counts candidates to (`LootFrame.lua:273`). A runtime
+ // need, not a load one — the loot window is toc row 76 and `RaidFrame.xml` is 82, so
+ // the live client has the constant long before a menu can open, while a harness that
+ // seats the loot window alone raises `'for' limit must be a number` on the first
+ // class-row hover.
+ r"Interface\FrameXML\RaidFrame.lua",
+ ],
),
(
"tradeframe.xml",
@@ -666,9 +675,13 @@ pub(super) const CHARACTER_UI: &[&str] = &[
r"Interface\FrameXML\ReputationFrame.xml",
"Interface\\FrameXML\\SkillFrame.xml",
"Interface\\FrameXML\\HonorFrame.xml",
- // Ours: the sixth page TurtleWoW's `CharacterFrame.lua` lists and 1.12 never shipped. It loads
- // here for the reason it does in `benilla.toc`: the subframe list is swept unguarded.
- "CharacterFrameAdapters.xml",
+ // The sixth page TurtleWoW's `CharacterFrame.lua` lists and 1.12 never shipped. The realm
+ // ships it itself, in FrameXML (toc rows 106-110), and the subframe list is swept unguarded,
+ // so it must be seated before the window opens. The `.lua` leads: the window's ``
+ // calls `ArenaFrame_OnLoad`, and the templates precede the frame that inherits them.
+ r"Interface\FrameXML\Turtle_ArenaUI\Turtle_ArenaUI.lua",
+ r"Interface\FrameXML\Turtle_ArenaUI\Turtle_ArenaUITemplates.xml",
+ r"Interface\FrameXML\Turtle_ArenaUI\Turtle_ArenaFrame.xml",
];
/// The manifest slice stock `FriendsFrame.xml` and `RaidFrame.xml` reach at load or on show, in its