the Rookery eggs hatch, and the bubble finds its height

- UBRS's Rookery eggs hatch on approach now: the trap's despawn-anim packet was silently dropped and the entity freed the instant the destroy landed — the packet is parsed, and the reference's refcount pin holds the egg alive through its 2.7 s burst before it goes
- the AQ drakes' crawl is the server's doing: vmangos flies cyclic paths at walk speed, and the client's ride measures exact against the wire — the motion is inspectable now, down to a per-spline trace
- the chat bubble sits at the height 1.12 gives it: a latched constant from the model's stand box, replacing the posed-attachment anchor the RE just refuted
- the trigger-creature render gate folds back from the binary, byte citations corrected

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-17 17:26:18 +07:00
co-authored by Claude Fable 5
parent a5637837b4
commit 911f974e09
18 changed files with 698 additions and 63 deletions
+43 -39
View File
@@ -27,7 +27,11 @@
//! PLAIN (`||`→`|`, color/hyperlink escapes stripped — [`sanitize`]).
//! - **Anchor** (`0x4b0c30`): `worldZ = unit.z + attachmentHeight·modelScale + 0.7`, projected
//! by the plate/name projector, seated **BOTTOM at the point, growing upward** (the mirror
//! of the plate, which hangs down).
//! of the plate, which hangs down). `attachmentHeight` is `0x4b0e38 call 0x711a20` — a read of
//! the **MD20 header image**, i.e. the **Stand sequence CAaBox's Z extent**, a file constant
//! with no bone matrix anywhere in its call tree — and the scaled product is **latched** at
//! `bubble+0x354` behind a parity guard, so it is queried exactly **once per chat line**
//! ([`crate::entities::StandBoxHeight`], wow-re's anchor cross-check 2026-08-17; 1406).
//!
//! Named divergences (all deliberate, decisions 0598/0599):
//! - **`ChatBubblesParty` defaults ON** (binary default "0") — the director asked for `/p`
@@ -37,10 +41,14 @@
//! bubbles too — but that category claim is INFERRED on an OPEN wire-type remap
//! (chat-bubble.md §1) and contradicts the remembered reference look, so the uncontested
//! set ships and a capture can widen it ([`bubble_cvar`]).
//! - **The anchor height is the posed overhead attachment** ([`overhead_anchor`], the
//! `0x608640` chain) rather than the client's `0x711a20` model-attachment query — the
//! attachment behind `0x711a20` is not identified in the note (INFERRED equivalence; both
//! are "the head-region attachment height, model-scaled").
//! - ~~**The anchor height is the posed overhead attachment**~~ — **REFUTED and removed (1406).**
//! This shipped as an INFERRED equivalence ("both are the head-region attachment height,
//! model-scaled") between the overhead-name chain `0x608640` and the bubble's `0x711a20`. They
//! are not equivalent, and they differ on exactly the axis that mattered: `0x608640` reads the
//! live posed palette and tracks the pose, `0x711a20` reads file bytes. The overhead *name*
//! keeps `0x608640`; the bubble now takes the Stand-box constant the bytes actually specify. It
//! sits **0.199 model units lower** on a human male (2.0128 vs the attachment's 2.2120) — a
//! deliberate, measured move toward the reference, not a regression.
//! - **Sizes ride the plates' damped diagonal basis** ([`plate_basis`], decisions 0185/0186)
//! so bubble text and plate text stay the same em at every window — the same director-pinned
//! deviation from the unbounded byte law.
@@ -60,7 +68,7 @@ use benilla_ui::layout::Rect as GxRect;
use benilla_ui::script::{inset_atlas_bleed, pieces, Backdrop, Insets};
use benilla_ui::script::{JustifyH, JustifyV, Outline};
use crate::entities::{overhead_anchor, BoneAttach, OverheadFallback};
use crate::entities::StandBoxHeight;
use crate::net::{Embodied, Guid, NetEntity, SelfGuid};
use crate::ui_chat::{default_color, ChatEventKind};
use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuads, UvRect};
@@ -282,6 +290,11 @@ struct Bubble {
born: f32,
/// The steady window past the fade-in ([`duration_secs`]).
duration: f32,
/// The **latched** anchor height above the speaker's feet: the Stand box's Z extent
/// ([`crate::entities::StandBoxHeight`]) already multiplied by the unit's model scale, queried
/// ONCE here and never re-read — the reference caches exactly this product at `bubble+0x354`
/// behind a parity guard, so it is one query per chat line (1406).
lift: f32,
/// Current fade alpha 0..1 — ramped 250 ms linear toward the eligibility verdict.
alpha: f32,
}
@@ -355,14 +368,8 @@ fn drive_bubbles(
mut atlas: Option<ResMut<UiFontAtlas>>,
mut quads: ResMut<UiQuads>,
art: Option<Res<BubbleArt>>,
// The overhead-anchor inputs ([`overhead_anchor`]).
anchor_q: (
Query<&BoneAttach>,
Query<&benilla_world::rig_anim::RigPose>,
Query<&OverheadFallback>,
Query<&GlobalTransform>,
Query<(), With<crate::entities::mount::MountChild>>,
),
// The latched anchor height, read once per bubble at spawn ([`StandBoxHeight`]).
heights: Query<&StandBoxHeight>,
time: Res<Time>,
// The device scale, for the seat's pixel snap ([`device_snap`]).
window: Query<&Window, With<bevy::window::PrimaryWindow>>,
@@ -416,6 +423,10 @@ fn drive_bubbles(
}
let is_self = self_guid.0 == Some(guid);
let c = default_color(kind);
// The one-time attachment query (`0x4b0e38 call 0x711a20`, scaled by `[unit+0x90]`): a file
// constant × this unit's model scale. A speaker with no bounds reads 0 and the bubble sits
// at the feet + 0.7, which is the reference's own degenerate for a bounds-less model.
let lift = heights.get(entity).map_or(0.0, |h| h.0) * tf.scale.y;
// Replace, never queue: the insert tears the old bubble down (`0x608c00`) and the
// fresh one fades in from 0.
bubbles.0.insert(
@@ -429,6 +440,7 @@ fn drive_bubbles(
],
born: now,
duration: duration_secs(words, is_self),
lift,
alpha: 0.0,
},
);
@@ -477,19 +489,12 @@ fn drive_bubbles(
else {
continue; // no camera/atlas/art — lifetimes still tick, nothing draws
};
// World anchor: unit position, Z lifted by the posed attachment height + 0.7 yd —
// seated BOTTOM at the projected point, growing upward. A point behind the camera
// draws nothing (state kept — it fades back the moment it projects again).
let anchor = overhead_anchor(
entity,
tf,
&anchor_q.0,
&anchor_q.1,
&anchor_q.2,
&anchor_q.3,
&anchor_q.4,
);
let seat_world = Vec3::new(tf.translation.x, anchor.y + LIFT, tf.translation.z);
// World anchor (`0x4b0c30`): the unit's position, Z lifted by the LATCHED Stand-box height
// + 0.7 yd — seated BOTTOM at the projected point, growing upward. Every term is this
// frame's `Transform` or a constant, so there is no pose to read and no clock to get wrong.
// A point behind the camera draws nothing (state kept — it fades back the moment it
// projects again).
let seat_world = tf.translation + Vec3::Y * (b.lift + LIFT);
let cam_tf = GlobalTransform::from(*cam_pose);
// The SEVENTH silent cause, and the one that cost the most to find: a seat behind the
// camera fails to project and the bubble draws nothing — alive, ticking, invisible, and
@@ -514,7 +519,7 @@ fn drive_bubbles(
scale,
trace,
entity,
anchor,
seat_world,
tf.translation,
cam_pose,
);
@@ -669,17 +674,16 @@ fn draw_bubble(
);
}
// **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`
// the plate's `vpl` line ([`crate::vplates`]) for the bubble: the seat's world point, the
// camera pose that projected it, the raw projected seat, and the snapped frame origin. "The
// bubble is jittery when running" is then attributed from numbers rather than from the eye —
// a moving anchor, a noisy camera, or the pixel snap. Since 1406 `anchor=` and `pos=` differ by
// a LATCHED constant on Y and by nothing at all on X/Z, so any wobble between them is a defect
// by construction. (Through 1398 they were read on different clocks — the anchor computed
// through a `GlobalTransform` Bevy propagates in `PostUpdate`, i.e. last frame's — and their
// difference measured exactly that lag: the term 1341 cleared for the plate by measuring a unit
// that was STANDING STILL, where it is identically zero. Dropping the pose read removes the
// seam rather than correcting it.) 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") {
+41 -1
View File
@@ -48,6 +48,19 @@ type GoCollisionReadout = (
Option<&'static crate::go_anim::GoAnim>,
);
/// The inspector's **motion** readout: the server-dictated path a creature is riding
/// ([`crate::net::Spline`]) and the dead-reckoned state of a remote mover
/// ([`crate::net::RemoteMotion`]) — the two producers of "this thing is moving" that the animation
/// selector itself unifies. On the card because the identity lines alone cannot answer the one
/// question a "that creature looks wrong" report always turns on: *is it actually moving, and how
/// fast?* The AQ drakes are the case that earned it — a Brood of Nozdormu 190 yd overhead beating
/// its wings at a flat 1× while its path crawls at walk pace reads as frozen, and nothing on the
/// card said the path was the slow half.
type MotionReadout = (
Option<&'static crate::net::Spline>,
Option<&'static crate::net::RemoteMotion>,
);
/// The inspector's entity LIGHT readout (decision 0776): the lane this object's parts render
/// under, and which attach found the room — "two identical GameObjects a few yards apart, one lit
/// like the room and one like the street" is the report that made this a card line rather than a
@@ -69,6 +82,7 @@ pub(super) struct InspectStores<'w, 's> {
kinds: Query<'w, 's, &'static crate::net::NetEntity>,
collision: Query<'w, 's, GoCollisionReadout>,
lit: Query<'w, 's, EntityLightReadout>,
motion: Query<'w, 's, MotionReadout>,
factions: Option<Res<'w, crate::target::ring::Factions>>,
self_store: Query<'w, 's, &'static ObjectStore, With<crate::net::SelfPlayer>>,
/// The ask-once GO template cache — the readable head a TEXT object's line reports
@@ -161,11 +175,12 @@ pub(super) fn inspect_ui(
.map(|c| c.parent());
let (factions, self_store) = (stores.factions.as_deref(), stores.self_store.single().ok());
let go_templates = &*stores.go_templates;
let (stores, kinds, collision, lit, go_anims) = (
let (stores, kinds, collision, lit, motion, go_anims) = (
&stores.stores,
&stores.kinds,
&stores.collision,
&stores.lit,
&stores.motion,
&stores.go_anims,
);
let store = net_entity.and_then(|p| stores.get(p).ok());
@@ -313,6 +328,28 @@ pub(super) fn inspect_ui(
None => format!("casting {}", c.spell_id),
}
});
// **Is it moving, and how fast** ([`MotionReadout`]) — the input the `anim` line below is a
// consequence of. A creature riding a server path reports the path's constant speed (its whole
// length over its whole duration, the same number the gait selector reads) and how far through
// it is, so a unit that looks static is either `still` (no path at all — the server has not
// launched one, or the last one expired) or a path so slow it cannot be seen. A remote mover
// reports its dead-reckoning speed instead. Units and players only: a GameObject has no mover.
let motion_line = net_entity
.filter(|_| is_unit)
.and_then(|p| motion.get(p).ok())
.map(|(spline, remote)| match (spline, remote) {
(Some(sp), _) => format!(
"motion {:.2} yd/s · {} path {:.0}% of {:.0}s",
sp.speed(),
if sp.grounded { "ground" } else { "flying" },
100.0 * sp.elapsed_frac(),
sp.duration.as_secs_f32(),
),
(None, Some(r)) if r.speed > 0.0 => {
format!("motion {:.2} yd/s · dead-reckoned", r.speed)
}
_ => "motion still".to_string(),
});
// An `AnimationData` id as the card names it — shared by the creature and GameObject anim
// lines below, which read the same id space.
let fmt = |id: u16| match anim_data.as_ref().and_then(|a| a.0.name(id)) {
@@ -398,6 +435,9 @@ pub(super) fn inspect_ui(
if let Some(line) = &casting_line {
lines.push(line.clone());
}
if let Some(line) = &motion_line {
lines.push(line.clone());
}
if let Some(line) = &anim_line {
lines.push(line.clone());
}
+18
View File
@@ -131,6 +131,24 @@ pub(crate) const ATTACH_OVERHEAD_MOUNTED: u16 = 29;
/// attachment anchors overhead content at `feet + scale × bbox_z × 1.25`.
const OVERHEAD_FALLBACK_FACTOR: f32 = 1.25;
/// The unit's **Stand-animation box height** (model-local, pre-scale) — the chat bubble's anchor,
/// and *only* the chat bubble's (1406).
///
/// The reference's two overhead heights are two different mechanisms, and benilla had recorded them
/// as one. The overhead NAME (and the floating combat text, and the V-plate) takes `0x608640`: the
/// live posed PlayerName attachment, which tracks the pose. The chat bubble takes `0x711a20`, which
/// wow-re's cross-check followed into the model layer and found reading the **MD20 header image** —
/// file bytes, no bone matrix in the call tree — returning the Stand sequence CAaBox's Z extent,
/// and the client caches it in the bubble at `+0x354` on a parity guard so it is queried **once per
/// chat line**. The recorded claim that the two calls were equivalent ("both are the head-region
/// attachment height, model-scaled", INFERRED) is refuted: they differ precisely on
/// animated-vs-static.
///
/// So this is a constant per display, stamped at attach and never re-read — which is also why the
/// bubble's height cannot acquire a pose-clock bug of the kind 1398 had to remove from the anchor.
#[derive(Component)]
pub(crate) struct StandBoxHeight(pub(crate) f32);
/// The unit's model bbox z-extent (model-local, pre-scale) — stamped by the attach path for
/// [`overhead_anchor`]'s fallback. `0.0` until the model loads (the fallback then anchors at
/// feet — the same degenerate the client hits with no model).
+11 -2
View File
@@ -757,6 +757,11 @@ pub(super) fn attach_entity_visuals(
commands.entity(entity).insert(super::OverheadFallback(
dm.map(|d| d.bbox_z_local).unwrap_or(0.0),
));
// The chat bubble's own anchor height — the Stand box's Z, a file constant, NOT the
// posed attachment the overhead name above rides (1406).
commands.entity(entity).insert(super::StandBoxHeight(
dm.map(|d| d.stand_box_z_local).unwrap_or(0.0),
));
// Selection-ring radius (model-local sphere radius, pre-scale) — the targeting ring reads it
// × the unit's scale (harmless on non-unit models, which are never ringed).
commands.entity(entity).insert(SelectionRadius(
@@ -891,8 +896,12 @@ pub(super) fn attach_entity_visuals(
// nothing (decision 1403, bug B13). The reference's mesh gate is type-independent: it
// draws any loaded model and the per-batch zero-alpha cull skips what is transparent
// (decision 0024, verified wow-re `go-render-gate`), so a model with no surviving batch
// submits no geometry and the unit is invisible. That is the whole of how a **trigger
// creature** hides — `Creature\InvisibleStalker\InvisibleStalker.m2` ships with
// submits no geometry and the unit is invisible. Byte-exact, folded back at 1407: the
// batch loop's TRIP COUNT is the selected ModelView's texUnit count (`0x707a72`), so a
// view with none skips the whole loop — and the unit lane reaches that same loop, on
// the same singleton `CM2Scene`, through the same `0x613e10` as every other typeId.
// That is the whole of how a **trigger creature** hides —
// `Creature\InvisibleStalker\InvisibleStalker.m2` ships with
// `nVertices == 0`, and its `NoName` sibling's one batch carries a constant-zero
// transparency track — no unit flag is consumed anywhere. Cubing them put a black slab
// over 154 vmangos templates on 8 displays (the Scarab Wall's Anachronos trigger,
@@ -227,6 +227,12 @@ pub(crate) struct DisplayModel {
/// fallback's input (`0x608640`: a unit whose model has no PlayerName attachment anchors overhead
/// text at `feet + scale × this × 1.25`). `0.0` for a bounds-less display.
pub(super) bbox_z_local: f32,
/// The **Stand animation** box's z-extent (model-local yards, pre-scale) — the CHAT BUBBLE's
/// anchor height (`0x711a20`, wow-re 2026-08-17; 1406). Distinct from `bbox_z_local` above,
/// which is the all-animation vertex box, and from the posed PlayerName attachment the overhead
/// NAME uses: this one is a file constant, queried once and cached, so the bubble's height
/// cannot move with the pose. `0.0` for a bounds-less display.
pub(super) stand_box_z_local: f32,
/// The M2 vertex-box CENTRE in Bevy model-local (pre-scale) — the interior fold's MOLR
/// reference point for a GameObject's footprint bake (the byte-cited `[def+0x5c]` anchor
/// family; `benilla_world::interior`). `Vec3::ZERO` for a bounds-less / WMO / model-less display.
@@ -341,6 +347,7 @@ pub(crate) fn empty_shell() -> DisplayModel {
portrait_camera: None,
pane_camera: None,
bbox_z_local: 0.0,
stand_box_z_local: 0.0,
bake_center_local: Vec3::ZERO,
terrain_tilt: 0,
is_character_body: false,
@@ -394,6 +401,7 @@ pub(super) fn build_parts(
let mut portrait_camera = None;
let mut pane_camera = None;
let mut bbox_z_local = 0.0;
let mut stand_box_z_local = 0.0;
let mut bake_center_local = Vec3::ZERO;
let parts = match &dm.handle {
ModelHandle::M2(h) => {
@@ -435,6 +443,9 @@ pub(super) fn build_parts(
bbox_z_local = model
.bounds
.map_or(0.0, |b| (b.bbox_max[2] - b.bbox_min[2]).max(0.0));
// The chat bubble's anchor height — the STAND sequence box's Z, off the same parse the
// selection ring takes its XY from, so the two can never resolve different animations.
stand_box_z_local = model.bounds.map_or(0.0, |b| b.stand_box_z);
// The vertex-box centre (Bevy local) — a GameObject's interior-fold reference point.
bake_center_local = model.bounds.map_or(Vec3::ZERO, |b| {
benilla_assets::coords::wow_to_bevy([
@@ -584,6 +595,7 @@ pub(super) fn build_parts(
dm.portrait_camera = portrait_camera;
dm.pane_camera = pane_camera;
dm.bbox_z_local = bbox_z_local;
dm.stand_box_z_local = stand_box_z_local;
dm.bake_center_local = bake_center_local;
dm.terrain_tilt = terrain_tilt;
}
+192 -12
View File
@@ -82,6 +82,11 @@ const GO_STATE_ACTIVE: u32 = 0;
/// only in this state; `GO_STATE_ACTIVE` (0, open) and `GO_STATE_ACTIVE_ALTERNATIVE` (2) are passable.
const GO_STATE_READY: u32 = 1;
/// `AnimationData.dbc` **157 Despawn** — the id the one-shot channel's code 6 resolves to
/// (wow-re `gameobject-anim-arm.md` §2c: `0x80b0e0[6]` = substate 12, `0x8607e4[12]` = 157). The
/// object plays this once and *then* goes away; see [`DespawnAnimAnnounced`].
const ANIM_DESPAWN: u16 = 157;
/// Marker + client-side state for an animated GameObject (decisions 0242/0250). Instanced by
/// [`crate::entities::attach`] on an animatable GO type whose model authors sequences; driven by
/// [`drive_go_anim`]. Distinct from creatures' `AnimDriver` so the two drivers never touch one entity.
@@ -100,13 +105,22 @@ pub(crate) struct GoAnim {
/// *genuine* wire change — a chest's wire state is constant (its lid is driven by loot events, not the
/// wire), so an unrelated values-update (a dyn-flag, a position) must not re-close an open lid.
last_wire: Option<u32>,
/// A pending one-shot **Custom** play (AnimationData id 153..156) from
/// `SMSG_GAMEOBJECT_CUSTOM_ANIM` — the second, disjoint arm channel of wow-re
/// `gameobject-anim-arm.md` §"one-shot channel" step 8 (substates 8..11, never the §243 lid
/// family). Armed by [`queue_custom_anim`], consumed by [`drive_go_anim`] AFTER the state arm,
/// so the bobber's same-frame pair (the forced `READY → ACTIVE` flip + the splash) resolves
/// with the splash on top (decision 1086).
custom: Option<u16>,
/// A pending **one-shot** play, as an `AnimationData.dbc` id — the second, disjoint arm
/// channel of wow-re `gameobject-anim-arm.md` §2c, never the §243 lid family. The reference
/// has ONE such slot fed by ONE entry point (`0x5f8c50(GO, code)` → slot 15), whose 7-entry
/// code table `0x80b0e0` is the whole channel:
///
/// | code | substate | id | producer |
/// |------|----------|----|----------|
/// | 1 | 0 | 145 Spawn | object CREATE on wire update-type 3 (**not built**) |
/// | 2..5 | 8..11 | 153..156 Custom0-3 | opcode `0xb3` (`anim_id >= 4` rejected) |
/// | 6 | 12 | 157 Despawn | opcode `0x215` — [`DespawnAnimAnnounced`] |
///
/// Written by [`queue_custom_anim`] / [`arm_despawn_anim`], consumed by [`drive_go_anim`]
/// AFTER the state arm, so the bobber's same-frame pair (the forced `READY → ACTIVE` flip +
/// the splash) resolves with the splash on top (decision 1086). Slot 15 pre-gates on the model
/// OWNING the resolved id, so this channel takes no §2c remap (decisions 1086/1404).
one_shot: Option<u16>,
/// The clip armed for the current **TRANSIENT substate**, if any — the completion-retire's
/// watch (decisions 1100/1151, wow-re `gameobject-anim-arm.md` §2d + `go-display-sound-events.md`
/// §6-8). The reference keeps exactly one current substate in `[handler+0x10]`, and its
@@ -429,7 +443,7 @@ fn queue_custom_anim(
continue;
};
if let Ok(mut anim) = gos.get_mut(e) {
anim.custom = Some(id);
anim.one_shot = Some(id);
}
}
}
@@ -441,6 +455,82 @@ fn custom_anim_id(anim_id: u32) -> Option<u16> {
(anim_id < 4).then(|| 153 + anim_id as u16)
}
/// The server announced this object's **despawn animation** (`SMSG_GAMEOBJECT_DESPAWN_ANIM`,
/// opcode `0x215`) — the one-shot channel's code 6, [`ANIM_DESPAWN`]. Inserted by the net apply
/// layer at packet time so it is on the entity *before* the `SMSG_DESTROY_OBJECT` that vmangos
/// sends in the same server tick is processed: that ordering is the whole mechanism, and it is
/// why this is a component rather than a message (a message is not read until later in the frame,
/// by which time the destroy has already freed the entity).
///
/// It is **not** GameObject-only — `WorldObject::SendObjectDeSpawnAnim` also fires for a totem's
/// death and a DynamicObject's expiry, and two vmangos boss scripts send it for objects that keep
/// living. So this marker asserts nothing on its own: it arms [`GoAnim::one_shot`] if there is a
/// [`GoAnim`] to arm, and it only defers a destroy that actually arrives ([`PendingDestroy`]).
#[derive(Component)]
pub(crate) struct DespawnAnimAnnounced;
/// The reference's **pending-destroy mark** — `[GO+0xe4]` bit `0x10`, set by the object-manager
/// destroy `0x464920` when it finds the object still pinned (wow-re
/// `go-display-sound-events.md` §6d, §5-VERIFIED). The arm takes the pin whenever the armed
/// substate is not a resting one (`0x5f3b27 call 0x4683e0` → refcount `[obj+0xe8]`), slot 14's
/// footer releases it at the window end (`0x468410`), and only then does the deferred destroy run
/// (`0x46844a call 0x464920`) — which is how an object gets to finish its own despawn animation
/// after the server has already told the client it is gone.
///
/// benilla takes the pin on the ONE substate that produces an observable — 12 / [`ANIM_DESPAWN`],
/// where the whole play happens after the destroy — and [`release_despawn_pin`] is the release.
/// A GO destroyed mid-transition (a door frozen half-open by a despawn) is the reference's other
/// pinned case and still pops instantly here; named, not built (decision 1404).
///
/// One deliberate divergence: the reference's `0x464920` returns *before* removing the object from
/// the manager, so a pinned object stays addressable by guid for the length of its play. benilla
/// drops the guid from the index at destroy time and keeps only the entity, because a respawn that
/// reused the guid would otherwise refresh an object already condemned to despawn. Nothing
/// observable rides on it — the object still draws, and the server has stopped answering for that
/// guid anyway.
#[derive(Component)]
pub(crate) struct PendingDestroy;
/// Caller 5 (the despawn-anim opcode): arm the one-shot [`ANIM_DESPAWN`] play on an object the
/// server has announced as despawning — one arm per announcement, exactly as the reference's
/// single `0x5f8c50(GO, 6)` call is one arm per packet.
///
/// `Changed`, not `Added`, and the difference is not pedantry: re-inserting a component that is
/// already present marks it changed but **not** added, so an object announced a second time
/// without having died in between (`SendObjectDeSpawnAnim` is a `WorldObject` method — two vmangos
/// boss scripts call it on objects that go on living) would silently never arm again.
///
/// A marked entity with no [`GoAnim`] (a totem, a DynamicObject, a GO type off the machine) simply
/// isn't matched, and [`release_despawn_pin`] then pops it on its first pass — the ordinary instant
/// destroy. Model ownership is judged at play time by [`drive_go_anim`], as on the Custom channel.
fn arm_despawn_anim(mut gos: Query<&mut GoAnim, Changed<DespawnAnimAnnounced>>) {
for mut go in &mut gos {
go.one_shot = Some(ANIM_DESPAWN);
}
}
/// Release the pin: an object whose destroy was deferred ([`PendingDestroy`]) goes away as soon as
/// it is no longer *playing* its despawn animation — the reference's `0x468410` decrement reaching
/// zero with the pending-destroy bit set, which runs the deferred `0x464920`.
///
/// Runs AFTER [`drive_go_anim`], and that ordering is load-bearing in both directions. On the
/// arming frame the drive has just set [`GoAnim::transient`], so the object is held; on the frame
/// [`retire_transient_anim`] clears it (the window ended) nothing re-arms 157, so the object pops.
/// An entity that never armed anything — no [`GoAnim`], a model that doesn't author 157 — fails the
/// test on its very first pass and pops the same frame, which is today's behaviour for everything
/// that isn't an egg.
fn release_despawn_pin(
mut commands: Commands,
pinned: Query<(Entity, Option<&GoAnim>), With<PendingDestroy>>,
) {
for (e, go) in &pinned {
if go.is_some_and(|g| g.transient == Some(ANIM_DESPAWN)) {
continue;
}
commands.entity(e).try_despawn();
}
}
/// Caller 3 (the loot-release): close a chest lid when its loot window closes. The client sends
/// `CMSG_LOOT_RELEASE` and immediately drops the state to READY(1) — no server round-trip. We watch the
/// open loot source guid change (any close path: the player's close, or the server's release when the last
@@ -604,8 +694,8 @@ fn drive_go_anim(
// pass, one `$GC0` splash, then the state pose — never a churning loop.
// (`is_some` pre-check: an unconditional `take()` mut-derefs the `Mut` and re-marks the
// component Changed every frame, keeping this Changed-filtered query hot forever.)
if go.custom.is_some() {
let id = go.custom.take().expect("checked is_some");
if go.one_shot.is_some() {
let id = go.one_shot.take().expect("checked is_some");
if anims.owns(id) {
if let Some(clip) = anims.find(id) {
let active = tr.play(
@@ -717,12 +807,15 @@ pub(crate) fn plugin(app: &mut App) {
open_go_lid,
close_go_lid,
queue_custom_anim,
arm_despawn_anim,
// The completion retire reads last frame's finished flags and must clear
// `shown` BEFORE the drive, so its state re-arm lands this frame — the
// reference's own one-frame-after-window-end timing.
retire_transient_anim,
),
(drive_go_anim, drive_go_collision),
// The pin release reads what the drive just armed — see its doc for why AFTER.
release_despawn_pin,
fire_go_anim_events,
)
.chain()
@@ -831,7 +924,15 @@ mod tests {
app.init_resource::<Time>();
app.init_resource::<NextStep>();
app.add_systems(bevy::app::First, step_clock);
app.add_systems(Update, (retire_transient_anim, drive_go_anim).chain());
app.add_systems(
Update,
(
(arm_despawn_anim, retire_transient_anim),
drive_go_anim,
release_despawn_pin,
)
.chain(),
);
let authored: Vec<(u16, f32)> = CRATE_FAMILY.iter().chain(extra_ids).copied().collect();
let handles: Vec<_> = authored
@@ -1004,7 +1105,7 @@ mod tests {
.entity_mut(go)
.get_mut::<GoAnim>()
.unwrap()
.custom = Some(153);
.one_shot = Some(153);
app.update();
assert_eq!(armed(&app, go), Some((153, RepeatAnimation::Never)));
@@ -1017,6 +1118,85 @@ mod tests {
);
}
/// **B140** (decision 1404): UBRS's Rookery Eggs. Walk into a `TRAP`'s radius, the server
/// spends its last charge and sends `SMSG_GAMEOBJECT_DESPAWN_ANIM` immediately followed by
/// `SMSG_DESTROY_OBJECT` in the same tick — and the egg must **hatch before it pops**: 157
/// Despawn, one window (2.667 s on `G_DragonEggFreeze`, whose bone 8 swells 4.76×), with the
/// object held alive for exactly that long by the pin.
///
/// The failing shape this pins is the whole report: the destroy freed the entity on arrival,
/// so 271 eggs blinked out with no animation at all.
#[test]
fn an_announced_despawn_plays_its_window_before_the_object_pops() {
let (mut app, go) = crate_app(&[(157, 2.667)]);
app.update();
assert_eq!(armed(&app, go), Some((0x93, RepeatAnimation::Forever)));
// The wire pair, in the order vmangos sends it and Commands apply it.
app.world_mut()
.entity_mut(go)
.insert((DespawnAnimAnnounced, PendingDestroy));
app.update();
assert_eq!(
armed(&app, go),
Some((157, RepeatAnimation::Never)),
"the announcement arms 157 Despawn for ONE window"
);
assert!(
app.world().get_entity(go).is_ok(),
"the pin holds the object alive across its own destroy"
);
advance(&mut app, 2700);
app.update();
assert!(
app.world().get_entity(go).is_err(),
"the window ended — the pin drops and the deferred destroy runs"
);
}
/// The other half of the same rule: a model that doesn't author 157 (or an object with no
/// animation machine at all — a totem, a DynamicObject, both of which `SendObjectDeSpawnAnim`
/// also fires for) must keep today's **instant pop**. Nothing arms, so the pin never forms and
/// the release runs on its first pass.
#[test]
fn an_unownable_despawn_anim_still_pops_instantly() {
let (mut app, go) = crate_app(&[]); // the crate family only — no 157
app.update();
app.world_mut()
.entity_mut(go)
.insert((DespawnAnimAnnounced, PendingDestroy));
app.update();
assert!(
app.world().get_entity(go).is_err(),
"nothing to play ⇒ the ordinary instant destroy, same frame"
);
}
/// The announcement **alone** kills nothing. `SendObjectDeSpawnAnim` lives on `WorldObject`,
/// and two vmangos boss scripts send it for objects that go on living (Sapphiron sends it for
/// *himself*); only a destroy that actually arrives marks [`PendingDestroy`]. So an announced
/// object with no destroy plays its window and returns to its state pose, still there.
#[test]
fn an_announcement_without_a_destroy_never_despawns_anything() {
let (mut app, go) = crate_app(&[(157, 2.667)]);
app.update();
app.world_mut().entity_mut(go).insert(DespawnAnimAnnounced);
app.update();
assert_eq!(armed(&app, go), Some((157, RepeatAnimation::Never)));
advance(&mut app, 2700);
app.update();
assert!(app.world().get_entity(go).is_ok(), "no destroy, no despawn");
assert_eq!(
armed(&app, go),
Some((0x93, RepeatAnimation::Forever)),
"and the window hands back to the state pose, like any other one-shot"
);
}
/// A `ModelAnimations` that owns exactly `ids` — only the lookup table matters to the remap.
fn owning(ids: &[u16]) -> ModelAnimations {
let hi = ids.iter().copied().max().unwrap_or(0) as usize;
+3
View File
@@ -688,6 +688,9 @@ pub(super) fn apply_net_updates(
SessionEvent::GameObjectCustomAnim { guid, anim_id } => {
objects::gameobject_custom_anim(guid, anim_id, &mut audio.15 .3)
}
SessionEvent::GameObjectDespawnAnim { guid } => {
objects::gameobject_despawn_anim(guid, &mut commands, &index)
}
SessionEvent::FishNotHooked => loot::fish_verdict(false, &mut ui_error_keys),
SessionEvent::FishEscaped => loot::fish_verdict(true, &mut ui_error_keys),
SessionEvent::PlaySound { sound_id } => world::play_sound(sound_id, &mut audio.0),
+37 -1
View File
@@ -39,6 +39,24 @@ pub(super) fn gameobject_custom_anim(
});
}
/// An object plays its one-shot **Despawn** animation (`SMSG_GAMEOBJECT_DESPAWN_ANIM`, decision
/// 1404) — the other half of wow-re `gameobject-anim-arm.md` §2c's channel, AnimationData id 157.
///
/// This marks the entity **immediately**, with a component rather than a message, because vmangos
/// sends `SMSG_DESTROY_OBJECT` for the same object in the same server tick: the mark has to be on
/// the entity before [`object_destroyed`] runs, or the destroy frees it before anything can play.
/// Commands apply in the order they were queued, so an insert queued here is visible to the
/// closure the destroy queues below. The arm itself, and the model-ownership gate, are
/// [`crate::go_anim`]'s.
pub(super) fn gameobject_despawn_anim(guid: u64, commands: &mut Commands, index: &GuidIndex) {
debug!("net: gameobject {guid:#x} despawn anim");
if let Some(&e) = index.0.get(&guid) {
commands
.entity(e)
.insert(crate::go_anim::DespawnAnimAnnounced);
}
}
/// An object entered range / was created (`SMSG_UPDATE_OBJECT` create block): spawn or refresh the
/// entity, warm the ask-once caches, and seed its descriptor store via the per-drain `pending` map.
#[allow(clippy::too_many_arguments)]
@@ -381,8 +399,26 @@ pub(super) fn object_destroyed(
// appear-fade on create). A respawn then streams in as a fresh entity. If it was the
// target, the ring's gone-entity branch clears the selection next frame — the
// reference's teardown does the same (and sends the same `CMSG_SET_SELECTION 0`).
//
// …*unless the object is pinned* — `0x464920` on a still-pinned object only sets the
// pending-destroy bit and returns, and the real free waits for the last pin to drop (wow-re
// `go-display-sound-events.md` §6d). The one pin benilla takes is the despawn animation
// announced a moment earlier by `SMSG_GAMEOBJECT_DESPAWN_ANIM`, which is the whole of how an
// object gets to play its own despawn after the server says it is gone (decision 1404).
// The guid leaves the index either way: to the server it no longer exists.
if let Some(e) = index.0.remove(&guid) {
commands.entity(e).try_despawn();
commands.queue(move |world: &mut bevy::ecs::world::World| {
if world
.get::<crate::go_anim::DespawnAnimAnnounced>(e)
.is_some()
{
if let Ok(mut ent) = world.get_entity_mut(e) {
ent.insert(crate::go_anim::PendingDestroy);
}
} else if let Ok(ent) = world.get_entity_mut(e) {
ent.despawn();
}
});
}
// An item destroy (consumed, sold) never had a scene entity — clear the item store.
items.remove_object(guid);
+82 -2
View File
@@ -63,6 +63,17 @@ impl Spline {
length / self.duration.as_secs_f32().max(1e-3)
}
/// How far through the ride this path is, `0..=1` — elapsed over duration, clamped. The
/// inspector's motion line reads it; [`sample`](Self::sample) computes the same fraction to
/// locate the unit, and a path at `1` is one frame from being dropped.
pub(crate) fn elapsed_frac(&self) -> f32 {
(Instant::now()
.saturating_duration_since(self.start)
.as_secs_f32()
/ self.duration.as_secs_f32().max(1e-3))
.clamp(0.0, 1.0)
}
/// Interpolated `(raw-WoW position, facing, travel pitch)` at `now`, constant speed along the
/// path (clamped at the ends). `facing` is the WoW orientation of the travel direction in
/// progress (`atan2` of its horizontal component), `None` for a degenerate/vertical direction so
@@ -355,13 +366,37 @@ fn create_spline_enabled() -> bool {
/// Interpolate every path-walking entity along its [`Spline`] into its [`Transform`] each frame — so
/// motion stays smooth between the sparse server `MSG_MOVE` packets. Writes translation + rotation
/// only (scale, set by the renderer when it attaches the model, is preserved).
#[allow(clippy::type_complexity)] // one Bevy system's full input set
pub(in crate::net) fn sample_splines(
mut commands: Commands,
mut q: Query<(Entity, &Spline, &mut Transform, Has<CreatureSwimming>)>,
mut q: Query<(
Entity,
&Spline,
&mut Transform,
Has<CreatureSwimming>,
// The rider's server identity, for the `spl` trace alone — `Option` because the sampler
// must never gate on it (a spline entity with no guid still has to be flown).
Option<&super::super::Guid>,
)>,
mut trace_next: Local<f32>,
time: Res<Time>,
) {
let now = Instant::now();
for (entity, spline, mut t, swimming) in &mut q {
// The `spl` sampler's own clock (see [`trace_ride`]): one tick per second for the WHOLE
// population, not per entity — a shared deadline keeps every rider's line on the same instant,
// which is what makes two units' progress directly comparable in the file.
let tracing = benilla_assets::trace::enabled_for("spl");
let tick = tracing && time.elapsed_secs() >= *trace_next;
if tick {
*trace_next = time.elapsed_secs() + SPL_TRACE_SECS;
}
for (entity, spline, mut t, swimming, guid) in &mut q {
let (wow_pos, facing, pitch) = spline.sample(now);
// What the transform held *coming in* — i.e. whatever the previous frame left there after
// every other writer had its turn. Traced beside the fresh sample as `was=`: a rider whose
// `was` is not last frame's sample is being stomped by another system, which no amount of
// staring at the sampler would ever show.
let was = tick.then(|| bevy_to_wow(t.translation));
t.translation = wow_to_bevy(wow_pos);
if let Some(f) = facing {
// The swim body pitch (TU-A's render law, applied to the spline movers): a swimming
@@ -386,16 +421,61 @@ pub(in crate::net) fn sample_splines(
Quat::from_rotation_y(f)
};
}
if let Some(was) = was {
trace_ride(guid.map_or(0, |g| g.0), spline, wow_pos, was, now);
}
// Path finished: the final pose is written above (the sample clamps to the last point), so drop
// the spline. It's what makes a `Spline` mean "actively moving" — otherwise a completed path
// lingers until the next packet and a creature reads as walking forever after one move
// (`creature_anim` keys Walk/Run on the spline's presence).
if now.saturating_duration_since(spline.start) >= spline.duration {
if tracing {
benilla_assets::trace::line(
"spl",
&format!("{:#x} DONE — spline dropped", guid.map_or(0, |g| g.0)),
);
}
commands.entity(entity).remove::<Spline>();
}
}
}
/// How often the `spl` tag samples a live ride. One second is the resolution a "does this thing
/// actually move?" question needs, and it keeps a zone full of walkers off the shared trace mutex
/// (see [`benilla_assets::trace`] on why a busy tag distorts the run it measures).
const SPL_TRACE_SECS: f32 = 1.0;
/// One `spl` line per live [`Spline`] per [`SPL_TRACE_SECS`] — the **ride** half of the movement
/// instrument, beside `csp`'s supply and `mmv`'s realized snaps (decision 0708). Those two log at
/// the wire; this one logs what the client is actually *drawing*, which is the only thing that
/// answers "the server says it is flying and it looks frozen to me".
///
/// `t=` is the ride's own progress (elapsed/duration, seconds), `pos` the sampled raw-WoW point,
/// `spd` the path's constant speed (its full length over its full duration), and `was=` the
/// distance from what the transform held *coming into this frame* to the fresh sample. The three
/// separate the three faults that all look identical on screen: a ride whose `pos` does not change
/// while `t` advances is a **sampler** fault; a ride whose `spd` is wrong is a **wire** fault; and
/// a `was=` far larger than one frame of travel is a **stomp** — some other system writing this
/// transform after us.
fn trace_ride(guid: u64, spline: &Spline, pos: [f32; 3], was: [f32; 3], now: Instant) {
let elapsed = now.saturating_duration_since(spline.start).as_secs_f32();
let (dx, dy, dz) = (pos[0] - was[0], pos[1] - was[1], pos[2] - was[2]);
let drift = (dx * dx + dy * dy + dz * dz).sqrt();
benilla_assets::trace::line(
"spl",
&format!(
"{guid:#x} t={elapsed:.1}/{:.1} pos=[{:.2},{:.2},{:.2}] was={drift:.3} spd={:.2} nodes={} {}",
spline.duration.as_secs_f32(),
pos[0],
pos[1],
pos[2],
spline.speed(),
spline.points.len(),
if spline.grounded { "ground" } else { "flying" },
),
);
}
/// Distance (yd) above a creature's current feet that the terrain probe starts. Generous enough to
/// clear the small float (a slightly-high server Z) and the "little hill" a straight-line spline Z
/// leaves a unit inside, low enough not to grab an overhang/bridge a unit walks *under*. It also
+28 -6
View File
@@ -39,6 +39,21 @@ pub struct M2Bounds {
/// `follow-camera`: `feet + (attach17.z + 0.0972)·scale`). `None` for a model with no slot-17
/// attachment; the camera then falls back to a fraction of the box. See [`M2Bounds::pivot_z`].
pub pivot_z: Option<f32>,
/// The **Stand** animation box's vertical extent, `max.z − min.z` (model-local, pre-scale) — the
/// chat bubble's anchor height above the unit's feet (× model scale, + 0.7 yd).
///
/// The same `0x711a20` query the selection ring above is sized from, taking the box's Z instead
/// of its XY: wow-re's chat-bubble anchor cross-check (2026-08-17) followed `0x4b0e38 call
/// 0x711a20` into the model layer and found it reading the **MD20 header image** — file bytes,
/// no bone matrix anywhere in the call tree — and returning `out+0x20 − out+0x14`, which is that
/// CAaBox's Z extent. Benilla anchored the bubble on the posed PlayerName attachment instead,
/// on a recorded INFERRED claim that `0x608640` and `0x711a20` were "both the head-region
/// attachment height"; that equivalence is **REFUTED** — they differ precisely on
/// animated-vs-static, `0x608640` reading the live posed palette (it bobs) and this reading a
/// file constant. The overhead *name* keeps `0x608640`; only the bubble takes this. See 1406.
///
/// Falls back to the header render-box Z extent for a model with no sequences, like the ring.
pub stand_box_z: f32,
}
/// Read an M2's authored bounds + the vertex-derived radius (see [`M2Bounds`]). Uses the same path
@@ -56,10 +71,14 @@ pub fn load_m2_bounds(chain: &mut Chain, raw_path: &str) -> Result<M2Bounds> {
/// The animation `M2Sequence` array is at MD20 `0x1c`(count)/`0x20`(offset), stride `0x44`, with the
/// sequence's `CAaBox` at record `+0x24` (min C3 `+0x24`, max C3 `+0x30`). Stand is animation **id 0**,
/// whose *sequence index* is `animationLookup[0]` (array at `0x24`/`0x28`, `u16` each) — NOT necessarily
/// record 0 (a chicken's record 0 is a flap, its Stand sits at index 2). Returns `(dx, dy)`, or `None`
/// when the model has no sequences / lookup or the indices are out of range (caller falls back to the
/// header box). VERIFIED: reproduces the reference ring radii to ~1 mm.
fn stand_box_xy(bytes: &[u8]) -> Option<(f32, f32)> {
/// record 0 (a chicken's record 0 is a flap, its Stand sits at index 2). Returns `(dx, dy, dz)`, or
/// `None` when the model has no sequences / lookup or the indices are out of range (caller falls back
/// to the header box). VERIFIED: reproduces the reference ring radii to ~1 mm.
///
/// The Z extent joins the pair the ring needs because the chat bubble's anchor is the **same box**
/// read on the other axis (1406) — one parse, one Stand-sequence resolution, two consumers, so the
/// ring and the bubble can never drift onto different animations.
fn stand_box(bytes: &[u8]) -> Option<(f32, f32, f32)> {
let anim_count = bytes.u32_at(0x1c)? as usize;
let anim_ofs = bytes.u32_at(0x20)? as usize;
let lookup_count = bytes.u32_at(0x24)? as usize;
@@ -82,7 +101,8 @@ fn stand_box_xy(bytes: &[u8]) -> Option<(f32, f32)> {
// formula so an unsorted box is harmless.
let dx = bytes.f32_at(rec + 0x30)? - bytes.f32_at(rec + 0x24)?;
let dy = bytes.f32_at(rec + 0x34)? - bytes.f32_at(rec + 0x28)?;
Some((dx, dy))
let dz = bytes.f32_at(rec + 0x38)? - bytes.f32_at(rec + 0x2c)?;
Some((dx, dy, dz))
}
/// Read an **in-memory** M2's authored bounds + vertex-derived radius (see [`M2Bounds`]). The bytes-in
@@ -103,9 +123,10 @@ pub fn parse_m2_bounds(bytes: &[u8]) -> Result<M2Bounds> {
// Selection-ring footprint: `sqrt(0.5 · sqrt(dx² + dy²))` of the Stand animation box's horizontal
// extents (the real client's living-unit ring input), falling back to the header render-box XY for a
// model with no animation sequences (the client's static-model path).
let (rx, ry) = stand_box_xy(bytes).unwrap_or((
let (rx, ry, rz) = stand_box(bytes).unwrap_or((
h.bounding_box_max[0] - h.bounding_box_min[0],
h.bounding_box_max[1] - h.bounding_box_min[1],
h.bounding_box_max[2] - h.bounding_box_min[2],
));
let ring_footprint = (0.5 * (rx * rx + ry * ry).sqrt()).sqrt();
Ok(M2Bounds {
@@ -115,5 +136,6 @@ pub fn parse_m2_bounds(bytes: &[u8]) -> Result<M2Bounds> {
vert_max_from_origin,
ring_footprint,
pivot_z: model.pivot_attach_z,
stand_box_z: rz.max(0.0),
})
}
@@ -0,0 +1,77 @@
//! Pins the **chat bubble's** anchor height against real build-5875 assets — the sibling of
//! `selection_ring_radius`, reading the same Stand-animation box on the other axis.
//!
//! The reference anchors the bubble at
//!
//! ```text
//! worldZ = unit.z + (StandBox.max.z − StandBox.min.z) × modelScale + 0.7
//! ```
//!
//! where the extent comes from `0x4b0e38 call 0x711a20` — which wow-re's anchor cross-check
//! (2026-08-17) followed into the model layer and found reading the **MD20 header image**: file
//! bytes, no bone matrix anywhere in the call tree, returning that CAaBox's Z. The scaled product
//! is latched at `bubble+0x354` behind a parity guard, so it is queried once per chat line.
//!
//! Benilla shipped the bubble on the **posed PlayerName attachment** instead (the `0x608640`
//! chain), on a recorded INFERRED claim that the two calls were equivalent — "both the head-region
//! attachment height, model-scaled". They are not: they differ precisely on animated-vs-static.
//! This test exists so that claim cannot come back by accident, and so the two heights stay
//! distinguishable — they are close enough (2.01 vs 2.21 on a human male) that an eye cannot tell
//! them apart on a human (2.01 vs 2.21) — which is exactly how the wrong one shipped. On a chicken
//! they are not close at all (0.44 vs 0.81): Blizzard floats the *name* anchor well clear of a small
//! model's head, while the bubble hugs the model. That spread is why the pair is pinned per model
//! rather than as a single fudge factor. Decision 1406.
//!
//! Skips when the gitignored client data isn't present.
use benilla_formats::{load_m2_bounds, open_chain};
#[test]
fn stand_box_z_is_the_bubble_anchor_height_and_not_the_attachment() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = open_chain(&data).expect("open chain");
// (model, the Stand box's Z extent, the PlayerName attachment-18 Z benilla used to anchor on).
// Both read straight out of the shipped M2s; the pair is the point — a test that only pinned
// the first would pass just as happily on the wrong source.
// All four numbers are read out of the shipped M2s, not estimated: the Stand box from the
// `M2Sequence` CAaBox at `animationLookup[0]` (record 0 for the human, record **2** for the
// chicken, whose record 0 is a flap), the attachment from the attachment table's id 18.
let cases = [
(
"Character\\Human\\Male\\HumanMale.mdx",
2.0128_f32,
2.2120_f32,
),
("Creature\\Chicken\\Chicken.mdx", 0.4435, 0.8090),
];
for (path, stand_z, attach_z) in cases {
let b = load_m2_bounds(&mut chain, path).unwrap_or_else(|e| panic!("bounds {path}: {e}"));
assert!(
(b.stand_box_z - stand_z).abs() < 0.001,
"{path}: stand_box_z {:.4} should be the Stand CAaBox Z extent {stand_z}",
b.stand_box_z
);
// The two sources are genuinely different values, so a regression to the attachment cannot
// slip through the bound above.
assert!(
(b.stand_box_z - attach_z).abs() > 0.02,
"{path}: stand_box_z {:.4} must not be the posed-attachment height {attach_z} — those \
are the two mechanisms 1406 separated",
b.stand_box_z
);
// It is a height, and it is not the all-animation vertex box (which on a character runs far
// over the head — the reason that box is wrong for anything head-shaped).
assert!(
b.stand_box_z > 0.0,
"{path}: a Stand box has positive height"
);
let header_z = b.bbox_max[2] - b.bbox_min[2];
assert!(
b.stand_box_z < header_z,
"{path}: the Stand box {:.4} sits inside the all-animation box {header_z:.4}",
b.stand_box_z
);
}
}
@@ -0,0 +1,95 @@
//! UBRS's Rookery Eggs "don't play their animation when you walk close" (bug B140, decision 1404).
//!
//! The egg is `gameobject_template` 175124, a **TRAP** with radius 3 and one charge: walk inside it
//! and vmangos spends the charge, casts the whelp-spawner, and — because the spawn rows carry
//! `animprogress = 100` — sends `SMSG_GAMEOBJECT_DESPAWN_ANIM` (`GameObject.cpp:654-656` →
//! `WorldObject::SendObjectDeSpawnAnim`) immediately followed by `SMSG_DESTROY_OBJECT`. The real
//! client arms substate 12 off that packet (wow-re `gameobject-anim-arm.md` §2c: code table
//! `0x80b0e0[6]` = 12, LUT `0x8607e4[12]` = **157 Despawn**) and *pins* the object so the destroy
//! waits for the play (`go-display-sound-events.md` §6d).
//!
//! This file pins the two asset facts that whole law rests on, because both are the kind of thing a
//! plausible reading gets wrong:
//!
//! 1. **The hatch is 157 Despawn, and it is unreachable from the §243 state machine.** The model
//! authors the door family (146..149) too — so a reader that only ever consults
//! `GAMEOBJECT_STATE` finds sequences, arms one, and looks correct while never playing the
//! animation anyone reported. Substate 1 (`state = 1`, `animprogress = 100`) resolves to 147
//! Closed, a 0.333 s near-static hold; the hatch is eight times longer and lives on the other,
//! disjoint arm channel.
//! 2. **The window the pin has to outlast is real time.** 157 is ~2.667 s — the entire visible
//! event happens *after* the server has told the client the object is gone, which is why a
//! client without the pin shows nothing at all rather than something clipped.
//!
//! Skips (passes) when the client isn't present at `<repo>/WoW/Data`.
use benilla_formats::{open_chain, parse_m2_animation_lookup, parse_m2_animations};
/// `GameObjectDisplayInfo.dbc` row **3891** (the Rookery Egg's `displayId`) → this model.
const EGG: &str = "World\\Goober\\G_DragonEggFreeze.m2";
/// `AnimationData.dbc` 157 Despawn — the one-shot channel's code 6.
const DESPAWN: u16 = 157;
/// 147 Closed — what substate 1 (the spawn state) holds instead.
const CLOSED: u16 = 147;
#[test]
fn the_rookery_egg_keys_its_hatch_in_despawn_not_in_the_state_family() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = open_chain(&data).expect("open vanilla patch chain");
let bytes = chain.read_file(EGG).expect("the egg M2 is in the chain");
let anims = parse_m2_animations(&bytes);
let seq = |id: u16| {
anims
.iter()
.find(|a| a.anim_id == id)
.unwrap_or_else(|| panic!("the egg authors animation id {id}"))
};
// 1. The model OWNS 157 — slot 15 pre-gates the one-shot channel on exactly this
// (`0x5f423d`/`0x711960`), and takes no §2c remap, so an unowned id would simply play
// nothing. This is the check that says the packet has somewhere to land.
let lookup = parse_m2_animation_lookup(&bytes).expect("animation lookup");
assert!(
lookup.get(DESPAWN as usize).is_some_and(|&s| s != 0xffff),
"the egg owns 157 Despawn"
);
// 2. The hatch's window — what the destroy pin has to hold the object alive for. Half a
// second of slack either side: the assertion is "seconds, not frames", not a float pin.
let hatch = seq(DESPAWN);
assert!(
(2.0..3.5).contains(&hatch.duration),
"157 Despawn is a multi-second play (got {}s) — the whole of what B140 reported missing",
hatch.duration
);
assert!(
!hatch.looping,
"it is a clamp band: one window, then the object goes"
);
// 3. …and it is where the CONTENT is. The state family's rest pose is a near-static hold; the
// hatch moves an order of magnitude more. A client that only reads `GAMEOBJECT_STATE` arms
// the left-hand column for ever and renders an egg that never hatches — the report.
let keys = |id: u16| -> usize {
seq(id)
.bones
.iter()
.map(|b| b.translation.len() + b.rotation.len() + b.scale.len())
.sum()
};
assert!(
keys(DESPAWN) > 4 * keys(CLOSED),
"the hatch ({} keys) dwarfs the state pose ({} keys) it is reachable past",
keys(DESPAWN),
keys(CLOSED),
);
// 4. One pass. `R = max(1, min + roll)` with an empty replay range rolls to 1 (wow-re
// `loop-replay-fidget.md`), so the completion — and the pin's release — lands at one band
// length, which is what `release_despawn_pin` models.
assert_eq!(
(hatch.min_replay, hatch.max_replay),
(0, 0),
"R = 1: the pin drops after exactly one band"
);
}
+7
View File
@@ -422,6 +422,13 @@ pub enum SessionEvent {
/// The load-bearing sender is the fishing bobber's bite (`anim_id 0`, the splash;
/// decision 1086).
GameObjectCustomAnim { guid: u64, anim_id: u32 },
/// An object plays its one-shot **Despawn** animation, then goes away
/// (`SMSG_GAMEOBJECT_DESPAWN_ANIM`): the client arms substate 12 — AnimationData **157
/// Despawn** — and holds the object alive across the `SMSG_DESTROY_OBJECT` that follows in
/// the same server tick, for the length of that play (decision 1404). UBRS's Rookery Eggs
/// hatch on this packet; a totem's death and a DynamicObject's expiry send it too, and those
/// carry nothing armable, so the consumer falls back to the ordinary instant destroy.
GameObjectDespawnAnim { guid: u64 },
/// The fishing channel ended with nothing hooked (`SMSG_FISH_NOT_HOOKED`, empty body):
/// the red `ERR_FISH_NOT_HOOKED` toast (decision 1086).
FishNotHooked,
@@ -714,6 +714,9 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
ServerPacket::GameObjectCustomAnim { guid, anim_id } => {
vec![SessionEvent::GameObjectCustomAnim { guid, anim_id }]
}
ServerPacket::GameObjectDespawnAnim { guid } => {
vec![SessionEvent::GameObjectDespawnAnim { guid }]
}
ServerPacket::FishNotHooked => vec![SessionEvent::FishNotHooked],
ServerPacket::FishEscaped => vec![SessionEvent::FishEscaped],
// The keepalive echo: the io layer matches the sequence against its ping clock to compute
@@ -94,6 +94,15 @@ pub(super) fn read_gameobject_custom_anim(r: &mut &[u8]) -> io::Result<(u64, u32
Ok((guid, anim_id))
}
/// Read `SMSG_GAMEOBJECT_DESPAWN_ANIM` → `guid`. VERIFIED vmangos
/// `WorldObject::SendObjectDeSpawnAnim` (`Objects/Object.cpp:2307`) →
/// `WorldPackets::Misc::GameObjectDespawnAnim`, whose whole body is the object guid. The
/// client-side meaning (wow-re `gameobject-anim-arm.md` §2c): arm substate 12, AnimationData id
/// **157 Despawn** — the arm channel, like the custom one, is disjoint from the §243 lid family.
pub(super) fn read_gameobject_despawn_anim(r: &mut &[u8]) -> io::Result<u64> {
read_u64_le(r)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -155,6 +164,21 @@ mod tests {
}
}
#[test]
fn gameobject_despawn_anim_decodes() {
// The whole body is the object guid (VERIFIED vmangos
// `WorldObject::SendObjectDeSpawnAnim` → `WorldPackets::Misc::GameObjectDespawnAnim`,
// whose `AppendBodyTo` writes only `gameObjectGuid`). A UBRS Rookery Egg spending its
// last trap charge is the load-bearing sender (decision 1404).
let body = hx("f0debc9a78563412");
match parse_server(opcode::SMSG_GAMEOBJECT_DESPAWN_ANIM, &body).unwrap() {
ServerPacket::GameObjectDespawnAnim { guid } => {
assert_eq!(guid, 0x1234_5678_9abc_def0);
}
other => panic!("expected GameObjectDespawnAnim, got {}", other.name()),
}
}
#[test]
fn gameobject_custom_anim_decodes() {
// A full little-endian guid + u32 animId (VERIFIED vmangos
@@ -338,6 +338,18 @@ pub const CMSG_GAMEOBJ_USE: u16 = 0x00B1; // 177
/// `gameobject-anim-arm.md` §"one-shot channel" step 8. The load-bearing sender: the fishing
/// bobber's bite (`animId 0` — the splash; decision 1086).
pub const SMSG_GAMEOBJECT_CUSTOM_ANIM: u16 = 0x00B3; // 179
/// The **other** one-shot GameObject arm channel (VERIFIED vmangos `Opcodes_1_12_1.h`: 533,
/// `WorldObject::SendObjectDeSpawnAnim` → `WorldPackets::Misc::GameObjectDespawnAnim`). Body in
/// [`super::gameobject`]: a bare `u64` guid. The client arms substate **12** — AnimationData id
/// **157 Despawn** — and the object then survives its own `SMSG_DESTROY_OBJECT` for the length of
/// that play (wow-re `gameobject-anim-arm.md` §2c's code table `0x80b0e0[6]` → the `0x8607e4` LUT,
/// and `go-display-sound-events.md` §6d's arm-time pin). The load-bearing sender: a TRAP that has
/// spent its charges — UBRS's Rookery Eggs hatch on this packet (decision 1404).
///
/// It is **not** GameObject-only: `SendObjectDeSpawnAnim` lives on `WorldObject`, so a totem's
/// death and a DynamicObject's expiry send it too. The consumer treats a guid with nothing armable
/// as an ordinary destroy.
pub const SMSG_GAMEOBJECT_DESPAWN_ANIM: u16 = 0x0215; // 533
/// VERIFIED vmangos `Opcodes_1_12_1.h:183`: 180 — and the reference writes this literal
/// (`0x5e2110`, its area-trigger check, builds `0xb4` + the trigger id). Body in
/// [`super::area_trigger`]: the `AreaTrigger.dbc` id the player just walked into.
@@ -307,6 +307,14 @@ pub enum ServerPacket {
guid: u64,
anim_id: u32,
},
/// `SMSG_GAMEOBJECT_DESPAWN_ANIM` — an object plays its one-shot **Despawn** animation and
/// then goes away. Payload VERIFIED vmangos `WorldObject::SendObjectDeSpawnAnim`: a bare
/// `u64` guid. The client arms substate 12 (AnimationData **157 Despawn**) and PINS the
/// object so the `SMSG_DESTROY_OBJECT` that follows in the same server tick waits for the
/// play to finish (decision 1404).
GameObjectDespawnAnim {
guid: u64,
},
/// `SMSG_FISH_NOT_HOOKED` — the fishing channel ended (expiry, or clicked before the splash)
/// with nothing hooked. Empty body (VERIFIED vmangos `GameObject::Update`/`Use`); the red
/// `ERR_FISH_NOT_HOOKED` toast (decision 1086).
@@ -1196,6 +1204,7 @@ impl ServerPacket {
ServerPacket::GameObjectQueryResponse { .. } => "SMSG_GAMEOBJECT_QUERY_RESPONSE".into(),
ServerPacket::PageTextQueryResponse { .. } => "SMSG_PAGE_TEXT_QUERY_RESPONSE".into(),
ServerPacket::GameObjectCustomAnim { .. } => "SMSG_GAMEOBJECT_CUSTOM_ANIM".into(),
ServerPacket::GameObjectDespawnAnim { .. } => "SMSG_GAMEOBJECT_DESPAWN_ANIM".into(),
ServerPacket::FishNotHooked => "SMSG_FISH_NOT_HOOKED".into(),
ServerPacket::FishEscaped => "SMSG_FISH_ESCAPED".into(),
ServerPacket::PlaySound { .. } => "SMSG_PLAY_SOUND".into(),
@@ -856,6 +856,10 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
let (guid, anim_id) = gameobject::read_gameobject_custom_anim(&mut r)?;
ServerPacket::GameObjectCustomAnim { guid, anim_id }
}
opcode::SMSG_GAMEOBJECT_DESPAWN_ANIM => {
let guid = gameobject::read_gameobject_despawn_anim(&mut r)?;
ServerPacket::GameObjectDespawnAnim { guid }
}
// Both fishing verdicts are empty-bodied (the opcode IS the message).
opcode::SMSG_FISH_NOT_HOOKED => ServerPacket::FishNotHooked,
opcode::SMSG_FISH_ESCAPED => ServerPacket::FishEscaped,