Files
benilla-octo/crates/benilla/src/creature_anim.rs
T
SamandClaude Fable 5 63efec188f the skinning code catches up, and 59k bone entities become arrays
- An honest correction first: the previous snapshot's message announced the
  owned skinning lane, but the code itself missed that snapshot by minutes —
  two sessions landed at once and the export carried the older tree. The
  skinning lane actually ships here, together with its follow-up.
- The follow-up is the bone-entity collapse. A creature's ~75-bone skeleton
  existed as ~75 world entities, each paying transform propagation and
  bookkeeping every frame, to serve the ~5 bones anything actually hangs off
  (held weapons, glow emitters, the mount seat, overhead markers). A rig's pose
  now lives in plain arrays on the model itself; only the consumer anchor
  points remain as entities. At the crowded dungeon spot ~59,000 entities
  became ~28,000 anchors (80k total → 50k), palette uploads coalesce into a
  handful of writes, and another ~3 CPU ms came off the frame.
- The movement echo the server sends about your own character is applied now,
  the way the original client always applied it — groundwork that GM flight
  cashes in later today.
- The capture harness pins its clock per frame now, not just its frame count —
  a slow run used to drift every animation out from under a golden image.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:55:01 +07:00

814 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Movement-driven character & creature animation (decision 0019, Milestone C; the locomotion controller
//! of decisions 0047 + 0049). Each animated unit carries a [`ModelAnimations`] component (its clips, by
//! `AnimationData.dbc` id), an [`AnimationPlayer`], and an
//! [`AnimationTransitions`][bevy::animation::transition::AnimationTransitions]; this module is a
//! small per-unit state machine that picks the animation from the unit's movement state, **cross-fades**
//! into it over the clip's blend-in time, and scales a locomotion clip's playback rate to the unit's
//! speed.
//!
//! Regimes (decisions 0049 → 0083/0087):
//! - **Gait** — the directly-cross-faded ground/swim gaits + idle: Stand / Walk / Run / Fast-run by speed,
//! WalkBackwards when moving back, the swim ids when swimming (verified client selector, wow-5875-re
//! RF-0057). A change cross-fades to the new clip over its `blend_time` instead of snapping.
//! - **Special** — the stand-state poses (sit/sleep/kneel: down → loop → up) as one-shot-bracketed
//! loops, **preemptible** (decision 0055), plus the jump's enter/hang. The jump's *landing* is NOT a
//! bracket: [`select::Mode::Land`] is a plain pick from the input at touchdown, freely overwritten the
//! instant a movement flag changes (decisions 0083/0087 — land-then-press runs immediately).
//! - **One-shots (swings, emotes)** — routed **per play from live state** (decision 0087, the
//! byte-verified `0x5fe2f0` route): a standing-idle unit plays them full-body on the base track
//! ([`select::Mode::Swing`]); a moving / seated / combat-airborne unit plays them on the SpineLow
//! torso-masked overlay ([`AnimClip::upper_node`]) while the base keeps driving the legs underneath —
//! never dropped, never bracket-gated.
//!
//! The playback **rate** of a locomotion clip is `speed / sequence.moveSpeed` (wow-5875-re `0x5fe2f0`), so
//! a unit moving faster than the clip's design speed cycles its legs proportionally faster (the fix for a
//! backpedal that otherwise drags). Non-locomotion clips (idle, the jump/sit transitions — `moveSpeed 0`)
//! play at 1×. Death (decoded health 0) overrides everything.
//!
//! The movement view is unified per unit ([`select::unify`]): the self-avatar's [`MovementState`] is
//! filled by the player controller from input; a **remote player**'s comes from its relayed
//! `MSG_MOVE_*` stream (live CMovement flags + extrapolation speed, via [`crate::net::RemoteMotion`]),
//! so its walk/run/ strafe/backpedal/turn animate from the real flags; a **creature**'s is derived from
//! its server [`crate::net::Spline`] (forward + speed). Stand-state (sit/sleep/kneel/chairs) comes from
//! the unit's `UNIT_FIELD_BYTES_1` byte for streamed units, and from the controller (echo + in-flight
//! request) for our own avatar (decision 0080c).
use std::time::Instant;
use benilla_assets::{AnimClip, ModelAnimations};
use benilla_formats::AnimDataCatalog;
use bevy::prelude::*;
use crate::assets::AssetSet;
use crate::schedule::WorldStage;
/// The pure animation-selection logic (RF-0057/0073 tables, movement/Special state, gait/swing/ready
/// picks, playback-rate math) — kept in its own file as it carries the bulk of the unit-tested selector
/// logic, separate from the Bevy driver systems in [`driver`].
mod select;
use select::Mode;
pub(crate) use select::{ease_strafe_yaw, move_flags, strafe_body_offset, MovementState};
/// The display-facing counter-twist (the strafe body pose): the [`BodyTwist`] component + the
/// post-animation system that composes the SpineLow/Head counter-rotation onto the frame's pose.
mod twist;
pub(crate) use twist::{wrap_pi, BodyTwist};
/// Global-sequence bone channels (the eye-blink eyelid scale, resting fidget pulses): free-clock loops
/// the per-sequence reader drops, driven on their own clock over the gait.
mod global_seq;
pub(crate) use global_seq::GlobalSeqDrive;
/// The unit animation-LOD gate (decision 0448): park an off-frustum rig's per-bone pose
/// evaluation — the clocks, the driver state machine, and the event tracks keep running, so
/// off-screen combat stays audible (0075) and a re-appearing unit snaps to the absolute-clock
/// pose. A modernization, not fidelity — the reference animates every in-range unit (wow-re
/// `unit-anim-visibility-gate.md`).
mod lod;
pub(crate) use lod::AnimParked;
mod pose;
pub(crate) use pose::{RigAnchor, RigFrame, RigPose};
/// The collapsed rig's composition passes (decision 0724): the pre-propagation model compose +
/// anchor re-seat, and the post-propagation world pass (palette rows + billboard replacements).
mod compose;
pub(crate) use compose::{finalize_rig_worlds, PosePost};
/// The unit's wielded weapon classes — `(item class, item subclass)` per hand, `None` for an empty
/// (or non-item) hand. Written by the held-item resolution ([`crate::entities`], decision 0072) from
/// the same descriptor data that places the weapon models; read by the swing/ready animation
/// selectors (decision 0073 — the byte-verified `GetWeapon` byte pair `0x605e30`).
#[derive(Component, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct Wielded {
pub(crate) main: Option<(u8, u8)>,
pub(crate) off: Option<(u8, u8)>,
/// The **ranged-slot** item's `(class, subclass)` — the local auto-repeat idle's selector
/// ([`select::ranged_load_anim`], the client's `0x5fd530` LUT; decision 0099 phase 5).
pub(crate) ranged: Option<(u8, u8)>,
/// The mainhand/offhand items' sheath *types* (1 back-2H · 2 back-staff · 3 hip · 4 shield ·
/// 0 none) — each picks its arm's draw/stow one-shot independently (VERIFIED wow-re
/// `sheath-anim-pick.md`: `(1 << (rec[+4] & 0x1f)) & 0x88` → HipSheath 90 for types {3, 7},
/// Sheath 89 otherwise; the slot's own byte is the *only* input).
pub(crate) main_sheath: u8,
pub(crate) off_sheath: u8,
}
/// The unit is engaged in melee auto-attack (`SMSG_ATTACKSTART` .. `ATTACKSTOP`, decision 0073):
/// standing still it plays the weapon-class Ready idle — the client's `0x5fd360` arm gates on the
/// auto-attack-target GUID being set, i.e. engagement, **not** sheath state.
#[derive(Component)]
pub(crate) struct Engaged;
/// The local player has fired an auto-repeat spell (Auto Shot / wand Shoot) — the client's
/// `[+0xd58] & 0x200`, whose **only writer binary-wide** is the local cast-send tail (`0x6e593b`,
/// gated `AttributesEx2 & 0x20`; byte-verified, wow-re `ranged-shot-anim.md`). While it and the
/// ranged **sheath** gate (`CUR == 2`) both hold, a standing unit's base idle is the ranged
/// weapon's Load/Hold clip ([`select::ranged_load_anim`]) — the client's resolver `0x5fd460`,
/// which gates on exactly this bit; local-player-only by construction (remote units never run
/// the local cast-send). A REMOTE shooter's drawn idle rides [`RangedHold`] instead. Cleared
/// only by [`cancel_auto_repeat_local`]'s callers — the client's cancel `0x6ea080` (its `0x200`
/// clear `0x6ea113`), reached from the cast-result fail of the cached spell, the button
/// re-press toggle, melee attack-start, target death, the wand-only new-cast handoff, and the
/// (vmangos-dead) `SMSG_CANCEL_AUTO_REPEAT` (wow-re `nocked-ammo-cancel.md`).
#[derive(Component)]
pub(crate) struct AutoRepeatArmed;
/// The weapon-visual hold — the client's `[+0xd58] & 0x400`, set for **ANY caster** (self,
/// remote players, NPCs) whose **ranged** spell visual plays (`0x60d020`, sole caller `0x6ec2cf`
/// inside `PlaySpellVisual`; byte-verified, wow-re `ranged-sheath-exempt-autorepeat.md` §Q4).
/// It is what sustains the drawn Load→Hold idle between a remote shooter's shots — HoldBow
/// self-loops on `0x200 | 0x400`, HoldRifle/HoldThrown on `0x400` alone (`0x5fc3f0`'s
/// continuation tables; ours folds both bits into one gait gate, the asymmetry unmodeled — a
/// bow's first-GO lands well inside the Load pass either way). Cleared like the client's
/// id-matched `0x60d040` + its unconditional sibling `0x60fc50`: a **non-ranged** visual play by
/// the same caster (the stale-visual cleanup `0x6ec39e`), the local cancel (`0x6ea12b`), and
/// melee attack-start. Deliberately NOT cleared on sheath change or volley end — the client's
/// bit persists latent (a stopped remote shooter keeps holding until its next action; the gait
/// gate needs `CUR == 2` anyway).
#[derive(Component)]
pub(crate) struct RangedHold;
/// A unit's displayed **nocked ammo** — the client's `[+0xd28]`/`[+0xd2c]` ammo-model pair,
/// written only by `CGUnit::UpdateAmmoDisplay 0x60ba30` (byte-verified, wow-re
/// `nocked-ammo-cancel.md`): the ammo's `ItemDisplayInfo` id, rendered as a model on the unit's
/// **body** at HandArrow (35) — the ONE bone attach, bow-only (§E2; the old Special2/Special3
/// reading is refuted — those are model-directory selectors, §E1). Set per shot from the
/// `SMSG_SPELL_START` `CAST_FLAG_AMMO` tail (any caster — the only source for a remote/NPC
/// shooter). This is the display-id CACHE; whether the arrow is visibly in the hand is the
/// [`NockLatch`] (the `$BWP`/`$BWR` cycle). Removed by the un-nock `0x60f530` paths: the local
/// cancel, leaving the ranged sheath stance (`0x60fc72`), or a weapon change.
#[derive(Component, Clone, Copy, PartialEq, Eq)]
pub(crate) struct NockedAmmo {
/// The ammo's `ItemDisplayInfo` id (never 0 — an id-0 refresh removes the component instead).
pub(crate) display_id: u32,
}
/// The client's nock latch `[+0xd58] & 0x4000` (wow-re `nocked-ammo-cancel.md` §G1/G4): SET at
/// the body clip's **`$BWP`** BowPull keyframe (LoadBow 105 carries it at ~0.6 s — the visible
/// pull-from-quiver moment, which also (re)attaches the in-hand arrow), CLEARED at **`$BWR`**
/// BowRelease (AttackBow 46 carries it at ~0.067 s — the arrow leaves the hand as the missile
/// flies) and by the un-nock paths. The equipment attach leg shows the [`NockedAmmo`] model only
/// while this holds, and the bowstring's middle vertex tracks the draw hand on the same gate.
#[derive(Component)]
pub(crate) struct NockLatch;
/// Drive [`NockLatch`] from the anim-event stream ([`AnimSoundEvent`] — the same `$`-tag
/// keyframes the sound/impact consumers ride): `$BWP` latches (only a unit with a [`NockedAmmo`]
/// display cached — the client's handler gates on the cached ranged spell), `$BWR` clears.
/// Ordered after [`events::fire_anim_events`] so a tag lands the frame it's crossed.
pub(crate) fn drive_nock_latch(
mut events: MessageReader<AnimSoundEvent>,
nocked: Query<Has<NockedAmmo>>,
mut commands: Commands,
) {
for ev in events.read() {
match &ev.ident {
b"$BWP" if nocked.get(ev.entity) == Ok(true) => {
commands.entity(ev.entity).insert(NockLatch);
}
b"$BWR" => {
if let Ok(mut e) = commands.get_entity(ev.entity) {
e.remove::<NockLatch>();
}
}
_ => {}
}
}
}
/// The one local auto-repeat cancel — benilla's `0x6ea080` (byte-verified whole, wow-re
/// `nocked-ammo-cancel.md`): clear the live key (`[0xceac30]`), drop the Load/Hold idle gates
/// (the `0x200`/`0x400` bits — [`AutoRepeatArmed`] + [`RangedHold`]), un-nock the ammo
/// (`0x60f530` — [`NockedAmmo`]), and ack the server (`CMSG_CANCEL_AUTO_REPEAT_SPELL`, the
/// client's sole send site `0x6ea0c6`) — **sheath untouched**: the weapon stays drawn, the idle
/// falls back to the ordinary stand. Every cancel trigger funnels here, like the client's 8
/// live callers.
pub(crate) fn cancel_auto_repeat_local(
entity: Option<Entity>,
auto_repeat: &mut crate::ui_action::AutoRepeatActive,
commands: &mut Commands,
net: &crate::net::NetCommands,
) {
if auto_repeat.0.is_none() {
// The client's `[0xceac30] == 0` early-out — nothing running, nothing sent.
return;
}
auto_repeat.0 = None;
if let Some(e) = entity {
commands
.entity(e)
.remove::<(AutoRepeatArmed, RangedHold, NockedAmmo, NockLatch)>();
}
let _ = net.0.send(crate::net::ClientCommand::CancelAutoRepeat);
}
/// A unit is mid-cast (`SMSG_SPELL_START` .. `GO`/`FAILED_OTHER`, decision 0099 phase 1) — the
/// wire-truth state seam. Inserted on `SpellStart` only when it carries a nonzero cast time (an
/// instant cast's `SpellGo` follows with nothing to interrupt, so it never gets one); removed on
/// the matching-spell-id `SpellGo`/`SpellFailedOther` (the client's reap `0x614150(spellId, 0)` is
/// keyed — a triggered proc's GO mid-cast never clears a different spell's precast, decision 0107).
/// The *animation* side rides [`CastEvent`]/[`CastHold`] (the resolved layer,
/// [`spell_visual::route_cast_visuals`]), not this component. `until` is `Option` rather than a
/// bare deadline so a later channel (an open-ended cast, no fixed end) can land here without a
/// sentinel.
#[derive(Component, Clone, Copy)]
pub(crate) struct Casting {
pub(crate) spell_id: u32,
#[allow(dead_code)]
pub(crate) until: Option<Instant>,
}
/// The client's PlayAnimation **call order**, reconstructed. The real client has no pecking
/// order between a swing and a spell kit's anim landing in the same frame — its handlers run
/// synchronously in packet order and the LATER `PlayAnimation` call simply overwrites bone 0
/// (wow-re, the Eviscerate-timing round's arm C). benilla's swing and cast pipelines are
/// separate message streams, so that order must ride the messages: every emitter of an
/// animation-bearing request ([`SwingMessage`], [`CastEvent`], [`EmoteAnim`], [`KitPush`])
/// stamps `next()` at emission — the wire drain stamps in packet order; scene-time emitters
/// (a missile arriving, an interact emote) stamp when they fire, which is after that frame's
/// packets, exactly where the client's scene-update calls sit. [`driver::drive_animations`]
/// resolves a same-frame collision by the highest stamp.
#[derive(Resource, Default)]
pub(crate) struct PlaySeq(u64);
impl PlaySeq {
pub(crate) fn next(&mut self) -> u64 {
self.0 += 1;
self.0
}
}
/// A cast lifecycle edge on a streamed unit, straight off the wire (written by the net bridge,
/// consumed by [`spell_visual::route_cast_visuals`], which resolves spell → `SpellVisual` → kit →
/// animation/sound — the DBC knowledge stays in the anim layer, decision 0107).
#[derive(Message, Clone, Copy)]
pub(crate) struct CastEvent {
pub(crate) entity: Entity,
pub(crate) spell_id: u32,
pub(crate) kind: CastEventKind,
/// [`PlaySeq`] stamp at emission — carried through to the kit's [`EmoteAnim`].
pub(crate) seq: u64,
}
/// Which cast edge fired (decision 0107 verdict 2's stage wiring).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum CastEventKind {
/// `SMSG_SPELL_START` — the precast kit persists from here (subliminal for an instant, whose
/// GO follows at once).
Start,
/// `SMSG_SPELL_GO` — reaps the precast, plays the cast/release kit.
Go,
/// The cast died without a release (`SMSG_SPELL_FAILED_OTHER`, or our own failed
/// `SMSG_CAST_RESULT`) — reap only, nothing plays.
Fail,
/// The spell **landed on this unit** (`entity` = the struck target, not the caster — decision
/// 0099 phase 4): a missile arrival, written back by `crate::entities::missile`. Plays the
/// impact kit (stage 1) + the state kit (stage 2) on the target — the client's unit-impact
/// hand-off `0x61dc50`. A speed-0 spell's impacts never come through here: the router plays
/// them inline at the GO (same resolve, no round-trip). `weapon_visual` = the caster's
/// ranged-weapon substitute `SpellVisual` id, resolved at GO time and carried through the
/// flight (the caster may despawn mid-flight; the client's missile carries its kit context)
/// — the `0x60d450` fallback a basic shot's impact kit resolves through (decision 0370).
Impact { weapon_visual: Option<u32> },
}
/// The resolved casting **hold** — the animation a unit sustains while a cast or channel is in
/// flight (`SpellVisualKit` field 2 of the precast/channel kit, decision 0107). Managed entirely
/// by [`spell_visual::route_cast_visuals`]; the driver renders it per frame: **standing → the
/// gait slot, full-body** (the client's stationary-cast pin — the settled `[CGUnit+0xb4]` gate) —
/// **moving → a looping masked overlay** (the ordinary torso-masked route a moving caster falls
/// through to). A Special (jump/pose) outranks it — the server interrupts such casts anyway.
/// `spell_id` keys the reap, like everything in this pipeline (the client's `0x614150(spellId, …)`):
/// an unrelated spell's GO/fail never drops another cast's hold.
#[derive(Component, Clone, Copy)]
pub(crate) struct CastHold {
pub(crate) anim_id: u16,
pub(crate) spell_id: u32,
/// A ranged-slot shot's wind-up (`Attributes & 0x2` — Throw's ReadyThrown, the bow's
/// LoadBow): the client brackets every ranged kit play with ranged snaps (`0x60f34c`
/// before, the outer `0x6e5930`/`0x6e78f3` after — wow-re
/// `ranged-sheath-exempt-autorepeat.md`), so while this hold is live the sheath
/// reconcile's force-stow never survives the frame ([`driver`]'s bracket override).
pub(crate) ranged: bool,
}
/// Per-hand weapon-grip state: whether a weapon is held in the right/left hand, so that hand's fingers
/// curl into a grip. The real client arms `HandsClosed` (AnimationData 15) on a hand's finger key-bones
/// **purely by that hand's attach point being occupied by a weapon** — not combat, not sheath state; a
/// forearm-mounted shield or an empty hand stays open (wow-re `hand-grip-mechanism.md`, `0x60b590` /
/// paperdoll `0x5059a0`). Written by the held-item resolver ([`crate::entities`], decision 0072) from the
/// weapon's hand attach point; read by [`driver::drive_hand_grip`] to hold/release the finger overlay.
#[derive(Component, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct HandGrip {
/// A weapon occupies the right-hand attach point (mainhand, id 1).
pub(crate) right: bool,
/// A weapon occupies the left-hand attach point (a non-shield offhand, id 2).
pub(crate) left: bool,
}
/// The weight of the per-hand weapon-grip finger overlay ([`benilla_assets::ModelAnimations::hand_close`])
/// over the base gait on the finger key-bones both drive. The base clip animates the whole skeleton
/// (fingers at Stand's flat 0°) and isn't masked out of the finger subtree, so grip and base blend there —
/// this makes the grip dominate ≈ 8:1 (the fingers read closed, not a half-open wash). Same rationale/value
/// as the sheath ceremony and the upper-body one-shot overlays. Shared by the live driver
/// ([`driver::drive_hand_grip`]) and the glue-booth spawn ([`crate::portrait`]) so both blend the grip
/// identically.
pub(crate) const HAND_GRIP_WEIGHT: f32 = 8.0;
/// One completed melee swing for `attacker` (`SMSG_ATTACKERSTATEUPDATE` → the net bridge; decision
/// 0073: one packet = one swing animation, no client-side timer). `hit_info` bit `0x4` = offhand
/// swing; bit `0x10000` suppresses the animation. The outcome fields (`victim`, `victim_state`,
/// `damage`) ride along for the combat-audio consumer (`sound::combat`), which plays the swing's
/// sounds when the animation's `$CSS`/`$CAH`/`$HIT` events fire. A fully-absorbed swing's
/// "Absorb" word keys `hit_info & 0x20`, not a magnitude (`0x6243e0` never reads one — decision
/// 0279), so no absorb field rides here; the full-block synthesis likewise rewrites
/// `victim_state` in the net bridge before this message is built.
#[derive(Message, Clone, Copy)]
pub(crate) struct SwingMessage {
pub(crate) attacker: Entity,
/// The swing's target, when it's streamed to us.
pub(crate) victim: Option<Entity>,
pub(crate) hit_info: u32,
/// vmangos `VictimState`: 1 hit · 2 dodge · 3 parry · 4 interrupt · 5 blocks · 6 evades ·
/// 7 immune · 8 deflects (decision 0279's byte-verified consequence table keys off it).
pub(crate) victim_state: u32,
pub(crate) damage: u32,
/// [`PlaySeq`] stamp at emission (the wire drain, in packet order).
pub(crate) seq: u64,
}
/// Play a one-shot **anim-emote** on a unit: the given `AnimationData.dbc` id, played over the
/// gait and returning to it when it finishes (the [`select::Mode::Swing`] one-shot pattern). The
/// gossip/vendor interact route ([`crate::target`], decision 0081) uses it to play EmoteTalk
/// (id 60) on the self-player: the per-animation sheath reconcile then reads that clip's
/// WeaponFlags (`0x10`) and stows the drawn weapon — a committed state change that *persists*
/// after the emote (nothing restores it; weapons stay stowed until re-triggered — decision 0080).
/// The stow is the emote's flags, not a sheath wire. Also fed by the `SMSG_EMOTE` receive path
/// ([`emote_anim::emote_to_anim`]), which maps a bridged anim emote's `Emotes.dbc` id to its
/// `AnimID` — `/wave`, `/bow`, `/laugh`, `/cheer`, … all play through this same one-shot.
#[derive(Message, Clone, Copy)]
pub(crate) struct EmoteAnim {
pub(crate) entity: Entity,
pub(crate) anim_id: u16,
/// [`PlaySeq`] stamp — a kit anim inherits its [`CastEvent`]'s; scene-time emitters stamp
/// fresh at fire time. The driver's same-frame swing collision resolves by the higher stamp.
pub(crate) seq: u64,
}
/// Rear up a rider's mount (decision 0441 P2): resolved to a [`EmoteAnim`] one-shot of
/// MountSpecial(94) on the unit's MOUNT CHILD entity by [`flourish_to_anim`] — the §5-verified
/// routing (the flourish plays on the mount model; the rider holds Mount(91) throughout).
/// Written by the net drain (`SMSG_MOUNTSPECIAL_ANIM` — observed riders only; our own echo
/// is dropped there) and by the self space-bar gate (`crate::player`, locally at send time).
/// A unit with no mount child (a race with dismount) drops silently — nothing to rear.
#[derive(Message, Clone, Copy)]
pub(crate) struct MountFlourish {
pub(crate) unit: Entity,
}
/// [`MountFlourish`] → [`EmoteAnim`]: hop unit → its mount child and fire the one-shot there.
/// The mount child runs the untouched creature machinery (it is not itself "mounted"), so the
/// general one-shot player takes it from here — full-body 94 over the mount's gait, returning
/// to it when done.
fn flourish_to_anim(
mut msgs: MessageReader<MountFlourish>,
mut out: MessageWriter<EmoteAnim>,
mut play_seq: ResMut<PlaySeq>,
mounts: Query<&crate::entities::mount::MountChild>,
) {
for m in msgs.read() {
let Ok(child) = mounts.get(m.unit) else {
debug!("flourish: {:?} has no mount child — dropped", m.unit);
continue; // dismounted before the flourish landed — nothing to rear
};
debug!(
"flourish: MountSpecial(94) one-shot on {:?} (mount of {:?})",
child.0, m.unit
);
out.write(EmoteAnim {
entity: child.0,
anim_id: select::MOUNT_SPECIAL,
seq: play_seq.next(),
});
}
}
/// Play a **wound-flinch** on a unit: the given `AnimationData.dbc` id (8–10, the CombatWound
/// family) laid into the wound SECONDARY-blend slot — a decaying overlay that never interrupts
/// what plays underneath (decision 0111). The spell pipeline's counterpart to the melee flinch:
/// the client's kit player itself branches here (`0x60edf0` @ `0x60f3ad`: anim in `[8,10]` →
/// the wound trigger `0x60ea70`, anything else → `PlayAnimation`), so an impact kit's wound anim
/// must never ride the [`EmoteAnim`] one-shot route — that replaces the base track, the exact
/// routing decision 0111 falsified. Written by [`spell_visual::route_cast_visuals`]; consumed by
/// [`driver::drive_animations`] into the same per-frame wound slot as a melee hit.
#[derive(Message, Clone, Copy)]
pub(crate) struct WoundAnim {
pub(crate) entity: Entity,
pub(crate) anim_id: u16,
}
/// The target lists off one `SMSG_SPELL_GO` (decision 0099 phase 4) — the payload [`CastEvent`]'s
/// `Copy` shape can't carry. Written by the net bridge alongside the GO's [`CastEvent`]; consumed
/// by [`spell_visual::route_cast_visuals`], which resolves `Spell.dbc` Speed and either plays the
/// impacts inline (speed 0) or launches missiles ([`spell_visual::MissileSpawn`]) — the client's
/// two GO branches (`0x6e8bf0` instant vs `0x6e8a50 → 0x60a3d0` projectile).
#[derive(Message, Clone)]
pub(crate) struct SpellGoTargets {
pub(crate) caster: Entity,
pub(crate) spell_id: u32,
/// Units the spell landed on — each gets the impact hand-off (inline or on missile arrival).
pub(crate) hits: Vec<Entity>,
/// Units it missed, with the wire's `SpellMissInfo` code — a projectile still flies at
/// each, and its **arrival** plays the victim's defense clip for DODGE(3)/BLOCK(5) (the
/// client's `Missile_C::Update 0x61ceb0` dispatch — Dodge 30 / ShieldBlock 24, never Parry;
/// wow-re `smsg-attackerstate-consequences.md` §Q4). No impact kit plays. The deflect
/// *flight* visual (the projectile glancing off) stays a named approximation — ours ends
/// the flight at the target.
pub(crate) misses: Vec<(Entity, u8)>,
/// The GO's ammo block (`castFlags & 0x20`, a ranged shot): the `ItemDisplayInfo` id of the
/// caster's ammo/thrown — the missile's model when the spell's visual chain has none (the
/// client's `0x479f40` fallback, decision 0099 phase 5).
pub(crate) ammo_display_id: Option<u32>,
/// [`PlaySeq`] stamp — same packet as the GO's [`CastEvent`], same stamp: the inline
/// impacts play from the one handler call.
pub(crate) seq: u64,
}
/// The sheath **policy layer**'s types + ceremony mechanics (decision 0080) — the one-setter
/// request, the ceremony overlays, the `AnimationData.dbc` policy table. The driver below
/// executes them, so every sheath transition has exactly one author.
mod sheath;
use sheath::{load_anim_data, SheathSwap};
pub(crate) use sheath::{AnimData, SheathRequest, SheathSwapMessage, VisualSheath};
/// The `SMSG_EMOTE` → [`EmoteAnim`] consumer (Part 1 of the emote-animation wiring) — kept
/// alongside `sheath` as its own small concern.
mod emote_anim;
use emote_anim::emote_to_anim;
/// The Bevy driver systems that execute the state machine [`select`] picks (decision 0049 + 0073,
/// decision 0087's one-shot routing, decision 0080's sheath execution) — kept in its own file as
/// it carries the bulk of the per-frame system logic, separate from this module face and from the
/// pure selector logic in [`select`].
mod driver;
pub(crate) use driver::oneshot_is_live;
use driver::{drive_animations, drive_hand_grip};
/// The model event-keyframe scanner (decision 0070 slice 3) — kept in its own file as its own
/// small concern, separate from the driver ([`driver`]) that advances the clips it scans.
mod events;
use events::fire_anim_events;
pub(crate) use events::{is_footstep, scan_events, AnimSoundEvent};
mod impact;
use impact::route_swing_impacts;
pub(crate) use impact::{DefenseAnim, PendingImpacts, SwingFlush, SwingImpact, SwingSlowdown};
/// The spell-visual data plane + the cast-edge router (decision 0099 phase 2 / 0107):
/// `SpellVisual.dbc` × `SpellVisualKit.dbc` loaded alongside [`AnimData`], and
/// [`spell_visual::route_cast_visuals`] resolving wire [`CastEvent`]s into [`CastHold`]s, release
/// one-shots ([`EmoteAnim`]) and kit sounds ([`spell_visual::SpellKitSound`]).
mod blood;
mod env_damage;
mod spell_visual;
use blood::{blood_spurts, load_blood_tables};
use env_damage::{hard_landing_dust, load_env_damage_table};
pub(crate) use env_damage::{EnvDamageTable, HardLanding, HARD_LANDING_DESCENT};
use spell_visual::{
arm_aura_state_fx, arm_level_up_fx, arm_loot_fx, load_spell_visuals, route_cast_visuals,
};
pub(crate) use spell_visual::{
held_strike_sound, FxClass, KitPush, MissileSpawn, SpellKitFx, SpellKitSound, SpellVisuals,
};
/// The per-unit animation state machine.
#[derive(Component)]
pub(crate) struct AnimDriver {
mode: Mode,
/// The gait id currently targeted while in [`Mode::Gait`] (so we cross-fade only on a change). `None`
/// forces a fresh selection (first eval, or after a Special state exits).
gait: Option<u16>,
/// The unit's **client-side sheath state** — the mirror of the client's committed CUR cache
/// (`[unit+0xd40]`, decision 0080): what the weapon placement renders (absent a
/// [`VisualSheath`] ceremony pin) and what the setter/reconcile test against. Seeded from
/// the descriptor byte at first sight; re-adopted whenever the server byte *changes* (the
/// `0x604c70` field-apply); overwritten by requests ([`SheathRequest`]) and the
/// per-animation reconcile. `None` = not yet seen (arms silently).
sheath_cur: Option<u8>,
/// The `UNIT_FIELD_BYTES_2` sheath byte last seen, to detect inbound field *changes* (our
/// own `CMSG_SETSHEATHED` echo arrives already-committed and adopts as a no-op).
sheath_byte: Option<u8>,
/// The pending mid-animation weapon swap, while a draw/stow one-shot is in flight.
sheath_swap: Option<SheathSwap>,
/// A **masked upper-body one-shot** in flight (decision 0087): a swing/emote the live-state route
/// sent to the SpineLow overlay (moving / seated / airborne-in-combat), playing *beside* [`Mode`]
/// while the base track keeps driving the legs (run / sit / jump-arc). `None` = no overlay; a
/// finished overlay releases the subtree back to the base. The full-body route never uses this —
/// it replaces the base via [`Mode::Swing`].
overlay: Option<Overlay>,
/// The victim **wound-flinch** in flight (decision 0111) — the client's per-bone **SECONDARY
/// blend slot**: a decaying cross-fade overlay (`λ` smoothstep `0.75 → 0` over the wound
/// clip's own span) layered over whatever else plays, then self-releasing. Deliberately a
/// separate slot from [`Self::overlay`] — in the client they are two independent per-bone
/// slots (primary arm vs secondary blend), so a masked swing and a wound decay **coexist**;
/// folding the wound into the one-shot slot is exactly the falsified 0107 routing.
wound: Option<Wound>,
/// Whether the current airborne arc launched **upward** — a jump (the JumpStart/Jump bracket)
/// vs a step-off fall (the gait freeze). Decided from `vertical_speed` on the arc's first
/// airborne frame — the MSG_MOVE_JUMP-vs-StartFalling distinction the real client carries as
/// an event, read off the launch velocity in our flag-collapsed machine. Stale on the ground.
jump_arc: bool,
/// FALLING was set last frame — marks the arc edges: takeoff (decide [`Self::jump_arc`]) and
/// the landing of a bracket-less step-off fall (which must still run the `0x602c60` land pick
/// even though no Special drove the arc).
was_falling: bool,
/// The **deferred combat one-shot** — the client's `CGUnit+0xd60` cache (wow-re
/// `combat-anim-fastpath.md`, decision 0406): a combat clip requested while another combat
/// clip plays is NOT armed — the playing clip's rate doubles (op6 2.0f) and the request
/// parks here, played by the driver the moment no one-shot is live (the client's
/// base-recompute read). Any normal arm clears it (the client's `0x5fe48e` writes −1 on
/// every non-fast-path PlayAnimation; the driver clears at its play sites).
deferred: Option<u16>,
/// The ranged standing idle has completed one pass of its **Load** clip — the idle now
/// plays the drawn HOLD twin ([`select::ranged_hold_anim`]) until a one-shot (the next
/// shot's release) resets the nock. INTERIM (decision 0400 §2's dispatch, Q4 — the
/// `[+0xd58] |= 0x400` hold layer's stand-in).
ranged_held: bool,
/// The armed looping arm's **replay window** (decision 0516 — wow-re `loop-replay-fidget`
/// §7/§7d): the variation node the loop armed + its rolled budget `R` (the client's
/// `block+0xbc`; the window is `R` clip-lengths wide, op4 `0x7126d8` — live for LOOPS too,
/// correcting 0117's "loops ignore it"). The per-frame watchdog (`0x719370`'s transcription
/// in `drive_animations`) re-arms the id when the node — still the MAIN armed animation —
/// completes `R` passes: the fresh weighted pick that alternates a gryphon's flap/glide and
/// walks a multi-part dance. `None` = the main arm is a one-shot (its budget is a `Count`
/// repeat) or a deliberate freeze (ranged Load / Loot).
loop_window: Option<(bevy::animation::graph::AnimationNodeIndex, u32)>,
}
impl AnimDriver {
/// The `AnimationData` ids currently driving this unit — `(base, masked overlay)` — the
/// inspector card's anim readout. The base is whatever occupies the full-body slot (gait /
/// special / landing / swing), as the *requested* ids before any missing-clip substitution
/// (the layer the selectors chose — also what the reconcile tests, decision 0125); the
/// overlay is a masked upper-body one-shot playing beside it, if any. A `None` base = no
/// gait selected yet (first frame).
pub(crate) fn playing(&self) -> (Option<u16>, Option<u16>) {
let base = match self.mode {
Mode::Gait => self.gait,
Mode::Entering(s) => Some(s.enter()),
Mode::Looping(s) => Some(s.loop_id()),
Mode::Exiting(_, id) => Some(id),
Mode::Land { id, .. } => Some(id),
Mode::Swing { id, .. } => Some(id),
};
(base, self.overlay.map(|o| o.id))
}
}
/// A masked upper-body play over the base ([`AnimDriver::overlay`]): the graph node it drives
/// ([`AnimClip::upper_node`]) and the requested `AnimationData.dbc` id (for the event scan +
/// the sheath reconcile — both must see this play, decision 0087 (d)). `looping` marks the cast
/// hold's sustained variant (decision 0107): a one-shot auto-releases when its clip finishes; a
/// looping hold never finishes and is released only by [`driver::drive_animations`]'s hold logic
/// (the [`CastHold`] gone, or the unit stopping — the hold then moves to the gait slot).
#[derive(Clone, Copy)]
struct Overlay {
node: bevy::animation::graph::AnimationNodeIndex,
id: u16,
looping: bool,
}
/// A wound-flinch decay in flight ([`AnimDriver::wound`], decision 0111): the graph node the
/// blend drives (the clip's masked [`AnimClip::upper_node`], or its full-body node when the
/// bone-selection forces bone 0 — [`select::wound_full_body`]) and the decay window's inputs.
/// The client seeds `end = clock + span, rate = 1/span, λ₀ = 0.75` (op4 `linkFlag=0`,
/// `0x712647-0x712682`); the driver's per-frame upkeep replays that decay off the clip's own
/// playback clock and stops the node when it expires — the kernel's self-release (`0x7147b9`).
#[derive(Clone, Copy)]
struct Wound {
node: bevy::animation::graph::AnimationNodeIndex,
/// The wound clip's full length (seconds) — the decay denominator (the client's `1/span`).
span: f32,
/// Masked to the upper-body subtree — where the base *and* a live one-shot overlay both
/// blend, so the λ-anchoring weight must count them ([`select::wound_weight`]'s `others`).
masked: bool,
}
impl Default for AnimDriver {
fn default() -> Self {
Self {
mode: Mode::Gait,
gait: None,
sheath_cur: None,
sheath_byte: None,
sheath_swap: None,
overlay: None,
wound: None,
jump_arc: false,
was_falling: false,
deferred: None,
ranged_held: false,
loop_window: None,
}
}
}
impl AnimDriver {
/// The `AnimationData.dbc` id of the clip this unit is currently playing (target gait, a Special's
/// enter/loop/exit one-shot, or Death — which the death override records as the gait). `None` before
/// the first selection. The mouse pick reads it for the **current animation's** bounds sphere — the
/// real client's broad-phase volume tracks the playing sequence (wow-re pick-volume RE).
pub(crate) fn active_anim(&self) -> Option<u16> {
match self.mode {
Mode::Gait => self.gait,
Mode::Entering(sp) => Some(sp.enter()),
Mode::Looping(sp) => Some(sp.loop_id()),
Mode::Exiting(_, exit) => Some(exit),
Mode::Land { id, .. } => Some(id),
Mode::Swing { id, .. } => Some(id),
}
}
/// The **resolved** id of the clip actually driving playback right now (decision 0082):
/// [`Self::active_anim`] (the selector's *requested* semantic id) run through the model's own
/// fallback resolution. This is what the real client's per-animation sheath reconcile tests
/// (`0x5fdb50` reads the record of the sequence **actually playing**, not the semantic pick that
/// was asked for) and what the event-fire path needs (a clip crossed via resolution is the one
/// whose timeline is actually advancing). [`Self::active_anim`] itself stays the raw requested id
/// — the state machine's `Mode`/`gait` identity comparisons key on the *selector's* choice, which
/// resolution must not perturb (two candidates that happen to resolve to the same baked substitute
/// are still a different selection). `catalog` absent (a brief window before `AnimationData.dbc`
/// loads) degrades to identity, matching [`find_resolved`]'s own degrade.
pub(crate) fn resolved_anim(
&self,
anims: &ModelAnimations,
catalog: Option<&AnimDataCatalog>,
) -> Option<u16> {
self.active_anim().map(|id| resolved_id(anims, id, catalog))
}
/// The unit's client-side sheath state (0 stowed · 1 melee · 2 ranged) — the committed CUR
/// cache the placement renders and the Z toggle flips. `None` until first driven.
pub(crate) fn sheath_state(&self) -> Option<u8> {
self.sheath_cur
}
/// Whether a draw/stow ceremony is in flight — the manual toggle's mid-ceremony debounce
/// (guards 11–12 of the client's `ToggleSheath` chain, decision 0080d).
pub(crate) fn sheath_ceremony_active(&self) -> bool {
self.sheath_swap.is_some()
}
}
/// Drives unit animation from movement state. Runs after [`WorldStage::Net`] (where the net bridge updates
/// splines and the controller writes our avatar's state), so what it reads is the current frame's.
pub(crate) struct CreatureAnimPlugin;
impl Plugin for CreatureAnimPlugin {
fn build(&self, app: &mut App) {
twist::plugin(app);
global_seq::plugin(app);
lod::plugin(app);
pose::plugin(app);
compose::plugin(app);
app.add_message::<AnimSoundEvent>()
.add_message::<SwingMessage>()
.add_message::<SwingImpact>()
.add_message::<SwingFlush>()
.add_message::<DefenseAnim>()
.add_message::<SwingSlowdown>()
.init_resource::<PendingImpacts>()
.init_resource::<PlaySeq>()
.add_message::<EmoteAnim>()
.add_message::<MountFlourish>()
.add_message::<WoundAnim>()
.add_message::<SheathSwapMessage>()
.add_message::<SheathRequest>()
.add_message::<CastEvent>()
.add_message::<SpellGoTargets>()
.add_message::<SpellKitSound>()
.add_message::<SpellKitFx>()
.add_message::<MissileSpawn>()
.add_message::<KitPush>()
.add_message::<HardLanding>()
.add_systems(
Startup,
(
load_anim_data,
load_spell_visuals,
load_blood_tables,
load_env_damage_table,
)
.after(AssetSet::Open),
)
.add_systems(
Update,
(
route_cast_visuals,
// The lootable-corpse sparkle's edge watcher — a SpellKitFx writer like the
// router above, consumed by the same entity-visuals chain this set precedes.
arm_loot_fx,
// Its level-edge sibling: the ding (decision 0305) — same shape, same home.
arm_level_up_fx,
// The aura-slot watcher: state kits persist for the aura's life (the bread).
arm_aura_state_fx,
blood_spurts,
// The landing predictor's dust leg (the vocal leg lives in `sound`).
hard_landing_dust,
emote_to_anim,
// The flourish hop (unit → mount child) — before the driver so the
// one-shot lands the same frame the packet (or space press) arrived.
flourish_to_anim,
drive_animations,
drive_hand_grip,
fire_anim_events,
// After the event scan: consume this frame's impact tags; the SwingImpact
// consumers (blood, flinch, floating text) read it next frame — noise against
// the ~300–600 ms deferral itself.
route_swing_impacts,
// The `$BWP`/`$BWR` nock latch, same-frame off the same scan — before the
// entity-visuals chain so the arrow appears/vanishes the frame the keyframe
// lands, not one behind.
drive_nock_latch,
)
.chain()
.after(WorldStage::Net)
// After Input so a sheath request (the Z toggle) executes the same frame.
.after(WorldStage::Input)
// Before the entity-visuals chain: [`VisualSheath`] writes must be applied
// before `resolve_equipment` reads them, or a sheath transition double-swaps
// the weapon placement for a frame (the flash).
.before(crate::entities::EntityVisualsSet),
);
}
}
/// Resolve `id` to the id this model actually plays (decision 0082, wow-re `anim-id-resolution.md`):
/// [`ModelAnimations::resolve`] via `catalog` when `AnimationData.dbc` has loaded, else identity — a
/// brief window at startup ([`load_anim_data`](sheath::load_anim_data) runs at `Startup`, so this
/// degrade is only ever live for the first few frames, same shape as `anim_data.map_or(0, ..)`
/// elsewhere in this module).
fn resolved_id(anims: &ModelAnimations, id: u16, catalog: Option<&AnimDataCatalog>) -> u16 {
catalog.map_or(id, |cat| anims.resolve(id, cat).id)
}
/// The clip for a *requested* id, resolved through the model's own baked fallback first (decision
/// 0082) — so a model lacking `id` exactly still plays its baked substitute (e.g. the chicken's
/// Attack2H request landing on AttackUnarmed) instead of coming back empty.
fn find_resolved<'a>(
anims: &'a ModelAnimations,
id: u16,
catalog: Option<&AnimDataCatalog>,
) -> Option<&'a AnimClip> {
anims.find(resolved_id(anims, id, catalog))
}
#[cfg(test)]
mod nock_latch_tests {
use super::*;
/// The `$BWP`/`$BWR` nock cycle (wow-re `nocked-ammo-cancel.md` §G1/G4): the pull keyframe
/// latches (only a unit with a cached ammo display), the release keyframe clears — the arrow
/// appears as she draws it and leaves the hand with the shot.
#[test]
fn bwp_latches_and_bwr_clears_the_nock() {
let mut app = App::new();
app.add_message::<AnimSoundEvent>();
app.add_systems(Update, drive_nock_latch);
let unit = app.world_mut().spawn(NockedAmmo { display_id: 5996 }).id();
app.world_mut().write_message(AnimSoundEvent {
entity: unit,
ident: *b"$BWP",
data: 0,
});
app.update();
assert!(
app.world().entity(unit).contains::<NockLatch>(),
"$BWP latches a unit with a cached ammo display"
);
app.world_mut().write_message(AnimSoundEvent {
entity: unit,
ident: *b"$BWR",
data: 0,
});
app.update();
assert!(
!app.world().entity(unit).contains::<NockLatch>(),
"$BWR clears — the arrow leaves with the shot"
);
// No cached ammo display → the pull nocks nothing (the client's handler gates on the
// cached ranged spell).
let bare = app.world_mut().spawn_empty().id();
app.world_mut().write_message(AnimSoundEvent {
entity: bare,
ident: *b"$BWP",
data: 0,
});
app.update();
assert!(
!app.world().entity(bare).contains::<NockLatch>(),
"no ammo display, no latch"
);
}
}