diff --git a/crates/benilla-protocol/src/messages/update_object/fields/player.rs b/crates/benilla-protocol/src/messages/update_object/fields/player.rs index 5ddc9991..c2c47173 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/player.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/player.rs @@ -1,125 +1,77 @@ -//! The item/container/PLAYER named accessors over [`ObjectFields`] — inventory and equipment -//! slots, purse/XP, the quest log, skills, buyback, reputation-adjacent player bytes. Split from -//! the one descriptor map (`mod.rs` holds the indices + codec). +//! Named accessors over [`ObjectFields`] for the item, container, player and GameObject blocks. use super::*; impl ObjectFields { - /// `OBJECT_FIELD_ENTRY` (3) — the object's template entry (an item object's item id, a - /// GameObject's template, a creature's `creature_template` row). The one field that names WHAT - /// a streamed object is. - /// - /// **For a PET this is the only route to its template**, and that is load-bearing: a - /// `HIGHGUID_PET` guid carries a *pet number* in the slot a creature's entry occupies - /// ([`crate::guid::pet_number`]), so [`crate::guid::entry`] refuses it — but vmangos still - /// writes the real `cinfo->entry` here (`Creature::InitEntry`, `Creature.cpp:376`, - /// `SetEntry(entry) // normal entry always`). Decision 1062's creature-family lookup goes - /// through this field for exactly that reason. + /// `OBJECT_FIELD_ENTRY` (3): the template entry. The only route to a pet's template: its guid + /// holds a pet number instead, but vmangos writes the entry here (`Creature.cpp:376`). pub fn object_entry(&self) -> Option { self.get_u32(3) } - /// `ITEM_FIELD_STACK_COUNT` — an item object's stack size. + /// `ITEM_FIELD_STACK_COUNT`: an item object's stack size. pub fn item_stack_count(&self) -> Option { self.get_u32(FIELD_ITEM_STACK_COUNT) } - /// `ITEM_FIELD_SPELL_CHARGES + i` — this *instance*'s remaining charges for its `i`th spell - /// block (`i < 5`), signed: negative = a consumable-on-empty counter, `0` = spent. Distinct - /// from the TEMPLATE's `ItemSpellEntry::charges`, which says how many a fresh copy carries. - /// Field 16 = `FIELD_ITEM_STACK_COUNT`(14) + DURATION(15) + 1 — the chain between the tested - /// STACK_COUNT and FLAGS(21) anchors, and the client's own `[item+0x114]+0x28` (wow-re - /// `action-item-slot.md` §8.2, the mode-`0x20` search filter that reads exactly this). + /// `ITEM_FIELD_SPELL_CHARGES + i` (field `16 + i`, the client's `[item+0x114]+0x28`): + /// charges left on spell `i`, signed; negative is consumed when empty, 0 is spent. pub fn item_spell_charges(&self, i: u8) -> Option { (i < 5) .then(|| self.get_u32(FIELD_ITEM_STACK_COUNT + 2 + u16::from(i)))? .map(|v| v as i32) } - /// `ITEM_FIELD_FLAGS` (field 21 — wire index; the client's item-relative index 15 + the 6 - /// object fields, VERIFIED wow-re inventory-alert-law): bit `0x08` = wrapped (a gift — never - /// alerts, never reads broken), bit `0x10` = force-red (status 4 regardless of durability; - /// the bit's game meaning is unnamed in the binary, the behavior byte-verified). + /// `ITEM_FIELD_FLAGS` (field 21): `0x08` wrapped, never alerts; `0x10` forces red status 4. pub fn item_flags(&self) -> Option { self.get_u32(21) } - /// `ITEM_FIELD_CREATOR` (field 10, guid — vmangos `UpdateFields_1_12_1.h` OBJECT_END+0x4) — - /// the item's author/crafter. On a mail-made permanent letter this is the letter's SENDER - /// (vmangos `HandleMailCreateTextItem` stamps it) — the reader window's "From," line. + /// `ITEM_FIELD_CREATOR` (field 10): crafter, or letter sender (`HandleMailCreateTextItem`). pub fn item_creator(&self) -> Option { self.get_guid(10).filter(|&g| g != 0) } - /// `ITEM_FIELD_ITEM_TEXT_ID` (field 45, OWNER_ONLY — vmangos `UpdateFields_1_12_1.h` - /// OBJECT_END+0x27) — the item *instance*'s letter text (a mail-made permanent copy). Nonzero - /// = right-click reads it (`CMSG_ITEM_TEXT_QUERY`) instead of using it. + /// `ITEM_FIELD_ITEM_TEXT_ID` (field 45, owner only): a mailed letter copy's text; nonzero + /// makes a right-click read it (`CMSG_ITEM_TEXT_QUERY`) instead of using the item. pub fn item_text_id(&self) -> Option { self.get_u32(45) } - /// `ITEM_FIELD_ENCHANTMENT + 3*slot` — the enchant id in one of an item OBJECT's **7** - /// enchantment slots (`slot < 7`; PERM = 0, TEMP = 1, then the five random-property slots). - /// `None` = empty / not sent. - /// - /// This is the item's own array, and it is the FULL one: vmangos - /// `UpdateFields_1_12_1.h` gives `ITEM_FIELD_ENCHANTMENT = OBJECT_END + 0x10, Size: 21, - /// PUBLIC` = 7 slots × 3 fields (id, duration, charges — `Item.h:117-119`), so field - /// `22 + 3*slot`. Distinct from [`Self::player_visible_item_enchant`], the 2-slot - /// (PERM, TEMP) copy broadcast on a *unit's* descriptor for everyone else to render from: - /// an item object streams only to its owner, so seven slots are readable for our own gear - /// and two for anybody else's. - /// - /// **Signed**, because the sign is load-bearing at exactly one consumer and nowhere else: the - /// tooltip resolves the `SpellItemEnchantment` row from `abs(id)` and uses the sign only to - /// paint the line pure-red instead of green (wow-re `tooltip-content-law.md` §E3). + /// `ITEM_FIELD_ENCHANTMENT + 3*slot` (field `22 + 3*slot`): the enchant id in one of 7 slots + /// (0 permanent, 1 temporary, 2-6 random properties), each an id, duration and charges + /// (`Item.h:117-119`). Signed: the tooltip looks up `abs(id)` and paints a negative one red. pub fn item_enchant(&self, slot: u8) -> Option { (slot < 7) .then(|| self.get_u32(FIELD_ITEM_ENCHANTMENT + 3 * u16::from(slot)))? .map(|id| id as i32) .filter(|&id| id != 0) } - /// `ITEM_FIELD_ENCHANTMENT + 3*slot + 2` — that slot's remaining enchant **charges**; `0` = - /// none. The tooltip appends `" (N Charges)"` to the slot's line when it is nonzero. - /// - /// The triple's MIDDLE field (the duration) is deliberately not exposed: the reference's - /// tooltip never reads it. A temporary enchant's countdown comes from - /// `SMSG_ITEM_ENCHANT_TIME_UPDATE` instead (`items::Countdowns::enchant_remaining_ms`). + /// `ITEM_FIELD_ENCHANTMENT + 3*slot + 2`: charges left, shown as `" (N Charges)"` if nonzero. + /// The tooltip skips the duration; a countdown comes from `SMSG_ITEM_ENCHANT_TIME_UPDATE`. pub fn item_enchant_charges(&self, slot: u8) -> u32 { (slot < 7) .then(|| self.get_u32(FIELD_ITEM_ENCHANTMENT + 3 * u16::from(slot) + 2)) .flatten() .unwrap_or(0) } - /// `ITEM_FIELD_RANDOM_PROPERTIES_ID` (field 44 — vmangos `UpdateFields_1_12_1.h` - /// `OBJECT_END + 0x26`, chain-locked here by the tested `ITEM_TEXT_ID` 45 / `DURABILITY` 46 - /// anchors on either side): the **random-suffix roll** this item instance carries, an - /// `ItemRandomProperties.dbc` id. `0` = unrolled. - /// - /// Its one consumer is the NAME: the client joins the row's suffix through - /// `ITEM_SUFFIX_TEMPLATE` in its single name formatter `0x5d8b00`, so the item reads "Chipped - /// Claw of the Bear" everywhere it is named. The roll's **enchants** never come from here on - /// an owned item — the server writes them into `ITEM_FIELD_ENCHANTMENT` slots 2..6, which - /// [`Self::item_enchant`] already reads. + /// `ITEM_FIELD_RANDOM_PROPERTIES_ID` (field 44): the `ItemRandomProperties.dbc` suffix, 0 if + /// none; only the name uses it (`0x5d8b00`), the roll's enchants fill enchant slots 2-6. pub fn item_random_properties_id(&self) -> u32 { self.get_u32(44).unwrap_or(0) } - /// `ITEM_FIELD_DURABILITY` — an item's current durability (field 46: chain-locked between the - /// tested STACK_COUNT 14 and CONTAINER_NUM_SLOTS 48 = ITEM_END anchors). + /// `ITEM_FIELD_DURABILITY` (field 46): current durability. pub fn item_durability(&self) -> Option { self.get_u32(46) } - /// `ITEM_FIELD_MAXDURABILITY` — the item's durability ceiling (0 = has no durability). + /// `ITEM_FIELD_MAXDURABILITY`: the durability ceiling, 0 for an item without durability. pub fn item_max_durability(&self) -> Option { self.get_u32(47) } - /// `CONTAINER_FIELD_NUM_SLOTS` — a bag object's capacity. + /// `CONTAINER_FIELD_NUM_SLOTS`: a bag object's capacity. pub fn container_num_slots(&self) -> Option { self.get_u32(FIELD_CONTAINER_NUM_SLOTS) } - /// `CONTAINER_FIELD_SLOT_1 + 2i` — the item guid in a bag's slot `i` (< 36). `Some(0)` = the - /// server said "empty"; `None` = the field wasn't sent. + /// `CONTAINER_FIELD_SLOT_1 + 2i`: the item guid in bag slot `i`, `Some(0)` when empty. pub fn container_slot(&self, i: u8) -> Option { (i < 36).then(|| self.get_guid(FIELD_CONTAINER_SLOT_1 + 2 * u16::from(i)))? } - /// `PLAYER_VISIBLE_ITEM__0` — the **public** item entry visibly worn in equipment - /// slot `i` (what other clients render from; 12 fields per slot after the 2-field creator). - /// The cross-check for the private INV_SLOT guids — and the entry source for our own - /// equipment until item objects have a decode path. + /// `PLAYER_VISIBLE_ITEM__0`: the public entry worn in equipment slot `i`, which other + /// clients render from; each slot is 12 dwords, the entry after a 2-dword creator. pub fn player_visible_item_entry(&self, i: u8) -> Option { (i < 19) .then(|| { @@ -128,13 +80,9 @@ impl ObjectFields { }) .flatten() } - /// `PLAYER_VISIBLE_ITEM__0 + 1 + j` — the **broadcast enchant ids** on the item worn in - /// equipment slot `i`: that 8-field block is the item entry followed by 7 enchant slots, of - /// which 1.12 fills exactly two (vmangos `Player::SetVisibleItemSlot` loops - /// `MAX_INSPECTED_ENCHANTMENT_SLOT = 2` — PERM then TEMP). This is the only enchant feed a - /// client has for **any** unit, itself included: the reference reads a full CGItem's 7-slot - /// array (`item+0xc`, wow-re `item-visual-enchant.md` §3b), which on the wire is these. - /// Consumed by the weapon-glow lane (decision 0805); `None` = empty slot / field not sent. + /// `PLAYER_VISIBLE_ITEM__0 + 1 + j`: broadcast enchant `j` of the item in slot `i`; + /// vmangos fills only 0 and 1 (`MAX_INSPECTED_ENCHANTMENT_SLOT`). The reference's enchant + /// visuals for every unit, itself included, come from these (`item+0xc`). pub fn player_visible_item_enchant(&self, i: u8, j: u8) -> Option { (i < 19 && j < 7) .then(|| { @@ -145,15 +93,8 @@ impl ObjectFields { }) .flatten() } - /// `PLAYER_VISIBLE_ITEM__PROPERTIES` — the **broadcast random-suffix roll** of the item - /// worn in equipment slot `i`. The 12-dword per-slot record is `creator(2) · entry · - /// enchant[7] · PROPERTIES · PAD`, and PROPERTIES is a `TWO_SHORT` whose **low half** is the - /// `ItemRandomProperties` id — the reference reads it with a `movzx WORD` (`0x53339f`, the - /// inspect leg's `+0x424` source; wow-re `tooltip-content-law.md` §E7). - /// - /// This is the only roll feed a client has for anybody else's gear, and its one consumer is - /// the NAME: vmangos fills only the PERM/TEMP enchant slots, so an inspected item's suffix - /// *lines* stay empty on both sides of the wire — the same thing the reference shows. + /// `PLAYER_VISIBLE_ITEM__PROPERTIES`: slot `i`'s broadcast suffix roll, the low half + /// (`0x53339f`, `movzx WORD`) of the dword after the 7 enchants. Only the name uses it. pub fn player_visible_item_properties(&self, i: u8) -> u32 { (i < 19) .then(|| self.get_u32(FIELD_PLAYER_VISIBLE_ITEM_1_CREATOR + 10 + 12 * u16::from(i))) @@ -161,124 +102,87 @@ impl ObjectFields { .unwrap_or(0) & 0xffff } - /// `PLAYER_FIELD_INV_SLOT_HEAD + 2i` — our equipment (0–18) and equipped-bag (19–22) guids. + /// `PLAYER_FIELD_INV_SLOT_HEAD + 2i`: our equipment (0-18) and equipped-bag (19-22) guids. pub fn player_inv_slot(&self, i: u8) -> Option { (i < 23).then(|| self.get_guid(FIELD_PLAYER_INV_SLOT_HEAD + 2 * u16::from(i)))? } - /// `PLAYER_FIELD_PACK_SLOT_1 + 2i` — our backpack's 16 item guids. + /// `PLAYER_FIELD_PACK_SLOT_1 + 2i`: our backpack's 16 item guids. pub fn player_pack_slot(&self, i: u8) -> Option { (i < 16).then(|| self.get_guid(FIELD_PLAYER_PACK_SLOT_1 + 2 * u16::from(i)))? } - /// `PLAYER_FIELD_BANK_SLOT_1 + 2i` — our bank's 24 item guids. + /// `PLAYER_FIELD_BANK_SLOT_1 + 2i`: our bank's 24 item guids. pub fn player_bank_slot(&self, i: u8) -> Option { (i < 24).then(|| self.get_guid(FIELD_PLAYER_BANK_SLOT_1 + 2 * u16::from(i)))? } - /// `PLAYER_FIELD_BANK_BAG_SLOT_1 + 2i` — the item guid of bank bag `i` (0–5, the 6 purchasable - /// bank bag slots); the bag's *contents* stream as an ordinary `CONTAINER_FIELD_SLOT_*` block - /// on the bag item itself, addressed by bag byte 63–68 (decision 0604). + /// `PLAYER_FIELD_BANK_BAG_SLOT_1 + 2i`: bank bag `i`'s guid; its contents stream on the bag + /// item's own `CONTAINER_FIELD_SLOT_*`, addressed as bag 63-68. pub fn player_bank_bag_slot(&self, i: u8) -> Option { (i < 6).then(|| self.get_guid(FIELD_PLAYER_BANK_BAG_SLOT_1 + 2 * u16::from(i)))? } - /// `PLAYER_FIELD_KEYRING_SLOT_1 + 2i` — the keyring's item guids. The **field array is 32 - /// guids** (`UpdateFields_1_12_1.h`: size 64 dwords; vmangos `MAX_KEYRING_SLOTS 32`) and the - /// client's inventory walker treats all 32 as its keyring band (absolute slots 81–112, mode - /// bit `0x40` — wow-re `action-item-slot.md` §8.2), but only the **first 16 are addressable - /// positions** on this wire (vmangos `KEYRING_SLOT_START 81`, `KEYRING_SLOT_END 97`, whose own - /// comment reads "32 slots (only 16 are visible/accessible in UI)"), and only as many of those - /// as the player's level unlocks are usable (4/8/12/16 — `ui_items::keyring_size`). Feeds the - /// keyring container (decision 0765) and the - /// GameObject lock chain's key-slot resolver, which scans the keyring alongside the bags - /// (wow-re `cursor-system.md` §8.4; decision 0752). `Some(0)` = empty. + /// `PLAYER_FIELD_KEYRING_SLOT_1 + 2i`: 32 guids the client walks as slots 81-112, but only + /// 16 are addressable (vmangos `KEYRING_SLOT_END 97`) and level unlocks 4, 8, 12 or 16. pub fn player_keyring_slot(&self, i: u8) -> Option { (i < 32).then(|| self.get_guid(FIELD_PLAYER_KEYRING_SLOT_1 + 2 * u16::from(i)))? } - /// `PLAYER_FIELD_VENDORBUYBACK_SLOT_1 + 2i` — the item guid parked in buyback slot `i` (0–11; - /// the wire's absolute inventory slot is `69 + i`). `Some(0)` = empty. + /// `PLAYER_FIELD_VENDORBUYBACK_SLOT_1 + 2i`: buyback slot `i`, inventory slot `69 + i`. pub fn player_buyback_slot(&self, i: u8) -> Option { (i < 12).then(|| self.get_guid(FIELD_PLAYER_VENDORBUYBACK_SLOT_1 + 2 * u16::from(i)))? } - /// `PLAYER_FIELD_BUYBACK_PRICE_1 + i` — what buying slot `i` back costs, in copper. + /// `PLAYER_FIELD_BUYBACK_PRICE_1 + i`: what buying slot `i` back costs, in copper. pub fn player_buyback_price(&self, i: u8) -> Option { (i < 12).then(|| self.get_u32(FIELD_PLAYER_BUYBACK_PRICE_1 + u16::from(i)))? } - /// `PLAYER_FIELD_BUYBACK_TIMESTAMP_1 + i` — slot `i`'s expiry stamp; the client uses it only - /// to order the buyback list oldest→newest (wow-re `0x4fafd0`). + /// `PLAYER_FIELD_BUYBACK_TIMESTAMP_1 + i`: expiry, used only to sort oldest first (`0x4fafd0`). pub fn player_buyback_timestamp(&self, i: u8) -> Option { (i < 12).then(|| self.get_u32(FIELD_PLAYER_BUYBACK_TIMESTAMP_1 + u16::from(i)))? } - /// `PLAYER_FIELD_COINAGE` — our purse in copper. `None` for a non-player unit (no PLAYER block) - /// or before the field has streamed in; the descriptor's zero-initialized default is 0 copper. + /// `PLAYER_FIELD_COINAGE`: our money, in copper. pub fn player_money(&self) -> Option { self.get_u32(FIELD_PLAYER_FIELD_COINAGE) } - /// `PLAYER_FARSIGHT` — the object our view is anchored to while a far-sight effect runs (Mind - /// Vision, Sentry Totem, the Ornate Spyglass), or `None` when the view is our own body. - /// - /// PRIVATE, so like [`Self::player_xp`] it only ever streams for our own avatar — which is the - /// whole consumer: this answers "what does the camera orbit", never anything about another - /// player. `0` is the cleared state and reads as `None`, following the house convention for - /// guid fields ([`Self::unit_charmed_by`] and friends): a zero guid is absence, not an object. + /// `PLAYER_FARSIGHT` (private): the view's anchor during Mind Vision and the like, or 0. pub fn player_farsight(&self) -> Option { self.get_guid(FIELD_PLAYER_FARSIGHT).filter(|&g| g != 0) } - /// `PLAYER_XP` — our current experience within the level (`UnitXP("player")`). `None` for a - /// non-player unit (no PLAYER block) or before the field has streamed; a PRIVATE field, so it - /// only ever arrives for our own avatar's descriptor. + /// `PLAYER_XP` (private): experience within the current level, `UnitXP("player")`. pub fn player_xp(&self) -> Option { self.get_u32(FIELD_PLAYER_XP) } - /// `PLAYER_NEXT_LEVEL_XP` — experience required to reach the next level (`UnitXPMax("player")`). - /// Same PRIVATE/own-player caveat as [`Self::player_xp`]. + /// `PLAYER_NEXT_LEVEL_XP` (private): experience to the next level, `UnitXPMax("player")`. pub fn player_next_level_xp(&self) -> Option { self.get_u32(FIELD_PLAYER_NEXT_LEVEL_XP) } - /// `PLAYER_FIELD_WATCHED_FACTION_INDEX` — the reputation-list slot whose bar rides the main - /// menu bar (`GetWatchedFactionInfo`), or `-1` for none. Same PRIVATE/own-player caveat as - /// [`Self::player_xp`]. - /// - /// **Signed, and `0` is not "none".** Slot 0 is the Bloodsail Buccaneers, so the only - /// no-faction value is `-1` — reading this field as unsigned would silently watch them. + /// `PLAYER_FIELD_WATCHED_FACTION_INDEX` (private): the reputation slot on the main menu bar, + /// signed: none is `-1`, since slot 0 is the Bloodsail Buccaneers. pub fn player_watched_faction(&self) -> Option { self.get_u32(FIELD_PLAYER_WATCHED_FACTION_INDEX) .map(|v| v as i32) } - /// `PLAYER_CHARACTER_POINTS1` — unspent talent points (`UnitCharacterPoints("player")`'s - /// first return; the talent frame's "N talent points" line, decision 0304). Same - /// PRIVATE/own-player caveat as [`Self::player_xp`]. + /// `PLAYER_CHARACTER_POINTS1` (private): unspent talent points, first of `UnitCharacterPoints`. pub fn player_talent_points(&self) -> Option { self.get_u32(FIELD_PLAYER_CHARACTER_POINTS1) } - /// `PLAYER_CHARACTER_POINTS2` — free primary professions (`UnitCharacterPoints`'s second - /// return; the "You now have %d free professions." chat line). Same caveat as above. + /// `PLAYER_CHARACTER_POINTS2` (private): free professions, second of `UnitCharacterPoints`. pub fn player_free_professions(&self) -> Option { self.get_u32(FIELD_PLAYER_CHARACTER_POINTS2) } - /// `PLAYER_TRACK_CREATURES` — the active creature-tracking bitmask (bit `1 << (creatureType − - /// 1)` per active TRACK_CREATURES aura): the minimap red-dot predicate's mask. PRIVATE — self - /// only; absent (no PLAYER block, or nothing ever tracked) reads as no tracking. + /// `PLAYER_TRACK_CREATURES` (private): bit `1 << (creatureType - 1)` per creature tracking + /// aura, the mask the minimap's red dots test. pub fn player_track_creatures(&self) -> u32 { self.get_u32(FIELD_PLAYER_TRACK_CREATURES).unwrap_or(0) } - /// `PLAYER_TRACK_RESOURCES` — the active resource-tracking bitmask (bit `1 << (lockType − 1)` - /// per active TRACK_RESOURCES aura, e.g. Find Minerals → the Mining `LockType.dbc` bit): the - /// minimap gold-dot predicate's mask. Same PRIVATE/self-only caveat as above. + /// `PLAYER_TRACK_RESOURCES` (private): bit `1 << (lockType - 1)` per resource tracking aura + /// (`LockType.dbc`), the mask the minimap's gold dots test. pub fn player_track_resources(&self) -> u32 { self.get_u32(FIELD_PLAYER_TRACK_RESOURCES).unwrap_or(0) } - /// `PLAYER_FIELD_BYTES` byte 0 bit `0x2` — track-stealthed active (vmangos - /// `PLAYER_FIELD_BYTE_TRACK_STEALTHED`, set by the TRACK_STEALTHED(151) aura handler): the - /// tracking classifier's stealth always-show clause — with it set, any CREEP-flagged unit - /// red-dots (`0x5ed210` @ the `+0x1028` read, wow-re `track-predicates.md`). Same - /// PRIVATE/self-only caveat as the tracking masks. + /// `PLAYER_FIELD_BYTES` byte 0 bit `0x2` (private): set by aura type 151, track stealthed; + /// every creep-flagged unit then gets a minimap dot (`0x5ed210`, the `+0x1028` read). pub fn player_track_stealthed(&self) -> bool { self.get_u32(FIELD_PLAYER_FIELD_BYTES).unwrap_or(0) & 0x2 != 0 } - /// `PLAYER_QUEST_LOG__*` — one decoded quest-log slot (0-based, `slot < - /// PLAYER_QUEST_LOG_SLOTS`). `None` when the slot's id field never streamed (or the index is out - /// of range); `Some` with `quest_id == 0` is a slot the server has explicitly cleared (an - /// abandon/turn-in leaves this). The count-state and timer fields default to `0` when absent — - /// the server writes them with the id on every occupy (vmangos `Player.h:1094-1099` - /// `SetQuestSlot`), so an absent pair only ever accompanies an untouched slot. + /// `PLAYER_QUEST_LOG__*`: one quest-log slot, `quest_id == 0` when the server cleared + /// it; the count-state and timer are written with the id (`Player.h:1094-1099`). pub fn player_quest_log(&self, slot: u8) -> Option { if slot >= PLAYER_QUEST_LOG_SLOTS { return None; @@ -298,81 +202,64 @@ impl ObjectFields { timer, }) } - /// `PLAYER_EXPLORED_ZONES_1 + i` — the discovery bitset's `i`-th u32 (absent slots read 0: - /// a fresh descriptor means nothing explored yet). The full set is - /// [`PLAYER_EXPLORED_ZONES_SLOTS`] slots; bit `n` of the whole = AreaTable exploreFlag `n`. + /// `PLAYER_EXPLORED_ZONES_1 + i`: bitset dword `i`; bit `n` is `AreaTable` explore flag `n`. pub fn player_explored_zone_slot(&self, i: u16) -> u32 { if i >= PLAYER_EXPLORED_ZONES_SLOTS { return 0; } self.get_u32(FIELD_PLAYER_EXPLORED_ZONES_1 + i).unwrap_or(0) } - /// `PLAYER_BLOCK_PERCENTAGE` — our chance to block, already a percent (2.62 = "2.62%"). The - /// spell tooltip's chance-to-X line (law §3-CHANCE) reads exactly this and its three - /// neighbours; the character sheet reads them too. `None` for a non-player unit. + /// `PLAYER_BLOCK_PERCENTAGE`: block chance, already a percent (2.62 means 2.62%). pub fn player_block_percentage(&self) -> Option { self.get_f32(FIELD_PLAYER_BLOCK_PERCENTAGE) } - /// `PLAYER_DODGE_PERCENTAGE` — see [`Self::player_block_percentage`]. + /// `PLAYER_DODGE_PERCENTAGE`: dodge chance, already a percent. pub fn player_dodge_percentage(&self) -> Option { self.get_f32(FIELD_PLAYER_DODGE_PERCENTAGE) } - /// `PLAYER_PARRY_PERCENTAGE` — see [`Self::player_block_percentage`]. + /// `PLAYER_PARRY_PERCENTAGE`: parry chance, already a percent. pub fn player_parry_percentage(&self) -> Option { self.get_f32(FIELD_PLAYER_PARRY_PERCENTAGE) } - /// `PLAYER_CRIT_PERCENTAGE` — melee crit; see [`Self::player_block_percentage`]. (The ranged - /// twin sits one slot above and has no consumer yet.) + /// `PLAYER_CRIT_PERCENTAGE`: melee crit chance, already a percent; ranged is the next field. pub fn player_crit_percentage(&self) -> Option { self.get_f32(FIELD_PLAYER_CRIT_PERCENTAGE) } - /// `PLAYER_FIELD_POSSTAT0 + i` — stat `i`'s **positive** buff delta: the sum of every gear, - /// enchant and aura contribution the server split out of `UNIT_FIELD_STAT0 + i`. **INT on the - /// wire**, whatever the server keeps internally — see the field's doc comment. `i >= 5` reads - /// `None`. + /// `PLAYER_FIELD_POSSTAT0 + i`: stat `i`'s positive buff total from gear, enchants and + /// auras; an int on the wire, narrowed from the server's float (`BuildValuesUpdate`). pub fn player_posstat(&self, i: u8) -> Option { (i < 5).then(|| self.get_i32(FIELD_PLAYER_POSSTAT0 + u16::from(i)))? } - /// `PLAYER_FIELD_NEGSTAT0 + i` — stat `i`'s **negative** buff delta; negative-or-zero, read - /// signed because only one of the two server host architectures can carry the sign at all (the - /// field's doc comment). `i >= 5` reads `None`. + /// `PLAYER_FIELD_NEGSTAT0 + i`: stat `i`'s negative buff total, an int at or below 0. An x86 + /// server sends it in two's complement, an arm64 one saturates it to 0. pub fn player_negstat(&self, i: u8) -> Option { (i < 5).then(|| self.get_i32(FIELD_PLAYER_NEGSTAT0 + u16::from(i)))? } - /// `PLAYER_FIELD_RESISTANCEBUFFMODSPOSITIVE + school` — the positive resistance buff for - /// `school` (0..6), INT on the wire. `school >= 7` reads `None`. + /// `PLAYER_FIELD_RESISTANCEBUFFMODSPOSITIVE + school`: the positive resistance buff, an int. pub fn player_resistance_buff_pos(&self, school: u8) -> Option { (school < 7) .then(|| self.get_i32(FIELD_PLAYER_RESISTANCEBUFFMODSPOSITIVE + u16::from(school)))? } - /// `PLAYER_FIELD_RESISTANCEBUFFMODSNEGATIVE + school` — negative-or-zero (see the field's doc - /// comment). `school >= 7` reads `None`. + /// `PLAYER_FIELD_RESISTANCEBUFFMODSNEGATIVE + school`: the negative resistance buff, at most 0. pub fn player_resistance_buff_neg(&self, school: u8) -> Option { (school < 7) .then(|| self.get_i32(FIELD_PLAYER_RESISTANCEBUFFMODSNEGATIVE + u16::from(school)))? } - /// `PLAYER_FIELD_MOD_DAMAGE_DONE_POS + school` — positive damage-done bonus (`[0]` = physical). - /// `school >= 7` reads `None`. + /// `PLAYER_FIELD_MOD_DAMAGE_DONE_POS + school`: the positive damage bonus, school 0 physical. pub fn player_mod_damage_done_pos(&self, school: u8) -> Option { (school < 7).then(|| self.get_i32(FIELD_PLAYER_MOD_DAMAGE_DONE_POS + u16::from(school)))? } - /// `PLAYER_FIELD_MOD_DAMAGE_DONE_NEG + school` — negative-or-zero (see the field's doc - /// comment). `school >= 7` reads `None`. + /// `PLAYER_FIELD_MOD_DAMAGE_DONE_NEG + school`: the negative damage bonus, at most 0. pub fn player_mod_damage_done_neg(&self, school: u8) -> Option { (school < 7).then(|| self.get_i32(FIELD_PLAYER_MOD_DAMAGE_DONE_NEG + u16::from(school)))? } - /// `PLAYER_FIELD_MOD_DAMAGE_DONE_PCT + school` — the damage-done percent multiplier (a true - /// float; see the field's doc comment — decision 0208's flagged question, now closed). - /// `school >= 7` reads `None`; an in-range-but-absent read (no aura has touched this school - /// yet) is the caller's job to default to `1.0` — the wire only sends fields that changed. + /// `PLAYER_FIELD_MOD_DAMAGE_DONE_PCT + school`: the damage multiplier, a true float that the + /// server starts at 1.0 (`Player.cpp:3336`); a caller reads `None` as 1.0. pub fn player_mod_damage_done_pct(&self, school: u8) -> Option { (school < 7).then(|| self.get_f32(FIELD_PLAYER_MOD_DAMAGE_DONE_PCT + u16::from(school)))? } - /// `PLAYER_SKILL_INFO__*` — one decoded skill slot (`slot < `[`PLAYER_SKILL_SLOTS`]). - /// `None` when the id dword never streamed (or `slot` is out of range); the value/max and - /// bonus dwords default to `0` when absent (mirroring [`Self::player_quest_log`]'s tolerance — - /// vmangos writes id and value together on `SetSkill`, so an absent pair only ever accompanies - /// a slot whose snapshot hasn't landed yet). + /// `PLAYER_SKILL_INFO__*`: one skill slot; vmangos writes the id and value together + /// (`SetSkill`), so an absent value or bonus reads 0. pub fn player_skill(&self, slot: u8) -> Option { if slot >= PLAYER_SKILL_SLOTS { return None; @@ -390,432 +277,226 @@ impl ObjectFields { perm_bonus: perm_bonus as i16, }) } - /// `PLAYER_AMMO_ID` — the equipped ammo pouch/quiver's item id (arrows/bullets), or `None` - /// before the field streams. `0` = no ammo selected. + /// `PLAYER_AMMO_ID`: the item id of the selected arrows or bullets, 0 for none. pub fn player_ammo_id(&self) -> Option { self.get_u32(FIELD_PLAYER_AMMO_ID) } - /// `PLAYER_SELF_RES_SPELL` — the spell the server will cast on `CMSG_SELF_RES`, i.e. **the - /// self-resurrect this corpse is owed**: a warlock's Soulstone ("Use Soulstone" 3026 / - /// 20758-20761), a shaman's Reincarnation (21169), or Twisting Nether (23700). `None` when - /// there is none — the field absent *or* zero are the same fact, so both collapse here - /// (decision 1746). - /// - /// The server writes it at the death itself (`Player::SetDeathState(JUST_DIED)` keeps a - /// standing soulstone id across the aura wipe, else falls back to - /// `Player::SelectResurrectionSpellId()` for the passive cases) and **zeroes it on any - /// resurrection by another means** — so this field alone is the whole client-side condition; - /// there is no second gate and no cooldown/reagent test on our side. It is flushed - /// *immediately*, in its own `SMSG_UPDATE_OBJECT`, one line **before** the health→0 flush - /// (`Unit::DealDamage`, `Unit.cpp:1136-1143`) — which is why the field is already in the - /// descriptor by the time the death edge fires, and why the DEATH popup can read it in - /// `OnShow`. - /// - /// `PLAYER_FLAGS_CAN_SELF_RESURRECT (0x1000)` is the pre-1.6 carrier of the same fact and is - /// dead on this build — vmangos sets it only under `SUPPORTED_CLIENT_BUILD < CLIENT_BUILD_1_6_1` - /// (`Player.cpp:1537-1569`). + /// `PLAYER_SELF_RES_SPELL`: the spell `CMSG_SELF_RES` casts (Soulstone 3026, 20758-20761, + /// Reincarnation 21169, Twisting Nether 23700) and the whole condition for offering it; set + /// one flush before health 0 at death (`Unit.cpp:1136-1143`), zeroed by any other resurrection. pub fn player_self_res_spell(&self) -> Option { self.get_u32(FIELD_PLAYER_SELF_RES_SPELL) .filter(|&s| s != 0) } - /// `PLAYER_BYTES` — packed skinColor (byte 0) / faceType (byte 1) / hairStyle (byte 2) / hairColor - /// (byte 3). The character compositor's customization input (RF-0056). + /// `PLAYER_BYTES`: skinColor, faceType, hairStyle and hairColor, bytes 0 to 3. pub fn player_bytes(&self) -> Option { self.get_u32(FIELD_PLAYER_BYTES) } - /// `PLAYER_BYTES_2` — byte 0 = facialHair, byte 1 = unk, byte 2 = bankBagSlots (see - /// [`Self::player_bank_bag_slots_purchased`]), byte 3 = restState (see - /// [`Self::player_rest_state`]). + /// `PLAYER_BYTES_2`: facialHair, unknown, bank bag slots and rest state, bytes 0 to 3. pub fn player_bytes_2(&self) -> Option { self.get_u32(FIELD_PLAYER_BYTES_2) } - /// `PLAYER_BYTES_2` **byte 3** — the rest state the client's `GetRestState()` maps to its - /// (id, name, multiplier) triple: `1` = rested, `2` = normal (vmangos `REST_STATE_*`, - /// `Player.h:673`). The server writes it with hysteresis off the rest pool — rested when the - /// pool exceeds 10, normal when it falls to ≤1 (`SetRestBonus`) — so the byte, not the pool, - /// is the state authority. - /// - /// **Absent reads `0`, not "normal"** — the struct's own absent-field law, and it is the - /// truth: vmangos always writes this word (`Player::Create` and `LoadFromDB` seed byte 3 with - /// `REST_STATE_NORMAL` and byte 1 with `0xEE`, so the field is never zero and never masked out - /// of a create block), so on a created store this IS the wire's byte. `0` is what the client's - /// own zero-initialized descriptor holds *before* the create lands, and it is a state - /// `Exhaustion.dbc` cannot map — `GetRestState()` answers the binary's `(nil, nil, nil)` fail - /// path for it, which the reference's own unguarded `exhaustionStateID >= 3` cannot survive. - /// The reference never meets that because FrameXML is up and the descriptor has landed before - /// any handler runs; we hold the same guarantee at the feed's own gate - /// (`ui_script::ingame_ui_pending`, 1348). Do not fabricate a `2` here — that hides an - /// ordering bug behind a value. + /// `PLAYER_BYTES_2` byte 3: rest state, 1 rested, 2 normal (`Player.h:673`), set with + /// hysteresis on the pool (`SetRestBonus`). Absent reads 0, which `GetRestState()` answers + /// with nils; do not default it to 2, which would hide a read before the create landed. pub fn player_rest_state(&self) -> Option { self.player_bytes_2().map(|b| (b >> 24) as u8) } - /// `PLAYER_REST_STATE_EXPERIENCE` (field 1175) — 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 `GetXPRestBonus`), so the doubled-XP span the exhaustion tick marks is 2× this - /// value. `None` before the field streams (PRIVATE — self only). + /// `PLAYER_REST_STATE_EXPERIENCE` (field 1175, private): the rested pool in base kill XP, + /// drained 1:1 while kills pay double (`GetXPRestBonus`), so the bar's rested span is twice it. pub fn player_rest_state_experience(&self) -> Option { self.get_u32(FIELD_PLAYER_REST_STATE_EXPERIENCE) } - /// `PLAYER_BYTES_3` **byte 1** — the inebriation byte (0..255) drunkenness renders from. The - /// server packs `gender | (drunk & 0xFFFE)` into the field's low u16 and decays it 256 per - /// 10 s (vmangos `Player::SetDrunkValue` / `HandleDrunkReduce`), so this byte is - /// `drunk_u16 >> 8` — the value the reference clamps to 100 and scales ×0.01 into its drunk - /// fraction (`[[unit+0xe68]+0x1d]`, wow-re `drunk_fraction_5e2a90`). `None` when never sent - /// on a bare delta; a create snapshot reads absent as `Some(0)` (sober). + /// `PLAYER_BYTES_3` byte 1: drunkenness, the high byte of `drunk & 0xFFFE` in the low half + /// (`SetDrunkValue`); the reference clamps it to 100 and scales by 0.01 (`0x5e2a90`). pub fn player_drunk_byte(&self) -> Option { self.get_u32(FIELD_PLAYER_BYTES_3).map(|v| (v >> 8) as u8) } - /// `PLAYER_FLAGS` (field 190) — the player state flags. `0` when absent (the descriptor's - /// zero default — a create block skips zero fields). + /// `PLAYER_FLAGS` (field 190): the player state flags. pub fn player_flags(&self) -> u32 { self.get_u32(FIELD_PLAYER_FLAGS).unwrap_or(0) } - /// **`PLAYER_FLAGS` bit `0x1` — this player leads their group.** The sole consumer image-wide - /// is `UnitIsPartyLeader 0x516210`'s first leg (`0x51624a`), out of 55 sites reading this - /// field, and it is the only test of the bit anywhere (wow-re - /// `ui/scratch/party-leader-and-nameplate-verbs.md`). - /// - /// The bit's POSITION is verified; the name `PLAYER_FLAGS_GROUP_LEADER` is inferred, earned - /// from two disjoint in-binary controls that pin its neighbours — `0x5eca0c` → `"CHAT_FLAG_AFK"` - /// and `0x5eca49` → `"CHAT_FLAG_DND"` fix bits 1 and 2, so this is the one before them. - /// - /// **Server-sourced, and that is the point.** It answers for any held player, including a - /// stranger who leads their *own* party — something a comparison against our group's leader - /// GUID cannot express. It is one of the predicate's two legs, never the whole of it. + /// `PLAYER_FLAGS` bit `0x1`: leads their group, for a stranger's party too; the first leg of + /// `UnitIsPartyLeader` (`0x51624a`) and the bit's only test. pub fn player_is_group_leader(&self) -> bool { self.player_flags() & 0x1 != 0 } - /// Whether the player is a released **ghost** — `PLAYER_FLAGS_GHOST (0x10)`, set/cleared by - /// the ghost aura 8326 at release/resurrect (decision 0308 §1). NB a ghost's health is **1** - /// on the wire, so `unit_is_dead` is FALSE for a ghost: the three predicates are - /// dead = health 0 · ghost = this bit · dead-or-ghost = either (the real client's - /// UnitIsDead/UnitIsGhost/UnitIsDeadOrGhost trio). + /// `PLAYER_FLAGS_GHOST` (`0x10`), from aura 8326; a ghost has health 1, so not `unit_is_dead`. pub fn player_is_ghost(&self) -> bool { self.player_flags() & 0x10 != 0 } - /// Whether this player has asked for their **helm** not to be drawn — - /// `PLAYER_FLAGS_HIDE_HELM (0x400)` (vmangos `Player.h:325`), flipped by `CMSG_TOGGLE_HELM` - /// and persisted per character through the char-enum record's `CHARACTER_FLAG_HIDE_HELM`. - /// - /// `PLAYER_FLAGS` is `UF_FLAG_PUBLIC`, which is the whole point of reading it *here* rather - /// than off a local setting: the preference streams for every player in range, so a remote - /// player's hidden helm hides on our screen too, and ours hides on theirs (decision 1472). - /// Creatures have no `PLAYER_FLAGS` and read `false`. + /// `PLAYER_FLAGS_HIDE_HELM` (`0x400`, `Player.h:325`), set by `CMSG_TOGGLE_HELM`; public. pub fn player_hides_helm(&self) -> bool { self.player_flags() & 0x400 != 0 } - /// Whether this player has asked for their **cloak** not to be drawn — - /// `PLAYER_FLAGS_HIDE_CLOAK (0x800)`, [`Self::player_hides_helm`]'s twin in every respect. + /// `PLAYER_FLAGS_HIDE_CLOAK` (`0x800`), public like the helm bit. pub fn player_hides_cloak(&self) -> bool { self.player_flags() & 0x800 != 0 } - /// Whether the player is inside a rest area (inn/city) — `PLAYER_FLAGS_RESTING (0x20)` - /// (vmangos `Player.h:320`), set/cleared by the area-trigger/zone rest checks - /// (`SetRestType`). The real client's `IsResting()` and the player frame's flashing zzz - /// state icon read exactly this bit. + /// `PLAYER_FLAGS_RESTING` (`0x20`, `Player.h:320`): in an inn or city; `IsResting()` reads it. pub fn player_is_resting(&self) -> bool { self.player_flags() & 0x20 != 0 } - /// `PLAYER_DUEL_ARBITER` (field 188, GUID) — the duel-flag GameObject of the duel this player - /// is in, `0` when they are in none. Set on both duellists by `Spell::EffectDuel`, cleared by - /// `Player::DuelComplete`; **PUBLIC**, so it streams for every player we can see and not just - /// ourselves — which is what lets the client resolve a duel relationship it is not part of. - /// Paired with [`Self::player_duel_team`] it is the whole client-side duel law (0633). + /// `PLAYER_DUEL_ARBITER` (field 188, guid, public): the duel flag GameObject, 0 for none; + /// set on both duellists at the challenge (`Spell::EffectDuel`). pub fn player_duel_arbiter(&self) -> u64 { self.get_guid(FIELD_PLAYER_DUEL_ARBITER).unwrap_or(0) } - /// `PLAYER_DUEL_TEAM` (field 196) — `0` until the countdown finishes, then `1` for the - /// challenger and `2` for the challenged (`Player::UpdateDuelFlag`). Non-zero on **both** - /// sides is what says the duel is actually under way: the arbiter is set from the moment of - /// the challenge, so hostility keys off the pair, never the arbiter alone (0633). + /// `PLAYER_DUEL_TEAM` (field 196): 0 until the countdown ends, then 1 challenger, 2 + /// challenged (`Player::UpdateDuelFlag`); a duel is live only when both sides are nonzero. pub fn player_duel_team(&self) -> u32 { self.get_u32(FIELD_PLAYER_DUEL_TEAM).unwrap_or(0) } - /// `PLAYER_GUILDID` (field 191) — the id of the guild this player belongs to, `0` for a - /// guildless one (and for the absent field, which is the same thing: the descriptor skips - /// zeroes). **PUBLIC**, so it arrives for every visible player, not only for us. - /// - /// It names a guild but does not describe one: the name and the rank names come only from - /// `SMSG_GUILD_QUERY_RESPONSE`, keyed by this id (decision 1257). The real client's own - /// `GetGuildInfo 0x4c9330` reads exactly this field and answers nil on both the `0` and the - /// cache-miss legs (`0x4c93a9`, `0x4c93d7`). + /// `PLAYER_GUILDID` (field 191, public): the guild, 0 for none; names come from + /// `SMSG_GUILD_QUERY_RESPONSE`, and `GetGuildInfo` (`0x4c9330`) answers nil on 0 or a miss. pub fn player_guild_id(&self) -> u32 { self.get_u32(FIELD_PLAYER_GUILDID).unwrap_or(0) } - /// `PLAYER_GUILDRANK` (field 192) — this player's rank within [`Self::player_guild_id`]'s - /// guild, **0-based with `0` = guild master** (vmangos promotes with `rank--`). Meaningless - /// while the guild id is `0`. Indexes both [`crate::messages::GuildQueryResponse::rank_names`] - /// and [`crate::messages::GuildRoster::rank_rights`]. - /// - /// The reference indexes its cached rank-name array with this **unbounded** - /// (`0x4c93e4 mov edx,[ecx+0x10]` / `shl edx,0x6`); a consumer of ours must bound it against - /// the ten slots that array really has. + /// `PLAYER_GUILDRANK` (field 192): the guild rank, 0 the guild master; the reference indexes + /// its rank names with it unbounded (`0x4c93e4`), so bound it to the ten ranks. pub fn player_guild_rank(&self) -> u32 { self.get_u32(FIELD_PLAYER_GUILDRANK).unwrap_or(0) } - /// `PLAYER_FIELD_BYTES` byte 0 bit `0x08` — `PLAYER_FIELD_BYTE_RELEASE_TIMER`, "Display time - /// till auto release spirit" (the header's own comment): set at death iff the map is - /// non-instanceable, i.e. exactly when the server will force-release after 6:00. The client - /// mirrors that constant for the DEATH popup's countdown (byte-VERIFIED — wow-re death-ui.md - /// §1: the client arms `now + 360000 ms` at its own alive→dead edge; bit clear ⇒ - /// `GetReleaseTimeRemaining() == -1`). + /// `PLAYER_FIELD_BYTES` byte 0 bit `0x08`, `PLAYER_FIELD_BYTE_RELEASE_TIMER`: set at death + /// outside instances, where the server releases the spirit after 6 minutes; the client arms + /// its own `now + 360000` ms, and with the bit clear `GetReleaseTimeRemaining()` is -1. pub fn player_release_timer_running(&self) -> bool { self.get_u32(FIELD_PLAYER_FIELD_BYTES).unwrap_or(0) & 0x08 != 0 } - /// `PLAYER_FIELD_BYTES` byte 1 — the player's **combo points** (0..5; vmangos - /// `PLAYER_FIELD_BYTES_OFFSET_COMBO_POINTS`, written by `Player::SetComboPoints`). PRIVATE, so - /// it streams only for our own player — the only consumer. The client reads this byte at - /// `[player+0xe68]+0x1029` in the usable walk's leg 5 (the combo-point gate, decision 0869): - /// the same dword whose byte 3 is [`Self::player_honor_rank`]'s byte-verified `+0x102b`, off - /// the player-block base the duel-arbiter comment pins at `[player+0xe68]`. - /// - /// **The byte is not rogue/druid-only — the *display* is** (decision 0875). vmangos writes one - /// here when a *warrior's* victim **dodges** (`Unit.cpp` `PROC_EX_DODGE` → - /// `AddComboPoints(target, 1)` plus a 4 s `REACTIVE_OVERPOWER` timer whose expiry - /// `ClearComboPoints`es it) — the server's chosen marker for "Overpower is up", and the only - /// way that window reaches the client. Leg 5 reads this byte with no class test, which is what - /// greys Overpower; the Lua `GetComboPoints 0x51a190` reads the *same* byte behind a - /// rogue-or-druid class gate, which is why a warrior never sees a combo dot light up for it. - /// `None` before the field streams. + /// `PLAYER_FIELD_BYTES` byte 1 (private): combo points, 0-5, read at `[player+0xe68]+0x1029`. + /// vmangos also sets one when a warrior's target dodges (the 4 s Overpower window): the usable + /// check reads it for any class, `GetComboPoints` (`0x51a190`) only for rogues and druids. pub fn player_combo_points(&self) -> Option { self.get_u32(FIELD_PLAYER_FIELD_BYTES) .map(|b| ((b >> 8) & 0xff) as u8) } - /// `PLAYER_FIELD_COMBO_TARGET` (field 714, GUID) — the unit the banked - /// [`Self::player_combo_points`] sit on, written by `Player::SetComboPoints` in the same breath - /// as the byte and zeroed with it. PRIVATE, so it only ever streams for our own player. - /// - /// Combo points are **per target**, and the client enforces that in the *display*, not on the - /// wire: `GetComboPoints 0x51a190` reads this pair at `[player+0xe68]+0x838/+0x83c`, compares - /// it against the current-target GUID global (`0xb4e2d8/dc`) and pushes `0` on a mismatch - /// (decision 0875) — so re-targeting empties the dots without the count moving, and selecting - /// the original unit again refills them. `0` when nothing is banked. + /// `PLAYER_FIELD_COMBO_TARGET` (field 714, guid, private): the unit the combo points sit on; + /// `GetComboPoints` shows 0 unless it is the current target (`0xb4e2d8`). pub fn player_combo_target(&self) -> u64 { self.get_guid(FIELD_PLAYER_FIELD_COMBO_TARGET).unwrap_or(0) } - /// `PLAYER_FIELD_BYTES` byte 2 — the **action-bar toggle** byte: which of the four extra bars - /// the player has switched on (vmangos `Player.h:360-363` - /// `PLAYER_FIELD_BYTES_OFFSET_ACTION_BARS = 2`, written by `HandleSetActionBarTogglesOpcode`'s - /// `SetByteValue(PLAYER_FIELD_BYTES, 2, packet.actionBar)`). PRIVATE — the field is only - /// *allocated* on our own player (self 1282 dwords vs 486 for any other player), which is why - /// the reference binding resolves the local GUID itself instead of taking a unit token. - /// - /// Byte-VERIFIED (wow-re `system/ui/scratch/action-bar-toggles.md` §4): `GetActionBarToggles - /// 0x4e7660` reads it at `[[player+0xe68]+0x102a]`, and `0x2f0 + 0x1028 = 0x1318 = 1222·4` - /// closes the arithmetic against index 1222 — the same player-block base that puts - /// [`Self::player_combo_points`] at `+0x1029` and [`Self::player_honor_rank`] at `+0x102b`. - /// (vmangos's inline comment on the field reads `// 0x4C0`, six low; its own arithmetic - /// `UNIT_END + 0x40A` and the binary both say `0x4C6`. The comment is stale — do not copy it.) - /// - /// **This is the ONLY source of the value: the client never writes the byte.** Displacement - /// `0x102a` occurs exactly once image-wide and it is that read; `SetActionBarToggles` stores - /// nothing outside its own stack frame and only posts `CMSG_SET_ACTIONBAR_TOGGLES` - /// ([`crate::messages::set_actionbar_toggles`]), so the cell moves solely through the generic - /// `SMSG_UPDATE_OBJECT` value-apply — and nothing is notified when it does (§4.2). A purely - /// field-driven UI therefore lags a round trip; the reference keeps its optimistic copy in Lua - /// and re-reads this exactly once, at `PLAYER_ENTERING_WORLD`. - /// - /// Only bits `0x01`..`0x08` are meaningful to the client — the binding tests four and can set - /// four (§2/§5); the high nibble is never read and is destroyed by the next `Set`. Bit→bar is a - /// FrameXML convention, not the descriptor's, so it is not named here. `None` before the field - /// streams. + /// `PLAYER_FIELD_BYTES` byte 2 (private, field 1222): the four extra action bars' toggles, + /// bits `0x01..0x08` (`Player.h:360-363`), read by `GetActionBarToggles` (`0x4e7660`) at + /// `[[player+0xe68]+0x102a]`. The client never writes it and no event fires when it moves. + /// vmangos's `// 0x4C0` beside the field is stale; `UNIT_END + 0x40A` is right. pub fn player_action_bar_toggles(&self) -> Option { self.get_u32(FIELD_PLAYER_FIELD_BYTES) .map(|b| ((b >> 16) & 0xff) as u8) } - /// `PLAYER_FIELD_BYTES` byte 3 — the player's **highest lifetime honor rank** (vmangos - /// `PLAYER_FIELD_BYTES_OFFSET_HIGHEST_HONOR_RANK`, written by `HonorMgr`): what the client's - /// item-usable gate compares an item's `RequiredHonorRank` against (`0x5ea930` reads the - /// values byte at `+0x102b`). `None` before the field streams; rank 0 = never ranked. - /// - /// **Not [`Self::player_pvp_rank`]** — that is the *current* rank, a different byte of a - /// different (and PUBLIC) field, and the two diverge the moment a rank is lost: vmangos only - /// ever raises this one (`HonorMgr.cpp:894-895`, `SetHighestRank` iff the new visual rank is - /// strictly greater), so a demoted player keeps their old title here while the current rank - /// falls. The Honor pane shows the current rank; an item requirement reads this one. + /// `PLAYER_FIELD_BYTES` byte 3 (private): the highest honor rank held, 0 never ranked, which + /// `RequiredHonorRank` checks (`0x5ea930`); only ever raised (`HonorMgr.cpp:894-895`), so it + /// outlives a demotion, unlike [`Self::player_pvp_rank`]. pub fn player_honor_rank(&self) -> Option { self.get_u32(FIELD_PLAYER_FIELD_BYTES) .map(|b| ((b >> 24) & 0xff) as u8) } - /// `PLAYER_BYTES_3` byte 3 — the player's **current** honor rank (vmangos - /// `PLAYER_BYTES_3_OFFSET_HONOR_RANK`, written by `HonorMgr::Update`, `HonorMgr.cpp:900`). - /// The *internal* rank — `HonorRankInfo::rank`, whose own comment reads "internal range - /// [0..18]": 0 = unranked, 1..4 the negative ranks (Pariah…Dishonored), 5 up the positive ones - /// (Scout/Private…). It indexes the `PVP_RANK__` GlobalStrings directly; the - /// number the UI *draws* is the **visual** rank (`rank > 4 ? rank - 4 : -rank`, - /// `HonorMgr.cpp:991`), which is a derivation, not a field — do it at the display edge, not - /// here, and only once. - /// - /// **This field is PUBLIC** (vmangos `UpdateFields_1_12_1.h:125`), the only honor value that - /// is: it streams for *every visible player*, not just for us. That is what makes a foreign - /// unit's rank answerable at all — the reference inspect pane's `UnitPVPRank("target")` reads - /// this byte off the target's own descriptor, with no packet involved. Everything else on the - /// Honor tab is PRIVATE and has to be requested - /// ([`crate::messages::read_inspect_honor_stats`]). - /// - /// Contrast [`Self::player_honor_rank`], the *highest lifetime* rank in a PRIVATE field's - /// byte 3; [`Self::player_drunk_byte`] reads byte 1 of this same dword. `None` before the - /// field streams. + /// `PLAYER_BYTES_3` byte 3 (public, `UpdateFields_1_12_1.h:125`): the current internal rank + /// (`HonorMgr.cpp:900`), 0 unranked, 1-4 negative, 5 up positive, indexing `PVP_RANK_*`; + /// the drawn rank is `rank > 4 ? rank - 4 : -rank` (`HonorMgr.cpp:991`). pub fn player_pvp_rank(&self) -> Option { self.get_u32(FIELD_PLAYER_BYTES_3) .map(|b| ((b >> 24) & 0xff) as u8) } - /// `PLAYER_BYTES_3` **byte 2** — the **city-protector title** - /// (`PLAYER_BYTES_3_OFFSET_CITY_PROTECTOR_TITLE`, vmangos `Player.h:354`, whose own comment - /// says the value is a **race id**). PUBLIC, like [`Self::player_pvp_rank`] beside it in the - /// same dword. - /// - /// Its one consumer is `UnitPVPName`, which appends `"\n"` plus the `PVP_MEDAL` - /// GlobalString when the byte is non-zero (wow-re `system/ui/scratch/honor-panel-law.md`, - /// `0x609370` leg C) — the "Protector of Stormwind" line under a city defender's name. - /// - /// **vmangos never writes it**, so it is 0 for every character on this server and the medal - /// line never renders. Decoded anyway: the field is real, the binding that reads it is real, - /// and a decoder that answered `None` here would make a live divergence look like an unbuilt - /// feature the day a server does write it. `None` before the field streams. + /// `PLAYER_BYTES_3` byte 2 (public): the city-protector title, a race id (`Player.h:354`); + /// `UnitPVPName` (`0x609370`) appends `PVP_MEDAL` when nonzero. vmangos never writes it. pub fn player_pvp_medal(&self) -> Option { self.get_u32(FIELD_PLAYER_BYTES_3) .map(|b| ((b >> 16) & 0xff) as u8) } - /// `PLAYER_FIELD_SESSION_KILLS` — **today's** `(honorable, dishonorable)` kills, low half then - /// high half. The one honor kill counter vmangos genuinely writes as two shorts - /// (`HonorMgr.cpp:913-914`, `SetUInt16Value(…, 0, todayHK)` / `(…, 1, todayDK)`), so both - /// halves are live here. PRIVATE — self only; `None` before the field streams. - /// - /// Despite the name the server recomputes it from *today's* honor-CP rows, not from - /// session start (`HonorMgr::Update`'s `itr.date == today` arm) — the reference's - /// `GetPVPSessionStats()` pair. + /// `PLAYER_FIELD_SESSION_KILLS` (private): today's honorable and dishonorable kills, both + /// halves written (`HonorMgr.cpp:913-914`); "session" means today, `GetPVPSessionStats()`. pub fn player_session_kills(&self) -> Option<(u16, u16)> { self.get_u16_pair(FIELD_PLAYER_FIELD_SESSION_KILLS) } - /// `PLAYER_FIELD_YESTERDAY_KILLS` — `(honorable, dishonorable)` kills for yesterday. - /// - /// **The dishonorable half is always `0` from this server, and that is not a decode bug.** - /// The descriptor type really is TWO_SHORT and the real client really does read halves, but - /// vmangos writes the field as a whole dword (`HonorMgr.cpp:917`, - /// `SetUInt32Value(PLAYER_FIELD_YESTERDAY_KILLS, yesterdayKills)`) — so the high half never - /// receives the DK count. We decode the wire faithfully rather than collapsing the field to a - /// `u32`: the shape belongs to the descriptor, and a different server (or a fixed vmangos) - /// would fill it. Same for [`Self::player_last_week_kills`] and - /// [`Self::player_this_week_kills`]. PRIVATE — self only; `None` before the field streams. + /// `PLAYER_FIELD_YESTERDAY_KILLS` (private): yesterday's kills, two shorts; vmangos writes a + /// whole dword (`HonorMgr.cpp:917`), so the dishonorable half is 0, not a decode bug. pub fn player_yesterday_kills(&self) -> Option<(u16, u16)> { self.get_u16_pair(FIELD_PLAYER_FIELD_YESTERDAY_KILLS) } - /// `PLAYER_FIELD_LAST_WEEK_KILLS` — `(honorable, dishonorable)` kills for last week. The - /// high half is always `0` from vmangos (`HonorMgr.cpp:929` writes the whole dword); see - /// [`Self::player_yesterday_kills`]. PRIVATE — self only. + /// `PLAYER_FIELD_LAST_WEEK_KILLS` (private): last week's kills; the dishonorable half is 0 + /// from vmangos (`HonorMgr.cpp:929` writes a whole dword). pub fn player_last_week_kills(&self) -> Option<(u16, u16)> { self.get_u16_pair(FIELD_PLAYER_FIELD_LAST_WEEK_KILLS) } - /// `PLAYER_FIELD_THIS_WEEK_KILLS` — `(honorable, dishonorable)` kills for the current week. - /// The high half is always `0` from vmangos (`HonorMgr.cpp:924` writes the whole dword); see - /// [`Self::player_yesterday_kills`]. PRIVATE — self only. + /// `PLAYER_FIELD_THIS_WEEK_KILLS` (private): this week's kills; the dishonorable half is 0 + /// from vmangos (`HonorMgr.cpp:924` writes a whole dword). pub fn player_this_week_kills(&self) -> Option<(u16, u16)> { self.get_u16_pair(FIELD_PLAYER_FIELD_THIS_WEEK_KILLS) } - /// `PLAYER_FIELD_THIS_WEEK_CONTRIBUTION` — honor points earned this week (vmangos's - /// "contribution": the summed CP of the week's honor rows, floored at 0 — - /// `HonorMgr.cpp:925`). PRIVATE — self only; `None` before the field streams. + /// `PLAYER_FIELD_THIS_WEEK_CONTRIBUTION` (private): honor earned this week, floored at 0 + /// (`HonorMgr.cpp:925`). pub fn player_this_week_contribution(&self) -> Option { self.get_u32(FIELD_PLAYER_FIELD_THIS_WEEK_CONTRIBUTION) } - /// `PLAYER_FIELD_YESTERDAY_CONTRIBUTION` — honor points earned yesterday - /// (`HonorMgr.cpp:918`). PRIVATE — self only. + /// `PLAYER_FIELD_YESTERDAY_CONTRIBUTION` (private): yesterday's honor (`HonorMgr.cpp:918`). pub fn player_yesterday_contribution(&self) -> Option { self.get_u32(FIELD_PLAYER_FIELD_YESTERDAY_CONTRIBUTION) } - /// `PLAYER_FIELD_LAST_WEEK_CONTRIBUTION` — honor points earned last week - /// (`HonorMgr.cpp:930`). This is the figure the weekly rank calculation actually consumed. - /// PRIVATE — self only. + /// `PLAYER_FIELD_LAST_WEEK_CONTRIBUTION` (private): last week's honor, what the weekly rank + /// calculation used (`HonorMgr.cpp:930`). pub fn player_last_week_contribution(&self) -> Option { self.get_u32(FIELD_PLAYER_FIELD_LAST_WEEK_CONTRIBUTION) } - /// `PLAYER_FIELD_LAST_WEEK_RANK` — last week's **standing**, i.e. our numeric position on the - /// server-wide honor ladder for that week (vmangos `m_standing`, `HonorMgr.cpp:931`), NOT an - /// honor rank. `0` means "unranked that week". PRIVATE — self only. + /// `PLAYER_FIELD_LAST_WEEK_RANK` (private): last week's standing on the honor ladder, not an + /// honor rank; 0 is unranked (`HonorMgr.cpp:931`). pub fn player_last_week_rank(&self) -> Option { self.get_u32(FIELD_PLAYER_FIELD_LAST_WEEK_RANK) } - /// `PLAYER_FIELD_LIFETIME_HONORBALE_KILLS` (vmangos's spelling) — the lifetime honorable-kill - /// total (`HonorMgr.cpp:934`). PRIVATE — self only; `None` before the field streams. + /// `PLAYER_FIELD_LIFETIME_HONORBALE_KILLS` (private, vmangos's spelling): lifetime honorable + /// kills (`HonorMgr.cpp:934`). pub fn player_lifetime_honorable_kills(&self) -> Option { self.get_u32(FIELD_PLAYER_FIELD_LIFETIME_HONORABLE_KILLS) } - /// `PLAYER_FIELD_LIFETIME_DISHONORBALE_KILLS` (vmangos's spelling) — the lifetime - /// dishonorable-kill total (`HonorMgr.cpp:935`). PRIVATE — self only. + /// `PLAYER_FIELD_LIFETIME_DISHONORBALE_KILLS` (private, vmangos's spelling): lifetime + /// dishonorable kills (`HonorMgr.cpp:935`). pub fn player_lifetime_dishonorable_kills(&self) -> Option { self.get_u32(FIELD_PLAYER_FIELD_LIFETIME_DISHONORABLE_KILLS) } - /// `PLAYER_FIELD_BYTES2` byte 0 — the **honor rank progress bar** - /// (`PLAYER_FIELD_BYTES_2_OFFSET_HONOR_RANK_BAR`, vmangos `HonorMgr.cpp:905-909`): - /// - /// ```text - /// honorBar = uint32(|rankPoints|); - /// honorBar = uint8(((honorBar - rank.minRP) / (rank.maxRP - rank.minRP)) - /// * (rank.positive ? 255 : -255)); - /// ``` - /// - /// so the byte is **how far through the CURRENT rank we are, scaled 0..255** — not an absolute - /// honor total, and it resets each time the rank changes (`minRP`/`maxRP` are that rank's own - /// bounds, `HonorMgr::CalculateRankInfo`). A *negative* rank multiplies by `-255`, and the - /// C++ cast of a negative float to `uint8` wraps rather than clamping, so the byte a demoted - /// player carries is the wrapped product. We carry the byte the wire sent; interpreting the - /// negative-rank case is the display edge's problem, not the decoder's. - /// - /// The field only exists from client 1.6.1 on (vmangos guards the write with - /// `SUPPORTED_CLIENT_BUILD >= CLIENT_BUILD_1_6_1`), which is live for build 5875. PRIVATE — - /// self only; `None` before the field streams. + /// `PLAYER_FIELD_BYTES2` byte 0 (private): progress through the current rank, 0-255 + /// (`HonorMgr.cpp:905-909`); a negative rank's `-255` scale wraps in the `uint8` cast. pub fn player_honor_rank_bar(&self) -> Option { self.get_u32(FIELD_PLAYER_FIELD_BYTES2) .map(|b| (b & 0xff) as u8) } - /// `UNIT_FIELD_BYTES_0` byte 0 — the unit's race. `None` when the field wasn't sent. + /// `UNIT_FIELD_BYTES_0` byte 0: race. pub fn unit_race(&self) -> Option { self.unit_bytes_0().map(|b| (b & 0xff) as u8) } - /// `UNIT_FIELD_BYTES_0` byte 1 — the unit's class. + /// `UNIT_FIELD_BYTES_0` byte 1: class. pub fn unit_class(&self) -> Option { self.unit_bytes_0().map(|b| ((b >> 8) & 0xff) as u8) } - /// `UNIT_FIELD_BYTES_0` byte 2 — the unit's gender/sex. + /// `UNIT_FIELD_BYTES_0` byte 2: gender. pub fn unit_gender(&self) -> Option { self.unit_bytes_0().map(|b| ((b >> 16) & 0xff) as u8) } - /// `PLAYER_BYTES` byte 0 — skinColor. `None` for a non-player unit (no PLAYER block). + /// `PLAYER_BYTES` byte 0: skinColor. pub fn player_skin(&self) -> Option { self.player_bytes().map(|b| (b & 0xff) as u8) } - /// `PLAYER_BYTES` byte 1 — faceType. + /// `PLAYER_BYTES` byte 1: faceType. pub fn player_face(&self) -> Option { self.player_bytes().map(|b| ((b >> 8) & 0xff) as u8) } - /// `PLAYER_BYTES` byte 2 — hairStyle. + /// `PLAYER_BYTES` byte 2: hairStyle. pub fn player_hair_style(&self) -> Option { self.player_bytes().map(|b| ((b >> 16) & 0xff) as u8) } - /// `PLAYER_BYTES` byte 3 — hairColor. + /// `PLAYER_BYTES` byte 3: hairColor. pub fn player_hair_color(&self) -> Option { self.player_bytes().map(|b| ((b >> 24) & 0xff) as u8) } - /// `PLAYER_BYTES_2` byte 0 — facialHair. + /// `PLAYER_BYTES_2` byte 0: facialHair. pub fn player_facial_hair(&self) -> Option { self.player_bytes_2().map(|b| (b & 0xff) as u8) } - /// `PLAYER_BYTES_2` byte 2 — `PLAYER_BYTES_2_OFFSET_BANK_BAG_SLOTS` (vmangos `Player.h:347`): - /// how many of the 6 purchasable bank bag slots are bought. Byte **2**, not byte 1 — the - /// server writes byte 0 facialHair, byte 1 unused, byte 2 bankBagSlots, byte 3 restState. The - /// only success signal for `CMSG_BUY_BANK_SLOT` (decision 0604): the server sends no packet on - /// success, only this descriptor delta (`PLAYERBANKBAGSLOTS_CHANGED` on the reference client). + /// `PLAYER_BYTES_2` byte 2 (`Player.h:347`): bank bag slots bought, of 6; this delta is the + /// only reply to a successful `CMSG_BUY_BANK_SLOT` (`PLAYERBANKBAGSLOTS_CHANGED`). pub fn player_bank_bag_slots_purchased(&self) -> Option { self.player_bytes_2().map(|b| ((b >> 16) & 0xff) as u8) } - /// `OBJECT_FIELD_CREATED_BY` (the GameObject block's creator guid, fields 6-7 in 1.12): the - /// summoning player of a spell-spawned object — the fishing bobber's owner. `None` when absent - /// or zero (a world-spawned GO has no creator). The reference's ONE reader on the interaction - /// path is the faction resolver `0x5f7fd0`, which prefers the creator's reaction over - /// `GAMEOBJECT_FACTION` — the bobber's ownership *gate* is NOT this field but the player's own - /// `UNIT_FIELD_CHANNEL_OBJECT` (wow-re `fishing-bobber-interaction.md` §2, the byte-proven - /// negative: `0x5f6710` never reads CREATED_BY). + /// `OBJECT_FIELD_CREATED_BY` (fields 6-7): who summoned a spell-spawned object; the faction + /// resolver `0x5f7fd0` prefers its reaction, but a bobber's use gate is our channel object. pub fn gameobject_created_by(&self) -> Option { self.get_guid(FIELD_GAMEOBJECT_CREATED_BY) .filter(|&g| g != 0) @@ -824,19 +505,12 @@ impl ObjectFields { pub fn gameobject_displayid(&self) -> Option { self.get_i32(FIELD_GAMEOBJECT_DISPLAYID) } - /// `GAMEOBJECT_STATE` — 0 active (door open/triggered), 1 ready (closed/armed), 2 active-alt - /// (vmangos `GOState`, `OBJECT_END + 0x8` in 1.12). Drives the open/close sound slots. + /// `GAMEOBJECT_STATE` (`OBJECT_END + 0x8`): 0 active (open), 1 ready (closed), 2 active-alt. pub fn gameobject_state(&self) -> Option { self.get_u32(FIELD_GAMEOBJECT_STATE) } - /// `GAMEOBJECT_ROTATION` — the spawn's local-rotation quaternion `(x, y, z, w)`. `None` when - /// the wire carried no component at all (vmangos omits zero fields from the create mask, and - /// an identity-ish quat still sends its nonzero `w`/`z`); absent components within a - /// partially-sent quat read as the create-block zero default, so a pure-yaw spawn - /// (`x = y = 0`, the live-data norm) round-trips exactly. This is the field a GameObject is - /// *placed* by — the reference builds its render matrix from this quaternion and never from - /// `GAMEOBJECT_FACING` (decision 1459) — and the basis a type-11 lift rotates its keyframe - /// offsets through. + /// `GAMEOBJECT_ROTATION`: the spawn quaternion `(x, y, z, w)`, unsent components 0; the + /// reference places a GameObject by it, never by `GAMEOBJECT_FACING`. pub fn gameobject_rotation(&self) -> Option<[f32; 4]> { let any_sent = (0..4u16).any(|i| self.contains(FIELD_GAMEOBJECT_ROTATION + i)); any_sent.then(|| { @@ -848,12 +522,8 @@ impl ObjectFields { ] }) } - /// GameObject position + facing (`GAMEOBJECT_POS_*` / `GAMEOBJECT_FACING`). On a create block this - /// is `Some` **even when none of the three were sent** — the created-store zero-fill - /// (`get_f32`/`get_u32`'s `created.then_some(0)`) can't tell "genuinely at the origin" from "never - /// carried a position at all" (a transport GameObject, whose stationary spot rides the movement - /// block's `HAS_POSITION` slot instead — decision 0438 "the wire"). [`Self::gameobject_pos_sent`] - /// answers that distinction; the create-placement fallback (`events/decode.rs`) gates on it. + /// `GAMEOBJECT_POS_*` and `GAMEOBJECT_FACING`; a create reads `Some` even with no position + /// sent, as for a transport, so check [`Self::gameobject_pos_sent`]. pub fn gameobject_position(&self) -> Option<(Vector3d, f32)> { Some(( Vector3d { @@ -864,66 +534,43 @@ impl ObjectFields { self.get_f32(FIELD_GAMEOBJECT_FACING).unwrap_or(0.0), )) } - /// Whether the wire genuinely carried **any** `GAMEOBJECT_POS_*` field — a raw presence probe on purpose - /// (like `get_guid`'s, bypassing the create block's absent-is-zero fold) so a consumer can tell - /// "this GO's create never carried a position" from "it did, and it's `0.0` on some axis". A normal - /// GameObject sends at least one non-zero axis virtually always; a boat/zeppelin/elevator sends - /// **none** (vmangos never sets `GAMEOBJECT_POS_*` on a transport). + /// Whether any `GAMEOBJECT_POS_*` was really sent; vmangos sends none for a transport. pub fn gameobject_pos_sent(&self) -> bool { self.contains(FIELD_GAMEOBJECT_POS_X) || self.contains(FIELD_GAMEOBJECT_POS_Y) || self.contains(FIELD_GAMEOBJECT_POS_Z) } - /// `GAMEOBJECT_TYPE_ID` — the GameObject sub-type (DOOR/CHEST/SPELL_FOCUS/…). **Absent ⇒ `0` = - /// DOOR**, not "unknown": vmangos omits a zero-valued field from the create mask (the same reason an - /// all-zero `PLAYER_BYTES` is never sent), and `GAMEOBJECT_TYPE_DOOR` *is* 0 — so a door sends no type - /// field at all, and the absent value is its default. Reading it as `None` silently mis-handled every - /// door (no interact cursor, no open/close animation), so this defaults to the wire default. + /// `GAMEOBJECT_TYPE_ID`: absent is 0, `GAMEOBJECT_TYPE_DOOR`: a create omits zero fields. pub fn gameobject_type_id(&self) -> i32 { self.get_i32(FIELD_GAMEOBJECT_TYPE_ID).unwrap_or(0) } - /// `GAMEOBJECT_FLAGS` (`GameObjectFlags`) — static interactability bits: `0x1` IN_USE (busy), - /// `0x4` INTERACT_COND (usable only when the per-player `GAMEOBJECT_DYN_FLAGS` activate bit is set — - /// the quest gate), `0x10` NO_INTERACT. Read by the cursor/interact usability gate (decision 0243). + /// `GAMEOBJECT_FLAGS`: `0x1` in use, `0x4` needs the quest activate bit, `0x10` no interact. pub fn gameobject_flags(&self) -> u32 { self.get_u32(FIELD_GAMEOBJECT_FLAGS).unwrap_or(0) } - /// `GAMEOBJECT_FACTION` — the object's `FactionTemplate.dbc` id, or `0` for "no faction" - /// (which the reference resolves as NEUTRAL). The highlightable predicate's faction term reads - /// exactly this (`[go+0x110]+0x38`, decision 0764): a GameObject whose template is **hostile to - /// the player** is not highlightable, so it shows no cursor, no tooltip and no hover brighten. + /// `GAMEOBJECT_FACTION`: the `FactionTemplate.dbc` id, 0 neutral; hostile to the player + /// means not highlightable (`[go+0x110]+0x38`): no cursor, tooltip or hover. pub fn gameobject_faction(&self) -> u32 { self.get_u32(FIELD_GAMEOBJECT_FACTION).unwrap_or(0) } - /// `GAMEOBJECT_DYN_FLAGS` — the **per-player** dynamic flags; bit `0x1` = `GO_DYNFLAG_LO_ACTIVATE` - /// (the sparkle / "usable for me now"), which the server sets from `GameObject::ActivateToQuest`. - /// Consulted only when `GAMEOBJECT_FLAGS & INTERACT_COND` is set (decision 0243). + /// `GAMEOBJECT_DYN_FLAGS` (per player): `0x1` `GO_DYNFLAG_LO_ACTIVATE`, the sparkle, set by + /// `GameObject::ActivateToQuest` and read only under flag `0x4`. pub fn gameobject_dynamic_flags(&self) -> u32 { self.get_u32(FIELD_GAMEOBJECT_DYN_FLAGS).unwrap_or(0) } - /// `GAMEOBJECT_LEVEL` — the object's level (vmangos `UpdateFields_1_12_1.h`: `OBJECT_END + 0x10` - /// = 22). The lock-refusal toast's required-rank fallback reads it: a skill lock whose - /// `Skill[0]` is 0 displays `level × 5` instead (wow-re cursor-system.md §8.8, `0x5f3490`). - /// `0` when absent (the create-block zero default). + /// `GAMEOBJECT_LEVEL` (field 22): a lock with `Skill[0]` 0 asks for `level * 5` (`0x5f3490`). pub fn gameobject_level(&self) -> u32 { self.get_u32(FIELD_GAMEOBJECT_LEVEL).unwrap_or(0) } - /// `OBJECT_FIELD_TYPE & TYPEMASK_CONTAINER` — whether this object is a **bag**, not merely an - /// item. The reference's own test, byte for byte: `mov ecx,[eax+8]` / `shr ecx,2` / - /// `test cl,1` (`0x4c879d`–`0x4c87a6` in `GetInventoryItemCount`, repeated verbatim at - /// `0x4c7dbb`–`0x4c7dc4` in `PutItemInBag`). A container's mask carries the ITEM bit too, so - /// this is a widening test and not an alternative to it. - /// - /// `false` when the field is absent — an item whose create block has not landed is not yet - /// known to be a bag, and every caller's fallback is the ordinary-item answer. + /// `OBJECT_FIELD_TYPE & TYPEMASK_CONTAINER`: a bag, whose mask carries the item bit too; the + /// reference's test at `0x4c879d` (`GetInventoryItemCount`) and `0x4c7dbb` (`PutItemInBag`). pub fn is_container(&self) -> bool { self.get_u32(FIELD_OBJECT_TYPE).unwrap_or(0) & 0x4 != 0 } - /// The object's class from the type field (`OBJECT_FIELD_TYPE`), used for `Values`/`Movement` - /// updates that carry no separate `TypeId`. Also the reference's own "is this a Player" test - /// (`[obj->descriptorBlock[2] + 8] >> 4 & 1`), which is how `CanAttack`/`CanInteract` ask. + /// The object's class from `OBJECT_FIELD_TYPE`, for updates with no `TypeId`; the player bit + /// `0x10` is the reference's own is-player test (`CanAttack`, `CanInteract`). pub fn object_type(&self) -> Option { self.get_u32(FIELD_OBJECT_TYPE).map(|bits| { if bits & 0x10 != 0 { diff --git a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs index 8b5f8037..882a1dae 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs @@ -1,16 +1,13 @@ use super::*; -/// The 0208 combat/stat indices re-derived from vmangos's own enum arithmetic (`OBJECT_END` = -/// 6, `UNIT_END` = 188) and chain-locked to the file's two live-tested anchors: -/// `FIELD_PLAYER_INV_SLOT_HEAD` (486, descriptor-dump verified) and -/// `FIELD_PLAYER_BUYBACK_PRICE_1` (1226, wow-re byte anchor, decision 0138). If any constant -/// drifts, this is the tripwire. +/// The indices re-derived from vmangos's enum arithmetic and chained to two anchors known +/// independently: `FIELD_PLAYER_INV_SLOT_HEAD` (486, a live descriptor dump) and +/// `FIELD_PLAYER_BUYBACK_PRICE_1` (1226, the client binary). #[test] fn combat_stat_indices_chain_to_the_tested_anchors() { const OBJECT_END: u16 = 6; const UNIT_END: u16 = OBJECT_END + 0xB6; // UpdateFields_1_12_1.h assert_eq!(UNIT_END, 188); - // The two tested anchors re-derive from the same arithmetic — the chain is sound. assert_eq!(FIELD_PLAYER_INV_SLOT_HEAD, UNIT_END + 0x12A); assert_eq!(FIELD_PLAYER_BUYBACK_PRICE_1, UNIT_END + 0x40E); // UNIT combat block. @@ -30,8 +27,6 @@ fn combat_stat_indices_chain_to_the_tested_anchors() { assert_eq!(FIELD_UNIT_RANGED_ATTACK_POWER_MULTIPLIER, OBJECT_END + 0xA4); assert_eq!(FIELD_UNIT_MINRANGEDDAMAGE, OBJECT_END + 0xA5); assert_eq!(FIELD_UNIT_MAXRANGEDDAMAGE, OBJECT_END + 0xA6); - // The rested-XP pool sits exactly one below the tested COINAGE anchor, and exactly one past - // the explored-zones bitset's 64 slots (decision 1082). assert_eq!(FIELD_PLAYER_REST_STATE_EXPERIENCE, UNIT_END + 0x3DB); assert_eq!( FIELD_PLAYER_REST_STATE_EXPERIENCE + 1, @@ -41,9 +36,7 @@ fn combat_stat_indices_chain_to_the_tested_anchors() { FIELD_PLAYER_EXPLORED_ZONES_1 + PLAYER_EXPLORED_ZONES_SLOTS, FIELD_PLAYER_REST_STATE_EXPERIENCE ); - // PLAYER stat block: POSSTAT0 sits one past the tested COINAGE, and the run through - // AMMO_ID lands three shy of the tested BUYBACK_PRICE_1 (BYTES 1222, SELF_RES 1224, - // PVP_MEDALS 1225 between). + // The stat block runs past COINAGE to AMMO_ID 1223; SELF_RES and PVP_MEDALS fill 1224-1225. assert_eq!(FIELD_PLAYER_POSSTAT0, FIELD_PLAYER_FIELD_COINAGE + 1); assert_eq!(FIELD_PLAYER_POSSTAT0, UNIT_END + 0x3DD); assert_eq!(FIELD_PLAYER_NEGSTAT0, UNIT_END + 0x3E2); @@ -56,18 +49,14 @@ fn combat_stat_indices_chain_to_the_tested_anchors() { assert_eq!(FIELD_PLAYER_AMMO_ID + 3, FIELD_PLAYER_BUYBACK_PRICE_1); assert_eq!(FIELD_PLAYER_FIELD_BYTES, UNIT_END + 0x40A); assert_eq!(FIELD_PLAYER_FIELD_BYTES + 1, FIELD_PLAYER_AMMO_ID); - // The watched reputation slot, anchored off the tested COINAGE the same way everything in the - // PLAYER block is: 0x431 − 0x3DC = 85 dwords above it. + // The watched-faction slot is 0x431 - 0x3DC = 85 dwords above COINAGE. assert_eq!(FIELD_PLAYER_WATCHED_FACTION_INDEX, UNIT_END + 0x431); assert_eq!( FIELD_PLAYER_WATCHED_FACTION_INDEX, FIELD_PLAYER_FIELD_COINAGE + 85 ); - // Far sight is the guid pair immediately before the combo-target pair, which is itself the two - // dwords before the live-tested XP pair — so it is chain-locked from BOTH ends against tested - // anchors and needs none of its own: keyring 648 +64 = 712, +2 combo 714, +2 XP 716. Worth - // asserting because vmangos's hex comment for this field reads 0x2C2 = 706, six low (the same - // drift as COINAGE/XP); its enum arithmetic, which is what the server compiles, agrees with us. + // Keyring 648 + 64 = far sight 712, + 2 combo target 714, + 2 XP 716. vmangos's hex comment + // for far sight says 0x2C2 = 706, six low; its enum arithmetic is what compiles. assert_eq!(FIELD_PLAYER_FARSIGHT, UNIT_END + 0x20C); assert_eq!( FIELD_PLAYER_FARSIGHT, @@ -75,31 +64,24 @@ fn combat_stat_indices_chain_to_the_tested_anchors() { "far sight closes the 32-slot keyring array" ); assert_eq!(FIELD_PLAYER_FARSIGHT + 2, FIELD_PLAYER_FIELD_COMBO_TARGET); - // The combo-target GUID is the two dwords immediately BEFORE the live-tested XP pair, so it - // needs no anchor of its own. The binary agrees from the other side: `GetComboPoints 0x51a190` - // reads it at `[player+0xe68]+0x838`, and 0x838/4 = 0x20E (decision 0875). + // `GetComboPoints` (`0x51a190`) reads the combo target at `[player+0xe68]+0x838`, and + // 0x838 / 4 = 0x20E. assert_eq!(FIELD_PLAYER_FIELD_COMBO_TARGET, UNIT_END + 0x20E); assert_eq!(FIELD_PLAYER_FIELD_COMBO_TARGET + 2, FIELD_PLAYER_XP); - // The skill array: one past the tested XP pair, 384 dwords ending at CHARACTER_POINTS1. + // The skill array is 128 slots of 3 dwords. assert_eq!(FIELD_PLAYER_SKILL_INFO_1_1, FIELD_PLAYER_NEXT_LEVEL_XP + 1); assert_eq!(FIELD_PLAYER_SKILL_INFO_1_1, UNIT_END + 0x212); assert_eq!(FIELD_PLAYER_SKILL_INFO_1_1 + 384, UNIT_END + 0x392); - // The character-points pair sits exactly there (UNIT_END + 0x392/0x393; decision 0304). assert_eq!( FIELD_PLAYER_CHARACTER_POINTS1, FIELD_PLAYER_SKILL_INFO_1_1 + 384 ); assert_eq!(FIELD_PLAYER_CHARACTER_POINTS2, UNIT_END + 0x393); - // The tracking-mask pair sits contiguously past it (UNIT_END + 0x394/0x395). assert_eq!( FIELD_PLAYER_TRACK_CREATURES, FIELD_PLAYER_CHARACTER_POINTS2 + 1 ); assert_eq!(FIELD_PLAYER_TRACK_RESOURCES, UNIT_END + 0x395); - // The honor block (decision 1512) is eleven contiguous fields filling EXACTLY the gap between - // two already-anchored neighbours — the 12-slot buyback-timestamp array below it and the - // watched-faction slot above it — so it needs no anchor of its own. If any of the three ever - // drifts, one of these two closures breaks. assert_eq!( FIELD_PLAYER_BUYBACK_TIMESTAMP_1 + 12, FIELD_PLAYER_FIELD_SESSION_KILLS, @@ -127,8 +109,7 @@ fn combat_stat_indices_chain_to_the_tested_anchors() { assert_eq!(FIELD_PLAYER_FIELD_LAST_WEEK_CONTRIBUTION, UNIT_END + 0x42E); assert_eq!(FIELD_PLAYER_FIELD_LAST_WEEK_RANK, UNIT_END + 0x42F); assert_eq!(FIELD_PLAYER_FIELD_BYTES2, UNIT_END + 0x430); - // The CURRENT rank rides the appearance dword instead, nowhere near that block — which is the - // whole point: PLAYER_BYTES_3 is PUBLIC, so it is the one honor value a foreign player streams. + // The current rank rides the public PLAYER_BYTES_3, outside the honor block. assert_eq!(FIELD_PLAYER_BYTES_3, UNIT_END + 0x7); } @@ -153,8 +134,8 @@ fn unit_stats_and_resistances_read_indexed_and_gate_range() { #[test] fn attack_power_mods_split_signed_halves() { - // vmangos SetInt16Value(index_mod, 0, pos) / (index_mod, 1, neg): lo = positive, - // hi = negative (already negative-or-zero). pos 30, neg −10. + // vmangos `SetInt16Value(index_mod, 0, pos)` and `(index_mod, 1, neg)`: low positive, high + // negative. pos 30, neg -10. let packed = u32::from(30u16) | (u32::from((-10i16) as u16) << 16); let f = ObjectFields::from_pairs(&[ (165, 78), // ATTACK_POWER @@ -170,7 +151,6 @@ fn attack_power_mods_split_signed_halves() { assert_eq!(f.unit_ranged_attack_power(), Some(52)); assert_eq!(f.unit_ranged_attack_power_mods(), (0, -3)); assert_eq!(f.unit_ranged_attack_power_multiplier(), Some(0.0)); - // Absent mods read (0, 0) — the descriptor's zero-initialized default. assert_eq!(ObjectFields::default().unit_attack_power_mods(), (0, 0)); } @@ -199,10 +179,8 @@ fn damage_and_attack_time_fields_read_their_slots() { assert_eq!(f.unit_max_ranged_damage(), Some(47.0)); } -/// The four stat/resistance buff-split arrays are **INT** on the wire (decision 1397), not the -/// f32 the server keeps internally — `BuildValuesUpdate` narrows them on the way out. The words -/// below are the ones a real 5875 server sends; the `-4` case is the x86 host's two's-complement -/// word (an arm64 host saturates the same debuff to a flat `0` — both decode correctly as `i32`). +/// The buff-split arrays are ints on the wire, narrowed from the server's floats by +/// `BuildValuesUpdate`; `-4` is an x86 server's word, where arm64 would send 0. #[test] fn player_stat_buff_arrays_read_signed_ints() { let f = ObjectFields::from_pairs(&[ @@ -219,8 +197,7 @@ fn player_stat_buff_arrays_read_signed_ints() { assert_eq!(f.player_resistance_buff_neg(2), Some(-20)); assert_eq!(f.player_resistance_buff_pos(7), None, "out of range"); assert_eq!(f.player_resistance_buff_neg(7), None, "out of range"); - // The regression this decode is: a `105` POSSTAT read as f32 rounds to 0, which is what made - // every gear-boosted stat on the character sheet render plain white (B165/B251). + // A live value: 105 read as an f32 bit pattern would be near 0. let live = ObjectFields::from_pairs(&[(1177, 105)]); assert_eq!(live.player_posstat(0), Some(105)); } @@ -231,7 +208,7 @@ fn player_mod_damage_done_reads_int_pos_neg_and_float_pct() { (1201, 25), // MOD_DAMAGE_DONE_POS[0] physical (1203, 40), // MOD_DAMAGE_DONE_POS[2] fire (1208, 15u32.wrapping_neg()), // MOD_DAMAGE_DONE_NEG[0] = −15 - (1215, 1.1f32.to_bits()), // MOD_DAMAGE_DONE_PCT[0] — a true float + (1215, 1.1f32.to_bits()), // MOD_DAMAGE_DONE_PCT[0], a true float ]); assert_eq!(f.player_mod_damage_done_pos(0), Some(25)); assert_eq!(f.player_mod_damage_done_pos(2), Some(40)); @@ -245,8 +222,8 @@ fn player_mod_damage_done_reads_int_pos_neg_and_float_pct() { #[test] fn player_skill_unpacks_the_three_dword_triplet() { - // Slot 1 (base 718 + 3 = 721): Swords (43) step 0, 25/300, temp −3 / perm +5 — - // MAKE_PAIR32 lo|hi packing throughout, bonuses signed (Player.cpp:94-100). + // Slot 1 (718 + 3 = 721): Swords (43) step 0, 25/300, temp -3, perm +5; lo|hi pairs, + // bonuses signed (`Player.cpp:94-100`). let f = ObjectFields::from_pairs(&[ (721, 43), (722, 25 | (300 << 16)), @@ -263,8 +240,6 @@ fn player_skill_unpacks_the_three_dword_triplet() { perm_bonus: 5, }) ); - // A never-streamed slot → None; a zeroed (server-cleared) slot → Some with id 0; - // out of range → None. assert_eq!(f.player_skill(0), None); assert_eq!( ObjectFields::from_pairs(&[(718, 0)]) @@ -284,9 +259,8 @@ fn player_ammo_id_reads_the_field() { #[test] fn player_self_res_spell_collapses_absent_and_zero() { - // 1224 sits one past PLAYER_AMMO_ID(1223), which is one past PLAYER_FIELD_BYTES(1222) — the - // run this test pins by neighbour so a mis-derived index shows up as a wrong reading, not a - // silent None. A rank-1 soulstone's effect spell is 3026 (decision 1746). + // 1224 follows AMMO_ID 1223 and FIELD_BYTES 1222, all set so a wrong index reads a + // neighbour. 3026 is the rank-1 soulstone's effect spell. let f = ObjectFields::from_pairs(&[(1222, 0), (1223, 0), (1224, 3026)]); assert_eq!(f.player_self_res_spell(), Some(3026)); assert_eq!( @@ -294,8 +268,6 @@ fn player_self_res_spell_collapses_absent_and_zero() { Some(0), "the neighbour is not disturbed" ); - // "No self-res" reaches us two ways — the field never streamed, or the server zeroed it on a - // resurrection by another means — and they are the same fact, so both read None. assert_eq!(ObjectFields::default().player_self_res_spell(), None); assert_eq!( ObjectFields::from_pairs(&[(1224, 0)]).player_self_res_spell(), @@ -305,10 +277,8 @@ fn player_self_res_spell_collapses_absent_and_zero() { #[test] fn player_field_bytes_splits_into_combo_points_toggles_and_honor_rank() { - // flags=0x01 / combo=0x02 / actionBars=0x03 / highestHonorRank=5 packed little-endian: the - // dword's three live readers take bytes 1, 2 and 3 without bleeding into each other. The - // offsets are the binary's own — the reads sit at `[[player+0xe68]+0x1029/0x102a/0x102b]` - // (wow-re `action-bar-toggles.md` §4), i.e. bytes 1/2/3 of field 1222. + // Bytes 0-3: flags 0x01, combo 2, action bars 3, highest rank 5; the client reads bytes 1-3 + // of field 1222 at `[[player+0xe68]+0x1029..0x102b]`. let f = ObjectFields::from_pairs(&[(1222, 0x05_03_02_01)]); assert_eq!(f.player_honor_rank(), Some(5)); assert_eq!(f.player_action_bar_toggles(), Some(3)); @@ -318,8 +288,7 @@ fn player_field_bytes_splits_into_combo_points_toggles_and_honor_rank() { assert_eq!(capped.player_combo_points(), Some(5)); assert_eq!(capped.player_action_bar_toggles(), Some(0)); assert_eq!(capped.player_honor_rank(), Some(0)); - // The server may legally hold a high nibble here (it stores the whole byte); the accessor is - // the raw byte and does not mask — the four-bit law is the Lua binding's, not the field's. + // The accessor returns the raw byte, high nibble included; only the binding masks to 4 bits. let high = ObjectFields::from_pairs(&[(1222, 0x00_f5_00_00)]); assert_eq!(high.player_action_bar_toggles(), Some(0xf5)); assert_eq!(ObjectFields::default().player_honor_rank(), None); @@ -327,13 +296,8 @@ fn player_field_bytes_splits_into_combo_points_toggles_and_honor_rank() { assert_eq!(ObjectFields::default().player_combo_points(), None); } -/// `PLAYER_BYTES_3` byte 3 is the **current** honor rank, and it shares its dword with the -/// gender+inebriation low u16 — so the fixture carries a non-zero drunk byte on purpose: a wrong -/// shift would read `0xA0` (160) as the rank and pass a byte-3-is-zero test. -/// -/// The pairing with `PLAYER_FIELD_BYTES`' byte 3 is the confusable one and the reason both are -/// asserted here: current rank (PUBLIC, field 195) vs highest lifetime rank (PRIVATE, field 1222). -/// A fixture carrying only 195 must answer `None` for the highest rank, not `0`. +/// The nonzero drunk byte catches a wrong shift; the current rank (field 195) and the highest +/// rank (field 1222) are separate fields. #[test] fn player_bytes_3_byte_3_is_the_current_pvp_rank_not_the_highest() { // byte 0 = 0x01 gender, byte 1 = 0xA0 drunk, byte 2 = 0x04 city-protector title, @@ -346,22 +310,19 @@ fn player_bytes_3_byte_3_is_the_current_pvp_rank_not_the_highest() { None, "the HIGHEST rank is a different field (1222) — absent means absent" ); - // The two really do diverge: a demoted player keeps the higher lifetime rank. + // A demoted player keeps the higher lifetime rank. let demoted = ObjectFields::from_pairs(&[(195, 0x03_00_00_00), (1222, 0x0B_00_00_00)]); assert_eq!(demoted.player_pvp_rank(), Some(3)); assert_eq!(demoted.player_honor_rank(), Some(11)); assert_eq!(ObjectFields::default().player_pvp_rank(), None); } -/// The four TWO_SHORT kill counters split low = honorable / high = dishonorable -/// (`MAKE_PAIR32`; vmangos writes SESSION_KILLS as two `SetUInt16Value` halves). The other three -/// come off vmangos as a whole dword, so their high half is 0 — asserted, because "DK reads 0" is -/// a property of *this server*, not of the field, and a future capture that shows otherwise is a -/// finding rather than a regression. +/// vmangos writes only SESSION_KILLS as two halves; it writes the other three as a whole dword, +/// so their dishonorable half is 0. #[test] fn honor_kill_counters_split_into_honorable_and_dishonorable_halves() { let f = ObjectFields::from_pairs(&[ - (1250, 0x0003_0011), // SESSION: 17 HK, 3 DK — both halves live + (1250, 0x0003_0011), // SESSION: 17 HK, 3 DK, both halves live (1251, 0x0000_0029), // YESTERDAY: 41 HK, whole-dword write ⇒ DK half 0 (1252, 0x0000_01A4), // LAST_WEEK: 420 HK (1253, 0x0000_007B), // THIS_WEEK: 123 HK @@ -370,11 +331,9 @@ fn honor_kill_counters_split_into_honorable_and_dishonorable_halves() { assert_eq!(f.player_yesterday_kills(), Some((41, 0))); assert_eq!(f.player_last_week_kills(), Some((420, 0))); assert_eq!(f.player_this_week_kills(), Some((123, 0))); - // A value that fills the high half of one of the dword-written fields still decodes as a - // pair — we carry the descriptor's shape, not vmangos's habit. + // A filled high half still decodes: the shape is the descriptor's, not vmangos's habit. let both = ObjectFields::from_pairs(&[(1251, 0x0007_0029)]); assert_eq!(both.player_yesterday_kills(), Some((41, 7))); - // Absent stays absent for every one of the four. let empty = ObjectFields::default(); assert_eq!(empty.player_session_kills(), None); assert_eq!(empty.player_yesterday_kills(), None); @@ -382,8 +341,6 @@ fn honor_kill_counters_split_into_honorable_and_dishonorable_halves() { assert_eq!(empty.player_this_week_kills(), None); } -/// The INT half of the honor block — three contribution totals, the weekly standing, and the two -/// lifetime counters — each reading its own index (a swapped constant shows up here). #[test] fn honor_contributions_standing_and_lifetime_read_their_own_fields() { let f = ObjectFields::from_pairs(&[ @@ -392,7 +349,7 @@ fn honor_contributions_standing_and_lifetime_read_their_own_fields() { (1256, 12), // LIFETIME_DISHONORABLE_KILLS (1257, 640), // YESTERDAY_CONTRIBUTION (1258, 8_431), // LAST_WEEK_CONTRIBUTION - (1259, 57), // LAST_WEEK_RANK — the STANDING, not an honor rank + (1259, 57), // LAST_WEEK_RANK: the standing, not an honor rank ]); assert_eq!(f.player_this_week_contribution(), Some(1_250)); assert_eq!(f.player_lifetime_honorable_kills(), Some(3_907)); @@ -409,14 +366,12 @@ fn honor_contributions_standing_and_lifetime_read_their_own_fields() { assert_eq!(empty.player_lifetime_dishonorable_kills(), None); } -/// `PLAYER_FIELD_BYTES2` byte 0 is the rank progress bar; bytes 1..3 are a flags byte and two -/// unknowns, so the fixture sets them to catch a wrong shift. +/// Bytes 1-3 are a flags byte and two unknowns; the fixture fills them to catch a wrong shift. #[test] fn player_honor_rank_bar_is_byte_zero_of_player_field_bytes2() { let f = ObjectFields::from_pairs(&[(1260, 0xDE_AD_BE_7F)]); assert_eq!(f.player_honor_rank_bar(), Some(0x7F)); - // A negative rank's `uint8(fraction * -255)` wraps rather than clamping — we carry the byte - // the wire sent and leave the interpretation to the display edge. + // A negative rank's wrapped `uint8(fraction * -255)` is carried as sent. assert_eq!( ObjectFields::from_pairs(&[(1260, 0x0000_009C)]).player_honor_rank_bar(), Some(0x9C) @@ -424,21 +379,16 @@ fn player_honor_rank_bar_is_byte_zero_of_player_field_bytes2() { assert_eq!(ObjectFields::default().player_honor_rank_bar(), None); } -/// `PLAYER_FIELD_COMBO_TARGET` reads as one GUID out of its two dwords — the unit the points are -/// banked on, which the display gate compares against the current target (decision 0875). #[test] fn player_combo_target_reads_the_guid_pair() { let f = ObjectFields::from_pairs(&[(714, 0x1234_5678), (715, 0xF000_0001)]); assert_eq!(f.player_combo_target(), 0xF000_0001_1234_5678); - // Nothing banked reads as 0 — the same value the current-target global holds with no target, - // which is exactly why the binary's plain equality compare needs no null special case. + // Nothing banked reads 0, as the current-target global does with no target. assert_eq!(ObjectFields::default().player_combo_target(), 0); } -/// The four aura arrays tile exactly, and the block lands on `BASEATTACKTIME` — an index this -/// file already chain-locks to two live-verified anchors. So this pins all four aura indices -/// without a new anchor of its own: 48 spell-id dwords, then 48 nibbles (6 dwords), then 48 -/// bytes (12 dwords) twice, then `AURASTATE` (+0x77), then `BASEATTACKTIME` (+0x78). +/// 48 spell-id dwords, 48 nibbles (6 dwords), 48 bytes (12 dwords) twice, `AURASTATE` (+0x77), +/// then `BASEATTACKTIME` (+0x78), which the first test chains to the anchors. #[test] fn aura_arrays_tile_from_object_end_onto_the_tested_anchor() { const OBJECT_END: u16 = 6; @@ -469,9 +419,8 @@ fn aura_arrays_tile_from_object_end_onto_the_tested_anchor() { ); } -/// The packing goldens: nibble-per-slot flags (8/dword), byte-per-slot levels and applications -/// (4/dword, holding `stack - 1`). Slot 32 deliberately omits its applications word — an absent -/// field must read as the descriptor's zero, i.e. a stack of 1, not a stack of 0. +/// Flags are a nibble per slot, levels and applications a byte (`stack - 1`); slot 32 has no +/// applications word, so it reads a stack of 1. #[test] fn aura_slots_unpack_nibbles_bytes_and_the_stack_bias() { let f = ObjectFields::from_pairs(&[ @@ -544,21 +493,16 @@ fn aura_slots_unpack_nibbles_bytes_and_the_stack_bias() { assert_eq!(ObjectFields::default().unit_auras().count(), 0); } -/// A slot whose flags nibble the server cleared (`& 0x0E == 0`) is **empty**, even if a stale -/// spell id lingers in `UNIT_FIELD_AURA` — the client's own `flags & 0x0E` liveness test. Gating -/// on the spell id alone (as an earlier cut did) surfaced the husk as a phantom buff the real -/// client hides. The cancelable bit `0x1` on its own does not resurrect it (it is not an effect -/// bit), so this covers both a fully-zeroed nibble and a `0x1`-only one. +/// The cancelable bit `0x1` alone is no effect bit: a `0x1`-only nibble is as empty as a zero one. #[test] fn a_stale_spell_id_with_a_cleared_flags_nibble_is_not_a_live_aura() { let f = ObjectFields::from_pairs(&[ - // slot 0: a live buff — spell id + effect bit 0x8. + // slot 0: a live buff, spell id and effect bit 0x8. (47, 1126), (95, 0x0000_0018), // slot 0 nibble 0x8 (eff0), slot 1 nibble 0x1 (cancelable only) - // slot 1: a HUSK — the server left the spell id but cleared every effect bit, keeping - // only the cancelable bit. `flags & 0x0E == 0`, so it is not live. + // slot 1: a stale id with only the cancelable bit. (48, 5000), - // slot 2: a HUSK — spell id present, flags nibble fully zero. + // slot 2: a stale id with a zero nibble. (49, 6000), ]); @@ -580,10 +524,8 @@ fn a_stale_spell_id_with_a_cleared_flags_nibble_is_not_a_live_aura() { ); } -/// The create-block absent-field semantics (director-caught: fully broken gear read as 100%). A -/// CREATE omits zero-valued fields (vmangos `_SetCreateBits`: mask bit iff `value != 0`), so a -/// create-seeded store must answer `Some(0)` for an absent field — the real client's -/// zero-initialized descriptor — while a bare `Values` delta keeps absent = `None` ("untouched"). +/// A create omits zero fields (vmangos `_SetCreateBits`), so a created store reads absent as 0, +/// like the client's zeroed descriptor; a bare delta reads it as untouched. #[test] fn created_store_reads_absent_fields_as_zero_a_delta_as_none() { // A broken item's create: MAXDURABILITY present (40), DURABILITY omitted (it is 0). @@ -595,15 +537,10 @@ fn created_store_reads_absent_fields_as_zero_a_delta_as_none() { let delta = ObjectFields::from_pairs(&[(47, 40)]); assert_eq!(delta.item_durability(), None, "delta absent = untouched"); - // A guid slot keeps present-iff-low-half semantics even on a created store (consumers - // already treat a sent-empty `Some(0)` as no-guid; the fallback would only blur the two). + // A guid slot is present only if its low half was sent, even on a created store. assert_eq!(create.corpse_owner(), None); } -/// The `TYPEID_CORPSE` descriptor's field indices and its two packed words (decision 1706). Every -/// number here is a *position*, and a position is exactly what a doc comment cannot check: an -/// off-by-one on `CORPSE_FIELD_ITEM`'s base would read the display id as a shoulder and dress a -/// body in a helm's row, silently. #[test] fn corpse_descriptor_indices_and_packing() { // `OBJECT_END + 0x6 = 12` display · `+0x7 = 13` item[0] · `+0x1A/0x1B = 32/33` bytes · @@ -648,8 +585,7 @@ fn corpse_descriptor_indices_and_packing() { "the seven bytes the reference loads at [descr+0x69..+0x6f], in that order" ); - // The flags are three independent bits on one field — and BONES is the model fork, so a - // mis-read here swaps a dressed body for a skeleton. + // Three independent bits; BONES picks the skeleton model. assert!(!corpse.corpse_is_bones(), "0x01 clear"); assert!(corpse.corpse_hides_helm(), "0x08 set"); assert!(!corpse.corpse_hides_cloak(), "0x10 clear"); @@ -658,7 +594,7 @@ fn corpse_descriptor_indices_and_packing() { "DYNAMIC_FLAGS bit 0 — a different field" ); - // …and CORPSE_END = 38, so a created corpse answers 0 (not None) for every field below it. + // CORPSE_END is 38, so a created corpse reads 0, not None, for every field below it. let bare = ObjectFields::from_pairs(&[(12, 49)]).into_created(ObjectType::Corpse); assert_eq!(bare.corpse_flags(), 0); assert!( @@ -668,19 +604,13 @@ fn corpse_descriptor_indices_and_packing() { assert!(!bare.corpse_lootable()); } -/// …and "absent = 0" stops at the end of the object's OWN descriptor (decision 1081). A creature -/// has no PLAYER block to be absent from, so a PLAYER-block read off one is `None` — a question it -/// cannot answer — not a confident `0`. -/// -/// The live bug: `unit_combat_stats` reads `PLAYER_FIELD_MOD_DAMAGE_DONE_PCT` for any unit and -/// defaults an absent answer to the divide-safe `1.0`. With a created pet answering `Some(0.0)`, -/// the default never fired and the ref's `damage / percent` produced the pet sheet's -/// `inf - inf` / `nan` tooltip and its red damage line (director, 2026-08-07). +/// Absent reads 0 only inside the object's own descriptor: a creature has no player block, so a +/// player-block read off one is `None`. #[test] fn a_creature_has_no_player_block_to_be_absent_from() { - // PLAYER_FIELD_MOD_DAMAGE_DONE_PCT[0] = UNIT_END(188) + 0x403 — past a UNIT's descriptor end. + // PLAYER_FIELD_MOD_DAMAGE_DONE_PCT[0] = UNIT_END(188) + 0x403, past a unit's descriptor. const MOD_DAMAGE_DONE_PCT: u16 = 1215; - // UNIT_FIELD_BASEATTACKTIME — well inside it. + // UNIT_FIELD_BASEATTACKTIME, well inside it. const BASEATTACKTIME: u16 = 126; let pet = ObjectFields::from_pairs(&[(2, 0x09), (150, 33)]).into_created(ObjectType::Unit); @@ -696,28 +626,25 @@ fn a_creature_has_no_player_block_to_be_absent_from() { ); assert_eq!(pet.unit_stat(0), Some(33), "and a present field is itself"); - // The same index on a real player: inside the descriptor, so absent is the honest 0 the - // client's own zero-initialized buffer holds. + // On a player the same index is inside the descriptor, so absent reads 0. let player = ObjectFields::from_pairs(&[(2, 0x19), (BASEATTACKTIME, 1800)]) .into_created(ObjectType::Player); assert_eq!(player.player_mod_damage_done_pct(0), Some(0.0)); assert_eq!(player.unit_base_attack_time(0), Some(1800)); - // A bare delta answers nothing either way — it never claimed to be complete. + // A bare delta reads absent as None either way. let delta = ObjectFields::from_pairs(&[(MOD_DAMAGE_DONE_PCT, 1.0f32.to_bits())]); assert_eq!(delta.player_mod_damage_done_pct(0), Some(1.0)); assert_eq!(delta.unit_base_attack_time(0), None); } -/// Merging keeps the semantics straight: a delta overlays a created store without unsetting the -/// created mark, and a re-CREATE *replaces* the store — stale non-zero values for fields the -/// fresh snapshot omits (they dropped to zero out of view) must die with it. +/// A re-create replaces the store, so a field that dropped to 0 out of view does not survive. #[test] fn merge_overlays_deltas_and_replaces_on_recreate() { let mut store = ObjectFields::from_pairs(&[(3, 2264), (46, 40), (47, 40)]).into_created(ObjectType::Item); - // A durability-damage delta overlays; the store stays a complete (created) descriptor. + // A durability delta overlays; the store stays created. store.merge(ObjectFields::from_pairs(&[(46, 30)])); assert_eq!(store.item_durability(), Some(30)); assert_eq!( @@ -735,10 +662,8 @@ fn merge_overlays_deltas_and_replaces_on_recreate() { ); } -/// The field edges a merge reports (decision 2297) — the reference's per-field notifier, whose -/// callback fires on a memcmp DIFFERENCE and receives the old value: only the dwords that moved, -/// ascending, absent reading as `0` on either side, and a value re-sent unchanged reporting -/// nothing at all. +/// Like the reference's per-field notifier, which fires on a memcmp difference with the old +/// value: moved dwords only, ascending, absent as 0, an unchanged resend silent. #[test] fn merge_diff_reports_each_moved_dword_once_with_its_old_value() { let mut store = @@ -758,9 +683,8 @@ fn merge_diff_reports_each_moved_dword_once_with_its_old_value() { assert_eq!(store.unit_flags(), 8); } -/// A re-CREATE is the reference's in-place refresh of a live guid — it notifies too, and here it -/// notifies over BOTH masks: a field the fresh snapshot omits dropped to zero, and that is an edge -/// the store's replace makes visible (`merge_overlays_deltas_and_replaces_on_recreate` above). +/// A re-create refreshes a live guid in place and notifies over both masks: an omitted field is +/// an edge to 0. #[test] fn merge_diff_on_a_recreate_reports_the_replace_on_both_masks() { let mut store = @@ -778,9 +702,6 @@ fn merge_diff_on_a_recreate_reports_the_replace_on_both_masks() { assert_eq!(store.item_durability(), Some(0)); } -/// The class a store was created as reads back off its length — every class round-trips, and a -/// bare delta (no create seen) answers nothing, which is what keeps a field edge honest about -/// which block its index belongs to. #[test] fn created_as_reads_back_off_the_created_length() { for t in [ @@ -801,14 +722,13 @@ fn created_as_reads_back_off_the_created_length() { assert_eq!(ObjectFields::from_pairs(&[(22, 1)]).created_as(), None); } -/// The `DYNAMICOBJECT_*` accessors against the exact live capture (vmangos, 2026-07-30, the -/// B132 follow-up's `--groundfx 10` run): Blizzard's dynobj create — caster guid 26, BYTES 1 -/// (area spell), SPELLID 10, RADIUS 8.0, the cast point in POS, FACING never sent. +/// A live vmangos capture of a Blizzard (spell 10) dynamic-object create: caster guid 26, area +/// spell, radius 8.0, the cast point in POS, FACING never sent. #[test] fn dynamicobject_fields_read_the_live_blizzard_capture() { let f = ObjectFields::from_pairs(&[ (0, 6), // OBJECT_FIELD_GUID lo - (1, 0xf100_0000), // guid hi — HIGHGUID dynobj + (1, 0xf100_0000), // guid hi, HIGHGUID dynobj (2, 0x41), // TYPE: OBJECT | DYNAMICOBJECT (3, 10), // ENTRY = the spell id (4, 1.0f32.to_bits()), // SCALE_X @@ -835,12 +755,8 @@ fn dynamicobject_fields_read_the_live_blizzard_capture() { ); } -/// **Feign death is one bit, and the client reads it everywhere it reads health** (decision 1022). -/// `UNIT_DYNFLAG_DEAD` leaves `UNIT_FIELD_HEALTH` alone — vmangos `Unit::SetFeignDeath` only sets -/// the flag — so every predicate that keys on the raw field must stay FALSE while every one that -/// keys on how the unit *reads* must flip. The byte law being transcribed: `UnitHealth 0x5174d0` -/// and `UnitMana 0x517670` answer 0 under the flag, their `*Max` siblings (`0x5175b0`/`0x5177e0`) -/// have no such gate, and `0x605f90` is the shared reads-dead triple. +/// `Unit::SetFeignDeath` sets only `UNIT_DYNFLAG_DEAD`: the raw predicates stay false, the +/// reads-dead ones (`0x605f90`, `UnitHealth`, `UnitMana`) flip, and the maxima stay. #[test] fn feign_death_reads_dead_without_touching_the_raw_health_field() { const MANA: u8 = 0; @@ -860,14 +776,13 @@ fn feign_death_reads_dead_without_touching_the_raw_health_field() { ]); let corpse = unit(&[(FIELD_UNIT_HEALTH, 0), (FIELD_UNIT_MAXHEALTH, 1500)]); - // The raw field predicate is untouched by the flag — the death arc, loot and the corpse never - // fire for a feign. + // The raw predicate ignores the flag. assert!(!alive.unit_is_dead()); assert!(!feigning.unit_is_dead(), "feign leaves HEALTH alone"); assert!(corpse.unit_is_dead()); assert_eq!(feigning.unit_health(), Some(1200), "the raw field survives"); - // …and the reads-dead predicate flips for both, plus the stand-state leg. + // The reads-dead predicate flips for both, plus the stand-state leg. assert!(!alive.unit_reads_dead()); assert!(feigning.unit_reads_dead(), "0x605f9d — the dynflag leg"); assert!(corpse.unit_reads_dead()); @@ -881,26 +796,22 @@ fn feign_death_reads_dead_without_touching_the_raw_health_field() { "0x605faa — stand state 7 (inert against vmangos, kept as the reference has it)" ); - // The UI getters: current goes to zero, the maxima do not — that is what makes the bar render - // 0/max (empty) instead of vanishing. + // Current values go to 0 and the maxima stay, so the bar shows empty. assert_eq!(alive.unit_shown_health(), Some(1200)); assert_eq!(feigning.unit_shown_health(), Some(0)); assert_eq!(feigning.unit_max_health(), Some(1500)); assert_eq!(alive.unit_shown_power(MANA), Some(300)); assert_eq!(feigning.unit_shown_power(MANA), Some(0)); assert_eq!(feigning.unit_max_power(MANA), Some(900)); - // Out-of-range power slots stay nil under the flag, exactly as the ungated reader has them. + // Out-of-range power slots stay None under the flag, as the ungated reader has them. assert_eq!(feigning.unit_shown_power(5), None); - // An empty store is not a feigning one: no flag, no zeroing, and no invented health. + // An empty store has no flag and no health. assert!(!ObjectFields::default().unit_reads_dead()); assert_eq!(ObjectFields::default().unit_shown_health(), None); } -/// **The raw→display power divide** (decision 1034, wow-re `feign-death-dyndead.md` §3): the -/// reference's `UnitMana`/`UnitManaMax` divide by `0x6e7130`'s table at `0x86f978` — -/// `{1, 10, 1, 1, 1000}`. vmangos ships rage max **1000** and pet happiness max **1050000** -/// (`GetCreatePowers`), which through that divide are the 100 and 1050 a real client shows. The -/// RAW accessors must stay raw: the pet happiness bucket thresholds compare on the wire scale. +/// vmangos sends rage max 1000 and happiness max 1050000 (`GetCreatePowers`); the table at +/// `0x86f978` divides them to the 100 and 1050 the reference shows. #[test] fn rage_and_happiness_divide_for_display_but_not_for_the_raw_readers() { const RAGE: u8 = 1; @@ -930,7 +841,7 @@ fn rage_and_happiness_divide_for_display_but_not_for_the_raw_readers() { assert_eq!(pet.unit_shown_power(HAPPINESS), Some(1020)); assert_eq!(pet.unit_shown_max_power(HAPPINESS), Some(1050)); - // Mana/focus/energy divide by 1 — the shown and raw readers agree. + // Mana, focus and energy divide by 1. let caster = ObjectFields::from_pairs(&[ (FIELD_UNIT_POWER1 + u16::from(MANA), 4200), (FIELD_UNIT_MAXPOWER1 + u16::from(MANA), 8000), @@ -940,7 +851,6 @@ fn rage_and_happiness_divide_for_display_but_not_for_the_raw_readers() { caster.unit_shown_max_power(MANA), caster.unit_max_power(MANA) ); - // Absent stays nil rather than becoming a divided zero. assert_eq!(caster.unit_shown_power(RAGE), None); assert_eq!(caster.unit_shown_max_power(RAGE), None); // The dead gate still wins over the divide, and only for the current value. diff --git a/crates/benilla-protocol/src/messages/update_object/fields/unit.rs b/crates/benilla-protocol/src/messages/update_object/fields/unit.rs index e37ca292..73a37796 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/unit.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/unit.rs @@ -1,54 +1,20 @@ -//! The unit/object/GameObject named accessors over [`ObjectFields`] — the UNIT_* family every -//! world entity carries (health/power/level/auras/combat stats), plus the object scale and the -//! GameObject block. Split from the one descriptor map (`mod.rs` holds the indices + codec). +//! Named accessors over [`ObjectFields`] for the object scale and the unit block. use super::*; -/// `UNIT_DYNAMIC_FLAGS` bit 5 — `UNIT_DYNFLAG_DEAD` (vmangos `SharedDefines.h:1158`). The client -/// reads it as "this body is a corpse" wherever it reads health for display; the server sets it for -/// **feign death** and for `CREATURE_FLAG_EXTRA_APPEAR_DEAD` spawns, in both cases with health left -/// intact. Every consumer goes through [`ObjectFields::unit_reads_dead`] (decision 1022). +/// `UNIT_DYNAMIC_FLAGS` bit 5 (`SharedDefines.h:1158`): reads as a corpse wherever health is +/// shown; set for feign death and appear-dead spawns, with health left intact. pub const UNIT_DYNFLAG_DEAD: u32 = 0x20; -/// `UNIT_STAND_STATE_DEAD` (vmangos `UnitDefines.h:109`) — the third leg of the client's -/// reads-dead predicate `0x605f90`. vmangos never writes it, so it is inert against our server. -/// -/// The client reads it through the virtual `[vtbl+0xa4]`, and the **two classes install different -/// functions there** — `CGUnit_C` `0x60be50`, `CGPlayer_C` `0x5ed570` (memoised at `[this+0x1d68]` -/// for the local player) — but both return the same byte, `[descr+0x210]` = `UNIT_FIELD_BYTES_1` -/// byte 0, which is exactly what [`ObjectFields::unit_stand_state`] reads (wow-re -/// `feign-death-dyndead.md` §1). +/// `UNIT_STAND_STATE_DEAD` (`UnitDefines.h:109`), the third leg of `0x605f90`; vmangos never +/// writes it. Both `[vtbl+0xa4]` overrides (`0x60be50`, `0x5ed570`) read `UNIT_FIELD_BYTES_1` +/// byte 0. const STAND_STATE_DEAD: u8 = 7; -/// The raw→display divisor per power type — the reference's `0x6e7130`, a lookup into the table at -/// `0x86f978`: **`{1, 10, 1, 1, 1000}`** for MANA / RAGE / FOCUS / ENERGY / HAPPINESS, with `1` for -/// any out-of-range type (`test ecx,ecx; jl → 1`). Rage is stored ×10 on the wire and pet happiness -/// ×1000 (vmangos `GetCreatePowers`: `POWER_RAGE → 1000`, `POWER_HAPPINESS → 1050000`), so the real -/// client's 100 rage and 1050 happiness are this divide, not a different field. -/// -/// **This is a whole-client law, not one getter's private step** (decision 2117). `0x6e7130` is -/// called from *every* place a power figure becomes something a player reads, and the wire number -/// is raw at all of them: -/// -/// | caller | what it scales | -/// |---|---| -/// | `UnitMana 0x517670` (`0x51770f`), `UnitManaMax 0x5177e0` (`0x517864`) | the live descriptor's power slot | -/// | the same pair's party-record legs (`0x517748`, `0x5178a8`, `0x5178ee`) | [`crate::messages::PartyMemberStatsInfo::shown_power`] (decision 1640) | -/// | `SMSG_SPELLENERGIZELOG`'s handler `0x5e8a90` (`0x5e8af3`) | the amount, **once**, before both the chat line (`0x62ca00`) and the `COMBAT_TEXT_UPDATE` push (`0x494770`) | -/// | `SMSG_PERIODICAURALOG`'s handler `0x626dd0` (`0x627087`) | an energize tick's amount, the same way | -/// | the power leech/drain formatter `0x627930` | `drained = amount / div` and `gained = trunc(amount·multiplier) / div` | -/// | the spell tooltip's cost cell `0x52e610` (`0x52e8d2`, `0x52e8e7`) | the flat and per-level costs alike | -/// -/// (wow-re `system/object-layer/scratch/combat-log-chat-law.md` §4.6 — *"Amounts are divided by -/// `0x6e7130(powerType)`"* — plus the disassembly at each address above.) -/// -/// The **raw** accessors stay raw: the pet happiness bucket thresholds are on the raw scale, which -/// is why both forms have to exist (decision 1034). -/// -/// The argument is a full 32-bit power tag because the wire's is (`SMSG_SPELLENERGIZELOG` ships a -/// `u32`) and because the reference's own compare is signed — `POWER_HEALTH (-2)` reaches -/// `0x6e7130` as a negative and takes the `1`. Arriving here as a large `u32` it takes the same -/// `1` off the same `_` arm. +/// The reference's raw-to-shown power divisor (`0x6e7130`, table `0x86f978`): 10 for rage, 1000 +/// for pet happiness, 1 for any other type, `POWER_HEALTH` (-2) included. Every power figure a +/// player reads divides by it: `UnitMana`, `UnitManaMax`, party power, the energize and periodic +/// logs, the drain formatter and the spell cost; happiness thresholds stay on the raw scale. pub fn power_display_scale(ty: u32) -> u32 { match ty { 1 => 10, // rage @@ -57,19 +23,18 @@ pub fn power_display_scale(ty: u32) -> u32 { } } -/// Which field [`ObjectFields::unit_owner`] falls back to when `CHARMEDBY` is clear — the one -/// thing the client's two owner-guid readers do differently. Named rather than defaulted because -/// picking the wrong one is invisible until a totem or a guardian behaves like a pet. +/// The field [`ObjectFields::unit_owner`] falls back to when `CHARMEDBY` is clear, the one thing +/// the client's two owner readers do differently. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OwnerFallback { - /// `UNIT_FIELD_CREATEDBY` — what `0x5ee5a0` ("resolve my own pet") uses. + /// `UNIT_FIELD_CREATEDBY`, as the own-pet resolver `0x5ee5a0` reads. CreatedBy, - /// `UNIT_FIELD_SUMMONEDBY` — what the `PET_ATTACK_*` callback `0x5ff580` uses. + /// `UNIT_FIELD_SUMMONEDBY`, as the `PET_ATTACK_*` callback `0x5ff580` reads. SummonedBy, } impl ObjectFields { - /// `OBJECT_FIELD_SCALE_X` — the per-object size multiplier. + /// `OBJECT_FIELD_SCALE_X`: the per-object size multiplier. pub fn object_scale_x(&self) -> Option { self.get_f32(FIELD_OBJECT_SCALE_X) } @@ -77,200 +42,119 @@ impl ObjectFields { pub fn unit_displayid(&self) -> Option { self.get_i32(FIELD_UNIT_DISPLAYID) } - /// `UNIT_FIELD_NATIVEDISPLAYID` (units + players) — the unit's **unshifted** appearance, which - /// no transform touches. The collision prism is derived from *this*, not from - /// [`Self::unit_displayid`]: a druid in bear form keeps the druid's swim/splash/foam lines - /// (decision 1574). `None` when absent — the caller falls back to the render display. + /// `UNIT_FIELD_NATIVEDISPLAYID`: the appearance no shapeshift touches; the collision prism + /// comes from it, so a druid in bear form keeps the druid's water lines. pub fn unit_native_displayid(&self) -> Option { self.get_i32(FIELD_UNIT_NATIVEDISPLAYID) } - /// `UNIT_FIELD_MOUNTDISPLAYID` — the `CreatureDisplayInfo` id of the mount this unit rides; - /// `0` (or absent) = not mounted. The one and only mounted signal on the wire (decision 0441). + /// `UNIT_FIELD_MOUNTDISPLAYID`: the mount's `CreatureDisplayInfo` id, the only mounted signal. pub fn unit_mount_display_id(&self) -> u32 { self.get_u32(FIELD_UNIT_MOUNTDISPLAYID).unwrap_or(0) } - /// `UNIT_FIELD_TARGET` — the guid this unit is attacking/interacting with (its "look at" target), - /// or `None` when it has none (an absent field or an explicit `0`). Drives the idle face-target turn. + /// `UNIT_FIELD_TARGET`: the guid this unit is attacking or interacting with. pub fn unit_target(&self) -> Option { self.get_guid(FIELD_UNIT_TARGET).filter(|&g| g != 0) } - /// `UNIT_FIELD_CHANNEL_OBJECT` — the object a channel is aimed at (may be the caster itself), - /// or `None` when not channeling (an absent field or an explicit `0`). + /// `UNIT_FIELD_CHANNEL_OBJECT`: what a channel is aimed at, possibly the caster itself. pub fn unit_channel_object(&self) -> Option { self.get_guid(FIELD_UNIT_CHANNEL_OBJECT).filter(|&g| g != 0) } - /// `UNIT_CHANNEL_SPELL` — the spell id a unit is channeling; `0` when not channeling or absent. + /// `UNIT_CHANNEL_SPELL`: the spell being channelled, 0 for none. pub fn unit_channel_spell(&self) -> u32 { self.get_u32(FIELD_UNIT_CHANNEL_SPELL).unwrap_or(0) } - /// `UNIT_FIELD_SUMMON` — the guid of the unit this one has summoned, or `None`. On our own - /// descriptor that is **our pet**, and so the anchor of the `"pet"` unit token (decision 0982). - /// The inverse of [`Self::unit_summoned_by`]. + /// `UNIT_FIELD_SUMMON`: the unit this one summoned; on our own descriptor, the `"pet"` unit. pub fn unit_summon(&self) -> Option { self.get_guid(FIELD_UNIT_SUMMON).filter(|&g| g != 0) } - /// `UNIT_FIELD_SUMMONEDBY` — the summoner's guid (pet/guardian/totem), or `None` (absent or - /// explicit 0 — an unowned unit). + /// `UNIT_FIELD_SUMMONEDBY`: the summoner of a pet, guardian or totem. pub fn unit_summoned_by(&self) -> Option { self.get_guid(FIELD_UNIT_SUMMONEDBY).filter(|&g| g != 0) } - /// `UNIT_FIELD_CHARMEDBY` — whoever is charming this unit, or `None` when nobody is. + /// `UNIT_FIELD_CHARMEDBY`: whoever is charming this unit. pub fn unit_charmed_by(&self) -> Option { self.get_guid(FIELD_UNIT_CHARMEDBY).filter(|&g| g != 0) } - /// The unit's **owner** as the client's own two owner-guid readers compute it: - /// `charmedBy` when set, else the caller's fallback field. - /// - /// Both readers are `(charmedBy != 0) ? charmedBy : ` and **they disagree on the - /// fallback**, which is why this takes it rather than picking one. `0x5ee5a0` — "resolve my own - /// pet" — falls back to `CREATEDBY` (`0x5ee62f`: `fields+0x10` else `fields+0x20`); the - /// `PET_ATTACK_*` field-change callback `0x5ff580` falls back to `SUMMONEDBY` (`0x5ff769`: - /// `+0x10` else `+0x18`). Reading one where the reference reads the other silently changes - /// which units count as yours (wow-re `object-layer/scratch/pet-command-validators.md` §1, §4). + /// The owner as the client's owner readers compute it: `CHARMEDBY` if set, else the fallback, + /// which differs by caller (`0x5ee62f` reads `CREATEDBY`, `0x5ff769` `SUMMONEDBY`). pub fn unit_owner(&self, fallback: OwnerFallback) -> Option { self.unit_charmed_by().or_else(|| match fallback { OwnerFallback::CreatedBy => self.unit_created_by(), OwnerFallback::SummonedBy => self.unit_summoned_by(), }) } - /// `UNIT_FIELD_CREATEDBY` — the creator's guid (totems and created objects), or `None`. + /// `UNIT_FIELD_CREATEDBY`: the creator of a totem or created object. pub fn unit_created_by(&self) -> Option { self.get_guid(FIELD_UNIT_CREATEDBY).filter(|&g| g != 0) } - /// `UNIT_CREATED_BY_SPELL` — the spell that summoned this unit, `None` when zero (nothing - /// summoned it). The reference's feed-pet validator `0x6ea1e0` requires it non-zero before it - /// will accept a unit as a feedable pet — the first of that function's three gates. + /// `UNIT_CREATED_BY_SPELL`: the summoning spell; `0x6ea1e0` requires it to feed a pet. pub fn unit_created_by_spell(&self) -> Option { self.get_u32(FIELD_UNIT_CREATED_BY_SPELL) .filter(|&s| s != 0) } - /// `UNIT_FIELD_PETNUMBER` — non-zero iff this unit is somebody's **pet or charm**. - /// - /// Not a count and not an index anyone displays: the client reads it as a pure boolean, as the - /// **second gate of the rank getter** `0x605620` (decision 0782). A creature with a cached - /// template but a non-zero pet number reports classification rank `0` — so an enslaved elite - /// loses its dragon border, its ELITE tooltip word and (at rank 3) its world-boss skull, all - /// three from this one read. - /// - /// What lands here is the server's call, and vmangos is narrower than the name suggests: - /// `CharmInfo::SetPetNumber(n, statWindow)` writes `n` only when `statWindow`, else **0**. So a - /// permanent pet (`Pet::IsPermanentPetFor`), a charm/mind-control apply and a player's summon - /// carry a non-zero number, while **guardians and totems carry 0** and therefore keep whatever - /// rank their template gives them. + /// `UNIT_FIELD_PETNUMBER` is nonzero: a pet or charm, which the rank getter `0x605620` + /// reports as rank 0. vmangos writes 0 for guardians and totems (`CharmInfo::SetPetNumber` + /// without the stat window), so they keep their template's rank. pub fn unit_is_pet_or_charm(&self) -> bool { self.unit_pet_number() != 0 } - /// `UNIT_FIELD_PETNUMBER` as a value — `0` when absent (a create omits zeros). Besides the - /// boolean read above, the reference **keys a unit's name on it**: `CGUnit_C::GetUnitName` - /// (`0x609210`) reads `[[unit+0x110]+0x214]` and, when non-zero, looks the name up in the - /// pet-name cache under *this value*; zero means the creature template's name. The number in a - /// `HIGHGUID_PET` guid is not a substitute — a companion pet's guid carries a pet number the - /// server never files in its charm info, so it answers no pet-name query for it. + /// `UNIT_FIELD_PETNUMBER`: `GetUnitName` (`0x609210`) keys the pet-name cache on it when + /// nonzero; a `HIGHGUID_PET` guid's number is not a substitute, no name query answers it. pub fn unit_pet_number(&self) -> u32 { self.get_u32(FIELD_UNIT_PETNUMBER).unwrap_or(0) } - /// `UNIT_FIELD_PET_NAME_TIMESTAMP` — when this pet's name was last set. `None` when the field - /// has never been sent (a create omits zeros), which reads the same as "never renamed". - /// - /// The **only** thing to do with the value is compare it with the last one seen: a pet's name - /// lives in a query cache keyed by pet number, and this changing is what says that cache has - /// gone stale (see the constant). + /// `UNIT_FIELD_PET_NAME_TIMESTAMP`: changes when the pet is renamed, staling the name cache. pub fn unit_pet_name_timestamp(&self) -> Option { self.get_u32(FIELD_UNIT_PET_NAME_TIMESTAMP) } - /// `UNIT_FIELD_HEALTH` — current hit points (0 ⇒ dead). + /// `UNIT_FIELD_HEALTH`: current hit points, 0 when dead. pub fn unit_health(&self) -> Option { self.get_u32(FIELD_UNIT_HEALTH) } - /// `UNIT_FIELD_MAXHEALTH` — full hit points. + /// `UNIT_FIELD_MAXHEALTH`: full hit points. pub fn unit_max_health(&self) -> Option { self.get_u32(FIELD_UNIT_MAXHEALTH) } - /// Whether the unit is **really dead** — health 0 on a real unit, the RAW field predicate. - /// Absent health reads as **zero** — a create block only carries *non-zero* fields (vmangos - /// `_SetCreateBits`), so an already-dead corpse streams in with `MAXHEALTH` but **no** `HEALTH` - /// at all; the real client's descriptor array is zero-initialized, so absent *is* 0 to it. The - /// `MAXHEALTH > 0` guard keeps a unit whose snapshot hasn't arrived (empty store) from reading - /// as dead. - /// - /// This is the predicate for everything that keys on the body actually being a corpse — the - /// death arc and its release/ghost UI, loot and skinning, the selection's death-clear edge. For - /// **how the unit reads to the eye** — which a feigning hunter shares with a corpse — use - /// [`Self::unit_reads_dead`] (decision 1022). + /// Really dead: health 0 with a known maximum, since a dead unit's create sends no health. + /// For how the unit looks, which a feigning hunter shares, use [`Self::unit_reads_dead`]. pub fn unit_is_dead(&self) -> bool { self.unit_max_health().unwrap_or(0) > 0 && self.unit_health().unwrap_or(0) == 0 } - /// Whether the unit **reads as dead** to the client — the reference's own one predicate - /// `0x605f90`, byte-exact: really dead (`[descr+0x40] <= 0`), **or** carrying - /// `UNIT_DYNFLAG_DEAD` (`0x605f9d`: `[descr+0x224] >> 5 & 1` — **feign death**), **or** stand - /// state 7 `UNIT_STAND_STATE_DEAD` (`0x605faa`: `vtbl+0xa4` == 7). - /// - /// Feign death is exactly this bit and nothing else: vmangos `Unit::SetFeignDeath` - /// (`Unit.cpp:9491`/`9500`) sets and clears it while leaving `UNIT_FIELD_HEALTH` alone, and - /// `Object::BuildValuesUpdate` only rewrites `TRACK_UNIT`/`TAPPED` per viewer — so the flag - /// reaches **every** viewer, the feigner included. The client then reads dead everywhere it - /// matters: `UnitHealth`/`UnitMana` answer 0 ([`Self::unit_shown_health`], - /// [`Self::unit_shown_power`]), `UnitIsDead 0x517ac0` answers true, the animation chain's top - /// resolver `0x5fcff0` claims and holds the corpse pose, and the plate/loop-sound gates drop. - /// - /// The stand-state leg is the reference's; vmangos never writes stand state 7, so against our - /// server the triple and the narrower `UnitIsDead 0x517ac0` pair (health ≤ 0 ∨ dynflag) - /// coincide. + /// Reads as dead, the reference's `0x605f90`: health 0, `UNIT_DYNFLAG_DEAD` (feign death, + /// `0x605f9d`) or stand state 7 (`0x605faa`). Feign death sets only the flag + /// (`Unit.cpp:9491`) and every viewer, the feigner included, receives it. pub fn unit_reads_dead(&self) -> bool { self.unit_is_dead() || self.unit_dynamic_flags() & UNIT_DYNFLAG_DEAD != 0 || self.unit_stand_state() == STAND_STATE_DEAD } - /// Health **as the UI reads it** — the `UnitHealth 0x5174d0` law, byte-exact: a unit carrying - /// `UNIT_DYNFLAG_DEAD` answers **0** (`0x51753c`–`0x517551`), everything else answers the raw - /// field clamped at zero (`0x517553`–`0x517560`: `setle`/`dec`/`and`). So a feigning hunter's - /// health bar empties for himself and for everyone watching. - /// - /// Deliberately NOT mirrored on the max: `UnitHealthMax 0x5175b0` reads `[descr+0x58]` with no - /// gate at all, which is what makes the bar render `0/max` (empty) rather than vanish. + /// Health as `UnitHealth` (`0x5174d0`) reads it: 0 under `UNIT_DYNFLAG_DEAD`, else raw. + /// `UnitHealthMax` (`0x5175b0`) has no such gate, so a feigning unit's bar shows empty. pub fn unit_shown_health(&self) -> Option { if self.unit_dynamic_flags() & UNIT_DYNFLAG_DEAD != 0 { return Some(0); } self.unit_health() } - /// Power **as the UI reads it** — the `UnitMana 0x517670` law, both halves (decision 1034): - /// the `UNIT_DYNFLAG_DEAD` gate (`0x5176d6`–`0x5176e6`) ahead of the power-slot read, and then - /// the **raw→display divide** by [`power_display_scale`] (`0x517704 call 0x6e7130; 0x51770f - /// div ecx`). Rage rides the wire ×10 and pet happiness ×1000; without the divide a warrior's - /// bar reads `0/1000` and a pet's `…/1050000`. - /// - /// `UnitManaMax 0x5177e0` divides too (`0x517864`) but has **no** dead gate, so the bar empties - /// against a real maximum — see [`Self::unit_shown_max_power`]. - /// - /// Not transcribed: the `powerType == POWER_HEALTH (-2)` substitution at `0x5176f7`. The - /// reference means to read HEALTH there, but `0x5176e8 movzx` zero-extends the byte while - /// `0x5176ec cmp edx,0xfffffffe` sign-extends its imm8, so the equality can never hold and the - /// leg is **dead code as compiled in 5875** (wow-re `feign-death-dyndead.md` §3, both §5 - /// workers independently). Rendering it would be reproducing a bug, not the behaviour. + /// Power as `UnitMana` (`0x517670`) reads it: 0 under `UNIT_DYNFLAG_DEAD`, else divided by + /// [`power_display_scale`]. Its `POWER_HEALTH` leg (`0x5176f7`) compares a zero-extended + /// byte with -2, so it never matches and is left out. pub fn unit_shown_power(&self, ty: u8) -> Option { if self.unit_dynamic_flags() & UNIT_DYNFLAG_DEAD != 0 { return (ty < 5).then_some(0); } Some(self.unit_power(ty)? / power_display_scale(u32::from(ty))) } - /// Maximum power **as the UI reads it** — `UnitManaMax 0x5177e0`: the same - /// [`power_display_scale`] divide (`0x517864`), and deliberately **no** dead gate. + /// Maximum power as `UnitManaMax` (`0x5177e0`) reads it: divided, with no dead gate. pub fn unit_shown_max_power(&self, ty: u8) -> Option { Some(self.unit_max_power(ty)? / power_display_scale(u32::from(ty))) } - /// `UNIT_FIELD_LEVEL` — the unit's level. + /// `UNIT_FIELD_LEVEL`: the unit's level. pub fn unit_level(&self) -> Option { self.get_u32(FIELD_UNIT_LEVEL) } - /// One aura slot (0-based), or `None` when the slot is empty or `slot >= UNIT_AURA_SLOTS`. - /// - /// Occupancy is the **client's own liveness test**: at least one effect-index bit set in the - /// `AURAFLAGS` nibble (`flags & 0x0E`, decision 0257) — *not* "has a spell id". A slot the server - /// clears can keep a stale spell id after its flags nibble goes to zero; gating on the id alone - /// would surface that husk as a phantom aura the real client hides (`flags & 0x0E` is precisely - /// why it doesn't). The `spell_id != 0` guard stays as a belt-and-braces check — for a live aura - /// (an applied effect implies a spell) the two always coincide. + /// One aura slot, live only when its `AURAFLAGS` nibble has an effect bit (`& 0x0E`) as the + /// client tests it, since a cleared slot can keep a stale spell id. pub fn unit_aura(&self, slot: u8) -> Option { if slot >= UNIT_AURA_SLOTS { return None; @@ -293,222 +177,147 @@ impl ObjectFields { .saturating_add(1), }) } - /// Every occupied aura slot, **ascending by slot index** — buffs (0–31) before debuffs (32–47). - /// - /// This is the reference client's ordering law for `UnitBuff`/`UnitDebuff` on **another unit**: - /// they read that unit's `UNIT_FIELD_AURA` straight, ascending raw slot within the half (wow-re - /// `0x519500`/`0x5198f0`, VERIFIED — decision 0257). - /// - /// It is **not** the order the player's own buff bar draws. For the local player the client - /// keeps a separate, densely packed cache (`0xbc6040`) in **insertion order**, which repacks on - /// removal — so a dropped buff slides the later ones along, and a new buff always lands at the - /// end rather than in whatever slot the server recycled. That order cannot be recovered from a - /// descriptor snapshot; it has to be *maintained* across updates (decision 0257). + /// Live auras by ascending slot, buffs 0-31 then debuffs 32-47: the order `UnitBuff` and + /// `UnitDebuff` read another unit in (`0x519500`, `0x5198f0`). The local player's buff bar + /// keeps insertion order in a packed cache (`0xbc6040`) instead, which this cannot recover. pub fn unit_auras(&self) -> impl Iterator + '_ { (0..UNIT_AURA_SLOTS).filter_map(|slot| self.unit_aura(slot)) } - /// Every `UNIT_FIELD_AURA` slot's **raw** spell id, unfiltered — no `AURAFLAGS` nibble test, - /// no zero-skip, in ascending slot order. - /// - /// This is deliberately NOT [`Self::unit_auras`], and the difference is the reference's, not - /// ours: the cast validator's crowd-control exemption scan (`0x6e9ca0`) reads these slots - /// straight and **does not consult `UNIT_FIELD_AURAFLAGS`** — it skips no "inactive" slot and - /// reads no duration, stack or caster state. Any non-zero, in-range id counts, husk or not. - /// The buff bar wants the filtered view; that scan wants this one (decision 1946). + /// Every `UNIT_FIELD_AURA` slot's raw spell id, zeros and stale ids included, as the cast + /// validator's crowd-control scan (`0x6e9ca0`) reads them, without `AURAFLAGS`. pub fn unit_aura_ids(&self) -> impl Iterator + '_ { (0..UNIT_AURA_SLOTS) .map(|slot| self.get_u32(FIELD_UNIT_AURA + u16::from(slot)).unwrap_or(0)) } - /// The unit's active power type (`UNIT_FIELD_BYTES_0` byte 3): `0` mana, `1` rage, `2` focus, - /// `3` energy, `4` happiness. Absent (no bytes_0 on the wire yet) reads as mana, the descriptor's - /// zero-initialized default. + /// `UNIT_FIELD_BYTES_0` byte 3: power type, 0 mana, 1 rage, 2 focus, 3 energy, 4 happiness. pub fn unit_power_type(&self) -> u8 { (self.unit_bytes_0().unwrap_or(0) >> 24) as u8 } - /// `UNIT_FIELD_BYTES_1` byte 0 — the unit's stand state (VERIFIED vmangos - /// `UNIT_BYTES_1_OFFSET_STAND_STATE = 0`, written by `Unit::SetStandState`): 0 stand · 1 sit - /// (ground) · 2 sit-chair · 3 sleep · 4/5/6 chair low/med/high · 8 kneel. For a player it's - /// the echo of their own `CMSG_STANDSTATECHANGE`. `0` (standing) when the field is absent — - /// the descriptor's zero-initialized default. + /// `UNIT_FIELD_BYTES_1` byte 0: stand state, 0 stand, 1 sit, 2 chair, 3 sleep, 4-6 low, + /// medium and high chair, 8 kneel; for a player, the echo of `CMSG_STANDSTATECHANGE`. pub fn unit_stand_state(&self) -> u8 { (self.get_u32(FIELD_UNIT_BYTES_1).unwrap_or(0) & 0xff) as u8 } - /// `UNIT_FIELD_BYTES_1` byte 1 — a hunter pet's **loyalty level**, `1..=8`, and `0` for a unit - /// that has none (VERIFIED vmangos `UNIT_BYTES_1_OFFSET_PET_LOYALTY = 1`; byte-verified - /// client-side as `[unit+0x110]+0x211`, the byte `GetPetLoyalty 0x4be700` indexes - /// `PetLoyalty.dbc` with). `0` is the binding's **nil**, not level 1. + /// `UNIT_FIELD_BYTES_1` byte 1: a hunter pet's loyalty level, 1-8, which `GetPetLoyalty` + /// (`0x4be700`) indexes `PetLoyalty.dbc` with; 0 is none and answers nil. pub fn unit_loyalty_level(&self) -> u8 { ((self.get_u32(FIELD_UNIT_BYTES_1).unwrap_or(0) >> 8) & 0xff) as u8 } - /// `UNIT_FIELD_PETEXPERIENCE` / `UNIT_FIELD_PETNEXTLEVELEXP` — `GetPetExperience`'s - /// `(currXP, nextXP)`, in that order. Absent fields read `0`, which is the binding's own - /// gate-failure value: this pair is numbers on every path, never nil. + /// `UNIT_FIELD_PETEXPERIENCE` and `UNIT_FIELD_PETNEXTLEVELEXP`: `GetPetExperience`'s + /// `(currXP, nextXP)`; absent reads 0, the binding's own failure value, never nil. pub fn unit_pet_experience(&self) -> (u32, u32) { ( self.get_u32(FIELD_UNIT_PETEXPERIENCE).unwrap_or(0), self.get_u32(FIELD_UNIT_PETNEXTLEVELEXP).unwrap_or(0), ) } - /// `UNIT_TRAINING_POINTS` split as the client splits it — `(total, spent)`, **high word - /// first**. The order is the load-bearing half: both are small numbers of the same magnitude, - /// so swapping them yields a pet that looks like it has spent everything or nothing, with no - /// error anywhere. + /// `UNIT_TRAINING_POINTS`: `(total, spent)`, total in the high half as the client splits it. pub fn unit_training_points(&self) -> (u16, u16) { let packed = self.get_u32(FIELD_UNIT_TRAINING_POINTS).unwrap_or(0); ((packed >> 16) as u16, (packed & 0xffff) as u16) } - /// `UNIT_FIELD_BYTES_1` byte 3's `UNIT_VIS_FLAGS_CREEP (0x2)` — the sneaking visual, set by - /// `SPELL_AURA_MOD_STEALTH` (vmangos `SpellAuras.cpp:3610`, - /// `UNIT_BYTES_1_OFFSET_VIS_FLAG = 3`). The usable walk's only-stealthed leg tests exactly - /// this bit (wow-re §2a leg 7: `[+0x110]+0x213 & 2`); the tracking classifier's - /// stealth always-show clause tests it against our own track-stealthed bit - /// (`0x5ed210`, wow-re `track-predicates.md`). + /// `UNIT_FIELD_BYTES_1` byte 3 bit `0x2`, `UNIT_VIS_FLAGS_CREEP` (`SpellAuras.cpp:3610`): + /// stealthed; read by the usable check (`[+0x110]+0x213 & 2`) and the tracker (`0x5ed210`). pub fn unit_is_stealthed(&self) -> bool { (self.get_u32(FIELD_UNIT_BYTES_1).unwrap_or(0) >> 24) & 0x2 != 0 } - /// `UNIT_FIELD_BYTES_1` byte 3's `UNIT_VIS_FLAGS_GHOST (0x1)` — set on ANY unit by the ghost - /// aura (vmangos `HandleAuraGhost`). Byte-VERIFIED (wow-re ghost-death-visuals.md): this flag - /// drives NO body render — its complete client effect is nameplate/marker suppression - /// (tested as `byte3 & 0x03`, ghost|creep, at `0x607101`/`0x60f62e`). The translucent - /// whitened BODY is the ghost aura's own state visual kit (spell 8326 → SpellVisual 886 → - /// kit 989: charproc-14 alpha 0.5 + charproc-1 tint 0xFF8CB9FD + `Spells\Ghost_state.mdx`) — - /// aura-driven, exactly like Stealth's 0.3 translucency. Consumers: the overhead-name and - /// V-plate gates. + /// `UNIT_FIELD_BYTES_1` byte 3 bit `0x1`, `UNIT_VIS_FLAGS_GHOST`, set by the ghost aura: it + /// only hides names and plates (`byte3 & 0x03`, `0x607101`, `0x60f62e`); the ghostly body is + /// the aura's own visual kit (spell 8326, kit 989). pub fn unit_is_ghost_visual(&self) -> bool { (self.get_u32(FIELD_UNIT_BYTES_1).unwrap_or(0) >> 24) & 0x1 != 0 } - /// `UNIT_FIELD_BYTES_1` byte 3's **`0x4`** — the third neighbour of the ghost (`0x1`) and creep - /// (`0x2`) bits above. Byte-VERIFIED as the minimap object-dot classifier's own third - /// precondition (wow-re `questgiver-marker.md` §W15: `byte [eax+0x213] & 4`, where `eax` is the - /// descriptor block base — `0x210/4 = 132` = `UNIT_FIELD_BYTES_1`; the only `& 4` site on this - /// byte image-wide, by two independent censuses). Set ⇒ the unit draws **no minimap dot at - /// all**, quest or tracking. - /// - /// The *name* is INFERRED from vmangos's `UNIT_VIS_FLAGS_UNTRACKABLE`, like its two siblings — - /// no flag-name string exists in the image. The bit and its effect are verified. + /// `UNIT_FIELD_BYTES_1` byte 3 bit `0x4` (vmangos `UNIT_VIS_FLAGS_UNTRACKABLE`): no minimap + /// dot at all, quest or tracking (`byte [eax+0x213] & 4`, the byte's only such test). pub fn unit_is_untrackable(&self) -> bool { (self.get_u32(FIELD_UNIT_BYTES_1).unwrap_or(0) >> 24) & 0x4 != 0 } - /// `UNIT_FIELD_AURASTATE` ([`FIELD_UNIT_AURASTATE`]) — the aura-state bit set (defense 1, - /// healthless-20% 2, …); the usable walk tests `1 << (state-1)` against it. + /// `UNIT_FIELD_AURASTATE`: the aura-state bits (1 defense, 2 health under 20%, ...), which + /// the usable check tests as `1 << (state - 1)`. pub fn unit_aura_state(&self) -> u32 { self.get_u32(FIELD_UNIT_AURASTATE).unwrap_or(0) } - /// `UNIT_FIELD_BYTES_1` byte 2 — the shapeshift form (VERIFIED vmangos `UpdateFields_1_12_1.h` - /// 0x8A + `UNIT_BYTES_1_OFFSET_SHAPESHIFT_FORM = 2`): 0 none, 17/18/19 = warrior - /// battle/defensive/berserker stance, druid forms in the low ids. Drives the action bar's - /// bonus-page pick, and the tracking dots' creature-type override (the `0x605570` - /// resolver's first branch — decision 0564). + /// `UNIT_FIELD_BYTES_1` byte 2: the shapeshift form, 0 none, 17-19 the warrior stances, the + /// druid forms low; the creature-type resolver `0x605570` checks it first. pub fn unit_shapeshift_form(&self) -> u8 { (self.get_u32(FIELD_UNIT_BYTES_1).unwrap_or(0) >> 16) as u8 } - /// `UNIT_FIELD_POWER1..5` — current power for slot `ty` (the [`Self::unit_power_type`] index). - /// Out-of-range slots and absent fields read `None`. + /// `UNIT_FIELD_POWER1..5`: current power of type `ty`, on the wire's raw scale. pub fn unit_power(&self, ty: u8) -> Option { (ty < 5).then(|| self.get_u32(FIELD_UNIT_POWER1 + u16::from(ty)))? } - /// `UNIT_FIELD_MAXPOWER1..5` — maximum power for slot `ty`. + /// `UNIT_FIELD_MAXPOWER1..5`: maximum power of type `ty`, raw. pub fn unit_max_power(&self, ty: u8) -> Option { (ty < 5).then(|| self.get_u32(FIELD_UNIT_MAXPOWER1 + u16::from(ty)))? } - /// `UNIT_FIELD_BASE_MANA` (VERIFIED vmangos `UpdateFields_1_12_1.h` `OBJECT_END + 0x9C` = 162; - /// PRIVATE + OWNER_ONLY, so it rides only our own descriptor) — the base the percent-cost - /// spells (`ManaCostPercentage`) scale from. `None` when absent (a non-self unit). + /// `UNIT_FIELD_BASE_MANA` (field 162, owner only): what `ManaCostPercentage` costs scale from. pub fn unit_base_mana(&self) -> Option { self.get_u32(FIELD_UNIT_BASE_MANA) } - /// `UNIT_FIELD_FACTIONTEMPLATE` — the unit's `FactionTemplate.dbc` row id, the key the client's - /// whole reaction/hostility decode starts from (wow-re: descriptor `+0x74`, read by every - /// `UnitReaction`/`Can*` predicate). Flagged PUBLIC server-side, so it's always on the wire. + /// `UNIT_FIELD_FACTIONTEMPLATE` (public, `+0x74`): the `FactionTemplate.dbc` row reactions use. pub fn unit_faction_template(&self) -> Option { self.get_u32(FIELD_UNIT_FACTIONTEMPLATE) } - /// `UNIT_FIELD_FLAGS` — unit state flags; bit `0x0400_0000` = **skinnable** (the cursor's Skin - /// branch reads it — wow-re cursor RE, client `+0xa0`). Absent counts as 0 (create omits zeros). + /// `UNIT_FIELD_FLAGS` (`+0xa0`): unit state flags; bit `0x0400_0000` is skinnable. pub fn unit_flags(&self) -> u32 { self.get_u32(FIELD_UNIT_FLAGS).unwrap_or(0) } - /// `UNIT_FIELD_COMBATREACH` — the melee-reach float the client's edge-to-edge attack range gate - /// reads (`rA + rB + 1.333`, cap 5.0 — wow-re cursor RE `0x6e3480`, client `+0x1f0`). Vanilla - /// default 1.5 when absent. + /// `UNIT_FIELD_COMBATREACH` (`+0x1f0`): melee reach, summed as `rA + rB + 1.333`, cap 5.0, + /// by the attack range gate (`0x6e3480`); 1.5, the vanilla default, before it streams. pub fn unit_combat_reach(&self) -> f32 { self.get_f32(FIELD_UNIT_COMBATREACH).unwrap_or(1.5) } - /// `UNIT_FIELD_BOUNDINGRADIUS` — the unit's horizontal bounding radius (yd). The water-foam - /// depth gate reads it (`max(2 × radius, 1.0)` — wow-re CWater0Ripple driver `0x5fa760`). - /// Absent counts as 0 — the gate's own 1.0-yd floor covers it, like the reference. + /// `UNIT_FIELD_BOUNDINGRADIUS`: horizontal radius in yards; the water-foam depth gate takes + /// `max(2 * radius, 1.0)` (`0x5fa760`), so absent reads 0. pub fn unit_bounding_radius(&self) -> f32 { self.get_f32(FIELD_UNIT_BOUNDINGRADIUS).unwrap_or(0.0) } - /// `UNIT_DYNAMIC_FLAGS` — per-viewer dynamic state; bit `0x1` = **lootable** (the cursor's Loot - /// branch — wow-re cursor RE, client `+0x224`), bit `0x2` = tracked (Hunter's Mark), bit `0x20` - /// = **dead-looking** ([`Self::unit_reads_dead`] — feign death). Absent counts as 0. + /// `UNIT_DYNAMIC_FLAGS` (`+0x224`), per viewer: `0x1` lootable, `0x2` tracked, `0x20` dead. pub fn unit_dynamic_flags(&self) -> u32 { self.get_u32(FIELD_UNIT_DYNAMIC_FLAGS).unwrap_or(0) } - /// `UNIT_DYNAMIC_FLAGS` bit `0x20` alone — the **dead-looking** flag, without - /// [`Self::unit_reads_dead`]'s two other legs. - /// - /// It has its own accessor because one caller wants exactly this bit and not that predicate: - /// `SetSelection`'s last-enemy stamp gates on `HEALTH > 0 || dynflag 0x20` - /// (`0x49372f`-`0x493778`), with no health-zero leg and no stand-state leg, so a feigning unit - /// is remembered and a genuinely dead one is not. Reaching for `unit_reads_dead` there would - /// quietly add a third condition the reference does not have. + /// `UNIT_DYNAMIC_FLAGS` bit `0x20` alone: `SetSelection`'s last-enemy stamp gates on + /// `HEALTH > 0 || dynflag 0x20` (`0x49372f`), not on [`Self::unit_reads_dead`]. pub fn unit_dynflag_dead(&self) -> bool { self.unit_dynamic_flags() & UNIT_DYNFLAG_DEAD != 0 } - /// `UNIT_DYNAMIC_FLAGS` bit `0x4` — **tapped**: this unit is someone's kill credit. - /// `UnitIsTapped 0x519c90` is exactly `0x519cd0 test BYTE PTR [eax+0x224],0x4` behind an - /// object-presence check, nothing else — no ownership, GUID compare, party/raid or health - /// conjunct anywhere in its body (wow-re `ui/scratch/tapped-bits-and-unit-faction.md`). + /// `UNIT_DYNAMIC_FLAGS` bit `0x4`: tapped, the only test in `UnitIsTapped` (`0x519c90`). pub fn unit_tapped(&self) -> bool { self.unit_dynamic_flags() & 0x4 != 0 } - /// `UNIT_DYNAMIC_FLAGS` bit `0x8` — **tapped by me** (`UnitIsTappedByPlayer 0x519d00`, the same - /// 108-byte body with the mask changed). Per-viewer: the server decides whose credit it is - /// before it ships the field, so the client reads it verbatim and does nothing else with it. - /// - /// The pair is read together — `tapped && !tapped_by_player` is "someone ELSE's kill", the - /// grey-bar condition every unit-frame addon draws. + /// `UNIT_DYNAMIC_FLAGS` bit `0x8`: tapped by me (`UnitIsTappedByPlayer`, `0x519d00`), which + /// the server sets per viewer; tapped but not by me is the grey unit frame. pub fn unit_tapped_by_player(&self) -> bool { self.unit_dynamic_flags() & 0x8 != 0 } - /// `UNIT_DYNAMIC_FLAGS` bit `0x1` — **lootable by me** (the server strips the bit per viewer - /// before it ships — vmangos's "hide lootable animation for unallowed players"). Gates the - /// loot cursor, the right-click loot route, and the corpse sparkle (wow-re - /// `loot-corpse-effect.md`). + /// `UNIT_DYNAMIC_FLAGS` bit `0x1`: lootable by me, stripped per viewer by the server; gates + /// the loot cursor, right-click looting and the corpse sparkle. pub fn unit_lootable(&self) -> bool { self.unit_dynamic_flags() & 0x1 != 0 } - /// `UNIT_NPC_FLAGS` — the NPC service bits (vanilla values: gossip `0x1`, questgiver `0x2`, - /// vendor `0x4`, flightmaster `0x8`, trainer `0x10`, … repair `0x4000` — vmangos - /// `UnitDefines.h`). The cursor's service ladder switches on these, first bit wins (wow-re - /// cursor RE, client `+0x234`). Absent counts as 0. + /// `UNIT_NPC_FLAGS` (`+0x234`, `UnitDefines.h`): gossip `0x1`, questgiver `0x2`, vendor + /// `0x4`, flight master `0x8`, trainer `0x10`, ..., repair `0x4000`; cursor: first bit wins. pub fn unit_npc_flags(&self) -> u32 { self.get_u32(FIELD_UNIT_NPC_FLAGS).unwrap_or(0) } - /// `UNIT_NPC_EMOTESTATE` — the unit's looping state emote (`Emotes.dbc` id: dance, NPC - /// cooking/working flavor; `0`/absent = none). The anim layer loops its `AnimID` while the - /// unit stands; movement suppresses it (the field stays set server-side until cleared). + /// `UNIT_NPC_EMOTESTATE`: the looping `Emotes.dbc` state, which the server keeps while moving. pub fn unit_emote_state(&self) -> u32 { self.get_u32(FIELD_UNIT_NPC_EMOTESTATE).unwrap_or(0) } - /// `UNIT_FIELD_BYTES_0` — packed race (byte 0) / class (byte 1) / gender (byte 2) / power type (3). + /// `UNIT_FIELD_BYTES_0`: race, class, gender and power type, bytes 0 to 3. pub fn unit_bytes_0(&self) -> Option { self.get_u32(FIELD_UNIT_BYTES_0) } - /// `UNIT_VIRTUAL_ITEM_SLOT_DISPLAY + slot` — a creature's virtual-weapon **display id** for - /// `slot` (0 mainhand / 1 offhand / 2 ranged): the item's `DisplayInfoID` directly, no item guid - /// behind it (see the field's doc comment). `slot >= 3` reads `None`. + /// `UNIT_VIRTUAL_ITEM_SLOT_DISPLAY + slot`: a creature's weapon `DisplayInfoID` for slot 0 + /// main hand, 1 off hand or 2 ranged, with no item behind it. pub fn unit_virtual_item_display(&self, slot: u8) -> Option { (slot < 3).then(|| self.get_u32(FIELD_UNIT_VIRTUAL_ITEM_SLOT_DISPLAY + u16::from(slot)))? } - /// `UNIT_VIRTUAL_ITEM_INFO + 2*slot` — `slot`'s flavor bytes: `(class, subclass, material, - /// inventory_type)`. `slot >= 3` reads `None`. + /// `UNIT_VIRTUAL_ITEM_INFO + 2*slot`: bytes `(class, subclass, material, inventory_type)`. pub fn unit_virtual_item_info(&self, slot: u8) -> Option<(u8, u8, u8, u8)> { let v = (slot < 3).then(|| self.get_u32(FIELD_UNIT_VIRTUAL_ITEM_INFO + 2 * u16::from(slot)))?; @@ -521,95 +330,84 @@ impl ObjectFields { ) }) } - /// `UNIT_VIRTUAL_ITEM_INFO + 2*slot + 1` byte 0 — `slot`'s virtual item Sheath. `slot >= 3` - /// reads `None`. + /// `UNIT_VIRTUAL_ITEM_INFO + 2*slot + 1` byte 0: the virtual item's sheath type. pub fn unit_virtual_item_sheath(&self, slot: u8) -> Option { let v = (slot < 3) .then(|| self.get_u32(FIELD_UNIT_VIRTUAL_ITEM_INFO + 2 * u16::from(slot) + 1))?; v.map(|v| (v & 0xff) as u8) } - /// `UNIT_FIELD_BYTES_2` byte 0 — the unit's sheath state: `0` unarmed/stowed, `1` melee drawn, - /// `2` ranged drawn. For a player this is purely what they last told the server via - /// `CMSG_SETSHEATHED`, not a server-computed fact. + /// `UNIT_FIELD_BYTES_2` byte 0: sheath state, 0 stowed, 1 melee drawn, 2 ranged drawn; for a + /// player, whatever they last sent in `CMSG_SETSHEATHED`. pub fn unit_sheath_state(&self) -> Option { self.get_u32(FIELD_UNIT_BYTES_2).map(|v| (v & 0xff) as u8) } - /// `UNIT_FIELD_STAT0..4 + i` — the unit's raw (post-buff) primary stat `i` (0 Str, 1 Agi, - /// 2 Stam, 3 Int, 4 Spirit — the `Stats` enum order). `i >= 5` reads `None`. + /// `UNIT_FIELD_STAT0 + i`: primary stat `i` after buffs, 0 Str, 1 Agi, 2 Sta, 3 Int, 4 Spi. pub fn unit_stat(&self, i: u8) -> Option { (i < 5).then(|| self.get_u32(FIELD_UNIT_STAT0 + u16::from(i)))? } - /// `UNIT_FIELD_RESISTANCES + i` — resistance `i` (0 = armor, 1..6 the magic schools). `i >= 7` - /// reads `None`. + /// `UNIT_FIELD_RESISTANCES + i`: resistance `i`, signed, 0 armor, 1-6 the magic schools. pub fn unit_resistance(&self, i: u8) -> Option { (i < 7).then(|| self.get_i32(FIELD_UNIT_RESISTANCES + u16::from(i)))? } - /// `UNIT_FIELD_ATTACK_POWER` — melee attack power before the split pos/neg mods. + /// `UNIT_FIELD_ATTACK_POWER`: melee attack power before the split pos/neg mods. pub fn unit_attack_power(&self) -> Option { self.get_i32(FIELD_UNIT_ATTACK_POWER) } - /// `UNIT_FIELD_ATTACK_POWER_MODS` — `(positive, negative)` flat AP mods (the negative half - /// already negative-or-zero; see the field's doc comment for the verified packing/sign). - /// Absent reads `(0, 0)` — no mods applied yet. + /// `UNIT_FIELD_ATTACK_POWER_MODS`: flat `(positive, negative)` mods, the low and high i16. pub fn unit_attack_power_mods(&self) -> (i16, i16) { let (lo, hi) = self .get_u16_pair(FIELD_UNIT_ATTACK_POWER_MODS) .unwrap_or((0, 0)); (lo as i16, hi as i16) } - /// `UNIT_FIELD_ATTACK_POWER_MULTIPLIER` — the percent AP bonus **minus 1.0** (`0.0` = no - /// bonus; vmangos writes `multiplier − 1.0`, see the field's doc comment). + /// `UNIT_FIELD_ATTACK_POWER_MULTIPLIER`: the percent bonus minus 1.0, so 0.0 is no bonus. pub fn unit_attack_power_multiplier(&self) -> Option { self.get_f32(FIELD_UNIT_ATTACK_POWER_MULTIPLIER) } - /// `UNIT_FIELD_RANGED_ATTACK_POWER` — ranged attack power before the split pos/neg mods. + /// `UNIT_FIELD_RANGED_ATTACK_POWER`: ranged attack power before the split pos/neg mods. pub fn unit_ranged_attack_power(&self) -> Option { self.get_i32(FIELD_UNIT_RANGED_ATTACK_POWER) } - /// `UNIT_FIELD_RANGED_ATTACK_POWER_MODS` — the ranged twin of - /// [`Self::unit_attack_power_mods`]. + /// `UNIT_FIELD_RANGED_ATTACK_POWER_MODS`: the ranged `(positive, negative)` flat mods. pub fn unit_ranged_attack_power_mods(&self) -> (i16, i16) { let (lo, hi) = self .get_u16_pair(FIELD_UNIT_RANGED_ATTACK_POWER_MODS) .unwrap_or((0, 0)); (lo as i16, hi as i16) } - /// `UNIT_FIELD_RANGED_ATTACK_POWER_MULTIPLIER` — the ranged twin of - /// [`Self::unit_attack_power_multiplier`]. + /// `UNIT_FIELD_RANGED_ATTACK_POWER_MULTIPLIER`: the ranged percent bonus minus 1.0. pub fn unit_ranged_attack_power_multiplier(&self) -> Option { self.get_f32(FIELD_UNIT_RANGED_ATTACK_POWER_MULTIPLIER) } - /// `UNIT_FIELD_MINDAMAGE` — the mainhand weapon's minimum damage. + /// `UNIT_FIELD_MINDAMAGE`: the mainhand weapon's minimum damage. pub fn unit_min_damage(&self) -> Option { self.get_f32(FIELD_UNIT_MINDAMAGE) } - /// `UNIT_FIELD_MAXDAMAGE` — the mainhand weapon's maximum damage. + /// `UNIT_FIELD_MAXDAMAGE`: the mainhand weapon's maximum damage. pub fn unit_max_damage(&self) -> Option { self.get_f32(FIELD_UNIT_MAXDAMAGE) } - /// `UNIT_FIELD_MINOFFHANDDAMAGE` — the offhand weapon's minimum damage (present whether or not - /// one's equipped; the paper-doll only shows it when an offhand item is worn). + /// `UNIT_FIELD_MINOFFHANDDAMAGE`: the offhand minimum damage, sent even with no offhand worn. pub fn unit_min_offhand_damage(&self) -> Option { self.get_f32(FIELD_UNIT_MINOFFHANDDAMAGE) } - /// `UNIT_FIELD_MAXOFFHANDDAMAGE` — the offhand weapon's maximum damage. + /// `UNIT_FIELD_MAXOFFHANDDAMAGE`: the offhand weapon's maximum damage. pub fn unit_max_offhand_damage(&self) -> Option { self.get_f32(FIELD_UNIT_MAXOFFHANDDAMAGE) } - /// `UNIT_FIELD_MINRANGEDDAMAGE` — the ranged weapon's minimum damage. + /// `UNIT_FIELD_MINRANGEDDAMAGE`: the ranged weapon's minimum damage. pub fn unit_min_ranged_damage(&self) -> Option { self.get_f32(FIELD_UNIT_MINRANGEDDAMAGE) } - /// `UNIT_FIELD_MAXRANGEDDAMAGE` — the ranged weapon's maximum damage. + /// `UNIT_FIELD_MAXRANGEDDAMAGE`: the ranged weapon's maximum damage. pub fn unit_max_ranged_damage(&self) -> Option { self.get_f32(FIELD_UNIT_MAXRANGEDDAMAGE) } - /// `UNIT_FIELD_BASEATTACKTIME + i` — attack speed in ms (`i` 0 mainhand, 1 offhand). `i >= 2` - /// reads `None`. + /// `UNIT_FIELD_BASEATTACKTIME + i`: attack speed in ms, 0 mainhand, 1 offhand. pub fn unit_base_attack_time(&self, i: u8) -> Option { (i < 2).then(|| self.get_u32(FIELD_UNIT_BASEATTACKTIME + u16::from(i)))? } - /// `UNIT_FIELD_RANGEDATTACKTIME` — ranged weapon attack speed in ms. + /// `UNIT_FIELD_RANGEDATTACKTIME`: ranged weapon attack speed in ms. pub fn unit_ranged_attack_time(&self) -> Option { self.get_u32(FIELD_UNIT_RANGEDATTACKTIME) } diff --git a/crates/benilla-protocol/src/messages/update_object/mod.rs b/crates/benilla-protocol/src/messages/update_object/mod.rs index 3e09adc1..0621e242 100644 --- a/crates/benilla-protocol/src/messages/update_object/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/mod.rs @@ -1,9 +1,5 @@ -//! `SMSG_UPDATE_OBJECT` body decode: the object list, each entry's [`MovementBlock`] pose and sparse -//! [`ObjectFields`] of descriptor fields. The complex, *growing* core of the message layer (decision -//! 0021) — split out because the descriptor-field decode expands as the object layer does. The parent -//! module ([`super`]) owns packet framing/dispatch and the client builders, and the shared -//! `MOVEMENT_FLAG_*` constants (also used by its movement-info parse), which this module reaches via -//! `super::`. +//! `SMSG_UPDATE_OBJECT` body decode: the object list, each entry's [`MovementBlock`] and its +//! sparse [`ObjectFields`]. use std::io::{self, Read}; @@ -31,7 +27,7 @@ pub enum Object { guid: u64, movement: MovementBlock, }, - /// `CREATE_OBJECT` / `CREATE_OBJECT2` (identical wire shape; the distinction is irrelevant here). + /// `CREATE_OBJECT` or `CREATE_OBJECT2`, which share one wire shape. Create { guid: u64, object_type: ObjectType, @@ -62,11 +58,8 @@ impl Object { let guid = read_packed_guid(r)?; let object_type = ObjectType::from_u8(read_u8(r)?); let movement = MovementBlock::read(r)?; - // A create's mask is the COMPLETE descriptor — the server omits zero-valued - // fields (vmangos `_SetCreateBits`), so absent must read 0, not unknown - // (`ObjectFields`'s created semantics). The TYPE rides along because "absent = 0" - // holds only inside this object's OWN descriptor: a creature has no PLAYER block - // to be absent from (decision 1081). + // A create omits zero fields (vmangos `_SetCreateBits`), so absent reads 0, but + // only inside this type's own descriptor. let mask = ObjectFields::read(r)?.into_created(object_type); Object::Create { guid, @@ -100,7 +93,7 @@ fn read_guid_list(r: &mut impl Read) -> io::Result> { Ok(guids) } -/// Parse an `SMSG_UPDATE_OBJECT` body's object list (the count + has-transport byte + each `Object`). +/// Parse an `SMSG_UPDATE_OBJECT` body: the count, the has-transport byte, then each `Object`. pub(super) fn read_update_object(r: &mut impl Read) -> io::Result> { let amount_of_objects = read_u32_le(r)?; let _has_transport = read_u8(r)?; @@ -117,8 +110,8 @@ mod tests { #[test] fn quest_log_slot_unpacks_counters_and_state() { - // Slot 2 (base 198 + 6 = 204): counters 5/10/63/0 packed at 6-bit strides, state byte - // COMPLETE, a timer value. Counter packing per vmangos Player.h:1100-1106. + // Slot 2 (198 + 6 = 204): counters 5/10/63/0 at 6-bit strides, state COMPLETE, a timer + // (`Player.h:1100-1106`). let count_state = 5u32 | (10 << 6) | (63 << 12) | ((quest_slot_state::COMPLETE as u32) << 24); let f = ObjectFields::from_pairs(&[(204, 783), (205, count_state), (206, 4242)]); @@ -131,7 +124,6 @@ mod tests { timer: 4242, }) ); - // The neighbouring slots never streamed → None; out of range → None. assert_eq!(f.player_quest_log(1), None); assert_eq!(f.player_quest_log(3), None); assert_eq!(f.player_quest_log(PLAYER_QUEST_LOG_SLOTS), None); @@ -139,7 +131,7 @@ mod tests { #[test] fn quest_log_cleared_slot_reads_zero_id() { - // An abandoned slot: the server zeroes the id (and pair) — still `Some`, id 0. + // An abandoned slot: the server zeroes the id and the pair. let f = ObjectFields::from_pairs(&[(198, 0), (199, 0), (200, 0)]); assert_eq!(f.player_quest_log(0).map(|s| s.quest_id), Some(0)); }