Files
benilla-octo/crates/benilla-app/src/creature_anim/select.rs
T
SamandClaude Fable 5.1 71d1677205 the fire layer sounds, heartbeats blend, and freezes refuse the flinch
- the burning city was silent: the director, at a Stratholme pin (map 329, `3471.91 -3366.89 136.85`), reported the burning-building flame layer missing — on the reference it swells as you approach a building — and that is the ambient emitter pool's signature exactly, one channel per sound id positioned at whichever of its emitters is nearest the listener (1867): 88 placements of `StratholmeFireSmokeEmberM/S.m2`, WMO props of `stratholme_b.wmo` with zero render batches, each authoring one `$DSL(6437)` StratholmeFireSmokeEmberSLoop; measured at the pin, the pool's four channels were held by UndeadCampfireSmall / CauldronLoop / TorchLoop / SlimeWaterfall, every one past its own DistanceCutoff, 0 sounding, the fire the fifth entitled entry and withheld by the cap, on a frame reporting 1,004 parked anim hosts each still holding a registration; two independent defects behind one line: (1) `doodad_events::fire_doodad_anim_events` scanned every resident host — no distance, frustum or portal gate — while the four channels are arbitrated by ascending entry index, claim order, so a surplus registration is not harmless but silence; the reference's event scan `0x719370` is reached only from `0x7074b0`'s walk of `[CM2Scene+0x20]`, a worklist emptied every frame (`0x707680`) and refilled by `0x710b90(model, 1)` from the terrain drain `0x683f80`, which walks only the bucket the scene-walk cull `0x683700` appended to — frustum, horizon occlusion, and the radius-tiered horizontal fade whose `alpha ≤ 0` is a hard cutoff — so a culled doodad's `$DSL` never fires and it never takes one of the pool's 32 entries; our prose — at four sites — had read wow-re's "linkage" as "loaded", and the scanner now honours `DoodadAnimHost::active`, benilla's one spelling of that membership; which made the pin MORE silent, because `gate_doodad_anim`'s meshless branch built its own `EmitterFade { instance: None, room: None }` on the reasoning that a particles-only model is not a building's prop — Stratholme's fire is 88 of them — so it fell to `ExteriorGate::admits_sphere`, and a sealed room is `Windows([])`, which admits nothing; a placement now has ONE draw-set gate, built once by `spawn::fx::emitter_fade` and handed to its emitters, ribbons, glow lights and anim host alike (a transport's props pass `None` and get `EmitterFade::sphere`, the single spelling of the bare form), so a meshless WMO prop's bones, particles and sound freeze and resume together under its building's portal PVS — which wow-re then CONFIRMED: a WMO `MODD` prop takes the same drain as an ADT `MDDF` prop (`0x684155`), behind a per-frame portal gate (`0x6838f0` runs only for the groups the traversal reaches); (2) the RE round that fix dispatched came back with a correction: we counted the cap on ENTITLEMENT, an out-of-range entry keeping its slot because FMOD was supposedly still playing it attenuated to nothing, and the binary says otherwise — `0x461b40` returns `[+0xE04] != 0 && 0x7a5810(ch) && !0x7a5870(ch)`, and `0x7a5870` is bit `0x100000` of `[ch+0x38]`, the DistanceCutoff verdict maintained by a second system the pump never names: `0x7a5ca0` registers the channel into a cull list only when the cutoff is non-zero, and `0x7a5000`, run immediately before `FSOUND_Update`, stops it out of range and re-issues `FSOUND_Stream_PlayEx` back in — so a far, inaudible ambience holds none of the four, neither does one whose start failed, and a cutoff of 0 is never range-culled at all; `admitted()` becomes `cap_step()`, one step of the reference's own fold over ascending entry index — Skip / Service / Retire — with the counter advanced only by a channel read back live from the mixer (`source_kit_playing`), and the pump may service up to all 32 entries looking for four live ones, which is what keeps an out-of-range ambience positioned to start the instant it comes into range; the census names WHICH ids the cap withheld, an edge like any other — "1 withheld" was a count, and *which* is the only question a missing-layer report asks — and reports three states, `serviced [out of range]` costing the cap nothing versus `withheld`, never reached; and the event scan forgets its cursor across a park, since `0x719370`'s window is one frame wide off a clock that never stops and has no persisted cursor, so a culled doodad's missed markers are lost, never replayed — carrying the memory would have burst every `$DSO`/`$SND` the park skipped; retested at the pin: five entitled entries, four out of range, 6437 SOUNDING, nothing withheld, hosts in the animate set 28 → 184, and the log shows the entry claimed by a distant fire coming into view then following to the nearest record 150 ms later — the swell the director described; the Goldshire forge control is unchanged, BellowIN/BellowOut alternating through one entry, 1 sounding; `$DSO`/`$SND` one-shots from culled doodads stop too, equally faithful; and the variation re-roll stays ungated on purpose — gating it would re-roll a whole field's lightning on the frame you turned back, a look for the director to weigh (2059, 2065)
- a poured particle is born already aged: the spawn kernels' third argument is the SUBSTEP dt, not the lifespan — the burst loop pushes a literal 0 (`0x7b5600`), both continuous loops push dt (`0x7b5779` interpolated, `0x7b57e2` plain), and the kernels store `particle.age = U01 · w` (`0x7b88c4`/`0x7b88c7`, byte-identical across the plane, sphere and spline kernels) — and benilla birthed everything at 0.0, so the births sharing one frame arrived in lockstep; `birth_age` now seeds a poured particle at a random fraction of the frame it was born in, which is why a steady stream does not pulse at the frame rate, while a burst's N particles keep one clock and age as one body; read as the LIFESPAN instead, `w` would scatter a burst over `[0, lifespan)` — half the integrated brightness, half the mean radius, half every steady pool's population — and an audit here proposed exactly that reading, which wow-re's own measured count (N = rate × lifespan = 300, confirmed) had already ruled out and the bytes refute, so `a_burst_births_every_particle_at_age_zero` pins it; one named approximation: the reference substeps at a hard 0.1 s (`[0x81d828]`), bounding its dt by construction, and benilla does not substep, so the frame delta is clamped at 0.1 s in `birth_age` instead — identical below 0.1 s, which is every frame that is not a hitch
- the wound-flinch gate is the freeze node, and the swing report stays open: the director's report of a warrior in a many-mob brawl whose swings read as more suppressed and cut short by incoming impacts than the reference's had survived a full re-read of the victim side with every mechanism matching, and one reference gate benilla did not model had turned up on the way — the wound trigger `0x60ea70` refuses the flinch while any CEffect node on the victim's `+0xb4` list carries `+0x2c & 0x4` (`0x60eab6`); wow-re's cross-check pinned the bit's one setter image-wide, `0x6201d0`, the node the kit CharProc applier `0x60d7c0` creates for CharProcType 11: it saves the owner's playback rates (mount root, body key-bone 4, body root), overrides them with `CharParamZero` via `0x712910` and ORs the bit in, and the teardown `0x6203e0` restores them — the freeze node 0889 already models as `AuraNodes::head_anim_rate`, seen from the other end; while it lives every flinch is refused — melee, spell, missile, `$HIT`; in shipped data 17 kits carry a proc 11, 15 at rate 0.0, all referenced only as state kits — Freezing Trap, Ice Block, Petrify, Entangle, web wrap, the frozen-in-place family, and not the stuns (kits 349 / 33 carry none) — and the gate is the node's presence, kit 3071's rate of 1.0 closing it exactly like 0.0; a level-60 warrior brawl spawns none of them — Bloodrage, Berserker Rage, Enrage, Death Wish, Battle Shout, Demoralizing Shout, Sunder Armor, Heroic Strike, Rend, an incoming Renew or Power Word: Shield, the creature Strike/Cleave/Thunderclap family, a mage's Frost Nova (state kit 285) — so this gate does not explain the report; the lead is dead, verified, and the report stays open with the move trace (`WOW_MOVE_TRACE_TAGS=fct,anim`) now carrying every wound trigger, evict and expire beside every swing play and fast-path, so the per-swing flinch count and timing can be read off one run instead of guessed at; the driver refuses the wound while `AuraNodes::head_anim_rate()` is `Some` — the node's presence, not its rate — traced as `wound trigger id= REFUSED (a proc-11 rate node is attached)`; `KitPlay::wound` runs the kit tail's `[8,10]` branch for a discrete play and not for the impact hand-off's state stage (`0x60f383`: a state kit runs the base recompute only); 2058's `0x6ea280` hostility-classifier decode is CONFIRMED (`Targets & 0x100 → 1`, `& 0x80 → 2`, else the per-effect implicit-target tables `0x6ea338`/`0x6ea378`); tests: a rate-0 node and a rate-1.0 node both refuse the flinch and no node flinches, an instant harmful `SMSG_SPELL_GO` lays exactly one wound per hit target with a state kit naming CombatWound adding nothing, a helpful one lays none, and a missile arrival wounds regardless of hostility; named and not done: an effect model's own `$HIT` event flinching the unit it is attached to (`0x61f6f0`), and the reflect impact on the caster (`0x6e8cb0`) (2063)
- tag `0x26` is the teleport's, not the heartbeat's: wow-re's cross-check of the relay family — one client handler `0x603bb0` → `OnUnitMoveEvent 0x601580`, thirty opcodes — confirmed 2061's teleport claim and corrected an attribution this client had rested on since 0601: the queued move-event node's tag that `0x619030` (facing interp) and `0x619090` (position reconcile) both bail on (`cmp [eax+0xc], 0x26` at `0x61904b`) belongs to `MSG_MOVE_TELEPORT`, and the heartbeat's is `0x25`; benilla had excluded the heartbeat from both blends for two years and blended the teleport — which it never parsed, so nothing contradicted it; checked on this side before acting: `push 0x26` occurs at exactly two addresses in the movement region, `0x6186bd` and `0x618736`, inside functions whose only callers are `0x602f90` and `0x602fb0`, and `0x602fb0`'s other branch pushes `0xc7` = `MSG_MOVE_TELEPORT_ACK`; it was most wrong exactly where it mattered most — the heartbeat is the ~500 ms pulse a moving player sends, and a mover running straight sends nothing else carrying position (0617), so a watched player running in a straight line had their position snapped at 2 Hz instead of blended, a second cause, of our own, for the "2 Hz height staircase" 0626/1545 were built around (the ground clamp stays: our floor and the server's genuinely differ, and no blend fixes a missing collider); `reconciles()` is now `verb != Teleport`, and the three opcode-derived booleans this was heading toward are one closed `RelayVerb` enum — `Pose` / `Heartbeat` / `Teleport` / `Root(bool)` — so every receiver has to say what it does with each — nothing had forced a reader to enumerate what an opcode could mean, which is how the tag mistake lived as long as it did; two more corrections from the same cross-check: `MSG_MOVE_ROOT`/`MSG_MOVE_UNROOT` are opcode-authoritative — after the merge the client runs `SetRoot 0x7c7340` (`or 0x1000`, then the one-shot motion wipe `& 0xffe07f00`) or `ClearRoot 0x7c7370` unconditionally, the flags word getting no vote; vmangos always sends a word that already agrees (`MovementHandler.cpp:1070`), so it changes nothing today and stops the root depending on the server continuing to be careful — and a relayed flags word is MERGED, not assigned (`new = old ^ ((old ^ wire) & 0x75a07dff)` at `0x618de7`), which the self lane already did and the remote lane did not; still consequence-free, since nothing reads the one client-owned bit, ON_TRANSPORT, off `RemoteMotion::flags`, but lifted to `move_flags::merge_server_authored` so both lanes share one implementation; pinned both ways — the teleport skips, Heartbeat / Pose / Root all arm, and a deliberately contradictory `MSG_MOVE_ROOT` whose word says running roots the mover and wipes the direction bits anyway; open from the same round: the transport re-anchor (`0x618b30 → 0x7c6900`), which benilla reaches by a different route and is worth proving equivalent, and `MovementInfo +0x04`, which the reference neither parses nor zeroes and whose readers were not censused (2064)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 16:07:44 +07:00

