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:
Sam Sung
2026-09-30 05:49:04 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 6a36d8c351
commit 9d4b3420f3
3 changed files with 237 additions and 29 deletions
+38 -23
View File
@@ -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);
}
}
+6
View File
@@ -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)?;
+193 -6
View File
@@ -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");
}