From e071b8ded347a935e4a5e9d85eca8231e837f0bd Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:02:42 +0700 Subject: [PATCH] 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) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/benilla-app/src/net/objects.rs | 10 +- crates/benilla-app/src/net/session.rs | 2 +- crates/benilla-app/src/portrait/mod.rs | 177 ++++- crates/benilla-app/src/ui_party/feed.rs | 70 +- crates/benilla-app/src/ui_party/mod.rs | 1 + crates/benilla-app/src/ui_party/net.rs | 59 +- crates/benilla-app/src/ui_party/pets.rs | 742 ++++++++++++++++++ crates/benilla-app/src/ui_party/records.rs | 12 +- crates/benilla-protocol/src/messages/group.rs | 26 +- 9 files changed, 1079 insertions(+), 20 deletions(-) create mode 100644 crates/benilla-app/src/ui_party/pets.rs diff --git a/crates/benilla-app/src/net/objects.rs b/crates/benilla-app/src/net/objects.rs index cccee071..7d2d7ebf 100644 --- a/crates/benilla-app/src/net/objects.rs +++ b/crates/benilla-app/src/net/objects.rs @@ -303,7 +303,14 @@ fn on_object_destroyed(In(ev): In, mut sc: Scene) { if let SessionEvent::ObjectDestroyed(guid) = ev { crate::death::net::forget_corpse(guid, &mut sc.death_net); let 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, mut sc: Scene) { &mut sc.group, held(*guid), held, + &sc.names, &sc.net, ); } diff --git a/crates/benilla-app/src/net/session.rs b/crates/benilla-app/src/net/session.rs index b0a3233c..dd841d4a 100644 --- a/crates/benilla-app/src/net/session.rs +++ b/crates/benilla-app/src/net/session.rs @@ -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, diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs index 27545750..efdd9933 100644 --- a/crates/benilla-app/src/portrait/mod.rs +++ b/crates/benilla-app/src/portrait/mod.rs @@ -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, sex: Option) -> 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, + store_of: impl Fn(u64) -> Option<&'a crate::net::ObjectStore>, +) -> (Option, Option) { + if !drawn { + return (None, None); + } + let Some(member) = token + .strip_prefix("partypet") + .and_then(|n| n.parse::().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(); diff --git a/crates/benilla-app/src/ui_party/feed.rs b/crates/benilla-app/src/ui_party/feed.rs index 899529bc..c7b525ce 100644 --- a/crates/benilla-app/src/ui_party/feed.rs +++ b/crates/benilla-app/src/ui_party/feed.rs @@ -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>, + /// `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, @@ -101,7 +104,7 @@ pub(super) fn feed_party( mut edges: MessageReader, self_q: Query<(Entity, &Guid, &ObjectStore), With>, factions: Option>, - names: Res, + names: pets::Names, areas: Option>, // 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 = 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, diff --git a/crates/benilla-app/src/ui_party/mod.rs b/crates/benilla-app/src/ui_party/mod.rs index 6b6b69cc..ecf273ce 100644 --- a/crates/benilla-app/src/ui_party/mod.rs +++ b/crates/benilla-app/src/ui_party/mod.rs @@ -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, diff --git a/crates/benilla-app/src/ui_party/net.rs b/crates/benilla-app/src/ui_party/net.rs index 00135661..521e313f 100644 --- a/crates/benilla-app/src/ui_party/net.rs +++ b/crates/benilla-app/src/ui_party/net.rs @@ -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 = 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()); } diff --git a/crates/benilla-app/src/ui_party/pets.rs b/crates/benilla-app/src/ui_party/pets.rs new file mode 100644 index 00000000..a1df8583 --- /dev/null +++ b/crates/benilla-app/src/ui_party/pets.rs @@ -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 { + 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>, +) -> 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, + state: Option, +} + +/// 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, +} + +/// 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::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::(); + 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::(); + 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::() + .0 + .insert(guid, e); + e + } + + fn set_fields(app: &mut App, e: Entity, pairs: &[(u16, u32)]) { + app.world_mut() + .entity_mut(e) + .get_mut::() + .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 { + app.update(); + let mut s = app.world_mut().non_send_resource_mut::(); + s.resolve(); + let seen: Vec = 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::() + .eval(&format!("return {code}")) + .unwrap() + } + + /// A string or nil the VM answers. + fn text(app: &mut App, code: &str) -> Option { + app.world_mut() + .non_send_resource::() + .eval(&format!("return {code}")) + .unwrap() + } + + /// A predicate answers the number 1 or nil, never a boolean. + fn flag(app: &mut App, code: &str) -> Option { + app.world_mut() + .non_send_resource::() + .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::() + .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::().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::().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::() + .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::() + .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::().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::().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::() + .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::() + .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::(); + script.resolve(); + let bound: Vec = 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::() + .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::().errors(); + assert!(errors.is_empty(), "script errors: {errors:?}"); + } +} diff --git a/crates/benilla-app/src/ui_party/records.rs b/crates/benilla-app/src/ui_party/records.rs index 25302c0e..eb1c3ca7 100644 --- a/crates/benilla-app/src/ui_party/records.rs +++ b/crates/benilla-app/src/ui_party/records.rs @@ -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`). diff --git a/crates/benilla-protocol/src/messages/group.rs b/crates/benilla-protocol/src/messages/group.rs index de465d5d..2300ba8d 100644 --- a/crates/benilla-protocol/src/messages/group.rs +++ b/crates/benilla-protocol/src/messages/group.rs @@ -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.