Files
benilla-octo/crates/benilla-app/src/creature_anim/spell_visual.rs
T
SamandClaude Fable 5 bdef9cd790 benilla-world: the engine becomes its own crate
- the world engine splits from the game into a benilla-world crate — 112 files, and the wall is ratcheted to zero names of the game inside the engine
- the engine's public doorway is counted and gated at 32 items, and the render materials, shaders and asset cache move down to benilla-assets
- the game's files leave the engine's asset root — game:// is its own space — and a spike binary boots the engine with no game attached
- weather rides along: snowfall emission is denominated in the reference's frame cap, and a long teleport retires the stranded precipitation field instead of leaving it behind

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:13:44 +07:00

1313 lines
70 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 spell-visual **data plane + cast-edge router** (decision 0099 phase 2; schemas and stage
//! wiring pinned decision 0107): `SpellVisual.dbc` × `SpellVisualKit.dbc` loaded once at startup
//! exactly like [`super::AnimData`], and [`route_cast_visuals`] — the one place spell ids resolve
//! to animations/sounds. The wire side ([`super::CastEvent`], written by the net bridge) and the
//! render side ([`super::CastHold`] + [`super::EmoteAnim`] one-shots, consumed by the driver)
//! never touch the DBC chain themselves.
#[cfg(test)]
mod tests;
use bevy::ecs::entity::{EntityHashMap, EntityHashSet};
use bevy::prelude::*;
use benilla_formats::{SpellVisualCatalog, VisualKit, VisualStages, MISSILE_ATTACH_TABLE};
use benilla_protocol::EntityKind;
use crate::aura_visual::AuraProc;
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{NetCommands, NetEntity, ObjectStore};
use benilla_assets::{LockRecover, WorldAssets};
use super::{CastEvent, CastEventKind, CastHold, EmoteAnim, SpellGoTargets, WoundAnim};
/// The client's literal missile-model fallback when the visual chain resolves to nothing
/// (`0x860c9c` — a checkerboard cube, shipped in the real MPQs; faithful, not a joke).
const ERROR_CUBE: &str = "Spells\\ErrorCube.mdx";
/// The reserved [`SpellKitFx`] reap key for the lootable-corpse effect — NOT a spell id (no 5875
/// spell id is anywhere near `u32::MAX`; the client keys these nodes by attach tag + node flag
/// instead of a spell, wow-re `loot-corpse-effect.md`). One key suffices: a unit carries at most
/// one loot effect (the client's flag8 dedup), which [`arm_loot_fx`]'s edge cache guarantees.
const LOOT_FX_KEY: u32 = u32::MAX;
/// The M2 attachment the three engine-spawned effects we ship hang from — the loot sparkle, the
/// level-up ding and the mount poof. It is also the `spell_fx` attach cascade's own last
/// fallback, so a model lacking the point lands on the unit base — exactly the reference's
/// root fallback (`0x61fb4f`–`0x61fb5f`: `HasAttachment(tag)` on `[unit+0xd8]`, else `0x13`).
///
/// **It is a table value, not an engine-wide constant** (wow-re `mount-composition.md` Q4b.1, §5
/// 2026-08-03 — the 14-entry `DAT_0080c968`, dumped and joined to the `0x8617b8` name table). It
/// happens to be `0x13` for indices **4** Loot Art, **5** Unit Level Up and **6** Mount Poof —
/// our three — and for the PetLoyalty/Meeting-Stone/Reputation rows; it is `0x11` for the two
/// breath effects and Inebriated Bubbles, and the sentinel `0x25` (spawn refused outright) for
/// the two footstep sprays. A fourth consumer must read the table row, not reuse this.
const HARDCODED_FX_ATTACH: u16 = 0x13;
/// The engine-spawned level-up effect's baked lookup name (the client's string `0x8618e0`,
/// matched by the boot name-resolve `0x61f5b0`; wow-re `levelup-ding.md` — decision 0305).
const LEVEL_UP_EFFECT: &str = "HARDCODED Unit Level Up";
/// The mount transition's cloud — hardcoded index **6**, shipped as `SpellVisualEffectName` row
/// 1185 → `Spells\DruidMorph_Impact_Base.mdx`, i.e. literally the druid-morph puff (wow-re
/// `mount-composition.md` Q4b, decision 0927).
const MOUNT_POOF_EFFECT: &str = "HARDCODED Mount Poof";
/// `SpellVisual.dbc` × `SpellVisualKit.dbc` — the stage-kit chain + each kit's anim/sound
/// (`crate::creature_anim::spell_visual` module docs). Optional like every other DBC-backed
/// resource: absent (no client data) degrades to no spell-visual playback, same shape as
/// [`super::AnimData`].
#[derive(Resource)]
pub(crate) struct SpellVisuals(pub(crate) SpellVisualCatalog);
/// A spell-visual kit's sound edge (kit field 13 → `SoundEntries.dbc`, decision 0107 verdict
/// C5-corrected: the kit's own sound column, rung at kit start). Written here, consumed by
/// `crate::sound`. One message type so the consumer sees the edges **in emission order** — a
/// GO's stop-then-release never races.
#[derive(Message, Clone, Copy, Debug)]
pub(crate) enum SpellKitSound {
/// Ring this kit at the unit. A LOOPING kit (`SoundEntries` flag 0x200) becomes the unit's
/// tracked **hold loop** — the client's looping-test split (`0x458830` → tracked `0x61fec0`
/// vs one-shot `0x458870`) — sustained until [`Self::StopHold`].
Play { entity: Entity, kit_sound: u32 },
/// The cast/channel hold ended (GO / fail / channel-clear / a replacing cast) — reap the
/// unit's tracked hold loop, if any (the client kills the effect's sound at `0x614150`).
StopHold { entity: Entity },
/// Ring this kit at a bare **world point**, with no owning unit — the kit-sound leg's
/// `extra`-override arm (wow-re `kit-sound-leg.md`: "position = `extra` if non-NULL else the
/// unit's own position", `60f49e`–`60f4bc`). Its one caller is the missile's ground arrival,
/// where `0x61d870` passes the landing point: the bomb's boom belongs where it landed, not at
/// the thrower. Always a one-shot — a LOOPING sound here would need a tracked node with an
/// owner, and none of the six shipped ground-arrival kits carries one (census below).
PlayAt { pos: Vec3, kit_sound: u32 },
/// Stop exactly `kit_sound`'s channels on this unit — the aura-drop reap of a LOOPING
/// **state**-kit sound (wow-re `kit-sound-leg.md`: a looping kit sound rides a tracked,
/// spell-id-tagged CEffect on the unit's list, and `0x614150` stops it with a 0.15 s fade
/// when the aura leaves). Kit-scoped so an unrelated hold loop on the same unit survives;
/// a one-shot kit is never tracked, so for it this is a no-op.
StopKit { entity: Entity, kit_sound: u32 },
}
/// A persistent effect instance's **lifetime class** — which owner's reap can kill it.
/// Byte-verified (wow-re `state-kit-aura-lifecycle.md`, §5 2026-07-14): the client's reap walk
/// `0x614150(spellId, force)` discriminates by node flags — every SPELL_GO / SPELL_FAILED reap
/// passes `force=0`, which **spares** stage-2 nodes (flag `0x1000`); only the aura-remove path
/// (`0x612320 → 0x5ff290`) passes `force=1` and takes them. So a cast's GO releasing its precast
/// never sweeps the same spell's aura-state models (re-eat while the food buff holds: the GO
/// must not take the bread with it). This enum is that discriminator, named.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum FxClass {
/// Cast-lifecycle instances: precast/channel holds (and every self-terminating flash, whose
/// class never matters — they die on their own clock). Reaped by the cast router's edges.
Hold,
/// Aura-state instances (stage 2 under a live aura) — owned and reaped only by
/// [`arm_aura_state_fx`]'s slot watcher.
AuraState,
}
/// Which of the reference's five kit **stages** an instance's model runs its *animation lifecycle*
/// as — the discriminator behind `PlaySpellVisualKit`'s per-stage completion-callback table
/// (`0x60edf0`'s jump table `0x60f4f8`; wow-re `ceffect-anim-lifecycle.md` §2, §5 trio 2026-08-02).
///
/// Distinct from [`FxClass`], which answers a different question — *whose reap can kill this* (the
/// `0x614150` force census). The two are not derivable from each other: a channel kit and an
/// aura-state kit are both stage 2 but reaped by different owners, while a precast and a channel
/// are both [`FxClass::Hold`] and run *different* animation lifecycles. So the stage rides its own
/// field, set at each writer from the caller table (`spell-visual-apply.md` §1.6).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum FxStage {
/// Stages 0/1 (kit push, cast release, impact) — callback `0x5fbf50`: destroy at the first
/// completion. Already modelled by the instance's own span clock, so it arms no watcher.
OneShot,
/// Stage 2 (aura state, channel) — callback `0x5ff170`: when the birth sequence completes, arm
/// **`Hold` (158)** if the model authors one and re-arm it for the effect's life; the reap arms
/// **`Decay` (159)** and the instance lives out that span. A model with no `Hold` is left
/// parked on its birth — `0x5ff170` does nothing at all in that case.
State,
/// Stages 3/4 (precast) — callback `0x60ed00`: **re-arm the id that just completed, forever**,
/// with no `Hold` lookup, no deadline and no destroy. So a precast model whose birth sequence
/// clamps still repeats it, where a loop-flag-only arm would freeze.
Relive,
}
/// A spell-visual kit's attach-point **effect model** edge (kit fields 3–11 →
/// `SpellVisualEffectName` `.mdx` — decision 0099 phase 3; slot loop byte-pinned in wow-re
/// `spell-visual-apply.md` §1.3). Written here, consumed by `crate::entities::spell_fx` (the
/// bone-rider home). One message type so the consumer sees edges **in emission order** — a GO's
/// reap-then-begin never races.
#[derive(Message, Clone)]
pub(crate) enum SpellKitFx {
/// Attach this kit's effect models to the unit — one instance per populated slot,
/// `(M2 attachment id, effect model path)`. `persistent` = the precast/channel/aura-state
/// lifetime (lives until a matching-[`FxClass`] [`Self::Reap`] — the client's stage-4/2
/// no-self-termination); else the effect self-terminates after one pass of its model's
/// first sequence (the stage-0/1 completion callback `0x5fbf50`). A persistent Begin
/// replaces the unit's live persistent instances of the same `(spell_id, class)`.
Begin {
entity: Entity,
spell_id: u32,
persistent: bool,
class: FxClass,
/// The kit stage this play is, which decides the instances' **animation lifecycle**
/// ([`FxStage`]) — a separate axis from `class`/`persistent`, both of which are about
/// lifetime ownership rather than what the model plays.
stage: FxStage,
effects: Vec<(u16, String)>,
},
/// The owner's lifetime ended (GO / fail / channel-clear / a replacing cast for
/// [`FxClass::Hold`]; the aura leaving the slots for [`FxClass::AuraState`]) — despawn the
/// unit's **persistent** effects matching `(spell_id, class)` (the client's spell-id-keyed
/// reap `0x614150`; self-terminating instances run out on their own clock).
Reap {
entity: Entity,
spell_id: u32,
class: FxClass,
},
}
/// A kit whose `CharProc` slots name a **beam** just played on `entity` — the client's CharProc
/// dispatcher reaching its chain case (`0x60da79`, decision 0955). Written here for every kit play
/// that carries one (the dispatcher runs at `PlaySpellVisualKit`'s tail, `0x60f35c`, and again from
/// the channel poll, `0x612b18`); consumed by `crate::entities::chain_beam`, which owns the target
/// selection against the caster's hop array, the beam's lifetime, and its geometry.
#[derive(Message, Clone, Copy)]
pub(crate) struct ChainProcPlay {
/// The unit the kit played on — the beam's caster end, and the owner of the hop array.
pub(crate) entity: Entity,
/// The kit's spell (`0` for a bare kit push, which the client's own `spellId != 0` guard at
/// `0x6ecbd0` refuses to build a beam for).
pub(crate) spell_id: u32,
pub(crate) proc: benilla_formats::ChainProc,
}
/// Launch a projectile from `caster` at each target (decision 0099 phase 4) — one missile per
/// target, the client's per-target spawn on the Speed>0 GO branch (`0x6e8a50 → 0x60a3d0`).
/// Written here (the DBC columns resolve in this module, like every kit edge); consumed by
/// `crate::entities::missile`, which owns launch/flight/arrival and writes the arrival back as
/// [`super::CastEventKind::Impact`].
#[derive(Message, Clone)]
pub(crate) struct MissileSpawn {
pub(crate) caster: Entity,
pub(crate) spell_id: u32,
/// The projectile's model path (`SpellVisual` field 7 → `SpellVisualEffectName`, already
/// `.mdx`-form; the client's literal `Spells\ErrorCube.mdx` when a nonzero id is
/// unresolvable). `None` = the visual chain names no missile (field 7 < 1, or no
/// `SpellVisual` row at all — every basic shot spell): the spawner falls to the wire ammo
/// model (the client's `0x479f40` branch), phase 5.
pub(crate) path: Option<String>,
/// The GO's ammo block — the flying arrow/bullet/thrown model when [`Self::path`] is `None`.
pub(crate) ammo_display_id: Option<u32>,
/// The M2 attach tag the missile homes to on a live target (`SpellVisual` field 9's ordinal
/// through [`benilla_formats::MISSILE_ATTACH_TABLE`], the client's `0x860a18`) — `None` for
/// an out-of-table ordinal (the target's base position, the client's `-1` sentinel path).
pub(crate) dest_tag: Option<u16>,
/// `Spell.dbc` Speed, world units/sec — travel time = launch distance / speed, then
/// arrive-on-time (the client's `0x61ceb0`).
pub(crate) speed: f32,
/// `(target, miss)` — `None` landed (arrival plays the impact hand-off); `Some(code)` the
/// wire's `SpellMissInfo`: the missile still flies, and arrival plays the victim's defense
/// clip for DODGE(3)/BLOCK(5) instead (the client's `Missile_C::Update` dispatch).
pub(crate) targets: Vec<(Entity, Option<u8>)>,
/// The **location fallback**: when [`Self::targets`] is empty and the GO carried a ground
/// point, exactly ONE projectile flies at it instead (the client's `0x6e8a50` empty-hit-array
/// arm — `0x6e8aa2 test al,0x60; setne cl` latches "SOURCE or DEST location present",
/// `0x6e8bd4` then calls the spawner once, `0x60a5b6`–`0x60a5c9` copies `targets+0x3c` into
/// the aim point and `0x60a5ec` forces the owning unit slot to −1; wow-re
/// `spell-go-dest-effect.md` §3). This is what a pure ground cast — Flare, a bomb thrown at
/// empty dirt — actually shows travelling. Its arrival is [`super::CastEventKind::GroundImpact`],
/// never the unit hand-off. Named divergence: the reference latches on `flags & 0x60` and then
/// reads the DEST vector regardless, so a SOURCE-only cast aims at whatever the zero-initialised
/// DEST slot holds; we carry only a real DEST (`flags & 0x40`) and launch nothing without one.
pub(crate) ground_aim: Option<Vec3>,
/// The caster's ranged-weapon fallback `SpellVisual` id, resolved at GO time — rides the
/// flight and returns in [`super::CastEventKind::Impact`] so a basic shot's impact kit
/// resolves through it at arrival (the caster may be gone by then; decision 0370).
pub(crate) weapon_visual: Option<u32>,
/// `SpellVisual` field 10 → the `SoundEntries.dbc` id the projectile LOOPS while it travels
/// (the thrown weapon's `WeaponLoop`, the fireball's `FireMissileLoop`) — the client's
/// per-missile loop handle (`CMissile+0x44`). `None` = a silent flight.
pub(crate) missile_sound: Option<u32>,
/// Whether the launch **waits for the cast animation's release keyframe** — the client queues
/// every projectile on the caster (`unit+0xac`) and its GO handler flushes immediately only
/// when the cast kit plays no body animation (`0x6e7a70`: `castKit == 0 || kit anim < 1` →
/// `0x60c9b0`); otherwise the launch is the animation's `$CSL`/`$CSR`/`$CST`/`$BWR` event
/// (the dispatcher `0x5ffbd0` → `0x60c940`), from the fired marker's live position — the
/// fireball leaves the raised hand mid-throw, not the hold pose at GO.
pub(crate) awaits_release: bool,
}
/// Load `SpellVisual.dbc` + `SpellVisualKit.dbc` off the patch chain at startup (mirrors
/// [`super::sheath::load_anim_data`]'s pattern exactly).
pub(super) fn load_spell_visuals(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
let Some(assets) = assets else { return };
let loaded = {
let mut chain = assets.chain.lock_recover();
benilla_formats::load_spell_visual_catalog(&mut chain)
};
match loaded {
Ok(cat) => {
info!(
"anim: {} SpellVisual / {} SpellVisualKit rows",
cat.len(),
cat.kit_len()
);
commands.insert_resource(SpellVisuals(cat));
}
Err(e) => warn!("anim: SpellVisual/SpellVisualKit failed to load: {e:#}"),
}
}
/// The lookup bundle behind the client's `0x60d450` ranged fallback: caster →
/// equipped-ranged-weapon display → `ItemDisplayInfo` col 10 substitute `SpellVisual` id
/// (wow-re `throw-ranged-attack-anim.md`, decision 0370).
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct WeaponVisualSrc<'w, 's> {
displays: Option<Res<'w, ItemDisplays>>,
items: Option<ResMut<'w, Items>>,
net: Option<Res<'w, NetCommands>>,
units: Query<'w, 's, (Option<&'static NetEntity>, &'static ObjectStore)>,
}
impl WeaponVisualSrc<'_, '_> {
/// The caster's equipped ranged weapon's SUBSTITUTE `SpellVisual` id (`vtable+0xa0(slot 2)`
/// → `ItemDisplayInfo` col 10). A creature carries the display id on the wire
/// (`UNIT_VIRTUAL_ITEM_SLOT_DISPLAY[2]`); a player resolves entry → template through the
/// ask-once item layer (cached by the time anything shoots — the equipped weapon already
/// rendered through it). `None` = no ranged weapon, or its display names no visual (every
/// non-ranged item).
fn caster(&mut self, caster: Entity) -> Option<u32> {
let (net_entity, store) = self.units.get(caster).ok()?;
let s = &store.0;
let display_id = match net_entity?.kind {
EntityKind::Unit => s.unit_virtual_item_display(2),
// 17 = vmangos EQUIPMENT_SLOT_RANGED (the equipment layer's PLAYER_HELD_SLOTS[2]).
EntityKind::Player => s
.player_visible_item_entry(17)
.filter(|e| *e != 0)
.and_then(|entry| self.items.as_deref_mut()?.held(entry, self.net.as_deref()?))
.map(|t| t.display_info_id),
_ => None,
}
.filter(|d| *d != 0)?;
let visual = self
.displays
.as_deref()?
.catalog
.get(display_id)?
.spell_visual;
(visual != 0).then_some(visual)
}
}
/// A spell's effective `SpellVisual` row (spell → `Spell.dbc` column 115 → `SpellVisual.dbc`).
/// `None` = a silent cast (no visual chain at all).
///
/// The one place the client's **ranged weapon-visual merge** applies (`0x60d450`; the law is
/// [`VisualStages::merged_over_weapon`], byte-read there): a RANGED-attribute spell
/// (`Attributes & 0x2`) takes the caster's equipped ranged weapon's substitute visual for every
/// slot its **own** row leaves at zero — precast/cast/impact kits, the missile block, the strike
/// sound; never state/channel. A basic shot (`SpellVisual1 = 0`) is only the degenerate case of
/// that fill — the whole zeroed row comes across (Throw → ReadyThrown/AttackThrown, Auto Shot →
/// LoadBow/AttackBow, wand Shoot → the gun kits), which is what decision 0370 read as the *whole*
/// mechanism; every hunter shot spell has its own impact + missile row and empty body-anim slots,
/// so it needs the per-field arm (decision 0986, bug B153). A non-ranged spell never pays the
/// lookup (`FnOnce`).
fn resolve_stages(
spells: &crate::ui_action::Spells,
visuals: &SpellVisualCatalog,
spell_id: u32,
weapon_visual: impl FnOnce() -> Option<u32>,
) -> Option<VisualStages> {
let def = spells.catalog.get(spell_id)?;
let own = visuals.stages(def.visual).copied();
if !def.ranged_slot() {
return own; // `ebx` stayed 0 at `60d468` — the fill block is a no-op
}
// `60d46e`–`60d4aa`: the caster's ranged display → `ItemDisplayInfo` col 10 → its row.
let Some(weapon) = weapon_visual().and_then(|v| visuals.stages(v)) else {
return own;
};
// No own row = the client's `rep stosd` zeroed `outKit` before the same fill (`60d4bc`).
Some(own.unwrap_or_default().merged_over_weapon(weapon))
}
/// Resolve one lifecycle stage of `spell_id`'s visual chain to its kit (spell → `Spell.dbc`
/// column 115 → `SpellVisual` stage column → `SpellVisualKit` row), via the given stage selector.
/// `None` anywhere along the chain = that stage is silent for this spell (the common case — most
/// spells populate only some stages). `weapon_visual` is [`resolve_stages`]'s ranged fallback.
fn resolve_kit(
spells: &crate::ui_action::Spells,
visuals: &SpellVisualCatalog,
spell_id: u32,
stage: impl Fn(&VisualStages) -> u32,
weapon_visual: impl FnOnce() -> Option<u32>,
) -> Option<VisualKit> {
let kit_id = stage(&resolve_stages(spells, visuals, spell_id, weapon_visual)?);
(kit_id != 0)
.then(|| visuals.kit(kit_id).copied())
.flatten()
}
/// The in-flight cast's **strike sound** for the `$TRD` anim event (`0x62faa0`): the handler
/// resolves the spell's visual to its 16-dword `SpellVisual.dbc` row (`0x60d450` →
/// `DAT_00c0d738[visualId]`) and plays that row's **dword 14** (`[row+0x38]`) positioned at the
/// unit — wow-re `sound/scratch/gather-sound-anim-events.md`, decision 0562. Mining's visual 93
/// carries 1143 "Mining Impact" here (the pick clang — client-side complete, no server state);
/// the smithing crafts' 395 the same hammer; Herb's 91 carries 1142 but its anim never fires
/// `$TRD`. No ranged-weapon fallback (`$TRD` rides work/craft anims, never a ranged shot's).
pub(crate) fn held_strike_sound(
spells: &crate::ui_action::Spells,
visuals: &SpellVisualCatalog,
spell_id: u32,
) -> Option<u32> {
resolve_stages(spells, visuals, spell_id, || None)?.strike_sound
}
/// A kit's populated emitter slots resolved to `(attach tag, model path)` pairs — the
/// [`SpellKitFx::Begin`] payload. Slots whose `SpellVisualEffectName` row/path is missing are
/// dropped (the client's NULL-record skip in the slot loop).
fn resolve_kit_effects(visuals: &SpellVisualCatalog, kit: &VisualKit) -> Vec<(u16, String)> {
kit.effects()
.filter_map(|(tag, id)| visuals.effect_path(id).map(|p| (tag, p.to_string())))
.collect()
}
/// A server-pushed kit outside any cast (`SMSG_PLAY_SPELL_VISUAL` → the net bridge; decision
/// 0280): the client bounds-checks the u32 against `SpellVisualKit.dbc` and plays it at a
/// hardcoded **stage 0** — the instant, self-terminating flavour (spellRec NULL, so its effects
/// carry spell id 0 and are reaped only by their own completion, `0x6e98d0` → `0x60edf0`). Live
/// traffic: the eat/drink kits (406/438) re-sent every ~5 s, and mid-channel kit swaps.
#[derive(Message, Clone, Copy)]
pub(crate) struct KitPush {
pub(crate) entity: Entity,
pub(crate) kit_id: u32,
/// [`super::PlaySeq`] stamp at emission (the wire drain, in packet order).
pub(crate) seq: u64,
}
/// The writer set one discrete kit play fans out to — bundled so [`play_kit`] threads through the
/// router's arms as one argument (each writer keeps its own system-param lifetime).
struct KitOut<'a, 'w1, 'w2, 'w3, 'w4, 'w5> {
oneshots: &'a mut MessageWriter<'w1, EmoteAnim>,
wounds: &'a mut MessageWriter<'w2, WoundAnim>,
sounds: &'a mut MessageWriter<'w3, SpellKitSound>,
fx: &'a mut MessageWriter<'w4, SpellKitFx>,
/// The kit's beam, if its `CharProc` slots name one — the dispatcher's chain case (0955).
chain: &'a mut MessageWriter<'w5, ChainProcPlay>,
}
/// One kit played as a **discrete event** on a unit — the client's `PlaySpellVisualKit`
/// (`0x60edf0`): the kit's anim, sound, and attach-point effect models. The anim branch is the
/// client's own (`0x60f3ad` → `0x60f510`): a CombatWound-family id (8–10) lays into the wound
/// SECONDARY-blend slot (never interrupting what plays — decision 0111); anything else is the
/// ordinary over-the-gait one-shot. `persistent` is the stage's effect-model lifetime
/// (decision 0107 verdict 2).
/// One kit play's fan-out policy — which legs run, per stage:
/// - `persistent`: the effect models' lifetime (the precast/channel hold vs self-terminating).
/// - `effects`: whether the models spawn here at all. `false` for the impact hand-off's STATE
/// stage — its models belong to the aura's life and [`arm_aura_state_fx`] owns them.
/// - `sound`: whether the kit's sound rings — **ungated and positional at the unit, every
/// stage** (wow-re `kit-sound-leg.md`, decision 0852: `0x60edf0`'s sound leg carries no self
/// test; the old "state sound is self-only" reading was `0x5ff43e`'s gate on a *different*
/// aura-apply cue, and `0x5fa6d0` appears nowhere in the kit play). The flag exists for
/// plays that deliberately hand the sound to another owner (none today).
#[derive(Clone, Copy)]
struct KitPlay {
persistent: bool,
effects: bool,
sound: bool,
/// The stage this play is, for the instances' animation lifecycle ([`FxStage`]).
stage: FxStage,
}
impl KitPlay {
/// The ordinary discrete play (kit push, cast release, impact stage): self-terminating
/// models, every leg on. Stages 0/1 both land here — they share `0x5fbf50`.
const DISCRETE: Self = Self {
persistent: false,
effects: true,
sound: true,
stage: FxStage::OneShot,
};
}
fn play_kit(
entity: Entity,
spell_id: u32,
kit: &VisualKit,
play: KitPlay,
seq: u64,
visuals: &SpellVisualCatalog,
out: &mut KitOut,
) {
if let Some(anim_id) = kit.anim_id {
if (8..=10).contains(&anim_id) {
out.wounds.write(WoundAnim { entity, anim_id });
} else {
out.oneshots.write(EmoteAnim {
entity,
anim_id,
seq,
});
}
}
if let Some(kit_sound) = kit.sound.filter(|_| play.sound) {
out.sounds.write(SpellKitSound::Play { entity, kit_sound });
}
// The CharProc dispatcher's beam case — run for EVERY kit play, at every stage, exactly as
// `PlaySpellVisualKit`'s tail runs it (`0x60f35c`). It is not gated by `play.effects`: the
// beam is not one of the kit's attach-point effect models, and a kit whose caster carries no
// hop array simply draws nothing (`0x60db01`'s `count == 0` exit).
if let Some(proc) = kit.chain_proc() {
out.chain.write(ChainProcPlay {
entity,
spell_id,
proc,
});
}
if !play.effects {
return;
}
let effects = resolve_kit_effects(visuals, kit);
if !effects.is_empty() {
out.fx.write(SpellKitFx::Begin {
entity,
spell_id,
persistent: play.persistent,
class: FxClass::Hold,
stage: play.stage,
effects,
});
}
}
/// The **unit-impact hand-off** (the client's `0x61dc50`, decision 0099 phase 4): the spell
/// landed on `entity` — play the impact kit (stage 1), then the state kit (stage 2). The state
/// kit plays its anim + sound only: its **effect models live for the aura's life**, owned by
/// [`arm_aura_state_fx`]'s slot watcher (a single instance, begun when the aura lands in the
/// slots and reaped when it leaves — the same packet burst as the GO, so there is no visible
/// gap). The state sound rings **ungated and positional** (wow-re `kit-sound-leg.md`, decision
/// 0852: `0x60edf0`'s sound leg has no self test — the old self-only reading belonged to a
/// different aura-apply cue); the same-frame duplicate against the aura watcher's ADD-edge play
/// collapses in the sound router. Named approximation: the client's small stage-2 gate
/// `0x61dc20` (37 B, content unpinned) is not modeled. `weapon_visual` = the CASTER's ranged
/// fallback visual (already resolved — the caster may be gone by a missile arrival), through
/// which a basic shot's impact kit resolves.
fn play_impact(
entity: Entity,
spell_id: u32,
weapon_visual: Option<u32>,
seq: u64,
spells: &crate::ui_action::Spells,
visuals: &SpellVisualCatalog,
out: &mut KitOut,
) {
for (stage, play) in [
(
(|s: &VisualStages| s.impact) as fn(&VisualStages) -> u32,
KitPlay::DISCRETE,
),
(
|s: &VisualStages| s.state,
KitPlay {
effects: false,
..KitPlay::DISCRETE
},
),
] {
if let Some(kit) = resolve_kit(spells, visuals, spell_id, stage, || weapon_visual) {
play_kit(entity, spell_id, &kit, play, seq, visuals, out);
}
}
}
/// The cast-edge router (decision 0107): wire [`CastEvent`]s + the public channel descriptor →
/// the resolved animation intents the driver renders.
///
/// - **Start** → the **precast** kit: its anim becomes the unit's [`CastHold`] (sustained until
/// the cast resolves — the client's stage-4 persistence), its sound rings.
/// - **Go** → the hold drops; the **cast** kit plays as a discrete event ([`play_kit`]), and the
/// GO's target lists ([`SpellGoTargets`]) branch on `Spell.dbc` Speed — 0 → the impacts play
/// inline ([`play_impact`] per hit, the client's instant branch `0x6e8bf0`); >0 → one
/// [`MissileSpawn`] (the projectile branch `0x6e8a50 → 0x60a3d0`), whose arrivals come back as
/// `Impact` events.
/// - **Impact** → the spell landed on this unit (a missile arrival) — [`play_impact`].
/// - **Fail** → the hold drops silently (the client's spell-id-keyed reap; no release).
/// - **Channel** (polled per frame, the client's `0x612a30` over `UNIT_CHANNEL_SPELL` — observers
/// have no packet, decision 0099): field set → the **channel** kit's anim as the hold + its
/// sound once at start; field cleared → hold drops. The per-entity edge cache is the dedup the
/// client gets from its per-tick armed-id guard — a held channel never restarts its clip.
#[allow(clippy::too_many_arguments)]
pub(super) fn route_cast_visuals(
mut commands: Commands,
mut events: MessageReader<CastEvent>,
mut go_targets: MessageReader<SpellGoTargets>,
mut pushes: MessageReader<KitPush>,
mut oneshots: MessageWriter<EmoteAnim>,
mut wounds: MessageWriter<WoundAnim>,
mut sounds: MessageWriter<SpellKitSound>,
mut fx: MessageWriter<SpellKitFx>,
mut sheaths: MessageWriter<super::SheathRequest>,
// One tuple param (the 16-SystemParam ceiling): the missile spawns, the dest one-shot orders
// (0797) and the beam plays (0955) — the spawn lanes resolved here where the catalogs live,
// built by `crate::entities`.
mut spawns: (
MessageWriter<MissileSpawn>,
MessageWriter<crate::entities::dest_fx::GroundBurst>,
MessageWriter<ChainProcPlay>,
),
visuals: Option<Res<SpellVisuals>>,
spells: Option<Res<crate::ui_action::Spells>>,
mut weapon_src: WeaponVisualSrc,
units: Query<(Entity, &ObjectStore)>,
holds: Query<&CastHold>,
mut channel_cache: Local<EntityHashMap<u32>>,
) {
let (Some(visuals), Some(spells)) = (visuals.as_deref(), spells.as_deref()) else {
return; // no client data — no spell visuals (every DBC resource degrades this way)
};
// Disjoint field borrows: the beam writer rides `out` for the whole body while the missile and
// burst writers stay free for the GO loop below.
let (missiles, bursts, chains) = (&mut spawns.0, &mut spawns.1, &mut spawns.2);
let mut out = KitOut {
oneshots: &mut oneshots,
wounds: &mut wounds,
sounds: &mut sounds,
fx: &mut fx,
chain: chains,
};
// The `holds` query is one command-flush stale: an instant cast's START and GO drain from
// the wire in the same frame, so the GO's spell-id-keyed release must see the hold its own
// batch's START just inserted — through the query it can't, the remove is skipped, and the
// deferred insert lands unopposed: the cast pose loops forever (the Demon Armor / Ice Armor
// stuck cast; the real client's handlers run synchronously in packet order, so it has no
// such gap). `pending` overlays this frame's hold writes; every hold read goes through
// `held_spell`.
let mut pending: EntityHashMap<Option<u32>> = EntityHashMap::default();
let held_spell = |pending: &EntityHashMap<Option<u32>>, entity: Entity| -> Option<u32> {
match pending.get(&entity) {
Some(&overlaid) => overlaid,
None => holds.get(entity).ok().map(|h| h.spell_id),
}
};
for p in pushes.read() {
// The kit-push opcode (decision 0280, stage-0 semantics): fresh transient effects/sound
// on EVERY send — the client allocates new CEffects per call, no dedup — while the body
// anim rides the driver's arm-level same-id dedup (`0x5fdba0`), so a looping eat/drink
// clip free-runs across the ~5 s resends instead of restarting.
if let Some(kit) = visuals.0.kit(p.kit_id) {
play_kit(
p.entity,
0,
kit,
KitPlay::DISCRETE,
p.seq,
&visuals.0,
&mut out,
);
}
}
for ev in events.read() {
// **The subject may already be dead** (B130's crash, the second ever reported): every
// despawn of an *indexed* unit runs inside the wire drain — `DESTROY_OBJECT`, the
// out-of-range stream-out, the worldport purge — and those commands are applied at the sync
// point this chain sits behind (`.after(WorldStage::Net)`), so a SPELL_START and its
// subject's death drain from the same batch and the edge outlives the unit. Every arm below
// is *about* that unit (its hold, its sound, its effect models), so a dead subject skips
// whole rather than spraying the downstream lanes with edges for an entity that is gone —
// and rather than warning once per component removed off a corpse.
if commands.get_entity(ev.entity).is_err() {
continue;
}
match ev.kind {
CastEventKind::Start => {
// A replacing cast reaps the prior hold's loop before its own sound starts —
// and the prior spell's persistent effect models, keyed by the hold it replaces.
out.sounds
.write(SpellKitSound::StopHold { entity: ev.entity });
if let Some(prior) = held_spell(&pending, ev.entity) {
out.fx.write(SpellKitFx::Reap {
entity: ev.entity,
spell_id: prior,
class: FxClass::Hold,
});
}
// A ranged-attribute shot arms the **ranged stance** the moment its START lands —
// the client's snap `SetSheatheState(2,1,1)` on the same gate (remote casters at
// `0x6e78f3`; the local player armed at cast-send `0x6e5930`, whose echo START
// lands a frame later — same look). One START per auto-repeat activation
// (VERIFIED, vmangos `Spell::prepare`: the per-shot re-casts are triggered and
// send only GO), and the executor's `newState == CUR` refusal makes any
// re-activation free.
if spells
.catalog
.get(ev.spell_id)
.is_some_and(|d| d.ranged_attack())
{
sheaths.write(super::SheathRequest {
entity: ev.entity,
state: 2,
ceremony: false,
});
}
if let Some(kit) = resolve_kit(
spells,
&visuals.0,
ev.spell_id,
|s| s.precast,
|| weapon_src.caster(ev.entity),
) {
// The `0x400` weapon-visual hold, ANY caster (wow-re
// `ranged-sheath-exempt-autorepeat.md` §Q4): a RANGED spell's visual play
// sets it (`0x60d020`, sole caller inside PlaySpellVisual); any other
// visual play clears it (the stale-visual cleanup `0x6ec39e` — re-set only
// when the new visual is ranged). It is what keeps a REMOTE shooter (an NPC
// archer, another hunter) in the drawn Load/Hold idle between shots — the
// local `0x200` ([`super::AutoRepeatArmed`]) never exists off the local
// cast-send.
let ranged = spells
.catalog
.get(ev.spell_id)
.is_some_and(|d| d.ranged_slot());
// Fallible, like every hold write in this system: the skip above sees only
// despawns already *applied*, and `model_fade::apply_despawn_fade` is
// Update-unordered against this chain — its instant path (a stream-out unit with
// no fadeable geometry, which is what a creature streamed in and back out at
// flight speed *is*) can queue the despawn this frame and have it applied before
// our commands. An infallible `insert` panics there; nothing can see it coming.
if ranged {
commands.entity(ev.entity).try_insert(super::RangedHold);
} else {
commands.entity(ev.entity).try_remove::<super::RangedHold>();
}
if let Some(anim_id) = kit.anim_id {
commands.entity(ev.entity).try_insert(CastHold {
anim_id,
spell_id: ev.spell_id,
ranged,
});
pending.insert(ev.entity, Some(ev.spell_id));
}
if let Some(kit_sound) = kit.sound {
out.sounds.write(SpellKitSound::Play {
entity: ev.entity,
kit_sound,
});
}
// The precast's attach-point effect models (the glowing hands) — persistent,
// the client's stage-4 lifetime: they live until the cast resolves, and their
// animation re-arms forever ([`FxStage::Relive`], `0x60ed00`).
let effects = resolve_kit_effects(&visuals.0, &kit);
if !effects.is_empty() {
out.fx.write(SpellKitFx::Begin {
entity: ev.entity,
spell_id: ev.spell_id,
persistent: true,
class: FxClass::Hold,
stage: FxStage::Relive,
effects,
});
}
}
}
CastEventKind::Go => {
// Spell-id-keyed reap (the client's `0x614150(spellId, 0)`) — a proc's GO landing
// mid-cast never drops another spell's hold.
if held_spell(&pending, ev.entity) == Some(ev.spell_id) {
commands.entity(ev.entity).try_remove::<CastHold>();
pending.insert(ev.entity, None);
}
// The release reaps the precast's loop unconditionally-of-hold-state: an instant
// cast's Start may have begun a loop even when its precast kit carried no anim
// (so no CastHold). The effect-model reap is the same shape, spell-id-keyed.
out.sounds
.write(SpellKitSound::StopHold { entity: ev.entity });
out.fx.write(SpellKitFx::Reap {
entity: ev.entity,
spell_id: ev.spell_id,
class: FxClass::Hold,
});
// A ranged-slot spell's GO **re-draws ranged before its cast-kit play** — the
// kit-play path's internal snap (`0x60f34c`, gate `Attributes & 0x2`, stages
// {0, 4} — wow-re `ranged-sheath-exempt-autorepeat.md` Q3): SPELL_GO makes no
// direct SetSheatheState call, but every auto-repeat shot re-snaps state 2
// through its play. This is what re-draws a bow an emote lowered mid-volley.
if spells
.catalog
.get(ev.spell_id)
.is_some_and(|d| d.ranged_slot())
{
sheaths.write(super::SheathRequest {
entity: ev.entity,
state: 2,
ceremony: false,
});
}
// The cast kit (the release flash) — a discrete play, effects self-terminating
// after their model's own clip span (the client's stage-0/1 completion callback).
// For a basic shot this is the fire clip itself (the ranged fallback: Throw's
// AttackThrown, Auto Shot's AttackBow — wow-re `throw-ranged-attack-anim.md`).
if let Some(kit) = resolve_kit(
spells,
&visuals.0,
ev.spell_id,
|s| s.cast,
|| weapon_src.caster(ev.entity),
) {
// The `0x400` hold's per-shot re-assert (and the stale-visual clear for a
// non-ranged play) — the Start arm's twin; every mid-volley GO keeps a
// remote shooter's hold alive.
if spells
.catalog
.get(ev.spell_id)
.is_some_and(|d| d.ranged_slot())
{
commands.entity(ev.entity).try_insert(super::RangedHold);
} else {
commands.entity(ev.entity).try_remove::<super::RangedHold>();
}
play_kit(
ev.entity,
ev.spell_id,
&kit,
KitPlay::DISCRETE,
ev.seq,
&visuals.0,
&mut out,
);
}
}
CastEventKind::Impact { weapon_visual } => {
// A missile arrived on this unit (decision 0099 phase 4; speed-0 impacts play
// inline from the GO loop below and never round-trip through here). The caster's
// ranged fallback rode the missile — resolve through it, not the target.
play_impact(
ev.entity,
ev.spell_id,
weapon_visual,
ev.seq,
spells,
&visuals.0,
&mut out,
);
}
CastEventKind::GroundImpact { pos } => {
// A missile arrived at a POINT (`0x61e1d0`'s ground arm → `0x61d870`): play
// `SpellVisual` field 13 — the **area kit** — at stage 3 on the caster, with
// `extra` = the landing point. The reference then walks the missile's own
// server-recorded hit array (`CMissile+0x58/+0x5c`) for per-unit impact/state
// kits; ours is empty by construction — this arm is only reached because the
// GO's hit list was empty, which is what selected the location fallback.
//
// The kit legs, against the shipped data: all six `SpellVisual` rows that can
// reach here (Volley 3229, the bomb/dynamite family 1704/3270, Goblin Mortar
// 695, Arcane Bomb 4831, Firecrackers 6447 — the whole census of speed>0 ∧
// `Targets & 0x40` ∧ field 6 ≠ 0 ∧ field 13 ≠ 0) carry **no body animation and
// no effect slots, only a sound**. So the arrival is exactly the kit's field-13
// `SoundEntries` id at the landing point, and the anim/slot legs that would need
// stage 3's forever-relive lifetime ([`FxStage::Relive`]) are dead data here —
// left unbuilt rather than guessed at.
// `|| None`: the weapon merge pointedly skips the dest-anchored `+0x2c/+0x30/
// +0x34` block ([`VisualStages::merged_over_weapon`]), so even Volley's area kit
// is its own row's — the lookup would be paid for nothing.
if let Some(kit_sound) =
resolve_kit(spells, &visuals.0, ev.spell_id, |s| s.area_kit, || None)
.and_then(|k| k.sound)
{
out.sounds.write(SpellKitSound::PlayAt { pos, kit_sound });
}
}
CastEventKind::Fail => {
if held_spell(&pending, ev.entity) == Some(ev.spell_id) {
commands.entity(ev.entity).try_remove::<CastHold>();
pending.insert(ev.entity, None);
}
out.sounds
.write(SpellKitSound::StopHold { entity: ev.entity });
out.fx.write(SpellKitFx::Reap {
entity: ev.entity,
spell_id: ev.spell_id,
class: FxClass::Hold,
});
}
}
}
// The GO's target lists: `Spell.dbc` Speed picks the client's two GO branches — 0 → every
// hit's impact plays now (`0x6e8bf0`); >0 → a projectile per target (`0x6e8a50 → 0x60a3d0`),
// arrival routed back as `Impact` by `crate::entities::missile`. Misses fly too (the client
// deflects them off the target; ours fizzle silently — a named approximation).
for go in go_targets.read() {
let Some(display) = spells.catalog.get(go.spell_id) else {
continue;
};
// The caster's ranged weapon visual, resolved once per GO (the client's `0x6e802e` call
// into `0x60d450`) — its merged row `edi` is what EVERY consumer below reads: the dest
// one-shot (`[edi+0x18]`/`[edi+0x30]`), the cast kit (`[edi+0x8]`), the inline impacts
// (`6e8169 mov edx,edi`) and the missile spawn (`6e8199 mov edx,edi`).
let wv = weapon_src.caster(go.caster);
let stages = resolve_stages(spells, &visuals.0, go.spell_id, || wv);
// The GO's dest one-shot (decision 0797, byte-pinned `0x6e8088`–`0x6e8143`): a
// dest-carrying GO plays the SpellVisual field-12 model ONCE at the packet's point —
// gate `field 6 == 0` (no missile owns the arrival) ∧ `field 12 ≠ 0`. NOT gated on the
// hit list (a pure ground cast's lists are empty and the burst still plays — the
// captured Flamestrike shape); fired here at the GO, never waiting on the dynobj
// create (wow-re trap #6: the burst precedes the object).
if let Some(dest) = go.dest {
if let Some(stages) = stages {
if stages.missile_gate == 0 && stages.area_effect != 0 {
if let Some(path) = visuals.0.effect_path(stages.area_effect) {
bursts.write(crate::entities::dest_fx::GroundBurst {
path: path.to_string(),
pos: dest,
});
}
}
}
}
if display.speed <= 0.0 {
for &target in &go.hits {
play_impact(
target,
go.spell_id,
wv,
go.seq,
spells,
&visuals.0,
&mut out,
);
}
} else {
// The spawn gate is Speed **alone** (byte-pinned: `0x60a3d0` fires on the projectile
// gate's fcomp; every basic shot spell has no `SpellVisual` row at all and still
// flies). The missile-model chain: field 7 ≥ 1 → its effect model (the client's
// literal ErrorCube when unresolvable); < 1 / no visual row → `None`, and the
// spawner falls to the GO's wire ammo model (`0x479f40`, phase 5).
let path = stages.and_then(|s| {
(s.missile_model >= 1).then(|| {
visuals
.0
.effect_path(s.missile_model)
.unwrap_or(ERROR_CUBE)
.to_string()
})
});
// Field 9's ordinal → the attach tag the missile homes to. An out-of-table ordinal
// reads adjacent `.rdata` in the client; here it degrades to the base position.
let dest_tag =
stages.and_then(|s| MISSILE_ATTACH_TABLE.get(s.missile_attach as usize).copied());
// Field 10: the flight loop the projectile carries the whole way (thrown WeaponLoop,
// fireball FireMissileLoop) — resolved off the same row as the model/attach.
let missile_sound = stages.and_then(|s| s.missile_sound);
let targets: Vec<(Entity, Option<u8>)> = go
.hits
.iter()
.map(|&e| (e, None))
.chain(go.misses.iter().map(|&(e, code)| (e, Some(code))))
.collect();
// The location fallback ([`MissileSpawn::ground_aim`]): an empty hit array plus a
// point on the wire flies ONE projectile at the point. The hit array wins whenever
// it has anything in it — the client only consults the latch after
// `0x6e8abc … je 0x6e8ba2` has found the array empty.
let ground_aim = targets.is_empty().then_some(go.dest).flatten();
if !targets.is_empty() || ground_aim.is_some() {
// The release gate (the client's `0x6e7a70` flush condition, inverted): a cast
// kit that plays a body animation defers the launch to its release keyframe;
// no kit / no anim (`kit+8 < 1` ⇔ our `anim_id: None`) launches at GO.
let awaits_release = stages
.filter(|s| s.cast != 0)
.and_then(|s| visuals.0.kit(s.cast))
.is_some_and(|k| k.anim_id.is_some());
missiles.write(MissileSpawn {
caster: go.caster,
spell_id: go.spell_id,
path,
ammo_display_id: go.ammo_display_id,
dest_tag,
speed: display.speed,
targets,
ground_aim,
weapon_visual: wv,
missile_sound,
awaits_release,
});
}
}
}
// The channel poll: an edge on the PUBLIC `UNIT_CHANNEL_SPELL` descriptor drives an observed
// unit's channel loop (the self-only MSG_CHANNEL_* never carry this — decision 0099). Only
// *edges* act, so a held channel's clip is started once (the client's dedup guard) and a
// cleared field releases the hold even when the interrupt had no other wire trace.
for (entity, store) in &units {
let cur = store.0.unit_channel_spell();
let prev = channel_cache.get(&entity).copied().unwrap_or(0);
if cur == prev {
continue;
}
channel_cache.insert(entity, cur);
if cur != 0 {
out.sounds.write(SpellKitSound::StopHold { entity });
if prev != 0 {
// A channel replacing a channel: the old spell's effects reap first.
out.fx.write(SpellKitFx::Reap {
entity,
spell_id: prev,
class: FxClass::Hold,
});
}
// No ranged fallback here: the channel poll is the descriptor-driven `0x612a30`
// path, not the SPELL_START/GO handlers' `0x60d450` resolve — and no basic shot
// channels anyway.
if let Some(kit) = resolve_kit(spells, &visuals.0, cur, |s| s.channel, || None) {
if let Some(anim_id) = kit.anim_id {
// Fallible for the same reason as the wire arms above — and this loop's
// subjects need it *more*: a unit mid-stream-out is un-indexed but still
// carries its `ObjectStore`, so it is in this very query while the fade lane
// is free to despawn it out from under us this frame.
commands.entity(entity).try_insert(CastHold {
anim_id,
spell_id: cur,
ranged: false, // no basic shot channels (the comment above)
});
pending.insert(entity, Some(cur));
}
if let Some(kit_sound) = kit.sound {
out.sounds.write(SpellKitSound::Play { entity, kit_sound });
}
// The CharProc dispatcher's SECOND caller (`0x612b18`, inside this very poll
// `0x612a30`) — which is how a channelled beam exists at all: Drain Life's kit
// is never reached by `PlaySpellVisualKit`, only from here. Emitted on the
// channel's rising edge; the beam then lives until the field clears (0955).
if let Some(proc) = kit.chain_proc() {
out.chain.write(ChainProcPlay {
entity,
spell_id: cur,
proc,
});
}
// The channel kit's effect models — persistent while the field holds (the
// client's stage-2 lifetime, and so the stage-2 Birth → Hold → Decay lifecycle:
// the channel poll `0x612a30` is one of the caller table's stage-2 sites).
let effects = resolve_kit_effects(&visuals.0, &kit);
if !effects.is_empty() {
out.fx.write(SpellKitFx::Begin {
entity,
spell_id: cur,
persistent: true,
class: FxClass::Hold,
stage: FxStage::State,
effects,
});
}
}
} else {
if held_spell(&pending, entity) == Some(prev) {
// Only the ending channel's own hold is reaped — a precast for the unit's next
// spell (already in flight when the field clears) survives.
commands.entity(entity).try_remove::<CastHold>();
pending.insert(entity, None);
}
out.sounds.write(SpellKitSound::StopHold { entity });
out.fx.write(SpellKitFx::Reap {
entity,
spell_id: prev,
class: FxClass::Hold,
});
}
}
// Streamed units despawn on range-out; drop their stale edge-cache rows with them.
channel_cache.retain(|e, _| units.contains(*e));
}
/// Arm/reap the **aura state kit** (stage 2's real lifetime — the bread in the eater's hand):
/// a spell id appearing in a unit's `UNIT_AURA` slots arms its state kit's effect models
/// **persistent**, and the id leaving the slots reaps them. This is what makes the food bread
/// (spell 433 → visual 51 → state kit 409 → `Spells\Item_Bread.mdx` at the spell hand) sit in
/// the hand for the aura's whole life — and puts it in the hand of a unit that streamed in
/// already eating (the slots are public), which no impact-time play can do. Closes the 0107
/// approximation "the state kit plays self-terminating (no aura tracking yet)": [`play_impact`]
/// still plays the state kit as its short impact-time flash (the client's `0x61dc50` order —
/// impact, then state), which simply overlaps the persistent instance this watcher owns.
///
/// The trigger and reap are byte-verified (wow-re `state-kit-aura-lifecycle.md`, §5 2026-07-14):
/// the aura watcher `0x604d00 → 0x6123f0 → 0x5ff350` reads `SpellVisual` field 4 and plays the
/// kit at stage 2 (`0x5ff4c2: push 2`); the remove path `0x612320 → 0x5ff290` reaps with
/// `0x614150(spellId, force=1)`. The ADD edge's kit sound rings **ungated and positional** (wow-re
/// `kit-sound-leg.md`, decision 0852 — its §5 corrected the earlier "SELF-gated" reading: the
/// `0x5fa6d0` gate at `0x5ff43e` covers only a separate spellRec-driven aura-apply cue, and both
/// branches fall through to the ungated kit play at `0x5ff4c6`); a looping kit sound is tracked
/// and stops with the aura ([`SpellKitSound::StopKit`]). The body anim is NOT code-suppressed
/// (`0x60edf0`'s tail plays `kit+0x08` unconditionally) — this watcher plays effects + sound,
/// with a **named residual** for a state kit carrying a real anim: that ADD-edge replay isn't
/// built (no live kit demonstrates it; build it from the verdict when one shows).
///
/// `armed` tracks exactly the (unit, spell) pairs this watcher began, so the REMOVE edge never
/// reaps another owner's persistent instances (a channel hold whose spell also rides an aura).
/// An aura refresh keeps its slot id present (no edge); a re-apply that flickers remove→add
/// across frames reaps then re-begins — the drain's replace-on-persistent-begin keeps even the
/// same-frame corner single-instanced.
/// A state kit has **two halves** and this watcher owns the slot diff for both: the attach-point
/// effect *models* above, and the kit's `CharProc` columns — what the aura does to the body itself
/// (its translucency, its tint), emitted as [`AuraProc`] edges for [`crate::aura_visual`]. One diff
/// and one kit resolve feed both fan-outs, the same way [`KitOut`] bundles a discrete play's writers,
/// so the two halves of a kit can never disagree about when an aura came or went.
///
/// **The arm predicate is "this kit does *anything*", not "this kit has effect models"** — B114 was
/// exactly that bug: Stealth's kit 312 carries no models, no anim and no attach at all (its whole
/// visual is one proc-14 CharProc), so an effects-only test dropped it and the character showed
/// nothing.
pub(crate) fn arm_aura_state_fx(
units: Query<(Entity, &ObjectStore)>,
visuals: Option<Res<SpellVisuals>>,
spells: Option<Res<crate::ui_action::Spells>>,
mut fx: MessageWriter<SpellKitFx>,
mut procs: MessageWriter<AuraProc>,
mut sounds: MessageWriter<SpellKitSound>,
mut armed: Local<EntityHashMap<Vec<u32>>>,
) {
let (Some(visuals), Some(spells)) = (visuals.as_deref(), spells.as_deref()) else {
return; // no client data — no spell visuals (the DBC-resource degrade shape)
};
for (entity, store) in &units {
let prev = armed.entry(entity).or_default();
// Occupied slots, deduped (the same spell re-applied by two casters holds two slots —
// one state instance either way).
let mut cur: Vec<u32> = store.0.unit_auras().map(|a| a.spell_id).collect();
cur.sort_unstable();
cur.dedup();
for &spell_id in prev.iter() {
if cur.binary_search(&spell_id).is_err() {
fx.write(SpellKitFx::Reap {
entity,
spell_id,
class: FxClass::AuraState,
});
procs.write(AuraProc::Reap { entity, spell_id });
// A LOOPING state-kit sound is a tracked hold that dies with the aura
// (`0x614150`'s 0.15 s fade — 0852); kit-scoped, a no-op for one-shots.
if let Some(kit_sound) =
resolve_kit(spells, &visuals.0, spell_id, |s| s.state, || None)
.and_then(|k| k.sound)
{
sounds.write(SpellKitSound::StopKit { entity, kit_sound });
}
}
}
let mut next = Vec::with_capacity(prev.len());
for &spell_id in &cur {
if prev.contains(&spell_id) {
next.push(spell_id); // already armed, aura still live
continue;
}
let Some(kit) = resolve_kit(spells, &visuals.0, spell_id, |s| s.state, || None) else {
continue; // no state kit — not this watcher's aura
};
let effects = resolve_kit_effects(&visuals.0, &kit);
let nodes: Vec<crate::aura_visual::AuraNode> = kit
.char_procs()
.filter_map(crate::aura_visual::node_for)
.collect();
if effects.is_empty() && nodes.is_empty() && kit.sound.is_none() {
continue; // a state kit that does nothing we model — nothing to arm or reap
}
if !effects.is_empty() {
fx.write(SpellKitFx::Begin {
entity,
spell_id,
persistent: true,
class: FxClass::AuraState,
stage: FxStage::State,
effects,
});
}
if !nodes.is_empty() {
procs.write(AuraProc::Begin {
entity,
spell_id,
nodes,
});
}
// The ADD edge rings the kit sound — ungated and positional (0852: the kit play's
// sound leg has no self test; the old self-only reading belonged to a different
// aura-apply cue). Covers the streamed-in-mid-aura unit no impact play can; the
// same-frame duplicate against the impact hand-off's state flash collapses in the
// sound router.
if let Some(kit_sound) = kit.sound {
sounds.write(SpellKitSound::Play { entity, kit_sound });
}
next.push(spell_id);
}
*prev = next;
}
// Streamed units despawn on range-out; their instances die with the entity — drop the rows.
armed.retain(|e, _| units.contains(*e));
}
/// Arm/reap the **lootable-corpse sparkle** (wow-re `loot-corpse-effect.md`, §5 cross-checked):
/// a DEAD unit carrying `UNIT_DYNFLAG_LOOTABLE` wears the `SpellVisualEffectName` row named
/// `"HARDCODED Loot Art"` (5875: `Particles\LootFX.mdl` — a golden flare + three star-twinkle
/// emitters; cadence/size/color/blend all authored in the asset, the client sets none of them)
/// attached at `0x13`, its own first sequence looping. The real client is edge-driven off the
/// descriptor apply (watcher `0x600440`) with **no viewer/tap/distance/loot-window logic** — the
/// server already strips the flag per viewer (vmangos's "hide lootable animation for unallowed
/// players"). The falling edge (looted empty, rights lost) reaps with no fade (`0x600680`);
/// a despawn tears the instance down with the unit. Rides the spell-kit fx plumbing under
/// [`LOOT_FX_KEY`] — the client hangs loot art on the same `Effect_C` node type its spell
/// visuals use, so sharing the one attach body is the faithful shape.
pub(super) fn arm_loot_fx(
units: Query<(Entity, &ObjectStore)>,
visuals: Option<Res<SpellVisuals>>,
mut fx: MessageWriter<SpellKitFx>,
mut armed: Local<EntityHashSet>,
) {
let Some(path) = visuals.as_ref().and_then(|v| v.0.loot_art_path()) else {
return; // no client data / no such row — no loot art (the DBC-resource degrade shape)
};
for (entity, store) in &units {
let lootable = store.0.unit_is_dead() && store.0.unit_lootable();
if lootable && armed.insert(entity) {
fx.write(SpellKitFx::Begin {
entity,
spell_id: LOOT_FX_KEY,
persistent: true,
class: FxClass::Hold,
// Not a kit stage at all (this is `SpawnHardcodedEffect`, not
// `PlaySpellVisualKit`), but wow-re `loot-corpse-effect.md`'s recorded law for the
// loot art is "its own first sequence, LOOPING" — which is precisely what
// [`FxStage::Relive`] renders, and unlike a bare loop-flag arm it holds even if the
// art model's sequence were clamp-flagged.
stage: FxStage::Relive,
effects: vec![(HARDCODED_FX_ATTACH, path.to_string())],
});
} else if !lootable && armed.remove(&entity) {
fx.write(SpellKitFx::Reap {
entity,
spell_id: LOOT_FX_KEY,
class: FxClass::Hold,
});
}
}
// Streamed units despawn on range-out; their instances die with the entity — drop the rows.
armed.retain(|e| units.contains(*e));
}
/// The engine-spawned **ding** (decision 0305, byte-verified — wow-re `levelup-ding.md`): a
/// `UNIT_FIELD_LEVEL` **change** on any streamed unit (the client's descriptor change-watcher
/// `CMirrorHandler 0x6045b0` → `SpawnHardcodedEffect(5)`) spawns [`LEVEL_UP_EFFECT`]
/// (`Spells\LevelUp\LevelUp.mdl`) at the base attach — the ding visual is DECOUPLED from
/// `SMSG_LEVELUP_INFO`, so anyone leveling nearby flashes too. First sight of a unit arms
/// silently (it streamed in *carrying* a level, it didn't just gain one). The instance
/// self-terminates on its own 1.867 s clip (spell id 0, the kit-push stage-0 shape — no reap
/// key needed, unlike the persistent loot sparkle above), and its **sound is the model's own**
/// `$SND(888)` event → `Sound\Spells\LevelUp.wav`, fired by
/// `crate::entities::spell_fx`'s event-track scanner.
pub(super) fn arm_level_up_fx(
changed: Query<(Entity, &ObjectStore), Changed<ObjectStore>>,
units: Query<(), With<ObjectStore>>,
visuals: Option<Res<SpellVisuals>>,
mut levels: Local<EntityHashMap<u32>>,
mut fx: MessageWriter<SpellKitFx>,
) {
for (entity, store) in &changed {
let Some(level) = store.0.unit_level() else {
continue;
};
match levels.insert(entity, level) {
Some(prev) if prev != level => {
let Some(path) = visuals
.as_ref()
.and_then(|v| v.0.hardcoded_effect(LEVEL_UP_EFFECT))
else {
continue; // no client data / no such row (the DBC-resource degrade shape)
};
debug!("anim: level {prev} → {level}, the ding flashes ({entity})");
fx.write(SpellKitFx::Begin {
entity,
spell_id: 0,
persistent: false,
class: FxClass::Hold,
// The ding self-terminates on its own 1.867 s clip — the stage-0 shape.
stage: FxStage::OneShot,
effects: vec![(HARDCODED_FX_ATTACH, path.to_string())],
});
}
_ => {} // first sight arms; an unchanged write is a no-op
}
}
// Streamed units despawn on range-out — drop their level memory with them.
levels.retain(|e, _| units.contains(*e));
}
/// The **mount poof** — the cloud the director reports seeing at mount-up and we never drew.
///
/// The reference's `UNIT_FIELD_MOUNTDISPLAYID` change-watcher (`0x604329` → `0x604570` →
/// `0x5ffa50`) does three things on its **build** leg, and only there: tear down any old mount
/// (`0x607ce0`, gated on the OLD value), build the new one (`0x607a00`), and then spawn a
/// `HARDCODED` effect — `SMemAlloc(0x90)` → `Effect_C` ctor `0x61f490` → `0x61fae0(index=6,
/// &ownerGUID, cb=0x5fbf50)` → `0x6210e0` (`5ffa90`–`5ffad9`). Index **6** resolves through the
/// boot name-match table to `"HARDCODED Mount Poof"` → row 1185 → `Spells\DruidMorph_Impact_Base`
/// — the druid-morph cloud, which is exactly the "cloud shapeshift animation" of the report.
/// Byte-for-byte the same spawn shape as [`arm_level_up_fx`]'s index 5, so it is the same edge
/// here (wow-re `mount-composition.md` Q4b, §5 2026-08-03; decision 0927).
///
/// The three properties that decide the shape of this arm, all VERIFIED there:
/// - **Mount side only.** The build *and the entire allocation* sit behind `5ffa87 je 0x5ffade`
/// on the **NEW** field value. `N→0` jumps clean past it: **no poof on dismount.** `0→N` and
/// `N→N′` both spawn one.
/// - **Any unit.** No active-player, local-player or distance gate exists anywhere in `0x5ffa50`
/// — the same shape as the level-up ding. Range is the server's replication, not a filter.
/// - **One-shot, on the rider's own body.** `[node+0x2c] = 0` (no world-plant, no dedup marker,
/// not cancel-immune), tag `0x13` re-resolved against `[owner+0xd8]` every tick, and the
/// registered callback is `0x5fbf50` — the CEffect *end-of-clip terminator* (its looping twin
/// `0x600640` is what makes the loot sparkle repeat). So: `FxStage::OneShot`, non-persistent,
/// 2.8 s of its own clock. Because the rider's body is the mount's child at slot 0 (0917), the
/// cloud rides the saddle for its whole span with no extra plumbing.
///
/// **Stated divergence.** The reference's tail `0x6208e0` is a same-record+same-tag dedup that
/// destroys a still-running poof when a new one spawns on that unit; our one-shot instances carry
/// no reap key, so a mount *swap* inside 2.8 s would show two overlapping clouds. Unreachable
/// today (the mounted gate refuses a second mount spell) and bounded at "one extra puff", so it
/// is named rather than modelled.
pub(super) fn arm_mount_poof_fx(
changed: Query<(Entity, &ObjectStore), Changed<ObjectStore>>,
units: Query<(), With<ObjectStore>>,
visuals: Option<Res<SpellVisuals>>,
mut displays: Local<EntityHashMap<u32>>,
mut fx: MessageWriter<SpellKitFx>,
) {
for (entity, store) in &changed {
let mount_display = store.0.unit_mount_display_id();
match displays.insert(entity, mount_display) {
// The build leg's gate is on the NEW value, so a dismount (`mount_display == 0`)
// spawns nothing — and first sight arms silently, exactly like the ding: a unit that
// streams in already mounted did not just mount.
Some(prev) if prev != mount_display && mount_display != 0 => {
let Some(path) = visuals
.as_ref()
.and_then(|v| v.0.hardcoded_effect(MOUNT_POOF_EFFECT))
else {
continue; // no client data / no such row (the DBC-resource degrade shape)
};
debug!("anim: mount display {prev} → {mount_display}, the poof puffs ({entity})");
fx.write(SpellKitFx::Begin {
entity,
spell_id: 0,
persistent: false,
class: FxClass::Hold,
// `0x5fbf50` — destroy at the first completion; the shipped model runs 2.8 s.
stage: FxStage::OneShot,
effects: vec![(HARDCODED_FX_ATTACH, path.to_string())],
});
}
_ => {}
}
}
// Streamed units despawn on range-out — drop their mount memory with them.
displays.retain(|e, _| units.contains(*e));
}