The realm ships its own ArenaFrame, so our stand-in only shadowed it

`CharacterFrameAdapters.xml` existed on one premise: "There is no `ArenaFrame.lua` or `.xml`
anywhere in the player's chain". That is no longer true — the realm's FrameXML carries
`Turtle_ArenaUI\Turtle_ArenaFrame.xml` (toc rows 106-110, which `benilla.toc` already loads at
270-274), and it defines `ArenaFrame` with `enableMouse="true"` and `id="5"`, sharing the PvP tab
rather than inventing a sixth. Our empty stub loaded 100 rows earlier and was overwritten, and
wherever it loaded last it replaced the realm's real arena window with a frame that has no
content. `frame_flag_gate` had both halves of that pinned as divergences (mouse false, id 6).

Twelve more red UI tests, each one a file the realm rewrote under an assertion written against
stock 1.12:

- `inspect_tests` (4): all four died in `InspectArenaFrame.xml`'s own OnLoad, which indexes
  `_G[this:GetName().."Text"]` — a child of `ArenaTeamTemplate`, which lives in FrameXML and so is
  always there live. The rotate buttons are `InspectModelFrame…`, not stock's `InspectModel…`.
- `character_tests` (4): `HonorLevelText` keeps its authored placeholder because the realm
  commented that `SetText` out and hides the string instead (`PaperDollFrame.lua:126-127`);
  `PaperDollFrame.xml` builds 77 frames, not 75, the two being the realm's player-titles dropdown
  and its template's `$parentButton` (read off `FrameXML_Debug(1)`'s own trace, which also proved
  each of the 77 is created exactly once — a grown file, not a double load);
  `CHARACTERFRAME_SUBFRAMES` has six names; and one sound at load is the reference's own —
  `UIDropDownMenu_Initialize` runs its init function on the spot and `UnitPopup_ShowMenu` ends in
  `PlaySound("igMainMenuOpen")`.
- `loot_tests` (4): same load-time blip, drained and named; and the master-loot menu is rewritten
  end to end — the party arm drops any candidate it cannot resolve to a party unit, the raid arm
  groups by class with `hasArrow` submenus instead of by subgroup, and the event is now a toggle
  whose memory `DropDownList1`'s own OnHide clears. It also needs `MAX_RAID_MEMBERS`, so the loot
  window's prereq row names `RaidFrame.lua`.
- `dressup_tests` (2): `CharacterFrame_OnShow` gsubs `UnitPVPName("player")` unguarded, so the
  fixture's player needs the name a real one always has.

The one accepted flag difference is now written down with its reason: our options window keeps
1.12's `toplevel` where the realm's rewrite of `OptionsFrame.xml` took `movable` instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-31 12:09:51 -06:00
co-authored by Claude Opus 5
parent 89556c991a
commit 3a89f825bf
10 changed files with 295 additions and 59 deletions
@@ -1,16 +0,0 @@
<!--
Adapters over the stock CharacterFrame.xml; loaded after it, so these win.
Deviation: `ArenaFrame`, a sixth tab page 1.12 has no version of. TurtleWoW's `patch-4.mpq`
copy of `CharacterFrame.lua` appends "ArenaFrame" to `CHARACTERFRAME_SUBFRAMES` — six names
where the reference has five (line 1) — so `CharacterFrame_ShowSubFrame` indexes all six and
raises `attempt to index field '?' a nil value` on the fifth tab's Hide. There is no
`ArenaFrame.lua` or `.xml` anywhere in the player's chain, none in the reference `_G` capture,
and benilla implements no arena frames, so the name has to exist or the character window's own
tab loop dies. An empty hidden frame is the whole of it: no tab button, no content, no script.
`id="6"` continues the reference's own numbering (PaperDoll 1, PetPaperDoll 2, Reputation 3,
Skill 4, Honor 5), which is what `ToggleCharacter` hands `PanelTemplates_SetTab`.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Frame name="ArenaFrame" setAllPoints="true" parent="CharacterFrame" hidden="true" id="6" />
</Ui>
@@ -2672,7 +2672,9 @@
</Frame>
<!-- The era SettingsPanel's size and strata, HIGH, where 1.12's OptionsFrame is DIALOG;
toplevel as 1.12's OptionsFrame.xml:5. -->
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. -->
<Frame name="BenillaOptionsFrame" parent="UIParent" frameStrata="HIGH" toplevel="true" enableMouse="true" hidden="true">
<Size><AbsDimension x="920" y="724"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
+5 -2
View File
@@ -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
@@ -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::<bool>("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::<String>("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::<bool>("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::<i64>("return ArenaFrame:GetID()").unwrap(),
5,
"the arena page shares the PvP tab's id"
);
assert!(
s.eval::<bool>(
"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::<i64>("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| {
@@ -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()),
@@ -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
@@ -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 {}",
+193 -20
View File
@@ -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::<bool>("return StaticPopup1:IsVisible()").unwrap());
let _ = s.take_loot_master_gives();
click(&mut s, "LootButton1");
assert!(
!s.eval::<bool>("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::<bool>("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::<bool>("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::<bool>("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::<String>("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::<String>(&format!("return DropDownList1Button{button}:GetText()"))
.unwrap(),
class,
"button {button} is the localized class name, in DropDownSorted order"
);
assert!(
s.eval::<bool>(&format!(
"return DropDownList1Button{button}ExpandArrow:IsShown()"
))
.unwrap(),
"Group 1"
);
assert_eq!(
s.eval::<String>("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::<i64>("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::<bool>("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::<String>("return DropDownList2Button1:GetText()")
.unwrap(),
"Thrall"
);
assert_eq!(
s.eval::<i64>("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"
);
}
@@ -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",
+17 -4
View File
@@ -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 `<OnLoad>`
// 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