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:
co-authored by
Claude Opus 5.5
parent
66340800a9
commit
0e04e54fbf
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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| {
|
||||
|
||||
Reference in New Issue
Block a user