party: a roster packet updates each member's online bit (#714)

A roster packet set a member's online bit only for someone new. So a member with a pet who logged off out of view kept answering `UnitExists("partypet1")` and `UnitBuff("party1", i)`, and the party pet's `UNIT_PET` fired on online flips.

The reference's list handler reseats every member through `0x4e82d0`. That keeps the saved record and sets bit 0 from the row's non-zero status byte (`0x4e8361`-`0x4e837b`; raid twin `0x4ba947`-`0x4ba960`). `UNIT_PET` fires only when the stats diff sees the pet guid move (`0x5e57e9`).

Each list now sets every known member's online bit. The `UNIT_PET` edge follows the pet guid unfiltered, and the no-object `UnitIsConnected` reads the record (`0x517dd3`).

Test: a status-only offline list clears the pet and buffs with no `UNIT_PET`, and an online list restores them.

Fixes #666
Fixes #665

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-30 13:02:47 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 2b16219357
commit 29c1ec6f0a
7 changed files with 540 additions and 60 deletions
+91 -9
View File
@@ -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::<GroupState>().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::<UiScript>()
.eval::<bool>(
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::<GroupState>().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]
+74 -15
View File
@@ -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::<GroupState>();
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::<GroupState>().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::<UiScript>();
+12 -1
View File
@@ -71,7 +71,8 @@ pub struct GroupState {
pub loot: Option<GroupLootInfo>,
/// The inviter's name while the invite popup is up.
pub pending_invite: Option<String>,
/// 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<u64, PartyMemberStatsInfo>,
/// 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;
+69 -7
View File
@@ -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());
}
}
+189 -17
View File
@@ -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<u64>,
/// 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<u64>,
edge: Option<u64>,
state: Option<UnitState>,
}
@@ -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::<GroupState>().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::<GroupState>().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::<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:?}"
);
assert_eq!(flag(&mut app, "UnitExists('partypet1')"), None);
app.world_mut().resource_mut::<GroupState>().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() {
+75 -11
View File
@@ -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<u64> {
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<Item = (u8, u16)> + '_ {
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));
@@ -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.