- the social window's fourth tab, Raid: the eight-subgroup grid over the roster, drag a member between subgroups, Convert To Raid, promote / demote assistant, uninvite, Ready Check with its popup, and the Raid Info flyout on the new `CMSG_REQUEST_RAID_INFO` / `SMSG_RAID_INSTANCE_INFO` wire (byte-golden against vmangos, an empty answer decoding as an empty list); `raid1`..`raid40` unit tokens, and `/partytest raid` stages a roster for a solo look - an imbue pressed with an empty weapon hand says "Your weapon hand is empty" instead of raising a cursor — the attribute bit has two readers in the reference and the second is the error (corrects the day's first cut) - sounds route onto the reference's own voice buses with its per-bus caps (the 0x87ce60 table, pinned as bytes): weapon impacts and clangs share four voices, footsteps six, the attacker's grunt one, the victim's injury two — so a pack swinging together makes about a dozen voices where it made dozens Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
728 lines
27 KiB
Rust
728 lines
27 KiB
Rust
//! Golden tests for the group/party opcode family — invite/accept/decline/kick/leader/disband, the
|
||
//! loot method, the roster push (`SMSG_GROUP_LIST`), party command feedback, live member stats for
|
||
//! the party/raid frame, minimap pings, raid subgroup management, raid-target icons, and ready
|
||
//! checks. CMSG bodies are asserted byte-exact against the builder output; SMSG bodies are hand-built
|
||
//! per the vmangos layouts (cited inline) and round-tripped through `parse_server` + `decode`. See
|
||
//! `tests/common` for the shared `hx()` fixture helper and methodology note.
|
||
|
||
mod common;
|
||
|
||
use benilla_protocol::events::{decode, SessionEvent};
|
||
use benilla_protocol::messages::{
|
||
self, member_status, opcode, party_member_mask, party_operation, party_result, GroupLootInfo,
|
||
GroupMemberEntry, PartyMemberStatsInfo, GROUP_MEMBER_ASSISTANT,
|
||
};
|
||
use benilla_protocol::ServerPacket;
|
||
use common::hx;
|
||
|
||
/// Every CMSG builder in the family, byte-exact — including the empty-body ones (asserting an
|
||
/// empty `Vec`) and the `raid_target_set` vs `raid_target_request` distinction.
|
||
#[test]
|
||
fn cmsg_bodies_golden() {
|
||
let guid = 0x1234_5678_9abc_def0u64;
|
||
let guid_hex = "f0debc9a78563412";
|
||
|
||
// CMSG_GROUP_INVITE / CMSG_GROUP_UNINVITE (vmangos Group.cpp:4-7 / 9-12): one cstring.
|
||
assert_eq!(
|
||
messages::group_invite("Bob"),
|
||
hx("426f6200"),
|
||
"CMSG_GROUP_INVITE body"
|
||
);
|
||
assert_eq!(
|
||
messages::group_uninvite("Bob"),
|
||
hx("426f6200"),
|
||
"CMSG_GROUP_UNINVITE body"
|
||
);
|
||
|
||
// CMSG_GROUP_ACCEPT / DECLINE / DISBAND / RAID_CONVERT (vmangos Opcodes.cpp: NullClientPacket):
|
||
// all empty.
|
||
assert_eq!(
|
||
messages::group_accept(),
|
||
Vec::<u8>::new(),
|
||
"CMSG_GROUP_ACCEPT body"
|
||
);
|
||
assert_eq!(
|
||
messages::group_decline(),
|
||
Vec::<u8>::new(),
|
||
"CMSG_GROUP_DECLINE body"
|
||
);
|
||
assert_eq!(
|
||
messages::group_disband(),
|
||
Vec::<u8>::new(),
|
||
"CMSG_GROUP_DISBAND body"
|
||
);
|
||
assert_eq!(
|
||
messages::group_raid_convert(),
|
||
Vec::<u8>::new(),
|
||
"CMSG_GROUP_RAID_CONVERT body"
|
||
);
|
||
|
||
// CMSG_GROUP_UNINVITE_GUID / CMSG_GROUP_SET_LEADER / CMSG_REQUEST_PARTY_MEMBER_STATS: a full guid.
|
||
assert_eq!(
|
||
messages::group_uninvite_guid(guid),
|
||
hx(guid_hex),
|
||
"CMSG_GROUP_UNINVITE_GUID body"
|
||
);
|
||
assert_eq!(
|
||
messages::group_set_leader(guid),
|
||
hx(guid_hex),
|
||
"CMSG_GROUP_SET_LEADER body"
|
||
);
|
||
assert_eq!(
|
||
messages::request_party_member_stats(guid),
|
||
hx(guid_hex),
|
||
"CMSG_REQUEST_PARTY_MEMBER_STATS body"
|
||
);
|
||
|
||
// CMSG_LOOT_METHOD (Group.cpp:26-31): u32 method, full guid lootMaster, u32 threshold.
|
||
assert_eq!(
|
||
messages::loot_method(2, guid, 3),
|
||
hx(concat!("02000000", "f0debc9a78563412", "03000000")),
|
||
"CMSG_LOOT_METHOD body"
|
||
);
|
||
|
||
// CMSG_GROUP_CHANGE_SUB_GROUP (Group.cpp:45-49): cstring name + u8 groupNr.
|
||
assert_eq!(
|
||
messages::group_change_sub_group("Bar", 5),
|
||
hx("4261720005"),
|
||
"CMSG_GROUP_CHANGE_SUB_GROUP body"
|
||
);
|
||
|
||
// CMSG_GROUP_SWAP_SUB_GROUP (Group.cpp:51-55): cstring name + cstring nameSwapWith.
|
||
assert_eq!(
|
||
messages::group_swap_sub_group("A", "B"),
|
||
hx("41004200"),
|
||
"CMSG_GROUP_SWAP_SUB_GROUP body"
|
||
);
|
||
|
||
// CMSG_GROUP_ASSISTANT_LEADER (Group.cpp:66-74, 1.12 guid branch): full guid + u8 flag.
|
||
assert_eq!(
|
||
messages::group_assistant_leader(guid, true),
|
||
hx(concat!("f0debc9a78563412", "01")),
|
||
"CMSG_GROUP_ASSISTANT_LEADER body, grant"
|
||
);
|
||
assert_eq!(
|
||
messages::group_assistant_leader(guid, false),
|
||
hx(concat!("f0debc9a78563412", "00")),
|
||
"CMSG_GROUP_ASSISTANT_LEADER body, revoke"
|
||
);
|
||
|
||
// MSG_MINIMAP_PING, outbound (Group.cpp:33-37): f32 x, f32 y — no guid.
|
||
assert_eq!(
|
||
messages::minimap_ping(1.0, 2.0),
|
||
hx("0000803f00000040"),
|
||
"MSG_MINIMAP_PING outbound body"
|
||
);
|
||
|
||
// MSG_RAID_TARGET_UPDATE, client bodies (Group.cpp:77-82): icon+guid to set/clear one icon, a
|
||
// lone 0xFF to ask for the current set — the two builders are never interchangeable byte-shapes.
|
||
assert_eq!(
|
||
messages::raid_target_set(3, guid),
|
||
hx(concat!("03", "f0debc9a78563412")),
|
||
"raid_target_set body"
|
||
);
|
||
assert_eq!(
|
||
messages::raid_target_set(1, 0),
|
||
hx(concat!("01", "0000000000000000")),
|
||
"raid_target_set clear (guid 0) body"
|
||
);
|
||
assert_eq!(
|
||
messages::raid_target_request(),
|
||
hx("ff"),
|
||
"raid_target_request body"
|
||
);
|
||
|
||
// MSG_RAID_READY_CHECK, client bodies (Group.cpp:84-92): empty to start, one byte to answer.
|
||
assert_eq!(
|
||
messages::ready_check_start(),
|
||
Vec::<u8>::new(),
|
||
"ready_check_start body"
|
||
);
|
||
assert_eq!(
|
||
messages::ready_check_answer(true),
|
||
hx("01"),
|
||
"ready_check_answer(true) body"
|
||
);
|
||
assert_eq!(
|
||
messages::ready_check_answer(false),
|
||
hx("00"),
|
||
"ready_check_answer(false) body"
|
||
);
|
||
}
|
||
|
||
/// The name/notice trio (`SMSG_GROUP_INVITE`/`_DECLINE`/`_SET_LEADER`, one cstring each) plus the
|
||
/// two empty-body notices (`SMSG_GROUP_UNINVITE`/`_DESTROYED`).
|
||
#[test]
|
||
fn group_notification_smsg_wire() {
|
||
// SMSG_GROUP_INVITE (Group.cpp:107-110): one cstring, the inviter's name.
|
||
let p = messages::parse_server(opcode::SMSG_GROUP_INVITE, &hx("426f6200")).unwrap();
|
||
match &p {
|
||
ServerPacket::GroupInvite { inviter } => assert_eq!(inviter, "Bob"),
|
||
other => panic!("expected GroupInvite, got {}", other.name()),
|
||
}
|
||
match decode(p).as_slice() {
|
||
[SessionEvent::GroupInvite { inviter }] => assert_eq!(inviter, "Bob"),
|
||
other => panic!("group invite decode: {other:?}"),
|
||
}
|
||
|
||
// SMSG_GROUP_DECLINE (Group.cpp:112-115): one cstring, the declining player's name.
|
||
let p = messages::parse_server(opcode::SMSG_GROUP_DECLINE, &hx("416c69636500")).unwrap();
|
||
match &p {
|
||
ServerPacket::GroupDecline { name } => assert_eq!(name, "Alice"),
|
||
other => panic!("expected GroupDecline, got {}", other.name()),
|
||
}
|
||
match decode(p).as_slice() {
|
||
[SessionEvent::GroupDecline { name }] => assert_eq!(name, "Alice"),
|
||
other => panic!("group decline decode: {other:?}"),
|
||
}
|
||
|
||
// SMSG_GROUP_SET_LEADER (Group.cpp:150-153): one cstring, the new leader's name.
|
||
let p = messages::parse_server(opcode::SMSG_GROUP_SET_LEADER, &hx("426f6200")).unwrap();
|
||
match &p {
|
||
ServerPacket::GroupLeaderChanged { name } => assert_eq!(name, "Bob"),
|
||
other => panic!("expected GroupLeaderChanged, got {}", other.name()),
|
||
}
|
||
match decode(p).as_slice() {
|
||
[SessionEvent::GroupLeaderChanged { name }] => assert_eq!(name, "Bob"),
|
||
other => panic!("group leader changed decode: {other:?}"),
|
||
}
|
||
|
||
// SMSG_GROUP_UNINVITE (Group.cpp:117-119): empty body — we were kicked/left.
|
||
let p = messages::parse_server(opcode::SMSG_GROUP_UNINVITE, &[]).unwrap();
|
||
assert!(matches!(p, ServerPacket::GroupUninvited));
|
||
assert!(matches!(
|
||
decode(p).as_slice(),
|
||
[SessionEvent::GroupUninvited]
|
||
));
|
||
|
||
// SMSG_GROUP_DESTROYED (Group.cpp:121-123): empty body — the group disbanded outright.
|
||
let p = messages::parse_server(opcode::SMSG_GROUP_DESTROYED, &[]).unwrap();
|
||
assert!(matches!(p, ServerPacket::GroupDestroyed));
|
||
assert!(matches!(
|
||
decode(p).as_slice(),
|
||
[SessionEvent::GroupDestroyed]
|
||
));
|
||
}
|
||
|
||
/// `SMSG_GROUP_LIST`, party shape: 2 other members + the full loot tail (master loot, so
|
||
/// `looterGuid` carries the master's guid).
|
||
#[test]
|
||
fn group_list_party_two_members_master_loot() {
|
||
// Group.cpp:155-180 (GroupList::AppendBodyTo): groupType, ownGroupAndAssistantFlag,
|
||
// memberCount, memberCount x member rows, leaderGuid, then (memberCount > 0) the loot tail.
|
||
let mut body = vec![0u8]; // groupType: party
|
||
body.push(0x00); // ownGroupAndAssistantFlag: subgroup 0, not assistant
|
||
body.extend_from_slice(&2u32.to_le_bytes()); // memberCount
|
||
|
||
body.extend_from_slice(b"Alice\0");
|
||
body.extend_from_slice(&0x1111u64.to_le_bytes());
|
||
body.push(member_status::ONLINE);
|
||
body.push(0x00);
|
||
|
||
body.extend_from_slice(b"Carol\0");
|
||
body.extend_from_slice(&0x3333u64.to_le_bytes());
|
||
body.push(member_status::ONLINE | member_status::DEAD);
|
||
body.push(0x00);
|
||
|
||
body.extend_from_slice(&0x1111u64.to_le_bytes()); // leaderGuid: Alice
|
||
|
||
body.push(2); // lootMethod: master loot
|
||
body.extend_from_slice(&0x3333u64.to_le_bytes()); // looterGuid: Carol is master looter
|
||
body.push(3); // lootThreshold
|
||
body.push(0); // dungeonDifficulty (always 0 at 5875)
|
||
|
||
let p = messages::parse_server(opcode::SMSG_GROUP_LIST, &body).unwrap();
|
||
match &p {
|
||
ServerPacket::GroupList {
|
||
group_type,
|
||
own_flags,
|
||
members,
|
||
leader,
|
||
loot,
|
||
} => {
|
||
assert_eq!(*group_type, 0);
|
||
assert_eq!(*own_flags, 0x00);
|
||
assert_eq!(
|
||
members,
|
||
&vec![
|
||
GroupMemberEntry {
|
||
name: "Alice".into(),
|
||
guid: 0x1111,
|
||
status: member_status::ONLINE,
|
||
flags: 0x00,
|
||
},
|
||
GroupMemberEntry {
|
||
name: "Carol".into(),
|
||
guid: 0x3333,
|
||
status: member_status::ONLINE | member_status::DEAD,
|
||
flags: 0x00,
|
||
},
|
||
]
|
||
);
|
||
assert_eq!(*leader, 0x1111);
|
||
assert_eq!(
|
||
*loot,
|
||
Some(GroupLootInfo {
|
||
method: 2,
|
||
master: 0x3333,
|
||
threshold: 3,
|
||
})
|
||
);
|
||
}
|
||
other => panic!("expected GroupList, got {}", other.name()),
|
||
}
|
||
|
||
match decode(p).as_slice() {
|
||
[SessionEvent::GroupList {
|
||
group_type: 0,
|
||
members,
|
||
leader: 0x1111,
|
||
loot: Some(loot),
|
||
..
|
||
}] => {
|
||
assert_eq!(members.len(), 2);
|
||
assert_eq!(loot.master, 0x3333);
|
||
}
|
||
other => panic!("group list decode: {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// `SMSG_GROUP_LIST`, raid shape: `groupType == 1`, one member carrying the raid-assistant bit
|
||
/// (`0x80`), round-robin loot (so `looterGuid` is `0` — vmangos only fills it for master loot).
|
||
#[test]
|
||
fn group_list_raid_with_assistant_flag() {
|
||
let mut body = vec![1u8]; // groupType: raid
|
||
body.push(0x02); // ownGroupAndAssistantFlag: subgroup 2, not assistant
|
||
body.extend_from_slice(&1u32.to_le_bytes()); // memberCount
|
||
|
||
body.extend_from_slice(b"Dave\0");
|
||
body.extend_from_slice(&0x4444u64.to_le_bytes());
|
||
body.push(member_status::ONLINE);
|
||
body.push(GROUP_MEMBER_ASSISTANT | 0x01); // subgroup 1, assistant
|
||
|
||
body.extend_from_slice(&0x4444u64.to_le_bytes()); // leaderGuid: Dave
|
||
|
||
body.push(1); // lootMethod: round-robin
|
||
body.extend_from_slice(&0u64.to_le_bytes()); // looterGuid: 0 (not master loot)
|
||
body.push(2); // lootThreshold
|
||
body.push(0); // dungeonDifficulty
|
||
|
||
let p = messages::parse_server(opcode::SMSG_GROUP_LIST, &body).unwrap();
|
||
match &p {
|
||
ServerPacket::GroupList {
|
||
group_type,
|
||
own_flags,
|
||
members,
|
||
leader,
|
||
loot,
|
||
} => {
|
||
assert_eq!(*group_type, 1);
|
||
assert_eq!(*own_flags, 0x02);
|
||
assert_eq!(
|
||
members,
|
||
&vec![GroupMemberEntry {
|
||
name: "Dave".into(),
|
||
guid: 0x4444,
|
||
status: member_status::ONLINE,
|
||
flags: GROUP_MEMBER_ASSISTANT | 0x01,
|
||
}]
|
||
);
|
||
assert!(members[0].flags & GROUP_MEMBER_ASSISTANT != 0);
|
||
assert_eq!(*leader, 0x4444);
|
||
assert_eq!(
|
||
*loot,
|
||
Some(GroupLootInfo {
|
||
method: 1,
|
||
master: 0,
|
||
threshold: 2,
|
||
})
|
||
);
|
||
}
|
||
other => panic!("expected GroupList, got {}", other.name()),
|
||
}
|
||
}
|
||
|
||
/// The degenerate "you left the group" shape (`Group.cpp:155-180` with an empty member list):
|
||
/// exactly 14 bytes (`groupType, ownFlags, u32 memberCount=0, u64 leaderGuid=0`), no loot tail.
|
||
#[test]
|
||
fn group_list_empty_you_left_shape_is_14_bytes() {
|
||
let body = vec![0u8; 14];
|
||
assert_eq!(body.len(), 14);
|
||
|
||
let p = messages::parse_server(opcode::SMSG_GROUP_LIST, &body).unwrap();
|
||
match &p {
|
||
ServerPacket::GroupList {
|
||
group_type,
|
||
own_flags,
|
||
members,
|
||
leader,
|
||
loot,
|
||
} => {
|
||
assert_eq!(*group_type, 0);
|
||
assert_eq!(*own_flags, 0);
|
||
assert!(members.is_empty());
|
||
assert_eq!(*leader, 0);
|
||
assert!(loot.is_none(), "empty member list carries no loot tail");
|
||
}
|
||
other => panic!("expected GroupList, got {}", other.name()),
|
||
}
|
||
|
||
match decode(p).as_slice() {
|
||
[SessionEvent::GroupList {
|
||
members,
|
||
loot: None,
|
||
..
|
||
}] => assert!(members.is_empty()),
|
||
other => panic!("empty group list decode: {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// `SMSG_PARTY_COMMAND_RESULT` (Group.cpp:100-105): a named refusal, and the ignoring-you refusal
|
||
/// which names no one (`Handlers/GroupHandler.cpp:466`: `SendPartyResult(PARTY_OP_INVITE, "", ...)`).
|
||
#[test]
|
||
fn party_command_result_wire() {
|
||
let mut body = party_operation::INVITE.to_le_bytes().to_vec();
|
||
body.extend_from_slice(b"Bob\0");
|
||
body.extend_from_slice(&party_result::ALREADY_IN_GROUP.to_le_bytes());
|
||
let p = messages::parse_server(opcode::SMSG_PARTY_COMMAND_RESULT, &body).unwrap();
|
||
match &p {
|
||
ServerPacket::PartyCommandResult {
|
||
operation,
|
||
member,
|
||
result,
|
||
} => {
|
||
assert_eq!(*operation, party_operation::INVITE);
|
||
assert_eq!(member, "Bob");
|
||
assert_eq!(*result, party_result::ALREADY_IN_GROUP);
|
||
}
|
||
other => panic!("expected PartyCommandResult, got {}", other.name()),
|
||
}
|
||
match decode(p).as_slice() {
|
||
[SessionEvent::PartyCommandResult { member, .. }] => assert_eq!(member, "Bob"),
|
||
other => panic!("party command result decode: {other:?}"),
|
||
}
|
||
|
||
let mut body = party_operation::LEAVE.to_le_bytes().to_vec();
|
||
body.push(0); // empty cstring: no member named
|
||
body.extend_from_slice(&party_result::IGNORING_YOU.to_le_bytes());
|
||
let p = messages::parse_server(opcode::SMSG_PARTY_COMMAND_RESULT, &body).unwrap();
|
||
match &p {
|
||
ServerPacket::PartyCommandResult {
|
||
operation,
|
||
member,
|
||
result,
|
||
} => {
|
||
assert_eq!(*operation, party_operation::LEAVE);
|
||
assert!(member.is_empty());
|
||
assert_eq!(*result, party_result::IGNORING_YOU);
|
||
}
|
||
other => panic!("expected PartyCommandResult, got {}", other.name()),
|
||
}
|
||
}
|
||
|
||
/// `SMSG_PARTY_MEMBER_STATS` (delta form): mask `0x000000FF` — every bit from `STATUS` through
|
||
/// `ZONE` — hand-computed body, the plain (non-`_FULL`) opcode.
|
||
#[test]
|
||
fn party_member_stats_delta_status_through_zone() {
|
||
let body = hx(concat!(
|
||
"012a", // packed guid 0x2A (mask 0x01, one nonzero byte)
|
||
"ff000000", // mask: STATUS|CUR_HP|MAX_HP|POWER_TYPE|CUR_POWER|MAX_POWER|LEVEL|ZONE
|
||
"01", // status: ONLINE
|
||
"6400", // cur_hp 100
|
||
"9600", // max_hp 150
|
||
"00", // power_type: mana
|
||
"c800", // cur_power 200
|
||
"fa00", // max_power 250
|
||
"3c00", // level 60
|
||
"ef05", // zone 1519
|
||
));
|
||
let p = messages::parse_server(opcode::SMSG_PARTY_MEMBER_STATS, &body).unwrap();
|
||
match &p {
|
||
ServerPacket::PartyMemberStats { guid, full, info } => {
|
||
assert_eq!(*guid, 0x2A);
|
||
assert!(!*full);
|
||
assert_eq!(
|
||
**info,
|
||
PartyMemberStatsInfo {
|
||
status: Some(member_status::ONLINE),
|
||
cur_hp: Some(100),
|
||
max_hp: Some(150),
|
||
power_type: Some(0),
|
||
cur_power: Some(200),
|
||
max_power: Some(250),
|
||
level: Some(60),
|
||
zone: Some(1519),
|
||
..Default::default()
|
||
}
|
||
);
|
||
}
|
||
other => panic!("expected PartyMemberStats, got {}", other.name()),
|
||
}
|
||
match decode(p).as_slice() {
|
||
[SessionEvent::PartyMemberStats {
|
||
guid: 0x2A,
|
||
full: false,
|
||
info,
|
||
}] => assert_eq!(info.level, Some(60)),
|
||
other => panic!("party member stats delta decode: {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// `SMSG_PARTY_MEMBER_STATS_FULL`: `POSITION` + `AURAS` (2 bits set) + `AURAS_NEGATIVE` (1 bit) +
|
||
/// the whole pet block (`PET_GUID`..`PET_AURAS_NEGATIVE`, including `PET_AURAS`) — every remaining
|
||
/// field family the delta test above didn't touch.
|
||
#[test]
|
||
fn party_member_stats_full_position_auras_and_pet_block() {
|
||
let mask = party_member_mask::POSITION
|
||
| party_member_mask::AURAS
|
||
| party_member_mask::AURAS_NEGATIVE
|
||
| party_member_mask::PET_GUID
|
||
| party_member_mask::PET_NAME
|
||
| party_member_mask::PET_MODEL_ID
|
||
| party_member_mask::PET_CUR_HP
|
||
| party_member_mask::PET_MAX_HP
|
||
| party_member_mask::PET_POWER_TYPE
|
||
| party_member_mask::PET_CUR_POWER
|
||
| party_member_mask::PET_MAX_POWER
|
||
| party_member_mask::PET_AURAS
|
||
| party_member_mask::PET_AURAS_NEGATIVE;
|
||
assert_eq!(mask, 0x001F_FF00, "sanity: the hand-picked bit set");
|
||
|
||
let mut body = vec![0x01, 0x7F]; // packed guid 0x7F (mask 0x01, one nonzero byte)
|
||
body.extend_from_slice(&mask.to_le_bytes());
|
||
|
||
// POSITION: i16 x, i16 y (negative, to prove the signed cast).
|
||
body.extend_from_slice(&1234i16.to_le_bytes());
|
||
body.extend_from_slice(&(-5678i16).to_le_bytes());
|
||
// AURAS: u32 posMask, bits 0 and 5 set -> spell ids 133 (Fireball), 116 (Frostbolt).
|
||
body.extend_from_slice(&0x0000_0021u32.to_le_bytes());
|
||
body.extend_from_slice(&133u16.to_le_bytes());
|
||
body.extend_from_slice(&116u16.to_le_bytes());
|
||
// AURAS_NEGATIVE: u16 negMask, bit 2 set -> spell id 8050.
|
||
body.extend_from_slice(&0x0004u16.to_le_bytes());
|
||
body.extend_from_slice(&8050u16.to_le_bytes());
|
||
// PET_GUID.
|
||
body.extend_from_slice(&0x1122_3344_5566_7788u64.to_le_bytes());
|
||
// PET_NAME.
|
||
body.extend_from_slice(b"Fido\0");
|
||
// PET_MODEL_ID, PET_CUR_HP, PET_MAX_HP.
|
||
body.extend_from_slice(&618u16.to_le_bytes());
|
||
body.extend_from_slice(&40u16.to_le_bytes());
|
||
body.extend_from_slice(&50u16.to_le_bytes());
|
||
// PET_POWER_TYPE: mana.
|
||
body.push(0);
|
||
// PET_CUR_POWER, PET_MAX_POWER.
|
||
body.extend_from_slice(&80u16.to_le_bytes());
|
||
body.extend_from_slice(&100u16.to_le_bytes());
|
||
// PET_AURAS: u32 mask, bit 3 set -> spell id 1126 (Mark of the Wild).
|
||
body.extend_from_slice(&0x0000_0008u32.to_le_bytes());
|
||
body.extend_from_slice(&1126u16.to_le_bytes());
|
||
// PET_AURAS_NEGATIVE: u16 mask, bit 1 set -> spell id 770 (Faerie Fire).
|
||
body.extend_from_slice(&0x0002u16.to_le_bytes());
|
||
body.extend_from_slice(&770u16.to_le_bytes());
|
||
|
||
let p = messages::parse_server(opcode::SMSG_PARTY_MEMBER_STATS_FULL, &body).unwrap();
|
||
match &p {
|
||
ServerPacket::PartyMemberStats { guid, full, info } => {
|
||
assert_eq!(*guid, 0x7F);
|
||
assert!(*full);
|
||
assert_eq!(info.position, Some((1234, -5678)));
|
||
assert_eq!(info.auras, Some(vec![133, 116]));
|
||
assert_eq!(info.auras_negative, Some(vec![8050]));
|
||
assert_eq!(info.pet_guid, Some(0x1122_3344_5566_7788));
|
||
assert_eq!(info.pet_name.as_deref(), Some("Fido"));
|
||
assert_eq!(info.pet_model_id, Some(618));
|
||
assert_eq!(info.pet_cur_hp, Some(40));
|
||
assert_eq!(info.pet_max_hp, Some(50));
|
||
assert_eq!(info.pet_power_type, Some(0));
|
||
assert_eq!(info.pet_cur_power, Some(80));
|
||
assert_eq!(info.pet_max_power, Some(100));
|
||
assert_eq!(info.pet_auras, Some(vec![1126]));
|
||
assert_eq!(info.pet_auras_negative, Some(vec![770]));
|
||
// Bits not in the mask stay None.
|
||
assert_eq!(info.status, None);
|
||
assert_eq!(info.level, None);
|
||
}
|
||
other => panic!("expected PartyMemberStats, got {}", other.name()),
|
||
}
|
||
|
||
match decode(p).as_slice() {
|
||
[SessionEvent::PartyMemberStats {
|
||
guid: 0x7F,
|
||
full: true,
|
||
info,
|
||
}] => assert_eq!(info.pet_name.as_deref(), Some("Fido")),
|
||
other => panic!("party member stats full decode: {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// The offline-miss reply (`Handlers/GroupHandler.cpp:763-774`, the not-in-raid-with-us branch):
|
||
/// `SMSG_PARTY_MEMBER_STATS_FULL`, mask `STATUS` only, status `MEMBER_STATUS_OFFLINE` (`0`).
|
||
#[test]
|
||
fn party_member_stats_offline_miss_is_status_only() {
|
||
let body = hx(concat!(
|
||
"015c", // packed guid 0x5C
|
||
"01000000", // mask: STATUS only
|
||
"00", // status: offline
|
||
));
|
||
let p = messages::parse_server(opcode::SMSG_PARTY_MEMBER_STATS_FULL, &body).unwrap();
|
||
match &p {
|
||
ServerPacket::PartyMemberStats { guid, full, info } => {
|
||
assert_eq!(*guid, 0x5C);
|
||
assert!(*full);
|
||
assert_eq!(info.status, Some(member_status::OFFLINE));
|
||
assert_eq!(info.cur_hp, None);
|
||
}
|
||
other => panic!("expected PartyMemberStats, got {}", other.name()),
|
||
}
|
||
}
|
||
|
||
/// `MSG_MINIMAP_PING` inbound (`Handlers/GroupHandler.cpp:382-391`): full guid + f32 x + f32 y —
|
||
/// the server-stamped rebroadcast shape (outbound, guid-less, is covered in [`cmsg_bodies_golden`]).
|
||
#[test]
|
||
fn minimap_ping_inbound_wire() {
|
||
let mut body = 0x77u64.to_le_bytes().to_vec();
|
||
body.extend_from_slice(&3.5f32.to_le_bytes());
|
||
body.extend_from_slice(&(-4.25f32).to_le_bytes());
|
||
let p = messages::parse_server(opcode::MSG_MINIMAP_PING, &body).unwrap();
|
||
match &p {
|
||
ServerPacket::MinimapPing { guid, x, y } => {
|
||
assert_eq!(*guid, 0x77);
|
||
assert_eq!(*x, 3.5);
|
||
assert_eq!(*y, -4.25);
|
||
}
|
||
other => panic!("expected MinimapPing, got {}", other.name()),
|
||
}
|
||
match decode(p).as_slice() {
|
||
[SessionEvent::MinimapPing { guid: 0x77, .. }] => {}
|
||
other => panic!("minimap ping decode: {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// `MSG_RAID_TARGET_UPDATE`, all three server shapes (Group.cpp:132-147): mode 0 (delta), mode 1
|
||
/// with 2 entries, and mode 1 empty (no icons currently set — a raid with a clean target board).
|
||
#[test]
|
||
fn raid_target_update_smsg_shapes() {
|
||
let mut body = vec![0u8, 3]; // mode 0 (delta), icon 3
|
||
body.extend_from_slice(&0x99u64.to_le_bytes());
|
||
let p = messages::parse_server(opcode::MSG_RAID_TARGET_UPDATE, &body).unwrap();
|
||
match &p {
|
||
ServerPacket::RaidTargetSet { icon, guid } => {
|
||
assert_eq!(*icon, 3);
|
||
assert_eq!(*guid, 0x99);
|
||
}
|
||
other => panic!("expected RaidTargetSet, got {}", other.name()),
|
||
}
|
||
match decode(p).as_slice() {
|
||
[SessionEvent::RaidTargetSet {
|
||
icon: 3,
|
||
guid: 0x99,
|
||
}] => {}
|
||
other => panic!("raid target set decode: {other:?}"),
|
||
}
|
||
|
||
let mut body = vec![1u8]; // mode 1 (full list), 2 entries
|
||
body.push(0);
|
||
body.extend_from_slice(&0x11u64.to_le_bytes());
|
||
body.push(7);
|
||
body.extend_from_slice(&0x22u64.to_le_bytes());
|
||
let p = messages::parse_server(opcode::MSG_RAID_TARGET_UPDATE, &body).unwrap();
|
||
match &p {
|
||
ServerPacket::RaidTargetList { entries } => {
|
||
assert_eq!(entries, &vec![(0u8, 0x11u64), (7u8, 0x22u64)]);
|
||
}
|
||
other => panic!("expected RaidTargetList, got {}", other.name()),
|
||
}
|
||
match decode(p).as_slice() {
|
||
[SessionEvent::RaidTargetList { entries }] => assert_eq!(entries.len(), 2),
|
||
other => panic!("raid target list decode: {other:?}"),
|
||
}
|
||
|
||
// mode 1, empty: no icons currently marked.
|
||
let p = messages::parse_server(opcode::MSG_RAID_TARGET_UPDATE, &[1u8]).unwrap();
|
||
match &p {
|
||
ServerPacket::RaidTargetList { entries } => assert!(entries.is_empty()),
|
||
other => panic!("expected empty RaidTargetList, got {}", other.name()),
|
||
}
|
||
}
|
||
|
||
/// `MSG_RAID_READY_CHECK`, both server shapes (Group.cpp:94-96 empty / 126-130 answer): the empty
|
||
/// "a check just started" body, and a member's forwarded answer (full guid + state).
|
||
#[test]
|
||
fn ready_check_smsg_shapes() {
|
||
let p = messages::parse_server(opcode::MSG_RAID_READY_CHECK, &[]).unwrap();
|
||
assert!(matches!(p, ServerPacket::ReadyCheckRequest));
|
||
assert!(matches!(
|
||
decode(p).as_slice(),
|
||
[SessionEvent::ReadyCheckRequest]
|
||
));
|
||
|
||
let mut body = 0x88u64.to_le_bytes().to_vec();
|
||
body.push(1);
|
||
let p = messages::parse_server(opcode::MSG_RAID_READY_CHECK, &body).unwrap();
|
||
match &p {
|
||
ServerPacket::ReadyCheckAnswer { guid, ready } => {
|
||
assert_eq!(*guid, 0x88);
|
||
assert_eq!(*ready, 1);
|
||
}
|
||
other => panic!("expected ReadyCheckAnswer, got {}", other.name()),
|
||
}
|
||
match decode(p).as_slice() {
|
||
[SessionEvent::ReadyCheckAnswer {
|
||
guid: 0x88,
|
||
ready: 1,
|
||
}] => {}
|
||
other => panic!("ready check answer decode: {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// `CMSG_REQUEST_RAID_INFO` / `SMSG_RAID_INSTANCE_INFO` (decision 1549's Raid Info panel;
|
||
/// vmangos `Player::SendRaidInfo`): an empty request, and a `u32 count` + `count` × 12-byte rows
|
||
/// answer. The zero-count body is the ordinary answer for a character bound to nothing, and it
|
||
/// must decode to an EMPTY list rather than to nothing at all — the UI reads the arrival as "the
|
||
/// server has spoken", which is what disables the Raid Info button.
|
||
#[test]
|
||
fn raid_instance_info_wire() {
|
||
assert_eq!(
|
||
messages::request_raid_info(),
|
||
Vec::<u8>::new(),
|
||
"CMSG_REQUEST_RAID_INFO body"
|
||
);
|
||
|
||
let p = messages::parse_server(opcode::SMSG_RAID_INSTANCE_INFO, &hx("00000000")).unwrap();
|
||
match &p {
|
||
ServerPacket::RaidInstanceInfo { entries } => assert!(entries.is_empty()),
|
||
other => panic!("expected empty RaidInstanceInfo, got {}", other.name()),
|
||
}
|
||
match decode(p).as_slice() {
|
||
[SessionEvent::RaidInstanceInfo { entries }] => assert!(entries.is_empty()),
|
||
other => panic!("empty raid info decode: {other:?}"),
|
||
}
|
||
|
||
// Two rows: Molten Core (map 409) resetting in 0x00015180 = 86400 s, instance 1234; and
|
||
// Onyxia's Lair (map 249) in 3600 s, instance 77.
|
||
let body = hx(concat!(
|
||
"02000000", // count
|
||
"99010000", "80510100", "d2040000", // map 409, 86400 s, instance 1234
|
||
"f9000000", "100e0000", "4d000000", // map 249, 3600 s, instance 77
|
||
));
|
||
let p = messages::parse_server(opcode::SMSG_RAID_INSTANCE_INFO, &body).unwrap();
|
||
match &p {
|
||
ServerPacket::RaidInstanceInfo { entries } => {
|
||
assert_eq!(entries.len(), 2);
|
||
assert_eq!(entries[0].map, 409);
|
||
assert_eq!(entries[0].reset, 86_400);
|
||
assert_eq!(entries[0].instance, 1234);
|
||
assert_eq!(entries[1].map, 249);
|
||
assert_eq!(entries[1].reset, 3_600);
|
||
assert_eq!(entries[1].instance, 77);
|
||
}
|
||
other => panic!("expected RaidInstanceInfo, got {}", other.name()),
|
||
}
|
||
|
||
// A count the body cannot back is a parse failure, not a short list.
|
||
assert!(
|
||
messages::parse_server(opcode::SMSG_RAID_INSTANCE_INFO, &hx("0100000099010000")).is_err()
|
||
);
|
||
}
|