- /target and /assist find units by name like the original, and /follow walks you behind your target - right click opens clams and lockboxes, and the tooltip promises it - the freeze thaws clean, poses release instead of sticking, and you drop to neutral standing before it grips - unit frame art layers over the bars like the original Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1313 lines
75 KiB
Rust
1313 lines
75 KiB
Rust
//! 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::<GuidIndex>()
|
||
.init_resource::<SelfGuid>()
|
||
.init_resource::<PendingTransfer>()
|
||
.init_resource::<NetStatus>()
|
||
.init_resource::<DroppedOpcodes>()
|
||
.init_resource::<ServerTime>()
|
||
.init_resource::<Reputations>()
|
||
.init_resource::<HomeBind>()
|
||
.init_resource::<Proficiencies>()
|
||
.init_resource::<crate::names::NameCache>()
|
||
.init_resource::<crate::go_templates::GameObjectTemplates>()
|
||
.init_resource::<crate::items::Items>()
|
||
.init_resource::<crate::world_state::WorldStates>()
|
||
.add_message::<TeleportMessage>()
|
||
.add_message::<SelfMoveMessage>()
|
||
.add_message::<SpeedChangeMessage>()
|
||
.add_message::<MoveModeMessage>()
|
||
.add_message::<ServerSoundMessage>()
|
||
.add_message::<WeatherMessage>()
|
||
.add_message::<EmoteMessage>()
|
||
.add_message::<AiReactionMessage>()
|
||
.add_message::<WorldportMessage>()
|
||
.add_message::<CharListMessage>()
|
||
.add_message::<CharActionResultMessage>()
|
||
.add_message::<EnteredWorldMessage>()
|
||
.add_message::<ServerSaidMessage>()
|
||
.add_message::<LoggedOutMessage>()
|
||
.add_message::<LoginStageMessage>()
|
||
.add_message::<LoginFailedMessage>()
|
||
.add_message::<DisconnectedMessage>()
|
||
.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<u32>,
|
||
/// `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<SessionEvent>);
|
||
|
||
/// The outbound command channel — cloned by the player/chat systems to send movement + chat.
|
||
#[derive(Resource)]
|
||
pub(crate) struct NetCommands(pub(crate) Sender<ClientCommand>);
|
||
|
||
/// 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<CharRequest>);
|
||
|
||
/// 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<io::LoginRequest>);
|
||
|
||
/// 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<std::sync::atomic::AtomicU64>);
|
||
|
||
/// 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<u64, Entity>);
|
||
|
||
/// 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<u64>);
|
||
|
||
/// 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<String>,
|
||
/// 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<u32>,
|
||
/// 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<u32>,
|
||
}
|
||
|
||
/// 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<u32> {
|
||
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<std::sync::Mutex<io::PingClock>>);
|
||
|
||
/// 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<u16, DropTally>);
|
||
|
||
/// 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<GameTime>);
|
||
|
||
/// 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<u32>);
|
||
|
||
/// 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<u32, u32>);
|
||
|
||
/// 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<bool> =
|
||
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<JumpInfo>,
|
||
/// The rider's pose in the transport's local frame — written iff `ON_TRANSPORT` is in `flags`.
|
||
transport: Option<TransportPose>,
|
||
},
|
||
/// 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<JumpInfo>,
|
||
transport: Option<TransportPose>,
|
||
},
|
||
/// 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<String>,
|
||
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<u64>,
|
||
},
|
||
/// 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<u64> },
|
||
/// 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 <name>`).
|
||
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<WhoRequest> },
|
||
/// 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<u32>,
|
||
},
|
||
}
|
||
|
||
/// 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<benilla_protocol::Character>,
|
||
pub(crate) realm: Option<benilla_protocol::RealmInfo>,
|
||
}
|
||
|
||
/// 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<u8>,
|
||
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<benilla_protocol::JumpInfo>,
|
||
}
|
||
|
||
/// 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<u32>,
|
||
}
|
||
|
||
/// 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<PendingTransferInfo>);
|
||
|
||
#[derive(Clone, Copy)]
|
||
pub(crate) struct PendingTransferInfo {
|
||
pub(crate) map_id: u32,
|
||
pub(crate) transport_entry: Option<u32>,
|
||
}
|
||
|
||
/// 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<Entity>,
|
||
}
|
||
|
||
#[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<Entity>,
|
||
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,
|
||
}
|