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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user