- on a 21:9 display the login, select and create chrome lays out on the boxed scene instead of the black bars beside it; at 16:9 nothing moves (B377, 2091) - a <Model> tile draws only inside its own cell of the shared atlas, so the pet bar's autocast shine no longer bleeds a golden bar onto a bag slot's cooldown (B379, 2093); a slider's first value always fires its handler, so Atlas's options load (2095) - a knockdown plays out: the reference locks the base animation for Knockdown, LiftOff and Land until the clip ends, and benilla re-picked Stand ten milliseconds in (2096) - the loot window closes and releases the corpse on your first step, strafe, turn or jump, the reference's movement-start guard; a distance leash shipped first and was a mislabelled census row (2094 → 2097, B381) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1516 lines
84 KiB
Rust
1516 lines
84 KiB
Rust
//! Movement-driven character & creature animation (decision 0019, Milestone C; the locomotion controller
|
||
//! of decisions 0047 + 0049). Each animated unit carries a [`ModelAnimations`] component (its clips, by
|
||
//! `AnimationData.dbc` id), an [`AnimationPlayer`], and an
|
||
//! [`AnimationTransitions`][bevy::animation::transition::AnimationTransitions]; this module is a
|
||
//! small per-unit state machine that picks the animation from the unit's movement state, **cross-fades**
|
||
//! into it over the clip's blend-in time, and scales a locomotion clip's playback rate to the unit's
|
||
//! speed.
|
||
//!
|
||
//! Regimes (decisions 0049 → 0083/0087):
|
||
//! - **Gait** — the directly-cross-faded ground/swim gaits + idle: Stand / Walk / Run / Fast-run by speed,
|
||
//! WalkBackwards when moving back, the swim ids when swimming (verified client selector, wow-5875-re
|
||
//! RF-0057). A change cross-fades to the new clip over its `blend_time` instead of snapping.
|
||
//! - **Special** — the stand-state poses (sit/sleep/kneel: down → loop → up) as one-shot-bracketed
|
||
//! loops, **preemptible** (decision 0055), plus the jump's enter/hang. The jump's *landing* is NOT a
|
||
//! bracket: [`select::Mode::Land`] is a plain pick from the input at touchdown, freely overwritten the
|
||
//! instant a movement flag changes (decisions 0083/0087 — land-then-press runs immediately).
|
||
//! - **One-shots (swings, emotes)** — routed **per play from live state** (decision 0087, the
|
||
//! byte-verified `0x5fe2f0` route): a standing-idle unit plays them full-body on the base track
|
||
//! ([`select::Mode::Swing`]); a moving / seated / combat-airborne unit plays them on the SpineLow
|
||
//! torso-masked overlay ([`AnimClip::upper_node`]) while the base keeps driving the legs underneath —
|
||
//! never dropped, never bracket-gated.
|
||
//!
|
||
//! The playback **rate** of a locomotion clip is `speed / sequence.moveSpeed` (wow-5875-re `0x5fe2f0`), so
|
||
//! a unit moving faster than the clip's design speed cycles its legs proportionally faster (the fix for a
|
||
//! backpedal that otherwise drags). Non-locomotion clips (idle, the jump/sit transitions — `moveSpeed 0`)
|
||
//! play at 1×. Death (decoded health 0) overrides everything.
|
||
//!
|
||
//! The movement view is unified per unit ([`select::unify`]): the self-avatar's [`MovementState`] is
|
||
//! filled by the player controller from input; a **remote player**'s comes from its relayed
|
||
//! `MSG_MOVE_*` stream (live CMovement flags + extrapolation speed, via [`crate::net::RemoteMotion`]),
|
||
//! so its walk/run/ strafe/backpedal/turn animate from the real flags; a **creature**'s is derived from
|
||
//! its server [`crate::net::Spline`] (forward + speed). Stand-state (sit/sleep/kneel/chairs) comes from
|
||
//! the unit's `UNIT_FIELD_BYTES_1` byte for streamed units, and from the controller (echo + in-flight
|
||
//! request) for our own avatar (decision 0080c).
|
||
|
||
use std::time::Instant;
|
||
|
||
use benilla_assets::{AnimClip, ModelAnimations};
|
||
use benilla_formats::AnimDataCatalog;
|
||
use bevy::prelude::*;
|
||
|
||
use benilla_assets::AssetSet;
|
||
use benilla_world::schedule::WorldStage;
|
||
|
||
/// The pure animation-selection logic (RF-0057/0073 tables, movement/Special state, gait/swing/ready
|
||
/// picks, playback-rate math) — kept in its own file as it carries the bulk of the unit-tested selector
|
||
/// logic, separate from the Bevy driver systems in [`driver`].
|
||
pub(crate) mod select;
|
||
pub(crate) use select::{
|
||
ease_strafe_yaw, move_flags, strafe_body_offset, swim_body_rotation, MovementState,
|
||
};
|
||
use select::{Mode, Special};
|
||
|
||
/// The display-facing counter-twist (the strafe body pose): the [`BodyTwist`] component + the
|
||
/// post-animation system that composes the SpineLow/Head counter-rotation onto the frame's pose.
|
||
mod twist;
|
||
pub(crate) use twist::{wrap_pi, BodyTwist};
|
||
|
||
/// The unit animation-LOD gate (decision 0448): park an off-frustum rig's per-bone pose
|
||
/// evaluation — the clocks, the driver state machine, and the event tracks keep running, so
|
||
/// off-screen combat stays audible (0075) and a re-appearing unit snaps to the absolute-clock
|
||
/// pose. Shipped as a modernization; wow-re's 2026-08-13 election correction
|
||
/// (`outdoor-object-pass-election.md`) made it the faithful direction instead — decision 1473
|
||
/// records that, and what still diverges (parked rigs keep drawing; the reference keeps only
|
||
/// `MORE_AUDIBLE` creatures audible off-screen).
|
||
mod lod;
|
||
|
||
/// The unit's wielded weapon classes — `(item class, item subclass)` per hand, `None` for an empty
|
||
/// (or non-item) hand. Written by the held-item resolution ([`crate::entities`], decision 0072) from
|
||
/// the same descriptor data that places the weapon models; read by the swing/ready animation
|
||
/// selectors (decision 0073 — the byte-verified `GetWeapon` byte pair `0x605e30`).
|
||
///
|
||
/// **The two readings of a hand** (decision 1863). The reference's accessor takes a `visFlag`:
|
||
/// `GetWeapon(slot, 1)` is what is *worn* there, `GetWeapon(slot, 0)` is what the unit can
|
||
/// *fight* with — and the two differ by exactly one live descriptor bit, [`UNIT_FLAG_DISARMED`].
|
||
/// The fields below are the `visFlag == 1` reading — what the paperdoll and the dress-up model
|
||
/// show, the two surfaces that keep the weapon while disarmed because they read the flag nowhere.
|
||
/// Everything that decides how the unit *behaves* — swing, Ready idle, parry, the sheath machine,
|
||
/// the swing sound, the attached model — goes through [`Wielded::armed_main`] /
|
||
/// [`Wielded::armed_off`], which apply the ladder.
|
||
#[derive(Component, Clone, Copy, Default, PartialEq, Eq)]
|
||
pub(crate) struct Wielded {
|
||
pub(crate) main: Option<(u8, u8)>,
|
||
pub(crate) off: Option<(u8, u8)>,
|
||
/// The **ranged-slot** item's `(class, subclass)` — the local auto-repeat idle's selector
|
||
/// ([`select::ranged_load_anim`], the client's `0x5fd530` LUT; decision 0099 phase 5).
|
||
pub(crate) ranged: Option<(u8, u8)>,
|
||
/// The mainhand/offhand items' sheath *types* (1 back-2H · 2 back-staff · 3 hip · 4 shield ·
|
||
/// 0 none) — each picks its arm's draw/stow one-shot independently (VERIFIED wow-re
|
||
/// `sheath-anim-pick.md`: `(1 << (rec[+4] & 0x1f)) & 0x88` → HipSheath 90 for types {3, 7},
|
||
/// Sheath 89 otherwise; the slot's own byte is the *only* input).
|
||
pub(crate) main_sheath: u8,
|
||
pub(crate) off_sheath: u8,
|
||
/// The ranged item's sheath type, same pick — the ranged **draw** clip reads it
|
||
/// (`0x6118a0`/`0x611960`/`0x611a20` all run `(1 << (rng[+4] & 0x1f)) & 0x88` on the ranged
|
||
/// record). Its **stow** clip does not: `0x611b60`'s PREV==2 legs push a literal `0x59`
|
||
/// (`0x611c8c`/`0x611cd3`), so a bow is always put away with Sheath 89.
|
||
pub(crate) ranged_sheath: u8,
|
||
/// The ranged item's `InventoryType` — the byte-verified **arm** pick: `0x1a`
|
||
/// (RANGEDRIGHT: gun/crossbow/wand) and `0x19` (THROWN) go to the right arm, everything else
|
||
/// (`0x0f`, a bow) to the left (`0x611b60` @ `0x611c74`, and the same compare in both
|
||
/// drawers). `0` when nothing is in the ranged slot.
|
||
pub(crate) ranged_inv: u32,
|
||
/// Each slot's `Material` id — the **only** key the draw/stow sound pick uses
|
||
/// (`SheatheSoundLookups`, decision 0882). Not derivable from the weapon class: real 5875 data
|
||
/// has maces (subclass 4) in both metal and wood, and puts every bow, crossbow and wand in
|
||
/// wood while guns and thrown are metal. It rides the wire — `SMSG_ITEM_QUERY_SINGLE_RESPONSE`
|
||
/// for players, the `UNIT_VIRTUAL_ITEM_INFO` byte triple for creatures — so nothing here is a
|
||
/// guess. Indexed by held slot: 0 mainhand · 1 offhand · 2 ranged.
|
||
pub(crate) materials: [u8; 3],
|
||
/// `UNIT_FIELD_FLAGS & `[`UNIT_FLAG_DISARMED`] — a rogue's Disarm, up on the unit's own
|
||
/// descriptor and therefore replicated for **every** unit, not just ours (CGUnit's ladder is
|
||
/// byte-identical to CGPlayer's, so a disarmed creature behaves exactly like a disarmed
|
||
/// player). Resolved beside the hands because that is where the reference reads it: inside
|
||
/// `GetWeapon` itself (`[[unit+0x110]+0xa0] & 0x200000`), off the same descriptor these pairs
|
||
/// come from.
|
||
pub(crate) disarmed: bool,
|
||
}
|
||
|
||
/// `UNIT_FIELD_FLAGS` bit 21 — **`UNIT_FLAG_DISARMED`** (vmangos `UnitDefines.h`), read by the
|
||
/// reference inside `GetWeapon` at `0x5ec2b8` (CGPlayer) and `0x605e58`/`0x605e98` (CGUnit):
|
||
/// `test dword ptr [ecx+0xa0], 0x200000`. `0xa0 = 4 × 0x28` pins it to field `UNIT_FIELD_FLAGS`
|
||
/// off the descriptor base `[unit+0x110]`.
|
||
pub(crate) const UNIT_FLAG_DISARMED: u32 = 0x0020_0000;
|
||
|
||
/// `ItemClass` 2 — **WEAPON**. The class byte the reference's disarm ladder tests, and the whole
|
||
/// of its test: no subclass, no inventory type, nothing else. Disarm takes weapons, so a shield or
|
||
/// a held-in-off-hand trinket is never the hand it hides.
|
||
pub(crate) const ITEM_CLASS_WEAPON: u8 = 2;
|
||
|
||
/// **The disarm ladder** — which single held slot `UNIT_FLAG_DISARMED` hides, given what each
|
||
/// melee hand holds (decision 1863; wow-re `disarm-weapon-gate-law.md` §2, byte-verified by a
|
||
/// trio including a cold decoder-first read, both class bodies identical).
|
||
///
|
||
/// The reference does not "empty a disarmed unit's hands". `GetWeapon(slot, visFlag = 0)` runs a
|
||
/// **two-probe ladder with main-hand precedence**, and the off-hand probe's polarity is the
|
||
/// opposite of the main hand's:
|
||
///
|
||
/// ```text
|
||
/// ; slot 0 (MAIN HAND) — one probe, 0x5ec2ae
|
||
/// 5ec2b8: test dword ptr [ecx+0xa0],0x200000 ; disarmed?
|
||
/// 5ec2cc: call [edx+0x98] ; GetWeapon(0, 1) — vis=1 skips the gate
|
||
/// 5ec2d6: cmp byte ptr [eax],2 ; je 0x5ec346 ; class 2 -> RETURN NULL
|
||
///
|
||
/// ; slot 1 (OFF HAND) — two probes, 0x5ec262, MAIN HAND asked FIRST
|
||
/// 5ec280: call [edx+0x98] ; GetWeapon(0, 1) <<< slot 0, not 1
|
||
/// 5ec28a: cmp byte ptr [eax],2 ; je 0x5ec2aa ; main hand IS a weapon -> PROCEED (keep the off hand)
|
||
/// 5ec297: call [eax+0x98] ; GetWeapon(1, 1)
|
||
/// 5ec2a1: cmp byte ptr [eax],2 ; je 0x5ec346 ; off hand IS a weapon -> RETURN NULL
|
||
/// ```
|
||
///
|
||
/// So **exactly one weapon is hidden — the first one found scanning main hand → off hand** — and
|
||
/// a disarmed dual-wielder keeps swinging their off-hand weapon, model and all. vmangos agrees
|
||
/// from the other side: `Unit::CanUseEquippedWeapon` returns `!DISARMED` for `BASE_ATTACK` and an
|
||
/// unconditional `true` for `OFF_ATTACK` and `RANGED_ATTACK`. The **ranged slot is never gated**
|
||
/// (`0x5ec25e` pushes equip index `0x11` and jumps past both tests).
|
||
///
|
||
/// Stated over classes rather than over [`Wielded`] because two layers need the same law from two
|
||
/// different data sources — the animation layer off the resolved hands, the action bar's
|
||
/// equipped-item test off the raw inventory (`0x5ea5d0`'s slot 0/1/2 loop).
|
||
pub(crate) fn disarmed_hand(main_class: Option<u8>, off_class: Option<u8>) -> Option<usize> {
|
||
if main_class == Some(ITEM_CLASS_WEAPON) {
|
||
return Some(0);
|
||
}
|
||
if off_class == Some(ITEM_CLASS_WEAPON) {
|
||
return Some(1);
|
||
}
|
||
None
|
||
}
|
||
|
||
impl Wielded {
|
||
/// The held slot this unit's disarm hides right now — [`disarmed_hand`] over its own hands,
|
||
/// `None` while the flag is down. The equipment layer reads it to know which weapon the
|
||
/// reference's flag reflex takes off the hand ([`crate::entities`]).
|
||
pub(crate) fn disarmed_hand(&self) -> Option<usize> {
|
||
self.disarmed
|
||
.then(|| disarmed_hand(self.main.map(|(c, _)| c), self.off.map(|(c, _)| c)))
|
||
.flatten()
|
||
}
|
||
|
||
/// The **mainhand** as `GetWeapon(0, 0)` sees it — the reading the swing (`0x6246a0`), the
|
||
/// Ready idle (`0x5fcdc0`), the parry LUT, the sheath machine and the swing sound all take.
|
||
pub(crate) fn armed_main(&self) -> Option<(u8, u8)> {
|
||
self.main.filter(|_| self.disarmed_hand() != Some(0))
|
||
}
|
||
|
||
/// The **offhand** as `GetWeapon(1, 0)` sees it. Nulled only when the main hand did *not*
|
||
/// claim the disarm — see [`disarmed_hand`].
|
||
pub(crate) fn armed_off(&self) -> Option<(u8, u8)> {
|
||
self.off.filter(|_| self.disarmed_hand() != Some(1))
|
||
}
|
||
}
|
||
|
||
/// The unit is engaged in melee auto-attack (`SMSG_ATTACKSTART` .. `ATTACKSTOP`, decision 0073):
|
||
/// standing still it plays the weapon-class Ready idle — the client's `0x5fd360` arm gates on the
|
||
/// auto-attack-target GUID being set, i.e. engagement, **not** sheath state.
|
||
#[derive(Component)]
|
||
pub(crate) struct Engaged;
|
||
|
||
/// The local player has fired an auto-repeat spell (Auto Shot / wand Shoot) — the client's
|
||
/// `[+0xd58] & 0x200`, whose **only writer binary-wide** is the local cast-send tail (`0x6e593b`,
|
||
/// gated `AttributesEx2 & 0x20`; byte-verified, wow-re `ranged-shot-anim.md`). While it and the
|
||
/// ranged **sheath** gate (`CUR == 2`) both hold, a standing unit's base idle is the ranged
|
||
/// weapon's Load clip ([`select::ranged_load_anim`]) — the client's resolver `0x5fd460`,
|
||
/// which gates on exactly this bit; local-player-only by construction (remote units never run
|
||
/// the local cast-send). A REMOTE shooter has no drawn idle at all: it plays LoadBow **once** at
|
||
/// the volley's single `SMSG_SPELL_START` through the PrecastKit ([`CastHold`]), then the fire
|
||
/// clip per GO over its ordinary idle (wow-re `shooter-stop-law.md` §J6 claim 2). Cleared
|
||
/// only by [`cancel_auto_repeat_local`]'s callers — the client's cancel `0x6ea080` (its `0x200`
|
||
/// clear `0x6ea113`), reached from the cast-result fail of the cached spell, the button
|
||
/// re-press toggle, melee attack-start, target death, the wand-only new-cast handoff, and
|
||
/// `SMSG_CANCEL_AUTO_REPEAT` — which vmangos DOES send (corrected 2026-08-05; see
|
||
/// [`crate::net::apply::spells::cancel_auto_repeat`]), so against a live server that packet, not
|
||
/// a local death watcher, is what ends a volley whose target dies (wow-re
|
||
/// `nocked-ammo-cancel.md`).
|
||
#[derive(Component)]
|
||
pub(crate) struct AutoRepeatArmed;
|
||
|
||
/// The weapon-visual hold — the client's `[+0xd58] & 0x400`, set for **ANY caster** (self,
|
||
/// remote players, NPCs) whose **ranged** spell visual plays (`0x60d020`, sole caller `0x6ec2cf`
|
||
/// inside `PlaySpellVisual`; byte-verified, wow-re `ranged-sheath-exempt-autorepeat.md` §Q4).
|
||
/// Cleared like the client's id-matched `0x60d040` + its unconditional sibling `0x60fc50`: a
|
||
/// **non-ranged** visual play by the same caster (the stale-visual cleanup `0x6ec39e`), the
|
||
/// local cancel (`0x6ea12b`), melee attack-start, and the spline-move apply (`0x6020e8`, wow-re
|
||
/// `shooter-stop-law.md` §J1.3). Deliberately NOT cleared on sheath change or volley end — the
|
||
/// client's bit persists latent.
|
||
///
|
||
/// **It gates nothing here — but no longer for the reason this doc used to give.** `0x400` is
|
||
/// tested in exactly one place image-wide: `0x5fc3f0`'s Hold self-loop gates (`test ah,0x6` for
|
||
/// HoldBow 109, `test ah,0x4` for 110/111/112). 0994 recorded that the dispatcher is never
|
||
/// reached for a bow id (`shooter-stop-law.md` §J4.1) and concluded the bit is dead. **wow-re's
|
||
/// §5 refuted that absence proof** (decision 1544): the dispatcher has a second, deferred fire
|
||
/// site, so those gates DO execute — they are the Hold's own per-completion re-arm.
|
||
///
|
||
/// We still gate nothing on it, and that is now a *modelling* choice with a stated equivalence
|
||
/// rather than a claim about the binary. Our Hold is armed once and loops until a real recompute
|
||
/// (`select::ranged_hold_anim`), where the reference re-arms it at each completion while the gate
|
||
/// holds. The two diverge only where `0x400` holds *without* `0x200` — a remote shooter's latent
|
||
/// hold — and a remote shooter never enters the drawn idle at all (`0x5fd460` claims on `0x200`,
|
||
/// which only the local cast-send sets), so the case cannot arise. Reading the bit as a second
|
||
/// *entry* into the drawn idle remains wrong: that is what left a shooter aiming forever after
|
||
/// one Serpent Sting (decision 0991).
|
||
#[derive(Component)]
|
||
pub(crate) struct RangedHold;
|
||
|
||
/// A unit's displayed **nocked ammo** — the client's `[+0xd28]`/`[+0xd2c]` ammo-model pair,
|
||
/// written only by `CGUnit::UpdateAmmoDisplay 0x60ba30` (byte-verified, wow-re
|
||
/// `nocked-ammo-cancel.md`): the ammo's `ItemDisplayInfo` id, rendered as a model on the unit's
|
||
/// **body** at HandArrow (35) — the ONE bone attach, bow-only (§E2; the old Special2/Special3
|
||
/// reading is refuted — those are model-directory selectors, §E1). Set per shot from the
|
||
/// `SMSG_SPELL_START` `CAST_FLAG_AMMO` tail (any caster — the only source for a remote/NPC
|
||
/// shooter). This is the display-id CACHE; whether the arrow is visibly in the hand is the
|
||
/// [`NockLatch`] (the `$BWP`/`$BWR` cycle). Removed by the un-nock `0x60f530` paths: the local
|
||
/// cancel, leaving the ranged sheath stance (`0x60fc72`), or a weapon change.
|
||
#[derive(Component, Clone, Copy, PartialEq, Eq)]
|
||
pub(crate) struct NockedAmmo {
|
||
/// The ammo's `ItemDisplayInfo` id (never 0 — an id-0 refresh removes the component instead).
|
||
pub(crate) display_id: u32,
|
||
}
|
||
|
||
/// The client's nock latch `[+0xd58] & 0x4000` (wow-re `nocked-ammo-cancel.md` §G1/G4): SET at
|
||
/// the body clip's **`$BWP`** BowPull keyframe (LoadBow 105 carries it at ~0.6 s — the visible
|
||
/// pull-from-quiver moment, which also (re)attaches the in-hand arrow), CLEARED at **`$BWR`**
|
||
/// BowRelease (AttackBow 46 carries it at ~0.067 s — the arrow leaves the hand as the missile
|
||
/// flies) and by the un-nock paths. The equipment attach leg shows the [`NockedAmmo`] model only
|
||
/// while this holds, and the bowstring's middle vertex tracks the draw hand on the same gate.
|
||
#[derive(Component)]
|
||
pub(crate) struct NockLatch;
|
||
|
||
/// Drive [`NockLatch`] from the anim-event stream ([`AnimSoundEvent`] — the same `$`-tag
|
||
/// keyframes the sound/impact consumers ride): `$BWP` latches (only a unit with a [`NockedAmmo`]
|
||
/// display cached — the client's handler gates on the cached ranged spell), `$BWR` clears.
|
||
/// Ordered after [`events::fire_anim_events`] so a tag lands the frame it's crossed.
|
||
pub(crate) fn drive_nock_latch(
|
||
mut events: MessageReader<AnimSoundEvent>,
|
||
nocked: Query<Has<NockedAmmo>>,
|
||
mut commands: Commands,
|
||
) {
|
||
for ev in events.read() {
|
||
match &ev.ident {
|
||
b"$BWP" if nocked.get(ev.entity) == Ok(true) => {
|
||
// `try_insert`, matching the `$BWR` arm's guard below: the tag names a unit that
|
||
// may be despawned by the time these commands apply (the fade lane is unordered
|
||
// against this chain — B130's second window, in the same defect class).
|
||
commands.entity(ev.entity).try_insert(NockLatch);
|
||
}
|
||
b"$BWR" => {
|
||
if let Ok(mut e) = commands.get_entity(ev.entity) {
|
||
e.remove::<NockLatch>();
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The one local auto-repeat cancel — benilla's `0x6ea080` (byte-verified whole, wow-re
|
||
/// `nocked-ammo-cancel.md`): clear the live key (`[0xceac30]`), drop the Load/Hold idle gates
|
||
/// (the `0x200`/`0x400` bits — [`AutoRepeatArmed`] + [`RangedHold`]), un-nock the ammo
|
||
/// (`0x60f530` — [`NockedAmmo`]), and ack the server (`CMSG_CANCEL_AUTO_REPEAT_SPELL`, the
|
||
/// client's sole send site `0x6ea0c6`) — **sheath untouched**: the weapon stays drawn, the idle
|
||
/// falls back to the ordinary stand. Every cancel trigger funnels here, like the client's 8
|
||
/// live callers.
|
||
pub(crate) fn cancel_auto_repeat_local(
|
||
entity: Option<Entity>,
|
||
auto_repeat: &mut crate::ui_action::AutoRepeatActive,
|
||
commands: &mut Commands,
|
||
net: &crate::net::NetCommands,
|
||
) {
|
||
if auto_repeat.0.is_none() {
|
||
// The client's `[0xceac30] == 0` early-out — nothing running, nothing sent.
|
||
return;
|
||
}
|
||
auto_repeat.0 = None;
|
||
if let Some(e) = entity {
|
||
commands
|
||
.entity(e)
|
||
.remove::<(AutoRepeatArmed, RangedHold, NockedAmmo, NockLatch)>();
|
||
}
|
||
let _ = net.0.send(crate::net::ClientCommand::CancelAutoRepeat);
|
||
}
|
||
|
||
/// The one local **StopAttack** — benilla's `0x5ecac0` (wow-re §5 trio-verified,
|
||
/// `object-layer/scratch/melee-autorepeat-exclusion.md`): player-only, a no-op unless an attack
|
||
/// lock is held, then `CMSG_ATTACKSTOP` (its builder `0x624370` pushes opcode `0x142`) followed by
|
||
/// the tail jump `0x5ecb63 → CancelQueuedCast 0x6e6f30` — which takes the **queued on-next-swing
|
||
/// spell** down with the attack: `0x6e6f30`'s first leg tests the inflight id `[0xceca88]` for
|
||
/// `Attributes & 0x404` and hands it to `CancelCast 0x6e4940(dl=1, reason 0x1c)`, whose casting arm
|
||
/// sends `CMSG_CANCEL_CAST 0x12f` naming it and then pops the slot through `0x6e4ad0`.
|
||
///
|
||
/// This is the un-queue [`crate::ui_cast::QueuedMeleeSpell`] names as its real clear path, and it
|
||
/// is why a Raptor Strike ring goes dark the moment Auto Shot starts (the auto-repeat arm inside
|
||
/// the cast commit calls straight into here, `0x6e5976`, guarded by nothing but "the caster is the
|
||
/// active player").
|
||
///
|
||
/// The lock is our server-echoed [`Engaged`] — the ref's `[+0xc48]`, which it sets *locally* at
|
||
/// attack-start and which **StopAttack does not clear** (only the `SMSG_ATTACKSTOP` echo does,
|
||
/// `0x624e40`), so the Attack button's own ring survives this call in both clients. Two ref fields
|
||
/// are deliberately unmodelled, neither with a benilla reader: `[+0xc50]` ("locally initiated, not
|
||
/// server-confirmed") and `[+0xc54]` ("stop sent, awaiting the echo") — the latter's sole reader
|
||
/// image-wide is `0x5eccda`, inside `Attack 0x5ecb70`, which no benilla cast path enters while
|
||
/// engaged (see [`crate::ui_action::cast_send`]'s tail). The two ratio legs `0x5ecac0` runs before
|
||
/// the stop are `TriggerTutorial(0xa)` / `(0xb)` — the low-health / low-mana popups, not audio and
|
||
/// not part of this seam.
|
||
pub(crate) fn stop_attack_local(
|
||
engaged: bool,
|
||
queued_melee: &mut crate::ui_cast::QueuedMeleeSpell,
|
||
net: &crate::net::NetCommands,
|
||
) {
|
||
if !engaged {
|
||
// The ref's `!IsAttacking(0x60ecb0) && [+0xc50] == 0` early-out: nothing running,
|
||
// nothing sent — and the queued strike, which cannot exist without a swing to fire it,
|
||
// survives untouched.
|
||
return;
|
||
}
|
||
let _ = net.0.send(crate::net::ClientCommand::AttackStop);
|
||
if let Some(spell_id) = queued_melee.current() {
|
||
let _ = net
|
||
.0
|
||
.send(crate::net::ClientCommand::CancelCast { spell_id });
|
||
queued_melee.clear_if(spell_id);
|
||
}
|
||
}
|
||
|
||
/// The one local **StartAttack** — benilla's `0x5ecb70`, [`stop_attack_local`]'s twin (wow-re §5
|
||
/// `melee-autorepeat-exclusion.md` §5c–§5f). Two halves, and keeping them apart is the whole point:
|
||
///
|
||
/// - **the send** (`0x5eccfd`) is gated by the already-attacking test at `0x5eccda` — skipped when
|
||
/// the attack lock is held, *unless* a stop is in flight (`[+0xc54]`), which is why
|
||
/// stop → select → re-swing re-points at a new target instead of going quiet;
|
||
/// - **the tail** (`0x5ecd78`–`0x5ecd95`) — melee sheath SNAP then the local auto-repeat cancel
|
||
/// `0x6ea080` — runs either way, because the skip's `jmp` lands *inside* it. That is what makes
|
||
/// "start swinging" and "keep auto-shooting" mutually exclusive.
|
||
///
|
||
/// `stop_in_flight` is a **parameter, not stored state**: the ref's `[+0xc54]` is set by
|
||
/// [`stop_attack_local`] and consumed here, and the only caller that spans both is the target
|
||
/// switch, which does them back to back in one call chain. Decision 1036 removed the persistent
|
||
/// latch for exactly this reason — outside that chain it has no reader.
|
||
///
|
||
/// The caller owes the reference's own two entry conditions, which differ per site and are not
|
||
/// re-derived here: a cast press must not call this at all while engaged (`TryCast`'s `6e51cb`/
|
||
/// `6e51d2` gate), and the Attack button forks to [`stop_attack_local`] instead (`0x6131a0` @
|
||
/// `0x6131d9`). `0x5ecb70`'s own validator legs — the target's alive-or-feign + `CanAttack
|
||
/// 0x606980` walk, and the `[0xb4b3e4]` world gate — stay with the callers that already compute
|
||
/// them (`target::scan`'s `new_attackable`, the drain's `attack_actor_refusal`).
|
||
#[allow(clippy::too_many_arguments)] // the tail writes four sinks; the alternative is a bundle
|
||
pub(crate) fn start_attack_local(
|
||
entity: Entity,
|
||
target: u64,
|
||
engaged: bool,
|
||
stop_in_flight: bool,
|
||
auto_repeat: &mut crate::ui_action::AutoRepeatActive,
|
||
sheath: &mut MessageWriter<SheathRequest>,
|
||
commands: &mut Commands,
|
||
net: &crate::net::NetCommands,
|
||
) {
|
||
// **Starting an attack stands you up** — `0x5ecc9b call 0x5ed430(0)`, inside this function and
|
||
// ahead of everything else it does (decision 1768).
|
||
//
|
||
// Its position is the content. It sits **after** the attackability cluster
|
||
// (`0x5ecc2e`/`0x5ecc35`, whose not-attackable leg diverts to `0x5ecc37` and never reaches the
|
||
// stand) — which is why a right-click on a friendly NPC does not stand you — and **before any
|
||
// range logic**, `CMSG_ATTACKSWING` being all the way down at `0x5eccfd`. So an attackable
|
||
// target out of reach *does* stand you, and so does re-issuing an attack you are already
|
||
// making: that skip lands at `0x5ecd78`, 73 bytes past the stand. Hence unconditional here,
|
||
// above the swing's own `engaged` fork, rather than inside it.
|
||
//
|
||
// benilla's callers own the attackability walk (see this function's header), so by the time we
|
||
// are called the diverting leg has already been taken elsewhere.
|
||
commands.write_message(crate::player::StandStateRequest { state: 0 });
|
||
if !engaged || stop_in_flight {
|
||
let _ = net
|
||
.0
|
||
.send(crate::net::ClientCommand::AttackSwing { guid: target });
|
||
}
|
||
// The tail. The sheath setter is idempotent — the client's own "no-op if already melee" — so
|
||
// re-running it on a re-point costs nothing and matches `0x5ecd80`'s unconditional call.
|
||
sheath.write(SheathRequest {
|
||
entity,
|
||
state: 1,
|
||
ceremony: false,
|
||
});
|
||
cancel_auto_repeat_local(Some(entity), auto_repeat, commands, net);
|
||
}
|
||
|
||
/// The **Attack toggle** — benilla's `0x6131a0`, the third of the trio and the one the base Attack
|
||
/// pseudo-spell runs (`TryCast`'s effect-`0x4e` short-circuit at `0x6e4c7a` dispatches straight to
|
||
/// it, ahead of every cast gate). It forks on the attack lock and nothing else: already attacking →
|
||
/// `0x6131d9 call 0x5ecac0` [`stop_attack_local`], otherwise `0x6131ee call 0x5ecb70`
|
||
/// [`start_attack_local`] (wow-re `melee-autorepeat-exclusion.md` §5f).
|
||
///
|
||
/// The fork is the whole content, and it is worth a name because both halves used to be wrong here:
|
||
/// the button never toggled melee OFF, and it cancelled a running auto-repeat on *every* press —
|
||
/// but only the start arm reaches `0x5ecd8c`, so in the reference toggling melee off leaves Auto
|
||
/// Shot running.
|
||
///
|
||
/// `[0xb4b3e4]` — the reference's second condition on the stop arm, a world/session global also
|
||
/// tested at `0x5ecbc0` — is unmodelled; nothing in benilla can be false there while a press is
|
||
/// being drained.
|
||
#[allow(clippy::too_many_arguments)] // the seams' write set, minus the bundle a caller can't hold
|
||
pub(crate) fn toggle_attack_local(
|
||
entity: Entity,
|
||
target: u64,
|
||
engaged: bool,
|
||
queued_melee: &mut crate::ui_cast::QueuedMeleeSpell,
|
||
auto_repeat: &mut crate::ui_action::AutoRepeatActive,
|
||
sheath: &mut MessageWriter<SheathRequest>,
|
||
commands: &mut Commands,
|
||
net: &crate::net::NetCommands,
|
||
) {
|
||
if engaged {
|
||
stop_attack_local(engaged, queued_melee, net);
|
||
} else {
|
||
start_attack_local(
|
||
entity,
|
||
target,
|
||
engaged,
|
||
false,
|
||
auto_repeat,
|
||
sheath,
|
||
commands,
|
||
net,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// **The local attack seams' whole write set, as ONE [`SystemParam`]** — so a caller threading
|
||
/// [`stop_attack_local`]/[`start_attack_local`] through a call chain takes one param instead of
|
||
/// six, and so a new sink in either seam lands in every caller at once.
|
||
///
|
||
/// The targeting side needs exactly this: `target::scan`'s `commit` runs the reference's
|
||
/// stop → select → re-swing law (`SetSelection 0x493540`'s own `0x493a08 call 0x5ecac0` and
|
||
/// `0x4938c8 call 0x5ecb70`), so every selection writer that can fire it has to carry both seams'
|
||
/// inputs. [`crate::ui_action::cast_send::CastLadder`] already carries them field by field for the
|
||
/// cast path and calls the free functions directly.
|
||
#[derive(bevy::ecs::system::SystemParam)]
|
||
pub(crate) struct AttackSeam<'w, 's> {
|
||
pub(crate) net: Res<'w, crate::net::NetCommands>,
|
||
pub(crate) queued_melee: ResMut<'w, crate::ui_cast::QueuedMeleeSpell>,
|
||
pub(crate) auto_repeat: ResMut<'w, crate::ui_action::AutoRepeatActive>,
|
||
pub(crate) sheath: MessageWriter<'w, SheathRequest>,
|
||
pub(crate) ecs: Commands<'w, 's>,
|
||
/// Our own entity — the sheath snap's and the auto-repeat cancel's subject.
|
||
pub(crate) me: Query<'w, 's, Entity, With<crate::net::SelfPlayer>>,
|
||
}
|
||
|
||
impl AttackSeam<'_, '_> {
|
||
/// `0x5ecac0` — see [`stop_attack_local`].
|
||
pub(crate) fn stop(&mut self, engaged: bool) {
|
||
stop_attack_local(engaged, &mut self.queued_melee, &self.net);
|
||
}
|
||
|
||
/// `0x5ecb70` — see [`start_attack_local`]. A no-op before our own entity streams in.
|
||
pub(crate) fn start(&mut self, target: u64, engaged: bool, stop_in_flight: bool) {
|
||
let Ok(e) = self.me.single() else { return };
|
||
start_attack_local(
|
||
e,
|
||
target,
|
||
engaged,
|
||
stop_in_flight,
|
||
&mut self.auto_repeat,
|
||
&mut self.sheath,
|
||
&mut self.ecs,
|
||
&self.net,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A unit is mid-cast (`SMSG_SPELL_START` .. `GO`/`FAILED_OTHER`, decision 0099 phase 1) — the
|
||
/// wire-truth state seam. Inserted on `SpellStart` only when it carries a nonzero cast time (an
|
||
/// instant cast's `SpellGo` follows with nothing to interrupt, so it never gets one); removed on
|
||
/// the matching-spell-id `SpellGo`/`SpellFailedOther` (the client's reap `0x614150(spellId, 0)` is
|
||
/// keyed — a triggered proc's GO mid-cast never clears a different spell's precast, decision 0107).
|
||
/// The *animation* side rides [`CastEvent`]/[`CastHold`] (the resolved layer,
|
||
/// [`spell_visual::route_cast_visuals`]), not this component. `until` is `Option` rather than a
|
||
/// bare deadline so a later channel (an open-ended cast, no fixed end) can land here without a
|
||
/// sentinel.
|
||
#[derive(Component, Clone, Copy)]
|
||
pub(crate) struct Casting {
|
||
pub(crate) spell_id: u32,
|
||
#[allow(dead_code)]
|
||
pub(crate) until: Option<Instant>,
|
||
}
|
||
|
||
/// The client's PlayAnimation **call order**, reconstructed. The real client has no pecking
|
||
/// order between a swing and a spell kit's anim landing in the same frame — its handlers run
|
||
/// synchronously in packet order and the LATER `PlayAnimation` call simply overwrites bone 0
|
||
/// (wow-re, the Eviscerate-timing round's arm C). benilla's swing and cast pipelines are
|
||
/// separate message streams, so that order must ride the messages: every emitter of an
|
||
/// animation-bearing request ([`SwingMessage`], [`CastEvent`], [`EmoteAnim`], [`KitPush`])
|
||
/// stamps `next()` at emission — the wire drain stamps in packet order; scene-time emitters
|
||
/// (a missile arriving, an interact emote) stamp when they fire, which is after that frame's
|
||
/// packets, exactly where the client's scene-update calls sit. [`driver::drive_animations`]
|
||
/// resolves a same-frame collision by the highest stamp.
|
||
#[derive(Resource, Default)]
|
||
pub(crate) struct PlaySeq(u64);
|
||
|
||
impl PlaySeq {
|
||
pub(crate) fn next(&mut self) -> u64 {
|
||
self.0 += 1;
|
||
self.0
|
||
}
|
||
}
|
||
|
||
/// A cast lifecycle edge on a streamed unit, straight off the wire (written by the net bridge,
|
||
/// consumed by [`spell_visual::route_cast_visuals`], which resolves spell → `SpellVisual` → kit →
|
||
/// animation/sound — the DBC knowledge stays in the anim layer, decision 0107).
|
||
#[derive(Message, Clone, Copy)]
|
||
pub(crate) struct CastEvent {
|
||
pub(crate) entity: Entity,
|
||
pub(crate) spell_id: u32,
|
||
pub(crate) kind: CastEventKind,
|
||
/// [`PlaySeq`] stamp at emission — carried through to the kit's [`EmoteAnim`].
|
||
pub(crate) seq: u64,
|
||
}
|
||
|
||
/// Which cast edge fired (decision 0107 verdict 2's stage wiring).
|
||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||
pub(crate) enum CastEventKind {
|
||
/// `SMSG_SPELL_START` — the precast kit persists from here (subliminal for an instant, whose
|
||
/// GO follows at once).
|
||
Start,
|
||
/// `SMSG_SPELL_GO` — reaps the precast, plays the cast/release kit.
|
||
Go,
|
||
/// The cast died without a release (`SMSG_SPELL_FAILED_OTHER`, or our own failed
|
||
/// `SMSG_CAST_RESULT`) — reap only, nothing plays.
|
||
Fail,
|
||
/// The spell **landed on this unit** (`entity` = the struck target, not the caster — decision
|
||
/// 0099 phase 4): a missile arrival, written back by `crate::entities::missile`. Plays the
|
||
/// impact kit (stage 1) + the state kit (stage 2) on the target — the client's unit-impact
|
||
/// hand-off `0x61dc50`. A speed-0 spell's impacts never come through here: the router plays
|
||
/// them inline at the GO (same resolve, no round-trip). `weapon_visual` = the caster's
|
||
/// ranged-weapon substitute `SpellVisual` id, resolved at GO time and carried through the
|
||
/// flight (the caster may despawn mid-flight; the client's missile carries its kit context)
|
||
/// — the `0x60d450` fallback a basic shot's impact kit resolves through (decision 0370).
|
||
Impact { weapon_visual: Option<u32> },
|
||
/// A projectile arrived at a **ground point** instead of on a unit — the client's per-tick
|
||
/// missile dispatch taking its ground arm (`0x61e1d0` → `0x61d870`), reached by the single
|
||
/// missile a dest-targeted GO with an empty hit list launches
|
||
/// ([`spell_visual::MissileSpawn::ground_aim`]). `entity` is the **caster** — `0x61d870`
|
||
/// plays the kit on it, not on anything at the point — and `pos` is the arrival position,
|
||
/// the client's `extra` override (`0x61d8e7`: `lea edx,[esi+0x20]`). Plays `SpellVisual`
|
||
/// **field 13 at stage 3** (wow-re `spell-visual-lifecycle.md` §Q4).
|
||
GroundImpact { pos: Vec3 },
|
||
}
|
||
|
||
/// The resolved casting **hold** — the animation a unit sustains while a cast or channel is in
|
||
/// flight (`SpellVisualKit` field 2 of the precast/channel kit, decision 0107). Managed entirely
|
||
/// by [`spell_visual::route_cast_visuals`]; the driver renders it per frame: **standing → the
|
||
/// gait slot, full-body** (the client's stationary-cast pin — the settled `[CGUnit+0xb4]` gate) —
|
||
/// **moving → a looping masked overlay** (the ordinary torso-masked route a moving caster falls
|
||
/// through to). A Special (jump/pose) outranks it — the server interrupts such casts anyway.
|
||
/// `spell_id` keys the reap, like everything in this pipeline (the client's `0x614150(spellId, …)`):
|
||
/// an unrelated spell's GO/fail never drops another cast's hold.
|
||
#[derive(Component, Clone, Copy)]
|
||
pub(crate) struct CastHold {
|
||
pub(crate) anim_id: u16,
|
||
pub(crate) spell_id: u32,
|
||
/// A ranged-slot shot's wind-up (`Attributes & 0x2` — Throw's ReadyThrown, the bow's
|
||
/// LoadBow): the client brackets every ranged kit play with ranged snaps (`0x60f34c`
|
||
/// before, the outer `0x6e5930`/`0x6e78f3` after — wow-re
|
||
/// `ranged-sheath-exempt-autorepeat.md`), so while this hold is live the sheath
|
||
/// reconcile's force-stow never survives the frame ([`driver`]'s bracket override).
|
||
pub(crate) ranged: bool,
|
||
}
|
||
|
||
/// Per-hand weapon-grip state: whether a weapon is held in the right/left hand, so that hand's fingers
|
||
/// curl into a grip. The real client arms `HandsClosed` (AnimationData 15) on a hand's finger key-bones
|
||
/// **purely by that hand's attach point being occupied by a weapon** — not combat, not sheath state; a
|
||
/// forearm-mounted shield or an empty hand stays open (wow-re `hand-grip-mechanism.md`, `0x60b590` /
|
||
/// paperdoll `0x5059a0`). Written by the held-item resolver ([`crate::entities`], decision 0072) from the
|
||
/// weapon's hand attach point; read by [`driver::drive_hand_grip`] to hold/release the finger overlay.
|
||
#[derive(Component, Default, Clone, Copy, PartialEq, Eq)]
|
||
pub(crate) struct HandGrip {
|
||
/// A weapon occupies the right-hand attach point (mainhand, id 1).
|
||
pub(crate) right: bool,
|
||
/// A weapon occupies the left-hand attach point (a non-shield offhand, id 2).
|
||
pub(crate) left: bool,
|
||
}
|
||
|
||
/// The weight of the per-hand weapon-grip finger overlay ([`benilla_assets::ModelAnimations::hand_close`])
|
||
/// over the base gait on the finger key-bones both drive. The base clip animates the whole skeleton
|
||
/// (fingers at Stand's flat 0°) and isn't masked out of the finger subtree, so grip and base blend there —
|
||
/// this makes the grip dominate ≈ 8:1 (the fingers read closed, not a half-open wash). Same rationale/value
|
||
/// as the sheath ceremony and the upper-body one-shot overlays. Shared by the live driver
|
||
/// ([`driver::drive_hand_grip`]) and the glue-booth spawn ([`crate::portrait`]) so both blend the grip
|
||
/// identically.
|
||
pub(crate) const HAND_GRIP_WEIGHT: f32 = 8.0;
|
||
|
||
/// One completed melee swing for `attacker` (`SMSG_ATTACKERSTATEUPDATE` → the net bridge; decision
|
||
/// 0073: one packet = one swing animation, no client-side timer). `hit_info` bit `0x4` = offhand
|
||
/// swing; bit `0x10000` suppresses the animation. The outcome fields (`victim`, `victim_state`,
|
||
/// `damage`) ride along for the combat-audio consumer (`sound::combat`), which plays the swing's
|
||
/// sounds when the animation's `$CSS`/`$CAH`/`$HIT` events fire. A fully-absorbed swing's
|
||
/// "Absorb" word keys `hit_info & 0x20`, not a magnitude (`0x6243e0` never reads one — decision
|
||
/// 0279), so no absorb field rides here; the full-block synthesis likewise rewrites
|
||
/// `victim_state` in the net bridge before this message is built.
|
||
#[derive(Message, Clone, Copy)]
|
||
pub(crate) struct SwingMessage {
|
||
pub(crate) attacker: Entity,
|
||
/// The swing's target, when it's streamed to us.
|
||
pub(crate) victim: Option<Entity>,
|
||
pub(crate) hit_info: u32,
|
||
/// vmangos `VictimState`: 1 hit · 2 dodge · 3 parry · 4 interrupt · 5 blocks · 6 evades ·
|
||
/// 7 immune · 8 deflects (decision 0279's byte-verified consequence table keys off it).
|
||
pub(crate) victim_state: u32,
|
||
pub(crate) damage: u32,
|
||
/// [`PlaySeq`] stamp at emission (the wire drain, in packet order).
|
||
pub(crate) seq: u64,
|
||
}
|
||
|
||
/// Play a one-shot **anim-emote** on a unit: the given `AnimationData.dbc` id, played over the
|
||
/// gait and returning to it when it finishes (the [`select::Mode::Swing`] one-shot pattern). The
|
||
/// gossip/vendor interact route ([`crate::target`], decision 0081) uses it to play EmoteTalk
|
||
/// (id 60) on the self-player: the per-animation sheath reconcile then reads that clip's
|
||
/// WeaponFlags (`0x10`) and stows the drawn weapon — a committed state change that *persists*
|
||
/// after the emote (nothing restores it; weapons stay stowed until re-triggered — decision 0080).
|
||
/// The stow is the emote's flags, not a sheath wire. Also fed by the `SMSG_EMOTE` receive path
|
||
/// ([`emote_anim::emote_to_anim`]), which maps a bridged anim emote's `Emotes.dbc` id to its
|
||
/// `AnimID` — `/wave`, `/bow`, `/laugh`, `/cheer`, … all play through this same one-shot.
|
||
#[derive(Message, Clone, Copy)]
|
||
pub(crate) struct EmoteAnim {
|
||
pub(crate) entity: Entity,
|
||
pub(crate) anim_id: u16,
|
||
/// [`PlaySeq`] stamp — a kit anim inherits its [`CastEvent`]'s; scene-time emitters stamp
|
||
/// fresh at fire time. The driver's same-frame swing collision resolves by the higher stamp.
|
||
pub(crate) seq: u64,
|
||
}
|
||
|
||
/// A **state kit's animation id, which is a comparison and never a play** — `PlaySpellVisualKit`'s
|
||
/// stage-2 leg (VERIFIED, wow-re `state-kit-anim-and-stun-pose.md` §1/§2; decision 2085).
|
||
///
|
||
/// `0x60edf0`'s tail has exactly one site that hands a kit's `+0x8` to the play primitive
|
||
/// (`0x60f3c5 call 0x5fe2f0`), and **stage 2 is diverted around it**: `0x60f387 jne` takes the
|
||
/// stage-2 leg, which compares the unit's currently-armed id (`0x5fdb50` — upper-body key bone
|
||
/// preferred, bone 0 as the fallback) against `kit.AnimID` and either does nothing (`je 0x60f3ca`,
|
||
/// already playing it) or runs `0x5fd9e0(unit, -1)` — a **base-animation recompute** — before
|
||
/// leaving the block for good. Both `SpellVisual` field-4 consumers hardcode stage 2 (the aura
|
||
/// watcher `0x5ff4c6` and the impact hand-off `0x61dced`), so no path in the image ever plays a
|
||
/// state kit's anim.
|
||
///
|
||
/// It is therefore an animation-**cutting** mechanism, and the cut is not incidental: Charge
|
||
/// (22911) plays `Knockdown`(121) from its impact kit and then cuts it with its own state kit's
|
||
/// `Stun`(14), because 121 ≠ 14 forces the recompute. That is also why no stun ever shows the
|
||
/// `Stun` pose despite 23 kits naming it — the selector `0x5fd8b0` cannot produce 14 (its literal
|
||
/// outputs are enumerated in the note), so the recompute lands on `Stand`.
|
||
///
|
||
/// The driver holds the comparison because the armed id lives there. An aura **refresh** in place
|
||
/// emits nothing: the watcher `0x604d00` fires neither arm when a slot is rewritten with the same
|
||
/// id and a non-zero flags nibble, which our per-unit armed-id diff reproduces by construction.
|
||
#[derive(Message, Clone, Copy)]
|
||
pub(crate) struct BaseAnimRecompute {
|
||
pub(crate) entity: Entity,
|
||
/// The state kit's `AnimID` — the right-hand side of `0x60f390`'s compare, never played.
|
||
pub(crate) anim_id: u16,
|
||
}
|
||
|
||
/// Rear up a rider's mount (decision 0441 P2): resolved to a [`EmoteAnim`] one-shot of
|
||
/// MountSpecial(94) on the unit's MOUNT CHILD entity by [`flourish_to_anim`] — the §5-verified
|
||
/// routing (the flourish plays on the mount model; the rider holds Mount(91) throughout).
|
||
/// Written by the net drain (`SMSG_MOUNTSPECIAL_ANIM` — observed riders only; our own echo
|
||
/// is dropped there) and by the self space-bar gate (`crate::player`, locally at send time).
|
||
/// A unit with no mount child (a race with dismount) drops silently — nothing to rear.
|
||
#[derive(Message, Clone, Copy)]
|
||
pub(crate) struct MountFlourish {
|
||
pub(crate) unit: Entity,
|
||
}
|
||
|
||
/// [`MountFlourish`] → [`EmoteAnim`]: hop unit → its mount child and fire the one-shot there.
|
||
/// The mount child runs the untouched creature machinery (it is not itself "mounted"), so the
|
||
/// general one-shot player takes it from here — full-body 94 over the mount's gait, returning
|
||
/// to it when done.
|
||
fn flourish_to_anim(
|
||
mut msgs: MessageReader<MountFlourish>,
|
||
mut out: MessageWriter<EmoteAnim>,
|
||
mut play_seq: ResMut<PlaySeq>,
|
||
mounts: Query<&crate::entities::mount::MountChild>,
|
||
) {
|
||
for m in msgs.read() {
|
||
let Ok(child) = mounts.get(m.unit) else {
|
||
debug!("flourish: {:?} has no mount child — dropped", m.unit);
|
||
continue; // dismounted before the flourish landed — nothing to rear
|
||
};
|
||
debug!(
|
||
"flourish: MountSpecial(94) one-shot on {:?} (mount of {:?})",
|
||
child.0, m.unit
|
||
);
|
||
out.write(EmoteAnim {
|
||
entity: child.0,
|
||
anim_id: select::MOUNT_SPECIAL,
|
||
seq: play_seq.next(),
|
||
});
|
||
}
|
||
}
|
||
|
||
/// A **spell-side wound flinch** on `entity` — the client's `0x60ea70(unit, severity = 0)`
|
||
/// reached from three spell paths (decision 2058): the kit player's own branch (`0x60edf0` @
|
||
/// `0x60f3ad`: a kit anim in `[8,10]` goes here instead of `PlayAnimation`), the instant-hit
|
||
/// impact loop (`0x6e8bf0` @ `0x6e8c89` — after the impact kit, iff the spell targets enemies,
|
||
/// [`benilla_formats::SpellDisplay::is_harmful`]), and the missile impact hand-off (`0x61dc50` @
|
||
/// `0x61dc74` — before the impact kit, every living target). All three pass **severity 0**, so
|
||
/// the id is never the kit's own column: the trigger picks CombatWound(9) / StandWound(8) by the
|
||
/// victim's engagement exactly like a non-crit melee hit ([`select::wound_anim`]) and lays it
|
||
/// into the SECONDARY-blend slot — a decaying overlay that never interrupts what plays
|
||
/// underneath (decision 0111). Never the [`EmoteAnim`] one-shot route: that replaces the base
|
||
/// track, the exact routing 0111 falsified. Written by [`spell_visual::route_cast_visuals`];
|
||
/// consumed by [`driver::drive_animations`] into the same per-frame wound slot as a melee hit.
|
||
#[derive(Message, Clone, Copy)]
|
||
pub(crate) struct WoundAnim {
|
||
pub(crate) entity: Entity,
|
||
}
|
||
|
||
/// The target lists off one `SMSG_SPELL_GO` (decision 0099 phase 4) — the payload [`CastEvent`]'s
|
||
/// `Copy` shape can't carry. Written by the net bridge alongside the GO's [`CastEvent`]; consumed
|
||
/// by [`spell_visual::route_cast_visuals`], which resolves `Spell.dbc` Speed and either plays the
|
||
/// impacts inline (speed 0) or launches missiles ([`spell_visual::MissileSpawn`]) — the client's
|
||
/// two GO branches (`0x6e8bf0` instant vs `0x6e8a50 → 0x60a3d0` projectile).
|
||
#[derive(Message, Clone)]
|
||
pub(crate) struct SpellGoTargets {
|
||
pub(crate) caster: Entity,
|
||
pub(crate) spell_id: u32,
|
||
/// Units the spell landed on — each gets the impact hand-off (inline or on missile arrival).
|
||
pub(crate) hits: Vec<Entity>,
|
||
/// Units it missed, with the wire's `SpellMissInfo` code — a projectile still flies at
|
||
/// each, and its **arrival** plays the victim's defense clip for DODGE(3)/BLOCK(5) (the
|
||
/// client's `Missile_C::Update 0x61ceb0` dispatch — Dodge 30 / ShieldBlock 24, never Parry;
|
||
/// wow-re `smsg-attackerstate-consequences.md` §Q4). No impact kit plays. The deflect
|
||
/// *flight* visual (the projectile glancing off) stays a named approximation — ours ends
|
||
/// the flight at the target.
|
||
pub(crate) misses: Vec<(Entity, u8)>,
|
||
/// The ground point a dest-targeted cast launched at (`TARGET_FLAG_DEST_LOCATION`), in
|
||
/// **bevy** coords (converted at the apply seam like every scene position). A ground AOE's
|
||
/// GO arrives with empty hit/miss lists and only this. No consumer reads it yet — the
|
||
/// dest-anchored launch visual is pending the dispatched wow-re read (the persistent area
|
||
/// effect anchors to the DynamicObject create, not to this packet).
|
||
pub(crate) dest: Option<Vec3>,
|
||
/// The GO's ammo block (`castFlags & 0x20`, a ranged shot): the `ItemDisplayInfo` id of the
|
||
/// caster's ammo/thrown — the missile's model when the spell's visual chain has none (the
|
||
/// client's `0x479f40` fallback, decision 0099 phase 5).
|
||
pub(crate) ammo_display_id: Option<u32>,
|
||
/// [`PlaySeq`] stamp — same packet as the GO's [`CastEvent`], same stamp: the inline
|
||
/// impacts play from the one handler call.
|
||
pub(crate) seq: u64,
|
||
}
|
||
|
||
/// The sheath **policy layer**'s types + ceremony mechanics (decision 0080) — the one-setter
|
||
/// request, the ceremony overlays, the `AnimationData.dbc` policy table. The driver below
|
||
/// executes them, so every sheath transition has exactly one author.
|
||
mod sheath;
|
||
use sheath::{load_anim_data, SheathSwap};
|
||
pub(crate) use sheath::{
|
||
toggle_sheath_next, AnimData, SheathRequest, SheathSwapMessage, VisualSheath,
|
||
};
|
||
|
||
/// The `SMSG_EMOTE` → [`EmoteAnim`] consumer (Part 1 of the emote-animation wiring) — kept
|
||
/// alongside `sheath` as its own small concern.
|
||
mod emote_anim;
|
||
|
||
/// The client-local gesture producers (decision 1469): the chat talk/question/exclamation/shout/
|
||
/// laugh, and the NPC-interact talk. The reference's `0x60bb30`, which shares the one-shot player
|
||
/// with `SMSG_EMOTE`.
|
||
mod gesture;
|
||
use emote_anim::emote_to_anim;
|
||
pub(crate) use gesture::{select_gesture, Gesture, GestureQueue};
|
||
|
||
/// The Bevy driver systems that execute the state machine [`select`] picks (decision 0049 + 0073,
|
||
/// decision 0087's one-shot routing, decision 0080's sheath execution) — kept in its own file as
|
||
/// it carries the bulk of the per-frame system logic, separate from this module face and from the
|
||
/// pure selector logic in [`select`].
|
||
mod driver;
|
||
pub(crate) use driver::oneshot_is_live;
|
||
use driver::{drive_animations, drive_hand_grip};
|
||
|
||
/// The model event-keyframe scanner (decision 0070 slice 3) — kept in its own file as its own
|
||
/// small concern, separate from the driver ([`driver`]) that advances the clips it scans.
|
||
mod events;
|
||
use events::fire_anim_events;
|
||
pub(crate) use events::{
|
||
advance_track, footfall_culls, footfall_side, is_footstep_sound, scan_events, AnimSoundEvent,
|
||
EventFrame, TrackMemory,
|
||
};
|
||
|
||
/// The `$BTH` breath puffs — a unit's visible cold vapour in a snow zone (B233, decision 1149).
|
||
mod breath;
|
||
use breath::{classify_breath, fire_breath};
|
||
|
||
mod impact;
|
||
use impact::route_swing_impacts;
|
||
pub(crate) use impact::{DefenseAnim, PendingImpacts, SwingFlush, SwingImpact, SwingSlowdown};
|
||
|
||
/// The spell-visual data plane + the cast-edge router (decision 0099 phase 2 / 0107):
|
||
/// `SpellVisual.dbc` × `SpellVisualKit.dbc` loaded alongside [`AnimData`], and
|
||
/// [`spell_visual::route_cast_visuals`] resolving wire [`CastEvent`]s into [`CastHold`]s, release
|
||
/// one-shots ([`EmoteAnim`]) and kit sounds ([`spell_visual::SpellKitSound`]).
|
||
mod blood;
|
||
mod env_damage;
|
||
pub(crate) mod spell_visual;
|
||
use blood::{blood_spurts, load_blood_tables};
|
||
use env_damage::{hard_landing_dust, load_env_damage_table};
|
||
pub(crate) use env_damage::{EnvDamageTable, HardLanding, HARD_LANDING_DESCENT};
|
||
use spell_visual::{
|
||
arm_aura_state_fx, arm_level_up_fx, arm_loot_fx, arm_morph_latch, arm_mount_poof_fx,
|
||
load_spell_visuals, replay_morph_kit, route_cast_visuals, MorphLatch,
|
||
};
|
||
// The aura-slot watcher is the shared trigger for BOTH halves of a state kit, so the CharProc half's
|
||
// own tests (`crate::aura_visual`) drive it directly rather than re-deriving the slot diff.
|
||
#[cfg(test)]
|
||
pub(crate) use spell_visual::arm_aura_state_fx as arm_aura_state_fx_for_test;
|
||
pub(crate) use spell_visual::{
|
||
held_strike_sound, ChainProcPlay, FxClass, FxStage, KitPush, MissileSpawn, SpellKitFx,
|
||
SpellKitShake, SpellKitSound, SpellVisuals,
|
||
};
|
||
|
||
/// The per-unit animation state machine.
|
||
///
|
||
/// `Transform` is required, not optional: the driver reads the entity's render scale as the
|
||
/// locomotion rate divisor (decision 0903), so a driver on a transform-less entity would silently
|
||
/// stop being driven at all. Every real unit is spawned with one — the requirement is here so a
|
||
/// hand-assembled entity (a test world, a future harness) can't quietly miss it.
|
||
#[derive(Component)]
|
||
#[require(Transform)]
|
||
pub(crate) struct AnimDriver {
|
||
mode: Mode,
|
||
/// The gait id currently targeted while in [`Mode::Gait`] (so we cross-fade only on a change). `None`
|
||
/// forces a fresh selection (first eval, or after a Special state exits).
|
||
gait: Option<u16>,
|
||
/// The movement flags the **base slot** was last armed for — the reference has no per-one-shot
|
||
/// latch, so "the movement state changed" means *since the base last took a request*, never
|
||
/// "since this one-shot started" (decision 0894). The distinction is invisible until a one-shot
|
||
/// and a movement change land on the SAME frame: Ice Block's root wipes the direction bits in
|
||
/// the very frame the cast one-shot arrives, so an arm-time comparison sees no edge at all and
|
||
/// the cast keeps bone 0 — where the reference's next base request overwrites it and leaves the
|
||
/// character neutral.
|
||
gait_flags: u32,
|
||
/// The **base-animation lock** ([`driver::play::BaseAnimLock`]) — the reference's
|
||
/// `[unit+0xd58] & 0xc0000`. While a `Knockdown`/`LiftOff`/`Land` holds it, every base request
|
||
/// is refused outright, which is how a stunned victim's knockdown survives the root's own
|
||
/// `Stand` recompute (decision 2096).
|
||
base_lock: driver::play::BaseAnimLock,
|
||
/// The unit's **client-side sheath state** — the mirror of the client's committed CUR cache
|
||
/// (`[unit+0xd40]`, decision 0080): what the weapon placement renders (absent a
|
||
/// [`VisualSheath`] ceremony pin) and what the setter/reconcile test against. Seeded from
|
||
/// the descriptor byte at first sight; re-adopted whenever the server byte *changes* (the
|
||
/// `0x604c70` field-apply); overwritten by requests ([`SheathRequest`]) and the
|
||
/// per-animation reconcile. `None` = not yet seen (arms silently).
|
||
sheath_cur: Option<u8>,
|
||
/// The `UNIT_FIELD_BYTES_2` sheath byte last seen, to detect inbound field *changes* (our
|
||
/// own `CMSG_SETSHEATHED` echo arrives already-committed and adopts as a no-op).
|
||
sheath_byte: Option<u8>,
|
||
/// The pending mid-animation weapon swap, while a draw/stow one-shot is in flight.
|
||
sheath_swap: Option<SheathSwap>,
|
||
/// **Did this unit start an animation THIS frame** — of any kind: a one-shot, a masked
|
||
/// overlay, a cast/channel hold, or the mode machine's own base/gait play. Rewritten every
|
||
/// pass by [`driver::drive_animations`], so a reader one system later sees exactly the
|
||
/// frame's edge.
|
||
///
|
||
/// Its one consumer is the weapon-trail latch ([`crate::weapon_trail`], decision 2076). The
|
||
/// reference consumes `unit+0xd1c`/`+0xd20` inside `CGUnit::PlayAnimation 0x5fe2f0` itself
|
||
/// (`0x5fe48e`, the fields' only reader), and `0x5fe2f0` is the **single** animation entry
|
||
/// point in the image — 40 call sites, locomotion among them (`0x602c60` → `0x5fd9e0` →
|
||
/// `0x5fd8b0` → `0x5fd100`), which is why this is not a one-shot flag: a unit that simply
|
||
/// starts running consumes the arm, and that is what starts Charge's trail (wow-re
|
||
/// `charproc8-trail-draw-state.md` §11.5/§11.6). benilla's driver is one batched system, not
|
||
/// a per-play function, so the arm cannot be read at the call the way the reference reads it.
|
||
started_anim: bool,
|
||
/// A **masked upper-body one-shot** in flight (decision 0087): a swing/emote the live-state route
|
||
/// sent to the SpineLow overlay (moving / seated / airborne-in-combat), playing *beside* [`Mode`]
|
||
/// while the base track keeps driving the legs (run / sit / jump-arc). `None` = no overlay; a
|
||
/// finished overlay **fades** the subtree back to the base ([`Self::overlay_fade`]) rather than
|
||
/// dropping it. The full-body route never uses this — it replaces the base via [`Mode::Swing`]
|
||
/// — until a locomotion request **transplants** that clip up here (decision 0878).
|
||
overlay: Option<Overlay>,
|
||
/// The key-bone slot's **cross-fade in flight** (decision 0878) — the client's per-bone
|
||
/// SECONDARY as an upper-body arm or release seeds it. Two producers, one curve
|
||
/// ([`select::blend_lambda`], λ decaying 1 → 0): the **fade-to-rest** that releases a finished
|
||
/// one-shot over a fixed 150 ms (`0x5fc920` → op4 `param_3 = −1`, `0x7123af`), and a **blended
|
||
/// re-arm** cross-fading a new masked clip in over the incoming clip's own blendTime
|
||
/// (`0x7125f2`). A **transplant** arm carries `blendFlag = 0` and seeds nothing — it resumes
|
||
/// the clip at its live frame with no fade at all.
|
||
overlay_fade: Option<OverlayFade>,
|
||
/// The victim **wound-flinch** in flight (decision 0111) — the client's per-bone **SECONDARY
|
||
/// blend slot**: a decaying cross-fade overlay (`λ` smoothstep `0.75 → 0` over the wound
|
||
/// clip's own span) layered over whatever else plays, then self-releasing. Deliberately a
|
||
/// separate slot from [`Self::overlay`] — in the client they are two independent per-bone
|
||
/// slots (primary arm vs secondary blend), so a masked swing and a wound decay **coexist**;
|
||
/// folding the wound into the one-shot slot is exactly the falsified 0107 routing.
|
||
wound: Option<Wound>,
|
||
/// Whether the current airborne arc launched **upward** — a jump (the JumpStart/Jump bracket)
|
||
/// vs a step-off fall (the gait freeze). Decided from `vertical_speed` on the arc's first
|
||
/// airborne frame — the MSG_MOVE_JUMP-vs-StartFalling distinction the real client carries as
|
||
/// an event, read off the launch velocity in our flag-collapsed machine. Stale on the ground.
|
||
jump_arc: bool,
|
||
/// Vertical speed as of last frame — the **launch** detector behind [`Self::jump_arc`]. A jump
|
||
/// out of a one-frame micro-detachment never toggles the FALLING bit (land and relaunch inside
|
||
/// one frame), so the bit's edge cannot be the only place the arc is classified; a rise past
|
||
/// `JUMP_ARC_MIN_UP` from below is the launch itself, and nothing but a jump produces one.
|
||
last_vertical_speed: f32,
|
||
/// FALLING was set last frame — marks the arc edges: takeoff (decide [`Self::jump_arc`]) and
|
||
/// the landing of a bracket-less step-off fall (which must still run the `0x602c60` land pick
|
||
/// even though no Special drove the arc).
|
||
was_falling: bool,
|
||
/// The **deferred combat one-shot** — the client's `CGUnit+0xd60` cache (wow-re
|
||
/// `combat-anim-fastpath.md`, decision 0406): a combat clip requested while another combat
|
||
/// clip plays is NOT armed — the playing clip's rate doubles (op6 2.0f) and the request
|
||
/// parks here, played by the driver the moment no one-shot is live (the client's
|
||
/// base-recompute read). Any normal arm clears it (the client's `0x5fe48e` writes −1 on
|
||
/// every non-fast-path PlayAnimation; the driver clears at its play sites).
|
||
deferred: Option<u16>,
|
||
/// The armed looping arm's **replay window** (decision 0516 — wow-re `loop-replay-fidget`
|
||
/// §7/§7d): the variation node the loop armed + its rolled budget `R` (the client's
|
||
/// `block+0xbc`; the window is `R` clip-lengths wide, op4 `0x7126d8` — live for LOOPS too,
|
||
/// correcting 0117's "loops ignore it"). The per-frame watchdog (`0x719370`'s transcription
|
||
/// in `drive_animations`) re-arms the id when the node — still the MAIN armed animation —
|
||
/// completes `R` passes: the fresh weighted pick that alternates a gryphon's flap/glide and
|
||
/// walks a multi-part dance. `None` = the main arm is a one-shot (its budget is a `Count`
|
||
/// repeat) or a deliberate freeze (ranged Load / Loot).
|
||
loop_window: Option<(bevy::animation::graph::AnimationNodeIndex, u32)>,
|
||
/// The playback rate last written to the base slot — `speed / (moveSpeed · |modelScale|)`
|
||
/// ([`select::playback_rate`], decision 0903); `1.0` for anything that is not a rate-scaled
|
||
/// locomotion clip. Purely an **instrument**: the hover inspector's anim line reads it, so
|
||
/// "this creature's walk looks too fast" is answerable by hovering the creature instead of by
|
||
/// working the divisor out by hand. Recorded rather than re-derived, so the card can never
|
||
/// disagree with what is actually playing.
|
||
gait_rate: f32,
|
||
/// `UNIT_FIELD_MOUNTDISPLAYID` as of the last pass — the driver's **mount-transition edge**
|
||
/// detector, the local twin of the client's own UpdateField change-watcher (`0x604329`:
|
||
/// TYPEID Unit, descriptor offset `0x1fc`, width 4, thunk `0x604570` → `0x5ffa50`). Any
|
||
/// change is an edge — 0→N (mount), N→N′ (swap) and N→0 (dismount) alike — because the
|
||
/// reference's handler is one watcher whose two legs BOTH arm bone 0 of the body: the build
|
||
/// `0x607b44` op4(bone 0, **91 `Mount`**, cross-fade, PRIMARY), the teardown `0x607ce0` op4
|
||
/// seq **0 `Stand`**. The arm is a plain last-writer-wins play, so it *displaces a full-body
|
||
/// one-shot the transition catches in flight* — which our gait-slot mount pin alone never did
|
||
/// (decision 0927).
|
||
mount_display: u32,
|
||
/// The Special wanted LAST frame — the driver's Special **edge** detector (decision 0864).
|
||
/// An edge is a play in the client (the jump/pose entry, the FALLINGFAR latch's Fall, the
|
||
/// land pick), so it clears the deferred-combat cache like any normal arm (`0x5fe48e`);
|
||
/// the *level* must not (mid-air the airborne-freeze issues no plays, so a fast-path park
|
||
/// survives to its clip's end).
|
||
last_special: Option<Special>,
|
||
/// The **airborne snapshot** node whose clock [`driver::play::leave_special`] stopped
|
||
/// (decision 0503, the swim hop's too-short kick — kept as a symptom fix whose mechanism is
|
||
/// open, because the "blends from a frozen pose" law it was justified by is refuted: the
|
||
/// client's blend source keeps running, decision **1566**. Scoped to the airborne cut and
|
||
/// **not** to be generalised). The per-frame rate write
|
||
/// ([`driver::play::sync_base_rate`](driver::play)) must not restart it, so it is named here
|
||
/// rather than inferred from a zero speed — a rate-scaled clip legitimately reads 0 when the
|
||
/// body is standing still, and skipping *that* would strand it frozen forever. Self-clearing:
|
||
/// the moment anything else is armed the node stops being the main animation and the sync
|
||
/// drops the name (decision 0906).
|
||
frozen: Option<bevy::animation::graph::AnimationNodeIndex>,
|
||
}
|
||
|
||
impl AnimDriver {
|
||
/// The `AnimationData` ids currently driving this unit — `(base, masked overlay)` — the
|
||
/// inspector card's anim readout. The base is whatever occupies the full-body slot (gait /
|
||
/// special / landing / swing), as the *requested* ids before any missing-clip substitution
|
||
/// (the layer the selectors chose — also what the reconcile tests, decision 0125); the
|
||
/// overlay is a masked upper-body one-shot playing beside it, if any. A `None` base = no
|
||
/// gait selected yet (first frame).
|
||
pub(crate) fn playing(&self) -> (Option<u16>, Option<u16>) {
|
||
let base = match self.mode {
|
||
Mode::Gait => self.gait,
|
||
Mode::Entering(s) => Some(s.enter()),
|
||
Mode::Looping(s) => Some(s.loop_id()),
|
||
Mode::Exiting(_, id) => Some(id),
|
||
Mode::Land { id, .. } => Some(id),
|
||
Mode::Swing { id, .. } => Some(id),
|
||
};
|
||
(base, self.overlay.map(|o| o.id))
|
||
}
|
||
|
||
/// The playback rate the base slot is running at ([`Self::gait_rate`]) — the inspector card's
|
||
/// `rate` readout. `1.0` for every clip outside the locomotion whitelist.
|
||
pub(crate) fn rate(&self) -> f32 {
|
||
self.gait_rate
|
||
}
|
||
}
|
||
|
||
/// A masked upper-body play over the base ([`AnimDriver::overlay`]): the graph node it drives
|
||
/// ([`AnimClip::upper_node`]) and the requested `AnimationData.dbc` id (for the event scan +
|
||
/// the sheath reconcile — both must see this play, decision 0087 (d)). `looping` marks the cast
|
||
/// hold's sustained variant (decision 0107): a one-shot auto-releases when its clip finishes; a
|
||
/// looping hold never finishes and is released only by [`driver::drive_animations`]'s hold logic
|
||
/// (the [`CastHold`] gone, or the unit stopping — the hold then moves to the gait slot).
|
||
#[derive(Clone, Copy)]
|
||
struct Overlay {
|
||
node: bevy::animation::graph::AnimationNodeIndex,
|
||
id: u16,
|
||
looping: bool,
|
||
}
|
||
|
||
/// A key-bone cross-fade in flight ([`AnimDriver::overlay_fade`], decision 0878 — wow-re
|
||
/// `oneshot-lifecycle.md` §5.4). `out` is the **retiring** node, holding the outgoing pose the
|
||
/// client snapshots into the bone's secondary slot (`rep movsd +0x98 → +0xc4`); it is `None` when
|
||
/// a fresh clip fades in over the *inherited base* pose, which needs no node of its own — the base
|
||
/// is already there. `left`/`total` are the window: the fixed 150 ms of a fade-to-rest, or the
|
||
/// incoming clip's own blendTime on a blended re-arm.
|
||
#[derive(Clone, Copy)]
|
||
struct OverlayFade {
|
||
out: Option<bevy::animation::graph::AnimationNodeIndex>,
|
||
/// Seconds still to run — λ = [`select::blend_lambda`]`(left / total)`, decaying 1 → 0.
|
||
left: f32,
|
||
total: f32,
|
||
}
|
||
|
||
/// A wound-flinch decay in flight ([`AnimDriver::wound`], decision 0111): the graph node the
|
||
/// blend drives (the clip's masked [`AnimClip::upper_node`], or its full-body node when the
|
||
/// bone-selection forces bone 0 — [`select::wound_full_body`]) and the decay window's inputs.
|
||
/// The client seeds `end = clock + span, rate = 1/span, λ₀ = 0.75` (op4 `linkFlag=0`,
|
||
/// `0x712647-0x712682`); the driver's per-frame upkeep replays that decay off the clip's own
|
||
/// playback clock and stops the node when it expires — the kernel's self-release (`0x7147b9`).
|
||
#[derive(Clone, Copy)]
|
||
struct Wound {
|
||
node: bevy::animation::graph::AnimationNodeIndex,
|
||
/// The wound clip's full length (seconds) — the decay denominator (the client's `1/span`).
|
||
span: f32,
|
||
/// Masked to the upper-body subtree — where the base *and* a live one-shot overlay both
|
||
/// blend, so the λ-anchoring weight must count them ([`select::wound_weight`]'s `others`).
|
||
masked: bool,
|
||
}
|
||
|
||
impl Default for AnimDriver {
|
||
fn default() -> Self {
|
||
Self {
|
||
mode: Mode::Gait,
|
||
gait: None,
|
||
gait_flags: 0,
|
||
base_lock: driver::play::BaseAnimLock::default(),
|
||
sheath_cur: None,
|
||
sheath_byte: None,
|
||
sheath_swap: None,
|
||
started_anim: false,
|
||
overlay: None,
|
||
overlay_fade: None,
|
||
wound: None,
|
||
jump_arc: false,
|
||
last_vertical_speed: 0.0,
|
||
was_falling: false,
|
||
deferred: None,
|
||
loop_window: None,
|
||
gait_rate: 1.0,
|
||
mount_display: 0,
|
||
last_special: None,
|
||
frozen: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl AnimDriver {
|
||
/// The `AnimationData.dbc` id of the clip this unit is currently playing (target gait, a Special's
|
||
/// enter/loop/exit one-shot, or Death — which the death override records as the gait). `None` before
|
||
/// the first selection. The mouse pick reads it for the **current animation's** bounds sphere — the
|
||
/// real client's broad-phase volume tracks the playing sequence (wow-re pick-volume RE).
|
||
pub(crate) fn active_anim(&self) -> Option<u16> {
|
||
match self.mode {
|
||
Mode::Gait => self.gait,
|
||
Mode::Entering(sp) => Some(sp.enter()),
|
||
Mode::Looping(sp) => Some(sp.loop_id()),
|
||
Mode::Exiting(_, exit) => Some(exit),
|
||
Mode::Land { id, .. } => Some(id),
|
||
Mode::Swing { id, .. } => Some(id),
|
||
}
|
||
}
|
||
|
||
/// The **resolved** id of the clip actually driving playback right now (decision 0082):
|
||
/// [`Self::active_anim`] (the selector's *requested* semantic id) run through the model's own
|
||
/// fallback resolution. This is what the real client's per-animation sheath reconcile tests
|
||
/// (`0x5fdb50` reads the record of the sequence **actually playing**, not the semantic pick that
|
||
/// was asked for) and what the event-fire path needs (a clip crossed via resolution is the one
|
||
/// whose timeline is actually advancing). [`Self::active_anim`] itself stays the raw requested id
|
||
/// — the state machine's `Mode`/`gait` identity comparisons key on the *selector's* choice, which
|
||
/// resolution must not perturb (two candidates that happen to resolve to the same baked substitute
|
||
/// are still a different selection). `catalog` absent (a brief window before `AnimationData.dbc`
|
||
/// loads) degrades to identity, matching [`find_resolved`]'s own degrade.
|
||
pub(crate) fn resolved_anim(
|
||
&self,
|
||
anims: &ModelAnimations,
|
||
catalog: Option<&AnimDataCatalog>,
|
||
) -> Option<u16> {
|
||
self.active_anim().map(|id| resolved_id(anims, id, catalog))
|
||
}
|
||
|
||
/// The unit's client-side sheath state (0 stowed · 1 melee · 2 ranged) — the committed CUR
|
||
/// cache the placement renders and the Z toggle flips. `None` until first driven.
|
||
pub(crate) fn sheath_state(&self) -> Option<u8> {
|
||
self.sheath_cur
|
||
}
|
||
|
||
/// Did this unit start an animation this frame — see [`Self::started_anim`].
|
||
pub(crate) fn started_anim(&self) -> bool {
|
||
self.started_anim
|
||
}
|
||
|
||
/// Stand in for the driver's own write, so a consumer's test can raise the edge without
|
||
/// standing up the whole animation pass to produce it.
|
||
#[cfg(test)]
|
||
pub(crate) fn set_started_anim(&mut self, played: bool) {
|
||
self.started_anim = played;
|
||
}
|
||
|
||
/// Whether a draw/stow ceremony is in flight — the manual toggle's mid-ceremony debounce
|
||
/// (guards 11–12 of the client's `ToggleSheath` chain, decision 0080d).
|
||
pub(crate) fn sheath_ceremony_active(&self) -> bool {
|
||
self.sheath_swap.is_some()
|
||
}
|
||
}
|
||
|
||
/// Drives unit animation from movement state. Runs after [`WorldStage::Net`] (where the net bridge updates
|
||
/// splines and the controller writes our avatar's state), so what it reads is the current frame's.
|
||
pub(crate) struct CreatureAnimPlugin;
|
||
|
||
impl Plugin for CreatureAnimPlugin {
|
||
fn build(&self, app: &mut App) {
|
||
twist::plugin(app);
|
||
lod::plugin(app);
|
||
breath::register(app);
|
||
// The breath classifier is a per-unit environment resolve, not an animation step: it runs
|
||
// off the area authority, at its own 10 s cadence, and only publishes the component
|
||
// `fire_breath` reads inside the chain below.
|
||
app.add_systems(
|
||
Update,
|
||
classify_breath.after(benilla_world::terrain_stream::AreaAuthoritySet),
|
||
);
|
||
app.add_message::<AnimSoundEvent>()
|
||
.add_message::<SwingMessage>()
|
||
.add_message::<SwingImpact>()
|
||
.add_message::<SwingFlush>()
|
||
.add_message::<DefenseAnim>()
|
||
.add_message::<SwingSlowdown>()
|
||
.init_resource::<PendingImpacts>()
|
||
.init_resource::<PlaySeq>()
|
||
.init_resource::<gesture::GestureQueue>()
|
||
.add_message::<EmoteAnim>()
|
||
.add_message::<BaseAnimRecompute>()
|
||
.add_message::<MountFlourish>()
|
||
.add_message::<WoundAnim>()
|
||
.add_message::<SheathSwapMessage>()
|
||
.add_message::<SheathRequest>()
|
||
.add_message::<CastEvent>()
|
||
.add_message::<SpellGoTargets>()
|
||
.add_message::<SpellKitSound>()
|
||
.add_message::<SpellKitShake>()
|
||
.add_message::<SpellKitFx>()
|
||
.add_message::<MissileSpawn>()
|
||
// The kit's beam edge (0955) — `crate::entities` owns what it becomes.
|
||
.add_message::<ChainProcPlay>()
|
||
.add_message::<KitPush>()
|
||
// The aura CharProc edges the slot watcher emits alongside its effect-model ones —
|
||
// drained by `crate::aura_visual`, which owns the body's alpha/tint for an aura's life.
|
||
.add_message::<crate::aura_visual::AuraProc>()
|
||
.add_message::<HardLanding>()
|
||
// The pending-morph latch — the reference's per-unit `[+0xd54]` SpellRec slot.
|
||
.init_resource::<MorphLatch>()
|
||
.add_systems(
|
||
Startup,
|
||
(
|
||
load_anim_data,
|
||
load_spell_visuals,
|
||
load_blood_tables,
|
||
load_env_damage_table,
|
||
)
|
||
.after(AssetSet::Open),
|
||
)
|
||
.add_systems(
|
||
Update,
|
||
(
|
||
route_cast_visuals,
|
||
// The lootable-corpse sparkle's edge watcher — a SpellKitFx writer like the
|
||
// router above, consumed by the same entity-visuals chain this set precedes.
|
||
arm_loot_fx,
|
||
// Its level-edge sibling: the ding (decision 0305) — same shape, same home.
|
||
arm_level_up_fx,
|
||
// …and its mount-edge sibling: the poof (decision 0927). Same hardcoded-effect
|
||
// spawn shape, one field over.
|
||
arm_mount_poof_fx,
|
||
// The aura-slot watcher: state kits persist for the aura's life (the bread).
|
||
arm_aura_state_fx,
|
||
// The pending-morph latch (wow-re `shapeshift-morph-cloud.md`): the aura
|
||
// add/remove edges arm it, and a display swap's rebuild replays the latched
|
||
// spell's impact kit — the shapeshift cloud, both directions. Arm before
|
||
// replay: the swap message crosses from last frame's teardown, and its
|
||
// latch was armed by the same wire burst.
|
||
(arm_morph_latch, replay_morph_kit).chain(),
|
||
blood_spurts,
|
||
// The landing predictor's dust leg (the vocal leg lives in `sound`).
|
||
hard_landing_dust,
|
||
emote_to_anim,
|
||
// The client-local gestures (decision 1469) — the chat talk/shout/laugh and
|
||
// the NPC-interact talk. The client's second (and only other) producer of a
|
||
// one-shot emote; same place in the order as the wire one, for the same reason.
|
||
gesture::drive_gestures,
|
||
// The flourish hop (unit → mount child) — before the driver so the
|
||
// one-shot lands the same frame the packet (or space press) arrived.
|
||
flourish_to_anim,
|
||
drive_animations,
|
||
// The weapon-trail latch (decision 2076) — immediately after the driver,
|
||
// because the edge it consumes is `AnimDriver::played_anim`, which the driver
|
||
// rewrites every pass. The reference reads the latch *inside*
|
||
// `PlayAnimation 0x5fe2f0` itself; one system later is as close as a
|
||
// batched driver gets, and it is still the same frame.
|
||
crate::weapon_trail::fire_weapon_trails,
|
||
drive_hand_grip,
|
||
fire_anim_events,
|
||
// After the event scan: consume this frame's impact tags; the SwingImpact
|
||
// consumers (blood, flinch, floating text) read it next frame — noise against
|
||
// the ~300–600 ms deferral itself.
|
||
route_swing_impacts,
|
||
// The `$BWP`/`$BWR` nock latch, same-frame off the same scan — before the
|
||
// entity-visuals chain so the arrow appears/vanishes the frame the keyframe
|
||
// lands, not one behind.
|
||
drive_nock_latch,
|
||
// …and the `$BTH` puff off the same scan: another SpellKitFx writer, so it
|
||
// belongs ahead of the entity-visuals chain like its `arm_*_fx` siblings.
|
||
fire_breath,
|
||
)
|
||
.chain()
|
||
.after(WorldStage::Net)
|
||
// After predicate B's recompute (decision 1477): the loot leg's self trigger
|
||
// is that boolean, and the reference has no gap between arming the latch and
|
||
// posing — its chest arm force-plays Loot 50 in the same handler. Without
|
||
// this edge the kneel lands a frame late at every loot window.
|
||
.after(crate::ui_loot::resolve_loot_kneel)
|
||
// After Input so a sheath request (the Z toggle) executes the same frame.
|
||
.after(WorldStage::Input)
|
||
// Before the entity-visuals chain: [`VisualSheath`] writes must be applied
|
||
// before `resolve_equipment` reads them, or a sheath transition double-swaps
|
||
// the weapon placement for a frame (the flash).
|
||
.before(crate::entities::EntityVisualsSet),
|
||
)
|
||
// The freeze (CharProc 11, decision 0889): **after the driver**, so this frame's arms
|
||
// are already in when the clocks are held — the Ice Block observable is precisely that
|
||
// the cast one-shot gets armed and then never advances a frame. After the drain too, so
|
||
// the node it reads is this frame's.
|
||
.add_systems(
|
||
Update,
|
||
crate::aura_visual::apply_aura_anim_rate
|
||
.after(drive_animations)
|
||
.after(crate::aura_visual::drain_aura_procs),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Resolve `id` to the id this model actually plays (decision 0082, wow-re `anim-id-resolution.md`):
|
||
/// [`ModelAnimations::resolve`] via `catalog` when `AnimationData.dbc` has loaded, else identity — a
|
||
/// brief window at startup ([`load_anim_data`](sheath::load_anim_data) runs at `Startup`, so this
|
||
/// degrade is only ever live for the first few frames, same shape as `anim_data.map_or(0, ..)`
|
||
/// elsewhere in this module).
|
||
fn resolved_id(anims: &ModelAnimations, id: u16, catalog: Option<&AnimDataCatalog>) -> u16 {
|
||
catalog.map_or(id, |cat| anims.resolve(id, cat).id)
|
||
}
|
||
|
||
/// The clip for a *requested* id, resolved through the model's own baked fallback first (decision
|
||
/// 0082) — so a model lacking `id` exactly still plays its baked substitute (e.g. the chicken's
|
||
/// Attack2H request landing on AttackUnarmed) instead of coming back empty.
|
||
fn find_resolved<'a>(
|
||
anims: &'a ModelAnimations,
|
||
id: u16,
|
||
catalog: Option<&AnimDataCatalog>,
|
||
) -> Option<&'a AnimClip> {
|
||
anims.find(resolved_id(anims, id, catalog))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod attack_stand_tests {
|
||
use super::*;
|
||
|
||
/// **`StartAttack` stands a seated player** — `0x5ecc9b`, and unconditionally within this
|
||
/// function (decision 1768).
|
||
///
|
||
/// `engaged = true` is the case worth pinning. It takes `0x5ecb70`'s skip, which jumps to the
|
||
/// tail at `0x5ecd78` — **73 bytes past the stand** — so re-issuing an attack you are already
|
||
/// making still stands you, and no `CMSG_ATTACKSWING` goes out. A stand written only on the
|
||
/// swing's own leg would miss exactly this case.
|
||
///
|
||
/// This is the behaviour that replaces the turn-driven stand 1766 deleted: what stands a
|
||
/// seated player on a right-click is the *action* the click dispatches, not the gesture.
|
||
#[test]
|
||
fn starting_an_attack_stands_you_even_when_the_swing_is_skipped() {
|
||
let mut app = App::new();
|
||
app.add_message::<SheathRequest>()
|
||
.add_message::<crate::player::StandStateRequest>();
|
||
// A dead-letter net channel: the send is `let _ = …` and no packet is under test here.
|
||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||
app.insert_resource(crate::net::NetCommands(tx));
|
||
app.insert_resource(crate::ui_action::AutoRepeatActive(None));
|
||
|
||
let me = app.world_mut().spawn_empty().id();
|
||
app.add_systems(
|
||
Update,
|
||
move |mut commands: Commands,
|
||
mut auto_repeat: ResMut<crate::ui_action::AutoRepeatActive>,
|
||
mut sheath: MessageWriter<SheathRequest>,
|
||
net: Res<crate::net::NetCommands>| {
|
||
start_attack_local(
|
||
me,
|
||
0xdead_beef,
|
||
true, // already engaged — the skip leg
|
||
false,
|
||
&mut auto_repeat,
|
||
&mut sheath,
|
||
&mut commands,
|
||
&net,
|
||
);
|
||
},
|
||
);
|
||
app.update();
|
||
|
||
let stands: Vec<u8> = app
|
||
.world_mut()
|
||
.resource_mut::<bevy::ecs::message::Messages<crate::player::StandStateRequest>>()
|
||
.drain()
|
||
.map(|r| r.state)
|
||
.collect();
|
||
assert_eq!(
|
||
stands,
|
||
vec![0],
|
||
"StartAttack asks for stand state 0 exactly once, even on the leg that sends no swing"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod nock_latch_tests {
|
||
use super::*;
|
||
|
||
/// The `$BWP`/`$BWR` nock cycle (wow-re `nocked-ammo-cancel.md` §G1/G4): the pull keyframe
|
||
/// latches (only a unit with a cached ammo display), the release keyframe clears — the arrow
|
||
/// appears as she draws it and leaves the hand with the shot.
|
||
#[test]
|
||
fn bwp_latches_and_bwr_clears_the_nock() {
|
||
let mut app = App::new();
|
||
app.add_message::<AnimSoundEvent>();
|
||
app.add_systems(Update, drive_nock_latch);
|
||
let unit = app.world_mut().spawn(NockedAmmo { display_id: 5996 }).id();
|
||
app.world_mut().write_message(AnimSoundEvent {
|
||
entity: unit,
|
||
ident: *b"$BWP",
|
||
data: 0,
|
||
pos: None,
|
||
});
|
||
app.update();
|
||
assert!(
|
||
app.world().entity(unit).contains::<NockLatch>(),
|
||
"$BWP latches a unit with a cached ammo display"
|
||
);
|
||
app.world_mut().write_message(AnimSoundEvent {
|
||
entity: unit,
|
||
ident: *b"$BWR",
|
||
data: 0,
|
||
pos: None,
|
||
});
|
||
app.update();
|
||
assert!(
|
||
!app.world().entity(unit).contains::<NockLatch>(),
|
||
"$BWR clears — the arrow leaves with the shot"
|
||
);
|
||
// No cached ammo display → the pull nocks nothing (the client's handler gates on the
|
||
// cached ranged spell).
|
||
let bare = app.world_mut().spawn_empty().id();
|
||
app.world_mut().write_message(AnimSoundEvent {
|
||
entity: bare,
|
||
ident: *b"$BWP",
|
||
data: 0,
|
||
pos: None,
|
||
});
|
||
app.update();
|
||
assert!(
|
||
!app.world().entity(bare).contains::<NockLatch>(),
|
||
"no ammo display, no latch"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod disarm_tests {
|
||
use super::*;
|
||
|
||
/// **The ladder, hand by hand** (decision 1863; wow-re `disarm-weapon-gate-law.md` §2c). The
|
||
/// reference hides **exactly one weapon**, main hand first — so a disarmed dual-wielder
|
||
/// punches with the main hand and still swings its off-hand weapon. Every selector downstream
|
||
/// reaches its own unarmed leg by its own table; there is no disarm case in any of them.
|
||
#[test]
|
||
fn disarm_hides_one_weapon_main_hand_first() {
|
||
let worn = Wielded {
|
||
main: Some((2, 0x7)), // 1H sword
|
||
off: Some((2, 0xf)), // dagger
|
||
ranged: Some((2, 0x2)), // bow
|
||
..Default::default()
|
||
};
|
||
let disarmed = Wielded {
|
||
disarmed: true,
|
||
..worn
|
||
};
|
||
|
||
// CONTROL — the weapon tables, untouched while the flag is down.
|
||
assert_eq!(worn.disarmed_hand(), None);
|
||
assert_eq!(select::swing_anim_main(worn.armed_main()), 17); // Attack1H
|
||
assert_eq!(select::swing_anim_off(worn.armed_off()), 88); // AttackOffPierce
|
||
assert_eq!(select::ready_anim(worn.armed_main()), 26); // Ready1H
|
||
|
||
// Disarmed, both hands full: the MAIN HAND is the one hidden, and its claim CANCELS the
|
||
// off-hand probe (`5ec28d je 0x5ec2aa`) — the dagger keeps swinging as a dagger.
|
||
assert_eq!(disarmed.disarmed_hand(), Some(0));
|
||
assert_eq!(disarmed.armed_main(), None);
|
||
assert_eq!(disarmed.armed_off(), Some((2, 0xf)), "the off hand is KEPT");
|
||
assert_eq!(select::swing_anim_main(disarmed.armed_main()), 16); // AttackUnarmed
|
||
assert_eq!(select::swing_anim_off(disarmed.armed_off()), 88); // still the dagger
|
||
assert_eq!(select::ready_anim(disarmed.armed_main()), 25); // ReadyUnarmed
|
||
|
||
// The RANGED slot has no gate at all: `0x5ec25e` pushes equip index `0x11` and jumps
|
||
// straight past both probes, so a disarmed hunter still nocks.
|
||
assert_eq!(disarmed.ranged, Some((2, 0x2)));
|
||
assert_eq!(select::ranged_load_anim(disarmed.ranged), 105); // LoadBow
|
||
|
||
// …and the WORN reading is untouched throughout — it is what the paperdoll shows.
|
||
assert_eq!(disarmed.main, worn.main);
|
||
}
|
||
|
||
/// The off hand is hidden only when the main hand does **not** claim the disarm — an empty or
|
||
/// non-weapon main hand. That is the one case that reaches AttackUnarmedOff(117), whose
|
||
/// `AnimationData.dbc` row (id 117, fallback 87) is a clip distinct from AttackUnarmed(16).
|
||
#[test]
|
||
fn an_empty_main_hand_hands_the_disarm_to_the_off_hand() {
|
||
let off_only = Wielded {
|
||
main: None,
|
||
off: Some((2, 0xf)),
|
||
disarmed: true,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(off_only.disarmed_hand(), Some(1));
|
||
assert_eq!(off_only.armed_off(), None);
|
||
assert_eq!(select::swing_anim_off(off_only.armed_off()), 117);
|
||
|
||
// A NON-WEAPON main hand does not claim it either — the test is `ItemClass == 2` only.
|
||
let torch = Wielded {
|
||
main: Some((15, 0)), // class 15 = miscellaneous
|
||
off: Some((2, 0xf)),
|
||
disarmed: true,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(torch.disarmed_hand(), Some(1));
|
||
assert_eq!(
|
||
torch.armed_main(),
|
||
Some((15, 0)),
|
||
"a non-weapon is not taken"
|
||
);
|
||
assert_eq!(torch.armed_off(), None);
|
||
}
|
||
|
||
/// Neither hand holds a weapon: the flag hides nothing at all, and a shield stays a shield.
|
||
#[test]
|
||
fn the_ladder_takes_weapons_and_leaves_the_rest() {
|
||
let shielded = Wielded {
|
||
main: None,
|
||
off: Some((4, 6)), // class 4 = ARMOR, subclass 6 = shield
|
||
disarmed: true,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(shielded.disarmed_hand(), None);
|
||
assert_eq!(shielded.armed_off(), Some((4, 6)));
|
||
}
|
||
|
||
/// The pure ladder, over the classes alone — the form the action bar's equipped-item test
|
||
/// (`0x5ea5d0`'s slot 0/1/2 loop) reads off the raw inventory.
|
||
#[test]
|
||
fn the_ladder_over_classes() {
|
||
assert_eq!(disarmed_hand(Some(2), Some(2)), Some(0));
|
||
assert_eq!(disarmed_hand(Some(2), None), Some(0));
|
||
assert_eq!(disarmed_hand(None, Some(2)), Some(1));
|
||
assert_eq!(disarmed_hand(Some(4), Some(2)), Some(1));
|
||
assert_eq!(disarmed_hand(None, None), None);
|
||
assert_eq!(disarmed_hand(Some(4), Some(4)), None);
|
||
}
|
||
}
|