pet bar: 0x4bcf70 is one predicate, asked by GetPetActionsUsable, every press and order, Dismiss, both autocast toggles and the drag's writes, so an unusable pet greys the bar and does nothing (#687)

No pet-bar verb asked whether the pet's actions were usable. So a stunned, feared or confused pet, a pet out of view, a pet that is not yours, or a charmed player still did all of this:
- pressed, latched Stay, Follow and the reactions, and swept the GCD;
- sent `CMSG_PET_ACTION` and flipped autocast;
- showed `GetPetActionsUsable()` as 1, which kept the stock bar in colour.

`0x4bcf70` is the reference's one predicate. It returns 0 in any of these cases:
- the active player does not resolve (`0x4bcf92`);
- the player is charmed (`0x4bcf98`-`0x4bcfa4`);
- the player's far-sight guid (`PLAYER_FARSIGHT`) is set and is not the pet (`0x4bcfbf`-`0x4bd012`);
- the pet guid does not resolve to a unit (`0x4bd034`-`0x4bd03d`);
- the pet's charmed-by guid, else its summoned-by guid, is not the player (`0x4bd03f`-`0x4bd067`);
- the pet is stunned, confused or fleeing (`0x4bd06f`-`0x4bd08b`);
- bar-state bit 27 is set (`0x4bd08d`).

These callers ask it:
- `GetPetActionsUsable` (`0x4be0b3`);
- the dispatcher behind `CastPetAction`, the orders and Dismiss (`0x4bd1f2`, before its type switch);
- `TogglePetAutocast` (`0x4bcbcf`);
- the pet book's toggle (`0x4bccce`);
- the bar write used by the drag's drop and the pickup's blank (`0x4bc9d0`).

Changes:
- `ui_pet/usable.rs` builds `0x4bcf70` once. The bar's feed pushes it as `GetPetActionsUsable`. `PetPress::press` asks it first, before the aura cancel.
- Both autocast drains, Dismiss and the drag's two writes ask it too. A pickup still lifts the action onto the cursor, as the reference does.
- The spell arm's later player and pet lookups, and the Attack arm's null branch, repeat the predicate's, so their dead handling goes.
- `arm_pet_gcd`'s doc now names the three places the pet's GCD starts: the press (`0x4bd36e`), a pet-addressed `SMSG_SPELL_COOLDOWN` (`0x6e9598`) and the generic cast send (`0x6e58fb`).

Tests:
- `ui_pet/usable_tests.rs` tries each of 11 exits alone, and 5 usable lookalikes, against `GetPetActionsUsable`, a spell press, the orders, both autocast toggles, Dismiss and the drag.
- A stock-UI test loads the default UI. A stunned pet's icon desaturates, and a click sends nothing until the stun ends.
- The three press tests that pinned ungated sends are flipped.

Fixes #672
Fixes #673
Fixes #674

