- the player picks the realm: benilla connected to whichever realm the auth server listed first — not a policy but the absence of one — with the reference's Change Realm button drawn permanently disabled and the character screen's banner reading "Connecting…", a string 1.12 does not have, invented because the realm only ever arrived as a passenger on the character list (`CharacterSelect.lua` l.66 ends that ladder with `CharSelectRealmName:Hide()` — no name, no banner); `read_realm_list` had read the flags byte, the category byte and the realm id into `_` locals and stringified the population on arrival, and all four are load-bearing; the IO thread gains a third park — logon → which realm? → world handshake → which character — publishing `SessionEvent::RealmList` and blocking on `RealmRequest::{Enter, Refresh, Abandon}` exactly as at the other two, `Enter` carrying the realm's name and not its index since the list is re-requested every 5 s (`RealmList.lua`'s `REALM_LIST_REFRESH_TIME`) off a realmd socket `Logon` now keeps; Change Realm hands the logon forward (`HeldLogon`) so a different realm costs a world dial, not a re-authentication, and a deliberate ask arms `Realms::forced` for exactly one list, because the realm remembered from last time is precisely the realm you are standing on and asking to leave — without it the button re-entered the same realm and appeared to do nothing; the auto-answer ladder is a pure `auto_answer` — `WOW_REALM`, then the remembered `realmName` CVar (which `set_realm_name` had poked into its slot in place, never reaching `cvar_changes`, so the persisted CVar whose whole job is "the last realm connected to" was absent from disk forever; it writes through `set_from_engine` now), then an unattended run takes the first realm that is up, every arm requiring the realm to be up — and the screen is `RealmList.xml` rebuilt on the same 640×512 HelpFrame plate the AddOns list uses, eighteen rows at the authored 20 px pitch, four sortable columns; the Population word is a BAND, not the float: `realm_load_stats` (0x46e510) takes the mean and `sqrt(Σ(pop − mean)² / (n − 1)) · 0.6`, `realm_load_classify` (0x46ec60) reads the sentinels first (0x20 → Recommended, 0x40 → New, 0x80 → Full) then strict `pop < mean − stddev` → Low, `pop > mean + stddev` → High, else Medium — so the same realm reads High on a quiet list and Medium on a busy one, and a lone realm is banded against the `n ≤ 1` short circuit's literal 1.0, so a local vmangos realm at 0.06 reads Low, not Medium; then wow-re's cross-check of `RealmList.cpp [0x46e3bf, 0x46fa05)` confirmed 0x01 = invalid / 0x02 = offline and that the real client does auto-connect by `realmName` without raising the list (`0x5ab800` resolves the stored name against the fresh list, its success leg calling no `SetStatus`), and corrected four things the first cut shipped: the statistics are GLOBAL — `0x46e510` walks the flat all-categories array unconditionally (`0x46e5ee fadd [esi+0x12c]`), the per-category bucketing in the same loop a side effect and never a bound, so scoping them to the tab made the same realm read a different word per tab; Recommended/New/Full never travel as flag bits at all — the parser recognises magic populations by bit pattern and rewrites them, 600.0 → 0.0 + 0x20, 200.0 → 0.001 + 0x40, 400.0 → 8.0 + 0x80, and it has to happen in `read_realm_list` before anything averages, since one realm advertised Recommended arriving as 600.0 drags the global mean past every other row; pvp/rp is a `Cfg_Configs.dbc` join on the type dword at `[realm+0x04]`, and the shipped rows make types 3 and 5 PvP and 7 RP where the 1/6/8 match had all three Normal; and the default sort is `[characters, load, name, mode]`, all ascending, written once per process by `0x46e300` — a four-key fallthrough where "ascending" characters means higher count first, a column click being move-to-front (`0x46e9b0`) that keeps the column's direction unless it was already in front — with the reference's non-antisymmetric characters comparator (its tie-breaking `strcmp` result clobbered one instruction later at `0x46e846`) deliberately not reproduced; `RealmWizard`, the `REALM_IS_FULL` confirm and the category tab strip are deliberately not built, each for a stated reason (2056, 2062)
- a re-played effect REPLACES the same record at the same attach point: the director's report that the reference draws far fewer impact effects when many mobs beat on one warrior — and named the star burst on both hands on every crit — is not a limiter but a replace, recorded in wow-re for weeks: `CEffect::AddEffect` (0x61fdd0), the path every kit slot and the melee blood spurt take, opens with `0x6208e0(owner, rec, tag)`, which walks the owner's `+0xb4` list and destroys every node carrying the same `SpellVisualEffectName` record at the same attach tag (bounded `[0x6208e0, 0x62092c]`), skipping `tag == -1`; benilla replaced only persistent instances, on the narrower `(spell_id, class)` key of the spell-id reap `0x614150`, and pushed every one-shot unconditionally — a gap seen and scoped to one caller four times (0850, 0927, 1149, 1518) without being generalised — so five attackers landing swings on one body held five blood spurts at `(UnitBlood, 0xf)` where the reference holds one, linear in the attacker count on additively-blended emitters where the reference's is flat, and near-identical in a 1v1, which is why it survived; the sharpest case is Flurry (12966–12970), applied on every critical strike, whose visual 2759 hangs record 416 — `Spells\Strike_Impact_Chest.mdx`, the gold chest-impact flare reused at the hand tags 0x15/0x16 — on both hands via cast kit 3010 over a persistent `BloodLust_State_Hand` glow from state kit 3009, and every crit stacked another pair; `spell_fx::replace_same_slot` now runs per slot at every Begin for persistent and self-terminating instances alike, keyed on the RECORD id threaded through the fx lane (`FxSlot { tag, effect, path }`, the hardcoded map becoming name → (id, path)) and not the path, because two rows may name one .mdx and the reference lets those coexist, with both exclusions kept — the world plant and an already-decaying node — and four tests pinning the key's two halves and both; the other half of the report, that the burst is too large and too long, is a separate mechanism and is not touched — and the instrument that would have measured it was lying: `m2part` read a burst emitter's `peak_rate()` as its particle count, while the reference fires on the rising edge of `enabled != 0 && rate > 0` sampled on one clock, and `Strike_Impact_Chest.m2`'s gold flare steps its gate 1 → 0 on the very keyframe its rate steps 0 → 50, so the two are never true together and that emitter emits NOTHING; the tool printed "burst of ~50 particles, life 0.50s" and a diagnosis started 2.7× over on count and 1.7× over on duration off that one line; `EmitTiming::first_burst` walks the slot's keyed span at 60 Hz and returns the first instant the predicate holds or `None`, the dump prints the real count and time or `NEVER FIRES`, and a real-asset test pins that model's emitter #0 as never and #1 as exactly 30 (2057)
- a harmful spell's impact flinches its victim: benilla had one spell-side wound path, `play_kit`'s `[8,10]` branch — the kit player's own `0x60f3ad` test — carrying the KIT's anim id, so a spell whose impact kit names no wound anim, which is nearly every one (Fireball, Frostbolt, Shadow Bolt, Sunder Armor, a mob's Strike), twitched nothing, and a kit naming CombatCritical played 10 where the client plays 9 or 8; `0x60ea70` has six callers, not the three an earlier sweep found (`grep "call 0x60ea70"` over the full objdump was the one command it never ran): the missile hand-off `0x61dc50` lays the flinch before the impact kit for every living target (`0x61dc74`, HEALTH > 0; the already-dead arm `0x60ea30` plays Death/SwimDeath instead), the instant-hit ImpactKit loop `0x6e8bf0` lays it after the kit when `0x6ea280(SpellRec) == 2` (`0x6e8c89`), the reflect kit `0x6e8cb0` does the same on the caster, and every spell-side call pushes severity 0, so the id is always `0x60ea70`'s own `(severity, engaged)` pick — CombatWound(9) engaged, StandWound(8) otherwise — never the kit column; `0x6ea280` is the spell's hostility class and `== 2` ⇔ the spell targets enemies: Targets bit `0x100` → never, `0x80` → yes, else `EffectImplicitTargetA[i]` then `B[i]` for effects 0..3 indexed into the byte tables `0x6ea338`/`0x6ea378`, whose case-0 ids `{2, 6, 15, 16, 24, 28, 53, 54}` are exactly the modern enum's `*_ENEMY` set; so `WoundAnim { entity }` loses its id and the driver resolves every spell edge as `wound_anim(0, engaged)`, `play_impact` takes an `ImpactPlay` with `flinch` — the instant hits pass `SpellDisplay::is_harmful()`, a missile arrival passes true — `SpellDisplay` gains `effect_implicit_target_a/b` (columns 82–84 / 85–87) and `is_harmful()`, and the flinch is laid as the same secondary-blend overlay as a melee wound, over a cast, a swing or a run, never replacing them; pinned against the real 5875 `Spell.dbc` — Fireball, Charge and Sunder Armor harmful through A, Frost Nova through B alone, Renew, Healing Touch, Arcane Intellect and Battle Shout not — and the driver never picks CombatCritical for a spell edge; `WOW_MOVE_TRACE` gains `fct wound trigger/evict/expire` lines, since the whole flinch lifecycle was invisible in a trace; and the report this was chased under — the warrior's swings reading as cut short by incoming impacts in a many-mob brawl — stays open, with one reference gate found on the way that benilla does not model: `0x60ea70` aborts while any attached CEffect on the victim carries `+0x2c & 0x4` (`0x60eab6`), and which kit-spawned nodes carry that bit is the question dispatched to wow-re (2058)
- other players' roots, levitation and blinks now relay to onlookers: a report that a nearby player getting rooted, water-walking or Blinking slid or stood in the wrong place until their next movement packet was exactly right — a movement mode has three legs and benilla carried two, the mover's own acked handshake (`SMSG_FORCE_MOVE_ROOT → CMSG_FORCE_MOVE_ROOT_ACK`, 0866) and the server-driven `SMSG_SPLINE_MOVE_*` twelve (1780), while the third, what observers are told once the mover acks (vmangos `MovementPacketSender.h:30-60`, broadcast by `SendMovementFlagChangeToObservers` / `SendTeleportToObservers`), fell through `parse_server` to `ServerPacket::Other` and was dropped — so a rooted player kept sliding for the ~500 ms to their next heartbeat, a Levitate on someone else was never rendered, and a Blink left them standing at the old spot; six opcodes — `MSG_MOVE_ROOT`/`UNROOT` (236/237), `MSG_MOVE_HOVER` (247), `MSG_MOVE_FEATHER_FALL` (688), `MSG_MOVE_WATER_WALK` (689), `MSG_MOVE_TELEPORT` (197) — all the ordinary `[packed guid][MovementInfo]` shape and all sharing the client's one relay handler `0x603bb0` with the 22 already carried (wow-re's opcode-handler table has thirty rows on it; the remaining two are cheat opcodes vmangos never constructs), so admitting them to `is_movement_relay` is the whole fix for five: apply/unapply rides the flags word rather than the opcode, since vmangos runs `SetHoverReal` &co. before the broadcast and the word on the wire already carries the bit's new state, and `apply_move` already commits it wholesale into the union the ground resolve reads for hover's offset and the water-walk plane — the pose the packet carries beside the mode is half the value, a rooted player's stopping place being news too, and the root arrives already post-wipe with `MOVEFLAG_ROOT` forced back on, so no wipe of ours is invented; the teleport is a relay in shape and a discontinuity in meaning, so `RelayMove::reconciles()` excludes it from the pre-fire reconcile beside the heartbeat, for the opposite reason — the heartbeat because the dead-reckon has already converged, the teleport because there is nothing to converge to, and lerping toward a Blink's destination would drag the mover across the twenty yards the Blink exists to skip, sweeping the shared capsule through every wall between — while it still rides the unit's relay chain rather than snapping at arrival, because replay is paced against the sender's own stamps and applying it early would put the player at the destination while their walk to the launch point was still replaying; a `tp` tag joins `hb`/`tr` on the `rly` trace line; and parse and decode of all six are pinned against golden bodies asserted four bytes shorter than the counter-carrying `MSG_MOVE_TELEPORT_ACK` form, so a counter is provably not being read (2061)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2934 lines
147 KiB
Rust
2934 lines
147 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;
|
||
use std::time::{Duration, Instant};
|
||
|
||
use benilla_protocol::{
|
||
messages::WhoRequest, EntityKind, JumpInfo, MoveMode, MoveSpeeds, ObjectFields, SessionEvent,
|
||
SpeedKind, TransportPose,
|
||
};
|
||
use bevy::prelude::*;
|
||
use crossbeam_channel::{Receiver, Sender};
|
||
|
||
use benilla_world::schedule::WorldStage;
|
||
|
||
mod apply;
|
||
pub(crate) mod io;
|
||
mod motion;
|
||
|
||
pub(crate) use apply::apply_net_updates;
|
||
use apply::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, ground_clamp_creatures, mark_swimming_creatures,
|
||
sample_splines,
|
||
};
|
||
// The client-local facing turn. `pub(crate)` because the shuffle latch it produces is read in
|
||
// `creature_anim`, and the cross-seam test that pins the pair runs both systems in one app.
|
||
pub(crate) use motion::drive_display_facing;
|
||
pub(crate) use motion::{
|
||
ground_derived, grounded_y, jump_seed, CreatureSwimming, FacingStep, RemoteMotion, Spline,
|
||
SplineStopped, UnitMoveModes,
|
||
};
|
||
// `GroundClamped`'s only consumer outside `net::motion::spline` is the ground-census probe, and a
|
||
// probe is an instrument — a build with the instruments compiled out contains nothing that names
|
||
// this re-export, and warned about it every time (decision 1451). The `allow` is "unused in *that*
|
||
// build", not dead code; a `cfg` here is not the alternative, because seam knowledge has exactly
|
||
// three addresses and this file is not one of them (1179, and its test says so out loud).
|
||
#[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 {
|
||
/// 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;
|
||
|
||
/// Release the ask-once latches at world enter — the counterpart to the disconnect teardown in
|
||
/// `apply::session`, which clears them when a socket DIES but never when one is born.
|
||
///
|
||
/// The asymmetry was load-bearing and wrong. `Items::template` (and `NameCache`'s resolvers) mark
|
||
/// an id pending *before* sending the query, and a command sent while the io thread holds no
|
||
/// writer evaporates with a warn — so an ask made before the first connect latched the id for the
|
||
/// life of the process. On 2026-09-06 that emptied the mail send tab's stationery list for an
|
||
/// entire session, in silence. The ask site that did it is fixed; this makes the class harmless,
|
||
/// because a redundant re-ask costs one packet and a wrong latch costs a dead feature nobody can
|
||
/// see is dead.
|
||
fn release_ask_once_latches_on_enter(
|
||
mut entered: MessageReader<EnteredWorldMessage>,
|
||
mut items: ResMut<crate::items::Items>,
|
||
mut names: ResMut<crate::names::NameCache>,
|
||
) {
|
||
if entered.read().count() == 0 {
|
||
return;
|
||
}
|
||
items.clear_pending();
|
||
names.clear_pending();
|
||
}
|
||
|
||
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);
|
||
}
|
||
// In the wire-drain stage, because that is what it is the product of. The lighting
|
||
// resolve is ordered after that stage so it always reads THIS frame's clock rather than
|
||
// whichever way the executor happened to break the tie.
|
||
app.add_systems(
|
||
Update,
|
||
publish_world_time.in_set(benilla_world::schedule::WorldStage::Net),
|
||
);
|
||
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::<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::<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::<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,
|
||
release_ask_once_latches_on_enter,
|
||
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,
|
||
// The client-local facing turn runs here — after the movers (spline / remote)
|
||
// have set their poses, so a turn reads the goal's fresh position this frame.
|
||
// A stationary unit squares up on its target, and the NPC whose interaction
|
||
// window is open turns to face us (decision 1467).
|
||
drive_display_facing,
|
||
)
|
||
.chain()
|
||
.in_set(WorldStage::Net),
|
||
// `InteractNpc`'s writer (`ui_session::feed_interact_npc`) seats itself INSIDE
|
||
// this chain — after `apply_net_updates`, before `drive_display_facing` — and
|
||
// declares both edges itself. It used to sit ahead of the whole chain, which
|
||
// read the window state one frame late; harmless for an ~8-frame facing ease,
|
||
// and exactly the frame the `"npc"` unit token was stale on when a window's
|
||
// own `MERCHANT_SHOW` handler read it (decision 2022).
|
||
)
|
||
// 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));
|
||
}
|
||
}
|
||
|
||
/// The population pulse (`WOW_MOVE_TRACE_TAGS=pop`): once a second, one trace line with the count
|
||
/// of net entities resident in the object index. The "did the world arrive" instrument, born as
|
||
/// decision 1340's retest: a teleport's visibility refresh (the release-time position report)
|
||
/// reads as the count climbing within a couple of seconds of the `sett` release, while the ~20 s
|
||
/// lazy-relocation pop-in the report guards against reads as the count sitting still until the
|
||
/// server's own timer fires. Free when the tag is off.
|
||
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. 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);
|
||
|
||
/// **`CMovement::GetCurrentSpeed 0x7c4c90`** — the one place a mover's move-flags become a
|
||
/// yards/second, for our own body and for every remote one. It is a *cascade*, and its ORDER is
|
||
/// the whole of it (VERIFIED bytes, wow-re `collision/scratch/airborne-steerability.md` §2.4 +
|
||
/// `object-layer/scratch/swim-mechanism.md` TU-H):
|
||
///
|
||
/// 1. `0x7c4c99 test dl,0xf` — **no direction bit at all → 0.0** (`[0x7ffd74]`), never a stale
|
||
/// speed. A standing mover is standing however its speed fields read.
|
||
/// 2. `0x7c4cd9 test edx,0x200000` — **SWIMMING first**, and its arm returns from inside: there
|
||
/// is no "swim-walk", so the walk bit below cannot reach a swimmer. Backward → the `min`.
|
||
/// 3. `0x7c4d11 test dh,0x1` — **WALK mode (`0x100`) next**, and this is the ordering that is easy
|
||
/// to get backwards: the walk arm `0x7c4d4d fld [ecx+0x88]; fld [ecx+0x8c]; fcompp` is taken
|
||
/// **before** the run arm's backward `min`, so a **walking backpedal is walk speed, not
|
||
/// run-back speed**. The arm is `min(MOVE_WALK, MOVE_RUN)` — not a plain select — so a
|
||
/// walk-speed buff above run speed is clamped exactly as the reference clamps it.
|
||
/// 4. `0x7c4d1d` — the run arm: backward → `min(MOVE_RUN_BACK, MOVE_RUN)`, else `MOVE_RUN`.
|
||
///
|
||
/// Both backward arms are a **min**, not a select: x87 `fcompp` + `test ah,0x41` + `jp` returns
|
||
/// the smaller (ties/NaN → the forward field). At vanilla values the min is always the back field,
|
||
/// so the clamp is only visible under a server that force-sets a back speed above its forward one.
|
||
///
|
||
/// **There is no zero-`walk` fallback here, and that is a settled question rather than an
|
||
/// oversight** (decision 1759). The ctor does zero all six speed fields (`0x7c48e8`–`0x7c4906`),
|
||
/// and 1752 guarded against a mover reaching this with `walk == 0` by falling back to the run arm.
|
||
/// The wow-re §5 then reported that the local player's create skips the speed-block apply, so the
|
||
/// zero was live after every world port — and then **retracted it**: the guard it found
|
||
/// (`0x5ff070`) has two callers, and the post-create `0x5fad50` passes 0, so the block is applied
|
||
/// for our own unit exactly as for a remote one (`0x5fad98 xor edi,edi`, unwritten to the push at
|
||
/// `0x5fb131`). The self-skip belongs to the *redundant*-create path `0x466350`, reached only when
|
||
/// the object-manager lookup HIT. The ctor's zero is a transient inside one call and is never
|
||
/// observable, so this is the plain minimum the bytes are.
|
||
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 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`]). This is **identity** — "my
|
||
/// character", the thing whose bags, auras, quest log and paper doll are mine. It is deliberately
|
||
/// *not* "the thing I steer": that is [`Embodied`], which normally sits on the same entity and
|
||
/// leaves it while you drive something else.
|
||
#[derive(Component)]
|
||
pub(crate) struct SelfPlayer;
|
||
|
||
/// **The body this client is attached to** — where the camera is, whose feet make the footsteps,
|
||
/// whose height the water lines are measured against, whose server-authored spline is ours to ride.
|
||
/// The reference's **camera anchor** (`camera+0x88`), which has exactly three writers — the
|
||
/// constructor and `SetTarget 0x50d0f0`'s two legs — and is *not* touched by a control update
|
||
/// (wow-re `control-loss-and-restore.md` §3).
|
||
///
|
||
/// Normally our own body, so it rides alongside [`SelfPlayer`]. While we hold somebody else's reins
|
||
/// — mind-controlling a creature, an Eye of Kilrogg — it moves to *that* entity and our own body
|
||
/// keeps none of it, exactly as `GetCamera().SetView(target)` moves the anchor at possession. That
|
||
/// is decision 0092's separation carried one step further: possession moves what you *inhabit*
|
||
/// without moving who you *are*.
|
||
///
|
||
/// **Attached is not the same as allowed to move**, and the second is [`ActiveMover`]'s job, not
|
||
/// this marker's (decision 1281). A feared player is still attached to their own body — the
|
||
/// reference keeps the anchor on it and keeps following it, merely smoothed — while the server, not
|
||
/// their input, says where it goes.
|
||
///
|
||
/// Exactly one entity carries it, and possibly none — a claimed mover whose object has not streamed
|
||
/// in yet is nobody, never a silent fallback to our own body. That distinction is load-bearing:
|
||
/// outbound `MSG_MOVE_*` carry no guid, so driving our body under a claimed creature's mover writes
|
||
/// our pose onto the creature. [`crate::player`] owns the placement.
|
||
#[derive(Component)]
|
||
pub(crate) struct Embodied;
|
||
|
||
/// **The one unit this client authors motion for** — the reference's active-mover global
|
||
/// (`ds:0xc4da98`/`0xc4da9c`, written only by `SetActiveMover 0x6006e0`), as a marker on the entity
|
||
/// that global names.
|
||
///
|
||
/// [`Embodied`] narrowed to the frames the body is ours to move. The narrowing is the reference's
|
||
/// own: `0x5fa600` **zeroes the mover globals** when a control update forbids the unit they name,
|
||
/// and with them zero the input applier (`0x514640` skips the whole tick when the mover does not
|
||
/// resolve) and every plain movement report — `0x600860`'s mover check kills the lot, heartbeat
|
||
/// included (wow-re `control-loss-and-restore.md` §2/§6). There is no separate "may not move" flag
|
||
/// in the movement path; the zeroed global *is* the immobility.
|
||
///
|
||
/// Here it also answers the ECS's version of that question — *may the ordinary server-replay path
|
||
/// move this unit?* A body we are attached to but may not move must answer **yes**, which is how a
|
||
/// mind-controlled player sees their own character walk where their captor drives it. A body we are
|
||
/// driving must answer **no**, or the server's echo of our own movement fights the controller. So
|
||
/// the replay lanes filter `Without<ActiveMover>`, not `Without<Embodied>`: the two differ only
|
||
/// while control is lost, which is precisely the window where the difference is the whole point.
|
||
#[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 — 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,
|
||
/// Select's **Change Realm**: drop the parked session but keep the logon, so the IO thread
|
||
/// reopens the realm list rather than the login screen. Picking a different realm re-dials a
|
||
/// world server; it does not re-authenticate.
|
||
ChangeRealm,
|
||
}
|
||
|
||
/// The **realm channel**: the app's answer to each [`RealmListMessage`] while the IO thread is
|
||
/// parked between the logon and the world dial. Sent by [`crate::realm_select`]'s policy (a
|
||
/// remembered realm, `WOW_REALM`, or the player's click on the realm list); the parked read thread
|
||
/// blocks on the other end.
|
||
#[derive(Resource)]
|
||
pub(crate) struct RealmChoice(pub(crate) Sender<RealmRequest>);
|
||
|
||
/// One request to the IO thread parked at the realm list.
|
||
pub(crate) enum RealmRequest {
|
||
/// Enter this realm — dial its world server. Carries the realm's **name**, not its index: the
|
||
/// list is re-requested every few seconds while the screen is up, and a server that adds or
|
||
/// drops a realm between the draw and the click would otherwise silently move the row out from
|
||
/// under the player's finger.
|
||
Enter(String),
|
||
/// Re-request the realm list on the still-open realmd connection (the reference's
|
||
/// `RequestRealmList`, fired by `RealmList_OnUpdate` every 5 s while the window is open).
|
||
Refresh,
|
||
/// The realm list's Cancel: drop the logon and return the IO thread to the pre-logon park —
|
||
/// the app is heading back 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 connection's ping clock and RTT history ([`io::PingClock`]), shared with both net threads:
|
||
/// the write thread stamps each `CMSG_PING` on it, and the **read thread** measures the
|
||
/// `SMSG_PONG` echo against it the moment the packet lands. The app only ever *reads* it — for
|
||
/// `GetNetStats` ([`crate::ui_net`]) and the debug panel — and clears it when a connection dies.
|
||
/// It does not measure: doing that from the drain is what B346 was.
|
||
#[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>);
|
||
|
||
/// Publish the session clock into the engine's [`benilla_world::lighting::WorldTime`] input, once a
|
||
/// frame. The renderer wants three scalars, not a wire sample with an `Instant` in it — and with
|
||
/// no server at all it wants noon, which is `WorldTime`'s own default rather than a branch here.
|
||
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 **wall clock** (`SMSG_QUERY_TIME_RESPONSE`, asked for on entering the world),
|
||
/// advanced monotonically from the sample. `None` until the first answer lands.
|
||
///
|
||
/// Not [`ServerTime`] just above: that is the *in-game* day/night clock the lighting reads, a
|
||
/// different quantity in a different unit which says nothing about the epoch here. This one is
|
||
/// unix-epoch seconds, and it exists because the server writes **absolute** stamps in that epoch
|
||
/// into descriptor fields — a timed quest's deadline is `time(nullptr) + limitTime` (vmangos
|
||
/// `Player::AddQuest`) and no packet ever restates it as a duration. So a countdown is
|
||
/// `deadline − now_unix()`, and the local machine's own wall clock is deliberately never consulted:
|
||
/// a player whose clock is a minute off would see every countdown a minute wrong, silently and only
|
||
/// on their screen (decision 1150).
|
||
#[derive(Resource, Default)]
|
||
pub(crate) struct ServerWallClock(pub(crate) Option<WallClockSample>);
|
||
|
||
/// One `SMSG_QUERY_TIME_RESPONSE` sample plus the monotonic instant it arrived — the [`GameTime`]
|
||
/// pattern, for the same reason: the clock advances between packets without re-reading a local wall
|
||
/// clock that an NTP step or a manual change can move under us.
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub(crate) struct WallClockSample {
|
||
/// The server's `time(nullptr)` at `received`.
|
||
base_unix: u32,
|
||
/// When the answer arrived (monotonic).
|
||
received: Instant,
|
||
}
|
||
|
||
impl ServerWallClock {
|
||
/// Take a fresh sample (the `SMSG_QUERY_TIME_RESPONSE` handler).
|
||
///
|
||
/// The reply is one round trip old, so the clock reads ~RTT/2 behind the server's; against a
|
||
/// countdown displayed to the second that is below the noise floor, which is why there is no
|
||
/// correction term here.
|
||
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), or `None` before the first sample.
|
||
pub(crate) fn now_unix(&self) -> Option<f64> {
|
||
self.0
|
||
.map(|s| f64::from(s.base_unix) + s.received.elapsed().as_secs_f64())
|
||
}
|
||
|
||
/// Whether the sample is old enough to re-ask (or absent entirely).
|
||
fn stale(&self) -> bool {
|
||
self.0.is_none_or(|s| s.received.elapsed() >= RESYNC_AFTER)
|
||
}
|
||
}
|
||
|
||
/// How long a wall-clock sample stands before the client asks again — the reference's own hour
|
||
/// (wow-re: the resync site `0x4de836` is gated on `now > [0xbb749c]`, armed `= now + 0xe10` at
|
||
/// `0x4def11`; decision 1154).
|
||
const RESYNC_AFTER: Duration = Duration::from_secs(3600);
|
||
|
||
/// Ask for the server's wall clock (decision 1150) — on entering the world (login, worldport and
|
||
/// instance transfer alike, the same cascade the mail arc's `QueryNextMailTime` hangs off), and
|
||
/// hourly thereafter.
|
||
///
|
||
/// The hourly leg is the reference's, and it is not about our own drift: a monotonic base does not
|
||
/// wander against a wall clock at any rate a countdown could show. What it tracks is the **server**
|
||
/// being re-clocked under us — the one direction no local reasoning can see — for the price of four
|
||
/// bytes an hour (decision 1154). It also self-heals a session that entered the world before the
|
||
/// socket could answer.
|
||
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 chase the cadence while a session exists — a command with no live writer evaporates
|
||
// with a warn, and the world-enter send covers the 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 player's reputation store: `(flags, standing)` per reputation-list slot, indexed by
|
||
/// `Faction.dbc`'s `reputationIndex`. The standing excludes the DBC race/class base — consumers add
|
||
/// it before ranking. Empty until `SMSG_INITIALIZE_FACTIONS` lands at login; kept current after that
|
||
/// by `SMSG_SET_FACTION_STANDING` (which also auto-reveals) and `SMSG_SET_FACTION_VISIBLE`.
|
||
///
|
||
/// Two consumers, and they read different halves. The **reaction decode** (targeting) reads the
|
||
/// standing: a reputation faction's NPCs colour by our rank with them, before any faction-template
|
||
/// comparison. The **reputation pane** ([`crate::ui_reputation`]) reads the flag byte as well — the
|
||
/// visible bit decides which rows exist at all, and bit `0x08` marks the pane's headers.
|
||
#[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 `/played` answer awaiting delivery to the VM (`SMSG_PLAYED_TIME`): total seconds played and
|
||
/// seconds since the last level-up.
|
||
///
|
||
/// A one-slot mailbox rather than a queue: the reply answers a request, the server sends one per
|
||
/// request, and a second landing before the first is drained would mean the first is already stale.
|
||
/// `None` whenever there is nothing undelivered.
|
||
#[derive(Resource, Default)]
|
||
pub(crate) struct PlayedTimeAnswer(pub(crate) Option<(u32, 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,
|
||
/// The walk/run gait flipped — the `TOGGLERUN` keybind's own packet. The reference sends it
|
||
/// from the toggle handler itself (`ToggleRun 0x513d50` → `0x60e080` → `0x617de0`, which
|
||
/// picks move-event kind `0xf`/`0xe` off the CURRENT walk bit and enqueues it), not from the
|
||
/// move-state broadcaster — so it goes out **standing still**, where the broadcaster's
|
||
/// locomotion-nibble gate (`0x61a99d test al,0xf`) would have swallowed it.
|
||
SetWalkMode,
|
||
SetRunMode,
|
||
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::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 wire `ChatMsg` type byte an addon broadcast rides (decision 1235) — the client's own
|
||
/// four-lane whitelist at `0x49fa3f`-`0x49fa4e`, VERIFIED in `WoW.exe` (5875), wow-re
|
||
/// `system/ui/scratch/addon-chat-law.md` §5.
|
||
///
|
||
/// A **total** map from a closed enum, and a named function rather than an inline `match` in
|
||
/// [`io`]'s dispatch so that it is assertable: this is the one place a distribution becomes a wire
|
||
/// byte, and getting it wrong sends an addon's payload down a lane nobody is listening on. There
|
||
/// is no fallback arm and none can be added — that is the whole reason the distribution crosses
|
||
/// the crate boundary as an enum instead of a token string.
|
||
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 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.
|
||
#[derive(Debug)]
|
||
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>,
|
||
},
|
||
/// Report that our mover's simulation advanced through `lag_ms` without integrating
|
||
/// (`CMSG_MOVE_TIME_SKIPPED`) — decision 1935. `guid` is the mover's own, the reference's
|
||
/// `0x600be0` gate being that it equals the active-mover globals. The server folds the number
|
||
/// into its copy of that mover's movement clock (`stime`/`ctime`), and — while it thinks we
|
||
/// have just boarded a transport — answers by re-sending the transport's create update.
|
||
MoveTimeSkipped {
|
||
guid: u64,
|
||
lag_ms: u32,
|
||
},
|
||
/// Claim `guid` as our mover (`CMSG_SET_ACTIVE_MOVER`). Login sends it for our own body; a
|
||
/// possession handoff re-sends it for the unit we were handed, because the server drops every
|
||
/// `MSG_MOVE_*` for a mover it has not confirmed.
|
||
SetActiveMover {
|
||
guid: u64,
|
||
},
|
||
/// Release `guid` as our mover (`CMSG_MOVE_NOT_ACTIVE_MOVER`) at the pose it is parting on —
|
||
/// the server re-broadcasts a stop under that guid from this payload, so observers do not keep
|
||
/// the unit sliding.
|
||
NotActiveMover {
|
||
guid: u64,
|
||
flags: u32,
|
||
pos: [f32; 3],
|
||
orientation: f32,
|
||
fall_time: u32,
|
||
},
|
||
/// Vote on the far-sight view (`CMSG_FAR_SIGHT`): `true` as it attaches, `false` as it
|
||
/// releases. The reference sends both; neither names an object.
|
||
FarSight {
|
||
engage: bool,
|
||
},
|
||
/// 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,
|
||
},
|
||
/// **An addon broadcast** (`SendAddonMessage`, decision 1235) — a `CMSG_MESSAGECHAT` carrying
|
||
/// `LANG_ADDON` in the language field instead of the speaker's tongue, which is the entire
|
||
/// difference between addon data and speech (1.12.1 has no addon opcode).
|
||
///
|
||
/// Deliberately **not** a flag on [`Self::Chat`]: that command's whole family speaks
|
||
/// `WorldWriter::chat_language`, and a language field bolted onto it would be a `None` that
|
||
/// fourteen arms have to remember to honour. This one carries no target (1.12's
|
||
/// `SendAddonMessage` has no fourth argument, so there is no whispered addon message) and no
|
||
/// language dial (there is exactly one legal value).
|
||
///
|
||
/// `distribution` is the client's own four-value whitelist, already resolved and already
|
||
/// downgraded for the outside-a-raid case by the binding
|
||
/// ([`benilla_ui::script::AddonDistribution`]); `text` is the composed `prefix` TAB `message`.
|
||
AddonMessage {
|
||
distribution: benilla_ui::script::AddonDistribution,
|
||
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.
|
||
///
|
||
/// `target` is the cast-targets block, bound by the one cast ladder exactly as a
|
||
/// [`Self::CastSpell`]'s is — the real client's `SendCast 0x6e54f0` writes one block for both
|
||
/// opcodes. `Object` is the key-in-a-lock arm (decision 0769); `Unit` a bandage/soulstone;
|
||
/// `SelfImplicit` every ordinary consumable.
|
||
UseItem {
|
||
bag_index: u8,
|
||
slot: u8,
|
||
spell_index: u8,
|
||
target: benilla_protocol::messages::UseItemTarget,
|
||
},
|
||
/// 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,
|
||
},
|
||
/// Wrap the item at `(item_bag, item_slot)` in the paper at `(gift_bag, gift_slot)`
|
||
/// (`CMSG_WRAP_ITEM`, the same bag addressing) — the completion of the local wrap cursor a
|
||
/// `ITEM_FLAG_WRAPPER` item's right-click armed. The **paper leads**, as the wire does.
|
||
///
|
||
/// The client applies no eligibility filter of its own (decision 1934): an equipped item, a
|
||
/// bag, a soulbound or stackable or unique item, or an already-wrapped one all send and are
|
||
/// refused server-side with one of the six `ERR_CANT_WRAP_*` reasons, arriving here as an
|
||
/// `InventoryFailure` on the UI error line. Success is silent — field updates on the target
|
||
/// and one paper destroyed.
|
||
WrapItem {
|
||
gift_bag: u8,
|
||
gift_slot: u8,
|
||
item_bag: u8,
|
||
item_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,
|
||
},
|
||
/// Auto-store an item into a bag (`CMSG_AUTOSTORE_BAG_ITEM`): take `(src_bag, src_slot)` and
|
||
/// put it anywhere inside `dst_bag` — **the client names no destination slot** (wow-re
|
||
/// `bag-verbs-law.md`: AUTOSTORE carries none). Sent by `PutItemInBag`'s third leg (an
|
||
/// ordinary item dropped on an occupied bag button) and by all of `PutItemInBackpack`.
|
||
/// Refusals surface as `InventoryFailure` events.
|
||
AutoStoreBagItem {
|
||
src_bag: u8,
|
||
src_slot: u8,
|
||
dst_bag: 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,
|
||
},
|
||
/// Post the four extra bars' visibility byte (`CMSG_SET_ACTIONBAR_TOGGLES`, `PLAYER_FIELD_BYTES`
|
||
/// byte 2 — wow-re `system/ui/scratch/action-bar-toggles.md`). Sent by the toggle drain, one
|
||
/// per `SetActionBarToggles` call: the binding gates nothing, so two calls in a frame are two
|
||
/// packets.
|
||
///
|
||
/// **Nothing answers it, and the sender is not allowed to answer itself.** Unlike
|
||
/// [`Self::SetActionButton`], whose state is genuinely ours, this byte is *server-owned*: the
|
||
/// real client has no instruction that writes the cell (the one `+0x102a` access image-wide is
|
||
/// a read), so the value only becomes true when the server's `SMSG_UPDATE_OBJECT` echoes it
|
||
/// into the descriptor — and no field-change callback fires when it does. The owner therefore
|
||
/// sees its own change come back the long way round, and the UI's optimism lives in Lua.
|
||
///
|
||
/// A send while disconnected is a **silent no-op**, matching the reference's three unreported
|
||
/// drops (`0x5ab637`, `0x5379ab`, `0x5379b6`) behind a binding that returns zero Lua values.
|
||
SetActionBarToggles {
|
||
toggles: u8,
|
||
},
|
||
/// Press one pet bar slot (`CMSG_PET_ACTION`, decisions 0982/0988). `packed` is the slot's OWN
|
||
/// word as the server last sent it — command, reaction and spell all ride this one command,
|
||
/// because the server dispatches on the type byte inside the word. `target_guid` is the
|
||
/// player's current selection, which is what the client always sends (wow-re §10.1).
|
||
///
|
||
/// **Nothing answers it.** Unlike [`Self::SetActionButton`], whose silence is because the
|
||
/// state is ours, this one is silent because the *server* simply does not reply — so the
|
||
/// caller has already applied the visible change locally before queueing it.
|
||
PetAction {
|
||
pet_guid: u64,
|
||
packed: u32,
|
||
target_guid: u64,
|
||
},
|
||
/// Write pet bar slots (`CMSG_PET_SET_ACTION`, decision 0988) — `(0-based position, the whole
|
||
/// new word)` pairs, one or two.
|
||
///
|
||
/// **This is how the autocast toggle travels**, with one entry: the client flips bit 30 in the
|
||
/// slot's word and posts the result, and the server reads the direction back out of the type
|
||
/// byte it arrives in. `CMSG_PET_SPELL_AUTOCAST` (0x2F3) is a *different* binding's opcode —
|
||
/// the pet spellbook's `ToggleSpellAutocast`, which indexes the spellbook rather than the bar
|
||
/// and which we do not ship. The same send carries the drag when that lands.
|
||
PetSetAction {
|
||
pet_guid: u64,
|
||
entries: Vec<(u32, u32)>,
|
||
},
|
||
/// Call the pet off its target (`CMSG_PET_STOP_ATTACK`) — the Attack button's second press.
|
||
PetStopAttack {
|
||
pet_guid: u64,
|
||
},
|
||
/// Cancel one of the **pet's** auras (`CMSG_PET_CANCEL_AURA`, decision 1007) — the pet bar's
|
||
/// press-again-to-cancel, and the pet-shaped twin of [`Self::CancelAura`].
|
||
///
|
||
/// It is a separate opcode rather than `CancelAura` with a guid because the server needs to
|
||
/// know *whose* aura to drop, and `CMSG_CANCEL_AURA`'s body is a bare spell id. Like its
|
||
/// player twin: no answer packet — the removal arrives as a `UNIT_FIELD_AURA` delta on the
|
||
/// pet, which is also what puts the slot's icon back.
|
||
PetCancelAura {
|
||
pet_guid: u64,
|
||
spell_id: u32,
|
||
},
|
||
/// Flip one pet **spellbook** entry's autocast (`CMSG_PET_SPELL_AUTOCAST` 0x2F3, decision
|
||
/// 1032) — `ToggleSpellAutocast`'s send, and **not** the pet bar's autocast verb.
|
||
///
|
||
/// The bar's right click is [`Self::PetSetAction`]: it rewrites a *slot's word*, so its body
|
||
/// names a position. This one names a **spell id**, because the book has no positions —
|
||
/// `0x4b4240` looks the id up in `0xb6f098` and hands `0x4bccb0` the id, never an index. Both
|
||
/// end up flipping the same bit on the same word; only one of them can say which word by slot.
|
||
/// No reply packet either way.
|
||
PetSpellAutocast {
|
||
pet_guid: u64,
|
||
spell_id: u32,
|
||
enabled: bool,
|
||
},
|
||
/// Give the pet up permanently (`CMSG_PET_ABANDON`, decision 1066) — the right-click menu's
|
||
/// **Abandon**, and only that row.
|
||
///
|
||
/// **Its Dismiss is not this**, however alike they read: `PetDismiss 0x4be4d0` opens no packet
|
||
/// and goes down the pet bar's ordinary [`Self::PetAction`] path with the word `0x07000003`
|
||
/// (wow-re §11c). Both would have worked against vmangos, which is why it is written here.
|
||
///
|
||
/// No reply: the answer is `SMSG_PET_SPELLS` with a zero guid, and the pet object leaving.
|
||
PetAbandon {
|
||
pet_guid: u64,
|
||
},
|
||
/// Rename the pet (`CMSG_PET_RENAME`, decision 1066) — the `PETRENAMECONFIRM` popup's accept.
|
||
///
|
||
/// The server may refuse the name outright, so nothing is applied locally; success arrives as a
|
||
/// bumped `UNIT_FIELD_PET_NAME_TIMESTAMP` on the pet, which is what re-asks the name cache.
|
||
PetRename {
|
||
pet_guid: u64,
|
||
name: String,
|
||
},
|
||
/// 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,
|
||
},
|
||
/// Buy into a **named** container slot (`CMSG_BUY_ITEM_IN_SLOT`, decision 1797) — what a
|
||
/// vendor row dragged off the merchant window and dropped into a bag or an equipment slot
|
||
/// sends, as against [`ClientCommand::BuyItem`]'s auto-place from a plain row click.
|
||
/// `bag_guid` is the container object's guid, or the player's own for the backpack and the
|
||
/// equipment slots. Same refusal shape as `BuyItem` — `SMSG_BUY_FAILED`.
|
||
BuyItemInSlot {
|
||
vendor: u64,
|
||
entry: u32,
|
||
bag_guid: u64,
|
||
bag_slot: u8,
|
||
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,
|
||
},
|
||
/// Accept an innkeeper's bind offer (`CMSG_BINDER_ACTIVATE`, decision 1331): the
|
||
/// `CONFIRM_BINDER` dialog's Accept, carrying the guid `SMSG_BINDER_CONFIRM` asked with. This
|
||
/// is the ONLY packet in the flow that binds anything — selecting the gossip line just raises
|
||
/// the question. Answered by `SMSG_BINDPOINTUPDATE` + `SMSG_PLAYERBOUND` once the innkeeper's
|
||
/// Bind cast lands; declining sends nothing.
|
||
/// File a GM trouble ticket (`CMSG_GMTICKET_CREATE`, decision 1673): the Help window's Submit.
|
||
/// `category` is a `GMTicketCategory.dbc` id (1..10); `map`/`pos` are where the player stands,
|
||
/// which is what a GM's `.ticket go` uses. Answered by `SMSG_GMTICKET_CREATE` — **or by
|
||
/// nothing**, on vmangos's several silent refusals (queue off, under `GMTickets.MinLevel`,
|
||
/// category >= 11). Never retried: the window asks once per click.
|
||
GmTicketCreate {
|
||
category: u8,
|
||
map: u32,
|
||
pos: [f32; 3],
|
||
text: String,
|
||
},
|
||
/// Edit the open ticket's text (`CMSG_GMTICKET_UPDATETEXT`): the window's "Save Changes".
|
||
/// Carries the category byte the real client carries even though vmangos discards it.
|
||
/// **Rate-limited server-side to 2 per world tick, sanction = kick** — one send per click.
|
||
GmTicketUpdate {
|
||
category: u8,
|
||
text: String,
|
||
},
|
||
/// Ask for the open ticket (`CMSG_GMTICKET_GETTICKET`, empty body): `GetGMTicket()`. The
|
||
/// window's own poll — once on world entry, then every 10 minutes while the toast is up.
|
||
GmTicketGet,
|
||
/// Abandon the open ticket (`CMSG_GMTICKET_DELETETICKET`, empty body): the
|
||
/// `HELP_TICKET_ABANDON_CONFIRM` dialog's Yes.
|
||
GmTicketDelete,
|
||
/// Ask whether the petition queue is taking tickets (`CMSG_GMTICKET_SYSTEMSTATUS`, empty
|
||
/// body): `GetGMStatus()`, called from the Help window's OnShow.
|
||
GmTicketSystemStatus,
|
||
BinderActivate {
|
||
binder: u64,
|
||
},
|
||
/// Accept a summon (`CMSG_SUMMON_RESPONSE`, decision 1747): the `CONFIRM_SUMMON` dialog's
|
||
/// Accept, carrying the guid the question arrived with. The **only** packet in the summon
|
||
/// flow — there is no decline opcode, so a refused summon expires on the server's own timer
|
||
/// and this client says nothing.
|
||
SummonResponse {
|
||
summoner: u64,
|
||
},
|
||
/// Unlearn every talent (`MSG_TALENT_WIPE_CONFIRM` outbound, decision 1580): the
|
||
/// `CONFIRM_TALENT_WIPE` dialog's Accept, carrying the guid the trainer's question asked with.
|
||
/// This is the ONLY packet in the flow that resets anything — selecting the gossip line just
|
||
/// raises the question. Answered by the un-learn of every rank spell plus the refreshed
|
||
/// `PLAYER_CHARACTER_POINTS1`; declining sends nothing.
|
||
TalentWipeConfirm {
|
||
trainer: u64,
|
||
},
|
||
// ── The dialog engine's verbs (decision 1963) ──
|
||
/// `ForceLogout()` — `CMSG_PLAYER_LOGOUT` (`0x4A`, empty), the forced flavour of the logout
|
||
/// dispatcher; sent only with a live world session.
|
||
ForceLogout,
|
||
/// `AcceptAreaSpiritHeal()` — `CMSG_AREA_SPIRIT_HEALER_QUEUE` with the cached healer.
|
||
AreaSpiritHealerQueue {
|
||
healer: u64,
|
||
},
|
||
/// `AcceptBattlefieldPort(index, accept)` — `CMSG_BATTLEFIELD_PORT`: the slot's map id and
|
||
/// the answer as one byte.
|
||
BattlefieldPort {
|
||
map_id: u32,
|
||
accept: bool,
|
||
},
|
||
/// `RequestBattlefieldScoreData()` — `MSG_PVP_LOG_DATA`, empty (decision 1972).
|
||
RequestBattlefieldScoreData,
|
||
/// `LeaveBattlefield()` — `CMSG_LEAVE_BATTLEFIELD`: the active slot's map (decision 1972).
|
||
LeaveBattlefield {
|
||
map_id: u32,
|
||
},
|
||
/// `CancelMeetingStoneRequest()` — `CMSG 0x293`, empty.
|
||
MeetingStoneLeave,
|
||
/// The enter-world meeting-stone status query — `CMSG 0x296`, empty (decision 1974).
|
||
MeetingStoneStatusQuery,
|
||
// ── The tutorial system (decision 1976) ──
|
||
/// `FlagTutorial` / an auto-acknowledge site — `CMSG_TUTORIAL_FLAG`, the 0-based id.
|
||
TutorialFlag {
|
||
id: u32,
|
||
},
|
||
/// `ClearTutorials()` — `CMSG_TUTORIAL_CLEAR`, empty.
|
||
TutorialClear,
|
||
/// `ResetTutorials()` — `CMSG_TUTORIAL_RESET`, empty.
|
||
TutorialReset,
|
||
// ── The battleground list window (decision 1974) ──
|
||
/// `ShowBattlefieldList(index)` — `CMSG_BATTLEFIELD_LIST`: the queued slot's map.
|
||
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` outbound (empty;
|
||
/// decision 1980), throttled app-side to the reference's 5000 ms.
|
||
RequestBattlefieldPositions,
|
||
// ── The tabard designer (decision 1977) ──
|
||
/// The NPC-click ladder's TABARDDESIGNER arm — `MSG_TABARDVENDOR_ACTIVATE` out.
|
||
TabardVendorActivate {
|
||
npc: u64,
|
||
},
|
||
/// `TabardModel:Save()` past its pre-flight checks — `MSG_SAVE_GUILD_EMBLEM` out.
|
||
SaveGuildEmblem {
|
||
vendor: u64,
|
||
design: [u32; 5],
|
||
},
|
||
/// The ladder's BATTLEMASTER arm — `CMSG_BATTLEMASTER_HELLO`; the list window answers.
|
||
BattlemasterHello {
|
||
npc: u64,
|
||
},
|
||
/// `JoinBattlefield` when the list arrived without one — `CMSG_BATTLEFIELD_JOIN`.
|
||
BattlefieldJoin {
|
||
map_id: u32,
|
||
instance_id: u32,
|
||
as_group: bool,
|
||
},
|
||
/// The world-enter status request — `CMSG_BATTLEFIELD_STATUS`, empty.
|
||
BattlefieldStatusRequest,
|
||
/// `ConfirmPetUnlearn()` — `CMSG_PET_UNLEARN` with the latched trainer guid.
|
||
PetUnlearn {
|
||
trainer: 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,
|
||
},
|
||
/// Ask (or re-ask) a stable master's pet list (`MSG_LIST_STABLED_PETS`, decision 1676): one
|
||
/// NPC guid. The window first opens off the gossip stable option's own inbound send of the
|
||
/// same opcode; this is the *refresh* verb, re-requested after every successful mutation
|
||
/// because none of them is answered with a fresh list.
|
||
ListStabledPets {
|
||
npc: u64,
|
||
},
|
||
/// Put the current pet away (`CMSG_STABLE_PET`, decision 1676). **No destination slot** — the
|
||
/// server takes the first free bought one, so there is none to carry.
|
||
StablePet {
|
||
npc: u64,
|
||
},
|
||
/// Summon a stabled pet (`CMSG_UNSTABLE_PET`, decision 1676): the NPC guid + the pet's own
|
||
/// number. Valid only with no current pet; with one out the verb is [`Self::StableSwapPet`].
|
||
UnstablePet {
|
||
npc: u64,
|
||
pet_number: u32,
|
||
},
|
||
/// Trade the current pet for a stabled one in one step (`CMSG_STABLE_SWAP_PET`, decision 1676).
|
||
StableSwapPet {
|
||
npc: u64,
|
||
pet_number: u32,
|
||
},
|
||
/// Buy the next stable slot (`CMSG_BUY_STABLE_SLOT`, decision 1676). The *which* is implicit,
|
||
/// as with the bank's bag slots; a refusal answers `SMSG_STABLE_RESULT`'s `ERR_MONEY`.
|
||
BuyStableSlot {
|
||
npc: u64,
|
||
},
|
||
/// 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,
|
||
},
|
||
/// Declare or withdraw war on a faction (`CMSG_SET_FACTION_ATWAR`): the reputation pane's
|
||
/// crossed-swords box. Addressed by reputation-list slot. No ack — the engine already flipped
|
||
/// its own flag copy ([`crate::ui_reputation`]), and vmangos DROPS the request outright while
|
||
/// the player is in combat.
|
||
SetFactionAtWar {
|
||
rep_list_id: u32,
|
||
at_war: bool,
|
||
},
|
||
/// Move a faction into or out of the pane's inactive bucket (`CMSG_SET_FACTION_INACTIVE`).
|
||
/// Same slot addressing and same no-ack rule as [`Self::SetFactionAtWar`].
|
||
SetFactionInactive {
|
||
rep_list_id: u32,
|
||
inactive: bool,
|
||
},
|
||
/// Watch a faction's bar on the main menu bar (`CMSG_SET_WATCHED_FACTION`). **Signed**, and
|
||
/// `-1` — not `0` — is "watch nothing": slot 0 is the Bloodsail Buccaneers. The answer returns
|
||
/// as a `PLAYER_FIELD_WATCHED_FACTION_INDEX` descriptor update.
|
||
SetWatchedFaction {
|
||
rep_list_id: i32,
|
||
},
|
||
/// 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,
|
||
},
|
||
/// Ask for one page of a book (`CMSG_PAGE_TEXT_QUERY`, decision 1105): the `PageText` id + the
|
||
/// asking object's `guid` (an item's or a TEXT GameObject's — the server discards it). Sent
|
||
/// ask-once by [`crate::ui_item_text::PageTexts`] when a reader opens on a page it hasn't got;
|
||
/// vmangos answers with the whole forward chain, one `SMSG_PAGE_TEXT_QUERY_RESPONSE` per page.
|
||
PageTextQuery {
|
||
page_id: 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,
|
||
},
|
||
/// Hand a loot row to a group member (`CMSG_LOOT_MASTER_GIVE`, decision 1675): the master
|
||
/// looter's dropdown pick. `guid` is the open loot source, `slot` the row's **wire** slot, and
|
||
/// `target` the recipient's guid. Success arrives as an ordinary `SMSG_LOOT_REMOVED` (the item
|
||
/// traffic goes to the recipient, not to us); a refusal as a `MASTER_*` loot error.
|
||
LootMasterGive {
|
||
guid: u64,
|
||
slot: u8,
|
||
target: u64,
|
||
},
|
||
/// 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,
|
||
},
|
||
/// Greet a questgiver (`CMSG_QUESTGIVER_HELLO`) — the quest session's END on a non-gossip
|
||
/// NPC, which is what re-opens its quest list after a decline (decision 1738). The gossip
|
||
/// twin of this send is [`Self::GossipHello`].
|
||
QuestgiverHello {
|
||
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 party quest-share (decision 1733; bodies in benilla-protocol `messages/quest/share.rs`). ──
|
||
/// Share the selected quest with the party (`CMSG_PUSHQUESTTOPARTY`) — the quest log's *Share
|
||
/// Quest* button. The server walks the group itself; the answers come back as one
|
||
/// `MSG_QUEST_PUSH_RESULT` per member (usually two: the opener, then the outcome).
|
||
PushQuestToParty {
|
||
quest: u32,
|
||
},
|
||
/// Answer the escort confirm with Yes (`CMSG_QUEST_CONFIRM_ACCEPT`) — a
|
||
/// `QUEST_FLAGS_PARTY_ACCEPT` quest a party member started. There is no No command: the
|
||
/// reference sends nothing at all when the popup is dismissed.
|
||
QuestConfirmAccept {
|
||
quest: u32,
|
||
},
|
||
/// Report our verdict on a quest a party member shared with us (`MSG_QUEST_PUSH_RESULT`) —
|
||
/// today only the decline; every other verdict on the share is the server's own. `sharer` is
|
||
/// the giver guid the shared `SMSG_QUESTGIVER_QUEST_DETAILS` arrived under.
|
||
QuestPushResult {
|
||
sharer: u64,
|
||
msg: benilla_protocol::messages::QuestShareMsg,
|
||
},
|
||
// ── 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,
|
||
/// The selected `Stationery.dbc` id (`CMSG_SEND_MAIL`'s sixth field, 1970).
|
||
stationery: u32,
|
||
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,
|
||
// ── The auction house arc (decision 1511; writer bodies in benilla-protocol
|
||
// `world/writer/auction.rs`). Every verb carries the auctioneer guid — the server
|
||
// re-checks the 5 yd interact distance on each one, so there is no session token, and
|
||
// several refusals (a zero bid/duration, an unaffordable bid, a cancel whose cut can't be
|
||
// paid, a list request while one is in flight) come back as NO packet at all. ──────────
|
||
//
|
||
// Each variant carries `#[allow(dead_code)]`: decision 1511 P0 is the WIRE, and the
|
||
// auction window that will construct these is a later phase. The allow is per-variant
|
||
// rather than on the enum so a genuinely dead command elsewhere still surfaces, and each
|
||
// one comes off the moment its caller lands.
|
||
/// Greet an auctioneer (`MSG_AUCTION_HELLO`, one guid) — the two-way opcode whose *reply*
|
||
/// (an `AuctionHello` event, carrying the `AuctionHouse.dbc` house id) opens the window.
|
||
#[allow(dead_code)]
|
||
AuctionHello {
|
||
auctioneer: u64,
|
||
},
|
||
/// Ask a Browse page (`CMSG_AUCTION_LIST_ITEMS`): the search filters exactly as the server
|
||
/// reads them, with `benilla_protocol::messages::auction_filter`'s sentinels for the unset
|
||
/// ones. **No sort rides the wire** — sorting the page is ours. `list_from` pages by 50.
|
||
/// Answered by `SMSG_AUCTION_LIST_RESULT` (an `AuctionListResult` event).
|
||
#[allow(dead_code)]
|
||
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,
|
||
},
|
||
/// Ask the Auctions tab page (`CMSG_AUCTION_LIST_OWNER_ITEMS`) — our own listings.
|
||
#[allow(dead_code)]
|
||
AuctionListOwnerItems {
|
||
auctioneer: u64,
|
||
list_from: u32,
|
||
},
|
||
/// Ask the Bid tab page (`CMSG_AUCTION_LIST_BIDDER_ITEMS`). `auction_ids` is a **refresh
|
||
/// set**, not a filter: those rows are emitted first, then every auction we currently hold the
|
||
/// bid on. Empty for a plain page.
|
||
#[allow(dead_code)]
|
||
AuctionListBidderItems {
|
||
auctioneer: u64,
|
||
list_from: u32,
|
||
auction_ids: Vec<u32>,
|
||
},
|
||
/// List an item for auction (`CMSG_AUCTION_SELL_ITEM`) — the Create Auction pane.
|
||
/// `etime_minutes` must be 120, 480 or 1440; the deposit leaves the purse immediately.
|
||
#[allow(dead_code)]
|
||
AuctionSellItem {
|
||
auctioneer: u64,
|
||
item_guid: u64,
|
||
bid: u32,
|
||
buyout: u32,
|
||
etime_minutes: u32,
|
||
},
|
||
/// Bid on — or buy out — an auction (`CMSG_AUCTION_PLACE_BID`). One verb for both: a `price`
|
||
/// at or above a nonzero buyout *is* the buyout, inferred server-side.
|
||
#[allow(dead_code)]
|
||
AuctionPlaceBid {
|
||
auctioneer: u64,
|
||
auction_id: u32,
|
||
price: u32,
|
||
},
|
||
/// Cancel one of our own auctions (`CMSG_AUCTION_REMOVE_ITEM`). The deposit is forfeit, and a
|
||
/// cancel on an auction that already has a bid costs the 5% cut out of pocket.
|
||
#[allow(dead_code)]
|
||
AuctionRemoveItem {
|
||
auctioneer: u64,
|
||
auction_id: u32,
|
||
},
|
||
/// Ask for the server's wall clock (`CMSG_QUERY_TIME`, empty body) — sent on every world
|
||
/// entry, answered by `SMSG_QUERY_TIME_RESPONSE` into [`ServerWallClock`]. That clock is the
|
||
/// only way to read the absolute deadlines the server writes into descriptor fields, which is
|
||
/// how the timed-quest countdown gets its number (decision 1150).
|
||
QueryTime,
|
||
/// 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,
|
||
},
|
||
/// Ask for a player's honor stats (`MSG_INSPECT_HONOR_STATS`, `u64 target`) — the inspect
|
||
/// window's Honor tab (decision 1512). Unlike [`Self::Inspect`] this one is a **real** round
|
||
/// trip: the reply rides the same opcode and is the only source of another player's honor
|
||
/// numbers, since every field of the honor block is PRIVATE. A refusal is silence — the server
|
||
/// applies the same three gates as `CMSG_INSPECT` and simply does not answer.
|
||
InspectHonorStats {
|
||
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,
|
||
},
|
||
/// **Accept** an incoming trade request (`CMSG_BEGIN_TRADE`, empty) — makes the server emit
|
||
/// `OPEN_WINDOW` to both sides. Sent by [`crate::ui_trade::answer_trade_request`], never by the
|
||
/// net drain: *whether* to accept is a policy with five inputs and (decision 1764) the player's
|
||
/// own answer, so the arm that decodes `BEGIN_TRADE` only records the request.
|
||
BeginTrade,
|
||
/// Decline an incoming trade request as **ignored** (`CMSG_IGNORE_TRADE`, `0x119`, empty) —
|
||
/// leg 2 of the reference's ladder (`0x4bf759` → `0x5d41c0`, its one call site image-wide),
|
||
/// and the only leg that answers on a different opcode. vmangos turns it into
|
||
/// `TRADE_STATUS_IGNORE_YOU`, so the initiator reads "… is ignoring you" rather than "busy".
|
||
IgnoreTrade,
|
||
/// **Decline** an incoming trade request as busy (`CMSG_BUSY_TRADE`, `0x118`, empty) — the
|
||
/// reference's own refusal sender (`0x5d4150`), which vmangos turns into `TRADE_STATUS_BUSY`
|
||
/// to *both* sides. Five of the ladder's eight refusal legs go out on this opcode (decision
|
||
/// 1764); the ignore leg uses [`Self::IgnoreTrade`], two legs send nothing at all, and the
|
||
/// **player's** own No is [`Self::CancelTrade`], not this.
|
||
BusyTrade,
|
||
/// 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,
|
||
},
|
||
/// `DisplayChannelOwner` — `CMSG_CHANNEL_OWNER`.
|
||
ChannelOwner {
|
||
name: String,
|
||
},
|
||
/// `SetChannelOwner` — `CMSG_CHANNEL_SET_OWNER`.
|
||
ChannelSetOwner {
|
||
name: String,
|
||
player: String,
|
||
},
|
||
/// `SetChannelPassword` — `CMSG_CHANNEL_PASSWORD` (an empty password clears it).
|
||
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,
|
||
/// Acknowledge a triggered cinematic as finished (`CMSG_COMPLETE_CINEMATIC`) — sent by
|
||
/// [`crate::cinematic`] when playback ends or the player ESCs out of it, and immediately for a
|
||
/// trigger it cannot resolve to a shot. 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 (decision 0196).
|
||
CompleteCinematic,
|
||
/// Announce the shot now being armed (`CMSG_NEXT_CINEMATIC_CAMERA`) — see
|
||
/// [`benilla_protocol::WorldWriter::next_cinematic_camera`]. Sent for **every** shot, the
|
||
/// first included: the reference's send is inside the shot arm (`0x48ef11`), not the shot
|
||
/// advance, so a shipped single-camera race intro sends exactly one. See
|
||
/// [`crate::cinematic`]'s module doc.
|
||
NextCinematicCamera,
|
||
/// **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,
|
||
},
|
||
/// **Acknowledge the knockback we just flew** (`CMSG_MOVE_KNOCK_BACK_ACK`, decision 1702): the
|
||
/// echoed `counter`, our live pose/flags, and `launch` — the server's own quad, echoed back as
|
||
/// the `MovementInfo` jump tail.
|
||
///
|
||
/// Sent by the controller on the frame the mover actually takes off, never on receipt, and never
|
||
/// if the launch was refused. That order is the reference's: the knockback is queued as a
|
||
/// due-timed record, and the frame update drains it **apply-then-send inside one call**
|
||
/// (`0x61624d call 0x6179c0` → `0x616261 push 0xf0`), so the `MovementInfo` on the wire is
|
||
/// always the post-launch one.
|
||
KnockBackAck {
|
||
guid: u64,
|
||
counter: u32,
|
||
launch: JumpInfo,
|
||
flags: u32,
|
||
pos: [f32; 3],
|
||
orientation: f32,
|
||
transport: Option<TransportPose>,
|
||
},
|
||
/// 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,
|
||
},
|
||
/// Self-resurrect (`CMSG_SELF_RES`, empty body — the DEATH popup's soulstone/Reincarnation
|
||
/// button, decision 1746). The server casts whatever `PLAYER_SELF_RES_SPELL` holds; success
|
||
/// returns as ordinary descriptor deltas, no answer packet.
|
||
SelfRes,
|
||
/// 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,
|
||
/// Convert the party into a raid (`CMSG_GROUP_RAID_CONVERT`, leader only; echoes back as a
|
||
/// `SMSG_GROUP_LIST` with `group_type` raid).
|
||
GroupRaidConvert,
|
||
/// Ask the server for one party/raid member's whole stat block
|
||
/// (`CMSG_REQUEST_PARTY_MEMBER_STATS`; answered by `SMSG_PARTY_MEMBER_STATS_FULL`).
|
||
///
|
||
/// **Sent from exactly two edges, both the reference's** (decision 1640, wow-re
|
||
/// `ui/scratch/party-oor-stats-and-portrait-law.md` §2): the moment a member's object leaves
|
||
/// the object manager (`net::apply::group::member_deactivated`), and the GROUP_LIST seat of a
|
||
/// member new to the roster whose object we do not hold (`seat_new_records`). There is no
|
||
/// timer and no Lua binding — a periodic poll would be ours, not the client's.
|
||
RequestPartyMemberStats {
|
||
guid: u64,
|
||
},
|
||
/// 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,
|
||
},
|
||
/// `MSG_MINIMAP_PING` (a minimap click, decision 1596): raw world `(x, y)`. The server relays
|
||
/// them verbatim to the rest of the group and does nothing at all when we are solo — which is
|
||
/// why the marker is drawn locally at click time rather than awaited off the wire.
|
||
MinimapPing {
|
||
x: f32,
|
||
y: f32,
|
||
},
|
||
// ── The raid-management family (decision 1549's Raid tab) ─────────────────────────────────
|
||
/// Move a raid member to another subgroup (`CMSG_GROUP_CHANGE_SUB_GROUP`): the member's NAME
|
||
/// and the **0-based** subgroup the wire wants. Leader/assistant only; echoes as a fresh
|
||
/// `SMSG_GROUP_LIST`.
|
||
GroupChangeSubGroup {
|
||
name: String,
|
||
group: u8,
|
||
},
|
||
/// Trade two raid members' subgroups (`CMSG_GROUP_SWAP_SUB_GROUP`, two names).
|
||
GroupSwapSubGroup {
|
||
name: String,
|
||
other: String,
|
||
},
|
||
/// Grant or revoke the raid-assistant flag (`CMSG_GROUP_ASSISTANT_LEADER`, leader only).
|
||
GroupAssistantLeader {
|
||
guid: u64,
|
||
grant: bool,
|
||
},
|
||
/// Start a ready check (`MSG_RAID_READY_CHECK`, empty body — leader only).
|
||
ReadyCheckStart,
|
||
/// Answer a ready check (`MSG_RAID_READY_CHECK`, one byte).
|
||
ReadyCheckAnswer {
|
||
ready: bool,
|
||
},
|
||
/// Ask for our saved raid lockouts (`CMSG_REQUEST_RAID_INFO`) — the Raid Info panel.
|
||
RequestRaidInfo,
|
||
/// Reset every dungeon we own (`CMSG_RESET_INSTANCES`, empty body) — the SELF menu's "Reset
|
||
/// all instances" (decision 1748). Answered per instance: an `SMSG_INSTANCE_RESET` for each
|
||
/// map that reset, an `SMSG_INSTANCE_RESET_FAILED` for each that would not.
|
||
ResetInstances,
|
||
// ── 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,
|
||
},
|
||
/// The client's LFG slots and comment (`CMSG_SET_LOOKING_FOR_GROUP`), sent by
|
||
/// `SetLookingForGroup` only when the commit changed something (1961).
|
||
SetLookingForGroup {
|
||
slots: [u32; 3],
|
||
comment: 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,
|
||
/// Ask to flip our own show-helm preference (`CMSG_TOGGLE_HELM`, empty body — decision 1472):
|
||
/// the Options window's *Show Helm* row, through the VM's intent queue. Nothing local changes;
|
||
/// the answer is `PLAYER_FLAGS`' `HIDE_HELM` bit, which is what dresses the body — ours and
|
||
/// every other player's, since the field is public.
|
||
ToggleHelm,
|
||
/// The cloak half of [`Self::ToggleHelm`] (`CMSG_TOGGLE_CLOAK`, empty body).
|
||
ToggleCloak,
|
||
// ── The guild family (writer bodies in benilla-protocol `world/writer/guild.rs`) ──────────
|
||
//
|
||
// Three things shape every caller of this band. **Members are addressed by NAME**, not by
|
||
// guid as the friend list is. **Rank ids are 0-based with 0 = guild master**, and authority
|
||
// falls as the id rises (promote = `rank - 1`). And **almost nothing is acked
|
||
// individually**: the mutating verbs answer with a whole fresh `SMSG_GUILD_ROSTER` (plus an
|
||
// `SMSG_GUILD_QUERY_RESPONSE` for the rank verbs), so the model updates when that snapshot
|
||
// lands, never optimistically at the send; a refusal arrives separately as
|
||
// `SMSG_GUILD_COMMAND_RESULT`.
|
||
//
|
||
// Every one of these is sent by `crate::ui_guild` (decision 1257) except `GuildCreate`,
|
||
// which keeps its `#[allow(dead_code)]`: *founding* a guild is the charter/petition flow,
|
||
// deliberately out of the membership slice, and vmangos registers the opcode `STATUS_NEVER`
|
||
// so nothing would answer it anyway.
|
||
/// Ask a guild's public identity by id (`CMSG_GUILD_QUERY`) — the ask-once cache fill behind
|
||
/// every "which guild is that?" (a roster row, a `/who` hit, a guild chat line).
|
||
GuildQuery {
|
||
guild_id: u32,
|
||
},
|
||
/// Found a guild by name (`CMSG_GUILD_CREATE`). vmangos registers the opcode `STATUS_NEVER` —
|
||
/// at 1.12 founding runs through the charter flow — so this draws no reply.
|
||
#[allow(dead_code)]
|
||
GuildCreate {
|
||
name: String,
|
||
},
|
||
/// Invite a character into our guild (`CMSG_GUILD_INVITE`).
|
||
GuildInvite {
|
||
name: String,
|
||
},
|
||
/// Accept the guild invitation we are holding (`CMSG_GUILD_ACCEPT`, empty body) — which one is
|
||
/// the server's pending state, not a field.
|
||
GuildAccept,
|
||
/// Turn it down (`CMSG_GUILD_DECLINE`, empty body); the inviter hears about it, we don't.
|
||
GuildDecline,
|
||
/// Ask our guild's founding date + member/account counts (`CMSG_GUILD_INFO`, empty body).
|
||
GuildInfoRequest,
|
||
/// Refresh the guild roster (`CMSG_GUILD_ROSTER`, empty body) — the guild pane's opener. The
|
||
/// server also pushes the roster unasked after every change, so this is never the only path.
|
||
GuildRosterRequest,
|
||
/// Promote a member one rank (`CMSG_GUILD_PROMOTE`) — towards guild master.
|
||
GuildPromote {
|
||
name: String,
|
||
},
|
||
/// Demote a member one rank (`CMSG_GUILD_DEMOTE`).
|
||
GuildDemote {
|
||
name: String,
|
||
},
|
||
/// Leave our guild (`CMSG_GUILD_LEAVE`, empty body). Refused while we are the guild master and
|
||
/// anyone else remains.
|
||
GuildLeave,
|
||
/// Kick a member by name (`CMSG_GUILD_REMOVE`).
|
||
GuildRemove {
|
||
name: String,
|
||
},
|
||
/// Disband the guild (`CMSG_GUILD_DISBAND`, empty body) — guild master only, irreversible.
|
||
GuildDisband,
|
||
/// Hand the guild to another member (`CMSG_GUILD_LEADER`).
|
||
GuildLeader {
|
||
name: String,
|
||
},
|
||
/// Set the message of the day (`CMSG_GUILD_MOTD`); `""` clears it.
|
||
GuildMotd {
|
||
motd: String,
|
||
},
|
||
/// Rewrite one rank's name **and** rights together (`CMSG_GUILD_RANK`) — there is no partial
|
||
/// form, so a caller changing one must resend the other's current value. Rank 0's rights are
|
||
/// ignored and forced to "all"; an over-long name gets the session kicked, so cap it at
|
||
/// `messages::GUILD_RANK_MAX_LENGTH`.
|
||
GuildRank {
|
||
rank_id: u32,
|
||
rights: u32,
|
||
name: String,
|
||
},
|
||
/// Append a rank at the bottom of the ladder (`CMSG_GUILD_ADD_RANK`).
|
||
GuildAddRank {
|
||
name: String,
|
||
},
|
||
/// Delete the **lowest** rank (`CMSG_GUILD_DEL_RANK`, empty body) — there is no rank id on the
|
||
/// wire, it is always the last one.
|
||
GuildDelRank,
|
||
/// Set a member's public note (`CMSG_GUILD_SET_PUBLIC_NOTE`).
|
||
GuildSetPublicNote {
|
||
name: String,
|
||
note: String,
|
||
},
|
||
/// Set a member's officer note (`CMSG_GUILD_SET_OFFICER_NOTE`). Editing and *viewing* officer
|
||
/// notes are separate rights — an all-empty officer column may mean we cannot see them.
|
||
GuildSetOfficerNote {
|
||
name: String,
|
||
note: String,
|
||
},
|
||
/// Set the guild information text (`CMSG_GUILD_INFO_TEXT`) — the long free-text pane, which
|
||
/// rides back as the roster's `info` field.
|
||
GuildInfoText {
|
||
text: String,
|
||
},
|
||
// ── The petition family (writer bodies in benilla-protocol `world/writer/petition.rs`,
|
||
// decision 1672) — FOUNDING a guild, which at 1.12 shares nothing with the band above but
|
||
// its error channel: a refusal on any of these comes back as `SMSG_GUILD_COMMAND_RESULT`.
|
||
//
|
||
// **A charter is addressed by its ITEM guid**, not by a petition id, everywhere except the
|
||
// record query. And almost none of these is acked positively: buying answers with the new
|
||
// item alone, offering answers the TARGET, renaming echoes only if it took. Nothing here may
|
||
// be treated as applied at the send.
|
||
/// Ask a petitioner NPC for its charter list (`CMSG_PETITION_SHOWLIST`). The gossip row pushes
|
||
/// the same answer unasked, so this is the direct re-open, not the only way in.
|
||
#[allow(dead_code)]
|
||
PetitionShowList {
|
||
npc: u64,
|
||
},
|
||
/// Buy a guild charter under a name (`CMSG_PETITION_BUY`). Answered by nothing at all on
|
||
/// success — only the new item arrives.
|
||
PetitionBuy {
|
||
npc: u64,
|
||
name: String,
|
||
},
|
||
/// Open a charter and list its signatures (`CMSG_PETITION_SHOW_SIGNATURES`). Sent by the
|
||
/// item-use fork when a charter is clicked, and again to refresh after a signature lands.
|
||
PetitionShowSignatures {
|
||
item: u64,
|
||
},
|
||
/// Sign somebody's charter (`CMSG_PETITION_SIGN`). `byte` is the optional Lua argument's wire
|
||
/// byte, whose client-side default is **1**; the server reads and discards it.
|
||
PetitionSign {
|
||
item: u64,
|
||
byte: i8,
|
||
},
|
||
/// Show our charter to a player so they can sign it (`CMSG_OFFER_PETITION`) — the success path
|
||
/// answers *them*, not us.
|
||
OfferPetition {
|
||
item: u64,
|
||
player: u64,
|
||
},
|
||
/// Turn a completed charter in (`CMSG_TURN_IN_PETITION`). Owner only; a name collision answers
|
||
/// with a guild command result and **no** turn-in result at all.
|
||
TurnInPetition {
|
||
item: u64,
|
||
},
|
||
/// Ask for a petition's record by id (`CMSG_PETITION_QUERY`) — the only packet carrying the
|
||
/// proposed guild's name and its signature requirement.
|
||
PetitionQuery {
|
||
petition_id: u32,
|
||
item: u64,
|
||
},
|
||
/// Rename a charter (`MSG_PETITION_RENAME`); echoed back only on success.
|
||
PetitionRename {
|
||
item: u64,
|
||
name: String,
|
||
},
|
||
/// Decline a charter offered to us (`MSG_PETITION_DECLINE`) — we send the item guid, the owner
|
||
/// receives ours.
|
||
#[allow(dead_code)]
|
||
PetitionDecline {
|
||
item: u64,
|
||
},
|
||
// ── 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 realms this account may enter (`CMD_REALM_LIST`, bridged from the Net drain): the IO thread
|
||
/// is parked between the logon and the world dial, waiting for the app to name one. Consumed by
|
||
/// [`crate::realm_select`]'s policy — auto-answer (the remembered realm / `WOW_REALM`) or show the
|
||
/// realm list and wait for the director.
|
||
#[derive(Message)]
|
||
pub(crate) struct RealmListMessage {
|
||
pub(crate) realms: Vec<benilla_protocol::RealmInfo>,
|
||
}
|
||
|
||
/// 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 {
|
||
/// The account's accumulated **rested billing minutes**, from the `SMSG_AUTH_RESPONSE` that
|
||
/// admitted the session — pushed into the script here because this is the only moment it ever
|
||
/// arrives (decision 1820).
|
||
pub(crate) billing_time_rested: u32,
|
||
/// The tutorial bank, if `SMSG_TUTORIAL_FLAGS` landed during the login handshake (1976);
|
||
/// otherwise it arrives in the world stream.
|
||
pub(crate) tutorial_flags: Option<Vec<u8>>,
|
||
}
|
||
|
||
/// The server asked us to play a cinematic (`SMSG_TRIGGER_CINEMATIC`) — a `CinematicSequences.dbc`
|
||
/// id. Read by [`crate::cinematic`], which owns the playback *and* the ack (decision 0196: the ack
|
||
/// is not optional, only late).
|
||
#[derive(Message)]
|
||
pub(crate) struct CinematicTriggeredMessage {
|
||
pub(crate) cinematic_id: u32,
|
||
}
|
||
|
||
/// 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,
|
||
}
|
||
|
||
/// We are **queued** for a full realm (decision 1681) — one per `AUTH_WAIT_QUEUE` packet, while
|
||
/// the world handshake is still parked. Not a failure: the attempt is live and the queue ends
|
||
/// either with a roster or with a real failure.
|
||
#[derive(Message, Clone)]
|
||
pub(crate) struct LoginQueuedMessage {
|
||
pub(crate) position: Option<u32>,
|
||
pub(crate) realm: Option<String>,
|
||
}
|
||
|
||
/// 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"; whether anything re-authenticates is `Self::ends_the_session`'s answer.
|
||
#[derive(Message, Clone)]
|
||
pub(crate) struct DisconnectedMessage {
|
||
pub(crate) reason: String,
|
||
/// How it ended — the fact the whole post-session behaviour turns on (decision 1262).
|
||
pub(crate) end: benilla_protocol::SessionEnd,
|
||
/// **Is this the reference's `DISCONNECTED_FROM_SERVER`** — the session over, the client back
|
||
/// at the account screen behind a "Disconnected from server" dialog, and nothing retried?
|
||
///
|
||
/// Decided **once**, by [`Self::new`], at the one edge where the event enters the app
|
||
/// (decision 1262). Four readers act on it — the world teardown, the screen flip, the black
|
||
/// cover, the credential policy — and they may not disagree: a teardown that keeps the avatar
|
||
/// for a reconnect that never comes is exactly the avatar-less free camera the report
|
||
/// described. Carrying the verdict rather than re-deriving it four times is what makes that
|
||
/// structural instead of conventional.
|
||
pub(crate) session_over: bool,
|
||
}
|
||
|
||
impl DisconnectedMessage {
|
||
/// The session-end event as the app sees it, with [`Self::session_over`] settled here and
|
||
/// nowhere else.
|
||
///
|
||
/// It is `false` twice over. A [`SessionEnd::LoggedOut`](benilla_protocol::SessionEnd::LoggedOut)
|
||
/// teardown is the relist *inside* one session — the roster that follows IS the character
|
||
/// select the player asked for. And a run that has **declared itself unattended**
|
||
/// ([`crate::run_mode::unattended`]) keeps 0065's seamless reconnect: a probe has nobody to
|
||
/// press Okay.
|
||
///
|
||
/// That second clause used to read the *environment* instead
|
||
/// ([`crate::run_mode::env_login`] — "any of `$WOW_USER`/`$WOW_PASS`/`$WOW_CHAR`"), which is
|
||
/// true of the director's own launch line, so 1262's fix was inert in every session a person
|
||
/// was actually in: the kick came, and the client reconnected and took the account back off
|
||
/// whoever had displaced it. Decision 1769 — the doubt now falls the other way, and only a run
|
||
/// that says it is driverless gets to act instead of a person.
|
||
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 (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 {
|
||
/// Which server refused us and with which byte — see [`benilla_protocol::LoginRefusal`] for
|
||
/// why this is not a bare `Option<u8>`.
|
||
pub(crate) refusal: Option<benilla_protocol::LoginRefusal>,
|
||
pub(crate) reason: String,
|
||
pub(crate) terminal: bool,
|
||
/// The dial that never opened a socket, when that is what failed — see
|
||
/// [`benilla_protocol::DialFailure`]. What lets the screen say *which* of "that name does not
|
||
/// exist" and "nothing is answering there" happened, instead of one "Unable to connect" that
|
||
/// sends a player off editing a correct address.
|
||
pub(crate) dial: Option<benilla_protocol::DialFailure>,
|
||
}
|
||
|
||
/// 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 `ON_TRANSPORT` tail — **the server's own deck-local pose for us**, when it sent one.
|
||
///
|
||
/// This used to be dropped here, and the consumer re-derived the local pose as
|
||
/// `world − boat.translation` against whatever the client's transport tick had last written.
|
||
/// That is only equal to the server's answer while the two agree about where the boat *is* —
|
||
/// and the one moment they provably do not is a cross-map seam, where our path clock has not
|
||
/// crossed yet and [`crate::transport::tick_transports`] is holding the transform frozen at the
|
||
/// far continent's pose. Re-deriving there yields a local offset the size of the gap between
|
||
/// two continents, which the next honest boat pose then multiplies into a fling off the deck.
|
||
/// The authoritative answer was on the wire the whole time (decision 2026).
|
||
pub(crate) transport: Option<benilla_protocol::TransportPose>,
|
||
}
|
||
|
||
/// The server granted or revoked control of a unit (`SMSG_CLIENT_CONTROL_UPDATE`). Written by
|
||
/// [`apply_net_updates`] verbatim — the *decision* is the controller's, because only it knows the
|
||
/// live pose it would have to park, so the drain deliberately interprets nothing.
|
||
///
|
||
/// **`mover` is the unit spoken about, not a new subject.** The server revokes by naming *us*
|
||
/// (`mover == self guid`, `allow_move = false`), which is what a mind-controlled player receives
|
||
/// about themselves, and grants by naming somebody else. Reading it as "the new mover" gets the
|
||
/// victim's case exactly backwards — it would hand us control of ourselves at the moment we lost it.
|
||
#[derive(Message, Clone, Copy)]
|
||
pub(crate) struct ClientControlMessage {
|
||
pub(crate) mover: u64,
|
||
pub(crate) allow_move: bool,
|
||
}
|
||
|
||
/// 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,
|
||
}
|
||
|
||
/// **The server aimed a knockback at our mover** (`SMSG_MOVE_KNOCK_BACK`, decision 1702). Written by
|
||
/// [`apply_net_updates`] for our own guid only, read by the player controller — the one place that
|
||
/// owns both the mover the launch acts on and the honest post-launch pose the ack must carry.
|
||
#[derive(Message, Clone, Copy)]
|
||
pub(crate) struct KnockBackMessage {
|
||
/// Our mover's guid (the bridge only emits ours) — echoed in the ack as a **full** u64.
|
||
pub(crate) guid: u64,
|
||
pub(crate) counter: u32,
|
||
/// The launch quad, in the jump tail's own convention (`zspeed` down-positive).
|
||
pub(crate) launch: JumpInfo,
|
||
}
|
||
|
||
/// 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 pet spoke (`SMSG_PET_ACTION_SOUND`, bridged from the Net drain; decision 2039): `talk` is
|
||
/// the wire's own selector (`PET_TALK_ORDER` / `PET_TALK_ATTACK`), not a `SoundEntries` id — the
|
||
/// kit comes off the pet's own `CreatureSoundData` row. Pure audio in the client (`0x6040c0`
|
||
/// resolves the guid and calls the bark dispatcher, nothing else); consumer: `sound::creature`.
|
||
#[derive(Message, Clone, Copy)]
|
||
pub(crate) struct PetTalkMessage {
|
||
pub(crate) unit: Entity,
|
||
pub(crate) talk: u32,
|
||
}
|
||
|
||
/// A dismissed pet's parting sound (`SMSG_PET_DISMISS_SOUND`, decision 2039): a
|
||
/// `CreatureModelData` id and the point, already in Bevy space. **No entity** — the packet names
|
||
/// no guid and the pet is gone by the time it arrives, which is the whole reason it carries a
|
||
/// position at all. Consumer: `sound::creature`.
|
||
#[derive(Message, Clone, Copy)]
|
||
pub(crate) struct PetDismissSoundMessage {
|
||
pub(crate) model_id: u32,
|
||
pub(crate) pos: Vec3,
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// **The kick sends the player back to the login screen** — decision 1262's whole promise,
|
||
/// pinned at the one place that decides it, in the environment the director actually plays in.
|
||
///
|
||
/// 1262 was built and verified in August and then quietly stopped working, with none of its
|
||
/// code touched: the verdict asked the *environment* whether anybody was here, and on
|
||
/// 2026-08-29 the director's launch line became `WOW_USER=one WOW_PASS=pone cargo play` — the
|
||
/// example line in our own `.cargo/config.toml`. From that moment every session they played
|
||
/// was "a probe", so a kick meant reconnect, and the account ping-ponged exactly as B252
|
||
/// reported. Nothing about the fix was wrong; the fact under it was. 1769 made the claim a
|
||
/// declaration, and this test is the tripwire: a run may only act instead of a person if it
|
||
/// said so.
|
||
#[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
|
||
};
|
||
|
||
// The director's launch line, verbatim. Credentials in the environment, nobody declared
|
||
// absent — this is a person playing, and a lost session is over.
|
||
let _user = EnvGuard::set("WOW_USER", "one");
|
||
let _pass = EnvGuard::set("WOW_PASS", "pone");
|
||
let _char = EnvGuard::set("WOW_CHAR", "One");
|
||
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`");
|
||
|
||
// A clean `/logout` is never "over" — the roster that follows IS the screen they asked for.
|
||
assert!(
|
||
!DisconnectedMessage::new("bye".into(), benilla_protocol::SessionEnd::LoggedOut)
|
||
.session_over
|
||
);
|
||
|
||
// 0065's seamless reconnect survives exactly where the run says nobody is there to press
|
||
// Okay — a declaration, or the two runs that cannot be a person.
|
||
{
|
||
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 vanilla player set, and the one every number below is read against:
|
||
/// walk 2.5 · run 7.0 · runBack 4.5 · swim 4.722 · swimBack 2.5 (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 cascade's ORDER is the whole feature, and one rung of it is counter-intuitive.**
|
||
/// `0x7c4c90` takes the walk arm at `0x7c4d11` *before* the run arm's backward min at
|
||
/// `0x7c4d1d`, so a walking backpedal is walk speed — not run-back. Our remote extrapolator
|
||
/// tested the backpedal first and got 4.5 here for five hundred commits (decision 1752); this
|
||
/// is the assertion that pins the fix, in the one place both callers now go through.
|
||
#[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"
|
||
);
|
||
// …and a strafing walker likewise — the walk arm is ahead of everything on land.
|
||
assert_eq!(current_speed(&s, f::STRAFE_LEFT | f::WALK_MODE), 2.5);
|
||
}
|
||
|
||
/// SWIMMING pre-empts the walk bit entirely (`0x7c4cd9` precedes `0x7c4d11`): there is no
|
||
/// "swim-walk" in this client, so a walker who wades in strokes at full swim speed.
|
||
#[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"
|
||
);
|
||
}
|
||
|
||
/// Both backward arms are a **min**, not a select (x87 `fcompp` + `test ah,0x41` + `jp`), and
|
||
/// so is the walk arm. Only visible under a server that force-sets a slower field above its
|
||
/// faster one — which is exactly why it is a test and not a comment.
|
||
#[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);
|
||
}
|
||
|
||
/// The guard at the TOP of the cascade: no direction bit → 0.0 (`0x7c4c99 test dl,0xf`), never
|
||
/// a stale speed. A mover standing still is standing however its fields read, and the
|
||
/// turn/pitch/mode bits are not translation — walk mode least of all, being precisely a mode
|
||
/// and not a motion.
|
||
///
|
||
/// The bottom of the cascade has **no** guard, and decision 1759 is why: 1752 shipped a
|
||
/// zero-`walk` fallback to the run arm, on the reading that a mover could reach here before
|
||
/// its speed block landed. The wow-re §5 retracted the basis for it — the local player's
|
||
/// create applies the speed block like anyone else's — so the fallback is gone. The zero is
|
||
/// asserted rather than merely no longer asserted against, so that re-adding the guard is a
|
||
/// deliberate act with a record to argue against, not a plausible-looking tidy-up.
|
||
#[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 (decision 1759)"
|
||
);
|
||
assert_eq!(
|
||
current_speed(&unfilled, f::FORWARD),
|
||
7.0,
|
||
"…and the run arm is untouched by it"
|
||
);
|
||
}
|
||
|
||
/// **The addon lane's four wire bytes** (decision 1235) — the client's own whitelist at
|
||
/// `0x49fa3f`-`0x49fa4e`, and the last link in the chain between an addon's Lua call and the
|
||
/// bytes `benilla-protocol`'s `addon_message_bodies_golden` pins. Wrong here and the payload
|
||
/// goes down a lane nobody listens on, which is silent at both ends.
|
||
///
|
||
/// `RAID_LEADER`/`RAID_WARNING`/`OFFICER`/`BATTLEGROUND_LEADER`/`CHANNEL` are asserted absent
|
||
/// from the map's *image*: vmangos would accept `LANG_ADDON` on all five, and the client sends
|
||
/// on none of them, so a future widening has to fail a test rather than pass unnoticed.
|
||
#[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"
|
||
);
|
||
|
||
// The image is exactly those four — 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"
|
||
);
|
||
}
|
||
}
|
||
}
|