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)

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) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-29 22:11:40 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 66340800a9
commit 0e04e54fbf
7 changed files with 463 additions and 2 deletions
+3 -2
View File
@@ -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.
+39
View File
@@ -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<Res<'w, Spells>>,
/// 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(
+2
View File
@@ -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;
@@ -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<ClientCommand>,
/// 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<ObjectStore>) -> 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::<SelfGuid>().0 = Some(ME);
if let Some(store) = pet {
let entity = world.spawn((Guid(PET), store)).id();
world.resource_mut::<GuidIndex>().0.insert(PET, entity);
}
world.insert_resource(Spells {
catalog: SpellCatalog::from_displays(catalog.into_iter().collect::<HashMap<_, _>>()),
..Spells::empty_for_tests()
});
let mut bar = world.resource_mut::<PetBar>();
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<String> {
self.feed.run(self.app.world_mut());
let mut script = self
.app
.world_mut()
.non_send_resource_mut::<benilla_ui::script::UiScript>();
script.resolve();
let seen = script.eval::<Vec<String>>("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<ClientCommand> {
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::<benilla_ui::script::UiScript>()
.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::<PetBar>().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::<PetBar>().cooldowns.generation == 0
}
/// Whether the player's own list was touched at all.
fn player_list_untouched(&self) -> bool {
self.app.world().resource::<Cooldowns>().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::<crate::spell::SpellModifiers>();
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);
}
@@ -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());
}
}
@@ -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 {
+8
View File
@@ -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<SpellCatalog> {
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| {