From 1401567b0cd3f399e18ea2c65016526df784f333 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 23 Sep 2026 20:09:59 +0700 Subject: [PATCH] comments: protocol, the chat, client, combat-log, death, duel, gameobject, ticket, gossip and group messages 1,079 comment lines become 390; the code is unchanged. Corrected against the code: - area spirit healers are modelled; - the resurrect name length is skipped, not cross-checked; - SMSG_CHAT_WRONG_FACTION is handled in parse.rs, not chat.rs. AFK/DND clearing in the member snapshot matches the reference and is no longer called a deviation. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-protocol/src/messages/chat.rs | 149 +++------- .../benilla-protocol/src/messages/client.rs | 212 ++++---------- .../src/messages/combat_log.rs | 195 ++++--------- crates/benilla-protocol/src/messages/death.rs | 118 +++----- crates/benilla-protocol/src/messages/duel.rs | 92 ++---- .../src/messages/gameobject.rs | 76 ++--- .../src/messages/gm_ticket.rs | 168 +++-------- .../benilla-protocol/src/messages/gossip.rs | 134 +++------ crates/benilla-protocol/src/messages/group.rs | 271 +++++------------- 9 files changed, 363 insertions(+), 1052 deletions(-) diff --git a/crates/benilla-protocol/src/messages/chat.rs b/crates/benilla-protocol/src/messages/chat.rs index 0bd0a16d..023fb784 100644 --- a/crates/benilla-protocol/src/messages/chat.rs +++ b/crates/benilla-protocol/src/messages/chat.rs @@ -1,35 +1,13 @@ -//! Inbound chat — `SMSG_MESSAGECHAT` (VERIFIED vmangos `ChatHandler::BuildChatPacket`, -//! `Chat/Chat.cpp:2542-2599`, all `SUPPORTED_CLIENT_BUILD` bands included for 5875): `u8 type`, `u32 -//! language`, a **per-type sender prefix**, then `u32 len` (includes the NUL) + the text + `u8 -//! chatTag`. This is how everything textual reaches the client — player say/yell/whisper, NPC -//! lines, channels, and the **system messages** GM dot-commands answer with (type `0x0A`), which -//! is what makes command feedback visible to the probe and the chat UI. -//! -//! Also owns the handful of small chat-adjacent SMSG bodies that aren't their own subsystem: -//! `SMSG_CHAT_PLAYER_NOT_FOUND`, `SMSG_CHAT_WRONG_FACTION`, `SMSG_PLAYED_TIME`, and `MSG_RANDOM_ROLL`'s -//! server→client broadcast shape. The channel subsystem (join/leave/list/moderation + -//! `SMSG_CHANNEL_NOTIFY`/`_LIST`) is big enough to own its own module: [`super::channel`]. +//! Inbound chat. `SMSG_MESSAGECHAT` (vmangos `Chat/Chat.cpp:2542-2599`) is `u8 type`, +//! `u32 language`, a per-type sender prefix, `u32 len` (NUL included), the text, `u8 chatTag`. +//! Also the small chat-adjacent bodies: player not found, notification, played time, random roll. use std::io; use crate::wire::{read_cstring, read_u32_le, read_u64_le, read_u8}; -// ChatMsg values (VERIFIED vmangos `SharedDefines.h:1191-1301`, the 5875 band — every -// `#if SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_11_2`-or-earlier guard is compile-time true for build -// 5875). vmangos's enum runs the full `0x00..=0x5D`; the combat-log/spell/skill/loot/money block -// (0x09, 0x0F-0x13, 0x17-0x50, 0x52-0x56) is **never** `SMSG_MESSAGECHAT` wire in 1.12 — canned -// emotes ride `SMSG_TEXT_EMOTE`, channel feedback rides `SMSG_CHANNEL_NOTIFY`/`_LIST`, and -// skill/loot/money/xp lines are client-composed from their own opcodes (decision 0288) — so those -// values have no named constant here. What's below is every type benilla either receives (as a -// distinct wire shape or a renderable line) or sends. -// -// **`0x5B` was in that list until decision 2077 and it did not belong there.** It is -// `CHAT_MSG_FILTERED`, and it is a real server→client line: 1.12's `ChatFrame.lua` puts it in -// `ChatTypeGroup["SYSTEM"]` (l.128) and renders it at l.1405 as `format(CHAT_FILTERED, arg2)` — -// "Unable to send chat to %s because your message contained reserved words." The client's own chat -// chokepoint carves it out of the spam filter **by name** (`0x49aac9 cmp edi,0x5b`) precisely -// because it is a line that arrives, and one the filter itself produces. vmangos declares the -// constant and never sends it, which is what the wrong claim was really observing. +// ChatMsg values (vmangos `SharedDefines.h:1191-1301`), only those benilla receives or sends. +// Text emotes, channel notices and combat, skill, loot and money lines come from other opcodes. pub const CHAT_MSG_SAY: u8 = 0x00; pub const CHAT_MSG_PARTY: u8 = 0x01; pub const CHAT_MSG_RAID: u8 = 0x02; @@ -37,12 +15,10 @@ pub const CHAT_MSG_GUILD: u8 = 0x03; pub const CHAT_MSG_OFFICER: u8 = 0x04; pub const CHAT_MSG_YELL: u8 = 0x05; pub const CHAT_MSG_WHISPER: u8 = 0x06; -/// Server→client only ("You whisper to Name: …" self-echo, vmangos `MasterPlayerChat.cpp:54`) — -/// never a `CMSG_MESSAGECHAT` type a client sends. +/// Server-to-client only: the "You whisper to Name" self-echo (vmangos `MasterPlayerChat.cpp:54`). pub const CHAT_MSG_WHISPER_INFORM: u8 = 0x07; pub const CHAT_MSG_EMOTE: u8 = 0x08; -/// GM dot-command feedback and other server-authored lines; always the single-sender-guid shape -/// (the guid is usually 0). +/// GM dot-command feedback and other server-authored lines; one sender guid, usually 0. pub const CHAT_MSG_SYSTEM: u8 = 0x0A; pub const CHAT_MSG_MONSTER_SAY: u8 = 0x0B; pub const CHAT_MSG_MONSTER_YELL: u8 = 0x0C; @@ -50,47 +26,22 @@ pub const CHAT_MSG_MONSTER_EMOTE: u8 = 0x0D; pub const CHAT_MSG_CHANNEL: u8 = 0x0E; pub const CHAT_MSG_AFK: u8 = 0x14; pub const CHAT_MSG_DND: u8 = 0x15; -/// The "so-and-so is ignoring you" self-notice (`WorldSession::HandleChatIgnoredOpcode`, -/// `Handlers/ChatHandler.cpp:755-763`) — single-sender-guid shape, sender is the ignoring player. +/// "Name is ignoring you": one sender guid, the ignoring player (`ChatHandler.cpp:755-763`). pub const CHAT_MSG_IGNORED: u8 = 0x16; pub const CHAT_MSG_MONSTER_WHISPER: u8 = 0x1A; -/// `#if SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_10_2` — active for 5875. pub const CHAT_MSG_RAID_LEADER: u8 = 0x57; pub const CHAT_MSG_RAID_WARNING: u8 = 0x58; -/// `#if SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_11_2` — active for 5875 (pre-1.11.3 this value -/// aliased `CHAT_MSG_MONSTER_WHISPER` instead; irrelevant here since 5875 always takes this branch). pub const CHAT_MSG_RAID_BOSS_WHISPER: u8 = 0x59; pub const CHAT_MSG_RAID_BOSS_EMOTE: u8 = 0x5A; -/// **The server's "your message was filtered" notice** — not a filtered incoming line. The stock -/// chat frame formats it as `CHAT_FILTERED` over `arg2` (the addressee), so the wire text carries -/// the name rather than a sentence. It is the one chat type the client's own spam filter is -/// *disarmed* for (`0x49aacc`), it is absent from the profanity arm's 14-type table, and it is -/// delivered whatever both CVars say. wow-re `system/ui/scratch/text-filter-law.md`. +/// The server's "your message was filtered" notice, which the chat frame formats as +/// `CHAT_FILTERED`; the client's spam and profanity filters exempt it (`0x49aacc`). pub const CHAT_MSG_FILTERED: u8 = 0x5B; pub const CHAT_MSG_BATTLEGROUND: u8 = 0x5C; pub const CHAT_MSG_BATTLEGROUND_LEADER: u8 = 0x5D; -/// The chat types the real client runs its `$`-macro expander over, and the **only** ones — every -/// other type, player chat included, reaches the frame verbatim. -/// -/// **There are two gates in the reference, and this is the WIRE one** (decision 0759 corrects 0754, -/// which shipped the other). VERIFIED in `WoW.exe` (5875): -/// -/// - the **wire** path — `0x49d5e4-0x49d606` in the `SMSG_MESSAGECHAT` parser `0x49d560` — routes -/// `0x0B 0x0C 0x0D 0x1A` **and `0x5A`** into the expanding branch at `0x49da3d`, plus -/// `0x52 0x53 0x54` into the one at `0x49d961`. **Eight types.** -/// - the **pending-chat** path — `0x49cf36-0x49cf5d`, guarding `0x49cf70` — is the *re*-expansion -/// after a name query answers, and its chain is seven: it omits `0x5A`. -/// -/// benilla decodes the wire, so the wire set is the one that applies. 0754 cited the pending gate -/// and so dropped `RAID_BOSS_EMOTE` (`0x5A`) — a boss emote's `$n` would have stayed literal. -/// -/// Note what the set is and isn't: the four monster shapes and `RAID_BOSS_EMOTE`, plus the three -/// **battleground system** lines (`BG_SYSTEM_NEUTRAL`/`_ALLIANCE`/`_HORDE`, vmangos -/// `SharedDefines.h:1279-1281`) — which is where "$n has taken the flag!" comes from, and why -/// "the monster/boss family" is the wrong name for it. `RAID_BOSS_WHISPER` (`0x59`) is **not** in -/// either gate: it takes its own branch at `0x49d610`, never expands, and is remapped to a plain -/// whisper (`mov byte [ebp+0xf],0x6` @ `0x49d67e`). +/// The only chat types the 1.12 client runs its `$`-macro expander over; the rest reach the frame +/// verbatim. This is the wire gate (`0x49d5e4-0x49d606`); the re-expansion after a name query +/// (`0x49cf36`) omits `0x5A`. pub const MACRO_EXPANDED_TYPES: [u8; 8] = [ CHAT_MSG_MONSTER_SAY, // 0x0B CHAT_MSG_MONSTER_YELL, // 0x0C @@ -99,18 +50,15 @@ pub const MACRO_EXPANDED_TYPES: [u8; 8] = [ CHAT_MSG_BG_SYSTEM_NEUTRAL, // 0x52 CHAT_MSG_BG_SYSTEM_ALLIANCE, // 0x53 CHAT_MSG_BG_SYSTEM_HORDE, // 0x54 - CHAT_MSG_RAID_BOSS_EMOTE, // 0x5A — wire gate only; absent from the pending-chat gate + CHAT_MSG_RAID_BOSS_EMOTE, // 0x5A, wire gate only ]; -/// `#if SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_6_1` — active for 5875. The battleground system -/// lines; they take `BuildChatPacket`'s `default:` branch, so their single guid slot IS the macro -/// subject. +/// The battleground system lines: one guid, the macro subject (`BuildChatPacket`'s `default:`). pub const CHAT_MSG_BG_SYSTEM_NEUTRAL: u8 = 0x52; pub const CHAT_MSG_BG_SYSTEM_ALLIANCE: u8 = 0x53; pub const CHAT_MSG_BG_SYSTEM_HORDE: u8 = 0x54; -/// `PlayerChatTag` (VERIFIED vmangos `Chat/Chat.h:86-92`) — [`ChatMessage::chat_tag`]'s values. -/// `Player::GetChatTag` (`Objects/Player.cpp:1757-1767`) picks GM over DND over AFK over none. +/// vmangos `PlayerChatTag`; the server picks GM over DND over AFK (`Player.cpp:1757-1767`). pub mod chat_tag { pub const NONE: u8 = 0; pub const AFK: u8 = 1; @@ -127,39 +75,22 @@ pub struct ChatMessage { pub language: u32, /// The speaker, when the type carries a guid (0 for system messages / name-only shapes). pub sender_guid: u64, - /// The **addressee** — the trailing guid slot the monster shapes carry (`0` for the shapes that - /// have none). Not display state: this is the subject the real client's `$`-macro expander - /// resolves monster chat against, which is why it is kept rather than dropped. See - /// [`MACRO_EXPANDED_TYPES`]. + /// The trailing addressee guid, 0 when the shape has none; the `$`-macro subject. pub target_guid: u64, - /// The speaker's name, for the monster shapes that carry it inline (`None` elsewhere — - /// player names resolve via the name query, exactly like the real client). + /// The inline speaker name of the monster shapes; player names come from the name query. pub sender_name: Option, /// The channel name, on `CHAT_MSG_CHANNEL` only. pub channel: Option, pub text: String, - /// The trailing tag byte ([`chat_tag`]) — 0 none, 1 AFK, 2 DND, 3 GM. Drives the - /// ``/``/`` prefix the real chat frame prepends to the sender's name. + /// The [`chat_tag`] byte, shown as the ``/``/`` prefix on the sender's name. pub chat_tag: u8, } impl ChatMessage { - /// Is this line addon traffic rather than speech? (`language == LANG_ADDON`, decision 1029.) + /// Whether this is addon traffic, which the chat frame never prints: 1.12 has no addon opcode + /// or chat type, so `LANG_ADDON` on any lane is the only mark. /// - /// The `language` field is the **whole** discriminator: 1.12.1 has no addon opcode and no addon - /// `ChatMsg` type, so an addon broadcast is an ordinary `SMSG_MESSAGECHAT` on whichever lane it - /// was sent over — `CHAT_MSG_PARTY` for a hunter addon's version ping, `CHAT_MSG_CHANNEL` for a - /// hidden-channel protocol — and only the sentinel language tells it apart. The chat frame must - /// drop it; rendering it is what printed `[Party] [Soreen]: Quiver VERSION:3.1.4` (B215). - /// - /// The `text` of such a line is an addon payload, not a sentence: `prefix`, a TAB, then the - /// addon's own encoding. It is deliberately **not** split here — nothing consumes it until - /// benilla runs third-party addons, and the split rule belongs with the `CHAT_MSG_ADDON` fire - /// that would need it. When that lands, the rule is byte-verified and slightly - /// counter-intuitive (wow-re `system/ui/scratch/addon-chat-law.md`): the copy **starts** in the - /// prefix buffer and the **first** tab switches it to the message buffer, so text with no tab - /// at all yields `prefix = `, `message = ""` — not the reverse — and the event - /// still fires. Later tabs are literal payload bytes. + /// Its text is `prefix`, a TAB, the payload; with no tab, the 1.12 client reads all as prefix. pub fn is_addon(&self) -> bool { self.language == super::LANGUAGE_ADDON } @@ -186,9 +117,7 @@ pub(super) fn read_message_chat(r: &mut &[u8]) -> io::Result { let chat_type = read_u8(r)?; let language = read_u32_le(r)?; let mut sender_guid = 0u64; - // The trailing "addressee" slot. Only the monster shapes carry one; it stays 0 elsewhere, and - // the macro expander then falls back to `sender_guid` (which is all the `default:` shapes — the - // BG system lines included — have). + // 0 unless the shape carries an addressee; the macro expander then uses `sender_guid`. let mut target_guid = 0u64; let mut sender_name = None; let mut channel = None; @@ -215,9 +144,7 @@ pub(super) fn read_message_chat(r: &mut &[u8]) -> io::Result { let _player_rank = read_u32_le(r)?; sender_guid = read_u64_le(r)?; } - // Everything else (RAID/GUILD/OFFICER/WHISPER/WHISPER_INFORM/EMOTE/SYSTEM/AFK/DND/IGNORED/ - // RAID_LEADER/RAID_WARNING/BATTLEGROUND(+LEADER)/BG_SYSTEM_*/…): one sender guid - // (vmangos `BuildChatPacket`'s own `default:` branch). + // Every other type is `BuildChatPacket`'s `default:` shape: one sender guid. _ => { sender_guid = read_u64_le(r)?; } @@ -236,40 +163,28 @@ pub(super) fn read_message_chat(r: &mut &[u8]) -> io::Result { }) } -/// Read `SMSG_CHAT_PLAYER_NOT_FOUND` (VERIFIED vmangos `WorldPackets::Chat::ChatPlayerNotFound:: -/// AppendBodyTo`, `Server/Packets/Chat.cpp:26-29`): one cstring, the (un-normalized) name the -/// player tried to whisper. Sent by `WorldSession::SendPlayerNotFoundNotice` -/// (`Handlers/ChatHandler.cpp:766-771`) — a bad whisper target, offline or misspelled. +/// Read `SMSG_CHAT_PLAYER_NOT_FOUND`: one cstring, the unnormalized name of an offline or +/// misspelled whisper target (vmangos `Server/Packets/Chat.cpp:26-29`). pub(super) fn read_chat_player_not_found(r: &mut &[u8]) -> io::Result { read_cstring(r) } -/// Read `SMSG_NOTIFICATION` (VERIFIED vmangos `WorldSession::SendNotification`, -/// `Server/WorldSession.cpp:900-915`: `data << szStr`): one cstring — the notice text, already -/// formatted server-side ("You do not know that language", trade refusals, and kin). The real -/// client flashes it in the red UIErrorsFrame. +/// Read `SMSG_NOTIFICATION`: one cstring of server-formatted notice text, which the 1.12 client +/// shows in the red UIErrorsFrame (vmangos `Server/WorldSession.cpp:900-915`). pub(super) fn read_notification(r: &mut &[u8]) -> io::Result { read_cstring(r) } -/// Read `SMSG_PLAYED_TIME` (VERIFIED vmangos `WorldPackets::Misc::PlayedTime::AppendBodyTo`, -/// `Server/Packets/Misc.cpp:278-282`; handler `WorldSession::HandlePlayedTime`, -/// `Handlers/MiscHandler.cpp:935-941`): `u32 totalPlayedTime` (seconds since character creation) + -/// `u32 levelPlayedTime` (seconds since the last level-up) — answers `CMSG_PLAYED_TIME` (empty -/// body, the `/played` command). +/// Read `SMSG_PLAYED_TIME`, the `/played` answer: total then this-level played time, both `u32` +/// seconds (vmangos `Server/Packets/Misc.cpp:278-282`). pub(super) fn read_played_time(r: &mut &[u8]) -> io::Result<(u32, u32)> { let total = read_u32_le(r)?; let level = read_u32_le(r)?; Ok((total, level)) } -/// Read `MSG_RANDOM_ROLL`'s server→client **broadcast** shape (VERIFIED vmangos -/// `WorldSession::HandleRandomRollOpcode`, `Handlers/GroupHandler.cpp:394-422`): `u32 minimum, u32 -/// maximum, u32 roll` (the server's own `urand(minimum, maximum)`), then a **raw** `u64` guid — the -/// roller (`data << GetPlayer()->GetObjectGuid()`, an `ObjectGuid` stream write, so unpacked). -/// Same opcode as the client→server request shape (`u32 minimum, u32 maximum`, -/// [`super::client::random_roll`]) — a client only ever *receives* this broadcast shape, never its -/// own request echoed back. +/// Read the `MSG_RANDOM_ROLL` broadcast (`GroupHandler.cpp:394-422`): `u32` minimum, maximum and +/// roll, then the roller's unpacked `u64` guid; the request on this opcode is only min and max. pub(super) fn read_random_roll(r: &mut &[u8]) -> io::Result<(u32, u32, u32, u64)> { let min = read_u32_le(r)?; let max = read_u32_le(r)?; diff --git a/crates/benilla-protocol/src/messages/client.rs b/crates/benilla-protocol/src/messages/client.rs index ba10ccbc..6fcd3d3e 100644 --- a/crates/benilla-protocol/src/messages/client.rs +++ b/crates/benilla-protocol/src/messages/client.rs @@ -1,14 +1,10 @@ -//! Client packet **body builders** — the outbound half of the message layer (the inbound parse + -//! the `ServerPacket` vocabulary stay in [`super`]; spell/attack builders live with their family -//! in [`super::spells`]). Framing/encryption is `world.rs`'s job; these produce plain body bytes. +//! Client packet body builders: plain body bytes, framed and encrypted elsewhere. use super::addons::{addon_tail, SecureAddon}; use super::{CharCreateReq, MovementInfo}; -/// `CMSG_AUTH_SESSION` body: build, server id, account name, client seed, proof, and the -/// **addon-info block** — `addons`' uncompressed size plus its zlib stream, exactly as the writer -/// at `0x51d910` appends it (see [`super::addons`], decision 1497). Pass -/// [`super::STOCK_SECURE_ADDONS`] for what a stock install sends. +/// `CMSG_AUTH_SESSION` body: build, server id, account, seed, proof, then the addon block's +/// uncompressed size and zlib stream (`0x51d910`); stock sends [`super::STOCK_SECURE_ADDONS`]. pub fn auth_session( build: u32, username: &str, @@ -28,15 +24,12 @@ pub fn auth_session( body } -/// `CMSG_CHAR_CREATE` body: name (CString), then race/class/gender + the 5 appearance bytes -/// (skin/face/hair_style/hair_color/facial_hair) + `outfit_id`. Matches vmangos's read exactly -/// (`Packets/Character.cpp:4-19`): name + 9 bytes. `outfit_id` is always 0 — the server reads and -/// ignores it (`CharacterHandler.cpp:310`) and recomputes start gear (decision 0423). +/// `CMSG_CHAR_CREATE` body: the name cstring then nine bytes (`Packets/Character.cpp:4-19`); the +/// last, `outfit_id`, is 0 because the server ignores it (`CharacterHandler.cpp:310`). pub fn char_create(req: &CharCreateReq) -> Vec { let mut body = Vec::with_capacity(req.name.len() + 10); body.extend_from_slice(req.name.as_bytes()); body.push(0); - // race, class, gender, skin, face, hair_style, hair_color, facial_hair, outfit_id(0). body.extend_from_slice(&[ req.race, req.class, @@ -51,13 +44,8 @@ pub fn char_create(req: &CharCreateReq) -> Vec { body } -/// `CMSG_MESSAGECHAT` body for every sendable `ChatMsg` type (VERIFIED vmangos -/// `WorldPackets::Chat::ChatMessage::ReadFromWorldPacket`, `Server/Packets/Chat.cpp:3-12`): -/// `recv_data >> type >> lang`, then `>> whisperTargetOrChannel` **only** for -/// `CHAT_MSG_WHISPER`/`CHAT_MSG_CHANNEL` (the target name or channel name rides *before* the -/// message, not after — vmangos's own union of the two), then `>> message`. `target` is `None` for -/// every other sendable type. The general form [`messagechat`]/[`messagechat_whisper`]/ -/// [`messagechat_channel`] build on. +/// `CMSG_MESSAGECHAT` body: `u32` type and language, the target or channel name for whisper and +/// channel only (`None` otherwise), then the message (vmangos `Server/Packets/Chat.cpp:3-12`). pub fn messagechat_kind( chat_type: u32, language: u32, @@ -76,31 +64,22 @@ pub fn messagechat_kind( body } -/// `CMSG_MESSAGECHAT` body (chat type + language as `u32`, then the message) — the shape shared by -/// every type that carries no target/channel name (SAY, YELL, EMOTE, PARTY, RAID, GUILD, OFFICER, -/// RAID_LEADER, RAID_WARNING, BATTLEGROUND(+LEADER), AFK, DND). Thin wrapper over -/// [`messagechat_kind`] with `target: None`. +/// `CMSG_MESSAGECHAT` body for every type without a target or channel name. pub fn messagechat(chat_type: u32, language: u32, message: &str) -> Vec { messagechat_kind(chat_type, language, None, message) } -/// `CMSG_MESSAGECHAT` body for `CHAT_MSG_WHISPER` (`0x6`): chat type + language, then the whisper -/// **target's player name**, then the message. Thin wrapper over [`messagechat_kind`]. +/// `CMSG_MESSAGECHAT` body for a whisper: the target's player name precedes the message. pub fn messagechat_whisper(language: u32, target: &str, message: &str) -> Vec { messagechat_kind(super::CHAT_TYPE_WHISPER, language, Some(target), message) } -/// `CMSG_MESSAGECHAT` body for `CHAT_MSG_CHANNEL` (`0xE`): chat type + language, then the -/// **channel name**, then the message — the same wire shape as [`messagechat_whisper`], vmangos's -/// `whisperTargetOrChannel` field doing double duty. Thin wrapper over [`messagechat_kind`]. +/// `CMSG_MESSAGECHAT` body for a channel line: the channel name precedes the message. pub fn messagechat_channel(language: u32, channel: &str, message: &str) -> Vec { messagechat_kind(super::CHAT_TYPE_CHANNEL, language, Some(channel), message) } -/// Concatenate `strings` as back-to-back NUL-terminated C-strings — the shape shared by the whole -/// `CMSG_CHANNEL_*` family (VERIFIED vmangos `Server/Packets/Channel.cpp`, every `ReadFromWorldPacket` -/// there: a channel name alone, or a channel name + a second field — a password or a target player -/// name). +/// Back-to-back cstrings, the shape of every `CMSG_CHANNEL_*` body (`Packets/Channel.cpp`). fn cstrings_body(strings: &[&str]) -> Vec { let mut body = Vec::with_capacity(strings.iter().map(|s| s.len() + 1).sum()); for s in strings { @@ -110,125 +89,101 @@ fn cstrings_body(strings: &[&str]) -> Vec { body } -/// `CMSG_JOIN_CHANNEL` body (VERIFIED vmangos `WorldPackets::Channel::JoinChannel:: -/// ReadFromWorldPacket`, `Server/Packets/Channel.cpp:3-7`): cstring channel name + cstring password -/// (empty string for none) — **no channel id** on the 1.12 wire (a TBC+ addition). +/// `CMSG_JOIN_CHANNEL` body: channel name, then password (empty for none); 1.12 sends no channel +/// id (`Channel.cpp:3-7`). pub fn join_channel(name: &str, password: &str) -> Vec { cstrings_body(&[name, password]) } -/// `CMSG_LEAVE_CHANNEL` body: cstring channel name (VERIFIED vmangos -/// `WorldPackets::Channel::LeaveChannel::ReadFromWorldPacket`, `Channel.cpp:9-12`). +/// `CMSG_LEAVE_CHANNEL` body: the channel name (`Channel.cpp:9-12`). pub fn leave_channel(name: &str) -> Vec { cstrings_body(&[name]) } -/// `CMSG_CHANNEL_LIST` body — asks for the member roster (VERIFIED vmangos -/// `WorldPackets::Channel::ChannelList::ReadFromWorldPacket`, `Channel.cpp:14-17`): cstring channel -/// name. Answered by `SMSG_CHANNEL_LIST` ([`super::channel::read_channel_list`]). +/// `CMSG_CHANNEL_LIST` body, the member-roster request: the channel name (`Channel.cpp:14-17`). pub fn channel_list(name: &str) -> Vec { cstrings_body(&[name]) } -/// `CMSG_CHANNEL_PASSWORD` body (VERIFIED vmangos `ChannelPassword::ReadFromWorldPacket`, -/// `Channel.cpp:19-23`): cstring channel name + cstring new password. +/// `CMSG_CHANNEL_PASSWORD` body: channel name, then the new password (`Channel.cpp:19-23`). pub fn channel_password(name: &str, password: &str) -> Vec { cstrings_body(&[name, password]) } -/// `CMSG_CHANNEL_SET_OWNER` body (VERIFIED vmangos `ChannelSetOwner::ReadFromWorldPacket`, -/// `Channel.cpp:25-29`): cstring channel name + cstring new owner's player name. +/// `CMSG_CHANNEL_SET_OWNER` body: channel name, then the new owner's name (`Channel.cpp:25-29`). pub fn channel_set_owner(name: &str, player: &str) -> Vec { cstrings_body(&[name, player]) } -/// `CMSG_CHANNEL_OWNER` body — asks who owns the channel (VERIFIED vmangos -/// `ChannelOwner::ReadFromWorldPacket`, `Channel.cpp:31-34`): cstring channel name only (the answer -/// rides `SMSG_CHANNEL_NOTIFY`'s [`super::channel::channel_notice::CHANNEL_OWNER`] notice). +/// `CMSG_CHANNEL_OWNER` body: the channel name (`Channel.cpp:31-34`); the answer is a +/// `SMSG_CHANNEL_NOTIFY` owner notice. pub fn channel_owner(name: &str) -> Vec { cstrings_body(&[name]) } -/// `CMSG_CHANNEL_MODERATOR` body (VERIFIED vmangos `ChannelModerator::ReadFromWorldPacket`, -/// `Channel.cpp:36-40`): cstring channel name + cstring target player name. +/// `CMSG_CHANNEL_MODERATOR` body: channel name, then the player's name (`Channel.cpp:36-40`). pub fn channel_moderator(name: &str, player: &str) -> Vec { cstrings_body(&[name, player]) } -/// `CMSG_CHANNEL_UNMODERATOR` body (VERIFIED vmangos `ChannelUnmoderator::ReadFromWorldPacket`, -/// `Channel.cpp:42-46`): cstring channel name + cstring target player name. +/// `CMSG_CHANNEL_UNMODERATOR` body: channel name, then the player's name (`Channel.cpp:42-46`). pub fn channel_unmoderator(name: &str, player: &str) -> Vec { cstrings_body(&[name, player]) } -/// `CMSG_CHANNEL_MUTE` body (VERIFIED vmangos `ChannelMute::ReadFromWorldPacket`, -/// `Channel.cpp:48-52`): cstring channel name + cstring target player name. +/// `CMSG_CHANNEL_MUTE` body: channel name, then the player's name (`Channel.cpp:48-52`). pub fn channel_mute(name: &str, player: &str) -> Vec { cstrings_body(&[name, player]) } -/// `CMSG_CHANNEL_UNMUTE` body (VERIFIED vmangos `ChannelUnmute::ReadFromWorldPacket`, -/// `Channel.cpp:54-58`): cstring channel name + cstring target player name. +/// `CMSG_CHANNEL_UNMUTE` body: channel name, then the player's name (`Channel.cpp:54-58`). pub fn channel_unmute(name: &str, player: &str) -> Vec { cstrings_body(&[name, player]) } -/// `CMSG_CHANNEL_INVITE` body (VERIFIED vmangos `ChannelInvite::ReadFromWorldPacket`, -/// `Channel.cpp:60-64`): cstring channel name + cstring invitee player name. +/// `CMSG_CHANNEL_INVITE` body: channel name, then the invitee's name (`Channel.cpp:60-64`). pub fn channel_invite(name: &str, player: &str) -> Vec { cstrings_body(&[name, player]) } -/// `CMSG_CHANNEL_KICK` body (VERIFIED vmangos `ChannelKick::ReadFromWorldPacket`, -/// `Channel.cpp:66-70`): cstring channel name + cstring target player name. +/// `CMSG_CHANNEL_KICK` body: channel name, then the player's name (`Channel.cpp:66-70`). pub fn channel_kick(name: &str, player: &str) -> Vec { cstrings_body(&[name, player]) } -/// `CMSG_CHANNEL_BAN` body (VERIFIED vmangos `ChannelBan::ReadFromWorldPacket`, -/// `Channel.cpp:72-76`): cstring channel name + cstring target player name. +/// `CMSG_CHANNEL_BAN` body: channel name, then the player's name (`Channel.cpp:72-76`). pub fn channel_ban(name: &str, player: &str) -> Vec { cstrings_body(&[name, player]) } -/// `CMSG_CHANNEL_UNBAN` body (VERIFIED vmangos `ChannelUnban::ReadFromWorldPacket`, -/// `Channel.cpp:78-82`): cstring channel name + cstring target player name. +/// `CMSG_CHANNEL_UNBAN` body: channel name, then the player's name (`Channel.cpp:78-82`). pub fn channel_unban(name: &str, player: &str) -> Vec { cstrings_body(&[name, player]) } -/// `CMSG_CHANNEL_ANNOUNCEMENTS` body — toggles the channel's join/leave announcements (VERIFIED -/// vmangos `ChannelAnnouncements::ReadFromWorldPacket`, `Channel.cpp:84-87`): cstring channel name -/// only. +/// `CMSG_CHANNEL_ANNOUNCEMENTS` body, the join/leave notice toggle (`Channel.cpp:84-87`). pub fn channel_announcements(name: &str) -> Vec { cstrings_body(&[name]) } -/// `CMSG_CHANNEL_MODERATE` body — toggles channel moderation (VERIFIED vmangos -/// `ChannelModerate::ReadFromWorldPacket`, `Channel.cpp:89-92`): cstring channel name only. +/// `CMSG_CHANNEL_MODERATE` body, the moderation toggle (`Channel.cpp:89-92`). pub fn channel_moderate(name: &str) -> Vec { cstrings_body(&[name]) } -/// `CMSG_PLAYED_TIME` body — empty (VERIFIED vmangos: the handler takes a `NullClientPacket`, -/// `Handlers/MiscHandler.cpp:935`). The `/played` request. +/// `CMSG_PLAYED_TIME` body, the `/played` request: empty (`Handlers/MiscHandler.cpp:935`). pub fn played_time() -> Vec { Vec::new() } -/// `CMSG_QUERY_TIME` body — empty (VERIFIED vmangos: `HandleQueryTimeOpcode` takes a -/// `NullClientPacket`, `Handlers/QueryHandler.cpp:107`). Asks for the server's wall clock; the -/// answer is `SMSG_QUERY_TIME_RESPONSE`, one `u32` of unix-epoch seconds. Decision 1150 — a timed -/// quest's deadline is an absolute stamp in *that* epoch, so the countdown is only as right as our -/// sample of the server's clock. +/// `CMSG_QUERY_TIME` body: empty (`Handlers/QueryHandler.cpp:107`). The answer is the server's +/// clock in unix seconds, the epoch timed-quest deadlines are stamped in. pub fn query_time() -> Vec { Vec::new() } -/// `MSG_RANDOM_ROLL` client→server request body (VERIFIED vmangos -/// `WorldPackets::Group::RandomRoll::ReadFromWorldPacket`, `Server/Packets/Group.cpp:39-43`): `u32 -/// minimum + u32 maximum`. The server validates `minimum <= maximum <= 10000` and broadcasts the -/// roll (layout in [`super::chat::read_random_roll`]); an out-of-range request is silently dropped. +/// `MSG_RANDOM_ROLL` request body (`Server/Packets/Group.cpp:39-43`); the server silently drops +/// one outside `minimum <= maximum <= 10000`. pub fn random_roll(min: u32, max: u32) -> Vec { let mut body = Vec::with_capacity(8); body.extend_from_slice(&min.to_le_bytes()); @@ -236,14 +191,8 @@ pub fn random_roll(min: u32, max: u32) -> Vec { body } -/// `CMSG_TEXT_EMOTE` body: `u32 textEmote (EmotesText.dbc id), u32 emoteNum, u64 target guid` -/// (0 = no target) — VERIFIED vmangos `TextEmote::ReadFromWorldPacket` (`Misc.cpp:60-65`), three -/// fields. `emoteNum` is only relayed to observers inside `SMSG_TEXT_EMOTE` (the emote-text -/// variation index; we send 0 = the first variation). The 12-byte two-field body this originally -/// sent made the server's guid read run off the packet's end — the emote was silently discarded, -/// on every send, which read as "/wave does nothing" (director-caught against the ref client). -/// The server echoes `SMSG_TEXT_EMOTE` to everyone in range **including us**, so our own emote -/// sound/anim plays through the same receive path as everyone else's. +/// `CMSG_TEXT_EMOTE` body: `u32` EmotesText.dbc id, `u32` text variation (only relayed), `u64` +/// target or 0 (`Misc.cpp:60-65`). The server echoes `SMSG_TEXT_EMOTE` to us as well. pub fn text_emote(text_id: u32, target: u64) -> Vec { let mut b = Vec::with_capacity(16); b.extend_from_slice(&text_id.to_le_bytes()); @@ -256,8 +205,7 @@ pub fn full_guid(guid: u64) -> Vec { guid.to_le_bytes().to_vec() } -/// Body of a `CMSG_CREATURE_QUERY`: the template `entry` + the asking guid (VERIFIED vmangos -/// `QueryCreature::ReadFromWorldPacket` — `entry` then a full 8-byte guid). +/// `CMSG_CREATURE_QUERY` body: the template entry, then a full guid (vmangos `QueryCreature`). pub fn creature_query(entry: u32, guid: u64) -> Vec { let mut body = Vec::with_capacity(12); body.extend_from_slice(&entry.to_le_bytes()); @@ -265,10 +213,8 @@ pub fn creature_query(entry: u32, guid: u64) -> Vec { body } -/// Body of a `CMSG_PET_NAME_QUERY`: the pet's **number** then its full guid (VERIFIED vmangos -/// `QueryPetName::ReadFromWorldPacket`, `Server/Packets/Pet.cpp:3-7`). Both values come out of the -/// pet's own guid ([`crate::guid::pet_number`]); the server answers only when the number agrees with -/// the live pet's `CharmInfo` (`PetHandler.cpp:190-192`), and stays silent otherwise. +/// `CMSG_PET_NAME_QUERY` body: the pet number, then its full guid (`Server/Packets/Pet.cpp:3-7`); +/// the server stays silent unless the number matches the live pet (`PetHandler.cpp:190-192`). pub fn pet_name_query(pet_number: u32, guid: u64) -> Vec { let mut body = Vec::with_capacity(12); body.extend_from_slice(&pet_number.to_le_bytes()); @@ -276,19 +222,15 @@ pub fn pet_name_query(pet_number: u32, guid: u64) -> Vec { body } -/// Body of a movement message (`MSG_MOVE_*`) — a `MovementInfo`. +/// Body of a `MSG_MOVE_*` message: a `MovementInfo`. pub fn movement(info: &MovementInfo) -> Vec { let mut body = Vec::with_capacity(28); info.write(&mut body); body } -/// Body of `CMSG_MOVE_TIME_SKIPPED` (client): the mover's **full 8-byte** guid then the skipped -/// milliseconds (vmangos `MoveTimeSkipped::ReadFromWorldPacket`, `Server/Packets/Movement.cpp:10-14` -/// — `recv_data >> guid` is `ObjectGuid`'s plain `read`, `ObjectGuid.cpp:180`, not the -/// packed reader `PackedGuidReader` takes). Note the asymmetry with the server's own relay of the -/// same fact, `MSG_MOVE_TIME_SKIPPED`, which writes `GetPackGUID()` (`MovementHandler.cpp:1014`): -/// inbound is full, outbound is packed. +/// `CMSG_MOVE_TIME_SKIPPED` body: the mover's unpacked guid, then the skipped ms +/// (`Server/Packets/Movement.cpp:10-14`); the server's relayed `MSG_MOVE_TIME_SKIPPED` packs it. pub fn move_time_skipped(guid: u64, lag_ms: u32) -> Vec { let mut body = Vec::with_capacity(12); body.extend_from_slice(&guid.to_le_bytes()); @@ -296,15 +238,9 @@ pub fn move_time_skipped(guid: u64, lag_ms: u32) -> Vec { body } -/// Body of `CMSG_MOVE_SPLINE_DONE` (client): the `MovementInfo` at the ride's endpoint, the -/// `spline_id` being acknowledged (matched against the server's newest spline for our mover), and a -/// trailing float the server reads-and-discards (`MoveSplineDone::ReadFromWorldPacket` does an -/// unconditional `read_skip()`, so the four bytes MUST be present or the server's parse -/// under-runs). The real client writes its **completion fraction** `clamp(elapsed/duration, 0, 1)` -/// there (byte-verified — `0x600b10`, decision 0496 §claim-6); we only ever send at ride -/// completion, so the faithful value is `1.0`. Emitted once when a server spline that drove our own -/// player (Charge/knockback/taxi) finishes; the server then relocates us and re-broadcasts a -/// stop/heartbeat to observers. +/// `CMSG_MOVE_SPLINE_DONE` body: the `MovementInfo` at the ride's end, the acked spline id, and a +/// float the server skips but requires. The 1.12 client writes its completion fraction there +/// (`0x600b10`); we send only at completion, so it is always 1.0. pub fn move_spline_done(info: &MovementInfo, spline_id: u32) -> Vec { let mut body = Vec::with_capacity(36); info.write(&mut body); @@ -313,8 +249,7 @@ pub fn move_spline_done(info: &MovementInfo, spline_id: u32) -> Vec { body } -/// Body of `MSG_MOVE_TELEPORT_ACK` (client): a **full** 8-byte GUID (not packed — vmangos requires -/// it for this opcode), the movement counter, and the client time. +/// `MSG_MOVE_TELEPORT_ACK` body: an unpacked guid (vmangos requires it), counter, client time. pub fn teleport_ack(guid: u64, counter: u32, time_ms: u32) -> Vec { let mut body = Vec::with_capacity(16); body.extend_from_slice(&guid.to_le_bytes()); @@ -323,13 +258,9 @@ pub fn teleport_ack(guid: u64, counter: u32, time_ms: u32) -> Vec { body } -/// Body of a `CMSG_FORCE_*_SPEED_CHANGE_ACK` (all six kinds share it): a **full** 8-byte guid (the -/// mover the SMSG named — vmangos `MoveSpeedAck::ReadFromWorldPacket` extracts a plain `ObjectGuid`, -/// which reads a raw `uint64`, `ObjectGuid.cpp:180` — NOT packed, unlike the SMSG's guid), the -/// echoed movement counter, our live `MovementInfo`, and the echoed new speed. The server accepts -/// only an exact counter match and a speed within ±0.01 of what it sent -/// (`Unit::FindPendingMovementSpeedChange`), then relocates us to the carried pose — so the info -/// must be the mover's honest live state, same as any `MSG_MOVE_*`. +/// Body of all six `CMSG_FORCE_*_SPEED_CHANGE_ACK`s: the mover's unpacked guid, the echoed +/// counter, our live `MovementInfo`, the echoed speed. The server needs an exact counter and a +/// speed within 0.01 (`Unit::FindPendingMovementSpeedChange`), then moves us to that pose. pub fn force_speed_ack(guid: u64, counter: u32, info: &MovementInfo, speed: f32) -> Vec { let mut body = Vec::with_capacity(44); body.extend_from_slice(&guid.to_le_bytes()); @@ -339,10 +270,8 @@ pub fn force_speed_ack(guid: u64, counter: u32, info: &MovementInfo, speed: f32) body } -/// Body of `CMSG_PING` (the ~30 s keepalive): `{u32 sequence, u32 lastRtt}` — VERIFIED twice, wow-re -/// net W1 (`SendPing 0x537e10`: seq = ++counter, then the last measured round-trip ms) and vmangos -/// `WorldSocket::_HandlePing` (`recvPacket >> ping >> latency`). The server echoes `sequence` back -/// as `SMSG_PONG` and stores `lastRtt` as the session's reported latency. +/// `CMSG_PING` body, the ~30 s keepalive (`0x537e10`): the sequence, then the last round trip in +/// ms, which the server stores as our latency; it echoes the sequence in `SMSG_PONG`. pub fn ping(sequence: u32, last_rtt_ms: u32) -> Vec { let mut body = Vec::with_capacity(8); body.extend_from_slice(&sequence.to_le_bytes()); @@ -350,18 +279,10 @@ pub fn ping(sequence: u32, last_rtt_ms: u32) -> Vec { body } -/// Body of a **movement-mode ack** — the whole ack'd family (root / water-walk / feather-fall / -/// hover; decisions 0308, 0866): a **full** `u64` guid, the echoed `u32` movement counter, our -/// current `MovementInfo`, and — for every mode *except root* — a trailing `u32 apply` (VERIFIED -/// vmangos `Server/Packets/Movement.cpp:38-59`: `MoveFlagChangeAck` reads the apply dword, -/// `MoveRootAck` does not; [`MoveMode::ack_carries_apply`](super::MoveMode::ack_carries_apply) is -/// that rule). The counter MUST be the one the server sent — a zero/greater counter trips its cheat -/// log (`HandleMoveRootAck`). -/// -/// **`info.flags` must carry the applied mode bit.** vmangos hard-requires it for root -/// (`HandleMoveRootAck:715` KICKS an apply-ack whose `MovementInfo` lacks `MOVEFLAG_ROOT`) and takes -/// the word as the mover's new flags for the rest — so an ack that drops the bit un-grants the very -/// mode it is accepting. +/// Body of a movement-mode ack: unpacked guid, the echoed counter, our `MovementInfo`, then +/// `u32 apply` for every mode but root (`Server/Packets/Movement.cpp:38-59`). `info.flags` must +/// carry the applied mode bit: vmangos kicks a root ack without it (`MovementHandler.cpp:722-729`) +/// and takes the flags as the mover's new ones for the other modes. pub fn move_flag_ack(guid: u64, counter: u32, info: &MovementInfo, apply: Option) -> Vec { let mut body = Vec::with_capacity(48); body.extend_from_slice(&guid.to_le_bytes()); @@ -373,18 +294,10 @@ pub fn move_flag_ack(guid: u64, counter: u32, info: &MovementInfo, apply: Option body } -/// Body of `CMSG_MOVE_KNOCK_BACK_ACK` (decision 1702) — **the same shape as a movement-mode ack -/// without its trailing `apply`**: a full `u64` guid, the echoed `u32` counter, and our -/// `MovementInfo`. Not a coincidence of layout: both land on a `WorldSession::HandleMove*Ack` that -/// reads exactly `guid >> counter >> movementInfo` (vmangos `Server/Packets/Movement.cpp:38-68`), -/// so they are one wire shape with two meanings, and delegating says so. -/// -/// **`info` must carry `MOVEFLAG_JUMPING` and the launch quad in its jump tail.** -/// `Unit::FindPendingMovementKnockbackChange` walks the pending changes and matches on the counter -/// *plus* all four floats within `0.01`; miss, and the ack is discarded as `OnWrongAckData` and the -/// knockback never reaches observers (the server builds their `MSG_MOVE_KNOCK_BACK` from this very -/// `MovementInfo`). Send nothing at all and it is `OnFailedToAckChange` — a knockback is the one -/// pending change vmangos refuses to re-send (`Unit.cpp:6887`). +/// `CMSG_MOVE_KNOCK_BACK_ACK` body, a movement-mode ack without `apply` (`Movement.cpp:38-68`). +/// `info` must carry `MOVEFLAG_JUMPING` and the launch quad: the server matches the counter and +/// all four floats within 0.01 (`Unit::FindPendingMovementKnockbackChange`), builds observers' +/// `MSG_MOVE_KNOCK_BACK` from this info, and never re-sends a knockback (`Unit.cpp:6887`). pub fn knock_back_ack(guid: u64, counter: u32, info: &MovementInfo) -> Vec { move_flag_ack(guid, counter, info, None) } @@ -394,11 +307,7 @@ mod tests { use super::*; use crate::wire::Vector3d; - /// `CMSG_MOVE_SPLINE_DONE` body, byte-exact: a `MovementInfo` (here with no swim/jump tail), - /// then the acked `splineId`, then the trailing float the server `read_skip`s. The order and the - /// mandatory-but-ignored trailing four bytes are what vmangos `MoveSplineDone::ReadFromWorldPacket` - /// requires (`>> movementInfo; >> splineId; read_skip()`); omitting the float under-runs - /// its parse. A new wire body never lands without a golden (method). + /// vmangos `MoveSplineDone::ReadFromWorldPacket` reads info, spline id, then a skipped float. #[test] fn move_spline_done_body_golden() { let info = MovementInfo { @@ -429,7 +338,6 @@ mod tests { 0x00, 0x00, 0x80, 0x3F, // completion fraction 1.0 (server-skipped) ]; assert_eq!(body, want, "CMSG_MOVE_SPLINE_DONE body"); - // The leading bytes are exactly a MovementInfo (the shape the server reads first). let mi = movement(&info); assert_eq!( &body[..mi.len()], diff --git a/crates/benilla-protocol/src/messages/combat_log.rs b/crates/benilla-protocol/src/messages/combat_log.rs index a3c26f16..a485725d 100644 --- a/crates/benilla-protocol/src/messages/combat_log.rs +++ b/crates/benilla-protocol/src/messages/combat_log.rs @@ -1,16 +1,5 @@ -//! The **combat log** wire — the inbound "what just happened to whom" packets: spell damage, -//! periodic aura ticks, heals, power gains, damage shields, environmental damage, and a cast's -//! per-target miss list. Split out of `messages/spells.rs` (decision 0640), where these already sat -//! behind a hand-drawn `--- the combat-log wire ---` banner; the banner was the concern boundary the -//! file couldn't express. -//! -//! Every layout here is VERIFIED against vmangos source (cited per item). This family is **inbound -//! only** — nothing in it has an outbound counterpart, so unlike its siblings it has no -//! `world::writer` twin. Its consumer is the floating/center combat text (decisions 0137 phase 2, -//! 0578, 0580) plus the fall-damage dust puff (`EnvironmentalDamageLog`). -//! -//! The melee half of the same story is [`super::attack`]'s `SMSG_ATTACKERSTATEUPDATE`: a *swing* -//! reports itself there, a *spell* reports itself here. +//! The combat log wire, inbound only: spell damage, periodic ticks, heals, power gains, damage +//! shields, environmental damage, misses, kills, dispels, enchants and spell effect logs. use std::io::{self, Read}; @@ -18,9 +7,8 @@ use crate::wire::{ capacity_hint, read_f32_le, read_i32_le, read_packed_guid, read_u32_le, read_u64_le, read_u8, }; -/// One decoded `SMSG_SPELLNONMELEEDAMAGELOG` — non-melee (spell) damage dealt (vmangos -/// `WorldPackets::Spell::SpellNonMeleeDamageLog::AppendBodyTo`, `Server/Packets/Spell.cpp:124-140` + -/// `Spell.h:178-198`). `hit_info` bit `0x2` is `SPELL_HIT_TYPE_CRIT` (vmangos `SpellDefines.h:179`). +/// `SMSG_SPELLNONMELEEDAMAGELOG`, spell damage dealt (`Server/Packets/Spell.cpp:124-140`); +/// `hit_info` bit `0x2` is a crit (`SpellDefines.h:179`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SpellDamageLog { pub target: u64, @@ -35,9 +23,6 @@ pub struct SpellDamageLog { pub hit_info: u32, } -/// Read `SMSG_SPELLNONMELEEDAMAGELOG`: target PackedGuid · attacker PackedGuid · spellId u32 · -/// damage u32 · school u8 · absorbed u32 · resist i32 · periodicLog u8 (bool) · unused u8 · blocked -/// u32 · hitInfo u32 · extendedData u8 (always 0 — read and dropped). pub(super) fn read_spell_damage_log(r: &mut impl Read) -> io::Result { let target = read_packed_guid(r)?; let attacker = read_packed_guid(r)?; @@ -65,11 +50,7 @@ pub(super) fn read_spell_damage_log(r: &mut impl Read) -> io::Result io::Result { let target = read_packed_guid(r)?; let caster = read_packed_guid(r)?; @@ -159,9 +136,7 @@ pub(super) fn read_periodic_aura_log(r: &mut impl Read) -> io::Result io::Result { Ok(SpellHealLog { target: read_packed_guid(r)?, @@ -183,10 +156,8 @@ pub(super) fn read_spell_heal_log(r: &mut impl Read) -> io::Result }) } -/// One decoded `SMSG_SPELLENERGIZELOG` — an instant power gain (vmangos -/// `WorldPackets::Spell::SpellEnergizeLog::AppendBodyTo`, `Server/Packets/Spell.cpp:114-121` + -/// `Spell.h:165-176`). `power` is the vmangos `Powers` enum (0 mana · 1 rage · 2 focus · -/// 3 energy · 4 happiness) — the center combat text's MANA/RAGE/FOCUS/ENERGY feed. +/// `SMSG_SPELLENERGIZELOG`, an instant power gain (`Server/Packets/Spell.cpp:114-121`); `power` is +/// 0 mana, 1 rage, 2 focus, 3 energy, 4 happiness. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SpellEnergizeLog { pub target: u64, @@ -196,8 +167,6 @@ pub struct SpellEnergizeLog { pub amount: u32, } -/// Read `SMSG_SPELLENERGIZELOG`: target PackedGuid · caster PackedGuid · spellId u32 · -/// powerType u32 · amount u32. pub(super) fn read_spell_energize_log(r: &mut impl Read) -> io::Result { Ok(SpellEnergizeLog { target: read_packed_guid(r)?, @@ -208,10 +177,8 @@ pub(super) fn read_spell_energize_log(r: &mut impl Read) -> io::Result io::Result { Ok(DamageShield { victim: read_u64_le(r)?, @@ -231,13 +196,9 @@ pub(super) fn read_damage_shield(r: &mut impl Read) -> io::Result }) } -/// One decoded `SMSG_ENVIRONMENTALDAMAGELOG` — environmental damage taken: fall, drowning, -/// fatigue, lava, slime, fire (vmangos `Unit::SendEnvironmentalDamageLog`, `Objects/Unit.cpp:5392` -/// → `WorldPackets::Combat::EnvironmentalDamageLog::AppendBodyTo`, `Server/Packets/Combat.cpp:58-67`; -/// the absorb/resist tail is the `> 1.6.1` layout our 5875 wire carries). `damage_type` is -/// vmangos `EnvironmentalDamageType` (`Objects/Player.h:590`): 0 exhausted · 1 drowning · -/// 2 **fall** · 3 lava · 4 slime · 5 fire — the index into the client's `EnvironmentalDamage.dbc` -/// 6-slot damage-type → SpellVisualKit table (its fall row is the landing dust puff). +/// `SMSG_ENVIRONMENTALDAMAGELOG`, environmental damage taken (`Server/Packets/Combat.cpp:58-67`). +/// `damage_type` is 0 exhausted, 1 drowning, 2 fall, 3 lava, 4 slime, 5 fire (`Player.h:590`), the +/// row of `EnvironmentalDamage.dbc` whose visual kit plays (fall's is the landing dust puff). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct EnvironmentalDamageLog { pub victim: u64, @@ -247,9 +208,7 @@ pub struct EnvironmentalDamageLog { pub resist: i32, } -/// Read `SMSG_ENVIRONMENTALDAMAGELOG`: victim raw `u64` guid (vmangos `ObjectGuid.cpp:174` -/// streams the raw value; the client reads it with its plain 8-byte guid reader `0x4190b0`) · -/// damageType u8 · damage u32 · absorbed u32 · resist i32. +/// Read `SMSG_ENVIRONMENTALDAMAGELOG`; the victim guid is raw on both ends (client `0x4190b0`). pub(super) fn read_environmental_damage_log( r: &mut impl Read, ) -> io::Result { @@ -262,12 +221,9 @@ pub(super) fn read_environmental_damage_log( }) } -/// One decoded `SMSG_SPELLLOGMISS` — a spell cast's per-target miss list (vmangos -/// `WorldPackets::Spell::SpellLogMiss::AppendBodyTo`, `Server/Packets/Spell.cpp:68-86` + -/// `Spell.h:109-124`). Each entry's `u8` is a `SpellMissInfo` (vmangos `SpellDefines.h:160-174`, -/// the same vocabulary [`SpellGo`](crate::messages::SpellGo)'s own miss list carries): 1 MISS · -/// 2 RESIST · 3 DODGE · 4 PARRY · -/// 5 BLOCK · 6 EVADE · 7/8 IMMUNE · 9 DEFLECT · 10 ABSORB · 11 REFLECT. +/// `SMSG_SPELLLOGMISS`, a cast's per-target miss list (`Server/Packets/Spell.cpp:68-86`). Each +/// `u8` is a `SpellMissInfo` (`SpellDefines.h:160-174`): 1 miss, 2 resist, 3 dodge, 4 parry, +/// 5 block, 6 evade, 7 and 8 immune, 9 deflect, 10 absorb, 11 reflect. #[derive(Debug, Clone, PartialEq)] pub struct SpellLogMiss { pub spell_id: u32, @@ -275,17 +231,12 @@ pub struct SpellLogMiss { pub misses: Vec<(u64, u8)>, } -/// Read `SMSG_SPELLLOGMISS`: spellId u32 · caster raw `u64` · useExtended u8 (vmangos always 0) · -/// count u32 · `count` entries of `{target raw u64, missInfo u8}`. When `useExtended != 0`, each -/// entry additionally carries a trailing `2×f32` — read and dropped to keep the cursor aligned; no -/// consumer needs it. pub(super) fn read_spell_log_miss(r: &mut impl Read) -> io::Result { let spell_id = read_u32_le(r)?; let caster = read_u64_le(r)?; let use_extended = read_u8(r)?; let count = read_u32_le(r)?; - // No server bound: vmangos never sends this opcode at all (its miss list rides - // `SMSG_SPELL_GO`), so 64 is a generous sane one. + // vmangos never sends this opcode (misses ride `SMSG_SPELL_GO`), so 64 is an arbitrary cap. let mut misses = Vec::with_capacity(capacity_hint(count, 64)); for _ in 0..count { let target = read_u64_le(r)?; @@ -303,18 +254,14 @@ pub(super) fn read_spell_log_miss(r: &mut impl Read) -> io::Result }) } -/// One decoded `SMSG_PARTYKILLLOG` — the killing blow, sent to the killer's party (vmangos -/// `WorldPackets::Combat::PartyKillLog::AppendBodyTo`, `Server/Packets/Combat.cpp:52-56`). -/// -/// It is the **only** source of the "You have slain %s!" / "%s is slain by %s!" pair; the plain -/// "%s dies." line is not a packet at all — it rides the client's own unit-death reflex. +/// `SMSG_PARTYKILLLOG`, the killing blow, sent to the killer's party (`Combat.cpp:52-56`): the +/// only source of "You have slain %s!"; "%s dies." comes from the client's own death handling. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PartyKillLog { pub killer: u64, pub victim: u64, } -/// Read `SMSG_PARTYKILLLOG`: killer raw `u64` guid · victim raw `u64` guid. pub(super) fn read_party_kill_log(r: &mut impl Read) -> io::Result { Ok(PartyKillLog { killer: read_u64_le(r)?, @@ -322,15 +269,13 @@ pub(super) fn read_party_kill_log(r: &mut impl Read) -> io::Result }) } -/// One decoded `SMSG_SPELLINSTAKILLLOG` — an instant kill (vmangos `Spell::EffectInstaKill`, -/// `Spells/SpellEffects.cpp:274-279`; the packet exists only for `> 1.11.2` clients, which 5875 is). +/// `SMSG_SPELLINSTAKILLLOG`, an instant kill (vmangos `Spells/SpellEffects.cpp:274-279`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SpellInstaKillLog { pub victim: u64, pub spell_id: u32, } -/// Read `SMSG_SPELLINSTAKILLLOG`: victim raw `u64` guid · spellId u32. pub(super) fn read_spell_insta_kill_log(r: &mut impl Read) -> io::Result { Ok(SpellInstaKillLog { victim: read_u64_le(r)?, @@ -338,12 +283,9 @@ pub(super) fn read_spell_insta_kill_log(r: &mut impl Read) -> io::Result io::Result { Ok(SpellOutcomeLog { caster: read_u64_le(r)?, @@ -363,19 +303,16 @@ pub(super) fn read_spell_outcome_log(r: &mut impl Read) -> io::Result= 1.12.1` branch our build takes). +/// `SMSG_SPELLDISPELLOG`, the auras a dispel removed (vmangos `SpellEffects.cpp:2524-2539`). #[derive(Debug, Clone, PartialEq, Eq)] pub struct SpellDispelLog { /// The unit the auras were on. pub victim: u64, - /// The dispeller. The sentence never names them — `AURADISPEL*` words the bearer and the aura. + /// The dispeller; the `AURADISPEL*` line never names them. pub caster: u64, pub spell_ids: Vec, } -/// Read `SMSG_SPELLDISPELLOG`: victim PackedGuid · caster PackedGuid · count u32 · `count` × u32. pub(super) fn read_spell_dispel_log(r: &mut impl Read) -> io::Result { let victim = read_packed_guid(r)?; let caster = read_packed_guid(r)?; @@ -391,11 +328,8 @@ pub(super) fn read_spell_dispel_log(r: &mut impl Read) -> io::Result, } -/// Read `SMSG_DISPEL_FAILED`: caster raw `u64` · victim raw `u64` · u32 spell ids to end of body. pub(super) fn read_dispel_failed(r: &mut impl Read) -> io::Result { let caster = read_u64_le(r)?; let victim = read_u64_le(r)?; @@ -412,7 +345,7 @@ pub(super) fn read_dispel_failed(r: &mut impl Read) -> io::Result let mut b = [0u8; 4]; match r.read_exact(&mut b) { Ok(()) => spell_ids.push(u32::from_le_bytes(b)), - // The body ended — the only terminator this packet has. + // The end of the body is the list's only terminator. Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break, Err(e) => return Err(e), } @@ -424,25 +357,19 @@ pub(super) fn read_dispel_failed(r: &mut impl Read) -> io::Result }) } -/// One decoded `SMSG_ENCHANTMENTLOG` — an enchant landing on, or fading from, an item (vmangos -/// `WorldPackets::Item::EnchantmentLog::AppendBodyTo`, `Server/Packets/Item.cpp:235-242`; -/// filled by `Player::SendEnchantmentLog`, `Objects/Player.cpp:12049-12072`). -/// -/// **An empty `caster` means the enchant FADED**, not that the server forgot who cast it — that is -/// vmangos's own comment on the field, and it is the two-way the reference switches on. +/// `SMSG_ENCHANTMENTLOG`, an enchant landing on or fading from an item +/// (`Server/Packets/Item.cpp:235-242`); a zero `caster` means it faded. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct EnchantmentLog { pub caster: u64, pub owner: u64, pub item_entry: u32, pub spell_id: u32, - /// vmangos `showAffiliation`: false on the copy sent to the item's owner, true on the broadcast - /// to everyone else. The reference lets it pick the msg-id selector on the ADD leg. + /// False on the owner's copy, true on everyone else's; the 1.12 client picks the + /// enchant-added message by it. pub show_affiliation: bool, } -/// Read `SMSG_ENCHANTMENTLOG`: caster raw `u64` · owner raw `u64` · itemEntry u32 · spellId u32 · -/// showAffiliation u8. pub(super) fn read_enchantment_log(r: &mut impl Read) -> io::Result { Ok(EnchantmentLog { caster: read_u64_le(r)?, @@ -453,17 +380,11 @@ pub(super) fn read_enchantment_log(r: &mut impl Read) -> io::Result io::Result { let caster = read_packed_guid(r)?; let spell_id = read_u32_le(r)?; @@ -583,8 +490,6 @@ pub(super) fn read_spell_log_execute(r: &mut impl Read) -> io::Result ExecuteLog::Target { target: read_u64_le(r)?, }, @@ -605,9 +510,7 @@ pub(super) fn read_spell_log_execute(r: &mut impl Read) -> io::Result bool { matches!( effect, diff --git a/crates/benilla-protocol/src/messages/death.rs b/crates/benilla-protocol/src/messages/death.rs index 24a0e810..99c1295e 100644 --- a/crates/benilla-protocol/src/messages/death.rs +++ b/crates/benilla-protocol/src/messages/death.rs @@ -1,38 +1,23 @@ -//! The death-arc messages (decision 0308): release/repop, the corpse query, the reclaim delay + -//! reclaim, the spirit healer, and resurrect requests. Every body shape below is VERIFIED against -//! vmangos source (file:line cites per item); the goldens mirror the server's own packet builds. -//! -//! Not modelled here (known siblings): `SMSG_DURABILITY_DAMAGE_DEATH` (0x2BD — an EMPTY body the -//! server sends with the 10% natural-death durability loss; the real client shows a durability -//! warning — a later polish slice), and the BG-only death family (area spirit healers, the forced -//! BG repop aura 2584 — 0308 defers battlegrounds). +//! Death and resurrection: the corpse query, corpse reclaim, spirit healers, resurrect offers. use std::io; use crate::wire::{read_cstring, read_f32_le, read_i32_le, read_u32_le, read_u64_le, read_u8}; -/// `MSG_CORPSE_QUERY`'s answer (opcode `0x216`/534 — VERIFIED vmangos `Opcodes_1_12_1.h:535`; our -/// request is the same opcode with an EMPTY body, `NullClientPacket`). VERIFIED -/// `QueryHandler.cpp:258-304` (`HandleCorpseQueryOpcode`): not-found = the lone `u8(0)`; found = -/// `u8(1), i32 mapid, f32 x, f32 y, f32 z, u32 corpsemapid`. -/// -/// The two maps differ deliberately: `display_map`/`position` are **where to walk toward** — for a -/// corpse inside a dungeon they are rewritten to the dungeon's `ghostEntranceMap` + entrance -/// coordinates — while `corpse_map` is always the corpse's real map, untouched. The server also -/// pushes an UNPROMPTED not-found (`u8(0)`) when a lootable corpse converts to bones -/// (`Map.cpp:3624-3629`), so a not-found mid-session means "drop the marker", not "query failed". +/// `MSG_CORPSE_QUERY`'s answer (`QueryHandler.cpp:258-304`), a lone `u8(0)` when not found; the +/// request is the same opcode, empty. The server also sends an unprompted not-found when the +/// corpse turns to bones (`Map.cpp:3624-3629`), which means "drop the marker". #[derive(Debug, Clone, PartialEq)] pub struct CorpseLocation { pub found: bool, - /// The map to show/route toward (dungeon-entrance-adjusted). `0` when `!found`. + /// The map to walk toward, rewritten to the entrance's for a corpse inside a dungeon. pub display_map: i32, - /// Raw WoW coordinates of the corpse (or the dungeon entrance). Zeroed when `!found`. + /// Raw WoW coordinates of the corpse, or of the dungeon entrance. pub position: [f32; 3], - /// The corpse's REAL map id, never adjusted. `0` when `!found`. + /// The corpse's real map id, never adjusted. pub corpse_map: u32, } -/// Read `MSG_CORPSE_QUERY`'s answer — both shapes (see [`CorpseLocation`]). pub(super) fn read_corpse_query_response(r: &mut &[u8]) -> io::Result { let found = read_u8(r)? != 0; if !found { @@ -54,26 +39,16 @@ pub(super) fn read_corpse_query_response(r: &mut &[u8]) -> io::Result io::Result { read_u32_le(r) } -/// A resurrection offer (`SMSG_RESURRECT_REQUEST`, opcode `0x15B`/347 — VERIFIED -/// `Opcodes_1_12_1.h:348`). VERIFIED `Spell.cpp:5024-5044` (`Spell::SendResurrectRequest`): -/// `u64 caster, u32 nameLen (strlen+1), cstring name, u8 sickness, u8 hasResTimer`. -/// -/// `name` is **empty when the caster is a player** (the client resolves the offerer's display name -/// from the guid through the ordinary name-query cache); it carries the localized creature name for -/// an NPC caster. `sickness` warns the accept applies resurrection sickness; `has_timer` says the -/// client should still honor the `SMSG_CORPSE_RECLAIM_DELAY` gate (inverted server-side from -/// `SPELL_ATTR_EX3_NO_RES_TIMER`). The reference UI picks its popup off exactly these two bits -/// (RESURRECT / RESURRECT_NO_SICKNESS / RESURRECT_NO_TIMER — UIParent.lua's RESURRECT_REQUEST arm). +/// `SMSG_RESURRECT_REQUEST`, a resurrection offer (`Spell.cpp:5024-5044`). `sickness` warns the +/// accept brings resurrection sickness, `has_timer` keeps the reclaim-delay gate, and the stock +/// popup picks its variant by the two. #[derive(Debug, Clone, PartialEq)] pub struct ResurrectRequestBody { pub caster: u64, @@ -83,9 +58,7 @@ pub struct ResurrectRequestBody { pub has_timer: bool, } -/// Read `SMSG_RESURRECT_REQUEST` (see [`ResurrectRequestBody`]). The `u32` length prefix duplicates -/// the C-string's own length (strlen+1) — read and cross-checked loosely (the cstring terminator is -/// authoritative; vmangos always writes both consistently). +/// Read `SMSG_RESURRECT_REQUEST`; the name's `u32` length (strlen + 1) is skipped for its NUL. pub(super) fn read_resurrect_request(r: &mut &[u8]) -> io::Result { let caster = read_u64_le(r)?; let _name_len = read_u32_le(r)?; @@ -100,40 +73,27 @@ pub(super) fn read_resurrect_request(r: &mut &[u8]) -> io::Result io::Result { read_u64_le(r) } -/// Body of `CMSG_RECLAIM_CORPSE` (opcode `0x1D2`/466 — VERIFIED `Opcodes_1_12_1.h:467`): one full -/// `u64` guid (VERIFIED `Server/Packets/Misc.cpp:122-125` — `recv_data >> guid`). The server -/// resolves the corpse through the player itself and never checks this guid's content, but the -/// real client sends its corpse's guid — we do the same. Server gates (`MiscHandler.cpp:573-603`): -/// dead + `PLAYER_FLAGS_GHOST` + corpse exists + the reclaim delay elapsed + within -/// `CORPSE_RECLAIM_RADIUS` (39 yd, `Corpse.h:40`) — success is `ResurrectPlayer(0.5)` + bones. +/// `CMSG_RECLAIM_CORPSE` body: the corpse's full guid, as the 1.12 client sends, though the server +/// ignores it. It requires a ghost, the delay elapsed and 39 yd range (`MiscHandler.cpp:573-603`). pub fn reclaim_corpse(corpse_guid: u64) -> Vec { corpse_guid.to_le_bytes().to_vec() } -/// Body of `CMSG_SPIRIT_HEALER_ACTIVATE` (opcode `0x21C`/540 — VERIFIED `Opcodes_1_12_1.h:541`): -/// one full `u64`, the spirit healer's guid (VERIFIED `Server/Packets/Npc.cpp:40-43`). Server -/// gates (`NPCHandler.cpp:416-428` → `CanInteractWithNPC` with `UNIT_NPC_FLAG_SPIRITHEALER` -/// `0x20`): 5-yd interaction distance, ghost-visible NPC. Effect (`SendSpiritResurrect`, -/// `NPCHandler.cpp:430-477`): res at 50%, 25% durability loss on ALL items, resurrection sickness -/// 15007 at level ≥ 11 (scaling duration to 10 min at 20+), corpse → bones, teleport to the -/// corpse-nearest graveyard when it differs from the current one. +/// `CMSG_SPIRIT_HEALER_ACTIVATE` body: the healer's full guid (`Npc.cpp:40-43`), within 5 yd. The +/// server resurrects at 50% with 25% durability loss and, from level 11, sickness +/// (`NPCHandler.cpp:430-477`). pub fn spirit_healer_activate(npc: u64) -> Vec { npc.to_le_bytes().to_vec() } -/// Body of `CMSG_RESURRECT_RESPONSE` (opcode `0x15C`/348 — VERIFIED `Opcodes_1_12_1.h:349`): the -/// offerer's `u64` guid + `u8 accept` (VERIFIED `Server/Packets/Misc.cpp:132-136`). A decline just -/// clears the server-side offer; an accept teleports us to the caster (cross-instance redirected to -/// the entrance) and resurrects with the offer's stored health/mana (`Player.cpp:20188-20244`). +/// `CMSG_RESURRECT_RESPONSE` body: the offerer's guid and `u8 accept` (`Misc.cpp:132-136`); an +/// accept moves us to the caster and resurrects us (`Player.cpp:20188-20244`). pub fn resurrect_response(caster: u64, accept: bool) -> Vec { let mut body = Vec::with_capacity(9); body.extend_from_slice(&caster.to_le_bytes()); @@ -145,9 +105,7 @@ pub fn resurrect_response(caster: u64, accept: bool) -> Vec { mod tests { use super::*; - /// The found shape, byte-exact against `HandleCorpseQueryOpcode`'s build (`QueryHandler.cpp: - /// 296-303`): `u8(1) << i32(mapid) << f32(x) << f32(y) << f32(z) << u32(corpsemapid)` — here a - /// corpse on Eastern Kingdoms (0) at Northshire-ish coords, no entrance adjustment. + /// Built as `HandleCorpseQueryOpcode` builds it (`QueryHandler.cpp:296-303`). #[test] fn corpse_query_found_golden() { let mut body = vec![1u8]; @@ -170,8 +128,7 @@ mod tests { ); } - /// The not-found shape is the lone `u8(0)` (`QueryHandler.cpp:262-267`) — also pushed - /// unprompted at bones-conversion (`Map.cpp:3624-3629`). + /// The lone `u8(0)` of `QueryHandler.cpp:262-267`. #[test] fn corpse_query_not_found_golden() { let body = [0u8]; @@ -182,8 +139,7 @@ mod tests { assert_eq!(loc.corpse_map, 0); } - /// A dungeon corpse: `mapid`/coords rewritten to the ghost entrance, `corpsemapid` kept real - /// (`QueryHandler.cpp:276-292`) — the two-map split the marker consumer relies on. + /// A dungeon corpse's entrance map and real map (`QueryHandler.cpp:276-292`). #[test] fn corpse_query_dungeon_entrance_split() { let mut body = vec![1u8]; @@ -191,14 +147,14 @@ mod tests { body.extend_from_slice(&(-11209.6f32).to_le_bytes()); // Deadmines entrance-ish body.extend_from_slice(&1666.54f32.to_le_bytes()); body.extend_from_slice(&25.0f32.to_le_bytes()); - body.extend_from_slice(&36u32.to_le_bytes()); // the corpse's REAL map: Deadmines + body.extend_from_slice(&36u32.to_le_bytes()); // the corpse's real map: Deadmines let mut r = body.as_slice(); let loc = read_corpse_query_response(&mut r).unwrap(); assert_eq!(loc.display_map, 0); assert_eq!(loc.corpse_map, 36); } - /// `SMSG_CORPSE_RECLAIM_DELAY` is one `u32` of milliseconds (`Misc.cpp:653-656`; 30 s base). + /// One `u32` of ms (`Misc.cpp:653-656`); 30 s is the base delay. #[test] fn corpse_reclaim_delay_golden() { let body = 30_000u32.to_le_bytes(); @@ -207,9 +163,7 @@ mod tests { assert!(r.is_empty()); } - /// A player-caster offer, byte-exact against `Spell::SendResurrectRequest` (`Spell.cpp: - /// 5024-5044`): empty name (len prefix 1 = just the terminator), sickness 0 (a player res - /// spell), hasResTimer 1 (no `SPELL_ATTR_EX3_NO_RES_TIMER`). + /// Built as `Spell::SendResurrectRequest` builds a player's offer (`Spell.cpp:5024-5044`). #[test] fn resurrect_request_player_caster_golden() { let mut body = Vec::new(); @@ -232,8 +186,6 @@ mod tests { ); } - /// An NPC-caster offer carries the creature's localized name and (for a spirit-healer-style - /// caster) the sickness warning bit. #[test] fn resurrect_request_npc_caster_golden() { let name = b"Spirit Healer"; @@ -263,8 +215,6 @@ mod tests { assert!(r.is_empty()); } - /// The three client bodies: full little-endian guids (+ the accept byte), matching the server - /// readers quoted on each builder. #[test] fn client_bodies_are_full_guids() { assert_eq!( @@ -284,16 +234,14 @@ mod tests { } } -/// `SMSG_AREA_SPIRIT_HEALER_TIME` (VERIFIED, wow-re `staticpopup-dialog-bindings.md` §6, arm -/// `0x48fa29`): the battleground spirit healer's guid and the milliseconds to its next -/// resurrection wave. Decision 1963. +/// `SMSG_AREA_SPIRIT_HEALER_TIME`: a battleground spirit healer's guid and the ms until its next +/// resurrection wave (client `0x48fa29`). #[derive(Clone, Debug, PartialEq, Eq)] pub struct AreaSpiritHealerTime { pub healer: u64, pub ms: u32, } -/// Parse `SMSG_AREA_SPIRIT_HEALER_TIME`: `u64 guid`, `u32 ms`. pub(super) fn read_area_spirit_healer_time( r: &mut impl std::io::Read, ) -> std::io::Result { @@ -303,8 +251,8 @@ pub(super) fn read_area_spirit_healer_time( }) } -/// Body of `CMSG_AREA_SPIRIT_HEALER_QUERY` / `CMSG_AREA_SPIRIT_HEALER_QUEUE` (VERIFIED, §6): one -/// `u64`, the healer's guid — the query when the client adopts a healer, the queue on Accept. +/// `CMSG_AREA_SPIRIT_HEALER_QUERY` or `_QUEUE` body: the healer's guid; the client queries when it +/// adopts a healer and queues on accept. pub fn area_spirit_healer(healer: u64) -> Vec { healer.to_le_bytes().to_vec() } diff --git a/crates/benilla-protocol/src/messages/duel.rs b/crates/benilla-protocol/src/messages/duel.rs index c37a63bf..ffbec623 100644 --- a/crates/benilla-protocol/src/messages/duel.rs +++ b/crates/benilla-protocol/src/messages/duel.rs @@ -1,43 +1,18 @@ -//! The duel family — challenge, countdown, bounds, completion, and the winner broadcast -//! (opcodes 359-365 + 695; decision 0633). -//! -//! Duels are the one family where **both ends are pinned at the bytes**. The server side is -//! vmangos `Server/Packets/Duel.{h,cpp}` (every `ReadFromWorldPacket` / `AppendBodyTo`), -//! `Handlers/DuelHandler.cpp`, `Objects/Player.cpp` (`DuelComplete`, `CheckDuelDistance`, -//! `UpdateDuelFlag`, `SendDuelCountdown`) and `Spells/SpellEffects.cpp` (`EffectDuel`). The -//! client side is WoW.exe's own duel TU — `E:\build\buildWoW\WoW\Source\Ui\DuelInfo.cpp` -//! (`__FILE__` @`0x84a020`), handlers registered at `0x4d4710`: -//! -//! | opcode | handler | what it reads | -//! |---|---|---| -//! | `SMSG_DUEL_REQUESTED` 0x167 | `0x4d49d0` | two full guids: arbiter, then challenger | -//! | `SMSG_DUEL_OUTOFBOUNDS` 0x168 | `0x4d4aa0` | nothing | -//! | `SMSG_DUEL_INBOUNDS` 0x169 | `0x4d4ac0` | nothing | -//! | `SMSG_DUEL_COMPLETE` 0x16a | `0x4d4b20` | one `u8` | -//! | `SMSG_DUEL_WINNER` 0x16b | `0x4d4ba0` | `u8` + two cstrings | -//! | `SMSG_DUEL_COUNTDOWN` 0x2b7 | `0x4d4ae0` | one `u32`, **milliseconds** | -//! -//! and the two sends, `AcceptDuel` `0x4d4830` / `CancelDuel` `0x4d48b0`, which each build the -//! opcode and push the **stored arbiter guid** (`[0xb73240]`, saved by the request handler) — the -//! body vmangos ignores, so only the binary could settle it. +//! The duel family: challenge, countdown, bounds, completion and the winner line. The 1.12 +//! client's handlers are registered at `0x4d4710`; the two bounds messages have empty bodies. use std::io::{self, Read}; use crate::wire::{read_cstring, read_u32_le, read_u64_le, read_u8}; -/// `SMSG_DUEL_REQUESTED` — a duel challenge, sent to **both** parties (VERIFIED vmangos -/// `SpellEffects.cpp` `EffectDuel`: `data << pGameObj->GetObjectGuid(); data << -/// caster->GetObjectGuid();` then `SendPacket` to caster *and* target). +/// `SMSG_DUEL_REQUESTED`, a duel challenge sent to both parties (vmangos `EffectDuel`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct DuelRequested { - /// The **duel-flag GameObject** the effect spawned midway between the two players (`Duel - /// Flag`, entry 21680, type 16 `DUEL_ARBITER`). It is both the out-of-bounds anchor and the - /// identity of the duel: it lands in each duellist's `PLAYER_DUEL_ARBITER` descriptor field, - /// and it is the guid the client echoes back on accept/cancel. + /// The duel flag game object (entry 21680) between the players: the bounds anchor, the + /// `PLAYER_DUEL_ARBITER` value, and the guid echoed on accept and cancel. pub arbiter: u64, - /// Who cast the challenge. The client compares this against its own guid: equal means *we* - /// challenged (show "You have requested a duel." and auto-accept), otherwise we are the one - /// being challenged (`0x4d49d0`). + /// Who challenged; when it is us, the 1.12 client shows "You have requested a duel." and + /// auto-accepts (`0x4d49d0`). pub challenger: u64, } @@ -49,28 +24,21 @@ pub fn read_duel_requested(r: &mut impl Read) -> io::Result { }) } -/// Read `SMSG_DUEL_COMPLETE`'s single `u8` and return it as vmangos's `started` flag (VERIFIED -/// `Player::DuelComplete`: `packet->started = (type != DUEL_INTERRUPTED)`). -/// -/// `false` — the duel ended **before it began** (declined, cancelled during the countdown, one -/// side logged out or was interrupted). That is exactly the case the client answers with the -/// `ERR_DUEL_CANCELLED` line (`0x4d4b20`: `test al,al; jne` past `DisplayError(0x136)`). +/// Read `SMSG_DUEL_COMPLETE`'s `started` flag; false means the duel ended before it began, which +/// the 1.12 client reports as `ERR_DUEL_CANCELLED` (`0x4d4b20`). pub fn read_duel_complete(r: &mut impl Read) -> io::Result { Ok(read_u8(r)? != 0) } -/// `SMSG_DUEL_WINNER` — the outcome line, broadcast to everyone around the loser (VERIFIED -/// `Player::DuelComplete`'s `SendObjectMessageToSet`, so bystanders read it too). +/// `SMSG_DUEL_WINNER`, the outcome line, broadcast around the loser (`Player::DuelComplete`). #[derive(Debug, Clone, PartialEq, Eq)] pub struct DuelWinner { - /// `false` = knocked out (`DUEL_WINNER_KNOCKOUT`, "%1$s has defeated %2$s in a duel"), - /// `true` = the loser fled or forfeited (`DUEL_WINNER_RETREAT`, "%2$s has fled from %1$s in - /// a duel"). vmangos writes `fled = (type != DUEL_WON)`; the client picks the template on - /// `test al,al` at `0x4d4bd9`. + /// True when the loser fled or forfeited (`DUEL_WINNER_RETREAT`), false for a knockout + /// (`DUEL_WINNER_KNOCKOUT`); the 1.12 client picks the line by it (`0x4d4bd9`). pub fled: bool, - /// Winner's name (`%1$s`). The client reads it into a 0x30-byte buffer. + /// The winner's name, `%1$s`. pub winner: String, - /// Loser's name (`%2$s`), same buffer size. + /// The loser's name, `%2$s`. pub loser: String, } @@ -83,33 +51,20 @@ pub fn read_duel_winner(r: &mut impl Read) -> io::Result { }) } -/// Read `SMSG_DUEL_COUNTDOWN` and return **seconds**. -/// -/// The wire carries milliseconds (vmangos `HandleDuelAcceptedOpcode` sends a literal `3000`), and -/// the client divides by 1000 before using it as a tick count — `0x4d4aef: mov eax,0x10624dd3; -/// mul [countdown]; shr edx,0x6` is the compiler's reciprocal for `/1000`. The truncation is the -/// client's, so it is ours: a hypothetical 3500 ms counts down from 3, not 4. +/// Read `SMSG_DUEL_COUNTDOWN` as whole seconds: the wire carries ms (vmangos sends 3000) and the +/// 1.12 client truncates them to seconds (`0x4d4aef`). pub fn read_duel_countdown(r: &mut impl Read) -> io::Result { Ok(read_u32_le(r)? / 1000) } -/// Body of `CMSG_DUEL_ACCEPTED` — the duel-arbiter guid (VERIFIED WoW.exe `AcceptDuel 0x4d4830`: -/// builds opcode `0x16c`, then `PutInt64([0xb73240])`, the guid the request handler stored). -/// -/// vmangos reads the guid into `DuelAccepted::playerGuid` and never looks at it -/// (`HandleDuelAcceptedOpcode` keys off `m_duel` alone) — the binary is the only authority for -/// what actually goes on the wire, which is why we read it there rather than guessing zero. +/// `CMSG_DUEL_ACCEPTED` body: the arbiter guid from the request, as the 1.12 client sends it +/// (`0x4d4830`); vmangos ignores it. pub fn duel_accepted(arbiter: u64) -> Vec { arbiter.to_le_bytes().to_vec() } -/// Body of `CMSG_DUEL_CANCELLED` — the same arbiter guid (VERIFIED WoW.exe `CancelDuel 0x4d48b0`, -/// identical to [`duel_accepted`] but for opcode `0x16d`). -/// -/// One opcode covers three player intents, disambiguated server-side by duel state -/// (`HandleDuelCancelledOpcode`): declining the popup, cancelling during the countdown -/// (`DUEL_INTERRUPTED`), and `/forfeit` **after** the duel has started — which makes the sender -/// beg (spell 7267) and hands the win to the opponent. +/// `CMSG_DUEL_CANCELLED` body: the arbiter guid (`0x4d48b0`). The server reads it by duel state as +/// a decline, a countdown cancel, or once started a forfeit that hands the opponent the win. pub fn duel_cancelled(arbiter: u64) -> Vec { arbiter.to_le_bytes().to_vec() } @@ -118,7 +73,6 @@ pub fn duel_cancelled(arbiter: u64) -> Vec { mod tests { use super::*; - /// `SMSG_DUEL_REQUESTED`: arbiter then challenger, both full 8-byte little-endian guids. #[test] fn duel_requested_is_arbiter_then_challenger() { let body = [ @@ -134,14 +88,12 @@ mod tests { ); } - /// `SMSG_DUEL_COMPLETE`'s `started` byte: 0 = interrupted before the duel began. #[test] fn duel_complete_started_byte() { assert!(!read_duel_complete(&mut &[0u8][..]).unwrap()); assert!(read_duel_complete(&mut &[1u8][..]).unwrap()); } - /// `SMSG_DUEL_WINNER`: flag, winner cstring, loser cstring. #[test] fn duel_winner_flag_then_two_names() { let mut body = vec![1u8]; @@ -157,7 +109,6 @@ mod tests { ); } - /// `SMSG_DUEL_COUNTDOWN` is milliseconds on the wire; the client's `/1000` truncates. #[test] fn duel_countdown_is_milliseconds_truncated_to_seconds() { assert_eq!( @@ -174,8 +125,7 @@ mod tests { ); } - /// Both client sends are the bare arbiter guid — byte-for-byte what `0x4d4830`/`0x4d48b0` - /// push. + /// The bytes `0x4d4830` and `0x4d48b0` push. #[test] fn accept_and_cancel_carry_the_arbiter_guid() { let guid = 0x0000_00F1_DEAD_BEEFu64; diff --git a/crates/benilla-protocol/src/messages/gameobject.rs b/crates/benilla-protocol/src/messages/gameobject.rs index c4f90300..3f06eaa4 100644 --- a/crates/benilla-protocol/src/messages/gameobject.rs +++ b/crates/benilla-protocol/src/messages/gameobject.rs @@ -1,31 +1,17 @@ -//! GameObject-interaction messages (decision 0236). The player-facing verb for every usable world -//! GameObject — a chest, a door, a quest object, a lever — is the single `CMSG_GAMEOBJ_USE`; the -//! server fans it out by GO type and answers on the systems we already have (the loot window for a -//! chest, the gossip/quest windows for a questgiver GO, a `GAMEOBJECT_STATE` flip for a door). This -//! module also carries the ask-once `CMSG_GAMEOBJECT_QUERY` template lookup (`entry + guid` → -//! `SMSG_GAMEOBJECT_QUERY_RESPONSE`'s type/display/name/`data[24]` head) — the GO twin of -//! `CMSG_CREATURE_QUERY`; it grows further as the arc lands its later phases. +//! Game object messages: `CMSG_GAMEOBJ_USE`, the one verb for every usable object, which the +//! server answers through loot, gossip or state; the template query; and one-shot animations. use std::io; use crate::wire::{read_cstring, read_i32_le, read_u32_le, read_u64_le}; -/// Body of `CMSG_GAMEOBJ_USE` (opcode `0xB1`/177 — VERIFIED vmangos -/// `Server/Protocol/Opcodes_1_12_1.h`): one full `u64` guid, the GameObject to use. Read server-side -/// by `WorldSession::HandleGameObjectUseOpcode` (`Handlers/SpellHandler.cpp`), which gates on -/// spawned · not GENERIC(5) · not `GO_FLAG_NO_INTERACT` · interact distance · `PlayerCanUse`, then -/// runs `GameObject::Use`. The wire shape is identical to `CMSG_LOOT` — a bare little-endian guid — -/// but the two are **not** interchangeable: `HandleLootOpcode` rejects a GameObject guid outright -/// (anticheat), so a chest opens its loot through *this* opcode (→ server `SendLoot` → -/// `SMSG_LOOT_RESPONSE`), never through `CMSG_LOOT`. +/// `CMSG_GAMEOBJ_USE` body: the object's full guid (`Handlers/SpellHandler.cpp`). A chest's loot +/// opens through this, never `CMSG_LOOT`, which rejects a game object guid. pub fn gameobj_use(guid: u64) -> Vec { guid.to_le_bytes().to_vec() } -/// Body of a `CMSG_GAMEOBJECT_QUERY` (opcode `0x005E`/94 — VERIFIED vmangos -/// `Server/Protocol/Opcodes_1_12_1.h`): the template `entry` + the asking guid (VERIFIED vmangos -/// `QueryGameObject::ReadFromWorldPacket` — `u32 entryID` then a full `u64` guid), identical shape to -/// [`super::creature_query`]. +/// `CMSG_GAMEOBJECT_QUERY` body: the template entry, then a full guid (vmangos `QueryGameObject`). pub fn gameobject_query(entry: u32, guid: u64) -> Vec { let mut body = Vec::with_capacity(12); body.extend_from_slice(&entry.to_le_bytes()); @@ -33,12 +19,8 @@ pub fn gameobject_query(entry: u32, guid: u64) -> Vec { body } -/// The head of a GameObject-template answer (`SMSG_GAMEOBJECT_QUERY_RESPONSE`, opcode `0x005F`/95, -/// answering `CMSG_GAMEOBJECT_QUERY`) — decision 0236's ask-once template lookup. `data[24]` is the -/// template's raw `GameObjectData` union tail (VERIFIED vmangos `QueryHandler.cpp` -/// `HandleGameObjectQueryOpcode`: `data << info->raw.data[i]` for `i in 0..24`); its slot layout is -/// **type-specific** (e.g. a chest's lockId lives at a different index than a door's) — this layer -/// parses the array verbatim and leaves resolving a slot to the consumer that knows `type_id`. +/// A game object template from `SMSG_GAMEOBJECT_QUERY_RESPONSE`; `data` is the raw +/// `GameObjectData` union, whose slots mean different things per `type_id`. #[derive(Debug, Clone, PartialEq)] pub struct GameObjectQueryInfo { pub type_id: u32, @@ -47,13 +29,8 @@ pub struct GameObjectQueryInfo { pub data: [i32; 24], } -/// Read `SMSG_GAMEOBJECT_QUERY_RESPONSE` → `(entry, Some(info))`, or `(entry, None)` on a miss. -/// VERIFIED vmangos `QueryHandler.cpp` `HandleGameObjectQueryOpcode`, the -/// `SUPPORTED_CLIENT_BUILD >= CLIENT_BUILD_1_12_1` branch — wire order: `entry, type, displayId, -/// name`, then 3 empty name slots + an `icon` C-string (the 5th name slot), all read past and -/// dropped except `name`, then `data[24]` as 24 little-endian `i32`s. There is **no** trailing float -/// `size` field in 5875 (the `data << float(info->size)` line is commented out server-side). A miss -/// is the lone `u32` of `entry | 0x8000_0000` — the same shape as the creature/item miss. +/// Read `SMSG_GAMEOBJECT_QUERY_RESPONSE` (vmangos `HandleGameObjectQueryOpcode`): no trailing +/// `size` float in 1.12.1, and a miss is the lone `u32` `entry | 0x8000_0000`. pub(super) fn read_gameobject_query_response( r: &mut &[u8], ) -> io::Result<(u32, Option)> { @@ -67,7 +44,7 @@ pub(super) fn read_gameobject_query_response( for _ in 0..3 { let _ = read_cstring(r)?; // name2..name4, always empty in 5875 } - let _icon = read_cstring(r)?; // name5 — the GO's icon key; unused until a consumer needs it + let _icon = read_cstring(r)?; // name5, the icon key let mut data = [0i32; 24]; for slot in &mut data { *slot = read_i32_le(r)?; @@ -83,22 +60,16 @@ pub(super) fn read_gameobject_query_response( )) } -/// Read `SMSG_GAMEOBJECT_CUSTOM_ANIM` → `(guid, anim_id)`. VERIFIED vmangos -/// `GameObject::SendGameObjectCustomAnim`: a full `u64` guid then `u32 animId`. The client-side -/// meaning (wow-re `gameobject-anim-arm.md` §"one-shot channel" step 8): arm the GO's Custom -/// substate `8 + animId` — AnimationData ids 153..156 — with `animId >= 4` rejected; the -/// consumer applies that gate, this layer parses verbatim. +/// Read `SMSG_GAMEOBJECT_CUSTOM_ANIM`: the guid, then `animId`, which the 1.12 client plays as +/// custom substate `8 + animId` (AnimationData 153 to 156), ignoring `animId >= 4`. pub(super) fn read_gameobject_custom_anim(r: &mut &[u8]) -> io::Result<(u64, u32)> { let guid = read_u64_le(r)?; let anim_id = read_u32_le(r)?; Ok((guid, anim_id)) } -/// Read `SMSG_GAMEOBJECT_DESPAWN_ANIM` → `guid`. VERIFIED vmangos -/// `WorldObject::SendObjectDeSpawnAnim` (`Objects/Object.cpp:2307`) → -/// `WorldPackets::Misc::GameObjectDespawnAnim`, whose whole body is the object guid. The -/// client-side meaning (wow-re `gameobject-anim-arm.md` §2c): arm substate 12, AnimationData id -/// **157 Despawn** — the arm channel, like the custom one, is disjoint from the §243 lid family. +/// Read `SMSG_GAMEOBJECT_DESPAWN_ANIM`, only the guid (`Objects/Object.cpp:2307`); the 1.12 +/// client plays substate 12, AnimationData 157 Despawn. pub(super) fn read_gameobject_despawn_anim(r: &mut &[u8]) -> io::Result { read_u64_le(r) } @@ -117,7 +88,7 @@ mod tests { #[test] fn cmsg_gameobj_use_body_golden() { - // A full little-endian guid, no more (SpellHandler.cpp reads a single ObjectGuid). + // SpellHandler.cpp reads a single ObjectGuid. assert_eq!( gameobj_use(0x1234_5678_9abc_def0), hx("f0debc9a78563412"), @@ -127,8 +98,7 @@ mod tests { #[test] fn cmsg_gameobject_query_body_golden() { - // entry u32 + full 8-byte guid — identical shape to CMSG_CREATURE_QUERY (VERIFIED vmangos - // `QueryGameObject::ReadFromWorldPacket`). + // As vmangos `QueryGameObject::ReadFromWorldPacket` reads it. assert_eq!( gameobject_query(1731, 0x1234_5678_9abc_def0), hx("c3060000f0debc9a78563412"), @@ -143,9 +113,9 @@ mod tests { body.extend_from_slice(&123u32.to_le_bytes()); // displayId body.extend_from_slice(b"Chest\0"); // name body.extend_from_slice(&[0, 0, 0]); // name2..name4, empty C-strings - body.extend_from_slice(b"Icon\0"); // icon (name5) — read past and dropped + body.extend_from_slice(b"Icon\0"); // icon (name5), dropped let mut data = [0i32; 24]; - data[0] = 57; // stand-in lockId — the type-specific slot a later consumer resolves + data[0] = 57; // a stand-in lockId for v in data { body.extend_from_slice(&v.to_le_bytes()); } @@ -166,10 +136,6 @@ mod tests { #[test] fn gameobject_despawn_anim_decodes() { - // The whole body is the object guid (VERIFIED vmangos - // `WorldObject::SendObjectDeSpawnAnim` → `WorldPackets::Misc::GameObjectDespawnAnim`, - // whose `AppendBodyTo` writes only `gameObjectGuid`). A UBRS Rookery Egg spending its - // last trap charge is the load-bearing sender (decision 1404). let body = hx("f0debc9a78563412"); match parse_server(opcode::SMSG_GAMEOBJECT_DESPAWN_ANIM, &body).unwrap() { ServerPacket::GameObjectDespawnAnim { guid } => { @@ -181,8 +147,7 @@ mod tests { #[test] fn gameobject_custom_anim_decodes() { - // A full little-endian guid + u32 animId (VERIFIED vmangos - // `GameObject::SendGameObjectCustomAnim`). The bobber's bite is animId 0. + // A fishing bobber's bite is animId 0. let body = hx("f0debc9a7856341200000000"); match parse_server(opcode::SMSG_GAMEOBJECT_CUSTOM_ANIM, &body).unwrap() { ServerPacket::GameObjectCustomAnim { guid, anim_id } => { @@ -208,9 +173,6 @@ mod tests { #[test] fn gameobject_query_response_miss_decodes() { - // A miss is the lone entry echoed with its top bit set (VERIFIED vmangos - // HandleGameObjectQueryOpcode's "GO's info was not found" branch — same shape as the - // creature/item miss). let body = (1731u32 | 0x8000_0000).to_le_bytes(); match parse_server(opcode::SMSG_GAMEOBJECT_QUERY_RESPONSE, &body).unwrap() { ServerPacket::GameObjectQueryResponse { entry, info } => { diff --git a/crates/benilla-protocol/src/messages/gm_ticket.rs b/crates/benilla-protocol/src/messages/gm_ticket.rs index 42e9ba39..730dd6c7 100644 --- a/crates/benilla-protocol/src/messages/gm_ticket.rs +++ b/crates/benilla-protocol/src/messages/gm_ticket.rs @@ -1,115 +1,53 @@ -//! The GM trouble-ticket family — the five request/answer pairs behind the Help window -//! (opcodes 0x205-0x21B; decision 1673). -//! -//! | opcode | direction | body | -//! |---|---|---| -//! | `CMSG_GMTICKET_CREATE` 0x205 | out | `u8` category, `u32` map, `f32` x/y/z, cstring text, cstring reserved | -//! | `SMSG_GMTICKET_CREATE` 0x206 | in | `u32` response | -//! | `CMSG_GMTICKET_UPDATETEXT` 0x207 | out | `u8` category, cstring text | -//! | `SMSG_GMTICKET_UPDATETEXT` 0x208 | in | `u32` response | -//! | `CMSG_GMTICKET_GETTICKET` 0x211 | out | *empty* | -//! | `SMSG_GMTICKET_GETTICKET` 0x212 | in | `u32` status, then the ticket iff status == 6 | -//! | `CMSG_GMTICKET_DELETETICKET` 0x217 | out | *empty* | -//! | `SMSG_GMTICKET_DELETETICKET` 0x218 | in | `u32` response | -//! | `CMSG_GMTICKET_SYSTEMSTATUS` 0x21A | out | *empty* | -//! | `SMSG_GMTICKET_SYSTEMSTATUS` 0x21B | in | `u32` queue status | -//! -//! ## The two outbound bodies are byte-verified against the real client, not just the server -//! -//! Both are places where reading only the emulator would have got it wrong, so both were taken -//! from the 5875 binary's own senders: -//! -//! - **`CMSG_GMTICKET_CREATE`'s category is a `u8`, not a `u32`** (sender `0x5ef740`, reached from -//! the Lua glue `NewGMTicket` `0x48c9d0`): `PutInt8` at `0x5ef851`, then `PutInt32(mapId)`, three -//! `PutFloat`s, `PutCString(text)`, and a second cstring — the literal -//! [`RESERVED_FOR_FUTURE_USE`] at VA `0x860708`. vmangos reads exactly that head -//! (`Server/Packets/GmTicket.cpp:5-17`). -//! - **`CMSG_GMTICKET_UPDATETEXT` carries the category byte too** (sender `0x5efac0`: `PutInt8` at -//! `0x5efb2d`, `PutCString` at `0x5efb3c`). This one is a real fork in the emulator world — -//! vmangos reads `u8 type; cstring text` (`GmTicket.cpp:19-23`) and matches the client, while -//! cmangos-classic reads a bare cstring (`GMTicketHandler.cpp:160-161`) and therefore swallows -//! our category byte as the first character of the ticket. We are faithful to the client; a -//! cmangos server would store a stray control byte at the head of the text. Recorded rather than -//! worked around: the client is the authority, and bending the wire to one emulator's bug would -//! break the other. -//! -//! **The `category == 2` chat-log tail is deliberately not sent.** The real client appends a -//! zlib-compressed transcript of the last (up to 60) chat lines to a Behavior/Harassment ticket -//! (`0x5ef936` onward). Neither emulator uses it: vmangos does not read it at all (and logs a -//! parse-length warning for the leftover bytes), cmangos reads two `u32`s under the wrong names -//! and discards the rest. Sending it would buy nothing and cost a log line per ticket, so -//! [`gm_ticket_create`] stops after the reserved string — see [`crate::messages::gm_ticket`]'s -//! test for the byte-exact shape we do send. -//! -//! ## The inbound ticket reads TEXT BEFORE CATEGORY -//! -//! [`GmTicket`]'s field order is the *wire* order, and it is not the order the Lua `UPDATE_TICKET` -//! event uses (`HelpFrameOpenTicket_OnEvent` reads arg1 = category, arg2 = description). vmangos -//! and cmangos agree byte-for-byte on the wire order — `cstring text` then `u8 category` -//! (vmangos `Server/Packets/GmTicket.cpp:66-79` filled at `GMTicketMgr.cpp:120-151`; cmangos -//! `GMTickets/GMTicketHandler.cpp:35-51`) — so the reorder happens inside the client's own handler -//! and belongs to the app's event feed, not here. Decoding it any other way would desynchronise -//! the whole struct after the first string. +//! The GM ticket family behind the Help window: create, update text, get, delete and queue +//! status, each a request and a `u32`-led answer; the get, delete and status requests are empty. use std::io; use crate::wire::{read_cstring, read_f32_le, read_u32_le, read_u8}; -/// The second cstring of `CMSG_GMTICKET_CREATE`, verbatim from the client's own constant at VA -/// `0x860708`. It is exactly what it says: a slot nothing has ever read. We send the real string -/// rather than an empty one because that is what the client puts on the wire, and a server that -/// ever grows a use for it should see what retail sent. +/// The second cstring of `CMSG_GMTICKET_CREATE`, the 1.12 client's constant at `0x860708`. pub const RESERVED_FOR_FUTURE_USE: &str = "Reserved for future use"; /// `SMSG_GMTICKET_GETTICKET`'s status when a ticket follows (`GMTICKET_STATUS_HASTEXT`). pub const GMTICKET_STATUS_HASTEXT: u32 = 0x06; -/// `SMSG_GMTICKET_GETTICKET`'s status when there is no ticket (`GMTICKET_STATUS_DEFAULT`) — a -/// 4-byte body and nothing after it. **This is the ordinary answer, not an error**: it is what -/// retail answered the client's own post-login ask with in the 1.12.1 sniff, and it is what the -/// Help window's "you have no open ticket" state is drawn from. +/// `SMSG_GMTICKET_GETTICKET`'s status for no ticket (`GMTICKET_STATUS_DEFAULT`): a 4-byte body, +/// and the ordinary answer to the client's post-login ask. pub const GMTICKET_STATUS_DEFAULT: u32 = 0x0A; /// The queue is accepting tickets (`GMTICKET_QUEUE_STATUS_ENABLED`). pub const GMTICKET_QUEUE_ENABLED: i32 = 1; -/// The player's open ticket, as `SMSG_GMTICKET_GETTICKET` describes it. -/// -/// The three `f32`s are **days**, not seconds, and two of them can legitimately be negative: -/// cmangos sends `-1.0` for `oldest_ticket_age`/`update_time` when it has never computed them -/// (`GMTicketMgr.h:318`, `GMTicketMgr.cpp:1340-1343`), and the shipped FrameXML tests exactly that -/// (`arg4 < 0 or arg5 < 0` → "Wait time currently unavailable"). So this is a plain `f32` with no -/// clamping: the negative *is* the signal. +/// The player's open ticket from `SMSG_GMTICKET_GETTICKET`, in wire order: text before category +/// (`GmTicket.cpp:66-79`), which the Lua `UPDATE_TICKET` event reverses. The `f32`s are days, and +/// a negative one means unknown (the stock Help frame then shows no wait time). #[derive(Debug, Clone, PartialEq)] pub struct GmTicket { - /// What the player typed. On vmangos a completed ticket has the GM's answer **appended into - /// this same string** (`GMTicketMgr.cpp:124-136`) — there is no response channel in 1.12, so - /// the reply arrives as part of the description. + /// What the player typed; vmangos appends a GM's answer here, as 1.12 has no reply channel + /// (`GMTicketMgr.cpp:124-136`). pub text: String, /// The `GMTicketCategory.dbc` id the ticket was filed under (1..10). pub category: u8, - /// Days since the ticket was last modified — the FrameXML's `arg3`. + /// Days since the ticket was last modified (FrameXML `arg3`). pub ticket_age: f32, - /// Days since the oldest open ticket on the realm was last modified; `0.0` when there is none, - /// negative when unknown. The FrameXML subtracts `ticket_age` from this to estimate the wait. + /// Days since the realm's oldest open ticket was modified, 0 when none, negative when unknown; + /// the FrameXML's wait estimate is this minus `ticket_age`. pub oldest_ticket_age: f32, - /// How stale the oldest-ticket figure is, in days; negative or `> 0.042` (≈ 1 hour) means the - /// data is no good and the window says so instead of guessing. + /// Age of the oldest-ticket figure in days; negative or over 0.042 (about an hour) is stale. pub update_time: f32, - /// 0 = unassigned, 1 = assigned to a GM, 2 = in the escalation queue. vmangos clamps its - /// internal 3 ("escalated and assigned") down to 2 before sending (`GMTicketMgr.cpp:147`), so - /// nothing above 2 can arrive. + /// 0 unassigned, 1 assigned to a GM, 2 escalated; vmangos clamps its 3 to 2 + /// (`GMTicketMgr.cpp:147`). pub assigned_to_gm: u8, /// 1 once a GM has actually opened the ticket, 0 before that. pub opened_by_gm: u8, } -/// Body of `CMSG_GMTICKET_CREATE` — the Submit that files a new ticket. +/// `CMSG_GMTICKET_CREATE` body as the 1.12 client sends it (`0x5ef740`): a `u8` category, the +/// player's map and position, the text, then [`RESERVED_FOR_FUTURE_USE`]. /// -/// `map`/`x`/`y`/`z` are where the player is standing when they file it; vmangos stores them so a -/// GM can `.ticket go` to the spot. The text is sent as typed: the client's own 1999-char cap -/// (`SStrCopy` bound `0x7d0` at `0x5ef7fd`) sits far above the Help window's `letters="500"` -/// EditBox, so nothing here can truncate a ticket the UI allowed. +/// Deviation: the 1.12 client appends a zlib chat transcript to a category 2 ticket (`0x5ef936`); +/// we omit it because vmangos never reads it and logs the leftover bytes. The client also cuts +/// the text at 1999 chars (`0x5ef7fd`); we do not, as the Help window stops at 500. pub fn gm_ticket_create(category: u8, map: u32, pos: [f32; 3], text: &str) -> Vec { let mut out = Vec::with_capacity(1 + 4 + 12 + text.len() + 1 + RESERVED_FOR_FUTURE_USE.len() + 1); @@ -125,18 +63,8 @@ pub fn gm_ticket_create(category: u8, map: u32, pos: [f32; 3], text: &str) -> Ve out } -/// Body of `CMSG_GMTICKET_UPDATETEXT` — the "Save Changes" that edits an open ticket. -/// -/// Carries the category byte as well as the text (module doc: byte-verified at `0x5efb2d`), and -/// vmangos **stores** it: `HandleGMTicketUpdateTextOpcode` does `SetMessage(packet.ticketText)` -/// then `SetTicketType(TicketType(packet.type))` before saving (`GMTicketHandler.cpp:59-61`). So an -/// edit re-files the ticket under whatever category the byte carries, and getting this field wrong -/// silently moves a GM's ticket to a different queue rather than merely being ignored. -/// -/// benilla's own Help window has no category picker (decision 1687), so it passes back the -/// category the server last reported for the ticket — precisely so an edit does not overwrite a -/// re-filing a GM made. That is the window's discipline, not the server's: the opcode itself will -/// happily move a ticket to whatever byte arrives. +/// `CMSG_GMTICKET_UPDATETEXT` body: the category byte (`0x5efb2d`), then the text. vmangos stores +/// the byte, so an edit re-files the ticket under it (`GMTicketHandler.cpp:59-61`). pub fn gm_ticket_updatetext(category: u8, text: &str) -> Vec { let mut out = Vec::with_capacity(1 + text.len() + 1); out.push(category); @@ -145,36 +73,20 @@ pub fn gm_ticket_updatetext(category: u8, text: &str) -> Vec { out } -/// Read the shared `u32` answer of `SMSG_GMTICKET_CREATE` / `UPDATETEXT` / `DELETETICKET`. -/// -/// One reader for three opcodes because all three bodies are the same 4 bytes — the *meaning* of -/// the number is the opcode's, and that split lives in [`crate::events`]. The value set (vmangos -/// `GMTicketMgr.h:49-54`): 1 already-exists, 2 create-ok, 3 create-error, 4 update-ok, -/// 5 update-error, 9 deleted. +/// Read the `u32` answer shared by create, update text and delete (vmangos `GMTicketMgr.h:49-54`): +/// 1 already exists, 2 created, 3 create failed, 4 updated, 5 update failed, 9 deleted. pub(super) fn read_gm_ticket_response(r: &mut &[u8]) -> io::Result { read_u32_le(r) } -/// Read `SMSG_GMTICKETSYSTEMSTATUS`: 1 = the queue is taking tickets, 0 = it is not. -/// -/// **Signed, and that is byte-verified rather than a preference.** The reference copies the field -/// off the wire verbatim (`0x418e95`, no extension), pushes it unmodified (`0x5e467b`) and hands it -/// to Lua through `0x704fa6 fild dword` — a signed load, with no `cmp`, `test`, clamp or mapping -/// anywhere in between (wow-re §5). `HelpFrame`'s `arg1 == -1` arm — "the queue is down, say so" — -/// is therefore reachable exactly when a server sends `0xFFFFFFFF`, and reading this unsigned would -/// turn that into 4294967295 and silently cost the dialog. vmangos only ever sends 0 or 1, so -/// nothing here would have caught the difference in a live run. +/// Read `SMSG_GMTICKET_SYSTEMSTATUS`, 1 when the queue takes tickets and 0 when not. Signed: the +/// 1.12 client hands it to Lua as a signed dword (`0x704fa6`), and `HelpFrame` branches on -1. pub(super) fn read_gm_ticket_system_status(r: &mut &[u8]) -> io::Result { Ok(read_u32_le(r)? as i32) } -/// Read `SMSG_GMTICKET_GETTICKET`: a status, and the ticket itself only when that status is -/// [`GMTICKET_STATUS_HASTEXT`]. -/// -/// Returns `Ok(None)` for every other status — which in practice is the 4-byte -/// [`GMTICKET_STATUS_DEFAULT`] body meaning "you have no open ticket". Deliberately **not** an -/// error: that answer is the common case, it is what the client's own post-login ask gets, and -/// treating it as a parse failure would put a warn line in the log on every login. +/// Read `SMSG_GMTICKET_GETTICKET`: a status, then the ticket only for +/// [`GMTICKET_STATUS_HASTEXT`]; any other status is no ticket, not an error. pub(super) fn read_gm_ticket(r: &mut &[u8]) -> io::Result> { if read_u32_le(r)? != GMTICKET_STATUS_HASTEXT { return Ok(None); @@ -194,9 +106,7 @@ pub(super) fn read_gm_ticket(r: &mut &[u8]) -> io::Result> { mod tests { use super::*; - /// The create body, byte for byte — the client's own field order and widths (`0x5ef740`), with - /// the category as ONE byte. Getting this wrong by widening the category to `u32` would shift - /// the map id and the whole position by three bytes and file every ticket at a garbage spot. + /// The field order and widths of the 1.12 client's sender (`0x5ef740`). #[test] fn the_create_body_is_a_category_byte_then_map_position_text_and_the_reserved_string() { let body = gm_ticket_create(4, 1, [-8949.95, -132.493, 83.5312], "My sword vanished."); @@ -208,8 +118,6 @@ mod tests { want.extend_from_slice(b"My sword vanished.\0"); want.extend_from_slice(b"Reserved for future use\0"); assert_eq!(body, want); - // The tail the client sends for a Behavior/Harassment ticket is deliberately absent: the - // body ends at the reserved string for every category, including 2. assert_eq!( gm_ticket_create(2, 0, [0.0; 3], "x").len(), 1 + 4 + 12 + 2 + 24, @@ -217,8 +125,7 @@ mod tests { ); } - /// The updatetext body leads with the category byte — the divergence the module doc records. - /// A bare cstring here is what cmangos expects and what the client never sends. + /// As the 1.12 client's sender writes it (`0x5efac0`). #[test] fn the_updatetext_body_leads_with_the_category_byte() { assert_eq!( @@ -227,9 +134,7 @@ mod tests { ); } - /// The ticket round-trips in WIRE order — text first, category second. Pinning it here is the - /// point: the Lua event's order is the reverse, and a "fix" that swapped these two to match - /// the event would desynchronise every field after the string. + /// The Lua `UPDATE_TICKET` event reverses this order; the wire does not. #[test] fn a_held_ticket_decodes_in_wire_order_text_before_category() { let mut bytes = GMTICKET_STATUS_HASTEXT.to_le_bytes().to_vec(); @@ -255,9 +160,7 @@ mod tests { ); } - /// The queue status is read SIGNED: `0xFFFFFFFF` must reach Lua as `-1`, which is the value the - /// shipped window branches on to raise "GM Help Tickets are currently unavailable." Read it - /// unsigned and that arm can never fire. + /// The stock Help frame branches on -1 ("GM Help Tickets are currently unavailable."). #[test] fn the_queue_status_is_signed_so_all_ones_reads_as_minus_one() { assert_eq!( @@ -270,13 +173,12 @@ mod tests { ); } - /// "You have no ticket" is a 4-byte body and an `Ok(None)`, never an error — it is the answer - /// retail gave the client's own post-login ask, so an error here would warn on every login. + /// Retail's answer to the client's own post-login ask. #[test] fn the_no_ticket_answer_is_four_bytes_and_is_not_an_error() { let bytes = GMTICKET_STATUS_DEFAULT.to_le_bytes(); assert_eq!(read_gm_ticket(&mut &bytes[..]).unwrap(), None); - // Any other status is likewise "nothing to show" rather than a parse failure. + // Any other status is also no ticket. let bytes = 0u32.to_le_bytes(); assert_eq!(read_gm_ticket(&mut &bytes[..]).unwrap(), None); } diff --git a/crates/benilla-protocol/src/messages/gossip.rs b/crates/benilla-protocol/src/messages/gossip.rs index aac5d1ca..5d5e93ab 100644 --- a/crates/benilla-protocol/src/messages/gossip.rs +++ b/crates/benilla-protocol/src/messages/gossip.rs @@ -1,19 +1,11 @@ -//! Gossip + NPC-text messages — the "right-click a friendly NPC → dialog" family (opcodes 379-384, -//! vmangos `Opcodes_1_12_1.h`, VERIFIED). Bodies from vmangos `Npc.{h,cpp}` + the hand-serialized -//! `GossipDef.cpp`. Vendor bodies live beside this in [`super::vendor`] — a different wire family -//! (`CMSG_LIST_INVENTORY`/buy/sell) that a gossip option can lead into but doesn't share a shape with. -//! Quest-giver flows (`SMSG_QUESTGIVER_*`) are a separate, out-of-scope arc; the quest-option block -//! riding inside `SMSG_GOSSIP_MESSAGE` is parsed here only to stay byte-aligned. +//! Gossip and NPC text, the dialog a friendly NPC opens (vmangos `Npc.cpp`, `GossipDef.cpp`). use std::io; use crate::wire::{capacity_hint, read_cstring, read_f32_le, read_u32_le, read_u64_le, read_u8}; -/// One gossip menu entry (`SMSG_GOSSIP_MESSAGE`'s option list, 1.12 shape — no box-money field, -/// that's TBC+). `index` is the value the client echoes back as `gossipListId` on select; `icon` is -/// a `GOSSIP_ICON_*` (0 chat bubble, 1 vendor, 2 taxi, 3 trainer, …); `coded` marks a password-gated -/// option (petition signing etc. — v1 sends an empty code only for non-coded options, see -/// [`gossip_select_option`]). +/// One `SMSG_GOSSIP_MESSAGE` option: `index` is echoed as `gossipListId` on select, `icon` is a +/// `GOSSIP_ICON_*` (0 chat, 1 vendor, 2 taxi, 3 trainer, …), `coded` asks for a code. #[derive(Debug, Clone, PartialEq, Eq)] pub struct GossipOption { pub index: u32, @@ -22,9 +14,7 @@ pub struct GossipOption { pub message: String, } -/// One quest-giver entry riding the same packet (`SMSG_GOSSIP_MESSAGE`'s quest-option list). Parsed -/// for byte alignment only — quest-giver flows are out of scope for this arc (gossip/vendor arc -/// brief §8). +/// One quest in `SMSG_GOSSIP_MESSAGE`'s quest list. #[derive(Debug, Clone, PartialEq, Eq)] pub struct QuestOption { pub quest_id: u32, @@ -33,18 +23,14 @@ pub struct QuestOption { pub title: String, } -/// Body of `CMSG_GOSSIP_HELLO` (vmangos `Npc.cpp:3`): one full 8-byte NPC guid. Works on *any* -/// interactable creature, not only gossip-flagged ones — the server's `CanInteractWithNPC` passes -/// `UNIT_NPC_FLAG_NONE` for this opcode (`Player.cpp:347`). +/// `CMSG_GOSSIP_HELLO` body: the NPC's full guid (`Npc.cpp:3`); it works on any interactable +/// creature, gossip flag or not (`Player.cpp:347`). pub fn gossip_hello(npc_guid: u64) -> Vec { npc_guid.to_le_bytes().to_vec() } -/// Body of `CMSG_GOSSIP_SELECT_OPTION` (vmangos `Npc.cpp:78-86`): guid, the `gossipListId` -/// (= the chosen [`GossipOption::index`]), then an **optional** trailing code cstring — appended -/// only for a `coded` option carrying a real code; the server reads it only when the buffer is -/// non-empty, so a non-coded select must omit it entirely rather than send an empty string. -/// Handler dispatch: `NPCHandler.cpp:370`. +/// `CMSG_GOSSIP_SELECT_OPTION` body: guid, the option's `index`, then the code only for a coded +/// option; the server reads a code whenever bytes remain, so omit it otherwise (`Npc.cpp:78-86`). pub fn gossip_select_option(npc_guid: u64, gossip_list_id: u32, code: Option<&str>) -> Vec { let mut body = Vec::with_capacity(12); body.extend_from_slice(&npc_guid.to_le_bytes()); @@ -56,9 +42,7 @@ pub fn gossip_select_option(npc_guid: u64, gossip_list_id: u32, code: Option<&st body } -/// Body of `CMSG_NPC_TEXT_QUERY` (vmangos `Npc.cpp:8-12`): `u32 textID`, `u64 guid`. Sent on -/// receiving a gossip menu to fetch the greeting text for `textId` (ask-once cacheable, like the -/// item template query). +/// `CMSG_NPC_TEXT_QUERY` body: the text id, then the NPC's guid (`Npc.cpp:8-12`). pub fn npc_text_query(text_id: u32, guid: u64) -> Vec { let mut body = Vec::with_capacity(12); body.extend_from_slice(&text_id.to_le_bytes()); @@ -66,10 +50,7 @@ pub fn npc_text_query(text_id: u32, guid: u64) -> Vec { body } -/// Read `SMSG_GOSSIP_MESSAGE` (vmangos `GossipDef.cpp:180-225`, the 1.12 shape — build 5875 predates -/// the TBC box-money field): `u64 objectGuid, u32 textId, u32 optionCount` + options -/// (`u32 index, u8 icon, u8 coded, cstr message`), then `u32 questCount` + quest options -/// (`u32 questId, u32 icon, u32 level, cstr title`). Returns `(npc_guid, text_id, options, quests)`. +/// Read `SMSG_GOSSIP_MESSAGE` (vmangos `GossipDef.cpp:180-225`); 1.12 options have no box money. pub(super) fn read_gossip_message( r: &mut &[u8], ) -> io::Result<(u64, u32, Vec, Vec)> { @@ -87,8 +68,7 @@ pub(super) fn read_gossip_message( }); } let quest_count = read_u32_le(r)?; - // The quest menu is an unbounded vector server-side (`GossipDef.h:184`); the same 32-item - // client display limit bounds the hint. + // Unbounded server-side (`GossipDef.h:184`); the 32-item display limit bounds the hint. let mut quests = Vec::with_capacity(capacity_hint(quest_count, 32)); for _ in 0..quest_count { quests.push(QuestOption { @@ -101,21 +81,18 @@ pub(super) fn read_gossip_message( Ok((npc_guid, text_id, options, quests)) } -/// One of the 8 greeting variants in an `SMSG_NPC_TEXT_UPDATE` record. Both gender columns are kept: -/// which one is read is decided **per NPC**, not per block — see [`select_greeting`]. +/// One of the eight greeting variants of an `SMSG_NPC_TEXT_UPDATE` record, both gender columns. #[derive(Debug, Clone, Default, PartialEq)] pub struct NpcTextBlock { - /// The block's draw weight. Not a rank — the reference sums these and draws (see - /// [`select_greeting`]); vmangos's own fallback record sets all eight to `0.0`. + /// The block's draw weight, not a rank; vmangos's fallback record sets all eight to 0. pub probability: f32, - /// `text0` — shown when the NPC is male, or genderless. + /// `text0`, shown when the NPC is male or genderless. pub male: String, - /// `text1` — shown when the NPC is female. + /// `text1`, shown when the NPC is female. pub female: String, } impl NpcTextBlock { - /// This block's text in the gender column [`select_greeting`] chose for the NPC. fn column(&self, female: bool) -> &str { if female { &self.female @@ -125,21 +102,14 @@ impl NpcTextBlock { } } -/// The number of greeting blocks in an `SMSG_NPC_TEXT_UPDATE` record — always exactly 8. +/// The greeting blocks in every `SMSG_NPC_TEXT_UPDATE` record. pub const NPC_TEXT_BLOCKS: usize = 8; -/// Read `SMSG_NPC_TEXT_UPDATE` (vmangos `GossipDef.cpp:298-369`): `u32 textID` then always exactly 8 -/// blocks of `{f32 probability, cstr text0 (male), cstr text1 (female), u32 languageId, 3x(u32 -/// emoteDelay, u32 emoteId)}`. Returns `(text_id, blocks)` — **all** of them, undecided. +/// Read `SMSG_NPC_TEXT_UPDATE` (vmangos `GossipDef.cpp:298-369`), all eight blocks undecided; the +/// greeting is picked when the frame opens, by [`select_greeting`]. /// -/// This layer used to pick the greeting here (highest probability, with a per-block fall back to the -/// other gender column). Both halves of that were wrong, and neither could be fixed here: the real -/// choice needs the NPC and a die roll, which the wire layer has neither of. It belongs at the -/// moment the frame opens, where the reference does it — [`select_greeting`], called from the app. -/// -/// The language and emote tails are still parsed for alignment and dropped. Language is the input to -/// the reference's garble pass (`0x49b560`), which is inert at `lang == 0`; we do not garble at all, -/// so a non-zero language would read plainly here. No such record is known in 1.12 data. +/// Deviation: the 1.12 client garbles a block in a non-zero language (`0x49b560`); we drop the +/// language because no 1.12 record sets one. pub(super) fn read_npc_text_update(r: &mut &[u8]) -> io::Result<(u32, Vec)> { let text_id = read_u32_le(r)?; let mut blocks = Vec::with_capacity(NPC_TEXT_BLOCKS); @@ -161,36 +131,26 @@ pub(super) fn read_npc_text_update(r: &mut &[u8]) -> io::Result<(u32, Vec io::Result { let flags = read_u32_le(r)?; let x = read_f32_le(r)?; @@ -204,34 +164,10 @@ pub(super) fn read_gossip_poi(r: &mut &[u8]) -> io::Result { }) } -/// Pick the greeting line the way the reference does (`0x4e2010`, wow-re -/// `system/ui/scratch/gossip-npctext-law.md`) — a **weighted random draw**, not the highest weight: -/// -/// ```text -/// sum = Σ probability over blocks whose CHOSEN-COLUMN text is non-empty -/// thr = (2.0 - roll) * sum ∈ (0, sum] for roll ∈ [1.0, 2.0) -/// walk the same blocks in order, acc += probability, take the first where thr <= acc -/// ``` -/// -/// Three details are load-bearing, and each is the opposite of what we assumed: -/// -/// - **The column is chosen once, from the NPC's own gender** (`UNIT_FIELD_BYTES_0` byte 2, tested -/// `== 1` for female — so genderless `2` reads as male), before any block is examined. It is never -/// chosen per block, never from the emptiness of a string, and never from the *player*, whom the -/// reference resolves only as an existence check and never dereferences for data. -/// - **There is no fallback to the other column.** A block whose chosen-column text is empty is -/// excluded from the draw outright; if that empties the record, the reference takes its error path -/// and shows no greeting at all, rather than reading the other gender's line. -/// - **The predicate is `<=`, not `<`** (the emitted jcc is `jne` on `test ah,0x41`, so the equal -/// case selects). That is what makes an all-`0.0` record — vmangos's fallback, and much of the -/// 1.12 table — deterministically select block 0 instead of nothing. -/// -/// `roll` is the reference's uniform float in `[1.0, 2.0)` (it builds one by stuffing PRNG bits into -/// a mantissa: `(rand & 0x7fffff) | 0x3f800000`); passing it in keeps this pure and lets tests pin -/// each branch. Accumulation is `f64` deliberately: the reference's accumulators live on the x87 -/// stack at 53-bit precision and round to `f32` exactly once, at the end. -/// -/// `None` = the record names no greeting for this NPC (the reference's `"Missing gossip text!"`). +/// Pick the greeting as the 1.12 client does (`0x4e2010`): a weighted draw over the blocks with +/// text in the NPC's gender column (female only at 1), never falling back to the other column. +/// `roll` is uniform in `[1.0, 2.0)`, `<=` makes an all-zero record pick block 0, and the sums +/// are `f64` as on the client's x87 stack. `None` is its "Missing gossip text!". pub fn select_greeting(blocks: &[NpcTextBlock], npc_gender: u8, roll: f32) -> Option<&str> { let female = npc_gender == 1; let drawn = || blocks.iter().filter(|b| !b.column(female).is_empty()); diff --git a/crates/benilla-protocol/src/messages/group.rs b/crates/benilla-protocol/src/messages/group.rs index 97d53e5b..73c2d269 100644 --- a/crates/benilla-protocol/src/messages/group.rs +++ b/crates/benilla-protocol/src/messages/group.rs @@ -1,12 +1,6 @@ -//! Group/party messages — invite/accept/decline/kick/leader/disband, the loot method, the roster -//! push, party command feedback, live member stats for the party/raid frame, minimap pings, raid -//! subgroup management, raid-target icons, and ready checks (opcodes 110/111/114-127, 469, -//! 638-640, 654-655, 754, 801-802 — VERIFIED vmangos `Server/Protocol/Opcodes_1_12_1.h`). CMSG -//! bodies from vmangos `Server/Packets/Group.{h,cpp}` (every `ReadFromWorldPacket`); SMSG layouts -//! from the same file's `AppendBodyTo` writers plus the hand-serialized member-stats builder in -//! `Handlers/GroupHandler.cpp` (line citations inline in each item). `ObjectGuid`'s own -//! `operator<>` (`ObjectGuid.cpp:172-183`) is a plain 8-byte `u64` — every guid below is FULL -//! unless explicitly marked PACKED (only [`PartyMemberStatsInfo`]'s subject guid is). +//! Group messages: invites, leadership, loot method, roster, member stats, minimap pings, +//! subgroups, raid target icons, ready checks and lockouts (vmangos `Server/Packets/Group.cpp`). +//! Every guid here is a full `u64` except the party member stats subject, which is packed. use std::io::{self, Read}; @@ -16,14 +10,11 @@ use crate::wire::{ read_u64_le, read_u8, }; -/// The raid-assistant bit in [`GroupMemberEntry::flags`] / `SMSG_GROUP_LIST`'s own-flags byte — -/// bits 0-2 carry the subgroup index (0-7, raid-only; a party has just one), this bit marks a -/// raid assistant (VERIFIED vmangos `Group.cpp:158,166`: `uint8(itr.group | (itr.assistant ? 0x80 -/// : 0))`). +/// The raid-assistant bit of a member's flags byte; bits 0-2 are the subgroup +/// (`Group.cpp:158,166`). pub const GROUP_MEMBER_ASSISTANT: u8 = 0x80; -/// `GroupMemberStatus` (VERIFIED vmangos `Group/Group.h:102-111`) — the bits of -/// [`GroupMemberEntry::status`] (bit `0x20` is reserved/unused — `MEMBER_STATUS_UNK3`, never set). +/// vmangos `GroupMemberStatus` (`Group/Group.h:102-111`); bit `0x20` is never set. pub mod member_status { pub const OFFLINE: u8 = 0x00; pub const ONLINE: u8 = 0x01; @@ -35,16 +26,13 @@ pub mod member_status { pub const DND: u8 = 0x80; } -/// `PartyOperation` (VERIFIED vmangos `Server/WorldSession.h:94-98`) — the `u32` on -/// `SMSG_PARTY_COMMAND_RESULT` naming which command the result answers. +/// vmangos `PartyOperation` (`WorldSession.h:94-98`): which command a party result answers. pub mod party_operation { pub const INVITE: u32 = 0; pub const LEAVE: u32 = 2; } -/// `PartyResult` (VERIFIED vmangos `Server/WorldSession.h:100-111`) — the `u32` verdict on -/// `SMSG_PARTY_COMMAND_RESULT`. `ERR_INTERNAL_BATTLEGROUND` (10, "does not exist client-side" per -/// vmangos's own comment) never reaches the wire and is omitted. +/// vmangos `PartyResult` (`WorldSession.h:100-111`), the verdict of `SMSG_PARTY_COMMAND_RESULT`. pub mod party_result { pub const OK: u32 = 0; pub const BAD_PLAYER_NAME: u32 = 1; @@ -57,38 +45,31 @@ pub mod party_result { pub const IGNORING_YOU: u32 = 8; } -/// One other member row on `SMSG_GROUP_LIST` (VERIFIED vmangos `Server/Packets/Group.h:261-267`, -/// `Group.cpp:161-167`, `Group::SendUpdate`). The recipient's own row never appears in the list — -/// their subgroup/assistant flag rides `SMSG_GROUP_LIST`'s separate `own_flags` byte instead. +/// One `SMSG_GROUP_LIST` member row (`Group.cpp:161-167`); the recipient has no row, their flags +/// ride the list's own-flags byte. #[derive(Debug, Clone, PartialEq, Eq)] pub struct GroupMemberEntry { pub name: String, pub guid: u64, /// [`member_status`] bits (`GetGroupMemberStatus`, `Group.cpp:45-63`). pub status: u8, - /// Subgroup index in bits 0-2; [`GROUP_MEMBER_ASSISTANT`] (`0x80`) set = raid assistant. + /// Subgroup in bits 0-2, plus [`GROUP_MEMBER_ASSISTANT`]. pub flags: u8, } -/// The loot-method tail `SMSG_GROUP_LIST` appends only when the member list is non-empty -/// (VERIFIED `Group.cpp:170-179`). `threshold` is an `ItemQualities` value (2..4 in practice — -/// uncommon and up). +/// The loot tail `SMSG_GROUP_LIST` carries only when it lists members (`Group.cpp:170-179`); +/// `threshold` is an item quality, 2 to 4 in practice. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct GroupLootInfo { - /// `0` free-for-all, `1` round-robin, `2` master, `3` group, `4` need-before-greed (vmangos - /// `LootMethod`, `Group.h`). + /// 0 free-for-all, 1 round-robin, 2 master, 3 group, 4 need before greed (`LootMethod`). pub method: u8, - /// The master looter's guid — `0` unless `method == 2` (master loot). + /// The master looter, 0 unless `method` is 2. pub master: u64, pub threshold: u8, } -/// Read `SMSG_GROUP_LIST` (VERIFIED vmangos `Group.cpp:155-180`, `GroupList::AppendBodyTo`): -/// `u8 groupType` (0 party / 1 raid), `u8 ownGroupAndAssistantFlag`, `u32 memberCount`, -/// `memberCount` × [`GroupMemberEntry`], `u64 leaderGuid`, then — only if `memberCount > 0` — the -/// [`GroupLootInfo`] tail plus a trailing `u8 dungeonDifficulty` (always `0` at 5875, `Group.h:281` -/// — "unused in 1.x"). The "you left the group" shape is the degenerate empty-member-list case: -/// exactly 14 bytes (`0, 0, u32 0, u64 0`), no tail at all. +/// Read `SMSG_GROUP_LIST` (`Group.cpp:155-180`): type (0 party, 1 raid), own flags, members, +/// leader, then the loot tail only with members. "You left the group" is the 14-byte empty list. #[allow(clippy::type_complexity)] pub(super) fn read_group_list( r: &mut &[u8], @@ -123,35 +104,28 @@ pub(super) fn read_group_list( Ok((group_type, own_flags, members, leader, loot)) } -/// Read `SMSG_GROUP_INVITE` (VERIFIED vmangos `Group.cpp:107-110`, -/// `GroupInviteNotification::AppendBodyTo`): one cstring, the inviter's name. +/// Read `SMSG_GROUP_INVITE`: the inviter's name (`Group.cpp:107-110`). pub(super) fn read_group_invite(r: &mut impl Read) -> io::Result { read_cstring(r) } -/// Read `SMSG_GROUP_DECLINE` (VERIFIED vmangos `Group.cpp:112-115`, -/// `GroupDeclineNotification::AppendBodyTo`): one cstring, the declining player's name. +/// Read `SMSG_GROUP_DECLINE`: the decliner's name (`Group.cpp:112-115`). pub(super) fn read_group_decline(r: &mut impl Read) -> io::Result { read_cstring(r) } -/// Read `SMSG_GROUP_SET_LEADER` (VERIFIED vmangos `Group.cpp:150-153`, -/// `GroupSetLeaderNotification::AppendBodyTo`): one cstring, the new leader's name. +/// Read `SMSG_GROUP_SET_LEADER`: the new leader's name (`Group.cpp:150-153`). pub(super) fn read_group_set_leader(r: &mut impl Read) -> io::Result { read_cstring(r) } -/// Read `SMSG_PARTY_COMMAND_RESULT` (VERIFIED vmangos `Group.cpp:100-105`, -/// `PartyCommandResult::AppendBodyTo`): `u32 operation` ([`party_operation`]), `cstring -/// memberName` (may be empty — e.g. the ignoring-you refusal names no one, `Handlers/ -/// GroupHandler.cpp:466`), `u32 result` ([`party_result`]). +/// Read `SMSG_PARTY_COMMAND_RESULT` (`Group.cpp:100-105`): operation, member name, result; the +/// name can be empty, as in the ignoring-you refusal (`GroupHandler.cpp:466`). pub(super) fn read_party_command_result(r: &mut impl Read) -> io::Result<(u32, String, u32)> { Ok((read_u32_le(r)?, read_cstring(r)?, read_u32_le(r)?)) } -/// `GROUP_UPDATE_FLAG_*` (VERIFIED vmangos `Group/Group.h:124-151`) — the bits of the leading `u32` -/// mask on `SMSG_PARTY_MEMBER_STATS`/`_FULL`, selecting which fields follow, each in ascending bit -/// order (see [`read_party_member_stats`]). +/// vmangos `GROUP_UPDATE_FLAG_*` (`Group.h:124-151`): the member stats that follow, in bit order. pub mod party_member_mask { pub const STATUS: u32 = 0x0000_0001; pub const CUR_HP: u32 = 0x0000_0002; @@ -176,13 +150,8 @@ pub mod party_member_mask { pub const PET_AURAS_NEGATIVE: u32 = 0x0010_0000; } -/// One `SMSG_PARTY_MEMBER_STATS`/`_FULL` payload — both opcodes decode through the same field -/// layout (the caller picks `full` off which opcode arrived, see [`super::ServerPacket::name`]'s -/// arm): a packed guid, a `u32` field mask ([`party_member_mask`]), then only the fields whose bit -/// is set — `None` otherwise (VERIFIED vmangos `Handlers/GroupHandler.cpp:590-742`, -/// `BuildPartyMemberStatsPacket`). The plain (delta) opcode's mask names only what *changed*; the -/// `_FULL` opcode (our own [`request_party_member_stats`] ask, or the offline-miss reply) sets -/// every bit the target has data for. +/// One `SMSG_PARTY_MEMBER_STATS` or `_FULL` payload (`GroupHandler.cpp:590-742`): only the masked +/// fields are present. The plain opcode carries what changed; `_FULL` everything the server has. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct PartyMemberStatsInfo { pub status: Option, @@ -193,13 +162,11 @@ pub struct PartyMemberStatsInfo { pub max_power: Option, pub level: Option, pub zone: Option, - /// Raw WoW `(x, y)`, truncated to `i16` on the wire (vmangos casts the live float position, - /// `GroupHandler.cpp:623`). + /// Raw WoW x and y, truncated to `i16` by the server (`GroupHandler.cpp:623`). pub position: Option<(i16, i16)>, - /// Active buff spell ids, ascending bit order — the wire's `u32` position-mask prefix is - /// decoded away (no consumer needs the raw aura slot, only which buffs are up). + /// Active buff spell ids in slot order; the wire's `u32` slot mask is not kept. pub auras: Option>, - /// Active debuff spell ids, same convention as [`Self::auras`] (the wire mask here is `u16`). + /// Active debuff spell ids, likewise, from a `u16` slot mask. pub auras_negative: Option>, pub pet_guid: Option, pub pet_name: Option, @@ -214,12 +181,8 @@ pub struct PartyMemberStatsInfo { } impl PartyMemberStatsInfo { - /// **The `1/1` placeholder** — the record a member *new to the roster* starts life with - /// (VERIFIED wow-re `ui/scratch/party-oor-stats-and-portrait-law.md` §2.2: the GROUP_LIST slot - /// writer `0x4e82d0` zero-fills the record and then stores `1` into `+0x0a`/`+0x0c`/`+0x0e`/ - /// `+0x10` at `0x4e833d`-`0x4e8357`). It is what an out-of-range member you have never seen - /// shows until their first stats packet lands: a *full* bar, not an empty one. `status` carries - /// the roster's own online bit, which is the one field the writer takes from its caller. + /// The record of a member new to the roster: 1/1 health and power, so an unseen member shows + /// full bars until stats arrive (`0x4e833d`), and the roster's online bit. pub fn placeholder(online: bool) -> Self { Self { status: Some(u8::from(online)), @@ -231,53 +194,31 @@ impl PartyMemberStatsInfo { } } - /// **The live-descriptor snapshot** — `0x5f0880`, the record's third writer beside the wire and - /// the slot writer (VERIFIED wow-re `object-layer/scratch/party-record-live-snapshot.md` §1). - /// The reference runs it at the instant a party/raid member's object leaves the object manager - /// (`SMSG_DESTROY_OBJECT` and the `OUT_OF_RANGE` block take the same virtual), immediately - /// before asking the server for the member's stats — which is why an out-of-range party frame - /// never reads `0/0` at the despawn edge. + /// Snapshot a member's live descriptor as the 1.12 client does (`0x5f0880`) when the member's + /// object leaves view, just before the stats request, so the frame never reads 0/0. Values are + /// raw; [`Self::shown_power`] divides at the read. AFK and DND are cleared, as in the client. /// - /// Fields, in the binary's own order: `status` (bit0 online — an object you can see belongs to - /// an online player, `0x5f088e c6 46 08 01`; bit3 ghost; bit1 PVP; bit4 FFA-PVP; bit2 dead), - /// `+0x0a` cur HP, `+0x0c` max HP, `+0x09` power type, `+0x0e`/`+0x10` the power pair, - /// `+0x12` level. **Raw** descriptor values, not the `Unit*` getters' display ones: the - /// reference copies the field words (`5f08ea 66 8b 51 40`), and the raw→display divide happens - /// at the *read*, in [`Self::shown_power`] — the same place the live path does it. - /// - /// **Three stated deviations** (decision 1640), each a field with no reader in benilla today: - /// - `zone` and `position` are **left as they were** rather than overwritten. The reference - /// writes the *viewer's* zone (`[0xb4e314]`) and the object's world position; ours keeps the - /// last wire-reported pair, which the `_FULL` answer to the request corrects within a round - /// trip either way. Nothing draws party dots on the minimap yet — that is the consumer whose - /// arrival makes the position worth plumbing. - /// - the 48-slot **aura** block and the **pet** block are not snapshotted: benilla feeds no - /// party-token aura list and resolves no `partypetN` token, so both would be write-only. - /// - AFK/DND (`0x40`/`0x80`) are dropped from `status`, exactly as the reference's byte is - /// rewritten whole — those two bits live on the roster entry, which is what the party frame - /// actually overlays. + /// Deviation: zone and position are kept, not set to the viewer's zone and the object's + /// position, and the aura and pet blocks are not copied, because nothing in benilla reads them. pub fn snapshot_descriptor(&mut self, fields: &crate::messages::update_object::ObjectFields) { let mut status = member_status::ONLINE; if fields.player_is_ghost() { status |= member_status::GHOST; } - // `UNIT_FIELD_FLAGS` PvP bit — the same word/bit the tooltip's PvP line reads. + // `UNIT_FIELD_FLAGS` PvP bit. if fields.unit_flags() & 0x1000 != 0 { status |= member_status::PVP; } - // `PLAYER_FLAGS` bit 7 (`0x5f08be c1 ea 07`). + // `PLAYER_FLAGS` bit 7 (`0x5f08be`). if fields.player_flags() & 0x80 != 0 { status |= member_status::PVP_FFA; } - // `[desc+0x40] <= 0` — the raw health, not `unit_reads_dead`: the snapshot has no - // dyn-flag leg, so a feigning member's record says alive (`5f08d3 85 c9; 7f 04`). + // Raw health, not `unit_reads_dead`: a feigning member reads alive (`0x5f08d3`). if fields.unit_health().unwrap_or(0) == 0 { status |= member_status::DEAD; } self.status = Some(status); - // The record's fields are `u16` (`+0x0a`..`+0x12`), and so is the wire's — the reference - // takes the low word of each dword (`66 8b 51 40`), which is a truncation no 1.12 - // character can reach. + // The record holds `u16`s; the 1.12 client keeps each dword's low word (`0x5f08ea`). let power_type = fields.unit_power_type(); self.cur_hp = Some(fields.unit_health().unwrap_or(0) as u16); self.max_hp = Some(fields.unit_max_health().unwrap_or(0) as u16); @@ -287,31 +228,26 @@ impl PartyMemberStatsInfo { self.level = Some(fields.unit_level().unwrap_or(0) as u16); } - /// `UnitPowerType` on the record path — `+0x09`, `0` when the record has never carried one - /// (the binding's own miss value). + /// `UnitPowerType` from the record, 0 when it has none (the binding's miss value). pub fn shown_power_type(&self) -> u8 { self.power_type.unwrap_or(0) } - /// `UnitMana` on the record path (`0x517744`-`0x51775e`) — the stored power divided by - /// [`power_display_scale`], exactly as the live-object leg divides. Without it an out-of-range - /// warrior's rage bar reads ten times an in-range one's. Miss ⇒ `0`, the binding's own. + /// `UnitMana` from the record (`0x517744`): the stored power divided by + /// [`power_display_scale`], as for a live unit. pub fn shown_power(&self) -> u32 { u32::from(self.cur_power.unwrap_or(0)) / power_display_scale(u32::from(self.shown_power_type())) } - /// `UnitManaMax` on the record path (`0x5178af`), the same divide. + /// `UnitManaMax` from the record (`0x5178af`), the same divide. pub fn shown_max_power(&self) -> u32 { u32::from(self.max_power.unwrap_or(0)) / power_display_scale(u32::from(self.shown_power_type())) } } -/// Read the aura tail shared by [`party_member_mask::AURAS`]/`PET_AURAS` (a `u32` bit mask) and -/// `AURAS_NEGATIVE`/`PET_AURAS_NEGATIVE` (a `u16` bit mask, passed widened): one `u16` spell id per -/// set bit, ascending bit order (VERIFIED `GroupHandler.cpp:624-630` positive, `634-644` -/// negative/pet-negative shape). +/// One `u16` spell id per set bit of `mask`, in bit order (`GroupHandler.cpp:624-644`). fn read_aura_spells(r: &mut impl Read, mask: u32, bits: u32) -> io::Result> { let mut spells = Vec::new(); for bit in 0..bits { @@ -322,9 +258,7 @@ fn read_aura_spells(r: &mut impl Read, mask: u32, bits: u32) -> io::ResultGetPackGUID()` — both build-5875 branches, `SUPPORTED_CLIENT_BUILD > 1_8_4`). +/// Read `SMSG_PARTY_MEMBER_STATS` or `_FULL`; the guid is packed (`GroupHandler.cpp:768`). pub(super) fn read_party_member_stats( r: &mut impl Read, ) -> io::Result<(u64, PartyMemberStatsInfo)> { @@ -404,25 +338,20 @@ pub(super) fn read_party_member_stats( Ok((guid, info)) } -/// Read `MSG_MINIMAP_PING`, the inbound (rebroadcast) shape (VERIFIED vmangos -/// `Handlers/GroupHandler.cpp:382-391`, `HandleMinimapPingOpcode`): full 8-byte `u64 guid` (the -/// server stamps the pinger's own guid on), `f32 x`, `f32 y`. +/// Read the relayed `MSG_MINIMAP_PING`: the pinger's guid, which the server adds, then x and y +/// (`GroupHandler.cpp:382-391`). pub(super) fn read_minimap_ping(r: &mut impl Read) -> io::Result<(u64, f32, f32)> { Ok((read_u64_le(r)?, read_f32_le(r)?, read_f32_le(r)?)) } -/// One decoded `MSG_RAID_TARGET_UPDATE` server body (VERIFIED vmangos `Server/Packets/ -/// Group.cpp:132-147`, `RaidTargetUpdateDelta`/`RaidTargetUpdateAll::AppendBodyTo`): a leading `u8` -/// mode byte picks the shape — `0` one changed icon, `1` the whole current icon set (only -/// currently-set icons ride the list; none marked sends an empty list, not a fixed 8-slot array). -/// Both shapes carry FULL 8-byte guids, not packed. +/// A server `MSG_RAID_TARGET_UPDATE` (`Group.cpp:132-147`): mode 0 is one changed icon, mode 1 +/// every set icon, an empty list when none is. #[derive(Debug, Clone, PartialEq, Eq)] pub enum RaidTargetUpdate { Delta { icon: u8, guid: u64 }, List(Vec<(u8, u64)>), } -/// Read one `MSG_RAID_TARGET_UPDATE` server body (see [`RaidTargetUpdate`]). pub(super) fn read_raid_target_update(r: &mut &[u8]) -> io::Result { let mode = read_u8(r)?; if mode == 0 { @@ -439,17 +368,14 @@ pub(super) fn read_raid_target_update(r: &mut &[u8]) -> io::Result io::Result { if r.is_empty() { return Ok(ReadyCheck::Started); @@ -460,8 +386,7 @@ pub(super) fn read_ready_check(r: &mut &[u8]) -> io::Result { }) } -/// Body of `CMSG_GROUP_INVITE` (VERIFIED vmangos `Group.cpp:4-7`, -/// `GroupInvite::ReadFromWorldPacket`): one cstring, the invited player's name. +/// `CMSG_GROUP_INVITE` body: the invitee's name (`Group.cpp:4-7`). pub fn group_invite(member_name: &str) -> Vec { let mut body = Vec::with_capacity(member_name.len() + 1); body.extend_from_slice(member_name.as_bytes()); @@ -469,20 +394,17 @@ pub fn group_invite(member_name: &str) -> Vec { body } -/// Body of `CMSG_GROUP_ACCEPT` (VERIFIED vmangos `Server/Protocol/Opcodes.cpp` — -/// `NullClientPacket`): empty. +/// `CMSG_GROUP_ACCEPT` body: empty (vmangos `NullClientPacket`). pub fn group_accept() -> Vec { Vec::new() } -/// Body of `CMSG_GROUP_DECLINE` (VERIFIED vmangos `Server/Protocol/Opcodes.cpp` — -/// `NullClientPacket`): empty. The inviter sees `SMSG_GROUP_DECLINE`. +/// `CMSG_GROUP_DECLINE` body: empty; the inviter gets `SMSG_GROUP_DECLINE`. pub fn group_decline() -> Vec { Vec::new() } -/// Body of `CMSG_GROUP_UNINVITE` (VERIFIED vmangos `Group.cpp:9-12`, -/// `GroupUninvite::ReadFromWorldPacket`): one cstring, the kicked player's name. +/// `CMSG_GROUP_UNINVITE` body: the kicked player's name (`Group.cpp:9-12`). pub fn group_uninvite(member_name: &str) -> Vec { let mut body = Vec::with_capacity(member_name.len() + 1); body.extend_from_slice(member_name.as_bytes()); @@ -490,23 +412,18 @@ pub fn group_uninvite(member_name: &str) -> Vec { body } -/// Body of `CMSG_GROUP_UNINVITE_GUID` (VERIFIED vmangos `Group.cpp:14-17`, -/// `GroupUninviteGuid::ReadFromWorldPacket`): one full 8-byte guid — the raid-frame right-click kick. +/// `CMSG_GROUP_UNINVITE_GUID` body, the raid frame's kick: a full guid (`Group.cpp:14-17`). pub fn group_uninvite_guid(guid: u64) -> Vec { guid.to_le_bytes().to_vec() } -/// Body of `CMSG_GROUP_SET_LEADER` (VERIFIED vmangos `Group.h:102-113` + `Group.cpp:57-64`, -/// `GroupSetLeader::ReadFromWorldPacket`, the `SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_11_2` branch -/// active for 5875): one full 8-byte guid — the 1.12 wire dropped the older name-based form. +/// `CMSG_GROUP_SET_LEADER` body: a full guid (`Group.cpp:57-64`, vmangos's post-1.11.2 branch). pub fn group_set_leader(guid: u64) -> Vec { guid.to_le_bytes().to_vec() } -/// Body of `CMSG_LOOT_METHOD` (VERIFIED vmangos `Group.h:51-60` + `Group.cpp:26-31`, -/// `LootMethod::ReadFromWorldPacket`): `u32 method` (`0` free-for-all, `1` round-robin, `2` -/// master, `3` group, `4` need-before-greed), full 8-byte `u64 loot_master` guid (ignored unless -/// `method == 2`), `u32 threshold` (an `ItemQualities` value, 2..4 in practice). +/// `CMSG_LOOT_METHOD` body (`Group.cpp:26-31`): `u32` method, the master looter's full guid +/// (ignored unless the method is 2), `u32` quality threshold. pub fn loot_method(method: u32, loot_master: u64, threshold: u32) -> Vec { let mut body = Vec::with_capacity(16); body.extend_from_slice(&method.to_le_bytes()); @@ -515,28 +432,23 @@ pub fn loot_method(method: u32, loot_master: u64, threshold: u32) -> Vec { body } -/// Body of `CMSG_GROUP_DISBAND` (VERIFIED vmangos `Server/Protocol/Opcodes.cpp` — -/// `NullClientPacket`): empty. +/// `CMSG_GROUP_DISBAND` body: empty. pub fn group_disband() -> Vec { Vec::new() } -/// Body of `CMSG_GROUP_RAID_CONVERT` (VERIFIED vmangos `Server/Protocol/Opcodes.cpp` — -/// `NullClientPacket`): empty — converts the sender's party into a raid. +/// `CMSG_GROUP_RAID_CONVERT` body: empty; turns the sender's party into a raid. pub fn group_raid_convert() -> Vec { Vec::new() } -/// Body of `CMSG_REQUEST_PARTY_MEMBER_STATS` (VERIFIED vmangos `Group.h:41-49` + `Group.cpp:20-23`, -/// `RequestPartyMemberStats::ReadFromWorldPacket`): one full 8-byte guid — the target whose stats -/// we're asking for. Answered by `SMSG_PARTY_MEMBER_STATS_FULL` (or its offline-miss shape). +/// `CMSG_REQUEST_PARTY_MEMBER_STATS` body: the member's full guid (`Group.cpp:20-23`); answered +/// by `SMSG_PARTY_MEMBER_STATS_FULL`. pub fn request_party_member_stats(guid: u64) -> Vec { guid.to_le_bytes().to_vec() } -/// Body of `CMSG_GROUP_CHANGE_SUB_GROUP` (VERIFIED vmangos `Group.cpp:45-49`, -/// `GroupChangeSubGroup::ReadFromWorldPacket`): cstring member name + `u8` destination subgroup -/// index — raid-only (a party has just one subgroup). +/// `CMSG_GROUP_CHANGE_SUB_GROUP` body: a member's name, then the subgroup (`Group.cpp:45-49`). pub fn group_change_sub_group(name: &str, group_nr: u8) -> Vec { let mut body = Vec::with_capacity(name.len() + 2); body.extend_from_slice(name.as_bytes()); @@ -545,9 +457,7 @@ pub fn group_change_sub_group(name: &str, group_nr: u8) -> Vec { body } -/// Body of `CMSG_GROUP_SWAP_SUB_GROUP` (VERIFIED vmangos `Group.cpp:51-55`, -/// `GroupSwapSubGroup::ReadFromWorldPacket`): two cstrings — the member to move, then the member -/// whose subgroup slot it swaps into. +/// `CMSG_GROUP_SWAP_SUB_GROUP` body: the two members' names, mover first (`Group.cpp:51-55`). pub fn group_swap_sub_group(name: &str, swap_with: &str) -> Vec { let mut body = Vec::with_capacity(name.len() + swap_with.len() + 2); body.extend_from_slice(name.as_bytes()); @@ -557,9 +467,8 @@ pub fn group_swap_sub_group(name: &str, swap_with: &str) -> Vec { body } -/// Body of `CMSG_GROUP_ASSISTANT_LEADER` (VERIFIED vmangos `Group.h:115-127` + `Group.cpp:66-74`, -/// `GroupAssistantLeader::ReadFromWorldPacket`, the 1.12 guid branch): full 8-byte guid + `u8 flag` -/// (`1` grant raid-assistant, `0` revoke). +/// `CMSG_GROUP_ASSISTANT_LEADER` body: a full guid, then 1 to grant raid assistant or 0 to revoke +/// it (`Group.cpp:66-74`). pub fn group_assistant_leader(guid: u64, grant: bool) -> Vec { let mut body = Vec::with_capacity(9); body.extend_from_slice(&guid.to_le_bytes()); @@ -567,9 +476,7 @@ pub fn group_assistant_leader(guid: u64, grant: bool) -> Vec { body } -/// Body of `MSG_MINIMAP_PING`, our own ping (VERIFIED vmangos `Group.cpp:33-37`, -/// `MinimapPing::ReadFromWorldPacket`): `f32 x, f32 y` — no guid (the server stamps ours on before -/// relaying, `Handlers/GroupHandler.cpp:382-391`). +/// Our `MSG_MINIMAP_PING` body: x and y, no guid (`Group.cpp:33-37`). pub fn minimap_ping(x: f32, y: f32) -> Vec { let mut body = Vec::with_capacity(8); body.extend_from_slice(&x.to_le_bytes()); @@ -577,10 +484,8 @@ pub fn minimap_ping(x: f32, y: f32) -> Vec { body } -/// Body of `MSG_RAID_TARGET_UPDATE`, setting/clearing one icon (VERIFIED vmangos -/// `Group.cpp:77-82`, `RaidTargetUpdate::ReadFromWorldPacket`): `u8 icon` (0..7) + full 8-byte -/// `u64 guid` — `guid == 0` clears that icon. The client never sends the server's mode byte -/// (compare [`RaidTargetUpdate`]); [`raid_target_request`] disambiguates purely by icon value. +/// `MSG_RAID_TARGET_UPDATE` body: icon 0 to 7, then a full guid, 0 to clear; the client sends no +/// mode byte (`Group.cpp:77-82`). pub fn raid_target_set(icon: u8, guid: u64) -> Vec { let mut body = Vec::with_capacity(9); body.push(icon); @@ -588,53 +493,36 @@ pub fn raid_target_set(icon: u8, guid: u64) -> Vec { body } -/// Body of `MSG_RAID_TARGET_UPDATE`, asking the current icon set (VERIFIED vmangos -/// `Group.cpp:80-81`: `iconId != 0xFF` gates the trailing guid read): the single byte `0xFF` — no -/// guid follows. Answered by the server's mode-1 full list ([`RaidTargetUpdate::List`]). +/// `MSG_RAID_TARGET_UPDATE` body asking for every icon: `0xFF` alone, no guid (`Group.cpp:80-81`). pub fn raid_target_request() -> Vec { vec![0xFF] } -/// Body of `MSG_RAID_READY_CHECK`, starting a check (VERIFIED vmangos -/// `RaidReadyCheckFromClient::ReadFromWorldPacket`, `Group.cpp:84-92`, the `state` unset case): -/// empty — the leader-only trigger; the server broadcasts the request to the rest of the raid. +/// `MSG_RAID_READY_CHECK` body starting a check: empty (`Group.cpp:84-92`). pub fn ready_check_start() -> Vec { Vec::new() } -/// Body of `MSG_RAID_READY_CHECK`, answering one (VERIFIED vmangos `Group.cpp:84-92`, the `state` -/// set case): one `u8`, `1` ready / `0` not — no guid; the server already knows the sender from -/// the session and forwards it to the raid leader alone. +/// `MSG_RAID_READY_CHECK` body answering one: 1 ready or 0 not, no guid (`Group.cpp:84-92`). pub fn ready_check_answer(ready: bool) -> Vec { vec![u8::from(ready)] } -/// One row of `SMSG_RAID_INSTANCE_INFO` — a raid lockout the character is bound to (VERIFIED -/// vmangos `Objects/Player.cpp::Player::SendRaidInfo`: the writer walks `m_boundInstances` and -/// emits a row for every **permanent** bind, so a heroic-style temporary bind never appears). -/// -/// `reset` is a REMAINING duration in seconds (`resetTime - time(nullptr)` on the server), not an -/// absolute timestamp — the Lua binding hands it straight to `SecondsToTime`, and reading it as a -/// timestamp would print "Resets in 57 years". +/// One `SMSG_RAID_INSTANCE_INFO` row, a permanent raid lockout (vmangos `Player::SendRaidInfo`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RaidInstanceEntry { - /// `Map.dbc` id — the *name* the UI shows is the client's own DBC lookup, never on the wire. + /// `Map.dbc` id; the name shown is the client's own lookup. pub map: u32, /// Seconds until the lockout resets. pub reset: u32, - /// The instance id, which is what the UI shows beside the name (`GetSavedInstanceInfo`'s - /// second return). + /// The instance id, `GetSavedInstanceInfo`'s second return. pub instance: u32, } -/// Read an `SMSG_RAID_INSTANCE_INFO` body (see [`RaidInstanceEntry`]): `u32 count` then that many -/// 12-byte rows. The count is authoritative — the server writes it back over its placeholder — so -/// a short body is a malformed packet rather than a silent truncation. +/// Read `SMSG_RAID_INSTANCE_INFO`; the count is authoritative, so a short body is an error. pub(super) fn read_raid_instance_info(r: &mut &[u8]) -> io::Result> { let count = read_u32_le(r)?; - // A cap before the allocation: `count` is attacker-controlled in the general case, and the - // real client's own list is `MAX_RAID_INFOS`-bounded at the UI. 1024 is far above anything a - // server can legitimately send and far below a memory problem. + // 1024 caps the preallocation far above any real lockout list. let mut out = Vec::with_capacity(capacity_hint(count, 1024)); for _ in 0..count { out.push(RaidInstanceEntry { @@ -646,8 +534,7 @@ pub(super) fn read_raid_instance_info(r: &mut &[u8]) -> io::Result Vec { Vec::new() }