windows take turns, the gossip page waits, the shot bar counts down

- the UIPanel registry is re-derived from the reference's bytes: the readable-item page finally registers, so a note or letter closes when a gossip window opens instead of interleaving with it line for line; the character sheet takes its real pushable-2 seat (an NPC window slides it over instead of replacing it), and the spellbook, quest log, talents and character sheet open for dead players again — a refused open now says "You can't do that when you're dead." instead of doing nothing
- the gossip window waits for its greeting: the first packet carries only the options and a text id, and we used to show the frame with a blank page for the round trip the text needed — the menu now exists only once the text has answered, a mid-flight click is refused, and a revisit is instant off the cache
- a bag slot whose stack count changes on the server now fires `ITEM_LOCK_CHANGED` just ahead of its `BAG_UPDATE` — the one signal 1.12 hunter addons use to detect a spent arrow — so Quiver's auto-shot bar drains per shot instead of pinning at 100%; a swap or create deliberately does not fire it, so rearranging bags can't read as a shot
- the outdoor minimap tiles' gamma byte reaches the screen exactly once — the ordinary decode arm learns the same skip the fast path already had

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-22 18:17:03 +07:00
co-authored by Claude Fable 5
parent 80d8bd48db
commit 312e123fb7
18 changed files with 1204 additions and 204 deletions
@@ -160,6 +160,7 @@ pub(super) fn emit(
circular: false,
desaturated: false,
premultiplied: false,
gamma_texel: false,
alpha_test: None,
clip: text_clip,
rotation: 0.0,
@@ -345,6 +346,7 @@ pub(super) fn emit(
circular: false,
desaturated: false,
premultiplied: false,
gamma_texel: false,
alpha_test: None,
clip: text_clip,
rotation: 0.0,
@@ -0,0 +1,119 @@
//! The fade kit's driver (`UIFrameFade` / `BenillaFadeDriver`, UiPanels.xml) — the park/wake
//! lifecycle that keeps the per-frame fade walk off the tick while nothing is fading. Split from
//! `panel_tests.rs` (1507's round): the slot manager and the fade kit share a source file, not a
//! concern.
use benilla_ui::script::UiScript;
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error — the same
/// per-file loader every `ui_script` test file carries (these tests only ever need
/// `UiPanels.xml`, where the fade kit lives).
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
/// **The idle fade driver parks itself** (decision 1396's class, the audit's driver-hide item):
/// with `FADEFRAMES` empty there is nothing to walk, so the driver's OnUpdate hides its own frame
/// — and the engine dispatches OnUpdate only to visible frames (`tick.rs`), which stops the
/// per-frame walk cold. The probe wraps `UIFrameFadeUpdate` in a counter BEFORE the first tick:
/// ten idle ticks later it has never run, and the driver is hidden.
#[test]
fn an_idle_fade_driver_parks_itself_off_the_tick() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "UiPanels.xml");
s.resolve();
s.run(
"BENILLA_TEST_FADE_TICKS = 0\n\
local real = UIFrameFadeUpdate\n\
function UIFrameFadeUpdate(elapsed)\n\
BENILLA_TEST_FADE_TICKS = BENILLA_TEST_FADE_TICKS + 1\n\
real(elapsed)\n\
end",
)
.unwrap();
for _ in 0..10 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaFadeDriver:IsShown()")
.unwrap(),
"an empty FADEFRAMES parks the driver"
);
assert_eq!(
s.eval::<i64>("return BENILLA_TEST_FADE_TICKS").unwrap(),
0,
"a parked driver never runs the fade walk"
);
}
/// The control for the guard above: the gate must not cost the fades it gates. `UIFrameFade` (the
/// kit's single insertion point — `UIFrameFadeIn`/`Out` both land there) wakes the driver beside
/// its `table.insert`; the fade then ramps on real ticks, completes, and the driver re-parks on
/// the first empty tick after.
#[test]
fn a_started_fade_still_ramps_and_the_driver_reparks_after() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "UiPanels.xml");
s.resolve();
s.run(r#"CreateFrame("Frame", "BenillaFadeProbe")"#)
.unwrap();
for _ in 0..2 {
s.tick(0.016); // settle: the driver parks
s.resolve();
}
assert!(!s
.eval::<bool>("return BenillaFadeDriver:IsShown()")
.unwrap());
s.run("UIFrameFadeIn(BenillaFadeProbe, 1.0)").unwrap();
assert!(
s.eval::<bool>("return BenillaFadeDriver:IsShown()")
.unwrap(),
"starting a fade wakes the driver"
);
assert_eq!(
s.eval::<f64>("return BenillaFadeProbe:GetAlpha()").unwrap(),
0.0,
"the fade armed at its IN startAlpha"
);
s.tick(0.5);
s.resolve();
let mid = s.eval::<f64>("return BenillaFadeProbe:GetAlpha()").unwrap();
assert!(
(mid - 0.5).abs() < 1e-3,
"half the timeToFade in, half the ramp: {mid}"
);
s.tick(0.6); // past timeToFade: the fade completes and leaves the list
s.resolve();
assert_eq!(
s.eval::<f64>("return BenillaFadeProbe:GetAlpha()").unwrap(),
1.0,
"the fade completes at its endAlpha"
);
s.tick(0.016); // the first empty tick re-parks the driver
s.resolve();
assert!(
!s.eval::<bool>("return BenillaFadeDriver:IsShown()")
.unwrap(),
"the driver re-parks once the list is empty"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+3
View File
@@ -698,6 +698,9 @@ mod perf_bar_tests;
#[cfg(test)]
mod panel_tests;
#[cfg(test)]
mod fade_tests;
#[cfg(test)]
mod merchant_tests;
+144 -83
View File
@@ -80,7 +80,7 @@ fn shipped_gossip_frame_drives_end_to_end() {
// The app's feed: a two-option menu (a vendor option + a coded petition option).
s.set_gossip(Some(GossipMenu {
greeting: Some("Greetings, traveler. How may I help you?".into()),
greeting: "Greetings, traveler. How may I help you?".into(),
quests: Vec::new(),
options: vec![
GossipOptionView {
@@ -234,7 +234,7 @@ fn shipped_gossip_frame_renders_quest_rows_above_options() {
load_xml(&s, "GossipFrame.xml");
s.set_gossip(Some(GossipMenu {
greeting: Some("A word, traveler.".into()),
greeting: "A word, traveler.".into(),
quests: vec![
GossipQuestRow {
title: "Report to Goldshire".into(),
@@ -356,7 +356,7 @@ fn shipped_gossip_rows_grow_to_their_wrapped_labels() {
coded: false,
};
s.set_gossip(Some(GossipMenu {
greeting: Some("Make your choice!".into()),
greeting: "Make your choice!".into(),
quests: Vec::new(),
options: vec![
long(
@@ -461,7 +461,7 @@ fn gossip_show_hide_plays_open_and_close_kits() {
);
s.set_gossip(Some(GossipMenu {
greeting: Some("Well met.".into()),
greeting: "Well met.".into(),
quests: Vec::new(),
options: Vec::new(),
}));
@@ -493,7 +493,7 @@ fn shipped_panel_slot_replaces_gossip_with_merchant() {
load_xml(&s, "MerchantFrame.xml");
s.set_gossip(Some(GossipMenu {
greeting: Some("Well met.".into()),
greeting: "Well met.".into(),
quests: Vec::new(),
options: vec![GossipOptionView {
label: "Let me browse your goods.".into(),
@@ -581,7 +581,7 @@ fn displacing_an_npc_window_ends_the_displaced_session() {
// Gossip opens over it → SetLeftFrame hides the merchant → merchant OnHide → CloseMerchant().
s.set_gossip(Some(GossipMenu {
greeting: Some("Well met.".into()),
greeting: "Well met.".into(),
quests: Vec::new(),
options: Vec::new(),
}));
@@ -715,7 +715,7 @@ fn gossip_bank_option_hands_the_left_slot_to_the_bank() {
// The gossip menu is open on the banker (its bank option showing).
s.set_gossip(Some(GossipMenu {
greeting: Some("Welcome to the bank of Ironforge!".into()),
greeting: "Welcome to the bank of Ironforge!".into(),
quests: Vec::new(),
options: vec![GossipOptionView {
label: "I would like to check my deposit box.".into(),
@@ -789,7 +789,7 @@ fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
coded: false,
};
s.set_gossip(Some(GossipMenu {
greeting: Some("Make your choice!".into()),
greeting: "Make your choice!".into(),
quests: Vec::new(),
options: (1..=8).map(long).collect(),
}));
@@ -956,7 +956,7 @@ fn an_addons_own_frame_registered_in_uipanelwindows_takes_the_left_slot() {
// when BOTH are pushable=0 (l.728-731). Getting this wrong for an addon means either losing
// its window or leaving two panels on top of each other.
s.set_gossip(Some(GossipMenu {
greeting: Some("Well met.".into()),
greeting: "Well met.".into(),
quests: Vec::new(),
options: Vec::new(),
}));
@@ -980,98 +980,159 @@ fn an_addons_own_frame_registered_in_uipanelwindows_takes_the_left_slot() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── The fade kit's driver (UIFrameFade / BenillaFadeDriver) ──────────────────────────────────
// ── the registry pinned to the bytes (decision 1507) ──────────────────────────────────────────
/// **The idle fade driver parks itself** (decision 1396's class, the audit's driver-hide item):
/// with `FADEFRAMES` empty there is nothing to walk, so the driver's OnUpdate hides its own frame
/// — and the engine dispatches OnUpdate only to visible frames (`tick.rs`), which stops the
/// per-frame walk cold. The probe wraps `UIFrameFadeUpdate` in a counter BEFORE the first tick:
/// ten idle ticks later it has never run, and the driver is hidden.
/// **The 1507 rows read exactly as the reference wrote them.** The registry is DATA — a different
/// number is a different window order — and 1507 exists because one row drifted unnoticed for
/// months (CharacterFrame carried pushable=0 labelled as "the ref's own row"; the bytes say
/// `pushable = 2, whileDead = 1`, UIParent.lua l.19) while another was simply missing
/// (ItemTextFrame, l.20 — bug B288: the reader opened through ShowUIPanel's unregistered bare-Show
/// branch, so gossip seated itself straight over the open note). A drive-by edit re-introducing
/// either now fails a named test instead of waiting for a player with a Verdant Note.
#[test]
fn an_idle_fade_driver_parks_itself_off_the_tick() {
fn the_1507_registry_rows_match_the_reference_bytes() {
let s = UiScript::new().unwrap();
load_xml(&s, "UiPanels.xml");
for probe in [
// ItemTextFrame — UIParent.lua l.20 (the B288 row).
"UIPanelWindows['ItemTextFrame'].area == 'left'",
"UIPanelWindows['ItemTextFrame'].pushable == 0",
"UIPanelWindows['ItemTextFrame'].whileDead == nil",
// CharacterFrame — UIParent.lua l.19: pushable 2 (slides beside an NPC session), not 0.
"UIPanelWindows['CharacterFrame'].pushable == 2",
"UIPanelWindows['CharacterFrame'].whileDead == 1",
// The whileDead flags the ref authors and 1507 carried (l.21, l.25, Blizzard_TalentUI:71).
"UIPanelWindows['SpellBookFrame'].whileDead == 1",
"UIPanelWindows['QuestLogFrame'].whileDead == 1",
"UIPanelWindows['BenillaTalentFrame'].whileDead == 1",
// UIChildWindows — UIParent.lua l.44-50 verbatim, all four shipped.
"table.getn(UIChildWindows) == 4",
"UIChildWindows[1] == 'OpenMailFrame'",
"UIChildWindows[2] == 'GuildControlPopupFrame'",
"UIChildWindows[3] == 'GuildMemberDetailFrame'",
"UIChildWindows[4] == 'GuildInfoFrame'",
] {
assert!(
s.eval::<bool>(&format!("return {probe}")).unwrap(),
"registry drifted from the bytes: {probe}"
);
}
}
/// **A dead player opens only the windows whose row asked for it** — ShowUIPanel's own guard
/// (UIParent.lua l.663-666: `UnitIsDead("player") and not info.whileDead` refuses the open),
/// live now that `UnitIsDead` is a real unit binding. The refused window must not show AND must
/// not take a slot; a `whileDead = 1` row (the quest log, l.25) opens exactly as alive.
#[test]
fn a_dead_player_opens_only_whiledead_windows() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "UiPanels.xml");
s.resolve();
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
max_health: 100,
health: 0,
dead: true,
..Default::default()
}),
);
// Stand-ins for two shipped rows: GossipFrame (no whileDead) and QuestLogFrame (whileDead=1).
// Bare frames are enough — the guard runs before any seat is chosen.
s.run(
"BENILLA_TEST_FADE_TICKS = 0\n\
local real = UIFrameFadeUpdate\n\
function UIFrameFadeUpdate(elapsed)\n\
BENILLA_TEST_FADE_TICKS = BENILLA_TEST_FADE_TICKS + 1\n\
real(elapsed)\n\
end",
r#"local g = CreateFrame("Frame", "GossipFrame") g:SetSize(50, 50) g:Hide()
local q = CreateFrame("Frame", "QuestLogFrame") q:SetSize(50, 50) q:Hide()
ShowUIPanel(GossipFrame)"#,
)
.unwrap();
for _ in 0..10 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaFadeDriver:IsShown()")
.unwrap(),
"an empty FADEFRAMES parks the driver"
!s.eval::<bool>("return GossipFrame:IsShown()").unwrap(),
"a row without whileDead is refused while dead"
);
assert!(
s.eval::<bool>("return GetLeftFrame() == nil").unwrap(),
"the refused window took no slot"
);
// ...and the refusal is heard: NotWhileDeadError (the binary's 0x48d340 — push 0x7e, wow-re
// cross-checked) queued the catalog row's key for the app to resolve and toast.
assert_eq!(
s.eval::<i64>("return BENILLA_TEST_FADE_TICKS").unwrap(),
0,
"a parked driver never runs the fade walk"
s.take_ui_errors(),
vec!["ERR_PLAYER_DEAD"],
"the dead refusal queued its error key"
);
s.run("ShowUIPanel(QuestLogFrame)").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return QuestLogFrame:IsShown()").unwrap(),
"a whileDead = 1 row opens exactly as alive"
);
assert!(
s.eval::<bool>("return GetLeftFrame():GetName() == 'QuestLogFrame'")
.unwrap(),
"and it seats normally at the left slot"
);
assert!(
s.take_ui_errors().is_empty(),
"an admitted open raises no error"
);
}
/// The control for the guard above: the gate must not cost the fades it gates. `UIFrameFade` (the
/// kit's single insertion point — `UIFrameFadeIn`/`Out` both land there) wakes the driver beside
/// its `table.insert`; the fade then ramps on real ticks, completes, and the driver re-parks on
/// the first empty tick after.
/// **A frame ARRIVING at the center seat puts the child windows away; a frame PUSHED there does
/// not** — SetCenterFrame's `UIChildWindows` hide loop (UIParent.lua l.839-846), carried since
/// 1507, and the ref's exact asymmetry: MovePanelToCenter re-seats by direct assignment after a
/// `SetCenterFrame(nil)`, so the slide never runs the loop. The open letter (OpenMailFrame) is
/// the shipped tenant; a bare stand-in proves the slot math without the mail window's whole
/// dependency chain.
#[test]
fn a_started_fade_still_ramps_and_the_driver_reparks_after() {
fn a_frame_arriving_at_center_puts_the_child_windows_away() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "UiPanels.xml");
s.resolve();
s.run(r#"CreateFrame("Frame", "BenillaFadeProbe")"#)
.unwrap();
for _ in 0..2 {
s.tick(0.016); // settle: the driver parks
s.resolve();
}
assert!(!s
.eval::<bool>("return BenillaFadeDriver:IsShown()")
.unwrap());
s.run("UIFrameFadeIn(BenillaFadeProbe, 1.0)").unwrap();
assert!(
s.eval::<bool>("return BenillaFadeDriver:IsShown()")
.unwrap(),
"starting a fade wakes the driver"
);
assert_eq!(
s.eval::<f64>("return BenillaFadeProbe:GetAlpha()").unwrap(),
0.0,
"the fade armed at its IN startAlpha"
);
s.tick(0.5);
s.resolve();
let mid = s.eval::<f64>("return BenillaFadeProbe:GetAlpha()").unwrap();
assert!(
(mid - 0.5).abs() < 1e-3,
"half the timeToFade in, half the ramp: {mid}"
);
s.tick(0.6); // past timeToFade: the fade completes and leaves the list
s.resolve();
assert_eq!(
s.eval::<f64>("return BenillaFadeProbe:GetAlpha()").unwrap(),
1.0,
"the fade completes at its endAlpha"
);
s.tick(0.016); // the first empty tick re-parks the driver
s.resolve();
assert!(
!s.eval::<bool>("return BenillaFadeDriver:IsShown()")
.unwrap(),
"the driver re-parks once the list is empty"
);
// The letter is open; two shipped-row stand-ins take the seats: MerchantFrame (pushable 0)
// holds left, TradeFrame (pushable 1) then ARRIVES at center (UIParent.lua l.734-741's
// else-arm: the incumbent outranks nobody, the newcomer settles at center).
s.run(
r#"local m = CreateFrame("Frame", "OpenMailFrame") m:SetSize(50, 50)
local a = CreateFrame("Frame", "MerchantFrame") a:SetSize(50, 50) a:Hide()
local b = CreateFrame("Frame", "TradeFrame") b:SetSize(50, 50) b:Hide()
ShowUIPanel(MerchantFrame)
ShowUIPanel(TradeFrame)"#,
)
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return GetCenterFrame():GetName() == 'TradeFrame'")
.unwrap(),
"the pushable=1 newcomer arrived at the center seat"
);
assert!(
!s.eval::<bool>("return OpenMailFrame:IsShown()").unwrap(),
"the arriving center frame hid the open letter (the UIChildWindows loop)"
);
// The asymmetry: seats cleared, letter re-shown, LootFrame (pushable 7) holds left and a
// pushable=0 window shoves it — MovePanelToCenter SLIDES loot across, and the letter stays.
s.run(
r#"HideUIPanel(TradeFrame) HideUIPanel(MerchantFrame)
OpenMailFrame:Show()
local l = CreateFrame("Frame", "LootFrame") l:SetSize(50, 50) l:Hide()
ShowUIPanel(LootFrame)
ShowUIPanel(MerchantFrame)"#,
)
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return GetCenterFrame():GetName() == 'LootFrame'")
.unwrap(),
"loot slid to center (MovePanelToCenter), merchant took left"
);
assert!(
s.eval::<bool>("return OpenMailFrame:IsShown()").unwrap(),
"a PUSHED frame does not run the child-window loop — the ref's exact trigger"
);
}
@@ -270,3 +270,301 @@ fn quiver_survives_a_hunter_session_start_without_raising() {
"Quiver raised at session start: {raised:#?}"
);
}
// ───────────────────────────────────────────────────────────────────────────────────────────────
// The Auto Shot Timer's state machine — why the bar "just stays full".
// ───────────────────────────────────────────────────────────────────────────────────────────────
/// A ranged weapon on the character sheet, so `UnitRangedDamage("player")` answers a real speed.
///
/// 2.8s is a vanilla hunter bow. The addon computes `reloadTime = speed - 0.5`, so this is what
/// makes the reload phase measurable rather than a divide by zero.
fn seat_a_bow(s: &mut UiScript) {
use benilla_ui::script::UnitCombatStats;
s.set_player_combat_stats(Some(UnitCombatStats {
ranged_attack_time_ms: 2800,
ranged_min_damage: 31.0,
ranged_max_damage: 47.0,
damage_percent: 1.0,
..Default::default()
}));
}
/// The event burst a real session start fires, in order.
fn start_session(s: &mut UiScript) {
s.fire_event("ADDON_LOADED", vec![ScriptValue::Str("Quiver".into())]);
for e in ["VARIABLES_LOADED", "PLAYER_LOGIN", "PLAYER_ENTERING_WORLD"] {
s.fire_event(e, Vec::new());
}
}
/// The addon's own state machine, read through the three globals it publishes for macros.
/// Reading these beats measuring the bar's pixels: they ARE what the bar draws from.
fn shot_state(s: &mut UiScript) -> (bool, bool, f64, f64) {
let mid = s.eval::<bool>("return Quiver.PredMidShot()").unwrap();
let (reloading, reload_left) = s
.eval::<(bool, f64)>("return Quiver.GetSecondsRemainingReload()")
.unwrap();
let (_, shoot_left) = s
.eval::<(bool, f64)>("return Quiver.GetSecondsRemainingShoot()")
.unwrap();
(mid, reloading, reload_left, shoot_left)
}
/// **The reported symptom, reproduced.** *"the shot timer doesn't seem to work properly when
/// standing still and shooting, it just stays full."*
///
/// Quiver's ONLY detector for "an auto shot actually fired" is `ITEM_LOCK_CHANGED` — in the real
/// client, spending an arrow toggles the ammo slot's lock, and the addon's own comment says so:
/// *"Inventory event, such as using ammo or drinking a potion. This is how we detect auto shots."*
///
/// benilla fires `ITEM_LOCK_CHANGED` only from bag / cursor / mail / loot paths. Nothing fires it
/// for ammo spent on a ranged attack. So the addon starts its 0.5s aim, saturates it, and waits
/// forever for a shot it is never told about — which is a bar pinned at 100%.
#[test]
fn auto_shot_bar_saturates_when_no_ammo_lock_event_ever_arrives() {
let root = quiver_or_skip!();
let mut s = seat_a_hunter(&root);
seat_a_bow(&mut s);
assert!(load_addon_files(&s, &root, "Quiver").is_empty());
start_session(&mut s);
// The player presses the ranged-attack key, then stands still and shoots for two seconds.
s.fire_event("START_AUTOREPEAT_SPELL", Vec::new());
for _ in 0..20 {
s.tick(0.1);
}
let (mid, reloading, _, shoot_left) = shot_state(&mut s);
assert!(mid, "the addon does believe it is shooting");
assert!(
!reloading,
"THE BUG: two seconds into a 2.8s weapon cycle and the reload phase never began, \
because nothing told the addon a shot went off"
);
assert!(
shoot_left <= 0.0,
"THE SYMPTOM: the 0.5s aim bar saturated {shoot_left:.2}s ago and has nowhere to go — \
this is 'it just stays full'"
);
}
/// **The mechanism, proven.** The same session, plus the one event benilla never sends: the bar
/// immediately behaves. This is what pins the diagnosis on the missing ammo lock rather than on
/// the addon, on `SetWidth`, or on the standing-still check.
#[test]
fn auto_shot_bar_drains_the_moment_an_ammo_lock_event_arrives() {
let root = quiver_or_skip!();
let mut s = seat_a_hunter(&root);
seat_a_bow(&mut s);
assert!(load_addon_files(&s, &root, "Quiver").is_empty());
start_session(&mut s);
s.fire_event("START_AUTOREPEAT_SPELL", Vec::new());
s.tick(0.1);
// The arrow leaves the quiver. This is the event the real client fires and we do not.
s.fire_event("ITEM_LOCK_CHANGED", Vec::new());
s.tick(0.1);
let (_, reloading, reload_left, _) = shot_state(&mut s);
assert!(
reloading,
"one ITEM_LOCK_CHANGED is the whole difference between a dead bar and a live one"
);
// reloadTime = UnitRangedDamage speed (2.8) - the addon's 0.5s aiming constant, less the tick.
assert!(
(2.0..=2.3).contains(&reload_left),
"the reload should be draining from ~2.3s, got {reload_left:.2}"
);
}
// ───────────────────────────────────────────────────────────────────────────────────────────────
// The Aspect Tracker — what it actually draws, and the one aspect it deliberately does not.
// ───────────────────────────────────────────────────────────────────────────────────────────────
/// Seat one active player buff by name and announce it the way the app's aura feed does.
fn seat_a_buff(s: &mut UiScript, spell_id: u32, name: &str, icon: &str) {
use benilla_ui::script::AuraState;
s.set_auras(
"player",
Some(vec![AuraState {
spell_id,
name: Some(name.into()),
icon: Some(icon.into()),
count: 0,
debuff_type: None,
duration: 0.0,
expiration_time: 0.0,
helpful: true,
cancelable: true,
until_cancelled: true,
channeled: false,
}]),
);
s.fire_event("PLAYER_AURAS_CHANGED", vec![]);
}
/// Is any quad drawing this art, visibly?
///
/// **The buff bar is hidden first, and that is load-bearing.** An active aura paints its own icon
/// through `BuffButton*`, so while Aspect of the Hawk is up TWO quads carry
/// `Spell_Nature_RavenForm` — the tracker's, and the player's buff bar. An earlier cut of this
/// helper counted both and reported the tracker as drawing when it had correctly hidden itself.
fn draws(s: &mut UiScript, leaf: &str) -> bool {
use benilla_ui::script::QuadContent;
s.eval::<()>("if BuffFrame then BuffFrame:Hide() end")
.unwrap();
for i in 1..=24 {
let _ = s.eval::<()>(&format!("if BuffButton{i} then BuffButton{i}:Hide() end"));
}
s.tick(0.1);
s.resolve();
s.extract().iter().any(|q| {
q.alpha > 0.0
&& q.rect.is_some()
&& matches!(&q.content,
QuadContent::Texture { path: Some(p), .. }
if p.to_ascii_lowercase().ends_with(&leaf.to_ascii_lowercase()))
})
}
/// **The Aspect Tracker draws.** Cheetah up ⇒ the Cheetah icon is on screen.
///
/// This is the module end to end on our stack: the scanning tooltip
/// (`CreateFrame("GameTooltip", …, "GameTooltipTemplate")` → `SetPlayerBuff` → the *named*
/// `…TextLeft1` font string → a string compare against the localized spell name), then
/// `Texture:SetTexture` and the backdrop frame it lives in. Every one of those is a real
/// dependency of ours, and a break in any of them shows up here as a missing quad.
#[test]
fn aspect_tracker_draws_the_icon_for_an_active_aspect() {
let root = quiver_or_skip!();
let mut s = seat_a_hunter(&root);
assert!(load_addon_files(&s, &root, "Quiver").is_empty());
start_session(&mut s);
seat_a_buff(
&mut s,
5118,
"Aspect of the Cheetah",
"Interface\\Icons\\Ability_Mount_JungleTiger",
);
assert!(
draws(&mut s, "Ability_Mount_JungleTiger"),
"the aspect tracker drew nothing for an active Aspect of the Cheetah — \
errors: {:#?}",
s.errors()
);
}
/// **The Hawk arm is gated on the frame LOCK, and getting that wrong looks exactly like a bug.**
///
/// `chooseIconTexture` tests seven aspects by name (Beast, Cheetah, Fox, Monkey, Viper, Wild,
/// Wolf) and then handles Hawk differently:
///
/// ```lua
/// elseif Api.Spell.PredSpellLearned(Hawk) and not Api.Aura.PredBuffActive(Hawk)
/// or not Quiver_Store.IsLockedFrames
/// ```
///
/// `and` binds tighter than `or`, so this is `(learned and NOT active) or (UNLOCKED)`. Two
/// clauses, and the second is the one that catches you out:
///
/// - the Hawk icon is a **missing-aspect reminder**, not a status light — with Hawk up it is
/// suppressed by the first clause;
/// - but **unlocked frames force it on regardless**, so you can see the frame you are dragging.
///
/// A fresh profile starts UNLOCKED, so the honest default is *the reminder shows even with Hawk
/// up*. This test asserts both halves, because an earlier cut of it asserted only "blank while
/// Hawk is up" and failed — the addon was right and the test was wrong.
#[test]
fn the_hawk_reminder_is_suppressed_only_once_frames_are_locked() {
let root = quiver_or_skip!();
let mut s = seat_a_hunter(&root);
assert!(load_addon_files(&s, &root, "Quiver").is_empty());
start_session(&mut s);
seat_a_buff(
&mut s,
13165,
"Aspect of the Hawk",
"Interface\\Icons\\Spell_Nature_RavenForm",
);
// Unlocked (the fresh-profile default): the reminder shows even though Hawk IS up.
s.eval::<()>("Quiver_Store.IsLockedFrames = false").unwrap();
s.fire_event("PLAYER_AURAS_CHANGED", vec![]);
assert!(
draws(&mut s, "Spell_Nature_RavenForm"),
"unlocked frames force the icon on so it can be dragged — errors: {:#?}",
s.errors()
);
// Locked: the first clause governs, and Hawk being up suppresses its own reminder.
s.eval::<()>("Quiver_Store.IsLockedFrames = true").unwrap();
s.fire_event("PLAYER_AURAS_CHANGED", vec![]);
assert!(
!draws(&mut s, "Spell_Nature_RavenForm"),
"locked + Hawk up must be blank: the reminder has nothing to remind you of"
);
}
/// **B267's second half, end to end through the real feed.** Not a hand-fired event this time:
/// a container snapshot whose arrow stack ticks down by one, pushed through
/// [`crate::ui_items::feed::apply_container_source`] exactly as a server object update does.
///
/// This is the test that would have caught the bug in the first place. The A/B above proves the
/// addon reacts to `ITEM_LOCK_CHANGED`; this proves **we actually send one when an arrow is
/// spent**, which is the half that was missing (decision 1509).
#[test]
fn spending_an_arrow_starts_quivers_reload_through_the_real_item_feed() {
use crate::ui_items::feed::{apply_container_source, FeedMemory};
use benilla_ui::script::{ContainerSlot, ContainerState};
use std::collections::HashMap;
let root = quiver_or_skip!();
let mut s = seat_a_hunter(&root);
seat_a_bow(&mut s);
assert!(load_addon_files(&s, &root, "Quiver").is_empty());
start_session(&mut s);
let quiver = |count: u32| {
Some(HashMap::from([(
0i64,
ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots: HashMap::from([(
1u32,
ContainerSlot {
item_id: 2512, // Rough Arrow
count,
..Default::default()
},
)]),
},
)]))
};
let mut memory = FeedMemory::default();
apply_container_source(&mut s, &mut memory, quiver(200), Vec::new());
s.fire_event("START_AUTOREPEAT_SPELL", Vec::new());
s.tick(0.1);
let (_, reloading_before, _, _) = shot_state(&mut s);
assert!(!reloading_before, "no shot has landed yet");
// The server tells us the stack is one lighter. That is a fired shot, and the only way the
// addon can ever know it.
apply_container_source(&mut s, &mut memory, quiver(199), Vec::new());
s.tick(0.1);
let (_, reloading, reload_left, _) = shot_state(&mut s);
assert!(
reloading,
"spending an arrow must start the reload drain — errors: {:#?}",
s.errors()
);
assert!(
(2.0..=2.3).contains(&reload_left),
"draining from ~2.3s (2.8s bow - 0.5s aim), got {reload_left:.2}"
);
}