Files
benilla-octo/crates/benilla-protocol/src/messages/update_object/fields/mod.rs
T
SamandClaude Fable 5 dc11de7ef3 the resurrect prompt is a 40-yard latch, and a corpse names itself
- the "Resurrect Now" prompt was never a click: release sends the corpse query, the position is cached, and a per-frame range check (d² ≤ 1600 — 40 yards) raises CORPSE_IN_RANGE, whose Lua raises the prompt, whose button sends CMSG_RECLAIM_CORPSE — a chain we already ran at exactly 40.0; what we were missing was the bones gate: every one of the reference's three latch writers requires the corpse's bones bit clear as well as ownership, and RetrieveCorpse itself carries no guard at all, so our reclaim guid could have named a pile of bones — the gate is in now, at create and on the in-place flip
- a corpse publishes a mouseover: the "Corpse of %s" name plate and the +64 brighten, applied before the publisher's type switch with no type test — any real body qualifies, owner irrelevant, only a bone pile with nothing to take is dropped — and it fires no event, so UnitName("mouseover") over a body stays nil, here as there; sitting refuses the corpse loot click with the client-local red not-standing error and no send; and the insignia doc is corrected — bit 5 unlocks spell 22027 "Remove Insignia", the corpse's actual PvP flag is bit 2, and there is no skinning of players in 1.12.1
- the size tripwire counts code, not lines: 145 flagged files → 15, because in this codebase the comments are the knowledge base and the tests are the gate — one flagged file was 4357 lines of which 5 are code, another 1566 with ZERO — so the budget (unchanged at 1000) now counts non-test, non-comment source, calibrated so the one split on record that actually mattered still fires

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 20:12:49 +07:00

