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:
co-authored by
Claude Opus 5.5
parent
ba7fe6e2e5
commit
05a9be68e5
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user