pet cooldown list: every mutation fires ACTIONBAR_UPDATE_COOLDOWN, SPELL_UPDATE_COOLDOWN and PET_BAR_UPDATE_COOLDOWN (0x4b31b0, 0x4bce90), and the SMSG_SPELL_GO pet leg no longer signals PET_BAR_UPDATE (#657)

The pet's cooldown list fired the wrong UI events:
- The `SMSG_SPELL_GO` pet leg bumped `PetBar::bar_signals`, so the pet bar feed fired `PET_BAR_UPDATE` instead of `PET_BAR_UPDATE_COOLDOWN`.
- No change to the pet's list ever fired `ACTIONBAR_UPDATE_COOLDOWN` or `SPELL_UPDATE_COOLDOWN`. Those fired only off the player list's generation.

The stock spellbook's buttons refresh on `SPELL_UPDATE_COOLDOWN`, so a pet-bar press's GCD never swept on the spellbook's pet tab.

**In the reference**, every mutation of the pet's list is followed by `0x4b31b0`, which fires `ACTIONBAR_UPDATE_COOLDOWN` (`0xd8`) and then `SPELL_UPDATE_COOLDOWN` (`0x106`), and then by `0x4bce90`, which fires `PET_BAR_UPDATE_COOLDOWN` (`0x162`). The mutations are:

| Mutation | Insert or clear | Flush |
|---|---|---|
| `SMSG_SPELL_GO` pet leg | `0x6e85f7` | `0x6e85fc`, `0x6e8601` |
| `StartGlobalCooldown` on the pet's list | — | `0x6e2e77`, `0x6e2e8e` |
| A pet-addressed `SMSG_SPELL_COOLDOWN` | — | `0x6e95b0`, `0x6e95b9` |
| The cooldown event and clear packets | — | `0x6e3071`-`0x6e3080` |
| `SMSG_COOLDOWN_CHEAT` | — | `0x6e9712`, `0x6e971c` |

`SMSG_PET_SPELLS` seeds the list with no flush (`0x6e2c60`). Its `SetPet` fires `PET_BAR_UPDATE` on every packet (`0x4bc90c`).

**Changes:**
- **The flush.** `fire_pet_cooldown_events` watches the pet list's generation and fires the three events once per frame it moved, after the bar, the book and the player's action state have pushed. It owns the pet list's events.
- **The feed.** `feed_pet_bar` no longer fires the cooldown event itself, so a natural expiry fires nothing, as on the player's list.
- **The `SMSG_SPELL_GO` pet leg** drops its `bar_signals` bump.
- **`SMSG_PET_SPELLS`** bumps `bar_signals` once per packet, as `SetPet` does, and seeds without moving the generation.
- **Teardown** clears the list without resetting its counters, so a new bar never masks the next flush.
- **Docs.** The `Cooldowns` doc names both lists and their owners.

**Tests:**
- `ui_pet/flush_tests.rs` drives the real handlers:
  - the `SMSG_SPELL_GO` trio with no `PET_BAR_UPDATE`;
  - a stranger's pet;
  - the pet-addressed `SMSG_SPELL_COOLDOWN`;
  - the event, clear and cheat packets;
  - one flush per frame;
  - `SMSG_PET_SPELLS` signalling the bar but never flushing;
  - teardown;
  - natural expiry;
  - a fresh VM;
  - the flush's schedule order.
- `a_pet_gcd_sweeps_the_spellbooks_pet_tab_button` loads the default UI, opens the spellbook's pet tab, presses a pet slot and checks `SpellButton1Cooldown` sweeps 1.5 s.
- The press test now asserts the three events.

Fixes #637

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-30 03:57:20 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 840719fc98
commit 79dc262eea
11 changed files with 610 additions and 87 deletions
+22 -5
View File
@@ -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<Record>,
/// 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
+21 -15
View File
@@ -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::<Cooldowns>()),
armed(&world.resource::<crate::ui_pet::PetBar>().cooldowns),
world.resource::<crate::ui_pet::PetBar>().bar_signals,
world
.resource::<crate::ui_pet::PetBar>()
.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
+2 -1
View File
@@ -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;
+36 -6
View File
@@ -29,7 +29,8 @@ pub(super) fn actions_usable(bar: &PetBar, pet_flags: Option<u32>) -> bool {
#[derive(Default)]
pub(super) struct PetBarMemory {
pushed: Option<(u32, bool, bool, bool, Vec<PetActionView>)>,
/// 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<Option<(i64, u32, bool)>>,
}
@@ -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<NonSendMut<UiScript>>,
bar: Res<PetBar>,
@@ -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<Option<(i64, u32, bool)>> = fresh.iter().map(|s| s.cooldown).collect();
let content: Vec<PetActionView> = 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<NonSendMut<UiScript>>,
bar: Res<PetBar>,
mut memory: Local<crate::ui_script::VmMemo<crate::ui_script::gate::Watch>>,
) {
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![]);
}
+5 -4
View File
@@ -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,
@@ -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::<benilla_ui::script::UiScript>()
}
/// 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<SessionEvent>) {
crate::net::handlers::dispatch(self.app.world_mut(), events);
}
fn generation(&self) -> u64 {
self.app.world().resource::<PetBar>().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::<PetBar>().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::<PetBar>().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::<PetBar>().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::<bool>("return SpellBookFrame.bookType == BOOKTYPE_PET")
.unwrap(),
"the pet tab is open"
);
assert_eq!(
s.eval::<String>("return SpellButton1SpellName:GetText()")
.unwrap(),
"Claw"
);
assert!(
!s.eval::<bool>("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::<bool>("return SpellButton1Cooldown:IsShown()")
.unwrap(),
"the GCD sweeps the spellbook's button"
);
assert!(
(s.eval::<f64>("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:?}");
}
+27 -5
View File
@@ -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),
+6 -3
View File
@@ -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<SessionEvent>, mut bar: ResMut<PetBar>) {
*bar = PetBar::default();
bar.clear();
}
fn on_spells(In(ev): In<SessionEvent>, catalog: Option<Res<Spells>>, mut bar: ResMut<PetBar>) {
@@ -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.
+70 -38
View File
@@ -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<ClientCommand>,
/// 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<ObjectStore>) -> Rig {
pub(super) fn rig(
catalog: Vec<(u32, SpellDisplay)>,
slots: &[u32],
pet: Option<ObjectStore>,
) -> 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<ObjectStore
for (i, &spell_id) in slots.iter().enumerate() {
bar.spells.bar[i] = PetActionEntry::from(spell_id | (u32::from(PET_ACT_ENABLED) << 24));
}
let script = benilla_ui::script::UiScript::new().expect("a VM");
script
.run(
r#"
SEEN = {}
local f = CreateFrame("Frame")
f:RegisterEvent("PET_BAR_UPDATE")
f:RegisterEvent("PET_BAR_UPDATE_COOLDOWN")
f:SetScript("OnEvent", function() table.insert(SEEN, event) end)
"#,
)
.expect("the listener registers");
world.insert_non_send_resource(script);
world.insert_non_send_resource(listening_vm());
// The order the plugin gives them: the feeds that push a pet cooldown, then the flush.
let mut feed = Schedule::default();
feed.add_systems(feed_pet_bar);
feed.add_systems(
(
crate::ui_pet_book::feed_pet_book,
feed_pet_bar,
fire_pet_cooldown_events,
)
.chain(),
);
let mut rig = Rig {
app,
commands,
@@ -137,8 +162,8 @@ fn rig(catalog: Vec<(u32, SpellDisplay)>, slots: &[u32], pet: Option<ObjectStore
}
impl Rig {
/// The bar's feed for one frame, and the events it fired.
fn frame(&mut self) -> Vec<String> {
/// The bar's feeds for one frame, and the events they fired.
pub(super) fn frame(&mut self) -> Vec<String> {
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::<benilla_ui::script::UiScript>()
@@ -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);
+5 -4
View File
@@ -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::<String>::new(),
"a cooldown alone is neither the bar's event nor this feed's"
);
let (start, duration, enable): (f64, f64, i32) = app
.world_mut()
+8 -6
View File
@@ -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<NonSendMut<UiScript>>,
bar: Res<PetBar>,
spells: Option<Res<Spells>>,
@@ -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()