partypetN and raidpetN unit snapshots: the getters read the held pet or its owner's record, UNIT_PET fires with the owner's token, and the stock party pet frame shows (#644)

The first half of #448 taught the unit-token resolver `partypetN` and `raidpetN`. The per-token unit snapshot that `UnitExists`, `UnitName`, `UnitHealth` and the other getters read still pushed neither. So the stock party pet frames never showed: `PartyMemberFrame_UpdatePet` shows the pet frame on `UnitExists("partypet"..id)`, and `SHOW_PARTY_PETS` defaults to `"1"`.

In the reference:
- **`UnitExists`** (`0x515fb0`) answers 1 for a unit the client holds. It also answers 1, through the roster fallback, for a party member's pet guid cached behind the owner's online bit (`0x4e80a0`) and for a raid row's pet (`0x4bafd0`, gated on the row's online bit at `0x4bb040`), with no object at all.
- **The getters** read, in order: the live descriptor, the party or raid record (`0x496400`), then the pet record (`0x496420`). The pet record holds health `+0xdc`, max health `+0xde`, power type `+0xd8`, and power and max power `+0xe0`/`+0xe2`, the power divided as the live path does.
  - `UnitIsConnected` is 1 on any pet-record hit (`0x517dfb`).
  - `UnitLevel` and the dead and ghost predicates have no pet leg.
- **Events:** the unit-token router `0x515c50` fires each unit event once per token naming the unit, `partypet%d` and `raidpet%d` included. The stats handler `0x5e5720` fires `UNIT_PET` for the owner on the pet-guid bit.

Changes:
- `ui_party/pets.rs` pushes a snapshot per `partypetN` and `raidpetN`. It comes from the pet's store while held, else from the owner's record, and the existing transitions fire each token's `UNIT_*` events.
- `UNIT_PET` fires with the owner's token when that owner's pet guid changes. The feed's change gate now watches the members' pet stores.
- The despawn snapshot copies the pet's cached name into the member's record (`0x5f0a63`-`0x5f0a70`), so `UnitName("partypetN")` answers for a pet out of view.
- `partypet1..4` get portrait slots. A held pet bakes like `"pet"`. An unheld one that the owner's record names shows `TemporaryPortrait-Pet` (`0x525cb0`).

Tests:
- `ui_party/pets.rs` covers the held-pet and record getters, an offline owner, `UNIT_PET` off the record and off a held member's `SUMMON`, and `UNIT_HEALTH` with the pet's token.
- It also covers raid rows with an offline row's pet.
- `the_stock_party_frame_shows_and_hides_its_pet_frame` loads the default UI and checks that `PartyMemberFrame1PetFrame` shows and hides on dismiss.
- Two portrait tests cover the slots and the stand-in.

