From 05a9be68e585ab8b85ebb090590900951a994e11 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:56:24 +0700 Subject: [PATCH 001/105] UnitBuff and UnitDebuff answer for party1-4 and partypet1-4: the live unit's slots, else the roster record's slot-indexed aura block, with UNIT_AURA on each change (#470) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-app/src/net/objects.rs | 14 +- crates/benilla-app/src/ui_aura.rs | 221 ++++++---- crates/benilla-app/src/ui_aura/party.rs | 376 ++++++++++++++++++ crates/benilla-app/src/ui_party/mod.rs | 31 +- crates/benilla-app/src/ui_party/net.rs | 73 +++- crates/benilla-app/src/ui_party/records.rs | 138 +++++++ crates/benilla-protocol/src/messages/group.rs | 120 +++++- crates/benilla-protocol/src/messages/mod.rs | 8 +- .../src/messages/update_object/fields/mod.rs | 2 + .../src/messages/update_object/fields/unit.rs | 9 + crates/benilla-protocol/tests/group.rs | 45 ++- 11 files changed, 894 insertions(+), 143 deletions(-) create mode 100644 crates/benilla-app/src/ui_aura/party.rs create mode 100644 crates/benilla-app/src/ui_party/records.rs diff --git a/crates/benilla-app/src/net/objects.rs b/crates/benilla-app/src/net/objects.rs index e9f4bd06..cccee071 100644 --- a/crates/benilla-app/src/net/objects.rs +++ b/crates/benilla-app/src/net/objects.rs @@ -302,8 +302,8 @@ fn on_object_values(In(ev): In, mut sc: Scene) { fn on_object_destroyed(In(ev): In, mut sc: Scene) { if let SessionEvent::ObjectDestroyed(guid) = ev { crate::death::net::forget_corpse(guid, &mut sc.death_net); - let store = sc.index.0.get(&guid).and_then(|e| sc.stores.get(*e).ok()); - crate::ui_party::net::member_deactivated(guid, &mut sc.group, store, &sc.net); + let held = |g: u64| sc.index.0.get(&g).and_then(|e| sc.stores.get(*e).ok()); + crate::ui_party::net::member_deactivated(guid, &mut sc.group, held(guid), held, &sc.net); object_destroyed(guid, &mut sc.commands, &mut sc.index); } } @@ -313,8 +313,14 @@ fn on_object_destroyed(In(ev): In, mut sc: Scene) { fn on_objects_removed(In(ev): In, mut sc: Scene) { if let SessionEvent::ObjectsRemoved(guids) = ev { for guid in &guids { - let store = sc.index.0.get(guid).and_then(|e| sc.stores.get(*e).ok()); - crate::ui_party::net::member_deactivated(*guid, &mut sc.group, store, &sc.net); + let held = |g: u64| sc.index.0.get(&g).and_then(|e| sc.stores.get(*e).ok()); + crate::ui_party::net::member_deactivated( + *guid, + &mut sc.group, + held(*guid), + held, + &sc.net, + ); } objects_removed(guids, &mut sc.commands, &mut sc.index); } diff --git a/crates/benilla-app/src/ui_aura.rs b/crates/benilla-app/src/ui_aura.rs index d24d58be..7190388a 100644 --- a/crates/benilla-app/src/ui_aura.rs +++ b/crates/benilla-app/src/ui_aura.rs @@ -1,7 +1,10 @@ -//! The aura feed: the `UNIT_FIELD_AURA` blocks of the player, the target, the target's target and -//! the pet as the ordered [`AuraState`] lists the aura bindings read, with the player's durations. -//! The player's list keeps the reference cache's insertion order (`0xbc6040`, repacked by -//! `PlayerAuras_Update 0x4e4170`); any other unit's reads its descriptor by ascending slot. +//! The aura feed: the `UNIT_FIELD_AURA` blocks of the player, the target, the target's target, the +//! pet and the party and their pets as the ordered [`AuraState`] lists the aura bindings read, with +//! the player's durations. The player's list keeps the reference cache's insertion order +//! (`0xbc6040`, repacked by `PlayerAuras_Update 0x4e4170`); any other unit's reads its descriptor +//! by ascending slot, or, for a group member or pet with no live object, its roster record. + +mod party; use std::collections::HashMap; @@ -159,6 +162,8 @@ struct AuraFeedMemo { target_last: Option<(u64, Vec)>, pet_last: Option<(u64, Vec)>, tot_last: Option<(u64, Vec)>, + /// Each `partyN` and `partypetN` token's, keyed the same way. + party_last: HashMap<&'static str, Option<(u64, Vec)>>, } /// The `BENILLA_AURA_TRACE` period in seconds; a set value that is not a positive number means 1 s. @@ -225,6 +230,48 @@ fn projection_of(list: &[AuraState]) -> Vec { .collect() } +/// A token's `UNIT_AURA` edge: its unit or its list moved since the last push. Keyed by guid too, +/// so a switch between equal lists re-fires; a token clearing fires nothing, since the frames +/// react to their own events (`PLAYER_TARGET_CHANGED`, `UNIT_PET`, `PARTY_MEMBERS_CHANGED`). +fn aura_edge( + last: &mut Option<(u64, Vec)>, + cur: Option<(u64, &[AuraState])>, +) -> bool { + let cur = cur.map(|(guid, l)| (guid, projection_of(l))); + let changed = cur.is_some() && cur != *last; + *last = cur; + changed +} + +/// One aura as `UnitBuff 0x519500` and `UnitDebuff 0x5198f0` return it for a unit not the player: +/// no duration, which the 1.12 wire carries for the player alone. +fn unit_aura_state( + catalog: Option<&SpellCatalog>, + slot: u8, + spell_id: u32, + count: u8, + flags: u8, +) -> AuraState { + let display = catalog.and_then(|cat| cat.get(spell_id)); + AuraState { + spell_id, + name: display.map(|d| d.name.clone()), + icon: display.and_then(|d| d.icon.clone()), + count, + debuff_type: display + .zip(catalog) + .and_then(|(d, cat)| cat.dispel_name(d)) + .map(str::to_string), + duration: 0.0, + expiration_time: 0.0, + helpful: slot < UNIT_AURA_POSITIVE_SLOTS, + cancelable: flags & AURA_FLAG_CANCELABLE != 0, + // `untilCancelled` is the player cache record's alone (`0xbc6040` `+0xc`). + until_cancelled: false, + channeled: display.is_some_and(|d| d.attributes_ex & SPELL_ATTR_EX_IS_CHANNELED != 0), + } +} + /// Another unit's auras as `UnitBuff 0x519500` and `UnitDebuff 0x5198f0` read them: ascending /// slot within each half, no durations, through the display filter. `buffs_visible` false drops /// the helpful half, the unit-level gate's nil at every index ([`buffs_visible_on`]). @@ -238,28 +285,20 @@ fn other_unit_auras( .unit_auras() .filter(|a| buffs_visible || a.slot >= UNIT_AURA_POSITIVE_SLOTS) .filter(|a| shown_in_aura_ui(catalog, a.spell_id)) - .map(|a| { - let display = catalog.and_then(|cat| cat.get(a.spell_id)); - AuraState { - spell_id: a.spell_id, - name: display.map(|d| d.name.clone()), - icon: display.and_then(|d| d.icon.clone()), - count: a.stacks, - debuff_type: display - .zip(catalog) - .and_then(|(d, cat)| cat.dispel_name(d)) - .map(str::to_string), - // The 1.12 wire carries durations for the player alone. - duration: 0.0, - expiration_time: 0.0, - helpful: a.slot < UNIT_AURA_POSITIVE_SLOTS, - cancelable: a.flags & AURA_FLAG_CANCELABLE != 0, - // `untilCancelled` is the player cache record's alone (`0xbc6040` `+0xc`). - until_cancelled: false, - channeled: display - .is_some_and(|d| d.attributes_ex & SPELL_ATTR_EX_IS_CHANNELED != 0), - } - }) + .map(|a| unit_aura_state(catalog, a.slot, a.spell_id, a.stacks, a.flags)) + .collect() +} + +/// A roster record's aura block as the two bindings' roster walk reads it (`0x519763`-`0x5197d2`, +/// `0x519b53`-`0x519bc5`): ascending slot, the display filter alone (`0x51979f`, `0x519b8f`), no +/// unit gate and no `AURAFLAGS`, which the record does not hold. The stack count is always 1 +/// (`0x51980f`, `0x519c2d`), and nothing is cancelable. +fn record_auras(slots: &[(u8, u16)], catalog: Option<&SpellCatalog>) -> Vec { + slots + .iter() + .map(|&(slot, spell)| (slot, u32::from(spell))) + .filter(|&(_, spell)| spell != 0 && shown_in_aura_ui(catalog, spell)) + .map(|(slot, spell)| unit_aura_state(catalog, slot, spell, 1, 0)) .collect() } @@ -357,6 +396,8 @@ fn feed_auras( stores: Query<&ObjectStore>, spells: Option>, pet: Res, + // `Option`: a UI-only harness has no party plugin; without it no party token has a list. + group: Option>, index: Res, // The `UnitBuff` gate's inputs, the same reaction the selection ring and nameplates use. factions: Option>, @@ -473,6 +514,20 @@ fn feed_auras( } } + let held = |guid: u64| index.0.get(&guid).and_then(|&e| stores.get(e).ok()); + // A live unit's rows behind `UnitBuff`'s unit gate. `unit` is never `store`, the player's, + // which is the gate's second argument. + let gated = |unit: &ObjectStore| { + let buffs = buffs_visible_on( + unit, + Some(store), + factions.as_deref(), + &reputations, + |owner| held(owner).cloned(), + ); + other_unit_auras(unit, catalog, buffs) + }; + // The target's rows. A self-target mirrors the player list in cache order, where the // reference's `UnitBuff` reads by slot. let target_list: Option> = @@ -480,30 +535,14 @@ fn feed_auras( if guid == self_guid.0 { return Some(list.clone()); } - // Not `store`: that is the player's, the gate's second argument. - let target_store = stores.get(e).ok()?; - let buffs = buffs_visible_on( - target_store, - Some(store), - factions.as_deref(), - &reputations, - |owner| { - index - .0 - .get(&owner) - .and_then(|&e| stores.get(e).ok()) - .cloned() - }, - ); - Some(other_unit_auras(target_store, catalog, buffs)) + Some(gated(stores.get(e).ok()?)) }); // The pet's list: the stock pet frame's four buttons draw its buffs (`PetFrame.lua:37,56`). let pet_guid = pet.spells.pet_guid; let pet_list: Option> = (pet_guid != 0) - .then(|| index.0.get(&pet_guid)) + .then(|| held(pet_guid)) .flatten() - .and_then(|&e| stores.get(e).ok()) // Ungated; the reference gates a pet too, but `CanAssist`'s player-controlled arm // (`0x60673e`-`0x60679f`, the controlling players' duel and FFA-PvP state) passes for // your own pet, leaving only the charm, selectable and reaction clauses to hide its buffs. @@ -517,36 +556,24 @@ fn feed_auras( .and_then(|s| s.0.unit_target()) .filter(|g| *g != 0) .unwrap_or(0); - let tot_list: Option> = (tot_guid != 0) - .then(|| index.0.get(&tot_guid)) - .flatten() - .and_then(|&e| { - if tot_guid == self_guid.0 { - return Some(list.clone()); - } - let tot_store = stores.get(e).ok()?; - let buffs = buffs_visible_on( - tot_store, - Some(store), - factions.as_deref(), - &reputations, - |owner| { - index - .0 - .get(&owner) - .and_then(|&e| stores.get(e).ok()) - .cloned() - }, - ); - Some(other_unit_auras(tot_store, catalog, buffs)) - }); + let tot_list: Option> = + (tot_guid != 0) + .then(|| held(tot_guid)) + .flatten() + .map(|tot_store| { + if tot_guid == self_guid.0 { + return list.clone(); + } + gated(tot_store) + }); - let target_cur = selection - .guid - .zip(target_list.as_deref()) - .map(|(guid, l)| (guid, projection_of(l))); - let target_changed = target_cur.is_some() && target_cur != memo.target_last; - memo.target_last = target_cur; + // `party1..4` and `partypet1..4`: the live unit's rows, else its roster record's. + let party_lists = party::party_aura_lists(group.as_deref(), held, gated, catalog); + + let target_changed = aura_edge( + &mut memo.target_last, + selection.guid.zip(target_list.as_deref()), + ); if target_changed && std::env::var_os("BENILLA_AURA_DUMP").is_some() { let l = target_list.as_deref().unwrap_or_default(); @@ -562,25 +589,39 @@ fn feed_auras( } } - let pet_cur = (pet_guid != 0) - .then_some(pet_list.as_deref()) - .flatten() - .map(|l| (pet_guid, projection_of(l))); - let pet_changed = pet_cur.is_some() && pet_cur != memo.pet_last; - memo.pet_last = pet_cur; - - let tot_cur = (tot_guid != 0) - .then_some(tot_list.as_deref()) - .flatten() - .map(|l| (tot_guid, projection_of(l))); - let tot_changed = tot_cur.is_some() && tot_cur != memo.tot_last; - memo.tot_last = tot_cur; + let pet_changed = aura_edge( + &mut memo.pet_last, + (pet_guid != 0) + .then_some(pet_list.as_deref()) + .flatten() + .map(|l| (pet_guid, l)), + ); + let tot_changed = aura_edge( + &mut memo.tot_last, + (tot_guid != 0) + .then_some(tot_list.as_deref()) + .flatten() + .map(|l| (tot_guid, l)), + ); + let party_changed: Vec<&'static str> = party_lists + .iter() + .filter(|(token, cur)| { + aura_edge( + memo.party_last.entry(token).or_default(), + cur.as_ref().map(|(guid, l)| (*guid, l.as_slice())), + ) + }) + .map(|(token, _)| *token) + .collect(); script.set_auras("player", Some(list)); // Clearing a token fires nothing: the frames react to `PLAYER_TARGET_CHANGED` and `UNIT_PET`. script.set_auras("target", target_list); script.set_auras("pet", pet_list); script.set_auras("targettarget", tot_list); + for (token, cur) in party_lists { + script.set_auras(token, cur.map(|(_, l)| l)); + } script.set_tracking(tracking); if changed { script.fire_event("UNIT_AURA", vec![ScriptValue::Str("player".into())]); @@ -597,6 +638,11 @@ fn feed_auras( if tot_changed { script.fire_event("UNIT_AURA", vec![ScriptValue::Str("targettarget".into())]); } + // The reference fires these from the record's aura bits (`0x5e57d8`, the pet's `0x5e58f1`) + // and from the live descriptor's aura fields, to every token naming the guid (`0x515e50`). + for token in party_changed { + script.fire_event("UNIT_AURA", vec![ScriptValue::Str(token.into())]); + } } /// How long before its aura a duration packet may arrive and still join it. The measured lead is @@ -632,6 +678,9 @@ fn end_session_aura_state( script.set_auras("player", None); script.set_auras("target", None); script.set_auras("targettarget", None); + for token in party::PARTY_AURA_TOKENS { + script.set_auras(token, None); + } script.set_tracking(None); } cache.auras.clear(); diff --git a/crates/benilla-app/src/ui_aura/party.rs b/crates/benilla-app/src/ui_aura/party.rs new file mode 100644 index 00000000..e1f4a054 --- /dev/null +++ b/crates/benilla-app/src/ui_aura/party.rs @@ -0,0 +1,376 @@ +//! The `partyN` and `partypetN` aura lists. `UnitBuff 0x519500` and `UnitDebuff 0x5198f0` read a +//! token's live object, gated as any unit's; a guid with no live object diverts to the roster +//! record's aura block instead (`0x519592`-`0x5195a4` to `0x519735`, `0x519b25`). + +use benilla_formats::SpellCatalog; +use benilla_ui::script::AuraState; + +use crate::net::ObjectStore; +use crate::ui_party::GroupState; + +/// The tokens this feeds, `partyN` then `partypetN`. +pub(super) const PARTY_AURA_TOKENS: [&str; 8] = [ + "party1", + "party2", + "party3", + "party4", + "partypet1", + "partypet2", + "partypet3", + "partypet4", +]; + +/// One unit's guid and its list. +pub(super) type UnitAuras = (u64, Vec); + +/// Each of [`PARTY_AURA_TOKENS`] with the guid it names and that unit's list, `None` for a token +/// naming nobody. `held` is the object manager's lookup (`0x468460`); `live` lists a held unit. +pub(super) fn party_aura_lists<'a>( + group: Option<&GroupState>, + held: impl Fn(u64) -> Option<&'a ObjectStore>, + live: impl Fn(&ObjectStore) -> Vec, + catalog: Option<&SpellCatalog>, +) -> Vec<(&'static str, Option)> { + let Some(group) = group else { + return PARTY_AURA_TOKENS.iter().map(|t| (*t, None)).collect(); + }; + let members: Vec = group.party_slots().map(|m| m.guid).collect(); + let list_of = |guid: u64| match held(guid) { + Some(store) => live(store), + None => super::record_auras(&group.roster_aura_slots(guid), catalog), + }; + let (member_tokens, pet_tokens) = PARTY_AURA_TOKENS.split_at(4); + let mut out = Vec::with_capacity(PARTY_AURA_TOKENS.len()); + for (i, token) in member_tokens.iter().enumerate() { + let guid = members.get(i).copied(); + out.push((*token, guid.map(|g| (g, list_of(g))))); + } + for (i, token) in pet_tokens.iter().enumerate() { + let pet = members + .get(i) + .and_then(|&m| group.party_pet_guid(m, held(m).map(|s| &s.0))); + out.push((*token, pet.map(|g| (g, list_of(g))))); + } + out +} + +#[cfg(test)] +mod tests { + use benilla_protocol::messages::{ + member_status, GroupMemberEntry, ObjectFields, PartyMemberStatsInfo, + }; + use benilla_ui::script::UiScript; + use bevy::prelude::*; + + use crate::char_select::ClientState; + use crate::net::{Guid, GuidIndex, NetCommands, ObjectStore, Reputations, SelfPlayer}; + use crate::ui_party::GroupState; + + const ME: u64 = 0x10; + const MEMBER: u64 = 0x1234; + const PET: u64 = 0xF140_0000_0000_0077; + const AURA: u16 = 47; + const AURAFLAGS: u16 = 95; + const SUMMON: u16 = 8; + /// `UNIT_FIELD_FLAGS`; bit 27 is `UNIT_FLAG_AURAS_VISIBLE`, which opens `UnitBuff`'s gate. + const FLAGS: u16 = 46; + + /// The feed on a bare app with a VM, us, and a party of one, `MEMBER`, with no record. + fn party_app() -> App { + let (tx, _) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin)) + .insert_state(ClientState::InWorld) + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .insert_resource(NetCommands(tx)) + .add_plugins(super::super::UiAuraPlugin); + app.insert_non_send_resource(UiScript::new().unwrap()); + app.world_mut() + .non_send_resource::() + .run( + r#" + SEEN = {} + local f = CreateFrame("Frame") + f:RegisterEvent("UNIT_AURA") + -- The party's tokens only: the player's list fires on the first frame too. + f:SetScript("OnEvent", function() + if string.find(arg1, "^party") then table.insert(SEEN, arg1) end + end) + "#, + ) + .unwrap(); + let me = app + .world_mut() + .spawn((ObjectStore(ObjectFields::default()), Guid(ME), SelfPlayer)) + .id(); + app.world_mut().resource_mut::().0.insert(ME, me); + let member = GroupMemberEntry { + name: "Brisca".into(), + guid: MEMBER, + status: member_status::ONLINE, + flags: 0, + }; + app.world_mut().resource_mut::().apply_list( + 0, + 0, + vec![member], + ME, + None, + Some(ME), + ); + app + } + + /// Stream `guid` in with these fields, as the object manager holds it. + fn stream(app: &mut App, guid: u64, fields: &[(u16, u32)]) { + let e = app + .world_mut() + .spawn(ObjectStore(ObjectFields::from_pairs(fields))) + .id(); + app.world_mut() + .resource_mut::() + .0 + .insert(guid, e); + } + + fn stats(app: &mut App, full: bool, info: PartyMemberStatsInfo) { + app.world_mut() + .resource_mut::() + .apply_stats(MEMBER, full, info); + } + + /// The spell ids `UnitBuff` (or `UnitDebuff`) enumerates for `token`, index 1 up to the nil, + /// through `UnitAura`'s tenth return, as the icon needs a spell catalog. + fn listed(app: &mut App, token: &str, harmful: bool) -> Vec { + let (verb, filter) = if harmful { + ("UnitDebuff", "HARMFUL") + } else { + ("UnitBuff", "HELPFUL") + }; + app.world_mut() + .non_send_resource::() + .eval::>(&format!( + r#"local out = {{}} + for i = 1, 48 do + local _, count = {verb}("{token}", i) + if count == nil then break end + local _, _, _, _, _, _, _, _, _, id = UnitAura("{token}", i, "{filter}") + table.insert(out, id) + end + return out"# + )) + .unwrap() + } + + fn seen(app: &mut App) -> Vec { + app.world_mut() + .non_send_resource::() + .eval::>("local s = SEEN SEEN = {} return s") + .unwrap() + } + + /// A live member reads its own `UNIT_FIELD_AURA` slots; `UnitBuff` keeps the unit gate, open + /// here through `UNIT_FLAG_AURAS_VISIBLE` (`0x5195bc`), and a stale slot stays out. + #[test] + fn a_live_members_auras_come_off_its_descriptor() { + let mut app = party_app(); + stream( + &mut app, + MEMBER, + &[ + (FLAGS, 0x0800_0000), + (AURA, 1126), + (AURA + 1, 6673), // stale: nibble 0x1 has no effect bit + (AURA + 33, 589), + (AURA + 34, 11976), + (AURAFLAGS, 0x0000_0018), + (AURAFLAGS + 4, 0x0000_0880), + ], + ); + // A record that disagrees, to show the live object wins. + stats( + &mut app, + true, + PartyMemberStatsInfo { + status: Some(member_status::ONLINE), + auras_negative: Some(vec![(40, 770)]), + ..Default::default() + }, + ); + app.update(); + assert_eq!(listed(&mut app, "party1", true), [589, 11976]); + assert_eq!(listed(&mut app, "party1", false), [1126]); + assert!( + listed(&mut app, "party2", true).is_empty(), + "no second member" + ); + assert_eq!(seen(&mut app), ["party1"]); + } + + /// With no live object, both bindings walk the record's block (`0x519735`, `0x519b25`), with + /// no unit gate, a stack count of 1 (`0x51980f`), and nil for an offline record (`0x519741`). + #[test] + fn an_out_of_range_member_reads_the_stats_aura_block() { + let mut app = party_app(); + stats( + &mut app, + true, + PartyMemberStatsInfo { + status: Some(member_status::ONLINE), + auras: Some(vec![(0, 1126), (3, 21562)]), + auras_negative: Some(vec![(33, 589)]), + ..Default::default() + }, + ); + app.update(); + assert_eq!( + listed(&mut app, "party1", false), + [1126, 21562], + "buffs, ungated: no factions, which would close the live gate" + ); + assert_eq!(listed(&mut app, "party1", true), [589]); + let count = app + .world_mut() + .non_send_resource::() + .eval::(r#"local _, count = UnitDebuff("party1", 1) return count"#) + .unwrap(); + assert_eq!(count, 1); + assert_eq!(seen(&mut app), ["party1"]); + + stats( + &mut app, + false, + PartyMemberStatsInfo { + status: Some(member_status::OFFLINE), + ..Default::default() + }, + ); + app.update(); + assert!(listed(&mut app, "party1", false).is_empty()); + assert!(listed(&mut app, "party1", true).is_empty()); + } + + /// The record is slot-indexed: deltas patch named slots, the halves split at slot 32, and each + /// walk is ascending slot whatever order the slots filled in. + #[test] + fn record_auras_list_in_slot_order_and_split_buffs_from_debuffs() { + let mut app = party_app(); + stats( + &mut app, + true, + PartyMemberStatsInfo { + status: Some(member_status::ONLINE), + auras: Some(vec![(5, 1126)]), + auras_negative: Some(vec![(40, 770)]), + ..Default::default() + }, + ); + app.update(); + let _ = seen(&mut app); + // A later aura in a lower slot, then a debuff, as two deltas. + stats( + &mut app, + false, + PartyMemberStatsInfo { + auras: Some(vec![(2, 21562)]), + ..Default::default() + }, + ); + stats( + &mut app, + false, + PartyMemberStatsInfo { + auras_negative: Some(vec![(34, 589)]), + ..Default::default() + }, + ); + app.update(); + assert_eq!(listed(&mut app, "party1", false), [21562, 1126]); + assert_eq!(listed(&mut app, "party1", true), [589, 770]); + assert_eq!(seen(&mut app), ["party1"]); + + // Slot 5 falls off: the delta names it with a 0, and only it goes. + stats( + &mut app, + false, + PartyMemberStatsInfo { + auras: Some(vec![(5, 0)]), + ..Default::default() + }, + ); + app.update(); + assert_eq!(listed(&mut app, "party1", false), [21562]); + assert_eq!(listed(&mut app, "party1", true), [589, 770]); + assert_eq!(seen(&mut app), ["party1"]); + + // A delta that moves no aura fires nothing. + stats( + &mut app, + false, + PartyMemberStatsInfo { + cur_hp: Some(7), + ..Default::default() + }, + ); + app.update(); + assert!(seen(&mut app).is_empty()); + } + + /// `partypetN` names the member's `CHARM`, else `SUMMON`, while the member is held, else the + /// record's pet guid (`0x4e81d0`); the pet's list is its own descriptor while held, else the + /// pet record's block (`0x496420`, `+0xe4`). + #[test] + fn a_party_pet_reads_its_descriptor_or_the_pet_block() { + let mut app = party_app(); + stats( + &mut app, + true, + PartyMemberStatsInfo { + status: Some(member_status::ONLINE), + pet_guid: Some(PET), + pet_auras: Some(vec![(1, 1126)]), + pet_auras_negative: Some(vec![(32, 770)]), + ..Default::default() + }, + ); + app.update(); + assert_eq!(listed(&mut app, "partypet1", false), [1126]); + assert_eq!(listed(&mut app, "partypet1", true), [770]); + assert!(listed(&mut app, "partypet2", true).is_empty()); + assert_eq!(seen(&mut app), ["party1", "partypet1"]); + + // A pet-aura delta moves the pet's list and fires for its token alone. + stats( + &mut app, + false, + PartyMemberStatsInfo { + pet_auras_negative: Some(vec![(32, 0), (33, 589)]), + ..Default::default() + }, + ); + app.update(); + assert_eq!(listed(&mut app, "partypet1", true), [589]); + assert_eq!(seen(&mut app), ["partypet1"]); + + // Member and pet in view: the pet guid comes off the member's SUMMON, the list off the + // pet's own slots. + stream( + &mut app, + MEMBER, + &[(SUMMON, PET as u32), (SUMMON + 1, (PET >> 32) as u32)], + ); + stream( + &mut app, + PET, + &[(AURA + 35, 11976), (AURAFLAGS + 4, 0x0000_2000)], + ); + app.update(); + assert_eq!(listed(&mut app, "partypet1", true), [11976]); + assert!(listed(&mut app, "partypet1", false).is_empty()); + assert!(seen(&mut app).contains(&"partypet1".to_string())); + } +} diff --git a/crates/benilla-app/src/ui_party/mod.rs b/crates/benilla-app/src/ui_party/mod.rs index b2b2d242..8d175ed4 100644 --- a/crates/benilla-app/src/ui_party/mod.rs +++ b/crates/benilla-app/src/ui_party/mod.rs @@ -7,7 +7,8 @@ use std::collections::HashMap; use crate::ui_action::UiError; use benilla_protocol::messages::{ - party_operation, party_result, GroupLootInfo, GroupMemberEntry, PartyMemberStatsInfo, + party_operation, party_result, patch_auras, GroupLootInfo, GroupMemberEntry, + PartyMemberStatsInfo, }; use bevy::prelude::*; @@ -15,6 +16,7 @@ use crate::ui_script::{UiFeed, UiInput}; mod feed; pub(crate) mod net; +mod records; pub(crate) use feed::{ raid_row_guid, synthetic_raid, synthetic_roster, GROUPTYPE_RAID, GROUP_MEMBER_SUBGROUP, PARTY_TOKENS, RAID_TOKENS, @@ -280,11 +282,12 @@ impl GroupState { } } - /// `SMSG_PARTY_MEMBER_STATS`: a delta merges field by field, `_FULL` replaces the record. + /// `SMSG_PARTY_MEMBER_STATS`: a delta patches the record field by field (`0x5e519d`, seeded from + /// the stored record), `_FULL` patches an emptied one (`0x5e51b1`). An aura block is patched + /// slot by slot ([`patch_auras`]). pub fn apply_stats(&mut self, guid: u64, full: bool, info: PartyMemberStatsInfo) { if full { - self.stats.insert(guid, info); - return; + self.stats.insert(guid, PartyMemberStatsInfo::default()); } let entry = self.stats.entry(guid).or_default(); macro_rules! merge { @@ -310,20 +313,18 @@ impl GroupState { pet_cur_power, pet_max_power, ); - if info.auras.is_some() { - entry.auras = info.auras; - } - if info.auras_negative.is_some() { - entry.auras_negative = info.auras_negative; - } if info.pet_name.is_some() { entry.pet_name = info.pet_name; } - if info.pet_auras.is_some() { - entry.pet_auras = info.pet_auras; - } - if info.pet_auras_negative.is_some() { - entry.pet_auras_negative = info.pet_auras_negative; + for (record, patch) in [ + (&mut entry.auras, info.auras), + (&mut entry.auras_negative, info.auras_negative), + (&mut entry.pet_auras, info.pet_auras), + (&mut entry.pet_auras_negative, info.pet_auras_negative), + ] { + if let Some(patch) = patch { + patch_auras(record, &patch); + } } } diff --git a/crates/benilla-app/src/ui_party/net.rs b/crates/benilla-app/src/ui_party/net.rs index bce20103..00135661 100644 --- a/crates/benilla-app/src/ui_party/net.rs +++ b/crates/benilla-app/src/ui_party/net.rs @@ -298,13 +298,14 @@ fn seat_new_records( } /// The despawn hook, the reference's deactivate virtual `0x5e9aa0` (object destroyed or out of -/// range): for a roster member, snapshot the live descriptor into their record (`0x5f0880`), then -/// request their stats (`0x4e8646`). Nothing happens off the roster or without an object, since -/// the hook is a virtual on the object. -pub(crate) fn member_deactivated( +/// range): for a roster member, snapshot the live descriptor into their record (`0x5f0880`), their +/// pet's too while `held` finds it, then request their stats (`0x4e8646`). Nothing happens off the +/// roster or without an object, since the hook is a virtual on the object. +pub(crate) fn member_deactivated<'a>( guid: u64, group: &mut GroupState, - store: Option<&ObjectStore>, + store: Option<&'a ObjectStore>, + held: impl Fn(u64) -> Option<&'a ObjectStore>, net_commands: &NetCommands, ) { let Some(store) = store else { @@ -313,11 +314,12 @@ pub(crate) fn member_deactivated( if !group.members.iter().any(|m| m.guid == guid) { return; } + let pet = store.0.unit_pet_guid().and_then(held); group .stats .entry(guid) .or_default() - .snapshot_descriptor(&store.0); + .snapshot_descriptor(&store.0, pet.map(|p| &p.0)); let _ = net_commands .0 .send(ClientCommand::RequestPartyMemberStats { guid }); @@ -337,9 +339,9 @@ pub(crate) fn roster_deactivated( .map(|m| m.guid) .filter(|g| index.0.contains_key(g)) .collect(); + let held = |g: u64| index.0.get(&g).and_then(|e| stores.get(*e).ok()); for guid in streamed { - let store = index.0.get(&guid).and_then(|e| stores.get(*e).ok()); - member_deactivated(guid, group, store, net_commands); + member_deactivated(guid, group, held(guid), held, net_commands); } } @@ -528,7 +530,7 @@ mod tests { (LEVEL, 41), (BYTES_0, 1 << 24), // POWER_RAGE in BYTES_0 byte 3 ])); - member_deactivated(guid, &mut group, Some(&store), &net); + member_deactivated(guid, &mut group, Some(&store), |_| None, &net); let rec = group.stats.get(&guid).expect("the member has a record"); assert_eq!( @@ -550,6 +552,55 @@ mod tests { assert_eq!(asked(&rx), vec![guid], "and the server is asked, once"); } + /// `0x5f098d`-`0x5f09f2`: a slot is copied when its id is set and its `AURAFLAGS` nibble has an + /// effect bit; the pet block comes off the pet's own object (`0x5f0a1f`-`0x5f0b72`). + #[test] + fn a_members_despawn_snapshots_their_auras_and_their_pets() { + const AURA: u16 = 47; + const AURAFLAGS: u16 = 95; + const SUMMON: u16 = 8; + let (tx, _rx) = crossbeam_channel::unbounded(); + let net = NetCommands(tx); + let guid = 0x1234; + let pet_guid = 0xF140_0000_0000_0077; + let mut group = grouped(&[member(guid, "Brisca")]); + + let store = ObjectStore(benilla_protocol::messages::ObjectFields::from_pairs(&[ + (AURA, 1126), // slot 0, live (nibble 0x8) + (AURA + 1, 6673), // slot 1, a stale id: its nibble has no effect bit + (AURA + 33, 589), // slot 33, live + (AURAFLAGS, 0x0000_0018), + (AURAFLAGS + 4, 0x0000_0020), + (SUMMON, pet_guid as u32), + (SUMMON + 1, (pet_guid >> 32) as u32), + ])); + let pet = ObjectStore(benilla_protocol::messages::ObjectFields::from_pairs(&[ + (HEALTH, 900), + (AURA + 40, 770), + (AURAFLAGS + 5, 0x0000_0002), + ])); + let held = |g: u64| (g == pet_guid).then_some(&pet); + member_deactivated(guid, &mut group, Some(&store), held, &net); + + let rec = group.stats.get(&guid).expect("the member has a record"); + assert_eq!( + rec.auras, + Some(vec![(0, 1126)]), + "the stale slot 1 is dropped" + ); + assert_eq!(rec.auras_negative, Some(vec![(33, 589)])); + assert_eq!(rec.pet_guid, Some(pet_guid)); + assert_eq!(rec.pet_cur_hp, Some(900)); + assert_eq!(rec.pet_auras, Some(vec![])); + assert_eq!(rec.pet_auras_negative, Some(vec![(40, 770)])); + + // The pet not held: its block is emptied, not left as it was. + member_deactivated(guid, &mut group, Some(&store), |_| None, &net); + let rec = group.stats.get(&guid).unwrap(); + assert_eq!((rec.pet_guid, rec.pet_auras_negative.clone()), (None, None)); + assert_eq!(rec.auras, Some(vec![(0, 1126)])); + } + #[test] fn a_despawn_that_is_not_a_party_member_asks_nothing() { let (tx, rx) = crossbeam_channel::unbounded(); @@ -560,12 +611,12 @@ mod tests { let store = ObjectStore(benilla_protocol::messages::ObjectFields::from_pairs(&[( HEALTH, 40, )])); - member_deactivated(0xdead, &mut group, Some(&store), &net); + member_deactivated(0xdead, &mut group, Some(&store), |_| None, &net); assert!(asked(&rx).is_empty()); assert!(!group.stats.contains_key(&0xdead)); // The object gate alone: a roster member we hold no object for asks nothing. - member_deactivated(0x1234, &mut group, None, &net); + member_deactivated(0x1234, &mut group, None, |_| None, &net); assert!(asked(&rx).is_empty()); } diff --git a/crates/benilla-app/src/ui_party/records.rs b/crates/benilla-app/src/ui_party/records.rs new file mode 100644 index 00000000..a8f67ec5 --- /dev/null +++ b/crates/benilla-app/src/ui_party/records.rs @@ -0,0 +1,138 @@ +//! The roster records' readers for a guid the object manager does not hold: the member record +//! (`0x496400`), the pet record (`0x496420`) and the party pet's guid (`0x4e81d0`). + +use benilla_protocol::messages::{member_status, ObjectFields, PartyMemberStatsInfo}; + +use super::GroupState; + +impl GroupState { + /// The member record for `guid`, party or raid (`0x496400`). + fn member_record(&self, guid: u64) -> Option<&PartyMemberStatsInfo> { + self.stats.get(&guid) + } + + /// The record whose pet block names `guid` (`0x496420`); a pet is found only while its owner's + /// record reads online (`0x4e8860`). + fn pet_record(&self, guid: u64) -> Option<&PartyMemberStatsInfo> { + self.stats + .values() + .find(|r| guid != 0 && r.pet_guid == Some(guid) && online(r)) + } + + /// `partypetN`'s guid for the member `member` (`0x4e81d0`): `CHARM`, else `SUMMON`, off the + /// member's descriptor while it is held (`0x4e8204`-`0x4e821a`), else the record's pet guid + /// while the record reads online (`0x4e8227`-`0x4e8236`). + pub(crate) fn party_pet_guid(&self, member: u64, live: Option<&ObjectFields>) -> Option { + match live { + Some(fields) => fields.unit_pet_guid(), + None => self + .member_record(member) + .filter(|r| online(r)) + .and_then(|r| r.pet_guid) + .filter(|&g| g != 0), + } + } + + /// The aura slots `UnitBuff` and `UnitDebuff` walk for a guid with no live object + /// (`0x519735`, `0x519b25`), ascending, buffs then debuffs: a member record's block (`+0x1a`) + /// unless it reads offline, which answers nothing and never tries the pet records + /// (`0x519741`); on a miss, the block of the pet record naming the guid (`+0xe4`, `0x519760`). + pub(crate) fn roster_aura_slots(&self, guid: u64) -> Vec<(u8, u16)> { + let (buffs, debuffs) = match self.member_record(guid) { + Some(r) if !online(r) => return Vec::new(), + Some(r) => (&r.auras, &r.auras_negative), + None => match self.pet_record(guid) { + Some(r) => (&r.pet_auras, &r.pet_auras_negative), + None => return Vec::new(), + }, + }; + buffs.iter().chain(debuffs).flatten().copied().collect() + } +} + +/// The record's status bit 0 (`[rec+8] & 1`), the online bit. +fn online(r: &PartyMemberStatsInfo) -> bool { + r.status.unwrap_or(0) & member_status::ONLINE != 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use benilla_protocol::messages::GroupMemberEntry; + + const MEMBER: u64 = 0x1234; + const PET: u64 = 0xF140_0000_0000_0077; + + fn group_with(record: PartyMemberStatsInfo) -> GroupState { + let mut g = GroupState::default(); + g.apply_list( + 0, + 0, + vec![GroupMemberEntry { + name: "Brisca".into(), + guid: MEMBER, + status: member_status::ONLINE, + flags: 0, + }], + MEMBER, + None, + None, + ); + g.stats.insert(MEMBER, record); + g + } + + fn record(status: u8) -> PartyMemberStatsInfo { + PartyMemberStatsInfo { + status: Some(status), + auras: Some(vec![(0, 1126), (4, 21562)]), + auras_negative: Some(vec![(32, 589)]), + pet_guid: Some(PET), + pet_auras: Some(vec![(2, 1126)]), + pet_auras_negative: Some(vec![(40, 770)]), + ..PartyMemberStatsInfo::default() + } + } + + #[test] + fn a_member_record_answers_its_own_block_and_an_offline_one_answers_nothing() { + let g = group_with(record(member_status::ONLINE)); + assert_eq!( + g.roster_aura_slots(MEMBER), + [(0, 1126), (4, 21562), (32, 589)], + "buffs then debuffs, ascending slot" + ); + assert_eq!(g.roster_aura_slots(PET), [(2, 1126), (40, 770)]); + assert!(g.roster_aura_slots(0x9999).is_empty(), "a miss everywhere"); + + let g = group_with(record(member_status::OFFLINE)); + assert!(g.roster_aura_slots(MEMBER).is_empty()); + assert!( + g.roster_aura_slots(PET).is_empty(), + "an offline owner's pet is not found" + ); + } + + #[test] + fn the_party_pet_guid_is_the_live_charm_then_summon_else_the_records() { + let g = group_with(record(member_status::ONLINE)); + assert_eq!(g.party_pet_guid(MEMBER, None), Some(PET)); + + let summon = ObjectFields::from_pairs(&[(8, 0x55), (9, 0)]); + assert_eq!(g.party_pet_guid(MEMBER, Some(&summon)), Some(0x55)); + let both = ObjectFields::from_pairs(&[(6, 0x66), (7, 0), (8, 0x55), (9, 0)]); + assert_eq!( + g.party_pet_guid(MEMBER, Some(&both)), + Some(0x66), + "CHARM first" + ); + // A held member with neither is petless, whatever the record says. + assert_eq!( + g.party_pet_guid(MEMBER, Some(&ObjectFields::default())), + None + ); + + let g = group_with(record(member_status::OFFLINE)); + assert_eq!(g.party_pet_guid(MEMBER, None), None); + } +} diff --git a/crates/benilla-protocol/src/messages/group.rs b/crates/benilla-protocol/src/messages/group.rs index e0ef1756..de465d5d 100644 --- a/crates/benilla-protocol/src/messages/group.rs +++ b/crates/benilla-protocol/src/messages/group.rs @@ -164,10 +164,12 @@ pub struct PartyMemberStatsInfo { pub zone: Option, /// Raw WoW x and y, truncated to `i16` by the server (`GroupHandler.cpp:625`). pub position: Option<(i16, i16)>, - /// Active buff spell ids in slot order; the wire's `u32` slot mask is not kept. - pub auras: Option>, - /// Active debuff spell ids, likewise, from a `u16` slot mask. - pub auras_negative: Option>, + /// Buff slots 0..31 as `(slot, spell id)`, one per set bit of the wire's `u32` slot mask, in + /// slot order. A delta's mask names the slots that changed, a 0 id clearing one + /// (`Player::GetAuraUpdateMask`), so the record is patched slot by slot ([`patch_auras`]). + pub auras: Option>, + /// Debuff slots, likewise, from a `u16` mask; the slot is absolute, 32..47. + pub auras_negative: Option>, pub pet_guid: Option, pub pet_name: Option, pub pet_model_id: Option, @@ -176,8 +178,26 @@ pub struct PartyMemberStatsInfo { pub pet_power_type: Option, pub pet_cur_power: Option, pub pet_max_power: Option, - pub pet_auras: Option>, - pub pet_auras_negative: Option>, + /// The pet's buff and debuff slots, as [`Self::auras`] and [`Self::auras_negative`]. + pub pet_auras: Option>, + pub pet_auras_negative: Option>, +} + +/// Apply one aura-block field to a record's (`0x5e52d0`-`0x5e52f8`): each named slot takes its +/// id, the other slots keep theirs, and a 0 empties the slot. The record keeps the occupied slots +/// in ascending order, the order `UnitBuff`'s roster walk reads them in (`0x519780`). +pub fn patch_auras(record: &mut Option>, patch: &[(u8, u16)]) { + let slots = record.get_or_insert_with(Vec::new); + for &(slot, spell) in patch { + match slots.binary_search_by_key(&slot, |&(s, _)| s) { + Ok(i) if spell == 0 => { + slots.remove(i); + } + Ok(i) => slots[i].1 = spell, + Err(i) if spell != 0 => slots.insert(i, (slot, spell)), + Err(_) => {} + } + } } impl PartyMemberStatsInfo { @@ -197,10 +217,18 @@ impl PartyMemberStatsInfo { /// Snapshot a member's live descriptor as the 1.12 client does (`0x5f0880`) when the member's /// object leaves view, just before the stats request, so the frame never reads 0/0. Values are /// raw; [`Self::shown_power`] divides at the read. AFK and DND are cleared, as in the client. + /// `pet` is the object of the member's pet guid ([`ObjectFields::unit_pet_guid`]) when held: + /// its block is copied, and without one the pet block is emptied (`0x5f0a1f`-`0x5f0b72`). /// /// Deviation: zone and position are kept, not set to the viewer's zone and the object's - /// position, and the aura and pet blocks are not copied, because nothing in benilla reads them. - pub fn snapshot_descriptor(&mut self, fields: &crate::messages::update_object::ObjectFields) { + /// position, and the pet's name is not copied, because nothing in benilla reads them. + /// + /// [`ObjectFields::unit_pet_guid`]: crate::messages::update_object::ObjectFields::unit_pet_guid + pub fn snapshot_descriptor( + &mut self, + fields: &crate::messages::update_object::ObjectFields, + pet: Option<&crate::messages::update_object::ObjectFields>, + ) { let mut status = member_status::ONLINE; if fields.player_is_ghost() { status |= member_status::GHOST; @@ -226,6 +254,39 @@ impl PartyMemberStatsInfo { self.cur_power = Some(fields.unit_power(power_type).unwrap_or(0) as u16); self.max_power = Some(fields.unit_max_power(power_type).unwrap_or(0) as u16); self.level = Some(fields.unit_level().unwrap_or(0) as u16); + let (buffs, debuffs) = snapshot_aura_block(fields); + self.auras = Some(buffs); + self.auras_negative = Some(debuffs); + + let pet_guid = fields.unit_pet_guid(); + match pet_guid.zip(pet) { + Some((guid, pet)) => { + let power_type = pet.unit_power_type(); + self.pet_guid = Some(guid); + self.pet_model_id = Some(pet.unit_displayid().unwrap_or(0) as u16); + self.pet_cur_hp = Some(pet.unit_health().unwrap_or(0) as u16); + self.pet_max_hp = Some(pet.unit_max_health().unwrap_or(0) as u16); + self.pet_power_type = Some(power_type); + self.pet_cur_power = Some(pet.unit_power(power_type).unwrap_or(0) as u16); + self.pet_max_power = Some(pet.unit_max_power(power_type).unwrap_or(0) as u16); + let (buffs, debuffs) = snapshot_aura_block(pet); + self.pet_auras = Some(buffs); + self.pet_auras_negative = Some(debuffs); + } + // No pet object: the block is zero-filled (`0x5f0a0f`), its guid included. + None => { + self.pet_guid = None; + self.pet_name = None; + self.pet_model_id = None; + self.pet_cur_hp = None; + self.pet_max_hp = None; + self.pet_power_type = None; + self.pet_cur_power = None; + self.pet_max_power = None; + self.pet_auras = None; + self.pet_auras_negative = None; + } + } } /// `UnitPowerType` from the record, 0 when it has none (the binding's miss value). @@ -247,15 +308,40 @@ impl PartyMemberStatsInfo { } } -/// One `u16` spell id per set bit of `mask`, in bit order (`GroupHandler.cpp:627-648`). -fn read_aura_spells(r: &mut impl Read, mask: u32, bits: u32) -> io::Result> { - let mut spells = Vec::new(); +/// `(slot, spell id)` pairs of one aura block, as [`PartyMemberStatsInfo::auras`] holds them. +type AuraSlots = Vec<(u8, u16)>; + +/// A live descriptor's aura block as the snapshot copies it (`0x5f098d`-`0x5f09f2`): a slot is +/// kept when its id is set and its `AURAFLAGS` nibble has an effect bit (`test al,0xe`), the test +/// [`ObjectFields::unit_auras`] makes; the record holds each id's low word. +/// +/// [`ObjectFields::unit_auras`]: crate::messages::update_object::ObjectFields::unit_auras +fn snapshot_aura_block( + fields: &crate::messages::update_object::ObjectFields, +) -> (AuraSlots, AuraSlots) { + use crate::messages::update_object::UNIT_AURA_POSITIVE_SLOTS; + fields + .unit_auras() + .map(|a| (a.slot, a.spell_id as u16)) + .filter(|&(_, id)| id != 0) + .partition(|&(slot, _)| slot < UNIT_AURA_POSITIVE_SLOTS) +} + +/// One `(slot, spell id)` per set bit of `mask`, in bit order, the slot counted from `first` +/// (`GroupHandler.cpp:627-648`; the client's reads, `0x5e52d0`-`0x5e52f8`). +fn read_aura_slots( + r: &mut impl Read, + mask: u32, + first: u8, + bits: u8, +) -> io::Result> { + let mut slots = Vec::new(); for bit in 0..bits { if mask & (1 << bit) != 0 { - spells.push(read_u16_le(r)?); + slots.push((first + bit, read_u16_le(r)?)); } } - Ok(spells) + Ok(slots) } /// Read `SMSG_PARTY_MEMBER_STATS` or `_FULL`; the guid is packed (`GroupHandler.cpp:593`). @@ -297,11 +383,11 @@ pub(super) fn read_party_member_stats( } if mask & party_member_mask::AURAS != 0 { let pos_mask = read_u32_le(r)?; - info.auras = Some(read_aura_spells(r, pos_mask, 32)?); + info.auras = Some(read_aura_slots(r, pos_mask, 0, 32)?); } if mask & party_member_mask::AURAS_NEGATIVE != 0 { let neg_mask = u32::from(read_u16_le(r)?); - info.auras_negative = Some(read_aura_spells(r, neg_mask, 16)?); + info.auras_negative = Some(read_aura_slots(r, neg_mask, 32, 16)?); } if mask & party_member_mask::PET_GUID != 0 { info.pet_guid = Some(read_u64_le(r)?); @@ -329,11 +415,11 @@ pub(super) fn read_party_member_stats( } if mask & party_member_mask::PET_AURAS != 0 { let pos_mask = read_u32_le(r)?; - info.pet_auras = Some(read_aura_spells(r, pos_mask, 32)?); + info.pet_auras = Some(read_aura_slots(r, pos_mask, 0, 32)?); } if mask & party_member_mask::PET_AURAS_NEGATIVE != 0 { let neg_mask = u32::from(read_u16_le(r)?); - info.pet_auras_negative = Some(read_aura_spells(r, neg_mask, 16)?); + info.pet_auras_negative = Some(read_aura_slots(r, neg_mask, 32, 16)?); } Ok((guid, info)) } diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index e0814a6a..b10ed6d4 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -124,10 +124,10 @@ pub use group::{ group_accept, group_assistant_leader, group_change_sub_group, group_decline, group_disband, group_invite, group_raid_convert, group_set_leader, group_swap_sub_group, group_uninvite, group_uninvite_guid, loot_method, member_status, minimap_ping, party_member_mask, - party_operation, party_result, raid_target_request, raid_target_set, ready_check_answer, - ready_check_start, request_party_member_stats, request_raid_info, GroupLootInfo, - GroupMemberEntry, PartyMemberStatsInfo, RaidInstanceEntry, RaidTargetUpdate, ReadyCheck, - GROUP_MEMBER_ASSISTANT, + party_operation, party_result, patch_auras, raid_target_request, raid_target_set, + ready_check_answer, ready_check_start, request_party_member_stats, request_raid_info, + GroupLootInfo, GroupMemberEntry, PartyMemberStatsInfo, RaidInstanceEntry, RaidTargetUpdate, + ReadyCheck, GROUP_MEMBER_ASSISTANT, }; pub use guild::{ guild_accept, guild_add_rank, guild_command, guild_command_error, guild_create, guild_decline, diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs index a9d5a74c..b657c5b9 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -33,6 +33,8 @@ pub const FIELD_CORPSE_DYNAMIC_FLAGS: u16 = 36; // bytes 0xa0, 0x1f0, 0x224 and 0x234. /// The unit's target; the client turns an idle unit to face it, and no packet carries that facing. const FIELD_UNIT_TARGET: u16 = 16; +/// The unit this one charms (`UpdateFields_1_12_1.h:41`), descriptor byte 0. +const FIELD_UNIT_CHARM: u16 = 6; /// The unit this one summoned (`UpdateFields_1_12_1.h:42`); on us, the `"pet"` unit. The pet bar /// reads its guid off `SMSG_PET_SPELLS`, so the two can disagree briefly around a summon. const FIELD_UNIT_SUMMON: u16 = 8; diff --git a/crates/benilla-protocol/src/messages/update_object/fields/unit.rs b/crates/benilla-protocol/src/messages/update_object/fields/unit.rs index 1ce69315..2feace7c 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/unit.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/unit.rs @@ -67,6 +67,15 @@ impl ObjectFields { pub fn unit_summon(&self) -> Option { self.get_guid(FIELD_UNIT_SUMMON).filter(|&g| g != 0) } + /// `UNIT_FIELD_CHARM`: the unit this one charms. + pub fn unit_charm(&self) -> Option { + self.get_guid(FIELD_UNIT_CHARM).filter(|&g| g != 0) + } + /// A player's pet as the client reads it off the descriptor: `CHARM` if set, else `SUMMON` + /// (`partypetN`'s `0x4e8204`-`0x4e821a`, the party snapshot's `0x5f0a25`-`0x5f0a39`). + pub fn unit_pet_guid(&self) -> Option { + self.unit_charm().or_else(|| self.unit_summon()) + } /// `UNIT_FIELD_SUMMONEDBY`: the summoner of a pet, guardian or totem. pub fn unit_summoned_by(&self) -> Option { self.get_guid(FIELD_UNIT_SUMMONEDBY).filter(|&g| g != 0) diff --git a/crates/benilla-protocol/tests/group.rs b/crates/benilla-protocol/tests/group.rs index cddbd584..312ece28 100644 --- a/crates/benilla-protocol/tests/group.rs +++ b/crates/benilla-protocol/tests/group.rs @@ -5,8 +5,8 @@ mod common; use benilla_protocol::events::{decode, SessionEvent}; use benilla_protocol::messages::{ - self, member_status, opcode, party_member_mask, party_operation, party_result, GroupLootInfo, - GroupMemberEntry, PartyMemberStatsInfo, GROUP_MEMBER_ASSISTANT, + self, member_status, opcode, party_member_mask, party_operation, party_result, patch_auras, + GroupLootInfo, GroupMemberEntry, PartyMemberStatsInfo, GROUP_MEMBER_ASSISTANT, }; use benilla_protocol::ServerPacket; use common::hx; @@ -518,8 +518,9 @@ fn party_member_stats_full_position_auras_and_pet_block() { assert_eq!(*guid, 0x7F); assert!(*full); assert_eq!(info.position, Some((1234, -5678))); - assert_eq!(info.auras, Some(vec![133, 116])); - assert_eq!(info.auras_negative, Some(vec![8050])); + assert_eq!(info.auras, Some(vec![(0, 133), (5, 116)])); + // The debuff slot is absolute: mask bit 2 is slot 34. + assert_eq!(info.auras_negative, Some(vec![(34, 8050)])); assert_eq!(info.pet_guid, Some(0x1122_3344_5566_7788)); assert_eq!(info.pet_name.as_deref(), Some("Fido")); assert_eq!(info.pet_model_id, Some(618)); @@ -528,8 +529,8 @@ fn party_member_stats_full_position_auras_and_pet_block() { assert_eq!(info.pet_power_type, Some(0)); assert_eq!(info.pet_cur_power, Some(80)); assert_eq!(info.pet_max_power, Some(100)); - assert_eq!(info.pet_auras, Some(vec![1126])); - assert_eq!(info.pet_auras_negative, Some(vec![770])); + assert_eq!(info.pet_auras, Some(vec![(3, 1126)])); + assert_eq!(info.pet_auras_negative, Some(vec![(33, 770)])); assert_eq!(info.status, None); assert_eq!(info.level, None); } @@ -546,6 +547,38 @@ fn party_member_stats_full_position_auras_and_pet_block() { } } +/// A delta's aura mask names the slots that changed, a 0 id among them for an aura that fell off +/// (`Player::GetAuraUpdateMask`, `GroupHandler.cpp:627-633`), and the record takes each named slot +/// and keeps the rest (`0x5e52d0`-`0x5e52f8`). +#[test] +fn a_delta_aura_mask_patches_the_named_slots_and_a_zero_clears_one() { + // Slots 0 and 5 changed: slot 0 lost its aura, slot 5 took 116. + let body = hx(concat!( + "017f", // packed guid 0x7F + "00020000", // mask: AURAS + "21000000", // slot mask: bits 0 and 5 + "0000", // slot 0: cleared + "7400", // slot 5: 116 + )); + let p = messages::parse_server(opcode::SMSG_PARTY_MEMBER_STATS, &body).unwrap(); + let ServerPacket::PartyMemberStats { info, full, .. } = &p else { + panic!("expected PartyMemberStats, got {}", p.name()); + }; + assert!(!*full); + assert_eq!(info.auras, Some(vec![(0, 0), (5, 116)])); + + let mut record = Some(vec![(0, 133), (3, 1126), (5, 10)]); + patch_auras(&mut record, info.auras.as_deref().unwrap()); + assert_eq!( + record, + Some(vec![(3, 1126), (5, 116)]), + "slot 0 emptied, slot 3 untouched, slot 5 overwritten" + ); + // A new slot goes in slot order, and clearing an empty slot is a no-op. + patch_auras(&mut record, &[(1, 774), (9, 0)]); + assert_eq!(record, Some(vec![(1, 774), (3, 1126), (5, 116)])); +} + /// The reply for a member not in our group (`Handlers/GroupHandler.cpp:773-789`): /// `SMSG_PARTY_MEMBER_STATS_FULL` with only `STATUS`, set to offline (0). #[test] From 03b1072220600827605010466398c99bfd233d4f Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:48:00 +0700 Subject: [PATCH 002/105] A script's selection, cast and targeting-cursor calls apply in the order it made them, so a macro's /target then /cast casts at the new target (#473) A script's calls that touch the selection, the player's cast or the targeting cursor now take effect in the order the script made them, as in 1.12.1, where each such call is done when it returns (the macro runner `0x4f14e0` fires every line in one pass; `TargetUnit` `0x4899d0` and `TargetByName` `0x489d60` commit through `SetSelection` `0x493540`; `CastSpellByName` `0x4b4ab0` casts at the selection as it stands then). - The VM keeps one ordered queue of these calls (`ScriptCall`) in place of fifteen per-kind queues, and one system applies it front to back in the target chain, each call through its owner's applier. - A macro's lines run inside the `UseAction` that pressed it (`0x4e6098 call 0x4f1460`), so the calls they make are applied before the next queued call. - `ClearTarget` deselects whatever is selected when it applies, so `TargetUnit("player") ClearTarget()` ends with no target. - `SpellStopCasting`, `SpellStopTargeting`, `TargetNearestFriend`, pet bar presses and orders and pet-book casts join the queue, since they touch the same state: `/script SpellStopCasting()` then `/cast`, and `/assist` then `/petattack`, now apply in order too. Tests run the real dispatcher on the headless client: target then cast casts at the new target, cast then target at the old one, a clear after a target ends with nothing selected, and a macro's calls land inside the `UseAction` that ran it. Built on macOS; no platform seam. Fixes #451 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../benilla-app/src/capture/probe_charter.rs | 2 +- crates/benilla-app/src/capture/probe_clam.rs | 2 +- crates/benilla-app/src/game_plugins.rs | 16 +- crates/benilla-app/src/lib.rs | 1 + crates/benilla-app/src/script_calls.rs | 297 ++++++++++++++ crates/benilla-app/src/spell/inflight.rs | 247 +++++++----- crates/benilla-app/src/spell/mod.rs | 23 +- crates/benilla-app/src/spell/targeting/mod.rs | 82 ++-- crates/benilla-app/src/target/by_name.rs | 136 ++++--- crates/benilla-app/src/target/click.rs | 169 +------- crates/benilla-app/src/target/mod.rs | 28 +- crates/benilla-app/src/target/scan.rs | 36 +- crates/benilla-app/src/target/script.rs | 197 +++++++++ crates/benilla-app/src/ui_action/drain.rs | 376 +++++++++--------- crates/benilla-app/src/ui_action/mod.rs | 3 +- crates/benilla-app/src/ui_items/drain.rs | 285 +++++++------ crates/benilla-app/src/ui_items/mod.rs | 11 +- crates/benilla-app/src/ui_pet/drain.rs | 138 ++++--- crates/benilla-app/src/ui_pet/mod.rs | 11 +- crates/benilla-app/src/ui_pet_book.rs | 76 ++-- .../benilla-app/src/ui_script/escape_tests.rs | 10 +- crates/benilla-app/src/ui_shapeshift.rs | 99 ++--- crates/benilla-app/src/ui_spellbook.rs | 91 ++--- crates/benilla-ui/src/script/action.rs | 11 +- crates/benilla-ui/src/script/calls.rs | 75 ++++ crates/benilla-ui/src/script/char_stats.rs | 9 +- crates/benilla-ui/src/script/chat_misc.rs | 6 +- crates/benilla-ui/src/script/container.rs | 11 +- crates/benilla-ui/src/script/cursor/doll.rs | 4 +- crates/benilla-ui/src/script/mod.rs | 2 + crates/benilla-ui/src/script/model.rs | 49 +-- crates/benilla-ui/src/script/pet.rs | 20 +- crates/benilla-ui/src/script/shapeshift.rs | 13 +- crates/benilla-ui/src/script/spellbook.rs | 50 ++- crates/benilla-ui/src/script/unit/bindings.rs | 33 +- crates/benilla-ui/src/script/unit/mod.rs | 39 +- docs/MAP.md | 2 +- 37 files changed, 1581 insertions(+), 1079 deletions(-) create mode 100644 crates/benilla-app/src/script_calls.rs create mode 100644 crates/benilla-app/src/target/script.rs create mode 100644 crates/benilla-ui/src/script/calls.rs diff --git a/crates/benilla-app/src/capture/probe_charter.rs b/crates/benilla-app/src/capture/probe_charter.rs index 8cbb8d03..c8746af6 100644 --- a/crates/benilla-app/src/capture/probe_charter.rs +++ b/crates/benilla-app/src/capture/probe_charter.rs @@ -710,7 +710,7 @@ fn charter_probe( } } // ── Step 6: the item-use fork ─────────────────────────────────────────────────────── - // A real bag right-click through `UseContainerItem`, so `drain_container_uses` reaches + // A real bag right-click through `UseContainerItem`, so `use_container_item` reaches // `ItemUseRoute::ShowPetition`. A FAIL is either the fork missing the charter arm or // `SMSG_PETITION_SHOW_SIGNATURES` never becoming `PETITION_SHOW`. No getter exposes the // open charter's item guid, so its identity rests on no window being up before the click. diff --git a/crates/benilla-app/src/capture/probe_clam.rs b/crates/benilla-app/src/capture/probe_clam.rs index f24d84ca..7a5504eb 100644 --- a/crates/benilla-app/src/capture/probe_clam.rs +++ b/crates/benilla-app/src/capture/probe_clam.rs @@ -321,7 +321,7 @@ fn clam_probe( } /// Right-click the clam through the live VM's `UseContainerItem`, so the real dispatcher -/// (`ui_items::drain::drain_container_uses`) picks the packet. +/// (`ui_items::ScriptItemUse::use_container_item`) picks the packet. fn click(script: &mut UiScript, probe: &mut ClamProbe, round: u8) { let slot = probe.slot; info!("PROBE_CLAM: round {round} — UseContainerItem(0, {slot}) through the live VM"); diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index c4fd6f11..7cc7a570 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -663,16 +663,18 @@ pub(crate) mod schedule_tests { /// - `ui_quest::lines::feed_quest_lines` against `ui_items::feed_item_stats` and /// `ui_tooltip::feed_spell_tooltips` over `Items` and the VM: it only reads templates, which /// those two do not write, and a chat line commutes with their pushes; - /// - `spell::targeting::drain_spell_target_unit` against the lone `.after(UiInput)` cast - /// drains over the `CastLadder`, and the party, pet-book and chat drains over the unit-token - /// resolver: the class `drop_item_on_unit` and the world click's legs already carry, as none - /// of those drains orders against the target chain. A cast press and a unit-frame bind in one - /// frame take either order until the cast drains share a set that does. + /// - `script_calls::apply_script_calls`, in the target chain, against the `.after(UiInput)` + /// drains of gestures that are not such script calls (the ATTACKTARGET binding, the + /// item-pick commit, the GameObject openers, the chat, party, duel, trade and death drains) + /// over the `CastLadder`, the selection and the unit-token resolver: the class + /// `drop_item_on_unit` and the world click's legs already carry, as none of those drains + /// orders against the target chain. Such a gesture and a script call in one frame take + /// either order. /// /// Raising the ceiling is a claim that a new undeclared order is acceptable: make it with the /// reason read off the dump, or declare the order (`.after`, a set, a `chain`). A resource /// that commutes by construction belongs in [`Classes`]. - const UPDATE_ACTIONABLE_CEILING: usize = 5_481; + const UPDATE_ACTIONABLE_CEILING: usize = 4_956; const UPDATE_ACTIONABLE_SLACK: usize = 40; fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) { @@ -1126,8 +1128,6 @@ pub(crate) mod schedule_tests { "`told` is a `VmMemo` inside the session (a fresh VM re-begins), and the open is a player click"), ("ui_items/drain.rs", "drain_container_destroys", Because::FilledByVm, "`take_container_destroys` is a VM-owned queue filled by Lua's `DeleteCursorItem`"), - ("ui_items/drain.rs", "drain_container_uses", Because::FilledByVm, - "`take_container_uses` and `take_container_repairs` are Lua intents held by the VM"), ("ui_logout.rs", "feed_logout", Because::PlayerRoundTrip, "both packets answer the `CMSG_LOGOUT_REQUEST`/cancel the game menu sent"), ("ui_loot/mod.rs", "drain_loot", Because::Deliberate, diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 7f3d229d..3a02b5e5 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -90,6 +90,7 @@ mod realmlist; mod run_mode; mod screen_fade; mod screenshot; +mod script_calls; mod shaders; mod game_tip; diff --git a/crates/benilla-app/src/script_calls.rs b/crates/benilla-app/src/script_calls.rs new file mode 100644 index 00000000..6a0876dd --- /dev/null +++ b/crates/benilla-app/src/script_calls.rs @@ -0,0 +1,297 @@ +//! The one point in the frame where the script calls that touch the selection, the player's cast +//! or the targeting cursor take effect, in the order the script made them. In the reference each +//! such call is done when it returns (the macro runner `0x4f14e0` fires every line in one pass; +//! `TargetByName` `0x489d60` and `TargetUnit` `0x4899d0` commit through `SetSelection 0x493540`; +//! `CastSpellByName` `0x4b4ab0` casts at the selection as it stands then), so `/target Bob` then +//! `/cast Flash Heal` heals Bob, and `/cast` then `/target` casts at the old target. A fixed order +//! between per-kind drains cannot give both, so the VM keeps one queue ([`ScriptCall`]) and this +//! system applies it front to back, each call through its owner's applier. + +use std::collections::VecDeque; + +use bevy::prelude::*; + +use benilla_ui::script::{ScriptCall, UiScript}; + +/// How deep calls made while applying a call may nest in one frame: a macro whose line presses a +/// macro, and so on. Past it (a macro that presses itself), what is left waits for the next frame, +/// so a frame always ends. +const MAX_NESTING: u8 = 8; + +/// Apply this frame's script calls in call order, in the target chain after the world clicks. +#[allow(clippy::type_complexity)] // the appliers, one per owner of the state a call touches +pub(crate) fn apply_script_calls( + script: Option>, + mut appliers: ParamSet<( + crate::target::ScriptSelect, + crate::spell::ScriptCursor, + crate::spell::SelfCancel, + crate::spell::ScriptCast, + crate::ui_action::ActionPress, + crate::ui_items::ScriptItemUse, + crate::ui_pet::PetPress, + )>, +) { + let Some(mut script) = script else { + return; + }; + in_call_order(&mut script, |script, call| match call { + ScriptCall::Select(request) => appliers.p0().select(request), + ScriptCall::TargetByName { name, exact } => appliers.p0().target_by_name(&name, exact), + ScriptCall::TargetNearestFriend { reverse } => { + appliers.p0().target_nearest_friend(reverse); + } + ScriptCall::ClearTarget => appliers.p0().clear_target(), + ScriptCall::SpellTargetUnit(token) => appliers.p1().spell_target_unit(&token), + ScriptCall::SpellStopTargeting => appliers.p1().stop_targeting(), + ScriptCall::SpellStopCasting => appliers.p2().stop_casting(), + ScriptCall::CastSpell(spell_id) => { + crate::ui_spellbook::cast_spell(&mut appliers.p3(), spell_id); + } + ScriptCall::CastShapeshiftForm(spell_id) => { + crate::ui_shapeshift::cast_form(&mut appliers.p3(), spell_id); + } + ScriptCall::UseAction(press) => { + crate::ui_action::use_action(&mut appliers.p4(), script, press); + } + ScriptCall::UseContainerItem { bag, slot } => { + appliers.p5().use_container_item(script, bag, slot); + } + ScriptCall::UseInventoryItem(id) => appliers.p5().use_inventory_item(script, id), + ScriptCall::CastPetSpell(spell_id) => { + crate::ui_pet_book::cast_pet_spell(&mut appliers.p6(), spell_id); + } + ScriptCall::PetAction(slot) => appliers.p6().press_slot(slot), + ScriptCall::PetOrder(packed) => appliers.p6().order(packed), + }); +} + +/// Apply every queued call front to back. Applying one may queue more: a macro's lines run +/// inside `UseAction` (`0x4e6098 call 0x4f1460`), so the calls they make go before the next one +/// queued. +pub(crate) fn in_call_order( + script: &mut UiScript, + mut apply: impl FnMut(&mut UiScript, ScriptCall), +) { + let mut queue: VecDeque<(ScriptCall, u8)> = script + .take_script_calls() + .into_iter() + .map(|call| (call, 0)) + .collect(); + let mut next_frame = Vec::new(); + while let Some((call, depth)) = queue.pop_front() { + apply(script, call); + let nested = script.take_script_calls(); + if depth >= MAX_NESTING { + next_frame.extend(nested); + continue; + } + for call in nested.into_iter().rev() { + queue.push_front((call, depth + 1)); + } + } + if !next_frame.is_empty() { + script.requeue_script_calls(next_frame); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::net::{ + ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfGuid, SelfPlayer, + }; + use crate::target::Selection; + use benilla_protocol::messages::{ActionButton, GroupMemberEntry, ACTION_KIND_MACRO}; + use benilla_ui::script::{SpellBookState, SpellSlotView, SpellTabView}; + use bevy::ecs::system::RunSystemOnce; + use crossbeam_channel::Receiver; + + const ME: u64 = 0x10; + /// The unit selected before the script runs. + const OLD: u64 = 0x21; + /// `party1`, the unit the script selects. + const NEW: u64 = 0x22; + /// Not in the (absent) spell catalog, so the cast's wire target is the selection as it stands. + const HEAL: u32 = 2050; + + struct Frame { + app: App, + rx: Receiver, + } + + /// The whole client, headless, with us, `OLD` (selected when `selected`) and `NEW` as + /// `party1`, and a VM whose book holds Heal. + fn frame(selected: bool) -> Frame { + let mut app = crate::game_plugins::schedule_tests::headless_client(); + let (tx, rx) = crossbeam_channel::unbounded(); + app.insert_resource(NetCommands(tx)); + let world = app.world_mut(); + // Fields 22 health and 28 max health: every unit is alive. + let unit = |guid: u64, x: f32| { + ( + Guid(guid), + Transform::from_xyz(x, 0.0, 0.0), + GlobalTransform::from_translation(Vec3::new(x, 0.0, 0.0)), + ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ + (22, 100), + (28, 100), + ])), + ) + }; + let me = world.spawn((SelfPlayer, unit(ME, 0.0))).id(); + let old = world.spawn(unit(OLD, 5.0)).id(); + let new = world.spawn(unit(NEW, 6.0)).id(); + let index = &mut world.resource_mut::().0; + index.insert(ME, me); + index.insert(OLD, old); + index.insert(NEW, new); + world.resource_mut::().0 = Some(ME); + world + .resource_mut::() + .members + .push(GroupMemberEntry { + name: "Bob".into(), + guid: NEW, + status: 1, + flags: 0, + }); + if selected { + *world.resource_mut::() = Selection { + target: Some(old), + guid: Some(OLD), + }; + } + let mut script = UiScript::new().expect("a VM"); + script.set_spellbook(SpellBookState { + tabs: vec![SpellTabView { + name: "Holy".into(), + texture: None, + offset: 0, + num_spells: 1, + }], + slots: vec![SpellSlotView { + spell_id: HEAL, + name: "Heal".into(), + ..Default::default() + }], + }); + if selected { + // The frame's unit snapshot, as the feed pushed it before the input pass. + script.set_unit( + "target", + Some(benilla_ui::script::UnitState { + exists: true, + ..Default::default() + }), + ); + } + world.insert_non_send_resource(script); + Frame { app, rx } + } + + /// Run `lua` in the VM, then this frame's one application of what it queued. + fn run(f: &mut Frame, lua: &str) { + f.app + .world_mut() + .non_send_resource_mut::() + .run(lua) + .expect("the script runs"); + f.app + .world_mut() + .run_system_once(apply_script_calls) + .expect("the calls apply as a one-shot system"); + } + + /// The `CMSG_CAST_SPELL`s sent, as `(spell, target)`. + fn casts(f: &Frame) -> Vec<(u32, Option)> { + f.rx.try_iter() + .filter_map(|c| match c { + ClientCommand::CastSpell { spell_id, target } => Some((spell_id, target)), + _ => None, + }) + .collect() + } + + fn selected(f: &Frame) -> Option { + f.app.world().resource::().guid + } + + /// `/target` then `/cast`: `TargetUnit` commits through `SetSelection 0x493540` before it + /// returns, and `CastSpellByName` (`0x4b4ab0`) casts at the selection as it stands then. + #[test] + fn a_cast_after_a_target_goes_to_the_new_target() { + let mut f = frame(true); + run(&mut f, r#"TargetUnit("party1") CastSpellByName("Heal")"#); + assert_eq!(casts(&f), vec![(HEAL, Some(NEW))]); + assert_eq!(selected(&f), Some(NEW)); + } + + /// `/cast` then `/target`: the cast is done before the selection moves. + #[test] + fn a_cast_before_a_target_goes_to_the_old_target() { + let mut f = frame(true); + run(&mut f, r#"CastSpellByName("Heal") TargetUnit("party1")"#); + assert_eq!(casts(&f), vec![(HEAL, Some(OLD))]); + assert_eq!(selected(&f), Some(NEW)); + } + + /// `ClearTarget` (`0x489ff0`) reads the selection at call time and deselects through + /// `0x493540(0,0)`: after `TargetUnit("player")` there is one to clear, with or without a + /// target before the script ran. + #[test] + fn a_clear_after_a_target_ends_with_nothing_selected() { + for selected_before in [true, false] { + let mut f = frame(selected_before); + run(&mut f, r#"TargetUnit("player") ClearTarget()"#); + assert_eq!(selected(&f), None, "selected before: {selected_before}"); + let world = f.app.world(); + assert_eq!(world.resource::().target, None); + let sends: Vec = + f.rx.try_iter() + .filter_map(|c| match c { + ClientCommand::SetSelection { guid } => Some(guid), + _ => None, + }) + .collect(); + assert_eq!(sends, vec![ME, 0], "selected before: {selected_before}"); + } + } + + /// A macro's lines run inside `UseAction` (`0x4e6098 call 0x4f1460`), so what they call lands + /// before the calls the pressing script makes after it: the macro's cast goes to the old + /// target, then the script's `TargetUnit` moves the selection. + #[test] + fn a_macros_calls_land_inside_the_use_action_that_ran_it() { + let mut f = frame(true); + { + let world = f.app.world_mut(); + let script = world.non_send_resource::(); + // `ChatFrame1`'s `EXECUTE_CHAT_LINE` handler (`ChatFrame.lua:1343`) sends the line + // through the edit box; this one runs it as Lua, which is all the order needs. + script + .run( + r#"local f = CreateFrame("Frame") + f:RegisterEvent("EXECUTE_CHAT_LINE") + f:SetScript("OnEvent", function() RunScript(arg1) end) + MACRO_INDEX = CreateMacro("heal", 1, 'CastSpellByName("Heal")', 1, nil)"#, + ) + .expect("the macro is made"); + let index: u32 = script.eval("return MACRO_INDEX").expect("an index"); + world + .resource_mut::() + .buttons + .insert( + 0, + ActionButton { + slot: 0, + action: index, + kind: ACTION_KIND_MACRO, + }, + ); + } + run(&mut f, r#"UseAction(1) TargetUnit("party1")"#); + assert_eq!(casts(&f), vec![(HEAL, Some(OLD))]); + assert_eq!(selected(&f), Some(NEW)); + } +} diff --git a/crates/benilla-app/src/spell/inflight.rs b/crates/benilla-app/src/spell/inflight.rs index b2b889e5..b67adc35 100644 --- a/crates/benilla-app/src/spell/inflight.rs +++ b/crates/benilla-app/src/spell/inflight.rs @@ -7,8 +7,6 @@ use std::time::{Duration, Instant}; use bevy::prelude::*; -use benilla_ui::script::UiScript; - use crate::creature_anim::{CastEvent, CastEventKind, Casting, PlaySeq}; use crate::net::{ClientCommand, GuidIndex, NetCommands, SelfGuid}; use crate::ui_action::Spells; @@ -257,112 +255,135 @@ pub(crate) struct LocalMoveStart(pub(crate) bool); /// into 0x154 `SPELLCAST_INTERRUPTED` (`0x6e1a00`). Our bar edges key on the self `Casting` this /// reap removes, so the pair is pushed here. The stock `CastingBarFrame.lua:132-139` then steps /// the flash once per OnUpdate after the 1 s hold, followed by the fade. -pub(super) fn local_self_cancel( - script: Option>, - mut moved: ResMut, - mut pending: ResMut, - mut queued_melee: ResMut, - channel: Res, - mut auto_repeat: ResMut, - mut feed: ResMut, - spells: Option>, - net: Res, - self_guid: Res, - index: Res, - casting: Query<&Casting>, - mut cast_events: MessageWriter, - mut play_seq: ResMut, - mut ecs: Commands, -) { - let esc = match script { - Some(mut s) => s.take_spell_stop(), - None => false, - }; - let moved = std::mem::take(&mut moved.0); - if !esc && !moved { - return; +pub(super) fn local_self_cancel(mut moved: ResMut, mut cancel: SelfCancel) { + if std::mem::take(&mut moved.0) { + cancel.cancel(false, true); } - let now = Instant::now(); - let self_e = self_guid.0.as_ref().and_then(|g| index.0.get(g)).copied(); - // Esc stops one thing, auto-repeat first (`0x6e6e80`). - let esc = if esc && auto_repeat.0.is_some() { - if *crate::net::CAST_TRACE { - info!("cast-trace: LOCAL auto-repeat self-cancel (esc), CMSG_CANCEL_AUTO_REPEAT_SPELL"); - } - crate::creature_anim::cancel_auto_repeat_local(self_e, &mut auto_repeat, &mut ecs, &net); - false // the press is spent - } else { - esc - }; - if !esc && !moved { - return; +} + +/// What [`local_self_cancel`] reads and writes, shared with the Esc cancel, which is a script call +/// (`SpellStopCasting`) and lands in call order ([`crate::script_calls`]). +#[derive(bevy::ecs::system::SystemParam)] +pub(crate) struct SelfCancel<'w, 's> { + pending: ResMut<'w, PendingCast>, + queued_melee: ResMut<'w, QueuedMeleeSpell>, + channel: Res<'w, ActiveChannel>, + auto_repeat: ResMut<'w, AutoRepeatActive>, + feed: ResMut<'w, CastBarFeed>, + spells: Option>, + net: Res<'w, NetCommands>, + self_guid: Res<'w, SelfGuid>, + index: Res<'w, GuidIndex>, + casting: Query<'w, 's, &'static Casting>, + cast_events: MessageWriter<'w, CastEvent>, + play_seq: ResMut<'w, PlaySeq>, + ecs: Commands<'w, 's>, +} + +impl SelfCancel<'_, '_> { + /// `SpellStopCasting()` (`0x6e6e80`), the ESC chain's cast rung. + pub(crate) fn stop_casting(&mut self) { + self.cancel(true, false); } - let flags_open = |pick: fn(&benilla_formats::SpellDisplay) -> bool, spell_id: u32| { - // An uncataloged spell cancels, as the server interrupts any ordinary cast. - spells - .as_ref() - .and_then(|s| s.catalog.get(spell_id)) - .is_none_or(pick) - }; - // `AbortCast` treats a cast and a queued strike alike; only the local teardown differs. - let started = self_e.and_then(|e| casting.get(e).ok()).map(|c| c.spell_id); - if let Some(slot) = inflight(&pending, &queued_melee, started, now) { - let spell_id = slot.spell_id(); - // Movement tests `InterruptFlags`, which spares a queued strike: Heroic Strike 78, - // Cleave 845 and Raptor Strike 2973 ship 0 (Fireball `0xf`). - if esc - || flags_open( - |d| d.interrupt_flags & SPELL_INTERRUPT_MOVEMENT != 0, - spell_id, - ) - { + + fn cancel(&mut self, esc: bool, moved: bool) { + let Self { + pending, + queued_melee, + channel, + auto_repeat, + feed, + spells, + net, + self_guid, + index, + casting, + cast_events, + play_seq, + ecs, + } = self; + let now = Instant::now(); + let self_e = self_guid.0.as_ref().and_then(|g| index.0.get(g)).copied(); + // Esc stops one thing, auto-repeat first (`0x6e6e80`). + let esc = if esc && auto_repeat.0.is_some() { if *crate::net::CAST_TRACE { - info!( - "cast-trace: LOCAL self-cancel — {slot:?} ({}), CMSG_CANCEL_CAST", - if esc { "esc" } else { "moved" } - ); + info!("cast-trace: LOCAL auto-repeat self-cancel (esc), CMSG_CANCEL_AUTO_REPEAT_SPELL"); } - let _ = net.0.send(ClientCommand::CancelCast { spell_id }); - match slot { - Inflight::Cast(_) => { - pending.clear_if(spell_id); - // STOP arms the flash, INTERRUPTED paints red and holds, so the flash bursts - // after the hold; an interrupt by an enemy has no STOP and no burst. - feed.0.push(CastBarEdge::Stop); - feed.0.push(CastBarEdge::Interrupted); - // `spell_failed_other`'s self reap, run early so the echo finds it done. - if let Some(e) = self_e { - if casting.get(e).is_ok_and(|c| c.spell_id == spell_id) { - ecs.entity(e).remove::(); + crate::creature_anim::cancel_auto_repeat_local(self_e, auto_repeat, ecs, net); + false // the press is spent + } else { + esc + }; + if !esc && !moved { + return; + } + let flags_open = |pick: fn(&benilla_formats::SpellDisplay) -> bool, spell_id: u32| { + // An uncataloged spell cancels, as the server interrupts any ordinary cast. + spells + .as_ref() + .and_then(|s| s.catalog.get(spell_id)) + .is_none_or(pick) + }; + // `AbortCast` treats a cast and a queued strike alike; only the local teardown differs. + let started = self_e.and_then(|e| casting.get(e).ok()).map(|c| c.spell_id); + if let Some(slot) = inflight(pending, queued_melee, started, now) { + let spell_id = slot.spell_id(); + // Movement tests `InterruptFlags`, which spares a queued strike: Heroic Strike 78, + // Cleave 845 and Raptor Strike 2973 ship 0 (Fireball `0xf`). + if esc + || flags_open( + |d| d.interrupt_flags & SPELL_INTERRUPT_MOVEMENT != 0, + spell_id, + ) + { + if *crate::net::CAST_TRACE { + info!( + "cast-trace: LOCAL self-cancel — {slot:?} ({}), CMSG_CANCEL_CAST", + if esc { "esc" } else { "moved" } + ); + } + let _ = net.0.send(ClientCommand::CancelCast { spell_id }); + match slot { + Inflight::Cast(_) => { + pending.clear_if(spell_id); + // STOP arms the flash, INTERRUPTED paints red and holds, so the flash bursts + // after the hold; an interrupt by an enemy has no STOP and no burst. + feed.0.push(CastBarEdge::Stop); + feed.0.push(CastBarEdge::Interrupted); + // `spell_failed_other`'s self reap, run early so the echo finds it done. + if let Some(e) = self_e { + if casting.get(e).is_ok_and(|c| c.spell_id == spell_id) { + ecs.entity(e).remove::(); + } + cast_events.write(CastEvent { + entity: e, + spell_id, + kind: CastEventKind::Fail, + seq: play_seq.next(), + }); } - cast_events.write(CastEvent { - entity: e, - spell_id, - kind: CastEventKind::Fail, - seq: play_seq.next(), - }); + } + Inflight::Strike(_) => { + // Clearing the slot darkens the checked ring now. `AbortCast` fires 0x152 + // here too, inert on a hidden bar; there is no INTERRUPTED. + queued_melee.clear_if(spell_id); + feed.0.push(CastBarEdge::Stop); } } - Inflight::Strike(_) => { - // Clearing the slot darkens the checked ring now. `AbortCast` fires 0x152 - // here too, inert on a hidden bar; there is no INTERRUPTED. - queued_melee.clear_if(spell_id); - feed.0.push(CastBarEdge::Stop); - } } } - } - // The channel: movement only, and the send only (`0x6e9b70`). - if moved { - if let Some(spell_id) = channel.current(now) { - if flags_open( - |d| d.channel_interrupt_flags & CHANNEL_INTERRUPT_MOVING != 0, - spell_id, - ) { - if *crate::net::CAST_TRACE { - info!("cast-trace: LOCAL channel self-cancel — spell {spell_id}, CMSG_CANCEL_CHANNELLING"); + // The channel: movement only, and the send only (`0x6e9b70`). + if moved { + if let Some(spell_id) = channel.current(now) { + if flags_open( + |d| d.channel_interrupt_flags & CHANNEL_INTERRUPT_MOVING != 0, + spell_id, + ) { + if *crate::net::CAST_TRACE { + info!("cast-trace: LOCAL channel self-cancel — spell {spell_id}, CMSG_CANCEL_CHANNELLING"); + } + let _ = net.0.send(ClientCommand::CancelChannelling { spell_id }); } - let _ = net.0.send(ClientCommand::CancelChannelling { spell_id }); } } } @@ -484,14 +505,15 @@ mod tests { assert_eq!(q.current(), None, "the wire resolution opened the queue"); } - /// [`local_self_cancel`] in a small App. With no `UiScript` Esc reads false; the Esc tests - /// insert a VM and call the real `SpellStopCasting()`. + /// [`local_self_cancel`] in a small App, and the Esc cancel: the Esc tests insert a VM, call + /// the real `SpellStopCasting()` and apply what it queued. mod local_cancel { use super::*; use crate::creature_anim::PlaySeq; use crate::net::{Guid, GuidIndex, NetCommands, SelfGuid, SelfPlayer}; use crate::ui_cast::feed_cast_bar; use benilla_formats::{SpellCatalog, SpellDisplay}; + use benilla_ui::script::{ScriptCall, UiScript}; use bevy::ecs::system::RunSystemOnce; use std::collections::HashMap; @@ -541,6 +563,19 @@ mod tests { app.world_mut().run_system_once(local_self_cancel).unwrap(); } + /// Apply the `SpellStopCasting()` calls the VM queued, as `crate::script_calls` does. + fn apply_stops(app: &mut App) { + app.world_mut() + .run_system_once(|mut script: NonSendMut, mut cancel: SelfCancel| { + crate::script_calls::in_call_order(&mut script, |_, call| { + if call == ScriptCall::SpellStopCasting { + cancel.stop_casting(); + } + }); + }) + .unwrap(); + } + #[test] fn a_move_edge_mid_cast_cancels_locally() { let (mut app, rx) = harness(HashMap::from([( @@ -704,7 +739,7 @@ mod tests { None, "mid-channel the binding answers nil — the vanilla /stopcasting quirk" ); - app.world_mut().run_system_once(local_self_cancel).unwrap(); + apply_stops(&mut app); assert!( rx.try_recv().is_err(), "no wire cancel — channels break only on the movement path" @@ -736,7 +771,7 @@ mod tests { .unwrap(), Some(1) ); - app.world_mut().run_system_once(local_self_cancel).unwrap(); + apply_stops(&mut app); assert!( matches!( rx.try_recv(), @@ -766,7 +801,7 @@ mod tests { .unwrap(), Some(1) ); - app.world_mut().run_system_once(local_self_cancel).unwrap(); + apply_stops(&mut app); assert!( matches!( rx.try_recv(), @@ -803,7 +838,7 @@ mod tests { "the queued strike makes IsCasting true, so the binding EATS the press — which \ is what spares the target three rungs further down the ladder" ); - app.world_mut().run_system_once(local_self_cancel).unwrap(); + apply_stops(&mut app); assert!( matches!( rx.try_recv(), @@ -873,7 +908,7 @@ mod tests { .unwrap(), Some(1) ); - app.world_mut().run_system_once(local_self_cancel).unwrap(); + apply_stops(&mut app); assert!( matches!( rx.try_recv(), @@ -898,7 +933,7 @@ mod tests { Some(1), "still stoppable — so this press is eaten too, and the target still survives" ); - app.world_mut().run_system_once(local_self_cancel).unwrap(); + apply_stops(&mut app); assert!(matches!( rx.try_recv(), Ok(crate::net::ClientCommand::CancelCast { diff --git a/crates/benilla-app/src/spell/mod.rs b/crates/benilla-app/src/spell/mod.rs index 67cd2a67..8482fb24 100644 --- a/crates/benilla-app/src/spell/mod.rs +++ b/crates/benilla-app/src/spell/mod.rs @@ -28,16 +28,24 @@ pub(crate) use cast_target::AutoSelfCast; pub(crate) use cooldowns::Cooldowns; pub(crate) use inflight::{ inflight, ActiveChannel, AutoRepeatActive, LocalMoveStart, PendingCast, QueuedMeleeSpell, - SPELL_INTERRUPT_MOVEMENT, + SelfCancel, SPELL_INTERRUPT_MOVEMENT, }; pub(crate) use mods::{SpellModifiers, OP_COST}; // `TargetingWants` is exported for the ground reticle, which draws for the location word alone. pub(crate) use targeting::{ - ground_cast_radius, CorpsePick, PicksSelf, SpellTargeting, TargetingWants, + ground_cast_radius, CorpsePick, PicksSelf, ScriptCursor, SpellTargeting, TargetingWants, }; +/// A script cast's inputs, the cast tail the action bar uses, for the spellbook's and the stance +/// bar's calls ([`crate::script_calls`]). +#[derive(bevy::ecs::system::SystemParam)] +pub(crate) struct ScriptCast<'w, 's> { + pub(crate) targeting: cast_target::CastTargeting<'w, 's>, + pub(crate) ladder: CastLadder<'w, 's>, +} + /// The local self-cancel's set: a reader of the in-flight state orders `.after(LocalCancel)` so a -/// cast ended by a move, jump or Esc drops its cast bar the same frame. +/// cast ended by a move or jump drops its cast bar the same frame. #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] pub(crate) struct LocalCancel; @@ -67,10 +75,10 @@ impl Plugin for SpellPlugin { mods::track_class_family .after(WorldStage::Net) .before(UnitFeed), - // The state push runs before the input pass's `ToggleGameMenu` and the drain - // after it, so an Esc cancel lands before next frame's cursor reads the mode. - // After the old-target clear, the pet bar's writer in the feed: `"pet"` - // resolves off the bar. + // The state push runs before the input pass's `ToggleGameMenu`, whose + // `SpellStopTargeting` lands with the script calls after it. After the + // old-target clear, the pet bar's writer in the feed: `"pet"` resolves off + // the bar. targeting::feed_targeting_to_vm .in_set(UnitFeed) .after(crate::ui_pet::pet_stop_on_old_target_clear), @@ -79,7 +87,6 @@ impl Plugin for SpellPlugin { targeting::publish_corpse_pick, ) .in_set(UiFeed), - targeting::drain_stop_targeting.after(UiInput), // The item-target commit (`0x495d60`): after the input pass so a bag click // binds the same frame; outside the target chain, as its clicks never reach // the world. diff --git a/crates/benilla-app/src/spell/targeting/mod.rs b/crates/benilla-app/src/spell/targeting/mod.rs index 5529d2d1..d1fb5209 100644 --- a/crates/benilla-app/src/spell/targeting/mod.rs +++ b/crates/benilla-app/src/spell/targeting/mod.rs @@ -9,7 +9,7 @@ //! - [`world`]: the world-click dispatcher `0x492ce0`, its terrain leg (`0x492580` → `BindLocation //! 0x6e60f0`) and its object leg (`0x4925d0` → `SetSelection 0x493540` → `BindTarget 0x6e5b40`), //! whose unit arm [`bind_target_unit`] shares with `SpellTargetUnit` -//! ([`drain_spell_target_unit`]), and whose corpse arm is [`corpse`]'s. +//! ([`ScriptCursor::spell_target_unit`]), and whose corpse arm is [`corpse`]'s. //! - [`item`]: the bag click (`PickupContainerItem 0x4f9b30`) and the paper-doll click //! (`0x4c7300`), both `IsTargeting`, `TargetingWantsItem 0x6e6330`, then `0x495d60`, whose //! confirm popups park the clicked guid (`0xb4e3c0`) with the word still standing. @@ -20,7 +20,7 @@ //! targeting, the pick flags come from the word alone, so a click over a unit with a dest-only //! word commits on the ground behind it. //! -//! Cancels: ESC through `UIParent.lua:1490` ([`feed_targeting_to_vm`], [`drain_stop_targeting`]), +//! Cancels: ESC through `UIParent.lua:1490` ([`feed_targeting_to_vm`], [`ScriptCursor::stop_targeting`]), //! the right-button down edge ([`cancel_targeting_on_right_press`]), a new spell's press, which //! aborts and proceeds (`TryCast 0x6e4b60` at `0x6e4d62`), and the bar's re-press of the same //! spell (`UseAction 0x4e5ee0`). A cancel clears the word and sends nothing; movement never @@ -384,21 +384,6 @@ pub(crate) fn feed_targeting_to_vm( } } -/// Drain the ESC chain's `SpellStopTargeting()` after the input pass: `StopTargeting 0x6e4900`, -/// word cleared, no packet. -pub(crate) fn drain_stop_targeting( - mut targeting: ResMut, - script: Option>, -) { - let Some(mut script) = script else { - return; - }; - if script.take_stop_targeting() { - debug!("ui_action: targeting cancelled (ESC chain)"); - targeting.clear(); - } -} - /// `BindTarget 0x6e5b40`'s unit arm, for the world click (`0x493540` at `4935d5`) and /// `SpellTargetUnit` (`0x6e5b10`): the caster under `AttributesEx & 0x80000` (`6e5bf7`) or a unit /// failing the relation checks binds nothing and the cursor stays up; otherwise the merge. The @@ -447,36 +432,43 @@ fn merge( ladder.commit_targeted(spell_id, commit, bound); } -/// Drain `SpellTargetUnit(unit)` (`0x6e6d90`) after the UI pass. The binding already raised the -/// usage and unknown-token errors and dropped a call made while not targeting. Here a token that -/// names no unit raises "Out of range." (0x59) and ends targeting, as the reference's abort -/// clears the word, and a unit goes to [`bind_target_unit`]. -pub(crate) fn drain_spell_target_unit( - script: Option>, - tokens: crate::ui_unit::UnitTokens, - selection: Res, - checks: BindChecks, - mut ladder: crate::spell::CastLadder, -) { - let Some(mut script) = script else { - return; - }; - for token in script.take_spell_target_unit() { - // An earlier token this frame may have bound or ended the cast: not targeting, no-op. - let Some(spell_id) = ladder.ground.spell() else { - continue; +/// The targeting cursor's script calls, applied in call order by [`crate::script_calls`]. +#[derive(bevy::ecs::system::SystemParam)] +pub(crate) struct ScriptCursor<'w, 's> { + tokens: crate::ui_unit::UnitTokens<'w, 's>, + selection: Res<'w, crate::target::Selection>, + checks: BindChecks<'w, 's>, + ladder: crate::spell::CastLadder<'w, 's>, +} + +impl ScriptCursor<'_, '_> { + /// `SpellTargetUnit(unit)` (`0x6e6d90`). The binding already raised the usage and + /// unknown-token errors and dropped a call made while not targeting. Here a token that names + /// no unit raises "Out of range." (0x59) and ends targeting, as the reference's abort clears + /// the word, and a unit goes to [`bind_target_unit`]. + pub(crate) fn spell_target_unit(&mut self, token: &str) { + // An earlier call may have bound or ended the cast: not targeting, no-op. + let Some(spell_id) = self.ladder.ground.spell() else { + return; }; - match tokens.resolve(&token, &selection) { - Some((entity, guid)) => bind_target_unit(&mut ladder, &checks, entity, guid), + match self.tokens.resolve(token, &self.selection) { + Some((entity, guid)) => bind_target_unit(&mut self.ladder, &self.checks, entity, guid), None => { debug!("ui_action: SpellTargetUnit({token}) names no unit — cast {spell_id} ends"); - ladder + self.ladder .cast_errors .push_local(spell_id, super::validator::ERR_OUT_OF_RANGE); - ladder.ground.clear(); + self.ladder.ground.clear(); } } } + + /// The ESC chain's `SpellStopTargeting()` (`0x6e6e30`): `StopTargeting 0x6e4900`, word + /// cleared, no packet. + pub(crate) fn stop_targeting(&mut self) { + debug!("ui_action: targeting cancelled (ESC chain)"); + self.ladder.ground.clear(); + } } #[cfg(test)] @@ -565,15 +557,21 @@ mod tests { ); } - /// Queue `SpellTargetUnit(token)` in the VM, then run the host's drain. + /// Queue `SpellTargetUnit(token)` in the VM, then apply it. fn spell_target_unit(world: &mut World, token: &str) { world .non_send_resource_mut::() .run(&format!("SpellTargetUnit({token:?})")) .expect("a known token"); world - .run_system_cached(drain_spell_target_unit) - .expect("the drain runs"); + .run_system_cached( + |mut script: NonSendMut, mut cursor: ScriptCursor| { + for token in script.take_spell_target_unit() { + cursor.spell_target_unit(&token); + } + }, + ) + .expect("the applier runs"); } fn errors(world: &mut World) -> Vec { diff --git a/crates/benilla-app/src/target/by_name.rs b/crates/benilla-app/src/target/by_name.rs index 476b1c76..d183bfd5 100644 --- a/crates/benilla-app/src/target/by_name.rs +++ b/crates/benilla-app/src/target/by_name.rs @@ -312,82 +312,85 @@ impl ByNameScan<'_, '_> { } } -/// Drain `/target ` through [`scan::commit`], the stop, select, re-swing path a click takes. -/// A miss leaves the target alone, as neither failure edge in `0x489db4` calls `SetSelection`, and -/// prints nothing; the reference prints message `0x127` `ERR_UNIT_NOT_FOUND` there, or `0xb8` +/// `/target ` and the Lua `TargetByName(name, exactMatch)` through [`scan::commit`], the +/// stop, select, re-swing path a click takes. `exact` is the Lua second argument, which `0x489d8e` +/// reads with default 0 as the resolver's exact-only flag (consumed at `0x493cab`). A miss leaves +/// the target alone, as neither failure edge in `0x489db4` calls `SetSelection`, and prints +/// nothing; the reference prints message `0x127` `ERR_UNIT_NOT_FOUND` there, or `0xb8` /// `ERR_GENERIC_NO_TARGET` for an empty name. +pub(super) fn target_named( + scan_params: &ByNameScan, + commit: &mut SelectCommit, + name: &str, + exact: bool, +) { + let Some((entity, guid, _)) = scan_params.resolve( + name, + NameSearch::AnyUnit, + Filter::AcceptAll, + if exact { + Match::ExactOnly + } else { + Match::PrefixOk + }, + ) else { + return; + }; + commit.commit(entity, guid); +} + +/// `/assist [name]` and the Lua `AssistByName`: the basis is a named player, or bare the current +/// selection (`AssistUnit("target")`, `ChatFrame.lua:744`), and [`SelectCommit::assist`] selects +/// its target. +pub(super) fn assist_named( + scan_params: &ByNameScan, + commit: &mut SelectCommit, + name: Option<&str>, + how: &str, +) { + let basis = match name { + Some(name) => scan_params + .resolve( + name, + NameSearch::PlayerOnly, + Filter::AcceptAll, + Match::PrefixOk, + ) + .map(|(e, _, _)| e), + None => commit.selection.target, + }; + let Some(basis) = basis else { + info!("assist ({how}): no basis unit; nothing to assist"); + return; + }; + // From here on this is `AssistUnit`'s tail too, one function as in the reference. + commit.assist(basis, how); +} + +/// Drain the chat layer's `/target ` asks. pub(super) fn target_by_name_requests( mut requests: MessageReader, scan_params: ByNameScan, mut commit: SelectCommit, ) { for request in requests.read() { - let Some((entity, guid, _)) = scan_params.resolve( - &request.name, - NameSearch::AnyUnit, - Filter::AcceptAll, - Match::PrefixOk, - ) else { - continue; - }; - commit.commit(entity, guid); + target_named(&scan_params, &mut commit, &request.name, false); } } -/// Drain the Lua `TargetByName(name, exactMatch)` asks, the `/target` path plus the second -/// argument, which `0x489d8e` reads with default 0 as the resolver's exact-only flag (consumed at -/// `0x493cab`). -pub(super) fn script_target_by_name_requests( - script: Option>, - scan_params: ByNameScan, - mut commit: SelectCommit, -) { - let Some(mut script) = script else { - return; - }; - for (name, exact) in script.take_target_by_name_requests() { - let Some((entity, guid, _)) = scan_params.resolve( - &name, - NameSearch::AnyUnit, - Filter::AcceptAll, - if exact { - Match::ExactOnly - } else { - Match::PrefixOk - }, - ) else { - continue; - }; - commit.commit(entity, guid); - } -} - -/// Drain `/assist [name]`: the basis is a named player, or bare the current selection -/// (`AssistUnit("target")`, `ChatFrame.lua:744`), and [`SelectCommit::assist`] selects its target. +/// Drain the chat layer's `/assist [name]` asks. pub(super) fn assist_requests( mut requests: MessageReader, scan_params: ByNameScan, mut commit: SelectCommit, ) { for request in requests.read() { - // The basis: a named player, or bare, whatever is selected. - let basis = match &request.name { - Some(name) => scan_params - .resolve( - name, - NameSearch::PlayerOnly, - Filter::AcceptAll, - Match::PrefixOk, - ) - .map(|(e, _, _)| e), - None => commit.selection.target, - }; - let Some(basis) = basis else { - info!("assist (/assist): no basis unit; nothing to assist"); - continue; - }; - // From here on this is `AssistUnit`'s tail too, one function as in the reference. - commit.assist(basis, "/assist"); + assist_named( + &scan_params, + &mut commit, + request.name.as_deref(), + "/assist", + ); } } @@ -460,7 +463,7 @@ pub(super) fn follow_requests( #[allow(clippy::type_complexity)] // one bundled param, the app's convention for big query sets pub(crate) struct SelectCommit<'w, 's> { pub(super) selection: ResMut<'w, Selection>, - seam: crate::creature_anim::AttackSeam<'w, 's>, + pub(super) seam: crate::creature_anim::AttackSeam<'w, 's>, // Our own body: the guid, the store `can_attack` reads, and whether we are mid-swing. me: Query< 'w, @@ -525,6 +528,17 @@ impl SelectCommit<'_, '_> { } } + /// Whether we are mid-swing, which a deselect stops. + pub(super) fn engaged(&self) -> bool { + self.me.single().is_ok_and(|(_, _, engaged)| engaged) + } + + /// Deselect (`0x493540(0,0)`), a no-op with nothing selected; see [`super::click::clear`]. + pub(super) fn clear(&mut self) { + let engaged = self.engaged(); + super::click::clear(&mut self.selection, &mut self.seam, engaged); + } + /// Select a resolved guid, `0x489a40`'s arm 1, through [`scan::commit`]. pub(super) fn commit(&mut self, entity: Entity, guid: u64) { let me = self.me.single().ok(); diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs index 53942be4..4a23f232 100644 --- a/crates/benilla-app/src/target/click.rs +++ b/crates/benilla-app/src/target/click.rs @@ -1,7 +1,5 @@ //! The click router: a clean left-click selects, a clean right-click takes the context action, -//! and the UI's selection asks and Esc commit into the same [`super::Selection`]. - -use benilla_ui::script::SelectionRequest; +//! and a loot close's deselect clears the same [`super::Selection`]. use super::lock::GoLockInputs; use super::*; @@ -991,26 +989,16 @@ pub(crate) fn service_action( } } -/// Drain `ClearTarget()`, the last leg of the Esc chain (`UIParent.lua:1492`), so the target drops -/// only when no cast, window or focused edit box took the press first. +/// Drain the guid-scoped deselects ([`DeselectGuid`]). `ClearTarget()`, the last leg of the Esc +/// chain (`UIParent.lua:1492`), is a script call and lands in call order +/// ([`crate::script_calls`]). pub(super) fn clear_target_requests( - script: Option>, mut selection: ResMut, mut seam: crate::creature_anim::AttackSeam, engaged: Query<(), (With, With)>, mut guid_asks: MessageReader, ) { - let asked = guid_asks.read().any(|ask| selection.guid == Some(ask.0)); - let Some(mut script) = script else { - if asked { - clear(&mut selection, &mut seam, !engaged.is_empty()); - } - return; - }; - // Both drained every frame: a `||` short-circuiting on `asked` would leave the VM's - // ClearTarget flag armed and clear the next frame's target. - let vm_clear = script.take_target_clear(); - if asked || vm_clear { + if guid_asks.read().any(|ask| selection.guid == Some(ask.0)) { clear(&mut selection, &mut seam, !engaged.is_empty()); } } @@ -1020,65 +1008,6 @@ pub(super) fn clear_target_requests( #[derive(bevy::ecs::message::Message, Clone, Copy, Debug)] pub(crate) struct DeselectGuid(pub(crate) u64); -/// Drain the UI's selection asks in order through the shared SetSelection path. `TargetUnit` -/// (`0x4899d0`), `AssistUnit` (`0x489b80`) and `TargetLastEnemy` share the reference's helper -/// `0x489a40`: a resolved unit or a roster member is committed, anything else is a no-op, never a -/// deselect. Here a token must name a streamed unit, so an out-of-range roster member is a no-op -/// (the guid-only selection is not built). -pub(super) fn selection_requests( - script: Option>, - // The one unit-token resolver, shared with the reach feed so `TargetUnit("target")` and - // `CheckInteractDistance("target", …)` name the same unit. - tokens: crate::ui_unit::UnitTokens, - // `TargetLastEnemy`'s memory (`[0xb4e2e8]`), stamped by `scan::remember_last_enemy`. - last_enemy: Res, - // The SetSelection tail shared with `/target` and `/assist`, classification included. - mut commit: super::by_name::SelectCommit, - mut assist_by_name: MessageWriter, -) { - let Some(mut script) = script else { - return; - }; - let requests = script.take_selection_requests(); - if requests.is_empty() { - return; - } - for request in requests { - match request { - SelectionRequest::Unit(token) => { - if let Some((entity, guid)) = tokens.resolve(&token, &commit.selection) { - commit.commit(entity, guid); - } - } - // `0x489ba9 call 0x515940(token)`, then the shared tail. An unresolvable token is - // silent here; the reference shows message `0xb8` `ERR_GENERIC_NO_TARGET` (`0x489c0e`). - SelectionRequest::Assist(token) => match tokens.resolve(&token, &commit.selection) { - Some((basis, _)) => commit.assist(basis, "AssistUnit"), - None => info!("assist (AssistUnit): \"{token}\" names nothing; silent no-op"), - }, - // `0x489c40`, `/assist `: a player name, prefix-matched by the app's name scan. - SelectionRequest::AssistByName(name) => { - assist_by_name.write(super::by_name::AssistRequest { name: Some(name) }); - } - // `0x489b45` → `0x489a40`. Empty memory is a no-op (the shim `0x489b40` tests nothing), - // unlike `TargetLastTarget 0x489b00`, which reaches `0x493540(0,0)` and deselects. - SelectionRequest::LastEnemy => { - let Some(guid) = last_enemy.0 else { - info!("TargetLastEnemy: nothing hostile has been targeted yet; no-op"); - continue; - }; - match tokens.held(guid) { - Some((entity, guid)) => commit.commit(entity, guid), - // Stale: a no-op, and the memory stays, as the reference never clears it. - None => info!( - "TargetLastEnemy: {guid:#x} is no longer streamed; target left untouched" - ), - } - } - } - } -} - /// Drop the target and send `CMSG_SET_SELECTION` 0 (a no-op with none); when `engaged`, melee /// stops too, as on the reference's Esc, click-off or target death. Weapons stay drawn. pub(super) fn clear( @@ -1624,94 +1553,6 @@ mod tests { ); } - /// The selection queue from Lua, through the binding bodies of ASSISTTARGET (`AssistUnit`) and - /// TARGETLASTHOSTILE (`TargetLastEnemy`): an empty basis, a garbage token and a stale memory - /// are each a no-op (`0x489a40`'s bare `ret`), never a deselect or a panic. - #[test] - fn the_selection_queue_runs_assist_and_last_enemy_without_ever_deselecting() { - use crate::net::NetCommands; - use benilla_ui::script::UiScript; - use bevy::ecs::system::RunSystemOnce; - - const ME: u64 = 1; - const BASIS: u64 = 0xB0A2; - const VICTIM: u64 = 0xC0DE; - const GONE: u64 = 0x6017; - // `UNIT_FIELD_TARGET` is a 2-field guid at index 16; HEALTH/MAXHEALTH keep the units live. - let store = - |pairs: &[(u16, u32)]| ObjectStore(benilla_protocol::ObjectFields::from_pairs(pairs)); - - let (tx, _rx) = crossbeam_channel::unbounded(); - let mut world = World::new(); - world.insert_resource(NetCommands(tx)); - world.init_resource::(); - world.init_resource::(); - world.init_resource::(); - world.init_resource::(); - world.init_resource::>(); - world.init_resource::(); - world.init_resource::(); - world.init_resource::(); - world.init_resource::(); - world.init_resource::(); - world.init_resource::(); - world.init_resource::>(); - world.insert_non_send_resource(UiScript::new().expect("a bare VM")); - - world.spawn((SelfPlayer, Guid(ME), store(&[(22, 100), (28, 100)]))); - // The basis is pointing at the victim; the victim points at nobody. - let basis = world - .spawn(( - Guid(BASIS), - store(&[(22, 100), (28, 100), (16, VICTIM as u32)]), - )) - .id(); - let victim = world - .spawn((Guid(VICTIM), store(&[(22, 100), (28, 100)]))) - .id(); - let index = &mut world.resource_mut::().0; - index.insert(BASIS, basis); - index.insert(VICTIM, victim); - - let run = |world: &mut World, lua: &str| { - world - .non_send_resource_mut::() - .eval::<()>(lua) - .expect("the binding runs"); - world - .run_system_once(selection_requests) - .expect("the drain runs as a one-shot system"); - world.resource::().guid - }; - let set = |world: &mut World, target: Option<(Entity, u64)>| { - let mut sel = world.resource_mut::(); - sel.target = target.map(|(e, _)| e); - sel.guid = target.map(|(_, g)| g); - }; - - // Assist the current target: one hop onto its target. - set(&mut world, Some((basis, BASIS))); - assert_eq!(run(&mut world, r#"AssistUnit("target")"#), Some(VICTIM)); - - // The victim targets nobody: assisting it is a no-op, not a deselect. - assert_eq!(run(&mut world, r#"AssistUnit("target")"#), Some(VICTIM)); - - assert_eq!(run(&mut world, r#"AssistUnit("nosuchunit")"#), Some(VICTIM)); - assert_eq!(run(&mut world, r#"TargetUnit("nosuchunit")"#), Some(VICTIM)); - - // TargetLastEnemy with nothing remembered: also a no-op. - assert_eq!(run(&mut world, "TargetLastEnemy()"), Some(VICTIM)); - - world.resource_mut::().0 = Some(VICTIM); - set(&mut world, None); - assert_eq!(run(&mut world, "TargetLastEnemy()"), Some(VICTIM)); - - // A stale memory (the unit despawned, so its guid is in no index) leaves the target alone. - world.resource_mut::().0 = Some(GONE); - assert_eq!(run(&mut world, "TargetLastEnemy()"), Some(VICTIM)); - set(&mut world, None); - assert_eq!(run(&mut world, "TargetLastEnemy()"), None); - } use crate::net::{ClientCommand, NetCommands, ObjectStore}; use bevy::ecs::system::RunSystemOnce; diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index 9e0460e5..f3608f1c 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -40,6 +40,8 @@ mod reticle; // `pub(crate)` for the hover inspector too: its faction catalog feeds `go_highlightable`. pub(crate) mod ring; mod scan; +mod script; +pub(crate) use script::ScriptSelect; /// `Faction.dbc` and our reputation table as one [`SystemParam`], since every reaction function /// takes both; `factions` is `Option` so a UI-only harness runs without client data. @@ -309,32 +311,28 @@ impl Plugin for TargetPlugin { crate::spell::targeting::commit_ground_cast_on_click, crate::spell::targeting::commit_object_cast_on_click, click::act_on_right_click, + // The loot close's guid-scoped deselect. click::clear_target_requests, - // The UI's unit-token drains. `SpellTargetUnit` first: it binds the cursor's - // cast and never moves the selection. Then the selection asks (`TargetUnit`, - // `AssistUnit`, `TargetLastEnemy`: one drain, as the reference has one - // `0x489a40`) and `DropItemOnUnit`'s pet leg, independent of each other. + // The script calls that touch the selection, the cast or the targeting cursor + // (`TargetUnit`, `/target`, `ClearTarget`, `/cast`, `UseAction`, …), applied + // in the order the script made them; then `DropItemOnUnit`'s pet leg. ( - crate::spell::targeting::drain_spell_target_unit, - ( - click::selection_requests, - crate::ui_action::drop_item::drop_item_on_unit, - ), + crate::script_calls::apply_script_calls, + crate::ui_action::drop_item::drop_item_on_unit, ) .chain(), - // The by-name asks: `/target`, the Lua `TargetByName` and `/assist` commit - // through `scan::commit`; `/follow` hands its subject to `crate::player`. + // The chat layer's by-name asks: `/target` and `/assist` commit through + // `scan::commit`; `/follow` hands its subject to `crate::player`. ( by_name::target_by_name_requests, - by_name::script_target_by_name_requests, by_name::assist_requests, by_name::follow_requests, ) .chain(), scan::auto_acquire_attacker, - // The one cycler's two sides (`0x493f60`, modes 1 and 2), chained: they share - // `TabHistory`, which a side switch clears. - (scan::tab_target, scan::target_nearest_friend_requests).chain(), + // The TAB cycler (`0x493f60`, mode 1); `TargetNearestFriend`, mode 2, is a + // script call. + scan::tab_target, scan::acquire_and_attack, flash::drive_flash, // The last-enemy stamp before the ring's death-clear, so a hostile that dies diff --git a/crates/benilla-app/src/target/scan.rs b/crates/benilla-app/src/target/scan.rs index 0712343c..be3330cd 100644 --- a/crates/benilla-app/src/target/scan.rs +++ b/crates/benilla-app/src/target/scan.rs @@ -482,7 +482,7 @@ pub(super) fn commit( /// One press on either side, as every `TargetNearest*` shim shares `0x493f60`: score the live /// world, pool by tier, walk the history forward or back, and [`commit`]. -fn cycle( +pub(super) fn cycle( side: ScanSide, reverse: bool, now: f64, @@ -601,40 +601,6 @@ pub(super) fn tab_target( ); } -/// Drain `TargetNearestFriend([reverse])` (`0x489aa0` → `0x493f60(reverse, 2)`), one cycle per -/// call in call order; the stock bindings reach it through their `Bindings.xml` bodies. -pub(super) fn target_nearest_friend_requests( - script: Option>, - time: Res