pet bar: a spell press with no Spell.dbc record, no active player object or no pet object sends nothing and arms nothing, as the spell arm's exits do (0x4bd2e7-0x4bd34f) (#660)
A pet-bar spell press sent `CMSG_PET_ACTION` even when the spell's `Spell.dbc` record or the pet object did not resolve. So with the pet out of the client's view, which leaves the bar standing on its guid from `SMSG_PET_SPELLS`, a press still made the server cast. In the reference the press does nothing there. **The reference.** The pet bar's spell arm (types 1-5 through the jump table at `0x4bd4d0`, the arm at `0x4bd240`) first handles the aura cancel (`0x4bd242`-`0x4bd2ad`). It then leaves for the epilogue `0x4bd4c6`, with no GCD and no send, in any of these cases: - the id is past `[0xc0d78c]` (`0x4bd2e7`-`0x4bd2ed`); - the id has no record (`0x4bd2f3`-`0x4bd2fe`); - the active player does not resolve (`0x4bd304`-`0x4bd324`); - the pet guid `[0xb714a0]` does not resolve to a unit (`0x4bd346`-`0x4bd34f`). The reaction arm (`0x4bd391`) and the command arm (`0x4bd3a3`) take none of these exits. **Changes.** - `PetPress::press` returns, with no send, no latch and no GCD, from a spell-slot press whose record, player object or pet object is unresolved. The exit comes after the aura cancel, as in the reference. - `arm_pet_gcd` is now called only for a resolved press, so the two guards are one. **Tests** (`ui_pet/press_tests.rs`): - a press with no pet object, no catalog row or no player object sends nothing; - the aura cancel needs no player object; - Follow, Stay and a reaction still send with the pet unheld; - Attack still sends at the selection with the pet unheld. Fixes #638 🤖 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
6a36d8c351
commit
9d4b3420f3
@@ -95,6 +95,24 @@ impl PetPress<'_, '_> {
|
||||
.send(ClientCommand::PetCancelAura { pet_guid, spell_id });
|
||||
return;
|
||||
}
|
||||
// The spell arm leaves for the epilogue, before its GCD and its send, unless the slot's
|
||||
// `Spell.dbc` record, the active player and the pet's object all resolve (`0x4bd2e7`-
|
||||
// `0x4bd2fe`, `0x4bd31a`-`0x4bd324`, `0x4bd346`-`0x4bd34f`); an id past the table's
|
||||
// maximum has no record. Commands and reactions have no such exit (`0x4bd391`, `0x4bd3a3`).
|
||||
let spell = if entry.is_spell() {
|
||||
let Some(spell) =
|
||||
display.filter(|_| pet_store.is_some() && pet.player_store().is_some())
|
||||
else {
|
||||
debug!(
|
||||
"ui_pet: slot {slot} (spell {}) has no record, player or pet object — no packet",
|
||||
entry.action()
|
||||
);
|
||||
return;
|
||||
};
|
||||
Some(spell)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Only Attack runs `0x612df0`, the attack validator with the pet as actor, whose target
|
||||
// pick can move the selection; every other press sends the selection as is (`0x4bd212`).
|
||||
let mut target_guid = selection.guid.unwrap_or(0);
|
||||
@@ -114,15 +132,16 @@ 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(),
|
||||
);
|
||||
if let Some(spell) = spell {
|
||||
arm_pet_gcd(
|
||||
bar,
|
||||
entry.action(),
|
||||
spell,
|
||||
possessing,
|
||||
spell_mods,
|
||||
Instant::now(),
|
||||
);
|
||||
}
|
||||
debug!(
|
||||
"ui_pet: press slot {slot} (action {} kind {:#04x}) at {target_guid:#x}",
|
||||
entry.action(),
|
||||
@@ -136,27 +155,23 @@ 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 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`).
|
||||
/// The spell arm's tail (`0x4bd355`-`0x4bd36e`) for a press whose record, player and pet object
|
||||
/// resolved: one 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 insert moves the list's generation,
|
||||
/// which carries the flush that follows it (`0x6e2e77`, `0x6e2e8e`) to
|
||||
/// `bar::fire_pet_cooldown_events`. 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,
|
||||
display: Option<&benilla_formats::SpellDisplay>,
|
||||
pet_resolved: bool,
|
||||
spell: &benilla_formats::SpellDisplay,
|
||||
possessing: bool,
|
||||
mods: &crate::spell::SpellModifiers,
|
||||
now: Instant,
|
||||
) {
|
||||
let Some(spell) = display.filter(|_| pet_resolved && !possessing) else {
|
||||
return;
|
||||
};
|
||||
if !spell.allows_client_targeting() {
|
||||
if !possessing && !spell.allows_client_targeting() {
|
||||
bar.cooldowns.start_gcd(spell_id, spell, now, mods);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,12 @@ impl PetUnit<'_, '_> {
|
||||
self.stores.get(e).ok()
|
||||
}
|
||||
|
||||
/// The active player's descriptor, `None` while its object is not streamed: the lookup the
|
||||
/// pet bar's spell arm makes on the client's player guid (`0x4bd31a`, typemask `0x10`).
|
||||
pub(crate) fn player_store(&self) -> Option<&ObjectStore> {
|
||||
self.store(self.self_guid.0?)
|
||||
}
|
||||
|
||||
/// The pet's entity under [`Self::store`]'s contract, for the pet paper doll's model.
|
||||
pub(crate) fn entity(&self, pet_guid: u64) -> Option<Entity> {
|
||||
let e = *self.index.0.get(&pet_guid)?;
|
||||
|
||||
@@ -11,9 +11,12 @@ use bevy::prelude::*;
|
||||
use crossbeam_channel::Receiver;
|
||||
|
||||
use benilla_formats::{SpellCatalog, SpellDisplay};
|
||||
use benilla_protocol::messages::{PetActionEntry, PET_ACT_ENABLED};
|
||||
use benilla_protocol::messages::{
|
||||
PetActionEntry, PET_ACT_COMMAND, PET_ACT_ENABLED, PET_ACT_REACTION, PET_COMMAND_ATTACK,
|
||||
PET_COMMAND_FOLLOW, PET_COMMAND_STAY, PET_REACT_AGGRESSIVE,
|
||||
};
|
||||
|
||||
use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfGuid};
|
||||
use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfGuid, SelfPlayer};
|
||||
use crate::spell::Cooldowns;
|
||||
use crate::ui_action::Spells;
|
||||
|
||||
@@ -83,6 +86,15 @@ pub(super) fn owned_pet() -> ObjectStore {
|
||||
]))
|
||||
}
|
||||
|
||||
/// Our own player: `OBJECT_FIELD_TYPE` 0x19, alive.
|
||||
fn player() -> ObjectStore {
|
||||
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
|
||||
(2, 0x19),
|
||||
(22, 100),
|
||||
(28, 100),
|
||||
]))
|
||||
}
|
||||
|
||||
pub(super) struct Rig {
|
||||
pub(super) app: App,
|
||||
commands: Receiver<ClientCommand>,
|
||||
@@ -127,6 +139,11 @@ pub(super) fn rig(
|
||||
app.insert_resource(NetCommands(tx));
|
||||
let world = app.world_mut();
|
||||
world.resource_mut::<SelfGuid>().0 = Some(ME);
|
||||
// Our own object, which a spell press needs to resolve (`0x4bd31a`).
|
||||
let me = world
|
||||
.spawn((Guid(ME), SelfPlayer, Transform::default(), player()))
|
||||
.id();
|
||||
world.resource_mut::<GuidIndex>().0.insert(ME, me);
|
||||
if let Some(store) = pet {
|
||||
let entity = world.spawn((Guid(PET), store)).id();
|
||||
world.resource_mut::<GuidIndex>().0.insert(PET, entity);
|
||||
@@ -353,15 +370,136 @@ fn an_aura_cancel_press_starts_no_pet_gcd() {
|
||||
assert_eq!(rig.pet_reads(COWER, &cower()).1, 1500);
|
||||
}
|
||||
|
||||
/// `0x4bd34f`: the arm leaves before the GCD when the pet object does not resolve.
|
||||
/// What a press latched: the mode word, its signal count and the attack latch.
|
||||
fn latched(rig: &Rig) -> (u32, u32, bool) {
|
||||
let bar = rig.app.world().resource::<PetBar>();
|
||||
(bar.spells.state, bar.bar_signals, bar.attacking)
|
||||
}
|
||||
|
||||
/// Everything a refused spell press leaves untouched: no command on the wire, nothing latched, no
|
||||
/// GCD on the pet's list, no event.
|
||||
fn assert_press_refused(rig: &mut Rig, before: (u32, u32, bool)) {
|
||||
assert!(rig.sent().is_empty(), "no packet of any kind");
|
||||
assert_eq!(latched(rig), before, "nothing latched");
|
||||
assert!(rig.pet_list_untouched(), "no GCD armed");
|
||||
assert!(rig.frame().is_empty(), "no event");
|
||||
}
|
||||
|
||||
/// `0x4bd346`-`0x4bd34f`: the arm leaves for the epilogue, before its GCD and its send, when the
|
||||
/// pet's object does not resolve, as it is while the pet is out of view and the bar stands on its
|
||||
/// guid.
|
||||
#[test]
|
||||
fn a_press_with_no_pet_object_starts_no_pet_gcd() {
|
||||
fn a_spell_press_with_no_pet_object_sends_nothing_and_arms_nothing() {
|
||||
let mut rig = rig(vec![(CLAW, claw())], &[CLAW], None);
|
||||
let before = latched(&rig);
|
||||
|
||||
rig.press(1);
|
||||
|
||||
assert!(rig.pet_list_untouched());
|
||||
assert!(rig.frame().is_empty());
|
||||
assert_press_refused(&mut rig, before);
|
||||
}
|
||||
|
||||
/// `0x4bd2e7`-`0x4bd2fe`: an id past the `Spell.dbc` table's maximum, or one inside it with no
|
||||
/// record, leaves the arm the same way; so does the unused slot's spell 0, which has none.
|
||||
#[test]
|
||||
fn a_spell_press_with_no_catalog_row_sends_nothing() {
|
||||
// Slots 1-3: a row the catalog lacks, the unused slot's spell 0, and the top of the id range.
|
||||
// Slot 4: the control, whose row it holds.
|
||||
let mut rig = rig(
|
||||
vec![(BITE, bite())],
|
||||
&[CLAW, 0, u32::from(u16::MAX), BITE],
|
||||
Some(owned_pet()),
|
||||
);
|
||||
let before = latched(&rig);
|
||||
|
||||
for slot in 1..=3 {
|
||||
rig.press(slot);
|
||||
assert_press_refused(&mut rig, before);
|
||||
}
|
||||
|
||||
rig.press(4);
|
||||
assert_eq!(pet_actions(&rig.sent()), 1, "the control sends");
|
||||
}
|
||||
|
||||
/// `0x4bd31a`-`0x4bd324`: the arm also needs the active player's object, between the record and
|
||||
/// the pet.
|
||||
#[test]
|
||||
fn a_spell_press_with_no_player_object_sends_nothing() {
|
||||
let mut rig = rig(vec![(CLAW, claw())], &[CLAW], Some(owned_pet()));
|
||||
rig.app
|
||||
.world_mut()
|
||||
.resource_mut::<GuidIndex>()
|
||||
.0
|
||||
.remove(&ME);
|
||||
let before = latched(&rig);
|
||||
|
||||
rig.press(1);
|
||||
|
||||
assert_press_refused(&mut rig, before);
|
||||
}
|
||||
|
||||
/// `0x4bd24a`: the aura leg comes first and needs neither the player nor a record beyond its own,
|
||||
/// so a running aura is cancelled with the player unresolved.
|
||||
#[test]
|
||||
fn an_aura_cancel_press_needs_no_player_object() {
|
||||
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.app
|
||||
.world_mut()
|
||||
.resource_mut::<GuidIndex>()
|
||||
.0
|
||||
.remove(&ME);
|
||||
|
||||
rig.press(1);
|
||||
|
||||
let sent = rig.sent();
|
||||
assert_eq!(pet_actions(&sent), 0);
|
||||
assert!(sent
|
||||
.iter()
|
||||
.any(|c| matches!(c, ClientCommand::PetCancelAura { .. })));
|
||||
}
|
||||
|
||||
/// `0x4bd391`, `0x4bd3a3`: a reaction and the Follow and Stay commands have no exit before the
|
||||
/// send, so with the pet's object unresolved each still latches and sends.
|
||||
#[test]
|
||||
fn a_command_or_reaction_press_with_no_pet_object_still_sends() {
|
||||
let word = |kind: u8, action: u32| action | (u32::from(kind) << 24);
|
||||
let mut rig = rig(vec![(CLAW, claw())], &[CLAW], None);
|
||||
for (packed, state) in [
|
||||
(
|
||||
word(PET_ACT_COMMAND, PET_COMMAND_FOLLOW),
|
||||
PET_COMMAND_FOLLOW << 8,
|
||||
),
|
||||
(
|
||||
word(PET_ACT_COMMAND, PET_COMMAND_STAY),
|
||||
PET_COMMAND_STAY << 8,
|
||||
),
|
||||
(
|
||||
word(PET_ACT_REACTION, PET_REACT_AGGRESSIVE),
|
||||
PET_REACT_AGGRESSIVE,
|
||||
),
|
||||
] {
|
||||
rig.app.world_mut().resource_mut::<PetBar>().spells.bar[0] = PetActionEntry::from(packed);
|
||||
let signals = latched(&rig).1;
|
||||
|
||||
rig.press(1);
|
||||
|
||||
let sent = rig.sent();
|
||||
assert!(
|
||||
matches!(
|
||||
sent.as_slice(),
|
||||
[ClientCommand::PetAction { pet_guid: PET, packed: p, .. }] if *p == packed
|
||||
),
|
||||
"{packed:#010x} sends: {sent:?}"
|
||||
);
|
||||
let (mode, now, _) = latched(&rig);
|
||||
assert_eq!(mode, state, "{packed:#010x} latches");
|
||||
assert_eq!(now, signals + 1, "{packed:#010x} signals the bar");
|
||||
}
|
||||
}
|
||||
|
||||
/// Op 21 applies to `StartRecoveryTime` first (`0x6e2e2c`), the player's table for a pet's
|
||||
@@ -387,3 +525,52 @@ fn the_pets_gcd_takes_the_players_op_21() {
|
||||
|
||||
assert_eq!(rig.pet_reads(CLAW, &shaved()).1, 1000);
|
||||
}
|
||||
|
||||
/// `0x4bd3c3`-`0x4bd405`: the Attack command resolves the pet before it validates, and with none
|
||||
/// jumps to the send (`0x4bd444`), the selection as its target.
|
||||
#[test]
|
||||
fn an_attack_press_with_no_pet_object_still_sends_at_the_selection() {
|
||||
const FOE: u64 = 0x77;
|
||||
let mut rig = rig(vec![(CLAW, claw())], &[CLAW], None);
|
||||
let world = rig.app.world_mut();
|
||||
// A player-controlled attacker, whose `CanAttack` arm needs no faction table.
|
||||
let me = world.resource::<GuidIndex>().0[&ME];
|
||||
world
|
||||
.entity_mut(me)
|
||||
.insert(ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
|
||||
(2, 0x19),
|
||||
(22, 100),
|
||||
(28, 100),
|
||||
(FLAGS, 0x8),
|
||||
])));
|
||||
let foe = world
|
||||
.spawn((
|
||||
Guid(FOE),
|
||||
Transform::default(),
|
||||
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
|
||||
(22, 100),
|
||||
(28, 100),
|
||||
])),
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<GuidIndex>().0.insert(FOE, foe);
|
||||
*world.resource_mut::<crate::target::Selection>() = crate::target::Selection {
|
||||
target: Some(foe),
|
||||
guid: Some(FOE),
|
||||
..Default::default()
|
||||
};
|
||||
let attack = PET_COMMAND_ATTACK | (u32::from(PET_ACT_COMMAND) << 24);
|
||||
world.resource_mut::<PetBar>().spells.bar[0] = PetActionEntry::from(attack);
|
||||
|
||||
rig.press(1);
|
||||
|
||||
let sent = rig.sent();
|
||||
assert!(
|
||||
matches!(
|
||||
sent.as_slice(),
|
||||
[ClientCommand::PetAction { pet_guid: PET, packed, target_guid: FOE }] if *packed == attack
|
||||
),
|
||||
"{sent:?}"
|
||||
);
|
||||
assert!(!latched(&rig).2, "an ordinary pet's Attack raises no latch");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user