Files
benilla-octo/crates/benilla-app/src/ui_script/action_bar_tests.rs
T
SamandClaude Fable 5.1 c3555b4eea the cooldown goes stock, early-Z comes back, and lists reach the end
- a size-less `<Model>` pane's rect is its file's box in layout units: the reference's geometry overrides (`0x76d080`/`0x76d0d0`) answer the bbox extent whenever no size is authored, `bboxExtent · 768·√(a²+1) · layoutScale` FrameXML units (`1280 · extent` at 4:3), written into the pane's layout input when the file's facts land, re-derived on an aspect change, and ended for good by any authored size; `SetPosition` is the same unit, which is how `0x4a7b20` centres the arrow at `½·GetWidth, ½·GetHeight` through the override — so the map arrow's sprite arm and its hand-sized 33.6 px footprint go (its rect is `0.0262 × 0.0263` layout units, 33.5 × 33.7 FrameXML units at 4:3, 41.0 × 41.2 at 16:9), and the stock `ItemAnim` pane plays the item push unadapted (`ReplaceIconTexture` on the type-14 batch, the 1000 ms clamp, the stock `OnAnimFinished`), `ContainerFrameAdapters.xml` losing 0887's Lua card and its hand-measured 42.41 × 124.72 rect — the implicit one at 16:9 — and keeping the keyring fork and the three bag verbs (2015); and the cooldown is the reference's own model: the texture-transform law `uv' = R_q((uv + t − p) ⊙ s) + p`, `p = (½, ½)`, the raw quaternion component-lerped and never normalised, is one function (`uv_transform`) with the shader's UV fold as its twin — rotation and scale tracks bake per file sequence slot beside the translation, the mat-anim table gains an affine row `[cos − 1, sin, sx − 1, sy − 1]` on `anim_slots.z` whose identity is the zero row so every static batch reads exactly what it did, and a UI tile clones the twin material per animated batch and writes both rows every frame off the pane's play head, never the world clock; the stock `Cooldown.xml` runs off the chain on 2007's two handlers, the file's four quadrant quads turned by their own rotation tracks (`0 → −90°` over each quadrant's 250 ms window, clockwise from 12 o'clock) and its alpha tracks revealing the star for the flash; retired: the native `Cooldown` widget and `SetCooldown` (an Era API 1.12 never had — `CreateFrame("Cooldown")` is the reference's unknown-type refusal now), the loader's `Model → Cooldown` substitution, the app's hand-built pie and star, and our `Cooldown.xml`; `assets/ui` is eight files (2019)
- `UpdateMapHighlight` gets its zone-level exits (B360): at zone zoom ours returned the nil tail for every cursor position, so hovering Deadwind Pass from Duskwood showed no label where 1.12 shows "Deadwind Pass"; the reference's `0x4a7fa0` runs two lookups at zone level and answers a NAME only — first the current zone's exploration-gated overlay list (the builder at `0x4a6ad9` gating on the explored byte), each row's HitRect scaled by 1/1002 and 1/668 against the normalized cursor, edges inclusive, the first hit naming its Area1, then the continent grid re-windowed through the displayed zone's own rect, naming the neighbouring zone or city and nothing for the displayed zone itself — and only the name-only tail is reachable from there, fileName nil and the six coords zero, so the stock frame hides the highlight quad, as the director's own 1.12 shot shows; and `SetVerticalScroll` stores the offset as given (B370, B363): the reference's `0x786db0` compares the new offset against the old and writes it to `[+0x328]` verbatim, `0x787100` re-anchors the scroll child off it, and the range at `[+0x320]` is never read on that path — every clamp the reference exhibits is FrameXML's, through the scroll bar's `[min, max]`; ours clamped into `[0, GetVerticalScrollRange()]`, the child's overflow past the FRAME, which is 40 px short of where `FauxScrollFrame_Update` sends the bar on the 220-tall skills list (three rows lost) and 15 px short on the 287-tall who list (one); the setter fires `OnVerticalScroll` only on a change, as the bytes do, `BenillaScroll_Step` bounds its wheel in Lua where the reference's handler does, and the ScrollFrame kind's prose stops calling its offset unpinned (2017)
- overdraw's cheap half, the sky first: since 0588 every sky element wrote `frag_depth = 0.0`, which costs a pipeline its early-Z, so on an outdoor frame every full-screen dome fragment under the ground and the buildings was shaded in full (the gradient's `asin`, `atan2` and eight table lookups at dawn and dusk) and thrown away; one shared `sky_vertex.wgsl` — bevy's mesh vertex verbatim plus `out.position.z = 0.0` — is now the vertex stage of the gradient dome, the cloud dome, the star patches and the celestial discs and glares, and the model lane's `WOW_SKY_DEPTH` branch moves from its fragment to its vertex, so no sky fragment declares `frag_depth` and the rasterizer rejects the covered screen before the shader runs; every sky `specialize` zeroes the rasterizer bias constant the `sky_order` rung had been leaking into `DepthBiasState::constant`, harmless only while the fragment overwrote its depth, and the prose beside the pin follows it; then the retained `static_gx` pass draws BEFORE bevy's opaque pass (`StartMainPass → static_gx → MainOpaquePass`) — buildings and doodads are the frame's best occluders and a terrain fragment its dearest, six samples a pixel — so walls reject the terrain behind them instead of the reverse, the pass taking the first-use clear with it and the dev depth probe ordered after it; measured on 16 capture scenarios: 14 bit-identical, `northshire-dusk` 1 px and `water-night` 11 px, attributed to exact depth ties on static/terrain silhouettes and last-bit interpolation noise; a depth prepass and near-to-far bins are declined until a journal says where the time goes (2016)
- the grass cut goes per pixel because of a quarter-mip: `0x6813d0` sets `D3DSAMP_MIPMAPLODBIAS = +0.25` on stage 0 (`0x6813f4`) once per frame before the detail-doodad pass — capture-confirmed on all 29 doodad batches, and outside the `[0x6b1000, 0x6b3200)` scan 2004 had called complete; it decides the whole look because the `clutter_cut` probe finds 6 of 31 shipped detail atlases carry a BINARY alpha pyramid (mip 0 has DXT3's 16 levels, every level below exactly two, both starting zones among them), so `a × ramp` degenerates to a global on/off and every texel of a leaf crosses at the same depth — Elwynn's survivor coverage is flat at 47.6 % across the whole 52.5–61.1 yd band, then 0.0 %, while the probe's box-filtered control erodes 47.7 → 20.9 over the same band, so the art has the gradient the shipped pyramid threw away, and `clutter_size` puts the median tuft at 0.92 yd tall, 1.03 yd deep, a full yard for the boundary plane to sweep; the escape is bilinear: full alpha needs a 2×2 all-opaque texel neighbourhood, and those run out between mip 2 and 3 (179/56/10/0/0/0 on the dominant Elwynn cell), so the reference's λ ≈ 2.9–3.3 sits past the cliff and the tuft erodes continuously — 2.2 % of a tuft's pixels leave in the last half-yard with the bias, 11.6 % without, 24.2 % one mip sharper; wgpu has no sampler LOD bias, so the clutter lane re-samples base colour at `view.mip_bias + 0.25`, keeping the sampler every other model batch shares at the reference's 0; ruled out on the way: our BLP decoder (all nine uploaded levels byte-identical against the archive's 52 793 BLP2s), mip regeneration, the `has_mipmaps` byte's value 2 (a plain boolean, an authoring marker), anisotropy and geometry; what it does not fix is resolution-dependent, and so is the reference's own look — λ with the bias is 3.22 at the capture's 648p but 2.48 at 1080p and 1.48 at 2160p, so at a modern resolution the reference itself pops leaf by leaf, and scaling the bias with the viewport is one reversible line left for a look call (2021)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:35:31 +07:00

1898 lines
82 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.
use benilla_ui::script::{ActionSlot, QuadContent, ScriptValue, UiScript};
/// The queued action ids alone — `take_action_uses` carries `UseAction`'s self-cast modifier
/// beside the id since 1745, and every assertion in this file is about the id.
fn action_ids(s: &mut UiScript) -> Vec<u32> {
s.take_action_uses().into_iter().map(|u| u.action).collect()
}
/// Load the real `assets/ui/ActionBar.xml` (the shipped default bar) into a bare engine and
/// drive it with a synthetic action snapshot — the slice-1 chain minus Bevy: template
/// expansion over 12 instances, the vanilla bonus-page formula, icon paint on events, empty
/// slots drawing no icon, and a physical click queuing the right UseAction id.
#[test]
fn shipped_action_bar_drives_end_to_end() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Cooldown.xml");
// `load_ui` returns the same `report.frames` the disk reader asserted on, so this
// count is the one that always stood here — moved, not re-derived.
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\UIParent.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\MoneyFrame.lua");
super::test_ui::load_ui(&s, r"Interface\FrameXML\MoneyFrame.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\GameTooltip.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua");
let frames = super::test_ui::load_ui(&s, "Interface\\FrameXML\\MainMenuBar.xml")
+ super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionBarFrame.xml")
+ super::test_ui::load_ui(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
assert_eq!(
frames, 80,
"what the three stock files declare (1938): MainMenuBar.xml's 8 — the bar, the XP StatusBar, \
the overlay frame, the max-level rail, the art frame, the performance bar and its button, \
the exhaustion tick; ActionBarFrame.xml's 14 — 12 ActionButtons and the 2 page arrows; \
BonusActionBarFrame.xml's 24 — the bonus frame with its 12 buttons and the shapeshift frame \
with its 10; plus one $parentCooldown per action, bonus and shapeshift button (12 + 12 + 10). \
Ours built 59 for the same seats: no shapeshift bar in the file (StanceBar.xml's), no \
overlay frame, no performance-bar button"
);
// `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code,
// safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since
// 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too
// or the tick raises on its first event.
// In manifest order: the fonts its check-box labels colour from, the panel templates those
// boxes inherit through, then the pane.
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\OptionsFrameTemplates.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\ReputationFrame.xml");
// A warrior in battle stance: offset 1 ⇒ the bar shows actions 73..84.
s.set_bonus_bar_offset(1);
s.set_action(
73,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Ability_SteelMelee".into()),
kind: 0x00,
action: 100,
count: 0,
consumable: false,
}),
);
s.set_action(
74,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Ability_Rogue_Ambush".into()),
kind: 0x00,
action: 101,
count: 0,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
// The app fires this on the offset's edge (ui_action/feed.rs); the stock bonus frame shows on
// it and slides up over the main bar for BONUSACTIONBAR_SLIDETIME (0.15 s) — one OnUpdate
// paints the start, the next lands it.
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
s.tick(10.0);
s.tick(0.2);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The stance page is painted by BonusActionButton1/2, not by the main bar (1897/1938). The
// 1024-wide bar centers at BOTTOM of the 1024-wide screen ⇒ bar left edge = 0; the bonus
// frame lands at the bar's BOTTOMLEFT + (BONUSACTIONBAR_XPOS 4, BONUSACTIONBAR_YPOS 43), is
// 43 high, and its button 1 sits at its own BOTTOMLEFT + (5, 4), 36×36 ⇒ x[9,45] y[4,40] —
// one pixel right of the main bar's (8, 4). The reference's own geometry, off
// BonusActionBarFrame.xml:54-96; the chain stride is 36 + 6 = 42.
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)
};
let r = icon("Interface\\Icons\\Ability_SteelMelee").expect("bonus button 1 icon");
assert_eq!((r.left, r.bottom, r.right, r.top), (9.0, 4.0, 45.0, 40.0));
let r2 = icon("Interface\\Icons\\Ability_Rogue_Ambush").expect("bonus button 2 icon");
assert_eq!(r2.left, 9.0 + 42.0); // bonus button 2 left = 51
// Only an OCCUPIED button is shown — an empty one hides while showgrid == 0
// (ActionButton.lua:69-70) — so two rings, two icons.
let rings = quads
.iter()
.filter(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("UI-Quickslot2"))
})
.count();
assert_eq!(
rings, 2,
"the two occupied bonus buttons; every empty main and bonus slot is hidden"
);
let icons = quads
.iter()
.filter(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("Icons"))
})
.count();
assert_eq!(icons, 2, "empty slots draw no icon quad");
// A physical click at (26, 22) lands on BonusActionButton1 (9..45 × 4..40, two frame levels
// above the hidden main slot) and queues UseAction(73) — the stance page's id, not 1.
s.mouse_button(26.0, 22.0, "LeftButton", true);
s.mouse_button(26.0, 22.0, "LeftButton", false);
assert_eq!(action_ids(&mut s), vec![73]);
// The keybinding entry (the app's key feed runs `ActionButtonDown/Up(i)` on the two
// key edges — the ref's ACTIONBUTTONn binding, ActionButton.lua:15-45): UP fires UseAction
// directly with no checkCursor (a keybind never places, decision 0216 §7) — but ONLY from
// the PUSHED state a DOWN set, so a stray release with no press is the ref's own no-op.
s.run("ActionButtonUp(2)").unwrap();
assert!(
s.take_action_uses().is_empty(),
"an Up without a Down is a no-op (the PUSHED gate)"
);
let depressed = |s: &UiScript| {
s.extract()
.iter()
.filter(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("UI-Quickslot-Depress"))
})
.count()
};
s.run("ActionButtonDown(2)").unwrap();
assert_eq!(depressed(&s), 1, "key DOWN shows the pushed texture");
assert_eq!(
s.eval::<String>("return BonusActionButton2:GetButtonState()")
.unwrap(),
"PUSHED",
"with the overlay up a key drives the BONUS button (ActionButton.lua:15-22)"
);
s.run("ActionButtonUp(2)").unwrap();
assert_eq!(action_ids(&mut s), vec![74], "key '2' fires action 74");
assert_eq!(depressed(&s), 0, "key UP restores the normal state");
// Stance drops (offset 0): the bar re-pages to actions 1..12 — all empty here, icons clear.
s.set_bonus_bar_offset(0);
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
// The overlay descends carrying the old form's page (`lastBonusBar`) and hides at the end
// of its slide — one OnUpdate more than BONUSACTIONBAR_SLIDETIME (paint, then advance).
s.tick(0.2);
s.tick(0.01);
s.resolve();
let icons_after = s
.extract()
.iter()
.filter(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("Icons"))
})
.count();
assert_eq!(icons_after, 0, "re-page to an empty page clears the icons");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
fn load_action_bar(s: &UiScript) {
super::test_ui::load_ui(s, "Interface\\FrameXML\\Cooldown.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\TextStatusBar.lua");
super::test_ui::load_ui(s, "Interface\\FrameXML\\TextStatusBar.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(s, r"Interface\FrameXML\UIParent.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\GlobalStrings.lua");
super::test_ui::load_ui(s, "Interface\\FrameXML\\MainMenuBar.xml");
super::test_ui::load_ui(s, r"Interface\FrameXML\MoneyFrame.lua");
super::test_ui::load_ui(s, r"Interface\FrameXML\MoneyFrame.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\GameTooltip.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\ActionBarFrame.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
// `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code,
// safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since
// 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too
// or the tick raises on its first event.
// In manifest order: the fonts its check-box labels colour from, the panel templates those
// boxes inherit through, then the pane.
super::test_ui::load_ui(s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(s, r"Interface\FrameXML\UIPanelTemplates.lua");
super::test_ui::load_ui(s, r"Interface\FrameXML\UIPanelTemplates.xml");
super::test_ui::load_ui(s, r"Interface\FrameXML\OptionsFrameTemplates.xml");
super::test_ui::load_ui(s, r"Interface\FrameXML\ReputationFrame.xml");
// The options window: `LOCK_ACTIONBAR` and `ALWAYS_SHOW_MULTIBARS` are declared there, as the
// reference declares them in UIOptionsFrame_Init (1938) — and the manifest loads it before
// the bars.
// The dialog engine — the keybindings page registers its two confirms into its table (1960).
super::test_ui::load_ui(s, r"Interface\FrameXML\BasicControls.xml"); // `TEXT`
super::test_ui::load_ui(s, r"Interface\FrameXML\LocaleProperties.lua"); // `GetText`
super::test_ui::load_ui(s, r"Interface\FrameXML\StaticPopup.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\UIDropDownMenu.xml");
super::test_ui::load_ui(s, "ScrollTemplates.xml");
super::test_ui::load_ui(s, "KeyBindingsPage.xml");
super::test_ui::load_ui(s, "OptionsFrame.xml");
}
/// The state/feedback layer (decision 0137 phase 4) through the REAL shipped XML: a pushed
/// cooldown + `ACTIONBAR_UPDATE_COOLDOWN` arms the button's Cooldown widget, `IsCurrentAction` +
/// `ACTIONBAR_UPDATE_STATE` checks the ring, and `IsUsableAction`'s OOM pair blue-tints the icon.
#[test]
fn state_feedback_drives_cooldown_checked_and_usable_through_the_xml() {
use benilla_ui::script::ActionState;
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\\Spell_Fire_FlameBolt".into()),
kind: 0x00,
action: 133,
count: 0,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.tick(10.0); // a nonzero GetTime epoch
// A running 10 s cooldown with 6 s left + the update event: the widget shows mid-sweep.
s.set_action_state(
1,
Some(ActionState {
usable: true,
cooldown: Some((6_000, 10_000, true)),
..Default::default()
}),
);
s.fire_event("ACTIONBAR_UPDATE_COOLDOWN", vec![]);
// The stock machine (decision 2019): `CooldownFrame_SetTimer` arms sequence 0 and shows the
// pane; the next paint's `OnUpdateModel` scrubs it to `(GetTime() − start) / duration`.
super::test_ui::cooldown_facts(&mut s);
s.tick(0.0);
s.resolve();
let play = super::test_ui::cooldown_play(&s, "ActionButton1Cooldown")
.expect("the button's cooldown pane is showing, sequence 0 armed");
assert_eq!(
play,
(0, 400),
"6 s of 10 s left ⇒ the sweep sits at 40 %: sequence 0 at 400 ms"
);
// The checked ring on the current action (the transcribed UpdateState).
s.set_action_state(
1,
Some(ActionState {
usable: true,
current: true,
cooldown: Some((6_000, 10_000, true)),
..Default::default()
}),
);
s.fire_event("ACTIONBAR_UPDATE_STATE", vec![]);
assert!(s.eval::<bool>("return ActionButton1:GetChecked()").unwrap());
// The OOM blue tint (the transcribed UpdateUsable): usable=false + notEnoughMana=true.
s.set_action_state(
1,
Some(ActionState {
usable: false,
not_enough_mana: true,
cooldown: Some((6_000, 10_000, true)),
..Default::default()
}),
);
s.fire_event("ACTIONBAR_UPDATE_USABLE", vec![]);
s.resolve();
let icon_color = s.extract().into_iter().find_map(|q| match &q.content {
QuadContent::Texture {
path: Some(p),
color,
..
} if p.contains("Spell_Fire_FlameBolt") => Some(*color),
_ => None,
});
let c = icon_color.expect("icon quad").expect("vertex color set");
assert_eq!(
(c[0], c[1], c[2]),
(0.5, 0.5, 1.0),
"the ref's out-of-power blue-grey"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The pie paints OVER its button's art. The Cooldown child is born at button-level+1, and the
/// draw key's LEVEL term outranks 0884's bucket-wide layer term — so the sweep quad must sort
/// after the icon (BACKGROUND) and after the button's own special textures. A regression here is
/// invisible to every store/feed instrument (the triple still pushes; only the pixels vanish
/// under the icon), which is exactly why the order is pinned end-to-end through the real XML.
#[test]
fn the_cooldown_sweep_paints_over_the_buttons_icon_and_ring() {
use benilla_ui::script::ActionState;
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\\Spell_Fire_FlameBolt".into()),
kind: 0x00,
action: 133,
count: 0,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.tick(10.0);
s.set_action_state(
1,
Some(ActionState {
usable: true,
cooldown: Some((6_000, 10_000, true)),
..Default::default()
}),
);
s.fire_event("ACTIONBAR_UPDATE_COOLDOWN", vec![]);
s.resolve();
let quads = s.extract();
let pos = |pred: &dyn Fn(&QuadContent) -> bool| quads.iter().position(|q| pred(&q.content));
let icon = pos(&|c| {
matches!(c, QuadContent::Texture { path: Some(p), .. } if p.contains("Spell_Fire_FlameBolt"))
})
.expect("the icon texture quad");
let ring = pos(
&|c| matches!(c, QuadContent::Texture { path: Some(p), .. } if p.contains("UI-Quickslot2")),
)
.expect("the NormalTexture ring quad");
let sweep = pos(&|c| {
matches!(c, QuadContent::ModelPane { model: Some(m), .. }
if m.eq_ignore_ascii_case(super::test_ui::COOLDOWN_MODEL))
})
.expect("the sweep pane's quad");
assert!(
icon < sweep,
"the sweep (index {sweep}) must paint over the icon (index {icon})"
);
assert!(
ring < sweep,
"the sweep (index {sweep}) must paint over the button ring (index {ring})"
);
}
/// An action button is a TWO-button button (decision 0908; director's report B200: "I can't right
/// click food on my bar to eat it or right click spells"). The ref's `ActionButton_OnLoad`
/// registers `("LeftButtonUp", "RightButtonUp")` (ActionButton.lua:109) and its OnClick body reads
/// no `arg1` — so either button runs the same fork and right-click USES the action. The widget
/// default is `{"LeftButtonUp"}` (`benilla_ui::script::button::wants_click`), so without the
/// explicit registration the input path silently swallowed every right-click on the bar. Driven
/// through the real shipped XML and the real `mouse_button` path, which is the only place the
/// registration set is consulted.
#[test]
fn a_right_click_on_an_action_button_uses_the_action() {
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\\INV_Misc_Food_11".into()),
kind: 0x80, // an ITEM action — the food/mount case the report is about
action: 4540,
count: 5,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
// Button 1's center (geometry as in the tests above): (26, 22).
s.mouse_button(26.0, 22.0, "RightButton", true);
s.mouse_button(26.0, 22.0, "RightButton", false);
assert_eq!(
action_ids(&mut s),
vec![1],
"right-click queues the same UseAction a left-click does"
);
// The middle button is registered by neither the ref nor us: it stays swallowed, which is what
// proves the assertion above is the REGISTRATION and not the gate having been removed.
s.mouse_button(26.0, 22.0, "MiddleButton", true);
s.mouse_button(26.0, 22.0, "MiddleButton", false);
assert!(
s.take_action_uses().is_empty(),
"an unregistered button still reaches nothing"
);
// Shift+right-click picks up, exactly as shift+left does — the OnClick fork is button-blind.
s.set_modifiers(true, false, false);
s.mouse_button(26.0, 22.0, "RightButton", true);
s.mouse_button(26.0, 22.0, "RightButton", false);
s.set_modifiers(false, false, false);
assert!(s.take_action_uses().is_empty());
assert!(
s.cursor_payload().is_some(),
"shift+right-click carries the action, like shift+left"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Decision 0216 §7 (byte-verified 0218 §4) driven through the REAL shipped XML, not the engine
/// unit tests directly — the modifier-key mirror gating `PickupAction` vs `UseAction`, end to end.
#[test]
fn shift_click_picks_up_not_uses() {
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\\Spell_A".into()),
kind: 0x00,
action: 111,
count: 0,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
// Button 1's center (unchanged geometry from the test above): (26, 22).
s.set_modifiers(true, false, false); // IsShiftKeyDown() true
s.mouse_button(26.0, 22.0, "LeftButton", true);
s.mouse_button(26.0, 22.0, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(
s.take_action_uses().is_empty(),
"shift-click PICKS UP, never queues a use"
);
assert!(s.cursor_payload().is_some(), "action 1 is on the cursor");
assert!(
!s.eval::<bool>("return HasAction(1)").unwrap(),
"slot cleared"
);
assert_eq!(
s.take_action_sets(),
vec![(1, 0)],
"picking up queues the clear-the-slot send"
);
// A PLAIN click while holding routes through checkCursor=1 to a place — closes the loop back
// onto the same (now empty) slot: no shift needed once something is already held.
s.mouse_button(26.0, 22.0, "LeftButton", true);
s.mouse_button(26.0, 22.0, "LeftButton", false);
assert!(s.take_action_uses().is_empty(), "routed to place, not use");
assert!(s.cursor_payload().is_none(), "empty destination clears");
assert!(s.eval::<bool>("return HasAction(1)").unwrap());
assert_eq!(s.take_action_sets(), vec![(1, 111)]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Lock ActionBars** (decision 1136) — the `LOCK_ACTIONBAR` uvar the Options window's Action Bars
/// row and the `TOGGLEACTIONBARLOCK` binding both write, guarding the two drag ends the way the
/// reference does (ActionBarFrame.xml:23-38).
///
/// The teeth are the second half: the reference leaves the shift-click pick-up in `OnClick`
/// UNGUARDED (l.12-22), so a locked bar still yields to the deliberate gesture. Guarding it too
/// would be a "sensible" tightening that silently diverges — and would leave a locked bar with no
/// way to rearrange it at all.
#[test]
fn the_action_bar_lock_stops_the_drag_and_leaves_shift_click_alone() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
assert_eq!(
s.eval::<String>("return LOCK_ACTIONBAR").unwrap(),
"0",
"the bar ships unlocked, the reference's own default"
);
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_A".into()),
kind: 0x00,
action: 111,
count: 0,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
s.run(r#"LOCK_ACTIONBAR = "1""#).unwrap();
s.run("this = ActionButton1 ActionButton1:GetScript(\"OnDragStart\")()")
.unwrap();
assert!(
s.cursor_payload().is_none(),
"a locked bar does not give the action up to a drag"
);
assert!(
s.eval::<bool>("return HasAction(1)").unwrap(),
"slot intact"
);
assert!(
s.take_action_sets().is_empty(),
"and nothing is sent to the server"
);
// Shift-click still picks up — the reference's unguarded fork, and the way out of a locked bar.
s.set_modifiers(true, false, false);
s.mouse_button(26.0, 22.0, "LeftButton", true);
s.mouse_button(26.0, 22.0, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(
s.cursor_payload().is_some(),
"shift-click is not what the lock stops"
);
// The receiving end is guarded too: the held action cannot be dropped back by a drag…
s.run("this = ActionButton1 ActionButton1:GetScript(\"OnReceiveDrag\")()")
.unwrap();
assert!(
s.cursor_payload().is_some(),
"a locked slot refuses the drop"
);
// …and unlocking makes both ends live again.
s.run(r#"LOCK_ACTIONBAR = "0""#).unwrap();
s.run("this = ActionButton1 ActionButton1:GetScript(\"OnReceiveDrag\")()")
.unwrap();
assert!(s.cursor_payload().is_none(), "unlocked, the drop lands");
assert_eq!(
s.take_action_sets(),
vec![(1, 0), (1, 111)],
"the shift-pickup's clear and the unlocked drop's set — and nothing from the two \
refused gestures between them"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A physical drag from button 1 onto OCCUPIED button 2: the byte-verified action-bar hop (0218
/// §4) — the displaced action lands on the cursor, TWO independent `action_sets` entries across
/// the one gesture (0218 §4: "a drag-swap is two sends, never atomic").
#[test]
fn drag_drop_onto_another_button_hops_the_displaced_action() {
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\\Spell_A".into()),
kind: 0x00,
action: 111,
count: 0,
consumable: false,
}),
);
s.set_action(
2,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_B".into()),
kind: 0x00,
action: 222,
count: 0,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
// Button 1 center (26, 22), button 2 center (68, 22) — same geometry as the end-to-end test.
s.mouse_button(26.0, 22.0, "LeftButton", true);
s.mouse_move(40.0, 22.0); // past the 4px drag-start threshold
let consumed = s.mouse_button(68.0, 22.0, "LeftButton", false);
assert!(consumed, "OnReceiveDrag consumed the release");
assert!(
!s.eval::<bool>("return HasAction(1)").unwrap(),
"slot 1 emptied"
);
assert!(s.eval::<bool>("return HasAction(2)").unwrap());
assert_eq!(
s.eval::<String>("return GetActionTexture(2)").unwrap(),
"Interface\\Icons\\Spell_A",
"slot 2 now shows the placed action"
);
let (kind, src) = s
.eval::<(String, i64)>("local k, slot = GetCursorInfo() return k, slot")
.unwrap();
assert_eq!(
(kind.as_str(), src),
("action", 2),
"the displaced action hopped on, sourced from slot 2"
);
// Two independent sends across the one gesture: the pickup's clear, then the place's write.
assert_eq!(s.take_action_sets(), vec![(1, 0), (2, 111)]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The Count fontstring, on the reference's own gate (`ActionButton_UpdateCount`, ref
/// ActionButton.lua:285-292): **`IsConsumableAction`**, never "count > 0". The director's report —
/// a mount on the bar wearing a stack number "1" — is the non-consumable case: a mount holds an
/// on-use spell with zero charges and `InventoryType` 0, so `IsConsumableAction 0x4e5250` answers
/// false and the ref paints nothing at all. It repaints on `ACTIONBAR_SLOT_CHANGED` alongside the
/// icon (the same event the identity resolve fires).
///
/// **The gate rides the SLOT, not the state map** (decision 1301). It used to be pushed through
/// `set_action_state`, and this test set that up *before* the repaint — the opposite of the
/// runtime order, where the identity feed fires `ACTIONBAR_SLOT_CHANGED` a whole system before the
/// state feed writes anything. That inversion is why a passing test sat over a fresh character
/// whose food showed no stack number at all. Every push here is now one `set_action`, which is
/// the only order the runtime can produce.
#[test]
fn count_fontstring_follows_is_consumable_action_not_the_bag_count() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
// Multi-digit counts, deliberately: the static HotKey labels are single characters
// ("1".."9","0","-","="), so a single-digit count could false-positive match an unrelated
// button's hotkey text rather than the Count fontstring actually under test.
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_A".into()),
kind: 0x00, // SPELL
action: 111,
count: 42, // the app never actually sets this for a spell — proves the XML, not the feed
consumable: false,
}),
);
s.set_action(
2,
Some(ActionSlot {
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
kind: 0x80, // ITEM — a stack of food
action: 117,
count: 15,
consumable: true,
}),
);
// The report's own shape: an ITEM action the player holds eleven of, which is NOT consumable
// (a mount). The count is fed all the same; the gate is what must suppress it.
s.set_action(
3,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Ability_Mount_Undeadhorse".into()),
kind: 0x80,
action: 13332,
count: 11,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
// Read the fontstrings by name, not by scanning painted text: a single-character count
// ("0") is indistinguishable from a neighbouring button's static HotKey label by content.
let count_of = |s: &UiScript, n: u32| {
s.eval::<String>(&format!("return ActionButton{n}Count:GetText() or \"\""))
.unwrap()
};
assert_eq!(
count_of(&s, 1),
"",
"SPELL kind never shows a count, however GetActionCount answers"
);
assert_eq!(
count_of(&s, 2),
"15",
"a consumable ITEM shows its bag count"
);
assert_eq!(
count_of(&s, 3),
"",
"B201: a NON-consumable ITEM (a mount) shows no stack number, whatever the count says"
);
// …and it really is painted, not just set (the multi-digit value is unambiguous in the quads).
assert!(
s.extract()
.iter()
.any(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "15")),
"the consumable's count reaches the screen"
);
// Eat the stack down to nothing. A *consumable* keeps its fontstring and reads a literal "0"
// — the ref's `SetText(GetActionCount(...))` is unconditional inside the gate, and 0216 §7's
// `count > 0` blank was ours, not the reference's.
s.set_action(
2,
Some(ActionSlot {
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
kind: 0x80,
action: 117,
count: 0,
consumable: true,
}),
);
s.fire_event("ACTIONBAR_SLOT_CHANGED", vec![ScriptValue::Int(2)]);
s.resolve();
assert_eq!(
count_of(&s, 2),
"0",
"a spent consumable reads 0, it does not go blank"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The macro-name line (ref `ActionButton_Update:236-238`, "Update Macro Text") through the REAL
/// shipped XML: a MACRO slot's button reads its macro's name under the icon, a SPELL slot's reads
/// nothing, and a macro slot that empties loses the name through the same unconditional write.
/// B340 (decision 1636): the template declared `$parentName` and nothing ever set it, so every
/// macro on the bar was nameless.
#[test]
fn macro_name_line_follows_get_action_text_through_the_xml() {
use benilla_ui::script::{MacroState, MacroView};
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
s.set_macros(MacroState {
account: vec![MacroView {
name: "spawn".into(),
texture: Some("Interface\\Icons\\Ability_Racial_Cannibalize".into()),
body: ".spawn 16032".into(),
local_only: false,
}],
character: Vec::new(),
});
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Ability_Racial_Cannibalize".into()),
kind: 0x40, // MACRO
action: 1, // macro index 1
count: 0,
consumable: false,
}),
);
s.set_action(
2,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_A".into()),
kind: 0x00, // SPELL
action: 111,
count: 0,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
// Read the fontstrings by name (the count test's reason: painted text is ambiguous).
let name_of = |s: &UiScript, n: u32| {
s.eval::<String>(&format!("return ActionButton{n}Name:GetText() or \"\""))
.unwrap()
};
assert_eq!(
name_of(&s, 1),
"spawn",
"a MACRO slot wears its macro's name"
);
assert_eq!(name_of(&s, 2), "", "a SPELL slot has no name line");
assert!(
s.extract()
.iter()
.any(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "spawn")),
"the name reaches the screen"
);
// The slot empties: the ref's write is unconditional, so a nil clears the line.
s.set_action(1, None);
s.fire_event("ACTIONBAR_SLOT_CHANGED", vec![ScriptValue::Int(1)]);
s.resolve();
assert_eq!(name_of(&s, 1), "", "an emptied slot loses the name");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The shipped **bag BAR** — `assets/ui/BagFrame.xml` — materialized frame for frame, and then
/// driven end to end: the bar's own backpack toggle opens the backpack window, the fed stack
/// paints in its slot's well, the slot's clicks queue the right intents, and the toggle shuts it
/// again. It lives in this file because the bar seats on `MainMenuBarArtFrame` (ActionBar.xml) —
/// the toggle's anchor arithmetic below is the reason.
///
/// **What decision 1751 changed.** This asserted `report.frames == 259` over a breakdown that
/// counted five bag WINDOWS and a keyring window (37 + 4×42 + 42, plus the bar's own handful).
/// Those windows are gone from this file: the live ones are the reference's `ContainerFrame1..12`,
/// executed off the player's own patch chain, and `BagFrame.xml` is the BAR and nothing else —
/// this client's stand-in for the reference's `MainMenuBarBagButtons.xml`. So the count is
/// recounted from what the file declares today, and the drive reaches the reference's window
/// through the bar's own button rather than showing one of ours by name.
#[test]
fn shipped_bag_frame_drives_end_to_end() {
let _data = benilla_formats::wow_data_or_skip!();
use super::test_ui::{bag_open, bag_slot_button, centre_of, load_ui, BAG_UI};
use benilla_ui::script::{ContainerSlot, ContainerState};
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// [`BAG_UI`] is `benilla.toc`'s own order for everything a bag window needs. Three files join
// it at the positions the manifest gives them:
// * ActionBar.xml straight after Cooldown.xml — the bag bar is anchored INTO
// MainMenuBarArtFrame, so the bar must exist before the bag bar loads or the toggle's
// cross-file `relativeTo` silently falls back to the screen root (which would land in the
// right place here anyway, by the 1024-wide coincidence: screen BOTTOMRIGHT == the
// full-width bar's art-frame BOTTOMRIGHT — so the failure would be invisible);
// * StackSplit.xml and MerchantFrame.xml after the bags — the reference's
// `ContainerFrameItemButton_OnClick` reads `StackSplitFrame` on both arms and
// `MerchantFrame:IsShown()` on the right one, so a slot click raises without them. That
// dependency is the reference's, not ours; MerchantFrame.xml is additionally the home of
// the `BenillaMoney_*` coin rig this chain's windows call.
let mut bar_frames = 0;
for file in BAG_UI {
let frames = load_ui(&s, file);
if *file == "Interface\\FrameXML\\Cooldown.xml" {
load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
load_ui(&s, r"Interface\FrameXML\UIParent.xml");
load_ui(&s, "ScrollTemplates.xml"); // the window tab template, ours (1004/1988)
load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_ui(&s, "Interface\\FrameXML\\MainMenuBar.xml");
load_ui(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
load_ui(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
}
if *file == "Interface\\FrameXML\\MainMenuBarBagButtons.xml" {
bar_frames = frames;
}
}
load_ui(&s, "Interface\\FrameXML\\StackSplitFrame.xml");
load_ui(&s, "Interface\\FrameXML\\MerchantFrame.xml");
assert_eq!(
bar_frames, 16,
"the bag bar is six CheckButtons — MainMenuBarBackpackButton, CharacterBag0..3Slot, \
KeyRingButton — each carrying one $parentItemAnim Model (6 + 6), plus a $parentCooldown \
Model on each of the four slots that inherit PaperDollItemSlotButtonTemplate (+4). The \
deleted BagFrame.xml built 12: it mirrored the six buttons and their push cards but had \
no cooldown on a bag-bar slot at all, which is the reference's own and is what the swap \
to Interface\\FrameXML\\MainMenuBarBagButtons.xml brought with it (1751 window 3)"
);
// The app's feed: a backpack with Tough Jerky ×5 in slot 1.
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
quality: Some(1),
item_id: 117,
link: None,
locked: false,
equip_slots: Vec::new(),
cooldown: None,
readable: false,
creator: None,
flags: 0,
enchants: Vec::new(),
},
);
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots,
}),
);
s.fire_event("BAG_UPDATE", vec![ScriptValue::Int(0)]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Nothing open at load: no jerky on screen.
s.resolve();
let jerky_visible = |quads: &[benilla_ui::script::ExtractedQuad]| {
quads.iter().any(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("INV_Misc_Food_16"))
})
};
assert!(!jerky_visible(&s.extract()), "no bag window at load");
// Click the toggle → the backpack window opens and slot 1 paints the jerky. The toggle seats
// on the bar's art frame BOTTOMRIGHT +(-6,2), 37×37: art frame BOTTOMRIGHT is the bar's
// (full-width, bottom-anchored) corner (1024,0) ⇒ toggle x[981,1018] y[2,39], center
// (999.5,20.5). That arithmetic is THIS file's — the button is `BagFrame.xml`'s and its seat
// is `ActionBar.xml`'s — so the click stays at literal coordinates rather than going through
// `centre_of`: hitting them is part of what is being tested.
s.mouse_button(999.0, 20.0, "LeftButton", true);
s.mouse_button(999.0, 20.0, "LeftButton", false);
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(bag_open(&s, 0), "the bar's toggle opened the backpack");
let quads = s.extract();
let icon = quads
.iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("INV_Misc_Food_16"))
})
.and_then(|q| q.rect)
.expect("slot 1 icon visible after toggle");
// WHERE inside the window that well sits is the reference's arithmetic, not ours
// (`ContainerFrame_GenerateFrame` + `updateContainerFrameAnchors`), so it is asked of the
// button rather than pinned to numbers this tree no longer owns — and asked by GetID, since
// the reference numbers its buttons backwards (`…Item1` is the bag's LAST slot). The property
// is what it always was: the fed stack paints in the well that says it is game slot 1.
let button = bag_slot_button(&s, 0, 1);
let (bx, by) = centre_of(&mut s, &button);
assert!(
icon.left <= bx && bx <= icon.right && icon.bottom <= by && by <= icon.top,
"the jerky icon {icon:?} is not painted on slot 1's button ({button} at {bx},{by})"
);
// The stack count renders as text.
assert!(
quads
.iter()
.any(|q| { matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "5") }),
"stack count shows"
);
// LEFT-click picks the item up onto the cursor — a local drag, no wire use queued until a
// place (ref ContainerFrameItemButton_OnClick's left arm: PickupContainerItem).
s.mouse_button(bx, by, "LeftButton", true);
s.mouse_button(bx, by, "LeftButton", false);
assert!(
s.take_container_uses().is_empty(),
"left-click is a pickup, not a use"
);
assert!(
s.cursor_item().is_some(),
"left-click put the item on the cursor"
);
assert!(
s.take_container_moves().is_empty(),
"a pickup alone queues no move"
);
// RIGHT-click while holding. **This assertion inverted with 1751, and the inversion is the
// point of recording it.** Our own `BenillaBagSlot_OnClick` had a cursor-cancel arm — a
// benilla divergence — so this used to assert that the pickup was cancelled and nothing sent.
// The reference's right arm has no cursor test at all: it falls through to
// `UseContainerItem(bag, slot)` like any other right-click, and the cursor keeps its payload.
// Pinned as what it now IS rather than dropped, so the swap is visible here. Whether the host
// should treat a use-while-holding as a place is a fidelity question for the bag arc, not
// this file's to answer.
s.mouse_button(bx, by, "RightButton", true);
s.mouse_button(bx, by, "RightButton", false);
assert_eq!(
s.take_container_uses(),
vec![(0, 1)],
"the reference's right arm uses the slot even with a full cursor"
);
assert!(
s.cursor_item().is_some(),
"…and leaves the held item where it was"
);
s.run("ClearCursor()").unwrap();
// RIGHT-click with an empty cursor uses slot 1 (UseContainerItem → the app's use/equip fork).
s.mouse_button(bx, by, "RightButton", true);
s.mouse_button(bx, by, "RightButton", false);
assert_eq!(s.take_container_uses(), vec![(0, 1)]);
// Toggle again → shut.
s.mouse_button(999.0, 20.0, "LeftButton", true);
s.mouse_button(999.0, 20.0, "LeftButton", false);
s.resolve();
assert!(!bag_open(&s, 0), "toggle closes the window");
assert!(!jerky_visible(&s.extract()), "…and its slots with it");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The empty-wells regression (director-reported, 2026-07-10): the bar-level event fan ran
/// UpdateUsable on EMPTY buttons, whose `IsUsableAction` answers (nil, nil) — the 0.4 grey
/// `SetVertexColor` landed on the texture-less icon region and drew a solid grey plate over every
/// empty well. The fix is the ref's own HasAction gate on the fan (ActionButton.lua registers the
/// state handlers only while the button has an action). This drives the exact failing sequence —
/// a usable/cooldown event with empties on the bar — and asserts no empty well gains a solid.
#[test]
fn state_events_leave_empty_wells_untinted() {
use benilla_ui::script::ActionState;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
// One occupied slot; 2..12 empty.
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
kind: 0x00,
action: 133,
count: 0,
consumable: false,
}),
);
s.set_action_state(
1,
Some(ActionState {
usable: false,
not_enough_mana: true,
..Default::default()
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
// The failing edges: the fanned usable/cooldown/state events with empties on the bar.
s.fire_event("ACTIONBAR_UPDATE_USABLE", vec![]);
s.fire_event("ACTIONBAR_UPDATE_COOLDOWN", vec![]);
s.fire_event("ACTIONBAR_UPDATE_STATE", vec![]);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
s.resolve();
// No texture-less colored quad anywhere on the button row (the icon regions of empty wells
// must stay path-None + color-None); the occupied icon still carries its OOM blue.
let mut oom_icon = None;
for q in s.extract() {
match &q.content {
QuadContent::Texture {
path: None,
color: Some(c),
..
} if q.rect.is_some_and(|r| r.right - r.left <= 40.0) => {
// Well-sized only: the XP bar's 1024-wide black backdrop is a legitimate solid.
panic!("an empty well gained a solid color quad: {c:?}")
}
QuadContent::Texture {
path: Some(p),
color,
..
} if p.contains("Spell_Fire_FlameBolt") => oom_icon = *color,
_ => {}
}
}
assert_eq!(
oom_icon,
Some([0.5, 0.5, 1.0, 1.0]),
"the occupied button still tints OOM blue"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The white-buttons regression (director-reported 2026-08-07, decision 1108): a slot that was
/// OCCUPIED — UpdateUsable painted its icon's 1/1/1 usable tint — then goes EMPTY (the feed's
/// character-switch diff: `set_action(None)` + `ACTIONBAR_SLOT_CHANGED`) kept the tint on the
/// now-artless icon region and drew it as a solid WHITE square. Two laws close it, both asserted
/// here: the empty arm HIDES the icon (ref ActionButton.lua l.168), and the engine emits nothing
/// for a texture-less region whatever its surviving tint (`0x7706e0` — the draw gate is `+0xcc`,
/// never the colour).
#[test]
fn an_occupied_slot_going_empty_leaves_no_white_plate() {
use benilla_ui::script::ActionState;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
s.set_action(
3,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_Nature_HealingTouch".into()),
kind: 0x00,
action: 5185,
count: 0,
consumable: false,
}),
);
s.set_action_state(
3,
Some(ActionState {
usable: true,
..Default::default()
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
// The usable pass paints the occupied icon's 1/1/1 usable tint — the tint that survives.
s.fire_event("ACTIONBAR_UPDATE_USABLE", vec![]);
s.resolve();
assert!(
s.extract().iter().any(|q| matches!(&q.content,
QuadContent::Texture { path: Some(p), .. } if p.contains("Spell_Nature_HealingTouch"))),
"the occupied slot draws its icon"
);
// The character switch: the new character's table has nothing in slot 3.
s.set_action(3, None);
s.set_action_state(3, None);
s.fire_event("ACTIONBAR_SLOT_CHANGED", vec![ScriptValue::Int(3)]);
s.resolve();
for q in s.extract() {
match &q.content {
QuadContent::Texture { path: Some(p), .. }
if p.contains("Spell_Nature_HealingTouch") =>
{
panic!("the emptied slot still draws the old icon")
}
QuadContent::Texture {
path: None,
color: Some(c),
..
} if q.rect.is_some_and(|r| r.right - r.left <= 40.0) => {
// Well-sized only, as in the sibling test: page-wide solids are legitimate.
panic!("the emptied slot draws its surviving tint as a solid plate: {c:?}")
}
_ => {}
}
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The bonus action bar exists and stays hidden** — the same posture 1219 gave the vertical
/// multibars, and the largest single session-start row in the corpus.
///
/// `ref-BonusActionBarFrame.xml` l.54 instantiates `BonusActionBarFrame` as a real
/// `parent="MainMenuBar"` frame carrying `hidden="true"`, with `BonusActionButton1..12` inside.
/// benilla models the bonus page by re-paging the MAIN bar, so we never show this one — but four
/// addons died at `CT_BarMod\CT_BarModOptions.lua:154`,
/// `getglobal("BonusActionButton" .. i):ClearAllPoints()`, which is pure layout and needs only
/// that the buttons be there.
///
/// The last assertion is the one that keeps it honest: declaring a hidden bar must not change what
/// the visible bar shows.
#[test]
fn the_bonus_action_bar_exists_hidden_and_takes_layout_calls() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
assert!(
s.eval::<bool>("return BonusActionBarFrame ~= nil").unwrap(),
"5 corpus addons index BonusActionBarFrame by name"
);
assert!(
!s.eval::<bool>("return BonusActionBarFrame:IsShown()")
.unwrap(),
"it must ship HIDDEN — benilla re-pages the main bar instead of showing this one"
);
for i in [1, 12] {
assert!(
s.eval::<bool>(&format!("return BonusActionButton{i} ~= nil"))
.unwrap(),
"BonusActionButton{i} must exist"
);
// CT_BarModOptions.lua:154's exact pair, on a bar that has never been shown.
s.run(&format!("BonusActionButton{i}:ClearAllPoints()"))
.unwrap();
s.run(&format!(
"BonusActionButton{i}:SetPoint(\"TOP\", \"ActionButton1\", \"BOTTOM\", 0, -4)"
))
.unwrap();
}
// A hidden bar changes nothing about the visible one. (An EMPTY main-bar button is itself
// hidden under the reference — `ActionButton_Update` hides a slot with no action while
// `showgrid == 0`, ActionButton.lua:69-70 — so slot 1 is occupied first.)
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Ability_SteelMelee".into()),
kind: 0x00,
action: 100,
count: 0,
consumable: false,
}),
);
s.fire_event("ACTIONBAR_SLOT_CHANGED", vec![ScriptValue::Int(1)]);
assert!(
s.eval::<bool>("return ActionButton1:IsShown()").unwrap(),
"the main bar is untouched"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The reference's action-bar constants are real globals, not comments.**
///
/// `ActionButton.lua:1-9` defines them; this file and `MultiBars.xml` cited them in comments and
/// defined none. An addon reading one got nil — `zBar.lua:40` is the shape,
/// `to = to or value.max or NUM_ACTIONBAR_BUTTONS` feeding a numeric `for`, which raises
/// `'for' limit must be a number`. Only the use-probe could find it: nothing else touches anything.
///
/// `CURRENT_ACTIONBAR_PAGE` is asserted ABSENT on purpose. It is the reference's mutable page
/// cursor and benilla does not page the main bar that way; a frozen 1 would be silently wrong
/// forever, where nil fails loudly. Pinned so a later "completeness" pass cannot quietly add it.
#[test]
fn the_reference_action_bar_constants_are_defined() {
let s = UiScript::new().unwrap();
load_action_bar(&s);
for (name, want) in [
("NUM_ACTIONBAR_PAGES", 6),
("NUM_ACTIONBAR_BUTTONS", 12),
("BOTTOMLEFT_ACTIONBAR_PAGE", 6),
("BOTTOMRIGHT_ACTIONBAR_PAGE", 5),
("LEFT_ACTIONBAR_PAGE", 4),
("RIGHT_ACTIONBAR_PAGE", 3),
] {
assert_eq!(
s.eval::<i64>(&format!("return {name}")).unwrap(),
want,
"{name} must be the reference's value"
);
}
// zBar's exact expression, which raised before these existed.
assert_eq!(
s.eval::<i64>("local to = nil or nil or NUM_ACTIONBAR_BUTTONS local n = 0 for i = 1, to do n = n + 1 end return n")
.unwrap(),
12,
"zBar.lua:40's numeric for must have a limit"
);
// `CURRENT_ACTIONBAR_PAGE` was asserted ABSENT here, on the grounds that a frozen 1 lies where
// nil fails loudly. That objection is discharged, not overruled: the bar pages now, so the
// global is live state the paged-id formula reads rather than a frozen number. It is therefore
// asserted as state — present, and MOVING — instead of as one of the constants above.
assert_eq!(s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(), 1);
s.run("ActionBar_PageUp()").unwrap();
assert_eq!(
s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(),
2,
"a frozen 1 would still be a lie — this one has to move"
);
s.run("ActionBar_PageDown()").unwrap();
}
/// **The reference's two-level action-button split, both halves inheritable by name.**
///
/// `ActionButtonTemplate` (ref ActionButtonTemplate.xml:3) is regions only and carries NO scripts;
/// `ActionBarButtonTemplate` (ref ActionBarFrame.xml:4) inherits it and adds the handlers. Ours
/// conflated them under one `Benilla*` name, so an addon inheriting either reference name got a
/// bare frame — no art, no regions, and no error (1203's silent shape).
///
/// `zBar.xml:7` is the corpus shape: it inherits `ActionBarButtonTemplate`, wires its own OnLoad,
/// and then reads `getglobal(button:GetName().."NormalTexture")` — a derived name it only has
/// because the template declares `$parentNormalTexture`. That read is where it died.
///
/// The last assertion is the one that keeps our own bars safe: the alias must still resolve to the
/// full thing, or 48 `inherits=` sites across four files silently lose their handlers.
#[test]
fn both_reference_action_button_templates_are_inheritable() {
let s = UiScript::new().unwrap();
load_action_bar(&s);
// zBar's exact shape: inherit the bar template, supply your own OnLoad.
let doc = benilla_ui::framexml::parse(
r#"<Ui>
<CheckButton name="ZLikeButton" inherits="ActionBarButtonTemplate" parent="UIParent" id="1">
<Anchors><Anchor point="CENTER"/></Anchors>
</CheckButton>
<CheckButton name="BareLikeButton" inherits="ActionButtonTemplate" id="1">
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</CheckButton>
</Ui>"#,
)
.unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"loader errors: {:?}",
report.errors
);
assert!(
!report
.warnings
.iter()
.any(|w| w.contains("unknown template")),
"both names must resolve: {:?}",
report.warnings
);
// The derived name zBar reads, on a button built from each half.
for owner in ["ZLikeButton", "BareLikeButton"] {
assert!(
s.eval::<bool>(&format!("return {owner}NormalTexture ~= nil"))
.unwrap(),
"{owner}NormalTexture — zBar.lua:88's read"
);
assert!(
s.eval::<bool>(&format!(
"return {owner}Icon ~= nil and {owner}Cooldown ~= nil"
))
.unwrap(),
"{owner} must carry the template's regions"
);
}
// The base half carries NO handlers, exactly as the reference's does — an addon inheriting it
// wires its own, and must not silently receive ours.
assert!(
!s.eval::<bool>("return BareLikeButton:GetScript(\"OnClick\") ~= nil")
.unwrap(),
"ActionButtonTemplate is regions only; handlers belong to the bar half"
);
assert!(
s.eval::<bool>("return ZLikeButton:GetScript(\"OnClick\") ~= nil")
.unwrap(),
"ActionBarButtonTemplate carries the handler set"
);
// ...and our own alias still resolves to the full thing.
assert!(
s.eval::<bool>("return ActionButton1NormalTexture ~= nil and ActionButton1:GetScript(\"OnClick\") ~= nil")
.unwrap(),
"BenillaActionButtonTemplate's 48 inherits= sites must be untouched by the split"
);
}
/// Main-bar paging — `CURRENT_ACTIONBAR_PAGE` and the three verbs around it.
///
/// The data was always there (the app owns all 120 action slots); only the selector was missing,
/// and its absence was visible on screen as page arrows with no `OnClick`. `Bartender2.lua:686`
/// died on the nil `ChangeActionBarPage` at session start.
///
/// Two things are asserted that a reconstruction would get wrong. **A bonus page outranks the
/// paged one** — the reference's own `ActionButton_GetPagedID` takes the bonus branch first, so
/// paging must be the `else` arm and not an addition. And **page-up wraps to the literal page 1**
/// while page-down rescans for the last viewable page: that asymmetry is the reference's, and it
/// is observable the moment a page is blanked from `VIEWABLE_ACTION_BAR_PAGES`.
///
/// Since 1500 the blanking is driven here the way the client drives it — by raising the two bottom
/// multibars — rather than read off a declaration. All six pages are viewable at rest now, because
/// every extra bar ships off and nothing has claimed a page yet.
#[test]
fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
// Fonts first: the pane's check-box labels colour from `RED_FONT_COLOR` in their own OnLoad.
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and
// `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own
// coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the
// two template files its check boxes inherit through (1875).
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\ReputationFrame.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
] {
// `test_ui::load_ui`, not a disk read: this list names chain entries now (the reputation
// pane and the templates it inherits through), and `assets/ui` cannot answer for those.
super::test_ui::load_ui(&s, file);
}
assert_eq!(s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(), 1);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
1,
"page 1 button 1 is action 1"
);
assert!(
s.eval::<bool>(
"for i = 1, NUM_ACTIONBAR_PAGES do \
if not VIEWABLE_ACTION_BAR_PAGES[i] then return false end \
end return true"
)
.unwrap(),
"all six pages are viewable at rest — every extra bar ships off (1500)"
);
// The NUMERAL is the only output of paging the player can actually see on the bar itself, and
// it went unwritten for as long as paging existed: the arrows worked, the twelve buttons
// repainted, and the "1" beside them was a declared literal nothing ever touched — so clicking
// them read as doing nothing at all (director, 2026-08-22). It seeds at the page's own value.
let page_text = |s: &UiScript| {
s.eval::<String>("return MainMenuBarPageNumber:GetText()")
.unwrap()
};
assert_eq!(page_text(&s), "1", "the load seed is the current page");
// Raise the two bottom bars the way the client does, which is what takes pages 6 and 5 out of
// the cycle from here on.
s.run("SHOW_MULTI_ACTIONBAR_1 = 1 SHOW_MULTI_ACTIONBAR_2 = 1 MultiActionBar_Update()")
.unwrap();
// Page up walks to 2, so button 1 shows action 13.
s.run("ActionBar_PageUp()").unwrap();
assert_eq!(s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(), 2);
assert_eq!(page_text(&s), "2", "the numeral follows the page up");
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
13
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton12)")
.unwrap(),
24
);
// Down again, and below page 1 it rescans to the LAST viewable page.
s.run("ActionBar_PageDown()").unwrap();
assert_eq!(s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(), 1);
s.run("ActionBar_PageDown()").unwrap();
assert_eq!(
s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(),
4,
"page-down off the bottom rescans for the last VIEWABLE page — 4, not 6, because the \
two raised bottom multibars own pages 5 and 6"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
37
);
assert_eq!(
page_text(&s),
"4",
"the numeral follows a wrap, not just a step"
);
// The pages the bottom bars already display are unreachable from the main bar — which is
// exactly what MultiActionBar_Update did above. Without it, paging up lands on a duplicate of
// the twelve actions already on screen below.
assert!(s
.eval::<bool>(
"return VIEWABLE_ACTION_BAR_PAGES[5] == nil and VIEWABLE_ACTION_BAR_PAGES[6] == nil"
)
.unwrap());
s.run("CURRENT_ACTIONBAR_PAGE = 4 ActionBar_PageUp()")
.unwrap();
assert_eq!(
s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(),
1,
"walking up from the last viewable page skips 5 and 6 and wraps to the LITERAL 1 — the \
reference's own asymmetry with page-down, which rescans instead"
);
// The bonus branch is GUARDED by the page (ActionButton.lua:447: `button.isBonus and
// CURRENT_ACTIONBAR_PAGE == 1`) — and a MAIN-bar button has no isBonus at all, so it never
// takes that branch: the bonus offset is BonusActionBarFrame's own twelve buttons' business
// (1897, adopted with 1938). Main-bar slot 1 on page 3 is action 25 whatever the offset.
s.run("CURRENT_ACTIONBAR_PAGE = 3").unwrap();
s.set_bonus_bar_offset(1);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
25,
"a main-bar button follows its page, never the bonus offset (ActionButton.lua:447-456)"
);
s.run("CURRENT_ACTIONBAR_PAGE = 1").unwrap();
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(BonusActionButton1)")
.unwrap(),
73,
"the bonus frame's own button, on page 1 with offset 1, is 72 + 1"
);
s.run("CURRENT_ACTIONBAR_PAGE = 3").unwrap();
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(BonusActionButton1)")
.unwrap(),
25,
"and off page 1 even the bonus button follows the page: the conjunct 1897 found missing"
);
s.set_bonus_bar_offset(0);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
25,
"and the page comes back when the form drops"
);
}
/// The form/stance/stealth swap transition (decision 1524; ref BonusActionBarFrame.lua:1-98).
/// Entering a form slides the BonusActionBarFrame replica up over 0.15s — the main bar keeps
/// painting the OLD page underneath until the landing, which is also the one moment the sound
/// (igBonusBarOpen) plays. The overlay then STAYS shown while the form holds (the ref-visible
/// state addons read), a direct form→form swap repaints without re-sliding or re-sounding, and
/// dropping the form slides it back down carrying the old form's page (lastBonusBar), silently.
/// Keys route to the overlay's buttons from the FIRST slide frame — the swap's feel half: a key
/// pressed the instant you enter stealth already drives the stealth page.
#[test]
fn bonus_bar_slides_up_with_sound_and_down_without() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Cooldown.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\UIParent.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\MainMenuBar.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\MoneyFrame.lua");
super::test_ui::load_ui(&s, r"Interface\FrameXML\MoneyFrame.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\GameTooltip.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
// `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code,
// safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since
// 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too
// or the tick raises on its first event.
// In manifest order: the fonts its check-box labels colour from, the panel templates those
// boxes inherit through, then the pane.
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\OptionsFrameTemplates.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\ReputationFrame.xml");
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.tick(10.0); // a nonzero clock epoch
let _ = s.take_sounds();
assert!(
!s.eval::<bool>("return BonusActionBarFrame:IsShown()")
.unwrap(),
"no form, no overlay"
);
// ── Enter cat form: offset 0→1, the app's edge (ui_action/feed.rs) fires the event ─────────
s.set_bonus_bar_offset(1);
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BonusActionBarFrame:IsShown()")
.unwrap());
assert_eq!(
s.eval::<String>("return BonusActionBarFrame.mode").unwrap(),
"show"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
1,
"the main bar holds the OLD page under the rising overlay"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(BonusActionButton1)")
.unwrap(),
73,
"the overlay paints the bonus page from the first slide frame"
);
assert!(s.take_sounds().is_empty(), "no sound until the bar lands");
// Keys route to the overlay immediately (ref ActionButton.lua:15-45's IsShown fork).
s.run("ActionButtonDown(1) ActionButtonUp(1)").unwrap();
assert_eq!(
action_ids(&mut s),
vec![73],
"a key pressed mid-slide already drives the bonus page"
);
// …and the same button with the SELF-CAST modifier. `SELFACTIONBUTTON1`-`12` (`ALT-1`…`ALT-=`)
// are `ActionButtonUp(id, 1)` and nothing else, so this is the whole of what those twelve
// bindings do that the plain twelve do not (1745).
s.run("ActionButtonDown(1) ActionButtonUp(1, 1)").unwrap();
assert_eq!(
s.take_action_uses()
.into_iter()
.map(|u| (u.action, u.on_self))
.collect::<Vec<_>>(),
vec![(73, false)],
"the BONUS branch of stock ActionButtonUp passes a literal 0 for onSelf (ActionButton.lua:38) \
— in a stance, ALT-1 drives the bonus page without self-cast; the reference's own rule, \
which 1745's expectation of (73, true) had smoothed over"
);
s.run("BonusActionBarFrame:Hide() ActionButtonDown(1) ActionButtonUp(1, 1)")
.unwrap();
assert_eq!(
s.take_action_uses()
.into_iter()
.map(|u| (u.action, u.on_self))
.collect::<Vec<_>>(),
vec![(1, true)],
"on the main-bar branch onSelf reaches UseAction's third argument (ActionButton.lua:51)"
);
s.run("BonusActionBarFrame:Show()").unwrap();
// The slide, frame by frame. Stock `BonusActionBar_OnUpdate` paints the position the timer
// has REACHED and then advances it (BonusActionBarFrame.lua:37-49), so the first OnUpdate
// after a Show paints the start (top = 0 over the bar's bottom edge at y=0), the second the
// half-risen replica (0.5 * 43), and the one that finds the timer past BONUSACTIONBAR_SLIDETIME
// lands it. Silent until then.
s.tick(0.075);
let top = s
.eval::<f64>("return BonusActionBarFrame:GetTop()")
.unwrap();
assert!(top.abs() < 0.01, "first frame top = {top}, want 0");
assert!(s.take_sounds().is_empty());
s.tick(0.075);
let top = s
.eval::<f64>("return BonusActionBarFrame:GetTop()")
.unwrap();
assert!(
(top - 21.5).abs() < 0.6,
"half-slide top = {top}, want ~21.5"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
1
);
assert!(s.take_sounds().is_empty());
// The landing edge: snap to 43 and THE sound. The main bar does NOT adopt the bonus page:
// a main-bar button has no `isBonus`, so `ActionButton_GetPagedID` keeps it on its page and the
// overlay IS the stance page (ActionButton.lua:447-456; 1897, adopted with 1938).
s.tick(0.01);
assert_eq!(
s.take_sounds(),
vec![benilla_ui::script::SoundRequest::KitName(
"igBonusBarOpen".into()
)]
);
let top = s
.eval::<f64>("return BonusActionBarFrame:GetTop()")
.unwrap();
assert!((top - 43.0).abs() < 0.01, "landed top = {top}, want 43");
assert_eq!(
s.eval::<String>("return BonusActionBarFrame.mode").unwrap(),
"none"
);
assert!(
s.eval::<bool>("return BonusActionBarFrame:IsShown()")
.unwrap(),
"the overlay stays up while the form holds — the ref-visible state"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
1,
"the main bar keeps its page under the landed overlay"
);
s.tick(0.5);
assert!(s.take_sounds().is_empty(), "a landed bar never re-sounds");
// ── A direct form→form swap (stance dance, powershift): repaint, no slide, no sound ────────
s.set_bonus_bar_offset(3);
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
assert_eq!(
s.eval::<String>("return BonusActionBarFrame.mode").unwrap(),
"none"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(BonusActionButton1)")
.unwrap(),
97
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
1,
"the main bar stays on its page through a form swap"
);
s.tick(0.2);
assert!(
s.take_sounds().is_empty(),
"form→form never re-slides or re-sounds"
);
// ── Drop the form: the overlay descends carrying the OLD form's page, silently ─────────────
s.set_bonus_bar_offset(0);
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
assert_eq!(
s.eval::<String>("return BonusActionBarFrame.mode").unwrap(),
"hide"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
1,
"the main bar returns to the page immediately — it is being revealed"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(BonusActionButton1)")
.unwrap(),
97,
"the descending overlay carries the OLD form's page (lastBonusBar)"
);
// A key mid-descent still drives the old form's page — ref GetPagedID's lastBonusBar
// stand-in while the frame is still shown.
s.run("ActionButtonDown(1) ActionButtonUp(1)").unwrap();
assert_eq!(action_ids(&mut s), vec![97]);
// Paint-then-advance again: the frame that finds the timer past the slide time is the one
// that hides (BonusActionBarFrame.lua:51-66), so the descent takes one OnUpdate more than
// the time itself.
s.tick(0.2);
s.tick(0.01);
assert!(
!s.eval::<bool>("return BonusActionBarFrame:IsShown()")
.unwrap(),
"the descent ends hidden"
);
assert!(
s.take_sounds().is_empty(),
"the down-slide is silent — the ref plays only on open"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A form flip mid-slide turns the bar around from where it is (our progress fraction), rather
/// than the ref's timer arithmetic, which mirror-jumps the position — the mechanism, not the
/// quirk (1524).
#[test]
fn bonus_bar_turnaround_continues_from_position() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Cooldown.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\UIParent.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\MainMenuBar.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\MoneyFrame.lua");
super::test_ui::load_ui(&s, r"Interface\FrameXML\MoneyFrame.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\GameTooltip.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
// `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code,
// safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since
// 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too
// or the tick raises on its first event.
// In manifest order: the fonts its check-box labels colour from, the panel templates those
// boxes inherit through, then the pane.
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\OptionsFrameTemplates.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\ReputationFrame.xml");
s.tick(10.0);
let _ = s.take_sounds();
// Up to half height, then the form drops mid-slide.
s.set_bonus_bar_offset(1);
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
s.tick(0.075);
s.set_bonus_bar_offset(0);
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
assert_eq!(
s.eval::<String>("return BonusActionBarFrame.mode").unwrap(),
"hide"
);
// A third of the way back down from the turnaround point: 43 * (0.5 - 0.03/0.15).
// `HideBonusActionBar` keeps a running timer (it resets it only when `completed`,
// BonusActionBarFrame.lua:86-88), so the descent starts where the rise had got to: the rise's
// one OnUpdate advanced the timer to 0.075 (half), and the hide arm paints (1 − 0.075/0.15) · 43
// = 21.5 on its first frame, then 12.9 on the next — paint, then advance.
s.tick(0.03);
let top = s
.eval::<f64>("return BonusActionBarFrame:GetTop()")
.unwrap();
assert!(
(top - 21.5).abs() < 0.6,
"turnaround descends from 21.5, top = {top}"
);
s.tick(0.03);
let top = s
.eval::<f64>("return BonusActionBarFrame:GetTop()")
.unwrap();
assert!(
(top - 12.9).abs() < 0.6,
"next frame top = {top}, want ~12.9"
);
s.tick(0.2);
s.tick(0.01);
assert!(!s
.eval::<bool>("return BonusActionBarFrame:IsShown()")
.unwrap());
assert!(
s.take_sounds().is_empty(),
"an aborted rise never lands, so it never sounds"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The two page arrows must not sit on top of each other.**
///
/// They are 32x32 squares stacked only 20 px apart, so their raw frame rects overlap by 12 px —
/// and the hit-test walks the draw order in reverse, so the later-declared DOWN button owned that
/// band: the bottom third of the visible UP arrow paged the bar the wrong way. The reference's
/// `<HitRectInsets>` (±6 horizontal, ±7 vertical) shrink each square to the 20x18 arrow it
/// actually draws, which separates them — the insets are behaviour here, not decoration.
#[test]
fn the_page_arrows_do_not_steal_each_other_s_clicks() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Cooldown.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\UIParent.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\MainMenuBar.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\MoneyFrame.lua");
super::test_ui::load_ui(&s, r"Interface\FrameXML\MoneyFrame.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\GameTooltip.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
// `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code,
// safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since
// 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too
// or the tick raises on its first event.
// In manifest order: the fonts its check-box labels colour from, the panel templates those
// boxes inherit through, then the pane.
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\OptionsFrameTemplates.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\ReputationFrame.xml");
// The post-login state, which is what a player clicks into. Without it `ExhaustionTick_Update`
// never runs, and the rested marker — DIALOG strata, declared CENTER on the XP strip, which is
// exactly where the arrows are — sits unhidden over both and eats every click. That is the
// reference's own declaration (`hidden="false"`, ref-MainMenuBar.xml l.415), hidden at runtime
// when `GetXPExhaustion()` is nil, so firing the event is the harness's job, not a fix.
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
let centre = |s: &UiScript, name: &str| {
s.eval::<(f64, f64)>(&format!(
"return ({name}:GetLeft() + {name}:GetRight()) / 2, \
({name}:GetBottom() + {name}:GetTop()) / 2"
))
.unwrap()
};
for name in ["ActionBarUpButton", "ActionBarDownButton"] {
let (x, y) = centre(&s, name);
assert_eq!(
s.hit_test_name(x as f32, y as f32).as_deref(),
Some(name),
"{name} must eat the click at its own centre"
);
}
// The contested band, measured rather than assumed: the two 32x32 squares are 20 px apart, so
// raw they share the 12 px above the UP button's bottom edge. 8 px up from that edge is inside
// the up arrow's own drawn art AND inside the down button's raw square — the exact pixel the
// player aims at and the exact pixel the later-declared down button used to win.
//
// With the ref's ±7 vertical insets the two hit rects become disjoint (up keeps its top 18 px,
// down its own), so this point resolves UP, which is what the arrow under the cursor says.
let (x, up_bottom) = s
.eval::<(f64, f64)>(
"return (ActionBarUpButton:GetLeft() + ActionBarUpButton:GetRight()) / 2, \
ActionBarUpButton:GetBottom()",
)
.unwrap();
assert_eq!(
s.hit_test_name(x as f32, up_bottom as f32 + 8.0).as_deref(),
Some("ActionBarUpButton"),
"the lower third of the visible UP arrow must page up, not down"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}