diff --git a/crates/benilla-protocol/src/messages/trade.rs b/crates/benilla-protocol/src/messages/trade.rs index fa4fe017..661c2d87 100644 --- a/crates/benilla-protocol/src/messages/trade.rs +++ b/crates/benilla-protocol/src/messages/trade.rs @@ -1,93 +1,75 @@ -//! The player-to-player trade arc's wire layer (decision 0592 phase P0): the request verbs the -//! trade window sends and the two status packets the server pushes back. Layout VERIFIED against -//! vmangos `Handlers/TradeHandler.cpp`, `Server/Packets/Trade.{h,cpp}`, `Objects/TradeData.{h,cpp}` -//! and `SharedDefines.h` (read directly at decision time). Trade renders nothing derived from -//! WoW.exe, so vmangos is the whole wire authority: its `ReadFromWorldPacket` pins every CMSG body -//! below, its `AppendBodyTo`/`SendUpdateTrade` pin both SMSG parses — no wow-re dispatch is -//! load-bearing (decision 0592, "No wow-re dispatch"). +//! Player-to-player trade wire: the trade window's request verbs and the two status packets the +//! server pushes back (vmangos `TradeHandler.cpp`, `Server/Packets/Trade.cpp`). use std::io; use crate::wire::{read_i32_le, read_u32_le, read_u64_le, read_u8}; -/// `TRADE_SLOT_COUNT` (`TradeData.h`): seven slots per side — six tradeable (0..6) plus the -/// seventh **non-traded / enchant** slot ([`TRADE_SLOT_NONTRADED`]), whose item is not exchanged -/// but is the target an enchant/lockpick spell is applied to through the window. +/// Slots per side (`TradeData.h`): six traded plus the non-traded enchant slot. pub const TRADE_SLOT_COUNT: usize = 7; -/// `TRADE_SLOT_TRADED_COUNT` — the six slots (0..6) whose items actually change hands. +/// Slots 0..6, whose items change hands. pub const TRADE_SLOT_TRADED_COUNT: usize = 6; -/// `TRADE_SLOT_NONTRADED` — the 7th slot (index 6, UI id 7): an item parked here stays with its -/// owner; it is the enchant/spell target, not part of the exchange. +/// UI slot 7: its item stays with its owner as the target of an enchant or lockpick spell. pub const TRADE_SLOT_NONTRADED: usize = 6; -/// The trade state machine's status codes (`SharedDefines.h` `enum TradeStatus`, 0..=22). The -/// tail-carrying members hold their `SMSG_TRADE_STATUS` payload inline (VERIFIED vmangos -/// `WorldPackets::Trade::TradeStatus::AppendBodyTo`): only `BEGIN_TRADE`, `CLOSE_WINDOW` and -/// `ONLY_CONJURED` ride a tail; every other status is the bare `u32`. [`TradeStatus::Unknown`] -/// keeps an out-of-range code (never emitted by vmangos on 5875) parseable rather than fatal. +/// `SMSG_TRADE_STATUS` codes (`SharedDefines.h` `enum TradeStatus`), each with its tail inline. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TradeStatus { - /// `0` — the target is already trading / busy (also the initiator's own "already trading"). + /// The target is busy, or the initiator is already trading. Busy, - /// `1` — sent to the *target* of a fresh `CMSG_INITIATE_TRADE`; `partner` is the initiator's - /// guid. The client answers `CMSG_BEGIN_TRADE`, which makes the server emit `OpenWindow` to - /// both sides. + /// To the target of `CMSG_INITIATE_TRADE`; its `CMSG_BEGIN_TRADE` opens both windows. BeginTrade { partner: u64 }, - /// `2` — open the trade window (fires the client's `TRADE_SHOW`). Sent to both sides once the - /// target's `CMSG_BEGIN_TRADE` arrives. + /// Opens the window on both sides (the client's `TRADE_SHOW`). OpenWindow, - /// `3` — the trade was cancelled; close both windows. + /// The trade was cancelled; both windows close. Canceled, - /// `4` — the *partner* pressed Trade (drives the accept-glow on their column). + /// The partner pressed Trade (the accept highlight on their column). Accept, - /// `5` — a second "busy" code, handled as busy. + /// A second busy code, handled as `Busy`. Busy2, - /// `6` — no such target for `CMSG_INITIATE_TRADE`. + /// No such target for `CMSG_INITIATE_TRADE`. NoTarget, - /// `7` — a change happened after an accept (or the 200 ms scam-delay bounced an accept): - /// drop the accept and go back to editing. + /// An offer changed after an accept, or the 200 ms scam delay bounced one: the accept drops. BackToTrade, - /// `8` — both sides accepted and the swap completed; close both windows. + /// Both sides accepted and the swap completed; both windows close. Complete, - /// `9` — the trade was rejected. + /// The trade was rejected. Rejected, - /// `10` — the partner is out of `TRADE_DISTANCE` (also the flying/no-map initiate refusal). + /// Out of `TRADE_DISTANCE`; also the refusal to initiate while flying or off-map. TargetTooFar, - /// `11` — cross-faction trade refused (unless the server allows two-side interaction). + /// Cross-faction, unless the server allows two-side interaction. WrongFaction, - /// `12` — close the window; `result`/`item_limit_category` are the vanilla tail (usually 0; - /// the middle `u8` vmangos writes is consumed but carries nothing for player trade). + /// The tail is usually zero; the `u8` vmangos writes between the two fields is dropped. CloseWindow { result: u32, item_limit_category: u32, }, - /// `13` — vmangos comments it is "handled with TRADE_STATUS_TRADE_CANCELED"; kept distinct so - /// the wire round-trips. + /// Per a vmangos note, handled as `Canceled`; kept distinct so the code round-trips. Unknown13, - /// `14` — the target has you on ignore. + /// The target has you on ignore. IgnoreYou, - /// `15` — you are stunned. + /// You are stunned. YouStunned, - /// `16` — the target is stunned. + /// The target is stunned. TargetStunned, - /// `17` — you are dead. + /// You are dead. YouDead, - /// `18` — the target is dead. + /// The target is dead. TargetDead, - /// `19` — you are logging out. + /// You are logging out. YouLogout, - /// `20` — the target is logging out. + /// The target is logging out. TargetLogout, - /// `21` — a trial account restriction. + /// A trial account restriction. TrialAccount, - /// `22` — "you can only trade conjured items…"; `slot` names the offending trade slot. + /// "You can only trade conjured items"; `slot` is the offending trade slot. OnlyConjured { slot: u8 }, - /// A code outside 0..=22 (vmangos never sends one on 5875) — kept parseable, no tail read. + /// A code outside 0..=22, which vmangos never sends; read with no tail. Unknown(u32), } impl TradeStatus { - /// The raw `u32` code this status serializes as (the enum discriminant on the wire). + /// The `u32` status code on the wire. pub fn code(self) -> u32 { match self { TradeStatus::Busy => 0, @@ -118,13 +100,10 @@ impl TradeStatus { } } -/// One item shown in a trade slot, as it rides `SMSG_TRADE_STATUS_EXTENDED`'s fixed 60-byte -/// (15×u32) per-slot block (VERIFIED vmangos `WorldSession::SendUpdateTrade`). An empty slot is -/// all-zero on the wire and folds to `None` rather than a zeroed `Some` (the `entry == 0` signal, -/// same convention as [`super::MailAttachment`]). +/// One trade slot's 60-byte item block; an all-zero block is an empty slot. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TradeItem { - /// `Item.dbc` entry (`OBJECT_FIELD_ENTRY`) — the key to name/icon via `ITEM_QUERY_SINGLE`. + /// `Item.dbc` entry (`OBJECT_FIELD_ENTRY`), the key for name and icon via `ITEM_QUERY_SINGLE`. pub entry: u32, pub display_id: u32, pub count: u32, @@ -133,7 +112,7 @@ pub struct TradeItem { pub gift_creator: u64, pub perm_enchant: u32, pub creator: u64, - /// Spell charges — signed (a negative count means "N uses left" on some items). + /// Signed: a negative count means N uses left on some items. pub charges: i32, pub suffix_factor: u32, pub random_prop_id: u32, @@ -142,62 +121,51 @@ pub struct TradeItem { pub durability: u32, } -/// A decoded `SMSG_TRADE_STATUS_EXTENDED` — the full item/gold snapshot for **one** window side. -/// The server pushes one of these per side whenever that side's offer changes. +/// `SMSG_TRADE_STATUS_EXTENDED`: one side's snapshot, sent whenever that side's offer changes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TradeStatusExtended { - /// `true` = the *partner's* (recipient / right) column, `false` = our own (left) column - /// (vmangos's `trader_state`: `1` for the trader's data, `0` for ours). + /// The partner's (right) column when the wire byte is 1, ours (left) when 0. pub their_window: bool, - /// Gold offered on this side, in copper. + /// In copper. pub gold: u32, - /// The spell applied to this side's non-traded slot item (`0` = none) — the enchant slot. + /// The spell cast on this side's non-traded slot item, 0 for none. pub enchant_spell_id: u32, - /// The seven slots (0..[`TRADE_SLOT_COUNT`]); `None` = empty. pub slots: [Option; TRADE_SLOT_COUNT], } -// ── Client → server bodies (what vmangos `ReadFromWorldPacket` consumes) ───────────────────── +// ── Client → server bodies ── -/// Body of `CMSG_INITIATE_TRADE` (vmangos `InitiateTrade::ReadFromWorldPacket`): one full 8-byte -/// target-player guid. The server answers the initiator on any refusal (`SMSG_TRADE_STATUS`) and, -/// on success, sends the *target* `BeginTrade`. +/// `CMSG_INITIATE_TRADE`: a refusal is a status back to us; success sends the target `BeginTrade`. pub fn initiate_trade(target: u64) -> Vec { target.to_le_bytes().to_vec() } -/// Body of `CMSG_ACCEPT_TRADE` (vmangos `AcceptTrade::ReadFromWorldPacket`): one `u32` the server -/// **read-skips**. The real client sends `1` once it has seen `OPEN_WINDOW`; we send `1` too (the -/// value is discarded either way). +/// `CMSG_ACCEPT_TRADE`: a `u32` the server skips; the 1.12 client sends 1. pub fn accept_trade() -> Vec { 1u32.to_le_bytes().to_vec() } -/// Body of `CMSG_SET_TRADE_ITEM` (vmangos `SetTradeItem::ReadFromWorldPacket`): `u8 tradeSlot, -/// u8 bag, u8 slot` — put the item at inventory (`bag`, `slot`) into trade slot `tradeSlot`. +/// `CMSG_SET_TRADE_ITEM`: puts the item at (`bag`, `slot`) into `trade_slot`. pub fn set_trade_item(trade_slot: u8, bag: u8, slot: u8) -> Vec { vec![trade_slot, bag, slot] } -/// Body of `CMSG_CLEAR_TRADE_ITEM` (vmangos `ClearTradeItem::ReadFromWorldPacket`): `u8 tradeSlot`. +/// `CMSG_CLEAR_TRADE_ITEM` body. pub fn clear_trade_item(trade_slot: u8) -> Vec { vec![trade_slot] } -/// Body of `CMSG_SET_TRADE_GOLD` (vmangos `SetTradeGold::ReadFromWorldPacket`): `u32 copper`. +/// `CMSG_SET_TRADE_GOLD` body, in copper. pub fn set_trade_gold(copper: u32) -> Vec { copper.to_le_bytes().to_vec() } // `CMSG_BEGIN_TRADE`, `CMSG_BUSY_TRADE`, `CMSG_IGNORE_TRADE`, `CMSG_UNACCEPT_TRADE` and -// `CMSG_CANCEL_TRADE` carry **empty** bodies (vmangos reads them as `NullClientPacket`); the -// writer sends `&[]` directly, so they need no builder here. +// `CMSG_CANCEL_TRADE` have empty bodies (vmangos `NullClientPacket`), so they need no builder. -// ── Server → client parses (what vmangos `AppendBodyTo` / `SendUpdateTrade` emit) ───────────── +// ── Server → client parses ── -/// Read `SMSG_TRADE_STATUS` (VERIFIED vmangos `TradeStatus::AppendBodyTo`): `u32 status`, then a -/// per-status tail — `BEGIN_TRADE → u64 partnerGuid`; `CLOSE_WINDOW → u32 result, u8 unk, u32 -/// itemLimitCategory`; `ONLY_CONJURED → u8 slot`; every other status has none. +/// `SMSG_TRADE_STATUS` (vmangos `TradeStatus::AppendBodyTo`): a `u32` code, then its tail. pub(super) fn read_trade_status(r: &mut &[u8]) -> io::Result { let code = read_u32_le(r)?; Ok(match code { @@ -238,12 +206,8 @@ pub(super) fn read_trade_status(r: &mut &[u8]) -> io::Result { }) } -/// Read `SMSG_TRADE_STATUS_EXTENDED` (VERIFIED vmangos `WorldSession::SendUpdateTrade`): -/// `u8 which`, `u32 slotCount`, `u32 slotCount` (again — the two counts match), `u32 gold`, -/// `u32 enchantSpellId`, then [`TRADE_SLOT_COUNT`] records of `u8 slotIndex` + a fixed 60-byte -/// (15×u32) item block (all-zero → the slot is empty). The `slotIndex` byte places the block, so -/// a block is stored at `slots[slotIndex]` (defensive against reordering; vmangos writes 0..7 in -/// order). +/// `SMSG_TRADE_STATUS_EXTENDED` (vmangos `WorldSession::SendUpdateTrade`): `u8 which`, the slot +/// count twice, gold, enchant spell, then seven records of `u8 index` and a 60-byte item block. pub(super) fn read_trade_status_extended(r: &mut &[u8]) -> io::Result { let which = read_u8(r)?; let _slot_count_a = read_u32_le(r)?; @@ -297,7 +261,7 @@ pub(super) fn read_trade_status_extended(r: &mut &[u8]) -> io::Result Vec { trainer_guid.to_le_bytes().to_vec() } -/// Body of `CMSG_TRAINER_BUY_SPELL` (vmangos `Npc.cpp`, `TrainerBuySpell::ReadFromWorldPacket`): -/// `u64 trainerGuid, u32 spellId` (the [`TrainerSpell::spell`] of the chosen service). The server -/// answers `SMSG_TRAINER_BUY_SUCCEEDED` (+ the learned spell via `SMSG_LEARNED_SPELL`) or -/// `SMSG_TRAINER_BUY_FAILED`. +/// `CMSG_TRAINER_BUY_SPELL`: the server answers `SMSG_TRAINER_BUY_SUCCEEDED` plus +/// `SMSG_LEARNED_SPELL`, or `SMSG_TRAINER_BUY_FAILED`. pub fn trainer_buy_spell(trainer_guid: u64, spell_id: u32) -> Vec { let mut body = Vec::with_capacity(12); body.extend_from_slice(&trainer_guid.to_le_bytes()); @@ -86,21 +60,18 @@ pub fn trainer_buy_spell(trainer_guid: u64, spell_id: u32) -> Vec { body } -/// Read `SMSG_TRAINER_LIST` (vmangos `WorldSession::SendTrainerList`, `NPCHandler.cpp:141-241`): -/// `u64 trainerGuid, u32 trainerType, u32 count, count ×` [`TrainerSpell`] (38 bytes each)`, cstr -/// title`. `trainerType` is the window-framing kind (0 class · 1 mount · 2 tradeskill · 3 pet); the -/// title is the greeting line. Returns `(trainer, trainer_type, services, title)`. +/// `SMSG_TRAINER_LIST` (`NPCHandler.cpp:141-241`): the type is 0 class, 1 mount, 2 tradeskill or +/// 3 pet, and the title is the greeting line. pub(super) fn read_trainer_list( r: &mut &[u8], ) -> io::Result<(u64, u32, Vec, String)> { let trainer = read_u64_le(r)?; let trainer_type = read_u32_le(r)?; let count = read_u32_le(r)?; - // No protocol bound: the list is every spell the trainer's template carries (vmangos - // `NPCHandler.cpp:170` sums its two lists). 1024 is far past any real trainer. + // No protocol bound (`NPCHandler.cpp:170` sums two lists); 1024 is past any real trainer. let mut services = Vec::with_capacity(capacity_hint(count, 1024)); for _ in 0..count { - // Struct-literal fields evaluate top-to-bottom, so this reads in wire order. + // Struct-literal fields evaluate top to bottom, so this reads in wire order. services.push(TrainerSpell { spell: read_u32_le(r)?, state: read_u8(r)?, @@ -117,15 +88,13 @@ pub(super) fn read_trainer_list( Ok((trainer, trainer_type, services, title)) } -/// Read `SMSG_TRAINER_BUY_SUCCEEDED` (vmangos `TrainerBuySucceeded::AppendBodyTo`): `u64 -/// trainerGuid, u32 spellId`. Confirmation + sound only — the learned spell itself lands via -/// `SMSG_LEARNED_SPELL`, and the green→gray repaint needs a `CMSG_TRAINER_LIST` re-request. +/// `SMSG_TRAINER_BUY_SUCCEEDED` (trainer, spell) is confirmation only: the spell arrives by +/// `SMSG_LEARNED_SPELL` and the gray repaint needs a `CMSG_TRAINER_LIST`. pub(super) fn read_trainer_buy_succeeded(r: &mut &[u8]) -> io::Result<(u64, u32)> { Ok((read_u64_le(r)?, read_u32_le(r)?)) } -/// Read `SMSG_TRAINER_BUY_FAILED` (vmangos `TrainerBuyFailed::AppendBodyTo`): `u64 trainerGuid, u32 -/// serviceId (the spell), u32 errorCode` (a [`train_fail`] code). +/// `SMSG_TRAINER_BUY_FAILED`: trainer, spell and a [`train_fail`] code. pub(super) fn read_trainer_buy_failed(r: &mut &[u8]) -> io::Result<(u64, u32, u32)> { Ok((read_u64_le(r)?, read_u32_le(r)?, read_u32_le(r)?)) } diff --git a/crates/benilla-protocol/src/messages/tutorial.rs b/crates/benilla-protocol/src/messages/tutorial.rs index 4750b716..1d064750 100644 --- a/crates/benilla-protocol/src/messages/tutorial.rs +++ b/crates/benilla-protocol/src/messages/tutorial.rs @@ -1,29 +1,21 @@ -//! The tutorial system's wire (decision 1976; wow-re `system/ui/scratch/tutorial-flags.md` §7): -//! one inbound bank of bits and the three sends the client's tutorial code makes. -//! -//! The client keeps two bit banks filled from the same `SMSG_TUTORIAL_FLAGS` — the fire-once bank -//! its C++ trigger reads and sets, and the acknowledged bank Lua's `FlagTutorial` and six C++ -//! auto-flag sites write, which is the only path that sends. The handler takes **whatever bytes -//! are left in the packet** and sizes both banks as `bytes × 8` bits; vmangos sends 32 bytes. +//! Tutorial wire: one inbound bank of bits and the client's three tutorial sends. The 1.12 +//! client sizes its banks from every remaining byte of the packet; vmangos sends 32 bytes. use std::io::{self, Read}; -/// `SMSG_TUTORIAL_FLAGS` (VERIFIED at the bytes, handler `0x4b5700`): the raw bank, every -/// remaining byte of the packet — bit `id` is `bytes[id >> 3] & (1 << (id & 7))`, the same -/// `word = id >> 5, bit = 1 << (id & 31)` addressing on little-endian dwords. +/// `SMSG_TUTORIAL_FLAGS` (handler `0x4b5700`): bit `id` is `bytes[id >> 3] & (1 << (id & 7))`. #[derive(Clone, Debug, PartialEq, Eq, Default)] pub struct TutorialFlags { pub bytes: Vec, } -/// Parse it: the rest of the body, whatever its length. pub(super) fn read_tutorial_flags(r: &mut impl Read) -> io::Result { let mut bytes = Vec::new(); r.read_to_end(&mut bytes)?; Ok(TutorialFlags { bytes }) } -/// Body of `CMSG_TUTORIAL_FLAG` (VERIFIED, `0x4b54c0`): one `u32`, the **0-based** id. +/// `CMSG_TUTORIAL_FLAG` (`0x4b54c0`): the 0-based id as a `u32`. pub fn tutorial_flag(id: u32) -> Vec { id.to_le_bytes().to_vec() } diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs index 34ee4af4..bd65fad4 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -4,74 +4,49 @@ use crate::wire::{capacity_hint, read_u32_le, read_u8, Vector3d}; use super::movement::ObjectType; -// ObjectFields descriptor-field indices (build 5875), shared across object types. +// Descriptor field indices for build 5875. const FIELD_OBJECT_TYPE: u16 = 2; const FIELD_OBJECT_SCALE_X: u16 = 4; -// `OBJECT_FIELD_CREATED_BY` = OBJECT_END(6) + 0x0..0x1 (vmangos `UpdateFields_1_12_1.h`): the -// GameObject block's creator guid pair — the summoning player of a spell-spawned object -// (fishing bobber, ritual portal); zero/absent for a world spawn. +// The creator of a spell-spawned object (bobber, ritual portal); 0 for a world spawn. const FIELD_GAMEOBJECT_CREATED_BY: u16 = 6; const FIELD_GAMEOBJECT_DISPLAYID: u16 = 8; const FIELD_GAMEOBJECT_FLAGS: u16 = 9; -// `GAMEOBJECT_ROTATION` = OBJECT_END(6) + 0x4 .. +0x7 (vmangos `UpdateFields_1_12_1.h`): the -// spawn's local-rotation quaternion (x, y, z, w), 4 floats. The type-11 transport evaluator -// rotates its keyframe offsets by this (decision 0438 phase 3's second consumer). +// The spawn's rotation quaternion (x, y, z, w), four floats. const FIELD_GAMEOBJECT_ROTATION: u16 = 10; pub const FIELD_GAMEOBJECT_STATE: u16 = 14; const FIELD_GAMEOBJECT_POS_X: u16 = 15; const FIELD_GAMEOBJECT_POS_Y: u16 = 16; const FIELD_GAMEOBJECT_POS_Z: u16 = 17; const FIELD_GAMEOBJECT_FACING: u16 = 18; -// `OBJECT_END + 0x34` bytes ⇒ `OBJECT_END(6) + field 13` = 19 (wow-re cursor-system §4a byte offset; -// vmangos' `UpdateFields_1_5_1.h` inserts a stray `GAMEOBJECT_TIMESTAMP` that would shift this to 20, -// but that header is not the 5875 layout — STATE@14 / TYPE_ID@21 both work against this same server). +// The client reads it at GameObject block byte 0x34. vmangos `UpdateFields_1_5_1.h` adds a +// `GAMEOBJECT_TIMESTAMP` that would make it 20, but that is not the 5875 layout. const FIELD_GAMEOBJECT_DYN_FLAGS: u16 = 19; -// GAMEOBJECT_FACTION = OBJECT_END(6) + field 14 — the slot between DYN_FLAGS(13) and TYPE_ID(15), -// i.e. the binary's `[go+0x110]+0x38` (wow-re cursor-system §4a's faction term, decision 0764). +// The client reads it at `[go+0x110]+0x38`, between DYN_FLAGS and TYPE_ID. const FIELD_GAMEOBJECT_FACTION: u16 = 20; const FIELD_GAMEOBJECT_TYPE_ID: u16 = 21; -// GAMEOBJECT_LEVEL = OBJECT_END(6) + 0x10 (vmangos UpdateFields_1_12_1.h:317). +// OBJECT_END + 0x10 (vmangos `UpdateFields_1_12_1.h:317`). const FIELD_GAMEOBJECT_LEVEL: u16 = 22; -/// `CORPSE_FIELD_DYNAMIC_FLAGS` = `OBJECT_END(6) + 0x1E` (vmangos `UpdateFields_1_12_1.h`) — bit 0 -/// is the lootable-insignia flag [`ObjectFields::corpse_lootable`] reads and the loot-art arm -/// watches. Named because a watch keys on the raw index, and `36` is `UNIT_FIELD_BYTES_0` on a -/// unit: the same number means two things, which is why a field edge carries its object class. +/// `CORPSE_FIELD_DYNAMIC_FLAGS`; bit 0 is lootable insignia. On a unit, index 36 is +/// `UNIT_FIELD_BYTES_0`, so a field edge carries its object class. pub const FIELD_CORPSE_DYNAMIC_FLAGS: u16 = 36; -// UNIT descriptor fields (verified against wow-5875-re object-layer node, build 5875; FLAGS / -// COMBATREACH / DYNAMIC_FLAGS / NPC_FLAGS additionally cross-checked against the cursor RE's client -// offsets `+0xa0`/`+0x1f0`/`+0x224`/`+0x234` and vmangos `UpdateFields_1_12_1.h`). -/// `UNIT_FIELD_TARGET` (idx 16, a 2-field guid) — who the unit is attacking/interacting with. The -/// real client turns an idle unit's body to *face* this target (there is no wire facing for a -/// stationary re-face — vmangos `SetInFront` is server-only); benilla mirrors that (`face_target`). +// UNIT fields. The client reads FLAGS, COMBATREACH, DYNAMIC_FLAGS and NPC_FLAGS at unit block +// bytes 0xa0, 0x1f0, 0x224 and 0x234. +/// The unit's target; the client turns an idle unit to face it, and no packet carries that facing. const FIELD_UNIT_TARGET: u16 = 16; -/// `UNIT_FIELD_SUMMON` (idx 8, `OBJECT_END(6) + 0x2`, a 2-field guid, PUBLIC — VERIFIED vmangos -/// `UpdateFields_1_12_1.h:42`) — the **inverse** of [`FIELD_UNIT_SUMMONEDBY`] below: the unit this -/// one has summoned. On our own descriptor it is the pet, and so the anchor of the `"pet"` unit -/// token; the pet action bar reads its own guid off `SMSG_PET_SPELLS` instead, which is why the -/// two can legitimately disagree for a beat around a summon (decision 0982). +/// The unit this one summoned (`UpdateFields_1_12_1.h:42`); on us, the `"pet"` unit. The pet bar +/// reads its guid off `SMSG_PET_SPELLS`, so the two can disagree briefly around a summon. const FIELD_UNIT_SUMMON: u16 = 8; -/// `UNIT_FIELD_CHARMEDBY` (idx 10, `OBJECT_END(6) + 0x4`, a 2-field guid — vmangos -/// `UpdateFields_1_12_1.h`) — whoever is currently charming this unit, `0` when nobody is. -/// Byte-verified client-side as `[unit+0x110]+0x10`: the shared attack-start validator -/// `0x612df0` reads it at `0x612e33` and refuses the swing when it is nonzero and is **not** the -/// active player's own guid (`ERR_ATTACK_CHARMED`), and the owner-guid pair in both `0x5ee5a0` -/// and the `PET_ATTACK_*` callback `0x5ff580` prefers it over their respective fallbacks -/// (wow-re `object-layer/scratch/pet-command-validators.md` §1/§2/§4). +/// The charmer, 0 for none. The attack-start check `0x612df0` refuses a swing when it is set and +/// is not us (`ERR_ATTACK_CHARMED`); `0x5ee5a0` and `0x5ff580` prefer it as the owner. const FIELD_UNIT_CHARMEDBY: u16 = 10; -/// `UNIT_FIELD_SUMMONEDBY` (idx 12, `OBJECT_END(6) + 0x6`, a 2-field guid — vmangos -/// `UpdateFields_1_12_1.h`) — the summoner of a pet/guardian/totem. With CREATEDBY below, the -/// combat-text source-ownership classifier's "owned by me" test (wow-re `0x5efea0`). +/// The summoner of a pet, guardian or totem; with CREATEDBY, the "owned by me" test of `0x5efea0`. const FIELD_UNIT_SUMMONEDBY: u16 = 12; -/// `UNIT_FIELD_CREATEDBY` (idx 14, a 2-field guid) — the creator (totems, summoned objects). const FIELD_UNIT_CREATEDBY: u16 = 14; -/// `UNIT_FIELD_CHANNEL_OBJECT` (idx 20, `OBJECT_END(6) + 0xE`, a 2-field guid, PUBLIC — VERIFIED -/// vmangos `UpdateFields_1_12_1.h:48`) — the object a channeled spell is aimed at (may be the -/// caster itself). Public, so an **observer** renders another unit's channel loop from this field -/// pair, not from the self-only `MSG_CHANNEL_START`/`UPDATE` UI packets (decision 0099 phase 1). +/// The channel's target, possibly the caster (`UpdateFields_1_12_1.h:48`). Public, so another +/// unit's channel renders from it; `MSG_CHANNEL_START`/`UPDATE` reach only the caster. const FIELD_UNIT_CHANNEL_OBJECT: u16 = 20; pub const FIELD_UNIT_HEALTH: u16 = 22; -/// `UNIT_FIELD_POWER1` — first of 5 consecutive power slots (mana, rage, focus, energy, happiness); -/// `MAXPOWER1..5` sit symmetrically after `MAXHEALTH` (vmangos `UpdateFields_1_12_1.h`). +/// Five power slots: mana, rage, focus, energy, happiness; `MAXPOWER1..5` follow `MAXHEALTH`. const FIELD_UNIT_POWER1: u16 = 23; pub const FIELD_UNIT_MAXHEALTH: u16 = 28; const FIELD_UNIT_MAXPOWER1: u16 = 29; @@ -80,188 +55,110 @@ pub const FIELD_UNIT_FACTIONTEMPLATE: u16 = 35; const FIELD_UNIT_BYTES_0: u16 = 36; pub const FIELD_UNIT_BYTES_1: u16 = 138; pub const FIELD_UNIT_FLAGS: u16 = 46; -// The aura block — four parallel arrays, all PUBLIC, so every unit we can see streams its full set -// (vmangos `UpdateFields_1_12_1.h:67-70`, `OBJECT_END(6) + 0x29/0x59/0x5F/0x6B`). wow-re places the -// same arrays in the client at `descriptor + 0xa4` / `+0x164` (= `0x29`/`0x59` × 4 — the same field -// indices, derived independently from the binary: `object-layer/scratch/w2d1.md`, VERIFIED). -// Duration and caster are NOT here and are not in any other field: duration reaches only the aura's -// own target, over `SMSG_UPDATE_AURA_DURATION`; a caster guid never reaches anyone (decision 0255). +// The aura block: four public parallel arrays (`UpdateFields_1_12_1.h:67-70`), which the client +// reads at unit block bytes 0xa4 and 0x164 onward. Duration reaches only the aura's own target, +// over `SMSG_UPDATE_AURA_DURATION`, and no packet carries the caster. pub const FIELD_UNIT_AURA: u16 = 47; /// Nibble-packed, 8 slots per `u32` (`SpellAuraHolder::SetAuraFlag`, `SpellAuras.cpp:7456-7462`). pub const FIELD_UNIT_AURAFLAGS: u16 = 95; -/// Byte-packed, 4 slots per `u32` — the *caster's level* (`SetAuraLevel`, `SpellAuras.cpp:7484`). +/// Byte-packed, 4 slots per `u32`: the caster's level (`SetAuraLevel`, `SpellAuras.cpp:7484`). const FIELD_UNIT_AURALEVELS: u16 = 101; -/// Byte-packed, 4 slots per `u32`, holding `stack - 1` ("field expect count-1 for proper amount -/// show" — `UpdateAuraApplication`, `SpellAuras.cpp:7500-7507`). +/// Byte-packed, 4 slots per `u32`, holding `stack - 1` (`SpellAuras.cpp:7500-7507`). pub const FIELD_UNIT_AURAAPPLICATIONS: u16 = 113; -/// `UNIT_FIELD_AURASTATE` (`OBJECT_END(6) + 0x77` = 125 — vmangos `UpdateFields_1_12_1.h`) — the -/// aura-state bit set the usable walk's CasterAuraState/TargetAuraState legs test with -/// `1 << (state-1)` (wow-re §2a; the client reads its parsed-descriptor copy at -/// `[unit+0x110]+0x1dc`, the update-field identity corroborated by the bit-test semantics + -/// vmangos `Unit::ModifyAuraState`). +/// Aura-state bits, tested as `1 << (state - 1)` by the usable check (client `[unit+0x110]+0x1dc`). const FIELD_UNIT_AURASTATE: u16 = 125; -/// `UNIT_FIELD_BOUNDINGRADIUS` (`OBJECT_END(6) + 0x7B` = 129, right before COMBATREACH — vmangos -/// `UpdateFields_1_12_1.h`) — the unit's horizontal bounding radius (yd). +/// Horizontal bounding radius, in yards. const FIELD_UNIT_BOUNDINGRADIUS: u16 = 129; const FIELD_UNIT_COMBATREACH: u16 = 130; const FIELD_UNIT_BASE_MANA: u16 = 162; const FIELD_UNIT_DISPLAYID: u16 = 131; -/// `UNIT_FIELD_NATIVEDISPLAYID` (`OBJECT_END(6) + 0x7E` = 132, PUBLIC — vmangos -/// `UpdateFields_1_12_1.h:77`; it is the index between DISPLAYID (131) and MOUNTDISPLAYID (133), -/// both of which are independently pinned above). The unit's **unshifted** appearance: the server -/// writes it once at spawn/login and leaves it alone through every transform — a druid form -/// (`Aura::HandleAuraModShapeshift` sets DISPLAYID and restores it *from* this field), a GM -/// `.modify morph`, a polymorph. `UNIT_FIELD_DISPLAYID` is what you see; this is what the unit is. -/// -/// The client reads it for exactly one thing benilla cares about: the **mover collision prism** -/// (`0x60b270` fetches its DBC row at `[unit+0x110]+0x1f8`, while its sibling `0x60ae10` uses -/// `+0x1f4` = DISPLAYID against the same array) — so a shapeshift does *not* resize the collision -/// box (wow-re `mover-collision-scalars.md`, `remote-swim-decision.md` §4; decision 1574). +/// The unshifted appearance, untouched by forms, morphs and polymorph (`UpdateFields_1_12_1.h:77`). +/// The client sizes the mover collision box from it (`0x60b270`), so a shapeshift keeps the box. const FIELD_UNIT_NATIVEDISPLAYID: u16 = 132; -/// `UNIT_FIELD_MOUNTDISPLAYID` (`OBJECT_END(6) + 0x7F` = 133, PUBLIC — vmangos -/// `UpdateFields_1_12_1.h:78`; byte-verified client-side at `[unit+0x110]+0x1fc` = index 0x85, -/// wow-re `rf39-field-index-map.md`/`d1.md`, HARD). A raw `CreatureDisplayInfo` id: the mount the -/// unit is riding, `0`/absent = not mounted. The server writes it on aura-78 apply and zeroes it -/// on every dismount path (`Unit::Mount`/`Unmount`) — this field, not the aura, IS the mounted -/// state (`Unit::IsMounted` reads it back; no flag or bytes field accompanies it). Decision 0441. +/// The ridden mount's `CreatureDisplayInfo` id, 0 when unmounted; this field, not the aura, is the +/// mounted state (`Unit::IsMounted`; client `[unit+0x110]+0x1fc`). pub const FIELD_UNIT_MOUNTDISPLAYID: u16 = 133; -/// `UNIT_FIELD_PETNUMBER` (`OBJECT_END(6) + 0x85` = 139, PUBLIC — vmangos -/// `UpdateFields_1_12_1.h:84`; byte-verified client-side as `[unit+0x110]+0x214` = relative index -/// `0x85`, the second gate inside the rank getter `0x605620` — decision 0782). Non-zero ⇒ this -/// unit is somebody's pet or charm, which is exactly how the -/// client decides a creature has **no** classification (rank forced to 0), so no elite dragon, no -/// ELITE/BOSS tooltip word and no world-boss skull on an enslaved mob. +/// Nonzero on a pet or charm; the rank getter `0x605620` then forces rank 0, so an enslaved mob +/// shows no elite dragon, tooltip rank word or boss skull. const FIELD_UNIT_PETNUMBER: u16 = 139; -/// `UNIT_FIELD_PET_NAME_TIMESTAMP` (`OBJECT_END(6) + 0x86` = 140, PUBLIC — vmangos -/// `UpdateFields_1_12_1.cpp:80`) — the unix second at which this pet's name was last set. -/// -/// It matters because a pet's name does **not** ride its descriptor: it is answered once by -/// `CMSG_PET_NAME_QUERY` and cached by pet NUMBER, so a rename would otherwise stay invisible until -/// relog. The server bumps this on every `SetName` (`Pet.cpp:285`, and `HandlePetRename`'s last -/// line `PetHandler.cpp:344`), which makes a **change** here the cache's one invalidation signal. +/// Unix time of the pet's last rename (`Pet.cpp:285`). The name itself comes from +/// `CMSG_PET_NAME_QUERY`, cached by pet number, so a change here invalidates that cache. const FIELD_UNIT_PET_NAME_TIMESTAMP: u16 = 140; -/// `UNIT_FIELD_PETEXPERIENCE` (idx 141) / `UNIT_FIELD_PETNEXTLEVELEXP` (142) — a hunter pet's XP -/// bar, the `(currXP, nextXP)` pair `GetPetExperience 0x4be840` returns in that order. -/// Byte-verified client-side at `[unit+0x110]+0x21c` / `+0x220` (wow-re -/// `ui/scratch/pet-action-bar-api.md` §11b); the client converts each with `fild qword` off a -/// **zero-extended** dword, so neither can read negative however large it gets. +/// With the next field, the `(currXP, nextXP)` pair `GetPetExperience` (`0x4be840`) returns; the +/// client reads both unsigned. const FIELD_UNIT_PETEXPERIENCE: u16 = 141; const FIELD_UNIT_PETNEXTLEVELEXP: u16 = 142; -/// `UNIT_TRAINING_POINTS` (idx 149) — **two `u16`s packed into one dword**, byte-verified at -/// `[unit+0x110]+0x23c` / `+0x23e`: `GetPetTrainingPoints 0x4be790` pushes the **high** word first -/// and the low word second, which `PetPaperDollFrame.lua` names `totalPoints, spent`. +/// Two `u16`s: `GetPetTrainingPoints` (`0x4be790`) returns the high word first, which +/// `PetPaperDollFrame.lua` names `totalPoints`, then the low word as `spent`. const FIELD_UNIT_TRAINING_POINTS: u16 = 149; pub const FIELD_UNIT_DYNAMIC_FLAGS: u16 = 143; -/// `UNIT_CHANNEL_SPELL` (idx 144, `OBJECT_END(6) + 0x8A`, PUBLIC — VERIFIED vmangos -/// `UpdateFields_1_12_1.h:89`) — the spell id a unit is channeling (`0` = not channeling). +/// The spell being channeled, 0 for none (`UpdateFields_1_12_1.h:89`). pub const FIELD_UNIT_CHANNEL_SPELL: u16 = 144; -/// `UNIT_CREATED_BY_SPELL` (idx 146, `OBJECT_END(6) + 0x8C`, PUBLIC — vmangos -/// `UpdateFields_1_12_1.h`) — the spell that summoned this unit (`0` = not summoned by one). The -/// **first** of the three gates on the reference's feed-pet path `0x6ea1e0`: a unit with no -/// creating spell is not a pet you can feed, whatever else it is. -/// -/// The index is fixed by construction rather than asserted from a header we don't ship: this table -/// already pins both ends of a contiguous run — `PETEXPERIENCE` 141, `PETNEXTLEVELEXP` 142, -/// `DYNAMIC_FLAGS` 143, `CHANNEL_SPELL` 144, `MOD_CAST_SPEED` 145, then this, then `NPC_FLAGS` 147. +/// The summoning spell, 0 for none; the first gate of the client's feed-pet path `0x6ea1e0`. const FIELD_UNIT_CREATED_BY_SPELL: u16 = 146; pub const FIELD_UNIT_NPC_FLAGS: u16 = 147; -/// `UNIT_NPC_EMOTESTATE` (`OBJECT_END + 0x8E`; PUBLIC — VERIFIED vmangos `UpdateFields_1_12_1.h:93`) -/// — the unit's looping **state emote** as an `Emotes.dbc` id (dance, NPC cooking/working flavor). -/// For a player it's the server echo of a state-class text emote (`HandleEmote`, -/// `ChatHandler.cpp:738`); `0` = none. +/// The looping state emote, an `Emotes.dbc` id or 0; on a player, the server's echo of a +/// state-class emote (`ChatHandler.cpp:738`). const FIELD_UNIT_NPC_EMOTESTATE: u16 = 148; -/// `UNIT_VIRTUAL_ITEM_SLOT_DISPLAY` (`OBJECT_END(6) + 0x1F`) — 3 consecutive dwords, one per -/// `WeaponAttackType` slot (0 mainhand / 1 offhand / 2 ranged): a creature's virtual-weapon -/// **display id**, written straight from `creature_equip_template`'s item's `DisplayInfoID` -/// (VERIFIED vmangos `Creature.cpp` `Creature::SetVirtualItem`, `Creature.cpp:4158-4181`) — there's -/// no item guid behind it, purely a render hint the client never resolves through the item system. +/// A creature's weapon display ids (main hand, off hand, ranged), with no item behind them +/// (`Creature.cpp:4158-4181`). const FIELD_UNIT_VIRTUAL_ITEM_SLOT_DISPLAY: u16 = 37; -/// `UNIT_VIRTUAL_ITEM_INFO` (`OBJECT_END + 0x22`) — 6 dwords, 2 per slot. Dword 0 of a slot's pair -/// packs `(class, subclass, material, inventoryType)` bytes (VERIFIED vmangos -/// `CreatureDefines.h:621-627` `VirtualItemInfoByteOffset`); dword 1's byte 0 is the slot's Sheath -/// (`CreatureDefines.h:628` `VIRTUAL_ITEM_INFO_1_OFFSET_SHEATH`), the rest of that dword unused. +/// Two dwords per weapon slot: class, subclass, material and inventory type bytes, then the +/// sheath byte (`CreatureDefines.h:621-628`). const FIELD_UNIT_VIRTUAL_ITEM_INFO: u16 = 40; -/// `UNIT_FIELD_BYTES_2` (`OBJECT_END + 0x9E`; PUBLIC) — byte 0 is the sheath state (VERIFIED vmangos -/// `UnitDefines.h:93` `UNIT_BYTES_2_OFFSET_SHEATH_STATE = 0`, written by `Unit::SetSheath`; for a -/// player it's purely client-volunteered via `CMSG_SETSHEATHED` — the server has no other way to -/// know a weapon is drawn). **Not** [`FIELD_PLAYER_BYTES_2`] (194, facialHair) — an unrelated, -/// differently-indexed field; this UNIT-block index was cross-checked clean of the a783a5b -/// PLAYER-block hex-comment drift (vmangos's own enum arithmetic and its stale hex comment agree -/// here: `UNIT_END + 0x9E` both ways). +/// Byte 0 is the sheath state (`UnitDefines.h:93`); a player's comes only from `CMSG_SETSHEATHED`. +/// Not `FIELD_PLAYER_BYTES_2`. const FIELD_UNIT_BYTES_2: u16 = 164; -// UNIT combat/stat block (decision 0208 phase 1a — the paper-doll stats pane). VERIFIED against -// vmangos `UpdateFields_1_12_1.h` **enum arithmetic** this session (`OBJECT_END` = 6; unlike the -// CONTAINER-onward block below, this block's own `//0x..` hex comments agree with the arithmetic — -// no staleness here). Every index below chain-locks to the file's already-tested -// `FIELD_PLAYER_INV_SLOT_HEAD` (486) anchor through `UNIT_END` (188) — see the tests. +// UNIT combat and stat block: offset from OBJECT_END, then the wire type. const FIELD_UNIT_BASEATTACKTIME: u16 = 126; // OBJECT_END+0x78; 2 slots [main, offhand], ms, INT const FIELD_UNIT_RANGEDATTACKTIME: u16 = 128; // +0x7A, INT const FIELD_UNIT_MINDAMAGE: u16 = 134; // +0x80, FLOAT const FIELD_UNIT_MAXDAMAGE: u16 = 135; // +0x81, FLOAT const FIELD_UNIT_MINOFFHANDDAMAGE: u16 = 136; // +0x82, FLOAT const FIELD_UNIT_MAXOFFHANDDAMAGE: u16 = 137; // +0x83, FLOAT -const FIELD_UNIT_STAT0: u16 = 150; // +0x90 ×5, INT (`Unit::SetStat`→`SetStatInt32Value`) -const FIELD_UNIT_RESISTANCES: u16 = 155; // +0x95 ×7, INT; [0] = armor (`Unit::SetArmor` writes +0) +const FIELD_UNIT_STAT0: u16 = 150; // +0x90 ×5, INT +const FIELD_UNIT_RESISTANCES: u16 = 155; // +0x95 ×7, INT; [0] = armor const FIELD_UNIT_ATTACK_POWER: u16 = 165; // +0x9F, INT -/// `UNIT_FIELD_ATTACK_POWER_MODS` (166, +0xA0, `TWO_SHORT`) — `(positiveMods, negativeMods)` as -/// **signed 16-bit halves**, lo = positive, hi = negative (VERIFIED vmangos -/// `StatSystem.cpp:335-336`, `Player::UpdateAttackPowerAndDamage`: `SetInt16Value(index_mod, 0, -/// (int16)attackPowerModPositive)` / `SetInt16Value(index_mod, 1, (int16)attackPowerModNegative)`; -/// `Object::SetUInt16Value`'s `offset*16` shift confirms offset 0 = the low half). The negative -/// half is stored **already negative-or-zero** — `Unit::HandleAttackPowerModifier`'s -/// `AP_MOD_NEGATIVE_FLAT` branch accumulates `apply ? amount : -amount`, and its caller -/// (`SpellAuras.cpp:5116`) only routes there when `!IsPositive()`, i.e. `amount` is already ≤ 0. +/// +0xA0, `TWO_SHORT`: signed halves, the low positive, the high already negative or zero +/// (`StatSystem.cpp:335-336`). const FIELD_UNIT_ATTACK_POWER_MODS: u16 = 166; -const FIELD_UNIT_ATTACK_POWER_MULTIPLIER: u16 = 167; // +0xA1, FLOAT: (multiplier − 1.0), StatSystem.cpp:339-340 +const FIELD_UNIT_ATTACK_POWER_MULTIPLIER: u16 = 167; // +0xA1, FLOAT: multiplier − 1.0 const FIELD_UNIT_RANGED_ATTACK_POWER: u16 = 168; // +0xA2, INT -const FIELD_UNIT_RANGED_ATTACK_POWER_MODS: u16 = 169; // +0xA3, same TWO_SHORT packing as melee above +const FIELD_UNIT_RANGED_ATTACK_POWER_MODS: u16 = 169; // +0xA3, TWO_SHORT as above const FIELD_UNIT_RANGED_ATTACK_POWER_MULTIPLIER: u16 = 170; // +0xA4, FLOAT const FIELD_UNIT_MINRANGEDDAMAGE: u16 = 171; // +0xA5, FLOAT const FIELD_UNIT_MAXRANGEDDAMAGE: u16 = 172; // +0xA6, FLOAT /// Aura slots per unit (vmangos `MAX_AURAS`, `SpellAuraDefines.h:25`). pub const UNIT_AURA_SLOTS: u8 = 48; -/// Slots `0..UNIT_AURA_POSITIVE_SLOTS` hold **buffs**; the rest hold **debuffs** (vmangos -/// `MAX_POSITIVE_AURAS`, `SpellAuraDefines.h:26`). The server picks the lowest free slot in the -/// matching half (`SpellAuraHolder::_AddSpellAuraHolder`, `SpellAuras.cpp:6715-6736`), and assigns -/// no slot at all to an aura that fails `IsNeedVisibleSlot` — so **passives never reach the wire** -/// and a client renders the array as-is, with no filtering of its own. +/// Slots below this hold buffs, the rest debuffs (`SpellAuraDefines.h:26`). Passives get no slot, +/// so the client renders the array unfiltered (`SpellAuras.cpp:6715-6736`). pub const UNIT_AURA_POSITIVE_SLOTS: u8 = 32; -/// `AFLAG_CANCELABLE` (vmangos `SpellAuraDefines.h:31`) — the right-click gate. The server sets it -/// iff the aura is positive and its spell lacks `SPELL_ATTR_NO_AURA_CANCEL` (`SpellAuras.cpp:7467`), -/// which is exactly the condition `CMSG_CANCEL_AURA`'s handler re-checks, so honouring this bit is -/// enough — no `Spell.dbc` lookup needed to know whether a buff can be clicked off. +/// `AFLAG_CANCELABLE`: set on a positive aura without `SPELL_ATTR_NO_AURA_CANCEL`, the same test +/// the `CMSG_CANCEL_AURA` handler makes (`SpellAuras.cpp:7467`). pub const AURA_FLAG_CANCELABLE: u8 = 0x01; -/// `AFLAG_EFF_INDEX_0|1|2` — which of the spell's three effects this holder actually applied. An -/// occupied slot always has at least one set, which is why the real client uses `flags & 0x0E` as -/// its liveness test (wow-re `object-layer/scratch/w2d1.md`), and why [`ObjectFields::unit_aura`] -/// gates on it too: a slot the server clears can keep a **stale spell id** after its flags nibble -/// goes to zero, so `flags & 0x0E` — not "has a spell id" — is what tells a live aura from a husk. -/// Gating on the id alone surfaces the husk as a phantom buff the real client never shows. +/// `AFLAG_EFF_INDEX_0|1|2`, the client's aura liveness test: a cleared slot can keep a stale spell +/// id, so a live aura is one with any of these bits set. pub const AURA_FLAG_EFF_INDEX_MASK: u8 = 0x0E; -/// One **occupied** aura slot, decoded from the four parallel `UNIT_FIELD_AURA*` arrays. Everything -/// the 1.12 wire says about a resident aura is here; note what is missing and cannot be added — the -/// **remaining duration** (self-only, and it arrives out-of-band on `SMSG_UPDATE_AURA_DURATION`) and -/// the **caster** (which no packet ever associates with a slot). Decision 0255. +/// One occupied aura slot, from the four `UNIT_FIELD_AURA*` arrays; duration and caster are not +/// on the descriptor. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct UnitAuraSlot { - /// The slot index it occupies, `0..UNIT_AURA_SLOTS`. Also its buff/debuff class, and the key - /// `SMSG_UPDATE_AURA_DURATION` uses. + /// Also the buff or debuff class, and the key `SMSG_UPDATE_AURA_DURATION` uses. pub slot: u8, - /// The `Spell.dbc` id. pub spell_id: u32, - /// The raw `UNIT_FIELD_AURAFLAGS` nibble for this slot. + /// The raw `UNIT_FIELD_AURAFLAGS` nibble. pub flags: u8, - /// The **caster's** level at apply time — the only thing the wire says about who cast this. + /// The caster's level at apply time, the only trace of the caster. pub level: u8, - /// Stack count, ≥ 1 (the wire byte holds `stack - 1`). + /// At least 1; the wire byte holds `stack - 1`. pub stacks: u8, } impl UnitAuraSlot { - /// A buff (slot in the positive half) rather than a debuff. + /// A buff: the slot is in the positive half. pub fn is_helpful(&self) -> bool { self.slot < UNIT_AURA_POSITIVE_SLOTS } @@ -271,35 +168,20 @@ impl UnitAuraSlot { } } -// PLAYER descriptor fields — player customization (RF-0056, byte-verified against the client's own -// appearance decode `0x5fb200` + corpse cross-check). The PLAYER block starts at field 188. +// PLAYER fields start at UNIT_END (188); the client decodes appearance bytes at `0x5fb200`. const FIELD_PLAYER_BYTES: u16 = 193; const FIELD_PLAYER_BYTES_2: u16 = 194; -// PLAYER_BYTES_3 = UNIT_END(188) + 0x7: the low u16 packs `gender | (drunk & 0xFFFE)` (vmangos -// `Player::SetDrunkValue`), so byte 1 is the drunk value's high byte — exactly the byte the -// reference client reads at `[[unit+0xe68]+0x1d]` (wow-re `drunk_fraction_5e2a90`). -// Byte 2 = the city-protector title (a race id), byte 3 = the **current** honor rank -// (`PLAYER_BYTES_3_OFFSET_HONOR_RANK`, vmangos `Player.h:351-356`). -// -// **PUBLIC** (VERIFIED vmangos `UpdateFields_1_12_1.h:125`) — and that flag is the whole reason a -// foreign player's rank is knowable: this is the ONE honor value that streams for every visible -// player. The rest of the honor block (1250..1260 below, plus `PLAYER_FIELD_BYTES`' highest-rank -// byte) is PRIVATE, which is why anyone else's honor stats have to be *asked* for over the wire -// (`MSG_INSPECT_HONOR_STATS`, [`crate::messages::InspectHonorStats`]). +// The low u16 is `gender | (drunk & 0xFFFE)`, so byte 1 is the drunk level the client reads at +// `[[unit+0xe68]+0x1d]`; byte 2 is the city-protector race, byte 3 the current honor rank +// (`Player.h:351-356`). Public: the one honor value that streams for every visible player. const FIELD_PLAYER_BYTES_3: u16 = 195; -// PLAYER_QUEST_LOG_1_1 = UNIT_END(188) + 0xA = 198 (vmangos `UpdateFields_1_12_1.h:128`; this block -// precedes the CONTAINER-onward hex-comment drift — enum arithmetic and hex comment agree here, and -// decision 0088 §4 live-verified an accepted quest id landing on 198 + 3·slot). 20 slots -// (MAX_QUEST_LOG_SIZE, vmangos `QuestDef.h:34`) × 3 fields (MAX_QUEST_OFFSET, `Player.h:439-444`): -// +0 quest id, +1 packed objective counters + state byte, +2 timer. The `_1` id field is GROUP_ONLY -// and the `_2` pair PRIVATE — both always stream for our own player, the exact consumer (the log). +// 20 slots of 3 fields (`Player.h:439-444`): the quest id (group-only), then the counters and +// state byte, and the timer (both private). pub const FIELD_PLAYER_QUEST_LOG_1_1: u16 = 198; -/// The descriptor indices a field watch names (decision 2297) — the very constants the accessors -/// above read through, re-exported under one roof so a watch and the accessor it mirrors cannot -/// disagree on a number. Anything watched lives here; anything only read stays private. +/// The indices a field watch names: the same constants the accessors read, so the two agree. pub mod field { - /// The one bit a watcher tests on a raw `UNIT_DYNAMIC_FLAGS` edge (the feign-death set edge). + /// The bit a watcher tests on a raw `UNIT_DYNAMIC_FLAGS` edge (feign death). pub use super::unit::UNIT_DYNFLAG_DEAD; pub use super::{ FIELD_CORPSE_DYNAMIC_FLAGS, FIELD_GAMEOBJECT_STATE, FIELD_PLAYER_FIELD_COINAGE, @@ -311,108 +193,56 @@ pub mod field { }; } -/// The number of `PLAYER_QUEST_LOG` slots (vmangos `MAX_QUEST_LOG_SIZE`, `QuestDef.h:34`). +/// `MAX_QUEST_LOG_SIZE` (`QuestDef.h:34`). pub const PLAYER_QUEST_LOG_SLOTS: u8 = 20; -/// One decoded `PLAYER_QUEST_LOG` slot — the descriptor's durable quest-log state (the -/// `SMSG_QUESTUPDATE_*` packets are the transient "toast" lines; this is what survives). +/// One `PLAYER_QUEST_LOG` slot, the durable quest state; `SMSG_QUESTUPDATE_*` are only toasts. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct QuestLogSlot { - /// The quest id occupying the slot (`0` = the server cleared it — an empty slot). + /// 0 for an empty slot. pub quest_id: u32, - /// The four 6-bit kill/cast/interact objective counters (counter `i` at bits `6i..6i+6` of the - /// count-state field — vmangos `Player.h:1100-1106` `SetQuestSlotCounter`). Item-objective - /// progress is *not* here; the client counts bag items itself. + /// Four 6-bit kill, cast and interact counters at bits `6i..6i+6` (`Player.h:1100-1106`); the + /// client counts item objectives from the bags itself. pub counters: [u8; 4], - /// The state byte (byte 3 of the count-state field — `Player.h:1107` `SetQuestSlotState`): - /// [`quest_slot_state::COMPLETE`] / [`quest_slot_state::FAIL`], `0` = in progress. + /// Byte 3 of the counter field (`Player.h:1107`): a [`quest_slot_state`] bit, 0 in progress. pub state: u8, - /// The timer field — the absolute end time for a timed quest, else `0`. + /// The absolute end time of a timed quest, else 0. pub timer: u32, } -/// `QUEST_STATE_*` (vmangos `Player.h:447-451`) — [`QuestLogSlot::state`] bit values. +/// `QUEST_STATE_*` bits of [`QuestLogSlot::state`] (`Player.h:447-451`). pub mod quest_slot_state { pub const COMPLETE: u8 = 0x01; pub const FAIL: u8 = 0x02; } -// ITEM/CONTAINER/PLAYER inventory fields (VERIFIED vmangos `UpdateFields_1_12_1.h` **enum -// arithmetic** — never its hex comments; see below) — the T2 container groundwork. The PLAYER -// slot arrays are PRIVATE-flagged: the server sends them only for our own player, which is -// exactly the consumer (our bags). Each slot is a 2-field guid. -const FIELD_ITEM_STACK_COUNT: u16 = 14; // OBJECT_END(6) + 0x8 (the ITEM-block comments are still true) -const FIELD_ITEM_ENCHANTMENT: u16 = 22; // OBJECT_END + 0x10, Size 21 = 7 slots × 3 (id/duration/charges) -const FIELD_CONTAINER_NUM_SLOTS: u16 = 48; // ITEM_END = 6 + 0x2A (comment says 42 — stale, 6 low) +// Inventory fields. The PLAYER slot arrays are private, and each slot is a 2-field guid. +const FIELD_ITEM_STACK_COUNT: u16 = 14; // OBJECT_END + 0x8 +const FIELD_ITEM_ENCHANTMENT: u16 = 22; // OBJECT_END + 0x10; 7 slots × 3 (id, duration, charges) +const FIELD_CONTAINER_NUM_SLOTS: u16 = 48; // ITEM_END = 6 + 0x2A const FIELD_CONTAINER_SLOT_1: u16 = 50; // ITEM_END + 0x2; 36 slots × 2 -// CORRECTED 2026-07-03: vmangos's UpdateFields_1_12_1.h hex COMMENTS go stale from the CONTAINER -// block on (6 low vs its own enum arithmetic, which is what the server compiles): INV_SLOT_HEAD = -// UNIT_END(0xBC) + 0x12A = 0x1E6 = 486, not the commented 0x1E0. VERIFIED against a live -// descriptor dump: with these bases One's shirt/legs/feet/mainhand/offhand/bags land on exactly -// the slots the character visibly wears (visible-item cross-check below). +// From the CONTAINER block on, the hex comments in vmangos `UpdateFields_1_12_1.h` run 6 low. The +// indices here follow its enum arithmetic, which the server compiles: INV_SLOT_HEAD = 188 + 0x12A. const FIELD_PLAYER_VISIBLE_ITEM_1_CREATOR: u16 = 258; // UNIT_END + 0x46; 12 fields per slot -pub const FIELD_PLAYER_INV_SLOT_HEAD: u16 = 486; // 23 slots × 2 (equipment 0–18, bag bags 19–22) +pub const FIELD_PLAYER_INV_SLOT_HEAD: u16 = 486; // 23 slots × 2 (equipment 0–18, bags 19–22) const FIELD_PLAYER_PACK_SLOT_1: u16 = 532; // 16 slots × 2 (the backpack) const FIELD_PLAYER_BANK_SLOT_1: u16 = 564; // 24 slots × 2 const FIELD_PLAYER_BANK_BAG_SLOT_1: u16 = 612; // 564 + 24×2; 6 bag slots × 2 (item guids) - // The buyback trio (VERIFIED two ways: the slot array is chain-locked between the tested BANK - // anchor and the tested XP anchor — 564 +48 bank = 612 bankbags, +12 = 624 buyback, +24 keyring - // 648, +64 farsight 712, +2 combo 714, +2 = XP 716 ✓ — and price/timestamp are wow-re byte - // anchors (ui/scratch/buyback-data-path.md: playerBlock +0x1038/+0x1068) rebased through the - // tested COINAGE anchor: block base = 1176 − 0xf70/4 = 188 → 188+1038 = 1226, 188+1050 = 1238). const FIELD_PLAYER_VENDORBUYBACK_SLOT_1: u16 = 624; // 12 slots × 2 (item guids) - // PLAYER_FIELD_KEYRING_SLOT_1 — the next link of the same chain the buyback comment walks - // (624 + 12×2 = 648; +32×2 = 712 = FARSIGHT ✓, which the chain's far end already pins). const FIELD_PLAYER_KEYRING_SLOT_1: u16 = 648; // 32 slots × 2 (item guids), wire slots 81–112 - // PLAYER_FARSIGHT — the object our view is anchored to (Mind Vision, Sentry Totem, the Ornate - // Spyglass), or 0 for "my own body". A 2-field guid, PRIVATE, so it only ever arrives on our own - // descriptor — exactly the consumer. Its index needs no fresh derivation: it is the next link of - // the same chain the two comments above walk, and both ends of that chain are tested anchors - // (648 + 32×2 = 712; +2 combo 714, +2 = XP 716 ✓). vmangos's hex comment reads 0x2C2 = 706 — the - // same 6-low drift as COINAGE/XP, and not what the server compiles: its enum arithmetic says - // UNIT_END(0xBC = 188) + 0x20C(524) = 712. -const FIELD_PLAYER_FARSIGHT: u16 = 712; +const FIELD_PLAYER_FARSIGHT: u16 = 712; // our view's anchor (Mind Vision, Sentry Totem), or 0 const FIELD_PLAYER_BUYBACK_PRICE_1: u16 = 1226; // 12 × u32 copper, indexed slot−69 -const FIELD_PLAYER_BUYBACK_TIMESTAMP_1: u16 = 1238; // 12 × u32 — the client's sort key only - // PLAYER_FIELD_COINAGE, our purse in copper (INT, PRIVATE — sent only for our own player, the exact - // consumer). Same comment drift as the slot arrays above: from the CONTAINER block on, vmangos's - // UpdateFields_1_12_1.h hex comments run 6 low vs its own enum arithmetic (which is what the server - // compiles). Derived by that arithmetic: COINAGE = UNIT_END(0xBC = 188) + 0x3DC(988) = 1176; the - // stale hex comment reads 0x492 = 1170 (decision 0081 quoted "field 1170" from it — 6 low). - // Cross-checked against the corrected neighbours: 486 (INV_SLOT_HEAD) + (0x3DC − 0x12A = 690) = 1176; - // 564 (BANK_SLOT_1) + (0x3DC − 0x178 = 612) = 1176. Both agree. -pub const FIELD_PLAYER_FIELD_COINAGE: u16 = 1176; -// PLAYER_XP / PLAYER_NEXT_LEVEL_XP (INT, PRIVATE — sent only for our own player, the exact consumer: -// the MainMenuBar XP bar). Derived by the same server enum arithmetic as COINAGE above: -// PLAYER_XP = UNIT_END(0xBC = 188) + 0x210(528) = 716; PLAYER_NEXT_LEVEL_XP = 717. Cross-checked -// against COINAGE: 1176 − 716 = 460 = (0x3DC − 0x210). (vmangos UpdateFields_1_12_1.h lists them at -// UNIT_END + 0x210 / 0x211; its stale hex comments read 0x2C6/0x2C7, the same 6-low drift as COINAGE.) +const FIELD_PLAYER_BUYBACK_TIMESTAMP_1: u16 = 1238; // 12 × u32, the client's sort key only +pub const FIELD_PLAYER_FIELD_COINAGE: u16 = 1176; // copper const FIELD_PLAYER_XP: u16 = 716; const FIELD_PLAYER_NEXT_LEVEL_XP: u16 = 717; -// PLAYER_FIELD_WATCHED_FACTION_INDEX (INT, PRIVATE — self only): the reputation-list slot whose bar -// rides the main menu bar, or -1 for none. SIGNED, and that matters — slot 0 is a real faction (the -// Bloodsail Buccaneers), so -1 is the only "watch nothing" (vmangos writes it with `SetInt32Value` -// in `HandleSetWatchedFactionOpcode`). Derived by the same server enum arithmetic as COINAGE above: -// UNIT_END(0xBC = 188) + 0x431(1073) = 1261. Cross-checked against the tested COINAGE anchor: -// 1176 + (0x431 − 0x3DC = 85) = 1261 ✓. (vmangos UpdateFields_1_12_1.h's stale hex comment reads -// 0x4E7 = 1255, the usual 6-low drift.) +// The watched reputation slot, signed: slot 0 is a real faction, so only -1 means none. const FIELD_PLAYER_WATCHED_FACTION_INDEX: u16 = 1261; -// PLAYER_REST_STATE_EXPERIENCE (INT, PRIVATE — self only): the rested-XP pool, in BASE kill-XP -// units — the server drains it 1:1 against a kill's base XP while granting +100% -// (vmangos `Player::GetXPRestBonus`), and caps it at NEXT_LEVEL_XP × 1.5 / 2 (`SetRestBonus`), so -// the on-screen doubled-XP span is 2× this field (the exhaustion tick's math; decision 1082). -// Derived by the same server enum arithmetic as COINAGE above: UNIT_END(0xBC = 188) + -// 0x3DB(987) = 1175 — exactly one below the tested COINAGE anchor (1175 + 1 = 1176 ✓). +// The rested pool in base kill-XP units: a kill drains it 1:1 while granting +100% +// (`Player::GetXPRestBonus`), so the doubled span on the XP bar is twice this value. const FIELD_PLAYER_REST_STATE_EXPERIENCE: u16 = 1175; -// PLAYER_EXPLORED_ZONES_1 (BYTES ×64, PRIVATE — self only): the discovery bitset, 2048 bits -// indexed by AreaTable's exploreFlag column; the world map's exploration overlays key off it -// (decision 0203 phase 3). Derived by the same server enum arithmetic as COINAGE/XP above: -// UNIT_END(0xBC = 188) + 0x39B(923) = 1111. Cross-checked against COINAGE: 0x3DC − 0x39B = 65 = -// the 64 bitset slots + PLAYER_REST_STATE_EXPERIENCE between them (1111 + 64 = 1175, then -// 1176 ✓); the stale hex comment reads 0x451 — the usual 6-low drift. -// The four avoidance/crit percentages the spell tooltip's chance-to-X line reads (law §3-CHANCE), -// consecutive and immediately below EXPLORED_ZONES_1: UNIT_END(188) + 0x396..0x399. FLOAT, and the -// header agrees for once. Anchored on EXPLORED_ZONES_1 = UNIT_END + 0x39B = 1111 just below. +// EXPLORED_ZONES_1 is the discovery bitset, 2048 bits indexed by `AreaTable.dbc` exploreFlag. Just +// below it sit block, dodge, parry and crit chance (FLOAT, UNIT_END + 0x396..0x399). const FIELD_PLAYER_BLOCK_PERCENTAGE: u16 = 1106; const FIELD_PLAYER_DODGE_PERCENTAGE: u16 = 1107; const FIELD_PLAYER_PARRY_PERCENTAGE: u16 = 1108; @@ -420,143 +250,47 @@ const FIELD_PLAYER_CRIT_PERCENTAGE: u16 = 1109; const FIELD_PLAYER_EXPLORED_ZONES_1: u16 = 1111; /// The bitset's slot count (`Size: 64` in the server enum). pub const PLAYER_EXPLORED_ZONES_SLOTS: u16 = 64; -// PLAYER stat/skill block (decision 0208 phase 1a). Derived the same way as the buyback trio above: -// `UNIT_END`(188) + vmangos's own symbolic offset, in **decimal** (never the hex `//` comments, stale -// from the CONTAINER block on) — chain-verified against the already-tested COINAGE(1176) and -// BUYBACK_PRICE_1(1226) anchors (see the tests). -// **These four families are INT on the wire, and the server's own storage type is not the wire -// type** (decision 1397). The header's "Type: INT" tag is right and the earlier "FLOAT" reading -// here was wrong: it was taken from vmangos's *internal* accessors (`Player.h:1505,1511-1514` -// really do route through `ApplyModSignedFloatValue`/`GetFloatValue`), which is one layer below -// the question. `Object::BuildValuesUpdate` (`Objects/Object.cpp`) carries an explicit special -// case — comment *"there are some float values which may be negative or can't get negative due to -// other checks"* — that sends exactly `NEGSTAT0..4`, `POSSTAT0..4`, -// `RESISTANCEBUFFMODSPOSITIVE+0..6` and `…NEGATIVE+0..6` as `*data << uint32(m_floatValues[index])`, -// the same float→int narrowing it already applies to `UNIT_FIELD_BASEATTACKTIME`. Live-confirmed on -// a level-60 warrior in preraid-BiS gear: fields 1177..1181 arrive as `105, 76, 68, 4, 4` (the -// gear's own +105 str / +76 agi / …), and 1215 `MOD_DAMAGE_DONE_PCT` in the same packet arrives as -// `0x3F800000` — genuine f32 — so the two encodings sit side by side in one block. -// -// Read **signed**: the server's cast of a negative float to `uint32` is UB, and the two -// architectures disagree. On x86 it wraps to the two's-complement word; on aarch64 `fcvtzu` -// saturates and a stat *debuff* arrives as a flat `0` (measured on this deploy's arm64 container: -// a −5 agility debuff moved `UNIT_FIELD_STAT1` but sent `NEGSTAT1 = 0`). `i32` is right for both. +// PLAYER stat block. POSSTAT, NEGSTAT and the resistance buff mods are floats in vmangos but go out +// as INT (`Object::BuildValuesUpdate` narrows them). Read them signed: the server's cast of a +// negative float wraps on x86 and saturates to 0 on aarch64. const FIELD_PLAYER_POSSTAT0: u16 = 1177; // UNIT_END+0x3DD ×5, INT const FIELD_PLAYER_NEGSTAT0: u16 = 1182; // ×5, INT; negative-or-zero where the wire can carry it const FIELD_PLAYER_RESISTANCEBUFFMODSPOSITIVE: u16 = 1187; // ×7, INT const FIELD_PLAYER_RESISTANCEBUFFMODSNEGATIVE: u16 = 1194; // ×7, INT; negative-or-zero -const FIELD_PLAYER_MOD_DAMAGE_DONE_POS: u16 = 1201; // ×7 schools ([0]=physical), INT (VERIFIED - // `StatSystem.cpp:87` `SetStatInt32Value` for the magic - // schools, `SpellAuras.cpp:5208-5210` - // `ApplyModUInt32Value` for school 0/physical). -const FIELD_PLAYER_MOD_DAMAGE_DONE_NEG: u16 = 1208; // ×7, INT; negative-or-zero (VERIFIED - // `SpellAuras.cpp:5211-5213,5244-5247` - // `ApplyModInt32Value(..., m_modifier.m_amount, apply)` - // with an already-negative `m_amount` on the debuff branch). -const FIELD_PLAYER_MOD_DAMAGE_DONE_PCT: u16 = 1215; // ×7, **FLOAT despite the header's "Type: INT" - // tag** (VERIFIED vmangos `Player.cpp:3336` - // `SetFloatValue(PLAYER_FIELD_MOD_DAMAGE_DONE_PCT + i, - // 1.00f)` init and `Player.cpp:7274` - // `SetFloatValue(PLAYER_FIELD_MOD_DAMAGE_DONE_PCT + - // school, multiplier)` — decision 0208's flagged - // question, now closed: a true float, default 1.0. - // UNIT_END+0x212, ×384 (128 skills × 3 dwords) — see [`PlayerSkillSlot`] for the verified - // packing. `pub` for fixtures that seed a skill block through [`ObjectFields::from_pairs`] - // (the lock resolver's skill-term tests); live reads go through [`ObjectFields::player_skill`]. -pub const FIELD_PLAYER_SKILL_INFO_1_1: u16 = 718; +const FIELD_PLAYER_MOD_DAMAGE_DONE_POS: u16 = 1201; // ×7 schools ([0] physical), INT +const FIELD_PLAYER_MOD_DAMAGE_DONE_NEG: u16 = 1208; // ×7, INT; negative-or-zero +const FIELD_PLAYER_MOD_DAMAGE_DONE_PCT: u16 = 1215; // ×7, FLOAT (header says INT), default 1.0 + // (`Player.cpp:3336`, `Player.cpp:7274`) +pub const FIELD_PLAYER_SKILL_INFO_1_1: u16 = 718; // ×384: 128 skills × 3 dwords -// PLAYER_CHARACTER_POINTS1/2 (INT, PRIVATE — self only): unspent talent points / free primary -// professions — the reference's `UnitCharacterPoints("player")` pair (decision 0304). Derived by -// the same server enum arithmetic as the anchors above: UNIT_END(188) + 0x392(914) = 1102 and -// +0x393 = 1103 — exactly one past the skill array (718 + 384 = 1102 ✓, the tests' chain). +// Unspent talent points and free primary professions, `UnitCharacterPoints("player")`. const FIELD_PLAYER_CHARACTER_POINTS1: u16 = 1102; const FIELD_PLAYER_CHARACTER_POINTS2: u16 = 1103; -// PLAYER_TRACK_CREATURES / PLAYER_TRACK_RESOURCES (INT, PRIVATE — self only): the active tracking -// bitmasks the minimap dot classifier tests — bit `1 << (n − 1)` where `n` is a creature type -// (TRACK_CREATURES) or a `LockType.dbc` id (TRACK_RESOURCES); the server sets one bit per active -// tracking aura from the aura's MiscValue (vmangos `SpellAuras.cpp` `HandleAuraTrackCreatures`/ -// `HandleAuraTrackResources`, `1 << (m_miscvalue − 1)`). Derived by the same server enum -// arithmetic as the anchors above: UNIT_END(188) + 0x394(916) = 1104 and +0x395 = 1105 — -// contiguous past the CHARACTER_POINTS pair (1103 + 1 = 1104 ✓). +// Tracking masks the minimap tests: bit `1 << (n - 1)` for creature type or `LockType.dbc` id `n`, +// one bit per active tracking aura. const FIELD_PLAYER_TRACK_CREATURES: u16 = 1104; const FIELD_PLAYER_TRACK_RESOURCES: u16 = 1105; -const FIELD_PLAYER_AMMO_ID: u16 = 1223; // UNIT_END+0x40B, INT — the equipped ammo item id - // (`Player.cpp:7656` etc., `Player::SetUInt32Value`). - // PLAYER_SELF_RES_SPELL = UNIT_END(188) + 0x40C (INT, **PRIVATE** — self only), the spell id the - // server will cast if we ask to self-resurrect: the whole soulstone/Reincarnation mechanism, on - // the wire as one dword (decision 1746). Contiguous past the ammo id (1223 + 1 = 1224 ✓), and - // cross-checked two ways in vmangos because its two tables disagree: the enum - // `UpdateFields_1_12_1.h:284` gives `UNIT_END + 0x40C` (= 1224) while its trailing *comment* says - // `// 0x4C2` — the comment is stale; the parallel descriptor table `UpdateFields_1_12_1.cpp:277` - // carries the absolute index `0x4C8` = **1224**, agreeing with the arithmetic. +const FIELD_PLAYER_AMMO_ID: u16 = 1223; // UNIT_END+0x40B, INT: the equipped ammo item id const FIELD_PLAYER_SELF_RES_SPELL: u16 = 1224; -// PLAYER_FLAGS = UNIT_END(188) + 0x2 (VERIFIED vmangos `UpdateFields_1_12_1.h:120`; the duel -// arbiter guid takes +0x0/+0x1). Bit 0x10 = PLAYER_FLAGS_GHOST (`Player.h:319`), set/cleared by -// the ghost aura 8326's HandleAuraGhost at release/resurrect (decision 0308 §1). +// Bit 0x10 is PLAYER_FLAGS_GHOST (`Player.h:319`), held by the ghost aura 8326 until resurrection. pub const FIELD_PLAYER_FLAGS: u16 = 190; -// PLAYER_DUEL_ARBITER = UNIT_END(188) + 0x0 (GUID, PUBLIC), PLAYER_DUEL_TEAM = UNIT_END + 0x8 -// (INT, PUBLIC) — VERIFIED vmangos `UpdateFields_1_12_1.h:119,126`. The arbiter opens the player -// block, which is why the real client caches a pointer to that base (`[player+0xe68]`) and indexes -// both off it: `+0x00/+0x04` the arbiter halves, `+0x08` PLAYER_FLAGS, `+0x20` the duel team — the -// three reads `UnitReaction 0x6061e0`'s duel leg makes (decision 0633). +// The client's player block base `[player+0xe68]` is field 188: the arbiter at +0x0, PLAYER_FLAGS +// at +0x8 and the duel team at +0x20, the three reads of `UnitReaction`'s duel leg (`0x6061e0`). const FIELD_PLAYER_DUEL_ARBITER: u16 = 188; const FIELD_PLAYER_DUEL_TEAM: u16 = 196; -// PLAYER_GUILDID = UNIT_END(188) + 0x3, PLAYER_GUILDRANK = UNIT_END + 0x4 — both INT and both -// **PUBLIC** (VERIFIED vmangos `UpdateFields_1_12_1.h:120-121`), the same arithmetic that pins -// PLAYER_FLAGS = 190 at +0x2 above. Independently VERIFIED in the binary off the very player-block -// base the duel pair is read through: `GetGuildInfo 0x4c9330` takes the guild id at `[+0xe68]+0xc` -// (`0x4c93a9`, whose `test ecx,ecx` IS the guildless→nil path) and the rank at `+0x10` (`0x4c93e4`, -// `shl edx,0x6` — the 0x40-byte rank-name slot in the cached guild record). 0xc/4 + 188 = 191 and -// 0x10/4 + 188 = 192, with `+0x8` = PLAYER_FLAGS(190) as the third point on the same line. -// -// PUBLIC is the load-bearing half: these stream for *every visible player*, not just for us, which -// is what makes `GetGuildInfo(unit)` answerable for an arbitrary unit — and why the guild query -// response carries a fixed ten rank names to decode another player's rank against (decision 1257). +// Public, so `GetGuildInfo(unit)` (`0x4c9330`, reading block +0xc and +0x10) answers for any +// visible player; a guild id of 0 is guildless. const FIELD_PLAYER_GUILDID: u16 = 191; const FIELD_PLAYER_GUILDRANK: u16 = 192; -// PLAYER_FIELD_COMBO_TARGET = UNIT_END(188) + 0x20E (GUID, PRIVATE) — VERIFIED vmangos -// `UpdateFields_1_12_1.h:251`, and independently by the binary: `GetComboPoints 0x51a190` reads the -// pair at `[player+0xe68]+0x838/+0x83c`, and 0x838/4 + 188 = 714 (decision 0875). The same player- -// block base that puts the combo-point byte at `+0x1029` (0x1029/4 + 188 = 1222) — two offsets off -// one pointer landing on two independently-known field indices is what pins the base itself. +// `GetComboPoints` (`0x51a190`) reads the target at block +0x838 and the count byte at +0x1029. const FIELD_PLAYER_FIELD_COMBO_TARGET: u16 = 714; -const FIELD_PLAYER_FIELD_BYTES: u16 = 1222; // UNIT_END+0x40A, BYTES — vmangos `PLAYER_FIELD_BYTES` - // (distinct from the appearance `PLAYER_BYTES`(193)): - // flags / comboPoints / actionBars / HIGHEST_HONOR_RANK - // (`Player.h` PLAYER_FIELD_BYTES_OFFSET_*). +const FIELD_PLAYER_FIELD_BYTES: u16 = 1222; // UNIT_END+0x40A: flags, combo points, action bars, + // highest honor rank; not the appearance PLAYER_BYTES -// The **honor block** — the eleven fields the two PvP/honor panes read, a contiguous run -// UNIT_END(188) + 0x426..0x430 (VERIFIED vmangos `UpdateFields_1_12_1.h:288-298` **enum -// arithmetic**; its inline `// 0x4DC`-style comments carry the usual 6-low drift and are not -// copied). The run needs no fresh anchor of its own: it closes against already-tested neighbours -// at BOTH ends — the 12-slot `BUYBACK_TIMESTAMP_1` array ends exactly where it begins -// (1238 + 12 = 1250) and `WATCHED_FACTION_INDEX`, itself chain-locked to the tested COINAGE, sits -// exactly one past its end (1260 + 1 = 1261). Eleven fields is precisely the size of that gap. -// -// **Every one of them is PRIVATE** — the server sends them only on our OWN descriptor — and that -// is the mechanical reason the character window's Honor tab works for the local player and for -// nobody else. A foreign player's honor stats are not on the wire at all until we ask for them -// with `MSG_INSPECT_HONOR_STATS` ([`crate::messages::InspectHonorStats`]); the sole honor value -// that *does* stream for every visible player is the current rank in the PUBLIC -// `PLAYER_BYTES_3`(195) byte 3 above. -// -// Types, in run order — `+0x426..0x429` TWO_SHORT, `+0x42A..0x42F` INT, `+0x430` BYTES: -// -// The four kill counters are TWO_SHORT, `(honorable, dishonorable)` in the low/high halves. Only -// SESSION_KILLS is genuinely written as two halves (vmangos `HonorMgr.cpp:913-914`, -// `SetUInt16Value(…, 0, todayHK)` / `(…, 1, todayDK)`); the other three go out as a whole dword -// (`SetUInt32Value(PLAYER_FIELD_YESTERDAY_KILLS, yesterdayKills)`, `HonorMgr.cpp:917/924/929`), so -// their high half is always 0 from this server. The descriptor type is TWO_SHORT regardless and -// the real client reads halves, so we decode halves — see the accessors in `player.rs`. -// -// The six INT fields are the three honor-point "contribution" totals, the two lifetime kill -// counts, and LAST_WEEK_RANK — which is the weekly **standing** (our ladder position), not a rank. -// All six are `SetUInt32Value` writes from `HonorMgr::Update` (`HonorMgr.cpp:917-935`). Our two -// lifetime names fix vmangos's own typo: the header spells them -// `PLAYER_FIELD_LIFETIME_HONORBALE_KILLS` / `…DISHONORBALE…`. -// -// BYTES2's byte 0 is `HONOR_RANK_BAR` (`PLAYER_FIELD_BYTES_2_OFFSET_HONOR_RANK_BAR`, vmangos -// `Player.h:366-372`); its bytes 1..3 are a flags byte and two unknowns this server never writes. +// The honor block (`UpdateFields_1_12_1.h:288-298`) is private: another player's honor needs +// `MSG_INSPECT_HONOR_STATS`. The four kill counters are TWO_SHORT (honorable, dishonorable), but +// vmangos writes all except SESSION_KILLS as a whole dword. LAST_WEEK_RANK is the weekly standing, +// not a rank. BYTES2 byte 0 is the rank bar (`Player.h:366-372`). const FIELD_PLAYER_FIELD_SESSION_KILLS: u16 = 1250; const FIELD_PLAYER_FIELD_YESTERDAY_KILLS: u16 = 1251; const FIELD_PLAYER_FIELD_LAST_WEEK_KILLS: u16 = 1252; @@ -569,67 +303,27 @@ const FIELD_PLAYER_FIELD_LAST_WEEK_CONTRIBUTION: u16 = 1258; const FIELD_PLAYER_FIELD_LAST_WEEK_RANK: u16 = 1259; const FIELD_PLAYER_FIELD_BYTES2: u16 = 1260; -/// The number of `PLAYER_SKILL_INFO` slots the descriptor reserves (384 fields ÷ 3 per slot; -/// vmangos `UpdateFields_1_12_1.h`). vmangos itself only ever *populates* `PLAYER_MAX_SKILLS` (127, -/// `Player.h:69`) of these — the last reserved slot is never written; `player_skill` honors the -/// full wire capacity so an out-of-range read is a real bug, not a silently-narrowed one. +/// `PLAYER_SKILL_INFO` slots on the wire, 3 fields each; vmangos fills only 127 (`Player.h:69`). pub const PLAYER_SKILL_SLOTS: u8 = 128; -/// One decoded `PLAYER_SKILL_INFO` slot (VERIFIED packing vmangos `Player.cpp:90-99` -/// `PLAYER_SKILL_INDEX`/`_VALUE_INDEX`/`_BONUS_INDEX` + the `MAKE_PAIR32`/`PAIR32_LOPART`/`_HIPART` -/// macros, `shared/Common.h:119-121`, and `Player::SetSkill`, `Player.cpp:5664-5677`): three -/// consecutive dwords per slot — `(id | step<<16)`, `(value | max<<16)`, -/// `(tempBonus | permBonus<<16)`, with the bonus halves **signed** (`SKILL_TEMP_BONUS`/ -/// `SKILL_PERM_BONUS` cast to `int16` — a malus reads negative). +/// One `PLAYER_SKILL_INFO` slot, three dwords (`Player.cpp:90-99`): `id | step << 16`, +/// `value | max << 16`, `temp_bonus | perm_bonus << 16` with signed bonuses. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PlayerSkillSlot { - /// The `SkillLine.dbc` id (`0` = an unused slot). + /// A `SkillLine.dbc` id, 0 for an unused slot. pub skill_id: u16, - /// The skill-tier step (`Player::SetSkill`'s `step` — 0 unless a tier-raising effect is live). + /// The tier step, 0 unless a tier-raising effect is live. pub step: u16, - /// Current skill value. pub value: u16, - /// Max skill value (the level/tier cap). pub max: u16, - /// Temporary bonus (auras/consumables/enchants); signed — a malus reads negative. + /// From auras, consumables and enchants; a malus is negative. pub temp_bonus: i16, - /// Permanent bonus (talents); signed. + /// From talents. pub perm_bonus: i16, } -/// A sparse update-field set: a bitmask of `u32` blocks followed by one `u32` per set bit. We keep -/// the wire's own mask plus a dense value array — not a map — because the ECS-side pollers -/// (animation, equipment, plates) probe thousands of fields **per frame**, so a read must be two -/// array indexings, never a tree walk (decision 1457); the wire's `u8` block count bounds the -/// arrays at 8160 dwords, and a Unit's real footprint is ~800 B. We expose **named** accessors for -/// the descriptor fields consumers read — the raw `get_*(index)` reads stay module-private, so -/// field-index knowledge (a wire fact) never leaves this crate (decision 0061). The ECS mirrors -/// one of these per object and [`Self::merge`]s each `Values` delta into it; the codec itself -/// stays stateless (decision 0006). -/// -/// **Absent-field semantics** hinge on `Self::descriptor_end`: a CREATE block is a *complete* -/// snapshot — the server masks in only non-zero fields (vmangos `Object::_SetCreateBits`: bit set -/// iff `value != 0`), and the real client reads it into a zero-initialized descriptor buffer — so -/// on a create-seeded store an absent field reads `Some(0)`, the truth. A bare `Values` delta -/// carries only *changed* fields; there an absent field means "not touched", so it reads `None` -/// (and a merge must not clobber it). Director-caught: an item at durability 0 streams its create -/// with `MAXDURABILITY` but no `DURABILITY` word, and the sparse `None` sent the tooltip to the -/// template's max/max — 100% on fully broken gear. -/// -/// **…but only within the object's OWN descriptor** (decision 1081). "Absent = 0" is a statement -/// about the *zero-initialized buffer*, and that buffer is exactly [`descriptor_len`] dwords long -/// for this object's type. A creature has no PLAYER block at all, so a PLAYER-block index read off -/// one is not an absent field — it is a question the object cannot answer, and the honest reply is -/// `None`. Conflating the two made every `player_*` accessor answer a confident `0` for any -/// created creature, which is how the pet sheet's damage multiplier came out `0` instead of the -/// unstreamed default `1.0` and the tooltip read `inf - inf` / `nan` (director, 2026-08-07). -/// A corpse's body appearance — the seven `CORPSE_FIELD_BYTES_1`/`_2` bytes the reference's dress -/// `0x5d6260` loads into the corpse's own `CCharacterComponent` (`+0x69..+0x6f`), which are the same -/// seven selectors a living player's body is composited from. Read via -/// [`ObjectFields::corpse_look`]. -/// -/// This is the corpse's OWN snapshot, taken at death: it is not the owner's live `PLAYER_BYTES`, -/// and it survives the owner logging out or being nowhere near. +/// A corpse's own appearance, snapshotted at death: the seven `CORPSE_FIELD_BYTES_1`/`_2` bytes +/// the client's dress `0x5d6260` loads, not the owner's live `PLAYER_BYTES`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CorpseLook { pub race: u8, @@ -641,29 +335,22 @@ pub struct CorpseLook { pub facial_hair: u8, } +/// A sparse update-field set: the wire's block mask and a dense value array. A CREATE carries only +/// nonzero fields (`Object::_SetCreateBits`) into the client's zeroed buffer, so an absent field +/// within the created type's descriptor reads `Some(0)`; in a `Values` delta, or past the +/// descriptor's end, it reads `None`. #[derive(Debug, Clone, Default)] pub struct ObjectFields { - /// Presence bitmask, one bit per field index (`index = word * 32 + bit`) — the wire's block - /// mask, kept verbatim. + /// The wire's block mask, verbatim (`index = word * 32 + bit`). present: Vec, - /// Dense value array, invariant `values.len() == present.len() * 32`; a slot is meaningful - /// iff its `present` bit is set (unset slots hold `0` but must be read through the mask — - /// absent-vs-zero is exactly the distinction the struct doc is about). + /// Always `present.len() * 32` long; an unset slot holds 0 but must be read through the mask. values: Vec, - /// How long this object's descriptor is, in dwords — [`descriptor_len`] of the CREATE block's - /// own `ObjectType` (set by the parser via [`Self::into_created`]). Every index **below** it is - /// known: absent = `0`. `0` marks a bare `Values` delta (or a hand-built fixture), where no - /// index is known and absent = `None`. + /// The created type's [`descriptor_len`], below which absent reads 0; 0 for a delta or fixture. descriptor_end: u16, } -/// The length of an object's descriptor buffer, in dwords — the `*_END` of the innermost block its -/// `ObjectType` names (vmangos `UpdateFields_1_12_1.h`, build 5875, **by enum arithmetic**: the -/// trailing comments in that header's CONTAINER and PLAYER blocks are stale by a constant and are -/// not the numbers here). -/// -/// The type hierarchy is inclusive — a Player's buffer spans OBJECT+UNIT+PLAYER, a Container's -/// OBJECT+ITEM+CONTAINER — so each entry is the outermost end, not a block size. +/// An object's descriptor length in dwords: the `*_END` of its innermost block, so a Player spans +/// OBJECT, UNIT and PLAYER. fn descriptor_len(object_type: ObjectType) -> u16 { match object_type { ObjectType::Object => 6, // OBJECT_END = 0x6 @@ -677,8 +364,7 @@ fn descriptor_len(object_type: ObjectType) -> u16 { } } -/// The widest descriptor's mask-word count: `PLAYER_END` 1282 fields (the [`descriptor_len`] -/// table) is 41 words of 32 bits. A block count past it describes no 1.12 object. +/// Mask words of the widest descriptor, `PLAYER_END`; no 1.12 object needs more. const MAX_MASK_WORDS: usize = 1282usize.div_ceil(32); impl ObjectFields { @@ -703,19 +389,13 @@ impl ObjectFields { }) } - /// Mark this set as a CREATE snapshot of `object_type` (see the struct doc's absent-field - /// semantics) — the parser calls it on a create block's mask with the type off the same block's - /// header; a `Values` delta never gets it. The type is what bounds "absent = 0" to the - /// object's own descriptor. + /// Marks a CREATE snapshot of `object_type`, so absent fields in its descriptor read 0. pub fn into_created(mut self, object_type: ObjectType) -> Self { self.descriptor_end = descriptor_len(object_type); self } - /// Build a field set directly from `(index, value)` pairs — the fixture constructor (unit - /// tests, the capture harness's synthetic self-player, probes). The wire path is - /// [`Self::read`]; live code never builds fields by hand. Chain [`Self::into_created`] when - /// the fixture stands in for a create snapshot. + /// A fixture from `(index, value)` pairs; [`Self::into_created`] makes it a create. pub fn from_pairs(pairs: &[(u16, u32)]) -> Self { let mut this = Self::default(); for &(index, value) in pairs { @@ -724,72 +404,40 @@ impl ObjectFields { this } - /// A 2-field guid: `lo | hi << 32`, present iff the low half is (the wire always sends guid - /// halves together; an absent slot reads `None`, a sent-empty slot reads `Some(0)`). - /// `CORPSE_FIELD_OWNER` (field 6, `OBJECT_END + 0x0`, a 2-field guid — VERIFIED vmangos - /// `UpdateFields_1_12_1.h:339`): the dead player this corpse belongs to. Only meaningful on a - /// TYPEID_CORPSE (7) object; the net bridge uses it to recognize OUR corpse for the reclaim - /// send (decision 0308 §5). `None`/0 when absent. + /// `CORPSE_FIELD_OWNER` (`UpdateFields_1_12_1.h:339`): the dead player, only on a corpse. pub fn corpse_owner(&self) -> Option { self.get_guid(6).filter(|&g| g != 0) } - /// `CORPSE_FIELD_DISPLAY_ID` (field 12, `OBJECT_END + 0x6`): the dead player's body model, as a - /// **`CreatureDisplayInfo` id** — vmangos writes the owner's `GetNativeDisplayId()` straight in - /// (`Player::CreateCorpse`, `Player.cpp:4809`), so it resolves down the very same - /// displayId → CreatureDisplayInfo → CreatureModelData chain a player's living body does - /// (decision 0041). The reference reads exactly this field for the model: `0x5d6700`'s - /// not-bones leg is `[[corpse+0x110]+0x18]` → `[0xc0de90]` (CreatureDisplayInfo) → `[row+4]` - /// → `[0xc0de68]` (CreatureModelData) → `[row+8]` = the path. - /// - /// **Only meaningful while [`Self::corpse_is_bones`] is false.** A bone pile carries the field - /// (the server's corpse→bones conversion copies fields 3.. verbatim, vmangos `Map.cpp:3638`) - /// and the client ignores it: `0x5d6700` branches on the BONES bit *first* and builds the - /// skeleton path from race/sex instead. + /// The body's `CreatureDisplayInfo` id, the owner's native display (`Player.cpp:4809`). A bone + /// pile keeps it, but `0x5d6700` ignores it there and builds the skeleton from race and sex. pub fn corpse_display_id(&self) -> Option { self.get_u32(12).filter(|&d| d != 0) } - /// One `CORPSE_FIELD_ITEM` slot (fields 13..31, `OBJECT_END + 0x7 + slot`, `slot` = the - /// **equipment slot** 0..18): the worn piece as `(ItemDisplayInfo id, InventoryType)` — the - /// server packs `DisplayInfoID | (InventoryType << 24)` (`Player::CreateCorpse`, - /// `Player.cpp:4821`), and the client unpacks the same way (`0x5d6423 and eax,0xffffff` - /// before the `[0xc0dc10]` ItemDisplayInfo index). `None` for an empty slot. - /// - /// Note this is a **display id, already resolved** — unlike `PLAYER_VISIBLE_ITEM`, which - /// carries item *entries* and needs a template round trip. A corpse dresses off the wire alone. + /// The piece in equipment slot `slot` (0..18) as `(ItemDisplayInfo id, InventoryType)`, packed + /// `display | type << 24` (`Player.cpp:4821`); unlike `PLAYER_VISIBLE_ITEM`, not an item entry. pub fn corpse_item(&self, slot: u8) -> Option<(u32, u8)> { let raw = self.get_u32(13 + u16::from(slot))?; let display = raw & 0x00ff_ffff; (display != 0).then_some((display, (raw >> 24) as u8)) } - /// `CORPSE_FIELD_GUILD` (field 34, `OBJECT_END + 0x1C`) — the dead player's guild id, - /// snapshotted at death (vmangos `Player::CreateCorpse` writes `GetGuildId()`). The reference - /// reads exactly this word at `0x5d6edf` (`[[corpse+0x110]+0x70]`) and hands it, with - /// `CORPSE_FIELD_OWNER`, to the guild-name-cache lookup `0x6d6d20` that feeds the corpse's - /// **tabard emblem** install `0x5d6ec0` (wow-re `corpse-decal-and-loot-sparkle.md` §6b). A - /// corpse therefore wears its owner's crest exactly as the living body did. `0` = guildless. + /// The owner's guild id at death, 0 for none; the client builds the corpse's tabard crest from + /// it (`0x5d6ec0`). pub fn corpse_guild(&self) -> u32 { self.get_u32(34).unwrap_or(0) } - /// `CORPSE_FIELD_BYTES_1` (field 32, `OBJECT_END + 0x1A`) — byte 1 **race**, byte 2 **gender**, - /// byte 3 **skinColor** (byte 0 is unused; vmangos writes `(0) | (race<<8) | (gender<<16) | - /// (skin<<24)`, `Corpse.cpp:228`). The reference reads them one byte at a time off the same - /// word — `[[corpse+0x110]+0x69/+0x6a/+0x6b]` in the dress `0x5d6260`. + /// Race, gender and skin in bytes 1..3; byte 0 is unused (`Corpse.cpp:228`). fn corpse_bytes_1(&self) -> Option { self.get_u32(32) } - /// `CORPSE_FIELD_BYTES_2` (field 33, `OBJECT_END + 0x1B`) — byte 0 **face**, byte 1 - /// **hairStyle**, byte 2 **hairColor**, byte 3 **facialHair** (`Corpse.cpp:229`; the reference's - /// `+0x6c..+0x6f`). + /// Face, hair style, hair colour and facial hair (`Corpse.cpp:229`). fn corpse_bytes_2(&self) -> Option { self.get_u32(33) } - /// The corpse's body appearance — `(race, sex, skin, face, hairStyle, hairColor, facialHair)`, - /// the seven bytes `0x5d6260` loads into the corpse's own `CCharacterComponent`. `None` when - /// neither BYTES word is known (a bare `Values` delta). + /// The corpse's appearance, `None` in a delta that lacks either BYTES word. pub fn corpse_look(&self) -> Option { let (b1, b2) = (self.corpse_bytes_1()?, self.corpse_bytes_2()?); Some(CorpseLook { @@ -803,99 +451,59 @@ impl ObjectFields { }) } - /// `CORPSE_FIELD_FLAGS` (field 35, `OBJECT_END + 0x1D`) — the corpse's own flag word, read by - /// the reference as `[[corpse+0x110]+0x74]`. `0` when absent. + /// `CORPSE_FIELD_FLAGS`, 0 when absent (client `[[corpse+0x110]+0x74]`). pub fn corpse_flags(&self) -> u32 { self.get_u32(35).unwrap_or(0) } - /// [`Self::corpse_flags`] **preserving absence** — `None` when this snapshot does not carry - /// field 35 at all. - /// - /// The two are not interchangeable, and the difference is the create-vs-delta seam: a values - /// delta carries only what changed, so an untouched `FLAGS` reads absent, and `unwrap_or(0)` - /// would report every bit *clear* — turning "nothing was said about the flags" into a positive - /// claim that the corpse is not bones. Readers acting on a **change** must use this one. + /// [`Self::corpse_flags`] keeping absence: untouched flags in a delta are `None`, not all + /// clear. A reader acting on a change must use this one. pub fn corpse_flags_present(&self) -> Option { self.get_u32(35) } - /// `CORPSE_FLAG_BONES` (`0x01`) — this object is a **bone pile**, not a fresh body. It is the - /// first thing `0x5d6260` tests (`0x5d6287 test byte [eax+0x74],0x1; jne`): a bone pile builds - /// **no** character component at all, wears nothing, and takes its model from race/sex rather - /// than from [`Self::corpse_display_id`] (`0x5d6700`). Named in vmangos `Corpse.h:45`; set on - /// the converted object at `Map.cpp:3644`. + /// `CORPSE_FLAG_BONES`: a bone pile, which `0x5d6260` tests first; it wears nothing and takes + /// its model from race and sex. pub fn corpse_is_bones(&self) -> bool { self.corpse_flags() & 0x01 != 0 } - /// `CORPSE_FLAG_HIDE_HELM` (`0x08`) — the owner's helm preference, snapshotted at death - /// (vmangos copies `PLAYER_FLAGS_HIDE_HELM` in, `Player.cpp:4801`). The corpse honours it by - /// **never installing equipment slot 0** (`0x5d6465 test byte [edx+0x74],0x8` → the skip), - /// which is its own bit on its own field — *not* `PLAYER_FLAGS`, and the opposite instruction - /// polarity to the player lane (wow-re `helm-cloak-hide.md` §2b). + /// `CORPSE_FLAG_HIDE_HELM`, the owner's setting at death; the client skips slot 0 (`0x5d6465`). pub fn corpse_hides_helm(&self) -> bool { self.corpse_flags() & 0x08 != 0 } - /// `CORPSE_FLAG_HIDE_CLOAK` (`0x10`) — the same, for equipment slot 14 (`0x5d6470`). + /// `CORPSE_FLAG_HIDE_CLOAK`: the same for slot 14 (`0x5d6470`). pub fn corpse_hides_cloak(&self) -> bool { self.corpse_flags() & 0x10 != 0 } - /// `CORPSE_FIELD_DYNAMIC_FLAGS` (field 36, `OBJECT_END + 0x1E`) bit 0 — this bone pile carries - /// **lootable insignia** (`CORPSE_DYNFLAG_LOOTABLE`, vmangos `Map.cpp:3660`). The reference's - /// `0x5d6e20` is literally `return *(+0x110)+0x78 & 1`, and it gates both the corpse highlight - /// emitter (`0x5d6e30`/`0x5d6e80`, armed at build and re-armed by the field's own mirror - /// handler) and the `CMSG_LOOT` route off the corpse's interact slot (wow-re - /// `loot-anim-leg.md`). + /// `CORPSE_DYNFLAG_LOOTABLE` (`Map.cpp:3660`): the bone pile has insignia; `0x5d6e20` gates the + /// loot highlight and the `CMSG_LOOT` click on it. pub fn corpse_lootable(&self) -> bool { self.get_u32(FIELD_CORPSE_DYNAMIC_FLAGS).unwrap_or(0) & 0x01 != 0 } - /// `CORPSE_FLAG_LOOTABLE` (`0x20`, [`Self::corpse_flags`] bit 5) — the flag that makes a body's - /// **battleground insignia** takeable. It gates the second leg of the corpse cursor classifier - /// `0x482740` (`[[corpse+0x110]+0x74] >> 5 & 1`), and the corpse right-click `0x5d6bf0` reads - /// it again at `0x5d6c9c` for the matching send. - /// - /// **Neither of `w2a.md`'s two labels for this bit was right**, and it took a §5 round to say - /// so (wow-re `corpse-click-and-reclaim.md`; decision 1729). "Reclaimable" (its line 264) is - /// wrong outright — nothing in the reclaim path consults it, and `RetrieveCorpse` reads no - /// corpse flag at all. "PvP flag" (its line 618) names the wrong bit: the corpse's actual PvP - /// flag is **bit 2**, read by `UnitIsPVP 0x516460` at `0x5164e6` (`shr eax,2; and al,1`), and - /// it is the only corpse flag this client exposes to Lua. - /// - /// What bit 5 actually unlocks is spell **22027 "Remove Insignia"** — the sole row in the - /// shipped `Spell.dbc` with `Effect[0] == 116`, targeting `TARGET_FLAG_PVP_CORPSE`, absent - /// from the base `dbc.MPQ` and added with Battlegrounds. The "skin a player" reading came from - /// the cursor **art** names (`SkinAlliance`/`SkinHorde`) and from nowhere else; there is no - /// skinning of players in 1.12.1. + /// `CORPSE_FLAG_LOOTABLE`: the battleground insignia is takeable, by spell 22027 "Remove + /// Insignia". The cursor `0x482740` and right-click `0x5d6bf0` test it; the corpse's PvP flag + /// is bit 2 (`UnitIsPVP`, `0x516460`). pub fn corpse_pvp_insignia(&self) -> bool { self.corpse_flags() & 0x20 != 0 } - /// `DYNAMICOBJECT_CASTER` (fields 6–7, `OBJECT_END + 0x0`, a 2-field guid — vmangos - /// `UpdateFields_1_12_1.h:325`): the unit whose ground cast anchored this object. - /// Index-twin of [`Self::corpse_owner`] — each TYPEID reuses `OBJECT_END`; the TypeId (or - /// [`Self::dynamicobject_spell_id`]'s presence) decides which name applies. + /// The ground caster (`UpdateFields_1_12_1.h:325`), at the same index as a corpse's owner. pub fn dynamicobject_caster(&self) -> Option { self.get_guid(6).filter(|&g| g != 0) } - /// `DYNAMICOBJECT_BYTES` (field 8): the dynobj type byte — vmangos sends `1` (area spell) - /// for every persistent-area cast (`DynamicObject::Create`; captured live 2026-07-30 for - /// Blizzard 10 and Flamestrike 2120). + /// The type byte; vmangos sends 1 (area spell) for every persistent-area cast. pub fn dynamicobject_bytes(&self) -> Option { self.get_u32(8) } - /// `DYNAMICOBJECT_SPELLID` (field 9): the anchoring spell — the root of the dest-anchored - /// visual chain (B132 follow-up). `None`/0 when absent. + /// The anchoring spell, the root of the ground-targeted visual chain. pub fn dynamicobject_spell_id(&self) -> Option { self.get_u32(9).filter(|&s| s != 0) } - /// `DYNAMICOBJECT_RADIUS` (field 10): the area's radius in yards (live: Blizzard 8.0, - /// Flamestrike 5.0 — the server's `SpellRadius.dbc` resolve). + /// The area's radius in yards, which the server resolves from `SpellRadius.dbc`. pub fn dynamicobject_radius(&self) -> Option { self.get_f32(10) } - /// `DYNAMICOBJECT_POS_X/Y/Z` + `FACING` (fields 11–14): the anchored point in raw WoW - /// coords. Duplicates the create's movement-block position on the vmangos wire (both - /// captured identical live); the accessor exists for `Values`-delta reads and tests. + /// The anchored point and facing in raw WoW coordinates, equal to the create's position. pub fn dynamicobject_position(&self) -> Option<([f32; 3], f32)> { Some(( [self.get_f32(11)?, self.get_f32(12)?, self.get_f32(13)?], @@ -903,24 +511,20 @@ impl ObjectFields { )) } - /// Whether the wire genuinely carried this field (mask bit set) — the raw presence probe - /// under every accessor, bypassing the created-store absent-is-zero fold. + /// Whether the mask carries the field, ignoring the created store's absent-is-zero. fn contains(&self, index: u16) -> bool { self.present .get(usize::from(index / 32)) .is_some_and(|w| w & (1u32 << (index % 32)) != 0) } - /// The carried value, `None` when the mask bit is unset — [`Self::contains`]'s value twin. fn get_raw(&self, index: u16) -> Option { self.contains(index) .then(|| self.values[usize::from(index)]) } fn insert(&mut self, index: u16, value: u32) { - // `index` is a `u16`, so `word` is at most 2047 and the store at most 64 Ki values — - // bounded by the type, not by anything the wire says (the wire's block count above is - // a `u8`, 255 words at the very most). + // A `u16` index bounds the store at 64 Ki values, whatever the wire says. let word = usize::from(index / 32); if word >= self.present.len() { self.present.resize(word + 1, 0); @@ -931,9 +535,7 @@ impl ObjectFields { } fn get_guid(&self, index: u16) -> Option { - // Raw read on purpose: a guid slot stays "present iff the low half is" even on a - // created store — every consumer already treats a sent-empty `Some(0)` as no-guid, so the - // absent-is-zero fallback would only blur the two identical cases. + // Raw on purpose: a guid is present iff its low half is, even on a created store. let lo = self.get_raw(index)?; let hi = self.get_u32(index + 1).unwrap_or(0); Some(u64::from(lo) | (u64::from(hi) << 32)) @@ -949,14 +551,12 @@ impl ObjectFields { fn get_i32(&self, index: u16) -> Option { self.get_u32(index).map(|v| v as i32) } - /// Split a `u32` field into its low/high 16-bit halves — a `TWO_SHORT`/`PAIR32` field - /// (`MAKE_PAIR32`/`PAIR32_LOPART`/`_HIPART`, vmangos `shared/Common.h:119-121`). + /// A `TWO_SHORT` field as its (low, high) halves (vmangos `shared/Common.h:119-121`). fn get_u16_pair(&self, index: u16) -> Option<(u16, u16)> { self.get_u32(index).map(|v| (v as u16, (v >> 16) as u16)) } - /// One slot's nibble out of a 8-slots-per-`u32` array (`UNIT_FIELD_AURAFLAGS`). An absent word - /// reads `0`, matching the client's zero-initialized descriptor array. + /// One slot's `UNIT_FIELD_AURAFLAGS` nibble; an absent word reads 0, as in the client. fn get_aura_nibble(&self, slot: u8) -> u8 { let word = self .get_u32(FIELD_UNIT_AURAFLAGS + u16::from(slot >> 3)) @@ -964,37 +564,22 @@ impl ObjectFields { ((word >> ((slot & 7) * 4)) & 0x0F) as u8 } - /// One slot's byte out of a 4-slots-per-`u32` array (`UNIT_FIELD_AURALEVELS`, - /// `UNIT_FIELD_AURAAPPLICATIONS`). An absent word reads `0`, as above. + /// One slot's byte of `UNIT_FIELD_AURALEVELS` or `UNIT_FIELD_AURAAPPLICATIONS`; an absent word + /// reads 0. fn get_aura_byte(&self, base: u16, slot: u8) -> u8 { let word = self.get_u32(base + u16::from(slot >> 2)).unwrap_or(0); (word >> ((slot & 3) * 8)) as u8 } - /// Fold an update into this store. A `Values` delta overlays: every field it carries replaces - /// ours (WoW only resends *changed* fields, and a field going to `0` is an explicit `0`, never - /// a removal), so the ECS accumulates the object's full descriptor set across packets - /// (decision 0061). A re-CREATE snapshot *replaces* instead: it is the complete descriptor - /// (absent = 0), and overlaying it would keep stale non-zero values for every field that - /// dropped to zero since the last stream (an aura that expired out of view, durability that - /// broke) — exactly the absent-vs-zero bug the `created` flag exists to kill. + /// Folds in an update: a `Values` delta overlays, since a field going to 0 is sent as 0; a + /// re-CREATE replaces, as overlaying it would keep fields that have since dropped to 0. pub fn merge(&mut self, delta: ObjectFields) { self.merge_diff(delta, |_, _, _| {}); } - /// [`Self::merge`] that also reports every dword the fold MOVED — the reference's - /// `CMirrorHandler` notify pass (`0x465330`: memcmp live-vs-shadow over each registered span, - /// fire only on a difference, the callback handed the OLD value — wow-re `object-layer.md`), - /// done in the one write that already holds both sides (decision 2297). - /// - /// `changed(index, old, new)` runs once per moved index, ascending, and only for a real - /// difference: a field re-sent with the value it already had reports nothing, exactly as the - /// reference's memcmp fires nothing. `old` reads an absent field as `0`, the reference's - /// zero-initialised array. A re-CREATE snapshot reports every index the replace moved, on - /// either side's mask — the reference's in-place refresh of an already-live guid goes down - /// its values-only path and notifies too — while a FIRST create is never merged at all (the - /// caller seeds the store with it), which is where the reference's create-time - /// notify-suppress flag lives here. + /// [`Self::merge`], calling `changed(index, old, new)` once per changed dword in ascending + /// order, as the client's `CMirrorHandler` pass does (`0x465330`): a resend of the same value + /// reports nothing, and an absent old value reads 0. A first create is seeded, never merged. pub fn merge_diff(&mut self, delta: ObjectFields, mut changed: impl FnMut(u16, u32, u32)) { if delta.descriptor_end != 0 { let words = self.present.len().max(delta.present.len()); @@ -1030,12 +615,8 @@ impl ObjectFields { } } - /// The object class this descriptor was CREATED as, read back off its own length ([`descriptor_len`] - /// is injective over the eight classes) — `None` for a bare `Values` delta or a hand-built - /// fixture that never went through [`Self::into_created`]. Distinct from - /// [`Self::object_type`], the typemask read, which cannot tell a corpse from an item: a field - /// edge carries THIS because a raw index means different things per class (`36`: a unit's - /// `BYTES_0`, a corpse's `DYNAMIC_FLAGS`). + /// The type this was created as, from its descriptor length (the eight lengths are distinct). + /// Unlike [`Self::object_type`] it tells a corpse from an item, as index 36 needs. pub fn created_as(&self) -> Option { const ALL: [ObjectType; 8] = [ ObjectType::Object, @@ -1055,12 +636,11 @@ impl ObjectFields { .flatten() } - /// Whether this carried no fields — the codec skips emitting an empty `Values` delta. + /// No fields carried; the codec skips an empty `Values` delta. pub fn is_empty(&self) -> bool { self.present.iter().all(|&w| w == 0) } - /// Every present `(index, value)` pair in ascending index order — the debug/probe affordance - /// for descriptor archaeology (named accessors stay the consumer surface; decision 0061). + /// Every carried `(index, value)` pair in ascending order, for debugging and probes. pub fn raw_fields(&self) -> impl Iterator + '_ { self.present.iter().enumerate().flat_map(move |(word, &w)| { (0..32u16)