From 78862ff29177974d8efe7765ca28a237b9a1708f Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:39:51 +0700 Subject: [PATCH] UnitBuff and UnitDebuff answer for raid1-40 and raidpet1-40, each unit's list built once and shared by every token naming it (#489) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `UnitBuff` and `UnitDebuff` now answer for `raid1`-`raid40` and `raidpet1`-`raidpet40`, so the raid pullouts draw each member's and each pet's buffs or debuffs (`Blizzard_RaidUI.lua:451`, `:477`), and `UNIT_AURA` fires for every group token naming a unit whose list changed (`0x515e50`). - As for the party tokens, a unit with a live object reads its own aura slots through `UnitBuff`'s unit gate; one with none reads its roster record's aura block (`0x519735`, `0x519b25`: the member record, else the pet record), with a stack count of 1 and no gate. - `raidN` names the rows `GetRaidRosterInfo` does; `raidpetN` is the member's `CHARM`, else `SUMMON`, while it is held, else the record's pet guid with no online test (`0x491960`, `0x4919ae`). - One feed serves all 88 group tokens: each unit's list is built once per frame and shared by every token naming it, rebuilt only when its inputs move, and pushed to the VM only when it changed. In a 40-man raid with 16 auras on each member this costs 87 µs a frame, where rebuilding every token every frame cost 414. Fixes #471 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-app/src/ui_aura.rs | 105 +-- crates/benilla-app/src/ui_aura/group.rs | 775 +++++++++++++++++++++ crates/benilla-app/src/ui_aura/party.rs | 376 ---------- crates/benilla-app/src/ui_party/mod.rs | 4 +- crates/benilla-app/src/ui_party/records.rs | 74 +- 5 files changed, 896 insertions(+), 438 deletions(-) create mode 100644 crates/benilla-app/src/ui_aura/group.rs delete mode 100644 crates/benilla-app/src/ui_aura/party.rs diff --git a/crates/benilla-app/src/ui_aura.rs b/crates/benilla-app/src/ui_aura.rs index 7190388a..383dee34 100644 --- a/crates/benilla-app/src/ui_aura.rs +++ b/crates/benilla-app/src/ui_aura.rs @@ -1,10 +1,10 @@ //! 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 +//! pet and the party and raid 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; +mod group; use std::collections::HashMap; @@ -162,8 +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 `partyN`, `partypetN`, `raidN` and `raidpetN` lists and their pushes. + group: group::GroupAuras, } /// The `BENILLA_AURA_TRACE` period in seconds; a set value that is not a positive number means 1 s. @@ -272,34 +272,56 @@ fn unit_aura_state( } } +/// What one aura of a unit not the player is listed from: its slot, spell, stack count and +/// `AURAFLAGS` nibble. A list is a pure function of these and the spell catalog. +type AuraInput = (u8, u32, u8, u8); + +/// The list [`AuraInput`]s make. +fn aura_list(catalog: Option<&SpellCatalog>, inputs: &[AuraInput]) -> Vec { + inputs + .iter() + .map(|&(slot, spell, count, flags)| unit_aura_state(catalog, slot, spell, count, flags)) + .collect() +} + /// 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`]). +fn other_unit_inputs<'a>( + store: &'a ObjectStore, + catalog: Option<&'a SpellCatalog>, + buffs_visible: bool, +) -> impl Iterator + 'a { + store + .0 + .unit_auras() + .filter(move |a| buffs_visible || a.slot >= UNIT_AURA_POSITIVE_SLOTS) + .filter(move |a| shown_in_aura_ui(catalog, a.spell_id)) + .map(|a| (a.slot, a.spell_id, a.stacks, a.flags)) +} + +/// [`other_unit_inputs`] as the list. fn other_unit_auras( store: &ObjectStore, catalog: Option<&SpellCatalog>, buffs_visible: bool, ) -> Vec { - store - .0 - .unit_auras() - .filter(|a| buffs_visible || a.slot >= UNIT_AURA_POSITIVE_SLOTS) - .filter(|a| shown_in_aura_ui(catalog, a.spell_id)) - .map(|a| unit_aura_state(catalog, a.slot, a.spell_id, a.stacks, a.flags)) - .collect() + let inputs: Vec = other_unit_inputs(store, catalog, buffs_visible).collect(); + aura_list(catalog, &inputs) } /// 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 { +fn record_inputs<'a>( + slots: impl Iterator + 'a, + catalog: Option<&'a SpellCatalog>, +) -> impl Iterator + 'a { 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() + .map(|(slot, spell)| (slot, u32::from(spell))) + .filter(move |&(_, spell)| spell != 0 && shown_in_aura_ui(catalog, spell)) + .map(|(slot, spell)| (slot, spell, 1, 0)) } /// `UnitBuff 0x519500`'s unit-level gate, before any slot: a unit's buffs are listed only if it @@ -515,18 +537,18 @@ 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( + // `UnitBuff`'s unit gate on a live unit. `unit` is never `store`, the player's, which is the + // gate's second argument. + let buffs_visible = |unit: &ObjectStore| { + buffs_visible_on( unit, Some(store), factions.as_deref(), &reputations, |owner| held(owner).cloned(), - ); - other_unit_auras(unit, catalog, buffs) + ) }; + let gated = |unit: &ObjectStore| other_unit_auras(unit, catalog, buffs_visible(unit)); // The target's rows. A self-target mirrors the player list in cache order, where the // reference's `UnitBuff` reads by slot. @@ -567,9 +589,6 @@ fn feed_auras( gated(tot_store) }); - // `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()), @@ -603,25 +622,25 @@ fn feed_auras( .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(); - + // `party1..4`, `partypet1..4`, `raid1..40` and `raidpet1..40`: the live unit's rows, else its + // roster record's, each pushed only as it moves. + let catalog_moved = spells.as_ref().is_some_and(|s| s.is_changed()); + let group_changed = memo.group.feed( + &mut script, + group::Inputs { + group: group.as_deref(), + me: (self_guid.0, &list, changed), + held, + buffs_visible, + catalog, + catalog_moved, + }, + ); 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())]); @@ -640,7 +659,7 @@ fn feed_auras( } // 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 { + for token in group_changed { script.fire_event("UNIT_AURA", vec![ScriptValue::Str(token.into())]); } } @@ -678,7 +697,7 @@ 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 { + for token in group::tokens() { script.set_auras(token, None); } script.set_tracking(None); diff --git a/crates/benilla-app/src/ui_aura/group.rs b/crates/benilla-app/src/ui_aura/group.rs new file mode 100644 index 00000000..e51c2636 --- /dev/null +++ b/crates/benilla-app/src/ui_aura/group.rs @@ -0,0 +1,775 @@ +//! The group tokens' aura lists: `partyN`, `partypetN`, `raidN` and `raidpetN`. `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`). A list is built once per guid and shared by every token naming it, +//! and rebuilt only when what it is built from moves: a 40-man raid names up to 80 units. + +use std::collections::hash_map::{Entry, HashMap}; + +use benilla_formats::SpellCatalog; +use benilla_ui::script::{AuraState, UiScript}; + +use super::{aura_list, other_unit_inputs, record_inputs, AuraInput}; +use crate::net::ObjectStore; +use crate::ui_party::{raid_row_guids, GroupState, RAID_TOKENS}; + +/// `partyN` then `partypetN`. +const PARTY_AURA_TOKENS: [&str; 8] = [ + "party1", + "party2", + "party3", + "party4", + "partypet1", + "partypet2", + "partypet3", + "partypet4", +]; + +/// `raidpet1..raidpet40`, one per raid row. +#[rustfmt::skip] +const RAID_PET_TOKENS: [&str; 40] = [ + "raidpet1", "raidpet2", "raidpet3", "raidpet4", "raidpet5", "raidpet6", "raidpet7", + "raidpet8", "raidpet9", "raidpet10", "raidpet11", "raidpet12", "raidpet13", "raidpet14", + "raidpet15", "raidpet16", "raidpet17", "raidpet18", "raidpet19", "raidpet20", "raidpet21", + "raidpet22", "raidpet23", "raidpet24", "raidpet25", "raidpet26", "raidpet27", "raidpet28", + "raidpet29", "raidpet30", "raidpet31", "raidpet32", "raidpet33", "raidpet34", "raidpet35", + "raidpet36", "raidpet37", "raidpet38", "raidpet39", "raidpet40", +]; + +const TOKEN_COUNT: usize = PARTY_AURA_TOKENS.len() + RAID_TOKENS.len() + RAID_PET_TOKENS.len(); + +/// Every token this feeds, in [`token_guids`]' order. +pub(super) fn tokens() -> impl Iterator { + PARTY_AURA_TOKENS + .into_iter() + .chain(RAID_TOKENS) + .chain(RAID_PET_TOKENS) +} + +/// The guid each of [`tokens`] names, `None` for nobody. `party1..4` are the own subgroup's slots +/// and `partypetN` their pets (`0x4e81d0`); `raidN` is `GetRaidRosterInfo` row N, as every other +/// raid-token reader resolves it, and `raidpetN` that row's pet (`0x491960`). `held` is the object +/// manager's lookup (`0x468460`). +fn token_guids<'a>( + group: &GroupState, + me: u64, + held: impl Fn(u64) -> Option<&'a ObjectStore>, +) -> Vec> { + let live = |g: u64| held(g).map(|s| &s.0); + let members: Vec = group.party_slots().map(|m| m.guid).collect(); + let rows = raid_row_guids(group, Some(me)); + let mut out = Vec::with_capacity(TOKEN_COUNT); + out.extend((0..4).map(|i| members.get(i).copied())); + out.extend((0..4).map(|i| { + members + .get(i) + .and_then(|&m| group.party_pet_guid(m, live(m))) + })); + out.extend((0..RAID_TOKENS.len()).map(|i| rows.get(i).copied())); + out.extend( + (0..RAID_PET_TOKENS.len()) + .map(|i| rows.get(i).and_then(|&m| group.raid_pet_guid(m, live(m)))), + ); + out +} + +/// What [`GroupAuras::feed`] reads this frame. +pub(super) struct Inputs<'g, 'a, H, V> { + pub(super) group: Option<&'g GroupState>, + /// Our guid, the player's list, and whether that list moved this frame: a raid row naming us + /// mirrors the player's list, as a self-target does. + pub(super) me: (u64, &'g [AuraState], bool), + /// The object manager's lookup (`0x468460`). + pub(super) held: H, + /// `UnitBuff`'s unit gate on a live unit. + pub(super) buffs_visible: V, + pub(super) catalog: Option<&'a SpellCatalog>, + /// The spell catalog was replaced, so every list is rebuilt. + pub(super) catalog_moved: bool, +} + +/// One guid's list, shared by every token naming it. +struct GuidList { + /// What the list was built from; the list is rebuilt only when these move. + inputs: Vec, + list: Vec, + /// Taken from [`GroupAuras::versions`] each time the list changes. + version: u64, + /// The frame that last read it; an entry no token read this frame is dropped. + frame: u64, +} + +/// The group tokens' feed state for one VM. +#[derive(Default)] +pub(super) struct GroupAuras { + /// Each token's last push, by [`tokens`] index: the guid and that guid's list version. + pushed: Vec>, + by_guid: HashMap, + /// The last version handed out; every list change takes the next, so none repeats. + versions: u64, + /// The player's list version, for a raid row naming us. + player_version: u64, + catalog_present: bool, + frame: u64, + /// The inputs being read, kept to save the allocation. + scratch: Vec, +} + +impl GroupAuras { + /// Push each token whose unit or list moved, clear each that stopped naming anyone, and + /// return the tokens `UNIT_AURA` fires for: every token naming a unit whose list moved + /// (`0x515e50`), or that names a new unit. Clearing a token fires nothing. + pub(super) fn feed<'a, H, V>( + &mut self, + script: &mut UiScript, + inputs: Inputs<'_, 'a, H, V>, + ) -> Vec<&'static str> + where + H: Fn(u64) -> Option<&'a ObjectStore>, + V: Fn(&ObjectStore) -> bool, + { + let Inputs { + group, + me: (me, player_list, player_moved), + held, + buffs_visible, + catalog, + catalog_moved, + } = inputs; + self.frame += 1; + if catalog_moved || catalog.is_some() != self.catalog_present { + self.catalog_present = catalog.is_some(); + self.by_guid.clear(); + } + if player_moved || self.player_version == 0 { + self.versions += 1; + self.player_version = self.versions; + } + let guids = match group { + Some(group) => token_guids(group, me, &held), + None => vec![None; TOKEN_COUNT], + }; + self.pushed.resize(TOKEN_COUNT, None); + let mut fire = Vec::new(); + for (i, (token, guid)) in tokens().zip(guids).enumerate() { + let cur = guid.map(|g| { + let version = if g == me { + // Every frame, for the durations the player's list carries. + script.set_auras(token, Some(player_list.to_vec())); + self.player_version + } else { + self.refresh(g, group, &held, &buffs_visible, catalog) + }; + (g, version) + }); + if cur == self.pushed[i] { + continue; + } + if let Some((g, _)) = cur.filter(|&(g, _)| g != me) { + script.set_auras(token, Some(self.by_guid[&g].list.clone())); + } else if cur.is_none() { + script.set_auras(token, None); + } + if cur.is_some() { + fire.push(token); + } + self.pushed[i] = cur; + } + let frame = self.frame; + self.by_guid.retain(|_, e| e.frame == frame); + fire + } + + /// `guid`'s list version this frame, rebuilding the list if what it is built from moved: the + /// live unit's gated slots, else the roster record's block. + fn refresh<'a>( + &mut self, + guid: u64, + group: Option<&GroupState>, + held: &impl Fn(u64) -> Option<&'a ObjectStore>, + buffs_visible: &impl Fn(&ObjectStore) -> bool, + catalog: Option<&SpellCatalog>, + ) -> u64 { + if let Some(e) = self.by_guid.get(&guid).filter(|e| e.frame == self.frame) { + return e.version; + } + self.scratch.clear(); + match held(guid) { + Some(store) => { + self.scratch + .extend(other_unit_inputs(store, catalog, buffs_visible(store))) + } + None => { + if let Some(group) = group { + self.scratch + .extend(record_inputs(group.roster_aura_slots(guid), catalog)); + } + } + } + match self.by_guid.entry(guid) { + Entry::Occupied(mut e) => { + let e = e.get_mut(); + e.frame = self.frame; + if e.inputs != self.scratch { + std::mem::swap(&mut e.inputs, &mut self.scratch); + let list = aura_list(catalog, &e.inputs); + if list != e.list { + e.list = list; + self.versions += 1; + e.version = self.versions; + } + } + e.version + } + Entry::Vacant(e) => { + self.versions += 1; + let inputs = self.scratch.clone(); + let list = aura_list(catalog, &inputs); + e.insert(GuidList { + inputs, + list, + version: self.versions, + frame: self.frame, + }) + .version + } + } + } +} + +#[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 group's tokens only: the player's list fires on the first frame too. + f:SetScript("OnEvent", function() + if arg1 ~= "player" 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) { + stats_of(app, MEMBER, full, info); + } + + fn stats_of(app: &mut App, guid: u64, full: bool, info: PartyMemberStatsInfo) { + app.world_mut() + .resource_mut::() + .apply_stats(guid, 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())); + } + + // == The raid tokens == + + /// In our subgroup, so `party1` and `raid2`. + const MATE: u64 = 0x2001; + /// Subgroup 2, `raid5` only. + const FAR: u64 = 0x2004; + const FAR_PET: u64 = 0xF140_0000_0000_0099; + + /// A raid of five: us (row 1, subgroup 1), `MATE` (subgroup 1), two in subgroup 2 and `FAR` + /// in subgroup 3, in wire order, none streamed and none with a record. + fn raid_app() -> App { + let mut app = party_app(); + let member = |name: &str, guid, subgroup| GroupMemberEntry { + name: name.into(), + guid, + status: member_status::ONLINE, + flags: subgroup, + }; + app.world_mut().resource_mut::().apply_list( + crate::ui_party::GROUPTYPE_RAID, + 0, + vec![ + member("Mate", MATE, 0), + member("Two", 0x2002, 1), + member("Three", 0x2003, 1), + member("Far", FAR, 2), + ], + ME, + None, + Some(ME), + ); + app.update(); + let _ = seen(&mut app); + app + } + + fn online(auras_negative: Vec<(u8, u16)>) -> PartyMemberStatsInfo { + PartyMemberStatsInfo { + status: Some(member_status::ONLINE), + auras_negative: Some(auras_negative), + ..Default::default() + } + } + + /// A raid member in view reads its own `UNIT_FIELD_AURA` slots under `raidN`, whatever its + /// subgroup, and its buffs keep the unit gate. + #[test] + fn a_live_raid_members_auras_come_off_its_descriptor() { + let mut app = raid_app(); + stream( + &mut app, + FAR, + &[ + (AURA, 1126), + (AURA + 33, 589), + (AURAFLAGS, 0x0000_0008), + (AURAFLAGS + 4, 0x0000_0080), + ], + ); + app.update(); + assert_eq!(listed(&mut app, "raid5", true), [589]); + assert!( + listed(&mut app, "raid5", false).is_empty(), + "no factions, so `CanAssist` fails and the gate drops the buffs" + ); + assert_eq!(seen(&mut app), ["raid5"]); + for token in ["party1", "party2", "raid6"] { + assert!(listed(&mut app, token, true).is_empty(), "{token}"); + } + } + + /// Out of range, `raidN` walks the member's record, which vmangos keeps current for every raid + /// member we do not see; `raidpetN` walks the pet block the same record names. + #[test] + fn an_out_of_range_raid_member_and_its_pet_read_the_record() { + let mut app = raid_app(); + stats_of( + &mut app, + FAR, + true, + PartyMemberStatsInfo { + pet_guid: Some(FAR_PET), + pet_auras: Some(vec![(1, 1126)]), + pet_auras_negative: Some(vec![(33, 770)]), + ..online(vec![(32, 589), (35, 11976)]) + }, + ); + app.update(); + assert_eq!(listed(&mut app, "raid5", true), [589, 11976]); + let count = app + .world_mut() + .non_send_resource::() + .eval::(r#"local _, count = UnitDebuff("raid5", 1) return count"#) + .unwrap(); + assert_eq!(count, 1); + assert_eq!(listed(&mut app, "raidpet5", false), [1126]); + assert_eq!(listed(&mut app, "raidpet5", true), [770]); + assert_eq!(seen(&mut app), ["raid5", "raidpet5"]); + assert!( + listed(&mut app, "raidpet4", true).is_empty(), + "a petless member's raidpet token names nobody" + ); + } + + /// `UNIT_AURA` fires for every token naming a unit whose list moved (`0x515e50`), and for + /// nothing else; a delta that moves no aura fires nothing. + #[test] + fn unit_aura_fires_for_each_token_naming_the_member_whose_list_moved() { + let mut app = raid_app(); + stats_of(&mut app, MATE, true, online(vec![(32, 589)])); + stats_of(&mut app, FAR, true, online(vec![(32, 770)])); + app.update(); + let mut first = seen(&mut app); + first.sort(); + assert_eq!(first, ["party1", "raid2", "raid5"]); + + stats_of(&mut app, MATE, false, online(vec![(33, 11976)])); + app.update(); + assert_eq!(seen(&mut app), ["party1", "raid2"]); + assert_eq!(listed(&mut app, "raid2", true), [589, 11976]); + assert_eq!(listed(&mut app, "party1", true), [589, 11976]); + + stats_of( + &mut app, + FAR, + false, + PartyMemberStatsInfo { + cur_hp: Some(7), + ..Default::default() + }, + ); + app.update(); + assert!(seen(&mut app).is_empty()); + } + + /// A raid token past the roster, a raid token outside a raid, and an offline member's pet + /// all answer nil. + #[test] + fn a_raid_token_naming_nobody_answers_nil() { + let mut app = party_app(); + stats(&mut app, true, online(vec![(32, 589)])); + app.update(); + assert_eq!(listed(&mut app, "party1", true), [589]); + for token in ["raid1", "raid2"] { + assert!( + listed(&mut app, token, true).is_empty(), + "a party is no raid: {token}" + ); + } + + let mut app = raid_app(); + stats_of( + &mut app, + FAR, + true, + PartyMemberStatsInfo { + status: Some(member_status::OFFLINE), + pet_guid: Some(FAR_PET), + pet_auras_negative: Some(vec![(33, 770)]), + auras_negative: Some(vec![(32, 589)]), + ..Default::default() + }, + ); + app.update(); + for token in ["raid5", "raidpet5", "raid6", "raid40", "raidpet40"] { + assert!(listed(&mut app, token, true).is_empty(), "{token}"); + } + } + + /// Row 1 is us: the player's own list, as `"player"` answers it. + #[test] + fn our_raid_row_mirrors_the_player_list() { + let mut app = raid_app(); + let me = app.world_mut().resource::().0[&ME]; + *app.world_mut().get_mut::(me).unwrap() = + ObjectStore(ObjectFields::from_pairs(&[ + (AURA + 32, 589), + (AURAFLAGS + 4, 0x0000_0008), + ])); + app.update(); + assert_eq!(listed(&mut app, "player", true), [589]); + assert_eq!(listed(&mut app, "raid1", true), [589]); + assert_eq!(seen(&mut app), ["raid1"]); + } + + /// A frame where nothing moved builds no list: the feed compares the inputs, not the lists. + #[test] + fn an_unchanged_raid_rebuilds_no_list() { + let mut app = raid_app(); + stats_of(&mut app, FAR, true, online(vec![(32, 589)])); + app.update(); + let versions = |app: &mut App| { + app.world_mut() + .resource_scope(|world, mut mem: Mut| { + let script = world.non_send_resource::(); + mem.vm.get(script).group.versions + }) + }; + let before = versions(&mut app); + for _ in 0..3 { + app.update(); + } + assert_eq!(versions(&mut app), before); + stats_of(&mut app, FAR, false, online(vec![(33, 770)])); + app.update(); + assert_eq!( + versions(&mut app), + before + 1, + "one list moved, one rebuilt" + ); + } +} diff --git a/crates/benilla-app/src/ui_aura/party.rs b/crates/benilla-app/src/ui_aura/party.rs deleted file mode 100644 index e1f4a054..00000000 --- a/crates/benilla-app/src/ui_aura/party.rs +++ /dev/null @@ -1,376 +0,0 @@ -//! 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 8d175ed4..f59bde22 100644 --- a/crates/benilla-app/src/ui_party/mod.rs +++ b/crates/benilla-app/src/ui_party/mod.rs @@ -18,8 +18,8 @@ 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, + raid_row_guid, raid_row_guids, synthetic_raid, synthetic_roster, GROUPTYPE_RAID, + GROUP_MEMBER_SUBGROUP, PARTY_TOKENS, RAID_TOKENS, }; pub(crate) struct UiPartyPlugin; diff --git a/crates/benilla-app/src/ui_party/records.rs b/crates/benilla-app/src/ui_party/records.rs index a8f67ec5..25302c0e 100644 --- a/crates/benilla-app/src/ui_party/records.rs +++ b/crates/benilla-app/src/ui_party/records.rs @@ -1,5 +1,6 @@ //! 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`). +//! (`0x496400`), the pet record (`0x496420`) and the party and raid pets' guids (`0x4e81d0`, +//! `0x491960`). use benilla_protocol::messages::{member_status, ObjectFields, PartyMemberStatsInfo}; @@ -12,7 +13,7 @@ impl GroupState { } /// The record whose pet block names `guid` (`0x496420`); a pet is found only while its owner's - /// record reads online (`0x4e8860`). + /// record reads online, party (`0x4e8860`) or raid (`0x4bb130`). fn pet_record(&self, guid: u64) -> Option<&PartyMemberStatsInfo> { self.stats .values() @@ -23,11 +24,27 @@ impl GroupState { /// 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 { + self.pet_guid_of(member, live, true) + } + + /// `raidpetN`'s guid for the raid member `member` (`0x491960`): `CHARM`, else `SUMMON`, off the + /// member's descriptor while it is held (`0x491993`-`0x4919a9`), else the record's pet guid + /// with no online test (`0x4919ae`), where `partypetN` has one. + pub(crate) fn raid_pet_guid(&self, member: u64, live: Option<&ObjectFields>) -> Option { + self.pet_guid_of(member, live, false) + } + + fn pet_guid_of( + &self, + member: u64, + live: Option<&ObjectFields>, + online_only: bool, + ) -> Option { match live { Some(fields) => fields.unit_pet_guid(), None => self .member_record(member) - .filter(|r| online(r)) + .filter(|r| !online_only || online(r)) .and_then(|r| r.pet_guid) .filter(|&g| g != 0), } @@ -37,16 +54,17 @@ impl GroupState { /// (`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(), - }, + pub(crate) fn roster_aura_slots(&self, guid: u64) -> impl Iterator + '_ { + let blocks = match self.member_record(guid) { + Some(r) if !online(r) => None, + Some(r) => Some((&r.auras, &r.auras_negative)), + None => self + .pet_record(guid) + .map(|r| (&r.pet_auras, &r.pet_auras_negative)), }; - buffs.iter().chain(debuffs).flatten().copied().collect() + blocks + .into_iter() + .flat_map(|(buffs, debuffs)| buffs.iter().chain(debuffs).flatten().copied()) } } @@ -97,18 +115,19 @@ mod tests { #[test] fn a_member_record_answers_its_own_block_and_an_offline_one_answers_nothing() { let g = group_with(record(member_status::ONLINE)); + let slots = |g: &GroupState, guid| g.roster_aura_slots(guid).collect::>(); assert_eq!( - g.roster_aura_slots(MEMBER), + slots(&g, 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"); + assert_eq!(slots(&g, PET), [(2, 1126), (40, 770)]); + assert!(slots(&g, 0x9999).is_empty(), "a miss everywhere"); let g = group_with(record(member_status::OFFLINE)); - assert!(g.roster_aura_slots(MEMBER).is_empty()); + assert!(slots(&g, MEMBER).is_empty()); assert!( - g.roster_aura_slots(PET).is_empty(), + slots(&g, PET).is_empty(), "an offline owner's pet is not found" ); } @@ -135,4 +154,25 @@ mod tests { let g = group_with(record(member_status::OFFLINE)); assert_eq!(g.party_pet_guid(MEMBER, None), None); } + + /// `raidpetN` reads the same fields as `partypetN`, but its record leg has no online test + /// (`0x4919ae`, against `partypetN`'s `0x4e8227`). + #[test] + fn the_raid_pet_guid_reads_the_record_whatever_its_online_bit() { + let g = group_with(record(member_status::ONLINE)); + assert_eq!(g.raid_pet_guid(MEMBER, None), Some(PET)); + let summon = ObjectFields::from_pairs(&[(8, 0x55), (9, 0)]); + assert_eq!(g.raid_pet_guid(MEMBER, Some(&summon)), Some(0x55)); + assert_eq!( + g.raid_pet_guid(MEMBER, Some(&ObjectFields::default())), + None + ); + + let g = group_with(record(member_status::OFFLINE)); + assert_eq!(g.raid_pet_guid(MEMBER, None), Some(PET)); + assert!( + g.roster_aura_slots(PET).next().is_none(), + "named, but the pet record is not found behind an offline owner" + ); + } }