comments: protocol, the guild, instance and item messages
959 comment lines become 400; the code is unchanged. The read-enchant- time doc had run into the next function's, so rustdoc gave it to read_open_container; it is back on its own function. MaxCount is no longer called account-wide. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,100 +1,49 @@
|
||||
//! The guild family — the roster, the guild query, invitations, rank administration and the event
|
||||
//! broadcast (opcodes `0x54`/`0x55`, `0x81`–`0x93`, `0x231`–`0x235`, `0x2FC`).
|
||||
//! The guild wire messages: roster, guild query, invitations, rank administration and the event
|
||||
//! broadcast (opcodes `0x54`/`0x55`, `0x81`-`0x93`, `0x231`-`0x235`, `0x2FC`).
|
||||
//!
|
||||
//! The server side is pinned at the bytes by vmangos `Server/Packets/Guild.{h,cpp}` (every
|
||||
//! `ReadFromWorldPacket` / `AppendBodyTo`), `Guild/Guild.{h,cpp}` (`SendGuildRoster`,
|
||||
//! `SendQueryResponse`, `BroadcastEvent`, and the enums) and `Handlers/GuildHandler.cpp` (which
|
||||
//! `NullClientPacket` handlers take an empty body).
|
||||
//!
|
||||
//! The client side of `SMSG_GUILD_EVENT` is VERIFIED in wow-re
|
||||
//! (`system/object-layer/scratch/rf77-smsg-chat-wire-order.md`, RF-0077 corrected): opcode `0x92`
|
||||
//! registers handler `0x5e7180` (`0x5e337a mov ecx,0x92; call 0x5ab650`), which reads `u8
|
||||
//! eventType`, `u8 strCount` (≤ 3 — three stack buffers), `strCount` × CString, and then — **only**
|
||||
//! in the `0xc`/`0xd` arms of its `switch` (jump table `0x5e74dc`) — a trailing `u64` guid via
|
||||
//! `0x4190b0`. That is the read [`read_guild_event`] implements, and it is *narrower* than what
|
||||
//! vmangos writes (see that function's doc).
|
||||
//!
|
||||
//! The client side of `SMSG_GUILD_ROSTER` and `SMSG_GUILD_QUERY_RESPONSE` is likewise VERIFIED in
|
||||
//! wow-re (`system/ui/scratch/guild-roster-wire.md`), by a §5 trio deriving each independently.
|
||||
//! The two do **not** live in the guild-init block the event handler does: `0x8a` is handler
|
||||
//! `0x4d0ad0` (registered `0x4d0a0e`) in a **`GuildInfo.cpp`** TU, and `0x55` is `0x555290`
|
||||
//! (registered `0x555073`) in **dbcache**, delegating to decoder `0x62f260` — which wow-re already
|
||||
//! held bit-exact as `PRIMITIVE:dbcache_guild`. Per-field addresses are on the two readers below.
|
||||
//!
|
||||
//! **The parse trap in this family is `SMSG_GUILD_ROSTER`'s `f32 lastOnlineTime`**: it rides on the
|
||||
//! member's presence byte, so reading it unconditionally desynchronises every *later* member —
|
||||
//! exactly the shape [`super::social::read_friend_status`] already has, and written the same way
|
||||
//! (an explicit condition on the field the wire keys off, never a "read if bytes remain").
|
||||
//!
|
||||
//! **One reference behaviour we deliberately do not reproduce: capped string reads.** The client's
|
||||
//! `0x4191b0` takes a per-field capacity (counting the NUL, so the real cap is `cap - 1`) — roster
|
||||
//! name `0x30`, notes `0x80`, motd `0x200`, info text `0x7d0`, query guild name `0x60`, rank name
|
||||
//! `0x40`. On overflow it does not truncate gracefully: it slams the cursor to `size + 1`
|
||||
//! (`0x4192aa`) and NULs `dest[0]` (`0x4192be`), so the field arrives **empty** *and every later
|
||||
//! read in that packet silently no-ops*. [`read_cstring`] is unbounded, which is strictly safer and
|
||||
//! cannot poison a packet — and it costs no fidelity, because every one of those caps is far above
|
||||
//! the content limit the server enforces (a 15-character rank name into a 64-byte buffer, a
|
||||
//! 128-character MOTD into 512). The caps are recorded here because they explain a class of
|
||||
//! "the guild pane went blank" behaviour in the reference, not because we want them.
|
||||
//! Deviation: string reads are unbounded. The reference `0x4191b0` caps each field, counting the
|
||||
//! NUL (roster name `0x30`, notes `0x80`, MOTD `0x200`, info `0x7d0`, guild name `0x60`, rank name
|
||||
//! `0x40`), and on overflow empties it and no-ops every later read in the packet; every cap is far
|
||||
//! above the server's own length limits, so a legitimate packet reads the same.
|
||||
|
||||
use std::io::{self, Read};
|
||||
|
||||
use crate::wire::{capacity_hint, read_cstring, read_f32_le, read_u32_le, read_u64_le, read_u8};
|
||||
|
||||
/// A guild always has at least this many ranks — vmangos `GUILD_RANKS_MIN_COUNT` (`Guild/Guild.h:31`);
|
||||
/// the five defaults are GM / Officer / Veteran / Member / Initiate ([`guild_default_rank`]).
|
||||
/// Minimum rank count: the five defaults in [`guild_default_rank`] (`Guild/Guild.h:31`).
|
||||
pub const GUILD_RANKS_MIN_COUNT: usize = 5;
|
||||
/// …and at most this many — vmangos `GUILD_RANKS_MAX_COUNT` (`Guild/Guild.h:32`). Load-bearing for
|
||||
/// the parse, not just a policy number: `SMSG_GUILD_QUERY_RESPONSE` always carries **exactly**
|
||||
/// this many rank-name cstrings, padding unused ranks with empty strings
|
||||
/// (`Guild/Guild.cpp:868-872`, whose own comment is "show always 10 ranks").
|
||||
/// Maximum rank count (`Guild/Guild.h:32`), and the exact number of rank names
|
||||
/// `SMSG_GUILD_QUERY_RESPONSE` carries, unused ones empty (`Guild/Guild.cpp:868-872`).
|
||||
pub const GUILD_RANKS_MAX_COUNT: usize = 10;
|
||||
|
||||
/// Rank-name cap, in *characters* — vmangos `GUILD_RANK_MAX_LENGTH` (`Guild/Guild.h:36`).
|
||||
///
|
||||
/// These five caps are the reference UI's edit-box `maxLetters`, and vmangos re-checks the three
|
||||
/// it can (`utf8length`, not byte length). Over-long is **not** a polite refusal on the rank ones:
|
||||
/// `HandleGuildRankOpcode` / `HandleGuildAddRankOpcode` (`Handlers/GuildHandler.cpp:580-584`,
|
||||
/// `:600-604`) run `ProcessAnticheatAction(… CHEAT_ACTION_KICK)`. The builders below do **not**
|
||||
/// truncate — silently shortening a rank name or a note would send the wrong text, which is its
|
||||
/// own bug — so the caller enforces the cap at the edit box, as the reference UI does.
|
||||
/// Rank-name cap in characters (`Guild/Guild.h:36`). The builders do not truncate, and an
|
||||
/// over-long rank name gets the session kicked (`Handlers/GuildHandler.cpp:580-584`, `:600-604`).
|
||||
pub const GUILD_RANK_MAX_LENGTH: usize = 15;
|
||||
/// Guild-name cap, in characters — vmangos `GUILD_NAME_MAX_LENGTH` (`Guild/Guild.h:37`).
|
||||
/// Guild-name cap, in characters (`Guild/Guild.h:37`).
|
||||
pub const GUILD_NAME_MAX_LENGTH: usize = 24;
|
||||
/// Public/officer note cap, in characters — vmangos `GUILD_NOTE_MAX_LENGTH` (`Guild/Guild.h:38`).
|
||||
/// Public/officer note cap, in characters (`Guild/Guild.h:38`).
|
||||
pub const GUILD_NOTE_MAX_LENGTH: usize = 31;
|
||||
/// Guild info text cap, in characters — vmangos `GUILD_INFO_MAX_LENGTH` (`Guild/Guild.h:39`).
|
||||
/// Guild info text cap, in characters (`Guild/Guild.h:39`).
|
||||
pub const GUILD_INFO_MAX_LENGTH: usize = 500;
|
||||
/// MOTD cap, in characters — vmangos `GUILD_MOTD_MAX_LENGTH` (`Guild/Guild.h:40`).
|
||||
/// MOTD cap, in characters (`Guild/Guild.h:40`).
|
||||
pub const GUILD_MOTD_MAX_LENGTH: usize = 128;
|
||||
|
||||
/// The five ranks every guild is created with and cannot delete — vmangos `GuildDefaultRanks`
|
||||
/// (`Guild/Guild.h:44-54`). Rank ids are **0-based with 0 = guild master**, and the ordering is the
|
||||
/// authority hierarchy: promote does `rank--`, demote does `rank++` (vmangos's own comments).
|
||||
/// The five ranks every guild is created with and cannot delete (`Guild/Guild.h:44-54`). Ids are
|
||||
/// 0-based with 0 = guild master; promote does `rank--`, demote `rank++`.
|
||||
pub mod guild_default_rank {
|
||||
/// `GR_GUILDMASTER` — rank 0. `HandleGuildRankOpcode` force-overwrites this rank's rights with
|
||||
/// `GR_RIGHT_ALL` whatever the client sends, "to prevent loss of leader rights".
|
||||
/// `GR_GUILDMASTER`; the server forces this rank's rights to `GR_RIGHT_ALL` whatever we send.
|
||||
pub const GUILDMASTER: u32 = 0;
|
||||
/// `GR_OFFICER`.
|
||||
pub const OFFICER: u32 = 1;
|
||||
/// `GR_VETERAN`.
|
||||
pub const VETERAN: u32 = 2;
|
||||
/// `GR_MEMBER`.
|
||||
pub const MEMBER: u32 = 3;
|
||||
/// `GR_INITIATE` — the lowest default rank.
|
||||
/// `GR_INITIATE`, the lowest default rank.
|
||||
pub const INITIATE: u32 = 4;
|
||||
}
|
||||
|
||||
/// One rank's rights bitmask — vmangos `GuildRankRights` (`Guild/Guild.h:56-74`), **with
|
||||
/// `GR_RIGHT_EMPTY` factored out**.
|
||||
///
|
||||
/// vmangos writes every named right as `GR_RIGHT_EMPTY | bit` (`GR_RIGHT_GCHATLISTEN = 0x41`,
|
||||
/// not `0x01`) because its test is `(rights & right) != GR_RIGHT_EMPTY` — the `0x40` is a sentinel
|
||||
/// that makes that comparison work, not a right. The meaningful bit is the other half, and that is
|
||||
/// what is named here; [`EMPTY`] is kept so the vmangos constants can be reconstructed and
|
||||
/// recognised.
|
||||
/// One rank's rights bitmask (`Guild/Guild.h:56-74`) with the `GR_RIGHT_EMPTY` bit factored out:
|
||||
/// vmangos spells each right `0x40 | bit` for its test `(rights & right) != GR_RIGHT_EMPTY`.
|
||||
pub mod guild_rank_right {
|
||||
/// `GR_RIGHT_EMPTY` (`0x40`) — the sentinel described in the module doc. Carries no permission.
|
||||
/// `GR_RIGHT_EMPTY`, the sentinel vmangos ORs into every right; it grants nothing.
|
||||
pub const EMPTY: u32 = 0x0000_0040;
|
||||
/// Read guild chat (`GR_RIGHT_GCHATLISTEN`, vmangos `0x41`).
|
||||
pub const GCHAT_LISTEN: u32 = 0x0000_0001;
|
||||
@@ -116,27 +65,21 @@ pub mod guild_rank_right {
|
||||
pub const SET_MOTD: u32 = 0x0000_1000;
|
||||
/// Edit any member's public note (`GR_RIGHT_EPNOTE`, vmangos `0x2040`).
|
||||
pub const EDIT_PUBLIC_NOTE: u32 = 0x0000_2000;
|
||||
/// **See** officer notes at all (`GR_RIGHT_VIEWOFFNOTE`, vmangos `0x4040`). Without it the
|
||||
/// server blanks every `officerNote` in the roster it sends us
|
||||
/// (`Guild/Guild.cpp:821`, `:844`) — the empty string is a *permission* signal, not "no note".
|
||||
/// See officer notes (`GR_RIGHT_VIEWOFFNOTE`, vmangos `0x4040`); without it the server blanks
|
||||
/// every roster `officerNote` (`Guild/Guild.cpp:821`, `:844`).
|
||||
pub const VIEW_OFFICER_NOTE: u32 = 0x0000_4000;
|
||||
/// Edit officer notes (`GR_RIGHT_EOFFNOTE`, vmangos `0x8040`).
|
||||
pub const EDIT_OFFICER_NOTE: u32 = 0x0000_8000;
|
||||
/// Edit the guild information text (`GR_RIGHT_MODIFY_GUILD_INFO`, vmangos `0x10040`).
|
||||
pub const MODIFY_GUILD_INFO: u32 = 0x0001_0000;
|
||||
/// `GR_RIGHT_ALL` — what the server force-writes onto rank 0 regardless of what we send.
|
||||
/// Unlike the named rights above, this is a **raw wire value, sentinel and all**: it includes
|
||||
/// [`EMPTY`], and it lights bits (`0x0000_0E00`, `0x000E_0000`) that no 1.12 right owns. Test
|
||||
/// a rank against the named bits, never against this.
|
||||
/// `GR_RIGHT_ALL`, forced onto rank 0 by the server. A raw vmangos value: it includes
|
||||
/// [`EMPTY`] and bits no 1.12 right owns, so test ranks against the named bits, never this.
|
||||
pub const ALL: u32 = 0x000F_F1FF;
|
||||
}
|
||||
|
||||
/// The thirteen rank-right checkboxes **in the order the FrameXML API indexes them**.
|
||||
///
|
||||
/// `GuildControlGetRankFlags()` returns a flat list and `GuildControlSetRankFlag(i, on)` takes an
|
||||
/// index into it, so **index order is the API contract** — and it is deliberately *not* bit order:
|
||||
/// Promote/Demote (indices 5/6 here) sit above Invite/Remove (7/8) in bit value. Anything that
|
||||
/// computes the bit as `1 << (i - 1)` is wrong; this table is the mapping.
|
||||
/// The thirteen rank-right checkboxes in the order `GuildControlGetRankFlags()` and
|
||||
/// `GuildControlSetRankFlag(i, on)` index them, which is not bit order: Promote/Demote (5/6) come
|
||||
/// before Invite/Remove (7/8), whose bits are lower.
|
||||
pub const GUILD_RANK_RIGHT_ORDER: [u32; 13] = [
|
||||
guild_rank_right::GCHAT_LISTEN,
|
||||
guild_rank_right::GCHAT_SPEAK,
|
||||
@@ -153,104 +96,81 @@ pub const GUILD_RANK_RIGHT_ORDER: [u32; 13] = [
|
||||
guild_rank_right::MODIFY_GUILD_INFO,
|
||||
];
|
||||
|
||||
/// A roster member's presence byte — vmangos `GuildRosterPresenceFlags` (`Guild/Guild.h:139-144`).
|
||||
///
|
||||
/// `0` (no bit at all) is the only value the wire treats specially: it is what makes the member's
|
||||
/// `f32 lastOnlineTime` present. The AFK/DND bits ride alongside `ONLINE`, exactly as the friend
|
||||
/// list's [`super::social::friend_status`] does.
|
||||
///
|
||||
/// The reference client tests these as its own chat flags, and **DND is tested first**
|
||||
/// (`0x4d13e4` before `0x4d13fb`, wow-re `system/ui/scratch/guild-roster-wire.md`) — so a member
|
||||
/// flagged both AFK and DND displays as DND. That precedence is a display rule, not a wire rule,
|
||||
/// but it belongs with the bits.
|
||||
/// A roster member's presence byte (`Guild/Guild.h:139-144`); only `0` changes the wire, adding
|
||||
/// the `f32 lastOnlineTime`. The reference tests DND before AFK (`0x4d13e4`, `0x4d13fb`), so a
|
||||
/// member flagged both shows as DND.
|
||||
pub mod guild_presence {
|
||||
/// Offline — and **only** this value carries the `lastOnlineTime` float.
|
||||
/// Offline; only this value carries the `lastOnlineTime` float.
|
||||
pub const OFFLINE: u8 = 0x00;
|
||||
/// `GRF_ONLINE`. Value INFERRED from vmangos: the reference client only ever tests the whole
|
||||
/// byte against zero, so nothing in the binary pins this bit specifically.
|
||||
/// `GRF_ONLINE` (vmangos value; the reference only tests the whole byte against zero).
|
||||
pub const ONLINE: u8 = 0x01;
|
||||
/// `GRF_AFK` — set alongside [`ONLINE`]. VERIFIED as the client's own `CHAT_FLAG_AFK`.
|
||||
/// `GRF_AFK`, set alongside [`ONLINE`]; the reference's own `CHAT_FLAG_AFK`.
|
||||
pub const AFK: u8 = 0x02;
|
||||
/// `GRF_DND` — set alongside [`ONLINE`]. VERIFIED as the client's own `CHAT_FLAG_DND`, and
|
||||
/// tested ahead of [`AFK`].
|
||||
/// `GRF_DND`, set alongside [`ONLINE`]; the reference's own `CHAT_FLAG_DND`.
|
||||
pub const DND: u8 = 0x04;
|
||||
}
|
||||
|
||||
/// `SMSG_GUILD_EVENT`'s leading byte — vmangos `GuildEvents` (`Guild/Guild.h:121-137`), which is
|
||||
/// also the reference client's own `switch` (14 arms, jump table `0x5e74dc`; wow-re RF-0077).
|
||||
/// `SMSG_GUILD_EVENT`'s leading byte (`Guild/Guild.h:121-137`), the arms of the reference
|
||||
/// handler's `switch` (jump table `0x5e74dc`).
|
||||
pub mod guild_event {
|
||||
/// `GE_PROMOTION` — params: promoter, promoted, new rank name.
|
||||
/// `GE_PROMOTION`, params: promoter, promoted, new rank name.
|
||||
pub const PROMOTION: u8 = 0x00;
|
||||
/// `GE_DEMOTION` — params: demoter, demoted, new rank name.
|
||||
/// `GE_DEMOTION`, params: demoter, demoted, new rank name.
|
||||
pub const DEMOTION: u8 = 0x01;
|
||||
/// `GE_MOTD` — param: the new message of the day. The reference client routes this one
|
||||
/// through the profanity filter (`0x703f50(0x11c,…)`, CVar-gated), not the normal event path.
|
||||
/// `GE_MOTD`, param: the new MOTD. The reference routes it through the CVar-gated profanity
|
||||
/// filter (`0x703f50`), not the normal event path.
|
||||
pub const MOTD: u8 = 0x02;
|
||||
/// `GE_JOINED` — param: the joiner's name.
|
||||
/// `GE_JOINED`, param: the joiner's name.
|
||||
pub const JOINED: u8 = 0x03;
|
||||
/// `GE_LEFT` — param: the leaver's name.
|
||||
/// `GE_LEFT`, param: the leaver's name.
|
||||
pub const LEFT: u8 = 0x04;
|
||||
/// `GE_REMOVED` — params: the removed player, then who removed them.
|
||||
/// `GE_REMOVED`, params: the removed player, then who removed them.
|
||||
pub const REMOVED: u8 = 0x05;
|
||||
/// `GE_LEADER_IS` — param: the current leader.
|
||||
/// `GE_LEADER_IS`, param: the current leader.
|
||||
pub const LEADER_IS: u8 = 0x06;
|
||||
/// `GE_LEADER_CHANGED` — params: old leader, new leader.
|
||||
/// `GE_LEADER_CHANGED`, params: old leader, new leader.
|
||||
pub const LEADER_CHANGED: u8 = 0x07;
|
||||
/// `GE_DISBANDED` — no params.
|
||||
/// `GE_DISBANDED`, no params.
|
||||
pub const DISBANDED: u8 = 0x08;
|
||||
/// `GE_TABARDCHANGE`. The reference client falls through to its default arm for this one.
|
||||
/// `GE_TABARDCHANGE`; the reference handles it in its default arm.
|
||||
pub const TABARD_CHANGE: u8 = 0x09;
|
||||
/// `GE_UPDATE_RANK_NAME` — params: rank id, new rank name. The reference client writes it
|
||||
/// straight into the roster cache (`0x560e30`) and fires **no** FrameScript event.
|
||||
/// `GE_UPDATE_RANK_NAME`, params: rank id, new rank name. The reference writes it into the
|
||||
/// roster cache (`0x560e30`) and fires no FrameScript event.
|
||||
pub const UPDATE_RANK_NAME: u8 = 0x0A;
|
||||
/// `GE_UPDATE_ROSTER` — "re-ask for the roster". Silent in the reference client; vmangos never
|
||||
/// sends it (it re-sends the whole `SMSG_GUILD_ROSTER` instead).
|
||||
/// `GE_UPDATE_ROSTER`: silent in the reference, and vmangos never sends it.
|
||||
pub const UPDATE_ROSTER: u8 = 0x0B;
|
||||
/// `GE_SIGNED_ON` — param: the guildmate's name. **Carries the trailing guid.**
|
||||
/// `GE_SIGNED_ON`, param: the guildmate's name; carries the trailing guid.
|
||||
pub const SIGNED_ON: u8 = 0x0C;
|
||||
/// `GE_SIGNED_OFF` — param: the guildmate's name. **Carries the trailing guid.**
|
||||
/// `GE_SIGNED_OFF`, param: the guildmate's name; carries the trailing guid.
|
||||
pub const SIGNED_OFF: u8 = 0x0D;
|
||||
}
|
||||
|
||||
/// `SMSG_GUILD_COMMAND_RESULT`'s leading `u32` — which *verb* the result is about (vmangos
|
||||
/// `Typecommand`, `Guild/Guild.h:76-85`).
|
||||
///
|
||||
/// It is a much coarser tag than the opcode that provoked it: vmangos answers "you're not in a
|
||||
/// guild" to `CMSG_GUILD_INFO`, `_ROSTER`, `_LEAVE`, `_DISBAND` and half a dozen others all as
|
||||
/// [`CREATE`], and every permission refusal as [`INVITE`]. So the pair
|
||||
/// `(command, result)` names the *message*, not the request — vmangos's own comment notes that
|
||||
/// values 2, 4-13 and 15-18 "have no effect" here.
|
||||
/// `SMSG_GUILD_COMMAND_RESULT`'s leading `u32`, the verb the result is about
|
||||
/// (`Guild/Guild.h:76-85`). Coarser than the request: vmangos reports "not in a guild" for most
|
||||
/// verbs as [`CREATE`] and every permission refusal as [`INVITE`].
|
||||
pub mod guild_command {
|
||||
/// `GUILD_CREATE_S` — also vmangos's catch-all for "you are not in a guild".
|
||||
/// `GUILD_CREATE_S`, also vmangos's catch-all for "you are not in a guild".
|
||||
pub const CREATE: u32 = 0x00;
|
||||
/// `GUILD_INVITE_S` — also vmangos's catch-all for a permission refusal.
|
||||
/// `GUILD_INVITE_S`, also vmangos's catch-all for a permission refusal.
|
||||
pub const INVITE: u32 = 0x01;
|
||||
/// `GUILD_QUIT_S` — leaving/being removed.
|
||||
/// `GUILD_QUIT_S`: leaving or being removed.
|
||||
pub const QUIT: u32 = 0x03;
|
||||
/// `GUILD_FOUNDER_S`.
|
||||
pub const FOUNDER: u32 = 0x0E;
|
||||
/// `GUILD_UNK19`.
|
||||
pub const UNK19: u32 = 0x13;
|
||||
/// `GUILD_UNK20`.
|
||||
pub const UNK20: u32 = 0x14;
|
||||
}
|
||||
|
||||
/// `SMSG_GUILD_COMMAND_RESULT`'s trailing `u32` — vmangos `CommandErrors`
|
||||
/// (`Guild/Guild.h:96-119`). The string field between the two carries whatever `%s` the matching
|
||||
/// `ERR_GUILD_*` GlobalString needs (a player name, a guild name, or empty).
|
||||
///
|
||||
/// Note [`LEADER_LEAVE`] and [`PERMISSIONS`] are the **same** value `0x08`: which one it means is
|
||||
/// decided by the [`guild_command`] tag beside it (`QUIT` → the leader-can't-leave message,
|
||||
/// anything else → the permission refusal). That collision is the reason the command tag has to be
|
||||
/// carried through to the consumer rather than dropped.
|
||||
/// `SMSG_GUILD_COMMAND_RESULT`'s trailing `u32` (`Guild/Guild.h:96-119`). [`LEADER_LEAVE`] and
|
||||
/// [`PERMISSIONS`] share `0x08`: with command `QUIT` it is the leader-cannot-leave message,
|
||||
/// otherwise the permission refusal.
|
||||
pub mod guild_command_error {
|
||||
/// `ERR_PLAYER_NO_MORE_IN_GUILD` — vmangos's own comment: "no message/error".
|
||||
/// `ERR_PLAYER_NO_MORE_IN_GUILD`: "no message/error" per vmangos.
|
||||
pub const PLAYER_NO_MORE_IN_GUILD: u32 = 0x00;
|
||||
/// `ERR_GUILD_INTERNAL`.
|
||||
pub const INTERNAL: u32 = 0x01;
|
||||
/// `ERR_ALREADY_IN_GUILD` — *you* are.
|
||||
/// `ERR_ALREADY_IN_GUILD`: you are.
|
||||
pub const ALREADY_IN_GUILD: u32 = 0x02;
|
||||
/// `ERR_ALREADY_IN_GUILD_S` — *they* are (the string names them).
|
||||
/// `ERR_ALREADY_IN_GUILD_S`: they are (the string names them).
|
||||
pub const ALREADY_IN_GUILD_S: u32 = 0x03;
|
||||
/// `ERR_INVITED_TO_GUILD`.
|
||||
pub const INVITED_TO_GUILD: u32 = 0x04;
|
||||
@@ -260,18 +180,17 @@ pub mod guild_command_error {
|
||||
pub const NAME_INVALID: u32 = 0x06;
|
||||
/// `ERR_GUILD_NAME_EXISTS_S`.
|
||||
pub const NAME_EXISTS_S: u32 = 0x07;
|
||||
/// `ERR_GUILD_LEADER_LEAVE` — the guild master cannot `/gquit` while anyone else remains.
|
||||
/// Same value as [`PERMISSIONS`]; disambiguated by [`guild_command::QUIT`].
|
||||
/// `ERR_GUILD_LEADER_LEAVE`: the guild master cannot `/gquit` while anyone else remains.
|
||||
pub const LEADER_LEAVE: u32 = 0x08;
|
||||
/// `ERR_GUILD_PERMISSIONS` — see [`LEADER_LEAVE`].
|
||||
/// `ERR_GUILD_PERMISSIONS`, sharing `0x08` with [`LEADER_LEAVE`].
|
||||
pub const PERMISSIONS: u32 = 0x08;
|
||||
/// `ERR_GUILD_PLAYER_NOT_IN_GUILD` — *you* aren't.
|
||||
/// `ERR_GUILD_PLAYER_NOT_IN_GUILD`: you aren't.
|
||||
pub const PLAYER_NOT_IN_GUILD: u32 = 0x09;
|
||||
/// `ERR_GUILD_PLAYER_NOT_IN_GUILD_S` — *they* aren't.
|
||||
/// `ERR_GUILD_PLAYER_NOT_IN_GUILD_S`: they aren't.
|
||||
pub const PLAYER_NOT_IN_GUILD_S: u32 = 0x0A;
|
||||
/// `ERR_GUILD_PLAYER_NOT_FOUND_S`.
|
||||
pub const PLAYER_NOT_FOUND_S: u32 = 0x0B;
|
||||
/// `ERR_GUILD_NOT_ALLIED` — cross-faction, refused by config.
|
||||
/// `ERR_GUILD_NOT_ALLIED`: cross-faction, refused by config.
|
||||
pub const NOT_ALLIED: u32 = 0x0C;
|
||||
/// `ERR_GUILD_RANK_TOO_HIGH_S`.
|
||||
pub const RANK_TOO_HIGH_S: u32 = 0x0D;
|
||||
@@ -279,46 +198,30 @@ pub mod guild_command_error {
|
||||
pub const RANK_TOO_LOW_S: u32 = 0x0E;
|
||||
/// `ERR_GUILD_RANKS_LOCKED` (`0x0F`/`0x10` are unused at 5875).
|
||||
pub const RANKS_LOCKED: u32 = 0x11;
|
||||
/// `ERR_GUILD_RANK_IN_USE` — can't delete the lowest rank while somebody holds it.
|
||||
/// `ERR_GUILD_RANK_IN_USE`: the lowest rank cannot be deleted while somebody holds it.
|
||||
pub const RANK_IN_USE: u32 = 0x12;
|
||||
/// `ERR_GUILD_IGNORING_YOU_S`.
|
||||
pub const IGNORING_YOU_S: u32 = 0x13;
|
||||
/// `ERR_GUILD_UNK20` — vmangos: "for Typecommand 0x05 only".
|
||||
/// `ERR_GUILD_UNK20`: "for Typecommand 0x05 only" per vmangos.
|
||||
pub const UNK20: u32 = 0x14;
|
||||
}
|
||||
|
||||
/// `SMSG_GUILD_QUERY_RESPONSE` — one guild's public identity: its name, its ten rank names, and
|
||||
/// its tabard.
|
||||
///
|
||||
/// This is an ask-once cache fill, the guild twin of the name query: the roster and every chat
|
||||
/// line reference a guild by id, and this is what turns that id into text.
|
||||
/// `SMSG_GUILD_QUERY_RESPONSE`: one guild's name, ten rank names and tabard, by guild id.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct GuildQueryResponse {
|
||||
/// The guild id we asked about.
|
||||
pub guild_id: u32,
|
||||
/// The guild's name — and, **empty, the "no such guild" answer**. The reference client tests
|
||||
/// exactly this (`0x5552ae test al,al` on the decoded record's name, choosing the cache
|
||||
/// *insert* `0x561070` or the cache *remove* `0x561390`); there is no separate not-found flag,
|
||||
/// and the full record is consumed either way. A consumer must therefore treat an empty name
|
||||
/// as "this id names nothing", not as "a guild with a blank name".
|
||||
/// The guild's name; empty means no such guild, and the reference then drops the id from its
|
||||
/// cache instead of inserting it (`0x5552ae`).
|
||||
pub name: String,
|
||||
/// **Always exactly [`GUILD_RANKS_MAX_COUNT`] entries**, indexed by rank id (0 = guild
|
||||
/// master); ranks the guild has not created are empty strings, not absent. A guild has between
|
||||
/// [`GUILD_RANKS_MIN_COUNT`] and [`GUILD_RANKS_MAX_COUNT`] real ranks, so the tail of this
|
||||
/// array is normally empty — "how many ranks exist" is the count of leading non-empty entries,
|
||||
/// or better, the roster's `rank_rights` length.
|
||||
/// Indexed by rank id, uncreated ranks empty; the live rank count is the roster's
|
||||
/// `rank_rights` length.
|
||||
pub rank_names: [String; GUILD_RANKS_MAX_COUNT],
|
||||
/// Tabard emblem symbol index — the file index the body composite formats into
|
||||
/// `Textures\GuildEmblems\Emblem_<style>_<color>_…` (decision 1704).
|
||||
///
|
||||
/// **Signed, and `-1` is the common case.** These five are `int32` on the wire (vmangos
|
||||
/// `Server/Packets/Guild.h:208`), a guild is *constructed* with all five at `-1`
|
||||
/// (`Guild/Guild.cpp:86`) and reloaded with `GetInt32()` (`:308`), and only the tabard designer
|
||||
/// ever writes a real index. So "this guild has never bought a tabard" arrives as `-1`, not as
|
||||
/// `0` — decoding these unsigned would turn that into 4294967295 and lose the one value that is
|
||||
/// not a file index. What the renderer does with it is 1704's business, not the decoder's.
|
||||
/// Tabard emblem index, formatted into `Textures\GuildEmblems\Emblem_<style>_<color>_…`. All
|
||||
/// five tabard fields are `int32` (`Server/Packets/Guild.h:208`) and a new guild has each at
|
||||
/// `-1` (`Guild/Guild.cpp:86`), meaning no tabard designed.
|
||||
pub emblem_style: i32,
|
||||
/// Tabard emblem colour index. Signed, `-1` = no tabard designed — see [`Self::emblem_style`].
|
||||
/// Tabard emblem colour index. Signed, `-1` = no tabard designed.
|
||||
pub emblem_color: i32,
|
||||
/// Tabard border style index. Signed, `-1` = no tabard designed.
|
||||
pub border_style: i32,
|
||||
@@ -328,21 +231,8 @@ pub struct GuildQueryResponse {
|
||||
pub background_color: i32,
|
||||
}
|
||||
|
||||
/// Read `SMSG_GUILD_QUERY_RESPONSE` (VERIFIED **both ends**): `u32 guildId`, the guild name, then
|
||||
/// **exactly ten** rank-name cstrings, then the five tabard `i32`s (signed — see
|
||||
/// [`GuildQueryResponse::emblem_style`] for why the sign is load-bearing).
|
||||
///
|
||||
/// Server: vmangos `Server/Packets/Guild.cpp:118-131` `GuildQueryResponse::AppendBodyTo`, filled by
|
||||
/// `Guild/Guild.cpp:862-880` `SendQueryResponse`. Client: wow-re
|
||||
/// `system/ui/scratch/guild-roster-wire.md` — handler `0x555290` (dbcache) delegating to decoder
|
||||
/// `0x62f260`, reads at `0x62f26e` (id), `0x62f27b` (name), `0x62f295` × 10, then `0x62f2af`,
|
||||
/// `0x62f2bd`, `0x62f2cb`, `0x62f2d9`, `0x62f2e7`.
|
||||
///
|
||||
/// The ten is a fixed loop over the sender's `rankNames[10]` array, **not** a counted list — the
|
||||
/// server pads with empty strings for ranks the guild never created ("show always 10 ranks", its
|
||||
/// own comment), and the client's own loop bound is the compile-time constant `0x62f283 mov
|
||||
/// [ebp+8],0xa` on a `do`/`while`, so it always runs ten times. Reading a count first, or stopping
|
||||
/// at the guild's real rank count, would land the parse in the middle of the emblem block.
|
||||
/// Read `SMSG_GUILD_QUERY_RESPONSE` (`Server/Packets/Guild.cpp:118-131`, reference `0x62f260`):
|
||||
/// `u32` id, name, exactly ten rank-name cstrings with no count, then five `i32` tabard fields.
|
||||
pub(super) fn read_guild_query_response(r: &mut impl Read) -> io::Result<GuildQueryResponse> {
|
||||
let guild_id = read_u32_le(r)?;
|
||||
let name = read_cstring(r)?;
|
||||
@@ -362,131 +252,64 @@ pub(super) fn read_guild_query_response(r: &mut impl Read) -> io::Result<GuildQu
|
||||
})
|
||||
}
|
||||
|
||||
/// One row of `SMSG_GUILD_ROSTER`.
|
||||
///
|
||||
/// Unlike a friend-list slot, every field the guild pane shows *is* on the wire — a roster is a
|
||||
/// snapshot the server composed, so no name-cache round trip is needed to draw it.
|
||||
/// One row of `SMSG_GUILD_ROSTER`; every field the guild pane shows is on the wire.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct GuildRosterMember {
|
||||
/// The member's player guid (vmangos always builds it as `ObjectGuid(HIGHGUID_PLAYER, …)`).
|
||||
pub guid: u64,
|
||||
/// [`guild_presence`] bits. [`guild_presence::OFFLINE`] (`0`) is the value that makes
|
||||
/// [`Self::last_online_days`] present on the wire — see [`Self::is_online`].
|
||||
/// [`guild_presence`] bits; `0` puts [`Self::last_online_days`] on the wire.
|
||||
pub presence: u8,
|
||||
/// Character name.
|
||||
pub name: String,
|
||||
/// Rank id, 0-based with 0 = guild master; indexes [`GuildQueryResponse::rank_names`] and
|
||||
/// [`GuildRoster::rank_rights`].
|
||||
pub rank_id: u32,
|
||||
/// Character level. A `u8` on the wire, not a `u32` — the roster is the widest packet in the
|
||||
/// game and it is packed accordingly.
|
||||
pub level: u8,
|
||||
/// Class id (`ChrClasses.dbc`).
|
||||
pub class: u8,
|
||||
/// Zone id (`AreaTable.dbc`). For an offline member this is their *last known* zone, out of
|
||||
/// the character table.
|
||||
/// Zone id (`AreaTable.dbc`); an offline member's last known zone.
|
||||
pub zone: u32,
|
||||
/// Days since this member last logged out, as a fraction — vmangos computes
|
||||
/// `(now - logoutTime) / DAY` (`Guild/Guild.cpp:841`). **Only on the wire for offline
|
||||
/// members**; reads back as `0.0` for online ones, where it is meaningless. The reference UI's
|
||||
/// `GetGuildRosterLastOnline` binding is what splits it into years/months/days.
|
||||
/// Fractional days since logout (`Guild/Guild.cpp:841`), sent for offline members only;
|
||||
/// `GetGuildRosterLastOnline` splits it into years, months and days.
|
||||
pub last_online_days: f32,
|
||||
/// The member's public note (empty if unset).
|
||||
pub public_note: String,
|
||||
/// The member's officer note — **empty when *we* lack [`guild_rank_right::VIEW_OFFICER_NOTE`]**,
|
||||
/// because the server blanks it per-viewer (`Guild/Guild.cpp:821`, `:844`). An empty string
|
||||
/// therefore does not distinguish "no note" from "not allowed to see it"; the viewer's own
|
||||
/// rank rights do.
|
||||
/// The member's officer note, blanked by the server unless the viewer holds
|
||||
/// [`guild_rank_right::VIEW_OFFICER_NOTE`] (`Guild/Guild.cpp:821`, `:844`).
|
||||
pub officer_note: String,
|
||||
}
|
||||
|
||||
impl GuildRosterMember {
|
||||
/// Is this member online at all (any of ONLINE/AFK/DND)? This is the wire's own test — vmangos
|
||||
/// writes [`Self::last_online_days`] under exactly `if (!member.presenceFlags)`, and
|
||||
/// [`read_guild_roster`] reads it under the same condition.
|
||||
///
|
||||
/// It is a **whole-byte** comparison against [`guild_presence::OFFLINE`], not `presence &
|
||||
/// ONLINE`, and that is the reference client's own predicate rather than a convenience: it
|
||||
/// derives the flag with `0x4d0c12 test dl,dl` / `0x4d0c1d setne cl` before branching on it.
|
||||
/// An unknown future presence bit therefore reads as *online*, which is the safe direction —
|
||||
/// masking `0x1` would mis-classify it and take the wrong branch on the float.
|
||||
/// Any presence bit set. A whole-byte test, not `& ONLINE`, as it is the wire's condition for
|
||||
/// `lastOnlineTime` in vmangos and in the reference (`0x4d0c12`, `0x4d0c1d`).
|
||||
pub fn is_online(&self) -> bool {
|
||||
self.presence != guild_presence::OFFLINE
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_GUILD_ROSTER` — the whole guild in one packet: the MOTD, the info text, every rank's
|
||||
/// rights, and every member.
|
||||
///
|
||||
/// Always a complete snapshot, never a delta: vmangos re-sends the whole thing for any change
|
||||
/// worth showing (a note edit, a rank-rights change, a promotion). It is also the game's widest
|
||||
/// packet, and the sender **truncates the member list** to fit `GUILD_ROSTER_MAX_LENGTH`
|
||||
/// (`0x8000 - 4` bytes, `Guild/Guild.h:41`) — so [`Self::members`] can be shorter than the guild
|
||||
/// for a very large guild with very long notes, which is the server's behaviour, not a parse error.
|
||||
/// `SMSG_GUILD_ROSTER`: the whole guild, re-sent complete on any change. The server truncates the
|
||||
/// member list to fit `GUILD_ROSTER_MAX_LENGTH` (`0x8000 - 4` bytes, `Guild/Guild.h:41`), so a
|
||||
/// large guild can arrive short.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct GuildRoster {
|
||||
/// The message of the day.
|
||||
pub motd: String,
|
||||
/// The guild information text (the "Guild Information" pane). 1.12-only — vmangos writes it
|
||||
/// under `SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_8_4`.
|
||||
/// The "Guild Information" text, written only for builds above `CLIENT_BUILD_1_8_4`.
|
||||
pub info: String,
|
||||
/// One rights bitmask ([`guild_rank_right`]) per rank, indexed by rank id. Its **length is the
|
||||
/// guild's real rank count** — the one place on the wire that says how many of
|
||||
/// [`GuildQueryResponse::rank_names`]'s ten slots are live.
|
||||
///
|
||||
/// More than [`GUILD_RANKS_MAX_COUNT`] entries is malformed. We still read them all (dropping
|
||||
/// any would desynchronise everything after) and hand them over; a consumer indexing a fixed
|
||||
/// ten-slot model must bound itself. The reference client does **not**: its loop writes
|
||||
/// straight into a 10-entry array at `[0xb726d0, 0xb726f8)`, so a `rankCount >= 12` walks over
|
||||
/// that array's own container fields — a bug to clamp around, never to reproduce (wow-re
|
||||
/// `system/ui/scratch/guild-roster-wire.md`).
|
||||
/// One [`guild_rank_right`] mask per rank, by rank id; the length is the guild's real rank
|
||||
/// count. More than [`GUILD_RANKS_MAX_COUNT`] is malformed but read in full, so a ten-slot
|
||||
/// consumer bounds itself (the reference overruns its array at `0xb726d0`).
|
||||
pub rank_rights: Vec<u32>,
|
||||
/// The members, **arrival order** — vmangos iterates its `members` map, i.e. by low guid.
|
||||
///
|
||||
/// This is not display order and is not stable in any way a UI should lean on: the reference
|
||||
/// client re-`qsort`s its own copy after every packet (`0x4d0d32`). Sorting is the consumer's
|
||||
/// job; this vector is what came off the wire.
|
||||
/// The members in wire order, by low guid; the reference re-sorts its own copy after every
|
||||
/// packet (`0x4d0d32`), so sorting is the consumer's job.
|
||||
pub members: Vec<GuildRosterMember>,
|
||||
}
|
||||
|
||||
/// A defensive cap on the `Vec::with_capacity` hint taken from the wire's member count: a
|
||||
/// corrupt/hostile `u32` must not turn into a multi-gigabyte allocation before the first read
|
||||
/// fails. The real ceiling is the packet's own `0x8000`-byte budget, which cannot hold more than
|
||||
/// ~1.4k members at the minimum member size.
|
||||
/// Allocation cap for the wire member count; a `0x8000`-byte roster fits about 1.4k members.
|
||||
const ROSTER_CAPACITY_HINT_CAP: usize = 2048;
|
||||
|
||||
/// Read `SMSG_GUILD_ROSTER` (VERIFIED **both ends**): `u32 memberCount`, the MOTD, the info text,
|
||||
/// `u32 rankCount`, that many `u32` rights, then the members.
|
||||
///
|
||||
/// Each member is `u64 guid`, `u8 presenceFlags`, name, `u32 rankId`, `u8 level`, `u8 classId`,
|
||||
/// `u32 zoneId`, **`f32 lastOnlineTime` if and only if `presenceFlags == 0`**, public note,
|
||||
/// officer note.
|
||||
///
|
||||
/// Server: vmangos `Server/Packets/Guild.cpp:143-173` `GuildRoster::AppendBodyTo`, filled by
|
||||
/// `Guild/Guild.cpp:803-858` `SendGuildRoster`. Client: wow-re
|
||||
/// `system/ui/scratch/guild-roster-wire.md` — handler `0x4d0ad0`, head reads at `0x4d0af3`,
|
||||
/// `0x4d0b65`, `0x4d0b82`, `0x4d0b9a`, `0x4d0bb3`; member reads at `0x4d0bfd`, `0x4d0c08`,
|
||||
/// `0x4d0c43`, `0x4d0c56`, `0x4d0c61`, `0x4d0c6c`, `0x4d0ca0`, `0x4d0cc2`, `0x4d0ce7`, `0x4d0d02`.
|
||||
///
|
||||
/// That condition is the whole difficulty of this packet. An offline member is 4 bytes longer than
|
||||
/// an online one, so a parser that reads the float unconditionally does not merely get one field
|
||||
/// wrong — it walks off into the following cstrings and mis-parses **every remaining member**.
|
||||
/// It is spelled out as an explicit test on the presence byte rather than a "read if bytes remain"
|
||||
/// check for the same reason [`super::social::read_friend_status`] is: the wire keys the field off
|
||||
/// a value, and only that value can decide it.
|
||||
///
|
||||
/// Three independent sources agree on it, and on its exact shape. vmangos states it twice — the
|
||||
/// writer (`Guild.cpp:164-165`) and the size accounting that decides where to truncate
|
||||
/// (`Guild/Guild.cpp:793-794`). The binary states it once, precisely: `0x4d0cad cmp
|
||||
/// DWORD PTR [eax+0x48],edi` (with `edi` held at `0` across the loop) / `0x4d0cb0 je 0x4d0cba`,
|
||||
/// whose taken leg is the guarded read `0x4d0cc2`; the other leg consumes nothing and stores `0`.
|
||||
/// Two details of that carve are load-bearing here: the predicate is the **whole presence byte**
|
||||
/// against zero (`0x4d0c12 test dl,dl` → `0x4d0c1d setne cl`), never `flags & ONLINE` — which is
|
||||
/// why [`GuildRosterMember::is_online`] compares against [`guild_presence::OFFLINE`] rather than
|
||||
/// masking a bit. And the field really is an `f32`: the client's 4-byte getters are byte-identical
|
||||
/// template clones that could never settle a type, so it is pinned at two *consumers* instead —
|
||||
/// `0x4d0f19 fld` / `0x4d0f1f fcomp` in the roster comparator, and `0x4d1508 fld` in
|
||||
/// `GetGuildRosterLastOnline`.
|
||||
/// Read `SMSG_GUILD_ROSTER` (`Server/Packets/Guild.cpp:143-173`, reference `0x4d0ad0`): `u32`
|
||||
/// member count, MOTD, info text, `u32` rank count and that many `u32` rights, then per member
|
||||
/// `u64` guid, `u8` presence, name, `u32` rank, `u8` level, `u8` class, `u32` zone, an `f32`
|
||||
/// last-online time only when presence is `0` (`0x4d0cad`), public note and officer note.
|
||||
/// Reading the float unconditionally misparses every later member.
|
||||
pub(super) fn read_guild_roster(r: &mut impl Read) -> io::Result<GuildRoster> {
|
||||
let member_count = read_u32_le(r)?;
|
||||
let motd = read_cstring(r)?;
|
||||
@@ -526,48 +349,26 @@ pub(super) fn read_guild_roster(r: &mut impl Read) -> io::Result<GuildRoster> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `SMSG_GUILD_EVENT` — one thing that happened in the guild, as the event id plus its
|
||||
/// already-formatted `%s` arguments.
|
||||
/// `SMSG_GUILD_EVENT`: a guild event id and its `%s` arguments.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct GuildEventNotice {
|
||||
/// A [`guild_event`] id. The reference client's `switch` covers `0x00`–`0x0D` and treats
|
||||
/// anything above as its default arm, so an unknown id is a display question, not a parse one.
|
||||
/// A [`guild_event`] id; the reference sends anything above `0x0D` to its default arm.
|
||||
pub event: u8,
|
||||
/// The event's string arguments, in order, exactly as many as `strCount` said.
|
||||
///
|
||||
/// **Not capped at three, and the reference is not either** — this doc used to claim it was.
|
||||
/// `0x5e7180`'s read loop has no bound: a `strCount >= 4` writes past its three `0x100` stack
|
||||
/// buffers into the live locals (wow-re `system/ui/scratch/guild-api-carve.md` §5). What *is*
|
||||
/// bounded is the **display**: the shared emitter tail `0x5e745f` passes 1, 2 or 3 strings to
|
||||
/// `DisplayError` and passes **none** for `strCount == 0` or `>= 4`. Reading into a `Vec` has
|
||||
/// no overflow to reproduce, so that rule lives on the display side
|
||||
/// (`ui_guild::lines::event_line`, decision 2054) and this field carries what arrived.
|
||||
/// Every string `strCount` announced, uncapped like the reference read loop; the reference
|
||||
/// display passes none of them when there are four or more (`0x5e745f`).
|
||||
pub params: Vec<String>,
|
||||
/// The guildmate the event is about — present for [`guild_event::SIGNED_ON`] and
|
||||
/// [`guild_event::SIGNED_OFF`] only. See [`read_guild_event`] for why that is narrower than
|
||||
/// what vmangos writes.
|
||||
/// The guildmate's guid, read only for [`guild_event::SIGNED_ON`] and
|
||||
/// [`guild_event::SIGNED_OFF`].
|
||||
pub guid: Option<u64>,
|
||||
}
|
||||
|
||||
/// Read `SMSG_GUILD_EVENT` (VERIFIED both ends: vmangos `Server/Packets/Guild.cpp:133-141`
|
||||
/// `GuildEvent::AppendBodyTo`, and the reference client's own handler `0x5e7180` — wow-re RF-0077,
|
||||
/// `system/object-layer/scratch/rf77-smsg-chat-wire-order.md`): `u8 event`, `u8 paramCount`, that
|
||||
/// many cstrings, then the optional trailing guid.
|
||||
///
|
||||
/// **The two ends disagree about *when* the guid is there, and we follow the client.** vmangos
|
||||
/// writes it whenever its `affectedPlayerGuid` is set, which its callers do for `GE_JOINED`
|
||||
/// (`Handlers/GuildHandler.cpp:218`) and `GE_LEFT` (`:405`, `Guild/Guild.cpp:572`) as well as
|
||||
/// `GE_SIGNED_ON`/`GE_SIGNED_OFF`. The reference client reads it in **only** the `0xc`/`0xd` arms
|
||||
/// of its jump table — for the other two the guid is simply trailing slack it never touches (the
|
||||
/// body is length-framed, so ignoring a trailing field costs nothing and desynchronises nothing).
|
||||
/// benilla is the client, so this reads on the event id, and `GE_JOINED`/`GE_LEFT` report
|
||||
/// `guid: None` even when the bytes are present. Nothing is lost: those two carry the player's
|
||||
/// **name** in `params`, which is what the event is displayed by.
|
||||
/// Read `SMSG_GUILD_EVENT` (`Server/Packets/Guild.cpp:133-141`, reference `0x5e7180`): `u8`
|
||||
/// event, `u8` count, that many cstrings, then a `u64` guid that the reference reads only for
|
||||
/// sign-on and sign-off; vmangos also sends it with `GE_JOINED` and `GE_LEFT`, unread there.
|
||||
pub(super) fn read_guild_event(r: &mut impl Read) -> io::Result<GuildEventNotice> {
|
||||
let event = read_u8(r)?;
|
||||
let param_count = read_u8(r)?;
|
||||
// No protocol bound (`Server/Packets/Guild.h:222`, a vector); the events carry one to three
|
||||
// strings, so 16 is generous.
|
||||
// Events carry one to three strings; the count is unbounded (`Server/Packets/Guild.h:222`).
|
||||
let mut params = Vec::with_capacity(capacity_hint(param_count, 16));
|
||||
for _ in 0..param_count {
|
||||
params.push(read_cstring(r)?);
|
||||
@@ -582,21 +383,18 @@ pub(super) fn read_guild_event(r: &mut impl Read) -> io::Result<GuildEventNotice
|
||||
})
|
||||
}
|
||||
|
||||
/// `SMSG_GUILD_COMMAND_RESULT` — the server's verdict on a guild verb we sent.
|
||||
/// `SMSG_GUILD_COMMAND_RESULT`: the server's verdict on a guild verb we sent.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct GuildCommandResult {
|
||||
/// A [`guild_command`] tag. Load-bearing rather than decorative: it is what disambiguates the
|
||||
/// two meanings of result `0x08` (see [`guild_command_error::LEADER_LEAVE`]).
|
||||
/// A [`guild_command`] tag; it decides which of result `0x08`'s two meanings applies.
|
||||
pub command: u32,
|
||||
/// The `%s` the matching error string wants — a player name, a guild name, or empty.
|
||||
/// The `%s` of the matching `ERR_GUILD_*` string: a player name, a guild name, or empty.
|
||||
pub name: String,
|
||||
/// A [`guild_command_error`] code.
|
||||
pub result: u32,
|
||||
}
|
||||
|
||||
/// Read `SMSG_GUILD_COMMAND_RESULT` (VERIFIED vmangos `Server/Packets/Guild.cpp:96-101`
|
||||
/// `GuildCommandResult::AppendBodyTo`): `u32 command`, cstring, `u32 result` — the string is in the
|
||||
/// **middle**, which is the field order to get right.
|
||||
/// Read `SMSG_GUILD_COMMAND_RESULT` (`Server/Packets/Guild.cpp:96-101`): `u32`, cstring, `u32`.
|
||||
pub(super) fn read_guild_command_result(r: &mut impl Read) -> io::Result<GuildCommandResult> {
|
||||
Ok(GuildCommandResult {
|
||||
command: read_u32_le(r)?,
|
||||
@@ -605,35 +403,21 @@ pub(super) fn read_guild_command_result(r: &mut impl Read) -> io::Result<GuildCo
|
||||
})
|
||||
}
|
||||
|
||||
/// `SMSG_GUILD_INFO` — the small "founded on / N members / N accounts" summary, answering
|
||||
/// `CMSG_GUILD_INFO`. Nothing here overlaps [`GuildRoster`]; it is a separate ask.
|
||||
///
|
||||
/// The layout — a cstring then five `u32`s — is VERIFIED at both ends. The **field names below are
|
||||
/// INFERRED**: they are vmangos's, and the binary does not label them. What the binary does supply
|
||||
/// is a corroboration of the first three: the `GUILD_INFO_TEMPLATE` pushes at `0x5e704d`–`0x5e7061`
|
||||
/// reorder them **wire #2, wire #1, wire #3-5**, which is exactly the swap an enUS `M/D/Y` date
|
||||
/// template applies to a `day, month, year` wire (wow-re `system/ui/scratch/guild-roster-wire.md`).
|
||||
/// Good evidence, not a labelled proof — treat a surprising founding date as a lead, not an
|
||||
/// impossibility.
|
||||
/// `SMSG_GUILD_INFO`, answering `CMSG_GUILD_INFO`: founding date, member and account counts. The
|
||||
/// field names are vmangos's; the reference formats wire fields 2, 1, 3 into its enUS `M/D/Y`
|
||||
/// `GUILD_INFO_TEMPLATE` (`0x5e704d`), which fits day, month, year.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct GuildInfo {
|
||||
/// The guild's name.
|
||||
pub name: String,
|
||||
/// Founding day-of-month (INFERRED — see the struct doc).
|
||||
pub created_day: u32,
|
||||
/// Founding month (INFERRED).
|
||||
pub created_month: u32,
|
||||
/// Founding year (INFERRED).
|
||||
pub created_year: u32,
|
||||
/// How many characters are in the guild.
|
||||
pub member_count: u32,
|
||||
/// How many distinct *accounts* those characters belong to — the number the guild pane shows
|
||||
/// alongside the member count.
|
||||
/// How many distinct accounts the members belong to.
|
||||
pub account_count: u32,
|
||||
}
|
||||
|
||||
/// Read `SMSG_GUILD_INFO` (VERIFIED vmangos `Server/Packets/Guild.cpp:103-111`
|
||||
/// `GuildInfo::AppendBodyTo`): the guild name, then day/month/year and the two counts, all `u32`.
|
||||
/// Read `SMSG_GUILD_INFO` (`Server/Packets/Guild.cpp:103-111`): the name, then five `u32`s.
|
||||
pub(super) fn read_guild_info(r: &mut impl Read) -> io::Result<GuildInfo> {
|
||||
Ok(GuildInfo {
|
||||
name: read_cstring(r)?,
|
||||
@@ -645,134 +429,101 @@ pub(super) fn read_guild_info(r: &mut impl Read) -> io::Result<GuildInfo> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Read `SMSG_GUILD_INVITE` (VERIFIED vmangos `Server/Packets/Guild.cpp:85-89`
|
||||
/// `GuildInviteNotification::AppendBodyTo`): two cstrings — who invited us, and into which guild.
|
||||
/// Answered by `CMSG_GUILD_ACCEPT` or `CMSG_GUILD_DECLINE`, neither of which echoes anything, so
|
||||
/// the pending invite is client-side state.
|
||||
/// Read `SMSG_GUILD_INVITE` (`Server/Packets/Guild.cpp:85-89`): inviter, then guild name. The
|
||||
/// accept and decline replies name neither, so the pending invite is client state.
|
||||
pub(super) fn read_guild_invite(r: &mut impl Read) -> io::Result<(String, String)> {
|
||||
let inviter = read_cstring(r)?;
|
||||
let guild = read_cstring(r)?;
|
||||
Ok((inviter, guild))
|
||||
}
|
||||
|
||||
/// Read `SMSG_GUILD_DECLINE` (VERIFIED vmangos `Server/Packets/Guild.cpp:91-94`
|
||||
/// `GuildDeclineNotification::AppendBodyTo`): one cstring — the name of the player who turned our
|
||||
/// invitation down. Delivered only to the inviter (`Handlers/GuildHandler.cpp:230-236`).
|
||||
/// Read `SMSG_GUILD_DECLINE` (`Server/Packets/Guild.cpp:91-94`): who declined our invitation.
|
||||
pub(super) fn read_guild_decline(r: &mut impl Read) -> io::Result<String> {
|
||||
read_cstring(r)
|
||||
}
|
||||
|
||||
/// Body of `CMSG_GUILD_QUERY` (VERIFIED vmangos `Server/Packets/Guild.cpp:8-11`
|
||||
/// `GuildQuery::ReadFromWorldPacket`): one `u32`, the guild id. Answered by
|
||||
/// `SMSG_GUILD_QUERY_RESPONSE` — or, if no such guild exists, by an
|
||||
/// `SMSG_GUILD_COMMAND_RESULT`(`CREATE`, `PLAYER_NOT_IN_GUILD`), which is vmangos's catch-all and
|
||||
/// not literally about us (`Handlers/GuildHandler.cpp:44`).
|
||||
/// Body of `CMSG_GUILD_QUERY` (`Server/Packets/Guild.cpp:8-11`): the `u32` guild id. vmangos
|
||||
/// answers an unknown id with `SMSG_GUILD_COMMAND_RESULT` `(CREATE, PLAYER_NOT_IN_GUILD)`
|
||||
/// (`Handlers/GuildHandler.cpp:44`).
|
||||
pub fn guild_query(guild_id: u32) -> Vec<u8> {
|
||||
guild_id.to_le_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// Body of `CMSG_GUILD_CREATE` (VERIFIED vmangos `Server/Packets/Guild.cpp:3-6`
|
||||
/// `GuildCreate::ReadFromWorldPacket`): one cstring, the desired name.
|
||||
///
|
||||
/// vmangos registers this opcode `STATUS_NEVER` (`Server/Protocol/Opcodes.cpp:210`) — on a 1.12
|
||||
/// realm a guild is founded through the charter/petition flow, never by this packet. Built for
|
||||
/// completeness of the family; do not expect a reply.
|
||||
/// Body of `CMSG_GUILD_CREATE` (`Server/Packets/Guild.cpp:3-6`): the name. vmangos never handles
|
||||
/// it (`STATUS_NEVER`, `Server/Protocol/Opcodes.cpp:210`); a 1.12 guild is founded by charter.
|
||||
pub fn guild_create(name: &str) -> Vec<u8> {
|
||||
cstring_body(name)
|
||||
}
|
||||
|
||||
/// Body of `CMSG_GUILD_INVITE` (VERIFIED vmangos `Server/Packets/Guild.cpp:13-16`
|
||||
/// `GuildInvite::ReadFromWorldPacket`): one cstring, the name to invite. The server normalises the
|
||||
/// case itself (`normalizePlayerName`), so the client need not.
|
||||
/// Body of `CMSG_GUILD_INVITE` (`Server/Packets/Guild.cpp:13-16`): the name, which the server
|
||||
/// case-normalises itself.
|
||||
pub fn guild_invite(name: &str) -> Vec<u8> {
|
||||
cstring_body(name)
|
||||
}
|
||||
|
||||
/// Body of `CMSG_GUILD_ACCEPT` (VERIFIED vmangos `Server/Protocol/Opcodes.cpp:213` —
|
||||
/// `NullClientPacket`): empty. Which invitation it accepts is the server's own pending state
|
||||
/// (`GetGuildIdInvited`), not a field.
|
||||
/// Body of `CMSG_GUILD_ACCEPT`: empty (`Server/Protocol/Opcodes.cpp:213`); the server holds the
|
||||
/// pending invite.
|
||||
pub fn guild_accept() -> Vec<u8> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Body of `CMSG_GUILD_DECLINE` (VERIFIED vmangos `Server/Protocol/Opcodes.cpp:214` —
|
||||
/// `NullClientPacket`): empty. Sends the inviter an `SMSG_GUILD_DECLINE` naming us.
|
||||
/// Body of `CMSG_GUILD_DECLINE`: empty (`Server/Protocol/Opcodes.cpp:214`); the inviter gets an
|
||||
/// `SMSG_GUILD_DECLINE` naming us.
|
||||
pub fn guild_decline() -> Vec<u8> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Body of `CMSG_GUILD_INFO` (VERIFIED vmangos `Server/Protocol/Opcodes.cpp:216` —
|
||||
/// `NullClientPacket`): empty. Answered by `SMSG_GUILD_INFO` for our own guild.
|
||||
/// Body of `CMSG_GUILD_INFO`: empty (`Server/Protocol/Opcodes.cpp:216`); asks about our guild.
|
||||
pub fn guild_info() -> Vec<u8> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Body of `CMSG_GUILD_ROSTER` (VERIFIED vmangos `Server/Protocol/Opcodes.cpp:218` —
|
||||
/// `NullClientPacket`): empty. Answered by the whole `SMSG_GUILD_ROSTER`; the server also pushes
|
||||
/// that packet unasked after any change, so this is a refresh.
|
||||
/// Body of `CMSG_GUILD_ROSTER`: empty (`Server/Protocol/Opcodes.cpp:218`); the server also pushes
|
||||
/// the roster unasked after any change.
|
||||
pub fn guild_roster() -> Vec<u8> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Body of `CMSG_GUILD_PROMOTE` (VERIFIED vmangos `Server/Packets/Guild.cpp:23-26`
|
||||
/// `GuildPromote::ReadFromWorldPacket`): one cstring, who to promote. Promotion is by name and
|
||||
/// moves them **up** one rank — the server does `rank--` (`Guild/Guild.h:52`).
|
||||
/// Body of `CMSG_GUILD_PROMOTE` (`Server/Packets/Guild.cpp:23-26`): the name, moved one rank up.
|
||||
pub fn guild_promote(name: &str) -> Vec<u8> {
|
||||
cstring_body(name)
|
||||
}
|
||||
|
||||
/// Body of `CMSG_GUILD_DEMOTE` (VERIFIED vmangos `Server/Packets/Guild.cpp:28-31`
|
||||
/// `GuildDemote::ReadFromWorldPacket`): one cstring; the server does `rank++`.
|
||||
/// Body of `CMSG_GUILD_DEMOTE` (`Server/Packets/Guild.cpp:28-31`): the name, moved one rank down.
|
||||
pub fn guild_demote(name: &str) -> Vec<u8> {
|
||||
cstring_body(name)
|
||||
}
|
||||
|
||||
/// Body of `CMSG_GUILD_LEAVE` (VERIFIED vmangos `Server/Protocol/Opcodes.cpp:222` —
|
||||
/// `NullClientPacket`): empty. Refused with
|
||||
/// `(QUIT, LEADER_LEAVE)` if we are the guild master and anyone else is still in the guild.
|
||||
/// Body of `CMSG_GUILD_LEAVE`: empty (`Server/Protocol/Opcodes.cpp:222`). A guild master is
|
||||
/// refused `(QUIT, LEADER_LEAVE)` while anyone else remains.
|
||||
pub fn guild_leave() -> Vec<u8> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Body of `CMSG_GUILD_REMOVE` (VERIFIED vmangos `Server/Packets/Guild.cpp:18-21`
|
||||
/// `GuildRemove::ReadFromWorldPacket`): one cstring, who to kick.
|
||||
/// Body of `CMSG_GUILD_REMOVE` (`Server/Packets/Guild.cpp:18-21`): the member to kick.
|
||||
pub fn guild_remove(name: &str) -> Vec<u8> {
|
||||
cstring_body(name)
|
||||
}
|
||||
|
||||
/// Body of `CMSG_GUILD_DISBAND` (VERIFIED vmangos `Server/Protocol/Opcodes.cpp:224` —
|
||||
/// `NullClientPacket`): empty. Guild master only.
|
||||
/// Body of `CMSG_GUILD_DISBAND`: empty (`Server/Protocol/Opcodes.cpp:224`); guild master only.
|
||||
pub fn guild_disband() -> Vec<u8> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Body of `CMSG_GUILD_LEADER` (VERIFIED vmangos `Server/Packets/Guild.cpp:33-36`
|
||||
/// `GuildLeader::ReadFromWorldPacket`): one cstring — hand the guild over to this member.
|
||||
/// Body of `CMSG_GUILD_LEADER` (`Server/Packets/Guild.cpp:33-36`): the new guild master's name.
|
||||
pub fn guild_leader(name: &str) -> Vec<u8> {
|
||||
cstring_body(name)
|
||||
}
|
||||
|
||||
/// Body of `CMSG_GUILD_MOTD` (VERIFIED vmangos `Server/Packets/Guild.cpp:38-42`
|
||||
/// `GuildMOTD::ReadFromWorldPacket`): one cstring, the new message of the day.
|
||||
///
|
||||
/// **Clearing the MOTD sends `""` as a one-byte body (the NUL), not a zero-byte body.** vmangos's
|
||||
/// read is guarded — `if (!recv_data.empty()) recv_data >> motd;` — so a *literally empty* body is
|
||||
/// also accepted and also means "clear it" (its `motd` stays default-constructed). Both shapes
|
||||
/// therefore work, and the guard exists to stop an empty packet throwing a `ByteBufferException`
|
||||
/// rather than to define a second wire form. We emit the well-formed cstring in every case: one
|
||||
/// builder, one shape, and the empty string is not a special case anywhere in this crate.
|
||||
/// Body of `CMSG_GUILD_MOTD` (`Server/Packets/Guild.cpp:38-42`): the new MOTD; `""`, a lone NUL,
|
||||
/// clears it.
|
||||
pub fn guild_motd(motd: &str) -> Vec<u8> {
|
||||
cstring_body(motd)
|
||||
}
|
||||
|
||||
/// Body of `CMSG_GUILD_RANK` (VERIFIED vmangos `Server/Packets/Guild.cpp:78-83`
|
||||
/// `GuildRank::ReadFromWorldPacket`): `u32 rankId`, `u32 rights`, then the rank name.
|
||||
///
|
||||
/// This one packet both renames a rank and rewrites its rights, so a caller changing only one must
|
||||
/// still send the current value of the other. Two server-side facts shape the call: sending it for
|
||||
/// [`guild_default_rank::GUILDMASTER`] has its `rights` **ignored and replaced** with
|
||||
/// [`guild_rank_right::ALL`], and a `name` longer than [`GUILD_RANK_MAX_LENGTH`] characters gets
|
||||
/// the session **kicked** by the anticheat, not refused.
|
||||
/// Body of `CMSG_GUILD_RANK` (`Server/Packets/Guild.cpp:78-83`): rank id, rights, name. Both are
|
||||
/// set, so changing one resends the other; rank 0's rights become [`guild_rank_right::ALL`], and
|
||||
/// a name over [`GUILD_RANK_MAX_LENGTH`] gets the session kicked.
|
||||
pub fn guild_rank(rank_id: u32, rights: u32, name: &str) -> Vec<u8> {
|
||||
let mut body = Vec::with_capacity(9 + name.len());
|
||||
body.extend_from_slice(&rank_id.to_le_bytes());
|
||||
@@ -781,50 +532,41 @@ pub fn guild_rank(rank_id: u32, rights: u32, name: &str) -> Vec<u8> {
|
||||
body
|
||||
}
|
||||
|
||||
/// Body of `CMSG_GUILD_ADD_RANK` (VERIFIED vmangos `Server/Packets/Guild.cpp:73-76`
|
||||
/// `GuildAddRank::ReadFromWorldPacket`): one cstring, the new rank's name. The rank is appended at
|
||||
/// Body of `CMSG_GUILD_ADD_RANK` (`Server/Packets/Guild.cpp:73-76`): the name. The rank goes at
|
||||
/// the bottom with `GCHAT_LISTEN | GCHAT_SPEAK` (`Handlers/GuildHandler.cpp:623`); the server
|
||||
/// silently ignores the packet once the guild already has [`GUILD_RANKS_MAX_COUNT`] ranks.
|
||||
/// ignores the packet once the guild has [`GUILD_RANKS_MAX_COUNT`] ranks.
|
||||
pub fn guild_add_rank(name: &str) -> Vec<u8> {
|
||||
cstring_body(name)
|
||||
}
|
||||
|
||||
/// Body of `CMSG_GUILD_DEL_RANK` (VERIFIED vmangos `Server/Protocol/Opcodes.cpp:655` —
|
||||
/// `NullClientPacket`): empty. There is no rank id: it always deletes the **lowest** rank, which is
|
||||
/// why the reference UI only ever offers to remove the last row.
|
||||
/// Body of `CMSG_GUILD_DEL_RANK`: empty (`Server/Protocol/Opcodes.cpp:655`); the server always
|
||||
/// deletes the lowest rank.
|
||||
pub fn guild_del_rank() -> Vec<u8> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Body of `CMSG_GUILD_SET_PUBLIC_NOTE` (VERIFIED vmangos `Server/Packets/Guild.cpp:61-65`
|
||||
/// `GuildSetPublicNote::ReadFromWorldPacket`): the member's name, then the note.
|
||||
/// Body of `CMSG_GUILD_SET_PUBLIC_NOTE` (`Server/Packets/Guild.cpp:61-65`): name, then note.
|
||||
pub fn guild_set_public_note(name: &str, note: &str) -> Vec<u8> {
|
||||
two_cstring_body(name, note)
|
||||
}
|
||||
|
||||
/// Body of `CMSG_GUILD_SET_OFFICER_NOTE` (VERIFIED vmangos `Server/Packets/Guild.cpp:67-71`
|
||||
/// `GuildSetOfficerNote::ReadFromWorldPacket`): the same two-cstring shape as
|
||||
/// [`guild_set_public_note`], gated on [`guild_rank_right::EDIT_OFFICER_NOTE`] instead.
|
||||
/// Body of `CMSG_GUILD_SET_OFFICER_NOTE` (`Server/Packets/Guild.cpp:67-71`): name, then note.
|
||||
pub fn guild_set_officer_note(name: &str, note: &str) -> Vec<u8> {
|
||||
two_cstring_body(name, note)
|
||||
}
|
||||
|
||||
/// Body of `CMSG_GUILD_INFO_TEXT` (VERIFIED vmangos `Server/Packets/Guild.cpp:44-49`
|
||||
/// `GuildChangeInfoText::ReadFromWorldPacket`): one cstring, the guild information text. 1.12-only
|
||||
/// — vmangos compiles both the opcode's handler and the roster's matching `guildInfo` field under
|
||||
/// `SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_8_4`.
|
||||
/// Body of `CMSG_GUILD_INFO_TEXT` (`Server/Packets/Guild.cpp:44-49`): the guild information text;
|
||||
/// vmangos handles it only for builds above `CLIENT_BUILD_1_8_4`.
|
||||
pub fn guild_info_text(text: &str) -> Vec<u8> {
|
||||
cstring_body(text)
|
||||
}
|
||||
|
||||
/// A body that is exactly one NUL-terminated string.
|
||||
fn cstring_body(s: &str) -> Vec<u8> {
|
||||
let mut body = Vec::with_capacity(s.len() + 1);
|
||||
push_cstring(&mut body, s);
|
||||
body
|
||||
}
|
||||
|
||||
/// A body that is exactly two NUL-terminated strings.
|
||||
fn two_cstring_body(a: &str, b: &str) -> Vec<u8> {
|
||||
let mut body = Vec::with_capacity(a.len() + b.len() + 2);
|
||||
push_cstring(&mut body, a);
|
||||
@@ -832,7 +574,6 @@ fn two_cstring_body(a: &str, b: &str) -> Vec<u8> {
|
||||
body
|
||||
}
|
||||
|
||||
/// 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);
|
||||
@@ -842,8 +583,6 @@ fn push_cstring(body: &mut Vec<u8>, s: &str) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The rights table is *not* `1 << (i - 1)`: the FrameXML checkbox order puts Promote/Demote
|
||||
/// ahead of Invite/Remove, whose bits are lower. This pins the two orders apart.
|
||||
#[test]
|
||||
fn rank_right_checkbox_order_is_not_bit_order() {
|
||||
assert_eq!(GUILD_RANK_RIGHT_ORDER[4], 0x0000_0080, "idx 5 = Promote");
|
||||
@@ -854,15 +593,13 @@ mod tests {
|
||||
GUILD_RANK_RIGHT_ORDER[4] > GUILD_RANK_RIGHT_ORDER[6],
|
||||
"the order is deliberately non-monotonic in bit value"
|
||||
);
|
||||
// Every entry is a single bit, and none of them is the GR_RIGHT_EMPTY sentinel.
|
||||
for right in GUILD_RANK_RIGHT_ORDER {
|
||||
assert_eq!(right.count_ones(), 1, "{right:#x} is one bit");
|
||||
assert_ne!(right, guild_rank_right::EMPTY);
|
||||
}
|
||||
}
|
||||
|
||||
/// Each right, ORed with the `GR_RIGHT_EMPTY` sentinel, reproduces vmangos's own constant —
|
||||
/// the check that the "factor the 0x40 out" reading of `Guild/Guild.h:56-74` is right.
|
||||
/// The expected values are vmangos's `GuildRankRights` (`Guild/Guild.h:56-74`).
|
||||
#[test]
|
||||
fn rank_rights_reconstruct_the_vmangos_constants() {
|
||||
use guild_rank_right::*;
|
||||
|
||||
@@ -1,58 +1,28 @@
|
||||
//! The instance/raid **lockout message** family — the six server packets the 1.12 client turns
|
||||
//! into a `CHAT_MSG_SYSTEM` line or into its own lockout bookkeeping, plus the one thing the
|
||||
//! player can ask for (`CMSG_RESET_INSTANCES`). Decision 1748.
|
||||
//!
|
||||
//! | opcode | direction | body | what the client does with it |
|
||||
//! |---|---|---|---|
|
||||
//! | `SMSG_INSTANCE_SAVE_CREATED` 0x2cb | in | `u32` flag | chat line `INSTANCE_SAVED` |
|
||||
//! | `SMSG_RAID_INSTANCE_MESSAGE` 0x2fa | in | `u32` type, `u32` mapId, `u32` secsToReset | chat line, one of four |
|
||||
//! | `CMSG_RESET_INSTANCES` 0x31d | out | empty | — |
|
||||
//! | `SMSG_INSTANCE_RESET` 0x31e | in | `u32` mapId | chat line `INSTANCE_RESET_SUCCESS` + clears the last-instance latch |
|
||||
//! | `SMSG_INSTANCE_RESET_FAILED` 0x31f | in | `u32` reason, `u32` mapId | chat line, one of three |
|
||||
//! | `SMSG_UPDATE_LAST_INSTANCE` 0x320 | in | `u32` mapId | records the last dungeon we were in |
|
||||
//! | `SMSG_UPDATE_INSTANCE_OWNERSHIP` 0x32b | in | `u32` flag | the "do I hold any permanent bind" latch |
|
||||
//!
|
||||
//! **The client side is VERIFIED at the bytes** (WoW.exe build 5875): the six handlers are
|
||||
//! registered together at `0x498680`-`0x4986cf` (`0x49e1c0` 0x2fa, `0x49e470` 0x31e, `0x49e540`
|
||||
//! 0x31f, `0x49e670` 0x320, `0x49e6c0` 0x32b) with the save-created one registered apart, in the
|
||||
//! chat-spam-filter TU, at `0x4e7e48` (`0x4e7e60`). Every one reads its fields with
|
||||
//! `CDataStore::GetInt32 0x418e30` in the order below, so the layouts here are the client's own
|
||||
//! read order, not an inference from the server. The server side is VERIFIED vmangos
|
||||
//! `Server/Packets/Misc.{h,cpp}` (`RaidInstanceMessage`, `InstanceReset`, `InstanceResetFailed`,
|
||||
//! `UpdateLastInstance`, `UpdateInstanceOwnership`) + `Maps/Map.cpp:2288,2388` for the
|
||||
//! save-created flag, and the two agree field for field.
|
||||
//!
|
||||
//! **No map NAME is ever on the wire** — every one of these carries a `Map.dbc` id and the client
|
||||
//! looks the display name up itself (`[0xc0daa8][id]`, field `+0x10` = `MapName_Lang[locale]`),
|
||||
//! falling back to the id printed through `"%d"` when the row is missing. Ours resolves through
|
||||
//! `MapCatalog::name` with the same fallback.
|
||||
//! The instance lockout messages and `CMSG_RESET_INSTANCES`. The 1.12 client prints a system chat
|
||||
//! line for a new save (`INSTANCE_SAVED`), a raid warning, a reset (`INSTANCE_RESET_SUCCESS`,
|
||||
//! which also clears the last instance) and a failed reset, and keeps the last instance and the
|
||||
//! permanent-bind flag as state. Maps travel as `Map.dbc` ids; the reference looks the name up
|
||||
//! itself (`0xc0daa8`) and prints the id when the row is missing.
|
||||
|
||||
use std::io;
|
||||
|
||||
use crate::wire::read_u32_le;
|
||||
|
||||
/// `SMSG_RAID_INSTANCE_MESSAGE`'s `type` — which of the four warning templates the line uses
|
||||
/// (VERIFIED: the client's `switch (type - 1)` jump table at `0x49e45c`, four entries; the same
|
||||
/// numbering vmangos writes in `Objects/Player.h:568-573`).
|
||||
///
|
||||
/// **Type 5 (`RAID_INSTANCE_EXPIRED` in later clients) and type 0 print nothing**: the dispatch is
|
||||
/// `dec eax; cmp eax,3; ja <return>`, so anything outside 1..=4 leaves the handler silently.
|
||||
/// `SMSG_RAID_INSTANCE_MESSAGE`'s `type` (`Objects/Player.h:568-573`). Outside 1-4 the reference
|
||||
/// prints nothing (jump table `0x49e45c`), even for 5, later clients' `RAID_INSTANCE_EXPIRED`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RaidInstanceWarning {
|
||||
/// 1 — `RAID_INSTANCE_WARNING_HOURS`, filled with `resetTime / 3600`.
|
||||
/// 1: `RAID_INSTANCE_WARNING_HOURS`, filled with `resetTime / 3600`.
|
||||
Hours,
|
||||
/// 2 — `RAID_INSTANCE_WARNING_MIN`, filled with `resetTime / 60`.
|
||||
/// 2: `RAID_INSTANCE_WARNING_MIN`, filled with `resetTime / 60`.
|
||||
Minutes,
|
||||
/// 3 — `RAID_INSTANCE_WARNING_MIN_SOON`, filled with `resetTime / 60`.
|
||||
/// 3: `RAID_INSTANCE_WARNING_MIN_SOON`, filled with `resetTime / 60`.
|
||||
MinutesSoon,
|
||||
/// 4 — `RAID_INSTANCE_WELCOME`, filled with the `d`/`h`/`m` breakdown of `resetTime`.
|
||||
/// 4: `RAID_INSTANCE_WELCOME`, filled with the `d`/`h`/`m` breakdown of `resetTime`.
|
||||
Welcome,
|
||||
}
|
||||
|
||||
impl RaidInstanceWarning {
|
||||
/// The wire `type` → the template, or `None` for the values the reference's jump table drops
|
||||
/// (0 and ≥ 5). A dropped type is not an error: the packet is well-formed, the client just
|
||||
/// has no line for it.
|
||||
pub fn from_wire(ty: u32) -> Option<Self> {
|
||||
match ty {
|
||||
1 => Some(Self::Hours),
|
||||
@@ -63,8 +33,7 @@ impl RaidInstanceWarning {
|
||||
}
|
||||
}
|
||||
|
||||
/// The GlobalStrings token the reference passes to `GetText` for this template (the four
|
||||
/// `mov ecx, <string>` at `0x49e276`/`0x49e2e5`/`0x49e354`/`0x49e40b`).
|
||||
/// The reference's GlobalStrings token (`0x49e276`, `0x49e2e5`, `0x49e354`, `0x49e40b`).
|
||||
pub fn token(self) -> &'static str {
|
||||
match self {
|
||||
Self::Hours => "RAID_INSTANCE_WARNING_HOURS",
|
||||
@@ -75,38 +44,31 @@ impl RaidInstanceWarning {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_RAID_INSTANCE_MESSAGE` — a raid lockout's welcome/countdown line.
|
||||
/// `SMSG_RAID_INSTANCE_MESSAGE`: a raid lockout's welcome or countdown line.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RaidInstanceMessage {
|
||||
/// The raw wire type. Kept raw rather than pre-resolved so a type the reference drops still
|
||||
/// round-trips through the event layer (and so a golden can assert the drop).
|
||||
/// The raw wire type; [`RaidInstanceWarning::from_wire`] resolves it.
|
||||
pub message_type: u32,
|
||||
/// `Map.dbc` id of the instance the warning is about.
|
||||
pub map: u32,
|
||||
/// **Seconds remaining** until the reset, not a timestamp (vmangos passes
|
||||
/// `resetTime` = a remaining duration into `SendInstanceResetWarning`).
|
||||
/// Seconds until the reset, a duration rather than a timestamp (`SendInstanceResetWarning`).
|
||||
pub reset: u32,
|
||||
}
|
||||
|
||||
/// `SMSG_INSTANCE_RESET_FAILED`'s `reason` — which of the three refusal templates the line uses
|
||||
/// (VERIFIED: the client's `sub eax,0; je / dec eax; je / dec eax; jne` ladder at `0x49e5b7`;
|
||||
/// vmangos `Maps/MapPersistentStateMgr.h:270-276`, whose fourth value `_SILENTLY` = 3 is
|
||||
/// deliberately outside the ladder).
|
||||
/// `SMSG_INSTANCE_RESET_FAILED`'s `reason` (ladder at `0x49e5b7`; vmangos
|
||||
/// `Maps/MapPersistentStateMgr.h:270-276`, whose `INSTANCERESET_FAIL_SILENTLY` is 3).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InstanceResetFailure {
|
||||
/// 0 — `INSTANCE_RESET_FAILED`: players still inside.
|
||||
/// 0: `INSTANCE_RESET_FAILED`, players still inside.
|
||||
General,
|
||||
/// 1 — `INSTANCE_RESET_FAILED_OFFLINE`: someone in the party is offline.
|
||||
/// 1: `INSTANCE_RESET_FAILED_OFFLINE`, someone in the party is offline.
|
||||
Offline,
|
||||
/// 2 — `INSTANCE_RESET_FAILED_ZONING`: someone in the party is zoning in.
|
||||
/// 2: `INSTANCE_RESET_FAILED_ZONING`, someone in the party is zoning in.
|
||||
Zoning,
|
||||
}
|
||||
|
||||
impl InstanceResetFailure {
|
||||
/// The wire `reason` → the template, or `None` for ≥ 3 (`INSTANCERESET_FAIL_SILENTLY` and
|
||||
/// above). The reference *falls through* those to its chat call with an **uninitialized**
|
||||
/// stack buffer — a real 1.12 bug we do not reproduce: silence is what the server means by
|
||||
/// them, and printing a garbage line is not fidelity to an intent, only to an accident.
|
||||
/// Deviation: a reason of 3 or more is silent, where the reference prints an uninitialized
|
||||
/// stack buffer; 3 is the server's `INSTANCERESET_FAIL_SILENTLY`.
|
||||
pub fn from_wire(reason: u32) -> Option<Self> {
|
||||
match reason {
|
||||
0 => Some(Self::General),
|
||||
@@ -116,7 +78,7 @@ impl InstanceResetFailure {
|
||||
}
|
||||
}
|
||||
|
||||
/// The GlobalStrings token (the three `mov ecx, <string>` at `0x49e61b`/`0x49e5f6`/`0x49e5d1`).
|
||||
/// The reference's GlobalStrings token (`0x49e61b`, `0x49e5f6`, `0x49e5d1`).
|
||||
pub fn token(self) -> &'static str {
|
||||
match self {
|
||||
Self::General => "INSTANCE_RESET_FAILED",
|
||||
@@ -126,17 +88,16 @@ impl InstanceResetFailure {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_INSTANCE_RESET_FAILED` — the group leader's reset was refused.
|
||||
/// `SMSG_INSTANCE_RESET_FAILED`: the group leader's reset was refused.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct InstanceResetFailed {
|
||||
/// The raw wire reason (see [`InstanceResetFailure::from_wire`]).
|
||||
/// The raw wire reason; [`InstanceResetFailure::from_wire`] resolves it.
|
||||
pub reason: u32,
|
||||
/// `Map.dbc` id of the instance that would not reset.
|
||||
pub map: u32,
|
||||
}
|
||||
|
||||
/// Read `SMSG_RAID_INSTANCE_MESSAGE`: `u32 type`, `u32 mapId`, `u32 secondsUntilReset` — the
|
||||
/// client's own read order at `0x49e1cd`/`0x49e1d8`/`0x49e1e3`.
|
||||
/// Read `SMSG_RAID_INSTANCE_MESSAGE`: type, map, seconds to reset (reference reads `0x49e1cd`,
|
||||
/// `0x49e1d8`, `0x49e1e3`).
|
||||
pub(super) fn read_raid_instance_message(r: &mut &[u8]) -> io::Result<RaidInstanceMessage> {
|
||||
Ok(RaidInstanceMessage {
|
||||
message_type: read_u32_le(r)?,
|
||||
@@ -153,31 +114,28 @@ pub(super) fn read_instance_reset_failed(r: &mut &[u8]) -> io::Result<InstanceRe
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a body that is one bare `u32` — `SMSG_INSTANCE_RESET`'s map id (`0x49e481`),
|
||||
/// `SMSG_UPDATE_LAST_INSTANCE`'s map id (`0x49e676`), `SMSG_UPDATE_INSTANCE_OWNERSHIP`'s flag
|
||||
/// (`0x49e6c6`) and `SMSG_INSTANCE_SAVE_CREATED`'s flag (`0x4e7e6c`) are all exactly this.
|
||||
/// Read a one-`u32` body: `SMSG_INSTANCE_RESET`'s map (`0x49e481`), `SMSG_UPDATE_LAST_INSTANCE`'s
|
||||
/// map (`0x49e676`), `SMSG_UPDATE_INSTANCE_OWNERSHIP`'s permanent-bind flag (`0x49e6c6`) and
|
||||
/// `SMSG_INSTANCE_SAVE_CREATED`'s flag (`0x4e7e6c`).
|
||||
pub(super) fn read_u32_body(r: &mut &[u8]) -> io::Result<u32> {
|
||||
read_u32_le(r)
|
||||
}
|
||||
|
||||
/// Body of `CMSG_RESET_INSTANCES` (VERIFIED both ways: the client's `ResetInstances` binding
|
||||
/// `0x48a6b0` builds a `CDataStore` and sends opcode `0x31d` without writing a field; vmangos
|
||||
/// `HandleResetInstancesOpcode` reads nothing): empty.
|
||||
/// Body of `CMSG_RESET_INSTANCES`: empty (reference `ResetInstances`, `0x48a6b0`).
|
||||
pub fn reset_instances() -> Vec<u8> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// `SMSG_RAID_GROUP_ONLY` (VERIFIED, wow-re `staticpopup-dialog-bindings.md` §5.2, arm
|
||||
/// `0x5e48f1`): the instance-boot clock and its reason. `delay_ms > 0` arms the boot deadline
|
||||
/// (`INSTANCE_BOOT_START`); `0` clears it (`INSTANCE_BOOT_STOP`), and then `reason` 1 / 2 is
|
||||
/// `ERR_RAID_GROUP_ONLY` / `ERR_RAID_GROUP_FULL` on screen. Decision 1963.
|
||||
/// `SMSG_RAID_GROUP_ONLY` (reference `0x5e48f1`): a nonzero `delay_ms` arms the instance-boot
|
||||
/// deadline (`INSTANCE_BOOT_START`); `0` clears it (`INSTANCE_BOOT_STOP`), and then `reason` 1 or
|
||||
/// 2 shows `ERR_RAID_GROUP_ONLY` or `ERR_RAID_GROUP_FULL`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RaidGroupOnly {
|
||||
pub delay_ms: u32,
|
||||
pub reason: u32,
|
||||
}
|
||||
|
||||
/// Parse `SMSG_RAID_GROUP_ONLY`: `u32 delayMs`, `u32 reason` — both 32-bit reads.
|
||||
/// Parse `SMSG_RAID_GROUP_ONLY`: `u32 delayMs`, `u32 reason`.
|
||||
pub(super) fn read_raid_group_only(r: &mut impl std::io::Read) -> std::io::Result<RaidGroupOnly> {
|
||||
Ok(RaidGroupOnly {
|
||||
delay_ms: crate::wire::read_u32_le(r)?,
|
||||
|
||||
@@ -1,209 +1,137 @@
|
||||
//! Item messages — the T2 container groundwork (decision 0068's tier ladder: Bagnon needs item
|
||||
//! identity), widened to the **full** 1.12.1 item template (decision 0274 P1: the tooltip builder
|
||||
//! needs every line the real client can render). The 1.12 wire carries no item *templates* in
|
||||
//! descriptors — like unit names, they answer a query pair: `CMSG_ITEM_QUERY_SINGLE` (entry + guid)
|
||||
//! → `SMSG_ITEM_QUERY_SINGLE_RESPONSE` (VERIFIED vmangos `HandleItemQuerySingleOpcode`; opcodes
|
||||
//! 86/88 `Opcodes_1_12_1.h`).
|
||||
//!
|
||||
//! [`ItemInfo`] now carries the response whole: identity (class/subclass/name — 4 name slots, the
|
||||
//! server sends 1 + 3 empties — displayInfoID, quality, inventoryType, sheath), the buy/sell
|
||||
//! economy, every requirement gate (level/skill/spell/honor rank/city rank/reputation,
|
||||
//! allowable class/race), stacking (maxCount/stackable/containerSlots), the full 10-slot stat
|
||||
//! block, all 5 damage blocks (block 0 also mirrors into the legacy `dmg_min`/`dmg_max`/`dmg_type`
|
||||
//! fields existing consumers already key on), armor plus the 6-wide resistance run, ranged data,
|
||||
//! all 5 spell-trigger slots (the first ON_USE slot still surfaces separately as `use_spell` — the
|
||||
//! client's own cooldown-scan key), bonding, description, page/lock/material/random-property/set,
|
||||
//! durability, and the area/map/bagFamily tail (VERIFIED field order vmangos
|
||||
//! `HandleItemQuerySingleOpcode`, `ItemHandler.cpp:269-415`; every `SUPPORTED_CLIENT_BUILD`
|
||||
//! conditional in that function evaluates *included* for build 5875). A **miss** (undiscovered/
|
||||
//! unknown entry) is the lone `u32` of `entry | 0x8000_0000`, the same shape as the creature miss.
|
||||
//! Item messages. Item templates are not in descriptors: `CMSG_ITEM_QUERY_SINGLE` (opcode 86,
|
||||
//! entry and guid) asks, and `SMSG_ITEM_QUERY_SINGLE_RESPONSE` (88) answers in the field order of
|
||||
//! vmangos `ItemHandler.cpp:269-415`, every build conditional included at 5875. A miss is the lone
|
||||
//! `u32` `entry | 0x8000_0000`.
|
||||
|
||||
use std::io;
|
||||
|
||||
use crate::wire::{read_cstring, read_f32_le, read_i32_le, read_u32_le, read_u64_le, read_u8};
|
||||
|
||||
/// A full item-template answer (decision 0274 P1: the tooltip builder's source of truth; every
|
||||
/// field the wire carries, none discarded). (`PartialEq` only: several fields are wire floats —
|
||||
/// the damage bounds and `ranged_mod_range`.)
|
||||
/// One item template, as `SMSG_ITEM_QUERY_SINGLE_RESPONSE` carries it.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ItemInfo {
|
||||
pub class: u32,
|
||||
pub subclass: u32,
|
||||
pub name: String,
|
||||
/// `ItemDisplayInfo.dbc` key — the icon/model resolve.
|
||||
/// `ItemDisplayInfo.dbc` id, for the icon and model.
|
||||
pub display_info_id: u32,
|
||||
/// 0 poor … 6 artifact (the RF-55 quality-color table's index).
|
||||
/// 0 poor to 6 artifact; indexes the quality colour table.
|
||||
pub quality: u32,
|
||||
/// `ItemPrototypeFlags` bitmask (conjured, lootable, indestructible, wrapper, no-equip-cooldown,
|
||||
/// …) — the tooltip's "Unique"/no-sell/no-disenchant lines key on bits here.
|
||||
/// `ItemPrototypeFlags` bits (conjured, lootable, wrapper, …); tooltip lines key on them.
|
||||
pub flags: u32,
|
||||
/// `BuyPrice` — what a vendor charges per [`crate::messages::VendorItem::buy_count`]-sized
|
||||
/// stack, in copper.
|
||||
/// Copper a vendor charges per [`crate::messages::VendorItem::buy_count`] stack.
|
||||
pub buy_price: u32,
|
||||
/// `SellPrice` — what a vendor pays per unit, in copper (the bag tooltip's money row while a
|
||||
/// merchant is open; 0 = unsellable → the "No sell price" line).
|
||||
/// Copper a vendor pays per unit; 0 is unsellable, the tooltip's "No sell price" line.
|
||||
pub sell_price: u32,
|
||||
/// `InventoryType` — the equip-slot family (1 head, 21/22 main/off-hand weapon, …); drives
|
||||
/// which paperdoll slot an item can go in and which visual-item field it feeds.
|
||||
/// `InventoryType`, the equip-slot family (1 head, 21/22 main/off-hand weapon, …).
|
||||
pub inventory_type: u32,
|
||||
/// `AllowableClass` — a class bitmask; the all-bits-set sentinel (`-1`) means no class
|
||||
/// restriction, so this stays signed rather than reading as the unsigned `0xFFFF_FFFF`.
|
||||
/// Class bitmask; `-1`, all bits, means no restriction.
|
||||
pub allowable_class: i32,
|
||||
/// `AllowableRace` — the same bitmask shape as [`Self::allowable_class`], races instead.
|
||||
/// Race bitmask; `-1` means no restriction.
|
||||
pub allowable_race: i32,
|
||||
/// `ItemLevel` — the repair-cost formula's `DurabilityCosts.dbc` row key (also a tooltip line).
|
||||
/// Item level, the `DurabilityCosts.dbc` row for the repair cost.
|
||||
pub item_level: u32,
|
||||
/// `RequiredLevel` — the tooltip's "Requires Level N" line; 0 = no level requirement.
|
||||
/// The "Requires Level N" line; 0 means none.
|
||||
pub required_level: u32,
|
||||
/// `RequiredSkill` — id from `SkillLine.dbc`; 0 = no skill requirement.
|
||||
/// `SkillLine.dbc` id; 0 means none.
|
||||
pub required_skill: u32,
|
||||
/// `RequiredSkillRank` — the skill value [`Self::required_skill`] must meet or exceed.
|
||||
/// The minimum [`Self::required_skill`] value.
|
||||
pub required_skill_rank: u32,
|
||||
/// `RequiredSpell` — id from `Spell.dbc`; the item is unusable without knowing this spell.
|
||||
/// A `Spell.dbc` id the player must know to use the item.
|
||||
pub required_spell: u32,
|
||||
/// `RequiredHonorRank`/`RequiredCityRank` — two more requirement gates the wire carries; the
|
||||
/// tooltip's requirement-line law for these two is unverified (folds in with decision 0274's
|
||||
/// §5 line-order dispatch).
|
||||
pub required_honor_rank: u32,
|
||||
pub required_city_rank: u32,
|
||||
/// `RequiredReputationFaction` — id from `Faction.dbc`; 0 = no reputation requirement.
|
||||
/// `Faction.dbc` id; 0 means none.
|
||||
pub required_rep_faction: u32,
|
||||
/// `RequiredReputationRank` — the wire's own gate: the server sends 0 whenever
|
||||
/// [`Self::required_rep_faction`] is 0, even if the row has a nonzero rank (VERIFIED vmangos
|
||||
/// `ItemHandler.cpp:321-322`).
|
||||
/// Sent as 0 whenever [`Self::required_rep_faction`] is 0 (`ItemHandler.cpp:321-322`).
|
||||
pub required_rep_rank: u32,
|
||||
/// `MaxCount` — the account-wide cap this item enforces (0 = uncapped); the tooltip's "Unique"
|
||||
/// family of lines derive from this and [`Self::flags`].
|
||||
/// `MaxCount`, 0 uncapped; the tooltip's "Unique" lines derive from it and [`Self::flags`].
|
||||
pub max_count: u32,
|
||||
/// `Stackable` — the max stack size a single slot can hold (1 = doesn't stack).
|
||||
/// Maximum stack size; 1 does not stack.
|
||||
pub stackable: u32,
|
||||
/// `ContainerSlots` — nonzero only for bag items (the number of slots the bag itself grants).
|
||||
/// Slots a bag grants; 0 for anything else.
|
||||
pub container_slots: u32,
|
||||
/// The 10-slot `ItemStat` block, `(type, value)`, **filtered to nonzero entries** (type or
|
||||
/// value nonzero) in wire order — the tooltip's "+N Stat" lines (`ItemModType` at this build:
|
||||
/// 0 mana, 1 health, 3 agility, 4 strength, 5 intellect, 6 spirit, 7 stamina).
|
||||
/// The 10-slot `ItemStat` block as `(type, value)`, all-zero slots dropped, in wire order
|
||||
/// (`ItemModType`: 0 mana, 1 health, 3 agility, 4 strength, 5 intellect, 6 spirit, 7 stamina).
|
||||
pub stats: Vec<(u32, i32)>,
|
||||
/// The 5-slot `Damage` block, **filtered to entries with `max > 0`**, in wire order —
|
||||
/// secondary damage lines (e.g. a Fiery weapon's bonus Fire line) beyond the primary
|
||||
/// [`Self::dmg_min`]/[`Self::dmg_max`]/[`Self::dmg_type`], which always mirror block 0 whether
|
||||
/// or not it clears this filter.
|
||||
/// The 5-slot `Damage` block, entries with `max > 0` only, in wire order.
|
||||
pub damages: Vec<ItemDamage>,
|
||||
/// Damage block 0's per-hit minimum (the tooltip's "X - Y Damage" line; 0 for non-weapons) —
|
||||
/// kept mirrored from `damages` block 0 for existing consumers.
|
||||
/// Damage block 0's minimum, whether or not block 0 is in [`Self::damages`].
|
||||
pub dmg_min: f32,
|
||||
/// Damage block 0's per-hit maximum.
|
||||
pub dmg_max: f32,
|
||||
/// Damage block 0's school (0 physical, 1 Holy … 6 Arcane — the tooltip's school suffix).
|
||||
/// Damage block 0's school (0 physical, 1 Holy … 6 Arcane).
|
||||
pub dmg_type: u32,
|
||||
/// `Armor` — the first slot of the wire's 7-wide resistance run.
|
||||
/// The first slot of the wire's 7-wide resistance run.
|
||||
pub armor: u32,
|
||||
/// The remaining 6 slots of the resistance run, in wire order: `[holy, fire, nature, frost,
|
||||
/// shadow, arcane]` (`int32` on the wire in vmangos's own `ItemPrototype` — a template's
|
||||
/// resistance can't go negative in practice, but the sign rides along).
|
||||
/// The other six resistances: holy, fire, nature, frost, shadow, arcane (`int32` in vmangos).
|
||||
pub resistances: [i32; 6],
|
||||
/// Attack delay in milliseconds (the tooltip's "Speed" = delay / 1000).
|
||||
pub delay_ms: u32,
|
||||
/// `AmmoType` — the projectile family a ranged weapon consumes (0 none, 2 arrow, 3 bullet).
|
||||
/// The projectile a ranged weapon consumes (0 none, 2 arrow, 3 bullet).
|
||||
pub ammo_type: u32,
|
||||
/// `RangedModRange` — a ranged weapon's range multiplier; the tooltip never shows this raw, it
|
||||
/// feeds the range formula.
|
||||
/// A ranged weapon's range multiplier.
|
||||
pub ranged_mod_range: f32,
|
||||
/// The 5-slot `ItemSpell` block, **filtered to entries with `spell_id != 0`**, in wire order —
|
||||
/// every "Use:"/"Equip:"/"Chance on hit:" trigger line the tooltip can render. Each entry
|
||||
/// carries its own [`ItemSpellEntry::index`], since this vector's positions are not the
|
||||
/// template's block ordinals.
|
||||
/// The 5-slot `ItemSpell` block, `spell_id != 0` only, in wire order; positions here are not
|
||||
/// block ordinals, [`ItemSpellEntry::index`] is.
|
||||
pub spells: Vec<ItemSpellEntry>,
|
||||
/// Spell **block 0**'s `SpellCharges` word, raw and unfiltered — the reference's
|
||||
/// `template+0x144`, and the sole input to [`Self::has_finite_charges`].
|
||||
/// Block 0's `SpellCharges`, unfiltered: the reference's `template+0x144`.
|
||||
pub spell_charges_0: i32,
|
||||
/// The first ON_USE (`SpellTrigger == 0`) spell block — what a right-click/action-bar use
|
||||
/// casts, and the key the item's cooldown tracks (the client's own 5-slot scan: spell id > 0,
|
||||
/// trigger == 0 — wow-re `wave-cooldown.md` `GetItemCooldown 0x6e2ed0`). `None` for items with
|
||||
/// no use effect. A stored view onto [`Self::spells`] (rather than a derived accessor) so
|
||||
/// existing cooldown/tooltip consumers reading `.use_spell` are untouched.
|
||||
/// The first ON_USE (trigger 0) spell block: what a use casts, and the item's cooldown key
|
||||
/// (the reference's `GetItemCooldown` scan, `0x6e2ed0`).
|
||||
pub use_spell: Option<ItemUseSpell>,
|
||||
/// `Bonding` — `ItemBondingType` (0 none … 4 quest-bind); the tooltip's "Binds when picked
|
||||
/// up"/"equipped"/"used" line.
|
||||
/// `ItemBondingType` (0 none … 4 quest-bind).
|
||||
pub bonding: u32,
|
||||
/// The item's flavor text (the tooltip's italic line under the stat block); empty = none.
|
||||
/// Flavor text, the tooltip's italic line; empty for none.
|
||||
pub description: String,
|
||||
/// `PageText` — a readable item's `PageText.wdb` id (0 = not a book/readable).
|
||||
/// A readable item's `PageText.wdb` id; 0 for none.
|
||||
pub page_text: u32,
|
||||
/// `LanguageID` — id from `Languages.dbc`; which in-game language a readable's text renders in.
|
||||
/// `Languages.dbc` id a readable's text is written in.
|
||||
pub language_id: u32,
|
||||
/// `PageMaterial` — id from `PageTextMaterial.dbc`; the book-frame background/texture.
|
||||
/// `PageTextMaterial.dbc` id, the book frame's background.
|
||||
pub page_material: u32,
|
||||
/// `StartQuest` — a quest-starter item's quest id (0 = doesn't start a quest).
|
||||
/// The quest this item starts; 0 for none.
|
||||
pub start_quest: u32,
|
||||
/// `LockID` — id from `Lock.dbc`; nonzero means the item (a chest/junkbox) needs picking/keying
|
||||
/// open.
|
||||
/// `Lock.dbc` id; nonzero means the item must be picked or keyed open.
|
||||
pub lock_id: u32,
|
||||
/// `Material` — id from `Material.dbc`; drives the item's equip/drop/footstep sound set.
|
||||
/// `Material.dbc` id, for the item's sounds.
|
||||
pub material: u32,
|
||||
/// `Sheath` — the holster style a drawn weapon of this type renders with (vmangos
|
||||
/// `ItemPrototype::Sheath`; the same vocabulary as [`super::update_object::ObjectFields`]'s
|
||||
/// virtual-item sheath byte).
|
||||
/// Sheath style, in the values of the descriptor's virtual-item sheath byte.
|
||||
pub sheath: u32,
|
||||
/// `RandomProperty` — id from `ItemRandomProperties.dbc`; a "of the Whale"-style suffix roll
|
||||
/// (the concrete roll lives on the item *instance*, not the template — this is just which
|
||||
/// property table applies).
|
||||
/// `ItemRandomProperties.dbc` id for an "of the Whale" suffix, rolled per instance.
|
||||
pub random_property: u32,
|
||||
/// `Block` — a shield's block value (two `u32`s past `Sheath`, after `RandomProperty`).
|
||||
/// A shield's block value.
|
||||
pub block: u32,
|
||||
/// `ItemSet` — id from `ItemSet.dbc`; 0 = not part of a set.
|
||||
/// `ItemSet.dbc` id; 0 for none.
|
||||
pub item_set: u32,
|
||||
/// `MaxDurability` — 0 for items without durability (never repairable).
|
||||
/// 0 for items without durability.
|
||||
pub max_durability: u32,
|
||||
/// `Area` — id from `AreaTable.dbc`; a zone-bound item's required zone (0 = anywhere).
|
||||
/// `AreaTable.dbc` zone the item is bound to; 0 for anywhere.
|
||||
pub area: u32,
|
||||
/// `Map` — id from `Map.dbc`; a map-bound item's required map (0 = anywhere).
|
||||
/// `Map.dbc` map the item is bound to; 0 for anywhere.
|
||||
pub map: u32,
|
||||
/// `BagFamily` — which specialised container an item belongs in (quiver 1, ammo pouch 2, soul
|
||||
/// bag 3, herb 6, enchanting 7, engineering 8, **keys 9**; 0 = an ordinary item / ordinary bag).
|
||||
/// An **enum, not a bitmask**, on this wire: 1.12 tests it for equality (vmangos
|
||||
/// `ItemPrototype.h`'s `enum BagFamily`, and the reference's own `HasKey` `0x48ae90` compares
|
||||
/// `template+0x1d0 == 9`) — it only became a mask in 2.x. `9` is what routes an item into the
|
||||
/// keyring, and what [`crate::ObjectFields::player_keyring_slot`]'s slots hold.
|
||||
/// The specialised container an item belongs in (1 quiver, 2 ammo pouch, 3 soul bag, 6 herb,
|
||||
/// 7 enchanting, 8 engineering, 9 keyring; 0 none). An enum, not a mask, in 1.12: the
|
||||
/// reference's `HasKey` (`0x48ae90`) tests `== 9`.
|
||||
pub bag_family: u32,
|
||||
}
|
||||
|
||||
impl ItemInfo {
|
||||
/// Does this item carry **finite charges**? The reference's
|
||||
/// `template+0x144 != 0 && template+0x144 != -1` (wow-re `action-item-slot.md` §8.2) — the
|
||||
/// gate on the use path's mode-`0x20` inventory search, which skips spent copies so a click
|
||||
/// reaches one that still works. `-1` is the "unlimited" sentinel, `0` "no charges at all".
|
||||
/// The reference's `template+0x144 != 0 && != -1` (`-1` unlimited, `0` none), which gates the
|
||||
/// use path's mode-`0x20` search for a copy with charges left.
|
||||
pub fn has_finite_charges(&self) -> bool {
|
||||
self.spell_charges_0 != 0 && self.spell_charges_0 != -1
|
||||
}
|
||||
|
||||
/// The **block ordinal** (0..4) of the first ON_USE spell — the third byte of `CMSG_USE_ITEM`
|
||||
/// (wow-re `action-item-slot.md` §8.3). `None` for an item with no on-use spell. Almost
|
||||
/// always 0, but an item whose block 0 is an ON_EQUIP proc and whose on-use sits in block 1
|
||||
/// needs the real index or the server casts the wrong block.
|
||||
/// The first ON_USE spell's block ordinal, the third byte of `CMSG_USE_ITEM`; usually 0, but
|
||||
/// an item with an ON_EQUIP block 0 has its on-use in a later block.
|
||||
pub fn use_spell_index(&self) -> Option<u8> {
|
||||
self.spells.iter().find(|s| s.trigger == 0).map(|s| s.index)
|
||||
}
|
||||
|
||||
/// Is this item **consumable** in the sense the action bar's Count fontstring means?
|
||||
/// `IsConsumableAction 0x4e5250`, byte-read (`4e52b9`–`4e52ea`), after it has resolved the
|
||||
/// slot's item template:
|
||||
///
|
||||
/// ```text
|
||||
/// [rec+0x2c] InventoryType == 0x18 (AMMO) or 0x19 (THROWN) -> true (4e52b9-4e52c4)
|
||||
/// OR ∃ i∈[0,5): SpellId[i] [rec+0x11c+4i] != 0 (4e52d0)
|
||||
/// ∧ SpellTrigger[i] [rec+0x130+4i] == 0 (ON_USE) (4e52d7)
|
||||
/// ∧ SpellCharges[i] [rec+0x144+4i] < 0 (destroy-on-use) (4e52dc)
|
||||
/// otherwise -> false
|
||||
/// ```
|
||||
///
|
||||
/// **Negative** charges — not merely finite ones — are the test: the sign is vmangos's own
|
||||
/// "the ITEM is consumed once the charges run out" convention, so a potion (`-1`) counts and a
|
||||
/// wand-like item with positive charges does not. Nothing here looks at `Class`: a mount
|
||||
/// (`Class` 15 Miscellaneous, `InventoryType` 0, on-use spell with `SpellCharges` 0) is **not**
|
||||
/// consumable, which is why the reference shows no stack number under a mount on the bar.
|
||||
///
|
||||
/// [`Self::spells`] already drops the `SpellId == 0` blocks, so iterating it is the `4e52d5`
|
||||
/// skip.
|
||||
/// Consumable as the action bar's count means it (reference `IsConsumableAction`,
|
||||
/// `0x4e5250`): ammo or thrown, or an ON_USE spell with negative charges, which use the item
|
||||
/// up. Neither positive charges nor `Class` count: a mount, with charges 0, shows no count.
|
||||
pub fn is_consumable(&self) -> bool {
|
||||
const INVTYPE_AMMO: u32 = 0x18;
|
||||
const INVTYPE_THROWN: u32 = 0x19;
|
||||
@@ -211,93 +139,53 @@ impl ItemInfo {
|
||||
|| self.spells.iter().any(|s| s.trigger == 0 && s.charges < 0)
|
||||
}
|
||||
|
||||
/// Can this item be placed on an action-bar slot? `PlaceAction`'s only item filter, byte-read
|
||||
/// (wow-re `action-item-slot.md` §5, `4e6571`–`4e6598`): **an on-use spell OR equippable**.
|
||||
/// No quality, bind, class/subclass, container or level test exists anywhere on that path — a
|
||||
/// bag (`InventoryType` 18) IS placeable; a grey trade good with neither is silently refused.
|
||||
/// The reference `PlaceAction`'s only item filter (`0x4e6571`): an on-use spell or anything
|
||||
/// equippable, bags included; anything else is refused silently.
|
||||
pub fn placeable_on_action_bar(&self) -> bool {
|
||||
self.use_spell.is_some() || self.inventory_type != 0
|
||||
}
|
||||
|
||||
/// Does the tooltip put the green `<Right Click to Open>` line on this instance?
|
||||
/// `instance_flags` is the item object's `ITEM_FIELD_FLAGS`; a template-only view (a
|
||||
/// hyperlink, a merchant row) passes 0 and, being object-less, gets no line at all — the
|
||||
/// reference's own gate 1 at `0x52e2e0`.
|
||||
///
|
||||
/// VERIFIED wow-re `right-click-open.md` §1.4 (`0x52e2f8`–`0x52e321`), re-derived by a §5 pair
|
||||
/// 2026-08-02: the template LOOTABLE bit [`ITEM_FLAG_LOOTABLE`] behind its **lock sub-gate** —
|
||||
/// a [`ItemInfo::lock_id`] item earns the line only once the instance carries
|
||||
/// [`ITEM_DYNFLAG_UNLOCKED`] — **or** a wrapped gift ([`ITEM_FLAG_WRAPPER`] on the template
|
||||
/// plus [`ITEM_DYNFLAG_WRAPPED`] on the instance).
|
||||
///
|
||||
/// **This is deliberately NOT the send condition** ([`Self::opens_loot`]). The line is a
|
||||
/// promise, and the reference only makes it when opening will actually work; the *click*
|
||||
/// tests the bare template bit and lets the server refuse a still-locked box with its own
|
||||
/// error line. Decision 0896 — an earlier reading of ours collapsed the two, which would have
|
||||
/// silently swallowed the click on every locked junkbox.
|
||||
/// Whether the tooltip shows the green `<Right Click to Open>` line (`0x52e2f8`), given the
|
||||
/// instance's `ITEM_FIELD_FLAGS`: lootable and, if locked, unlocked; or a wrapped gift. An
|
||||
/// object-less view (a hyperlink, a merchant row) gets no line at all (`0x52e2e0`). Stricter
|
||||
/// than [`Self::opens_loot`] on purpose: the click still sends for a locked box.
|
||||
pub fn shows_open_line(&self, instance_flags: u32) -> bool {
|
||||
let lootable = self.flags & ITEM_FLAG_LOOTABLE != 0
|
||||
&& (self.lock_id == 0 || instance_flags & ITEM_DYNFLAG_UNLOCKED != 0);
|
||||
lootable || self.unwraps_gift(instance_flags)
|
||||
}
|
||||
|
||||
/// Does a right-click on this instance send `CMSG_OPEN_ITEM` to **unwrap a gift**? The first
|
||||
/// arm of the reference's use dispatcher (`0x5d8d00` #2, `0x5d8d92`/`0x5d8d9d` → the `0x5edd60`
|
||||
/// emitter): template [`ITEM_FLAG_WRAPPER`] **and** instance [`ITEM_DYNFLAG_WRAPPED`].
|
||||
///
|
||||
/// It is a separate predicate from [`Self::opens_loot`] because it sits at a different point
|
||||
/// in the dispatcher's order — *before* the quest-starter and readable arms, where the loot
|
||||
/// arm sits after them. A wrapper template whose instance is *not* wrapped takes the
|
||||
/// begin-wrap cursor path instead ([`Self::begins_gift_wrap`]) — local, no packet.
|
||||
/// Whether a right-click sends `CMSG_OPEN_ITEM` to unwrap a gift (`0x5d8d92`). The reference
|
||||
/// tests it before the quest-starter and readable arms; the loot arm comes after them.
|
||||
pub fn unwraps_gift(&self, instance_flags: u32) -> bool {
|
||||
self.flags & ITEM_FLAG_WRAPPER != 0 && instance_flags & ITEM_DYNFLAG_WRAPPED != 0
|
||||
}
|
||||
|
||||
/// Does a right-click on this instance **arm the gift-wrap cursor**? The *other* side of the
|
||||
/// same fork [`Self::unwraps_gift`] tests (`0x5d8d00` arm 2): template [`ITEM_FLAG_WRAPPER`]
|
||||
/// with the instance's [`ITEM_DYNFLAG_WRAPPED`] **clear** — a piece of wrapping paper rather
|
||||
/// than a wrapped present.
|
||||
///
|
||||
/// **Nothing is sent on this click.** `0x5d8dba` calls `0x5edea0`, whose only three acts are
|
||||
/// `LockItem 0x4953e0` on the paper, `SetCursorBaseMode(2)` and `CursorSetMode(2)` — a purely
|
||||
/// local arm (wow-re `object-layer/scratch/gift-wrap-law.md`, §5-carved). What completes it is
|
||||
/// the next **left**-click on a container slot, which sends [`wrap_item`]. Decision 1934.
|
||||
/// Whether a right-click arms the gift-wrap cursor: wrapping paper, not a wrapped present.
|
||||
/// Nothing is sent (`0x5edea0` locks the paper and sets cursor mode 2); the next left-click on
|
||||
/// a container slot sends [`wrap_item`].
|
||||
pub fn begins_gift_wrap(&self, instance_flags: u32) -> bool {
|
||||
self.flags & ITEM_FLAG_WRAPPER != 0 && instance_flags & ITEM_DYNFLAG_WRAPPED == 0
|
||||
}
|
||||
|
||||
/// Does a right-click on this template send `CMSG_OPEN_ITEM` to **loot it open**? The
|
||||
/// reference's arm #8 (`0x5d8f7c: test al,4` → the `0x5edc80` emitter): a **bare** template
|
||||
/// [`ITEM_FLAG_LOOTABLE`] test.
|
||||
///
|
||||
/// VERIFIED wow-re `right-click-open.md` §3, positively *and* negatively — no `LockID`
|
||||
/// (`[rec+0x1ac]`) operand exists anywhere on the send path, checked against a positive
|
||||
/// control. So a **still-locked junkbox does send the packet**, and the server answers
|
||||
/// `EQUIP_ERR_ITEM_LOCKED`, which is where the player's "Item is locked" line comes from.
|
||||
/// Refusing locally instead would eat the click in silence.
|
||||
/// Whether a right-click sends `CMSG_OPEN_ITEM` to loot the item (`0x5d8f7c`): the bare
|
||||
/// template bit with no lock test, so a locked box still sends and the server answers
|
||||
/// `EQUIP_ERR_ITEM_LOCKED`.
|
||||
pub fn opens_loot(&self) -> bool {
|
||||
self.flags & ITEM_FLAG_LOOTABLE != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// `ITEM_FLAG_LOOTABLE` — the **template** bit that makes an item right-clickable into a loot
|
||||
/// window (a clam, a lockbox, a Gnomish Mind Control Cap's box). vmangos
|
||||
/// `ItemPrototype.h:66`, whose own comment names the client behaviour this drives: "It or lockid
|
||||
/// set enable for client show 'Right click to open'".
|
||||
/// Template bit: right-click opens a loot window, as on a clam or lockbox (`ItemPrototype.h:66`).
|
||||
pub const ITEM_FLAG_LOOTABLE: u32 = 0x0000_0004;
|
||||
/// `ITEM_FLAG_WRAPPER` — the template bit for gift wrapping (vmangos `ItemPrototype.h:73`); paired
|
||||
/// with [`ITEM_DYNFLAG_WRAPPED`] on the instance it makes the wrapped present openable.
|
||||
/// Template bit for gift wrapping (`ItemPrototype.h:73`).
|
||||
pub const ITEM_FLAG_WRAPPER: u32 = 0x0000_0200;
|
||||
/// `ITEM_DYNFLAG_UNLOCKED` — the **instance** (`ITEM_FIELD_FLAGS`) bit a lockbox gains once it has
|
||||
/// been picked/keyed open; until then a `LockID` item refuses to open (vmangos
|
||||
/// `HandleOpenItemOpcode`'s `EQUIP_ERR_ITEM_LOCKED`).
|
||||
/// Instance (`ITEM_FIELD_FLAGS`) bit a lockbox gains once picked or keyed open.
|
||||
pub const ITEM_DYNFLAG_UNLOCKED: u32 = 0x0000_0004;
|
||||
/// `ITEM_DYNFLAG_WRAPPED` — the instance bit on a gift-wrapped item; opening it unwraps to the
|
||||
/// present (vmangos swaps the entry from `character_gifts` rather than sending loot).
|
||||
/// Instance bit on a wrapped gift; opening it swaps the entry back from `character_gifts`.
|
||||
pub const ITEM_DYNFLAG_WRAPPED: u32 = 0x0000_0008;
|
||||
|
||||
/// One `Damage` block ([`ItemInfo::damages`] — block 0 is also mirrored into
|
||||
/// [`ItemInfo::dmg_min`]/[`ItemInfo::dmg_max`]/[`ItemInfo::dmg_type`]).
|
||||
/// One `Damage` block of [`ItemInfo::damages`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ItemDamage {
|
||||
pub min: f32,
|
||||
@@ -306,18 +194,11 @@ pub struct ItemDamage {
|
||||
pub school: u32,
|
||||
}
|
||||
|
||||
/// One item-template spell block ([`ItemInfo::spells`]) — the full 6-word wire shape, not just the
|
||||
/// resolved ON_USE cooldown pair ([`ItemUseSpell`]). `charges`: positive = consumed only while
|
||||
/// charges last, negative = the item itself is consumed once charges run out (vmangos
|
||||
/// `ItemPrototype::_ItemSpell::SpellCharges`). The cooldown pair is **server-resolved** the same way
|
||||
/// as [`ItemUseSpell`]'s (VERIFIED vmangos `ItemHandler.cpp:354-391`).
|
||||
/// One template spell block of [`ItemInfo::spells`]. Negative `charges` use the item up when they
|
||||
/// run out, positive ones leave it (`ItemPrototype::_ItemSpell::SpellCharges`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ItemSpellEntry {
|
||||
/// Which of the template's **five** spell blocks this is (0..4). Not the index in
|
||||
/// [`ItemInfo::spells`] — that vector drops empty blocks, and the wire's own
|
||||
/// `CMSG_USE_ITEM` spell byte is this block ordinal (wow-re `action-item-slot.md` §8.3: the
|
||||
/// reference scans `SpellId[5]`/`SpellTrigger[5]` for the block it is casting and sends its
|
||||
/// position).
|
||||
/// The template block ordinal (0..4), which `CMSG_USE_ITEM`'s spell byte carries.
|
||||
pub index: u8,
|
||||
pub spell_id: u32,
|
||||
/// `ItemSpelltriggerType`: 0 ON_USE, 1 ON_EQUIP, 2 CHANCE_ON_HIT.
|
||||
@@ -331,13 +212,9 @@ pub struct ItemSpellEntry {
|
||||
pub category_cooldown_ms: i32,
|
||||
}
|
||||
|
||||
/// The first ON_USE spell block ([`ItemInfo::use_spell`]) — a resolved-cooldown view of whichever
|
||||
/// [`ItemSpellEntry`] has `trigger == 0`. The cooldown pair is **server-resolved** (VERIFIED vmangos
|
||||
/// `ItemHandler.cpp:354-380`: the `item_template` override when its value is `>= 0`, else the
|
||||
/// spell's own `RecoveryTime`/`Category`/`CategoryRecoveryTime`) — but a lone `-1` can still ride
|
||||
/// next to a set override, so the fields stay signed and a negative means "use the spell's own
|
||||
/// Spell.dbc value" (the client's `>= 0` pick in `StartCooldown 0x6e2c60`, wow-re
|
||||
/// `wave-cooldown.md`).
|
||||
/// An item's first ON_USE spell block. The server substitutes the spell's own cooldowns for unset
|
||||
/// ones (`ItemHandler.cpp:354-380`), but a `-1` can still arrive; negative means the spell's
|
||||
/// `Spell.dbc` value, as the reference's `StartCooldown` (`0x6e2c60`) reads it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ItemUseSpell {
|
||||
pub spell_id: u32,
|
||||
@@ -349,8 +226,7 @@ pub struct ItemUseSpell {
|
||||
pub category_cooldown_ms: i32,
|
||||
}
|
||||
|
||||
/// Read `SMSG_ITEM_QUERY_SINGLE_RESPONSE` → `(entry, Some(head))`, or `(entry, None)` on a miss
|
||||
/// (VERIFIED field order vmangos `HandleItemQuerySingleOpcode`, `ItemHandler.cpp:269-415`).
|
||||
/// Read `SMSG_ITEM_QUERY_SINGLE_RESPONSE` in the field order of `ItemHandler.cpp:269-415`.
|
||||
pub(super) fn read_item_query_response(r: &mut &[u8]) -> io::Result<(u32, Option<ItemInfo>)> {
|
||||
let entry = read_u32_le(r)?;
|
||||
if entry & 0x8000_0000 != 0 {
|
||||
@@ -360,7 +236,7 @@ pub(super) fn read_item_query_response(r: &mut &[u8]) -> io::Result<(u32, Option
|
||||
let subclass = read_u32_le(r)?;
|
||||
let name = read_cstring(r)?;
|
||||
for _ in 0..3 {
|
||||
let _ = read_cstring(r)?; // name2..name4 — the server sends empties
|
||||
let _ = read_cstring(r)?; // name2..name4, always sent empty
|
||||
}
|
||||
let display_info_id = read_u32_le(r)?;
|
||||
let quality = read_u32_le(r)?;
|
||||
@@ -383,8 +259,7 @@ pub(super) fn read_item_query_response(r: &mut &[u8]) -> io::Result<(u32, Option
|
||||
let stackable = read_u32_le(r)?;
|
||||
let container_slots = read_u32_le(r)?;
|
||||
|
||||
// 10x ItemStat { type, value } — kept only where either half is nonzero (an all-zero slot is a
|
||||
// genuinely unused one), wire order preserved.
|
||||
// 10 ItemStat { type, value }; an all-zero slot is unused.
|
||||
let mut stats = Vec::new();
|
||||
for _ in 0..10 {
|
||||
let stat_type = read_u32_le(r)?;
|
||||
@@ -394,10 +269,7 @@ pub(super) fn read_item_query_response(r: &mut &[u8]) -> io::Result<(u32, Option
|
||||
}
|
||||
}
|
||||
|
||||
// 5x Damage { min f32, max f32, type u32 }, wire order. Block 0 is the tooltip's primary
|
||||
// damage line — always mirrored into the legacy dmg_min/dmg_max/dmg_type fields for existing
|
||||
// consumers, whether or not it clears the `max > 0` filter below (a non-weapon's block 0 is a
|
||||
// real 0/0/0, not a missing value).
|
||||
// 5 Damage { min f32, max f32, type u32 }; block 0 also fills dmg_* unfiltered.
|
||||
let dmg_min = read_f32_le(r)?;
|
||||
let dmg_max = read_f32_le(r)?;
|
||||
let dmg_type = read_u32_le(r)?;
|
||||
@@ -418,8 +290,6 @@ pub(super) fn read_item_query_response(r: &mut &[u8]) -> io::Result<(u32, Option
|
||||
}
|
||||
}
|
||||
|
||||
// Armor is its own field; the remaining 6-wide resistance run (Holy/Fire/Nature/Frost/
|
||||
// Shadow/Arcane) lands in `resistances` in wire order.
|
||||
let armor = read_u32_le(r)?;
|
||||
let holy_res = read_i32_le(r)?;
|
||||
let fire_res = read_i32_le(r)?;
|
||||
@@ -435,17 +305,10 @@ pub(super) fn read_item_query_response(r: &mut &[u8]) -> io::Result<(u32, Option
|
||||
let ammo_type = read_u32_le(r)?;
|
||||
let ranged_mod_range = read_f32_le(r)?;
|
||||
|
||||
// 5x Spell block { SpellId, SpellTrigger, SpellCharges, Cooldown, Category, CategoryCooldown }
|
||||
// (VERIFIED vmangos `ItemHandler.cpp:354-391`) — the server always writes all six words; a slot
|
||||
// with no resolvable spell sends the sentinel 0,0,0,-1,0,-1. Kept in `spells` wherever
|
||||
// `spell_id != 0`; the first ON_USE (trigger 0) slot also surfaces as `use_spell` — the
|
||||
// client's own 5-slot scan.
|
||||
// 5 spell blocks of six words (`ItemHandler.cpp:354-391`); an empty block is 0,0,0,-1,0,-1.
|
||||
let mut spells = Vec::new();
|
||||
let mut use_spell = None;
|
||||
// Block 0's charges, kept RAW — whether the item has finite charges is the reference's
|
||||
// `template+0x144 != 0 && != -1` test on exactly this word ([`ItemInfo::has_finite_charges`],
|
||||
// wow-re `action-item-slot.md` §8.2), which reads block 0 even when block 0 carries no spell
|
||||
// and so never reaches `spells` below.
|
||||
// Kept even when block 0 has no spell: the reference's charge test reads it regardless.
|
||||
let mut spell_charges_0 = 0;
|
||||
for block in 0..5u8 {
|
||||
let spell_id = read_u32_le(r)?;
|
||||
@@ -554,9 +417,7 @@ pub(super) fn read_item_query_response(r: &mut &[u8]) -> io::Result<(u32, Option
|
||||
))
|
||||
}
|
||||
|
||||
/// Body of `CMSG_ITEM_QUERY_SINGLE` (vmangos `QueryItem::ReadFromWorldPacket`): the template
|
||||
/// `entry` + a full 8-byte item guid (0 when asking about a template with no instance in hand) —
|
||||
/// the exact shape of the creature query.
|
||||
/// Body of `CMSG_ITEM_QUERY_SINGLE`: entry, then a full item guid (0 with no instance in hand).
|
||||
pub fn item_query(entry: u32, guid: u64) -> Vec<u8> {
|
||||
let mut body = Vec::with_capacity(12);
|
||||
body.extend_from_slice(&entry.to_le_bytes());
|
||||
@@ -564,82 +425,46 @@ pub fn item_query(entry: u32, guid: u64) -> Vec<u8> {
|
||||
body
|
||||
}
|
||||
|
||||
/// The wire's "the player's own inventory" bag index (`INVENTORY_SLOT_BAG_0`): with it, `slot`
|
||||
/// addresses the player descriptor's item array directly — equipment 0–18, bag slots 19–22, the
|
||||
/// backpack 23–38 (VERIFIED vmangos `Player.h` slot enums; the same 23-slot base the descriptor's
|
||||
/// `PACK_SLOT_1` offset encodes).
|
||||
/// The player's own bag index (`INVENTORY_SLOT_BAG_0`): `slot` then indexes the player's item
|
||||
/// array, equipment 0-18, bags 19-22, backpack 23-38 (vmangos `Player.h`).
|
||||
pub const BAG_PLAYER_INVENTORY: u8 = 255;
|
||||
/// The backpack's first player-array slot (`INVENTORY_SLOT_ITEM_START`).
|
||||
pub const SLOT_PACK_FIRST: u8 = 23;
|
||||
/// The first equipped-bag player-array slot (`INVENTORY_SLOT_BAG_START`; bags occupy 19–22).
|
||||
/// The first equipped-bag player-array slot (`INVENTORY_SLOT_BAG_START`; bags occupy 19-22).
|
||||
pub const SLOT_BAG_FIRST: u8 = 19;
|
||||
|
||||
/// `TARGET_FLAG_GAMEOBJECT` — the cast-target bit that carries a GO guid (vmangos
|
||||
/// `SpellDefines.h`; its `SpellCastTargets::read` is the only bit here that consumes bytes).
|
||||
/// `SpellCastTargets` mask bits, vmangos `SpellDefines.h`.
|
||||
const TARGET_FLAG_GAMEOBJECT: u16 = 0x0800;
|
||||
|
||||
/// `TARGET_FLAG_UNIT` — a bound unit target: the same bit, and the same packed guid, a
|
||||
/// `CMSG_CAST_SPELL` writes. In the real client ONE block builder serves both opcodes, so one bit
|
||||
/// table serves both here.
|
||||
const TARGET_FLAG_UNIT: u16 = 0x0002;
|
||||
/// `TARGET_FLAG_DEST_LOCATION` — a ground point, the same bit and the same three `f32` WoW coords
|
||||
/// a ground-targeted `CMSG_CAST_SPELL` writes ([`super::spells::cast_spell_at_dest`]).
|
||||
const TARGET_FLAG_DEST_LOCATION: u16 = 0x0040;
|
||||
/// `TARGET_FLAG_SOURCE_LOCATION` — the dest bit's twin one place down, and the same three `f32`
|
||||
/// ([`super::spells::cast_spell_at_source`]). Both come out of the one binder `BindLocation
|
||||
/// 0x6e60f0` (decision 2218).
|
||||
const TARGET_FLAG_SOURCE_LOCATION: u16 = 0x0020;
|
||||
/// `TARGET_FLAG_ITEM` — a bound *item* target, the same bit and the same packed guid an
|
||||
/// item-targeted `CMSG_CAST_SPELL` writes (`super::spells::cast_spell_on_item`). One block
|
||||
/// builder, two opcodes (decision 0923).
|
||||
const TARGET_FLAG_ITEM: u16 = 0x0010;
|
||||
|
||||
/// The `SpellCastTargets` block a `CMSG_USE_ITEM` carries — built by the *same* code as a
|
||||
/// `CMSG_CAST_SPELL`'s. `SendCast 0x6e54f0` picks the opcode from its item-vs-caster discriminator
|
||||
/// (`0x6e57d8 push 0xab` when the pending-cast block's guid is the item's, `push 0x12e` when it is
|
||||
/// the caster's) and then writes the one targets block `ArmCast 0x6e5250` bound — item or not
|
||||
/// (wow-re `action-item-slot.md` §8: "body `{u8 bagIndex, u8 slot, u8 spell_index}` + the
|
||||
/// cast-targets block"; `cursor-system.md` §8.4a for the split).
|
||||
/// The `SpellCastTargets` block of a `CMSG_USE_ITEM`, the same block a `CMSG_CAST_SPELL` carries:
|
||||
/// the reference's `SendCast` (`0x6e54f0`) only picks the opcode (`0x6e57d8`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub enum UseItemTarget {
|
||||
/// Mask 0 (`TARGET_FLAG_SELF`) — the implicit self-cast an ordinary consumable sends, whose
|
||||
/// target the server resolves itself. What 81% of the 1.12 on-use items bind to.
|
||||
/// Mask 0 (`TARGET_FLAG_SELF`): an ordinary consumable; the server resolves the target.
|
||||
#[default]
|
||||
SelfImplicit,
|
||||
/// `TARGET_FLAG_UNIT` + the packed guid — an item whose spell binds a unit exactly as a
|
||||
/// spell's does: a bandage, a soulstone, an offensive trinket.
|
||||
/// `TARGET_FLAG_UNIT` and a packed guid: a bandage, a soulstone, an offensive trinket.
|
||||
Unit(u64),
|
||||
/// `TARGET_FLAG_GAMEOBJECT` + the packed guid — the **key-in-a-lock** case (decision 0769):
|
||||
/// opening a locked door or chest with a key is not a spell cast, it is *using the key at the
|
||||
/// object*. It matters that this is USE_ITEM and not a bare cast: `Spell::CanOpenLock` honours
|
||||
/// a `Lock.dbc` KEY slot **only** when `m_CastItem` is set (`Spell.cpp:7892`), which only this
|
||||
/// packet supplies. The mask is `0x0800` alone — `TARGET_FLAG_LOCKED` is a *targeting-word*
|
||||
/// bit that `BindTarget 0x6e5b40` consumes and never writes to the wire (decision 0939,
|
||||
/// correcting 0769; see [`super::spells::cast_spell_gameobject`] for the census).
|
||||
/// `TARGET_FLAG_GAMEOBJECT` and a packed guid: a key used on a lock, which the server honours
|
||||
/// only from an item cast (`Spell.cpp:7892`). The mask is `0x0800` alone; the reference's
|
||||
/// `BindTarget` (`0x6e5b40`) never writes `TARGET_FLAG_LOCKED`.
|
||||
Object(u64),
|
||||
/// `TARGET_FLAG_DEST_LOCATION` + three `f32` WoW coords — the targeting-cursor commit for a
|
||||
/// **thrown** item: dynamite, grenades, bombs, the Goblin Mortar (46 of the 1.12 on-use item
|
||||
/// spells, decision 0914). Same block a ground-targeted spell writes; only the opcode differs.
|
||||
/// `TARGET_FLAG_DEST_LOCATION` and three `f32` coords: a thrown item, dynamite or a grenade.
|
||||
Dest([f32; 3]),
|
||||
/// `TARGET_FLAG_ITEM` + the packed guid — the targeting cursor's **item** commit: a poison, a
|
||||
/// sharpening stone, a weapon oil, an enchanting scroll applied to the item you clicked in a
|
||||
/// bag or on the paper doll (decision 0923). The reference reaches it through the very same
|
||||
/// `BindTarget 0x6e5b40` a unit goes through (`0x495d60` @ `496056`), so the block is the same
|
||||
/// block; only which bit is set differs.
|
||||
/// `TARGET_FLAG_ITEM` and a packed guid: a poison, oil or stone applied to a clicked item.
|
||||
Item(u64),
|
||||
/// `TARGET_FLAG_SOURCE_LOCATION` + three `f32` WoW coords — [`Self::Dest`]'s twin, one bit
|
||||
/// over: `BindLocation 0x6e60f0`'s bit-5 arm writes the clicked point to `SPELLCAST+0x30` and
|
||||
/// ORs `0x0020` into the wire mask, where its bit-6 arm writes `+0x3c` and ORs `0x0040`. Three
|
||||
/// shipped items reach it — Martin Fury (17), 192 and 5417, all carrying spell 265 "Area Death
|
||||
/// (TEST)" (decision 2218). vmangos reads this triple **before** the dest one.
|
||||
/// `TARGET_FLAG_SOURCE_LOCATION` and three `f32` coords (`BindLocation`, `0x6e60f0`); only
|
||||
/// items carrying spell 265, such as Martin Fury, use it. vmangos reads it before the dest.
|
||||
Source([f32; 3]),
|
||||
}
|
||||
|
||||
/// Body of `CMSG_USE_ITEM` (VERIFIED vmangos `UseItem::ReadFromWorldPacket` + opcode 171
|
||||
/// `Opcodes_1_12_1.h`): `bagIndex` (a bag's player-array slot 19–22, or [`BAG_PLAYER_INVENTORY`]
|
||||
/// with an absolute `slot`), `slot` (0-based within the bag), `spellSlot` (which of the template's
|
||||
/// 5 spell effects — 0, the "use" effect, for a plain use), then a `SpellCastTargets` block
|
||||
/// ([`UseItemTarget`]).
|
||||
/// Body of `CMSG_USE_ITEM` (opcode 171): bag index (a bag's player-array slot 19-22, or
|
||||
/// [`BAG_PLAYER_INVENTORY`]), slot within it, the spell block ordinal, then the targets block.
|
||||
pub fn use_item(bag_index: u8, slot: u8, spell_slot: u8, target: UseItemTarget) -> Vec<u8> {
|
||||
let mut body = Vec::with_capacity(5);
|
||||
body.push(bag_index);
|
||||
@@ -681,112 +506,66 @@ pub fn use_item(bag_index: u8, slot: u8, spell_slot: u8, target: UseItemTarget)
|
||||
body
|
||||
}
|
||||
|
||||
/// Body of `CMSG_OPEN_ITEM` (VERIFIED vmangos `OpenItem::ReadFromWorldPacket`,
|
||||
/// `Server/Packets/Spell.cpp` + `.h:36-45`; opcode 172 `Opcodes_1_12_1.h:175`): `bagIndex`, `slot`
|
||||
/// — the same two bytes and the same bag addressing as [`use_item`], and nothing else (no spell
|
||||
/// ordinal, no targets block: opening is not a cast).
|
||||
///
|
||||
/// The right-click fork for an [`ItemInfo::openable`] item. The server answers on the **item's own
|
||||
/// guid**: `SendLoot(item, LOOT_CORPSE)` for a lootable, or — for a wrapped gift — an entry swap
|
||||
/// and no window at all.
|
||||
/// Body of `CMSG_OPEN_ITEM` (opcode 172, `Server/Packets/Spell.h:36-45`): bag index and slot, as
|
||||
/// in [`use_item`]. The server loots the item on its own guid, or unwraps a gift in place.
|
||||
pub fn open_item(bag_index: u8, slot: u8) -> Vec<u8> {
|
||||
vec![bag_index, slot]
|
||||
}
|
||||
|
||||
/// Body of `CMSG_WRAP_ITEM` (VERIFIED vmangos `WrapItem::ReadFromWorldPacket`,
|
||||
/// `Server/Packets/Item.cpp:121-127` + `.h:191-201`; opcode 467 `Opcodes_1_12_1.h:468`):
|
||||
/// `giftBag`, `giftSlot`, `itemBag`, `itemSlot` — all `uint8`, the **paper's** position first and
|
||||
/// the target's second, the same bag addressing as [`use_item`].
|
||||
///
|
||||
/// The server refuses every ineligible target with an `EQUIP_ERR_*` of its own
|
||||
/// (`HandleWrapItemOpcode`: equipped, a bag, soulbound, stackable, unique, already wrapped, or
|
||||
/// mid-cast) — so a refusal reaches the player as an `SMSG_INVENTORY_CHANGE_FAILURE` line, not as
|
||||
/// a silently eaten click. On success the *target* item keeps its guid and takes the paper's
|
||||
/// `WrappedGift` entry, gains `ITEM_FIELD_GIFTCREATOR` and `ITEM_DYNFLAG_WRAPPED`, and one paper
|
||||
/// is destroyed — all as ordinary field updates, no answering packet of its own.
|
||||
/// Body of `CMSG_WRAP_ITEM` (opcode 467, `Server/Packets/Item.cpp:121-127`): the paper's bag and
|
||||
/// slot, then the target's. The server refuses with `SMSG_INVENTORY_CHANGE_FAILURE`; success is
|
||||
/// only field updates, the target taking the paper's gift entry and `ITEM_DYNFLAG_WRAPPED`.
|
||||
pub fn wrap_item(gift_bag: u8, gift_slot: u8, item_bag: u8, item_slot: u8) -> Vec<u8> {
|
||||
vec![gift_bag, gift_slot, item_bag, item_slot]
|
||||
}
|
||||
|
||||
/// Body of `CMSG_AUTOEQUIP_ITEM` (VERIFIED vmangos `AutoEquipItem::ReadFromWorldPacket`,
|
||||
/// `Server/Packets/Item.cpp:17-21` + `.h:31-39`; opcode 266 `Opcodes_1_12_1.h:269`): source
|
||||
/// `srcbag`/`srcslot` (both `uint8`), the same bag addressing as [`use_item`]. The real client
|
||||
/// sends this — not USE_ITEM — when the clicked bag item is *equippable* (the equip-vs-use fork is
|
||||
/// client-side); the server picks the destination slot itself. Refusals answer
|
||||
/// `SMSG_INVENTORY_CHANGE_FAILURE`.
|
||||
/// Body of `CMSG_AUTOEQUIP_ITEM` (opcode 266, `Server/Packets/Item.cpp:17-21`): source bag and
|
||||
/// slot. The reference sends it instead of `CMSG_USE_ITEM` for an equippable item; the server
|
||||
/// picks the slot.
|
||||
pub fn auto_equip_item(bag_index: u8, slot: u8) -> Vec<u8> {
|
||||
vec![bag_index, slot]
|
||||
}
|
||||
|
||||
/// Body of `CMSG_AUTOSTORE_BAG_ITEM` (VERIFIED vmangos `AutoStoreBagItem::ReadFromWorldPacket`,
|
||||
/// `Server/Packets/Item.cpp:23-28` + `.h:41-49`; opcode 267 `Opcodes_1_12_1.h:270`): `srcbag`,
|
||||
/// `srcslot`, `dstbag` — all `uint8`. "Auto-store this item into that bag, server picks the slot."
|
||||
/// Builder only tonight (backpack-internal moves take [`swap_inv_item`]); no UI path yet.
|
||||
/// Body of `CMSG_AUTOSTORE_BAG_ITEM` (opcode 267, `Server/Packets/Item.cpp:23-28`): source bag
|
||||
/// and slot, then the bag to store into; the server picks the slot.
|
||||
pub fn auto_store_bag_item(src_bag: u8, src_slot: u8, dst_bag: u8) -> Vec<u8> {
|
||||
vec![src_bag, src_slot, dst_bag]
|
||||
}
|
||||
|
||||
/// Body of `CMSG_SWAP_ITEM` (VERIFIED vmangos `SwapItem::ReadFromWorldPacket`,
|
||||
/// `Server/Packets/Item.cpp:30-36` + `.h:51-61`; opcode 268 `Opcodes_1_12_1.h:271`): `dstbag`,
|
||||
/// `dstslot`, `srcbag`, `srcslot` — all `uint8`, **destination FIRST**. The general bag↔bag move
|
||||
/// (either endpoint an equipped bag). Builder only tonight; the windowed backpack's internal moves
|
||||
/// go out as [`swap_inv_item`].
|
||||
/// Body of `CMSG_SWAP_ITEM` (opcode 268, `Server/Packets/Item.cpp:30-36`): destination bag and
|
||||
/// slot first, then the source; a move with an equipped bag at either end.
|
||||
pub fn swap_item(dst_bag: u8, dst_slot: u8, src_bag: u8, src_slot: u8) -> Vec<u8> {
|
||||
vec![dst_bag, dst_slot, src_bag, src_slot]
|
||||
}
|
||||
|
||||
/// Body of `CMSG_SWAP_INV_ITEM` (VERIFIED vmangos `SwapInvItem::ReadFromWorldPacket`,
|
||||
/// `Server/Packets/Item.cpp:38-42` + `.h:63-72`; opcode 269 `Opcodes_1_12_1.h:272`): `srcslot`,
|
||||
/// `dstslot` — two `uint8` player-array slots, both implicitly on the player itself
|
||||
/// (`INVENTORY_SLOT_BAG_0`). This is the wire for a backpack-internal pick/place/swap: both slots
|
||||
/// are `INVENTORY_SLOT_ITEM_START`+i (see [`SLOT_PACK_FIRST`]). An empty destination is still a
|
||||
/// swap on this wire — the server treats it as a move.
|
||||
/// Body of `CMSG_SWAP_INV_ITEM` (opcode 269, `Server/Packets/Item.cpp:38-42`): source then
|
||||
/// destination slot in the player's own array; an empty destination makes it a move.
|
||||
pub fn swap_inv_item(src_slot: u8, dst_slot: u8) -> Vec<u8> {
|
||||
vec![src_slot, dst_slot]
|
||||
}
|
||||
|
||||
/// Body of `CMSG_SPLIT_ITEM` (VERIFIED vmangos `SplitItem::ReadFromWorldPacket`,
|
||||
/// `Server/Packets/Item.cpp:44-51` + `.h:74-85`; opcode 270 `Opcodes_1_12_1.h:273`): `srcbag`,
|
||||
/// `srcslot`, `dstbag`, `dstslot`, `count` — all `uint8`. Builder only: the UI split dialog is out
|
||||
/// of scope, but the wire is pinned so a later stack-split slice has a byte-exact starting point.
|
||||
/// Body of `CMSG_SPLIT_ITEM` (opcode 270, `Server/Packets/Item.cpp:44-51`): source bag and slot,
|
||||
/// destination bag and slot, count.
|
||||
pub fn split_item(src_bag: u8, src_slot: u8, dst_bag: u8, dst_slot: u8, count: u8) -> Vec<u8> {
|
||||
vec![src_bag, src_slot, dst_bag, dst_slot, count]
|
||||
}
|
||||
|
||||
/// Body of `CMSG_DESTROYITEM` (VERIFIED vmangos `Packets/Item.cpp:59-68`; opcode 273
|
||||
/// `Opcodes_1_12_1.h`): `bag`, `slot`, `count` (0 = the whole stack — matches [`split_item`]'s
|
||||
/// count and the app's `container_destroys` triple), then THREE more `uint8`s the server reads
|
||||
/// off the wire and discards — the real client sends them, so the body stays 6 bytes rather than
|
||||
/// a shorter, non-matching one. Decision 0216 §3: the delete-confirm popup's `OnAccept`
|
||||
/// (`DeleteCursorItem`).
|
||||
/// Body of `CMSG_DESTROYITEM` (opcode 273, `Packets/Item.cpp:59-68`): bag, slot, count (0 for the
|
||||
/// whole stack), then three bytes the reference sends and the server discards.
|
||||
pub fn destroy_item(bag: u8, slot: u8, count: u8) -> Vec<u8> {
|
||||
vec![bag, slot, count, 0, 0, 0]
|
||||
}
|
||||
|
||||
/// Body of `CMSG_SET_AMMO` (VERIFIED wow-re `cursor-dragdrop-slots.md`: the client's auto-equip
|
||||
/// sender `0x5e1480` forks ammo-class → opcode `0x268`, body `{itemEntry}` (a single `u32`); the
|
||||
/// vmangos handler `HandleSetAmmoOpcode` reads the same lone `uint32` entry). Unlike every other
|
||||
/// item CMSG this is NOT a `(bag, slot)` address — ammo is loaded by item *entry*, and the stack
|
||||
/// stays put in the bag (`PLAYER_AMMO_ID` just references it). The server refuses a mismatch
|
||||
/// (`EQUIP_ERR_ONLY_AMMO_CAN_GO_HERE` &c.) via `SMSG_INVENTORY_CHANGE_FAILURE`. Decision 0526.
|
||||
/// Body of `CMSG_SET_AMMO` (opcode `0x268`): the ammo's item entry, not a bag and slot; the
|
||||
/// reference's auto-equip sender (`0x5e1480`) sends it for ammo, and the stack stays in its bag.
|
||||
pub fn set_ammo(entry: u32) -> Vec<u8> {
|
||||
entry.to_le_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// Read `SMSG_INVENTORY_CHANGE_FAILURE` (VERIFIED vmangos `InventoryChangeFailure::AppendBodyTo`):
|
||||
/// `u8 reason` (`InventoryResult`; 0 = OK, no tail), then — only when failed — a `u32` required
|
||||
/// level *iff* `reason == 1` (`CANT_EQUIP_LEVEL_I`), the two full item guids, and the bag slot.
|
||||
///
|
||||
/// The trailing `u8` is **the destination bag's absolute player slot**, not a subslot: vmangos
|
||||
/// declares it *"slot of target bag that has storing condition (can be InventorySlots or
|
||||
/// BankBagSlots)"* and fills it with `bagSlot = bag` at each `CanStoreItem` refusal
|
||||
/// (`Player.cpp:8899`ff), where `bag` is `INVENTORY_SLOT_BAG_0` (255, the player's own array) or
|
||||
/// an equipped bag's slot. The reference reads it the same way — its reason-16 helper
|
||||
/// `0x5ede00` bails on `slot == 0xFF` and otherwise indexes the player's slot array by it (wow-re
|
||||
/// `inventory-change-failure-display.md` §6). It is the `%s` source of *"Only Arrows can be
|
||||
/// placed in that."*; see `benilla::ui_items::feed`.
|
||||
///
|
||||
/// Returns `(reason, required_level, item_guid, bag_slot)`.
|
||||
/// Read `SMSG_INVENTORY_CHANGE_FAILURE` into `(reason, required_level, item_guid, bag_slot)`: a
|
||||
/// `u8` reason and, unless it is 0, a `u32` level for reason 1 only, two item guids and a bag
|
||||
/// slot. That slot is the target bag's player-array slot, 255 for the player's own
|
||||
/// (`Player.cpp:8899`); the reference names that bag in reason 16's message (`0x5ede00`).
|
||||
pub(super) fn read_inventory_change_failure(
|
||||
r: &mut &[u8],
|
||||
) -> io::Result<(u8, Option<u32>, u64, u8)> {
|
||||
@@ -805,45 +584,29 @@ pub(super) fn read_inventory_change_failure(
|
||||
Ok((reason, required_level, item_guid, bag_slot))
|
||||
}
|
||||
|
||||
/// Read `SMSG_ITEM_TIME_UPDATE` (VERIFIED vmangos `Item::SendTimeUpdate`,
|
||||
/// `Objects/Item.cpp:1096-1106`): the item guid, then the remaining **seconds**
|
||||
/// (`ITEM_FIELD_DURATION`, which vmangos counts down in seconds — `Item::UpdateDuration`,
|
||||
/// `Item.cpp:243-257`). No trailing player guid on this one, unlike its enchant sibling: the
|
||||
/// packet is `(8 + 4)` bytes and vmangos sizes it exactly so.
|
||||
///
|
||||
/// Sent on world enter for every carried duration item (`Player::SendItemDurations`,
|
||||
/// `Player.cpp:12007`) and again whenever one is created or its duration changes
|
||||
/// (`Player.cpp:19782-19785`).
|
||||
/// Read `SMSG_ITEM_TIME_UPDATE` (`Objects/Item.cpp:1096-1106`): item guid, then seconds left, and
|
||||
/// no player guid after. Sent for every duration item on world enter and on each change.
|
||||
pub(super) fn read_item_time(r: &mut &[u8]) -> io::Result<(u64, u32)> {
|
||||
let item_guid = read_u64_le(r)?;
|
||||
let seconds = read_u32_le(r)?;
|
||||
Ok((item_guid, seconds))
|
||||
}
|
||||
|
||||
/// Read `SMSG_ITEM_ENCHANT_TIME_UPDATE` (VERIFIED vmangos
|
||||
/// `WorldPackets::Item::ItemEnchantTimeUpdate::AppendBodyTo`, `Server/Packets/Item.cpp:161-169`):
|
||||
/// item guid, enchant **slot**, remaining **seconds**, then the owning player's guid (present from
|
||||
/// build 1.10.2 up, so always in 1.12). Returns `(item_guid, slot, seconds)`; the trailing player
|
||||
/// guid is dropped — the packet only ever concerns our own items and the item guid already names
|
||||
/// which one.
|
||||
///
|
||||
/// `seconds == 0` means expired, and the reference stores that as **no timer** (`0x5d9cc0` writes
|
||||
/// a `0` deadline whenever `seconds <= 0`), not as "0 seconds left".
|
||||
/// Read `SMSG_OPEN_CONTAINER` → the item guid. VERIFIED vmangos `Player::SendOpenContainer`
|
||||
/// (`Objects/Player.cpp:11707`) → `WorldPackets::Item::OpenContainer`, whose whole body is the item
|
||||
/// guid; the one sender is `HandleAutoEquipItemOpcode` (`ItemHandler.cpp:227`) when the destination
|
||||
/// is a bag slot. The reference fires `BAG_OPEN(containerId)` from it and nothing else (2339).
|
||||
/// Read `SMSG_OPEN_CONTAINER`, the bag's guid, sent when an auto-equip lands in a bag slot
|
||||
/// (`ItemHandler.cpp:227`); the reference fires only `BAG_OPEN` from it.
|
||||
pub(super) fn read_open_container(r: &mut &[u8]) -> io::Result<u64> {
|
||||
read_u64_le(r)
|
||||
}
|
||||
|
||||
/// Read `SMSG_INSPECT` → the echoed target guid. VERIFIED vmangos `HandleInspectOpcode`
|
||||
/// (`MiscHandler.cpp:957`) → `WorldPackets::Misc::InspectResponse`, one `u64`. The reference's
|
||||
/// handler `0x5e7d70` reads it and does nothing with it (2339; decision 0631's call).
|
||||
/// Read `SMSG_INSPECT`, the echoed target guid (`MiscHandler.cpp:957`); the reference's handler
|
||||
/// (`0x5e7d70`) ignores it.
|
||||
pub(super) fn read_inspect(r: &mut &[u8]) -> io::Result<u64> {
|
||||
read_u64_le(r)
|
||||
}
|
||||
|
||||
/// Read `SMSG_ITEM_ENCHANT_TIME_UPDATE` (`Server/Packets/Item.cpp:161-169`): item guid, slot,
|
||||
/// seconds, then our own guid, left unread. The reference stores 0 seconds as no timer
|
||||
/// (`0x5d9cc0`), not as a timer at zero.
|
||||
pub(super) fn read_item_enchant_time(r: &mut &[u8]) -> io::Result<(u64, u32, u32)> {
|
||||
let item_guid = read_u64_le(r)?;
|
||||
let slot = read_u32_le(r)?;
|
||||
@@ -855,16 +618,12 @@ pub(super) fn read_item_enchant_time(r: &mut &[u8]) -> io::Result<(u64, u32, u32
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// The `SMSG_ITEM_QUERY_SINGLE_RESPONSE` parse goldens (hit + miss, byte-exact against the
|
||||
// vmangos field order) live in `tests/items.rs` — one home, no drifting twins.
|
||||
// The query-response parse goldens are in `tests/items.rs`; do not duplicate them here.
|
||||
|
||||
// Byte-exact encode goldens — the item-move CMSG bodies (VERIFIED field order + widths against
|
||||
// vmangos `Server/Packets/Item.cpp` `ReadFromWorldPacket`s; every field is a `uint8`).
|
||||
// Item-move body goldens follow vmangos `Server/Packets/Item.cpp`; every field is a `u8`.
|
||||
|
||||
#[test]
|
||||
fn open_container_decodes() {
|
||||
// The whole body is the bag's guid (VERIFIED vmangos `Player::SendOpenContainer` →
|
||||
// `WorldPackets::Item::OpenContainer::AppendBodyTo` writes only `itemGuid`); decision 2339.
|
||||
let body = 0x4000_0000_0012_3456u64.to_le_bytes();
|
||||
match crate::messages::parse_server(crate::messages::opcode::SMSG_OPEN_CONTAINER, &body)
|
||||
.unwrap()
|
||||
@@ -878,8 +637,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn inspect_decodes_and_is_ignored() {
|
||||
// One u64, the echoed target (VERIFIED vmangos `Misc::InspectResponse::AppendBodyTo`), and
|
||||
// `decode()` turns it into nothing — the reference's handler discards it too (2339, 0631).
|
||||
let body = 0x0000_0000_0000_0007u64.to_le_bytes();
|
||||
let packet =
|
||||
crate::messages::parse_server(crate::messages::opcode::SMSG_INSPECT, &body).unwrap();
|
||||
@@ -898,7 +655,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn wrap_item_body_paper_first() {
|
||||
// 467: giftbag, giftslot, itembag, itemslot (Item.cpp:121-127) — the PAPER's pair leads.
|
||||
// 467: giftbag, giftslot, itembag, itemslot (Item.cpp:121-127).
|
||||
assert_eq!(wrap_item(255, 23, 255, 24), vec![255, 23, 255, 24]);
|
||||
}
|
||||
|
||||
@@ -910,19 +667,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn swap_item_body_destination_first() {
|
||||
// 268: dstbag, dstslot, srcbag, srcslot — destination pair FIRST (Item.cpp:30-36).
|
||||
// 268: dstbag, dstslot, srcbag, srcslot (Item.cpp:30-36).
|
||||
assert_eq!(swap_item(19, 3, 255, 30), vec![19, 3, 255, 30]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_inv_item_body() {
|
||||
// 269: srcslot, dstslot (Item.cpp:38-42). Backpack slot 1↔2 = player-array 23↔24.
|
||||
// 269: srcslot, dstslot (Item.cpp:38-42); backpack slots 1 and 2 are 23 and 24.
|
||||
assert_eq!(swap_inv_item(23, 24), vec![23, 24]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_ammo_body() {
|
||||
// 616 (0x268): a lone little-endian u32 item entry (wow-re cursor-dragdrop-slots.md).
|
||||
// 616 (0x268): a lone little-endian u32 item entry.
|
||||
assert_eq!(set_ammo(0x0001_6b74), vec![0x74, 0x6b, 0x01, 0x00]);
|
||||
}
|
||||
|
||||
@@ -938,13 +695,10 @@ mod tests {
|
||||
assert_eq!(destroy_item(255, 23, 0), vec![255, 23, 0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
// SMSG_INVENTORY_CHANGE_FAILURE parse — both branches of the conditional `requiredLevel u32`
|
||||
// (VERIFIED vmangos `InventoryChangeFailure::AppendBodyTo`, `Item.cpp:198-209`;
|
||||
// EQUIP_ERR_CANT_EQUIP_LEVEL_I = 1, `Objects/ItemDefines.h`).
|
||||
// Reason 1 is `EQUIP_ERR_CANT_EQUIP_LEVEL_I` (`Objects/ItemDefines.h`, `Item.cpp:198-209`).
|
||||
|
||||
#[test]
|
||||
fn inventory_failure_ok_reason_is_bare() {
|
||||
// reason 0 (EQUIP_ERR_OK) ships no tail.
|
||||
let buf = [0u8];
|
||||
let mut r = &buf[..];
|
||||
assert_eq!(
|
||||
@@ -955,7 +709,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn inventory_failure_level_branch_reads_the_u32() {
|
||||
// reason 1 (CANT_EQUIP_LEVEL_I): requiredLevel u32, item1Guid u64, item2Guid u64, bagSlot u8.
|
||||
let mut buf = Vec::new();
|
||||
buf.push(1u8); // reason
|
||||
buf.extend_from_slice(&40u32.to_le_bytes()); // requiredLevel
|
||||
@@ -969,9 +722,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `SMSG_ITEM_ENCHANT_TIME_UPDATE`'s body, byte-exact against vmangos's writer: guid, slot,
|
||||
/// seconds, then the player guid we drop. Pinned because a wrong width here would silently
|
||||
/// park a garbage deadline (decision 0920).
|
||||
#[test]
|
||||
fn item_enchant_time_reads_guid_slot_seconds() {
|
||||
let mut buf = Vec::new();
|
||||
@@ -984,13 +734,10 @@ mod tests {
|
||||
read_item_enchant_time(&mut r).unwrap(),
|
||||
(0x4000_0000_0000_00f7, 1, 600)
|
||||
);
|
||||
// The trailing player guid stays unread — the reader consumes exactly its three fields.
|
||||
// The trailing player guid stays unread.
|
||||
assert_eq!(r.len(), 8);
|
||||
}
|
||||
|
||||
/// `SMSG_ITEM_TIME_UPDATE`'s body: guid then seconds, and **nothing after** — the enchant
|
||||
/// twin's trailing player guid is not on this packet (vmangos sizes it `8 + 4`). Pinned
|
||||
/// because the two share a handler and it would be easy to copy the wrong tail.
|
||||
#[test]
|
||||
fn item_time_reads_guid_then_seconds() {
|
||||
let mut buf = Vec::new();
|
||||
@@ -1006,9 +753,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn inventory_failure_nonlevel_branch_has_no_u32() {
|
||||
// Any failed reason != 1 skips requiredLevel: item1Guid u64, item2Guid u64, bagSlot u8.
|
||||
let mut buf = Vec::new();
|
||||
buf.push(3u8); // reason (ITEM_DOESNT_GO_TO_SLOT) — no requiredLevel
|
||||
buf.push(3u8); // reason (ITEM_DOESNT_GO_TO_SLOT)
|
||||
buf.extend_from_slice(&0xDEAD_BEEF_0000_0001u64.to_le_bytes()); // item1
|
||||
buf.extend_from_slice(&0u64.to_le_bytes()); // item2
|
||||
buf.push(0); // bagSlot
|
||||
|
||||
Reference in New Issue
Block a user