diff --git a/crates/benilla-app/src/spell/cooldowns.rs b/crates/benilla-app/src/spell/cooldowns.rs index 4dd60226..24812350 100644 --- a/crates/benilla-app/src/spell/cooldowns.rs +++ b/crates/benilla-app/src/spell/cooldowns.rs @@ -10,8 +10,9 @@ //! - A failed cast clears the GCD armed at send (`0x6e1d83`, `0x6e1630`) and, unless the reason //! is 0x3c, removes a cooldown-on-event spell's parked record (`0x6e73cc`). //! -//! Every mutation bumps [`Cooldowns::generation`], the `ACTIONBAR_UPDATE_COOLDOWN` edge; a -//! natural expiry does not, since the stock `Cooldown.lua` frame hides itself at the end. +//! Every mutation the reference flushes bumps [`Cooldowns::generation`], the edge of the flush +//! `0x4b31b0` makes (`ACTIONBAR_UPDATE_COOLDOWN`, `SPELL_UPDATE_COOLDOWN`); a natural expiry does +//! not, since the stock `Cooldown.lua` frame hides itself at the end. use std::time::{Duration, Instant}; @@ -92,12 +93,15 @@ impl CooldownInfo { } } -/// The player's cooldown list (the client's `SpellHistory` at `0xcecaec`); the pet list has no -/// consumer. +/// 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. Each list's edge feeds its own events: the +/// player's `feed_action_state`, the pet's `ui_pet::bar::fire_pet_cooldown_events`. #[derive(Resource, Default)] pub(crate) struct Cooldowns { records: Vec, - /// Bumped on every mutation: the `ACTIONBAR_UPDATE_COOLDOWN` edge. + /// Bumped on every mutation the reference flushes (`0x4b31b0`), the flush's edge. Only counts + /// up, across [`Self::clear_silent`] too, so a watcher reads a reset as no mutation. pub(crate) generation: u64, /// Bumped when [`Self::prune`] removes records. Kept apart from [`Self::generation`]: gated /// feeds must see an expiry, but the reference never fires the event for one. @@ -362,6 +366,14 @@ impl Cooldowns { } } + /// Empty the list with no flush: `SMSG_PET_SPELLS` clears the pet's list (`0x6e9b60` → + /// `0x6e1880`) before it seeds it again, or for a teardown leaves it empty, and the packet + /// calls no `0x4b31b0`. The counters keep their place, so a watcher never reads the reset as + /// a mutation. + pub(crate) fn clear_silent(&mut self) { + self.records.clear(); + } + /// Session end ([`crate::net::session::disconnected`]): `SMSG_INITIAL_SPELLS` re-sends every /// running cooldown at world entry and [`Self::seed_initial`] appends, so a surviving record /// would outlive the fresh one. @@ -470,6 +482,9 @@ impl Cooldowns { /// One `SMSG_PET_SPELLS` cooldown: remainders, starting now. The category duration's /// [`PET_COOLDOWN_PERMANENT`] marker is stripped, or it would read as a 37-hour sweep. + /// + /// The handler seeds through `StartCooldown 0x6e2c60` (`0x4bdabf`), which calls no flush, so + /// the insert leaves [`Self::generation`] where it was. pub(crate) fn seed_pet( &mut self, cd: &benilla_protocol::messages::PetSpellCooldown, @@ -478,6 +493,7 @@ impl Cooldowns { ) { use benilla_protocol::messages::PET_COOLDOWN_PERMANENT; let category_ms = cd.category_cd_ms & !PET_COOLDOWN_PERMANENT; + let generation = self.generation; self.add( cd.spell_id, 0, @@ -496,6 +512,7 @@ impl Cooldowns { 0, Timer::none(now), ); + self.generation = generation; } /// The client's `IsSpellOnCooldown 0x6e1690`, which despite its name is true only for an diff --git a/crates/benilla-app/src/spell/net.rs b/crates/benilla-app/src/spell/net.rs index 84c4cf56..77446e01 100644 --- a/crates/benilla-app/src/spell/net.rs +++ b/crates/benilla-app/src/spell/net.rs @@ -993,10 +993,9 @@ fn spell_go( pet_bar .cooldowns .start_spell(spell_id, d, 0, now, Some(spell_mods)); - // `0x6e85fc`/`0x6e8601` fire SPELL_UPDATE_COOLDOWN and PET_BAR_UPDATE_COOLDOWN; the pet - // bar's one repaint fires off its diff, and the signal bump covers a re-arm to an - // identical triple. - pet_bar.bar_signals = pet_bar.bar_signals.wrapping_add(1); + // The flush that follows (`0x6e85fc` `0x4b31b0`, `0x6e8601` `0x4bce90`) fires off the + // list's generation, which the insert bumped: `ACTIONBAR_UPDATE_COOLDOWN`, + // `SPELL_UPDATE_COOLDOWN`, `PET_BAR_UPDATE_COOLDOWN`, and no `PET_BAR_UPDATE`. if benilla_assets::trace::enabled() { let (recovery_ms, category_ms) = spell_mods.spell_cooldowns(d, 0); benilla_assets::trace::line( @@ -1987,14 +1986,14 @@ mod tests { /// One SPELL_GO of `display` (as spell 2649) by `caster` in a world holding us (guid 10) and, /// when given, the casting item; `mods` is the player's table. Returns the player bank's and - /// the pet bank's armed durations and the pet bar's repaint count. + /// the pet bank's armed durations, the pet bar's repaint count and the pet list's generation. fn go_cooldowns( caster: u64, store: ObjectStore, item: Option<(u64, ObjectStore)>, display: fn() -> benilla_formats::SpellDisplay, mods: crate::spell::SpellModifiers, - ) -> (u32, u32, u32) { + ) -> (u32, u32, u32, u64) { use crate::combat_text::CombatTextSpawn; use crate::creature_anim::Casting; use crate::go_anim::GoLidOpen; @@ -2123,6 +2122,10 @@ mod tests { armed(world.resource::()), armed(&world.resource::().cooldowns), world.resource::().bar_signals, + world + .resource::() + .cooldowns + .generation, ) } @@ -2140,34 +2143,37 @@ mod tests { #[test] fn a_pets_own_go_arms_the_pet_bank_and_only_the_pet_bank() { let fire = |caster: u64, store: ObjectStore| { - let (player, pet, signals) = go_cooldowns( + let (player, pet, signals, generation) = go_cooldowns( caster, store, None, growl, crate::spell::SpellModifiers::default(), ); - (player > 0, pet > 0, signals) + (player > 0, pet > 0, signals, generation) }; - // Our pet: the PET bank only, and a forced repaint with it. - let (player, pet, signals) = fire(20, owned(SUMMONEDBY, 10)); + // Our pet: the PET bank only. The insert moves the list's generation, which is the flush's + // edge (`0x6e85fc`, `0x6e8601`: the cooldown events), and signals no `PET_BAR_UPDATE`. + let (player, pet, signals, generation) = fire(20, owned(SUMMONEDBY, 10)); assert!(pet, "our pet's GO arms the pet bank"); assert!(!player, "…and never the player's"); - assert_eq!(signals, 1, "PET_BAR_UPDATE_COOLDOWN's repaint"); + assert_eq!(generation, 1, "the pet list's flush edge"); + assert_eq!(signals, 0, "the bar's own signal is not the GO's"); // A charm reads CHARMEDBY first: the same leg, the other field. - let (_, charmed, _) = fire(20, owned(CHARMEDBY, 10)); + let (_, charmed, _, _) = fire(20, owned(CHARMEDBY, 10)); assert!(charmed, "a charmed unit's GO arms it too"); // A totem carries CREATEDBY and no SUMMONEDBY: `0x5ee5a0` would accept it, `0x6e859a` does // not. - let (_, totem, _) = fire(30, owned(CREATEDBY, 10)); + let (_, totem, _, _) = fire(30, owned(CREATEDBY, 10)); assert!(!totem, "CREATEDBY alone is not this leg's owner test"); - // Somebody else's pet: neither bank. - let (p2, pet2, _) = fire(40, owned(SUMMONEDBY, 99)); + // Somebody else's pet: neither bank, no flush. + let (p2, pet2, _, generation) = fire(40, owned(SUMMONEDBY, 99)); assert!(!p2 && !pet2, "a stranger's pet arms nothing"); + assert_eq!(generation, 0, "and moves no edge"); } /// Op 11 on the GO insert: the self leg (`0x6e846f`, and `0x6e2b60`'s tail) and the pet leg diff --git a/crates/benilla-app/src/ui_action/mod.rs b/crates/benilla-app/src/ui_action/mod.rs index 62341dfc..ee7dfb27 100644 --- a/crates/benilla-app/src/ui_action/mod.rs +++ b/crates/benilla-app/src/ui_action/mod.rs @@ -33,7 +33,8 @@ mod weapon_icon; /// Every feed that pushes cooldown triples runs `.before` this set: [`state::feed_action_state`] /// fires the cooldown events synchronously, so a triple pushed later goes unread until the next -/// cooldown change. +/// cooldown change. The pet list's flush, `ui_pet::bar::fire_pet_cooldown_events`, runs `.after` +/// it on the same rule. #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] pub(crate) struct CooldownEvents; diff --git a/crates/benilla-app/src/ui_pet/bar.rs b/crates/benilla-app/src/ui_pet/bar.rs index 00c29fe5..9e18e767 100644 --- a/crates/benilla-app/src/ui_pet/bar.rs +++ b/crates/benilla-app/src/ui_pet/bar.rs @@ -29,7 +29,8 @@ pub(super) fn actions_usable(bar: &PetBar, pet_flags: Option) -> bool { #[derive(Default)] pub(super) struct PetBarMemory { pushed: Option<(u32, bool, bool, bool, Vec)>, - /// The ten cooldown triples as last pushed: `PET_BAR_UPDATE_COOLDOWN`'s edge. + /// The ten cooldown triples as last pushed, so a cooldown alone is pushed with no event: its + /// events are [`fire_pet_cooldown_events`]'s. cooldowns: Vec>, } @@ -173,8 +174,9 @@ pub(super) fn active_aura_press( crate::ui_action::toggle::active_action_toggle(spell_id, spell?, pet?).then_some(spell_id) } -/// Push the ten slot views on a change: `PET_BAR_UPDATE`, or `PET_BAR_UPDATE_COOLDOWN` when only -/// cooldowns moved, the reference's fire from the pet's cooldown bank (`0x6e2e8e`). +/// 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. pub(super) fn feed_pet_bar( script: Option>, bar: Res, @@ -237,7 +239,7 @@ pub(super) fn feed_pet_bar( }; // `bar_signals` in the key repaints a press that moved nothing (`0x4bc940`/`0x4bc960`); the - // cooldown triples are keyed apart, their edge being the bank's. + // cooldown triples are keyed apart, since they carry no `PET_BAR_UPDATE`. let cooldowns: Vec> = fresh.iter().map(|s| s.cooldown).collect(); let content: Vec = fresh .iter() @@ -264,7 +266,35 @@ pub(super) fn feed_pet_bar( ); memory.pushed = Some(key); script.fire_event("PET_BAR_UPDATE", vec![]); - } else if cooldowns_changed { - script.fire_event("PET_BAR_UPDATE_COOLDOWN", vec![]); } } + +/// 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. `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( + script: Option>, + bar: Res, + mut memory: Local>, +) { + let Some(mut script) = script else { + return; + }; + // A new VM starts from the list as it stands: the reference flushes on a mutation, not at load. + let (watch, fresh) = memory.get_reset(&script); + let moved = watch.moved(bar.cooldowns.generation); + if !moved || fresh { + return; + } + script.fire_event("ACTIONBAR_UPDATE_COOLDOWN", vec![]); + script.fire_event("SPELL_UPDATE_COOLDOWN", vec![]); + 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 931b69f5..5dda81eb 100644 --- a/crates/benilla-app/src/ui_pet/drain.rs +++ b/crates/benilla-app/src/ui_pet/drain.rs @@ -143,10 +143,11 @@ impl PetPress<'_, '_> { /// The spell arm's tail (`0x4bd355`-`0x4bd36e`): a press that neither cancels an aura nor takes /// the generic cast entry `0x6e4b60` (`AttributesEx4 & 0x20`, or the bar's unit possessed) 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 bar's -/// cooldown edge then reads it, which is `PET_BAR_UPDATE_COOLDOWN` (`0x6e2e8e`). An unresolved -/// `SpellRec` or pet object leaves the arm before it (`0x4bd2fe`, `0x4bd34f`). This is the only -/// place the pet's GCD starts: the `SMSG_SPELL_GO` pet leg inserts none (`0x6e85f7`). +/// `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`. An unresolved `SpellRec` or pet object leaves the arm before +/// it (`0x4bd2fe`, `0x4bd34f`). This is the only place the pet's GCD starts: the `SMSG_SPELL_GO` +/// pet leg inserts the spell's own timers and no GCD (`0x6e85f7`). pub(super) fn arm_pet_gcd( bar: &mut PetBar, spell_id: u32, diff --git a/crates/benilla-app/src/ui_pet/flush_tests.rs b/crates/benilla-app/src/ui_pet/flush_tests.rs new file mode 100644 index 00000000..4febfa9c --- /dev/null +++ b/crates/benilla-app/src/ui_pet/flush_tests.rs @@ -0,0 +1,408 @@ +//! The flush behind every mutation of the pet's cooldown list, whole client headless: each packet +//! that reaches the list arrives as the session event it is, and the pet's feeds run after it. +//! `0x4b31b0` fires `ACTIONBAR_UPDATE_COOLDOWN` then `SPELL_UPDATE_COOLDOWN` and `0x4bce90` fires +//! `PET_BAR_UPDATE_COOLDOWN`; none of the callers fires `PET_BAR_UPDATE`. + +use std::time::Duration; + +use benilla_formats::SpellDisplay; +use benilla_protocol::messages::{PetSpellCooldown, PetSpells}; +use benilla_protocol::SessionEvent; + +use super::press_tests::{claw, growl, listen, listening_vm, rig, Rig, CLAW, GROWL, ME, PET}; +use super::PetBar; +use crate::net::ObjectStore; + +/// The three events, in the reference's order. +const FLUSH: [&str; 3] = [ + "ACTIONBAR_UPDATE_COOLDOWN", + "SPELL_UPDATE_COOLDOWN", + "PET_BAR_UPDATE_COOLDOWN", +]; + +/// `UNIT_FIELD_SUMMONEDBY`, low word, the owner test the GO's pet leg reads (`0x6e859a`). +const SUMMONEDBY: u16 = 12; + +/// Our own pet by the field the GO's pet leg reads: `SUMMONEDBY` us. +fn summoned_pet() -> ObjectStore { + ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ + (SUMMONEDBY, ME as u32), + (SUMMONEDBY + 1, 0), + ])) +} + +/// The rig's VM. +fn ui(rig: &mut Rig) -> bevy::ecs::change_detection::Mut<'_, benilla_ui::script::UiScript> { + rig.app + .world_mut() + .non_send_resource_mut::() +} + +/// Claw as the pet book lists it: named, iconed, and with the ordinary pet GCD. +fn claw_book_spell() -> SpellDisplay { + SpellDisplay { + icon: Some("Interface\\Icons\\Ability_Druid_Rake".into()), + ..claw() + } +} + +/// A rig with Growl (a 5 s recovery of its own) on slot 1 of `PET`'s bar. +fn growl_rig() -> Rig { + rig(vec![(GROWL, growl())], &[GROWL], Some(summoned_pet())) +} + +/// `SMSG_SPELL_GO` of `spell_id` by `caster`, as the session decodes a plain cast. +fn go(caster: u64, spell_id: u32) -> SessionEvent { + SessionEvent::SpellGo { + caster, + spell_id, + cast_flags: 0, + hits: vec![], + misses: vec![], + target: None, + go_target: None, + dest: None, + ammo_display_id: None, + item_caster: None, + } +} + +impl Rig { + /// The packets, handled as the session hands them to the client. + fn receive(&mut self, events: Vec) { + crate::net::handlers::dispatch(self.app.world_mut(), events); + } + + fn generation(&self) -> u64 { + self.app.world().resource::().cooldowns.generation + } +} + +/// `0x6e85f7` inserts into the pet's list, `0x6e85fc` and `0x6e8601` flush. The bar takes no +/// `PET_BAR_UPDATE` from it, which the spellbook's pet tab would take as a repaint of its own. +#[test] +fn a_pets_spell_go_fires_the_flush_and_no_bar_update() { + let mut rig = growl_rig(); + + rig.receive(vec![go(PET, GROWL)]); + + assert_eq!(rig.frame(), FLUSH); + assert!(rig.frame().is_empty(), "once, not every frame"); +} + +/// The GO's owner test (`0x6e859a`) admits our pet and nobody else's: no insert, no flush. +#[test] +fn a_strangers_pet_go_flushes_nothing() { + let strangers = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ + (SUMMONEDBY, 0x99), + (SUMMONEDBY + 1, 0), + ])); + let mut rig = rig(vec![(GROWL, growl())], &[GROWL], Some(strangers)); + + rig.receive(vec![go(PET, GROWL)]); + + assert_eq!(rig.generation(), 0); + assert!(rig.frame().is_empty()); +} + +/// `0x6e95b0`, `0x6e95b9`: the flush follows `SMSG_SPELL_COOLDOWN` for the pet's guid, and for it +/// alone; the player's own list flushes through the action state's feed. +#[test] +fn a_pet_addressed_spell_cooldown_fires_the_flush() { + let mut rig = growl_rig(); + + rig.receive(vec![SessionEvent::SpellCooldowns { + caster: ME, + cooldowns: vec![(GROWL, 3000)], + }]); + assert!( + rig.frame().is_empty(), + "the player's list is not the pet's flush" + ); + + rig.receive(vec![SessionEvent::SpellCooldowns { + caster: PET, + cooldowns: vec![(GROWL, 3000)], + }]); + assert_eq!(rig.frame(), FLUSH); +} + +/// `0x6e3071`-`0x6e3080` (`SMSG_COOLDOWN_EVENT`, `SMSG_CLEAR_COOLDOWN`) and `0x6e9712`-`0x6e971c` +/// (`SMSG_COOLDOWN_CHEAT`): each flushes for the pet's list as for a spell cast. +#[test] +fn the_pets_event_clear_and_cheat_packets_fire_the_flush() { + // Attributes bit 25: the record parks until the event. + let parked = || SpellDisplay { + attributes: 1 << 25, + ..growl() + }; + let mut rig = rig(vec![(GROWL, parked())], &[GROWL], Some(summoned_pet())); + let park = |rig: &mut Rig| { + rig.receive(vec![SessionEvent::SpellCooldowns { + caster: PET, + cooldowns: vec![(GROWL, 3000)], + }]); + assert_eq!(rig.frame(), FLUSH, "parked"); + }; + + park(&mut rig); + rig.receive(vec![SessionEvent::CooldownEvent { + spell_id: GROWL, + caster: PET, + }]); + assert_eq!(rig.frame(), FLUSH, "the parked timers start"); + + rig.receive(vec![SessionEvent::ClearCooldown { + spell_id: GROWL, + caster: PET, + }]); + assert_eq!(rig.frame(), FLUSH, "the record goes"); + + park(&mut rig); + rig.receive(vec![SessionEvent::CooldownCheat { caster: PET }]); + assert_eq!(rig.frame(), FLUSH, "the GM wipe"); +} + +/// The frame's mutations are one flush for the frame, as the player's list has them. +#[test] +fn several_mutations_in_a_frame_fire_the_flush_once() { + let mut rig = growl_rig(); + + rig.receive(vec![ + go(PET, GROWL), + SessionEvent::SpellCooldowns { + caster: PET, + cooldowns: vec![(GROWL, 3000)], + }, + ]); + + assert_eq!(rig.frame(), FLUSH); +} + +/// `SMSG_PET_SPELLS` seeds the list through `0x6e2c60`, which flushes nothing, and ends in +/// `SetPet`'s `SignalEvent(0x161)` (`0x4bc90c`) whatever the packet changed: a packet that moves +/// only the cooldowns is one `PET_BAR_UPDATE`, not the flush. +#[test] +fn the_pet_spells_packet_signals_the_bar_and_never_flushes() { + let mut rig = growl_rig(); + let same_bar = PetSpells { + cooldowns: vec![PetSpellCooldown { + spell_id: GROWL, + category: 82, + spell_cd_ms: 4000, + category_cd_ms: 0, + }], + ..rig.app.world().resource::().spells.clone() + }; + + rig.receive(vec![SessionEvent::PetSpells(Box::new(same_bar))]); + assert_eq!( + rig.frame(), + ["PET_BAR_UPDATE"], + "the bar is what it was and its cooldown is new: the packet's own signal repaints it" + ); + let (_, duration, enable) = rig.cooldown(1); + assert!( + (duration - 4.0).abs() < 1e-9 && enable == 1, + "and the seeded cooldown reached the bar: {duration}" + ); + assert_eq!(rig.generation(), 0, "a seed is no mutation of the flush"); + + // The teardown clears the list and flushes nothing either. + rig.receive(vec![SessionEvent::PetSpells(Box::default())]); + assert_eq!(rig.frame(), ["PET_BAR_UPDATE"]); +} + +/// A reset moves no counter back: a mutation, the teardown, a new bar, and the next mutation +/// flushes once. A counter that fell back to 0 would read as a mutation at the teardown. +#[test] +fn a_teardown_and_a_new_bar_do_not_hide_the_next_flush() { + let mut rig = growl_rig(); + rig.receive(vec![go(PET, GROWL)]); + assert_eq!(rig.frame(), FLUSH); + let held = rig.generation(); + + rig.receive(vec![SessionEvent::PetSpells(Box::default())]); + assert_eq!(rig.frame(), ["PET_BAR_UPDATE"], "the teardown"); + assert_eq!(rig.generation(), held, "the counter is where it was"); + + let mut bar = rig.app.world().resource::().spells.clone(); + bar.pet_guid = PET; + bar.bar[0] = benilla_protocol::messages::PetActionEntry::from( + GROWL | (u32::from(benilla_protocol::messages::PET_ACT_ENABLED) << 24), + ); + rig.receive(vec![SessionEvent::PetSpells(Box::new(bar))]); + assert_eq!(rig.frame(), ["PET_BAR_UPDATE"], "the new bar"); + + rig.receive(vec![go(PET, GROWL)]); + assert_eq!(rig.frame(), FLUSH); +} + +/// The stock `Cooldown` frame ends its own sweep, and the reference flushes no event for the end +/// of a cooldown: an elapsed record moves neither the list nor the events. +#[test] +fn a_natural_expiry_fires_nothing() { + let brief = || SpellDisplay { + name: "Brief".into(), + recovery_ms: 30, + ..Default::default() + }; + let mut rig = rig(vec![(GROWL, brief())], &[GROWL], Some(summoned_pet())); + rig.receive(vec![go(PET, GROWL)]); + assert_eq!(rig.frame(), FLUSH); + assert!(rig.cooldown(1).1 > 0.0, "running"); + + std::thread::sleep(Duration::from_millis(80)); + + assert!( + rig.frame().is_empty(), + "its end is the frame's, not an event" + ); + assert_eq!( + rig.cooldown(1).1, + 0.0, + "and the sweep is gone from the push" + ); +} + +/// A new VM meets the list as it stands: the flush is for a mutation, not for a load. +#[test] +fn a_new_vm_is_not_flushed_for_a_list_that_already_moved() { + let mut rig = growl_rig(); + rig.receive(vec![go(PET, GROWL)]); + assert_eq!(rig.frame(), FLUSH); + + // The interface reloads: a new VM, the bar pushed into it afresh. + rig.app.world_mut().insert_non_send_resource(listening_vm()); + assert_eq!(rig.frame(), ["PET_BAR_UPDATE"], "the bar, and no flush"); + + rig.receive(vec![go(PET, GROWL)]); + 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"] { + 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 +/// through the flush's `SPELL_UPDATE_COOLDOWN` alone. +#[test] +fn a_pet_gcd_sweeps_the_spellbooks_pet_tab_button() { + benilla_formats::wow_data_or_skip!(); + use benilla_protocol::messages::{PetActionEntry, PET_ACT_ENABLED}; + + let mut rig = rig( + vec![(CLAW, claw_book_spell())], + &[CLAW], + Some(summoned_pet()), + ); + { + let world = rig.app.world_mut(); + // Our class, so the book reads a token (`0x4b4463`); no `ChrClasses` row, so "PET". + world.spawn(( + crate::net::SelfPlayer, + ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(36, 3 << 8)])), + )); + world.insert_resource(crate::chr_classes::ChrClassTable(Default::default())); + world.resource_mut::().spells.spells = vec![PetActionEntry::from( + CLAW | (u32::from(PET_ACT_ENABLED) << 24), + )]; + } + + let mut script = benilla_ui::script::UiScript::new().unwrap(); + script.set_screen_size(1024.0, 768.0); + let failures = crate::ui_script::load_default_ui(&script); + assert!(failures.is_empty(), "load failures: {failures:?}"); + listen(&script); + rig.app.world_mut().insert_non_send_resource(script); + + // The book arrives, and the window opens on its pet tab. + rig.frame(); + let mut s = ui(&mut rig); + s.run("ToggleSpellBook(BOOKTYPE_PET)").unwrap(); + s.resolve(); + assert!( + s.eval::("return SpellBookFrame.bookType == BOOKTYPE_PET") + .unwrap(), + "the pet tab is open" + ); + assert_eq!( + s.eval::("return SpellButton1SpellName:GetText()") + .unwrap(), + "Claw" + ); + assert!( + !s.eval::("return SpellButton1Cooldown:IsShown()") + .unwrap(), + "no cooldown yet" + ); + + // A press on the bar's Claw starts the pet's GCD. + rig.press(1); + rig.frame(); + + let mut s = ui(&mut rig); + s.resolve(); + assert!( + s.eval::("return SpellButton1Cooldown:IsShown()") + .unwrap(), + "the GCD sweeps the spellbook's button" + ); + assert!( + (s.eval::("return SpellButton1Cooldown.duration") + .unwrap() + - 1.5) + .abs() + < 1e-9, + "and it is the 1.5 s the press started" + ); + let errors = s.errors(); + assert!(errors.is_empty(), "script errors: {errors:?}"); +} diff --git a/crates/benilla-app/src/ui_pet/mod.rs b/crates/benilla-app/src/ui_pet/mod.rs index f0823354..f99fb3c4 100644 --- a/crates/benilla-app/src/ui_pet/mod.rs +++ b/crates/benilla-app/src/ui_pet/mod.rs @@ -11,6 +11,7 @@ use benilla_protocol::messages::PetSpells; use crate::net::{GuidIndex, ObjectStore}; use crate::spell::Cooldowns; +use crate::ui_action::CooldownEvents; use crate::ui_script::UiInput; use crate::ui_unit::UnitFeed; @@ -20,7 +21,7 @@ mod menu; mod net; mod unit; -use bar::feed_pet_bar; +use bar::{feed_pet_bar, fire_pet_cooldown_events}; use drain::drain_pet_actions; // A pet bar press, applied in call order by `crate::script_calls`. pub(crate) use drain::pet_stop_on_old_target_clear; @@ -28,6 +29,8 @@ pub(crate) use drain::PetPress; use menu::{drain_pet_menu, feed_pet_menu}; use unit::feed_pet_unit; +#[cfg(test)] +mod flush_tests; #[cfg(test)] mod press_tests; #[cfg(test)] @@ -39,8 +42,10 @@ mod tests; pub(crate) struct PetBar { /// The last `SMSG_PET_SPELLS`, with `SMSG_PET_MODE` and local presses folded in. pub(crate) spells: PetSpells, - /// The pet's own cooldowns: the reference keeps a `SPELLHISTORY` per unit. Seeded from - /// `SMSG_PET_SPELLS`, topped up by `SMSG_SPELL_COOLDOWN` for the pet's guid. + /// 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`). pub(crate) cooldowns: Cooldowns, /// The client's `[0xb714b0]`, a local latch: all of `IsPetAttackActive`, and the only thing /// that lights Attack (`0x4bdf16`-`0x4bdf22`). Raised only for a possessed unit (`0x4bd420`), @@ -61,6 +66,17 @@ impl PetBar { pub(crate) fn has_bar(&self) -> bool { self.spells.pet_guid != 0 } + + /// No pet: every field back to its default, the cooldown list emptied with its counters kept + /// ([`Cooldowns::clear_silent`]), for the teardown packet and the session end. + pub(crate) fn clear(&mut self) { + let mut cooldowns = std::mem::take(&mut self.cooldowns); + cooldowns.clear_silent(); + *self = Self { + cooldowns, + ..Default::default() + }; + } } pub(crate) struct UiPetPlugin; @@ -79,10 +95,16 @@ impl Plugin for UiPetPlugin { .in_set(UnitFeed) .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. + // 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. feed_pet_bar .in_set(UnitFeed) - .after(crate::ui_pet_stats::PetSnapshot), + .after(crate::ui_pet_stats::PetSnapshot) + .before(CooldownEvents), + fire_pet_cooldown_events + .in_set(UnitFeed) + .after(CooldownEvents), feed_pet_unit .in_set(UnitFeed) .after(crate::ui_pet_stats::PetSnapshot), diff --git a/crates/benilla-app/src/ui_pet/net.rs b/crates/benilla-app/src/ui_pet/net.rs index 95623c14..ef68bd42 100644 --- a/crates/benilla-app/src/ui_pet/net.rs +++ b/crates/benilla-app/src/ui_pet/net.rs @@ -32,7 +32,7 @@ pub(super) fn register(app: &mut App) { /// A lost socket sends no zero-guid `SMSG_PET_SPELLS`, so the session end resets the bar itself. fn on_session_end(In(_): In, mut bar: ResMut) { - *bar = PetBar::default(); + bar.clear(); } fn on_spells(In(ev): In, catalog: Option>, mut bar: ResMut) { @@ -96,7 +96,7 @@ fn pet_spells(spells: PetSpells, catalog: Option<&Spells>, bar: &mut PetBar) { if bar.spells.pet_guid != 0 { debug!("net: pet bar torn down"); } - *bar = PetBar::default(); + bar.clear(); return; } // A new pet guid clears the attack latch (`0x4bc8ce`); a re-send from the same pet keeps it. @@ -157,12 +157,15 @@ fn pet_spells(spells: PetSpells, catalog: Option<&Spells>, bar: &mut PetBar) { // `SetPet` (`0x4bc7e0`) re-arms the expiry on every packet, and a 0 duration clears it. bar.expires = (spells.duration_ms != 0) .then(|| now + std::time::Duration::from_millis(u64::from(spells.duration_ms))); - bar.cooldowns = crate::spell::Cooldowns::default(); + bar.cooldowns.clear_silent(); for cd in &spells.cooldowns { let display = catalog.and_then(|c| c.catalog.get(cd.spell_id)); bar.cooldowns.seed_pet(cd, display, now); } bar.spells = spells; + // `SetPet` (`0x4bc7e0`) ends in `SignalEvent(0x161)` on every packet (`0x4bc90c`), whatever + // changed: the one repaint a packet that moves only the pet's cooldowns gets. + bar.bar_signals = bar.bar_signals.wrapping_add(1); } /// `SMSG_PET_MODE`: the state word alone, applied only to the bar of the pet it names. diff --git a/crates/benilla-app/src/ui_pet/press_tests.rs b/crates/benilla-app/src/ui_pet/press_tests.rs index e530bf12..61970442 100644 --- a/crates/benilla-app/src/ui_pet/press_tests.rs +++ b/crates/benilla-app/src/ui_pet/press_tests.rs @@ -1,5 +1,6 @@ //! A pet bar spell press and the pet's global cooldown: the spell arm of `0x4bd1d0` starts it //! (`0x4bd367`-`0x4bd36e`), whole client headless, with the press applied as the script call it is. +//! The rig is shared with `flush_tests`, whose packets reach the same list. use std::collections::HashMap; use std::time::Instant; @@ -16,27 +17,27 @@ use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfG use crate::spell::Cooldowns; use crate::ui_action::Spells; -use super::bar::feed_pet_bar; +use super::bar::{feed_pet_bar, fire_pet_cooldown_events}; use super::drain::PetPress; use super::PetBar; -const ME: u64 = 0x10; -const PET: u64 = 0x2A; +pub(super) const ME: u64 = 0x10; +pub(super) const PET: u64 = 0x2A; /// `UNIT_FIELD_CHARMEDBY` and `UNIT_FIELD_CREATEDBY`, low words; the high words stay 0. -const CHARMEDBY: u16 = 10; -const CREATEDBY: u16 = 14; -const FLAGS: u16 = 46; +pub(super) const CHARMEDBY: u16 = 10; +pub(super) const CREATEDBY: u16 = 14; +pub(super) const FLAGS: u16 = 46; /// `UNIT_FIELD_AURA` slot 0 and `UNIT_FIELD_AURAFLAGS`, a nibble per slot. const AURA: u16 = 47; const AURAFLAGS: u16 = 95; -const CLAW: u32 = 16829; -const BITE: u32 = 17258; -const GROWL: u32 = 14918; -const COWER: u32 = 1742; +pub(super) const CLAW: u32 = 16829; +pub(super) const BITE: u32 = 17258; +pub(super) const GROWL: u32 = 14918; +pub(super) const COWER: u32 = 1742; /// Claw's shape: no timer of its own, and the ordinary pet GCD, category 133 for 1500 ms. -fn claw() -> SpellDisplay { +pub(super) fn claw() -> SpellDisplay { SpellDisplay { name: "Claw".into(), start_recovery_category: 133, @@ -46,7 +47,7 @@ fn claw() -> SpellDisplay { } /// Bite's shape: the same GCD pair, and a 10 s category timer of its own. -fn bite() -> SpellDisplay { +pub(super) fn bite() -> SpellDisplay { SpellDisplay { name: "Bite".into(), category: 19, @@ -56,7 +57,7 @@ fn bite() -> SpellDisplay { } /// Growl's shape: a 5 s category timer and no GCD pair. -fn growl() -> SpellDisplay { +pub(super) fn growl() -> SpellDisplay { SpellDisplay { name: "Growl".into(), category: 82, @@ -75,24 +76,52 @@ fn cower() -> SpellDisplay { } /// Our own pet: `CREATEDBY` us, no flags. -fn owned_pet() -> ObjectStore { +pub(super) fn owned_pet() -> ObjectStore { ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ (CREATEDBY, ME as u32), (CREATEDBY + 1, 0), ])) } -struct Rig { - app: App, +pub(super) struct Rig { + pub(super) app: App, commands: Receiver, - /// The bar's feed as a schedule of its own, so its `Local` memory lives across runs. + /// The pet's feeds as a schedule of their own, so their `Local` memory lives across runs. feed: Schedule, } +/// A VM whose listener records every event the pet's lists fire into `SEEN`, in order. +pub(super) fn listening_vm() -> benilla_ui::script::UiScript { + let script = benilla_ui::script::UiScript::new().expect("a VM"); + listen(&script); + script +} + +/// The listener [`listening_vm`] carries, on a VM that has one already. +pub(super) fn listen(script: &benilla_ui::script::UiScript) { + script + .run( + r#" + SEEN = {} + local f = CreateFrame("Frame") + f:RegisterEvent("PET_BAR_UPDATE") + f:RegisterEvent("PET_BAR_UPDATE_COOLDOWN") + f:RegisterEvent("ACTIONBAR_UPDATE_COOLDOWN") + f:RegisterEvent("SPELL_UPDATE_COOLDOWN") + f:SetScript("OnEvent", function() table.insert(SEEN, event) end) + "#, + ) + .expect("the listener registers"); +} + /// The whole client, headless, with `PET` on the bar and `catalog` as the spell table. Slot `n` /// (1-based) holds `slots[n - 1]` as an enabled spell word. `pet` is its object, or none /// streamed. -fn rig(catalog: Vec<(u32, SpellDisplay)>, slots: &[u32], pet: Option) -> Rig { +pub(super) fn rig( + catalog: Vec<(u32, SpellDisplay)>, + slots: &[u32], + pet: Option, +) -> Rig { let mut app = crate::game_plugins::schedule_tests::headless_client(); let (tx, commands) = crossbeam_channel::unbounded(); app.insert_resource(NetCommands(tx)); @@ -111,21 +140,17 @@ fn rig(catalog: Vec<(u32, SpellDisplay)>, slots: &[u32], pet: Option, slots: &[u32], pet: Option Vec { + /// The bar's feeds for one frame, and the events they fired. + pub(super) fn frame(&mut self) -> Vec { self.feed.run(self.app.world_mut()); let mut script = self .app @@ -151,7 +176,7 @@ impl Rig { } /// `CastPetAction(slot)`, applied. - fn press(&mut self, slot: u32) { + pub(super) fn press(&mut self, slot: u32) { self.app .world_mut() .run_system_once(move |mut press: PetPress| press.press_slot(slot)) @@ -164,7 +189,7 @@ impl Rig { } /// `GetPetActionCooldown(slot)`, off what the last feed pushed: `(start, duration, enable)`. - fn cooldown(&mut self, slot: u32) -> (f64, f64, i32) { + pub(super) fn cooldown(&mut self, slot: u32) -> (f64, f64, i32) { self.app .world_mut() .non_send_resource_mut::() @@ -228,8 +253,15 @@ fn a_spell_press_starts_the_pets_gcd_for_every_slot_sharing_its_category() { ); assert!(rig.player_list_untouched(), "list 1 is the pet's, not ours"); - // `0x6e2e8e`: one `PET_BAR_UPDATE_COOLDOWN`, no bar update with it. - assert_eq!(rig.frame(), ["PET_BAR_UPDATE_COOLDOWN"]); + // `0x6e2e77`, `0x6e2e8e`: the flush, in the reference's order, and no bar update with it. + assert_eq!( + rig.frame(), + [ + "ACTIONBAR_UPDATE_COOLDOWN", + "SPELL_UPDATE_COOLDOWN", + "PET_BAR_UPDATE_COOLDOWN" + ] + ); let (start, duration, enable) = rig.cooldown(1); assert!(start > 0.0 && (duration - 1.5).abs() < 1e-9 && enable == 1); let (_, duration, enable) = rig.cooldown(2); diff --git a/crates/benilla-app/src/ui_pet/tests.rs b/crates/benilla-app/src/ui_pet/tests.rs index d0fd53f2..b7b64bc0 100644 --- a/crates/benilla-app/src/ui_pet/tests.rs +++ b/crates/benilla-app/src/ui_pet/tests.rs @@ -761,9 +761,10 @@ fn the_dismiss_word_is_the_carved_literal() { assert!(!entry.is_spell(), "dismiss is a command, never a cast"); } -/// The reference fires `PET_BAR_UPDATE_COOLDOWN` from the pet's cooldown bank (`0x6e2e8e`). +/// A cooldown moves no bar: the feed pushes the new triple and fires nothing, for its events are +/// the list's flush (`0x4b31b0`, `0x4bce90`), which `flush_tests` covers. #[test] -fn a_cooldown_alone_fires_the_cooldown_event_and_not_the_bar_update() { +fn a_cooldown_alone_is_pushed_with_no_event_from_the_bar_feed() { use bevy::prelude::*; use std::collections::HashMap; const CLAW: u32 = 3010; @@ -821,8 +822,8 @@ fn a_cooldown_alone_fires_the_cooldown_event_and_not_the_bar_update() { .start_spell(CLAW, &claw(), 0, std::time::Instant::now(), None); assert_eq!( seen(&mut app), - vec!["PET_BAR_UPDATE_COOLDOWN".to_string()], - "a cooldown alone is the bank's edge, not the bar's" + Vec::::new(), + "a cooldown alone is neither the bar's event nor this feed's" ); let (start, duration, enable): (f64, f64, i32) = app .world_mut() diff --git a/crates/benilla-app/src/ui_pet_book.rs b/crates/benilla-app/src/ui_pet_book.rs index 6b2124e2..5da56efd 100644 --- a/crates/benilla-app/src/ui_pet_book.rs +++ b/crates/benilla-app/src/ui_pet_book.rs @@ -50,11 +50,11 @@ impl Plugin for UiPetBookPlugin { /// What the feed last pushed, so `SPELLS_CHANGED` fires only on a real change. #[derive(Default)] -struct FeedMemory { +pub(crate) struct FeedMemory { pushed: PetBookState, } -fn feed_pet_book( +pub(crate) fn feed_pet_book( script: Option>, bar: Res, spells: Option>, @@ -117,16 +117,18 @@ fn feed_pet_book( script.set_pet_book(fresh.clone()); memory.pushed = fresh; // One `SPELLS_CHANGED` for both books off the shared re-sort (`0x4b2fd0` tail-jumps - // `SignalEvent(0x104)`). Cooldown and autocast repaint the pet page through - // `PET_BAR_UPDATE` (`SpellBookFrame.lua:214`, `227-231`), which `ui_pet`'s feed fires. + // `SignalEvent(0x104)`). 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`), which `ui_pet`'s feed fires. if changed { script.fire_event("SPELLS_CHANGED", vec![]); } } } -/// Whether the book itself changed; a slot's cooldown, autocast or ring reaches the buttons -/// through `PET_BAR_UPDATE` instead. +/// Whether the book itself changed; a slot's cooldown reaches the buttons through the flush's +/// `SPELL_UPDATE_COOLDOWN`, its autocast and ring through `PET_BAR_UPDATE`, instead. fn book_changed(fresh: &PetBookState, old: &PetBookState) -> bool { fresh.token != old.token || fresh.slots.len() != old.slots.len()