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
+7 -4
View File
@@ -808,10 +808,13 @@ pub(super) fn seed_ui_fixture(
(722, 24 | 60 << 16),
(724, 45),
(725, 55 | 60 << 16),
// Stat buffs (floats): +10 stamina, −5 spirit; +10 fire resistance.
(1179, 10.0f32.to_bits()), // POSSTAT2
(1186, (-5.0f32).to_bits()), // NEGSTAT4
(1189, 10.0f32.to_bits()), // RESISTANCEBUFFMODSPOSITIVE[2] (fire)
// Stat buffs (INT on the wire — decision 1397): +10 stamina, −5 spirit; +10 fire
// resistance. The −5 is the two's-complement word an x86-hosted server sends; an
// arm64 host saturates that same debuff to a flat 0, so the RED leg of the sheet is
// exercisable here and not on this deploy.
(1179, 10), // POSSTAT2
(1186, (-5i32) as u32), // NEGSTAT4
(1189, 10), // RESISTANCEBUFFMODSPOSITIVE[2] (fire)
// Equipment guids (INV_SLOT_HEAD base 486 + 2·slot): chest 4, main hand 15,
// ranged 17; the first backpack slot (PACK_SLOT_1 base 532) holds the arrows.
(494, G_CHEST as u32),
+151 -6
View File
@@ -65,7 +65,7 @@ use crate::net::{Embodied, Guid, NetEntity, SelfGuid};
use crate::ui_chat::{default_color, ChatEventKind};
use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuads, UvRect};
use crate::ui_text::{layout_text_quads, measure_text, FontSpec, Justify, UiFontAtlas};
use crate::vplates::{gx_px, plate_basis, text_px, VPlateSet, VPlates};
use crate::vplates::{device_snap, gx_px, plate_basis, text_px, VPlateSet, VPlates};
use benilla_assets::{AssetSet, WorldAssets};
use benilla_world::view::WorldCamera;
@@ -204,6 +204,22 @@ fn sanitize(text: &str) -> String {
out
}
/// The frame's bottom-left origin for a projected seat: BOTTOM-CENTER on the seat, then both
/// coordinates snapped onto the **device** pixel grid ([`device_snap`], the plate's law).
///
/// Split out so the snap law is nameable and pinned. It shipped as a plain logical `round()`,
/// which is 1 device pixel only at scale 1: on the 2× display we play on it stepped the bubble
/// two physical pixels per axis over a continuously-sliding world, and at a fractional scale
/// (1.25/1.5) it never landed on a texel boundary at all — so it paid the stepping without even
/// buying the crispness it exists for. Same bug, same fix as the plate (0188's snap → the plate's
/// device grid); the bubble was the site that never got it (1398).
fn seat_origin(seat: Vec2, w: f32, scale: f32) -> Vec2 {
Vec2::new(
device_snap(seat.x - w * 0.5, scale),
device_snap(seat.y, scale),
)
}
/// The border-unit in logical px for this viewport: 16/1024 of the screen width, carried
/// through the damped size basis (so it shrinks in step with plate/text sizes past the knee).
fn border_px(viewport: Vec2, basis: f32) -> f32 {
@@ -314,6 +330,8 @@ fn drive_bubbles(
Query<(), With<crate::entities::mount::MountChild>>,
),
time: Res<Time>,
// The device scale, for the seat's pixel snap ([`device_snap`]).
window: Query<&Window, With<bevy::window::PrimaryWindow>>,
) {
active.0.clear();
let now = time.elapsed_secs();
@@ -372,6 +390,7 @@ fn drive_bubbles(
// ── Tick + draw ─────────────────────────────────────────────────────────────────────────
let cam = camera.single().ok();
let art = art.as_deref();
let scale = window.single().map_or(1.0, Window::scale_factor);
let trace = std::env::var("WOW_BUBBLE_TRACE").as_deref() == Ok("1");
let mut dead = Vec::new();
for (guid, b) in bubbles.0.iter_mut() {
@@ -428,7 +447,20 @@ fn drive_bubbles(
let Some(viewport) = cam.logical_viewport_size() else {
continue;
};
draw_bubble(atlas, &mut quads, art, b, seat, viewport, trace);
draw_bubble(
atlas,
&mut quads,
art,
b,
seat,
viewport,
scale,
trace,
entity,
anchor,
tf.translation,
cam_pose,
);
}
for g in dead {
bubbles.0.remove(&g);
@@ -437,6 +469,7 @@ fn drive_bubbles(
/// Append one bubble's draw list: the Backdrop pieces (bg fill inset by the border-unit +
/// the 8-piece edge), the tail square, and the wrapped, centered, chatType-colored text.
#[allow(clippy::too_many_arguments)] // the draw inputs + the jitter trace's decomposition
fn draw_bubble(
atlas: &mut UiFontAtlas,
quads: &mut UiQuads,
@@ -444,7 +477,13 @@ fn draw_bubble(
b: &Bubble,
seat: Vec2,
viewport: Vec2,
scale: f32,
trace: bool,
// The `bub` jitter-decomposition trace's inputs — see the tail of this function.
entity: Entity,
anchor: Vec3,
unit_pos: Vec3,
cam_pose: &Transform,
) {
let basis = plate_basis(viewport);
let border = border_px(viewport, basis);
@@ -475,12 +514,17 @@ fn draw_bubble(
let (text_w, text_h) = (box_w.ceil(), box_h.ceil());
// The auto-fit body: the frame hugs the text layout ± the flat margin; BOTTOM-center on
// the seat, growing upward. Snapped to whole logical px (the plate divergence — a
// fractional corner bilinear-smears the border art).
// the seat, growing upward. Snapped onto the **device** pixel grid (the plate divergence —
// a fractional corner bilinear-smears the border art), which is [`device_snap`], the plate's
// own law, not the plain logical `round()` this shipped with: the quad lane is logical px, so
// rounding there quantized the bubble to `scale_factor` PHYSICAL pixels — two pixels of
// stepping per axis on the 2× display we play on, against a world sliding continuously
// underneath, and no texel alignment at all at a fractional scale. It is the same bug the
// plate was carrying and the same fix; the bubble is simply the site that never got it (1398).
let w = text_w + 2.0 * margin;
let h = text_h + 2.0 * margin;
let left = (seat.x - w * 0.5).round();
let bottom = seat.y.round();
let origin = seat_origin(seat, w, scale);
let (left, bottom) = (origin.x, origin.y);
let frame = Rect::new(left, bottom - h, left + w, bottom);
let alpha = b.alpha;
@@ -558,6 +602,50 @@ fn draw_bubble(
frame.min.x, frame.min.y, frame.max.x, frame.max.y, b.text
);
}
// **The jitter decomposition** (`WOW_MOVE_TRACE` tag `bub`, one line per bubble per frame) —
// the plate's `vpl` line ([`crate::vplates`]) for the bubble: the world anchor the pose
// returned, the camera pose that projected it, the raw projected seat, and the snapped frame
// origin. "The bubble is jittery when running" is then attributed to one of three from
// numbers rather than from the eye — a bobbing anchor (the animated head attachment re-read
// every frame), a noisy camera, or the pixel snap — which is what settles whether the snap fix
// was the whole of it. `pos=` is the unit's OWN `Transform` this frame, beside the `anchor=`
// the pose returned: the two are read on **different clocks** (the anchor computes through a
// `GlobalTransform` that Bevy propagates in `PostUpdate`, so it is last frame's; `pos` is this
// frame's), and their horizontal difference is therefore a direct reading of that lag in yards
// — the term 1341 cleared for the plate by measuring a unit that was STANDING STILL, where it
// is identically zero. It rides the shared tag-filtered trace and NOT the `WOW_BUBBLE_TRACE`
// eprintln beside it, whose unbuffered writes would distort the frame pacing the question is
// about (the 0880 lesson, the same reason `vpl` lives there).
if benilla_assets::trace::enabled_for("bub") {
let (cp, cf) = (cam_pose.translation, cam_pose.forward());
benilla_assets::trace::line(
"bub",
&format!(
"e={} vp=({:.0},{:.0}) anchor=[{:.4},{:.4},{:.4}] cam=[{:.4},{:.4},{:.4}] \
fwd=[{:.4},{:.4},{:.4}] pos=[{:.4},{:.4},{:.4}] scr=({:.3},{:.3}) \
frame=({:.2},{:.2}) scale={scale:.2}",
entity.index(),
viewport.x,
viewport.y,
anchor.x,
anchor.y,
anchor.z,
cp.x,
cp.y,
cp.z,
cf.x,
cf.y,
cf.z,
unit_pos.x,
unit_pos.y,
unit_pos.z,
seat.x,
seat.y,
frame.min.x,
frame.max.y,
),
);
}
quads.overlays.append(&mut text_quads);
}
@@ -678,6 +766,63 @@ mod tests {
);
}
/// The seat snaps on the DEVICE grid, not the logical one — the plate's law
/// ([`device_snap`]), which the bubble shipped without. At the 2× display we play on, a
/// logical `round()` moved the bubble two physical pixels per axis against a world that
/// slides continuously; this moves it one, the smallest step that still lands the border
/// blit on a texel boundary. Pinned so `scale` can't be "simplified" back out into `round()`.
#[test]
fn the_bubble_seat_snaps_on_the_device_grid() {
// 2×: the grid is every half logical pixel, and every snapped edge is a whole physical px.
for (seat_y, want) in [(10.0, 10.0), (10.2, 10.0), (10.3, 10.5), (10.6, 10.5)] {
let o = seat_origin(Vec2::new(100.0, seat_y), 40.0, 2.0);
assert_eq!(o.y, want, "seat y {seat_y} at 2×");
assert_eq!(
(o.y * 2.0).fract(),
0.0,
"{} is a whole physical pixel",
o.y
);
}
// The x half is the same law applied to the CENTERED left edge (seat − w/2), so an odd
// width still lands the left edge — the one the border blit starts from — on the grid.
let o = seat_origin(Vec2::new(100.4, 0.0), 41.0, 2.0);
assert_eq!(o.x, 80.0);
assert_eq!(((100.4_f32 - 20.5) * 2.0).round() / 2.0, o.x);
// 1×: identical to the logical round() it replaces — the fix costs nothing at scale 1.
assert_eq!(seat_origin(Vec2::new(0.0, 10.4), 0.0, 1.0).y, 10.0);
assert_eq!(seat_origin(Vec2::new(0.0, 10.6), 0.0, 1.0).y, 11.0);
// 1.5× (the Windows norm), where a logical round() was never texel-aligned at all.
for v in [10.4, 10.9, 11.2] {
let o = seat_origin(Vec2::new(0.0, v), 0.0, 1.5);
assert_eq!(
(o.y * 1.5).fract(),
0.0,
"{v} at 1.5× is a whole physical px"
);
}
}
/// The snap must not be a *quantizer with a large step*: over a continuous glide, the extra
/// displacement it adds to any one frame is bounded by half a device pixel. This is the
/// property the jitter report is about — at 2× the old logical round() allowed a whole
/// logical pixel (two physical) of extra step, which is what read as judder.
#[test]
fn the_snap_adds_at_most_half_a_device_pixel_of_step() {
let scale = 2.0;
let mut worst: f32 = 0.0;
// A continuous glide across ~40 px at a fractional per-frame speed, like a run.
for i in 0..400 {
let seat = 137.317 + 0.1013 * i as f32;
let snapped = seat_origin(Vec2::new(0.0, seat), 0.0, scale).y;
worst = worst.max((snapped - seat).abs());
}
assert!(
worst <= 0.5 / scale + f32::EPSILON,
"snap displacement {worst} exceeds half a device pixel"
);
}
/// The border-unit lands the byte constants: 16 px at 1024-wide 4:3 (G44·16/(S·1024)
/// nets width/64), damped past the plate knee like every other size.
#[test]
+25 -1
View File
@@ -147,6 +147,24 @@ pub(crate) struct OverheadFallback(pub(crate) f32);
/// A pure position read: it computes through `RigPose::posed_point` — the composed pose × the
/// rig root's frame — and never touches an anchor entity, so the overhead bone spawns nothing
/// (decision 1355).
///
/// **The rig root's frame is taken from `tf`, not from its propagated `GlobalTransform`, whenever
/// the rig root IS the unit** — which is the normal case (a mounted rider's root is the seat
/// anchor, a conform-tilted model's is its conform node; those two still read the propagated
/// frame and keep the lag below). Bevy propagates `GlobalTransform` in `PostUpdate`, so an
/// `Update` reader like the V-plate or the chat bubble gets **last frame's** world frame while the
/// unit's own `Transform` beside it — and the camera it projects through, and the model being
/// drawn — are this frame's. Running, that seam is one frame of travel: measured on a Westfall run
/// (1398), "head above feet" — a body constant — wobbled up to **11.7 px** per frame and 15.1 px
/// across the leg, and collapsed to 0.02 px the moment the anchor was paired with the position it
/// was actually computed from. That is the chat bubble sliding against the head the director
/// reported as jitter, and 1341 cleared the same term for the plate by measuring a unit that was
/// STANDING STILL, where it is identically zero.
///
/// Reading `tf` is exact here because a unit is a world-root entity — nothing in the world lane
/// parents a `NetEntity` (the `ChildOf` sites are the portrait booth, the pipe-warm menagerie and
/// the UI glue), so its global IS its local. A `PostUpdate` caller ([`crate::nameplates`], which
/// moved there for this very lag) is unaffected: after propagation the two are the same value.
pub(crate) fn overhead_anchor<F: bevy::ecs::query::QueryFilter>(
entity: Entity,
tf: &Transform,
@@ -168,7 +186,13 @@ pub(crate) fn overhead_anchor<F: bevy::ecs::query::QueryFilter>(
};
let &(bone, offset) = a.points.get(&slot)?;
let pose = poses.get(entity).ok()?;
let root = globals.get(pose.joints_root).ok()?;
let own;
let root = if pose.joints_root == entity {
own = GlobalTransform::from(*tf);
&own
} else {
globals.get(pose.joints_root).ok()?
};
pose.posed_point(root, bone, offset)
})
.unwrap_or_else(|| {
+133 -6
View File
@@ -23,7 +23,8 @@ use benilla_assets::{AssetSet, LockRecover, WorldAssets};
use benilla_world::schedule::WorldStage;
use super::kit::{
play_kit, play_kit_ext, source_kit_playing, stop_source_kit, KitRef, SoundCategory, SoundKits,
play_kit, play_kit_ext, source_kit_playing, stop_source_kit, unit_voice_playing,
voice_category, KitRef, SoundCategory, SoundKits,
};
use super::{AudioListener, SoundConfig, SoundOutput};
@@ -103,10 +104,26 @@ fn death_vocals(
/// The aggro/alert flare vocals (`SMSG_AI_REACTION` → the net bridge; decision 0280): HOSTILE →
/// the aggro bark (CreatureSoundData col 10), ALERT → the alert bark (col 13) — pure audio, like
/// the client (`0x6056e0` plays no animation/UI on either leg). INTERIM, named: the client's
/// per-unit priority voice channel (a HOSTILE bark never interrupts a playing bark) and ALERT's
/// RNG sometimes-skip gate (`0x623520`) aren't modeled — vocals here play unconditionally, like
/// the exertion/injury family.
/// the client (`0x6056e0` plays no animation/UI on either leg).
///
/// **HOSTILE rides the unit's one-shot voice channel, and that is not a nicety** (decision 1399,
/// wow-re `object-layer/scratch/smsg-ai-reaction.md`): the bark goes out through `0x623a40(0)`,
/// which stores its channel in `[unit+0xb20]` with category 0 latched in `[unit+0xb24]` —
/// **the lowest category in the table, so it never interrupts a playing bark and a repeat is
/// dropped while one is live** ([`unit_voice_playing`]). The wire does not send this packet
/// sparingly: vmangos fires `SMSG_AI_REACTION` HOSTILE from `Unit::Attack` on every target
/// acquisition *and* from `Unit::SendPetAIReaction` on every pet autocast and self-picked
/// target (`PetAI::DoAttack`, `PetAI::UpdateAI`'s autocast leg), so a hunter's pet in a normal
/// fight draws them in bursts. Played ungated, a level-60 bear pet fired its 3.166 s
/// `mBearAggroA.wav` **63 times in two minutes** — three copies inside a single frame, then a
/// ~10 Hz run — which is up to thirty overlapping copies of one roar, and is what the director
/// heard as a phasing, stuttering, constantly-growling bear. The reference receives the same
/// packets and collapses them at this slot.
///
/// ALERT keeps its own shape: the note records it as reaching `vtable+0x88(8)` instead, with an
/// RNG sometimes-skip gate (`0x623520`) and **no** channel latch of its own. Neither the gate's
/// probability nor its exact mute test is byte-pinned yet, so ALERT is deliberately left playing
/// unconditionally rather than fitted to a guess — the open half of the same wow-re dispatch.
#[allow(clippy::too_many_arguments)]
fn ai_reaction_vocals(
mut reactions: MessageReader<crate::net::AiReactionMessage>,
@@ -149,7 +166,27 @@ fn ai_reaction_vocals(
if kit == 0 {
continue;
}
if let Err(e) = play_kit(
// The `[unit+0xb20]` gate. Only HOSTILE latches the slot; ALERT neither tests nor stores
// it here (see the doc comment) and stays on the plain play.
if !r.hostile {
if let Err(e) = play_kit(
&mut kits,
&assets,
&mut out,
&config,
listener,
KitRef::Id(kit),
Some(transform.translation),
SoundCategory::Sfx,
) {
warn!("alert vocal (kit {kit}): {e:#}");
}
continue;
}
if unit_voice_playing(&out, r.unit) {
continue; // category 0 is the lowest: a live bark wins, this one is dropped
}
if let Err(e) = play_kit_ext(
&mut kits,
&assets,
&mut out,
@@ -158,6 +195,10 @@ fn ai_reaction_vocals(
KitRef::Id(kit),
Some(transform.translation),
SoundCategory::Sfx,
None,
Some(r.unit),
false,
Some(voice_category::HOSTILE),
) {
warn!("aggro vocal (kit {kit}): {e:#}");
}
@@ -247,6 +288,7 @@ fn creature_body_loops(
None,
Some(entity),
true,
None,
) {
warn!("body loop (kit {desired}): {e:#}");
}
@@ -390,3 +432,88 @@ pub(super) fn plugin(app: &mut App) {
.in_set(WorldStage::Present),
);
}
#[cfg(test)]
mod tests {
use super::super::kit::occupies_voice_slot;
use super::*;
/// **The measured report.** Every `SMSG_AI_REACTION` HOSTILE arrival for the director's bear
/// pet in one traced session (2026-08-17, `RUST_LOG=benilla_app::sound=debug`), in seconds
/// from the first: 63 of them inside 19 s — three inside a single frame, then a ~1.53 s
/// metronome, then two ~10 Hz runs. vmangos sends them that fast by design (`Unit::Attack`
/// fires one on every target acquisition, `Unit::SendPetAIReaction` one on every pet autocast
/// and every self-picked target — `PetAI::DoAttack` and `PetAI::UpdateAI`'s autocast leg), so
/// this is the traffic the client is *supposed* to survive.
const BEAR_BURST_S: [f32; 63] = [
0.000, 0.000, 0.000, 1.417, 2.950, 4.481, 5.997, 7.530, 9.046, 10.589, 10.670, 10.792,
12.312, 12.413, 12.546, 12.603, 12.713, 12.813, 12.912, 13.013, 13.112, 13.214, 13.315,
13.424, 13.528, 13.624, 13.740, 13.829, 13.928, 14.032, 14.137, 14.239, 14.331, 14.449,
14.545, 14.646, 14.745, 14.847, 16.393, 16.499, 16.684, 16.789, 16.909, 17.013, 17.096,
17.205, 17.303, 17.411, 17.540, 17.612, 17.711, 17.811, 17.920, 18.032, 18.111, 18.232,
18.340, 18.418, 18.553, 18.623, 18.728, 18.827, 18.928,
];
/// Kit 478 `BearAggro` has exactly one variation, `mBearAggroA.wav` — **3.166 s** at
/// 22 050 Hz, measured off the install. Long enough that the burst above stacks up to some
/// thirty copies of one roar over each other, which is what the director heard as phasing.
const BEAR_AGGRO_S: f32 = 3.166;
/// Replay a burst through the `[unit+0xb20]` slot and return the arrivals that actually
/// sounded. The slot holds at most one live channel; the pump reaps it when the sound stops,
/// and that reap IS the handle release.
fn barks_that_sound(arrivals: &[f32], clip: f32) -> Vec<f32> {
let bear = Entity::from_raw_u32(1).expect("valid entity id");
let mut slot: Option<(Option<Entity>, Option<u8>, f32)> = None;
let mut sounded = Vec::new();
for &t in arrivals {
if slot.is_some_and(|(_, _, ends)| ends <= t) {
slot = None;
}
if slot.is_some_and(|(src, v, _)| occupies_voice_slot(src, v, bear)) {
continue; // category 0 is the lowest — a live bark wins, this one is dropped
}
slot = Some((Some(bear), Some(voice_category::HOSTILE), t + clip));
sounded.push(t);
}
sounded
}
/// **The bug and the fix, on the measured data** (decision 1399). Ungated — what benilla
/// did — every arrival sounds: 63 overlapping 3.166 s roars. Through the voice slot the same
/// burst is five barks, no two of them overlapping.
#[test]
fn the_voice_slot_collapses_the_measured_bear_burst() {
assert_eq!(
BEAR_BURST_S.len(),
63,
"ungated, every arrival sounds — the reported symptom",
);
let sounded = barks_that_sound(&BEAR_BURST_S, BEAR_AGGRO_S);
assert_eq!(
sounded,
vec![0.0, 4.481, 9.046, 12.312, 16.393],
"the slot admits a bark only once the last one has finished",
);
for pair in sounded.windows(2) {
assert!(
pair[1] - pair[0] >= BEAR_AGGRO_S,
"two barks {pair:?} overlap — the phasing the report describes",
);
}
}
/// The slot is **per unit**, not global: a second bear beside the first keeps its own voice.
/// A world-wide throttle would have collapsed the burst too, and would have been wrong.
#[test]
fn the_voice_slot_is_per_unit() {
let (a, b) = (
Entity::from_raw_u32(1).expect("valid entity id"),
Entity::from_raw_u32(2).expect("valid entity id"),
);
let barking = (Some(a), Some(voice_category::HOSTILE));
assert!(occupies_voice_slot(barking.0, barking.1, a));
assert!(!occupies_voice_slot(barking.0, barking.1, b));
}
}
+1
View File
@@ -209,6 +209,7 @@ fn play_line(
variant,
Some(npc),
false,
None,
) {
warn!("npc vocal (kit {kit_id}): {e:#}");
}
+101 -3
View File
@@ -103,6 +103,23 @@ pub(crate) struct ActiveChannel {
/// `set_volume`). Written via [`set_source_kit_gain`].
gain: f32,
category: SoundCategory,
/// The **voice category latched on this channel**, when this play is a unit's one-shot
/// creature bark — the reference's `[unit+0xb24]` beside the `[unit+0xb20]` handle
/// ([`unit_voice_playing`]). `None` for every other play, which is what keeps a unit's body
/// loop, greeting line and footsteps out of the voice slot.
voice: Option<u8>,
}
/// The **voice categories** of the reference's per-unit one-shot bark dispatch `0x623a40` (its
/// 5-way jump table at `0x623afc`; wow-re `object-layer/scratch/smsg-ai-reaction.md`). Only the
/// one the note pins byte-exactly is named here — the rest of the table, and the priority
/// ordering that decides which category may interrupt which, are an open wow-re question and are
/// deliberately NOT guessed at: today only [`voice_category::HOSTILE`] is routed through the slot.
pub(super) mod voice_category {
/// `0x623a40(0)` — the HOSTILE aggro bark, `CreatureSoundData` col 10. Byte-verified as the
/// **lowest** priority in the table: it never interrupts a playing bark, and it is dropped
/// outright while the unit's voice slot is live.
pub(in crate::sound) const HOSTILE: u8 = 0;
}
/// A kit to play, by id or by `PlaySoundByName` name.
@@ -127,7 +144,7 @@ pub(crate) fn play_kit(
category: SoundCategory,
) -> Result<()> {
play_kit_ext(
kits, assets, out, config, listener, kit_ref, pos, category, None, None, false,
kits, assets, out, config, listener, kit_ref, pos, category, None, None, false, None,
)
}
@@ -139,7 +156,9 @@ pub(crate) fn play_kit(
/// and **`force_loop`** — loop regardless of the kit's own 0x200 flag, for the drivers whose
/// *column* is the loop authority (the creature body-loop: every CreatureSoundData col-23 kit is
/// authored `*Loop*` yet half omit 0x200 — INTERIM, the client's loop-start `0x461d80` flag
/// handling is unpinned; decision record with the drone build).
/// handling is unpinned; decision record with the drone build), and a **voice category** — the
/// category latched on the unit's one-shot bark slot ([`ActiveChannel::voice`]); `Some` only for
/// the barks the reference stores in `[unit+0xb20]`.
#[allow(clippy::too_many_arguments)]
pub(super) fn play_kit_ext(
kits: &mut SoundKits,
@@ -153,6 +172,7 @@ pub(super) fn play_kit_ext(
variant: Option<usize>,
source: Option<Entity>,
force_loop: bool,
voice: Option<u8>,
) -> Result<()> {
// The cover's audio hold ([`SoundConfig::world_hold`]): while the loading screen is up, no
// new sound starts — checked before anything allocates, so the per-frame retry drivers (the
@@ -280,15 +300,66 @@ pub(super) fn play_kit_ext(
v,
gain: 1.0,
category,
voice,
});
Ok(())
}
/// Does `unit` hold a **live one-shot voice channel** — the reference's `[unit+0xb20]` handle,
/// nonzero-gated (wow-re `object-layer/scratch/smsg-ai-reaction.md`)? The slot's liveness IS the
/// gate: the HOSTILE aggro bark is the lowest category in `0x623a40`'s table, so it never
/// interrupts what is sounding and is simply dropped while this is true.
///
/// Scoped to channels that actually latched a category, which is the whole point — the reference
/// keeps this handle separate from the combat drone (`0x623800` carries its own latch) and from
/// the greeting line (`[unit+0xb1c]`), so a humming elemental or a talking quest-giver must not
/// mute its own barks.
pub(super) fn unit_voice_playing(out: &SoundOutput, unit: Entity) -> bool {
out.channels
.iter()
.any(|c| occupies_voice_slot(c.source, c.voice, unit))
}
/// Does one channel, described by its `(source, voice)` identity, occupy `unit`'s **voice** slot
/// (`[unit+0xb20]`) — as opposed to its greeting latch ([`occupies_greeting_latch`],
/// `[unit+0xb1c]`)? The two are disjoint by construction, which is the whole point of separating
/// them: a bark and a greeting line are different handles in the reference and must not mute each
/// other.
pub(super) fn occupies_voice_slot(source: Option<Entity>, voice: Option<u8>, unit: Entity) -> bool {
source == Some(unit) && voice.is_some()
}
/// The complement — the greeting latch's own test (see [`source_playing`] for what else currently
/// lands in it).
pub(super) fn occupies_greeting_latch(
source: Option<Entity>,
voice: Option<u8>,
unit: Entity,
) -> bool {
source == Some(unit) && voice.is_none()
}
/// Is a channel tagged with `source` still live? The NPC-greeting per-unit latch (`[unit+0xb1c]`
/// nonzero-gate, `0x60c28c`/`0x60c40a`): a unit with a greeting line still sounding refuses a new
/// one. Release is automatic — the pump reaps the channel when the sound stops.
///
/// **Voice-latched channels are excluded**, because in the reference they are a *different
/// handle*: the greeting owns `[unit+0xb1c]`, the one-shot bark owns `[unit+0xb20]`
/// ([`unit_voice_playing`]). Conflating them would let a unit's aggro roar mute its own greeting
/// line, which the client never does.
///
/// Everything else tagged with `source` is still counted, and that is a **known pre-existing
/// conflation, not a claim of fidelity**: the body loop (`0x623800`'s own latch), the water
/// splash and the spell hold all ride the same one `source` tag, so any of them currently masks
/// a greeting the reference would let through. It bites real data — 6 of the 4 509 displays that
/// carry an `NPCSounds` greeting also resolve a `CreatureSoundData` row with a nonzero
/// `loop_sound` (1303, 10006, 10045, 10699, 11912, 12769; counted at the DBCs this session). The
/// honest fix is a per-latch marker rather than one shared tag, which is wider than this change
/// and is raised as such in decision 1399 — deliberately not slipped in here.
pub(super) fn source_playing(out: &SoundOutput, source: Entity) -> bool {
out.channels.iter().any(|c| c.source == Some(source))
out.channels
.iter()
.any(|c| occupies_greeting_latch(c.source, c.voice, source))
}
/// Is a channel tagged with `source` playing kit `kit_id`? The kit-scoped latch — the creature
@@ -379,6 +450,7 @@ pub(crate) fn play_file(
v: 1.0,
gain: 1.0,
category,
voice: None,
});
Ok(())
}
@@ -675,4 +747,30 @@ mod tests {
assert!(seen.iter().all(|&s| s), "cycle {cycle} covered all 5");
}
}
/// **The two per-unit handles are disjoint** (decision 1399): the one-shot bark occupies
/// `[unit+0xb20]`, the greeting line `[unit+0xb1c]`, and neither may answer the other's
/// question. This is the regression guard for the way the voice slot was added — tagging the
/// bark's channel with its unit made it visible to the greeting latch, which would have let a
/// creature's aggro roar silence its own hello.
#[test]
fn the_voice_slot_and_the_greeting_latch_are_disjoint() {
let bear = Entity::from_raw_u32(1).expect("valid entity id");
let other = Entity::from_raw_u32(2).expect("valid entity id");
let bark = (Some(bear), Some(voice_category::HOSTILE));
let greet = (Some(bear), None);
assert!(occupies_voice_slot(bark.0, bark.1, bear));
assert!(!occupies_greeting_latch(bark.0, bark.1, bear));
assert!(occupies_greeting_latch(greet.0, greet.1, bear));
assert!(!occupies_voice_slot(greet.0, greet.1, bear));
// Neither slot is world-global: a bark on one unit says nothing about another.
assert!(!occupies_voice_slot(bark.0, bark.1, other));
assert!(!occupies_greeting_latch(greet.0, greet.1, other));
// An untagged channel (every ordinary one-shot) is in neither slot.
assert!(!occupies_voice_slot(None, None, bear));
assert!(!occupies_greeting_latch(None, None, bear));
}
}
@@ -308,6 +308,7 @@ fn start_loop(
None,
Some(emitter),
true,
None,
) {
warn!("liquid loop kit {kit_id}: {e:#}");
return;
+1
View File
@@ -52,6 +52,7 @@ fn route_missile_sounds(
None,
Some(entity), // tag the loop to the missile — the pump follows it in flight
true, // the missile loop is looping by construction, not by the flag
None,
) {
warn!("missile flight sound {kit_sound}: {e:#}");
}
+1
View File
@@ -74,6 +74,7 @@ fn route_spell_kit_sounds(
None,
Some(entity),
false,
None,
)
} else {
play_kit(
+1
View File
@@ -92,6 +92,7 @@ fn water_splashes(
None,
Some(entity),
false,
None,
) {
warn!("water splash: {e:#}");
}
+7 -5
View File
@@ -430,23 +430,25 @@ fn base_swing(streamed: Option<u32>) -> u32 {
pub(crate) fn unit_combat_stats(store: &ObjectStore) -> UnitCombatStats {
let f = &store.0;
let round = |v: Option<f32>| v.unwrap_or(0.0).round() as i32;
let mut stats = [0i32; 5];
let mut stat_pos = [0i32; 5];
let mut stat_neg = [0i32; 5];
for i in 0..5u8 {
stats[usize::from(i)] = f.unit_stat(i).unwrap_or(0) as i32;
stat_pos[usize::from(i)] = round(f.player_posstat(i));
stat_neg[usize::from(i)] = round(f.player_negstat(i));
// The four buff-split arrays are INT on the wire (decision 1397) — they used to be read as
// f32 and rounded, which turned every real value into `0` and is why B165/B251's stats
// never went green.
stat_pos[usize::from(i)] = f.player_posstat(i).unwrap_or(0);
stat_neg[usize::from(i)] = f.player_negstat(i).unwrap_or(0);
}
let mut resistances = [0i32; 7];
let mut resistance_pos = [0i32; 7];
let mut resistance_neg = [0i32; 7];
for i in 0..7u8 {
resistances[usize::from(i)] = f.unit_resistance(i).unwrap_or(0);
resistance_pos[usize::from(i)] = round(f.player_resistance_buff_pos(i));
resistance_neg[usize::from(i)] = round(f.player_resistance_buff_neg(i));
resistance_pos[usize::from(i)] = f.player_resistance_buff_pos(i).unwrap_or(0);
resistance_neg[usize::from(i)] = f.player_resistance_buff_neg(i).unwrap_or(0);
}
let (ap_pos, ap_neg) = f.unit_attack_power_mods();
+6 -1
View File
@@ -251,7 +251,12 @@ fn con_color(pl_level: u32, unit_level: u32) -> [f32; 4] {
/// stepping at 2×: measured over a Goldshire walk at 1440×810 (the `vpl` trace), the extra
/// displacement the snap adds to a plate's glide fell from a median 0.33 / max 0.99 logical px per
/// frame to 0.21 / 0.49. What is left is the ±½ device pixel every crisp UI pays.
fn device_snap(v: f32, scale: f32) -> f32 {
///
/// **Shared with the chat bubble** ([`crate::chat_bubble`]), like [`plate_basis`]/[`gx_px`]: it is
/// the same outside-UIParent overlay family sliding over the same world, so it is the same snap
/// law. The bubble shipped on a plain logical `round()` — this function's own bug, at the one site
/// that never got the fix — which is 1398's finding.
pub(crate) fn device_snap(v: f32, scale: f32) -> f32 {
(v * scale).round() / scale
}
+215
View File
@@ -0,0 +1,215 @@
//! Drives the REAL `assets/ui/CharacterFrame.xml` stat rows through the engine — the first test
//! that executes the paper doll's own `PaperDollFrame_Set*` Lua at all.
//!
//! That gap is why this file exists. The character sheet's whole buff-decomposition surface — the
//! green number, the red number, the `(base +x -y)` tooltip — sat broken from the day it shipped
//! (B165, B251) because the four `POSSTAT`/`NEGSTAT`/`RESISTANCEBUFFMODS*` descriptor arrays were
//! decoded as f32 when the wire carries INT, so every real buff arrived as a rounded `0` and every
//! row took its "nothing is modifying this" leg. Both gates were green throughout: the bindings'
//! own unit tests fed the snapshot directly, and nothing ever ran the Lua that colours the row.
//!
//! So the assertions here are deliberately at the *rendered string*: what `CharacterStatFrameNStatText`
//! and `MagicResTextN` actually carry after a repaint, colour escapes included. Three shapes per
//! family — untouched (plain), buffed (green), debuffed (red) — plus the tooltip's base arithmetic,
//! which is the guard for `UnitStat`'s first return being the RAW field rather than a pre-subtracted
//! one (decision 1397: the ref Lua subtracts `posBuff`/`negBuff` itself, so a pre-subtracted first
//! return deducts the buff twice — a defect the f32 bug was hiding).
use benilla_ui::script::{UiScript, UnitCombatStats, UnitState};
const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
/// The character window's load prefix, in `assets/ui/benilla.toc` order. The .toc's own entry says
/// it "depends only on Fonts/UiPanels/GameTooltip"; `ItemButtonTemplate.xml` joins them because the
/// doll's equipment slots are `SetItemButton*` buttons.
const FILES: [&str; 5] = [
"Fonts.xml",
"ItemButtonTemplate.xml",
"UiPanels.xml",
"GameTooltip.xml",
"CharacterFrame.xml",
];
fn load_ui(script: &UiScript) {
let dir = std::path::Path::new(UI_DIR);
// The app's provider shape: backslash paths, dir-relative, basename fallback.
let provider = |req: &str| -> Option<Vec<u8>> {
let norm = req.replace('\\', "/");
let base = norm.rsplit('/').next().unwrap_or(&norm);
std::fs::read(dir.join(&norm))
.or_else(|_| std::fs::read(dir.join(base)))
.ok()
};
for file in FILES {
let text = std::fs::read_to_string(dir.join(file))
.unwrap_or_else(|e| panic!("reading {file}: {e}"));
let doc =
benilla_ui::framexml::parse(&text).unwrap_or_else(|e| panic!("parsing {file}: {e}"));
let report = benilla_ui::loader::load(script, &doc, &provider);
assert!(
report.errors.is_empty(),
"{file} loaded with errors: {:#?}",
report.errors
);
}
}
/// The two colour escapes the sheet writes, as `Fonts.xml` defines them — asserted against the
/// rendered string rather than re-derived, so a palette change fails here loudly instead of
/// silently agreeing with itself.
const GREEN: &str = "|cff20ff20";
const RED: &str = "|cffff2020";
/// A level-60 body carrying one of each case: strength heavily geared (+105), agility cursed
/// (−12), stamina untouched, and the same three shapes across the resistance schools. The negative
/// halves are the wire words an **x86**-hosted server sends; an arm64 host saturates a debuff to a
/// flat `0` on the way out (decision 1397), which is exactly why the red leg has to be exercised
/// here and cannot be exercised against the local deploy.
fn stats() -> UnitCombatStats {
UnitCombatStats {
stats: [225, 68, 178, 34, 51],
stat_pos: [105, 0, 0, 4, 0],
stat_neg: [0, -12, 0, 0, 0],
// schools: [0] armor, then holy/fire/nature/frost/shadow/arcane.
resistances: [2965, 0, 65, 20, 0, 0, 0],
resistance_pos: [0, 0, 65, 0, 0, 0, 0],
resistance_neg: [0, 0, 0, -10, 0, 0, 0],
..Default::default()
}
}
fn player() -> UnitState {
UnitState {
exists: true,
name: Some("Probefour".into()),
level: 60,
race: Some("Human".into()),
class: Some("Warrior".into()),
..Default::default()
}
}
/// Repaint the sheet the way an in-world `UNIT_STATS` does and read a row's rendered text back.
fn painted(script: &mut UiScript) {
script
.run("PaperDollFrame_SetStats() PaperDollFrame_SetResistances() PaperDollFrame_SetArmor()")
.expect("the paper doll's stat rows repaint");
}
fn text_of(script: &mut UiScript, region: &str) -> String {
script
.eval::<String>(&format!(r#"return getglobal("{region}"):GetText()"#))
.unwrap_or_else(|e| panic!("reading {region}: {e}"))
}
fn tooltip_of(script: &mut UiScript, frame: &str) -> String {
script
.eval::<String>(&format!(r#"return getglobal("{frame}").tooltip"#))
.unwrap_or_else(|e| panic!("reading {frame}.tooltip: {e}"))
}
fn seated() -> UiScript {
let mut script = UiScript::new().expect("a UI VM");
load_ui(&script);
script.set_unit("player", Some(player()));
script.set_player_combat_stats(Some(stats()));
script
}
#[test]
fn a_geared_stat_renders_green_and_its_tooltip_names_the_unbuffed_base() {
let mut s = seated();
painted(&mut s);
// Strength: 225 total off a 120 base, +105 from gear. Green number, and the tooltip's
// parenthesised base is the ONE subtraction the ref Lua performs (225 − 105 − 0 = 120).
let text = text_of(&mut s, "CharacterStatFrame1StatText");
assert!(
text.starts_with(GREEN) && text.contains("225"),
"a gear-boosted strength must render green, got {text:?}"
);
let tip = tooltip_of(&mut s, "CharacterStatFrame1");
assert!(
tip.contains("Strength 225") && tip.contains("(120") && tip.contains("+105"),
"the tooltip must read the total, the unbuffed base and the delta, got {tip:?}"
);
assert!(
!tip.contains("(15"),
"base 15 = 225 − 105 − 105: the buff was deducted twice, so UnitStat's first return is \
pre-subtracted. It must be the raw UNIT_FIELD_STAT — {tip:?}"
);
}
#[test]
fn a_debuffed_stat_renders_red_and_an_untouched_one_stays_plain() {
let mut s = seated();
painted(&mut s);
let agility = text_of(&mut s, "CharacterStatFrame2StatText");
assert!(
agility.starts_with(RED) && agility.contains("68"),
"a cursed agility must render red, got {agility:?}"
);
// Stamina has neither half — the ref's `posBuff == 0 and negBuff == 0` leg: a bare number with
// no escape at all. This is the assertion the shipped bug would have PASSED, and it is here so
// a future over-correction that colours everything is caught.
let stamina = text_of(&mut s, "CharacterStatFrame3StatText");
assert_eq!(
stamina, "178",
"an unmodified stat carries no colour escape at all"
);
// Red wins over green when both halves are in play — the ref colours on `negBuff < 0` first.
s.set_player_combat_stats(Some(UnitCombatStats {
stat_pos: [105, 30, 0, 4, 0],
..stats()
}));
painted(&mut s);
let mixed = text_of(&mut s, "CharacterStatFrame2StatText");
assert!(
mixed.starts_with(RED),
"a stat with both a buff and a debuff renders RED, got {mixed:?}"
);
}
#[test]
fn resistance_rows_colour_by_which_half_is_bigger() {
let mut s = seated();
painted(&mut s);
// The five magic rows are the sheet's own school order (BENILLA_MAGICRES_IDS); resolve each
// row's school from the frame rather than assuming, so a row-order change cannot silently
// re-point these assertions.
let row_of = |s: &mut UiScript, school: i64| -> usize {
s.eval::<i64>(&format!(
"for i = 1, BENILLA_NUM_RESISTANCE_TYPES do \
if BENILLA_MAGICRES_IDS[i] == {school} then return i end \
end return 0"
))
.expect("the school→row map") as usize
};
let fire = row_of(&mut s, 2);
let nature = row_of(&mut s, 3);
assert!(fire > 0 && nature > 0, "fire and nature both have rows");
let fire_text = text_of(&mut s, &format!("MagicResText{fire}"));
assert!(
fire_text.starts_with(GREEN) && fire_text.contains("65"),
"a +65 fire resistance renders green, got {fire_text:?}"
);
let nature_text = text_of(&mut s, &format!("MagicResText{nature}"));
assert!(
nature_text.starts_with(RED),
"a nature school whose debuff outweighs its buff renders red, got {nature_text:?}"
);
}
/// Armor is the one row of this family the local server never decomposes — item armor lands in
/// `UNIT_FIELD_RESISTANCES[0]` itself, not in the buff split — so its plain-number leg is the live
/// shape and worth pinning as such.
#[test]
fn armor_with_no_buff_split_renders_plain() {
let mut s = seated();
painted(&mut s);
assert_eq!(text_of(&mut s, "CharacterArmorFrameStatText"), "2965");
}
@@ -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]
+58 -25
View File
@@ -489,9 +489,25 @@ impl Model {
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// UnitStat("player", i /*1..=5*/) → (stat, effectiveStat, posBuff, negBuff): effective is the
// UNIT_FIELD_STAT value, pos/neg the POSSTAT/NEGSTAT deltas (neg ≤ 0), and stat (base) their
// inverse — the ref computes base = effective − pos − neg and branches on negBuff < 0.
// UnitStat("player", i /*1..=5*/) → (stat, effectiveStat, posBuff, negBuff).
//
// **The first two returns are the same field, UNDECOMPOSED** — `UNIT_FIELD_STAT0+i` raw
// (`fild`, `0x518689`), then that same value clamped at zero (`0x5186a3`–`0x5186b7`:
// `setl cl; dec ecx; and ecx,eax`). Slots 3/4 are `PLAYER_FIELD_POSSTAT0+i` (`0x518712`) and
// `NEGSTAT0+i` (`0x518772`), each behind a SELF gate. VERIFIED, wow-re
// `ui/scratch/pet-paperdoll-stat-api.md` §4.
//
// This served `effective − pos − neg` as the first return until decision 1397. Subtracting is
// the ref Lua's *own* job — `PaperDollFrame_SetStats` writes the tooltip's base as
// `(stat - posBuff - negBuff)` — so a pre-subtracted `stat` deducts the buff twice. It was
// invisible only because pos/neg were stuck at 0 by the field-decode bug 1397 fixes; the two
// are one repair.
//
// **Note the contrast with the two resistance bindings below**, whose first return really *is*
// the decomposed base (`0x5efcd0`: `*arg2 = raw - pos - neg`). The reference reads the two
// families differently, and the ref Lua's own asymmetry — `SetStats` subtracts, `SetResistances`
// and `PaperDollFormatStat` do not — is the tell.
//
// Out-of-range i (the ref never passes one) serves the absent zeros.
g.set(
"UnitStat",
@@ -503,10 +519,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
if idx >= 5 {
return (0, 0, 0, 0);
}
let (eff, pos, neg) = (s.stats[idx], s.stat_pos[idx], s.stat_neg[idx]);
let (raw, pos, neg) = (s.stats[idx], s.stat_pos[idx], s.stat_neg[idx]);
(
i64::from(eff - pos - neg),
i64::from(eff),
i64::from(raw),
i64::from(raw.max(0)),
i64::from(pos),
i64::from(neg),
)
@@ -515,7 +531,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// UnitResistance("player", school /*0..=6*/) → (base, resistance, positive, negative) — the
// same inverse math per school ([0] = armor).
// decomposition helper `0x5efcd0` per school ([0] = armor): `base = raw − pos − neg`, computed
// BEFORE the clamp, and `resistance = max(raw, 0)`. A school cursed below zero therefore reads
// a *displayed* 0 with the real (negative) total still folded out of `base`.
g.set(
"UnitResistance",
lua.create_function(|lua, (token, school): (Option<String>, i64)| {
@@ -523,14 +541,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let Some(idx) = usize::try_from(school).ok().filter(|&v| v < 7) else {
return (0, 0, 0, 0);
};
let (eff, pos, neg) = (
let (raw, pos, neg) = (
s.resistances[idx],
s.resistance_pos[idx],
s.resistance_neg[idx],
);
(
i64::from(eff - pos - neg),
i64::from(eff),
i64::from(raw - pos - neg),
i64::from(raw.max(0)),
i64::from(pos),
i64::from(neg),
)
@@ -538,17 +556,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// UnitArmor("player") → (base, effectiveArmor, armor, posBuff, negBuff) — school 0 of the
// resistance block; the ref reads effectiveArmor and armor equivalently (both the total).
// UnitArmor("player") → (base, effectiveArmor, armor, posBuff, negBuff) — school 0 through the
// same `0x5efcd0`, whose 3rd and 4th out-params are both the clamped total (the ref reads
// effectiveArmor and armor equivalently, and both are zeroed together when raw < 0).
g.set(
"UnitArmor",
lua.create_function(|lua, token: Option<String>| {
Ok(with_unit_stats(lua, &token, |s| {
let (eff, pos, neg) = (s.resistances[0], s.resistance_pos[0], s.resistance_neg[0]);
let (raw, pos, neg) = (s.resistances[0], s.resistance_pos[0], s.resistance_neg[0]);
(
i64::from(eff - pos - neg),
i64::from(eff),
i64::from(eff),
i64::from(raw - pos - neg),
i64::from(raw.max(0)),
i64::from(raw.max(0)),
i64::from(pos),
i64::from(neg),
)
@@ -1002,21 +1021,34 @@ mod tests {
}
#[test]
fn unit_stat_serves_effective_and_the_derived_base() {
fn unit_stat_serves_the_raw_field_twice_and_the_buff_split() {
let mut s = UiScript::new().unwrap();
s.set_player_combat_stats(Some(stats()));
// Str: effective 25, pos 4, neg 0 → base 21.
// Str: the raw field 25 in BOTH of the first two slots, then the +4/0 split. The ref Lua
// does the subtraction itself (`stat - posBuff - negBuff` = 21 for the tooltip's base) —
// this binding must not pre-subtract, or the buff is deducted twice.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 1)"#)
.unwrap(),
(21, 25, 4, 0)
(25, 25, 4, 0)
);
// Agi: effective 20, pos 0, neg −2 → base 22 (the ref tests negBuff < 0).
// Agi: raw 20 with a −2 debuff (the ref tests negBuff < 0 to pick red over green).
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 2)"#)
.unwrap(),
(22, 20, 0, -2)
(20, 20, 0, -2)
);
// Only the SECOND return is clamped: a stat driven below zero keeps its sign in slot 1.
s.set_player_combat_stats(Some(UnitCombatStats {
stats: [-3, 20, 22, 10, 11],
..stats()
}));
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 1)"#)
.unwrap(),
(-3, 0, 4, 0)
);
s.set_player_combat_stats(Some(stats()));
// A non-player token serves the absent zeros (no other unit streams these fields).
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("target", 1)"#)
@@ -1052,11 +1084,12 @@ mod tests {
.unwrap(),
(0, 20, 25, -5)
);
// A cursed school can read negative (arcane: −5 total, all from the debuff).
// A school cursed below zero (arcane: −5 total, all from the debuff): the DISPLAYED total
// is clamped to 0 (`0x5efcd0`'s tail), while `base` keeps the pre-clamp decomposition.
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitResistance("player", 6)"#)
.unwrap(),
(0, -5, 0, -5)
(0, 0, 0, -5)
);
// UnitArmor is school 0 with the five-return shape (effectiveArmor = armor = the total).
assert_eq!(
@@ -1189,7 +1222,7 @@ mod tests {
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 1)"#)
.unwrap(),
(21, 25, 4, 0)
(25, 25, 4, 0)
);
// Resistances: fire (school 2) — 15 for the pet, 20 (+25/−5) for the player.
assert_eq!(
@@ -1257,7 +1290,7 @@ mod tests {
assert_eq!(
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("player", 1)"#)
.unwrap(),
(21, 25, 4, 0)
(25, 25, 4, 0)
);
}