Fixes #448

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-30 00:02:42 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 8ef62da6ac
commit e071b8ded3
9 changed files with 1079 additions and 20 deletions
+9 -1
View File
@@ -303,7 +303,14 @@ 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 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);
crate::ui_party::net::member_deactivated(
guid,
&mut sc.group,
held(guid),
held,
&sc.names,
&sc.net,
);
object_destroyed(guid, &mut sc.commands, &mut sc.index);
}
}
@@ -319,6 +326,7 @@ fn on_objects_removed(In(ev): In<SessionEvent>, mut sc: Scene) {
&mut sc.group,
held(*guid),
held,
&sc.names,
&sc.net,
);
}
+1 -1
View File
@@ -228,7 +228,7 @@ fn on_worldport(
} = ev
{
// Every streamed roster member is about to be purged; the reference deactivates each.
crate::ui_party::net::roster_deactivated(&mut group, &b.index, &b.stores, &b.net);
crate::ui_party::net::roster_deactivated(&mut group, &b.index, &b.stores, &b.names, &b.net);
worldport(
map_id,
position,
+175 -2
View File
@@ -65,8 +65,9 @@ pub(crate) mod test_bake;
/// The round portrait slots, each with its own layer and camera. `"npc"` is an interaction
/// window's NPC ([`crate::ui_session::InteractNpc`]); `"targettarget"` resolves only while drawn,
/// and so does the loot source `SetLootPortrait` binds ([`benilla_ui::script::LOOT_PORTRAIT_UNIT`]).
const SLOTS: [&str; 10] = [
/// and so do a party member's pet, `"partypetN"`, and the loot source `SetLootPortrait` binds
/// ([`benilla_ui::script::LOOT_PORTRAIT_UNIT`]).
const SLOTS: [&str; 14] = [
"player",
"target",
"targettarget",
@@ -76,6 +77,10 @@ const SLOTS: [&str; 10] = [
"party2",
"party3",
"party4",
"partypet1",
"partypet2",
"partypet3",
"partypet4",
benilla_ui::script::LOOT_PORTRAIT_UNIT,
];
/// The character window's full-body pane: the dressed player, sampled square.
@@ -1343,6 +1348,19 @@ fn sync_portraits(
.and_then(|g| party.index.0.get(&g).copied())
})
.flatten(),
// A party member's pet, gated on the UI drawing it as `"targettarget"` is: a slot's
// frame ships hidden, and an undrawn one would bake for nothing.
tok if tok.starts_with("partypet") => {
let (entity, stand_in) = party_pet_subject(
&party.roster,
tok,
party.panes.0.contains_key(tok),
|g| party.index.0.get(&g).copied(),
|g| party.index.0.get(&g).and_then(|e| stores_q.get(*e).ok()),
);
unseen = stand_in.or(unseen);
entity
}
// Out of range, the stand-in's race and sex come from the name cache.
tok => {
let member = tok
@@ -2193,12 +2211,56 @@ fn player_temporary_portrait(race: Option<u8>, sex: Option<u8>) -> String {
format!("{TEMPORARY_PORTRAIT}-{sex}-{race}.blp")
}
/// What `"partypetN"`'s portrait shows, as the entity to bake and the stand-in file: the pet the
/// roster names for that party slot (`0x4e81d0`) while the UI draws the slot. A pet the client does
/// not hold takes the roster pet's stand-in (`0x525cb0`), and one with no pet record, blank
/// (`0x51a0ce`), so neither.
///
/// Deviation: the reference probes a bake cache keyed by the record's display id (`0x525cb0`,
/// `0xc0cea4`) before the stand-in, so a pet out of view keeps its face. benilla holds no such
/// cache, and [`PortraitBakes`] is keyed by guid for the party slots alone: the stand-in shows.
fn party_pet_subject<'a>(
roster: &crate::ui_party::GroupState,
token: &str,
drawn: bool,
entity_of: impl Fn(u64) -> Option<Entity>,
store_of: impl Fn(u64) -> Option<&'a crate::net::ObjectStore>,
) -> (Option<Entity>, Option<String>) {
if !drawn {
return (None, None);
}
let Some(member) = token
.strip_prefix("partypet")
.and_then(|n| n.parse::<usize>().ok())
.and_then(|n| n.checked_sub(1))
.and_then(|n| roster.party_slots().nth(n))
else {
return (None, None);
};
let Some(pet) = roster.party_pet_guid(member.guid, store_of(member.guid).map(|s| &s.0)) else {
return (None, None);
};
match entity_of(pet) {
Some(entity) => (Some(entity), None),
None => (
None,
roster.pet_record(pet).map(|_| pet_temporary_portrait()),
),
}
}
/// The creature stand-in, benilla's own file: the reference's only creature stand-in is `-Pet`,
/// for a roster pet with no object (`0x525cb0`), and a live creature not yet loaded shows blank.
fn creature_temporary_portrait() -> String {
format!("{TEMPORARY_PORTRAIT}-Monster.blp")
}
/// A roster pet with no object and no bake under its display id (`0x525cb0`): the reference's
/// creature stand-in, its only.
fn pet_temporary_portrait() -> String {
format!("{TEMPORARY_PORTRAIT}-Pet.blp")
}
/// Set the named slot's camera transform and projection to `rig`.
fn aim(
cams: &mut Query<(&BoothCam, &mut Transform, &mut Projection)>,
@@ -2218,6 +2280,117 @@ mod tests {
use super::*;
use crate::entities::ItemModelKind;
/// The stock pet frame binds `"partypetN"` (`PartyFrameTemplates.xml`, `UnitFrame_Initialize`),
/// and `SetPortraitTexture` draws only a token a slot bakes.
#[test]
fn every_party_pet_token_has_a_portrait_slot() {
for token in crate::ui_party::PARTY_PET_TOKENS {
assert!(SLOTS.contains(&token), "{token} has no slot");
}
}
/// The party pet's portrait, by what the client holds: the pet's own model while it is held,
/// the roster pet's stand-in (`0x525cb0`) while only its owner's record names it, blank with no
/// record (`0x51a0ce`), and nothing while the UI is not drawing the slot.
#[test]
fn a_party_pets_portrait_is_its_bake_else_the_roster_pets_stand_in() {
use benilla_protocol::messages::{
member_status, GroupMemberEntry, ObjectFields, PartyMemberStatsInfo,
};
const MEMBER: u64 = 0x1001;
const PET: u64 = 0xF140_0000_0000_0001;
let mut world = World::new();
let held = world.spawn_empty().id();
let summon = |guid: u64| {
crate::net::ObjectStore(ObjectFields::from_pairs(&[
(8, guid as u32),
(9, (guid >> 32) as u32),
]))
};
let group = |status: u8| {
let mut g = crate::ui_party::GroupState::default();
g.apply_list(
0,
0,
vec![GroupMemberEntry {
name: "Brisca".into(),
guid: MEMBER,
status,
flags: 0,
}],
MEMBER,
None,
None,
);
g.stats.insert(
MEMBER,
PartyMemberStatsInfo {
status: Some(status),
pet_guid: Some(PET),
..Default::default()
},
);
g
};
let subject = |g: &crate::ui_party::GroupState,
drawn: bool,
pet_held: bool,
owner: Option<&crate::net::ObjectStore>| {
party_pet_subject(
g,
"partypet1",
drawn,
|guid| (pet_held && guid == PET).then_some(held),
|guid| owner.filter(|_| guid == MEMBER),
)
};
let online = group(member_status::ONLINE);
let stand_in = Some(pet_temporary_portrait());
assert!(stand_in
.as_deref()
.is_some_and(|f| f.ends_with("TemporaryPortrait-Pet.blp")));
assert_eq!(
subject(&online, false, true, None),
(None, None),
"not drawn"
);
assert_eq!(
subject(&online, true, true, None),
(Some(held), None),
"held"
);
assert_eq!(
subject(&online, true, false, None),
(None, stand_in.clone()),
"the record"
);
// A held owner names its pet off its descriptor, whatever the record holds.
let owner = summon(PET);
assert_eq!(
subject(&online, true, false, Some(&owner)),
(None, stand_in)
);
let mut no_record = group(member_status::ONLINE);
no_record.stats.clear();
assert_eq!(
subject(&no_record, true, false, Some(&owner)),
(None, None),
"a pet with no record is blank"
);
let offline = group(member_status::OFFLINE);
assert_eq!(
subject(&offline, true, true, None),
(None, None),
"an offline owner's pet"
);
assert_eq!(
party_pet_subject(&online, "partypet2", true, |_| Some(held), |_| None),
(None, None),
"a slot with no member"
);
}
#[test]
fn the_bake_cache_replaces_in_place_and_evicts_oldest_first() {
let mut bakes = PortraitBakes::default();
+66 -4
View File
@@ -18,6 +18,7 @@ use crate::net::{
use crate::target::Selection;
use crate::ui_script::gate;
use super::pets::{self, Roster};
use super::GroupState;
// ─── The VM feed and drain ───────────────────────────────────────────────────────────────────────
@@ -39,6 +40,8 @@ pub(super) struct FedParty {
names_generation: gate::Watch,
area: gate::Watch,
raid_units: Vec<Option<UnitState>>,
/// `partypet1..4` and `raidpet1..40`, with the `UNIT_PET` edges (see [`pets`]).
pets: pets::FedPets,
/// The raid roster's identity (guid, rank, subgroup, online): `RAID_ROSTER_UPDATE`'s edge.
raid_key: Vec<(u64, u32, u32, bool)>,
saved: Vec<SavedInstanceInfo>,
@@ -101,7 +104,7 @@ pub(super) fn feed_party(
mut edges: MessageReader<FieldChanged>,
self_q: Query<(Entity, &Guid, &ObjectStore), With<SelfPlayer>>,
factions: Option<Res<crate::target::Factions>>,
names: Res<NameCache>,
names: pets::Names,
areas: Option<Res<crate::area::AreaTableRes>>,
// The area under us, through the accessor `crate::area` uses: `terrain_stream::CurrentArea`
// would cross the world-API wall (`tests/world_api_wall.rs`).
@@ -129,7 +132,9 @@ pub(super) fn feed_party(
let area_moved = fed.area.moved(here.area().map_or(u64::MAX, u64::from));
let group_changed = group.is_changed();
let index_changed = index.is_changed();
// Only the stores the merged view reads: a crowd's other stores change every frame.
let look = pets::Lookup::new(&index, &stores, &names);
// Only the stores the merged view reads: a crowd's other stores change every frame. The
// members' pets are the group's too, `partypetN` and `raidpetN` reading their descriptors.
let stores_changed = self_q
.iter()
.next()
@@ -139,7 +144,13 @@ pub(super) fn feed_party(
.0
.get(&m.guid)
.is_some_and(|&e| changed_stores.get(e).is_ok())
});
})
|| pets::store_changed(
&look,
&group,
self_q.iter().next().map(|(_, g, store)| (g.0, store)),
&changed_stores,
);
let stores_removed = !removed_stores.is_empty();
let factions_changed = factions.as_ref().is_some_and(|r| r.is_changed());
let areas_changed = areas.as_ref().is_some_and(|r| r.is_changed());
@@ -321,6 +332,27 @@ pub(super) fn feed_party(
}
}
// ── partypet1..partypet4 ────────────────────────────────────────────────
//
// Each slot's pet, off its descriptor or its record, after the members' own tokens and before
// the roster event whose handler asks `UnitExists("partypetN")` (`PartyMemberFrame.lua:73`).
for (i, token) in PARTY_PET_TOKENS.iter().enumerate() {
let owner = slots.get(i).map_or(0, |m| m.guid);
let now = (owner != 0)
.then(|| pets::member_pet(&look, &group, owner, look.store(owner), Roster::Party))
.flatten();
pets::feed_token(
&mut script,
&gate,
&edges,
&mut fed.pets.party[i],
token,
Some(PARTY_TOKENS[i]),
owner,
now,
);
}
// ── raid1..raid40 ──────────────────────────────────────────────────────
//
// Token N is `GetRaidRosterInfo` row N, both in `raid_row_guids`' order. Our own row comes off
@@ -368,6 +400,35 @@ pub(super) fn feed_party(
}
}
// ── raidpet1..raidpet40 ────────────────────────────────────────────────
//
// Row N's pet. Our own row's pet is `"pet"`, whose own feed fires `UNIT_PET("player")`.
fed.pets
.raid
.resize_with(RAID_PET_TOKENS.len(), Default::default);
for (i, token) in RAID_PET_TOKENS.iter().enumerate() {
let owner = raid_guids.get(i).copied().unwrap_or(0);
let ours = Some(owner) == self_guid;
let held = if ours {
self_pair.map(|(_, _, store)| store)
} else {
look.store(owner)
};
let now = (owner != 0)
.then(|| pets::member_pet(&look, &group, owner, held, Roster::Raid))
.flatten();
pets::feed_token(
&mut script,
&gate,
&edges,
&mut fed.pets.raid[i],
token,
(!ours).then_some(RAID_TOKENS[i]),
owner,
now,
);
}
let roster: Vec<u64> = group.members.iter().map(|m| m.guid).collect();
if roster != fed.roster {
gate.audit("feed_party", "the roster edge");
@@ -661,7 +722,8 @@ fn member_unit_state(
// Unstreamed: the roster record, snapshotted from the descriptor at despawn (`0x5f0880`),
// seated at 1/1 for a member never seen (`0x4e82d0`) and patched by the wire. The
// reference's getters read the descriptor, then the party record (`0x496400`), then the
// pet record (`0x496420`); a `partyN` token is never a pet, so the pet leg cannot arise.
// pet record (`0x496420`); a `partyN` token names a player, so only `partypetN` reaches
// that last leg ([`pets`]).
None => UnitState {
exists: true,
guid: m.guid,
+1
View File
@@ -16,6 +16,7 @@ use crate::ui_script::{UiFeed, UiInput};
mod feed;
pub(crate) mod net;
mod pets;
mod records;
pub(crate) use feed::{
raid_row_guid, raid_row_guids, synthetic_raid, synthetic_roster, GROUPTYPE_RAID,
+49 -10
View File
@@ -299,13 +299,15 @@ 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`), 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.
/// pet's too while `held` finds it, with the name `names` holds for 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<&'a ObjectStore>,
held: impl Fn(u64) -> Option<&'a ObjectStore>,
names: &NameCache,
net_commands: &NetCommands,
) {
let Some(store) = store else {
@@ -314,12 +316,16 @@ pub(crate) fn member_deactivated<'a>(
if !group.members.iter().any(|m| m.guid == guid) {
return;
}
let pet = store.0.unit_pet_guid().and_then(held);
let pet_guid = store.0.unit_pet_guid();
let pet = pet_guid.and_then(held);
let pet_name = pet_guid
.zip(pet)
.and_then(|(g, p)| names.peek_unit(g, Some(p)));
group
.stats
.entry(guid)
.or_default()
.snapshot_descriptor(&store.0, pet.map(|p| &p.0));
.snapshot_descriptor(&store.0, pet.map(|p| &p.0), pet_name);
let _ = net_commands
.0
.send(ClientCommand::RequestPartyMemberStats { guid });
@@ -331,6 +337,7 @@ pub(crate) fn roster_deactivated(
group: &mut GroupState,
index: &GuidIndex,
stores: &Query<&mut ObjectStore>,
names: &NameCache,
net_commands: &NetCommands,
) {
let streamed: Vec<u64> = group
@@ -341,7 +348,7 @@ pub(crate) fn roster_deactivated(
.collect();
let held = |g: u64| index.0.get(&g).and_then(|e| stores.get(*e).ok());
for guid in streamed {
member_deactivated(guid, group, held(guid), held, net_commands);
member_deactivated(guid, group, held(guid), held, names, net_commands);
}
}
@@ -530,7 +537,14 @@ mod tests {
(LEVEL, 41),
(BYTES_0, 1 << 24), // POWER_RAGE in BYTES_0 byte 3
]));
member_deactivated(guid, &mut group, Some(&store), |_| None, &net);
member_deactivated(
guid,
&mut group,
Some(&store),
|_| None,
&NameCache::default(),
&net,
);
let rec = group.stats.get(&guid).expect("the member has a record");
assert_eq!(
@@ -574,13 +588,18 @@ mod tests {
(SUMMON, pet_guid as u32),
(SUMMON + 1, (pet_guid >> 32) as u32),
]));
// `UNIT_FIELD_PETNUMBER`, the key the pet-name cache files a pet's name under.
const PETNUMBER: u16 = 139;
let pet = ObjectStore(benilla_protocol::messages::ObjectFields::from_pairs(&[
(HEALTH, 900),
(PETNUMBER, 4),
(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 mut names = NameCache::default();
names.insert_pet(4, "Whelp".into());
member_deactivated(guid, &mut group, Some(&store), held, &names, &net);
let rec = group.stats.get(&guid).expect("the member has a record");
assert_eq!(
@@ -590,14 +609,20 @@ mod tests {
);
assert_eq!(rec.auras_negative, Some(vec![(33, 589)]));
assert_eq!(rec.pet_guid, Some(pet_guid));
assert_eq!(
rec.pet_name.as_deref(),
Some("Whelp"),
"the pet's cached name rides into the record, as `0x5f0a63`-`0x5f0a70` copy it (`+0x88`)"
);
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);
member_deactivated(guid, &mut group, Some(&store), |_| None, &names, &net);
let rec = group.stats.get(&guid).unwrap();
assert_eq!((rec.pet_guid, rec.pet_auras_negative.clone()), (None, None));
assert_eq!(rec.pet_name, None, "and its name with it (`0x5f0a0f`)");
assert_eq!(rec.auras, Some(vec![(0, 1126)]));
}
@@ -611,12 +636,26 @@ mod tests {
let store = ObjectStore(benilla_protocol::messages::ObjectFields::from_pairs(&[(
HEALTH, 40,
)]));
member_deactivated(0xdead, &mut group, Some(&store), |_| None, &net);
member_deactivated(
0xdead,
&mut group,
Some(&store),
|_| None,
&NameCache::default(),
&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, |_| None, &net);
member_deactivated(
0x1234,
&mut group,
None,
|_| None,
&NameCache::default(),
&net,
);
assert!(asked(&rx).is_empty());
}
+742
View File
@@ -0,0 +1,742 @@
//! The group pets' unit tokens, `partypet1..4` and `raidpet1..40`: each member's pet as the
//! per-token snapshot the `Unit*` getters read, and the `UNIT_PET` edge that tells a party frame
//! its member's pet came, went or changed. [`super::feed`] drives it from the party feed's gate.
//!
//! A pet the object manager holds is an ordinary live unit. One it does not hold is answered from
//! the owner's roster record, the way the reference's getters fall through: the live descriptor,
//! then the member record, then the pet record (`0x496420`). `UnitExists` answers 1 for it through
//! the roster fallback (`0x491900`), which needs no object, and `UnitLevel`, `UnitIsDead` and
//! `UnitIsGhost` have no pet leg, so they read as they do for a unit the client knows nothing of.
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use benilla_ui::script::{ScriptValue, UiScript, UnitState};
use crate::names::NameCache;
use crate::net::{FieldEdges, GuidIndex, NetCommands, ObjectStore};
use crate::ui_script::gate;
use super::GroupState;
/// Which roster a pet token counts its owner in: the two resolve the pet's guid apart
/// (`0x4e81d0` behind the owner's online bit, `0x491960` with none).
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Roster {
Party,
Raid,
}
/// The name cache with the handle that asks it, one parameter of [`super::feed::feed_party`],
/// which sits at Bevy's 16-parameter limit: a held pet's name is asked of the server once, as any
/// held unit's is. It derefs to the cache.
#[derive(SystemParam)]
pub(super) struct Names<'w> {
cache: Res<'w, NameCache>,
commands: Res<'w, NetCommands>,
}
impl std::ops::Deref for Names<'_> {
type Target = NameCache;
fn deref(&self) -> &NameCache {
&self.cache
}
}
/// What the pets' snapshots read: the object manager and the name cache.
pub(super) struct Lookup<'a, 'w, 's, 'q> {
index: &'a GuidIndex,
stores: &'a Query<'w, 's, &'q ObjectStore>,
names: &'a NameCache,
commands: &'a NetCommands,
}
impl<'a, 'w, 's, 'q> Lookup<'a, 'w, 's, 'q> {
pub(super) fn new(
index: &'a GuidIndex,
stores: &'a Query<'w, 's, &'q ObjectStore>,
names: &'a Names,
) -> Self {
Self {
index,
stores,
names: &names.cache,
commands: &names.commands,
}
}
/// The held object's descriptor (`0x468460`).
pub(super) fn store(&self, guid: u64) -> Option<&ObjectStore> {
self.stores.get(*self.index.0.get(&guid)?).ok()
}
}
/// The guid `partypetN` or `raidpetN` names for `owner`, off its descriptor while held, else its
/// record (see [`GroupState::party_pet_guid`], [`GroupState::raid_pet_guid`]).
fn pet_guid(
group: &GroupState,
owner: u64,
held: Option<&ObjectStore>,
roster: Roster,
) -> Option<u64> {
let live = held.map(|s| &s.0);
match roster {
Roster::Party => group.party_pet_guid(owner, live),
Roster::Raid => group.raid_pet_guid(owner, live),
}
}
/// Whether a held pet of any group member has moved, for the party feed's gate. Every member is
/// asked as a raid row, whose record leg has no online test: a superset of the party slots'.
pub(super) fn store_changed(
look: &Lookup<'_, '_, '_, '_>,
group: &GroupState,
own: Option<(u64, &ObjectStore)>,
changed: &Query<(), Changed<ObjectStore>>,
) -> bool {
let moved = |owner: u64, held: Option<&ObjectStore>| {
pet_guid(group, owner, held, Roster::Raid)
.and_then(|pet| look.index.0.get(&pet))
.is_some_and(|&e| changed.get(e).is_ok())
};
group.members.iter().any(|m| moved(m.guid, look.store(m.guid)))
// Our own pet is a raid row's only, a party's slots being the others'.
|| (group.group_type == super::GROUPTYPE_RAID
&& own.is_some_and(|(guid, store)| moved(guid, Some(store))))
}
/// One member's pet: the guid its token names and the snapshot the getters read, or `None` for a
/// member with no pet the roster or the descriptor names.
pub(super) fn member_pet(
look: &Lookup<'_, '_, '_, '_>,
group: &GroupState,
owner: u64,
held: Option<&ObjectStore>,
roster: Roster,
) -> Option<(u64, UnitState)> {
let guid = pet_guid(group, owner, held, roster)?;
let mut state = match look.store(guid) {
// A held pet is a live unit like any: every getter reads its descriptor (`0x468460`), and
// `UnitIsConnected` answers 1 for any object (`0x517daf`). No reaction: the pet frame
// reads none, as the `"pet"` token's.
Some(store) => {
let name = look
.names
.resolve_unit(guid, Some(store), look.commands)
.map(str::to_string);
UnitState {
is_connected: true,
..crate::ui_unit::snapshot(store, guid, name, 0, None)
}
}
None => record_state(
group,
guid,
owner,
group.roster_names_pet(owner, held.is_some()),
),
};
// Judged by guid alone, with no object needed.
state.raid_target = group.raid_target_index(guid);
Some((guid, state))
}
/// A pet with no object, off the pet record that names it (`0x496420`); with no record, a bare
/// snapshot that carries only the guid.
///
/// The getters' record leg (`UnitHealth 0x5174d0`, `UnitHealthMax 0x5175b0`, `UnitMana 0x517670`,
/// `UnitManaMax 0x5177e0`, `UnitPowerType 0x517940`) reads the pet block's `+0xdc`, `+0xde`,
/// `+0xe0`, `+0xe2` and `+0xd8`, the two power figures divided as a live unit's. `UnitName`
/// (`0x5171da`) reads the block's name buffer whole, so an unnamed pet is the empty string, not
/// `UNKNOWNOBJECT`. `UnitIsConnected` is existence-only there (`0x517dfb`). `UnitLevel`,
/// `UnitIsDead`, `UnitIsGhost` and `UnitIsDeadOrGhost` never look at a pet record: 0, nil, nil,
/// nil, which the snapshot's defaults are.
///
/// The roster names the pet's owner with no object, on the terms it answers `UnitExists`: the
/// party half of `UnitPlayerOrPetInParty` compares guids alone (`0x4e8fa0`).
fn record_state(group: &GroupState, guid: u64, owner: u64, exists: bool) -> UnitState {
let record = group.pet_record(guid);
UnitState {
exists,
guid,
owner: if exists { owner } else { 0 },
name: record.map(|r| r.pet_name.clone().unwrap_or_default()),
health: record.map_or(0, |r| u32::from(r.pet_cur_hp.unwrap_or(0))),
max_health: record.map_or(0, |r| u32::from(r.pet_max_hp.unwrap_or(0))),
power_type: record.map_or(0, |r| r.shown_pet_power_type()),
power: record.map_or(0, |r| r.shown_pet_power()),
max_power: record.map_or(0, |r| r.shown_pet_max_power()),
is_connected: record.is_some(),
..Default::default()
}
}
/// What one pet token last pushed: the owner and the pet guid it named, `UNIT_PET`'s trigger, and
/// the snapshot, [`fire_transitions`](crate::ui_unit::fire_transitions)' per-field diff.
#[derive(Default)]
pub(super) struct FedPet {
owner: u64,
pet: Option<u64>,
state: Option<UnitState>,
}
/// Every pet token's memo: `partypet1..4` and `raidpet1..40`.
#[derive(Default)]
pub(super) struct FedPets {
pub(super) party: [FedPet; 4],
pub(super) raid: Vec<FedPet>,
}
/// Push one pet token's snapshot and fire what moved, in the reference's order (`0x5e5720`):
/// `UNIT_PET` for the owner's token, then the pet's own `UNIT_*` events for its own.
///
/// `UNIT_PET` fires when the same owner's pet guid changes: a pet arriving, going or being
/// replaced, off the record or the descriptor alike. An owner that changes is the roster's edge
/// (`PARTY_MEMBERS_CHANGED`), which the party frame answers by rereading, and the first look is
/// no edge. `owner_token` is `None` where the owner's own feed fires it.
pub(super) fn feed_token(
script: &mut UiScript,
gate: &gate::Gate,
edges: &FieldEdges,
fed: &mut FedPet,
token: &str,
owner_token: Option<&str>,
owner: u64,
now: Option<(u64, UnitState)>,
) {
let (pet, state) = match now {
Some((guid, state)) => (Some(guid), Some(state)),
None => (None, None),
};
let moved = owner != 0 && fed.owner == owner && fed.pet != pet;
let changed = fed.state != state;
if changed {
gate.audit("feed_party", "a group pet-token snapshot");
script.set_unit(token, state.clone());
}
if let Some(owner_token) = owner_token.filter(|_| moved) {
gate.audit("feed_party", "a group pet edge");
script.fire_event("UNIT_PET", vec![ScriptValue::Str(owner_token.to_string())]);
}
if let Some(cur) = state.as_ref().filter(|_| changed) {
crate::ui_unit::fire_transitions(script, token, fed.state.as_ref(), cur, edges);
}
*fed = FedPet { owner, pet, state };
}
#[cfg(test)]
mod tests {
use super::*;
use crate::net::{FieldChanged, Guid};
use benilla_protocol::messages::{
member_status, GroupMemberEntry, ObjectFields, PartyMemberStatsInfo,
};
/// Unit descriptor field indices, build 5875: the pair a member's `SUMMON` holds, and a pet's
/// health, maximum health and pet number.
const SUMMON: u16 = 8;
const HEALTH: u16 = 22;
const MAXHEALTH: u16 = 28;
const PETNUMBER: u16 = 139;
const ME: u64 = 0x10;
/// The `i`-th party member, and its pet.
fn member(i: u64) -> u64 {
0x1000 + i
}
fn pet(i: u64) -> u64 {
0xF140_0000_0000_0000 + i
}
fn store(pairs: &[(u16, u32)]) -> ObjectStore {
ObjectStore(ObjectFields::from_pairs(pairs))
}
fn summon(guid: u64) -> [(u16, u32); 2] {
[(SUMMON, guid as u32), (SUMMON + 1, (guid >> 32) as u32)]
}
/// The frames of a group feed running [`super::super::feed::feed_party`] on a VM whose one
/// spy frame logs the unit events pets raise, as `"EVENT:token"`.
fn app_with(script: UiScript) -> App {
let mut app = App::new();
app.add_message::<FieldChanged>()
.init_resource::<GroupState>()
.init_resource::<GuidIndex>()
.init_resource::<NameCache>()
.init_resource::<crate::ui_chat::ChatLog>();
let (tx, rx) = crossbeam_channel::unbounded();
// Kept alive for the run: a dropped receiver would fail the name queries.
std::mem::forget(rx);
app.insert_resource(NetCommands(tx));
benilla_world::world_point::init_world_point_resources(app.world_mut());
script
.run(
r#"
SEEN = {}
local f = CreateFrame("Frame")
for _, e in ipairs({ "UNIT_PET", "UNIT_HEALTH", "UNIT_MAXHEALTH", "UNIT_MANA",
"UNIT_NAME_UPDATE" }) do
f:RegisterEvent(e)
end
f:SetScript("OnEvent", function()
table.insert(SEEN, event .. ":" .. tostring(arg1))
end)
"#,
)
.unwrap();
app.insert_non_send_resource(script);
app.add_systems(Update, super::super::feed::feed_party);
app
}
fn app() -> App {
app_with(UiScript::new().unwrap())
}
/// A party of `n`, each `online` on the wire and in its record, the record naming pet `i`.
fn party(app: &mut App, n: u64, online: bool) {
let status = if online {
member_status::ONLINE
} else {
member_status::OFFLINE
};
let list = (1..=n)
.map(|i| GroupMemberEntry {
name: format!("M{i}"),
guid: member(i),
status,
flags: 0,
})
.collect();
let mut group = app.world_mut().resource_mut::<GroupState>();
group.apply_list(0, 0, list, ME, None, Some(ME));
for i in 1..=n {
group.apply_stats(
member(i),
true,
PartyMemberStatsInfo {
status: Some(status),
cur_hp: Some(2000),
max_hp: Some(3000),
pet_guid: Some(pet(i)),
pet_name: Some("Whelp".into()),
pet_cur_hp: Some(400),
pet_max_hp: Some(500),
pet_power_type: Some(0),
pet_cur_power: Some(30),
pet_max_power: Some(60),
..Default::default()
},
);
}
}
/// Hold `guid` in the object manager.
fn stream(app: &mut App, guid: u64, pairs: &[(u16, u32)]) -> Entity {
let e = app.world_mut().spawn((Guid(guid), store(pairs))).id();
app.world_mut()
.resource_mut::<GuidIndex>()
.0
.insert(guid, e);
e
}
fn set_fields(app: &mut App, e: Entity, pairs: &[(u16, u32)]) {
app.world_mut()
.entity_mut(e)
.get_mut::<ObjectStore>()
.unwrap()
.0
.merge(ObjectFields::from_pairs(pairs));
}
/// Run a frame and take the pet events it fired; `UNIT_PET`, or a unit event naming a pet.
fn frame(app: &mut App) -> Vec<String> {
app.update();
let mut s = app.world_mut().non_send_resource_mut::<UiScript>();
s.resolve();
let seen: Vec<String> = s.eval("return SEEN").unwrap();
s.run("SEEN = {}").unwrap();
seen.into_iter()
.filter(|e| e.starts_with("UNIT_PET:") || e.contains("pet"))
.collect()
}
/// A number the VM answers, for the getters.
fn num(app: &mut App, code: &str) -> f64 {
app.world_mut()
.non_send_resource::<UiScript>()
.eval(&format!("return {code}"))
.unwrap()
}
/// A string or nil the VM answers.
fn text(app: &mut App, code: &str) -> Option<String> {
app.world_mut()
.non_send_resource::<UiScript>()
.eval(&format!("return {code}"))
.unwrap()
}
/// A predicate answers the number 1 or nil, never a boolean.
fn flag(app: &mut App, code: &str) -> Option<f64> {
app.world_mut()
.non_send_resource::<UiScript>()
.eval(&format!("return {code}"))
.unwrap()
}
/// A pet the object manager holds is a live unit: `UnitExists` and `UnitIsVisible` answer 1,
/// and the getters read its descriptor and its cached name.
#[test]
fn a_held_party_pet_reads_its_own_descriptor() {
let mut app = app();
party(&mut app, 1, true);
stream(&mut app, member(1), &summon(pet(1)));
stream(
&mut app,
pet(1),
&[(HEALTH, 900), (MAXHEALTH, 1000), (PETNUMBER, 4)],
);
app.world_mut()
.resource_mut::<NameCache>()
.insert_pet(4, "Fang".into());
frame(&mut app);
assert_eq!(flag(&mut app, "UnitExists('partypet1')"), Some(1.0));
assert_eq!(flag(&mut app, "UnitIsVisible('partypet1')"), Some(1.0));
assert_eq!(flag(&mut app, "UnitIsConnected('partypet1')"), Some(1.0));
assert_eq!(
text(&mut app, "UnitName('partypet1')").as_deref(),
Some("Fang")
);
assert_eq!(num(&mut app, "UnitHealth('partypet1')"), 900.0);
assert_eq!(num(&mut app, "UnitHealthMax('partypet1')"), 1000.0);
assert_eq!(
flag(&mut app, "UnitExists('partypet2')"),
None,
"no second member"
);
assert_eq!(
flag(&mut app, "UnitExists('party1')"),
Some(1.0),
"the member is unchanged"
);
}
/// A pet the object manager does not hold answers from its online owner's record, as the
/// getters fall through the party record to the pet record (`0x496420`): `UnitExists` is 1
/// through the roster fallback with no object, `UnitIsVisible` is nil, and `UnitLevel` has no
/// pet leg.
#[test]
fn an_unheld_party_pet_reads_its_online_owners_record() {
let mut app = app();
party(&mut app, 1, true);
// A rage pet: the record's power is raw, and divides at the read as a live unit's does.
app.world_mut().resource_mut::<GroupState>().apply_stats(
member(1),
false,
PartyMemberStatsInfo {
pet_power_type: Some(1),
pet_cur_power: Some(570),
pet_max_power: Some(1000),
..Default::default()
},
);
frame(&mut app);
assert_eq!(flag(&mut app, "UnitExists('partypet1')"), Some(1.0));
assert_eq!(
flag(&mut app, "UnitIsVisible('partypet1')"),
None,
"no object"
);
assert_eq!(
text(&mut app, "UnitName('partypet1')").as_deref(),
Some("Whelp")
);
assert_eq!(num(&mut app, "UnitHealth('partypet1')"), 400.0);
assert_eq!(num(&mut app, "UnitHealthMax('partypet1')"), 500.0);
assert_eq!(num(&mut app, "UnitPowerType('partypet1')"), 1.0);
assert_eq!(num(&mut app, "UnitMana('partypet1')"), 57.0);
assert_eq!(num(&mut app, "UnitManaMax('partypet1')"), 100.0);
assert_eq!(
num(&mut app, "UnitLevel('partypet1')"),
0.0,
"`UnitLevel` has no pet leg"
);
assert_eq!(
flag(&mut app, "UnitIsConnected('partypet1')"),
Some(1.0),
"existence alone (`0x517dfb`)"
);
assert_eq!(
flag(&mut app, "UnitPlayerOrPetInParty('partypet1')"),
Some(1.0),
"the roster names it by guid alone (`0x4e8fa0`)"
);
for dead in ["UnitIsDead", "UnitIsGhost", "UnitIsDeadOrGhost"] {
assert_eq!(
flag(&mut app, &format!("{dead}('partypet1')")),
None,
"{dead}"
);
}
}
/// A pet is found only behind its owner's online bit (`0x4e8227`, `0x4e8884`): an offline
/// member's record names nobody.
#[test]
fn an_offline_owners_party_pet_is_nobody() {
let mut app = app();
party(&mut app, 1, false);
frame(&mut app);
assert_eq!(flag(&mut app, "UnitExists('partypet1')"), None);
assert_eq!(text(&mut app, "UnitName('partypet1')"), None);
assert_eq!(num(&mut app, "UnitHealth('partypet1')"), 0.0);
assert_eq!(flag(&mut app, "UnitIsConnected('partypet1')"), None);
}
/// `PARTY_MEMBERS_CHANGED` and first sight announce nothing about pets: `UNIT_PET` fires when
/// the same member's pet guid moves, off the record.
#[test]
fn unit_pet_fires_with_the_owners_token_when_the_records_pet_changes() {
let mut app = app();
party(&mut app, 1, true);
let first = frame(&mut app);
assert!(
!first.iter().any(|e| e.starts_with("UNIT_PET:")),
"the first look is no edge: {first:?}"
);
assert!(frame(&mut app).is_empty(), "a steady frame is silent");
// A new pet.
app.world_mut().resource_mut::<GroupState>().apply_stats(
member(1),
false,
PartyMemberStatsInfo {
pet_guid: Some(pet(9)),
..Default::default()
},
);
let changed = frame(&mut app);
assert_eq!(
changed.iter().filter(|e| *e == "UNIT_PET:party1").count(),
1,
"{changed:?}"
);
// Dismissed.
app.world_mut()
.resource_mut::<GroupState>()
.stats
.get_mut(&member(1))
.unwrap()
.pet_guid = None;
assert!(frame(&mut app).contains(&"UNIT_PET:party1".to_string()));
assert_eq!(
flag(&mut app, "UnitExists('partypet1')"),
None,
"and the token clears"
);
// Back.
app.world_mut()
.resource_mut::<GroupState>()
.stats
.get_mut(&member(1))
.unwrap()
.pet_guid = Some(pet(1));
assert!(frame(&mut app).contains(&"UNIT_PET:party1".to_string()));
assert_eq!(flag(&mut app, "UnitExists('partypet1')"), Some(1.0));
}
/// The same edge off a held member's descriptor (`SUMMON`), where no stats packet moves.
#[test]
fn unit_pet_fires_when_a_held_members_summon_changes() {
let mut app = app();
party(&mut app, 1, true);
let owner = stream(&mut app, member(1), &summon(pet(1)));
frame(&mut app);
set_fields(&mut app, owner, &summon(pet(2)));
assert!(frame(&mut app).contains(&"UNIT_PET:party1".to_string()));
set_fields(&mut app, owner, &summon(0));
assert!(frame(&mut app).contains(&"UNIT_PET:party1".to_string()));
assert_eq!(flag(&mut app, "UnitExists('partypet1')"), None);
}
/// A held pet's health moving fires the unit event with the pet's own token, which its frame's
/// bar listens to. The pet's store is the only input that moved, so its gate input carries it.
#[test]
fn unit_health_fires_with_the_pets_token_when_its_health_changes() {
let mut app = app();
party(&mut app, 1, true);
stream(&mut app, member(1), &summon(pet(1)));
let held = stream(&mut app, pet(1), &[(HEALTH, 900), (MAXHEALTH, 1000)]);
frame(&mut app);
assert!(frame(&mut app).is_empty(), "a steady frame is silent");
set_fields(&mut app, held, &[(HEALTH, 700)]);
assert_eq!(frame(&mut app), ["UNIT_HEALTH:partypet1"]);
assert_eq!(num(&mut app, "UnitHealth('partypet1')"), 700.0);
set_fields(&mut app, held, &[(MAXHEALTH, 1200)]);
assert_eq!(frame(&mut app), ["UNIT_MAXHEALTH:partypet1"]);
}
/// The same off the record, where a stats packet moves it.
#[test]
fn unit_health_fires_with_the_pets_token_when_the_records_pet_health_changes() {
let mut app = app();
party(&mut app, 1, true);
frame(&mut app);
app.world_mut().resource_mut::<GroupState>().apply_stats(
member(1),
false,
PartyMemberStatsInfo {
pet_cur_hp: Some(250),
..Default::default()
},
);
assert_eq!(frame(&mut app), ["UNIT_HEALTH:partypet1"]);
assert_eq!(num(&mut app, "UnitHealth('partypet1')"), 250.0);
}
/// A raid's pets are its rows' (`0x491960`): the record leg has no online test at the
/// resolver, but `UnitExists`' roster fallback (`0x4bb040`) has one, so an offline member's
/// pet resolves to a guid the client never holds and does not exist.
#[test]
fn raid_pets_follow_their_rows_and_an_offline_rows_pet_does_not_exist() {
let mut app = app();
party(&mut app, 2, true);
app.world_mut().resource_mut::<GroupState>().apply_list(
super::super::GROUPTYPE_RAID,
0,
(1..=2)
.map(|i| GroupMemberEntry {
name: format!("M{i}"),
guid: member(i),
status: member_status::ONLINE,
flags: 0,
})
.collect(),
ME,
None,
Some(ME),
);
// Row 2 has gone offline.
app.world_mut()
.resource_mut::<GroupState>()
.stats
.get_mut(&member(2))
.unwrap()
.status = Some(member_status::OFFLINE);
let first = frame(&mut app);
assert!(
!first.iter().any(|e| e.starts_with("UNIT_PET:")),
"{first:?}"
);
assert_eq!(flag(&mut app, "UnitExists('raidpet1')"), Some(1.0));
assert_eq!(
text(&mut app, "UnitName('raidpet1')").as_deref(),
Some("Whelp")
);
assert_eq!(num(&mut app, "UnitHealth('raidpet1')"), 400.0);
assert_eq!(
flag(&mut app, "UnitExists('raidpet2')"),
None,
"an offline row's pet"
);
assert_eq!(num(&mut app, "UnitHealth('raidpet2')"), 0.0);
assert_eq!(
flag(&mut app, "UnitIsUnit('raidpet1', 'partypet1')"),
Some(1.0),
"a row and its party slot name one pet"
);
// A raid pet's arrival is announced to the row's token as well as the slot's.
app.world_mut()
.resource_mut::<GroupState>()
.stats
.get_mut(&member(1))
.unwrap()
.pet_guid = Some(pet(9));
let changed = frame(&mut app);
assert!(
changed.contains(&"UNIT_PET:party1".to_string()),
"{changed:?}"
);
assert!(
changed.contains(&"UNIT_PET:raid1".to_string()),
"{changed:?}"
);
}
/// The stock party frame, loaded whole off the player's own chain, shows a member's pet frame
/// once `UnitExists("partypet1")` answers (`PartyMemberFrame.lua:73`), and hides it on the
/// `UNIT_PET` that names the member (`PartyMemberFrame.lua:189`).
#[test]
fn the_stock_party_frame_shows_and_hides_its_pet_frame() {
benilla_formats::wow_data_or_skip!();
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
let failures = crate::ui_script::load_default_ui(&script);
assert!(failures.is_empty(), "load failures: {failures:?}");
let mut app = app_with(script);
assert_eq!(
text(&mut app, "SHOW_PARTY_PETS").as_deref(),
Some("1"),
"the stock default the pet frame gates on"
);
assert_eq!(flag(&mut app, "PartyMemberFrame1PetFrame:IsShown()"), None);
party(&mut app, 1, true);
frame(&mut app);
assert_eq!(
flag(&mut app, "PartyMemberFrame1PetFrame:IsShown()"),
Some(1.0),
"an online member with a pet the client does not hold"
);
assert_eq!(
flag(&mut app, "PartyMemberFrame2PetFrame:IsShown()"),
None,
"and no other slot's"
);
// The frame's portrait binds the token the portrait booths bake for it.
let mut script = app.world_mut().non_send_resource_mut::<UiScript>();
script.resolve();
let bound: Vec<String> = script
.extract()
.into_iter()
.filter_map(|q| match q.content {
benilla_ui::script::QuadContent::Texture {
portrait_unit: Some(unit),
..
} => Some(unit),
_ => None,
})
.filter(|unit| unit.starts_with("partypet"))
.collect();
assert_eq!(
bound,
["partypet1"],
"the pet frame draws the bake of its own token"
);
// The pet is dismissed: `UNIT_PET` names the member, and the frame hides.
app.world_mut()
.resource_mut::<GroupState>()
.stats
.get_mut(&member(1))
.unwrap()
.pet_guid = None;
frame(&mut app);
assert_eq!(flag(&mut app, "PartyMemberFrame1PetFrame:IsShown()"), None);
let errors = app.world().non_send_resource::<UiScript>().errors();
assert!(errors.is_empty(), "script errors: {errors:?}");
}
}
+11 -1
View File
@@ -14,12 +14,22 @@ impl GroupState {
/// The record whose pet block names `guid` (`0x496420`); a pet is found only while its owner's
/// record reads online, party (`0x4e8860`) or raid (`0x4bb130`).
fn pet_record(&self, guid: u64) -> Option<&PartyMemberStatsInfo> {
pub(crate) fn pet_record(&self, guid: u64) -> Option<&PartyMemberStatsInfo> {
self.stats
.values()
.find(|r| guid != 0 && r.pet_guid == Some(guid) && online(r))
}
/// Whether the roster fallback of `UnitExists` (`0x491900`) names `member`'s pet, which the
/// object manager may not hold and which `partypetN` or `raidpetN` resolved to a guid: a held
/// member answers off its descriptor's `CHARM`/`SUMMON` (`0x4e814d`, `0x4bb022`), an unheld one
/// off its record's pet guid while the record reads online (`0x4e816b`, `0x4bb040`). That test
/// is the party pet's own online gate already, but not the raid pet's guid, which the resolver
/// reads off the record with none (`0x4919ae`).
pub(crate) fn roster_names_pet(&self, member: u64, member_held: bool) -> bool {
member_held || self.member_record(member).is_some_and(online)
}
/// `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`).
+25 -1
View File
@@ -219,15 +219,18 @@ impl PartyMemberStatsInfo {
/// 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`).
/// `pet_name` is that pet's cached name (`0x609210`, copied into `+0x88` at `0x5f0a70`); a
/// pet whose name the cache does not hold yet keeps the name the record had.
///
/// Deviation: zone and position are kept, not set to the viewer's zone and the object's
/// position, and the pet's name is not copied, because nothing in benilla reads them.
/// position, 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>,
pet_name: Option<&str>,
) {
let mut status = member_status::ONLINE;
if fields.player_is_ghost() {
@@ -263,6 +266,9 @@ impl PartyMemberStatsInfo {
Some((guid, pet)) => {
let power_type = pet.unit_power_type();
self.pet_guid = Some(guid);
if let Some(name) = pet_name {
self.pet_name = Some(name.to_string());
}
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);
@@ -306,6 +312,24 @@ impl PartyMemberStatsInfo {
u32::from(self.max_power.unwrap_or(0))
/ power_display_scale(u32::from(self.shown_power_type()))
}
/// `UnitPowerType` from the pet block (`+0xd8`, `0x5179d2`'s pet leg), 0 when it has none.
pub fn shown_pet_power_type(&self) -> u8 {
self.pet_power_type.unwrap_or(0)
}
/// `UnitMana` from the pet block (`+0xe0`): the stored power divided by
/// [`power_display_scale`] of the pet's own power type, as for a live unit.
pub fn shown_pet_power(&self) -> u32 {
u32::from(self.pet_cur_power.unwrap_or(0))
/ power_display_scale(u32::from(self.shown_pet_power_type()))
}
/// `UnitManaMax` from the pet block (`+0xe2`), the same divide.
pub fn shown_pet_max_power(&self) -> u32 {
u32::from(self.pet_max_power.unwrap_or(0))
/ power_display_scale(u32::from(self.shown_pet_power_type()))
}
}
/// `(slot, spell id)` pairs of one aura block, as [`PartyMemberStatsInfo::auras`] holds them.