diff --git a/crates/benilla-app/src/ui_action/errors.rs b/crates/benilla-app/src/ui_action/errors.rs index 0847a3e6..6ba6d1d1 100644 --- a/crates/benilla-app/src/ui_action/errors.rs +++ b/crates/benilla-app/src/ui_action/errors.rs @@ -352,7 +352,8 @@ pub(crate) fn attack_actor_blocked( actor: Option<&ObjectStore>, self_guid: Option, ) -> 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() { diff --git a/crates/benilla-app/src/ui_pet/bar.rs b/crates/benilla-app/src/ui_pet/bar.rs index 08ad0464..90085b3d 100644 --- a/crates/benilla-app/src/ui_pet/bar.rs +++ b/crates/benilla-app/src/ui_pet/bar.rs @@ -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) -> 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, spells: Option>, clock: Res, - index: Res, - stores: Query<&ObjectStore>, + pet: PetUnit, mut memory: Local>, ) { 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; diff --git a/crates/benilla-app/src/ui_pet/drain.rs b/crates/benilla-app/src/ui_pet/drain.rs index ebebd15b..33fbbb23 100644 --- a/crates/benilla-app/src/ui_pet/drain.rs +++ b/crates/benilla-app/src/ui_pet/drain.rs @@ -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>, mut bar: ResMut, commands: Res, + 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; }; diff --git a/crates/benilla-app/src/ui_pet/menu.rs b/crates/benilla-app/src/ui_pet/menu.rs index 8e018344..495e8ea3 100644 --- a/crates/benilla-app/src/ui_pet/menu.rs +++ b/crates/benilla-app/src/ui_pet/menu.rs @@ -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, commands: Res, mut ui_errors: ResMut, + 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, diff --git a/crates/benilla-app/src/ui_pet/mod.rs b/crates/benilla-app/src/ui_pet/mod.rs index e32643bc..e18ee462 100644 --- a/crates/benilla-app/src/ui_pet/mod.rs +++ b/crates/benilla-app/src/ui_pet/mod.rs @@ -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?) } diff --git a/crates/benilla-app/src/ui_pet/press_tests.rs b/crates/benilla-app/src/ui_pet/press_tests.rs index fcd84dde..d4fe0c95 100644 --- a/crates/benilla-app/src/ui_pet/press_tests.rs +++ b/crates/benilla-app/src/ui_pet/press_tests.rs @@ -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, /// 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 { + pub(super) fn sent(&self) -> Vec { 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::().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::(); (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::() .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::() .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::().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::().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::().0.insert(PET, pet); rig.press(1); diff --git a/crates/benilla-app/src/ui_pet/tests.rs b/crates/benilla-app/src/ui_pet/tests.rs index 1f142cbe..3022534c 100644 --- a/crates/benilla-app/src/ui_pet/tests.rs +++ b/crates/benilla-app/src/ui_pet/tests.rs @@ -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::() .init_resource::() + .init_resource::() .insert_resource(crate::ui_action::Spells { catalog: benilla_formats::SpellCatalog::from_displays(HashMap::from([(CLAW, claw())])), ..crate::ui_action::Spells::empty_for_tests() diff --git a/crates/benilla-app/src/ui_pet/unit.rs b/crates/benilla-app/src/ui_pet/unit.rs index e971243b..31d3471a 100644 --- a/crates/benilla-app/src/ui_pet/unit.rs +++ b/crates/benilla-app/src/ui_pet/unit.rs @@ -39,7 +39,9 @@ pub(super) const UNIT_FLAG_PET_IN_COMBAT: u32 = 0x0000_0800; pub(super) fn feed_pet_unit( script: Option>, bar: Res, - mut pet: PetUnit, + pet: PetUnit, + // The per-field edges, for `fire_transitions`' watch-bridge arms. + mut field_edges: MessageReader, changed_stores: Query<(), Changed>, mut removed_stores: RemovedComponents, mut names: ResMut, @@ -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(); diff --git a/crates/benilla-app/src/ui_pet/usable.rs b/crates/benilla-app/src/ui_pet/usable.rs new file mode 100644 index 00000000..0de724e5 --- /dev/null +++ b/crates/benilla-app/src/ui_pet/usable.rs @@ -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, + 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() + } +} diff --git a/crates/benilla-app/src/ui_pet/usable_tests.rs b/crates/benilla-app/src/ui_pet/usable_tests.rs new file mode 100644 index 00000000..a6d9fdc4 --- /dev/null +++ b/crates/benilla-app/src/ui_pet/usable_tests.rs @@ -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::().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::().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::().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::().0.insert(FOE, foe); + *world.resource_mut::() = 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::() + .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::() + .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::().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 { + 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) -> 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(rig: &mut Rig, f: impl FnOnce(&mut benilla_ui::script::UiScript) -> R) -> R { + let mut script = rig + .app + .world_mut() + .non_send_resource_mut::(); + 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::("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::().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::().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::(); + (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::(); + 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::().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::().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))); +} diff --git a/crates/benilla-app/src/ui_pet_book.rs b/crates/benilla-app/src/ui_pet_book.rs index 5da56efd..38ac0a22 100644 --- a/crates/benilla-app/src/ui_pet_book.rs +++ b/crates/benilla-app/src/ui_pet_book.rs @@ -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>, mut bar: ResMut, commands: Res, + 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; }; diff --git a/crates/benilla-ui/src/script/cursor/pet.rs b/crates/benilla-ui/src/script/cursor/pet.rs index e17500e2..5ff4da6a 100644 --- a/crates/benilla-ui/src/script/cursor/pet.rs +++ b/crates/benilla-ui/src/script/cursor/pet.rs @@ -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 { } /// 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 { + if !model.pet_bar.actions_usable { + return None; + } let mut words: Vec = 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::("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(); diff --git a/crates/benilla-ui/src/script/pet.rs b/crates/benilla-ui/src/script/pet.rs index dfee9d48..e82b8e79 100644 --- a/crates/benilla-ui/src/script/pet.rs +++ b/crates/benilla-ui/src/script/pet.rs @@ -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, /// `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`).