diff --git a/crates/benilla-app/src/ui_aura/units.rs b/crates/benilla-app/src/ui_aura/units.rs index 560bcafa..f1d7d23d 100644 --- a/crates/benilla-app/src/ui_aura/units.rs +++ b/crates/benilla-app/src/ui_aura/units.rs @@ -506,6 +506,60 @@ mod tests { assert!(listed(&mut app, "party1", true).is_empty()); } + /// The list writes the record's online bit too (`0x4e8361`-`0x4e837b`), so a status-only + /// `SMSG_GROUP_LIST` for a member out of view takes their last auras: `UnitBuff` and + /// `UnitDebuff` answer nil behind a clear bit (`0x519741`, `0x519b31`), and an online list brings them back. + #[test] + fn a_status_only_list_takes_an_out_of_view_members_auras_and_an_online_one_returns_them() { + let mut app = party_app(); + stats( + &mut app, + true, + PartyMemberStatsInfo { + status: Some(member_status::ONLINE), + auras: Some(vec![(0, 1126)]), + auras_negative: Some(vec![(33, 589)]), + ..Default::default() + }, + ); + app.update(); + assert_eq!(listed(&mut app, "party1", false), [1126]); + assert_eq!(listed(&mut app, "party1", true), [589]); + + let relist = |app: &mut App, status: u8| { + let member = GroupMemberEntry { + name: "Brisca".into(), + guid: MEMBER, + status, + flags: 0, + }; + app.world_mut().resource_mut::().apply_list( + 0, + 0, + vec![member], + ME, + None, + Some(ME), + ); + app.update(); + }; + relist(&mut app, member_status::OFFLINE); + let nil = app + .world_mut() + .non_send_resource::() + .eval::( + r#"return UnitBuff("party1", 1) == nil and UnitDebuff("party1", 1) == nil"#, + ) + .unwrap(); + assert!(nil, "the record kept the auras, the clear bit hides them"); + assert!(listed(&mut app, "party1", false).is_empty()); + assert!(listed(&mut app, "party1", true).is_empty()); + + relist(&mut app, member_status::ONLINE); + assert_eq!(listed(&mut app, "party1", false), [1126]); + assert_eq!(listed(&mut app, "party1", true), [589]); + } + /// The record is slot-indexed: deltas patch named slots, the halves split at slot 32, and each /// walk is ascending slot whatever order the slots filled in. #[test] @@ -638,28 +692,34 @@ mod tests { /// in subgroup 3, in wire order, none streamed and none with a record. fn raid_app() -> App { let mut app = party_app(); - let member = |name: &str, guid, subgroup| GroupMemberEntry { + relist_raid(&mut app, member_status::ONLINE); + app.update(); + let _ = seen(&mut app); + app + } + + /// The raid's list as `raid_app` sends it, with `MATE`'s status byte as given. + fn relist_raid(app: &mut App, mate: u8) { + let member = |name: &str, guid, status, subgroup| GroupMemberEntry { name: name.into(), guid, - status: member_status::ONLINE, + status, flags: subgroup, }; + let online = member_status::ONLINE; app.world_mut().resource_mut::().apply_list( crate::ui_party::GROUPTYPE_RAID, 0, vec![ - member("Mate", MATE, 0), - member("Two", 0x2002, 1), - member("Three", 0x2003, 1), - member("Far", FAR, 2), + member("Mate", MATE, mate, 0), + member("Two", 0x2002, online, 1), + member("Three", 0x2003, online, 1), + member("Far", FAR, online, 2), ], ME, None, Some(ME), ); - app.update(); - let _ = seen(&mut app); - app } fn online(auras_negative: Vec<(u8, u16)>) -> PartyMemberStatsInfo { @@ -761,6 +821,28 @@ mod tests { assert!(seen(&mut app).is_empty()); } + /// The raid roster's twin of the list write (`0x4ba947`-`0x4ba960`): a status-only raid list + /// hides an out-of-view row's auras under `raidN` and its party slot alike, and an online list + /// returns them. + #[test] + fn a_status_only_raid_list_takes_an_out_of_view_rows_auras_and_an_online_one_returns_them() { + let mut app = raid_app(); + stats_of(&mut app, MATE, true, online(vec![(32, 589)])); + app.update(); + assert_eq!(listed(&mut app, "raid2", true), [589]); + assert_eq!(listed(&mut app, "party1", true), [589]); + + relist_raid(&mut app, member_status::OFFLINE); + app.update(); + assert!(listed(&mut app, "raid2", true).is_empty()); + assert!(listed(&mut app, "party1", true).is_empty()); + + relist_raid(&mut app, member_status::ONLINE); + app.update(); + assert_eq!(listed(&mut app, "raid2", true), [589]); + assert_eq!(listed(&mut app, "party1", true), [589]); + } + /// A raid token past the roster, a raid token outside a raid, and an offline member's pet /// all answer nil. #[test] diff --git a/crates/benilla-app/src/ui_party/feed.rs b/crates/benilla-app/src/ui_party/feed.rs index 9fa1e110..5a89297c 100644 --- a/crates/benilla-app/src/ui_party/feed.rs +++ b/crates/benilla-app/src/ui_party/feed.rs @@ -369,9 +369,7 @@ pub(super) fn feed_party( // 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(); + let now = pets::member_pet(&look, &group, owner, look.store(owner), Roster::Party); pets::feed_token( &mut script, &gate, @@ -445,9 +443,7 @@ pub(super) fn feed_party( } else { look.store(owner) }; - let now = (owner != 0) - .then(|| pets::member_pet(&look, &group, owner, held, Roster::Raid)) - .flatten(); + let now = pets::member_pet(&look, &group, owner, held, Roster::Raid); pets::feed_token( &mut script, &gate, @@ -789,10 +785,11 @@ fn member_unit_state( // Divided as on the live leg (`UnitMana`'s record path, `0x517744`-`0x51775e`). power: stats.map_or(0, PartyMemberStatsInfo::shown_power), max_power: stats.map_or(0, PartyMemberStatsInfo::shown_max_power), - // Connected is the roster byte's online bit, the one `0x4e82d0` writes into the - // record's bit 0 (`0x4e836e`-`0x4e837b`) for `UnitIsConnected` to read (`0x517dd3`); - // a held member never gets here, its 1 is `snapshot`'s (`0x517daf`). - is_connected: m.status & member_status::ONLINE != 0, + // Connected is the record's own online bit (`+0x08 & 1`, `0x517dd3`), which each list + // rewrites from its row's status (`0x4e836e`-`0x4e837b`) and a stats packet's status + // moves alone, so the roster byte is not read. A held member never gets here, its 1 is + // `snapshot`'s (`0x517daf`); a member with no record has no bit to read. + is_connected: stats.is_some_and(PartyMemberStatsInfo::is_online), // Dead and ghost from the record, as the reference's `UnitIsDead` (`0x517b5d`, // `+0x08 & 4`) and `UnitIsGhost` (`0x517c32`, `& 8`) read it: fresher than the roster // byte, which only `SMSG_GROUP_LIST` moves. 1.12 has no AFK or DND predicate to feed. @@ -1339,11 +1336,17 @@ pub(crate) fn synthetic_roster( (0xF003, 0, 1105, 31, 0, seat(-300.0, 0.0)), (0xF004, 0, 0, 0, 0, None), ] { + // A server's full stats carry the status, which the record's online bit is read from. + let status = group + .members + .iter() + .find(|m| m.guid == guid) + .map(|m| m.status); group.apply_stats( guid, true, PartyMemberStatsInfo { - status: None, + status, cur_hp: Some(hp), max_hp: Some(max), level: Some(level), @@ -1439,11 +1442,16 @@ pub(crate) fn synthetic_raid( for (i, _) in ROSTER.iter().enumerate() { let guid = 0xF100 + i as u64; let dead = i == 3; + let status = group + .members + .iter() + .find(|m| m.guid == guid) + .map(|m| m.status); group.apply_stats( guid, true, PartyMemberStatsInfo { - status: None, + status, cur_hp: Some(if dead { 0 } else { @@ -1501,6 +1509,7 @@ mod tests { flags: 0, }; let record = PartyMemberStatsInfo { + status: Some(member_status::ONLINE), cur_hp: Some(2400), max_hp: Some(3000), level: Some(41), @@ -1608,8 +1617,8 @@ mod tests { assert!(s.dead); } - /// Seat member 1 alone in the party or raid roster with `status`, on the wire and in its - /// record, as [`party`] does. + /// Send member 1 alone as the party or raid roster with `status`, as a server's list does: the + /// member's record, which [`party`] seated, takes its online bit from the list. fn seat(app: &mut App, group_type: u8, status: u8) { let mut group = app.world_mut().resource_mut::(); group.apply_list( @@ -1625,7 +1634,6 @@ mod tests { None, Some(ME), ); - group.stats.get_mut(&member(1)).unwrap().status = Some(status); } /// `UnitIsConnected` (`0x517d50`) answers 1 for any unit the object manager holds @@ -1710,6 +1718,57 @@ mod tests { assert_eq!(flag(&mut app, "UnitIsConnected('raid1')"), Some(1.0)); } + /// The answer is the record's bit, not the roster byte the list also carries: a stats packet's + /// status moves the record alone (`0x5e54b7`-`0x5e54d8`), and `UnitIsConnected` reads the record + /// (`0x517dca`-`0x517dd3`), so the two can disagree between lists. + #[test] + fn an_unheld_members_connected_follows_the_record_when_only_a_stats_packet_moves_it() { + let mut app = app(); + party(&mut app, 1, true); + frame(&mut app); + assert_eq!(flag(&mut app, "UnitIsConnected('party1')"), Some(1.0)); + + let status = |app: &mut App, status: u8| { + app.world_mut().resource_mut::().apply_stats( + member(1), + false, + PartyMemberStatsInfo { + status: Some(status), + ..Default::default() + }, + ); + frame(app); + }; + status(&mut app, member_status::OFFLINE); + assert_eq!( + flag(&mut app, "UnitIsConnected('party1')"), + None, + "the record went offline, the roster byte still says online" + ); + status(&mut app, member_status::ONLINE); + assert_eq!(flag(&mut app, "UnitIsConnected('party1')"), Some(1.0)); + + // The list writes the same bit, so the next one agrees with the byte it carries. + seat(&mut app, 0, member_status::OFFLINE); + frame(&mut app); + assert_eq!(flag(&mut app, "UnitIsConnected('party1')"), None); + status(&mut app, member_status::ONLINE); + assert_eq!( + flag(&mut app, "UnitIsConnected('party1')"), + Some(1.0), + "the record online again, the roster byte still offline" + ); + + seat(&mut app, GROUPTYPE_RAID, member_status::ONLINE); + frame(&mut app); + status(&mut app, member_status::OFFLINE); + assert_eq!( + flag(&mut app, "UnitIsConnected('raid1')"), + None, + "the raid row" + ); + } + /// A frame that counts the `PARTY_MEMBERS_CHANGED`s the VM is told of; [`changed`] takes them. fn watch_members_changed(app: &mut App) { let script = app.world_mut().non_send_resource_mut::(); diff --git a/crates/benilla-app/src/ui_party/mod.rs b/crates/benilla-app/src/ui_party/mod.rs index 66ffc4e8..9fc57699 100644 --- a/crates/benilla-app/src/ui_party/mod.rs +++ b/crates/benilla-app/src/ui_party/mod.rs @@ -71,7 +71,8 @@ pub struct GroupState { pub loot: Option, /// The inviter's name while the invite popup is up. pub pending_invite: Option, - /// Each member's record by guid, seated at 1/1 and patched by `SMSG_PARTY_MEMBER_STATS`. + /// Each member's record by guid, seated at 1/1, patched by `SMSG_PARTY_MEMBER_STATS`, and given + /// its online bit again by each `SMSG_GROUP_LIST` that lists the member. pub stats: HashMap, /// The marked guid per raid-target icon 0-7 (star to skull), 0 when unset. pub raid_targets: [u64; 8], @@ -223,6 +224,16 @@ impl GroupState { self.stats .retain(|guid, _| members.iter().any(|m| m.guid == *guid)); + // Each listed member's record takes its online bit from the row's status byte and keeps the + // rest: a known member's record is passed back to `0x4e82d0` (`0x5e6ddb`-`0x5e6de8`), which + // writes the bit last (`0x4e8361`-`0x4e837b`), and the raid roster's twin does the same to + // its row's record (`0x4ba947`-`0x4ba960`). A member new to the roster has no record yet; + // `net::list` seats its placeholder. + for m in &members { + if let Some(record) = self.stats.get_mut(&m.guid) { + record.set_online(m.listed_online()); + } + } self.in_group = true; self.group_type = group_type; diff --git a/crates/benilla-app/src/ui_party/net.rs b/crates/benilla-app/src/ui_party/net.rs index 838e1398..b39e2dd4 100644 --- a/crates/benilla-app/src/ui_party/net.rs +++ b/crates/benilla-app/src/ui_party/net.rs @@ -3,9 +3,7 @@ //! catalog row gives the text, the surface and the sound. [`member_deactivated`] and //! [`roster_deactivated`] are the object layer's hooks on a roster member's stream-out. -use benilla_protocol::messages::{ - member_status, GroupLootInfo, GroupMemberEntry, PartyMemberStatsInfo, -}; +use benilla_protocol::messages::{GroupLootInfo, GroupMemberEntry, PartyMemberStatsInfo}; use bevy::prelude::*; use benilla_protocol::{SessionEvent, SessionEventKind}; @@ -269,7 +267,7 @@ fn list( // no record as new, the reference's `srcRec == 0`. let seats: Vec<(u64, bool)> = members .iter() - .map(|m| (m.guid, m.status & member_status::ONLINE != 0)) + .map(|m| (m.guid, m.listed_online())) .collect(); let shown = group.apply_list_awaiting( group_type, @@ -289,9 +287,10 @@ fn list( } /// `SMSG_GROUP_LIST`'s record leg (`0x4e82d0`, raid twin `0x4ba5f0`): a known member's record -/// carries over and nothing is sent; a new member gets the 1/1 placeholder and, when we hold no -/// object for them, a stats request (`0x4e83f1`, raid `0x4bab6e`). Every member therefore owns a -/// record, so an unseen one shows full bars, not 0/0, until their stats land. +/// carries over, its online bit rewritten by [`GroupState::apply_list`], and nothing is sent; a new +/// member gets the 1/1 placeholder and, when we hold no object for them, a stats request +/// (`0x4e83f1`, raid `0x4bab6e`). A listed member therefore owns a record, so an unseen one shows +/// full bars, not 0/0, until their stats land. fn seat_new_records( group: &mut GroupState, seats: &[(u64, bool)], @@ -387,6 +386,7 @@ mod tests { use super::*; use crate::net::ClientCommand; use benilla_protocol::guid; + use benilla_protocol::messages::member_status; fn member(g: u64, name: &str) -> GroupMemberEntry { GroupMemberEntry { @@ -811,4 +811,66 @@ mod tests { "and the record it already had survives the resync" ); } + + /// A re-sent list that moves only a member's status rewrites the online bit of the record the + /// member already owns (`0x4e82d0`), asks nothing, and leaves the rest of the record. + #[test] + fn a_resent_list_moves_a_known_members_online_bit_and_keeps_its_record() { + let (tx, rx) = crossbeam_channel::unbounded(); + let net = NetCommands(tx); + let (mut group, mut errors, mut quest) = ( + GroupState::default(), + UiErrorKeys::default(), + QuestGiver::default(), + ); + let names = NameCache::default(); + let far = 0x22u64; + let mut send = |group: &mut GroupState, status: u8| { + let row = GroupMemberEntry { + status, + ..member(far, "Brisca") + }; + list( + group, + &mut errors, + &mut MessageSounds::default(), + &mut quest, + 0, + 0, + vec![row], + far, + None, + &SelfGuid::default(), + &names, + &GuidIndex::default(), + &net, + ); + }; + + send(&mut group, member_status::ONLINE); + group.apply_stats( + far, + false, + PartyMemberStatsInfo { + cur_hp: Some(900), + pet_guid: Some(0xF140_0000_0000_0077), + ..PartyMemberStatsInfo::default() + }, + ); + assert!(group.stats[&far].is_online(), "seated from the row"); + let _ = asked(&rx); + + send(&mut group, member_status::OFFLINE); + let rec = &group.stats[&far]; + assert!(!rec.is_online(), "the row's byte moved the record's bit"); + assert_eq!( + (rec.cur_hp, rec.pet_guid), + (Some(900), Some(0xF140_0000_0000_0077)), + "and nothing else" + ); + assert!(asked(&rx).is_empty(), "no stats request for a known member"); + + send(&mut group, member_status::ONLINE); + assert!(group.stats[&far].is_online()); + } } diff --git a/crates/benilla-app/src/ui_party/pets.rs b/crates/benilla-app/src/ui_party/pets.rs index ecd03d9a..669cab8f 100644 --- a/crates/benilla-app/src/ui_party/pets.rs +++ b/crates/benilla-app/src/ui_party/pets.rs @@ -111,16 +111,35 @@ pub(super) fn store_changed( && 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. +/// One pet token's inputs for a frame: the guid `UNIT_PET` keys on, and the token's own guid and +/// snapshot. The two differ for a party slot whose owner has no object and reads offline: the +/// record's pet guid is still what the owner's stats named, while `partypetN` names nobody +/// (`0x4e8227`). +#[derive(Default)] +pub(super) struct PetNow { + /// The owner's pet guid as the stats handler's diff sees it ([`GroupState::pet_edge_guid`]). + edge: Option, + /// The guid the token names and the snapshot the getters read, or `None` for a member with no + /// pet the roster or the descriptor names ([`GroupState::party_pet_guid`], + /// [`GroupState::raid_pet_guid`]). + token: Option<(u64, UnitState)>, +} + +/// One member's pet for its token and its `UNIT_PET` edge; nothing for an empty slot, `owner` 0. 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)?; +) -> PetNow { + if owner == 0 { + return PetNow::default(); + } + let edge = group.pet_edge_guid(owner, held.map(|s| &s.0)); + let Some(guid) = pet_guid(group, owner, held, roster) else { + return PetNow { edge, token: None }; + }; 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`), as `snapshot` does. No @@ -141,7 +160,10 @@ pub(super) fn member_pet( }; // Judged by guid alone, with no object needed. state.raid_target = group.raid_target_index(guid); - Some((guid, state)) + PetNow { + edge, + token: Some((guid, state)), + } } /// A pet with no object, off the pet record that names it (`0x496420`); with no record, a bare @@ -174,12 +196,13 @@ fn record_state(group: &GroupState, guid: u64, owner: u64, exists: bool) -> Unit } } -/// 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. +/// What one pet token last pushed: the owner and the pet guid the edge keyed on, `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, + edge: Option, state: Option, } @@ -193,8 +216,10 @@ pub(super) struct FedPets { /// 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 +/// `UNIT_PET` fires when the same owner's pet guid changes ([`PetNow::edge`]): a pet arriving, +/// going or being replaced, off the record or the descriptor alike. The token's own guid and +/// snapshot are not that edge, so a member going online or offline with the same pet moves the +/// snapshot and fires no `UNIT_PET`. 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( @@ -205,13 +230,11 @@ pub(super) fn feed_token( token: &str, owner_token: Option<&str>, owner: u64, - now: Option<(u64, UnitState)>, + now: PetNow, ) { - 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 PetNow { edge, token: now } = now; + let state = now.map(|(_, state)| state); + let moved = owner != 0 && fed.owner == owner && fed.edge != edge; let changed = fed.state != state; if changed { gate.audit("feed_party", "a group pet-token snapshot"); @@ -224,7 +247,7 @@ pub(super) fn feed_token( 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 }; + *fed = FedPet { owner, edge, state }; } #[cfg(test)] @@ -336,6 +359,29 @@ pub(in crate::ui_party) mod tests { } } + /// `SMSG_GROUP_LIST` re-sent with only the members' status bytes moved: members `1..` of the + /// party or raid, one per status. + fn relist(app: &mut App, group_type: u8, statuses: &[u8]) { + let list = statuses + .iter() + .enumerate() + .map(|(i, &status)| GroupMemberEntry { + name: format!("M{}", i + 1), + guid: member(i as u64 + 1), + status, + flags: 0, + }) + .collect(); + app.world_mut().resource_mut::().apply_list( + group_type, + 0, + list, + ME, + None, + Some(ME), + ); + } + /// Hold `guid` in the object manager. pub(in crate::ui_party) fn stream(app: &mut App, guid: u64, pairs: &[(u16, u32)]) -> Entity { let e = app.world_mut().spawn((Guid(guid), store(pairs))).id(); @@ -555,6 +601,132 @@ pub(in crate::ui_party) mod tests { assert_eq!(flag(&mut app, "UnitExists('partypet1')"), Some(1.0)); } + /// The reported shape: a member with a pet logs off out of view, and the server sends the list + /// again with their status alone moved. The list rewrites the record's online bit + /// (`0x4e8361`-`0x4e837b`), so the pet is nobody at once (`0x4e8227`) and comes back with the + /// next list; no list reaches the stats diff (`0x5e5510`'s callers are `0x5e54a8` and `0x5f0c1c`), so + /// neither fires `UNIT_PET`. + #[test] + fn a_status_only_list_takes_an_out_of_view_members_pet_and_fires_no_unit_pet() { + let mut app = app(); + party(&mut app, 2, true); + frame(&mut app); + assert_eq!(flag(&mut app, "UnitExists('partypet1')"), Some(1.0)); + + relist( + &mut app, + 0, + &[member_status::OFFLINE, member_status::ONLINE], + ); + let offline = frame(&mut app); + assert!( + !offline.iter().any(|e| e.starts_with("UNIT_PET:")), + "{offline:?}" + ); + 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, "UnitExists('partypet2')"), + Some(1.0), + "the member still listed online keeps theirs" + ); + assert_eq!( + app.world().resource::().stats[&member(1)].pet_guid, + Some(pet(1)), + "the list keeps the rest of the record" + ); + + relist(&mut app, 0, &[member_status::ONLINE, member_status::ONLINE]); + let online = frame(&mut app); + assert!( + !online.iter().any(|e| e.starts_with("UNIT_PET:")), + "{online:?}" + ); + assert_eq!(flag(&mut app, "UnitExists('partypet1')"), Some(1.0)); + assert_eq!( + text(&mut app, "UnitName('partypet1')").as_deref(), + Some("Whelp") + ); + assert_eq!(num(&mut app, "UnitHealth('partypet1')"), 400.0); + } + + /// The edge is the record's own pet guid, whatever the online bit: a stats packet that moves + /// an offline out-of-view owner's pet guid fires `UNIT_PET` for it (`0x5e55fb`-`0x5e5617`, + /// `0x5e57e9`), though `partypetN` still names nobody behind the bit. + #[test] + fn unit_pet_fires_when_an_offline_owners_pet_guid_moves() { + let mut app = app(); + party(&mut app, 1, false); + frame(&mut app); + assert!(frame(&mut app).is_empty(), "a steady frame is silent"); + + 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:?}" + ); + assert_eq!(flag(&mut app, "UnitExists('partypet1')"), None); + + app.world_mut().resource_mut::().apply_stats( + member(1), + false, + PartyMemberStatsInfo { + pet_guid: Some(0), + ..Default::default() + }, + ); + assert!( + frame(&mut app).contains(&"UNIT_PET:party1".to_string()), + "the pet dismissed" + ); + } + + /// A raid's rows take the same list: the record's bit moves, the offline row's pet does not + /// exist (`0x4bb040`) and its party slot's is nobody, and no `UNIT_PET` fires for either token. + #[test] + fn a_status_only_raid_list_takes_an_offline_rows_pet_and_fires_no_unit_pet() { + let mut app = app(); + party(&mut app, 2, true); + let (on, off) = (member_status::ONLINE, member_status::OFFLINE); + relist(&mut app, super::super::GROUPTYPE_RAID, &[on, on]); + frame(&mut app); + assert_eq!(flag(&mut app, "UnitExists('raidpet2')"), Some(1.0)); + + relist(&mut app, super::super::GROUPTYPE_RAID, &[on, off]); + let offline = frame(&mut app); + assert!( + !offline.iter().any(|e| e.starts_with("UNIT_PET:")), + "{offline:?}" + ); + assert_eq!(flag(&mut app, "UnitExists('raidpet2')"), None); + assert_eq!(num(&mut app, "UnitHealth('raidpet2')"), 0.0); + assert_eq!(flag(&mut app, "UnitExists('partypet2')"), None); + assert_eq!( + flag(&mut app, "UnitExists('raidpet1')"), + Some(1.0), + "the row listed online keeps its pet" + ); + + relist(&mut app, super::super::GROUPTYPE_RAID, &[on, on]); + let online = frame(&mut app); + assert!( + !online.iter().any(|e| e.starts_with("UNIT_PET:")), + "{online:?}" + ); + assert_eq!(flag(&mut app, "UnitExists('raidpet2')"), Some(1.0)); + assert_eq!(flag(&mut app, "UnitExists('partypet2')"), 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() { diff --git a/crates/benilla-app/src/ui_party/records.rs b/crates/benilla-app/src/ui_party/records.rs index eb1c3ca7..7a3ac629 100644 --- a/crates/benilla-app/src/ui_party/records.rs +++ b/crates/benilla-app/src/ui_party/records.rs @@ -2,7 +2,7 @@ //! (`0x496400`), the pet record (`0x496420`) and the party and raid pets' guids (`0x4e81d0`, //! `0x491960`). -use benilla_protocol::messages::{member_status, ObjectFields, PartyMemberStatsInfo}; +use benilla_protocol::messages::{ObjectFields, PartyMemberStatsInfo}; use super::GroupState; @@ -17,7 +17,7 @@ impl GroupState { pub(crate) fn pet_record(&self, guid: u64) -> Option<&PartyMemberStatsInfo> { self.stats .values() - .find(|r| guid != 0 && r.pet_guid == Some(guid) && online(r)) + .find(|r| guid != 0 && r.pet_guid == Some(guid) && r.is_online()) } /// Whether the roster fallback of `UnitExists` (`0x491900`) names `member`'s pet, which the @@ -27,7 +27,10 @@ impl GroupState { /// 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) + member_held + || self + .member_record(member) + .is_some_and(PartyMemberStatsInfo::is_online) } /// `partypetN`'s guid for the member `member` (`0x4e81d0`): `CHARM`, else `SUMMON`, off the @@ -44,6 +47,16 @@ impl GroupState { self.pet_guid_of(member, live, false) } + /// The guid `UNIT_PET` for the owner `member` keys on, party or raid: `CHARM`, else `SUMMON`, off + /// the member's descriptor while it is held, else the record's pet guid, with no online test. + /// The stats handler's diff fires it on a moved pet guid (`0x5e55fb`-`0x5e5617`, `0x5e57e9`); a + /// moved status byte sets another bit (`0x5e5519`-`0x5e552a`), which is health and power events, + /// and the list's own write of the online bit fires nothing (`0x4e8361`). A `partypetN` that + /// appears or vanishes behind the bit is therefore no move of this guid. + pub(crate) fn pet_edge_guid(&self, member: u64, live: Option<&ObjectFields>) -> Option { + self.pet_guid_of(member, live, false) + } + fn pet_guid_of( &self, member: u64, @@ -54,7 +67,7 @@ impl GroupState { Some(fields) => fields.unit_pet_guid(), None => self .member_record(member) - .filter(|r| !online_only || online(r)) + .filter(|r| !online_only || r.is_online()) .and_then(|r| r.pet_guid) .filter(|&g| g != 0), } @@ -66,7 +79,7 @@ impl GroupState { /// (`0x519741`); on a miss, the block of the pet record naming the guid (`+0xe4`, `0x519760`). pub(crate) fn roster_aura_slots(&self, guid: u64) -> impl Iterator + '_ { let blocks = match self.member_record(guid) { - Some(r) if !online(r) => None, + Some(r) if !r.is_online() => None, Some(r) => Some((&r.auras, &r.auras_negative)), None => self .pet_record(guid) @@ -78,15 +91,10 @@ impl GroupState { } } -/// The record's status bit 0 (`[rec+8] & 1`), the online bit. -fn online(r: &PartyMemberStatsInfo) -> bool { - r.status.unwrap_or(0) & member_status::ONLINE != 0 -} - #[cfg(test)] mod tests { use super::*; - use benilla_protocol::messages::GroupMemberEntry; + use benilla_protocol::messages::{member_status, GroupMemberEntry}; const MEMBER: u64 = 0x1234; const PET: u64 = 0xF140_0000_0000_0077; @@ -142,6 +150,62 @@ mod tests { ); } + /// `0x4e82d0` copies the saved record back and then sets or clears bit 0 of its status byte + /// from the row's (`0x4e8361`-`0x4e837b`); the raid roster's twin does the same to its row's + /// record (`0x4ba947`-`0x4ba960`). Both test the row's byte as non-zero. + #[test] + fn a_list_rewrites_a_known_members_online_bit_and_keeps_the_rest_of_the_record() { + use crate::ui_party::GROUPTYPE_RAID; + for group_type in [0, GROUPTYPE_RAID] { + let mut g = group_with(record( + member_status::ONLINE | member_status::DEAD | member_status::PVP, + )); + let relist = |g: &mut GroupState, status: u8| { + let row = |guid, name: &str| GroupMemberEntry { + name: name.into(), + guid, + status, + flags: 0, + }; + g.apply_list( + group_type, + 0, + vec![row(MEMBER, "Brisca"), row(0x5678, "Aldwyn")], + MEMBER, + None, + None, + ); + }; + + relist(&mut g, member_status::OFFLINE); + assert_eq!( + g.stats[&MEMBER], + record(member_status::DEAD | member_status::PVP), + "the bit clears and the rest of the record stays as it was" + ); + assert!(g.roster_aura_slots(MEMBER).next().is_none()); + assert_eq!(g.party_pet_guid(MEMBER, None), None); + + relist(&mut g, member_status::ONLINE | member_status::AFK); + assert_eq!( + g.stats[&MEMBER], + record(member_status::ONLINE | member_status::DEAD | member_status::PVP), + "the bit sets, and the row's other bits are not copied" + ); + assert_eq!(g.party_pet_guid(MEMBER, None), Some(PET)); + + relist(&mut g, member_status::AFK); + assert!( + g.stats[&MEMBER].is_online(), + "a non-zero byte is online, bit 0 or not" + ); + assert!( + !g.stats.contains_key(&0x5678), + "a member new to the roster gets its record from the seat, not here" + ); + } + } + #[test] fn the_party_pet_guid_is_the_live_charm_then_summon_else_the_records() { let g = group_with(record(member_status::ONLINE)); diff --git a/crates/benilla-protocol/src/messages/group.rs b/crates/benilla-protocol/src/messages/group.rs index 2300ba8d..9a20c57f 100644 --- a/crates/benilla-protocol/src/messages/group.rs +++ b/crates/benilla-protocol/src/messages/group.rs @@ -57,6 +57,16 @@ pub struct GroupMemberEntry { pub flags: u8, } +impl GroupMemberEntry { + /// Whether the row's status byte marks the member online for the record write: a non-zero byte, + /// the client's own test for a party slot (`0x5e6c3b`) and a raid row (`0x4ba951`), where bit 0 + /// alone is not asked. vmangos sends 0 for an absent member and a byte with bit 0 set for a + /// present one (`Group/Group.cpp:45-63`), so the two tests agree on its lists. + pub fn listed_online(&self) -> bool { + self.status != 0 + } +} + /// The loot tail `SMSG_GROUP_LIST` carries only when it lists members (`Group.cpp:170-179`); /// `threshold` is an item quality, 2 to 4 in practice. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -214,6 +224,26 @@ impl PartyMemberStatsInfo { } } + /// The record's online bit (`[rec+8] & 1`), the bit the readers test (`0x4e8227`, `0x4e816b`, + /// `0x4e8884`, `0x519741`, `UnitIsConnected`'s `0x517dd3`). A record with no status yet reads + /// as the zeroed byte of a fresh one, offline. + pub fn is_online(&self) -> bool { + self.status.unwrap_or(0) & member_status::ONLINE != 0 + } + + /// Set or clear the record's online bit (`[rec+8] & 1`), the other bits of its status byte as + /// they were: the write `SMSG_GROUP_LIST` makes to a member it lists, from that row's status + /// (`0x4e8361`-`0x4e837b`, raid `0x4ba947`-`0x4ba960`). A record with no status yet reads as + /// the zeroed byte of a fresh one. + pub fn set_online(&mut self, online: bool) { + let status = self.status.unwrap_or(0); + self.status = Some(if online { + status | member_status::ONLINE + } else { + status & !member_status::ONLINE + }); + } + /// Snapshot a member's live descriptor as the 1.12 client does (`0x5f0880`) when the member's /// object leaves view, just before the stats request, so the frame never reads 0/0. Values are /// raw; [`Self::shown_power`] divides at the read. AFK and DND are cleared, as in the client.