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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user