From bfb5c008b878a390f79b7ab76abfb4fe6e214348 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 23 Sep 2026 20:12:46 +0700 Subject: [PATCH] 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) --- crates/benilla-protocol/tests/auth.rs | 20 +- crates/benilla-protocol/tests/bank.rs | 27 +-- crates/benilla-protocol/tests/broadcast.rs | 41 +--- crates/benilla-protocol/tests/char_enum.rs | 43 +--- crates/benilla-protocol/tests/chat.rs | 134 ++++-------- crates/benilla-protocol/tests/client.rs | 175 +++++----------- crates/benilla-protocol/tests/common/mod.rs | 7 +- .../benilla-protocol/tests/gossip_vendor.rs | 84 +++----- crates/benilla-protocol/tests/group.rs | 83 +++----- crates/benilla-protocol/tests/guild.rs | 148 ++++--------- crates/benilla-protocol/tests/handshake.rs | 24 +-- crates/benilla-protocol/tests/instance.rs | 38 ++-- crates/benilla-protocol/tests/items.rs | 112 ++++------ crates/benilla-protocol/tests/mail.rs | 43 ++-- crates/benilla-protocol/tests/movement.rs | 198 +++++------------- crates/benilla-protocol/tests/petition.rs | 100 +++------ crates/benilla-protocol/tests/pvp.rs | 29 +-- crates/benilla-protocol/tests/quest_share.rs | 41 ++-- crates/benilla-protocol/tests/reputation.rs | 46 ++-- 19 files changed, 413 insertions(+), 980 deletions(-) diff --git a/crates/benilla-protocol/tests/auth.rs b/crates/benilla-protocol/tests/auth.rs index 49f1df1a..10622fc0 100644 --- a/crates/benilla-protocol/tests/auth.rs +++ b/crates/benilla-protocol/tests/auth.rs @@ -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 { 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", diff --git a/crates/benilla-protocol/tests/bank.rs b/crates/benilla-protocol/tests/bank.rs index 46f1ce4f..92ca27e8 100644 --- a/crates/benilla-protocol/tests/bank.rs +++ b/crates/benilla-protocol/tests/bank.rs @@ -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)); } diff --git a/crates/benilla-protocol/tests/broadcast.rs b/crates/benilla-protocol/tests/broadcast.rs index 3d1641b0..dfc963e0 100644 --- a/crates/benilla-protocol/tests/broadcast.rs +++ b/crates/benilla-protocol/tests/broadcast.rs @@ -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(); diff --git a/crates/benilla-protocol/tests/char_enum.rs b/crates/benilla-protocol/tests/char_enum.rs index 101754d8..78c56f3f 100644 --- a/crates/benilla-protocol/tests/char_enum.rs +++ b/crates/benilla-protocol/tests/char_enum.rs @@ -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] { diff --git a/crates/benilla-protocol/tests/chat.rs b/crates/benilla-protocol/tests/chat.rs index 236fcdba..9c5c669b 100644 --- a/crates/benilla-protocol/tests/chat.rs +++ b/crates/benilla-protocol/tests/chat.rs @@ -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, diff --git a/crates/benilla-protocol/tests/client.rs b/crates/benilla-protocol/tests/client.rs index aea89f75..40f44622 100644 --- a/crates/benilla-protocol/tests/client.rs +++ b/crates/benilla-protocol/tests/client.rs @@ -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 = 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(); diff --git a/crates/benilla-protocol/tests/common/mod.rs b/crates/benilla-protocol/tests/common/mod.rs index 43575f36..8570acca 100644 --- a/crates/benilla-protocol/tests/common/mod.rs +++ b/crates/benilla-protocol/tests/common/mod.rs @@ -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 { (0..s.len()) diff --git a/crates/benilla-protocol/tests/gossip_vendor.rs b/crates/benilla-protocol/tests/gossip_vendor.rs index 246dfd28..a17c3297 100644 --- a/crates/benilla-protocol/tests/gossip_vendor.rs +++ b/crates/benilla-protocol/tests/gossip_vendor.rs @@ -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 { #[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, diff --git a/crates/benilla-protocol/tests/group.rs b/crates/benilla-protocol/tests/group.rs index a4e0a94a..e4b2ac3e 100644 --- a/crates/benilla-protocol/tests/group.rs +++ b/crates/benilla-protocol/tests/group.rs @@ -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::::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 diff --git a/crates/benilla-protocol/tests/guild.rs b/crates/benilla-protocol/tests/guild.rs index ac312881..1cb3bd71 100644 --- a/crates/benilla-protocol/tests/guild.rs +++ b/crates/benilla-protocol/tests/guild.rs @@ -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::::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, 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 { 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( diff --git a/crates/benilla-protocol/tests/handshake.rs b/crates/benilla-protocol/tests/handshake.rs index dfa07487..1812d197 100644 --- a/crates/benilla-protocol/tests/handshake.rs +++ b/crates/benilla-protocol/tests/handshake.rs @@ -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)>) -> 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)>) -> 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])]); diff --git a/crates/benilla-protocol/tests/instance.rs b/crates/benilla-protocol/tests/instance.rs index 06878fec..1c70ed7d 100644 --- a/crates/benilla-protocol/tests/instance.rs +++ b/crates/benilla-protocol/tests/instance.rs @@ -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 { .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::::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(); diff --git a/crates/benilla-protocol/tests/items.rs b/crates/benilla-protocol/tests/items.rs index 1c9168b2..1927e352 100644 --- a/crates/benilla-protocol/tests/items.rs +++ b/crates/benilla-protocol/tests/items.rs @@ -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( diff --git a/crates/benilla-protocol/tests/mail.rs b/crates/benilla-protocol/tests/mail.rs index b8af7981..4a2054c6 100644 --- a/crates/benilla-protocol/tests/mail.rs +++ b/crates/benilla-protocol/tests/mail.rs @@ -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, 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() { diff --git a/crates/benilla-protocol/tests/movement.rs b/crates/benilla-protocol/tests/movement.rs index ac802da7..0b0d4465 100644 --- a/crates/benilla-protocol/tests/movement.rs +++ b/crates/benilla-protocol/tests/movement.rs @@ -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); diff --git a/crates/benilla-protocol/tests/petition.rs b/crates/benilla-protocol/tests/petition.rs index ebeed018..44aebe48 100644 --- a/crates/benilla-protocol/tests/petition.rs +++ b/crates/benilla-protocol/tests/petition.rs @@ -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!( diff --git a/crates/benilla-protocol/tests/pvp.rs b/crates/benilla-protocol/tests/pvp.rs index 00f69873..9f99c970 100644 --- a/crates/benilla-protocol/tests/pvp.rs +++ b/crates/benilla-protocol/tests/pvp.rs @@ -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 { hx(concat!( "b32a000001000000", // u64 playerGuid @@ -38,9 +31,8 @@ fn inspect_honor_body() -> Vec { )) } -/// 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__` 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__` 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(); diff --git a/crates/benilla-protocol/tests/quest_share.rs b/crates/benilla-protocol/tests/quest_share.rs index 032904cd..667aa351 100644 --- a/crates/benilla-protocol/tests/quest_share.rs +++ b/crates/benilla-protocol/tests/quest_share.rs @@ -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); diff --git a/crates/benilla-protocol/tests/reputation.rs b/crates/benilla-protocol/tests/reputation.rs index a283cfb8..777ff630 100644 --- a/crates/benilla-protocol/tests/reputation.rs +++ b/crates/benilla-protocol/tests/reputation.rs @@ -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);