Files
benilla-octo/crates/benilla-app/src/creature_anim/driver.rs
T
SamandClaude Fable 5 80551cab5e the cave hides the lake, the lifts come back, the bolt gets its width
- from inside a building the open-world water outside draws only through a doorway you can see, as terrain and trees already did — the Caverns of Time lake is gone from the cavern's ceiling: there was never a second visibility lane for open-world liquid (the old deferral said "it already has an authority"; a sweep of every visibility writer found none, so Bevy's frustum test was the lake's only gate), an ADT liquid layer is tagged exterior scene at spawn and the existing window cull owns it, one 33 yd chunk layer at a time — which the reference's bytes then confirmed is its unit too (`CChunkLiquid`, reachable only from the per-window walk, a sealed room's supply discarded not deferred); a building's own pool also rides the Map Objects toggle now, so switching buildings off no longer leaves the canals hanging in the air
- the Thunder Bluff elevators and every other Kalimdor lift were invisible for the whole session whenever you logged in on that continent: the lift arm snapshotted the current map one stage before the login frame had written it, so every lift compared itself against map 0 and hid as off-map — a lift carries no map at all now (it is only ever streamed to players standing on its own map), the off-map term stays for the boats it was written for, and `WOW_LIFT_CENSUS` prints each transport's arm state, cycle and visibility (13 transports, 11 hidden → 0)
- the lightning across Razorgore's room and Chain Lightning's mob-to-mob hop drew as hairlines: the chain renderer took each hop's cross-section as the world-horizontal perpendicular, where the reference takes `(−d.y, d.x, 0)` in eye space — the ribbon is view-plane aligned and keeps its authored 2 × halfWidth yards at every angle, so a long level beam watched from its own plane no longer thins to a line (the "a hop seen end-on genuinely collapses" note was wrong and is gone)
- a display that names a model builds even when it draws no batch — Naxxramas' Unholy Axe / Swords / Staff are an invisible stalker holding a Monster weapon, and the held weapon had nowhere to hang; the same gate was keeping 43 emitter-only GameObjects dark (house and blacksmith smoke, Ironforge's lava steam, Stratholme's embers, every firework), and a name sits at the authored PlayerName attachment instead of at the feet; and a vendor's foot-shuffle on turning to you now blends the whole way in and runs to its own clip window — a 500 ms step on 1100 of 1130 shipped shuffle records — where the latch had tested the gap left against a band sixty times too wide, and nothing ever ended the step

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 17:15:08 +07:00

1479 lines
83 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 Bevy driver: [`drive_animations`], which executes the state machine [`super::select`]
//! picks, reading and writing [`super::AnimDriver`]'s private state. Kept in its own file as it
//! carries the bulk of the per-frame Bevy system logic, separate from the module face ([`super`])
//! and the pure selector logic ([`super::select`]). Split into a small family of children — the
//! playback primitives ([`play`]), the wound-flinch secondary-blend slot logic ([`wound`]), the
//! per-hand weapon-grip overlay ([`grip`]), and the base track's **mode machine** ([`mode`]) —
//! with this file the stable face, re-exporting [`grip::drive_hand_grip`] unchanged.
//!
//! What is left here is one function, [`drive_animations`], and it is a *sequence of phases* over
//! one unit, not a set of concerns: the prologue that computes the frame's facts, the death
//! override, the mount transition edge, the sheath state, the one-shot gather-and-arm, the mode
//! machine (now [`mode`]), the rate write, the fades, the trace, the wound, the sheath reconcile.
//! The phases share ~20 live bindings and a handful of frame-local flags, so they do not come
//! apart on a module boundary the way the four children did — decision 0933 records which cut was
//! taken, which was not, and why.
use std::time::Duration;
use benilla_assets::ModelAnimations;
use benilla_formats::AnimDataCatalog;
use bevy::animation::graph::AnimationNodeIndex;
use bevy::animation::transition::AnimationTransitions;
use bevy::prelude::*;
use crate::net::{
ClientCommand, FacingStep, NetCommands, ObjectStore, RemoteMotion, SelfPlayer, Spline,
UnitSpeeds,
};
use crate::sound::EmoteSounds;
use super::select::{
self, current_special, defense_anim, is_swing_id, route_oneshot, swing_anim_main,
swing_anim_off, unify, Mode, OneShotRoute, DEATH, DEFAULT_WALK_SPEED, STAND,
};
use super::sheath::{advance_sheath_ceremony, start_sheath_ceremony};
use super::{
find_resolved, move_flags, AnimData, AnimDriver, AutoRepeatArmed, CastHold, DefenseAnim,
EmoteAnim, Engaged, MovementState, NockLatch, Overlay, OverlayFade, SheathRequest,
SheathSwapMessage, SwingImpact, SwingMessage, SwingSlowdown, Wielded, WoundAnim,
};
mod grip;
mod mode;
mod play;
#[cfg(test)]
mod tests;
mod wound;
pub(super) use grip::drive_hand_grip;
use play::{oneshot_finished, play_clip, roll_loop, roll_oneshot};
use wound::{wound_evict, wound_trigger, wound_upkeep, WoundEdge};
/// The weight of a masked upper-body one-shot overlay ([`AnimDriver::overlay`]) over the base clip on
/// the SpineLow subtree both drive (decision 0087). The base clip is *not* masked out of that subtree
/// (it animates the whole skeleton), so base and overlay blend there — this makes the overlay
/// dominate ≈ 8:1 (the torso visibly swings/emotes, not a wash with the run's torso), a small bleed
/// the cost of Bevy's weighted blend. The legs are excluded by the mask entirely. Same rationale and
/// value as the per-arm sheath ceremony's overlay weight.
const ONESHOT_OVERLAY_WEIGHT: f32 = 8.0;
/// The key-bone **fade-to-rest** window (decision 0878 — wow-re `oneshot-lifecycle.md` §5.4): a
/// finished upper-body one-shot is never stopped. The client's per-frame advance latches the
/// completion (`0x719370`), the deferred event reaches `CGUnit::OnAnimationFinished 0x5fc920` the
/// same frame, and that calls op4 with `param_3 = -1` (`0x5fcacb`) — which snapshots the clip's
/// **held final frame** into the bone's secondary slot, seeds `+0x100 = clock + 150`,
/// `+0x104 = 1/150`, `+0x108 = 1.0`, and disarms the primary so the bone inherits bone 0 again.
/// **Fixed 150 ms** — deliberately NOT the sequence's own blendTime, which is used only on the arm
/// path.
const ONESHOT_RELEASE_FADE: f32 = 0.150;
/// The launch vertical speed (yd/s, +up) above which a new airborne arc is a **jump** (the 37/38
/// bracket) rather than a step-off fall. A jump launches at ≈7.96 up (our own controller's
/// `JUMP_SPEED`; a remote's `-zspeed - g·t` is still ≳4 after relay latency); a step-off starts
/// level or already descending — the two never come near this line.
const JUMP_ARC_MIN_UP: f32 = 0.5;
/// A one-shot play request gathered from this frame's messages, resolved to an anim id inside
/// the unit loop (a swing's id keys the attacker's own wielded weapon).
enum OneShotReq {
Swing(u32),
Emote(u16),
}
/// The currently-armed one-shot the combat fast-path tests — the client's key-bone-else-bone-0
/// current-id read (`0x5fe422`, wow-re `combat-anim-fastpath.md` §1): the masked overlay while
/// its node still plays, else the full-body [`Mode::Swing`] clip while unfinished. Gait and
/// Special clips are never returned; their ids aren't combat, so nothing is lost at the
/// classifier gate.
fn live_oneshot(
drv: &AnimDriver,
player: &AnimationPlayer,
tr: &AnimationTransitions,
anims: &ModelAnimations,
catalog: Option<&AnimDataCatalog>,
) -> Option<(u16, AnimationNodeIndex)> {
if let Some(ov) = drv.overlay {
if player.animation(ov.node).is_some_and(|a| !a.is_finished()) {
return Some((ov.id, ov.node));
}
}
if let Mode::Swing { id: m, .. } = drv.mode {
if !oneshot_finished(player, anims, m, catalog) {
return tr.get_main_animation().map(|n| (m, n));
}
}
None
}
/// Whether **any** one-shot is currently live on the unit — [`live_oneshot`] minus the node. The
/// missile queue's anim-end flush backstop (the client's `0x5fc920` on-anim-finish → `0x60c9b0`
/// is a *callback*; ours polls this edge): a queued missile whose release keyframe never fired
/// launches when the cast one-shot ends.
pub(crate) fn oneshot_is_live(
drv: &AnimDriver,
player: &AnimationPlayer,
tr: &AnimationTransitions,
anims: &ModelAnimations,
catalog: Option<&AnimDataCatalog>,
) -> bool {
live_oneshot(drv, player, tr, anims, catalog).is_some()
}
/// Retire whatever holds the key-bone slot into its cross-fade ([`AnimDriver::overlay_fade`]) —
/// the client's op4 snapshot of the outgoing pose into the bone's SECONDARY (`rep movsd
/// +0x98 → +0xc4`) plus the window seed (decision 0878). `total` is the window: the incoming
/// clip's own blendTime on a blended re-arm, [`ONESHOT_RELEASE_FADE`] on a fade-to-rest. Honors
/// the client's **re-snapshot guard** (`0x7125d4` on the arm path, `0x7123a2` on the release
/// path, both against `0x7ffa24 = 0.5f`): a fade still running above λ = 0.5 is not re-seeded —
/// the older pose keeps decaying and the superseded node is simply dropped. Leaves
/// [`AnimDriver::overlay`] empty; the caller arms the incoming clip (if any) after it.
fn retire_overlay(drv: &mut AnimDriver, player: &mut AnimationPlayer, total: f32) {
let out = drv.overlay.take().map(|ov| ov.node);
if drv.overlay_fade.is_some_and(|f| fade_lambda(&f) > 0.5) {
if let Some(n) = out {
player.stop(n);
}
return;
}
if let Some(prev) = drv.overlay_fade.take().and_then(|f| f.out) {
player.stop(prev);
}
drv.overlay_fade = Some(OverlayFade {
out,
left: total,
total,
});
}
/// The blend weight λ of a key-bone cross-fade this frame — `smoothstep` over the fraction of the
/// window still to run, so it decays 1 → 0 ([`select::blend_lambda`]).
fn fade_lambda(f: &OverlayFade) -> f32 {
select::blend_lambda(if f.total > 0.0 { f.left / f.total } else { 0.0 })
}
/// The key-bone cross-fade's per-frame advance (decision 0878) — the kernel's λ decay
/// (`0x714880`–`0x714923`) and its self-release (`0x7147b9`: `+0xd0 = -1` and λ = 0 the same
/// frame). Two shapes, decided by whether an incoming clip holds the slot:
///
/// - **A release** (no incoming): the retiring node alone fades against the base, so its weight
/// must land the blended share on `λ · W/(1+W)` — the client's full-override λ, capped by the
/// standing [`ONESHOT_OVERLAY_WEIGHT`] approximation. `w = W·λ / (1 + W·(1−λ))`.
/// - **A re-arm** (incoming present): the two clips split the slot's share by λ exactly as the
/// client's `primary + (secondary − primary)·λ` does, at constant total — `W·λ` out, `W·(1−λ)`
/// in. (An incoming that arrived by *transplant* seeds no fade at all: `blendFlag = 0`.)
fn overlay_fade_upkeep(drv: &mut AnimDriver, player: &mut AnimationPlayer, dt: f32) {
let Some(mut f) = drv.overlay_fade else {
return;
};
f.left = (f.left - dt).max(0.0);
let lambda = fade_lambda(&f);
let live = drv.overlay.map(|ov| ov.node);
let w = ONESHOT_OVERLAY_WEIGHT;
if let Some(a) = f.out.and_then(|n| player.animation_mut(n)) {
a.set_weight(if live.is_some() {
w * lambda
} else {
w * lambda / (1.0 + w * (1.0 - lambda))
});
}
if let Some(a) = live.and_then(|n| player.animation_mut(n)) {
a.set_weight(w * (1.0 - lambda));
}
if f.left <= 0.0 {
if let Some(n) = f.out {
player.stop(n);
}
if let Some(a) = live.and_then(|n| player.animation_mut(n)) {
a.set_weight(w);
}
drv.overlay_fade = None;
} else {
drv.overlay_fade = Some(f);
}
}
/// The **TRANSPLANT** (`0x5fe919` — wow-re `oneshot-lifecycle.md` §3a, the half
/// `anim-composition-model.md` §2 was missing; decision 0878). A base **locomotion** clip
/// requested while bone 0 still plays a live **CAST** or **COMBAT** one-shot does not replace it:
/// the client copies the bone-0 descriptor — its id, its rate, and `+0x08` the clip's **live
/// elapsed position** — onto the key-bone with `blendFlag = 0`, then hands bone 0 the request. The
/// cast therefore keeps running on the torso *at exactly the frame it was on*, with no cross-fade
/// and no restart, while the legs take the jump or the run. This is the director's "jump right
/// after a cast: the legs jump, the arms finish the cast".
///
/// A no-op when the key-bone is already armed — `0x5fe912` then jumps straight to `0x5fe930`
/// (request → bone 0, key-bone untouched), which is exactly what leaving [`AnimDriver::overlay`]
/// alone already gives us. Named simplification: the moved clip re-arms as a plain `Never` repeat,
/// dropping any unspent replay budget (`R > 1`); every cast and swing this fires for is authored
/// `(0, 0)` ⇒ `R = 1`.
fn transplant_up(
drv: &mut AnimDriver,
player: &mut AnimationPlayer,
tr: &AnimationTransitions,
anims: &ModelAnimations,
entity: Entity,
id: u16,
) -> bool {
if drv.overlay.is_some() || !(select::is_cast_anim(id) || select::is_combat_anim(id)) {
return false;
}
let Some(node) = tr.get_main_animation() else {
return false;
};
// The live position + rate, read off the armed VARIATION node (the play rolled one of them),
// which is also how we find the masked twin to resume on.
let Some((seek, speed)) = player
.animation(node)
.filter(|a| !a.is_finished())
.map(|a| (a.seek_time(), a.speed()))
else {
return false;
};
let Some(upper) = anims
.clips
.iter()
.find(|c| c.node == node)
.and_then(|c| c.upper_node)
else {
return false;
};
let active = player.play(upper);
active.replay();
active.set_repeat(bevy::animation::RepeatAnimation::Never);
active.seek_to(seek);
active.set_speed(speed);
active.set_weight(ONESHOT_OVERLAY_WEIGHT); // `blendFlag = 0` — no ramp, no cross-fade
drv.overlay = Some(Overlay {
node: upper,
id,
looping: false,
});
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fct",
&format!("anim transplant unit={entity} id={id} -> key-bone at {seek:.3}s (bone 0 takes the request)"),
);
}
true
}
/// The animation state machine, per unit (decision 0049 + 0073). Death overrides; otherwise:
/// enter/loop/exit the Special states (jump, sit/sleep/kneel) as one-shot-bracketed loops, play the
/// per-packet melee swings as preemptible one-shots, and cross-fade the gaits (the engaged Ready
/// idle among them).
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub(super) fn drive_animations(
mut commands: Commands,
mut units: Query<(
Entity,
&ModelAnimations,
&mut AnimationPlayer,
&mut AnimationTransitions,
&mut AnimDriver,
Option<&Spline>,
Option<&RemoteMotion>,
Option<&MovementState>,
Option<&UnitSpeeds>,
Option<&ObjectStore>,
Option<&Wielded>,
Option<&CastHold>,
Option<&FacingStep>,
(
Has<Engaged>,
Has<AutoRepeatArmed>,
Has<SelfPlayer>,
Option<&crate::entities::mount::MountBody>,
Has<crate::net::CreatureSwimming>,
// Is the in-hand arrow currently latched (the `$BWP`..`$BWR` window)? Read only by
// the leaving-the-stance un-nock at the tail of this pass, which is edge-triggered
// off it rather than issuing a remove every frame.
Has<NockLatch>,
// The rendered model scale — `OBJECT_FIELD_SCALE_X`, baked onto the entity transform by
// `entities::attach`. The locomotion playback rate divides by it (decision 0903), so an
// ogre at 2.2× cycles its legs 2.2× slower than a same-speed human. Read-only: nothing
// in this system moves a unit.
&Transform,
),
)>,
// A mount child's movement view is its HOST's (decision 0441): the same
// MovementState/RemoteMotion/Spline/speeds the rider's `unify` reads, fetched through
// `MountBody.host` — so the untouched gait machinery locomotes the mount for self, remote
// players, and mounted NPCs alike. Read-only over types `units` also only reads.
mount_hosts: Query<(
Option<&Spline>,
Option<&RemoteMotion>,
Option<&MovementState>,
Option<&UnitSpeeds>,
Option<&FacingStep>,
Has<crate::net::CreatureSwimming>,
// …and the rider's own `OBJECT_FIELD_SCALE_X`: a mount child's transform carries only its
// `CreatureDisplayInfo` column, and the mount renders at the PRODUCT of the two (the
// byte-verified mount composition, wow-re `0x613ef0`). The rate divisor wants that product
// — the mount model's world scale — not the child's local column (decision 0903).
&Transform,
// …and whether the rider is US, which only the debug trace reads (decision 0906).
Has<SelfPlayer>,
)>,
mut swings: MessageReader<SwingMessage>,
mut impacts: MessageReader<SwingImpact>,
mut defenses: MessageReader<DefenseAnim>,
mut slows: MessageReader<SwingSlowdown>,
mut emotes: MessageReader<EmoteAnim>,
mut spell_wounds: MessageReader<WoundAnim>,
mut sheath_requests: MessageReader<SheathRequest>,
mut sheath_swaps: MessageWriter<SheathSwapMessage>,
anim_data: Option<Res<AnimData>>,
net: Res<NetCommands>,
// The shared emote-audio catalog, promoted here for its `Emotes.dbc` → `AnimID` column: the
// looping state-emote idle (`UNIT_NPC_EMOTESTATE`) below resolves through it, same as the
// `SMSG_EMOTE` one-shot consumer ([`emote_anim::emote_to_anim`]).
// One tuple param (the 16-SystemParam ceiling): the shared emote-audio catalog, the
// client-local loot-target latch, and the frame clock.
aux: (
Option<Res<EmoteSounds>>,
// Predicate B's standing answer (decision 1477): whether the currently latched loot
// target is one the SELF unit kneels at — the loot leg's whole self trigger, since it is
// false while the latch is cold. `Option` for the headless test worlds that don't build
// the loot seam.
Option<Res<crate::ui_loot::LootKneel>>,
// The frame clock — the key-bone cross-fade's only time source (decision 0878): its
// retiring node is *frozen* on the clip's final frame, so unlike the wound's decay there
// is no playback clock to read the λ window off.
Res<Time>,
),
// The variation roll's LCG state (decision 0114 — the client's single CRT `_rand` stream,
// shared by every play; [`select::msvc_rand`]).
mut rng: Local<u32>,
// The SELF unit's last-written anim state line of the `WOW_MOVE_TRACE` debug trace (the
// diff-only filter; see the trace block after the mode machine).
mut anim_trace_last: Local<std::collections::HashMap<Entity, String>>,
) {
let (emote_sounds, loot_kneel, time) = aux;
let dt = time.delta_secs();
// This frame's one-shot PLAY CALLS (swings + anim-emotes), gathered per unit and replayed
// in the client's call order below ([`PlaySeq`] stamps — the net drain stamps packet order,
// scene-time emitters stamp after it). Order matters twice over: the later call overwrites
// the earlier on the default path (decision 0399), and the combat FAST-PATH (decision 0406)
// keys on what is *currently playing* when each call runs.
let mut pending: bevy::ecs::entity::EntityHashMap<Vec<(OneShotReq, u64)>> = default();
// …and by victim: a landed hit with the flinch bit (`HitInfo & 0x2` — the sole trigger gate,
// decision 0111) plays the victim's wound-flinch **decay overlay** below, as does a spell
// impact whose kit carries a CombatWound anim ([`WoundAnim`], decision 0099 phase 4 — the
// kit player's own 8–10 branch). Last hit wins, matching the client, where a re-trigger
// re-seeds the same secondary slot. Independent of the attacker-side 0x10000 suppressor
// (that bit gates the *swing* animation only).
let mut pending_wound: bevy::ecs::entity::EntityHashMap<WoundEdge> = default();
for s in swings.read() {
// HitInfo bit 0x10000 suppresses the swing anim (decision 0073's verified suppressor).
if s.hit_info & 0x10000 == 0 {
pending
.entry(s.attacker)
.or_default()
.push((OneShotReq::Swing(s.hit_info), s.seq));
}
}
// The victim's wound flinch fires at the swing clip's IMPACT keyframe ([`SwingImpact`],
// `impact.rs`), not at packet receive — the blow lands, then the victim recoils.
for SwingImpact {
swing: s,
text_only,
..
} in impacts.read()
{
if !text_only && s.hit_info & 0x2 != 0 {
if let Some(victim) = s.victim {
pending_wound.insert(victim, WoundEdge::Melee(s.hit_info));
}
}
}
for w in spell_wounds.read() {
pending_wound.insert(w.entity, WoundEdge::Spell(w.anim_id));
}
// This frame's victim DEFENSE reactions (`$CPP`, decision 0279), keyed by victim — last wins
// (a re-trigger re-arms the same primary). Resolved to an anim id inside the loop: the parry
// pick keys the victim's OWN mainhand, and the client's alive gate needs the store.
let mut pending_defense: bevy::ecs::entity::EntityHashMap<u32> = default();
for d in defenses.read() {
pending_defense.insert(d.victim, d.victim_state);
}
// This frame's whiff slow-downs (`0x712910`, decision 0279): the attacker's in-flight swing
// drops to HALF speed for its remainder.
let mut pending_slow: bevy::ecs::entity::EntityHashSet = default();
for s in slows.read() {
pending_slow.insert(s.0);
}
// This frame's anim-emotes join the same request list (the [`PlaySeq`] sort below puts the
// two writer streams in emission order). Played as the same over-the-gait one-shot as a
// swing (the talk emote on interact — decision 0081).
for e in emotes.read() {
pending
.entry(e.entity)
.or_default()
.push((OneShotReq::Emote(e.anim_id), e.seq));
}
// This frame's sheath requests, keyed by unit (a later request replaces an earlier — the
// client's last SetSheatheState call wins).
let mut pending_sheath: bevy::ecs::entity::EntityHashMap<SheathRequest> = default();
for r in sheath_requests.read() {
pending_sheath.insert(r.entity, *r);
}
// The missing-clip resolver's DBC source (decision 0082): `None` for the brief window before
// `AnimationData.dbc` loads, in which every lookup below degrades to identity.
let catalog = anim_data.as_deref().map(|d| &d.0);
// `WOW_ANIM_COST=1` — the memo-sizing counter (1370 item 9's bracket): counts the frame's
// RESTING rows, the shape an input-memo fast path would skip. Sizing only — no skip is
// taken here. (The EVENT scan is 1482's to gate now — a parked unflagged unit's tracks go
// unscanned — but the driver still arms clips for every parked rig: the wake pose must be
// the absolute-clock pose of the RIGHT clip, and only the state machine knows which clip
// that is. The memo remains the only safe skip shape for these rows.)
let anim_cost = anim_cost_enabled();
let (mut cost_rows, mut cost_resting) = (0u32, 0u32);
for (
entity,
anims,
mut player,
mut tr,
mut drv,
spline,
remote,
movement,
speeds,
store,
wielded,
cast_hold,
facing_step,
(engaged, auto_repeat, is_self, mount_body, creature_swimming, nock_latched, transform),
) in &mut units
{
// A mount child drives from its HOST's movement view (decision 0441) — same inputs the
// rider's own pass reads, so the mount plays exactly the locomotion the rider suppresses.
// A vanished host (teardown race) reads as stationary until the child despawns with it.
// The host also supplies the missing half of the mount's world scale (see the query docs).
//
// `ridden_by_self` only feeds the debug trace: while mounted, the animation the director is
// *looking at* is the mount child's (the rider is pinned to Mount 91), so a trace that
// followed `SelfPlayer` alone went blind on exactly the body in question — which is how a
// gallop cadence report had to be diagnosed from asset bytes instead of a run
// (decision 0906). It deliberately does NOT feed `is_self`: the sheath reconcile and the
// wire echoes below mean the player's OWN unit, and a mount is not it.
let (
spline,
remote,
movement,
speeds,
facing_step,
creature_swimming,
host_scale,
ridden_by_self,
) = match mount_body {
Some(mb) => match mount_hosts.get(mb.host) {
Ok((s, r, m, sp, f, sw, t, host_self)) => {
(s, r, m, sp, f, sw, t.scale.x, host_self)
}
Err(_) => (None, None, None, None, None, false, 1.0, false),
},
None => (
spline,
remote,
movement,
speeds,
facing_step,
creature_swimming,
1.0,
false,
),
};
// The rendered world scale of the model playing these clips — the `0x5fe2f0` rate divisor's
// `|modelScale|` (decision 0903). A plain unit's transform already IS its world scale; a
// mount child's is its display column, which composes under the rider's.
let model_scale = transform.scale.x * host_scale;
let traced = is_self || ridden_by_self;
// Which body a trace line is about — the rider's own, or the mount it is sitting on.
let subject = if mount_body.is_some() { "mount " } else { "" };
let walk = speeds.map_or(DEFAULT_WALK_SPEED, |s| s.0.walk);
// Dead ⇒ health 0 with a real max, **or** the dead-looking dynamic flag (`unit_reads_dead`
// — the reference's own `0x605f90`, decision 1022): feign death leaves health untouched and
// signals purely through `UNIT_DYNFLAG_DEAD`, and the reference runs it through this exact
// path — the flag's watcher `0x600440` calls `0x60ea30` (play Death) and the per-frame
// chain's top resolver `0x5fcff0` then claims and holds the corpse pose, the same pair the
// real death handler `0x625190` runs. So a hunter feigning collapses and lies there, for
// himself and for everyone watching, and stands back up when the flag clears.
// Absent health counts as ZERO: a create block omits zero fields, so an already-dead corpse
// streams in with no HEALTH at all — requiring an explicit `Some(0)` here is exactly the bug
// where corpses stood up on relog.
let dead = store.is_some_and(|s| s.0.unit_reads_dead());
// Mounted (decision 0441): `UNIT_FIELD_MOUNTDISPLAYID` nonzero — the wire's one mounted
// signal. The rider's base pins to Mount(91) below, Specials/one-shot full-body routes are
// suppressed, and the sheath reconcile force-stows; the locomotion the selector would have
// picked plays on the MOUNT child entity instead (its own driver pass, fed the host's own
// movement view through `MountBody.host` — the `mount_hosts` fetch at the top of this
// loop). A mount child itself has no `ObjectStore`, so it can never read as mounted here.
let mount_display = store.map_or(0, |s| s.0.unit_mount_display_id());
let mounted = mount_display != 0;
// The transition EDGE, consumed after the death override below ([`AnimDriver::mount_display`]
// carries the byte-cited why). Latched here, with the other per-frame edge bookkeeping, so
// a pass that returns early still consumes it: a unit that mounts and dies in the same
// breath must not have the arm fire late, over the death play that owns bone 0.
let mount_edge = std::mem::replace(&mut drv.mount_display, mount_display) != mount_display;
// First time we drive this unit (nothing chosen yet) — used to settle a corpse to its end pose.
let first = drv.gait.is_none() && drv.mode == Mode::Gait;
let mut mv = unify(movement, remote, spline, creature_swimming);
// Stand state (decision 0080c): a unit without a controller-fed [`MovementState`] (remote
// players, creatures) poses from its `UNIT_FIELD_BYTES_1` stand-state byte — a seated
// remote player renders for free. Our own avatar's rides its `MovementState` (the
// controller overlays its in-flight request so the pose never waits on the echo).
if movement.is_none() {
mv.stand_state = store.map_or(0, |s| s.0.unit_stand_state());
}
// Mounted forces stand-state 0 on every recompute (`0x5fdf80`, wow-re sheath-policy §3):
// no sit/sleep/kneel pose can hold in the saddle.
if mounted {
mv.stand_state = 0;
}
// Stealth — the prowl pose ([`select::STEALTH_WALK`] / [`select::STEALTH_STAND`]): the CREEP
// vis flag off the unit's OWN descriptor, read every frame for self and remote alike, which
// is where the client reads it (`[[unit+0x110]+0x213] & 2` at select time). Never
// controller-fed, unlike the stand state — there is no client-side prediction of stealth, so
// the crouch lands with the server's aura. A mount child carries no store and reads `false`:
// right by construction, since the rider's body holds Mount(91) regardless and stealth and
// mounts are mutually exclusive anyway.
mv.stealthed = store.is_some_and(|s| s.0.unit_is_stealthed());
let moving = mv.flags & move_flags::ANY_MOVE != 0;
// The airborne arc's bookkeeping (wow-re land-anim-height-gate + rf57b §2): on the arc's
// FIRST airborne frame, its launch vertical speed splits a **jump** (upward — the client's
// JumpStart/Jump bracket rides the MSG_MOVE_JUMP event) from a **step-off fall** (level or
// downward — no bracket; the gait freezes until FALLINGFAR latches Fall 40, and an
// unlatched landing is a full no-op: the gait keeps rolling — decision 0187).
let falling = mv.flags & move_flags::FALLING != 0;
let was_falling = std::mem::replace(&mut drv.was_falling, falling);
// **The edge is the LAUNCH, not the FALLING bit** (decision 1137). The bit's rising edge is
// the arc's start only when the body was on the ground the frame before, and on broken
// ground it often is not: a run across a hillside micro-detaches for a frame or two, and a
// jump pressed in that window lands and relaunches inside a single frame, with FALLING set
// continuously across the seam. The bit never toggles, the sample never runs, and the arc
// inherits `jump_arc = false` from the detachment — so a real jump plays the step-off
// freeze and the body sails upward still running. Seven of the nineteen jumps in the
// director's hillside capture took exactly that path (`grounded=0 vy=-0.65`, then
// `vy=+7.96` the next frame).
//
// A launch is visible in the quantity itself: nothing but a jump drives vertical speed
// *upward* past the threshold. The mover's own arc bookkeeping already agrees — a
// same-frame land+relaunch is a new arc there ([`crate::player::Player::advance_airborne_arc`]).
let prev_vertical = std::mem::replace(&mut drv.last_vertical_speed, mv.vertical_speed);
let launched = mv.vertical_speed > JUMP_ARC_MIN_UP && prev_vertical <= JUMP_ARC_MIN_UP;
// The resting predicate, counted (never acted on — see the counter's note above): mode
// Gait with nothing in flight, nothing pending, grounded and motionless. A strict lower
// bound on what an input-memo would skip.
if anim_cost {
cost_rows += 1;
if drv.mode == Mode::Gait
&& drv.overlay.is_none()
&& drv.overlay_fade.is_none()
&& drv.wound.is_none()
&& drv.sheath_swap.is_none()
&& !first
&& !mount_edge
&& cast_hold.is_none()
&& !moving
&& !falling
&& !was_falling
&& !launched
&& !pending.contains_key(&entity)
&& !pending_wound.contains_key(&entity)
&& !pending_defense.contains_key(&entity)
&& !pending_slow.contains(&entity)
&& !pending_sheath.contains_key(&entity)
{
cost_resting += 1;
}
}
if falling && (!was_falling || launched) {
drv.jump_arc = mv.vertical_speed > JUMP_ARC_MIN_UP;
}
// The airborne-freeze's exact gate (`0x5fd8e8`, §5-arbitrated — decision 0868):
// keep-current iff `FALLING && (FALLINGFAR || vz ≠ 0)`. The vz clause pins every real
// arc (ballistics make vz ≠ 0 from the first integrated substep); the one uncovered
// case is a fresh walk-off's vz == 0 substep, where the client genuinely may re-pick.
// Gates the gait slot's mid-air keep-current AND the deferred-cache consumer below
// (the `+0xd60` read sits downstream of the freeze: a parked clip never plays mid-air).
let airborne_frozen =
falling && (mv.flags & move_flags::FALLING_FAR != 0 || mv.vertical_speed != 0.0);
// The idle re-face turn-shuffle (decision 0123 — the client's facing-delta latch
// `0x607ed0`, wow-re `loop-replay-fidget.md` §5b): a stationary creature easing its yaw
// toward its target reads as *turning* to the anim layer, so the gait picks
// ShuffleLeft/Right and each Shuffle→Stand return re-arms (and, relaxed, re-rolls) Stand
// — the fidget's recurring trigger. Gated like the client's can-fidget gate (`0x5fce30`:
// no combat/cast); positive step = counterclockwise WoW yaw = turning left.
if let Some(step) = facing_step.filter(|_| !moving && !engaged && cast_hold.is_none()) {
mv.flags |= if step.0 > 0.0 {
move_flags::TURN_LEFT
} else {
move_flags::TURN_RIGHT
};
}
// Whether a looping base arm this frame may ROLL its variation (the client's base-arm
// `variationIdx = −1`, decision 0123) or is forced to the head: decided from the state
// *before* this frame's transitions (the client tests the outgoing armed id).
// (0880 also gated this on a stun. That gate cited `0x5eb4f2`/`0x5ec219` as "the idle/fidget
// selectors bail on UNIT_FLAG_STUNNED" — re-read at the bytes, those two sites are
// `ToggleSheath 0x5eb480` and `CanLootNow 0x5ec110`, and NO site in the whole `0x40000`
// census touches animation selection. The stun does not quiet the fidget; the freeze does,
// by stopping the clock (decision 0889), so the invented gate is gone.)
let outgoing = drv.active_anim().unwrap_or(STAND);
let relaxed = !select::arm_forces_head(engaged, cast_hold.is_some(), outgoing);
wound_upkeep(&mut drv, &mut player);
// Death overrides every state (a corpse doesn't transition); play Death and hold.
if dead {
if drv.gait != Some(DEATH) {
// One line per collapse, naming WHICH death it is — a body lying down with full
// health is a feign, and without this the two are indistinguishable in a log.
debug!(
"death pose: unit {entity} arms Death ({DEATH}) — {}{}",
if store.is_some_and(|s| s.0.unit_is_dead()) {
"killed"
} else {
"feign (UNIT_DYNFLAG_DEAD, health intact)"
},
if first { ", streamed in dead" } else { "" }
);
if let Some(c) = find_resolved(anims, DEATH, catalog)
.or_else(|| find_resolved(anims, STAND, catalog))
{
// A witnessed death rolls its variation like any one-shot (decision 0114); a
// corpse that streamed in dead settles on the deterministic head instead.
let c = if first {
c
} else {
anims
.pick_variation(c.anim_id, select::msvc_rand(&mut rng))
.unwrap_or(c)
};
let active =
tr.play(&mut player, c.node, Duration::from_secs_f32(c.blend_time));
if c.looping {
active.repeat();
} else if first {
active.seek_to(c.duration); // streamed in dead → settled corpse, not a replay
}
}
// The death play arms bone 0 — a blended primary re-arm overwrites that bone's
// secondary slot (decision 0114, the shared-slot eviction): a FULL-BODY wound in
// flight is evicted; a masked wound rides the key-bone and decays out over the
// collapse via the upkeep above.
if let Some(wd) = drv.wound.take_if(|wd| !wd.masked) {
player.stop(wd.node);
}
drv.mode = Mode::Gait;
drv.gait = Some(DEATH);
drv.deferred = None; // the death play is a normal arm — the cache clears
}
continue;
}
// **The mount transition's own bone-0 arm** (wow-re `mount-cast-anim-disposition.md` §1 +
// `mount-composition.md` Q4, §5 2026-08-03). Both legs of the `UNIT_FIELD_MOUNTDISPLAYID`
// watcher arm the BODY's bone 0 through op4 `0x7121a0` on the PRIMARY slot: the build
// `0x607b44` plays **91 `Mount`** with a cross-fade, the teardown `0x607ce0` plays seq
// **0 `Stand`** with the cross-fade **off** (decision 0931 — the two legs are the same
// seven-argument call differing in that one literal, and the difference is visible).
// Because that is an ordinary last-writer-wins play, it **displaces a full-body one-shot
// the transition catches mid-clip** — and our gait-slot mount pin (below) never could:
// `Mode::Swing` holds bone 0 until its clip ends, so the pin waited.
//
// That is exactly B203. The mount summon's cast-stage kit (1604/1607 → **54
// `SpellCastOmni`**) is armed at SPELL_GO, while the caster is still *unmounted*, so it
// takes the full-body route; the mount field then lands a beat later and, without this
// arm, the caster sat on the horse finishing a standing spellcast. The reference plays
// the same clip and then takes bone 0 straight back — which is why the director sees "no
// cast animation", only the poof.
//
// Deliberately NOT touched: the masked overlay. `0x607b44` names bone 0, the key bone is
// a different per-bone block, and op4's replace-notice fires only for the bone's own
// primary — so an upper-body clip rides the transition untouched. (While mounted every
// later one-shot takes the masked route anyway, and the reference's own re-force
// `0x5fe803`–`0x5fe816` re-pins 91 on every subsequent play; the gait pin renders that.)
if mount_edge {
if matches!(drv.mode, Mode::Swing { .. }) {
drv.mode = Mode::Gait;
}
if mounted {
// A FULL-BODY wound in flight is evicted by a **blended** primary re-arm — the
// same shared-slot eviction the death play does above (decision 0114): op4's
// cross-fade path snapshots the outgoing pose *into* the secondary slot
// (`rep movsd` at `0x7125d9`). Only the BUILD leg blends (`0x607b35 push 0x1`);
// the teardown's `crossFadeFlag = 0` jumps past that memcpy entirely
// (`0x71253e`–`0x712543`), so a dismount leaves the secondary alone — decision
// 0931. A masked wound rides the key bone and decays out on its own, either way.
if let Some(wd) = drv.wound.take_if(|wd| !wd.masked) {
player.stop(wd.node);
}
} else {
// **The teardown CUTS** (decision 0931 — the director's "the dismount looks like
// it's landing after a jump"). `0x607ce0`'s arm is
// `op4(bone −1, anim 0 Stand, variation −1, bias 0, rate 1.0, crossFade 0,
// PRIMARY)` at `0x607d1a`–`0x607d2b` — byte-for-byte the build leg's call with
// **one** literal different: `push 0x0` where `0x607b35` pushes `0x1`. So the
// saddle pose does not blend away, it is *gone* on the arm's first frame.
//
// Blending it away is what the crouch-and-recover was: Mount(91) splays and bends
// the legs, and easing that into Stand over the clip's blend time reads as a body
// absorbing an impact. There is no landing clip anywhere near this — the shape of
// the cross-fade WAS the landing.
//
// The gait re-pick below is the arm's own successor, `RecomputeBaseAnim(−1)` at
// `0x607d30`–`0x607d34` (`0x5fd9e0`: run the base chain `0x5fd8b0`, play the
// result through `0x5fe2f0` unless it comes back `0xD0`). It blends normally —
// which is right, because it now blends from **Stand**, not from the saddle.
play::cut_loop(
&mut tr,
&mut player,
anims,
STAND,
catalog,
&mut rng,
&mut drv.loop_window,
);
}
// Force a fresh gait pick even when the selector's answer is unchanged: the id may
// still read `Stand` on both sides of a dismount, but the *node* holding bone 0 is
// the displaced one-shot's, and only a re-arm takes it back.
drv.gait = None;
drv.deferred = None; // a normal arm clears the fast-path cache (`0x5fe48e`)
}
// The loot kneel's trigger (see [`select::LOOT`]; byte-verified, wow-re
// `loot-anim-leg.md` §5/§8 — decisions 0515 / 1471 / 1477): the leg needs **two**
// predicates, and both SPLIT on IsActivePlayer.
//
// **Self.** Predicate A (`0x6126b0`) is "a loot session is open" — the client-local
// loot-target latch. Predicate B (`0x612710`) is the per-object-CLASS filter: which
// *kind* of target the character kneels at. Only B is read here, because
// [`crate::ui_loot::LootKneel`] already folds in A (it is false whenever the latch is
// cold). Keeping them apart is the correction 1477 makes to 1471: a fishing bobber arms
// the latch exactly like a chest does, and the reference still does not kneel at it —
// the difference is entirely B's `GAMEOBJECT_TYPE_ID == 17` test.
//
// **Remote.** The mirrored descriptor: `UNIT_FLAG_LOOTING` (0x400) set AND the
// `0x10000000` bit clear.
//
// Never mounted (the leg's `[+0xdc]==0` gate). Movement outranks by CHAIN POSITION, not an
// in-leg gate — the core ground selector claims first on any direction bit (`[9e8]&0xf`)
// — transcribed as the `ANY_MOVE` test here; a stationary *swimmer* with the trigger up
// kneels mid-tread, as the bytes order it (locomotion's swim block needs a direction
// bit too).
let looting = !mounted
&& mv.flags & move_flags::ANY_MOVE == 0
&& if is_self {
loot_kneel.as_ref().is_some_and(|k| k.0)
} else {
store.is_some_and(|s| {
let f = s.0.unit_flags();
f & select::UNIT_FLAG_LOOTING != 0 && f & select::UNIT_FLAG_LOOT_SUPPRESS == 0
})
};
// No Special claims a mounted rider (decision 0441): the jump/fall arc plays on the mount
// child (whose synced view carries the same flags), and poses were zeroed above.
let special = if mounted {
None
} else {
current_special(&mv, drv.jump_arc)
};
// The loot leg outranks the standState pose resolver (the rf57 chain calls loot before
// standState): a looting unit kneels at the loot, never into a sit/sleep bracket. The
// airborne Specials keep their precedence (the chain's airborne freeze runs first).
let special = special.filter(|s| !(looting && matches!(s, select::Special::Pose(_))));
// ── Sheath state (decision 0080). The unit's rendered sheath is the **client-side
// committed cache** (`drv.sheath_cur`, the client's `[+0xd40]`), not the raw descriptor
// byte: seeded from the byte at first sight (silently), re-adopted whenever the byte
// *changes* (the `0x604c70` field-apply — a remote unit's own client volunteered the
// change; our own echo arrives already-committed and adopts as a no-op), and written by
// the one-setter requests here + the per-animation reconcile below. Every transition
// **snaps by default** — byte-verified across all 24 SetSheatheState call sites (wow-re
// `sheath-policy.md`): only the manual toggle's request carries the ceremony, and the
// draw/stow sound rides the ceremony playback (no clip → no sound).
let sheath_frame_start = drv.sheath_cur;
let sheath_byte = store.and_then(|s| s.0.unit_sheath_state()).unwrap_or(0);
if drv.sheath_cur.is_none() || drv.sheath_byte != Some(sheath_byte) {
drv.sheath_cur = Some(sheath_byte);
drv.sheath_byte = Some(sheath_byte);
}
// The one setter's queue ([`SheathRequest`] — the Z toggle, the attack-start auto-draw,
// the stand-state stow rider): idempotency refusal (the client's `newState == CUR`
// abort), commit, the local player's `CMSG_SETSHEATHED` volunteer, and — for the manual
// toggle alone — the ceremony: **per-arm masked overlays** (the client's per-slot plays,
// `0x60b770`): the mainhand's on the right-arm subtree, the offhand's on the left, each
// by that item's stow family (hip → HipSheath 90, back/shield → Sheath 89), composed
// over whatever the body is doing and never cancelled; [`VisualSheath`] pins the old
// weapon placement until the authored `$SHL`/`$SHR` event (~halfway — the hand at the
// stow point). A ceremony without playable clips degrades to the snap.
if let Some(req) = pending_sheath.get(&entity) {
let cur = drv.sheath_cur.unwrap_or(0);
// Mounted is a persistent DRAW-BLOCK (wow-re `sheath-policy.md` §3: the client's
// recompute `0x5fdf80` tests the mount model FIRST and forces stowed on every
// PlayAnimation — nothing can stay drawn on a mount). The client's composite
// recomputes constantly (the mount's own gait plays); our rider track goes silent
// under the seat pin, so the equivalent is refusing the draw at the setter — no
// commit, no volunteered CMSG (named deviation: same observable, earlier site).
if req.state != cur && !(mounted && req.state != 0) {
// The setter's own trace, the twin of the reconcile's below: between them every
// sheath transition names itself and its author, so "why is the weapon out"
// is one `RUST_LOG=benilla=debug` grep rather than a re-derivation of the
// 24-site policy from the symptom.
debug!(
"sheath: unit {entity} {cur} -> {} (request, ceremony {})",
req.state, req.ceremony
);
drv.sheath_cur = Some(req.state);
if is_self {
let _ = net.0.send(ClientCommand::SetSheathed {
state: u32::from(req.state),
});
}
if req.ceremony {
start_sheath_ceremony(
&mut commands,
entity,
&mut drv,
&mut player,
anims,
wielded,
cur,
req.state,
catalog,
);
}
}
}
// Ceremony upkeep, per arm: move that arm's weapon when its clip crosses the authored
// `$SHL`/`$SHR` event (the hand-touches-weapon moment — the draw/stow ring fires there,
// `sound::sheathe`, since the sound can't ride the tags themselves: hip clips carry none),
// and when a *stow* clip finishes, run the client's **phase 2** — the on-anim-finish
// drawer `0x5fc920` @ `0x5fca8c`/`0x5fcaa1`. That deferred second movement is what makes a
// melee → ranged toggle read as "put the swords away, come back to neutral, *then* reach
// over the shoulder for the bow" instead of one blended swap.
let sheath_now = drv.sheath_cur.unwrap_or(0);
advance_sheath_ceremony(
&mut commands,
entity,
&mut drv,
&mut player,
anims,
wielded,
sheath_now,
catalog,
&mut sheath_swaps,
);
// ── This frame's one-shot: a melee swing (decision 0073 — one per SMSG_ATTACKERSTATEUPDATE)
// or an anim-emote (decision 0081). A swing outranks an emote when both land (combat wins).
// Its destination is chosen **per play from the unit's live state** (decision 0087,
// `route_oneshot`): **masked** onto the SpineLow overlay (moving / seated / airborne-in-combat)
// — playing beside `mode`, the base track's legs untouched — or **full-body** on the base
// track (standing idle), replacing whatever the base was doing.
//
// The shared-slot eviction's inputs (decision 0114): a wound-flinch overlay is the client's
// per-bone SECONDARY, and a blended primary re-arm on the *same bone* overwrites that slot
// (op4 `blendFlag≠0` copies the outgoing pose over `+0xc4..`). So this frame's plays are
// tracked — full-body plays (bone 0: the base track) evict a full-body wound; masked-slot
// plays (the key-bone) evict a masked wound. A play on the *other* bone leaves the wound
// decaying (the §5's inherited-swing case). Mode/gait changes proxy the mode machine's own
// base plays; the flags catch the same-id re-plays the proxy can't see.
let pre_state = (drv.mode, drv.gait);
let mut base_played = false;
let mut masked_played = false;
let mut played_oneshot: Option<u16> = None;
// Defense outranks a same-frame own swing/emote: the client's `$CPP` arm is the later
// PlayAnimation call in that (rare) frame, and the being-hit reaction is the one the
// player must read. Gated alive (`0x60ec00` checks IsDead / stand-state 7 before the LUT).
let defense = pending_defense.get(&entity).and_then(|&vs| {
if dead {
return None;
}
let id = defense_anim(vs, wielded.and_then(|w| w.main));
if let Some(id) = id {
debug!("defense: unit {entity} anim {id} (victimState {vs})");
}
id
});
// The frame's play calls, resolved to anim ids in [`PlaySeq`] call order; the defense
// reaction runs last (the client plays it from the deferred impact scan, later in the
// frame than any message handler — decision 0399's surviving arm).
let mut requests: Vec<u16> = pending
.remove(&entity)
.map(|mut v| {
v.sort_by_key(|&(_, seq)| seq);
v.into_iter()
.map(|(req, _)| match req {
OneShotReq::Swing(hit_info) => {
let w = wielded.copied().unwrap_or_default();
let id = if hit_info & 0x4 != 0 {
swing_anim_off(w.off)
} else {
swing_anim_main(w.main)
};
debug!("swing: unit {entity} anim {id} (hitInfo {hit_info:#x})");
id
}
OneShotReq::Emote(id) => id,
})
.collect()
})
.unwrap_or_default();
requests.extend(defense);
// The deferred-cache consumer (the client's `+0xd60` read at the base recompute,
// decision 0406): the moment no one-shot is live, the parked combat clip plays — the
// swing the Eviscerate spin deferred fires once the spin ends. A frame with fresh
// requests consumes it implicitly instead (a normal arm clears the cache; a fast-path
// hit re-parks its own request — both what the client's `0x5fe48e`/`0x5fe480` do).
// NEVER mid-air: the read (`0x5fd392`, inside the `0x5fd360` recompute arm) sits
// downstream of the airborne-freeze, so a park made mid-arc waits — and dies at the
// landing play's clear (§5-verified, decision 0868).
if requests.is_empty()
&& drv.deferred.is_some()
&& !airborne_frozen
&& live_oneshot(&drv, &player, &tr, anims, catalog).is_none()
{
requests.extend(drv.deferred.take());
}
for id in requests {
// The COMBAT FAST-PATH (`0x5fe43c`–`0x5fe48b`, wow-re `combat-anim-fastpath.md`,
// decision 0406): a combat clip requested while another combat clip is playing is
// NOT armed — the CURRENT clip's rate doubles (op6 2.0f re-times its remainder,
// pose-continuous) and the request parks in the `+0xd60` cache to play afterwards.
// This is why the Eviscerate spin survives the auto-swings its cast triggers —
// sped up, never cut — and why consecutive swings don't hard-cut each other.
if let Some((cur, node)) = live_oneshot(&drv, &player, &tr, anims, catalog) {
if select::is_combat_anim(cur) && select::is_combat_anim(id) {
if let Some(active) = player.animation_mut(node) {
active.set_speed(2.0);
}
drv.deferred = Some(id);
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fct",
&format!(
"anim fastpath unit={entity} cur={cur} req={id} (cur 2x, req deferred)"
),
);
}
continue;
}
}
// A normal arm clears the cache (the client's `0x5fe48e` writes −1 on every
// non-fast-path PlayAnimation).
drv.deferred = None;
// The arm-level same-id dedup (`0x5fdba0`, decision 0280): a requested id that
// already occupies its slot and is still playing is NOT re-armed — the mechanism
// that lets a looping eat/drink kit clip free-run across the server's ~5 s kit
// resends. Combat same-id re-plays never reach it (the fast-path above catches
// them first — the client's head-of-function order).
let overlay_live = drv.overlay.is_some_and(|ov| {
ov.id == id && player.animation(ov.node).is_some_and(|a| !a.is_finished())
});
let base_live = matches!(drv.mode, Mode::Swing { id: m, .. } if m == id)
&& !oneshot_finished(&player, anims, id, catalog);
if overlay_live || base_live {
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fct",
&format!(
"anim dedup-eat unit={entity} id={id} overlay_live={overlay_live} base_live={base_live}"
),
);
}
continue;
}
// Mounted forces the masked route (byte-verified, wow-re mount-composition B1:
// upper-body one-shots route to the key-bone while mounted, and the 91-force would
// reclaim bone 0 on the next play anyway): a full-body /wave would replace the seat
// pose with a standing wave floating over the saddle.
let masked =
mounted || route_oneshot(id, mv.flags, mv.stand_state) == OneShotRoute::Masked;
// The play's two rolls (decisions 0114/0117): resolve to what this model has, then
// pick among that id's variations — the alternating swing arcs — and roll the replay
// budget (a clamp one-shot authored `(min,max)` plays R times before releasing).
let picked =
find_resolved(anims, id, catalog).map(|h| roll_oneshot(anims, h, &mut rng));
let upper = masked
.then(|| picked.and_then(|(c, r)| c.upper_node.map(|n| (n, r))))
.flatten();
if let Some((node, repeat)) = upper {
// Masked route: the SpineLow overlay, beside `mode`. The base machine runs untouched.
// The re-arm is **blended** (op4 `blendFlag = 1`, decision 0878): whatever held the
// key-bone retires into the fade slot and this clip rises over its own blendTime
// (`0x7125f2` — the INCOMING sequence's `M2Sequence+0x20`), so a masked swing never
// swaps the torso in one frame. Note the full-body branch below deliberately does
// NOT touch the overlay: on that route the client's key-bone slot keeps its current
// descriptor (a dedup no-op), so a standing emote over a still-running masked swing
// plays legs-only underneath it (`0x5fe930`; wow-re claim 6 CONFIRMED).
retire_overlay(
&mut drv,
&mut player,
picked.map_or(0.0, |(c, _)| c.blend_time.max(0.0)),
);
let active = player.play(node);
active.replay();
active.set_repeat(repeat); // always set — a reused node never keeps a stale count
active.set_speed(1.0); // nor a stale rate (a prior whiff slow-down, decision 0279)
active.set_weight(0.0); // the fade upkeep raises it from λ = 1 this same frame
drv.overlay = Some(Overlay {
node,
id,
looping: false,
});
masked_played = true;
played_oneshot = Some(id);
} else {
// Full-body route (standing idle / airborne non-combat), or the split-boneless
// masked fallback (the client's −1 key-bone sentinel arms bone 0 too): the clip
// replaces the base on bone 0 — **even over a Special**. The client never drops a
// play: one slot, last-writer-wins (decisions 0083/0087; the wow-re §3 route puts
// a jump-in-place cast/emote on bone 0, replacing the hang — decision 0864 is the
// ref's mid-air cast). Cutting an airborne clip freezes the outgoing node first —
// the pose-snapshot decay of the client's op4 blend, scoped exactly like
// [`leave_special`]'s (decision 0503).
if matches!(special, Some(select::Special::Jump | select::Special::Fall)) {
if let Some(active) = tr
.get_main_animation()
.and_then(|n| player.animation_mut(n))
{
active.set_speed(0.0);
}
}
if let Some((c, repeat)) = picked {
play_clip(&mut tr, &mut player, c, repeat, 1.0);
}
drv.mode = Mode::Swing { id, under: special };
drv.gait = None;
base_played = true;
played_oneshot = Some(id);
}
if benilla_assets::trace::enabled() {
benilla_assets::trace::line(
"fct",
&format!(
"anim play unit={entity} id={id} masked={masked_played} base={base_played} under={special:?}"
),
);
}
}
// The whiff slow-down (decision 0279, `0x712910`): a miss/dodge/evade drops the attacker's
// in-flight swing to HALF speed for its remainder — a slowed follow-through (the verified
// 0.5 rate write), never a cut. The client writes bone 0's rate blindly; benilla scopes it
// to the swing's own node (masked overlay or full-body main), so a moving attacker's gait
// keeps its pace — a deliberate deviation, named in 0279. `Mode::Swing` holds ANY
// full-body one-shot (a spell kit's Special1H rides it too), so the base arm re-checks
// the id — a whiffing auto-attack must not drag a concurrent special to half speed.
if pending_slow.contains(&entity) {
let node = match drv.overlay {
Some(ov) if is_swing_id(ov.id) => Some(ov.node),
_ if matches!(drv.mode, Mode::Swing { id: m, .. } if is_swing_id(m)) => {
tr.get_main_animation()
}
_ => None,
};
if let Some(active) = node.and_then(|n| player.animation_mut(n)) {
active.set_speed(0.5);
}
}
// A Special EDGE is a play (the jump/pose entry, the FALLINGFAR latch's Fall, the land
// pick — each a normal PlayAnimation), and every normal arm clears the deferred-combat
// cache (the client's `0x5fe48e`): a swing parked behind the spin dies when a jump cuts
// the spin. The *level* must not clear (decision 0864 corrects the old per-frame kill):
// mid-air the airborne-freeze issues no plays, so a fast-path park made mid-arc
// survives to its clip's end, exactly like the ref.
let special_edge = special != drv.last_special;
drv.last_special = special;
if special_edge {
drv.deferred = None;
}
// The looping-variation ADVANCE (decision 0516 — wow-re `loop-replay-fidget.md` §7/§7d,
// the per-frame watchdog `0x719370`): every looping arm installed a window `R`
// clip-lengths wide; when the armed node — still the MAIN animation (the client checks
// the armed block: any newer arm superseded the window) — completes its `R` passes, the
// completion callback re-arms the same id with `variationIdx = −1`: a fresh weighted,
// MEMORYLESS pick with a fresh window. One id's weighted walk is what alternates a
// gryphon's flap/glide (freq 26214/6553) and strings a five-part /dance together; a
// single-variation loop just restarts — the wrap it was already playing. Units always
// opt in (the client's `model+0x70` callback — every CGUnit controller installs it;
// the callback-less map doodads, which loop one variation forever, are `doodad_anim`'s
// separate concern). A combat/cast re-arm keeps the deterministic head, like any base
// arm. The carried rate keeps the gait's scaling; the per-frame sync re-syncs it anyway.
//
// **In the GAIT slot the completion is a re-SELECTION, not a re-arm** (decision 1655).
// The client's callback is `RecomputeBaseAnim(−1)` — `0x5fc3f0`'s jump-table row
// `0x5fc844` is literally `push -1; call 0x5fd9e0`, which runs the whole `0x5fd8b0`
// selector chain. For Fly and /dance the chain hands back the id it was already playing,
// so a same-id re-arm and a re-selection are indistinguishable and this code read as
// right for two years. For the turn-shuffle they are the difference between stepping once
// and shuffling for ever: the shuffle's window is the ONLY thing that ends it (the
// per-frame tail refuses to — see [`select::SHUFFLE_LEFT`] and `mode`'s hold), so if the
// completion re-armed 11 the vendor would never stop. Clearing the target IS the
// re-selection — `mode::run` picks fresh a few lines below, rolling the variation exactly
// as this arm would have. Outside the gait slot (a Special loop) the id is not the
// chain's to choose, so the same-id re-arm stands.
if let Some((node, budget)) = drv.loop_window {
if tr.get_main_animation() == Some(node)
&& player
.animation(node)
.is_some_and(|a| a.completions() >= budget)
{
if drv.mode == Mode::Gait {
drv.gait = None;
} else {
let head = anims
.clips
.iter()
.find(|c| c.node == node)
.and_then(|armed| find_resolved(anims, armed.anim_id, catalog));
if let Some(head) = head {
let rate = player.animation(node).map_or(1.0, |a| a.speed());
let (c, fresh) = roll_loop(anims, head, relaxed, &mut rng);
play_clip(
&mut tr,
&mut player,
c,
bevy::animation::RepeatAnimation::Forever,
rate,
);
drv.loop_window = Some((c.node, fresh));
}
}
}
}
// ── **The mode machine** — the base track's whole decision, lifted into [`mode`]
// (decision 0933). It is the one phase of this pass with a clean input boundary: a fixed
// set of already-computed facts in, the driver + player + transitions out, and none of
// this loop's frame-local flags touched.
mode::run(
mode::Frame {
entity,
anims,
catalog,
mv,
special,
moving,
relaxed,
mounted,
looting,
engaged,
airborne_frozen,
auto_repeat,
cast_hold,
wielded,
store,
emote_sounds: emote_sounds.as_deref(),
walk,
model_scale,
traced,
subject,
},
&mut drv,
&mut tr,
&mut player,
&mut rng,
);
// ── The base slot's **playback rate**, written once per frame over whatever the mode
// machine settled on — the client's rate write sits OUTSIDE the selector (`0x5fe2f0`), so
// it is not the gait's private business (decision 0906). The landing is why it matters:
// JumpLandRun 187 resolves to **Run(5)** on every creature model's baked lookup, so a
// mount's or a travel form's land clip is its own gallop cycle and must be rate-scaled
// like any other locomotion — at the arm's literal 1× it ran ~35% slow for the clip's
// whole 0.8 s. It also records the result in `gait_rate` — the number the hover card and
// the trace's `rate=` read (decision 0903) — so those stay honest in every mode too. See
// [`play::sync_base_rate`].
play::sync_base_rate(&mut drv, &tr, &mut player, anims, mv.speed, model_scale);
// ── Masked one-shot completion — the **fade-to-rest** (decision 0878, correcting 0087 (c)):
// a finished overlay is NOT stopped. The client latches the completion in its per-frame
// model advance (`0x719370`) and delivers a deferred event the same frame to
// `CGUnit::OnAnimationFinished 0x5fc920`, which disarms the key-bone through op4
// `param_3 = -1` — snapshotting the clip's **held final frame** into the bone's secondary
// slot and cross-fading it back onto the inherited base over a fixed 150 ms. So the torso's
// return is a blend out of the follow-through, never the one-frame cut this used to do
// (the director's "the end of the cast is cut off and it instantly snaps back"). A looping
// cast-hold overlay never completes — the hold block below owns its release.
if let Some(ov) = drv.overlay {
let done = if ov.looping {
false // a cast-hold loop: the hold block below owns its release
} else if find_resolved(anims, ov.id, catalog).is_some_and(|c| c.looping) {
// A hold-less overlay whose CLIP is authored looping (a pushed kit's seated
// eat/drink gesture) has no natural end — release it when the body is claimed by
// MOVEMENT. INTERIM (decision 0280): the client's exact release chain for this case
// is unpinned; supersede-by-a-later-play already evicts above. A **Special** no
// longer counts (decision 0878): a jump is a bone-0 play, and bone-0 plays never
// touch the key-bone slot — cutting the torso on takeoff was the same defect the
// fade above fixes at the other end.
moving
} else {
player.animation(ov.node).is_none_or(|a| a.is_finished())
};
if done {
// Freeze the held frame, then retire it: the client's snapshot copies an already
// *expired* window, so the secondary clamps to `seq.end` and the fade runs from
// the clip's final pose (`oneshot-lifecycle.md` §5.4).
if let Some(a) = player.animation_mut(ov.node) {
a.set_speed(0.0);
}
retire_overlay(&mut drv, &mut player, ONESHOT_RELEASE_FADE);
}
}
// ── The committed-move cast hold (decision 0107): a casting/channeling unit that
// translates or swims loops the hold clip on its torso over the gait — the masked route a
// moving caster falls through to (the stationary case pinned the gait slot above; the
// split is the same `[9e8] & 0x20000f` gate, [`move_flags::CAST_PIN_MOVE`] — a merely
// TURNING caster stays pinned there, decision 0491). A
// masked one-shot in the slot (a swing over the run) wins while it plays; the hold
// re-takes the subtree the frame after it finishes (the client's last-writer-wins slot).
// Its start is a play like any other, so the sheath reconcile below sees it (`hold_played`).
// A **Special no longer cancels it** (decision 0878): jumping mid-cast is a bone-0 play
// (JumpStart takes the legs) and the client's key-bone slot is untouched by it —
// `0x5fe912` routes a locomotion request straight to bone 0 when the key-bone is armed.
// Dropping the hold on takeoff was exactly the director's "jump-running cuts the cast".
let mut hold_played: Option<u16> = None;
let masked_hold = cast_hold
.filter(|_| mv.flags & move_flags::CAST_PIN_MOVE != 0)
.and_then(|h| {
find_resolved(anims, h.anim_id, catalog).and_then(|c| {
c.upper_node
.map(|node| (h.anim_id, node, c.blend_time.max(0.0)))
})
});
match (masked_hold, drv.overlay) {
(Some((id, node, blend)), prior)
if prior.is_none_or(|ov| ov.looping && ov.id != id) =>
{
// Free slot, or a stale hold loop (the channel switched spells): (re)take it — a
// blended key-bone re-arm like any other (decision 0878), so the outgoing pose
// retires under the hold's own blendTime instead of vanishing.
retire_overlay(&mut drv, &mut player, blend);
let active = player.play(node);
active.replay();
active.repeat();
active.set_weight(0.0); // the fade upkeep raises it from λ = 1 this same frame
drv.overlay = Some(Overlay {
node,
id,
looping: true,
});
hold_played = Some(id);
}
(None, Some(ov)) if ov.looping => {
// The hold ended, or the unit stopped — release the subtree (the standing pin or
// the gait takes over). A looping clip never completes, so the client's
// completion-driven release never fires for it and its true release chain is
// still unpinned (0107/0280 INTERIM); it borrows the verified fade-to-rest shape
// rather than snapping, which is right either way.
retire_overlay(&mut drv, &mut player, ONESHOT_RELEASE_FADE);
}
_ => {}
}
// The key-bone cross-fade's advance, last — after every arm/release this frame, so the
// weights land on the slot's settled state (the client's kernel likewise runs λ after the
// frame's PlayAnimation calls).
overlay_fade_upkeep(&mut drv, &mut player, dt);
// The anim half of the `WOW_MOVE_TRACE` debug trace ([`benilla_assets::trace`]): one line per
// frame the SELF unit's settled anim state *changes* — mode, gait, Special, flags, the
// rate-driving speed, and **both slots** — interleaved with the mover's `move` lines on the
// same clock, so a feel report ("the char's frames snap on landing") pins which layer moved
// and when. It runs HERE, after every arm/release/fade this frame, so `upper` is the
// settled key-bone state: without it a torso report ("the cast got cut") could not be told
// from a base one at all (decision 0878). `+fade` marks the 150 ms fade-to-rest running —
// a boolean, not a countdown, so a fade doesn't spam a line per frame.
//
// `base=` is the **resolved** clip the full-body slot actually holds — not the id the
// selector *asked* for, which `gait=`/`mode=` already carry. A cadence report ("the run
// goes slow when I land") is unreadable without it: the request was JumpLandRun 187, the
// clip playing was Run 5, and `rate=` was the wrong number for exactly that clip
// (decision 0906). `?` = the slot holds a node this model has no clip record for.
if traced && benilla_assets::trace::enabled() {
let base = tr
.get_main_animation()
.and_then(|n| anims.clips.iter().find(|c| c.node == n))
.map_or_else(|| "?".to_string(), |c| c.anim_id.to_string());
let state = format!(
"{subject}mode={:?} gait={:?} base={base} special={:?} flags={:08x} \
speed={:.2} scale={:.2} rate={:.2} upper={:?}{}",
drv.mode,
drv.gait,
special,
mv.flags,
mv.speed,
// The rate's new input and its result (decision 0903): `speed` alone stopped
// predicting the gait's playback the moment the model scale joined the divisor,
// so a "the gait looks wrong" trace carrying only speed could no longer answer it.
model_scale,
drv.gait_rate,
drv.overlay.map(|ov| ov.id),
if drv.overlay_fade.is_some() {
"+fade"
} else {
""
},
);
if anim_trace_last.get(&entity) != Some(&state) {
benilla_assets::trace::line("anim", &state);
anim_trace_last.insert(entity, state);
}
}
let masked_played = masked_played || hold_played.is_some();
let base_played = base_played || (drv.mode, drv.gait) != pre_state;
wound_evict(&mut drv, &mut player, masked_played, base_played);
if let Some(&edge) = pending_wound.get(&entity) {
// A melee edge picks its wound id by severity + the victim's engagement (decision
// 0111); a spell edge arrives with the kit's own id (the client passes it through).
let id = match edge {
WoundEdge::Melee(hit_info) => select::wound_anim(hit_info, engaged),
WoundEdge::Spell(anim_id) => anim_id,
};
wound_trigger(
&mut drv,
&mut player,
anims,
catalog,
&mut rng,
id,
&mv,
mounted,
);
}
// ── The per-animation sheath reconcile (decision 0080 structure 3 — the client's
// `0x5fdf80`, run after every animation pick). The playing clip's `AnimationData.dbc`
// WeaponFlags + engagement force the state, data-driven — swimming/looting/casting stow,
// punching stows (fists need empty hands), armed attacks and the Ready idles draw — and
// a remote unit with no force pulls back to the server byte. Every force is a SNAP; the
// local player's force volunteers `CMSG_SETSHEATHED` (`bFireEvent = 1`), which the
// server echoes into the descriptor for observers. This is why a vendor gossip stows:
// the talk emote's flags, not a vendor wire (0080's resolved mystery) — future systems
// (gossip, loot, mounts) inherit the policy with zero new sheath wiring.
// The reconcile tests the **requested** id, not the model-resolved substitute (decision
// 0125, director-eyes falsification of 0082's resolved-id reading): the client's arm
// descriptor carries the id that was ASKED FOR (the cast-override §2.1 writes the kit's
// own anim id even on a model lacking the sequence), and `0x5fdb50` reads that
// descriptor. The two differ only when a model lacks the requested clip — the ground
// truth is GnollCaster.m2: no ReadySpellDirected(51) at all, its hold falls back to a
// flags-less Stand for *playback*, yet the ref still stows the staff for the whole
// windup — only 51's own `&4` row can do that. (0082's chicken rationale collapses under
// the same lens: requested Attack2H's 0x20 vs resolved AttackUnarmed's 0x10 is
// unobservable on a weaponless chicken.) Byte arbitration of `0x5fdb50`'s miss path is
// dispatched to wow-re; the director's eyes outrank the old reading meanwhile.
// The reconcile must see **every play** (decision 0087 (d)): the client's `0x5fdf80` runs
// inside `PlayAnimation` itself, so a *masked* one-shot — which never touched `mode`, so
// `active_anim()` still reports the base gait — is reconciled all the same. A masked emote
// carrying WeaponFlags `0x10` still stows. So this frame's routed one-shot id (either route)
// takes precedence for the reconcile; absent one, the base track's playing id is tested.
// And it must see **only plays** (the same byte-fact's other half): `PlayAnimation` is the
// reconcile's *sole* trigger, so a frame where nothing played keeps the committed state
// untouched. Re-running it every frame against the base track was the caster staff bug: a
// masked cast hold stows on its retake play, then the very next frame the base gait
// (flags-less Stand/Run) + the engaged/server-byte re-assert yanked the weapon back out —
// the client instead holds the stow across the base loop's silent wraps until something
// actually plays. The server-byte adopt above needs no play, exactly like the client's
// field-change watcher (`0x604c70`).
// …and, beyond plays: a mounted unit whose committed state reads drawn anyway (the
// descriptor byte-adopt above is the one write the setter gate can't refuse) re-arms
// the reconcile directly — the client's recompute would run on the composite's next
// mount-gait play, a beat our silent rider track never gets. Forces once (the mounted
// branch yields 0, cur becomes 0, the re-arm goes quiet) — no every-frame oscillation
// (the caster-staff trap below).
if played_oneshot.is_some()
|| hold_played.is_some()
|| base_played
|| (mounted && drv.sheath_cur.unwrap_or(0) != 0)
{
let cur = drv.sheath_cur.unwrap_or(0);
let anim = played_oneshot
.or(hold_played)
.or_else(|| drv.active_anim())
.unwrap_or(STAND);
let flags = catalog.map_or(0, |cat| cat.weapon_flags(anim));
if let Some(forced) =
select::reconcile_sheath(cur, anim, flags, engaged, is_self, sheath_byte, mounted)
{
// The ranged wind-up **bracket** (wow-re `ranged-sheath-exempt-autorepeat.md`,
// Q1's ordering law): the client surrounds every ranged kit play with ranged
// snaps — `0x60f34c` before the play, the outer send/START `SetSheatheState
// (2,1,1)` after — so ReadyThrown 108's genuine force-stow (NOT in the `0x5fe180`
// exempt set) exists but never renders: the snap always wins the frame. Our
// frame-spread ECS can't reproduce the call-stack order, so the bracket is
// stated directly: while a ranged shot's precast hold is live, a reconcile
// stow resolves to the drawn-ranged state instead. (Named deviation: the real
// *remote* thrower's wind-up ends CUR=0 until its GO — a derived, unobserved
// corollary; we keep every caster at 2, the observable the director verified.)
let forced = if forced != 2 && cast_hold.is_some_and(|h| h.ranged) {
2
} else {
forced
};
if forced != cur {
debug!(
"sheath: unit {entity} {cur} -> {forced} (anim {anim} flags {flags:#x}, \
engaged {engaged})"
);
drv.sheath_cur = Some(forced);
if is_self {
let _ = net.0.send(ClientCommand::SetSheathed {
state: u32::from(forced),
});
}
}
}
}
// Leaving the ranged sheath stance un-nocks (the client's `0x60fc72` un-nock, gated
// `[+0xd40] != 2`; wow-re `nocked-ammo-cancel.md`): however the state left 2 this frame
// — a stow/melee request, the reconcile, the server byte — the ammo model drops.
// Re-drawing shows no ammo until the next `SMSG_SPELL_START` re-affirms it.
if sheath_frame_start == Some(2) && drv.sheath_cur != Some(2) {
commands
.entity(entity)
.remove::<(super::NockedAmmo, super::NockLatch)>();
}
// …and **TRANSLATING** un-nocks too — the arrow leaves the hand and the string relaxes,
// while the cached ammo display survives for the next pull and the weapon stays DRAWN.
// Byte-verified (wow-re `shooter-stop-law.md` §J1.1, which refuted our recorded "nothing
// in the client un-nocks on movement"): `0x60e480` is a movement-OPCODE dispatcher whose
// jump-table arm 0 runs `0x60e4ac call 0x60f530` (un-nock) + `RecomputeBaseAnim(-1)`, and
// arm 0 is reached by exactly MSG_MOVE_START_{FORWARD, BACKWARD, STRAFE_LEFT,
// STRAFE_RIGHT, SWIM} (181/182/184/185/202). **Turning and stopping do NOT un-nock** —
// they take arm 1, which recomputes only. So the mask is the four translation bits plus
// SWIMMING, never the turn bits: [`move_flags::ANY_MOVE`] is exactly the former, and this
// is the one place that needs the swim bit with it.
//
// The reference's trigger is the START opcode (an edge); a level test is equivalent here
// because the only thing that can re-latch is `$BWP`, authored solely in the Load clips —
// which a translating unit never plays. Sheath is untouched (§J1's claim-4 split: REFUTED
// for un-nock, CONFIRMED for stow — `[+0xd40]` has no movement-path writer).
if mv.flags & (move_flags::ANY_MOVE | move_flags::SWIMMING) != 0 && nock_latched {
commands.entity(entity).remove::<super::NockLatch>();
}
}
if anim_cost && cost_rows > 0 {
use std::sync::atomic::{AtomicU32, Ordering};
static FRAME: AtomicU32 = AtomicU32::new(0);
// Once a second at 60 fps, not per frame — the counter is a sizing read, not a trace.
if FRAME.fetch_add(1, Ordering::Relaxed).is_multiple_of(64) {
eprintln!("[anim-cost] rows={cost_rows} resting={cost_resting}");
}
}
}
/// `WOW_ANIM_COST=1` — arms the memo-sizing counter above. Read once, the meter posture.
fn anim_cost_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("WOW_ANIM_COST").as_deref() == Ok("1"))
}