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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<String>,
|
||||
/// The channel name, on `CHAT_MSG_CHANNEL` only.
|
||||
pub channel: Option<String>,
|
||||
pub text: String,
|
||||
/// The trailing tag byte ([`chat_tag`]) — 0 none, 1 AFK, 2 DND, 3 GM. Drives the
|
||||
/// `<AFK>`/`<DND>`/`<GM>` prefix the real chat frame prepends to the sender's name.
|
||||
/// The [`chat_tag`] byte, shown as the `<AFK>`/`<DND>`/`<GM>` 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 = <the whole text>`, `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<ChatMessage> {
|
||||
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<ChatMessage> {
|
||||
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<ChatMessage> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
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<String> {
|
||||
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)?;
|
||||
|
||||
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
body
|
||||
}
|
||||
|
||||
/// Body of a movement message (`MSG_MOVE_*`) — a `MovementInfo`.
|
||||
/// Body of a `MSG_MOVE_*` message: a `MovementInfo`.
|
||||
pub fn movement(info: &MovementInfo) -> Vec<u8> {
|
||||
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<uint64>`, `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<u8> {
|
||||
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<u8> {
|
||||
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<float>()`, 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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<bool>) -> Vec<u8> {
|
||||
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<u8> {
|
||||
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<float>()`); 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()],
|
||||
|
||||
@@ -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<SpellDamageLog> {
|
||||
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<SpellDamage
|
||||
})
|
||||
}
|
||||
|
||||
/// One tick of `SMSG_PERIODICAURALOG` — the payload shape depends on the tick's `AuraType` (vmangos
|
||||
/// `SpellAuraDefines.h`): `PERIODIC_DAMAGE` (3) / `PERIODIC_DAMAGE_PERCENT` (89) carry a damage
|
||||
/// breakdown; `PERIODIC_HEAL` (8) / `OBS_MOD_HEALTH` (20) a plain heal amount; `OBS_MOD_MANA` (21) /
|
||||
/// `PERIODIC_ENERGIZE` (24) a power+amount pair; `PERIODIC_MANA_LEECH` (64) a power+amount+multiplier
|
||||
/// triple.
|
||||
/// One `SMSG_PERIODICAURALOG` tick, shaped by its aura type (vmangos `SpellAuraDefines.h`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum PeriodicTick {
|
||||
Damage {
|
||||
@@ -92,9 +73,7 @@ pub enum PeriodicTick {
|
||||
},
|
||||
}
|
||||
|
||||
/// One decoded `SMSG_PERIODICAURALOG` — periodic (DoT/HoT/regen) aura ticks (vmangos
|
||||
/// `Unit::SendPeriodicAuraLog`, `Unit.cpp:4395-4443`). vmangos always writes `count == 1`; the loop
|
||||
/// is decoded faithfully regardless.
|
||||
/// `SMSG_PERIODICAURALOG`, periodic aura ticks (vmangos `Unit.cpp:4395-4443`).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PeriodicAuraLog {
|
||||
pub target: u64,
|
||||
@@ -111,9 +90,7 @@ const AURA_PERIODIC_ENERGIZE: u32 = 24;
|
||||
const AURA_PERIODIC_MANA_LEECH: u32 = 64;
|
||||
const AURA_PERIODIC_DAMAGE_PERCENT: u32 = 89;
|
||||
|
||||
/// Read `SMSG_PERIODICAURALOG`: target PackedGuid · caster PackedGuid · spellId u32 · count u32 ·
|
||||
/// `count` entries of `{auraType u32, payload}` — see [`PeriodicTick`] for the payload shapes. An
|
||||
/// aura type outside that set cannot be skipped without desyncing the stream, so it errors instead.
|
||||
/// Read `SMSG_PERIODICAURALOG`; an unknown aura type errors, as its payload width is unknown.
|
||||
pub(super) fn read_periodic_aura_log(r: &mut impl Read) -> io::Result<PeriodicAuraLog> {
|
||||
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<PeriodicAu
|
||||
})
|
||||
}
|
||||
|
||||
/// One decoded `SMSG_SPELLHEALLOG` — a direct heal landing (vmangos
|
||||
/// `WorldPackets::Spell::SpellHealLog::AppendBodyTo`, `Server/Packets/Spell.cpp:105-112` +
|
||||
/// `Spell.h:151-163`) — the center combat text's HEAL/HEAL_CRIT feed (decision 0578).
|
||||
/// `SMSG_SPELLHEALLOG`, a direct heal landing (`Server/Packets/Spell.cpp:105-112`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SpellHealLog {
|
||||
pub target: u64,
|
||||
@@ -171,8 +146,6 @@ pub struct SpellHealLog {
|
||||
pub critical: bool,
|
||||
}
|
||||
|
||||
/// Read `SMSG_SPELLHEALLOG`: target PackedGuid · healer PackedGuid · spellId u32 · amount u32 ·
|
||||
/// critical u8 (bool).
|
||||
pub(super) fn read_spell_heal_log(r: &mut impl Read) -> io::Result<SpellHealLog> {
|
||||
Ok(SpellHealLog {
|
||||
target: read_packed_guid(r)?,
|
||||
@@ -183,10 +156,8 @@ pub(super) fn read_spell_heal_log(r: &mut impl Read) -> io::Result<SpellHealLog>
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<SpellEnergizeLog> {
|
||||
Ok(SpellEnergizeLog {
|
||||
target: read_packed_guid(r)?,
|
||||
@@ -208,10 +177,8 @@ pub(super) fn read_spell_energize_log(r: &mut impl Read) -> io::Result<SpellEner
|
||||
})
|
||||
}
|
||||
|
||||
/// One decoded `SMSG_SPELLDAMAGESHIELD` — a damage-shield (Thorns-style) return hit (vmangos
|
||||
/// `WorldPackets::Combat::SpellDamageShield::AppendBodyTo`, `Server/Packets/Combat.cpp:73-79` +
|
||||
/// `Combat.h:124-134`). `victim` is the shield's bearer; `attacker` is the unit that struck them and
|
||||
/// now **receives** this damage back.
|
||||
/// `SMSG_SPELLDAMAGESHIELD`, a Thorns-style return hit (`Server/Packets/Combat.cpp:73-79`):
|
||||
/// `victim` bears the shield, `attacker` struck it and takes this damage.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DamageShield {
|
||||
pub victim: u64,
|
||||
@@ -220,8 +187,6 @@ pub struct DamageShield {
|
||||
pub school: u32,
|
||||
}
|
||||
|
||||
/// Read `SMSG_SPELLDAMAGESHIELD`: victim raw `u64` guid · attacker raw `u64` guid · damage u32 ·
|
||||
/// school u32.
|
||||
pub(super) fn read_damage_shield(r: &mut impl Read) -> io::Result<DamageShield> {
|
||||
Ok(DamageShield {
|
||||
victim: read_u64_le(r)?,
|
||||
@@ -231,13 +196,9 @@ pub(super) fn read_damage_shield(r: &mut impl Read) -> io::Result<DamageShield>
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<EnvironmentalDamageLog> {
|
||||
@@ -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<SpellLogMiss> {
|
||||
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<SpellLogMiss>
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<PartyKillLog> {
|
||||
Ok(PartyKillLog {
|
||||
killer: read_u64_le(r)?,
|
||||
@@ -322,15 +269,13 @@ pub(super) fn read_party_kill_log(r: &mut impl Read) -> io::Result<PartyKillLog>
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<SpellInstaKillLog> {
|
||||
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<SpellIn
|
||||
})
|
||||
}
|
||||
|
||||
/// One decoded `SMSG_PROCRESIST` **or** `SMSG_SPELLORDAMAGE_IMMUNE` — the two share a body byte for
|
||||
/// byte (vmangos `WorldPackets::Spell::ProcResist` / `SpellOrDamageImmune`,
|
||||
/// `Server/Packets/Spell.cpp:88-102`) and differ only in which sentence they word.
|
||||
///
|
||||
/// `log_format` is vmangos's `logFormat` (`0` default, `1` debug); the reference reads it as the
|
||||
/// "is periodic" flag that decides whether `CombatLogPeriodicSpells` gates the line.
|
||||
/// `SMSG_PROCRESIST` or `SMSG_SPELLORDAMAGE_IMMUNE`, which share one body
|
||||
/// (`Server/Packets/Spell.cpp:88-102`). The 1.12 client reads `log_format` as the "is periodic"
|
||||
/// flag that lets `CombatLogPeriodicSpells` gate the line.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SpellOutcomeLog {
|
||||
pub caster: u64,
|
||||
@@ -352,8 +294,6 @@ pub struct SpellOutcomeLog {
|
||||
pub log_format: u8,
|
||||
}
|
||||
|
||||
/// Read `SMSG_PROCRESIST` / `SMSG_SPELLORDAMAGE_IMMUNE`: caster raw `u64` · target raw `u64` ·
|
||||
/// spellId u32 · logFormat u8.
|
||||
pub(super) fn read_spell_outcome_log(r: &mut impl Read) -> io::Result<SpellOutcomeLog> {
|
||||
Ok(SpellOutcomeLog {
|
||||
caster: read_u64_le(r)?,
|
||||
@@ -363,19 +303,16 @@ pub(super) fn read_spell_outcome_log(r: &mut impl Read) -> io::Result<SpellOutco
|
||||
})
|
||||
}
|
||||
|
||||
/// One decoded `SMSG_SPELLDISPELLOG` — auras a dispel actually removed (vmangos
|
||||
/// `Spell::EffectDispel`, `Spells/SpellEffects.cpp:2524-2539`; the guid pair is **packed** on the
|
||||
/// `>= 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<u32>,
|
||||
}
|
||||
|
||||
/// Read `SMSG_SPELLDISPELLOG`: victim PackedGuid · caster PackedGuid · count u32 · `count` × u32.
|
||||
pub(super) fn read_spell_dispel_log(r: &mut impl Read) -> io::Result<SpellDispelLog> {
|
||||
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<SpellDispel
|
||||
})
|
||||
}
|
||||
|
||||
/// One decoded `SMSG_DISPEL_FAILED` — auras a dispel tried and failed to remove (vmangos
|
||||
/// `Spell::EffectDispel`, `Spells/SpellEffects.cpp:2549-2555`).
|
||||
///
|
||||
/// **The list runs to the end of the packet** — vmangos writes no count, so the body's own length
|
||||
/// is the terminator. The reference reads it the same way (`0x628c20` loops to end of buffer).
|
||||
/// `SMSG_DISPEL_FAILED`, the auras a dispel failed to remove (`SpellEffects.cpp:2549-2555`). No
|
||||
/// count: the spell ids run to the end of the body, as the 1.12 client reads them (`0x628c20`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DispelFailed {
|
||||
pub caster: u64,
|
||||
@@ -403,7 +337,6 @@ pub struct DispelFailed {
|
||||
pub spell_ids: Vec<u32>,
|
||||
}
|
||||
|
||||
/// 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<DispelFailed> {
|
||||
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<DispelFailed>
|
||||
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<DispelFailed>
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<EnchantmentLog> {
|
||||
Ok(EnchantmentLog {
|
||||
caster: read_u64_le(r)?,
|
||||
@@ -453,17 +380,11 @@ pub(super) fn read_enchantment_log(r: &mut impl Read) -> io::Result<EnchantmentL
|
||||
})
|
||||
}
|
||||
|
||||
/// One `SMSG_SPELLLOGEXECUTE` entry — a single (effect, target) row. The payload shape is decided
|
||||
/// by the **effect id**, exactly as vmangos's `Spell::SendLogExecute` switch writes it
|
||||
/// (`Spells/Spell.cpp:4694-4771`) and as the reference's own per-effect jump table reads it
|
||||
/// (`0x5e8074 jmp [edx*4+0x5e8430]`, byte remap `0x5e845c`).
|
||||
/// One `SMSG_SPELLLOGEXECUTE` row, shaped by its effect id as vmangos writes it
|
||||
/// (`Spells/Spell.cpp:4694-4771`) and the 1.12 client reads it (`0x5e8074`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum ExecuteLog {
|
||||
/// Effect 8 `POWER_DRAIN`. **Field order VERIFIED at the client's bytes, not just the server's**:
|
||||
/// the arm at `0x5e807b` reads guid → u32 → u32 → f32 and hands them to `0x62dbf0` →
|
||||
/// `0x627930(…, spellId, powerType, amount, multiplier)` — where `0x62793c cmp edi,4` and
|
||||
/// `0x62795d call 0x6278f0` (GetPowerTypeNoun) identify the SECOND u32 as the power type. So
|
||||
/// the wire is (amount, power), which is what vmangos writes, and the two agree.
|
||||
/// Effect 8 `POWER_DRAIN`: amount, then power (the 1.12 client, `0x5e807b` and `0x62793c`).
|
||||
PowerDrain {
|
||||
target: u64,
|
||||
amount: u32,
|
||||
@@ -482,35 +403,27 @@ pub enum ExecuteLog {
|
||||
amount: u32,
|
||||
power: u32,
|
||||
},
|
||||
/// Effect 19 `ADD_EXTRA_ATTACKS` — "You gain %d extra attacks through %s."
|
||||
/// Effect 19 `ADD_EXTRA_ATTACKS`: "You gain %d extra attacks through %s."
|
||||
ExtraAttacks { target: u64, count: u32 },
|
||||
/// Effect 24 `CREATE_ITEM` — the tradeskill line. **No target guid**: the item entry is the
|
||||
/// whole payload.
|
||||
/// Effect 24 `CREATE_ITEM`, the tradeskill line: no target guid, only the item entry.
|
||||
CreateItem { item_entry: u32 },
|
||||
/// Effect 68 `INTERRUPT_CAST` — the interrupted target and the spell that was interrupted.
|
||||
/// Effect 68 `INTERRUPT_CAST`: the interrupted target and spell.
|
||||
InterruptCast { target: u64, spell_id: u32 },
|
||||
/// Effect 101 `FEED_PET` — like [`Self::CreateItem`], an item entry alone.
|
||||
/// Effect 101 `FEED_PET`: an item entry alone.
|
||||
FeedPet { item_entry: u32 },
|
||||
/// Effect 111 `DURABILITY_DAMAGE`. Both fields are **signed**: `-1`/`-1` is the "all items"
|
||||
/// form, which the reference words with its own `SPELLDURABILITYDAMAGEALL*` family. vmangos
|
||||
/// calls the second field `unk`; the reference calls it the item SLOT and tests it with the
|
||||
/// entry for exactly that `-1` pair.
|
||||
/// Effect 111 `DURABILITY_DAMAGE`, both fields signed; `-1`, `-1` is the all-items form
|
||||
/// (`SPELLDURABILITYDAMAGEALL*`). The 1.12 client reads vmangos's `unk` as the item slot.
|
||||
DurabilityDamage {
|
||||
target: u64,
|
||||
item_entry: i32,
|
||||
slot: i32,
|
||||
},
|
||||
/// Every other effect vmangos logs — a bare target guid. The effect id is kept because it is
|
||||
/// what the reference's formatter switch keys on (open lock, dismiss pet, summon, dispel …).
|
||||
/// Every other effect vmangos logs: a bare target guid.
|
||||
Target { target: u64 },
|
||||
}
|
||||
|
||||
/// One decoded `SMSG_SPELLLOGEXECUTE` — "this cast's effects did these things" (vmangos
|
||||
/// `Spell::SendLogExecute`, `Spells/Spell.cpp:4662-4778`).
|
||||
///
|
||||
/// The packet is a *list of lists*: one group per spell effect that logged anything, each group a
|
||||
/// run of per-target rows. Groups keep their effect id because the whole formatter choice hangs off
|
||||
/// it — one packet can carry a tradeskill create, an interrupt and a power drain at once.
|
||||
/// `SMSG_SPELLLOGEXECUTE`, what a cast's effects did (`Spells/Spell.cpp:4662-4778`): one group of
|
||||
/// rows per logging effect, keyed by the effect id that picks the combat-log line.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SpellLogExecute {
|
||||
pub caster: u64,
|
||||
@@ -530,13 +443,7 @@ const EFFECT_INTERRUPT_CAST: u32 = 68;
|
||||
const EFFECT_FEED_PET: u32 = 101;
|
||||
const EFFECT_DURABILITY_DAMAGE: u32 = 111;
|
||||
|
||||
/// Read `SMSG_SPELLLOGEXECUTE`: caster PackedGuid · spellId u32 · effectCount u32 · per group
|
||||
/// `{effect u32, count u32, count × payload}` — the payload per [`ExecuteLog`].
|
||||
///
|
||||
/// An effect id outside the set vmangos logs cannot be skipped without desyncing the body (the row
|
||||
/// width is not on the wire), so it errors rather than guessing. vmangos never sends one: its own
|
||||
/// switch `return`s before `SendMessageToSet` for anything it has no case for, so an unlisted
|
||||
/// effect means no packet at all rather than a truncated one.
|
||||
/// Read `SMSG_SPELLLOGEXECUTE`; an unknown effect errors, as its row width is not on the wire.
|
||||
pub(super) fn read_spell_log_execute(r: &mut impl Read) -> io::Result<SpellLogExecute> {
|
||||
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<SpellLogEx
|
||||
item_entry: read_i32_le(r)?,
|
||||
slot: read_i32_le(r)?,
|
||||
},
|
||||
// The long tail of vmangos's switch: instakill, dispel, threat, summon, open lock,
|
||||
// dismiss pet … all one guid wide.
|
||||
_ if rows_are_guid_only(effect) => ExecuteLog::Target {
|
||||
target: read_u64_le(r)?,
|
||||
},
|
||||
@@ -605,9 +510,7 @@ pub(super) fn read_spell_log_execute(r: &mut impl Read) -> io::Result<SpellLogEx
|
||||
})
|
||||
}
|
||||
|
||||
/// The effects vmangos logs as a bare target guid — its `SendLogExecute` switch's long
|
||||
/// fall-through case list (`Spells/Spell.cpp:4735-4770`), each name resolved against
|
||||
/// `Spells/SpellDefines.h`'s `SpellEffects` enum rather than transcribed from memory.
|
||||
/// The effects vmangos logs as a bare target guid (`Spells/Spell.cpp:4735-4770`).
|
||||
fn rows_are_guid_only(effect: u32) -> bool {
|
||||
matches!(
|
||||
effect,
|
||||
|
||||
@@ -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<CorpseLocation> {
|
||||
let found = read_u8(r)? != 0;
|
||||
if !found {
|
||||
@@ -54,26 +39,16 @@ pub(super) fn read_corpse_query_response(r: &mut &[u8]) -> io::Result<CorpseLoca
|
||||
})
|
||||
}
|
||||
|
||||
/// Read `SMSG_CORPSE_RECLAIM_DELAY` (opcode `0x269`/617 — VERIFIED `Opcodes_1_12_1.h:618`): one
|
||||
/// `u32`, the delay in **milliseconds** before the corpse can be reclaimed (VERIFIED
|
||||
/// `Server/Packets/Misc.cpp:653-656` — `buffer << delayMs`). Sent at release
|
||||
/// (`Player::BuildPlayerRepop`, `Player.cpp:4677`) and at login while dead
|
||||
/// (`CharacterHandler.cpp:571-572`) — never at the death itself. The value is 30 s base,
|
||||
/// 60/120 s for repeated deaths within 5-minute steps (`copseReclaimDelay[]`, `Player.cpp:106`).
|
||||
/// Read `SMSG_CORPSE_RECLAIM_DELAY`: the ms until the corpse can be reclaimed
|
||||
/// (`Misc.cpp:653-656`), sent at release and at login while dead, never at death itself. It is
|
||||
/// 30 s, or 60 or 120 s after repeated deaths (`Player.cpp:106`).
|
||||
pub(super) fn read_corpse_reclaim_delay(r: &mut &[u8]) -> io::Result<u32> {
|
||||
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<ResurrectRequestBody> {
|
||||
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<ResurrectReque
|
||||
})
|
||||
}
|
||||
|
||||
/// Read `SMSG_SPIRIT_HEALER_CONFIRM` (opcode `0x222`/546 — VERIFIED `Opcodes_1_12_1.h:547`): one
|
||||
/// full `u64`, the spirit-healer NPC's guid. VERIFIED `SpellEffects.cpp:818-831` — vmangos sends it
|
||||
/// from the gossip-menu spirit-healer option (the NPC casts 17251 "Spirit Healer Res" whose dummy
|
||||
/// effect builds this packet), NOT from `CMSG_SPIRIT_HEALER_ACTIVATE`. The client answers the
|
||||
/// eventual XP_LOSS confirm-accept with [`spirit_healer_activate`] carrying this guid.
|
||||
/// Read `SMSG_SPIRIT_HEALER_CONFIRM`: the spirit healer's full guid, sent from its gossip option
|
||||
/// through spell 17251 (`SpellEffects.cpp:818-831`); accepting sends [`spirit_healer_activate`].
|
||||
pub(super) fn read_spirit_healer_confirm(r: &mut &[u8]) -> io::Result<u64> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<AreaSpiritHealerTime> {
|
||||
@@ -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<u8> {
|
||||
healer.to_le_bytes().to_vec()
|
||||
}
|
||||
|
||||
@@ -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<DuelRequested> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<bool> {
|
||||
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<DuelWinner> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<u32> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
arbiter.to_le_bytes().to_vec()
|
||||
}
|
||||
@@ -118,7 +73,6 @@ pub fn duel_cancelled(arbiter: u64) -> Vec<u8> {
|
||||
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;
|
||||
|
||||
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<GameObjectQueryInfo>)> {
|
||||
@@ -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<u64> {
|
||||
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 } => {
|
||||
|
||||
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u32> {
|
||||
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<i32> {
|
||||
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<Option<GmTicket>> {
|
||||
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<Option<GmTicket>> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<GossipOption>, Vec<QuestOption>)> {
|
||||
@@ -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<NpcTextBlock>)> {
|
||||
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<NpcTex
|
||||
Ok((text_id, blocks))
|
||||
}
|
||||
|
||||
/// A point-of-interest marker (`SMSG_GOSSIP_POI`) — the flag a guard drops on your map when you
|
||||
/// ask where the warrior trainer is. Volunteered by the server when a gossip option carries an
|
||||
/// `action_poi_id` (vmangos `Player::OnGossipSelect` → `PlayerMenu::SendPointOfInterest`), so it
|
||||
/// answers no request of ours and can arrive with or without the menu staying open.
|
||||
///
|
||||
/// The reference client feeds it into the minimap's **AreaPOI landmark** pipeline as a synthetic
|
||||
/// record (`system/minimap/scratch/minimap-re.md`: blip slot 1, `0xcea7d4`, written by
|
||||
/// `set_blip 0x6dac10` from the 0x224 handler `0x4e2840`) — which is why the field names below are
|
||||
/// the DBC's: `flags` and `icon` are read by exactly the laws that read `AreaPOI.dbc`'s columns.
|
||||
/// `SMSG_GOSSIP_POI`, the map flag a guard drops when asked for directions; the server sends it
|
||||
/// unasked from a gossip option (`Player::OnGossipSelect`). The 1.12 client treats it as an
|
||||
/// `AreaPOI.dbc` record in minimap blip slot 1 (`0x4e2840`), hence the DBC field names.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct GossipPoi {
|
||||
/// The `AreaPOI.dbc` `Flags` column's role: bit 0 = a candidate at all, bit 1 = draw the
|
||||
/// in-range icon. 5875-era `points_of_interest` rows all ship `99` (0x63 — both bits set).
|
||||
/// As `AreaPOI.dbc` `Flags`: bit 0 makes it a candidate, bit 1 draws the in-range icon; every
|
||||
/// vmangos row sends 99.
|
||||
pub flags: u32,
|
||||
/// World position — **x, y only**; the wire carries no z (the marker is a map pin, and every
|
||||
/// law that consumes it measures a 2-D distance).
|
||||
/// World x and y; the wire carries no z.
|
||||
pub pos: [f32; 2],
|
||||
/// The `POIIcons.blp` cell, 8×8 grid, gated `< 64` by the draw. All 250 5875-era rows use
|
||||
/// `6` = `ICON_POI_REDFLAG`, "red flag w/ yellow !" (vmangos `GossipDef.h:113`).
|
||||
/// The `POIIcons.blp` cell (8×8 grid, drawn only below 64); every vmangos row uses 6, the red
|
||||
/// flag (`GossipDef.h:113`).
|
||||
pub icon: u32,
|
||||
/// vmangos `points_of_interest.data` — `0` in every 5875-era row (the `.debug send poi`
|
||||
/// command sends `30`). Carried for the wire's sake; see the app-side marker for what the
|
||||
/// reference does with it.
|
||||
/// vmangos `points_of_interest.data`, 0 in every row.
|
||||
pub data: u32,
|
||||
/// The destination's name ("Stormwind Warrior Trainer") — the marker's hover tooltip.
|
||||
/// The destination's name, the marker's tooltip.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Read `SMSG_GOSSIP_POI` (vmangos `GossipDef.cpp:239-295` — both overloads write the same
|
||||
/// shape): `u32 flags, f32 x, f32 y, u32 icon, u32 data, cstr name`.
|
||||
/// Read `SMSG_GOSSIP_POI` (vmangos `GossipDef.cpp:239-295`).
|
||||
pub(super) fn read_gossip_poi(r: &mut &[u8]) -> io::Result<GossipPoi> {
|
||||
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<GossipPoi> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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());
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<u8>,
|
||||
@@ -193,13 +162,11 @@ pub struct PartyMemberStatsInfo {
|
||||
pub max_power: Option<u16>,
|
||||
pub level: Option<u16>,
|
||||
pub zone: Option<u16>,
|
||||
/// 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<Vec<u16>>,
|
||||
/// 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<Vec<u16>>,
|
||||
pub pet_guid: Option<u64>,
|
||||
pub pet_name: Option<String>,
|
||||
@@ -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<Vec<u16>> {
|
||||
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::Result<Vec<u
|
||||
Ok(spells)
|
||||
}
|
||||
|
||||
/// Read one `SMSG_PARTY_MEMBER_STATS`/`_FULL` body → `(guid, info)` (see [`PartyMemberStatsInfo`]).
|
||||
/// The guid is PACKED (`GroupHandler.cpp:768`, `packet.guid.WriteAsPacked()` / `773`
|
||||
/// `player->GetPackGUID()` — 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<RaidTargetUpdate> {
|
||||
let mode = read_u8(r)?;
|
||||
if mode == 0 {
|
||||
@@ -439,17 +368,14 @@ pub(super) fn read_raid_target_update(r: &mut &[u8]) -> io::Result<RaidTargetUpd
|
||||
}
|
||||
}
|
||||
|
||||
/// One decoded `MSG_RAID_READY_CHECK` server body (VERIFIED vmangos `Server/Packets/
|
||||
/// Group.cpp:94-96` the empty request, `:126-130` the answer): empty = the leader just started a
|
||||
/// check; non-empty = one member's answer, forwarded to the leader only (`ObjectGuid senderGuid`
|
||||
/// FULL, not packed, + `u8 state`).
|
||||
/// A server `MSG_RAID_READY_CHECK`: empty when the leader starts a check, else one member's answer,
|
||||
/// sent to the leader only (`Group.cpp:94-96`, `126-130`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReadyCheck {
|
||||
Started,
|
||||
Answer { guid: u64, ready: u8 },
|
||||
}
|
||||
|
||||
/// Read one `MSG_RAID_READY_CHECK` server body (see [`ReadyCheck`]).
|
||||
pub(super) fn read_ready_check(r: &mut &[u8]) -> io::Result<ReadyCheck> {
|
||||
if r.is_empty() {
|
||||
return Ok(ReadyCheck::Started);
|
||||
@@ -460,8 +386,7 @@ pub(super) fn read_ready_check(r: &mut &[u8]) -> io::Result<ReadyCheck> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
body
|
||||
}
|
||||
|
||||
/// Body of `CMSG_GROUP_DISBAND` (VERIFIED vmangos `Server/Protocol/Opcodes.cpp` —
|
||||
/// `NullClientPacket`): empty.
|
||||
/// `CMSG_GROUP_DISBAND` body: empty.
|
||||
pub fn group_disband() -> Vec<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
let mut body = Vec::with_capacity(9);
|
||||
body.push(icon);
|
||||
@@ -588,53 +493,36 @@ pub fn raid_target_set(icon: u8, guid: u64) -> Vec<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<Vec<RaidInstanceEntry>> {
|
||||
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<RaidInsta
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Body of `CMSG_REQUEST_RAID_INFO` (VERIFIED vmangos `Handlers/GroupHandler.cpp`'s
|
||||
/// `HandleRequestRaidInfoOpcode` — the handler reads nothing and answers `SendRaidInfo()`): empty.
|
||||
/// `CMSG_REQUEST_RAID_INFO` body: empty (vmangos `HandleRequestRaidInfoOpcode`).
|
||||
pub fn request_raid_info() -> Vec<u8> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user