comments: protocol, the world session, reader, wire helpers and the first writer sends

1,071 comment lines become 370 across 23 files; the code is unchanged.
The Warden refusal and the billing-time default now carry their reasons.
A gap note on cyclic splines described the app, not this crate, and is
gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-23 20:12:21 +07:00
co-authored by Claude Opus 5.5
parent 3512b69766
commit d9673d438d
23 changed files with 334 additions and 1033 deletions
@@ -36,127 +36,66 @@ impl ObjectType {
}
}
// MovementBlock update_flag bits (UpdateObject-only; the shared MOVEMENT_FLAG_* live in `super`).
const UPDATE_FLAG_TRANSPORT: u8 = 0x02;
const UPDATE_FLAG_MELEE_ATTACKING: u8 = 0x04;
const UPDATE_FLAG_HIGH_GUID: u8 = 0x08;
const UPDATE_FLAG_ALL: u8 = 0x10;
const UPDATE_FLAG_LIVING: u8 = 0x20;
const UPDATE_FLAG_HAS_POSITION: u8 = 0x40;
// SplineFlag final-destination bits (checked angle → target → point).
const SPLINE_FLAG_FINAL_POINT: u32 = 0x1_0000;
const SPLINE_FLAG_FINAL_TARGET: u32 = 0x2_0000;
const SPLINE_FLAG_FINAL_ANGLE: u32 = 0x4_0000;
/// `MoveSplineFlag::Flying` — and in vmangos 1.12 that bit alone *is* `Mask_CatmullRom`
/// (`MoveSplineFlag.h:48,77`), the same split [`crate::ServerPacket::MonsterMove`] reads: set ⇒ a 3-D
/// flight path that keeps its own Z, clear ⇒ a ground walk whose Z the client re-derives from terrain.
/// `MoveSplineFlag::Flying`; in vmangos it alone is `Mask_CatmullRom` (`MoveSplineFlag.h:48,77`).
const SPLINE_FLAG_FLYING: u32 = 0x200;
/// `SPLINEFLAG_RUNMODE` — see [`super::super::monster_move`]'s copy for why its *absence* is what
/// matters: a spline without it forces walk mode on for the unit it moves.
const SPLINE_FLAG_RUNMODE: u32 = 0x100;
/// `MoveSplineFlag::Cyclic` (`MoveSplineFlag.h:59`) — the path loops back on itself forever.
/// `MoveSplineFlag::Cyclic` (`MoveSplineFlag.h:59`).
const SPLINE_FLAG_CYCLIC: u32 = 0x10_0000;
/// The **live spline a unit is already riding** when it streams into view — the `LIVING` block's
/// `MOVEFLAG_SPLINE_ENABLED` tail (vmangos `Object.cpp:535` → `PacketBuilder::WriteCreate`,
/// `packet_builder.cpp:152`). Without it, a creature that was walking when we first saw it stands
/// frozen at its create pose until the server's *next* `SMSG_MONSTER_MOVE` — which then teleports it
/// to wherever it actually got to (decision 0708).
///
/// [`Self::path`] is the **travel-order polyline**, the same shape `MonsterMove` decodes to. The wire
/// carries the server's raw internal control array (`MoveSpline::getPath()` = `Spline::getPoints()`),
/// which is *not* that: vmangos builds every spline — linear ones included — through
/// `SplineBase::InitCatmullRom` ("we should use catmullrom initializer even for linear mode!",
/// `spline.cpp:52`), so the array is `[phantom, p₀, …, pₙ, tail]` whose walkable range is
/// `index_lo = 1 ..= index_hi = len − 2` (`spline.cpp:238-270`): the phantom head is `p₀` extrapolated
/// one segment *backwards*, and the tail duplicates the destination (or, cyclic, closes the loop).
/// We hand over exactly that range.
/// The spline a `LIVING` unit is already riding when it streams in (`packet_builder.cpp:152`).
/// vmangos builds every spline as Catmull-Rom (`spline.cpp:52`), so the wire array is
/// `[phantom, p₀, …, pₙ, tail]` and [`Self::path`] is its walkable range `1 ..= len − 2`.
#[derive(Debug, Clone)]
pub struct CreateSpline {
/// The full travel-order polyline in raw WoW coords: `[start, …waypoints…, endpoint]`.
/// The travel-order polyline in raw WoW coords, start to endpoint.
pub path: Vec<[f32; 3]>,
/// The server's spline id (`MoveSpline::GetId`), as on `MonsterMove`.
pub id: u32,
/// Milliseconds of this spline the server has **already ridden** (`MoveSpline::timePassed`): the
/// unit is `time_passed_ms / duration_ms` of the way along `path`, so a client that starts the
/// ride at `now − time_passed_ms` lands exactly where the server has it.
/// Ms of the path already ridden (`MoveSpline::timePassed`); start the ride at now minus this.
pub time_passed_ms: u32,
/// The whole path's duration in ms (`MoveSpline::Duration` = the spline's length, which vmangos
/// initialises in *time* units at one constant velocity — `CommonInitializer`,
/// `MoveSpline.cpp:125-135`). Time is therefore uniform in arc length along the whole polyline,
/// which is what makes the `time_passed` fraction above exact.
/// Duration in ms at one constant speed, time uniform in arc length (`MoveSpline.cpp:125-135`).
pub duration_ms: u32,
/// `MoveSplineFlag::Flying`: a 3-D flight path (keep the spline's Z); clear for a ground walk.
pub flying: bool,
/// `MoveSplineFlag::Cyclic` — the server loops this path forever. The app rides it once; a looping
/// follow is unbuilt (the same gap `MonsterMove`'s cyclic paths have).
/// `MoveSplineFlag::Cyclic`: the server loops this path forever.
pub cyclic: bool,
/// `MoveSplineFlag::Runmode` — and its absence forces walk mode on (decision 1758).
/// `MoveSplineFlag::Runmode`; its absence forces walk mode on.
pub run_mode: bool,
}
/// A `LIVING` block's live mover state: the `MOVEMENTFLAGS` word and the swim pitch that rides it.
/// See [`MovementBlock::mover`] for why both are surfaced rather than parsed and dropped.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MoverState {
/// The raw `MOVEMENTFLAGS` word (`CMovement+0x40`) — direction bits, swim, falling, the granted
/// modes.
/// The raw `MOVEMENTFLAGS` word (`CMovement+0x40`).
pub flags: u32,
/// The **swim pitch** (radians, +up), present on the wire iff `MOVEFLAG_SWIMMING`; `0.0` (level)
/// otherwise.
/// Swim pitch in radians, +up; on the wire only with `MOVEFLAG_SWIMMING`, else `0.0`.
pub pitch: f32,
}
/// The decoded fields a movement block carries: the pose (from its `LIVING`/`HAS_POSITION` flag),
/// for a `LIVING` block the unit's 6 movement speeds, its live [`CreateSpline`] and (if
/// `ON_TRANSPORT`) its rider pose, and (if `UPDATE_FLAG_TRANSPORT`) a transport GameObject's path
/// progress. The rest of the block is parsed and discarded to stay aligned.
/// The decoded fields of a movement block; the rest is read and discarded to stay aligned.
pub struct MovementBlock {
pub position: Option<(Vector3d, f32)>,
/// A `LIVING` block's **live `MOVEMENTFLAGS` word** and its **swim pitch** — the mover state this
/// unit is *already in* at the moment it streams into view. `None` for a `HAS_POSITION`-only
/// block (a GameObject has no mover).
///
/// **The reference applies both, byte-VERIFIED** (wow-5875-re §5,
/// `system/collision/scratch/create-block-swim-pitch.md`). The create-block apply `0x619320`
/// reaches `0x618c30`, which merges the block's flags into `CMovement+0x40` under the same
/// `0x75a07dff` mask a relayed `MSG_MOVE_*` uses (`0x618df3`; `SWIMMING`, `FORWARD` and
/// `BACKWARD` are all inside it) and then calls the pose commit `0x7c6420`, whose
/// `fld [edx+0x34]` / `fst [ecx+0x20]` copies **this pitch** into the mover pitch field
/// unconditionally, alongside the position and facing. So a unit that streams in mid-swim is
/// pitched on its **first drawn frame** — the apply runs before `0x613e10` has even built the
/// model or registered its per-frame render callback.
///
/// Dropping the pair is why a player who came into view mid-swim rendered *level* until their
/// next relay packet: the body-pitch render law reads exactly these two values and had neither.
///
/// The pitch is `0.0` (level) whenever `MOVEFLAG_SWIMMING` is clear, and that is the
/// reference's own value, not our convenience: the block field is explicitly zeroed at
/// `0x47ec4a` on the non-swimming leg, so the unconditional commit stores a real 0.0 rather
/// than stale memory.
/// A `LIVING` block's mover state. The reference merges these flags under the relay's
/// `0x75a07dff` mask (`0x618c30`) and commits the pitch at once (`0x7c6420`), so a unit
/// streaming in mid-swim is pitched on its first frame.
pub mover: Option<MoverState>,
/// A `LIVING` block's 6 movement speeds (yd/s) in wire order `[walk, run, run_back, swim, swim_back,
/// turn_rate]`; `None` for a `HAS_POSITION`-only block (GameObjects, which don't move under their
/// own power). The animation selector keys walk-vs-run on `walk` (boundary 2× it — RF-0057); the
/// net bridge extrapolates a remote mover between packets at `run`/`run_back`/`swim` + `turn_rate`.
/// A `LIVING` block's speeds in wire order `[walk, run, run_back, swim, swim_back, turn_rate]`.
pub speeds: Option<[f32; 6]>,
/// A transport GameObject's (`UPDATE_FLAG_TRANSPORT`, 0x02) path progress: the server's ms clock in
/// the path domain (vmangos `Object.cpp:590-605` — `GenericTransport::GetPathProgress()`, the same
/// `m_pathProgress` `ShipTransport::Update`/`CalculateSegmentPos` key off, `Transports/Transport.cpp:
/// 285-301`). `None` for every non-transport object: vmangos raises `UPDATEFLAG_TRANSPORT` only in
/// the transport GO paths — `ShipTransport`'s ctor (`Transport.cpp:39`) and the type-11 elevator
/// branch of `GameObject::Create` (`GameObject.cpp:246`); units/players never set it
/// (`Unit.cpp:88`). This is decision 0438's
/// **cycle anchor**: `anchor = this value`, `t₀ = Instant::now()` on create, `progress = anchor +
/// elapsed_ms` thereafter.
/// A ship's or elevator's path progress in ms at create, the anchor its cycle runs from
/// (`Object.cpp:590-605`); no other object sets `UPDATE_FLAG_TRANSPORT`.
pub transport_progress: Option<u32>,
/// A `LIVING` block's rider pose on a transport (`MOVEFLAG_ON_TRANSPORT`, 0x0200_0000) — `Some` when this
/// unit/player is standing on a boat/zeppelin/elevator, its position local to that transport's frame
/// (decision 0438 "Riding is the mover's platform frame" — an observed rider carried this way
/// re-anchors through the transport's live matrix each frame, never treating `pos` as world-space).
/// A `LIVING` block's rider pose (`MOVEFLAG_ON_TRANSPORT`), local to the transport's frame.
pub transport: Option<TransportPose>,
/// The path this unit is **already walking** at create time (`MOVEFLAG_SPLINE_ENABLED`) — see
/// [`CreateSpline`]. `None` for a unit standing still, and for every non-`LIVING` block.
/// The path this unit is already walking at create (`MOVEFLAG_SPLINE_ENABLED`).
pub spline: Option<CreateSpline>,
}
@@ -178,17 +117,13 @@ impl MovementBlock {
position = Some((living_position, living_orientation));
if flags & MOVEMENT_FLAG_ON_TRANSPORT != 0 {
// A FULL u64, not packed: the LIVING block serializes through the same vmangos
// `MovementInfo::Write` as the MSG_MOVE relays (`Object.cpp:524` `*data << m`),
// whose `data << t_guid` is the plain-u64 `ObjectGuid` operator.
// A full u64, not packed, as in `MovementInfo::Write` (`Object.cpp:524`).
transport = Some(TransportPose {
guid: read_u64_le(r)?,
pos: Vector3d::read(r)?,
orientation: read_f32_le(r)?,
});
}
// The swim-pitch tail — a lone `f32` present iff `MOVEFLAG_SWIMMING`, the same gate
// (and the same position in the layout) the `MSG_MOVE_*` body uses.
let pitch = if flags & MOVEMENT_FLAG_SWIMMING != 0 {
read_f32_le(r)?
} else {
@@ -204,9 +139,6 @@ impl MovementBlock {
if flags & MOVEMENT_FLAG_SPLINE_ELEVATION != 0 {
let _ = read_f32_le(r)?;
}
// The 6 movement speeds (RF-0058 order): [0]=walk, [1]=run, [2]=run-back, [3]=swim,
// [4]=swim-back, [5]=turn-rate. The animation selector keys walk-vs-run on the walk speed
// (boundary = 2× it — RF-0057); the net bridge extrapolates a remote mover with the rest.
let mut s = [0.0f32; 6];
for slot in &mut s {
*slot = read_f32_le(r)?;
@@ -214,10 +146,7 @@ impl MovementBlock {
speeds = Some(s);
if flags & MOVEMENT_FLAG_SPLINE_ENABLED != 0 {
let spline_flags = read_u32_le(r)?;
// The dictated final facing, in vmangos's own angle → target → point order
// (`packet_builder.cpp:162-167`). Parsed for alignment only: a unit still riding a
// path faces its travel tangent every frame, exactly as `MonsterMove`'s facing snap is
// overwritten while a path runs (`net::objects::monster_move`).
// The final facing (`packet_builder.cpp:162-167`); a unit on a path faces along it.
if spline_flags & SPLINE_FLAG_FINAL_ANGLE != 0 {
let _ = read_f32_le(r)?;
} else if spline_flags & SPLINE_FLAG_FINAL_TARGET != 0 {
@@ -234,11 +163,9 @@ impl MovementBlock {
let v = Vector3d::read(r)?;
nodes.push([v.x, v.y, v.z]);
}
// The final destination trails the array; it is `path`'s last real point again
// (`MoveSpline::FinalDestination` = `getPoint(last())`), or zero on a cyclic path.
// The final destination repeats the path's last point, or is zero on a cyclic path.
let _final_node = Vector3d::read(r)?;
// Drop the server's two virtual control points — see [`CreateSpline`]. Fewer than
// four nodes cannot hold a two-point path, so there is nothing to ride.
// Drop the two virtual control points; under four nodes there is no path to ride.
spline = (nodes.len() >= 4).then(|| CreateSpline {
path: nodes[1..nodes.len() - 1].to_vec(),
id,
+21 -53
View File
@@ -1,17 +1,11 @@
//! Vendor messages — `CMSG_LIST_INVENTORY`/buy/sell (opcodes 414-421, vmangos `Opcodes_1_12_1.h`,
//! VERIFIED). Bodies from vmangos `Item.{h,cpp}` + the hand-serialized `ItemHandler.cpp`. Lives
//! beside, not inside, [`super::items`] (the item-*template* query pair): vendor rows carry their
//! own wire shape and the buy/sell result vocabulary, unrelated to
//! `SMSG_ITEM_QUERY_SINGLE_RESPONSE`.
//! Vendor messages: the inventory list, buy and sell (opcodes 414-421), buyback and repair.
use std::io;
use crate::wire::{capacity_hint, read_u32_le, read_u64_le, read_u8};
/// One vendor row (`SMSG_LIST_INVENTORY`, vmangos `ItemHandler.cpp:741-810`). `slot` (the wire's
/// `muid`) is the row's 1-based position in the vendor's list — **not** what buying uses; buying
/// addresses the item by [`VendorItem::entry`] (see [`buy_item`]). `current_count` of
/// `0xFFFF_FFFF` means unlimited stock; `price` is already reputation-discounted server-side.
/// One vendor row (`ItemHandler.cpp:741-810`): `slot` is the 1-based list position, not what a buy
/// sends; `current_count` `0xFFFF_FFFF` is unlimited; `price` is already reputation-discounted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VendorItem {
pub slot: u32,
@@ -26,7 +20,7 @@ pub struct VendorItem {
pub buy_count: u32,
}
/// `BuyResult` (vmangos `ItemDefines.h:120-141`) — the `u8` reason on `SMSG_BUY_FAILED`.
/// `BuyResult` (vmangos `ItemDefines.h:120-141`): the `u8` reason on `SMSG_BUY_FAILED`.
pub mod buy_result {
pub const CANT_FIND_ITEM: u8 = 0;
pub const ITEM_ALREADY_SOLD: u8 = 1;
@@ -39,9 +33,7 @@ pub mod buy_result {
pub const REPUTATION_REQUIRE: u8 = 12;
}
/// `SellResult` (vmangos `ItemDefines.h:120-141`) — the `u8` reason on `SMSG_SELL_ITEM`. A
/// *successful* sell sends no packet at all (the item vanishes + coinage rises via
/// `UPDATE_OBJECT`); only the error path (`SendSellError`, `Player.cpp:11723`) uses this shape.
/// `SellResult` (vmangos `ItemDefines.h:120-141`): the `u8` reason on `SMSG_SELL_ITEM`.
pub mod sell_result {
pub const CANT_FIND_ITEM: u8 = 1;
pub const CANT_SELL_ITEM: u8 = 2;
@@ -51,16 +43,13 @@ pub mod sell_result {
pub const ONLY_EMPTY_BAG: u8 = 6;
}
/// Body of `CMSG_LIST_INVENTORY` (vmangos `Item.cpp:94`): one full 8-byte vendor guid. The server
/// gates on `UNIT_NPC_FLAG_VENDOR` + the player being alive (`ItemHandler.cpp:693`).
/// Body of `CMSG_LIST_INVENTORY`: the vendor guid; the dead get no list (`ItemHandler.cpp:693`).
pub fn list_inventory(vendor_guid: u64) -> Vec<u8> {
vendor_guid.to_le_bytes().to_vec()
}
/// Body of `CMSG_BUY_ITEM` (vmangos `Item.cpp:104-110`): `u64 vendorGuid, u32 item` (the template
/// **entry**, not the vendor row's `muid`), `u8 count` (stacks to buy), `u8 unk1` (always 0).
/// Auto-places into the first free bag slot — the specific-bag-slot variant is
/// [`buy_item_in_slot`]. Handler → `BuyItemFromVendor` (`ItemHandler.cpp:688`).
/// Body of `CMSG_BUY_ITEM` (vmangos `Item.cpp:104-110`): `u64 vendorGuid`, `u32` template entry
/// (not the row's `muid`), `u8 count` (stacks), `u8 unk1` (0); it fills the first free slot.
pub fn buy_item(vendor_guid: u64, entry: u32, count: u8) -> Vec<u8> {
let mut body = Vec::with_capacity(14);
body.extend_from_slice(&vendor_guid.to_le_bytes());
@@ -70,18 +59,9 @@ pub fn buy_item(vendor_guid: u64, entry: u32, count: u8) -> Vec<u8> {
body
}
/// Body of `CMSG_BUY_ITEM_IN_SLOT` (vmangos `Item.cpp:113-120`): `u64 vendorGuid, u32 item`
/// (the template **entry**, like [`buy_item`]), `u64 bagGuid`, `u8 bagSlot`, `u8 count`.
///
/// The buy-into-a-named-slot variant, and the packet the **merchant cursor** sends: the reference
/// puts a vendor row on the cursor as payload mode 5 (`PickupMerchantItem 0x4fb760`) and the
/// three container drop handlers each call the sender `0x5e1f30` with `count = 1`
/// (wow-re `system/ui/scratch/merchant-cursor-law.md` §5). Clicking a row is the auto-place
/// [`buy_item`]; dragging it into a slot is this.
///
/// `bag_guid` is the **container object's** guid, or the player's own for the backpack and the
/// equipment slots; `bag_slot` is the destination within it. Handler → `BuyItemFromVendor`, the
/// same one `CMSG_BUY_ITEM` reaches, with the slot pair filled instead of `NULL_BAG`/`NULL_SLOT`.
/// Body of `CMSG_BUY_ITEM_IN_SLOT` (vmangos `Item.cpp:113-120`): `u64 vendorGuid`, `u32` template
/// entry, `u64 bagGuid` (ours for the backpack), `u8 bagSlot`, `u8 count`; the reference sends it
/// with `count = 1` for a vendor row dropped into a slot (`0x5e1f30`).
pub fn buy_item_in_slot(
vendor_guid: u64,
entry: u32,
@@ -98,8 +78,8 @@ pub fn buy_item_in_slot(
body
}
/// Body of `CMSG_SELL_ITEM` (vmangos `Item.cpp:87-92`): `u64 vendorGuid, u64 itemGuid, u8 count`
/// (0 = sell the whole stack). Handler `ItemHandler.cpp:442`.
/// Body of `CMSG_SELL_ITEM` (vmangos `Item.cpp:87-92`): `u64 vendorGuid, u64 itemGuid, u8 count`,
/// where 0 sells the whole stack.
pub fn sell_item(vendor_guid: u64, item_guid: u64, count: u8) -> Vec<u8> {
let mut body = Vec::with_capacity(17);
body.extend_from_slice(&vendor_guid.to_le_bytes());
@@ -108,10 +88,8 @@ pub fn sell_item(vendor_guid: u64, item_guid: u64, count: u8) -> Vec<u8> {
body
}
/// Body of `CMSG_BUYBACK_ITEM` (VERIFIED wow-re ui/scratch/buyback-data-path.md, `0x4fb950`:
/// `u64 vendorGuid, u32 slot` — the slot is the **absolute** player-array buyback slot 69–80
/// (`BUYBACK_SLOT_START` + index), sent raw with no re-base; vmangos `HandleBuybackItem` reads it
/// the same way).
/// Body of `CMSG_BUYBACK_ITEM` (`0x4fb950`): `u64 vendorGuid, u32 slot`, the absolute buyback
/// slot 69-80 (`BUYBACK_SLOT_START` + index), not re-based.
pub fn buyback_item(vendor_guid: u64, slot: u32) -> Vec<u8> {
let mut body = Vec::with_capacity(12);
body.extend_from_slice(&vendor_guid.to_le_bytes());
@@ -119,9 +97,7 @@ pub fn buyback_item(vendor_guid: u64, slot: u32) -> Vec<u8> {
body
}
/// Body of `CMSG_REPAIR_ITEM` (VERIFIED wow-re ui/scratch/repair-machinery.md, all 4 client send
/// sites: `u64 vendorGuid, u64 itemGuid`; `itemGuid == 0` = repair everything. vmangos
/// `HandleRepairItemOpcode` corroborates).
/// Body of `CMSG_REPAIR_ITEM`: `u64 vendorGuid, u64 itemGuid`; item guid 0 repairs everything.
pub fn repair_item(vendor_guid: u64, item_guid: u64) -> Vec<u8> {
let mut body = Vec::with_capacity(16);
body.extend_from_slice(&vendor_guid.to_le_bytes());
@@ -130,12 +106,8 @@ pub fn repair_item(vendor_guid: u64, item_guid: u64) -> Vec<u8> {
}
/// Read `SMSG_LIST_INVENTORY` (vmangos `ItemHandler.cpp:741-810`): `u64 vendorGuid, u8 count`
/// (≤ `MAX_VENDOR_ITEMS` 128) then `count` rows of [`VendorItem`]. The empty-vendor case is
/// `count = 0` followed by one trailing `u8` — both producers write a literal `uint8(0)`
/// ("Vendor has no inventory": `ItemHandler.cpp:728-733` for a creature with no item lists,
/// `:806-809` for one whose every item is hidden from this player). It carries no other value
/// on this wire, so it is consumed and dropped — consumed *explicitly*, so an empty list leaves
/// no tail for the decode-length check to report (decision 2265 §B1).
/// (at most 128), then `count` rows. An empty list is followed by one `u8` 0
/// (`ItemHandler.cpp:728-733`, `:806-809`), read here so no tail is left over.
pub(super) fn read_list_inventory(r: &mut &[u8]) -> io::Result<(u64, Vec<VendorItem>)> {
let vendor_guid = read_u64_le(r)?;
let count = read_u8(r)?;
@@ -159,11 +131,8 @@ pub(super) fn read_list_inventory(r: &mut &[u8]) -> io::Result<(u64, Vec<VendorI
Ok((vendor_guid, items))
}
/// Read `SMSG_BUY_ITEM` (vmangos `Item.cpp:190-196`, sent from `Player.cpp:18637`): `u64
/// vendorGuid, u32 vendorSlot` (1-based), `u32 newCount` (new stock; `0xFFFF_FFFF` unlimited),
/// `u32 purchaseCount`. Only updates the vendor stock display — the purchased item itself arrives
/// through the normal `SMSG_ITEM_PUSH_RESULT` + `UPDATE_OBJECT` path (`SendNewItem`,
/// `Player.cpp:18644`), which benilla's inventory already handles.
/// Read `SMSG_BUY_ITEM` (vmangos `Item.cpp:190-196`): `u64 vendorGuid, u32 vendorSlot` (1-based),
/// `u32 newCount` (`0xFFFF_FFFF` unlimited), `u32 purchaseCount`; the item arrives separately.
pub(super) fn read_buy_item(r: &mut &[u8]) -> io::Result<(u64, u32, u32, u32)> {
Ok((
read_u64_le(r)?,
@@ -174,8 +143,7 @@ pub(super) fn read_buy_item(r: &mut &[u8]) -> io::Result<(u64, u32, u32, u32)> {
}
/// Read `SMSG_SELL_ITEM` (vmangos `Item.cpp:183-188`): `u64 vendorGuid, u64 itemGuid, u8 reason`
/// (a [`sell_result`] code). Only the error path sends this packet at all — a successful sell is
/// silent, visible only as the item's removal + coinage rising via `UPDATE_OBJECT`.
/// (a [`sell_result`] code). Only a failed sell sends it; a sale shows only in `UPDATE_OBJECT`.
pub(super) fn read_sell_item(r: &mut &[u8]) -> io::Result<(u64, u64, u8)> {
Ok((read_u64_le(r)?, read_u64_le(r)?, read_u8(r)?))
}
@@ -1,56 +1,28 @@
//! World states — the server's key/value scoreboard table (`SMSG_INIT_WORLD_STATES` `0x2C2`,
//! `SMSG_UPDATE_WORLD_STATE` `0x2C3`). It is what the NPC-text `$<n>w`/`$<n>e` tokens read, and it
//! is what a battleground scoreboard is built from; benilla uses the first today.
//! World states, the server's key/value table (`SMSG_INIT_WORLD_STATES` `0x2C2`,
//! `SMSG_UPDATE_WORLD_STATE` `0x2C3`) that NPC-text `$<n>w`/`$<n>e` tokens and scoreboards read.
//!
//! Both wires are settled from two independent sources that agree:
//!
//! - **The reference binary** (wow-re `system/ui/scratch/questtext-macro-expander.md` §3): the one
//! handler `0x48f690` holds the only two callers of the table setter `0x4c5870`, registered
//! against exactly these two opcodes at `0x48f515-0x48f52f`. `0x2C2` is *two leading dwords*
//! (handed to `0x4c5650`), a `u16` count, then `count × (id:u32, value:u32)`; `0x2C3` is a
//! single such pair. **`0x4c5650` is carved** (2026-08-25, wow-re
//! `system/ui/scratch/worldstate-ui-law.md`): it CLEARS the table, stores the two dwords as the
//! world-state UI's display filter, and rebuilds that list — all before the pair loop runs. See
//! [`InitWorldStates::zone`].
//! - **vmangos's writers** (`Player::SendInitWorldStates`, `Player.cpp:8219-8293`, and
//! `WorldPackets::Misc::UpdateWorldState::AppendBodyTo`, `Misc.cpp:685-694`): `u32 mapId`,
//! `u32 zoneId` (the zone dword is `#if SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_11_2`, so 1.12.1
//! gets it), the `u16` count back-patched at the end, then the pairs; and `u32 field, u32 value`
//! respectively (also build-gated — a pre-1.8.4 client would read `u16`s).
//!
//! **The count includes a `(0, 0)` terminator.** vmangos starts `count = 1` and appends
//! `uint32(0) << uint32(0)` last, *"to prevent repeating audio bug"* — so the pair run is read
//! verbatim, terminator and all, exactly as the reference's count loop does. There is no
//! terminator special case on either side and we add none: id 0 simply lands in the table as 0.
//!
//! Ids and values are **raw dwords** on the wire and stay raw here. That matters: the reference's
//! `$<n>e` token reads the table at the *negated* key, so a text asking for `$2077e` looks up
//! `0xFFFFF7E3` — a key only reachable if nothing has re-interpreted the field on the way in.
//! On `0x2C2` the reference clears the table and takes map and zone as its UI filter (`0x4c5650`)
//! before the pairs run. vmangos counts a trailing `(0, 0)` pair (`Player.cpp:8219-8293`), which
//! is read as data. Ids and values stay raw dwords: `$<n>e` reads the table at the negated key, so
//! `$2077e` looks up `0xFFFFF7E3`.
use std::io;
use crate::wire::{read_u16_le, read_u32_le};
/// `SMSG_INIT_WORLD_STATES` — the whole table for a zone, sent on login and on every zone change
/// (vmangos `Player::UpdateZone` → `SendInitWorldStates`).
/// `SMSG_INIT_WORLD_STATES`: the whole table for a zone, sent on login and every zone change.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct InitWorldStates {
/// The map the states are scoped to — the client's `[0xb71e84]`, and the first half of the
/// filter its world-state UI list is built against (see the module doc).
/// The map the states are scoped to; the reference keeps it at `[0xb71e84]`.
pub map: u32,
/// The second leading dword. Named `zone` for the field vmangos fills it from; the client
/// stores it as `[0xb71ea8]` and matches it against **`WorldStateUI.dbc`'s `AreaID`** when it
/// decides which always-up rows a scope admits, so "whatever the server put here" is exactly
/// the comparand either way.
/// The zone vmangos fills in; the reference keeps it at `[0xb71ea8]` and matches it against
/// `WorldStateUI.dbc`'s `AreaID`.
pub zone: u32,
/// `(id, value)` verbatim, including the trailing `(0, 0)` terminator.
pub states: Vec<(u32, u32)>,
}
/// Read `SMSG_INIT_WORLD_STATES`: `u32 map, u32 zone, u16 count`, then `count` `(id, value)` pairs.
///
/// The count is trusted only as far as the body allows — a truncated run stops at the short read
/// like every other reader here, rather than pre-allocating on a hostile length.
pub(in crate::messages) fn read_init_world_states(r: &mut &[u8]) -> io::Result<InitWorldStates> {
let map = read_u32_le(r)?;
let zone = read_u32_le(r)?;
@@ -71,8 +43,6 @@ pub(in crate::messages) fn read_update_world_state(r: &mut &[u8]) -> io::Result<
mod tests {
use super::*;
/// A vmangos-shaped `SMSG_INIT_WORLD_STATES`: map, zone, a count that *includes* the trailing
/// `(0, 0)` terminator, then the pairs. The terminator is data, not a sentinel we strip.
#[test]
fn init_reads_every_pair_including_the_terminator() {
let mut body = Vec::new();
@@ -89,7 +59,6 @@ mod tests {
assert_eq!(got.states, vec![(2264, 7), (2263, 0), (0, 0)]);
}
/// A count longer than the body is a short read, not a panic or a giant allocation.
#[test]
fn init_truncated_run_errors() {
let mut body = Vec::new();
@@ -100,8 +69,7 @@ mod tests {
assert!(read_init_world_states(&mut body.as_slice()).is_err());
}
/// The update wire is the bare pair, and both halves stay raw dwords — a value with the top bit
/// set survives as-is, so the `%d` render downstream is the only place a sign appears.
/// A top-bit value stays unsigned; only the `%d` render downstream gives it a sign.
#[test]
fn update_reads_a_raw_dword_pair() {
let mut body = Vec::new();
+12 -47
View File
@@ -1,11 +1,8 @@
//! Low-level wire read/write helpers shared by the auth ([`crate::auth`]) and world
//! ([`crate::world`]) protocols. Everything is little endian unless noted; these mirror the exact
//! field encodings the 1.12 client uses.
//! Wire read/write helpers shared by the auth and world protocols; little endian unless noted.
use std::io::{self, Read, Write};
/// WoW packs an 8-byte GUID as a one-byte mask (which of the 8 bytes are non-zero) followed by only
/// those non-zero bytes, low to high — used throughout the world protocol for object references.
/// Read a packed GUID: a mask byte naming the non-zero bytes, then only those bytes, low to high.
pub fn read_packed_guid(r: &mut impl Read) -> io::Result<u64> {
let mask = read_u8(r)?;
let mut guid = 0u64;
@@ -92,19 +89,8 @@ pub fn read_cstring(r: &mut impl Read) -> io::Result<String> {
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
/// Bound a wire-derived element count before it becomes a `Vec::with_capacity` hint.
///
/// A count read off the wire is server- (and so bug- and attacker-) controlled, and
/// `Vec::with_capacity(count as usize)` on a raw `u32` is the one failure the decoders' `io::Result`
/// totality does not cover: an allocation that cannot be satisfied is `handle_alloc_error` →
/// `abort()` — no unwind, no [`crate::Poll::Skipped`], no tally (decision 2265 §B1). The returned
/// hint bounds only the **up-front** allocation: the `Vec` still grows past it when the body really
/// carries more rows, and a lying count still fails on the short read of the body, which is
/// `u16`-bounded and read whole before parse. `cap` is the protocol's own bound where one exists
/// (cited at the call site), or a generous sane one where none does.
///
/// Every `with_capacity` under `messages/` whose argument is a wire count goes through this; the
/// source-scan test beside it (`wire_count_capacity_hints_are_capped`) keeps that true.
/// Bound a wire-derived count before it becomes a `Vec::with_capacity` hint: a failed allocation
/// aborts rather than erroring. `cap` is the protocol's own bound where one exists.
pub fn capacity_hint(count: impl TryInto<u64>, cap: usize) -> usize {
count
.try_into()
@@ -137,11 +123,8 @@ impl Vector3d {
}
}
/// Decode a movement-spline packed point (`SMSG_MONSTER_MOVE` packs every point after the first this
/// way): **signed** two's-complement fields — 11 bits x, 11 bits y, 10 bits z — in quarter-yard
/// units (the server packs `round(v * 4)` per axis; vmangos `ByteBuffer::appendPackXYZ`). The
/// decoded vector is the offset from the spline's **destination** back to this waypoint
/// (`destination - waypoint`; vmangos `PacketBuilder::WriteLinearPath`), not an absolute position.
/// Decode a `SMSG_MONSTER_MOVE` packed point: signed 11-bit x, 11-bit y, 10-bit z in quarter yards
/// (`ByteBuffer::appendPackXYZ`), the offset `destination - waypoint`, not a position.
pub fn packed_to_vector3d(p: i32) -> Vector3d {
// Sign-extend each field: shift it to the top of the i32, then arithmetic-shift back down.
Vector3d {
@@ -155,8 +138,7 @@ pub fn packed_to_vector3d(p: i32) -> Vector3d {
mod tests {
use super::*;
/// The producer's encoder, transcribed from vmangos `ByteBuffer::appendPackXYZ`:
/// `packed |= ((int)lroundf(v * 4.0f) & mask) << shift` with masks 0x7FF/0x7FF/0x3FF.
/// vmangos's encoder, `ByteBuffer::appendPackXYZ`.
fn pack_xyz(x: f32, y: f32, z: f32) -> i32 {
let mut packed = 0u32;
packed |= ((x * 4.0).round() as i32 & 0x7FF) as u32;
@@ -167,9 +149,7 @@ mod tests {
#[test]
fn packed_point_roundtrips_signed_quarter_yards() {
// Field ranges: x/y are 11-bit signed (−256 .. 255.75 yd), z 10-bit signed (−128 .. 127.75 yd).
// Quarter-yard multiples are exact in f32, so the round-trip must be exact — including the
// negative offsets the old unsigned decode destroyed.
// x and y span −256..255.75 yd, z −128..127.75 yd; quarter yards are exact in f32.
for &(x, y, z) in &[
(0.0f32, 0.0f32, 0.0f32),
(1.25, -2.5, 3.75),
@@ -190,14 +170,8 @@ mod tests {
assert_eq!(capacity_hint(u64::MAX, 1024), 1024);
}
/// **The rule, enforced instead of remembered** (decision 2265 §B1).
///
/// A `Vec::with_capacity` whose argument is a raw wire count aborts the process — not the
/// packet — the day a server (or a decoder bug) puts a huge number there: an allocation
/// failure never unwinds, so it never becomes `Poll::Skipped`. There is no runtime signal to
/// test for, which is exactly why the check is structural: every `with_capacity(` under
/// `messages/` whose argument is a bare variable (cast or not) must go through
/// [`capacity_hint`] or carry a `.min(`, or be named in [`EXEMPT`] with the reason.
/// Every `with_capacity` under `messages/` whose argument is a bare variable goes through
/// [`capacity_hint`], carries a `.min(`, or is listed in [`EXEMPT`].
#[test]
fn wire_count_capacity_hints_are_capped() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/messages");
@@ -226,15 +200,11 @@ mod tests {
);
}
/// `with_capacity` arguments under `messages/` that look like bare variables but are **not**
/// wire counts, each with the reason. Keyed `(path under src/messages/, argument text)`.
/// Bare-variable arguments that are not wire counts, as `(path under src/messages/, argument)`.
const EXEMPT: &[(&str, &str)] = &[];
/// The classifier, pinned on synthetic arguments so a regression in it cannot pass silently
/// as "nothing to flag".
#[test]
fn the_scan_flags_a_bare_wire_count_and_passes_a_bounded_one() {
// Flagged: a bare variable, with or without the cast, however it is wrapped.
for arg in [
"count as usize",
"count",
@@ -245,7 +215,6 @@ mod tests {
] {
assert!(uncapped_wire_count(arg), "should flag `{arg}`");
}
// Passes: literals, length arithmetic, a constant, and the two bounded forms.
for arg in [
"12",
"name.len() + 1",
@@ -259,7 +228,6 @@ mod tests {
] {
assert!(!uncapped_wire_count(arg), "should pass `{arg}`");
}
// The extractor: the balanced argument of every call on the line, with its line number.
let src = "let a = Vec::with_capacity(count as usize);\nlet b = Vec::with_capacity((n as usize).min(4));\n";
assert_eq!(
capacity_arguments(src),
@@ -270,9 +238,7 @@ mod tests {
);
}
/// Is this `with_capacity` argument a raw wire count? A bare identifier (optionally cast
/// `as usize`, optionally parenthesised, optionally `usize::from(...)`-wrapped) that is not a
/// `const` (all caps) and carries neither `.min(` nor `capacity_hint(`.
/// A lowercase identifier, however cast or wrapped, with no `.min(` or `capacity_hint(`.
fn uncapped_wire_count(arg: &str) -> bool {
if arg.contains(".min(") || arg.contains("capacity_hint(") {
return false;
@@ -330,7 +296,6 @@ mod tests {
out
}
/// Every `.rs` file under `root`, recursively.
fn rust_files(root: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
+8 -28
View File
@@ -1,18 +1,8 @@
//! Phase 4: world server (`mangosd`) connection.
//! The world server (`mangosd`) connection, opened with the realm logon's SRP6 session key.
//!
//! After the realmd logon ([`crate::logon`]) hands us the SRP6 session key, we connect to the world
//! server, prove we know that key, then stream object updates. The handshake:
//!
//! 1. Read `SMSG_AUTH_CHALLENGE` (unencrypted) → server seed.
//! 2. Send `CMSG_AUTH_SESSION` (unencrypted): build, uppercased account name, a client seed, and a
//! SHA1 digest binding the session key to both seeds (computed by `benilla-srp`).
//! 3. **1.12 header obfuscation turns on right after** `CMSG_AUTH_SESSION` — every subsequent packet
//! header (4-byte server / 6-byte client) is encrypted; bodies stay plaintext. The first encrypted
//! packet we read is `SMSG_AUTH_RESPONSE`.
//! 4. `CMSG_CHAR_ENUM` → `SMSG_CHAR_ENUM`, then `CMSG_PLAYER_LOGIN(guid)`, then the world streams
//! `SMSG_UPDATE_OBJECT`.
//!
//! Headers are (de)crypted by [`benilla_srp::vanilla_header`]; bodies by [`crate::messages`].
//! `SMSG_AUTH_CHALLENGE` (server seed) and `CMSG_AUTH_SESSION` (build, uppercased account, client
//! seed, SHA1 over key and seeds) travel plain; every header after them is encrypted (4-byte
//! server, 6-byte client), bodies never, starting with `SMSG_AUTH_RESPONSE`.
use std::io::{Read, Write};
use std::net::TcpStream;
@@ -31,14 +21,10 @@ pub use reader::WorldReader;
pub use session::{WardenRequired, WorldAuthReject, WorldSession};
pub use writer::WorldWriter;
/// Default `mangosd` world-server port — the stock one, which our vmangos deploy maps straight
/// through. Normally the realm list reply carries the port (the deploy's
/// `VMANGOS_REALMLIST_PORT=8085` matches); this constant is the fallback for probes/examples that
/// dial the world server directly.
/// The stock `mangosd` port, for probes that dial the world server without a realm list.
pub const WORLD_PORT: u16 = 8085;
/// Read one server packet from `stream`: decrypt the 4-byte header, read the body, parse by opcode.
/// `decrypter` is `None` for the (single) unencrypted `SMSG_AUTH_CHALLENGE`.
/// Read, decrypt and parse one packet; `decrypter` is `None` only for `SMSG_AUTH_CHALLENGE`.
pub(super) fn recv_packet(
stream: &mut TcpStream,
decrypter: Option<&mut DecrypterHalf>,
@@ -61,8 +47,7 @@ pub(super) fn recv_packet(
messages::parse_server(opcode, &body).map_err(|e| anyhow!("parsing opcode {opcode:#x}: {e}"))
}
/// Write one client packet: an (optionally encrypted) 6-byte header + plaintext body. The header size
/// field counts the 4-byte opcode plus the body, but not the size field itself.
/// Write one client packet: a 6-byte header, its size counting opcode and body, then the body.
pub(super) fn send_packet(
stream: &mut TcpStream,
encrypter: Option<&mut EncrypterHalf>,
@@ -79,12 +64,7 @@ pub(super) fn send_packet(
if let Some(e) = encrypter {
e.encrypt(&mut header);
}
// **One write, not two** (decision 0617). Header-then-body was a self-inflicted Nagle stall: the
// 6-byte header goes out as its own segment, and the body — small, and now behind unacknowledged
// data — is held by the kernel until the server ACKs it. With Nagle now off (see
// [`WorldSession::connect`]) that stall is gone either way, but two segments per packet is still
// two syscalls and two headers for a ≤40-byte message, and the server cannot parse the packet
// until both land. Joined here so one movement report is one segment.
// One write, not two: header and body leave as one segment.
let mut packet = Vec::with_capacity(header.len() + body.len());
packet.extend_from_slice(&header);
packet.extend_from_slice(body);
+5 -18
View File
@@ -1,18 +1,10 @@
use crate::messages::MovementInfo;
use crate::wire::Vector3d;
/// `MovementFlags::FORWARD` — the only movement flag benilla sets.
pub(super) const MOVEMENT_FLAG_FORWARD: u32 = 0x1;
/// A monotonic **client-uptime** millisecond stamp for the `MovementInfo` time field — the kind the real
/// 1.12 client sends (`GetTickCount()`, ms since the client started), **not** wall-clock. vmangos reads it
/// into `MovementInfo::ctime` and uses it only two ways (VERIFIED, decision 0057): a non-zero guard
/// (`ctime == 0` reads as "not a client packet" and *pauses* its movement extrapolation) and *deltas*
/// (`getMSTimeDiff`, so the epoch cancels). It never compares the value to its own clock, and it
/// overwrites the field with its server clock before relaying to nearby players — so this value never
/// reaches observers. Hence: match the real client's clock kind, wrap cleanly as a full `u32` (~49.7 days,
/// which `getMSTimeDiff` handles), and keep it non-zero. (The old value was wall-clock masked to 31 bits,
/// which folded backwards every ~24.85 days — a delta glitch the clean `u32` wrap avoids.)
/// Ms since start for the `MovementInfo` time, as the 1.12 client's `GetTickCount()`, never 0:
/// vmangos pauses extrapolation on 0 and otherwise uses only deltas, so a full `u32` wrap is safe.
pub(super) fn client_uptime_ms() -> u32 {
use std::sync::OnceLock;
use std::time::Instant;
@@ -20,7 +12,7 @@ pub(super) fn client_uptime_ms() -> u32 {
(START.get_or_init(Instant::now).elapsed().as_millis() as u32).max(1)
}
/// Build a `MovementInfo` stamped with the client-uptime time ([`client_uptime_ms`]).
/// Build a `MovementInfo` stamped with [`client_uptime_ms`].
pub(super) fn movement_info(pos: [f32; 3], orientation: f32, flags: u32) -> MovementInfo {
MovementInfo {
flags,
@@ -31,11 +23,9 @@ pub(super) fn movement_info(pos: [f32; 3], orientation: f32, flags: u32) -> Move
z: pos[2],
},
orientation,
// benilla never sets ON_TRANSPORT outbound yet (boarding/riding is decision 0438 phase 2) — the
// writer only emits the transport tail while the flag is set, so this is always dropped.
// Written only under ON_TRANSPORT, which benilla does not send.
transport: None,
// Level swim (0) by default; the writer only emits the pitch tail while SWIMMING is set, and the
// controller supplies the live swim pitch through `send_movement` when it does.
// Written only under SWIMMING; `send_movement` supplies the live pitch then.
pitch: 0.0,
fall_time: 0,
jump: None,
@@ -48,9 +38,6 @@ mod tests {
#[test]
fn client_uptime_ms_is_nonzero_and_monotonic() {
// The load-bearing property: vmangos treats `ctime == 0` as "not a client packet" and pauses its
// extrapolation, so our stamp must never be zero (the bug the old wall-clock value worked around).
// It must also be non-decreasing so `getMSTimeDiff` deltas stay sane.
let a = client_uptime_ms();
let b = client_uptime_ms();
assert!(a >= 1, "stamp is non-zero: {a}");
+4 -14
View File
@@ -8,8 +8,7 @@ use crate::messages::{self, ServerPacket};
use super::recv_packet;
/// Read half of a split [`WorldSession`](super::WorldSession) — owns a cloned socket + the decrypter. Lives on the network
/// thread, streaming decoded [`crate::SessionEvent`]s (via [`Self::poll`]).
/// The read half of a split [`WorldSession`](super::WorldSession): cloned socket and decrypter.
pub struct WorldReader {
pub(super) stream: TcpStream,
pub(super) decrypter: DecrypterHalf,
@@ -21,13 +20,8 @@ impl WorldReader {
recv_packet(&mut self.stream, Some(&mut self.decrypter))
}
/// Read one packet and decode it into a [`crate::Poll`]: the typed [`crate::SessionEvent`]s it
/// produced, or [`crate::Poll::Skipped`] for an unparseable one. Errors only on a real socket
/// failure (disconnect). The running world model lives in the ECS — the reader is stateless,
/// turning bytes into events and nothing more.
///
/// `recv_packet` reads the whole body into a buffer before parsing, so a parse error leaves the
/// stream aligned — we skip that packet rather than tear down the session.
/// Read one packet and decode it into a [`crate::Poll`]; errors only when the socket fails. The
/// whole body is read before parsing, so an unparseable packet is skipped, the stream aligned.
pub fn poll(&mut self) -> Result<crate::Poll> {
let mut header = [0u8; 4];
if let Err(e) = self.stream.read_exact(&mut header) {
@@ -47,9 +41,6 @@ impl WorldReader {
events: crate::decode(packet),
tail,
}),
// Include the raw body (capped) so an unparseable packet can be decoded by hand — a parse
// bug is otherwise invisible past "failed to fill whole buffer". The opcode rides
// separately so the net thread can feed the app's dropped-packet tally.
Err(e) => Ok(crate::Poll::Skipped {
opcode,
reason: format!(
@@ -62,8 +53,7 @@ impl WorldReader {
}
}
/// Space-separated hex of the first `max` bytes of `body` (with a `…` when truncated) — the diagnostic
/// tail on a [`crate::Poll::Skipped`] reason so an unparseable packet's layout can be decoded by hand.
/// Hex of the first `max` bytes of `body`, `…` when truncated, for decoding a packet by hand.
fn hex_preview(body: &[u8], max: usize) -> String {
use std::fmt::Write;
let mut s = String::new();
+103 -300
View File
@@ -11,45 +11,21 @@ use super::reader::WorldReader;
use super::writer::WorldWriter;
use super::{recv_packet, send_packet};
/// Socket read timeout for the handshake phase only (connect → `player_login`), where each step
/// waits for one specific server reply and silence means the session is dead (decision 0065).
/// Generous — a local vmangos answers in milliseconds; this only bounds the pathological stall.
/// Read timeout through `player_login`, where each step awaits one reply.
const HANDSHAKE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// The read bound while **queued** — a different question from the handshake's.
///
/// [`HANDSHAKE_READ_TIMEOUT`] is 10 s because every handshake step should answer promptly. Sitting
/// in a login queue is the one step that should not: the server speaks when our place moves, which
/// on a full realm can be minutes apart. Keeping the handshake bound would abort every queue that
/// mattered. The real client bounds this not at all (it is event-driven and simply waits), but an
/// unbounded blocking read here would make a server that dies mid-queue indistinguishable from a
/// long wait, so the wait is generous rather than infinite.
/// Read timeout in the login queue, where updates can be minutes apart. Deviation: the reference
/// waits forever; a bound tells a server that died mid-queue from a long wait.
const QUEUE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
/// The server requires Warden, which benilla does not implement — raised by
/// [`WorldSession::connect`] and recoverable through the `anyhow` chain with
/// `err.downcast_ref::<WardenRequired>()`, so the login screen can say so plainly.
///
/// VERIFIED (vmangos `src/game/Anticheat/WardenAnticheat/Warden.cpp`): every Warden request arms a
/// response clock (`BeginTimeoutClock`, `Warden.ClientResponseDelay` — default 30 s in
/// `World.cpp`), and `Warden::Update` kicks unconditionally when it expires — not subject to the
/// penalty config, and regardless of module/maiev mode. So a client that cannot answer cannot stay
/// in the world: refusing at the handshake is the honest outcome, not a policy choice.
/// The server requires Warden. Deviation: benilla does not implement it; vmangos ships with it
/// off (`Warden.WinEnabled`, `Warden.OSXEnabled`). vmangos kicks a client that leaves a Warden
/// request unanswered for 30 s (`Warden.cpp`), so the connect refuses.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WardenRequired;
/// The **world server** refused the session (`SMSG_AUTH_RESPONSE` with anything but
/// [`messages::AUTH_OK`]).
///
/// Typed, and carrying its raw code, for the same reason [`crate::AuthReject`] is: the screen owes
/// the player the client's own authored words for what happened, and it cannot look them up from a
/// formatted string. Before this the code was interpolated into a `bail!` and thrown away, so every
/// world-side refusal — expired session, server shutting down, already logging in — arrived at the
/// login screen as the same generic "Unable to connect".
///
/// **Its codes are `messages`' `AUTH_*` block, NOT [`crate::AuthReject`]'s.** See that block for
/// why the distinction is load-bearing: the two enums overlap numerically and mean unrelated
/// things.
/// The world server refused the session. Its code is from `messages`' `AUTH_*` block, not
/// [`crate::AuthReject`]'s, whose numbers overlap with unrelated meanings.
#[derive(Debug, Clone, Copy)]
pub struct WorldAuthReject {
pub code: u8,
@@ -82,31 +58,23 @@ impl std::error::Error for WardenRequired {}
pub struct WorldSession {
stream: TcpStream,
crypto: HeaderCrypto,
/// The roster's guid → race, remembered from [`Self::char_enum`] so [`Self::player_login`]
/// can derive the character's faction tongue without the caller's help.
/// Roster guid to race, so [`Self::player_login`] can pick the chat language.
roster_races: std::collections::HashMap<u64, u8>,
/// The language chat sends speak — the logged-in character's faction tongue
/// ([`messages::faction_language`]), set automatically by [`Self::player_login`]. Load-bearing:
/// vmangos drops any chat whose language the character doesn't know (dot-commands included) —
/// hardcoded Common silently ate every Horde character's chat and commands once.
/// The language chat sends speak, the character's faction tongue: vmangos drops chat, even
/// dot-commands, in a language the character does not know.
chat_language: u32,
/// The rested billing minutes the admitting `SMSG_AUTH_RESPONSE` carried, or `0` when its body
/// was too short for the billing group. Read by [`Self::billing_time_rested`]; see decision
/// 1820 for why `0` rather than the client's own uninitialised-global behaviour.
/// The rested billing minutes the admitting `SMSG_AUTH_RESPONSE` carried. Deviation: `0` when
/// the body is too short, where the reference leaves its global as it was; this field lives
/// per connection, with nothing earlier to keep.
billing_time_rested: u32,
/// `SMSG_TUTORIAL_FLAGS` if it landed during the login handshake rather than in the world
/// stream (decision 1976) — handed to the world entry the way the billing minutes are.
/// `SMSG_TUTORIAL_FLAGS` when it lands during the handshake rather than in the world stream.
tutorial_flags: Option<Vec<u8>>,
/// `SMSG_ADDON_INFO`'s per-record `status` bytes, in arrival order — `None` until the server
/// answers our addon block, which is the state that keeps the Lua index space empty
/// (decision 2175). Read by [`Self::take_addon_info`].
/// `SMSG_ADDON_INFO`'s per-record status bytes in order; `None` until the server answers.
addon_info: Option<Vec<u8>>,
}
impl WorldSession {
/// Connect to the world server and complete the auth handshake, leaving header obfuscation
/// enabled. `username` must be the same account used at logon; `session_key` is what
/// [`crate::logon`] returned.
/// Connect and complete the auth handshake with the logon's account and session key.
pub fn connect(
addr: impl ToSocketAddrs,
username: &str,
@@ -115,19 +83,9 @@ impl WorldSession {
Self::connect_queued(addr, username, session_key, &mut |_| true)
}
/// [`Self::connect`], reporting our place in the **login queue** as it moves.
///
/// `on_queue` fires once per `AUTH_WAIT_QUEUE` the server sends — `None` when the packet
/// carried no readable position — and the call returns only once we are admitted (`AUTH_OK`),
/// refused, or the socket fails. The queue is a wait, not an outcome, so it is a callback
/// rather than a return value: the screen has to be able to show the position *while* the
/// handshake is still parked here.
///
/// **Returning `false` abandons the queue** and fails the connect. That is the only way out of
/// a wait that can last minutes: every other stage of the handshake is bounded tightly enough
/// that a cancel is honoured at its next boundary, and a queue is not. Abandoning is checked
/// once per packet rather than per frame — the reference tears the socket down on the next
/// tick, which is finer-grained, but it costs a player at most one server update.
/// [`Self::connect`], calling `on_queue` with each queue position; `false` abandons the queue.
/// Deviation: checked per queue packet where the reference checks each tick, so a cancel can
/// lag one server update.
pub fn connect_queued(
addr: impl ToSocketAddrs,
username: &str,
@@ -136,33 +94,20 @@ impl WorldSession {
) -> Result<Self> {
let mut queued = false;
let mut stream = TcpStream::connect(addr).context("connecting to world server")?;
// **Nagle off** (decision 0617). VERIFIED in the reference client: `0x5bca60` calls
// `setsockopt(s, 6 /* IPPROTO_TCP */, 1 /* TCP_NODELAY */, &1, 4)` unconditionally on its game
// socket (WSOCK32 ordinal 21, IAT slot `0x7ff6f8`; a sibling helper at `0x43dda0` toggles the
// same option from a bool). Rust leaves Nagle *on*, which is exactly wrong for this protocol:
// every packet we send is a sub-MSS write on a latency-critical stream, so the kernel holds
// each one until the server ACKs the last — coalescing our movement stream into delayed-ACK-
// sized clumps. The de-jitter chain on the observing client then sizes its buffer to that
// self-inflicted lateness (decision 0615). Fatal on failure: the option cannot fail on a
// healthy connected socket, so an error here means the next handshake write is doomed anyway —
// better one named failure than a session that silently plays at delayed-ACK cadence.
// Nagle off: the reference sets `TCP_NODELAY` on its game socket (`0x5bca60`).
stream
.set_nodelay(true)
.context("disabling Nagle (TCP_NODELAY) on the world socket")?;
// Handshake reads wait for one specific reply each — silence is failure, so bound them
// (decision 0065). A server that stays connected but never answers must not wedge the net
// thread forever. Cleared in `into_split` for the streaming phase (a quiet world is legal).
// `into_split` clears this for the streaming phase, where a quiet world is legal.
stream
.set_read_timeout(Some(HANDSHAKE_READ_TIMEOUT))
.context("setting handshake read timeout")?;
// 1. SMSG_AUTH_CHALLENGE (unencrypted) carries the server seed.
let server_seed = match recv_packet(&mut stream, None)? {
ServerPacket::AuthChallenge { server_seed } => server_seed,
other => bail!("expected SMSG_AUTH_CHALLENGE, got {}", other.name()),
};
// 2. Bind the session key to (server_seed, our client_seed) → proof + header crypto.
let username_n =
NormalizedString::new(username).map_err(|e| anyhow!("invalid username: {e}"))?;
let seed = ProofSeed::new();
@@ -170,10 +115,8 @@ impl WorldSession {
let (client_proof, crypto) =
seed.into_client_header_crypto(&username_n, session_key, server_seed);
// 3. CMSG_AUTH_SESSION goes out unencrypted; obfuscation begins immediately after.
// The addon block is the tail of this packet and is NOT optional: cmangos-classic kicks a
// session whose addon size is zero, which is what benilla used to send (B277, decision
// 1497). `STOCK_SECURE_ADDONS` is what a stock 1.12.1 install reports.
// Sent plain; header encryption starts right after. The addon block is required (cmangos
// kicks a zero-size one); `STOCK_SECURE_ADDONS` is what a stock 1.12.1 install reports.
let body = messages::auth_session(
u32::from(crate::CLIENT_BUILD),
&username.to_uppercase(),
@@ -194,15 +137,7 @@ impl WorldSession {
addon_info: None,
};
// 4. Wait for SMSG_AUTH_RESPONSE. Usually the first encrypted packet, but not always first
// on the wire, so skip interleaved packets (as `char_enum` does) rather than demanding
// AUTH_RESPONSE lead. Each read is still bounded by the handshake timeout, so a server that
// never answers still fails per decision 0065.
//
// SMSG_WARDEN_DATA among them ends the connection here: it means the server runs Warden,
// whose response clock kicks us ~30 s later no matter what else we do ([`WardenRequired`]).
// Refusing at the handshake trades an unplayable 30-second kick/reconnect cycle for one
// honest message at the login screen.
// AUTH_RESPONSE is not always first, so others are skipped; Warden data ends the connect.
loop {
match session.recv()? {
ServerPacket::AuthResponse {
@@ -210,16 +145,11 @@ impl WorldSession {
billing_time_rested,
..
} if result == messages::AUTH_OK => {
// The admitting packet is the only one that counts: a queue packet carries the
// group too, but the client re-reads it on every AUTH_RESPONSE and the last one
// wins, which is this one.
// The reference keeps the last AUTH_RESPONSE's billing group.
session.billing_time_rested = billing_time_rested.unwrap_or(0);
break;
}
// **Queued, not refused** — the realm is full and we keep our place in line. Report
// the position and go back to reading; the server re-sends as we move up and ends
// the wait with an `AUTH_OK`. Treating this as an ending (which it was until now)
// meant benilla could not log into a busy server at all.
// Queued, not refused: the server re-sends as we move up and ends with `AUTH_OK`.
ServerPacket::AuthResponse {
result,
queue_position,
@@ -244,8 +174,7 @@ impl WorldSession {
_ => continue,
}
}
// Admitted. The rest of the handshake is prompt again, so it gets the prompt bound back —
// leaving the queue's 300 s in place would make a stalled roster step look like a long wait.
// Admitted: the rest of the handshake is prompt again.
if queued {
session
.set_read_timeout(Some(HANDSHAKE_READ_TIMEOUT))
@@ -255,24 +184,17 @@ impl WorldSession {
Ok(session)
}
/// The account's accumulated **rested billing minutes**, from the `SMSG_AUTH_RESPONSE` that
/// admitted this session — what `GetBillingTimeRested()` reports (decision 1820).
///
/// Against vmangos this is always `0`: the server hardcodes `uint32(0)` for the field
/// (`World.cpp:331`, and `:380` for the queue packet), guarded on a build newer than 1.7.1,
/// which 1.12 is. It is read from the wire rather than assumed so that a server which does
/// populate it is reported honestly.
/// Rested billing minutes (`GetBillingTimeRested`); always 0 from vmangos (`World.cpp:331`).
pub fn billing_time_rested(&self) -> u32 {
self.billing_time_rested
}
/// The tutorial bank captured during the login handshake, if any (decision 1976).
/// The tutorial bank captured during the login handshake, if any.
pub fn take_tutorial_flags(&mut self) -> Option<Vec<u8>> {
self.tutorial_flags.take()
}
/// Set a read timeout on the underlying socket (e.g. so a debug read-loop can stop when the
/// world goes quiet). `None` clears it (blocking reads).
/// Set the socket's read timeout; `None` makes reads block.
pub fn set_read_timeout(&self, timeout: Option<std::time::Duration>) -> Result<()> {
self.stream
.set_read_timeout(timeout)
@@ -282,20 +204,15 @@ impl WorldSession {
/// Read + decrypt + parse one server packet.
pub fn recv(&mut self) -> Result<ServerPacket> {
let packet = recv_packet(&mut self.stream, Some(self.crypto.decrypter()))?;
// **Captured here rather than in a loop arm**, because it is not one loop's business:
// `SMSG_ADDON_INFO` lands somewhere between `CMSG_AUTH_SESSION` and the roster, and which
// of the handshake's three read loops sees it is the server's timing, not our contract.
// `recv` is the one place all of them go through (decision 2175).
// `SMSG_ADDON_INFO` can reach any of the handshake's read loops, so it is caught here.
if let ServerPacket::AddonInfo { statuses } = &packet {
self.addon_info = Some(statuses.clone());
}
Ok(packet)
}
/// The `SMSG_ADDON_INFO` verdict, taken once — `None` when the server never answered our addon
/// block, which is a real state and not a failure: vmangos only replies when
/// `BuildAddonPacket` accepts the block, and a rejection leaves the session alive and silent
/// (`WorldSocket.cpp:447`). The reference behaves the same way — no reply, no Lua index space.
/// The `SMSG_ADDON_INFO` statuses, taken once; `None` is real, as vmangos stays silent when it
/// rejects the addon block (`WorldSocket.cpp:447`).
pub fn take_addon_info(&mut self) -> Option<Vec<u8>> {
self.addon_info.take()
}
@@ -310,8 +227,7 @@ impl WorldSession {
)
}
/// Request the character list and return it (remembering each character's race, so
/// [`Self::player_login`] can pick the right chat tongue).
/// Request and return the character list, remembering each race for the chat language.
pub fn char_enum(&mut self) -> Result<Vec<Character>> {
self.send(opcode::CMSG_CHAR_ENUM, &[])?;
loop {
@@ -320,13 +236,11 @@ impl WorldSession {
self.roster_races = characters.iter().map(|c| (c.guid, c.race)).collect();
return Ok(characters);
}
// Warden can land either side of SMSG_AUTH_RESPONSE depending on when the server
// arms it, so the roster step refuses it too — same reason as `connect`.
// Warden can arm on either side of SMSG_AUTH_RESPONSE, so this step refuses it too.
ServerPacket::Other {
opcode: opcode::SMSG_WARDEN_DATA,
} => return Err(WardenRequired.into()),
// The tutorial bank, if the server sends it this early (1976): kept for the world
// entry — skipped here it would be lost to the roster loop.
// Kept for the world entry when the server sends it this early.
ServerPacket::TutorialFlags(flags) => {
self.tutorial_flags = Some(flags.bytes);
continue;
@@ -337,9 +251,7 @@ impl WorldSession {
}
}
/// Create a character (the create screen's request, or the create-if-empty starter that unblocks
/// `PLAYER_LOGIN` on a fresh account). Returns the `SMSG_CHAR_CREATE` result byte (`WorldResult`)
/// for the caller to inspect ([`messages::CHAR_CREATE_SUCCESS`], a `CHAR_NAME_*` code, …).
/// Create a character; returns the `SMSG_CHAR_CREATE` result byte.
pub fn create_character(&mut self, req: &messages::CharCreateReq) -> Result<u8> {
self.send(opcode::CMSG_CHAR_CREATE, &messages::char_create(req))?;
loop {
@@ -350,9 +262,7 @@ impl WorldSession {
}
}
/// Delete a character (`CMSG_CHAR_DELETE`, full u64 guid — only valid at character select,
/// never while in-world). Returns the `SMSG_CHAR_DELETE` result byte
/// ([`messages::CHAR_DELETE_SUCCESS`] on success) for the caller to inspect.
/// Delete a character at character select; returns the `SMSG_CHAR_DELETE` result byte.
pub fn delete_character(&mut self, guid: u64) -> Result<u8> {
self.send(opcode::CMSG_CHAR_DELETE, &messages::full_guid(guid))?;
loop {
@@ -363,11 +273,7 @@ impl WorldSession {
}
}
/// Send `CMSG_PLAYER_LOGIN` to enter the world as the given character. Also adopts the
/// character's faction tongue for every subsequent chat send (session and split writer alike):
/// vmangos drops chat — dot-commands included — spoken in a language the character doesn't
/// know, so the tongue must follow the pick. Falls back to Common when the guid wasn't in the
/// enumerated roster (no [`Self::char_enum`] this connection).
/// Enter the world as `guid`, adopting its faction tongue for chat (Common if not enumerated).
pub fn player_login(&mut self, guid: u64) -> Result<()> {
self.chat_language = self
.roster_races
@@ -378,19 +284,17 @@ impl WorldSession {
self.send(opcode::CMSG_PLAYER_LOGIN, &messages::full_guid(guid))
}
/// Declare `guid` as the unit this client controls. vmangos drops all `MSG_MOVE_*` until the
/// client is a *confirmed mover*, which this establishes (the real client sends it at login).
/// Declare the unit we move, as the 1.12 client does at login; vmangos drops moves until then.
pub fn set_active_mover(&mut self, guid: u64) -> Result<()> {
self.send(opcode::CMSG_SET_ACTIVE_MOVER, &messages::full_guid(guid))
}
/// Acknowledge a triggered cinematic as finished (`CMSG_COMPLETE_CINEMATIC`, empty body) — the
/// unsplit twin of [`WorldWriter::complete_cinematic`], for the CLI/probe path.
/// Acknowledge a triggered cinematic as finished (`CMSG_COMPLETE_CINEMATIC`, empty body).
pub fn complete_cinematic(&mut self) -> Result<()> {
self.send(opcode::CMSG_COMPLETE_CINEMATIC, &[])
}
/// Walk our player forward `info`-style: begin moving (`MSG_MOVE_START_FORWARD`).
/// Start walking forward (`MSG_MOVE_START_FORWARD`).
pub fn start_forward(&mut self, pos: [f32; 3], orientation: f32) -> Result<()> {
self.send(
opcode::MSG_MOVE_START_FORWARD,
@@ -414,11 +318,7 @@ impl WorldSession {
)
}
/// Answer a `SMSG_FORCE_*_SPEED_CHANGE` at rest — the unsplit twin of
/// [`WorldWriter::force_speed_change_ack`], for the `--speed` probe: echo the mover guid +
/// counter + exact speed with a stationary `MovementInfo`. A wrong shape (packed guid, missing
/// counter, off-by-0.01 speed) is rejected by the server's pending-change matcher — the probe's
/// pass is the server logging no mismatch and the next change arriving with counter+1.
/// Ack a `SMSG_FORCE_*_SPEED_CHANGE` at rest with the full guid, counter and exact speed.
pub fn force_speed_ack(
&mut self,
kind: messages::SpeedKind,
@@ -434,11 +334,7 @@ impl WorldSession {
)
}
/// Acknowledge a finished server spline (`CMSG_MOVE_SPLINE_DONE`) — the unsplit twin of
/// [`WorldWriter::move_spline_done`], for the `--charge` probe: after the server drives us with a
/// self `SMSG_MONSTER_MOVE`, echo its `spline_id` at the endpoint (at rest) so the server clears
/// its spline-pending state and relocates us. A malformed body throws in the server's parser and
/// drops the session — so a surviving stream is the live proof the wire is right.
/// Ack a finished self `SMSG_MONSTER_MOVE` at its endpoint (`CMSG_MOVE_SPLINE_DONE`).
pub fn move_spline_done(
&mut self,
pos: [f32; 3],
@@ -451,14 +347,12 @@ impl WorldSession {
)
}
/// Ask a player's name (`CMSG_NAME_QUERY`) — the unsplit twin of [`WorldWriter::name_query`],
/// for the CLI/probe path. The answer arrives in the stream as `SMSG_NAME_QUERY_RESPONSE`.
/// Ask a player's name (`CMSG_NAME_QUERY`), answered by `SMSG_NAME_QUERY_RESPONSE`.
pub fn name_query(&mut self, guid: u64) -> Result<()> {
self.send(opcode::CMSG_NAME_QUERY, &messages::full_guid(guid))
}
/// Ask a creature template's name (`CMSG_CREATURE_QUERY`) — the unsplit twin of
/// [`WorldWriter::creature_query`].
/// Ask a creature template's name (`CMSG_CREATURE_QUERY`).
pub fn creature_query(&mut self, entry: u32, guid: u64) -> Result<()> {
self.send(
opcode::CMSG_CREATURE_QUERY,
@@ -466,8 +360,7 @@ impl WorldSession {
)
}
/// Cast a spell (`CMSG_CAST_SPELL`) — the unsplit twin of [`WorldWriter::cast_spell`], for the
/// CLI/probe path. `target: None` = self/implicit; the verdict arrives as `SMSG_CAST_RESULT`.
/// Cast a spell (`CMSG_CAST_SPELL`), `None` targeting self; answered by `SMSG_CAST_RESULT`.
pub fn cast_spell(&mut self, spell_id: u32, target: Option<u64>) -> Result<()> {
self.send(
opcode::CMSG_CAST_SPELL,
@@ -475,9 +368,7 @@ impl WorldSession {
)
}
/// Cast a spell at a **ground point** (`CMSG_CAST_SPELL` with `TARGET_FLAG_DEST_LOCATION` +
/// WoW world coords) — the unsplit twin of [`WorldWriter::cast_spell_at_dest`], for the
/// `--spells` probe's dest-cast round trip (decision 0792).
/// Cast a spell at a ground point in world coords (`TARGET_FLAG_DEST_LOCATION`).
pub fn cast_spell_at_dest(&mut self, spell_id: u32, dest: [f32; 3]) -> Result<()> {
self.send(
opcode::CMSG_CAST_SPELL,
@@ -485,10 +376,7 @@ impl WorldSession {
)
}
/// Cast an OPEN_LOCK spell at a **GameObject** (`CMSG_CAST_SPELL`) — the unsplit twin of
/// [`WorldWriter::cast_spell_gameobject`], for a probe's live chest-loot verification (decision
/// 0239): cast e.g. 3365 "Opening" / 2575 "Mining" at a lockable GO and expect
/// `SMSG_LOOT_RESPONSE`.
/// Cast an OPEN_LOCK spell (e.g. 3365 Opening, 2575 Mining) at a GameObject.
pub fn cast_spell_gameobject(&mut self, spell_id: u32, go_guid: u64) -> Result<()> {
self.send(
opcode::CMSG_CAST_SPELL,
@@ -496,8 +384,7 @@ impl WorldSession {
)
}
/// Ask an item template (`CMSG_ITEM_QUERY_SINGLE`) — the unsplit twin of
/// [`WorldWriter::item_query`]. Answered by `SMSG_ITEM_QUERY_SINGLE_RESPONSE`.
/// Ask an item template (`CMSG_ITEM_QUERY_SINGLE`).
pub fn item_query(&mut self, entry: u32, guid: u64) -> Result<()> {
self.send(
opcode::CMSG_ITEM_QUERY_SINGLE,
@@ -505,8 +392,7 @@ impl WorldSession {
)
}
/// Use an item by bag position (`CMSG_USE_ITEM`) — the unsplit twin of
/// [`WorldWriter::use_item`], for the probe's live use-item verification.
/// Use an item by bag position (`CMSG_USE_ITEM`).
pub fn use_item(&mut self, bag_index: u8, slot: u8, spell_slot: u8) -> Result<()> {
self.send(
opcode::CMSG_USE_ITEM,
@@ -519,8 +405,7 @@ impl WorldSession {
)
}
/// Open an item by bag position (`CMSG_OPEN_ITEM`) — the unsplit twin of
/// [`WorldWriter::open_item`], for the probe's live open-item verification.
/// Open an item by bag position (`CMSG_OPEN_ITEM`).
pub fn open_item(&mut self, bag_index: u8, slot: u8) -> Result<()> {
self.send(
opcode::CMSG_OPEN_ITEM,
@@ -528,8 +413,7 @@ impl WorldSession {
)
}
/// Equip a bag item (`CMSG_AUTOEQUIP_ITEM`) — the unsplit twin of
/// [`WorldWriter::auto_equip_item`], for the probe's live equip verification.
/// Equip a bag item (`CMSG_AUTOEQUIP_ITEM`).
pub fn auto_equip_item(&mut self, bag_index: u8, slot: u8) -> Result<()> {
self.send(
opcode::CMSG_AUTOEQUIP_ITEM,
@@ -537,8 +421,7 @@ impl WorldSession {
)
}
/// Swap two player-array slots (`CMSG_SWAP_INV_ITEM`) — the unsplit twin of
/// [`WorldWriter::swap_inv_item`], for the probe's live backpack-move verification.
/// Swap two player-array slots (`CMSG_SWAP_INV_ITEM`).
pub fn swap_inv_item(&mut self, src_slot: u8, dst_slot: u8) -> Result<()> {
self.send(
opcode::CMSG_SWAP_INV_ITEM,
@@ -546,9 +429,7 @@ impl WorldSession {
)
}
/// Ask a vendor's stock (`CMSG_LIST_INVENTORY`) — the unsplit twin of
/// [`WorldWriter::list_inventory`], for the probe's live vendor verification. Answered by
/// `SMSG_LIST_INVENTORY` (a `VendorInventory` event).
/// Ask a vendor's stock (`CMSG_LIST_INVENTORY`), answered by `SMSG_LIST_INVENTORY`.
pub fn list_inventory(&mut self, vendor_guid: u64) -> Result<()> {
self.send(
opcode::CMSG_LIST_INVENTORY,
@@ -556,8 +437,7 @@ impl WorldSession {
)
}
/// Buy from a vendor (`CMSG_BUY_ITEM`) — the unsplit twin of [`WorldWriter::buy_item`]; `entry`
/// is the item template id, not the vendor row's `muid`.
/// Buy from a vendor (`CMSG_BUY_ITEM`); `entry` is the item template, not the row's `muid`.
pub fn buy_item(&mut self, vendor_guid: u64, entry: u32, count: u8) -> Result<()> {
self.send(
opcode::CMSG_BUY_ITEM,
@@ -565,7 +445,7 @@ impl WorldSession {
)
}
/// `CMSG_BUY_ITEM_IN_SLOT` — buy into a named container slot, the merchant cursor's drop.
/// Buy into a named container slot (`CMSG_BUY_ITEM_IN_SLOT`), the merchant cursor's drop.
pub fn buy_item_in_slot(
&mut self,
vendor_guid: u64,
@@ -580,8 +460,7 @@ impl WorldSession {
)
}
/// Sell an item to a vendor (`CMSG_SELL_ITEM`) — the unsplit twin of [`WorldWriter::sell_item`];
/// `count` 0 = the whole stack.
/// Sell an item to a vendor (`CMSG_SELL_ITEM`); `count` 0 sells the whole stack.
pub fn sell_item(&mut self, vendor_guid: u64, item_guid: u64, count: u8) -> Result<()> {
self.send(
opcode::CMSG_SELL_ITEM,
@@ -589,8 +468,7 @@ impl WorldSession {
)
}
/// Buy a sold item back (`CMSG_BUYBACK_ITEM`) — the unsplit twin of
/// [`WorldWriter::buyback_item`]; `slot` is the absolute buyback slot 69–80.
/// Buy a sold item back (`CMSG_BUYBACK_ITEM`); `slot` is the absolute buyback slot 69-80.
pub fn buyback_item(&mut self, vendor_guid: u64, slot: u32) -> Result<()> {
self.send(
opcode::CMSG_BUYBACK_ITEM,
@@ -598,8 +476,7 @@ impl WorldSession {
)
}
/// Repair at a vendor (`CMSG_REPAIR_ITEM`) — the unsplit twin of [`WorldWriter::repair_item`];
/// `item_guid` 0 = repair everything.
/// Repair at a vendor (`CMSG_REPAIR_ITEM`); `item_guid` 0 repairs everything.
pub fn repair_item(&mut self, vendor_guid: u64, item_guid: u64) -> Result<()> {
self.send(
opcode::CMSG_REPAIR_ITEM,
@@ -607,8 +484,7 @@ impl WorldSession {
)
}
/// Right-click a pure banker (`CMSG_BANKER_ACTIVATE`) — opens the bank window, answered by
/// `SMSG_SHOW_BANK` (a `ShowBank` event). Decision 0604.
/// Open the bank at a banker (`CMSG_BANKER_ACTIVATE`), answered by `SMSG_SHOW_BANK`.
pub fn banker_activate(&mut self, banker_guid: u64) -> Result<()> {
self.send(
opcode::CMSG_BANKER_ACTIVATE,
@@ -616,10 +492,7 @@ impl WorldSession {
)
}
/// Buy the next bank bag slot (`CMSG_BUY_BANK_SLOT`) — the server buys slot
/// `purchased_count + 1` and debits the price itself; success is silent (the
/// `PLAYER_BYTES_2` bank-bag-count byte advances via `UPDATE_OBJECT`), failure answers
/// `SMSG_BUY_BANK_SLOT_RESULT` (a `BuyBankSlotResult` event). Decision 0604.
/// Buy the next bank bag slot; success is silent, failure answers `SMSG_BUY_BANK_SLOT_RESULT`.
pub fn buy_bank_slot(&mut self, banker_guid: u64) -> Result<()> {
self.send(
opcode::CMSG_BUY_BANK_SLOT,
@@ -627,8 +500,7 @@ impl WorldSession {
)
}
/// Deposit a bag item into the bank (`CMSG_AUTOBANK_ITEM`) — moves the item at player-array
/// position `(bag, slot)` into the first free bank slot. Decision 0604.
/// Deposit the item at `(bag, slot)` into the first free bank slot (`CMSG_AUTOBANK_ITEM`).
pub fn autobank_item(&mut self, bag: u8, slot: u8) -> Result<()> {
self.send(
opcode::CMSG_AUTOBANK_ITEM,
@@ -636,9 +508,7 @@ impl WorldSession {
)
}
/// Auto-move at the bank (`CMSG_AUTOSTORE_BANK_ITEM`) — withdraws `(bag, slot)` to inventory
/// when it names a bank position, else deposits it (vmangos `HandleAutoStoreBankItemOpcode`,
/// `ItemHandler.cpp:971`). Decision 0604.
/// Withdraw a bank position, or deposit any other (`CMSG_AUTOSTORE_BANK_ITEM`).
pub fn autostore_bank_item(&mut self, bag: u8, slot: u8) -> Result<()> {
self.send(
opcode::CMSG_AUTOSTORE_BANK_ITEM,
@@ -646,8 +516,7 @@ impl WorldSession {
)
}
/// Ask an NPC's questgiver dialog status (`CMSG_QUESTGIVER_STATUS_QUERY`) — answered by
/// `SMSG_QUESTGIVER_STATUS`, the overhead `!`/`?` marker's value.
/// Ask an NPC's overhead `!`/`?` status (`CMSG_QUESTGIVER_STATUS_QUERY`).
pub fn questgiver_status_query(&mut self, npc: u64) -> Result<()> {
self.send(
opcode::CMSG_QUESTGIVER_STATUS_QUERY,
@@ -655,8 +524,7 @@ impl WorldSession {
)
}
/// Open a questgiver dialog (`CMSG_QUESTGIVER_HELLO`) — the unsplit twin for the probe's live
/// quest verification. The same `SendPreparedGossip` server path as gossip-hello.
/// Open a questgiver dialog (`CMSG_QUESTGIVER_HELLO`), the server's gossip-hello path.
pub fn questgiver_hello(&mut self, npc: u64) -> Result<()> {
self.send(
opcode::CMSG_QUESTGIVER_HELLO,
@@ -664,8 +532,7 @@ impl WorldSession {
)
}
/// Ask a quest's detail panel (`CMSG_QUESTGIVER_QUERY_QUEST`) — answered by
/// `SMSG_QUESTGIVER_QUEST_DETAILS`.
/// Ask a quest's detail panel (`CMSG_QUESTGIVER_QUERY_QUEST`).
pub fn questgiver_query_quest(&mut self, npc: u64, quest: u32) -> Result<()> {
self.send(
opcode::CMSG_QUESTGIVER_QUERY_QUEST,
@@ -673,8 +540,7 @@ impl WorldSession {
)
}
/// Accept a quest (`CMSG_QUESTGIVER_ACCEPT_QUEST`) — adds it to the log; the server closes the
/// gossip window.
/// Accept a quest (`CMSG_QUESTGIVER_ACCEPT_QUEST`); the server closes the gossip window.
pub fn questgiver_accept_quest(&mut self, npc: u64, quest: u32) -> Result<()> {
self.send(
opcode::CMSG_QUESTGIVER_ACCEPT_QUEST,
@@ -682,8 +548,7 @@ impl WorldSession {
)
}
/// Ask for a quest's turn-in progress panel (`CMSG_QUESTGIVER_COMPLETE_QUEST`) — answered by
/// `SMSG_QUESTGIVER_REQUEST_ITEMS` (or OFFER_REWARD when there are no required items).
/// Ask a quest's turn-in panel: `REQUEST_ITEMS`, or `OFFER_REWARD` when nothing is required.
pub fn questgiver_complete_quest(&mut self, npc: u64, quest: u32) -> Result<()> {
self.send(
opcode::CMSG_QUESTGIVER_COMPLETE_QUEST,
@@ -691,8 +556,7 @@ impl WorldSession {
)
}
/// Advance from progress to the reward panel (`CMSG_QUESTGIVER_REQUEST_REWARD`) — answered by
/// `SMSG_QUESTGIVER_OFFER_REWARD`.
/// Advance to the reward panel (`CMSG_QUESTGIVER_REQUEST_REWARD`).
pub fn questgiver_request_reward(&mut self, npc: u64, quest: u32) -> Result<()> {
self.send(
opcode::CMSG_QUESTGIVER_REQUEST_REWARD,
@@ -700,8 +564,7 @@ impl WorldSession {
)
}
/// Choose a reward and finish the quest (`CMSG_QUESTGIVER_CHOOSE_REWARD`, `reward` = choice
/// index) — answered by `SMSG_QUESTGIVER_QUEST_COMPLETE` + the XP/money/item grants.
/// Choose reward index `reward` and finish the quest (`CMSG_QUESTGIVER_CHOOSE_REWARD`).
pub fn questgiver_choose_reward(&mut self, npc: u64, quest: u32, reward: u32) -> Result<()> {
self.send(
opcode::CMSG_QUESTGIVER_CHOOSE_REWARD,
@@ -709,25 +572,17 @@ impl WorldSession {
)
}
/// Ask a quest's full template (`CMSG_QUEST_QUERY`) — the unsplit twin of
/// [`WorldWriter::quest_query`]; the quest-log detail pane's ask-once source, distinct from
/// [`Self::questgiver_query_quest`] (which needs an NPC guid, not just the quest id). Answered
/// by `SMSG_QUEST_QUERY_RESPONSE` (a `QuestTemplate` event).
/// Ask a quest's full template by id alone (`CMSG_QUEST_QUERY`).
pub fn quest_query(&mut self, quest_id: u32) -> Result<()> {
self.send(opcode::CMSG_QUEST_QUERY, &messages::quest_query(quest_id))
}
/// Ask the server for its wall clock (`CMSG_QUERY_TIME`) — the unsplit twin of
/// [`WorldWriter::query_time`]; answered by `SMSG_QUERY_TIME_RESPONSE`, one `u32` of
/// unix-epoch seconds. That is the epoch a timed quest's deadline is written in, so no
/// countdown can be read off the descriptor without it (decision 1150).
/// Ask the server's unix-seconds clock, the epoch of timed-quest deadlines (`CMSG_QUERY_TIME`).
pub fn query_time(&mut self) -> Result<()> {
self.send(opcode::CMSG_QUERY_TIME, &messages::query_time())
}
/// Abandon a quest-log slot (`CMSG_QUESTLOG_REMOVE_QUEST`) — the unsplit twin of
/// [`WorldWriter::questlog_remove_quest`]; no ack SMSG, the server clears the
/// `PLAYER_QUEST_LOG` slot fields directly.
/// Abandon a quest-log slot; no reply, the server clears the `PLAYER_QUEST_LOG` fields.
pub fn questlog_remove_quest(&mut self, slot: u8) -> Result<()> {
self.send(
opcode::CMSG_QUESTLOG_REMOVE_QUEST,
@@ -735,10 +590,7 @@ impl WorldSession {
)
}
/// Send a `/say` line — the unsplit twin of [`WorldWriter::send_chat`]; the probe CLI drives GM
/// dot-commands (`.go xyz …`) through it. Same body rules as the writer: spoken in the
/// character's own tongue (set by [`Self::player_login`]) — the server rejects `Universal`
/// from clients, and rejects a tongue the character doesn't know.
/// Send a `/say` line, GM dot-commands included, in the character's own tongue.
pub fn send_chat(&mut self, message: &str) -> Result<()> {
self.send(
opcode::CMSG_MESSAGECHAT,
@@ -746,10 +598,7 @@ impl WorldSession {
)
}
/// Send a chat line on an arbitrary lane — the generalised twin of [`Self::send_chat`]
/// (which is this with `CHAT_TYPE_SAY`). `target` is the whisper target on
/// `CHAT_TYPE_WHISPER` and the channel name on `CHAT_TYPE_CHANNEL`, `None` elsewhere. Spoken
/// in the character's own tongue, with the same server-side rules.
/// Send a chat line on any lane; `target` is the whisper target or channel name.
pub fn send_chat_kind(
&mut self,
chat_type: u32,
@@ -762,8 +611,7 @@ impl WorldSession {
)
}
/// Join a channel — the unsplit twin of [`WorldWriter::join_channel`]; `password` is empty for
/// a channel that has none.
/// Join a channel; `password` is empty for a channel that has none.
pub fn join_channel(&mut self, name: &str, password: &str) -> Result<()> {
self.send(
opcode::CMSG_JOIN_CHANNEL,
@@ -771,13 +619,12 @@ impl WorldSession {
)
}
/// Leave a channel — the unsplit twin of [`WorldWriter::leave_channel`].
/// Leave a channel.
pub fn leave_channel(&mut self, name: &str) -> Result<()> {
self.send(opcode::CMSG_LEAVE_CHANNEL, &messages::leave_channel(name))
}
/// Invite a player to our group by name (`CMSG_GROUP_INVITE`); they answer with
/// `SMSG_GROUP_INVITE` and, if they want in, [`Self::group_accept`].
/// Invite a player to our group by name (`CMSG_GROUP_INVITE`).
pub fn group_invite(&mut self, member_name: &str) -> Result<()> {
self.send(
opcode::CMSG_GROUP_INVITE,
@@ -790,23 +637,14 @@ impl WorldSession {
self.send(opcode::CMSG_GROUP_ACCEPT, &messages::group_accept())
}
/// Leave/disband our group (`CMSG_GROUP_DISBAND`, empty body) — probes tear the group down so
/// the next run starts ungrouped.
/// Leave or disband our group (`CMSG_GROUP_DISBAND`, empty body).
pub fn group_disband(&mut self) -> Result<()> {
self.send(opcode::CMSG_GROUP_DISBAND, &messages::group_disband())
}
/// Send an **addon message** — a `CMSG_MESSAGECHAT` on `chat_type`'s lane with
/// [`messages::LANGUAGE_ADDON`] in the language field, which is what makes it addon traffic
/// rather than speech (decision 1029). `target` is the channel name on
/// [`messages::CHAT_TYPE_CHANNEL`] and `None` on the group/guild lanes; `text` is the raw
/// payload (`prefix`, TAB, the addon's own encoding) — this verb does not compose it.
///
/// Probe-only, and deliberately: benilla runs FrameXML, not third-party addons, so it has
/// nothing to *say* over this lane. It exists so the receive gate can be proved against the
/// live server (`addon_chat_probe`) rather than argued from folk memory of the lane. The
/// server drops the whole message unless `AddonChannel` is on and `chat_type` is one of the
/// lanes `IsLanguageAllowedForChatType` permits (see [`messages::LANGUAGE_ADDON`]).
/// Send an addon message: [`messages::LANGUAGE_ADDON`] as the language, `target` the channel
/// name on [`messages::CHAT_TYPE_CHANNEL`]. vmangos drops it unless `AddonChannel` is on and
/// the lane allows it. Probe-only: benilla runs no third-party addons.
pub fn send_addon_message(
&mut self,
chat_type: u32,
@@ -819,8 +657,7 @@ impl WorldSession {
)
}
/// Echo a same-map teleport ack — the unsplit twin of [`WorldWriter::teleport_ack`] (without it
/// the server freezes our movement after a `.go`).
/// Echo a same-map teleport ack; without it the server freezes our movement.
pub fn teleport_ack(&mut self, guid: u64, counter: u32) -> Result<()> {
self.send(
opcode::MSG_MOVE_TELEPORT_ACK,
@@ -828,19 +665,12 @@ impl WorldSession {
)
}
/// Echo a cross-map worldport ack — the unsplit twin of [`WorldWriter::worldport_ack`]
/// (`MSG_MOVE_WORLDPORT_ACK`, empty body). Without it the server never runs
/// `HandleMoveWorldportAckOpcode`, so nothing on the destination map is ever streamed: no self
/// create block, and none of the arrival's own side effects (the `--mount-tele` probe's whole
/// subject — the mount strip a map that forbids mounting performs right there).
/// Ack a cross-map worldport (empty body); without it nothing on the new map is streamed.
pub fn worldport_ack(&mut self) -> Result<()> {
self.send(opcode::MSG_MOVE_WORLDPORT_ACK, &[])
}
/// **Acknowledge a granted mover mode** — root, water-walk, feather-fall or hover (the ack'd
/// family; decision 0866) — the unsplit twin of [`WorldWriter::move_mode_ack`], for the
/// `--death` probe: the server roots us at death, unroots and grants walk-on-water at release,
/// and applies nothing until we echo the counter (+ our current `pose`) back.
/// Ack a granted mover mode with the counter and current `pose`; nothing applies until then.
pub fn move_mode_ack(
&mut self,
guid: u64,
@@ -858,30 +688,22 @@ impl WorldSession {
)
}
/// Release the spirit (`CMSG_REPOP_REQUEST`, empty body) — the unsplit twin of
/// [`WorldWriter::repop_request`], for the `--death` probe's live release verification.
/// Release the spirit (`CMSG_REPOP_REQUEST`, empty body).
pub fn repop_request(&mut self) -> Result<()> {
self.send(opcode::CMSG_REPOP_REQUEST, &[])
}
/// Ask where our corpse is (`MSG_CORPSE_QUERY`, empty request) — the unsplit twin of
/// [`WorldWriter::corpse_query`], for the `--death` probe. Answered by the same opcode (a
/// [`SessionEvent::CorpseQuery`](crate::SessionEvent::CorpseQuery)).
/// Ask where our corpse is (`MSG_CORPSE_QUERY`, empty body), answered on the same opcode.
pub fn corpse_query(&mut self) -> Result<()> {
self.send(opcode::MSG_CORPSE_QUERY, &[])
}
/// Self-resurrect (`CMSG_SELF_RES`, empty body) — the unsplit twin of
/// [`WorldWriter::self_res`], for the `--self-res` probe. The server casts whatever
/// `PLAYER_SELF_RES_SPELL` holds and zeroes the field.
/// Self-resurrect; the server casts `PLAYER_SELF_RES_SPELL` and zeroes it.
pub fn self_res(&mut self) -> Result<()> {
self.send(opcode::CMSG_SELF_RES, &[])
}
/// Ask the graveyard's Spirit Healer for the immediate res (`CMSG_SPIRIT_HEALER_ACTIVATE`,
/// full guid) — the unsplit twin of [`WorldWriter::spirit_healer_activate`], for the
/// `--spirit` probe's live durability-loss verification. The server resurrects at 50% health
/// and pushes the 25% durability loss as item values-deltas.
/// Take the Spirit Healer's resurrection: 50% health and a 25% durability loss.
pub fn spirit_healer_activate(&mut self, npc: u64) -> Result<()> {
self.send(
opcode::CMSG_SPIRIT_HEALER_ACTIVATE,
@@ -889,36 +711,27 @@ impl WorldSession {
)
}
/// Start melee auto-attack (`CMSG_ATTACKSWING`, full guid) — the unsplit twin of
/// [`WorldWriter::attack_swing`]. Echoed as `SMSG_ATTACKSTART`; each completed swing arrives as
/// `SMSG_ATTACKERSTATEUPDATE`.
/// Start melee auto-attack (`CMSG_ATTACKSWING`), echoed as `SMSG_ATTACKSTART`.
pub fn attack_swing(&mut self, guid: u64) -> Result<()> {
self.send(opcode::CMSG_ATTACKSWING, &messages::attack_swing(guid))
}
/// Set our target (`CMSG_SET_SELECTION`, full guid) — the unsplit twin of
/// [`WorldWriter::set_selection`], for probe fidelity (the client selects before it casts).
/// Set our target (`CMSG_SET_SELECTION`, full guid); the client selects before it casts.
pub fn set_selection(&mut self, guid: u64) -> Result<()> {
self.send(opcode::CMSG_SET_SELECTION, &messages::full_guid(guid))
}
/// Open a loot window (`CMSG_LOOT`) — the unsplit twin of [`WorldWriter::loot`], for the
/// probe's live loot verification. Answered by `SMSG_LOOT_RESPONSE` (either shape).
/// Open a loot window (`CMSG_LOOT`), answered by `SMSG_LOOT_RESPONSE`.
pub fn loot(&mut self, guid: u64) -> Result<()> {
self.send(opcode::CMSG_LOOT, &messages::loot(guid))
}
/// Use a world GameObject (`CMSG_GAMEOBJ_USE`) — the unsplit twin of [`WorldWriter::gameobj_use`],
/// for a probe's live GO-interaction verification (decision 0236 phase 3's chest-loot capture).
/// The server answers on the type's own system (`SMSG_LOOT_RESPONSE` for a chest, gossip/quest for
/// a questgiver GO) or not at all.
/// Use a world GameObject; the server answers by type, or not at all.
pub fn gameobj_use(&mut self, guid: u64) -> Result<()> {
self.send(opcode::CMSG_GAMEOBJ_USE, &messages::gameobj_use(guid))
}
/// Ask for a GameObject template's head (`CMSG_GAMEOBJECT_QUERY`) — the unsplit twin of
/// [`WorldWriter::gameobject_query`], for the CLI/probe path. Answered by
/// `SMSG_GAMEOBJECT_QUERY_RESPONSE`.
/// Ask a GameObject template (`CMSG_GAMEOBJECT_QUERY`).
pub fn gameobject_query(&mut self, entry: u32, guid: u64) -> Result<()> {
self.send(
opcode::CMSG_GAMEOBJECT_QUERY,
@@ -926,9 +739,7 @@ impl WorldSession {
)
}
/// Take one loot-window row (`CMSG_AUTOSTORE_LOOT_ITEM`) — the unsplit twin of
/// [`WorldWriter::autostore_loot_item`]; `loot_slot` is the wire's 0-based row index from the
/// `SMSG_LOOT_RESPONSE` this answers.
/// Take one loot row; `loot_slot` is the 0-based row of the `SMSG_LOOT_RESPONSE`.
pub fn autostore_loot_item(&mut self, loot_slot: u8) -> Result<()> {
self.send(
opcode::CMSG_AUTOSTORE_LOOT_ITEM,
@@ -936,24 +747,19 @@ impl WorldSession {
)
}
/// Take the loot's coin pile (`CMSG_LOOT_MONEY`, empty body) — the unsplit twin of
/// [`WorldWriter::loot_money`]. Answered by `SMSG_LOOT_MONEY_NOTIFY` + `SMSG_LOOT_CLEAR_MONEY`.
/// Take the loot's coin (`CMSG_LOOT_MONEY`, empty body).
pub fn loot_money(&mut self) -> Result<()> {
self.send(opcode::CMSG_LOOT_MONEY, &messages::loot_money())
}
/// Close the loot window (`CMSG_LOOT_RELEASE`) — the unsplit twin of
/// [`WorldWriter::loot_release`]; the server ignores `guid` and releases its own stored loot
/// target. Answered by `SMSG_LOOT_RELEASE_RESPONSE`.
/// Close the loot window; the server ignores `guid` and releases its own loot target.
pub fn loot_release(&mut self, guid: u64) -> Result<()> {
self.send(opcode::CMSG_LOOT_RELEASE, &messages::loot_release(guid))
}
/// Split into independent read/write halves (separate cloned sockets + crypto halves) so a reader
/// thread can stream updates while another path sends our movement. Call after [`Self::player_login`].
/// Split into a reader and a writer on cloned sockets, after [`Self::player_login`].
pub fn into_split(self) -> Result<(WorldReader, WorldWriter)> {
// The handshake is over — clear its read timeout (decision 0065): the streaming phase reads
// block indefinitely (a quiet world is legal; connection liveness is the caller's loop).
// A quiet world is legal, so streaming reads block without a timeout.
self.stream
.set_read_timeout(None)
.context("clearing handshake read timeout")?;
@@ -976,10 +782,7 @@ impl WorldSession {
))
}
/// Cleanly leave the world back to character-select: send `CMSG_LOGOUT_REQUEST` and wait for
/// `SMSG_LOGOUT_COMPLETE` (instant for GM / rested). The server persists the character on logout,
/// so a follow-up [`Self::char_enum`] reflects the saved position. Requires a read timeout (so the
/// poll loop can give up); returns once logged out or `timeout` elapses.
/// Log out to character select, waiting up to `timeout`; needs a socket read timeout.
pub fn logout(&mut self, timeout: std::time::Duration) -> Result<()> {
self.send(opcode::CMSG_LOGOUT_REQUEST, &[])?;
let deadline = std::time::Instant::now() + timeout;
@@ -992,7 +795,7 @@ impl WorldSession {
denied = reason != messages::LOGOUT_SUCCESS;
}
Ok(_) => {}
Err(_) => {} // read-timeout tick — keep polling until the deadline
Err(_) => {} // a read-timeout tick; poll until the deadline
}
}
if denied {
@@ -1,13 +1,5 @@
//! The action bar's `WorldWriter` send — one verb, mirroring [`crate::messages::action_bar`]. Split
//! out of [`super::spells`] by decision 0640.
//!
//! One verb is the whole family because the bar is **client-authoritative** (decisions 0216 §7 /
//! 0218 §4): the server stores the slots and hands them back at login and never edits them in
//! normal play. So every pickup, place and hop the player makes is exactly one of these — a
//! drag-swap is two sends, never atomic.
//!
//! The second verb is the mirror image of the first: [`WorldWriter::set_actionbar_toggles`] posts a
//! byte the client is **not** allowed to write locally.
//! The action bar sends. Its contents are client-authoritative: the server stores the slots,
//! hands them back at login and never edits them in play.
use anyhow::Result;
@@ -16,12 +8,8 @@ use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// Set (or clear, `packed == 0`) one action-bar slot (`CMSG_SET_ACTION_BUTTON`, layout in
/// [`messages::set_action_button`]) — decision 0216 §7/0218 §4: the bar is
/// client-authoritative, so this is the ONLY wire traffic a local pickup/place/hop generates,
/// one send per slot mutation (a drag-swap is two sends, never atomic). No dedicated answer
/// packet — `SMSG_ACTION_BUTTONS` only ever re-arrives on a server-side edit (a GM command, a
/// macro-menu save), never as our own edit's echo.
/// Set one action-bar slot, or clear it with `packed == 0` (`CMSG_SET_ACTION_BUTTON`). A
/// drag-swap is two sends, never atomic; the server never echoes our own edit.
pub fn set_action_button(&mut self, button: u8, packed: u32) -> Result<()> {
self.send(
opcode::CMSG_SET_ACTION_BUTTON,
@@ -29,21 +17,9 @@ impl WorldWriter {
)
}
/// Post the four extra bars' visibility byte (`CMSG_SET_ACTIONBAR_TOGGLES`, layout in
/// [`messages::set_actionbar_toggles`]) — `PLAYER_FIELD_BYTES` byte 2, VERIFIED wow-re
/// `system/ui/scratch/action-bar-toggles.md`.
///
/// **The opposite arrangement to [`Self::set_action_button`]**, and the difference is the whole
/// point. The bar's *contents* are client-authoritative; the bar's *visibility* is
/// server-owned: the real client never stores this byte itself (§4.1 — the one `+0x102a`
/// instruction image-wide is a read), so the value only becomes true when the server's
/// `SMSG_UPDATE_OBJECT` echoes it back into the descriptor. There is no answer packet and no
/// field-change notification (§4.2), which is why the reference UI keeps its own optimistic
/// copy in Lua and re-reads the field exactly once, at `PLAYER_ENTERING_WORLD`.
///
/// Sending while disconnected is a **silent no-op**, not an error, matching the reference: the
/// binding gates nothing and drops the packet at `0x5ab637` (no connection) or `0x5379ab`
/// (connection state ≠ 6), returning zero Lua values either way, so nothing upstream can tell.
/// Post the extra bars' visibility byte, `PLAYER_FIELD_BYTES` byte 2. The server owns it: the
/// reference never writes it locally, it holds once `SMSG_UPDATE_OBJECT` echoes it, and a
/// disconnected send is dropped silently (`0x5ab637`).
pub fn set_actionbar_toggles(&mut self, toggles: u8) -> Result<()> {
self.send(
opcode::CMSG_SET_ACTIONBAR_TOGGLES,
@@ -1,4 +1,4 @@
//! The one area-trigger send. Body in [`crate::messages::area_trigger`], whose scope this mirrors.
//! The area-trigger send.
use anyhow::Result;
@@ -7,14 +7,8 @@ use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// Report that we just walked into an `AreaTrigger.dbc` volume (`CMSG_AREATRIGGER`, layout in
/// [`messages::area_trigger`]) — the client's whole part in the trigger system: it owns the
/// geometry and names the id, and the **server** decides what that trigger means (a teleport,
/// a quest's explore objective, the inn's rested state, a battleground's entrance list).
///
/// There is no dedicated success reply: a teleport arrives as the ordinary
/// `SMSG_TRANSFER_PENDING` + `SMSG_NEW_WORLD` pair or a same-map `MSG_MOVE_TELEPORT_ACK`, a
/// refusal as `SMSG_AREA_TRIGGER_MESSAGE`, and most triggers answer with nothing at all.
/// Report walking into an `AreaTrigger.dbc` volume (`CMSG_AREATRIGGER`). There is no success
/// reply; a refusal is `SMSG_AREA_TRIGGER_MESSAGE`, and most triggers answer nothing.
pub fn area_trigger(&mut self, trigger_id: u32) -> Result<()> {
self.send(
opcode::CMSG_AREATRIGGER,
@@ -1,11 +1,5 @@
//! The auto-attack family's `WorldWriter` sends — start and stop the melee swing, and stop the
//! ranged auto-repeat. Bodies in [`crate::messages::attack`], whose scope this mirrors (the two
//! stops are bodyless). Split out of [`super::spells`] by decision 0640, when the messages side
//! grew an `attack` family of its own.
//!
//! Melee and ranged sit together because they are two halves of one toggle: the client runs at most
//! one auto-attack, and switching weapons hands off between `CMSG_ATTACKSTOP` and
//! `CMSG_CANCEL_AUTO_REPEAT_SPELL`.
//! The auto-attack sends. The client runs at most one auto-attack, so switching between melee and
//! ranged hands off between `CMSG_ATTACKSTOP` and `CMSG_CANCEL_AUTO_REPEAT_SPELL`.
use anyhow::Result;
@@ -14,8 +8,7 @@ use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// Start melee auto-attack on `guid` (`CMSG_ATTACKSWING`, a full 8-byte guid — vmangos
/// `AttackSwing::ReadFromWorldPacket`). Echoed back as `SMSG_ATTACKSTART`.
/// Start melee auto-attack (`CMSG_ATTACKSWING`, full guid), echoed as `SMSG_ATTACKSTART`.
pub fn attack_swing(&mut self, guid: u64) -> Result<()> {
self.send(opcode::CMSG_ATTACKSWING, &messages::attack_swing(guid))
}
@@ -25,8 +18,7 @@ impl WorldWriter {
self.send(opcode::CMSG_ATTACKSTOP, &[])
}
/// Stop our ranged auto-repeat (`CMSG_CANCEL_AUTO_REPEAT_SPELL`, empty body) — the ack every
/// local cancel sends (the client's one send site `0x6ea0c6`, inside the cancel `0x6ea080`).
/// Stop our ranged auto-repeat; the reference sends it on every local cancel (`0x6ea080`).
pub fn cancel_auto_repeat(&mut self) -> Result<()> {
self.send(opcode::CMSG_CANCEL_AUTO_REPEAT_SPELL, &[])
}
@@ -1,17 +1,7 @@
//! The auction house's `WorldWriter` sends (decision 1511 P0): the auctioneer greeting, the three
//! list pages (browse / your bids / your auctions), and the three market verbs — list an item,
//! bid or buy out, cancel. Bodies in [`crate::messages`]'s `auction` builders (layout VERIFIED
//! against vmangos `Handlers/AuctionHouseHandler.cpp` + `Server/Packets/AuctionHouse.cpp`). Split
//! out per `writer/mod.rs`'s rule — the module a body builder lives in names the module its send
//! lives in.
//!
//! Two things every caller here has to know. **The auctioneer guid rides on every send**, not
//! just the hello: the server re-validates the 5 yd interact distance on each one independently
//! (`GetCheckedAuctionHouseForAuctioneer`), so there is no session to hold and a walk-away
//! invalidates the next verb rather than the window. And **several refusals are silent** — a sell
//! with a zero bid or duration, a bid the player cannot afford, a cancel whose 5% cut they cannot
//! pay, and any list request while one is already in flight all get *no packet at all*. A UI that
//! blocks waiting for an ack hangs.
//! The auction house sends. Each carries the auctioneer guid, and the server re-checks the 5 yd
//! range on each (`GetCheckedAuctionHouseForAuctioneer`). Sell, bid and cancel answer
//! `SMSG_AUCTION_COMMAND_RESULT`, but some refusals are silent: a zero bid or duration, an
//! unaffordable bid or cancel cut, and a list request while one is in flight.
use anyhow::Result;
@@ -20,10 +10,7 @@ use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// Greet an auctioneer (`MSG_AUCTION_HELLO`, layout in [`messages::auction_hello`]) — one
/// auctioneer guid. **Two-way opcode**: the reply carries the guid back plus the
/// `AuctionHouse.dbc` house id (an `AuctionHello` event), and it is that reply which opens the
/// window.
/// Greet an auctioneer; the reply, carrying the `AuctionHouse.dbc` id, opens the window.
pub fn auction_hello(&mut self, auctioneer: u64) -> Result<()> {
self.send(
opcode::MSG_AUCTION_HELLO,
@@ -31,11 +18,8 @@ impl WorldWriter {
)
}
/// Ask a Browse page (`CMSG_AUCTION_LIST_ITEMS`, layout in
/// [`messages::auction_list_items`]) — the search's filters as the server reads them, with
/// [`messages::auction_filter`]'s sentinels for the ones left unset. **No sort rides the
/// wire**: sorting the returned page is entirely ours. `list_from` pages by
/// [`messages::AUCTION_PAGE_SIZE`]. Answered by `SMSG_AUCTION_LIST_RESULT`.
/// Ask a Browse page; unset filters are [`messages::auction_filter`]'s sentinels, no sort rides
/// the wire, and `list_from` pages by [`messages::AUCTION_PAGE_SIZE`].
pub fn auction_list_items(
&mut self,
auctioneer: u64,
@@ -66,9 +50,7 @@ impl WorldWriter {
)
}
/// Ask the Auctions tab page (`CMSG_AUCTION_LIST_OWNER_ITEMS`, layout in
/// [`messages::auction_list_owner_items`]) — our own listings. Answered by
/// `SMSG_AUCTION_OWNER_LIST_RESULT`.
/// Ask a page of our own auctions (`CMSG_AUCTION_LIST_OWNER_ITEMS`).
pub fn auction_list_owner_items(&mut self, auctioneer: u64, list_from: u32) -> Result<()> {
self.send(
opcode::CMSG_AUCTION_LIST_OWNER_ITEMS,
@@ -76,11 +58,8 @@ impl WorldWriter {
)
}
/// Ask the Bid tab page (`CMSG_AUCTION_LIST_BIDDER_ITEMS`, layout in
/// [`messages::auction_list_bidder_items`]). `auction_ids` is a **refresh set**, not a filter:
/// the server prepends a record for each id named and then appends every auction we are the
/// current bidder on. Pass `&[]` for a plain page. Answered by
/// `SMSG_AUCTION_BIDDER_LIST_RESULT`.
/// Ask a Bid tab page (`CMSG_AUCTION_LIST_BIDDER_ITEMS`). `auction_ids` is a refresh set, not
/// a filter: the server lists those first, then every auction we are the current bidder on.
pub fn auction_list_bidder_items(
&mut self,
auctioneer: u64,
@@ -93,12 +72,7 @@ impl WorldWriter {
)
}
/// List an item for auction (`CMSG_AUCTION_SELL_ITEM`, layout in
/// [`messages::auction_sell_item`]) — the Create Auction pane's Create button.
/// `etime_minutes` must be one of [`messages::auction_duration`]'s three values (120/480/1440);
/// the deposit is charged immediately out of the player's purse. Answered by
/// `SMSG_AUCTION_COMMAND_RESULT` (action [`messages::auction_action::STARTED`]) — or by
/// silence, if the bid or duration is zero or the deposit is unaffordable.
/// List an item; `etime_minutes` is 120, 480 or 1440, and the deposit is charged at once.
pub fn auction_sell_item(
&mut self,
auctioneer: u64,
@@ -113,11 +87,7 @@ impl WorldWriter {
)
}
/// Bid on — or buy out — an auction (`CMSG_AUCTION_PLACE_BID`, layout in
/// [`messages::auction_place_bid`]). One verb for both: the server reads a `price` at or above
/// a nonzero buyout *as* a buyout; there is no separate opcode and no flag. Answered by
/// `SMSG_AUCTION_COMMAND_RESULT` (action [`messages::auction_action::BID_PLACED`]), or by
/// silence if the player cannot afford the price.
/// Bid on an auction; a `price` at or above a nonzero buyout is the buyout.
pub fn auction_place_bid(
&mut self,
auctioneer: u64,
@@ -130,11 +100,7 @@ impl WorldWriter {
)
}
/// Cancel one of our own auctions (`CMSG_AUCTION_REMOVE_ITEM`, layout in
/// [`messages::auction_remove_item`]) — the Auctions tab's Cancel button. The deposit is
/// **forfeit**, and if the auction already carries a bid the 5% cut comes out of the seller's
/// pocket (and the cancel is refused *silently* when they cannot pay it). Answered by
/// `SMSG_AUCTION_COMMAND_RESULT` (action [`messages::auction_action::REMOVED`]).
/// Cancel one of our auctions; the deposit is forfeit, and with a bid the seller pays 5%.
pub fn auction_remove_item(&mut self, auctioneer: u64, auction_id: u32) -> Result<()> {
self.send(
opcode::CMSG_AUCTION_REMOVE_ITEM,
@@ -1,11 +1,5 @@
//! The bank window's `WorldWriter` sends — open the bank, buy the next bag slot, deposit,
//! withdraw. Bodies in [`crate::messages::bank`], whose scope this mirrors. Split out of
//! `writer/mod.rs` (decision 0636).
//!
//! There is no dedicated withdraw opcode in the sense the names suggest: vmangos routes
//! `CMSG_AUTOBANK_ITEM` / `CMSG_AUTOSTORE_BANK_ITEM` by whether the source `(bag, slot)` *is* a
//! bank position, so it tolerates either direction and the pair is really "move it across the bank
//! boundary, server picks the destination" (decision 0604).
//! The bank sends. vmangos routes `CMSG_AUTOBANK_ITEM` and `CMSG_AUTOSTORE_BANK_ITEM` by whether
//! the source is a bank position, so either moves an item across the bank boundary.
use anyhow::Result;
@@ -14,8 +8,7 @@ use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// Open the bank (`CMSG_BANKER_ACTIVATE`, layout in [`messages::banker_activate`]) — one
/// 8-byte banker guid. Answered by `SMSG_SHOW_BANK` (decision 0604).
/// Open the bank (`CMSG_BANKER_ACTIVATE`, full banker guid), answered by `SMSG_SHOW_BANK`.
pub fn banker_activate(&mut self, banker_guid: u64) -> Result<()> {
self.send(
opcode::CMSG_BANKER_ACTIVATE,
@@ -23,9 +16,7 @@ impl WorldWriter {
)
}
/// Buy the next bank-bag slot (`CMSG_BUY_BANK_SLOT`, layout in [`messages::buy_bank_slot`]).
/// No packet on success (the PLAYER_BYTES_2 count + coinage deltas are the confirmation);
/// refusal answers `SMSG_BUY_BANK_SLOT_RESULT`.
/// Buy the next bank-bag slot; success is silent, failure answers `SMSG_BUY_BANK_SLOT_RESULT`.
pub fn buy_bank_slot(&mut self, banker_guid: u64) -> Result<()> {
self.send(
opcode::CMSG_BUY_BANK_SLOT,
@@ -33,8 +24,7 @@ impl WorldWriter {
)
}
/// Deposit an item into the bank (`CMSG_AUTOBANK_ITEM`, layout in
/// [`messages::autobank_item`]): the wire `(bag, slot)` of the source item.
/// Deposit the item at wire `(bag, slot)` into the bank (`CMSG_AUTOBANK_ITEM`).
pub fn autobank_item(&mut self, bag: u8, slot: u8) -> Result<()> {
self.send(
opcode::CMSG_AUTOBANK_ITEM,
@@ -42,9 +32,7 @@ impl WorldWriter {
)
}
/// Withdraw a bank item into the bags (`CMSG_AUTOSTORE_BANK_ITEM`, layout in
/// [`messages::autostore_bank_item`]): the wire `(bag, slot)` of the bank item (vmangos
/// routes by whether the source is a bank position, so it tolerates either direction).
/// Withdraw the bank item at wire `(bag, slot)` into the bags (`CMSG_AUTOSTORE_BANK_ITEM`).
pub fn autostore_bank_item(&mut self, bag: u8, slot: u8) -> Result<()> {
self.send(
opcode::CMSG_AUTOSTORE_BANK_ITEM,
@@ -1,4 +1,4 @@
//! The battleground queue's sends (decision 1963).
//! The battleground queue's sends.
use anyhow::Result;
@@ -7,8 +7,7 @@ use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// Answer a ready battleground (`CMSG_BATTLEFIELD_PORT`): the slot's map id and whether to
/// enter — `AcceptBattlefieldPort(index, accept)`'s packet.
/// Enter or decline a ready battleground by map id, as `AcceptBattlefieldPort` sends.
pub fn battlefield_port(&mut self, map_id: u32, accept: bool) -> Result<()> {
self.send(
opcode::CMSG_BATTLEFIELD_PORT,
@@ -16,14 +15,12 @@ impl WorldWriter {
)
}
/// Ask for the scoreboard (`MSG_PVP_LOG_DATA`, empty) — `RequestBattlefieldScoreData()`'s
/// packet; the 5000 ms throttle is the caller's (decision 1972).
/// Ask the scoreboard (`RequestBattlefieldScoreData`); the caller throttles it to 5000 ms.
pub fn request_battlefield_score_data(&mut self) -> Result<()> {
self.send(opcode::MSG_PVP_LOG_DATA, &[])
}
/// Leave the battleground (`CMSG_LEAVE_BATTLEFIELD`): `LeaveBattlefield()`'s packet, sent
/// only once the scoreboard's "ended" byte has arrived (decision 1972).
/// Leave the battleground, sent only once the scoreboard's "ended" byte has arrived.
pub fn leave_battlefield(&mut self, map_id: u32) -> Result<()> {
self.send(
opcode::CMSG_LEAVE_BATTLEFIELD,
@@ -31,8 +28,7 @@ impl WorldWriter {
)
}
/// Reopen a queued battleground's instance list (`CMSG_BATTLEFIELD_LIST`):
/// `ShowBattlefieldList(index)`'s packet, the queued slot's map (decision 1974).
/// Reopen a queued slot's instance list by map, as `ShowBattlefieldList` sends.
pub fn battlefield_list(&mut self, map_id: u32) -> Result<()> {
self.send(
opcode::CMSG_BATTLEFIELD_LIST,
@@ -40,8 +36,7 @@ impl WorldWriter {
)
}
/// Join through the battlemaster the list came from (`CMSG_BATTLEMASTER_JOIN`) —
/// `JoinBattlefield`'s packet when the cached guid is non-zero (decision 1974).
/// Join through the list's battlemaster, as `JoinBattlefield` sends for a non-zero guid.
pub fn battlemaster_join(
&mut self,
battlemaster: u64,
@@ -55,8 +50,7 @@ impl WorldWriter {
)
}
/// Join without a battlemaster (`CMSG_BATTLEFIELD_JOIN`) — `JoinBattlefield`'s packet when
/// the list arrived with a zero guid (decision 1974).
/// Join without a battlemaster, as `JoinBattlefield` sends when the list had a zero guid.
pub fn battlefield_join(
&mut self,
map_id: u32,
@@ -69,14 +63,12 @@ impl WorldWriter {
)
}
/// Ask for every queue slot's state (`CMSG_BATTLEFIELD_STATUS`, empty) — the reference's
/// world-enter reset sends it once per entry (decision 1974).
/// Ask every queue slot's state; the reference sends it once per world entry.
pub fn battlefield_status(&mut self) -> Result<()> {
self.send(opcode::CMSG_BATTLEFIELD_STATUS, &[])
}
/// Ask for the teammates' positions (`MSG_BATTLEGROUND_PLAYER_POSITIONS`, empty) —
/// `RequestBattlefieldPositions()`'s packet; the 5000 ms throttle is the caller's (1980).
/// Ask teammates' positions (`RequestBattlefieldPositions`); the caller throttles to 5000 ms.
pub fn request_battlefield_positions(&mut self) -> Result<()> {
self.send(opcode::MSG_BATTLEGROUND_PLAYER_POSITIONS, &[])
}
@@ -1,9 +1,5 @@
//! The innkeeper bind answer — the client's one send in the bind family (decision 1331).
//!
//! There is no "bind me here" verb: the flow starts with the *server's* `SMSG_BINDER_CONFIRM`
//! (raised by selecting the innkeeper's gossip line), and this is the Yes. The guid is the one
//! that arrived in the confirm; vmangos resolves it back to a live innkeeper in interact range
//! (`HandleBinderActivateOpcode`), so it is load-bearing rather than an echo.
//! The innkeeper bind answer: the Yes to the server's `SMSG_BINDER_CONFIRM`, carrying its guid,
//! which vmangos resolves to an innkeeper in range (`HandleBinderActivateOpcode`).
use anyhow::Result;
@@ -12,9 +8,8 @@ use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// Accept an innkeeper's bind offer (`CMSG_BINDER_ACTIVATE`) — the `CONFIRM_BINDER` dialog's
/// Accept. The server answers by casting spell 3286 on us, which lands as
/// `SMSG_BINDPOINTUPDATE` + `SMSG_PLAYERBOUND`; declining sends nothing at all.
/// Accept an innkeeper's bind offer (`CMSG_BINDER_ACTIVATE`). The server casts spell 3286 on
/// us, landing as `SMSG_BINDPOINTUPDATE` and `SMSG_PLAYERBOUND`; declining sends nothing.
pub fn binder_activate(&mut self, binder_guid: u64) -> Result<()> {
self.send(
opcode::CMSG_BINDER_ACTIVATE,
@@ -1,8 +1,4 @@
//! The channel family's `WorldWriter` sends — join/leave/list/moderation (bodies in
//! [`crate::messages`]'s `join_channel`/`leave_channel`/`channel_*` builders, all cstring-only
//! shapes; layout VERIFIED against vmangos `Server/Packets/Channel.h`/`.cpp`). Split out of
//! `writer/mod.rs` (decision 0288 phase 1) — the channel administration commands are one clearly
//! separable concern among the writer's many domains.
//! The channel sends: join, leave, list and moderation, all with cstring-only bodies.
use anyhow::Result;
@@ -11,12 +7,7 @@ use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// Join a channel (`CMSG_JOIN_CHANNEL`, layout in [`messages::join_channel`]): `password` is an
/// empty string for a channel that has none. Zone channels (General/Trade/LocalDefense) are
/// joined by the CLIENT too — in 1.12 the client walks ChatChannels.dbc on login/zone-in and
/// sends this same packet with the composed name ("General - Elwynn Forest"); the server only
/// tracks membership by name (decision 0288 phase 6 drives that walk). Answered by
/// `SMSG_CHANNEL_NOTIFY`'s `YOU_JOINED`/`WRONG_PASSWORD`/`BANNED`/… notice.
/// Join a channel; the 1.12 client joins zone channels this way ("General - Elwynn Forest").
pub fn join_channel(&mut self, name: &str, password: &str) -> Result<()> {
self.send(
opcode::CMSG_JOIN_CHANNEL,
@@ -24,19 +15,17 @@ impl WorldWriter {
)
}
/// Leave a channel (`CMSG_LEAVE_CHANNEL`, layout in [`messages::leave_channel`]).
/// Leave a channel (`CMSG_LEAVE_CHANNEL`).
pub fn leave_channel(&mut self, name: &str) -> Result<()> {
self.send(opcode::CMSG_LEAVE_CHANNEL, &messages::leave_channel(name))
}
/// Ask a channel's member roster (`CMSG_CHANNEL_LIST`, layout in [`messages::channel_list`]),
/// the `/chatlist` / `/list` command. Answered by `SMSG_CHANNEL_LIST`.
/// Ask a channel's members (`CMSG_CHANNEL_LIST`, `/chatlist`), answered by `SMSG_CHANNEL_LIST`.
pub fn channel_list(&mut self, name: &str) -> Result<()> {
self.send(opcode::CMSG_CHANNEL_LIST, &messages::channel_list(name))
}
/// Set a channel's password (`CMSG_CHANNEL_PASSWORD`, layout in
/// [`messages::channel_password`]) — owner-only server-side.
/// Set a channel's password (`CMSG_CHANNEL_PASSWORD`); owner only.
pub fn channel_password(&mut self, name: &str, password: &str) -> Result<()> {
self.send(
opcode::CMSG_CHANNEL_PASSWORD,
@@ -44,8 +33,7 @@ impl WorldWriter {
)
}
/// Transfer channel ownership (`CMSG_CHANNEL_SET_OWNER`, layout in
/// [`messages::channel_set_owner`]) — owner-only server-side.
/// Transfer channel ownership (`CMSG_CHANNEL_SET_OWNER`); owner only.
pub fn channel_set_owner(&mut self, name: &str, player: &str) -> Result<()> {
self.send(
opcode::CMSG_CHANNEL_SET_OWNER,
@@ -53,14 +41,12 @@ impl WorldWriter {
)
}
/// Ask who owns a channel (`CMSG_CHANNEL_OWNER`, layout in [`messages::channel_owner`]) — the
/// `/console` "who owns" query. Answered by `SMSG_CHANNEL_NOTIFY`'s `CHANNEL_OWNER` notice.
/// Ask who owns a channel (`CMSG_CHANNEL_OWNER`), answered by a `CHANNEL_OWNER` notify.
pub fn channel_owner(&mut self, name: &str) -> Result<()> {
self.send(opcode::CMSG_CHANNEL_OWNER, &messages::channel_owner(name))
}
/// Grant moderator (`CMSG_CHANNEL_MODERATOR`, layout in [`messages::channel_moderator`]) —
/// owner-only server-side.
/// Grant moderator (`CMSG_CHANNEL_MODERATOR`); owner only.
pub fn channel_moderator(&mut self, name: &str, player: &str) -> Result<()> {
self.send(
opcode::CMSG_CHANNEL_MODERATOR,
@@ -68,7 +54,7 @@ impl WorldWriter {
)
}
/// Revoke moderator (`CMSG_CHANNEL_UNMODERATOR`, layout in [`messages::channel_unmoderator`]).
/// Revoke moderator (`CMSG_CHANNEL_UNMODERATOR`).
pub fn channel_unmoderator(&mut self, name: &str, player: &str) -> Result<()> {
self.send(
opcode::CMSG_CHANNEL_UNMODERATOR,
@@ -76,8 +62,7 @@ impl WorldWriter {
)
}
/// Mute a player on the channel (`CMSG_CHANNEL_MUTE`, layout in [`messages::channel_mute`]) —
/// moderator-only server-side.
/// Mute a player on the channel (`CMSG_CHANNEL_MUTE`); moderator only.
pub fn channel_mute(&mut self, name: &str, player: &str) -> Result<()> {
self.send(
opcode::CMSG_CHANNEL_MUTE,
@@ -85,7 +70,7 @@ impl WorldWriter {
)
}
/// Unmute a player (`CMSG_CHANNEL_UNMUTE`, layout in [`messages::channel_unmute`]).
/// Unmute a player (`CMSG_CHANNEL_UNMUTE`).
pub fn channel_unmute(&mut self, name: &str, player: &str) -> Result<()> {
self.send(
opcode::CMSG_CHANNEL_UNMUTE,
@@ -93,9 +78,7 @@ impl WorldWriter {
)
}
/// Invite a player to a moderated/private channel (`CMSG_CHANNEL_INVITE`, layout in
/// [`messages::channel_invite`]); level-gated server-side
/// (`CONFIG_UINT32_CHANNEL_INVITE_MIN_LEVEL`).
/// Invite a player to a channel, gated on `CONFIG_UINT32_CHANNEL_INVITE_MIN_LEVEL`.
pub fn channel_invite(&mut self, name: &str, player: &str) -> Result<()> {
self.send(
opcode::CMSG_CHANNEL_INVITE,
@@ -103,8 +86,7 @@ impl WorldWriter {
)
}
/// Kick a player off the channel (`CMSG_CHANNEL_KICK`, layout in [`messages::channel_kick`]) —
/// moderator-only server-side.
/// Kick a player off the channel (`CMSG_CHANNEL_KICK`); moderator only.
pub fn channel_kick(&mut self, name: &str, player: &str) -> Result<()> {
self.send(
opcode::CMSG_CHANNEL_KICK,
@@ -112,8 +94,7 @@ impl WorldWriter {
)
}
/// Ban a player from the channel (`CMSG_CHANNEL_BAN`, layout in [`messages::channel_ban`]) —
/// moderator-only server-side.
/// Ban a player from the channel (`CMSG_CHANNEL_BAN`); moderator only.
pub fn channel_ban(&mut self, name: &str, player: &str) -> Result<()> {
self.send(
opcode::CMSG_CHANNEL_BAN,
@@ -121,7 +102,7 @@ impl WorldWriter {
)
}
/// Unban a player (`CMSG_CHANNEL_UNBAN`, layout in [`messages::channel_unban`]).
/// Unban a player (`CMSG_CHANNEL_UNBAN`).
pub fn channel_unban(&mut self, name: &str, player: &str) -> Result<()> {
self.send(
opcode::CMSG_CHANNEL_UNBAN,
@@ -129,8 +110,7 @@ impl WorldWriter {
)
}
/// Toggle join/leave announcements (`CMSG_CHANNEL_ANNOUNCEMENTS`, layout in
/// [`messages::channel_announcements`]) — owner/moderator-only server-side.
/// Toggle join/leave announcements (`CMSG_CHANNEL_ANNOUNCEMENTS`); owner or moderator only.
pub fn channel_announcements(&mut self, name: &str) -> Result<()> {
self.send(
opcode::CMSG_CHANNEL_ANNOUNCEMENTS,
@@ -138,8 +118,7 @@ impl WorldWriter {
)
}
/// Toggle moderation (speak-restricted-to-moderators) mode (`CMSG_CHANNEL_MODERATE`, layout in
/// [`messages::channel_moderate`]) — owner-only server-side.
/// Toggle moderated mode, where only moderators speak (`CMSG_CHANNEL_MODERATE`); owner only.
pub fn channel_moderate(&mut self, name: &str) -> Result<()> {
self.send(
opcode::CMSG_CHANNEL_MODERATE,
@@ -1,16 +1,5 @@
//! The chat frame's `WorldWriter` sends — every `CMSG_MESSAGECHAT` flavour the slash commands
//! reach (say/yell/emote/whisper/party/raid/guild/officer/the two raid-leader forms/the two
//! battleground forms/AFK/DND/channel), the ignore-list notify, and the three non-`MESSAGECHAT`
//! slash commands whose *answer* is a chat line (`/played`, `/random`, the DBC-indexed `/wave`).
//! Bodies in [`crate::messages`]'s `messagechat*`/`played_time`/`random_roll`/`text_emote`
//! builders; the inbound half is [`crate::messages::chat`]. Split out of `writer/mod.rs`
//! (decision 0636), mirroring [`super::channel`], which owns the channel *administration* verbs
//! (join/leave/moderation) — only the channel *send* lives here.
//!
//! One fact governs the whole family: **every send must speak [`WorldWriter::chat_language`]**, the
//! logged-in character's own tongue. vmangos rejects `Universal` from clients and rejects a tongue
//! the character doesn't know, dropping the whole message — which silently ate every Horde
//! character's dot-commands while this was hardcoded Common.
//! The chat sends. Chat speaks [`WorldWriter::chat_language`]: vmangos drops `Universal` from
//! clients and any tongue the character does not know.
use anyhow::Result;
@@ -19,11 +8,7 @@ use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// Send a chat line as `/say`. Used to issue server **dot commands** (`.tele Westfall`, …) —
/// vmangos parses anything beginning with `.` as a GM command on the way in, but only *after*
/// the language gate: the send must speak [`Self::chat_language`], the character's own tongue
/// (**vmangos rejects `Universal` from clients**, and rejects a tongue the character doesn't
/// know — which silently ate every Horde character's commands while this was hardcoded Common).
/// Send a `/say` line; dot-commands go out this way, parsed after the language gate.
pub fn send_chat(&mut self, message: &str) -> Result<()> {
self.send(
opcode::CMSG_MESSAGECHAT,
@@ -31,8 +16,7 @@ impl WorldWriter {
)
}
/// Send a `/yell` line (`CHAT_MSG_YELL`) — same body shape as [`Self::send_chat`], a different
/// wire chat type (VERIFIED vmangos `SharedDefines.h:1199`).
/// Send a `/yell` line (`CHAT_MSG_YELL`, `SharedDefines.h:1199`).
pub fn send_yell(&mut self, message: &str) -> Result<()> {
self.send(
opcode::CMSG_MESSAGECHAT,
@@ -40,9 +24,7 @@ impl WorldWriter {
)
}
/// Send a custom `/emote <text>` line (`CHAT_MSG_EMOTE`, VERIFIED vmangos
/// `SharedDefines.h:1202`) — distinct from [`Self::text_emote`]'s DBC-indexed `/wave`: the
/// server renders it verbatim as `"PlayerName <text>"`.
/// Send a custom `/emote` line (`CHAT_MSG_EMOTE`), shown verbatim as `"PlayerName <text>"`.
pub fn send_emote_chat(&mut self, message: &str) -> Result<()> {
self.send(
opcode::CMSG_MESSAGECHAT,
@@ -50,10 +32,7 @@ impl WorldWriter {
)
}
/// Send a `/whisper <target> <text>` line (`CHAT_MSG_WHISPER`): body in
/// [`messages::messagechat_whisper`], the one `CMSG_MESSAGECHAT` shape that carries a name
/// ahead of the message (VERIFIED vmangos `Server/Packets/Chat.cpp:3-12`). A bad `target`
/// answers `SMSG_CHAT_PLAYER_NOT_FOUND`, unmodelled here — silent from the client's own POV.
/// Send a `/whisper` line; the body carries the name before the message (`Chat.cpp:3-12`).
pub fn send_whisper(&mut self, target: &str, message: &str) -> Result<()> {
self.send(
opcode::CMSG_MESSAGECHAT,
@@ -61,10 +40,7 @@ impl WorldWriter {
)
}
/// Send a `/p` party line (`CHAT_MSG_PARTY`) — same body shape as [`Self::send_chat`] (VERIFIED
/// vmangos `Handlers/ChatHandler.cpp:472-493`: the server rebroadcasts to the group, no group
/// membership needed on the wire — the server enforces it and silently drops the send if we're
/// not grouped).
/// Send a `/p` party line, dropped silently when ungrouped (`ChatHandler.cpp:472-493`).
pub fn send_party(&mut self, message: &str) -> Result<()> {
self.send(
opcode::CMSG_MESSAGECHAT,
@@ -72,8 +48,7 @@ impl WorldWriter {
)
}
/// Send a `/ra` raid line (`CHAT_MSG_RAID`) — requires a raid group server-side
/// (`Handlers/ChatHandler.cpp:514-536`).
/// Send a `/ra` raid line (`CHAT_MSG_RAID`); needs a raid (`ChatHandler.cpp:514-536`).
pub fn send_raid(&mut self, message: &str) -> Result<()> {
self.send(
opcode::CMSG_MESSAGECHAT,
@@ -81,8 +56,7 @@ impl WorldWriter {
)
}
/// Send a `/g` guild line (`CHAT_MSG_GUILD`) — requires guild membership server-side
/// (`Handlers/ChatHandler.cpp:494-503`).
/// Send a `/g` guild line (`CHAT_MSG_GUILD`); needs a guild (`ChatHandler.cpp:494-503`).
pub fn send_guild(&mut self, message: &str) -> Result<()> {
self.send(
opcode::CMSG_MESSAGECHAT,
@@ -90,8 +64,7 @@ impl WorldWriter {
)
}
/// Send a `/o` guild-officer line (`CHAT_MSG_OFFICER`) — requires guild membership server-side
/// (`Handlers/ChatHandler.cpp:504-513`).
/// Send a `/o` officer line (`CHAT_MSG_OFFICER`); needs a guild (`ChatHandler.cpp:504-513`).
pub fn send_officer(&mut self, message: &str) -> Result<()> {
self.send(
opcode::CMSG_MESSAGECHAT,
@@ -99,8 +72,7 @@ impl WorldWriter {
)
}
/// Send a `/rl` raid-leader line (`CHAT_MSG_RAID_LEADER`) — leader-only server-side
/// (`Handlers/ChatHandler.cpp:538-559`; VERIFIED active for 5875, `> CLIENT_BUILD_1_10_2`).
/// Send a `/rl` raid-leader line, leader only (`ChatHandler.cpp:538-559`).
pub fn send_raid_leader(&mut self, message: &str) -> Result<()> {
self.send(
opcode::CMSG_MESSAGECHAT,
@@ -108,8 +80,7 @@ impl WorldWriter {
)
}
/// Send a `/rw` raid-warning line (`CHAT_MSG_RAID_WARNING`) — leader/assistant-only server-side
/// (`Handlers/ChatHandler.cpp:561-576`).
/// Send a `/rw` raid warning, leader or assistant only (`ChatHandler.cpp:561-576`).
pub fn send_raid_warning(&mut self, message: &str) -> Result<()> {
self.send(
opcode::CMSG_MESSAGECHAT,
@@ -121,8 +92,7 @@ impl WorldWriter {
)
}
/// Send a battleground raid line (`CHAT_MSG_BATTLEGROUND`) — requires a BG group server-side
/// (`Handlers/ChatHandler.cpp:579-593`; VERIFIED active for 5875, `> CLIENT_BUILD_1_11_2`).
/// Send a battleground line; needs a battleground group (`ChatHandler.cpp:579-593`).
pub fn send_battleground(&mut self, message: &str) -> Result<()> {
self.send(
opcode::CMSG_MESSAGECHAT,
@@ -134,8 +104,7 @@ impl WorldWriter {
)
}
/// Send a battleground-leader line (`CHAT_MSG_BATTLEGROUND_LEADER`) — BG-group-leader-only
/// server-side (`Handlers/ChatHandler.cpp:595-609`).
/// Send a battleground-leader line, leader only (`ChatHandler.cpp:595-609`).
pub fn send_battleground_leader(&mut self, message: &str) -> Result<()> {
self.send(
opcode::CMSG_MESSAGECHAT,
@@ -147,9 +116,7 @@ impl WorldWriter {
)
}
/// Toggle AFK (`CHAT_MSG_AFK`, `/afk [message]`) — `message` may be empty (a bare toggle); when
/// non-empty it becomes the AFK auto-reply text (vmangos `Handlers/ChatHandler.cpp:611-630`:
/// `masterPlr->afkMsg`). Setting AFK clears DND server-side (mutually exclusive).
/// Toggle AFK, `message` the auto-reply if any; it clears DND (`ChatHandler.cpp:611-630`).
pub fn send_afk(&mut self, message: &str) -> Result<()> {
self.send(
opcode::CMSG_MESSAGECHAT,
@@ -157,8 +124,7 @@ impl WorldWriter {
)
}
/// Toggle DND (`CHAT_MSG_DND`, `/dnd [message]`) — same shape as [`Self::send_afk`]
/// (`Handlers/ChatHandler.cpp:632-648`); mutually exclusive with AFK server-side.
/// Toggle DND (`CHAT_MSG_DND`), which clears AFK (`ChatHandler.cpp:632-648`).
pub fn send_dnd(&mut self, message: &str) -> Result<()> {
self.send(
opcode::CMSG_MESSAGECHAT,
@@ -166,26 +132,9 @@ impl WorldWriter {
)
}
/// **Send an addon broadcast** (`SendAddonMessage`, decision 1235) — the one `CMSG_MESSAGECHAT`
/// in this module that does **not** speak [`Self::chat_language`].
///
/// `chat_type` is one of the client's four addon lanes (`CHAT_TYPE_PARTY`/`RAID`/`GUILD`/
/// `BATTLEGROUND`); `text` is the already-composed `prefix` TAB `message` payload — this verb
/// does not compose it, because the Lua binding does and the tab's position is the only thing
/// the far client's splitter has to go on.
///
/// The language field carries [`messages::LANGUAGE_ADDON`] (`0xFFFFFFFF`), and that sentinel
/// is the entire difference between addon data and speech: 1.12.1 has no addon opcode.
/// VERIFIED in `WoW.exe` (5875), wow-re `system/ui/scratch/addon-chat-law.md` §5 — the binding
/// `0x49f920` writes opcode `0x95` (`0x49facf`), the chat type (`0x49fad8`), then
/// `or ebx,-0x1` / `push ebx` (`0x49fab9`/`0x49fadd`) into the u32 language write at
/// `0x49fae1`, then the message CString at `0x49faf0`. Corroborated end-to-end against a live
/// vmangos by `examples/addon_chat_probe` (decision 1029): the sentinel and the tab both
/// survive the relay untouched.
///
/// Server-side the line is exempt from the `KnowsLanguage` gate, from flood control and from
/// `SanitizeChatMessage`, and gated instead by the `AddonChannel` config — see
/// [`messages::LANGUAGE_ADDON`] for the whole treatment.
/// Send an addon message (`SendAddonMessage`). Its language, [`messages::LANGUAGE_ADDON`], is
/// the only mark of addon data (`0x49f920`); vmangos gates it on `AddonChannel` and skips the
/// language gate, flood control and sanitizing.
pub fn send_addon_message(&mut self, chat_type: u32, text: &str) -> Result<()> {
self.send(
opcode::CMSG_MESSAGECHAT,
@@ -193,9 +142,7 @@ impl WorldWriter {
)
}
/// Send a `/1`-style channel line (`CHAT_MSG_CHANNEL`) — `channel` is the channel **name**
/// (`Handlers/ChatHandler.cpp:255-327`; body in [`messages::messagechat_channel`]). Requires
/// membership; the server silently drops it if we're not on the channel.
/// Send a channel line by name, dropped unless we are on it (`ChatHandler.cpp:255-327`).
pub fn send_channel(&mut self, channel: &str, message: &str) -> Result<()> {
self.send(
opcode::CMSG_MESSAGECHAT,
@@ -203,31 +150,22 @@ impl WorldWriter {
)
}
/// Tell the server we've added `guid` to our ignore list (`CMSG_CHAT_IGNORED`, a raw 8-byte
/// guid — VERIFIED vmangos `WorldPackets::Misc::ChatIgnored::ReadFromWorldPacket`,
/// `Server/Packets/Misc.cpp:127-130`). The server whispers that player a `CHAT_MSG_IGNORED`
/// self-notice ("So-and-so is now ignoring you") — `Handlers/ChatHandler.cpp:755-763`.
/// Tell the server we ignore `guid`; that player gets a `CHAT_MSG_IGNORED` notice.
pub fn chat_ignored(&mut self, guid: u64) -> Result<()> {
self.send(opcode::CMSG_CHAT_IGNORED, &messages::full_guid(guid))
}
/// Ask our played time (`CMSG_PLAYED_TIME`, empty body, layout in [`messages::played_time`]) —
/// the `/played` command. Answered by `SMSG_PLAYED_TIME` (total + since-last-level-up seconds).
/// Ask our played time (`CMSG_PLAYED_TIME`), answered by `SMSG_PLAYED_TIME`.
pub fn played_time(&mut self) -> Result<()> {
self.send(opcode::CMSG_PLAYED_TIME, &messages::played_time())
}
/// Roll `/random [min] [max]` (`MSG_RANDOM_ROLL`, layout in [`messages::random_roll`]): the
/// server validates `min <= max <= 10000` and broadcasts the result (to our group if we're in
/// one, else just to us) as the same opcode's server→client shape — `min, max, roll, guid`
/// (decoded by the codec into a `RandomRoll` event).
/// Roll `/random`; the server checks `min <= max <= 10000` and answers on the same opcode.
pub fn random_roll(&mut self, min: u32, max: u32) -> Result<()> {
self.send(opcode::MSG_RANDOM_ROLL, &messages::random_roll(min, max))
}
/// Perform a chat emote (`CMSG_TEXT_EMOTE`: EmotesText id + target guid, 0 = untargeted).
/// The server echoes `SMSG_TEXT_EMOTE` to everyone in range **including us**, so our own
/// emote's sound/anim arrive through the same receive path as everyone else's.
/// Perform a text emote (EmotesText id, target or 0), echoed to everyone in range, us included.
pub fn text_emote(&mut self, text_id: u32, target: u64) -> Result<()> {
self.send(
opcode::CMSG_TEXT_EMOTE,
@@ -1,13 +1,5 @@
//! The death/corpse-run family's `WorldWriter` sends — release the spirit, find the corpse,
//! reclaim it, self-resurrect off a soulstone, or take the spirit healer's res; plus the answer
//! to someone else's res offer.
//! Bodies in [`crate::messages`]'s `reclaim_corpse`/`spirit_healer_activate`/`resurrect_response`
//! builders (`repop_request`, `corpse_query` and `self_res` are bodyless). Split out of
//! `writer/mod.rs` (decision 0636), mirroring [`crate::messages::death`].
//!
//! Every one of these is server-gated on a death state the client only *believes* it is in
//! (ghost/unreleased/delay-elapsed/in-range), so a refusal is normal and is not always a packet —
//! the confirmations arrive as ordinary descriptor deltas.
//! The death and corpse-run sends. Each is server-gated on a death state the client only believes
//! it is in, so refusals are normal and not always a packet; success arrives as descriptor deltas.
use anyhow::Result;
@@ -16,32 +8,23 @@ use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// Release the spirit (`CMSG_REPOP_REQUEST`, empty body — decision 0308 slice 1): valid only
/// while dead and unreleased (the server refuses it alive or already-ghost). The server
/// answers with the ghost form (aura 8326 → the ghost flags), the corpse object, unroot,
/// water-walk, `SMSG_CORPSE_RECLAIM_DELAY`, and the graveyard teleport.
/// Release the spirit while dead and unreleased; the server answers with ghost aura 8326, the
/// corpse, `SMSG_CORPSE_RECLAIM_DELAY` and the graveyard teleport.
pub fn repop_request(&mut self) -> Result<()> {
self.send(opcode::CMSG_REPOP_REQUEST, &[])
}
/// Ask where our corpse is (`MSG_CORPSE_QUERY`, empty request): answered by the same opcode
/// (the [`SessionEvent::CorpseQuery`](crate::SessionEvent::CorpseQuery) feed for the map
/// markers + the corpse-run range gate).
/// Ask where our corpse is (`MSG_CORPSE_QUERY`, empty body), answered on the same opcode.
pub fn corpse_query(&mut self) -> Result<()> {
self.send(opcode::MSG_CORPSE_QUERY, &[])
}
/// Self-resurrect (`CMSG_SELF_RES`, empty body — decision 1746): the DEATH popup's second
/// button, offered only while `PLAYER_SELF_RES_SPELL` is non-zero. The server casts that
/// spell on us and zeroes the field; like the reclaim, the success is ordinary descriptor
/// deltas (alive, health/mana per the spell) with no answer packet of its own.
/// Self-resurrect while `PLAYER_SELF_RES_SPELL` is set; the server casts it, with no reply.
pub fn self_res(&mut self) -> Result<()> {
self.send(opcode::CMSG_SELF_RES, &[])
}
/// Reclaim our corpse (`CMSG_RECLAIM_CORPSE` — the RECOVER_CORPSE popup's Accept): the corpse's
/// guid. Server gates: ghost, the reclaim delay elapsed, within 39 yd. Success comes back as
/// ordinary descriptor deltas (alive, ghost flags clear) + the corpse-to-bones swap.
/// Reclaim our corpse as a ghost, past the reclaim delay, within 39 yd.
pub fn reclaim_corpse(&mut self, corpse_guid: u64) -> Result<()> {
self.send(
opcode::CMSG_RECLAIM_CORPSE,
@@ -49,9 +32,7 @@ impl WorldWriter {
)
}
/// Take the spirit healer's resurrection (`CMSG_SPIRIT_HEALER_ACTIVATE` — the XP_LOSS
/// confirm's final Accept): res at 50%, 25% durability loss, resurrection sickness at
/// level ≥ 11. `npc` is the spirit healer's guid (from `SMSG_SPIRIT_HEALER_CONFIRM`).
/// Take the spirit healer's res: 50% health, 25% durability loss, sickness from level 11.
pub fn spirit_healer_activate(&mut self, npc: u64) -> Result<()> {
self.send(
opcode::CMSG_SPIRIT_HEALER_ACTIVATE,
@@ -59,8 +40,7 @@ impl WorldWriter {
)
}
/// Answer a resurrection offer (`CMSG_RESURRECT_RESPONSE` — the RESURRECT popup's
/// Accept/Decline): the offerer's guid + the accept byte.
/// Accept or decline a resurrection offer (`CMSG_RESURRECT_RESPONSE`).
pub fn resurrect_response(&mut self, caster: u64, accept: bool) -> Result<()> {
self.send(
opcode::CMSG_RESURRECT_RESPONSE,
@@ -68,9 +48,7 @@ impl WorldWriter {
)
}
/// Ask a battleground spirit healer for its clock (`CMSG_AREA_SPIRIT_HEALER_QUERY`) — sent
/// when the client adopts a new healer; answered by `SMSG_AREA_SPIRIT_HEALER_TIME`
/// (decision 1963).
/// Ask a new battleground spirit healer's clock, answered by `SMSG_AREA_SPIRIT_HEALER_TIME`.
pub fn area_spirit_healer_query(&mut self, healer: u64) -> Result<()> {
self.send(
opcode::CMSG_AREA_SPIRIT_HEALER_QUERY,
@@ -78,8 +56,7 @@ impl WorldWriter {
)
}
/// Queue for the healer's next wave (`CMSG_AREA_SPIRIT_HEALER_QUEUE`) —
/// `AcceptAreaSpiritHeal()`'s packet, carrying the cached healer (decision 1963).
/// Queue for the healer's next wave, as `AcceptAreaSpiritHeal` sends.
pub fn area_spirit_healer_queue(&mut self, healer: u64) -> Result<()> {
self.send(
opcode::CMSG_AREA_SPIRIT_HEALER_QUEUE,
@@ -1,10 +1,6 @@
//! The duel family's `WorldWriter` sends — the only two the client ever makes (decision 0633).
//!
//! There is no "start a duel" opcode: the challenge goes out as an ordinary `CMSG_CAST_SPELL` of
//! the spellbook spell whose `Effect[0]` is `SPELL_EFFECT_DUEL`, which is why nothing here builds
//! one. Both bodies carry the duel-arbiter guid, byte-verified against WoW.exe's own
//! `AcceptDuel 0x4d4830` / `CancelDuel 0x4d48b0` (bodies in [`crate::messages`]'s `duel_*`
//! builders).
//! The duel sends. There is no start-duel opcode: a challenge is a `CMSG_CAST_SPELL` of the spell
//! whose `Effect[0]` is `SPELL_EFFECT_DUEL`. Both bodies carry the arbiter guid (`AcceptDuel`
//! `0x4d4830`, `CancelDuel` `0x4d48b0`).
use anyhow::Result;
@@ -13,8 +9,7 @@ use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// Accept a duel challenge (`CMSG_DUEL_ACCEPTED`) — the popup's Accept, and the challenger's
/// own immediate auto-accept of the request it just triggered.
/// Accept a duel (`CMSG_DUEL_ACCEPTED`); the challenger also auto-accepts its own request.
pub fn duel_accepted(&mut self, arbiter: u64) -> Result<()> {
self.send(
opcode::CMSG_DUEL_ACCEPTED,
@@ -22,9 +17,7 @@ impl WorldWriter {
)
}
/// Decline or abandon a duel (`CMSG_DUEL_CANCELLED`) — the popup's Decline, a cancel during
/// the countdown, and `/forfeit` once the duel is under way. The server reads the intent from
/// the duel's own state, not from the packet.
/// Decline, cancel or forfeit a duel; the server reads which from the duel's state.
pub fn duel_cancelled(&mut self, arbiter: u64) -> Result<()> {
self.send(
opcode::CMSG_DUEL_CANCELLED,
@@ -1,11 +1,4 @@
//! The GameObject family's `WorldWriter` sends — the single player-facing USE verb and the
//! ask-once template head. Bodies in [`crate::messages::gameobject`], whose scope this mirrors.
//! Split out of `writer/mod.rs` (decision 0636).
//!
//! Two verbs is the whole family by design (decision 0236): the client does not know or care what
//! kind of GameObject it just clicked — one `CMSG_GAMEOBJ_USE` goes out and the *server* fans it
//! out by GO type. Everything the client needs to draw the thing and pick its cursor comes from the
//! template query.
//! The GameObject sends; one `CMSG_GAMEOBJ_USE` serves every kind, dispatched by type.
use anyhow::Result;
@@ -14,20 +7,12 @@ use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// Use a world GameObject (`CMSG_GAMEOBJ_USE`, layout in [`messages::gameobj_use`]) — the single
/// player-facing verb for any usable GO (decision 0236): a full guid naming the chest/door/quest
/// object/lever. The server fans it out by GO type — a chest answers with `SMSG_LOOT_RESPONSE`
/// (the loot window), a questgiver GO with the gossip/quest packets, a door with a
/// `GAMEOBJECT_STATE` flip via `UPDATE_OBJECT` — or refuses silently (out of range, locked, no
/// quest). There is no dedicated success reply.
/// Use a world GameObject; the server answers by type or refuses silently, with no ack.
pub fn gameobj_use(&mut self, guid: u64) -> Result<()> {
self.send(opcode::CMSG_GAMEOBJ_USE, &messages::gameobj_use(guid))
}
/// Ask for a GameObject template's type/display/name/`data[24]` head (`CMSG_GAMEOBJECT_QUERY`:
/// entry + guid — the ask-once template lookup, decision 0236). The `entry` is the one embedded
/// in the GameObject's guid bits 24–47 ([`crate::guid::entry`]), the same convention as
/// [`WorldWriter::creature_query`]. Answered by `SMSG_GAMEOBJECT_QUERY_RESPONSE`.
/// Ask a GameObject template; `entry` is the guid's bits 24-47 ([`crate::guid::entry`]).
pub fn gameobject_query(&mut self, entry: u32, guid: u64) -> Result<()> {
self.send(
opcode::CMSG_GAMEOBJECT_QUERY,
@@ -35,12 +20,7 @@ impl WorldWriter {
)
}
/// Ask for one page of a book (`CMSG_PAGE_TEXT_QUERY`, layout in
/// [`messages::page_text_query`]) — the ask-once page cache a readable reaches when the reader
/// opens on it (decision 1105). `guid` names the asking object (the book item or the
/// `GAMEOBJECT_TYPE_TEXT` world object); the server resolves the page from the id alone.
/// Answered by `SMSG_PAGE_TEXT_QUERY_RESPONSE` — once per page of the whole chain, not just
/// the one asked for.
/// Ask a book page; the server resolves it by id alone and answers each page of the chain.
pub fn page_text_query(&mut self, page_id: u32, guid: u64) -> Result<()> {
self.send(
opcode::CMSG_PAGE_TEXT_QUERY,
@@ -1,19 +1,9 @@
//! The Help window's five sends — the whole client half of the GM trouble-ticket flow
//! (decision 1673).
//! The GM ticket sends. The client holds no ticket state; the server's answers are the only truth.
//!
//! Three of the five have empty bodies, which is the shape of the feature: the client holds no
//! ticket state of its own, so "show me my ticket", "abandon it" and "is the queue up?" are each a
//! bare opcode and the server's answer is the only truth. The two that carry a body — file and
//! edit — are byte-verified against the real client's own senders (`0x5ef740` / `0x5efac0`); see
//! [`crate::messages::gm_ticket`] for why the category is a single byte in both.
//!
//! **Nothing here may be retried on a timeout.** vmangos answers several refusals with silence
//! rather than a response code (queue off, under `GMTickets.MinLevel`, category >= 11 —
//! `GMTicketHandler.cpp:91,106-113`), and it treats more than two `CMSG_GMTICKET_UPDATETEXT` in a
//! single world tick as flooding, whose default sanction is a **kick**
//! (`WorldSession.cpp:1316-1342`, `Antiflood.Sanction = 8`). So a client that re-sent an edit
//! because no answer came would disconnect itself. The Help window asks once per click, exactly as
//! the reference does.
//! Never retry one on a timeout: vmangos refuses several silently (queue off, under
//! `GMTickets.MinLevel`, category >= 11, `GMTicketHandler.cpp:91,106-113`), and more than two
//! `CMSG_GMTICKET_UPDATETEXT` in one world tick is flooding, kicked by default
//! (`WorldSession.cpp:1316-1342`).
use anyhow::Result;
@@ -22,12 +12,9 @@ use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// File a new ticket (`CMSG_GMTICKET_CREATE`) — the Help window's Submit.
///
/// `category` is a `GMTicketCategory.dbc` id (1..10, the clicked row); `map`/`pos` are where
/// the player is standing, which is what lets a GM `.tele` to the reported spot. Answered by
/// `SMSG_GMTICKET_CREATE` — **or by nothing at all**, on the several vmangos paths that refuse
/// silently (module doc).
/// File a ticket (`CMSG_GMTICKET_CREATE`, sender `0x5ef740`): `category` is a
/// `GMTicketCategory.dbc` id 1-10, `map` and `pos` where the player stands. Answered by
/// `SMSG_GMTICKET_CREATE`, or by silence.
pub fn gm_ticket_create(
&mut self,
category: u8,
@@ -41,11 +28,7 @@ impl WorldWriter {
)
}
/// Edit the open ticket's text (`CMSG_GMTICKET_UPDATETEXT`) — the Help window's "Save Changes".
///
/// Carries the category byte the client carries even though vmangos reads and discards it; the
/// category of an existing ticket cannot actually be changed this way. Answered by
/// `SMSG_GMTICKET_UPDATETEXT`. **Rate-limited server-side** — see the module doc.
/// Edit the open ticket's text (sender `0x5efac0`); vmangos discards the category byte.
pub fn gm_ticket_updatetext(&mut self, category: u8, text: &str) -> Result<()> {
self.send(
opcode::CMSG_GMTICKET_UPDATETEXT,
@@ -53,26 +36,18 @@ impl WorldWriter {
)
}
/// Ask for the open ticket (`CMSG_GMTICKET_GETTICKET`, empty body) — `GetGMTicket()`.
///
/// The client's own poll: once on world entry and every 10 minutes while the ticket status
/// toast is up. Answered by `SMSG_GMTICKET_GETTICKET`, and on vmangos **also** by an
/// unsolicited `SMSG_QUERY_TIME_RESPONSE` — its handler calls `SendQueryTimeResponse()` as its
/// first statement (`GMTicketHandler.cpp:34`), so two packets come back for this one request.
/// Ask for the open ticket, on world entry and every 10 minutes with the toast up; vmangos also
/// sends an unsolicited `SMSG_QUERY_TIME_RESPONSE` (`GMTicketHandler.cpp:34`).
pub fn gm_ticket_get(&mut self) -> Result<()> {
self.send(opcode::CMSG_GMTICKET_GETTICKET, &[])
}
/// Abandon the open ticket (`CMSG_GMTICKET_DELETETICKET`, empty body) — the
/// `HELP_TICKET_ABANDON_CONFIRM` dialog's Yes. Answered by `SMSG_GMTICKET_DELETETICKET`
/// carrying 9, or — with no ticket to delete — by nothing at all on vmangos.
/// Abandon the open ticket; the reply carries 9, and vmangos sends none if there is no ticket.
pub fn gm_ticket_delete(&mut self) -> Result<()> {
self.send(opcode::CMSG_GMTICKET_DELETETICKET, &[])
}
/// Ask whether the petition queue is taking tickets (`CMSG_GMTICKET_SYSTEMSTATUS`, empty body)
/// — `GetGMStatus()`, which the Help window calls from its own OnShow. Answered by
/// `SMSG_GMTICKETSYSTEMSTATUS`.
/// Ask whether the ticket queue is open, as `GetGMStatus` does when the Help window shows.
pub fn gm_ticket_system_status(&mut self) -> Result<()> {
self.send(opcode::CMSG_GMTICKET_SYSTEMSTATUS, &[])
}
@@ -1,11 +1,5 @@
//! The gossip family's `WorldWriter` sends — open a menu, choose an option, and fetch a menu's
//! greeting text. Bodies in [`crate::messages::gossip`], whose scope this mirrors. Split out of
//! `writer/mod.rs` (decision 0636).
//!
//! `CMSG_GOSSIP_HELLO` is the front door to every other NPC service window — vendor, trainer,
//! bank, taxi, quest — because the server passes `UNIT_NPC_FLAG_NONE` for this opcode (vmangos
//! `CanInteractWithNPC`, `Player.cpp:347`), so it works on any interactable creature, not only
//! gossip-flagged ones.
//! The gossip sends. `CMSG_GOSSIP_HELLO` opens every NPC service window: vmangos accepts it for any
//! interactable creature (`CanInteractWithNPC` with `UNIT_NPC_FLAG_NONE`, `Player.cpp:347`).
use anyhow::Result;
@@ -14,18 +8,12 @@ use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// Open a gossip menu on an NPC (`CMSG_GOSSIP_HELLO`, layout in [`messages::gossip_hello`]) —
/// works on any interactable creature, not only gossip-flagged ones (vmangos
/// `CanInteractWithNPC`, `Player.cpp:347`, passes `UNIT_NPC_FLAG_NONE` for this opcode).
/// Answered by `SMSG_GOSSIP_MESSAGE` (a `GossipMenu` event).
/// Open a gossip menu on an NPC (`CMSG_GOSSIP_HELLO`), answered by `SMSG_GOSSIP_MESSAGE`.
pub fn gossip_hello(&mut self, npc_guid: u64) -> Result<()> {
self.send(opcode::CMSG_GOSSIP_HELLO, &messages::gossip_hello(npc_guid))
}
/// Choose a gossip option (`CMSG_GOSSIP_SELECT_OPTION`, layout in
/// [`messages::gossip_select_option`]): `gossip_list_id` is the option's echoed `index`; `code`
/// carries a password only for a `coded` option, omitted entirely otherwise. The server answers
/// either a fresh `SMSG_GOSSIP_MESSAGE` (a sub-menu) or `SMSG_GOSSIP_COMPLETE`.
/// Choose a gossip option by its echoed `index`; `code` is sent only for a coded option.
pub fn gossip_select_option(
&mut self,
npc_guid: u64,
@@ -38,9 +26,7 @@ impl WorldWriter {
)
}
/// Ask for a gossip menu's greeting text (`CMSG_NPC_TEXT_QUERY`, layout in
/// [`messages::npc_text_query`]) — sent on receiving a gossip menu's `text_id`. Answered by
/// `SMSG_NPC_TEXT_UPDATE` (an `NpcGreeting` event); ask-once cacheable like an item template.
/// Ask a gossip menu's greeting text (`CMSG_NPC_TEXT_QUERY`); ask once and cache.
pub fn npc_text_query(&mut self, text_id: u32, guid: u64) -> Result<()> {
self.send(
opcode::CMSG_NPC_TEXT_QUERY,
@@ -1,9 +1,4 @@
//! The group/party family's `WorldWriter` sends — invite/accept/decline/kick/leader/disband, loot
//! method, party-stats asks, raid subgroup management, minimap pings, raid-target icons, and ready
//! checks (bodies in [`crate::messages`]'s `group_*`/`loot_method`/`minimap_ping`/`raid_target_*`/
//! `ready_check_*` builders; layout VERIFIED against vmangos `Server/Packets/Group.{h,cpp}`,
//! `Handlers/GroupHandler.cpp`). Split out of `writer/mod.rs`, mirroring [`super::channel`] — the
//! group commands are one clearly separable concern among the writer's many domains.
//! The group and raid sends.
use anyhow::Result;
@@ -12,8 +7,7 @@ use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// Invite a player to group (`CMSG_GROUP_INVITE`, layout in [`messages::group_invite`]).
/// Answered by `SMSG_PARTY_COMMAND_RESULT`; the invited player sees `SMSG_GROUP_INVITE`.
/// Invite a player to group, answered by `SMSG_PARTY_COMMAND_RESULT`.
pub fn group_invite(&mut self, member_name: &str) -> Result<()> {
self.send(
opcode::CMSG_GROUP_INVITE,
@@ -26,13 +20,12 @@ impl WorldWriter {
self.send(opcode::CMSG_GROUP_ACCEPT, &messages::group_accept())
}
/// Decline a pending group invite (`CMSG_GROUP_DECLINE`, empty body); the inviter sees
/// `SMSG_GROUP_DECLINE`.
/// Decline a pending group invite; the inviter gets `SMSG_GROUP_DECLINE`.
pub fn group_decline(&mut self) -> Result<()> {
self.send(opcode::CMSG_GROUP_DECLINE, &messages::group_decline())
}
/// Kick a group member by name (`CMSG_GROUP_UNINVITE`, layout in [`messages::group_uninvite`]).
/// Kick a group member by name (`CMSG_GROUP_UNINVITE`).
pub fn group_uninvite(&mut self, member_name: &str) -> Result<()> {
self.send(
opcode::CMSG_GROUP_UNINVITE,
@@ -40,7 +33,7 @@ impl WorldWriter {
)
}
/// Kick a group member by guid (`CMSG_GROUP_UNINVITE_GUID`) — the raid-frame right-click kick.
/// Kick a group member by guid (`CMSG_GROUP_UNINVITE_GUID`), the raid frame's kick.
pub fn group_uninvite_guid(&mut self, guid: u64) -> Result<()> {
self.send(
opcode::CMSG_GROUP_UNINVITE_GUID,
@@ -48,7 +41,7 @@ impl WorldWriter {
)
}
/// Hand off group leadership (`CMSG_GROUP_SET_LEADER`, a full guid — the 1.12 wire form).
/// Hand off group leadership (`CMSG_GROUP_SET_LEADER`, a full guid in 1.12).
pub fn group_set_leader(&mut self, guid: u64) -> Result<()> {
self.send(
opcode::CMSG_GROUP_SET_LEADER,
@@ -56,10 +49,9 @@ impl WorldWriter {
)
}
/// Set the group's loot method (`CMSG_LOOT_METHOD`, layout in [`messages::loot_method`]):
/// `method` is `0` free-for-all / `1` round-robin / `2` master / `3` group / `4`
/// need-before-greed; `loot_master` is the master looter's guid (ignored unless `method ==
/// 2`); `threshold` is an `ItemQualities` value.
/// Set the group's loot method (`CMSG_LOOT_METHOD`): `method` 0 free-for-all, 1 round-robin,
/// 2 master (the only one that reads `loot_master`), 3 group, 4 need-before-greed;
/// `threshold` is an `ItemQualities` value.
pub fn loot_method(&mut self, method: u32, loot_master: u64, threshold: u32) -> Result<()> {
self.send(
opcode::CMSG_LOOT_METHOD,
@@ -72,8 +64,7 @@ impl WorldWriter {
self.send(opcode::CMSG_GROUP_DISBAND, &messages::group_disband())
}
/// Ask a party/raid member's full live stats (`CMSG_REQUEST_PARTY_MEMBER_STATS`, a full guid).
/// Answered by `SMSG_PARTY_MEMBER_STATS_FULL` (or its offline-miss shape).
/// Ask a member's live stats, answered by `SMSG_PARTY_MEMBER_STATS_FULL`.
pub fn request_party_member_stats(&mut self, guid: u64) -> Result<()> {
self.send(
opcode::CMSG_REQUEST_PARTY_MEMBER_STATS,
@@ -81,8 +72,7 @@ impl WorldWriter {
)
}
/// Move a raid member to a different subgroup (`CMSG_GROUP_CHANGE_SUB_GROUP`, layout in
/// [`messages::group_change_sub_group`]) — raid-only drag-drop between subgroup columns.
/// Move a raid member to another subgroup (`CMSG_GROUP_CHANGE_SUB_GROUP`).
pub fn group_change_sub_group(&mut self, name: &str, group_nr: u8) -> Result<()> {
self.send(
opcode::CMSG_GROUP_CHANGE_SUB_GROUP,
@@ -90,8 +80,7 @@ impl WorldWriter {
)
}
/// Swap two raid members' subgroups (`CMSG_GROUP_SWAP_SUB_GROUP`, layout in
/// [`messages::group_swap_sub_group`]).
/// Swap two raid members' subgroups (`CMSG_GROUP_SWAP_SUB_GROUP`).
pub fn group_swap_sub_group(&mut self, name: &str, swap_with: &str) -> Result<()> {
self.send(
opcode::CMSG_GROUP_SWAP_SUB_GROUP,
@@ -99,8 +88,7 @@ impl WorldWriter {
)
}
/// Convert the party into a raid (`CMSG_GROUP_RAID_CONVERT`, empty body) — leader-only,
/// one-way.
/// Convert the party into a raid (`CMSG_GROUP_RAID_CONVERT`, empty body); leader only, one-way.
pub fn group_raid_convert(&mut self) -> Result<()> {
self.send(
opcode::CMSG_GROUP_RAID_CONVERT,
@@ -108,8 +96,7 @@ impl WorldWriter {
)
}
/// Grant or revoke raid-assistant on a member (`CMSG_GROUP_ASSISTANT_LEADER`, layout in
/// [`messages::group_assistant_leader`]).
/// Grant or revoke raid assistant (`CMSG_GROUP_ASSISTANT_LEADER`).
pub fn group_assistant_leader(&mut self, guid: u64, grant: bool) -> Result<()> {
self.send(
opcode::CMSG_GROUP_ASSISTANT_LEADER,
@@ -117,14 +104,12 @@ impl WorldWriter {
)
}
/// Ping the minimap for the group (`MSG_MINIMAP_PING`, layout in [`messages::minimap_ping`]) —
/// the server stamps our guid and relays it to the rest of the group.
/// Ping the minimap (`MSG_MINIMAP_PING`); the server adds our guid and relays it to the group.
pub fn minimap_ping(&mut self, x: f32, y: f32) -> Result<()> {
self.send(opcode::MSG_MINIMAP_PING, &messages::minimap_ping(x, y))
}
/// Set (or clear, `guid == 0`) one raid-target icon (`MSG_RAID_TARGET_UPDATE`, layout in
/// [`messages::raid_target_set`]).
/// Set one raid-target icon, or clear it with `guid == 0` (`MSG_RAID_TARGET_UPDATE`).
pub fn raid_target_set(&mut self, icon: u8, guid: u64) -> Result<()> {
self.send(
opcode::MSG_RAID_TARGET_UPDATE,
@@ -132,8 +117,7 @@ impl WorldWriter {
)
}
/// Ask the current raid-target icon set (`MSG_RAID_TARGET_UPDATE`, layout in
/// [`messages::raid_target_request`]). Answered by the server's full-list shape.
/// Ask the raid-target icons (`MSG_RAID_TARGET_UPDATE`), answered with the full list.
pub fn raid_target_request(&mut self) -> Result<()> {
self.send(
opcode::MSG_RAID_TARGET_UPDATE,
@@ -141,13 +125,12 @@ impl WorldWriter {
)
}
/// Start a raid ready check (`MSG_RAID_READY_CHECK`, empty body) — leader-only.
/// Start a raid ready check (`MSG_RAID_READY_CHECK`, empty body); leader only.
pub fn ready_check_start(&mut self) -> Result<()> {
self.send(opcode::MSG_RAID_READY_CHECK, &messages::ready_check_start())
}
/// Answer a raid ready check (`MSG_RAID_READY_CHECK`, layout in
/// [`messages::ready_check_answer`]).
/// Answer a raid ready check (`MSG_RAID_READY_CHECK`).
pub fn ready_check_answer(&mut self, ready: bool) -> Result<()> {
self.send(
opcode::MSG_RAID_READY_CHECK,
@@ -155,8 +138,7 @@ impl WorldWriter {
)
}
/// Ask for our saved-instance list (`CMSG_REQUEST_RAID_INFO`, empty body — decision 1549's
/// Raid Info panel). Answered by `SMSG_RAID_INSTANCE_INFO`.
/// Ask our saved-instance list, answered by `SMSG_RAID_INSTANCE_INFO`.
pub fn request_raid_info(&mut self) -> Result<()> {
self.send(
opcode::CMSG_REQUEST_RAID_INFO,