From 0e04e54fbff6b49e4d90815d32ff2a2ea8eff4cf Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:11:40 +0700 Subject: [PATCH] pet bar: a spell press starts the pet's global cooldown on the pet's list, as 1.12.1's spell arm does before it sends (#636) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pet-bar spell press armed no global cooldown on the pet's list. Claw or Firebolt sent `CMSG_PET_ACTION`, and no slot on the pet bar swept, so the bar looked ready while the pet could not cast. Only a spell with its own recovery timer, such as Bite, showed a sweep. In the reference, the pet bar's spell arm (`0x4bd240` in `0x4bd1d0`) first handles two cases that skip the cooldown: - A spell that is a live aura on the pet sends the cancel and returns. - A possessed unit, or a spell with `AttributesEx4 & 0x20` (`0x4bd355`, `0x4bd35e`), takes the generic cast entry `0x6e4b60`. Every other press calls `StartGlobalCooldown 0x6e2de0(spellId, 1)` (`0x4bd367`-`0x4bd36e`) before the `0x175` send. That call applies the spell's own `StartRecoveryCategory`/`StartRecoveryTime` pair, under spell-mod op 21, into the pet's list (`0xcecaec + 0x18`). This press is the only place the pet's GCD starts, because the `SMSG_SPELL_GO` pet leg inserts none (`0x6e85f7`). Changes: - `SpellDisplay` reads `AttributesEx4` (column 10). - `PetPress::press` arms the pet list's GCD through the existing `Cooldowns::start_gcd`, with the player's modifier tables. It skips the arm for the cases above and for an unresolved record or pet object (`0x4bd2fe`, `0x4bd34f`). - The feed's cooldown diff then fires `PET_BAR_UPDATE_COOLDOWN`. In the shipped `Spell.dbc` the pair is 133/1500 for most pet spells (later Claw ranks, Bite, Lash of Pain, Torment, Suffering) and 133/1000 for Firebolt and Claw rank 1. A real-data test pins both values. Tests: - `ui_pet/press_tests.rs` presses through `PetPress::press_slot` and covers: - the GCD across the slots sharing the category, read back through `GetPetActionCooldown`; - no pair; - possessed; - `AttributesEx4 & 0x20`; - the aura cancel; - no pet object; - op 21. - `real_pet_spells_carry_the_gcd_pair_and_ex4_reads_column_10` reads the real `Spell.dbc`. Fixes #589 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-app/src/spell/cooldowns.rs | 5 +- crates/benilla-app/src/ui_pet/drain.rs | 39 ++ crates/benilla-app/src/ui_pet/mod.rs | 2 + crates/benilla-app/src/ui_pet/press_tests.rs | 357 ++++++++++++++++++ .../src/spells/catalog_tests.rs | 45 +++ crates/benilla-formats/src/spells/display.rs | 9 + crates/benilla-formats/src/spells/mod.rs | 8 + 7 files changed, 463 insertions(+), 2 deletions(-) create mode 100644 crates/benilla-app/src/ui_pet/press_tests.rs diff --git a/crates/benilla-app/src/spell/cooldowns.rs b/crates/benilla-app/src/spell/cooldowns.rs index 3d319290..4dd60226 100644 --- a/crates/benilla-app/src/spell/cooldowns.rs +++ b/crates/benilla-app/src/spell/cooldowns.rs @@ -260,8 +260,9 @@ impl Cooldowns { ); } - /// Arm the GCD at cast send (`StartGlobalCooldown 0x6e2de0`, from `0x6e58fb`) for any cast, - /// item use or pet cast, unless `startRecoveryCategory` and `startRecoveryTime` are both 0 + /// Arm the GCD at cast send (`StartGlobalCooldown 0x6e2de0`, from `0x6e58fb`) for any cast or + /// item use, and at a pet-bar spell press (`0x4bd367`, into the pet's list), unless + /// `startRecoveryCategory` and `startRecoveryTime` are both 0 /// (`0x6e2e0f`-`0x6e2e21`), then with op 21 applied (`0x6e2e2c`) and nothing armed for a zero /// result (`0x6e2e3d`). A negative one arms nothing either, where the reference inserts a /// record no reader shows. `on_hold` is Attributes bit 25. diff --git a/crates/benilla-app/src/ui_pet/drain.rs b/crates/benilla-app/src/ui_pet/drain.rs index 4caa30c5..931b69f5 100644 --- a/crates/benilla-app/src/ui_pet/drain.rs +++ b/crates/benilla-app/src/ui_pet/drain.rs @@ -3,6 +3,8 @@ //! (`PetHandler.cpp:203-300`), and sends `SMSG_PET_MODE` only from `Pet::SetEnabled` //! (`Pet.cpp:2362-2377`), so the client applies them itself until the next `SMSG_PET_SPELLS`. +use std::time::Instant; + use bevy::prelude::*; use benilla_protocol::messages::{ @@ -30,6 +32,9 @@ pub(crate) struct PetPress<'w, 's> { pub(crate) commands: Res<'w, NetCommands>, pub(crate) pet: PetUnit<'w, 's>, pub(crate) spells: Option>, + /// The talent modifiers the pet GCD's op 21 goes through: the player's tables, which gate on + /// the spell's family and never on the caster (`0x6e6b30`). + spell_mods: Res<'w, crate::spell::SpellModifiers>, ui_errors: ResMut<'w, crate::ui_action::UiErrorKeys>, pick: crate::target::AttackPick<'w, 's>, seam: crate::creature_anim::AttackSeam<'w, 's>, @@ -69,6 +74,7 @@ impl PetPress<'_, '_> { commands, pet, spells, + spell_mods, ui_errors, pick, seam, @@ -112,6 +118,15 @@ impl PetPress<'_, '_> { debug!("ui_pet: slot {slot} refused by the attack validator — no packet"); return; } + arm_pet_gcd( + bar, + entry.action(), + display, + pet_store.is_some(), + possessing, + spell_mods, + Instant::now(), + ); debug!( "ui_pet: press slot {slot} (action {} kind {:#04x}) at {target_guid:#x}", entry.action(), @@ -125,6 +140,30 @@ 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`). +pub(super) fn arm_pet_gcd( + bar: &mut PetBar, + spell_id: u32, + display: Option<&benilla_formats::SpellDisplay>, + pet_resolved: bool, + possessing: bool, + mods: &crate::spell::SpellModifiers, + now: Instant, +) { + let Some(spell) = display.filter(|_| pet_resolved && !possessing) else { + return; + }; + if !spell.allows_client_targeting() { + bar.cooldowns.start_gcd(spell_id, spell, now, mods); + } +} + /// Send the bar's other intents under [`PetBar`]'s pet guid: the autocast toggles, the stops and /// the drag writes. pub(super) fn drain_pet_actions( diff --git a/crates/benilla-app/src/ui_pet/mod.rs b/crates/benilla-app/src/ui_pet/mod.rs index adc3f61d..f0823354 100644 --- a/crates/benilla-app/src/ui_pet/mod.rs +++ b/crates/benilla-app/src/ui_pet/mod.rs @@ -28,6 +28,8 @@ pub(crate) use drain::PetPress; use menu::{drain_pet_menu, feed_pet_menu}; use unit::feed_pet_unit; +#[cfg(test)] +mod press_tests; #[cfg(test)] mod tests; diff --git a/crates/benilla-app/src/ui_pet/press_tests.rs b/crates/benilla-app/src/ui_pet/press_tests.rs new file mode 100644 index 00000000..e530bf12 --- /dev/null +++ b/crates/benilla-app/src/ui_pet/press_tests.rs @@ -0,0 +1,357 @@ +//! 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. + +use std::collections::HashMap; +use std::time::Instant; + +use bevy::ecs::schedule::Schedule; +use bevy::ecs::system::RunSystemOnce; +use bevy::prelude::*; +use crossbeam_channel::Receiver; + +use benilla_formats::{SpellCatalog, SpellDisplay}; +use benilla_protocol::messages::{PetActionEntry, PET_ACT_ENABLED}; + +use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfGuid}; +use crate::spell::Cooldowns; +use crate::ui_action::Spells; + +use super::bar::feed_pet_bar; +use super::drain::PetPress; +use super::PetBar; + +const ME: u64 = 0x10; +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; +/// `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; + +/// Claw's shape: no timer of its own, and the ordinary pet GCD, category 133 for 1500 ms. +fn claw() -> SpellDisplay { + SpellDisplay { + name: "Claw".into(), + start_recovery_category: 133, + start_recovery_ms: 1500, + ..Default::default() + } +} + +/// Bite's shape: the same GCD pair, and a 10 s category timer of its own. +fn bite() -> SpellDisplay { + SpellDisplay { + name: "Bite".into(), + category: 19, + category_recovery_ms: 10_000, + ..claw() + } +} + +/// Growl's shape: a 5 s category timer and no GCD pair. +fn growl() -> SpellDisplay { + SpellDisplay { + name: "Growl".into(), + category: 82, + category_recovery_ms: 5_000, + ..Default::default() + } +} + +/// Cower's shape here: a toggle with an active icon, so a press with its aura up cancels it. +fn cower() -> SpellDisplay { + SpellDisplay { + name: "Cower".into(), + active_icon_id: 122, + ..claw() + } +} + +/// Our own pet: `CREATEDBY` us, no flags. +fn owned_pet() -> ObjectStore { + ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ + (CREATEDBY, ME as u32), + (CREATEDBY + 1, 0), + ])) +} + +struct Rig { + app: App, + commands: Receiver, + /// The bar's feed as a schedule of its own, so its `Local` memory lives across runs. + feed: Schedule, +} + +/// 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 { + let mut app = crate::game_plugins::schedule_tests::headless_client(); + let (tx, commands) = crossbeam_channel::unbounded(); + app.insert_resource(NetCommands(tx)); + let world = app.world_mut(); + world.resource_mut::().0 = Some(ME); + if let Some(store) = pet { + let entity = world.spawn((Guid(PET), store)).id(); + world.resource_mut::().0.insert(PET, entity); + } + world.insert_resource(Spells { + catalog: SpellCatalog::from_displays(catalog.into_iter().collect::>()), + ..Spells::empty_for_tests() + }); + let mut bar = world.resource_mut::(); + bar.spells.pet_guid = PET; + 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); + let mut feed = Schedule::default(); + feed.add_systems(feed_pet_bar); + let mut rig = Rig { + app, + commands, + feed, + }; + // The bar arriving is `PET_BAR_UPDATE`'s; every test reads what a press adds to it. + assert_eq!(rig.frame(), ["PET_BAR_UPDATE"], "the bar arrives"); + rig +} + +impl Rig { + /// The bar's feed for one frame, and the events it fired. + fn frame(&mut self) -> Vec { + self.feed.run(self.app.world_mut()); + let mut script = self + .app + .world_mut() + .non_send_resource_mut::(); + script.resolve(); + let seen = script.eval::>("return SEEN").unwrap(); + script.run("SEEN = {}").unwrap(); + seen + } + + /// `CastPetAction(slot)`, applied. + fn press(&mut self, slot: u32) { + self.app + .world_mut() + .run_system_once(move |mut press: PetPress| press.press_slot(slot)) + .expect("the press applies as a one-shot system"); + } + + /// The commands sent so far. + fn sent(&self) -> Vec { + self.commands.try_iter().collect() + } + + /// `GetPetActionCooldown(slot)`, off what the last feed pushed: `(start, duration, enable)`. + fn cooldown(&mut self, slot: u32) -> (f64, f64, i32) { + self.app + .world_mut() + .non_send_resource_mut::() + .eval(&format!("return GetPetActionCooldown({slot})")) + .unwrap() + } + + /// What the pet's own list reads for `spell_id`, as the bar's reader asks it. + fn pet_reads(&self, spell_id: u32, spell: &SpellDisplay) -> (u32, u32) { + let info = self.app.world().resource::().cooldowns.info( + spell_id, + 0, + Some(spell), + Instant::now(), + ); + (info.remaining_ms, info.duration_ms) + } + + /// Whether the pet's own list took no record at all, whichever category a reader asks in. + fn pet_list_untouched(&self) -> bool { + self.app.world().resource::().cooldowns.generation == 0 + } + + /// Whether the player's own list was touched at all. + fn player_list_untouched(&self) -> bool { + self.app.world().resource::().generation == 0 + } +} + +fn pet_actions(sent: &[ClientCommand]) -> usize { + sent.iter() + .filter(|c| matches!(c, ClientCommand::PetAction { .. })) + .count() +} + +/// `0x4bd367`: the press starts the GCD on the pet's list before it sends, and every slot on the +/// bar sharing the category reads it through `GetPetActionCooldown`. +#[test] +fn a_spell_press_starts_the_pets_gcd_for_every_slot_sharing_its_category() { + let mut rig = rig( + vec![(CLAW, claw()), (BITE, bite()), (GROWL, growl())], + &[CLAW, BITE, GROWL], + Some(owned_pet()), + ); + + rig.press(1); + + assert_eq!(pet_actions(&rig.sent()), 1, "and the press still sends"); + let (remaining, duration) = rig.pet_reads(CLAW, &claw()); + assert_eq!(duration, 1500, "the spell's StartRecoveryTime"); + assert!(remaining > 0 && remaining <= 1500, "running: {remaining}"); + assert_eq!( + rig.pet_reads(BITE, &bite()).1, + 1500, + "a spell sharing category 133 reads the same GCD, its own timers unstarted" + ); + assert_eq!( + rig.pet_reads(GROWL, &growl()), + (0, 0), + "a spell outside the category reads nothing" + ); + 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"]); + 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); + assert!( + (duration - 1.5).abs() < 1e-9 && enable == 1, + "the second slot sweeps the GCD: {duration}" + ); + assert_eq!(rig.cooldown(3), (0.0, 0.0, 1), "Growl shows no sweep"); +} + +/// `0x6e2e0f`-`0x6e2e21`: a spell with neither `StartRecovery*` field starts nothing. +#[test] +fn a_spell_without_a_gcd_pair_starts_none() { + let free = || SpellDisplay { + start_recovery_category: 0, + start_recovery_ms: 0, + ..claw() + }; + let mut rig = rig(vec![(CLAW, free())], &[CLAW], Some(owned_pet())); + + rig.press(1); + + assert_eq!(pet_actions(&rig.sent()), 1); + assert!(rig.pet_list_untouched()); + assert_eq!(rig.pet_reads(CLAW, &claw()), (0, 0)); + assert!(rig.frame().is_empty(), "no cooldown edge either"); +} + +/// `0x4bd35e`-`0x4bd365`: a possessed unit's press returns into the generic cast entry +/// (`0x6e4b60`), which starts its own GCD at its send (`0x6e58fb`), so this arm starts none. +#[test] +fn a_possessed_units_press_starts_no_pet_gcd() { + let charmed = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ + (CHARMEDBY, ME as u32), + (CHARMEDBY + 1, 0), + (FLAGS, super::drain::UNIT_FLAG_POSSESSED), + ])); + let mut rig = rig(vec![(CLAW, claw())], &[CLAW], Some(charmed)); + + rig.press(1); + + assert!(rig.pet_list_untouched()); + assert!(rig.frame().is_empty()); +} + +/// `0x4bd355`: `AttributesEx4 & 0x20` takes the same route out of the arm. +#[test] +fn a_client_targeted_spell_press_starts_no_pet_gcd() { + let ground = || SpellDisplay { + attributes_ex4: 0x20, + ..claw() + }; + let mut rig = rig(vec![(CLAW, ground())], &[CLAW], Some(owned_pet())); + + rig.press(1); + + assert!(rig.pet_list_untouched()); + assert!(rig.frame().is_empty()); +} + +/// `0x4bd24f`: a press on a running aura cancels it and returns before the arm. +#[test] +fn an_aura_cancel_press_starts_no_pet_gcd() { + let running = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ + (CREATEDBY, ME as u32), + (CREATEDBY + 1, 0), + (AURA, COWER), + (AURAFLAGS, 0x3), + ])); + let mut rig = rig(vec![(COWER, cower())], &[COWER], Some(running)); + + rig.press(1); + + let sent = rig.sent(); + assert_eq!(pet_actions(&sent), 0, "the cancel sends no pet action"); + assert!(sent.iter().any(|c| matches!( + c, + ClientCommand::PetCancelAura { + spell_id: COWER, + .. + } + ))); + assert!(rig.pet_list_untouched()); + assert!(rig.frame().is_empty()); + + // The same spell with its aura down is an ordinary press, and starts the GCD. + let mut rig = self::rig(vec![(COWER, cower())], &[COWER], Some(owned_pet())); + rig.press(1); + assert_eq!(rig.pet_reads(COWER, &cower()).1, 1500); +} + +/// `0x4bd34f`: the arm leaves before the GCD when the pet object does not resolve. +#[test] +fn a_press_with_no_pet_object_starts_no_pet_gcd() { + let mut rig = rig(vec![(CLAW, claw())], &[CLAW], None); + + rig.press(1); + + assert!(rig.pet_list_untouched()); + assert!(rig.frame().is_empty()); +} + +/// Op 21 applies to `StartRecoveryTime` first (`0x6e2e2c`), the player's table for a pet's +/// spell as for ours. +#[test] +fn the_pets_gcd_takes_the_players_op_21() { + let shaved = || SpellDisplay { + spell_family: 9, + spell_family_flags: 1, + ..claw() + }; + let mut rig = rig(vec![(CLAW, shaved())], &[CLAW], Some(owned_pet())); + { + let mut mods = rig + .app + .world_mut() + .resource_mut::(); + mods.set_class_family(9); + mods.set(true, 0, crate::spell::OP_GCD, -500); + } + + rig.press(1); + + assert_eq!(rig.pet_reads(CLAW, &shaved()).1, 1000); +} diff --git a/crates/benilla-formats/src/spells/catalog_tests.rs b/crates/benilla-formats/src/spells/catalog_tests.rs index efb744c8..8f96c991 100644 --- a/crates/benilla-formats/src/spells/catalog_tests.rs +++ b/crates/benilla-formats/src/spells/catalog_tests.rs @@ -1448,3 +1448,48 @@ fn real_spell_family_columns_carry_the_modifier_gate() { assert_eq!(set, bits, "{id} {:?} family bits", d.name); } } + +/// The pet bar's GCD starts from the pressed spell's `StartRecoveryCategory`/`StartRecoveryTime` +/// pair (`0x6e2de0`), which every learnable pet ability carries as category 133, and its press +/// route branches on `AttributesEx4 & 0x20` (`0x4bd355`), the dword at `+0x28`, column 10. +#[test] +fn real_pet_spells_carry_the_gcd_pair_and_ex4_reads_column_10() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon"); + + // (id, name, startRecoveryCategory, startRecoveryTime): the ranks a pet learns, one per + // ability, with Claw's first rank and every Firebolt rank on 1 s and the rest on 1.5 s. + for (id, name, gcd_category, gcd_ms) in [ + (1082u32, "Claw", 133u32, 1000u32), + (3010, "Claw", 133, 1500), + (17258, "Bite", 133, 1500), + (3110, "Firebolt", 133, 1000), + (11763, "Firebolt", 133, 1000), + (7814, "Lash of Pain", 133, 1500), + (3716, "Torment", 133, 1500), + (17735, "Suffering", 133, 1500), + ] { + let d = cat.get(id).unwrap_or_else(|| panic!("{name} {id}")); + assert_eq!(d.name, name); + assert_eq!( + (d.start_recovery_category, d.start_recovery_ms), + (gcd_category, gcd_ms), + "{name} {id}" + ); + assert!( + !d.allows_client_targeting(), + "{name} {id} is an ordinary press" + ); + } + // Bite's own timer is its category's, beside the GCD. + let bite = cat.get(17258).expect("Bite"); + assert_eq!((bite.category, bite.category_recovery_ms), (19, 10_000)); + + // The rows with the bit are Flamestrike and Rain of Fire, the ground-targeted casts. + for id in [11829u32, 19474] { + let d = cat.get(id).expect("a ground-targeted row"); + assert_eq!(d.attributes_ex4, 0x20, "{} {id}", d.name); + assert!(d.allows_client_targeting()); + } +} diff --git a/crates/benilla-formats/src/spells/display.rs b/crates/benilla-formats/src/spells/display.rs index 2f60e817..fd7e2761 100644 --- a/crates/benilla-formats/src/spells/display.rs +++ b/crates/benilla-formats/src/spells/display.rs @@ -38,6 +38,8 @@ pub struct SpellDisplay { /// `AttributesEx3` (column 9). Bits `0x400` and `0x1000000` limit the equipped-item search to /// the main or the off hand (`0x5f0c50`). pub attributes_ex3: u32, + /// `AttributesEx4` (column 10, `+0x28`). + pub attributes_ex4: u32, /// `SpellFamilyName` (column 160): `GetSpellModifiers` (`0x6e6b30`) applies nothing unless it /// is nonzero and equals the local player's class family (`[0xcecaac]`, `0x6e6b46`). pub spell_family: u32, @@ -208,6 +210,7 @@ impl Default for SpellDisplay { attributes_ex2: 0, modal_next_spell: 0, attributes_ex3: 0, + attributes_ex4: 0, spell_family: 0, spell_family_flags: 0, prevention_type: 0, @@ -355,6 +358,12 @@ impl SpellDisplay { self.attributes_ex2 & ATTR_EX2_AUTO_REPEAT != 0 } + /// `AttributesEx4 & 0x20` (`0x4bd355`): a pet-bar spell press takes the generic cast entry + /// `0x6e4b60` and returns, sending no pet action and starting no GCD of its own. + pub fn allows_client_targeting(&self) -> bool { + self.attributes_ex4 & ATTR_EX4_ALLOW_CLIENT_TARGETING != 0 + } + /// `AttributesEx3 & 0x400000`: a running auto-repeat with this bit ends when any new cast /// begins (`0x60959e`). Only wand Shoot 5019 has it; Auto Shot survives, so hunters weave. pub fn casting_cancels_autorepeat(&self) -> bool { diff --git a/crates/benilla-formats/src/spells/mod.rs b/crates/benilla-formats/src/spells/mod.rs index c1913b85..6dccea1f 100644 --- a/crates/benilla-formats/src/spells/mod.rs +++ b/crates/benilla-formats/src/spells/mod.rs @@ -104,6 +104,9 @@ const COL_ATTRIBUTES: usize = 6; const COL_ATTRIBUTES_EX: usize = 7; const COL_ATTRIBUTES_EX2: usize = 8; const COL_ATTRIBUTES_EX3: usize = 9; +/// `AttributesEx4` (`+0x28`), the dword after `AttributesEx3`: the pet-bar spell arm tests its +/// `0x20` at `0x4bd355`. +const COL_ATTRIBUTES_EX4: usize = 10; const COL_SPEED: usize = 37; const COL_EFFECT_1: usize = 61; /// `EffectMiscValue[0]`, `61 + 15 × 3`: the 16th `[3]` array from `Effect`. @@ -176,6 +179,10 @@ const COL_EFFECT_ITEM_TYPE_1: usize = 103; /// `SPELL_ATTR3_NORMAL_RANGED_ATTACK`: damage floats melee white (`0x6128b0`). const ATTR_EX3_NORMAL_RANGED_ATTACK: u32 = 0x8000; +/// `SPELL_ATTR_EX4_ALLOW_CLIENT_TARGETING` (vmangos `SpellDefines.h:953`): a pet-bar press casts +/// through the generic entry `0x6e4b60` (`0x4bd355`, `0x4bd378`) instead of sending the pet +/// action. +const ATTR_EX4_ALLOW_CLIENT_TARGETING: u32 = 0x20; /// `SPELL_ATTR_EX3_NO_CASTING_BAR_TEXT` (vmangos `SpellDefines.h:907`). const ATTR_EX3_NO_CASTING_BAR_TEXT: u32 = 0x4; /// `AttributesEx3` bit 13: `0x6e7595` tests it as `0x20` in the word's second byte. @@ -485,6 +492,7 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result { attributes_ex: u32_at(r, COL_ATTRIBUTES_EX).unwrap_or(0), attributes_ex2: u32_at(r, COL_ATTRIBUTES_EX2).unwrap_or(0), attributes_ex3: u32_at(r, COL_ATTRIBUTES_EX3).unwrap_or(0), + attributes_ex4: u32_at(r, COL_ATTRIBUTES_EX4).unwrap_or(0), school: u32_at(r, COL_SCHOOL).unwrap_or(0), mechanic: u32_at(r, COL_MECHANIC).unwrap_or(0), effect_mechanic: std::array::from_fn(|i| {