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) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-27 18:56:24 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ba7fe6e2e5
commit 05a9be68e5
11 changed files with 894 additions and 143 deletions
+10 -4
View File
@@ -302,8 +302,8 @@ fn on_object_values(In(ev): In<SessionEvent>, mut sc: Scene) {
fn on_object_destroyed(In(ev): In<SessionEvent>, 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<SessionEvent>, mut sc: Scene) {
fn on_objects_removed(In(ev): In<SessionEvent>, 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);
}
+135 -86
View File
@@ -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<AuraProjection>)>,
pet_last: Option<(u64, Vec<AuraProjection>)>,
tot_last: Option<(u64, Vec<AuraProjection>)>,
/// Each `partyN` and `partypetN` token's, keyed the same way.
party_last: HashMap<&'static str, Option<(u64, Vec<AuraProjection>)>>,
}
/// 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<AuraProjection> {
.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<AuraProjection>)>,
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<AuraState> {
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<Res<Spells>>,
pet: Res<crate::ui_pet::PetBar>,
// `Option`: a UI-only harness has no party plugin; without it no party token has a list.
group: Option<Res<crate::ui_party::GroupState>>,
index: Res<GuidIndex>,
// The `UnitBuff` gate's inputs, the same reaction the selection ring and nameplates use.
factions: Option<Res<Factions>>,
@@ -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<Vec<AuraState>> =
@@ -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<Vec<AuraState>> = (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<Vec<AuraState>> = (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<Vec<AuraState>> =
(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();
+376
View File
@@ -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<AuraState>);
/// 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<AuraState>,
catalog: Option<&SpellCatalog>,
) -> Vec<(&'static str, Option<UnitAuras>)> {
let Some(group) = group else {
return PARTY_AURA_TOKENS.iter().map(|t| (*t, None)).collect();
};
let members: Vec<u64> = 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::<crate::target::Selection>()
.init_resource::<crate::ui_pet::PetBar>()
.init_resource::<GuidIndex>()
.init_resource::<Reputations>()
.init_resource::<GroupState>()
.insert_resource(NetCommands(tx))
.add_plugins(super::super::UiAuraPlugin);
app.insert_non_send_resource(UiScript::new().unwrap());
app.world_mut()
.non_send_resource::<UiScript>()
.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::<GuidIndex>().0.insert(ME, me);
let member = GroupMemberEntry {
name: "Brisca".into(),
guid: MEMBER,
status: member_status::ONLINE,
flags: 0,
};
app.world_mut().resource_mut::<GroupState>().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::<GuidIndex>()
.0
.insert(guid, e);
}
fn stats(app: &mut App, full: bool, info: PartyMemberStatsInfo) {
app.world_mut()
.resource_mut::<GroupState>()
.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<i64> {
let (verb, filter) = if harmful {
("UnitDebuff", "HARMFUL")
} else {
("UnitBuff", "HELPFUL")
};
app.world_mut()
.non_send_resource::<UiScript>()
.eval::<Vec<i64>>(&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<String> {
app.world_mut()
.non_send_resource::<UiScript>()
.eval::<Vec<String>>("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::<UiScript>()
.eval::<i64>(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()));
}
}
+16 -15
View File
@@ -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);
}
}
}
+62 -11
View File
@@ -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());
}
+138
View File
@@ -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<u64> {
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);
}
}
+103 -17
View File
@@ -164,10 +164,12 @@ pub struct PartyMemberStatsInfo {
pub zone: Option<u16>,
/// 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<Vec<u16>>,
/// Active debuff spell ids, likewise, from a `u16` slot mask.
pub auras_negative: Option<Vec<u16>>,
/// 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<Vec<(u8, u16)>>,
/// Debuff slots, likewise, from a `u16` mask; the slot is absolute, 32..47.
pub auras_negative: Option<Vec<(u8, u16)>>,
pub pet_guid: Option<u64>,
pub pet_name: Option<String>,
pub pet_model_id: Option<u16>,
@@ -176,8 +178,26 @@ pub struct PartyMemberStatsInfo {
pub pet_power_type: Option<u8>,
pub pet_cur_power: Option<u16>,
pub pet_max_power: Option<u16>,
pub pet_auras: Option<Vec<u16>>,
pub pet_auras_negative: Option<Vec<u16>>,
/// The pet's buff and debuff slots, as [`Self::auras`] and [`Self::auras_negative`].
pub pet_auras: Option<Vec<(u8, u16)>>,
pub pet_auras_negative: Option<Vec<(u8, u16)>>,
}
/// 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<Vec<(u8, u16)>>, 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<Vec<u16>> {
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<Vec<(u8, u16)>> {
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))
}
+4 -4
View File
@@ -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,
@@ -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;
@@ -67,6 +67,15 @@ impl ObjectFields {
pub fn unit_summon(&self) -> Option<u64> {
self.get_guid(FIELD_UNIT_SUMMON).filter(|&g| g != 0)
}
/// `UNIT_FIELD_CHARM`: the unit this one charms.
pub fn unit_charm(&self) -> Option<u64> {
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<u64> {
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<u64> {
self.get_guid(FIELD_UNIT_SUMMONEDBY).filter(|&g| g != 0)
+39 -6
View File
@@ -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]