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:
co-authored by
Claude Opus 5.5
parent
8ef62da6ac
commit
e071b8ded3
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user