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) <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-23 20:10:35 +07:00
co-authored by Claude Opus 5.5
parent 1401567b0c
commit 90a28f3d76
7 changed files with 257 additions and 805 deletions
+73 -226
View File
@@ -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<LootItem>,
},
/// 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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<LootStartRoll> {
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<LootStartRoll> {
})
}
/// Read `SMSG_LOOT_ROLL` (see [`LootRoll`] — including the overloaded `(roll_number, roll_type)`).
pub(super) fn read_loot_roll(r: &mut &[u8]) -> io::Result<LootRoll> {
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<LootRoll> {
})
}
/// 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<LootRollWon> {
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<LootRollWon> {
})
}
/// Read `SMSG_LOOT_ALL_PASSED` (see [`LootAllPassed`] — note the swapped `u32` pair).
pub(super) fn read_loot_all_passed(r: &mut &[u8]) -> io::Result<LootAllPassed> {
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<LootAllPassed> {
})
}
/// 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<Vec<u64>> {
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<Vec<u64>> {
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<u8> {
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<u32> {
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<ItemPushResult> {
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::<u8>::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
+37 -93
View File
@@ -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<u64>,
/// `Some` for [`mail_message_type::AUCTION`]/`CREATURE`/`GAMEOBJECT`.
pub sender_id: Option<u32>,
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<MailAttachment>,
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<Vec<MailListEntry>> {
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<Vec<MailListEnt
};
let subject = read_cstring(r)?;
let item_text_id = read_u32_le(r)?;
let _package = read_u32_le(r)?; // always 0 on 5875 (vmangos hardcodes it); no consumer
let _package = read_u32_le(r)?; // always 0 (vmangos hardcodes it)
let stationery = read_u32_le(r)?;
let entry = read_u32_le(r)?;
let perm_enchant = read_u32_le(r)?;
@@ -268,14 +228,8 @@ pub(super) fn read_mail_list_result(r: &mut &[u8]) -> io::Result<Vec<MailListEnt
Ok(mails)
}
/// Read `SMSG_SEND_MAIL_RESULT` (VERIFIED vmangos `Server/Packets/Mail.cpp`,
/// `SendMailResult::AppendBodyTo`): `u32 mailId, u32 action ([`mail_action`]), u32 error
/// ([`mail_error`])`, then a conditional tail — `u32 equipError` iff `error ==
/// `[`mail_error::EQUIP_ERROR`]`, else `u32 itemEntry, u32 itemCount` iff `action ==
/// `[`mail_action::ITEM_TAKEN`]` and `error == `[`mail_error::OK`]``. Neither tail rides any other
/// action/error combination; the remaining-length guard mirrors how the codec's other
/// conditional tails (e.g. `SMSG_TRANSFER_PENDING`'s transport block) stay robust to a body that
/// doesn't carry the tail its fields would otherwise predict.
/// `SMSG_SEND_MAIL_RESULT` (vmangos `Server/Packets/Mail.cpp`): `u32 mailId, action, error`, then
/// `u32 equipError` on `EQUIP_ERROR`, or `u32 itemEntry, itemCount` on an OK `ITEM_TAKEN`.
#[allow(clippy::type_complexity)]
pub(super) fn read_send_mail_result(
r: &mut &[u8],
@@ -293,29 +247,19 @@ pub(super) fn read_send_mail_result(
Ok((mail_id, action, error, equip_error, item))
}
/// Read `SMSG_ITEM_TEXT_QUERY_RESPONSE` (VERIFIED vmangos `Server/Packets/Mail.cpp`,
/// `ItemTextQueryResponse::AppendBodyTo`): `u32 textId, cstr text` — the letter body's ask-once
/// fetch, answering [`item_text_query`].
/// `SMSG_ITEM_TEXT_QUERY_RESPONSE` (vmangos `Server/Packets/Mail.cpp`): `u32 textId, cstr text`.
pub(super) fn read_item_text_query_response(r: &mut &[u8]) -> 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<f32> {
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<f32> {
read_f32_le(r)
}
@@ -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<MeetingStoneSetQueue> {
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<Meet
})
}
/// Body of `CMSG 0x292` (VERIFIED, builder `0x4c9ff0`): one full little-endian `u64` — the guid of
/// the `GAMEOBJECT_TYPE_MEETINGSTONE` (23) the player right-clicked, written by the 8-byte guid
/// writer `0x418370` straight after `PutUInt32(0x292)`. Nothing else is in the packet: the area is
/// the SERVER's to resolve from the stone's `gameobject_template.data[2]`, which is why the join
/// names an object and the reply names an area.
/// `CMSG 0x292` (reference builder `0x4c9ff0`): the right-clicked stone's guid (GO type 23) and
/// nothing else; the server resolves the area from the stone's `gameobject_template.data[2]`.
pub fn meeting_stone_join(go_guid: u64) -> Vec<u8> {
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<u8> {
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<MeetingStoneNotice> {
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<MeetingStoneNotice> {
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<Me
mod tests {
use super::*;
/// The join body is the bare guid, little-endian, eight bytes and no more — `0x4c9ff0` writes
/// `PutUInt32(0x292)` into the header and then exactly one `0x418370` (the 8-byte guid writer).
/// The reference builder `0x4c9ff0` writes one 8-byte guid (`0x418370`) after the opcode.
#[test]
fn cmsg_meetingstone_join_body_golden() {
assert_eq!(
@@ -77,7 +67,6 @@ mod tests {
);
}
/// Both of the other two sends in this family are empty bodies.
#[test]
fn the_leave_body_is_empty() {
assert!(meeting_stone_leave().is_empty());
@@ -1,59 +1,26 @@
//! The mirror timers — the breath / fatigue / feign-death bars (opcodes 473-475; decision 0874).
//!
//! "Mirror" is the client's own word for a timer whose authority is the **server**: the client
//! never computes breath or fatigue, it mirrors a countdown the server ships and integrates it
//! locally between packets. Three opcodes carry the whole system:
//!
//! | opcode | what it means |
//! |---|---|
//! | `SMSG_START_MIRROR_TIMER` 0x1D9 | start **or fully re-state** one timer |
//! | `SMSG_PAUSE_MIRROR_TIMER` 0x1DA | freeze/unfreeze one running timer |
//! | `SMSG_STOP_MIRROR_TIMER` 0x1DB | that timer is over — hide its bar |
//!
//! The bodies are VERIFIED against vmangos's own packet builds
//! (`Server/Packets/Misc.h:718-751` + `Misc.cpp:472-491`), which are the exact bytes benilla
//! receives, and the type numbering against `Objects/MirrorTimer.h`
//! (`FATIGUE=0, BREATH=1, FEIGNDEATH=2, NUM_CLIENT_TIMERS=3`) — the server's `SendMirrorTimers`
//! gate means a **fourth** type (its internal `ENVIRONMENTAL`, lava/slime damage) is never sent
//! to a client at all; it drives damage server-side with no bar.
//!
//! Two things about this family are worth knowing before reading the app side:
//!
//! - **There is no separate "update" opcode.** A running timer that changes anything —
//! direction, remaining time, frozen state — is re-sent as a whole `START`
//! (`Player::SendMirrorTimers`, the `FULL_UPDATE` arm). So the consumer must treat `START` as
//! idempotent *re-statement*, not only as a first appearance.
//! - **`PAUSE` is effectively dead on a vanilla wire.** vmangos deliberately never sends it —
//! `SendMirrorTimers` replaces the pause with a full `START` and says why in the source
//! ("Gotta do a full update with SMSG_START_MIRROR_TIMER to avoid lua errors"), because the
//! shipped 1.12 `MirrorTimer.lua` handler tests the same `arg1` both as a name and as a number.
//! We still decode it — a server that sends one should not desync us (see
//! `crate::ui_mirror` for what the app does with it).
//! Mirror timers: the breath, fatigue and feign-death bars. The server owns the countdown and the
//! client integrates it between packets. Any change is re-sent as a full `START`
//! (`Player::SendMirrorTimers`), so `START` also re-states a running timer; vmangos never sends
//! `PAUSE`, whose stock `MirrorTimer.lua` handler errors on it.
use std::io::{self, Read};
use crate::wire::{read_i32_le, read_u32_le, read_u8};
/// Which mirror timer a packet is about — the wire's `timerType` word.
///
/// The numbering is the server's `MirrorTimer::Type` (vmangos `Objects/MirrorTimer.h`), and it is
/// also the index the reference client uses into its own 3-entry name table, so a value outside
/// this set has no meaning on either end.
/// The wire's `timerType` (vmangos `Objects/MirrorTimer.h`), also the reference client's index
/// into its 3-entry timer name table.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MirrorTimerKind {
/// `0` — swimming in "high sea" (deep, uncharted water): the **Fatigue** bar.
/// `0`: swimming in deep, uncharted water.
Fatigue,
/// `1` — head under the surface: the **Breath** bar.
/// `1`: head under the surface.
Breath,
/// `2` — feigning death. The server starts this one from a duration that is zero unless a
/// script set it, so vanilla play essentially never shows it; it is a client timer type all
/// the same.
/// `2`: feigning death; its duration is zero unless a script sets it, so it rarely shows.
FeignDeath,
}
impl MirrorTimerKind {
/// Map the wire's `timerType` word, or `None` for a type this client has no bar for
/// (the server's own `NUM_CLIENT_TIMERS` gate means vanilla never sends one).
/// The server's `NUM_CLIENT_TIMERS` gate keeps its internal `ENVIRONMENTAL` (3) off the wire.
pub fn from_wire(value: u32) -> Option<Self> {
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<MirrorTimerStart> {
Ok(MirrorTimerStart {
kind: read_u32_le(r)?,
@@ -112,14 +69,12 @@ pub fn read_start_mirror_timer(r: &mut impl Read) -> io::Result<MirrorTimerStart
})
}
/// Read `SMSG_PAUSE_MIRROR_TIMER`: `u32 type, u8 paused` (vmangos `PauseMirrorTimer::AppendBodyTo`,
/// `Misc.cpp:487`). Returns the raw type and the flag.
/// `SMSG_PAUSE_MIRROR_TIMER` (vmangos `Misc.cpp:487`): the raw type and the paused flag.
pub fn read_pause_mirror_timer(r: &mut impl Read) -> 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<u32> {
read_u32_le(r)
}
@@ -128,8 +83,6 @@ pub fn read_stop_mirror_timer(r: &mut impl Read) -> io::Result<u32> {
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();
+23 -71
View File
@@ -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;
@@ -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<ServerPacket> {
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<Vec<Vector3d>> {
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<u8> {
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<u8>, 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
+57 -197
View File
@@ -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<MovementInfo> {
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<MovementInfo>
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<uint64>()`); the packed
// reader is a distinct operator this path never uses.
// A full u64, not packed (vmangos `ObjectGuid.cpp:180`, `buf.read<uint64>()`).
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<MovementInfo>
})
}
// --- 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<TransportPose>,
/// 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<JumpInfo>,
}
@@ -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();