diff --git a/crates/benilla-protocol/src/guid.rs b/crates/benilla-protocol/src/guid.rs index 9c16e366..9c64f84d 100644 --- a/crates/benilla-protocol/src/guid.rs +++ b/crates/benilla-protocol/src/guid.rs @@ -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 { 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 { match high(guid) { HIGH_MO_TRANSPORT => Some((guid & 0xFFFF_FFFF) as u32), @@ -116,14 +72,13 @@ pub fn entry(guid: u64) -> Option { 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); } diff --git a/crates/benilla-protocol/src/lib.rs b/crates/benilla-protocol/src/lib.rs index ad9cb9d5..985b73c8 100644 --- a/crates/benilla-protocol/src/lib.rs +++ b/crates/benilla-protocol/src/lib.rs @@ -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::() { @@ -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 { use std::net::ToSocketAddrs; let address = format!("{host}:{port}"); @@ -122,71 +99,42 @@ fn dial(host: &str, port: u16) -> Result { }) } -/// 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, - /// `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, } 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 { - // `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 { 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 { 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 { 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 { ) .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) diff --git a/crates/benilla-protocol/src/messages/action_bar.rs b/crates/benilla-protocol/src/messages/action_bar.rs index 8e0ec41b..c5481dc9 100644 --- a/crates/benilla-protocol/src/messages/action_bar.rs +++ b/crates/benilla-protocol/src/messages/action_bar.rs @@ -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> { 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 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 { let mut body = Vec::with_capacity(5); body.push(button); @@ -68,29 +50,11 @@ pub fn set_action_button(button: u8, packed: u32) -> Vec { 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 { vec![toggles] } diff --git a/crates/benilla-protocol/src/messages/addons.rs b/crates/benilla-protocol/src/messages/addons.rs index 7edb9576..7d19f77a 100644 --- a/crates/benilla-protocol/src/messages/addons.rs +++ b/crates/benilla-protocol/src/messages/addons.rs @@ -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 { statuses .iter() @@ -79,26 +23,22 @@ pub fn hidden_from_reply(statuses: &[u8], sent: &[SecureAddon]) -> Vec { .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 { 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 { 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 { if addons.is_empty() { return Vec::new(); @@ -170,14 +98,12 @@ pub fn addon_tail(addons: &[SecureAddon]) -> Vec { 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()); diff --git a/crates/benilla-protocol/src/messages/area_trigger.rs b/crates/benilla-protocol/src/messages/area_trigger.rs index c7e24693..a8794a0b 100644 --- a/crates/benilla-protocol/src/messages/area_trigger.rs +++ b/crates/benilla-protocol/src/messages/area_trigger.rs @@ -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 { 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 { 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."; diff --git a/crates/benilla-protocol/src/messages/attack.rs b/crates/benilla-protocol/src/messages/attack.rs index 114e0490..b4bcbf2b 100644 --- a/crates/benilla-protocol/src/messages/attack.rs +++ b/crates/benilla-protocol/src/messages/attack.rs @@ -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 { 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 io::Result= 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 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 { 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"); diff --git a/crates/benilla-protocol/src/messages/auction.rs b/crates/benilla-protocol/src/messages/auction.rs index 00a29db4..10d85fd1 100644 --- a/crates/benilla-protocol/src/messages/auction.rs +++ b/crates/benilla-protocol/src/messages/auction.rs @@ -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 { 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 { 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 { 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 { 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 { 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 { 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 Vec { 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 { Ok(AuctionListEntry { auction_id: read_u32_le(r)?, @@ -380,39 +262,19 @@ fn read_auction_list_entry(r: &mut &[u8]) -> io::Result { }) } -/// 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` 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, 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 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 { @@ -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 { @@ -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)?)) } diff --git a/crates/benilla-protocol/src/messages/bank.rs b/crates/benilla-protocol/src/messages/bank.rs index df38f476..fbb4150f 100644 --- a/crates/benilla-protocol/src/messages/bank.rs +++ b/crates/benilla-protocol/src/messages/bank.rs @@ -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 { 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 { 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 { 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 { 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 { 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 { read_u32_le(r) } diff --git a/crates/benilla-protocol/src/messages/battlefield.rs b/crates/benilla-protocol/src/messages/battlefield.rs index dfceb428..aceafd77 100644 --- a/crates/benilla-protocol/src/messages/battlefield.rs +++ b/crates/benilla-protocol/src/messages/battlefield.rs @@ -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, - /// 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 { 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, } -/// `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, pub rows: Vec, } -/// 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 { 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 { }) } -/// 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 { 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, } -/// 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 { 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, pub carrier: Option, } -/// 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 { 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 Vec { 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 { 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 Vec { 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]; diff --git a/crates/benilla-protocol/src/messages/binder.rs b/crates/benilla-protocol/src/messages/binder.rs index e0158826..21402888 100644 --- a/crates/benilla-protocol/src/messages/binder.rs +++ b/crates/benilla-protocol/src/messages/binder.rs @@ -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 { 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 { 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 { Ok(PlayerBound { binder: read_u64_le(r)?, @@ -66,8 +39,6 @@ pub(super) fn read_player_bound(r: &mut &[u8]) -> io::Result { 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; diff --git a/crates/benilla-protocol/src/messages/broadcast.rs b/crates/benilla-protocol/src/messages/broadcast.rs index bd8eb7e9..05813d6c 100644 --- a/crates/benilla-protocol/src/messages/broadcast.rs +++ b/crates/benilla-protocol/src/messages/broadcast.rs @@ -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 { 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(); diff --git a/crates/benilla-protocol/src/messages/channel.rs b/crates/benilla-protocol/src/messages/channel.rs index 65b9c764..e31c6d4c 100644 --- a/crates/benilla-protocol/src/messages/channel.rs +++ b/crates/benilla-protocol/src/messages/channel.rs @@ -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 { use channel_notice as n; @@ -110,7 +81,7 @@ pub(super) fn read_channel_notify(r: &mut &[u8]) -> io::Result { 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 { }) } -/// 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)?;