comments: protocol, the crate root, guids and the action-bar to channel messages

1,031 comment lines become 320; the code is unchanged. A guid test
called transport 176495 the Menethil-Theramore ferry. The world
database names it the Grom'Gol-Undercity zeppelin.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-23 20:03:55 +07:00
co-authored by Claude Opus 5.5
parent c08a733d0e
commit 0668465bf3
12 changed files with 297 additions and 1008 deletions
+22 -83
View File
@@ -1,73 +1,53 @@
//! GUID bit-layout decode for 1.12.1 — the wire's object identity, `[high:16][entry:24][counter:24]`
//! for entry-carrying types and `[high:16][counter:48-usable-as-32]` for the rest (VERIFIED vmangos
//! `ObjectGuid.h`: `GetHigh` = bits 48–63, `GetEntry` = bits 24–47 masked to 24 bits, gated on the
//! high value carrying an entry at all).
//!
//! The one load-bearing consumer today: `CMSG_CREATURE_QUERY` wants the creature's **template entry**,
//! which the client recovers from the guid exactly this way — the u64→entry extraction is the same
//! stable bijection decision 0068's mapping layer relies on for modern string GUIDs later.
//! The 1.12.1 guid bit layout (vmangos `ObjectGuid.h`): `[high:16][entry:24][counter:24]` for
//! the families that carry a template entry, `[high:16][counter:48]` (32 bits used) for the rest.
/// `HIGHGUID_PLAYER` — a player character's guid high part.
/// `HIGHGUID_PLAYER`.
pub const HIGH_PLAYER: u16 = 0x0000;
/// `HIGHGUID_ITEM` == `HIGHGUID_CONTAINER` — items and bags share the marker (a bag *is* an item).
/// `HIGHGUID_ITEM`, which is also `HIGHGUID_CONTAINER`: a bag is an item.
pub const HIGH_ITEM: u16 = 0x4000;
/// `HIGHGUID_GAMEOBJECT`.
pub const HIGH_GAMEOBJECT: u16 = 0xF110;
/// `HIGHGUID_UNIT` — a spawned creature.
/// `HIGHGUID_UNIT`: a spawned creature.
pub const HIGH_UNIT: u16 = 0xF130;
/// `HIGHGUID_PET`.
pub const HIGH_PET: u16 = 0xF140;
/// `HIGHGUID_MO_TRANSPORT` — a boat/zeppelin (`GAMEOBJECT_TYPE_MO_TRANSPORT`, template type 15): the
/// WMO-modeled transports that sail scripted taxi-path routes. VERIFIED vmangos `ObjectGuid.h:77`.
/// `HIGHGUID_MO_TRANSPORT` (`ObjectGuid.h:77`): a boat or zeppelin, gameobject type 15.
pub const HIGH_MO_TRANSPORT: u16 = 0x1FC0;
/// `HIGHGUID_TRANSPORT` — an elevator (`GAMEOBJECT_TYPE_TRANSPORT`, template type 11): the M2-modeled,
/// `TransportAnimation.dbc`-driven lifts. VERIFIED vmangos `ObjectGuid.h:72`.
/// `HIGHGUID_TRANSPORT` (`ObjectGuid.h:72`): an elevator, gameobject type 11.
pub const HIGH_TRANSPORT: u16 = 0xF120;
/// The guid's high 16 bits (`GetHigh`, vmangos `ObjectGuid.h`) — the object-family tag.
/// The high 16 bits, the object-family tag (`GetHigh`).
pub fn high(guid: u64) -> u16 {
((guid >> 48) & 0xFFFF) as u16
}
/// A (non-zero) player-character guid.
/// A non-zero player-character guid.
pub fn is_player(guid: u64) -> bool {
guid != 0 && high(guid) == HIGH_PLAYER
}
/// A creature or pet guid (`IsCreatureOrPet`) — both are `TYPEID_UNIT` to every consumer. They are
/// **not** interchangeable for naming: only the creature half carries a template entry (see
/// [`entry`] and [`pet_number`]).
/// A creature or pet guid (`IsCreatureOrPet`); only a creature's carries a template entry.
pub fn is_creature_or_pet(guid: u64) -> bool {
matches!(high(guid), HIGH_UNIT | HIGH_PET)
}
/// A pet guid (`IsPet`) — a summoned or charmed unit, whose guid's middle field is a **pet number**
/// and not a creature template entry. See [`pet_number`].
/// A pet guid (`IsPet`), whose middle field is a pet number, not a template entry.
pub fn is_pet(guid: u64) -> bool {
high(guid) == HIGH_PET
}
/// A pet guid's **pet number** — the per-pet id `CMSG_PET_NAME_QUERY` is keyed by, and the value the
/// server matches against `CharmInfo::GetPetNumber()` before it will answer at all.
///
/// It occupies the same guid bits 24–47 that a creature's template entry does, because vmangos
/// composes a pet with `Object::_Create(guidlow, petNumber, HIGHGUID_PET)` (`Objects/Pet.cpp:2250`,
/// from `Pet::Create(guidlow, pos, cinfo, petNumber)`): the shared `_Create`'s `entry` parameter is
/// fed the pet number, **not** `cinfo->entry`. That is why [`entry`] answers `None` for this family —
/// the slot is occupied, just not by a template id, and a `CMSG_CREATURE_QUERY` for it can only miss.
/// The pet number in bits 24-47, fed in as `_Create`'s entry (`Objects/Pet.cpp:2250`): the key
/// of `CMSG_PET_NAME_QUERY`, answered only when it matches `CharmInfo::GetPetNumber()`.
pub fn pet_number(guid: u64) -> Option<u32> {
is_pet(guid).then_some(((guid >> 24) & 0xFF_FFFF) as u32)
}
/// A (non-zero) item or container guid (`IsItem` — containers included). Items carry **no** entry
/// in the guid; the entry lives in the descriptor (`OBJECT_FIELD_ENTRY`).
/// A non-zero item or container guid (`IsItem`); the entry is only in `OBJECT_FIELD_ENTRY`.
pub fn is_item(guid: u64) -> bool {
guid != 0 && high(guid) == HIGH_ITEM
}
/// Either transport guid family — a boat/zeppelin (`HIGH_MO_TRANSPORT`) or an elevator
/// (`HIGH_TRANSPORT`). The two are composed differently (see [`entry`]) but both name "a transport" to
/// every other consumer (the create-routing gate, the rider wire tail).
/// Either transport family: a boat or zeppelin, or an elevator.
pub fn is_transport(guid: u64) -> bool {
matches!(high(guid), HIGH_MO_TRANSPORT | HIGH_TRANSPORT)
}
@@ -76,34 +56,10 @@ pub fn is_gameobject(guid: u64) -> bool {
high(guid) == HIGH_GAMEOBJECT
}
/// The embedded template entry, for the guid families that carry one. Two different layouts, both
/// VERIFIED against vmangos:
///
/// - Creatures/GameObjects/**elevators** (`HIGH_UNIT`/`HIGH_GAMEOBJECT`/
/// `HIGH_TRANSPORT`) use the standard `[high:16][entry:24][counter:24]` layout — vmangos's
/// class-internal `HasEntry` switch returns `true` for these (`ObjectGuid.h:223-240`), and an
/// elevator composes exactly like an ordinary GameObject: `GameObject::Create` calls
/// `Object::_Create(guidlow, goinfo->id, HIGHGUID_TRANSPORT)` for a `GAMEOBJECT_TYPE_TRANSPORT`
/// template (`Objects/GameObject.cpp:207`, reached via `ElevatorTransport::Create` →
/// `GenericTransport::Create` → the same `GameObject::Create`) — `goinfo->id` (the template entry)
/// rides bits 24–47, the spawn's own DB-assigned low guid rides bits 0–23.
/// - **Boats/zeppelins** (`HIGH_MO_TRANSPORT`) carry **no** entry part at all: vmangos's
/// `DoesGuidHaveEntryPart`/`HasEntry` both return `false` for `HIGHGUID_MO_TRANSPORT`
/// (`ObjectGuid.h:87-104`, `223-240`). `ShipTransport::Create` calls `Object::_Create(guidlow, 0,
/// HIGHGUID_MO_TRANSPORT)` (`Transports/Transport.cpp:65`) where `guidlow` is the
/// `gameobject_template` entry itself, threaded in from `TransportMgr::CreateTransport(uint32
/// entry, …)` (`TransportMgr.cpp:364,400`: `trans->Create(entry, frameItr)`). The `ObjectGuid`
/// 3-arg ctor folds `entry`(=0 here) and `counter`(=guidlow) into ONE 32-bit low field when the
/// family has no entry part (`ObjectGuid.h:123`: `counter | (entry << 24) | (hi << 48)`) — so the
/// template entry rides the **full low 32 bits**, recovered the same way `GetCounter`'s
/// `hasEntry=false` branch does (`ObjectGuid.h:157-165`: mask `0xFFFF_FFFF`, not the 24-bit entry
/// slot the other families use). Masking to 24 bits here would silently truncate a >16.7M template
/// id — none exist today, but the 32-bit mask is what vmangos actually does, so that's what we do.
///
/// `None` for players/items/corpses/dynamic objects (no entry part, and not composed like a transport
/// either) — and `None` for **pets**, whose entry-shaped slot holds a pet number instead
/// ([`pet_number`]): `HasEntry` is `true` for the family, but what vmangos stores there is not a
/// template id, so answering with it would hand every caller a creature entry that cannot resolve.
/// The template entry: bits 24-47 for creatures, gameobjects and elevators (`HasEntry`,
/// `ObjectGuid.h:223-240`; `GameObject.cpp:207`), the full low 32 bits for boats and zeppelins,
/// whose template entry is passed as the counter (`Transport.cpp:65`, `ObjectGuid.h:123`). `None`
/// for pets although `HasEntry` is true: their slot holds the pet number.
pub fn entry(guid: u64) -> Option<u32> {
match high(guid) {
HIGH_MO_TRANSPORT => Some((guid & 0xFFFF_FFFF) as u32),
@@ -116,14 +72,13 @@ pub fn entry(guid: u64) -> Option<u32> {
mod tests {
use super::*;
/// Compose a guid the way vmangos does: `counter | (entry << 24) | (high << 48)`.
/// Composes a guid as the vmangos ctor does (`ObjectGuid.h:123`).
fn compose(high: u16, entry: u32, counter: u32) -> u64 {
u64::from(counter) | (u64::from(entry) << 24) | (u64::from(high) << 48)
}
#[test]
fn creature_guid_decodes_high_and_entry() {
// A Northshire creature: entry 69 (Diseased Young Wolf), counter 12345.
let g = compose(HIGH_UNIT, 69, 12345);
assert_eq!(high(g), HIGH_UNIT);
assert!(is_creature_or_pet(g));
@@ -165,9 +120,6 @@ mod tests {
assert_eq!(entry(g), Some(0xFF_FFFF));
}
/// A pet's entry-shaped slot is its PET NUMBER — `Pet::Create` feeds `_Create`'s `entry`
/// parameter `petNumber`, not `cinfo->entry` (`Objects/Pet.cpp:2250`). Asking
/// `CMSG_CREATURE_QUERY` for it is what left NPC-summoned pets nameless, so `entry` must refuse.
#[test]
fn a_pet_carries_a_pet_number_not_a_template_entry() {
let g = compose(HIGH_PET, 137, 4242);
@@ -175,32 +127,20 @@ mod tests {
assert!(is_creature_or_pet(g));
assert_eq!(pet_number(g), Some(137));
assert_eq!(entry(g), None, "a pet's slot is not a template entry");
// ...and only a pet answers `pet_number`.
assert_eq!(pet_number(compose(HIGH_UNIT, 137, 4242)), None);
}
/// An elevator (`HIGHGUID_TRANSPORT`, 0xF120) composes exactly like an ordinary GameObject —
/// `Object::_Create(guidlow, goinfo->id, HIGHGUID_TRANSPORT)` (`GameObject.cpp:207`): the DB spawn's
/// own low guid in `counter`, the `gameobject_template` entry in the standard entry slot.
#[test]
fn elevator_guid_decodes_high_and_entry() {
// The Undercity/Ironforge-style elevator template entry 900 (a real GAMEOBJECT_TYPE_TRANSPORT
// id range), DB spawn low guid 4242.
let g = compose(HIGH_TRANSPORT, 900, 4242);
assert_eq!(high(g), HIGH_TRANSPORT);
assert!(is_transport(g));
assert_eq!(entry(g), Some(900));
}
/// A boat/zeppelin (`HIGHGUID_MO_TRANSPORT`, 0x1FC0) composes the OTHER way —
/// `Object::_Create(guidlow, 0, HIGHGUID_MO_TRANSPORT)` (`Transport.cpp:65`) where `guidlow` is
/// itself the `gameobject_template` entry (`TransportMgr::CreateTransport`'s `entry` param threaded
/// straight through, `TransportMgr.cpp:364,400`): the entry rides the FULL low 32 bits, not the
/// 24-bit entry slot the standard families use — composed here with `entry=0` per the ctor
/// (`ObjectGuid.h:123`), matching `entry()`'s `HIGH_MO_TRANSPORT` mask.
#[test]
fn mo_transport_guid_decodes_high_and_entry() {
// The Menethil-Theramore ferry's real gameobject_template entry, 176495.
// The Grom'Gol-Undercity zeppelin's template entry, which the ctor takes as the counter.
let g = compose(HIGH_MO_TRANSPORT, 0, 176_495);
assert_eq!(high(g), HIGH_MO_TRANSPORT);
assert!(is_transport(g));
@@ -209,8 +149,7 @@ mod tests {
Some(176_495),
"the template entry rides the full low 32 bits, not bits 24-47"
);
// The naive `(guid >> 24) & 0xFFFFFF` extraction the other families use would find nothing
// useful here — pin that the two layouts genuinely differ, not just that our helper works.
// The 24-bit entry slot does not hold it: the two layouts really differ.
assert_ne!(((g >> 24) & 0xFF_FFFF) as u32, 176_495);
}
+31 -100
View File
@@ -1,10 +1,5 @@
//! `benilla-protocol` — WoW 1.12.1 (build 5875) auth + world wire protocol.
//!
//! Auth/SRP6 and the world header crypto come from `benilla-srp`; message (de)serialization is
//! in-repo ([`auth`] for the realmd login protocol, [`world`]/[`events`] for the world protocol) —
//! decision 0021. 1.12.1 uses **login protocol version 3**. This crate owns the session logic; the
//! async↔ECS bridge lives in `benilla`. Phase 3: SRP6 logon + realm
//! list.
//! The WoW 1.12.1 (build 5875) wire protocol: [`auth`] for realmd (login protocol version 3),
//! [`world`] and [`events`] for the world server. SRP6 and the header crypto are `benilla-srp`'s.
pub mod auth;
pub mod events;
@@ -33,19 +28,15 @@ use std::net::TcpStream;
use anyhow::{anyhow, Context, Result};
use benilla_srp::{NormalizedString, PublicKey, SrpClientChallenge, SESSION_KEY_LENGTH};
/// The realmd (auth/login) server port — the stock one a vmangos `realmd` listens on, which our
/// deploy maps straight through (`3724:3724` in a docker deploy's compose file).
/// The port a stock vmangos `realmd` listens on.
pub const AUTH_PORT: u16 = 3724;
/// The 1.12.1 client build we present to the server.
pub const CLIENT_BUILD: u16 = 5875;
/// How many logon challenges [`logon`] will ask for while looking for a `B` both serialization
/// conventions read the same way (see the redial comment there). One dial in ~137 comes back
/// ambiguous, so eight is already a probability of about 10⁻¹⁷ of running out.
/// Challenges [`logon`] draws for an unambiguous `B`; one in ~137 is not, so all 8 fail ~10⁻¹⁷.
const MAX_CHALLENGE_DIALS: u32 = 8;
/// Split an optional `:port` off a host string (`play.example.com:3724`). A bare host — or one
/// whose suffix isn't a port, e.g. a raw IPv6 address like `::1` — comes back intact with
/// `default`.
/// Splits an optional `:port` off a host; one without a numeric port, a raw IPv6 address
/// included, comes back whole with `default`.
pub fn host_port(host: &str, default: u16) -> (&str, u16) {
match host.rsplit_once(':') {
Some((h, p)) if !h.contains(':') => match p.parse::<u16>() {
@@ -56,17 +47,8 @@ pub fn host_port(host: &str, default: u16) -> (&str, u16) {
}
}
/// The dial never got a socket — **and which of the two reasons it was**.
///
/// This distinction only started mattering when the realmlist became something a player types
/// (decision 1667). Before that the address was a constant and "Unable to connect" could only mean
/// one thing; afterwards it means either *you typed a name that does not exist* or *the address is
/// fine and nothing is answering on it*, and a player with no way to tell them apart edits a
/// correct address over and over. That is exactly what the first live use of the editor produced.
///
/// The split is drawn where it is honest — resolution and connection are two separate operations,
/// so we do them separately rather than reading tea leaves out of one `io::Error` (a DNS failure
/// surfaces as `ErrorKind::Uncategorized` on macOS, so the kind cannot carry this).
/// A dial that got no socket, split by step: the name did not resolve, or nothing answered (one
/// `io::Error` cannot tell them apart; macOS reports a failed lookup as `Uncategorized`).
#[derive(Debug, Clone)]
pub struct DialFailure {
/// What we tried to reach, as the player would recognise it (`host:port`).
@@ -87,13 +69,8 @@ impl std::fmt::Display for DialFailure {
impl std::error::Error for DialFailure {}
/// Resolve `host:port` and open a socket to it, reporting the two failures apart ([`DialFailure`]).
///
/// `to_socket_addrs` is the resolution step on its own; an empty result is a name that resolved to
/// no addresses, which is as much a "cannot find it" as an outright lookup error. Connecting to the
/// resolved list (rather than to `(host, port)` again) also means the OS does not repeat the
/// lookup, and that every address the name carries is tried — the `localhost` → `::1` then
/// `127.0.0.1` case on a machine whose server binds only IPv4.
/// Resolves, then tries every resolved address in turn: `localhost` gives `::1` first, and a
/// server may bind only IPv4.
fn dial(host: &str, port: u16) -> Result<TcpStream> {
use std::net::ToSocketAddrs;
let address = format!("{host}:{port}");
@@ -122,71 +99,42 @@ fn dial(host: &str, port: u16) -> Result<TcpStream> {
})
}
/// A realm as advertised by the auth server's realm list — **every** field the wire carries.
///
/// Three of them used to be dropped on the floor (`_flag`, `_category`, `_realm_id`) and
/// `population` was stringified on arrival, which was survivable only while benilla connected to
/// `realms.first()` and never drew a list. The realm-list screen needs all of them, and it needs
/// the population as the float it is.
/// A realm from realmd's realm list, with every field the wire carries.
#[derive(Debug, Clone)]
pub struct RealmInfo {
pub name: String,
/// `host:port` of the world server, as the client would connect to it.
pub address: String,
/// The population float — **not** what the realm list displays. The displayed word
/// (`Low`/`Medium`/`High`/`Full`/…) is a *band* computed against the mean and standard
/// deviation of **every** realm; see `realm_select::load` on the app side.
///
/// Already **rewritten** if the server sent one of the three sentinel values — see
/// [`auth::MAGIC_POPULATIONS`]. What is stored here is what the reference's own parser would
/// hold, which is the only value it is safe to average.
/// The population, sentinels already rewritten as the reference parser does
/// ([`auth::MAGIC_POPULATIONS`]); the list shows a band against all realms' mean and deviation.
pub population: f32,
/// How many characters this account has on that realm — a genuine count, printed by the list as
/// `"(3)"`. (The client's realm record keeps it at `[realm+0x130]`; wow-re's earlier band sweep
/// had read that offset as the realm type, which is really the dword at `[+0x04]` below.)
/// This account's character count there, shown as `"(3)"` (client realm record `+0x130`).
pub characters: u8,
/// The realm **type** — the join key for the game-type columns, *not* an enumeration to match
/// on directly. `GetRealmInfo` resolves `(pvp, rp)` by scanning `Cfg_Configs.dbc` for the row
/// whose `RealmType` equals this; `realm_select::load::pvp_rp` is that table.
/// The realm type (record `+0x04`), a join key, not an enum: `GetRealmInfo` takes `(pvp, rp)`
/// from the `Cfg_Configs.dbc` row whose `RealmType` equals it.
pub realm_type: u32,
/// The realm-flags byte — the client's realm record `[realm+0x08]`.
///
/// **Only `0x01` (invalid) and `0x02` (offline) — plus `0x04` — actually travel on the wire.**
/// The `0x20`/`0x40`/`0x80` sentinels that mean Recommended / New / Full are *synthesized on
/// arrival* from the magic populations the server sends instead, exactly as the reference's
/// parser does it ([`auth::MAGIC_POPULATIONS`]) — so this byte is the client's view of the
/// flags, not the server's.
/// The realm flags as the client holds them (record `+0x08`): the wire carries only `0x01`
/// invalid, `0x02` offline and `0x04`; `0x20`/`0x40`/`0x80` (Recommended, New, Full) are
/// synthesized from the magic populations ([`auth::MAGIC_POPULATIONS`]).
pub flags: u8,
/// The wire's category (timezone) byte — the realm list's category tabs group on it. Matched
/// against a category id by **equality**; it is not an index into anything.
/// The category (timezone) byte the list's tabs group on, matched by equality, not an index.
pub category: u8,
/// The wire's realm id.
pub id: u8,
}
/// Result of a successful logon: the SRP6 session key (carried into the world server), the realms,
/// and the **still-open realmd socket** the list was read from.
///
/// The socket is kept because the realm list is not a one-shot: the reference's `RealmList.lua`
/// re-requests it every `REALM_LIST_REFRESH_TIME` (5 s) for as long as its window is open, which
/// is how a realm going offline or filling up shows up without a re-login. [`Logon::refresh_realms`]
/// is that request. It is private so the only thing anyone can do with the connection is ask it
/// the one question it still answers.
/// A successful logon: the SRP6 session key, the realms, and the realmd socket, kept open because
/// the reference's `RealmList.lua` re-requests the list every `REALM_LIST_REFRESH_TIME` (5 s).
pub struct Logon {
pub session_key: [u8; SESSION_KEY_LENGTH],
pub realms: Vec<RealmInfo>,
/// `None` once a refresh has failed — the list we hold is then the last word, and we stop
/// asking rather than retrying a dead socket every five seconds.
/// `None` once a refresh has failed; the held list is then final.
stream: Option<TcpStream>,
}
impl Logon {
/// Re-request the realm list on the realmd connection — the reference's `RequestRealmList`.
///
/// Returns whether the list was refreshed. A failed refresh **drops the connection and keeps
/// the realms we already have**: a stale list the player can still pick from beats an empty
/// one, and realmd closing an idle socket is ordinary. Bounded by `timeout` so a park that
/// calls this can never block on a server that accepted the request and said nothing.
/// The reference's `RequestRealmList`: re-reads the list, keeping the old one and dropping
/// the socket on failure (realmd closes idle sockets); `timeout` bounds a silent server.
pub fn refresh_realms(&mut self, timeout: std::time::Duration) -> bool {
let Some(stream) = self.stream.as_mut() else {
return false;
@@ -208,20 +156,14 @@ impl Logon {
}
}
/// Whether the realmd connection is still up, i.e. whether [`Self::refresh_realms`] can do
/// anything. Lets a caller stop scheduling refreshes rather than calling into a no-op.
/// Whether the realmd socket is still up, so [`Self::refresh_realms`] can do anything.
pub fn realmd_live(&self) -> bool {
self.stream.is_some()
}
}
/// Perform the full SRP6 logon against a vanilla `realmd` and fetch the realm list.
///
/// Flow: logon challenge → server challenge (B, g, N, salt) → SRP6 → logon proof → verify the
/// server's proof (M2) → request + read the realm list.
/// The full SRP6 logon against a vanilla `realmd`, then the realm list.
pub fn logon(host: &str, username: &str, password: &str) -> Result<Logon> {
// `host` may carry an explicit `:port` (`WOW_HOST=play.example.com:3724`); bare hosts get
// [`AUTH_PORT`].
let (host, port) = host_port(host, AUTH_PORT);
let username_n =
@@ -229,14 +171,9 @@ pub fn logon(host: &str, username: &str, password: &str) -> Result<Logon> {
let password_n =
NormalizedString::new(password).map_err(|e| anyhow!("invalid password: {e}"))?;
// 1-2. Logon challenge → the server's SRP6 inputs. The account name is sent uppercased (matching
// the SRP6 calculation).
//
// `B` is the one hashed value benilla does not draw, and ~1 in 137 of them is one the client and
// the mangos family serialize differently — a correct password answered with 0x04 (see
// `benilla-srp`, "Encoding-unambiguous handshakes"). Redial for a fresh `B` when that lands: the
// challenge is abandoned before any proof goes out, and realmd counts nothing at this stage for a
// known account (`AuthSocket::_HandleLogonChallenge`), so a redial is free and invisible.
// The account name goes uppercased, as SRP6 hashes it. About one `B` in 137 serializes
// differently in the client and mangos (a right password gets 0x04), so redial for a fresh
// one; realmd counts nothing before the proof (`AuthSocket::_HandleLogonChallenge`).
let (mut stream, reply, server_public_key) = {
let mut dialed = None;
for _ in 0..MAX_CHALLENGE_DIALS {
@@ -253,12 +190,10 @@ pub fn logon(host: &str, username: &str, password: &str) -> Result<Logon> {
break;
}
}
// Every dial ran out ambiguous (p ≈ 10⁻¹⁷): send the last anyway rather than inventing a
// failure the server never gave us.
// All ambiguous: go on with the last, since only the server can say it fails.
dialed.expect("MAX_CHALLENGE_DIALS is non-zero")
};
// 3. SRP6 (WoW flavor) — compute A, M1, and the session key.
let challenge = SrpClientChallenge::new(
username_n,
password_n,
@@ -268,7 +203,6 @@ pub fn logon(host: &str, username: &str, password: &str) -> Result<Logon> {
reply.salt,
);
// 4. Logon proof.
auth::write_logon_proof(
&mut stream,
challenge.client_public_key(),
@@ -277,14 +211,12 @@ pub fn logon(host: &str, username: &str, password: &str) -> Result<Logon> {
)
.context("sending logon proof")?;
// 5. Verify the server's proof (M2); on success we hold the session key.
let server_proof = auth::read_proof_reply(&mut stream).context("reading logon proof reply")?;
let client = challenge
.verify_server_proof(server_proof)
.map_err(|e| anyhow!("server proof mismatch (wrong password?): {e}"))?;
let session_key = *client.session_key();
// 6. Realm list.
auth::write_realm_list_request(&mut stream).context("requesting realm list")?;
let realms = auth::read_realm_list(&mut stream).context("reading realm list")?;
@@ -301,7 +233,6 @@ mod host_port_tests {
#[test]
fn explicit_port_splits() {
// Explicit port beats the default — they differ so the assert can tell them apart.
assert_eq!(
host_port("play.example.com:5000", AUTH_PORT),
("play.example.com", 5000)
@@ -1,43 +1,29 @@
//! Action-bar messages — the 120-slot bar's packing, the login snapshot, and the one-slot write.
//! Split out of `messages/spells.rs` (decision 0640); mirrored by `world::writer::action_bar`.
//!
//! The bar is **client-authoritative** (decisions 0216 §7 / 0218 §4): the server stores 120 packed
//! `u32`s and hands them back at login, and the client sends one `CMSG_SET_ACTION_BUTTON` per local
//! slot mutation. There is no server-side edit in normal play, so `SMSG_ACTION_BUTTONS` is a
//! login-only packet in practice — which is why the whole family is three items.
//!
//! The **visibility** of the four extra bars is the opposite arrangement and shares this file only
//! because it shares the subject: it is one server-owned byte the client can post but never write
//! locally. See [`set_actionbar_toggles`].
//! Action-bar messages: the 120-slot login snapshot, the one-slot write, and the bar toggles.
//! The bar is client-authoritative: the server stores the slots, sends them at login, and takes
//! one `CMSG_SET_ACTION_BUTTON` per local change.
use std::io::{self};
use crate::wire::read_u32_le;
/// Action-button kind byte (bits 24–31 of the packed slot word — vmangos `Player.h`
/// `ActionButtonType`): a spell id, a macro id, or an item id in the low 24 bits.
/// The action-button kind, bits 24-31 of a slot word (`ActionButtonType`, vmangos `Player.h`).
pub const ACTION_KIND_SPELL: u8 = 0x00;
pub const ACTION_KIND_MACRO: u8 = 0x40;
pub const ACTION_KIND_ITEM: u8 = 0x80;
/// One *occupied* action-bar slot from `SMSG_ACTION_BUTTONS`. The wire is 120 packed `u32`s
/// (`MAX_ACTION_BUTTONS`, vmangos `MasterPlayer::SendInitialActionButtons`) — `action` in bits
/// 0–23, `kind` in bits 24–31 (`ACTION_BUTTON_ACTION/TYPE`, `Player.h`); a zero word is an empty
/// slot and is not surfaced.
/// An occupied slot of `SMSG_ACTION_BUTTONS`, 120 packed `u32`s
/// (`MasterPlayer::SendInitialActionButtons`); a zero word is an empty slot.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ActionButton {
/// The bar slot index (0..119). Slots 0–11 are the main bar's buttons 1–12.
pub slot: u8,
/// The spell/macro/item id (bits 0–23).
pub action: u32,
/// The kind byte (bits 24–31): [`ACTION_KIND_SPELL`]/[`ACTION_KIND_MACRO`]/[`ACTION_KIND_ITEM`]
/// (0x01 "click?" exists in the enum, carried raw if it ever appears).
/// The kind byte (bits 24-31), an `ACTION_KIND_*`; the enum's 0x01 ("click?") is carried raw.
pub kind: u8,
}
/// Read `SMSG_ACTION_BUTTONS`: packed `u32` per slot to end-of-body (the server sends exactly 120;
/// reading to the boundary keeps us robust to a different count). Zero words (empty slots) are
/// dropped; occupied slots surface as [`ActionButton`]s.
/// Reads slot words to the end of the body (the server sends 120), dropping empty ones.
pub(super) fn read_action_buttons(r: &mut &[u8]) -> io::Result<Vec<ActionButton>> {
let mut buttons = Vec::new();
let mut slot: u32 = 0;
@@ -55,12 +41,8 @@ pub(super) fn read_action_buttons(r: &mut &[u8]) -> io::Result<Vec<ActionButton>
Ok(buttons)
}
/// Body of `CMSG_SET_ACTION_BUTTON` (VERIFIED vmangos `WorldPackets::Misc::SetActionButton::
/// ReadFromWorldPacket`, `Server/Packets/Misc.cpp:87-90`; opcode 296 `Opcodes_1_12_1.h:299`):
/// `button u8` + `packetData u32` (`action | kind<<24`, [`ActionButton`]'s own packing) — 5
/// bytes. `packed == 0` clears the slot (`HandleSetActionButtonOpcode`'s `!packet.packetData`
/// branch calls `removeActionButton`, never sent back over the wire — decision 0216 §7/0218 §4:
/// the client sends ONE of these per local slot mutation, a drag-swap is two sends, never atomic).
/// `CMSG_SET_ACTION_BUTTON` (296): `button u8`, `packetData u32` packed as in [`ActionButton`]
/// (`Server/Packets/Misc.cpp:87-90`). Zero clears the slot (no reply); a drag-swap is two sends.
pub fn set_action_button(button: u8, packed: u32) -> Vec<u8> {
let mut body = Vec::with_capacity(5);
body.push(button);
@@ -68,29 +50,11 @@ pub fn set_action_button(button: u8, packed: u32) -> Vec<u8> {
body
}
/// Body of `CMSG_SET_ACTIONBAR_TOGGLES` (opcode [`super::opcode::CMSG_SET_ACTIONBAR_TOGGLES`]):
/// **one `u8`**, and nothing else — VERIFIED at the bytes, wow-re
/// `system/ui/scratch/action-bar-toggles.md` §3. `SetActionBarToggles 0x4e76e0` builds a stack
/// `CDataStore`, appends the opcode with `PutUInt32 0x418190` and the byte with `PutUInt8
/// 0x418070`, and sends; those two appends are the only ones between construction and send, and
/// `NetClient::Send 0x5379b3` computes the payload length as `size − read` = **5** — the
/// independent confirmation that 4 + 1 is the whole frame.
///
/// **The byte is server-owned; this is a post, not a write.** No instruction in the real client
/// stores to `PLAYER_FIELD_BYTES` byte 2 (§4.1: displacement `0x102a` occurs exactly once
/// image-wide and it is the *read* inside `GetActionBarToggles`) — the cell only ever moves through
/// the generic `SMSG_UPDATE_OBJECT` value-apply. So the sender's own field copy still holds the old
/// value until the server echoes, and nothing is notified when it does (§4.2: all 49 field-change
/// registrations at `0x468070` were enumerated and none sits at an offset ≥ `0x1000`). Read it back
/// with [`super::ObjectFields::player_action_bar_toggles`].
///
/// **Only the low nibble is reachable.** The real binding accumulates from zero and ORs at most
/// bits 0..3 (§2), so a value it produces is always `0x00..=0x0f` and a `Set` *destroys* whatever
/// the server held in the high nibble. This encoder does not mask: what the caller packed is what
/// goes on the wire, and the four-bit law belongs where the packing happens (the Lua binding).
///
/// The bit→bar meaning is **not** the binary's — it is bar-agnostic (§7), and naming the bars here
/// would put a FrameXML convention in the protocol layer.
/// `CMSG_SET_ACTIONBAR_TOGGLES`: one `u8`, the whole body (`SetActionBarToggles 0x4e76e0`).
/// The byte is server-owned: the reference never writes its copy (`PLAYER_FIELD_BYTES` byte 2),
/// which changes only when `SMSG_UPDATE_OBJECT` echoes it, with no change event; read it with
/// [`super::ObjectFields::player_action_bar_toggles`]. The reference packs only bits 0..3, so a
/// set clears the high nibble; this encoder does not mask. Bits name bars only in FrameXML.
pub fn set_actionbar_toggles(toggles: u8) -> Vec<u8> {
vec![toggles]
}
+24 -102
View File
@@ -1,75 +1,19 @@
//! The **addon-info block** at the tail of `CMSG_AUTH_SESSION` — what the client tells the world
//! server about its *secure* addons (decision 1497).
//! The addon-info block ending `CMSG_AUTH_SESSION`, written by `0x51d910` after the 20-byte
//! proof: a `u32` uncompressed size, then a zlib (RFC1950) stream to the end of the packet.
//! Uncompressed, it is one record per addon whose `.toc` sets `## Secure:` non-zero, enabled or
//! not, with no count or trailer: `CString` name, `u8` flags (`.pub` byte 0), `u32` CRC-32 of the
//! 256 modulus bytes (`.pub` bytes 1..=256), `u32` CRC-32 of the `.url` string (0 if none).
//!
//! Every 1.12.1 logon carries one of these. It is not optional decoration: it is the last field of
//! the auth packet, and a server reads it before the session exists. benilla used to send a
//! `decompressed_size = 0` + zlib-of-nothing stub, which **no real client can produce** — and
//! cmangos-classic kicks the session for it (B277). See [`addon_block`] for the shape and
//! [`STOCK_SECURE_ADDONS`] for what a stock install actually sends.
//!
//! ## Where the shape comes from
//!
//! VERIFIED in `WoW.exe` (5875) — wow-5875-re `system/net/scratch/cmsg-auth-session-addon-block.md`.
//! The writer is `0x51d910`, called once, from `HandleAuthChallenge` (`0x5b4143`) straight after the
//! 20-byte SHA-1 proof. It appends:
//!
//! ```text
//! u32 uncompressed_size ; byte length of the buffer below
//! u8[] zlib stream ; RFC1950 framing (deflateInit_ windowBits +15), runs to end of packet
//! ```
//!
//! and the uncompressed buffer is a **bare concatenation** — no leading count, no trailer —
//! of one record per secure addon:
//!
//! ```text
//! CString name ; NUL-terminated
//! u8 flags ; byte 0 of the addon's own .pub signature file
//! u32 modulus_crc ; CRC-32 of the 256 modulus bytes (.pub bytes 1..=256)
//! u32 url_crc ; CRC-32 of the addon's .url string; 0 when it has none
//! ```
//!
//! **Which** addons: exactly those whose `.toc` declares `## Secure:` non-zero — enable/disable
//! state is not consulted. On a stock install that is precisely the twelve `Blizzard_*` built-ins
//! and nothing else, because no third-party 1.12 addon declares `## Secure`. A client with *zero*
//! secure addons appends **nothing at all** (not a zero dword) — the writer returns before the
//! local store exists.
//!
//! ## Why the emulators disagree about it, and why it matters
//!
//! Both vmangos and cmangos reject `size == 0` with the same comment ("empty addon packet … can't
//! be received from real client") — that judgement is *correct*, and matched the binary all along.
//! What differs is the consequence:
//!
//! - vmangos `WorldSocket.cpp:447` — `if (BuildAddonPacket(...)) SendPacket(addonPacket);`. A
//! rejection just means no `SMSG_ADDON_INFO` goes back; the session lives. That is the only
//! reason benilla's stub ever worked.
//! - cmangos-classic `WorldSocket.cpp:562` — `if (!ReadAddonInfo(...)) { … "sent bad addon info.
//! Kicking."; return false; }`. Same rejection, session killed. Not config-gated: the check is in
//! the anticheat module *and* in `NullSessionAnticheat`, so it runs with the anticheat off.
//!
//! cmangos's two readers also disagree with each other on field order — its anticheat module reads
//! `name, u8, u32, u32` (right, matching the binary) and its `NullSessionAnticheat` reads
//! `name, u32, u32, u8` (wrong, but the same nine bytes, so it parses without throwing).
//! With no secure addons the client appends nothing. Both emulators refuse a zero size: vmangos
//! then skips `SMSG_ADDON_INFO` (`WorldSocket.cpp:447`), cmangos-classic kicks the session
//! (`WorldSocket.cpp:562`).
/// CRC-32 of the stock Blizzard public-key modulus — what a stock, unmodified addon's `.pub`
/// hashes to, and the value every emulator calls the "standard addon CRC". A server that sees it
/// knows it need not push the 256-byte modulus back in `SMSG_ADDON_INFO`.
///
/// VERIFIED: `zlib.crc32` of the 256 modulus bytes in this install's `Blizzard_*.pub` files, which
/// are byte-identical to the `modulus`/`tdata` array carried in vmangos and cmangos alike.
/// CRC-32 of the stock Blizzard public-key modulus, the emulators' "standard addon CRC"; a server
/// seeing it does not send the 256-byte modulus back in `SMSG_ADDON_INFO`.
pub const STANDARD_MODULUS_CRC: u32 = 0x4C1C_776D;
/// **Which addons `SMSG_ADDON_INFO` hid** — the reply's `status` bytes paired back against the
/// records we sent (decision 2175).
///
/// The reply carries no names: the client re-walks its own `## Secure:` list in the same order it
/// sent it, so record *i* is `sent[i]`. A `status` of **2** is what makes the reference set
/// `[rec+0x29] = 1` (`0x51db84`) and drop the addon from the Lua index space — on a stock install
/// that is all twelve, which is why the AddOns list shows only the player's own addons.
///
/// A reply with **more** records than we sent is truncated to what we sent, and one with fewer
/// simply says nothing about the rest: both are the server disagreeing with us about the block,
/// and neither is worth failing a login over.
/// The addons `SMSG_ADDON_INFO` hides: record i answers `sent[i]` (the reply carries no names),
/// and status 2 makes the reference set `[rec+0x29] = 1` (`0x51db84`), dropping it from Lua.
pub fn hidden_from_reply(statuses: &[u8], sent: &[SecureAddon]) -> Vec<String> {
statuses
.iter()
@@ -79,26 +23,22 @@ pub fn hidden_from_reply(statuses: &[u8], sent: &[SecureAddon]) -> Vec<String> {
.collect()
}
/// One record in the addon block — a secure addon as the client describes it to the server.
/// One record of the addon block.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SecureAddon<'a> {
/// The addon's folder name (`Blizzard_AuctionUI`), sent as a `CString`.
pub name: &'a str,
/// Byte 0 of the addon's `.pub` file — `1` for a stock signature, `0` when the client found no
/// usable `.pub`. A server may write a different byte back (`SMSG_ADDON_INFO`), which the real
/// client persists into the `.pub` and echoes next logon; cmangos's anticheat uses exactly that
/// channel to stamp a per-install fingerprint across four of the twelve.
/// Byte 0 of the `.pub`: 1 for a stock signature, 0 for none usable. A server may write another
/// back in `SMSG_ADDON_INFO`, which the reference saves to the `.pub` and echoes next logon.
pub flags: u8,
/// CRC-32 of the `.pub`'s 256 modulus bytes — [`STANDARD_MODULUS_CRC`] for a stock signature,
/// `0` when `flags` is `0`.
/// CRC-32 of the `.pub`'s 256 modulus bytes; 0 when `flags` is 0.
pub modulus_crc: u32,
/// CRC-32 of the addon's `.url` string. `0` for every stock addon (none ship one).
pub url_crc: u32,
}
impl SecureAddon<'_> {
/// A stock, signed Blizzard built-in: enabled signature, standard modulus, no URL. The shape
/// all twelve take on an unmodified install.
/// A stock signed built-in, the shape all twelve take on an unmodified install.
const fn stock(name: &str) -> SecureAddon<'_> {
SecureAddon {
name,
@@ -109,20 +49,9 @@ impl SecureAddon<'_> {
}
}
/// The twelve `Blizzard_*` built-ins a stock 1.12.1 install reports, in the order the client sends
/// them (ascending ASCII of the folder name).
///
/// VERIFIED against a real 1.12.1.5875 client's `CMSG_AUTH_SESSION` captured against a live
/// Blizzard server in 2006 (wow-5875-re `system/net/scratch/cmsg-auth-session-addon-block.md`):
/// 342 uncompressed bytes, twelve records, every one `flags = 1`, `modulus_crc =`
/// [`STANDARD_MODULUS_CRC`], `url_crc = 0`, and nothing left over — the arithmetic proof that the
/// buffer carries no count and no trailer.
///
/// benilla sends this list rather than one derived from the install's own `Interface/AddOns`
/// folder, and the two agree on every stock install: the client's rule is "`## Secure:` non-zero",
/// which no third-party 1.12 addon sets, and benilla's own addons (loaded from
/// `benilla-config/AddOns`) are third-party by construction. Reading each `.pub` off the player's
/// chain would only differ on an install whose signature files have been altered — see 1497.
/// The twelve `Blizzard_*` built-ins a stock 1.12.1 install reports, in the client's order
/// (ascending ASCII). Deviation: a fixed list, not read off the install, because no third-party
/// addon sets `## Secure:`, so the two differ only where the `.pub` files were altered.
pub const STOCK_SECURE_ADDONS: [SecureAddon<'static>; 12] = [
SecureAddon::stock("Blizzard_AuctionUI"),
SecureAddon::stock("Blizzard_BattlefieldMinimap"),
@@ -138,7 +67,7 @@ pub const STOCK_SECURE_ADDONS: [SecureAddon<'static>; 12] = [
SecureAddon::stock("Blizzard_TrainerUI"),
];
/// The **uncompressed** addon buffer: one record per addon, concatenated, nothing else.
/// The uncompressed addon buffer: the records concatenated, nothing else.
pub fn addon_block(addons: &[SecureAddon]) -> Vec<u8> {
let mut out = Vec::with_capacity(addons.iter().map(|a| a.name.len() + 10).sum());
for addon in addons {
@@ -151,8 +80,7 @@ pub fn addon_block(addons: &[SecureAddon]) -> Vec<u8> {
out
}
/// The block as it rides the wire: `u32` uncompressed size + the zlib stream, or **nothing at all**
/// when there are no secure addons (what the real client does — it never emits a zero size).
/// The block on the wire: `u32` size and the zlib stream, or nothing when there are no addons.
pub fn addon_tail(addons: &[SecureAddon]) -> Vec<u8> {
if addons.is_empty() {
return Vec::new();
@@ -170,14 +98,12 @@ pub fn addon_tail(addons: &[SecureAddon]) -> Vec<u8> {
mod tests {
use super::*;
/// The retail ground truth: 342 uncompressed bytes, consumed exactly by twelve records.
#[test]
fn stock_block_matches_the_retail_capture() {
let plain = addon_block(&STOCK_SECURE_ADDONS);
assert_eq!(plain.len(), 342, "retail sent 342 uncompressed bytes");
// Walk it back the way a server does — name, flags, modulus crc, url crc — and require it
// to land exactly on the end. That is what proves there is no count and no trailer.
// Parse it as a server does; landing exactly on the end proves no count and no trailer.
let mut rest = &plain[..];
let mut seen = Vec::new();
while !rest.is_empty() {
@@ -206,9 +132,7 @@ mod tests {
);
}
/// The wire tail: size prefix, zlib framing, and the retail compressed length. `flate2` at its
/// default level reproduces the 2006 client's 130 bytes byte-for-byte — the real client used
/// `Z_DEFAULT_COMPRESSION` through the same zlib deflate.
/// `flate2`'s default is the reference's `Z_DEFAULT_COMPRESSION`, hence the retail 130 bytes.
#[test]
fn stock_tail_is_size_plus_zlib() {
let tail = addon_tail(&STOCK_SECURE_ADDONS);
@@ -221,8 +145,6 @@ mod tests {
assert_eq!(tail.len(), 4 + 130, "retail compressed to 130 bytes");
}
/// Zero secure addons appends *nothing* — never a zero size, which no real client can emit and
/// which cmangos-classic kicks for.
#[test]
fn no_secure_addons_appends_nothing() {
assert!(addon_tail(&[]).is_empty());
@@ -1,37 +1,20 @@
//! The area-trigger pair: the client's "I walked into trigger N" report, and the server's refusal
//! text that sometimes comes back.
//!
//! An `AreaTrigger.dbc` volume means nothing to the client — it owns the geometry and nothing else.
//! Walking into one sends [`area_trigger`] and the **server** decides what happens: a teleport
//! (every instance entrance, the Darnassus/Rut'theran portals), a quest's explore objective, the
//! inn's rested state, a battleground's entrance list. A teleport comes back as the ordinary
//! `SMSG_TRANSFER_PENDING` + `SMSG_NEW_WORLD` pair (decision 0455) or a same-map
//! `MSG_MOVE_TELEPORT_ACK`; a refusal comes back as [`read_area_trigger_message`]'s text.
//! The area-trigger pair: the client's report of entering an `AreaTrigger.dbc` volume, and the
//! server's refusal text. The server decides what a trigger does; a teleport arrives as
//! `SMSG_TRANSFER_PENDING` and `SMSG_NEW_WORLD`, or a same-map `MSG_MOVE_TELEPORT_ACK`.
use std::io;
use crate::wire::{read_cstring, read_u32_le};
/// Body of `CMSG_AREATRIGGER` (opcode `0xB4`/180 — VERIFIED vmangos `Opcodes_1_12_1.h:183`, and the
/// reference builds exactly this: `0x5e2110` writes `0xb4` then the record's first field, its id).
/// One `u32`: the `AreaTrigger.dbc` id.
///
/// The server re-checks the claim before acting — `HandleAreaTriggerOpcode`
/// (`Handlers/MiscHandler.cpp:622`) drops it unless the id is a real DBC row and the player is
/// inside the volume with 5 yd of slop — so a stale or invented id costs nothing but is never
/// obeyed. It is also dropped outright while taxi-flying.
/// `CMSG_AREATRIGGER` (180, as `0x5e2110` sends it): the `u32` `AreaTrigger.dbc` id. The server
/// obeys only a real row with the player inside the volume (5 yd slop), never while taxi-flying
/// (`Handlers/MiscHandler.cpp:622`).
pub fn area_trigger(trigger_id: u32) -> Vec<u8> {
trigger_id.to_le_bytes().to_vec()
}
/// Read `SMSG_AREA_TRIGGER_MESSAGE` (VERIFIED vmangos `WorldSession::SendAreaTriggerMessage`,
/// `Server/WorldSession.cpp:882-898`: `u32 length` then the cstring): server-composed text
/// explaining why a trigger did *not* fire — "You must be at least level 58 to enter…", "You
/// cannot enter %s while in ghost form.", a battleground's level/faction refusal.
///
/// The length prefix counts the cstring's bytes **including** its terminator; it is redundant with
/// the string itself, and is read and discarded (the reference does the same — its `0x2b8` arm
/// reads a u32, then a string, then displays the string).
/// `SMSG_AREA_TRIGGER_MESSAGE` (`0x2b8`, `Server/WorldSession.cpp:882-898`): why a trigger did
/// not fire, as a `u32` length (terminator included), which the reference ignores, then the text.
pub(super) fn read_area_trigger_message(r: &mut &[u8]) -> io::Result<String> {
let _length = read_u32_le(r)?;
read_cstring(r)
@@ -46,7 +29,6 @@ mod tests {
assert_eq!(area_trigger(542), 542u32.to_le_bytes().to_vec());
}
/// The length prefix is skipped and the text read whole — built the way the server builds it.
#[test]
fn message_reads_past_its_length_prefix() {
let text = "You must be at least level 58 to enter.";
+41 -131
View File
@@ -1,103 +1,53 @@
//! Melee auto-attack messages — the attack start/stop edges, the per-swing damage report, and the
//! creature-aggro notice. Split out of `messages/spells.rs` (decision 0640); mirrored by
//! `world::writer::attack`.
//!
//! `SMSG_ATTACKERSTATEUPDATE` is the melee twin of [`super::combat_log`]'s spell reports: a *swing*
//! reports itself here (fired exactly once per weapon-timer cycle, independently per hand — which is
//! what makes it the animation driver, decision 0073), a *spell* reports itself there.
//! `SMSG_AI_REACTION` rides along because it is the same moment from the creature's side: vmangos
//! broadcasts it from `Unit::Attack`, i.e. the instant a creature decides to swing (decision 0277).
//! Melee auto-attack messages: the attack start and stop edges, the per-swing report, and the
//! creature-aggro notice, which vmangos sends from `Unit::Attack`.
use std::io::{self, Read};
use crate::wire::{read_f32_le, read_packed_guid, read_u32_le, read_u64_le, read_u8};
/// Read `SMSG_ATTACKSTART` (vmangos `AttackStart::AppendBodyTo`): two full `u64` guids.
/// `SMSG_ATTACKSTART` (vmangos `AttackStart::AppendBodyTo`): two full `u64` guids.
pub(super) fn read_attack_start(r: &mut impl Read) -> io::Result<(u64, u64)> {
Ok((read_u64_le(r)?, read_u64_le(r)?))
}
/// One decoded `SMSG_ATTACKERSTATEUPDATE` — a completed melee swing (vmangos
/// `Unit::SendAttackStateUpdate`, `Unit.cpp:4572-4605`; fired **exactly once per weapon-timer
/// cycle**, independently per hand — the real client plays one attacker swing animation per packet,
/// wow-re `combat-swing-anim.md`, decision 0073). The per-school sub-damage split collapses to the
/// packet's own `TotalDamage` plus the summed `absorb` (decision 0137 phase 2's floating combat
/// text feed); `hit_info` bit `0x4` marks an **offhand** swing (the anim selector keys on it), bit
/// `0x10000` suppresses the swing anim.
/// `SMSG_ATTACKERSTATEUPDATE`: one melee swing (`Unit.cpp:4572-4605`), sent once per weapon-timer
/// cycle per hand; the reference plays one swing animation per packet. `hit_info` bit `0x4` marks
/// an offhand swing, bit `0x10000` suppresses the swing animation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AttackerState {
pub attacker: u64,
pub victim: u64,
pub hit_info: u32,
/// `TotalDamage` — the swing's damage before the sub-damage split.
/// `TotalDamage`: the whole swing, before the sub-damage split.
pub damage: u32,
/// `TargetState` (vmangos `VictimState`): 1 hit · 2 dodge · 3 parry · 4 interrupt · 5 blocks ….
/// A defended outcome (dodge/parry/block/deflect) plays a dedicated victim defense clip at
/// the swing's `$CPP` keyframe (decision 0279, correcting 0073's "never a body animation");
/// landed hits flinch/bleed; the rest is sound + floating text.
/// `TargetState` (vmangos `VictimState`): 1 hit, 2 dodge, 3 parry, 4 interrupt, 5 blocks, …
pub victim_state: u32,
/// Sum of the per-sub-damage `absorb` fields (vmangos writes exactly one sub-damage block in
/// practice; summed faithfully in case more than one ever rides the wire).
/// The sub-damage blocks' `absorb`, summed (vmangos writes one block).
pub absorb: u32,
/// Sum of the per-sub-damage `resist` fields — the partial-resist trailer's amount
/// (decision 0580's center-text fold-back).
/// The sub-damage blocks' `resist`, summed: the partial-resist amount.
pub resist: i32,
/// `BlockedAmount` — the trailing blocked-damage word.
/// `BlockedAmount`, the trailing blocked-damage word.
pub blocked: u32,
/// `meleeSpellId` (`rec+0xac`) — **a boolean in practice, not an id.** Both of the client's
/// readers only compare it to zero (`0x625e40`, and `0x6246ca`'s `xor/cmp/sbb/neg` feeding
/// the swing-animation selector), so in this packet it means "this swing came from a spell".
/// Nonzero for an on-next-swing ability — Heroic Strike is the one the field was first
/// noticed on.
/// `meleeSpellId` (`rec+0xac`), nonzero for an on-next-swing ability such as Heroic Strike; the
/// reference only ever compares it to zero (`0x625e40`, `0x6246ca`).
pub melee_spell_id: u32,
/// The **first** sub-damage block's school (`SpellSchools`: 0 physical … 6 arcane; vmangos
/// writes `GetFirstSchoolInMask(subDamage->damageSchoolMask)`), `0` when the swing carried no
/// sub-damage at all.
///
/// It exists for the combat log's wording: a non-physical swing takes the `…SCHOOL` template
/// ("You hit X for 5 fire damage.") and physical takes the plain one. The floating text has
/// never needed it, which is why the field was read and dropped until B297.
/// The first sub-damage block's school (0 physical to 6 arcane, `GetFirstSchoolInMask`), 0
/// with no blocks; a non-physical swing takes the combat log's `…SCHOOL` wording.
pub school: u8,
}
impl AttackerState {
/// `0x625e40` — **whether this swing is displayed at all**, in either of the two places that
/// display one. Twenty-six bytes:
///
/// ```text
/// 625e40 mov edx, [ecx+0xac] ; meleeSpellId
/// 625e46 test edx, edx
/// 625e48 mov eax, 1
/// 625e4d je 0x625e59 ; id == 0 -> TRUE
/// 625e4f cmp [ecx+0xa4], eax ; victimState vs 1
/// 625e55 je 0x625e59 ; state == 1 -> TRUE
/// 625e57 xor eax, eax ; otherwise -> FALSE
/// ```
///
/// **Two callers, and they are the two display paths**: `0x629b3e` in `0x629b10`, which skips
/// the whole chat dispatcher `0x629b60` — no line, no trailer, no `COMBAT_TEXT_UPDATE` — and
/// `0x62440d`, the first thing the **floating-combat-text builder** `0x6243e0` does, jumping
/// straight to its own epilogue. So a Heroic Strike that is dodged or parried produces neither
/// a combat-log line nor a floating number.
///
/// **Untouched by it:** the swing animation (`0x6246a0`), the swing timers and autorepeat, the
/// auto-target arm, the tutorial trigger, and everything `0x624530` does after its call to
/// `0x6243e0` — the wound flinch, the blood spurt and the whole victimState-keyed sound block.
/// A suppressed swing still swings, still connects, still sounds.
///
/// A landing ability (`victimState == 1`) is unaffected whatever its spell id, which is why
/// ordinary Heroic Strike damage reads normally.
/// `0x625e40`: whether the swing gets a combat-log line (`0x629b10`) and a floating number
/// (`0x6243e0`). A dodged or parried Heroic Strike gets neither, yet still animates and sounds.
#[must_use]
pub fn displayed(&self) -> bool {
self.melee_spell_id == 0 || self.victim_state == 1
}
}
/// Read `SMSG_ATTACKERSTATEUPDATE` (byte-verified order, attacker **PackGUID first** — settled by
/// the wow-re §5 against the handler's downstream use, decision 0073): HitInfo · attacker PackGUID ·
/// victim PackGUID · TotalDamage · SubDamageCount + per-sub `{school u32, damage f32, damage u32,
/// absorb u32, resist i32}` · TargetState · two u32s (zero + "spell id, seen with heroic strike") ·
/// BlockedAmount. TargetState is then normalized the way the handler normalizes it (`0x625e35`).
/// `SMSG_ATTACKERSTATEUPDATE`: `HitInfo`, attacker then victim packed guid, `TotalDamage`, a `u8`
/// count of `{school u32, damage f32, damage u32, absorb u32, resist i32}`, `TargetState`,
/// `attackerState`, `meleeSpellId`, `BlockedAmount`.
pub(super) fn read_attacker_state(r: &mut impl Read) -> io::Result<AttackerState> {
let hit_info = read_u32_le(r)?;
let attacker = read_packed_guid(r)?;
@@ -108,9 +58,7 @@ pub(super) fn read_attacker_state(r: &mut impl Read) -> io::Result<AttackerState
let mut resist = 0i32;
let mut school = 0u8;
for i in 0..subs {
// damage f32 + damage u32 are folded into TotalDamage above; absorb/resist are summed. The
// school is kept from the FIRST block only — the wording takes one word, and vmangos's
// extra blocks are the off-school splits of the same swing.
// The school comes from the first block; later blocks split off other schools.
let block_school = read_u32_le(r)?;
if i == 0 {
school = u8::try_from(block_school).unwrap_or(0);
@@ -121,22 +69,13 @@ pub(super) fn read_attacker_state(r: &mut impl Read) -> io::Result<AttackerState
resist += read_u32_le(r)? as i32;
}
let victim_state = read_u32_le(r)?;
// vmangos names the tail `victimState · attackerState · meleeSpellId · blockedAmount`
// (`Server/Packets/Combat.cpp:81`), and the client's parse `0x625c60` stores them at
// `rec+0xa4`/`+0xa8`/`+0xac`/`+0xb0` in that order. `attackerState` is read by **nothing** in
// the whole combat TU; `meleeSpellId` is read twice and only ever compared to zero.
// The tail from `victimState` (`Server/Packets/Combat.cpp:81`) lands at `rec+0xa4`..`+0xb0`
// (`0x625c60`); the reference never reads `attackerState`.
let _attacker_state = read_u32_le(r)?;
let melee_spell_id = read_u32_le(r)?;
let blocked = read_u32_le(r)?;
// **The handler does not trust the wire's TargetState for a full block** (`0x625e35`): a swing
// that got no damage through while blocking something is forced to `VICTIMSTATE_BLOCKS`,
// upstream of every consumer, which is why the display dispatcher's flag table has a `1` at an
// index its own earlier arm makes unreachable. Normalized here, in the same position — before
// the chat line, the floating word and the sound each read it.
//
// Against vmangos it is belt-and-braces: `Unit.cpp`'s `MELEE_HIT_BLOCK` arm already sets
// `VICTIMSTATE_BLOCKS` when `blocked_amount >= subDamage[0].damage`. It costs three lines and
// removes the client's dependence on a server doing so.
// The reference handler (`0x625e35`) turns a swing with no damage and a block into
// `VICTIMSTATE_BLOCKS` (5) before any consumer reads it; vmangos already sends it that way.
let victim_state = if damage == 0 && blocked != 0 {
5
} else {
@@ -156,30 +95,21 @@ pub(super) fn read_attacker_state(r: &mut impl Read) -> io::Result<AttackerState
})
}
/// The server's refusal of a `CMSG_ATTACKSWING` — the melee auto-attack answer that is not
/// `SMSG_ATTACKSTART`. **Three variants for four opcodes, because that is what the client can
/// distinguish**: the reference dispatches `0x145`/`0x146`/`0x148`/`0x149` through `0x6255b0`'s
/// jump table `0x625aec`, and `0x148` DEADTARGET and `0x149` CANT_ATTACK land on **arm 4
/// (`0x625ab8`) verbatim** — one shared body, no reason byte, nothing to tell them apart with.
/// Collapsing them here is the fidelity fact in the type: a consumer cannot key on a difference
/// the real client never had.
///
/// Every body is empty; the arms read nothing.
/// The server's refusal of `CMSG_ATTACKSWING`; every body is empty. Four opcodes, three variants:
/// the reference (`0x6255b0`) handles `0x148` and `0x149` in one shared arm.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttackSwingError {
/// `SMSG_ATTACKSWING_NOTINRANGE` (`0x145`) — arm 2 `0x625a8a`: latch code **1**.
/// `SMSG_ATTACKSWING_NOTINRANGE` (`0x145`): the reference latches code 1 (`0x625a8a`).
NotInRange,
/// `SMSG_ATTACKSWING_BADFACING` (`0x146`) — arm 3 `0x625aa1`: latch code **2**.
/// `SMSG_ATTACKSWING_BADFACING` (`0x146`): the reference latches code 2 (`0x625aa1`).
BadFacing,
/// `SMSG_ATTACKSWING_DEADTARGET` (`0x148`) **or** `SMSG_ATTACKSWING_CANT_ATTACK` (`0x149`) —
/// both arm 4 `0x625ab8`, which raises **no message at all**: it resolves the active player
/// and calls StopAttack (`0x5ecac0`) on it, full stop. The name is deliberately the union of
/// the two: naming one of them would claim a distinction the client does not have.
/// `SMSG_ATTACKSWING_DEADTARGET` (`0x148`) or `SMSG_ATTACKSWING_CANT_ATTACK` (`0x149`): no
/// message, the reference only stops the attack (`0x625ab8`).
DeadOrUnattackable,
}
/// Read `SMSG_ATTACKSTOP` (vmangos `AttackStop::AppendBodyTo`): two **packed** guids + a `u32`
/// "victim is dead" word (dropped — death arrives through the descriptor seam).
/// `SMSG_ATTACKSTOP` (`AttackStop::AppendBodyTo`): two packed guids and a `u32` victim-is-dead
/// word, dropped because death arrives through the descriptor.
pub(super) fn read_attack_stop(r: &mut impl Read) -> io::Result<(u64, u64)> {
let attacker = read_packed_guid(r)?;
let victim = read_packed_guid(r)?;
@@ -187,21 +117,17 @@ pub(super) fn read_attack_stop(r: &mut impl Read) -> io::Result<(u64, u64)> {
Ok((attacker, victim))
}
/// Read `SMSG_AI_REACTION` → `(unit, reaction)` (vmangos `Creature::SendAIReaction`,
/// `Objects/Creature.cpp:2490-2498` → `WorldPackets::Misc::AiReaction::AppendBodyTo`,
/// `Server/Packets/Misc.cpp:445-449`): a raw (unpacked) `u64` guid + a `u32` reaction. Broadcast
/// with reaction 2 (HOSTILE) on every creature melee-attack start (`Unit::Attack`) and 0 (ALERT)
/// on stealth pre-aggro detection (`CreatureAI::TriggerAlertDirect`); 1/4 exist server-side but
/// are never sent (decision 0277).
/// `SMSG_AI_REACTION` (`Server/Packets/Misc.cpp:445-449`): a raw `u64` guid and a `u32` reaction,
/// 2 (hostile) when a creature starts a melee attack, 0 (alert) on stealth detection; 1 and 4 are
/// never sent.
pub(super) fn read_ai_reaction(r: &mut impl Read) -> io::Result<(u64, u32)> {
let unit = read_u64_le(r)?;
let reaction = read_u32_le(r)?;
Ok((unit, reaction))
}
/// Body of `CMSG_ATTACKSWING` (vmangos `AttackSwing::ReadFromWorldPacket`): one full `u64` victim
/// guid. Starts melee auto-attack; the server answers `SMSG_ATTACKSTART` (or an attack-swing error
/// packet). `CMSG_ATTACKSTOP`'s body is empty — no builder needed.
/// `CMSG_ATTACKSWING`: the full `u64` victim guid, answered by `SMSG_ATTACKSTART` or an
/// [`AttackSwingError`]. `CMSG_ATTACKSTOP` has an empty body.
pub fn attack_swing(guid: u64) -> Vec<u8> {
guid.to_le_bytes().to_vec()
}
@@ -224,19 +150,12 @@ mod tests {
b.extend_from_slice(&0u32.to_le_bytes()); // absorb
b.extend_from_slice(&0u32.to_le_bytes()); // resist
b.extend_from_slice(&victim_state.to_le_bytes()); // TargetState
b.extend_from_slice(&0u32.to_le_bytes()); // zero
b.extend_from_slice(&0u32.to_le_bytes()); // spell id
b.extend_from_slice(&0u32.to_le_bytes()); // attackerState
b.extend_from_slice(&0u32.to_le_bytes()); // meleeSpellId
b.extend_from_slice(&blocked.to_le_bytes()); // BlockedAmount
b
}
/// `0x625e40` — a swing that came from a spell and did not land plainly is not displayed at
/// all, in either of the two places a swing is displayed.
///
/// Heroic Strike is the case: it rides `SMSG_ATTACKERSTATEUPDATE` with a nonzero
/// `meleeSpellId`, so when it lands (`victimState == 1`) it reads exactly like a white hit,
/// and when it is dodged or parried the reference prints **no combat-log line and floats no
/// word**. An ordinary swing carries id 0 and is never touched by this.
#[test]
fn a_swing_from_a_spell_that_did_not_land_is_not_displayed() {
let with_id = |state: u32| {
@@ -245,32 +164,23 @@ mod tests {
b[at..at + 4].copy_from_slice(&78u32.to_le_bytes()); // Heroic Strike
read_attacker_state(&mut b.as_slice()).expect("parse")
};
// Dodged, parried, missed outright: suppressed.
// Every state but a hit (1) is suppressed.
for state in [0, 2, 3, 4] {
assert!(
!with_id(state).displayed(),
"spell swing, victimState {state}"
);
}
// Landed: displayed like any other hit.
assert!(
with_id(1).displayed(),
"a landing Heroic Strike still shows"
);
// A plain swing carries id 0 and is displayed whatever the victim did.
for state in [0, 1, 2, 3, 4] {
let s = read_attacker_state(&mut packet(0, state, 0).as_slice()).expect("parse");
assert!(s.displayed(), "plain swing, victimState {state}");
}
}
/// `0x625e35` — a swing that got nothing through while blocking something **is** a block, and
/// the handler says so before any consumer reads the field.
///
/// It matters because the display dispatcher's `victimState == 5` arm is what words `VSBLOCK`,
/// and its else-arm emits no line at all for the `1` the wire might otherwise carry — so
/// without this normalization a fully-blocked swing could go silent instead of reading
/// "X blocks your attack."
#[test]
fn a_fully_blocked_swing_is_normalized_to_victimstate_blocks() {
let s = read_attacker_state(&mut packet(0, 1, 90).as_slice()).expect("parse");
+72 -233
View File
@@ -1,144 +1,89 @@
//! The auction house arc's wire layer (decision 1511 phase P0): the auctioneer hello, the three
//! list pages (browse / your bids / your auctions), the sell/bid/cancel verbs, the one command
//! verdict, and the three notifications (vmangos `Handlers/AuctionHouseHandler.cpp`,
//! `AuctionHouse/AuctionHouseMgr.{h,cpp}`, `Server/Packets/AuctionHouse.{h,cpp}`,
//! `Opcodes_1_12_1.h`, VERIFIED — read twice independently at decision time and re-read against
//! the source while writing this module).
//!
//! **There is no `*_LIST_PENDING_SALES` opcode on 5875** — the symbol does not exist anywhere in
//! vmangos's opcode tables (0x264/0x265 sit between `SMSG_SPELLORDAMAGE_IMMUNE` and
//! `SMSG_SET_FLAT_SPELL_MODIFIER` with no gap for one), and the matching mail answer
//! `AUCTION_SALE_PENDING(6)` is dead in the tree. The same shape of recorded fact as `mail`'s
//! missing `SMSG_SHOW_MAILBOX`: a pane that exists in later clients has no wire here to build.
//!
//! Two things hold across every packet below. **Every guid is a plain 8-byte LE `u64`, never a
//! packed guid** — vmangos's `ObjectGuid` stream operators write and read `GetRawValue()` whole
//! (`ObjectGuid.cpp`), and `PackedGuid` never appears on the auction path. And **the auctioneer
//! guid rides at the head of every CMSG** because the server re-validates the 5 yd interact
//! distance on each one independently (`GetCheckedAuctionHouseForAuctioneer`): the hello is not a
//! session token.
//! The auction-house messages (vmangos `Server/Packets/AuctionHouse.cpp`): hello, the three list
//! pages, sell, bid, cancel, the command result and three notifications; 5875 has no
//! pending-sales opcode. Every guid is a plain `u64`, and the auctioneer guid heads every CMSG
//! because the server re-checks the 5 yd interact distance on each one.
use std::io;
use crate::wire::{capacity_hint, read_i32_le, read_u32_le, read_u64_le};
/// `SMSG_AUCTION_COMMAND_RESULT`'s `action` field — which verb the verdict answers (VERIFIED
/// vmangos `AuctionHouseMgr.h:53-58`, `AuctionAction`).
/// `SMSG_AUCTION_COMMAND_RESULT`'s `action` (`AuctionAction`, `AuctionHouseMgr.h:53-58`).
pub mod auction_action {
/// Answers [`super::auction_sell_item`] — a new listing was created.
/// Answers [`super::auction_sell_item`].
pub const STARTED: u32 = 0;
/// Answers [`super::auction_remove_item`] — a listing was cancelled.
/// Answers [`super::auction_remove_item`].
pub const REMOVED: u32 = 1;
/// Answers [`super::auction_place_bid`]. A *buyout* rides this action too: buyout is inferred
/// server-side (`price >= buyout && buyout != 0`), never flagged on the wire.
/// Answers [`super::auction_place_bid`], buyouts too (inferred, never flagged).
pub const BID_PLACED: u32 = 2;
}
/// `SMSG_AUCTION_COMMAND_RESULT`'s `error` field (VERIFIED vmangos `AuctionHouseMgr.h:40-51`,
/// `AuctionError`). **6, 8, 9, 11 and 12 have no name**: vmangos has no enumerator for them and
/// never sends them, and the real client's `ERR_AUCTION_*` table falls through to a generic
/// failure branch for anything it doesn't recognize — so an unknown code here is a generic
/// refusal to report, not a parse failure.
/// `SMSG_AUCTION_COMMAND_RESULT`'s `error` (`AuctionError`, `AuctionHouseMgr.h:40-51`). vmangos
/// never sends 6, 8, 9, 11 or 12; the reference shows any unknown code as a generic failure.
pub mod auction_error {
/// Success — read [`super::auction_action`] to know *what* succeeded.
pub const OK: u32 = 0;
/// The only error carrying an [`super::AuctionCommandTail::Inventory`] tail: an
/// `InventoryResult` (`EQUIP_ERR_*`) code, the same table the bag/equip refusals use.
/// Carries an `EQUIP_ERR_*` tail ([`super::AuctionCommandTail::Inventory`]).
pub const INVENTORY: u32 = 1;
/// `ERR_AUCTION_DATABASE_ERROR` — also vmangos's catch-all refusal (a bad `etime` lands here).
/// `ERR_AUCTION_DATABASE_ERROR`, also vmangos's catch-all (a bad `etime` lands here).
pub const DATABASE: u32 = 2;
pub const NOT_ENOUGH_MONEY: u32 = 3;
pub const ITEM_NOT_FOUND: u32 = 4;
/// Someone outbid us mid-flight; carries the [`super::AuctionCommandTail::HigherBid`] tail.
/// Outbid in flight; carries the [`super::AuctionCommandTail::HigherBid`] tail.
pub const HIGHER_BID: u32 = 5;
/// The bid did not clear the minimum increment (5% of the current bid, floored, min 1 copper).
pub const BID_INCREMENT: u32 = 7;
/// You cannot bid on your own auction.
pub const BID_OWN: u32 = 10;
pub const RESTRICTED_ACCOUNT: u32 = 13;
}
/// The "don't filter" sentinels [`auction_list_items`]'s browse filters use (VERIFIED vmangos
/// `AuctionHouseObject::BuildListAuctionItems`, which compares each filter against exactly these
/// before applying it — and takes a whole-table fast path when *all* of them are unset).
/// The no-filter values of [`auction_list_items`] (`AuctionHouseObject::BuildListAuctionItems`,
/// which takes a whole-table fast path when all are unset).
pub mod auction_filter {
/// `slot_id` / `main_category` / `sub_category` / `quality`: `0xFFFF_FFFF` = no filter.
/// For `slot_id`, `main_category`, `sub_category` and `quality`.
pub const ANY: u32 = 0xFFFF_FFFF;
/// `level_min` / `level_max`: `0` = no filter. The pair gates `RequiredLevel`, **not** item
/// level, and `level_max` is only consulted when `level_min` is set.
/// For `level_min`/`level_max`, which gate `RequiredLevel`; `level_max` needs `level_min`.
pub const ANY_LEVEL: u8 = 0;
/// `usable`: `0` = no filter. Nonzero drops everything `CanUseItem` refuses (and recipes we
/// already know).
/// For `usable`; nonzero drops what `CanUseItem` refuses and recipes already known.
pub const ANY_USABILITY: u8 = 0;
}
/// The three `etime` values `CMSG_AUCTION_SELL_ITEM` may carry, in **minutes** (VERIFIED vmangos
/// `HandleAuctionSellItem`, `AuctionHouseHandler.cpp:281-296`: `etime * MINUTE` is switched
/// against 1/4/12 × `MIN_AUCTION_TIME` — and `MIN_AUCTION_TIME` is `2*HOUR`). Anything else is
/// refused with [`auction_error::DATABASE`]. The same 1/4/12 multiple also scales the deposit.
/// The three `etime` values of `CMSG_AUCTION_SELL_ITEM`, in minutes: 1, 4 and 12 times the 2 h
/// `MIN_AUCTION_TIME` (`AuctionHouseHandler.cpp:281-296`); the multiple also scales the deposit.
pub mod auction_duration {
pub const SHORT_MINUTES: u32 = 120;
pub const MEDIUM_MINUTES: u32 = 480;
pub const LONG_MINUTES: u32 = 1440;
}
/// Records per list page. Every server list builder stops at 50 (`count < 50`), so paging is
/// `list_from = page * AUCTION_PAGE_SIZE` and the pager's "of N" comes from the list result's
/// trailing `total_count`, which is counted *before* this cap.
/// Records per list page (every server list builder stops at 50); `list_from` steps by it.
pub const AUCTION_PAGE_SIZE: u32 = 50;
/// One list-result record's fixed width: **64** bytes — twelve 4-byte fields plus the two 8-byte
/// guids (`owner_guid`, `bidder_guid`). Decision 1511's prose says 60; that is an arithmetic slip
/// in the summary, not in its field list, which sums to 64 (VERIFIED by re-reading the single
/// producer, `AuctionEntry::BuildAuctionInfo`, `AuctionHouseMgr.cpp:811-842`, whose writes are
/// `7×u32, u64, 4×u32, u64, u32`). Load-bearing rather than decorative — it is the bound
/// [`read_auction_list_result`] uses to survive a body shorter than its own `count` claims.
/// A list record's fixed width: `7×u32, u64, 4×u32, u64, u32` (`AuctionEntry::BuildAuctionInfo`,
/// `AuctionHouseMgr.cpp:811-842`); it bounds the read of a body shorter than its `count`.
pub const AUCTION_RECORD_BYTES: usize = 64;
/// One row of any of the three auction list results. `SMSG_AUCTION_LIST_RESULT`,
/// `SMSG_AUCTION_OWNER_LIST_RESULT` and `SMSG_AUCTION_BIDDER_LIST_RESULT` share **one** record
/// layout and one frame — all three call the same producer (VERIFIED vmangos
/// `AuctionEntry::BuildAuctionInfo`). Fixed width ([`AUCTION_RECORD_BYTES`]), no cstrings: the
/// item's *name* is not on the wire at all, and comes from the ordinary item-template cache.
/// One row of any of the three list results; the item name is not on the wire.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuctionListEntry {
/// The auction's server id — the handle [`auction_place_bid`]/[`auction_remove_item`] name.
pub auction_id: u32,
pub item_entry: u32,
/// `PERM_ENCHANTMENT_SLOT` only. 1.12 carries no other enchant information on an auction row:
/// vmangos's own `[-ZERO] no other infos about enchantment in 1.12 [?]` comment sits on the
/// commented-out loop that would have written the durations and charges a later client reads.
/// The `PERM_ENCHANTMENT_SLOT` enchant, the only enchant a 1.12 auction row carries.
pub perm_enchant: u32,
/// **Signed** — negative is a random *suffix* id, positive a random *property* id.
/// `Item::GetItemRandomPropertyId` returns `int32` and the server casts it through `uint32`,
/// so the wire bytes are a two's-complement `i32` and reading them unsigned turns every
/// suffixed item ("of the Bear") into a ~4.29-billion property id that resolves to nothing.
/// Signed: negative is a random suffix id, positive a random property id.
pub random_property_id: i32,
pub suffix_factor: u32,
/// The stack size.
pub count: u32,
/// **Signed** — a negative charge count means "N charges, then destroy the item".
/// `Item::GetSpellCharges` returns `int32` and the server casts it through `uint32` (its own
/// comment on the line reads `item->charge FFFFFFF`).
/// Signed: negative means that many charges, then the item is destroyed (`GetSpellCharges`).
pub spell_charges: i32,
pub owner_guid: u64,
/// The seller's **opening** price, captured at listing time and constant for the auction's
/// life. NOT the current bid ([`Self::current_bid`]) and not the minimum next bid — vmangos's
/// own field comment calls it "maybe useless", but it *is* what the first bid must meet.
/// The seller's opening price, fixed for the auction's life: what the first bid must meet.
pub start_bid: u32,
/// How much above [`Self::current_bid`] the next bid must go: 5% of the current bid, floored,
/// minimum 1 copper. **`0` while nobody has bid** — the server writes
/// `bid ? GetAuctionOutBid() : 0`, so a UI must fall back to [`Self::start_bid`] for the
/// opening bid rather than reading a `0` here as "any amount will do".
/// The next bid's step over [`Self::current_bid`] (5%, floored, min 1); 0 before any bid.
pub min_increment: u32,
/// `0` = no buyout. Buyout is *inferred*, never flagged: a bid of `>= buyout` on a nonzero
/// buyout is a buyout.
/// 0 for none; a bid of at least a nonzero buyout is a buyout.
pub buyout: u32,
/// **Milliseconds** remaining, and **unclamped**. The server writes
/// `(expireTime - now) * IN_MILLISECONDS` with no floor and only sweeps expiry on its own
/// timer, so an expired-but-still-listed auction wraps to a value near 4.29 billion. Read
/// faithfully here — a consumer must treat an implausibly large value as **expired**, not as
/// "very long".
/// Milliseconds left, unclamped: an expired auction not yet swept wraps to near 2^32, which a
/// consumer must read as expired.
pub time_left_ms: u32,
/// `0` = nobody has bid.
pub bidder_guid: u64,
@@ -146,22 +91,17 @@ pub struct AuctionListEntry {
pub current_bid: u32,
}
/// The conditional tail `SMSG_AUCTION_COMMAND_RESULT` carries after its three dwords — which
/// shape follows is decided by `error` first and `action` only inside the `OK` arm (VERIFIED
/// vmangos `WorldSession::SendAuctionCommandResult`, `AuctionHouseHandler.cpp:70-96`, whose
/// `switch` is on the error code).
/// The tail after `SMSG_AUCTION_COMMAND_RESULT`'s three dwords, chosen by `error`, then by
/// `action` only on success (`AuctionHouseHandler.cpp:70-96`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuctionCommandTail {
/// Every other (error, action) pair — the three dwords are the whole packet. This is the
/// common case: a plain `OK` + `STARTED`/`REMOVED`, and every named error but two.
/// Every other pair: the three dwords are the whole packet.
Empty,
/// [`auction_error::OK`] **and** [`auction_action::BID_PLACED`]: the outbid step the *next*
/// bid must clear, recomputed from the bid just accepted.
/// A placed bid: the step the next bid must clear, from the bid just accepted.
BidPlaced { new_min_outbid: u32 },
/// [`auction_error::INVENTORY`]: an `InventoryResult` (`EQUIP_ERR_*`) code.
Inventory { result: u32 },
/// [`auction_error::HIGHER_BID`]: we were outbid between reading the page and bidding — who
/// holds it now, at what bid, and the step the next bid must clear.
/// [`auction_error::HIGHER_BID`]: who outbid us, at what bid, and the next step to clear.
HigherBid {
new_bidder_guid: u64,
new_bid: u32,
@@ -169,67 +109,46 @@ pub enum AuctionCommandTail {
},
}
/// `SMSG_AUCTION_BIDDER_NOTIFICATION` — "you won" / "you were outbid", pushed to the *bidder*
/// (VERIFIED vmangos `AuctionBidderNotification::AppendBodyTo`,
/// `Server/Packets/AuctionHouse.cpp:78-87`). Deliberately **not** the same shape as
/// [`AuctionOwnerNotification`]: this one leads with the house id and puts the guid third, the
/// owner one has no house id and puts the guid fourth. Two structs and two readers, on purpose —
/// sharing either would silently mis-decode one of them.
/// `SMSG_AUCTION_BIDDER_NOTIFICATION`, won or outbid (`Server/Packets/AuctionHouse.cpp:78-87`);
/// it leads with the house id and has the guid third, unlike the owner notice.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuctionBidderNotification {
/// `AuctionHouse.dbc` row (1..7) — the same id [`read_auction_hello`] answers with.
/// `AuctionHouse.dbc` row (1..7), as in the hello reply.
pub house_id: u32,
pub auction_id: u32,
/// The bidder the notice is *about* — us.
pub bidder_guid: u64,
/// **`0` means WON**, not "no bid" (vmangos writes `won ? 0 : auction->bid`, and the real
/// client branches `ERR_AUCTION_WON_S` vs `ERR_AUCTION_OUTBID_S` on exactly this). Nonzero is
/// the bid that beat us.
/// 0 means won (`won ? 0 : auction->bid`); otherwise the bid that beat us.
pub bid_or_zero: u32,
/// The step the next bid must clear (`GetAuctionOutBid()`).
pub out_bid: u32,
pub item_entry: u32,
/// **Signed**, as on [`AuctionListEntry::random_property_id`] — the same
/// `Item::GetItemRandomPropertyId()` value, cast through vmangos's `uint32` packet field.
/// `0` when the item is gone.
/// Signed, as on [`AuctionListEntry::random_property_id`]; 0 when the item is gone.
pub random_property_id: i32,
}
/// `SMSG_AUCTION_OWNER_NOTIFICATION` — "your auction sold" / a bid landed, pushed to the *seller*
/// (VERIFIED vmangos `AuctionOwnerNotification::AppendBodyTo`,
/// `Server/Packets/AuctionHouse.cpp:89-97`). **No `house_id`, and the guid sits fourth** — see
/// [`AuctionBidderNotification`] for why the two are kept apart.
/// `SMSG_AUCTION_OWNER_NOTIFICATION`, a sale or a new bid, to the seller
/// (`Server/Packets/AuctionHouse.cpp:89-97`): no house id, and the guid fourth.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuctionOwnerNotification {
pub auction_id: u32,
/// The current bid.
pub bid: u32,
/// The step the next bid must clear (`GetAuctionOutBid()`).
pub out_bid: u32,
/// Who bid. **All-zero on a sale**: vmangos fills this field only when the auction has *not*
/// sold yet (`if (!sold)`), so a zero guid here is the "sold" signal, not a missing bidder.
/// Who bid; zero on a sale, since vmangos fills it only `if (!sold)`.
pub bidder_guid: u64,
pub item_entry: u32,
/// **Signed**, as on [`AuctionListEntry::random_property_id`]. `0` when the item is gone.
/// Signed, as on [`AuctionListEntry::random_property_id`]; 0 when the item is gone.
pub random_property_id: i32,
}
/// Body of `MSG_AUCTION_HELLO` (VERIFIED vmangos `AuctionHello::ReadFromWorldPacket`,
/// `Server/Packets/AuctionHouse.cpp:3-6`): one full 8-byte auctioneer guid. **Same opcode both
/// directions** — the reply ([`read_auction_hello`]) echoes the guid and adds the house id, and it
/// is the *reply* that opens the window, not our send.
/// `MSG_AUCTION_HELLO` (`Server/Packets/AuctionHouse.cpp:3-6`): the auctioneer guid. The same
/// opcode comes back with the house id ([`read_auction_hello`]), and that reply opens the window.
pub fn auction_hello(auctioneer: u64) -> Vec<u8> {
auctioneer.to_le_bytes().to_vec()
}
/// Body of `CMSG_AUCTION_SELL_ITEM` (VERIFIED vmangos `AuctionSellItem::ReadFromWorldPacket`,
/// `Server/Packets/AuctionHouse.cpp:42-49`): `u64 auctioneer, u64 itemGuid, u32 bid, u32 buyout,
/// u32 etime`.
///
/// `etime_minutes` may only be one of [`auction_duration`]'s three values; anything else is
/// refused with [`auction_error::DATABASE`]. A `bid` or `etime_minutes` of `0` is answered with
/// **nothing at all** (`HandleAuctionSellItem`'s silent `return`), as is a bid the player cannot
/// pay the deposit for — a UI that blocks waiting for an ack will hang.
/// `CMSG_AUCTION_SELL_ITEM`: an `etime_minutes` outside [`auction_duration`] gets `DATABASE`; a
/// zero `bid` or `etime`, or an unaffordable deposit, gets no answer (`HandleAuctionSellItem`).
pub fn auction_sell_item(
auctioneer: u64,
item_guid: u64,
@@ -246,11 +165,8 @@ pub fn auction_sell_item(
body
}
/// Body of `CMSG_AUCTION_REMOVE_ITEM` (VERIFIED vmangos `AuctionRemoveItem::ReadFromWorldPacket`,
/// `Server/Packets/AuctionHouse.cpp:36-40`): `u64 auctioneer, u32 auctionId` — the seller's
/// cancel. Answered by `SMSG_AUCTION_COMMAND_RESULT` (action [`auction_action::REMOVED`]) *except*
/// when the auction already carries a bid and the seller cannot pay the 5% cut out of pocket, in
/// which case the server refuses **silently**.
/// `CMSG_AUCTION_REMOVE_ITEM` (`Server/Packets/AuctionHouse.cpp:36-40`), the seller's cancel. A
/// seller who cannot pay the 5% cut on an auction with a bid gets no answer at all.
pub fn auction_remove_item(auctioneer: u64, auction_id: u32) -> Vec<u8> {
let mut body = Vec::with_capacity(8 + 4);
body.extend_from_slice(&auctioneer.to_le_bytes());
@@ -258,24 +174,9 @@ pub fn auction_remove_item(auctioneer: u64, auction_id: u32) -> Vec<u8> {
body
}
/// Body of `CMSG_AUCTION_LIST_ITEMS` — the Browse tab's search (VERIFIED vmangos
/// `AuctionListItems::ReadFromWorldPacket`, `Server/Packets/AuctionHouse.cpp:51-63`): `u64
/// auctioneer, u32 listfrom, cstr searchedname, u8 levelmin, u8 levelmax, u32 auctionSlotID, u32
/// auctionMainCategory, u32 auctionSubCategory, u32 quality, u8 usable`.
///
/// **Ten fields and nothing else: no sort columns, no sort count, no trailing padding.** That is
/// the field an implementer invents, because later expansions do carry sort bytes here — on 5875
/// *filtering* is entirely server-side and *sorting* is entirely client-side. (Corroborated
/// beyond the reader: vmangos runs `VerifyPacketWasCorrectlyRead` after every `ClientPacket`
/// parse and error-logs a body it did not consume to the end, which a real 5875 client appending
/// sort bytes would trip on every search.)
///
/// The sentinels are in [`auction_filter`]: `0xFFFF_FFFF` for the four u32 filters, `0` for the
/// three small ones, and an empty `searched_name` is just the lone NUL. `quality` is a
/// **minimum**, not an equality (`proto->Quality < query.quality` is what drops a row).
/// `level_min`/`level_max` gate `RequiredLevel`, not item level. The name match is a
/// case-insensitive substring against the localized name *with the random-property suffix
/// appended*. `list_from` pages by [`AUCTION_PAGE_SIZE`].
/// `CMSG_AUCTION_LIST_ITEMS` (`Server/Packets/AuctionHouse.cpp:51-63`): ten fields, no sort
/// bytes (5875 sorts on the client). `quality` is a minimum; the name is a case-insensitive
/// substring match on the localized name plus its random-property suffix.
pub fn auction_list_items(
auctioneer: u64,
list_from: u32,
@@ -303,10 +204,8 @@ pub fn auction_list_items(
body
}
/// Body of `CMSG_AUCTION_LIST_OWNER_ITEMS` — the Auctions tab (VERIFIED vmangos
/// `AuctionListOwnerItems::ReadFromWorldPacket`, `Server/Packets/AuctionHouse.cpp:23-27`): `u64
/// auctioneer, u32 listfrom`. Answered by `SMSG_AUCTION_OWNER_LIST_RESULT`. The server matches on
/// the **account**, then filters to this character's guid.
/// `CMSG_AUCTION_LIST_OWNER_ITEMS`, the Auctions tab (`Server/Packets/AuctionHouse.cpp:23-27`);
/// the server matches the account, then filters to this character.
pub fn auction_list_owner_items(auctioneer: u64, list_from: u32) -> Vec<u8> {
let mut body = Vec::with_capacity(8 + 4);
body.extend_from_slice(&auctioneer.to_le_bytes());
@@ -314,10 +213,8 @@ pub fn auction_list_owner_items(auctioneer: u64, list_from: u32) -> Vec<u8> {
body
}
/// Body of `CMSG_AUCTION_PLACE_BID` (VERIFIED vmangos `AuctionPlaceBid::ReadFromWorldPacket`,
/// `Server/Packets/AuctionHouse.cpp:29-34`): `u64 auctioneer, u32 auctionId, u32 price`. The
/// buyout verb too — send `price = buyout` and the server takes it as one; there is no separate
/// opcode and no flag. A bid the player cannot afford is refused **silently**.
/// `CMSG_AUCTION_PLACE_BID` (`Server/Packets/AuctionHouse.cpp:29-34`), also the buyout: a `price`
/// equal to the buyout is one. An unaffordable bid gets no answer at all.
pub fn auction_place_bid(auctioneer: u64, auction_id: u32, price: u32) -> Vec<u8> {
let mut body = Vec::with_capacity(8 + 4 + 4);
body.extend_from_slice(&auctioneer.to_le_bytes());
@@ -326,15 +223,9 @@ pub fn auction_place_bid(auctioneer: u64, auction_id: u32, price: u32) -> Vec<u8
body
}
/// Body of `CMSG_AUCTION_LIST_BIDDER_ITEMS` — the Bid tab (VERIFIED vmangos
/// `AuctionListBidderItem::ReadFromWorldPacket`, `Server/Packets/AuctionHouse.cpp:8-21`): `u64
/// auctioneer, u32 listfrom, u32 count`, then `count` × `u32 auctionId`.
///
/// The trailing id list is a **refresh set**, not a filter: the server emits a record for each id
/// named here *first* and then appends every auction this player is the current bidder on, both
/// feeding one `count`/`total_count` — so an id that is also still ours appears twice in the page.
/// Send it empty (`&[]`) for a plain page fetch; the reference client fills it with the auctions
/// it has been outbid on so their rows re-sync.
/// `CMSG_AUCTION_LIST_BIDDER_ITEMS`, the Bid tab (`Server/Packets/AuctionHouse.cpp:8-21`). The
/// ids are a refresh set, not a filter: the server lists them first, then every auction we lead,
/// in one page, so an id in both appears twice. The reference sends those it was outbid on.
pub fn auction_list_bidder_items(auctioneer: u64, list_from: u32, auction_ids: &[u32]) -> Vec<u8> {
let mut body = Vec::with_capacity(8 + 4 + 4 + 4 * auction_ids.len());
body.extend_from_slice(&auctioneer.to_le_bytes());
@@ -346,21 +237,12 @@ pub fn auction_list_bidder_items(auctioneer: u64, list_from: u32, auction_ids: &
body
}
/// Read `MSG_AUCTION_HELLO`'s **reply** (VERIFIED vmangos `AuctionHelloResponse::AppendBodyTo`,
/// `Server/Packets/AuctionHouse.cpp:72-76`): `u64 auctioneerGuid, u32 houseId`. Nothing follows
/// the house id on 5875.
///
/// `house_id` indexes `AuctionHouse.dbc` (rows 1..7: the six faction houses, then Blackwater at 7)
/// and is the field the sell pane needs — the deposit and cut rates are read out of that row, so a
/// window that assumes a house shows the neutral 25% where it should show 5%. It is also the id
/// the auction invoice mails arrive under (`MailListEntry::sender_id` on a
/// [`super::mail_message_type::AUCTION`] mail).
/// The `MSG_AUCTION_HELLO` reply (`Server/Packets/AuctionHouse.cpp:72-76`): the guid and the
/// `AuctionHouse.dbc` house id (1..7), whose row sets the deposit and cut rates.
pub(super) fn read_auction_hello(r: &mut &[u8]) -> io::Result<(u64, u32)> {
Ok((read_u64_le(r)?, read_u32_le(r)?))
}
/// One [`AuctionListEntry`] — [`AUCTION_RECORD_BYTES`] of fixed-width fields, 14 reads, in
/// `AuctionEntry::BuildAuctionInfo`'s order.
fn read_auction_list_entry(r: &mut &[u8]) -> io::Result<AuctionListEntry> {
Ok(AuctionListEntry {
auction_id: read_u32_le(r)?,
@@ -380,39 +262,19 @@ fn read_auction_list_entry(r: &mut &[u8]) -> io::Result<AuctionListEntry> {
})
}
/// Read the body all three list results share — `SMSG_AUCTION_LIST_RESULT`,
/// `SMSG_AUCTION_OWNER_LIST_RESULT`, `SMSG_AUCTION_BIDDER_LIST_RESULT` (VERIFIED vmangos's async
/// `AuctionHouseClientQueryTask`, `AuctionHouseHandler.cpp:651-701`, which builds all three and
/// differs only in `SetOpcode`): `u32 count`, then `count` × [`AuctionListEntry`], then `u32
/// totalCount`.
///
/// **`total_count` rides at the very END, after the records.** The leading `count` is written as
/// a placeholder and patched with `put<uint32>` once the records are in; the total is appended
/// last. It is the match count *before* the [`AUCTION_PAGE_SIZE`] cap — the pager's "of N" — so
/// it is routinely larger than the number of records, and reading it in the wrong place silently
/// yields a record field.
///
/// Returns `(records, total_count)`.
/// The body the three list results share (`AuctionHouseHandler.cpp:651-701`): `u32 count`, the
/// records, then `u32 totalCount`, the match count before the page cap, at the very end.
pub(super) fn read_auction_list_result(r: &mut &[u8]) -> io::Result<(Vec<AuctionListEntry>, u32)> {
let count = read_u32_le(r)?;
// Bound the allocation by what the buffer could actually hold, not by a `count` we do not
// trust (see the loop guard below).
let mut auctions = Vec::with_capacity(capacity_hint(count, r.len() / AUCTION_RECORD_BYTES));
for _ in 0..count {
// `count` is an UPPER BOUND, not a record count. vmangos's *browse fast
// path* (the no-filter branch of `BuildListAuctionItems`, `AuctionHouseMgr.cpp:716-735`)
// does `BuildAuctionInfo(data); if ((++count) >= 50) break;` — discarding a `false`
// return. A stale auction whose item row is gone therefore writes ZERO bytes and still
// counts, so a real server sends `count = N` with N-k records. Stop cleanly on a short
// buffer and return what we did get: the layout is fixed-width, so a dropped record
// shifts nothing, and every record we read is intact.
// `count` is an upper bound: vmangos's no-filter path (`AuctionHouseMgr.cpp:716-735`)
// counts an auction whose item is gone but writes no bytes for it.
if r.len() < AUCTION_RECORD_BYTES {
break;
}
auctions.push(read_auction_list_entry(r)?);
}
// A body truncated past the last record has no trailing total either; fall back to what we
// actually read rather than failing the whole page over the pager's hint.
let total_count = if r.len() >= 4 {
read_u32_le(r)?
} else {
@@ -421,24 +283,14 @@ pub(super) fn read_auction_list_result(r: &mut &[u8]) -> io::Result<(Vec<Auction
Ok((auctions, total_count))
}
/// Read `SMSG_AUCTION_COMMAND_RESULT` (VERIFIED vmangos `WorldSession::SendAuctionCommandResult`,
/// `AuctionHouseHandler.cpp:70-96`): `u32 auctionId, u32 action ([`auction_action`]), u32 error
/// ([`auction_error`])`, then the conditional [`AuctionCommandTail`] the error selects.
///
/// `auction_id` is **`0`** whenever the server had no `AuctionEntry` to name — which is most of
/// the failure paths, so it is not a usable correlation handle on an error. The
/// remaining-length guard on each tail mirrors `read_send_mail_result`: a body that doesn't carry
/// the tail its fields predict decodes as [`AuctionCommandTail::Empty`] rather than erroring.
///
/// Returns `(auction_id, action, error, tail)`.
/// `SMSG_AUCTION_COMMAND_RESULT` (`AuctionHouseHandler.cpp:70-96`): `u32` auction id, action,
/// error, then the [`AuctionCommandTail`]; the auction id is 0 on most failures.
pub(super) fn read_auction_command_result(
r: &mut &[u8],
) -> io::Result<(u32, u32, u32, AuctionCommandTail)> {
let auction_id = read_u32_le(r)?;
let action = read_u32_le(r)?;
let error = read_u32_le(r)?;
// The server switches on `error`; only its OK arm then looks at `action`. Mirror that order —
// an OK with any other action carries no tail at all.
let tail = match error {
auction_error::OK if action == auction_action::BID_PLACED && !r.is_empty() => {
AuctionCommandTail::BidPlaced {
@@ -458,11 +310,6 @@ pub(super) fn read_auction_command_result(
Ok((auction_id, action, error, tail))
}
/// Read `SMSG_AUCTION_BIDDER_NOTIFICATION` (VERIFIED vmangos
/// `AuctionBidderNotification::AppendBodyTo`, `Server/Packets/AuctionHouse.cpp:78-87`): `u32
/// houseId, u32 auctionId, u64 bidderGuid, u32 bidOrZero, u32 outBid, u32 itemEntry, i32
/// randomPropertyId`. **Not interchangeable with [`read_auction_owner_notification`]** — see
/// [`AuctionBidderNotification`].
pub(super) fn read_auction_bidder_notification(
r: &mut &[u8],
) -> io::Result<AuctionBidderNotification> {
@@ -477,10 +324,6 @@ pub(super) fn read_auction_bidder_notification(
})
}
/// Read `SMSG_AUCTION_OWNER_NOTIFICATION` (VERIFIED vmangos
/// `AuctionOwnerNotification::AppendBodyTo`, `Server/Packets/AuctionHouse.cpp:89-97`): `u32
/// auctionId, u32 bid, u32 outBid, u64 bidderGuid, u32 itemEntry, i32 randomPropertyId` — a
/// different field order from the bidder notification, and no house id.
pub(super) fn read_auction_owner_notification(
r: &mut &[u8],
) -> io::Result<AuctionOwnerNotification> {
@@ -494,12 +337,8 @@ pub(super) fn read_auction_owner_notification(
})
}
/// Read `SMSG_AUCTION_REMOVED_NOTIFICATION` (VERIFIED vmangos
/// `AuctionRemovedNotification::AppendBodyTo`, `Server/Packets/AuctionHouse.cpp:65-70`): `u32
/// auctionId, u32 itemEntry, i32 randomPropertyId` — pushed to a *bidder* whose auction the seller
/// cancelled (the real client shows `ERR_AUCTION_REMOVED_S`).
///
/// Returns `(auction_id, item_entry, random_property_id)`.
/// `SMSG_AUCTION_REMOVED_NOTIFICATION` (`Server/Packets/AuctionHouse.cpp:65-70`), to a bidder
/// whose auction was cancelled: `(auction_id, item_entry, random_property_id)`.
pub(super) fn read_auction_removed_notification(r: &mut &[u8]) -> io::Result<(u32, u32, i32)> {
Ok((read_u32_le(r)?, read_u32_le(r)?, read_i32_le(r)?))
}
+14 -34
View File
@@ -1,19 +1,11 @@
//! Bank messages — the six-opcode wire (`CMSG_BANKER_ACTIVATE`/`SMSG_SHOW_BANK`/
//! `CMSG_BUY_BANK_SLOT`/`SMSG_BUY_BANK_SLOT_RESULT`/`CMSG_AUTOSTORE_BANK_ITEM`/
//! `CMSG_AUTOBANK_ITEM`, opcodes 439-442,642-643, vmangos `Opcodes_1_12_1.h`, VERIFIED). Bodies
//! from vmangos `Server/Packets/Npc.{h,cpp}` + `Server/Packets/Item.{h,cpp}`, all read this
//! session. Decision 0604 — the vault itself is already streamed through the ordinary player
//! descriptor (bank slots 39–62, bank bags 63–68); these opcodes only open the window, buy a bag
//! slot, and move items in/out.
//! Bank messages: open the window, buy a bag slot, move items in and out. The vault itself
//! streams in the player descriptor (bank slots 39-62, bank bags 63-68).
use std::io;
use crate::wire::{read_u32_le, read_u64_le};
/// `SMSG_BUY_BANK_SLOT_RESULT`'s `u32` reason (vmangos `Player.h:91-94`,
/// `BANK_SLOT_*` enum). Sent only on failure — success is signaled purely by the
/// `PLAYER_BYTES_2` bank-bag-count byte advancing + the coinage delta, both via `UPDATE_OBJECT`
/// (decision 0604).
/// `SMSG_BUY_BANK_SLOT_RESULT`'s reason (`BANK_SLOT_*`, vmangos `Player.h:91-94`).
pub mod bank_slot_result {
pub const FAILED_TOO_MANY: u32 = 0;
pub const INSUFFICIENT_FUNDS: u32 = 1;
@@ -21,49 +13,37 @@ pub mod bank_slot_result {
pub const OK: u32 = 3;
}
/// Body of `CMSG_BANKER_ACTIVATE` (vmangos `Npc.h:58-66`): one full 8-byte banker guid. Sent on a
/// right-click of a *pure* banker (bits 0-7 of `UNIT_NPC_FLAGS` clear, bit 8 `0x100` set) — a
/// gossip-flagged banker instead opens the gossip menu and the bank arrives through the
/// `GOSSIP_OPTION_BANKER` option's own `SMSG_SHOW_BANK` (decision 0604).
/// `CMSG_BANKER_ACTIVATE` (vmangos `Npc.h:58-66`), for a pure banker (`UNIT_NPC_FLAGS` bit 8
/// `0x100` set, bits 0-7 clear); a gossip banker opens its bank through the gossip option.
pub fn banker_activate(banker_guid: u64) -> Vec<u8> {
banker_guid.to_le_bytes().to_vec()
}
/// Body of `CMSG_BUY_BANK_SLOT` (vmangos `Item.h:157-163`): one full 8-byte banker guid. The
/// server buys slot `purchased_count + 1` itself (no slot index on the wire) — the client's job
/// is only knowing the next price, read from `BankBagSlotPrices.dbc` by `benilla-formats`
/// (decision 0604).
/// `CMSG_BUY_BANK_SLOT` (vmangos `Item.h:157-163`): no slot index, the server buys the next one;
/// its price is in `BankBagSlotPrices.dbc`.
pub fn buy_bank_slot(banker_guid: u64) -> Vec<u8> {
banker_guid.to_le_bytes().to_vec()
}
/// Body of `CMSG_AUTOBANK_ITEM` (vmangos `Item.h:108-116`): `u8 srcbag, u8 srcslot`. Deposit
/// only — moves a non-bank item into the first free bank slot. Handler
/// `HandleAutoBankItemOpcode` (`ItemHandler.cpp:874`).
/// `CMSG_AUTOBANK_ITEM` (`ItemHandler.cpp:874`): deposit only, into the first free bank slot.
pub fn autobank_item(bag: u8, slot: u8) -> Vec<u8> {
vec![bag, slot]
}
/// Body of `CMSG_AUTOSTORE_BANK_ITEM` (vmangos `Item.h:118-126`): `u8 srcbag, u8 srcslot`.
/// Direction-tolerant: vmangos's `HandleAutoStoreBankItemOpcode` (`ItemHandler.cpp:971`)
/// withdraws to inventory when `srcbag`/`srcslot` name a bank position, else deposits — the same
/// opcode drives both halves of the reference client's right-click auto-move while the bank
/// window is open.
/// `CMSG_AUTOSTORE_BANK_ITEM` (`ItemHandler.cpp:971`): withdraws from a bank position, deposits
/// from anywhere else; the reference's right-click auto-move uses it both ways.
pub fn autostore_bank_item(bag: u8, slot: u8) -> Vec<u8> {
vec![bag, slot]
}
/// Read `SMSG_SHOW_BANK` (vmangos `Npc.cpp:94`, `buffer << bankerGuid`): one full 8-byte banker
/// guid. Opens the bank window; the server also sends this **unprompted** for the gossip bank
/// option (`GOSSIP_OPTION_BANKER` → `SendShowBank`, vmangos `Player.cpp:12426`) — a handler must
/// not assume it only ever follows our own [`banker_activate`].
/// `SMSG_SHOW_BANK` (vmangos `Npc.cpp:94`): the banker guid. Also sent unprompted for the gossip
/// bank option (`Player.cpp:12426`), not only after [`banker_activate`].
pub(super) fn read_show_bank(r: &mut &[u8]) -> io::Result<u64> {
read_u64_le(r)
}
/// Read `SMSG_BUY_BANK_SLOT_RESULT` (vmangos `Item.cpp:137-140`): one `u32` [`bank_slot_result`]
/// code. vmangos sends this only on failure; a successful buy is silent, visible only as the
/// `PLAYER_BYTES_2` bank-bag-count byte advancing plus the coinage drop, both via `UPDATE_OBJECT`.
/// `SMSG_BUY_BANK_SLOT_RESULT` (vmangos `Item.cpp:137-140`), sent only on failure: a bought slot
/// shows only as the `PLAYER_BYTES_2` bank-bag count rising and the coinage falling.
pub(super) fn read_buy_bank_slot_result(r: &mut &[u8]) -> io::Result<u32> {
read_u32_le(r)
}
@@ -1,38 +1,31 @@
//! The battleground queue's wire (decision 1963; wow-re `staticpopup-dialog-bindings.md` §7):
//! the server's per-slot status and the port answer `AcceptBattlefieldPort` sends. Three queue
//! slots exist in the client (`0xb6e9d0`, stride `0x20`), addressed 1-based from Lua.
//! Battleground messages: queue status, the port answer, the scoreboard, the instance list and
//! teammate positions. The client keeps three queue slots (`0xb6e9d0`, stride `0x20`).
use std::io::{self, Read};
use crate::wire::{capacity_hint, read_u32_le, read_u8};
/// `SMSG_BATTLEFIELD_STATUS`, one slot's update (VERIFIED at the bytes, handler `0x4aa850`).
/// `SMSG_BATTLEFIELD_STATUS`, one slot's update (handler `0x4aa850`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BattlefieldStatus {
/// Which of the three slots, 0-based on the wire.
/// Which of the three slots, 0-based on the wire (1-based in Lua).
pub slot: u32,
/// The battleground's Map.dbc row id; zero clears the slot.
pub map_id: u32,
pub bracket: u8,
/// `+0x10` — the instance id, `GetBattlefieldStatus`'s third value (1963 parked this dword
/// as reader-less; `battlefield-verb-family.md` §2.2 found the reader, 1974).
/// The instance id (`+0x10`), `GetBattlefieldStatus`'s third value.
pub instance_id: u32,
pub status: u32,
/// Status 2: the port deadline's delta, in ms (`+0x14 = now + Δ`, `GetBattlefieldPortExpiration`).
/// Status 2: the port deadline in ms from now (`+0x14`, `GetBattlefieldPortExpiration`).
pub time_ms: Option<u32>,
/// Status 3 (`battlefield-verb-family.md` §4.2, 1972): `(Δ₁, Δ₂)` — the instance's expiration
/// delta (`[0xb6ebb8] = now + Δ₁`, `GetBattlefieldInstanceExpiration`) and its elapsed run time
/// (`[0xb6ebbc] = now − Δ₂`, `GetBattlefieldInstanceRunTime`).
/// Status 3: the expiry delta (`[0xb6ebb8] = now + Δ₁`, `GetBattlefieldInstanceExpiration`)
/// and the run time (`[0xb6ebbc] = now − Δ₂`, `GetBattlefieldInstanceRunTime`).
pub in_progress: Option<(u32, u32)>,
/// Status 1 (§4.2): `(estimated wait ms, raw; Δ waited)` — `[slot+0x18]` and
/// `[slot+0x1c] = now − Δ`, the `GetBattlefieldEstimatedWaitTime`/`GetBattlefieldTimeWaited` pair.
/// 1963's reader dropped this tail; the reference reads it (1972).
/// Status 1: the estimated wait in ms (`[slot+0x18]`, `GetBattlefieldEstimatedWaitTime`)
/// and the time waited (`[slot+0x1c] = now − Δ`, `GetBattlefieldTimeWaited`).
pub queued: Option<(u32, u32)>,
}
/// Parse `SMSG_BATTLEFIELD_STATUS`: `u32 slot`, `u32 mapId`, and — only for a non-zero map —
/// `u8 bracket`, `u32 instanceId`, `u32 status`, then the status-conditional tail: two `u32` for
/// status 1, one for status 2, two for status 3.
pub(super) fn read_battlefield_status(r: &mut impl Read) -> io::Result<BattlefieldStatus> {
let slot = read_u32_le(r)?;
let map_id = read_u32_le(r)?;
@@ -78,35 +71,31 @@ pub(super) fn read_battlefield_status(r: &mut impl Read) -> io::Result<Battlefie
})
}
/// One scoreboard row of `MSG_PVP_LOG_DATA` (VERIFIED at the bytes, handler `0x4aab30`; wow-re
/// `battlefield-verb-family.md` §2.3/§4.3, 1972). The client's 0x40-byte block, wire order.
/// One `MSG_PVP_LOG_DATA` scoreboard row (handler `0x4aab30`), a 0x40-byte block in the client.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PvpLogRow {
pub guid: u64,
/// Wire field 2 — read before the kills, stored at `+0x1c`.
/// Wire field 2, before the kills; stored at `+0x1c`.
pub rank: u32,
pub killing_blows: u32,
pub honorable_kills: u32,
pub deaths: u32,
pub honor_gained: u32,
/// The extra-stat dwords, at most eight stored — the client consumes and discards the rest.
/// The extra-stat dwords; the client keeps eight and reads past the rest.
pub stats: Vec<u32>,
}
/// `MSG_PVP_LOG_DATA` inbound: the whole scoreboard, rows in wire order (nothing here sorts).
/// `MSG_PVP_LOG_DATA` inbound: the whole scoreboard, rows in wire order.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct PvpLogData {
/// `u8 != 0` — the battleground has ended; `LeaveBattlefield` sends nothing until this is set.
/// The battleground has ended; `LeaveBattlefield` sends nothing until it has.
pub ended: bool,
/// Read only when `ended`: `0` = Horde, `1` = Alliance (`GetBattlefieldWinner`).
/// Present only when ended: 0 Horde, 1 Alliance (`GetBattlefieldWinner`).
pub winner: Option<u8>,
pub rows: Vec<PvpLogRow>,
}
/// Parse `MSG_PVP_LOG_DATA` (§4.3): `u8 ended`, `u8 winner` iff ended, `u32 count`, `count` rows of
/// `u64 guid, u32 rank, u32 kb, u32 hk, u32 deaths, u32 honor, u32 statCount, statCount × u32`.
/// The client stores no more than eight stats per row and clamps nothing else — a count past its
/// 80 blocks is its own anomaly (§10); ours keeps every row the wire carries.
/// Deviation: every row is kept; the reference has 80 row blocks and does not clamp the count.
pub(super) fn read_pvp_log_data(r: &mut impl Read) -> io::Result<PvpLogData> {
let ended = read_u8(r)? != 0;
let winner = if ended { Some(read_u8(r)?) } else { None };
@@ -144,30 +133,23 @@ pub(super) fn read_pvp_log_data(r: &mut impl Read) -> io::Result<PvpLogData> {
})
}
/// Body of `CMSG_LEAVE_BATTLEFIELD` (VERIFIED, `0x4abe60`): `u32 mapId` — the active slot's map,
/// or the literal 0 when no slot is active.
/// `CMSG_LEAVE_BATTLEFIELD` (`0x4abe60`): the active slot's map id, or 0 with none active.
pub fn leave_battlefield(map_id: u32) -> Vec<u8> {
map_id.to_le_bytes().to_vec()
}
/// `SMSG_BATTLEFIELD_LIST` (VERIFIED at the bytes, handler `0x4aa6c0`; wow-re
/// `battlefield-verb-family.md` §4.1, 1974): the instance list a battlemaster (or
/// `ShowBattlefieldList`) opens.
/// `SMSG_BATTLEFIELD_LIST` (handler `0x4aa6c0`): the instance list a battlemaster opens.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct BattlefieldList {
/// The battlemaster's guid — `0` when the list was opened without an NPC. Cached by the
/// client and read by `JoinBattlefield` to choose between the two join opcodes.
/// 0 when opened without an NPC; `JoinBattlefield` picks the join opcode by it.
pub battlemaster: u64,
/// The battleground's Map.dbc row id.
pub map_id: u32,
/// The level-bracket index; the client derives the bracket's min/max from it and the map row.
pub bracket: u8,
/// The instance ids, wire order — nothing sorts them; index 0 on the wire is instance 1 in Lua.
/// The instance ids in wire order; wire index 0 is instance 1 in Lua.
pub instances: Vec<u32>,
}
/// Parse `SMSG_BATTLEFIELD_LIST` (§4.1): `u64 battlemaster`, `u32 mapId`, `u8 bracket`,
/// `u32 count`, `count × u32 instanceId`.
pub(super) fn read_battlefield_list(r: &mut impl Read) -> io::Result<BattlefieldList> {
let battlemaster = crate::wire::read_u64_le(r)?;
let map_id = read_u32_le(r)?;
@@ -185,9 +167,8 @@ pub(super) fn read_battlefield_list(r: &mut impl Read) -> io::Result<Battlefield
})
}
/// One teammate's map position off `MSG_BATTLEGROUND_PLAYER_POSITIONS` (1980): raw world
/// floats — the client prefers the live object's position when it has one, and normalizes
/// either through the world-map projection under the active battleground's map.
/// One teammate's position from `MSG_BATTLEGROUND_PLAYER_POSITIONS`, in raw world coordinates;
/// the client prefers a live object's own position.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BattlefieldPosition {
pub guid: u64,
@@ -195,21 +176,18 @@ pub struct BattlefieldPosition {
pub y: f32,
}
/// `MSG_BATTLEGROUND_PLAYER_POSITIONS` inbound (VERIFIED at the bytes, handler `0x4aad40`; wow-re
/// `worldmap-arrow-and-positions.md` §3.1): the teammates not in the requester's group, then the
/// friendly flag carrier when there is one.
/// `MSG_BATTLEGROUND_PLAYER_POSITIONS` (handler `0x4aad40`): teammates outside our group, then
/// the friendly flag carrier if any.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct BattlefieldPositions {
pub players: Vec<BattlefieldPosition>,
pub carrier: Option<BattlefieldPosition>,
}
/// The client's store holds 40 entries and its handler writes past them for a larger count
/// (§3.2, an overrun the reference has); ours keeps the first 40.
/// The client's 40-entry store. Deviation: a larger count keeps the first 40, where the
/// reference's handler writes past the store.
pub const BATTLEFIELD_POSITIONS_MAX: usize = 40;
/// Parse it (§3.1): `u32 count`, `count × (u64, f32, f32)`, `u8 hasCarrier`, and the carrier
/// triple only when that byte is non-zero.
pub(super) fn read_battlefield_positions(r: &mut impl Read) -> io::Result<BattlefieldPositions> {
let count = read_u32_le(r)?;
let mut players = Vec::with_capacity(capacity_hint(count, BATTLEFIELD_POSITIONS_MAX));
@@ -233,13 +211,12 @@ pub(super) fn read_battlefield_positions(r: &mut impl Read) -> io::Result<Battle
Ok(BattlefieldPositions { players, carrier })
}
/// Body of `CMSG_BATTLEFIELD_LIST` (VERIFIED, `0x4ab8c0`): `u32 mapId` of the queued slot.
/// `CMSG_BATTLEFIELD_LIST` (`0x4ab8c0`): the queued slot's map id.
pub fn battlefield_list(map_id: u32) -> Vec<u8> {
map_id.to_le_bytes().to_vec()
}
/// Body of `CMSG_BATTLEMASTER_JOIN` (VERIFIED, `0x4a9f60`'s GUID arm): `u64 battlemaster`,
/// `u32 mapId`, `u32 instanceId` (`0` = first available), `u8 asGroup`.
/// `CMSG_BATTLEMASTER_JOIN` (`0x4a9f60`, with a battlemaster); instance 0 means first available.
pub fn battlemaster_join(
battlemaster: u64,
map_id: u32,
@@ -253,8 +230,7 @@ pub fn battlemaster_join(
body
}
/// Body of `CMSG_BATTLEFIELD_JOIN` (VERIFIED, `0x4a9f60`'s no-GUID arm): `u32 mapId`,
/// `u32 instanceId` (`0` = first available), `u8 asGroup`.
/// `CMSG_BATTLEFIELD_JOIN` (`0x4a9f60`, no battlemaster); instance 0 means first available.
pub fn battlefield_join(map_id: u32, instance_id: u32, as_group: bool) -> Vec<u8> {
let mut body = map_id.to_le_bytes().to_vec();
body.extend_from_slice(&instance_id.to_le_bytes());
@@ -262,8 +238,7 @@ pub fn battlefield_join(map_id: u32, instance_id: u32, as_group: bool) -> Vec<u8
body
}
/// Body of `CMSG_BATTLEFIELD_PORT` (VERIFIED, `0x4ab3b0`): `u32 mapId` then a genuinely
/// one-byte `accept`, normalised to 0/1 before it reaches the wire.
/// `CMSG_BATTLEFIELD_PORT` (`0x4ab3b0`): the map id, then a one-byte accept, 0 or 1.
pub fn battlefield_port(map_id: u32, accept: bool) -> Vec<u8> {
let mut body = map_id.to_le_bytes().to_vec();
body.push(u8::from(accept));
@@ -304,8 +279,6 @@ mod tests {
mod pvp_log_tests {
use super::*;
/// The scoreboard reader: the winner byte only when ended, the rank BEFORE the kills, and no
/// more than eight stats kept while every one is consumed.
#[test]
fn the_scoreboard_reads_its_conditional_winner_and_keeps_eight_stats() {
let mut body = vec![1u8, 0u8, 1, 0, 0, 0];
@@ -340,7 +313,6 @@ mod pvp_log_tests {
assert_eq!(leave_battlefield(489), vec![0xE9, 1, 0, 0]);
}
/// The instance list: the guid, the map, the bracket byte and the ids in wire order.
#[test]
fn the_list_reads_its_guid_bracket_and_instances() {
let mut body = 0x1234_5678_9abc_def0u64.to_le_bytes().to_vec();
@@ -366,7 +338,6 @@ mod pvp_log_tests {
assert!(l.instances.is_empty());
}
/// The positions reply: the count-led list, the carrier byte, and the 40-entry cap.
#[test]
fn the_positions_reply_reads_the_list_and_the_carrier() {
let mut body = 2u32.to_le_bytes().to_vec();
@@ -414,7 +385,6 @@ mod pvp_log_tests {
);
}
/// The two join bodies differ only by the leading guid; the list request is the map alone.
#[test]
fn the_join_bodies_and_the_list_request() {
assert_eq!(
@@ -428,7 +398,6 @@ mod pvp_log_tests {
assert_eq!(battlefield_list(529), vec![0x11, 2, 0, 0]);
}
/// Status 1 carries the two wait dwords 1963's reader dropped.
#[test]
fn a_queued_status_carries_its_wait_pair() {
let mut body = vec![0u8, 0, 0, 0];
+11 -42
View File
@@ -1,60 +1,33 @@
//! The innkeeper bind family — the confirm question, its answer, and the "you are bound" echo
//! (opcodes 747, 437, 344; decision 1331).
//!
//! Three packets and one law worth stating plainly, because getting it wrong is silent: **selecting
//! the innkeeper's gossip line does not bind anything.** vmangos's `GOSSIP_OPTION_INNKEEPER` arm
//! (`Player.cpp:12341`) closes the gossip menu and calls `Player::SetBindPoint`, which sends
//! `SMSG_BINDER_CONFIRM` and stops. The bind happens only when the client answers
//! `CMSG_BINDER_ACTIVATE` (`HandleBinderActivateOpcode`, `NPCHandler.cpp:479`), at which point the
//! innkeeper casts spell 3286 "Bind" on the player and `SPELL_EFFECT_BIND`
//! (`SpellEffects.cpp:5837`) writes the homebind and sends `SMSG_BINDPOINTUPDATE` +
//! `SMSG_PLAYERBOUND`. A client that shows the gossip line and never sends the answer therefore
//! looks exactly like a server that ignored the click — which is how B249 read.
//!
//! | opcode | direction | body |
//! |---|---|---|
//! | `SMSG_BINDER_CONFIRM` 0x2eb | in | `u64` binder guid |
//! | `CMSG_BINDER_ACTIVATE` 0x1b5 | out | `u64` binder guid |
//! | `SMSG_PLAYERBOUND` 0x158 | in | `u64` binder guid + `u32` area id |
//!
//! The server bodies are VERIFIED against vmangos `Server/Packets/Npc.{h,cpp}` and
//! `Server/Packets/Misc.{h,cpp}` (`BinderConfirm`, `BinderActivate`, `PlayerBound`). What the
//! *client* puts in `CMSG_BINDER_ACTIVATE` is the guid it stored from the confirm: vmangos reads
//! that guid and uses it (`GetNPCIfCanInteractWith(packet.npcGuid, UNIT_NPC_FLAG_INNKEEPER)`), so
//! unlike the duel echo this one is load-bearing — a zero here is an ignored bind.
//! The innkeeper bind: `SMSG_BINDER_CONFIRM` (0x2eb) asks, `CMSG_BINDER_ACTIVATE` (0x1b5)
//! answers, `SMSG_PLAYERBOUND` (0x158) confirms. The gossip line only asks (`Player.cpp:12341`);
//! the bind happens on the answer, when the innkeeper casts spell 3286 (`NPCHandler.cpp:479`).
use std::io;
use crate::wire::{read_u32_le, read_u64_le};
/// `SMSG_PLAYERBOUND` — the server's echo once the bind has actually taken.
/// `SMSG_PLAYERBOUND`, sent once the bind has taken (`SpellEffects.cpp:5837`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PlayerBound {
/// The NPC that bound us (vmangos sends the *caster* of spell 3286, i.e. the innkeeper).
/// The caster of spell 3286, the innkeeper.
pub binder: u64,
/// The `AreaTable` id the player is now bound in — the same id `SMSG_BINDPOINTUPDATE` carries
/// in the packet beside it, so the two can never disagree.
/// The `AreaTable` id now bound, the same one `SMSG_BINDPOINTUPDATE` carries beside it.
pub area: u32,
}
/// Body of `CMSG_BINDER_ACTIVATE`: the binder's full 8-byte guid — the one the client stored from
/// [`read_binder_confirm`]. **Not decorative:** `HandleBinderActivateOpcode` resolves this guid to
/// a live `UNIT_NPC_FLAG_INNKEEPER` creature in interact range and drops the request if it can't,
/// so an empty or stale guid binds nothing and reports nothing.
/// `CMSG_BINDER_ACTIVATE`: the guid from [`read_binder_confirm`]. The server binds only if it is
/// an innkeeper in interact range, and says nothing otherwise.
pub fn binder_activate(binder_guid: u64) -> Vec<u8> {
binder_guid.to_le_bytes().to_vec()
}
/// Read `SMSG_BINDER_CONFIRM` (vmangos `Npc.cpp`, `buffer << binderGuid`): one full 8-byte guid.
///
/// This is the *question*, and it arrives with `SMSG_GOSSIP_COMPLETE` right before it — the gossip
/// window is already closing when the dialog goes up, which is why the confirm is not part of the
/// gossip session's state.
/// `SMSG_BINDER_CONFIRM` (vmangos `Npc.cpp`): the innkeeper's guid. `SMSG_GOSSIP_COMPLETE` comes
/// just before it, so the question outlives the gossip window.
pub(super) fn read_binder_confirm(r: &mut &[u8]) -> io::Result<u64> {
read_u64_le(r)
}
/// Read `SMSG_PLAYERBOUND` (vmangos `Misc.cpp`, `buffer << binderGuid << areaId`).
/// `SMSG_PLAYERBOUND` (vmangos `Misc.cpp`).
pub(super) fn read_player_bound(r: &mut &[u8]) -> io::Result<PlayerBound> {
Ok(PlayerBound {
binder: read_u64_le(r)?,
@@ -66,8 +39,6 @@ pub(super) fn read_player_bound(r: &mut &[u8]) -> io::Result<PlayerBound> {
mod tests {
use super::*;
/// The activate body is the bare guid, little-endian — the shape vmangos's `BinderActivate`
/// reads back.
#[test]
fn the_activate_body_is_one_little_endian_guid() {
assert_eq!(
@@ -76,8 +47,6 @@ mod tests {
);
}
/// Confirm in, bound out: both read the guid first, and `SMSG_PLAYERBOUND` carries the area
/// id after it.
#[test]
fn the_two_inbound_bodies_round_trip() {
let guid: u64 = 0xF130_0000_0001_2345;
@@ -1,104 +1,31 @@
//! The four **world broadcasts** — the packets the server sends to everybody, or to everybody in a
//! zone, rather than to one player about their own doing.
//!
//! They have no shared wire family (two carry a zone id, one a DBC index, one nothing at all), and
//! they are grouped here because they share a *destination*: all four end at a chat line or a UI
//! toast the player did not ask for. The client's own handlers are three adjacent functions plus
//! one arm of a shared dispatcher, and two of them are the same function twice over.
//!
//! | opcode | handler | what it becomes |
//! |---|---|---|
//! | `SMSG_ZONE_UNDER_ATTACK` `0x254` | `0x49dcc0` | `CHAT_MSG_CHANNEL` on the defense channels |
//! | `SMSG_DEFENSE_MESSAGE` `0x33B` | `0x49de30` | `CHAT_MSG_CHANNEL` on the defense channels |
//! | `SMSG_SERVER_MESSAGE` `0x291` | `0x49df80` | `CHAT_MSG_SYSTEM` |
//! | `SMSG_CHAT_RESTRICTED` `0x2FD` | `0x5e38c0` arm `0x5e4a09` | `DisplayError(451)` → `CHAT_MSG_SYSTEM` |
//!
//! **The two defense broadcasts do not go where a reimplementation guesses.** Neither is a system
//! line: the client walks its own **joined-channel list** (`[0xb4fe04]`, count `[0xb4fe00]`, stride
//! `0xa0` — the record wow-re's `chat-msg-event-args.md` §7 owns) and delivers the text on every
//! joined channel whose `ChatChannels.dbc` row carries the `DEFENSE` flag `0x10000`, as chat type
//! `0xE` = `CHAT_MSG_CHANNEL` with **no sender** and language `0`. A `ZONE_DEP` (`0x2`) channel —
//! LocalDefense — additionally requires the broadcast's zone to be the player's current one; a
//! GLOBAL one — WorldDefense — always passes. So a player who has joined neither channel sees
//! nothing at all, which is the faithful answer and not a dropped packet.
//!
//! The zone test is on the **parent** zone, not the packet's area: both handlers resolve the id
//! through `AreaTable.dbc` and replace it with the row's parent (`[rec+0x8]`) when that is nonzero.
//! `SMSG_ZONE_UNDER_ATTACK`'s *text* still names the packet's own area — the name is read off the
//! row before the remap — so "Sentinel Hill is under attack!" is matched against Westfall.
//!
//! The wire shapes below are VERIFIED against vmangos source with a file:line cite per field, and
//! confirmed against the client's own reads in the handlers above. The goldens
//! (`tests/broadcast.rs`) are built the way the server builds each body.
//! The world broadcasts: server messages, zone-under-attack and defense messages. The last two
//! print, senderless, on every joined channel whose `ChatChannels.dbc` row has `DEFENSE`
//! (0x10000); a `ZONE_DEP` (0x2) channel also needs the area's parent zone to be the player's.
use std::io;
use crate::wire::{read_cstring, read_u32_le};
/// Read `SMSG_SERVER_MESSAGE` (VERIFIED vmangos `WorldPackets::Misc::ServerMessage::AppendBodyTo`,
/// `Server/Packets/Misc.cpp:341-345`): `u32 messageType` + one cstring.
///
/// `messageType` is a **`ServerMessages.dbc` row id**, not a free enum — the client indexes the
/// store at `[0xc0d990]` by it directly (`0x49dfbb`) and takes the row's localized text as a
/// **format string**, which the packet's own text fills. vmangos's producers are the five rows the
/// shipped table has (`World.h:62`, whose comment says "ServerMessages.dbc"):
///
/// | type | `ServerMessages.dbc` text (enUS) | vmangos sends |
/// |---|---|---|
/// | 1 | `[SERVER] Shutdown in %s` | `secsToTimeString(remaining)` |
/// | 2 | `[SERVER] Restart in %s` | `secsToTimeString(remaining)` |
/// | 3 | `%s` | the operator's own text (`SERVER_MSG_CUSTOM`) |
/// | 4 | `[SERVER] Shutdown cancelled` | `""` |
/// | 5 | `[SERVER] Restart cancelled` | `""` |
///
/// The text is carried whole rather than being resolved here: the DBC lives on the *client* side
/// of this crate's boundary (`benilla_formats::ServerMessagesCatalog`), and the row-missing
/// fallback the client itself uses — `"[%d]: %s"` at `0x844864` — needs the raw type to render.
/// `SMSG_SERVER_MESSAGE` (`Server/Packets/Misc.cpp:341-345`, handler `0x49df80`): a
/// `ServerMessages.dbc` row id whose text is a format string for the cstring (vmangos sends 1-5,
/// `World.h:62`); a missing row prints as `"[%d]: %s"` (`0x844864`).
pub(super) fn read_server_message(r: &mut &[u8]) -> io::Result<(u32, String)> {
let message_type = read_u32_le(r)?;
let text = read_cstring(r)?;
Ok((message_type, text))
}
/// Read `SMSG_ZONE_UNDER_ATTACK` (VERIFIED vmangos
/// `WorldPackets::Misc::ZoneUnderAttack::AppendBodyTo`, `Server/Packets/Misc.cpp:451-454`): one
/// `u32`, the `AreaTable.dbc` id of the area under attack.
///
/// vmangos sends it from `Creature::SendZoneUnderAttackMessage`
/// (`src/game/Objects/Creature.cpp:2889`), whose sole caller is the creature-death path
/// (`Objects/Unit.cpp:1200`) when the victim `IsGuard()` or carries
/// `CREATURE_STATIC_FLAG_PVP_ENABLING` and the killer was a player. It goes **map-wide to the
/// opposing team**, throttled to one per area per 10 s — and the function's own comment reads
/// *"Send a message to LocalDefense channel for players opposition team in the zone"*, which is the
/// server independently naming the destination the client's handler picks.
/// `SMSG_ZONE_UNDER_ATTACK` (`Server/Packets/Misc.cpp:451-454`, handler `0x49dcc0`): the attacked
/// area's `AreaTable.dbc` id, whose own name fills the text. vmangos sends it map-wide to the
/// enemy team when a player kills a guard or PvP-enabling creature, once per area per 10 s.
pub(super) fn read_zone_under_attack(r: &mut &[u8]) -> io::Result<u32> {
read_u32_le(r)
}
/// Read `SMSG_DEFENSE_MESSAGE` (VERIFIED vmangos `Map::SendDefenseMessage`,
/// `src/game/Maps/Map.cpp:1868-1884`): `u32 zoneId`, `u32 length`, then `length` bytes of text.
///
/// **Built raw, not through a packet class** — the whole body is three `ByteBuffer <<` writes
/// inside a `#if SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_11_2`, so this packet exists for 1.12 and
/// no earlier build. The length is written as `strlen(text) + 1` and the `char const*` write
/// appends the NUL, so it counts the terminator.
///
/// **The length is load-bearing here, unlike [`super::area_trigger::read_area_trigger_message`]'s.**
/// The client does not read a C string at all: `0x49de5c` calls the `CDataStore` **borrow-and-skip**
/// `0x419ac0`, which copies nothing, hands back a live pointer into the receive buffer and advances
/// the cursor by exactly `length` — and which **zeroes its out-parameter before any bounds check**,
/// so a `length` that overruns the packet reaches the chat composer as a NULL text and
/// `0x49a870`'s NULL guard at `0x49a889` drops the line entirely (wow-re
/// `system/net/scratch/cdatastore-get-primitives.md` §5, `system/ui/scratch/world-broadcast-opcodes.md`
/// §3 — a §5 cross-check that corrected two committed notes calling `0x419ac0` a copy).
///
/// So an overrunning length is a **refusal**, not a hint to ignore: it errors here, and the session
/// drops the packet, which is the same nothing-displayed the reference produces. Within the bounds
/// the text is the borrowed window up to its first NUL — the reference reads past `length` when
/// there is no NUL in it (an unmitigated OOB read, wow-re's words), which is the one place a
/// faithful reimplementation stops short on purpose.
///
/// The Eastern Plaguelands tower captures are its only vmangos sender.
/// `SMSG_DEFENSE_MESSAGE` (`Maps/Map.cpp:1868-1884`, handler `0x49de30`): `u32` zone id, `u32`
/// length counting the NUL, then the text. The reference skips exactly `length` bytes
/// (`0x419ac0`) and drops the line when that overruns the packet, so an overrun errors here.
/// Deviation: text without a NUL stops at `length`, where the reference reads out of bounds.
pub(super) fn read_defense_message(r: &mut &[u8]) -> io::Result<(u32, String)> {
let zone_id = read_u32_le(r)?;
let length = read_u32_le(r)? as usize;
@@ -131,8 +58,6 @@ mod tests {
);
}
/// The cancel messages carry an EMPTY text, and the row's own string is the whole line — so an
/// empty string here is a value, never a parse failure.
#[test]
fn a_cancel_message_carries_an_empty_text() {
let mut body = 4u32.to_le_bytes().to_vec();
@@ -149,8 +74,6 @@ mod tests {
assert_eq!(read_zone_under_attack(&mut &body[..]).unwrap(), 40);
}
/// The length prefix is skipped and the text read whole — built the way `Map.cpp` builds it,
/// terminator counted.
#[test]
fn a_defense_message_skips_its_length_and_reads_the_text() {
let text = b"The Eastern Plaguelands tower has been taken!";
@@ -163,10 +86,6 @@ mod tests {
assert_eq!(out, String::from_utf8_lossy(text));
}
/// A truncated body is an error, not a silently empty line — and it is the *declared length*
/// that decides, because that is what the reference bounds-checks. An overrun leaves its
/// out-pointer NULL and the chat composer drops the line; erroring here reproduces the same
/// nothing-displayed, with a log line instead of silence.
#[test]
fn a_length_that_overruns_the_body_is_refused() {
let mut body = 139u32.to_le_bytes().to_vec();
@@ -180,8 +99,6 @@ mod tests {
assert!(read_defense_message(&mut &body[..]).is_err());
}
/// A window with no NUL in it is the whole window — the reference would read on past `length`
/// (an OOB read it mitigates nowhere); stopping at the bound is the deliberate divergence.
#[test]
fn a_window_without_a_terminator_is_taken_whole() {
let mut body = 139u32.to_le_bytes().to_vec();
+15 -48
View File
@@ -1,19 +1,10 @@
//! The channel subsystem — join/leave/list/moderation (opcodes 151-168, VERIFIED vmangos
//! `Server/Protocol/Opcodes_1_12_1.h:154-171`) plus the two server notification packets:
//! `SMSG_CHANNEL_NOTIFY` (a notice byte + channel name + a per-notice tail — [`read_channel_notify`],
//! VERIFIED vmangos `Chat/Channel.cpp:804-1008` against the enum in `Chat/Channel.h:35-71`) and
//! `SMSG_CHANNEL_LIST` (the member roster — [`read_channel_list`], VERIFIED
//! `Chat/Channel.cpp:497-557`). CMSG bodies are VERIFIED against `Server/Packets/Channel.h`/`.cpp`
//! (every `ReadFromWorldPacket` there): every moderation command reads `cstring channelName [+
//! cstring playerName]` — the client body builders in [`super::client`] all funnel through
//! [`super::client::cstrings_body`] for that shared shape.
//! The channel notices and member roster (vmangos `Chat/Channel.cpp`).
use std::io;
use crate::wire::{capacity_hint, read_cstring, read_u32_le, read_u64_le, read_u8};
/// `SMSG_CHANNEL_NOTIFY`'s notice byte (`ChatNotify`, VERIFIED vmangos `Chat/Channel.h:35-71`) —
/// selects which tail follows the channel name (see [`ChannelNoticeTail`]/[`read_channel_notify`]).
/// `SMSG_CHANNEL_NOTIFY`'s notice byte (`ChatNotify`, vmangos `Chat/Channel.h:35-71`).
pub mod channel_notice {
pub const JOINED: u8 = 0x00;
pub const LEFT: u8 = 0x01;
@@ -49,43 +40,26 @@ pub mod channel_notice {
pub const THROTTLED: u8 = 0x1F;
}
/// The per-notice tail `SMSG_CHANNEL_NOTIFY` carries after `u8 notice + cstring channel` — which
/// shape follows depends on `notice` ([`channel_notice`]); VERIFIED against vmangos's one
/// `Channel::Make*` producer per notice (`Chat/Channel.cpp:804-1008`). `NOT_MEMBER` (0x05) has a
/// second, **static** producer (`Channel::MakeNotOnPacket`, `Channel.cpp:847-851`, used when there's
/// no live `Channel` object to query — e.g. `/list` on a channel name that doesn't exist) that writes
/// the byte-**identical** shape (`u8 notice, cstring channel`, nothing more): both call sites decode
/// the same way, an empty tail — there is no ambiguity to special-case here.
/// What follows the notice byte and channel name, by notice (`Chat/Channel.cpp:804-1008`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChannelNoticeTail {
/// 0x00 JOINED / 0x01 LEFT: the guid that joined/left.
/// The member who joined or left.
Guid(u64),
/// 0x02 YOU_JOINED: our own new channel flags. The trailing `u32` vmangos writes after it is a
/// hardcoded `0` ("the non-zero number will be appended to the channel name" — never actually
/// sent nonzero) and carries no information, so it isn't surfaced.
/// Our channel flags; the `u32` after them is always 0.
YouJoined { flags: u32 },
/// 0x03 YOU_LEFT / 0x04 WRONG_PASSWORD / 0x05 NOT_MEMBER / 0x06 NOT_MODERATOR / 0x0A NOT_OWNER /
/// 0x11 MUTED / 0x13 BANNED / 0x19 INVITE_WRONG_FACTION / 0x1A WRONG_FACTION / 0x1B INVALID_NAME /
/// 0x1C NOT_MODERATED / 0x1F THROTTLED: no tail at all — the notice + channel name is the whole
/// packet.
/// Nothing follows the channel name.
Empty,
/// 0x07 PASSWORD_CHANGED / 0x08 OWNER_CHANGED / 0x0D ANNOUNCEMENTS_ON / 0x0E ANNOUNCEMENTS_OFF /
/// 0x0F MODERATION_ON / 0x10 MODERATION_OFF / 0x17 PLAYER_ALREADY_MEMBER / 0x18 INVITE: the guid
/// that made the change / is doing the inviting.
/// The player who made the change or sent the invite.
Actor(u64),
/// 0x09 PLAYER_NOT_FOUND / 0x0B CHANNEL_OWNER / 0x16 PLAYER_NOT_BANNED / 0x1D PLAYER_INVITED /
/// 0x1E PLAYER_INVITE_BANNED: a bare player name (CHANNEL_OWNER sends the literal string
/// `"Nobody"` for a non-constant channel with no owner, or `"PLAYER_NOT_FOUND"` if the owner's
/// name can't be resolved — both ride this same string field).
/// A player name; `CHANNEL_OWNER` may send the literal `"Nobody"` or `"PLAYER_NOT_FOUND"`.
Name(String),
/// 0x0C MODE_CHANGE: the affected guid + its `ChannelMemberFlags` byte before/after the change.
/// The member and its `ChannelMemberFlags` before and after.
ModeChange {
guid: u64,
old_flags: u8,
new_flags: u8,
},
/// 0x12 PLAYER_KICKED / 0x14 PLAYER_BANNED / 0x15 PLAYER_UNBANNED: the affected player, then who
/// did it.
/// The affected player, then who acted.
Actors { target: u64, source: u64 },
}
@@ -97,10 +71,7 @@ pub struct ChannelNotify {
pub tail: ChannelNoticeTail,
}
/// Read `SMSG_CHANNEL_NOTIFY` (see [`ChannelNoticeTail`] for the per-notice VERIFIED citations).
/// An unrecognized notice byte (outside vmangos's defined `0x00..=0x1F`) errors rather than guessing
/// a shape — there is no way to skip an unknown tail without risking misreading the rest of this
/// packet's bytes as something else.
/// A notice byte past 0x1F errors, since the length of its tail is unknown.
pub(super) fn read_channel_notify(r: &mut &[u8]) -> io::Result<ChannelNotify> {
use channel_notice as n;
@@ -110,7 +81,7 @@ pub(super) fn read_channel_notify(r: &mut &[u8]) -> io::Result<ChannelNotify> {
n::JOINED | n::LEFT => ChannelNoticeTail::Guid(read_u64_le(r)?),
n::YOU_JOINED => {
let flags = read_u32_le(r)?;
let _reserved = read_u32_le(r)?; // always 0 on the wire (see the variant doc)
let _reserved = read_u32_le(r)?; // always 0 on the wire
ChannelNoticeTail::YouJoined { flags }
}
n::YOU_LEFT
@@ -161,18 +132,14 @@ pub(super) fn read_channel_notify(r: &mut &[u8]) -> io::Result<ChannelNotify> {
})
}
/// Read `SMSG_CHANNEL_LIST` (`Channel::List`, VERIFIED vmangos `Chat/Channel.cpp:513-556`): cstring
/// channel name + `u8` channel flags + `u32` member count + that many `(u64 guid, u8 memberFlags)`
/// rows (`ChannelMemberFlags` — owner/moderator/voiced/muted/custom/mic-muted, `Chat/Channel.h:
/// 119-130`). Returns `(channel, flags, members)`.
/// `SMSG_CHANNEL_LIST` (`Chat/Channel.cpp:513-556`): `(channel, flags, members)`, each member a
/// guid and its `ChannelMemberFlags` (`Chat/Channel.h:119-130`).
#[allow(clippy::type_complexity)]
pub(super) fn read_channel_list(r: &mut &[u8]) -> io::Result<(String, u8, Vec<(u64, u8)>)> {
let channel = read_cstring(r)?;
let flags = read_u8(r)?;
let count = read_u32_le(r)?;
// The count is wire-controlled — never let it drive allocation (a corrupt 0xFFFFFFFF must fail
// at the bounds-checked reads below, not abort in the allocator). Legit rosters exceed 256
// rarely; push simply grows past the hint.
// The count is wire-controlled, so it only hints the allocation.
let mut members = Vec::with_capacity(capacity_hint(count, 256));
for _ in 0..count {
let guid = read_u64_le(r)?;