buffed stats go green, and the bear stops stacking its roar

- gear and buff bonuses finally render green in the character sheet, tooltips naming the unbuffed base — the stat-split fields are int on the wire and were decoded as float, which rounded every bonus to zero
- the bear pet's aggro roar played 63 times in two minutes and stacked into a phasing growl; it now plays through the reference's per-unit voice channel, where a live bark drops the next
- the chat bubble stops jittering: its anchor lagged the body by a frame, and it now snaps to the device pixel grid like the nameplate under it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-17 17:06:24 +07:00
co-authored by Claude Fable 5
parent 39f5953a7e
commit d3c1a501de
17 changed files with 762 additions and 85 deletions
@@ -384,18 +384,27 @@ pub const PLAYER_EXPLORED_ZONES_SLOTS: u16 = 64;
// `UNIT_END`(188) + vmangos's own symbolic offset, in **decimal** (never the hex `//` comments, stale
// from the CONTAINER block on) — chain-verified against the already-tested COINAGE(1176) and
// BUYBACK_PRICE_1(1226) anchors (see the tests).
const FIELD_PLAYER_POSSTAT0: u16 = 1177; // UNIT_END+0x3DD ×5; FLOAT despite the header's "Type: INT"
// tag (VERIFIED vmangos `Player.h:1505,1511`
// `ApplyStatBuffMod`/`GetPosStat` route through
// `ApplyModSignedFloatValue`/`GetFloatValue`).
const FIELD_PLAYER_NEGSTAT0: u16 = 1182; // ×5, FLOAT, same header-tag mismatch; **negative-or-zero**
// (VERIFIED `Player.h:1505`: a stat *debuff* routes its
// already-negative `val` into this array through the same
// signed accumulator).
const FIELD_PLAYER_RESISTANCEBUFFMODSPOSITIVE: u16 = 1187; // ×7, FLOAT (`Player.h:1513-1514`)
const FIELD_PLAYER_RESISTANCEBUFFMODSNEGATIVE: u16 = 1194; // ×7, FLOAT; negative-or-zero (VERIFIED
// `SpellAuras.cpp:4496-4499`: `change` is the signed
// resistance delta, routed here unchanged).
// **These four families are INT on the wire, and the server's own storage type is not the wire
// type** (decision 1397). The header's "Type: INT" tag is right and the earlier "FLOAT" reading
// here was wrong: it was taken from vmangos's *internal* accessors (`Player.h:1505,1511-1514`
// really do route through `ApplyModSignedFloatValue`/`GetFloatValue`), which is one layer below
// the question. `Object::BuildValuesUpdate` (`Objects/Object.cpp`) carries an explicit special
// case — comment *"there are some float values which may be negative or can't get negative due to
// other checks"* — that sends exactly `NEGSTAT0..4`, `POSSTAT0..4`,
// `RESISTANCEBUFFMODSPOSITIVE+0..6` and `…NEGATIVE+0..6` as `*data << uint32(m_floatValues[index])`,
// the same float→int narrowing it already applies to `UNIT_FIELD_BASEATTACKTIME`. Live-confirmed on
// a level-60 warrior in preraid-BiS gear: fields 1177..1181 arrive as `105, 76, 68, 4, 4` (the
// gear's own +105 str / +76 agi / …), and 1215 `MOD_DAMAGE_DONE_PCT` in the same packet arrives as
// `0x3F800000` — genuine f32 — so the two encodings sit side by side in one block.
//
// Read **signed**: the server's cast of a negative float to `uint32` is UB, and the two
// architectures disagree. On x86 it wraps to the two's-complement word; on aarch64 `fcvtzu`
// saturates and a stat *debuff* arrives as a flat `0` (measured on this deploy's arm64 container:
// a −5 agility debuff moved `UNIT_FIELD_STAT1` but sent `NEGSTAT1 = 0`). `i32` is right for both.
const FIELD_PLAYER_POSSTAT0: u16 = 1177; // UNIT_END+0x3DD ×5, INT
const FIELD_PLAYER_NEGSTAT0: u16 = 1182; // ×5, INT; negative-or-zero where the wire can carry it
const FIELD_PLAYER_RESISTANCEBUFFMODSPOSITIVE: u16 = 1187; // ×7, INT
const FIELD_PLAYER_RESISTANCEBUFFMODSNEGATIVE: u16 = 1194; // ×7, INT; negative-or-zero
const FIELD_PLAYER_MOD_DAMAGE_DONE_POS: u16 = 1201; // ×7 schools ([0]=physical), INT (VERIFIED
// `StatSystem.cpp:87` `SetStatInt32Value` for the magic
// schools, `SpellAuras.cpp:5208-5210`
@@ -297,27 +297,30 @@ impl ObjectFields {
pub fn player_crit_percentage(&self) -> Option<f32> {
self.get_f32(FIELD_PLAYER_CRIT_PERCENTAGE)
}
/// `PLAYER_FIELD_POSSTAT0 + i` — stat `i`'s **positive** buff delta (float despite the header's
/// "Type: INT" tag; see the field's doc comment). `i >= 5` reads `None`.
pub fn player_posstat(&self, i: u8) -> Option<f32> {
(i < 5).then(|| self.get_f32(FIELD_PLAYER_POSSTAT0 + u16::from(i)))?
/// `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`.
pub fn player_posstat(&self, i: u8) -> Option<i32> {
(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 (see the
/// field's doc comment for the verified sign). `i >= 5` reads `None`.
pub fn player_negstat(&self, i: u8) -> Option<f32> {
(i < 5).then(|| self.get_f32(FIELD_PLAYER_NEGSTAT0 + 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`.
pub fn player_negstat(&self, i: u8) -> Option<i32> {
(i < 5).then(|| self.get_i32(FIELD_PLAYER_NEGSTAT0 + u16::from(i)))?
}
/// `PLAYER_FIELD_RESISTANCEBUFFMODSPOSITIVE + school` — the positive resistance buff for
/// `school` (0..6). `school >= 7` reads `None`.
pub fn player_resistance_buff_pos(&self, school: u8) -> Option<f32> {
/// `school` (0..6), INT on the wire. `school >= 7` reads `None`.
pub fn player_resistance_buff_pos(&self, school: u8) -> Option<i32> {
(school < 7)
.then(|| self.get_f32(FIELD_PLAYER_RESISTANCEBUFFMODSPOSITIVE + u16::from(school)))?
.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`.
pub fn player_resistance_buff_neg(&self, school: u8) -> Option<f32> {
pub fn player_resistance_buff_neg(&self, school: u8) -> Option<i32> {
(school < 7)
.then(|| self.get_f32(FIELD_PLAYER_RESISTANCEBUFFMODSNEGATIVE + u16::from(school)))?
.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`.
@@ -165,22 +165,30 @@ 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`).
#[test]
fn player_stat_buff_arrays_read_floats_with_signed_negatives() {
fn player_stat_buff_arrays_read_signed_ints() {
let f = ObjectFields::from_pairs(&[
(1177, 10.0f32.to_bits()), // POSSTAT0
(1182, (-4.0f32).to_bits()), // NEGSTAT0 (stored negative — ApplyStatBuffMod)
(1187, 30.0f32.to_bits()), // RESISTANCEBUFFMODSPOSITIVE[0]
(1196, (-20.0f32).to_bits()), // RESISTANCEBUFFMODSNEGATIVE[2]
(1177, 10), // POSSTAT0
(1182, (-4i32) as u32), // NEGSTAT0
(1187, 30), // RESISTANCEBUFFMODSPOSITIVE[0]
(1196, (-20i32) as u32), // RESISTANCEBUFFMODSNEGATIVE[2]
]);
assert_eq!(f.player_posstat(0), Some(10.0));
assert_eq!(f.player_negstat(0), Some(-4.0));
assert_eq!(f.player_posstat(0), Some(10));
assert_eq!(f.player_negstat(0), Some(-4));
assert_eq!(f.player_posstat(5), None, "out of range");
assert_eq!(f.player_negstat(5), None, "out of range");
assert_eq!(f.player_resistance_buff_pos(0), Some(30.0));
assert_eq!(f.player_resistance_buff_neg(2), Some(-20.0));
assert_eq!(f.player_resistance_buff_pos(0), Some(30));
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).
let live = ObjectFields::from_pairs(&[(1177, 105)]);
assert_eq!(live.player_posstat(0), Some(105));
}
#[test]