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