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] 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]