🤖 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 07:45:16 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 8f6d337ad6
commit 7623cbae11
13 changed files with 942 additions and 152 deletions
+2 -1
View File
@@ -352,7 +352,8 @@ pub(crate) fn attack_actor_blocked(
actor: Option<&ObjectStore>,
self_guid: Option<u64>,
) -> Option<&'static str> {
// An unresolved actor skips the chain, as in the reference (`0x4bd403`).
// An unresolved actor skips the chain, as in the reference (`0x4bd403`), though the pet bar's
// own gate (`0x4bd1f2`) never lets an unresolved pet reach it.
let fields = &actor?.0;
// `0x605f30` (`0x612e10`): dead, or a player's ghost flag, since a ghost has health 1.
let key = if fields.is_dead_or_ghost() {
+6 -17
View File
@@ -11,18 +11,11 @@ use benilla_protocol::messages::{
};
use benilla_ui::script::{PetActionView, UiScript};
use crate::net::{GuidIndex, ObjectStore};
use crate::net::ObjectStore;
use crate::target::UNIT_FLAG_POSSESSED;
use crate::ui_action::Spells;
use super::PetBar;
/// `GetPetActionsUsable()` (`0x4bcf70`): false while the state's bit 27 is set or the pet is
/// stunned, confused or fleeing. Its other four steps test ownership, which a held bar implies.
pub(super) fn actions_usable(bar: &PetBar, pet_flags: Option<u32>) -> bool {
!bar.spells.bar_disabled()
&& pet_flags.is_none_or(|f| f & benilla_protocol::messages::PET_UNUSABLE_UNIT_FLAGS == 0)
}
use super::{PetBar, PetUnit};
/// What the feed last pushed, so `PET_BAR_UPDATE` fires on a change; the key's leading `u32` is
/// [`PetBar::bar_signals`], so a press that changes nothing still repaints.
@@ -182,8 +175,7 @@ pub(super) fn feed_pet_bar(
bar: Res<PetBar>,
spells: Option<Res<Spells>>,
clock: Res<crate::ui_script::UiClock>,
index: Res<GuidIndex>,
stores: Query<&ObjectStore>,
pet: PetUnit,
mut memory: Local<crate::ui_script::VmMemo<PetBarMemory>>,
) {
let Some(mut script) = script else {
@@ -193,13 +185,10 @@ pub(super) fn feed_pet_bar(
let now = Instant::now();
let (anchor, ui_now) = (clock.anchor, clock.ui_now);
let has_bar = bar.has_bar();
// An unstreamed pet leaves usability to bit 27 alone, and no slot shows active.
let pet_store = index
.0
.get(&bar.spells.pet_guid)
.and_then(|&e| stores.get(e).ok());
// An unstreamed pet is not usable (`0x4bd034`), and no slot shows active.
let pet_store = pet.store(bar.spells.pet_guid);
let pet_flags = pet_store.map(|s| s.0.unit_flags());
let usable = actions_usable(&bar, pet_flags);
let usable = pet.actions_usable(&bar);
// `PickupPetAction`'s gate alone (`0x4be1c1`): a possessed unit's bar cannot be rearranged,
// but its buttons work, so possession stays out of `usable`.
let pickup_allowed = pet_flags.unwrap_or(0) & UNIT_FLAG_POSSESSED == 0;
+37 -22
View File
@@ -62,7 +62,8 @@ impl PetPress<'_, '_> {
/// Send under [`PetBar`]'s pet guid, never one from the VM, so a press queued as the pet
/// leaves dies here. `CMSG_PET_ACTION` echoes the slot's word, which the server dispatches on
/// its type, with our selection as the target; `HandlePetAction` drops a target the spell
/// does not want.
/// does not want. The dispatcher's first act is the usability predicate (`0x4bd1f2`), and a
/// zero answer leaves for the epilogue (`0x4bd1f9`) ahead of everything below it.
fn press(&mut self, slot: u32, entry: PetActionEntry) {
let Self {
bar,
@@ -75,20 +76,19 @@ impl PetPress<'_, '_> {
pick,
seam,
} = self;
let pet_guid = bar.spells.pet_guid;
if pet_guid == 0 {
debug!("ui_pet: dropping a queued pet press — the bar is gone");
let Some(pet_store) = pet.usable_pet(bar) else {
debug!("ui_pet: slot {slot} refused, the pet's actions are not usable — no packet");
return;
}
let pet_store = pet.store(pet_guid);
let possessing = possessing(pet_store, pet.self_guid.0);
};
let pet_guid = bar.spells.pet_guid;
let possessing = possessing(Some(pet_store), pet.self_guid.0);
// A press on a spell the pet is running cancels its aura and sends no `CMSG_PET_ACTION`
// (`0x4bd240`-`0x4bd2ad`); nothing latches, the icon follows the pet's aura field.
let display = entry
.is_spell()
.then(|| spells.as_ref().and_then(|s| s.catalog.get(entry.action())))
.flatten();
if let Some(spell_id) = active_aura_press(entry, pet_store, display) {
if let Some(spell_id) = active_aura_press(entry, Some(pet_store), display) {
debug!("ui_pet: slot {slot} cancels its own aura (spell {spell_id}) — no PetAction");
let _ = commands
.0
@@ -96,15 +96,15 @@ impl PetPress<'_, '_> {
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`).
// `Spell.dbc` record resolves (`0x4bd2e7`-`0x4bd2fe`); an id past the table's maximum has
// none. Its lookups of the active player and the pet (`0x4bd31a`-`0x4bd324`,
// `0x4bd346`-`0x4bd34f`) repeat the predicate's, on the same guids and typemasks, and
// cannot fail past it. Commands and reactions have no exit of their own (`0x4bd391`,
// `0x4bd3a3`).
let spell = if entry.is_spell() {
let Some(spell) =
display.filter(|_| pet_store.is_some() && pet.player_store().is_some())
else {
let Some(spell) = display else {
debug!(
"ui_pet: slot {slot} (spell {}) has no record, player or pet object — no packet",
"ui_pet: slot {slot} (spell {}) has no record — no packet",
entry.action()
);
return;
@@ -117,7 +117,7 @@ impl PetPress<'_, '_> {
// pick can move the selection; every other press sends the selection as is (`0x4bd212`).
let mut target_guid = selection.guid.unwrap_or(0);
let refused = if is_attack_order(entry) {
crate::ui_action::attack_actor_refusal(pet_store, pet.self_guid.0, ui_errors)
crate::ui_action::attack_actor_refusal(Some(pet_store), pet.self_guid.0, ui_errors)
|| match pick.target(None, selection, seam, ui_errors) {
Some(guid) => {
target_guid = guid;
@@ -155,14 +155,20 @@ impl PetPress<'_, '_> {
}
}
/// 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
/// The spell arm's tail (`0x4bd355`-`0x4bd36e`) for a press past the usability gate whose
/// `Spell.dbc` record 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`).
/// `bar::fire_pet_cooldown_events`.
///
/// The pet's GCD starts in three places, and this arm is one. A `SMSG_SPELL_COOLDOWN` addressed
/// to the pet's guid inserts the same pair for every entry whose spell lacks `Attributes` bit 25
/// (`0x6e9553`-`0x6e9598`, `Cooldowns::apply_wire_cooldown`). The generic cast send starts it too
/// (`0x6e58fb`), with the list picked by the caster's charmed-by, else summoned-by, guid
/// (`0x6e58c2`-`0x6e58f3`); the possessed and client-targeted presses above reach it through
/// `0x6e4b60`. 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,
@@ -177,11 +183,14 @@ pub(super) fn arm_pet_gcd(
}
/// Send the bar's other intents under [`PetBar`]'s pet guid: the autocast toggles, the stops and
/// the drag writes.
/// the drag writes. A toggle asks the usability predicate first (`0x4bcbcf`), so on an unusable
/// pet it flips nothing and sends nothing. The stops do not (`0x4bd650`); the drag's writes were
/// gated where the VM applied them (`0x4bc9d0`), on the answer the bar last pushed.
pub(super) fn drain_pet_actions(
script: Option<NonSendMut<UiScript>>,
mut bar: ResMut<PetBar>,
commands: Res<NetCommands>,
pet: PetUnit,
) {
let Some(mut script) = script else {
return;
@@ -201,6 +210,12 @@ pub(super) fn drain_pet_actions(
// (`0x4bcbf7`: any right-click on a token) signals nothing, so after `OnClick`'s
// `SetChecked(0)` that button stays unlit until the next repaint, as in the reference.
for slot in toggles {
if !pet.actions_usable(&bar) {
debug!(
"ui_pet: autocast toggle on slot {slot} refused, the pet's actions are not usable"
);
continue;
}
let Some(flipped) = toggle_slot_autocast(&mut bar, slot) else {
continue;
};
+9 -2
View File
@@ -2,7 +2,8 @@
//! `PetCanBeAbandoned()`, Rename when `PetCanBeRenamed()` too, and Dismiss otherwise. `PetAbandon`
//! (`0x4be4c0`, `0x4bd740`) sends `CMSG_PET_ABANDON`; `PetDismiss` (`0x4be4d0`) sends no packet
//! itself but hands the Dismiss command word to the bar's dispatcher (`0x4bd1d0`), so it leaves as
//! `CMSG_PET_ACTION`. vmangos unsummons a summon on either, so sending the wrong one goes unseen.
//! `CMSG_PET_ACTION`, and only past the dispatcher's usability predicate (`0x4bd1f2`). vmangos
//! unsummons a summon on either, so sending the wrong one goes unseen.
use bevy::prelude::*;
@@ -56,6 +57,7 @@ pub(super) fn drain_pet_menu(
bar: Res<PetBar>,
commands: Res<NetCommands>,
mut ui_errors: ResMut<UiErrorKeys>,
pet: PetUnit,
) {
let Some(mut script) = script else {
return;
@@ -76,9 +78,14 @@ pub(super) fn drain_pet_menu(
debug!("ui_pet: abandoning pet {pet_guid:#x}");
let _ = commands.0.send(ClientCommand::PetAbandon { pet_guid });
}
// `0x4bd6e0` stages `0x07000003` at target 0.
// `0x4bd6e0` stages `0x07000003` at target 0 and enters the dispatcher, whose first act is the
// usability predicate (`0x4bd1f2`): a pet that fails it is not sent the word.
let dismiss_word = PET_COMMAND_DISMISS | (u32::from(PET_ACT_COMMAND) << 24);
for _ in 0..dismisses {
if !pet.actions_usable(&bar) {
debug!("ui_pet: dismiss refused, the pet's actions are not usable — no packet");
continue;
}
debug!("ui_pet: dismissing pet {pet_guid:#x} ({dismiss_word:#010x})");
let _ = commands.0.send(ClientCommand::PetAction {
pet_guid,
+6 -4
View File
@@ -20,6 +20,7 @@ mod drain;
mod menu;
mod net;
mod unit;
mod usable;
use bar::{feed_pet_bar, fire_pet_cooldown_events};
use drain::drain_pet_actions;
@@ -35,6 +36,8 @@ mod flush_tests;
mod press_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod usable_tests;
/// The pet action bar's state. A zero `spells.pet_guid` means no bar: the teardown packet carries
/// only that guid.
@@ -121,14 +124,13 @@ impl Plugin for UiPetPlugin {
}
/// The unit behind [`PetBar`]'s cached guid, plus our own guid for the ownership tests the
/// reference makes before trusting the pet's fields (`0x5ff780`, `0x612e33`).
/// reference makes before trusting the pet's fields (`0x5ff780`, `0x612e33`), and the usability
/// predicate every pet-bar verb the reference guards asks of them ([`PetUnit::usable_pet`]).
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct PetUnit<'w, 's> {
index: Res<'w, GuidIndex>,
stores: Query<'w, 's, &'static ObjectStore>,
self_guid: Res<'w, crate::net::SelfGuid>,
/// The per-field edges, for `fire_transitions`' watch-bridge arms.
pub(super) edges: MessageReader<'w, 's, crate::net::FieldChanged>,
}
impl PetUnit<'_, '_> {
@@ -139,7 +141,7 @@ impl PetUnit<'_, '_> {
}
/// 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`).
/// usability predicate makes on the client's player guid (`0x4bcf89`, typemask `0x10`).
pub(crate) fn player_store(&self) -> Option<&ObjectStore> {
self.store(self.self_guid.0?)
}
+86 -75
View File
@@ -26,13 +26,15 @@ use super::PetBar;
pub(super) const ME: u64 = 0x10;
pub(super) const PET: u64 = 0x2A;
/// `UNIT_FIELD_CHARMEDBY` and `UNIT_FIELD_CREATEDBY`, low words; the high words stay 0.
/// `UNIT_FIELD_CHARMEDBY`, `UNIT_FIELD_SUMMONEDBY` and `UNIT_FIELD_CREATEDBY`, low words; the high
/// words stay 0.
pub(super) const CHARMEDBY: u16 = 10;
pub(super) const SUMMONEDBY: u16 = 12;
pub(super) const CREATEDBY: u16 = 14;
pub(super) 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;
pub(super) const AURA: u16 = 47;
pub(super) const AURAFLAGS: u16 = 95;
pub(super) const CLAW: u32 = 16829;
pub(super) const BITE: u32 = 17258;
@@ -70,7 +72,7 @@ pub(super) fn growl() -> SpellDisplay {
}
/// Cower's shape here: a toggle with an active icon, so a press with its aura up cancels it.
fn cower() -> SpellDisplay {
pub(super) fn cower() -> SpellDisplay {
SpellDisplay {
name: "Cower".into(),
active_icon_id: 122,
@@ -78,28 +80,42 @@ fn cower() -> SpellDisplay {
}
}
/// Our own pet: `CREATEDBY` us, no flags.
/// Our own pet as vmangos fills it, `SUMMONEDBY` and `CREATEDBY` us (`Pet.cpp:264`, `:287`), no
/// flags: the predicate's owner test reads `SUMMONEDBY` (`0x4bd054`).
pub(super) fn owned_pet() -> ObjectStore {
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
owned_pet_with(&[])
}
/// [`owned_pet`] with more fields set, which win over its own.
pub(super) fn owned_pet_with(extra: &[(u16, u32)]) -> ObjectStore {
let mut pairs = vec![
(SUMMONEDBY, ME as u32),
(SUMMONEDBY + 1, 0),
(CREATEDBY, ME as u32),
(CREATEDBY + 1, 0),
]))
];
pairs.retain(|(index, _)| !extra.iter().any(|(set, _)| set == index));
pairs.extend_from_slice(extra);
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&pairs))
}
/// 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) fn player() -> ObjectStore {
player_with(&[])
}
/// [`player`] with more fields set.
pub(super) fn player_with(extra: &[(u16, u32)]) -> ObjectStore {
let mut pairs = vec![(2, 0x19), (22, 100), (28, 100)];
pairs.extend_from_slice(extra);
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&pairs))
}
pub(super) struct Rig {
pub(super) app: App,
commands: Receiver<ClientCommand>,
/// The pet's feeds as a schedule of their own, so their `Local` memory lives across runs.
feed: Schedule,
pub(super) feed: Schedule,
}
/// A VM whose listener records every event the pet's lists fire into `SEEN`, in order.
@@ -200,8 +216,16 @@ impl Rig {
.expect("the press applies as a one-shot system");
}
/// `PetAttack` and the other one-shot orders, as their slot's packed word, applied.
pub(super) fn order(&mut self, packed: u32) {
self.app
.world_mut()
.run_system_once(move |mut press: PetPress| press.order(packed))
.expect("the order applies as a one-shot system");
}
/// The commands sent so far.
fn sent(&self) -> Vec<ClientCommand> {
pub(super) fn sent(&self) -> Vec<ClientCommand> {
self.commands.try_iter().collect()
}
@@ -226,7 +250,7 @@ impl Rig {
}
/// Whether the pet's own list took no record at all, whichever category a reader asks in.
fn pet_list_untouched(&self) -> bool {
pub(super) fn pet_list_untouched(&self) -> bool {
self.app.world().resource::<PetBar>().cooldowns.generation == 0
}
@@ -236,7 +260,7 @@ impl Rig {
}
}
fn pet_actions(sent: &[ClientCommand]) -> usize {
pub(super) fn pet_actions(sent: &[ClientCommand]) -> usize {
sent.iter()
.filter(|c| matches!(c, ClientCommand::PetAction { .. }))
.count()
@@ -342,12 +366,7 @@ fn a_client_targeted_spell_press_starts_no_pet_gcd() {
/// `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 running = owned_pet_with(&[(AURA, COWER), (AURAFLAGS, 0x3)]);
let mut rig = rig(vec![(COWER, cower())], &[COWER], Some(running));
rig.press(1);
@@ -371,23 +390,23 @@ fn an_aura_cancel_press_starts_no_pet_gcd() {
}
/// What a press latched: the mode word, its signal count and the attack latch.
fn latched(rig: &Rig) -> (u32, u32, bool) {
pub(super) 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)) {
pub(super) 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.
/// `0x4bd1f2`: the dispatcher leaves for the epilogue, before the arm, 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_spell_press_with_no_pet_object_sends_nothing_and_arms_nothing() {
let mut rig = rig(vec![(CLAW, claw())], &[CLAW], None);
@@ -420,8 +439,7 @@ fn a_spell_press_with_no_catalog_row_sends_nothing() {
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.
/// `0x4bd1f2`: the predicate also needs the active player's object (`0x4bcf92`).
#[test]
fn a_spell_press_with_no_player_object_sends_nothing() {
let mut rig = rig(vec![(CLAW, claw())], &[CLAW], Some(owned_pet()));
@@ -430,6 +448,8 @@ fn a_spell_press_with_no_player_object_sends_nothing() {
.resource_mut::<GuidIndex>()
.0
.remove(&ME);
// The bar greys as the player leaves, which is its own repaint.
assert_eq!(rig.frame(), ["PET_BAR_UPDATE"]);
let before = latched(&rig);
rig.press(1);
@@ -437,68 +457,43 @@ fn a_spell_press_with_no_player_object_sends_nothing() {
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.
/// `0x4bd1f2`: the aura leg (`0x4bd240`) sits behind the predicate, so with the active player
/// unresolved a running aura is not cancelled either.
#[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),
]));
fn an_aura_cancel_press_with_no_player_object_sends_nothing() {
let running = owned_pet_with(&[(AURA, COWER), (AURAFLAGS, 0x3)]);
let mut rig = rig(vec![(COWER, cower())], &[COWER], Some(running));
rig.app
.world_mut()
.resource_mut::<GuidIndex>()
.0
.remove(&ME);
assert_eq!(rig.frame(), ["PET_BAR_UPDATE"], "the bar greys");
let before = latched(&rig);
rig.press(1);
let sent = rig.sent();
assert_eq!(pet_actions(&sent), 0);
assert!(sent
.iter()
.any(|c| matches!(c, ClientCommand::PetCancelAura { .. })));
assert_press_refused(&mut rig, before);
}
/// `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.
/// `0x4bd1f2`: a reaction and the Follow and Stay commands have no exit of their own (`0x4bd391`,
/// `0x4bd3a3`), only the predicate's, so with the pet's object unresolved none latches or sends.
#[test]
fn a_command_or_reaction_press_with_no_pet_object_still_sends() {
fn a_command_or_reaction_press_with_no_pet_object_sends_nothing() {
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,
),
for packed in [
word(PET_ACT_COMMAND, PET_COMMAND_FOLLOW),
word(PET_ACT_COMMAND, PET_COMMAND_STAY),
word(PET_ACT_REACTION, PET_REACT_AGGRESSIVE),
] {
rig.app.world_mut().resource_mut::<PetBar>().spells.bar[0] = PetActionEntry::from(packed);
let signals = latched(&rig).1;
rig.frame();
let before = latched(&rig);
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");
assert_press_refused(&mut rig, before);
}
}
@@ -526,10 +521,12 @@ 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.
/// `0x4bd1f2`: the Attack command's own null-pet branch (`0x4bd403`-`0x4bd405`) repeats the lookup
/// the predicate made (`0x4bd034`) on the same guid and typemask, so it never runs: with the pet's
/// object unresolved the press leaves at the gate and sends nothing, at the selection or any
/// other target.
#[test]
fn an_attack_press_with_no_pet_object_still_sends_at_the_selection() {
fn an_attack_press_with_no_pet_object_sends_nothing() {
const FOE: u64 = 0x77;
let mut rig = rig(vec![(CLAW, claw())], &[CLAW], None);
let world = rig.app.world_mut();
@@ -561,6 +558,20 @@ fn an_attack_press_with_no_pet_object_still_sends_at_the_selection() {
};
let attack = PET_COMMAND_ATTACK | (u32::from(PET_ACT_COMMAND) << 24);
world.resource_mut::<PetBar>().spells.bar[0] = PetActionEntry::from(attack);
rig.frame();
let before = latched(&rig);
rig.press(1);
assert_press_refused(&mut rig, before);
// The same press with the pet streamed in goes out at the selection, and an ordinary pet's
// Attack raises no latch.
let world = rig.app.world_mut();
let pet = world
.spawn((Guid(PET), owned_pet_with(&[(22, 100), (28, 100)])))
.id();
world.resource_mut::<GuidIndex>().0.insert(PET, pet);
rig.press(1);
+1 -22
View File
@@ -161,28 +161,6 @@ fn a_disabled_bar_reads_passive_and_lights_no_command() {
);
}
#[test]
fn usability_is_the_disabled_bit_and_the_pets_crowd_control() {
let mut bar = PetBar {
spells: state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE),
..Default::default()
};
assert!(actions_usable(&bar, Some(0)));
assert!(
actions_usable(&bar, None),
"a missing descriptor is not a no"
);
for flag in [0x0004_0000, 0x0040_0000, 0x0080_0000] {
assert!(!actions_usable(&bar, Some(flag)), "flag {flag:#x} disables");
}
// Those three are stunned, confused, fleeing; POSSESSED is not among them.
assert!(actions_usable(&bar, Some(0x0100_0000)));
bar.spells.state |= PET_STATE_BAR_DISABLED;
assert!(!actions_usable(&bar, Some(0)));
}
#[test]
fn spell_slots_read_their_autocast_off_bits_31_and_30() {
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
@@ -781,6 +759,7 @@ fn a_cooldown_alone_is_pushed_with_no_event_from_the_bar_feed() {
let mut app = App::new();
app.init_resource::<crate::ui_script::UiClock>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<crate::net::SelfGuid>()
.insert_resource(crate::ui_action::Spells {
catalog: benilla_formats::SpellCatalog::from_displays(HashMap::from([(CLAW, claw())])),
..crate::ui_action::Spells::empty_for_tests()
+4 -2
View File
@@ -39,7 +39,9 @@ pub(super) const UNIT_FLAG_PET_IN_COMBAT: u32 = 0x0000_0800;
pub(super) fn feed_pet_unit(
script: Option<NonSendMut<UiScript>>,
bar: Res<PetBar>,
mut pet: PetUnit,
pet: PetUnit,
// The per-field edges, for `fire_transitions`' watch-bridge arms.
mut field_edges: MessageReader<crate::net::FieldChanged>,
changed_stores: Query<(), Changed<ObjectStore>>,
mut removed_stores: RemovedComponents<ObjectStore>,
mut names: ResMut<NameCache>,
@@ -52,7 +54,7 @@ pub(super) fn feed_pet_unit(
return;
};
let (memory, vm_reset) = memory.get_reset(&script);
let edges = crate::net::FieldEdges::collect(&mut pet.edges);
let edges = crate::net::FieldEdges::collect(&mut field_edges);
let names_moved = memory.names_generation.moved(names.generation());
let bar_changed = bar.is_changed();
let stores_changed = !changed_stores.is_empty();
+74
View File
@@ -0,0 +1,74 @@
//! The pet-usability predicate `0x4bcf70`, asked once and by everything the reference lets it
//! gate: `GetPetActionsUsable` (`0x4be0b3`), the dispatcher behind `CastPetAction` and the orders
//! (`0x4bd1f2`), both autocast toggles (`0x4bcbcf`, `0x4bccce`) and the bar write the drag makes
//! (`0x4bc9d0`, for the drop `0x4bce33` and the pickup's blank `0x4be27f`). A zero answer leaves
//! each of them at its epilogue, before anything is latched, armed, cancelled or sent.
use benilla_protocol::messages::{OwnerFallback, PET_UNUSABLE_UNIT_FLAGS};
use crate::net::ObjectStore;
use super::{PetBar, PetUnit};
/// `0x4bcf70`, every exit in the order the bytes take them. `me` is the client's active player
/// guid (`0x468550`); `player` is that guid's object and `pet` the bar's cached guid's (typemasks
/// `0x10` and `8`), each `None` while it is not streamed.
pub(super) fn actions_usable(
bar: &PetBar,
me: Option<u64>,
player: Option<&ObjectStore>,
pet: Option<&ObjectStore>,
) -> bool {
// `0x4bcf92`: the active player must resolve.
let (Some(me), Some(player)) = (me, player) else {
return false;
};
// `0x4bcf98`-`0x4bcfa4`: a charmed player, whose `UNIT_FIELD_CHARMEDBY` pair is nonzero, has
// no pet bar to work.
if player.0.unit_charmed_by().is_some() {
return false;
}
// `0x4bcfbf`-`0x4bd012`: a view held on some unit, `PLAYER_FARSIGHT` (player field 712, at
// `[[player+0xe68]+0x830]`), must be on the pet: Eyes of the Beast keeps the bar, Far Sight or
// Mind Vision on anything else takes it. The two compares of the player's own guid against the
// active guid (`0x4bcfb7`, `0x4bcfe0`) are the lookup's key against itself, so the branch that
// zeroes the held guid (`0x4bcfe8`) has no way in.
if player
.0
.player_farsight()
.is_some_and(|held| held != bar.spells.pet_guid)
{
return false;
}
// `0x4bd034`-`0x4bd03d`: the cached guid must resolve to a unit.
let Some(pet) = pet else {
return false;
};
// `0x4bd03f`-`0x4bd067`: the pet's charmed-by guid, or its summoned-by guid while that is
// zero, must be the active player's.
if pet.0.unit_owner(OwnerFallback::SummonedBy) != Some(me) {
return false;
}
// `0x4bd06f`-`0x4bd08b`: stunned (`0x40000`), confused (`0x400000`) and fleeing (`0x800000`).
// Health is never read.
if pet.0.unit_flags() & PET_UNUSABLE_UNIT_FLAGS != 0 {
return false;
}
// `0x4bd08d`: bit 27 of the bar state `[0xb71468]`, which only the server writes.
!bar.spells.bar_disabled()
}
impl PetUnit<'_, '_> {
/// [`actions_usable`] for the bar as it stands: the pet's object when the predicate holds,
/// `None` when it does not. A caller past the gate holds the pet the predicate resolved, which
/// the dispatcher's own later lookups of the same guid find again (`0x4bd346`, `0x4bd403`).
pub(crate) fn usable_pet(&self, bar: &PetBar) -> Option<&ObjectStore> {
let pet = self.store(bar.spells.pet_guid);
pet.filter(|_| actions_usable(bar, self.self_guid.0, self.player_store(), pet))
}
/// `GetPetActionsUsable()`, and the gate every pet-bar verb the reference guards asks.
pub(crate) fn actions_usable(&self, bar: &PetBar) -> bool {
self.usable_pet(bar).is_some()
}
}
@@ -0,0 +1,633 @@
//! The pet-usability predicate `0x4bcf70` and every verb it gates, whole client headless: each of
//! its exits alone must grey `GetPetActionsUsable`, and must stop a press, an order, both autocast
//! toggles and the drag's writes, while a pet that trips none of them takes all of it as before.
use bevy::ecs::system::RunSystemOnce;
use bevy::prelude::*;
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, PET_STATE_BAR_DISABLED,
};
use crate::net::{ClientCommand, Guid, GuidIndex};
use crate::target::UNIT_FLAG_POSSESSED;
use super::press_tests::{
claw, latched, owned_pet, owned_pet_with, pet_actions, player_with, rig, Rig, CHARMEDBY, CLAW,
FLAGS, ME, PET, SUMMONEDBY,
};
use super::PetBar;
/// `PLAYER_FARSIGHT`, low word: the view's anchor, the guid `0x4bcfbf`-`0x4bd012` holds against the
/// pet's.
const FARSIGHT: u16 = 712;
/// Some other guid than ours or the pet's.
const OTHER: u64 = 0x99;
/// A foe the Attack order can be aimed at.
const FOE: u64 = 0x77;
fn guid_pair(field: u16, guid: u64) -> [(u16, u32); 2] {
[(field, guid as u32), (field + 1, (guid >> 32) as u32)]
}
/// The player, replaced: player-controlled, so the Attack order's validator needs no faction
/// table, and carrying `extra`.
fn set_player(rig: &mut Rig, extra: &[(u16, u32)]) {
let world = rig.app.world_mut();
let me = world.resource::<GuidIndex>().0[&ME];
let mut fields = vec![(FLAGS, 0x8)];
fields.extend_from_slice(extra);
world.entity_mut(me).insert(player_with(&fields));
}
/// The pet, replaced by [`owned_pet_with`] `extra`: alive, so the Attack order's validator passes.
fn set_pet(rig: &mut Rig, extra: &[(u16, u32)]) {
let world = rig.app.world_mut();
let pet = world.resource::<GuidIndex>().0[&PET];
let mut fields = vec![(22, 100), (28, 100)];
fields.extend_from_slice(extra);
world.entity_mut(pet).insert(owned_pet_with(&fields));
}
const ATTACK: u32 = PET_COMMAND_ATTACK | ((PET_ACT_COMMAND as u32) << 24);
const FOLLOW: u32 = PET_COMMAND_FOLLOW | ((PET_ACT_COMMAND as u32) << 24);
const STAY: u32 = PET_COMMAND_STAY | ((PET_ACT_COMMAND as u32) << 24);
const AGGRESSIVE: u32 = PET_REACT_AGGRESSIVE | ((PET_ACT_REACTION as u32) << 24);
const CLAW_WORD: u32 = CLAW | ((PET_ACT_ENABLED as u32) << 24);
/// A usable pet on a bar with Claw (autocast on) in slot 1, and in the pet's book, with the foe
/// selected for the Attack order. Fed once, so the VM holds what the bar last pushed.
fn scene() -> Rig {
let mut rig = rig(vec![(CLAW, claw())], &[CLAW], Some(owned_pet()));
set_player(&mut rig, &[]);
set_pet(&mut rig, &[]);
let world = rig.app.world_mut();
world.resource_mut::<PetBar>().spells.spells = vec![PetActionEntry::from(CLAW_WORD)];
let foe = world
.spawn((
Guid(FOE),
Transform::default(),
crate::net::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()
};
rig.frame();
rig
}
/// One state of the world to try every verb in.
struct Case {
name: &'static str,
apply: fn(&mut Rig),
/// What `0x4bcf70` answers.
usable: bool,
/// Whether `PickupPetAction`'s own gate lets the drag start: `UNIT_FLAG_POSSESSED` blocks it
/// (`0x4be1c1`), which is no exit of the predicate.
draggable: bool,
}
/// One way `0x4bcf70` answers zero, and nothing else changed.
type Exit = (&'static str, fn(&mut Rig));
/// Every exit of the predicate, each alone, in the order the bytes take them.
const EXITS: [Exit; 11] = [
("the active player does not resolve (0x4bcf92)", |rig| {
rig.app
.world_mut()
.resource_mut::<GuidIndex>()
.0
.remove(&ME);
}),
("the player is charmed (0x4bcf98)", |rig| {
set_player(rig, &guid_pair(CHARMEDBY, OTHER));
}),
(
"the player's far sight is on another unit (0x4bcfbf)",
|rig| set_player(rig, &guid_pair(FARSIGHT, OTHER)),
),
("the pet does not resolve (0x4bd034)", |rig| {
rig.app
.world_mut()
.resource_mut::<GuidIndex>()
.0
.remove(&PET);
}),
("the pet is summoned by another (0x4bd054)", |rig| {
set_pet(rig, &guid_pair(SUMMONEDBY, OTHER));
}),
(
"the pet has no summoner, whatever created it (0x4bd054)",
|rig| set_pet(rig, &guid_pair(SUMMONEDBY, 0)),
),
(
"the pet is charmed by another, summoned by us (0x4bd048)",
|rig| set_pet(rig, &guid_pair(CHARMEDBY, OTHER)),
),
("the pet is stunned (0x4bd075)", |rig| {
set_pet(rig, &[(FLAGS, 0x0004_0000)]);
}),
("the pet is confused (0x4bd089)", |rig| {
set_pet(rig, &[(FLAGS, 0x0040_0000)]);
}),
("the pet is fleeing (0x4bd081)", |rig| {
set_pet(rig, &[(FLAGS, 0x0080_0000)]);
}),
("bit 27 of the bar state (0x4bd08d)", |rig| {
rig.app.world_mut().resource_mut::<PetBar>().spells.state |= PET_STATE_BAR_DISABLED;
}),
];
/// Pets that trip none of the exits, however much like one: each still works.
const USABLE: [Exit; 5] = [
("an untouched pet", |_| {}),
("far sight on the pet itself, Eyes of the Beast", |rig| {
set_player(rig, &guid_pair(FARSIGHT, PET));
}),
(
"the pet charmed by us and summoned by another: the charmed-by wins",
|rig| {
let mut fields = guid_pair(CHARMEDBY, ME).to_vec();
fields.extend(guid_pair(SUMMONEDBY, OTHER));
set_pet(rig, &fields);
},
),
("the pet possessed, which greys nothing", |rig| {
set_pet(rig, &[(FLAGS, UNIT_FLAG_POSSESSED)]);
}),
("the pet in combat", |rig| set_pet(rig, &[(FLAGS, 0x0800)])),
];
/// Every exit alone, then every pet that trips none: the cases the rest of the file runs each
/// verb over.
fn all_cases() -> impl Iterator<Item = Case> {
let usable = USABLE.iter().map(|&(name, apply)| Case {
name,
apply,
usable: true,
draggable: !name.contains("possessed"),
});
let exits = EXITS.iter().map(|&(name, apply)| Case {
name,
apply,
usable: false,
draggable: true,
});
usable.chain(exits)
}
/// What a press did, by name, so one assert says which effect leaked and for which case.
fn effects(rig: &Rig, sent: Vec<ClientCommand>) -> Vec<&'static str> {
let mut out: Vec<&'static str> = sent
.iter()
.map(|c| match c {
ClientCommand::PetAction { .. } => "PetAction",
ClientCommand::PetCancelAura { .. } => "PetCancelAura",
_ => "another command",
})
.collect();
if !rig.pet_list_untouched() {
out.push("a GCD armed");
}
out
}
fn ui<R>(rig: &mut Rig, f: impl FnOnce(&mut benilla_ui::script::UiScript) -> R) -> R {
let mut script = rig
.app
.world_mut()
.non_send_resource_mut::<benilla_ui::script::UiScript>();
f(&mut script)
}
/// `GetPetActionsUsable()` as the Lua reads it after the feed ran.
fn usable_answer(rig: &mut Rig) -> bool {
rig.frame();
ui(rig, |s| {
s.eval::<bool>("return GetPetActionsUsable() ~= nil")
.unwrap()
})
}
/// `0x4be0b3`: the display answers nil for each exit alone, and 1 for every pet that trips none.
#[test]
fn each_exit_alone_makes_get_pet_actions_usable_nil() {
for Case {
name,
apply,
usable,
..
} in all_cases()
{
let mut rig = scene();
apply(&mut rig);
assert_eq!(usable_answer(&mut rig), usable, "{name}");
}
}
/// `0x4bd1f2`: a spell press, and the orders: every one leaves at the gate, before the aura cancel,
/// the latch, the GCD and the send.
#[test]
fn each_exit_alone_stops_a_press_and_every_order() {
for Case {
name,
apply,
usable,
..
} in all_cases()
{
let mut rig = scene();
apply(&mut rig);
rig.frame();
let before = latched(&rig);
rig.press(1);
let sent = rig.sent();
let got = effects(&rig, sent);
if usable {
// A possessed unit's press takes the generic cast entry, which arms its own GCD.
assert!(got.contains(&"PetAction"), "{name}: a spell press: {got:?}");
} else {
assert_eq!(got, Vec::<&str>::new(), "{name}: a spell press");
assert_eq!(latched(&rig), before, "{name}: nothing latched");
}
for (word, order) in [
(FOLLOW, "Follow"),
(STAY, "Stay"),
(AGGRESSIVE, "Aggressive"),
(ATTACK, "Attack"),
] {
let before = latched(&rig);
rig.order(word);
let sent = rig.sent();
let went = sent
.iter()
.any(|c| matches!(c, ClientCommand::PetAction { packed, .. } if *packed == word));
assert_eq!(went, usable, "{name}: {order} sends only on a usable pet");
if !usable {
assert_eq!(latched(&rig), before, "{name}: {order} latched nothing");
}
}
}
}
/// `0x4bcbcf`: the bar's `TogglePetAutocast` flips the word and sends only for a usable pet.
#[test]
fn each_exit_alone_stops_the_bars_autocast_toggle() {
for Case {
name,
apply,
usable,
..
} in all_cases()
{
let mut rig = scene();
apply(&mut rig);
rig.frame();
let word = rig.app.world().resource::<PetBar>().spells.bar[0];
ui(&mut rig, |s| s.run("TogglePetAutocast(1)").unwrap());
rig.app
.world_mut()
.run_system_once(super::drain::drain_pet_actions)
.expect("the drain runs");
let sent = rig.sent();
let flipped = rig.app.world().resource::<PetBar>().spells.bar[0] != word;
assert_eq!(flipped, usable, "{name}: the word flips only when usable");
assert_eq!(
sent.iter()
.filter(|c| matches!(c, ClientCommand::PetSetAction { .. }))
.count(),
usize::from(usable),
"{name}: the toggle is sent only when usable"
);
}
}
/// `0x4bccce`: the pet book's `ToggleSpellAutocast` flips the word, repaints and sends only for a
/// usable pet.
#[test]
fn each_exit_alone_stops_the_pet_books_autocast_toggle() {
for Case {
name,
apply,
usable,
..
} in all_cases()
{
let mut rig = scene();
apply(&mut rig);
rig.frame();
let (word, signals) = {
let bar = rig.app.world().resource::<PetBar>();
(bar.spells.spells[0], bar.bar_signals)
};
ui(&mut rig, |s| {
s.run("ToggleSpellAutocast(1, BOOKTYPE_PET)").unwrap()
});
rig.app
.world_mut()
.run_system_once(crate::ui_pet_book::drain_pet_book)
.expect("the drain runs");
let sent = rig.sent();
let bar = rig.app.world().resource::<PetBar>();
assert_eq!(
bar.spells.spells[0] != word,
usable,
"{name}: the book's word flips only when usable"
);
assert_eq!(
bar.bar_signals != signals,
usable,
"{name}: the bar repaints only when usable"
);
assert_eq!(
sent.iter()
.filter(|c| matches!(c, ClientCommand::PetSpellAutocast { .. }))
.count(),
usize::from(usable),
"{name}: the toggle is sent only when usable"
);
}
}
/// `0x4bd6e0`: the pet menu's Dismiss hands its command word to the dispatcher, so it is refused
/// at `0x4bd1f2` like any other order.
#[test]
fn each_exit_alone_stops_the_menus_dismiss() {
const DISMISS: u32 = 0x0700_0003;
for Case {
name,
apply,
usable,
..
} in all_cases()
{
let mut rig = scene();
apply(&mut rig);
rig.frame();
ui(&mut rig, |s| s.run("PetDismiss()").unwrap());
rig.app
.world_mut()
.run_system_once(super::menu::drain_pet_menu)
.expect("the drain runs");
let went = rig
.sent()
.iter()
.any(|c| matches!(c, ClientCommand::PetAction { packed, .. } if *packed == DISMISS));
assert_eq!(went, usable, "{name}: Dismiss sends only on a usable pet");
}
}
/// `0x4bc9d0`, from the drop `0x4bce33` and the pickup's blank `0x4be27f`: lifting Claw and
/// dropping it on the next slot writes and sends nothing on an unusable pet, and two writes on a
/// usable one.
#[test]
fn each_exit_alone_stops_the_drags_writes() {
for Case {
name,
apply,
usable,
draggable,
} in all_cases()
{
let writes = usable && draggable;
let mut rig = scene();
apply(&mut rig);
rig.frame();
let bar_before = rig.app.world().resource::<PetBar>().spells.bar;
ui(&mut rig, |s| {
s.run("PickupPetAction(1) PickupPetAction(2)").unwrap();
});
rig.app
.world_mut()
.run_system_once(super::drain::drain_pet_actions)
.expect("the drain runs");
let sent = rig.sent();
let bar = rig.app.world().resource::<PetBar>().spells.bar;
assert_eq!(
sent.iter()
.filter(|c| matches!(c, ClientCommand::PetSetAction { .. }))
.count(),
if writes { 2 } else { 0 },
"{name}: the pickup's blank and the drop send only when usable"
);
assert_eq!(
bar != bar_before,
writes,
"{name}: the bar's words move only when usable"
);
}
}
/// The whole default UI on the rig's world in place of its listening VM: the stock pet bar, and
/// the fed pet under it.
fn with_stock_ui(rig: &mut Rig) {
let mut script = benilla_ui::script::UiScript::new().expect("a VM");
script.set_screen_size(1024.0, 768.0);
// The in-game UI loads on world entry, so a player exists.
script.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Probefour".into()),
level: 60,
..Default::default()
}),
);
let failures = crate::ui_script::load_default_ui(&script);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
rig.app.world_mut().insert_non_send_resource(script);
}
/// The feeds, then the VM's clock and layout, so the bar's slide and repaint have happened.
fn settle(rig: &mut Rig) {
rig.feed.run(rig.app.world_mut());
ui(rig, |s| {
for _ in 0..3 {
s.tick(0.05);
}
s.resolve();
});
}
/// Whether the button's icon quad draws greyscale: `PetActionBar_Update`'s `SetDesaturation` on
/// `PetActionButton1Icon` (`PetActionBarFrame.lua:129-134`).
fn icon_is_grey(rig: &mut Rig, icon: &str) -> bool {
ui(rig, |s| {
s.extract()
.iter()
.find_map(|q| match &q.content {
benilla_ui::script::QuadContent::Texture {
path: Some(p),
desaturated,
..
} if p == icon => Some(*desaturated),
_ => None,
})
.unwrap_or_else(|| panic!("the icon {icon} is on screen"))
})
}
/// A left click on `PetActionButton1`, at its own centre, and the frame's script calls applied.
fn click_first_button(rig: &mut Rig) {
let (x, y): (f32, f32) = ui(rig, |s| {
s.eval("return PetActionButton1:GetCenter()").unwrap()
});
ui(rig, |s| {
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
});
rig.app
.world_mut()
.run_system_once(crate::script_calls::apply_script_calls)
.expect("the frame's script calls apply");
}
/// With the default UI loaded, the stock bar reads `GetPetActionsUsable` for its icons and clicks
/// `CastPetAction` whatever it answered, so the engine's gate is the only one: a stunned pet's bar
/// greys, and clicking it sends nothing (`0x4bd1f2`), where the same click on the pet before it was
/// stunned went out.
#[test]
fn the_stock_bar_greys_a_stunned_pet_and_its_click_sends_nothing() {
benilla_formats::wow_data_or_skip!();
const ICON: &str = "Interface\\Icons\\Ability_Druid_Rake";
let display = benilla_formats::SpellDisplay {
icon: Some(ICON.into()),
..claw()
};
let mut rig = rig(vec![(CLAW, display)], &[CLAW], Some(owned_pet()));
with_stock_ui(&mut rig);
settle(&mut rig);
assert!(
!icon_is_grey(&mut rig, ICON),
"a usable pet's icon is in colour"
);
click_first_button(&mut rig);
assert_eq!(
pet_actions(&rig.sent()),
1,
"and its button presses, as before"
);
set_pet(&mut rig, &[(FLAGS, 0x0004_0000)]);
settle(&mut rig);
assert!(icon_is_grey(&mut rig, ICON), "a stunned pet's bar greys");
let before = latched(&rig);
click_first_button(&mut rig);
assert!(
rig.sent().is_empty(),
"the click on the greyed button sends nothing"
);
assert_eq!(latched(&rig), before);
// The same click again once the stun ends.
set_pet(&mut rig, &[]);
settle(&mut rig);
assert!(!icon_is_grey(&mut rig, ICON));
click_first_button(&mut rig);
assert_eq!(pet_actions(&rig.sent()), 1);
}
/// `0x4bd06f`-`0x4bd08b` read three flags and never the pet's health: a dead pet's bar is usable,
/// its press goes out, and only the Attack order's own validator refuses it (`0x612e10`).
#[test]
fn a_dead_pet_is_still_usable() {
let mut rig = scene();
set_pet(&mut rig, &[(22, 0)]);
assert!(usable_answer(&mut rig));
rig.press(1);
assert_eq!(pet_actions(&rig.sent()), 1, "the spell press goes out");
rig.order(FOLLOW);
assert_eq!(pet_actions(&rig.sent()), 1, "and so does Follow");
rig.order(ATTACK);
assert_eq!(
pet_actions(&rig.sent()),
0,
"the attack validator refuses the dead actor, past the gate"
);
}
/// The predicate on its own, over every field it reads, so the exit table above is what the
/// function says and not what the rig happens to build.
#[test]
fn the_predicate_reads_exactly_the_reference_fields() {
use super::usable::actions_usable;
let store = |fields: &[(u16, u32)]| {
crate::net::ObjectStore(benilla_protocol::ObjectFields::from_pairs(fields))
};
let bar = PetBar {
spells: benilla_protocol::messages::PetSpells {
pet_guid: PET,
..Default::default()
},
..Default::default()
};
let me = Some(ME);
let player = store(&[(2, 0x19)]);
let pet = owned_pet();
assert!(actions_usable(&bar, me, Some(&player), Some(&pet)));
assert!(!actions_usable(&bar, None, Some(&player), Some(&pet)));
assert!(!actions_usable(&bar, me, None, Some(&pet)));
assert!(!actions_usable(&bar, me, Some(&player), None));
// The owner is `CHARMEDBY` when set, else `SUMMONEDBY`, and never `CREATEDBY`.
let created_only = store(&[(14, ME as u32), (15, 0)]);
assert!(!actions_usable(
&bar,
me,
Some(&player),
Some(&created_only)
));
let unowned = store(&[]);
assert!(!actions_usable(&bar, me, Some(&player), Some(&unowned)));
// A far-sight guid of zero is no view; the pet's own guid is the one that stays usable.
let zero_view = store(&[(FARSIGHT, 0), (FARSIGHT + 1, 0)]);
assert!(actions_usable(&bar, me, Some(&zero_view), Some(&pet)));
let on_pet = store(&[(FARSIGHT, PET as u32), (FARSIGHT + 1, 0)]);
assert!(actions_usable(&bar, me, Some(&on_pet), Some(&pet)));
let elsewhere = store(&[(FARSIGHT, OTHER as u32), (FARSIGHT + 1, 0)]);
assert!(!actions_usable(&bar, me, Some(&elsewhere), Some(&pet)));
// The high word counts: a view whose low word is the pet's and whose high word is not.
let high_only = store(&[(FARSIGHT, PET as u32), (FARSIGHT + 1, 1)]);
assert!(!actions_usable(&bar, me, Some(&high_only), Some(&pet)));
// Each of the three flags alone, and no other.
for flag in [0x0004_0000, 0x0040_0000, 0x0080_0000] {
let hurt = owned_pet_with(&[(FLAGS, flag)]);
assert!(
!actions_usable(&bar, me, Some(&player), Some(&hurt)),
"flag {flag:#x} disables"
);
}
let possessed = owned_pet_with(&[(FLAGS, UNIT_FLAG_POSSESSED)]);
assert!(actions_usable(&bar, me, Some(&player), Some(&possessed)));
let mut disabled = PetBar {
spells: bar.spells.clone(),
..Default::default()
};
disabled.spells.state |= PET_STATE_BAR_DISABLED;
assert!(!actions_usable(&disabled, me, Some(&player), Some(&pet)));
}
+12 -4
View File
@@ -23,7 +23,7 @@ use benilla_ui::script::{PetBookState, SpellSlotView, UiScript};
use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_action::Spells;
use crate::ui_pet::PetBar;
use crate::ui_pet::{PetBar, PetUnit};
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
@@ -173,7 +173,8 @@ fn slot_view(
/// `CastSpell(id, "pet", onSelf)`: cancel first, as on the bar (`0x4b33af`-`0x4b3461`), a spell
/// whose aura is on the pet sending `CMSG_PET_CANCEL_AURA` (0x26B) instead of the order; else
/// `CMSG_PET_ACTION` at the selection as the calls before it left it, or with `onSelf` at the
/// player.
/// player. Unlike the bar's press it asks no usability predicate: no call site of `0x4bcf70` is
/// in this path.
pub(crate) fn cast_pet_spell(p: &mut crate::ui_pet::PetPress, spell_id: u32, on_self: bool) {
let pet_guid = p.bar.spells.pet_guid;
if pet_guid == 0 {
@@ -203,11 +204,14 @@ pub(crate) fn cast_pet_spell(p: &mut crate::ui_pet::PetPress, spell_id: u32, on_
});
}
/// Drains the pet book's `ToggleSpellAutocast` intents.
fn drain_pet_book(
/// Drains the pet book's `ToggleSpellAutocast` intents. Each asks the usability predicate before
/// it looks anything up (`0x4bccce`), so an unusable pet's toggle flips, repaints and sends
/// nothing.
pub(crate) fn drain_pet_book(
script: Option<NonSendMut<UiScript>>,
mut bar: ResMut<PetBar>,
commands: Res<NetCommands>,
pet: PetUnit,
) {
let Some(mut script) = script else {
return;
@@ -222,6 +226,10 @@ fn drain_pet_book(
return;
}
for spell_id in autocasts {
if !pet.actions_usable(&bar) {
debug!("ui_pet_book: autocast {spell_id} refused, the pet's actions are not usable");
continue;
}
let Some(on) = flip_autocast(&mut bar.spells, spell_id) else {
continue;
};
+68 -1
View File
@@ -3,6 +3,9 @@
//! payload, a word carried verbatim (`0x4bce00`), so a drag only rearranges the bar the server
//! sent. A drop clears the cursor before deciding (`0x495190`) and only a write that relocated
//! nothing refills it (`0x4bce38`), so a refused drop loses the payload, as in the reference.
//! Both bar writes, the drop's (`0x4bce33`) and the pickup's blank (`0x4be27f`), ask the pet's
//! usability predicate first (`0x4bc9d0`): on an unusable pet the drop writes nothing and refills
//! nothing, and the pickup holds its action with the slot left as it was.
use mlua::Lua;
@@ -166,8 +169,14 @@ fn slot_index(model: &Model, slot: u32) -> Option<usize> {
}
/// Run [`assign`] on the engine's optimistic mirror of the ten words and queue the send; the
/// pickup's blank goes through it too, as in the reference (`0x4be268`).
/// pickup's blank goes through it too, as in the reference (`0x4be268`). `0x4bc9a0` takes its
/// send flag from both callers as 1, which makes it ask `0x4bcf70` before anything else
/// (`0x4bc9c7`-`0x4bc9d7`); the app's answer, the one the bar's `GetPetActionsUsable` reads, is
/// `PetBarState::actions_usable`.
fn write_slot(model: &mut Model, target: usize, source: u32, passive: bool) -> Option<Assigned> {
if !model.pet_bar.actions_usable {
return None;
}
let mut words: Vec<u32> = model.pet_bar.slots.iter().map(|s| s.view.packed).collect();
let assigned = assign(&mut words, target, source, passive)?;
@@ -369,6 +378,64 @@ mod tests {
assert_eq!(p.texture, None);
}
/// `0x4bc9d0`: the write asks the pet's usability predicate first, and the app's answer is the
/// one the bar last pushed. The pickup still lifts its action (`0x4be25d`), but its blank
/// (`0x4be27f`) is refused, so the slot keeps the word and nothing is sent; the drop clears the
/// cursor before it decides (`0x4be220`), and its refused write refills nothing (`0x4bce38`).
#[test]
fn an_unusable_bar_writes_nothing_at_either_end() {
let mut s = UiScript::new().unwrap();
s.set_pet_actions(true, false, true, hunter_bar());
assert!(s.eval::<bool>("return PickupPetAction(4)").unwrap());
assert!(
matches!(s.cursor_payload(), Some(crate::script::CursorPayload::PetAction(c))
if c.src_slot == 4 && c.packed == CLAW),
"the pickup lifted Claw: {:?}",
s.cursor_payload()
);
assert!(
s.take_pet_set_actions().is_empty(),
"but its blank was refused, so nothing is sent"
);
s.run("PickupPetAction(5)").unwrap();
assert!(
s.cursor_payload().is_none(),
"the drop cleared the cursor and nothing refilled it"
);
assert!(s.take_pet_set_actions().is_empty(), "and wrote nothing");
// Slot 4 was never blanked: a second pickup lifts the whole word, autocast bits and all,
// where a blanked slot would give the type with id 0.
s.run("PickupPetAction(4)").unwrap();
assert!(
matches!(s.cursor_payload(), Some(crate::script::CursorPayload::PetAction(c))
if c.packed == CLAW),
"{:?}",
s.cursor_payload()
);
// Growl kept its slot too: the refused drop never displaced it.
s.run("ClearCursor() PickupPetAction(5)").unwrap();
assert!(matches!(
s.cursor_payload(),
Some(crate::script::CursorPayload::PetAction(c)) if c.packed == GROWL
));
assert!(s.take_pet_set_actions().is_empty());
}
/// The same bar made usable takes the same two writes, which is what the test above stops.
#[test]
fn a_usable_bar_takes_both_writes() {
let mut s = bar_script();
s.run("PickupPetAction(4) PickupPetAction(5)").unwrap();
assert_eq!(
s.take_pet_set_actions().len(),
2,
"the pickup's blank and the drop"
);
}
#[test]
fn picking_up_a_token_leaves_the_slot_alone() {
let mut s = bar_script();
+4 -2
View File
@@ -104,7 +104,8 @@ pub struct PetStats {
pub(crate) struct PetBarState {
/// `PetHasActionBar()`: there is a bar, even one of ten empty slots.
pub(crate) has_bar: bool,
/// `GetPetActionsUsable()`: false desaturates the whole bar.
/// `GetPetActionsUsable()` (`0x4bcf70`): false desaturates the whole bar, and refuses the
/// drag's bar writes with it (`0x4bc9d0`).
pub(crate) actions_usable: bool,
pub(crate) slots: Vec<StoredPetAction>,
/// `HasPetUI`'s first return: a pet with a nonzero `UNIT_FIELD_PETNUMBER` (`0x4be697`). Not
@@ -113,7 +114,8 @@ pub(crate) struct PetBarState {
pub(crate) stats: PetStats,
/// `PickupPetAction`'s gate, `UNIT_FLAG_POSSESSED` clear (`0x4be1c1`), which blocks the drop
/// as well as the pick-up. Not `actions_usable`: possession does not grey the bar, and the
/// flags that grey it do not block a drag.
/// pick-up itself does not ask it. The writes at both ends do (`0x4bc9d0`), so a greyed bar
/// still lifts an action onto the cursor and then writes nothing.
pub(crate) pickup_allowed: bool,
/// `PetCanBeAbandoned()`: a kept pet rather than a summon. It forks the pet menu: paperdoll,
/// rename and abandon show when true, dismiss only when false (`UnitPopup.lua:402-417`).