Files
benilla-octo/crates/benilla-app/src/ui_script/multibar_stance_tests.rs
T
Sam SungandClaude Opus 5.5 34b7c30af2 The core loads the chain's Bindings.xml and DefaultBindings.wtf, and every command runs its stock body (#587)
The core now loads the chain's `Interface\FrameXML\Bindings.xml` after the `FrameXML.toc` rows, as the reference's UI load does. The load checks the file at `0x48fff9` and loads it through the addons' loader `0x4b6f70` at `0x490018`. The default keys come from the chain's `WTF\DefaultBindings.wtf` (`0x4b62b0`). Every command now runs the stock file's own body, and the registry compiled into benilla, with its 91 retyped bodies, is deleted.

- **The loader's rules (`0x4b6f70`).** It reads the root's children only and skips empty, `debug="true"` and non-Windows rows. A second definition of a name is skipped, and duplicate headers are refused.
- **Keys and commands are separate tables, as in 1.12.** `SetBinding` never reads the command (`0x4b7490`). `RunBinding` runs the body in the VM. `RunCommand` sets `keystate` around the body and clears it after (`0x4b7c42`).
- **Engine behaviour sits behind the Lua globals the bodies call.** These are the movement, turn and mouse-steer bits. Focus loss clears the direction bits and keeps the mouse pair (`0x514490`). One key's stop clears its bit, as `0x514b70` does, so releasing W while UP is held stops the character.
- **Behaviour changes that follow the stock bodies.**
  - The nameplate keys (V, Shift-V, Ctrl-V) now run the stock bodies.
  - ALT-Z runs `CloseAllWindows(); UIParent:Hide()`.
  - The minimap, sound, stance and bonus-bar keys call their stock functions.
  - The wheel zooms one `CameraZoomIn(1.0)` per notch.
- **Platform.** benilla answers as the PC build, so a `platform="mac"` row does not register on any OS.

Verified:
- New tests: a fixture `Bindings.xml` (reordered, edited, and with debug, empty, nested and Mac rows) drives the command list, defaults and bodies. The V keys follow the stock bodies. No stock body's text remains in the tree. Every stock body's calls resolve after a production load. Each test fails with its change undone.
- `scripts/gates.sh` and `scripts/smoke.sh` are green.
- A live run with `WOW_GM=off`:
  - W and Space moved and jumped.
  - B toggled the backpack.
  - 1 reached `UseAction`.
  - V, Shift-V and Ctrl-V behaved as the stock bodies do.

Built on macOS; no platform seam touched.

Fixes #570
Fixes #563

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:51:21 +07:00

