comments: protocol, the quest log and share, roster, social, spell, stable, summon and taxi messages

1,061 comment lines become 396; the code is unchanged. A spell test's
comment described the unit-target shape while its assert checks the
self-cast one; it now describes the self-cast shape.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-23 20:06:38 +07:00
co-authored by Claude Opus 5.5
parent ba16121048
commit 28db7f0f37
13 changed files with 356 additions and 1021 deletions
@@ -1,46 +1,19 @@
//! `CMSG_QUEST_QUERY`/`SMSG_QUEST_QUERY_RESPONSE` (92/93) plus the quest-log book-keeping wire:
//! `CMSG_QUESTLOG_SWAP_QUEST`/`CMSG_QUESTLOG_REMOVE_QUEST`/`SMSG_QUESTLOG_FULL` (403-405) and the
//! `SMSG_QUESTUPDATE_*` progress pushes (406-410) — all VERIFIED vmangos `Opcodes_1_12_1.h`. CMSG
//! bodies from vmangos `Server/Packets/Quest.cpp:6-78`; SMSG layouts from the same file's
//! `AppendBodyTo` writers (line citations inline).
//!
//! Wire traps this parser is written against the *byte stream* to survive (never the C++ control
//! flow):
//! - **The cstr order is `title, objectives, details, endText`** (`Quest.cpp:393-528`, the
//! `>1_9_4` branch, live on 5875) — objectives lands *before* details, reversed from the giver
//! [`super::giver::QuestDetails`] panel's `title, details, objectives`.
//! - **The reward/choice/objective arrays are fixed-count, not count-prefixed** — always 4/6/4
//! slots regardless of how many the quest uses (the tail is zero-filled), and unlike the
//! giver-panel triples they carry **no display ids**.
//! - **The 4 objective texts trail the whole packet** — after all 4 objective quads, not
//! interleaved with them.
//! - **A gameobject objective id is `(−id)|0x80000000`** (`Quest.cpp:512-516`) — the same raw
//! encoding `SMSG_QUESTUPDATE_ADD_KILL`'s `entry` field carries for a GO kill/use event
//! (`Player.cpp:14634-14637`); both are kept undecoded on the parsed structs.
//! Quest-log wire (vmangos `Server/Packets/Quest.cpp`): the quest query and response (92/93), log
//! swap, remove and full (403-405), and the `SMSG_QUESTUPDATE_*` progress pushes (406-410).
use std::io;
use crate::wire::{read_cstring, read_f32_le, read_i32_le, read_u32_le, read_u64_le};
/// `QUEST_OBJECTIVES_COUNT` (= `QUEST_ITEM_OBJECTIVES_COUNT`, vmangos `QuestDef.h:34-43`) — the
/// fixed objective-quad count `SMSG_QUEST_QUERY_RESPONSE` always writes, regardless of how many
/// objectives the quest actually has (the unused tail is zero-filled).
/// Objective quads the response always writes, the unused tail zero-filled (`QuestDef.h:34-43`).
pub const QUEST_OBJECTIVES_COUNT: u32 = 4;
/// `QUEST_REWARDS_COUNT` (vmangos `QuestDef.h:34-43`) — the fixed count of `{itemId, count}` reward
/// slots on `SMSG_QUEST_QUERY_RESPONSE`.
/// `{itemId, count}` reward slots the response always writes.
pub const QUEST_REWARDS_COUNT: u32 = 4;
/// `QUEST_REWARD_CHOICES_COUNT` (vmangos `QuestDef.h:34-43`) — the fixed count of `{itemId, count}`
/// reward-choice slots on `SMSG_QUEST_QUERY_RESPONSE`.
/// `{itemId, count}` reward-choice slots the response always writes.
pub const QUEST_REWARD_CHOICES_COUNT: u32 = 6;
/// One objective slot inside [`QuestTemplate`] (vmangos `Quest.cpp:512-522`): the
/// `{reqCreatureOrGOId, reqCreatureOrGOCount, reqItemId, reqItemCount}` quad plus its trailing
/// `objectiveText` cstr (read separately, at the very end of the packet — see
/// [`read_quest_query_response`]). `creature_or_go` is kept **raw**: a positive value is a creature
/// entry, a gameobject objective is written as `(−id)|0x80000000` (`Quest.cpp:512-516`) — the same
/// encoding `SMSG_QUESTUPDATE_ADD_KILL`'s `entry` field carries (`Player.cpp:14634-14637`).
/// Deciding which case applies (and un-negating the GO id) is a UI concern; the wire doesn't
/// disambiguate beyond the sign/top bit.
/// One objective quad plus its text, which the packet sends after all the quads. `creature_or_go`
/// stays raw: a creature entry, or `(-id)|0x80000000` for a gameobject (`Quest.cpp:512-516`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QuestObjective {
pub creature_or_go: u32,
@@ -50,46 +23,30 @@ pub struct QuestObjective {
pub text: String,
}
/// `QuestFlags` (vmangos `QuestDef.h:145-160`) — the [`QuestTemplate::flags`] word, which is the
/// only place the client learns a quest's *kind* (the giver panels carry text and rewards, never
/// flags). Two of these are load-bearing for the party share (decision 1733) and the rest are
/// pinned beside them so the word is documented once:
///
/// - [`PARTY_ACCEPT`] — an escort quest. When a party member accepts one, every other eligible
/// member gets `SMSG_QUEST_CONFIRM_ACCEPT`.
/// - [`SHARABLE`] — the quest log's *Share Quest* button. **The client owns this test**: the
/// server's `HandlePushQuestToParty` does not check the bit at all, it only re-tests it when the
/// receiver accepts (`Player::CanShareQuest`, `Player.cpp:13959`).
/// - [`HIDDEN_REWARDS`] documents a wire shape this parser already survives (see the module's
/// uniform-rewards note): the rewards block is written as three zero `u32`s rather than omitted.
/// `QuestFlags` (vmangos `QuestDef.h:145-160`): the `QuestTemplate::flags` word, the only place
/// the client learns a quest's kind; the giver panels never carry it.
pub mod quest_flags {
/// Not used by 1.12 data.
pub const STAY_ALIVE: u32 = 0x0000_0001;
/// Escort: accepting it asks the rest of the party whether they want it too.
/// Escort: one member accepting sends every other eligible one `SMSG_QUEST_CONFIRM_ACCEPT`.
pub const PARTY_ACCEPT: u32 = 0x0000_0002;
/// Not used by 1.12 data.
pub const EXPLORATION: u32 = 0x0000_0004;
/// The quest may be shared with the party.
/// Shareable. The client gates Share Quest on it: the server never tests it on the push, only
/// on the receiver's accept (`Player::CanShareQuest`, `Player.cpp:13959`).
pub const SHARABLE: u32 = 0x0000_0008;
/// Not used by 1.12 data.
pub const EPIC: u32 = 0x0000_0020;
/// Not used by 1.12 data.
pub const RAID: u32 = 0x0000_0040;
/// Rewards ship only in `SMSG_QUESTGIVER_OFFER_REWARD` — the DETAILS panel and this template
/// write an empty rewards block instead.
/// Rewards ship only in `SMSG_QUESTGIVER_OFFER_REWARD`; DETAILS and this template zero them.
pub const HIDDEN_REWARDS: u32 = 0x0000_0200;
/// Granted and completed in one step; never appears in the client's quest log.
pub const AUTO_REWARDED: u32 = 0x0000_0400;
}
/// `SMSG_QUEST_QUERY_RESPONSE` (vmangos `Quest.cpp:393-528`, the `>1_9_4` branch — live on 5875):
/// the full quest template, answering `CMSG_QUEST_QUERY` and feeding the quest log's detail view.
/// Unlike the giver panels this carries **fixed-count** reward/choice/objective arrays (no count
/// prefix, no display ids) and a **different cstr order** — `title, objectives, details, endText`,
/// objectives *before* details, reversed from [`super::giver::QuestDetails`]. `money`/
/// `money_max_level` mirror the giver DETAILS panel's `rewOrReqMoney`/`rewMoneyMaxLevel` split; a
/// HIDDEN_REWARDS quest zeroes the reward/choice/money fields but keeps the same shape — this
/// parser is flag-blind, same as [`super::giver::read_questgiver_quest_details`].
/// `SMSG_QUEST_QUERY_RESPONSE` (vmangos `Quest.cpp:393-528`, the `>1_9_4` branch): the quest
/// template behind the quest log's detail view. Its arrays are fixed-count with no display ids.
#[derive(Debug, Clone, PartialEq)]
pub struct QuestTemplate {
pub quest_id: u32,
@@ -118,33 +75,27 @@ pub struct QuestTemplate {
pub objectives: [QuestObjective; QUEST_OBJECTIVES_COUNT as usize],
}
// ── CMSG encoders (readers at vmangos `Quest.cpp:6-58`) ───────────────────────────────────────────
// ── CMSG encoders ────────────────────────────────────────────────────────────────────────────────
/// Body of `CMSG_QUEST_QUERY` (vmangos `Quest.cpp:6`, `Quest.h:17`): one `u32` quest entry.
/// Answered by `SMSG_QUEST_QUERY_RESPONSE` ([`QuestTemplate`]) — the quest-log detail view's
/// opener, distinct from the giver-panel `CMSG_QUESTGIVER_QUERY_QUEST` (which needs an NPC guid).
/// Body of `CMSG_QUEST_QUERY` (`Quest.cpp:6`): the quest id. Unlike the giver query, no NPC guid.
pub fn quest_query(quest_id: u32) -> Vec<u8> {
quest_id.to_le_bytes().to_vec()
}
/// Body of `CMSG_QUESTLOG_REMOVE_QUEST` (vmangos `Quest.cpp:58`, `Quest.h:105`): one `u8` log
/// slot. Abandons the quest in that slot; the server clears the `PLAYER_QUEST_LOG` fields
/// directly — there is no ack SMSG, the field update *is* the confirmation.
/// Body of `CMSG_QUESTLOG_REMOVE_QUEST` (`Quest.cpp:58`): abandons a log slot. There is no reply;
/// the cleared `PLAYER_QUEST_LOG` fields are the confirmation.
pub fn questlog_remove_quest(slot: u8) -> Vec<u8> {
vec![slot]
}
/// Body of `CMSG_QUESTLOG_SWAP_QUEST` (vmangos `Quest.cpp:52`, `Quest.h:95-96`): two `u8` log
/// slots to swap.
/// Body of `CMSG_QUESTLOG_SWAP_QUEST` (`Quest.cpp:52`).
pub fn questlog_swap_quest(slot1: u8, slot2: u8) -> Vec<u8> {
vec![slot1, slot2]
}
// ── SMSG parsers ──────────────────────────────────────────────────────────────────────────────────
// ── SMSG parsers ─────────────────────────────────────────────────────────────────────────────────
/// Read `SMSG_QUEST_QUERY_RESPONSE` (vmangos `Quest.cpp:393-528`, the `>1_9_4` branch, live on
/// 5875) — see [`QuestTemplate`] for the field-by-field wire-trap notes (cstr order, fixed-count
/// arrays, the trailing objective texts, the raw GO objective encoding).
/// Read `SMSG_QUEST_QUERY_RESPONSE`.
pub(in crate::messages) fn read_quest_query_response(r: &mut &[u8]) -> io::Result<QuestTemplate> {
let quest_id = read_u32_le(r)?;
let method = read_u32_le(r)?;
@@ -153,8 +104,8 @@ pub(in crate::messages) fn read_quest_query_response(r: &mut &[u8]) -> io::Resul
let quest_type = read_u32_le(r)?;
let rep_objective_faction = read_u32_le(r)?;
let rep_objective_value = read_u32_le(r)?;
let _opposite_rep_faction = read_u32_le(r)?; // requiredOppositeRepFaction — always 0
let _opposite_rep_value = read_u32_le(r)?; // requiredOppositeRepValue — always 0
let _opposite_rep_faction = read_u32_le(r)?; // requiredOppositeRepFaction, always 0
let _opposite_rep_value = read_u32_le(r)?; // requiredOppositeRepValue, always 0
let next_quest_in_chain = read_u32_le(r)?;
let money = read_i32_le(r)?;
let money_max_level = read_u32_le(r)?;
@@ -162,8 +113,7 @@ pub(in crate::messages) fn read_quest_query_response(r: &mut &[u8]) -> io::Resul
let src_item_id = read_u32_le(r)?;
let flags = read_u32_le(r)?;
// Fixed-count reward/choice arrays — NOT count-prefixed like the giver-panel item blocks, and
// no display ids. HIDDEN_REWARDS zeroes the values but keeps the same shape.
// Fixed-count, unlike the giver panels' count-prefixed blocks; HIDDEN_REWARDS only zeroes them.
let mut rewards = [(0u32, 0u32); QUEST_REWARDS_COUNT as usize];
for slot in rewards.iter_mut() {
*slot = (read_u32_le(r)?, read_u32_le(r)?);
@@ -179,8 +129,7 @@ pub(in crate::messages) fn read_quest_query_response(r: &mut &[u8]) -> io::Resul
let point_opt = read_u32_le(r)?;
let title = read_cstring(r)?;
// The trap: objectives overview text lands BEFORE details here — reversed vs DETAILS
// (`QuestDetails`, whose order is title/details/objectives).
// Objectives before details: the reverse of the giver panel's `QuestDetails` order.
let objectives_text = read_cstring(r)?;
let details = read_cstring(r)?;
let end_text = read_cstring(r)?;
@@ -194,7 +143,7 @@ pub(in crate::messages) fn read_quest_query_response(r: &mut &[u8]) -> io::Resul
read_u32_le(r)?,
);
}
// The 4 objective texts trail the *whole* packet — after every quad, not interleaved.
// The objective texts follow all the quads, not interleaved with them.
let mut objective_texts = Vec::with_capacity(QUEST_OBJECTIVES_COUNT as usize);
for _ in 0..QUEST_OBJECTIVES_COUNT {
objective_texts.push(read_cstring(r)?);
@@ -244,12 +193,9 @@ pub(in crate::messages) fn read_quest_query_response(r: &mut &[u8]) -> io::Resul
})
}
/// Read `SMSG_QUESTUPDATE_ADD_KILL` (vmangos `Quest.cpp:144`): `u32 questId, u32 entry, u32
/// count, u32 required, u64 guid`. `entry` for a gameobject objective is
/// `(−ReqCreatureOrGOId)|0x80000000` (`Player.cpp:14634-14637`, the same raw encoding as
/// [`QuestObjective::creature_or_go`]); `count` is asserted `< 64` server-side (a 6-bit
/// slot-counter store) and mirrors — doesn't replace — the durable `PLAYER_QUEST_LOG` counter
/// field: this packet is the toast line, the field update is the durable state.
/// Read `SMSG_QUESTUPDATE_ADD_KILL` (`Quest.cpp:144`): `(questId, entry, count, required, guid)`.
/// A gameobject `entry` is `(-id)|0x80000000` (`Player.cpp:14634`); `count` stays below 64. This
/// is the toast line only: the `PLAYER_QUEST_LOG` counter field is the durable state.
pub(in crate::messages) fn read_quest_update_add_kill(
r: &mut &[u8],
) -> io::Result<(u32, u32, u32, u32, u64)> {
@@ -267,9 +213,7 @@ pub(in crate::messages) fn read_quest_update_add_item(r: &mut &[u8]) -> io::Resu
Ok((read_u32_le(r)?, read_u32_le(r)?))
}
/// Read `SMSG_QUESTUPDATE_COMPLETE` (vmangos `Quest.cpp:91`): one `u32` quest id. Fires once
/// every objective is complete (`Player.cpp` ~14494); the log slot's state byte gets
/// `QUEST_STATE_COMPLETE`.
/// Read `SMSG_QUESTUPDATE_COMPLETE` (`Quest.cpp:91`): the quest id, once every objective is done.
pub(in crate::messages) fn read_quest_update_complete(r: &mut &[u8]) -> io::Result<u32> {
read_u32_le(r)
}
@@ -279,8 +223,7 @@ pub(in crate::messages) fn read_quest_update_failed(r: &mut &[u8]) -> io::Result
read_u32_le(r)
}
/// Read `SMSG_QUESTUPDATE_FAILEDTIMER` (vmangos `Quest.cpp:121`): one `u32` quest id — a timed
/// quest's clock ran out.
/// Read `SMSG_QUESTUPDATE_FAILEDTIMER` (`Quest.cpp:121`): the id of a timed quest that expired.
pub(in crate::messages) fn read_quest_update_failedtimer(r: &mut &[u8]) -> io::Result<u32> {
read_u32_le(r)
}
@@ -289,7 +232,7 @@ pub(in crate::messages) fn read_quest_update_failedtimer(r: &mut &[u8]) -> io::R
mod tests {
use super::*;
// ── Small byte-buffer builders for the hand-built parse fixtures ──────────────────────────────
// ── Fixture builders ─────────────────────────────────────────────────────────────────────────
fn push_u32(b: &mut Vec<u8>, v: u32) {
b.extend_from_slice(&v.to_le_bytes());
}
@@ -427,7 +370,6 @@ mod tests {
assert_eq!(q.point_y, -45.25);
assert_eq!(q.point_opt, 2);
assert_eq!(q.title, "A Threat Within");
// The trap: objectives-overview text lands BEFORE details.
assert_eq!(q.objectives_text, "Slay 10 kobolds.");
assert_eq!(q.details, "Kill the kobolds threatening Goldshire.");
assert_eq!(q.end_text, "You have done well, adventurer.");
@@ -445,8 +387,7 @@ mod tests {
#[test]
fn quest_query_response_hidden_rewards_shape_is_blind() {
// A HIDDEN_REWARDS quest zeroes every reward/choice slot and the money field, but the
// fixed-count shape is unchanged — this parser can't see (and doesn't need) the flag.
// HIDDEN_REWARDS zeroes the rewards, choices and money but keeps the fixed-count shape.
let mut b = Vec::new();
push_u32(&mut b, 200); // questId
push_u32(&mut b, 0); // method
@@ -462,7 +403,7 @@ mod tests {
push_u32(&mut b, 0); // rewMoneyMaxLevel
push_u32(&mut b, 99); // rewSpell
push_u32(&mut b, 0); // srcItemId
push_u32(&mut b, 0x200); // questFlags (a HIDDEN_REWARDS-shaped value; parser is flag-blind)
push_u32(&mut b, 0x200); // questFlags: HIDDEN_REWARDS
for _ in 0..QUEST_REWARDS_COUNT {
push_u32(&mut b, 0);
push_u32(&mut b, 0);
@@ -515,7 +456,6 @@ mod tests {
#[test]
fn quest_update_simple_smsgs_parse() {
// COMPLETE/FAILED/FAILEDTIMER are all a lone u32 quest id.
let mut b = Vec::new();
push_u32(&mut b, 42);
assert_eq!(read_quest_update_complete(&mut b.as_slice()).unwrap(), 42);
@@ -525,7 +465,6 @@ mod tests {
42
);
// ADD_ITEM: {itemId, count}.
let mut b2 = Vec::new();
push_u32(&mut b2, 3000);
push_u32(&mut b2, 5);
@@ -1,15 +1,5 @@
//! Questgiver + quest-log messages, split along the two decision-scoped slices: [`giver`] — the
//! "accept/turn-in a quest at an NPC" family (opcodes 386-402, vmangos `Opcodes_1_12_1.h`,
//! VERIFIED; decision 0088) — and [`log`] — the quest-log wire: `CMSG_QUEST_QUERY`/
//! `SMSG_QUEST_QUERY_RESPONSE` (92/93), `CMSG_QUESTLOG_SWAP_QUEST`/`CMSG_QUESTLOG_REMOVE_QUEST`/
//! `SMSG_QUESTLOG_FULL` (403-405), and the `SMSG_QUESTUPDATE_*` progress pushes (406-410). CMSG
//! bodies from vmangos `Server/Packets/Quest.cpp:6-78`; SMSG layouts from the same file's
//! `AppendBodyTo` writers (line citations inline in each submodule). The third slice — `share`,
//! `CMSG_QUEST_CONFIRM_ACCEPT` / `SMSG_QUEST_CONFIRM_ACCEPT` / `CMSG_PUSHQUESTTOPARTY` /
//! `MSG_QUEST_PUSH_RESULT` (411-413 + 630) — is the party quest-share flow (decision 1733). It is
//! its own file and not `giver`'s, even though a shared quest lands on the giver's own DETAILS
//! panel: what is new here is the *verdict* wire between sharer and receiver, and `giver` never
//! learns that a share exists — it only ever sees a giver guid that happens to be a player.
//! Quest messages: [`giver`] for accepting and turning in at an NPC (386-402), [`log`] for the
//! quest log (92/93, 403-410), and `share` for the party quest share (411-413, 630).
mod giver;
mod log;
@@ -1,119 +1,73 @@
//! The **party quest-share** wire (opcodes 411-413 + 630, VERIFIED vmangos `Opcodes_1_12_1.h`) —
//! decision 1733, the third slice of the quest arc after [`super::giver`] and [`super::log`].
//!
//! Two independent flows share this file because they share the same server-side latch (vmangos
//! `Player::SetQuestShareInfo`/`ClearQuestShareInfo`, one `{sharerGuid, questId}` pair per player):
//!
//! 1. **The push** — the quest log's *Share Quest* button. `CMSG_PUSHQUESTTOPARTY{questId}` → the server
//! walks the group and, per member, sends the SHARER a `MSG_QUEST_PUSH_RESULT`
//! verdict; an eligible member additionally gets an ordinary `SMSG_QUESTGIVER_QUEST_DETAILS`
//! whose `npcGuid` is the **sharer's player guid** (vmangos `QuestHandler.cpp:403-459`). That is
//! the whole reason the receiver needs no new inbound opcode: a shared quest arrives on the
//! detail panel already built, and Accept answers it with the ordinary
//! `CMSG_QUESTGIVER_ACCEPT_QUEST` addressed to that player guid (the server's own
//! `TYPEID_PLAYER` arm, `QuestHandler.cpp:111-114`, gated on `Player::CanShareQuest`).
//! A **decline** is the one verdict the client itself originates: `MSG_QUEST_PUSH_RESULT` back
//! up, which the server relays to the sharer with the *receiver's* guid substituted
//! (`QuestHandler.cpp:461-478`).
//! 2. **The escort confirm** — `QUEST_FLAGS_PARTY_ACCEPT` (0x2). When a party member accepts such
//! a quest, every other eligible member gets `SMSG_QUEST_CONFIRM_ACCEPT` and answers with
//! `CMSG_QUEST_CONFIRM_ACCEPT` if they say yes (vmangos `QuestHandler.cpp:172-193`,
//! `Player.cpp:14571-14594`). Saying *no* sends nothing at all — the server's latch is cleared
//! by the next thing that touches it.
//!
//! **Who the guid names flips direction on `MSG_QUEST_PUSH_RESULT`, and that is the one trap here.**
//! The opcode is `MSG_*` — the same number both ways — but the `u64` never means the same thing
//! twice: going *up* it is the sharer the receiver is answering; coming *down* it is the OTHER
//! party (`Player::SendPushToPartyResponse` writes `pPlayer`'s guid, the member the verdict is
//! *about*, `Player.cpp:14596-14608`), so the sharer's `%s` fills from it directly.
//! The party quest-share wire (411-413, 630). The sharer gets one `MSG_QUEST_PUSH_RESULT` per
//! member; an eligible member gets an ordinary `SMSG_QUESTGIVER_QUEST_DETAILS` with the sharer's
//! player guid as `npcGuid`, and Accept sends `CMSG_QUESTGIVER_ACCEPT_QUEST` to that guid
//! (`QuestHandler.cpp:111-114, 403-459`). The push-result guid names a different player each way.
use std::io;
use crate::wire::{read_cstring, read_u32_le, read_u64_le, read_u8};
/// `QuestShareMessages` (vmangos `QuestDef.h:62-70`) — the `u8` verdict `MSG_QUEST_PUSH_RESULT`
/// carries in both directions. The server originates 0/1/4/5/6/7/8 while walking the group, and 2
/// when the receiver accepts; the client originates 3 when the receiver declines.
///
/// Kept as a plain `u8` newtype rather than an enum with a `TryFrom` because the *display* mapping
/// (which `ERR_QUEST_PUSH_*` message id each value selects, and on which surface) belongs to the
/// app layer's message table, not to the wire — and an unknown value must survive the parse the way
/// every other unmapped code in this crate does, not become a parse error that drops the packet.
/// `QuestShareMessages` (vmangos `QuestDef.h:62-70`): the `u8` verdict of `MSG_QUEST_PUSH_RESULT`.
/// A newtype, not an enum, so an unknown value survives the parse.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct QuestShareMsg(pub u8);
impl QuestShareMsg {
/// `QUEST_PARTY_MSG_SHARING_QUEST` — the server's opening "the push went out to this member".
/// `QUEST_PARTY_MSG_SHARING_QUEST`: the push went out to this member.
pub const SHARING_QUEST: Self = Self(0);
/// `QUEST_PARTY_MSG_CANT_TAKE_QUEST` — the member fails `CanTakeQuest` (level, race, prereqs).
/// `QUEST_PARTY_MSG_CANT_TAKE_QUEST`: the member fails `CanTakeQuest` (level, race, prereqs).
pub const CANT_TAKE_QUEST: Self = Self(1);
/// `QUEST_PARTY_MSG_ACCEPT_QUEST` — the member accepted (server-originated, on their
/// `CMSG_QUESTGIVER_ACCEPT_QUEST`).
/// `QUEST_PARTY_MSG_ACCEPT_QUEST`: sent by the server when the member accepts.
pub const ACCEPT_QUEST: Self = Self(2);
/// `QUEST_PARTY_MSG_DECLINE_QUEST` — **the one value the client sends**.
/// `QUEST_PARTY_MSG_DECLINE_QUEST`: the one value the client sends.
pub const DECLINE_QUEST: Self = Self(3);
/// `QUEST_PARTY_MSG_TOO_FAR` — outside `QUEST_SHARE_DISTANCE` (14.0 yd, vmangos `Object.h:72`).
/// `QUEST_PARTY_MSG_TOO_FAR`: beyond `QUEST_SHARE_DISTANCE`, 14 yd (vmangos `Object.h:72`).
pub const TOO_FAR: Self = Self(4);
/// `QUEST_PARTY_MSG_BUSY` — the member already has a share latched.
/// `QUEST_PARTY_MSG_BUSY`: the member already has a share latched.
pub const BUSY: Self = Self(5);
/// `QUEST_PARTY_MSG_LOG_FULL` — the member's quest log is full.
/// `QUEST_PARTY_MSG_LOG_FULL`: the member's quest log is full.
pub const LOG_FULL: Self = Self(6);
/// `QUEST_PARTY_MSG_HAVE_QUEST` — the member is already on the quest.
/// `QUEST_PARTY_MSG_HAVE_QUEST`: the member is already on the quest.
pub const HAVE_QUEST: Self = Self(7);
/// `QUEST_PARTY_MSG_FINISH_QUEST` — the member has already completed the quest.
/// `QUEST_PARTY_MSG_FINISH_QUEST`: the member has already completed the quest.
pub const FINISH_QUEST: Self = Self(8);
}
/// `MSG_QUEST_PUSH_RESULT` as the **server** sends it (vmangos
/// `Server/Packets/Quest.cpp:81-85`): `u64 guid, u8 msg`.
///
/// `guid` is the party member the verdict is *about* — never the sharer, and never us (see the
/// module header's direction trap). It is what fills the `%s` of every `ERR_QUEST_PUSH_*` string.
/// `MSG_QUEST_PUSH_RESULT` from the server (`Quest.cpp:81-85`). Its guid is the member the verdict
/// is about, never the sharer, and fills the `%s` of `ERR_QUEST_PUSH_*` (`Player.cpp:14596-14608`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct QuestPushResult {
/// The party member the verdict concerns.
pub member: u64,
/// The verdict.
pub msg: QuestShareMsg,
}
/// `SMSG_QUEST_CONFIRM_ACCEPT` (vmangos `Server/Packets/Quest.cpp:131-136`): `u32 questId, cstr
/// questTitle, u64 senderGuid` — the `QUEST_FLAGS_PARTY_ACCEPT` (escort) confirm box.
///
/// The **title travels on the wire** rather than being looked up from the client's template cache,
/// because the server localizes it per receiver session (`Player::SendQuestConfirmAccept`,
/// `Player.cpp:14575-14586`) — and because the receiver may never have queried this quest at all.
/// `SMSG_QUEST_CONFIRM_ACCEPT` (`Quest.cpp:131-136`): the escort confirm, sent to the other
/// eligible members when one accepts.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QuestConfirmAccept {
/// The quest being started — echoed back verbatim in [`quest_confirm_accept`].
/// Echoed back in [`quest_confirm_accept`].
pub quest_id: u32,
/// The quest title, already localized by the server.
/// Server-localized (`Player.cpp:14575-14586`); the receiver may not have the quest cached.
pub title: String,
/// The party member who accepted it — the first `%s` of `QUEST_ACCEPT`.
/// The member who accepted: the first `%s` of `QUEST_ACCEPT`.
pub sender: u64,
}
// ── CMSG encoders ─────────────────────────────────────────────────────────────────────────────────
// ── CMSG encoders ────────────────────────────────────────────────────────────────────────────────
/// Body of `CMSG_PUSHQUESTTOPARTY` (vmangos `Quest.cpp:68-71`): one `u32` quest id. The quest log's
/// *Share Quest* button; the server addresses the group itself, so no member guid rides along.
/// Body of `CMSG_PUSHQUESTTOPARTY` (`Quest.cpp:68-71`): Share Quest; the server walks the group.
pub fn push_quest_to_party(quest_id: u32) -> Vec<u8> {
quest_id.to_le_bytes().to_vec()
}
/// Body of `CMSG_QUEST_CONFIRM_ACCEPT` (vmangos `Quest.cpp:63-66`): one `u32` quest id — the
/// escort confirm's Yes.
/// Body of `CMSG_QUEST_CONFIRM_ACCEPT` (`Quest.cpp:63-66`): the escort confirm's yes. A no sends
/// nothing (`QuestHandler.cpp:172-193`).
pub fn quest_confirm_accept(quest_id: u32) -> Vec<u8> {
quest_id.to_le_bytes().to_vec()
}
/// Body of `MSG_QUEST_PUSH_RESULT` as the **client** sends it (vmangos `Quest.cpp:73-77`): `u64
/// guid, u8 msg`. `guid` is the *sharer* we are answering — the `npcGuid` the shared
/// `SMSG_QUESTGIVER_QUEST_DETAILS` arrived under.
///
/// vmangos ignores the guid entirely (it re-derives the sharer from its own latch,
/// `QuestHandler.cpp:461-467`), so a wrong value here would go unnoticed against this server. It is
/// sent correctly anyway: the field is the protocol's, not the server's.
/// Body of `MSG_QUEST_PUSH_RESULT` from the client (`Quest.cpp:73-77`): the sharer being answered,
/// the `npcGuid` the shared DETAILS came under. vmangos ignores it (`QuestHandler.cpp:461-467`).
pub fn quest_push_result(sharer: u64, msg: QuestShareMsg) -> Vec<u8> {
let mut b = Vec::with_capacity(9);
b.extend_from_slice(&sharer.to_le_bytes());
@@ -121,9 +75,9 @@ pub fn quest_push_result(sharer: u64, msg: QuestShareMsg) -> Vec<u8> {
b
}
// ── SMSG readers ──────────────────────────────────────────────────────────────────────────────────
// ── SMSG readers ─────────────────────────────────────────────────────────────────────────────────
/// Read `MSG_QUEST_PUSH_RESULT` (see [`QuestPushResult`]).
/// Read `MSG_QUEST_PUSH_RESULT` from the server.
pub(in crate::messages) fn read_quest_push_result(r: &mut &[u8]) -> io::Result<QuestPushResult> {
Ok(QuestPushResult {
member: read_u64_le(r)?,
@@ -131,7 +85,7 @@ pub(in crate::messages) fn read_quest_push_result(r: &mut &[u8]) -> io::Result<Q
})
}
/// Read `SMSG_QUEST_CONFIRM_ACCEPT` (see [`QuestConfirmAccept`]).
/// Read `SMSG_QUEST_CONFIRM_ACCEPT`.
pub(in crate::messages) fn read_quest_confirm_accept(
r: &mut &[u8],
) -> io::Result<QuestConfirmAccept> {
@@ -163,7 +117,6 @@ mod tests {
#[test]
fn push_result_reads_member_then_msg() {
// The server's `AppendBodyTo`: guid first, verdict byte last.
let mut b = 0x0000_0000_0000_00AAu64.to_le_bytes().to_vec();
b.push(8);
let r = read_quest_push_result(&mut b.as_slice()).unwrap();
@@ -176,8 +129,6 @@ mod tests {
);
}
/// An unmapped verdict byte must survive the parse — the display table decides what (if
/// anything) to show, exactly as every other unmapped wire code in this crate is handled.
#[test]
fn unknown_verdict_byte_parses() {
let mut b = 1u64.to_le_bytes().to_vec();
@@ -1,61 +1,33 @@
//! The reputation pane's three send verbs, and the one server push that is not a standing.
//!
//! Every faction the client can *change* is addressed by its **reputation-list id** — `Faction.dbc`'s
//! `reputationIndex`, the same slot the `SMSG_INITIALIZE_FACTIONS` array is positional in — never by
//! the `Faction.dbc` id and never by the panel's display row. The three bodies are byte-exact against
//! the vmangos reader side (`Server/Packets/Misc.cpp` + `Misc.h`, the `SetFactionAtWar` /
//! `SetFactionInactive` / `SetWatchedFaction` `ClientPacket`s), which is what a real server will
//! actually parse:
//!
//! | verb | opcode | body |
//! |---|---|---|
//! | at-war toggle | `CMSG_SET_FACTION_ATWAR` 0x125 | `u32 repListId`, `u8 flag` |
//! | inactive toggle | `CMSG_SET_FACTION_INACTIVE` 0x317 | `u32 repListId`, `u8 inactive` |
//! | watched faction | `CMSG_SET_WATCHED_FACTION` 0x318 | `i32 repListId` |
//!
//! **The watched verb is signed, and that is load-bearing.** Slot `0` is a real faction (the Bloodsail
//! Buccaneers hold `reputationIndex` 0 in 1.12's `Faction.dbc`), so "watch nothing" cannot be 0 on the
//! wire — vmangos writes the value straight into `PLAYER_FIELD_WATCHED_FACTION_INDEX` with
//! `SetInt32Value` (`Handlers/CharacterHandler.cpp` `HandleSetWatchedFactionOpcode`), and the descriptor
//! field's "none" is `-1`. FrameXML's own `SetWatchedFactionIndex(0)` passes a *display row* of 0
//! meaning "no row", so the binding — not this layer — is where 0 becomes [`WATCHED_FACTION_NONE`].
//!
//! None of the three is acked. The at-war and inactive flags come back as the flag byte of a fresh
//! `SMSG_INITIALIZE_FACTIONS`-shaped state only at the next login; within a session the client owns
//! its own optimistic flag copy, exactly as it owns the collapse state. The watched index comes back
//! as a `PLAYER_FIELD_WATCHED_FACTION_INDEX` descriptor update.
//! The reputation pane's send verbs (vmangos `Misc.cpp`). A faction is addressed by its
//! reputation-list id (`Faction.dbc` `reputationIndex`, its `SMSG_INITIALIZE_FACTIONS` slot),
//! never by the DBC id or the panel row. None is acked: at-war and inactive return only at the
//! next login, so the client keeps its own copy; the watched index returns as a descriptor update.
/// The length of the reputation-list array `SMSG_INITIALIZE_FACTIONS` is positional in — vmangos
/// `WorldPackets::Misc::InitializeFactions::MAX_FACTION_COUNT` (`Server/Packets/Misc.h:607`), the
/// `std::array<FactionInitEntry, 64>` the packet is built over. Every `repListId` on this wire —
/// the `SMSG_SET_FACTION_STANDING` / `SMSG_SET_FACTION_VISIBLE` slot, and the three send verbs' —
/// is an index into that array, so one past it is not a faction and a store sized from it is a
/// store sized from a lie (decision 2265 §B1).
/// Length of the reputation list `SMSG_INITIALIZE_FACTIONS` is positional in (vmangos `Misc.h:607`,
/// `MAX_FACTION_COUNT`); every `repListId` on this wire indexes it.
pub const FACTION_LIST_LEN: usize = 64;
/// The `PLAYER_FIELD_WATCHED_FACTION_INDEX` / `CMSG_SET_WATCHED_FACTION` sentinel for "watch no
/// faction" — see the module header on why it cannot be `0`.
/// "Watch no faction" for `CMSG_SET_WATCHED_FACTION` and `PLAYER_FIELD_WATCHED_FACTION_INDEX`. Not
/// 0: slot 0 is a real faction (Bloodsail Buccaneers) and vmangos stores the value as sent
/// (`HandleSetWatchedFactionOpcode`). FrameXML's row 0 means none; the binding maps it to this.
pub const WATCHED_FACTION_NONE: i32 = -1;
/// Body of `CMSG_SET_FACTION_ATWAR`: the reputation-list slot, then the desired at-war state.
///
/// vmangos drops the request outright while the player is in combat
/// (`HandleSetFactionAtWarOpcode`), so a declare/withdraw mid-fight is silently nothing.
/// Body of `CMSG_SET_FACTION_ATWAR`. vmangos drops it without a reply while the player is in combat
/// (`HandleSetFactionAtWarOpcode`).
pub fn set_faction_at_war(rep_list_id: u32, at_war: bool) -> Vec<u8> {
let mut out = rep_list_id.to_le_bytes().to_vec();
out.push(u8::from(at_war));
out
}
/// Body of `CMSG_SET_FACTION_INACTIVE`: the reputation-list slot, then the desired inactive state.
/// Body of `CMSG_SET_FACTION_INACTIVE`.
pub fn set_faction_inactive(rep_list_id: u32, inactive: bool) -> Vec<u8> {
let mut out = rep_list_id.to_le_bytes().to_vec();
out.push(u8::from(inactive));
out
}
/// Body of `CMSG_SET_WATCHED_FACTION`: one **signed** reputation-list slot, or
/// [`WATCHED_FACTION_NONE`] to stop watching.
/// Body of `CMSG_SET_WATCHED_FACTION`: a slot, or [`WATCHED_FACTION_NONE`] to stop watching.
pub fn set_watched_faction(rep_list_id: i32) -> Vec<u8> {
rep_list_id.to_le_bytes().to_vec()
}
+20 -39
View File
@@ -1,6 +1,5 @@
//! The character-select roster family: the `SMSG_CHAR_ENUM` [`Character`] entry (vmangos
//! `Player::BuildEnumData` field order, byte-goldened in `tests/char_enum.rs`) + the
//! create/delete result codes and the race/class/gender ids the create path sends.
//! Character select: the `SMSG_CHAR_ENUM` [`Character`] entry (vmangos `Player::BuildEnumData`
//! order), the create/delete result codes and the create request.
use std::io::{self, Read};
@@ -9,26 +8,19 @@ use crate::wire::{read_cstring, read_u32_le, read_u64_le, read_u8, Vector3d};
/// `WorldResult::CharCreateSuccess` / `CharCreateNameInUse` (`SMSG_CHAR_CREATE`).
pub const CHAR_CREATE_SUCCESS: u8 = 0x2E;
pub const CHAR_CREATE_NAME_IN_USE: u8 = 0x31;
/// `WorldResult::CharCreateServerLimit` — the account already holds `CharactersPerRealm` characters
/// (vmangos `HandleCharCreateOpcode`, checked against the last char-enum's count). Counted from the
/// `CHAR_CREATE_SUCCESS = 0x2E` anchor through the `SharedDefines.h` ResponseCodes order
/// (SUCCESS, ERROR, FAILED, NAME_IN_USE, DISABLED, PVP_TEAMS_VIOLATION, SERVER_LIMIT).
/// `WorldResult::CharCreateServerLimit`: the account already holds `CharactersPerRealm`
/// characters (vmangos `HandleCharCreateOpcode`; 0x2E + 6 in `SharedDefines.h` ResponseCodes).
pub const CHAR_CREATE_SERVER_LIMIT: u8 = 0x34;
/// `WorldResult::CharDeleteSuccess` (`SMSG_CHAR_DELETE`; vmangos `SharedDefines.h` ResponseCodes,
/// counted from the verified `CHAR_CREATE_SUCCESS = 0x2E` anchor).
/// `WorldResult::CharDeleteSuccess` (`SMSG_CHAR_DELETE`; vmangos `SharedDefines.h` ResponseCodes).
pub const CHAR_DELETE_SUCCESS: u8 = 0x39;
/// Race/Class/Gender values benilla's char-create sends.
pub const RACE_HUMAN: u8 = 0x1;
pub const CLASS_WARRIOR: u8 = 0x1;
pub const GENDER_MALE: u8 = 0x0;
/// A character-creation request: the identity + the five appearance dials the create screen picked.
/// Consumed by the [`super::char_create`] body builder (`CMSG_CHAR_CREATE`) and carried to the
/// parked IO thread over the pick channel (decision 0423). `outfit_id` isn't here — it's always 0 on
/// the wire (the server reads and ignores it, then recomputes start gear; verified vmangos
/// `CharacterHandler.cpp:310`). The five appearance bytes are `ChrRaces`/`CharSections`-valid indices
/// the create UI derives from the DBCs, so every request the UI can build passes the server's
/// `Player::ValidateAppearance`.
/// A `CMSG_CHAR_CREATE` request for [`super::char_create`]. The outfit id is always 0 on the wire:
/// the server ignores it and picks the start gear (vmangos `CharacterHandler.cpp:310`). The
/// appearance bytes must be valid `CharSections` indices or `Player::ValidateAppearance` refuses.
#[derive(Debug, Clone)]
pub struct CharCreateReq {
pub name: String,
@@ -45,28 +37,21 @@ pub struct CharCreateReq {
pub facial_hair: u8,
}
/// The enum entry's `flags` bits the select screen renders (vmangos `CHARACTER_FLAG_*`,
/// `Player::BuildEnumData`): the at-login helm/cloak hide toggles, the dead-as-ghost marker, and
/// the server-ordered rename.
/// The vmangos `CHARACTER_FLAG_*` bits of [`Character::flags`] that the select screen renders.
pub const CHARACTER_FLAG_HIDE_HELM: u32 = 0x0400;
pub const CHARACTER_FLAG_HIDE_CLOAK: u32 = 0x0800;
pub const CHARACTER_FLAG_GHOST: u32 = 0x2000;
pub const CHARACTER_FLAG_RENAME: u32 = 0x4000;
/// One visible-equipment entry of the enum record: the `ItemDisplayInfo.dbc` id (NOT an item id —
/// the server already resolved the template hop) and the item's `InventoryType`. `display_id == 0`
/// is an empty slot. 19 slots in the vanilla equipment order (head 0 … tabard 18).
/// One visible-equipment slot: an `ItemDisplayInfo.dbc` id (not an item id, 0 for empty) and the
/// item's `InventoryType`.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct CharEnumItem {
pub display_id: u32,
pub inventory_type: u8,
}
/// A character from `SMSG_CHAR_ENUM` — the full roster entry the character-select screen renders
/// (decision 0465): identity, the five appearance bytes, zone/map/position, the display flags, the
/// visible equipment display ids, and the **pet triple** (the hunter/warlock companion standing
/// beside the selected character). Only the guild id, first-login byte and first-bag pair are
/// parsed for alignment and discarded (nothing at select renders them).
/// One `SMSG_CHAR_ENUM` roster entry.
#[derive(Debug, Clone)]
pub struct Character {
pub guid: u64,
@@ -77,30 +62,26 @@ pub struct Character {
pub class: u8,
/// 0 male, 1 female.
pub gender: u8,
/// The five appearance dials, `CharSections` indices (the create screen's tuple, echoed back).
/// First of the five appearance bytes, all `CharSections` indices.
pub skin: u8,
pub face: u8,
pub hair_style: u8,
pub hair_color: u8,
pub facial_hair: u8,
pub level: u8,
/// `AreaTable.dbc` id of the character's zone (the roster row's location line).
/// `AreaTable.dbc` id of the character's zone.
pub zone: u32,
pub map: u32,
pub position: Vector3d,
/// `CHARACTER_FLAG_*` bits (hide-helm/hide-cloak/ghost/rename are the rendered ones).
/// `CHARACTER_FLAG_*` bits.
pub flags: u32,
/// The 19 visible equipment slots (vanilla order: head, neck, shoulders, shirt, chest, waist,
/// legs, feet, wrists, hands, finger×2, trinket×2, back, main hand, off hand, ranged, tabard).
/// Equipment order: head, neck, shoulders, shirt, chest, waist, legs, feet, wrists, hands,
/// finger x2, trinket x2, back, main hand, off hand, ranged, tabard.
pub equipment: [CharEnumItem; 19],
/// The pet's `CreatureDisplayInfo.dbc` id — the companion the select screen stands beside the
/// character. **0 = no pet**, and that one test is the whole client-side gate: the server
/// already suppresses the triple for anything but a living hunter/warlock (vmangos
/// `Player::BuildEnumData` — `!GHOST && (CLASS_WARLOCK || CLASS_HUNTER)`), so no class or
/// ghost check belongs here.
/// The pet's `CreatureDisplayInfo.dbc` id, 0 for none. The server already zeroes it for all but
/// a living hunter or warlock (`Player::BuildEnumData`), so the client needs no class check.
pub pet_display_id: u32,
/// The pet's level, and its `CreatureFamily.dbc` id. Carried because they are the record's own
/// fields; **nothing renders them today** — the select screen's pet is display-id-driven.
/// The pet's level; `pet_family` is its `CreatureFamily.dbc` id.
pub pet_level: u32,
pub pet_family: u32,
}
@@ -1,11 +1,6 @@
//! Skill-line messages — the skills pane's one send verb, `CMSG_UNLEARN_SKILL` (0x202, vmangos
//! `Opcodes.cpp`'s `DEFINE_HANDLER` → `HandleUnlearnSkillOpcode`). Body from vmangos
//! `Server/Packets/Skill.h` (`UnlearnSkill { uint32 skillId }`); semantics from
//! `Handlers/SkillHandler.cpp`: the server gates on the requested line's `SkillRaceClassInfo.flags
//! & SKILL_FLAG_UNLEARNABLE (0x20)` — a request without it is DROPPED and anticheat-flagged, so
//! the client must only ever offer the unlearn where that bit is set (the skills pane's
//! `abandonable` predicate, `benilla-formats::skill_lines`) — and answers `SetSkill(id, 0, 0)`:
//! the removal comes back as a `PLAYER_SKILL_INFO` field update, never an ack packet.
//! Skill-line messages: the skills pane's `CMSG_UNLEARN_SKILL` (0x202). `HandleUnlearnSkillOpcode`
//! drops and anticheat-flags a request for a line without `SKILL_FLAG_UNLEARNABLE` (0x20) in its
//! `SkillRaceClassInfo.flags`; a removal comes back as a `PLAYER_SKILL_INFO` update, not an ack.
/// Body of `CMSG_UNLEARN_SKILL`: one `u32` skill line id.
pub fn unlearn_skill(skill_id: u32) -> Vec<u8> {
+66 -180
View File
@@ -1,66 +1,37 @@
//! The social family — the friend list, the ignore list, and `/who` (opcodes 0x62–0x6d;
//! decision 0668).
//!
//! Like the duel family, **both ends are pinned at the bytes**. The server side is vmangos
//! `SocialMgr.{h,cpp}` (`PlayerSocial::SendFriendList`/`SendIgnoreList`,
//! `SocialMgr::SendFriendStatus`), `Server/Packets/Social.{h,cpp}` (every `AppendBodyTo`),
//! `Server/Packets/Misc.{h,cpp}` (the five client reads) and `Handlers/MiscHandler.cpp`
//! (`HandleWhoOpcode`'s `WhoListClientQueryTask` + the add/del handlers). The client side is
//! WoW.exe's own `FriendList.cpp` TU, recorded VERIFIED in wow-re `system/net/scratch/w2b.md`
//! (the 0x728-byte `FriendList` object `DAT_00c28168`, its six registered SMSG handlers, and the
//! Lua glue):
//!
//! | opcode | client fn | what it reads / writes |
//! |---|---|---|
//! | `SMSG_FRIEND_LIST` 0x67 | `FriendList::ParseFriendList 0x5ae350` | `u8` count, per slot `{guid u64, online u8, area/level/class u32}` |
//! | `SMSG_FRIEND_STATUS` 0x68 | `0x5add30` → `HandleResult 0x5acab0` | `u8` op + `u64` guid (+ the online tail) |
//! | `SMSG_IGNORE_LIST` 0x6b | the ignore-slot half of the same object | `u8` count, `u64` guids into the 25 slots @+0x650 |
//! | `SMSG_WHO` 0x63 | `0x5adde0` | count-led entry list → FrameScript |
//!
//! Two client-side facts that shape everything above this layer, both from that TU:
//!
//! - **The friend/ignore lists are guids, not names.** A slot holds `{status, note*, guid, area,
//! level, class}` — no name — and the client resolves the display name through the **ObjectMgr
//! name cache** (`0x55f080`, the `CMSG_NAME_QUERY` cache) at format time. benilla's
//! [`NameCache`](../../../benilla/src/names.rs) plays that role, which is why a friend row can
//! populate a frame late rather than never.
//! - **The client's own capacity is 50 friends / 25 ignores** (the slot arrays at `+0x008` and
//! `+0x650`), exactly vmangos's `SOCIALMGR_FRIEND_LIMIT` / `SOCIALMGR_IGNORE_LIMIT`. The
//! FrameXML's `MAX_IGNORE = 50` is dead data — the engine cap is 25.
//! The social family: friend list, ignore list and `/who` (0x62-0x6d). The 1.12 client holds 50
//! friends and 25 ignores (the `FriendList` slot arrays at `+0x008` and `+0x650`), the vmangos
//! limits too; FrameXML's `MAX_IGNORE = 50` is dead data.
use std::io::{self, Read};
use crate::wire::{capacity_hint, read_cstring, read_u32_le, read_u64_le, read_u8};
/// `SMSG_FRIEND_STATUS`'s result byte — vmangos's `FriendsResult` (`SocialMgr.h:81-110`), which is
/// the client's own enum: `FriendList::HandleResult 0x5acab0` switches on it and displays
/// `0x496720`'s catalog ids `0x104..0x114` (one per result, `ADDED_ONLINE`/`ADDED_OFFLINE`
/// sharing `ERR_FRIEND_ADDED_S`). Every one of those rows routes **kind 0 = the chat frame**, not
/// the red error line — see [`crate::messages`]'s consumer for that mapping.
/// `SMSG_FRIEND_STATUS`'s result byte, vmangos `FriendsResult` (`SocialMgr.h:81-110`). The client
/// (`0x5acab0`) prints each result's `ERR_*` string to the chat frame, not the red error line.
pub mod friend_result {
/// `ERR_FRIEND_DB_ERROR` — the server's lookup failed.
/// `ERR_FRIEND_DB_ERROR`: the server's lookup failed.
pub const DB_ERROR: u8 = 0x00;
/// `ERR_FRIEND_LIST_FULL` — 50 friends already.
/// `ERR_FRIEND_LIST_FULL`: 50 friends already.
pub const LIST_FULL: u8 = 0x01;
/// `ERR_FRIEND_ONLINE_SS` — a friend just logged in (broadcast, not an ack).
/// `ERR_FRIEND_ONLINE_SS`: a friend just logged in (broadcast, not an ack).
pub const ONLINE: u8 = 0x02;
/// `ERR_FRIEND_OFFLINE_S` — a friend just logged out (broadcast).
/// `ERR_FRIEND_OFFLINE_S`: a friend just logged out (broadcast).
pub const OFFLINE: u8 = 0x03;
/// `ERR_FRIEND_NOT_FOUND` — no character by that name.
/// `ERR_FRIEND_NOT_FOUND`: no character by that name.
pub const NOT_FOUND: u8 = 0x04;
/// `ERR_FRIEND_REMOVED_S` — the ack for `CMSG_DEL_FRIEND`.
/// `ERR_FRIEND_REMOVED_S`: the ack for `CMSG_DEL_FRIEND`.
pub const REMOVED: u8 = 0x05;
/// `ERR_FRIEND_ADDED_S`, and the added friend is online — carries the online tail.
/// `ERR_FRIEND_ADDED_S`, friend online: carries the online tail.
pub const ADDED_ONLINE: u8 = 0x06;
/// `ERR_FRIEND_ADDED_S`, added while offline — no tail.
/// `ERR_FRIEND_ADDED_S`, friend offline: no tail.
pub const ADDED_OFFLINE: u8 = 0x07;
/// `ERR_FRIEND_ALREADY_S`.
pub const ALREADY: u8 = 0x08;
/// `ERR_FRIEND_SELF`.
pub const SELF: u8 = 0x09;
/// `ERR_FRIEND_WRONG_FACTION` — cross-faction friending, refused by config.
/// `ERR_FRIEND_WRONG_FACTION`: cross-faction friending, refused by config.
pub const ENEMY: u8 = 0x0A;
/// `ERR_IGNORE_FULL` — 25 ignores already.
/// `ERR_IGNORE_FULL`: 25 ignores already.
pub const IGNORE_FULL: u8 = 0x0B;
/// `ERR_IGNORE_SELF`.
pub const IGNORE_SELF: u8 = 0x0C;
@@ -68,21 +39,19 @@ pub mod friend_result {
pub const IGNORE_NOT_FOUND: u8 = 0x0D;
/// `ERR_IGNORE_ALREADY_S`.
pub const IGNORE_ALREADY: u8 = 0x0E;
/// `ERR_IGNORE_ADDED_S` — the ack for `CMSG_ADD_IGNORE`.
/// `ERR_IGNORE_ADDED_S`: the ack for `CMSG_ADD_IGNORE`.
pub const IGNORE_ADDED: u8 = 0x0F;
/// `ERR_IGNORE_REMOVED_S` — the ack for `CMSG_DEL_IGNORE`.
/// `ERR_IGNORE_REMOVED_S`: the ack for `CMSG_DEL_IGNORE`.
pub const IGNORE_REMOVED: u8 = 0x10;
/// `ERR_IGNORE_AMBIGUOUS`.
pub const IGNORE_AMBIGUOUS: u8 = 0x11;
/// `ERR_FRIEND_ERROR` — "Unknown friend response from server."; vmangos's `FRIEND_UNKNOWN`.
/// `ERR_FRIEND_ERROR` ("Unknown friend response from server."), vmangos `FRIEND_UNKNOWN`.
pub const UNKNOWN: u8 = 0x1A;
}
/// A friend slot's presence byte — vmangos's `FriendStatus` (`SocialMgr.h:36-43`). `0` is the
/// only value the wire treats specially (it suppresses the area/level/class tail); the rest are
/// the AFK/DND flavours the friends list renders as its `<AFK>` / `<DND>` tag.
/// A friend's presence byte, vmangos `FriendStatus` (`SocialMgr.h:36-43`).
pub mod friend_status {
/// Offline — the entry carries no area/level/class.
/// Offline: the entry carries no area/level/class.
pub const OFFLINE: u8 = 0;
/// Online, no away flag.
pub const ONLINE: u8 = 1;
@@ -92,42 +61,29 @@ pub mod friend_status {
pub const DND: u8 = 4;
}
/// One row of the friend list — a guid plus, when the friend is online, where they are.
///
/// The name is deliberately absent: it is not on the wire (see the module doc's first client-side
/// fact) and is resolved from the name cache by the consumer.
/// One friend-list row. The name is not on the wire: the client resolves it through its name cache
/// (`0x55f080`, filled by `CMSG_NAME_QUERY`) when it draws the row.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FriendEntry {
/// The friend's player guid. vmangos rebuilds it as `ObjectGuid(HIGHGUID_PLAYER, lowguid)`,
/// so it is always a player guid.
/// Always a player guid: vmangos rebuilds it as `ObjectGuid(HIGHGUID_PLAYER, lowguid)`.
pub guid: u64,
/// [`friend_status`] — `OFFLINE` means the three fields below were **not** sent and read back
/// as zero.
/// A [`friend_status`]; when `OFFLINE`, the fields below were not sent and read as zero.
pub status: u8,
/// The friend's zone id (`AreaTable.dbc`), `0` when offline.
/// The friend's zone id (`AreaTable.dbc`).
pub area: u32,
/// The friend's level, `0` when offline.
pub level: u32,
/// The friend's class id, `0` when offline.
pub class: u32,
}
impl FriendEntry {
/// Is this friend online at all (any of ONLINE/AFK/DND)? The wire's own test — vmangos writes
/// the area/level/class tail under exactly `if (entry.status)`, and the client's parse reads
/// it under the same condition.
/// Any of ONLINE/AFK/DND: the wire's own test for whether the area/level/class tail is sent.
pub fn is_online(&self) -> bool {
self.status != friend_status::OFFLINE
}
}
/// Read `SMSG_FRIEND_LIST` (VERIFIED vmangos `Social.cpp`
/// `WorldPackets::Social::FriendList::AppendBodyTo`, and WoW.exe
/// `FriendList::ParseFriendList 0x5ae350`): a `u8` count, then per friend a `u64` guid and a `u8`
/// status — and the `u32` area/level/class triple **only when the status is non-zero**.
///
/// The conditional tail is why this cannot be a fixed-stride read: an offline friend is 9 bytes,
/// an online one 21.
/// Read `SMSG_FRIEND_LIST` (vmangos `Social.cpp`, client `0x5ae350`): a `u8` count, then per
/// friend a `u64` guid, a `u8` status and, only if the status is non-zero, `u32` area/level/class.
pub fn read_friend_list(r: &mut impl Read) -> io::Result<Vec<FriendEntry>> {
let count = read_u8(r)?;
// vmangos `SOCIALMGR_FRIEND_LIMIT` 50 (`SocialMgr.h:114`).
@@ -148,53 +104,36 @@ pub fn read_friend_list(r: &mut impl Read) -> io::Result<Vec<FriendEntry>> {
Ok(friends)
}
/// Read `SMSG_IGNORE_LIST` (VERIFIED vmangos `Social.cpp`
/// `WorldPackets::Social::IgnoreList::AppendBodyTo`): a `u8` count then that many full guids, and
/// nothing else — an ignore slot in the client is 8 bytes wide for the same reason (the `+0x650`
/// array).
/// Read `SMSG_IGNORE_LIST` (vmangos `Social.cpp`): a `u8` count, then that many guids.
pub fn read_ignore_list(r: &mut impl Read) -> io::Result<Vec<u64>> {
let count = read_u8(r)?;
(0..count).map(|_| read_u64_le(r)).collect()
}
/// `SMSG_FRIEND_STATUS` — one result code about one player, and (for the two "is online" results)
/// where they are.
/// `SMSG_FRIEND_STATUS`: one result about one player, with their whereabouts when online.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FriendStatusUpdate {
/// A [`friend_result`] code. It is both an ack (we asked to add/remove) and a broadcast (a
/// friend logged in or out) — the code, not the opcode, says which.
/// A [`friend_result`] code, either an ack or a login/logout broadcast; the code says which.
pub result: u8,
/// The player the result is about. Note vmangos sends the *asked-for* guid even for
/// `NOT_FOUND`, where it is `0`.
/// The player the result is about; 0 for `NOT_FOUND`.
pub guid: u64,
/// The online tail, present iff the result is [`friend_result::ONLINE`] or
/// [`friend_result::ADDED_ONLINE`] (VERIFIED `SocialMgr::SendFriendStatus`'s switch).
/// Present only for [`friend_result::ONLINE`] and [`friend_result::ADDED_ONLINE`].
pub online: Option<FriendOnline>,
}
/// The presence tail `SMSG_FRIEND_STATUS` carries for an online friend — the same four fields a
/// [`FriendEntry`] holds, so a status update can refresh a list row in place.
/// The presence tail of an online `SMSG_FRIEND_STATUS`: the four fields a [`FriendEntry`] holds.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FriendOnline {
/// [`friend_status`] — 1.12 sends this byte (the `SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_8_4`
/// arm; the 1.8 wire had no status byte here).
/// A [`friend_status`] (the vmangos `SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_8_4` arm).
pub status: u8,
/// Zone id.
pub area: u32,
/// Level.
pub level: u32,
/// Class id.
pub class: u32,
}
/// Read `SMSG_FRIEND_STATUS` (VERIFIED vmangos `Social.cpp`
/// `WorldPackets::Social::FriendStatus::AppendBodyTo` + `SocialMgr::SendFriendStatus`; client
/// handler `0x5add30`, which reads `u8` op + `u64` guid before handing off to
/// `HandleResult 0x5acab0`).
///
/// The tail is read on the *result code*, not on a length check: only `FRIEND_ADDED_ONLINE` and
/// `FRIEND_ONLINE` carry it. Reading it on any other code would desynchronise the parse, which is
/// exactly why the condition is spelled out rather than "read if bytes remain".
/// Read `SMSG_FRIEND_STATUS` (vmangos `SocialMgr::SendFriendStatus`, client `0x5add30`): `u8`
/// result, `u64` guid, then the online tail when the result code says so, whatever the length.
pub fn read_friend_status(r: &mut impl Read) -> io::Result<FriendStatusUpdate> {
let result = read_u8(r)?;
let guid = read_u64_le(r)?;
@@ -215,43 +154,32 @@ pub fn read_friend_status(r: &mut impl Read) -> io::Result<FriendStatusUpdate> {
})
}
/// One `/who` hit. Unlike the friend list, every field is on the wire — a who result is a
/// snapshot the server composed, not a reference into any client cache.
/// One `/who` hit; unlike a friend row, every field is on the wire.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct WhoEntry {
/// Character name.
pub name: String,
/// Guild name, empty when unguilded (vmangos writes `GetGuildNameById(0)` = `""`).
/// An empty cstring when unguilded.
pub guild: String,
/// Level.
pub level: u32,
/// Class id.
pub class: u32,
/// Race id.
pub race: u32,
/// Zone id (`AreaTable.dbc`) — the server sends the id, the client names it.
/// `AreaTable.dbc` zone id; the client resolves the name.
pub zone: u32,
}
/// `SMSG_WHO` — the results, plus the two counts the "N players total (M displayed)" line needs.
/// `SMSG_WHO`: the results and the two counts of the "N players total (M displayed)" line.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct WhoResults {
/// How many entries follow (`clientCount`, ≤ 49 — vmangos breaks the walk at 49 despite the
/// "50 is maximum" comment).
/// Entries that follow (`clientCount`); vmangos stops at 49.
pub displayed: u32,
/// How many matched in total: the full match count when it exceeds 49, otherwise the same as
/// [`Self::displayed`] (VERIFIED `data.put(4, count > 49 ? count : clientCount)`).
/// The full match count when over 49, else equal to [`Self::displayed`].
pub total: u32,
/// The entries themselves, server order.
/// In server order.
pub entries: Vec<WhoEntry>,
}
/// Read `SMSG_WHO` (VERIFIED vmangos `MiscHandler.cpp`'s `WhoListClientQueryTask::operator()`):
/// two `u32` counts, then `displayed` entries of `{name, guild, level, class, race, zone}`.
///
/// 1.12 does **not** carry the trailing party-status `u32` — vmangos writes it only under
/// `SUPPORTED_CLIENT_BUILD <= CLIENT_BUILD_1_8_4`, and its own comment calls it "not actually
/// displayed anywhere".
/// Read `SMSG_WHO` (vmangos `WhoListClientQueryTask`, client `0x5adde0`): two `u32` counts, then
/// `{name, guild, level, class, race, zone}` per entry. 1.12 has no trailing party-status `u32`.
pub fn read_who(r: &mut impl Read) -> io::Result<WhoResults> {
let displayed = read_u32_le(r)?;
let total = read_u32_le(r)?;
@@ -273,31 +201,25 @@ pub fn read_who(r: &mut impl Read) -> io::Result<WhoResults> {
})
}
/// A `/who` query, already parsed out of the filter string the player typed.
///
/// The *parsing* is engine work in the real client too (the FrameXML hands `SendWho` a raw string
/// like `z-"Elwynn Forest" 1-10`), and it needs `AreaTable.dbc` to turn a zone name into the id
/// the wire wants — so benilla parses it app-side, where the DBCs live, and this struct is the
/// result. [`Default`] is the query the client sends for a bare `/who` with no terms.
/// A `/who` query parsed from the typed filter, e.g. `z-"Elwynn Forest" 1-10`, which the 1.12
/// engine (not FrameXML) parses for `SendWho`. [`Default`] is a bare `/who`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WhoRequest {
/// Inclusive minimum level.
pub level_min: u32,
/// Inclusive maximum level. The real client's un-set value is **100** (vmangos's own comment:
/// "client send in case not set max level value 100"), not 255 — so that is the default here.
/// Inclusive maximum level; the 1.12 client sends 100 when unset, not 255.
pub level_max: u32,
/// `n-` — a name substring, empty for "any".
/// `n-`: a name substring, empty for any.
pub player_name: String,
/// `g-` — a guild-name substring, empty for "any".
/// `g-`: a guild-name substring, empty for any.
pub guild_name: String,
/// `r-` — a bitmask over race ids (`1 << race`), all-ones for "any".
/// `r-`: a bitmask over race ids (`1 << race`), all-ones for any.
pub race_mask: u32,
/// `c-` — a bitmask over class ids (`1 << class`), all-ones for "any".
/// `c-`: a bitmask over class ids (`1 << class`), all-ones for any.
pub class_mask: u32,
/// `z-` — zone ids, resolved from names client-side. The server refuses more than 10.
/// `z-`: zone ids, resolved from names client-side.
pub zones: Vec<u32>,
/// Untagged words: the server matches each against name, guild **and** zone name (any hit
/// keeps the row). Refused past 4.
/// Untagged words; the server matches each against name, guild and zone name.
pub search_terms: Vec<String>,
}
@@ -316,20 +238,13 @@ impl Default for WhoRequest {
}
}
/// The server's own limits on a `/who` (`HandleWhoOpcode`: `> 10` zones or `> 4` terms and the
/// whole packet is dropped, silently). Trimming to them client-side turns a dropped query into a
/// narrower one.
/// Over 10 zones or 4 search terms, `HandleWhoOpcode` silently drops the whole query.
pub const WHO_MAX_ZONES: usize = 10;
/// See [`WHO_MAX_ZONES`].
pub const WHO_MAX_SEARCH_TERMS: usize = 4;
/// Body of `CMSG_WHO` (VERIFIED vmangos `Misc.cpp` `WorldPackets::Misc::Who::ReadFromWorldPacket`):
/// `levelMin`, `levelMax`, name cstring, guild cstring, `raceMask`, `classMask`, then a counted
/// `u32` zone array and a counted cstring array — in that order.
///
/// Both arrays are trimmed to the server's caps ([`WHO_MAX_ZONES`], [`WHO_MAX_SEARCH_TERMS`]):
/// over them the handler `return`s before doing anything, so an untrimmed query would look
/// exactly like a `/who` that matched nobody.
/// Body of `CMSG_WHO` (vmangos `Misc.cpp`, `Who::ReadFromWorldPacket`). Both arrays are trimmed to
/// the server's caps, so an over-long query narrows instead of matching nobody.
pub fn who(request: &WhoRequest) -> Vec<u8> {
let zones = &request.zones[..request.zones.len().min(WHO_MAX_ZONES)];
let terms = &request.search_terms[..request.search_terms.len().min(WHO_MAX_SEARCH_TERMS)];
@@ -352,43 +267,34 @@ pub fn who(request: &WhoRequest) -> Vec<u8> {
body
}
/// Body of `CMSG_FRIEND_LIST` (VERIFIED vmangos `Opcodes.cpp` — `NullClientPacket`): empty. The
/// list also arrives unasked at login (`CharacterHandler.cpp:527`), so this is a refresh, which is
/// exactly what the FrameXML's `ShowFriends()` is for.
/// Body of `CMSG_FRIEND_LIST`: empty. The `ShowFriends()` refresh; the list also arrives unasked
/// at login (`CharacterHandler.cpp:527`).
pub fn friend_list() -> Vec<u8> {
Vec::new()
}
/// Body of `CMSG_ADD_FRIEND` (VERIFIED vmangos `Misc.cpp` `AddFriend::ReadFromWorldPacket`): one
/// cstring, the name to befriend. The server normalises the case itself
/// (`normalizePlayerName`), so the client need not.
/// Body of `CMSG_ADD_FRIEND`: the name; the server normalises its case (`normalizePlayerName`).
pub fn add_friend(name: &str) -> Vec<u8> {
cstring_body(name)
}
/// Body of `CMSG_DEL_FRIEND` (VERIFIED vmangos `Misc.cpp` `DelFriend::ReadFromWorldPacket`): one
/// full guid. Removal is by **guid**, not name — which is why the client keeps the list keyed by
/// guid and resolves names for display only.
/// Body of `CMSG_DEL_FRIEND`: removal is by guid, not name.
pub fn del_friend(guid: u64) -> Vec<u8> {
guid.to_le_bytes().to_vec()
}
/// Body of `CMSG_ADD_IGNORE` (VERIFIED vmangos `Misc.cpp` `AddIgnore::ReadFromWorldPacket`): one
/// cstring, the name to ignore.
/// Body of `CMSG_ADD_IGNORE`: the name to ignore.
pub fn add_ignore(name: &str) -> Vec<u8> {
cstring_body(name)
}
/// Body of `CMSG_DEL_IGNORE` (VERIFIED vmangos `Misc.cpp` `DelIgnore::ReadFromWorldPacket`): one
/// full guid, the [`del_friend`] shape.
/// Body of `CMSG_DEL_IGNORE`: removal is by guid.
pub fn del_ignore(guid: u64) -> Vec<u8> {
guid.to_le_bytes().to_vec()
}
/// Body of `CMSG_SET_LOOKING_FOR_GROUP` (VERIFIED at the bytes, wow-re `lfg-set-get-law.md` §5,
/// `0x4e88c0`): the three slot words as stored (`u32` LE each), then the comment as a
/// NUL-terminated string, appended unconditionally — a packet, once sent, always carries it. The
/// reference sends only when the commit changed something, which the caller decides.
/// Body of `CMSG_SET_LOOKING_FOR_GROUP` (`0x4e88c0`): the three slot words as stored, then the
/// comment cstring, always. The 1.12 client sends it only when a commit changed something.
pub fn set_looking_for_group(slots: [u32; 3], comment: &str) -> Vec<u8> {
let mut body = Vec::with_capacity(12 + comment.len() + 1);
for slot in slots {
@@ -398,14 +304,12 @@ pub fn set_looking_for_group(slots: [u32; 3], comment: &str) -> Vec<u8> {
body
}
/// A body that is exactly one NUL-terminated string.
fn cstring_body(s: &str) -> Vec<u8> {
let mut body = Vec::with_capacity(s.len() + 1);
push_cstring(&mut body, s);
body
}
/// Append a NUL-terminated string to `body`.
fn push_cstring(body: &mut Vec<u8>, s: &str) {
body.extend_from_slice(s.as_bytes());
body.push(0);
@@ -415,7 +319,6 @@ fn push_cstring(body: &mut Vec<u8>, s: &str) {
mod tests {
use super::*;
/// `CMSG_SET_LOOKING_FOR_GROUP`: three slot words then the comment, NUL-terminated (1961).
#[test]
fn set_looking_for_group_is_three_words_and_a_cstring() {
let body = set_looking_for_group([0, 0, 0], "LF2M UBRS");
@@ -428,9 +331,6 @@ mod tests {
});
}
/// `SMSG_FRIEND_LIST`: the area/level/class tail rides on the status byte, so an offline and
/// an online entry have different widths in the same packet — the parse has to follow it or
/// every later entry is garbage.
#[test]
fn friend_list_tail_is_conditional_on_the_status_byte() {
let mut body = vec![2u8]; // count
@@ -463,7 +363,6 @@ mod tests {
assert!(!friends[0].is_online() && friends[1].is_online());
}
/// `SMSG_IGNORE_LIST` is a `u8` count and bare guids.
#[test]
fn ignore_list_is_a_counted_guid_array() {
let mut body = vec![2u8];
@@ -475,8 +374,6 @@ mod tests {
);
}
/// `SMSG_FRIEND_STATUS`: only ONLINE and ADDED_ONLINE carry the presence tail. A REMOVED with
/// trailing bytes must still read as tail-less — the code decides, not the length.
#[test]
fn friend_status_tail_is_keyed_on_the_result_code() {
let mut online = vec![friend_result::ONLINE];
@@ -507,8 +404,6 @@ mod tests {
assert_eq!(status.online, None);
}
/// `SMSG_WHO`: two counts, then name/guild cstrings and four `u32`s per entry. `total` can
/// exceed `displayed` — that difference is what the "(N displayed)" suffix reports.
#[test]
fn who_results_carry_both_counts() {
let mut body = Vec::new();
@@ -538,8 +433,6 @@ mod tests {
);
}
/// An unguilded who row still carries its (empty) guild cstring — dropping it would eat the
/// next field.
#[test]
fn who_reads_the_empty_guild_string() {
let mut body = Vec::new();
@@ -556,8 +449,6 @@ mod tests {
assert_eq!(who.entries[0].level, 5);
}
/// `CMSG_WHO`'s field order, byte for byte — the golden for the one body in this family the
/// server actually parses field-by-field.
#[test]
fn who_request_body_is_byte_exact() {
let request = WhoRequest {
@@ -584,8 +475,6 @@ mod tests {
assert_eq!(who(&request), want);
}
/// The default query is the bare `/who`: every level up to the client's own 100, every race
/// and class, no terms.
#[test]
fn the_default_who_query_is_the_clients_unset_shape() {
let body = who(&WhoRequest::default());
@@ -599,8 +488,6 @@ mod tests {
assert_eq!(body.len(), 26);
}
/// Both arrays are trimmed to the server's caps: past them `HandleWhoOpcode` drops the whole
/// packet, so an over-long query would silently return nothing at all.
#[test]
fn who_trims_to_the_servers_caps() {
let request = WhoRequest {
@@ -616,7 +503,6 @@ mod tests {
assert_eq!(term_count as usize, WHO_MAX_SEARCH_TERMS);
}
/// The four small client bodies: two cstrings, two bare guids.
#[test]
fn the_add_and_del_bodies() {
assert_eq!(add_friend("Bob"), b"Bob\0");
@@ -1,28 +1,12 @@
//! Spell-book and cooldown messages — what the character knows, and when they may use it again.
//! Split out of `messages/spells.rs` (decision 0640).
//!
//! Book and cooldowns share a file because they share a **packet**: `SMSG_INITIAL_SPELLS` delivers
//! the known-spell list and the active-cooldown list in one body, so [`read_initial_spells`] cannot
//! be separated from [`SpellCooldown`] at the wire. The book then grows through
//! `SMSG_LEARNED_SPELL` / `SMSG_SUPERCEDED_SPELL` (a trainer purchase, a quest reward, a level-up
//! rank gain — decision 0237), and the four cooldown packets adjust it from the server side.
//!
//! Worth knowing before reaching for these: a **normal cast's cooldown is CLIENT-tracked**. The
//! server only sends cooldowns for school lockouts, pets, item procs and GM resets — so these
//! packets are the exceptions, not the mechanism. Layouts VERIFIED against vmangos and, for the four
//! cooldown packets, against the real client's own handlers (wow-re `wave-handlers.md`).
//!
//! This family is inbound only; the outbound half of "what I know" is
//! `world::writer::{skills, progression}`.
//! Spell-book and cooldown messages, all inbound. A normal cast's cooldown is tracked by the
//! client; the server sends cooldowns only for school lockouts, pets, item procs and GM resets.
use std::io::{self, Read};
use crate::wire::{capacity_hint, read_u16_le, read_u32_le, read_u64_le, read_u8};
/// One active cooldown from `SMSG_INITIAL_SPELLS`' second list (vmangos `SendInitialSpells`):
/// `u16 spell, u16 castItem, u16 category, u32 spellCdMs, u32 categoryCdMs`. A *permanent*
/// cooldown (a one-per-fight ability the server re-arms) is `spell_cd_ms == 1` with the category
/// word's top bit set.
/// One active cooldown from `SMSG_INITIAL_SPELLS` (vmangos `SendInitialSpells`). A permanent one,
/// re-armed by the server, is `spell_cd_ms == 1` with the category word's top bit set.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SpellCooldown {
pub spell_id: u16,
@@ -32,14 +16,12 @@ pub struct SpellCooldown {
pub category_cd_ms: u32,
}
/// Read `SMSG_INITIAL_SPELLS` (vmangos `Player::SendInitialSpells`): `u8 0; u16 n; n×(u16 spellId,
/// u16 0); u16 m; m×`[`SpellCooldown`]. The per-spell second word is "not slot id" (vmangos's own
/// note) and always 0 — skipped.
/// Read `SMSG_INITIAL_SPELLS` (vmangos `Player::SendInitialSpells`): `u8 0`, a `u16` count of
/// `{u16 spellId, u16 0}`, then a `u16` count of [`SpellCooldown`]s.
pub(super) fn read_initial_spells(r: &mut impl Read) -> io::Result<(Vec<u16>, Vec<SpellCooldown>)> {
let _ = read_u8(r)?;
let n = read_u16_le(r)?;
// No protocol bound on either list (`Player::SendInitialSpells` walks the spell map); a
// 1.12 spellbook is a few hundred entries, so 1024 is generous.
// No protocol bound on either list; a 1.12 spellbook holds a few hundred spells.
let mut spells = Vec::with_capacity(capacity_hint(n, 1024));
for _ in 0..n {
spells.push(read_u16_le(r)?);
@@ -59,40 +41,28 @@ pub(super) fn read_initial_spells(r: &mut impl Read) -> io::Result<(Vec<u16>, Ve
Ok((spells, cooldowns))
}
/// Read `SMSG_LEARNED_SPELL` (vmangos `WorldPackets::Spell::LearnedSpell::AppendBodyTo`,
/// `Server/Packets/Spell.cpp:175-179`): `u16 spellId, u16 actionBarSlot`. The slot is "not used on
/// client" (vmangos's own note) and dropped. This is the one wire that grows the spell book *after*
/// login — a trainer purchase, a quest reward, a level-up rank gain (decision 0237); benilla's book
/// was otherwise login-only ([`read_initial_spells`]).
/// Read `SMSG_LEARNED_SPELL` (vmangos `Spell.cpp:175-179`): `u16 spellId`, then an action-bar slot
/// the client does not use. The one message that adds to the book after login.
pub(super) fn read_learned_spell(r: &mut impl Read) -> io::Result<u16> {
let spell_id = read_u16_le(r)?;
let _action_bar_slot = read_u16_le(r)?;
Ok(spell_id)
}
/// Read `SMSG_REMOVED_SPELL` (vmangos `RemovedSpell::AppendBodyTo`, `Server/Packets/Spell.cpp:181`):
/// a bare `u16 spellId`, and **no action-bar slot** — unlike its `SMSG_LEARNED_SPELL` sibling above,
/// which pads one on. The spell is gone from the book; what happens to a bar button still pointing
/// at it is a separate law (decision 1584).
/// Read `SMSG_REMOVED_SPELL` (vmangos `Spell.cpp:181`): a bare `u16 spellId`, no action-bar slot.
pub(super) fn read_removed_spell(r: &mut impl Read) -> io::Result<u16> {
read_u16_le(r)
}
/// Read `SMSG_SUPERCEDED_SPELL` (vmangos `SupercededSpell::AppendBodyTo`, `Spell.cpp:169-173`): `u16
/// oldSpellId, u16 newSpellId` — a rank-up replaces the old spell with the new one in both the book
/// and the action bar (decision 0237).
/// Read `SMSG_SUPERCEDED_SPELL` (vmangos `Spell.cpp:169-173`): `(oldSpellId, newSpellId)`; a
/// rank-up replaces the old spell in both the book and the action bar.
pub(super) fn read_superceded_spell(r: &mut impl Read) -> io::Result<(u16, u16)> {
Ok((read_u16_le(r)?, read_u16_le(r)?))
}
/// Read `SMSG_SPELL_COOLDOWN` → `(caster, Vec<(spell_id, cooldown_ms)>)` (VERIFIED both sides:
/// vmangos `WorldPackets::Spell::SpellCooldown::AppendBodyTo`, `Server/Packets/Spell.cpp:142-151` —
/// a raw `u64` guid then pairs to end-of-body, NO flags byte in 1.12 (vmangos's own commented-out
/// `uint8`); the client handler `0x6e9460` reads exactly guid + `(GetInt32, GetUInt32)*` until the
/// stream runs dry, wow-re `wave-handlers.md`). `cooldown_ms == 0` means "use the spell's own
/// `Spell.dbc` RecoveryTime/CategoryRecoveryTime" (the handler's `cooldownMs!=0` fork); nonzero is
/// a server-set duration (the school-lockout path sends these). vmangos sends this for lockouts
/// (`Player::LockOutSpells`) and pet cooldowns — a normal cast's cooldown is CLIENT-tracked.
/// Read `SMSG_SPELL_COOLDOWN` (vmangos `Spell.cpp:142-151`, client `0x6e9460`): a `u64` caster,
/// then `(spell, ms)` pairs to the end, with no flags byte in 1.12. `ms == 0` means the spell's own
/// `Spell.dbc` recovery times. Sent for school lockouts and pet cooldowns.
pub(super) fn read_spell_cooldown(r: &mut &[u8]) -> io::Result<(u64, Vec<(u32, u32)>)> {
let caster = read_u64_le(r)?;
let mut cooldowns = Vec::new();
@@ -104,29 +74,21 @@ pub(super) fn read_spell_cooldown(r: &mut &[u8]) -> io::Result<(u64, Vec<(u32, u
Ok((caster, cooldowns))
}
/// Read `SMSG_ITEM_COOLDOWN` → `(item_guid, spell_id)` (VERIFIED both sides: vmangos
/// `WorldPackets::Item::ItemCooldown::AppendBodyTo`, `Server/Packets/Item.cpp:229-233` — raw `u64`
/// item guid + `u32` spell id; the client handler `0x6e95d0` resolves the item object and inserts a
/// **fixed 30 000 ms** cooldown on it, wow-re `wave-handlers.md` — the 30 s is the client's
/// hardcode, nothing more rides the wire). Sent when a proc puts an equipped on-use item on its
/// shared 30 s use-cooldown (vmangos `Player.cpp:19370-19383`).
/// Read `SMSG_ITEM_COOLDOWN` (vmangos `Item.cpp:229-233`): `(item_guid, spell_id)`. The client
/// (`0x6e95d0`) gives the item a hardcoded 30 s cooldown; no duration is on the wire.
pub(super) fn read_item_cooldown(r: &mut impl Read) -> io::Result<(u64, u32)> {
Ok((read_u64_le(r)?, read_u32_le(r)?))
}
/// Read `SMSG_COOLDOWN_EVENT` / `SMSG_CLEAR_COOLDOWN` → `(spell_id, caster)` — the two share one
/// body shape (VERIFIED both sides: vmangos `CooldownEvent`/`ClearCooldown::AppendBodyTo`,
/// `Server/Packets/Spell.cpp:152-167` — `u32` spell id THEN raw `u64` guid; the client handler
/// `0x6e9670` reads GetInt32 then GetGuid, wow-re `wave-handlers.md`). EVENT **starts** an on-hold
/// (`SPELL_ATTR_COOLDOWN_ON_EVENT`) record's parked timers now; CLEAR removes the record outright.
/// Read `SMSG_COOLDOWN_EVENT` or `SMSG_CLEAR_COOLDOWN` (vmangos `Spell.cpp:152-167`, client
/// `0x6e9670`): the `u32` spell id first, then the `u64` caster. EVENT starts the timers of an
/// on-hold (`SPELL_ATTR_COOLDOWN_ON_EVENT`) cooldown; CLEAR removes the cooldown.
pub(super) fn read_cooldown_event(r: &mut impl Read) -> io::Result<(u32, u64)> {
Ok((read_u32_le(r)?, read_u64_le(r)?))
}
/// Read `SMSG_COOLDOWN_CHEAT` → the target guid (VERIFIED both sides: vmangos
/// `CooldownCheat::AppendBodyTo` — one raw `u64`; the client handler `0x6e9730` wipes the whole
/// self/pet cooldown list when the guid matches, wow-re `wave-handlers.md`). The GM `.cooldown`
/// reset.
/// Read `SMSG_COOLDOWN_CHEAT`, the GM `.cooldown` reset. The client (`0x6e9730`) clears every
/// cooldown of the self or pet the guid names.
pub(super) fn read_cooldown_cheat(r: &mut impl Read) -> io::Result<u64> {
read_u64_le(r)
}
@@ -135,10 +97,6 @@ pub(super) fn read_cooldown_cheat(r: &mut impl Read) -> io::Result<u64> {
mod tests {
use super::*;
/// The three incremental book packets, side by side, because their bodies differ in exactly the
/// way that bites: LEARNED pads an unused action-bar slot after the id, REMOVED does not pad
/// anything, and SUPERCEDED carries a pair. Reading REMOVED with LEARNED's reader would work by
/// accident on a 4-byte body and starve on the real 2-byte one.
#[test]
fn the_three_book_deltas_have_three_different_bodies() {
// SMSG_LEARNED_SPELL: u16 spell + u16 slot (dropped).
@@ -158,10 +116,7 @@ mod tests {
#[test]
fn cooldown_bodies_golden() {
// SMSG_SPELL_COOLDOWN: raw u64 guid, then (u32 spell, u32 ms) pairs to end-of-body — NO
// flags byte in 1.12 (vmangos's own commented-out uint8; the client handler reads the
// pairs straight after the guid). Guid 0x10, then {133, 0} ("use Spell.dbc") and
// {5384, 30000}.
// SMSG_SPELL_COOLDOWN: guid, then (spell, ms) pairs, no flags byte; {133, 0} = Spell.dbc.
let body: Vec<u8> = [
0x10u64.to_le_bytes().to_vec(),
133u32.to_le_bytes().to_vec(),
@@ -177,8 +132,7 @@ mod tests {
);
assert!(r.is_empty());
// SMSG_ITEM_COOLDOWN: raw u64 item guid + u32 spell id — nothing else (the 30 s is the
// client's hardcode).
// SMSG_ITEM_COOLDOWN: item guid + spell id, nothing else.
let body: Vec<u8> = [
0x40u64.to_le_bytes().to_vec(),
439u32.to_le_bytes().to_vec(),
@@ -187,8 +141,7 @@ mod tests {
let mut r = &body[..];
assert_eq!(read_item_cooldown(&mut r).unwrap(), (0x40, 439));
// SMSG_COOLDOWN_EVENT / SMSG_CLEAR_COOLDOWN: u32 spell id FIRST, then the raw u64 guid
// (vmangos `CooldownEvent::AppendBodyTo`; the client reads GetInt32 then GetGuid).
// SMSG_COOLDOWN_EVENT / SMSG_CLEAR_COOLDOWN: spell id first, then the guid.
let body: Vec<u8> = [
1784u32.to_le_bytes().to_vec(),
0x22u64.to_le_bytes().to_vec(),
+85 -239
View File
@@ -1,25 +1,6 @@
//! The **cast pipeline** — a cast's whole life on the wire: the four outbound `CMSG_CAST_SPELL`
//! shapes, the server's verdict, the start/launch pair every observer sees, the interrupt and
//! pushback notices, the channel timer, and the aura a cast leaves behind. Every layout here is
//! VERIFIED against vmangos source (cited per item); the opcodes are in [`super::opcode`] (verified
//! `Opcodes_1_12_1.h`).
//!
//! This file was once the whole spell/combat/bar/progression wire in one 1061-line block; decision
//! 0640 peeled off [`super::spellbook`], [`super::action_bar`], [`super::attack`],
//! [`super::combat_log`], [`super::progression`] and [`super::pose`], leaving the cast itself.
//! Mirrored by `world::writer::spells`.
//!
//! Two things about the shape that are easy to get wrong and are load-bearing here:
//!
//! - [`SpellCastTargets`]'s decode must follow vmangos' **write**-side branch order bit for bit, not
//! the symmetric-looking read side — the writer's if/else-if chain emits exactly one packed guid
//! when several target bits are set, and guessing differently desyncs the stream.
//! - Nothing about missile travel rides `SMSG_SPELL_GO`. It is sent at **launch**; the server
//! schedules impact itself off `Spell.dbc` Speed (decision 0099).
//!
//! The aura pair lives here rather than in a family of its own because an aura is what a spell
//! leaves behind, and the wire says so: `CMSG_CANCEL_AURA` is addressed **by spell id, not by aura
//! slot**. Aura *state* is descriptor data (`UNIT_FIELD_AURA`), not a packet.
//! The cast pipeline on the wire: the `CMSG_CAST_SPELL` shapes, the server's verdict, the
//! start/launch pair every observer sees, interrupt and pushback notices, the channel timer and the
//! aura messages. Aura state itself is descriptor data (`UNIT_FIELD_AURA`), not a packet.
use std::io::{self, Read};
@@ -28,45 +9,24 @@ use crate::wire::{
read_u64_le, read_u8, Vector3d,
};
/// `SMSG_CAST_RESULT`'s verdict: `u32 spellId, u8 status` — status `0` (`SPELL_RESULT_STATUS_OKAY`)
/// ends the packet, status `2` (`SPELL_RESULT_STATUS_FAIL`) appends a `u8` failure reason and then
/// up to two **reason-specific argument words** (vmangos `CastResult::AppendBodyTo`).
/// `SMSG_CAST_RESULT`'s verdict: status 0 (`OKAY`) ends the packet; status 2 (`FAIL`) adds a `u8`
/// reason and up to two argument words (vmangos `CastResult::AppendBodyTo`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CastOutcome {
Ok,
Failed {
reason: u8,
/// The failure's first argument word, when the server sent one — the `%s` source of the
/// client's argument-formatted messages (the arm table at `0x6e1d8e`): a
/// `SpellFocusObject.dbc` id for `REQUIRES_SPELL_FOCUS` (0x5e), an `AreaTable.dbc` id for
/// `REQUIRES_AREA` (0x5d), an `EquippedItemClass` for the 0x19–0x1b family, a
/// permanent-cooldown flag for `NOT_READY` (0x3c). `None` = the packet ended at the reason.
/// The `%s` of the client's message (`0x6e1d8e`): a `SpellFocusObject.dbc` id for
/// `REQUIRES_SPELL_FOCUS` (0x5e), an `AreaTable.dbc` id for `REQUIRES_AREA` (0x5d), an
/// `EquippedItemClass` for 0x19-0x1b, a permanent-cooldown flag for `NOT_READY` (0x3c).
arg: Option<u32>,
},
}
/// Read `SMSG_CAST_RESULT` → `(spell_id, outcome)`.
///
/// The trailing argument words are **positional, gated on the remaining body length** — and that
/// is the reference's own decode, not merely a convenient one. `CastResultHandler 0x6e7330`
/// (registered at `0x6e7155`) reads `u32 spellId`, `u8 status`, then on `status == 2` a
/// `u8 reason`, then each word only `if cursor < size` (`0x6e737a` and `0x6e738c`, comparing
/// CDataStore `+0x14` against `+0x10`) — never keyed off the reason value (wow-re
/// `system/spell/scratch/cast-fail-strings.md` §WIRE-ARGS, §5 cross-checked). It has to be: vmangos
/// writes `if (arg1 || arg2) << u32 arg1; if (arg2) << u32 arg2;`
/// (`Server/Packets/Spell.cpp`, `CastResult::AppendBodyTo`), so a reason whose argument is
/// legitimately 0 with no second argument behind it sends no word at all, and a reason-keyed decode
/// would desync on exactly that case.
///
/// **The reference's absent value is `-1`, not zero** (`0x6e736e or eax,0xffffffff` initializes both
/// slots before the gated reads). `None` here is that sentinel: every consumer treats it and an
/// out-of-range id the same way — decline the fill — so the two encodings are behaviourally
/// identical, and `Option` says which case it is at the type level.
///
/// The second word is read to keep the shape documented, then dropped: only the
/// `EQUIPPED_ITEM_CLASS*` family (0x19–0x1b) sends one, and that arm's `%s` fill reads the failing
/// spell's own `EquippedItemClass`/`EquippedItemSubClassMask` columns — the same two values the
/// server copied out of `SpellEntry` to build the packet (`Spell::SendCastResult`).
/// Read `SMSG_CAST_RESULT` (client `0x6e7330`). Each argument word is read only while bytes
/// remain, never keyed on the reason, because vmangos omits a zero argument. `None` stands for
/// the client's absent value, -1 (`0x6e736e`). The second word, sent only for 0x19-0x1b, is
/// dropped: the client fills that message from the spell's own `EquippedItem*` columns.
pub(super) fn read_cast_result(r: &mut &[u8]) -> io::Result<(u32, CastOutcome)> {
let spell_id = read_u32_le(r)?;
let status = read_u8(r)?;
@@ -81,10 +41,9 @@ pub(super) fn read_cast_result(r: &mut &[u8]) -> io::Result<(u32, CastOutcome)>
Ok((spell_id, outcome))
}
// --- the spell-visual pipeline wire (decision 0099 phase 1) -----------------------------------------
// --- Start and go --------------------------------------------------------------------------------
//
// `SpellCastTargets` bit flags (vmangos `SpellDefines.h:96-113`, `SpellCastTargetFlags`) needed to
// decode the `write()` shape (`SpellCastTargetsInfo.cpp:180-234`) SMSG_SPELL_START/GO carry.
// vmangos `SpellCastTargetFlags` (`SpellDefines.h:96-113`).
const TARGET_FLAG_UNIT: u16 = 0x0002;
const TARGET_FLAG_ITEM: u16 = 0x0010;
const TARGET_FLAG_SOURCE_LOCATION: u16 = 0x0020;
@@ -95,20 +54,14 @@ const TARGET_FLAG_TRADE_ITEM: u16 = 0x1000;
const TARGET_FLAG_STRING: u16 = 0x2000;
const TARGET_FLAG_CORPSE_ALLY: u16 = 0x8000;
/// A decoded `SpellCastTargets` (vmangos `SpellCastTargetsInfo.cpp:180-234`, the **write** side —
/// asymmetric from the client→server `read()`: when more than one of UNIT/GAMEOBJECT/CORPSE_* is set,
/// the writer's if/else-if chain emits exactly **one** packed guid, by priority UNIT > GAMEOBJECT >
/// CORPSE_ALLY|CORPSE_ENEMY — never happens for a real spell, but the decode must follow the same
/// branch order bit for bit or it desyncs the stream). Surfaces what a consumer needs now
/// (`unit_target`, `go_target`, `dest`); item/corpse/source/string targets are read to keep the cursor
/// aligned and dropped — no consumer for them yet.
/// A decoded `SpellCastTargets` in the server's write order (`SpellCastTargetsInfo.cpp:180-234`),
/// not its read order: any mix of UNIT, GAMEOBJECT and CORPSE_* bits yields one packed guid.
/// Item, corpse, source and string targets are read and dropped.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpellCastTargets {
pub mask: u16,
pub unit_target: Option<u64>,
/// The GameObject this cast targets (`TARGET_FLAG_GAMEOBJECT`) — an open-lock cast on a chest / locked
/// door rides here. Surfaced for the GO lid/door open animation (decision 2271); a unit spell leaves
/// it `None`. Mutually exclusive with `unit_target` in practice (the writer emits one guid).
/// The `TARGET_FLAG_GAMEOBJECT` target, such as the chest an open-lock cast opens.
pub go_target: Option<u64>,
pub dest: Option<Vector3d>,
}
@@ -124,8 +77,7 @@ fn read_spell_cast_targets(r: &mut impl Read) -> io::Result<SpellCastTargets> {
| TARGET_FLAG_CORPSE_ALLY)
!= 0
{
// Exactly one packed guid rides here (see the struct doc); the writer's priority is UNIT >
// GAMEOBJECT, so mirror that branch order (UNIT wins if both bits are set, never in practice).
// One packed guid; the server's priority is UNIT, then GAMEOBJECT, then CORPSE_*.
let guid = read_packed_guid(r)?;
if mask & TARGET_FLAG_UNIT != 0 {
unit_target = Some(guid);
@@ -155,27 +107,20 @@ fn read_spell_cast_targets(r: &mut impl Read) -> io::Result<SpellCastTargets> {
})
}
/// `CAST_FLAG_AMMO` (vmangos `Spell.h:53`) — the projectile-visual bit, set for every ranged spell on
/// both `SMSG_SPELL_START` and `SMSG_SPELL_GO`; its presence gates the trailing ammo block.
/// vmangos `Spell.h:53`: set on START and GO for every ranged spell; gates the ammo block.
const CAST_FLAG_AMMO: u16 = 0x0020;
/// Read the ammo block (`Spell::WriteAmmoToPacket`, `Spell.cpp:4540-4606`): `u32 displayId, u32
/// inventoryType`. Only `displayId` has a consumer today (the projectile model, phase 4/5) —
/// `inventoryType` is read to stay aligned and dropped.
/// Read the ammo block (`Spell.cpp:4540-4606`): `u32 displayId`, `u32 inventoryType` (dropped).
fn read_ammo(r: &mut impl Read) -> io::Result<u32> {
let display_id = read_u32_le(r)?;
let _inventory_type = read_u32_le(r)?;
Ok(display_id)
}
/// One decoded `SMSG_SPELL_START` — a non-triggered cast began, instants included (`cast_time_ms ==
/// 0` — the precast trigger, decision 0099 phase 1). VERIFIED vmangos `Spell::SendSpellStart`
/// (`Spell.cpp:4468-4503`): `item_or_caster` pguid (the cast item's guid when one is in play, else
/// the caster's own — `WriteGuidHelper`, `Spell.cpp:4453-4466`) · `caster` pguid (`m_casterUnit` —
/// **empty, i.e. guid 0, whenever the caster is a GameObject**, which never sets that member;
/// [`crate::events`]' `spell_caster` is what resolves the pair) · `u32 spellId` · `u16 castFlags` (always `CAST_FLAG_UNKNOWN2` 0x2, +
/// `CAST_FLAG_AMMO` 0x20 for a ranged spell) · `u32` remaining cast-time ms (`m_timer`) ·
/// [`SpellCastTargets`] · the ammo block iff `castFlags & 0x20`.
/// `SMSG_SPELL_START` (vmangos `Spell.cpp:4468-4503`): a non-triggered cast began, instants
/// included (`cast_time_ms == 0`). `item_or_caster` is the cast item's guid if any, else the
/// caster's; `caster` is 0 for a GameObject caster. `cast_flags` is always 0x2, plus
/// `CAST_FLAG_AMMO` for a ranged spell.
#[derive(Debug, Clone, PartialEq)]
pub struct SpellStart {
pub item_or_caster: u64,
@@ -210,19 +155,14 @@ pub(super) fn read_spell_start(r: &mut impl Read) -> io::Result<SpellStart> {
})
}
/// `SPELL_MISS_REFLECT` (vmangos `SpellDefines.h:173`) — the one `SpellMissInfo` that carries a
/// trailing byte (the reflected spell's own outcome against its new target).
/// vmangos `SpellDefines.h:173`: the one `SpellMissInfo` followed by a byte, the reflected
/// spell's outcome against its new target.
const SPELL_MISS_REFLECT: u8 = 11;
/// One decoded `SMSG_SPELL_GO` — the cast launched. VERIFIED vmangos `Spell::SendSpellGo`
/// (`Spell.cpp:4505-4538`) + its target-list writer `WriteSpellGoTargets` (`Spell.cpp:4608-4659`):
/// the same guid pair + spellId as [`SpellStart`] (caster slot 2 empty for a GameObject caster —
/// see there) · `u16 castFlags` (always `CAST_FLAG_UNKNOWN9`
/// 0x100, + `CAST_FLAG_AMMO` 0x20 for a ranged spell) · `u8` hit count + that many **raw** (unpacked)
/// `u64` hit guids · `u8` miss count + that many `{u64 guid, u8 SpellMissInfo, u8 reflectResult iff
/// the reason is `SPELL_MISS_REFLECT`}` · [`SpellCastTargets`] · the ammo block iff `castFlags &
/// 0x20`. Sent at **launch** — the server schedules impact itself off `Spell.dbc` Speed, so nothing
/// about missile travel rides this packet (decision 0099).
/// `SMSG_SPELL_GO` (vmangos `Spell.cpp:4505-4538, 4608-4659`): the cast launched. Guids and spell
/// as in [`SpellStart`]; `cast_flags` is always 0x100, plus `CAST_FLAG_AMMO` for a ranged spell.
/// Sent at launch: the server times the impact from `Spell.dbc` Speed, so no missile travel data
/// rides it.
#[derive(Debug, Clone, PartialEq)]
pub struct SpellGo {
pub item_or_caster: u64,
@@ -230,8 +170,7 @@ pub struct SpellGo {
pub spell_id: u32,
pub cast_flags: u16,
pub hits: Vec<u64>,
/// `(guid, SpellMissInfo reason)` — the reflect-outcome byte (present only when `reason ==
/// SPELL_MISS_REFLECT`) is read to stay aligned and dropped; no consumer yet.
/// `(guid, SpellMissInfo)`; a reflect's trailing outcome byte is dropped.
pub misses: Vec<(u64, u8)>,
pub targets: SpellCastTargets,
pub ammo_display_id: Option<u32>,
@@ -243,12 +182,11 @@ pub(super) fn read_spell_go(r: &mut impl Read) -> io::Result<SpellGo> {
let spell_id = read_u32_le(r)?;
let cast_flags = read_u16_le(r)?;
// Both counts are `u8` placeholders the server writes back over (`Spell.cpp:4607-4609`);
// no tighter bound exists.
// Both counts are `u8`s the server backfills (`Spell.cpp:4607-4609`); no tighter bound.
let hit_count = read_u8(r)?;
let mut hits = Vec::with_capacity(capacity_hint(hit_count, usize::from(u8::MAX)));
for _ in 0..hit_count {
hits.push(read_u64_le(r)?); // raw guid (Spell.cpp:4627,4635) — the hit list is never packed
hits.push(read_u64_le(r)?); // raw guid, never packed (Spell.cpp:4627,4635)
}
let miss_count = read_u8(r)?;
let mut misses = Vec::with_capacity(capacity_hint(miss_count, usize::from(u8::MAX)));
@@ -279,68 +217,41 @@ pub(super) fn read_spell_go(r: &mut impl Read) -> io::Result<SpellGo> {
})
}
/// Read `SMSG_SPELL_FAILED_OTHER` → `(caster, spell_id)` (vmangos `Spell::SendInterrupted`,
/// `Spell.cpp:4780-4789`): the broadcast cast-cancel notice for **observers** — a raw (unpacked)
/// `u64` guid + `u32` spellId. Our own cast's failure rides `SMSG_CAST_RESULT` instead;
/// `SMSG_SPELL_FAILURE` is never constructed server-side (decision 0099).
/// Read `SMSG_SPELL_FAILED_OTHER` (vmangos `Spell.cpp:4780-4789`): an observer's cast-cancel
/// notice, raw `u64` caster then spell id. Our own failure rides `SMSG_CAST_RESULT`; vmangos never
/// sends `SMSG_SPELL_FAILURE`.
pub(super) fn read_spell_failed_other(r: &mut impl Read) -> io::Result<(u64, u32)> {
let caster = read_u64_le(r)?;
let spell_id = read_u32_le(r)?;
Ok((caster, spell_id))
}
/// Read `SMSG_SPELL_DELAYED` → `(caster, delay_ms)` (vmangos `Spell::Delayed`, `Spell.cpp:7472`):
/// a raw (unpacked) `u64` caster guid + `u32` pushback time in ms, sent **to the caster** when a
/// pushback-eligible cast takes damage (Fireball carries `DAMAGE_PUSHBACK`; the server extends its
/// own cast timer by `delay_ms`). The cast bar shifts its window out by the same (decision 0256).
/// Read `SMSG_SPELL_DELAYED` (vmangos `Spell.cpp:7472`): raw `u64` caster, `u32` pushback ms. Sent
/// to the caster when damage pushes back a cast; the server extends its own cast timer by as much.
pub(super) fn read_spell_delayed(r: &mut impl Read) -> io::Result<(u64, u32)> {
let caster = read_u64_le(r)?;
let delay_ms = read_u32_le(r)?;
Ok((caster, delay_ms))
}
/// Read `MSG_CHANNEL_START` → `(spell_id, duration_ms)` (vmangos `Spell::SendChannelStart`,
/// `Spell.cpp:4951-4954`): `u32 spellId` + `u32 duration` — **self-only** (SendDirectMessage to
/// the casting player; no guid on the wire). The cast bar's channel-open edge (decision 0137).
/// Read `MSG_CHANNEL_START` (vmangos `Spell.cpp:4951-4954`): `(spell_id, duration_ms)`, sent only
/// to the caster, so no guid.
pub(super) fn read_channel_start(r: &mut impl Read) -> io::Result<(u32, u32)> {
let spell_id = read_u32_le(r)?;
let duration_ms = read_u32_le(r)?;
Ok((spell_id, duration_ms))
}
/// Read `MSG_CHANNEL_UPDATE` → `remaining_ms` (vmangos `Player::SendChannelUpdate`,
/// `Player.cpp:21106-21110`): a single `u32` time-left — **self-only**, `0` = the channel is over
/// (sent on natural end and on interrupt alike). The cast bar's channel tick/close (decision 0137).
/// Read `MSG_CHANNEL_UPDATE` (vmangos `Player.cpp:21106-21110`): ms left, caster only; 0 ends the
/// channel, whether it finished or was interrupted.
pub(super) fn read_channel_update(r: &mut impl Read) -> io::Result<u32> {
read_u32_le(r)
}
/// Read `SMSG_SET_FLAT_SPELL_MODIFIER` / `SMSG_SET_PCT_SPELL_MODIFIER` → `(mask_bit, op, value)`.
///
/// **Exactly 6 bytes, and the first byte is the one that is easy to get backwards.** The single
/// handler `Spell_C::HandleSetSpellModifier 0x6e9950` reads `u8` (`6e9963`), `u8` (`6e996e`), `i32`
/// (`6e9979`) — the widths are the stream accessors' own (`0x418cb0` advances the cursor by 1,
/// `0x418e30` by 4) — and then stores at `imul eax,eax,0x1d; add eax,ecx`, i.e.
/// **`index = field1 * 29 + field2`**. The reader `GetSpellModifiers 0x6e6b30` strength-reduces
/// the same index to `4*op + 0x74*i` over a loop counter `i` bounded by `cmp eax,0x40`, which
/// forces `field1 = i` (the `SpellFamilyFlags` **bit index**, 0..63) and `field2 = op` (the
/// SpellModOp, 0..28) uniquely — and only that assignment fills the 64×29 table exactly
/// (`29*63 + 28 == 1855`). The transposed reading addresses 47% of the array and would make the
/// writer and the reader touch systematically disjoint cells, dropping every modifier silently.
/// wow-re `system/spell/scratch/spellmod-table-law.md` §1/§3 — which was written to correct
/// exactly that inversion in a note that had carried it, `verified`, for months.
///
/// **vmangos's writer says the same thing from the other end**, which is worth having because the
/// client-side roles were published inverted once already: `Player::SendSpellMod`
/// (`Objects/Player.cpp:17815`) loops `for (int eff = 0; eff < 64; ++eff)` over the modifier's
/// 64-bit `mask` and, for each set bit, writes `uint8(eff)`, `uint8(mod->op)`, `int32(val)` — one
/// packet per set bit, `val` the SUM of every matching modifier, i.e. absolute. `MAX_SPELLMOD`
/// there is **29** (`Spells/SpellDefines.h`), the stride.
///
/// Both bytes are `movzx`-ed (unsigned); the value's signedness is fixed three independent ways by
/// the reader (`sets dl` on the pct sum, `fild dword`, and a signed magic-divide). Neither byte is
/// bounds-checked by the reference — that is the consumer's job, and
/// `benilla::spell_mods::SpellModifiers` does it; this decode owns the shape only.
/// Read `SMSG_SET_FLAT_SPELL_MODIFIER` / `SMSG_SET_PCT_SPELL_MODIFIER`: a `u8` `SpellFamilyFlags`
/// bit index (0..63), a `u8` SpellModOp (0..28), then the `i32` sum of every matching modifier.
/// The bit comes first: the client (`0x6e9950`) stores at `bit * 29 + op`, and vmangos
/// (`Player.cpp:17815`) sends one packet per set bit. The client range-checks neither byte.
pub(super) fn read_set_spell_modifier(r: &mut impl Read) -> io::Result<(u8, u8, i32)> {
let mask_bit = read_u8(r)?;
let op = read_u8(r)?;
@@ -348,32 +259,26 @@ pub(super) fn read_set_spell_modifier(r: &mut impl Read) -> io::Result<(u8, u8,
Ok((mask_bit, op, value))
}
/// Read `SMSG_UPDATE_AURA_DURATION` → `(slot, remaining_ms)` (vmangos
/// `SpellAuraHolder::UpdateAuraDuration`, `SpellAuras.cpp:7511-7523`): a `u8` `UNIT_FIELD_AURA` slot
/// index and a `u32` of milliseconds left. **Self-only** — it goes to the aura's target, never to
/// the caster or an onlooker — and never sent for a permanent aura, so an occupied slot that has no
/// duration is the reference's "until cancelled" (decision 0255).
/// Read `SMSG_UPDATE_AURA_DURATION` (vmangos `SpellAuras.cpp:7511-7523`): an aura slot and its ms
/// left. Sent only to the aura's target and never for a permanent aura, so an occupied slot with
/// no duration lasts until cancelled.
pub(super) fn read_update_aura_duration(r: &mut impl Read) -> io::Result<(u8, u32)> {
let slot = read_u8(r)?;
let remaining_ms = read_u32_le(r)?;
Ok((slot, remaining_ms))
}
/// Read `SMSG_PLAY_SPELL_VISUAL` → `(unit, kit_id)` (vmangos
/// `WorldPackets::Spell::PlaySpellVisual::AppendBodyTo`, `Server/Packets/Spell.cpp:54-58`): a raw
/// (unpacked) `u64` guid + `u32` kit id, bounds-checked against `SpellVisualKit.dbc` and played
/// at the client's hardcoded stage 0 (`0x6e98d0` — the eat/drink cadence; decision 0280).
/// Read `SMSG_PLAY_SPELL_VISUAL` (vmangos `Spell.cpp:54-58`): raw `u64` unit, `u32` kit id. The
/// client (`0x6e98d0`) checks the kit against `SpellVisualKit.dbc` and plays it at stage 0.
pub(super) fn read_play_spell_visual(r: &mut impl Read) -> io::Result<(u64, u32)> {
let unit = read_u64_le(r)?;
let kit_id = read_u32_le(r)?;
Ok((unit, kit_id))
}
/// Body of `CMSG_CAST_SPELL` (vmangos `CastSpell::ReadFromWorldPacket` → `SpellCastTargets::read`):
/// `u32 spellId` + the target block. `None` = a self/implicit cast — mask `TARGET_FLAG_SELF (0)`,
/// nothing follows (the server fills the target from the spell's implicit targeting).
/// `Some(guid)` = an explicit unit target — mask `TARGET_FLAG_UNIT (0x0002)` + the guid **packed**
/// (`ReadAsPackedClientBuildAware` is the packed reader for builds > 1.8.4).
/// Body of `CMSG_CAST_SPELL` for a self or unit target. `None` sends mask 0 (`TARGET_FLAG_SELF`)
/// and the server applies the spell's implicit targeting; `Some` sends `TARGET_FLAG_UNIT` and the
/// packed guid.
pub fn cast_spell(spell_id: u32, target: Option<u64>) -> Vec<u8> {
let mut body = Vec::with_capacity(16);
body.extend_from_slice(&spell_id.to_le_bytes());
@@ -387,20 +292,10 @@ pub fn cast_spell(spell_id: u32, target: Option<u64>) -> Vec<u8> {
body
}
/// Body of `CMSG_CAST_SPELL` aimed at a **GameObject** (decision 0239): the OPEN_LOCK cast a
/// right-click on a locked chest / mining vein / herb node sends instead of `CMSG_GAMEOBJ_USE`, and
/// the targeting cursor's world commit (decision 0939). `spell_id`, then the target mask
/// `GAMEOBJECT (0x0800)` **alone**, then the GameObject's **packed** guid — the one field that mask
/// reads. Distinct from [`cast_spell`]'s unit/self target shape (flag `0x2` + packed guid).
///
/// **`TARGET_FLAG_LOCKED (0x4000)` is not in this mask** (decision 0939, correcting 0239/0769). The
/// bit lives in the *targeting* word `0xcecac0`: the implicit-target switch ORs it there
/// (`6e44e1: orl $0x4000, %ebx` — `%ebx` is the word that switch is accumulating, not the outgoing
/// mask), and `BindTarget 0x6e5b40`'s GameObject arm then **consumes** it — `6e5f60 testb $0x48, %ch`
/// on the word, `6e5f69 orb $0x8, 0xceac5d` (⇒ `0x0800`) into the wire mask, `6e5f70` clearing the
/// word's own `0x4800`. A whole-image census of every write to the 16-bit mask at `0xceac5c` (one
/// `movw` restore plus eleven `orb` sites) contains no `0x4000` anywhere: the real client never puts
/// `LOCKED` on the wire, in this packet or any other.
/// Body of `CMSG_CAST_SPELL` at a GameObject: the open-lock cast on a locked chest, vein or herb
/// (instead of `CMSG_GAMEOBJ_USE`), or a targeting-cursor commit. The mask is `GAMEOBJECT` alone:
/// the 1.12 client never puts `TARGET_FLAG_LOCKED` (0x4000) on the wire; `BindTarget` (`0x6e5b40`)
/// consumes it from the targeting word, and no write to the wire mask (`0xceac5c`) sets it.
pub fn cast_spell_gameobject(spell_id: u32, go_guid: u64) -> Vec<u8> {
let mut body = Vec::with_capacity(16);
body.extend_from_slice(&spell_id.to_le_bytes());
@@ -409,11 +304,8 @@ pub fn cast_spell_gameobject(spell_id: u32, go_guid: u64) -> Vec<u8> {
body
}
/// Body of `CMSG_CAST_SPELL` aimed at an **ITEM** (decision 0437 phase 3): the enchant/poison
/// cast the CraftFrame's item pick completes. `spell_id`, mask `TARGET_FLAG_ITEM (0x0010)`, then
/// the item's **packed** guid — the one field the mask reads (vmangos `SpellCastTargets::read`,
/// `SpellCastTargetsInfo.cpp:159-160`: `ITEM | TRADE_ITEM → one packed guid`; the trade-window
/// sentinel form (`TRADE_ITEM 0x1000`) is the player-trade arc's, not built here).
/// Body of `CMSG_CAST_SPELL` at an item, such as an enchant or poison: `TARGET_FLAG_ITEM` and the
/// item's packed guid (`SpellCastTargetsInfo.cpp:159-160`).
pub fn cast_spell_item(spell_id: u32, item_guid: u64) -> Vec<u8> {
let mut body = Vec::with_capacity(16);
body.extend_from_slice(&spell_id.to_le_bytes());
@@ -422,12 +314,9 @@ pub fn cast_spell_item(spell_id: u32, item_guid: u64) -> Vec<u8> {
body
}
/// Body of `CMSG_CAST_SPELL` aimed at a **ground point** (decision 0792): the targeting-cursor
/// commit for a `Targets & 0x40` spell (Blizzard, Flamestrike, Rain of Fire…). `spell_id`, mask
/// `DEST_LOCATION (0x0040)`, then the destination as three `f32` **WoW world coords** — the one
/// field the mask reads (vmangos `SpellCastTargets::read`, `SpellCastTargetsInfo.cpp:169-174`:
/// `DEST_LOCATION → x,y,z`, `IsValidMapCoord`-gated). The real client ships the same shape from
/// `SPELLCAST+0x3c..0x44` (`BindLocation 0x6e60f0` → `SendCast 0x6e54f0`, wow-re `wave-cast.md`).
/// Body of `CMSG_CAST_SPELL` at a ground point, for a `Targets & 0x40` spell such as Blizzard:
/// `DEST_LOCATION` and three `f32` WoW world coords (`SpellCastTargetsInfo.cpp:169-174`; client
/// `BindLocation` `0x6e60f0`).
pub fn cast_spell_at_dest(spell_id: u32, dest: [f32; 3]) -> Vec<u8> {
let mut body = Vec::with_capacity(18);
body.extend_from_slice(&spell_id.to_le_bytes());
@@ -438,16 +327,9 @@ pub fn cast_spell_at_dest(spell_id: u32, dest: [f32; 3]) -> Vec<u8> {
body
}
/// Body of `CMSG_CAST_SPELL` aimed at a **source point** (decision 2218): the targeting-cursor
/// commit for a `Targets & 0x20` spell — the *other* half of `BindLocation 0x6e60f0`, whose bit-5
/// arm writes `SPELLCAST+0x30..0x38` and ORs `SOURCE_LOCATION (0x0020)` into the wire mask
/// (`6e6105`–`6e6126`), exactly as its bit-6 arm does for the dest at `+0x3c..0x44`. Same block,
/// same three `f32` WoW world coords, one bit over (vmangos `SpellCastTargets::read`,
/// `SpellCastTargetsInfo.cpp:161-167`: `SOURCE_LOCATION → x,y,z`, `IsValidMapCoord`-gated, read
/// **before** the dest triple).
///
/// The server centres the AoE on it: `TARGET_ENUM_UNITS_ENEMY_AOE_AT_SRC_LOC (15)` fills its
/// target map with `FillAreaTargets(…, PUSH_SRC_CENTER, …)` (`Spell.cpp:2265`).
/// Body of `CMSG_CAST_SPELL` at a source point, for a `Targets & 0x20` spell: `SOURCE_LOCATION`
/// and three `f32` WoW world coords (client `0x6e60f0`, `SpellCastTargetsInfo.cpp:161-167`). The
/// server centres the AoE there (`TARGET_ENUM_UNITS_ENEMY_AOE_AT_SRC_LOC`, `Spell.cpp:2265`).
pub fn cast_spell_at_source(spell_id: u32, src: [f32; 3]) -> Vec<u8> {
let mut body = Vec::with_capacity(18);
body.extend_from_slice(&spell_id.to_le_bytes());
@@ -458,11 +340,9 @@ pub fn cast_spell_at_source(spell_id: u32, src: [f32; 3]) -> Vec<u8> {
body
}
/// Body of `CMSG_CANCEL_AURA` (vmangos `WorldPackets::Spell::CancelAura`, `Server/Packets/Spell.h:55-62`):
/// one `u32` spell id. The server cancels **by spell, not by slot** — `HandleCancelAuraOpcode`
/// (`SpellHandler.cpp:333-405`) looks the spell up, refuses passives, `SPELL_ATTR_NO_AURA_CANCEL`
/// spells and debuffs, then calls `RemoveAurasDueToSpellByCancel`. The wire's own
/// `AURA_FLAG_CANCELABLE` nibble bit is the matching client-side gate (decision 0255).
/// Body of `CMSG_CANCEL_AURA`: by spell id, not slot. `HandleCancelAuraOpcode`
/// (`SpellHandler.cpp:333-405`) refuses passives, debuffs and `SPELL_ATTR_NO_AURA_CANCEL` spells;
/// the client gates on the aura's `AURA_FLAG_CANCELABLE` bit.
pub fn cancel_aura(spell_id: u32) -> Vec<u8> {
spell_id.to_le_bytes().to_vec()
}
@@ -473,24 +353,19 @@ mod tests {
#[test]
fn cast_spell_gameobject_body_golden() {
// spell_id (LE) + target mask GAMEOBJECT = 0x0800 (LE `00 08`) + packed guid 0x1234
// (mask 0x03, bytes 34 12). The `0x4000` LOCKED bit is deliberately absent: it is a
// *targeting-word* bit that `BindTarget 0x6e5b40` consumes, never a wire bit (decision
// 0939 — the whole-image census of writes to `0xceac5c` has no `0x4000`).
// spell 1, mask 0x0800 (no LOCKED 0x4000), packed guid 0x1234 (mask 0x03, bytes 34 12).
assert_eq!(
cast_spell_gameobject(1, 0x1234),
[0x01, 0x00, 0x00, 0x00, 0x00, 0x08, 0x03, 0x34, 0x12],
"CMSG_CAST_SPELL (GameObject/OPEN_LOCK) body"
);
// The unit-target twin still carries flag 0x2, not 0x800 — the two shapes stay distinct.
// The self-cast shape stays distinct: mask 0, no guid.
assert_eq!(cast_spell(1, None), [0x01, 0x00, 0x00, 0x00, 0x00, 0x00]);
}
#[test]
fn cast_spell_at_dest_body_golden() {
// spell_id 10 (Blizzard, LE) + mask DEST_LOCATION 0x0040 (LE `40 00`) + the dest Vec3 as
// three f32 LE: 1.0 = 00 00 80 3F, -2.5 = 00 00 20 C0, 3.0 = 00 00 40 40. VERIFIED against
// vmangos `SpellCastTargets::read` (mask → DEST branch reads exactly x, y, z).
// Spell 10 is Blizzard.
assert_eq!(
cast_spell_at_dest(10, [1.0, -2.5, 3.0]),
[
@@ -506,10 +381,7 @@ mod tests {
#[test]
fn cast_spell_at_source_body_golden() {
// spell_id 265 (Area Death (TEST) — Martin Fury's on-use, LE) + mask SOURCE_LOCATION
// 0x0020 (LE `20 00`) + the source Vec3 as three f32 LE. VERIFIED against vmangos
// `SpellCastTargets::read` (`SpellCastTargetsInfo.cpp:161-167`), which reads the SOURCE
// triple BEFORE the dest one and `IsValidMapCoord`-gates it. Decision 2218.
// Spell 265 is Area Death (TEST), Martin Fury's on-use.
assert_eq!(
cast_spell_at_source(265, [1.0, -2.5, 3.0]),
[
@@ -521,8 +393,6 @@ mod tests {
],
"CMSG_CAST_SPELL (ground source) body"
);
// One bit apart from its twin, and nothing else: the two bodies differ in exactly the
// mask byte.
let (src, dest) = (
cast_spell_at_source(265, [1.0, -2.5, 3.0]),
cast_spell_at_dest(265, [1.0, -2.5, 3.0]),
@@ -537,8 +407,7 @@ mod tests {
#[test]
fn aura_bodies_golden() {
// CMSG_CANCEL_AURA: a lone u32 spell id, LE. 1126 (Mark of the Wild) = 0x0000_0466. The
// server cancels by spell — there is no slot byte to get wrong.
// CMSG_CANCEL_AURA: 1126 (Mark of the Wild) = 0x0000_0466.
assert_eq!(cancel_aura(1126), [0x66, 0x04, 0x00, 0x00]);
// SMSG_UPDATE_AURA_DURATION: u8 slot, then u32 ms LE. Slot 3, 12_000 ms = 0x0000_2EE0.
@@ -551,10 +420,9 @@ mod tests {
);
}
/// The spell-modifier body, byte for byte — and the field ORDER, which is the whole trap.
#[test]
fn set_spell_modifier_body_golden() {
// mask_bit 35 (0x23) · op 14 (0x0e, SPELLMOD_COST) · value -30 (0xFFFF_FFE2 LE).
// mask_bit 35 (0x23), op 14 (0x0e, SPELLMOD_COST), value -30 (0xFFFF_FFE2 LE).
let body = [0x23, 0x0E, 0xE2, 0xFF, 0xFF, 0xFF];
let mut r = &body[..];
assert_eq!(read_set_spell_modifier(&mut r).unwrap(), (35, 14, -30));
@@ -563,10 +431,7 @@ mod tests {
"the body is exactly 6 bytes — two bytes and a dword, never three dwords"
);
// The two bytes are NOT interchangeable, and this body is chosen so that a swap is
// detectable rather than plausible: 35 is a legal mask bit (0..=63) and an ILLEGAL op
// (0..=28), so a decode that read them the other way round would hand the consumer an op
// of 35 — off the end of the table's 29-wide axis.
// 35 is a legal mask bit (0..=63) but not a legal op (0..=28), so a swap would show.
let (mask_bit, op, _) = read_set_spell_modifier(&mut &body[..]).unwrap();
assert!(
mask_bit >= 29,
@@ -574,8 +439,7 @@ mod tests {
);
assert!(op < 29, "field 2 is a legal op as read");
// Both bytes are unsigned (`movzx`) and the value is signed: 0xFF in field 1 is bit 255,
// not -1, and it is the consumer that must refuse it.
// Both bytes are unsigned, the value signed; the consumer must refuse bit 255.
assert_eq!(
read_set_spell_modifier(&mut &[0xFF, 0xFF, 0x01, 0x00, 0x00, 0x00][..]).unwrap(),
(255, 255, 1)
@@ -583,42 +447,24 @@ mod tests {
}
}
/// One decoded `SMSG_SPELL_UPDATE_CHAIN_TARGETS` — the **beam's hop list** (decision 0955).
///
/// VERIFIED on both sides. Server: vmangos `Spell::SendChannelStart` (`Spell.cpp:4970-4997`) —
/// `ObjectGuid caster` (raw `u64`) · `u32 spellId` · `u32 count` · that many raw `u64` target
/// guids. Client: the handler `0x6e9820` decodes exactly that shape (`GetGuid` · `GetInt32` ·
/// `GetInt32` · `count × GetGuid`) and hands it to the array filler `0x605780`, which writes the
/// growable array at `unit+0xd44`. The chain `CharProc` (`0x60da79`) then consumes it once and
/// zeroes the count (`0x60db72`).
///
/// **This packet is NOT the only producer** (wow-re `chain-beam-law.md`, which refuted that
/// reading at the bytes): `0x605780` has exactly two callers — this handler's `0x605767`, and
/// **`0x6e800d`, inside `HandleSpellGo` `0x6e7a70`**. The client fills the same hop array from
/// `SMSG_SPELL_GO`'s own hit list, which is what makes a chain spell draw on its very first cast.
/// That matters here because vmangos only ever *sends* this packet from `SendChannelStart`, gated
/// on `SPELL_ATTR_EX_IS_CHANNELED` — so on this server the channel beams (Drain Life, Mind Flay,
/// Health Funnel, Corruption…) arrive by packet and the cast-stage chains (Chain Lightning, Chain
/// Heal, Chain Burn) arrive by the GO path. Both are the reference's own mechanism; benilla's
/// GO-derived hops are **not** a divergence (decision 0955).
/// `SMSG_SPELL_UPDATE_CHAIN_TARGETS`: a beam's hop list (vmangos `Spell.cpp:4970-4997`, client
/// `0x6e9820`). vmangos sends it only for channeled spells; the 1.12 client also fills the same
/// hop array from `SMSG_SPELL_GO`'s hits (`0x6e800d`), which is how Chain Lightning draws.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpellChainTargets {
pub caster: u64,
pub spell_id: u32,
/// The hop list in wire order. The client's filler (`0x605780`) walks it dropping any entry
/// equal to the caster's own guid, so that filter belongs to the consumer, not to this decode.
/// In wire order. The client (`0x605780`) skips any entry equal to the caster; that filter is
/// the consumer's.
pub targets: Vec<u64>,
}
/// Read `SMSG_SPELL_UPDATE_CHAIN_TARGETS`. Guids are **raw** `u64` on both ends of the list — the
/// server writes `ObjectGuid` and the client reads `GetGuid` (0x4190b0), never the packed form.
/// Read `SMSG_SPELL_UPDATE_CHAIN_TARGETS`; every guid in it is a raw `u64`, never packed.
pub(super) fn read_spell_chain_targets(r: &mut impl Read) -> io::Result<SpellChainTargets> {
let caster = read_u64_le(r)?;
let spell_id = read_u32_le(r)?;
let count = read_u32_le(r)?;
// The count is server-written and unbounded on the wire; read defensively rather than
// pre-allocating from it (the `with_capacity` idiom the rest of this module uses is safe only
// where a `u8` bounds the count).
// The `u32` count is unbounded, so nothing is pre-allocated from it.
let mut targets = Vec::new();
for _ in 0..count {
targets.push(read_u64_le(r)?);
+34 -126
View File
@@ -1,121 +1,58 @@
//! Stable-master messages — the hunter pet stable's wire (opcodes 623-629, vmangos
//! `Opcodes_1_12_1.h`, VERIFIED). Bodies from vmangos `NPCHandler.cpp` (`SendStablePet` +
//! the five `HandleStable*` handlers) and the hand-serialized `Server/Packets/Npc.{h,cpp}`
//! (`ListStabledPets`/`StablePet`/`UnstablePet`/`BuyStableSlot`/`StableSwapPet`/`StableResult`).
//!
//! **The shape of the system.** A stable master keeps a hunter's pets in **three places**: the one
//! *current* pet walking beside the player, plus up to two *stable* slots that must each be bought
//! with gold (`Player::m_stableSlots`, `StableSlotPrices.dbc`). The window (`PetStableFrame`) is
//! opened by the gossip stable option, which makes the server send `MSG_LIST_STABLED_PETS`
//! unprompted — the same opcode the client sends back as the *refresh* verb (it is an `MSG_`, one
//! number in both directions). Every mutation is a single-verb ask answered by one
//! `SMSG_STABLE_RESULT` byte and **nothing else**: the list is not resent, so a client that acted
//! successfully re-asks with [`list_stabled_pets`] to repaint.
//!
//! **Slots are 1-based on the wire and 0-based in the client**, and this module absorbs the
//! difference exactly once — see [`StabledPet::slot`]. The rest of benilla only ever sees the
//! client's own indices (`0` = the current pet, `1..=NUM_PET_STABLE_SLOTS`).
//!
//! Only *hunter* pets reach the stable: vmangos gates every verb on `HUNTER_PET`, so a warlock at a
//! stable master gets an empty list. Decision 1676.
//! Stable-master messages (623-629; vmangos `NPCHandler.cpp`, `Server/Packets/Npc.cpp`): a
//! hunter's current pet plus up to two bought stable slots. The gossip stable option makes the
//! server send `MSG_LIST_STABLED_PETS`; every other verb gets only a `SMSG_STABLE_RESULT` byte,
//! never a fresh list. Only hunter pets are listed, so a warlock sees an empty stable.
use std::io;
use crate::wire::{capacity_hint, read_cstring, read_u32_le, read_u64_le, read_u8};
/// One row of `MSG_LIST_STABLED_PETS` (vmangos `WorldSession::SendStablePet`,
/// `NPCHandler.cpp:522-575`) — a pet the stable master can show, whether it is the one currently at
/// the player's side or one asleep in a stable slot.
///
/// The wire record is `u32 petNumber, u32 creatureEntry, u32 level, cstring name, u32 loyalty,
/// u8 slot`. Note what is *not* on it: no display id and no family — a stabled pet has no world
/// object to read either from, so drawing its portrait takes a `CMSG_CREATURE_QUERY` on
/// [`creature_entry`](Self::creature_entry) (which is why that response's display id is kept,
/// decision 1676).
/// One `MSG_LIST_STABLED_PETS` row (vmangos `NPCHandler.cpp:522-575`), the current pet or a
/// stabled one. It has no display id or family; those come from a creature query on the entry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StabledPet {
/// The pet's own id — vmangos's `character_pet.id`, the same number
/// [`crate::guid::pet_number`] reads out of a live pet's guid. This, **not** the slot, is what
/// [`unstable_pet`] and [`stable_swap_pet`] name.
/// vmangos `character_pet.id`; [`unstable_pet`] and [`stable_swap_pet`] take this, not a slot.
pub pet_number: u32,
/// `creature_template` entry (`Pet::GetEntry()` / `character_pet.entry`) — **not** a display
/// id. Feed it to `CMSG_CREATURE_QUERY` to get the name, family and display id the window's
/// icon and diet tooltip need.
/// A `creature_template` entry, not a display id.
pub creature_entry: u32,
/// The pet's level.
pub level: u32,
/// The pet's name — the one the hunter gave it, not the creature template's.
/// The name the hunter gave it, not the template's.
pub name: String,
/// Loyalty **level** 1..6 (`LoyaltyLevel`, vmangos `Pet.h:57-65`: Rebellious … Best Friend) — a
/// `PetLoyalty.dbc` row id the client renders as a string, never a string on the wire.
/// Loyalty level 1..6 (`Pet.h:57-65`), a `PetLoyalty.dbc` row id, not a string.
pub loyalty: u32,
/// **Client** slot index, already rebased: `0` = the current pet, `1..=2` = the two stable
/// slots.
///
/// The wire byte is 1-based over vmangos's own 0-based `character_pet.slot`: `SendStablePet`
/// writes a literal `0x01` for the current pet (`PET_SAVE_AS_CURRENT` = 0) and `it->slot + 1`
/// for a stabled one, where `PET_SAVE_FIRST_STABLE_SLOT` = 1 and `PET_SAVE_LAST_STABLE_SLOT` =
/// `MAX_PET_STABLES` = 2 (`Pet.h:37-48`). The reference UI indexes `0` (current) through
/// `NUM_PET_STABLE_SLOTS`, so **client index = wire slot − 1** — done once, here, so no
/// consumer has to rediscover a ±1.
/// The client's index: 0 is the current pet, 1..=2 the stable slots. The wire byte is one
/// higher (`SendStablePet` writes `character_pet.slot + 1`) and is rebased here, once.
pub slot: u8,
}
/// `SMSG_STABLE_RESULT`'s one `u8` — the whole answer to every stable verb (vmangos's
/// `StableResultCode`, `NPCHandler.cpp:40-47`, VERIFIED). Nothing else comes back: the list is
/// **not** resent, so a success is a cue to re-ask with [`list_stabled_pets`].
/// `SMSG_STABLE_RESULT` codes, vmangos `StableResultCode` (`NPCHandler.cpp:40-47`).
pub mod stable_result {
/// `STABLE_ERR_MONEY` — not enough gold for the next stable slot. Only [`super::buy_stable_slot`]
/// produces it (`HandleBuyStableSlot`, `NPCHandler.cpp:704-729`).
/// `STABLE_ERR_MONEY`: not enough gold for the next slot; only a slot purchase gets it.
pub const ERR_MONEY: u8 = 0x01;
/// `STABLE_ERR_STABLE` — the catch-all failure, and the answer to *every* refusal except the
/// money one: dead player, not a real stable master (or out of interact range), no live hunter
/// pet to stable, all bought slots full, an unknown/untameable pet number, a pet already out
/// when unstabling, a failed load from the DB, or buying a fourth slot. It carries no reason —
/// the client cannot tell those cases apart, and neither can we.
/// `STABLE_ERR_STABLE`: every refusal but the money one, with no reason attached.
pub const ERR_STABLE: u8 = 0x06;
/// `STABLE_SUCCESS_STABLE` — the current pet went into the first free stable slot
/// ([`super::stable_pet`]).
/// `STABLE_SUCCESS_STABLE`: the current pet went into the first free stable slot.
pub const SUCCESS_STABLE: u8 = 0x08;
/// `STABLE_SUCCESS_UNSTABLE` — a stabled pet is now the current pet. Sent for **both**
/// [`super::unstable_pet`] and a successful [`super::stable_swap_pet`] (vmangos
/// `HandleStableSwapPet` ends on this same code), so it does not identify which verb was used.
/// `STABLE_SUCCESS_UNSTABLE`: a stabled pet is now current, after an unstable or a swap.
pub const SUCCESS_UNSTABLE: u8 = 0x09;
/// `STABLE_SUCCESS_BUY_SLOT` — a stable slot was purchased; the gold is already gone and
/// `m_stableSlots` has advanced, visible on the next list's `num_stable_slots`.
/// `STABLE_SUCCESS_BUY_SLOT`: a slot was bought; the next list's slot count shows it.
pub const SUCCESS_BUY_SLOT: u8 = 0x0A;
}
/// Body of `MSG_LIST_STABLED_PETS` sent **client → server** (vmangos `Npc.cpp:51-54`,
/// `ListStabledPets::ReadFromWorldPacket`): one full 8-byte stable-master guid.
///
/// The *refresh* verb, not the open verb: the window first appears off the gossip stable option,
/// which has the server send this same opcode inbound unprompted. Re-ask after any successful
/// mutation — `SMSG_STABLE_RESULT` is the entire answer to those, and the server never resends the
/// list on its own.
/// Body of `MSG_LIST_STABLED_PETS` from the client (`Npc.cpp:51-54`): the refresh, needed after
/// every successful verb because the server never resends the list.
pub fn list_stabled_pets(npc_guid: u64) -> Vec<u8> {
npc_guid.to_le_bytes().to_vec()
}
/// Body of `CMSG_STABLE_PET` (vmangos `Npc.cpp:56-59`, `StablePet::ReadFromWorldPacket`): one full
/// 8-byte stable-master guid.
///
/// **It carries no slot** — the surprising part. The server picks the destination itself:
/// `HandleStablePet` (`NPCHandler.cpp:609-655`) walks `PET_SAVE_FIRST_STABLE_SLOT..=
/// PET_SAVE_LAST_STABLE_SLOT`, takes the first slot no `character_pet` row occupies, and refuses
/// with [`stable_result::ERR_STABLE`] if that index exceeds the number of slots the player has
/// bought. So there is no "stable into slot 2" intent to express, and a UI that lets the player
/// drop a pet onto a particular empty slot is describing a placement the wire cannot carry.
/// Body of `CMSG_STABLE_PET` (`Npc.cpp:56-59`). It carries no slot: the server takes the first
/// free bought slot or refuses (`HandleStablePet`, `NPCHandler.cpp:609-655`).
pub fn stable_pet(npc_guid: u64) -> Vec<u8> {
npc_guid.to_le_bytes().to_vec()
}
/// Body of `CMSG_UNSTABLE_PET` (vmangos `Npc.cpp:61-65`, `UnstablePet::ReadFromWorldPacket`): `u64
/// npcGuid, u32 petNumber` — the [`StabledPet::pet_number`] of the chosen row, never its slot.
///
/// Summons that pet as the current pet. Refused with [`stable_result::ERR_STABLE`] when the player
/// already *has* a pet — even an unsummoned one that is merely out of range
/// (`HandleUnstablePet`, `NPCHandler.cpp:657-702`); trading places is [`stable_swap_pet`]'s job.
/// Body of `CMSG_UNSTABLE_PET` (`Npc.cpp:61-65`): makes the pet current. Refused while the player
/// has any pet, even one out of range (`NPCHandler.cpp:657-702`); swapping is [`stable_swap_pet`].
pub fn unstable_pet(npc_guid: u64, pet_number: u32) -> Vec<u8> {
let mut body = Vec::with_capacity(12);
body.extend_from_slice(&npc_guid.to_le_bytes());
@@ -123,25 +60,14 @@ pub fn unstable_pet(npc_guid: u64, pet_number: u32) -> Vec<u8> {
body
}
/// Body of `CMSG_BUY_STABLE_SLOT` (vmangos `Npc.cpp:67-70`, `BuyStableSlot::ReadFromWorldPacket`):
/// one full 8-byte stable-master guid.
///
/// Like the bank's slot purchase, the *which* is implicit: the server buys slot
/// `m_stableSlots + 1`, prices it from `StableSlotPrices.dbc` at that row, and answers
/// [`stable_result::SUCCESS_BUY_SLOT`], [`stable_result::ERR_MONEY`], or — past `MAX_PET_STABLES`
/// (2) — [`stable_result::ERR_STABLE`] (`HandleBuyStableSlot`, `NPCHandler.cpp:704-729`). Knowing
/// the next price is the client's own job, read from that DBC.
/// Body of `CMSG_BUY_STABLE_SLOT` (`Npc.cpp:67-70`): buys the next slot at its
/// `StableSlotPrices.dbc` price, which the client reads itself (`NPCHandler.cpp:704-729`).
pub fn buy_stable_slot(npc_guid: u64) -> Vec<u8> {
npc_guid.to_le_bytes().to_vec()
}
/// Body of `CMSG_STABLE_SWAP_PET` (vmangos `Npc.cpp:72-76`, `StableSwapPet::ReadFromWorldPacket`):
/// `u64 npcGuid, u32 petNumber` — same shape as [`unstable_pet`], and the verb to use when the
/// player *already* has a current pet.
///
/// The current pet takes the named pet's stable slot and the named pet is summoned, in one step
/// (`HandleStableSwapPet`, `NPCHandler.cpp:735-789`). Success answers
/// [`stable_result::SUCCESS_UNSTABLE`] — the same code a plain unstable returns.
/// Body of `CMSG_STABLE_SWAP_PET` (`Npc.cpp:72-76`): the current pet and the named one trade
/// places in one step (`NPCHandler.cpp:735-789`).
pub fn stable_swap_pet(npc_guid: u64, pet_number: u32) -> Vec<u8> {
let mut body = Vec::with_capacity(12);
body.extend_from_slice(&npc_guid.to_le_bytes());
@@ -149,26 +75,11 @@ pub fn stable_swap_pet(npc_guid: u64, pet_number: u32) -> Vec<u8> {
body
}
// No `CMSG_STABLE_REVIVE_PET` (0x0274) send verb, deliberately: vmangos's
// `HandleStableRevivePet` (`NPCHandler.cpp:731-733`) is an empty no-op, and whether the 5875 client
// ever puts that opcode on the wire is an open RE question — nothing here can be verified against a
// handler that does nothing. If a capture ever shows the reference client sending it, the verb gets
// added then, from that evidence (decision 1676).
// No `CMSG_STABLE_REVIVE_PET` (0x0274) verb: vmangos's handler is empty (`NPCHandler.cpp:731`).
/// Read `MSG_LIST_STABLED_PETS` arriving **server → client** (vmangos
/// `WorldSession::SendStablePet`, `NPCHandler.cpp:522-575`): `u64 npcGuid, u8 numPets, u8
/// numStableSlots, numPets ×` [`StabledPet`] (a variable-length record — the name is a cstring).
/// Returns `(npc, num_stable_slots, pets)`.
///
/// `num_stable_slots` is `Player::m_stableSlots` — how many slots are **purchased** (0..=2), which
/// is what greys the unbought ones and drives the "buy the next slot" price line; it is *not* a
/// count of occupied slots.
///
/// **The current pet's row may be absent.** vmangos emits a slot-0 row only when the player has a
/// live `HUNTER_PET` or a cached `character_pet` for one that has despawned; a warlock, or a hunter
/// with no pet at all, simply gets a list whose rows are all stabled ones (or none). So `pets[0]`
/// is not the current pet — the rows are keyed by their own [`StabledPet::slot`], and a consumer
/// must look the slot up rather than index positionally.
/// Read `MSG_LIST_STABLED_PETS` from the server (`NPCHandler.cpp:522-575`). The slot count is how
/// many are bought (0..=2), not occupied. The current pet's row may be missing, so find rows by
/// [`StabledPet::slot`], not position.
pub(super) fn read_list_stabled_pets(r: &mut &[u8]) -> io::Result<(u64, u8, Vec<StabledPet>)> {
let npc = read_u64_le(r)?;
let num_pets = read_u8(r)?;
@@ -183,17 +94,14 @@ pub(super) fn read_list_stabled_pets(r: &mut &[u8]) -> io::Result<(u64, u8, Vec<
level: read_u32_le(r)?,
name: read_cstring(r)?,
loyalty: read_u32_le(r)?,
// Wire slot is 1-based (see `StabledPet::slot`); rebase to the client's own index here,
// once. `saturating_sub` rather than `- 1` only so a malformed 0 can't wrap to 255 —
// vmangos never writes one.
// 1-based on the wire; saturating so a malformed 0 cannot wrap to 255.
slot: read_u8(r)?.saturating_sub(1),
});
}
Ok((npc, num_stable_slots, pets))
}
/// Read `SMSG_STABLE_RESULT` (vmangos `StableResult::AppendBodyTo`, `Npc.cpp:99-102`): one
/// [`stable_result`] byte, and the complete answer to every stable verb.
/// Read `SMSG_STABLE_RESULT` (`Npc.cpp:99-102`): one [`stable_result`] byte.
pub(super) fn read_stable_result(r: &mut &[u8]) -> io::Result<u8> {
read_u8(r)
}
+11 -50
View File
@@ -1,52 +1,24 @@
//! The player-summon family — the server's question and the client's one-word answer
//! (opcodes 683 and 684; decision 1747).
//!
//! Two packets, and the asymmetry between them is the whole story. A warlock's Ritual of
//! Summoning, a meeting stone, or a GM's `.summon` all end in the *server* asking the summoned
//! player `SMSG_SUMMON_REQUEST`; the client parks the three fields and puts the `CONFIRM_SUMMON`
//! dialog up. Accepting sends `CMSG_SUMMON_RESPONSE` carrying **the summoner's guid and nothing
//! else** — there is no accept/decline byte in 1.12, because **declining sends no packet at all**
//! and the server auto-declines on its own timer.
//!
//! | opcode | direction | body |
//! |---|---|---|
//! | `SMSG_SUMMON_REQUEST` 0x2ab | in | `u64` summoner guid + `u32` zone id + `u32` delay ms |
//! | `CMSG_SUMMON_RESPONSE` 0x2ac | out | `u64` summoner guid |
//!
//! **Byte-pinned client-side**, not inferred from the server: the 1.12.1 handler `0x5e6140` (the
//! `0x5ab650` registration site maps `0x2ab` to it) reads exactly three fields in this order —
//! `CDataStore::GetGuid 0x4190b0`, `CDataStore::GetInt32 0x418e30`, `CDataStore::GetUInt32_2
//! 0x418fb0` — and the accept binding `ConfirmSummon 0x48b770` writes opcode `0x2ac` followed by
//! one `CDataStore::PutGuid 0x418370` and sends (`ClientServices::Send 0x5ab630`). Nothing else
//! goes into either body.
//!
//! vmangos agrees on both, independently: `Server/Packets/Misc.cpp`'s
//! `SummonRequest::AppendBodyTo` writes `summonerGuid; zoneId; autoDeclineDelay`, and
//! `SummonResponse::ReadFromWorldPacket` reads **only** `summonerGuid` — then ignores it, calling
//! `Player::SummonIfPossible(true)` off its own `m_summon_expire` window
//! (`Handlers/MovementHandler.cpp`). So the guid is an echo the server does not check; what makes
//! a stale accept harmless is the server's timer, not the guid.
//! The player summon: `SMSG_SUMMON_REQUEST` (0x2ab, client `0x5e6140`) and the accept,
//! `CMSG_SUMMON_RESPONSE` (0x2ac, `ConfirmSummon` `0x48b770`). There is no decline packet: the
//! server declines on its own timer.
use std::io;
use crate::wire::{read_u32_le, read_u64_le};
/// `SMSG_SUMMON_REQUEST` — someone is asking to pull us to them.
/// `SMSG_SUMMON_REQUEST`: someone is asking to pull us to them.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SummonRequest {
/// Who is summoning. Echoed back in [`summon_response`], and it is the guid
/// `GetSummonConfirmSummoner()` resolves through the name cache.
/// Echoed in [`summon_response`]; `GetSummonConfirmSummoner()` names it from the name cache.
pub summoner: u64,
/// The **summoner's** zone — an `AreaTable.dbc` id, which is the whole of what
/// `GetSummonConfirmAreaName()` looks up. Not our own zone, and not a map id.
/// The summoner's `AreaTable.dbc` zone id, not ours and not a map id.
pub zone: u32,
/// Milliseconds until the server auto-declines (vmangos `MAX_PLAYER_SUMMON_DELAY * 1000`, two
/// minutes). The client turns it into a deadline the moment the packet lands and counts down
/// against it; the dialog takes itself off screen when it runs out.
/// Ms until the server auto-declines (vmangos `MAX_PLAYER_SUMMON_DELAY`, two minutes); the
/// client counts down from arrival and closes the dialog at zero.
pub delay_ms: u32,
}
/// Read `SMSG_SUMMON_REQUEST`: guid, zone, delay — see the module header for the byte pin.
/// Read `SMSG_SUMMON_REQUEST`: guid, zone, delay.
pub(super) fn read_summon_request(r: &mut &[u8]) -> io::Result<SummonRequest> {
Ok(SummonRequest {
summoner: read_u64_le(r)?,
@@ -55,12 +27,8 @@ pub(super) fn read_summon_request(r: &mut &[u8]) -> io::Result<SummonRequest> {
})
}
/// Body of `CMSG_SUMMON_RESPONSE`: the summoner's full 8-byte guid, and nothing after it.
///
/// **Decorative on vmangos, and still the right thing to send**: its handler reads the guid and
/// never looks at it again. The client's own `ConfirmSummon 0x48b770` puts the latched guid here,
/// so a server that *does* check it (or a future one that must distinguish two pending summons)
/// gets what the reference would have sent.
/// Body of `CMSG_SUMMON_RESPONSE`: the summoner's guid, nothing else. vmangos ignores it and
/// relies on its own expiry timer.
pub fn summon_response(summoner_guid: u64) -> Vec<u8> {
summoner_guid.to_le_bytes().to_vec()
}
@@ -69,9 +37,6 @@ pub fn summon_response(summoner_guid: u64) -> Vec<u8> {
mod tests {
use super::*;
/// The request body is the three fields in wire order, and the reader must not reorder the
/// two `u32`s — a swapped pair would name the zone by a millisecond count and count down two
/// minutes' worth of area ids, both of which read as "the dialog is just wrong".
#[test]
fn the_request_body_is_guid_then_zone_then_delay() {
let mut bytes = 0xF130_0000_0001_2345u64.to_le_bytes().to_vec();
@@ -87,16 +52,12 @@ mod tests {
);
}
/// A truncated body is an error, not a zero-filled request: a half-read packet that latched
/// would put a dialog up naming nobody, for no time.
#[test]
fn a_short_request_body_is_an_error() {
let bytes = [0u8; 15];
assert!(read_summon_request(&mut &bytes[..]).is_err());
}
/// The answer is one little-endian guid — the shape vmangos's `SummonResponse` reads back,
/// and byte-for-byte what `ConfirmSummon 0x48b770` puts on the wire.
#[test]
fn the_response_body_is_one_little_endian_guid_and_nothing_else() {
assert_eq!(
+10 -20
View File
@@ -1,21 +1,12 @@
//! The guild tabard designer's wire (decision 1977; wow-re `system/ui/scratch/tabard-designer.md`
//! §4, VERIFIED at the bytes): two `MSG_*` opcodes used in both directions, and the battlemaster
//! greeting the same TU ships beside them.
//!
//! - `MSG_TABARDVENDOR_ACTIVATE 0x1F2` — out: `u64 npcGuid` (the NPC-click ladder's
//! TABARDDESIGNER arm, `0x5e00e0`); in: `u64 vendorGuid`, the only thing that opens the frame
//! (`0x5e70c0` → `0x4f5840`).
//! - `MSG_SAVE_GUILD_EMBLEM 0x1F1` — out: `u64 vendorGuid` then the five `u32` design values in
//! the order *emblemStyle, emblemColor, borderStyle, borderColor, backgroundColor* (`0x5e03f0`);
//! in: one `u32` result (`0x5e70f0`), indexed into a six-row message table.
//! - `CMSG_BATTLEMASTER_HELLO 0x2D7` — out: `u64 npcGuid`, no gate (`0x5e01a0`).
//! The guild tabard designer's wire, two `MSG_*` opcodes used both ways, plus the battlemaster
//! greeting.
use std::io::{self, Read};
use crate::wire::{read_u32_le, read_u64_le};
/// Body of `MSG_SAVE_GUILD_EMBLEM` outbound: the vendor guid raw little-endian (not packed), then
/// the five design values as `u32`s in the designer's slot order.
/// Body of `MSG_SAVE_GUILD_EMBLEM` (0x1F1, client `0x5e03f0`): the raw vendor guid, then emblem
/// style, emblem color, border style, border color and background color as `u32`s.
pub fn save_guild_emblem(vendor: u64, design: [u32; 5]) -> Vec<u8> {
let mut body = vendor.to_le_bytes().to_vec();
for v in design {
@@ -24,29 +15,28 @@ pub fn save_guild_emblem(vendor: u64, design: [u32; 5]) -> Vec<u8> {
body
}
/// Body of `MSG_TABARDVENDOR_ACTIVATE` outbound: the NPC guid and nothing else.
/// Body of `MSG_TABARDVENDOR_ACTIVATE` (0x1F2, client `0x5e00e0`): the NPC guid.
pub fn tabard_vendor_activate(npc: u64) -> Vec<u8> {
npc.to_le_bytes().to_vec()
}
/// Body of `CMSG_BATTLEMASTER_HELLO`: the NPC guid and nothing else.
/// Body of `CMSG_BATTLEMASTER_HELLO` (0x2D7, client `0x5e01a0`, no gate): the NPC guid.
pub fn battlemaster_hello(npc: u64) -> Vec<u8> {
npc.to_le_bytes().to_vec()
}
/// `MSG_SAVE_GUILD_EMBLEM` inbound: one `u32` result.
/// `MSG_SAVE_GUILD_EMBLEM` inbound (client `0x5e70f0`): the result row.
pub(super) fn read_save_guild_emblem_result(r: &mut impl Read) -> io::Result<u32> {
read_u32_le(r)
}
/// `MSG_TABARDVENDOR_ACTIVATE` inbound: the vendor guid.
/// `MSG_TABARDVENDOR_ACTIVATE` inbound (`0x5e70c0`): the vendor guid. Only this opens the frame.
pub(super) fn read_tabard_vendor_activate(r: &mut impl Read) -> io::Result<u64> {
read_u64_le(r)
}
/// The reply handler's result table (`0x85fe88`, six rows — the consumer's own `0 ≤ result < 6`
/// bound): the message-catalog key each result shows, `None` for row 5, the `0x1d1` sentinel that
/// shows nothing. A result past the table is ignored outright.
/// The reply's message keys (`0x85fe88`); row 5 is the `0x1d1` sentinel that shows nothing, and a
/// result past the table is ignored.
pub const GUILD_EMBLEM_RESULT_MESSAGES: [Option<&str>; 6] = [
Some("ERR_GUILDEMBLEM_SUCCESS"),
Some("ERR_GUILDEMBLEM_INVALID_TABARD_COLORS"),
+21 -58
View File
@@ -1,29 +1,17 @@
//! Taxi/flight-master messages — the "right-click a flight master → pick a destination → fly"
//! wire (opcodes 425-431 + 786, vmangos `Opcodes_1_12_1.h`, VERIFIED; decision 0484). Bodies from
//! vmangos `Handlers/TaxiHandler.cpp` (the SMSG writers) and the hand-serialized
//! `Server/Packets/Taxi.{h,cpp}` (the CMSG readers + the two hand-serialized SMSGs,
//! `TaxiNodeStatus`/`ActivateTaxiReply`/`NewTaxiPath`). Every guid on this wire, both directions,
//! is a **plain** u64 — `ObjectGuid`'s own `ByteBuffer` stream operators (`ObjectGuid.cpp:174-186`)
//! read/write the raw value; `PackedGuid` is a distinct type these packets never reach for. The
//! flight itself (`SMSG_MONSTER_MOVE`, the mount rails) rides existing wire families
//! (`messages::monster_move`, decision 0441) — this module is only the menu + activate send.
//! Flight-master messages (425-431, 786; vmangos `TaxiHandler.cpp`, `Server/Packets/Taxi.cpp`).
//! Every guid here, both ways, is a plain `u64`, never packed (`ObjectGuid.cpp:174-186`).
use std::io;
use crate::wire::{read_u32_le, read_u64_le, read_u8};
/// The known-node bitmask (`SHOWTAXINODES`'s tail; vmangos `PlayerTaxi::m_taximask`, a `TaxiMask`
/// = `uint32[8]`, `DBCStructure.h:842-844` `TaxiMaskSize`/`TaxiMask`, written by
/// `PlayerTaxi::AppendTaximaskTo`, `PlayerTaxi.cpp:53-65`). Bit law (`PlayerTaxi.h:37-49`,
/// `IsTaximaskNodeKnown`/`SetTaximaskNode`): a node id is 1-based, `field = (id-1)/32`, `bit =
/// (id-1)%32` within that word.
/// The known-node bitmask (vmangos `PlayerTaxi::AppendTaximaskTo`, `PlayerTaxi.cpp:53-65`). Node
/// ids are 1-based: node `id` is bit `(id-1) % 32` of word `(id-1) / 32` (`PlayerTaxi.h:37-49`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TaxiMask(pub [u32; 8]);
impl TaxiMask {
/// Whether `node_id` is known. Node id `0` is never valid (ids are 1-based) and reads
/// unknown; an id past the mask's 256-node range reads unknown too (the shipped 1.12 catalog
/// tops out at 85 nodes, so real data never reaches that branch).
/// Whether `node_id` is known; 0 and ids past 256 read unknown (1.12 ships 85 nodes).
pub fn is_known(&self, node_id: u32) -> bool {
node_id != 0
&& self
@@ -33,12 +21,9 @@ impl TaxiMask {
}
}
/// `TaxiError` (vmangos `Objects/Player.h:404-416`) — `SMSG_ACTIVATETAXIREPLY`'s `replyCode`.
/// Codes actually assigned by `Player::ActivateTaxiPathTo` (`Player.cpp:18008-18252`, this
/// session's grep): OK, UNSPECIFIED_SERVER_ERROR, NO_SUCH_PATH, NOT_ENOUGH_MONEY, TOO_FAR, BUSY,
/// ALREADY_MOUNTED, SHAPESHIFTED. `NO_VENDOR_NEARBY`/`NOT_VISITED`/`PLAYER_MOVING`/`SAME_NODE`/
/// `NOT_STANDING` are defined in the enum but no send site ever assigns them — kept for
/// completeness (a private-server fork could still emit one) but never observed on the wire.
/// `SMSG_ACTIVATETAXIREPLY` codes, vmangos `TaxiError` (`Player.h:404-416`). vmangos never sends
/// `NO_VENDOR_NEARBY`, `NOT_VISITED`, `PLAYER_MOVING`, `SAME_NODE` or `NOT_STANDING`
/// (`Player::ActivateTaxiPathTo`, `Player.cpp:18008-18252`).
pub mod taxi_reply {
pub const OK: u32 = 0;
pub const UNSPECIFIED_SERVER_ERROR: u32 = 1;
@@ -55,29 +40,20 @@ pub mod taxi_reply {
pub const NOT_STANDING: u32 = 12;
}
/// Body of `CMSG_TAXINODE_STATUS_QUERY` (vmangos `Packets/Taxi.cpp`,
/// `TaxiNodeStatusQuery::ReadFromWorldPacket`): one full 8-byte guid — "the GUID of the
/// flightmaster" per vmangos's own comment (a normal client sends the NPC it's standing near, not
/// itself). Answered by `SMSG_TAXINODE_STATUS`.
/// Body of `CMSG_TAXINODE_STATUS_QUERY`, answered by `SMSG_TAXINODE_STATUS`.
pub fn taxi_node_status_query(flightmaster_guid: u64) -> Vec<u8> {
flightmaster_guid.to_le_bytes().to_vec()
}
/// Body of `CMSG_TAXIQUERYAVAILABLENODES` (vmangos `Packets/Taxi.cpp`,
/// `TaxiQueryAvailableNodes::ReadFromWorldPacket`): one full 8-byte flight-master guid — the
/// direct taxi-map opener (decision 0496 I4: CONFIRMED as built — the client's interact ladder is
/// first-match-wins low→high over `UNIT_NPC_FLAGS`, so a gossip+taxi NPC pre-empts to gossip and
/// only a pure flightmaster sends this; the gossip taxi option reaches the same menu server-side
/// through `SendTaxiMenu`). Answered by `SMSG_SHOWTAXINODES` on a known node; a
/// never-visited node instead answers `SMSG_NEW_TAXI_PATH` + `SMSG_TAXINODE_STATUS(known=1)` —
/// the server "learns, never opens" on first contact (vmangos `SendLearnNewTaxiNode`).
/// Body of `CMSG_TAXIQUERYAVAILABLENODES`, sent only for a pure flight master: the 1.12 client's
/// interact order sends a gossip+taxi NPC to gossip. A known node answers `SMSG_SHOWTAXINODES`; an
/// unvisited one is learned, not opened (`SMSG_NEW_TAXI_PATH`, `SendLearnNewTaxiNode`).
pub fn taxi_query_available_nodes(flightmaster_guid: u64) -> Vec<u8> {
flightmaster_guid.to_le_bytes().to_vec()
}
/// Body of `CMSG_ACTIVATETAXI` (vmangos `Packets/Taxi.cpp`, `ActivateTaxi::ReadFromWorldPacket`):
/// `u64 flightmasterGuid, u32 node1 (source), u32 node2 (dest)` — a single-hop flight. Answered
/// by `SMSG_ACTIVATETAXIREPLY`; success continues into the mount + `SMSG_MONSTER_MOVE` flight.
/// Body of `CMSG_ACTIVATETAXI`: a flight along one direct `TaxiPath` edge, `node1` to `node2`,
/// answered by `SMSG_ACTIVATETAXIREPLY`.
pub fn activate_taxi(flightmaster_guid: u64, node1: u32, node2: u32) -> Vec<u8> {
let mut body = Vec::with_capacity(16);
body.extend_from_slice(&flightmaster_guid.to_le_bytes());
@@ -86,13 +62,8 @@ pub fn activate_taxi(flightmaster_guid: u64, node1: u32, node2: u32) -> Vec<u8>
body
}
/// Body of `CMSG_ACTIVATETAXIEXPRESS` (vmangos `Packets/Taxi.cpp`,
/// `ActivateTaxiExpress::ReadFromWorldPacket`, `> CLIENT_BUILD_1_9_4` — active for 5875): `u64
/// flightmasterGuid, u32 totalcost, u32 nodeCount, nodeCount x u32 node` — the full multi-hop
/// chain in one send when **no direct `TaxiPath` edge exists current→target** (decision 0496
/// §TU-3, byte-verified discriminator at `0x4dbad0`: it is edge presence, not hop count — a
/// direct edge sends the single-hop `ActivateTaxi` below even when the drawn route detours
/// through intermediate stops).
/// Body of `CMSG_ACTIVATETAXIEXPRESS`: the whole multi-hop chain, sent when no direct `TaxiPath`
/// edge joins the two nodes (client `0x4dbad0`), however many stops the drawn route has.
pub fn activate_taxi_express(flightmaster_guid: u64, total_cost: u32, nodes: &[u32]) -> Vec<u8> {
let mut body = Vec::with_capacity(16 + nodes.len() * 4);
body.extend_from_slice(&flightmaster_guid.to_le_bytes());
@@ -104,14 +75,9 @@ pub fn activate_taxi_express(flightmaster_guid: u64, total_cost: u32, nodes: &[u
body
}
/// Read `SMSG_SHOWTAXINODES` (vmangos `WorldSession::SendTaxiMenu`, `TaxiHandler.cpp:82-96`):
/// `u32 gate`, then **iff `gate != 0`** `u64 flightmasterGuid` (plain, not packed), `u32
/// nearestNode` (the node closest to the flight master — the map's "you are here"), `u32[8]
/// knownMask` ([`TaxiMask`], `AppendTaximaskTo`). The leading word is a real conditional gate in
/// the client's own parser (`0x5ece60` reads the rest only when it is nonzero — byte-verified,
/// decision 0496 §claim-1), not a constant to skip; vmangos always sends `1` (48 bytes), so the
/// gated-off 4-byte body never occurs live — parsed faithfully anyway. Returns `(gate,
/// flightmaster, nearest_node, mask)`, all-zero past a zero gate (decode drops it — no event).
/// Read `SMSG_SHOWTAXINODES` (vmangos `TaxiHandler.cpp:82-96`): a `u32` gate, then, only when it
/// is nonzero (client `0x5ece60`), the flight master, the nearest node and the [`TaxiMask`].
/// vmangos always sends 1.
pub(super) fn read_show_taxi_nodes(r: &mut &[u8]) -> io::Result<(u32, u64, u32, TaxiMask)> {
let window = read_u32_le(r)?;
if window == 0 {
@@ -126,15 +92,12 @@ pub(super) fn read_show_taxi_nodes(r: &mut &[u8]) -> io::Result<(u32, u64, u32,
Ok((window, flightmaster, nearest_node, TaxiMask(mask)))
}
/// Read `SMSG_TAXINODE_STATUS` (vmangos `TaxiNodeStatus::AppendBodyTo`, `Packets/Taxi.cpp:36-40`):
/// `u64 guid` (plain), `u8 known` (a C++ `bool` — `ByteBuffer::operator<<(bool const&)` appends
/// exactly one byte, `ByteBuffer.h:194-197`). Returns `(guid, known)`.
/// Read `SMSG_TAXINODE_STATUS` (`Taxi.cpp:36-40`): `(guid, known)`, `known` a one-byte bool.
pub(super) fn read_taxi_node_status(r: &mut &[u8]) -> io::Result<(u64, u8)> {
Ok((read_u64_le(r)?, read_u8(r)?))
}
/// Read `SMSG_ACTIVATETAXIREPLY` (vmangos `ActivateTaxiReply::AppendBodyTo`,
/// `Packets/Taxi.cpp:46-49`): one [`taxi_reply`] `u32` code.
/// Read `SMSG_ACTIVATETAXIREPLY` (`Taxi.cpp:46-49`): one [`taxi_reply`] code.
pub(super) fn read_activate_taxi_reply(r: &mut &[u8]) -> io::Result<u32> {
read_u32_le(r)
}