970 lines
63 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::io::{self, Read};
use crate::wire::{read_u32_le, read_u8, Vector3d};
use super::movement::ObjectType;
// ObjectFields descriptor-field indices (build 5875), shared across object types.
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.
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).
const FIELD_GAMEOBJECT_ROTATION: u16 = 10;
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).
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).
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).
const FIELD_GAMEOBJECT_LEVEL: u16 = 22;
// 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`).
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).
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).
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`).
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).
const FIELD_UNIT_CHANNEL_OBJECT: u16 = 20;
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`).
const FIELD_UNIT_POWER1: u16 = 23;
const FIELD_UNIT_MAXHEALTH: u16 = 28;
const FIELD_UNIT_MAXPOWER1: u16 = 29;
const FIELD_UNIT_LEVEL: u16 = 34;
const FIELD_UNIT_FACTIONTEMPLATE: u16 = 35;
const FIELD_UNIT_BYTES_0: u16 = 36;
const FIELD_UNIT_BYTES_1: u16 = 138;
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).
const FIELD_UNIT_AURA: u16 = 47;
/// Nibble-packed, 8 slots per `u32` (`SpellAuraHolder::SetAuraFlag`, `SpellAuras.cpp:7456-7462`).
const FIELD_UNIT_AURAFLAGS: u16 = 95;
/// 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`).
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`).
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).
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).
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.
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.
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.
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.
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`.
const FIELD_UNIT_TRAINING_POINTS: u16 = 149;
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).
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.
const FIELD_UNIT_CREATED_BY_SPELL: u16 = 146;
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.
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.
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.
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).
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.
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_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.
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_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_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.
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.
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.
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.
#[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.
pub slot: u8,
/// The `Spell.dbc` id.
pub spell_id: u32,
/// The raw `UNIT_FIELD_AURAFLAGS` nibble for this slot.
pub flags: u8,
/// The **caster's** level at apply time — the only thing the wire says about who cast this.
pub level: u8,
/// Stack count, ≥ 1 (the wire byte holds `stack - 1`).
pub stacks: u8,
}
impl UnitAuraSlot {
/// A buff (slot in the positive half) rather than a debuff.
pub fn is_helpful(&self) -> bool {
self.slot < UNIT_AURA_POSITIVE_SLOTS
}
/// Whether the server will honour a `CMSG_CANCEL_AURA` for this aura.
pub fn is_cancelable(&self) -> bool {
self.flags & AURA_FLAG_CANCELABLE != 0
}
}
// 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.
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`]).
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).
const FIELD_PLAYER_QUEST_LOG_1_1: u16 = 198;
/// The number of `PLAYER_QUEST_LOG` slots (vmangos `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).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct QuestLogSlot {
/// The quest id occupying the slot (`0` = the server cleared it — 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.
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.
pub state: u8,
/// The timer field — the absolute end time for a timed quest, else `0`.
pub timer: u32,
}
/// `QUEST_STATE_*` (vmangos `Player.h:447-451`) — [`QuestLogSlot::state`] bit values.
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)
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).
const FIELD_PLAYER_VISIBLE_ITEM_1_CREATOR: u16 = 258; // UNIT_END + 0x46; 12 fields per slot
const FIELD_PLAYER_INV_SLOT_HEAD: u16 = 486; // 23 slots × 2 (equipment 0–18, bag 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_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.
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_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.)
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 ✓).
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.
const FIELD_PLAYER_BLOCK_PERCENTAGE: u16 = 1106;
const FIELD_PLAYER_DODGE_PERCENTAGE: u16 = 1107;
const FIELD_PLAYER_PARRY_PERCENTAGE: u16 = 1108;
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.
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;
// 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).
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 ✓).
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_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).
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).
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).
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.
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_*).
// 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.
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;
const FIELD_PLAYER_FIELD_THIS_WEEK_KILLS: u16 = 1253;
const FIELD_PLAYER_FIELD_THIS_WEEK_CONTRIBUTION: u16 = 1254;
const FIELD_PLAYER_FIELD_LIFETIME_HONORABLE_KILLS: u16 = 1255;
const FIELD_PLAYER_FIELD_LIFETIME_DISHONORABLE_KILLS: u16 = 1256;
const FIELD_PLAYER_FIELD_YESTERDAY_CONTRIBUTION: u16 = 1257;
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.
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).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PlayerSkillSlot {
/// The `SkillLine.dbc` id (`0` = an unused slot).
pub skill_id: u16,
/// The skill-tier step (`Player::SetSkill`'s `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.
pub temp_bonus: i16,
/// Permanent bonus (talents); signed.
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.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CorpseLook {
pub race: u8,
pub sex: u8,
pub skin: u8,
pub face: u8,
pub hair_style: u8,
pub hair_color: u8,
pub facial_hair: u8,
}
#[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.
present: Vec<u32>,
/// 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).
values: Vec<u32>,
/// 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`.
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.
fn descriptor_len(object_type: ObjectType) -> u16 {
match object_type {
ObjectType::Object => 6, // OBJECT_END = 0x6
ObjectType::Item => 48, // ITEM_END = OBJECT_END + 0x2A
ObjectType::Container => 122, // CONTAINER_END = ITEM_END + 0x4A
ObjectType::Unit => 188, // UNIT_END = OBJECT_END + 0xB6
ObjectType::Player => 1282, // PLAYER_END = UNIT_END + 0x446
ObjectType::GameObject => 26, // GAMEOBJECT_END = OBJECT_END + 0x14
ObjectType::DynamicObject => 16, // DYNAMICOBJECT_END = OBJECT_END + 0xA
ObjectType::Corpse => 38, // CORPSE_END = OBJECT_END + 0x20
}
}
impl ObjectFields {
pub(super) fn read(r: &mut impl Read) -> io::Result<Self> {
let amount_of_blocks = read_u8(r)?;
let mut present = Vec::with_capacity(amount_of_blocks as usize);
for _ in 0..amount_of_blocks {
present.push(read_u32_le(r)?);
}
let mut values = vec![0u32; present.len() * 32];
for (word, block) in present.iter().enumerate() {
for bit in 0..32 {
if block & (1u32 << bit) != 0 {
values[word * 32 + bit] = read_u32_le(r)?;
}
}
}
Ok(Self {
present,
values,
descriptor_end: 0,
})
}
/// 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.
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.
pub fn from_pairs(pairs: &[(u16, u32)]) -> Self {
let mut this = Self::default();
for &(index, value) in pairs {
this.insert(index, value);
}
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.
pub fn corpse_owner(&self) -> Option<u64> {
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.
pub fn corpse_display_id(&self) -> Option<u32> {
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.
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_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`.
fn corpse_bytes_1(&self) -> Option<u32> {
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`).
fn corpse_bytes_2(&self) -> Option<u32> {
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).
pub fn corpse_look(&self) -> Option<CorpseLook> {
let (b1, b2) = (self.corpse_bytes_1()?, self.corpse_bytes_2()?);
Some(CorpseLook {
race: (b1 >> 8) as u8,
sex: (b1 >> 16) as u8,
skin: (b1 >> 24) as u8,
face: b2 as u8,
hair_style: (b2 >> 8) as u8,
hair_color: (b2 >> 16) as u8,
facial_hair: (b2 >> 24) as u8,
})
}
/// `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.
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.
pub fn corpse_flags_present(&self) -> Option<u32> {
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`.
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).
pub fn corpse_hides_helm(&self) -> bool {
self.corpse_flags() & 0x08 != 0
}
/// `CORPSE_FLAG_HIDE_CLOAK` (`0x10`) — the same, for equipment 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`).
pub fn corpse_lootable(&self) -> bool {
self.get_u32(36).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.
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.
pub fn dynamicobject_caster(&self) -> Option<u64> {
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).
pub fn dynamicobject_bytes(&self) -> Option<u32> {
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.
pub fn dynamicobject_spell_id(&self) -> Option<u32> {
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).
pub fn dynamicobject_radius(&self) -> Option<f32> {
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.
pub fn dynamicobject_position(&self) -> Option<([f32; 3], f32)> {
Some((
[self.get_f32(11)?, self.get_f32(12)?, self.get_f32(13)?],
self.get_f32(14).unwrap_or(0.0),
))
}
/// 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.
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<u32> {
self.contains(index)
.then(|| self.values[usize::from(index)])
}
fn insert(&mut self, index: u16, value: u32) {
let word = usize::from(index / 32);
if word >= self.present.len() {
self.present.resize(word + 1, 0);
self.values.resize(self.present.len() * 32, 0);
}
self.present[word] |= 1u32 << (index % 32);
self.values[usize::from(index)] = value;
}
fn get_guid(&self, index: u16) -> Option<u64> {
// 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.
let lo = self.get_raw(index)?;
let hi = self.get_u32(index + 1).unwrap_or(0);
Some(u64::from(lo) | (u64::from(hi) << 32))
}
fn get_u32(&self, index: u16) -> Option<u32> {
self.get_raw(index)
.or((index < self.descriptor_end).then_some(0))
}
fn get_f32(&self, index: u16) -> Option<f32> {
self.get_u32(index).map(f32::from_bits)
}
fn get_i32(&self, index: u16) -> Option<i32> {
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`).
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.
fn get_aura_nibble(&self, slot: u8) -> u8 {
let word = self
.get_u32(FIELD_UNIT_AURAFLAGS + u16::from(slot >> 3))
.unwrap_or(0);
((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.
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.
pub fn merge(&mut self, delta: ObjectFields) {
if delta.descriptor_end != 0 {
*self = delta;
} else {
for (index, value) in delta.raw_fields() {
self.insert(index, value);
}
}
}
/// Whether this carried no fields — the codec skips emitting 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).
pub fn raw_fields(&self) -> impl Iterator<Item = (u16, u32)> + '_ {
self.present.iter().enumerate().flat_map(move |(word, &w)| {
(0..32u16)
.filter(move |bit| w & (1u32 << bit) != 0)
.map(move |bit| {
let index = word as u16 * 32 + bit;
(index, self.values[usize::from(index)])
})
})
}
}
mod player;
mod unit;
pub use unit::{power_display_scale, OwnerFallback};
#[cfg(test)]
mod tests;