1533 lines
57 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The four extra bars (MultiActionBars.xml) and the stance bar (BonusActionBarFrame.xml), end to
//! end. The bars start down, so most tests first raise theirs through the Options rows' globals.
use benilla_ui::script::{ActionSlot, QuadContent, ScriptValue, SpellTooltipView, UiScript};
use super::test_ui::load_ui as load_xml;
/// The main bar's and the four extra bars' stock files, with UIParent.xml for the manage pass the
/// stance bar's OnShow runs and GameTooltip.xml for the OnLeave a bar hiding under the cursor
/// fires, and `extra` merged in; returns the frames MultiActionBars.xml built.
fn load_action_bar(s: &UiScript, extra: &[&str]) -> usize {
const MULTIBARS: &str = "Interface\\FrameXML\\MultiActionBars.xml";
let mut built = 0;
for file in super::test_ui::production_order(&[
&[
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
// `MultiActionBarFrame_OnLoad` writes `UIOptionsFrameCheckButtons`, so
// UIOptionsFrame.xml loads first, as in FrameXML.toc (l.21, l.39); it also declares
// `ALWAYS_SHOW_MULTIBARS`.
r"Interface\FrameXML\OptionsFrame.lua",
r"Interface\FrameXML\UIOptionsFrame.xml",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
MULTIBARS,
// `ExhaustionTick_Update` reads `ReputationWatchBar`, which ReputationFrame.xml
// declares.
r"Interface\FrameXML\ReputationFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
"ScrollTemplates.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
],
extra,
]) {
let frames = load_xml(s, file);
if file == MULTIBARS {
built = frames;
}
}
built
}
/// Raise exactly the bars named, through the globals the Options rows write.
fn show_bars(s: &UiScript, bars: &[u32]) {
let mut lua = String::new();
for bar in 1..=4u32 {
lua.push_str(&format!(
"SHOW_MULTI_ACTIONBAR_{bar} = {} ",
if bars.contains(&bar) { "1" } else { "nil" }
));
}
// …then the manage pass, which the options row runs after a toggle (UIOptionsFrame.xml:666)
// and `MultiActionBar_Update` does not.
lua.push_str("MultiActionBar_Update() UIParent_ManageFramePositions()");
s.run(&lua).unwrap();
}
/// The two bottom bars, raised: fixed pages (BottomLeft 61-72, BottomRight 49-60, from
/// `ActionButton_GetPagedID`'s parent-name fork, ActionButton.lua:455-466), empty wells hidden
/// unless a payload is held, and ids no bonus page moves.
#[test]
fn shipped_multibars_drive_end_to_end() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let frames = load_action_bar(&s, &[]);
assert_eq!(
frames, 100,
"what stock MultiActionBars.xml declares: the four bar frames and their 48 buttons, \
each with a $parentCooldown — the same 100 ours built for the same seats"
);
// Occupy main slot 1, BottomLeft slot 1 (action 61), BottomRight slot 1 (action 49).
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_Main".into()),
kind: 0x00,
action: 100,
count: 0,
consumable: false,
}),
);
s.set_action(
61,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_BL".into()),
kind: 0x00,
action: 200,
count: 0,
consumable: false,
}),
);
s.set_action(
49,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_BR".into()),
kind: 0x00,
action: 300,
count: 0,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
show_bars(&s, &[1, 2]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.resolve();
let quads = s.extract();
let icon = |path: &str| {
quads
.iter()
.find(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == path))
.and_then(|q| q.rect)
};
// Main button 1 spans x[8,44] y[4,40], and BottomLeft's BOTTOMLEFT is that button's TOPLEFT
// + (0, 17): its button 1 spans x[8,44] y[57,93]. BottomRight's LEFT is BottomLeft's
// (500-wide) RIGHT + (10, 0), so its left is 518 (MultiActionBars.xml:496-513).
let bl = icon("Interface\\Icons\\Spell_BL").expect("BottomLeft button 1 icon");
assert_eq!(
(bl.left, bl.bottom, bl.right, bl.top),
(8.0, 57.0, 44.0, 93.0)
);
let br = icon("Interface\\Icons\\Spell_BR").expect("BottomRight button 1 icon");
assert_eq!(
(br.left, br.bottom, br.right, br.top),
(518.0, 57.0, 554.0, 93.0)
);
let rings = |quads: &[benilla_ui::script::ExtractedQuad], path: &str| {
quads
.iter()
.filter(
|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == path),
)
.count()
};
assert_eq!(
rings(&quads, "Interface\\Buttons\\UI-Quickslot2"),
3,
"the occupied main slot + the 2 occupied multibar buttons; every empty well is hidden, \
the main bar's included (ActionButton.lua:69-70)"
);
// BottomLeft button 1 (centre (26, 75)) queues its fixed id 61, bonus page or not.
s.mouse_button(26.0, 75.0, "LeftButton", true);
s.mouse_button(26.0, 75.0, "LeftButton", false);
assert_eq!(
s.take_action_uses()
.into_iter()
.map(|u| u.action)
.collect::<Vec<_>>(),
vec![61]
);
s.set_bonus_bar_offset(1);
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
s.mouse_button(26.0, 75.0, "LeftButton", true);
s.mouse_button(26.0, 75.0, "LeftButton", false);
assert_eq!(
s.take_action_uses()
.into_iter()
.map(|u| u.action)
.collect::<Vec<_>>(),
vec![61],
"a bonus page never re-pages a multibar"
);
s.set_bonus_bar_offset(0);
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
// Let the bonus overlay finish its descent (one OnUpdate past 0.15 s), or its 12 wells answer
// the SHOWGRID below.
s.tick(0.2);
s.tick(0.01);
// While a payload is held every empty well shows the `UI-Quickslot` ring: 11 main, 22 multibar.
s.fire_event("ACTIONBAR_SHOWGRID", vec![]);
s.resolve();
assert_eq!(
rings(&s.extract(), "Interface\\Buttons\\UI-Quickslot"),
33,
"grid shows every empty well as a drop target"
);
s.fire_event("ACTIONBAR_HIDEGRID", vec![]);
s.resolve();
assert_eq!(rings(&s.extract(), "Interface\\Buttons\\UI-Quickslot"), 0);
assert_eq!(
rings(&s.extract(), "Interface\\Buttons\\UI-Quickslot2"),
3,
"letting go hides every empty well again, the main bar's included; the three occupied \
buttons keep their rings"
);
// An unbound button's hotkey text is `RANGE_INDICATOR` while `IsActionInRange` is non-nil, set
// on PLAYER_TARGET_CHANGED (ActionButton.lua:121-144, 332-334); `ActionButton_OnUpdate` shows
// it only out of range (l.389-395) and tints it red on the range recheck (l.416-428).
use benilla_ui::script::ActionState;
s.set_action_state(
61,
Some(ActionState {
usable: true,
in_range: Some(false),
has_range: true,
..Default::default()
}),
);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
s.tick(0.5); // past the 0.2 s range recheck
s.resolve();
let dot_shown = |quads: &[benilla_ui::script::ExtractedQuad]| {
quads
.iter()
.any(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "●"))
};
assert!(dot_shown(&s.extract()), "out of range shows the red dot");
s.set_action_state(
61,
Some(ActionState {
usable: true,
in_range: Some(true),
has_range: true,
..Default::default()
}),
);
s.tick(0.5);
s.resolve();
assert!(!dot_shown(&s.extract()), "back in range clears the dot");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The stance bar (`ShapeshiftBarFrame`) over the reference's form list (`0xb71100`): hidden at
/// zero forms, the active form checked, a non-castable one greyed, a click queuing its spell.
#[test]
fn shipped_stance_bar_drives_end_to_end() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::ShapeshiftFormView;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// `ShapeshiftBarFrame` is parented to `MainMenuBar` and its update calls
// `CooldownFrame_SetTimer` (BonusActionBarFrame.lua:201), so both load first.
load_action_bar(&s, &[]);
// Bar 1 up: the stance bar's OnShow runs the manage pass (BonusActionBarFrame.xml:350-352),
// whose `ShapeshiftBarFrame` row adds 45 only while bar 1 is up (UIParent.lua:1583, 1605).
show_bars(&s, &[1]);
assert!(
s.eval::<bool>("return ShapeshiftBarFrame ~= nil and ShapeshiftButton10 ~= nil")
.unwrap(),
"the stance bar's frames come with the stock bonus-bar file"
);
// No forms pushed (a mage): the frame is hidden.
assert!(!s
.eval::<bool>("return ShapeshiftBarFrame:IsShown()")
.unwrap());
// A warrior's two stances: battle active, defensive known but not castable.
s.set_shapeshift_forms(vec![
ShapeshiftFormView {
spell_id: 2457,
texture: Some("Interface\\Icons\\Stance_A".into()),
name: "Battle Stance".into(),
active: true,
castable: true,
cooldown: None,
},
ShapeshiftFormView {
spell_id: 71,
texture: Some("Interface\\Icons\\Stance_B".into()),
name: "Defensive Stance".into(),
active: false,
castable: false,
cooldown: None,
},
]);
s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.resolve();
let quads = s.extract();
// Geometry: the stance frame's BOTTOMLEFT = MainMenuBar (1024×53, screen-bottom
// centered ⇒ left edge 0) TOPLEFT +(30,45) = (30,98); button 1 at frame BOTTOMLEFT +(11,3),
// 30×30 ⇒ x[41,71] y[101,131]; button 2 chains +7 ⇒ left 78.
let icon = |quads: &[benilla_ui::script::ExtractedQuad], path: &str| {
quads
.iter()
.find(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == path))
.and_then(|q| q.rect)
};
let a = icon(&quads, "Interface\\Icons\\Stance_A").expect("stance button 1 icon");
assert_eq!(
(a.left, a.bottom, a.right, a.top),
(41.0, 101.0, 71.0, 131.0)
);
let b = icon(&quads, "Interface\\Icons\\Stance_B").expect("stance button 2 icon");
assert_eq!(b.left, 78.0);
assert!(s
.eval::<bool>("return ShapeshiftButton1:GetChecked()")
.unwrap());
assert!(!s
.eval::<bool>("return ShapeshiftButton2:GetChecked()")
.unwrap());
let grey = quads.iter().find_map(|q| match &q.content {
QuadContent::Texture {
path: Some(p),
color: Some(c),
..
} if p.contains("Stance_B") => Some(*c),
_ => None,
});
assert_eq!(
grey,
Some([0.4, 0.4, 0.4, 1.0]),
"not-castable greys the icon"
);
// Buttons past numForms stay hidden: exactly two stance icons drew.
let stance_icons = quads
.iter()
.filter(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains("Stance_"))
})
.count();
assert_eq!(stance_icons, 2);
// Button 2 (centre (93, 116)) queues the form's spell id; cast or cancel is the app drain's.
s.mouse_button(93.0, 116.0, "LeftButton", true);
s.mouse_button(93.0, 116.0, "LeftButton", false);
assert_eq!(s.take_shapeshift_casts(), vec![71]);
// The OnClick undoes the CheckButton's own toggle (BonusActionBarFrame.xml:31-38), so the
// ring waits for the form byte…
assert!(
!s.eval::<bool>("return ShapeshiftButton2:GetChecked()")
.unwrap(),
"a clicked non-active form must stay unchecked until the form byte confirms"
);
assert!(s
.eval::<bool>("return ShapeshiftButton1:GetChecked()")
.unwrap());
// …and clicking the active form still queues its spell (the app drain no-ops it).
s.mouse_button(56.0, 116.0, "LeftButton", true);
s.mouse_button(56.0, 116.0, "LeftButton", false);
assert_eq!(s.take_shapeshift_casts(), vec![2457]);
assert!(
s.eval::<bool>("return ShapeshiftButton1:GetChecked()")
.unwrap(),
"clicking the active stance must not untoggle its checked ring"
);
// Right-click does nothing here, as in the reference: `ShapeshiftButtonTemplate`'s OnLoad only
// scales the cooldown (BonusActionBarFrame.xml:28-30), so the click mask stays left-up only,
// and `0x77924b` gates the pushed texture on that mask.
let depressed = |s: &UiScript| {
s.extract().iter().any(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains("Quickslot-Depress"))
})
};
s.mouse_move(56.0, 116.0);
s.mouse_button(56.0, 116.0, "RightButton", true);
assert!(
!depressed(&s),
"a right-press must NOT flash — unregistered"
);
s.mouse_button(56.0, 116.0, "RightButton", false);
assert!(
s.take_shapeshift_casts().is_empty(),
"and must not cast — the ref never routes a right-click here at all"
);
s.mouse_button(56.0, 116.0, "LeftButton", true);
assert!(depressed(&s), "the LEFT press does flash — the mask has it");
s.mouse_button(56.0, 116.0, "LeftButton", false);
let _ = s.take_shapeshift_casts();
assert!(
s.eval::<bool>("return ShapeshiftButton1:GetChecked()")
.unwrap(),
"and the active form stays lit through either"
);
// An emptied list hides the whole frame.
s.set_shapeshift_forms(vec![]);
s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
assert!(!s
.eval::<bool>("return ShapeshiftBarFrame:IsShown()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A multibar button's tooltip shows its own paged action, not the main bar's slot of the same
/// index (`ActionButton_SetTooltip`, ActionButton.lua:365-382).
#[test]
fn multibar_hover_renders_the_buttons_own_action() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
// `ExhaustionTick_Update` reads `ReputationWatchBar`, which ReputationFrame.xml declares.
r"Interface\FrameXML\ReputationFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
"ScrollTemplates.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
] {
load_xml(&s, file);
}
// Main slot 2 stays empty under BottomLeft slot 2 (action 62), so a wrong id shows no tooltip.
for (slot, spell) in [(1u32, 100u32), (61, 200), (49, 300), (62, 400)] {
s.set_action(
slot,
Some(ActionSlot {
texture: Some(format!("Interface\\Icons\\Spell_{spell}")),
kind: 0x00,
action: spell,
count: 0,
consumable: false,
}),
);
s.set_spell_tooltip(
spell,
SpellTooltipView {
name: format!("Spell {spell}"),
description: "does a thing".into(),
..Default::default()
},
);
}
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
show_bars(&s, &[1, 2]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let hover_name = |s: &UiScript, button: &str| -> Option<String> {
s.run(&format!(
"GameTooltip:Hide() this = {button} ActionButton_SetTooltip()"
))
.unwrap();
s.eval::<Option<String>>(
"if not GameTooltip:IsShown() then return nil end return GameTooltipTextLeft1:GetText()",
)
.unwrap()
};
assert_eq!(
hover_name(&s, "ActionButton1").as_deref(),
Some("Spell 100"),
"the main bar still reads its own slot"
);
assert_eq!(
hover_name(&s, "MultiBarBottomLeftButton1").as_deref(),
Some("Spell 200"),
"BottomLeft button 1 is action 61 — not main slot 1 (the spell below it)"
);
assert_eq!(
hover_name(&s, "MultiBarBottomRightButton1").as_deref(),
Some("Spell 300"),
"BottomRight button 1 is action 49"
);
assert_eq!(
hover_name(&s, "MultiBarBottomLeftButton2").as_deref(),
Some("Spell 400"),
"an occupied multibar slot over an EMPTY main slot still renders (the no-tooltip half)"
);
s.set_bonus_bar_offset(1);
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
assert_eq!(
hover_name(&s, "MultiBarBottomLeftButton1").as_deref(),
Some("Spell 200"),
"a bonus page never re-pages a multibar hover"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `MultiBarRight` and `MultiBarLeft` are `parent="UIParent"` frames (MultiActionBars.xml:514,
/// 523) whose templates are `hidden="true"` (l.266, 381); Atlas (`Atlas.lua:387`) and Bartender2
/// (`Bartender2.lua:74`) index them by name.
#[test]
fn the_vertical_multibars_exist_hidden_on_the_reference_pages() {
benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_action_bar(&s, &[]);
for bar in ["MultiBarRight", "MultiBarLeft"] {
assert!(
s.eval::<bool>(&format!("return {bar} ~= nil")).unwrap(),
"{bar} must exist — Atlas and Bartender2 index it by name at session start"
);
assert!(
!s.eval::<bool>(&format!("return {bar}:IsShown()")).unwrap(),
"{bar} must ship HIDDEN, exactly as VerticalMultiBar3/4 do"
);
// The two calls the addons make.
s.run(&format!("{bar}:SetFrameStrata(\"MEDIUM\")")).unwrap();
s.run(&format!("{bar}:ClearAllPoints()")).unwrap();
}
// ActionButton.lua:8-9: `RIGHT_ACTIONBAR_PAGE` 3 (actions 25..36), `LEFT_ACTIONBAR_PAGE` 4.
for (bar, first, last) in [("MultiBarRight", 25, 36), ("MultiBarLeft", 37, 48)] {
assert_eq!(
s.eval::<i64>(&format!("return ActionButton_GetPagedID({bar}Button1)"))
.unwrap(),
first,
"{bar}'s first slot"
);
assert_eq!(
s.eval::<i64>(&format!("return ActionButton_GetPagedID({bar}Button12)"))
.unwrap(),
last,
"{bar}'s last slot"
);
}
}
/// Each extra bar is down until its bit of `PLAYER_FIELD_BYTES` byte 2 is set; a fresh character's
/// byte is 0, and the bits name bars only in FrameXML (`0x4e76e0`). Bar 4 also needs bar 3
/// (MultiActionBars.lua:73), since `MultiBarLeft` anchors to `MultiBarRight`.
#[test]
fn every_extra_bar_stays_down_until_its_own_toggle_is_set() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s, &[]);
const BARS: [&str; 4] = [
"MultiBarBottomLeft",
"MultiBarBottomRight",
"MultiBarRight",
"MultiBarLeft",
];
let shown =
|s: &UiScript, bar: &str| s.eval::<bool>(&format!("return {bar}:IsShown()")).unwrap();
// The zero byte, through `MultiActionBar_Update` as at login (UIParent.lua:364-365).
s.run("MultiActionBar_Update()").unwrap();
for bar in BARS {
assert!(!shown(&s, bar), "{bar} must be down at a zero toggle byte");
}
for (flag, want) in [(1u32, 0usize), (2, 1), (3, 2)] {
show_bars(&s, &[flag]);
for (i, bar) in BARS.iter().enumerate() {
assert_eq!(
shown(&s, bar),
i == want,
"bar {flag} alone: {bar} shown {}, expected {}",
shown(&s, bar),
i == want
);
}
}
show_bars(&s, &[4]);
for bar in BARS {
assert!(
!shown(&s, bar),
"{bar}: SHOW_MULTI_ACTIONBAR_4 without 3 raises nothing"
);
}
show_bars(&s, &[3, 4]);
assert!(shown(&s, "MultiBarRight"));
assert!(shown(&s, "MultiBarLeft"), "MultiBarLeft rides on bar 3");
assert!(!shown(&s, "MultiBarBottomLeft"));
assert!(!shown(&s, "MultiBarBottomRight"));
show_bars(&s, &[1, 2, 3, 4]);
for bar in BARS {
assert!(shown(&s, bar), "{bar} up with the full byte");
}
show_bars(&s, &[]);
for bar in BARS {
assert!(!shown(&s, bar), "{bar} down again");
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Raising a bottom bar moves what shares the bottom band, through the manage pass the toggle runs
/// after `MultiActionBar_Update`: a variable row, `CONTAINER_OFFSET_Y` (70, +27), and a frame row,
/// `CastingBarFrame` (60, +40), each with `bottomEither` (UIParent.lua:1580, 1587).
#[test]
fn raising_a_bottom_bar_moves_the_managed_bottom_stack() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s, &[r"Interface\FrameXML\CastingBarFrame.xml"]);
// Read the row's y off the anchor, so a hidden cast bar answers too.
let cast_y = |s: &UiScript| {
s.eval::<f64>("local _, _, _, _, y = CastingBarFrame:GetPoint() return y")
.unwrap()
};
let offset_y = |s: &UiScript| s.eval::<f64>("return CONTAINER_OFFSET_Y").unwrap();
show_bars(&s, &[]);
assert_eq!(offset_y(&s), 70.0, "band clear: the row's base");
let low = cast_y(&s);
assert_eq!(low, 60.0, "CastingBarFrame's baseY");
show_bars(&s, &[1]);
assert_eq!(
offset_y(&s),
97.0,
"bottom-left up: 70 + the row's bottomEither 27"
);
assert_eq!(cast_y(&s), 100.0, "the cast bar rises with it (60 + 40)");
show_bars(&s, &[2]);
assert_eq!(offset_y(&s), 97.0, "bottomEither is either");
assert_eq!(cast_y(&s), 100.0);
// Both up pays `bottomEither` once, and the row's `bottomRight` is 0.
show_bars(&s, &[1, 2]);
assert_eq!(offset_y(&s), 97.0);
show_bars(&s, &[]);
assert_eq!(offset_y(&s), 70.0);
assert_eq!(cast_y(&s), low);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A raised bar takes its page out of the main bar's cycle and a lowered one gives it back:
/// ActionButton.lua:13 declares all six viewable, and `MultiActionBar_Update` blanks the raised
/// bars' pages (MultiActionBars.lua:49-80).
#[test]
fn viewable_action_bar_pages_follow_the_bar_toggles() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s, &[]);
let viewable = |s: &UiScript| {
s.eval::<String>(
"local out = {} \
for i = 1, NUM_ACTIONBAR_PAGES do \
if VIEWABLE_ACTION_BAR_PAGES[i] then table.insert(out, i) end \
end \
return table.concat(out, \",\")",
)
.unwrap()
};
show_bars(&s, &[]);
assert_eq!(viewable(&s), "1,2,3,4,5,6", "no bar up, no page claimed");
// ActionButton.lua:6-9: BottomLeft 6, BottomRight 5, Right 3, Left 4.
show_bars(&s, &[1]);
assert_eq!(viewable(&s), "1,2,3,4,5", "BottomLeft owns page 6");
show_bars(&s, &[2]);
assert_eq!(viewable(&s), "1,2,3,4,6", "BottomRight owns page 5");
show_bars(&s, &[3]);
assert_eq!(viewable(&s), "1,2,4,5,6", "MultiBarRight owns page 3");
show_bars(&s, &[3, 4]);
assert_eq!(
viewable(&s),
"1,2,5,6",
"…and MultiBarLeft page 4 beside it"
);
// Bar 4 without bar 3 claims nothing: the pages follow the same conjunction as the bar.
show_bars(&s, &[4]);
assert_eq!(viewable(&s), "1,2,3,4,5,6");
show_bars(&s, &[1, 2, 3, 4]);
assert_eq!(viewable(&s), "1,2", "all four up: only the main pages left");
show_bars(&s, &[]);
assert_eq!(viewable(&s), "1,2,3,4,5,6", "and every page comes back");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Always Show ActionBars holds every extra bar's empty wells open. `showgrid` is a count
/// (ActionButton.lua:245, 255), so the option and a held payload are independent askers.
#[test]
fn the_grid_option_holds_the_extra_bars_empty_wells_open() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s, &[]);
show_bars(&s, &[1]);
let well = |s: &UiScript| {
s.eval::<bool>("return MultiBarBottomLeftButton5:IsShown()")
.unwrap()
};
assert!(!well(&s), "an empty multibar well is hidden by default");
s.run("ALWAYS_SHOW_MULTIBARS = \"1\" MultiActionBar_UpdateGridVisibility()")
.unwrap();
assert!(well(&s), "the option opens it with nothing in hand");
// All four bars, raised or not (`MultiActionBar_ShowAllGrids`, MultiActionBars.lua:82-87).
for bar in ["MultiBarBottomRight", "MultiBarRight", "MultiBarLeft"] {
assert!(
s.eval::<bool>(&format!("return {bar}Button5:IsShown()"))
.unwrap(),
"{bar}'s wells are held open too"
);
}
// A payload on top of the option keeps the well open when the option lets go.
s.fire_event("ACTIONBAR_SHOWGRID", vec![]);
assert!(well(&s));
s.run("ALWAYS_SHOW_MULTIBARS = \"0\" MultiActionBar_UpdateGridVisibility()")
.unwrap();
assert!(well(&s), "the payload still holds the well open");
s.fire_event("ACTIONBAR_HIDEGRID", vec![]);
assert!(!well(&s), "and it closes when the last asker lets go");
// The hide is a counted decrement (ActionButton.lua:251-259); the option applies it only on a
// click (UIOptionsFrame.xml:683), and its VARIABLES_LOADED arm only shows
// (UIOptionsFrame.lua:218-220). The load arm, off then on:
s.run("ALWAYS_SHOW_MULTIBARS = \"0\"").unwrap();
s.fire_event("VARIABLES_LOADED", vec![]);
s.fire_event("ACTIONBAR_SHOWGRID", vec![]);
assert!(well(&s));
s.fire_event("ACTIONBAR_HIDEGRID", vec![]);
assert!(!well(&s), "a load with the option off left the count alone");
s.run("ALWAYS_SHOW_MULTIBARS = \"1\"").unwrap();
s.fire_event("VARIABLES_LOADED", vec![]);
assert!(well(&s), "a load with the option on opens the wells");
s.run("ALWAYS_SHOW_MULTIBARS = \"0\" MultiActionBar_UpdateGridVisibility()")
.unwrap();
assert!(!well(&s));
// …and the click arm against a held payload: one apply closes the well the payload opened, and
// the payload's own HIDEGRID would take the count to -1, as in the reference.
s.fire_event("ACTIONBAR_SHOWGRID", vec![]);
assert!(well(&s));
s.run("MultiActionBar_UpdateGridVisibility()").unwrap();
assert!(
!well(&s),
"the option's hide counts against the payload's open"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A held payload ghosts the wells it opens: `ActionButton_ShowGrid` sets the ring's alpha to 0.5
/// (ActionButton.lua:246), and `UI-Quickslot` is a filled plate (black at 0.6), not a hollow ring.
/// `SetNormalTexture` keeps the region's colour (`0x778f10`) and `ActionButton_HideGrid` sets none,
/// so a ghosted ring stays dim after the payload goes.
#[test]
fn a_held_payload_ghosts_the_empty_wells_it_opens() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s, &[]);
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Ability_SteelMelee".into()),
kind: 0x00,
action: 100,
count: 0,
consumable: false,
}),
);
// Raise after PLAYER_ENTERING_WORLD: its arm re-reads the server's toggle byte and would lower
// the bar again (UIParent.lua:362-365).
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
show_bars(&s, &[1]);
s.resolve();
/// The drawn alpha of every ring quad wearing `path`; no colour is untinted white.
fn ring_alphas(s: &UiScript, path: &str) -> Vec<f32> {
s.extract()
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture {
path: Some(p),
color,
..
} if p == path => Some(color.map_or(1.0, |c| c[3])),
_ => None,
})
.collect()
}
let quickslot = "Interface\\Buttons\\UI-Quickslot";
let quickslot2 = "Interface\\Buttons\\UI-Quickslot2";
assert_eq!(ring_alphas(&s, quickslot), Vec::<f32>::new());
let resting = ring_alphas(&s, quickslot2);
assert_eq!(
resting.len(),
1,
"only the occupied main slot draws a ring: every empty well, main bar included, is \
hidden while nothing is held (ActionButton.lua:69-70)"
);
assert!(
resting.iter().all(|a| *a == 1.0),
"a resting ring is opaque: {resting:?}"
);
s.fire_event("ACTIONBAR_SHOWGRID", vec![]);
s.resolve();
let ghosts = ring_alphas(&s, quickslot);
assert_eq!(
ghosts.len(),
23,
"11 empty main wells swap art + MultiBarBottomLeft's 12 appear"
);
assert!(
ghosts.iter().all(|a| *a == 0.5),
"every grid ring is the ref's half-alpha ghost, not an opaque plate: {ghosts:?}"
);
// …and the occupied ring with it: `ActionButton_ShowGrid` dims every button, occupied or not.
let occupied = ring_alphas(&s, quickslot2);
assert_eq!(occupied, vec![0.5], "the occupied ring ghosts too");
// Filling a ghosted well runs `ActionButton_UpdateUsable` (ActionButton.lua:269-283), whose
// three-argument `SetVertexColor(1, 1, 1)` sets alpha 1.0 in this engine; the reference keeps
// the region's alpha (`0x79abd0`), which would leave this ring dim.
s.set_action(
2,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Ability_Rogue_Ambush".into()),
kind: 0x00,
action: 101,
count: 0,
consumable: false,
}),
);
s.fire_event("ACTIONBAR_SLOT_CHANGED", vec![ScriptValue::Number(2.0)]);
s.resolve();
assert_eq!(
ring_alphas(&s, quickslot2),
vec![0.5, 1.0],
"the well that got an action is opaque again (its Update ran UpdateUsable); the other \
occupied ring keeps its ghost, payload still held"
);
s.fire_event("ACTIONBAR_HIDEGRID", vec![]);
s.resolve();
assert_eq!(ring_alphas(&s, quickslot), Vec::<f32>::new());
let after = ring_alphas(&s, quickslot2);
assert_eq!(
after,
vec![0.5, 1.0],
"HideGrid touches no colour: a ring the grid ghosted stays dim past the payload — the \
reference's own rule (ours used to clear it here)"
);
// The ghosted ring's next `ActionButton_UpdateUsable` is not asserted: this engine resets its
// alpha to 1.0, the reference keeps 0.5 (`0x79abd0`).
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `SetActionBarToggles` posts the whole byte, bits `0x01..0x08` for bars 1-4, one packet per call
/// (`0x4e76e0`); only the server's descriptor push moves the getter, which the
/// PLAYER_ENTERING_WORLD arm reads once (UIParent.lua:362-365).
#[test]
fn a_bar_toggle_sends_the_byte_its_globals_pack_to() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s, &[]);
let _ = s.take_action_bar_toggle_sends();
// The Options row's sequence: assign, update the bars, send.
s.run("SHOW_MULTI_ACTIONBAR_1 = 1 MultiActionBar_Update() SetActionBarToggles(SHOW_MULTI_ACTIONBAR_1, SHOW_MULTI_ACTIONBAR_2, SHOW_MULTI_ACTIONBAR_3, SHOW_MULTI_ACTIONBAR_4)")
.unwrap();
assert_eq!(s.take_action_bar_toggle_sends(), vec![0x01]);
s.run("SHOW_MULTI_ACTIONBAR_3 = 1 MultiActionBar_Update() SetActionBarToggles(SHOW_MULTI_ACTIONBAR_1, SHOW_MULTI_ACTIONBAR_2, SHOW_MULTI_ACTIONBAR_3, SHOW_MULTI_ACTIONBAR_4)")
.unwrap();
assert_eq!(
s.take_action_bar_toggle_sends(),
vec![0x05],
"the WHOLE byte re-sent, not a delta"
);
s.run("SHOW_MULTI_ACTIONBAR_4 = 1 MultiActionBar_Update() SetActionBarToggles(SHOW_MULTI_ACTIONBAR_1, SHOW_MULTI_ACTIONBAR_2, SHOW_MULTI_ACTIONBAR_3, SHOW_MULTI_ACTIONBAR_4)")
.unwrap();
assert_eq!(s.take_action_bar_toggle_sends(), vec![0x0d]);
s.run("SHOW_MULTI_ACTIONBAR_1 = nil MultiActionBar_Update() SetActionBarToggles(SHOW_MULTI_ACTIONBAR_1, SHOW_MULTI_ACTIONBAR_2, SHOW_MULTI_ACTIONBAR_3, SHOW_MULTI_ACTIONBAR_4)")
.unwrap();
assert_eq!(
s.take_action_bar_toggle_sends(),
vec![0x0c],
"the row turns its \"0\" into nil before the send, and nil is off"
);
// The globals hold 1 or nil, the shape `GetActionBarToggles` returns.
assert_eq!(
s.eval::<String>(
"return type(SHOW_MULTI_ACTIONBAR_3) .. \"/\" .. type(SHOW_MULTI_ACTIONBAR_1)"
)
.unwrap(),
"number/nil"
);
// The way back: push the server's byte and fire PLAYER_ENTERING_WORLD, its only reader.
show_bars(&s, &[]);
let _ = s.take_action_bar_toggle_sends();
s.set_action_bar_toggles(0x0c);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
assert!(
s.eval::<bool>("return MultiBarRight:IsShown() and MultiBarLeft:IsShown()")
.unwrap(),
"the seed brings back exactly the byte that was sent"
);
assert!(!s
.eval::<bool>("return MultiBarBottomLeft:IsShown()")
.unwrap());
assert!(
s.take_action_bar_toggle_sends().is_empty(),
"the seed READS — a login must not post the byte back at the server"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Deviation: the Action Bars row passes `SetActionBarToggles` four arguments, not the fifth
/// `ALWAYS_SHOW_MULTIBARS` that `UIOptionsFrame_Save` adds (UIOptionsFrame.lua:363), because the
/// binding never reads it (`0x4e770e cmp esi,4`) and passing it makes the grid option look
/// server-backed. A fifth argument is invisible from outside, so a Lua spy replaces the binding.
#[test]
fn the_shipped_setter_passes_exactly_four_arguments() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s, &[]);
// Lua 5.0's `arg.n`: `...` as a value is not in the 1.12 client's grammar.
s.run(
r#"
BENILLA_TEST_TOGGLE_ARGC = nil
function SetActionBarToggles(...)
BENILLA_TEST_TOGGLE_ARGC = arg.n
end
"#,
)
.unwrap();
// The row's setter (our OptionsFrame.xml) calls `SetActionBarToggles`.
s.run("BenillaOptionsFrameContainerBodyActionBarsRowMultiBar2Check:Click()")
.unwrap();
assert_eq!(
s.eval::<i64>("return BENILLA_TEST_TOGGLE_ARGC").unwrap(),
4,
"four — never the reference's five, which the binding drops on the floor"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The stance bar's seat is the manage pass's `ShapeshiftBarFrame` row (baseY 0, bottomLeft 45,
/// UIParent.lua:1583), in both bottom-bar states.
#[test]
fn the_stance_bar_sits_where_the_pass_puts_it() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::ShapeshiftFormView;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s, &[]);
s.set_shapeshift_forms(vec![ShapeshiftFormView {
spell_id: 2457,
texture: Some("Interface\\Icons\\Stance_A".into()),
name: "Battle Stance".into(),
active: true,
castable: true,
cooldown: None,
}]);
s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
let seat = |s: &UiScript| {
s.eval::<f64>("local _, _, _, _, y = ShapeshiftBarFrame:GetPoint() return y")
.unwrap()
};
show_bars(&s, &[]);
assert_eq!(
seat(&s),
0.0,
"no bottom bar: the row's baseY, on the main bar"
);
show_bars(&s, &[1]);
assert_eq!(seat(&s), 45.0, "bottom-left up: +45, the ref's raised seat");
show_bars(&s, &[2]);
assert_eq!(
seat(&s),
0.0,
"the RIGHT bottom bar is not under it — the row's flag is bottomLeft, not bottomEither"
);
show_bars(&s, &[]);
assert_eq!(seat(&s), 0.0);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The stance shelf's art follows the manage pass (UIParent.lua:1705-1732): unraised, both end caps
/// show and the rings are 64; raised over the bottom-left bar, all three strips hide and the rings
/// drop to 50.
#[test]
fn the_stance_shelf_follows_the_bottom_left_bar() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::ShapeshiftFormView;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s, &[]);
let form = |id: u32| ShapeshiftFormView {
spell_id: id,
texture: Some(format!("Interface\\Icons\\Stance_{id}")),
name: format!("Form {id}"),
active: false,
castable: true,
cooldown: None,
};
let shown = |s: &UiScript, region: &str| {
s.eval::<bool>(&format!("return {region}:IsShown()"))
.unwrap()
};
let ring = |s: &UiScript| {
s.eval::<f64>("return ShapeshiftButton1NormalTexture:GetWidth()")
.unwrap()
};
// A three-form warrior, no bottom bar: the whole shelf, and the big ring.
s.set_shapeshift_forms(vec![form(2457), form(71), form(2458)]);
s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
show_bars(&s, &[]);
assert!(shown(&s, "ShapeshiftBarLeft"), "the left end cap");
assert!(shown(&s, "ShapeshiftBarRight"), "the right end cap");
assert!(
shown(&s, "ShapeshiftBarMiddle"),
"3 forms: the middle strip"
);
assert_eq!(ring(&s), 64.0, "unraised rings are the ref's 64");
// Two forms: `ShapeshiftBar_Update` hides the middle strip (BonusActionBarFrame.lua:166-167).
s.set_shapeshift_forms(vec![form(2457), form(71)]);
s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
assert!(shown(&s, "ShapeshiftBarLeft"));
assert!(shown(&s, "ShapeshiftBarRight"));
assert!(
!shown(&s, "ShapeshiftBarMiddle"),
"exactly 2 forms: end caps only"
);
assert_eq!(ring(&s), 64.0);
// Raising the bottom-left bar moves the seat and hides the art in one pass.
show_bars(&s, &[1]);
assert_eq!(
s.eval::<f64>("local _, _, _, _, y = ShapeshiftBarFrame:GetPoint() return y")
.unwrap(),
45.0,
"the seat rose"
);
for region in [
"ShapeshiftBarLeft",
"ShapeshiftBarMiddle",
"ShapeshiftBarRight",
] {
assert!(
!shown(&s, region),
"{region} must not draw across the row below"
);
}
assert_eq!(ring(&s), 50.0, "raised rings are the ref's 50");
// A third form learned while raised: `ShapeshiftBar_Update` shows the middle strip regardless
// (BonusActionBarFrame.lua:169-175) and nothing runs the pass on that event, so the strip
// draws over the row until the next pass (UIParent.lua:1706-1717).
s.set_shapeshift_forms(vec![form(2457), form(71), form(2458)]);
s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
assert!(
shown(&s, "ShapeshiftBarMiddle"),
"Update shows the strip; only the pass takes it down"
);
s.run("UIParent_ManageFramePositions()").unwrap();
assert!(!shown(&s, "ShapeshiftBarMiddle"));
assert_eq!(ring(&s), 50.0);
show_bars(&s, &[]);
assert!(shown(&s, "ShapeshiftBarMiddle"));
assert_eq!(ring(&s), 64.0);
assert_eq!(
s.eval::<f64>("local _, _, _, _, y = ShapeshiftBarFrame:GetPoint() return y")
.unwrap(),
0.0
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `ShapeshiftBar_Update` sizes the shelf to the form count (BonusActionBarFrame.lua:161-175).
/// The strips are declared as the three-form chain, and a hidden middle strip still resolves its
/// 38 px rect, so the right cap is re-pointed for one and two forms.
#[test]
fn the_stance_shelf_is_as_long_as_the_form_count() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::ShapeshiftFormView;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s, &[]);
show_bars(&s, &[]); // unraised: the shelf art is the state under test
let form = |id: u32| ShapeshiftFormView {
spell_id: id,
texture: Some(format!("Interface\\Icons\\Stance_{id}")),
name: format!("Form {id}"),
active: false,
castable: true,
cooldown: None,
};
let set_forms = |s: &mut UiScript, n: u32| {
s.set_shapeshift_forms((0..n).map(|i| form(2450 + i)).collect());
s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
s.resolve();
};
// The right cap's left edge relative to the left cap's: the shelf in one number.
let cap = |s: &UiScript| {
s.eval::<f64>("return ShapeshiftBarRight:GetLeft() - ShapeshiftBarLeft:GetLeft()")
.unwrap()
};
// Button 1 sits at +11 and is 30 wide, so its own span is 11..41 in the same space.
const BUTTON1_RIGHT: f64 = 41.0;
// One form (a rogue): the cap sits 12 px into the left cap (BonusActionBarFrame.lua:165).
set_forms(&mut s, 1);
assert_eq!(
cap(&s),
12.0,
"one form: the cap sits 12px into the left cap"
);
assert!(
cap(&s) + 42.0 > BUTTON1_RIGHT,
"the one-form shelf still covers its button"
);
assert!(
!s.eval::<bool>("return ShapeshiftBarMiddle:IsShown()")
.unwrap(),
"one form: no middle strip"
);
// Two forms: the caps butt together, 45 + 42 = 87 px of shelf (l.168).
set_forms(&mut s, 2);
assert_eq!(cap(&s), 45.0, "two forms: cap on the left cap's RIGHT edge");
// Past two, the middle strip is 38 px per extra form and the cap chains off it (l.170-174).
set_forms(&mut s, 3);
assert_eq!(
s.eval::<f64>("return ShapeshiftBarMiddle:GetWidth()")
.unwrap(),
38.0,
"3 forms: one slot of middle"
);
assert_eq!(cap(&s), 83.0);
set_forms(&mut s, 5);
assert_eq!(
s.eval::<f64>("return ShapeshiftBarMiddle:GetWidth()")
.unwrap(),
114.0,
"5 forms: three slots of middle — the width the ref computes, not the declared 38"
);
assert_eq!(cap(&s), 159.0);
// Button 5 spans 159..189 (11 + 4 * 37); the cap must clear it.
assert!(
cap(&s) >= 11.0 + 4.0 * 37.0,
"the cap clears the last button"
);
set_forms(&mut s, 1);
assert_eq!(cap(&s), 12.0);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// An extra bar's empty well keeps its bound key's label: `ActionButton_Update` touches only the
/// label's colour (ActionButton.lua:172), and `ActionButton_UpdateHotkeys` writes a bound key's
/// text whether or not the slot has an action (l.141-142).
#[test]
fn an_extra_bars_empty_well_keeps_its_bound_hotkey_label() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The real command set, so `MULTIACTIONBAR1BUTTONn` is bindable; it ships unbound.
crate::ui_script::load_stock_bindings(&mut s);
load_action_bar(&s, &[]);
show_bars(&s, &[1]);
s.set_action(
61,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_BL".into()),
kind: 0x00,
action: 200,
count: 0,
consumable: false,
}),
);
// An occupied well and two empty ones, all bound, held open so the empty ones show.
s.run(
r#"SetBinding("Q", "MULTIACTIONBAR1BUTTON1")
SetBinding("E", "MULTIACTIONBAR1BUTTON2")
SetBinding("R", "MULTIACTIONBAR1BUTTON3")
ALWAYS_SHOW_MULTIBARS = "1" MultiActionBar_UpdateGridVisibility()"#,
)
.unwrap();
s.fire_event("UPDATE_BINDINGS", vec![]);
let label = |s: &UiScript, button: &str| {
s.eval::<Option<String>>(&format!("return {button}HotKey:GetText()"))
.unwrap()
.unwrap_or_default()
};
let drawn = |s: &mut UiScript, want: &str| {
s.resolve();
s.extract()
.iter()
.any(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == want))
};
assert_eq!(label(&s, "MultiBarBottomLeftButton1"), "Q");
assert_eq!(label(&s, "MultiBarBottomLeftButton2"), "E");
assert_eq!(label(&s, "MultiBarBottomLeftButton3"), "R");
assert!(drawn(&mut s, "E"), "the empty well's label is on screen");
// Each event repaints the buttons through `ActionButton_Update`.
for (event, what) in [
("PLAYER_ENTERING_WORLD", "a world enter"),
("ACTIONBAR_SHOWGRID", "a payload picked up over the bars"),
("ACTIONBAR_HIDEGRID", "and put down again"),
] {
s.fire_event(event, vec![]);
assert_eq!(label(&s, "MultiBarBottomLeftButton1"), "Q", "{what}");
assert_eq!(
label(&s, "MultiBarBottomLeftButton2"),
"E",
"the empty well keeps its bound label through {what}"
);
assert_eq!(label(&s, "MultiBarBottomLeftButton3"), "R", "{what}");
}
// The world enter re-seeded the toggles from a byte the harness never pushed, lowering bar 1;
// raise it again to see the label drawn.
show_bars(&s, &[1]);
assert!(drawn(&mut s, "E"), "…and it is still on screen");
// An unbound empty well stays blank (ActionButton.lua:129-131).
assert_eq!(label(&s, "MultiBarBottomLeftButton4"), "");
// Unbinding one takes its label away.
s.run(r#"SetBinding("E", nil)"#).unwrap();
s.fire_event("UPDATE_BINDINGS", vec![]);
assert_eq!(label(&s, "MultiBarBottomLeftButton2"), "");
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
assert_eq!(label(&s, "MultiBarBottomLeftButton2"), "");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `ShapeshiftBar_Update`'s `SetTexCoord(0, n-2, 0, 1)` (BonusActionBarFrame.lua:173) tiles the
/// middle strip along u only. Wrapping v too would bleed the texture's opaque last row
/// (`ShapeshiftBarMiddle.blp` row 31) into its transparent top edge as a grey hairline.
#[test]
fn the_middle_strip_tiles_along_its_length_only() {
benilla_formats::wow_data_or_skip!();
use super::extract::tiling_axes;
use crate::ui_pass::UvRect;
use benilla_ui::script::{ShapeshiftFormView, TexCoords};
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s, &[]);
show_bars(&s, &[]); // bars down: the shelf is the state under test
let form = |id: u32| ShapeshiftFormView {
spell_id: id,
texture: Some(format!("Interface\\Icons\\Stance_{id}")),
name: format!("Form {id}"),
active: id == 2457,
castable: true,
cooldown: None,
};
// Every drawn piece whose texture path ends in `piece`, as the wrap it asks the renderer for.
let wraps_of = |s: &UiScript, piece: &str| -> Vec<(bool, bool)> {
s.extract()
.into_iter()
.filter_map(|q| match q.content {
QuadContent::Texture {
path: Some(p),
tex_coords,
..
} if p.ends_with(piece) => Some(tiling_axes(&match tex_coords {
Some(TexCoords::Rect(e)) => UvRect::from_tex_coords(e),
Some(TexCoords::Corners(c)) => UvRect::from_corners(c),
None => UvRect::FULL,
})),
_ => None,
})
.collect()
};
// Four forms (a druid): two slots of middle, wrapped along u only; the end caps, atlas crops of
// `ShapeshiftBarEnds`, never wrap.
s.set_shapeshift_forms(vec![form(2457), form(71), form(768), form(2458)]);
s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
s.resolve();
assert_eq!(
wraps_of(&s, "ShapeshiftBarMiddle"),
vec![(true, false)],
"four forms: the strip tiles along its length and clamps across it"
);
assert_eq!(
wraps_of(&s, "ShapeshiftBarEnds"),
vec![(false, false), (false, false)],
"the end caps are atlas crops and never tile"
);
// Three forms (a warrior): one slot, the whole texture, nothing tiles.
s.set_shapeshift_forms(vec![form(2457), form(71), form(2458)]);
s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
s.resolve();
assert_eq!(wraps_of(&s, "ShapeshiftBarMiddle"), vec![(false, false)]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The reference fires `UPDATE_SHAPESHIFT_FORMS` only when the form list changes (`0x4b28ff`,
/// `0x4b2e43`): its `ShapeshiftBar_Update` re-shows the middle strip with no manage pass
/// (BonusActionBarFrame.lua:170, 177), so a state change sent as one would draw the strip over the
/// raised bar. Driven through [`crate::ui_shapeshift::push_forms`].
#[test]
fn a_forms_state_change_leaves_the_shelf_down_over_the_raised_bar() {
benilla_formats::wow_data_or_skip!();
use crate::ui_shapeshift::{push_forms, FormsEdge, StanceMemory};
use benilla_ui::script::ShapeshiftFormView;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s, &[]);
// A recorder for the two events a push may announce.
s.run(
"STANCE_LOG = {} local f = CreateFrame('Frame') \
f:RegisterEvent('UPDATE_SHAPESHIFT_FORMS') f:RegisterEvent('SPELL_UPDATE_USABLE') \
f:SetScript('OnEvent', function() table.insert(STANCE_LOG, event) end)",
)
.unwrap();
let log = |s: &UiScript| {
s.eval::<String>("local l = table.concat(STANCE_LOG, ',') STANCE_LOG = {} return l")
.unwrap()
};
let shown = |s: &UiScript, region: &str| {
s.eval::<bool>(&format!("return {region}:IsShown()"))
.unwrap()
};
let shelf = [
"ShapeshiftBarLeft",
"ShapeshiftBarMiddle",
"ShapeshiftBarRight",
];
let ring = |s: &UiScript| {
s.eval::<f64>("return ShapeshiftButton1NormalTexture:GetWidth()")
.unwrap()
};
let checked = |s: &UiScript, i: u32| {
s.eval::<bool>(&format!("return ShapeshiftButton{i}:GetChecked() == 1"))
.unwrap()
};
let stance = |id: u32, active: bool, castable: bool, cooldown| ShapeshiftFormView {
spell_id: id,
texture: Some(format!("Interface\\Icons\\Stance_{id}")),
name: format!("Stance {id}"),
active,
castable,
cooldown,
};
let mut memory = StanceMemory::default();
// Login: the list arrives, a list edge, with Battle Stance active.
assert_eq!(
push_forms(
&mut s,
&mut memory,
vec![
stance(2457, true, true, None),
stance(71, false, true, None),
stance(2458, false, true, None),
],
),
FormsEdge::List
);
assert_eq!(log(&s), "UPDATE_SHAPESHIFT_FORMS");
assert!(checked(&s, 1) && !checked(&s, 2));
// The bottom-left bar up: the pass seats the stance bar a row higher and takes the shelf down
// (UIParent.lua:1706-1716).
show_bars(&s, &[1]);
for region in shelf {
assert!(!shown(&s, region), "{region} is down under a raised bar");
}
assert_eq!(ring(&s), 50.0);
// A stance switch: the form byte flips to Defensive and category 47 arms for a second on all
// three, a state move the reference announces through the aura and cooldown events.
let cd = Some((0, 1000, true));
let edge = push_forms(
&mut s,
&mut memory,
vec![
stance(2457, false, true, cd),
stance(71, true, true, cd),
stance(2458, false, true, cd),
],
);
for region in shelf {
assert!(
!shown(&s, region),
"{region}: the shelf re-shown over the raised bar"
);
}
assert_eq!(ring(&s), 50.0);
assert_eq!(edge, FormsEdge::Silent);
assert_eq!(log(&s), "", "a state move fires no list edge");
// …and the checked ring follows the aura event the switch carries.
assert!(
checked(&s, 1) && !checked(&s, 2),
"no repaint before the state event"
);
s.fire_event("PLAYER_AURAS_CHANGED", vec![]);
assert!(
!checked(&s, 1) && checked(&s, 2),
"the aura event repaints the ring"
);
for region in shelf {
assert!(
!shown(&s, region),
"{region}: a state repaint never touches the shelf"
);
}
// The cooldown running out is silent too: the reference's store fires nothing at expiry.
assert_eq!(
push_forms(
&mut s,
&mut memory,
vec![
stance(2457, false, true, None),
stance(71, true, true, None),
stance(2458, false, true, None),
],
),
FormsEdge::Silent
);
assert_eq!(log(&s), "");
assert!(!shown(&s, "ShapeshiftBarMiddle"));
// A castable flip fires `SPELL_UPDATE_USABLE`; the bar greys the icon and leaves the shelf.
assert_eq!(
push_forms(
&mut s,
&mut memory,
vec![
stance(2457, false, false, None),
stance(71, true, true, None),
stance(2458, false, true, None),
],
),
FormsEdge::Usable
);
assert_eq!(log(&s), "SPELL_UPDATE_USABLE");
let grey = s
.eval::<f64>("local r = ShapeshiftButton1Icon:GetVertexColor() return r")
.unwrap();
assert!(
(grey - 0.4).abs() < 1e-6,
"not castable: the 0.4 grey, got {grey}"
);
assert!(!shown(&s, "ShapeshiftBarMiddle"));
// Nothing moved: nothing pushed, nothing fired.
assert_eq!(
push_forms(
&mut s,
&mut memory,
vec![
stance(2457, false, false, None),
stance(71, true, true, None),
stance(2458, false, true, None),
],
),
FormsEdge::Unchanged
);
assert_eq!(log(&s), "");
// A fourth stance learned is a list edge: `ShapeshiftBar_Update` shows the strip over the
// raised bar until the next pass.
assert_eq!(
push_forms(
&mut s,
&mut memory,
vec![
stance(2457, false, false, None),
stance(71, true, true, None),
stance(2458, false, true, None),
stance(768, false, true, None),
],
),
FormsEdge::List
);
assert_eq!(log(&s), "UPDATE_SHAPESHIFT_FORMS");
assert!(
shown(&s, "ShapeshiftBarMiddle"),
"the learn edge: Update shows the strip"
);
s.run("UIParent_ManageFramePositions()").unwrap();
assert!(!shown(&s, "ShapeshiftBarMiddle"));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}