Files
benilla-octo/crates/benilla-protocol/tests/group.rs
T
SamandClaude Fable 5 eeb607e5e6 the raid tab lands, and sounds take the reference's voice buses
- 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>
2026-08-25 18:22:17 +07:00

728 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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()
);
}