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:
@@ -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());
|
||||
}
|
||||
@@ -698,6 +698,9 @@ mod perf_bar_tests;
|
||||
#[cfg(test)]
|
||||
mod panel_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod fade_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod merchant_tests;
|
||||
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user