Files
benilla-octo/crates/benilla-app/src/net.rs
T
Sam SungandClaude Opus 5.5 243fd09154 Logging in to an account with no characters lands on the empty character list, as in 1.12.1, instead of creating a Human Warrior (#457)
Logging in to an account or realm with no characters lands on the empty character list. Until now the login cycle created a male Human Warrior on the server, named `WOW_CHAR` or "One", on every login that found an empty roster, a typed one included and again after Change Realm.

- **The reference.** The client builds `CMSG_CHAR_CREATE` in one place, `0x5aac50`, reached only from the glue binding `CreateCharacter` (`0x4719e0` → `0x4711d0` → `0x46b290` → `0x5aac50`), which stock `CharacterCreate.lua` calls only from `CharacterCreate_Okay`. With an empty roster, stock `CharacterSelect.lua`'s `UpdateCharacterList` disables Enter World and Delete and shows Create New Character, which benilla's select screen already does.
- **The change.** The create-if-empty step is gone from the per-realm cycle, and `NetConfig`, which existed only for it, goes with it. The create screen's own path is untouched.
- **Scripted runs.** `WOW_CHAR` must now be on the account: a driverless run whose `WOW_CHAR` is missing fails at select, as it already did on a non-empty roster, and a player's run shows the list. `docs/CONTRIBUTING.md` says so, and names `WOW_PROBE_CHARCREATE` as the explicit way to make one.

Checked: `an_empty_account_parks_at_select_without_creating_a_character` drives the real login cycle against a fake realmd (the server half of SRP6) and a fake world server answering an empty roster. It fails with the old step restored (the client sends `CMSG_CHAR_CREATE` on its own) and passes without it. Live against a local vmangos on a fresh account with no characters: before, the server gained a level-1 "One"; after, the client parks on the empty list and the account still holds no character. With a `WOW_CHAR` not on the account, a driverless run exits at select, and nothing is created. Gates and the smoke run green on macOS; no platform seam touched.

Fixes #343

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 10:43:33 +07:00

2393 lines
85 KiB
Rust

//! The network-to-ECS bridge. A read thread owns the blocking [`WorldSession`] and streams
//! [`SessionEvent`]s; a write thread drains [`ClientCommand`]s. [`apply_net_updates`] turns the
//! events into real entities keyed by [`Guid`] through [`GuidIndex`]; movement paths become
//! [`Spline`]s, the server clock [`ServerTime`], one-shot directives Bevy [`Message`]s.
//!
//! It runs in [`WorldStage::Net`], before `Input`, so a server teleport snaps, streams and covers
//! in one frame. Coordinates cross from WoW into Bevy space here (`wow_to_bevy`).
use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
use benilla_protocol::{
messages::WhoRequest, EntityKind, JumpInfo, MoveMode, MoveSpeeds, ObjectFields, SessionEvent,
SpeedKind, TransportPose,
};
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use crossbeam_channel::{Receiver, Sender};
use benilla_world::schedule::WorldStage;
mod apply;
pub(crate) mod handlers;
pub(crate) mod io;
mod motion;
mod objects;
pub(crate) use objects::tear_down;
mod session;
mod world;
pub(crate) use apply::apply_net_updates;
use apply::tag_self_player;
pub(crate) use handlers::NetHandlerApp;
pub(crate) use io::LoginRequest;
use motion::{
drain_pending_moves, extrapolate_remote_units, ground_clamp_creatures, mark_swimming_creatures,
sample_splines,
};
// `pub(crate)`: `creature_anim` reads the shuffle latch it produces, and a test runs both.
pub(crate) use motion::drive_display_facing;
pub(crate) use motion::{
ground_derived, grounded_y, jump_seed, CreatureSwimming, FacingStep, RemoteMotion, Spline,
SplineStopped, UnitMoveModes,
};
// Only the ground-census probe reads this, so a build without the instruments leaves it unused.
// Not a `cfg`: seam knowledge lives in three fixed places, and this file is not one of them.
#[allow(unused_imports)]
pub(crate) use motion::GroundClamped;
/// The net subsystem: spawns the background IO threads and drives the per-frame event drain.
pub(crate) struct NetPlugin {
/// `false` in capture mode ([`crate::capture`]): the channels exist, sends are no-ops, and no
/// IO thread runs.
pub(crate) connect: bool,
}
/// This process has no IO thread ([`NetPlugin::connect`] false); `crate::preflight` announces it.
/// Not [`NetStatus`]'s `connected`, which a dropped connection also clears.
#[derive(Resource)]
pub(crate) struct NetOffline;
impl Plugin for NetPlugin {
fn build(&self, app: &mut App) {
// The release-on-enter for the ask-once caches this plugin owns.
crate::query_cache::register::<crate::names::NameCache>(app);
crate::query_cache::register::<crate::go_templates::GameObjectTemplates>(app);
crate::query_cache::register::<crate::items::Items>(app);
let handles = io::spawn_net(self.connect);
if !self.connect {
app.insert_resource(NetOffline);
}
// In the net stage: the lighting resolve runs after it and reads this frame's clock.
app.add_systems(
Update,
publish_world_time.in_set(benilla_world::schedule::WorldStage::Net),
);
world::register(app);
session::register(app);
objects::register(app);
crate::names::net::register(app);
app.insert_resource(NetEvents(handles.events))
.insert_resource(NetCommands(handles.commands))
.insert_resource(CharPick(handles.pick))
.insert_resource(RealmChoice(handles.realm))
.insert_resource(LoginSubmit(handles.login))
.insert_resource(LoginAbandon(handles.login_abandon))
.insert_resource(PingShared(handles.ping))
.init_resource::<GuidIndex>()
.init_resource::<SelfGuid>()
.init_resource::<AddonInfoReply>()
.init_resource::<PendingTransfer>()
.init_resource::<NetStatus>()
.init_resource::<DroppedOpcodes>()
.init_resource::<ServerTime>()
.init_resource::<ServerWallClock>()
.init_resource::<Reputations>()
.init_resource::<HomeBind>()
.init_resource::<PlayedTimeAnswer>()
.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::<FieldChanged>()
.add_message::<SelfMoveMessage>()
.add_message::<SpeedChangeMessage>()
.add_message::<ClientControlMessage>()
.add_message::<MoveModeMessage>()
.add_message::<KnockBackMessage>()
.add_message::<ServerSoundMessage>()
.add_message::<EmoteMessage>()
.add_message::<AiReactionMessage>()
.add_message::<PetTalkMessage>()
.add_message::<PetDismissSoundMessage>()
.add_message::<WorldportMessage>()
.add_message::<RealmListMessage>()
.add_message::<CharListMessage>()
.add_message::<CharActionResultMessage>()
.add_message::<CharacterLoginFailedMessage>()
.add_message::<EnteredWorldMessage>()
.add_message::<CinematicTriggeredMessage>()
.add_message::<ServerSaidMessage>()
.add_message::<LoggedOutMessage>()
.add_message::<LoginStageMessage>()
.add_message::<LoginQueuedMessage>()
.add_message::<LoginFailedMessage>()
.add_message::<DisconnectedMessage>()
.add_systems(
Update,
(
apply_net_updates,
tag_self_player,
sample_splines,
// Swim state from the water at the feet (the wire never carries it for
// creatures), before the clamp so a swimmer is exempt the same frame.
mark_swimming_creatures,
// After the raw spline Z: re-ground walkers (the client discards a ground
// spline's Z); a swimmer keeps its wire Z.
ground_clamp_creatures,
// Due relays fire before the extrapolator advances the fresh state.
drain_pending_moves,
extrapolate_remote_units,
// After the movers, so a turn reads the goal's position this frame.
drive_display_facing,
)
.chain()
.in_set(WorldStage::Net),
// `ui_session::feed_interact_npc` seats itself inside this chain, after
// `apply_net_updates` and before `drive_display_facing`, so the `"npc"` token is
// current when a window's show handler reads it.
)
// Not part of the movement chain above: one send on the world-enter message.
.add_systems(Update, send_query_time.in_set(WorldStage::Net))
.add_systems(Update, population_pulse.in_set(WorldStage::Net));
}
}
/// Trace tag `pop`: once a second, the count of entities in the object index, which shows whether
/// the world arrives after a teleport or waits for the server's ~20 s relocation timer.
fn population_pulse(index: Res<GuidIndex>, time: Res<Time>, mut last: Local<f32>) {
if !benilla_assets::trace::enabled_for("pop") {
return;
}
let now = time.elapsed_secs();
if now - *last < 1.0 {
return;
}
*last = now;
benilla_assets::trace::line("pop", &format!("net entities={}", index.0.len()));
}
// ── ECS state: components ────────────────────────────────────────────────────────────────────────
/// The server guid of a streamed entity.
#[derive(Component, Clone, Copy)]
pub(crate) struct Guid(pub(crate) u64);
/// A streamed entity's kind and display id, from which [`crate::entities`] builds its visual.
#[derive(Component)]
pub(crate) struct NetEntity {
pub(crate) kind: EntityKind,
pub(crate) display_id: Option<u32>,
/// `OBJECT_FIELD_SCALE_X`, the complete render scale: the server already folds in the DBC
/// scale, so it is never multiplied by ours. A live change eases over 2 s with a cosine
/// smoothstep (`0x614bbf`), through `entities::live_display`.
pub(crate) scale: f32,
}
/// A unit's movement speeds (yd/s) from its `LIVING` movement block. The animation selector runs
/// above 2x `walk` (`0x5fd224`).
#[derive(Component, Clone, Copy)]
pub(crate) struct UnitSpeeds(pub(crate) MoveSpeeds);
/// A mover's speed from its move flags (`CMovement::GetCurrentSpeed 0x7c4c90`). The order is the
/// fact: no direction bit is 0 (`0x7c4c99`); swimming first (`0x7c4cd9`); then walk mode as
/// `min(walk, run)` even backward (`0x7c4d11`); then run, backward as `min(run_back, run)`
/// (`0x7c4d1d`). Each min is x87 `fcompp`, ties and NaN taking the forward field.
///
/// No zero-`walk` fallback: the ctor's zeroed speeds (`0x7c48e8`) are never observable, since
/// the post-create apply `0x5fad50` applies our own speed block as it does a remote one.
pub(crate) fn current_speed(s: &MoveSpeeds, flags: u32) -> f32 {
use crate::creature_anim::move_flags as f;
if flags & f::ANY_MOVE == 0 {
return 0.0;
}
if flags & f::SWIMMING != 0 {
return if flags & f::BACKWARD != 0 {
s.swim_back.min(s.swim)
} else {
s.swim
};
}
if flags & f::WALK_MODE != 0 {
return s.walk.min(s.run);
}
if flags & f::BACKWARD != 0 {
return s.run_back.min(s.run);
}
s.run
}
/// A streamed object's descriptor fields, seeded by the create block and merged with each
/// `Values` delta. Speeds and pose are movement-block data, not here.
#[derive(Component, Clone, Default)]
pub(crate) struct ObjectStore(pub(crate) ObjectFields);
/// One descriptor dword moved on a streamed object, the reference's `CMirrorHandler` edge: the
/// values notifier (`0x465330`) diffs live against a shadow copy and passes the old value
/// (`0x465570`). A first create fires nothing; a re-create of a live guid fires like a delta.
///
/// `kind` is the store's class from its create block, since a raw index differs per class (`36`
/// is a unit's `BYTES_0` and a corpse's `DYNAMIC_FLAGS`).
#[derive(Message, Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct FieldChanged {
pub entity: Entity,
pub guid: u64,
pub kind: benilla_protocol::messages::ObjectType,
pub index: u16,
pub old: u32,
pub new: u32,
}
impl FieldChanged {
/// A unit-block edge: units and players share the UNIT block.
pub fn is_unit(&self) -> bool {
use benilla_protocol::messages::ObjectType;
matches!(self.kind, ObjectType::Unit | ObjectType::Player)
}
/// The edge on one named unit field.
pub fn unit_field(&self, index: u16) -> bool {
self.is_unit() && self.index == index
}
/// The edge's slot in a unit-block array of `len` dwords at `base` (`UNIT_FIELD_AURA[48]`).
pub fn unit_array_slot(&self, base: u16, len: u16) -> Option<u16> {
(self.is_unit() && (base..base + len).contains(&self.index)).then(|| self.index - base)
}
}
/// The field edges one feed saw this run, by guid and index: the reference fans a moved field out
/// to every unit token naming the unit (`0x515e50`).
#[derive(Default)]
pub(crate) struct FieldEdges(HashSet<(u64, u16)>);
impl FieldEdges {
pub fn collect(reader: &mut MessageReader<FieldChanged>) -> Self {
Self(reader.read().map(|e| (e.guid, e.index)).collect())
}
pub fn moved(&self, guid: u64, index: u16) -> bool {
self.0.contains(&(guid, index))
}
#[cfg(test)]
pub fn of(edges: &[(u64, u16)]) -> Self {
Self(edges.iter().copied().collect())
}
}
/// Merge a descriptor update into a store and emit its [`FieldChanged`] edges; the drain and the
/// test harness share it.
pub(crate) fn merge_store_fields(
store: &mut ObjectFields,
delta: ObjectFields,
entity: Entity,
guid: u64,
mut emit: impl FnMut(FieldChanged),
) {
let Some(kind) = store.created_as() else {
// A bare delta with no create: nothing for the reference to notify on; the fields land.
store.merge(delta);
return;
};
store.merge_diff(delta, |index, old, new| {
emit(FieldChanged {
entity,
guid,
kind,
index,
old,
new,
});
});
}
/// Apply a descriptor update the way the drain does. The first one must be
/// [`ObjectFields::into_created`], like a real create block.
#[cfg(test)]
pub(crate) fn apply_fields_for_test(world: &mut World, entity: Entity, delta: ObjectFields) {
let guid = world.get::<Guid>(entity).map_or(0, |g| g.0);
let mut edges = Vec::new();
match world.get_mut::<ObjectStore>(entity) {
Some(mut store) => merge_store_fields(&mut store.0, delta, entity, guid, |e| edges.push(e)),
None => {
assert!(
delta.created_as().is_some(),
"a fixture's first descriptor is a create block: chain `.into_created(..)`"
);
world.entity_mut(entity).insert(ObjectStore(delta));
}
}
world
.resource_mut::<Messages<FieldChanged>>()
.write_batch(edges);
}
/// Our own character's entity (guid == [`SelfGuid`]): identity, not what we steer ([`Embodied`]).
#[derive(Component)]
pub(crate) struct SelfPlayer;
/// The body this client is attached to: the reference's camera anchor (`camera+0x88`), written
/// only by the constructor and `SetTarget 0x50d0f0`, never by a control update. Normally our own
/// body; under possession, the possessed unit.
///
/// At most one entity carries it, and a claimed mover not yet streamed is none: outbound
/// `MSG_MOVE_*` carry no guid, so a fallback to our body would write our pose onto the creature.
/// Whether it may move is [`ActiveMover`]. [`crate::player`] owns the placement.
#[derive(Component)]
pub(crate) struct Embodied;
/// The unit this client authors motion for: the reference's active-mover global
/// (`0xc4da98`/`0xc4da9c`, written only by `SetActiveMover 0x6006e0`). A control update that
/// forbids the unit zeroes it (`0x5fa600`), which stops input (`0x514640`) and every movement
/// report (`0x600860`).
///
/// The server-replay lanes filter `Without<ActiveMover>`, not `Without<Embodied>`, so a body we
/// are attached to but lost control of still follows the server.
#[derive(Component)]
pub(crate) struct ActiveMover;
// ── ECS state: resources ─────────────────────────────────────────────────────────────────────────
/// The inbound event channel, drained each frame by [`apply_net_updates`].
#[derive(Resource)]
pub(crate) struct NetEvents(Receiver<SessionEvent>);
/// The outbound command channel.
#[derive(Resource)]
pub(crate) struct NetCommands(pub(crate) Sender<ClientCommand>);
/// The character-select channel to the IO thread parked at select: create and delete are served
/// in place, re-emitting the roster, until an [`CharRequest::Enter`] moves into the world.
#[derive(Resource)]
pub(crate) struct CharPick(pub(crate) Sender<CharRequest>);
/// One request to the IO thread parked at character select.
pub(crate) enum CharRequest {
/// Log in as this character (`CMSG_PLAYER_LOGIN`).
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: drop the session and return to the pre-logon park.
Abandon,
}
/// The realm channel: the answer to each [`RealmListMessage`]. Two parks listen, the login-side
/// one and the character one, since the reference's realm list is a dialog over either screen
/// (`RealmList.xml` is `frameStrata="DIALOG"`).
#[derive(Resource)]
pub(crate) struct RealmChoice(pub(crate) Sender<RealmRequest>);
/// One request to the IO thread parked at the realm list.
#[derive(Debug)]
pub(crate) enum RealmRequest {
/// Enter this realm, by name: the list refreshes while open, so an index could shift.
Enter(String),
/// Re-request the realm list (`RequestRealmList`, every 5 s from `RealmList_OnUpdate`).
Refresh,
/// Cancel (`RealmList_OnCancel` only hides the frame): the screen underneath stays, login or
/// character select.
Abandon,
}
/// The credentials channel to the IO thread's pre-logon park, sent by [`crate::login`].
#[derive(Resource)]
pub(crate) struct LoginSubmit(pub(crate) Sender<io::LoginRequest>);
/// The login abandon generation: Cancel bumps it, and the IO thread drops an
/// [`io::LoginRequest`] submitted under an older value.
#[derive(Resource)]
pub(crate) struct LoginAbandon(pub(crate) std::sync::Arc<std::sync::atomic::AtomicU64>);
/// Guid to entity for every kind, items included, like the reference's one `ClntObjMgr` index.
/// Written only by `net::objects`.
#[derive(Resource, Default)]
pub(crate) struct GuidIndex(pub(crate) HashMap<u64, Entity>);
/// The object manager's guid lookup (`ClntObjMgrObjectPtr 0x468460`): a guid to its descriptor
/// store, whatever the kind, and an item's countdown cells. Read-only.
#[derive(SystemParam)]
pub(crate) struct Objects<'w, 's> {
index: Res<'w, GuidIndex>,
stores: Query<'w, 's, &'static ObjectStore>,
countdowns: Query<'w, 's, &'static crate::items::Countdowns>,
}
impl Objects<'_, '_> {
/// The entity behind a guid, if streamed.
pub(crate) fn entity(&self, guid: u64) -> Option<Entity> {
self.index.0.get(&guid).copied()
}
/// A streamed object's merged descriptor fields.
pub(crate) fn object(&self, guid: u64) -> Option<&ObjectFields> {
self.index
.0
.get(&guid)
.and_then(|&e| self.stores.get(e).ok())
.map(|s| &s.0)
}
/// An item object's countdown cells.
pub(crate) fn countdowns(&self, guid: u64) -> Option<&crate::items::Countdowns> {
self.index
.0
.get(&guid)
.and_then(|&e| self.countdowns.get(e).ok())
}
}
/// Our own player's guid, once in the world.
#[derive(Resource, Default)]
pub(crate) struct SelfGuid(pub(crate) Option<u64>);
/// Connection status; `last_reason` keeps the most recent failure.
#[derive(Resource, Default)]
pub(crate) struct NetStatus {
pub(crate) connected: bool,
pub(crate) last_reason: Option<String>,
}
/// The ping clock and RTT history, shared with both net threads: the write thread stamps each
/// `CMSG_PING`, the read thread times `SMSG_PONG` on arrival. The app only reads and clears it,
/// never times a pong from the drain.
#[derive(Resource)]
pub(crate) struct PingShared(pub(crate) std::sync::Arc<std::sync::Mutex<io::PingClock>>);
/// Server packets the codec dropped, by opcode: `unknown` has no parse arm, `unparseable` failed
/// its parser. Never cleared on reconnect.
#[derive(Resource, Default)]
pub(crate) struct DroppedOpcodes(pub(crate) HashMap<u16, DropTally>);
/// Per-opcode drop counts.
#[derive(Default, Clone, Copy)]
pub(crate) struct DropTally {
pub(crate) unknown: u64,
pub(crate) unparseable: u64,
}
/// The in-game clock (`SMSG_LOGIN_SETTIMESPEED`), advanced by its timescale.
#[derive(Resource, Default)]
pub(crate) struct ServerTime(pub(crate) Option<GameTime>);
/// Publish the session clock into [`benilla_world::lighting::WorldTime`] each frame; with no
/// server, its default (noon).
pub(crate) fn publish_world_time(
server: Res<ServerTime>,
mut world_time: ResMut<benilla_world::lighting::WorldTime>,
) {
*world_time = match server.0 {
Some(gt) => benilla_world::lighting::WorldTime {
minute: gt.minute_of_day(),
minute_f: gt.minute_of_day_f32(),
day: gt.day_continuous(),
live: true,
},
None => benilla_world::lighting::WorldTime::default(),
};
}
/// The server's unix wall clock (`SMSG_QUERY_TIME_RESPONSE`), advanced monotonically. Descriptor
/// deadlines are absolute server stamps (a timed quest's is `time(nullptr) + limitTime`, vmangos
/// `Player::AddQuest`), so a countdown reads this, never the local clock.
#[derive(Resource, Default)]
pub(crate) struct ServerWallClock(pub(crate) Option<WallClockSample>);
/// One `SMSG_QUERY_TIME_RESPONSE` sample and the monotonic instant it arrived.
#[derive(Debug, Clone, Copy)]
pub(crate) struct WallClockSample {
/// The server's `time(nullptr)` at `received`.
base_unix: u32,
received: Instant,
}
impl ServerWallClock {
/// Take a fresh sample; it reads about RTT/2 behind, below a countdown's one-second grain.
pub(crate) fn sample(&mut self, unix_time: u32) {
self.0 = Some(WallClockSample {
base_unix: unix_time,
received: Instant::now(),
});
}
/// Server unix seconds now, fractional.
pub(crate) fn now_unix(&self) -> Option<f64> {
self.0
.map(|s| f64::from(s.base_unix) + s.received.elapsed().as_secs_f64())
}
fn stale(&self) -> bool {
self.0.is_none_or(|s| s.received.elapsed() >= RESYNC_AFTER)
}
}
/// The wall-clock resync interval: the reference re-asks after an hour (`0x4de836` gated on
/// `[0xbb749c]`, armed `now + 0xe10` at `0x4def11`).
const RESYNC_AFTER: Duration = Duration::from_secs(3600);
/// Ask for the server's wall clock on entering the world (login, worldport, instance transfer)
/// and hourly after, which tracks a server re-clocked under us.
fn send_query_time(
mut entered: MessageReader<EnteredWorldMessage>,
commands: Res<NetCommands>,
clock: Res<ServerWallClock>,
status: Res<NetStatus>,
mut asked_at: Local<Option<Instant>>,
) {
let entering = entered.read().next().is_some();
// Only while connected; the world-enter send covers a reconnect.
let due =
status.connected && clock.stale() && asked_at.is_none_or(|t| t.elapsed() >= RESYNC_AFTER);
if entering || due {
*asked_at = Some(Instant::now());
let _ = commands.0.send(ClientCommand::QueryTime);
}
}
/// Our `(flags, standing)` per slot, indexed by `Faction.dbc`'s `reputationIndex`. The standing
/// excludes the DBC race/class base, which consumers add before ranking. Filled by
/// `SMSG_INITIALIZE_FACTIONS`, kept current by `SMSG_SET_FACTION_STANDING` (which also reveals)
/// and `SMSG_SET_FACTION_VISIBLE`. Flag bit `0x08` marks a pane header.
#[derive(Resource, Default)]
pub(crate) struct Reputations(pub(crate) Vec<(u8, i32)>);
/// The hearthstone bind point (`SMSG_BINDPOINTUPDATE`): the AreaTable id the tooltip's `$z`
/// token names.
#[derive(Resource, Default)]
pub(crate) struct HomeBind(pub(crate) Option<u32>);
/// The undelivered `/played` answer (`SMSG_PLAYED_TIME`): total seconds and seconds this level.
/// One slot: the server sends one reply per request.
#[derive(Resource, Default)]
pub(crate) struct PlayedTimeAnswer(pub(crate) Option<(u32, u32)>);
/// Equip proficiencies (`SMSG_SET_PROFICIENCY`): item class (2 weapon, 4 armor) to a subclass
/// bitmask, the client's `0xc4d4a0[class]` store.
#[derive(Resource, Default)]
pub(crate) struct Proficiencies(pub(crate) std::collections::HashMap<u32, u32>);
/// The in-game clock from `SMSG_LOGIN_SETTIMESPEED`: the server's `localtime()`, advancing at
/// `timescale` game-minutes per real second (vmangos about `0.01667`, real time).
#[derive(Debug, Clone, Copy)]
pub(crate) struct GameTime {
/// Minute of the game day (`0..1440`) at `received`.
base_minute: u32,
/// Day serial of the packed date, `year*372 + month*31 + day`; only differences matter.
base_day: u32,
timescale: f32,
received: Instant,
}
impl GameTime {
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`).
pub(crate) fn minute_of_day(&self) -> u32 {
self.minute_of_day_f32() as u32
}
/// Current fractional minute of the game day, so the sky bodies move without steps.
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)
}
/// Days and day fraction since the serial epoch, unwrapped, for the moon phase
/// `(dayCounter + todPhase) mod 1.7` (`0x6d41b9`); `f64` for sub-minute grain at ~12k days.
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, each a `MSG_MOVE_*` opcode: the reference sends each axis transition,
/// a jump, a standing facing change and a heartbeat while moving.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum MoveKind {
StartForward,
StartBackward,
Stop,
StartStrafeLeft,
StartStrafeRight,
StopStrafe,
StartTurnLeft,
StartTurnRight,
StopTurn,
Jump,
FallLand,
StartSwim,
StopSwim,
/// The walk/run toggle: sent from the toggle handler (`ToggleRun 0x513d50` to `0x617de0`),
/// not the move broadcaster, so it goes out standing still (`0x61a99d` gates the latter).
SetWalkMode,
SetRunMode,
SetFacing,
Heartbeat,
}
impl MoveKind {
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::SetWalkMode => op::MSG_MOVE_SET_WALK_MODE,
MoveKind::SetRunMode => op::MSG_MOVE_SET_RUN_MODE,
MoveKind::SetFacing => op::MSG_MOVE_SET_FACING,
MoveKind::Heartbeat => op::MSG_MOVE_HEARTBEAT,
}
}
}
/// The `ChatMsg` type an addon broadcast rides: the client's four-lane whitelist
/// (`0x49fa3f`-`0x49fa4e`).
pub(crate) fn addon_wire_chat_type(distribution: benilla_ui::script::AddonDistribution) -> u32 {
use benilla_protocol::messages as m;
use benilla_ui::script::AddonDistribution as D;
match distribution {
D::Party => m::CHAT_TYPE_PARTY,
D::Raid => m::CHAT_TYPE_RAID,
D::Guild => m::CHAT_TYPE_GUILD,
D::Battleground => m::CHAT_TYPE_BATTLEGROUND,
}
}
/// The `ChatMsg` type of a chat-bar line: the full sendable set (vmangos
/// `HandleChatMessageOpcode`). `Whisper` and `Channel` name their target in `target`.
#[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,
}
/// `WOW_CAST_TRACE=1`: log our own cast packets and every outbound movement packet. vmangos
/// interrupts a cast on a movement report whose position differs at all (`Player::SetPosition`).
pub(crate) static CAST_TRACE: std::sync::LazyLock<bool> =
std::sync::LazyLock::new(|| std::env::var("WOW_CAST_TRACE").is_ok());
/// A message to the server through the write thread, carrying any pose it needs.
#[derive(Debug)]
pub(crate) enum ClientCommand {
/// A self-movement packet. Each tail is written iff its flag is set (`JUMPING`, `SWIMMING`,
/// `ON_TRANSPORT`): a flag without its tail desyncs the server's parse.
Move {
kind: MoveKind,
flags: u32,
pos: [f32; 3],
orientation: f32,
/// Swim pitch (radians, up positive).
pitch: f32,
fall_time: u32,
jump: Option<JumpInfo>,
/// The rider's pose in the transport's local frame.
transport: Option<TransportPose>,
},
/// `CMSG_MOVE_SPLINE_DONE`: a server spline that drove our player ended. The server holds the
/// mover spline-pending until it arrives.
MoveSplineDone {
flags: u32,
pos: [f32; 3],
orientation: f32,
spline_id: u32,
},
/// `CMSG_FORCE_*_SPEED_CHANGE_ACK`: echoes guid, counter and exact speed with a live movement
/// payload. Unacked, the server force-resolves after ~4 s and flags the session.
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>,
},
/// `CMSG_MOVE_TIME_SKIPPED`: our mover skipped `lag_ms` without integrating; `guid` must be the
/// active mover (`0x600be0`). Just after boarding, the server answers with the transport's
/// create update.
MoveTimeSkipped {
guid: u64,
lag_ms: u32,
},
/// `CMSG_SET_ACTIVE_MOVER`: at login and on possession; the server drops `MSG_MOVE_*` for an
/// unconfirmed mover.
SetActiveMover {
guid: u64,
},
/// `CMSG_MOVE_NOT_ACTIVE_MOVER`: the server re-broadcasts a stop from this pose.
NotActiveMover {
guid: u64,
flags: u32,
pos: [f32; 3],
orientation: f32,
fall_time: u32,
},
/// `CMSG_FAR_SIGHT`: `true` on attach, `false` on release; neither names an object.
FarSight {
engage: bool,
},
/// `MSG_MOVE_TELEPORT_ACK`: unacked, the server freezes our movement.
TeleportAck {
guid: u64,
counter: u32,
},
/// `MSG_MOVE_WORLDPORT_ACK`: unblocks the new map's stream.
WorldportAck,
/// `CMSG_SET_SELECTION`; `guid == 0` clears.
SetSelection {
guid: u64,
},
/// `CMSG_MESSAGECHAT`. A plain line, `.` GM commands included, goes as `Say`.
Chat {
kind: ChatKind,
target: Option<String>,
text: String,
},
/// `SendAddonMessage`: a `CMSG_MESSAGECHAT` in `LANG_ADDON` (1.12 has no addon opcode and no
/// whispered addon message). `text` is `prefix` TAB `message`.
AddonMessage {
distribution: benilla_ui::script::AddonDistribution,
text: String,
},
/// `CMSG_NAME_QUERY`, sent only by [`crate::names::NameCache`]'s ask-once resolve.
NameQuery {
guid: u64,
},
/// `CMSG_CREATURE_QUERY`, the entry from the guid's bits 24-47.
CreatureQuery {
entry: u32,
guid: u64,
},
/// `CMSG_PET_NAME_QUERY`: a pet's bits 24-47 are its pet number, not a creature entry.
PetNameQuery {
pet_number: u32,
guid: u64,
},
/// `CMSG_ITEM_QUERY_SINGLE` (`guid` 0 for template-only), sent only by
/// [`crate::items::Items`]' ask-once resolve.
ItemQuery {
entry: u32,
guid: u64,
},
/// `CMSG_USE_ITEM` by wire position: bag 255 and an absolute slot for the player's own array
/// (equipment, backpack, keyring), else the bag's slot 19-22 and the inner slot.
/// `spell_index` is the template's spell block ordinal. `target` is the same cast-targets
/// block as a cast's (`SendCast 0x6e54f0` writes both).
UseItem {
bag_index: u8,
slot: u8,
spell_index: u8,
target: benilla_protocol::messages::UseItemTarget,
},
/// `CMSG_OPEN_ITEM`: the server answers `SMSG_LOOT_RESPONSE` on the item's own guid; a wrapped
/// gift instead changes entry in place with no window.
OpenItem {
bag_index: u8,
slot: u8,
},
/// `CMSG_WRAP_ITEM`, the paper first as on the wire. The client filters nothing; the server
/// refuses with an `ERR_CANT_WRAP_*`, and success is silent.
WrapItem {
gift_bag: u8,
gift_slot: u8,
item_bag: u8,
item_slot: u8,
},
/// `CMSG_AUTOEQUIP_ITEM`.
AutoEquipItem {
bag_index: u8,
slot: u8,
},
/// `CMSG_SET_AMMO`, the auto-equip fork for ammo (`0x5e1480`): by entry, the stack stays in
/// the bag and `PLAYER_AMMO_ID` names it.
SetAmmo {
entry: u32,
},
/// `CMSG_SWAP_INV_ITEM`: both slots on the player array; an empty destination is a move.
SwapInvItem {
src_slot: u8,
dst_slot: u8,
},
/// `CMSG_SWAP_ITEM`, when either end is in an equipped bag; the body is destination first
/// (vmangos `Packets/Item.cpp:30-36`).
SwapItem {
dst_bag: u8,
dst_slot: u8,
src_bag: u8,
src_slot: u8,
},
/// `CMSG_AUTOSTORE_BAG_ITEM`: into `dst_bag` with no destination slot (`0x5e12e0`).
AutoStoreBagItem {
src_bag: u8,
src_slot: u8,
dst_bag: u8,
},
/// `CMSG_SPLIT_ITEM`: part of a stack, either end in any bag.
SplitItem {
src_bag: u8,
src_slot: u8,
dst_bag: u8,
dst_slot: u8,
count: u8,
},
/// `CMSG_DESTROYITEM`; `count` 0 is the whole stack.
DestroyItem {
bag_index: u8,
slot: u8,
count: u8,
},
/// `CMSG_TEXT_EMOTE`; our own sound and animation ride the `SMSG_TEXT_EMOTE` echo.
TextEmote {
text_id: u32,
target: u64,
},
/// `CMSG_CAST_SPELL`; `None` is a self or implicit target.
CastSpell {
spell_id: u32,
target: Option<u64>,
},
/// `CMSG_CAST_SPELL` with `TARGET_FLAG_DEST_LOCATION`; `dest` in WoW coordinates.
CastSpellAtDest {
spell_id: u32,
dest: [f32; 3],
},
/// `CMSG_CAST_SPELL` with `TARGET_FLAG_SOURCE_LOCATION` (`BindLocation 0x6e60f0`, bit 5);
/// `src` in WoW coordinates.
CastSpellAtSource {
spell_id: u32,
src: [f32; 3],
},
/// `CMSG_CANCEL_AURA`, by spell id, not slot; the removal returns as a `UNIT_FIELD_AURA` delta.
CancelAura {
spell_id: u32,
},
/// `CMSG_SET_ACTION_BUTTON`: `button` is the Lua action id minus 1, `packed` is
/// `kind<<24 | action` (0 clears). Client-authoritative, no answer.
SetActionButton {
button: u8,
packed: u32,
},
/// `CMSG_SET_ACTIONBAR_TOGGLES` (sent by `0x4e76e0`), one per call. The byte is server-owned
/// (`PLAYER_FIELD_BYTES` byte 2, read at `0x4e768c`; nothing in the client writes it), so it
/// only changes by the descriptor echo. Disconnected, a silent no-op (`0x5ab637`, `0x5379ab`,
/// `0x5379b6`).
SetActionBarToggles {
toggles: u8,
},
/// `CMSG_PET_ACTION`: `packed` is the slot's word as the server sent it, dispatched on its type
/// byte; `target_guid` is our selection (`0x4bd212`). The server does not reply, so the
/// caller applies the change locally first.
PetAction {
pet_guid: u64,
packed: u32,
target_guid: u64,
},
/// `CMSG_PET_SET_ACTION`: one or two `(position, word)` pairs. The bar's autocast toggle is
/// one entry with bit 30 flipped.
PetSetAction {
pet_guid: u64,
entries: Vec<(u32, u32)>,
},
/// `CMSG_PET_STOP_ATTACK`: the Attack button's second press.
PetStopAttack {
pet_guid: u64,
},
/// `CMSG_PET_CANCEL_AURA`; the removal returns as a `UNIT_FIELD_AURA` delta on the pet.
PetCancelAura {
pet_guid: u64,
spell_id: u32,
},
/// `CMSG_PET_SPELL_AUTOCAST`: the pet spellbook's `ToggleSpellAutocast`, by spell id
/// (`0x4b4240` to `0x4bccb0`); the bar's toggle is [`Self::PetSetAction`]. No reply.
PetSpellAutocast {
pet_guid: u64,
spell_id: u32,
enabled: bool,
},
/// `CMSG_PET_ABANDON`: the menu's Abandon only. Dismiss (`PetDismiss 0x4be4d0`) is a
/// [`Self::PetAction`] with word `0x07000003`. Answered by a zero-guid `SMSG_PET_SPELLS`.
PetAbandon {
pet_guid: u64,
},
/// `CMSG_PET_RENAME`: success arrives as a bumped `UNIT_FIELD_PET_NAME_TIMESTAMP`.
PetRename {
pet_guid: u64,
name: String,
},
/// `CMSG_ATTACKSWING`; echoed as `SMSG_ATTACKSTART`.
AttackSwing {
guid: u64,
},
/// `CMSG_ATTACKSTOP`, sent when the target is lost; the weapons stay drawn.
AttackStop,
/// `CMSG_CANCEL_AUTO_REPEAT_SPELL`, sent by every local cancel (`0x6ea0c6` in `0x6ea080`);
/// harmless when the server cancelled first.
CancelAutoRepeat,
/// `CMSG_CANCEL_CAST`: from the wand auto-repeat handoff (`0x6095b8`) and a local cancel
/// (`AbortCast 0x6e4940`).
CancelCast {
spell_id: u32,
},
/// `CMSG_CANCEL_CHANNELLING`; the server ignores the spell id the client writes.
CancelChannelling {
spell_id: u32,
},
/// `CMSG_SETSHEATHED` (0 stowed, 1 melee, 2 ranged); the client sends 1 on starting melee
/// stowed (`0x5ecb70` to `0x611cf0`). Our own pose follows the `UNIT_FIELD_BYTES_2` echo.
SetSheathed {
state: u32,
},
/// `CMSG_STANDSTATECHANGE` (0 stand, 1 sit, 3 sleep, 8 kneel); our own pose follows the
/// `UNIT_FIELD_BYTES_1` echo.
StandStateChange {
state: u32,
},
/// `CMSG_MOUNTSPECIAL_ANIM`: we play MountSpecial (94) locally and ignore our own echo.
MountSpecial,
/// `CMSG_GOSSIP_HELLO`: works on any interactable creature (the server's interact check is
/// passed `UNIT_NPC_FLAG_NONE`, `NPCHandler.cpp:347`).
GossipHello {
guid: u64,
},
/// `CMSG_GOSSIP_SELECT_OPTION` with the line's `index`. No code is ever sent: coded options
/// are not selectable.
GossipSelectOption {
guid: u64,
option: u32,
},
/// `CMSG_NPC_TEXT_QUERY`, asked once per `text_id` on menu receipt.
NpcTextQuery {
text_id: u32,
guid: u64,
},
/// `CMSG_LIST_INVENTORY`: a vendor-only NPC's direct opener.
ListInventory {
guid: u64,
},
/// `CMSG_BUY_ITEM` by template entry, not row; `count` stacks go to the first free slot.
BuyItem {
vendor: u64,
entry: u32,
count: u8,
},
/// `CMSG_BUY_ITEM_IN_SLOT`: a vendor row dropped on a slot. `bag_guid` is the container's, or
/// the player's own for the backpack and equipment.
BuyItemInSlot {
vendor: u64,
entry: u32,
bag_guid: u64,
bag_slot: u8,
count: u8,
},
/// `CMSG_SELL_ITEM`; `count` 0 is the whole stack. Success is silent; a refusal is
/// `SMSG_SELL_ITEM`'s error shape.
SellItem {
vendor: u64,
item_guid: u64,
count: u8,
},
/// `CMSG_BUYBACK_ITEM`: `slot` is the absolute player-array buyback slot 69-80.
BuybackItem {
vendor: u64,
slot: u32,
},
/// `CMSG_REPAIR_ITEM`; `item_guid` 0 repairs everything. No answer packet.
RepairItem {
vendor: u64,
item_guid: u64,
},
/// `CMSG_GMTICKET_CREATE`: `category` is a `GMTicketCategory.dbc` id (1..10), `map`/`pos`
/// where we stand. vmangos refuses several cases silently (queue off, under
/// `GMTickets.MinLevel`, category >= 11).
GmTicketCreate {
category: u8,
map: u32,
pos: [f32; 3],
text: String,
},
/// `CMSG_GMTICKET_UPDATETEXT`, with the category byte vmangos discards. vmangos kicks past 2
/// per world tick, so one send per click.
GmTicketUpdate {
category: u8,
text: String,
},
/// `CMSG_GMTICKET_GETTICKET`: `GetGMTicket()`.
GmTicketGet,
/// `CMSG_GMTICKET_DELETETICKET`.
GmTicketDelete,
/// `CMSG_GMTICKET_SYSTEMSTATUS`: `GetGMStatus()`.
GmTicketSystemStatus,
/// `CMSG_BINDER_ACTIVATE`, the `CONFIRM_BINDER` Accept: the only packet in the flow that
/// binds. Declining sends nothing.
BinderActivate {
binder: u64,
},
/// `CMSG_SUMMON_RESPONSE`, the `CONFIRM_SUMMON` Accept; there is no decline opcode.
SummonResponse {
summoner: u64,
},
/// `MSG_TALENT_WIPE_CONFIRM`, the `CONFIRM_TALENT_WIPE` Accept: the only packet in the flow
/// that resets. Declining sends nothing.
TalentWipeConfirm {
trainer: u64,
},
// ── The dialog engine's verbs ──
/// `ForceLogout()`: `CMSG_PLAYER_LOGOUT` (`0x4A`), only with a live world session.
ForceLogout,
/// `AcceptAreaSpiritHeal()`: `CMSG_AREA_SPIRIT_HEALER_QUEUE` with the cached healer.
AreaSpiritHealerQueue {
healer: u64,
},
/// `CMSG_AREA_SPIRIT_HEALER_QUERY`, asking a newly adopted healer's wave clock. No Lua verb
/// sends it: only the set-current-healer routine `0x4921c0`.
AreaSpiritHealerQuery {
healer: u64,
},
/// `AcceptBattlefieldPort(index, accept)`: `CMSG_BATTLEFIELD_PORT`.
BattlefieldPort {
map_id: u32,
accept: bool,
},
/// `RequestBattlefieldScoreData()`: `MSG_PVP_LOG_DATA`.
RequestBattlefieldScoreData,
/// `LeaveBattlefield()`: `CMSG_LEAVE_BATTLEFIELD`.
LeaveBattlefield {
map_id: u32,
},
/// A meeting stone right-click past the client's four refusals: `CMSG 0x292`.
MeetingStoneJoin {
go_guid: u64,
},
/// `CancelMeetingStoneRequest()`: `CMSG 0x293`.
MeetingStoneLeave,
/// The enter-world meeting-stone status query: `CMSG 0x296`.
MeetingStoneStatusQuery,
// ── The tutorial system ──
/// `FlagTutorial` or an auto-acknowledge: `CMSG_TUTORIAL_FLAG`, the 0-based id.
TutorialFlag {
id: u32,
},
/// `ClearTutorials()`: `CMSG_TUTORIAL_CLEAR`.
TutorialClear,
/// `ResetTutorials()`: `CMSG_TUTORIAL_RESET`.
TutorialReset,
// ── The battleground list window ──
/// `ShowBattlefieldList(index)`: `CMSG_BATTLEFIELD_LIST`.
BattlefieldList {
map_id: u32,
},
/// `JoinBattlefield` when the list came from a battlemaster: `CMSG_BATTLEMASTER_JOIN`.
BattlemasterJoin {
battlemaster: u64,
map_id: u32,
instance_id: u32,
as_group: bool,
},
/// `RequestBattlefieldPositions()`: `MSG_BATTLEGROUND_PLAYER_POSITIONS`, throttled to the
/// reference's 5000 ms.
RequestBattlefieldPositions,
// ── The tabard designer ──
/// The NPC-click ladder's TABARDDESIGNER arm: `MSG_TABARDVENDOR_ACTIVATE`.
TabardVendorActivate {
npc: u64,
},
/// `TabardModel:Save()` past its checks: `MSG_SAVE_GUILD_EMBLEM`.
SaveGuildEmblem {
vendor: u64,
design: [u32; 5],
},
/// The ladder's BATTLEMASTER arm: `CMSG_BATTLEMASTER_HELLO`.
BattlemasterHello {
npc: u64,
},
/// `JoinBattlefield` when the list came without one: `CMSG_BATTLEFIELD_JOIN`.
BattlefieldJoin {
map_id: u32,
instance_id: u32,
as_group: bool,
},
/// The world-enter status request: `CMSG_BATTLEFIELD_STATUS`.
BattlefieldStatusRequest,
/// `ConfirmPetUnlearn()`: `CMSG_PET_UNLEARN` with the latched trainer.
PetUnlearn {
trainer: u64,
},
/// `CMSG_BANKER_ACTIVATE`: a pure banker's direct opener (bit 8 the lowest service bit); a
/// gossip banker goes through the menu. Answered by `SMSG_SHOW_BANK`.
BankerActivate {
guid: u64,
},
/// `CMSG_BUY_BANK_SLOT`: success is only the `PLAYER_BYTES_2` count rising; a refusal is
/// `SMSG_BUY_BANK_SLOT_RESULT`.
BuyBankSlot {
guid: u64,
},
/// `CMSG_AUTOBANK_ITEM`: deposit into the bank.
AutoBankItem {
bag: u8,
slot: u8,
},
/// `CMSG_AUTOSTORE_BANK_ITEM`: withdraw into the bags.
AutoStoreBankItem {
bag: u8,
slot: u8,
},
/// `CMSG_TRAINER_LIST`: the refresh after a purchase, which the server does not resend.
TrainerList {
trainer: u64,
},
/// `CMSG_TRAINER_BUY_SPELL`: success is `SMSG_TRAINER_BUY_SUCCEEDED` and
/// `SMSG_LEARNED_SPELL`, refusal `SMSG_TRAINER_BUY_FAILED`.
TrainerBuySpell {
trainer: u64,
spell_id: u32,
},
/// `MSG_LIST_STABLED_PETS`: the refresh after each change, none of which returns a list.
ListStabledPets {
npc: u64,
},
/// `CMSG_STABLE_PET`: the server picks the first free slot.
StablePet {
npc: u64,
},
/// `CMSG_UNSTABLE_PET`, only with no pet out; otherwise [`Self::StableSwapPet`].
UnstablePet {
npc: u64,
pet_number: u32,
},
/// `CMSG_STABLE_SWAP_PET`.
StableSwapPet {
npc: u64,
pet_number: u32,
},
/// `CMSG_BUY_STABLE_SLOT`; a refusal is `SMSG_STABLE_RESULT`'s `ERR_MONEY`.
BuyStableSlot {
npc: u64,
},
/// `CMSG_LEARN_TALENT`: a `Talent.dbc` row and the 0-based rank, which is the current rank
/// count. No reply beyond the learn and `PLAYER_CHARACTER_POINTS1`.
LearnTalent {
talent_id: u32,
rank: u32,
},
/// `CMSG_UNLEARN_SKILL`. No ack: the line leaves only by the `PLAYER_SKILL_INFO` update.
UnlearnSkill {
skill_id: u32,
},
/// `CMSG_SET_FACTION_ATWAR`, by reputation-list slot. No ack; vmangos drops it in combat.
SetFactionAtWar {
rep_list_id: u32,
at_war: bool,
},
/// `CMSG_SET_FACTION_INACTIVE`, by reputation-list slot. No ack.
SetFactionInactive {
rep_list_id: u32,
inactive: bool,
},
/// `CMSG_SET_WATCHED_FACTION`: `-1` watches nothing (slot 0 is a real faction). Answered by
/// `PLAYER_FIELD_WATCHED_FACTION_INDEX`.
SetWatchedFaction {
rep_list_id: i32,
},
/// `CMSG_GAMEOBJ_USE`: the server answers by GameObject type (a chest with loot, a door with a
/// `GAMEOBJECT_STATE` flip) or refuses silently.
GameObjUse {
guid: u64,
},
/// `CMSG_AREATRIGGER`: we entered an `AreaTrigger.dbc` volume. A teleport answers with the
/// ordinary transfer, a refusal with `SMSG_AREA_TRIGGER_MESSAGE`, most with nothing.
AreaTrigger {
trigger_id: u32,
},
/// `CMSG_GAMEOBJECT_QUERY`, asked once per entry ([`crate::go_templates`]).
GameObjectQuery {
entry: u32,
guid: u64,
},
/// `CMSG_PAGE_TEXT_QUERY` (the server discards `guid`); vmangos answers the whole forward
/// chain, one response per page.
PageTextQuery {
page_id: u32,
guid: u64,
},
/// `CMSG_CAST_SPELL` at a GameObject: an OPEN_LOCK cast on a lock, vein or herb.
CastSpellGameObject {
spell_id: u32,
go_guid: u64,
},
/// An item-targeted cast (`TARGET_FLAG_ITEM` and a packed guid).
CastSpellItem {
spell_id: u32,
item_guid: u64,
},
/// `CMSG_LOOT` on a unit; the server rejects a GameObject guid, which loots by `GameObjUse`.
Loot {
guid: u64,
},
/// `CMSG_AUTOSTORE_LOOT_ITEM` by the 0-based wire loot slot.
AutostoreLootItem {
slot: u8,
},
/// `CMSG_LOOT_MONEY`: answered by `SMSG_LOOT_CLEAR_MONEY`; solo, no `SMSG_LOOT_MONEY_NOTIFY`.
LootMoney,
/// `CMSG_LOOT_RELEASE`: the server ignores `guid` and releases whatever loot it holds for us.
LootRelease {
guid: u64,
},
/// `CMSG_LOOT_ROLL`, by the `(looted_target, item_slot)` pair; the Lua `rollID` is client-only.
LootRoll {
looted_target: u64,
item_slot: u32,
roll_type: u8,
},
/// `CMSG_LOOT_MASTER_GIVE`: `slot` is the wire slot. Success is `SMSG_LOOT_REMOVED`, a refusal
/// a `MASTER_*` loot error.
LootMasterGive {
guid: u64,
slot: u8,
target: u64,
},
/// `CMSG_QUESTGIVER_QUERY_QUEST`; answered by `SMSG_QUESTGIVER_QUEST_DETAILS`.
QuestgiverQuery {
npc: u64,
quest: u32,
},
/// `CMSG_QUESTGIVER_ACCEPT_QUEST`; the server closes gossip with `SMSG_GOSSIP_COMPLETE`.
QuestgiverAccept {
npc: u64,
quest: u32,
},
/// `CMSG_QUESTGIVER_COMPLETE_QUEST`; answered by `SMSG_QUESTGIVER_REQUEST_ITEMS`, or
/// `OFFER_REWARD` with no required items.
QuestgiverComplete {
npc: u64,
quest: u32,
},
/// `CMSG_QUESTGIVER_REQUEST_REWARD`; answered by `SMSG_QUESTGIVER_OFFER_REWARD`.
QuestgiverRequestReward {
npc: u64,
quest: u32,
},
/// `CMSG_QUESTGIVER_CHOOSE_REWARD` with the 0-based choice.
QuestgiverChooseReward {
npc: u64,
quest: u32,
choice: u32,
},
/// `CMSG_QUEST_QUERY`: the quest log's ask-once template, needing no NPC.
QuestQuery {
quest: u32,
},
/// `CMSG_QUESTGIVER_STATUS_QUERY`: the overhead `!`/`?` marker.
QuestgiverStatusQuery {
npc: u64,
},
/// `CMSG_QUESTGIVER_HELLO`: re-opens a non-gossip NPC's quest list after a decline.
QuestgiverHello {
npc: u64,
},
/// `CMSG_QUESTLOG_REMOVE_QUEST`. No ack: the `PLAYER_QUEST_LOG` slot fields clear.
QuestlogRemove {
slot: u8,
},
// ── The party quest share ──
/// `CMSG_PUSHQUESTTOPARTY`; answered by `MSG_QUEST_PUSH_RESULT`s per member.
PushQuestToParty {
quest: u32,
},
/// `CMSG_QUEST_CONFIRM_ACCEPT`, the escort confirm's Yes; dismissing sends nothing.
QuestConfirmAccept {
quest: u32,
},
/// `MSG_QUEST_PUSH_RESULT`: our decline of a shared quest, to `sharer`.
QuestPushResult {
sharer: u64,
msg: benilla_protocol::messages::QuestShareMsg,
},
// ── Mail ──
/// `CMSG_GET_MAIL_LIST`: the window's open and refresh.
GetMailList {
mailbox: u64,
},
/// `CMSG_SEND_MAIL`; `item_guid` 0 is no attachment.
SendMail {
mailbox: u64,
receiver: String,
subject: String,
body: String,
/// The `Stationery.dbc` id, the sixth field.
stationery: u32,
item_guid: u64,
money: u32,
cod: u32,
},
/// `CMSG_MAIL_TAKE_MONEY`.
MailTakeMoney {
mailbox: u64,
mail_id: u32,
},
/// `CMSG_MAIL_TAKE_ITEM`.
MailTakeItem {
mailbox: u64,
mail_id: u32,
},
/// `CMSG_MAIL_MARK_AS_READ`, when a letter opens. No response.
MailMarkAsRead {
mailbox: u64,
mail_id: u32,
},
/// `CMSG_MAIL_RETURN_TO_SENDER`.
MailReturnToSender {
mailbox: u64,
mail_id: u32,
},
/// `CMSG_MAIL_DELETE`.
MailDelete {
mailbox: u64,
mail_id: u32,
},
/// `CMSG_MAIL_CREATE_TEXT_ITEM`; answered by `SMSG_SEND_MAIL_RESULT` with `MADE_PERMANENT`.
MailCreateTextItem {
mailbox: u64,
mail_id: u32,
},
/// `CMSG_ITEM_TEXT_QUERY`, once per mail with a nonzero `item_text_id`.
ItemTextQuery {
text_id: u32,
mail_id: u32,
},
/// `MSG_QUERY_NEXT_MAIL_TIME`, on entering the world, to seed `HasNewMail()`.
QueryNextMailTime,
// ── The auction house ──
// Every verb carries the auctioneer guid, range-checked (5 yd) each time. Several refusals
// get no packet: a zero bid or duration, an unaffordable bid, a cancel whose cut cannot be
// paid, a list request in flight.
/// `MSG_AUCTION_HELLO`: the reply, with the `AuctionHouse.dbc` id, opens the window.
AuctionHello {
auctioneer: u64,
},
/// `CMSG_AUCTION_LIST_ITEMS`: unset filters use `auction_filter`'s sentinels. No sort rides
/// the wire; `list_from` pages by 50.
AuctionListItems {
auctioneer: u64,
list_from: u32,
searched_name: String,
level_min: u8,
level_max: u8,
slot_id: u32,
main_category: u32,
sub_category: u32,
quality: u32,
usable: u8,
},
/// `CMSG_AUCTION_LIST_OWNER_ITEMS`: our own listings.
AuctionListOwnerItems {
auctioneer: u64,
list_from: u32,
},
/// `CMSG_AUCTION_LIST_BIDDER_ITEMS`: `auction_ids` are listed first, then every auction we
/// hold the bid on; not a filter.
AuctionListBidderItems {
auctioneer: u64,
list_from: u32,
auction_ids: Vec<u32>,
},
/// `CMSG_AUCTION_SELL_ITEM`: `etime_minutes` is 120, 480 or 1440; the deposit is taken at once.
AuctionSellItem {
auctioneer: u64,
item_guid: u64,
bid: u32,
buyout: u32,
etime_minutes: u32,
},
/// `CMSG_AUCTION_PLACE_BID`: a `price` at or above a nonzero buyout is the buyout.
AuctionPlaceBid {
auctioneer: u64,
auction_id: u32,
price: u32,
},
/// `CMSG_AUCTION_REMOVE_ITEM`: the deposit is forfeit; with a bid, the 5% cut is charged.
AuctionRemoveItem {
auctioneer: u64,
auction_id: u32,
},
/// `CMSG_QUERY_TIME`, answered into [`ServerWallClock`].
QueryTime,
/// `CMSG_INSPECT`: the window paints from the streamed `PLAYER_VISIBLE_ITEM_*` fields; the
/// send also sets our selection server-side (`MiscHandler.cpp:945`).
Inspect {
target: u64,
},
/// `MSG_INSPECT_HONOR_STATS`: the only source of another player's honor, whose fields are
/// private. A refusal is silence.
InspectHonorStats {
target: u64,
},
// ── Player trade ──
/// `CMSG_INITIATE_TRADE`: a refusal answers us; success sends the target `BEGIN_TRADE`.
InitiateTrade {
target: u64,
},
/// `CMSG_BEGIN_TRADE`: accept a request; the server sends `OPEN_WINDOW` to both. Sent only by
/// [`crate::ui_trade::answer_trade_request`].
BeginTrade,
/// `CMSG_IGNORE_TRADE` (`0x119`): the refusal ladder's ignore leg (`0x4bf759` to `0x5d41c0`);
/// vmangos answers the initiator `TRADE_STATUS_IGNORE_YOU`.
IgnoreTrade,
/// `CMSG_BUSY_TRADE` (`0x118`, sent by `0x5d4150`): five of the ladder's eight refusal legs;
/// vmangos sends `TRADE_STATUS_BUSY` to both. The player's own No is [`Self::CancelTrade`].
BusyTrade,
/// `CMSG_ACCEPT_TRADE`: the Trade button.
AcceptTrade,
/// `CMSG_UNACCEPT_TRADE`: drop our accept, stay in the trade.
UnacceptTrade,
/// `CMSG_CANCEL_TRADE`.
CancelTrade,
/// `CMSG_SET_TRADE_GOLD`: clears both accepts and re-arms the server's 200 ms delay; echoed
/// in `SMSG_TRADE_STATUS_EXTENDED`.
SetTradeGold {
copper: u32,
},
/// `CMSG_SET_TRADE_ITEM`: `trade_slot` 0..=6, 6 the not-traded enchant slot.
SetTradeItem {
trade_slot: u8,
bag: u8,
slot: u8,
},
/// `CMSG_CLEAR_TRADE_ITEM`, 0-based slot.
ClearTradeItem {
trade_slot: u8,
},
/// `CMSG_LOGOUT_REQUEST`: `SMSG_LOGOUT_COMPLETE` becomes a [`LoggedOutMessage`] and a
/// reconnect to character select.
Logout,
/// `CMSG_LOGOUT_CANCEL`; acked by `SMSG_LOGOUT_CANCEL_ACK`.
LogoutCancel,
/// `CMSG_JOIN_CHANNEL`: `/join` and the zone auto-join.
JoinChannel {
name: String,
password: String,
},
/// `CMSG_LEAVE_CHANNEL`.
LeaveChannel {
name: String,
},
/// `CMSG_CHANNEL_LIST`.
ChannelList {
name: String,
},
/// `/random [min] [max]` (`MSG_RANDOM_ROLL`).
RandomRoll {
min: u32,
max: u32,
},
/// `DisplayChannelOwner`: `CMSG_CHANNEL_OWNER`.
ChannelOwner {
name: String,
},
/// `SetChannelOwner`: `CMSG_CHANNEL_SET_OWNER`.
ChannelSetOwner {
name: String,
player: String,
},
/// `SetChannelPassword`: `CMSG_CHANNEL_PASSWORD`; empty clears.
ChannelPassword {
name: String,
password: String,
},
ChannelModerator {
name: String,
player: String,
},
ChannelUnmoderator {
name: String,
player: String,
},
ChannelMute {
name: String,
player: String,
},
ChannelUnmute {
name: String,
player: String,
},
ChannelInvite {
name: String,
player: String,
},
ChannelKick {
name: String,
player: String,
},
ChannelBan {
name: String,
player: String,
},
ChannelUnban {
name: String,
player: String,
},
/// `ChannelToggleAnnouncements`: `CMSG_CHANNEL_ANNOUNCEMENTS`.
ChannelAnnouncements {
name: String,
},
/// `ChannelModerate`: `CMSG_CHANNEL_MODERATE`.
ChannelModerate {
name: String,
},
/// `/played` (`CMSG_PLAYED_TIME`).
PlayedTime,
/// `CMSG_COMPLETE_CINEMATIC`, at the end or skip, or at once for an unresolvable trigger.
/// Unacked, vmangos keeps visibility on the cinematic camera and nearby NPCs despawn.
CompleteCinematic,
/// `CMSG_NEXT_CINEMATIC_CAMERA`, for every shot including the first: the send is in the shot
/// arm (`0x48ef11`), so a single-camera intro sends one.
NextCinematicCamera,
/// Acknowledge a root, water-walk, feather-fall or hover on [`MoveMode::ack_opcode`];
/// unacked, the server never applies it. `flags` must carry the mode bit: vmangos kicks a
/// root ack without `MOVEFLAG_ROOT` and takes the word as the new flags for the others.
MoveModeAck {
guid: u64,
counter: u32,
mode: MoveMode,
apply: bool,
flags: u32,
pos: [f32; 3],
orientation: f32,
},
/// `CMSG_MOVE_KNOCK_BACK_ACK`, `launch` echoed as the jump tail. Sent on the frame the mover
/// takes off, never on receipt: the reference applies (`0x61624d call 0x6179c0`) then sends
/// (`0x616261`) in one call, so the wire pose is post-launch.
KnockBackAck {
guid: u64,
counter: u32,
launch: JumpInfo,
flags: u32,
pos: [f32; 3],
orientation: f32,
transport: Option<TransportPose>,
},
/// `CMSG_REPOP_REQUEST`: Release Spirit.
RepopRequest,
/// `MSG_CORPSE_QUERY`, on becoming a ghost and on login dead.
CorpseQuery,
/// `CMSG_RECLAIM_CORPSE`: the server requires a ghost, the delay elapsed and 39 yd.
ReclaimCorpse {
corpse: u64,
},
/// `CMSG_SELF_RES`: the server casts `PLAYER_SELF_RES_SPELL`. No answer packet.
SelfRes,
/// `CMSG_SPIRIT_HEALER_ACTIVATE`: 50% res, 25% durability, sickness from level 11.
SpiritHealerActivate {
npc: u64,
},
/// `CMSG_RESURRECT_RESPONSE`.
ResurrectResponse {
caster: u64,
accept: bool,
},
// ── Group ──
/// `CMSG_GROUP_INVITE` by name; acked by `SMSG_PARTY_COMMAND_RESULT`.
GroupInvite {
name: String,
},
/// `CMSG_GROUP_ACCEPT`.
GroupAccept,
/// `CMSG_GROUP_DECLINE`.
GroupDecline,
/// `CMSG_GROUP_UNINVITE` by name, leader only.
GroupUninvite {
name: String,
},
/// `CMSG_GROUP_SET_LEADER`, leader only.
GroupSetLeader {
guid: u64,
},
/// `CMSG_GROUP_DISBAND`: leave the group, despite the name.
GroupLeave,
/// `CMSG_GROUP_RAID_CONVERT`, leader only.
GroupRaidConvert,
/// `CMSG_REQUEST_PARTY_MEMBER_STATS`, only on the reference's two edges: a member's object
/// leaves (`0x4e8646`), or a new roster member's object is not held (`0x4e83f1`). No timer.
RequestPartyMemberStats {
guid: u64,
},
/// `CMSG_LOOT_METHOD`: `method` 0..4, `threshold` quality 2..4. Leader only.
LootMethod {
method: u32,
master: u64,
threshold: u32,
},
/// `MSG_RAID_TARGET_UPDATE`: `icon` 0..7, `guid` 0 clears it. Leader or assistant only.
SetRaidTarget {
icon: u8,
guid: u64,
},
/// `MSG_MINIMAP_PING`, raw world `(x, y)`. The server relays it to the rest of the group and
/// ignores it solo, so our own marker is drawn locally.
MinimapPing {
x: f32,
y: f32,
},
// ── Raid management ──
/// `CMSG_GROUP_CHANGE_SUB_GROUP`: by name, 0-based subgroup. Leader or assistant only.
GroupChangeSubGroup {
name: String,
group: u8,
},
/// `CMSG_GROUP_SWAP_SUB_GROUP`, two names.
GroupSwapSubGroup {
name: String,
other: String,
},
/// `CMSG_GROUP_ASSISTANT_LEADER`, leader only.
GroupAssistantLeader {
guid: u64,
grant: bool,
},
/// `MSG_RAID_READY_CHECK` with an empty body, leader only.
ReadyCheckStart,
/// `MSG_RAID_READY_CHECK` with one byte.
ReadyCheckAnswer {
ready: bool,
},
/// `CMSG_REQUEST_RAID_INFO`.
RequestRaidInfo,
/// `CMSG_RESET_INSTANCES`; answered per instance, `SMSG_INSTANCE_RESET` or `_FAILED`.
ResetInstances,
// ── Duel ── (a challenge is a `CastSpell` of the duel spell)
/// `CMSG_DUEL_ACCEPTED`, also the challenger's own auto-accept.
DuelAccepted {
arbiter: u64,
},
/// `CMSG_DUEL_CANCELLED`: decline, cancel and forfeit alike.
DuelCancelled {
arbiter: u64,
},
// ── Social ── (added by name, removed by guid)
/// `CMSG_FRIEND_LIST`: `ShowFriends()`.
FriendListRequest,
/// `CMSG_ADD_FRIEND`.
AddFriend {
name: String,
},
/// `CMSG_SET_LOOKING_FOR_GROUP`, only when the commit changed something.
SetLookingForGroup {
slots: [u32; 3],
comment: String,
},
/// `CMSG_DEL_FRIEND`.
DelFriend {
guid: u64,
},
/// `CMSG_ADD_IGNORE`.
AddIgnore {
name: String,
},
/// `CMSG_DEL_IGNORE`.
DelIgnore {
guid: u64,
},
/// `CMSG_CHAT_IGNORED`: the server still delivers ignored chat, so the client drops it and
/// reports, and the sender hears they are ignored.
ChatIgnored {
guid: u64,
},
/// `CMSG_WHO`, parsed into wire fields by `ui_social::who_query`.
Who {
request: Box<WhoRequest>,
},
/// `CMSG_TOGGLE_PVP`: nothing changes locally; the answer is the PvP bit, and flagging off
/// waits out the server's 300 s timer.
TogglePvp,
/// `CMSG_TOGGLE_HELM`: nothing changes locally; the answer is `PLAYER_FLAGS`' `HIDE_HELM`.
ToggleHelm,
/// `CMSG_TOGGLE_CLOAK`, as [`Self::ToggleHelm`].
ToggleCloak,
// ── Guild ──
// Members by name; rank 0 is the guild master. A change is answered by a fresh
// `SMSG_GUILD_ROSTER`, never applied at the send; a refusal by `SMSG_GUILD_COMMAND_RESULT`.
/// `CMSG_GUILD_QUERY`, the ask-once guild name cache.
GuildQuery {
guild_id: u32,
},
/// `CMSG_GUILD_CREATE`: vmangos registers it `STATUS_NEVER` (founding is the charter flow).
#[allow(dead_code)]
GuildCreate {
name: String,
},
/// `CMSG_GUILD_INVITE`.
GuildInvite {
name: String,
},
/// `CMSG_GUILD_ACCEPT`: the server holds which invite.
GuildAccept,
/// `CMSG_GUILD_DECLINE`.
GuildDecline,
/// `CMSG_GUILD_INFO`: founding date and counts.
GuildInfoRequest,
/// `CMSG_GUILD_ROSTER`.
GuildRosterRequest,
/// `CMSG_GUILD_PROMOTE`.
GuildPromote {
name: String,
},
/// `CMSG_GUILD_DEMOTE`.
GuildDemote {
name: String,
},
/// `CMSG_GUILD_LEAVE`: refused to a guild master while others remain.
GuildLeave,
/// `CMSG_GUILD_REMOVE`.
GuildRemove {
name: String,
},
/// `CMSG_GUILD_DISBAND`, guild master only.
GuildDisband,
/// `CMSG_GUILD_LEADER`.
GuildLeader {
name: String,
},
/// `CMSG_GUILD_MOTD`; empty clears.
GuildMotd {
motd: String,
},
/// `CMSG_GUILD_RANK`: name and rights always together. Rank 0's rights are forced to all; a
/// name over `messages::GUILD_RANK_MAX_LENGTH` gets the session kicked.
GuildRank {
rank_id: u32,
rights: u32,
name: String,
},
/// `CMSG_GUILD_ADD_RANK`: appended at the bottom.
GuildAddRank {
name: String,
},
/// `CMSG_GUILD_DEL_RANK`: always the lowest rank; no id on the wire.
GuildDelRank,
/// `CMSG_GUILD_SET_PUBLIC_NOTE`.
GuildSetPublicNote {
name: String,
note: String,
},
/// `CMSG_GUILD_SET_OFFICER_NOTE`; editing and viewing officer notes are separate rights.
GuildSetOfficerNote {
name: String,
note: String,
},
/// `CMSG_GUILD_INFO_TEXT`; returns as the roster's `info` field.
GuildInfoText {
text: String,
},
// ── Petitions (founding a guild) ──
// A charter is addressed by its item guid except in the record query. A refusal is
// `SMSG_GUILD_COMMAND_RESULT`; almost nothing is acked, so nothing is applied at the send.
/// `CMSG_PETITION_SHOWLIST`.
PetitionShowList {
npc: u64,
},
/// `CMSG_PETITION_BUY`: success is only the new item.
PetitionBuy {
npc: u64,
name: String,
},
/// `CMSG_PETITION_SHOW_SIGNATURES`, on use and after a signature lands.
PetitionShowSignatures {
item: u64,
},
/// `CMSG_PETITION_SIGN`: `byte` is the optional Lua argument (default 1), which vmangos drops.
PetitionSign {
item: u64,
byte: i8,
},
/// `CMSG_OFFER_PETITION`: success answers the target, not us.
OfferPetition {
item: u64,
player: u64,
},
/// `CMSG_TURN_IN_PETITION`, owner only; a name collision answers a guild command result only.
TurnInPetition {
item: u64,
},
/// `CMSG_PETITION_QUERY`: the only source of the guild name and signature requirement.
PetitionQuery {
petition_id: u32,
item: u64,
},
/// `MSG_PETITION_RENAME`; echoed only on success.
PetitionRename {
item: u64,
name: String,
},
/// `MSG_PETITION_DECLINE`: we send the item guid; the owner receives ours.
#[allow(dead_code)]
PetitionDecline {
item: u64,
},
// ── Taxi ──
/// `CMSG_TAXINODE_STATUS_QUERY` with the flight master's guid. Not sent yet.
#[allow(dead_code)]
TaxiNodeStatusQuery {
guid: u64,
},
/// `CMSG_TAXIQUERYAVAILABLENODES`: a pure flight master's opener (gossip wins on a gossip NPC).
/// A known node answers `SMSG_SHOWTAXINODES`; a new one the learn pair, and opens nothing.
TaxiQueryNodes {
guid: u64,
},
/// `CMSG_ACTIVATETAXI`: one hop; answered by `SMSG_ACTIVATETAXIREPLY`.
ActivateTaxi {
guid: u64,
source_node: u32,
dest_node: u32,
},
/// `CMSG_ACTIVATETAXIEXPRESS`: sent when no direct `TaxiPath` edge joins the two nodes, not by
/// hop count; the combined fare and the node chain in order.
ActivateTaxiExpress {
guid: u64,
total_cost: u32,
nodes: Vec<u32>,
},
}
/// The realm list (`CMD_REALM_LIST`); the IO thread waits for [`crate::realm_select`] to pick.
#[derive(Message)]
pub(crate) struct RealmListMessage {
pub(crate) realms: Vec<benilla_protocol::RealmInfo>,
}
/// The character roster (`SMSG_CHAR_ENUM`); the IO thread waits for [`crate::char_select`] to
/// pick. `realm` is the realm this session connected to.
#[derive(Message)]
pub(crate) struct CharListMessage {
pub(crate) characters: Vec<benilla_protocol::Character>,
pub(crate) realm: Option<benilla_protocol::RealmInfo>,
}
/// A character create or delete result; a refreshed [`CharListMessage`] precedes a success.
/// `code` is the raw `WorldResult` byte.
#[derive(Message, Clone, Copy)]
pub(crate) struct CharActionResultMessage {
pub(crate) action: benilla_protocol::CharAction,
pub(crate) code: u8,
}
/// `SMSG_CHARACTER_LOGIN_FAILED`: the picked character was refused. `result` is the raw reason,
/// read by [`crate::char_select::char_login_refusal_text`].
#[derive(Message, Clone, Copy)]
pub(crate) struct CharacterLoginFailedMessage {
pub(crate) result: u8,
}
/// We entered the world.
#[derive(Message)]
pub(crate) struct EnteredWorldMessage {
/// Rested billing minutes from `SMSG_AUTH_RESPONSE`, the only time they arrive.
pub(crate) billing_time_rested: u32,
/// The tutorial flags, if `SMSG_TUTORIAL_FLAGS` came during the login handshake.
pub(crate) tutorial_flags: Option<Vec<u8>>,
}
/// `SMSG_ADDON_INFO`: the addons the server hid from the Lua index space, or `None` if it did
/// not answer. A resource, since it must exist before the world-entry UI load runs any addon;
/// rewritten on every login.
#[derive(Resource, Default)]
pub(crate) struct AddonInfoReply(pub(crate) Option<Vec<String>>);
/// `SMSG_TRIGGER_CINEMATIC`: a `CinematicSequences.dbc` id; [`crate::cinematic`] plays and acks it.
#[derive(Message)]
pub(crate) struct CinematicTriggeredMessage {
pub(crate) cinematic_id: u32,
}
/// One `CHAT_MSG_SYSTEM` line, such as a GM command's answer: the only evidence of server state
/// with no field, like vmangos god mode (`Unit::m_invincibilityHpThreshold`).
#[derive(Message, Clone)]
pub(crate) struct ServerSaidMessage {
pub(crate) text: String,
}
/// `SMSG_LOGOUT_COMPLETE`: back to character select. The drain tears down our own entity.
#[derive(Message)]
pub(crate) struct LoggedOutMessage;
/// A login stage, shown as its `LOGIN_STATE_*` string.
#[derive(Message, Clone, Copy)]
pub(crate) struct LoginStageMessage {
pub(crate) stage: benilla_protocol::LoginStage,
}
/// Queued for a full realm, one per `AUTH_WAIT_QUEUE`; the attempt is still live.
#[derive(Message, Clone)]
pub(crate) struct LoginQueuedMessage {
pub(crate) position: Option<u32>,
pub(crate) realm: Option<String>,
}
/// The session ended; the IO thread is back at its pre-logon park.
#[derive(Message, Clone)]
pub(crate) struct DisconnectedMessage {
pub(crate) reason: String,
pub(crate) end: benilla_protocol::SessionEnd,
/// The reference's `DISCONNECTED_FROM_SERVER`: back at the account screen, nothing retried.
/// Decided once in [`Self::new`], so its four readers (teardown, screen, cover, credentials)
/// cannot disagree.
pub(crate) session_over: bool,
}
impl DisconnectedMessage {
/// `session_over` is false after a logout (the relist is the select the player asked for) and
/// in a run declared unattended ([`crate::run_mode::unattended`]), which reconnects. Not the
/// login env vars: a person's launch line sets them too, and a reconnect would take the
/// account back from whoever displaced us.
pub(crate) fn new(reason: String, end: benilla_protocol::SessionEnd) -> Self {
let session_over =
end == benilla_protocol::SessionEnd::Lost && !crate::run_mode::unattended();
Self {
reason,
end,
session_over,
}
}
}
/// A login attempt failed before the roster; the IO thread is back at its pre-logon park.
/// `terminal` is a failure retrying cannot fix (the server requires Warden, say): never resubmit.
#[derive(Message, Clone)]
pub(crate) struct LoginFailedMessage {
/// Which server refused us, with which byte; `None` on a transport failure.
pub(crate) refusal: Option<benilla_protocol::LoginRefusal>,
pub(crate) reason: String,
pub(crate) terminal: bool,
/// The dial that never opened a socket, so the screen can tell an unknown name from a host
/// that does not answer.
pub(crate) dial: Option<benilla_protocol::DialFailure>,
}
/// A same-map teleport of our player: snap, then ack. Read by the controller in `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,
}
/// An inbound `MSG_MOVE_*` for our own mover: always server-authored (vmangos
/// `MovementInfo::SetAsServerSide`), from GM moves and the anticheat snap-back, with no ack owed.
/// The reference applies it like a remote's, having no mover-guid gate (`0x603bb0`); ours lands
/// in `player::wire_in`.
#[derive(Message, Clone, Copy)]
pub(crate) struct SelfMoveMessage {
pub(crate) position: [f32; 3],
pub(crate) orientation: f32,
/// Merged under [`crate::creature_anim::move_flags::SERVER_AUTHORED`], never assigned.
pub(crate) flags: u32,
pub(crate) pitch: f32,
pub(crate) fall_time: u32,
pub(crate) jump: Option<benilla_protocol::JumpInfo>,
/// The server's deck-local pose for us. Never re-derive it from the boat's transform, which
/// [`crate::transport::tick_transports`] holds at the far pose across a map seam.
pub(crate) transport: Option<benilla_protocol::TransportPose>,
}
/// `SMSG_CLIENT_CONTROL_UPDATE`, verbatim; the controller decides. `mover` is the unit spoken
/// about, not the new mover: the server revokes by naming us (`allow_move` false) and grants by
/// naming another unit.
#[derive(Message, Clone, Copy)]
pub(crate) struct ClientControlMessage {
pub(crate) mover: u64,
pub(crate) allow_move: bool,
}
/// `SMSG_FORCE_*_SPEED_CHANGE` for our mover; the controller answers
/// [`ClientCommand::ForceSpeedAck`] with its live 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,
}
/// Root, water-walk, feather-fall or hover granted or revoked on our mover: the server's
/// `IsFlagAckOpcode` set. The controller acks with its live pose.
#[derive(Message, Clone, Copy)]
pub(crate) struct MoveModeMessage {
/// Our mover's guid, echoed in the ack.
pub(crate) guid: u64,
pub(crate) counter: u32,
pub(crate) mode: MoveMode,
pub(crate) apply: bool,
}
/// `SMSG_MOVE_KNOCK_BACK` at our mover; the controller launches and acks.
#[derive(Message, Clone, Copy)]
pub(crate) struct KnockBackMessage {
/// Our mover's guid, echoed in the ack as a full u64.
pub(crate) guid: u64,
pub(crate) counter: u32,
/// The launch, in the jump tail's convention (`zspeed` down-positive).
pub(crate) launch: JumpInfo,
}
/// A cross-map worldport: snap, swap the map, ack if required.
#[derive(Message, Clone, Copy)]
pub(crate) struct WorldportMessage {
pub(crate) map_id: u32,
/// Boat-local when `transport_entry` is set (vmangos `SendNewWorld` sends
/// `GetTransportPos()`); world otherwise.
pub(crate) position: [f32; 3],
pub(crate) orientation: f32,
pub(crate) needs_ack: bool,
/// The transport carrying us, from `SMSG_TRANSFER_PENDING` ([`PendingTransfer`]). `None`
/// means the server detached us and any ride is stale.
pub(crate) transport_entry: Option<u32>,
}
/// The `SMSG_TRANSFER_PENDING` latch: whether the coming worldport rides a transport. Cleared
/// on abort and 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>,
}
/// `SMSG_PLAY_SOUND`, `PLAY_MUSIC` or `PLAY_OBJECT_SOUND`: a SoundEntries id. An unstreamed
/// `source` plays 2D.
#[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,
}
/// `SMSG_TEXT_EMOTE` or `SMSG_EMOTE`; an unstreamed performer is dropped (voice needs its race
/// and sex).
#[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 {
/// An `EmotesText.dbc` id.
Text(u32),
/// An `Emotes.dbc` id (its `EventSoundID`).
Anim(u32),
}
/// `SMSG_AI_REACTION`: `hostile` is reaction 2 (each melee start), else 0 (the stealth alert).
/// Audio only in the client (`0x6056e0`).
#[derive(Message, Clone, Copy)]
pub(crate) struct AiReactionMessage {
pub(crate) unit: Entity,
pub(crate) hostile: bool,
}
/// `SMSG_PET_ACTION_SOUND`: `talk` is `PET_TALK_ORDER` or `PET_TALK_ATTACK`, resolved through
/// the pet's `CreatureSoundData`. Audio only (`0x6040c0`).
#[derive(Message, Clone, Copy)]
pub(crate) struct PetTalkMessage {
pub(crate) unit: Entity,
pub(crate) talk: u32,
}
/// `SMSG_PET_DISMISS_SOUND`: a `CreatureModelData` id and a point in Bevy space; no guid, the
/// pet is already gone.
#[derive(Message, Clone, Copy)]
pub(crate) struct PetDismissSoundMessage {
pub(crate) model_id: u32,
pub(crate) pos: Vec3,
}
#[cfg(test)]
mod tests {
use super::*;
/// A re-create of a live object notifies like a delta.
#[test]
fn the_merge_emits_one_edge_per_moved_dword_and_none_for_a_first_create() {
use benilla_protocol::field::{FIELD_UNIT_HEALTH, FIELD_UNIT_LEVEL};
use benilla_protocol::messages::ObjectType;
let mut world = World::new();
world.init_resource::<Messages<FieldChanged>>();
let unit = world.spawn(Guid(0xF130_0000_0000_0042)).id();
let drain = |world: &mut World| -> Vec<FieldChanged> {
world
.resource_mut::<Messages<FieldChanged>>()
.drain()
.collect()
};
apply_fields_for_test(
&mut world,
unit,
ObjectFields::from_pairs(&[(FIELD_UNIT_HEALTH, 100), (FIELD_UNIT_LEVEL, 9)])
.into_created(ObjectType::Unit),
);
assert!(drain(&mut world).is_empty(), "a create is not an edge");
// The level re-sent unchanged, the health moved.
apply_fields_for_test(
&mut world,
unit,
ObjectFields::from_pairs(&[(FIELD_UNIT_LEVEL, 9), (FIELD_UNIT_HEALTH, 0)]),
);
assert_eq!(
drain(&mut world),
vec![FieldChanged {
entity: unit,
guid: 0xF130_0000_0000_0042,
kind: ObjectType::Unit,
index: FIELD_UNIT_HEALTH,
old: 100,
new: 0,
}],
"one edge for the dword that moved, carrying the old value; the resend is silent"
);
// A re-create of the live guid: health was already 0, so the level is the only edge.
apply_fields_for_test(
&mut world,
unit,
ObjectFields::from_pairs(&[(FIELD_UNIT_LEVEL, 10)]).into_created(ObjectType::Unit),
);
let edges = drain(&mut world);
assert_eq!(edges.len(), 1);
assert!(edges[0].unit_field(FIELD_UNIT_LEVEL) && edges[0].old == 9 && edges[0].new == 10);
assert_eq!(
world.get::<ObjectStore>(unit).unwrap().0.unit_level(),
Some(10)
);
}
/// Login env vars alone do not make a run unattended: a person's launch line sets them too.
#[test]
fn a_kick_ends_the_session_unless_the_run_declared_itself_driverless() {
use crate::local_state::test_env::{EnvGuard, ENV_LOCK};
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let lost = || {
DisconnectedMessage::new("kicked".into(), benilla_protocol::SessionEnd::Lost)
.session_over
};
// Credentials in the environment and nothing declared: a person, so the session is over.
let _user = EnvGuard::set("WOW_USER", "player");
let _pass = EnvGuard::set("WOW_PASS", "secret");
let _char = EnvGuard::set("WOW_CHAR", "Hero");
let _decl = EnvGuard::unset("WOW_UNATTENDED");
let _cap = EnvGuard::unset("WOW_CAPTURE");
let _rig = EnvGuard::unset("WOW_RIG");
assert!(lost(), "the reported regression: this must be `true`");
assert!(
!DisconnectedMessage::new("bye".into(), benilla_protocol::SessionEnd::LoggedOut)
.session_over
);
// A declared unattended run, a rig or a capture reconnects.
{
let _d = EnvGuard::set("WOW_UNATTENDED", "1");
assert!(!lost(), "a declared probe still reconnects");
}
{
let _r = EnvGuard::set("WOW_RIG", "at:229,0,0,0");
assert!(!lost(), "a rig drives the body; it cannot be a person");
}
{
let _c = EnvGuard::set("WOW_CAPTURE", "some-scenario");
assert!(
!lost(),
"a capture authors the camera; it cannot be a person"
);
}
assert!(lost(), "and the guards put every one of those back");
}
/// The player speeds, vmangos `baseMoveSpeed`.
fn vanilla() -> MoveSpeeds {
MoveSpeeds {
walk: 2.5,
run: 7.0,
run_back: 4.5,
swim: 4.722,
swim_back: 2.5,
turn_rate: std::f32::consts::PI,
}
}
/// The walk arm (`0x7c4d11`) precedes the backward min (`0x7c4d1d`).
#[test]
fn the_walk_arm_outranks_the_backward_min() {
use crate::creature_anim::move_flags as f;
let s = vanilla();
assert_eq!(current_speed(&s, f::FORWARD), 7.0, "plain run");
assert_eq!(current_speed(&s, f::BACKWARD), 4.5, "the backward min");
assert_eq!(current_speed(&s, f::FORWARD | f::WALK_MODE), 2.5, "walk");
assert_eq!(
current_speed(&s, f::BACKWARD | f::WALK_MODE),
2.5,
"walking backwards is a WALK, not a run-back: the walk arm is taken first"
);
// A strafing walker likewise.
assert_eq!(current_speed(&s, f::STRAFE_LEFT | f::WALK_MODE), 2.5);
}
/// `0x7c4cd9` precedes `0x7c4d11`.
#[test]
fn swimming_is_tested_first_so_there_is_no_swim_walk() {
use crate::creature_anim::move_flags as f;
let s = vanilla();
assert_eq!(
current_speed(&s, f::FORWARD | f::SWIMMING | f::WALK_MODE),
s.swim
);
assert_eq!(
current_speed(&s, f::BACKWARD | f::SWIMMING | f::WALK_MODE),
2.5,
"the swim arm's own backward min — reached, and unaffected by the walk bit"
);
}
/// x87 `fcompp`, `test ah,0x41`, `jp`: visible only when a server sets a slow field above a
/// fast one.
#[test]
fn every_arm_that_looks_like_a_select_is_really_a_min() {
use crate::creature_anim::move_flags as f;
let s = MoveSpeeds {
walk: 12.0, // a walk-speed buff above run
run: 7.0,
run_back: 9.0, // a run-back above run
swim: 4.0,
swim_back: 9.0, // a swim-back above swim
turn_rate: std::f32::consts::PI,
};
assert_eq!(current_speed(&s, f::FORWARD | f::WALK_MODE), 7.0);
assert_eq!(current_speed(&s, f::BACKWARD), 7.0);
assert_eq!(current_speed(&s, f::FORWARD | f::SWIMMING), 4.0);
assert_eq!(current_speed(&s, f::BACKWARD | f::SWIMMING), 4.0);
}
/// No direction bit is 0 (`0x7c4c99`). No zero-`walk` fallback: our own create applies the
/// speed block like any other (`0x5fad50`).
#[test]
fn a_mode_is_not_a_motion_and_a_zero_walk_speed_is_a_zero() {
use crate::creature_anim::move_flags as f;
let s = vanilla();
assert_eq!(current_speed(&s, 0), 0.0);
assert_eq!(
current_speed(&s, f::WALK_MODE),
0.0,
"a mode is not a motion"
);
assert_eq!(current_speed(&s, f::TURN_LEFT | f::ROOT | f::SWIMMING), 0.0);
let unfilled = MoveSpeeds {
run: 7.0,
..Default::default()
};
assert_eq!(
current_speed(&unfilled, f::FORWARD | f::WALK_MODE),
0.0,
"no fallback: `min(walk, run)` with walk 0 is 0"
);
assert_eq!(
current_speed(&unfilled, f::FORWARD),
7.0,
"…and the run arm is untouched by it"
);
}
/// The whitelist at `0x49fa3f`-`0x49fa4e`. vmangos would accept `LANG_ADDON` on the refused
/// lanes too; the client never sends on them.
#[test]
fn addon_distributions_map_to_the_clients_own_four_wire_types() {
use benilla_protocol::messages as m;
use benilla_ui::script::AddonDistribution as D;
assert_eq!(addon_wire_chat_type(D::Party), 0x01, "CHAT_MSG_PARTY");
assert_eq!(addon_wire_chat_type(D::Raid), 0x02, "CHAT_MSG_RAID");
assert_eq!(addon_wire_chat_type(D::Guild), 0x03, "CHAT_MSG_GUILD");
assert_eq!(
addon_wire_chat_type(D::Battleground),
0x5C,
"CHAT_MSG_BATTLEGROUND"
);
// No distribution reaches a lane the reference refuses.
let image: Vec<u32> = [D::Party, D::Raid, D::Guild, D::Battleground]
.into_iter()
.map(addon_wire_chat_type)
.collect();
for refused in [
m::CHAT_TYPE_SAY,
m::CHAT_TYPE_YELL,
m::CHAT_TYPE_WHISPER,
m::CHAT_TYPE_EMOTE,
m::CHAT_TYPE_OFFICER,
m::CHAT_TYPE_CHANNEL,
m::CHAT_TYPE_RAID_LEADER,
m::CHAT_TYPE_RAID_WARNING,
m::CHAT_TYPE_BATTLEGROUND_LEADER,
] {
assert!(
!image.contains(&refused),
"{refused:#04x} is a lane the client never sends addon data on"
);
}
}
}