comments: protocol, the pet, petition, pose, progression, honor and quest-giver messages

Each item says what it is and the wire or server fact behind it, with
the vmangos or client citation; the two kept deviations say why.
History, record numbers and emphasis go. With comments stripped the six
files are identical.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-23 19:35:54 +07:00
co-authored by Claude Opus 5.5
parent 989e5917e0
commit 7c012bd3ec
6 changed files with 346 additions and 985 deletions
+109 -351
View File
@@ -1,174 +1,96 @@
//! Pet messages — the pet action bar's state packet and the client verbs that drive it
//! (decisions 0982, 0988). Mirrored on the outbound side by `world::writer::pet`.
//!
//! The **shape of the system** is worth stating once, because it is neither the action bar's nor
//! the one it looks like. The bar's **contents** are the server's: `SMSG_PET_SPELLS` carries all
//! ten slots and nothing else does. The bar's **state** — the lit command, the lit reaction, the
//! autocast bit, "the pet is attacking" — is the **client's**, applied on the press and never
//! confirmed. Not as an optimisation: the server does not answer these packets at all, and the
//! real client writes its own state and repaints before the send (wow-re §10.1/§10.2). A client
//! that waited would show a bar that never lights.
//!
//! Compare the player's 120-slot bar, which is client-authoritative and merely *echoed* to the
//! server ([`super::action_bar`], decisions 0216 §7/0218 §4): different split, same lesson — know
//! which half of a surface each end owns before writing either.
//!
//! `SMSG_PET_SPELLS` carries the whole bar in one body, and the same body serves a hunter/warlock
//! pet, a possessed minion and a charmed creature (vmangos `Player::PetSpellInitialize` /
//! `PossessSpellInitialize` / `CharmSpellInitialize`, `Objects/Player.cpp:17519-17672` — three
//! builders, one byte layout: the possess builder's `int32 duration` + `uint32 0` occupies exactly
//! the pet builder's `uint32 0` + four state bytes). Its **guid-only** form (an 8-byte body
//! carrying a zero guid — `Player::RemovePetActionBar`) is the teardown: the bar goes away.
//! Pet messages: the pet action bar's state packet and the client verbs that drive it. The bar's
//! contents are the server's; its state (lit command, lit reaction, autocast) is the client's,
//! applied on the press, because the server never answers these verbs.
use std::io::{self, Read};
use crate::wire::{capacity_hint, read_u16_le, read_u32_le, read_u64_le, read_u8, Vector3d};
/// Pet action-bar slots (vmangos `MAX_UNIT_ACTION_BAR_INDEX` = `ACTION_BAR_INDEX_END(10) -
/// ACTION_BAR_INDEX_START(0)`, `Objects/UnitDefines.h:781-787`); the client's own
/// `NUM_PET_ACTION_SLOTS` is the same 10 (`PetActionBarFrame.lua:4`).
/// Pet bar slots: vmangos `MAX_UNIT_ACTION_BAR_INDEX`, FrameXML's `NUM_PET_ACTION_SLOTS`.
pub const PET_ACTION_SLOTS: usize = 10;
/// `ActiveStates` — the value the **server** puts in a packed word's top byte (vmangos
/// `Objects/UnitDefines.h:724-732`). These are what you BUILD a word from; they are **not** what
/// you decode one into (see [`PetActionEntry::kind`], and the note below).
///
/// A passive spell: shown, never clickable, no autocast.
/// vmangos `ActiveStates` (`UnitDefines.h:724-732`): the server's top byte of a packed word; the
/// client decodes the masked [`PetActionEntry::kind`] instead. Passive: shown, never clickable.
pub const PET_ACT_PASSIVE: u8 = 0x01;
/// A castable spell with autocast OFF — `0x01 | ` [`PET_AUTOCAST_ALLOWED`]`>>24`.
/// A castable spell with autocast off: `0x01 | ` [`PET_AUTOCAST_ALLOWED`]`>>24`.
pub const PET_ACT_DISABLED: u8 = 0x81;
/// A castable spell with autocast ON — `0x81 | ` [`PET_AUTOCAST_ON`]`>>24`.
/// A castable spell with autocast on: `0x81 | ` [`PET_AUTOCAST_ON`]`>>24`.
pub const PET_ACT_ENABLED: u8 = 0xC1;
/// A command token — the action is a [`PET_COMMAND_STAY`]-family value, not a spell id.
/// A command token: the action is a [`PET_COMMAND_STAY`]-family value, not a spell id.
pub const PET_ACT_COMMAND: u8 = 0x07;
/// A reaction token — the action is a [`PET_REACT_PASSIVE`]-family value, not a spell id.
/// A reaction token: the action is a [`PET_REACT_PASSIVE`]-family value, not a spell id.
pub const PET_ACT_REACTION: u8 = 0x06;
/// The **client's** slot type, after its own `& 0x3F` mask (wow-re
/// `system/ui/scratch/pet-action-bar-api.md` §1, `0x4bdccd`). This is the vocabulary a decoded
/// word speaks, and it is deliberately SHORTER than `ActiveStates`': the mask drops bits 30/31,
/// which is precisely what collapses [`PET_ACT_PASSIVE`], [`PET_ACT_DISABLED`] and
/// [`PET_ACT_ENABLED`] — three server states — onto **one** client type, with the autocast pair
/// carried separately as [`PET_AUTOCAST_ALLOWED`]/[`PET_AUTOCAST_ON`].
///
/// `GetPetActionInfo`'s jump table sends types 1–5 down the spell branch, 6 to reaction, 7 to
/// command (§2). The two token constants are numerically the `ACT_` ones, so a `kind()` compare
/// against `PET_ACT_COMMAND`/`PET_ACT_REACTION` still reads correctly; the spell family does not,
/// which is why [`PetActionEntry::is_spell`] is a range and not a set of equalities.
/// First spell-branch slot type. The client's type is the top byte `& 0x3F` (`0x4bdccd`), which
/// folds all three spell `ActiveStates` onto 1; types 6 and 7 are reaction and command.
pub const PET_TYPE_SPELL_FIRST: u8 = 1;
/// The last type the client's jump table routes to the spell branch.
pub const PET_TYPE_SPELL_LAST: u8 = 5;
/// Bit 31 of a packed word: this slot **may** autocast (wow-re §2.1, `0x4bdd65`). The client
/// additionally requires the spell to exist in `Spell.dbc` before reporting it.
/// Bit 31 of a packed word: the slot may autocast (`0x4bdd65`), given a `Spell.dbc` record.
pub const PET_AUTOCAST_ALLOWED: u32 = 0x8000_0000;
/// Bit 30: autocast is currently **running** (wow-re §2.1, `0x4bdda4`). This is the bit
/// `TogglePetAutocast` flips in place before it sends (§10.2, `0x4bcbff`).
/// Bit 30: autocast is running (`0x4bdda4`); `TogglePetAutocast` flips it before the send.
pub const PET_AUTOCAST_ON: u32 = 0x4000_0000;
/// `CommandStates` (vmangos `UnitDefines.h:755-761`) — the action value carried by a
/// [`PET_ACT_COMMAND`] slot, and what `CharmInfo::GetCommandState` reports.
/// vmangos `CommandStates` (`UnitDefines.h:755-761`): a [`PET_ACT_COMMAND`] slot's action.
pub const PET_COMMAND_STAY: u32 = 0;
pub const PET_COMMAND_FOLLOW: u32 = 1;
pub const PET_COMMAND_ATTACK: u32 = 2;
pub const PET_COMMAND_DISMISS: u32 = 3;
/// `ReactStates` (vmangos `UnitDefines.h:734-739`) — the action value carried by a
/// [`PET_ACT_REACTION`] slot, and what `CharmInfo::GetReactState` reports.
/// vmangos `ReactStates` (`UnitDefines.h:734-739`): a [`PET_ACT_REACTION`] slot's action.
pub const PET_REACT_PASSIVE: u32 = 0;
pub const PET_REACT_DEFENSIVE: u32 = 1;
pub const PET_REACT_AGGRESSIVE: u32 = 2;
/// The bit that marks the pet's bar **disabled**, in [`PetSpells::state`]'s own dword.
///
/// Two independent readings meet on this one bit, which is why it is worth stating: vmangos writes
/// `pet->IsEnabled() ? 0x0 : 0x8` as the state field's **fourth byte** (`Player.cpp:17536`), and
/// the client reads **bit 27** of the whole dword (wow-re §1/§4, `0x4bd08d`). `0x8 << 24` is bit
/// 27 — the same bit, reached from both ends.
///
/// It does two things, both verified in the client: it forces the reaction compare's left side to
/// Passive (§2.2) and it fails the usability predicate (§4). A disabled bar still draws.
/// Bit 27 of [`PetSpells::state`], vmangos's fourth byte `0x8` (`Player.cpp:17536`): the bar is
/// disabled, its lit reaction reads Passive and it is unusable, but it still draws (`0x4bd08d`).
pub const PET_STATE_BAR_DISABLED: u32 = 0x0800_0000;
/// The pet's `UNIT_FIELD_FLAGS` bits that make its bar unusable — vmangos
/// `UNIT_FLAG_STUNNED 0x00040000` / `UNIT_FLAG_CONFUSED 0x00400000` / `UNIT_FLAG_FLEEING
/// 0x00800000` (`UnitDefines.h:509-514`), which are exactly bits 18/22/23, the three the client's
/// usability predicate tests (wow-re §4 step 6, `0x4bd075`-`0x4bd08b`).
///
/// i.e. **a stunned, confused or feared pet cannot be ordered** — and note `UNIT_FLAG_POSSESSED`
/// (bit 24) is deliberately NOT among them: a possessed unit is exactly the case where the bar
/// must still work.
/// The pet's `UNIT_FIELD_FLAGS` bits that make its bar unusable: stunned, confused, fleeing
/// (`0x4bd075`). `UNIT_FLAG_POSSESSED` is not among them: a possessed unit's bar must work.
pub const PET_UNUSABLE_UNIT_FLAGS: u32 = 0x0004_0000 | 0x0040_0000 | 0x0080_0000;
/// The permanent-cooldown marker OR-ed into a pet cooldown's **category** duration (vmangos
/// `Unit::WritePetSpellsCooldown`, `Objects/Unit.cpp:11270-11271`) — the twin of the player
/// initial-spell list's top-bit convention. Strip it before using the number as a duration.
/// Permanent-cooldown marker OR-ed into a pet cooldown's category duration (vmangos
/// `Unit.cpp:11270-11271`); strip it before using the number as a duration.
pub const PET_COOLDOWN_PERMANENT: u32 = 0x0800_0000;
/// One packed pet action word, decoded **the way the client decodes it** (wow-re §1, verified at
/// `0x4bdcc7`/`0x4bdccd`/`0x4bdce3`):
///
/// ```text
/// kind = (packed >> 24) & 0x3F the 0x3F mask is load-bearing — see below
/// action = packed & 0xFFFF SIXTEEN bits, not 24
/// bit 31 = autocast allowed bits 16..23 are never read by anything
/// bit 30 = autocast on
/// ```
///
/// The mask is the whole reason the server's five `ActiveStates` become the client's three
/// branches: `0x01`, `0x81` and `0xC1` all mask to type **1**, and what separated them —
/// castability and autocast — is read from bits 31/30 instead. Decoding `>> 24` unmasked (which
/// benilla did until this was pinned) happens to work for the exact bytes vmangos sends and
/// silently mis-sorts anything else.
///
/// What `action` *means* is `kind`'s to say: a spell id on the spell branch, a `CommandStates`
/// for [`PET_ACT_COMMAND`], a `ReactStates` for [`PET_ACT_REACTION`]. The raw word is kept
/// because the client echoes it **verbatim** in a [`pet_action`] body — re-packing from the split
/// pair would be a second chance to get it wrong.
/// One packed pet action word, as the client decodes it (`0x4bdcc7`/`0x4bdccd`/`0x4bdce3`): the
/// action in bits 0-15, the type in 24-29, autocast allowed in 31 and on in 30; bits 16-23 are
/// never read. The raw word is kept because the client echoes it verbatim in [`pet_action`].
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PetActionEntry {
/// The whole word, exactly as it arrived (what [`pet_action`] echoes).
pub packed: u32,
}
impl PetActionEntry {
/// Bits 0–15: the spell id, command state or react state.
/// Bits 0-15: the spell id, command state or react state.
pub fn action(self) -> u32 {
self.packed & 0xFFFF
}
/// The client's slot type — bits 24–29 (see the type doc for why the mask matters).
/// The client's slot type, bits 24-29.
pub fn kind(self) -> u8 {
((self.packed >> 24) & 0x3F) as u8
}
/// Does this slot take the **spell** branch (client types 1–5), rather than the reaction or
/// command branch? A type outside 1–7 is neither: it falls into the client's own default arm,
/// which we treat as an empty slot rather than reproducing (wow-re §2.5 quirk 1).
/// Client types 1-5 take the spell branch. Deviation: a type outside 1-7 reads as nothing
/// rather than the reference's default arm, which under-pushes its returns.
pub fn is_spell(self) -> bool {
(PET_TYPE_SPELL_FIRST..=PET_TYPE_SPELL_LAST).contains(&self.kind())
}
/// Is the slot EMPTY? **The whole word is zero** — the client tests the dword, not a field
/// (`0x4bdcbd test ecx,ecx`).
///
/// vmangos's own unused middle slots are `(0, ACT_DISABLED)` = `0x8100_0000`, which is *not*
/// zero and so takes the spell branch — where spell id 0 has no `Spell.dbc` record, so every
/// return including the autocast pair comes back nil anyway (§2.1's no-record sub-path). Two
/// routes, one empty-looking button; this is the client's route.
/// Empty is a zero word (`0x4bdcbd`). vmangos's unused slots, `0x8100_0000`, take the spell
/// branch instead and come back nil, since spell 0 has no `Spell.dbc` record.
pub fn is_empty(self) -> bool {
self.packed == 0
}
/// Bit 31 — this slot may autocast. (The client also requires the spell to resolve in
/// `Spell.dbc`; that half belongs to whoever holds the catalog.)
/// Bit 31; the client also requires a `Spell.dbc` record, which the catalog holder checks.
pub fn autocast_allowed(self) -> bool {
self.packed & PET_AUTOCAST_ALLOWED != 0
}
/// Bit 30 — autocast is currently running.
/// Bit 30: autocast is running.
pub fn autocast_on(self) -> bool {
self.packed & PET_AUTOCAST_ON != 0
}
/// The word with bit 30 set or cleared — exactly `TogglePetAutocast`'s own in-place flip
/// (wow-re §10.2, `0x4bcbff`: `if (bit30 clear) v |= 0x40000000 else v &= 0xBFFFFFFF`), which
/// the client writes back to the slot **before** sending and which is then what goes on the
/// wire.
/// Bit 30 set or cleared: `TogglePetAutocast`'s in-place flip (`0x4bcbff`), before the send.
pub fn with_autocast(self, on: bool) -> Self {
Self {
packed: if on {
@@ -186,9 +108,7 @@ impl From<u32> for PetActionEntry {
}
}
/// One entry of `SMSG_PET_SPELLS`' trailing cooldown block, decoded. The wire widths are
/// [`read_cooldown_block`]'s problem — the two senders disagree about them — so this is the
/// decoded shape only.
/// One decoded entry of `SMSG_PET_SPELLS`' trailing cooldown block.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PetSpellCooldown {
pub spell_id: u32,
@@ -198,80 +118,46 @@ pub struct PetSpellCooldown {
pub category_cd_ms: u32,
}
/// `SMSG_PET_SPELLS` in full — the pet action bar's entire state, in one body.
/// `SMSG_PET_SPELLS`: the pet action bar's entire state in one body.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PetSpells {
/// The controlled unit (pet, possessed minion or charmed creature). **Zero means the bar is
/// gone**: `Player::RemovePetActionBar` sends an 8-byte body holding a zero guid and nothing
/// else, and [`read_pet_spells`] answers that with an otherwise-default value.
/// The pet, possessed minion or charmed creature; zero means the bar is gone.
pub pet_guid: u64,
/// The charm's remaining duration in ms (`0` for a real pet — only the possess/charm builders
/// fill it, from the `SPELL_AURA_MOD_POSSESS`/`MOD_CHARM` aura).
/// The possess or charm aura's remaining ms; 0 for a real pet.
pub duration_ms: u32,
/// The pet's mode word, **stored verbatim** — react state in byte 0, command state in byte 1,
/// [`PET_STATE_BAR_DISABLED`] at bit 27.
///
/// One dword rather than four bytes because that is what the client keeps (`[0xb71468]`, wow-re
/// §1) and because two of its readings depend on the *whole* word rather than a field: the
/// reaction compare zeroes itself on bit 27, and the command compare is an **unmasked**
/// `state >> 8`, so a set bit 27 puts it out of range of any action and no command can light
/// (§2.3). Splitting it into bytes loses both behaviours silently.
/// The mode word, one dword as the client keeps it (`[0xb71468]`): react state in byte 0,
/// command state in byte 1, [`PET_STATE_BAR_DISABLED`] at bit 27. Do not split it into bytes.
pub state: u32,
/// The ten bar slots, in bar order.
pub bar: [PetActionEntry; PET_ACTION_SLOTS],
/// The pet's whole known-spell list — the pet spellbook, packed the same way (spell id +
/// its current autocast type). Empty for a temporary pet or a charm; only a permanent pet
/// gets one (`Pet::IsPermanentPetFor` gates the loop, `Player.cpp:17549`).
/// The pet spellbook, packed like the bar; only a permanent pet has one (`Player.cpp:17549`).
pub spells: Vec<PetActionEntry>,
/// The pet's running cooldowns.
pub cooldowns: Vec<PetSpellCooldown>,
}
impl PetSpells {
/// Byte 0 of [`Self::state`] — a [`PET_REACT_PASSIVE`]-family value.
/// Byte 0 of [`Self::state`], a [`PET_REACT_PASSIVE`]-family value.
pub fn react_state(&self) -> u32 {
self.state & 0xFF
}
/// `state >> 8` — a [`PET_COMMAND_STAY`]-family value, **unmasked on purpose**.
///
/// The client's own command compare is `(state >> 8) == action` with no `& 0xFF`
/// (`0x4bdf0f`), so when bit 27 is set this is a number no command state can equal and every
/// command button goes dark. Masking here would be "tidier" and would quietly delete that.
/// `state >> 8`, unmasked like the client's compare (`0x4bdf0f`): with bit 27 set it matches
/// no command state, so every command button goes dark. Masking it would lose that.
pub fn command_state(&self) -> u32 {
self.state >> 8
}
/// Bit 27 — the server says the bar is disabled.
/// Bit 27: the server has disabled the bar.
pub fn bar_disabled(&self) -> bool {
self.state & PET_STATE_BAR_DISABLED != 0
}
}
/// Read `SMSG_PET_SPELLS` (VERIFIED vmangos `Player::PetSpellInitialize`,
/// `Objects/Player.cpp:17519-17561`, with `CharmInfo::BuildActionBar` `Unit.cpp:8782-8786` and
/// `Unit::WritePetSpellsCooldown` `Unit.cpp:11245-11280`):
///
/// ```text
/// u64 petGuid
/// u32 duration (0 for a pet; the charm/possess remainder otherwise)
/// u32 state react | command<<8 | 0<<16 | enabledFlag<<24 — kept VERBATIM
/// u32 × 10 the bar, packed
/// u8 spellCount then that many u32, packed the same way
/// <the cooldown block> see read_cooldown_block — the one field the client and vmangos disagree on
/// ```
///
/// vmangos writes the state as four separate bytes and the client reads one dword
/// (`0x4bd9d3 GetInt32` → `[0xb71468]`); those are the same four bytes, and holding the dword is
/// what lets bit 27 and the unmasked `>> 8` behave (see [`PetSpells::state`]).
///
/// The **8-byte teardown** form (`Player::RemovePetActionBar`, `Player.cpp:17672-17677`) is a lone
/// zero guid: this returns [`PetSpells::default`] for it, so a caller's only test is
/// `pet_guid == 0`. Anything short of a full body after a non-zero guid is a real parse error and
/// surfaces as one — a truncated bar is not a bar.
/// Read `SMSG_PET_SPELLS` (vmangos `Player::PetSpellInitialize`, `Player.cpp:17519-17561`):
/// `u64 petGuid, u32 duration, u32 state, u32 bar[10], u8 count, u32 spells[count]`, then the
/// cooldown block. A lone guid is the teardown; any longer body cut short is an error.
pub(super) fn read_pet_spells(r: &mut &[u8]) -> io::Result<PetSpells> {
let pet_guid = read_u64_le(r)?;
if r.is_empty() {
// The teardown. vmangos always writes a zero guid here; carrying whatever arrived keeps
// the reader honest, and the `pet_guid == 0` test is the caller's contract either way.
// The teardown; vmangos always writes a zero guid here.
return Ok(PetSpells {
pet_guid,
..Default::default()
@@ -286,7 +172,7 @@ pub(super) fn read_pet_spells(r: &mut &[u8]) -> io::Result<PetSpells> {
}
let spell_count = read_u8(r)?;
// A `u8` count with no tighter server bound (`Player.cpp:17471`, `uint8 addlist`).
// A `u8` count with no tighter server bound (`Player.cpp:17471`).
let mut spells = Vec::with_capacity(capacity_hint(spell_count, usize::from(u8::MAX)));
for _ in 0..spell_count {
spells.push(read_u32_le(r)?.into());
@@ -304,28 +190,14 @@ pub(super) fn read_pet_spells(r: &mut &[u8]) -> io::Result<PetSpells> {
})
}
/// The trailing cooldown block — **the one place the real client and vmangos disagree about this
/// packet**, so it is read by measuring rather than by choosing a side.
///
/// - The **client** reads `u8 count` then 12-byte entries `{u16 spellId, u16 category, u32, u32}`
/// (wow-re §8, getter widths verified at `0x4bda58`/`0x4bda6c`/`0x4bda82`; the arbitration is
/// §12.6).
/// - **vmangos** writes `u16 count` then 14-byte entries `{u32 spellId, u16 category, u32, u32}`
/// (`Unit::WritePetSpellsCooldown`, `Objects/Unit.cpp:11225-11258`) — the later-expansion
/// layout. A real 1.12 client talking to vmangos mis-parses this block; we are not going to
/// reproduce that bug just to match it.
///
/// The block is the packet's **tail**, so the remaining length settles it outright: `n` client
/// entries leave `12n` bytes, `n` vmangos entries leave `1 + 14n` (the `+1` being the u16 count's
/// high byte, which the `u8` read did not consume). `12n == 1 + 14m` has no solution in
/// non-negative integers with `n == m`, and for `count == 0` both leave nothing to read. So the
/// discriminator is exact, not a heuristic — and the day vmangos is fixed, this keeps working.
/// The trailing cooldown block. The client reads `u8 count` then 12-byte entries
/// `{u16 spell, u16 category, u32, u32}` (`0x4bda58`); vmangos writes `u16 count` then 14-byte
/// entries with a `u32` spell (`Unit.cpp:11225-11258`). Deviation: both are read, told apart
/// exactly by the tail length (`12n` vs `1 + 14n`), since the 1.12 client mis-parses vmangos's.
fn read_cooldown_block(r: &mut &[u8]) -> io::Result<Vec<PetSpellCooldown>> {
let count = usize::from(read_u8(r)?);
// The discriminator holds at `count == 0` too: vmangos's lone leftover is its `u16` count's
// high byte, the client's form leaves nothing. The byte is consumed rather than left behind
// so an empty block is not a tail for the decode-length check to announce (decision 2265
// §B1); either way nothing else is read and no entry is invented.
// At `count == 0` vmangos still leaves its `u16` count's high byte; it is consumed so the
// decode-length check sees no tail.
let vmangos = r.len() == 1 + 14 * count;
if vmangos {
let _high_byte = read_u8(r)?;
@@ -333,7 +205,7 @@ fn read_cooldown_block(r: &mut &[u8]) -> io::Result<Vec<PetSpellCooldown>> {
if count == 0 {
return Ok(Vec::new());
}
// `count` came off a `u8` (above); no tighter bound exists on either producer.
// `count` came off a `u8`; no tighter bound exists on either producer.
let mut cooldowns = Vec::with_capacity(capacity_hint(count, usize::from(u8::MAX)));
for _ in 0..count {
let spell_id = if vmangos {
@@ -351,20 +223,15 @@ fn read_cooldown_block(r: &mut &[u8]) -> io::Result<Vec<PetSpellCooldown>> {
Ok(cooldowns)
}
/// `SMSG_PET_MODE` — the state-only refresh: the same four bytes `SMSG_PET_SPELLS` carries, with
/// no bar behind them. The server sends it when a command or reaction changes without the bar's
/// contents moving.
/// `SMSG_PET_MODE`: the state word without the bar, for a command or reaction change.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PetMode {
pub pet_guid: u64,
/// The same verbatim dword [`PetSpells::state`] holds — the client stores it through the very
/// same writer (`0x4bc930`) from both packets.
/// The same dword as [`PetSpells::state`]; the client stores both through `0x4bc930`.
pub state: u32,
}
/// Read `SMSG_PET_MODE` — `u64 petGuid, u32 state` (client handler `0x4bdb10`, wow-re §8;
/// vmangos's four state bytes at `WorldPackets::Pet::PetMode::AppendBodyTo`,
/// `Server/Packets/Pet.cpp:101-108`, are that dword).
/// Read `SMSG_PET_MODE`: `u64 petGuid, u32 state` (`0x4bdb10`; vmangos `Packets/Pet.cpp:101-108`).
pub(super) fn read_pet_mode(r: &mut impl Read) -> io::Result<PetMode> {
Ok(PetMode {
pet_guid: read_u64_le(r)?,
@@ -372,32 +239,18 @@ pub(super) fn read_pet_mode(r: &mut impl Read) -> io::Result<PetMode> {
})
}
/// Read `SMSG_PET_ACTION_FEEDBACK` (VERIFIED vmangos `PetActionFeedback::AppendBodyTo`,
/// `Server/Packets/Pet.cpp:87-90`): one reason byte. The client turns it into the red error line
/// ("Your pet has no path to that location", …).
/// Read `SMSG_PET_ACTION_FEEDBACK` (vmangos `Packets/Pet.cpp:87-90`): one reason byte.
pub(super) fn read_pet_action_feedback(r: &mut impl Read) -> io::Result<u8> {
read_u8(r)
}
/// Read `SMSG_PET_TAME_FAILURE` (VERIFIED vmangos `PetTameFailure::AppendBodyTo`,
/// `Server/Packets/Pet.cpp:122-125`): one reason byte. [`pet_tame_failure_key`] is what the
/// reference does with it.
/// Read `SMSG_PET_TAME_FAILURE` (vmangos `Packets/Pet.cpp:122-125`): one reason byte.
pub(super) fn read_pet_tame_failure(r: &mut impl Read) -> io::Result<u8> {
read_u8(r)
}
/// The `SMSG_PET_TAME_FAILURE` reason -> the `GlobalStrings.lua` key whose text fills
/// `ERR_TAME_FAILED`'s lone `%s` — the reference's jump table at `0x6e6ac0`, read out of the
/// image dword for dword.
///
/// `0x6e6a20` computes `reason - 1` and bounds it at `0xa`, so **`1..=11` index the table and
/// everything else takes the default arm** `0x6e6a87` -> `PETTAME_UNKNOWNERROR`. That default is
/// reachable in play: vmangos's own enum has a twelfth value (`PETTAME_UNKNOWNERROR = 12`) which
/// falls out of range and lands on it, arriving at the same string by the other road.
///
/// The eleven pairs are byte-identical to vmangos's `PetTameFailureReason`
/// (`src/game/SharedDefines.h:1707`) — the two ends agree without either having been fitted to
/// the other.
/// The `GlobalStrings.lua` key filling `ERR_TAME_FAILED`'s `%s`: the reference's table
/// (`0x6e6ac0`) covers `1..=11`, and anything else, vmangos's 12 included, is the default arm.
pub fn pet_tame_failure_key(reason: u8) -> &'static str {
match reason {
1 => "PETTAME_INVALIDCREATURE",
@@ -415,44 +268,26 @@ pub fn pet_tame_failure_key(reason: u8) -> &'static str {
}
}
/// `SMSG_PET_ACTION_SOUND`'s selector: the pet acknowledging an **order** (vmangos
/// `PET_TALK_SPECIAL_SPELL`, sent when a pet bar spell press is accepted —
/// `PetHandler.cpp:523`, `PetAI.cpp:336`).
/// Sound selector for an accepted order (vmangos `PET_TALK_SPECIAL_SPELL`, `PetHandler.cpp:523`).
pub const PET_TALK_ORDER: u32 = 0;
/// `SMSG_PET_ACTION_SOUND`'s selector: the pet **attacking** (vmangos `PET_TALK_ATTACK`, sent
/// from `Unit.cpp:8941` when an attack order is taken).
/// Sound selector for an attack order (vmangos `PET_TALK_ATTACK`, `Unit.cpp:8941`).
pub const PET_TALK_ATTACK: u32 = 1;
/// Read `SMSG_PET_ACTION_SOUND` (VERIFIED vmangos `PetActionSound::AppendBodyTo`,
/// `Server/Packets/Pet.cpp:96-100`, and the reference's own reader at `0x6040ca`/`0x6040db`):
/// `u64 petGuid` then the `u32` talk selector.
///
/// The selector is NOT a `SoundEntries` id — it is [`PET_TALK_ORDER`] / [`PET_TALK_ATTACK`], and
/// the sound comes off the pet's own `CreatureSoundData` row. Worth saying because the opcode's
/// name invites the other reading, and a two-value selector and a kit id are indistinguishable
/// on the wire.
/// Read `SMSG_PET_ACTION_SOUND`: `u64 petGuid, u32 selector` (`0x6040ca`, vmangos
/// `Packets/Pet.cpp:96-100`). The selector is a `PET_TALK_*` value, not a `SoundEntries` id: the
/// sound comes off the pet's `CreatureSoundData` row.
pub(super) fn read_pet_action_sound(r: &mut impl Read) -> io::Result<(u64, u32)> {
Ok((read_u64_le(r)?, read_u32_le(r)?))
}
/// Read `SMSG_PET_DISMISS_SOUND` — `u32 creatureModelDataId` then `f32` x/y/z.
///
/// VERIFIED off **the reference's own reader**, which is the only authority there is: `0x604140`
/// takes one `0x418e30` (u32) then three `0x419130` (f32) in that order, and vmangos has no
/// sender to cross-check against. The `+1.0` the handler adds to `z` afterwards is a *play*
/// detail, not a wire one, so it is not applied here.
/// Read `SMSG_PET_DISMISS_SOUND`: `u32 creatureModelDataId, f32 x, y, z` (reader `0x604140`;
/// vmangos never sends it). The handler's `+1.0` on `z` is a play detail, not applied here.
pub(super) fn read_pet_dismiss_sound(r: &mut impl Read) -> io::Result<(u32, Vector3d)> {
Ok((read_u32_le(r)?, Vector3d::read(r)?))
}
/// Read `SMSG_PET_CAST_FAILED` (VERIFIED vmangos `PetCastFailed::AppendBodyTo`,
/// `Server/Packets/Pet.cpp:127-132`): `u32 spellId, u8 status, u8 reason`. The pet's twin of
/// `SMSG_CAST_RESULT` — the same `SpellCastResult` vocabulary, decoded the same way (status `2` =
/// fail) so the one message table renders both. vmangos only ever sends the fail status here, but
/// reading it the shared way costs nothing and keeps the two decodes from drifting.
///
/// The body stops at the reason: unlike `CastResult`, `PetCastFailed::AppendBodyTo` writes **no
/// argument words** at all, so a pet failure never carries a `%s` fill.
/// Read `SMSG_PET_CAST_FAILED` (vmangos `Packets/Pet.cpp:127-132`): `u32` spell, `u8` status,
/// `u8` reason, in `SMSG_CAST_RESULT`'s vocabulary (2 is a failure) but with no argument words.
pub(super) fn read_pet_cast_failed(r: &mut impl Read) -> io::Result<(u32, super::CastOutcome)> {
let spell_id = read_u32_le(r)?;
let status = read_u8(r)?;
@@ -467,14 +302,8 @@ pub(super) fn read_pet_cast_failed(r: &mut impl Read) -> io::Result<(u32, super:
Ok((spell_id, outcome))
}
/// Body of `CMSG_PET_ACTION` (VERIFIED vmangos `WorldPackets::Pet::PetAction::ReadFromWorldPacket`,
/// `Server/Packets/Pet.cpp:9-14`; opcode 373 `Opcodes_1_12_1.h:374`): `u64 petGuid, u32 packedData,
/// u64 targetGuid`.
///
/// **`packed` is the slot's own word, echoed** — the server re-splits it exactly as the client
/// packed it (`UNIT_ACTION_BUTTON_ACTION/TYPE` at `PetHandler.cpp:37-38`) and dispatches on the
/// type: `ACT_COMMAND` runs `HandlePetCommand`, `ACT_REACTION` sets the react state, and the three
/// spell types cast. `target` is `0` when the action needs none.
/// Body of `CMSG_PET_ACTION` (opcode 373, vmangos `Packets/Pet.cpp:9-14`): `u64` pet guid, the
/// slot's `u32` word echoed verbatim, `u64` target guid (0 when the action needs none).
pub fn pet_action(pet_guid: u64, packed: u32, target_guid: u64) -> Vec<u8> {
let mut body = Vec::with_capacity(20);
body.extend_from_slice(&pet_guid.to_le_bytes());
@@ -483,26 +312,15 @@ pub fn pet_action(pet_guid: u64, packed: u32, target_guid: u64) -> Vec<u8> {
body
}
/// Body of `CMSG_PET_STOP_ATTACK` (VERIFIED vmangos `PetStopAttack::ReadFromWorldPacket`,
/// `Server/Packets/Pet.cpp:33-36`; opcode 746 `Opcodes_1_12_1.h:747`): the lone `u64` pet guid.
/// The Attack button's *second* press — `PetActionBarFrame.lua:258-262` calls `PetStopAttack()`
/// instead of `CastPetAction` when the attack is already running.
/// Body of `CMSG_PET_STOP_ATTACK` (opcode 746, vmangos `Packets/Pet.cpp:33-36`): the pet guid,
/// sent by the Attack button's second press (`PetActionBarFrame.lua:258-262`).
pub fn pet_stop_attack(pet_guid: u64) -> Vec<u8> {
pet_guid.to_le_bytes().to_vec()
}
/// Body of `CMSG_PET_CANCEL_AURA` (opcode 619): `u64 petGuid` then `u32 spellId`.
///
/// Both ends agree independently. wow-re read the send off the client
/// (`ui/scratch/pet-action-bar-api.md` §10.1, `0x4bd25f`–`0x4bd2ad`: `push 0x26b`, the guid pair,
/// the id); vmangos reads the same two fields in the same order
/// (`PetCancelAura::ReadFromWorldPacket`, `Server/Packets/Pet.cpp:27-31`).
///
/// The guid is what distinguishes it from the player's `CMSG_CANCEL_AURA` — which carries a bare
/// spell id and so could only ever mean "mine". The handler re-checks that the guid is our pet or
/// our charm and drops the packet otherwise, and answers a **dead** pet with
/// `SendPetActionFeedback(FEEDBACK_PET_DEAD)` rather than silence (`HandlePetCancelAuraOpcode`,
/// `Handlers/SpellHandler.cpp:407-432`) — the same feedback channel the bar's other refusals use.
/// Body of `CMSG_PET_CANCEL_AURA` (opcode 619, sender `0x4bd25f`): `u64` pet guid, `u32` spell.
/// The server drops it unless the guid is our pet or charm and answers a dead pet with
/// `FEEDBACK_PET_DEAD` (vmangos `SpellHandler.cpp:407-432`).
pub fn pet_cancel_aura(pet_guid: u64, spell_id: u32) -> Vec<u8> {
let mut body = Vec::with_capacity(12);
body.extend_from_slice(&pet_guid.to_le_bytes());
@@ -510,20 +328,8 @@ pub fn pet_cancel_aura(pet_guid: u64, spell_id: u32) -> Vec<u8> {
body
}
/// Body of `CMSG_PET_SPELL_AUTOCAST` (opcode 755): `u64 petGuid`, `u32 spellId`, `u8 state` —
/// **thirteen** bytes, and the trailing field is a byte (decision 1032).
///
/// Both ends agree independently. The client builds it at `0x4bcd5f`–`0x4bcdb2`: `push 0x2f3`, the
/// guid pair through the u64 writer `0x418370`, the spell id through the u32 writer `0x418190`,
/// then `(word >> 30) & 0xFFFFFF01` through **`0x418070`**, the *byte* writer — which is also why
/// that mask ends in `01`. vmangos reads `guid, spellId, state` in that order with `state` a
/// `uint8` (`PetSpellAutocast::ReadFromWorldPacket`, `Server/Packets/Pet.cpp:44-49`).
///
/// **This is the pet SPELLBOOK's autocast verb, not the pet bar's.** The bar's right click sends
/// [`pet_set_action`], whose body names a slot *position*; the book has no positions, so this one
/// names the spell. The server's handler is correspondingly different — it re-derives which slots
/// to update itself (`HandlePetSpellAutocastOpcode`, `PetHandler.cpp:451-478`), gating on
/// `pet->HasSpell(spellId) && Spells::IsAutocastable(spellId)`. No reply packet.
/// Body of `CMSG_PET_SPELL_AUTOCAST` (opcode 755, sender `0x4bcd5f`): `u64` guid, `u32` spell,
/// `u8` state. The pet spellbook's autocast verb, with no reply; the bar's is [`pet_set_action`].
pub fn pet_spell_autocast(pet_guid: u64, spell_id: u32, enabled: bool) -> Vec<u8> {
let mut body = Vec::with_capacity(13);
body.extend_from_slice(&pet_guid.to_le_bytes());
@@ -532,22 +338,11 @@ pub fn pet_spell_autocast(pet_guid: u64, spell_id: u32, enabled: bool) -> Vec<u8
body
}
/// Body of `CMSG_PET_SET_ACTION` (opcode 372): `u64 petGuid` then one or two
/// `(u32 position, u32 packedData)` pairs. The server distinguishes the forms **by body size
/// alone** — 24 bytes ⇒ two entries, anything else ⇒ one (`PetSetAction::ReadFromWorldPacket`,
/// `Server/Packets/Pet.cpp:52-62`) — so a one-entry body must not be padded.
///
/// This one opcode carries **both** of the bar's write verbs, which is the thing to know about it:
///
/// - **the autocast toggle** — one pair, the pressed slot and its word with bit 30 flipped. That
/// is `TogglePetAutocast`'s whole send (wow-re §10.2, `0x4bcc1e push 0x174`), and the server
/// reads the flip straight out of the type byte: `ACT_ENABLED` ⇒ `ToggleAutocast(true)`,
/// `ACT_DISABLED` ⇒ `false` (`HandlePetSetAction`, `PetHandler.cpp`). `CMSG_PET_SPELL_AUTOCAST`
/// (0x2F3) is **not** this verb — it belongs to `ToggleSpellAutocast`, the pet *spellbook*
/// frame's binding, and indexes the spellbook rather than the bar (wow-re §10.2/§11).
/// - **the drag** — one or two pairs, from `0x4bc9a0` (wow-re §10.4): the optional first pair
/// relocates whatever occupied the target slot, the mandatory second writes the drop. vmangos
/// requires the two-pair form whenever a command/reaction token moves.
/// Body of `CMSG_PET_SET_ACTION` (opcode 372): `u64` pet guid, then one or two
/// `(u32 position, u32 packed)` pairs, counted by body size alone (24 bytes is two, vmangos
/// `Packets/Pet.cpp:52-62`), so one pair must not be padded. It carries the bar's autocast toggle
/// (one pair, bit 30 flipped, `0x4bcc1e`) and its drag (`0x4bc9a0`), whose optional first pair
/// relocates the target slot's occupant; vmangos requires it when a command or reaction moves.
pub fn pet_set_action(pet_guid: u64, entries: &[(u32, u32)]) -> Vec<u8> {
let mut body = Vec::with_capacity(8 + 8 * entries.len());
body.extend_from_slice(&pet_guid.to_le_bytes());
@@ -558,24 +353,15 @@ pub fn pet_set_action(pet_guid: u64, entries: &[(u32, u32)]) -> Vec<u8> {
body
}
/// Body of `CMSG_PET_ABANDON` (VERIFIED vmangos `PetAbandon::ReadFromWorldPacket`,
/// `Server/Packets/Pet.cpp:16-19`; opcode 374): the lone `u64` pet guid.
///
/// **The menu's Abandon and its Dismiss are this same message** — see [`super::opcode`]'s note.
/// Nothing in the body says which the player picked, and nothing needs to: the server forks on the
/// pet's own type, deleting a hunter pet and unsummoning anything else. The client's fork is purely
/// about which *word* the menu shows.
/// Body of `CMSG_PET_ABANDON` (opcode 374, vmangos `Packets/Pet.cpp:16-19`): the pet guid. The
/// menu's Abandon and Dismiss both send it; the server deletes a hunter pet and unsummons others.
pub fn pet_abandon(pet_guid: u64) -> Vec<u8> {
pet_guid.to_le_bytes().to_vec()
}
/// Body of `CMSG_PET_RENAME` (VERIFIED vmangos `PetRename::ReadFromWorldPacket`,
/// `Server/Packets/Pet.cpp:21-25`; opcode 375): `u64 petGuid` then the name as a **C string**.
///
/// The name is not length-prefixed and not padded — vmangos's `>> std::string` reads to the first
/// NUL. The 12-character cap is the popup's (`RENAME_PET`'s `maxLetters = 12`), not the wire's; the
/// server re-checks with `ObjectMgr::CheckPetName` and refuses with `SMSG_PET_NAME_INVALID`
/// rather than truncating.
/// Body of `CMSG_PET_RENAME` (opcode 375, vmangos `Packets/Pet.cpp:21-25`): the pet guid, then
/// the name NUL-terminated. The 12-letter cap is the popup's; the server refuses a bad name with
/// `SMSG_PET_NAME_INVALID` rather than truncating.
pub fn pet_rename(pet_guid: u64, name: &str) -> Vec<u8> {
let mut body = Vec::with_capacity(9 + name.len());
body.extend_from_slice(&pet_guid.to_le_bytes());
@@ -588,8 +374,7 @@ pub fn pet_rename(pet_guid: u64, name: &str) -> Vec<u8> {
mod tests {
use super::*;
/// A hunter pet's `SMSG_PET_SPELLS`, byte-for-byte as vmangos builds it: the default
/// 3 commands / 4 spell slots / 3 reactions bar, one known spell, one running cooldown.
/// A hunter pet's `SMSG_PET_SPELLS` as vmangos builds it, with one spell and one cooldown.
fn pet_spells_body() -> Vec<u8> {
let mut b = Vec::new();
b.extend_from_slice(&0xF140_0000_0000_002Au64.to_le_bytes()); // pet guid
@@ -598,8 +383,7 @@ mod tests {
b.push(PET_COMMAND_FOLLOW as u8);
b.push(0);
b.push(0); // enabled
// The bar, exactly CharmInfo::InitPetActionBar's default: attack/follow/stay,
// four spell slots (one filled with Claw 3010, autocast on), aggressive/def/passive.
// The bar is CharmInfo::InitPetActionBar's default, Claw 3010 autocasting.
for packed in [
PET_COMMAND_ATTACK | (u32::from(PET_ACT_COMMAND) << 24),
PET_COMMAND_FOLLOW | (u32::from(PET_ACT_COMMAND) << 24),
@@ -616,7 +400,7 @@ mod tests {
}
b.push(1); // spell count
b.extend_from_slice(&(3010u32 | (u32::from(PET_ACT_ENABLED) << 24)).to_le_bytes());
// The cooldown block in VMANGOS's layout: u16 count, u32 spell id (14-byte entries).
// The cooldown block in vmangos's layout: u16 count, u32 spell id (14-byte entries).
b.extend_from_slice(&1u16.to_le_bytes());
b.extend_from_slice(&3010u32.to_le_bytes());
b.extend_from_slice(&0u16.to_le_bytes()); // category
@@ -625,8 +409,7 @@ mod tests {
b
}
/// The same packet with the cooldown block in the **client's** layout: u8 count, u16 spell id
/// (12-byte entries). Both must decode identically — see [`read_cooldown_block`].
/// The same packet with the cooldown block in the client's layout: u8 count, 12-byte entries.
fn pet_spells_body_client_cooldowns() -> Vec<u8> {
let mut b = pet_spells_body();
b.truncate(b.len() - 16); // drop vmangos's u16 count + its one 14-byte entry
@@ -651,22 +434,19 @@ mod tests {
assert_eq!(p.command_state(), PET_COMMAND_FOLLOW);
assert!(!p.bar_disabled());
// The three branches, read off the same word shape.
assert_eq!(p.bar[0].kind(), PET_ACT_COMMAND);
assert_eq!(p.bar[0].action(), PET_COMMAND_ATTACK);
assert!(!p.bar[0].is_spell());
assert_eq!(p.bar[9].kind(), PET_ACT_REACTION);
assert_eq!(p.bar[9].action(), PET_REACT_PASSIVE);
// The mask at work: the server sent 0xC1, and the client's type is 1 — the autocast pair
// having moved out of the type byte into bits 31/30.
// The server sent 0xC1; the client's type is 1, with autocast in bits 31/30.
assert_eq!(p.bar[3].kind(), PET_TYPE_SPELL_FIRST);
assert_eq!(p.bar[3].action(), 3010);
assert!(p.bar[3].is_spell() && !p.bar[3].is_empty());
assert!(p.bar[3].autocast_allowed() && p.bar[3].autocast_on());
// vmangos's unused middle slot (0, ACT_DISABLED) is NOT the client's empty word — it takes
// the spell branch, where spell id 0 resolves to nothing. Same button, different route.
// vmangos's unused slot (0, ACT_DISABLED) is not empty; it takes the spell branch.
assert!(!p.bar[4].is_empty());
assert!(p.bar[4].is_spell() && p.bar[4].action() == 0);
assert!(
@@ -687,8 +467,6 @@ mod tests {
);
}
/// The cooldown block's two layouts decode to the same thing — the client's 12-byte form and
/// vmangos's 14-byte one, told apart by the tail length alone.
#[test]
fn both_cooldown_layouts_decode_identically() {
let from_vmangos = read_pet_spells(&mut &pet_spells_body()[..]).unwrap();
@@ -700,9 +478,7 @@ mod tests {
assert_eq!(from_client, from_vmangos);
}
/// A pet with no cooldowns at all: the client's form ends the packet, vmangos's leaves its
/// count's high byte behind. Neither is an error and neither invents an entry — and the
/// high byte is consumed, so neither leaves a tail.
/// No cooldowns: the client's form is one byte, vmangos's two, and both are fully consumed.
#[test]
fn an_empty_cooldown_block_reads_either_way() {
let mut client = &[0u8][..];
@@ -716,8 +492,7 @@ mod tests {
);
}
/// The teardown: an 8-byte body of zero guid ⇒ a default value whose `pet_guid` is 0. This is
/// the ONLY signal that the bar has gone away, so it must not read as a parse error.
/// A zero-guid body is the only signal that the bar is gone, so it must not be an error.
#[test]
fn the_guid_only_body_is_the_teardown() {
let body = 0u64.to_le_bytes();
@@ -727,8 +502,7 @@ mod tests {
assert_eq!(p.pet_guid, 0);
}
/// A truncated bar is a parse error, not a half-read bar — the failure mode that would
/// otherwise paint 4 real slots and 6 zeroes.
/// Cut after the first bar slot: an error, not a half-read bar.
#[test]
fn a_truncated_body_errors() {
let body = pet_spells_body();
@@ -738,8 +512,7 @@ mod tests {
#[test]
fn pet_mode_and_the_client_verbs_golden() {
// SMSG_PET_MODE: guid + the same state dword, no bar behind it. vmangos's fourth byte 0x8
// IS the client's bit 27 — the two ends of the same bit.
// SMSG_PET_MODE: vmangos's fourth state byte 0x8 is the client's bit 27.
let mut body = 0x2Au64.to_le_bytes().to_vec();
body.extend_from_slice(&[2, 0, 0, 0x8]);
let mut r = &body[..];
@@ -748,7 +521,6 @@ mod tests {
assert_eq!(mode.state, 0x0800_0002);
assert_eq!(mode.state & PET_STATE_BAR_DISABLED, PET_STATE_BAR_DISABLED);
// CMSG_PET_ACTION: guid, the slot word VERBATIM, target guid.
let packed = 3010 | (u32::from(PET_ACT_ENABLED) << 24);
assert_eq!(
pet_action(0x2A, packed, 0x99),
@@ -760,11 +532,8 @@ mod tests {
.concat()
);
// CMSG_PET_STOP_ATTACK: the lone guid.
assert_eq!(pet_stop_attack(0x2A), 0x2Au64.to_le_bytes());
// CMSG_PET_CANCEL_AURA: guid then spell id, 12 bytes — the guid is the whole difference
// from the player's own CMSG_CANCEL_AURA, which is a bare u32.
assert_eq!(
pet_cancel_aura(0x2A, 2645),
[
@@ -775,17 +544,13 @@ mod tests {
);
assert_eq!(pet_cancel_aura(0x2A, 2645).len(), 12);
// CMSG_PET_SET_ACTION: the server tells one entry from two BY BODY SIZE, so the one-entry
// form is 16 bytes and the two-entry form is exactly 24.
// CMSG_PET_SET_ACTION: the server counts entries by body size, so 16 bytes or exactly 24.
assert_eq!(pet_set_action(0x2A, &[(3, packed)]).len(), 16);
assert_eq!(pet_set_action(0x2A, &[(3, packed), (4, 0)]).len(), 24);
// CMSG_PET_ABANDON: the lone guid — the same shape as stop-attack, a different verb.
assert_eq!(pet_abandon(0x2A), 0x2Au64.to_le_bytes());
// CMSG_PET_RENAME: guid then a NUL-TERMINATED name. The terminator is the whole framing —
// there is no length prefix and no padding, so dropping it would leave the server reading
// the name off the end of the body.
// CMSG_PET_RENAME: guid then a NUL-terminated name, the terminator being its only framing.
assert_eq!(
pet_rename(0x2A, "Bruce"),
[&0x2Au64.to_le_bytes()[..], b"Bruce\0"].concat()
@@ -797,8 +562,6 @@ mod tests {
);
}
/// The autocast flip touches **bit 30 and nothing else** — the client's own in-place edit, and
/// the word it produces is exactly what `CMSG_PET_SET_ACTION` then carries.
#[test]
fn the_autocast_flip_moves_only_bit_30() {
let on = PetActionEntry::from(3010 | (u32::from(PET_ACT_ENABLED) << 24));
@@ -811,14 +574,11 @@ mod tests {
assert!(off.autocast_allowed() && !off.autocast_on());
assert_eq!(off.with_autocast(true), on);
// A passive spell can be asked to autocast and the word still says it may not — bit 31 is
// the server's to grant, and flipping 30 does not forge it.
// Bit 31 is the server's to grant; flipping bit 30 on a passive spell does not forge it.
let passive = PetActionEntry::from(3010 | (u32::from(PET_ACT_PASSIVE) << 24));
assert!(!passive.with_autocast(true).autocast_allowed());
}
/// The `& 0x3F` mask, stated on its own: the server's five `ActiveStates` land on three client
/// branches, and the two dropped bits reappear as the autocast pair.
#[test]
fn the_type_mask_collapses_the_spell_states() {
for act in [PET_ACT_PASSIVE, PET_ACT_DISABLED, PET_ACT_ENABLED] {
@@ -836,17 +596,15 @@ mod tests {
PET_ACT_REACTION
);
// A type outside 1-7 is the client's default arm; we treat it as nothing at all rather
// than reproduce its under-push (wow-re §2.5 quirk 1).
// A type outside 1-7 is neither a spell nor a token.
let odd = PetActionEntry::from(3010 | (0x33u32 << 24));
assert!(!odd.is_spell());
assert!(odd.kind() != PET_ACT_COMMAND && odd.kind() != PET_ACT_REACTION);
}
}
/// `SMSG_PET_UNLEARN_CONFIRM` (VERIFIED at the bytes, wow-re `staticpopup-dialog-bindings.md`
/// §9, arm `0x5e4a26`): the pet trainer's guid and what unlearning costs, in copper — the
/// reference latches both and fires `CONFIRM_PET_UNLEARN(cost)`. Decision 1963.
/// `SMSG_PET_UNLEARN_CONFIRM` (`0x5e4a26`): the trainer's guid and the unlearn cost in copper,
/// which the reference latches before firing `CONFIRM_PET_UNLEARN(cost)`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PetUnlearnConfirm {
pub trainer: u64,
@@ -863,7 +621,7 @@ pub(super) fn read_pet_unlearn_confirm(
})
}
/// Body of `CMSG_PET_UNLEARN` (VERIFIED, §9 `0x5dfba0`'s confirm arm): the latched trainer guid.
/// Body of `CMSG_PET_UNLEARN` (the confirm arm of `0x5dfba0`): the latched trainer guid.
pub fn pet_unlearn(trainer: u64) -> Vec<u8> {
trainer.to_le_bytes().to_vec()
}
+103 -283
View File
@@ -1,44 +1,8 @@
//! The petition family — the guild-charter flow that *founds* a guild (opcodes `0x1BB`–`0x1C7`
//! plus `0x2C1`).
//!
//! [`super::guild`] is about *being* in a guild; this is about *making* one. At 1.12 those are
//! entirely different wires: `CMSG_GUILD_CREATE` is registered `STATUS_NEVER` on vmangos, and the
//! only path to a new guild is buy a charter → collect nine signatures → turn it in.
//!
//! The server side is pinned at the bytes by vmangos `Server/Packets/Petition.{h,cpp}` (the
//! `ReadFromWorldPacket` / `AppendBodyTo` pairs) and `Handlers/PetitionsHandler.cpp` (which
//! packets each handler answers with, and the order of its refusals). **Two of the five
//! server→client packets are not built through the packet classes at all** —
//! `SMSG_PETITION_SHOW_SIGNATURES` is hand-assembled at `PetitionsHandler.cpp:160-168` and
//! `:390-397` — so their layout is read off the handler, not off `Petition.cpp`.
//!
//! Four shapes of this family are worth knowing before touching any of it:
//!
//! - **The charter is an ITEM, and the petition id rides in its enchantment slot.** Buying stores
//! item [`CHARTER_ITEM_ENTRY`] and writes the petition id into `ITEM_FIELD_ENCHANTMENT` slot 0's
//! `id` dword (`PetitionsHandler.cpp:126`). That is not a hack the client is unaware of: the
//! reference's item tooltip **forces enchantment slot 0 to zero and skips the enchant lines
//! entirely** for any item whose template flags carry [`ITEM_FLAG_CHARTER`]
//! (`test ah,0x20 / jne 0x52c9ee`, wow-re `system/ui/scratch/tooltip-content-law.md:485-505`).
//! So slot 0 on a charter is a petition id by contract on both ends, and nothing may read it as
//! a `SpellItemEnchantment.dbc` row.
//! - **Almost nothing is acked positively.** A successful buy sends **no confirmation packet** —
//! the client learns of it only from the `SMSG_ITEM_PUSH_RESULT` for the new item
//! (`PetitionsHandler.cpp:130`). Rename echoes only on success (`:209-215`). The refusals come
//! back as `SMSG_GUILD_COMMAND_RESULT`, the *guild* family's error channel, reused here.
//! - **`SMSG_PETITION_SHOW_SIGNATURES` is the answer to two different questions.** It answers our
//! own `CMSG_PETITION_SHOW_SIGNATURES` (`:160-168`), and it is also what a *third party* is sent
//! when someone offers us their charter (`:390-397`, the `CMSG_OFFER_PETITION` path). One
//! inbound packet, two meanings, distinguished only by whether its `owner` is us.
//! - **The signature limit is enforced three times with three different numbers.** vmangos's
//! `MinPetitionSigns` config (clamped 0..=9, `World.cpp:665`) decides when a petition
//! `IsComplete()` — with `==`, not `>=` (`GuildMgr.h:124`); the sign handler *separately*
//! hard-caps at nine with the comment "Client hard limit at 9 signatures"
//! (`PetitionsHandler.cpp:269-271`); and `SMSG_PETITION_QUERY_RESPONSE` reports min = max = 9
//! hardcoded (`:182-183`), regardless of the config. [`MAX_PETITION_SIGNATURES`] is that client
//! limit; a consumer wanting "how many does this petition need" reads the wire's
//! `max_signatures`, not the constant, because on a server with `MinPetitionSigns` lowered the
//! two disagree.
//! The petition family (opcodes `0x1BB`-`0x1C7`, `0x2C1`): the guild-charter flow. vmangos
//! registers `CMSG_GUILD_CREATE` as `STATUS_NEVER`, so a guild is founded only by buying a
//! charter, collecting nine signatures and turning it in. The charter item's enchantment slot 0
//! holds the petition id (`PetitionsHandler.cpp:126`); the reference tooltip skips enchant lines
//! on a charter (`0x52c9ee`), so nothing may read that slot as an enchantment.
use std::io::{self, Read};
@@ -46,111 +10,76 @@ use crate::wire::{
capacity_hint, read_cstring, read_i32_le, read_u16_le, read_u32_le, read_u64_le, read_u8,
};
/// The one item that is a guild charter — vmangos `GUILD_CHARTER` (`PetitionsHandler.cpp:37`),
/// also `Item::IsCharter`'s literal (`Item.h:158`, `GetEntry() == 5863u`). Confirmed against this
/// deployment's world DB: entry 5863 "Guild Charter", display 16161, flags `0x2000`.
/// The guild charter's item entry, vmangos `GUILD_CHARTER` (`PetitionsHandler.cpp:37`).
pub const CHARTER_ITEM_ENTRY: u32 = 5863;
/// The charter's inventory display id — vmangos `CHARTER_DISPLAY_ID` (`PetitionsHandler.cpp:38`).
/// It rides in [`PetitionShowListEntry::charter_display_id`] as well as in the item template, and
/// the two agree; the packet's copy exists so a vendor list can be drawn before the item is owned.
/// The charter's display id, vmangos `CHARTER_DISPLAY_ID` (`PetitionsHandler.cpp:38`).
pub const CHARTER_DISPLAY_ID: u32 = 16161;
/// `ITEM_FLAG_CHARTER` — the template-flag bit that makes an item a signable petition
/// (vmangos `ItemPrototype.h:77`). **The reference client keys on this same bit** to print the
/// green `ITEM_SIGNABLE` tooltip line and to suppress the enchantment lines
/// (wow-re `system/ui/scratch/tooltip-content-law.md:485-505`), so it is a shared contract rather
/// than a server-side convenience.
/// The template-flag bit of a signable petition (vmangos `ItemPrototype.h:77`); the reference
/// tooltip keys on it for the green `ITEM_SIGNABLE` line and to suppress enchantment lines.
pub const ITEM_FLAG_CHARTER: u32 = 0x0000_2000;
/// The most signatures a 1.12 charter can hold — vmangos's own "Client hard limit at 9
/// signatures" (`PetitionsHandler.cpp:269-271`), and the reference FrameXML's
/// `MAX_PETITION_SIGNATURES = 9` (`PetitionFrame.lua:1`), which sizes the window's name rows.
///
/// This is the **row count**, not the requirement: how many a petition needs to be turned in is
/// [`PetitionQueryResponse::min_signatures`] off the wire, which a server may configure lower.
/// The most signatures a charter holds (`PetitionFrame.lua:1`, `PetitionsHandler.cpp:269`); the
/// requirement is [`PetitionQueryResponse::min_signatures`].
pub const MAX_PETITION_SIGNATURES: usize = 9;
/// Charter-name cap, in *characters* — vmangos `MAX_CHARTER_NAME` (`ObjectMgr.h:401`), enforced by
/// `ObjectMgr::IsValidCharterName` (`ObjectMgr.cpp:9322-9334`) as a UTF-8 length. The reference's
/// own edit boxes agree: `GuildRegistrarFrameEditBox letters="24"` and the `RENAME_GUILD` popup's
/// `maxLetters = 24`. Same number as [`super::GUILD_NAME_MAX_LENGTH`], and not a coincidence — a
/// charter name becomes a guild name.
/// Charter-name cap in characters (vmangos `MAX_CHARTER_NAME`, `ObjectMgr.h:401`); equal to
/// [`super::GUILD_NAME_MAX_LENGTH`] because the charter name becomes the guild's.
pub const CHARTER_NAME_MAX_LENGTH: usize = 24;
/// `SMSG_PETITION_SIGN_RESULTS`' and `SMSG_TURN_IN_PETITION_RESULTS`' shared result code —
/// vmangos `PetitionSigns` (`Guild/Guild.h:147-154`). One enum, two packets, and the two use
/// **disjoint subsets** of it, which is why the arms are documented by packet.
/// The result code shared by `SMSG_PETITION_SIGN_RESULTS` and `SMSG_TURN_IN_PETITION_RESULTS`
/// (vmangos `PetitionSigns`, `Guild.h:147-154`); each packet uses its own subset.
pub mod petition_result {
/// Signed, or turned in. Used by both packets.
/// Signed, or turned in; both packets.
pub const OK: u32 = 0;
/// Sign only — this account has already signed this charter. vmangos checks uniqueness **per
/// account first**, then per character (`GuildMgr.cpp:388-400`), so a second character on one
/// account gets this, not a second signature.
/// Sign only: this account already signed. vmangos checks per account before per character
/// (`GuildMgr.cpp:388-400`), so an alt on the same account gets this.
pub const ALREADY_SIGNED: u32 = 1;
/// Turn-in only — the would-be founder is already in a guild (`PetitionsHandler.cpp:427`).
/// Never sent by the sign path, which answers an already-guilded *signer* with an
/// `SMSG_GUILD_COMMAND_RESULT` instead (`:258`).
/// Turn-in only: the founder is already in a guild (`PetitionsHandler.cpp:427`); a guilded
/// signer gets `SMSG_GUILD_COMMAND_RESULT` instead (`:258`).
pub const ALREADY_IN_GUILD: u32 = 2;
/// Sign only — you cannot sign your own charter (`PetitionsHandler.cpp:237`).
/// Sign only: you cannot sign your own charter (`PetitionsHandler.cpp:237`).
pub const CANT_SIGN_OWN: u32 = 3;
/// Turn-in only — not enough signatures yet (`PetitionsHandler.cpp:438`).
/// Turn-in only: not enough signatures (`PetitionsHandler.cpp:438`); vmangos requires exactly
/// `MinPetitionSigns` (`GuildMgr.h:124`).
pub const NEED_MORE: u32 = 4;
/// Not on the same realm. vmangos never sends it (cross-realm charters cannot arise on a
/// single-realm server); modelled because the reference has a string for it
/// (`ERR_PETITION_NOT_SAME_SERVER`) and the client's switch must have an arm.
/// Not on the same realm (`ERR_PETITION_NOT_SAME_SERVER`); vmangos never sends it.
pub const NOT_SERVER: u32 = 5;
}
/// One row of `SMSG_PETITION_SHOWLIST` — a charter a petitioner NPC will sell.
///
/// vmangos always sends exactly one of these, and its own header says **"only 1 element is
/// supported in the client"** (`Petition.h:176`). We parse the counted list faithfully anyway: a
/// count is a count, and a reader that assumes one would desynchronise rather than degrade if it
/// were ever wrong.
/// One row of `SMSG_PETITION_SHOWLIST`: a charter the NPC sells. vmangos always sends exactly one
/// (`Petition.h:176`: "only 1 element is supported in the client").
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PetitionShowListEntry {
/// Row index, 1-based (`PetitionsHandler.cpp:501`, a literal `1`).
/// Row index, 1-based (`PetitionsHandler.cpp:501`).
pub index: u32,
/// The item entry sold — [`CHARTER_ITEM_ENTRY`] in practice.
/// The item entry sold, [`CHARTER_ITEM_ENTRY`] in practice.
pub charter_entry: u32,
/// Its display id — [`CHARTER_DISPLAY_ID`] in practice.
/// Its display id, [`CHARTER_DISPLAY_ID`] in practice.
pub charter_display_id: u32,
/// Price in **copper** (1000 = 10 silver on vmangos, `PetitionsHandler.cpp:39`). Signed on the
/// wire (`int32`), which is why it is signed here: a reader that narrows it to `u32` would be
/// asserting a range the sender does not.
/// Price in copper, an `int32` on the wire (vmangos charges 1000, `PetitionsHandler.cpp:39`).
pub charter_cost: i32,
/// vmangos sends `1` and its header says the row **"must be `&1` to show it in the UI"**
/// (`Petition.h:169`). Kept raw rather than folded into a `bool`: that claim is a *comment*
/// about the reference client, not something vmangos itself enforces, and a consumer that
/// wants the filter can apply it knowingly.
/// vmangos sends 1; its header says a row "must be `&1` to show it in the UI"
/// (`Petition.h:169`).
pub entry_flags: i32,
}
/// `SMSG_PETITION_SHOWLIST` — a petitioner NPC's "here is what I sell" list. In 1.12 that is
/// always the single guild charter, and this packet is what puts the guild registrar's window on
/// screen.
/// `SMSG_PETITION_SHOWLIST`: the registrar NPC's charter list, which opens its window.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PetitionShowList {
/// The NPC we are talking to. Every later verb in the flow that names an NPC uses this guid.
/// The NPC; every later verb in the flow that names an NPC takes this guid.
pub npc: u64,
/// The rows, in wire order. Normally exactly one; see [`PetitionShowListEntry`].
pub entries: Vec<PetitionShowListEntry>,
}
/// Read `SMSG_PETITION_SHOWLIST` (VERIFIED vmangos `Server/Packets/Petition.cpp:115-127`
/// `PetitionShowList::AppendBodyTo`, filled at `Handlers/PetitionsHandler.cpp:482-507`):
/// `u64 npcGuid`, `u8 count`, then per row `u32 index`, `u32 charterEntry`, `u32 charterDisplayId`,
/// `i32 charterCost`, `i32 entryFlags`.
///
/// The send is gated on `GetNPCIfCanInteractWith(guid, UNIT_NPC_FLAG_PETITIONER)`
/// (`:484`; the flag is `0x200`, `Objects/UnitDefines.h:666`), so a list arriving at all is the
/// server's confirmation that we are in range of a real registrar.
/// Read `SMSG_PETITION_SHOWLIST` (vmangos `Packets/Petition.cpp:115-127`): `u64 npc, u8 count`,
/// then per row `u32 index, u32 entry, u32 displayId, i32 cost, i32 flags`. The request handler
/// sends it only for an NPC in range with `UNIT_NPC_FLAG_PETITIONER` (`PetitionsHandler.cpp:484`).
pub(super) fn read_petition_show_list(r: &mut impl Read) -> io::Result<PetitionShowList> {
let npc = read_u64_le(r)?;
let count = read_u8(r)?;
// vmangos sends exactly one charter (`PetitionsHandler.cpp:503`, `entries = { entry }`);
// 8 is generous.
// vmangos sends exactly one charter (`PetitionsHandler.cpp:503`); 8 is generous.
let mut entries = Vec::with_capacity(capacity_hint(count, 8));
for _ in 0..count {
entries.push(PetitionShowListEntry {
@@ -164,48 +93,30 @@ pub(super) fn read_petition_show_list(r: &mut impl Read) -> io::Result<PetitionS
Ok(PetitionShowList { npc, entries })
}
/// One signature on a charter — a signer's guid, and the dword that always follows it.
/// One signature on a charter: the signer's guid and a trailing dword.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PetitionSignature {
/// The signing character's guid. Turning it into a *name* is the client's job: the packet
/// carries no names at all.
/// The signer's guid; the packet carries no names.
pub signer: u64,
/// The trailing dword, which vmangos writes as a literal `0`
/// (`Guild/GuildMgr.cpp:358-366`, `data << 0`). Kept rather than skipped so the field is
/// visible in a dump and a non-zero value from another server would be *noticed* rather than
/// silently consumed.
/// Always 0 from vmangos (`GuildMgr.cpp:358-366`); decoded, not skipped.
pub unknown: u32,
}
/// `SMSG_PETITION_SHOW_SIGNATURES` — who has signed a charter. **The answer to two different
/// questions** (module doc): our own `CMSG_PETITION_SHOW_SIGNATURES`, and someone else's
/// `CMSG_OFFER_PETITION` aimed at us.
///
/// Note what is *not* here: the guild name, the body text, and the signature requirement. Those
/// live only in `SMSG_PETITION_QUERY_RESPONSE`, keyed by [`Self::petition_id`] — the same
/// two-caches shape [`super::guild`] has, where the roster carries no guild name.
/// `SMSG_PETITION_SHOW_SIGNATURES`: a charter's signers, in answer to our request or when
/// someone offers us theirs. Its name and requirement come from `SMSG_PETITION_QUERY_RESPONSE`.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PetitionShowSignatures {
/// The charter **item's** guid — the handle every later verb takes (sign, offer, rename, turn
/// in, decline). Not the petition id.
/// The charter item's guid, which every later verb takes; not the petition id.
pub item: u64,
/// The charter owner's character guid. Whether this is us is what makes the window the
/// leader's view or a signer's view.
/// The charter's owner; whether it is us decides the leader's view or a signer's.
pub owner: u64,
/// The petition id — the key `CMSG_PETITION_QUERY` takes, and the same value the charter
/// item's enchantment slot 0 carries (module doc).
/// The key `CMSG_PETITION_QUERY` takes, also carried in the charter's enchantment slot 0.
pub petition_id: u32,
/// The signatures so far, in wire order.
pub signatures: Vec<PetitionSignature>,
}
/// Read `SMSG_PETITION_SHOW_SIGNATURES` (VERIFIED vmangos `Handlers/PetitionsHandler.cpp:160-168`
/// and `:390-397`, which build it **by hand** rather than through a packet class):
/// `u64 itemGuid`, `u64 ownerGuid`, `u32 petitionId`, `u8 signatureCount`, then per signature
/// `u64 signerGuid` + `u32 0`.
///
/// The 12-byte stride is corroborated by the sender's own size hint `8+8+4+1 + signs*12` and by
/// `Guild/GuildMgr.cpp:358-366` (`Petition::BuildSignatureData`).
/// Read `SMSG_PETITION_SHOW_SIGNATURES` (hand-built at vmangos `PetitionsHandler.cpp:160-168`,
/// `:390-397`): `u64 item, u64 owner, u32 petitionId, u8 count`, then `u64 signer, u32 0` each.
pub(super) fn read_petition_show_signatures(
r: &mut impl Read,
) -> io::Result<PetitionShowSignatures> {
@@ -229,24 +140,19 @@ pub(super) fn read_petition_show_signatures(
})
}
/// `SMSG_PETITION_SIGN_RESULTS` — the verdict on one signature attempt.
///
/// **It is sent to both parties on success**: once to the signer (`PetitionsHandler.cpp:299`) and
/// again, identical, to the charter's owner if they are online (`:312-316`). Both copies name the
/// *signer* in [`Self::player`], so a receiver cannot tell the two apart from this packet alone —
/// only from whether it holds that charter.
/// `SMSG_PETITION_SIGN_RESULTS`: the verdict on one signature. A success goes, identical, to the
/// signer and the online owner (`PetitionsHandler.cpp:299`, `:312-316`); both name the signer.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PetitionSignResults {
/// The charter item's guid.
pub item: u64,
/// The **signer's** guid — in both copies of the packet, per the doc above.
/// The signer's guid, in both copies.
pub player: u64,
/// A [`petition_result`] code.
pub result: u32,
}
/// Read `SMSG_PETITION_SIGN_RESULTS` (VERIFIED vmangos `Server/Packets/Petition.cpp:69-74`
/// `PetitionSignResults::AppendBodyTo`): `u64 itemGuid`, `u64 playerGuid`, `u32 result`.
/// Read `SMSG_PETITION_SIGN_RESULTS` (vmangos `Packets/Petition.cpp:69-74`).
pub(super) fn read_petition_sign_results(r: &mut impl Read) -> io::Result<PetitionSignResults> {
Ok(PetitionSignResults {
item: read_u64_le(r)?,
@@ -255,64 +161,48 @@ pub(super) fn read_petition_sign_results(r: &mut impl Read) -> io::Result<Petiti
})
}
/// `SMSG_PETITION_QUERY_RESPONSE` — a petition's own record, keyed by petition id.
///
/// Sixteen fields, of which vmangos fills three with anything but a constant, and whose own header
/// says *"all those fields below don't really change anything in the UI"* (`Petition.h:126`). They
/// are parsed in full regardless: the trailing `choice` list is length-prefixed, so skipping the
/// middle would make the tail unreadable, and a field that is constant on this server is not
/// thereby constant on the wire.
/// `SMSG_PETITION_QUERY_RESPONSE`: a petition's record, keyed by petition id.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PetitionQueryResponse {
/// The petition id we asked about.
pub petition_id: u32,
/// The charter owner's character guid.
pub owner: u64,
/// The proposed guild's name. This is the only place it exists on the wire.
/// The proposed guild's name, carried by no other packet.
pub name: String,
/// Free text on the charter — always empty on vmangos (`PetitionsHandler.cpp:178`).
/// Free text on the charter; empty on vmangos (`PetitionsHandler.cpp:178`).
pub body_text: String,
/// vmangos sends `1` (`:179`). Meaning unknown; kept because it is a real wire field.
/// vmangos sends 1 (`PetitionsHandler.cpp:179`); meaning unknown.
pub flags: u32,
/// How many signatures are **required**. vmangos hardcodes 9 here even when its
/// `MinPetitionSigns` config is lower (module doc), so this is the number the reference's
/// Request-Signature button disables against, and it need not equal
/// [`MAX_PETITION_SIGNATURES`].
/// Signatures required, which the reference's Request-Signature button tests; vmangos sends 9
/// even when its `MinPetitionSigns` is lower (`PetitionsHandler.cpp:182`).
pub min_signatures: u32,
/// How many signatures fit. vmangos hardcodes 9 (`:183`).
/// Signatures that fit; 9 on vmangos (`PetitionsHandler.cpp:183`).
pub max_signatures: u32,
/// Deadline timestamp — `0` on vmangos.
/// Deadline timestamp, 0 on vmangos.
pub deadline: u32,
/// Creation timestamp — `0` on vmangos.
/// Creation timestamp, 0 on vmangos.
pub creation: u32,
/// Restricting guild id — `0` on vmangos.
/// Restricting guild id, 0 on vmangos.
pub allowed_guild_id: u32,
/// Class mask restriction — `0` on vmangos.
/// Class mask restriction, 0 on vmangos.
pub allowed_classes: u32,
/// Race mask restriction — `0` on vmangos.
/// Race mask restriction, 0 on vmangos.
pub allowed_races: u32,
/// Gender restriction — **`u16` on the wire, not `u32`** (`Petition.cpp:94`). The one odd
/// width in the packet, and misreading it as a dword shifts every field after it by two bytes.
/// Gender restriction: a `u16`, the packet's one odd width (`Packets/Petition.cpp:94`).
pub allowed_gender: u16,
/// Minimum level — `0` on vmangos.
/// Minimum level, 0 on vmangos.
pub allowed_min_level: u32,
/// Maximum level — `0` on vmangos.
/// Maximum level, 0 on vmangos.
pub allowed_max_level: u32,
/// The multiple-choice options this petition offers — always empty on vmangos, and the reason
/// the tail cannot be skipped: the count is what says where `default_choice` starts.
/// The petition's multiple-choice options; empty on vmangos.
pub choices: Vec<String>,
/// Which choice is pre-selected — `0` on vmangos.
/// The pre-selected choice, 0 on vmangos.
pub default_choice: u32,
}
/// Read `SMSG_PETITION_QUERY_RESPONSE` (VERIFIED vmangos `Server/Packets/Petition.cpp:81-102`
/// `PetitionQueryResponse::AppendBodyTo`, filled at `Handlers/PetitionsHandler.cpp:177-184`):
/// `u32 petitionId`, `u64 ownerGuid`, cstring name, cstring bodyText, `u32 flags`,
/// `u32 minSignatures`, `u32 maxSignatures`, `u32 deadline`, `u32 creation`, `u32 allowedGuildID`,
/// `u32 allowedClasses`, `u32 allowedRaces`, **`u16 allowedGender`**, `u32 allowedMinLevel`,
/// `u32 allowedMaxLevel`, `u32 choiceCount`, `choiceCount ×` cstring, `u32 defaultChoice`.
///
/// The `u16` in the middle is the trap: see [`PetitionQueryResponse::allowed_gender`].
/// Read `SMSG_PETITION_QUERY_RESPONSE` (vmangos `Packets/Petition.cpp:81-102`): the fields in
/// struct order, the choices as a `u32` count of C strings.
pub(super) fn read_petition_query_response(r: &mut impl Read) -> io::Result<PetitionQueryResponse> {
let petition_id = read_u32_le(r)?;
let owner = read_u64_le(r)?;
@@ -355,22 +245,16 @@ pub(super) fn read_petition_query_response(r: &mut impl Read) -> io::Result<Peti
})
}
/// `MSG_PETITION_RENAME` — the server's echo of a successful rename.
///
/// Sent **only on success** (`PetitionsHandler.cpp:209-215`); a rejected name comes back as an
/// `SMSG_GUILD_COMMAND_RESULT` instead. Note the handler does **no ownership check** — anyone
/// holding the item may rename it.
/// `MSG_PETITION_RENAME`: the server's echo of a successful rename (`PetitionsHandler.cpp:209`);
/// a rejected name gets `SMSG_GUILD_COMMAND_RESULT`. Any holder of the item may rename it.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PetitionRename {
/// The charter item's guid.
pub item: u64,
/// Its new name.
pub name: String,
}
/// Read `MSG_PETITION_RENAME` (VERIFIED vmangos `Server/Packets/Petition.cpp:104-108`
/// `PetitionRename::AppendBodyTo`): `u64 itemGuid`, cstring newName. The same two fields the
/// client sends, in the same order — which is what makes it an `MSG_` rather than a pair.
/// Read `MSG_PETITION_RENAME` (vmangos `Packets/Petition.cpp:104-108`): `u64` item, cstring name.
pub(super) fn read_petition_rename(r: &mut impl Read) -> io::Result<PetitionRename> {
Ok(PetitionRename {
item: read_u64_le(r)?,
@@ -378,56 +262,29 @@ pub(super) fn read_petition_rename(r: &mut impl Read) -> io::Result<PetitionRena
})
}
/// Read `SMSG_TURN_IN_PETITION_RESULTS` (VERIFIED vmangos `Server/Packets/Petition.cpp:76-79`
/// `TurnInPetitionResults::AppendBodyTo`): one `u32`, a [`petition_result`] code — and **only** the
/// code. Which charter it refers to is implicit; there can be only one in flight.
///
/// The three codes vmangos actually sends here are [`petition_result::OK`] (`:473`),
/// [`petition_result::ALREADY_IN_GUILD`] (`:427`) and [`petition_result::NEED_MORE`] (`:438`). A
/// name collision is reported as `SMSG_GUILD_COMMAND_RESULT` instead and produces no results
/// packet at all (`:445`), so "no answer" is a real outcome of a turn-in.
/// Read `SMSG_TURN_IN_PETITION_RESULTS` (vmangos `Packets/Petition.cpp:76-79`): a lone
/// [`petition_result`] code. A name collision sends `SMSG_GUILD_COMMAND_RESULT` and no result
/// packet at all (`PetitionsHandler.cpp:445`).
pub(super) fn read_turn_in_petition_results(r: &mut impl Read) -> io::Result<u32> {
read_u32_le(r)
}
/// Read `MSG_PETITION_DECLINE`'s **inbound** form (VERIFIED vmangos
/// `Server/Packets/Petition.cpp:110-113` `PetitionDecline::AppendBodyTo`): one `u64`, the guid of
/// the player who declined. Delivered only to the charter's owner
/// (`Handlers/PetitionsHandler.cpp:321-338`).
///
/// The outbound form of the same opcode carries the *item* guid instead
/// ([`petition_decline`]) — one opcode, two different bodies by direction, which is what `MSG_`
/// means here.
/// Read `MSG_PETITION_DECLINE` inbound (vmangos `Packets/Petition.cpp:110-113`): the decliner's
/// guid, sent to the owner only. The outbound form carries the item guid instead.
pub(super) fn read_petition_decline(r: &mut impl Read) -> io::Result<u64> {
read_u64_le(r)
}
/// Body of `CMSG_PETITION_SHOWLIST` (VERIFIED vmangos `Server/Packets/Petition.cpp:3-6`
/// `PetitionShowListRequest::ReadFromWorldPacket`): one `u64`, the petitioner NPC's guid.
///
/// Answered by `SMSG_PETITION_SHOWLIST`, or by nothing at all if the NPC is out of range or lacks
/// `UNIT_NPC_FLAG_PETITIONER` (`Handlers/PetitionsHandler.cpp:484`). The server also sends that
/// answer unasked when a `GOSSIP_OPTION_PETITIONER` row is selected (`Player.cpp:12428-12431`), so
/// this request is the *second* way in, not the only one.
/// Body of `CMSG_PETITION_SHOWLIST` (vmangos `Packets/Petition.cpp:3-6`): the NPC's guid. The
/// server also sends the list unasked for a `GOSSIP_OPTION_PETITIONER` row (`Player.cpp:12428`).
pub fn petition_show_list(npc: u64) -> Vec<u8> {
npc.to_le_bytes().to_vec()
}
/// Body of `CMSG_PETITION_BUY` (VERIFIED vmangos `Server/Packets/Petition.cpp:47-67`
/// `PetitionBuy::ReadFromWorldPacket`) — **72 bytes, of which the server reads exactly two
/// fields**: the NPC guid at offset 0 and the name at offset 20. Everything else is skipped.
///
/// The shape is a generic multi-choice petition struct that never shipped: `u64 npcGuid`,
/// `u32 0`, `u64 0`, cstring name, `10 × u32 0`, `u16 0`, `u8 0`, `u32 index`, `u32 0`. We send
/// zeros for every skipped field — including `index`, which the server's own comment calls
/// "unused" — because a field nobody reads has no correct non-zero value, and a zero is the one
/// choice that cannot encode an accident.
///
/// The refusal ladder is worth knowing, because none of it is a petition packet: an already-taken
/// or invalid name answers `SMSG_GUILD_COMMAND_RESULT(GUILD_CREATE_S, name, …)`
/// (`Handlers/PetitionsHandler.cpp:78`, `:83`, `:93`), too little money answers `SMSG_BUY_FAILED`
/// (`:108`), a full bag answers `SMSG_INVENTORY_CHANGE_FAILURE` (`:116`) — and **success answers
/// nothing but the new item** (`:130`).
/// Body of `CMSG_PETITION_BUY` (vmangos `Packets/Petition.cpp:47-67`): `u64 npc, u32 0, u64 0`,
/// the name, `u32 0` x10, `u16 0, u8 0, u32 index, u32 0`. The server reads only NPC and name.
/// A bad name gets `SMSG_GUILD_COMMAND_RESULT`, no money `SMSG_BUY_FAILED`, a full bag
/// `SMSG_INVENTORY_CHANGE_FAILURE`; success is only the item push (`PetitionsHandler.cpp:78-130`).
pub fn petition_buy(npc: u64, name: &str) -> Vec<u8> {
let mut body = Vec::with_capacity(72 + name.len());
body.extend_from_slice(&npc.to_le_bytes());
@@ -437,30 +294,19 @@ pub fn petition_buy(npc: u64, name: &str) -> Vec<u8> {
body.extend_from_slice(&[0u8; 40]); // 10 × u32
body.extend_from_slice(&0u16.to_le_bytes());
body.push(0);
body.extend_from_slice(&0u32.to_le_bytes()); // index — the server's own "unused"
body.extend_from_slice(&0u32.to_le_bytes()); // index, "unused" per the server
body.extend_from_slice(&0u32.to_le_bytes());
body
}
/// Body of `CMSG_PETITION_SHOW_SIGNATURES` (VERIFIED vmangos `Server/Packets/Petition.cpp:8-11`
/// `PetitionShowSignaturesRequest::ReadFromWorldPacket`): one `u64`, the charter **item's** guid.
///
/// Answered with `SMSG_PETITION_SHOW_SIGNATURES` — or silently dropped if we are already in a
/// guild (`Handlers/PetitionsHandler.cpp:140`) or do not hold that item (`:143`).
/// Body of `CMSG_PETITION_SHOW_SIGNATURES` (vmangos `Packets/Petition.cpp:8-11`): the charter
/// item's guid, unanswered if we are guilded or lack the item (`PetitionsHandler.cpp:140-143`).
pub fn petition_show_signatures(item: u64) -> Vec<u8> {
item.to_le_bytes().to_vec()
}
/// Body of `CMSG_PETITION_SIGN` (VERIFIED vmangos `Server/Packets/Petition.cpp:35-39`
/// `PetitionSign::ReadFromWorldPacket`): `u64 itemGuid` then one `i8` the server **skips**, whose
/// own vmangos comment is *"argument of `/run SignPetition(123)` is never used in the official
/// interface"*.
///
/// **The client's default for that byte is `1`, not `0`** — `SignPetition`'s optional Lua argument
/// initialises `edi = 1` at `0x4f46d9` and the byte goes out through `Put8` at `0x4f4749` (wow-re
/// `system/ui/scratch/petition-charter-api.md`). Since the server reads and discards it, **nothing
/// observable depends on the value**, which is exactly why it shipped here as `0` and why only a
/// golden can tell the difference.
/// Body of `CMSG_PETITION_SIGN` (vmangos `Packets/Petition.cpp:35-39`): the item guid, then an
/// `i8` the server skips. `SignPetition` defaults it to 1 (`0x4f46d9`, sent at `0x4f4749`).
pub fn petition_sign(item: u64, arg: i8) -> Vec<u8> {
let mut body = Vec::with_capacity(9);
body.extend_from_slice(&item.to_le_bytes());
@@ -468,13 +314,9 @@ pub fn petition_sign(item: u64, arg: i8) -> Vec<u8> {
body
}
/// Body of `CMSG_OFFER_PETITION` (VERIFIED vmangos `Server/Packets/Petition.cpp:41-45`
/// `OfferPetition::ReadFromWorldPacket`): `u64 itemGuid`, `u64 playerGuid` — the charter, and who
/// to show it to.
///
/// On success the server sends `SMSG_PETITION_SHOW_SIGNATURES` **to the target**, not back to us
/// (`Handlers/PetitionsHandler.cpp:390-397`); we hear only about refusals, as
/// `SMSG_GUILD_COMMAND_RESULT`.
/// Body of `CMSG_OFFER_PETITION` (vmangos `Packets/Petition.cpp:41-45`): item guid, target guid.
/// Success sends the target `SMSG_PETITION_SHOW_SIGNATURES` (`PetitionsHandler.cpp:390-397`); we
/// hear only refusals, as `SMSG_GUILD_COMMAND_RESULT`.
pub fn offer_petition(item: u64, player: u64) -> Vec<u8> {
let mut body = Vec::with_capacity(16);
body.extend_from_slice(&item.to_le_bytes());
@@ -482,24 +324,15 @@ pub fn offer_petition(item: u64, player: u64) -> Vec<u8> {
body
}
/// Body of `CMSG_TURN_IN_PETITION` (VERIFIED vmangos `Server/Packets/Petition.cpp:24-27`
/// `TurnInPetition::ReadFromWorldPacket`): one `u64`, the charter item's guid.
///
/// Only the petition's **owner** may turn it in; anyone else is refused silently
/// (`Handlers/PetitionsHandler.cpp:432`). On success the guild is created with every signer as a
/// member at the lowest rank, the petition row is deleted, and *then* the item is destroyed — in
/// that order, because destroying a charter cascades into deleting its petition
/// (`Player.cpp:10811-10817`).
/// Body of `CMSG_TURN_IN_PETITION` (vmangos `Packets/Petition.cpp:24-27`): the charter item's
/// guid. Only the owner may turn it in; anyone else gets silence (`PetitionsHandler.cpp:432`).
/// Success founds the guild with every signer at the lowest rank.
pub fn turn_in_petition(item: u64) -> Vec<u8> {
item.to_le_bytes().to_vec()
}
/// Body of `CMSG_PETITION_QUERY` (VERIFIED vmangos `Server/Packets/Petition.cpp:13-17`
/// `PetitionQuery::ReadFromWorldPacket`): `u32 petitionId`, `u64 itemGuid`.
///
/// The item guid is **read and then ignored** by the handler (`Handlers/PetitionsHandler.cpp:171-185`),
/// which looks the petition up by id alone. Sent anyway, at its true value: a field the current
/// server does not use is not a field to fill with garbage.
/// Body of `CMSG_PETITION_QUERY` (vmangos `Packets/Petition.cpp:13-17`): `u32 petitionId`,
/// `u64 item`; the server looks up by id alone (`PetitionsHandler.cpp:171-185`).
pub fn petition_query(petition_id: u32, item: u64) -> Vec<u8> {
let mut body = Vec::with_capacity(12);
body.extend_from_slice(&petition_id.to_le_bytes());
@@ -507,14 +340,8 @@ pub fn petition_query(petition_id: u32, item: u64) -> Vec<u8> {
body
}
/// Body of `MSG_PETITION_RENAME`'s **outbound** form (VERIFIED vmangos
/// `Server/Packets/Petition.cpp:29-33` `PetitionRename::ReadFromWorldPacket`): `u64 itemGuid`,
/// cstring newName.
///
/// Echoed back as the same two fields on success ([`read_petition_rename`]); rejected names come
/// back as `SMSG_GUILD_COMMAND_RESULT` and no echo. The name is capped at
/// [`CHARTER_NAME_MAX_LENGTH`] characters by the server's own validator — not truncated here, for
/// the reason [`super::guild`]'s caps give: silently shortening sends the wrong text.
/// Body of `MSG_PETITION_RENAME` outbound (vmangos `Packets/Petition.cpp:29-33`): `u64` item,
/// cstring name. Not truncated to [`CHARTER_NAME_MAX_LENGTH`]; the server validates it.
pub fn petition_rename(item: u64, name: &str) -> Vec<u8> {
let mut body = Vec::with_capacity(9 + name.len());
body.extend_from_slice(&item.to_le_bytes());
@@ -522,15 +349,12 @@ pub fn petition_rename(item: u64, name: &str) -> Vec<u8> {
body
}
/// Body of `MSG_PETITION_DECLINE`'s **outbound** form (VERIFIED vmangos
/// `Server/Packets/Petition.cpp:19-22` `PetitionDecline::ReadFromWorldPacket`): one `u64`, the
/// charter item's guid. The server forwards our guid to the charter's owner
/// ([`read_petition_decline`]).
/// Body of `MSG_PETITION_DECLINE` outbound (vmangos `Packets/Petition.cpp:19-22`): the charter
/// item's guid; the server forwards our guid to the owner.
pub fn petition_decline(item: u64) -> Vec<u8> {
item.to_le_bytes().to_vec()
}
/// Append a NUL-terminated string to `body`.
fn push_cstring(body: &mut Vec<u8>, s: &str) {
body.extend_from_slice(s.as_bytes());
body.push(0);
@@ -540,9 +364,7 @@ fn push_cstring(body: &mut Vec<u8>, s: &str) {
mod tests {
use super::*;
/// `CMSG_PETITION_BUY`'s body is a fixed 72 bytes plus the name — the shape vmangos's reader
/// walks field by field. A body one byte short leaves the reader mid-field and the buy is
/// silently dropped, which is exactly the failure this pins.
/// A body one byte short leaves vmangos's reader mid-field and the buy is silently dropped.
#[test]
fn petition_buy_body_is_seventy_two_bytes_plus_the_name() {
let body = petition_buy(0x1234_5678_9abc_def0, "Test");
@@ -555,8 +377,6 @@ mod tests {
);
}
/// The charter constants are a shared contract between server and reference client, not
/// vmangos policy — the tooltip's signable branch keys on the same `0x2000`.
#[test]
fn charter_constants_match_the_world_data() {
assert_eq!(CHARTER_ITEM_ENTRY, 5863);
+9 -32
View File
@@ -1,47 +1,24 @@
//! The two **client-volunteered pose** bodies — sheath state and stand state. Split out of
//! `messages/spells.rs` (`set_sheathed`) and `messages/client.rs` (`stand_state_change`) by
//! decision 0640, which is also what mirrors `world::writer::pose`.
//!
//! They were apart for no reason anyone recorded, and they are plainly one thing: the client
//! decides, the server stores whatever we send with no validation of its own, and the echo into our
//! `UNIT_FIELD_BYTES_2` / `UNIT_FIELD_BYTES_1` is what every observer's body reads (decisions
//! 0080 / 0080c). The server has no independent way to know a weapon is drawn or that we sat down —
//! so the only consequence of getting one wrong is that other players see the wrong body.
//!
//! `CMSG_MOUNTSPECIAL_ANIM`, the third member of that family, has an empty body and so needs no
//! builder here (see `world::writer::pose`).
//!
//! The stand state also has an **inbound** twin, [`read_stand_state_update`] (decision 2339): the
//! server's own `SMSG_STANDSTATE_UPDATE`, one byte, which the reference applies to the local player
//! through the same setter the volunteered change goes through.
//! The client-volunteered pose bodies, sheath and stand state: the server stores what we send
//! unvalidated and echoes it through our unit fields, which every observer's body reads.
use std::io;
use crate::wire::read_u8;
/// Body of `CMSG_SETSHEATHED` (vmangos `SetSheathed::ReadFromWorldPacket`: `recv_data >> sheathed`):
/// one `u32` sheath state (0 unarmed/stowed, 1 melee drawn, 2 ranged drawn). Purely
/// client-volunteered — `HandleSetSheathedOpcode` (`CombatHandler.cpp:80-87`) just stores whatever
/// we send via `Unit::SetSheath`, which lands in our own `UNIT_FIELD_BYTES_2` and relays to nearby
/// observers on the next values update; the server has no independent way to know a weapon is drawn.
/// Body of `CMSG_SETSHEATHED`: a `u32` sheath state (0 stowed, 1 melee, 2 ranged), stored
/// unvalidated into `UNIT_FIELD_BYTES_2` (vmangos `CombatHandler.cpp:80-87`).
pub fn set_sheathed(state: u32) -> Vec<u8> {
state.to_le_bytes().to_vec()
}
/// Body of `CMSG_STANDSTATECHANGE` (vmangos `StandStateChange::ReadFromWorldPacket`:
/// `recv_data >> animState`): one `u32` stand state. The server accepts only
/// {0 STAND, 1 SIT, 3 SLEEP, 8 KNEEL} (`HandleStandStateChangeOpcode`,
/// `MiscHandler.cpp:437`) and applies it via `Unit::SetStandState` → `UNIT_FIELD_BYTES_1`
/// byte 0, which relays to every observer — the same client-volunteers → server-echoes →
/// fields-drive-everyone pattern as sheath (decision 0080c).
/// Body of `CMSG_STANDSTATECHANGE`: a `u32` stand state, of which the server accepts only
/// 0 stand, 1 sit, 3 sleep and 8 kneel (`MiscHandler.cpp:437`), into `UNIT_FIELD_BYTES_1` byte 0.
pub fn stand_state_change(state: u32) -> Vec<u8> {
state.to_le_bytes().to_vec()
}
/// Read `SMSG_STANDSTATE_UPDATE` → the stand state. VERIFIED vmangos `Unit::SetStandState`
/// (`Objects/Unit.cpp:9541`) → `WorldPackets::Misc::StandStateUpdate::AppendBodyTo`, one `u8`
/// (`UnitStandStateType`: 0 STAND · 1 SIT · 3 SLEEP · 8 KNEEL …). No guid: the reference's
/// `0x603e50` applies it to the local player whatever unit the server meant (2339).
/// Read `SMSG_STANDSTATE_UPDATE`: one `u8` stand state (vmangos `Unit.cpp:9541`), with no guid;
/// the reference (`0x603e50`) applies it to the local player whatever unit the server meant.
pub(super) fn read_stand_state_update(r: &mut &[u8]) -> io::Result<u8> {
read_u8(r)
}
@@ -52,7 +29,7 @@ mod inbound_tests {
#[test]
fn stand_state_update_decodes() {
// One byte (VERIFIED vmangos `Misc::StandStateUpdate::AppendBodyTo`); a drink's sit is 1.
// One byte; a drink's sit is 1.
match parse_server(opcode::SMSG_STANDSTATE_UPDATE, &[1]).unwrap() {
ServerPacket::StandStateUpdate { state } => assert_eq!(state, 1),
other => panic!("expected StandStateUpdate, got {}", other.name()),
@@ -1,31 +1,22 @@
//! Character-progression messages — XP awarded, the level-up summary, and the talent spend. Split
//! out of `messages/spells.rs` (decision 0640).
//!
//! One decision produced all three (**0304**), and they are one arc on the wire: kill or complete
//! something → `SMSG_LOG_XPGAIN` → (on a ding) `SMSG_LEVELUP_INFO` → the points it granted go back
//! out as `CMSG_LEARN_TALENT`. Layouts VERIFIED against vmangos (cited per item).
//! Character-progression messages: XP, exploration, level-up, talent learning and talent wipe.
use std::io::{self, Read};
use crate::wire::{read_f32_le, read_u32_le, read_u64_le, read_u8};
/// One decoded `SMSG_LOG_XPGAIN` — an XP award, kill or non-kill (vmangos
/// `WorldPackets::Misc::LogXpGain::AppendBodyTo`, `Server/Packets/Misc.cpp:512-522` +
/// `Misc.h:779-790`). `victim` is `0` for non-kill xp (quest/exploration/GM command).
/// `SMSG_LOG_XPGAIN`: an XP award; `victim` is 0 for non-kill XP (vmangos `Misc.cpp:512-522`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct XpGain {
pub victim: u64,
pub total: u32,
/// The award before the rested bonus — only a kill carries it on the wire (rested applies
/// to kill XP only, so a non-kill packet's `base` is `= total` here). `total − base` is the
/// chat line's "(+N exp Rested bonus)" amount (decision 0304).
/// The award before the rested bonus; only a kill carries it, else it equals `total`.
/// `total - base` is the chat line's "(+N exp Rested bonus)".
pub base: u32,
pub kill: bool,
}
/// Read `SMSG_LOG_XPGAIN`: victim raw `u64` (0 for non-kill) · totalXp u32 · xpType u8 (0 = kill,
/// 1 = non-kill) · iff `xpType == 0`: baseXp u32 · groupBonus f32 (read and dropped — composing
/// the "+N group bonus" line form waits for parties to exist; decision 0304 scope call).
/// Read `SMSG_LOG_XPGAIN`: `u64 victim, u32 total, u8 type` (0 kill, 1 other), then for a kill
/// `u32 base, f32 groupBonus`. Deviation: the group bonus is read and dropped.
pub(super) fn read_xp_gain(r: &mut impl Read) -> io::Result<XpGain> {
let victim = read_u64_le(r)?;
let total = read_u32_le(r)?;
@@ -44,32 +35,25 @@ pub(super) fn read_xp_gain(r: &mut impl Read) -> io::Result<XpGain> {
})
}
/// One decoded `SMSG_EXPLORATION_EXPERIENCE` — a first visit to an area: the newly explored
/// `AreaTable.dbc` row **id** (not the explore-flag bit) and the XP the discovery granted
/// (vmangos `WorldPackets::Misc::ExplorationExperience`, `Server/Packets/Misc.h:838-846` +
/// `Misc.cpp:552-556`; filled by `Player::CheckAreaExploreAndOutdoor`, Player.cpp:6228-6341,
/// which sends it **even when xp is 0** — max level, or an area with no level). The XP itself
/// also rides a separate non-kill [`XpGain`]; this packet is what names the area. Decision 0828.
/// `SMSG_EXPLORATION_EXPERIENCE`: a first visit to an area, sent even for 0 XP
/// (`Player.cpp:6228-6341`). The XP also arrives as a non-kill [`XpGain`]; this names the area.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExplorationXp {
/// The `AreaTable.dbc` row id of the area just discovered.
/// The `AreaTable.dbc` row id, not the explore-flag bit.
pub area_id: u32,
/// The exploration XP awarded (0 at max level / unleveled areas).
/// The XP awarded; 0 at max level or for an area without a level.
pub xp: u32,
}
/// Read `SMSG_EXPLORATION_EXPERIENCE`: areaId u32 · xp u32 (8 bytes, `Misc.cpp:552-556`).
/// Read `SMSG_EXPLORATION_EXPERIENCE`: `u32 areaId, u32 xp` (vmangos `Misc.cpp:552-556`).
pub(super) fn read_exploration_xp(r: &mut impl Read) -> io::Result<ExplorationXp> {
let area_id = read_u32_le(r)?;
let xp = read_u32_le(r)?;
Ok(ExplorationXp { area_id, xp })
}
/// Body of `CMSG_LEARN_TALENT` (vmangos `Server/Packets/Skill.h:10-19` +
/// `Player::LearnTalent`, Player.cpp:20807): `u32 talentId` (a `Talent.dbc` row id) +
/// `u32 requestedRank`, **0-based** — requesting rank k learns *up to* k (the server spends
/// `k − current + 1` points), so the click sends the current rank count itself (the next
/// 0-based rank). Decision 0304.
/// Body of `CMSG_LEARN_TALENT`: `u32` `Talent.dbc` id, `u32` 0-based rank. Requesting rank k
/// learns up to k (`Player.cpp:20807`), so a click sends the current rank count.
pub fn learn_talent(talent_id: u32, requested_rank: u32) -> Vec<u8> {
let mut body = Vec::with_capacity(8);
body.extend_from_slice(&talent_id.to_le_bytes());
@@ -77,26 +61,18 @@ pub fn learn_talent(talent_id: u32, requested_rank: u32) -> Vec<u8> {
body
}
/// One decoded **inbound** `MSG_TALENT_WIPE_CONFIRM` — a class trainer asking whether to unlearn
/// every talent (vmangos `WorldPackets::Skill::TalentWipeConfirmResponse`,
/// `Server/Packets/Skill.h:39-52` + `Skill.cpp:19-23`, filled by `Player::SendTalentWipeConfirm`,
/// `Player.cpp:8338`). Decision 1580.
/// Inbound `MSG_TALENT_WIPE_CONFIRM`: a class trainer asks whether to unlearn every talent
/// (vmangos `Player.cpp:8338`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TalentWipeConfirm {
/// The trainer that asked — echoed back in the outbound answer, where it is load-bearing
/// (`HandleTalentWipeConfirmOpcode` resolves it to a live `UNIT_NPC_FLAG_TRAINER` in range and
/// drops the request otherwise), exactly like the innkeeper guid in [`super::binder`].
///
/// **Zero means "you have no talents to reset"** — vmangos's own comment on the field, and the
/// value its refusal path sends (`SkillHandler.cpp:52`, after a failed `ResetTalents`). There
/// is no trainer to ask about, so there is nothing to put on screen.
/// The asking trainer, echoed in the answer and required to be a trainer in range there.
/// Zero means "you have no talents to reset" (vmangos `SkillHandler.cpp:52`).
pub trainer: u64,
/// What the reset costs, in copper — `Player::GetResetTalentsCost()`, which climbs with every
/// reset (the dialog's money frame, and the line "The cost will increase each time you do it").
/// Cost in copper, climbing with every reset (`Player::GetResetTalentsCost`).
pub cost: u32,
}
/// Read the inbound `MSG_TALENT_WIPE_CONFIRM`: trainer guid u64 · cost u32 (`Skill.cpp:19-23`).
/// Read the inbound `MSG_TALENT_WIPE_CONFIRM`: `u64 trainer, u32 cost` (`Skill.cpp:19-23`).
pub(super) fn read_talent_wipe_confirm(r: &mut impl Read) -> io::Result<TalentWipeConfirm> {
Ok(TalentWipeConfirm {
trainer: read_u64_le(r)?,
@@ -104,35 +80,28 @@ pub(super) fn read_talent_wipe_confirm(r: &mut impl Read) -> io::Result<TalentWi
})
}
/// Body of the **outbound** `MSG_TALENT_WIPE_CONFIRM` — the `CONFIRM_TALENT_WIPE` dialog's Accept
/// (vmangos `WorldPackets::Skill::TalentWipeConfirm`, `Skill.cpp:14-17`: one guid, no cost). The
/// guid is the one the question arrived with; the real client sends its own latched copy
/// (`0xc4d7a0`, wow-re `talent-api.md`), which is the same number. Decision 1580.
/// Body of the outbound `MSG_TALENT_WIPE_CONFIRM`, the `CONFIRM_TALENT_WIPE` Accept: the trainer
/// guid alone (vmangos `Skill.cpp:14-17`), as the reference latches it at `0xc4d7a0`.
pub fn talent_wipe_confirm(trainer_guid: u64) -> Vec<u8> {
trainer_guid.to_le_bytes().to_vec()
}
/// One decoded `SMSG_LEVELUP_INFO` — our own ding, self-addressed only (vmangos
/// `WorldPackets::Misc::LevelUpInfo`, `Misc.h:793-802` + `Misc.cpp:524-532`; filled by
/// `Player::GiveLevel`, Player.cpp:3210-3218, and sent straight to the leveling session — no
/// guid). Decision 0304.
/// `SMSG_LEVELUP_INFO`: our own level-up, sent only to us with no guid (`Player.cpp:3210-3218`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LevelUpInfo {
/// The level just reached.
pub level: u32,
/// Hit points gained.
pub health: u32,
/// Power gains indexed mana, rage, focus, energy, happiness. The 1.12 server fills mana (or
/// zero for non-mana classes) and leaves the rest zero; `powers[0]` is the reference chat
/// line's mana arg.
/// Power gains: mana, rage, focus, energy, happiness. The server fills only mana, which is
/// the reference chat line's mana argument.
pub powers: [u32; 5],
/// Stat gains indexed str, agi, stam, int, spirit — the reference chat's `SPELL_STAT0..4`
/// order, one line per positive entry.
/// Stat gains in `SPELL_STAT0..4` order (str, agi, stam, int, spirit), one chat line per
/// positive entry.
pub stats: [u32; 5],
}
/// Read `SMSG_LEVELUP_INFO`: level u32 · healthGain u32 · powerGains u32×5 · statGains u32×5
/// (48 bytes, `Misc.cpp:524-532`).
/// Read `SMSG_LEVELUP_INFO`: `u32` level, health, 5 powers, 5 stats (`Misc.cpp:524-532`).
pub(super) fn read_level_up_info(r: &mut impl Read) -> io::Result<LevelUpInfo> {
let level = read_u32_le(r)?;
let health = read_u32_le(r)?;
@@ -156,8 +125,6 @@ pub(super) fn read_level_up_info(r: &mut impl Read) -> io::Result<LevelUpInfo> {
mod tests {
use super::*;
/// The respec pair, both directions: the question carries guid + cost, the answer carries the
/// guid alone. Asymmetric bodies on ONE opcode is the whole reason this is an `MSG_`.
#[test]
fn the_talent_wipe_pair_round_trips() {
let trainer: u64 = 0xF130_0000_0000_2A01;
@@ -173,9 +140,6 @@ mod tests {
assert_eq!(talent_wipe_confirm(trainer), trainer.to_le_bytes().to_vec());
}
/// vmangos's "you have no talents to reset" reply — guid 0, and a cost that is still filled in.
/// It parses; what to DO with it is the app's call (decision 1580: there is no trainer to ask
/// about, so nothing goes on screen).
#[test]
fn a_zero_trainer_is_a_parseable_refusal() {
let mut body = 0u64.to_le_bytes().to_vec();
+43 -141
View File
@@ -1,68 +1,35 @@
//! The two honor-system wire messages (decision 1512): the inspect-honor request/reply pair and
//! the honor-gain credit. Every shape below is VERIFIED against vmangos source (file:line cites
//! per item); the goldens are hand-built against the server's own `AppendBodyTo` order.
//!
//! The *durable* honor state is not here — it rides the player descriptor, eleven PRIVATE fields
//! plus the PUBLIC current-rank byte ([`crate::messages::ObjectFields::player_pvp_rank`] and
//! friends). These two packets cover exactly what the descriptor cannot: **somebody else's**
//! honor stats (which never stream to us), and the *moment* a kill pays out.
//!
//! Not modelled here (known siblings, VERIFIED present in vmangos `Opcodes_1_12_1.h`): the
//! battleground family — `MSG_PVP_LOG_DATA` (736), `CMSG`/`SMSG_BATTLEFIELD_LIST` (572/573) and
//! `…_STATUS` (723/724) — which 0208/0631 defer wholesale, and `SMSG_ZONE_UNDER_ATTACK` (596), a
//! world broadcast rather than an honor message.
//! The honor wire messages: the inspect-honor request/reply pair and the honor-gain credit.
//! Durable honor state rides the player descriptor; these carry what it cannot, another player's
//! stats and the moment a kill pays out.
use std::io;
use crate::wire::{read_i32_le, read_u16_le, read_u32_le, read_u64_le, read_u8};
/// Another player's honor stats — the `MSG_INSPECT_HONOR_STATS` **reply** (opcode `0x2D6`/726,
/// VERIFIED vmangos `Opcodes_1_12_1.h`; already named in `opcode_names`). A 50-byte body, all
/// little-endian with no padding, in the exact order
/// `WorldPackets::Misc::InspectHonorStatsResponse::AppendBodyTo` writes it
/// (`Server/Packets/Misc.cpp:301-325`). Every `#if SUPPORTED_CLIENT_BUILD >= CLIENT_BUILD_1_6_1`
/// block in that function is live for build 5875, so all three conditional members are present.
///
/// This exists because the honor descriptor fields are PRIVATE: nothing about a *foreign* player's
/// kills, honor or progress bar is on the wire until we ask. The one exception is their current
/// rank, which streams publicly in `PLAYER_BYTES_3` and so is **not** in this packet —
/// [`crate::messages::ObjectFields::player_pvp_rank`] answers it off the target's own descriptor.
///
/// The server builds every value straight out of the target's descriptor
/// (`Handlers/MiscHandler.cpp:962-1010`), so these fields are the inspected player's own
/// `PLAYER_FIELD_*` honor block, re-serialized — with two shape differences worth knowing:
/// `sessionKills` arrives as the raw packed dword rather than a split pair, and the three weekly
/// HK counts arrive as bare `u16`s with a separate (dead) dishonorable slot beside each.
/// Another player's honor stats, the `MSG_INSPECT_HONOR_STATS` reply (opcode 726): 50 bytes in
/// vmangos `Misc.cpp:301-325` order, from the target's `PLAYER_FIELD_*` honor block. Their
/// current rank is not here; it streams publicly in `PLAYER_BYTES_3`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InspectHonorStats {
/// Whose stats these are — echoed from our request (`MiscHandler.cpp:977`). The reply carries
/// no error shape at all: the server simply does not answer when the target is missing, out of
/// `INSPECT_DISTANCE` (10 yd), or a valid attack target, so a silent non-reply is the refusal.
/// Echoed from the request. There is no error reply: a missing, distant (10 yd) or hostile
/// target gets no answer at all (`MiscHandler.cpp:977`).
pub player_guid: u64,
/// The target's **highest lifetime** honor rank (`GetHighestRank().rank`,
/// `MiscHandler.cpp:985`) — the *internal* rank 0..18, the same domain as
/// [`crate::messages::ObjectFields::player_honor_rank`], NOT the visual number the UI draws.
/// Their *current* rank is the PUBLIC descriptor byte, not this.
/// The highest lifetime rank (`MiscHandler.cpp:985`), internal 0..18 like
/// [`crate::messages::ObjectFields::player_honor_rank`], not the visual number.
pub highest_rank: u8,
/// Today's kills as the raw packed `PLAYER_FIELD_SESSION_KILLS` dword — low `u16` honorable,
/// high `u16` dishonorable (`MiscHandler.cpp:988` reads the whole field with
/// `GetUInt32Value`). Split with [`InspectHonorStats::session_kills`].
/// Today's kills, the packed `PLAYER_FIELD_SESSION_KILLS` dword (`MiscHandler.cpp:988`).
pub session_kills: u32,
/// Yesterday's honorable kills (`GetUInt16Value(PLAYER_FIELD_YESTERDAY_KILLS, 0)`).
/// Yesterday's honorable kills, the low half of `PLAYER_FIELD_YESTERDAY_KILLS`.
pub yesterday_hk: u16,
/// Written `0` by vmangos (`MiscHandler.cpp:994`, comment *"Unknown (deprecated, yesterday
/// dishonourable?)"*). Decoded and carried rather than skipped: whether the real client reads
/// these three slots at all is a question currently out with an RE orchestrator in
/// `wow-5875-re`, and a field we decode and ignore is honest where a silently-skipped one
/// would be a landmine if the verdict comes back "it reads them".
/// Always 0 from vmangos (`MiscHandler.cpp:994`); decoded, not skipped.
pub unknown_old1: u16,
/// Last week's honorable kills.
pub last_week_hk: u16,
/// Written `0` by vmangos — see [`InspectHonorStats::unknown_old1`].
/// Always 0 from vmangos.
pub unknown_old2: u16,
/// This week's honorable kills (a 1.6.0 addition; live for 5875).
/// This week's honorable kills.
pub this_week_hk: u16,
/// Written `0` by vmangos — see [`InspectHonorStats::unknown_old1`].
/// Always 0 from vmangos.
pub unknown_old3: u16,
/// Lifetime honorable kills (`PLAYER_FIELD_LIFETIME_HONORBALE_KILLS`, vmangos's spelling).
pub lifetime_hk: u32,
@@ -72,35 +39,24 @@ pub struct InspectHonorStats {
pub yesterday_honor: u32,
/// Last week's honor points.
pub last_week_honor: u32,
/// This week's honor points (a 1.6.0 addition; live for 5875).
/// This week's honor points.
pub this_week_honor: u32,
/// Last week's **standing** — the numeric ladder position, not a rank
/// (`PLAYER_FIELD_LAST_WEEK_RANK`; `0` = unranked that week).
/// Last week's standing, the ladder position and not a rank; 0 if unranked that week.
pub last_week_rank: u32,
/// The rank progress bar, `0..255` **within the target's current rank** (a 1.6.0 addition;
/// live for 5875). See [`crate::messages::ObjectFields::player_honor_rank_bar`] for the
/// server's computation and the negative-rank wrap.
/// Progress within the target's current rank, 0..255, computed as
/// [`crate::messages::ObjectFields::player_honor_rank_bar`] is.
pub rank_bar: u8,
}
impl InspectHonorStats {
/// [`Self::session_kills`] split into `(honorable, dishonorable)` — the `TWO_SHORT` packing
/// the descriptor field uses (low half, then high half). The wire carries the dword whole
/// here, so unlike the descriptor accessor this split is ours to do.
/// The packed field split into `(honorable, dishonorable)`, low half then high half.
pub fn session_kills(&self) -> (u16, u16) {
(self.session_kills as u16, (self.session_kills >> 16) as u16)
}
}
/// Read the `MSG_INSPECT_HONOR_STATS` **reply** (see [`InspectHonorStats`]): 50 bytes in
/// `AppendBodyTo` order. A short body errors (`UnexpectedEof`) rather than yielding a
/// half-populated struct.
///
/// The opcode is an **`MSG_`**: the same number 0x2D6 carries our request *and* the server's
/// reply, and the two bodies are different shapes (8 bytes vs 50). Nothing has to disambiguate
/// them, because direction does it: [`super::parse_server`] is fed only by the world *reader*, so
/// every 0x2D6 body it ever sees is a reply. Our own request never passes through here — it is
/// built by [`inspect_honor_stats`] and handed straight to the writer.
/// Read the `MSG_INSPECT_HONOR_STATS` reply. Our 8-byte request shares the opcode, but only
/// server packets reach this reader.
pub(super) fn read_inspect_honor_stats(r: &mut &[u8]) -> io::Result<InspectHonorStats> {
Ok(InspectHonorStats {
player_guid: read_u64_le(r)?,
@@ -122,66 +78,30 @@ pub(super) fn read_inspect_honor_stats(r: &mut &[u8]) -> io::Result<InspectHonor
})
}
/// Body of the `MSG_INSPECT_HONOR_STATS` **request**: the inspected player's full 8-byte GUID and
/// nothing else (VERIFIED vmangos `WorldPackets::Misc::InspectHonorStats::ReadFromWorldPacket`,
/// `Server/Packets/Misc.cpp:93-96` — `recv_data >> guid`, a raw `uint64`, not a packed guid).
///
/// Server gates (`MiscHandler.cpp:962-972`) are the same three `CMSG_INSPECT` applies: the target
/// must be an online player, within `INSPECT_DISTANCE` (10.0), and not a valid attack target. Each
/// refusal is a silent `return` — there is no error reply, so a consumer must not block on an
/// answer that may never come. Unlike `CMSG_INSPECT`, this handler does **not** set our selection
/// (`HandleInspectOpcode` opens with `SetSelectionGuid`; this one does not), which is why the send
/// lives in `world::writer::pvp` rather than `writer::selection`.
/// Body of the `MSG_INSPECT_HONOR_STATS` request: the raw, unpacked `u64` guid (vmangos
/// `Misc.cpp:93-96`). A refused request gets no answer, and unlike `CMSG_INSPECT` it does not
/// set our selection (`MiscHandler.cpp:962-972`).
pub fn inspect_honor_stats(guid: u64) -> Vec<u8> {
guid.to_le_bytes().to_vec()
}
/// An honor payout (`SMSG_PVP_CREDIT`, opcode `0x28C`/652 — VERIFIED vmangos
/// `Opcodes_1_12_1.h`; already named in `opcode_names`). A 16-byte body in the order
/// `WorldPackets::Misc::PvpCredit::AppendBodyTo` writes it (`Server/Packets/Misc.cpp:376-381`),
/// sent from `HonorMgr::SendPVPCredit` (`HonorMgr.cpp:1061-1093`) on every honor row the server
/// books — **including a dishonorable one**, which is why [`PvpCredit::honor`] is signed.
///
/// This is the *only* notice of a specific payout: the descriptor's contribution fields move too,
/// but they are running totals with no attribution. The chat line and the floating combat text
/// both have to come from here.
/// `SMSG_PVP_CREDIT` (opcode 652): one honor payout, dishonorable ones included (vmangos
/// `HonorMgr.cpp:1061-1093`). The descriptor holds only totals; this is the only per-payout notice.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PvpCredit {
/// Honor points, truncated to an integer server-side (`static_cast<int32>(honor)`).
///
/// **Signed, and genuinely negative for a dishonorable kill**: `HonorMgr::Add` computes
/// `honor = (type == DISHONORABLE) ? -cp : cp` (`HonorMgr.cpp:807`) and hands *that* to
/// `SendPVPCredit` (`HonorMgr.cpp:833`). A `u32` here would read a DK penalty as ~4 billion
/// honor.
/// Honor, truncated server-side; negative for a dishonorable kill (`HonorMgr.cpp:807`).
pub honor: i32,
/// Who we killed. **`0` means there is no victim** — vmangos only fills the guid inside
/// `if (victim)` (`HonorMgr.cpp:1069-1071`), and it passes the *pre-fallback* `realSource`, so
/// an honor row booked with no source at all arrives with a zero guid. The UI needs a
/// victim-less phrasing rather than resolving a name for guid 0.
/// Who we killed; 0 for an honor row with no source (`HonorMgr.cpp:1069-1071`), which
/// needs a victim-less phrasing rather than a name lookup.
pub victim_guid: u64,
/// The victim's **internal** rank — the same `[0..18]` domain as the descriptor's rank bytes
/// and [`InspectHonorStats::highest_rank`], and therefore a direct index into the
/// `PVP_RANK_<rank>_<team>` GlobalStrings. NOT the visual rank the badge texture uses; do the
/// `internal > 4 ? internal - 4 : -internal` conversion (`HonorMgr.cpp:991`) at the display
/// edge if a badge is wanted, and do not do it twice.
///
/// VERIFIED `HonorMgr::SendPVPCredit` (`HonorMgr.cpp:1078-1089`): a **player** victim sends
/// `GetHonorMgr().GetRank().rank`, a **creature** victim sends `19` iff it is a racial leader
/// and otherwise leaves the field at its `0` default.
///
/// One quirk to observe rather than imitate: for a player victim vmangos **floors the value at
/// 5** (`if (!rank) rank = (HONOR_RANK_COUNT - POSITIVE_HONOR_RANK_COUNT) + 1`, i.e. "at least
/// Scout"), with the comment *"Never display just 'HK:' without rank name"*. That is the
/// server working around a client that would otherwise print an empty rank name — it is
/// **server behaviour we receive**, not a rule we should re-implement: we render whatever
/// number arrives, and an unranked-victim `0` from some other server must still come out
/// sanely at the display edge.
///
/// `i32` because that is the wire type; nothing vmangos sends here is negative.
/// The victim's internal rank (0..18), indexing the `PVP_RANK_<rank>_<team>` strings; the
/// badge's visual rank is `internal > 4 ? internal - 4 : -internal` (`HonorMgr.cpp:991`).
/// A creature sends 19 if a racial leader, else 0; vmangos floors a player victim at 5
/// (`HonorMgr.cpp:1078-1089`), a server fallback rather than a rule to re-implement.
pub victim_rank: i32,
}
/// Read `SMSG_PVP_CREDIT` (see [`PvpCredit`]): `i32 honor`, `u64 victimGuid`, `i32 victimRank`.
/// Read `SMSG_PVP_CREDIT`: `i32 honor, u64 victimGuid, i32 victimRank`.
pub(super) fn read_pvp_credit(r: &mut &[u8]) -> io::Result<PvpCredit> {
Ok(PvpCredit {
honor: read_i32_le(r)?,
@@ -194,9 +114,6 @@ pub(super) fn read_pvp_credit(r: &mut &[u8]) -> io::Result<PvpCredit> {
mod tests {
use super::*;
/// The 50-byte reply, hand-built in `InspectHonorStatsResponse::AppendBodyTo` order with the
/// field boundaries commented — a level-60 target with a rank-11 lifetime best, kills in every
/// bucket, and a bar three-quarters of the way through the current rank.
#[test]
fn inspect_honor_stats_golden() {
#[rustfmt::skip]
@@ -205,11 +122,11 @@ mod tests {
0xB3, 0x2A, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
// [8] u8 highestRank = 11 (internal)
0x0B,
// [9..13) u32 sessionKills = 0x0002_0011 — 17 HK low, 2 DK high
// [9..13) u32 sessionKills = 0x0002_0011: 17 HK low, 2 DK high
0x11, 0x00, 0x02, 0x00,
// [13..15) u16 yesterdayHK = 41
0x29, 0x00,
// [15..17) u16 unknownOld1 = 0 (server always writes 0)
// [15..17) u16 unknownOld1 = 0
0x00, 0x00,
// [17..19) u16 lastWeekHK = 420
0xA4, 0x01,
@@ -229,7 +146,7 @@ mod tests {
0xEF, 0x20, 0x00, 0x00,
// [41..45) u32 thisWeekHonor = 1250
0xE2, 0x04, 0x00, 0x00,
// [45..49) u32 lastWeekRank = 57 (the STANDING)
// [45..49) u32 lastWeekRank = 57 (the standing)
0x39, 0x00, 0x00, 0x00,
// [49] u8 rankBar = 191
0xBF,
@@ -258,13 +175,10 @@ mod tests {
rank_bar: 191,
}
);
// The packed today-kills dword splits the same way the descriptor's TWO_SHORT does.
assert_eq!(stats.session_kills(), (17, 2));
}
/// A truncated reply must ERROR, not panic and not yield a half-populated struct — the wire is
/// hostile input. 49 bytes is the nastiest case: everything but the trailing `rankBar`, which
/// is exactly what a pre-1.6.1 server would send.
/// 49 bytes is everything but the trailing `rankBar`, which is what a pre-1.6.1 server sends.
#[test]
fn inspect_honor_stats_truncated_body_errors() {
let full = [0u8; 50];
@@ -275,25 +189,21 @@ mod tests {
"a {len}-byte inspect-honor body must be rejected"
);
}
// …and the full length still parses, so the loop above is testing truncation and not a
// decoder that rejects everything.
// The full length parses, so the loop above really tests truncation.
let mut r = full.as_slice();
assert!(read_inspect_honor_stats(&mut r).is_ok());
}
/// The request is the bare 8-byte guid — byte-exact against
/// `InspectHonorStats::ReadFromWorldPacket`'s single `recv_data >> guid`.
#[test]
fn inspect_honor_stats_request_golden() {
assert_eq!(
inspect_honor_stats(0x0000_0001_0000_2AB3),
vec![0xB3, 0x2A, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00],
);
// No sentinel handling: a zero guid is encoded as-is (the server answers by not answering).
// A zero guid is encoded as-is.
assert_eq!(inspect_honor_stats(0), vec![0u8; 8]);
}
/// The 16-byte credit body, hand-built in `PvpCredit::AppendBodyTo` order.
#[test]
fn pvp_credit_golden() {
#[rustfmt::skip]
@@ -302,7 +212,7 @@ mod tests {
0x8F, 0x00, 0x00, 0x00,
// [4..12) u64 victimGuid = 0x0000_0001_0000_2AB3
0xB3, 0x2A, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
// [12..16) i32 victimRank = 11 (INTERNAL rank — the PVP_RANK_11_<team> key)
// [12..16) i32 victimRank = 11 (internal rank, the PVP_RANK_11_<team> key)
0x0B, 0x00, 0x00, 0x00,
];
let mut r = body.as_slice();
@@ -318,9 +228,6 @@ mod tests {
);
}
/// A **dishonorable** kill rides the same packet with a negative `honor`
/// (`HonorMgr.cpp:807`, `honor = -cp`) — the reason the field is `i32`. Decoded as `u32` this
/// body would read as 4 294 967 291 honor.
#[test]
fn pvp_credit_dishonorable_kill_is_negative_honor() {
let mut body = Vec::with_capacity(16);
@@ -334,10 +241,6 @@ mod tests {
assert_eq!(credit.victim_rank, 5);
}
/// The victim-less shape the UI has to phrase differently: vmangos fills the guid only inside
/// `if (victim)` and passes the pre-fallback `realSource`, so an honor row with no source
/// arrives as a zero guid with the rank left at its `0` default (which is also what a
/// non-racial-leader creature victim sends).
#[test]
fn pvp_credit_zero_guid_means_no_victim() {
let mut body = Vec::with_capacity(16);
@@ -351,7 +254,6 @@ mod tests {
assert_eq!(credit.victim_rank, 0);
}
/// A truncated credit body errors rather than panicking.
#[test]
fn pvp_credit_truncated_body_errors() {
let full = [0u8; 16];
@@ -1,34 +1,15 @@
//! `SMSG_QUESTGIVER_*` / `CMSG_QUESTGIVER_*` — the "accept/turn-in a quest at an NPC" family
//! (opcodes 386-402, vmangos `Opcodes_1_12_1.h`, VERIFIED). This is the *giver panel* slice
//! (decision 0088): the four questgiver windows carry their own text, so it needs none of the full
//! `SMSG_QUEST_QUERY_RESPONSE` template — that lives in the sibling [`super::log`] slice. CMSG
//! bodies from vmangos `Server/Packets/Quest.cpp:6-78`; SMSG layouts from the same file's
//! `AppendBodyTo` writers (line citations inline).
//!
//! Wire traps this parser is written against the *byte stream* to survive (never the C++ control
//! flow):
//! - **QUEST_LIST icon is a `u32`** — `QuestListEntry`'s `icon` field is `uint32` (`Quest.h:270`),
//! not the `u8` the gossip-menu option icon uses.
//! - **OFFER_REWARD emotes are `{delay, emote}`** — reversed from DETAILS' `{emote, delay}`
//! (`Quest.cpp:271` vs `:308-309`).
//! - **DETAILS rewards parse is uniform across the HIDDEN_REWARDS fork** — the hidden branch writes
//! three `u32` zeros (`Quest.cpp:235-237`), which a count-prefixed reader consumes as
//! `choiceCount=0, rewCount=0, money=0`, identical in shape to an empty rewards block. The parser
//! can't see the quest flag, and it doesn't need to.
//! The questgiver family (opcodes 386-402): the NPC accept, progress and reward panels, which
//! carry their own text. Layouts from vmangos `Server/Packets/Quest.cpp`.
use std::io;
use crate::wire::{capacity_hint, read_cstring, read_i32_le, read_u32_le, read_u64_le, read_u8};
/// `QUEST_EMOTE_COUNT` (vmangos `QuestDef.h:43`) — DETAILS always writes exactly this many emote
/// pairs; OFFER_REWARD writes a variable count (0..=4), so only DETAILS relies on the constant.
/// vmangos `QUEST_EMOTE_COUNT` (`QuestDef.h:43`): the emote pairs DETAILS always writes.
pub const QUEST_EMOTE_COUNT: u32 = 4;
/// `DIALOG_STATUS_*` (vmangos `QuestDef.h:120-129`) — the questgiver dialog status a
/// `SMSG_QUESTGIVER_STATUS` carries per NPC guid, and the per-quest `icon` in a `QUEST_LIST` /
/// gossip quest entry. Drives the `!`/`?` over the NPC head (a world-marker concern, out of the
/// panel slice — parsed + stored per guid now, rendered later) and the greeting panel's
/// active-vs-available split.
/// vmangos `DIALOG_STATUS_*` (`QuestDef.h:120-129`): an NPC's `SMSG_QUESTGIVER_STATUS`, which
/// drives the `!`/`?` over its head, and a quest row's `icon`.
pub mod dialog_status {
pub const NONE: u32 = 0;
pub const UNAVAILABLE: u32 = 1;
@@ -40,10 +21,8 @@ pub mod dialog_status {
pub const REWARD2: u32 = 7;
}
/// One reward/choice item on a questgiver panel (`{itemId u32, count u32, displayId u32}` triple,
/// as written in DETAILS/OFFER_REWARD — vmangos `Quest.cpp:244-249` etc.). `display_id` →
/// `ItemDisplayInfo.dbc` icon, exactly like a vendor row; a `0` id means the server had no template
/// (writes a zero triple, `Quest.cpp:248-249`).
/// One reward or choice item on a questgiver panel (vmangos `Quest.cpp:244-249`). `display_id`
/// is an `ItemDisplayInfo.dbc` row; an all-zero triple means the server had no item template.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct QuestRewardItem {
pub item_id: u32,
@@ -51,13 +30,10 @@ pub struct QuestRewardItem {
pub display_id: u32,
}
/// One required item on the progress panel (`SMSG_QUESTGIVER_REQUEST_ITEMS`), same triple shape as
/// [`QuestRewardItem`] (vmangos `Quest.cpp:376-381`).
/// A required item on the progress panel, the same triple (vmangos `Quest.cpp:376-381`).
pub type QuestRequiredItem = QuestRewardItem;
/// One entry in a `SMSG_QUESTGIVER_QUEST_LIST` (the greeting panel's quest rows). `icon` is a
/// [`dialog_status`] value (**`u32`** — `Quest.h:270`, the list-entry trap), `level` the quest's
/// display level, `title` the row label.
/// One greeting-panel quest row; `icon` is a [`dialog_status`] value, a `u32` (`Quest.h:270`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QuestListEntry {
pub quest_id: u32,
@@ -76,9 +52,8 @@ pub struct QuestGiverList {
pub quests: Vec<QuestListEntry>,
}
/// `SMSG_QUESTGIVER_QUEST_DETAILS` (vmangos `Quest.cpp:205-273`): the accept panel — full quest
/// text + the rewards on offer. `money` is the reward money (`GetRewOrReqMoney`, `i32`; negative on
/// a money-*sink* quest, which DETAILS shows as a required cost — rare, kept signed).
/// `SMSG_QUESTGIVER_QUEST_DETAILS` (vmangos `Quest.cpp:205-273`): the accept panel. `money` is
/// signed, negative for a quest that costs money (`GetRewOrReqMoney`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QuestDetails {
pub npc: u64,
@@ -93,9 +68,7 @@ pub struct QuestDetails {
pub reward_spell: u32,
}
/// `SMSG_QUESTGIVER_OFFER_REWARD` (vmangos `Quest.cpp:275-339`): the reward panel — the turn-in
/// text + the rewards to grant. Note the emote pairs are `{delay, emote}` (reversed vs DETAILS) and
/// are consumed for alignment only.
/// `SMSG_QUESTGIVER_OFFER_REWARD` (vmangos `Quest.cpp:275-339`): the reward panel.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QuestOfferReward {
pub npc: u64,
@@ -110,10 +83,8 @@ pub struct QuestOfferReward {
pub reward_spell: u32,
}
/// `SMSG_QUESTGIVER_REQUEST_ITEMS` (vmangos `Quest.cpp:341-391`): the progress panel — the "bring
/// me these" text + required items/money, and the four completion flags the client ANDs to enable
/// the Continue button. `is_complete` is derived from the second flag word (`0x03` complete /
/// `0x00` not — `Quest.cpp:388`), the only place the completability reaches the client.
/// `SMSG_QUESTGIVER_REQUEST_ITEMS` (vmangos `Quest.cpp:341-391`): the progress panel. The client
/// ANDs four flag words to enable Continue; `is_complete` comes from the second (`Quest.cpp:388`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QuestRequestItems {
pub npc: u64,
@@ -127,9 +98,8 @@ pub struct QuestRequestItems {
pub is_complete: bool,
}
/// `SMSG_QUESTGIVER_QUEST_COMPLETE` (vmangos `Quest.cpp:96-108`): the turn-in result — the XP +
/// money granted and the fixed items handed over. The reward *item* rows here carry only
/// `{itemId, count}` (no display id — the client already knows them from the offer panel).
/// `SMSG_QUESTGIVER_QUEST_COMPLETE` (vmangos `Quest.cpp:96-108`): the XP, money and fixed items
/// granted; the item rows carry no display id.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QuestComplete {
pub quest_id: u32,
@@ -138,52 +108,46 @@ pub struct QuestComplete {
pub items: Vec<(u32, u32)>,
}
// ── CMSG encoders (all trivial, readers at vmangos `Quest.cpp:6-50`) ──────────────────────────────
// ── CMSG encoders (vmangos `Quest.cpp:6-50`) ─────────────────────────────────────────────────────
/// Body of `CMSG_QUESTGIVER_STATUS_QUERY` (`Quest.cpp:11-14`): one `u64` NPC guid. Answered by
/// `SMSG_QUESTGIVER_STATUS`.
/// Body of `CMSG_QUESTGIVER_STATUS_QUERY` (`Quest.cpp:11-14`): the `u64` NPC guid.
pub fn questgiver_status_query(npc: u64) -> Vec<u8> {
npc.to_le_bytes().to_vec()
}
/// Body of `CMSG_QUESTGIVER_HELLO` (`Quest.cpp:16-19`): one `u64` NPC guid. The same
/// `SendPreparedGossip` path as `CMSG_GOSSIP_HELLO` (vmangos `QuestHandler.cpp:103`), so benilla's
/// interact route can keep sending gossip-hello; this opener exists for completeness/parity.
/// Body of `CMSG_QUESTGIVER_HELLO` (`Quest.cpp:16-19`): the NPC guid. The server answers it like
/// `CMSG_GOSSIP_HELLO`, through `SendPreparedGossip` (`QuestHandler.cpp:103`).
pub fn questgiver_hello(npc: u64) -> Vec<u8> {
npc.to_le_bytes().to_vec()
}
/// Body of `CMSG_QUESTGIVER_QUERY_QUEST` (`Quest.cpp:27-31`): `u64 guid, u32 quest`. Answered by
/// `SMSG_QUESTGIVER_QUEST_DETAILS` (vmangos `QuestHandler.cpp:224`, always — this is "look at an
/// available quest"). The click a greeting/gossip quest row makes.
/// Body of `CMSG_QUESTGIVER_QUERY_QUEST` (`Quest.cpp:27-31`): `u64 guid, u32 quest`, sent by a
/// quest row's click and always answered with the details panel (`QuestHandler.cpp:224`).
pub fn questgiver_query_quest(npc: u64, quest: u32) -> Vec<u8> {
guid_quest(npc, quest)
}
/// Body of `CMSG_QUESTGIVER_ACCEPT_QUEST` (`Quest.cpp:21-25`): `u64 guid, u32 quest`. Adds the quest
/// to the log, then the server closes the gossip window (`SMSG_GOSSIP_COMPLETE`).
/// Body of `CMSG_QUESTGIVER_ACCEPT_QUEST` (`Quest.cpp:21-25`): `u64 guid, u32 quest`. The server
/// logs the quest, then closes the gossip window (`SMSG_GOSSIP_COMPLETE`).
pub fn questgiver_accept_quest(npc: u64, quest: u32) -> Vec<u8> {
guid_quest(npc, quest)
}
/// Body of `CMSG_QUESTGIVER_COMPLETE_QUEST` (`Quest.cpp:46-50`): `u64 guid, u32 quest`. Asks the
/// server to show the turn-in **progress** panel (`SMSG_QUESTGIVER_REQUEST_ITEMS`, vmangos
/// `QuestHandler.cpp:383-397`) — the alternate entry from a quest-log completable quest.
/// Body of `CMSG_QUESTGIVER_COMPLETE_QUEST` (`Quest.cpp:46-50`): `u64 guid, u32 quest`, answered
/// with the progress panel (`QuestHandler.cpp:383-397`).
pub fn questgiver_complete_quest(npc: u64, quest: u32) -> Vec<u8> {
guid_quest(npc, quest)
}
/// Body of `CMSG_QUESTGIVER_REQUEST_REWARD` (`Quest.cpp:40-44`): `u64 guid, u32 quest`. The progress
/// panel's Continue button — advances to the **reward** panel (`SMSG_QUESTGIVER_OFFER_REWARD`,
/// vmangos `QuestHandler.cpp:286-312`, which first `CompleteQuest`s if it can).
/// Body of `CMSG_QUESTGIVER_REQUEST_REWARD` (`Quest.cpp:40-44`): `u64 guid, u32 quest`, the
/// progress panel's Continue, answered with the reward panel (`QuestHandler.cpp:286-312`).
pub fn questgiver_request_reward(npc: u64, quest: u32) -> Vec<u8> {
guid_quest(npc, quest)
}
/// Body of `CMSG_QUESTGIVER_CHOOSE_REWARD` (`Quest.cpp:33-38`): `u64 guid, u32 quest, u32 reward`.
/// `reward` is the **index** into the quest's choice items (0-based; ignored when the quest has no
/// choice rewards — pass 0). The reward panel's Complete button — grants the reward and answers
/// `SMSG_QUESTGIVER_QUEST_COMPLETE` (vmangos `QuestHandler.cpp:239-284`).
/// Body of `CMSG_QUESTGIVER_CHOOSE_REWARD` (`Quest.cpp:33-38`): `u64 guid, u32 quest, u32 reward`,
/// `reward` a 0-based choice index (0 with no choice). Sent by the reward panel's Complete and
/// answered with `SMSG_QUESTGIVER_QUEST_COMPLETE` (`QuestHandler.cpp:239-284`).
pub fn questgiver_choose_reward(npc: u64, quest: u32, reward: u32) -> Vec<u8> {
let mut body = Vec::with_capacity(16);
body.extend_from_slice(&npc.to_le_bytes());
@@ -192,7 +156,6 @@ pub fn questgiver_choose_reward(npc: u64, quest: u32, reward: u32) -> Vec<u8> {
body
}
/// The shared `{u64 guid, u32 quest}` body of the five simple questgiver CMSGs.
fn guid_quest(npc: u64, quest: u32) -> Vec<u8> {
let mut body = Vec::with_capacity(12);
body.extend_from_slice(&npc.to_le_bytes());
@@ -200,10 +163,8 @@ fn guid_quest(npc: u64, quest: u32) -> Vec<u8> {
body
}
// ── SMSG parsers ──────────────────────────────────────────────────────────────────────────────────
// ── SMSG parsers ─────────────────────────────────────────────────────────────────────────────────
/// Read a `{itemId u32, count u32, displayId u32}` reward/choice/required triple (the shape shared
/// by DETAILS/OFFER_REWARD/REQUEST_ITEMS).
fn read_item_triple(r: &mut &[u8]) -> io::Result<QuestRewardItem> {
Ok(QuestRewardItem {
item_id: read_u32_le(r)?,
@@ -212,11 +173,9 @@ fn read_item_triple(r: &mut &[u8]) -> io::Result<QuestRewardItem> {
})
}
/// Read a count-prefixed run of item triples (`u32 count` then that many triples).
fn read_item_block(r: &mut &[u8]) -> io::Result<Vec<QuestRewardItem>> {
let count = read_u32_le(r)?;
// The wider of the two lists this reads: `QUEST_REWARD_CHOICES_COUNT` 6 (vmangos
// `QuestDef.h:39`; the fixed rewards are `QUEST_REWARDS_COUNT` 4).
// `QUEST_REWARD_CHOICES_COUNT` 6, the wider of the two lists (vmangos `QuestDef.h:39`).
let mut items = Vec::with_capacity(capacity_hint(count, 6));
for _ in 0..count {
items.push(read_item_triple(r)?);
@@ -229,18 +188,16 @@ pub(in crate::messages) fn read_questgiver_status(r: &mut &[u8]) -> io::Result<(
Ok((read_u64_le(r)?, read_u32_le(r)?))
}
/// Read `SMSG_QUESTGIVER_QUEST_LIST` (vmangos `Quest.cpp:159-203`): `u64 npcGuid, cstr greeting,
/// u32 emoteDelay, u32 emote, u8 count`, then per quest `{u32 questId, u32 icon, u32 level, cstr
/// title}`. Note the entry `icon` is a **`u32`** (the list-entry trap), unlike the gossip option's
/// `u8` icon.
/// Read `SMSG_QUESTGIVER_QUEST_LIST` (vmangos `Quest.cpp:159-203`): `u64 npc`, greeting,
/// `u32 emoteDelay, u32 emote, u8 count`, then per quest `u32 id, u32 icon, u32 level`, title.
/// The icon is a `u32` here, unlike a gossip option's `u8`.
pub(in crate::messages) fn read_questgiver_quest_list(r: &mut &[u8]) -> io::Result<QuestGiverList> {
let npc = read_u64_le(r)?;
let greeting = read_cstring(r)?;
let emote_delay = read_u32_le(r)?;
let emote = read_u32_le(r)?;
let count = read_u8(r)?;
// No server cap (the quest menu is a vector, `GossipDef.h:184`); `GOSSIP_MAX_MENU_ITEMS` 32
// (`GossipDef.h:32`) is the client's display limit.
// No server cap (`GossipDef.h:184`); `GOSSIP_MAX_MENU_ITEMS` 32 is the display limit.
let mut quests = Vec::with_capacity(capacity_hint(count, 32));
for _ in 0..count {
quests.push(QuestListEntry {
@@ -259,12 +216,10 @@ pub(in crate::messages) fn read_questgiver_quest_list(r: &mut &[u8]) -> io::Resu
})
}
/// Read `SMSG_QUESTGIVER_QUEST_DETAILS` (vmangos `Quest.cpp:205-273`): `u64 npcGuid, u32 questId,
/// cstr title, cstr details, cstr objectives, u32 autoFinish`, then the count-prefixed choice block,
/// the count-prefixed reward block, `i32 money`, `u32 rewSpell`, `u32 emoteCount (=4)` and that many
/// `{u32 emote, u32 delay}` pairs (consumed for alignment). The HIDDEN_REWARDS fork writes three
/// `u32` zeros in place of the two blocks + money, which this count-prefixed reader consumes
/// identically to an empty rewards block (the uniform-parse trap).
/// Read `SMSG_QUESTGIVER_QUEST_DETAILS` (vmangos `Quest.cpp:205-273`): npc, id, title, details,
/// objectives, auto-finish, the choice and reward blocks, money, spell, then a count (always 4)
/// of `{u32 emote, u32 delay}` pairs. A hidden-rewards quest writes three zeros for the blocks
/// and money (`Quest.cpp:235-237`), which read as empty blocks.
pub(in crate::messages) fn read_questgiver_quest_details(
r: &mut &[u8],
) -> io::Result<QuestDetails> {
@@ -297,11 +252,9 @@ pub(in crate::messages) fn read_questgiver_quest_details(
})
}
/// Read `SMSG_QUESTGIVER_OFFER_REWARD` (vmangos `Quest.cpp:275-339`): `u64 npcGuid, u32 questId,
/// cstr title, cstr offerText, u32 autoFinish, u32 emoteCount` then that many `{u32 delay, u32
/// emote}` pairs (**delay first** — reversed vs DETAILS), the choice block, the reward block, `i32
/// money, u32 questFlags, u32 rewSpell` (the spell is guarded by build > 1.4.2 server-side, which
/// 5875 satisfies — always present).
/// Read `SMSG_QUESTGIVER_OFFER_REWARD` (vmangos `Quest.cpp:275-339`): npc, id, title, text,
/// auto-finish, a counted run of `{u32 delay, u32 emote}` pairs (delay first, unlike DETAILS,
/// `Quest.cpp:308-309`), the choice and reward blocks, money, quest flags, spell.
pub(in crate::messages) fn read_questgiver_offer_reward(
r: &mut &[u8],
) -> io::Result<QuestOfferReward> {
@@ -312,7 +265,7 @@ pub(in crate::messages) fn read_questgiver_offer_reward(
let auto_finish = read_u32_le(r)?;
let emote_count = read_u32_le(r)?;
for _ in 0..emote_count {
let _delay = read_u32_le(r)?; // delay FIRST here (reversed vs DETAILS)
let _delay = read_u32_le(r)?; // delay first here, unlike DETAILS
let _emote = read_u32_le(r)?;
}
let choices = read_item_block(r)?;
@@ -334,11 +287,9 @@ pub(in crate::messages) fn read_questgiver_offer_reward(
})
}
/// Read `SMSG_QUESTGIVER_REQUEST_ITEMS` (vmangos `Quest.cpp:341-391`): `u64 npcGuid, u32 questId,
/// cstr title, cstr requestText, u32 emoteDelay(0), u32 emote, u32 closeOnCancel, u32 requiredMoney,
/// u32 reqCount` then that many `{u32 itemId, u32 count, u32 displayId}` triples, then four `u32`
/// completion flags (`0x02, isComplete?0x03:0x00, 0x04, 0x08`). `is_complete` comes from the second
/// flag word — the only completability signal on this wire.
/// Read `SMSG_QUESTGIVER_REQUEST_ITEMS` (vmangos `Quest.cpp:341-391`): npc, id, title, text,
/// `u32` emote delay, emote, close-on-cancel and money, the required items, then four flag words
/// `0x02, complete ? 0x03 : 0x00, 0x04, 0x08`.
pub(in crate::messages) fn read_questgiver_request_items(
r: &mut &[u8],
) -> io::Result<QuestRequestItems> {
@@ -368,9 +319,8 @@ pub(in crate::messages) fn read_questgiver_request_items(
})
}
/// Read `SMSG_QUESTGIVER_QUEST_COMPLETE` (vmangos `Quest.cpp:96-108`): `u32 questId, u32 unknown,
/// u32 xp, u32 money, u32 itemCount` then that many `{u32 itemId, u32 count}` pairs (no display id
/// on this wire).
/// Read `SMSG_QUESTGIVER_QUEST_COMPLETE` (vmangos `Quest.cpp:96-108`): `u32` quest, unknown, xp,
/// money and item count, then `{u32 itemId, u32 count}` pairs.
pub(in crate::messages) fn read_questgiver_quest_complete(
r: &mut &[u8],
) -> io::Result<QuestComplete> {
@@ -406,7 +356,7 @@ pub(in crate::messages) fn read_questgiver_quest_failed(r: &mut &[u8]) -> io::Re
mod tests {
use super::*;
// ── Small byte-buffer builders for the hand-built parse fixtures ──────────────────────────────
// ── Byte-buffer builders ─────────────────────────────────────────────────────────────────────
fn push_u32(b: &mut Vec<u8>, v: u32) {
b.extend_from_slice(&v.to_le_bytes());
}
@@ -430,7 +380,6 @@ mod tests {
#[test]
fn cmsg_guid_only_bodies() {
// STATUS_QUERY and HELLO are both a lone u64 guid, little-endian.
assert_eq!(
questgiver_status_query(0x0102_0304_0506_0708),
vec![0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01]
@@ -440,7 +389,6 @@ mod tests {
#[test]
fn cmsg_guid_quest_bodies() {
// QUERY/ACCEPT/COMPLETE/REQUEST_REWARD share the {u64 guid, u32 quest} body.
let mut want = Vec::new();
push_u64(&mut want, 0x42);
push_u32(&mut want, 3);
@@ -452,7 +400,6 @@ mod tests {
#[test]
fn cmsg_choose_reward_body() {
// {u64 guid, u32 quest, u32 reward-index}.
let mut want = Vec::new();
push_u64(&mut want, 0x99);
push_u32(&mut want, 40);
@@ -474,8 +421,7 @@ mod tests {
#[test]
fn quest_list_reads_u32_icon() {
// The list-entry `icon` is a u32 — exercise it by putting a value that would misalign the
// next field if read as a u8 (0x0000_0005). Two quests.
// Reading the `u32` icon as a `u8` would misalign every field after it.
let mut b = Vec::new();
push_u64(&mut b, 0xABCD);
push_cstr(&mut b, "Greetings, hero.");
@@ -483,7 +429,7 @@ mod tests {
push_u32(&mut b, 1); // emote
b.push(2); // count (u8)
push_u32(&mut b, 100); // questId
push_u32(&mut b, dialog_status::AVAILABLE); // icon (u32!)
push_u32(&mut b, dialog_status::AVAILABLE); // icon, a u32
push_u32(&mut b, 3); // level
push_cstr(&mut b, "A Threat Within");
push_u32(&mut b, 200);
@@ -504,8 +450,6 @@ mod tests {
#[test]
fn details_rewards_block_is_uniform() {
// A quest with one choice + one fixed reward + money. The count-prefixed reader is the same
// shape the HIDDEN_REWARDS fork's three zeros parse as (choice 0, reward 0, money 0).
let mut b = Vec::new();
push_u64(&mut b, 0x42);
push_u32(&mut b, 100); // questId
@@ -550,7 +494,6 @@ mod tests {
#[test]
fn details_hidden_rewards_fork_parses_as_empty() {
// The HIDDEN_REWARDS branch writes exactly three u32 zeros where the blocks + money go.
let mut b = Vec::new();
push_u64(&mut b, 0x42);
push_u32(&mut b, 100);
@@ -576,8 +519,7 @@ mod tests {
#[test]
fn offer_reward_emotes_are_delay_first() {
// OFFER_REWARD's emote pairs are {delay, emote} — the reverse of DETAILS. Encode a distinctive
// pair and prove the rest of the packet still aligns (choices/rewards/money/flags/spell land).
// Both emote values are discarded; this checks that the fields after them align.
let mut b = Vec::new();
push_u64(&mut b, 0x42);
push_u32(&mut b, 100);
@@ -585,7 +527,7 @@ mod tests {
push_cstr(&mut b, "Here is your reward.");
push_u32(&mut b, 1); // autoFinish
push_u32(&mut b, 2); // emoteCount
push_u32(&mut b, 500); // delay (FIRST)
push_u32(&mut b, 500); // delay, first
push_u32(&mut b, 1); // emote
push_u32(&mut b, 0); // delay
push_u32(&mut b, 2); // emote
@@ -671,12 +613,10 @@ mod tests {
#[test]
fn quest_invalid_and_failed_parse() {
// QUESTGIVER_QUEST_INVALID: one u32 message code.
let mut b = Vec::new();
push_u32(&mut b, 7);
assert_eq!(read_questgiver_quest_invalid(&mut b.as_slice()).unwrap(), 7);
// QUESTGIVER_QUEST_FAILED: {questId, reason}.
let mut b2 = Vec::new();
push_u32(&mut b2, 100);
push_u32(&mut b2, 4);