//! 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, nocked: Query>, 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::(); } } _ => {} } } } /// 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, 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, } /// 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 }, } /// 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, 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, mut out: MessageWriter, mut play_seq: ResMut, 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, /// 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, /// [`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, /// 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, /// 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, /// The pending mid-animation weapon swap, while a draw/stow one-shot is in flight. sheath_swap: Option, /// 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, /// 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, /// 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, /// 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, Option) { 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 { 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 { 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 { 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::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .init_resource::() .init_resource::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .add_message::() .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::(); 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::(), "$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::(), "$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::(), "no ammo display, no latch" ); } }