comments: protocol, the player and unit field accessors

1,040 comment lines become 387; the code is unchanged. The note that
item objects have no decode path is gone, since the same file decodes
them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-23 20:14:22 +07:00
co-authored by Claude Opus 5.5
parent 6e1e06f431
commit e27e68049a
4 changed files with 368 additions and 1021 deletions
File diff suppressed because it is too large Load Diff
@@ -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.
@@ -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<f32> {
self.get_f32(FIELD_OBJECT_SCALE_X)
}
@@ -77,200 +42,119 @@ impl ObjectFields {
pub fn unit_displayid(&self) -> Option<i32> {
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<i32> {
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<u64> {
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<u64> {
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<u64> {
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<u64> {
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<u64> {
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 : <fallback>` 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<u64> {
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<u64> {
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<u32> {
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<u32> {
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<u32> {
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<u32> {
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<u32> {
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<u32> {
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<u32> {
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<u32> {
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<UnitAuraSlot> {
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<Item = UnitAuraSlot> + '_ {
(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<Item = u32> + '_ {
(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<u32> {
(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<u32> {
(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<u32> {
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<u32> {
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<u32> {
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<u32> {
(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<u8> {
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<u8> {
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<u32> {
(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<i32> {
(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<i32> {
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<f32> {
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<i32> {
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<f32> {
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<f32> {
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<f32> {
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<f32> {
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<f32> {
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<f32> {
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<f32> {
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<u32> {
(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<u32> {
self.get_u32(FIELD_UNIT_RANGEDATTACKTIME)
}
@@ -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<Vec<u64>> {
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<Vec<Object>> {
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));
}