diff --git a/crates/benilla-protocol/src/messages/quest/log.rs b/crates/benilla-protocol/src/messages/quest/log.rs index 3f5753dc..dc50a823 100644 --- a/crates/benilla-protocol/src/messages/quest/log.rs +++ b/crates/benilla-protocol/src/messages/quest/log.rs @@ -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 { 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 { 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 { 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 { 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 { 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 { 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, 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); diff --git a/crates/benilla-protocol/src/messages/quest/mod.rs b/crates/benilla-protocol/src/messages/quest/mod.rs index f9056d8e..95fc47d6 100644 --- a/crates/benilla-protocol/src/messages/quest/mod.rs +++ b/crates/benilla-protocol/src/messages/quest/mod.rs @@ -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; diff --git a/crates/benilla-protocol/src/messages/quest/share.rs b/crates/benilla-protocol/src/messages/quest/share.rs index f2e07e7b..ee26c5a1 100644 --- a/crates/benilla-protocol/src/messages/quest/share.rs +++ b/crates/benilla-protocol/src/messages/quest/share.rs @@ -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 { 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 { 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 { 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 { 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 { 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 io::Result { @@ -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(); diff --git a/crates/benilla-protocol/src/messages/reputation.rs b/crates/benilla-protocol/src/messages/reputation.rs index 673e0961..cb86b8ef 100644 --- a/crates/benilla-protocol/src/messages/reputation.rs +++ b/crates/benilla-protocol/src/messages/reputation.rs @@ -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` 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 { 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 { 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 { rep_list_id.to_le_bytes().to_vec() } diff --git a/crates/benilla-protocol/src/messages/roster.rs b/crates/benilla-protocol/src/messages/roster.rs index b82154fe..81852c9f 100644 --- a/crates/benilla-protocol/src/messages/roster.rs +++ b/crates/benilla-protocol/src/messages/roster.rs @@ -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, } diff --git a/crates/benilla-protocol/src/messages/skills.rs b/crates/benilla-protocol/src/messages/skills.rs index a8e7beca..7aab2c76 100644 --- a/crates/benilla-protocol/src/messages/skills.rs +++ b/crates/benilla-protocol/src/messages/skills.rs @@ -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 { diff --git a/crates/benilla-protocol/src/messages/social.rs b/crates/benilla-protocol/src/messages/social.rs index 7058a1a4..61177b42 100644 --- a/crates/benilla-protocol/src/messages/social.rs +++ b/crates/benilla-protocol/src/messages/social.rs @@ -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 `` / `` 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> { 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> { 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> { 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, } -/// 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 { 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 { }) } -/// 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, } -/// 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 { 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 { }) } -/// 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, - /// 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, } @@ -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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { body } -/// A body that is exactly one NUL-terminated string. fn cstring_body(s: &str) -> Vec { 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, s: &str) { body.extend_from_slice(s.as_bytes()); body.push(0); @@ -415,7 +319,6 @@ fn push_cstring(body: &mut Vec, 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"); diff --git a/crates/benilla-protocol/src/messages/spellbook.rs b/crates/benilla-protocol/src/messages/spellbook.rs index e28126ad..dd946de7 100644 --- a/crates/benilla-protocol/src/messages/spellbook.rs +++ b/crates/benilla-protocol/src/messages/spellbook.rs @@ -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, Vec)> { 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, 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 { 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 { 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 { read_u64_le(r) } @@ -135,10 +97,6 @@ pub(super) fn read_cooldown_cheat(r: &mut impl Read) -> io::Result { 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 = [ 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 = [ 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 = [ 1784u32.to_le_bytes().to_vec(), 0x22u64.to_le_bytes().to_vec(), diff --git a/crates/benilla-protocol/src/messages/spells.rs b/crates/benilla-protocol/src/messages/spells.rs index 3f5339c6..01a0f18b 100644 --- a/crates/benilla-protocol/src/messages/spells.rs +++ b/crates/benilla-protocol/src/messages/spells.rs @@ -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, }, } -/// 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, - /// 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, pub dest: Option, } @@ -124,8 +77,7 @@ fn read_spell_cast_targets(r: &mut impl Read) -> io::Result { | 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 { }) } -/// `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 { 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 { }) } -/// `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, - /// `(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, @@ -243,12 +182,11 @@ pub(super) fn read_spell_go(r: &mut impl Read) -> io::Result { 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 { }) } -/// 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 { 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) -> Vec { 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) -> Vec { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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, } -/// 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 { 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)?); diff --git a/crates/benilla-protocol/src/messages/stable.rs b/crates/benilla-protocol/src/messages/stable.rs index df2491b7..16153263 100644 --- a/crates/benilla-protocol/src/messages/stable.rs +++ b/crates/benilla-protocol/src/messages/stable.rs @@ -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 { 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 { 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 { 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 { 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 { 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 { 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 { 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)> { 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 { read_u8(r) } diff --git a/crates/benilla-protocol/src/messages/summon.rs b/crates/benilla-protocol/src/messages/summon.rs index 8a0718c7..e1502d44 100644 --- a/crates/benilla-protocol/src/messages/summon.rs +++ b/crates/benilla-protocol/src/messages/summon.rs @@ -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 { Ok(SummonRequest { summoner: read_u64_le(r)?, @@ -55,12 +27,8 @@ pub(super) fn read_summon_request(r: &mut &[u8]) -> io::Result { }) } -/// 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 { summoner_guid.to_le_bytes().to_vec() } @@ -69,9 +37,6 @@ pub fn summon_response(summoner_guid: u64) -> Vec { 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!( diff --git a/crates/benilla-protocol/src/messages/tabard.rs b/crates/benilla-protocol/src/messages/tabard.rs index c6c85ab8..cdf81db4 100644 --- a/crates/benilla-protocol/src/messages/tabard.rs +++ b/crates/benilla-protocol/src/messages/tabard.rs @@ -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 { 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 { 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 { 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 { 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 { 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 { 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"), diff --git a/crates/benilla-protocol/src/messages/taxi.rs b/crates/benilla-protocol/src/messages/taxi.rs index dee70ac1..0424d318 100644 --- a/crates/benilla-protocol/src/messages/taxi.rs +++ b/crates/benilla-protocol/src/messages/taxi.rs @@ -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 { 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 { 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 { 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 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 { 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 { read_u32_le(r) }