From d814179f7c223fcd59c24c3e32ab0b645dfd480b Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:50:51 +0700 Subject: [PATCH] Pet cooldowns flush at each packet's place, as 1.12.1's do (#2421) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In 1.12.1 the pet's cooldown list (list 1) flushes at each packet's own place. `PET_BAR_UPDATE_COOLDOWN` fires from five sites: `0x6e2e8e`, `0x6e3080`, `0x6e8601`, `0x6e95b9` and `0x6e971c`. The pet getters query that list live (`GetPetActionCooldown` at `0x4be03c`, plus the pet book and bar at `0x4b40dd` and `0x4e6cf7`). benilla flushed the pet's list once a frame and read it from the frame's final state. Now the pet's list logs and replays like the player's (#2414), on its own lane in packet order, and the three getters read it live. A failed `CAST_RESULT` fires its GCD-clear pair (`0x6e167d`) before the pop (`0x6e2218`), so the cast slot and the targeting word change only at the pop. The tests cover two pet packets in one frame, a player packet then a pet packet, and the GCD-clear pair seeing the cast still held. Each fails without its piece. Part of #2217 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- .../benilla-app/src/lanes/cast_live_tests.rs | 91 ++++++++++++++ .../src/lanes/cooldown_live_tests.rs | 105 +++++++++++++++- crates/benilla-app/src/lanes/guard_tests.rs | 13 +- crates/benilla-app/src/spell/cooldowns.rs | 50 +++++--- .../benilla-app/src/spell/cooldowns/live.rs | 23 +++- crates/benilla-app/src/spell/cooldowns/log.rs | 50 +++----- .../benilla-app/src/spell/cooldowns_tests.rs | 41 +++++-- crates/benilla-app/src/spell/report.rs | 5 + .../src/ui_action/cooldown_lane.rs | 88 ++++++++++---- crates/benilla-app/src/ui_action/mod.rs | 6 +- crates/benilla-app/src/ui_action/state.rs | 7 ++ crates/benilla-app/src/ui_cast.rs | 2 +- crates/benilla-app/src/ui_cast/views.rs | 57 +++++++-- crates/benilla-app/src/ui_pet/bar.rs | 54 +-------- crates/benilla-app/src/ui_pet/drain.rs | 5 +- crates/benilla-app/src/ui_pet/flush_tests.rs | 112 ++++++++---------- crates/benilla-app/src/ui_pet/mod.rs | 16 +-- crates/benilla-app/src/ui_pet/press_tests.rs | 15 ++- crates/benilla-app/src/ui_pet_book.rs | 4 +- crates/benilla-ui/src/script/action.rs | 17 ++- crates/benilla-ui/src/script/cooldown_read.rs | 15 ++- crates/benilla-ui/src/script/pet.rs | 16 ++- crates/benilla-ui/src/script/spellbook.rs | 24 ++-- .../benilla-ui/src/script/tests/cooldown.rs | 1 + docs/DEVIATIONS.md | 2 +- 25 files changed, 553 insertions(+), 266 deletions(-) diff --git a/crates/benilla-app/src/lanes/cast_live_tests.rs b/crates/benilla-app/src/lanes/cast_live_tests.rs index 11fbcee16..ba674824a 100644 --- a/crates/benilla-app/src/lanes/cast_live_tests.rs +++ b/crates/benilla-app/src/lanes/cast_live_tests.rs @@ -369,3 +369,94 @@ fn a_failed_cast_result_pops_the_slot_at_its_place_in_the_drain() { ] ); } + +/// A failed `SMSG_CAST_RESULT`'s report clears the GCD the send armed and fires its pair +/// (`0x6e1d89` → `0x6e1630`, the pair at `0x6e167d`) before it pops the slot (`0x6e2218`): the +/// pair's `SPELL_UPDATE_COOLDOWN` reads Heal still in flight, and the pop's +/// `CURRENT_SPELL_CAST_CHANGED` and `SPELLCAST_FAILED` after it read it popped. +#[test] +fn a_failed_results_gcd_clear_reads_the_cast_still_in_flight() { + let mut c = client(); + c.app.insert_resource(crate::ui_action::Spells { + catalog: SpellCatalog::from_displays(HashMap::from([( + HEAL, + SpellDisplay { + id: HEAL, + name: "Heal".into(), + icon: Some("Interface\\Icons\\Spell_Holy_Heal".into()), + start_recovery_category: 133, + start_recovery_ms: 1500, + ..Default::default() + }, + )])), + ..crate::ui_action::Spells::empty_for_tests() + }); + c.frame(vec![]); + c.seen(&[]); + cast_heal(&mut c); + c.frame(vec![vec![SessionEvent::CastResult { + spell_id: HEAL, + success: false, + reason: Some(0x0b), + arg: None, + }]]); + assert_eq!( + c.seen(&[ + "SPELL_UPDATE_COOLDOWN", + "CURRENT_SPELL_CAST_CHANGED", + "SPELLCAST_FAILED", + ]), + [ + "SPELL_UPDATE_COOLDOWN:1:nil", + "CURRENT_SPELL_CAST_CHANGED:nil:nil", + "SPELLCAST_FAILED:nil:nil", + ] + ); +} + +/// `CancelCast 0x6e4940` drops the targeting word (`0x6e4955`) where it pops the slot +/// (`0x6e49d6`), at the failed report's pop (`0x6e2218`), after the GCD clear's pair +/// (`0x6e167d`): with the cursor up for Heal, the pair's `SPELL_UPDATE_COOLDOWN` reads the slot and +/// the word both still up. +#[test] +fn a_failed_results_gcd_clear_reads_the_targeting_word_still_up() { + let mut c = client(); + let heal = SpellDisplay { + id: HEAL, + name: "Heal".into(), + start_recovery_category: 133, + start_recovery_ms: 1500, + ..Default::default() + }; + let world = c.app.world_mut(); + world.resource_mut::().start_gcd( + HEAL, + &heal, + std::time::Instant::now(), + &crate::spell::SpellModifiers::default(), + ); + world.resource_mut::().enter( + HEAL, + crate::spell::CastCommit::Spell, + 0x0002, + ); + c.frame(vec![]); + c.seen(&[]); + c.frame(vec![vec![SessionEvent::CastResult { + spell_id: HEAL, + success: false, + reason: Some(0x0b), + arg: None, + }]]); + let seen = c.seen(&["SPELL_UPDATE_COOLDOWN", "CURRENT_SPELL_CAST_CHANGED"]); + assert_eq!( + seen.first().map(String::as_str), + Some("SPELL_UPDATE_COOLDOWN:1:1"), + "{seen:?}" + ); + assert_eq!( + seen.last().map(String::as_str), + Some("CURRENT_SPELL_CAST_CHANGED:nil:nil"), + "{seen:?}" + ); +} diff --git a/crates/benilla-app/src/lanes/cooldown_live_tests.rs b/crates/benilla-app/src/lanes/cooldown_live_tests.rs index c8e1027c7..fc8cd0ec4 100644 --- a/crates/benilla-app/src/lanes/cooldown_live_tests.rs +++ b/crates/benilla-app/src/lanes/cooldown_live_tests.rs @@ -75,6 +75,11 @@ impl Client { /// The two spells' rows and the potion's spell. fn spells() -> crate::ui_action::Spells { + spells_with(&[]) +} + +/// [`spells`] and the rows of `more`. +fn spells_with(more: &[u32]) -> crate::ui_action::Spells { let row = |id: u32| { ( id, @@ -87,11 +92,13 @@ fn spells() -> crate::ui_action::Spells { ) }; crate::ui_action::Spells { - catalog: SpellCatalog::from_displays(HashMap::from([ - row(FROST_NOVA), - row(BLINK), - row(POTION_SPELL), - ])), + catalog: SpellCatalog::from_displays( + [FROST_NOVA, BLINK, POTION_SPELL] + .iter() + .chain(more) + .map(|&id| row(id)) + .collect::>(), + ), ..crate::ui_action::Spells::empty_for_tests() } } @@ -401,3 +408,91 @@ fn a_worn_items_cooldown_reads_live_inside_its_packet() { assert_eq!(c.eval::(live, chunk), 30.0, "live {live}"); } } + +/// The pet's list read inside the call, through the book and the bar: `GetSpellCooldown(id, +/// "pet")` queries list 1 (`0x4b40dd` → `0x4b410f` → `0x6e2ea0`), and `GetActionCooldown` on a +/// slot whose macro is bound to the pet's book likewise (`0x4e6cf7` → `0x4e6d0b`). Two pet +/// `SMSG_SPELL_COOLDOWN`s in one frame: the first packet's `SPELL_UPDATE_COOLDOWN` reads Bite cold +/// through both, the second's reads it running. +#[test] +fn the_pet_book_and_a_pet_bound_slot_read_the_pets_list_inside_their_packet() { + use benilla_protocol::messages::{PetActionEntry, ACTION_KIND_MACRO, PET_ACT_ENABLED}; + const PET: u64 = 0xF130_0000_0000_0042; + const GROWL: u32 = 2649; + const BITE: u32 = 17253; + let mut c = client(); + let world = c.app.world_mut(); + world.insert_resource(spells_with(&[GROWL, BITE])); + let entry = |id: u32| PetActionEntry::from(id | (u32::from(PET_ACT_ENABLED) << 24)); + { + let mut bar = world.resource_mut::(); + bar.spells.pet_guid = PET; + bar.spells.bar[0] = entry(GROWL); + bar.spells.bar[1] = entry(BITE); + bar.spells.spells = vec![entry(GROWL), entry(BITE)]; + } + let pet = world + .spawn(( + crate::net::Guid(PET), + Transform::default(), + crate::net::ObjectStore(ObjectFields::from_pairs(&[ + (OBJECT_TYPE, 0x9), + (22, 100), + (12, ME as u32), + (13, (ME >> 32) as u32), + ])), + )) + .id(); + world + .resource_mut::() + .0 + .insert(PET, pet); + { + let mut slots = world.resource_mut::(); + slots.buttons.insert( + 3, + ActionButton { + slot: 3, + action: 1, + kind: ACTION_KIND_MACRO, + }, + ); + slots.dirty = true; + } + c.frame(vec![]); + c.frame(vec![]); + c.app + .world_mut() + .resource_mut::() + .0 + .insert( + 1, + crate::ui_macro::Bound { + spell: crate::ui_macro::BoundSpell::Spell(BITE), + pet_book: true, + }, + ); + c.frame(vec![]); + c.eval::<()>( + false, + r#"local g = CreateFrame("Frame") + g:RegisterEvent("SPELL_UPDATE_COOLDOWN") + g:SetScript("OnEvent", function() + local _, a = GetSpellCooldown(1, "pet") + local _, b = GetSpellCooldown(2, "pet") + local _, s = GetActionCooldown(4) + table.insert(Seen, "PET:" .. tostring(a) .. ":" .. tostring(b) .. ":" .. tostring(s)) + end)"#, + ); + c.seen(&[]); + let pet_cooldown = |spell: u32, ms: u32| SessionEvent::SpellCooldowns { + caster: PET, + cooldowns: vec![(spell, ms)], + }; + c.frame(vec![ + vec![pet_cooldown(GROWL, 3_000)], + vec![pet_cooldown(BITE, 4_000)], + ]); + // The book sorts Bite ahead of Growl. + assert_eq!(c.seen(&["PET"]), ["PET:0:3:0", "PET:4:3:4"]); +} diff --git a/crates/benilla-app/src/lanes/guard_tests.rs b/crates/benilla-app/src/lanes/guard_tests.rs index f02c4f460..357d0019e 100644 --- a/crates/benilla-app/src/lanes/guard_tests.rs +++ b/crates/benilla-app/src/lanes/guard_tests.rs @@ -400,7 +400,6 @@ const HOST_HELD_UNREAD: &[&str] = &[ "::ui_loot::AutoLootByte", "::ui_loot::LootLatch", "::ui_merchant::MerchantOpen", - "::ui_pet::PetBar", "::ui_petition::GuildRegistrarState", "::ui_petition::PetitionRecords", "::ui_session::latch::InteractLatch", @@ -554,7 +553,6 @@ const READS_FRAME_FINAL: &[(&str, &[&str])] = &[ "spell::mods::SpellModifiers", "target::Selection", "ui_action::PlayerActions"]), ("::ui_pet::bar::feed_pet_bar", &["net::GuidIndex", "net::ObjectStore", "net::SelfGuid", "ui_pet::PetAttackLatch", "ui_pet::PetBar"]), - ("::ui_pet::bar::fire_pet_cooldown_events", &["ui_pet::PetBar"]), // The pet's snapshot reads its store as of the drain's place (`ui_unit::views`); its name // reads as the frame left it. ("::ui_pet::unit::feed_pet_unit", &["names::NameCache", "net::GuidIndex", "net::ObjectStore", @@ -758,7 +756,6 @@ const READ_ONLY_TO_HANDLERS: &[&str] = &[ "::BattlefieldQueue", "::DuelState", "::SummonState", - "::PetBar", "::QuestLog", "::UiClock", "::InteractNpc", @@ -780,7 +777,7 @@ const READ_ONLY_TO_HANDLERS: &[&str] = &[ ]; /// Every lane's queue, by type: what a handler fills there fires in the packets' order. -fn lane_types() -> [TypeId; 16] { +fn lane_types() -> [TypeId; 17] { [ TypeId::of::(), // The views lane: what each packet changed in the stores. @@ -802,8 +799,10 @@ fn lane_types() -> [TypeId; 16] { TypeId::of::(), // The item-text lane: the page-text packets' reads, stamped at their packets. TypeId::of::(), - // The cooldown lane: the player's list, which logs its writes and flushes at their places. + // The cooldown lanes: the player's list and the pet's, which log their writes and flushes + // at their places. TypeId::of::(), + TypeId::of::(), // The cast views lane: the view each packet left, stamped ahead of the packet. TypeId::of::(), ] @@ -811,7 +810,7 @@ fn lane_types() -> [TypeId; 16] { /// [`lane_types`] by the lane that drains each: a queue is stripped only from its own lane's row, /// so another lane's read of the same type shows as the state it is. -fn lane_queue_owners() -> [(&'static str, TypeId); 16] { +fn lane_queue_owners() -> [(&'static str, TypeId); 17] { [ ("bag view", TypeId::of::()), ("unit views", TypeId::of::()), @@ -840,6 +839,7 @@ fn lane_queue_owners() -> [(&'static str, TypeId); 16] { TypeId::of::(), ), ("cooldowns", TypeId::of::()), + ("pet cooldowns", TypeId::of::()), ("cast views", TypeId::of::()), ] } @@ -1006,6 +1006,7 @@ fn the_drain_runs_after_the_feeds_and_before_the_tick() { "list rebuilds", "loot", "party", + "pet cooldowns", "unit views" ] ); diff --git a/crates/benilla-app/src/spell/cooldowns.rs b/crates/benilla-app/src/spell/cooldowns.rs index e162a9a80..d35cbed1e 100644 --- a/crates/benilla-app/src/spell/cooldowns.rs +++ b/crates/benilla-app/src/spell/cooldowns.rs @@ -153,14 +153,16 @@ impl CooldownInfo { /// One cooldown list, the client's `SpellHistory`: the player's is this resource (`0xcecaec`), the /// pet's is [`crate::ui_pet::PetBar::cooldowns`] (`0xcecb04`), which the pet bar, the pet book and -/// the pet's `SMSG_SPELL_GO` leg read and write. The player's list signals at each flush site in -/// its place on the cooldown lane ([`log`]); the pet's, which keeps no log, through its counters, -/// once a frame (`ui_pet::bar::fire_pet_cooldown_events`). +/// the pet's `SMSG_SPELL_GO` leg read and write. Each signals at each flush site in its place on +/// its lane ([`log`]). #[derive(Resource, Default)] pub(crate) struct Cooldowns { records: Vec, - /// The frame's writes and signals, which the drain replays (the player's list alone). + /// The frame's writes and signals, which the drain replays. log: CooldownLog, + /// The pet's list (`0xcecb04`, the list index 1 every site tests): its sites end in the pet's + /// flush `0x4bce90` where the player's call the bags' or nothing. + pet: bool, /// Bumped by [`Self::flush`], `0x4b31b0`. Only counts up, across [`Self::clear_silent`] too, /// so a watcher reads a reset as no mutation. pub(crate) generation: u64, @@ -209,10 +211,10 @@ struct PendingItem { } impl Cooldowns { - /// A list that keeps no log, the pet's: its watcher reads its counters once a frame. - pub(crate) fn unlogged() -> Self { + /// The pet's list. + pub(crate) fn pet() -> Self { Self { - log: CooldownLog::new(false), + pet: true, ..Default::default() } } @@ -223,9 +225,7 @@ impl Cooldowns { write.apply(&mut self.records); } - /// `0x4b31b0`, the cooldown pair: `ACTIONBAR_UPDATE_COOLDOWN` and `SPELL_UPDATE_COOLDOWN`. On - /// the pet's list its watcher adds `PET_BAR_UPDATE_COOLDOWN`, the `0x4bce90` each pet site - /// calls after it. + /// `0x4b31b0`, the cooldown pair: `ACTIONBAR_UPDATE_COOLDOWN` and `SPELL_UPDATE_COOLDOWN`. pub(crate) fn flush(&mut self) { self.generation = self.generation.wrapping_add(1); self.log.signalled(Signal::Pair); @@ -236,12 +236,27 @@ impl Cooldowns { self.log.signalled(Signal::Usable); } + /// `0x4f93d0`, `BAG_UPDATE_COOLDOWN`, on the player's list; on the pet's, its flush + /// `0x4bce90` in its place (`0x6e3080`, `0x6e971c`). fn flush_bags(&mut self) { - self.bag_flushes = self.bag_flushes.wrapping_add(1); - self.log.signalled(Signal::Bags); + if self.pet { + self.log.signalled(Signal::Pet); + } else { + self.bag_flushes = self.bag_flushes.wrapping_add(1); + self.log.signalled(Signal::Bags); + } } - /// `0x4b31b0` then `0x4f93d0`, the pair every item leg calls. + /// `0x4bce90`, `PET_BAR_UPDATE_COOLDOWN`, the tail of a site that calls the bags' for neither + /// list: the pet's alone (`0x6e2e8e`, `0x6e8601`, `0x6e95b9`). + fn flush_pet(&mut self) { + if self.pet { + self.log.signalled(Signal::Pet); + } + } + + /// `0x4b31b0` then `0x4f93d0`, the pair every item leg calls, and the cheat's (`0x6e9712`, + /// then `0x6e9721` or the pet's flush at `0x6e971c`). pub(crate) fn flush_item_leg(&mut self) { self.flush(); self.flush_bags(); @@ -249,7 +264,7 @@ impl Cooldowns { /// `0x6e3050`'s tail after its walk, whether or not a record matched: the pair (`0x6e3071`), /// the usable refresh (`0x6e3076`), then the bags for the player (`0x6e3089`) or the pet's flush - /// for the pet (`0x6e3080`), which reads [`Self::generation`]. + /// for the pet (`0x6e3080`). fn flush_records(&mut self) { self.flush(); self.refresh_usable(); @@ -418,7 +433,10 @@ impl Cooldowns { 0, 0, ); + // The self leg's pair (`0x6e849d`), or the pet leg's, then its flush (`0x6e85fc`, + // `0x6e8601`). self.flush(); + self.flush_pet(); } /// The go's item leg when the item's record names the spell (`0x6e8511`-`0x6e8566`), then @@ -642,6 +660,8 @@ impl Cooldowns { if spell.cooldown_on_event() { self.refresh_usable(); } + // The pet's list's flush last (`0x6e2e8a`-`0x6e2e8e`). + self.flush_pet(); } /// `ClearGlobalCooldown 0x6e1630`, on a failed cast: every record of the spell matches @@ -736,6 +756,8 @@ impl Cooldowns { } } self.flush(); + // The pet's list's flush (`0x6e95b5`-`0x6e95b9`). + self.flush_pet(); } /// One `SMSG_SPELL_COOLDOWN` entry, started at the packet: diff --git a/crates/benilla-app/src/spell/cooldowns/live.rs b/crates/benilla-app/src/spell/cooldowns/live.rs index fed7247a4..083cc5962 100644 --- a/crates/benilla-app/src/spell/cooldowns/live.rs +++ b/crates/benilla-app/src/spell/cooldowns/live.rs @@ -1,6 +1,6 @@ //! The cooldown getters' query at the call, the call host's half of -//! [`benilla_ui::script::CooldownQuery`]: the player's list as of the drain's place (the cooldown -//! lane replays it packet by packet, [`super::log`]), each item found as of there too +//! [`benilla_ui::script::CooldownQuery`]: the player's list and the pet's as of the drain's place +//! (their cooldown lanes replay them packet by packet, [`super::log`]), each item found as of there too //! ([`crate::ui_unit::views::ViewStores`]), each answer derived as the feeds derive their //! snapshots. @@ -16,6 +16,7 @@ use crate::query_cache::Ask; #[derive(SystemParam)] pub(crate) struct CooldownLive<'w, 's> { cooldowns: Option>, + pet: Option>, spells: Option>, items: Option>, /// The frame's clock pair every pushed start is mapped through, as the action feed last read @@ -29,9 +30,24 @@ impl CooldownLive<'_, '_> { /// `query`'s answer as of here; `None` for an item object not held then, and in a harness /// without the list, the items or the clock. pub(crate) fn read(&self, query: CooldownQuery) -> Option> { - let (cooldowns, items) = (self.cooldowns.as_deref()?, self.items.as_deref()?); let (anchor, ui_now) = self.clock.as_deref()?.clock()?; let catalog = self.spells.as_deref().map(|s| &s.catalog); + // `0x6e2ea0` on list 1 with item 0, as the pet bar's feed reads it. + if let CooldownQuery::PetSpell(spell) = query { + return Some( + self.pet + .as_deref()? + .cooldowns + .info( + spell, + 0, + catalog.and_then(|c| c.get(spell)), + std::time::Instant::now(), + ) + .ui_triple(anchor, ui_now), + ); + } + let (cooldowns, items) = (self.cooldowns.as_deref()?, self.items.as_deref()?); let objects = self.stores.item_objects(&self.objects); match query { // `0x6e2ea0`: the spell with item 0, as the bar's and the book's pushes read it. @@ -82,6 +98,7 @@ impl CooldownLive<'_, '_> { .flatten(), ) } + CooldownQuery::PetSpell(_) => None, } } } diff --git a/crates/benilla-app/src/spell/cooldowns/log.rs b/crates/benilla-app/src/spell/cooldowns/log.rs index d3cde5f6d..c8b114db3 100644 --- a/crates/benilla-app/src/spell/cooldowns/log.rs +++ b/crates/benilla-app/src/spell/cooldowns/log.rs @@ -1,11 +1,11 @@ -//! The player's list as each packet left it. In the reference each cooldown packet's handler -//! writes the list and signals inside itself (`0x6e9460` adds its records, then `0x4b31b0` at -//! `0x6e95b0`), and the getters query the list inside their calls (`0x6e2ea0` → `0x6e13e0`), so a -//! listener reads its own packet's records. Here every handler of the frame writes the list before -//! any listener runs, so each write is logged with its stamp, as are the flushes' signals -//! ([`Signal`]); the cooldown lane (`ui_action::cooldown_lane`) rewinds the list to the frame's -//! start and replays the log in stamp order among the other lanes, firing each signal at its -//! place. A write made during the drain, a listener's cast arming its global cooldown, lands at +//! Each list as each packet left it, the player's and the pet's. In the reference each cooldown +//! packet's handler writes its list and signals inside itself (`0x6e9460` adds its records, then +//! `0x4b31b0` at `0x6e95b0`, and for the pet's list `0x4bce90` at `0x6e95b9`), and the getters +//! query the list inside their calls (`0x6e2ea0` → `0x6e13e0`, the pet's through `0x4be03c`), so +//! a listener reads its own packet's records. Here every handler of the frame writes the lists +//! before any listener runs, so each write is logged with its stamp, as are the flushes' signals +//! ([`Signal`]); each list's lane (`ui_action::cooldown_lane`) rewinds it to the frame's start and +//! replays its log in stamp order among the other lanes, firing each signal at its place. A write made during the drain, a listener's cast arming its global cooldown, lands at //! its own place and is not logged; its signal is, stamped under the item firing, and fires //! straight after it. @@ -85,6 +85,8 @@ pub(crate) enum Signal { Usable, /// `0x4f93d0`: `BAG_UPDATE_COOLDOWN`. Bags, + /// `0x4bce90`: `PET_BAR_UPDATE_COOLDOWN`, the pet's list's tail in place of the bags. + Pet, } /// One logged item. @@ -94,13 +96,10 @@ enum Entry { Signal(Signal), } -/// A list's log of the frame's writes and signals ([`super::Cooldowns`]), which only the -/// player's list keeps: the pet's list signals its own watcher once a frame -/// (`crate::ui_pet::bar`). -#[derive(Debug)] +/// A list's log of the frame's writes and signals ([`super::Cooldowns`]), the player's and the +/// pet's alike. +#[derive(Debug, Default)] pub(crate) struct CooldownLog { - /// The list keeps the log. - on: bool, /// The records before the log's first write. start: Option>, /// The writes and signals not yet replayed, in stamp order. @@ -109,24 +108,7 @@ pub(crate) struct CooldownLog { replaying: bool, } -impl Default for CooldownLog { - /// The player's list's: one that keeps. - fn default() -> Self { - Self::new(true) - } -} - impl CooldownLog { - /// A log that keeps the list's writes and signals, or one that keeps nothing. - pub(super) fn new(on: bool) -> Self { - Self { - on, - start: None, - entries: VecDeque::new(), - replaying: false, - } - } - /// Insert `entry` at `at` behind everything stamped before it: one pushed while an item fires /// is stamped under that item and goes ahead of the later ones. fn push(&mut self, at: Stamp, entry: Entry) { @@ -136,7 +118,7 @@ impl CooldownLog { /// Log `write`, made on `records`, unless the drain is replaying. pub(super) fn wrote(&mut self, records: &[Record], write: &Write) { - if !self.on || self.replaying { + if self.replaying { return; } if self.start.is_none() { @@ -147,9 +129,7 @@ impl CooldownLog { /// Log `signal`, the replay's included: a listener's flush fires after its cause. pub(super) fn signalled(&mut self, signal: Signal) { - if self.on { - self.push(crate::lanes::stamp(), Entry::Signal(signal)); - } + self.push(crate::lanes::stamp(), Entry::Signal(signal)); } /// The lane's head: the rewind, at the origin, while the log holds what the drain has not diff --git a/crates/benilla-app/src/spell/cooldowns_tests.rs b/crates/benilla-app/src/spell/cooldowns_tests.rs index fc6e8459b..9e355d84f 100644 --- a/crates/benilla-app/src/spell/cooldowns_tests.rs +++ b/crates/benilla-app/src/spell/cooldowns_tests.rs @@ -397,11 +397,12 @@ fn an_awaited_record_flushes_its_running_cooldown_once_it_lands() { /// the pair (`0x6e95b0`); `SMSG_ITEM_COOLDOWN` the pair and the bags (`0x6e9656`, `0x6e965b`); /// the event and the clear the pair, the usable refresh and the bags (`0x6e3071`, `0x6e3076`, /// `0x6e3089`); the cheat the pair and the bags (`0x6e9712`, `0x6e9721`); a held global cooldown -/// the pair and the refresh (`0x6e2e77`, `0x6e2e85`). The pet's list logs nothing: its watcher -/// reads its counters. +/// the pair and the refresh (`0x6e2e77`, `0x6e2e85`). The pet's list ends each site in its flush +/// `0x4bce90`, in the bags' place or after the pair and the refresh (`0x6e95b9`, `0x6e3080`, +/// `0x6e971c`, `0x6e2e8e`, the GO's pet leg `0x6e8601`). #[test] fn each_flush_site_logs_its_signals_in_the_references_order() { - use super::Signal::{Bags, Pair, Usable}; + use super::Signal::{Bags, Pair, Pet, Usable}; let now = Instant::now(); let mods = SpellModifiers::default(); let mut cds = Cooldowns::default(); @@ -432,11 +433,35 @@ fn each_flush_site_logs_its_signals_in_the_references_order() { site(&|c| c.start_gcd(1784, &held, now, &mods)), [Pair, Usable] ); - let mut pet = Cooldowns::unlogged(); - pet.apply_spell_cooldowns([(122, 0, Some(&nova))], &mods, CategoryPad::Skip, now); - pet.wipe(); - assert!(pet.logged_signals().is_empty()); - assert_eq!(pet.generation, 2, "its watcher's counter moves"); + let mut pet = Cooldowns::pet(); + let mut pet_site = |f: &dyn Fn(&mut Cooldowns)| { + let before = pet.logged_signals().len(); + f(&mut pet); + pet.logged_signals()[before..].to_vec() + }; + assert_eq!( + pet_site(&|c| c.apply_spell_cooldowns( + [(122, 0, Some(&nova))], + &mods, + CategoryPad::Skip, + now + )), + [Pair, Pet] + ); + assert_eq!( + pet_site(&|c| c.cooldown_event(122, now)), + [Pair, Usable, Pet] + ); + assert_eq!(pet_site(&|c| c.clear_spell(122)), [Pair, Usable, Pet]); + assert_eq!(pet_site(&|c| c.wipe()), [Pair, Pet]); + assert_eq!( + pet_site(&|c| c.start_gcd(1784, &held, now, &mods)), + [Pair, Usable, Pet] + ); + assert_eq!( + pet_site(&|c| c.start_spell(122, &nova, 0, now, None)), + [Pair, Pet] + ); } /// The drain rewinds the list to the frame's start and replays each write at its stamp: a read diff --git a/crates/benilla-app/src/spell/report.rs b/crates/benilla-app/src/spell/report.rs index b9a6d50f9..a421ef86a 100644 --- a/crates/benilla-app/src/spell/report.rs +++ b/crates/benilla-app/src/spell/report.rs @@ -274,6 +274,11 @@ impl CastCore<'_> { } match self.slot() { Some(slot) if slot.spell_id() == spell_id => { + // The server's report pops the slot here (`0x6e2218`), after the GCD clear's pair + // its packet signalled (`0x6e167d`), which reads the cast still in flight. + if from_server { + crate::ui_cast::slot_moves_here(); + } self.cancel(true, !from_server, reason); } Some(_) => {} diff --git a/crates/benilla-app/src/ui_action/cooldown_lane.rs b/crates/benilla-app/src/ui_action/cooldown_lane.rs index 8232c33f1..6a2fae3cf 100644 --- a/crates/benilla-app/src/ui_action/cooldown_lane.rs +++ b/crates/benilla-app/src/ui_action/cooldown_lane.rs @@ -1,51 +1,80 @@ -//! The cooldown lane: the player's list replayed packet by packet, and each flush's events fired -//! at its place among the other lanes' items ([`crate::lanes`]). In the reference each cooldown -//! packet's handler writes the list and signals inside itself (`SMSG_SPELL_COOLDOWN` `0x6e9460` -//! calls `0x4b31b0` at `0x6e95b0`; `SMSG_ITEM_COOLDOWN` `0x6e95d0` calls `0x4b31b0` and `0x4f93d0` -//! at `0x6e9656` and `0x6e965b`; the event and the clear call `0x6e3050`, which signals the pair, -//! the usable refresh and the bags at `0x6e3071`, `0x6e3076` and `0x6e3089`; the cheat calls -//! `0x6e9700`), so its listeners read its own records through the getters' queries (`0x6e2ea0`, -//! `0x6e2ed0`). The list logs its writes and its signals as the handlers make them -//! ([`crate::spell::Cooldowns`]); the lane rewinds it to the frame's start, replays each write at -//! its stamp, and fires each signal there with the host lent. +//! The cooldown lanes: each list replayed packet by packet, and each flush's events fired at its +//! place among the other lanes' items ([`crate::lanes`]). In the reference each cooldown packet's +//! handler writes its list and signals inside itself (`SMSG_SPELL_COOLDOWN` `0x6e9460` calls +//! `0x4b31b0` at `0x6e95b0`, and for the pet's list `0x4bce90` at `0x6e95b9`; `SMSG_ITEM_COOLDOWN` +//! `0x6e95d0` calls `0x4b31b0` and `0x4f93d0` at `0x6e9656` and `0x6e965b`; the event and the +//! clear call `0x6e3050`, which signals the pair, the usable refresh, then the bags or the pet's +//! flush at `0x6e3071`, `0x6e3076` and `0x6e3089` or `0x6e3080`; the cheat calls `0x6e9700`, the +//! pair, then the bags or the pet's flush), so its listeners read its own records through the +//! getters' queries (`0x6e2ea0`, `0x6e2ed0`; the pet bar's `0x6e2ea0` on list 1 at `0x4be03c`). +//! Each list logs its writes and its signals as the handlers make them +//! ([`crate::spell::Cooldowns`]); its lane rewinds it to the frame's start, replays each write at +//! its stamp, and fires each signal there with the host lent. The player's list is its resource, +//! the pet's [`crate::ui_pet::PetBar::cooldowns`]. use bevy::prelude::*; use crate::lanes::{LaneApp, Until}; use crate::spell::{Cooldowns, Signal}; +use crate::ui_pet::PetBar; -/// The lane, its end and the drop under the exact complement of the drain's gate. +/// The resource that holds a cooldown list. +pub(crate) trait HoldsList: Resource { + fn list_mut(&mut self) -> &mut Cooldowns; +} + +impl HoldsList for Cooldowns { + fn list_mut(&mut self) -> &mut Cooldowns { + self + } +} + +impl HoldsList for PetBar { + fn list_mut(&mut self) -> &mut Cooldowns { + &mut self.cooldowns + } +} + +/// The two lanes, the player's ahead of the pet's at a tie, their ends and the drops under the +/// exact complement of the drain's gate. pub(super) fn add_cooldown_lane(app: &mut App) { app.init_resource::() .add_lane( "cooldowns", |world| world.get_resource::()?.lane_head(), - fire_cooldowns, + fire_cooldowns::, None, ) - .add_drain_end(end_cooldowns) + .add_lane( + "pet cooldowns", + |world| world.get_resource::()?.cooldowns.lane_head(), + fire_cooldowns::, + None, + ) + .add_drain_end(end_cooldowns::) + .add_drain_end(end_cooldowns::) .add_systems( Update, - drop_cooldown_log + (drop_cooldown_log::, drop_cooldown_log::) .in_set(crate::ui_script::UiFeed) .run_if(not(crate::ui_script::ingame_ui_up)), ); } -/// The lane's firing: the list rewound to the frame's start, once per drain, at the origin and +/// A lane's firing: the list rewound to the frame's start, once per drain, at the origin and /// alone, so every other lane's start is pushed before a signal fires; then each write the drain /// admits replayed and each signal fired at its place with the host lent, the usable refresh /// through the action states ([`crate::script_calls::VmWith::engine_usable_refresh`]). It stops /// after a signal whose firing stamped anything, so what that pushed fires next. -fn fire_cooldowns( +fn fire_cooldowns( In(until): In, - mut vm: crate::script_calls::VmWith>, + mut vm: crate::script_calls::VmWith>, ) { - if vm.own().rewind() { + if vm.own().list_mut().rewind() { return; } loop { - let Some((at, signal)) = vm.own().replay(until) else { + let Some((at, signal)) = vm.own().list_mut().replay(until) else { return; }; let ((), moved) = crate::lanes::firing(at, || match signal { @@ -61,6 +90,11 @@ fn fire_cooldowns( script.fire_event("BAG_UPDATE_COOLDOWN", vec![]); } }), + Signal::Pet => vm.run(|script| { + if let Some(script) = script { + script.fire_event("PET_BAR_UPDATE_COOLDOWN", vec![]); + } + }), }); if moved { return; @@ -69,18 +103,20 @@ fn fire_cooldowns( } /// The drain's end, however it ended: the list as the frame left it. -fn end_cooldowns(world: &mut World) { - if let Some(mut cooldowns) = world.get_resource_mut::() { - cooldowns.end_replay(); +fn end_cooldowns(world: &mut World) { + if let Some(mut holder) = world.get_resource_mut::() { + holder.list_mut().end_replay(); } } /// What the packets wrote while no in-game interface is up, left unreplayed: no VM hears it. -fn drop_cooldown_log(mut cooldowns: ResMut) { - cooldowns.drop_log(); +fn drop_cooldown_log(holder: Option>) { + if let Some(mut holder) = holder { + holder.list_mut().drop_log(); + } } -/// The lane fired alone with no host, for a test that drives the list without the drain: each +/// The player's lane fired alone with no host, for a test that drives the list without the drain: each /// signal fired on the VM, the usable refresh run on the action states directly /// ([`super::run_watcher_refresh_once`]), then the drain's end. #[cfg(test)] @@ -102,5 +138,5 @@ pub(crate) fn fire_cooldown_lane(world: &mut World) { script.fire_event("BAG_UPDATE_COOLDOWN", vec![]); } } - end_cooldowns(world); + end_cooldowns::(world); } diff --git a/crates/benilla-app/src/ui_action/mod.rs b/crates/benilla-app/src/ui_action/mod.rs index eb04ff103..c2f951eb2 100644 --- a/crates/benilla-app/src/ui_action/mod.rs +++ b/crates/benilla-app/src/ui_action/mod.rs @@ -52,10 +52,8 @@ pub(crate) mod toggle; mod unlearn; mod weapon_icon; -/// Every feed that pushes cooldown triples runs `.before` this set: the pet list's flush, -/// `ui_pet::bar::fire_pet_cooldown_events`, runs `.after` it and fires the pet's events -/// synchronously, so a triple pushed later goes unread until the next cooldown change. The player's -/// list fires on the cooldown lane, after every feed ([`cooldown_lane`]). +/// Every feed that pushes cooldown triples runs `.before` this set. Both lists' flushes fire on +/// their cooldown lanes, after every feed ([`cooldown_lane`]). #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] pub(crate) struct CooldownEvents; diff --git a/crates/benilla-app/src/ui_action/state.rs b/crates/benilla-app/src/ui_action/state.rs index d16dc42f7..11933068e 100644 --- a/crates/benilla-app/src/ui_action/state.rs +++ b/crates/benilla-app/src/ui_action/state.rs @@ -197,6 +197,7 @@ struct PetBookLeg<'a> { fn pet_book_state(leg: PetBookLeg) -> ActionState { let mut st = ActionState { is_attack: leg.d.is_melee_auto_attack(), + pet_spell: Some(leg.spell), ..Default::default() }; if let (Some(player), Some(pet)) = (leg.player, leg.pet) { @@ -364,6 +365,12 @@ impl ActionStateMemory { pub(crate) fn clock(&self) -> Option<(Instant, f64)> { self.clock } + + /// The pair a feed would have read, for a rig that runs no action feed. + #[cfg(test)] + pub(crate) fn set_clock(&mut self, clock: (Instant, f64)) { + self.clock = Some(clock); + } } /// One pass over the slots: when it runs, and which of the refreshes it is. diff --git a/crates/benilla-app/src/ui_cast.rs b/crates/benilla-app/src/ui_cast.rs index 8963f46c0..ef2802d57 100644 --- a/crates/benilla-app/src/ui_cast.rs +++ b/crates/benilla-app/src/ui_cast.rs @@ -15,7 +15,7 @@ use crate::ui_action::{CastFail, CastFailLines, MessageSink, Shown, Spells}; use crate::ui_unit::UnitFeed; mod views; -pub(crate) use views::CastViews; +pub(crate) use views::{slot_moves_here, CastViews}; /// One edge of our own cast, queued by the spell's packet handlers, its reports and its cancels. pub(crate) enum CastBarEdge { diff --git a/crates/benilla-app/src/ui_cast/views.rs b/crates/benilla-app/src/ui_cast/views.rs index d3c8f36b3..9877c4178 100644 --- a/crates/benilla-app/src/ui_cast/views.rs +++ b/crates/benilla-app/src/ui_cast/views.rs @@ -134,6 +134,22 @@ impl CastViews { self.shown = Some(view); } + /// `view` logged at `at`, when it differs from the one before it. + fn log(&mut self, at: Stamp, view: CastView) { + let Some(last) = self.last else { + return; + }; + if last == view { + return; + } + if self.entries.is_empty() && !self.started && self.start.is_none() { + self.start = Some(last); + } + let i = self.entries.partition_point(|(s, _)| *s < at); + self.entries.insert(i, (at, view)); + self.last = Some(view); + } + /// A logged `view` into the VM, with the fields the drain's calls moved as they stand live. fn replay(&mut self, script: &mut UiScript, view: CastView) { let view = self.called.over(view, self.live.unwrap_or(view)); @@ -159,9 +175,25 @@ fn live_view(world: &mut World) -> Option { view } +thread_local! { + /// Where the packet being handled moves the in-flight slot, past what it raised before + /// ([`slot_moves_here`]). + static SLOT_MOVES: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// The packet being handled moves the in-flight slot here, after what its handler raised so far: +/// a failed `SMSG_CAST_RESULT`'s report fires the GCD clear's pair (`0x6e1d89` → `0x6e167d`) +/// before it pops the slot (`0x6e2218`), so that pair's listeners read the cast still in flight. +/// The packet's view then lands in two steps: everything but the slot and the targeting word, +/// which `CancelCast 0x6e4940` drops with it (`0x6e4955`), ahead of the packet, and those here. +pub(crate) fn slot_moves_here() { + SLOT_MOVES.set(Some(crate::lanes::stamp())); +} + /// The net stage's start: the view as the frame's input left it, which the first packet that /// moves it is compared with. fn packets_begin(world: &mut World) { + SLOT_MOVES.set(None); let view = live_view(world); if let Some(mut views) = world.get_resource_mut::() { views.last = view; @@ -169,8 +201,10 @@ fn packets_begin(world: &mut World) { } /// A packet handled: its view logged at `at`, the stamp taken ahead of its handler, when it -/// differs from the one before it. +/// differs from the one before it; with its slot at the stamp its handler moved it, when it +/// named one ([`slot_moves_here`]). fn packet_end(world: &mut World, at: Stamp) { + let moves = SLOT_MOVES.take().filter(|s| *s > at); let Some(view) = live_view(world) else { return; }; @@ -180,15 +214,20 @@ fn packet_end(world: &mut World, at: Stamp) { let Some(last) = views.last else { return; }; - if last == view { - return; + if let Some(moves) = moves { + // `CancelCast 0x6e4940` drops the targeting word (`0x6e4955`) and pops the slot + // (`0x6e49d6`) at the pop's place. + let ahead = CastView { + slot: last.slot, + item: last.item, + targeting: last.targeting, + ..view + }; + views.log(at, ahead); + views.log(moves, view); + } else { + views.log(at, view); } - if views.entries.is_empty() && !views.started && views.start.is_none() { - views.start = Some(last); - } - let i = views.entries.partition_point(|(s, _)| *s < at); - views.entries.insert(i, (at, view)); - views.last = Some(view); } /// The lane's head: its start, at the origin, while the frame logged a view; then its next entry. diff --git a/crates/benilla-app/src/ui_pet/bar.rs b/crates/benilla-app/src/ui_pet/bar.rs index 5eb78af34..c900515e9 100644 --- a/crates/benilla-app/src/ui_pet/bar.rs +++ b/crates/benilla-app/src/ui_pet/bar.rs @@ -24,7 +24,7 @@ pub(super) struct PetBarMemory { slots: Vec, state: Option, /// The ten cooldown triples as last pushed, so a cooldown alone is pushed with no event: its - /// events are [`fire_pet_cooldown_events`]'s. + /// events are the list's flush, on the pet's cooldown lane (`crate::ui_action::cooldown_lane`). cooldowns: Vec>, } @@ -222,8 +222,8 @@ pub(crate) fn active_aura_press( } /// Push the ten slot views on a change, and `PET_BAR_UPDATE` when the bar itself moved. A change -/// of the cooldowns alone is pushed without an event: the pet's cooldown events fire off the -/// list's generation, so a natural expiry fires none, as in the reference. +/// of the cooldowns alone is pushed without an event: the pet's cooldown events are its list's +/// flushes, so a natural expiry fires none, as in the reference. pub(super) fn feed_pet_bar( mut vm: crate::script_calls::VmWith<( Res<'static, PetBar>, @@ -362,51 +362,3 @@ pub(super) fn feed_pet_bar( } }) } - -/// The flush behind every mutation of the pet's cooldown list: `0x4b31b0` fires -/// `ACTIONBAR_UPDATE_COOLDOWN` (`0x4e5c60`, event `0xd8`) then `SPELL_UPDATE_COOLDOWN` (`0x106`), -/// and `0x4bce90` fires `PET_BAR_UPDATE_COOLDOWN` (`0x162`). Its callers are the pet's -/// `SMSG_SPELL_GO` leg (`0x6e85fc`), `StartGlobalCooldown` for the list (`0x6e2e77`, `0x6e2e8e`), -/// `SMSG_SPELL_COOLDOWN` for the pet's guid (`0x6e95b0`, `0x6e95b9`), `SMSG_COOLDOWN_EVENT` and -/// `SMSG_CLEAR_COOLDOWN` (`0x6e3071`, `0x6e3080`) and `SMSG_COOLDOWN_CHEAT` (`0x6e9712`, -/// `0x6e971c`); each is a change of [`Cooldowns::generation`](crate::spell::Cooldowns), and one -/// frame's changes fire it once. Between the two, the list's own usable refresh `0x4b31c0` -/// (`0x6e2e85`, `0x6e3076`) signals `SPELL_UPDATE_USABLE` on a change of -/// [`Cooldowns::usable_refreshes`](crate::spell::Cooldowns). `SMSG_PET_SPELLS` seeds without it, -/// so the events never fire for a rebuild. Runs after -/// [`CooldownEvents`](crate::ui_action::CooldownEvents): every feed that pushes a pet cooldown, -/// the bar's and the book's, has pushed by then, so the handlers read the new list. -pub(super) fn fire_pet_cooldown_events( - mut vm: crate::script_calls::VmWith>, - mut memory: Local< - crate::ui_script::VmMemo<(crate::ui_script::gate::Watch, crate::ui_script::gate::Watch)>, - >, -) { - let (generation, usable_refreshes) = { - let bar = vm.own(); - (bar.cooldowns.generation, bar.cooldowns.usable_refreshes) - }; - vm.run(|script| { - let Some(script) = script else { - return; - }; - // A new VM starts from the list as it stands: the reference flushes on a mutation, not at - // load. - let ((flushes, refreshes), fresh) = memory.get_reset(script); - let flushed = flushes.moved(generation); - let refreshed = refreshes.moved(usable_refreshes); - if fresh || !(flushed || refreshed) { - return; - } - if flushed { - script.fire_event("ACTIONBAR_UPDATE_COOLDOWN", vec![]); - script.fire_event("SPELL_UPDATE_COOLDOWN", vec![]); - } - if refreshed { - script.fire_event("SPELL_UPDATE_USABLE", vec![]); - } - if flushed { - script.fire_event("PET_BAR_UPDATE_COOLDOWN", vec![]); - } - }) -} diff --git a/crates/benilla-app/src/ui_pet/drain.rs b/crates/benilla-app/src/ui_pet/drain.rs index e5a52f510..aad240d88 100644 --- a/crates/benilla-app/src/ui_pet/drain.rs +++ b/crates/benilla-app/src/ui_pet/drain.rs @@ -358,9 +358,8 @@ impl PetPress<'_, '_> { /// The spell arm's tail (`0x4bd367`-`0x4bd36e`) for a press past the usability gate whose /// `Spell.dbc` record resolved and that neither cancels an aura nor takes the attempt: it calls /// `StartGlobalCooldown 0x6e2de0(spellId, 1)` before the send (`0x4bd444`), the spell's own -/// `StartRecovery*` pair, under op 21, into the pet's list (`0xcecaec + 0x18`). The insert moves -/// the list's generation, which carries the flush that follows it (`0x6e2e77`, `0x6e2e8e`) to -/// `bar::fire_pet_cooldown_events`. +/// `StartRecovery*` pair, under op 21, into the pet's list (`0xcecaec + 0x18`), and the flush +/// that follows it (`0x6e2e77`, `0x6e2e8e`), at its place on the pet's cooldown lane. /// /// The pet's GCD starts in three places, and this arm is one. A `SMSG_SPELL_COOLDOWN` addressed /// to the pet's guid inserts the same pair for every entry whose spell lacks `Attributes` bit 25 diff --git a/crates/benilla-app/src/ui_pet/flush_tests.rs b/crates/benilla-app/src/ui_pet/flush_tests.rs index b70567e83..5fa5d35bc 100644 --- a/crates/benilla-app/src/ui_pet/flush_tests.rs +++ b/crates/benilla-app/src/ui_pet/flush_tests.rs @@ -232,20 +232,62 @@ fn the_pets_event_and_clear_refresh_the_usable_state_inside_the_flush() { assert_eq!(rig.frame(), FLUSH, "the cheat on an empty list"); } -/// The frame's mutations are one flush for the frame, as the player's list has them. +/// Each packet's handler flushes inside itself (`0x6e95b0`, `0x6e95b9`): two pet cooldown packets +/// in one frame fire the flush twice, and each `PET_BAR_UPDATE_COOLDOWN` listener's +/// `GetPetActionCooldown` (`0x4be03c` → `0x6e2ea0` on the pet's list) reads the list as its own +/// packet left it: Claw cold to the first, running to the second. #[test] -fn several_mutations_in_a_frame_fire_the_flush_once() { +fn each_pet_cooldown_packet_flushes_and_reads_its_own_list() { + let mut rig = rig( + vec![(GROWL, growl()), (CLAW, claw())], + &[GROWL, CLAW], + Some(summoned_pet()), + ); + ui(&mut rig) + .run( + "READ = {} local f = CreateFrame('Frame') f:RegisterEvent('PET_BAR_UPDATE_COOLDOWN') \ + f:SetScript('OnEvent', function() local _, a = GetPetActionCooldown(1) \ + local _, b = GetPetActionCooldown(2) \ + table.insert(READ, tostring(a) .. ':' .. tostring(b)) end)", + ) + .unwrap(); + + rig.receive(vec![ + SessionEvent::SpellCooldowns { + caster: PET, + cooldowns: vec![(GROWL, 3000)], + }, + SessionEvent::SpellCooldowns { + caster: PET, + cooldowns: vec![(CLAW, 4000)], + }, + ]); + + assert_eq!(rig.frame(), [FLUSH, FLUSH].concat()); + assert_eq!( + ui(&mut rig).eval::>("return READ").unwrap(), + ["3:0", "3:4"] + ); +} + +/// The pet's flush falls in packet order with the player's: a player's `SMSG_SPELL_COOLDOWN` +/// ahead of the pet's signals its pair first, then the pet's packet its pair and its flush. +#[test] +fn the_pets_flush_falls_in_packet_order_with_the_players() { let mut rig = growl_rig(); rig.receive(vec![ - go(PET, GROWL), + SessionEvent::SpellCooldowns { + caster: ME, + cooldowns: vec![(GROWL, 3000)], + }, SessionEvent::SpellCooldowns { caster: PET, cooldowns: vec![(GROWL, 3000)], }, ]); - assert_eq!(rig.frame(), FLUSH); + assert_eq!(rig.frame(), [&FLUSH[..2], &FLUSH[..]].concat()); } /// `SMSG_PET_SPELLS` seeds the list through `0x6e2c60`, which flushes nothing, and ends in @@ -350,60 +392,6 @@ fn a_new_vm_is_not_flushed_for_a_list_that_already_moved() { assert_eq!(rig.frame(), FLUSH, "the next mutation is a flush"); } -/// The flush reads what the feeds pushed: it runs after the bar's feed and the book's, which -/// push the pet's cooldown triples the handlers ask for, and (declared through the cooldown -/// events) after the player's own flush. Skips where bevy carries no system names. -#[test] -fn the_flush_runs_after_every_feed_that_pushes_a_pet_cooldown() { - use crate::game_plugins::schedule_tests::{census, headless_client, SyncPoints}; - use bevy::prelude::Update; - use std::collections::{HashMap, HashSet}; - - let mut app = headless_client(); - let c = census(&mut app, Update, SyncPoints::Declared); - let named = |needle: &str| { - let hits: Vec<_> = c - .systems - .iter() - .filter(|(_, s)| s.name.ends_with(needle)) - .map(|(k, _)| *k) - .collect(); - (!hits.is_empty()).then(|| { - assert_eq!(hits.len(), 1, "one system named {needle}"); - hits[0] - }) - }; - let Some(fire) = named("::fire_pet_cooldown_events") else { - eprintln!("skipped: this build carries no system names"); - return; - }; - let mut after: HashMap<_, Vec<_>> = HashMap::new(); - for (a, b) in &c.dependencies { - after.entry(*a).or_default().push(*b); - } - let runs_before = |first| { - let (mut seen, mut stack) = (HashSet::new(), vec![first]); - while let Some(n) = stack.pop() { - if n == fire { - return true; - } - if seen.insert(n) { - stack.extend(after.get(&n).into_iter().flatten().copied()); - } - } - false - }; - for feed in [ - "::feed_pet_bar", - "::feed_pet_book", - "::feed_action_state", - "::fire_action_events", - ] { - let key = named(feed).unwrap_or_else(|| panic!("{feed} is scheduled")); - assert!(runs_before(key), "{feed} runs before the pet's flush"); - } -} - /// The stock spellbook (`SpellBookFrame.lua:209`-`231`): a `SpellButton` redraws on /// `SPELL_UPDATE_COOLDOWN`, and on `PET_BAR_UPDATE` while the pet tab is open, and never hears /// `PET_BAR_UPDATE_COOLDOWN`. So the GCD a pet-bar press starts sweeps the pet tab's button @@ -567,7 +555,7 @@ fn an_inserts_prune_fires_only_the_inserts_own_flush() { for _ in 0..5 { rig.press(1); } - assert_eq!(rig.frame(), FLUSH, "the presses' own flush"); + assert_eq!(rig.frame(), FLUSH.repeat(5), "each press's own flush"); let held = rig.generation(); let_the_gcds_run_out(); let pressed = Instant::now(); @@ -611,7 +599,11 @@ fn an_insert_changes_no_bar_read_of_the_records_that_stay() { for _ in 0..5 { rig.press(1); } - assert_eq!(rig.frame(), FLUSH); + assert_eq!( + rig.frame(), + FLUSH.repeat(7), + "the go, the packet and each press" + ); let (running, parked_read) = (rig.cooldown(2), rig.cooldown(3)); assert_eq!(running.1, 5.0, "Growl sweeps its 5 s"); assert!( diff --git a/crates/benilla-app/src/ui_pet/mod.rs b/crates/benilla-app/src/ui_pet/mod.rs index 5a35f0d52..6e941c7e7 100644 --- a/crates/benilla-app/src/ui_pet/mod.rs +++ b/crates/benilla-app/src/ui_pet/mod.rs @@ -25,7 +25,7 @@ mod usable; // The predicate `0x4bcf70` over what the caller already holds, for the action bar's pet legs. pub(crate) use usable::actions_usable as pet_actions_usable; -use bar::{feed_pet_bar, fire_pet_cooldown_events}; +use bar::feed_pet_bar; // A pet bar press and the pet's other verbs, applied in call order by `crate::script_calls`. pub(crate) use drain::call_pet_off; pub(crate) use drain::{cast_pet_press, PetPress, PetStep}; @@ -60,8 +60,8 @@ pub(crate) struct PetBar { pub(crate) spells: PetSpells, /// The pet's own cooldowns, the list at `0xcecb04`. Seeded from `SMSG_PET_SPELLS`, topped up /// by `SMSG_SPELL_COOLDOWN` for the pet's guid, by the pet's own `SMSG_SPELL_GO` and by a - /// press's GCD. Each mutation but the seed fires the flush, off [`Cooldowns::generation`] - /// (`bar::fire_pet_cooldown_events`), once a frame: it keeps no log. + /// press's GCD. Each mutation but the seed fires the flush at its place, on the pet's cooldown + /// lane (`crate::ui_action::cooldown_lane`). pub(crate) cooldowns: Cooldowns, /// `PET_BAR_UPDATE` signals, counted. Both state writes signal unconditionally (`0x4bc940`, /// `0x4bc960`), and `OnClick`'s `SetChecked(0)` needs that repaint to relight a press on the @@ -94,11 +94,11 @@ impl PetAttackLatch { } impl Default for PetBar { - /// No bar, and the pet's list, which keeps no log ([`Cooldowns::unlogged`]). + /// No bar, and the pet's list ([`Cooldowns::pet`]). fn default() -> Self { Self { spells: PetSpells::default(), - cooldowns: Cooldowns::unlogged(), + cooldowns: Cooldowns::pet(), bar_signals: 0, book_sorts: 0, expires: None, @@ -177,15 +177,11 @@ impl Plugin for UiPetPlugin { .before(feed_pet_bar), // After the pet snapshot: these feeds' events reach Lua at once, and their // handlers read `HasPetUI()`, which `crate::ui_pet_stats` pushes. The bar pushes - // its cooldown triples before the cooldown events and the pet's flush fires after - // them, so the buttons it wakes read the new list. + // its cooldown triples before the cooldown events, as every cooldown feed does. feed_pet_bar .in_set(UnitFeed) .after(crate::ui_pet_stats::PetSnapshot) .before(CooldownEvents), - fire_pet_cooldown_events - .in_set(UnitFeed) - .after(CooldownEvents), feed_pet_unit .in_set(UnitFeed) .in_set(crate::ui_unit::field_events::FieldEventPushes) diff --git a/crates/benilla-app/src/ui_pet/press_tests.rs b/crates/benilla-app/src/ui_pet/press_tests.rs index 508952b66..4ebf7d0e5 100644 --- a/crates/benilla-app/src/ui_pet/press_tests.rs +++ b/crates/benilla-app/src/ui_pet/press_tests.rs @@ -20,7 +20,7 @@ use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfG use crate::spell::Cooldowns; use crate::ui_action::Spells; -use super::bar::{feed_pet_bar, fire_pet_cooldown_events}; +use super::bar::feed_pet_bar; use super::drain::PetPress; use super::PetBar; @@ -178,14 +178,14 @@ pub(super) fn rig( } world.insert_non_send_resource(listening_vm()); world.init_resource::(); - // The order the plugin gives them: the feeds that push a pet cooldown, then the flush. + // The order the plugin gives them: the feeds that push a pet cooldown; the flush is the pet's + // cooldown lane's, in the drain. let mut feed = Schedule::default(); feed.add_systems( ( super::drain::arm_owed_pet_gcds, crate::ui_pet_book::feed_pet_book, feed_pet_bar, - fire_pet_cooldown_events, ) .chain(), ); @@ -204,6 +204,15 @@ impl Rig { /// The bar's feeds for one frame, then the lanes' drain, and the events they fired. pub(super) fn frame(&mut self) -> Vec { self.feed.run(self.app.world_mut()); + // The frame's clock pair, as the action feed hands it to the call host's cooldown reads. + let clock = { + let c = self.app.world().resource::(); + (c.anchor, c.ui_now) + }; + self.app + .world_mut() + .resource_mut::() + .set_clock(clock); crate::lanes::fire_in_order(self.app.world_mut()); let mut script = self .app diff --git a/crates/benilla-app/src/ui_pet_book.rs b/crates/benilla-app/src/ui_pet_book.rs index 315a37653..838374cf3 100644 --- a/crates/benilla-app/src/ui_pet_book.rs +++ b/crates/benilla-app/src/ui_pet_book.rs @@ -112,8 +112,8 @@ pub(crate) fn feed_pet_book( // The push signals nothing: the book's `SPELLS_CHANGED` is its packet's sort // (`0x4bdadb` → `0x4b2fd0`, `crate::ui_pet`'s handler), on the notice lane after this // push. A cooldown repaints the pet page through the flush's `SPELL_UPDATE_COOLDOWN` - // (`SpellBookFrame.lua:209`, `221`), which `ui_pet`'s `fire_pet_cooldown_events` fires - // after this feed's push; autocast, through `PET_BAR_UPDATE` (`227-231`). + // (`SpellBookFrame.lua:209`, `221`), which the pet's cooldown lane fires after every + // feed; autocast, through `PET_BAR_UPDATE` (`227-231`). } }) } diff --git a/crates/benilla-ui/src/script/action.rs b/crates/benilla-ui/src/script/action.rs index fbaac735e..8fa544ba4 100644 --- a/crates/benilla-ui/src/script/action.rs +++ b/crates/benilla-ui/src/script/action.rs @@ -101,6 +101,9 @@ pub struct ActionState { /// The entry an item slot holds, which `IsCurrentAction`'s item arm compares with the in-flight /// cast's item (`0x4e53fc`-`0x4e5428`); `None` for any other slot. pub item: Option, + /// The pet's book spell a macro slot is bound to (`0x4e5a50`'s pet flag), whose cooldown is + /// queried on the pet's list (`0x4e6d04` → `0x6e2ea0`); `None` for any other slot. + pub pet_spell: Option, /// `(start_ms, duration_ms, enabled)`, the start absolute on the `GetTime` clock, so a running /// cooldown re-pushes the same triple and a re-arm a new one (`0x6e13e0` returns the start). pub cooldown: Option<(i64, u32, bool)>, @@ -649,8 +652,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // a slot that reaches no query, an empty one included, answers `(0, 0, 0)`, and one that // reaches it with nothing running `(0, 0, 1)`, so a later `CooldownFrame_SetTimer` replays no // sweep and no finish flash. The query reads the list inside the call (`0x4e6ca0`: `0x6e2ea0` - // for a spell at `0x4e6d0b`, `0x6e2ed0` for an item at `0x4e6ce9`), through the lent host; a slot bound to the - // pet's book reads the pet's list off the snapshot. + // for a spell at `0x4e6d0b`, `0x6e2ed0` for an item at `0x4e6ce9`), through the lent host; a + // slot bound to the pet's book queries the pet's list, the resolve's flag in `edx` + // (`0x4e6cf7` → `0x4e6d04`). g.set( "GetActionCooldown", lua.create_function(|lua, action: Value| { @@ -666,10 +670,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { else { return Ok((0.0, 0.0, 0)); }; - let query = match (state.state.spell, state.state.item) { - (Some(spell), _) => Some(super::CooldownQuery::Spell(spell)), - (None, Some(entry)) => Some(super::CooldownQuery::Item(entry)), - (None, None) => None, + let query = match (state.state.spell, state.state.item, state.state.pet_spell) { + (Some(spell), _, _) => Some(super::CooldownQuery::Spell(spell)), + (None, Some(entry), _) => Some(super::CooldownQuery::Item(entry)), + (None, None, Some(spell)) => Some(super::CooldownQuery::PetSpell(spell)), + (None, None, None) => None, }; (query, state.cooldown) }; diff --git a/crates/benilla-ui/src/script/cooldown_read.rs b/crates/benilla-ui/src/script/cooldown_read.rs index d759d9272..4e750b9da 100644 --- a/crates/benilla-ui/src/script/cooldown_read.rs +++ b/crates/benilla-ui/src/script/cooldown_read.rs @@ -1,13 +1,14 @@ -//! The cooldown getters' query of the player's list inside the call. `GetActionCooldown` -//! (`0x4e6ed0` → `0x4e6ca0`), `GetSpellCooldown` (`0x4b40a0`), `GetContainerItemCooldown` -//! (`0x4f99b0`) and `GetInventoryItemCooldown` (`0x4c8a60`) ask the list as it stands -//! (`GetSpellCooldown 0x6e2ea0` and `GetItemCooldown 0x6e2ed0`, both through `GetCooldownInfo -//! 0x6e13e0`), so a listener reads its own packet's records. Through the lent host when one is +//! The cooldown getters' query of a list inside the call. `GetActionCooldown` (`0x4e6ed0` → +//! `0x4e6ca0`), `GetSpellCooldown` (`0x4b40a0`), `GetContainerItemCooldown` (`0x4f99b0`) and +//! `GetInventoryItemCooldown` (`0x4c8a60`) ask the player's list as it stands, and +//! `GetPetActionCooldown` (`0x4bdfa0`) the pet's (`GetSpellCooldown 0x6e2ea0` and `GetItemCooldown +//! 0x6e2ed0`, both through `GetCooldownInfo 0x6e13e0`), so a listener reads its own packet's +//! records. Through the lent host when one is //! free ([`crate::script::CallHost::read_cooldown`]); with none, the frame's snapshot answers. use mlua::Lua; -/// What a getter asks of the player's list. +/// What a getter asks of a list. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CooldownQuery { /// `0x6e2ea0` on the player's list: the spell, with item 0. @@ -21,6 +22,8 @@ pub enum CooldownQuery { /// `0x6e2ed0` for the item the active player wears in descriptor slot `slot0`, the paper /// doll's (`0x4c8b3a`), likewise. Worn(u8), + /// `0x6e2ea0` on the pet's list (`edx = 1`, `0x4be037`): a pet bar slot's spell. + PetSpell(u32), } /// `(start_ms on the GetTime clock, duration_ms, enabled)`, the shape the snapshots push. diff --git a/crates/benilla-ui/src/script/pet.rs b/crates/benilla-ui/src/script/pet.rs index 1b8842e85..85f591c1f 100644 --- a/crates/benilla-ui/src/script/pet.rs +++ b/crates/benilla-ui/src/script/pet.rs @@ -504,8 +504,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // `is-number` else `Usage: GetPetActionCooldown(index)` (`0x8478d8`, `0x4bdfb7`). let i = binding_abi::index_arg32(lua, i, "Usage: GetPetActionCooldown(index)")?; let now = crate::script::clock::now(lua); - let model = lua.app_data_ref::().expect("model app_data"); - Ok(match slot_at(&model, i).and_then(|s| s.cooldown) { + let (spell, snapshot) = { + let model = lua.app_data_ref::().expect("model app_data"); + let slot = slot_at(&model, i); + ( + slot.and_then(|s| s.view.spell_id), + slot.and_then(|s| s.cooldown), + ) + }; + // A spell slot's query reads the pet's list inside the call (`0x4be03c` → + // `0x6e2ea0`), through the lent host; with none, the frame's snapshot answers. + let live = spell.and_then(|spell| { + super::cooldown_read::read_live(lua, super::CooldownQuery::PetSpell(spell)) + }); + Ok(match live.unwrap_or(snapshot) { Some((start, duration, enabled)) if start + duration > now || !enabled => ( crate::script::clock::held_start(start, enabled, now), duration, diff --git a/crates/benilla-ui/src/script/spellbook.rs b/crates/benilla-ui/src/script/spellbook.rs index 3a6193543..60d562aa0 100644 --- a/crates/benilla-ui/src/script/spellbook.rs +++ b/crates/benilla-ui/src/script/spellbook.rs @@ -454,28 +454,30 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // GetSpellCooldown(id, bookType) -> start, duration, enable in `GetTime` seconds, as // `GetActionCooldown`: enable 0 for an on-hold cooldown; an expired or empty one answers the - // reference's no-cooldown `(0, 0, 1)`, so a re-feed cannot replay the finish flash. A player's - // book slot queries the list inside the call (`0x4b410f` → `0x6e2ea0`), through the lent host; - // the pet's book reads the pet's list off the snapshot. + // reference's no-cooldown `(0, 0, 1)`, so a re-feed cannot replay the finish flash. The slot + // queries its book's list inside the call (`0x4b410f` → `0x6e2ea0`, the book type in `edx` + // from `0x4b40dd`), through the lent host. g.set( "GetSpellCooldown", lua.create_function(|lua, (id, book_type): (Value, Value)| { let (id, book_type) = spell_slot_args(id, book_type, "GetSpellCooldown")?; let now = crate::script::clock::now(lua); - let (spell, snapshot) = { + let (query, snapshot) = { let model = lua.app_data_ref::().expect("model app_data"); let slot = book_slot(&model, id, &book_type); - let spell = slot - .filter(|_| !is_pet_book(&book_type)) - .map(|s| s.spell_id); + let query = slot.map(|s| { + if is_pet_book(&book_type) { + super::CooldownQuery::PetSpell(s.spell_id) + } else { + super::CooldownQuery::Spell(s.spell_id) + } + }); let snapshot = slot .and_then(|s| s.cooldown) .map(super::cooldown_read::seconds); - (spell, snapshot) + (query, snapshot) }; - let live = spell.and_then(|spell| { - super::cooldown_read::read_live(lua, super::CooldownQuery::Spell(spell)) - }); + let live = query.and_then(|q| super::cooldown_read::read_live(lua, q)); Ok(match live.unwrap_or(snapshot) { Some((start, duration, enabled)) if start + duration > now || !enabled => ( crate::script::clock::held_start(start, enabled, now), diff --git a/crates/benilla-ui/src/script/tests/cooldown.rs b/crates/benilla-ui/src/script/tests/cooldown.rs index 12f1b0096..a21879d9b 100644 --- a/crates/benilla-ui/src/script/tests/cooldown.rs +++ b/crates/benilla-ui/src/script/tests/cooldown.rs @@ -30,6 +30,7 @@ fn action_state_bindings_answer_the_reference_conventions() { is_attack: false, equipped: false, item: None, + pet_spell: None, // 4 s remaining of a 10 s cooldown, running: started at GetTime 94. cooldown: Some((94_000, 10_000, true)), no_cooldown_query: false, diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index bbb2db8d2..dc5ac90bc 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -700,7 +700,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it #### script -- An out-of-range slot answers nil, because the reference indexes `0xbc6980` with no bounds check and reads the memory beside its 120 slots. ([`src/script/action.rs:414`](../crates/benilla-ui/src/script/action.rs#L414)) +- An out-of-range slot answers nil, because the reference indexes `0xbc6980` with no bounds check and reads the memory beside its 120 slots. ([`src/script/action.rs:417`](../crates/benilla-ui/src/script/action.rs#L417)) - The files the handler writes beside the install (`.pub` with the key, `.url`; `0x51dce0`, `0x51dde0`) are not written, as the install is read-only; the key is never read either, as there is no signature to verify with it. ([`src/script/addon.rs:207`](../crates/benilla-ui/src/script/addon.rs#L207)) - The reference fails silently; we report it, as a player needs to know, and hold the file so the shutdown write keeps it. ([`src/script/addon.rs:785`](../crates/benilla-ui/src/script/addon.rs#L785)) - The reference drops a send that resolves to PARTY while party slot 0's guid (`0xbc6f48`) is zero (`0x49fa9f`-`0x49faab`); benilla sends it, because `PartyState` is empty until the first `SMSG_GROUP_LIST` and the gate would swallow a real broadcast. vmangos drops a group-less party line anyway (`ChatHandler.cpp:476`). ([`src/script/addon_message.rs:7`](../crates/benilla-ui/src/script/addon_message.rs#L7))