1273 lines
76 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.
//! 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 [`super::driver`].
use benilla_assets::AnimClip;
use bevy::prelude::*;
use crate::net::{RemoteMotion, Spline};
/// Fallback walk speed (yd/s) for a unit whose movement block didn't carry one — vanilla's default
/// creature walk speed. The run boundary is 2× this (`RecomputeBaseAnim`, RF-0057).
pub(super) const DEFAULT_WALK_SPEED: f32 = 2.5;
/// Fast-run cutoff (yd/s): at or above it the unit plays the sprint animation (id 143) ahead of Run
/// (`[0x80c484]` in the real selector, RF-0057).
const FAST_RUN_SPEED: f32 = 11.0;
/// Below this ground speed (yd/s) a streamed mover counts as standing still — guards a near-zero residual.
const MOVING_EPSILON: f32 = 0.1;
pub(super) const STAND: u16 = 0;
pub(super) const DEATH: u16 = 1;
/// ShuffleLeft (11) / ShuffleRight (12) — the **turn-in-place foot-shuffle**, picked below when
/// the unit turns without translating. Named because their *lifecycle* is unlike any other gait:
/// once armed they are held to their own clip window rather than released when the turn ends
/// (decision 1655, wow-re `object-layer/scratch/turn-shuffle-lifecycle.md`), and the driver has
/// to be able to say so.
pub(super) const SHUFFLE_LEFT: u16 = 11;
/// See [`SHUFFLE_LEFT`].
pub(super) const SHUFFLE_RIGHT: u16 = 12;
/// StealthWalk (119) — the **prowl creep**, and with [`STEALTH_STAND`] the entire difference
/// stealth makes to a body's *pose*. Byte-verified (wow-re `rf57-movement-anim-select.md`, the
/// 2026-07-18 §5): both ids are gated by the same descriptor bit — `[[unit+0x110]+0x213] & 2`,
/// `UNIT_FIELD_BYTES_1` byte 3's CREEP flag ([`crate::net::ObjectStore`]'s `unit_is_stealthed`) —
/// 119 from the core cascade at `0x5fd1d3`, tested **after** the backward branch and **before the
/// whole speed tail**, so a prowling unit never plays Run or Sprint however fast it travels.
///
/// No aura, no visual kit, no spell id is involved. Stealth's *translucency* is its aura state
/// kit's CharProc-14 (decision 0806) and its *pose* is this flag: two independent mechanisms off
/// one server byte, which is exactly why the body could go translucent and still stand bolt
/// upright. (The same flag's other three read sites are nameplate/marker suppression — it drives
/// no body render at all; wow-re `ghost-death-visuals.md`.)
///
/// **119 is NOT rate-scaled.** The client's locomotion-rate whitelist `0x5fee80` is exactly
/// `{4,5,11,12,13,37,38,39,42,43,44,45,135,143,187}` (byte-verified, §5-agreed; wow-re
/// `rf57b-rate-jump-standstate.md`) and 119 is absent — the creep cycle plays at 1× whatever the
/// live speed, so [`playback_rate`] leaves it alone.
pub(super) const STEALTH_WALK: u16 = 119;
/// StealthStand (120) — the prowl idle, from the **last** resolver in the chain (the fallback idle
/// `0x5fd830`: swim-idle → `[110]+0x213&2` → jump/float → plain Stand). Being last is its
/// precedence: every other resolver — the sit/sleep/kneel and chair stand-states, the turn-in-place
/// shuffle, the combat Ready idle, loot — outranks it. See [`STEALTH_WALK`] for the shared gate.
pub(super) const STEALTH_STAND: u16 = 120;
/// Mount (91) — the rider's seated pose while a mount model is attached (decision 0441;
/// byte-verified, wow-re `mount-composition.md` B1): held **unconditionally** — moving, turning,
/// airborne. The client arms it outside the base selector (once at attach by `0x607a00`, then
/// re-forced on every `PlayAnimation` by `0x5fe2f0`'s mounted branch — there is NO mount leg in
/// the `0x5fd8b0` chain); benilla pins the gait slot instead, the same rendered result. The
/// locomotion the selector would pick plays on the mount model (same `0x5fd100` thresholds),
/// via the mount child's own driver pass. Variations roll as any base loop (HumanMale authors 3).
pub(super) const MOUNT: u16 = 91;
/// Loot (50) — the kneel-and-rummage held while a unit's loot window is open. Byte-verified end
/// to end (wow-re `loot-anim-leg.md`, the 2026-07-18 §5 + the 08-21 §5 trio; decisions 0515 /
/// 1471 / 1477): the `0x5fd8b0` chain's loot leg `0x5fd260` → 0x32 = 50, chain order locomotion →
/// **LOOT** → standState → combat/channel (movement outranks by position — the leg itself reads no
/// movement state), leg gates `[+0xdc]==0` (never mounted) + the `[+0xd58]&0x40` enable.
///
/// The leg needs **two** predicates, and both split on IsActivePlayer:
/// - **A `0x6126b0` — is a session open.** Self: the client-local loot-target latch
/// (`[player+0x1d28]`, ours [`crate::ui_loot::LootLatch`]) — armed at the `CMSG_LOOT` send for a
/// corpse, at `SMSG_SPELL_GO` for a chest, and at an admitted `SMSG_LOOT_RESPONSE` otherwise.
/// Remote: [`UNIT_FLAG_LOOTING`] set and [`UNIT_FLAG_LOOT_SUPPRESS`] clear.
/// - **B `0x612710` — is that KIND of target knelt at.** Self: a per-object-class filter, ours
/// [`crate::ui_loot::LootKneel`]. It is why a fishing bobber arms the latch and still does not
/// kneel, and 1471 shipped without it.
///
/// One consequence worth stating plainly, because it looks like a bug: on vmangos, other players
/// never see us kneel at a chest. `Player::SendLoot` sets `UNIT_FLAG_LOOTING` only for
/// `LOOT_CORPSE`, and the remote half has nothing else to read. The clip is authored
/// **clamp** (HumanMale: one 0.5 s sequence, no variations): kneel down, freeze in the rummage
/// pose; the rise back is the ordinary cross-fade to Stand when the trigger drops. Weapons stow
/// for free — row 50 carries the `WeaponFlags & 4` force-stow the per-animation sheath
/// reconcile already applies.
pub(super) const LOOT: u16 = 50;
/// `UNIT_FIELD_FLAGS` bit `0x400` — `UNIT_FLAG_LOOTING` (vmangos `UnitDefines.h`: "Displays loot
/// animation", set by `Player::SendLoot` for corpse loot, removed at `DoLootRelease`): the
/// **remote** half of the loot-kneel predicate (`0x6126db shr 0xa; and 1` — see [`LOOT`]); the
/// self unit reads its latch instead.
pub(super) const UNIT_FLAG_LOOTING: u32 = 0x400;
/// `UNIT_FIELD_FLAGS` bit `0x1000_0000` — must be CLEAR for a remote unit's loot kneel
/// (`0x612710`, byte-verified; the §5's one INFERRED residue is this bit's Blizzard *name*, not
/// its role). vmangos never sets it on players, so the gate is inert on our wire — carried for
/// the faithful predicate shape.
pub(super) const UNIT_FLAG_LOOT_SUPPRESS: u32 = 0x1000_0000;
/// MountSpecial (94) — the mounted flourish (the horse rears; decision 0441 P2). Plays on the
/// MOUNT model as a one-shot (§5-verified, wow-re `mount-composition.md`: MountSpecial routes to
/// the mount via the same `0x5fe2f0` op4 target as its locomotion), never on the rider — the
/// rider holds [`MOUNT`] throughout. Fired by the space-bar gate (self, locally at send time)
/// and by `SMSG_MOUNTSPECIAL_ANIM` (observed riders; our own echo is dropped in the net drain).
pub(crate) const MOUNT_SPECIAL: u16 = 94;
/// Movement direction/mode flag bits, matching the client's CMovement `MOVEMENTFLAGS` (cached at
/// `unit+0x9e8`; VERIFIED wow-5875-re RF-0057 + the jump §5 cross-check). The selector tests these
/// exactly as the binary does, so a streamed unit can eventually drop the server's raw `u32` straight in.
pub(crate) mod move_flags {
pub const FORWARD: u32 = 0x1;
pub const BACKWARD: u32 = 0x2;
pub const STRAFE_LEFT: u32 = 0x4;
pub const STRAFE_RIGHT: u32 = 0x8;
/// TURN-left / TURN-right — the keyboard turn keys (vanilla A/D when not mouse-looking). The client
/// rotates the facing while these are set (`0x7c4f30` heading integrate) and plays the turn-in-place
/// foot-shuffle (11/12) when turning with no translation. Strafe (above) slides without turning.
pub const TURN_LEFT: u32 = 0x10;
pub const TURN_RIGHT: u32 = 0x20;
/// WALK-mode (vs run). In the client this only scales the rate numerator (walk vs run speed), never
/// the id choice. The net bridge reads it to extrapolate a `/walk`-toggled remote mover at walk speed.
pub const WALK_MODE: u32 = 0x100;
/// MOVEFLAG_LEVITATING — **the free-flight bit, and it works by SUPPRESSION.** It is the very
/// first test in the client's per-frame swim decision `0x6030c0` (`0x6030d2 test ah,4` → bail to
/// `0x6031fa`; VERIFIED, wow-re `collision/scratch/swim-transition.md`): set, and the whole
/// water/depth decision is skipped — neither the ENTER arm nor the STOP arm runs, so liquid can
/// no longer latch *or unlatch* [`SWIMMING`].
///
/// That suppression is the entirety of GM flight. vmangos's `.cheat fly` sends
/// `LEVITATING | SWIMMING | MOVED | FLYING` (`Player::SetFly`): `SWIMMING` puts the mover in the
/// 3-D floating regime (gravity bypassed, vertical from the aim pitch), and `LEVITATING` stops
/// the dry-land depth test from clearing it again the very next frame. `MOVED` (`0x800000`) and
/// `FLYING` (`0x1000000`) carry no behaviour on this build — vmangos's own header marks both
/// doubtful — and we model neither. Decision 0726.
pub const LEVITATING: u32 = 0x400;
/// JUMPING/FALLING — set for the whole airborne arc; drives the jump Special state.
pub const FALLING: u32 = 0x2000;
/// MOVEFLAG_FALLINGFAR — the arc has become a **far fall** (`0x633220`/`0x633240`; wow-re
/// `land-anim-height-gate.md`, legs corrected by decision 0179): a *jump* (launch vz ≠ 0)
/// latches once it descends 1/9 yd below its launch height; a *step-off fall* (launch vz = 0)
/// latches at 500 ms airborne (≈ 2.41 yd of free fall — a fence hop never latches, a
/// wagon-height drop latches just before the floor). Cleared only with FALLING at StopFalling
/// (`0x7c6290`). Mid-air it swaps the pose to Fall(40) and it opens the landing-anim gate; a
/// flat jump never descends below its takeoff, so its hang stays Jump(38).
pub const FALLING_FAR: u32 = 0x4000;
/// MOVEFLAG_ROOT — the server rooted this mover (death, until release). The controller MUST
/// carry it in the root-apply ack's MovementInfo (vmangos `HandleMoveRootAck:715` KICKS a
/// root-apply ack without it — live-verified 2026-07-11, `Movement.log`), and moving bits
/// must never accompany it (they freeze the real client).
pub const ROOT: u32 = 0x1000;
pub const SWIMMING: u32 = 0x20_0000;
/// MOVEFLAG_ONTRANSPORT — standing on a boat/zepp (1.12 bit 25, vmangos `MovementInfo.h:56`
/// `0x02000000`; NOT the TBC-era 0x200). Set while the mover rides a transport's platform
/// frame; every packet carrying it also carries the local-pose tail (decision 0438 phase 2).
pub const ON_TRANSPORT: u32 = 0x0200_0000;
/// MOVEFLAG_WATERWALKING — the liquid surface counts as walkable ground (Water Walking,
/// Levitate, and the ghost form; decisions 0308/0866). Reference consumers: the liquid-mask
/// selector `0x6315f0` (`0x63160d test eax,0x10000000`, taken *only* when not swimming) and the
/// opcode-0x22 apply `0x61a430`.
pub const WATER_WALKING: u32 = 0x1000_0000;
/// MOVEFLAG_SAFE_FALL — **feather fall** (Slow Fall, Levitate; decision 0866). It has exactly
/// one effect: the fall-velocity query `0x7c5d20` picks its terminal clamp on this bit
/// (`0x7c5d23 test [ecx+0x40],0x20000000`) — 7.0 yd/s `[0x87d898]` instead of the ordinary
/// 60.148 `[0x87d894]`. wow-re's ledger labels it "in-water; selects swim gravity", which is the
/// bit's *shape* read without the server-side name: vmangos sets it only from
/// `SPELL_AURA_FEATHER_FALL`, and swimming is the separate [`SWIMMING`] (0x200000).
pub const SAFE_FALL: u32 = 0x2000_0000;
/// MOVEFLAG_HOVER — the body rests 1.0 yd above the ground (Levitate; decision 0866). VERIFIED
/// as hover rather than a wade bit in wow-re `system/collision/collision.md`: the WALK resolver
/// `0x6367b0` gates a `[0x7ff9d8]` = +1.0-yd surface offset on it, and the step-down reach
/// widens by the same yard (`0x633e35`).
pub const HOVER: u32 = 0x4000_0000;
/// The bits a **server-authored move packet owns**, and the only ones it may write — the
/// reference's flag *merge* mask, VERIFIED at `0x618c30 @0x618deb`
/// (`new = old ^ ((old ^ wire) & 0x75a07dff)`; wow-re `self-addressed-move.md`, decision 0725).
/// Applying a `MSG_MOVE_*` is not an assignment: bits outside this mask are kept from local
/// state, because they are the client's own (`0x618c30` also holds a second mask,
/// `0x75a01dff`, for a caller arm that passes a non-zero 4th arg — the plain state family
/// passes zero and takes this one).
///
/// Two of ours land outside it and the omissions are the point. **[`ON_TRANSPORT`]** (bit 25)
/// is client-owned: a server-authored pose relocates you, it never boards or deboards you —
/// which is why the self-move arm re-anchors the ride rather than dropping it. Bit 27 (the
/// reference's free-advance selector) and bit 31 (its clock baseline) are CMovement internals
/// with no benilla analog. Every bit we *do* model besides `ON_TRANSPORT` — the direction and
/// turn bits, walk mode, root, [`FALLING`]/[`FALLING_FAR`], swim, water-walk — is inside.
pub const SERVER_AUTHORED: u32 = 0x75a0_7dff;
/// Merge a server-authored packet's `MOVEMENTFLAGS` into a mover's own — the reference's masked
/// merge (`0x618c30 @0x618de7-df3`: `new = old ^ ((old ^ wire) & 0x75a07dff)`), **not** an
/// assignment. One law, both movers: our own (`player::wire_in`'s self-addressed pose) and every
/// watched one (`net::motion::remote::apply_move`), because the reference runs this merge inside
/// the one scheduler both go through and the mask is arm-invariant across all thirty relay
/// opcodes (wow-re `collision/scratch/movement-relay-family-map.md`, decision 2064).
///
/// The omission that bites is pinned by test on the self lane: [`ON_TRANSPORT`] sits **outside**
/// the mask, so a server-authored pose can relocate a rider but never board or deboard them.
pub const fn merge_server_authored(local: u32, wire: u32) -> u32 {
(local & !SERVER_AUTHORED) | (wire & SERVER_AUTHORED)
}
/// Any horizontal-movement direction bit (forward/back/strafe) — the client's `[9e8] & 0xf` gate.
pub const ANY_MOVE: u32 = FORWARD | BACKWARD | STRAFE_LEFT | STRAFE_RIGHT;
/// **Whether this mover is integrated at all** — the client's `0x20ff`, and the gate a mover
/// must pass before any physics runs on it. `CMovement::Update 0x616de0` opens its substep loop
/// with `0x616e20 test dword [esi+0x40],0x20ff; je 0x616f49` (none set ⇒ finalize, having
/// integrated nothing), and the movement manager tests the same mask before it ever calls the
/// integrator (`0x6166f5`) — the "idle gate" decision 0059 named by address. wow-re states the
/// consequence for a watched player outright: *"for a **STANDING remote** the integrator does
/// NOT run … a flag-less unit is not even in the mover list"*
/// (`collision/scratch/remote-air-facing.md` §A4; the loop gate is in `collision/collision.md`
/// and `scratch/spec-driver-A.md`/`-B.md`).
///
/// So a mover with none of these bits keeps the pose its last packet wrote, verbatim, until a
/// packet or a flag moves it — which is what decision 1545 makes benilla do. The bits: the four
/// direction bits, the two keyboard turn bits, and the two pitch bits benilla does not model
/// (together `0xff`), plus [`FALLING`] (`0x2000`). Note what is **absent**: [`SWIMMING`]
/// (`0x200000`) and every mode bit — a floating swimmer holding no direction is not integrated
/// either.
pub const INTEGRATED: u32 = 0xff | FALLING;
/// The **committed-lower-body** move mask that routes a one-shot to the masked overlay (decision
/// 0087): the client's `[9e8] & 0x20003f` test at `0x5fe6dc` — every direction bit (`0x3f`,
/// which folds in the keyboard turn keys) plus swim (`0x200000`). Any of these set ⇒ the legs are
/// committed, so a swing/emote plays masked over them rather than full-body. (The client's
/// *separate* facing-delta turn test `d58 & 0x1800` — a mouse turn-in-place with no move flag —
/// is a field benilla does not model; keyboard turning is already covered here via `0x30`.)
pub const ROUTE_COMMITTED_MOVE: u32 =
FORWARD | BACKWARD | STRAFE_LEFT | STRAFE_RIGHT | TURN_LEFT | TURN_RIGHT | SWIMMING;
/// The stationary-cast pin's "moving" test — the client's `[9e8] & 0x20000f` in the `0x5fde80`
/// cast-override gate (wow-re `spell-visual-apply.md` §2.1, VERIFIED): the direction bits plus
/// swim, and NOTHING else. The turn bits are absent — a caster turning in place (keys or a
/// mouselook body-step) keeps the full-body pin. Distinct from [`ROUTE_COMMITTED_MOVE`]'s
/// `0x20003f` (the one-shot route test at `0x5fe6dc` — a different byte site, a different
/// mask); using that mask here made the pin flap at mouse-event cadence — the transient
/// chase-step TURN flags demoted the hold to shuffle+overlay on every mouse-delta frame and
/// re-pinned on every quiet one (decision 0491, the frostbolt right-drag jitter).
pub const CAST_PIN_MOVE: u32 = ANY_MOVE | SWIMMING;
}
/// How fast a strafing unit's rendered root yaw eases toward its offset heading — the client blends
/// the display facing a **quarter of the remaining gap per frame** (`0x607ed0` tail, `×0.25`
/// `[0x8029b0]`); at the reference 60 fps that is the continuous exponential rate
/// `−ln(0.75) × 60 ≈ 17.3 /s` (we take the time-based form rather than aping the frame-rate
/// dependence). Applied by [`ease_strafe_yaw`], the pose owners' one blend.
const STRAFE_BLEND_RATE: f32 = 17.26;
/// Advance a unit's rendered yaw toward `aim + offset` by easing the **aim-relative offset** as a
/// plain scalar ([`STRAFE_BLEND_RATE`] exponential). For a stationary aim this is the same curve as
/// easing the absolute yaw — EXCEPT at the strafe flip (±90° → ∓90°), where the absolute
/// shortest-arc is an exact 180° tie that float noise resolves to either side ("sometimes it spins
/// around the back" — director). Offset space has no tie: the swing always passes through the aim,
/// i.e. around the front, and the spine/head twist gap unwinds through 0 instead of snap-flipping
/// at ±180°.
pub(crate) fn ease_strafe_yaw(current_yaw: f32, aim: f32, offset: f32, dt: f32) -> f32 {
let cur = super::wrap_pi(current_yaw - aim);
let eased = cur + (offset - cur) * (1.0 - (-STRAFE_BLEND_RATE * dt).exp());
super::wrap_pi(aim + eased)
}
/// The strafe **body-heading offset** (radians) a unit's rendered root yaw sits from its aim — the
/// client's display-facing strafe blend (wow-5875-re `body-facing-pipeline.md` §3, `0x607ed0` tail):
/// ±π/2 for a pure strafe, ±π/4 when forward/back is also held (`flags & 3`). Yaw is left-positive
/// (WoW orientation and our Bevy yaw alike), so strafe-left is `+` — **mirrored while backpedaling**
/// (the client's sign fold `((flags>>1)&2) == (flags&2)`): a back-left diagonal faces the body
/// forward-right and backpedals along the movement line, legs never crossing. `0` when not strafing
/// (both strafe bits set cancel — no lateral motion results, unlike the client's left-bit-only fold,
/// an unobservable edge). The [`super::twist::BodyTwist`] counter-twist then walks the upper body
/// back toward the aim.
pub(crate) fn strafe_body_offset(flags: u32) -> f32 {
use std::f32::consts::{FRAC_PI_2, FRAC_PI_4};
let left = flags & move_flags::STRAFE_LEFT != 0;
let right = flags & move_flags::STRAFE_RIGHT != 0;
if left == right {
return 0.0;
}
let diagonal = flags & (move_flags::FORWARD | move_flags::BACKWARD) != 0;
let magnitude = if diagonal { FRAC_PI_4 } else { FRAC_PI_2 };
let back = flags & move_flags::BACKWARD != 0;
if left != back {
magnitude
} else {
-magnitude
}
}
/// The **swim body pitch** — the one render law, for every mover that has a reported pitch
/// (TU-A, decision 0464 §1; `0x60a110`→`0x710620`, the Euler-ZYX builder).
///
/// The reference composes `T·S·Rz(facing)·Ry(2π − pitch)`: yaw about WoW +Z, pitch about WoW +Y
/// from `CMovement+0x20`, +pitch = nose up. That is this expression, worked through the basis
/// rather than assumed — WoW +Z → Bevy +Y (so `Rz(facing)` is `Quat::from_rotation_y`, the mapping
/// `net::motion::wire_yaw` already rests on, no 180° offset) and WoW +Y → Bevy **−X**
/// (`coords::wow_bevy_basis_is_golden`), so `Ry(2π − pitch)` = `Ry(−pitch)` about −X is `+pitch`
/// about Bevy +X. Same multiplication order, so the pitch stays body-local; and `Rx(+θ)` carries
/// Bevy forward (−Z) to `(0, sin θ, −cos θ)`, i.e. up — nose-up positive on both sides.
///
/// The facing it reads is `+0xc94`, the **render** facing rather than the raw movement one
/// (`+0x1c`); for a swimmer the two coincide, because SWIMMING is on the display-facing SNAP list
/// (`mov [esi+0xc94],[esi+0xc98]`, wow-re `body-facing-pipeline.md`) — the same gate our callers
/// apply before handing a yaw in.
///
/// The gate is `SWIMMING` **and** a forward/back bit: strafe-only, idle, and grounded all render
/// **level** (the ground path). It is `CMovement+0x40 & 3`, read at `0x60857d` off the very word
/// the create-block apply and every relayed `MSG_MOVE_*` merge into under `0x75a07dff` — **not**
/// a `CM2Model+0x3c & 3` dirty gate, which is what an earlier reading of this law named and which
/// a wow-re §5 has since scanned for exhaustively and found does not exist (`0x710620` is an
/// unconditional 16-dword copy). Recorded in wow-re
/// `system/collision/scratch/create-block-swim-pitch.md`.
///
/// One function rather than one per pose owner: the law has three consumers — our own avatar
/// ([`crate::player::body_pose`]), an observed relay mover
/// ([`crate::net::motion::extrapolate_remote_units`]), and the create-block seed
/// ([`crate::net::apply::objects`]) — and TU-A is explicit that all of them present the *same*
/// pitch. Held apart, they drift: the remote lane shipped in July 2026 with no test and no trace
/// of its own, so "does an observed swimmer tilt?" had no answer short of the director's eye.
pub(crate) fn swim_body_rotation(yaw: f32, flags: u32, pitch: f32) -> Quat {
if flags & move_flags::SWIMMING != 0
&& flags & (move_flags::FORWARD | move_flags::BACKWARD) != 0
{
Quat::from_rotation_y(yaw) * Quat::from_rotation_x(pitch)
} else {
Quat::from_rotation_y(yaw)
}
}
/// The per-frame movement descriptor that drives a unit's animation (wow-5875-re RF-0057). The player
/// controller fills it for our avatar from input; a streamed unit's is derived from its [`Spline`].
#[derive(Component, Clone, Copy, Default)]
pub(crate) struct MovementState {
/// Live movement speed (yd/s) — `0` while standing. The rate-scaler's numerator and the
/// walk/run threshold read this; it is already the *directional* speed (runBack when
/// backpedaling). On the ground it's the horizontal speed; for a swimmer it's the flag-scalar
/// 3D travel speed (swim/swimBack regardless of pitch — a vertical stroke plays at full rate).
pub(crate) speed: f32,
/// Vertical speed (yd/s, +up); non-zero while airborne. Its sign on the arc's FIRST airborne
/// frame splits a **jump** (upward — the JumpStart/Jump bracket) from a **step-off fall** (the
/// gait freeze) — the information the real client carries as the MSG_MOVE_JUMP event vs a bare
/// StartFalling, which our flag-collapsed machine reads from the launch velocity instead.
pub(crate) vertical_speed: f32,
/// CMovement `MOVEMENTFLAGS` subset (see [`move_flags`]).
pub(crate) flags: u32,
/// Stand-state (`UNIT_FIELD_BYTES_1` byte 0): 0 Stand · 1 Sit · 3 Sleep · 4/5/6 chair low/med/high ·
/// 8 Kneel — drives the idle pose / the sit-sleep-kneel transition while standing.
pub(crate) stand_state: u8,
/// **Stealthed** — `UNIT_FIELD_BYTES_1` byte 3's CREEP bit, the gate on [`STEALTH_WALK`] /
/// [`STEALTH_STAND`]. Stamped by the driver from the unit's OWN descriptor every frame, self
/// and remote alike (the client reads `[[unit+0x110]+0x213]` at select time), so [`unify`]'s
/// legs never touch it: unlike the stand state there is nothing to predict controller-side —
/// the crouch waits on the server's aura, exactly as the reference does.
pub(crate) stealthed: bool,
/// Riding a **flying** server spline (the taxi flight): the client's selector reads the active
/// CMovement's spline flags (`[[unit+0x118]+0xa4]+0x18`) and plays Fly 135 when the wire Flying
/// bit (0x200) is set — RF-0057 `0x5fd19c`. Stamped by [`unify`] from the entity's live
/// [`Spline`] each frame (the same read-through the client does); the controller's stored
/// component leaves it `false`.
pub(crate) flying: bool,
}
/// A transition-bracketed animation state — a one-shot **enter** → **loop** → one-shot **exit**, as
/// opposed to the directly-cross-faded gaits. The ids are from the verified selector + the HumanMale.m2
/// sequence table (wow-5875-re; decisions 0047/0049).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum Special {
/// Airborne from a **jump** (the arc launched upward): JumpStart(37) → Jump(38) hang.
/// **VERIFIED** (the swim-hop §5, wow-re `swim-jump-anim-law.md`): `0x60e480` case 0xbb
/// (MSG_MOVE_JUMP) arms **37 directly**, local and observed alike, and 38 follows only by
/// 37's own 833 ms window elapsing — a short arc (the ~0.24 s deep swim hop) never shows
/// 38, which the Entering→`oneshot_finished` machine reproduces. (rf57b's dispatcher
/// divergence is settled: `0x5fc3f0`'s events 0x25/0x26 → 38 is the separate
/// emote/anim-event dispatcher, not the jump path.) FALLINGFAR
/// latching mid-arc hands off to [`Special::Fall`]; the landing is [`jump_land_pick`]. A
/// **step-off fall** never enters here — its gait freezes (the base selector's keep-current,
/// via the takeoff-frozen flags/speed) until FALLINGFAR latches.
Jump,
/// Airborne and **fallen far** (FALLINGFAR latched): the Fall(40) loop, entered *directly* —
/// the client plays 40 the tick the flag latches (`0x602c40`), no enter one-shot — so
/// `enter_special` skips [`Mode::Entering`] for it. Lands like Jump, via [`jump_land_pick`].
Fall,
/// A stand-state pose keyed on the standState value (1 Sit · 3 Sleep · 8 Kneel): down → loop → up.
Pose(u8),
}
impl Special {
/// The one-shot played on entry (JumpStart / SitDown / SleepDown / KneelDown). Fall has no
/// enter — `enter_special` sends it straight to its loop; its arm here is unreachable, kept
/// for the exhaustive match.
pub(super) fn enter(self) -> u16 {
match self {
Special::Jump => 37,
Special::Fall => 40,
Special::Pose(1) => 96,
Special::Pose(3) => 99,
Special::Pose(8) => 114,
Special::Pose(_) => STAND,
}
}
/// The looping middle (Jump hang / Fall / Sit / Sleep / Kneel).
pub(super) fn loop_id(self) -> u16 {
match self {
Special::Jump => 38,
Special::Fall => 40,
Special::Pose(1) => 97,
Special::Pose(3) => 100,
Special::Pose(8) => 115,
Special::Pose(_) => STAND,
}
}
/// The one-shot played on a *pose's* exit (SitUp / SleepUp / KneelUp). An airborne state's
/// landing never routes here — it is [`jump_land_pick`] (the `0x602c60` dispatcher) via
/// `Mode::Land`; the Jump/Fall arms are unreachable by flow, kept for the exhaustive match.
pub(super) fn exit(self) -> u16 {
match self {
Special::Jump | Special::Fall => STAND,
Special::Pose(1) => 98,
Special::Pose(3) => 101,
Special::Pose(8) => 116,
Special::Pose(_) => STAND,
}
}
/// Whether starting to move cuts this state's exit short. A pose's stand-up (SitUp / …) is abandoned
/// the instant the unit moves — you don't watch yourself stand up when you walk off; the gait
/// cross-fade carries the half-pose into the walk. Jump/Fall never reach [`Mode::Exiting`] any
/// more — their landing is [`Mode::Land`], a freely-overwritten pick (decisions 0083/0087) — so
/// their `false` here is vestigial, kept for the exhaustive match.
pub(super) fn interruptible_by_move(self) -> bool {
matches!(self, Special::Pose(_))
}
}
/// The jump-landing one-shot, from the flags at touchdown — the client's land dispatcher `0x602c60`
/// (wow-5875-re rf57b §2), transcribed: swimming → no landing anim (the recompute picks the swim
/// gait); stopped (`flags & 0xf == 0`) → JumpEnd **39**; moving — unless BACKWARD (`0x2`) or
/// WALK-mode (`0x100`) — → JumpLandRun **187**; a **backpedaling or walking** landing plays **no
/// one-shot at all**, dropping straight into the gait (the reference backpedals the instant it
/// touches down; 187 is a forward-run footplant and never plays backward — the "forward run flash
/// after a jump-then-hold-S" bug). `None` = no landing clip, go straight to `Mode::Gait`.
///
/// **A ROOTED arc end is not a landing** (decision 0880). A root or a stun caught mid-air ends the
/// fall where the body hangs — `SetRoot 0x7c7340`'s `StopFalling` clears FALLING with no ground
/// involved — and the reference suppresses the land packet on exactly that state
/// (`0x602df3 test ah,0x10` gating opcode `0xc9`). This dispatcher *is* that packet's consumer
/// (`0x602c60` runs on the FALL_LAND apply, local and observed alike), so no packet means no pick:
/// the pose starves to Stand instead of playing JumpEnd in mid-air. Same suppression, same byte
/// site, as the wire half in [`crate::player`]'s arc bookkeeping.
pub(super) fn jump_land_pick(flags: u32) -> Option<u16> {
use move_flags::*;
if flags & (SWIMMING | ROOT) != 0 {
None
} else if flags & ANY_MOVE == 0 {
Some(39)
} else if flags & (BACKWARD | WALK_MODE) == 0 {
Some(187)
} else {
None
}
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub(super) enum Mode {
/// Cross-faded ground/swim gaits + idle.
Gait,
/// Playing a Special's enter one-shot; settles into its loop when the one-shot finishes.
Entering(Special),
/// Looping a Special; plays the exit one-shot when its condition clears.
Looping(Special),
/// Playing a *pose's* exit one-shot (SitUp/SleepUp/KneelUp — the `Special` being left + the exit
/// clip id). Returns to Gait when it finishes — unless a new Special, or movement, interrupts it
/// first. (Jump's landing is [`Mode::Land`], not this — its bracket was killed per decision 0083.)
Exiting(Special, u16),
/// The jump **landing** as a plain, freely-overwritten pick (decisions 0083/0087 (d)):
/// [`jump_land_pick`] chooses from the input *at touchdown* (`flags`) — JumpEnd 39 stopped,
/// JumpLandRun 187 moving forward; a backpedal/walk landing plays no clip and never enters this
/// mode — and re-picks the instant any movement flag changes, so land-then-press runs
/// immediately, land-then-release stands immediately, and a direction flip at landing shows no
/// stale-direction flash. Plays through to Gait only if the input holds steady. Not a
/// non-preemptible bracket.
Land { id: u16, flags: u32 },
/// Playing an action one-shot **full-body on the base track** (the bone-0 route of decision
/// 0087): a standing melee swing (decision 0073), a standing `/cheer`-class emote — or a
/// **mid-air cast/emote** (decision 0864: the airborne route test masks only COMBAT ids, so a
/// jump-in-place cast replaces the hang on bone 0, exactly the ref's full-body mid-air cast).
/// `under` is the Special whose base clip this play replaced (`None`: it replaced a gait).
/// While `under` is airborne the one-shot obeys the client's airborne-freeze: a finished clip
/// clamps and holds its last frame, flag changes are keep-current no-ops, and only a Special
/// *edge* exits — the FALLINGFAR latch's Fall (played ONCE, at the latch edge
/// `0x61a820` — 0864's per-tick re-assert was §5-refuted, decision 0868; a clip armed
/// after the latch holds like any other) and the `0x602c60` land pick at touchdown. Grounded
/// (`under == None`) it returns to Gait when it finishes; a new one restarts it — and any
/// movement-flag change since the **base** was last armed
/// ([`AnimDriver::gait_flags`](crate::creature_anim::AnimDriver)) re-picks immediately: the
/// client's base re-arm overwrites bone 0 blindly on the change (the same re-arm decision 0280
/// named), so a shot fired standing yields to the run the instant the player moves instead of
/// sliding the clip out. It carried its own arm-time flags until 0894, which made the edge
/// invisible whenever the one-shot and the movement change landed on the same frame. The
/// *masked* route never enters here — it plays as an overlay beside `Mode`, leaving the base
/// machine (this enum) running underneath.
Swing { id: u16, under: Option<Special> },
}
/// The gait an idle/moving unit should play in [`Mode::Gait`], most-specific id first with fallbacks so a
/// model lacking the ideal clip steps down rather than snapping (RF-0057 core, minus the Special states —
/// jump and the sit/sleep/kneel poses are handled by the state machine). `walk_speed` is the unit's own
/// walk speed (the run boundary is 2× it). `ready` is the engaged combat idle (decision 0073), played
/// only when standing on the ground — locomotion outranks it, and a swimming engaged unit treads
/// water (the client's own gate order).
pub(super) fn gait_candidates(
state: &MovementState,
walk_speed: f32,
ready: Option<u16>,
ranged_load: Option<u16>,
) -> &'static [u16] {
use move_flags::*;
let f = state.flags;
// Swimming (RF-0057 core, `[9e8] & 0x200000`) — the VERIFIED `0x5fd100` cascade (the swim §5's
// TU-E, wow-re `swim-mechanism.md`): **TURN > STRAFE > BACKWARD > FORWARD**, ids turn→41,
// strafeL(0x4)→43 / strafeR(0x8)→44 (SwimLeft/SwimRight — names byte-read from
// AnimationData.dbc), back→45, fwd→42, idle→41. So a turning swimmer treads water whatever its
// travel bits, and a strafe diagonal (fwd+strafe, back+strafe) plays the side-stroke, not 42/45.
if f & SWIMMING != 0 {
return if f & (TURN_LEFT | TURN_RIGHT) != 0 {
&[41, 0]
} else if f & STRAFE_LEFT != 0 {
&[43, 42, 41, 0]
} else if f & STRAFE_RIGHT != 0 {
&[44, 42, 41, 0]
} else if f & BACKWARD != 0 {
&[45, 41, 0]
} else if f & FORWARD != 0 {
&[42, 41, 0]
} else {
&[41, 0]
};
}
// A flying server spline — the taxi ride (RF-0057 `0x5fd19c`, between the swim block and the
// backward test in the byte cascade): the spline's wire Flying bit selects Fly 135 outright,
// before the backward and speed branches — a 32 yd/s taxi plays Fly, never Sprint 143. The
// one-step fallback mirrors AnimationData (Fly → 0 Stand); every shipped taxi mount authors
// real Fly clips (the gryphon carries two variations).
if state.flying {
return &[135, 0];
}
// Backward dominates strafe (RF-0057 `[9e8] & 2` → WalkBackwards 13).
if f & BACKWARD != 0 {
return &[13, 4, 0];
}
// Stealthed and moving — the prowl (RF-0057 `0x5fd1d3`, sitting between the backward branch and
// the speed tail; [`STEALTH_WALK`]). The gate outranks the ENTIRE speed tail, so a stealthed
// unit creeps at any speed, while a *backpedaling* one plays WalkBackwards — backward is tested
// first. The Walk 4 fallback is `AnimationData.dbc`'s own Fallback for row 119, which is also
// what the baked PlayableAnimationLookup resolves 119 to on a model lacking the clip: HumanMale
// and NightElfFemale author both stealth clips, `druidcat.m2` authors NEITHER (119→4, 120→0), so
// a prowling cat shows its ordinary walk — in the reference too, through the same resolver.
if state.stealthed && f & ANY_MOVE != 0 {
return &[STEALTH_WALK, 4, 0];
}
// Ground gaits by live speed (RF-0057 core): fast-run ≥ 11, run > 2× walk, else walk.
// (Strafe currently routes here to Run/Walk; the dedicated-shuffle question is under §5 verification.)
if f & ANY_MOVE != 0 {
let s = state.speed;
return if s >= FAST_RUN_SPEED {
&[143, 5, 4, 0]
} else if s > 2.0 * walk_speed {
&[5, 4, 0]
} else {
&[4, 0]
};
}
// Turning in place (the turn keys with no translation): the foot-shuffle (RF-0057 `0x5fd3f0` →
// ShuffleLeft 11 / ShuffleRight 12). Only reached when not moving — moving with a turn curves the
// run path and plays the gait above.
if f & TURN_LEFT != 0 {
return &[SHUFFLE_LEFT, STAND];
}
if f & TURN_RIGHT != 0 {
return &[SHUFFLE_RIGHT, STAND];
}
// Standing + engaged in melee: the weapon-class Ready idle (decision 0073 — gated on engagement,
// not sheath state; outranks the chair poses, since a fighting unit isn't seated).
if let Some(r) = ready {
return match r {
26 => &[26, 25, 0],
27 => &[27, 25, 0],
28 => &[28, 25, 0],
_ => &[25, 0],
};
}
// Standing in the ranged stance with auto-repeat armed (the client's `0x5fd460`, gate
// `sheath CUR == 2 && [+0xd58] & 0x200` — local player only, decision 0099 phase 5): the
// ranged weapon's Load/Hold idle ([`ranged_load_anim`]). Placed after the engaged Ready
// (they can't co-occur — auto-shot never sets the engaged GUID) and before the chair loops
// (OUR ordering; the client's call-site order vs the chair resolver isn't pinned).
if let Some(l) = ranged_load {
// The Hold twins 109/110 ARE reachable (decision 1544): `0x5fd460`'s own jump table writes
// only 105/106/111/112, but a finished Load is promoted to its Hold by the completion
// dispatch (`0x5fc3f0` slot 11/12/15), and the caller passes whichever of the pair
// currently owns the pose. 0994 left them out on §J4.1's absence proof — that the
// dispatcher is never reached for a bow id — which wow-re's §5 has since refuted.
// Each Hold falls back to its own Load first: a model that authors the pull but not the
// hold should freeze at full draw, not drop to ReadyUnarmed.
return match l {
105 => &[105, 25, 0],
106 => &[106, 25, 0],
109 => &[109, 105, 25, 0],
110 => &[110, 106, 25, 0],
111 => &[111, 25, 0],
112 => &[112, 25, 0],
_ => &[25, 0],
};
}
// Standing: a chair-sit loop (server stand-states 4/5/6 — no down/up triple) else Stand. The
// transition-bracketed poses (1 Sit / 3 Sleep / 8 Kneel) are Special, handled before this is called.
// State 2 (generic SIT_CHAIR) is verified VESTIGIAL (decision 0280): the client's resolver row
// for 2 writes no override (`0x5fd644` returns a bool; the 0xd0 sentinel stands), so falling to
// plain Stand here is the faithful render, not a gap.
// Standing stealthed: the prowl idle ([`STEALTH_STAND`]) — placed *after* the chair loops, since
// its resolver (`0x5fd830`) is the last in the chain and the stand-state resolver precedes it.
match state.stand_state {
4 => &[102, 0],
5 => &[103, 0],
6 => &[104, 0],
_ if state.stealthed => &[STEALTH_STAND, STAND],
_ => &[STAND],
}
}
/// The local auto-repeat **standing idle** id — the client's `0x5fd460` → LUT `0x5fd530`
/// (byte-verified, wow-re `ranged-shot-anim.md`): the RANGED-slot item's subclass picks a held
/// Load/Hold clip — Bow → LoadBow 105 · Gun/Crossbow → LoadRifle 106 · Thrown → LoadThrown 112 ·
/// Wand → HoldThrown 111 · anything else → ReadyUnarmed 25. **Not** ReadyBow/AttackBow: no code
/// in the client plays those rows.
///
/// (This doc used to carry `ranged-shot-anim.md` §(a)'s "the per-shot caster fire animation is a
/// verified NEGATIVE — the shot shows only the missile". That was REFUTED twice over: by the
/// weapon-visual merge, which plays the fire clip off the weapon's own substitute visual
/// (decision 0370/0986), and by the completion dispatch below (decision 1544). It is gone rather
/// than hedged — a stale negative in a doc is how a session concludes the missing animation is
/// correct, which is exactly what happened to bug B307.)
pub(super) fn ranged_load_anim(ranged: Option<(u8, u8)>) -> u16 {
match ranged {
Some((2, 2)) => 105, // Bow → LoadBow
Some((2, 3) | (2, 18)) => 106, // Gun / Crossbow → LoadRifle
Some((2, 16)) => 112, // Thrown → LoadThrown
Some((2, 19)) => 111, // Wand → HoldThrown
_ => 25, // no/odd ranged item → ReadyUnarmed
}
}
/// Whether `id` is one of the ranged Load clips the drawn idle arms — the clips that must play
/// **once and freeze at full draw** rather than looping ([`ranged_load_anim`]'s outputs, minus
/// the wand's HoldThrown 111, which is already a hold pose).
pub(super) fn is_ranged_load(id: u16) -> bool {
matches!(id, 105 | 106 | 112)
}
/// The **Hold** clip a finished ranged Load promotes to — the completion dispatch `0x5fc3f0`'s
/// slot 11/12/15 arms: LoadBow 105 → HoldBow 109 · LoadRifle 106 → HoldRifle 110 · LoadThrown 112
/// → HoldThrown 111. The wand's HoldThrown 111 is already the hold and re-arms itself, and
/// anything else (ReadyUnarmed 25) holds nothing and stays put.
///
/// **The promotion is UNCONDITIONAL** (wow-re §5, decision 1544): the `[+0xd24]` ranged-prop and
/// `[+0xd58] & 0x600` test at `0x5fc5bc` belongs to slot **13** — the Hold's own re-arm — not to
/// the Load's slot 11, which is the bare `mov eax,0x6d ; push eax ; call 0x5fe2f0` at `0x5fc5e9`.
/// Reading that gate onto the Load is the mistake that would leave a shooter frozen at full draw.
///
/// The Hold is the only clip in the ranged cycle authored as a **loop** (asset flag, verified on
/// five shipped character models); the fire and Load clips are clamp. That is why it can sit
/// between shots at all, and why nothing needs to re-arm it per frame.
pub(super) fn ranged_hold_anim(load: u16) -> u16 {
match load {
105 => 109, // LoadBow → HoldBow
106 => 110, // LoadRifle → HoldRifle
112 | 111 => 111, // LoadThrown → HoldThrown; the wand's hold re-arms itself
other => other,
}
}
/// Does the drawn ranged Load idle own this unit's standing pose? Byte-verified, wow-re
/// `shooter-stop-law.md` §J6 claim 1 (§5, four independent pairs + byte arbitration):
///
/// `0x5fd460` — tier 9 of `ComputeAnimation 0x5fd8b0`, and the **only** writer of 105/106/111/112
/// image-wide — claims on exactly two tests and nothing else:
///
/// ```text
/// 5fd463: cmp dword [ecx+0xd40], 2 ; sheath CUR == RANGED
/// 5fd476: test ah, 0x2 ; [+0xd58] & 0x200 = auto-repeat active
/// ```
///
/// `0x200`'s only writer image-wide is the LOCAL cast-send `0x6e593b`, gated `AttributesEx2 &
/// 0x20` — so entry means *this client's player is actively auto-repeating*. **`0x400`
/// ([`super::RangedHold`]) is never tested in this function.** It appears only in `0x5fc3f0`'s
/// sustain gates (`test ah,0x6` for 109, `test ah,0x4` for 110/111/112), which §J4 shows never
/// run. Folding it into this gate is what let one Serpent Sting / Multi-Shot — any ranged-slot
/// spell whose visual sets the hold bit — leave the shooter aiming a drawn bow with nothing able
/// to clear it (director-reported 2026-08-05).
///
/// A REMOTE shooter never runs the local cast-send, so it never enters here. It is not simply
/// Stand, though (§J6 claim 2, CORRECTED): it plays **LoadBow(105) once** at the volley's single
/// `SMSG_SPELL_START`, through the PrecastKit — our [`super::CastHold`] path — then AttackBow per
/// GO over its ordinary idle. Never a sustained aim pose.
///
/// The caller applies the tier order: locomotion (tier 4) outranks tier 9, so a moving unit is
/// never in the idle whatever these bits say.
pub(super) fn ranged_idle_gate(auto_repeat: bool, sheath_cur: Option<u8>) -> bool {
sheath_cur == Some(2) && auto_repeat
}
/// The victim **defense-reaction** id (decision 0279 — the `$CPP` dispatch, byte-verified
/// `0x624a01` → `0x60ec00`): DODGE/DEFLECTS → Dodge(30) · BLOCKS → ShieldBlock(24) · PARRY keys
/// the victim's own **mainhand** through the `0x60ec98` LUT (read off `WoW.exe` `.text`, its
/// third distinct weapon bucketing after the swing and Ready tables): 1H axes/maces/swords/
/// exotic1H/misc **and dagger** → Parry1H(21) · 2H axes/maces/swords/exotic2H → Parry2H(22) ·
/// polearm/staff/spear → Parry2HL(23) · **fist → ParryUnarmed(20)** · ranged/obsolete/
/// subclass>0x11/non-weapon/empty → the client bails, no clip (`0x60ec3a`/bucket 4 → ret).
/// Every other victimState plays nothing.
pub(super) fn defense_anim(victim_state: u32, main: Option<(u8, u8)>) -> Option<u16> {
match victim_state {
2 | 8 => Some(30), // DODGE / DEFLECTS → Dodge
5 => Some(24), // BLOCKS → ShieldBlock
3 => match main {
Some((2, sub)) => match sub {
0 | 4 | 7 | 0xb | 0xe | 0xf => Some(21), // 1H family + misc + dagger
1 | 5 | 8 | 0xc => Some(22), // 2H family
6 | 0xa | 0x11 => Some(23), // polearm / staff / spear
0xd => Some(20), // fist → ParryUnarmed
_ => None, // ranged/obsolete/oddball: bail
},
_ => None, // empty or non-weapon mainhand: bail
},
_ => None,
}
}
/// The **play-time unarmed-special substitution** — the third weapon substitution in the client,
/// and the only one that lives inside `PlayAnimation` itself rather than in a selector
/// (`0x5fe2f0` @ `0x5fe3cc`–`0x5fe3e9`, byte-verified: wow-re `disarm-weapon-gate-law.md` §7).
/// A requested **Special1H(57) / Special2H(58)** — the weapon-remapped spin a spell kit asks for,
/// Eviscerate's among them — becomes **SpecialUnarmed(118)** when `GetWeapon(0, 0)` *and*
/// `GetWeapon(1, 0)` are both NULL, i.e. both hands are empty to the combat reading.
/// `SpecialUnarmed`'s own `AnimationData.dbc` fallback column is 57, closing the ring.
///
/// The test is emptiness, not weapon-ness: a hand holding a non-weapon is non-NULL and keeps the
/// armed clip. Because it sits at the play seam it covers every requester — a genuinely
/// weaponless rogue and a disarmed one reach it by the same route (decision 1863).
pub(super) fn unarmed_special(id: u16, main: Option<(u8, u8)>, off: Option<(u8, u8)>) -> u16 {
if matches!(id, 57 | 58) && main.is_none() && off.is_none() {
118
} else {
id
}
}
/// The per-packet melee swing one-shot ids (decision 0073's tables) — what the whiff slow-down
/// (decision 0279) is allowed to touch when it finds them on the masked overlay.
pub(super) fn is_swing_id(id: u16) -> bool {
matches!(id, 16..=19 | 85 | 87 | 88 | 117)
}
/// The client's COMBAT-anim classifier (`0x5fcc10` — wow-re `combat-anim-fastpath.md` §3, the
/// exhaustive byte-decoded table, cross-confirmed against `anim-composition-model.md`): the gate
/// on `PlayAnimation`'s combat fast-path (decision 0406). A combat clip requested while another
/// combat clip is playing is NOT armed — the current clip's rate doubles and the request defers.
/// Members: the swings (16–19 main, 85/86 dagger, 87/88/117 off), the specials (57/58/118 —
/// Eviscerate's weapon-remapped spin among them — and 59), the defenses (20–23 parry, 24
/// shield-block, 30 dodge), 10, 36, and Kick 95. The Ready idles (25–28) are NOT combat — a
/// swing over a ready idle arms normally.
pub(super) fn is_combat_anim(id: u16) -> bool {
matches!(id, 10 | 16..=24 | 30 | 36 | 57..=59 | 85..=88 | 95 | 117 | 118)
}
/// The client's **CAST** classifier (`0x5fcbb0` — byte-decoded in wow-re `oneshot-lifecycle.md`
/// §7, previously unlabelled): the spell-cast release anims `{2, 32, 33, 53, 54}`. Together with
/// [`is_combat_anim`] it is what the **transplant** predicate (`0x5feae0`) tests on the *currently
/// armed bone-0 clip*: a locomotion request over one of these does not replace it — the clip moves
/// up onto the key-bone at its live play position ([`OneShotRoute`]'s two slots, decision 0878).
/// NOT the ReadySpell holds (51/52) — those are their own set (`0x5fde40`), and a jump over a
/// standing hold really does take the whole body.
pub(super) fn is_cast_anim(id: u16) -> bool {
matches!(id, 2 | 32 | 33 | 53 | 54)
}
/// Whether `cands` (this frame's [`gait_candidates`] pick) is bare `[STAND]` — the one precedence
/// slot the looping **state-emote idle** (`UNIT_NPC_EMOTESTATE`: `/dance`, NPC cooking/sweeping
/// flavor loops) is allowed to fill instead, exactly where Stand itself would otherwise sit.
/// Everything that already outranks plain Stand keeps outranking it: swim/backward/moving/turning
/// return their own arrays above, the Ready idle requires `ready.is_some()` (i.e. engaged), and a
/// chair-loop stand-state (4/5/6) has its own arm before the default. `current_special` (jump, the
/// sit/sleep/kneel poses) is checked by the caller *before* [`gait_candidates`] is even invoked, so
/// this predicate never has to account for Specials itself — they simply never reach here. Movement
/// starting again drops straight out of the state-emote idle for the same reason Stand does: the
/// very next frame's `cands` is no longer bare.
///
/// The **prowl idle** counts as bare too: [`STEALTH_STAND`] comes from `0x5fd830`, the *last*
/// resolver in the client's chain, so anything with a resolver of its own outranks it. (OUR
/// ordering, on that structural argument — where the emote-state idle sits in the real chain is
/// unpinned; a stealthed unit holding an emote state is degenerate either way.)
pub(super) fn is_bare_stand(cands: &[u16]) -> bool {
cands == [STAND] || cands == [STEALTH_STAND, STAND]
}
/// The candidate array for a state-emote idle occupying the bare-Stand slot: the resolved anim id
/// first, `STAND` as the fallback should the model lack it — resolved through the same
/// [`super::find_resolved`] path as every other gait candidate (decision 0082). Call only when
/// [`is_bare_stand`] held for this frame's `cands`.
pub(super) fn state_emote_gait(emote_anim: u16) -> [u16; 2] {
[emote_anim, STAND]
}
/// The mainhand swing animation id (decision 0073 — byte-verified `0x6246a0`): keyed on the wielded
/// item's `(class, subclass)`. Anything that isn't an equipped melee weapon — empty hand, non-weapon
/// item, fist weapon, every ranged/wand, obsolete(9), unknown subclasses — swings AttackUnarmed(16).
pub(super) fn swing_anim_main(wielded: Option<(u8, u8)>) -> u16 {
match wielded {
Some((2, sub)) => match sub {
0x0 | 0x4 | 0x7 | 0xb | 0xe => 17, // Attack1H: 1H axe/mace/sword/exotic/misc
0x1 | 0x5 | 0x8 | 0xc => 18, // Attack2H: 2H axe/mace/sword/exotic
0x6 | 0xa | 0x11 | 0x14 => 19, // Attack2HL: polearm/staff/spear/fishing pole
0xf => 85, // Attack1HPierce: dagger
_ => 16, // AttackUnarmed: fist/ranged/wand/obsolete/unknown
},
_ => 16,
}
}
/// The offhand swing animation id (decision 0073, HitInfo bit `0x4`): a dagger stabs (88
/// AttackOffPierce), any other equipped weapon swings 87 (AttackOff), an empty/non-weapon offhand
/// punches (117 AttackUnarmedOff).
pub(super) fn swing_anim_off(wielded: Option<(u8, u8)>) -> u16 {
match wielded {
Some((2, 0xf)) => 88,
Some((2, _)) => 87,
_ => 117,
}
}
/// The draw/stow clip for one weapon slot, by the item's **sheathe-type** byte — the byte-verified
/// pick (wow-re `sheath-anim-pick.md`, 8 sites, byte-identical): `(1 << (type & 0x1f)) & 0x88` →
/// HipSheath(90) for types {3, 7}, Sheath(89) for everything else (back-mounts, shields, staves).
/// The slot's own byte is the *only* input — mainhand, offhand, shield and ranged all run the same
/// test, each on its own record.
pub(super) fn sheath_clip(sheath_type: u8) -> u16 {
if (1u32 << (sheath_type & 0x1f)) & 0x88 != 0 {
90
} else {
89
}
}
/// The ranged-handling anims exempt from the `&0x10` force-stow while ranged-drawn —
/// **byte-verified** (wow-re `ranged-sheath-exempt-autorepeat.md`, the 2026-07-15 §5): the
/// exemption is the predicate `0x5fe180` (sole caller `0x5fe04c`, the **CUR==2 path only**;
/// index table `0x5fe1a8`, targets `0x5fe1a0`), returning true for exactly these nine ids —
/// the ranged Load/Hold/Attack family. **ReadyThrown 108 is NOT exempt** and genuinely stows;
/// the real thrown wind-up survives by the snap **bracket** around every ranged kit play
/// (`driver`'s CastHold-ranged override), not by an exemption. The explicit trio compare at
/// `0x5fe037`–`0x5fe04a` (105/106/112) is a fast-path subset.
const SHEATH_RANGED_EXEMPT: [u16; 9] = [46, 49, 105, 106, 107, 109, 110, 111, 112];
/// The per-animation sheath reconcile (decision 0080 — the client's `0x5fdf80`, run after every
/// animation pick): given the unit's current sheath state and the playing clip's
/// `AnimationData.dbc` WeaponFlags, the state the policy *forces*, or `None` to leave it alone.
/// Priority-ordered, first match wins, every force is a SNAP:
///
/// 1. flag `&4` → stow (casts, swim, mount, sit-chair, loot);
/// 2. mounted → stow — the persistent mounted draw-block (`0x5fdfd9`: the reconcile forces
/// state 0 on every recompute while a mount model is attached, so weapons sit at their sheath
/// points and the manual toggle can never stick — wow-re `sheath-policy.md` §3, VERIFIED;
/// dismount does NOT restore the pre-mount state);
/// 3. flag `&0x10` → stow (emotes, unarmed attacks, sit-ground/kneel, bow/rifle/thrown shots) —
/// except the three ranged-handling anims while ranged-drawn;
/// 4. engaged **or** flag `&0x20` → draw melee (armed attacks, the Ready idles, fishing) — only
/// while not ranged-drawn;
/// 5. a remote unit with no force pulls back to the server's descriptor byte (`0x5fe16e`; the
/// local player's committed state is never server-reconciled).
pub(super) fn reconcile_sheath(
cur: u8,
anim: u16,
weapon_flags: u32,
engaged: bool,
local: bool,
server_byte: u8,
mounted: bool,
) -> Option<u8> {
if weapon_flags & 4 != 0 {
return Some(0);
}
if mounted {
return Some(0);
}
if cur == 2 {
if weapon_flags & 0x10 != 0 && !SHEATH_RANGED_EXEMPT.contains(&anim) {
return Some(0);
}
} else {
if weapon_flags & 0x10 != 0 {
return Some(0);
}
if engaged || weapon_flags & 0x20 != 0 {
return Some(1);
}
}
if !local && cur != server_byte {
return Some(server_byte);
}
None
}
/// Whether a **looping base arm** keeps the deterministic HEAD variation instead of rolling a
/// fresh one — the client's re-zero gate on the arm helper (`0x5fdba0`, wow-re
/// `loop-replay-fidget.md` §5b): every base (re-)arm carries `variationIdx = −1` (a weighted
/// roll) **unless** the unit has an auto-attack target, is holding a cast/channel, or the
/// *outgoing* clip was a combat/cast/ready id — then it is forced to `0` (the head). This is why
/// a relaxed unit "looks around" on each Stand re-arm while a fighting one holds one steady idle.
/// The outgoing-id families here approximate the client's four classifier calls
/// (`0x5fcc10`/`0x5fcbb0`/`0x5fde40`/`0x5fde60`, exact id sets unpinned): the melee swings
/// (16–19), the ready stances (25–29, the 0111-cited set), and the spell ready/cast poses
/// (51–54) — engagement and the cast hold carry the main weight regardless (decision 0123).
pub(super) fn arm_forces_head(engaged: bool, casting: bool, outgoing: u16) -> bool {
engaged || casting || matches!(outgoing, 16..=19 | 25..=29 | 51..=54)
}
/// The victim wound-flinch id (`0x60ea70`, decision 0111 §5.3 — byte-verified): decided **solely**
/// by `(severity, engaged)`. Crit (`HitInfo & 0x80`) → CombatCritical; the victim engaged in
/// melee (its auto-attack-target GUID set, `[unit+0xc48]`) → CombatWound; else StandWound.
pub(super) fn wound_anim(hit_info: u32, engaged: bool) -> u16 {
if hit_info & 0x80 != 0 {
10 // CombatCritical
} else if engaged {
9 // CombatWound
} else {
8 // StandWound
}
}
/// Whether the wound overlay covers the **full body** (the client forces op4's key-bone to `-1` =
/// bone 0) or stays **masked** to the upper-body subtree — the two byte-decoded mechanisms
/// (decision 0111 §5.2, `0x60eae8` / `0x60eb9a`):
///
/// - **(A), all ids:** the victim's current bone-0 pose is a combat-ready stance {25–29} — a
/// weapon-drawn victim standing between its own swings flinches full-body. `base_anim` is the
/// **resolved** id actually driving the base track (the client reads the armed record,
/// `[block0+0xf8]`), so mid-swing the base is the swing — not ready — and the flinch masks.
/// - **(B), StandWound(8) only:** genuinely stationary — the client's `[+0x118]+0x40 & 0x20200f`
/// (move/jump/swim; note the keyboard-turn bits `0x30` are **not** in the mask) — **and not
/// mounted** (the secondary-blend note's `[unit+0xdc]==0` companion clause; a mounted rider's
/// flinch never replaces the seat pose, decision 0441). The transport-substate companion is
/// still a state benilla doesn't model.
///
/// Everything else is masked: the legs keep the base animation untouched.
pub(super) fn wound_full_body(id: u16, base_anim: u16, flags: u32, mounted: bool) -> bool {
matches!(base_anim, 25..=29)
|| (id == 8
&& !mounted
&& flags & (move_flags::ANY_MOVE | move_flags::FALLING | move_flags::SWIMMING) == 0)
}
/// The wound overlay's decay amplitude λ₀ — the client seeds `+0x108 = 0.75f` on the op4
/// `linkFlag=0` path (`0x712682`, decision 0111): the flinch peaks at 75% wound, never fully
/// replacing the pose underneath. (A normal clip transition's outgoing-pose fade uses 1.0.)
pub(super) const WOUND_AMPLITUDE: f32 = 0.75;
/// The wound overlay's Bevy graph weight this frame (decision 0111): the client's kernel computes
/// `λ = smoothstep(t) · λ₀` with `smoothstep(t) = (3 − 2t)·t²` and `t` the remaining fraction of
/// the decay window (`0x714880`: t runs 1 → 0 over the clip's span, so λ **decays** λ₀ → 0 — then
/// the slot self-releases), and blends `out = primary + (secondary − primary)·λ`. Bevy's
/// normalized weighted blend gives `out = others·base/(others+w) + w·wound/(others+w)`, so the
/// weight that lands the blend exactly on λ is `w = others · λ/(1−λ)` — `others` being the total
/// weight of the other animations driving the same subtree. Bounded: λ ≤ 0.75 ⇒ `w ≤ 3·others`.
pub(super) fn wound_weight(remaining_frac: f32, others: f32) -> f32 {
let t = remaining_frac.clamp(0.0, 1.0);
let lambda = (3.0 - 2.0 * t) * t * t * WOUND_AMPLITUDE;
others * lambda / (1.0 - lambda)
}
/// The client's per-bone blend weight λ at amplitude 1.0 (`0x714880`–`0x714921`): `smoothstep(t)`
/// with `smoothstep(t) = (3 − 2t)·t²` and `t` the fraction of the blend window **still to run**,
/// so λ **decays 1 → 0** across it. Shared by the key-bone slot's two cross-fades (decision 0878):
/// the fade-to-rest that releases a finished one-shot over a fixed 150 ms (op4 `param_3 = −1`,
/// `0x7123af`: `+0x104 = 1/150`, amplitude `+0x108 = 1.0`) and the blended re-arm that cross-fades
/// a new masked clip in over the *incoming* sequence's own blendTime (`0x7125f2`). The wound's
/// twin ([`wound_weight`]) is the same curve at amplitude 0.75.
pub(crate) fn blend_lambda(remaining_frac: f32) -> f32 {
let t = remaining_frac.clamp(0.0, 1.0);
(3.0 - 2.0 * t) * t * t
}
/// The client's `_rand` — the MSVCRT LCG (`state × 214013 + 2531011`, output `(state >> 16) &
/// 0x7fff`; byte-verified wow-re `rf36-rand-stub.md` at `0x7400e5`) — the roll feeding op4's
/// per-play **variation pick** (`ModelAnimations::pick_variation`) and its **replay-count roll**
/// ([`replay_count`] — the second `_rand` site). Owned exactly rather than delegating to a host
/// RNG, per the determinism guidance in the same note; one stream shared by every play, like the
/// client's single CRT stream.
pub(crate) fn msvc_rand(state: &mut u32) -> u16 {
*state = state.wrapping_mul(214013).wrapping_add(2531011);
((*state >> 16) & 0x7fff) as u16
}
/// The per-arm **replay-count roll** (wow-re `loop-replay-fidget.md`, op4's second `_rand` site
/// `0x712692..0x7126cd`): `R = max(1, min + ⌊roll·(max−min)/32768⌋)` from the sequence's
/// `(minReplay, maxReplay)`. The client multiplies `R` into the play window (`0x7126d8`) — a
/// clamp-flag one-shot runs its timeline `R` times before freezing; loop-flag sequences ignore it.
/// Benilla expresses the same window as a repeat count on the one-shot play. `(0, 0)` → 1.
pub(super) fn replay_count(replay: (u32, u32), roll: u16) -> u32 {
let (min, max) = replay;
let extra = if max > min {
(u64::from(roll) * u64::from(max - min) / 32768) as u32
} else {
0
};
(min + extra).max(1)
}
/// The engaged standing idle (decision 0073 — the `0x5fd360` arm's weapon-class Ready pick,
/// `0x5fcdc0`). Note the buckets differ from the swing table: fist **and** dagger ready as 1H.
pub(super) fn ready_anim(main: Option<(u8, u8)>) -> u16 {
match main {
Some((2, sub)) => match sub {
0x0 | 0x4 | 0x7 | 0xb | 0xd | 0xe | 0xf => 26, // Ready1H (incl. fist + dagger)
0x1 | 0x5 | 0x8 | 0xc => 27, // Ready2H
0x6 | 0xa | 0x11 => 28, // Ready2HL
_ => 25, // ReadyUnarmed: ranged/obsolete/unknown
},
_ => 25,
}
}
/// The Special state a unit is in this frame, if any. Airborne splits three ways (wow-re
/// `land-anim-height-gate.md` + rf57b §2): FALLINGFAR latched → **Fall** (the 40 loop); else a
/// **jump** arc (launched upward, `jump_arc`) → Jump (the 37/38 bracket); else a **step-off fall**
/// → `None` — the base selector's keep-current freeze: the gait keeps playing off the
/// takeoff-frozen flags/speed until FALLINGFAR latches or the unit lands. Standing with a
/// transition-able stand-state (sit/sleep/kneel) → Pose; movement suppresses a pose.
pub(super) fn current_special(mv: &MovementState, jump_arc: bool) -> Option<Special> {
if mv.flags & move_flags::FALLING != 0 {
if mv.flags & move_flags::FALLING_FAR != 0 {
Some(Special::Fall)
} else if jump_arc {
Some(Special::Jump)
} else {
None
}
} else if mv.flags & move_flags::ANY_MOVE == 0 && matches!(mv.stand_state, 1 | 3 | 8) {
Some(Special::Pose(mv.stand_state))
} else {
None
}
}
/// Where a requested one-shot (a swing or an emote) is routed this play (decision 0087, wow-re
/// `anim-composition-model.md` §3/§5 at `923ac7bc`).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum OneShotRoute {
/// Onto the SpineLow-subtree **masked overlay** ([`AnimClip::upper_node`]): the torso plays the
/// clip while the legs keep whatever the base is doing (run / sit / jump-arc). `Mode` is untouched.
Masked,
/// **Full-body on the base track** (bone 0): the clip replaces the base, legs included. Standing
/// idle only — the clip's authored leg keys then read as-is (a swing lunges, a `/cheer` is
/// full-body, a `/clap`/`/bow` reads waist-up because the *asset* barely keys the legs).
FullBody,
}
/// The **CLASS_A** membership (wow-re `0x5fed90`) — the maskable-eligible set that gates the whole
/// state-route block; a non-CLASS_A id is always full-body. The load-bearing memberships were
/// byte-decoded (17/66/68/80 ∈; the 37–45 jump/swim/locomotion band excluded); the patchy interior of
/// the wide ranges is INFERRED (wow-re note §3 Open) but never load-bearing here — the only ids that
/// reach [`route_oneshot`] are swings, emotes, and the spell-kit cast anims (32/33/51–54 — wow-re's
/// own CLASS_A gloss: "swings, emotes, casts"), all squarely inside these ranges.
fn is_class_a(id: u16) -> bool {
matches!(id,
2 | 8..=10 | 14..=36 | 46..=49 | 51..=90 | 105..=113 | 117..=118 | 122..=138 | 185..=186 | 195)
}
/// The **COMBAT** membership (wow-re `0x5fcc10`) — the set whose airborne test can route to the mask
/// (a mid-jump swing). Byte-decoded memberships: **17 ∈**, and **66/68/80 ∉** (the emotes never mask
/// on airborne alone). Every swing id (16–19/85/87/88/117) is in it; no emote id is — and no CAST id
/// (32/33/51–54) either, which is why a jump-in-place cast routes FULL-BODY and replaces the hang
/// (decision 0864), while the same cast over a moving jump masks via the frozen-in move bits.
fn is_combat(id: u16) -> bool {
matches!(id, 10 | 16..=24 | 30 | 36 | 57..=59 | 85..=88 | 95 | 117 | 118)
}
/// The **forced-full-body** carve-outs (wow-re §3): Death-class `{1,6,131,132}` (`0x5fda90`) and the
/// sit-transition ids `{57,58,118}` (`0x5fec60`) route to bone 0 regardless of state. Kept faithful
/// though benilla never feeds these through the one-shot path.
fn is_forced_full_body(id: u16) -> bool {
matches!(id, 1 | 6 | 131 | 132 | 57 | 58 | 118)
}
/// Route a requested one-shot (swing/emote id) by the unit's **live state**, per play — the client's
/// `esi` decision `0x5fe6c8..0x5fe74d` (decision 0087, wow-re §3/§5): masked onto the SpineLow overlay
/// when the lower body is committed — **moving/turning/swimming** (`[9e8] & 0x20003f`), a **non-Stand
/// stand-state** (seated/sleep/kneel/chair, `standState ≠ 0`), or a **combat** id while **airborne**
/// (`activeCMovement+0x40 & 0x2000`); **full-body** on bone 0 when standing idle (none of those). The
/// id gates only *which* tests apply (CLASS_A gates the block; COMBAT gates the airborne test); it
/// never decides maskability by itself — the same Attack1H is full-body standing and masked running.
pub(super) fn route_oneshot(id: u16, flags: u32, stand_state: u8) -> OneShotRoute {
if is_forced_full_body(id) || !is_class_a(id) {
return OneShotRoute::FullBody;
}
let committed_lower = flags & move_flags::ROUTE_COMMITTED_MOVE != 0
|| stand_state != 0
|| (is_combat(id) && flags & move_flags::FALLING != 0);
if committed_lower {
OneShotRoute::Masked
} else {
OneShotRoute::FullBody
}
}
/// `AnimationData` ids whose playback rate the client scales by movement speed (wow-5875-re `0x5fee80`):
/// the locomotion gaits. An id outside this set plays at rate 1×. The **same** table is the
/// LOCOMOTION membership the transplant predicates key on ([`is_locomotion`]).
const RATE_SCALED: &[u16] = &[4, 5, 11, 12, 13, 37, 38, 39, 42, 43, 44, 45, 135, 143, 187];
/// The client's **LOCOMOTION** membership (`0x5fee80` — the very table the rate scaler uses):
/// what the transplant predicates test as "the clip being *requested* is a base locomotion clip"
/// (`0x5feae0`/`0x5fe912`, wow-re `oneshot-lifecycle.md` §3a). Note Fall(40) and SwimIdle(41) are
/// **not** members — a FALLINGFAR latch mid-cast replaces the cast on bone 0 rather than
/// transplanting it, exactly as the bytes order it.
pub(super) fn is_locomotion(id: u16) -> bool {
RATE_SCALED.contains(&id)
}
/// Whether the gait this movement state resolves to is a **locomotion** id — i.e. whether a
/// movement-flag re-arm is the transplant's `0x5fee80` request or an ordinary bone-0 overwrite.
///
/// The re-arm at a flag change has no id in hand yet (the gait is recomputed the following frame),
/// so this asks [`gait_candidates`] what it *will* pick. `ready`/`ranged_load` are passed `None`
/// deliberately and it changes no answer: both are **standing** idles that locomotion outranks in
/// the cascade, so a moving unit picks its locomotion id whether or not they are set, and a still
/// one picks something that is not locomotion either way.
///
/// This is the gate Ice Block turns on. A stun's root wipes the direction bits, the flag change
/// re-arms, and the pick is **Stand(0)** — *not* locomotion — so the cast one-shot on bone 0 is
/// **overwritten** rather than transplanted onto the torso: the character goes fully neutral before
/// the freeze catches it (decision 0894, the director's reading of the reference client).
pub(super) fn gait_is_locomotion(state: &MovementState, walk_speed: f32) -> bool {
gait_candidates(state, walk_speed, None, None)
.first()
.is_some_and(|id| is_locomotion(*id))
}
/// The playback rate for a clip given the unit's live speed and its rendered model scale — the
/// client's `0x5fe2f0` divide, VERIFIED byte-for-byte and §5 cross-checked (wow-5875-re
/// `anim-rate-divisor.md`, the canonical note; `0x5fe4be..0x5fe550`):
///
/// ```text
/// 5fe508 call 0x711a20 ; DIVISOR = ‖row0(M+0xbc)‖ · M2Sequence[reqId].moveSpeed
/// 5fe515 fcomp 0.0 ; jne ; GUARD A: divisor > 0 (else stay 1×)
/// 5fe528 call 0x5fee80 ; GUARD B: the locomotion id whitelist (else stay 1×)
/// 5fe53c call 0x7c4c90 ; NUMERATOR = the flag-scalar current speed
/// 5fe541 fdiv ; rate = speed / (moveSpeed · scale)
///
/// M = CGUnit+0xdc (the mount) if nonzero, else CGUnit+0xd8 (the body)
/// ‖row0‖ = (mounted ? CGUnit+0x9c : 1.0) · CGUnit+0x94 · CGUnit+0x90
/// ```
///
/// **`model_scale` is the half we were missing** (decision 0903). A sequence's `moveSpeed` is the
/// ground speed its authored cycle covers *at the model's authored size*; render that model at `s×`
/// and one cycle covers `s×` the ground, so the legs must cycle `s×` slower to hold the same speed.
/// Without the divisor every off-1.0-scale creature ran its gait exactly `s×` too fast — a Gordok
/// Ogre-Mage (`CreatureDisplayInfo.CreatureModelScale` 2.2) scurried its walk at 2.2× rate, and a
/// 1.5× riding sabre its run at 1.5×.
///
/// The scale to pass is the **rendered world scale of the model actually playing the clip** — which
/// is what the two terms above are: `CGUnit+0x90` is `OBJECT_FIELD_SCALE_X` (the server has already
/// folded the DBC scale in — `entities::attach`), and `+0x9c` is a mount's own
/// `CreatureDisplayInfo` column, composed under the rider's. Our unit transform and mount-child
/// transform carry exactly those, so `transform.scale.x × host` reproduces the product (decision
/// 0910). Two exactness notes from the cross-check:
///
/// - The client takes the **L2 length of row 0** of the model's world matrix, not an `abs()`. For a
/// *uniform* scale — which every unit of ours is (`Vec3::splat`) — the two are identical at any
/// rotation, since row 0 is `s · (a unit row)`. `abs()` is the cheaper spelling of the same
/// number, and is only a distinction if unit scale ever stops being uniform.
/// - `CGUnit+0x94`, a spell-visual scale multiplier clamped to `[0.75, 2.0]` (1.0 when idle), is a
/// **third** factor we do not carry — because we have no producer for it: the aura CharProc
/// dispatch (`crate::aura_visual::node_for`) implements Alpha/Tint/AnimRate and nothing that
/// scales a unit. Whoever builds that layer must multiply it in here, or a scaling spell visual
/// will re-time the gait wrongly for its duration. (`CGUnit+0x98`, the fourth slot, is a literal
/// constant 1.0 in the binary — its constructor is its only writer.)
pub(super) fn playback_rate(clip: &AnimClip, speed: f32, model_scale: f32) -> f32 {
scaled_rate(clip, speed, model_scale).unwrap_or(1.0)
}
/// [`playback_rate`] as the scaler's own question — **`Some` only where the scaler applies at
/// all**: both of `0x5fe2f0`'s guards passed (a positive divisor, a locomotion id).
///
/// The distinction is what the per-frame rate write
/// ([`sync_base_rate`](super::driver::play::sync_base_rate)) needs, and it is not pedantry: the
/// scaler is one rate producer among several. The combat fast-path doubles an already-playing
/// swing to 2× (decision 0406, op6 `2.0f`), the whiff slows a missed one to 0.5×, decision 0503
/// freezes an airborne snapshot to 0×. Each owns the clip it wrote, and a per-frame write that
/// returned a blanket `1.0` for "the guards failed" would silently stomp all three (it did — the
/// suite caught it, decision 0906).
/// The `abs()` is on the **scale only** — deliberately, and the asymmetry is the client's (decision
/// 0912). `‖row0‖` is a matrix-row length, so it is never negative; `moveSpeed` is *signed*, and a
/// backwards gait is authored negative (`RidingKodo.m2` WalkBackwards, `-2.5`). Guard A's strict
/// `> 0` therefore leaves an authored backwards clip at a flat 1× while a model that falls back to
/// forward Walk (`+2.5`) gets speed-scaled. Do not "tidy" this into `move_speed.abs()`.
pub(super) fn scaled_rate(clip: &AnimClip, speed: f32, model_scale: f32) -> Option<f32> {
let divisor = clip.move_speed * model_scale.abs();
(divisor > 0.0 && RATE_SCALED.contains(&clip.anim_id)).then(|| speed / divisor)
}
/// Build the unified movement view, in precedence order: a populated [`MovementState`] (our avatar,
/// driven by the controller) wins; else a remote player's live `moveFlags` + extrapolation speed
/// ([`RemoteMotion`], from the relayed `MSG_MOVE_*` stream); else a creature's server spline (it moves
/// forward at the path speed); else stationary. The remote flags are raw CMovement bits, which already
/// match [`move_flags`], so the same selector animates a remote player's walk/run/strafe/backpedal/turn.
///
/// `creature_swimming` is the creature legs' swim state ([`crate::net::CreatureSwimming`] — the wire
/// never carries `SWIMMING` for a creature, so it's derived from the water over its feet): it folds
/// the flag into the spline/stationary views, so a pathing water creature plays Swim 42 and an idle
/// one treads water (SwimIdle 41) instead of running/standing on the lakebed. Ignored for the
/// self/remote legs — their flag arrives properly.
///
/// `modes` is the unit's **server-granted** movement modes ([`crate::net::UnitMoveModes`], decision
/// 1780) — the `SMSG_SPLINE_MOVE_*` family. They are OR'd into the flags word on the remote and
/// creature legs, because in the reference they are bits of the *same* `CMovement+0x40` word the
/// selector already tests; a creature the server has put in walk mode or rooted has those bits set
/// exactly like a player who arrived carrying them in a pose. The self leg deliberately skips them:
/// our own mover's modes are the ack'd family's, held on [`crate::player::state::MoveModes`] and
/// already folded into the controller's own `MovementState`.
pub(super) fn unify(
movement: Option<&MovementState>,
remote: Option<&RemoteMotion>,
spline: Option<&Spline>,
creature_swimming: bool,
modes: Option<&crate::net::UnitMoveModes>,
) -> MovementState {
// The flying-spline fact is read through to the live `Spline` on EVERY leg — the client's
// selector reads the active CMovement's spline flags at select time (RF-0057 `0x5fd19c`),
// so a self ride (MovementState leg) and a remote taxi (spline leg) both fly.
let flying = spline.is_some_and(|s| !s.grounded);
if let Some(m) = movement {
return MovementState { flying, ..*m };
}
let granted = modes.map_or(0, |m| m.0);
if let Some(r) = remote {
return MovementState {
speed: r.speed,
flags: r.flags | granted,
vertical_speed: r.vertical_velocity,
flying,
..default()
};
}
let swim = if creature_swimming {
move_flags::SWIMMING
} else {
0
};
let s = spline.map(|s| s.speed()).filter(|s| *s > MOVING_EPSILON);
MovementState {
speed: s.unwrap_or(0.0),
flags: granted | swim | if s.is_some() { move_flags::FORWARD } else { 0 },
flying,
..default()
}
}
#[cfg(test)]
mod tests;