//! The network↔ECS bridge: a typed-event channel into real ECS entities (decision 0006). //! //! A background **read** thread owns the blocking [`WorldSession`] (logon → world handshake → enter //! world) and streams the world as a flat list of [`SessionEvent`]s over a channel; a sibling //! **write** thread owns the writer and drains [`ClientCommand`]s. One ECS system, //! [`apply_net_updates`], drains the event channel each frame and mutates **real Bevy entities** — //! spawn on create, transform-write on move, despawn on remove — keyed by a [`Guid`] component with a //! [`GuidIndex`] for O(1) lookup. There is no shadow world: a unit is one entity, full stop. //! //! Movement paths become a [`Spline`] component sampled by [`sample_splines`]; the server clock //! becomes the [`ServerTime`] resource; one-shot directives (teleport, worldport) become Bevy //! [`Message`]s the player/terrain consume. Outbound movement + chat go through [`NetCommands`]. //! //! The bridge runs in [`WorldStage::Net`], before `Input`, so a server teleport snaps → streams → //! covers in one frame. Coordinates cross from raw WoW into Bevy space here (`wow_to_bevy`). use std::collections::{HashMap, VecDeque}; use std::time::Instant; use benilla_protocol::{ messages::WhoRequest, EntityKind, JumpInfo, MoveMode, MoveSpeeds, ObjectFields, SessionEvent, SpeedKind, TransportPose, }; use bevy::prelude::*; use crossbeam_channel::{Receiver, Sender}; use crate::schedule::WorldStage; mod apply; pub(crate) mod io; mod motion; use apply::{apply_net_updates, tag_self_player}; // The per-frame motion model lives in [`motion`]: `RemoteMotion`/`Spline` are re-exported for the // crate (the animation selector reads them); the integration systems + pose helpers stay `pub(super)` // and are pulled in here for the plugin + the event bridge. pub(crate) use io::LoginRequest; use motion::{ drain_pending_moves, extrapolate_remote_units, face_target, ground_clamp_creatures, mark_swimming_creatures, sample_splines, }; pub(crate) use motion::{jump_seed, CreatureSwimming, FacingStep, RemoteMotion, Spline}; /// The net subsystem: spawns the background IO threads and drives the per-frame event drain. pub(crate) struct NetPlugin { /// Connect to the server. `false` in capture mode ([`crate::capture`]): the channel resources /// still exist (so the player/chat command sends stay harmless no-ops), but no IO thread runs, so /// captures are deterministic regardless of whether a server happens to be up. pub(crate) connect: bool, } /// Marker for "this process has **no** IO thread" — inserted only when [`NetPlugin::connect`] is /// false. Read by `crate::preflight`'s startup notice, which says so out loud, so a run that cannot /// exercise the wire path is never mistaken for one that did (decision 0728). /// /// A resource of its own rather than a bool on [`NetStatus`], because it answers a different /// question: `connected` is *runtime* state that a refused or dropped connection also clears, while /// this one says the attempt was never made and no packet can ever arrive. #[derive(Resource)] pub(crate) struct NetOffline; impl Plugin for NetPlugin { fn build(&self, app: &mut App) { let handles = io::spawn_net(io::NetConfig::from_env(), self.connect); if !self.connect { app.insert_resource(NetOffline); } app.insert_resource(NetEvents(handles.events)) .insert_resource(NetCommands(handles.commands)) .insert_resource(CharPick(handles.pick)) .insert_resource(LoginSubmit(handles.login)) .insert_resource(LoginAbandon(handles.login_abandon)) .insert_resource(PingShared(handles.ping)) .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_systems( Update, ( apply_net_updates, tag_self_player, sample_splines, // Derive each creature's swim state from the water over its feet (the wire never // carries it for creatures) — before the clamp, so a swimmer is exempt same-frame. mark_swimming_creatures, // After `sample_splines` writes the raw spline Z: re-ground walking units onto our // terrain (the client discards a ground spline's Z — decision 0059); a swimming // creature keeps its wire Z (its path runs through the water volume). ground_clamp_creatures, // Fire due scheduled relays (decision 0601) before the extrapolator advances // the freshly-applied state and reconcile-lerps toward the next queued head. drain_pending_moves, extrapolate_remote_units, // Idle creatures turn to face their target here — after the movers (spline / remote) // have set their poses, so a face turn reads the target's fresh position this frame. face_target, ) .chain() .in_set(WorldStage::Net), ); } } // ── ECS state: components ──────────────────────────────────────────────────────────────────────── /// The server guid of a streamed entity. The one stable identity for a unit/player/GameObject. #[derive(Component, Clone, Copy)] pub(crate) struct Guid(pub(crate) u64); /// A streamed entity's network-authoritative identity: its coarse kind + model display id. The /// renderer ([`crate::entities`]) attaches a visual from these; the entity's [`Transform`] is its pose. #[derive(Component)] pub(crate) struct NetEntity { pub(crate) kind: EntityKind, pub(crate) display_id: Option, /// `OBJECT_FIELD_SCALE_X` — the unit/object's *complete* render scale: the server already folds /// the DBC scale (`CreatureModelData.modelScale × CreatureDisplayInfo.scale`, or a per-spawn /// override) into it, so the renderer ([`crate::entities`]) bakes this onto the transform *alone*, /// never times its own DBC scale (that would double-apply). `1.0` is the default (no rescale). /// A *live* SCALE_X change (a values delta on an existing unit) updates this and eases the /// render scale over 2 s with a cosine smoothstep — the reference's own transition /// (byte-verified, `0x614bbf`; the code once misread as a selection fade-in) — through /// `entities::live_display` (decision 0695), which likewise applies a live /// `UNIT_FIELD_DISPLAYID` change (druid forms, GM morphs — the old F04 deferral). pub(crate) scale: f32, } /// A unit's movement speeds (yd/s), decoded from its `LIVING` movement block. The creature animation /// selector ([`crate::creature_anim`]) keys its walk-vs-run boundary on `walk` (run above 2× walk — /// RF-0057); [`extrapolate_remote_units`] integrates a remote mover between packets at `run` / /// `run_back` / `swim` and turns it in place at `turn_rate`. Present only on units (GameObjects don't /// move under their own power). #[derive(Component, Clone, Copy)] pub(crate) struct UnitSpeeds(pub(crate) MoveSpeeds); /// A streamed object's descriptor field set (`UpdateFields`) — the ECS's mirror of the object's /// server-side values array (decision 0061). Seeded from the create mask and [`ObjectFields::merge`]d /// with each `Values` delta, so it accumulates the object's full descriptor state over its life. /// Consumers read named accessors on the inner [`ObjectFields`]: `unit_health`/`unit_max_health` for the /// Death selector + the inspector, `unit_race`/`unit_gender` + the `player_*` customization for the /// character compositor. Speeds and pose are **not** here — they're movement-block data ([`UnitSpeeds`] /// + the `Transform`), not descriptor fields. #[derive(Component, Clone, Default)] pub(crate) struct ObjectStore(pub(crate) ObjectFields); /// Marks our own player's streamed entity (guid == [`SelfGuid`]). The renderer skips it — the player /// controller owns our avatar — but the controller reads its [`Transform`] to take control on login. #[derive(Component)] pub(crate) struct SelfPlayer; // ── ECS state: resources ───────────────────────────────────────────────────────────────────────── /// The inbound event channel — drained each frame by [`apply_net_updates`]. #[derive(Resource)] struct NetEvents(Receiver); /// The outbound command channel — cloned by the player/chat systems to send movement + chat. #[derive(Resource)] pub(crate) struct NetCommands(pub(crate) Sender); /// The character-management channel: the app's answer to each [`CharListMessage`] while the IO /// thread is parked at select. [`CharRequest::Enter`] picks a character to `CMSG_PLAYER_LOGIN` as /// (decision 0193); [`CharRequest::Create`]/[`CharRequest::Delete`] are serviced *in place* by the /// parked loop, which stays parked (re-enum, re-emit the roster, emit a [`SessionEvent::CharActionResult`]) /// until an `Enter` moves the session into the world (decision 0423). Sent by [`crate::char_select`]'s /// pick policy and the char-create screen; the parked IO read thread blocks on the other end. #[derive(Resource)] pub(crate) struct CharPick(pub(crate) Sender); /// One request to the parked IO thread over the [`CharPick`] channel (decision 0423). `Delete`'s /// wire + service path is live (proven by `WOW_PROBE_CHARCREATE`); its UI affordance is a later /// slice (decision 0423's deferrals). pub(crate) enum CharRequest { /// Log in as this character (`CMSG_PLAYER_LOGIN`) — the session leaves select for the world. Enter(u64), /// Create a character (`CMSG_CHAR_CREATE`), then stay parked at select with the fresh roster. Create(benilla_protocol::CharCreateReq), /// Delete a character by guid (`CMSG_CHAR_DELETE`), then stay parked at select. Delete(u64), /// Select's Back (decision 0539): drop the parked session and return the IO thread to the /// pre-logon park — the app is heading to the login screen. Abandon, } /// The credentials channel (decision 0539): the app's answer to the IO thread's **pre-logon park**. /// Sent by [`crate::login`]'s policy (the screen's submit, the env fast path, the reconnect /// resubmit); the parked read thread blocks on the other end. #[derive(Resource)] pub(crate) struct LoginSubmit(pub(crate) Sender); /// The login abandon generation (decision 0539): Cancel bumps it; each [`io::LoginRequest`] carries /// the value read at submit, and the IO thread discards an attempt whose value has been passed. #[derive(Resource)] pub(crate) struct LoginAbandon(pub(crate) std::sync::Arc); /// guid → spawned ECS entity, for O(1) lookup on move/remove. Maintained solely by /// [`apply_net_updates`]; read-only to everyone else (the merchant range-close resolves its vendor /// through it). #[derive(Resource, Default)] pub(crate) struct GuidIndex(pub(crate) HashMap); /// Our own player's guid, once the IO thread reports we're in the world. Used to tag /// [`SelfPlayer`], and read by the combat-text emitters' source-ownership classifier /// (`crate::combat_text::melee_impact_text` — the "mine" test against Summoned/CreatedBy). #[derive(Resource, Default)] pub(crate) struct SelfGuid(pub(crate) Option); /// Connection status for the rest of the app (decision 0065): `connected` flips on /// `Connected`/`Disconnected`; `last_reason` keeps the most recent failure so it can be surfaced /// (debug panel, future UI). The streamed-world teardown itself happens in [`apply_net_updates`]. #[derive(Resource, Default)] pub(crate) struct NetStatus { pub(crate) connected: bool, pub(crate) last_reason: Option, /// The last measured ping round trip (ms), from the `SMSG_PONG` echo against [`PingShared`]'s /// clock. `None` until the first pong of a connection (and again after a disconnect). pub(crate) latency_ms: Option, /// The most recent [`RTT_RING`] round trips (ms), oldest first — the real client's own RTT /// history (wow-re net W1: `HandlePong 0x537d60` puts each sample into a ring with "head/tail /// wrap 16", which `0x537f20`'s avg-RTT math averages). [`Self::avg_latency_ms`] is what the UI /// reports through `GetNetStats`; the *last* sample stays separate because that is the one the /// wire echoes back in the next `CMSG_PING`'s lastRtt field. Cleared with the connection. pub(crate) rtt_ring: VecDeque, } /// How many round trips [`NetStatus::rtt_ring`] keeps — the reference ring's depth (wow-re net W1, /// `HandlePong 0x537d60`: "head/tail wrap 16"). pub(crate) const RTT_RING: usize = 16; impl NetStatus { /// Record one measured round trip: the newest sample in, the oldest out at depth. pub(crate) fn record_rtt(&mut self, ms: u32) { self.latency_ms = Some(ms); if self.rtt_ring.len() == RTT_RING { self.rtt_ring.pop_front(); } self.rtt_ring.push_back(ms); } /// Forget this connection's measurements — the next one's latency is its own. pub(crate) fn clear_rtt(&mut self) { self.latency_ms = None; self.rtt_ring.clear(); } /// The reported latency: the mean of the ring, truncated — `None` while it is empty (no pong /// since the connection came up). The reference averages its ring rather than reporting the /// last sample; the exact rounding is ours (`0x537f20`'s math is recorded as "avg-RTT" without /// a byte-level form), and at a 30 s ping cadence one sample's rounding is invisible anyway. pub(crate) fn avg_latency_ms(&self) -> Option { if self.rtt_ring.is_empty() { return None; } let sum: u64 = self.rtt_ring.iter().map(|&ms| u64::from(ms)).sum(); Some((sum / self.rtt_ring.len() as u64) as u32) } } #[cfg(test)] mod rtt_tests { use super::{NetStatus, RTT_RING}; /// The reported latency is the ring's mean, and the ring is bounded at the reference depth — /// so a single spike moves the meter by a sixteenth, not the whole way (which is the point of /// averaging at all: the ping cadence is 30 s, and one bad sample must not sit on a red bar /// for eight minutes). #[test] fn the_reported_latency_is_the_mean_of_a_bounded_ring() { let mut status = NetStatus::default(); assert_eq!(status.avg_latency_ms(), None, "no pong yet"); status.record_rtt(40); assert_eq!(status.avg_latency_ms(), Some(40)); status.record_rtt(60); assert_eq!(status.avg_latency_ms(), Some(50), "the mean, not the last"); // Fill past the ring: only the newest RTT_RING samples count, so the two above age out. for _ in 0..RTT_RING { status.record_rtt(100); } assert_eq!(status.rtt_ring.len(), RTT_RING); assert_eq!(status.avg_latency_ms(), Some(100)); assert_eq!( status.latency_ms, Some(100), "the last sample stays separate" ); status.clear_rtt(); assert_eq!(status.avg_latency_ms(), None, "a disconnect forgets it all"); assert_eq!(status.latency_ms, None); } } /// The ping clock shared with the write thread's 30 s keepalive sender ([`io::PingClock`]): the /// apply drain matches each `SMSG_PONG` echo against it to measure the round trip (stored back for /// the next ping's lastRtt field, and surfaced in [`NetStatus::latency_ms`]). #[derive(Resource)] pub(crate) struct PingShared(pub(crate) std::sync::Arc>); /// The dropped-packet tally — the wire-coverage instrument (decision 0022). Every server packet the /// codec dropped on the floor lands here, keyed by opcode: `unknown` counts opcodes with **no parse /// arm at all** (`ServerPacket::Other`), `unparseable` counts packets whose parser errored (skipped /// to keep the stream aligned). Fed by [`apply_net_updates`] from `SessionEvent::PacketDropped`; /// read by the debug panel's Net section. Never cleared on reconnect — the tally is about coverage, /// not session state. #[derive(Resource, Default)] pub(crate) struct DroppedOpcodes(pub(crate) HashMap); /// Per-opcode drop counts (see [`DroppedOpcodes`]). #[derive(Default, Clone, Copy)] pub(crate) struct DropTally { pub(crate) unknown: u64, pub(crate) unparseable: u64, } /// The server in-game clock sample (`SMSG_LOGIN_SETTIMESPEED`), advanced by its timescale. `None` /// until the first time packet. Read by the lighting subsystem to drive time-of-day. #[derive(Resource, Default)] pub(crate) struct ServerTime(pub(crate) Option); /// Our 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. Read by the reaction decode (targeting): a reputation faction's NPCs colour /// by our rank with them, before any faction-template comparison. Empty until the packet lands. #[derive(Resource, Default)] pub(crate) struct Reputations(pub(crate) Vec<(u8, i32)>); /// The player's hearthstone bind point (`SMSG_BINDPOINTUPDATE`, at login + on re-bind): the /// AreaTable id the hearthstone tooltip's `$z` token names ("Returns you to Goldshire."). /// `None` until the packet lands. #[derive(Resource, Default)] pub(crate) struct HomeBind(pub(crate) Option); /// The player's equip proficiencies (`SMSG_SET_PROFICIENCY`, at login + on train): item class /// (2 weapons / 4 armor) → allowed-subclass bitmask — the client's `0xc4d4a0[class]` store. /// The item tooltip's slot-line red compares against it. Empty until the packets land. #[derive(Resource, Default)] pub(crate) struct Proficiencies(pub(crate) std::collections::HashMap); /// The server's in-game clock, from `SMSG_LOGIN_SETTIMESPEED`. WoW packs the server's `localtime()` /// into the `DateTime`, so the in-game hour:minute follows the server's wall clock; `timescale` is how /// many game-minutes pass per real second (vmangos ≈ `0.01667` → real-time). We keep the sample plus /// the [`Instant`] it arrived so the clock advances between packets. #[derive(Debug, Clone, Copy)] pub(crate) struct GameTime { /// Minute of the game day (`0..1440`) at `received`. base_minute: u32, /// Monotonic day serial at `received` (`year·372 + month·31 + day` of the packed server date — /// the packed convention's fixed 31-day months). Only differences matter: it feeds the /// celestial moon-phase precession (`(dayCounter + todPhase) mod 1.7`, decision 0485). base_day: u32, /// Game-minutes elapsed per real second. timescale: f32, /// When this sample arrived (monotonic; used to advance the clock). received: Instant, } impl GameTime { /// Build from a decoded `SMSG_LOGIN_SETTIMESPEED` (server `hours`/`minutes`/`day_serial` + /// `timescale`). pub(crate) fn new(hours: u8, minutes: u8, day_serial: u32, timescale: f32) -> Self { Self { base_minute: hours as u32 * 60 + minutes as u32, base_day: day_serial, timescale, received: Instant::now(), } } /// Current minute of the game day (`0..1440`), advanced from the sample by `timescale`. pub(crate) fn minute_of_day(&self) -> u32 { self.minute_of_day_f32() as u32 } /// Current **fractional** minute of the game day (`0.0..1440.0`), advanced from the sample by /// `timescale` — same value as [`Self::minute_of_day`] but without truncation. Drives the celestial /// body *positions* continuously (sampling at the truncated integer minute steps them once per /// game-minute, a visible jump at a fast timescale). pub(crate) fn minute_of_day_f32(&self) -> f32 { let elapsed = self.received.elapsed().as_secs_f32() * self.timescale; (self.base_minute as f32 + elapsed).rem_euclid(1440.0) } /// Continuous **days + day-fraction** since the day-serial epoch, advanced by `timescale` — /// UNWRAPPED (it crosses midnight by growing past the next integer), in `f64` (the serial /// reaches ~12k and the consumer needs sub-minute resolution on top). Feeds the celestial /// moon-phase `(dayCounter + todPhase) mod 1.7` (`0x6d41b9`; decision 0485). pub(crate) fn day_continuous(&self) -> f64 { let elapsed = self.received.elapsed().as_secs_f64() * self.timescale as f64; self.base_day as f64 + (self.base_minute as f64 + elapsed) / 1440.0 } } // ── Outbound commands + one-shot inbound events ────────────────────────────────────────────────── /// One self-movement event the controller streams to the server. Each maps to a `MSG_MOVE_*` opcode /// ([`Self::opcode`]); the writer thread attaches the current `MovementInfo`. This mirrors the real /// client: it announces each movement-*axis* transition (start/stop forward-back, strafe, turn), a /// jump, a facing change (mouse-turn while standing), and a periodic heartbeat while moving — so the /// server relays a faithful movement stream to nearby players. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum MoveKind { StartForward, StartBackward, Stop, StartStrafeLeft, StartStrafeRight, StopStrafe, StartTurnLeft, StartTurnRight, StopTurn, Jump, FallLand, StartSwim, StopSwim, SetFacing, Heartbeat, } impl MoveKind { /// The wire opcode this event sends. pub(crate) fn opcode(self) -> u16 { use benilla_protocol::messages::opcode as op; match self { MoveKind::StartForward => op::MSG_MOVE_START_FORWARD, MoveKind::StartBackward => op::MSG_MOVE_START_BACKWARD, MoveKind::Stop => op::MSG_MOVE_STOP, MoveKind::StartStrafeLeft => op::MSG_MOVE_START_STRAFE_LEFT, MoveKind::StartStrafeRight => op::MSG_MOVE_START_STRAFE_RIGHT, MoveKind::StopStrafe => op::MSG_MOVE_STOP_STRAFE, MoveKind::StartTurnLeft => op::MSG_MOVE_START_TURN_LEFT, MoveKind::StartTurnRight => op::MSG_MOVE_START_TURN_RIGHT, MoveKind::StopTurn => op::MSG_MOVE_STOP_TURN, MoveKind::Jump => op::MSG_MOVE_JUMP, MoveKind::FallLand => op::MSG_MOVE_FALL_LAND, MoveKind::StartSwim => op::MSG_MOVE_START_SWIM, MoveKind::StopSwim => op::MSG_MOVE_STOP_SWIM, MoveKind::SetFacing => op::MSG_MOVE_SET_FACING, MoveKind::Heartbeat => op::MSG_MOVE_HEARTBEAT, } } } /// The wire `ChatMsg` type a chat-bar line sends as — the FULL sendable set (decision 0288 P5; /// vmangos `HandleChatMessageOpcode`'s switch). Each maps to its `WorldWriter` sender in [`io`]'s /// dispatch; `Whisper` carries its target and `Channel` its channel name through /// [`ClientCommand::Chat`]'s `target` field. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum ChatKind { Say, Yell, Emote, Whisper, Party, Raid, RaidLeader, RaidWarning, Guild, Officer, Battleground, BattlegroundLeader, Afk, Dnd, Channel, } /// Diagnostic gate (`WOW_CAST_TRACE=1`): log our own cast lifecycle — every self `SMSG_SPELL_START` /// / `GO` / `CAST_RESULT` / `SPELL_FAILED_OTHER` / `SPELL_DELAYED` — **and** every outbound movement /// packet, at info. A live "the cast bar vanished when I got hit" repro then shows exactly which /// packet ended the cast (a pushback that should extend vs an interrupt that cancels) and whether a /// movement packet preceded it — vmangos interrupts a cast on any movement report whose position /// differs from the server's stored one (`Player::SetPosition`'s exact-float `positionChanged`). A /// reusable instrument, off by default (decision 0022 — own the instruments). pub(crate) static CAST_TRACE: std::sync::LazyLock = std::sync::LazyLock::new(|| std::env::var("WOW_CAST_TRACE").is_ok()); /// A message the ECS sends to the server through the write thread. Carries the player's pose so the /// writer thread needs no shared state. pub(crate) enum ClientCommand { /// A self-movement packet: the [`MoveKind`] opcode + the live CMovement `flags` + our pose, plus the /// airborne clock (`fall_time`, ms) and ballistic launch tail (`jump`) while jumping/falling. The /// `flags` set the base directional/turn/walk bits, `JUMPING` while airborne, and `ON_TRANSPORT` /// while standing on a boat/zepp (with its `transport` local-frame tail — decision 0438 phase 2); /// the writer serializes each tail iff its flag is set (a flag without its tail desyncs the /// server's parse). Move { kind: MoveKind, flags: u32, pos: [f32; 3], orientation: f32, /// Swim pitch (radians, +up) — written iff `SWIMMING` is in `flags`; `0` otherwise. pitch: f32, fall_time: u32, jump: Option, /// The rider's pose in the transport's local frame — written iff `ON_TRANSPORT` is in `flags`. transport: Option, }, /// Acknowledge a finished server-authored spline that drove our own player (Charge, and later /// knockback/taxi): `CMSG_MOVE_SPLINE_DONE` with our pose at the ride's endpoint and the /// `spline_id` we were driven by. The server holds a player mover as spline-pending until this /// arrives, so the controller sends it the instant the ride ends, then resumes its own stream. MoveSplineDone { flags: u32, pos: [f32; 3], orientation: f32, spline_id: u32, }, /// Answer a `SMSG_FORCE_*_SPEED_CHANGE` (`CMSG_FORCE_*_SPEED_CHANGE_ACK`): echo the mover /// `guid`/`counter`/exact `speed`, carrying the same live wire payload a [`Self::Move`] would /// (the server relocates us to it and its anticheat runs the same position tests). Mandatory — /// unacked, the server force-resolves after ~4 s and cheat-flags the session. Sent by the /// controller (which owns the live pose) off a [`SpeedChangeMessage`]. ForceSpeedAck { kind: SpeedKind, guid: u64, counter: u32, speed: f32, flags: u32, pos: [f32; 3], orientation: f32, pitch: f32, fall_time: u32, jump: Option, transport: Option, }, /// Echo a same-map teleport ack (`MSG_MOVE_TELEPORT_ACK_Client`) — without it the server freezes /// our movement until relog. TeleportAck { guid: u64, counter: u32 }, /// Echo a cross-map worldport ack (`MSG_MOVE_WORLDPORT_ACK`) — unblocks the new map's stream. WorldportAck, /// Set (or clear, with `guid == 0`) our current target on the server (`CMSG_SET_SELECTION`). SetSelection { guid: u64 }, /// Send a chat line as the given [`ChatKind`] (`CMSG_MESSAGECHAT`). `target` carries the /// whisper recipient's name — meaningful only when `kind == ChatKind::Whisper` — and is /// `None` otherwise. Plain lines with no leading `/` (including `.` dot commands, which /// vmangos parses as GM commands on the way in) ride as `ChatKind::Say`, unchanged from /// before slash commands existed. Built by [`crate::ui_chat`]'s command parser. Chat { kind: ChatKind, target: Option, text: String, }, /// Ask a player character's name (`CMSG_NAME_QUERY`); answered by a `PlayerName` event into the /// [`crate::names::NameCache`]. Sent by the cache's ask-once resolve, never directly. NameQuery { guid: u64 }, /// Ask a creature template's name (`CMSG_CREATURE_QUERY`, entry from the guid's bits 24–47); /// answered by a `CreatureName` event into the cache. CreatureQuery { entry: u32, guid: u64 }, /// Ask a pet's name (`CMSG_PET_NAME_QUERY`, pet number from the guid's bits 24–47 — where a /// creature keeps its template entry); answered by a `PetName` event into the cache. A pet /// cannot be named by [`Self::CreatureQuery`]; see [`benilla_protocol::guid::pet_number`]. PetNameQuery { pet_number: u32, guid: u64 }, /// Ask an item template (`CMSG_ITEM_QUERY_SINGLE`; `guid` = the concrete item when the ask is /// about one, `0` for template-only); answered by an `ItemTemplate` event into the /// [`crate::items::Items`] cache. Sent by the cache's ask-once resolve, never directly. ItemQuery { entry: u32, guid: u64 }, /// Use an item by wire bag position (`CMSG_USE_ITEM` — bag 255 + absolute slot for anything /// in the player's own field array, i.e. an equipment slot, the backpack or the keyring; a /// bag's own player-array slot 19–22 + inner slot otherwise). The container drain maps the /// Lua `(bagID, slot)` space before sending. `spell_index` is the template block ordinal the /// server should cast (`ItemInfo::use_spell_index`) — 0 for every item whose on-use spell is /// its first block, which is nearly all of them. /// /// `go_target` aims the use at a GameObject. That is the **key-in-a-lock** packet (decision /// 0769): opening a locked door with a key is "use the key at the door", not a bare cast of the /// key's spell — and `Spell::CanOpenLock` honours a `Lock.dbc` KEY slot only when the cast /// carries `m_CastItem`, which only this packet supplies. `None` for every ordinary use. UseItem { bag_index: u8, slot: u8, spell_index: u8, go_target: Option, }, /// Open a bag item (`CMSG_OPEN_ITEM`, same bag addressing) — the drain's fork for an /// *openable* click (`ItemInfo::openable`): a clam, an unlocked lockbox, a wrapped gift. The /// server answers `SMSG_LOOT_RESPONSE` on the **item's own guid**, so the ordinary loot feed /// opens a window over a thing in the bag; a wrapped gift instead swaps entry in place and /// sends no window. Refusals arrive as `InventoryFailure` (still locked, dead, flying). OpenItem { bag_index: u8, slot: u8 }, /// Equip a bag item (`CMSG_AUTOEQUIP_ITEM`, same bag addressing) — the drain's fork for an /// *equippable* click, mirroring the real client's equip-vs-use decision. Refusals come back /// as `InventoryFailure` events onto the UI error line. AutoEquipItem { bag_index: u8, slot: u8 }, /// Load ammo into the ammo slot (`CMSG_SET_AMMO`) — the equip drains' fork when the clicked/ /// dropped item is ammo-class (INVTYPE_AMMO), mirroring the real client's own auto-equip fork /// (wow-re `cursor-dragdrop-slots.md`). Addressed by item `entry`, not a bag slot: the stack /// stays in the bag and `PLAYER_AMMO_ID` starts referencing it. A wrong/absent ranged weapon /// refuses via `InventoryFailure`. Decision 0526. SetAmmo { entry: u32 }, /// Swap two of the player's own inventory slots (`CMSG_SWAP_INV_ITEM`) — the wire for a /// backpack-internal pick/place/swap, both slots on the player array (`INVENTORY_SLOT_ITEM_START` /// onward). The container-move drain maps the Lua backpack `(bag 0, slot)` space onto these; an /// empty destination is a move. Refusals surface as `InventoryFailure` events. SwapInvItem { src_slot: u8, dst_slot: u8 }, /// The general bag↔bag move (`CMSG_SWAP_ITEM`, decision 0216 §6 slice 2) — either endpoint may /// be an equipped bag (unlike [`ClientCommand::SwapInvItem`], the player-array-only wire). The /// container-move drain sends this instead of `SwapInvItem` whenever either end's wire bag /// isn't [`benilla_protocol::messages::BAG_PLAYER_INVENTORY`] — VERIFIED vmangos /// `Packets/Item.cpp:30-36`: body order is dstbag, dstslot, srcbag, srcslot (opcode 0x10C). /// Refusals surface as `InventoryFailure` events. SwapItem { dst_bag: u8, dst_slot: u8, src_bag: u8, src_slot: u8, }, /// Carry a partial stack from one bag position to another (`CMSG_SPLIT_ITEM`, decision 0216 /// §6) — either endpoint may be an equipped bag, unlike [`ClientCommand::SwapInvItem`]. The /// container-move drain sends this instead of a swap when the queued move carries a split /// count. Refusals surface as `InventoryFailure` events. SplitItem { src_bag: u8, src_slot: u8, dst_bag: u8, dst_slot: u8, count: u8, }, /// Destroy a bag item (`CMSG_DESTROYITEM`, decision 0216 §3): `count` 0 = the whole stack. /// Sent by the delete-confirm popup's accept (`DeleteCursorItem`), mapped from the engine's /// queued `(bag, slot, count)` destroy through the same wire-position map as every other /// container drain. DestroyItem { bag_index: u8, slot: u8, count: u8 }, /// Perform a chat emote (`CMSG_TEXT_EMOTE`); the server echoes it back to us (and everyone /// in range) as `SMSG_TEXT_EMOTE`, so the local sound/anim ride the receive path. TextEmote { text_id: u32, target: u64 }, /// Cast a spell (`CMSG_CAST_SPELL`): `target: None` = self/implicit-target cast, `Some(guid)` /// = explicit unit target. Answered by `SMSG_CAST_RESULT` (a `CastResult` event). CastSpell { spell_id: u32, target: Option }, /// Cast a spell at a **ground point** (`CMSG_CAST_SPELL` with `TARGET_FLAG_DEST_LOCATION`, /// decision 0792): the targeting-cursor commit for a ground-targeted AOE. `dest` is the /// clicked world point in **WoW coords** (`bevy_to_wow` at the send site — the wire never /// sees Bevy space). Answered by `SMSG_CAST_RESULT`. CastSpellAtDest { spell_id: u32, dest: [f32; 3] }, /// Cancel one of our own auras (`CMSG_CANCEL_AURA`, decision 0257): the right-click-a-buff wire, /// carrying the **spell id** (the server cancels by spell, not slot). No answer packet — the /// removal comes back as a `UNIT_FIELD_AURA` delta. Sent by the aura feed's cancel drain. CancelAura { spell_id: u32 }, /// Set (or clear, `packed == 0`) one action-bar slot (`CMSG_SET_ACTION_BUTTON`, decision /// 0216 §7/0218 §4): `button` is the 0-based wire slot (lua action id − 1), `packed` the /// engine's own `kind<<24 | action` word. Sent by the action drain on every queued /// `PickupAction`/`PlaceAction` mutation — client-authoritative, no answer packet. SetActionButton { button: u8, packed: u32 }, /// Start melee auto-attack on `guid` (`CMSG_ATTACKSWING`); echoed as `SMSG_ATTACKSTART`. AttackSwing { guid: u64 }, /// Stop melee auto-attack (`CMSG_ATTACKSTOP`); echoed as `SMSG_ATTACKSTOP`, whose receive path /// drops the attacker's [`crate::creature_anim::Engaged`] stance. Sent when the attack target /// is lost — Esc / click-off / target death (ref-observed: losing the target stops the swing /// and leaves the attack stance; the weapons stay drawn). AttackStop, /// Stop our ranged auto-repeat (`CMSG_CANCEL_AUTO_REPEAT_SPELL`, empty body) — the ack every /// local cancel sends (the client's sole send site `0x6ea0c6`, inside the cancel routine /// `0x6ea080`; wow-re `nocked-ammo-cancel.md`). vmangos interrupts the held auto-repeat /// spell; idempotent when the server already cancelled it first (the cast-result path). CancelAutoRepeat, /// Cancel a named in-flight cast (`CMSG_CANCEL_CAST`, one `u32` spell id). Sent by the /// wand-only auto-repeat handoff before its local cancel (`0x6095b8`, wow-re /// `nocked-ammo-cancel.md` §Q-B-5) and by the cast bar's local self-cancel (movement/Esc /// mid-cast — `ui_cast`'s mirror of the client's `AbortCast 0x6e4940` send leg). CancelCast { spell_id: u32 }, /// End our own running channel (`CMSG_CANCEL_CHANNELLING`, one `u32` spell id the server /// reads and ignores — the real client still writes it). The channel half of the local /// self-cancel (`ui_cast`). CancelChannelling { spell_id: u32 }, /// Volunteer our sheath state (`CMSG_SETSHEATHED`: 0 stowed · 1 melee · 2 ranged). The real /// client auto-sends `1` when initiating melee with weapons stowed (decision 0073, verified /// `0x5ecb70` → `0x611cf0`); it lands in our `UNIT_FIELD_BYTES_2`, which drives everyone's /// weapon placement — including our own, via the descriptor echo. SetSheathed { state: u32 }, /// Volunteer our stand state (`CMSG_STANDSTATECHANGE`: 0 stand · 1 sit · 3 sleep · 8 kneel). /// The echo into `UNIT_FIELD_BYTES_1` byte 0 drives everyone's sit/stand pose — including our /// own, via the descriptor (decision 0080c: the same pattern as sheath). StandStateChange { state: u32 }, /// The mounted space-bar flourish (`CMSG_MOUNTSPECIAL_ANIM`, empty body). The sender /// plays MountSpecial(94) on its own mount locally at send time; the broadcast echo — /// present or not by server config — is self-suppressed on receive (decision 0441 P2). MountSpecial, /// Open a gossip dialog on an NPC (`CMSG_GOSSIP_HELLO`, decision 0081 phase 2): the universal /// interact opener — the server's `CanInteractWithNPC` passes `UNIT_NPC_FLAG_NONE`, so it /// works on any interactable creature. Sent by the right-click interact route /// ([`crate::target`]); answered by `SMSG_GOSSIP_MESSAGE` (a `GossipMenu` event). GossipHello { guid: u64 }, /// Choose a gossip menu option (`CMSG_GOSSIP_SELECT_OPTION`): `option` is the chosen line's /// echoed `index`. v1 never sends a password — coded options are greyed and never selected /// (decision 0081), so the writer always omits the trailing code. The server answers a fresh /// menu (`SMSG_GOSSIP_MESSAGE`) or closes it (`SMSG_GOSSIP_COMPLETE`). GossipSelectOption { guid: u64, option: u32 }, /// Fetch a gossip menu's greeting text (`CMSG_NPC_TEXT_QUERY`) — auto-sent on menu receipt for /// its `text_id`; answered by `SMSG_NPC_TEXT_UPDATE` (an `NpcGreeting` event). Ask-once cached /// per `text_id`, like an item template (decision 0081 phase 3). NpcTextQuery { text_id: u32, guid: u64 }, /// Open a vendor's stock (`CMSG_LIST_INVENTORY`, decision 0081 phase 2): the direct opener a /// right-click on a vendor-only NPC uses. Answered by `SMSG_LIST_INVENTORY` (a /// `VendorInventory` event; the merchant window is phase 4). ListInventory { guid: u64 }, /// Buy from a vendor (`CMSG_BUY_ITEM`, decision 0081 phase 4): `entry` is the item **template** /// id (buy is by entry, not the vendor row's `muid`), `count` the number of stacks. Auto-places /// into the first free bag slot; success answers `SMSG_BUY_ITEM` + the item-create path, refusal /// `SMSG_BUY_FAILED` (a `VendorBuyFailed` event → the merchant error line). BuyItem { vendor: u64, entry: u32, count: u8 }, /// Sell an item to a vendor (`CMSG_SELL_ITEM`, decision 0081 phase 4's sell affordance): /// `item_guid` is the concrete bag item, `count` 0 = the whole stack. Success is silent (the item /// vanishes + coinage rises via `UPDATE_OBJECT`); refusal answers `SMSG_SELL_ITEM`'s error shape /// (a `VendorSellFailed` event → the merchant error line). SellItem { vendor: u64, item_guid: u64, count: u8, }, /// Buy a sold item back (`CMSG_BUYBACK_ITEM`): `slot` is the **absolute** player-array buyback /// slot 69–80 (the app maps the clicked 1-based, timestamp-sorted list index to it). Success is /// the item re-creating + coinage falling via `UPDATE_OBJECT`; refusal answers /// `SMSG_BUY_FAILED`. BuybackItem { vendor: u64, slot: u32 }, /// Repair at a repair-capable vendor (`CMSG_REPAIR_ITEM`): `item_guid` 0 = repair everything, /// else the one item. Success is durability rising + coinage falling via `UPDATE_OBJECT` /// (no dedicated answer packet). RepairItem { vendor: u64, item_guid: u64 }, /// Open the bank (`CMSG_BANKER_ACTIVATE`, decision 0604): the direct opener a right-click on /// a pure banker (bit 8 the lowest service bit) uses — a gossip-flagged banker routes through /// the gossip menu instead, whose bank option makes the server volunteer the same answer. /// Answered by `SMSG_SHOW_BANK` (a `ShowBank` event → the bank window opens off local state). BankerActivate { guid: u64 }, /// Buy the next bank-bag slot (`CMSG_BUY_BANK_SLOT`, decision 0604): the purchase popup's /// accept. **No packet on success** — the descriptor's `PLAYER_BYTES_2` bank-bag count + the /// falling coinage are the confirmation; refusal answers `SMSG_BUY_BANK_SLOT_RESULT` (a /// `BuyBankSlotResult` event → the red error line). BuyBankSlot { guid: u64 }, /// Deposit: auto-place a bag/doll item into the bank (`CMSG_AUTOBANK_ITEM`, decision 0604) — /// the right-click auto-move while the bank window is open. Wire `(bag, slot)`; refusal /// answers `SMSG_INVENTORY_CHANGE_FAILURE` (the red line), success moves the item via /// `UPDATE_OBJECT` field deltas. AutoBankItem { bag: u8, slot: u8 }, /// Withdraw: auto-place a bank item into the bags (`CMSG_AUTOSTORE_BANK_ITEM`, decision 0604) /// — the right-click auto-move on a bank slot. Same wire shape and answers as /// [`Self::AutoBankItem`] (vmangos routes by whether the source is a bank position). AutoStoreBankItem { bag: u8, slot: u8 }, /// Ask (or re-ask) a trainer's service list (`CMSG_TRAINER_LIST`, decision 0237): one trainer /// guid. The window first opens off the gossip trainer option's `SMSG_TRAINER_LIST`; this is the /// *refresh* verb, re-requested after a purchase to repaint the bought row green→gray (the server /// does not auto-resend on a buy). Answered by `SMSG_TRAINER_LIST` (a `TrainerList` event). TrainerList { trainer: u64 }, /// Buy (learn) a trainer service (`CMSG_TRAINER_BUY_SPELL`, decision 0237): the trainer guid + the /// service's `spell_id`. Sent by the Train button ([`crate::ui_trainer`]'s buy drain). Success /// answers `SMSG_TRAINER_BUY_SUCCEEDED` and delivers the spell via `SMSG_LEARNED_SPELL`; refusal /// answers `SMSG_TRAINER_BUY_FAILED` (a `TrainerBuyFailed` event → the window's error line). TrainerBuySpell { trainer: u64, spell_id: u32 }, /// Spend talent points (`CMSG_LEARN_TALENT`, decision 0304): a `Talent.dbc` row id + the /// requested rank (0-based, learn-up-to — the click sends the current rank count). Sent by /// the talent window's click-to-learn. No dedicated reply: success arrives as the rank /// spell's learn effects + the refreshed `PLAYER_CHARACTER_POINTS1`. LearnTalent { talent_id: u32, rank: u32 }, /// Abandon a whole skill line (`CMSG_UNLEARN_SKILL`): the skills pane's unlearn button → /// the `UNLEARN_SKILL` popup's accept ([`crate::ui_char`]'s abandon drain). No ack — the /// server's `SetSkill(id, 0, 0)` returns as a `PLAYER_SKILL_INFO` field update, which the /// skills feed re-pushes (the engine never removes the line locally). UnlearnSkill { skill_id: u32 }, /// Use a world GameObject (`CMSG_GAMEOBJ_USE`, decision 0236): a full guid naming the /// chest/door/quest-object/lever under the cursor. Sent by the right-click route /// ([`crate::target`]) when the nearest hovered CGObject is a usable GameObject. 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 — or refuses silently. GameObjUse { guid: u64 }, /// Report walking into an `AreaTrigger.dbc` volume (`CMSG_AREATRIGGER`): the trigger's id. /// Sent by [`crate::area_trigger`]'s per-frame containment check — the client's whole part in /// the system. The server answers a teleport trigger with the ordinary /// `SMSG_TRANSFER_PENDING`/`SMSG_NEW_WORLD` pair (or a same-map `MSG_MOVE_TELEPORT_ACK`), a /// refused one with `SMSG_AREA_TRIGGER_MESSAGE`, and most with nothing at all. AreaTrigger { trigger_id: u32 }, /// Ask a GameObject's template (`CMSG_GAMEOBJECT_QUERY`, decision 0239): `entry` + the asking /// `guid`. Sent ask-once when a GameObject streams in ([`crate::go_templates`]); the answer's /// `lockId` decides whether a right-click uses it or casts an OPEN_LOCK spell. GameObjectQuery { entry: u32, guid: u64 }, /// Cast an OPEN_LOCK spell at a **GameObject** (`CMSG_CAST_SPELL`, decision 0239): the right-click /// on a locked chest / mining vein / herb node. The server runs `EffectOpenLock` → a chest opens /// its loot (`SMSG_LOOT_RESPONSE`); the profession/skill gate is the server's. CastSpellGameObject { spell_id: u32, go_guid: u64 }, /// An item-targeted cast (`TARGET_FLAG_ITEM` + packed guid) — the CraftFrame enchant pick /// (decision 0437 phase 3). CastSpellItem { spell_id: u32, item_guid: u64 }, /// Open a corpse/creature's loot (`CMSG_LOOT`, decision 0084): a full guid naming the lootable /// unit. Sent by the right-click loot route ([`crate::target`]) on the loot classification /// (a dead unit carrying `UNIT_DYNFLAG_LOOTABLE`). Answered by `SMSG_LOOT_RESPONSE` (a /// `LootResponse` event on success, `LootError` on refusal). Not usable on a GameObject guid — /// the server rejects those; a GameObject loots via `GameObjUse` above. Loot { guid: u64 }, /// Take one loot row into the bags (`CMSG_AUTOSTORE_LOOT_ITEM`): `slot` is the **wire** loot slot /// (0-based, from `SMSG_LOOT_RESPONSE`), which the loot drain maps from the clicked 1-based /// display row. The server auto-places into the first free bag slot; success answers /// `SMSG_LOOT_REMOVED` + `SMSG_ITEM_PUSH_RESULT` + the item-create path. AutostoreLootItem { slot: u8 }, /// Take the loot's coin pile (`CMSG_LOOT_MONEY`, empty body): the intent a click on the /// synthesized coin row queues. Answered by `SMSG_LOOT_CLEAR_MONEY` (+ the coinage rising via /// `UPDATE_OBJECT`; solo looting gets no `SMSG_LOOT_MONEY_NOTIFY` on this server). LootMoney, /// Close the loot window (`CMSG_LOOT_RELEASE`, decision 0084): the server ignores `guid` and /// releases whatever loot it has stored for us. Sent on `CloseLoot` (the window's `OnHide`). /// Answered by `SMSG_LOOT_RELEASE_RESPONSE` (a `LootReleaseResponse` event). LootRelease { guid: u64 }, /// Cast a group-loot vote (`CMSG_LOOT_ROLL`, decision 0591): the Need/Greed/Pass click on a /// `GroupLootFrame`. The roll is addressed by the `(looted_target, item_slot)` pair the server /// opened it with — the client-internal `rollID` the Lua side uses never reaches the wire. /// Answered by an `SMSG_LOOT_ROLL` echo of our vote, then the roll's resolution. LootRoll { looted_target: u64, item_slot: u32, roll_type: u8, }, /// Look at an available quest (`CMSG_QUESTGIVER_QUERY_QUEST`, decision 0088): a greeting/gossip /// quest row click. Answered by `SMSG_QUESTGIVER_QUEST_DETAILS` (a `QuestDetail` event → the /// accept panel). QuestgiverQuery { npc: u64, quest: u32 }, /// Accept a quest (`CMSG_QUESTGIVER_ACCEPT_QUEST`): the detail panel's Accept button. Adds it to /// the log; the server closes the gossip window (`SMSG_GOSSIP_COMPLETE`). QuestgiverAccept { npc: u64, quest: u32 }, /// Ask a quest's turn-in progress panel (`CMSG_QUESTGIVER_COMPLETE_QUEST`): an active greeting/ /// gossip quest row click. Answered by `SMSG_QUESTGIVER_REQUEST_ITEMS` (a `QuestProgress` event), /// or OFFER_REWARD when there are no required items. QuestgiverComplete { npc: u64, quest: u32 }, /// Advance from the progress panel to the reward panel (`CMSG_QUESTGIVER_REQUEST_REWARD`): the /// progress panel's Continue button. Answered by `SMSG_QUESTGIVER_OFFER_REWARD` (a `QuestOffer` /// event). QuestgiverRequestReward { npc: u64, quest: u32 }, /// Choose a reward and finish the quest (`CMSG_QUESTGIVER_CHOOSE_REWARD`, `choice` = the 0-based /// choice index): the reward panel's Complete button. Answered by `SMSG_QUESTGIVER_QUEST_COMPLETE` /// (a `QuestComplete` event) + the XP/money/item grants via `UPDATE_OBJECT`. QuestgiverChooseReward { npc: u64, quest: u32, choice: u32 }, /// Ask a quest's full template (`CMSG_QUEST_QUERY`, the quest-log slice, decision 0088's /// deferred second half): the log window's ask-once detail source, distinct from /// `QuestgiverQuery` (which needs an NPC guid, not just the quest id). Answered by /// `SMSG_QUEST_QUERY_RESPONSE` (a `QuestTemplate` event) into the [`crate::ui_quest_log::QuestLog`] /// cache. QuestQuery { quest: u32 }, /// Ask an NPC's questgiver dialog status (`CMSG_QUESTGIVER_STATUS_QUERY`) — the overhead /// `!`/`?` marker's value, answered by `SMSG_QUESTGIVER_STATUS`. QuestgiverStatusQuery { npc: u64 }, /// Abandon a quest-log slot (`CMSG_QUESTLOG_REMOVE_QUEST`): the log window's confirmed abandon /// (`AbandonQuest()`'s two-step). No ack SMSG — the server clears the `PLAYER_QUEST_LOG` slot /// fields directly, which the next feed pass reads as the slot going empty. QuestlogRemove { slot: u8 }, // ── The mail arc (decision 0544; writer bodies in benilla-protocol `world/writer/mail.rs`). ── /// Ask the mailbox's inbox page (`CMSG_GET_MAIL_LIST`) — the window's open verb and its /// refresh. Answered by `SMSG_MAIL_LIST_RESULT` (a `MailList` event). GetMailList { mailbox: u64 }, /// Send a mail (`CMSG_SEND_MAIL`): recipient/subject/body, an optional item attachment /// (`item_guid == 0` = none), money, and COD. Answered by `SMSG_SEND_MAIL_RESULT`. SendMail { mailbox: u64, receiver: String, subject: String, body: String, item_guid: u64, money: u32, cod: u32, }, /// Take a mail's attached money (`CMSG_MAIL_TAKE_MONEY`). MailTakeMoney { mailbox: u64, mail_id: u32 }, /// Take a mail's attached item (`CMSG_MAIL_TAKE_ITEM`). MailTakeItem { mailbox: u64, mail_id: u32 }, /// Mark a mail read (`CMSG_MAIL_MARK_AS_READ`) — sent when a letter opens. No response packet. MailMarkAsRead { mailbox: u64, mail_id: u32 }, /// Return a mail to its sender (`CMSG_MAIL_RETURN_TO_SENDER`). MailReturnToSender { mailbox: u64, mail_id: u32 }, /// Delete a mail (`CMSG_MAIL_DELETE`) — the taken-letter close and the explicit Delete button /// alike. MailDelete { mailbox: u64, mail_id: u32 }, /// Make a permanent copy of a letter's body (`CMSG_MAIL_CREATE_TEXT_ITEM`) — the open letter's /// attachment-row letter button. Answered by `SMSG_SEND_MAIL_RESULT` (action MADE_PERMANENT). MailCreateTextItem { mailbox: u64, mail_id: u32 }, /// Fetch a letter's body text (`CMSG_ITEM_TEXT_QUERY`) — sent once per mail whose /// `item_text_id != 0`. Answered by `SMSG_ITEM_TEXT_QUERY_RESPONSE` (a `MailItemText` event). ItemTextQuery { text_id: u32, mail_id: u32 }, /// Ask whether unread mail is waiting (`MSG_QUERY_NEXT_MAIL_TIME`, empty body). Sent once at /// login to seed `HasNewMail()`/the minimap letter icon (decision 0544 P3, sent by /// `crate::ui_mail`'s world-enter one-shot). QueryNextMailTime, /// Ask to inspect a player (`CMSG_INSPECT`, `u64 target`) — the UnitPopup INSPECT row /// (decision 0631). Fire-and-forget: the reply echoes the guid and nothing else, and the window /// paints from the already-streamed PUBLIC `PLAYER_VISIBLE_ITEM_*` fields. Sent anyway because /// server-side it also sets our selection (`MiscHandler.cpp:945`), as the real client's does. Inspect { target: u64 }, // ── The player-trade arc (decision 0592; writer bodies in benilla-protocol // `world/writer/trade.rs`). ───────────────────────────────────────────────────────────── /// Offer to trade with a player (`CMSG_INITIATE_TRADE`, `u64 target`) — the UnitPopup TRADE row. /// The server answers the initiator on any refusal (`SMSG_TRADE_STATUS`) and, on success, sends /// the *target* `BEGIN_TRADE`. InitiateTrade { target: u64 }, /// The target's auto-reply to `BEGIN_TRADE` (`CMSG_BEGIN_TRADE`, empty) — makes the server emit /// `OPEN_WINDOW` to both sides. Sent by the net bridge the frame it decodes a `BEGIN_TRADE` /// status (decision 0592 P1; the reference client auto-answers, `TradeHandler.cpp`). BeginTrade, /// Press the Trade button (`CMSG_ACCEPT_TRADE`) — the accept half of the two-sided confirm. AcceptTrade, /// Drop your own accept but stay in the trade (`CMSG_UNACCEPT_TRADE`). UnacceptTrade, /// Cancel/close the trade (`CMSG_CANCEL_TRADE`, empty) — the window's close/cancel verb. CancelTrade, /// Offer this many copper on our side (`CMSG_SET_TRADE_GOLD`, `u32`) — the money input's /// value-changed callback (decision 0592 P2). Clears both accepts + re-arms the 200 ms scam delay /// server-side; the server echoes our new gold back as `SMSG_TRADE_STATUS_EXTENDED`. SetTradeGold { copper: u32 }, /// Place the item at inventory (`bag`, `slot`) into our trade slot `trade_slot` (0-based, 0..=6; /// 6 = the non-traded/enchant slot) — `CMSG_SET_TRADE_ITEM`, a bag item dropped onto the slot /// (decision 0592 P2). The server echoes the filled slot back as `SMSG_TRADE_STATUS_EXTENDED`. SetTradeItem { trade_slot: u8, bag: u8, slot: u8 }, /// Clear our trade slot `trade_slot` (0-based) — `CMSG_CLEAR_TRADE_ITEM`, an empty-cursor click on /// a filled slot (decision 0592 P2). ClearTradeItem { trade_slot: u8 }, /// Leave the world back to character select (`CMSG_LOGOUT_REQUEST`, the `/logout` command — /// decision 0193). The server answers `SMSG_LOGOUT_COMPLETE` (instant for a resting/GM /// character), which the IO thread turns into a [`LoggedOutMessage`] + an immediate /// reconnect whose fresh roster is the select screen. Logout, /// Call off a pending logout (`CMSG_LOGOUT_CANCEL`) — the CAMP/QUIT dialog's Cancel, decision /// 0674. Only the non-instant logout has anything to cancel; the server acks with /// `SMSG_LOGOUT_CANCEL_ACK`, which becomes the UI's `LOGOUT_CANCEL` event. LogoutCancel, /// Join a chat channel (`CMSG_JOIN_CHANNEL` — `/join`, and the 0288 P6 zone auto-join). JoinChannel { name: String, password: String }, /// Leave a chat channel (`CMSG_LEAVE_CHANNEL` — `/leave`). LeaveChannel { name: String }, /// Ask a channel's roster (`CMSG_CHANNEL_LIST` — `/chatlist `). ChannelList { name: String }, /// `/random [min] [max]` (`MSG_RANDOM_ROLL`). RandomRoll { min: u32, max: u32 }, /// `/played` (`CMSG_PLAYED_TIME`). PlayedTime, /// Acknowledge a triggered cinematic as finished (`CMSG_COMPLETE_CINEMATIC`). benilla doesn't /// play cinematics yet, so the Net drain answers every `SMSG_TRIGGER_CINEMATIC` immediately — /// the skip a real player's ESC sends. Without the ack, vmangos anchors object visibility to /// the flying cinematic camera (a first login's race intro) and every NPC around the body /// despawns until relog. A future cinematic arc moves this send to the playback's end. CompleteCinematic, /// **Acknowledge a granted mover mode** — root, water-walk, feather-fall or hover (decisions /// 0308, 0866): the echoed `counter` + our live pose, on the opcode /// [`MoveMode::ack_opcode`] picks. Sent by the controller the frame the /// [`MoveModeMessage`] lands; un-acked, the server never applies the change and observers never /// see it. /// /// `flags` must already carry the applied mode bit — the controller ORs it in before sending /// (vmangos KICKS a root-apply ack whose `MovementInfo` lacks `MOVEFLAG_ROOT`, and reads the /// word as the mover's new flags for the other three). MoveModeAck { guid: u64, counter: u32, mode: MoveMode, apply: bool, flags: u32, pos: [f32; 3], orientation: f32, }, /// Release the spirit (`CMSG_REPOP_REQUEST`, empty body) — the DEATH popup's Release Spirit /// (and its timeout's server-forced twin is server-side; decision 0308 slice 1). RepopRequest, /// Ask where our corpse is (`MSG_CORPSE_QUERY`, empty request) — on becoming a ghost and on /// login-while-dead; answered into [`crate::death::DeathNet`] (decision 0308 §5). CorpseQuery, /// Reclaim our corpse (`CMSG_RECLAIM_CORPSE` — RECOVER_CORPSE's Accept). Server-gated to /// ghost + delay elapsed + 39 yd; success returns as ordinary descriptor deltas. ReclaimCorpse { corpse: u64 }, /// Take the spirit healer's resurrection (`CMSG_SPIRIT_HEALER_ACTIVATE` — the XP_LOSS /// confirm's final Accept): 50% res, 25% durability, sickness at level ≥ 11. SpiritHealerActivate { npc: u64 }, /// Answer a resurrection offer (`CMSG_RESURRECT_RESPONSE` — the RESURRECT popups). ResurrectResponse { caster: u64, accept: bool }, // ── The group/party family (decision 0434; writer bodies in benilla-protocol // `world/writer/group.rs`) ────────────────────────────────────────────────────────────── /// Invite a player by name (`CMSG_GROUP_INVITE` — `/invite`, the unit menus, the invite ack /// comes back as `SMSG_PARTY_COMMAND_RESULT`). GroupInvite { name: String }, /// Accept the pending group invite (`CMSG_GROUP_ACCEPT` — the PARTY_INVITE popup's Accept). GroupAccept, /// Decline the pending group invite (`CMSG_GROUP_DECLINE` — Decline/timeout/escape). GroupDecline, /// Kick a member by name (`CMSG_GROUP_UNINVITE` — `/uninvite`, leader only). GroupUninvite { name: String }, /// Hand leadership to a member (`CMSG_GROUP_SET_LEADER` — `/promote`, leader only). GroupSetLeader { guid: u64 }, /// Leave the group (`CMSG_GROUP_DISBAND` — the wire's leave verb, despite the name). GroupLeave, /// Set the loot rules (`CMSG_LOOT_METHOD`): `method` 0..4, `master` guid (master loot only), /// `threshold` quality 2..4. Leader only; echoes back as a fresh `SMSG_GROUP_LIST`. LootMethod { method: u32, master: u64, threshold: u32, }, /// Mark a unit with a raid-target icon (`MSG_RAID_TARGET_UPDATE` outbound — the popup's /// submenu, decision 0434 §5): wire `icon` 0..7, `guid` 0 clears that icon's slot. Leader/ /// assistant only server-side; echoes back as the delta form. SetRaidTarget { icon: u8, guid: u64 }, // ── The duel family (decision 0633; writer bodies in benilla-protocol // `world/writer/duel.rs`). Challenging is a `CastSpell` of the duel spell, not a verb here. /// Accept a duel challenge (`CMSG_DUEL_ACCEPTED`) — the popup's Accept, and the challenger's /// own auto-accept the instant its request echoes back. DuelAccepted { arbiter: u64 }, /// Decline / cancel / forfeit a duel (`CMSG_DUEL_CANCELLED`) — one opcode for all three; the /// server reads the intent from the duel's state. DuelCancelled { arbiter: u64 }, // ── The social family (decision 0668; writer bodies in benilla-protocol // `world/writer/social.rs`). Note the wire's own asymmetry: add by NAME, remove by GUID. /// Refresh the friend list (`CMSG_FRIEND_LIST`) — the FrameXML's `ShowFriends()`. The list /// also arrives unasked at login, so this is never the only path. FriendListRequest, /// Befriend a character by name (`CMSG_ADD_FRIEND`). AddFriend { name: String }, /// Drop a friend by guid (`CMSG_DEL_FRIEND`) — the caller resolves the name first. DelFriend { guid: u64 }, /// Ignore a character by name (`CMSG_ADD_IGNORE`). AddIgnore { name: String }, /// Stop ignoring, by guid (`CMSG_DEL_IGNORE`). DelIgnore { guid: u64 }, /// Tell the server we dropped an ignored player's whisper (`CMSG_CHAT_IGNORED`) so it can /// answer them "X is ignoring you". The drop itself is entirely client-side — the server /// keeps delivering an ignored player's chat, which is why the client has to filter *and* /// report. ChatIgnored { guid: u64 }, /// Run a `/who` (`CMSG_WHO`) — the filter string is already parsed into wire fields /// (`ui_social::who_query`, which needs the DBCs the parse resolves names against). Who { request: Box }, /// Ask to flip our own PvP flag (`CMSG_TOGGLE_PVP`, empty body — decision 0646): `/pvp` and /// the unit popup's PvP row, both through the VM's intent queue. Nothing local changes; the /// answer is the descriptor's PvP bit (and flagging *off* waits out the server's 300 s timer). TogglePvp, // ── The taxi/flight-master family (decision 0484 phase 1; writer bodies in // benilla-protocol `messages::taxi`) ────────────────────────────────────────────────── /// Ask a nearby flight master's known status (`CMSG_TAXINODE_STATUS_QUERY`): the guid of the /// flight master, not ours (vmangos's own comment on the wire field). Answered by /// `SMSG_TAXINODE_STATUS` (a `TaxiNodeStatus` event). No send site yet — phase 2's cursor/ /// tooltip work; wired end-to-end now so that phase only adds a call site. #[allow(dead_code)] TaxiNodeStatusQuery { guid: u64 }, /// Open a flight master's taxi map (`CMSG_TAXIQUERYAVAILABLENODES`, decision 0496 I4: /// CONFIRMED as built): the direct opener the taxi-cursor interact sends — the interact ladder /// is first-match-wins low→high over `UNIT_NPC_FLAGS`, so a gossip+taxi NPC pre-empts to /// gossip and only a pure flightmaster reaches this send. A known node answers /// `SMSG_SHOWTAXINODES` (a `TaxiNodesShown` event); a never-visited node instead answers the /// first-visit learn pair (`NewTaxiPath` + `TaxiNodeStatus`) and opens nothing on this click. TaxiQueryNodes { guid: u64 }, /// Fly a single hop (`CMSG_ACTIVATETAXI`): the flight-master guid, the source node, the /// destination node. Answered by `SMSG_ACTIVATETAXIREPLY`; success continues into the mount + /// `SMSG_MONSTER_MOVE` flight (the existing self-spline rails, decision 0260). Sent by the /// taxi-map window's `TakeTaxiNode` drain on a one-hop route pick (`crate::ui_taxi`, phase 2). ActivateTaxi { guid: u64, source_node: u32, dest_node: u32, }, /// Fly a multi-hop chain in one send (`CMSG_ACTIVATETAXIEXPRESS`, decision 0496 §TU-3): sent /// when no direct `TaxiPath` edge exists current→target — the byte-verified discriminator, /// not hop count. The flight-master guid, the route's combined fare, and the full node chain /// in order. Answered by `SMSG_ACTIVATETAXIREPLY`, same as [`Self::ActivateTaxi`]. Sent by the /// taxi-map window's `TakeTaxiNode` drain on an edge-less route pick (`crate::ui_taxi`, phase 2). ActivateTaxiExpress { guid: u64, total_cost: u32, nodes: Vec, }, } /// The account's character roster (`SMSG_CHAR_ENUM`, bridged from the Net drain): the IO thread is /// parked at character select waiting for the app's pick. Consumed by [`crate::char_select`]'s /// policy — auto-answer (pending pick / `WOW_CHAR`) or show the roster and wait for the director. /// `realm` is the auth realm-list entry this session connected to (name + type for the screen's /// realm banner, decision 0465). #[derive(Message)] pub(crate) struct CharListMessage { pub(crate) characters: Vec, pub(crate) realm: Option, } /// The result of an in-place char create/delete the parked IO thread serviced (decision 0423), /// bridged from the Net drain. A refreshed [`CharListMessage`] precedes a successful one. Consumed by /// the char-create screen (`crate::char_create`) to show the outcome; `code` is the raw /// `WorldResult` byte the screen maps to its 1.12 status text. #[derive(Message, Clone, Copy)] pub(crate) struct CharActionResultMessage { pub(crate) action: benilla_protocol::CharAction, pub(crate) code: u8, } /// We entered the world (the IO thread's `Connected`, bridged from the Net drain): flips /// [`crate::char_select::ClientState`] to `InWorld`. #[derive(Message)] pub(crate) struct EnteredWorldMessage; /// One `CHAT_MSG_SYSTEM` line — the server's own answer to a GM dot-command ("You set god mode to /// on for …", "There is no such command", "Player not found!"). It has always been *logged* /// (`net: server says — …`, decision 0651); this makes it *readable*, which is what lets a sender /// know whether its command landed. That matters most for server state the descriptor does not /// carry: vmangos's god mode is a runtime-only `Unit::m_invincibilityHpThreshold` with no field and /// no flag on the wire, so this line is the only ground truth there is (decision 0677). #[derive(Message, Clone)] pub(crate) struct ServerSaidMessage { pub(crate) text: String, } /// The server confirmed our logout (`SMSG_LOGOUT_COMPLETE`): back to character select. The Net /// drain does the self-teardown (the part 0065's disconnect teardown deliberately keeps); this /// message flips [`crate::char_select::ClientState`] and clears the pick policy's pending state. #[derive(Message)] pub(crate) struct LoggedOutMessage; /// A login attempt progressed (the IO thread's `LoginStage`, bridged from the Net drain): the /// login screen's connecting dialog quotes the matching `LOGIN_STATE_*` string (decision 0539). #[derive(Message, Clone, Copy)] pub(crate) struct LoginStageMessage { pub(crate) stage: benilla_protocol::LoginStage, } /// The session ended (socket death, logout's teardown edge) — bridged from the Net drain's /// `Disconnected` arm. [`crate::login`]'s policy reads it as "the IO thread is back at its /// pre-logon park": with pending credentials it schedules the silent re-auth (decision 0539 §3 — /// 0065's seamless reconnect, paced app-side). #[derive(Message, Clone)] pub(crate) struct DisconnectedMessage { pub(crate) reason: String, } /// A login attempt failed before the roster (decision 0539): `code` is the server's auth result /// byte on a refusal (mapped to its `AUTH_*` glue string), `None` on a transport failure. The IO /// thread is back at its pre-logon park; [`crate::login`]'s policy decides what happens next. /// `terminal` marks a codeless failure that retrying cannot fix (the server is unusable by this /// client — e.g. it requires Warden): show `reason` and stop, never resubmit. #[derive(Message, Clone)] pub(crate) struct LoginFailedMessage { pub(crate) code: Option, pub(crate) reason: String, pub(crate) terminal: bool, } /// A same-map teleport for our player: snap to the pose, then echo the ack. Written by /// [`apply_net_updates`] (only for our guid), read by the player controller in `WorldStage::Input`. #[derive(Message, Clone, Copy)] pub(crate) struct TeleportMessage { pub(crate) guid: u64, pub(crate) counter: u32, pub(crate) position: [f32; 3], pub(crate) orientation: f32, } /// A **server-authored move for our own mover** — an inbound `MSG_MOVE_*` whose guid is ours. It is /// never an echo of our own reporting: vmangos stamps every one `MovementInfo::SetAsServerSide` /// (`ctime = 0`, "not a client packet"), and the senders are all server-side edges with no /// handshake and no ack owed — `.go forward`/`up`/`relative` (`Unit::NearLandTo` → /// `MSG_MOVE_FALL_LAND`), `.cheat fly`/`fixedz` and the movement anticheat's snap-back /// (`SendHeartBeat(true)` → `MSG_MOVE_HEARTBEAT`). /// /// **The real client applies these** — its inbound move path has no mover-guid gate at all, and the /// local player sits in the object manager under its own guid, so a self-addressed packet resolves /// and applies exactly like a remote's (wow-re `system/collision/scratch/self-addressed-move.md`; /// decision 0725, which corrects the drop this used to take). Our avatar's motion source is the /// controller rather than the `RemoteMotion` lane, so the pose crosses as this message and lands in /// `player::wire_in`. #[derive(Message, Clone, Copy)] pub(crate) struct SelfMoveMessage { pub(crate) position: [f32; 3], pub(crate) orientation: f32, /// The wire's `MOVEMENTFLAGS`. Merged into ours under a mask, never assigned — see /// [`crate::creature_anim::move_flags::SERVER_AUTHORED`]. pub(crate) flags: u32, pub(crate) pitch: f32, pub(crate) fall_time: u32, pub(crate) jump: Option, } /// The server changed one of our own mover's speeds (`SMSG_FORCE_*_SPEED_CHANGE` — aura, mount, GM /// `.modify speed`). Written by [`apply_net_updates`] (which also applies the new value to the /// entity's [`UnitSpeeds`], and only forwards changes addressed to our guid); read by the player /// controller, which answers with [`ClientCommand::ForceSpeedAck`] carrying its live wire state — /// the same pattern as [`TeleportMessage`], because only the controller owns the honest pose. #[derive(Message, Clone, Copy)] pub(crate) struct SpeedChangeMessage { pub(crate) guid: u64, pub(crate) kind: SpeedKind, pub(crate) counter: u32, pub(crate) speed: f32, } /// **The server granted or revoked a mover mode** on our own mover — root, water-walk, feather-fall /// or hover (the ack'd movement-mode family; decision 0866). Sibling of [`SpeedChangeMessage`] in /// every respect: written by [`apply_net_updates`] for our guid only, read by the player controller, /// which is the one place that owns both the mode state and the honest pose the ack must carry. /// /// This used to be two death-arc-shaped messages (`MoveRootMessage`/`WaterWalkMessage` in /// `crate::death`), which is why the other two modes were never noticed as missing: root arrived at /// death, water-walk arrived in ghost form, and nothing framed them as one family. They are one — /// the server's own `IsFlagAckOpcode` set. #[derive(Message, Clone, Copy)] pub(crate) struct MoveModeMessage { /// Our mover's guid (the bridge only emits ours) — echoed in the ack body. pub(crate) guid: u64, pub(crate) counter: u32, pub(crate) mode: MoveMode, pub(crate) apply: bool, } /// A cross-map worldport (`.tele Orgrimmar`, initial-login map, a boat crossing the sea): snap /// the avatar, swap the map's ADTs, and ack if required. Written by [`apply_net_updates`], read /// by the player controller. #[derive(Message, Clone, Copy)] pub(crate) struct WorldportMessage { pub(crate) map_id: u32, /// **Boat-local** when `transport_entry` is set (vmangos `SendNewWorld` sends the rider's /// `GetTransportPos()` — decision 0455); world otherwise. pub(crate) position: [f32; 3], pub(crate) orientation: f32, pub(crate) needs_ack: bool, /// The `gameobject_template` entry of the transport carrying us through this transfer, from /// the preceding `SMSG_TRANSFER_PENDING` (the [`PendingTransfer`] latch). `Some` ⇒ the pose /// above is boat-local and the ride survives the port; `None` ⇒ world pose, and any ride is /// stale (the server detached us — e.g. a GM `.tele` off a deck). pub(crate) transport_entry: Option, } /// The `SMSG_TRANSFER_PENDING` latch (decision 0455): a far teleport was announced; the value /// tells the coming worldport whether we ride a transport through it (and which). Set by the /// apply layer's TransferPending arm, consumed by its worldport arm, cleared on abort and on /// disconnect. #[derive(Resource, Default)] pub(crate) struct PendingTransfer(pub(crate) Option); #[derive(Clone, Copy)] pub(crate) struct PendingTransferInfo { pub(crate) map_id: u32, pub(crate) transport_entry: Option, } /// A server-pushed sound (`SMSG_PLAY_SOUND`/`PLAY_MUSIC`/`PLAY_OBJECT_SOUND`): a SoundEntries kit /// to play — 2D, on the music channel, or 3D at `source` (guid already resolved → entity by /// [`apply_net_updates`]; `None` when the source isn't streamed to us — consumers play it 2D /// then, audible beats dropped). Read by the sound subsystem. #[derive(Message, Clone, Copy)] pub(crate) struct ServerSoundMessage { pub(crate) kind: ServerSoundKind, pub(crate) sound_id: u32, pub(crate) source: Option, } #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum ServerSoundKind { Sound2d, Music, ObjectSound, } /// A nearby unit's emote (`SMSG_TEXT_EMOTE` / `SMSG_EMOTE`, bridged from the Net drain), guid /// already resolved to its entity. `None` source = the performer isn't streamed (drop — an /// emote needs its performer for voice race/sex and position). #[derive(Message, Clone, Copy)] pub(crate) struct EmoteMessage { pub(crate) source: Option, pub(crate) kind: EmoteKind, } #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum EmoteKind { /// A chat emote — the `EmotesText.dbc` id (voice by performer race/sex). Text(u32), /// An anim emote — the `Emotes.dbc` id (its `EventSoundID`). Anim(u32), } /// A creature flared at someone (`SMSG_AI_REACTION`, bridged from the Net drain; decision 0280): /// `hostile` = reaction 2 HOSTILE (sent on every creature melee-attack start) vs reaction 0, the /// stealth pre-aggro ALERT. Pure audio in the client (`0x6056e0` — no animation, nameplate, or /// UI); consumer: `sound::creature`. #[derive(Message, Clone, Copy)] pub(crate) struct AiReactionMessage { pub(crate) unit: Entity, pub(crate) hostile: bool, } /// The zone's weather state (`SMSG_WEATHER`, bridged from the Net drain): the loop kit for the /// sound subsystem; `weather_type`/`grade`/`instant` for the visuals' state machine /// (`weather::weather_tick`, decision 0310). #[derive(Message, Clone, Copy)] pub(crate) struct WeatherMessage { pub(crate) weather_type: u32, pub(crate) grade: f32, /// A SoundEntries loop kit (8533..8558), 0 = clear skies. pub(crate) sound_id: u32, pub(crate) instant: bool, }