- dialogs carry the reference's toplevel bit, so the stack-split window opens above Bagnon instead of behind it - the failure line fills its blank — "Requires Blacksmith Hammer", not "Requires" — and Opening casts stop printing a label the spell itself marks "no text" - the pet bar's Attack/Follow/Stay tooltips name their keybinding, and typed text reaches the focused frame through the client's own delivery walk - AtlasLoot parses again: our Lua keeps 5.0's compatibility semicolon Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1131 lines
66 KiB
Rust
1131 lines
66 KiB
Rust
//! Decoded session events — the codec's typed output.
|
||
//!
|
||
//! `benilla-protocol` decodes the world stream into a flat list of [`SessionEvent`]s; the running
|
||
//! world model (which entities exist, where they are, the clock) is the **app's** job (the ECS), not
|
||
//! the codec's. One `SMSG_(COMPRESSED_)UPDATE_OBJECT` fans out into many create/move/remove events; a
|
||
//! `SMSG_MONSTER_MOVE` becomes a path the app turns into a spline; the clock packet becomes a time
|
||
//! sample the app advances. Only primitives + the coarse [`EntityKind`] classification cross this
|
||
//! boundary.
|
||
//!
|
||
//! Coordinates stay **raw WoW** (the `benilla` boundary applies `bevy = (-y, z, -x)`).
|
||
|
||
use crate::messages::{
|
||
ActionButton, AttackerState, ChannelNoticeTail, Character, CreateSpline, DamageShield,
|
||
EnvironmentalDamageLog, ExplorationXp, FriendEntry, FriendStatusUpdate, GossipOption,
|
||
GroupLootInfo, GroupMemberEntry, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo,
|
||
LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart,
|
||
MonsterMoveFacing, ObjectFields, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells,
|
||
QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward, QuestRequestItems,
|
||
QuestTemplate, SpellDamageLog, SpellEnergizeLog, SpellHealLog, SpellLogMiss, TaxiMask,
|
||
TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain,
|
||
};
|
||
|
||
/// Coarse entity classification, free of wire types so the app can branch on it without depending on
|
||
/// the message layer. Part of the codec's output vocabulary (carried on [`SessionEvent::ObjectCreate`]).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
pub enum EntityKind {
|
||
Player,
|
||
Unit,
|
||
GameObject,
|
||
/// A `TYPEID_DYNAMICOBJECT` (6) create — the invisible anchor a ground-targeted spell's
|
||
/// area effect hangs on (Blizzard's storm, Flamestrike's burn). Carries no display id; its
|
||
/// visual resolves through the spell chain (`DYNAMICOBJECT_SPELLID`), not a model field.
|
||
DynamicObject,
|
||
Other,
|
||
}
|
||
|
||
/// Which character-management verb produced a [`SessionEvent::CharActionResult`] — so the screen can
|
||
/// phrase the outcome ("created"/"deleted") and map the `WorldResult` code against the right family.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum CharAction {
|
||
Create,
|
||
Delete,
|
||
}
|
||
|
||
/// A unit's 6 movement speeds (yd/s), decoded from its `LIVING` block. The animation selector keys its
|
||
/// walk-vs-run boundary on `walk` (2× it → run, RF-0057); the net bridge extrapolates a remote mover
|
||
/// between movement packets at `run`/`run_back`/`swim` and turns it in place at `turn_rate` (rad/s).
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||
pub struct MoveSpeeds {
|
||
pub walk: f32,
|
||
pub run: f32,
|
||
pub run_back: f32,
|
||
pub swim: f32,
|
||
pub swim_back: f32,
|
||
pub turn_rate: f32,
|
||
}
|
||
|
||
impl MoveSpeeds {
|
||
/// From the wire order `[walk, run, run_back, swim, swim_back, turn_rate]` (RF-0058).
|
||
fn from_wire(s: [f32; 6]) -> Self {
|
||
Self {
|
||
walk: s[0],
|
||
run: s[1],
|
||
run_back: s[2],
|
||
swim: s[3],
|
||
swim_back: s[4],
|
||
turn_rate: s[5],
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Where a login attempt currently is — the IO thread emits these as it walks the pre-roster
|
||
/// sequence (decision 0539), and the login screen's connecting dialog quotes the matching
|
||
/// `LOGIN_STATE_*` glue string for each.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum LoginStage {
|
||
/// Dialing the auth server (`LOGIN_STATE_CONNECTING`).
|
||
Connecting,
|
||
/// SRP6 challenge/proof in flight (`LOGIN_STATE_AUTHENTICATING`).
|
||
Authenticating,
|
||
/// Realm picked; the world-socket handshake + char enum (`LOGIN_STATE_HANDSHAKING`).
|
||
Handshaking,
|
||
}
|
||
|
||
/// **How a world session ended** — the fact [`SessionEvent::Disconnected`] carries alongside its
|
||
/// reason string, and the one the client's whole post-session behaviour hangs on (decision 1262).
|
||
///
|
||
/// The two are not shades of the same thing. A logout is a door the player walked through, and the
|
||
/// roster on the other side of it is the character-select screen they asked for. A loss is a door
|
||
/// that slammed: the reference's engine fires `DISCONNECTED_FROM_SERVER`, and its `GlueParent.lua`
|
||
/// answers `SetGlueScreen("login")` + `GlueDialog_Show("DISCONNECTED")` — back to the account
|
||
/// screen, one Okay button, no retry. Before this the app told them apart by comparing the reason
|
||
/// *string* to `"logged out"`, which is how "the session died" and "the session ended" came to share
|
||
/// one code path.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum SessionEnd {
|
||
/// A clean, client-initiated logout the server confirmed (`SMSG_LOGOUT_COMPLETE`). The IO
|
||
/// thread cycles the connection and a fresh [`SessionEvent::CharacterList`] follows — the
|
||
/// teardown here is bookkeeping between two halves of one continuous session.
|
||
LoggedOut,
|
||
/// The stream died under us: a socket error, an EOF, or a post-roster handshake failure. A
|
||
/// **displacement kick is exactly this** and carries nothing else — vmangos's
|
||
/// `WorldSession::KickPlayer` is a bare `CloseSocket()`, no packet (verified in
|
||
/// `vmangos-core/src/game/Server/WorldSession.cpp`), so "another client took the account" and
|
||
/// "the server went away" are the same event on the wire and get the same answer.
|
||
Lost,
|
||
}
|
||
|
||
/// One decoded event from the world stream. Carries only primitives + the coarse [`EntityKind`]
|
||
/// classification — no wire types leak to the app, and no running state lives here.
|
||
#[derive(Debug, Clone)]
|
||
pub enum SessionEvent {
|
||
/// A login attempt progressed to `stage` (decision 0539) — IO-thread-emitted, like
|
||
/// [`Self::CharacterList`], never wire-decoded.
|
||
LoginStage { stage: LoginStage },
|
||
/// A login attempt failed before the roster (decision 0539): `code` is the server's auth
|
||
/// result byte ([`crate::auth::AuthReject`]) when the server *refused* us, `None` for a
|
||
/// transport failure (dial/handshake error). The IO thread is re-parked pre-logon; the app
|
||
/// decides whether to resubmit (transport, with intent) or surface the dialog (refusal).
|
||
/// `terminal` marks a failure that *retrying cannot fix* even though it carries no auth code —
|
||
/// the server is simply unusable by this client (e.g. [`crate::WardenRequired`]). The app must
|
||
/// surface `reason` and stop, never fold it into the paced-resubmit path.
|
||
LoginFailed {
|
||
code: Option<u8>,
|
||
reason: String,
|
||
terminal: bool,
|
||
},
|
||
/// The account's character roster (`SMSG_CHAR_ENUM`): the world socket is authenticated and
|
||
/// parked at character select. The IO thread emits this each connection cycle, then waits for
|
||
/// the app's pick (its pick channel) before `CMSG_PLAYER_LOGIN` moves the session into the world.
|
||
/// `realm` is the auth realm-list entry this session connected to (the select screen's realm
|
||
/// name + type banner) — `None` only when the packet arrives outside the IO thread's own emit
|
||
/// (the raw [`crate::decode`] path has no auth context).
|
||
CharacterList {
|
||
characters: Vec<Character>,
|
||
realm: Option<crate::RealmInfo>,
|
||
},
|
||
/// The result of a character create or delete the IO thread serviced in place while parked at
|
||
/// select (`SMSG_CHAR_CREATE` / `SMSG_CHAR_DELETE` result byte — decision 0423). On a successful
|
||
/// create/delete a fresh [`Self::CharacterList`] is emitted *before* this, so the roster is
|
||
/// already up to date when the screen shows the outcome. `code` is the raw `WorldResult`
|
||
/// (`CHAR_CREATE_SUCCESS`, a `CHAR_NAME_*` code, …) for the app to map to its status text.
|
||
CharActionResult { action: CharAction, code: u8 },
|
||
/// We are in the world: `self_guid` is our player, `name` its character name. The IO thread emits
|
||
/// this first, before any object updates.
|
||
Connected { self_guid: u64, name: String },
|
||
/// The server confirmed our logout (`SMSG_LOGOUT_COMPLETE`) — we are back at character select.
|
||
/// The IO thread cycles the connection immediately; a fresh [`Self::CharacterList`] follows.
|
||
LoggedOut,
|
||
/// The server answered `CMSG_LOGOUT_REQUEST` (`SMSG_LOGOUT_RESPONSE`) — and this is where the
|
||
/// camp countdown comes from (decision 0674). `reason` non-zero is a refusal (vmangos: 1 in
|
||
/// combat, 3 jumping/falling, 2 GM-frozen) and nothing further happens; `reason == 0` starts a
|
||
/// logout that is either `instant` — [`Self::LoggedOut`] follows at once (resting, on a taxi,
|
||
/// or a GM account) — or a 20-second server-side timer the client narrates.
|
||
LogoutResponse { reason: u32, instant: bool },
|
||
/// The server dropped a pending logout at our `CMSG_LOGOUT_CANCEL` (`SMSG_LOGOUT_CANCEL_ACK`).
|
||
LogoutCancelled,
|
||
/// The session ended; carries a human-readable reason and **how** it ended ([`SessionEnd`]).
|
||
Disconnected { reason: String, end: SessionEnd },
|
||
/// An object entered range / was created: a unit, player, or GameObject at a raw-WoW pose. Carries
|
||
/// the interpreted spawn identity (`display_id`/`scale`, decoded per object type here so the app
|
||
/// needn't know the wire layout) plus the full descriptor `fields` mask the ECS seeds its per-object
|
||
/// store from (decision 0061).
|
||
ObjectCreate {
|
||
guid: u64,
|
||
kind: EntityKind,
|
||
/// The model display id (`UNIT_FIELD_DISPLAYID` / `GAMEOBJECT_DISPLAYID`), interpreted per type;
|
||
/// `None` when absent/zero. Present raw in `fields` too, but decoded here (unit-vs-GameObject).
|
||
display_id: Option<u32>,
|
||
position: [f32; 3],
|
||
orientation: f32,
|
||
/// `OBJECT_FIELD_SCALE_X` — the per-object size multiplier (see [`object_scale`]). `1.0` when
|
||
/// absent; for a GameObject this *is* its size (no DBC scale column in 5875).
|
||
scale: f32,
|
||
/// The unit's 6 movement speeds from its `LIVING` block; `None` for a GameObject. Seeds the
|
||
/// animation selector's walk-vs-run boundary (RF-0057) and the net bridge's remote-mover
|
||
/// extrapolation. (Movement-block data, not a descriptor field — hence not in `fields`.)
|
||
speeds: Option<MoveSpeeds>,
|
||
/// A transport GameObject's (`UPDATE_FLAG_TRANSPORT`) path-progress anchor — `Some` only for a
|
||
/// type-15/type-11 transport create (a boat/zeppelin/elevator). Decision 0438's cycle anchor:
|
||
/// `anchor = this value`, `t₀ = Instant::now()` on create/re-create; per frame `progress =
|
||
/// anchor + elapsed_ms`, position = `timetable(progress % period)`.
|
||
transport_progress: Option<u32>,
|
||
/// This object's own rider pose on a transport (`MOVEFLAG_ON_TRANSPORT` in its `LIVING` block) —
|
||
/// `Some` when a freshly-created unit/player is standing on a boat/zeppelin/elevator at create
|
||
/// time, local to that transport's frame (decision 0438).
|
||
transport: Option<TransportPose>,
|
||
/// The path this unit was **already walking** when it streamed into view
|
||
/// (`MOVEFLAG_SPLINE_ENABLED` in its `LIVING` block) — the same travel-order polyline as
|
||
/// [`Self::MonsterMove`], plus the milliseconds of it the server has already ridden, so the app
|
||
/// can join the walk mid-path. Dropping it is what froze every creature that happened to be in
|
||
/// motion at first sight, until its next `MonsterMove` teleported it forward (decision 0708).
|
||
spline: Option<CreateSpline>,
|
||
/// The object's full descriptor field set from the create mask — health/level/appearance/
|
||
/// customization and everything else. The ECS seeds its `ObjectStore` from this, then merges
|
||
/// later [`Self::ObjectValues`] deltas into it (decision 0061).
|
||
fields: ObjectFields,
|
||
},
|
||
/// An item or container entered our view: our own inventory streaming in at login, a looted drop,
|
||
/// a traded bag. A pure **descriptor** object — its create block carries no pose (wire-verified:
|
||
/// vmangos sends `UPDATEFLAG_ALL` + a constant, nothing spatial), so it never becomes a scene
|
||
/// entity. The app seeds its item store from `fields` and merges later [`Self::ObjectValues`] /
|
||
/// removes by guid, exactly like the scene store. Which *slot* holds the item is not here — that
|
||
/// lives in the owning player/bag descriptor (`PLAYER_FIELD_INV_SLOT`/`PACK_SLOT`,
|
||
/// `CONTAINER_FIELD_SLOT`) as this guid.
|
||
ItemCreate {
|
||
guid: u64,
|
||
/// `true` for a container (a bag — its fields carry `CONTAINER_FIELD_SLOT_*` contents),
|
||
/// `false` for a plain item.
|
||
container: bool,
|
||
/// The item's full descriptor set: `OBJECT_FIELD_ENTRY` (→ the template for name/icon via
|
||
/// `ITEM_QUERY_SINGLE`), stack count, durability, enchants; container slots when `container`.
|
||
fields: ObjectFields,
|
||
},
|
||
/// An existing object moved to a new authoritative pose (supersedes any active path). From an
|
||
/// `SMSG_UPDATE_OBJECT` movement block — a one-off correction/relocation, not live locomotion.
|
||
ObjectMove {
|
||
guid: u64,
|
||
position: [f32; 3],
|
||
orientation: f32,
|
||
},
|
||
/// A relayed *player* movement packet (`MSG_MOVE_*`): the mover's authoritative pose plus its live
|
||
/// CMovement `moveFlags`. This is how another player's continuous walking/turning/strafing reaches
|
||
/// us (creatures move via [`Self::MonsterMove`] splines instead). The app snaps to `position` and
|
||
/// extrapolates from `flags` between packets so motion stays smooth at the ~2 Hz relay rate.
|
||
/// `fall_time` (ms airborne) + `jump` (the ballistic launch) are present while `JUMPING` is set, so
|
||
/// the app replays a jump as one arc instead of stair-stepping its height (decision 0053).
|
||
/// `transport` is `Some` while `MOVEFLAG_ON_TRANSPORT` is set: the mover's local pose on the named
|
||
/// transport, for an observed rider to re-anchor through that transport's live matrix (decision
|
||
/// 0438) rather than snap to `position` as a world coordinate.
|
||
UnitMove {
|
||
guid: u64,
|
||
position: [f32; 3],
|
||
orientation: f32,
|
||
flags: u32,
|
||
/// The swim pitch (radians, +up) — the mover's 3D travel angle while `MOVEFLAG_SWIMMING`
|
||
/// is set (`0.0` otherwise): the wire tail exists so observers can integrate a swimmer's
|
||
/// vertical between packets (the client's swim velocity basis adds pitch, `0x7c5880`).
|
||
pitch: f32,
|
||
/// The `MovementInfo` time word — vmangos's own ms clock stamped at receipt (`stime`
|
||
/// = `WorldTimer::getMSTime()` in `MovementInfo::Read`, relayed verbatim by `Write`), one
|
||
/// coherent server clock across all movers. The reference schedules a remote's apply off the
|
||
/// *deltas* between consecutive stamps — replay paced by the sender's own cadence, wow-re
|
||
/// `remote-apply-timing.md`; the app mirrors that per unit (decisions 0601/0615).
|
||
time: u32,
|
||
/// True for `MSG_MOVE_HEARTBEAT` — the periodic mid-move pulse. The reference's reconcile
|
||
/// lerp is armed only for NON-heartbeat events (`0x619090` excludes tag `0x26`); a
|
||
/// heartbeat applies as an outright snap (decision 0601).
|
||
heartbeat: bool,
|
||
fall_time: u32,
|
||
jump: Option<JumpInfo>,
|
||
transport: Option<TransportPose>,
|
||
},
|
||
/// A descriptor `Values` update: the fields this packet changed (`UpdateFields`; WoW resends only
|
||
/// what changed, so a health-only hit carries just that), for the ECS to merge into the object's
|
||
/// store (decision 0061). Emitted from `Object::Values`; a create's *initial* fields ride on
|
||
/// [`Self::ObjectCreate`] instead. Never empty (the codec drops an empty delta).
|
||
ObjectValues { guid: u64, fields: ObjectFields },
|
||
/// Objects that left our view range (the update-object `OutOfRange` block). They still exist
|
||
/// server-side — the real client demotes them to a staging table for cheap re-create.
|
||
ObjectsRemoved(Vec<u64>),
|
||
/// One object destroyed outright (`SMSG_DESTROY_OBJECT` — a corpse decaying ahead of respawn, a
|
||
/// despawn): it ceases to exist. The real client frees it on the spot — an **instant pop**, no
|
||
/// fade-out; the only lifecycle fade is the appear-fade on a fresh create (wow-re object-layer,
|
||
/// selection-death-clear RE). The corpse → destroy → fresh-create-on-respawn sequence is why a
|
||
/// respawn never stands the old corpse up.
|
||
ObjectDestroyed(u64),
|
||
/// The server asked us to play a cinematic (`SMSG_TRIGGER_CINEMATIC`): a character's
|
||
/// first-ever login (the race intro) or a GameObject camera. The app must answer
|
||
/// `CMSG_COMPLETE_CINEMATIC` when it ends — or immediately, to skip — because vmangos anchors
|
||
/// object visibility to the flying cinematic camera while one runs unacked
|
||
/// (`Player::UpdateCinematic`): the world around the body despawns until the ack arrives.
|
||
CinematicTriggered { cinematic_id: u32 },
|
||
/// A server-dictated movement path: the unit traverses `path` — the full travel-order polyline
|
||
/// `[start, …waypoints…, endpoint]` — at constant (arc-length) speed over `duration_ms`. Every
|
||
/// waypoint is carried, so a curved patrol reads as its real path, not a straight `start → endpoint`
|
||
/// shortcut. A `Stop` / zero-duration / <2-point move clears the path (`path` empty).
|
||
MonsterMove {
|
||
guid: u64,
|
||
start: [f32; 3],
|
||
/// The server's per-move spline counter — echoed in `CMSG_MOVE_SPLINE_DONE` when this spline
|
||
/// drives our own player (Charge/knockback/taxi); ignored for a creature's walk.
|
||
spline_id: u32,
|
||
path: Vec<[f32; 3]>,
|
||
/// The dictated final facing (`moveType` 2/3/4), applied as a snap. [`MonsterMoveFacing::None`]
|
||
/// for a plain move — the unit faces its travel direction along the path. This is how a
|
||
/// creature re-faces **without walking** (aggro/scripted/emote), the "won't turn to face me" fix.
|
||
facing: MonsterMoveFacing,
|
||
stop: bool,
|
||
duration_ms: u32,
|
||
/// `true` ⇒ a 3-D flight path (keep the spline's Z); `false` ⇒ a ground walk whose Z the app
|
||
/// re-derives from the terrain under the unit (see the renderer's creature ground-clamp).
|
||
flying: bool,
|
||
},
|
||
/// Same-map teleport (`MSG_MOVE_TELEPORT_ACK`): the app snaps our player + echoes the ack.
|
||
Teleport {
|
||
guid: u64,
|
||
counter: u32,
|
||
position: [f32; 3],
|
||
orientation: f32,
|
||
},
|
||
/// Cross-map worldport (`SMSG_NEW_WORLD`, `needs_ack = true`) or the initial-login map
|
||
/// announcement (`SMSG_LOGIN_VERIFY_WORLD`, `needs_ack = false`): load the new map's ADTs.
|
||
/// While a pending transfer named a transport, `position`/`orientation` are **boat-local**
|
||
/// (decision 0455 — vmangos `SendNewWorld` sends `GetTransportPos()` when riding).
|
||
Worldport {
|
||
map_id: u32,
|
||
position: [f32; 3],
|
||
orientation: f32,
|
||
needs_ack: bool,
|
||
},
|
||
/// The far-teleport preamble (`SMSG_TRANSFER_PENDING`): a worldport to `map_id` follows.
|
||
/// `transport_entry` is set iff the player rides that transport through the transfer — the
|
||
/// signal that the coming [`Self::Worldport`] carries boat-local coordinates (decision 0455).
|
||
TransferPending {
|
||
map_id: u32,
|
||
transport_entry: Option<u32>,
|
||
},
|
||
/// `SMSG_TRANSFER_ABORTED`: the announced transfer will not happen (map full, no instance);
|
||
/// clears the pending-transfer latch.
|
||
TransferAborted { reason: u8 },
|
||
/// The server in-game clock (`SMSG_LOGIN_SETTIMESPEED`): drives time-of-day lighting.
|
||
TimeSpeed {
|
||
hours: u8,
|
||
minutes: u8,
|
||
/// Monotonic day count (`year·372 + month·31 + day` of the packed server date) — the
|
||
/// celestial moon-phase precession input.
|
||
day_serial: u32,
|
||
timescale: f32,
|
||
},
|
||
/// The server's **wall clock** in unix-epoch seconds (`SMSG_QUERY_TIME_RESPONSE`, answering our
|
||
/// `CMSG_QUERY_TIME`) — not [`Self::TimeSpeed`]'s in-game day/night clock, a different quantity
|
||
/// with a different consumer. This one dates the absolute stamps the server writes into
|
||
/// descriptor fields: a timed quest's deadline is `time(nullptr) + limitTime`, so the quest
|
||
/// countdown is a subtraction against *this* number (decision 1150).
|
||
ServerUnixTime { unix_time: u32 },
|
||
/// The player's hearthstone bind point (`SMSG_BINDPOINTUPDATE`): the AreaTable id the
|
||
/// `$z` token names ("Returns you to <area>.").
|
||
BindPoint { area: u32 },
|
||
/// The player's equip proficiencies for one item class (`SMSG_SET_PROFICIENCY`, at login +
|
||
/// on train): the subclass bitmask the item tooltip's slot-line red compares against
|
||
/// (the client's `0xc4d4a0[class]` store).
|
||
Proficiency { item_class: u32, subclass_mask: u32 },
|
||
/// The player's reputation store (`SMSG_INITIALIZE_FACTIONS`, once at login): `(flags, standing)`
|
||
/// per reputation-list slot, indexed by `Faction.dbc`'s `reputationIndex`. The standing excludes
|
||
/// the DBC race/class base — consumers add it before ranking. Drives the reputation branch of
|
||
/// unit reaction (a reputation faction's NPCs colour by the player's rank, not faction templates).
|
||
Reputations { standings: Vec<(u8, i32)> },
|
||
/// Mid-session reputation deltas (`SMSG_SET_FACTION_STANDING`): `(reputationListId,
|
||
/// standing)` per changed slot — same standing convention as [`Self::Reputations`].
|
||
ReputationDelta { standings: Vec<(u32, i32)> },
|
||
/// One reputation-list slot just became visible in the pane (`SMSG_SET_FACTION_VISIBLE`) — the
|
||
/// server's "you have now met these people". Carries no standing; it only lifts the slot's
|
||
/// visible flag, which is what decides whether the pane lists the row at all.
|
||
ReputationVisible { list_id: u32 },
|
||
/// A player character's identity (`SMSG_NAME_QUERY_RESPONSE`, answering our `CMSG_NAME_QUERY`).
|
||
/// Unit names are **not** descriptor fields on the 1.12 wire — they arrive only through this
|
||
/// query pair (players) and [`Self::CreatureName`] (creatures); the app caches them by guid/entry
|
||
/// (decision 0068 §3's query-cache seam). An unknown guid answers with an empty `name`.
|
||
PlayerName {
|
||
guid: u64,
|
||
name: String,
|
||
race: u32,
|
||
gender: u32,
|
||
class: u32,
|
||
},
|
||
/// A pet's display name (`SMSG_PET_NAME_QUERY_RESPONSE`), keyed by **pet number** — the third
|
||
/// naming path, alongside [`Self::PlayerName`] and [`Self::CreatureName`], and the only one a
|
||
/// pet can use: its guid carries a pet number where a creature carries its template entry
|
||
/// ([`crate::guid::pet_number`]), so a creature query for it can only miss. The name is the
|
||
/// pet's own — a hunter pet's custom name, or the creature name for anything summoned. There is
|
||
/// no miss shape: the server stays silent when the pet is gone or the number disagrees.
|
||
PetName { pet_number: u32, name: String },
|
||
/// A creature template's display name (`SMSG_CREATURE_QUERY_RESPONSE`). Keyed by template
|
||
/// `entry` (shared by every spawn of that template), not by spawn guid. `name` is `None` when
|
||
/// the server flagged the entry unknown; `subname` is the tooltip line ("Stable Master", …).
|
||
CreatureName {
|
||
entry: u32,
|
||
name: Option<String>,
|
||
subname: Option<String>,
|
||
/// The template's `CreatureType.dbc` id (Beast, Humanoid, Critter, …) — the TAB-target
|
||
/// critter/totem filter's input. `None` on a server miss.
|
||
creature_type: Option<u32>,
|
||
/// The template's `CreatureFamily.dbc` id (Wolf, Cat, Imp, …) — `UnitCreatureFamily`'s
|
||
/// word and, through that row's pet-food mask, the diet tooltip (decision 1062). `0` for
|
||
/// everything that is neither a tameable beast nor a warlock minion, and `0` on a miss.
|
||
pet_family: u32,
|
||
/// Elite rank 0..4 (the unit tooltip's rank word, decision 0276). `0` on a miss.
|
||
rank: u32,
|
||
/// The template type flags — bit `0x10` hides the tooltip's faction-name line. `0` on a miss.
|
||
type_flags: u32,
|
||
/// The civilian flag (the tooltip's green CIVILIAN line). `false` on a miss.
|
||
civilian: bool,
|
||
/// The racial-leader flag (the tooltip's white LEADER line). `false` on a miss.
|
||
racial_leader: bool,
|
||
},
|
||
/// A GameObject template's type/display/name/`data[24]` head (`SMSG_GAMEOBJECT_QUERY_RESPONSE`,
|
||
/// answering our `CMSG_GAMEOBJECT_QUERY`) — the ask-once GO template lookup, decision 0236.
|
||
/// Keyed by template `entry` (shared by every spawn of that template), not spawn guid. `data[24]`
|
||
/// is the type-specific raw tail (e.g. a chest's lockId lives at a type-specific slot); resolving
|
||
/// a slot is a later consumer's job. On a server miss (unknown entry) this still fires, with
|
||
/// `type_id`/`display_id` zeroed, `name` empty, and `data` all zero — the flattened-field twin of
|
||
/// how [`Self::CreatureName`] answers a miss with `None`s.
|
||
GameObjectInfo {
|
||
entry: u32,
|
||
type_id: u32,
|
||
display_id: u32,
|
||
name: String,
|
||
data: [i32; 24],
|
||
},
|
||
/// A GameObject plays a one-shot **Custom** animation (`SMSG_GAMEOBJECT_CUSTOM_ANIM`):
|
||
/// the client arms GO substate `8 + anim_id` — AnimationData ids 153..156 (Custom0..3),
|
||
/// `anim_id >= 4` rejected at the consumer (wow-re `gameobject-anim-arm.md` §step 8).
|
||
/// The load-bearing sender is the fishing bobber's bite (`anim_id 0`, the splash;
|
||
/// decision 1086).
|
||
GameObjectCustomAnim { guid: u64, anim_id: u32 },
|
||
/// The fishing channel ended with nothing hooked (`SMSG_FISH_NOT_HOOKED`, empty body):
|
||
/// the red `ERR_FISH_NOT_HOOKED` toast (decision 1086).
|
||
FishNotHooked,
|
||
/// The hooked fish got away — the fishing-skill roll failed on the bobber click
|
||
/// (`SMSG_FISH_ESCAPED`, empty body): the red `ERR_FISH_ESCAPED` toast (decision 1086).
|
||
FishEscaped,
|
||
/// A server-pushed 2D sound kit (`SMSG_PLAY_SOUND`): BG events, quest/zone scripts.
|
||
PlaySound { sound_id: u32 },
|
||
/// A server-pushed music kit for the music channel (`SMSG_PLAY_MUSIC`).
|
||
PlayMusic { music_id: u32 },
|
||
/// A server-pushed 3D sound kit at a source object (`SMSG_PLAY_OBJECT_SOUND`) — e.g. the
|
||
/// fishing-bobber splash. The guid positions the emitter.
|
||
PlayObjectSound { sound_id: u32, guid: u64 },
|
||
/// The zone's weather state (`SMSG_WEATHER`): `sound_id` is a SoundEntries loop kit
|
||
/// (8533..8558 rain/snow/sandstorm, 0 = clear); `weather_type`/`grade`/`instant` also feed
|
||
/// the weather visuals when they exist.
|
||
Weather {
|
||
weather_type: u32,
|
||
grade: f32,
|
||
sound_id: u32,
|
||
instant: bool,
|
||
},
|
||
/// A nearby unit's chat emote (`SMSG_TEXT_EMOTE`): the `EmotesText.dbc` id; the guid names
|
||
/// the performer (voice race/sex resolve from its descriptor). `target_name` is the wire's
|
||
/// target display name, empty when untargeted — the sentence-form selector, decision 1274.
|
||
TextEmote {
|
||
guid: u64,
|
||
text_emote: u32,
|
||
target_name: String,
|
||
},
|
||
/// A unit's anim emote (`SMSG_EMOTE`): the `Emotes.dbc` id (drives the anim + its
|
||
/// `EventSoundID`).
|
||
Emote { guid: u64, emote_id: u32 },
|
||
/// The player's spell book (`SMSG_INITIAL_SPELLS`, once at login): the known spell ids,
|
||
/// widened from the wire's `u16`s, plus the active-cooldown list (the seed of the
|
||
/// cooldown store — decision 0137 phase 4).
|
||
SpellBook {
|
||
spell_ids: Vec<u32>,
|
||
cooldowns: Vec<crate::messages::SpellCooldown>,
|
||
},
|
||
/// The player's saved action bar (`SMSG_ACTION_BUTTONS`, once at login): the occupied slots
|
||
/// (0..119; 0–11 = the main bar). Feeds the FrameXML action bar (decision 0068 slice 1).
|
||
ActionButtons { buttons: Vec<ActionButton> },
|
||
/// A spell added to the book after login (`SMSG_LEARNED_SPELL`): trainer/quest/level-up, widened
|
||
/// from the wire `u16`. The first post-login spell-book mutation (decision 0237).
|
||
SpellLearned { spell_id: u32 },
|
||
/// A rank-up (`SMSG_SUPERCEDED_SPELL`): the new rank replaces the old in the book and on the
|
||
/// action bar. Both ids widened from the wire `u16`.
|
||
SpellSuperceded {
|
||
old_spell_id: u32,
|
||
new_spell_id: u32,
|
||
},
|
||
/// The server's verdict on our cast (`SMSG_CAST_RESULT`): `reason` is the failure code when
|
||
/// `success` is false (the client's error-text table keys on it), and `arg` the reason-specific
|
||
/// argument word that fills that message's `%s` — a `SpellFocusObject.dbc` id for
|
||
/// `REQUIRES_SPELL_FOCUS`, an `AreaTable.dbc` id for `REQUIRES_AREA`
|
||
/// ([`crate::messages::CastOutcome::Failed`]).
|
||
CastResult {
|
||
spell_id: u32,
|
||
success: bool,
|
||
reason: Option<u8>,
|
||
arg: Option<u32>,
|
||
},
|
||
/// The pet action bar's whole state (`SMSG_PET_SPELLS`, decision 0982) — or its **teardown**,
|
||
/// which is the same event carrying a zero `pet_guid`. Server-authoritative: this is not a
|
||
/// delta, it is the bar, and every visible pet-bar change arrives through it.
|
||
PetSpells(Box<PetSpells>),
|
||
/// The pet's react/command state alone (`SMSG_PET_MODE`) — a state change with no bar edit
|
||
/// behind it (the reaction buttons' usual wire).
|
||
PetMode(PetMode),
|
||
/// A refused pet order (`SMSG_PET_ACTION_FEEDBACK`): one reason code for the red error line.
|
||
PetActionFeedback { reason: u8 },
|
||
/// The pet's cast refusal (`SMSG_PET_CAST_FAILED`) — [`Self::CastResult`]'s vocabulary, but
|
||
/// the caster is the pet, so it never touches OUR cast state.
|
||
PetCastFailed { spell_id: u32, reason: Option<u8> },
|
||
/// An item template's display head (`SMSG_ITEM_QUERY_SINGLE_RESPONSE`, answering our
|
||
/// `CMSG_ITEM_QUERY_SINGLE`). Keyed by template entry; `None` = the server doesn't know it
|
||
/// (undiscovered) — cached negative, like an unknown creature entry. Boxed for the same reason
|
||
/// as [`crate::messages::ServerPacket::ItemQueryResponse`]: the full item template is wide, and
|
||
/// this is one variant among many tiny, hot ones.
|
||
ItemTemplate {
|
||
entry: u32,
|
||
info: Option<Box<ItemInfo>>,
|
||
},
|
||
/// One inbound chat line (`SMSG_MESSAGECHAT`) — say/yell/system/NPC/channel. System lines
|
||
/// (type `0x0A`) carry GM command feedback. Carries the sender's `chat_tag` (AFK/DND/GM) for
|
||
/// the `<AFK>`/`<DND>`/`<GM>` name-prefix the real chat frame renders.
|
||
Chat(crate::messages::ChatMessage),
|
||
/// A channel join/leave/error/moderation notice (`SMSG_CHANNEL_NOTIFY`) — `tail` is the payload
|
||
/// selected by `notice` (see [`ChannelNoticeTail`]).
|
||
ChannelNotify {
|
||
notice: u8,
|
||
channel: String,
|
||
tail: ChannelNoticeTail,
|
||
},
|
||
/// A channel's member roster (`SMSG_CHANNEL_LIST`, answering our `CMSG_CHANNEL_LIST`): `(guid,
|
||
/// memberFlags)` per row (owner/moderator/voiced/muted/custom/mic-muted — vmangos
|
||
/// `Chat/Channel.h:119-130`).
|
||
ChannelList {
|
||
channel: String,
|
||
flags: u8,
|
||
members: Vec<(u64, u8)>,
|
||
},
|
||
/// A whisper target wasn't found online (`SMSG_CHAT_PLAYER_NOT_FOUND`).
|
||
ChatPlayerNotFound { name: String },
|
||
/// A cross-faction whisper was refused (`SMSG_CHAT_WRONG_FACTION`); empty body.
|
||
ChatWrongFaction,
|
||
/// A server notice (`SMSG_NOTIFICATION`) — pre-formatted text the real client flashes in the
|
||
/// red UIErrorsFrame ("You do not know that language", trade refusals, and kin).
|
||
Notification { text: String },
|
||
/// An area trigger refused (`SMSG_AREA_TRIGGER_MESSAGE`) — pre-formatted text explaining why a
|
||
/// portal or instance entrance did nothing ("You must be at least level 58 to enter…", "You
|
||
/// cannot enter … while in ghost form."). The reference sends it to the same system-message
|
||
/// sink as [`Self::Notification`].
|
||
AreaTriggerMessage { text: String },
|
||
/// Answers `/played` (`SMSG_PLAYED_TIME`, our `CMSG_PLAYED_TIME`): total played time + time
|
||
/// since the last level-up, both in seconds.
|
||
PlayedTime { total: u32, level: u32 },
|
||
/// The server's `/random` broadcast (`MSG_RANDOM_ROLL`): the rolled range, the result, and the
|
||
/// roller's guid.
|
||
RandomRoll {
|
||
min: u32,
|
||
max: u32,
|
||
roll: u32,
|
||
guid: u64,
|
||
},
|
||
/// The server refused an inventory operation (`SMSG_INVENTORY_CHANGE_FAILURE` — equip level,
|
||
/// proficiency, bag full, …): the UI error line's inventory vocabulary, the equip twin of
|
||
/// [`Self::CastResult`]'s failure path. `required_level` rides only on the level refusal.
|
||
InventoryFailure {
|
||
reason: u8,
|
||
required_level: Option<u32>,
|
||
item_guid: u64,
|
||
/// The destination bag's ABSOLUTE player slot — the `%s` source of reason 16's
|
||
/// "Only Arrows can be placed in that."; 255 = the player's own array (no bag to name).
|
||
bag_slot: u8,
|
||
},
|
||
/// A unit began melee auto-attack (`SMSG_ATTACKSTART` — including the echo of our own
|
||
/// `CMSG_ATTACKSWING`).
|
||
AttackStart { attacker: u64, victim: u64 },
|
||
/// A unit stopped melee auto-attack (`SMSG_ATTACKSTOP`).
|
||
AttackStop { attacker: u64, victim: u64 },
|
||
/// One completed melee swing (`SMSG_ATTACKERSTATEUPDATE`) — the attacker's swing-animation
|
||
/// trigger (decision 0073: one packet = one swing, no client timer).
|
||
AttackerState(AttackerState),
|
||
/// A creature flared at someone (`SMSG_AI_REACTION`): reaction 2 = HOSTILE (sent on every
|
||
/// creature melee-attack start), 0 = ALERT (stealth pre-aggro detection); any other value is
|
||
/// a no-op. Pure audio in the client — the aggro/alert vocals (decision 0280).
|
||
AiReaction { unit: u64, reaction: u32 },
|
||
/// A unit began a non-triggered cast (`SMSG_SPELL_START`), instants included (`cast_time_ms ==
|
||
/// 0`) — the precast trigger the phase-2 casting animation loop builds on (decision 0099 phase
|
||
/// 1). `target` is the explicit unit target, when the spell's target block carries one.
|
||
SpellStart {
|
||
caster: u64,
|
||
spell_id: u32,
|
||
cast_flags: u16,
|
||
cast_time_ms: u32,
|
||
target: Option<u64>,
|
||
/// The nocked-ammo display id (the `CAST_FLAG_AMMO` tail, ranged spells only) — feeds the
|
||
/// caster's worn ammo model (the client's `0x60ba30` @ START `0x6e78b6`, any caster;
|
||
/// wow-re `nocked-ammo-cancel.md`). `None` when the flag is clear.
|
||
ammo_display_id: Option<u32>,
|
||
},
|
||
/// The cast launched (`SMSG_SPELL_GO`): hit/miss lists + (for a ranged spell) the ammo display
|
||
/// id for the projectile visual. The server schedules impact itself off `Spell.dbc` Speed —
|
||
/// nothing about missile travel rides this packet.
|
||
SpellGo {
|
||
caster: u64,
|
||
spell_id: u32,
|
||
cast_flags: u16,
|
||
hits: Vec<u64>,
|
||
misses: Vec<(u64, u8)>,
|
||
target: Option<u64>,
|
||
/// The GameObject an open-lock cast launched at (`TARGET_FLAG_GAMEOBJECT`) — opens a chest lid /
|
||
/// locked door (decision 0250). `None` for a unit spell.
|
||
go_target: Option<u64>,
|
||
/// The ground point a dest-targeted cast launched at (`TARGET_FLAG_DEST_LOCATION`), raw
|
||
/// WoW coords — where a ground AOE's launch-side visual belongs (the B132 follow-up;
|
||
/// the persistent effect anchors to the DynamicObject create instead). `None` otherwise.
|
||
dest: Option<[f32; 3]>,
|
||
ammo_display_id: Option<u32>,
|
||
/// The **cast item's** guid when the packet's first guid names one (an item use — potion,
|
||
/// scroll: `item_or_caster != caster`); `None` for a plain spell cast. The item-use
|
||
/// cooldown keys on it (decision 0137 phase 4 — the client resolves its SPELLCAST item
|
||
/// the same way).
|
||
item_caster: Option<u64>,
|
||
},
|
||
/// The hop list for a caster's **beam** (`SMSG_SPELL_UPDATE_CHAIN_TARGETS`, decision 0955):
|
||
/// the units a chain/beam visual runs through, caster → t1 → t2 → …. The client's own array
|
||
/// (`unit+0xd44`) is filled from this and from nothing else, and is consumed once by the next
|
||
/// chain `CharProc` — so this is an edge, not a state.
|
||
///
|
||
/// vmangos sends it only for **channeled** spells. The cast-stage chains get their hops from
|
||
/// [`Self::SpellGo`]'s hit list instead — which is the reference's own second producer
|
||
/// (`0x6e800d` inside its SPELL_GO handler fills the same array), not a divergence.
|
||
SpellChainTargets {
|
||
caster: u64,
|
||
spell_id: u32,
|
||
targets: Vec<u64>,
|
||
},
|
||
/// An observed cast was interrupted/cancelled (`SMSG_SPELL_FAILED_OTHER`) — ends the caster's
|
||
/// `Casting` state seam the same as a [`Self::SpellGo`].
|
||
SpellFailedOther { caster: u64, spell_id: u32 },
|
||
/// Our own cast was pushed back by damage (`SMSG_SPELL_DELAYED`) — the cast bar slides its
|
||
/// window out by `delay_ms` instead of finishing early (decision 0256).
|
||
SpellDelayed { caster: u64, delay_ms: u32 },
|
||
/// Stop our own ranged auto-repeat visual (`SMSG_CANCEL_AUTO_REPEAT`, self-only). No
|
||
/// ranged-attack consumer exists yet (decision 0099 phase 5).
|
||
CancelAutoRepeat,
|
||
/// Server-pushed cooldowns (`SMSG_SPELL_COOLDOWN`) for `caster` (the player or pet): pairs of
|
||
/// `(spell_id, cooldown_ms)`, where `0` ms means "use the spell's own Spell.dbc recovery
|
||
/// times" (decision 0137 phase 4; the school-lockout / pet path — a normal cast's cooldown is
|
||
/// client-tracked).
|
||
SpellCooldowns {
|
||
caster: u64,
|
||
cooldowns: Vec<(u32, u32)>,
|
||
},
|
||
/// Put an item instance on the client's fixed 30 s use cooldown (`SMSG_ITEM_COOLDOWN`).
|
||
ItemCooldown { item_guid: u64, spell_id: u32 },
|
||
/// `SMSG_ITEM_ENCHANT_TIME_UPDATE` — the seconds left on one item's TEMPORARY enchant, in the
|
||
/// named enchant slot. The **only** feed for the tooltip's countdown: the item's own
|
||
/// `ITEM_FIELD_ENCHANTMENT` duration field is never read for it (wow-re
|
||
/// `ui/scratch/tooltip-content-law.md` §E3; decision 0920). `seconds == 0` = expired.
|
||
ItemEnchantTime {
|
||
item_guid: u64,
|
||
slot: u32,
|
||
seconds: u32,
|
||
},
|
||
/// Start an on-hold (`SPELL_ATTR_COOLDOWN_ON_EVENT`) cooldown's parked timers now
|
||
/// (`SMSG_COOLDOWN_EVENT`).
|
||
CooldownEvent { spell_id: u32, caster: u64 },
|
||
/// Remove one spell's cooldown record (`SMSG_CLEAR_COOLDOWN`).
|
||
ClearCooldown { spell_id: u32, caster: u64 },
|
||
/// Wipe every cooldown for `caster` (`SMSG_COOLDOWN_CHEAT`, the GM reset).
|
||
CooldownCheat { caster: u64 },
|
||
/// Our own channeled cast opened (`MSG_CHANNEL_START`, self-only — no guid on the wire). The
|
||
/// cast bar's channel-open edge (decision 0137).
|
||
ChannelStart { spell_id: u32, duration_ms: u32 },
|
||
/// Our own channel's remaining time (`MSG_CHANNEL_UPDATE`, self-only); `0` = the channel is
|
||
/// over — natural end and interrupt alike (decision 0137).
|
||
ChannelUpdate { remaining_ms: u32 },
|
||
/// How long one of **our own** auras has left (`SMSG_UPDATE_AURA_DURATION`), keyed by its
|
||
/// `UNIT_FIELD_AURA` slot. Self-only, never sent for a permanent aura, and it arrives *before*
|
||
/// the descriptor delta that names the slot's spell — so the aura feed buffers it by slot and
|
||
/// joins on the spell present when it lands (decisions 0255/0257).
|
||
AuraDuration { slot: u8, remaining_ms: u32 },
|
||
/// Play a spell-visual kit on a unit outside the normal cast sequence (`SMSG_PLAY_SPELL_VISUAL`
|
||
/// — eat/drink kits, a channel-kit refresh workaround). No VFX consumer yet (decision 0099
|
||
/// phase 3).
|
||
PlaySpellVisual { unit: u64, kit_id: u32 },
|
||
/// Non-melee (spell) damage dealt (`SMSG_SPELLNONMELEEDAMAGELOG`) — decision 0137 phase 2's
|
||
/// floating-combat-text data feed.
|
||
SpellDamageLog(SpellDamageLog),
|
||
/// Periodic (DoT/HoT/regen) aura ticks (`SMSG_PERIODICAURALOG`) — decision 0137 phase 2.
|
||
PeriodicAuraLog(PeriodicAuraLog),
|
||
/// A direct heal landing (`SMSG_SPELLHEALLOG`) — decision 0578's center-combat-text feed.
|
||
SpellHealLog(SpellHealLog),
|
||
/// An instant power gain (`SMSG_SPELLENERGIZELOG`) — decision 0578.
|
||
SpellEnergizeLog(SpellEnergizeLog),
|
||
/// A damage-shield (Thorns-style) return hit (`SMSG_SPELLDAMAGESHIELD`) — decision 0137 phase 2.
|
||
DamageShield(DamageShield),
|
||
/// Environmental damage taken — fall/drowning/fatigue/lava/slime/fire
|
||
/// (`SMSG_ENVIRONMENTALDAMAGELOG`): the client-side feedback trigger (the fall-landing dust
|
||
/// kit via `EnvironmentalDamage.dbc`).
|
||
EnvironmentalDamageLog(EnvironmentalDamageLog),
|
||
/// A spell cast's per-target miss list (`SMSG_SPELLLOGMISS`) — decision 0137 phase 2.
|
||
SpellLogMiss(SpellLogMiss),
|
||
/// An XP award, kill or non-kill (`SMSG_LOG_XPGAIN`) — decision 0137 phase 2.
|
||
XpGain(XpGain),
|
||
/// A first visit to an area (`SMSG_EXPLORATION_EXPERIENCE`) — the discovered area id + its
|
||
/// XP award; the "Discovered …" chat line's data (decision 0828).
|
||
ExplorationXp(ExplorationXp),
|
||
/// Our own ding (`SMSG_LEVELUP_INFO`, self-addressed only) — decision 0304.
|
||
LevelUp(LevelUpInfo),
|
||
/// A gossip menu opened on an NPC (`SMSG_GOSSIP_MESSAGE`, answering our `CMSG_GOSSIP_HELLO`):
|
||
/// `text_id` drives a follow-up [`crate::messages::npc_text_query`] for the greeting body;
|
||
/// `options` are the selectable lines (icon + coded flag + label). `quests` are the quest-option
|
||
/// rows riding the same packet — `(quest_id, dialog-status icon, title)`; the gossip window lists
|
||
/// them and a click sends `CMSG_QUESTGIVER_QUERY_QUEST` (decision 0088).
|
||
GossipMenu {
|
||
npc: u64,
|
||
text_id: u32,
|
||
options: Vec<GossipOption>,
|
||
quests: Vec<(u32, u32, String)>,
|
||
},
|
||
/// A questgiver dialog status for one NPC (`SMSG_QUESTGIVER_STATUS`) — the `!`/`?` marker's
|
||
/// [`crate::messages::dialog_status`] value. Stored per guid now; the world marker is a later
|
||
/// slice (decision 0088).
|
||
QuestGiverStatus { npc: u64, status: u32 },
|
||
/// The greeting panel: an NPC's offered/active quest rows (`SMSG_QUESTGIVER_QUEST_LIST`).
|
||
QuestGreeting(QuestGiverList),
|
||
/// The accept panel: full quest text + rewards on offer (`SMSG_QUESTGIVER_QUEST_DETAILS`).
|
||
QuestDetail(QuestDetails),
|
||
/// The progress panel: "bring me these" text + required items/money + completability
|
||
/// (`SMSG_QUESTGIVER_REQUEST_ITEMS`).
|
||
QuestProgress(QuestRequestItems),
|
||
/// The reward panel: turn-in text + rewards to grant (`SMSG_QUESTGIVER_OFFER_REWARD`).
|
||
QuestOffer(QuestOfferReward),
|
||
/// The turn-in result: XP/money granted + fixed items (`SMSG_QUESTGIVER_QUEST_COMPLETE`).
|
||
QuestComplete(QuestComplete),
|
||
/// The full quest template (`SMSG_QUEST_QUERY_RESPONSE`, answering our `CMSG_QUEST_QUERY`) —
|
||
/// the quest log's ask-once detail source, cached by `quest_id`.
|
||
QuestTemplate(Box<QuestTemplate>),
|
||
/// A kill/use objective ticked (`SMSG_QUESTUPDATE_ADD_KILL`) — the "Kobold Vermin slain: 3/10"
|
||
/// toast. `entry` carries the raw creature/GO encoding (GO = `(−id)|0x80000000`); the durable
|
||
/// count also lands in the `PLAYER_QUEST_LOG` slot's counter field, this is the announce line.
|
||
QuestObjectiveKill {
|
||
quest_id: u32,
|
||
entry: u32,
|
||
count: u32,
|
||
required: u32,
|
||
},
|
||
/// An item-collection objective toast (`SMSG_QUESTUPDATE_ADD_ITEM`).
|
||
QuestObjectiveItem { item_id: u32, count: u32 },
|
||
/// Every objective on the quest is complete (`SMSG_QUESTUPDATE_COMPLETE`) — the "Quest
|
||
/// completed" line; the slot's state byte carries the durable fact.
|
||
QuestObjectivesComplete { quest_id: u32 },
|
||
/// The quest failed (`SMSG_QUESTUPDATE_FAILED` / `_FAILEDTIMER` — `timed` picks which).
|
||
QuestFailed { quest_id: u32, timed: bool },
|
||
/// The log refused a new quest — no free slot (`SMSG_QUESTLOG_FULL`).
|
||
QuestLogFull,
|
||
/// The giver won't OFFER the quest (`SMSG_QUESTGIVER_QUEST_INVALID`): one `QuestFailedReason`
|
||
/// msg code and no quest id — vmangos' `SendCanTakeQuestResponse`, the answer to a query or an
|
||
/// accept that fails `CanTakeQuest` ("already on that quest", "not high enough level").
|
||
QuestGiverInvalid { reason: u32 },
|
||
/// A quest the giver DID offer failed on accept (`SMSG_QUESTGIVER_QUEST_FAILED`): the
|
||
/// `{questId, reason}` pair, whose line names the quest. Kept apart from
|
||
/// [`Self::QuestGiverInvalid`] because the reference reads the two packets with two different
|
||
/// handlers and two different message tables (decision 0669).
|
||
QuestGiverFailed { quest_id: u32, reason: u32 },
|
||
/// The gossip window closes (`SMSG_GOSSIP_COMPLETE`) — no menu is open server-side any more.
|
||
GossipComplete,
|
||
/// The greeting record for a gossip menu (`SMSG_NPC_TEXT_UPDATE`, answering our
|
||
/// `CMSG_NPC_TEXT_QUERY`) — all 8 blocks, **undrawn**: which line greets you depends on the
|
||
/// NPC's gender and a die roll, so the app draws it when the frame opens
|
||
/// (`messages::gossip::select_greeting`). `$N` and friends are client-substituted tokens still
|
||
/// in the strings.
|
||
NpcGreeting {
|
||
text_id: u32,
|
||
blocks: Vec<crate::messages::NpcTextBlock>,
|
||
},
|
||
/// A vendor's stock (`SMSG_LIST_INVENTORY`, answering our `CMSG_LIST_INVENTORY`): entry, icon,
|
||
/// price, and remaining stock per row (`current_count == 0xFFFF_FFFF` = unlimited).
|
||
VendorInventory { vendor: u64, items: Vec<VendorItem> },
|
||
/// A trainer's service list (`SMSG_TRAINER_LIST`, reached via the gossip trainer option): the
|
||
/// wire services (each a spell id + cost + green/red/gray state + level/skill/ability gates), the
|
||
/// window-framing `trainer_type` (0 class · 1 mount · 2 tradeskill · 3 pet), and the greeting
|
||
/// title. The app resolves each service to name/icon through its spell catalog (decision 0237).
|
||
TrainerList {
|
||
trainer: u64,
|
||
trainer_type: u32,
|
||
services: Vec<TrainerSpell>,
|
||
greeting: String,
|
||
},
|
||
/// A trainer taught a service (`SMSG_TRAINER_BUY_SUCCEEDED`, answering `CMSG_TRAINER_BUY_SPELL`):
|
||
/// confirmation only — the spell itself arrives via `SMSG_LEARNED_SPELL` (already in the book).
|
||
/// The app re-requests `CMSG_TRAINER_LIST` on this to repaint the bought row green→gray (decision
|
||
/// 0237: the server never auto-resends the list on a buy).
|
||
TrainerBuySucceeded { trainer: u64, spell_id: u32 },
|
||
/// A trainer refused a purchase (`SMSG_TRAINER_BUY_FAILED`): `error` is a
|
||
/// [`crate::messages::train_fail`] code (0 unavailable · 1 not-enough-money · 2 not-enough-skill).
|
||
/// Surfaces on the trainer window's error line.
|
||
TrainerBuyFailed {
|
||
trainer: u64,
|
||
spell_id: u32,
|
||
error: u32,
|
||
},
|
||
/// A vendor's stock updated after a purchase (`SMSG_BUY_ITEM`). The purchased item itself
|
||
/// arrives through the normal item-create + inventory-slot update path, already handled.
|
||
VendorBuyResult {
|
||
vendor: u64,
|
||
slot: u32,
|
||
new_count: u32,
|
||
purchase_count: u32,
|
||
},
|
||
/// A sell was refused (`SMSG_SELL_ITEM`'s error path — a success sends no packet at all).
|
||
/// `reason` is a `SellResult` code ([`crate::messages::sell_result`]).
|
||
VendorSellFailed {
|
||
vendor: u64,
|
||
item_guid: u64,
|
||
reason: u8,
|
||
},
|
||
/// A purchase was refused (`SMSG_BUY_FAILED`). `reason` is a `BuyResult` code
|
||
/// ([`crate::messages::buy_result`]).
|
||
VendorBuyFailed {
|
||
vendor: u64,
|
||
item_entry: u32,
|
||
reason: u8,
|
||
},
|
||
/// The bank window opens (`SMSG_SHOW_BANK`), answering our `CMSG_BANKER_ACTIVATE` — or
|
||
/// arriving unprompted for the `GOSSIP_OPTION_BANKER` gossip option, so a handler must not
|
||
/// assume it always follows our own activate. The vault contents are already in the player
|
||
/// descriptor (decision 0604); this only opens the window.
|
||
ShowBank { banker: u64 },
|
||
/// A bank-slot purchase was refused (`SMSG_BUY_BANK_SLOT_RESULT`). `result` is a `BankSlotResult`
|
||
/// code ([`crate::messages::bank_slot_result`]); a successful buy sends no packet, visible only
|
||
/// as the `PLAYER_BYTES_2` bank-bag-count byte advancing + the coinage drop.
|
||
BuyBankSlotResult { result: u32 },
|
||
/// A loot window opened (`SMSG_LOOT_RESPONSE`'s normal shape), answering our `CMSG_LOOT`:
|
||
/// `loot_type` is a `loot::loot_type` code, `items` the row list (quest rows ride the same
|
||
/// list, `slot = items.len() + i`). A row still under a group roll arrives with
|
||
/// `slot_type == ROLL_ONGOING` (decision 0591); master loot stays out of scope.
|
||
LootResponse {
|
||
guid: u64,
|
||
loot_type: u8,
|
||
gold: u32,
|
||
items: Vec<LootItem>,
|
||
},
|
||
/// The server refused to open the loot window (`SMSG_LOOT_RESPONSE`'s error shape — didn't
|
||
/// kill it, too far, not standing, …). `error` is a `loot::loot_error` code.
|
||
LootError { guid: u64, error: u8 },
|
||
/// One loot-window row was taken, by anyone (`SMSG_LOOT_REMOVED`) — the UI clears that row.
|
||
LootRemoved { slot: u8 },
|
||
/// Our share of the loot's coin pile (`SMSG_LOOT_MONEY_NOTIFY`), answering our
|
||
/// `CMSG_LOOT_MONEY`.
|
||
LootMoneyNotify { amount: u32 },
|
||
/// The coin line disappears for every current looter (`SMSG_LOOT_CLEAR_MONEY`).
|
||
LootClearMoney,
|
||
/// The loot window closes (`SMSG_LOOT_RELEASE_RESPONSE`), answering our `CMSG_LOOT_RELEASE`.
|
||
LootReleaseResponse { guid: u64 },
|
||
/// A group roll opened on one drop (`SMSG_LOOT_START_ROLL`) — the app allocates a client-side
|
||
/// `rollID` for it and raises a `GroupLootFrame` (decision 0591).
|
||
LootStartRoll(LootStartRoll),
|
||
/// One roller's vote or dice result (`SMSG_LOOT_ROLL`) — the chat announcement line. The
|
||
/// `(roll_number, roll_type)` pair is overloaded; `LootRoll::is_dice`/`vote` disentangle it.
|
||
LootRoll(LootRoll),
|
||
/// A group roll resolved (`SMSG_LOOT_ROLL_WON`) — closes that roll's frame.
|
||
LootRollWon(LootRollWon),
|
||
/// Everyone passed (`SMSG_LOOT_ALL_PASSED`) — closes that roll's frame; the item returns to
|
||
/// the corpse for ordinary looting.
|
||
LootAllPassed(LootAllPassed),
|
||
/// An item landed in our bags — looted or received from an NPC (`SMSG_ITEM_PUSH_RESULT`);
|
||
/// drives the "You receive loot: …" chat line.
|
||
ItemPushResult(ItemPushResult),
|
||
/// The keepalive echo (`SMSG_PONG`): the sequence number of the `CMSG_PING` it answers. The app
|
||
/// matches it against the ping clock (shared with the write thread's 30 s sender) to measure
|
||
/// the round-trip time shown in the debug panel.
|
||
Pong { sequence: u32 },
|
||
/// The server changed one of our mover's speeds (`SMSG_FORCE_*_SPEED_CHANGE` — an aura
|
||
/// slow/haste, a mount, GM `.modify speed`) and awaits the matching ack. The app must (1) apply
|
||
/// `speed` to the mover's [`MoveSpeeds`] entry for `kind`, and (2) answer
|
||
/// `CMSG_FORCE_*_SPEED_CHANGE_ACK` echoing `counter` + the exact `speed` with a live
|
||
/// `MovementInfo` — unacked, the server force-resolves after ~4 s and flags its anticheat
|
||
/// (`Unit::CheckPendingMovementChanges`), so every speed change desyncs without this.
|
||
ForceSpeedChange {
|
||
guid: u64,
|
||
kind: crate::messages::SpeedKind,
|
||
counter: u32,
|
||
speed: f32,
|
||
},
|
||
/// A speed change on a unit we do NOT control (`SMSG_SPLINE_SET_*_SPEED` /
|
||
/// `MSG_MOVE_SET_*_SPEED` — an observed player mounting up, a hastened creature). Apply to the
|
||
/// unit's [`MoveSpeeds`] entry for `kind`; **no ack** (decision 0441). The MOVE_SET flavour's
|
||
/// accompanying pose arrives as its own [`Self::UnitMove`].
|
||
SpeedChanged {
|
||
guid: u64,
|
||
kind: crate::messages::SpeedKind,
|
||
speed: f32,
|
||
},
|
||
/// The server's answer to our (dis)mount attempt (`SMSG_MOUNTRESULT` /
|
||
/// `SMSG_DISMOUNTRESULT`, split by `mount`): a raw result code (OK = 10 mounting / 3
|
||
/// dismounting; the failure codes are red-error-line material — a P2 trimming, decision 0441).
|
||
MountResult { mount: bool, code: u32 },
|
||
/// A nearby rider hit the mounted flourish (`SMSG_MOUNTSPECIAL_ANIM`): play MountSpecial(94)
|
||
/// on that unit's mount. Our own guid may arrive too — whether the sender gets the echo
|
||
/// is a server-config detail (vmangos's non-broadcaster delivery echoes; the optional
|
||
/// per-player broadcaster does not) — so the app self-suppresses it on receive and plays
|
||
/// its own flourish locally at send time.
|
||
MountSpecial { guid: u64 },
|
||
/// The server handed us — or took from us — the reins of a unit (`SMSG_CLIENT_CONTROL_UPDATE`).
|
||
///
|
||
/// `mover` is the unit being spoken about, **not** necessarily a new subject: when the server
|
||
/// takes control away it names *us*, with `allow_move = false`. So the two questions the app
|
||
/// asks of this are different — "is this about me?" (`mover == self guid`) and "may that unit
|
||
/// move?" — and conflating them gets the mind-controlled *victim*'s case backwards.
|
||
///
|
||
/// Answering with `CMSG_SET_ACTIVE_MOVER` is not optional when control is granted: vmangos
|
||
/// discards every `MSG_MOVE_*` for a mover it has not confirmed.
|
||
ClientControl { mover: u64, allow_move: bool },
|
||
/// A packet the codec dropped on the floor: `unparseable = false` means **no parse arm exists**
|
||
/// for the opcode at all (it fell through to `ServerPacket::Other` — a wire-coverage gap);
|
||
/// `true` means a parser exists but errored on this body (the reader skipped it to keep the
|
||
/// stream aligned). The app tallies these per opcode (the debug panel's dropped-opcode
|
||
/// instrument): a silently-ignored opcode is how a whole wire family — a speed change, a
|
||
/// compressed-moves batch — goes unnoticed for months.
|
||
PacketDropped { opcode: u16, unparseable: bool },
|
||
/// Where our corpse is (`MSG_CORPSE_QUERY`'s answer, after our empty-body request): feeds the
|
||
/// map corpse markers and the corpse-run range gate (decision 0308 §5). `found == false` also
|
||
/// arrives UNPROMPTED when the corpse converts to bones — it means "drop the marker".
|
||
/// `display_map`/`position` are where to walk toward (dungeon-entrance-adjusted);
|
||
/// `corpse_map` is the corpse's real map.
|
||
CorpseQuery {
|
||
found: bool,
|
||
display_map: i32,
|
||
position: [f32; 3],
|
||
corpse_map: u32,
|
||
},
|
||
/// Milliseconds until our corpse can be reclaimed (`SMSG_CORPSE_RECLAIM_DELAY` — at release
|
||
/// and at login-while-dead; 30 s base, 60/120 s on repeated deaths). Feeds
|
||
/// `GetCorpseRecoveryDelay` and the RECOVER_CORPSE popup's StartDelay gate.
|
||
CorpseReclaimDelay { delay_ms: u32 },
|
||
/// The 10% natural-death durability loss landed (`SMSG_DURABILITY_DAMAGE_DEATH`, empty
|
||
/// body — sent beside the item-field deltas): the red
|
||
/// "Your equipped items suffer a 10% durability loss." error line's cue.
|
||
DurabilityDamageDeath,
|
||
/// Someone offered to resurrect us (`SMSG_RESURRECT_REQUEST`). `name` is EMPTY for a player
|
||
/// caster (resolve via the guid name cache); `sickness`/`has_timer` pick the reference popup
|
||
/// variant. Answered by `CMSG_RESURRECT_RESPONSE` (accept/decline).
|
||
ResurrectRequest {
|
||
caster: u64,
|
||
name: String,
|
||
sickness: bool,
|
||
has_timer: bool,
|
||
},
|
||
/// The spirit healer asks for the confirm (`SMSG_SPIRIT_HEALER_CONFIRM`, from the server's
|
||
/// gossip spirit-healer option): fires the CONFIRM_XP_LOSS popup; its Accept sends
|
||
/// `CMSG_SPIRIT_HEALER_ACTIVATE` back with this guid.
|
||
SpiritHealerConfirm { npc: u64 },
|
||
/// **A granted mover mode changed** — root, water-walk, feather-fall or hover (the ack'd
|
||
/// movement-mode family, decision 0866). Must be acked with the echoed `counter` (+ our current
|
||
/// `MovementInfo`), or the server never applies it and observers never see the change. The app
|
||
/// also **applies the mode to its own mover**: each one changes how the mover behaves — see
|
||
/// [`crate::messages::MoveMode`] for what each does.
|
||
MoveMode {
|
||
guid: u64,
|
||
counter: u32,
|
||
mode: crate::messages::MoveMode,
|
||
apply: bool,
|
||
},
|
||
/// Someone invited us to their group (`SMSG_GROUP_INVITE`) — the invite popup.
|
||
GroupInvite { inviter: String },
|
||
/// An invite we sent was declined (`SMSG_GROUP_DECLINE`).
|
||
GroupDecline { name: String },
|
||
/// We were removed from our group (`SMSG_GROUP_UNINVITE`, empty body) — kicked or left.
|
||
GroupUninvited,
|
||
/// The group's leader changed (`SMSG_GROUP_SET_LEADER`).
|
||
GroupLeaderChanged { name: String },
|
||
/// The group disbanded outright (`SMSG_GROUP_DESTROYED`, empty body).
|
||
GroupDestroyed,
|
||
/// The full roster (`SMSG_GROUP_LIST`, sent on every membership change): `members` excludes
|
||
/// the recipient's own row (`own_flags` carries theirs); `loot` is `None` for the empty "you
|
||
/// left" shape and whenever the fetched list has no other members.
|
||
GroupList {
|
||
group_type: u8,
|
||
own_flags: u8,
|
||
members: Vec<GroupMemberEntry>,
|
||
leader: u64,
|
||
loot: Option<GroupLootInfo>,
|
||
},
|
||
/// The server's verdict on a group command we issued — invite/leave
|
||
/// (`SMSG_PARTY_COMMAND_RESULT`); `operation` is a [`crate::messages::party_operation`] code,
|
||
/// `result` a [`crate::messages::party_result`] code, `member` the named player (may be empty).
|
||
PartyCommandResult {
|
||
operation: u32,
|
||
member: String,
|
||
result: u32,
|
||
},
|
||
/// A party/raid member's live stats for the frame (`SMSG_PARTY_MEMBER_STATS`/`_FULL` — `full`
|
||
/// distinguishes the delta form from the ask-once full form our own
|
||
/// [`crate::messages::request_party_member_stats`] pulls, and the offline-miss reply).
|
||
PartyMemberStats {
|
||
guid: u64,
|
||
full: bool,
|
||
info: Box<PartyMemberStatsInfo>,
|
||
},
|
||
/// Someone pinged the minimap (`MSG_MINIMAP_PING`) — the group ping marker.
|
||
MinimapPing { guid: u64, x: f32, y: f32 },
|
||
/// One raid-target icon changed (`MSG_RAID_TARGET_UPDATE`, delta shape) — `guid == 0` clears it.
|
||
RaidTargetSet { icon: u8, guid: u64 },
|
||
/// The full current raid-target icon set (`MSG_RAID_TARGET_UPDATE`, full-list shape) — only
|
||
/// currently-set icons are present.
|
||
RaidTargetList { entries: Vec<(u8, u64)> },
|
||
/// The raid leader started a ready check (`MSG_RAID_READY_CHECK`, empty body).
|
||
ReadyCheckRequest,
|
||
/// One member's ready-check answer, forwarded to the leader only (`MSG_RAID_READY_CHECK`,
|
||
/// non-empty body).
|
||
ReadyCheckAnswer { guid: u64, ready: u8 },
|
||
/// A duel challenge (`SMSG_DUEL_REQUESTED`, decision 0633) — delivered to challenger and
|
||
/// challenged alike. `arbiter` is the duel-flag GameObject that identifies the duel and is
|
||
/// echoed on accept/cancel; `challenger` equal to our own guid means we are the one asking.
|
||
DuelRequested { arbiter: u64, challenger: u64 },
|
||
/// We left the duel-flag bubble (`SMSG_DUEL_OUTOFBOUNDS`) — the 10 s forfeit timer runs.
|
||
DuelOutOfBounds,
|
||
/// We are back inside the bubble (`SMSG_DUEL_INBOUNDS`) — the forfeit timer is cleared.
|
||
DuelInBounds,
|
||
/// The duel ended (`SMSG_DUEL_COMPLETE`). `started` is false only when it never began.
|
||
DuelComplete { started: bool },
|
||
/// The duel's outcome line (`SMSG_DUEL_WINNER`), broadcast to everyone nearby: `fled` picks
|
||
/// the retreat template over the knockout one.
|
||
DuelWinner {
|
||
fled: bool,
|
||
winner: String,
|
||
loser: String,
|
||
},
|
||
/// Start the duel countdown (`SMSG_DUEL_COUNTDOWN`) — already in whole seconds.
|
||
DuelCountdown { seconds: u32 },
|
||
/// One mirror timer started, or was wholly re-stated (`SMSG_START_MIRROR_TIMER`, decision
|
||
/// 0874) — the breath / fatigue bars. There is no separate update opcode: the server re-sends
|
||
/// this whole packet whenever direction, remaining time or frozen state changes, so a
|
||
/// consumer must treat it as idempotent re-statement, not only as a first appearance.
|
||
MirrorTimerStart(MirrorTimerStart),
|
||
/// Freeze/unfreeze one running mirror timer (`SMSG_PAUSE_MIRROR_TIMER`). vmangos never sends
|
||
/// it on purpose — see [`crate::messages::opcode::SMSG_PAUSE_MIRROR_TIMER`].
|
||
MirrorTimerPause { kind: u32, paused: bool },
|
||
/// One mirror timer is over (`SMSG_STOP_MIRROR_TIMER`) — hide its bar. Sent both when the
|
||
/// condition ends *well* (surfacing refills the bar to full, and the server stops it there)
|
||
/// and when it stops applying at all (death, leaving the water).
|
||
MirrorTimerStop { kind: u32 },
|
||
/// The whole friend list (`SMSG_FRIEND_LIST`, decision 0668) — pushed unasked at login and
|
||
/// again for every `CMSG_FRIEND_LIST`. Always the complete list, never a delta, so it
|
||
/// replaces whatever is held. Names are NOT on the wire (see [`crate::messages::social`]);
|
||
/// the consumer resolves them through its name cache.
|
||
FriendList { friends: Vec<FriendEntry> },
|
||
/// The whole ignore list (`SMSG_IGNORE_LIST`) — the same replace-everything shape, guids only.
|
||
IgnoreList { guids: Vec<u64> },
|
||
/// One friend/ignore result (`SMSG_FRIEND_STATUS`): both the ack for an add/remove **and**
|
||
/// the broadcast a friend's login/logout sends to everyone listing them. The
|
||
/// [`friend_result`](crate::messages::friend_result) code says which, and carries the
|
||
/// presence tail for the two online flavours.
|
||
FriendStatus(FriendStatusUpdate),
|
||
/// A `/who` answer (`SMSG_WHO`) — at most 49 rows, plus the true match total.
|
||
WhoResults(WhoResults),
|
||
/// The taxi map (`SMSG_SHOWTAXINODES`, decision 0484): `flightmaster` is the NPC the menu
|
||
/// opened on, `nearest_node` the node it sits at, `known_mask` the full known-node bitmask
|
||
/// ([`TaxiMask::is_known`]). The wire's window-framing constant carries no state and is
|
||
/// dropped here.
|
||
TaxiNodesShown {
|
||
flightmaster: u64,
|
||
nearest_node: u32,
|
||
known_mask: TaxiMask,
|
||
},
|
||
/// A taxi node's known status (`SMSG_TAXINODE_STATUS`) — answers
|
||
/// `CMSG_TAXINODE_STATUS_QUERY`, and also rides a first-visit "learn" alongside
|
||
/// [`Self::NewTaxiPath`] (vmangos `SendLearnNewTaxiNode`). `guid` names the flight master
|
||
/// asked about; `known` is whether the nearest node to it is now in our taxi mask.
|
||
TaxiNodeStatus { guid: u64, known: bool },
|
||
/// The server's verdict on `CMSG_ACTIVATETAXI`/`CMSG_ACTIVATETAXIEXPRESS`
|
||
/// (`SMSG_ACTIVATETAXIREPLY`): `code` is a [`crate::messages::taxi_reply`] value — `0` OK,
|
||
/// everything else a refusal (no flight starts, and no mount/spline packets follow).
|
||
ActivateTaxiReply { code: u32 },
|
||
/// `SMSG_NEW_TAXI_PATH` — empty body, rides a first-visit "learn" alongside
|
||
/// [`Self::TaxiNodeStatus`]. No payload to carry; modelled so the wire isn't silently dropped.
|
||
NewTaxiPath,
|
||
/// `SMSG_MAIL_LIST_RESULT` — the inbox page, answering `CMSG_GET_MAIL_LIST` (decision 0544
|
||
/// P0's wire layer; the inbox/open-mail window is P1).
|
||
MailList { mails: Vec<MailListEntry> },
|
||
/// `SMSG_SEND_MAIL_RESULT` — the verdict on a send/take-money/take-item/return/delete action;
|
||
/// `equip_error`/`item` are the two mutually-exclusive conditional tails (decision 0544).
|
||
SendMailResult {
|
||
mail_id: u32,
|
||
action: u32,
|
||
error: u32,
|
||
equip_error: Option<u32>,
|
||
item: Option<(u32, u32)>,
|
||
},
|
||
/// `SMSG_PAGE_TEXT_QUERY_RESPONSE` — one page of a book, answering `CMSG_PAGE_TEXT_QUERY`.
|
||
/// The ask-once page cache both readables reach: a readable item template's `PageText` and a
|
||
/// `GAMEOBJECT_TYPE_TEXT` object's `data[0]` (decision 1105). `next_page_id == 0` ends the
|
||
/// chain; vmangos pushes the whole chain in answer to the first query.
|
||
PageText {
|
||
page_id: u32,
|
||
text: String,
|
||
next_page_id: u32,
|
||
},
|
||
/// `SMSG_ITEM_TEXT_QUERY_RESPONSE` — a letter's body text, answering `CMSG_ITEM_TEXT_QUERY`
|
||
/// (a mail's nonzero `item_text_id` triggers the ask-once fetch; decision 0544).
|
||
MailItemText { text_id: u32, text: String },
|
||
/// `SMSG_RECEIVED_MAIL` — a mail arrived (instant for text-only, on the delivery timer's
|
||
/// expiry otherwise). `seconds` is the delay until it is "waiting", in the pending-mail
|
||
/// countdown's units (vmangos only ever sends `0.0` = now; decision 0913).
|
||
ReceivedMail { seconds: f32 },
|
||
/// `MSG_QUERY_NEXT_MAIL_TIME`'s reply (same opcode as our empty-body request): `0.0` = unread
|
||
/// mail waiting, `-86400.0` = none. Drives `HasNewMail()`/`UPDATE_PENDING_MAIL` (decision 0544
|
||
/// P3).
|
||
NextMailTime { seconds: f32 },
|
||
/// `SMSG_TRADE_STATUS` — one pulse of the player-trade state machine (open/accept/cancel/
|
||
/// complete + the refusal reasons). The app drives the trade window and the auto-`BEGIN_TRADE`
|
||
/// reply off this (decision 0592; the window itself is P1).
|
||
TradeStatus { status: TradeStatus },
|
||
/// `SMSG_TRADE_STATUS_EXTENDED` — the item/gold snapshot for one window side (our own or the
|
||
/// partner's, per [`TradeStatusExtended::their_window`]); pushed whenever that side changes.
|
||
/// Boxed (~460 bytes) so it doesn't bloat every `SessionEvent`.
|
||
TradeStatusExtended { state: Box<TradeStatusExtended> },
|
||
/// The world-state table changed. `SMSG_INIT_WORLD_STATES` carries the whole table for a zone
|
||
/// (`states` is the wire run verbatim, terminator pair included); `SMSG_UPDATE_WORLD_STATE`
|
||
/// arrives as a one-entry `states` with `scope: None`, so both wires land on one event and the
|
||
/// app has a single write path — the reference likewise funnels both into the one setter.
|
||
/// `scope` is the init packet's `(map, zone)` dwords; nothing consumes them yet.
|
||
WorldStates {
|
||
scope: Option<(u32, u32)>,
|
||
states: Vec<(u32, u32)>,
|
||
},
|
||
}
|
||
|
||
/// The result of polling the reader for the next packet's events.
|
||
pub enum Poll {
|
||
/// A packet decoded into these events. `events` is **empty** for an opcode we parse but do not
|
||
/// model — a third outcome, distinct from both "decoded into something" and [`Poll::Skipped`],
|
||
/// and until decision 0624 it was invisible from outside: no event, no skip, and the inbound
|
||
/// census counted it like any other packet. A relayed move landing in an unmodelled opcode was
|
||
/// therefore indistinguishable from one that never arrived. `opcode` rides along so the net
|
||
/// thread can name what actually came off the wire.
|
||
Events {
|
||
opcode: u16,
|
||
events: Vec<SessionEvent>,
|
||
},
|
||
/// An unparseable packet was skipped — kept the stream aligned, not an error. Carries the
|
||
/// opcode (for the app's dropped-packet tally) and a short description (opcode + error + a hex
|
||
/// preview) so the net thread can log *which* packet was dropped.
|
||
Skipped { opcode: u16, reason: String },
|
||
}
|
||
|
||
mod decode;
|
||
pub use decode::decode;
|