comments: protocol, the auth to reputation integration tests
1,039 comment lines become 472 across 19 files; the code is unchanged. The movement test's claim that write() never serializes a transport tail is gone: it does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,22 +1,16 @@
|
||||
//! Oracle-free regression tests for the realmd login protocol — specifically **stream alignment**.
|
||||
//! Login packets are not length-framed, so each reader must consume its packet *exactly*; a reader
|
||||
//! that stops early leaves trailing bytes that desync the next read. These build a back-to-back
|
||||
//! challenge → proof → realm-list byte stream (the real logon sequence) and read all three from one
|
||||
//! cursor, which fails loudly if any reader under- or over-consumes.
|
||||
//! Login packets are not length-framed, so each realmd reader must consume its packet exactly:
|
||||
//! these read a back-to-back challenge, proof and realm-list stream from one cursor.
|
||||
|
||||
use benilla_protocol::auth;
|
||||
|
||||
const N: [u8; 32] = benilla_srp::LARGE_SAFE_PRIME_LITTLE_ENDIAN;
|
||||
|
||||
/// The `crc_salt` a real mangos-family realmd sends (its fixed `VersionChallenge`). The fixture uses
|
||||
/// the live bytes rather than zeros, so the reader meets what it actually meets — and so the `got
|
||||
/// 0xba` in the proof-read comment below names a real byte of this array.
|
||||
/// The `crc_salt` a mangos-family realmd sends: its fixed `VersionChallenge` bytes.
|
||||
const CRC_SALT: [u8; 16] = [
|
||||
0xba, 0xa3, 0x1e, 0x99, 0xa0, 0x0b, 0x21, 0x57, 0xfc, 0x37, 0x3f, 0xb3, 0x69, 0xcd, 0xd2, 0xf1,
|
||||
];
|
||||
|
||||
/// A successful `CMD_AUTH_LOGON_CHALLENGE_Server` body, including the `crc_salt` + `security_flag`
|
||||
/// tail that must be consumed.
|
||||
/// A successful `CMD_AUTH_LOGON_CHALLENGE_Server` body, with its `crc_salt` + `security_flag` tail.
|
||||
fn challenge_packet(server_public_key: &[u8; 32], salt: &[u8; 32]) -> Vec<u8> {
|
||||
let mut p = vec![0x00, 0x00, 0x00]; // opcode, protocol_version, result=success
|
||||
p.extend_from_slice(server_public_key);
|
||||
@@ -66,14 +60,13 @@ fn logon_read_sequence_stays_aligned() {
|
||||
let server_proof: [u8; 20] =
|
||||
std::array::from_fn(|i| (i as u8).wrapping_mul(13).wrapping_add(4));
|
||||
|
||||
// Concatenate the three replies as the server would stream them back-to-back.
|
||||
let mut buf = challenge_packet(&server_public_key, &salt);
|
||||
buf.extend_from_slice(&proof_packet(&server_proof));
|
||||
buf.extend_from_slice(&realm_list_packet());
|
||||
|
||||
let mut stream: &[u8] = &buf;
|
||||
|
||||
// 1. Challenge — must consume crc_salt + security_flag so the proof read lands on opcode 0x01.
|
||||
// The challenge read must consume the `crc_salt` + `security_flag` tail to land on opcode 0x01.
|
||||
let reply = auth::read_challenge_reply(&mut stream).expect("challenge reply");
|
||||
assert_eq!(reply.server_public_key, server_public_key);
|
||||
assert_eq!(reply.generator, 7);
|
||||
@@ -81,12 +74,10 @@ fn logon_read_sequence_stays_aligned() {
|
||||
assert_eq!(reply.salt, salt);
|
||||
assert_eq!(reply.crc_salt, CRC_SALT); // the proof's `crc_hash` is computed from this
|
||||
|
||||
// 2. Proof — this is exactly where a challenge-reply under-read used to surface (`got 0xba`).
|
||||
let proof =
|
||||
auth::read_proof_reply(&mut stream).expect("proof reply (stream desynced if this fails)");
|
||||
assert_eq!(proof, server_proof);
|
||||
|
||||
// 3. Realm list — confirms the proof reader also consumed its packet exactly.
|
||||
let realms =
|
||||
auth::read_realm_list(&mut stream).expect("realm list (stream desynced if this fails)");
|
||||
assert_eq!(realms.len(), 1);
|
||||
@@ -94,7 +85,6 @@ fn logon_read_sequence_stays_aligned() {
|
||||
assert_eq!(realms[0].address, "127.0.0.1:8085");
|
||||
assert_eq!(realms[0].characters, 3);
|
||||
|
||||
// The whole stream should be consumed (no leftover bytes).
|
||||
assert!(
|
||||
stream.is_empty(),
|
||||
"{} trailing bytes — a reader over/under-consumed",
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
//! Oracle-free golden tests for the bank arc's protocol layer (Phase 1 of decision 0604): the
|
||||
//! six-opcode wire (`CMSG_BANKER_ACTIVATE`/`SMSG_SHOW_BANK`/`CMSG_BUY_BANK_SLOT`/
|
||||
//! `SMSG_BUY_BANK_SLOT_RESULT`/`CMSG_AUTOSTORE_BANK_ITEM`/`CMSG_AUTOBANK_ITEM`) plus the two field
|
||||
//! accessors the vault window needs (`player_bank_bag_slot`, `player_bank_bag_slots_purchased`).
|
||||
//! Same idioms as `tests/gossip_vendor.rs` — `hx(...)` golden CMSG bodies, hand-built SMSG bodies
|
||||
//! round-tripped through `parse_server`.
|
||||
//! The bank wire: the banker, slot-purchase and autostore opcodes, plus the two player fields the
|
||||
//! vault window reads (the bank bag guids and the purchased-slot count).
|
||||
|
||||
use benilla_protocol::events::{decode, SessionEvent};
|
||||
use benilla_protocol::messages::{self, ObjectFields};
|
||||
@@ -25,8 +21,7 @@ fn bank_bodies_golden() {
|
||||
"CMSG_BANKER_ACTIVATE body"
|
||||
);
|
||||
|
||||
// CMSG_BUY_BANK_SLOT (vmangos Item.h:157-163): a full guid — no slot index, the server buys
|
||||
// `purchased_count + 1` itself.
|
||||
// CMSG_BUY_BANK_SLOT (vmangos Item.h:157-163): only a guid; the server buys the next slot.
|
||||
assert_eq!(
|
||||
messages::buy_bank_slot(0x1234_5678_9abc_def0),
|
||||
hx("f0debc9a78563412"),
|
||||
@@ -40,8 +35,8 @@ fn bank_bodies_golden() {
|
||||
"CMSG_AUTOBANK_ITEM body"
|
||||
);
|
||||
|
||||
// CMSG_AUTOSTORE_BANK_ITEM (vmangos Item.h:118-126): srcbag, srcslot — same shape, direction
|
||||
// is decided server-side by whether the position names a bank slot.
|
||||
// CMSG_AUTOSTORE_BANK_ITEM (vmangos Item.h:118-126): the same shape; the server picks the
|
||||
// direction by whether the position is a bank slot.
|
||||
assert_eq!(
|
||||
messages::autostore_bank_item(255, 39),
|
||||
vec![255u8, 39],
|
||||
@@ -73,7 +68,7 @@ fn show_bank_wire() {
|
||||
fn buy_bank_slot_result_wire() {
|
||||
use messages::bank_slot_result;
|
||||
|
||||
// Result enum values (vmangos Player.h:91-94) — pinned so a future edit can't drift them.
|
||||
// The result codes (vmangos Player.h:91-94).
|
||||
assert_eq!(
|
||||
(
|
||||
bank_slot_result::FAILED_TOO_MANY,
|
||||
@@ -84,7 +79,7 @@ fn buy_bank_slot_result_wire() {
|
||||
(0, 1, 2, 3)
|
||||
);
|
||||
|
||||
// SMSG_BUY_BANK_SLOT_RESULT (vmangos Item.cpp:137-140): one u32 result. Round-trip every code.
|
||||
// SMSG_BUY_BANK_SLOT_RESULT (vmangos Item.cpp:137-140): one u32 result.
|
||||
for &code in &[
|
||||
bank_slot_result::FAILED_TOO_MANY,
|
||||
bank_slot_result::INSUFFICIENT_FUNDS,
|
||||
@@ -129,18 +124,16 @@ fn player_bank_bag_slot_field() {
|
||||
|
||||
#[test]
|
||||
fn player_bank_bag_slots_purchased_field() {
|
||||
// PLAYER_BYTES_2 (field 194): byte 0 facialHair, byte 1 unk, byte 2 bankBagSlots, byte 3
|
||||
// restState (vmangos Player.h:347, PLAYER_BYTES_2_OFFSET_BANK_BAG_SLOTS = 2). Packed value
|
||||
// 0x04_03_02_01 = restState 4, bankBagSlots 3, unk 2, facialHair 1.
|
||||
// PLAYER_BYTES_2 (field 194), from byte 0: facialHair, unk, bankBagSlots, restState
|
||||
// (vmangos Player.h:347).
|
||||
let f = ObjectFields::from_pairs(&[(194, 0x0403_0201)]);
|
||||
assert_eq!(f.player_bank_bag_slots_purchased(), Some(3));
|
||||
assert_eq!(f.player_facial_hair(), Some(1), "byte 0 unaffected");
|
||||
|
||||
// Unsent field: None, not 0 — the caller must not treat "unstreamed" as "zero slots bought".
|
||||
// An unsent field reads None, not 0: unstreamed is not "zero slots bought".
|
||||
let empty = ObjectFields::from_pairs(&[]);
|
||||
assert_eq!(empty.player_bank_bag_slots_purchased(), None);
|
||||
|
||||
// All 6 slots purchased.
|
||||
let full = ObjectFields::from_pairs(&[(194, 6 << 16)]);
|
||||
assert_eq!(full.player_bank_bag_slots_purchased(), Some(6));
|
||||
}
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
//! World-broadcast wire decode goldens (mirrors `src/messages/broadcast.rs`):
|
||||
//! `SMSG_SERVER_MESSAGE`, `SMSG_ZONE_UNDER_ATTACK`, `SMSG_DEFENSE_MESSAGE` and the bodyless
|
||||
//! `SMSG_CHAT_RESTRICTED`. Bytes hand-computed from the vmangos layout (citations inline),
|
||||
//! independent of the Rust decoder — see `tests/common` for the shared `hx` fixture helper.
|
||||
//!
|
||||
//! These four are the packets nobody asks for: they arrive because the world did something, not
|
||||
//! because this client did. Three of them had no decode at all until now, which meant a shutdown
|
||||
//! countdown, a civilian-kill alarm and an Eastern Plaguelands tower capture all reached benilla as
|
||||
//! an unhandled opcode — the server drops you with no warning, and the tower changes hands in
|
||||
//! silence.
|
||||
//! The unsolicited world broadcasts, bytes built from the vmangos layout: `SMSG_SERVER_MESSAGE`,
|
||||
//! `SMSG_ZONE_UNDER_ATTACK`, `SMSG_DEFENSE_MESSAGE` and the bodyless `SMSG_CHAT_RESTRICTED`.
|
||||
|
||||
mod common;
|
||||
|
||||
@@ -16,14 +8,9 @@ use benilla_protocol::messages;
|
||||
use benilla_protocol::ServerPacket;
|
||||
use common::hx;
|
||||
|
||||
/// `SMSG_SERVER_MESSAGE` (VERIFIED vmangos `WorldPackets::Misc::ServerMessage::AppendBodyTo`,
|
||||
/// `Server/Packets/Misc.cpp:341-345`: `buffer << messageType; buffer << text`): `u32` + cstring.
|
||||
///
|
||||
/// Both shapes the five `ServerMessageType` producers actually send (`World.cpp:2742`/`2761`): a
|
||||
/// countdown carrying `secsToTimeString(...)` for its row's `%s`, and a cancellation carrying an
|
||||
/// **empty** string, because rows 4 and 5 have no `%s` to fill. The empty text is a value, not a
|
||||
/// short packet — the client's own handler tests `[ebp-0x400] == 0` at `0x49dfc8` and takes a
|
||||
/// straight copy of the row instead of a format.
|
||||
/// `SMSG_SERVER_MESSAGE` (vmangos `Server/Packets/Misc.cpp:341-345`): a `u32` type and a cstring.
|
||||
/// A countdown fills its row's `%s`; a cancellation sends empty text (`World.cpp:2742`/`2761`),
|
||||
/// which the reference prints as the bare row (`0x49dfc8`).
|
||||
#[test]
|
||||
fn server_message_decodes_a_countdown_and_a_cancellation() {
|
||||
let body = hx("020000003135204d696e7574657300"); // type 2 (RESTART_TIME), "15 Minutes"
|
||||
@@ -59,9 +46,8 @@ fn server_message_decodes_a_countdown_and_a_cancellation() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_ZONE_UNDER_ATTACK` (VERIFIED vmangos
|
||||
/// `WorldPackets::Misc::ZoneUnderAttack::AppendBodyTo`, `Server/Packets/Misc.cpp:451-454`): one
|
||||
/// `u32` `AreaTable.dbc` id and nothing else. 40 is Westfall.
|
||||
/// `SMSG_ZONE_UNDER_ATTACK` (vmangos `Server/Packets/Misc.cpp:451-454`): one `u32`
|
||||
/// `AreaTable.dbc` id; 40 is Westfall.
|
||||
#[test]
|
||||
fn zone_under_attack_decodes() {
|
||||
let body = hx("28000000");
|
||||
@@ -73,12 +59,8 @@ fn zone_under_attack_decodes() {
|
||||
));
|
||||
}
|
||||
|
||||
/// `SMSG_DEFENSE_MESSAGE` (VERIFIED vmangos `Map::SendDefenseMessage`, `Maps/Map.cpp:1868-1884`):
|
||||
/// `u32 zoneId`, `u32 strlen(text) + 1`, then the NUL-terminated text. 139 is the Eastern
|
||||
/// Plaguelands, whose tower captures are this packet's only vmangos sender.
|
||||
///
|
||||
/// The length counts the terminator (`0x30` = 48 for a 47-byte string) and is redundant with the
|
||||
/// string; the reference reads and discards it too.
|
||||
/// `SMSG_DEFENSE_MESSAGE` (vmangos `Maps/Map.cpp:1868-1884`): `u32 zoneId`, `u32 strlen + 1`
|
||||
/// and the cstring; the reference reads and discards the length. 139 is the Eastern Plaguelands.
|
||||
#[test]
|
||||
fn defense_message_decodes() {
|
||||
let body = hx(
|
||||
@@ -102,9 +84,8 @@ fn defense_message_decodes() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_CHAT_RESTRICTED` (VERIFIED vmangos `WorldPackets::Chat::ChatRestricted::AppendBodyTo`,
|
||||
/// `Server/Packets/Chat.cpp:21-23`): an **empty** body, exactly like `SMSG_CHAT_WRONG_FACTION`.
|
||||
/// The client's arm reads nothing off the wire either (`0x5e4a09` is `push 0x1c3; call 0x496720`).
|
||||
/// `SMSG_CHAT_RESTRICTED` (vmangos `Server/Packets/Chat.cpp:21-23`) has an empty body; the
|
||||
/// reference's handler reads nothing either (`0x5e4a09`).
|
||||
#[test]
|
||||
fn chat_restricted_is_bodyless() {
|
||||
let p = messages::parse_server(messages::opcode::SMSG_CHAT_RESTRICTED, &[]).unwrap();
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
//! `SMSG_CHAR_ENUM` (the character-select roster) + the logout round-trip. The enum body is
|
||||
//! hand-built byte-exact to the vmangos serializer (`Player::BuildEnumData`, `Player.cpp:1629`:
|
||||
//! guid u64 · name cstring · race/class/gender/skin/face/hairStyle/hairColor/facialHair u8 ·
|
||||
//! level u8 · zone u32 · map u32 · x/y/z f32 · guild u32 · flags u32 · firstLogin u8 ·
|
||||
//! petDisplay/petLevel/petFamily u32 · 19×(display u32 + invType u8) equipment · first-bag
|
||||
//! display u32 + invType u8); two characters in one body exercise the alignment of everything
|
||||
//! the parser skips.
|
||||
//! `SMSG_CHAR_ENUM` in vmangos `Player::BuildEnumData` order (`Player.cpp:1629`), two entries per
|
||||
//! body so any misalignment shows, plus the logout and refused-login replies.
|
||||
|
||||
use benilla_protocol::events::{decode, SessionEvent};
|
||||
use benilla_protocol::messages;
|
||||
use benilla_protocol::ServerPacket;
|
||||
|
||||
/// One serialized enum entry, vmangos field order. Non-roster fields get distinct junk values so a
|
||||
/// misaligned parse can't accidentally pass.
|
||||
/// One enum entry; the fixed fields hold distinct values so a misaligned parse cannot pass.
|
||||
fn enum_entry(
|
||||
guid: u64,
|
||||
name: &str,
|
||||
@@ -36,7 +30,7 @@ fn enum_entry(
|
||||
b.extend_from_slice(&c.to_le_bytes());
|
||||
}
|
||||
b.extend_from_slice(&0u32.to_le_bytes()); // guild id
|
||||
b.extend_from_slice(&0x0200_0000u32.to_le_bytes()); // character flags (DECLINED — junk)
|
||||
b.extend_from_slice(&0x0200_0000u32.to_le_bytes()); // character flags (DECLINED, a junk value)
|
||||
b.push(1); // first login
|
||||
b.extend_from_slice(&pet_display.to_le_bytes()); // pet display id
|
||||
b.extend_from_slice(&7u32.to_le_bytes()); // pet level
|
||||
@@ -62,7 +56,7 @@ fn char_enum_roster_parses_and_decodes() {
|
||||
1,
|
||||
0,
|
||||
[-8949.95, -132.493, 83.5312],
|
||||
99, // a pet — the select screen stands one beside this character
|
||||
99, // a pet, which the select screen stands beside this character
|
||||
));
|
||||
body.extend(enum_entry(
|
||||
0x0000_0000_0000_0009,
|
||||
@@ -86,17 +80,12 @@ fn char_enum_roster_parses_and_decodes() {
|
||||
(c.guid, c.name.as_str(), c.race, c.class, c.gender),
|
||||
(7, "Two", 1, 1, 0)
|
||||
);
|
||||
// The appearance bytes + zone + flags land verbatim (decision 0465 — the select screen
|
||||
// renders them; they used to be alignment-skips).
|
||||
assert_eq!(
|
||||
(c.skin, c.face, c.hair_style, c.hair_color, c.facial_hair),
|
||||
(3, 4, 5, 6, 7)
|
||||
);
|
||||
assert_eq!((c.zone, c.flags), (12, 0x0200_0000));
|
||||
// The pet triple, which used to be three alignment-skips: it sits between the first-login byte
|
||||
// and the equipment array, and reading it *as* the roster's fields is what stands a pet on the
|
||||
// select screen. The equipment assertions below are the alignment half of this — a triple read
|
||||
// one field wide would slide every display id.
|
||||
// The pet triple sits before the equipment array: a misread slides every display id below.
|
||||
assert_eq!(
|
||||
(c.pet_display_id, c.pet_level, c.pet_family),
|
||||
(99, 7, 1),
|
||||
@@ -104,7 +93,6 @@ fn char_enum_roster_parses_and_decodes() {
|
||||
);
|
||||
assert_eq!((c.level, c.map), (1, 0));
|
||||
assert_eq!((c.position.x, c.position.y), (-8949.95, -132.493));
|
||||
// All 19 equipment pairs, in slot order: display 1000+slot, invType = slot.
|
||||
for (slot, item) in c.equipment.iter().enumerate() {
|
||||
assert_eq!(
|
||||
(item.display_id, item.inventory_type),
|
||||
@@ -118,12 +106,11 @@ fn char_enum_roster_parses_and_decodes() {
|
||||
);
|
||||
assert_eq!((c.level, c.map, c.position.z), (60, 1, 3.5));
|
||||
assert_eq!(c.equipment[18].display_id, 1018);
|
||||
// No pet: `petDisplayId == 0` is the whole gate — the server zeroes the triple for every
|
||||
// character but a living hunter's or warlock's, so the client needs no class test of its own.
|
||||
// The server zeroes the triple for all but a hunter's or warlock's pet, so display id 0 is the
|
||||
// whole no-pet gate.
|
||||
assert_eq!(c.pet_display_id, 0);
|
||||
|
||||
// Decodes to one CharacterList event carrying the roster through (no realm context on the raw
|
||||
// decode path — the IO thread's own emit is what carries the auth realm entry).
|
||||
// The raw decode has no realm; the IO thread's own emit carries the auth realm entry.
|
||||
match &decode(p)[..] {
|
||||
[SessionEvent::CharacterList { characters, realm }] => {
|
||||
assert_eq!(characters.len(), 2);
|
||||
@@ -135,20 +122,12 @@ fn char_enum_roster_parses_and_decodes() {
|
||||
|
||||
#[test]
|
||||
fn logout_complete_decodes_to_logged_out() {
|
||||
// SMSG_LOGOUT_COMPLETE: empty body.
|
||||
let p = messages::parse_server(messages::opcode::SMSG_LOGOUT_COMPLETE, &[]).unwrap();
|
||||
assert!(matches!(decode(p)[..], [SessionEvent::LoggedOut]));
|
||||
}
|
||||
|
||||
/// The **refused** pick (`SMSG_CHARACTER_LOGIN_FAILED`, opcode 65): one result byte, straight
|
||||
/// through to the event the glue layer answers.
|
||||
///
|
||||
/// It used to parse into nothing at all — the opcode had a name and no arm — so a server that
|
||||
/// refused a character login said it to a client that could not hear it, and the entry the IO
|
||||
/// thread had already announced simply never finished. The byte travels **raw**: it is a 1-based
|
||||
/// reason index whose meaning is a table in the reference's glue layer, and the wire has no
|
||||
/// business holding that table. `0x01` is vmangos's only value; `0x08` is the top of
|
||||
/// mangos-classic's `CharLoginFailReasons`, past the client's six-entry switch.
|
||||
/// `SMSG_CHARACTER_LOGIN_FAILED` (opcode 65): one raw 1-based reason byte the glue layer maps;
|
||||
/// vmangos sends only `0x01`, and `0x08` is past the reference's six-entry switch.
|
||||
#[test]
|
||||
fn a_refused_character_login_parses_and_decodes() {
|
||||
for result in [0x01u8, 0x08] {
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
//! Chat + channel wire decode tests (mirrors `src/messages/chat.rs` + `src/messages/channel.rs`,
|
||||
//! decision 0288 phase 1): one golden per `SMSG_MESSAGECHAT` per-type wire shape (the reader was
|
||||
//! byte-correct before this decision but had zero golden coverage), `SMSG_TEXT_EMOTE`/`SMSG_EMOTE`,
|
||||
//! every `SMSG_CHANNEL_NOTIFY` tail shape, `SMSG_CHANNEL_LIST`, `SMSG_CHAT_PLAYER_NOT_FOUND`,
|
||||
//! `SMSG_CHAT_WRONG_FACTION`, `SMSG_NOTIFICATION`, `SMSG_PLAYED_TIME`, and `MSG_RANDOM_ROLL`'s
|
||||
//! broadcast shape. Bytes
|
||||
//! hand-computed from the vmangos layout (citations inline), independent of the Rust decoder — see
|
||||
//! `tests/common` for the shared `hx` fixture helper.
|
||||
//! The chat and channel wire: every `SMSG_MESSAGECHAT` shape, the emotes, the channel notices and
|
||||
//! list, and the small chat replies, bytes built from the vmangos layout.
|
||||
|
||||
mod common;
|
||||
|
||||
@@ -14,17 +8,13 @@ use benilla_protocol::messages::{self, ChannelNoticeTail};
|
||||
use benilla_protocol::ServerPacket;
|
||||
use common::hx;
|
||||
|
||||
/// `SMSG_MESSAGECHAT`'s five distinct wire shapes (VERIFIED vmangos `ChatHandler::BuildChatPacket`,
|
||||
/// `Chat/Chat.cpp:2542-2599`): SAY/PARTY/YELL (sender guid **twice**), MONSTER_SAY/MONSTER_YELL
|
||||
/// (guid + length-prefixed name + target guid), CHANNEL (cstring channel + `u32` rank + guid),
|
||||
/// MONSTER_WHISPER/RAID_BOSS_WHISPER/RAID_BOSS_EMOTE/MONSTER_EMOTE (length-prefixed name + target
|
||||
/// guid, no leading sender guid), and the `default:` shape (one sender guid) — SYSTEM, WHISPER,
|
||||
/// EMOTE, RAID, GUILD, OFFICER, AFK, DND, IGNORED, RAID_LEADER/WARNING, BATTLEGROUND(+LEADER) all
|
||||
/// fall here. Every shape ends `u32 len (incl NUL) + text + NUL`, then the trailing `u8 chatTag`
|
||||
/// (`Chat/Chat.h:86-92`) this decision surfaces on [`messages::ChatMessage::chat_tag`].
|
||||
/// The five `SMSG_MESSAGECHAT` shapes (vmangos `Chat/Chat.cpp:2542-2599`): SAY/PARTY/YELL send
|
||||
/// the sender guid twice; MONSTER_SAY/YELL a guid, a length-prefixed name and a target guid;
|
||||
/// CHANNEL a cstring channel, a `u32` rank and a guid; MONSTER_WHISPER, the raid-boss types and
|
||||
/// MONSTER_EMOTE the name and target guid only; every other type one sender guid. Each ends with a
|
||||
/// `u32` length (NUL included), the text and a `u8` chat tag (`Chat/Chat.h:86-92`).
|
||||
#[test]
|
||||
fn message_chat_decodes_every_wire_shape() {
|
||||
// SAY: guid twice, chat_tag = AFK (1) — proves the tag rides through on the "two guid" shape.
|
||||
let body = hx("0007000000887766554433221188776655443322110900000068692074686572650001");
|
||||
match messages::parse_server(messages::opcode::SMSG_MESSAGECHAT, &body).unwrap() {
|
||||
ServerPacket::MessageChat(m) => {
|
||||
@@ -39,17 +29,12 @@ fn message_chat_decodes_every_wire_shape() {
|
||||
_ => panic!("expected MessageChat"),
|
||||
}
|
||||
|
||||
// MONSTER_SAY: guid + length-prefixed name + target guid, chat_tag = none. Body: type(0b) +
|
||||
// language(00000000) + sender guid(ddccbbaa…) + namelen(0f000000) + "Timmy the Wolf\0" +
|
||||
// target guid(66778899…) + msglen(05000000) + "Grr!\0" + chatTag(00).
|
||||
let body = hx("0b00000000ddccbbaa000000000f00000054696d6d792074686520576f6c6600667788990000000005000000477272210000");
|
||||
match messages::parse_server(messages::opcode::SMSG_MESSAGECHAT, &body).unwrap() {
|
||||
ServerPacket::MessageChat(m) => {
|
||||
assert_eq!(m.chat_type, messages::CHAT_MSG_MONSTER_SAY);
|
||||
assert_eq!(m.sender_guid, 0xAABB_CCDD);
|
||||
// The addressee — the guid slot between the name and the message. It is the subject
|
||||
// the real client's `$`-macro expander resolves this line against (decision 0754), so
|
||||
// it has to survive the parse instead of being read and dropped.
|
||||
// The target guid is what the reference's `$`-macro expander resolves the line against.
|
||||
assert_eq!(m.target_guid, 0x9988_7766);
|
||||
assert_eq!(m.sender_name.as_deref(), Some("Timmy the Wolf"));
|
||||
assert_eq!(m.channel, None);
|
||||
@@ -59,9 +44,6 @@ fn message_chat_decodes_every_wire_shape() {
|
||||
_ => panic!("expected MessageChat"),
|
||||
}
|
||||
|
||||
// CHANNEL: cstring channel + u32 rank + guid. Body: type(0e) + language(00000000) +
|
||||
// "General\0" + playerRank(00000000) + sender guid(3412…) + msglen(10000000) +
|
||||
// "wtb boar livers\0" + chatTag(00).
|
||||
let body = hx("0e0000000047656e6572616c000000000034120000000000001000000077746220626f6172206c69766572730000");
|
||||
match messages::parse_server(messages::opcode::SMSG_MESSAGECHAT, &body).unwrap() {
|
||||
ServerPacket::MessageChat(m) => {
|
||||
@@ -74,9 +56,6 @@ fn message_chat_decodes_every_wire_shape() {
|
||||
_ => panic!("expected MessageChat"),
|
||||
}
|
||||
|
||||
// MONSTER_WHISPER: length-prefixed name + target guid (no leading sender guid at all). Body:
|
||||
// type(1a) + language(00000000) + namelen(0e000000) + "Innkeeper Bob\0" + target guid(55…) +
|
||||
// msglen(19000000) + "Welcome, weary traveler!\0".
|
||||
let body = hx("1a000000000e000000496e6e6b656570657220426f620055000000000000001900000057656c636f6d652c2077656172792074726176656c6572210000");
|
||||
match messages::parse_server(messages::opcode::SMSG_MESSAGECHAT, &body).unwrap() {
|
||||
ServerPacket::MessageChat(m) => {
|
||||
@@ -89,9 +68,6 @@ fn message_chat_decodes_every_wire_shape() {
|
||||
_ => panic!("expected MessageChat"),
|
||||
}
|
||||
|
||||
// default shape (SYSTEM): one sender guid (0), chat_tag = GM (3). Body: type(0a) +
|
||||
// language(00000000) + sender guid(0000000000000000) + msglen(1a000000) +
|
||||
// "This is a system message.\0" + chatTag(03).
|
||||
let body = hx(
|
||||
"0a0000000000000000000000001a0000005468697320697320612073797374656d206d6573736167652e0003",
|
||||
);
|
||||
@@ -106,17 +82,14 @@ fn message_chat_decodes_every_wire_shape() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_TEXT_EMOTE` (VERIFIED vmangos `MaNGOS::EmoteChatBuilder::operator()`,
|
||||
/// `Handlers/ChatHandler.cpp:681-702`): `u64` performer guid (an `ObjectGuid` write, unpacked) + `u32`
|
||||
/// textEmote + `u32` emoteNum + `u32 namelen` + `namelen` bytes (the target's name + NUL, or a lone
|
||||
/// NUL when untargeted) — and `SMSG_EMOTE` (VERIFIED vmangos `EmoteNotify::AppendBodyTo`,
|
||||
/// `Server/Packets/Misc.cpp:670-674`): `u32` emoteId + `u64` guid.
|
||||
/// `SMSG_TEXT_EMOTE` (vmangos `Handlers/ChatHandler.cpp:681-702`): unpacked `u64` guid, `u32`
|
||||
/// textEmote, `u32` emoteNum, `u32` namelen and the target name with its NUL; `SMSG_EMOTE`
|
||||
/// (`Server/Packets/Misc.cpp:670-674`): `u32` emoteId and `u64` guid.
|
||||
#[test]
|
||||
fn text_emote_and_emote_decode() {
|
||||
let body = hx("7700000000000000650000000000000004000000426f6200");
|
||||
let p = messages::parse_server(messages::opcode::SMSG_TEXT_EMOTE, &body).unwrap();
|
||||
// The target name is KEPT, not skipped past: it is the sentence-form selector (decision 1274).
|
||||
// The NUL inside `namelen` is trimmed, so the string is exactly the display name.
|
||||
// The target name picks the sentence form; the NUL counted in `namelen` is trimmed.
|
||||
assert!(matches!(
|
||||
&p,
|
||||
ServerPacket::TextEmote {
|
||||
@@ -134,8 +107,7 @@ fn text_emote_and_emote_decode() {
|
||||
}] if target_name == "Bob"
|
||||
));
|
||||
|
||||
// Untargeted: vmangos writes `namelen == 1` and a lone NUL, which must read as the EMPTY
|
||||
// string — the "untargeted" bit — and not as a one-byte name.
|
||||
// Untargeted, vmangos sends `namelen == 1` and a lone NUL: an empty name, not a one-byte one.
|
||||
let body = hx("770000000000000065000000000000000100000000");
|
||||
assert!(matches!(
|
||||
messages::parse_server(messages::opcode::SMSG_TEXT_EMOTE, &body).unwrap(),
|
||||
@@ -160,12 +132,9 @@ fn text_emote_and_emote_decode() {
|
||||
));
|
||||
}
|
||||
|
||||
/// Every `SMSG_CHANNEL_NOTIFY` tail shape (VERIFIED vmangos `Channel::Make*`,
|
||||
/// `Chat/Channel.cpp:804-1008`), one representative per [`ChannelNoticeTail`] variant, plus the
|
||||
/// unrecognized-notice error path.
|
||||
/// One `SMSG_CHANNEL_NOTIFY` per tail shape (vmangos `Chat/Channel.cpp:804-1008`).
|
||||
#[test]
|
||||
fn channel_notify_decodes_each_tail_shape() {
|
||||
// 0x00 JOINED: guid.
|
||||
let body = hx("0047656e6572616c00aa00000000000000");
|
||||
match messages::parse_server(messages::opcode::SMSG_CHANNEL_NOTIFY, &body).unwrap() {
|
||||
ServerPacket::ChannelNotify(n) => {
|
||||
@@ -186,8 +155,7 @@ fn channel_notify_decodes_each_tail_shape() {
|
||||
_ => panic!("expected ChannelNotify"),
|
||||
}
|
||||
|
||||
// 0x05 NOT_MEMBER: empty tail — both the instance (`MakeNotMember`) and static
|
||||
// (`MakeNotOnPacket`) vmangos producers write this exact shape; no special-casing needed.
|
||||
// 0x05 NOT_MEMBER: empty tail; `MakeNotMember` and `MakeNotOnPacket` both send it.
|
||||
let body = hx("0553656372657400");
|
||||
match messages::parse_server(messages::opcode::SMSG_CHANNEL_NOTIFY, &body).unwrap() {
|
||||
ServerPacket::ChannelNotify(n) => {
|
||||
@@ -198,7 +166,6 @@ fn channel_notify_decodes_each_tail_shape() {
|
||||
_ => panic!("expected ChannelNotify"),
|
||||
}
|
||||
|
||||
// 0x18 INVITE: a lone actor guid.
|
||||
let body = hx("1847656e6572616c00bb00000000000000");
|
||||
match messages::parse_server(messages::opcode::SMSG_CHANNEL_NOTIFY, &body).unwrap() {
|
||||
ServerPacket::ChannelNotify(n) => {
|
||||
@@ -208,7 +175,6 @@ fn channel_notify_decodes_each_tail_shape() {
|
||||
_ => panic!("expected ChannelNotify"),
|
||||
}
|
||||
|
||||
// 0x09 PLAYER_NOT_FOUND: a bare name.
|
||||
let body = hx("0947656e6572616c0047686f737400");
|
||||
match messages::parse_server(messages::opcode::SMSG_CHANNEL_NOTIFY, &body).unwrap() {
|
||||
ServerPacket::ChannelNotify(n) => {
|
||||
@@ -228,7 +194,6 @@ fn channel_notify_decodes_each_tail_shape() {
|
||||
_ => panic!("expected ChannelNotify"),
|
||||
}
|
||||
|
||||
// 0x0C MODE_CHANGE: guid + old flags + new flags.
|
||||
let body = hx("0c47656e6572616c00cc000000000000000002");
|
||||
match messages::parse_server(messages::opcode::SMSG_CHANNEL_NOTIFY, &body).unwrap() {
|
||||
ServerPacket::ChannelNotify(n) => {
|
||||
@@ -245,7 +210,6 @@ fn channel_notify_decodes_each_tail_shape() {
|
||||
_ => panic!("expected ChannelNotify"),
|
||||
}
|
||||
|
||||
// 0x12 PLAYER_KICKED: target guid + source (actor) guid.
|
||||
let body = hx("1247656e6572616c00dd00000000000000ee00000000000000");
|
||||
match messages::parse_server(messages::opcode::SMSG_CHANNEL_NOTIFY, &body).unwrap() {
|
||||
ServerPacket::ChannelNotify(n) => {
|
||||
@@ -261,8 +225,6 @@ fn channel_notify_decodes_each_tail_shape() {
|
||||
_ => panic!("expected ChannelNotify"),
|
||||
}
|
||||
|
||||
// Round-trip the decoded ChannelNotify through the event layer once (the mapping is a flat
|
||||
// passthrough, but it must actually be wired).
|
||||
let body = hx("0047656e6572616c00aa00000000000000");
|
||||
let p = messages::parse_server(messages::opcode::SMSG_CHANNEL_NOTIFY, &body).unwrap();
|
||||
match &decode(p)[..] {
|
||||
@@ -278,14 +240,13 @@ fn channel_notify_decodes_each_tail_shape() {
|
||||
other => panic!("channel notify decode: {} events", other.len()),
|
||||
}
|
||||
|
||||
// An unrecognized notice byte (outside vmangos's defined 0x00..=0x1F) errors rather than
|
||||
// guessing a tail shape.
|
||||
// A notice byte past vmangos's 0x00..=0x1F errors rather than guessing a tail.
|
||||
let body = hx("2047656e6572616c00");
|
||||
assert!(messages::parse_server(messages::opcode::SMSG_CHANNEL_NOTIFY, &body).is_err());
|
||||
}
|
||||
|
||||
/// `SMSG_CHANNEL_LIST` (VERIFIED vmangos `Channel::List`, `Chat/Channel.cpp:513-556`): cstring
|
||||
/// channel + `u8` flags + `u32` count + that many `(u64 guid, u8 memberFlags)` rows.
|
||||
/// `SMSG_CHANNEL_LIST` (vmangos `Chat/Channel.cpp:513-556`): cstring channel, `u8` flags, `u32`
|
||||
/// count, then `(u64 guid, u8 memberFlags)` rows.
|
||||
#[test]
|
||||
fn channel_list_decodes_roster() {
|
||||
let body = hx("47656e6572616c001802000000011000000000000001021000000000000000");
|
||||
@@ -303,7 +264,7 @@ fn channel_list_decodes_roster() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_CHAT_PLAYER_NOT_FOUND` (cstring name, VERIFIED vmangos `Server/Packets/Chat.cpp:26-29`) and
|
||||
/// `SMSG_CHAT_PLAYER_NOT_FOUND` (cstring name, vmangos `Server/Packets/Chat.cpp:26-29`) and
|
||||
/// `SMSG_CHAT_WRONG_FACTION` (empty body, `Server/Packets/Chat.cpp:16-18`).
|
||||
#[test]
|
||||
fn chat_player_not_found_and_wrong_faction_decode() {
|
||||
@@ -324,10 +285,8 @@ fn chat_player_not_found_and_wrong_faction_decode() {
|
||||
assert!(matches!(decode(p)[..], [SessionEvent::ChatWrongFaction]));
|
||||
}
|
||||
|
||||
/// `SMSG_NOTIFICATION` (VERIFIED vmangos `WorldSession::SendNotification`,
|
||||
/// `Server/WorldSession.cpp:900-915`: `data << szStr`): one cstring, pre-formatted server-side —
|
||||
/// the "You do not know that language" reply is how a wrong-tongue send fails (silently, until
|
||||
/// this packet was rendered).
|
||||
/// `SMSG_NOTIFICATION` (vmangos `Server/WorldSession.cpp:900-915`): one cstring, formatted by the
|
||||
/// server; a send in a language the sender does not know fails with this reply.
|
||||
#[test]
|
||||
fn notification_decodes() {
|
||||
let body = hx("596f7520646f206e6f74206b6e6f772074686174206c616e677561676500");
|
||||
@@ -343,8 +302,7 @@ fn notification_decodes() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_PLAYED_TIME` (VERIFIED vmangos `WorldPackets::Misc::PlayedTime::AppendBodyTo`,
|
||||
/// `Server/Packets/Misc.cpp:278-282`): `u32` total + `u32` level, both seconds.
|
||||
/// `SMSG_PLAYED_TIME` (vmangos `Server/Packets/Misc.cpp:278-282`): total and level, `u32` seconds.
|
||||
#[test]
|
||||
fn played_time_decodes() {
|
||||
let body = hx("40e20100100e0000"); // total = 123456, level = 3600
|
||||
@@ -365,9 +323,8 @@ fn played_time_decodes() {
|
||||
));
|
||||
}
|
||||
|
||||
/// `MSG_RANDOM_ROLL`'s server→client broadcast shape (VERIFIED vmangos
|
||||
/// `WorldSession::HandleRandomRollOpcode`, `Handlers/GroupHandler.cpp:394-422`): `u32` min + `u32`
|
||||
/// max + `u32` roll + a **raw** `u64` guid.
|
||||
/// `MSG_RANDOM_ROLL`'s broadcast (vmangos `Handlers/GroupHandler.cpp:394-422`): `u32` min, max
|
||||
/// and roll, then an unpacked `u64` guid.
|
||||
#[test]
|
||||
fn random_roll_broadcast_decodes() {
|
||||
let body = hx("01000000640000002a0000009999000000000000");
|
||||
@@ -392,25 +349,16 @@ fn random_roll_broadcast_decodes() {
|
||||
));
|
||||
}
|
||||
|
||||
/// The `$`-macro gate is a byte-level fact, not a design choice — pin it.
|
||||
///
|
||||
/// **The reference has TWO gates, and we want the WIRE one** (decision 0759 corrects 0754). The
|
||||
/// `SMSG_MESSAGECHAT` parser `0x49d560` routes `0x0B 0x0C 0x0D 0x1A 0x5A` into the expanding branch
|
||||
/// at `0x49da3d` (`0x49d5e4-0x49d606`) and `0x52 0x53 0x54` into the one at `0x49d961` — **eight**.
|
||||
/// The seven-value chain at `0x49cf36-0x49cf5d` is the *pending-chat re-expansion* gate and omits
|
||||
/// `0x5A`; 0754 cited that one by mistake, leaving boss emotes unexpanded.
|
||||
///
|
||||
/// Three plausible-and-wrong edits this guards against: dropping `RAID_BOSS_EMOTE` (0x5A) because
|
||||
/// the other gate omits it, adding `RAID_BOSS_WHISPER` (0x59) because it looks like its sibling, and
|
||||
/// dropping the BG_SYSTEM trio (0x52-0x54) because they look like plain system lines. Player chat is
|
||||
/// never expanded.
|
||||
/// The `$`-macro types are the reference's wire gate: parser `0x49d560` sends 0x0B, 0x0C, 0x0D,
|
||||
/// 0x1A and 0x5A to the expander at `0x49da3d`, and 0x52 to 0x54 to the one at `0x49d961`. The
|
||||
/// chain at `0x49cf36-0x49cf5d` is the pending-chat gate, which omits 0x5A; it is not this one.
|
||||
#[test]
|
||||
fn the_macro_expanded_chat_types_are_the_reference_wire_gate() {
|
||||
let mut got = messages::MACRO_EXPANDED_TYPES;
|
||||
got.sort_unstable();
|
||||
assert_eq!(got, [0x0B, 0x0C, 0x0D, 0x1A, 0x52, 0x53, 0x54, 0x5A]);
|
||||
// 0x59 RAID_BOSS_WHISPER takes its own branch (`0x49d610`), never expands, and is remapped to a
|
||||
// plain whisper — it must never creep in alongside 0x5A.
|
||||
// 0x59 RAID_BOSS_WHISPER takes its own branch (`0x49d610`): never expanded, remapped to a
|
||||
// plain whisper.
|
||||
for t in [
|
||||
messages::CHAT_MSG_SAY,
|
||||
messages::CHAT_MSG_YELL,
|
||||
@@ -427,20 +375,11 @@ fn the_macro_expanded_chat_types_are_the_reference_wire_gate() {
|
||||
}
|
||||
}
|
||||
|
||||
/// **Addon chat is ordinary chat with a sentinel language** (decision 1029, B215). 1.12.1 has no
|
||||
/// addon opcode and no addon `ChatMsg` type: a hunter addon's version ping reaches the client as a
|
||||
/// `CHAT_MSG_PARTY` like any other, and only `language == LANG_ADDON` (`0xFFFFFFFF`) separates it
|
||||
/// from someone talking. Miss that field and it renders — which is exactly what printed
|
||||
/// `[Party] [Soreen]: Quiver VERSION:3.1.4` in a mixed-client party.
|
||||
///
|
||||
/// The bytes below are the shape `addon_chat_probe` observed coming back off the **live** vmangos
|
||||
/// on both lanes it tested (`cargo run -p benilla-protocol --example addon_chat_probe`): the
|
||||
/// reported PARTY lane and the CHANNEL lane. Note what the payload is — `prefix`, a TAB, then the
|
||||
/// addon's own encoding — and that the server passes it through untouched (addon chat skips
|
||||
/// `SanitizeChatMessage` entirely, vmangos `Handlers/ChatHandler.cpp:49`).
|
||||
/// 1.12.1 has no addon opcode or chat type: addon chat is ordinary chat in language `LANG_ADDON`
|
||||
/// (`0xFFFFFFFF`), its payload `prefix`, a TAB and the message, which vmangos passes through
|
||||
/// unsanitized (`Handlers/ChatHandler.cpp:49`). The bytes are what `addon_chat_probe` reads back.
|
||||
#[test]
|
||||
fn addon_chat_is_an_ordinary_type_with_the_sentinel_language() {
|
||||
// PARTY (the reported case): the two-guid shape, language 0xFFFFFFFF, "Quiver\tVERSION:3.1.4".
|
||||
let body = hx("01ffffffff21000000000000002100000000000000150000005175697665720956455253494f4e3a332e312e340000");
|
||||
match messages::parse_server(messages::opcode::SMSG_MESSAGECHAT, &body).unwrap() {
|
||||
ServerPacket::MessageChat(m) => {
|
||||
@@ -455,10 +394,8 @@ fn addon_chat_is_an_ordinary_type_with_the_sentinel_language() {
|
||||
}
|
||||
_ => panic!("expected MessageChat"),
|
||||
}
|
||||
// The control, same lane, same shape: speech must not be caught by a language-keyed gate. The
|
||||
// live server hands party speech back as LANG_UNIVERSAL (0) — `HandleChatMessageOpcode`
|
||||
// rewrites the tongue for GMs and for two-side-enabled lanes, and the one language it never
|
||||
// rewrites is LANG_ADDON (the whole normalisation block sits in that `if`'s `else`).
|
||||
// Party speech as vmangos sends it back: `HandleChatMessageOpcode` rewrites the language to
|
||||
// LANG_UNIVERSAL (0) for GMs and two-side-enabled lanes, and never rewrites LANG_ADDON.
|
||||
let body = hx("01000000002100000000000000210000000000000013000000706172747920636f6e74726f6c206c696e650000");
|
||||
match messages::parse_server(messages::opcode::SMSG_MESSAGECHAT, &body).unwrap() {
|
||||
ServerPacket::MessageChat(m) => {
|
||||
@@ -468,8 +405,7 @@ fn addon_chat_is_an_ordinary_type_with_the_sentinel_language() {
|
||||
}
|
||||
_ => panic!("expected MessageChat"),
|
||||
}
|
||||
// Every tongue a character can actually speak is speech, sentinel or not — the gate is an
|
||||
// equality test against 0xFFFFFFFF, never a range or a "not a known language" test.
|
||||
// Every speakable language is speech: the gate is equality with 0xFFFFFFFF, not a range.
|
||||
for lang in [0, 1, 2, 3, 6, 7, 8, 9, 10, 11, 12, 13, 14, 33] {
|
||||
let m = messages::ChatMessage {
|
||||
chat_type: messages::CHAT_MSG_PARTY,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Client-body + query wire tests (mirrors `src/messages/client.rs`): the golden client-encoded
|
||||
//! bodies (auth/char-create/chat/emote/stand-state/movement/teleport-ack) and the creature/name
|
||||
//! query request+response roundtrip. Split out of the former `tests/messages.rs` (decision-adjacent
|
||||
//! mechanical split — see `tests/common` for the shared fixtures and methodology note).
|
||||
//! The client-built bodies (auth, char create, chat, emotes, channels, movement, ping) and the
|
||||
//! name and creature query replies.
|
||||
|
||||
mod common;
|
||||
|
||||
@@ -11,10 +9,8 @@ use benilla_protocol::wire::Vector3d;
|
||||
use benilla_protocol::ServerPacket;
|
||||
use common::hx;
|
||||
|
||||
/// The faction tongue every chat send must speak ([`messages::faction_language`]): race → tongue
|
||||
/// VERIFIED against the live world DB (`playercreateinfo_spell` — Alliance races 1/3/4/7 learn
|
||||
/// spell 668 Language Common, Horde races 2/5/6/8 learn 669 Language Orcish); wire ids VERIFIED
|
||||
/// vmangos `SharedDefines.h:256-261` (`LANG_ORCISH = 1`, `LANG_COMMON = 7`).
|
||||
/// Alliance races learn Common (spell 668), Horde races Orcish (669) in `playercreateinfo_spell`;
|
||||
/// `LANG_COMMON = 7`, `LANG_ORCISH = 1` (vmangos `SharedDefines.h:256-261`).
|
||||
#[test]
|
||||
fn faction_language_per_race() {
|
||||
for race in [1u8, 3, 4, 7] {
|
||||
@@ -36,9 +32,8 @@ fn faction_language_per_race() {
|
||||
#[test]
|
||||
fn client_bodies_golden() {
|
||||
let proof: [u8; 20] = std::array::from_fn(|i| (i as u8).wrapping_mul(3).wrapping_add(7));
|
||||
// The tail is the addon-info block: `342` uncompressed + the zlib stream of the stock twelve
|
||||
// (decision 1497). Byte-identical to a real 1.12.1.5875 client's — the same 130 compressed
|
||||
// bytes as the 2006 retail capture wow-5875-re verified this against.
|
||||
// The tail is the addon block: `u32` 342 uncompressed, then the zlib stream of the stock
|
||||
// twelve, the same 130 bytes a retail 1.12.1 client sends.
|
||||
assert_eq!(
|
||||
messages::auth_session(
|
||||
5875,
|
||||
@@ -57,13 +52,14 @@ fn client_bodies_golden() {
|
||||
)),
|
||||
"CMSG_AUTH_SESSION body"
|
||||
);
|
||||
// No secure addons: the tail is *absent*, never a zero size (which no real client emits).
|
||||
// No secure addons: the tail is absent, never a zero size, which no real client sends.
|
||||
assert_eq!(
|
||||
messages::auth_session(5875, "TESTUSER", 0x1122_3344, &proof, &[]),
|
||||
hx("f31600000000000054455354555345520044332211070a0d101316191c1f2225282b2e3134373a3d40"),
|
||||
"CMSG_AUTH_SESSION body with no secure addons"
|
||||
);
|
||||
// Zero-appearance body (the create-if-empty starter): name + [race,class,gender] + 5 zeros + 0.
|
||||
// CMSG_CHAR_CREATE in vmangos's read order (`Packets/Character.cpp:4-19`): name, race, class,
|
||||
// gender, skin, face, hairStyle, hairColor, facialHair, outfit.
|
||||
assert_eq!(
|
||||
messages::char_create(&messages::CharCreateReq {
|
||||
name: "Benilla".into(),
|
||||
@@ -79,9 +75,6 @@ fn client_bodies_golden() {
|
||||
hx("42656e696c6c6100010100000000000000"),
|
||||
"CMSG_CHAR_CREATE body (zero appearance)"
|
||||
);
|
||||
// Distinct appearance dials (3/4/5/6/7) — a misplaced byte can't pass. Body is name + nul +
|
||||
// race(01) class(01) gender(00) skin(03) face(04) hairStyle(05) hairColor(06) facialHair(07)
|
||||
// outfit(00), matching vmangos's read order (`Packets/Character.cpp:4-19`).
|
||||
assert_eq!(
|
||||
messages::char_create(&messages::CharCreateReq {
|
||||
name: "Benilla".into(),
|
||||
@@ -102,9 +95,8 @@ fn client_bodies_golden() {
|
||||
hx("00000000070000002e74656c65205765737466616c6c00"),
|
||||
"CMSG_MESSAGECHAT body"
|
||||
);
|
||||
// The three-field vmangos shape (`Misc.cpp:60-65`): textEmote, emoteNum(0), full target
|
||||
// guid. The original two-field body was silently discarded server-side (the guid read ran
|
||||
// off the packet) — /wave did nothing on any client until the director caught it on the ref.
|
||||
// CMSG_TEXT_EMOTE (vmangos `Misc.cpp:60-65`): textEmote, emoteNum (0) and the full target
|
||||
// guid; the server silently drops a body without the guid.
|
||||
assert_eq!(
|
||||
messages::text_emote(101, 0x2A),
|
||||
hx("65000000000000002a00000000000000"),
|
||||
@@ -115,8 +107,7 @@ fn client_bodies_golden() {
|
||||
hx("01000000"),
|
||||
"CMSG_STANDSTATECHANGE body: one u32 animState (Misc.cpp:35-38)"
|
||||
);
|
||||
// The chat-type consts feeding CMSG_MESSAGECHAT's type field (VERIFIED vmangos
|
||||
// `SharedDefines.h:1194..1202`, the 5875 band).
|
||||
// The CMSG_MESSAGECHAT type values (vmangos `SharedDefines.h:1194..1202`).
|
||||
assert_eq!(messages::CHAT_TYPE_SAY, 0x0, "ChatMsg::CHAT_MSG_SAY");
|
||||
assert_eq!(messages::CHAT_TYPE_YELL, 0x5, "ChatMsg::CHAT_MSG_YELL");
|
||||
assert_eq!(
|
||||
@@ -125,7 +116,6 @@ fn client_bodies_golden() {
|
||||
"ChatMsg::CHAT_MSG_WHISPER"
|
||||
);
|
||||
assert_eq!(messages::CHAT_TYPE_EMOTE, 0x8, "ChatMsg::CHAT_MSG_EMOTE");
|
||||
// /yell and /emote share `messagechat`'s body shape, only the type field differs.
|
||||
assert_eq!(
|
||||
messages::messagechat(messages::CHAT_TYPE_YELL, 7, "for the horde"),
|
||||
hx("0500000007000000666f722074686520686f72646500"),
|
||||
@@ -136,9 +126,8 @@ fn client_bodies_golden() {
|
||||
hx("080000000700000064616e63657300"),
|
||||
"CMSG_MESSAGECHAT (emote) body"
|
||||
);
|
||||
// A Horde say speaks Orcish (0x1) in the language field — hardcoded Common here once made
|
||||
// vmangos drop every Horde character's sends at the `KnowsLanguage` gate, dot-commands
|
||||
// included (only an SMSG_NOTIFICATION came back).
|
||||
// A Horde say speaks Orcish (1): vmangos drops a send in an unknown language at its
|
||||
// `KnowsLanguage` gate, dot-commands included, and answers only with SMSG_NOTIFICATION.
|
||||
assert_eq!(
|
||||
messages::messagechat(
|
||||
messages::CHAT_TYPE_SAY,
|
||||
@@ -148,29 +137,27 @@ fn client_bodies_golden() {
|
||||
hx("0000000001000000666f722074686520686f72646500"),
|
||||
"CMSG_MESSAGECHAT (say, Orcish) body"
|
||||
);
|
||||
// CMSG_MESSAGECHAT (whisper): type + language, then the target name, then the message — both
|
||||
// NUL-terminated C-strings, target *before* message (VERIFIED vmangos
|
||||
// `Server/Packets/Chat.cpp:3-12`, `ChatMessage::ReadFromWorldPacket`).
|
||||
// Whisper: type, language, then the target and the message cstrings, target first (vmangos
|
||||
// `Server/Packets/Chat.cpp:3-12`).
|
||||
assert_eq!(
|
||||
messages::messagechat_whisper(7, "Bob", "hi there"),
|
||||
hx("0600000007000000426f6200686920746865726500"),
|
||||
"CMSG_MESSAGECHAT (whisper) body"
|
||||
);
|
||||
// The full 8-byte LE guid body, shared by CMSG_PLAYER_LOGIN / CMSG_SET_ACTIVE_MOVER /
|
||||
// CMSG_SET_SELECTION (all read `recv_data >> guid` server-side — a raw uint64, not a packed guid).
|
||||
// CMSG_PLAYER_LOGIN, CMSG_SET_ACTIVE_MOVER and CMSG_SET_SELECTION all read a raw `u64` guid,
|
||||
// not a packed one.
|
||||
assert_eq!(
|
||||
messages::full_guid(0x1234_5678_9abc_def0),
|
||||
hx("f0debc9a78563412"),
|
||||
"full guid body"
|
||||
);
|
||||
// CMSG_SET_SELECTION opcode value (317 / 0x013D) — verified vmangos `Opcodes_1_12_1.h`.
|
||||
// CMSG_SET_SELECTION is opcode 317 (vmangos `Opcodes_1_12_1.h`).
|
||||
assert_eq!(
|
||||
messages::opcode::CMSG_SET_SELECTION,
|
||||
0x013D,
|
||||
"CMSG_SET_SELECTION opcode"
|
||||
);
|
||||
// CMSG_INSPECT (276 / 0x0114) — verified vmangos `Opcodes_1_12_1.h`; body is the same raw
|
||||
// 8-byte guid (`WorldPackets::Misc::Inspect`), NOT a packed guid. Decision 0631.
|
||||
// CMSG_INSPECT is opcode 276 (vmangos `Opcodes_1_12_1.h`), its body a raw guid, not packed.
|
||||
assert_eq!(
|
||||
messages::opcode::CMSG_INSPECT,
|
||||
0x0114,
|
||||
@@ -207,12 +194,9 @@ fn client_bodies_golden() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The full sendable `CMSG_MESSAGECHAT` type set beyond the original four (decision 0288 phase 1) —
|
||||
/// every one VERIFIED against vmangos `Handlers/ChatHandler.cpp`'s `HandleChatMessageOpcode` switch
|
||||
/// (253-655): PARTY/RAID/GUILD/OFFICER/RAID_LEADER/RAID_WARNING/BATTLEGROUND(+LEADER)/AFK/DND all
|
||||
/// share [`messages::messagechat`]'s plain shape (no target field); CHANNEL carries the channel name
|
||||
/// the same way WHISPER carries its target (`messages::messagechat_channel`). Bytes hand-computed
|
||||
/// from the vmangos layout (`Server/Packets/Chat.cpp:3-12`), independent of the Rust builder.
|
||||
/// The other sendable `CMSG_MESSAGECHAT` types (vmangos `Handlers/ChatHandler.cpp:253-655`) use
|
||||
/// the plain shape, except CHANNEL, which carries its channel name where WHISPER carries its
|
||||
/// target (`Server/Packets/Chat.cpp:3-12`).
|
||||
#[test]
|
||||
fn messagechat_sendable_types_golden() {
|
||||
assert_eq!(messages::CHAT_TYPE_PARTY, 0x1, "ChatMsg::CHAT_MSG_PARTY");
|
||||
@@ -307,15 +291,11 @@ fn messagechat_sendable_types_golden() {
|
||||
hx("1500000007000000646f206e6f74206469737475726200"),
|
||||
"CMSG_MESSAGECHAT (dnd) body"
|
||||
);
|
||||
// CHANNEL: type + language, then the channel name, then the message — the same
|
||||
// target-before-message shape as whisper (vmangos's `whisperTargetOrChannel` union).
|
||||
assert_eq!(
|
||||
messages::messagechat_channel(0, "General", "wtb boar livers"),
|
||||
hx("0e0000000000000047656e6572616c0077746220626f6172206c697665727300"),
|
||||
"CMSG_MESSAGECHAT (channel) body"
|
||||
);
|
||||
// The generic builder both named wrappers are thin shells over: `target: None` matches
|
||||
// `messagechat`, `target: Some(_)` matches `messagechat_whisper`/`messagechat_channel`.
|
||||
assert_eq!(
|
||||
messages::messagechat_kind(messages::CHAT_TYPE_SAY, 7, None, "hi"),
|
||||
messages::messagechat(messages::CHAT_TYPE_SAY, 7, "hi"),
|
||||
@@ -328,38 +308,18 @@ fn messagechat_sendable_types_golden() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The addon broadcast body** (`SendAddonMessage`, decision 1235) — `CMSG_MESSAGECHAT` on one of
|
||||
/// four ordinary lanes with the `LANG_ADDON` sentinel in the language field. Bytes hand-computed
|
||||
/// from the layout, independent of the Rust builder.
|
||||
///
|
||||
/// VERIFIED in `WoW.exe` (5875) — wow-re `system/ui/scratch/addon-chat-law.md` §5, the binding
|
||||
/// `0x49f920`: opcode `0x95` (`0x49facf`), u32 chat type (`0x49fad8`), u32 language from
|
||||
/// `or ebx,-0x1` (`0x49fab9`) written at `0x49fae1`, then the message CString (`0x49faf0`). There
|
||||
/// is **no prefix field and no target field on the wire** — the prefix is glued to the message
|
||||
/// with a literal TAB (`_snprintf(dst, 0x800, "%s\t%s", …)` at `0x49f9b3`, format `0x844b5c`, raw
|
||||
/// `25 73 09 25 73 00`) and the pair rides as one C-string. The far client splits on the FIRST tab
|
||||
/// (`0x49a8d0`).
|
||||
///
|
||||
/// Corroborated end-to-end against a live vmangos by `examples/addon_chat_probe` (decision 1029):
|
||||
/// the sentinel comes back intact and the `0x09` survives (addon chat skips `SanitizeChatMessage`,
|
||||
/// `Handlers/ChatHandler.cpp:49`).
|
||||
/// `SendAddonMessage` (reference `0x49f920`) sends `CMSG_MESSAGECHAT` on one of four lanes: `u32`
|
||||
/// type, `u32` language `LANG_ADDON`, then one cstring of prefix, TAB and message (`0x49f9b3`);
|
||||
/// there is no prefix or target field. The receiver splits on the first TAB (`0x49a8d0`).
|
||||
#[test]
|
||||
fn addon_message_bodies_golden() {
|
||||
// `LANG_ADDON` — the whole discriminator. Not a tongue, and never rewritten by the server.
|
||||
assert_eq!(
|
||||
messages::LANGUAGE_ADDON,
|
||||
0xFFFF_FFFF,
|
||||
"LANG_ADDON (vmangos SharedDefines.h:270)"
|
||||
);
|
||||
|
||||
// oRA2 `Core.lua:563`, verbatim: SendAddonMessage("CTRA", msg, "RAID") — the corpus's one
|
||||
// live caller of this verb.
|
||||
// 02 00 00 00 CHAT_MSG_RAID
|
||||
// ff ff ff ff LANG_ADDON
|
||||
// 43 54 52 41 "CTRA"
|
||||
// 09 TAB — the prefix/message separator
|
||||
// 73 74 61 74 75 73 "status"
|
||||
// 00 the message C-string's NUL
|
||||
// oRA2's `SendAddonMessage("CTRA", msg, "RAID")` (`Core.lua:563`).
|
||||
assert_eq!(
|
||||
messages::messagechat(
|
||||
messages::CHAT_TYPE_RAID,
|
||||
@@ -370,8 +330,7 @@ fn addon_message_bodies_golden() {
|
||||
"CMSG_MESSAGECHAT (addon, RAID) body"
|
||||
);
|
||||
|
||||
// The other three lanes of the client's four-value whitelist (`0x49fa3f`-`0x49fa4e`). Only the
|
||||
// type byte moves; the sentinel and the tab-composed payload are identical.
|
||||
// The other lanes of the reference's four-lane whitelist (`0x49fa3f`-`0x49fa4e`).
|
||||
assert_eq!(
|
||||
messages::messagechat(
|
||||
messages::CHAT_TYPE_PARTY,
|
||||
@@ -400,12 +359,8 @@ fn addon_message_bodies_golden() {
|
||||
"CMSG_MESSAGECHAT (addon, BATTLEGROUND) body"
|
||||
);
|
||||
|
||||
// AceEvent-2.0.lua's own line — `SendAddonMessage("LOOT_OPENED", "", "RAID")`, the call
|
||||
// replicated in 24 of the 218 corpus addons. **The empty message still carries its tab**: the
|
||||
// composition is unconditional, so the payload is `"LOOT_OPENED\t"` and the body ends TAB, NUL.
|
||||
// A builder that "helpfully" dropped a trailing separator would make the far client read
|
||||
// prefix `"LOOT_OPENED"` with no message at all — which is the same thing, but only by
|
||||
// accident of the receiver's no-tab rule; the bytes must still match the reference's.
|
||||
// An empty message still carries its TAB: the reference composes `"%s\t%s"` unconditionally,
|
||||
// so AceEvent-2.0's `SendAddonMessage("LOOT_OPENED", "", "RAID")` ends TAB, NUL.
|
||||
assert_eq!(
|
||||
messages::messagechat(
|
||||
messages::CHAT_TYPE_RAID,
|
||||
@@ -416,9 +371,6 @@ fn addon_message_bodies_golden() {
|
||||
"CMSG_MESSAGECHAT (addon, empty message keeps its TAB) body"
|
||||
);
|
||||
|
||||
// An addon line is byte-identical to ordinary speech on the same lane EXCEPT the language
|
||||
// field — the property the receive-side gate rests on, pinned so a future refactor cannot
|
||||
// quietly reintroduce a tongue here.
|
||||
let addon = messages::messagechat(messages::CHAT_TYPE_PARTY, messages::LANGUAGE_ADDON, "x\ty");
|
||||
let speech =
|
||||
messages::messagechat(messages::CHAT_TYPE_PARTY, messages::LANGUAGE_COMMON, "x\ty");
|
||||
@@ -429,10 +381,8 @@ fn addon_message_bodies_golden() {
|
||||
assert_eq!(&speech[4..8], &[0x07, 0x00, 0x00, 0x00], "LANG_COMMON");
|
||||
}
|
||||
|
||||
/// The channel wire family's CMSG bodies (decision 0288 phase 1) — join/leave/list + the full
|
||||
/// moderation set, ALL verified vmangos `Server/Packets/Channel.cpp` (every `ReadFromWorldPacket`
|
||||
/// there is a channel-name cstring, optionally followed by a second cstring: a password or a target
|
||||
/// player name). Bytes hand-computed from that layout, independent of the Rust builder.
|
||||
/// The channel CMSG bodies: a channel-name cstring, then optionally a password or target-name
|
||||
/// cstring (vmangos `Server/Packets/Channel.cpp`).
|
||||
#[test]
|
||||
fn channel_client_bodies_golden() {
|
||||
assert_eq!(
|
||||
@@ -521,8 +471,7 @@ fn channel_client_bodies_golden() {
|
||||
"CMSG_CHANNEL_MODERATE body"
|
||||
);
|
||||
|
||||
// Every opcode in the family (VERIFIED vmangos `Server/Protocol/Opcodes_1_12_1.h:154-171`,
|
||||
// decimal 151-168).
|
||||
// The family's opcodes, 151 to 168 (vmangos `Server/Protocol/Opcodes_1_12_1.h:154-171`).
|
||||
assert_eq!(messages::opcode::CMSG_JOIN_CHANNEL, 0x0097);
|
||||
assert_eq!(messages::opcode::CMSG_LEAVE_CHANNEL, 0x0098);
|
||||
assert_eq!(messages::opcode::SMSG_CHANNEL_NOTIFY, 0x0099);
|
||||
@@ -543,11 +492,9 @@ fn channel_client_bodies_golden() {
|
||||
assert_eq!(messages::opcode::CMSG_CHANNEL_MODERATE, 0x00A8);
|
||||
}
|
||||
|
||||
/// The remaining decision-0288 phase-1 small bodies: `CMSG_CHAT_IGNORED` (reuses
|
||||
/// [`messages::full_guid`] — VERIFIED vmangos `WorldPackets::Misc::ChatIgnored::
|
||||
/// ReadFromWorldPacket`, `Server/Packets/Misc.cpp:127-130`, a raw un-packed guid), `CMSG_PLAYED_TIME`
|
||||
/// (empty), and `MSG_RANDOM_ROLL`'s client→server request shape (VERIFIED vmangos
|
||||
/// `WorldPackets::Group::RandomRoll::ReadFromWorldPacket`, `Server/Packets/Group.cpp:39-43`).
|
||||
/// `CMSG_CHAT_IGNORED` is a raw guid (vmangos `Server/Packets/Misc.cpp:127-130`),
|
||||
/// `CMSG_PLAYED_TIME` is empty, and the `MSG_RANDOM_ROLL` request is `u32` min and max
|
||||
/// (`Server/Packets/Group.cpp:39-43`).
|
||||
#[test]
|
||||
fn chat_ignored_played_time_random_roll_golden() {
|
||||
assert_eq!(
|
||||
@@ -575,15 +522,15 @@ fn chat_ignored_played_time_random_roll_golden() {
|
||||
|
||||
#[test]
|
||||
fn name_and_creature_query_roundtrip() {
|
||||
// CMSG_CREATURE_QUERY body: entry u32 + full 8-byte guid (vmangos QueryCreature::ReadFromWorldPacket).
|
||||
// CMSG_CREATURE_QUERY: `u32` entry and the full guid (vmangos `QueryCreature`).
|
||||
assert_eq!(
|
||||
messages::creature_query(69, 0x1234_5678_9abc_def0),
|
||||
hx("45000000f0debc9a78563412"),
|
||||
"CMSG_CREATURE_QUERY body"
|
||||
);
|
||||
|
||||
// SMSG_NAME_QUERY_RESPONSE: guid, name, realm (empty), race/gender/class u32s (vmangos
|
||||
// NameQueryResponse::AppendBodyTo, 1.12.1 includes the realm string).
|
||||
// SMSG_NAME_QUERY_RESPONSE (vmangos `NameQueryResponse::AppendBodyTo`): guid, name, an empty
|
||||
// realm cstring, then race, gender and class as `u32`.
|
||||
let body = hx("070000000000000042656e696c6c610000010000000000000001000000");
|
||||
match messages::parse_server(messages::opcode::SMSG_NAME_QUERY_RESPONSE, &body).unwrap() {
|
||||
ServerPacket::NameQueryResponse {
|
||||
@@ -600,14 +547,8 @@ fn name_and_creature_query_roundtrip() {
|
||||
_ => panic!("name query response"),
|
||||
}
|
||||
|
||||
// SMSG_CREATURE_QUERY_RESPONSE hit: entry, name, 3 empty names, subname, the 7-u32 tail
|
||||
// (type_flags, TYPE — the TAB critter filter's input — FAMILY, RANK, unk, pet_spell_id,
|
||||
// display_id), 2 u8 tail. Family and rank are given DIFFERENT non-zero values on purpose:
|
||||
// they are adjacent dwords, so a one-column slip between them reads as plausible data and
|
||||
// shows up only as a pet whose level line names the wrong beast (decision 1062). `display_id`
|
||||
// is non-zero for the same reason (decision 1676): it sits behind two alignment-only dwords
|
||||
// that were zeros here, so a slip into either read 0 — indistinguishable from "no model" —
|
||||
// and the stable window would have drawn an empty booth for every pet.
|
||||
// A hit. Family, rank and display id get distinct non-zero values so a one-dword slip between
|
||||
// adjacent fields cannot read as plausible data.
|
||||
let body = hx(concat!(
|
||||
"45000000",
|
||||
"596f756e6720576f6c6600", // "Young Wolf"
|
||||
@@ -618,7 +559,7 @@ fn name_and_creature_query_roundtrip() {
|
||||
"01000000", // pet_family = 1 (Wolf)
|
||||
"02000000", // rank = 2 (rare elite)
|
||||
"0000000000000000", // unk, pet_spell_list_id
|
||||
"15020000", // display_id = 533 — the model a stabled pet is drawn from
|
||||
"15020000", // display_id = 533, the model a stabled pet is drawn from
|
||||
"0101" // civilian, racial_leader
|
||||
));
|
||||
match messages::parse_server(messages::opcode::SMSG_CREATURE_QUERY_RESPONSE, &body).unwrap() {
|
||||
@@ -653,10 +594,8 @@ fn name_and_creature_query_roundtrip() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The event layer's subname law: an EMPTY wire subname is NO subname (vmangos sends "" for
|
||||
/// creatures without one; the real client renders no line for it — the nameplate builder's
|
||||
/// verified shape, `0x608f50`). `Some("")` reaching a consumer painted an empty tooltip line
|
||||
/// whose zero-extent chain slot spilled every later line below the plate.
|
||||
/// An empty wire subname is no subname: vmangos sends "" for a creature without one, and the
|
||||
/// reference renders no line for it (`0x608f50`).
|
||||
#[test]
|
||||
fn creature_query_empty_subname_decodes_to_none() {
|
||||
let body = |subname_hex: &str| {
|
||||
@@ -684,7 +623,6 @@ fn creature_query_empty_subname_decodes_to_none() {
|
||||
}
|
||||
other => panic!("expected one CreatureName event, got {other:?}"),
|
||||
}
|
||||
// Control: a real subname survives verbatim.
|
||||
match decode(parse(&body("5465737400"))).as_slice() {
|
||||
[SessionEvent::CreatureName { subname, .. }] => {
|
||||
assert_eq!(subname.as_deref(), Some("Test"));
|
||||
@@ -693,10 +631,8 @@ fn creature_query_empty_subname_decodes_to_none() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The keepalive pair, byte-exact both ways: the `CMSG_PING` body is `{u32 sequence, u32 lastRtt}`
|
||||
/// LE (VERIFIED wow-re net W1 `SendPing 0x537e10` + vmangos `_HandlePing`'s read order), and the
|
||||
/// `SMSG_PONG` echo (`{u32 sequence}`) parses and decodes to the event the io layer times against
|
||||
/// its ping clock. A new wire body never lands without a golden (method).
|
||||
/// `CMSG_PING` is `u32` sequence then `u32` last RTT (reference `0x537e10`, vmangos
|
||||
/// `_HandlePing`); `SMSG_PONG` echoes the sequence.
|
||||
#[test]
|
||||
fn ping_body_golden_and_pong_roundtrip() {
|
||||
assert_eq!(
|
||||
@@ -724,13 +660,9 @@ fn ping_body_golden_and_pong_roundtrip() {
|
||||
}
|
||||
}
|
||||
|
||||
/// **`SMSG_ADDON_INFO`, against the retail capture's own bytes** (decision 2175).
|
||||
///
|
||||
/// The reply carries no count and no names: the client re-walks the `## Secure:` list it sent and
|
||||
/// reads one record per addon. The 2006 capture is the minimal form — 96 bytes, 12 x
|
||||
/// `{status = 2, infoProvided = 1, keyProvided = 0, revision = 0u32, urlProvided = 0}` — and
|
||||
/// `status = 2` is what sets `[rec+0x29] = 1` (`0x51db84`) and drops the addon from the Lua index
|
||||
/// space (wow-re `system/net/scratch/cmsg-auth-session-addon-block.md` §6).
|
||||
/// `SMSG_ADDON_INFO` has no count or names: the client reads one record per addon it sent. A
|
||||
/// retail capture is 12 records of `{status 2, info 1, key 0, u32 revision 0, url 0}`; status 2
|
||||
/// hides the addon from the Lua index (reference `0x51db84`).
|
||||
#[test]
|
||||
fn the_addon_info_reply_pairs_its_statuses_back_against_what_we_sent() {
|
||||
let capture: Vec<u8> = std::iter::repeat_n(hx("0201000000000000"), 12)
|
||||
@@ -743,8 +675,7 @@ fn the_addon_info_reply_pairs_its_statuses_back_against_what_we_sent() {
|
||||
};
|
||||
assert_eq!(statuses, vec![2u8; 12]);
|
||||
|
||||
// Paired back against the block we send, every stock addon is hidden from the index space —
|
||||
// which is why the reference's AddOns list shows the player's addons and none of Blizzard's.
|
||||
// Every stock addon comes back hidden, so the reference's AddOns list shows none of Blizzard's.
|
||||
let hidden = messages::hidden_from_reply(&statuses, &messages::STOCK_SECURE_ADDONS);
|
||||
assert_eq!(hidden.len(), 12);
|
||||
assert!(
|
||||
@@ -752,8 +683,8 @@ fn the_addon_info_reply_pairs_its_statuses_back_against_what_we_sent() {
|
||||
"{hidden:?}"
|
||||
);
|
||||
|
||||
// A record that says it carries a key and a url is 8 + 256 + 256 bytes, and `status = 1`
|
||||
// leaves the addon VISIBLE — the exclusion is `2` alone, not "the server said something".
|
||||
// A record with a key and a url is 8 + 256 + 256 bytes; status 1 leaves the addon visible,
|
||||
// only status 2 hides it.
|
||||
let mut fat = vec![1u8, 1, 1];
|
||||
fat.extend(std::iter::repeat_n(0xABu8, 256)); // modulus
|
||||
fat.extend([0u8; 4]); // revision
|
||||
@@ -766,8 +697,6 @@ fn the_addon_info_reply_pairs_its_statuses_back_against_what_we_sent() {
|
||||
assert_eq!(statuses, vec![1u8], "the fat record parsed whole");
|
||||
assert!(messages::hidden_from_reply(&statuses, &messages::STOCK_SECURE_ADDONS).is_empty());
|
||||
|
||||
// A truncated record contributes nothing, and the whole ones before it survive — the pairing
|
||||
// against what we sent cannot slip.
|
||||
let mut short = hx("0201000000000000").to_vec();
|
||||
short.extend([2u8, 1]); // a second record that stops mid-way
|
||||
let packet = messages::parse_server(messages::opcode::SMSG_ADDON_INFO, &short).unwrap();
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
//! Shared fixtures for the world message layer's oracle-free regression tests (split by domain
|
||||
//! across `tests/*.rs`). Client packet bodies are pinned to golden hex captured from the validated
|
||||
//! implementation (byte-validated against `wow_world_messages` during the decision-0021 migration);
|
||||
//! `SMSG_UPDATE_OBJECT` fixtures are real serialized packet bodies from that same corpus, parsed +
|
||||
//! decoded here. Simple server bodies are hand-built (their layout is trivial: a few little-endian
|
||||
//! scalars).
|
||||
//! Shared fixtures for the protocol tests: `hx` decodes the hex of a golden packet body.
|
||||
|
||||
pub fn hx(s: &str) -> Vec<u8> {
|
||||
(0..s.len())
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
//! Oracle-free golden tests for the gossip/vendor interaction arc's protocol layer (Phase 1 of the
|
||||
//! gossip/vendor arc): `CMSG_GOSSIP_HELLO`/`SELECT_OPTION`, `CMSG_NPC_TEXT_QUERY`, and the vendor
|
||||
//! `LIST_INVENTORY`/`BUY_ITEM`/`SELL_ITEM` family. Split out from `messages.rs` (same idioms —
|
||||
//! `hx(...)` golden CMSG bodies, hand-built SMSG bodies round-tripped through `parse_server`) since
|
||||
//! it's a self-contained wire family or size.
|
||||
//! The gossip and vendor wire: hello, option select, NPC text and the greeting draw, and the vendor
|
||||
//! inventory, buy and sell messages.
|
||||
|
||||
use benilla_protocol::events::{decode, SessionEvent};
|
||||
use benilla_protocol::messages;
|
||||
@@ -17,8 +14,7 @@ fn hx(s: &str) -> Vec<u8> {
|
||||
|
||||
#[test]
|
||||
fn gossip_bodies_golden() {
|
||||
// CMSG_GOSSIP_HELLO (vmangos Npc.cpp:3) / CMSG_LIST_INVENTORY (Item.cpp:94) share the same
|
||||
// full-guid body shape.
|
||||
// CMSG_GOSSIP_HELLO (vmangos Npc.cpp:3): a full guid.
|
||||
assert_eq!(
|
||||
messages::gossip_hello(0x1234_5678_9abc_def0),
|
||||
hx("f0debc9a78563412"),
|
||||
@@ -32,9 +28,8 @@ fn gossip_bodies_golden() {
|
||||
"CMSG_NPC_TEXT_QUERY body"
|
||||
);
|
||||
|
||||
// CMSG_GOSSIP_SELECT_OPTION (vmangos Npc.cpp:78-86): guid, gossipListId, then an optional
|
||||
// trailing code cstring — appended only for a coded option with a real code, omitted entirely
|
||||
// otherwise (the server reads it only when the buffer is non-empty).
|
||||
// CMSG_GOSSIP_SELECT_OPTION (vmangos Npc.cpp:78-86): guid, gossipListId, then a code cstring
|
||||
// only for a coded option; the server reads it only when bytes remain.
|
||||
assert_eq!(
|
||||
messages::gossip_select_option(0x1234_5678_9abc_def0, 3, None),
|
||||
hx(concat!("f0debc9a78563412", "03000000")),
|
||||
@@ -58,8 +53,7 @@ fn vendor_bodies_golden() {
|
||||
"CMSG_LIST_INVENTORY body"
|
||||
);
|
||||
|
||||
// CMSG_BUY_ITEM (vmangos Item.cpp:104-110): vendorGuid, item ENTRY (not muid), count, unk1(0).
|
||||
// entry 2488 is the live-verified creature-54 vendor's Gladius row (decision 0081).
|
||||
// CMSG_BUY_ITEM (vmangos Item.cpp:104-110): vendorGuid, item entry (not muid), count, unk1 (0).
|
||||
assert_eq!(
|
||||
messages::buy_item(0x1234_5678_9abc_def0, 2488, 1),
|
||||
hx(concat!("f0debc9a78563412", "b8090000", "01", "00")),
|
||||
@@ -78,9 +72,7 @@ fn vendor_bodies_golden() {
|
||||
fn gossip_message_wire() {
|
||||
use benilla_protocol::messages::{GossipOption, QuestOption};
|
||||
|
||||
// SMSG_GOSSIP_MESSAGE (vmangos GossipDef.cpp:180-225, the 1.12 shape): objectGuid, textId,
|
||||
// optionCount + options (index, icon, coded, message), questOptionCount + quest options. Zero
|
||||
// quest options — the common case for a pure vendor/gossip NPC.
|
||||
// SMSG_GOSSIP_MESSAGE (vmangos GossipDef.cpp:180-225), with no quest options.
|
||||
let mut body = 0xAAu64.to_le_bytes().to_vec(); // objectGuid
|
||||
body.extend_from_slice(&100u32.to_le_bytes()); // textId
|
||||
body.extend_from_slice(&2u32.to_le_bytes()); // optionCount
|
||||
@@ -124,7 +116,6 @@ fn gossip_message_wire() {
|
||||
other => panic!("gossip message, got {}", other.name()),
|
||||
}
|
||||
|
||||
// decode() surfaces a GossipMenu event carrying the (empty here) quest block.
|
||||
let packet = messages::parse_server(messages::opcode::SMSG_GOSSIP_MESSAGE, &body).unwrap();
|
||||
match decode(packet).pop().unwrap() {
|
||||
SessionEvent::GossipMenu {
|
||||
@@ -139,8 +130,6 @@ fn gossip_message_wire() {
|
||||
other => panic!("gossip menu event, got {other:?}"),
|
||||
}
|
||||
|
||||
// Nonzero quest options: parsed for byte alignment, kept on the wire struct (arc ignores them
|
||||
// downstream — quest-giver flows are out of scope, decision 0081).
|
||||
let mut body_q = 0xBBu64.to_le_bytes().to_vec();
|
||||
body_q.extend_from_slice(&200u32.to_le_bytes()); // textId
|
||||
body_q.extend_from_slice(&0u32.to_le_bytes()); // optionCount: 0
|
||||
@@ -172,7 +161,6 @@ fn gossip_message_wire() {
|
||||
other => panic!("gossip message with quests, got {}", other.name()),
|
||||
}
|
||||
|
||||
// SMSG_GOSSIP_COMPLETE — empty body.
|
||||
match messages::parse_server(messages::opcode::SMSG_GOSSIP_COMPLETE, &[]).unwrap() {
|
||||
ServerPacket::GossipComplete => {}
|
||||
other => panic!("gossip complete, got {}", other.name()),
|
||||
@@ -184,10 +172,8 @@ fn gossip_message_wire() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_GOSSIP_POI` — the guard's directions marker (vmangos `GossipDef.cpp:239-295`):
|
||||
/// `u32 flags, f32 x, f32 y, u32 icon, u32 data, cstr name`. The golden body is a REAL row of the
|
||||
/// 5875-era `points_of_interest` table (entry 658, "Lion's Pride Inn": flags 99, icon 6 =
|
||||
/// `ICON_POI_REDFLAG`, data 0) — the shape every guard direction actually takes.
|
||||
/// `SMSG_GOSSIP_POI`, a guard's directions marker (vmangos `GossipDef.cpp:239-295`); the body is
|
||||
/// `points_of_interest` row 658, "Lion's Pride Inn".
|
||||
#[test]
|
||||
fn gossip_poi_wire_and_event() {
|
||||
let body = hx(concat!(
|
||||
@@ -235,7 +221,7 @@ fn npc_text_update_greeting_extraction() {
|
||||
}
|
||||
}
|
||||
|
||||
// The wire carries all 8 blocks through UNDECIDED — the parse layer no longer picks.
|
||||
// The parse carries all 8 blocks; `select_greeting` picks one.
|
||||
let mut body = 321u32.to_le_bytes().to_vec(); // textID
|
||||
block(&mut body, 0.1, "Low probability greeting", "");
|
||||
block(&mut body, 0.0, "", "");
|
||||
@@ -268,9 +254,8 @@ fn npc_text_update_greeting_extraction() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The greeting DRAW (wow-re `gossip-npctext-law.md`, reference `0x4e2010`) — a weighted random
|
||||
/// pick over the blocks whose chosen gender column is non-empty. Every branch pinned with an
|
||||
/// explicit roll, because all three rules here are the opposite of what this client used to do.
|
||||
/// The greeting draw (reference `0x4e2010`): a weighted random pick over the blocks whose chosen
|
||||
/// gender column is non-empty.
|
||||
#[test]
|
||||
fn greeting_draw_follows_the_weighted_law() {
|
||||
use benilla_protocol::messages::{select_greeting, NpcTextBlock};
|
||||
@@ -280,27 +265,24 @@ fn greeting_draw_follows_the_weighted_law() {
|
||||
male: male.into(),
|
||||
female: female.into(),
|
||||
};
|
||||
// roll ∈ [1, 2) ⇒ thr = (2 - roll) * sum ∈ (0, sum]. roll = 1.0 is the far end (thr = sum),
|
||||
// roll → 2.0 the near end (thr → 0).
|
||||
// The threshold is (2 - roll) * sum for roll in [1, 2): FAR gives the sum, NEAR nearly 0.
|
||||
const NEAR: f32 = 1.999_999_9;
|
||||
const FAR: f32 = 1.0;
|
||||
|
||||
// 1 · The all-zero record — vmangos's fallback, and much of the 1.12 table. sum = 0 ⇒ thr = 0,
|
||||
// and the predicate is `<=`, so block 0 wins on EVERY roll rather than nothing winning.
|
||||
// An all-zero record, vmangos's fallback: the threshold is 0 and the test is `<=`, so block 0
|
||||
// wins on every roll.
|
||||
let zeros: Vec<_> = (0..8).map(|i| b(0.0, &format!("line {i}"), "")).collect();
|
||||
for roll in [FAR, 1.5, NEAR] {
|
||||
assert_eq!(select_greeting(&zeros, 0, roll), Some("line 0"));
|
||||
}
|
||||
|
||||
// 2 · Real weights: the draw walks in order, so a near-zero threshold takes the first non-empty
|
||||
// block and a threshold of the full sum takes the last. The old rule — highest probability
|
||||
// wins — would have answered "big" for both.
|
||||
// The draw walks in order: a near-zero threshold takes the first non-empty block, the full
|
||||
// sum the last.
|
||||
let weighted = vec![b(0.1, "small", ""), b(0.8, "big", ""), b(0.1, "last", "")];
|
||||
assert_eq!(select_greeting(&weighted, 0, NEAR), Some("small"));
|
||||
assert_eq!(select_greeting(&weighted, 0, FAR), Some("last"));
|
||||
|
||||
// 3 · The column is the NPC's gender, chosen once. Genderless (2) reads as male, because the
|
||||
// reference tests `== 1`, not `!= 0`.
|
||||
// Genderless (2) reads as male: the reference tests the gender `== 1`, not `!= 0`.
|
||||
let gendered = vec![b(1.0, "sir", "madam")];
|
||||
assert_eq!(select_greeting(&gendered, 0, FAR), Some("sir"));
|
||||
assert_eq!(select_greeting(&gendered, 1, FAR), Some("madam"));
|
||||
@@ -310,9 +292,8 @@ fn greeting_draw_follows_the_weighted_law() {
|
||||
"genderless"
|
||||
);
|
||||
|
||||
// 4 · No fallback to the other column. A female NPC skips male-only blocks outright — she does
|
||||
// NOT borrow their text (this client used to) — and if that empties the record she gets no
|
||||
// greeting at all, which is the reference's "Missing gossip text!" path.
|
||||
// No fallback to the other column: a female NPC skips male-only blocks, and if none remain she
|
||||
// gets no greeting, the reference's "Missing gossip text!" path.
|
||||
let male_only = vec![b(1.0, "men only", ""), b(1.0, "", "ladies")];
|
||||
assert_eq!(select_greeting(&male_only, 1, FAR), Some("ladies"));
|
||||
assert_eq!(select_greeting(&male_only, 1, NEAR), Some("ladies"));
|
||||
@@ -324,11 +305,8 @@ fn greeting_draw_follows_the_weighted_law() {
|
||||
fn vendor_list_inventory_wire() {
|
||||
use benilla_protocol::messages::VendorItem;
|
||||
|
||||
// SMSG_LIST_INVENTORY (vmangos ItemHandler.cpp:741-810): vendorGuid, u8 count, count x {muid,
|
||||
// entry, displayId, currentCount, price, maxDurability, buyCount}. Two rows keyed off the
|
||||
// live-verified creature-54 weapon vendor data (decision 0081): Gladius (entry
|
||||
// 2488, price 536, display 22078, maxcount 0 -> unlimited) and a limited-stock second row.
|
||||
// Trailing junk proves the parser stops reading rows at `count`.
|
||||
// SMSG_LIST_INVENTORY (vmangos ItemHandler.cpp:741-810): vendorGuid, `u8` count, then per row
|
||||
// `u32` muid, entry, displayId, currentCount, price, maxDurability and buyCount.
|
||||
let mut body = 0xCCu64.to_le_bytes().to_vec();
|
||||
body.push(2); // count
|
||||
for v in [1u32, 2488, 22078, 0xFFFF_FFFF, 536, 35, 1] {
|
||||
@@ -377,8 +355,7 @@ fn vendor_list_inventory_wire() {
|
||||
other => panic!("vendor inventory event, got {other:?}"),
|
||||
}
|
||||
|
||||
// Empty stock: count = 0 followed by the trailing error byte (ItemHandler.cpp:728-733,
|
||||
// 806-809) — consumed explicitly, so the decode leaves no tail to report.
|
||||
// Empty stock: count 0, then an error byte (vmangos ItemHandler.cpp:728-733, 806-809).
|
||||
let mut empty_body = 0xDDu64.to_le_bytes().to_vec();
|
||||
empty_body.push(0); // count
|
||||
empty_body.push(0); // trailing errorByte(0)
|
||||
@@ -393,14 +370,13 @@ fn vendor_list_inventory_wire() {
|
||||
}
|
||||
other => panic!("empty vendor list, got {}", other.name()),
|
||||
}
|
||||
// …and the byte is REQUIRED: a count of 0 with nothing after it is the short read it is.
|
||||
// The error byte is required: count 0 with nothing after it is a short read.
|
||||
let truncated = &empty_body[..empty_body.len() - 1];
|
||||
assert!(messages::parse_server(messages::opcode::SMSG_LIST_INVENTORY, truncated).is_err());
|
||||
}
|
||||
|
||||
/// **The falsifier for decision 2265 §B1** on a `u32`-counted list: an option count of
|
||||
/// `0xFFFF_FFFF` followed by one valid option must come back as a short-read `Err` — the decoder
|
||||
/// must neither reserve the count nor abort.
|
||||
/// A `u32` option count of `0xFFFF_FFFF` over one valid option is a short-read `Err`: the decoder
|
||||
/// neither reserves the count nor aborts.
|
||||
#[test]
|
||||
fn a_lying_gossip_option_count_is_a_short_read_not_an_allocation() {
|
||||
let mut body = 0x77u64.to_le_bytes().to_vec(); // npc guid
|
||||
@@ -420,8 +396,8 @@ fn a_lying_gossip_option_count_is_a_short_read_not_an_allocation() {
|
||||
fn vendor_buy_sell_result_wire() {
|
||||
use benilla_protocol::messages::{buy_result, sell_result};
|
||||
|
||||
// SMSG_BUY_ITEM (vmangos Item.cpp:190-196): vendorGuid, vendorSlot (1-based), newCount
|
||||
// (0xFFFF_FFFF unlimited), purchaseCount. Only the vendor stock display changes here.
|
||||
// SMSG_BUY_ITEM (vmangos Item.cpp:190-196): vendorGuid, 1-based vendorSlot, newCount
|
||||
// (0xFFFF_FFFF unlimited), purchaseCount.
|
||||
let mut body = 0xCCu64.to_le_bytes().to_vec();
|
||||
for v in [1u32, 0xFFFF_FFFF, 1] {
|
||||
body.extend_from_slice(&v.to_le_bytes());
|
||||
@@ -448,7 +424,7 @@ fn vendor_buy_sell_result_wire() {
|
||||
other => panic!("vendor buy result event, got {other:?}"),
|
||||
}
|
||||
|
||||
// SMSG_SELL_ITEM (vmangos Item.cpp:183-188) — the error path only: vendorGuid, itemGuid, reason.
|
||||
// SMSG_SELL_ITEM (vmangos Item.cpp:183-188), the error path only: vendorGuid, itemGuid, reason.
|
||||
let mut body = 0xCCu64.to_le_bytes().to_vec();
|
||||
body.extend_from_slice(&0x42u64.to_le_bytes());
|
||||
body.push(sell_result::CANT_FIND_ITEM);
|
||||
@@ -498,7 +474,7 @@ fn vendor_buy_sell_result_wire() {
|
||||
other => panic!("vendor buy failed event, got {other:?}"),
|
||||
}
|
||||
|
||||
// Result enum values (vmangos ItemDefines.h:120-141) — pinned so a future edit can't drift them.
|
||||
// The result codes (vmangos ItemDefines.h:120-141).
|
||||
assert_eq!(
|
||||
(
|
||||
buy_result::CANT_FIND_ITEM,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
//! 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.
|
||||
//! The group wire: invites, leadership, loot method, the roster, member stats, minimap pings, raid
|
||||
//! subgroups, target icons, ready checks and raid info, laid out per vmangos.
|
||||
|
||||
mod common;
|
||||
|
||||
@@ -15,8 +11,6 @@ use benilla_protocol::messages::{
|
||||
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;
|
||||
@@ -34,8 +28,7 @@ fn cmsg_bodies_golden() {
|
||||
"CMSG_GROUP_UNINVITE body"
|
||||
);
|
||||
|
||||
// CMSG_GROUP_ACCEPT / DECLINE / DISBAND / RAID_CONVERT (vmangos Opcodes.cpp: NullClientPacket):
|
||||
// all empty.
|
||||
// ACCEPT, DECLINE, DISBAND and RAID_CONVERT are empty (vmangos Opcodes.cpp: NullClientPacket).
|
||||
assert_eq!(
|
||||
messages::group_accept(),
|
||||
Vec::<u8>::new(),
|
||||
@@ -57,7 +50,7 @@ fn cmsg_bodies_golden() {
|
||||
"CMSG_GROUP_RAID_CONVERT body"
|
||||
);
|
||||
|
||||
// CMSG_GROUP_UNINVITE_GUID / CMSG_GROUP_SET_LEADER / CMSG_REQUEST_PARTY_MEMBER_STATS: a full guid.
|
||||
// UNINVITE_GUID, SET_LEADER and REQUEST_PARTY_MEMBER_STATS: a full guid.
|
||||
assert_eq!(
|
||||
messages::group_uninvite_guid(guid),
|
||||
hx(guid_hex),
|
||||
@@ -107,15 +100,15 @@ fn cmsg_bodies_golden() {
|
||||
"CMSG_GROUP_ASSISTANT_LEADER body, revoke"
|
||||
);
|
||||
|
||||
// MSG_MINIMAP_PING, outbound (Group.cpp:33-37): f32 x, f32 y — no guid.
|
||||
// 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.
|
||||
// MSG_RAID_TARGET_UPDATE (Group.cpp:77-82): icon and guid set or clear one icon; a lone 0xFF
|
||||
// requests the current set.
|
||||
assert_eq!(
|
||||
messages::raid_target_set(3, guid),
|
||||
hx(concat!("03", "f0debc9a78563412")),
|
||||
@@ -150,8 +143,6 @@ fn cmsg_bodies_golden() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -187,7 +178,7 @@ fn group_notification_smsg_wire() {
|
||||
other => panic!("group leader changed decode: {other:?}"),
|
||||
}
|
||||
|
||||
// SMSG_GROUP_UNINVITE (Group.cpp:117-119): empty body — we were kicked/left.
|
||||
// SMSG_GROUP_UNINVITE (Group.cpp:117-119): empty; we were removed or left.
|
||||
let p = messages::parse_server(opcode::SMSG_GROUP_UNINVITE, &[]).unwrap();
|
||||
assert!(matches!(p, ServerPacket::GroupUninvited));
|
||||
assert!(matches!(
|
||||
@@ -195,7 +186,7 @@ fn group_notification_smsg_wire() {
|
||||
[SessionEvent::GroupUninvited]
|
||||
));
|
||||
|
||||
// SMSG_GROUP_DESTROYED (Group.cpp:121-123): empty body — the group disbanded outright.
|
||||
// SMSG_GROUP_DESTROYED (Group.cpp:121-123): empty; the group disbanded.
|
||||
let p = messages::parse_server(opcode::SMSG_GROUP_DESTROYED, &[]).unwrap();
|
||||
assert!(matches!(p, ServerPacket::GroupDestroyed));
|
||||
assert!(matches!(
|
||||
@@ -204,12 +195,11 @@ fn group_notification_smsg_wire() {
|
||||
));
|
||||
}
|
||||
|
||||
/// `SMSG_GROUP_LIST`, party shape: 2 other members + the full loot tail (master loot, so
|
||||
/// `looterGuid` carries the master's guid).
|
||||
/// `SMSG_GROUP_LIST`, party shape, master loot: `looterGuid` carries the master looter.
|
||||
#[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.
|
||||
// Group.cpp:155-180: groupType, own flags, memberCount, the member rows, leaderGuid, then the
|
||||
// loot tail only when memberCount > 0.
|
||||
let mut body = vec![0u8]; // groupType: party
|
||||
body.push(0x00); // ownGroupAndAssistantFlag: subgroup 0, not assistant
|
||||
body.extend_from_slice(&2u32.to_le_bytes()); // memberCount
|
||||
@@ -287,8 +277,8 @@ fn group_list_party_two_members_master_loot() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `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).
|
||||
/// `SMSG_GROUP_LIST`, raid shape (`groupType == 1`), with the assistant bit (`0x80`) and
|
||||
/// round-robin loot, whose `looterGuid` is 0: vmangos fills it only for master loot.
|
||||
#[test]
|
||||
fn group_list_raid_with_assistant_flag() {
|
||||
let mut body = vec![1u8]; // groupType: raid
|
||||
@@ -342,8 +332,7 @@ fn group_list_raid_with_assistant_flag() {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// The "you left the group" shape (`Group.cpp:155-180`, no members): 14 bytes, no loot tail.
|
||||
#[test]
|
||||
fn group_list_empty_you_left_shape_is_14_bytes() {
|
||||
let body = vec![0u8; 14];
|
||||
@@ -377,8 +366,8 @@ fn group_list_empty_you_left_shape_is_14_bytes() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `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, "", ...)`).
|
||||
/// `SMSG_PARTY_COMMAND_RESULT` (Group.cpp:100-105): a named refusal, and the ignoring-you refusal,
|
||||
/// which names no one (`Handlers/GroupHandler.cpp:466`).
|
||||
#[test]
|
||||
fn party_command_result_wire() {
|
||||
let mut body = party_operation::INVITE.to_le_bytes().to_vec();
|
||||
@@ -420,8 +409,7 @@ fn party_command_result_wire() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_PARTY_MEMBER_STATS` (delta form): mask `0x000000FF` — every bit from `STATUS` through
|
||||
/// `ZONE` — hand-computed body, the plain (non-`_FULL`) opcode.
|
||||
/// `SMSG_PARTY_MEMBER_STATS` (delta) with mask `0xFF`: every field from `STATUS` to `ZONE`.
|
||||
#[test]
|
||||
fn party_member_stats_delta_status_through_zone() {
|
||||
let body = hx(concat!(
|
||||
@@ -468,9 +456,8 @@ fn party_member_stats_delta_status_through_zone() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `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.
|
||||
/// `SMSG_PARTY_MEMBER_STATS_FULL` with position, auras and the whole pet block: every field the
|
||||
/// delta test does not cover.
|
||||
#[test]
|
||||
fn party_member_stats_full_position_auras_and_pet_block() {
|
||||
let mask = party_member_mask::POSITION
|
||||
@@ -501,9 +488,7 @@ fn party_member_stats_full_position_auras_and_pet_block() {
|
||||
// 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());
|
||||
@@ -539,7 +524,6 @@ fn party_member_stats_full_position_auras_and_pet_block() {
|
||||
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);
|
||||
}
|
||||
@@ -556,8 +540,8 @@ fn party_member_stats_full_position_auras_and_pet_block() {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`).
|
||||
/// The reply for a member not in our group (`Handlers/GroupHandler.cpp:763-774`):
|
||||
/// `SMSG_PARTY_MEMBER_STATS_FULL` with only `STATUS`, set to offline (0).
|
||||
#[test]
|
||||
fn party_member_stats_offline_miss_is_status_only() {
|
||||
let body = hx(concat!(
|
||||
@@ -577,8 +561,8 @@ fn party_member_stats_offline_miss_is_status_only() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `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`]).
|
||||
/// `MSG_MINIMAP_PING` inbound (`Handlers/GroupHandler.cpp:382-391`): the server prefixes the
|
||||
/// sender's full guid to the `f32` x and y.
|
||||
#[test]
|
||||
fn minimap_ping_inbound_wire() {
|
||||
let mut body = 0x77u64.to_le_bytes().to_vec();
|
||||
@@ -599,8 +583,8 @@ fn minimap_ping_inbound_wire() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `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).
|
||||
/// `MSG_RAID_TARGET_UPDATE` from the server (Group.cpp:132-147): mode 0 carries one icon, mode 1
|
||||
/// the full list, possibly empty.
|
||||
#[test]
|
||||
fn raid_target_update_smsg_shapes() {
|
||||
let mut body = vec![0u8, 3]; // mode 0 (delta), icon 3
|
||||
@@ -638,7 +622,6 @@ fn raid_target_update_smsg_shapes() {
|
||||
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()),
|
||||
@@ -646,8 +629,8 @@ fn raid_target_update_smsg_shapes() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `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).
|
||||
/// `MSG_RAID_READY_CHECK` from the server: empty when a check starts (Group.cpp:94-96), a full
|
||||
/// guid and state for a member's answer (126-130).
|
||||
#[test]
|
||||
fn ready_check_smsg_shapes() {
|
||||
let p = messages::parse_server(opcode::MSG_RAID_READY_CHECK, &[]).unwrap();
|
||||
@@ -676,11 +659,9 @@ fn ready_check_smsg_shapes() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `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.
|
||||
/// `CMSG_REQUEST_RAID_INFO` is empty; `SMSG_RAID_INSTANCE_INFO` (vmangos `Player::SendRaidInfo`)
|
||||
/// is a `u32` count of 12-byte rows. A zero count still decodes to an event: its arrival is what
|
||||
/// disables the Raid Info button.
|
||||
#[test]
|
||||
fn raid_instance_info_wire() {
|
||||
assert_eq!(
|
||||
@@ -699,8 +680,6 @@ fn raid_instance_info_wire() {
|
||||
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
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
//! Golden tests for the guild opcode family — the query and roster caches, invitations, the member
|
||||
//! verbs, rank administration, the event broadcast and the command-result feedback. 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.
|
||||
//!
|
||||
//! Three of the tests below exist for a specific failure mode rather than for coverage, and each
|
||||
//! was mutation-checked — the parser was broken that exact way and the test observed to fail:
|
||||
//! `guild_roster_conditional_float_keeps_later_members_in_sync` (the `f32` that rides on the
|
||||
//! presence byte, whose third member is the one that proves the parse re-synchronises after a
|
||||
//! wider record), `guild_query_response_always_carries_ten_rank_names` (the fixed ten, which a
|
||||
//! counted read walks straight past into the emblem block), and
|
||||
//! `guild_event_trailing_guid_rides_on_the_event_id` (which pins us to the *client's* narrower
|
||||
//! read, not vmangos's write rule).
|
||||
//! The guild wire: the query and roster caches, invitations, member verbs, rank administration,
|
||||
//! the event broadcast and command results, laid out per vmangos.
|
||||
|
||||
mod common;
|
||||
|
||||
@@ -23,9 +11,6 @@ use benilla_protocol::messages::{
|
||||
use benilla_protocol::ServerPacket;
|
||||
use common::hx;
|
||||
|
||||
/// Every CMSG builder in the family, byte-exact — the six empty-body verbs asserting an empty
|
||||
/// `Vec`, the eight one-cstring verbs, the two two-cstring note verbs, the `u32` query, and the
|
||||
/// three-field rank write.
|
||||
#[test]
|
||||
fn cmsg_bodies_golden() {
|
||||
// CMSG_GUILD_QUERY (vmangos Server/Packets/Guild.cpp:8-11): one u32, the guild id.
|
||||
@@ -84,8 +69,7 @@ fn cmsg_bodies_golden() {
|
||||
"CMSG_GUILD_MOTD body"
|
||||
);
|
||||
|
||||
// The empty-body verbs (vmangos Opcodes.cpp: NullClientPacket at :213, :214, :216, :218, :222,
|
||||
// :224, :655). Every one of these carries its meaning entirely in the opcode.
|
||||
// The empty verbs (vmangos Opcodes.cpp:213, 214, 216, 218, 222, 224, 655: NullClientPacket).
|
||||
assert_eq!(
|
||||
messages::guild_accept(),
|
||||
Vec::<u8>::new(),
|
||||
@@ -122,8 +106,7 @@ fn cmsg_bodies_golden() {
|
||||
"CMSG_GUILD_DEL_RANK body"
|
||||
);
|
||||
|
||||
// CMSG_GUILD_RANK (Guild.cpp:78-83): u32 rankId, u32 rights, cstring rankName — the rights
|
||||
// here are the officer set (chat listen/speak both channels, invite, promote, demote).
|
||||
// CMSG_GUILD_RANK (Guild.cpp:78-83): u32 rankId, u32 rights, cstring rankName.
|
||||
let rights = guild_rank_right::GCHAT_LISTEN
|
||||
| guild_rank_right::GCHAT_SPEAK
|
||||
| guild_rank_right::OFFCHAT_LISTEN
|
||||
@@ -138,8 +121,7 @@ fn cmsg_bodies_golden() {
|
||||
"CMSG_GUILD_RANK body"
|
||||
);
|
||||
|
||||
// CMSG_GUILD_SET_PUBLIC_NOTE / _OFFICER_NOTE (Guild.cpp:61-65 / 67-71): name then note, two
|
||||
// cstrings — the same byte shape, distinguished only by opcode and by which right gates it.
|
||||
// CMSG_GUILD_SET_PUBLIC_NOTE / _OFFICER_NOTE (Guild.cpp:61-65 / 67-71): name, then note.
|
||||
assert_eq!(
|
||||
messages::guild_set_public_note("Bob", "hi"),
|
||||
hx(concat!("426f6200", "686900")),
|
||||
@@ -152,14 +134,8 @@ fn cmsg_bodies_golden() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Clearing the MOTD.
|
||||
///
|
||||
/// vmangos's read is guarded — `GuildMOTD::ReadFromWorldPacket` (Server/Packets/Guild.cpp:38-42)
|
||||
/// is `if (!recv_data.empty()) recv_data >> motd;` — so a **zero-byte** body is a legal shape that
|
||||
/// also means "clear it" (the packet's `motd` stays default-constructed). We emit the well-formed
|
||||
/// one-byte empty cstring instead: one builder, one shape, and the empty string is not a special
|
||||
/// case anywhere in the crate. Both land on the server as `motd == ""`, so this is a choice about
|
||||
/// our own code, not about what the server does.
|
||||
/// An empty MOTD is a one-byte empty cstring; vmangos (`Server/Packets/Guild.cpp:38-42`) reads a
|
||||
/// zero-byte body as a clear too.
|
||||
#[test]
|
||||
fn guild_motd_clears_with_an_empty_cstring_not_an_empty_body() {
|
||||
assert_eq!(
|
||||
@@ -170,14 +146,8 @@ fn guild_motd_clears_with_an_empty_cstring_not_an_empty_body() {
|
||||
assert_eq!(messages::guild_motd("").len(), 1, "one NUL, not zero bytes");
|
||||
}
|
||||
|
||||
/// `SMSG_GUILD_QUERY_RESPONSE`: **exactly ten** rank-name cstrings, always — a fixed loop over the
|
||||
/// sender's `rankNames[10]`, not a counted list (vmangos Server/Packets/Guild.cpp:118-131, filled
|
||||
/// by Guild/Guild.cpp:862-880, whose own comment reads "show always 10 ranks").
|
||||
///
|
||||
/// The fixture gives the guild five real ranks, so five of the ten are empty strings. A parser
|
||||
/// that read a count, or that stopped at the guild's real rank count, would land in the middle of
|
||||
/// the five-`u32` emblem block — which is why the emblem values here are all distinct: getting
|
||||
/// them back in order is the proof the ten were consumed.
|
||||
/// `SMSG_GUILD_QUERY_RESPONSE` always carries ten rank-name cstrings, not a counted list (vmangos
|
||||
/// `Server/Packets/Guild.cpp:118-131`); the distinct emblem values after them catch a short read.
|
||||
#[test]
|
||||
fn guild_query_response_always_carries_ten_rank_names() {
|
||||
let mut body = Vec::new();
|
||||
@@ -193,7 +163,7 @@ fn guild_query_response_always_carries_ten_rank_names() {
|
||||
body.extend_from_slice(name);
|
||||
body.push(0);
|
||||
}
|
||||
// The five ranks this guild never created: an empty string each — a bare NUL, not absent.
|
||||
// The five uncreated ranks: an empty string each, a bare NUL, not absent.
|
||||
body.extend_from_slice(&[0u8; GUILD_RANKS_MAX_COUNT - 5]);
|
||||
body.extend_from_slice(&1u32.to_le_bytes()); // emblemStyle
|
||||
body.extend_from_slice(&2u32.to_le_bytes()); // emblemColor
|
||||
@@ -215,7 +185,6 @@ fn guild_query_response_always_carries_ten_rank_names() {
|
||||
&["", "", "", "", ""],
|
||||
"the five uncreated ranks are empty strings on the wire"
|
||||
);
|
||||
// The emblem block landing intact is what proves all ten strings were consumed.
|
||||
assert_eq!(response.emblem_style, 1, "emblemStyle — the desync canary");
|
||||
assert_eq!(response.emblem_color, 2);
|
||||
assert_eq!(response.border_style, 3);
|
||||
@@ -228,13 +197,8 @@ fn guild_query_response_always_carries_ten_rank_names() {
|
||||
}
|
||||
}
|
||||
|
||||
/// "No such guild" has no flag of its own: the answer is a normal, complete
|
||||
/// `SMSG_GUILD_QUERY_RESPONSE` whose **guild name is empty**. The reference client branches on
|
||||
/// exactly that (`0x5552ae test al,al` → cache insert `0x561070` vs cache remove `0x561390`,
|
||||
/// wow-re `system/ui/scratch/guild-roster-wire.md`) and consumes the whole record either way.
|
||||
///
|
||||
/// So the not-found shape must still parse end to end — all ten rank strings and the emblem block
|
||||
/// included — or a consumer keying off the empty name would be reading a half-parsed record.
|
||||
/// "No such guild" is a complete `SMSG_GUILD_QUERY_RESPONSE` with an empty name: the reference
|
||||
/// branches on it (`0x5552ae`) and still consumes the whole record.
|
||||
#[test]
|
||||
fn guild_query_response_reports_no_such_guild_as_an_empty_name() {
|
||||
let mut body = Vec::new();
|
||||
@@ -256,11 +220,8 @@ fn guild_query_response_reports_no_such_guild_as_an_empty_name() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Append one `SMSG_GUILD_ROSTER` member per vmangos Server/Packets/Guild.cpp:155-172.
|
||||
///
|
||||
/// `last_online` is passed explicitly rather than derived from `presence`, so the *fixture* states
|
||||
/// the wire rule independently of the parser under test: an offline member carries the float, an
|
||||
/// online one does not.
|
||||
/// Append one `SMSG_GUILD_ROSTER` member (vmangos `Server/Packets/Guild.cpp:155-172`); the caller
|
||||
/// passes `last_online` itself, `Some` only for an offline member.
|
||||
fn push_member(
|
||||
body: &mut Vec<u8>,
|
||||
guid: u64,
|
||||
@@ -291,8 +252,8 @@ fn push_member(
|
||||
body.push(0);
|
||||
}
|
||||
|
||||
/// Append an `SMSG_GUILD_ROSTER` head (Server/Packets/Guild.cpp:143-153): member count, MOTD, info
|
||||
/// text, then the counted rank-rights array.
|
||||
/// An `SMSG_GUILD_ROSTER` head (Server/Packets/Guild.cpp:143-153): member count, MOTD, info text,
|
||||
/// then the counted rank-rights array.
|
||||
fn roster_head(member_count: u32, motd: &str, info: &str, rank_rights: &[u32]) -> Vec<u8> {
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(&member_count.to_le_bytes());
|
||||
@@ -307,15 +268,9 @@ fn roster_head(member_count: u32, motd: &str, info: &str, rank_rights: &[u32]) -
|
||||
body
|
||||
}
|
||||
|
||||
/// **The one that matters.** `SMSG_GUILD_ROSTER`'s `f32 lastOnlineTime` is on the wire only when
|
||||
/// the member's presence byte is `0` (vmangos Server/Packets/Guild.cpp:164-165, and independently
|
||||
/// Guild/Guild.cpp:793-794's size accounting, which decides where the sender truncates).
|
||||
///
|
||||
/// The fixture is deliberately **online, offline, online**, and the assertions that count are on
|
||||
/// the *third* member: it is the only one that can show the parse **re-synchronising** after a
|
||||
/// record of a different width. Getting one member's own fields right proves the field list; only
|
||||
/// a member that follows a wider one proves the stride. Mutation-checked — reading the float
|
||||
/// unconditionally fails this test (and the officer-note one) on exactly those assertions.
|
||||
/// `SMSG_GUILD_ROSTER`'s `f32 lastOnlineTime` is sent only when the presence byte is 0 (vmangos
|
||||
/// `Server/Packets/Guild.cpp:164-165`); the third member, after a wider offline one, checks the
|
||||
/// parse stays in step.
|
||||
#[test]
|
||||
fn guild_roster_conditional_float_keeps_later_members_in_sync() {
|
||||
let mut body = roster_head(
|
||||
@@ -324,7 +279,6 @@ fn guild_roster_conditional_float_keeps_later_members_in_sync() {
|
||||
"We are a guild.",
|
||||
&[guild_rank_right::ALL, 0x0000_019F, 0x0000_0003],
|
||||
);
|
||||
// 1. online — NO float.
|
||||
push_member(
|
||||
&mut body,
|
||||
0x1111,
|
||||
@@ -338,7 +292,6 @@ fn guild_roster_conditional_float_keeps_later_members_in_sync() {
|
||||
"the GM",
|
||||
"trusted",
|
||||
);
|
||||
// 2. offline — the float IS there. This is the member whose width differs.
|
||||
push_member(
|
||||
&mut body,
|
||||
0x2222,
|
||||
@@ -352,7 +305,6 @@ fn guild_roster_conditional_float_keeps_later_members_in_sync() {
|
||||
"on holiday",
|
||||
"back next week",
|
||||
);
|
||||
// 3. online again — the one that catches a desynchronised parse.
|
||||
push_member(
|
||||
&mut body,
|
||||
0x3333,
|
||||
@@ -396,7 +348,6 @@ fn guild_roster_conditional_float_keeps_later_members_in_sync() {
|
||||
assert_eq!(bob.public_note, "on holiday");
|
||||
assert_eq!(bob.officer_note, "back next week");
|
||||
|
||||
// The whole point of the fixture: everything about the member *after* the wider one.
|
||||
let carol = &roster.members[2];
|
||||
assert_eq!(carol.guid, 0x3333, "third member guid — the desync canary");
|
||||
assert_eq!(carol.name, "Carol");
|
||||
@@ -416,9 +367,6 @@ fn guild_roster_conditional_float_keeps_later_members_in_sync() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A roster with no members at all — the head alone, and nothing after the rank-rights array. The
|
||||
/// degenerate shape a brand-new or just-disbanded guild produces; the member loop must simply not
|
||||
/// run rather than reach for a first guid.
|
||||
#[test]
|
||||
fn guild_roster_with_no_members_is_just_the_head() {
|
||||
let body = roster_head(0, "", "", &[guild_rank_right::ALL]);
|
||||
@@ -434,11 +382,8 @@ fn guild_roster_with_no_members_is_just_the_head() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Officer notes blanked because *we* lack `GR_RIGHT_VIEWOFFNOTE`: vmangos decides that once, per
|
||||
/// viewer (`canViewOfficerNote`, Guild/Guild.cpp:821), and writes `""` for every member
|
||||
/// (`:844`) — the cstring is still on the wire, just empty. Dropping the field for an
|
||||
/// unprivileged viewer would desynchronise exactly as the float does, so this pins that the
|
||||
/// terminator is read.
|
||||
/// A viewer without `GR_RIGHT_VIEWOFFNOTE` gets `""` for every officer note (vmangos
|
||||
/// `Guild/Guild.cpp:821`, `:844`): the cstring's NUL is still on the wire.
|
||||
#[test]
|
||||
fn guild_roster_reads_the_empty_officer_notes_of_an_unprivileged_viewer() {
|
||||
let mut body = roster_head(2, "motd", "info", &[guild_rank_right::ALL, 0x0000_0003]);
|
||||
@@ -485,20 +430,13 @@ fn guild_roster_reads_the_empty_officer_notes_of_an_unprivileged_viewer() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The float's condition is the **whole presence byte** against zero, never `presence & ONLINE`.
|
||||
///
|
||||
/// That is the reference client's own predicate — it derives the flag with `0x4d0c12 test dl,dl` /
|
||||
/// `0x4d0c1d setne cl` and branches on the result (wow-re `system/ui/scratch/guild-roster-wire.md`),
|
||||
/// and the binary never tests bit `0x1` at all. The difference shows up on any presence byte with
|
||||
/// a bit set that we don't have a name for: a whole-byte test reads it as online and skips the
|
||||
/// float, a `& 0x1` mask reads it as offline and eats four bytes of the next field. This fixture
|
||||
/// uses `0x08` — a bit no 1.12 flag owns — as the member *before* a normal one, so a masking
|
||||
/// implementation desynchronises and fails on the second member.
|
||||
/// The float's condition is the whole presence byte against zero, as the reference tests it
|
||||
/// (`0x4d0c12`), never `presence & ONLINE`; `0x08`, a bit no 1.12 flag owns, still means online.
|
||||
#[test]
|
||||
fn roster_presence_is_tested_whole_byte_not_masked_against_online() {
|
||||
let mut body = roster_head(2, "", "", &[guild_rank_right::ALL]);
|
||||
push_member(
|
||||
&mut body, 0x1111, 0x08, // an unnamed presence bit: online, so NO float
|
||||
&mut body, 0x1111, 0x08, // an unnamed presence bit: online, so no float
|
||||
"Alice", 0, 60, 2, 1519, None, "first", "",
|
||||
);
|
||||
push_member(
|
||||
@@ -532,17 +470,12 @@ fn roster_presence_is_tested_whole_byte_not_masked_against_online() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_GUILD_EVENT`'s trailing guid rides on the **event id**, not on whether bytes remain.
|
||||
///
|
||||
/// The reference client's handler `0x5e7180` reads it in only the `0xc`/`0xd` arms of its jump
|
||||
/// table (wow-re RF-0077, `system/object-layer/scratch/rf77-smsg-chat-wire-order.md`), while
|
||||
/// vmangos *writes* one whenever `affectedPlayerGuid` is set — which its callers also do for
|
||||
/// `GE_JOINED` (Handlers/GuildHandler.cpp:218) and `GE_LEFT` (`:405`). benilla is the client, so
|
||||
/// the third case below is the interesting one: a `GE_JOINED` **with** vmangos's trailing guid
|
||||
/// still reports `guid: None`, and its params parse correctly, exactly as the reference does.
|
||||
/// `SMSG_GUILD_EVENT`'s trailing guid rides on the event id: the reference (`0x5e7180`) reads it
|
||||
/// only for 0x0c and 0x0d, though vmangos also writes one for `GE_JOINED` and `GE_LEFT`
|
||||
/// (`Handlers/GuildHandler.cpp:218`, `:405`).
|
||||
#[test]
|
||||
fn guild_event_trailing_guid_rides_on_the_event_id() {
|
||||
// GE_SIGNED_ON (0x0c) — one param and the guid (Server/Packets/Guild.cpp:133-141).
|
||||
// GE_SIGNED_ON (0x0c): one param and the guid (Server/Packets/Guild.cpp:133-141).
|
||||
let mut body = vec![guild_event::SIGNED_ON, 1];
|
||||
body.extend_from_slice(b"Alice\0");
|
||||
body.extend_from_slice(&0xDEAD_BEEFu64.to_le_bytes());
|
||||
@@ -560,7 +493,7 @@ fn guild_event_trailing_guid_rides_on_the_event_id() {
|
||||
other => panic!("guild event decode: {other:?}"),
|
||||
}
|
||||
|
||||
// GE_MOTD (0x02) — one param, no guid at all on the wire.
|
||||
// GE_MOTD (0x02): one param, no guid.
|
||||
let mut body = vec![guild_event::MOTD, 1];
|
||||
body.extend_from_slice(b"Raid at 8\0");
|
||||
let packet = messages::parse_server(opcode::SMSG_GUILD_EVENT, &body).unwrap();
|
||||
@@ -573,8 +506,7 @@ fn guild_event_trailing_guid_rides_on_the_event_id() {
|
||||
other => panic!("expected GuildEvent, got {}", other.name()),
|
||||
}
|
||||
|
||||
// GE_JOINED (0x03) — vmangos DOES append a guid here; the reference client never reads it.
|
||||
// The params must still be right, and the guid must be reported absent.
|
||||
// GE_JOINED (0x03): vmangos appends a guid here, which the reference never reads.
|
||||
let mut body = vec![guild_event::JOINED, 1];
|
||||
body.extend_from_slice(b"Carol\0");
|
||||
body.extend_from_slice(&0x1234u64.to_le_bytes());
|
||||
@@ -590,7 +522,7 @@ fn guild_event_trailing_guid_rides_on_the_event_id() {
|
||||
other => panic!("expected GuildEvent, got {}", other.name()),
|
||||
}
|
||||
|
||||
// GE_PROMOTION (0x00) — the three-param maximum (promoter, promoted, new rank name).
|
||||
// GE_PROMOTION (0x00): the three-param maximum (promoter, promoted, new rank name).
|
||||
let mut body = vec![guild_event::PROMOTION, 3];
|
||||
body.extend_from_slice(b"Alice\0Bob\0Officer\0");
|
||||
match &messages::parse_server(opcode::SMSG_GUILD_EVENT, &body).unwrap() {
|
||||
@@ -601,7 +533,7 @@ fn guild_event_trailing_guid_rides_on_the_event_id() {
|
||||
other => panic!("expected GuildEvent, got {}", other.name()),
|
||||
}
|
||||
|
||||
// GE_DISBANDED (0x08) — no params, no guid: the two-byte minimum body.
|
||||
// GE_DISBANDED (0x08): no params, no guid, the two-byte minimum body.
|
||||
let packet = messages::parse_server(
|
||||
opcode::SMSG_GUILD_EVENT,
|
||||
&hx(concat!("08", "00")), // event, paramCount
|
||||
@@ -617,12 +549,8 @@ fn guild_event_trailing_guid_rides_on_the_event_id() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_GUILD_COMMAND_RESULT` (Server/Packets/Guild.cpp:96-101): `u32 command`, cstring, `u32
|
||||
/// result` — the string sits in the **middle**, which is the field order to get wrong.
|
||||
///
|
||||
/// The two cases here are the pair that share result `0x08`: with `QUIT` it is "the guild master
|
||||
/// cannot leave", with anything else it is "you don't have permission". Carrying the command tag
|
||||
/// through is what keeps them apart.
|
||||
/// `SMSG_GUILD_COMMAND_RESULT` (Server/Packets/Guild.cpp:96-101): `u32` command, a cstring in the
|
||||
/// middle, `u32` result.
|
||||
#[test]
|
||||
fn guild_command_result_wire() {
|
||||
let mut body = Vec::new();
|
||||
@@ -643,8 +571,8 @@ fn guild_command_result_wire() {
|
||||
other => panic!("guild command result decode: {other:?}"),
|
||||
}
|
||||
|
||||
// The collision: 0x08 under QUIT is ERR_GUILD_LEADER_LEAVE, under anything else it is
|
||||
// ERR_GUILD_PERMISSIONS (vmangos Guild/Guild.h:106-107) — and both carry an empty string.
|
||||
// Result 0x08 is ERR_GUILD_LEADER_LEAVE under QUIT and ERR_GUILD_PERMISSIONS otherwise
|
||||
// (vmangos Guild/Guild.h:106-107); the command tag tells them apart.
|
||||
assert_eq!(
|
||||
guild_command_error::LEADER_LEAVE,
|
||||
guild_command_error::PERMISSIONS
|
||||
@@ -699,8 +627,8 @@ fn guild_info_wire() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The two notification packets: `SMSG_GUILD_INVITE` (Server/Packets/Guild.cpp:85-89) is inviter
|
||||
/// then guild, two cstrings; `SMSG_GUILD_DECLINE` (`:91-94`) is one cstring, the decliner.
|
||||
/// `SMSG_GUILD_INVITE` (Server/Packets/Guild.cpp:85-89) is inviter then guild, two cstrings;
|
||||
/// `SMSG_GUILD_DECLINE` (`:91-94`) is one cstring, the decliner.
|
||||
#[test]
|
||||
fn guild_invite_and_decline_wire() {
|
||||
let packet = messages::parse_server(
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
//! The world handshake against a fake server, exercising [`WorldSession::connect`] over a real
|
||||
//! socket with real header obfuscation.
|
||||
//!
|
||||
//! What these pin down is the packet *ordering* tolerance the handshake needs. A server does not
|
||||
//! promise `SMSG_AUTH_RESPONSE` is the first encrypted packet — it interleaves its own traffic —
|
||||
//! and one of those interleaved packets, `SMSG_WARDEN_DATA`, means the server runs an anticheat we
|
||||
//! cannot answer and must be refused at login rather than entered and kicked ~30 s later.
|
||||
//! The world handshake over a real socket against a fake server: packets may precede
|
||||
//! `SMSG_AUTH_RESPONSE`. Deviation: `SMSG_WARDEN_DATA` refuses the login, because benilla cannot
|
||||
//! answer Warden and the server kicks a client that stays silent about 30 s later.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
@@ -18,8 +14,8 @@ use benilla_srp::SESSION_KEY_LENGTH;
|
||||
const SESSION_KEY: [u8; SESSION_KEY_LENGTH] = [7u8; SESSION_KEY_LENGTH];
|
||||
const SERVER_SEED: u32 = 0xDEAD_BEEF;
|
||||
|
||||
/// Write one server packet: 2-byte BE size (counts the opcode, not itself) + 2-byte LE opcode,
|
||||
/// encrypted once `crypto` is in play, then the plaintext body.
|
||||
/// Write one server packet: `u16` BE size (opcode plus body) and `u16` LE opcode, encrypted once
|
||||
/// `crypto` is set, then the plaintext body.
|
||||
fn send(stream: &mut TcpStream, crypto: Option<&mut HeaderCrypto>, opcode: u16, body: &[u8]) {
|
||||
let size = (body.len() + 2) as u16;
|
||||
let s = size.to_be_bytes();
|
||||
@@ -41,8 +37,8 @@ fn read_auth_session(stream: &mut TcpStream) {
|
||||
stream.read_exact(&mut body).unwrap();
|
||||
}
|
||||
|
||||
/// Stand up a fake world server that sends `pre` (opcode, body) pairs — encrypted, in order —
|
||||
/// before a successful `SMSG_AUTH_RESPONSE`. Returns the address to point `connect` at.
|
||||
/// A fake world server that sends the `pre` packets, encrypted and in order, before a successful
|
||||
/// `SMSG_AUTH_RESPONSE`; returns its address.
|
||||
fn fake_server(pre: Vec<(u16, Vec<u8>)>) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
@@ -71,15 +67,13 @@ fn fake_server(pre: Vec<(u16, Vec<u8>)>) -> String {
|
||||
addr
|
||||
}
|
||||
|
||||
/// The plain case: `SMSG_AUTH_RESPONSE` leads, the handshake completes.
|
||||
#[test]
|
||||
fn auth_response_alone_completes_the_handshake() {
|
||||
let addr = fake_server(vec![]);
|
||||
assert!(WorldSession::connect(&addr, "one", SESSION_KEY).is_ok());
|
||||
}
|
||||
|
||||
/// A server interleaving its own traffic ahead of the auth response must not fail the handshake —
|
||||
/// the real one does this, and demanding AUTH_RESPONSE lead once broke login outright.
|
||||
/// The server interleaves its own packets ahead of the auth response.
|
||||
#[test]
|
||||
fn packets_ahead_of_the_auth_response_are_skipped() {
|
||||
let addr = fake_server(vec![
|
||||
@@ -89,8 +83,6 @@ fn packets_ahead_of_the_auth_response_are_skipped() {
|
||||
assert!(WorldSession::connect(&addr, "one", SESSION_KEY).is_ok());
|
||||
}
|
||||
|
||||
/// Warden among them is refused, with the error the login screen shows — never a session that
|
||||
/// would be kicked once the server's response clock expires.
|
||||
#[test]
|
||||
fn a_warden_server_is_refused_at_the_handshake() {
|
||||
let addr = fake_server(vec![(opcode::SMSG_WARDEN_DATA, vec![0u8; 16])]);
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
//! Oracle-free golden tests for the instance/raid **lockout message** family (decision 1748): the
|
||||
//! six inbound bodies, the one outbound body, and the two enums the client's own dispatch tables
|
||||
//! define.
|
||||
//!
|
||||
//! Every layout here is the *client's* read order, taken from the five handlers registered at
|
||||
//! `0x498680`-`0x4986cf` plus `0x4e7e60`, and cross-checked against vmangos
|
||||
//! `Server/Packets/Misc.{h,cpp}`. `hx(...)` bodies round-tripped through `parse_server`, the
|
||||
//! idiom of `tests/bank.rs`.
|
||||
//! The instance lockout messages in the reference's read order (handlers registered at
|
||||
//! `0x498680`-`0x4986cf` and `0x4e7e60`; vmangos `Server/Packets/Misc.cpp`).
|
||||
|
||||
use benilla_protocol::events::{decode, SessionEvent};
|
||||
use benilla_protocol::messages::{
|
||||
@@ -20,17 +14,14 @@ fn hx(s: &str) -> Vec<u8> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `CMSG_RESET_INSTANCES` — empty, both in the binding (`0x48a6b0` writes no field before its
|
||||
/// send) and in vmangos's handler (which reads none).
|
||||
/// `CMSG_RESET_INSTANCES` is empty in the reference (`0x48a6b0`) and in vmangos's handler.
|
||||
#[test]
|
||||
fn reset_instances_body_is_empty() {
|
||||
assert_eq!(messages::reset_instances(), Vec::<u8>::new());
|
||||
}
|
||||
|
||||
/// `SMSG_RAID_INSTANCE_MESSAGE`: `u32 type`, `u32 mapId`, `u32 secondsUntilReset`, in that order
|
||||
/// — the client reads the three at `0x49e1cd`/`0x49e1d8`/`0x49e1e3` and uses the SECOND for the
|
||||
/// `Map.dbc` lookup, which is what fixes the order (a type/map swap would name the wrong instance
|
||||
/// and pick the wrong template, silently).
|
||||
/// `SMSG_RAID_INSTANCE_MESSAGE`: `u32` type, map id and seconds until reset, in that order; the
|
||||
/// reference reads them at `0x49e1cd`/`0x49e1d8`/`0x49e1e3` and looks up `Map.dbc` by the second.
|
||||
#[test]
|
||||
fn raid_instance_message_wire() {
|
||||
// type 4 (WELCOME), map 409 (Molten Core), 3 d 2 h 0 m = 266400 s.
|
||||
@@ -64,9 +55,8 @@ fn raid_instance_message_wire() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The four warning types and the two the reference's jump table drops. `dec eax; cmp eax,3; ja`
|
||||
/// at `0x49e246` means 0 and ≥ 5 leave the handler in silence — including the value later clients
|
||||
/// call `RAID_INSTANCE_EXPIRED` (5).
|
||||
/// The four warning types; the reference's jump table (`0x49e246`) prints nothing for 0 or 5 and
|
||||
/// up, including 5, which later clients call `RAID_INSTANCE_EXPIRED`.
|
||||
#[test]
|
||||
fn raid_instance_warning_types() {
|
||||
use RaidInstanceWarning as W;
|
||||
@@ -103,9 +93,7 @@ fn instance_reset_wire() {
|
||||
assert!(messages::parse_server(opcode::SMSG_INSTANCE_RESET, &hx("2400")).is_err());
|
||||
}
|
||||
|
||||
/// `SMSG_INSTANCE_RESET_FAILED`: `u32 reason` FIRST, then `u32 mapId` (`0x49e54d`/`0x49e558`) —
|
||||
/// the opposite order to the reset success packet's single field, and the pair vmangos writes in
|
||||
/// `InstanceResetFailed::AppendBodyTo`.
|
||||
/// `SMSG_INSTANCE_RESET_FAILED`: `u32` reason first, then `u32` map id (`0x49e54d`/`0x49e558`).
|
||||
#[test]
|
||||
fn instance_reset_failed_wire() {
|
||||
let body = hx(concat!("01000000", "24000000")); // OFFLINE, Deadmines
|
||||
@@ -128,9 +116,8 @@ fn instance_reset_failed_wire() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The three refusal reasons, and the silent tail. vmangos's own enum names 3
|
||||
/// `INSTANCERESET_FAIL_SILENTLY` "as well as any above this"; the reference falls through those
|
||||
/// to its chat call with an uninitialized buffer, which we deliberately do not reproduce.
|
||||
/// The three refusal reasons; vmangos names 3 and up `INSTANCERESET_FAIL_SILENTLY`. Deviation:
|
||||
/// for those we print nothing, because the reference prints an uninitialized buffer.
|
||||
#[test]
|
||||
fn instance_reset_failure_reasons() {
|
||||
use InstanceResetFailure as F;
|
||||
@@ -145,9 +132,8 @@ fn instance_reset_failure_reasons() {
|
||||
assert_eq!(F::Zoning.token(), "INSTANCE_RESET_FAILED_ZONING");
|
||||
}
|
||||
|
||||
/// The three bare-`u32` bodies that are not a map name: the save-created flag (`0x4e7e6c`), the
|
||||
/// last-instance map (`0x49e676`) and the ownership flag (`0x49e6c6`). The ownership flag is
|
||||
/// narrowed to a bool at the event layer because the reference's reader is a `test eax,eax`.
|
||||
/// Three bare `u32` bodies: the save-created flag (`0x4e7e6c`), the last-instance map
|
||||
/// (`0x49e676`) and the ownership flag (`0x49e6c6`), a bool: the reference tests it against zero.
|
||||
#[test]
|
||||
fn save_created_last_instance_and_ownership_wire() {
|
||||
let p = messages::parse_server(opcode::SMSG_INSTANCE_SAVE_CREATED, &hx("00000000")).unwrap();
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Item wire tests (mirrors `src/messages/items.rs`): the query request/response roundtrip,
|
||||
//! inventory-change-failure (level refusal vs. plain), the GM-command system chat shape, and the
|
||||
//! full `SMSG_ITEM_QUERY_SINGLE_RESPONSE` field walk (hit + undiscovered miss). Split out of the
|
||||
//! former `tests/messages.rs` — see `tests/common` for the shared fixtures and methodology note.
|
||||
//! The item wire: the query request and its full template reply, `CMSG_USE_ITEM`'s target forms,
|
||||
//! equip and open, and the inventory-change refusals.
|
||||
|
||||
mod common;
|
||||
|
||||
@@ -12,29 +10,20 @@ use common::hx;
|
||||
|
||||
#[test]
|
||||
fn item_query_wire() {
|
||||
// CMSG_ITEM_QUERY_SINGLE: entry u32 + full item guid (vmangos QueryItem::ReadFromWorldPacket).
|
||||
// CMSG_ITEM_QUERY_SINGLE: `u32` entry and the full item guid (vmangos `QueryItem`).
|
||||
assert_eq!(messages::item_query(117, 0), hx("750000000000000000000000"));
|
||||
|
||||
// CMSG_USE_ITEM (vmangos UseItem::ReadFromWorldPacket): bagIndex, slot, spellSlot, then a
|
||||
// self-shaped target block (u16 mask 0). Backpack slot 1 = bag 255 + player-array slot 23.
|
||||
// target block, here self (`u16` mask 0). Backpack slot 1 is bag 255, player slot 23.
|
||||
assert_eq!(
|
||||
messages::use_item(255, 23, 0, messages::UseItemTarget::SelfImplicit),
|
||||
hx("ff17000000")
|
||||
);
|
||||
|
||||
// The KEY-IN-A-LOCK form (decision 0769): the same three bytes, then a SpellCastTargets with
|
||||
// mask TARGET_FLAG_GAMEOBJECT (0x0800) and the object's PACKED guid. This is the packet the
|
||||
// real client sends for a key (wow-re cursor-system.md §8.4: sender 0x6e54f0's item arm ->
|
||||
// 0x6e57d8 push 0xab) and the only one vmangos will open a KEY-slot lock for
|
||||
// (Spell::CanOpenLock requires m_CastItem, Spell.cpp:7892).
|
||||
//
|
||||
// `TARGET_FLAG_LOCKED` (0x4000) is deliberately NOT here (decision 0939, correcting 0769): it
|
||||
// is a bit of the *targeting word* `0xcecac0` that `BindTarget 0x6e5b40`'s GameObject arm reads
|
||||
// and clears (`6e5f60`/`6e5f70`) while writing only `0x800` to the outgoing mask (`6e5f69`).
|
||||
// A whole-image census of writes to `0xceac5c` finds no `0x4000` anywhere.
|
||||
//
|
||||
// Keyring slot 1 = bag 255 + player-array slot 81 (0x51). The guid 0xF110000C1F00A3B2 has two
|
||||
// zero bytes (indices 2 and 5), so it packs to mask 0xDB + the other six, low byte first.
|
||||
// A key in a lock: TARGET_FLAG_GAMEOBJECT (0x0800) and the object's packed guid, as the
|
||||
// reference sends it (`0x6e57d8`); vmangos opens a key lock only for a cast item
|
||||
// (Spell.cpp:7892). TARGET_FLAG_LOCKED (0x4000) never reaches the wire mask (`0x6e5f69`).
|
||||
// Keyring slot 1 is bag 255, player slot 81; the guid's two zero bytes pack to mask 0xDB.
|
||||
assert_eq!(
|
||||
messages::use_item(
|
||||
255,
|
||||
@@ -45,45 +34,36 @@ fn item_query_wire() {
|
||||
hx("ff51000008dbb2a31f0c10f1"),
|
||||
);
|
||||
|
||||
// The packed form really does drop zero bytes: guid 1 is mask 0x01 + a single byte, and the
|
||||
// spellSlot ordinal rides in the third byte.
|
||||
// Packed guid 1 is mask 0x01 and one byte; the spellSlot ordinal is the third byte.
|
||||
assert_eq!(
|
||||
messages::use_item(255, 81, 2, messages::UseItemTarget::Object(1)),
|
||||
hx("ff510200080101")
|
||||
);
|
||||
|
||||
// The UNIT form (decision 0914): an item whose spell binds a unit the way a spell's does — a
|
||||
// bandage, a soulstone — writes TARGET_FLAG_UNIT (0x0002) + the packed guid, the SAME block a
|
||||
// CMSG_CAST_SPELL writes. In the real client one builder serves both opcodes: SendCast
|
||||
// 0x6e54f0 picks 0xab-vs-0x12e from its item discriminator and then writes ArmCast's block.
|
||||
// A unit target (a bandage, a soulstone): TARGET_FLAG_UNIT (0x0002) and the packed guid, the
|
||||
// block CMSG_CAST_SPELL writes; the reference builds both in one sender (`0x6e54f0`).
|
||||
assert_eq!(
|
||||
messages::use_item(255, 24, 0, messages::UseItemTarget::Unit(1)),
|
||||
hx("ff180002000101")
|
||||
);
|
||||
|
||||
// The DEST form (decision 0914): the targeting-cursor commit for a THROWN item — dynamite, a
|
||||
// grenade, the Goblin Mortar. TARGET_FLAG_DEST_LOCATION (0x0040) + three f32 WoW coords, the
|
||||
// same tail cast_spell_at_dest writes. 1.0f32 = 0x3f800000, 2.0 = 0x40000000, 3.0 = 0x40400000.
|
||||
// A thrown item (dynamite, a grenade): TARGET_FLAG_DEST_LOCATION (0x0040) and three `f32`
|
||||
// coords, the tail `cast_spell_at_dest` writes.
|
||||
assert_eq!(
|
||||
messages::use_item(19, 3, 1, messages::UseItemTarget::Dest([1.0, 2.0, 3.0])),
|
||||
hx("13030140000000803f0000004000004040")
|
||||
);
|
||||
|
||||
// The SOURCE form (decision 2218): the same terrain click one bit over — the three shipped
|
||||
// items carrying spell 265 "Area Death (TEST)" (Martin Fury 17, plus 192 and 5417), whose
|
||||
// `Targets` is a bare 0x20. `BindLocation 0x6e60f0` writes SPELLCAST+0x30 and ORs 0x0020
|
||||
// where its dest arm writes +0x3c and ORs 0x0040; vmangos reads this triple first. Martin
|
||||
// Fury is worn, so the wire position is the player array (255) + EQUIPMENT_SLOT_BODY (3).
|
||||
// The source form (0x0020), as for spell 265 on Martin Fury: the reference (`0x6e60f0`) sets
|
||||
// 0x0020 where a dest sets 0x0040. Martin Fury is worn, so bag 255, slot 3 (body).
|
||||
assert_eq!(
|
||||
messages::use_item(255, 3, 0, messages::UseItemTarget::Source([1.0, 2.0, 3.0])),
|
||||
hx("ff030020000000803f0000004000004040")
|
||||
);
|
||||
|
||||
// The ITEM form (decision 0923): the targeting cursor's item commit — a poison / sharpening
|
||||
// stone / weapon oil applied to the weapon you clicked. TARGET_FLAG_ITEM (0x0010) + the packed
|
||||
// guid, the same block cast_spell_on_item writes; the reference reaches both through the one
|
||||
// BindTarget 0x6e5b40 (0x495d60 @ 496056). Packed guid 0xF1500000_0000ABCD = mask 0xc3 (bytes
|
||||
// 0, 1, 6, 7 nonzero) then those four bytes.
|
||||
// An item target (a poison or oil on a weapon): TARGET_FLAG_ITEM (0x0010) and the packed
|
||||
// guid, as `cast_spell_on_item` writes (reference `0x6e5b40`); 0xF150_0000_0000_ABCD packs to
|
||||
// mask 0xC3 and four bytes.
|
||||
assert_eq!(
|
||||
messages::use_item(255, 24, 0, messages::UseItemTarget::Item(1)),
|
||||
hx("ff180010000101")
|
||||
@@ -101,12 +81,9 @@ fn item_query_wire() {
|
||||
// CMSG_AUTOEQUIP_ITEM (vmangos AutoEquipItem::ReadFromWorldPacket): bagIndex, slot.
|
||||
assert_eq!(messages::auto_equip_item(255, 25), hx("ff19"));
|
||||
|
||||
// CMSG_OPEN_ITEM (vmangos OpenItem::ReadFromWorldPacket, Server/Packets/Spell.cpp:19-23):
|
||||
// bagIndex, slot — and NOTHING else (no spell ordinal, no targets block; opening is not a
|
||||
// cast). The same two bytes as auto-equip under a different opcode: this is the fork a
|
||||
// clam/lockbox/gift takes instead of USE_ITEM, and the only one the server answers with
|
||||
// SendLoot on the item's own guid. Backpack slot 3 = bag 255 + player-array slot 25; an item
|
||||
// inside an equipped bag addresses that bag's own player-array slot (19..22) + inner slot.
|
||||
// CMSG_OPEN_ITEM (vmangos Server/Packets/Spell.cpp:19-23): bagIndex and slot only, no cast
|
||||
// block. A clam, lockbox or gift uses it; the server answers with loot on the item's guid.
|
||||
// An item in an equipped bag addresses that bag's player slot (19..22) and its inner slot.
|
||||
assert_eq!(messages::open_item(255, 25), hx("ff19"));
|
||||
assert_eq!(messages::open_item(19, 0), hx("1300"));
|
||||
|
||||
@@ -126,7 +103,7 @@ fn item_query_wire() {
|
||||
} => {
|
||||
assert_eq!((reason, required_level), (1, Some(10)));
|
||||
assert_eq!(item_guid, 0x4000_0000_0000_0042);
|
||||
// The trailing byte is the destination BAG's absolute player slot, not a subslot.
|
||||
// The trailing byte is the destination bag's absolute player slot, not a subslot.
|
||||
assert_eq!(bag_slot, 0);
|
||||
}
|
||||
other => panic!("level refusal, got {}", other.name()),
|
||||
@@ -145,8 +122,8 @@ fn item_query_wire() {
|
||||
other => panic!("plain refusal, got {}", other.name()),
|
||||
}
|
||||
|
||||
// SMSG_MESSAGECHAT, the system shape (type 0x0A: guid, len-prefixed text incl. NUL, tag) —
|
||||
// the GM-command feedback channel (live-verified: '.additem' answers exactly this shape).
|
||||
// SMSG_MESSAGECHAT's system shape (type 0x0A: guid, length-prefixed text, tag), the shape
|
||||
// GM commands such as `.additem` answer with.
|
||||
let sys = messages::parse_server(
|
||||
messages::opcode::SMSG_MESSAGECHAT,
|
||||
&hx("0a0000000000000000000000000600000068656c6c6f0000"),
|
||||
@@ -161,13 +138,8 @@ fn item_query_wire() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_ITEM_QUERY_SINGLE_RESPONSE`, hit: the full 1.12.1 item template, byte-exact (VERIFIED
|
||||
/// field order vmangos `HandleItemQuerySingleOpcode`, `ItemHandler.cpp:269-415`; every
|
||||
/// `SUPPORTED_CLIENT_BUILD` conditional there is included for build 5875) — decision 0274 P1's
|
||||
/// tooltip builder needs every line. A main-hand sword exercising every filtered/mirrored shape at
|
||||
/// once: 2 of 5 damage blocks, 3 of 10 stats, 2 of 6 resistances, 2 of 5 spells (one ON_USE trigger
|
||||
/// 0, one ON_EQUIP trigger 1), a nonempty description, and real requirement/durability values.
|
||||
/// Every field carries a distinct value so a transposed or misaligned read fails the assert below.
|
||||
/// `SMSG_ITEM_QUERY_SINGLE_RESPONSE` in vmangos order (`ItemHandler.cpp:269-415`, build 5875
|
||||
/// branches), for a sword whose fields all differ so a misaligned read fails.
|
||||
#[test]
|
||||
fn item_query_response_full_weapon_golden() {
|
||||
use benilla_protocol::messages::{ItemDamage, ItemInfo, ItemSpellEntry, ItemUseSpell};
|
||||
@@ -198,8 +170,7 @@ fn item_query_response_full_weapon_golden() {
|
||||
body.extend_from_slice(&1u32.to_le_bytes()); // Stackable
|
||||
body.extend_from_slice(&0u32.to_le_bytes()); // ContainerSlots
|
||||
|
||||
// 10x ItemStat { type, value } — 3 nonzero (one negative value proves signed reads), 7 empty
|
||||
// (dropped by the nonzero filter).
|
||||
// 10 ItemStat { type, value }: empty ones are dropped; the negative one checks signed reads.
|
||||
let stat_slots: [(u32, i32); 10] = [
|
||||
(4, 15), // STRENGTH +15
|
||||
(7, 20), // STAMINA +20
|
||||
@@ -217,8 +188,8 @@ fn item_query_response_full_weapon_golden() {
|
||||
body.extend_from_slice(&stat_value.to_le_bytes());
|
||||
}
|
||||
|
||||
// 5x Damage { min f32, max f32, type u32 } — 2 real blocks (physical + a secondary Fire line),
|
||||
// 3 empty (dropped by the `max > 0` filter). Block 0 also mirrors into dmg_min/dmg_max/dmg_type.
|
||||
// 5 Damage { f32 min, f32 max, u32 type }: blocks with `max > 0` are kept, and block 0 also
|
||||
// fills dmg_min/dmg_max/dmg_type.
|
||||
let dmg_slots: [(f32, f32, u32); 5] = [
|
||||
(12.0, 22.0, 0), // physical
|
||||
(3.0, 7.0, 2), // Fire
|
||||
@@ -233,7 +204,7 @@ fn item_query_response_full_weapon_golden() {
|
||||
}
|
||||
|
||||
body.extend_from_slice(&15u32.to_le_bytes()); // Armor
|
||||
// Resistances: Holy/Fire/Nature/Frost/Shadow/Arcane — 2 of 6 nonzero.
|
||||
// Holy, Fire, Nature, Frost, Shadow, Arcane.
|
||||
for r in [0i32, 8, 0, 0, 12, 0] {
|
||||
body.extend_from_slice(&r.to_le_bytes());
|
||||
}
|
||||
@@ -241,10 +212,9 @@ fn item_query_response_full_weapon_golden() {
|
||||
body.extend_from_slice(&2u32.to_le_bytes()); // AmmoType
|
||||
body.extend_from_slice(&1.5f32.to_le_bytes()); // RangedModRange
|
||||
|
||||
// 5x Spell block { SpellId, SpellTrigger, SpellCharges, Cooldown, Category, CategoryCooldown }
|
||||
// — slot 0 ON_USE (a lone -1 use-cooldown next to a resolved category pair, the mixed shape
|
||||
// vmangos can send), slot 1 ON_EQUIP (a plain passive, no cooldown), slots 2-4 the server's own
|
||||
// unresolved-slot sentinel (0,0,0,-1,0,-1).
|
||||
// 5 spell blocks { id, trigger, charges, cooldown, category, categoryCooldown }: ON_USE with
|
||||
// a -1 cooldown beside a real category (a mix vmangos sends), ON_EQUIP, then the server's
|
||||
// empty-slot sentinel (0, 0, 0, -1, 0, -1).
|
||||
let spell_slots: [(u32, u32, i32, i32, u32, i32); 5] = [
|
||||
(17_251, 0, 0, -1, 4, 60_000),
|
||||
(671, 1, 0, 0, 0, 0),
|
||||
@@ -379,14 +349,9 @@ fn item_query_response_full_weapon_golden() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_ITEM_QUERY_SINGLE_RESPONSE`, hit: a minimal consumable (a potion). The server zeroes
|
||||
/// `subclass` for every `ITEM_CLASS_CONSUMABLE` row regardless of the DB value (VERIFIED vmangos
|
||||
/// `ItemHandler.cpp:300`) — that's a server-side rule, so this fixture just carries the
|
||||
/// already-zeroed wire byte; the parser itself has no class-conditional logic. What this exercises
|
||||
/// instead is the "everything empty" path the weapon golden above only partly covers: `stats`,
|
||||
/// `damages`, and 4 of 5 `spells` slots all read as real zeroed wire data that must land as **empty
|
||||
/// `Vec`s**, not get pushed as garbage entries — while the one real ON_USE slot still resolves both
|
||||
/// into `spells` and into the legacy `use_spell` accessor.
|
||||
/// A potion: zeroed stats, damages and spell slots land as empty `Vec`s while the ON_USE slot
|
||||
/// fills both `spells` and `use_spell`. The server zeroes a consumable's subclass (vmangos
|
||||
/// `ItemHandler.cpp:300`).
|
||||
#[test]
|
||||
fn item_query_response_consumable_all_zero_slots_are_empty_not_garbage() {
|
||||
use benilla_protocol::messages::{ItemInfo, ItemSpellEntry, ItemUseSpell};
|
||||
@@ -428,7 +393,7 @@ fn item_query_response_consumable_all_zero_slots_are_empty_not_garbage() {
|
||||
body.extend_from_slice(&0u32.to_le_bytes()); // Delay
|
||||
body.extend_from_slice(&0u32.to_le_bytes()); // AmmoType
|
||||
body.extend_from_slice(&0f32.to_le_bytes()); // RangedModRange
|
||||
// Spell slot 0: the ON_USE heal; slots 1-4 the server's unresolved-slot sentinel.
|
||||
// Slot 0: the ON_USE heal; 1-4: empty sentinels.
|
||||
body.extend_from_slice(&2024u32.to_le_bytes()); // SpellId
|
||||
body.extend_from_slice(&0u32.to_le_bytes()); // SpellTrigger: ON_USE
|
||||
body.extend_from_slice(&0u32.to_le_bytes()); // SpellCharges
|
||||
@@ -536,8 +501,7 @@ fn item_query_response_consumable_all_zero_slots_are_empty_not_garbage() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_ITEM_QUERY_SINGLE_RESPONSE`, miss: the lone entry with the top bit set (vmangos
|
||||
/// undiscovered/unknown-entry branch) — the same shape as the creature miss.
|
||||
/// A miss is the lone entry with the top bit set (vmangos's unknown-entry branch).
|
||||
#[test]
|
||||
fn item_query_response_miss() {
|
||||
let fail = messages::parse_server(
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
//! Oracle-free golden tests for the mail arc's protocol layer (decision 0544 phase P0): the
|
||||
//! `CMSG_GET_MAIL_LIST`/`CMSG_SEND_MAIL`/take-money/take-item/mark-read/return/delete/
|
||||
//! `CMSG_ITEM_TEXT_QUERY` send verbs, the `SMSG_MAIL_LIST_RESULT` inbox page (all three sender
|
||||
//! shapes + the always-present item block), `SMSG_SEND_MAIL_RESULT`'s three tail shapes, the
|
||||
//! letter-body fetch reply, and the arrival pair (`SMSG_RECEIVED_MAIL` / `MSG_QUERY_NEXT_MAIL_TIME`).
|
||||
//! Same idioms as `tests/trainer.rs` — `hx(...)` golden CMSG bodies, hand-built SMSG bodies
|
||||
//! round-tripped through `parse_server`, and a `decode()` bridge assertion.
|
||||
//! The mail wire: the send verbs, the inbox page with its three sender shapes, the send result's
|
||||
//! tails, the letter-body fetch, and the arrival notices.
|
||||
|
||||
use benilla_protocol::events::{decode, SessionEvent};
|
||||
use benilla_protocol::messages::{
|
||||
@@ -30,8 +25,6 @@ fn mail_send_bodies_golden() {
|
||||
"CMSG_GET_MAIL_LIST body"
|
||||
);
|
||||
|
||||
// CMSG_SEND_MAIL: u64 mailbox, cstr receiver, cstr subject, cstr body, u32 stationery, u32
|
||||
// package, u64 itemGuid, u32 money, u32 COD, then the 9-byte zero tail (u64 0 + u8 0).
|
||||
assert_eq!(
|
||||
messages::send_mail(
|
||||
MAILBOX,
|
||||
@@ -68,7 +61,7 @@ fn mail_send_bodies_golden() {
|
||||
assert_eq!(messages::mail_return_to_sender(MAILBOX, 77), golden);
|
||||
assert_eq!(messages::mail_delete(MAILBOX, 77), golden);
|
||||
|
||||
// CMSG_MAIL_CREATE_TEXT_ITEM: the shared shape + u32 mailTemplateId(0) (vmangos
|
||||
// CMSG_MAIL_CREATE_TEXT_ITEM: the shared shape, then `u32` mailTemplateId 0 (vmangos
|
||||
// `MailCreateTextItem::ReadFromWorldPacket`).
|
||||
assert_eq!(
|
||||
messages::mail_create_text_item(MAILBOX, 77),
|
||||
@@ -84,9 +77,8 @@ fn mail_send_bodies_golden() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Append one 8-byte-fixed item block (the tail every `SMSG_MAIL_LIST_RESULT` row always carries,
|
||||
/// zeroed when the mail has no attachment): entry, permEnchant, randomPropId, suffixFactor u32 ×4,
|
||||
/// stackCount u8, spellCharges/durabilityMax/durabilityCur u32 ×3.
|
||||
/// Append the item block every `SMSG_MAIL_LIST_RESULT` row carries, zeroed without an attachment:
|
||||
/// four `u32`, a `u8` stack count, then three `u32`.
|
||||
fn push_item_block(
|
||||
body: &mut Vec<u8>,
|
||||
entry: u32,
|
||||
@@ -110,14 +102,11 @@ fn push_item_block(
|
||||
|
||||
#[test]
|
||||
fn mail_list_result_wire() {
|
||||
// SMSG_MAIL_LIST_RESULT: u8 count, then per row: u32 messageId, u8 messageType, the sender
|
||||
// branch keyed by messageType, cstr subject, u32 itemTextId, u32 package(dropped), u32
|
||||
// stationery, the item block (ALWAYS present), u32 money, u32 COD, u32 checked, f32
|
||||
// expireDays, u32 mailTemplateId. Three rows exercise all three sender shapes.
|
||||
// SMSG_MAIL_LIST_RESULT: `u8` count, then rows whose sender field depends on the message type
|
||||
// and whose item block is always present.
|
||||
let mut body = vec![3u8]; // count
|
||||
|
||||
// Row 1 — MAIL_NORMAL: a player sender (guid), an attached item, money + COD + checked flags,
|
||||
// a fractional expire_days.
|
||||
// MAIL_NORMAL: the sender is a player guid.
|
||||
body.extend_from_slice(&1u32.to_le_bytes()); // messageId
|
||||
body.push(mail_message_type::NORMAL);
|
||||
body.extend_from_slice(&0x0000_0001_0000_00AAu64.to_le_bytes()); // sender guid
|
||||
@@ -132,7 +121,7 @@ fn mail_list_result_wire() {
|
||||
body.extend_from_slice(&2.5f32.to_le_bytes()); // expire_days
|
||||
body.extend_from_slice(&0u32.to_le_bytes()); // mailTemplateId
|
||||
|
||||
// Row 2 — MAIL_AUCTION: a u32 sender id, no item (all-zero item block).
|
||||
// MAIL_AUCTION: the sender is a `u32` id; no item, so an all-zero item block.
|
||||
body.extend_from_slice(&2u32.to_le_bytes());
|
||||
body.push(mail_message_type::AUCTION);
|
||||
body.extend_from_slice(&42u32.to_le_bytes()); // sender id (auction id)
|
||||
@@ -147,7 +136,7 @@ fn mail_list_result_wire() {
|
||||
body.extend_from_slice(&1.0f32.to_le_bytes()); // expire_days
|
||||
body.extend_from_slice(&0u32.to_le_bytes());
|
||||
|
||||
// Row 3 — MAIL_ITEM: NO sender bytes at all.
|
||||
// MAIL_ITEM: no sender bytes at all.
|
||||
body.extend_from_slice(&3u32.to_le_bytes());
|
||||
body.push(mail_message_type::ITEM);
|
||||
body.extend_from_slice(b"Welcome\0");
|
||||
@@ -216,7 +205,6 @@ fn mail_list_result_wire() {
|
||||
other => panic!("mail list, got {}", other.name()),
|
||||
}
|
||||
|
||||
// The decode() bridge carries the wire rows through unchanged.
|
||||
match decode(packet).pop().unwrap() {
|
||||
SessionEvent::MailList { mails } => assert_eq!(mails.len(), 3),
|
||||
other => panic!("mail list event, got {other:?}"),
|
||||
@@ -225,7 +213,7 @@ fn mail_list_result_wire() {
|
||||
|
||||
#[test]
|
||||
fn send_mail_result_wire() {
|
||||
// Plain shape: action SEND, error OK — neither conditional tail rides.
|
||||
// Action SEND, error OK: no tail.
|
||||
let mut plain = 1u32.to_le_bytes().to_vec();
|
||||
plain.extend_from_slice(&mail_action::SEND.to_le_bytes());
|
||||
plain.extend_from_slice(&mail_error::OK.to_le_bytes());
|
||||
@@ -246,7 +234,6 @@ fn send_mail_result_wire() {
|
||||
}
|
||||
other => panic!("send mail result (plain), got {}", other.name()),
|
||||
}
|
||||
// The decode() bridge, asserted on this shape.
|
||||
let packet = messages::parse_server(messages::opcode::SMSG_SEND_MAIL_RESULT, &plain).unwrap();
|
||||
match decode(packet).pop().unwrap() {
|
||||
SessionEvent::SendMailResult {
|
||||
@@ -335,15 +322,13 @@ fn item_text_query_response_wire() {
|
||||
|
||||
#[test]
|
||||
fn received_mail_wire() {
|
||||
// SMSG_RECEIVED_MAIL: one f32 delay, the same countdown units as MSG_QUERY_NEXT_MAIL_TIME's
|
||||
// reply (decision 0913 — the real client reads these four bytes as a float). vmangos writes
|
||||
// them as `uint32(0)`, and 0x00000000 *is* 0.0f, so its only value decodes as "waiting now".
|
||||
// SMSG_RECEIVED_MAIL: one `f32` delay, which the reference reads as a float; vmangos writes
|
||||
// `uint32(0)`, the same bits as 0.0.
|
||||
let vmangos_body = 0u32.to_le_bytes();
|
||||
match messages::parse_server(messages::opcode::SMSG_RECEIVED_MAIL, &vmangos_body).unwrap() {
|
||||
ServerPacket::ReceivedMail { seconds } => assert_eq!(seconds, 0.0),
|
||||
other => panic!("received mail, got {}", other.name()),
|
||||
}
|
||||
// A server that sends a real delay round-trips it.
|
||||
for seconds in [0.0f32, 120.0f32] {
|
||||
let body = seconds.to_le_bytes();
|
||||
let packet = messages::parse_server(messages::opcode::SMSG_RECEIVED_MAIL, &body).unwrap();
|
||||
@@ -356,7 +341,7 @@ fn received_mail_wire() {
|
||||
|
||||
#[test]
|
||||
fn query_next_mail_time_wire() {
|
||||
// MSG_QUERY_NEXT_MAIL_TIME's reply: one f32 — 0.0 unread waiting, -86400.0 none.
|
||||
// MSG_QUERY_NEXT_MAIL_TIME's reply: one `f32`, 0.0 for unread mail waiting, -86400.0 for none.
|
||||
for seconds in [0.0f32, -86400.0f32] {
|
||||
let body = seconds.to_le_bytes();
|
||||
match messages::parse_server(messages::opcode::MSG_QUERY_NEXT_MAIL_TIME, &body).unwrap() {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Player-movement relay + teleport-ack wire tests: the `[packed guid][MovementInfo]` relay shape
|
||||
//! that rebroadcasts other players' `MSG_MOVE_*` locomotion (including the jump ballistic tail), and
|
||||
//! `MSG_MOVE_TELEPORT_ACK`'s server-form pose. Split out of the former `tests/messages.rs` — see
|
||||
//! `tests/common` for the shared fixtures and methodology note.
|
||||
//! The movement wire: the `[packed guid][MovementInfo]` relay of other units' moves, teleport
|
||||
//! acks, speed changes, knockbacks, compressed batches and the movement-mode opcodes.
|
||||
|
||||
mod common;
|
||||
|
||||
@@ -13,10 +11,8 @@ use common::hx;
|
||||
|
||||
#[test]
|
||||
fn movement_relay_decodes_to_unit_move() {
|
||||
// A relayed player move: [packed mover guid][MovementInfo]. Packed guid 0xAA = mask 0x01 + byte
|
||||
// 0xAA; the MovementInfo body is the same 28-byte FORWARD info pinned in `client_bodies_golden`
|
||||
// (flags 0x1, time, pos, orientation 1.25, fall_time). The server rebroadcasts with the mover's
|
||||
// opcode — here MSG_MOVE_START_FORWARD — which is how another player's walking reaches us.
|
||||
// Packed guid 0xAA, then the 28-byte FORWARD MovementInfo of `client_bodies_golden`; the
|
||||
// server relays another player's move under the mover's own opcode.
|
||||
let body = hx("01aa0100000004030201cdd70bc6357e04c3f90fa7420000a03f00000000");
|
||||
match messages::parse_server(messages::opcode::MSG_MOVE_START_FORWARD, &body).unwrap() {
|
||||
ServerPacket::PlayerMove {
|
||||
@@ -40,8 +36,7 @@ fn movement_relay_decodes_to_unit_move() {
|
||||
}
|
||||
p => panic!("expected PlayerMove, got {}", p.name()),
|
||||
}
|
||||
// The whole relay family routes through the same body shape — spot-check a couple more opcodes and
|
||||
// the fan-out into a SessionEvent the ECS consumes.
|
||||
// Every relay opcode shares the body shape.
|
||||
for op in [
|
||||
messages::opcode::MSG_MOVE_HEARTBEAT,
|
||||
messages::opcode::MSG_MOVE_SET_FACING,
|
||||
@@ -65,9 +60,8 @@ fn movement_relay_decodes_to_unit_move() {
|
||||
|
||||
#[test]
|
||||
fn jump_relay_round_trips_the_ballistic_tail() {
|
||||
// A jumping MovementInfo (JUMPING 0x2000 | FORWARD) carries the ballistic launch tail. Round-trip it
|
||||
// through write → parse to pin the wire layout: `fall_time` is a u32 (ms), and the jump tail order is
|
||||
// `zspeed, cosAngle, sinAngle, xyspeed` — cos *before* sin (VERIFIED vmangos `MovementInfo::Read`).
|
||||
// JUMPING (0x2000) adds the launch tail after the `u32` fall_time (ms): zspeed, cosAngle,
|
||||
// sinAngle, xyspeed, cos before sin (vmangos `MovementInfo::Read`).
|
||||
let mi = MovementInfo {
|
||||
flags: 0x2000 | 0x1,
|
||||
timestamp: 0x0102_0304,
|
||||
@@ -77,8 +71,8 @@ fn jump_relay_round_trips_the_ballistic_tail() {
|
||||
z: 3.0,
|
||||
},
|
||||
orientation: 0.5,
|
||||
transport: None, // not on a transport — no transport tail is written
|
||||
pitch: 0.0, // not swimming — no pitch tail is written
|
||||
transport: None, // not on a transport: no transport tail
|
||||
pitch: 0.0, // not swimming: no pitch tail
|
||||
fall_time: 250,
|
||||
jump: Some(messages::JumpInfo {
|
||||
zspeed: 7.955_547,
|
||||
@@ -87,7 +81,7 @@ fn jump_relay_round_trips_the_ballistic_tail() {
|
||||
xy_speed: 7.0,
|
||||
}),
|
||||
};
|
||||
let mut body = hx("01aa"); // [packed guid 0xAA][MovementInfo] — the relay shape.
|
||||
let mut body = hx("01aa"); // [packed guid 0xAA][MovementInfo], the relay shape.
|
||||
body.extend_from_slice(&messages::movement(&mi));
|
||||
match messages::parse_server(messages::opcode::MSG_MOVE_JUMP, &body).unwrap() {
|
||||
ServerPacket::PlayerMove {
|
||||
@@ -111,35 +105,29 @@ fn jump_relay_round_trips_the_ballistic_tail() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A relayed rider on a transport: `MOVEFLAG_ON_TRANSPORT | MOVEFLAG_SWIMMING | MOVEFLAG_JUMPING` all
|
||||
/// set, so the transport pose, swim pitch, `fall_time`, and jump tail all ride the same packet — the
|
||||
/// exact regression the old discard comment warned about (`messages/movement.rs`'s prior "the
|
||||
/// transport ... tail is parsed to stay aligned but discarded"): a wrong transport-tail length would
|
||||
/// misalign every tail after it. `write()` never serializes a transport tail (benilla doesn't set the
|
||||
/// flag outbound — riding is decision 0438 phase 2), so this fixture is hand-built to the exact byte
|
||||
/// order `read_movement_info` implements.
|
||||
/// A rider with ON_TRANSPORT, SWIMMING and JUMPING set: the transport pose, pitch, fall_time and
|
||||
/// jump tail share one packet, so a wrong transport-tail length misaligns everything after it.
|
||||
#[test]
|
||||
fn movement_relay_surfaces_transport_pose_and_keeps_the_rest_aligned() {
|
||||
// An elevator (HIGH_TRANSPORT, 0xF120): template entry 900, DB spawn low guid 4242 — the standard
|
||||
// entry layout (guid.rs `entry()`).
|
||||
// An elevator guid (HIGH_TRANSPORT 0xF120): entry 900, low guid 4242.
|
||||
let transport_guid: u64 = 4242 | (900u64 << 24) | (0xF120u64 << 48);
|
||||
|
||||
let mut body = Vec::new();
|
||||
write_packed_guid(0xAA, &mut body).unwrap(); // mover guid
|
||||
let flags: u32 = 0x0200_0000 | 0x20_0000 | 0x2000 | 0x1; // ON_TRANSPORT | SWIMMING | JUMPING | FORWARD
|
||||
let flags: u32 = 0x0200_0000 | 0x20_0000 | 0x2000 | 0x1; // transport, swim, jump, forward
|
||||
body.extend_from_slice(&flags.to_le_bytes());
|
||||
body.extend_from_slice(&0x1122_3344u32.to_le_bytes()); // timestamp
|
||||
body.extend_from_slice(&10.0f32.to_le_bytes()); // position.x
|
||||
body.extend_from_slice(&20.0f32.to_le_bytes()); // position.y
|
||||
body.extend_from_slice(&30.0f32.to_le_bytes()); // position.z
|
||||
body.extend_from_slice(&0.75f32.to_le_bytes()); // orientation
|
||||
body.extend_from_slice(&transport_guid.to_le_bytes()); // ON_TRANSPORT tail: FULL u64 guid
|
||||
body.extend_from_slice(&transport_guid.to_le_bytes()); // ON_TRANSPORT tail: full u64 guid
|
||||
body.extend_from_slice(&1.0f32.to_le_bytes()); // local x
|
||||
body.extend_from_slice(&2.0f32.to_le_bytes()); // local y
|
||||
body.extend_from_slice(&3.0f32.to_le_bytes()); // local z
|
||||
body.extend_from_slice(&0.5f32.to_le_bytes()); // local o
|
||||
body.extend_from_slice(&(-0.3f32).to_le_bytes()); // SWIMMING tail: pitch
|
||||
body.extend_from_slice(&999u32.to_le_bytes()); // fall_time (u32 — must land right after pitch)
|
||||
body.extend_from_slice(&999u32.to_le_bytes()); // fall_time (u32, right after pitch)
|
||||
body.extend_from_slice(&7.9f32.to_le_bytes()); // JUMPING tail: zspeed
|
||||
body.extend_from_slice(&0.6f32.to_le_bytes()); // cos_angle
|
||||
body.extend_from_slice(&0.8f32.to_le_bytes()); // sin_angle
|
||||
@@ -186,8 +174,6 @@ fn movement_relay_surfaces_transport_pose_and_keeps_the_rest_aligned() {
|
||||
p => panic!("expected PlayerMove, got {}", p.name()),
|
||||
}
|
||||
|
||||
// The same fixture through the SessionEvent fan-out: UnitMove carries the same transport pose and
|
||||
// the same post-tail fields.
|
||||
let events =
|
||||
decode(messages::parse_server(messages::opcode::MSG_MOVE_HEARTBEAT, &body).unwrap());
|
||||
match events.as_slice() {
|
||||
@@ -207,8 +193,7 @@ fn movement_relay_surfaces_transport_pose_and_keeps_the_rest_aligned() {
|
||||
|
||||
#[test]
|
||||
fn teleport_ack_parses_pose() {
|
||||
// MSG_MOVE_TELEPORT_ACK (server→client form): [packed guid][counter u32][MovementInfo]. Exercises
|
||||
// the refactored read_movement_info (non-transport path) end to end.
|
||||
// MSG_MOVE_TELEPORT_ACK from the server: [packed guid][u32 counter][MovementInfo].
|
||||
let body = hx("01aa070000000100000004030201cdd70bc6357e04c3f90fa7420000a03f00000000");
|
||||
match messages::parse_server(messages::opcode::MSG_MOVE_TELEPORT_ACK, &body).unwrap() {
|
||||
ServerPacket::Teleport {
|
||||
@@ -229,11 +214,9 @@ fn teleport_ack_parses_pose() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The force-speed-change family, byte-exact both directions (a new wire body never lands without
|
||||
/// a golden — method): the SMSG body is `[packed guid][u32 counter][f32 speed]` (vmangos
|
||||
/// `SendSpeedChangeToController`, 5875 branch), and the ACK body is `[u64 FULL guid][u32 counter]
|
||||
/// [MovementInfo][f32 speed]` (`MoveSpeedAck::ReadFromWorldPacket` — a plain `ObjectGuid`
|
||||
/// extraction is a raw u64, `ObjectGuid.cpp:180`, NOT packed like the SMSG's).
|
||||
/// `SMSG_FORCE_*_SPEED_CHANGE` is `[packed guid][u32 counter][f32 speed]` (vmangos
|
||||
/// `SendSpeedChangeToController`); the ack is `[u64 guid][u32 counter][MovementInfo][f32 speed]`
|
||||
/// with the guid unpacked (`MoveSpeedAck::ReadFromWorldPacket`).
|
||||
#[test]
|
||||
fn force_speed_change_parses_and_ack_body_golden() {
|
||||
use benilla_protocol::messages::SpeedKind;
|
||||
@@ -260,8 +243,7 @@ fn force_speed_change_parses_and_ack_body_golden() {
|
||||
}] => assert_eq!(*speed, 14.0),
|
||||
other => panic!("expected one ForceSpeedChange event, got {other:?}"),
|
||||
}
|
||||
// Every kind maps to its own SMSG opcode (the walk/swim-back/turn-rate trio lives in the
|
||||
// 730-735 block, not the 226-231 run).
|
||||
// Each kind has its own opcode; walk, swim-back and turn rate sit at 730-735, not 226-231.
|
||||
for (op, kind) in [
|
||||
(
|
||||
messages::opcode::SMSG_FORCE_WALK_SPEED_CHANGE,
|
||||
@@ -282,8 +264,6 @@ fn force_speed_change_parses_and_ack_body_golden() {
|
||||
}
|
||||
}
|
||||
|
||||
// The ACK body golden: full 8-byte guid, counter, a stationary MovementInfo (flags 0 => no
|
||||
// conditional tails), then the echoed speed.
|
||||
let info = MovementInfo {
|
||||
flags: 0,
|
||||
timestamp: 12345,
|
||||
@@ -303,28 +283,18 @@ fn force_speed_change_parses_and_ack_body_golden() {
|
||||
hx("08000000000000000700000000000000393000000000803f00000040000040400000003f2a00000000006041"),
|
||||
"CMSG_FORCE_*_SPEED_CHANGE_ACK body"
|
||||
);
|
||||
// The ack opcode per kind — the run ack is 227, the walk ack lives in the high block (731).
|
||||
assert_eq!(SpeedKind::Run.ack_opcode(), 0x00E3);
|
||||
assert_eq!(SpeedKind::Walk.ack_opcode(), 0x02DB);
|
||||
assert_eq!(SpeedKind::TurnRate.ack_opcode(), 0x02DF);
|
||||
}
|
||||
|
||||
/// **The knockback handshake, byte-exact both ways** (decision 1702).
|
||||
///
|
||||
/// The two halves are one test because the second is only correct *relative to* the first: the ack's
|
||||
/// jump tail has to be the arriving quad, and the two packets order those same four floats
|
||||
/// differently. `SMSG_MOVE_KNOCK_BACK` is direction-first (`vcos, vsin, speedXY, speedZ`); the
|
||||
/// `MovementInfo` jump tail is `zspeed, cos, sin, xyspeed`. Read one in the other's order and every
|
||||
/// field is still a plausible float, the arc flies off at a wrong angle, and
|
||||
/// `FindPendingMovementKnockbackChange` rejects the ack — so this is exactly the transposition a
|
||||
/// type alone cannot catch.
|
||||
///
|
||||
/// `speedZ` is negative here because it is **down-positive**: this is a knockback that throws the
|
||||
/// body *upward* at 12 yd/s (wow-re: `0x7c61f0` stores the wire value verbatim into the same
|
||||
/// `CMovement+0xa0` a land jump seeds with `0xc0fe93d8` = −7.955547).
|
||||
/// The knockback and its ack: `SMSG_MOVE_KNOCK_BACK` orders the launch vcos, vsin, speedXY,
|
||||
/// speedZ; the ack's jump tail orders it zspeed, cos, sin, xyspeed, and vmangos rejects a
|
||||
/// mismatched ack. speedZ is down-positive: the reference stores it as-is (`0x7c61f0`) where a
|
||||
/// jump stores -7.955547, so -12 throws the body up.
|
||||
#[test]
|
||||
fn knock_back_parses_and_ack_body_golden() {
|
||||
// packed guid 8 (mask 0x01, one byte), counter 7, then vcos 0.6, vsin 0.8, speedXY 25, speedZ −12.
|
||||
// Packed guid 8, counter 7, then vcos 0.6, vsin 0.8, speedXY 25, speedZ -12.
|
||||
let body = hx("0108070000009a99193fcdcc4c3f0000c841000040c1");
|
||||
let packet = messages::parse_server(messages::opcode::SMSG_MOVE_KNOCK_BACK, &body).unwrap();
|
||||
let launch = match &packet {
|
||||
@@ -354,9 +324,8 @@ fn knock_back_parses_and_ack_body_golden() {
|
||||
other => panic!("expected one KnockBack event, got {other:?}"),
|
||||
}
|
||||
|
||||
// The ACK body golden: a **full** 8-byte guid (packed on the way in, never on the way out), the
|
||||
// echoed counter, and a `MovementInfo` carrying `MOVEFLAG_JUMPING` (0x2000) — without which the
|
||||
// jump tail is not serialized at all and the server has nothing to match against.
|
||||
// The ack: the full guid (packed only inbound), the counter, and a MovementInfo with
|
||||
// MOVEFLAG_JUMPING (0x2000), without which no jump tail is written for the server to match.
|
||||
let info = MovementInfo {
|
||||
flags: 0x2000,
|
||||
timestamp: 12345,
|
||||
@@ -380,17 +349,12 @@ fn knock_back_parses_and_ack_body_golden() {
|
||||
assert_eq!(messages::opcode::CMSG_MOVE_KNOCK_BACK_ACK, 0x00F0);
|
||||
}
|
||||
|
||||
/// **Somebody else's knockback reaches us, and replays as an arc** (decision 1702, correcting
|
||||
/// decision 0277's "no observer-side knockback signal exists in 1.12 at all").
|
||||
///
|
||||
/// `MSG_MOVE_KNOCK_BACK` is an ordinary `[packed guid][MovementInfo]` relay with the launch quad
|
||||
/// appended — and the appendix is redundant by construction, because the server builds that
|
||||
/// `MovementInfo` from the victim's own ack, whose jump tail already carries the quad. So the
|
||||
/// trailing four floats go unread and the arc still replays: this test's assertion is that the tail
|
||||
/// the reader *does* reach is the launch, not that the packet was consumed to the last byte.
|
||||
/// `MSG_MOVE_KNOCK_BACK` relays another unit's knockback: `[packed guid][MovementInfo]` and the
|
||||
/// launch again. The MovementInfo is built from the victim's ack, whose jump tail already holds
|
||||
/// the launch, so the appended quad goes unread.
|
||||
#[test]
|
||||
fn an_observed_knockback_relays_the_arc() {
|
||||
// packed guid 0xAA, a JUMPING MovementInfo whose tail is (zspeed −12, cos 0.6, sin 0.8, xy 25),
|
||||
// packed guid 0xAA, a JUMPING MovementInfo whose tail is (zspeed -12, cos 0.6, sin 0.8, xy 25),
|
||||
// then the same quad again in the SMSG's own direction-first order.
|
||||
let body = hx("01aa00200000393000000000803f00000040000040400000003f00000000000040c19a99193fcdcc4c3f0000c8419a99193fcdcc4c3f0000c841000040c1");
|
||||
let packet = messages::parse_server(messages::opcode::MSG_MOVE_KNOCK_BACK, &body).unwrap();
|
||||
@@ -413,12 +377,9 @@ fn an_observed_knockback_relays_the_arc() {
|
||||
assert_eq!(messages::opcode::MSG_MOVE_KNOCK_BACK, 0x00F1);
|
||||
}
|
||||
|
||||
/// The OBSERVER speed legs, byte-exact (decision 0441 — how an observed unit's mounted speed
|
||||
/// reaches us; no counter, no ack on either): `SMSG_SPLINE_SET_*_SPEED` is `[packed guid]
|
||||
/// [f32 speed]` (vmangos `SendSpeedChangeToAll` + the mid-spline observer branch), and
|
||||
/// `MSG_MOVE_SET_*_SPEED` is `[packed guid][MovementInfo][f32 speed]`
|
||||
/// (`SendSpeedChangeToObservers`, finalized branch) — a speed change AND a fresh pose, decoding
|
||||
/// to a `UnitMove` + `SpeedChanged` pair.
|
||||
/// An observed unit's speed changes, with no counter or ack: `SMSG_SPLINE_SET_*_SPEED` is
|
||||
/// `[packed guid][f32 speed]` (vmangos `SendSpeedChangeToAll`), `MSG_MOVE_SET_*_SPEED` is
|
||||
/// `[packed guid][MovementInfo][f32 speed]` (`SendSpeedChangeToObservers`), a pose and a speed.
|
||||
#[test]
|
||||
fn observer_speed_legs_parse_golden() {
|
||||
use benilla_protocol::messages::SpeedKind;
|
||||
@@ -446,8 +407,7 @@ fn observer_speed_legs_parse_golden() {
|
||||
}] => assert_eq!(*speed, 14.0),
|
||||
other => panic!("expected one SpeedChanged event, got {other:?}"),
|
||||
}
|
||||
// Each kind maps to its own opcode — spot-check the walk (769) and swim-back (770) slots,
|
||||
// whose order differs from the FORCE family's.
|
||||
// Walk (769) and swim-back (770) sit in a different order from the FORCE family's.
|
||||
for (op, kind) in [
|
||||
(
|
||||
messages::opcode::SMSG_SPLINE_SET_WALK_SPEED,
|
||||
@@ -464,8 +424,7 @@ fn observer_speed_legs_parse_golden() {
|
||||
}
|
||||
}
|
||||
|
||||
// MSG_MOVE_SET_RUN_SPEED: [packed guid 0xAA][the shared 28-byte FORWARD MovementInfo golden]
|
||||
// [speed 14.0] — decodes to the pose + the speed, in one drain.
|
||||
// MSG_MOVE_SET_RUN_SPEED: packed guid 0xAA, the shared FORWARD MovementInfo, speed 14.0.
|
||||
let body = hx("01aa0100000004030201cdd70bc6357e04c3f90fa7420000a03f0000000000006041");
|
||||
let packet = messages::parse_server(messages::opcode::MSG_MOVE_SET_RUN_SPEED, &body).unwrap();
|
||||
match &packet {
|
||||
@@ -523,8 +482,7 @@ fn observer_speed_legs_parse_golden() {
|
||||
other => panic!("expected MountResult(dismount, 3), got {other:?}"),
|
||||
}
|
||||
|
||||
// SMSG_MOUNTSPECIAL_ANIM: one raw u64 guid, NOT packed (vmangos
|
||||
// `HandleMountSpecialAnimOpcode` writes `data << GetObjectGuid()` — full 8 bytes).
|
||||
// SMSG_MOUNTSPECIAL_ANIM: one unpacked `u64` guid (vmangos `HandleMountSpecialAnimOpcode`).
|
||||
match decode(
|
||||
messages::parse_server(
|
||||
messages::opcode::SMSG_MOUNTSPECIAL_ANIM,
|
||||
@@ -541,20 +499,16 @@ fn observer_speed_legs_parse_golden() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_COMPRESSED_MOVES` — the batch carrier vmangos switches a session onto once it has been
|
||||
/// sent 300 movement packets in ten seconds (decision 0624). The fixture is built exactly as
|
||||
/// `MovementData::AddPacket` + `BuildPacket` do: `[u32 uncompressed size][zlib(records)]`, each
|
||||
/// record `[u8 size][u16 opcode][body]` with `size` counting the opcode's two bytes. The two
|
||||
/// records carry the same relay bodies pinned by `movement_relay_decodes_to_unit_move` above, so
|
||||
/// what this adds is the *framing* — that a batched move decodes identically to a loose one.
|
||||
/// `SMSG_COMPRESSED_MOVES`, which vmangos switches a session to after 300 movement packets in ten
|
||||
/// seconds: `[u32 size][zlib(records)]`, each record `[u8 size][u16 opcode][body]` with the size
|
||||
/// counting the opcode (vmangos `MovementData::BuildPacket`).
|
||||
#[test]
|
||||
fn compressed_moves_unwraps_to_the_same_events_as_loose_relays() {
|
||||
let body = hx("42000000780153d8cac0b88a91818181859989f1ec75ee63a6752c877ff22f77626058600f146650b805540062602a38005600002f2610d9");
|
||||
let events =
|
||||
decode(messages::parse_server(messages::opcode::SMSG_COMPRESSED_MOVES, &body).unwrap());
|
||||
|
||||
// Both records, in wire order, as ordinary moves — order is load-bearing: the remote-replay
|
||||
// queue reconstructs a mover's arc from it (decision 0618).
|
||||
// Both records, in wire order: the remote-replay queue rebuilds a mover's arc from that order.
|
||||
match events.as_slice() {
|
||||
[SessionEvent::UnitMove {
|
||||
guid: 0xAA,
|
||||
@@ -570,7 +524,6 @@ fn compressed_moves_unwraps_to_the_same_events_as_loose_relays() {
|
||||
),
|
||||
}
|
||||
|
||||
// A batched move must be byte-identical to the same move arriving loose.
|
||||
let loose = decode(
|
||||
messages::parse_server(
|
||||
messages::opcode::MSG_MOVE_START_FORWARD,
|
||||
@@ -581,39 +534,25 @@ fn compressed_moves_unwraps_to_the_same_events_as_loose_relays() {
|
||||
assert_eq!(format!("{:?}", events[0]), format!("{:?}", loose[0]));
|
||||
}
|
||||
|
||||
/// A misframed batch must fail **loudly** (one `Poll::Skipped` naming the inner opcode), never
|
||||
/// silently drop the moves it carries — the exact silence that made the runaway unattributable for
|
||||
/// three rounds of fixes (decision 0624).
|
||||
/// A misframed batch is a parse error, never a silent drop of the moves it carries.
|
||||
#[test]
|
||||
fn a_misframed_batch_errors_rather_than_dropping_moves() {
|
||||
// A HEARTBEAT record claiming a 40-byte body with only 4 bytes left in the stream.
|
||||
let truncated = hx("070000007801d37ac700020006c10119");
|
||||
assert!(messages::parse_server(messages::opcode::SMSG_COMPRESSED_MOVES, &truncated).is_err());
|
||||
|
||||
// A record whose size (1) cannot even hold the opcode that follows it — misframing, not an
|
||||
// empty-body packet, and it must not underflow into a huge body length.
|
||||
// A record whose size (1) cannot hold its opcode: misframed, and it must not underflow.
|
||||
let undersized = hx("030000007801637cc7000001e200f0");
|
||||
assert!(messages::parse_server(messages::opcode::SMSG_COMPRESSED_MOVES, &undersized).is_err());
|
||||
}
|
||||
|
||||
/// **The observer movement-mode family, byte-exact** (decision 1780) — the `SMSG_SPLINE_MOVE_*`
|
||||
/// twelve. Three things are asserted because all three are how this family differs from the ack'd
|
||||
/// `SMSG_FORCE_*` one it is otherwise a twin of:
|
||||
///
|
||||
/// 1. **The body is a bare packed guid** — parsing a *four-byte-shorter* body than the FORCE family's
|
||||
/// must succeed, so a trailing counter is not being read. Reading one would desync every
|
||||
/// subsequent packet in the same TCP read.
|
||||
/// 2. **All twelve opcodes are handled**, including `SMSG_SPLINE_MOVE_ROOT`'s out-of-band `0x31A`
|
||||
/// (the reference's dispatcher range is `0x304..=0x31A`, root deliberately at the top).
|
||||
/// 3. **The run/walk pair is inverted** — `SET_RUN_MODE` *clears* `MOVEFLAG_WALK_MODE`, because the
|
||||
/// reference's `0x617e80` hands the opcode's bool to `SetRunMode 0x7c71c0`, whose argument is
|
||||
/// *run*. Getting this backwards would walk every running creature in view.
|
||||
/// The twelve `SMSG_SPLINE_MOVE_*` opcodes (reference dispatch `0x304..=0x31A`, root at `0x31A`):
|
||||
/// the body is a bare packed guid with no counter, and `SET_RUN_MODE` clears
|
||||
/// `MOVEFLAG_WALK_MODE`, because the reference (`0x617e80`) passes its bool to `SetRunMode`.
|
||||
#[test]
|
||||
fn spline_move_mode_family_parses_golden() {
|
||||
use benilla_protocol::messages::SplineMode;
|
||||
|
||||
// Packed guid 0xAA (mask 0x01, one byte) and NOTHING else — four bytes shorter than the
|
||||
// FORCE family's `[packed guid][u32 counter]`.
|
||||
let body = hx("01aa");
|
||||
assert_eq!(body.len(), 2, "the whole body is one packed guid");
|
||||
|
||||
@@ -707,7 +646,6 @@ fn spline_move_mode_family_parses_golden() {
|
||||
}
|
||||
}
|
||||
|
||||
// The flag bits are the same word the ack'd family writes — the two families are one state.
|
||||
assert_eq!(SplineMode::Root.flag(), messages::MoveMode::Root.flag());
|
||||
assert_eq!(
|
||||
SplineMode::WaterWalk.flag(),
|
||||
@@ -720,32 +658,14 @@ fn spline_move_mode_family_parses_golden() {
|
||||
assert_eq!(SplineMode::Hover.flag(), messages::MoveMode::Hover.flag());
|
||||
}
|
||||
|
||||
/// **The observer leg of the movement-mode family, byte-exact** (decision 2061) — the six opcodes
|
||||
/// that tell everyone *else* a player was rooted, levitated or blinked. Until this landed they hit
|
||||
/// [`ServerPacket::Other`] and were dropped, so a watched player kept sliding through a root and
|
||||
/// stood in the wrong place after a Blink until their next ordinary pose packet.
|
||||
///
|
||||
/// Four things are asserted, because each is how this leg differs from a sibling it is otherwise a
|
||||
/// twin of:
|
||||
///
|
||||
/// 1. **The body is the ordinary relay shape** — `[packed guid][MovementInfo]`, no counter. That is
|
||||
/// the whole difference from the ack'd `SMSG_FORCE_MOVE_ROOT` leg (`[packed guid][u32 counter]`)
|
||||
/// and from `MSG_MOVE_TELEPORT_ACK` (`[packed guid][u32 counter][MovementInfo]`); reading a
|
||||
/// counter that isn't there would shift the pose by four bytes and desync the rest of the read.
|
||||
/// 2. **Apply/unapply rides the flags word, not the opcode.** Only root splits in two; hover,
|
||||
/// feather-fall and water-walk use ONE opcode for both directions, because vmangos runs
|
||||
/// `SetHoverReal` &co. *before* the broadcast (`MovementHandler.cpp:626-638`, `:743-744`) so the
|
||||
/// `m_movementInfo` it sends already carries the bit's new state.
|
||||
/// 3. **The whole word reaches the app**, which is what makes (2) work: `UnitMove.flags` is the
|
||||
/// wire word verbatim, and Levitate — feather fall + hover + water walk at once (decision 1706)
|
||||
/// — arrives as three packets whose flags words accumulate server-side.
|
||||
/// 4. **`MSG_MOVE_TELEPORT` is tagged as a teleport and nothing else is.** The pose is a
|
||||
/// discontinuity; the app's pre-fire reconcile has to know not to glide the mover into it.
|
||||
/// The movement-mode opcodes relayed to observers (root, hover, feather fall, water walk,
|
||||
/// teleport) use the plain relay shape, no counter. Only root has a separate off opcode: vmangos
|
||||
/// updates the others' flag before broadcasting (`MovementHandler.cpp:626-638`, `:743-744`), so
|
||||
/// the flags word carries the state. Only `MSG_MOVE_TELEPORT` is tagged as a teleport.
|
||||
#[test]
|
||||
fn observer_move_mode_family_parses_golden() {
|
||||
// Packed guid 0xAA (mask 0x01, one byte) + a 28-byte MovementInfo with no conditional tails:
|
||||
// flags, time 12345, pos (1, 2, 3), orientation 0.5, fall_time 0. 30 bytes total — FOUR SHORT
|
||||
// of `MSG_MOVE_TELEPORT_ACK`'s same-content body, which is the point of the length assertion.
|
||||
// Packed guid 0xAA and a 28-byte MovementInfo with no tails: time 12345, pos (1, 2, 3),
|
||||
// orientation 0.5, fall_time 0; 30 bytes, four fewer than `MSG_MOVE_TELEPORT_ACK`'s.
|
||||
let info = |flags: u32| {
|
||||
let mut b = hx("01aa");
|
||||
b.extend_from_slice(&flags.to_le_bytes());
|
||||
@@ -758,7 +678,7 @@ fn observer_move_mode_family_parses_golden() {
|
||||
const FEATHER_FALL: u32 = 0x2000_0000;
|
||||
const HOVER: u32 = 0x4000_0000;
|
||||
|
||||
// (opcode, the flags word the server would have written into it, the opcode's own verb)
|
||||
// (opcode, the flags word the server writes into it, the opcode's verb)
|
||||
let expected = [
|
||||
(messages::opcode::MSG_MOVE_ROOT, ROOT, RelayVerb::Root(true)),
|
||||
(messages::opcode::MSG_MOVE_UNROOT, 0, RelayVerb::Root(false)),
|
||||
@@ -767,8 +687,7 @@ fn observer_move_mode_family_parses_golden() {
|
||||
WATER_WALK,
|
||||
RelayVerb::Pose,
|
||||
),
|
||||
// The same opcode, the other direction — the bit is simply absent, and the verb is still
|
||||
// `Pose`: for these three the word is the whole message.
|
||||
// The same opcode, the other direction: the bit is absent and the verb is still `Pose`.
|
||||
(messages::opcode::MSG_MOVE_WATER_WALK, 0, RelayVerb::Pose),
|
||||
(
|
||||
messages::opcode::MSG_MOVE_FEATHER_FALL,
|
||||
@@ -776,7 +695,7 @@ fn observer_move_mode_family_parses_golden() {
|
||||
RelayVerb::Pose,
|
||||
),
|
||||
(messages::opcode::MSG_MOVE_HOVER, HOVER, RelayVerb::Pose),
|
||||
// Levitate grants all three at once (decision 1706) — one word, three bits.
|
||||
// Levitate grants all three at once: one word, three bits.
|
||||
(
|
||||
messages::opcode::MSG_MOVE_HOVER,
|
||||
HOVER | FEATHER_FALL | WATER_WALK,
|
||||
@@ -800,7 +719,6 @@ fn observer_move_mode_family_parses_golden() {
|
||||
} => {
|
||||
assert_eq!(*opcode, op);
|
||||
assert_eq!(*f, flags, "the whole flags word survives for {op:#06x}");
|
||||
// Four bytes off and this would read (2.0, 3.0, 0.5) — the counter-eating bug.
|
||||
assert_eq!(
|
||||
(position.x, position.y, position.z),
|
||||
(1.0, 2.0, 3.0),
|
||||
@@ -829,9 +747,7 @@ fn observer_move_mode_family_parses_golden() {
|
||||
}
|
||||
}
|
||||
|
||||
// The numbers themselves (vmangos `Opcodes_1_12_1.h`; cross-checked against the client's own
|
||||
// name table). `MSG_MOVE_TELEPORT` (197) is NOT `MSG_MOVE_TELEPORT_ACK` (199) — the pair this
|
||||
// family is easiest to get wrong on, and the reason (1)'s length assertion exists.
|
||||
// Opcode numbers (vmangos `Opcodes_1_12_1.h`).
|
||||
assert_eq!(messages::opcode::MSG_MOVE_TELEPORT, 197);
|
||||
assert_eq!(messages::opcode::MSG_MOVE_TELEPORT_ACK, 199);
|
||||
assert_eq!(messages::opcode::MSG_MOVE_ROOT, 236);
|
||||
|
||||
@@ -1,22 +1,4 @@
|
||||
//! Golden tests for the petition opcode family — the guild-charter flow that founds a guild
|
||||
//! (decision 1672). 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()` helper and methodology note.
|
||||
//!
|
||||
//! Four of the tests exist for a specific failure mode rather than for coverage, and each was
|
||||
//! mutation-checked — the code was broken that exact way and the test observed to fail:
|
||||
//!
|
||||
//! - `petition_buy_body_is_the_full_seventy_two_byte_frame` — vmangos's reader walks 72 fixed bytes
|
||||
//! field by field and never checks the remainder, so a body one field short leaves it reading
|
||||
//! garbage and the buy is dropped **silently**. There is no error packet for a malformed buy.
|
||||
//! - `petition_query_response_gender_is_sixteen_bit` — the one odd width in the family. Read as a
|
||||
//! `u32`, every field after it shifts by two bytes and the choice count becomes a huge number.
|
||||
//! - `petition_show_signatures_stride_is_twelve_bytes` — the trailing zero dword per signature is
|
||||
//! easy to omit; a nine-signature charter then reads eight signers and a truncation error, and a
|
||||
//! two-signature fixture would still pass.
|
||||
//! - `msg_petition_opcodes_read_a_different_body_than_they_write` — the two `MSG_` opcodes are
|
||||
//! genuinely asymmetric (decline sends an item guid and receives a player guid), which is exactly
|
||||
//! the shape a shared body type would paper over.
|
||||
//! The petition wire: the guild-charter flow that founds a guild, laid out per vmangos.
|
||||
|
||||
mod common;
|
||||
|
||||
@@ -27,7 +9,6 @@ use benilla_protocol::messages::{
|
||||
use benilla_protocol::ServerPacket;
|
||||
use common::hx;
|
||||
|
||||
/// Every CMSG builder in the family, byte-exact.
|
||||
#[test]
|
||||
fn cmsg_bodies_golden() {
|
||||
// CMSG_PETITION_SHOWLIST (vmangos Server/Packets/Petition.cpp:3-6): one u64, the NPC.
|
||||
@@ -37,7 +18,7 @@ fn cmsg_bodies_golden() {
|
||||
"CMSG_PETITION_SHOWLIST body"
|
||||
);
|
||||
|
||||
// CMSG_PETITION_SHOW_SIGNATURES (Petition.cpp:8-11): one u64, the charter ITEM.
|
||||
// CMSG_PETITION_SHOW_SIGNATURES (Petition.cpp:8-11): one u64, the charter item.
|
||||
assert_eq!(
|
||||
messages::petition_show_signatures(0x0000_0000_0001_0203),
|
||||
hx("0302010000000000"),
|
||||
@@ -51,16 +32,15 @@ fn cmsg_bodies_golden() {
|
||||
"CMSG_TURN_IN_PETITION body"
|
||||
);
|
||||
|
||||
// MSG_PETITION_DECLINE outbound (Petition.cpp:19-22): one u64 — the ITEM, not the player.
|
||||
// MSG_PETITION_DECLINE outbound (Petition.cpp:19-22): one u64, the item, not the player.
|
||||
assert_eq!(
|
||||
messages::petition_decline(0x0000_0000_0001_0203),
|
||||
hx("0302010000000000"),
|
||||
"MSG_PETITION_DECLINE body"
|
||||
);
|
||||
|
||||
// CMSG_PETITION_SIGN (Petition.cpp:35-39): u64 item, then an i8 the server reads and skips.
|
||||
// The client's own default for that byte is 1, not 0 (`0x4f46d9`) — and because the server
|
||||
// discards it, this golden is the ONLY thing that can tell the two apart.
|
||||
// CMSG_PETITION_SIGN (Petition.cpp:35-39): u64 item, then an i8 the server skips; the
|
||||
// reference defaults it to 1 (`0x4f46d9`).
|
||||
assert_eq!(
|
||||
messages::petition_sign(0x0000_0000_0001_0203, 1),
|
||||
hx("030201000000000001"),
|
||||
@@ -94,14 +74,9 @@ fn cmsg_bodies_golden() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `CMSG_PETITION_BUY` is 72 fixed bytes plus the name's, laid out exactly as vmangos's reader
|
||||
/// walks them (`Server/Packets/Petition.cpp:47-67`): `u64 npc`, `u32 0`, `u64 0`, cstring name,
|
||||
/// `10 × u32 0`, `u16 0`, `u8 0`, `u32 index`, `u32 0`.
|
||||
///
|
||||
/// **Mutation-checked.** Dropping any one of the skipped fields still compiles, still sends, and is
|
||||
/// answered by nothing at all — the server reads past the end, `IsValidCharterName` sees garbage or
|
||||
/// the handler returns, and no error packet exists for a malformed buy. This test is the only thing
|
||||
/// between that and a "the Purchase button does nothing" report.
|
||||
/// `CMSG_PETITION_BUY` is 72 fixed bytes plus the name, in vmangos's read order
|
||||
/// (`Server/Packets/Petition.cpp:47-67`); a short body gets no reply at all, since the server has
|
||||
/// no error packet for a malformed buy.
|
||||
#[test]
|
||||
fn petition_buy_body_is_the_full_seventy_two_byte_frame() {
|
||||
let body = messages::petition_buy(0x0000_0000_0000_2a1f, "Legacy");
|
||||
@@ -116,15 +91,14 @@ fn petition_buy_body_is_the_full_seventy_two_byte_frame() {
|
||||
"00000000000000000000000000000000000000000000000000000000000000000000000000000000",
|
||||
"0000", // u16 skipped
|
||||
"00", // u8 skipped
|
||||
"00000000", // u32 index — the server's own "unused"
|
||||
"00000000", // u32 index, unused by the server
|
||||
"00000000", // u32 skipped
|
||||
)),
|
||||
"CMSG_PETITION_BUY body"
|
||||
);
|
||||
assert_eq!(body.len(), 72 + "Legacy".len(), "72 fixed bytes + the name");
|
||||
|
||||
// The name is the only variable-length part, and it sits at offset 20 — where the server's
|
||||
// reader arrives after skipping 8 + 4 + 8 bytes.
|
||||
// The name, the only variable part, starts at offset 20 (8 + 4 + 8).
|
||||
assert_eq!(
|
||||
&body[20..27],
|
||||
b"Legacy\0",
|
||||
@@ -132,17 +106,13 @@ fn petition_buy_body_is_the_full_seventy_two_byte_frame() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `SMSG_PETITION_SHOWLIST` — the packet that opens the guild registrar. Layout from
|
||||
/// `Server/Packets/Petition.cpp:115-127`, values from `Handlers/PetitionsHandler.cpp:482-507`.
|
||||
///
|
||||
/// The list is **counted**, and parsed as one even though vmangos's own header says the reference
|
||||
/// client supports only one row: a reader that assumed one would desynchronise rather than degrade
|
||||
/// if a server ever sent two. The fixture sends two for exactly that reason.
|
||||
/// `SMSG_PETITION_SHOWLIST` opens the guild registrar (`Server/Packets/Petition.cpp:115-127`).
|
||||
/// The list is counted: vmangos sends one row, the fixture two, so a one-row reader fails.
|
||||
#[test]
|
||||
fn petition_show_list_parses_a_counted_list() {
|
||||
let body = hx(concat!(
|
||||
"1f2a000000000000", // u64 npcGuid
|
||||
"02", // u8 count — deliberately not vmangos's 1
|
||||
"02", // u8 count, not vmangos's 1
|
||||
// row 1: index 1, entry 5863, display 16161, cost 1000, flags 1
|
||||
"01000000",
|
||||
"e7160000",
|
||||
@@ -184,13 +154,9 @@ fn petition_show_list_parses_a_counted_list() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `SMSG_PETITION_SHOW_SIGNATURES` — `u64 item`, `u64 owner`, `u32 petitionId`, `u8 count`, then
|
||||
/// **12 bytes per signature**: the signer's guid and a dword vmangos writes as a literal zero
|
||||
/// (`Handlers/PetitionsHandler.cpp:160-168`, `Guild/GuildMgr.cpp:358-366`).
|
||||
///
|
||||
/// **Mutation-checked** against omitting that dword. The fixture carries a full nine signatures
|
||||
/// because that is the only size at which the drift is unmistakable — with two, an 8-byte stride
|
||||
/// reads one signer and then a truncation error, which is easy to misread as a short packet.
|
||||
/// `SMSG_PETITION_SHOW_SIGNATURES`: `u64` item, `u64` owner, `u32` petitionId, `u8` count, then
|
||||
/// 12 bytes per signature, the signer's guid and a zero dword (vmangos
|
||||
/// `Handlers/PetitionsHandler.cpp:160-168`); nine signatures make a wrong stride fail plainly.
|
||||
#[test]
|
||||
fn petition_show_signatures_stride_is_twelve_bytes() {
|
||||
let mut body = hx(concat!(
|
||||
@@ -212,8 +178,6 @@ fn petition_show_signatures_stride_is_twelve_bytes() {
|
||||
assert_eq!(sigs.owner, 0xaa);
|
||||
assert_eq!(sigs.petition_id, 7);
|
||||
assert_eq!(sigs.signatures.len(), MAX_PETITION_SIGNATURES);
|
||||
// The LAST signer is the one that proves the stride: an 8-byte stride would have walked into
|
||||
// the padding long before here.
|
||||
assert_eq!(
|
||||
sigs.signatures[MAX_PETITION_SIGNATURES - 1].signer,
|
||||
MAX_PETITION_SIGNATURES as u64,
|
||||
@@ -228,12 +192,8 @@ fn petition_show_signatures_stride_is_twelve_bytes() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `SMSG_PETITION_QUERY_RESPONSE` — sixteen fields, one of which is sixteen bits.
|
||||
///
|
||||
/// **Mutation-checked** against reading `allowedGender` as a `u32`. That shifts everything after it
|
||||
/// by two bytes: `allowedMinLevel` picks up half of `allowedMaxLevel`, and the choice count reads a
|
||||
/// large number, so the parse fails on a truncated cstring — far from the cause. The fixture gives
|
||||
/// every trailing field a distinct value so the shift cannot land on a lucky zero.
|
||||
/// `SMSG_PETITION_QUERY_RESPONSE`: sixteen fields, of which `allowedGender` alone is a `u16`; the
|
||||
/// fields after it get distinct values so a 32-bit read cannot land on a lucky zero.
|
||||
#[test]
|
||||
fn petition_query_response_gender_is_sixteen_bit() {
|
||||
let body = hx(concat!(
|
||||
@@ -267,7 +227,6 @@ fn petition_query_response_gender_is_sixteen_bit() {
|
||||
assert_eq!(r.min_signatures, 9);
|
||||
assert_eq!(r.max_signatures, 9);
|
||||
assert_eq!(r.allowed_gender, 0x6666, "sixteen bits, not thirty-two");
|
||||
// These two are what a 32-bit gender read would corrupt first.
|
||||
assert_eq!(r.allowed_min_level, 0x7777_7777);
|
||||
assert_eq!(r.allowed_max_level, 0x8888_8888);
|
||||
assert_eq!(r.choices, vec!["Yes".to_string()], "the counted tail");
|
||||
@@ -280,14 +239,13 @@ fn petition_query_response_gender_is_sixteen_bit() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `SMSG_PETITION_SIGN_RESULTS` (`Petition.cpp:69-74`) and `SMSG_TURN_IN_PETITION_RESULTS`
|
||||
/// (`:76-79`) share the `PetitionSigns` enum but not a layout: the first carries two guids and a
|
||||
/// code, the second carries **only** the code.
|
||||
/// `SMSG_PETITION_SIGN_RESULTS` (`Petition.cpp:69-74`) carries two guids and a code,
|
||||
/// `SMSG_TURN_IN_PETITION_RESULTS` (`:76-79`) only the code, from the same `PetitionSigns` enum.
|
||||
#[test]
|
||||
fn the_two_results_packets_share_an_enum_but_not_a_layout() {
|
||||
let body = hx(concat!(
|
||||
"0302010000000000", // u64 itemGuid
|
||||
"bb00000000000000", // u64 playerGuid — the SIGNER, in both copies of this packet
|
||||
"bb00000000000000", // u64 playerGuid: the signer, in both copies of this packet
|
||||
"03000000", // u32 result = CANT_SIGN_OWN
|
||||
));
|
||||
let packet = messages::parse_server(opcode::SMSG_PETITION_SIGN_RESULTS, &body).unwrap();
|
||||
@@ -298,7 +256,6 @@ fn the_two_results_packets_share_an_enum_but_not_a_layout() {
|
||||
assert_eq!(r.player, 0xbb);
|
||||
assert_eq!(r.result, petition_result::CANT_SIGN_OWN);
|
||||
|
||||
// The turn-in answer is a bare u32 with no charter named at all.
|
||||
let packet = messages::parse_server(
|
||||
opcode::SMSG_TURN_IN_PETITION_RESULTS,
|
||||
&hx("04000000"), // NEED_MORE
|
||||
@@ -322,16 +279,10 @@ fn the_two_results_packets_share_an_enum_but_not_a_layout() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The two `MSG_` opcodes are genuinely bidirectional, and **decline reads a different body than it
|
||||
/// writes**: we send the charter's item guid, the owner receives the declining player's guid. Both
|
||||
/// are eight bytes, so nothing but this test distinguishes a correct implementation from one that
|
||||
/// reuses a single body type and is wrong half the time.
|
||||
///
|
||||
/// Rename, by contrast, is symmetric — the same `u64 item` + cstring both ways — which is why the
|
||||
/// two are asserted together: the family is not uniformly one or the other.
|
||||
/// `MSG_PETITION_DECLINE` sends the charter's item guid but receives the decliner's player guid,
|
||||
/// both eight bytes; `MSG_PETITION_RENAME` is `u64` item and cstring name both ways.
|
||||
#[test]
|
||||
fn msg_petition_opcodes_read_a_different_body_than_they_write() {
|
||||
// Decline: outbound is the ITEM, inbound is the PLAYER. Same width, opposite meaning.
|
||||
assert_eq!(
|
||||
messages::petition_decline(0x0001_0203),
|
||||
hx("0302010000000000"),
|
||||
@@ -345,7 +296,7 @@ fn msg_petition_opcodes_read_a_different_body_than_they_write() {
|
||||
packet.name()
|
||||
);
|
||||
|
||||
// Rename: symmetric, and the echo arrives only on success.
|
||||
// The rename echo arrives only on success.
|
||||
let body = hx("03020100000000004c656761637900");
|
||||
assert_eq!(
|
||||
messages::petition_rename(0x0001_0203, "Legacy"),
|
||||
@@ -366,9 +317,6 @@ fn msg_petition_opcodes_read_a_different_body_than_they_write() {
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty charter — no signatures yet, which is what the owner sees the moment they buy one.
|
||||
/// The count byte is zero and the packet simply ends; nothing may read a signature record that is
|
||||
/// not there.
|
||||
#[test]
|
||||
fn a_freshly_bought_charter_has_no_signatures() {
|
||||
let body = hx(concat!(
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
//! The honor-system wire route end to end (decision 1512), mirroring `src/messages/pvp.rs`: raw
|
||||
//! body → [`messages::parse_server`] → `ServerPacket` → [`decode`] → `SessionEvent`. The
|
||||
//! byte-exact field goldens live inline in `pvp.rs`; what this file adds is the part those cannot
|
||||
//! reach — that the opcode actually dispatches to the right reader, that the packet names itself
|
||||
//! correctly in the log, and that the event fan-out carries the payload through unchanged. See
|
||||
//! `tests/common` for the shared fixtures and methodology note.
|
||||
//! The honor wire end to end: an opcode dispatches to its reader, names itself, and reaches its
|
||||
//! event unchanged.
|
||||
|
||||
mod common;
|
||||
|
||||
@@ -12,11 +8,8 @@ use benilla_protocol::messages;
|
||||
use benilla_protocol::ServerPacket;
|
||||
use common::hx;
|
||||
|
||||
/// The 50-byte `MSG_INSPECT_HONOR_STATS` reply, in `InspectHonorStatsResponse::AppendBodyTo`
|
||||
/// order: guid `0x0000000100002AB3`, highestRank 11, sessionKills 17|2<<16, yesterdayHK 41,
|
||||
/// unknownOld1 0, lastWeekHK 420, unknownOld2 0, thisWeekHK 123, unknownOld3 0, lifetimeHK 3907,
|
||||
/// lifetimeDHK 12, yesterdayHonor 640, lastWeekHonor 8431, thisWeekHonor 1250, lastWeekRank 57,
|
||||
/// rankBar 191.
|
||||
/// The 50-byte `MSG_INSPECT_HONOR_STATS` reply, in vmangos
|
||||
/// `InspectHonorStatsResponse::AppendBodyTo` order.
|
||||
fn inspect_honor_body() -> Vec<u8> {
|
||||
hx(concat!(
|
||||
"b32a000001000000", // u64 playerGuid
|
||||
@@ -38,9 +31,8 @@ fn inspect_honor_body() -> Vec<u8> {
|
||||
))
|
||||
}
|
||||
|
||||
/// The reply dispatches off opcode 0x2D6 and reaches the UI as one event with every field intact.
|
||||
/// The opcode is an `MSG_` — the same number carries our request — but `parse_server` only ever
|
||||
/// sees inbound bodies, so there is nothing to disambiguate here.
|
||||
/// `MSG_INSPECT_HONOR_STATS` (0x2D6) also carries our request, a bare guid; `parse_server` only
|
||||
/// sees the reply.
|
||||
#[test]
|
||||
fn inspect_honor_stats_wire() {
|
||||
let body = inspect_honor_body();
|
||||
@@ -79,17 +71,14 @@ fn inspect_honor_stats_wire() {
|
||||
other => panic!("one inspect-honor event, got {other:?}"),
|
||||
}
|
||||
|
||||
// Our own request on the same opcode is the bare guid — a different shape entirely, which is
|
||||
// exactly why direction and not content has to tell the two apart.
|
||||
assert_eq!(
|
||||
messages::inspect_honor_stats(0x0000_0001_0000_2AB3),
|
||||
hx("b32a000001000000")
|
||||
);
|
||||
}
|
||||
|
||||
/// `SMSG_PVP_CREDIT` dispatches off 0x28C and carries honor / victim / rank through to the event.
|
||||
/// The victim rank is the **internal** rank (`SendPVPCredit` sends `GetRank().rank`, floored at 5
|
||||
/// for a player victim) — a direct `PVP_RANK_<rank>_<team>` key, not the visual badge number.
|
||||
/// `SMSG_PVP_CREDIT` (0x28C): honor, victim guid, and the victim's internal rank (vmangos
|
||||
/// `SendPVPCredit`, floored at 5 for a player), a `PVP_RANK_<rank>_<team>` key, not the badge.
|
||||
#[test]
|
||||
fn pvp_credit_wire() {
|
||||
// i32 honor = 143, u64 victimGuid = 0x0000000100002AB3, i32 victimRank = 11.
|
||||
@@ -119,8 +108,6 @@ fn pvp_credit_wire() {
|
||||
assert_eq!(credit.honor, -5);
|
||||
}
|
||||
|
||||
/// A truncated body must surface as a parse error, not a panic — both opcodes, at the wire edge
|
||||
/// rather than only at the reader.
|
||||
#[test]
|
||||
fn truncated_honor_bodies_error_at_the_dispatch() {
|
||||
let full = inspect_honor_body();
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
//! Golden tests for the **party quest-share** opcode family (decision 1733) — the quest log's
|
||||
//! *Share Quest* push, the escort confirm, and the two-way verdict relay. 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.
|
||||
//!
|
||||
//! The property worth a test file of its own is the **direction flip** on `MSG_QUEST_PUSH_RESULT`:
|
||||
//! one opcode number, two meanings for its `u64`. Going up it is the sharer we are answering;
|
||||
//! coming down it is the party member the verdict is *about*. Nothing in the type system can catch
|
||||
//! a swap, so it is pinned here from both ends.
|
||||
//! The party quest-share wire: the Share Quest push, the escort confirm, and the verdict relay.
|
||||
//! `MSG_QUEST_PUSH_RESULT`'s guid is the sharer going up and the member concerned coming down.
|
||||
|
||||
mod common;
|
||||
|
||||
@@ -16,11 +8,11 @@ use benilla_protocol::messages::{self, opcode, quest_flags, QuestShareMsg};
|
||||
use benilla_protocol::ServerPacket;
|
||||
use common::hx;
|
||||
|
||||
/// The three CMSG builders, byte-exact (vmangos `Server/Packets/Quest.cpp:63-77`).
|
||||
/// The three CMSG bodies (vmangos `Server/Packets/Quest.cpp:63-77`).
|
||||
#[test]
|
||||
fn cmsg_bodies_golden() {
|
||||
// CMSG_PUSHQUESTTOPARTY / CMSG_QUEST_CONFIRM_ACCEPT: one u32 quest id, nothing else — the
|
||||
// server addresses the group itself, so no member guid rides along.
|
||||
// CMSG_PUSHQUESTTOPARTY / CMSG_QUEST_CONFIRM_ACCEPT: one u32 quest id and no member guid; the
|
||||
// server addresses the group itself.
|
||||
assert_eq!(
|
||||
messages::push_quest_to_party(0x1234),
|
||||
hx("34120000"),
|
||||
@@ -32,8 +24,8 @@ fn cmsg_bodies_golden() {
|
||||
"CMSG_QUEST_CONFIRM_ACCEPT body"
|
||||
);
|
||||
|
||||
// MSG_QUEST_PUSH_RESULT going UP: u64 guid, then the u8 verdict. The guid is the SHARER we are
|
||||
// answering (`Quest.cpp:73-77`).
|
||||
// MSG_QUEST_PUSH_RESULT going up: the u64 guid of the sharer we answer, then the u8 verdict
|
||||
// (`Quest.cpp:73-77`).
|
||||
assert_eq!(
|
||||
messages::quest_push_result(0x1234_5678_9abc_def0, QuestShareMsg::DECLINE_QUEST),
|
||||
hx("f0debc9a7856341203"),
|
||||
@@ -41,9 +33,8 @@ fn cmsg_bodies_golden() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `MSG_QUEST_PUSH_RESULT` coming DOWN (`Quest.cpp:81-85`, written by
|
||||
/// `Player::SendPushToPartyResponse`): the same two fields, but the guid is now the party MEMBER
|
||||
/// the verdict concerns — never the sharer, and never us.
|
||||
/// `MSG_QUEST_PUSH_RESULT` coming down (`Quest.cpp:81-85`): the same two fields, the guid now the
|
||||
/// party member the verdict concerns.
|
||||
#[test]
|
||||
fn push_result_decodes_member_and_verdict() {
|
||||
let body = hx("f0debc9a7856341200");
|
||||
@@ -64,9 +55,7 @@ fn push_result_decodes_member_and_verdict() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole verdict enum survives the round trip, including a value the client has no message
|
||||
/// for: an unmapped byte must reach the app as data, not kill the packet (the display table is
|
||||
/// where "show nothing" is decided).
|
||||
/// An unmapped verdict byte reaches the app as data rather than failing the parse.
|
||||
#[test]
|
||||
fn every_verdict_byte_round_trips() {
|
||||
for raw in [0u8, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0xFF] {
|
||||
@@ -82,9 +71,8 @@ fn every_verdict_byte_round_trips() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_QUEST_CONFIRM_ACCEPT` (`Quest.cpp:131-136`): `u32 questId, cstr questTitle, u64
|
||||
/// senderGuid` — note the title sits BETWEEN the two numbers, so a reader that grouped the
|
||||
/// scalars would decode the guid out of the title's tail.
|
||||
/// `SMSG_QUEST_CONFIRM_ACCEPT` (`Quest.cpp:131-136`): `u32` questId, cstring title, `u64`
|
||||
/// senderGuid; the title sits between the two numbers.
|
||||
#[test]
|
||||
fn confirm_accept_decodes_id_title_sender() {
|
||||
let mut body = 1234u32.to_le_bytes().to_vec();
|
||||
@@ -102,9 +90,8 @@ fn confirm_accept_decodes_id_title_sender() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The two flag bits the share flow reads, pinned against vmangos `QuestDef.h:145-160`. They are
|
||||
/// the only route by which the client learns a quest is shareable or an escort — the giver panels
|
||||
/// carry no flags at all.
|
||||
/// The two quest flags the share flow reads (vmangos `QuestDef.h:145-160`), the client's only
|
||||
/// sign that a quest is shareable or an escort.
|
||||
#[test]
|
||||
fn share_quest_flags_are_the_documented_bits() {
|
||||
assert_eq!(quest_flags::PARTY_ACCEPT, 0x2);
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
//! The reputation pane's wire: the pane's three client verbs, byte-exact against the vmangos
|
||||
//! reader side, and the one server push (`SMSG_SET_FACTION_VISIBLE`) that carries no standing.
|
||||
//!
|
||||
//! The standing-bearing pair (`SMSG_INITIALIZE_FACTIONS` / `SMSG_SET_FACTION_STANDING`) already
|
||||
//! has its goldens in `tests/simple_packets.rs`, where it landed with the unit-reaction decode.
|
||||
//! The reputation pane's wire: its three client verbs, in vmangos's read order, and the one push
|
||||
//! without a standing, `SMSG_SET_FACTION_VISIBLE`.
|
||||
|
||||
mod common;
|
||||
|
||||
@@ -11,26 +8,21 @@ use benilla_protocol::messages::{self, opcode};
|
||||
use benilla_protocol::ServerPacket;
|
||||
use common::hx;
|
||||
|
||||
/// `CMSG_SET_FACTION_ATWAR` (293): `u32 repListId`, `u8 flag` — the pane's crossed-swords box.
|
||||
///
|
||||
/// Byte-exact against vmangos's reader (`WorldPackets::Misc::SetFactionAtWar::ReadFromWorldPacket`,
|
||||
/// `Server/Packets/Misc.cpp`, over the `uint32 repListId` / `uint8 flag` fields in `Misc.h`), which
|
||||
/// is the side that has to parse what we send.
|
||||
/// `CMSG_SET_FACTION_ATWAR` (293), the pane's crossed-swords box: `u32` repListId, `u8` flag
|
||||
/// (vmangos `SetFactionAtWar::ReadFromWorldPacket`).
|
||||
#[test]
|
||||
fn set_faction_at_war_body_is_slot_then_flag() {
|
||||
assert_eq!(opcode::CMSG_SET_FACTION_ATWAR, 293);
|
||||
// Slot 21 (Booty Bay is reputationIndex 1; 21 is Darnassus's), war ON.
|
||||
// Slot 21 (Darnassus), at war.
|
||||
assert_eq!(messages::set_faction_at_war(21, true), hx("1500000001"));
|
||||
// …and OFF. The flag is a whole byte, not a bit — `0`, never a cleared bit in a mask.
|
||||
// The flag is a whole byte: off is 0, not a cleared bit.
|
||||
assert_eq!(messages::set_faction_at_war(21, false), hx("1500000000"));
|
||||
// Slot 0 is a REAL faction (the Bloodsail Buccaneers hold reputationIndex 0), so a zero slot
|
||||
// is an ordinary request here — the "nothing" sentinel problem is the watched verb's alone.
|
||||
// Slot 0 is a real faction (the Bloodsail Buccaneers), an ordinary request here.
|
||||
assert_eq!(messages::set_faction_at_war(0, true), hx("0000000001"));
|
||||
}
|
||||
|
||||
/// `CMSG_SET_FACTION_INACTIVE` (791): `u32 repListId`, `u8 inactive` — the "move to inactive" box.
|
||||
///
|
||||
/// Byte-exact against `WorldPackets::Misc::SetFactionInactive::ReadFromWorldPacket`.
|
||||
/// `CMSG_SET_FACTION_INACTIVE` (791), the "move to inactive" box: `u32` repListId, `u8` inactive
|
||||
/// (vmangos `SetFactionInactive::ReadFromWorldPacket`).
|
||||
#[test]
|
||||
fn set_faction_inactive_body_is_slot_then_flag() {
|
||||
assert_eq!(opcode::CMSG_SET_FACTION_INACTIVE, 791);
|
||||
@@ -38,14 +30,9 @@ fn set_faction_inactive_body_is_slot_then_flag() {
|
||||
assert_eq!(messages::set_faction_inactive(54, false), hx("3600000000"));
|
||||
}
|
||||
|
||||
/// `CMSG_SET_WATCHED_FACTION` (792): one **signed** `i32` slot, `-1` for "watch nothing".
|
||||
///
|
||||
/// The signedness is the whole point and is why this has its own assert: vmangos writes the value
|
||||
/// straight into `PLAYER_FIELD_WATCHED_FACTION_INDEX` with `SetInt32Value`
|
||||
/// (`HandleSetWatchedFactionOpcode`), and slot `0` is a real faction — so a `0` here would watch the
|
||||
/// Bloodsail Buccaneers rather than clear the bar. FrameXML's `SetWatchedFactionIndex(0)` means "no
|
||||
/// display row"; translating that to [`messages::WATCHED_FACTION_NONE`] is the binding's job, above
|
||||
/// this layer, and this test pins the two values apart so the translation cannot be skipped silently.
|
||||
/// `CMSG_SET_WATCHED_FACTION` (792): one signed `i32` slot, -1 for none. vmangos writes it into
|
||||
/// `PLAYER_FIELD_WATCHED_FACTION_INDEX` (`HandleSetWatchedFactionOpcode`) and slot 0 is a real
|
||||
/// faction, so the binding maps FrameXML's `SetWatchedFactionIndex(0)` to `WATCHED_FACTION_NONE`.
|
||||
#[test]
|
||||
fn set_watched_faction_body_is_a_signed_slot_and_none_is_minus_one() {
|
||||
assert_eq!(opcode::CMSG_SET_WATCHED_FACTION, 792);
|
||||
@@ -55,19 +42,14 @@ fn set_watched_faction_body_is_a_signed_slot_and_none_is_minus_one() {
|
||||
messages::set_watched_faction(messages::WATCHED_FACTION_NONE),
|
||||
hx("ffffffff")
|
||||
);
|
||||
// The trap, asserted: watching slot 0 and watching nothing are different bytes.
|
||||
assert_ne!(
|
||||
messages::set_watched_faction(0),
|
||||
messages::set_watched_faction(messages::WATCHED_FACTION_NONE)
|
||||
);
|
||||
}
|
||||
|
||||
/// `SMSG_SET_FACTION_VISIBLE` (291): one `u32` reputation-list slot, no standing
|
||||
/// (vmangos `ReputationMgr::SendVisible` / `SetFactionVisible::AppendBodyTo`).
|
||||
///
|
||||
/// The server pushes it the first time the player meets a faction. A client that drops it keeps a
|
||||
/// correct standing for a row the pane will not list — which is exactly the failure this parse arm
|
||||
/// exists to prevent, so the decode leg is asserted too.
|
||||
/// `SMSG_SET_FACTION_VISIBLE` (291): one `u32` reputation-list slot (vmangos
|
||||
/// `ReputationMgr::SendVisible`), pushed the first time the player meets a faction.
|
||||
#[test]
|
||||
fn set_faction_visible_parses_and_decodes() {
|
||||
assert_eq!(opcode::SMSG_SET_FACTION_VISIBLE, 291);
|
||||
|
||||
Reference in New Issue
Block a user