From 90a28f3d76aec50740db2dd8b96499f01a6bdff7 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 23 Sep 2026 20:10:35 +0700 Subject: [PATCH] comments: protocol, the loot, mail, meeting-stone, mirror-timer, monster-move and movement messages 812 comment lines become 264; the code is unchanged. JumpInfo's doc called zspeed up-positive, against its own captured fact and every consumer, which negate it; the up-positive formulas are gone. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-protocol/src/messages/loot.rs | 299 +++++------------- crates/benilla-protocol/src/messages/mail.rs | 130 +++----- .../src/messages/meeting_stone.rs | 29 +- .../src/messages/mirror_timer.rs | 99 ++---- crates/benilla-protocol/src/messages/mod.rs | 94 ++---- .../src/messages/monster_move.rs | 157 +++------ .../benilla-protocol/src/messages/movement.rs | 254 ++++----------- 7 files changed, 257 insertions(+), 805 deletions(-) diff --git a/crates/benilla-protocol/src/messages/loot.rs b/crates/benilla-protocol/src/messages/loot.rs index 7f5e23ad..9317243b 100644 --- a/crates/benilla-protocol/src/messages/loot.rs +++ b/crates/benilla-protocol/src/messages/loot.rs @@ -1,25 +1,12 @@ -//! Loot messages — the solo-loot wire family (opcodes 264, 349-355, 357-358; VERIFIED vmangos -//! `Server/Protocol/Opcodes_1_12_1.h:267,350-359`). Bodies from vmangos `Server/Packets/Loot.{h,cpp}` -//! (the light client-read / server-append shapes) + the hand-serialized `LootMgr.cpp` -//! (`operator<<(LootItem)`/`operator<<(LootView)`) and `Player.cpp` (`SendLoot`/`SendLootError`). -//! The group-roll family (opcodes 670-674 — `SMSG_LOOT_START_ROLL`/`SMSG_LOOT_ROLL`/ -//! `SMSG_LOOT_ROLL_WON`/`SMSG_LOOT_ALL_PASSED` + our `CMSG_LOOT_ROLL`) rides here too, from -//! vmangos `Group/Group.cpp`'s four senders (decision 0591). Master loot -//! (`CMSG_LOOT_MASTER_GIVE` 675 / `SMSG_LOOT_MASTER_LIST` 676) lands here too, from -//! `Group::MasterLoot` + `WorldSession::HandleLootMasterGiveOpcode` (decision 1675). +//! Loot wire messages: the loot window, group rolls, master loot and item-push results. use std::io; use crate::wire::{capacity_hint, read_u32_le, read_u64_le, read_u8}; -/// One item row on the normal shape of `SMSG_LOOT_RESPONSE` (vmangos `LootMgr.cpp:837-845` the -/// `LootItem` write, wrapped by the slot index + trailing slot-type byte the caller appends around -/// it — `LootMgr.cpp:900-912`, `ALL_PERMISSION` branch, the solo-loot path). `slot` is the wire's -/// 0-based loot-window slot — what [`autostore_loot_item`] addresses back; a quest item rides the -/// same list with `slot = items.len() + i` (`LootMgr.cpp:970`, out of the solo drop-loot path but -/// parsed the same way if ever present). The wire's `randomSuffix` field is **not** a real per-item -/// value — the server always writes a literal `0` there (`LootMgr.cpp:841`) — so this parser reads -/// and discards it. +/// One item row of `SMSG_LOOT_RESPONSE` (vmangos `LootMgr.cpp:837-845,900-912`). `slot` is the +/// 0-based wire slot that [`autostore_loot_item`] sends back; quest items follow at +/// `items.len() + i` (`LootMgr.cpp:970`). The wire's `randomSuffix` is always 0 and is discarded. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LootItem { pub slot: u8, @@ -28,44 +15,27 @@ pub struct LootItem { /// `item_template.display_id` → icon via `ItemDisplayInfo.dbc`. pub display_info_id: u32, pub random_property_id: u32, - /// A [`slot_type`] code. Solo paths (`ALL_PERMISSION`/`OWNER_PERMISSION`) always write - /// `ALLOW_LOOT` (`LootMgr.cpp:918-926`); `MASTER` rides the master-loot path (decision 1675), - /// `ROLL_ONGOING` a group roll in flight, `LOCKED` a requirement the viewer fails. + /// A [`slot_type`] code; solo looting always writes `ALLOW_LOOT` (`LootMgr.cpp:918-926`). pub slot_type: u8, } -/// `LootSlotType` (VERIFIED vmangos `LootMgr.h:75-83`) — the `u8` riding after each [`LootItem`] on -/// `SMSG_LOOT_RESPONSE`. +/// `LootSlotType` (vmangos `LootMgr.h:75-83`): the `u8` that ends each [`LootItem`] row. pub mod slot_type { - /// The player can loot the item. pub const ALLOW_LOOT: u8 = 0; - /// A group roll is ongoing on this item — shown, not yet lootable. + /// A group roll is in progress on this item: shown, not yet lootable. pub const ROLL_ONGOING: u8 = 1; - /// Only the group's loot master can distribute this item — the row is not takeable by a - /// `CMSG_AUTOSTORE_LOOT_ITEM` from anyone, and the master looter assigns it with - /// [`loot_master_give`] instead (decision 1675). - /// - /// **vmangos stamps this on *every* row it shows a group member under master loot** - /// (`LootMgr.cpp:917-941`, the `MASTER_PERMISSION` arm, which never consults - /// `is_underthreshold`) — even greys, which the server's own take handler would have allowed - /// (`LootHandler.cpp:171-182`). `LootItem::GetSlotTypeForSharedLoot` (`LootMgr.cpp:443-459`) - /// encodes the threshold-aware answer but is not on this path. So under vmangos the whole - /// window reads as master-only; we render the wire (decision 0086's rule). + /// Only the loot master can hand this out ([`loot_master_give`]). Under master loot vmangos + /// sends it on every row, even greys its take handler allows (`LootMgr.cpp:917-941`). pub const MASTER: u8 = 2; - /// Shown red — not lootable (a missing-requirement item in a group). + /// Shown red and not lootable: a requirement the viewer fails, in a group. pub const LOCKED: u8 = 3; /// Owner-permission solo looting, binding checks skipped. pub const OWNER: u8 = 4; } -/// `LootType` (VERIFIED vmangos `LootMgr.h:49-61`) — the `u8` naming what's being looted on the -/// normal shape of `SMSG_LOOT_RESPONSE`. Only 4 values ever reach the wire: `Player::SendLoot` -/// (`Player.cpp:8117-8131`) remaps `SKINNING`(6)/`INSIGNIA`(22) to `PICKPOCKETING` and -/// `FISHING_HOLE`(20)/`FISHING_FAIL`(21) to `FISHING` before sending — the client has no separate -/// UI state for those. `CMSG_LOOT` (a corpse/creature loot request) always answers with `CORPSE` -/// (`LootHandler.cpp:340-354`, `HandleLootOpcode` → `SendLoot(guid, LOOT_CORPSE)` unconditionally); -/// the rest arrive only from other loot sources (pickpocket spell, fishing, disenchant), out of -/// scope for this slice but pinned for completeness. +/// `LootType` (vmangos `LootMgr.h:49-61`). Only these four reach the wire: `SendLoot` remaps +/// skinning and insignia to `PICKPOCKETING`, fishing holes and fails to `FISHING` +/// (`Player.cpp:8117-8131`); `CMSG_LOOT` always gets `CORPSE` (`LootHandler.cpp:340-354`). pub mod loot_type { pub const CORPSE: u8 = 1; pub const PICKPOCKETING: u8 = 2; @@ -73,16 +43,10 @@ pub mod loot_type { pub const DISENCHANTING: u8 = 4; } -/// `LootError` (VERIFIED vmangos `LootMgr.h:85-100`) — the `u8` on the error shape of -/// `SMSG_LOOT_RESPONSE` (`Player::SendLootError`, `Player.cpp:7744-7750`). A plain `CMSG_LOOT` on a -/// creature corpse can only surface a subset: `DIDNT_KILL` (guid isn't a lootable -/// creature/player/corpse, or the creature check fails — `LootHandler.cpp:342-346`, -/// `Player.cpp:7938-7943`), `PLAYER_NOT_FOUND` (dead/not-in-world — `LootHandler.cpp:349-353`), -/// `PLAY_TIME_EXCEEDED`, `NOTSTANDING`, `STUNNED` (`LootHandler.cpp:358-373`), and `TOO_FAR` -/// (range check in `Player.cpp:7938-7947`). The `MASTER_*` trio answers a refused -/// [`loot_master_give`] and reaches only the master looter (`LootHandler.cpp:618-750`, decision -/// 1675); `BAD_FACING`/`LOCKED`/`ALREADY_PICKPOCKETED`/`NOT_WHILE_SHAPESHIFTED` ride other loot -/// sources, pinned for completeness. +/// `LootError` (vmangos `LootMgr.h:85-100`): the code on the error shape of `SMSG_LOOT_RESPONSE`. +/// `CMSG_LOOT` can draw `DIDNT_KILL`, `PLAYER_NOT_FOUND`, `PLAY_TIME_EXCEEDED`, `NOTSTANDING`, +/// `STUNNED` (`LootHandler.cpp:342-373`) and `TOO_FAR` (`Player.cpp:7938-7947`); the `MASTER_*` +/// codes answer a refused [`loot_master_give`], to the master looter only. pub mod loot_error { pub const DIDNT_KILL: u8 = 0; pub const TOO_FAR: u8 = 4; @@ -99,70 +63,49 @@ pub mod loot_error { pub const NOT_WHILE_SHAPESHIFTED: u8 = 16; } -/// The two shapes `SMSG_LOOT_RESPONSE` can take, disambiguated by the `lootType` byte right after -/// the guid: `0` means "no window — this is an error", anything else is a real loot window. +/// The two shapes of `SMSG_LOOT_RESPONSE`: a `lootType` of 0 after the guid is the error shape. #[derive(Debug, Clone, PartialEq)] pub enum LootResponseBody { - /// The normal shape (VERIFIED vmangos `Player.cpp:8135-8138` + `operator<<(LootView)`, - /// `LootMgr.cpp:848-873`): `u32 gold, u8 itemCount`, then `itemCount` rows of [`LootItem`]. + /// `u32 gold, u8 itemCount`, then the [`LootItem`] rows (vmangos `LootMgr.cpp:848-873`). Items { loot_type: u8, gold: u32, items: Vec, }, - /// The error shape (VERIFIED vmangos `Player::SendLootError`, `Player.cpp:7744-7750`): just - /// the trailing `u8` error code, nothing else follows. + /// Only a [`loot_error`] code follows (`Player.cpp:7744-7750`). Error { error: u8 }, } -/// Body of `CMSG_LOOT` (VERIFIED vmangos `Server/Packets/Loot.cpp:8-11`, -/// `LootUnit::ReadFromWorldPacket`): one full 8-byte guid — the corpse/creature/player being -/// opened. Always answered by `SMSG_LOOT_RESPONSE` (one shape or the other). +/// `CMSG_LOOT` (`Loot.cpp:8-11`): the guid to open, always answered by `SMSG_LOOT_RESPONSE`. pub fn loot(guid: u64) -> Vec { guid.to_le_bytes().to_vec() } -/// Body of `CMSG_AUTOSTORE_LOOT_ITEM` (VERIFIED vmangos `Server/Packets/Loot.cpp:3-6`, -/// `AutoStoreLootItem::ReadFromWorldPacket`): one `u8` — the 0-based wire loot slot (see -/// [`LootItem::slot`]). The server auto-places the item into the first free bag slot -/// (`LootHandler.cpp:183-206`, `CanStoreNewItem`/`StoreNewItem`); no specific-slot variant exists -/// for loot (unlike vendor buy). +/// `CMSG_AUTOSTORE_LOOT_ITEM` (`Loot.cpp:3-6`): a [`LootItem::slot`]; the server picks the bag. pub fn autostore_loot_item(loot_slot: u8) -> Vec { vec![loot_slot] } -/// Body of `CMSG_LOOT_MONEY` (VERIFIED vmangos `Server/Protocol/Opcodes_1_12_1.h:351` — the -/// handler `WorldSession::HandleLootMoneyOpcode` takes a `NullClientPacket`): empty. +/// `CMSG_LOOT_MONEY`: an empty body (vmangos `Opcodes_1_12_1.h:351`). pub fn loot_money() -> Vec { Vec::new() } -/// Body of `CMSG_LOOT_RELEASE` (VERIFIED vmangos `Server/Packets/Loot.cpp:13-16`, -/// `LootRelease::ReadFromWorldPacket`): one full 8-byte guid, though the server ignores it and -/// releases whatever loot guid it has stored for us instead (`Server/Packets/Loot.h:31-36`: "not -/// used by server"; handler `HandleLootReleaseOpcode`, `LootHandler.cpp:383-389`). +/// `CMSG_LOOT_RELEASE` (`Loot.cpp:13-16`): a guid the server ignores for the loot guid it stored. pub fn loot_release(guid: u64) -> Vec { guid.to_le_bytes().to_vec() } -/// `RollVote` (VERIFIED vmangos `Group/Group.h:88-99`) — the `u8` vote a client may cast on -/// `CMSG_LOOT_ROLL`. The server hard-rejects anything `>= 3` (`MAX_ROLL_FROM_CLIENT`, -/// `GroupHandler.cpp:367-368`), so these three are the whole client-side vocabulary; `Group.h`'s -/// `ROLL_NOT_EMITED_YET`(3)/`ROLL_NOT_VALID`(4) are server bookkeeping that never reaches us. -/// -/// These same values also ride *back* as `SMSG_LOOT_ROLL`/`SMSG_LOOT_ROLL_WON`'s `roll_type` — -/// but there they are **not** self-describing: see [`LootRoll::is_dice`] for the disambiguation. +/// `RollVote` (vmangos `Group/Group.h:88-99`): the server rejects a vote `>= 3` +/// (`GroupHandler.cpp:367-368`). Overloaded on `SMSG_LOOT_ROLL`: see [`LootRoll::is_dice`]. pub mod roll_vote { pub const PASS: u8 = 0; pub const NEED: u8 = 1; pub const GREED: u8 = 2; } -/// Body of `CMSG_LOOT_ROLL` (VERIFIED vmangos `Server/Packets/Loot.cpp:18-23`, -/// `LootRoll::ReadFromWorldPacket`): `u64 lootedTarget, u32 itemSlot, u8 rollType` — the corpse -/// guid + wire loot slot that identify *which* roll, and our [`roll_vote`]. The pair -/// `(lootedTarget, itemSlot)` is the roll's server-side identity; the client-side `rollID` the -/// FrameXML API passes around is ours alone and never reaches the wire. +/// `CMSG_LOOT_ROLL` (`Loot.cpp:18-23`): `u64 lootedTarget, u32 itemSlot, u8 rollType`. The +/// corpse and slot identify the roll; the FrameXML `rollID` never reaches the wire. pub fn loot_roll(looted_target: u64, item_slot: u32, roll_type: u8) -> Vec { let mut body = looted_target.to_le_bytes().to_vec(); body.extend_from_slice(&item_slot.to_le_bytes()); @@ -170,15 +113,8 @@ pub fn loot_roll(looted_target: u64, item_slot: u32, roll_type: u8) -> Vec { body } -/// Body of `CMSG_LOOT_MASTER_GIVE` (VERIFIED vmangos `Server/Packets/Loot.{h:50-59,cpp:25-30}`, -/// `LootMasterGive::ReadFromWorldPacket`): `u64 lootGuid, u8 slotId, u64 playerGuid` — the open -/// loot source, the **wire** loot slot (see [`LootItem::slot`], not the display row), and the -/// group member to hand the item to. -/// -/// Sent only by the master looter; the server re-checks all three (`LootHandler.cpp:618-750`) and -/// answers a refusal with a [`loot_error`] `MASTER_*` code on `SMSG_LOOT_RESPONSE`. Note the slot -/// width: this one is a `u8` where the roll family's `item_slot` is a `u32`, so the two are not -/// interchangeable even though they index the same array. +/// `CMSG_LOOT_MASTER_GIVE` (`Loot.cpp:25-30`): `u64 lootGuid, u8 slotId, u64 playerGuid`; the +/// slot is a `u8` here, a `u32` in the roll family. A refusal is a `MASTER_*` [`loot_error`]. pub fn loot_master_give(loot_guid: u64, slot: u8, player_guid: u64) -> Vec { let mut body = loot_guid.to_le_bytes().to_vec(); body.push(slot); @@ -186,69 +122,40 @@ pub fn loot_master_give(loot_guid: u64, slot: u8, player_guid: u64) -> Vec { body } -/// `SMSG_LOOT_START_ROLL` (VERIFIED vmangos `Server/Packets/Loot.{h:78-90,cpp:43-51}`) — a group -/// roll opened on one drop; sent to every eligible roller. `countdown_ms` is the server's -/// `LOOT_ROLL_TIMEOUT`, a flat `1*MINUTE*IN_MILLISECONDS` = `60_000` at 5875 -/// (`Group/Group.cpp:67`), but it is read from the wire rather than assumed. -/// -/// The wire's `randomSuffix` field is a literal `0` the server never fills (`Group.cpp:764`), so -/// this parser reads and discards it — same treatment as [`LootItem`]'s. +/// `SMSG_LOOT_START_ROLL` (`Loot.cpp:43-51`): a group roll opened on one drop, sent to every +/// eligible roller. Its `randomSuffix` is always 0 (`Group/Group.cpp:764`) and is discarded. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LootStartRoll { - /// The corpse/container being looted — with `item_slot`, the roll's wire identity. pub looted_target: u64, - /// The 0-based **wire** loot slot (the same vocabulary as [`LootItem::slot`], widened to `u32` - /// here because this packet carries it as one). + /// The wire [`LootItem::slot`], carried as a `u32` here. pub item_slot: u32, pub item_id: u32, pub random_property_id: u32, - /// How long the roll stays open, in milliseconds. + /// How long the roll stays open, in ms (vmangos: `60_000`, `Group.cpp:67`). pub countdown_ms: u32, } -/// `SMSG_LOOT_ROLL` (VERIFIED vmangos `Server/Packets/Loot.{h:92-106,cpp:53-63}`) — one -/// announcement about one roller, broadcast to every eligible roller. -/// -/// **The `(roll_number, roll_type)` pair is overloaded** and this is the load-bearing subtlety of -/// the whole family. vmangos emits exactly four shapes (VERIFIED `Group/Group.cpp:970-990` for the -/// votes, `:1163`/`:1214` for the dice): -/// -/// | `roll_number` | `roll_type` | meaning | -/// |---------------|-------------|--------------------------------------------| -/// | `0` | `0` | that player **voted** Need | -/// | `128` | `128` | that player **voted** Pass | -/// | `128` | `2` | that player **voted** Greed | -/// | `1..=100` | `1` or `2` | that player's **dice result** (Need/Greed) | -/// -/// So `roll_type` alone cannot tell a Greed *vote* (`128, 2`) from a Greed *dice roll* -/// (`57, 2`) — `roll_number` is the discriminator. [`LootRoll::is_dice`] encodes it. +/// `SMSG_LOOT_ROLL` (`Loot.cpp:53-63`): one player's vote or dice roll, sent to every roller. +/// `(roll_number, roll_type)` is overloaded: a vote is `(0, 0)` Need, `(128, 128)` Pass or +/// `(128, 2)` Greed; a dice result is `(1..=100, 1 or 2)` (`Group/Group.cpp:970-990,1163,1214`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LootRoll { pub looted_target: u64, pub item_slot: u32, - /// The player this announcement is about — compare against our own guid for the "You …" - /// phrasing. pub roller: u64, pub item_id: u32, pub random_property_id: u32, - /// `1..=100` for a dice result; `0`/`128` for a vote announcement (see the type docs). pub roll_number: u8, - /// A [`roll_vote`] value for a dice result; `0`/`2`/`128` for a vote (see the type docs). pub roll_type: u8, } impl LootRoll { - /// Whether this is an actual **dice result** (`roll_number` in `1..=100`) rather than one of - /// the three vote announcements (`roll_number` `0` or `128`). See the type docs for the table - /// this encodes — vmangos only ever emits `urand(1, 100)` for real rolls (`Group.cpp:1162`, - /// `:1213`), and only `0`/`128` for votes, so the ranges cannot collide. + /// A dice result rather than a vote: vmangos rolls `urand(1, 100)` (`Group.cpp:1162,1213`). pub fn is_dice(&self) -> bool { (1..=100).contains(&self.roll_number) } - /// The vote this announcement reports when it is *not* a dice result — a [`roll_vote`] value. - /// `None` for a dice result (there `roll_type` is already a true [`roll_vote`]) or for a pair - /// outside the four shapes vmangos emits. + /// The [`roll_vote`] this announcement reports, when it is a vote. pub fn vote(&self) -> Option { match (self.roll_number, self.roll_type) { (0, 0) => Some(roll_vote::NEED), @@ -259,9 +166,8 @@ impl LootRoll { } } -/// `SMSG_LOOT_ROLL_WON` (VERIFIED vmangos `Server/Packets/Loot.{h:108-122,cpp:65-75}`) — the roll -/// resolved and `winner` took the item. Note the field order differs from [`LootRoll`]: the winner -/// guid lands *after* the item block, not before it. +/// `SMSG_LOOT_ROLL_WON` (`Loot.cpp:65-75`): `winner` took the item. Unlike [`LootRoll`], the +/// winner guid comes after the item fields. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LootRollWon { pub looted_target: u64, @@ -269,21 +175,14 @@ pub struct LootRollWon { pub item_id: u32, pub random_property_id: u32, pub winner: u64, - /// The winning dice value (`1..=100`), or a literal `100` on the uncontested - /// single-eligible-roller short-circuit (`Group.cpp:1110`, sent with `ROLL_NEED`). + /// The winning dice value, or 100 with Need when only one player may roll (`Group.cpp:1110`). pub roll_number: u8, - /// A [`roll_vote`] value — which kind of roll won. + /// The [`roll_vote`] that won. pub roll_type: u8, } -/// `SMSG_LOOT_ALL_PASSED` (VERIFIED vmangos `Server/Packets/Loot.{h:124-134,cpp:77-84}`) — nobody -/// wanted it; the roll closes and the item returns to the corpse for ordinary looting. -/// -/// **Field-order quirk:** unlike every other packet in this family, the append order here is -/// `itemRandomPropId` **then** `randomSuffixId` (`Loot.cpp:79-83`) — the two `u32`s are swapped -/// relative to [`LootStartRoll`]/[`LootRoll`]/[`LootRollWon`]. Both are effectively `0` in -/// practice (the server never fills `randomSuffixId`), so the swap is invisible on the wire today, -/// but the parser follows the source order rather than the family's. +/// `SMSG_LOOT_ALL_PASSED` (`Loot.cpp:77-84`): everyone passed; the item stays for normal looting. +/// Unlike the rest of the family, `itemRandomPropId` comes before `randomSuffixId` here. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LootAllPassed { pub looted_target: u64, @@ -292,7 +191,6 @@ pub struct LootAllPassed { pub random_property_id: u32, } -/// Read `SMSG_LOOT_START_ROLL` (see [`LootStartRoll`]). pub(super) fn read_loot_start_roll(r: &mut &[u8]) -> io::Result { let looted_target = read_u64_le(r)?; let item_slot = read_u32_le(r)?; @@ -309,7 +207,6 @@ pub(super) fn read_loot_start_roll(r: &mut &[u8]) -> io::Result { }) } -/// Read `SMSG_LOOT_ROLL` (see [`LootRoll`] — including the overloaded `(roll_number, roll_type)`). pub(super) fn read_loot_roll(r: &mut &[u8]) -> io::Result { let looted_target = read_u64_le(r)?; let item_slot = read_u32_le(r)?; @@ -330,7 +227,6 @@ pub(super) fn read_loot_roll(r: &mut &[u8]) -> io::Result { }) } -/// Read `SMSG_LOOT_ROLL_WON` (see [`LootRollWon`] — the winner guid follows the item block). pub(super) fn read_loot_roll_won(r: &mut &[u8]) -> io::Result { let looted_target = read_u64_le(r)?; let item_slot = read_u32_le(r)?; @@ -351,13 +247,12 @@ pub(super) fn read_loot_roll_won(r: &mut &[u8]) -> io::Result { }) } -/// Read `SMSG_LOOT_ALL_PASSED` (see [`LootAllPassed`] — note the swapped `u32` pair). pub(super) fn read_loot_all_passed(r: &mut &[u8]) -> io::Result { let looted_target = read_u64_le(r)?; let item_slot = read_u32_le(r)?; let item_id = read_u32_le(r)?; let random_property_id = read_u32_le(r)?; - let _random_suffix = read_u32_le(r)?; // the swapped tail — a literal 0 (Group.cpp:849) + let _random_suffix = read_u32_le(r)?; // the swapped tail, a literal 0 (Group.cpp:849) Ok(LootAllPassed { looted_target, item_slot, @@ -366,19 +261,12 @@ pub(super) fn read_loot_all_passed(r: &mut &[u8]) -> io::Result { }) } -/// Read `SMSG_LOOT_MASTER_LIST` (VERIFIED vmangos `Server/Packets/Group.{h:288-296,cpp:182-187}`, -/// `LootMasterList::AppendBodyTo`): `u8 count`, then `count` full guids — the group members -/// eligible to be handed an item from the loot window that is opening. -/// -/// It rides the **open**, not a click: `Player::SendLoot` calls `Group::MasterLoot` -/// (`Player.cpp:8077-8081`) before it writes `SMSG_LOOT_RESPONSE`, so the candidate list is -/// already in hand by the time the window paints. vmangos sends it to *every* group member who -/// opens the corpse, not only the master looter (the `MASTER_PERMISSION` arm is unconditional), -/// and filters the list by loot-XP distance and `IsAllowedLooter` (`Group.cpp:914-940`). +/// `SMSG_LOOT_MASTER_LIST` (vmangos `Server/Packets/Group.cpp:182-187`): `u8 count`, then the +/// eligible members' guids. Every member who opens the corpse gets it just before +/// `SMSG_LOOT_RESPONSE` (`Player.cpp:8077-8081`), filtered by range (`Group/Group.cpp:914-940`). pub(super) fn read_loot_master_list(r: &mut &[u8]) -> io::Result> { let count = read_u8(r)?; - // The candidates are group members (`Group.cpp:914-940`): vmangos `MAX_RAID_SIZE` 40 - // (`Group/Group.h:50`). + // At most a raid: vmangos `MAX_RAID_SIZE` is 40 (`Group/Group.h:50`). let mut candidates = Vec::with_capacity(capacity_hint(count, 40)); for _ in 0..count { candidates.push(read_u64_le(r)?); @@ -386,11 +274,8 @@ pub(super) fn read_loot_master_list(r: &mut &[u8]) -> io::Result> { Ok(candidates) } -/// Read `SMSG_LOOT_RESPONSE` — both shapes (see [`LootResponseBody`]). Wire order (VERIFIED -/// vmangos `Player.cpp:8135-8138`): `u64 guid, u8 lootType`, then either the error tail (`lootType -/// == 0`) or `u32 gold, u8 itemCount` + `itemCount` × [`LootItem`] rows (each `u8 slot, u32 -/// itemid, u32 count, u32 displayInfoID, u32 0(randomSuffix, discarded), u32 randomPropertyId, u8 -/// slotType` — `LootMgr.cpp:848-873,900-912`, the `ALL_PERMISSION` solo branch). +/// `SMSG_LOOT_RESPONSE` (vmangos `Player.cpp:8135-8138`): `u64 guid, u8 lootType`, then the +/// error code when `lootType` is 0, else gold and the [`LootItem`] rows. pub(super) fn read_loot_response(r: &mut &[u8]) -> io::Result<(u64, LootResponseBody)> { let guid = read_u64_le(r)?; let loot_type = read_u8(r)?; @@ -400,8 +285,7 @@ pub(super) fn read_loot_response(r: &mut &[u8]) -> io::Result<(u64, LootResponse } let gold = read_u32_le(r)?; let count = read_u8(r)?; - // The loot view indexes both lists by one `u8`: `MAX_NR_LOOT_ITEMS` 16 + `MAX_NR_QUEST_ITEMS` - // 32 (vmangos `LootMgr.h:34,36`). + // `MAX_NR_LOOT_ITEMS` 16 + `MAX_NR_QUEST_ITEMS` 32 (vmangos `LootMgr.h:34,36`). let mut items = Vec::with_capacity(capacity_hint(count, 16 + 32)); for _ in 0..count { let slot = read_u8(r)?; @@ -430,44 +314,34 @@ pub(super) fn read_loot_response(r: &mut &[u8]) -> io::Result<(u64, LootResponse )) } -/// Read `SMSG_LOOT_RELEASE_RESPONSE` (VERIFIED vmangos `Server/Packets/Loot.h:137-145`, -/// `Player::SendLootRelease`, `Player.cpp:7736-7742`): `u64 guid, u8 result` — `result` is always -/// `1` (the struct's default; vmangos never sets it otherwise). +/// `SMSG_LOOT_RELEASE_RESPONSE` (`Loot.h:137-145`): `u64 guid, u8 result`; the result is always 1. pub(super) fn read_loot_release_response(r: &mut &[u8]) -> io::Result<(u64, u8)> { Ok((read_u64_le(r)?, read_u8(r)?)) } -/// Read `SMSG_LOOT_REMOVED` (VERIFIED vmangos `Server/Packets/Loot.h:147-154`): one `u8` — the wire -/// loot slot that was just taken (by anyone; the row disappears from every current looter's -/// window, `Loot::NotifyItemRemoved`). +/// `SMSG_LOOT_REMOVED` (`Loot.h:147-154`): the wire slot just taken, sent to every looter. pub(super) fn read_loot_removed(r: &mut &[u8]) -> io::Result { read_u8(r) } -/// Read `SMSG_LOOT_MONEY_NOTIFY` (VERIFIED vmangos `Server/Packets/Loot.h:69-76`): one `u32` — the -/// requester's share of the coin pile (an equal split among current looters; solo looting is the -/// degenerate 1-looter case, the requester's whole share). +/// `SMSG_LOOT_MONEY_NOTIFY` (`Loot.h:69-76`): our share of the coin, split equally among looters. pub(super) fn read_loot_money_notify(r: &mut &[u8]) -> io::Result { read_u32_le(r) } -/// `SMSG_ITEM_PUSH_RESULT` (VERIFIED vmangos `Server/Packets/Item.h:324-345` + -/// `Item.cpp:211-224`; both `SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_10_2` conditionals evaluate -/// *included* for build 5875) — drives the "You receive loot: …" / "You receive item: …" chat -/// line. `item_slot` is `0xFFFF_FFFF` when the item stacked onto an existing slot instead of -/// landing in a fresh one. +/// `SMSG_ITEM_PUSH_RESULT` (vmangos `Server/Packets/Item.cpp:211-224`, both post-1.10.2 fields +/// present at 5875): the "You receive …" chat line. `item_slot` is `0xFFFF_FFFF` when it stacked. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ItemPushResult { pub player_guid: u64, /// `false` = looted, `true` = received from an NPC (vendor purchase, quest reward). pub from_npc: bool, - /// `true` = the item was CREATED (crafting's `EffectCreateItem`, a conjure aura — vmangos - /// `SendNewItem`'s own comment "0=received, 1=created"; the only `created=true` senders are - /// the create-item paths) → the client's "You create: …" line. NOT a stack-merge flag. + /// Crafted or conjured, giving the "You create: …" line; not a stack-merge flag (vmangos + /// `SendNewItem`: "0=received, 1=created"). pub created: bool, pub show_in_chat: bool, - /// Which bag slot the item went to (`NULL_BAG`/`INVENTORY_SLOT_BAG_0`-style index — the same - /// vocabulary as [`super::items::BAG_PLAYER_INVENTORY`]/[`super::items::SLOT_BAG_FIRST`]). + /// The bag it went to, numbered as [`super::items::BAG_PLAYER_INVENTORY`] and + /// [`super::items::SLOT_BAG_FIRST`]. pub bag_slot: u8, pub item_slot: u32, pub item_entry: u32, @@ -476,7 +350,6 @@ pub struct ItemPushResult { pub count: u32, } -/// Read `SMSG_ITEM_PUSH_RESULT` (see [`ItemPushResult`] for the field-inclusion verification). pub(super) fn read_item_push_result(r: &mut &[u8]) -> io::Result { let player_guid = read_u64_le(r)?; let received = read_u32_le(r)?; @@ -516,40 +389,32 @@ mod tests { #[test] fn cmsg_bodies_golden() { - // CMSG_LOOT (Server/Packets/Loot.cpp:8-11): a full guid. assert_eq!( loot(0x1234_5678_9abc_def0), hx("f0debc9a78563412"), "CMSG_LOOT body" ); - // CMSG_AUTOSTORE_LOOT_ITEM (Loot.cpp:3-6): one u8 wire slot. assert_eq!( autostore_loot_item(3), hx("03"), "CMSG_AUTOSTORE_LOOT_ITEM body" ); - // CMSG_LOOT_MONEY: empty. assert_eq!(loot_money(), Vec::::new(), "CMSG_LOOT_MONEY body"); - // CMSG_LOOT_RELEASE (Loot.cpp:13-16): a full guid (server ignores it). assert_eq!( loot_release(0x1234_5678_9abc_def0), hx("f0debc9a78563412"), "CMSG_LOOT_RELEASE body" ); - // CMSG_LOOT_ROLL (Loot.cpp:18-23): u64 lootedTarget, u32 itemSlot, u8 rollType. assert_eq!( loot_roll(0x1234_5678_9abc_def0, 2, roll_vote::GREED), hx("f0debc9a785634120200000002"), "CMSG_LOOT_ROLL body" ); - // CMSG_LOOT_MASTER_GIVE (Loot.cpp:25-30): u64 lootGuid, u8 slotId, u64 playerGuid. The - // slot is a BYTE here — the roll family's itemSlot is a u32 over the same array, so the - // two builders are not interchangeable and the byte count proves which one this is. let give = loot_master_give(0x1234_5678_9abc_def0, 2, 0x0fed_cba9_8765_4321); assert_eq!( give, @@ -559,8 +424,7 @@ mod tests { assert_eq!(give.len(), 17, "8 + 1 + 8, not 8 + 4 + 8"); } - /// The five group-roll opcode values (VERIFIED vmangos `Opcodes_1_12_1.h:671-675`) and the - /// master-loot pair that follows them (`:676-677`). + /// The roll and master-loot opcodes (vmangos `Opcodes_1_12_1.h:671-677`). #[test] fn roll_opcode_values() { assert_eq!(opcode::SMSG_LOOT_ALL_PASSED, 670); @@ -574,7 +438,6 @@ mod tests { #[test] fn loot_master_list_decodes() { - // Group.cpp:182-187: u8 count, then count full guids. let mut body = vec![3u8]; for guid in [0xAAu64, 0xBB, 0xCC] { body.extend_from_slice(&guid.to_le_bytes()); @@ -588,9 +451,7 @@ mod tests { } } - /// An empty candidate list is a legal body, not a truncation: `Group::MasterLoot` - /// (`Group.cpp:925-937`) filters by loot-XP distance and `IsAllowedLooter`, so a group whose - /// other members are all out of range sends `count = 0` and nothing else. + /// `Group::MasterLoot` filters by range (`Group/Group.cpp:925-937`): `count = 0` is legal. #[test] fn loot_master_list_accepts_an_empty_list() { match parse_server(opcode::SMSG_LOOT_MASTER_LIST, &[0u8]).unwrap() { @@ -601,12 +462,10 @@ mod tests { #[test] fn loot_start_roll_decodes() { - // Group.cpp:759-772 / Loot.cpp:43-51: guid, itemSlot, itemEntry, randomSuffix(0), - // randomPropId, countdown. let mut body = 0xAAu64.to_le_bytes().to_vec(); body.extend_from_slice(&1u32.to_le_bytes()); // itemSlot body.extend_from_slice(&17182u32.to_le_bytes()); // itemEntryId - body.extend_from_slice(&0u32.to_le_bytes()); // randomSuffix — always 0 + body.extend_from_slice(&0u32.to_le_bytes()); // randomSuffix, always 0 body.extend_from_slice(&0u32.to_le_bytes()); // itemRandomPropId body.extend_from_slice(&60_000u32.to_le_bytes()); // LOOT_ROLL_TIMEOUT (Group.cpp:67) @@ -625,10 +484,7 @@ mod tests { } } - /// The four `(roll_number, roll_type)` shapes vmangos actually emits — the overloaded pair - /// [`LootRoll::is_dice`]/[`LootRoll::vote`] disentangle. The Greed pair is the load-bearing - /// case: a Greed *vote* is `(128, 2)` and a Greed *dice roll* is `(1..=100, 2)`, identical in - /// `roll_type`. + /// A Greed vote `(128, 2)` and a Greed roll `(1..=100, 2)` share their `roll_type`. #[test] fn loot_roll_decodes_and_disambiguates() { let announce = |roll_number: u8, roll_type: u8| { @@ -646,7 +502,6 @@ mod tests { } }; - // Field placement, once, off the Need-vote shape. let need_vote = announce(0, 0); assert_eq!( need_vote, @@ -661,7 +516,7 @@ mod tests { } ); - // The three vote announcements (Group.cpp:970-990). + // The three vote shapes (Group.cpp:970-990). assert!(!need_vote.is_dice()); assert_eq!(need_vote.vote(), Some(roll_vote::NEED)); @@ -673,7 +528,7 @@ mod tests { assert!(!greed_vote.is_dice()); assert_eq!(greed_vote.vote(), Some(roll_vote::GREED)); - // The dice results (Group.cpp:1163 / :1214) — urand(1, 100), so both bounds are legal. + // Dice results are urand(1, 100) (Group.cpp:1163, 1214), so both bounds are legal. for roll_number in [1u8, 57, 100] { for roll_type in [roll_vote::NEED, roll_vote::GREED] { let dice = announce(roll_number, roll_type); @@ -686,7 +541,6 @@ mod tests { #[test] fn loot_roll_won_decodes() { - // Loot.cpp:65-75 — note the winner guid lands AFTER the item block, unlike SMSG_LOOT_ROLL. let mut body = 0xAAu64.to_le_bytes().to_vec(); body.extend_from_slice(&1u32.to_le_bytes()); // itemSlot body.extend_from_slice(&17182u32.to_le_bytes()); // itemEntryId @@ -715,13 +569,12 @@ mod tests { #[test] fn loot_all_passed_decodes() { - // Loot.cpp:77-84 — the SWAPPED tail: itemRandomPropId THEN randomSuffixId, the reverse of - // every sibling in this family. A non-zero prop id proves the parser follows source order. + // A non-zero prop id ahead of the suffix proves the swapped order (Loot.cpp:77-84). let mut body = 0xAAu64.to_le_bytes().to_vec(); body.extend_from_slice(&1u32.to_le_bytes()); // itemSlot body.extend_from_slice(&17182u32.to_le_bytes()); // itemEntryId - body.extend_from_slice(&7u32.to_le_bytes()); // itemRandomPropId — read - body.extend_from_slice(&0u32.to_le_bytes()); // randomSuffixId — discarded + body.extend_from_slice(&7u32.to_le_bytes()); // itemRandomPropId, read + body.extend_from_slice(&0u32.to_le_bytes()); // randomSuffixId, discarded match parse_server(opcode::SMSG_LOOT_ALL_PASSED, &body).unwrap() { ServerPacket::LootAllPassed(p) => assert_eq!( @@ -739,12 +592,11 @@ mod tests { #[test] fn loot_response_items_shape_decodes() { - // SMSG_LOOT_RESPONSE, normal shape: guid, lootType=CORPSE, gold, itemCount, 2 rows. let mut body = 0xAAu64.to_le_bytes().to_vec(); body.push(loot_type::CORPSE); body.extend_from_slice(&1234u32.to_le_bytes()); // gold body.push(2); // item count - // item 0: slot 0, entry 117, count 1, display 123, randomSuffix(0), randomPropertyId 0, ALLOW_LOOT + // slot 0: entry 117, count 1, display 123, suffix 0, prop 0, ALLOW_LOOT body.push(0); body.extend_from_slice(&117u32.to_le_bytes()); body.extend_from_slice(&1u32.to_le_bytes()); @@ -752,7 +604,7 @@ mod tests { body.extend_from_slice(&0u32.to_le_bytes()); body.extend_from_slice(&0u32.to_le_bytes()); body.push(slot_type::ALLOW_LOOT); - // item 1: slot 1, entry 6948 (Hearthstone), count 1, display 4500, randomSuffix(0), randomPropertyId 0, LOCKED + // slot 1: entry 6948 (Hearthstone), count 1, display 4500, suffix 0, prop 0, LOCKED body.push(1); body.extend_from_slice(&6948u32.to_le_bytes()); body.extend_from_slice(&1u32.to_le_bytes()); @@ -799,8 +651,6 @@ mod tests { #[test] fn loot_response_error_shape_decodes() { - // SMSG_LOOT_RESPONSE, error shape: guid, lootType=0, error code. Nothing else follows — - // the parser must not try to read gold/itemCount off a slice this short. let mut body = 0xBBu64.to_le_bytes().to_vec(); body.push(0); // lootType 0 ⇒ error shape body.push(loot_error::TOO_FAR); @@ -855,9 +705,6 @@ mod tests { #[test] fn item_push_result_wire() { - // SMSG_ITEM_PUSH_RESULT (Item.cpp:211-224, both build-5875 conditionals included): - // playerGuid, received, created, showInChat, bagSlot, itemSlot, itemEntry, suffixFactor, - // randomPropertyId, count. let mut body = 0x42u64.to_le_bytes().to_vec(); body.extend_from_slice(&0u32.to_le_bytes()); // received: looted body.extend_from_slice(&1u32.to_le_bytes()); // created: new item diff --git a/crates/benilla-protocol/src/messages/mail.rs b/crates/benilla-protocol/src/messages/mail.rs index 8335bdd5..94244714 100644 --- a/crates/benilla-protocol/src/messages/mail.rs +++ b/crates/benilla-protocol/src/messages/mail.rs @@ -1,31 +1,25 @@ -//! The mail arc's wire layer (decision 0544 phase P0): the mailbox window's send/inbox/take/ -//! item-text/arrival opcodes (vmangos `Handlers/MailHandler.cpp`, `Mail/Mail.{h,cpp}`, -//! `Server/Packets/Mail.{h,cpp}`, `Misc.{h,cpp}`, `Opcodes_1_12_1.h`, VERIFIED at decision time). -//! There is no `SMSG_SHOW_MAILBOX` on 5875 — the window open is entirely client-side (0544's -//! recorded fact); every mail CMSG below carries the mailbox guid at its head and the server -//! re-validates the 5 yd interact distance (`CheckMailBox`) on each one independently. +//! Mail wire messages. There is no `SMSG_SHOW_MAILBOX`: the client opens the window itself. Each +//! mailbox CMSG leads with the mailbox guid, range-checked (5 yd) by vmangos `CheckMailBox`. use std::io; use crate::wire::{capacity_hint, read_cstring, read_f32_le, read_u32_le, read_u64_le, read_u8}; -/// `MAIL_ITEM (5)` on [`MailListEntry::message_type`] carries no sender bytes at all — the only -/// branch of the four that reads nothing before the subject cstring (VERIFIED vmangos -/// `WorldSession::HandleGetMailList`, `Handlers/MailHandler.cpp`). +/// [`MailListEntry::message_type`]: each value fixes the form of the row's sender field. pub mod mail_message_type { - /// A player sender — the sender field is a plain 8-byte guid. + /// A player: the sender field is an 8-byte guid. pub const NORMAL: u8 = 0; - /// An auction-house notice — the sender field is a u32 auction id. + /// An auction-house notice: the sender field is a u32 auction id. pub const AUCTION: u8 = 2; - /// A creature/NPC sender — the sender field is a u32 creature entry. + /// A creature: the sender field is a u32 creature entry. pub const CREATURE: u8 = 3; - /// A GameObject sender — the sender field is a u32 GameObject entry. + /// A GameObject: the sender field is a u32 GameObject entry. pub const GAMEOBJECT: u8 = 4; - /// A pre-made "item" mail (e.g. a GM/starter mail) — NO sender field rides the wire at all. + /// A pre-made item mail (a GM or starter mail): no sender field at all. pub const ITEM: u8 = 5; } -/// `SMSG_SEND_MAIL_RESULT`'s `action` field (VERIFIED vmangos `Mail.h` `MailResponseType`). +/// `SMSG_SEND_MAIL_RESULT`'s `action` (vmangos `Mail.h` `MailResponseType`). pub mod mail_action { pub const SEND: u32 = 0; pub const MONEY_TAKEN: u32 = 1; @@ -35,10 +29,9 @@ pub mod mail_action { pub const MADE_PERMANENT: u32 = 5; } -/// `SMSG_SEND_MAIL_RESULT`'s `error` field (VERIFIED vmangos `Mail.h` `MailResponseResult`). +/// `SMSG_SEND_MAIL_RESULT`'s `error` (vmangos `Mail.h` `MailResponseResult`). pub mod mail_error { pub const OK: u32 = 0; - /// The trailing `equipError` tail rides only this one. pub const EQUIP_ERROR: u32 = 1; pub const CANNOT_SEND_TO_SELF: u32 = 2; pub const NOT_ENOUGH_MONEY: u32 = 3; @@ -49,10 +42,8 @@ pub mod mail_error { pub const TOO_MANY_ATTACHMENTS: u32 = 15; } -/// The single attachment slot a mail can carry (`MAX_MAIL_ITEMS == 1` on 5875) — the fixed-width -/// item block that always rides `SMSG_MAIL_LIST_RESULT`'s per-record tail, zeroed when the mail -/// carries no item. `entry == 0` on the wire is the "no item" signal; [`read_mail_list_result`] -/// folds that into `None` rather than a zeroed `Some`. +/// A mail's one attachment (`MAX_MAIL_ITEMS` is 1 in 1.12): a fixed-width block present on every +/// `SMSG_MAIL_LIST_RESULT` row, all zero when there is no item. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct MailAttachment { pub entry: u32, @@ -65,47 +56,35 @@ pub struct MailAttachment { pub durability: u32, } -/// One `SMSG_MAIL_LIST_RESULT` row (VERIFIED vmangos `WorldSession::HandleGetMailList`'s -/// hand-serialization, `Handlers/MailHandler.cpp`). `sender_guid`/`sender_id` are -/// mutually exclusive branches of one wire field, keyed by `message_type` -/// ([`mail_message_type`]): a player sender is a guid, an auction/creature/GameObject sender is a -/// u32 id, and a pre-made item mail (`ITEM`) sends neither. +/// One `SMSG_MAIL_LIST_RESULT` row (vmangos `HandleGetMailList`, `Handlers/MailHandler.cpp`). +/// `sender_guid` and `sender_id` are one wire field, whose form `message_type` picks. #[derive(Debug, Clone, PartialEq)] pub struct MailListEntry { pub message_id: u32, pub message_type: u8, - /// `Some` only for [`mail_message_type::NORMAL`]. pub sender_guid: Option, - /// `Some` for [`mail_message_type::AUCTION`]/`CREATURE`/`GAMEOBJECT`. pub sender_id: Option, pub subject: String, /// `0` = no letter body to fetch; nonzero = a [`super::item_text_query`] key. pub item_text_id: u32, pub stationery: u32, - /// `None` iff the wire's item-block `entry` is `0`. pub item: Option, pub money: u32, pub cod: u32, - /// `checked` bit masks (VERIFIED vmangos `Mail.h`): READ `0x1`, RETURNED `0x2`, COPIED `0x4`, - /// COD_PAYMENT `0x8`, HAS_BODY `0x10`. + /// vmangos `Mail.h` flags: READ 0x1, RETURNED 0x2, COPIED 0x4, COD_PAYMENT 0x8, HAS_BODY 0x10. pub checked: u32, pub expire_days: f32, pub mail_template_id: u32, } -/// Body of `CMSG_GET_MAIL_LIST` (VERIFIED vmangos `Server/Packets/Mail.cpp`, -/// `GetMailList::ReadFromWorldPacket`): one full 8-byte mailbox guid. Answered by -/// `SMSG_MAIL_LIST_RESULT` ([`read_mail_list_result`]). +/// `CMSG_GET_MAIL_LIST` (vmangos `Server/Packets/Mail.cpp`): the mailbox guid. pub fn get_mail_list(mailbox: u64) -> Vec { mailbox.to_le_bytes().to_vec() } -/// Body of `CMSG_SEND_MAIL` (VERIFIED vmangos `Server/Packets/Mail.cpp`, -/// `SendMail::ReadFromWorldPacket`): `u64 mailbox, cstr receiver, cstr subject, cstr body, u32 -/// stationery, u32 package, u64 itemGuid, u32 money, u32 COD`, then **9 zero bytes** (a `u64 0` + -/// a `u8 0`) — the 1.12.1 tail vmangos read-skips without interpreting. vmangos *discards* the -/// client's `stationery`/`package` choice: player mail is always stored `MAIL_STATIONERY_DEFAULT` -/// (41), so both fields exist only to keep the reader aligned. +/// `CMSG_SEND_MAIL` (vmangos `Server/Packets/Mail.cpp`): mailbox guid, receiver, subject and body +/// cstrings, `u32` stationery and package, item guid, `u32` money and COD, then 9 zero bytes +/// vmangos skips. It ignores stationery and package, storing `MAIL_STATIONERY_DEFAULT` (41). pub fn send_mail( mailbox: u64, receiver: &str, @@ -132,13 +111,11 @@ pub fn send_mail( out.extend_from_slice(&item_guid.to_le_bytes()); out.extend_from_slice(&money.to_le_bytes()); out.extend_from_slice(&cod.to_le_bytes()); - // The 9-byte read-skipped tail: a u64 then a u8, both zero, both discarded server-side. out.extend_from_slice(&0u64.to_le_bytes()); out.push(0); out } -/// The shared `u64 mailbox, u32 mailId` shape of the five take/mark/return/delete CMSGs below. fn mailbox_and_mail_id(mailbox: u64, mail_id: u32) -> Vec { let mut body = Vec::with_capacity(12); body.extend_from_slice(&mailbox.to_le_bytes()); @@ -146,52 +123,41 @@ fn mailbox_and_mail_id(mailbox: u64, mail_id: u32) -> Vec { body } -/// Body of `CMSG_MAIL_TAKE_MONEY`: `u64 mailbox, u32 mailId`. Answered by `SMSG_SEND_MAIL_RESULT` -/// (action [`mail_action::MONEY_TAKEN`]). +/// `CMSG_MAIL_TAKE_MONEY`: answered by a [`mail_action::MONEY_TAKEN`] result. pub fn mail_take_money(mailbox: u64, mail_id: u32) -> Vec { mailbox_and_mail_id(mailbox, mail_id) } -/// Body of `CMSG_MAIL_TAKE_ITEM`: `u64 mailbox, u32 mailId`. Answered by `SMSG_SEND_MAIL_RESULT` -/// (action [`mail_action::ITEM_TAKEN`], carrying the taken item's entry + count on success). +/// `CMSG_MAIL_TAKE_ITEM`: answered by an [`mail_action::ITEM_TAKEN`] result naming the item. pub fn mail_take_item(mailbox: u64, mail_id: u32) -> Vec { mailbox_and_mail_id(mailbox, mail_id) } -/// Body of `CMSG_MAIL_MARK_AS_READ`: `u64 mailbox, u32 mailId`. **No response packet** — the app -/// flips the local `checked` READ bit and repaints. +/// `CMSG_MAIL_MARK_AS_READ`: there is no reply; the caller sets the `checked` READ bit itself. pub fn mail_mark_as_read(mailbox: u64, mail_id: u32) -> Vec { mailbox_and_mail_id(mailbox, mail_id) } -/// Body of `CMSG_MAIL_RETURN_TO_SENDER`: `u64 mailbox, u32 mailId`. Answered by -/// `SMSG_SEND_MAIL_RESULT` (action [`mail_action::RETURNED`]). +/// `CMSG_MAIL_RETURN_TO_SENDER`: answered by a [`mail_action::RETURNED`] result. pub fn mail_return_to_sender(mailbox: u64, mail_id: u32) -> Vec { mailbox_and_mail_id(mailbox, mail_id) } -/// Body of `CMSG_MAIL_DELETE`: `u64 mailbox, u32 mailId`. Answered by `SMSG_SEND_MAIL_RESULT` -/// (action [`mail_action::DELETED`]). +/// `CMSG_MAIL_DELETE`: answered by a [`mail_action::DELETED`] result. pub fn mail_delete(mailbox: u64, mail_id: u32) -> Vec { mailbox_and_mail_id(mailbox, mail_id) } -/// Body of `CMSG_MAIL_CREATE_TEXT_ITEM` (VERIFIED vmangos `Server/Packets/Mail.cpp`, -/// `MailCreateTextItem::ReadFromWorldPacket`): `u64 mailbox, u32 mailId, u32 mailTemplateId` — -/// the letter button's "permanent copy" verb; `mailTemplateId` is always `0` for player mail (no -/// mail templates in 1.12 player mail; vmangos only reads it on builds > 1.9.4). Answered by -/// `SMSG_SEND_MAIL_RESULT` (action [`mail_action::MADE_PERMANENT`]); on OK the server also stamps -/// the mail's `checked` COPIED bit — the next list re-sync flips `textCreated`. +/// `CMSG_MAIL_CREATE_TEXT_ITEM`: mailbox, mail id and a `u32` template id, 0 for player mail. +/// Answered by a [`mail_action::MADE_PERMANENT`] result; on OK the mail's COPIED bit is set. pub fn mail_create_text_item(mailbox: u64, mail_id: u32) -> Vec { let mut body = mailbox_and_mail_id(mailbox, mail_id); body.extend_from_slice(&0u32.to_le_bytes()); // mailTemplateId body } -/// Body of `CMSG_ITEM_TEXT_QUERY` (VERIFIED vmangos `Server/Packets/Mail.cpp`, -/// `ItemTextQuery::ReadFromWorldPacket`): `u32 textId, u32 mailId, u32 unk` — vmangos resolves the -/// text from `textId` alone and never reads `mailId`/`unk` back out, but the real client writes -/// all three. Answered by `SMSG_ITEM_TEXT_QUERY_RESPONSE` ([`read_item_text_query_response`]). +/// `CMSG_ITEM_TEXT_QUERY` (vmangos `Server/Packets/Mail.cpp`): `u32 textId, u32 mailId, u32 unk`. +/// The 1.12 client writes all three; vmangos only uses `textId`. pub fn item_text_query(text_id: u32, mail_id: u32) -> Vec { let mut body = Vec::with_capacity(12); body.extend_from_slice(&text_id.to_le_bytes()); @@ -200,13 +166,7 @@ pub fn item_text_query(text_id: u32, mail_id: u32) -> Vec { body } -/// Read `SMSG_MAIL_LIST_RESULT` (VERIFIED vmangos `WorldSession::HandleGetMailList`, -/// hand-serialized in `Handlers/MailHandler.cpp`): `u8 count` (cap 254; ≤100 by business rule), then `count` -/// × [`MailListEntry`] rows. Each row: `u32 messageId, u8 messageType`, the sender branch keyed by -/// `messageType` ([`mail_message_type`]), `cstr subject, u32 itemTextId, u32 package(dropped — -/// always 0, no consumer), u32 stationery`, the fixed-width item block (ALWAYS present; `entry == -/// 0` folds to [`MailListEntry::item`] being `None`), `u32 money, u32 COD, u32 checked, f32 -/// expireDays, u32 mailTemplateId`. +/// `SMSG_MAIL_LIST_RESULT` (vmangos `HandleGetMailList`): `u8 count`, then [`MailListEntry`] rows. pub(super) fn read_mail_list_result(r: &mut &[u8]) -> io::Result> { let count = read_u8(r)?; // vmangos stops the list at 254 (`MailHandler.cpp:761`, `mailsCount >= 254`). @@ -224,7 +184,7 @@ pub(super) fn read_mail_list_result(r: &mut &[u8]) -> io::Result io::Result io::Result<(u32, String)> { Ok((read_u32_le(r)?, read_cstring(r)?)) } -/// Read `SMSG_RECEIVED_MAIL`: one **`f32` delay** — the seconds until the mail is "waiting", the -/// same countdown units as [`read_query_next_mail_time`]. Sent when a mail *arrives* (instant for -/// text-only, on the delivery timer's expiry otherwise). -/// -/// The real client reads these four bytes as a float and feeds them to the pending-mail countdown's -/// set-value ladder (VERIFIED wow-re, handler `0x4ad620`; decision 0913). vmangos writes them as -/// `uint32(0)` (`MailHandler.cpp::SendNewMail`) and `0x00000000` *is* `0.0f`, so the two agree -/// exactly on the only value vmangos ever sends — reading it the way the client does costs nothing -/// and keeps a server that sends a real delay working. +/// `SMSG_RECEIVED_MAIL`: an `f32` delay in seconds until the mail shows as waiting; the 1.12 +/// client reads a float (`0x4ad620`). vmangos writes `uint32(0)` (`SendNewMail`), bitwise `0.0`. pub(super) fn read_received_mail(r: &mut &[u8]) -> io::Result { read_f32_le(r) } -/// Read `MSG_QUERY_NEXT_MAIL_TIME`'s reply (same opcode both directions — our request is an EMPTY -/// body, [`super::opcode::MSG_QUERY_NEXT_MAIL_TIME`]): one `f32` — `0.0` = unread mail waiting, -/// `-86400.0` = none. +/// The `MSG_QUERY_NEXT_MAIL_TIME` reply (the request is an empty body on the same opcode): an +/// `f32`, `0.0` for unread mail waiting and `-86400.0` for none. pub(super) fn read_query_next_mail_time(r: &mut &[u8]) -> io::Result { read_f32_le(r) } diff --git a/crates/benilla-protocol/src/messages/meeting_stone.rs b/crates/benilla-protocol/src/messages/meeting_stone.rs index 3200d6ab..0a2a7e16 100644 --- a/crates/benilla-protocol/src/messages/meeting_stone.rs +++ b/crates/benilla-protocol/src/messages/meeting_stone.rs @@ -1,20 +1,17 @@ -//! The meeting-stone queue's wire (decisions 1963/1974/2283; wow-re `staticpopup-dialog-bindings.md` -//! §8 and `meeting-stone-status.md` §8/§9/§10): the server's queue state, the JOIN a right-click on -//! the stone sends, and the leave request `CancelMeetingStoneRequest` sends. +//! Meeting-stone wire messages: the queue state, the right-click join and the leave request. use std::io::{self, Read}; use crate::wire::{read_u32_le, read_u8}; -/// `SMSG 0x295` (VERIFIED at the bytes, handler `0x4ca230`): the queued area and a status byte -/// the client turns into one of five local messages, then `MEETINGSTONE_CHANGED`. +/// `SMSG 0x295` (reference handler `0x4ca230`): the queued area and a status byte, which the +/// client turns into one of five messages before firing `MEETINGSTONE_CHANGED`. #[derive(Clone, Debug, PartialEq, Eq)] pub struct MeetingStoneSetQueue { pub area: u32, pub status: u8, } -/// Parse it: `u32 areaId`, `u8 status`. pub(super) fn read_meeting_stone_set_queue(r: &mut impl Read) -> io::Result { Ok(MeetingStoneSetQueue { area: read_u32_le(r)?, @@ -22,42 +19,36 @@ pub(super) fn read_meeting_stone_set_queue(r: &mut impl Read) -> io::Result Vec { go_guid.to_le_bytes().to_vec() } -/// Body of `CMSG 0x293` (VERIFIED, `0x4ca120`): empty. +/// `CMSG 0x293` (reference `0x4ca120`, `CancelMeetingStoneRequest`): an empty body. pub fn meeting_stone_leave() -> Vec { Vec::new() } -/// The four display-only replies one handler serves (`0x4ca3c0`, dispatched on the opcode; wow-re -/// `meeting-stone-status.md` §9, 1974). None touches the queue state, none fires an event. +/// Display-only replies (reference handler `0x4ca3c0`): none changes the queue or fires an event. #[derive(Clone, Debug, PartialEq, Eq)] pub enum MeetingStoneNotice { /// `0x297`, empty. Success, /// `0x298`, empty. InProgress, - /// `0x299`, `u64 guid` — the line waits for the name cache. + /// `0x299`, `u64 guid`; the line waits for the name cache. MemberAdded { guid: u64 }, /// `0x2BB`, `u8 code`. JoinFailed { code: u8 }, } -/// Parse `0x299`'s guid. pub(super) fn read_meeting_stone_member_added(r: &mut impl Read) -> io::Result { Ok(MeetingStoneNotice::MemberAdded { guid: crate::wire::read_u64_le(r)?, }) } -/// Parse `0x2BB`'s code byte. pub(super) fn read_meeting_stone_join_failed(r: &mut impl Read) -> io::Result { Ok(MeetingStoneNotice::JoinFailed { code: read_u8(r)? }) } @@ -66,8 +57,7 @@ pub(super) fn read_meeting_stone_join_failed(r: &mut impl Read) -> io::Result Option { match value { 0 => Some(Self::Fatigue), @@ -63,7 +30,6 @@ impl MirrorTimerKind { } } - /// The `timerType` word this kind is sent as — the inverse of [`Self::from_wire`]. pub fn to_wire(self) -> u32 { match self { Self::Fatigue => 0, @@ -73,34 +39,25 @@ impl MirrorTimerKind { } } -/// `SMSG_START_MIRROR_TIMER` — start, or wholly re-state, one mirror timer. +/// `SMSG_START_MIRROR_TIMER`: starts, or wholly re-states, one mirror timer. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct MirrorTimerStart { - /// The wire's raw `timerType`. Kept raw rather than pre-mapped so an unknown type survives - /// the decode as data instead of failing the whole packet; [`MirrorTimerKind::from_wire`] - /// is the mapping, applied at the UI seam. + /// The raw `timerType`, left unmapped so an unknown one still decodes. pub kind: u32, - /// Time left, in **milliseconds**. Counts toward `0` while draining and toward `duration_ms` - /// while refilling; the bar is `remaining_ms / 1000` in bar units. + /// Time left in ms, counting toward 0 while draining and toward `duration_ms` while refilling. pub remaining_ms: u32, - /// The timer's full span, in **milliseconds** — the bar's maximum (60 s of breath by - /// default: vmangos `CONFIG_UINT32_MIRRORTIMER_BREATH_MAX`). + /// The bar's maximum in ms (breath: 60 s by default, `CONFIG_UINT32_MIRRORTIMER_BREATH_MAX`). pub duration_ms: u32, - /// Rate of change, in bar-units per second, and **signed**: `-1` while the timer drains - /// (underwater, in high sea), `+10` while it refills (surfaced — a full breath bar refills - /// ten times faster than it drained, which is why surfacing snaps it back). The consumer - /// integrates `scale * elapsed` between packets; this is the whole of the client-side motion. + /// Signed rate in bar seconds per second: `-1` while draining, `+10` while refilling after + /// surfacing. The client integrates `scale * elapsed` between packets. pub scale: i32, - /// Frozen: the bar holds its value and stops integrating. Sent as one byte. + /// Frozen: the bar holds its value and stops integrating. pub paused: bool, - /// The spell driving this timer, if any (a water-breathing aura owns the breath timer for its - /// duration). `0` = no spell. The reference client's Lua never sees this field. + /// The spell driving the timer (water breathing) or `0`; the reference Lua never sees it. pub spell_id: u32, } -/// Read `SMSG_START_MIRROR_TIMER`: `u32 type, u32 remaining, u32 duration, i32 scale, u8 paused, -/// u32 spellId` — VERIFIED against vmangos `StartMirrorTimer::AppendBodyTo` (`Misc.cpp:472`), -/// field for field in that order. `scale` is the one signed field. +/// `SMSG_START_MIRROR_TIMER` (vmangos `Server/Packets/Misc.cpp:472`). pub fn read_start_mirror_timer(r: &mut impl Read) -> io::Result { Ok(MirrorTimerStart { kind: read_u32_le(r)?, @@ -112,14 +69,12 @@ pub fn read_start_mirror_timer(r: &mut impl Read) -> io::Result io::Result<(u32, bool)> { Ok((read_u32_le(r)?, read_u8(r)? != 0)) } -/// Read `SMSG_STOP_MIRROR_TIMER`: the bare `u32 type` and nothing else -/// (vmangos `StopMirrorTimer::AppendBodyTo`, `Misc.cpp:482`). +/// `SMSG_STOP_MIRROR_TIMER` (vmangos `Misc.cpp:482`): the raw type alone. pub fn read_stop_mirror_timer(r: &mut impl Read) -> io::Result { read_u32_le(r) } @@ -128,8 +83,6 @@ pub fn read_stop_mirror_timer(r: &mut impl Read) -> io::Result { mod tests { use super::*; - /// A golden `SMSG_START_MIRROR_TIMER` body, byte-for-byte as vmangos writes it: the breath - /// timer draining with 45 s left of a 60 s span, unfrozen, no spell. #[test] fn start_reads_every_field_in_the_servers_order() { let mut body = Vec::new(); @@ -153,9 +106,6 @@ mod tests { ); } - /// The refill direction: surfacing re-sends the same timer with `scale = +10`, frozen flag - /// clear, and a water-breathing aura's id when one owns it. `scale` must survive as a - /// *signed* field — a `u32` read would still pass on `+10` and silently break on `-1`. #[test] fn start_carries_a_signed_scale_and_the_owning_spell() { let mut body = Vec::new(); @@ -174,8 +124,6 @@ mod tests { assert_eq!(start.spell_id, 5697); } - /// The three client timer types, and the fact that nothing else maps — the server's - /// `NUM_CLIENT_TIMERS` gate keeps its internal `ENVIRONMENTAL` (3) off the wire entirely. #[test] fn only_the_three_client_timer_types_map() { assert_eq!( @@ -202,7 +150,6 @@ mod tests { } } - /// `SMSG_PAUSE_MIRROR_TIMER`: type then one flag byte. #[test] fn pause_is_a_type_and_a_flag_byte() { let mut body = 0u32.to_le_bytes().to_vec(); @@ -213,7 +160,6 @@ mod tests { assert_eq!(read_pause_mirror_timer(&mut &body[..]).unwrap(), (1, false)); } - /// `SMSG_STOP_MIRROR_TIMER`: the bare type word. #[test] fn stop_is_the_bare_type_word() { assert_eq!( @@ -222,8 +168,7 @@ mod tests { ); } - /// A truncated body is an error, never a silently zeroed field — the `paused` byte and the - /// trailing `spellId` are the two the server appends last and the two a sloppy reader drops. + /// The cuts fall before `scale`, before `paused`, before `spellId` and inside it. #[test] fn a_truncated_start_body_is_an_error() { let mut full = Vec::new(); diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index 5465d11d..a22a00d1 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -1,19 +1,5 @@ -//! The world (`mangosd`) message layer — in-repo replacement for `wow_world_messages` (decision -//! 0021), scoped to the opcodes benilla sends and receives. -//! -//! World packets are framed by a header-encrypted size+opcode (see [`crate::world`]); this module owns -//! only the *bodies*: parsing the server packets benilla decodes into [`ServerPacket`], and building -//! the client packet bodies benilla sends. The one genuinely complex packet is `SMSG_UPDATE_OBJECT` -//! (and its zlib twin) — an object list where each entry carries a [`MovementBlock`]-shaped position -//! and a sparse [`ObjectFields`] of descriptor fields; its decode lives in [`update_object`], and -//! [`crate::events`] pulls out the handful of fields the renderer uses. -//! The [`ServerPacket`] enum itself lives in `packet`; the opcode→variant dispatch -//! ([`parse_server`]) in `parse`. -//! -//! Proven byte-for-byte against `wow_world_messages` during the decision-0021 migration (oracle test -//! in git history); ongoing coverage is the oracle-free golden/fixture tests in `tests/*.rs` (split by -//! domain — client, movement, items, spells, update_object, simple_packets — sharing fixtures via -//! `tests/common`). +//! World (`mangosd`) packet bodies: server packets decode into [`ServerPacket`] via +//! [`parse_server`], client bodies are built here; the header framing lives in [`crate::world`]. mod action_bar; pub mod addons; @@ -273,18 +259,9 @@ pub use vendor::{ }; pub use world_state::InitWorldStates; -/// The **world server's** `SMSG_AUTH_RESPONSE` result codes — a **different enum** from the -/// realmd logon-proof's `AuthLogonResult` (`crate::AuthReject`, 0x00..=0x12), which is the trap -/// this block exists to close: both are "the auth result byte", they overlap numerically, and they -/// mean unrelated things. 0x0C is `AUTH_OK` here and `AUTH_LOGON_FAILED_SUSPENDED` there. -/// -/// VERIFIED two ways. The client's own dispatch over exactly this enum is decompiled in wow-re -/// `system/net/scratch/w2b-pack.c` — case 0x0c → `AUTH_OK`, 0x0d → `AUTH_FAILED`, 0x0e → -/// `AUTH_REJECT`, … 0x15 → `AUTH_UNKNOWN_ACCOUNT`, 0x16 → `AUTH_INCORRECT_PASSWORD`, 0x1b → -/// `AUTH_WAIT_QUEUE`, 0x22..=0x26 → the `REALM_LIST_*` strings — and the same numbering is -/// `AuthResponseCodes` in cmangos `src/game/Globals/SharedDefines.h:1721+`. Each constant's name -/// is the `GlueStrings` key the client shows for it, which is what makes the mapping in -/// `crate::login`'s `world_refusal_text` a transcription rather than a judgement call. +/// World `SMSG_AUTH_RESPONSE` result codes, not realmd's `AuthLogonResult` (`crate::AuthReject`): +/// the ranges overlap, and 0x0C is `AUTH_OK` here but `AUTH_LOGON_FAILED_SUSPENDED` there. Each +/// name is the `GlueStrings` key the client shows (cmangos `SharedDefines.h:1721`). pub const AUTH_OK: u8 = 0x0C; pub const AUTH_FAILED: u8 = 0x0D; pub const AUTH_REJECT: u8 = 0x0E; @@ -300,11 +277,10 @@ pub const AUTH_SESSION_EXPIRED: u8 = 0x17; pub const AUTH_SERVER_SHUTTING_DOWN: u8 = 0x18; pub const AUTH_ALREADY_LOGGING_IN: u8 = 0x19; pub const AUTH_LOGIN_SERVER_NOT_FOUND: u8 = 0x1A; -/// The realm is full and we are **queued**, not refused — the one code here that is not an ending. +/// The realm is full and we are queued, not refused: the one code here that is not an ending. pub const AUTH_WAIT_QUEUE: u8 = 0x1B; -// The tail past the queue. These five are exactly the codes the client's `OKAY_WITH_URL` table -// (`0x803740`, 5 records, stride 0x24) keys on — banned/no-time/db-busy/suspended/parental — which -// is why the URL dialog is reachable ONLY from this enum and never from realmd. +// The 1.12 client's `OKAY_WITH_URL` table (`0x803740`) keys on banned, no-time, db-busy, +// suspended and parental, so the URL dialog is reachable only from this enum, never from realmd. pub const AUTH_BANNED: u8 = 0x1C; pub const AUTH_ALREADY_ONLINE: u8 = 0x1D; pub const AUTH_NO_TIME: u8 = 0x1E; @@ -313,54 +289,30 @@ pub const AUTH_SUSPENDED: u8 = 0x20; pub const AUTH_PARENTAL_CONTROL: u8 = 0x21; /// `LogoutResult::Success` (`SMSG_LOGOUT_RESPONSE`). pub const LOGOUT_SUCCESS: u32 = 0x0; -/// Chat `Language` wire ids (VERIFIED vmangos `SharedDefines.h:256-261`): the faction tongues. -/// `Universal` (0) is server-reserved — vmangos rejects it from clients for ordinary chat. +/// Faction-tongue `Language` ids (vmangos `SharedDefines.h:256-261`); vmangos rejects +/// `Universal` (0) from clients in ordinary chat. pub const LANGUAGE_COMMON: u32 = 0x7; pub const LANGUAGE_ORCISH: u32 = 0x1; -/// `LANG_ADDON` (VERIFIED vmangos `SharedDefines.h:270`) — **not a tongue**. 1.12.1 has no addon -/// opcode: `SendAddonMessage` rides the ordinary chat lanes and this sentinel in the `language` -/// field is the *only* thing that marks the line as addon-to-addon data rather than speech -/// (decision 1029). The real client routes it to the `CHAT_MSG_ADDON` event; it never reaches the -/// chat frame. -/// -/// The server treats it as its own class throughout: exempt from the `KnowsLanguage` gate, from -/// flood control, and from message sanitizing (`SanitizeChatMessage` returns early, -/// `Handlers/ChatHandler.cpp:49`); gated instead by the `AddonChannel` config -/// (`ChatHandler.cpp:165`); and restricted by `WorldSession::IsLanguageAllowedForChatType` -/// (`ChatHandler.cpp:84`) to the group/guild/channel lanes — PARTY, RAID, RAID_LEADER, -/// RAID_WARNING, GUILD, OFFICER, BATTLEGROUND, BATTLEGROUND_LEADER, CHANNEL. Never -/// SAY/YELL/EMOTE/WHISPER, which is why addon traffic can never reach a chat bubble or a -/// `/r` target. It is also the one language the server never rewrites: the whole -/// GM/two-side/`SPELL_AURA_MOD_LANGUAGE` normalisation block lives in that check's `else` -/// (`ChatHandler.cpp:176-218`), so on the party lane a line's language is either a tongue (often -/// normalised to `Universal`) or exactly this. -/// -/// **The client's own send set is narrower than the server's permission list.** VERIFIED in -/// `WoW.exe` (5875) — wow-re `system/ui/scratch/addon-chat-law.md`: `SendAddonMessage` -/// (`0x49f920`) hard-whitelists **four** types at `0x49fa3f`-`0x49fa4e` — PARTY, RAID, GUILD, -/// BATTLEGROUND — and the receive side's `distribution` argument uses the same four (remap table -/// `0x49aff4`), reporting every other type as the literal `"UNKNOWN"`. So a real client accepts -/// addon traffic on lanes it will never itself send on. +/// `LANG_ADDON` (vmangos `SharedDefines.h:270`): marks a chat line as addon data, not speech; +/// 1.12 has no addon opcode, and the client routes such a line to `CHAT_MSG_ADDON`. The server +/// skips language, flood and sanitize checks for it, never rewrites it, and allows it only on the +/// group, guild and channel lanes (`ChatHandler.cpp:49,84,176-218`). The 1.12 `SendAddonMessage` +/// (`0x49f920`) sends only PARTY, RAID, GUILD and BATTLEGROUND, and the receive side names any +/// other lane "UNKNOWN" (`0x49aff4`). pub const LANGUAGE_ADDON: u32 = 0xFFFF_FFFF; -/// The language a character speaks by default — its faction tongue. Every send must carry a -/// language the character *knows*: vmangos drops the whole message (including a `.command` -/// payload, which is intercepted downstream of the check) with a "not learned" notification -/// otherwise (`HandleChatMessageOpcode`'s `KnowsLanguage` gate, `Handlers/ChatHandler.cpp`). -/// Race → tongue VERIFIED against the live world DB (`playercreateinfo_spell`): races 1/3/4/7 -/// (Alliance) learn spell 668 Language Common, races 2/5/6/8 (Horde) learn 669 Language Orcish. +/// A race's faction tongue. vmangos drops a chat line, `.command`s included, in a language the +/// speaker does not know (`KnowsLanguage`, `Handlers/ChatHandler.cpp`); races 2/5/6/8 learn 669 +/// Language Orcish, the rest 668 Language Common (`playercreateinfo_spell`). pub fn faction_language(race: u8) -> u32 { match race { 2 | 5 | 6 | 8 => LANGUAGE_ORCISH, // orc, undead, tauren, troll _ => LANGUAGE_COMMON, // human, dwarf, night elf, gnome } } -/// `ChatMsg` wire values benilla sends, widened to `u32` (VERIFIED vmangos `SharedDefines.h: -/// 1191-1301`) — `CMSG_MESSAGECHAT`'s `type` field is a `u32` on the wire -/// (`WorldPackets::Chat::ChatMessage::type`, `Server/Packets/Chat.h:12`), unlike the inbound -/// `SMSG_MESSAGECHAT` decode's `u8` (hence the separate, narrower [`CHAT_MSG_SAY`] etc. set there): -/// two constant sets for the same enum because the two wire fields are different widths. +/// `ChatMsg` values (vmangos `SharedDefines.h:1191-1301`) as `CMSG_MESSAGECHAT`'s `u32` `type` +/// (`Server/Packets/Chat.h:12`). The inbound `u8` field has its own set, [`CHAT_MSG_SAY`] etc. pub const CHAT_TYPE_SAY: u32 = 0x0; pub const CHAT_TYPE_PARTY: u32 = 0x1; pub const CHAT_TYPE_RAID: u32 = 0x2; @@ -372,9 +324,9 @@ pub const CHAT_TYPE_EMOTE: u32 = 0x8; pub const CHAT_TYPE_CHANNEL: u32 = 0xE; pub const CHAT_TYPE_AFK: u32 = 0x14; pub const CHAT_TYPE_DND: u32 = 0x15; -/// `#if SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_10_2` — active for 5875. +/// `#if SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_10_2`: active for 5875. pub const CHAT_TYPE_RAID_LEADER: u32 = 0x57; pub const CHAT_TYPE_RAID_WARNING: u32 = 0x58; -/// `#if SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_11_2` — active for 5875. +/// `#if SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_11_2`: active for 5875. pub const CHAT_TYPE_BATTLEGROUND: u32 = 0x5C; pub const CHAT_TYPE_BATTLEGROUND_LEADER: u32 = 0x5D; diff --git a/crates/benilla-protocol/src/messages/monster_move.rs b/crates/benilla-protocol/src/messages/monster_move.rs index 7e9e282c..63ec064a 100644 --- a/crates/benilla-protocol/src/messages/monster_move.rs +++ b/crates/benilla-protocol/src/messages/monster_move.rs @@ -1,19 +1,7 @@ -//! `SMSG_MONSTER_MOVE` — the server-dictated creature movement path — and its transport twin. -//! -//! One packet: a mover, its `start`, a spline id, a `moveType`-switched final facing, then (unless it is -//! a `Stop`) the spline block — a flag word, a duration, and the waypoints. Decodes into the full -//! travel-order polyline [`ServerPacket::MonsterMove::path`] the app rides at constant (arc-length) speed -//! (decision 0097). The wire packs the waypoints two ways, keyed by the `Flying`/`Mask_CatmullRom` flag — -//! see [`read_monster_move_spline`]. -//! -//! **`SMSG_MONSTER_MOVE_TRANSPORT` is the same packet with one field inserted**: a second packed guid, -//! the transport's, immediately after the mover's — and every coordinate in the body is then an -//! **offset in that transport's frame**, not world space (decision 1936). vmangos writes exactly that -//! (`Movement::MoveSplineInit::Launch`, `spline/MoveSplineInit.cpp:138-170`: the same builder, the -//! opcode swapped and the transport packed guid appended, after `CalculatePassengerOffset` has moved -//! the start into deck space), and it computes such a path against the transport's **model** navmesh -//! rather than the map's (`PathFinder::calculate`, `Maps/PathFinder.cpp:76-84`), so the waypoints are -//! deck-local too. One reader serves both, keyed by [`read_monster_move`]'s `on_transport`. +//! `SMSG_MONSTER_MOVE`: a mover, its start, a spline id, a `moveType` facing, then (unless a stop) +//! spline flags, a duration and the waypoints. `SMSG_MONSTER_MOVE_TRANSPORT` inserts the +//! transport's packed guid after the mover's, and every coordinate is then deck-local, waypoints +//! included (vmangos `MoveSplineInit.cpp:138-170`, `PathFinder.cpp:76-84`). use std::io; @@ -23,46 +11,26 @@ use crate::wire::{ read_u64_le, read_u8, Vector3d, }; -/// `MonsterMoveType::Stop` (`SMSG_MONSTER_MOVE`). const MONSTER_MOVE_STOP: u8 = 0x1; -/// `SplineFlags::Flying` in the `SMSG_MONSTER_MOVE` spline-flags word. When set, the unit follows the -/// path's **own Z** (a 3-D flight path, points sent explicit); when clear, the path is a *ground* walk -/// and the real client **discards the spline Z and re-derives it from the terrain** under the unit (the -/// swept-collision down-probe — byte-verified in wow-5875-re: the grounded fork zeroes the spline's -/// Z-delta at `0x616cb0` and the WALK resolver `0x634040 → 0x6367b0` reads Z off the world trace -/// `0x6721b0`). benilla mirrors that split: a non-flying spline is terrain-clamped, a flying one keeps -/// its Z. The client gates on this same bit (`0x6018f0: test ah,0x2`). It is also `Mask_CatmullRom` — -/// flying paths interpolate (and wire-encode) as a curve, ground paths as straight segments. +/// `SplineFlags::Flying`, also `Mask_CatmullRom` (tested at `0x6018f0`). Set, the unit keeps the +/// path's own Z along a curve; clear, the 1.12 client drops the spline Z (`0x616cb0`) and takes Z +/// from the terrain (`0x634040`), moving along straight segments. const SPLINE_FLAG_FLYING: u32 = 0x200; -/// `SPLINEFLAG_RUNMODE` — the path is travelled at run speed. Its **absence** is what matters: -/// the real client feeds this bit straight into `CMovement::SetRunMode 0x7c71c0` -/// (`0x7c6ac2 and edi,0x100` → `0x7c6acb call 0x7c71c0`, inside the `SMSG_MONSTER_MOVE` commit -/// `0x7c6a50`), and that setter's argument is *run* — so a spline **without** this bit **sets** -/// `MOVEFLAG_WALK_MODE` on the unit it moves. The two `0x100`s are inverses of each other -/// (wow-re `collision/scratch/walk-mode-law.md` §5.2; benilla decision 1758). +/// `SPLINEFLAG_RUNMODE`: run speed. The 1.12 client passes it to `CMovement::SetRunMode` +/// (`0x7c71c0`, from `0x7c6ac2`), so a spline without it sets `MOVEFLAG_WALK_MODE` on the unit. const SPLINE_FLAG_RUNMODE: u32 = 0x100; -/// Parse an `SMSG_MONSTER_MOVE` body into [`ServerPacket::MonsterMove`]. The head (mover, `start`, spline -/// id, `moveType` + its final facing) is always present; a **stop** (`moveType 1`) ends there (the client -/// reads no flags/duration/points and implies `flags=0x100, count=1, duration=0` — wow-re RF-0049; -/// reading a tail anyway over-ran the body, the `0x00dd: failed to fill whole buffer` skip that dropped -/// every creature stop). Otherwise the spline block yields the full polyline `[start, …waypoints…, -/// endpoint]`. +/// A stop (`moveType` 1) ends after the head: the 1.12 client reads no spline block and implies +/// `flags = 0x100, count = 1, duration = 0`. Otherwise the path runs `[start, …, endpoint]`. pub(super) fn read_monster_move(r: &mut &[u8], on_transport: bool) -> io::Result { let guid = read_packed_guid(r)?; - // `SMSG_MONSTER_MOVE_TRANSPORT` only: the deck the whole body is expressed on, packed like the - // mover's guid and read immediately after it. Everything below is unchanged — the two opcodes - // share the builder server-side and differ by this one field. let transport = on_transport.then(|| read_packed_guid(r)).transpose()?; let start = Vector3d::read(r)?; - // The server's per-move spline counter. Discarded for a creature (nothing acks its walk), but - // the client echoes it back in `CMSG_MOVE_SPLINE_DONE` when a spline drives its OWN player - // (Charge/knockback/taxi): the server validates the ack against the newest spline id. + // Echoed in `CMSG_MOVE_SPLINE_DONE` when the spline moves our own player (charge, knockback, + // taxi); the server checks it against its newest spline id. let spline_id = read_u32_le(r)?; let move_type = read_u8(r)?; - // The `moveType`-switched final facing (jumptable `0x602114`): 2 = spot, 3 = target guid, 4 = angle. - // The real client snaps the unit to it (`0x7c6f30`); benilla applies it in the net bridge. A plain - // move (0) / stop (1) dictates no facing. + // Final facing (jumptable `0x602114`); the 1.12 client snaps the unit to it (`0x7c6f30`). let facing = match move_type { 2 => { let spot = Vector3d::read(r)?; @@ -83,8 +51,7 @@ pub(super) fn read_monster_move(r: &mut &[u8], on_transport: bool) -> io::Result stop: true, duration_ms: 0, flying: false, - // The stop form carries no spline-flags dword at all, so there is no run/walk verdict - // in it — and it builds no path, so nothing downstream reads this. + // A stop carries no flags and builds no path; nothing reads this. run_mode: true, } } else { @@ -92,9 +59,7 @@ pub(super) fn read_monster_move(r: &mut &[u8], on_transport: bool) -> io::Result let duration_ms = read_u32_le(r)?; let flying = spline_flags & SPLINE_FLAG_FLYING != 0; let run_mode = spline_flags & SPLINE_FLAG_RUNMODE != 0; - // The decoded waypoints *after* the start (see [`read_monster_move_spline`]) — both layouts - // ship only post-start points, so the wire `start` anchors the full travel-order polyline - // `[start, …waypoints…, endpoint]`. + // Both layouts ship only the points after the start, so the head's `start` leads the path. let tail = read_monster_move_spline(r, flying)?; let path = if tail.is_empty() { Vec::new() @@ -116,54 +81,28 @@ pub(super) fn read_monster_move(r: &mut &[u8], on_transport: bool) -> io::Result }) } -/// `SMSG_MONSTER_MOVE` spline points → the reconstructed **absolute** waypoints the block carries, in -/// travel order (the point *after* the start … the endpoint). Two wire layouts, keyed by `catmull_rom` -/// (the `Mask_CatmullRom` = `Flying` spline flag), both from vmangos `PacketBuilder`: -/// -/// - **Ground (linear, `WriteLinearPath`):** a `u32` count of the points **after** the start, then the -/// endpoint as an absolute `Vector3d`, then `count − 1` packed `i32` offsets — `endpoint − waypoint` -/// for each intermediate waypoint (see [`packed_to_vector3d`]), in travel order. We invert each -/// (`waypoint = endpoint − offset`) and append the endpoint, so the returned list is -/// `[waypoint₁, …, endpoint]`; the start is the packet head's. -/// **The start is not among the offsets.** vmangos calls the builder with `firstPoint = 1` -/// (`packet_builder.h:34`'s default, `MoveSplineInit.cpp:169`) over a spline laid out -/// `[phantom, c₀ … cₙ₋₁, cₙ₋₁]` — the Catmull-Rom initializer even for a linear path -/// (`spline.cpp:52`) — so `last_idx = n − 1`, the count is `n − 1`, and the offset loop runs -/// `c₁ … cₙ₋₂`; the head's start is `getPoint(first())` = `c₀`. The reference decoder -/// (`0x6018f0`, wow-re `net/scratch/rf49-inbound-wire-parses.md` rows 9–11) reads the same -/// `count − 1` intermediates, and its curve is `[start, points…]` (`curvemath/…/rf52-curve-construction.md`). -/// Until the sweep that found it, this read assumed `firstPoint = 0` (0097, 0708): it treated the -/// first offset as a re-encoded start and dropped it — every ground path lost its first corner — -/// and read none at `count == 2`, leaving the one real waypoint unread (the decode-length -/// instrument's `SMSG_MONSTER_MOVE … left 4 trailing byte(s)` on a live run). -/// - **Flying (Catmull-Rom, `WriteCatmullRomPath`):** a `u32` count, then `count` **absolute** `Vector3d`s -/// (the control points from `getPoint(2)` on — the post-start waypoints through the endpoint). Returned -/// verbatim. (Reading these as packed offsets — the old single-layout path — mis-sized the body and -/// dropped every flight move.) -/// -/// The client interpolates a ground path **piecewise-linearly, arc-length-parameterised** through all of -/// these (wow-re curvemath RF-0048/RF-0052: the creature follow's point-at-t is `linear_pos_diff`, not the -/// Catmull-Rom blend — only flying units take the curved family). +/// The absolute waypoints after the start, endpoint last (vmangos `PacketBuilder`). Flying +/// (`WriteCatmullRomPath`): a `u32` count, then that many absolute points. Ground +/// (`WriteLinearPath`): a `u32` count, the absolute endpoint, then `count - 1` packed +/// `endpoint - waypoint` offsets. The start is not among them: vmangos writes from +/// `firstPoint = 1` (`MoveSplineInit.cpp:169`), and the reference decoder (`0x6018f0`) reads +/// the same `count - 1`. fn read_monster_move_spline(r: &mut &[u8], catmull_rom: bool) -> io::Result> { let count = read_u32_le(r)?; - // Cap the *pre-allocation* (not the read) at a sane bound — a corrupt `count` must not reserve GBs; - // the read itself still errors the instant the (bounded) body underruns. + // 0xFFFF bounds only the pre-allocation against a corrupt `count`. if catmull_rom { - // Absolute control points, verbatim. let mut points = Vec::with_capacity(capacity_hint(count, 0xFFFF)); for _ in 0..count { points.push(Vector3d::read(r)?); } return Ok(points); } - // Endpoint (absolute) + packed offsets from it; invert to absolute, endpoint last. if count == 0 { return Ok(Vec::new()); } let endpoint = Vector3d::read(r)?; let mut points = Vec::with_capacity(capacity_hint(count, 0xFFFF)); - // `count == 1` ⇒ a straight hop: the producer's `last_idx > 1` guard skips the loop, and - // `count − 1` is already zero. + // `count == 1` is a straight hop: no offsets (vmangos: `if (last_idx > 1)`). for _ in 1..count { let off = packed_to_vector3d(read_i32_le(r)?); points.push(Vector3d { @@ -182,7 +121,7 @@ mod tests { use crate::messages::{opcode, parse_server, parse_server_with_tail}; use crate::wire::write_packed_guid; - /// The fixed head of a `SMSG_MONSTER_MOVE`: packed guid, start pos, splineId, moveType. + /// The fixed head: packed guid, start, splineId, moveType. fn head(guid: u64, start: [f32; 3], move_type: u8) -> Vec { let mut b = Vec::new(); write_packed_guid(guid, &mut b).unwrap(); @@ -194,12 +133,8 @@ mod tests { b } - /// The producer's linear encoder, transcribed from vmangos `PacketBuilder::WriteLinearPath` as - /// `WriteMonsterMove` calls it (`firstPoint = 1`). `path` is the full `[c₀ = start, …, cₙ₋₁]` - /// (the head carries `c₀` separately); over the spline's `[phantom, c₀ … cₙ₋₁, cₙ₋₁]` layout - /// `last_idx = n − 1`, so it writes count `n − 1`, the **endpoint** absolute, then — under - /// `if (last_idx > 1)` — `c₁ … cₙ₋₂` as packed `endpoint − point` offsets. The start is never - /// among them. + /// vmangos `WriteLinearPath` from `firstPoint = 1` over `path = [c₀, …, cₙ₋₁]`: the count + /// `n - 1`, the endpoint, then `c₁ … cₙ₋₂` as packed `endpoint - point` offsets. fn append_linear_path(body: &mut Vec, path: &[[f32; 3]]) { let (&endpoint, leading) = path.split_last().expect("a path has an endpoint"); let last_idx = path.len() - 1; @@ -208,10 +143,9 @@ mod tests { body.extend_from_slice(&f.to_le_bytes()); } if last_idx <= 1 { - return; // vmangos `packet_builder.cpp:92` — `if (last_idx > 1)` + return; // vmangos `packet_builder.cpp:92`: `if (last_idx > 1)` } - // `for (i = start; i < last_idx; ++i)` with `start = 1`: the intermediates, packed as - // endpoint − point (¼-yd quantized). + // `for (i = start; i < last_idx; ++i)` with `start = 1`, packed in ¼-yd units. for &p in &leading[1..] { let pack = |v: f32, shift: u32, mask: i32| ((v * 4.0).round() as i32 & mask) << shift; let off = [endpoint[0] - p[0], endpoint[1] - p[1], endpoint[2] - p[2]]; @@ -220,12 +154,7 @@ mod tests { } } - /// `SMSG_MONSTER_MOVE_TRANSPORT` is `SMSG_MONSTER_MOVE` with the transport's packed guid - /// inserted between the mover's guid and the start position — everything after it parses - /// identically, and every coordinate is then a deck-local offset. Reading the *plain* opcode's - /// layout for it would consume the transport guid as the start's X and Y and land the creature - /// somewhere near the map origin; reading the transport layout for a plain packet is the same - /// mistake in reverse. Both directions are pinned here (decision 1936). + /// Each opcode reads its own layout; the plain one must not find a transport guid. #[test] fn monster_move_transport_reads_the_deck_guid_first() { let mut body = Vec::new(); @@ -257,19 +186,16 @@ mod tests { _ => panic!("expected MonsterMove"), } - // The plain opcode over the SAME bytes must NOT come back with a transport — and it - // mis-reads the head, which is exactly why the two layouts cannot share one arm. + // The plain opcode over the same bytes must not come back with a transport. match parse_server(opcode::SMSG_MONSTER_MOVE, &body) { Ok(ServerPacket::MonsterMove { transport, .. }) => assert_eq!(transport, None), Ok(_) => panic!("expected MonsterMove"), - Err(_) => {} // an under-run is equally fine: the point is that it is not the same read + Err(_) => {} // an under-run is fine too: it is not the same read } } #[test] fn monster_move_stop_has_no_tail() { - // A STOP (moveType 1) is head-only: no flags/duration/points. Parsing the tail anyway - // over-ran the body and skipped the packet (`0x00dd: failed to fill whole buffer`). let body = head(0x1234, [1.0, 2.0, 3.0], MONSTER_MOVE_STOP); let p = parse_server(opcode::SMSG_MONSTER_MOVE, &body).expect("a stop parses head-only"); match p { @@ -283,7 +209,7 @@ mod tests { #[test] fn monster_move_facing_angle_is_captured() { - // A facing-angle move (moveType 4): head, angle, then flags/duration/count + one point. + // moveType 4 carries a facing angle. let mut body = head(0x55, [0.0, 0.0, 0.0], 4); body.extend_from_slice(&1.25f32.to_le_bytes()); // facing angle body.extend_from_slice(&0u32.to_le_bytes()); // spline flags (ground) @@ -303,7 +229,6 @@ mod tests { } => { assert!(!stop); assert_eq!(duration_ms, 500); - // A count-1 (endpoint-only) move is the two-point straight hop `[start, endpoint]`. assert_eq!(path.len(), 2, "start + endpoint"); assert!((path[0].x - 0.0).abs() < 1e-6, "anchored at the wire start"); assert!((path[1].x - 10.0).abs() < 1e-6, "endpoint verbatim"); @@ -316,8 +241,6 @@ mod tests { } } - /// The plain two-point hop — a `MoveTo` with no intermediate waypoints: `count = 1`, the - /// destination, no offsets. Encoded here by the transcribed producer above. #[test] fn monster_move_two_point_path_carries_no_offsets() { let mut body = head(0x77, [4.0, 8.0, 0.0], 0); @@ -336,11 +259,8 @@ mod tests { } } - /// **The one-corner path, byte for byte as vmangos writes it** — hand-assembled, not through the - /// transcribed encoder, so the two cannot share a misreading. A pathfinder route `c₀ → c₁ → c₂` - /// is `count = 2`, the endpoint `c₂`, and ONE packed offset `c₂ − c₁`. The read this replaced - /// took `count == 2` for an offset-free hop: it read nothing, cut the corner (`[c₀, c₂]`) and - /// left four bytes unread — the live run's `SMSG_MONSTER_MOVE … left 4 trailing byte(s)`. + /// Hand-assembled rather than through `append_linear_path`: the route `c₀ → c₁ → c₂` is + /// `count = 2`, the endpoint `c₂` and one packed offset `c₂ - c₁`. #[test] fn monster_move_one_corner_path_keeps_its_corner() { let mut body = head(0x77, [0.0, 0.0, 0.0], 0); // c₀ in the head @@ -369,9 +289,7 @@ mod tests { #[test] fn monster_move_ground_path_decodes_every_waypoint() { - // A four-waypoint ground patrol, encoded exactly as vmangos `WriteLinearPath` ships it, must - // round-trip to the full travel-order polyline — not collapse to `start → endpoint`, and not - // lose its first corner. Points are ¼-yd multiples so the packed quantization is exact. + // ¼-yd multiples, so the packed quantization is exact. let want = [ [0.0f32, 0.0, 0.0], // start [10.0, 0.0, 0.0], // corner east @@ -408,9 +326,6 @@ mod tests { #[test] fn monster_move_flying_path_reads_absolute_points() { - // A flying (`Mask_CatmullRom`) move ships its waypoints ABSOLUTE (vmangos `WriteCatmullRomPath`), - // not as packed offsets. The parser must switch layouts on the flag — the old single-layout read - // mis-sized the body and dropped every flight. `count` = post-start points; the start prepends. let mids = [[3.0f32, 4.0, 50.0], [6.0, 8.0, 55.0]]; let mut body = head(0x77, [0.0, 0.0, 40.0], 0); body.extend_from_slice(&SPLINE_FLAG_FLYING.to_le_bytes()); // flying ⇒ catmull-rom layout diff --git a/crates/benilla-protocol/src/messages/movement.rs b/crates/benilla-protocol/src/messages/movement.rs index caddfe01..d27b3a4c 100644 --- a/crates/benilla-protocol/src/messages/movement.rs +++ b/crates/benilla-protocol/src/messages/movement.rs @@ -1,50 +1,29 @@ -//! The shared movement vocabulary: the `MovementInfo` body (+ its `JumpInfo` ballistic tail and -//! `TransportPose` rider tail) every `MSG_MOVE_*` relay carries, its flag bits, and the parse — read by -//! the update-object living block and [`super`]'s relay arms, written by [`super::client`]'s outbound -//! movement. +//! The `MovementInfo` body every `MSG_MOVE_*` carries, its flag bits, and the movement verbs. use std::io::{self, Read}; use crate::wire::{read_f32_le, read_u32_le, read_u64_le, Vector3d}; -// MovementFlags bits, shared by the living [`MovementBlock`](super::MovementBlock) (in -// `update_object`) and the client [`MovementInfo`] parse ([`read_movement_info`]) below. -// ON_TRANSPORT is 1.12's bit 25 (vmangos `MovementInfo.h:56` `MOVEFLAG_ONTRANSPORT = 0x02000000`) -// — NOT the TBC-era 0x200, which in vanilla is `MOVEFLAG_UNUSED10`. The original transcription -// carried 0x200; caught 2026-07-17 against the vmangos source when riding (0438 phase 2) landed. +// MovementFlags bits (vmangos `MovementInfo.h`). ON_TRANSPORT is 1.12's 0x0200_0000 +// (`MovementInfo.h:56`), not TBC's 0x200, which 1.12 names `MOVEFLAG_UNUSED10`. pub(super) const MOVEMENT_FLAG_ON_TRANSPORT: u32 = 0x0200_0000; pub(super) const MOVEMENT_FLAG_JUMPING: u32 = 0x2000; pub(super) const MOVEMENT_FLAG_SWIMMING: u32 = 0x20_0000; pub(super) const MOVEMENT_FLAG_SPLINE_ENABLED: u32 = 0x40_0000; pub(super) const MOVEMENT_FLAG_SPLINE_ELEVATION: u32 = 0x400_0000; -/// Read `MSG_MOVE_TIME_SKIPPED` — one observed mover's **packed** guid and the milliseconds its -/// own client skipped (VERIFIED: the reference's handler `0x603b40` reads a packed guid through -/// `0x642ed0`, resolves under `TYPEMASK_UNIT`, then reads a plain `u32`; vmangos relays exactly -/// that shape, `MovementHandler.cpp:1011-1017`). -/// -/// Note the **asymmetry with the client's own send**, which is the same fact in the other -/// direction and writes a *plain* 8-byte guid (see [`super::client::move_time_skipped`]). -/// Inbound packed, outbound plain — that is the reference's own encoding, not a slip. +/// `MSG_MOVE_TIME_SKIPPED` inbound: a packed guid and the ms that mover's client skipped +/// (reference handler `0x603b40`; vmangos `MovementHandler.cpp:1011-1017`). Our own send uses a +/// plain 8-byte guid ([`super::client::move_time_skipped`]); the asymmetry is the reference's. pub(super) fn read_move_time_skipped(r: &mut &[u8]) -> io::Result<(u64, u32)> { let guid = crate::wire::read_packed_guid(r)?; let lag_ms = read_u32_le(r)?; Ok((guid, lag_ms)) } -/// Read a wire `MovementInfo` — the body shared by every `MSG_MOVE_*` (and the teleport ack). Surfaces -/// `flags`/`position`/`orientation`/`timestamp`/`fall_time`, the **transport pose** ([`TransportPose`], -/// present iff `MOVEFLAG_ON_TRANSPORT` — this is how a boarded rider's `MSG_MOVE_*` heartbeat carries -/// its local frame, decision 0438 "Riding is the mover's platform frame"), the **swim pitch** -/// (`Self::pitch`, present iff `MOVEFLAG_SWIMMING`), and the **jump tail** ([`JumpInfo`], so an -/// observer can replay a jump arc); the spline-elevation tail is parsed to stay aligned but discarded. -/// -/// VERIFIED byte-for-byte against vmangos `MovementInfo::Read` (build 1.12.1): note 1.12 has **no** -/// transport-time field after the transport pose — that was added in TBC. (The `update_object` -/// movement-block transport read, proven against `wow_world_messages`, omits it for the same reason.) -/// The **swim pitch** is a lone `f32` after the (optional) transport pose and before `fallTime`, gated on -/// `MOVEFLAG_SWIMMING`. `fallTime` is a `u32` and the jump tail (gated on `MOVEFLAG_JUMPING`) is `zspeed, -/// cosAngle, sinAngle, xyspeed`, after `fallTime` and before the spline-elevation float. +/// vmangos `MovementInfo::Read` order: flags, time, position, facing, the transport pose if +/// `ON_TRANSPORT` (1.12 has no transport time), the swim pitch if `SWIMMING`, `u32` fall time, the +/// jump tail if `JUMPING`, then a spline-elevation `f32` if `SPLINE_ELEVATION`, read and dropped. pub(super) fn read_movement_info(r: &mut impl Read) -> io::Result { let flags = read_u32_le(r)?; let timestamp = read_u32_le(r)?; @@ -52,9 +31,7 @@ pub(super) fn read_movement_info(r: &mut impl Read) -> io::Result let orientation = read_f32_le(r)?; let transport = if flags & MOVEMENT_FLAG_ON_TRANSPORT != 0 { Some(TransportPose { - // A FULL u64, not packed: vmangos `data >> t_guid` resolves to the plain - // `ObjectGuid` operator (`ObjectGuid.cpp:180`, `buf.read()`); the packed - // reader is a distinct operator this path never uses. + // A full u64, not packed (vmangos `ObjectGuid.cpp:180`, `buf.read()`). guid: read_u64_le(r)?, pos: Vector3d::read(r)?, orientation: read_f32_le(r)?, @@ -93,11 +70,10 @@ pub(super) fn read_movement_info(r: &mut impl Read) -> io::Result }) } -// --- shared movement vocabulary (read by the arms above, written by `client`) --------------------- +// --- movement vocabulary --- -/// Which of a mover's six movement speeds a force-speed-change packet addresses — the wire family's -/// shared axis (each kind is its own SMSG/CMSG opcode pair; see [`super::opcode`]'s force-speed -/// block). Order matches the `LIVING` block's speed array and vmangos `UnitMoveType`. +/// A mover speed, each with its own force-change opcode pair; the order is the `LIVING` block's +/// speed array and vmangos `UnitMoveType`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SpeedKind { Walk, @@ -123,25 +99,11 @@ impl SpeedKind { } } -/// **A granted mover *mode*** — one of the four the server hands the controlling client, each a -/// single `MOVEMENTFLAGS` bit that changes how the mover behaves rather than where it is going -/// (decision 0866). Sibling of [`SpeedKind`]: same shape (a mode per opcode pair, an ack per mode), -/// same reason for existing — the server treats the set as one family (`IsFlagAckOpcode`, -/// `MovementChangeType`), so we do too, and a fifth mode is a variant rather than a new lane. -/// -/// The bit values are 1.12's, VERIFIED vmangos `Objects/MovementInfo.h:25-62`. What each one *does* -/// is the client's own business — the server only grants it — and all four are byte-verified in the -/// reference's CMovement setter cluster (`0x7c7280`–`0x7c7370`, wow-re `system/collision`): -/// -/// | mode | bit | reference effect | granted by | -/// |---|---|---|---| -/// | [`Root`](Self::Root) | `0x0000_1000` | translation dies; the fall stops. **Turning stays live**, by the input tick's authored allow-list (`0x618054`). | root/stun/death | -/// | [`WaterWalk`](Self::WaterWalk) | `0x1000_0000` | the liquid surface becomes walkable ground | `SPELL_AURA_WATER_WALK` (104) | -/// | [`FeatherFall`](Self::FeatherFall) | `0x2000_0000` | terminal fall velocity drops to 7 yd/s from 60.148 (`0x7c5d20`) | `SPELL_AURA_FEATHER_FALL` (105) | -/// | [`Hover`](Self::Hover) | `0x4000_0000` | ground contact rises by 1.0 yd (walk resolver `0x6367b0`) | `SPELL_AURA_HOVER` (106) | -/// -/// **Levitate (1706) grants three of them at once** — feather fall + hover + water walk — which is -/// what makes them one system rather than four coincidences. +/// A mode the server grants the controlling client, one `MOVEMENTFLAGS` bit each (vmangos +/// `MovementInfo.h:25-62`, reference setters `0x7c7280`-`0x7c7370`). Root stops translation and +/// falling but not turning (`0x618054`); water walk makes liquid walkable (aura 104); feather fall +/// caps the fall at 7 yd/s instead of 60.148 (aura 105, `0x7c5d20`); hover lifts ground contact by +/// 1.0 yd (aura 106, `0x6367b0`). Levitate (spell 1706) grants the last three at once. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MoveMode { Root, @@ -161,8 +123,8 @@ impl MoveMode { } } - /// The `CMSG_*_ACK` opcode answering this mode's SMSG. Root is the only mode whose two - /// directions ack on *different* opcodes (vmangos routes both to `HandleMoveRootAck`). + /// The `CMSG_*_ACK` answering this mode's SMSG. Only root acks apply and remove on different + /// opcodes (vmangos routes both to `HandleMoveRootAck`). pub fn ack_opcode(self, apply: bool) -> u16 { use super::opcode; match (self, apply) { @@ -174,53 +136,32 @@ impl MoveMode { } } - /// Whether the ack body carries the trailing `u32 apply` dword. Root's does **not**: it lands on - /// vmangos's `HandleMoveRootAck` (which infers apply from the opcode), while the other three land - /// on `HandleMovementFlagChangeToggleAck`, which reads it (`Server/Packets/Movement.cpp:38-59`). + /// Whether the ack ends in a `u32 apply`: not root's, whose handler infers it from the opcode + /// (vmangos `Server/Packets/Movement.cpp:38-59`). pub fn ack_carries_apply(self) -> bool { !matches!(self, MoveMode::Root) } } -/// **A movement mode granted on a unit we do not control** — the `SMSG_SPLINE_MOVE_*` twelve -/// (decision 1780), the observer half of [`MoveMode`]'s family. -/// -/// Same bits, and for the four they share the same reference setters — but it is a *different -/// message*, and the three differences are what the type exists to keep straight: -/// -/// 1. **No counter and no ack.** The controller's family is a handshake (`packed guid + u32 -/// counter`, ack or the server never applies it); this one is a bare `packed guid` broadcast. -/// The client's handler `0x603c80` replies to nothing. -/// 2. **Any unit.** `0x603c80` resolves the guid with `TYPEMASK_UNIT` and applies to whatever it -/// finds — normally a creature, since vmangos only takes this leg for a unit *not* being moved -/// by a player (`Unit::SetRooted`/`SetWaterWalking`/`SetFeatherFall`/`SetHover`'s `else` arm, -/// and `SendToggleRunWalkToAll` unconditionally). -/// 3. **Two extra modes.** [`WalkMode`](Self::WalkMode) and [`Swimming`](Self::Swimming) have no -/// ack'd counterpart at all. -/// -/// [`Swimming`](Self::Swimming) is modelled but vmangos never sends it — nothing in the tree -/// constructs `SMSG_SPLINE_MOVE_START_SWIM`/`_STOP_SWIM`, and `Opcodes.cpp:888` marks both -/// `SendByServer`-only. The client handles them (`0x61a130`/`0x61a160`), so we decode them; on this -/// server they are dead wire. +/// A mode set on a unit we do not control, by the twelve `SMSG_SPLINE_MOVE_*`: a bare packed guid, +/// no counter, no ack (client handler `0x603c80`, any unit). vmangos sends the four shared modes +/// only for units no player moves, and walk/run always. Walk mode and swimming have no acked form. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SplineMode { Root, WaterWalk, FeatherFall, Hover, - /// `MOVEFLAG_WALK_MODE` (`0x100`) — the gait selector. **`apply` here is the flag's direction, - /// not the opcode's noun**: `SMSG_SPLINE_MOVE_SET_WALK_MODE` is `apply: true` and - /// `..._SET_RUN_MODE` is `apply: false`, because the reference's `0x617e80` passes the opcode's - /// bool to `SetRunMode 0x7c71c0`, whose argument is *run*. The inversion is folded at the parse - /// so every variant of this enum means the same thing by "apply". + /// `MOVEFLAG_WALK_MODE` (`0x100`); `apply` is the flag's direction, so `SET_WALK_MODE` applies + /// and `SET_RUN_MODE` removes (the reference `0x617e80` feeds `SetRunMode`, `0x7c71c0`). WalkMode, - /// `MOVEFLAG_SWIMMING` (`0x20_0000`) — `SMSG_SPLINE_MOVE_START_SWIM`/`_STOP_SWIM`. Never sent by - /// vmangos (see the type docs). + /// `MOVEFLAG_SWIMMING` (`0x20_0000`), by `START_SWIM`/`STOP_SWIM`. The reference handles them + /// (`0x61a130`, `0x61a160`); vmangos never sends them (`Opcodes.cpp:888`). Swimming, } impl SplineMode { - /// This mode's `MOVEMENTFLAGS` bit — the same word [`MoveMode::flag`] writes into. + /// This mode's `MOVEMENTFLAGS` bit, in the same word as [`MoveMode::flag`]. pub fn flag(self) -> u32 { match self { SplineMode::Root => 0x0000_1000, @@ -233,55 +174,29 @@ impl SplineMode { } } -/// **What the opcode of a relayed move means**, beyond the pose every one of them carries — the -/// receiver's switch, modelled once (decision 2064). -/// -/// The `[packed guid][MovementInfo]` relay family is 23 opcodes on ONE client handler (`0x603bb0` → -/// `OnUnitMoveEvent 0x601580`), and for most of them the opcode is pure narration: the flags word in -/// the body already says what changed, and `0x601580`'s parse runs *before* it even loads the opcode -/// (`0x47eba0` at `0x6015ce`). Three cases are not narration, and this enum is exactly those three — -/// VERIFIED against the binary in wow-re's `collision/scratch/movement-relay-family-map.md`, which -/// maps all thirty rows. -/// -/// Deliberately one field rather than a bag of booleans: they are disjoint (one opcode per packet) -/// and the set is closed, so an enum forces every receiver to say what it does with each — which is -/// how the mistake below was possible for as long as it was. +/// What a relayed move's opcode means beyond its pose. The 23 `[packed guid][MovementInfo]` relays +/// share one reference handler (`0x603bb0`, `0x601580`), and for most the opcode adds nothing the +/// flags word does not say. The variants beyond `Pose` name the opcodes a receiver tells apart. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum RelayVerb { - /// The ordinary pose stream: start/stop/strafe/jump/turn/pitch/swim/fall-land/set-facing, - /// walk-vs-run mode, the observer's hover / feather-fall / water-walk, and the knockback relay. - /// The opcode adds nothing the flags word does not already carry. + /// Every other relay (start, stop, strafe, jump, turn, swim, land, facing, modes, knockback). #[default] Pose, - /// `MSG_MOVE_HEARTBEAT` — the periodic mid-move pulse. - /// - /// **It is NOT excluded from the pre-fire reconcile, and believing otherwise was a two-year - /// error** (decision 2064, correcting 0601/0603). The queued move-event node's tag `0x26`, which - /// `0x619030` (facing interp) and `0x619090` (position reconcile) both skip, is the - /// **teleport's**, not the heartbeat's — `push 0x26` appears at exactly two addresses in the - /// movement region, `0x6186bd` and `0x618736`, both inside functions reached only from the - /// teleport arms (`0x602fb0`, whose other branch sends `push 0xc7` = `MSG_MOVE_TELEPORT_ACK`). - /// A deferred heartbeat *is* eligible for both blends. The variant survives the correction - /// because the distinction is still worth tracing. + /// `MSG_MOVE_HEARTBEAT`, the periodic mid-move pulse. Both reference blends apply to it + /// (`0x619030`, `0x619090`); the tag they skip, `0x26`, is only pushed for a teleport. Heartbeat, - /// `MSG_MOVE_TELEPORT` — the observer's near-teleport (a Blink, a `.tele`). **The one opcode of - /// the thirty the client never smooths toward**: tag `0x26`, skipped by both blends. Every relay - /// snaps the pose (`0x7c6420` writes the wire pose into the live position *and* the integrator - /// base); this one additionally re-bases the mover, zeroes the interp cells `[cmov+0x148]`/ - /// `[cmov+0x14c]` via `0x617e90`, and past 30.0 yd forces a world re-anchor. + /// `MSG_MOVE_TELEPORT`, an observed near-teleport (Blink, `.tele`): the one relay the client + /// never smooths toward (tag `0x26`). It also re-bases the mover, zeroes its interpolation + /// (`0x617e90`) and past 30 yd re-anchors the world. Teleport, - /// `MSG_MOVE_ROOT` (`true`) / `MSG_MOVE_UNROOT` (`false`) — the one place in this family where - /// **the opcode decides and the flags word does not get a vote**. After the masked merge, the - /// client runs `SetRoot 0x7c7340` (`or 0x1000`, then the one-shot motion wipe `& 0xffe07f00`) or - /// `ClearRoot 0x7c7370` (`and ~0x1000`) unconditionally. vmangos happens to send a word that - /// already agrees — it forces `MOVEFLAG_ROOT` back on for a rooted mover - /// (`MovementHandler.cpp:1070`) — so honouring the opcode changes nothing today and makes the - /// root independent of the server continuing to be careful. + /// `MSG_MOVE_ROOT` (`true`) / `MSG_MOVE_UNROOT` (`false`): the opcode, not the flags word, + /// decides. The client then runs `SetRoot` (`0x7c7340`, also wiping motion) or `ClearRoot` + /// (`0x7c7370`) unconditionally; vmangos's flags already agree (`MovementHandler.cpp:1070`). Root(bool), } impl RelayVerb { - /// Which verb a relay opcode carries. Anything not named here is [`Self::Pose`]. + /// The verb a relay opcode carries. pub fn of(opcode: u16) -> Self { use super::opcode as op; match opcode { @@ -294,77 +209,43 @@ impl RelayVerb { } } -/// One jump's ballistic launch parameters — the conditional `MovementInfo` tail present iff the -/// `MOVEFLAG_JUMPING` (0x2000) flag is set. VERIFIED byte-for-byte against vmangos `MovementInfo::Read` -/// (build 1.12.1): wire order is `zspeed, cosAngle, sinAngle, xyspeed` (note cos *before* sin). The -/// horizontal launch velocity is `(cos_angle, sin_angle) · xy_speed` in **world XY** (absolute, frozen -/// at take-off) and `zspeed` is the constant take-off vertical speed (+Z up); the resulting arc is -/// `pos += horiz·t`, `z += zspeed·t − ½·g·t²` (the same `g = 19.291105` the controller uses). This is -/// what lets a receiver replay a jump as one ballistic event between packets rather than snapping its -/// height at the packet rate (decision 0053). Corroborated by vmangos `Unit.cpp` `ExtrapolateMovement`, -/// which integrates exactly this arc from the same fields. +/// The `JUMPING` tail: `zspeed, cosAngle, sinAngle, xyspeed`, cos before sin (vmangos +/// `MovementInfo::Read`). The launch is `(cos, sin) * xy_speed` in world XY, frozen at take-off, +/// so a receiver can replay the arc under `g = 19.291105` (vmangos `ExtrapolateMovement`). #[derive(Debug, Clone, Copy, Default, PartialEq)] pub struct JumpInfo { - /// Take-off vertical speed (yd/s), constant for the whole arc, **down-positive**: the real 1.12.1 - /// client sends `-7.955547` for a *rising* jump (VERIFIED, vanilla-sniffs `dwarf_rogue_dun_morogh`). - /// The receiver's up-speed is therefore `-zspeed` (decision 0054). + /// Take-off vertical speed (yd/s), down-positive: the 1.12 client sends `-7.955547` for a + /// rising jump, so the up-speed is `-zspeed`. pub zspeed: f32, - /// Cosine of the horizontal launch heading (the X component of the unit launch direction, world XY). pub cos_angle: f32, - /// Sine of the horizontal launch heading (the Y component, world XY). pub sin_angle: f32, /// Horizontal launch speed (yd/s); the frozen ground speed at take-off. pub xy_speed: f32, } -/// A rider's pose **relative to the transport it's on** — the `MOVEFLAG_ON_TRANSPORT` (0x0200_0000) -/// conditional tail carried by both a `LIVING` update-object movement block (`MovementBlock::transport`, -/// in `update_object`) and a `MSG_MOVE_*` relay's `MovementInfo` ([`Self::transport`]): `[u64 -/// transport guid][local x, y, z][local o]`. VERIFIED byte-for-byte against vmangos `MovementInfo::Read` -/// (build 1.12.1) — the guid is a FULL u64 (never packed here), and 1.12 carries **no** -/// transport-time field after this (added TBC). -/// `pos`/`orientation` are in the transport's own local frame (decision 0438 "Riding is the mover's -/// platform frame"): a consumer composes `world = transport_matrix × local` off the named transport's -/// live pose each frame, never treats these as raw world coordinates. +/// The `ON_TRANSPORT` tail of `MovementInfo` and of a `LIVING` block: a full `u64` transport guid, +/// then position and facing in the transport's own frame (vmangos `MovementInfo::Read`). #[derive(Debug, Clone, Copy, PartialEq)] pub struct TransportPose { - /// The transport GameObject's guid (`HIGH_MO_TRANSPORT` or `HIGH_TRANSPORT` — [`crate::guid`]). + /// The transport GameObject's guid, `HIGH_MO_TRANSPORT` or `HIGH_TRANSPORT`. pub guid: u64, pub pos: Vector3d, pub orientation: f32, } -/// A wire `MovementInfo` — the body shared by every `MSG_MOVE_*` (both directions) and the teleport -/// ack. benilla sets the base directional/turn/walk flags, `JUMPING` while airborne, `SWIMMING` -/// while in the water, and `ON_TRANSPORT` (+ the [`TransportPose`] tail) while riding a boat/zepp -/// (decision 0438 phase 2). The transport, swim-pitch, and jump tails are conditional -/// outbound, gated on their flags. Inbound, every conditional tail is parsed (see -/// [`read_movement_info`]) — the transport pose into [`Self::transport`], the swim pitch into -/// `Self::pitch`, the jump tail into [`Self::jump`], and the spline-elevation float to stay aligned -/// only (no consumer needs it). +/// The body of every `MSG_MOVE_*`, in both directions, and of the teleport ack. pub struct MovementInfo { pub flags: u32, pub timestamp: u32, pub position: Vector3d, pub orientation: f32, - /// The sender's pose on a transport — `Some` iff `flags & MOVEMENT_FLAG_ON_TRANSPORT`, carried - /// right after `orientation` and before the swim-pitch tail (VERIFIED order vs vmangos - /// `MovementInfo::Read`). This is how another rider's `MSG_MOVE_*` heartbeat tells us they're on a - /// boat/elevator and where, in that transport's local frame. + /// `Some` iff `ON_TRANSPORT` is set: the sender's pose in the transport's frame. pub transport: Option, - /// The **swim pitch** (radians, +up) — the conditional `f32` tail present iff `flags & - /// MOVEMENT_FLAG_SWIMMING`, carried after the orientation/transport pose and before `fall_time` - /// (VERIFIED order vs vmangos `MovementInfo::Read`). It is the vertical angle of the swim heading, so - /// observers can pitch a swimming body up/down the way the local client does. `0.0` (level) when not - /// swimming — the serializer only emits it while the `SWIMMING` flag is set, so a zero here on a - /// non-swimming packet costs nothing. + /// Swim pitch in radians, up-positive; on the wire only while `SWIMMING`, else read as `0.0`. pub pitch: f32, - /// Milliseconds airborne — a **`u32`** on the wire (VERIFIED vmangos `MovementInfo::fallTime`; it - /// was read+written as an f32 while discarded, harmless then but wrong once consumed). The - /// receiver's ballistic clock: current vertical speed is `zspeed − g·(fall_time/1000)`. + /// Milliseconds airborne, a `u32` (vmangos `MovementInfo::fallTime`): the jump arc's clock. pub fall_time: u32, - /// The jump tail — `Some` iff `flags & MOVEMENT_FLAG_JUMPING`. The launch params an observer - /// replays the arc from. + /// `Some` iff `JUMPING` is set: the launch an observer replays the arc from. pub jump: Option, } @@ -376,11 +257,7 @@ impl MovementInfo { w.extend_from_slice(&self.position.y.to_le_bytes()); w.extend_from_slice(&self.position.z.to_le_bytes()); w.extend_from_slice(&self.orientation.to_le_bytes()); - // The transport tail rides iff ON_TRANSPORT is set — right after `orientation`, before the - // swim pitch, matching vmangos `MovementInfo::Read` exactly: `[u64 guid][local x,y,z][local - // o]` (the guid is a FULL u64 — see `read_movement_info`). Flag and tail must travel - // together or the server's read desyncs; the caller sets both from one rider state - // (decision 0438 "Riding is the mover's platform frame"). + // Each conditional tail must travel with its flag or vmangos `MovementInfo::Read` desyncs. if self.flags & MOVEMENT_FLAG_ON_TRANSPORT != 0 { let t = self.transport.unwrap_or(TransportPose { guid: 0, @@ -397,18 +274,10 @@ impl MovementInfo { w.extend_from_slice(&t.pos.z.to_le_bytes()); w.extend_from_slice(&t.orientation.to_le_bytes()); } - // The swim-pitch tail rides iff SWIMMING is set — after the transport pose and before - // `fall_time`, matching `read_movement_info`'s gate and order exactly: setting the flag - // without serializing the pitch (or vice-versa) desyncs the server's `MovementInfo::Read`, - // the reason this bit was masked off the wire until now. if self.flags & MOVEMENT_FLAG_SWIMMING != 0 { w.extend_from_slice(&self.pitch.to_le_bytes()); } - // fall_time follows the transport + swim-pitch tails. w.extend_from_slice(&self.fall_time.to_le_bytes()); - // The jump tail rides iff JUMPING is set — matching the parser's gate exactly: setting the flag - // without serializing the tail (or vice-versa) desyncs the server's `MovementInfo::Read`. Order - // matches vmangos: zspeed, cosAngle, sinAngle, xyspeed. if self.flags & MOVEMENT_FLAG_JUMPING != 0 { let j = self.jump.unwrap_or_default(); w.extend_from_slice(&j.zspeed.to_le_bytes()); @@ -441,9 +310,6 @@ mod tests { } } - /// The swim-pitch tail round-trips byte-exactly through `write` → `read_movement_info` when - /// SWIMMING is set: the reason the flag was masked off the wire (an unwritten tail desyncing the - /// server's `MovementInfo::Read`) is closed only if write and read agree on gate + order. #[test] fn swim_pitch_round_trips_when_swimming() { let mut bytes = Vec::new(); @@ -458,10 +324,6 @@ mod tests { ); } - /// The transport tail round-trips byte-exactly through `write` → `read_movement_info` when - /// ON_TRANSPORT is set: `[u64 guid][x,y,z][o]` right after `orientation` (vmangos - /// `MovementInfo::Read`/`Write` order), 24 bytes exactly — a packed guid or the TBC 0x200 - /// flag value would both shift `fall_time` and desync the server. #[test] fn transport_tail_round_trips_when_riding() { let mut riding = info(MOVEMENT_FLAG_ON_TRANSPORT); @@ -491,8 +353,6 @@ mod tests { assert_eq!(back.fall_time, 42, "fall_time lands after the tail"); } - /// Non-swimming packets carry NO pitch tail — the four pitch bytes are absent, so the wire stays - /// byte-identical to before this tail existed (and `fall_time` reads from the right offset). #[test] fn no_pitch_tail_when_not_swimming() { let mut swimming = Vec::new();