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:
co-authored by
Claude Opus 5.5
parent
2b16219357
commit
29c1ec6f0a
@@ -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]
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user