diff --git a/crates/benilla-assets/src/model/anims.rs b/crates/benilla-assets/src/model/anims.rs index 0f6498f6..3d275e58 100644 --- a/crates/benilla-assets/src/model/anims.rs +++ b/crates/benilla-assets/src/model/anims.rs @@ -168,12 +168,25 @@ impl ModelAnimations { } /// The clip a free/effect model instance runs: the caller's `preferred` id when the model has - /// it (the thrown-weapon missile asks for InFlight), else the file-order-first clip — the - /// existing default. One selector so the rig ([`crate::…`]'s `arm_effect_rig`) and its ribbon - /// gate agree on which sequence is playing. + /// it (the thrown-weapon missile asks for InFlight), else the reference's **model-load + /// bootstrap** — animation id **0 (`Stand`)**, variation 0 (wow-re `ceffect-anim-lifecycle.md` + /// §D1a, byte-verified `0x710153`–`0x71019b`: `0x711bf0(reqId = 0)` → `0x711960` → op4 with an + /// explicit `push 0x0` variation), falling back to the first sequence record's own id only when + /// the model authors no `Stand` (the guarded `*(dword*)&sequences[0]` at `0x710181`). + /// + /// **Not the file-order-first slot**, which is what this used to be: the two differ on 583 + /// models corpus-wide (`benilla-extract fxlifescan`), and arming slot 0 hands + /// `Spells\LightningShield_State_Base` its `Decay` sequence from frame one. It is the same + /// correction `first_seq` already carries for the placed-doodad lane (0637, the duel flag) — + /// but taken *without* that field's bind-pose content gate, because an effect's emitters read + /// the posed joints even when the mesh would render identically. + /// + /// One selector so the rig (`entities::spell_fx::arm_effect_rig`) and its per-sequence riders + /// agree on which sequence is playing. pub fn preferred_clip(&self, preferred: Option) -> Option<&AnimClip> { preferred .and_then(|id| self.find(id)) + .or_else(|| self.find(0)) .or_else(|| self.clips.first()) } @@ -454,6 +467,37 @@ mod tests { ); } + // ── preferred_clip: the model-load bootstrap (wow-re `ceffect-anim-lifecycle.md` §D1a) ────── + + /// The bootstrap arms **id 0 `Stand`**, not the file's first slot. `LightningShield_State`'s + /// real shape: sequence 0 is its `Decay`(159), and arming file-order-first handed a freshly + /// cast shield its fade-out from frame one. + #[test] + fn preferred_clip_arms_stand_over_the_file_order_first_slot() { + let anims = test_anims(&[159, 0, 158], Vec::new()); + assert_eq!(anims.preferred_clip(None).map(|c| c.anim_id), Some(0)); + } + + /// The guarded fallback (`0x710181`): a model authoring no `Stand` at all — Cripple, Curse of + /// Tongues, both Holy Word: Shields, whose slot 0 is `Hold` — plays that first record's own id. + #[test] + fn preferred_clip_falls_back_to_the_first_record_without_a_stand() { + let anims = test_anims(&[158, 159], Vec::new()); + assert_eq!(anims.preferred_clip(None).map(|c| c.anim_id), Some(158)); + } + + /// A caller-named id still wins (the thrown-weapon missile's InFlight) — and when the model + /// lacks it, the resolve falls through to the bootstrap rather than to slot 0. + #[test] + fn preferred_clip_honours_a_named_id_then_falls_to_stand() { + let anims = test_anims(&[159, 0, 158], Vec::new()); + assert_eq!( + anims.preferred_clip(Some(158)).map(|c| c.anim_id), + Some(158) + ); + assert_eq!(anims.preferred_clip(Some(42)).map(|c| c.anim_id), Some(0)); + } + /// The variation pick is the client's weighted walk (wow-re `anim-id-resolution.md`, op4 with /// variationIdx −1): `roll < frequency` picks, else `roll -= frequency` and advance; chain /// exhaustion clamps to the last variation; a lone variation always wins. diff --git a/crates/benilla-formats/src/bin/benilla-extract/charprocs.rs b/crates/benilla-formats/src/bin/benilla-extract/charprocs.rs index e5436095..d5c7e872 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/charprocs.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/charprocs.rs @@ -30,6 +30,7 @@ fn type_name(ty: i32) -> &'static str { match ty { char_proc_type::TINT => "TINT (body RGB)", char_proc_type::ALPHA => "ALPHA (body translucency)", + char_proc_type::ANIM_RATE => "ANIM_RATE (body anim playback rate — 0 freezes the pose)", _ => "(unmodelled)", } } diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index 575fb32d..ef8a7110 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -218,6 +218,17 @@ enum Command { /// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted. prefix: Option, }, + /// Sweep every `.m2` (optionally under a path prefix) and census the **effect-model animation + /// lifecycle**: which models author the `Stand`(0) -> `Hold`(158) -> `Decay`(159) triple, and + /// what a consumer that arms ONE sequence and never advances renders for each. A spell-effect + /// model puts its grow-in in the clamping `Stand`, its sustained pulse in the looping `Hold` + /// and its fade-out in `Decay` — so arming only the first freezes on the birth's last frame for + /// the effect's whole life (the frozen Ice Barrier shield). The population instrument for that + /// class: FREEZE / hold-loops / decay-only counts, corpus-wide; `m2seq` explains one model. + Fxlifescan { + /// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted. + prefix: Option, + }, /// Sweep every `.m2` (optionally under a path prefix) and measure how far our SKELETAL parse /// sits from the reference's sampler, per bone track per sequence band: empty bands (our /// nearest-key clamp vs the file's own `interpolation_ranges` window), held band edges (our @@ -565,6 +576,7 @@ fn main() -> Result<()> { Command::Groundscan { prefix } => scan::groundscan(&mut chain, prefix.as_deref())?, Command::Geosetscan { prefix } => scan::geosetscan(&mut chain, prefix.as_deref())?, Command::Alphascan { prefix } => scan::alphascan(&mut chain, prefix.as_deref())?, + Command::Fxlifescan { prefix } => scan::fxlifescan(&mut chain, prefix.as_deref())?, Command::Goanimscan => scan::goanimscan(&mut chain)?, Command::Bonescan { prefix } => scan::bonescan(&mut chain, prefix.as_deref())?, Command::Partcensus { prefix } => scan::partcensus(&mut chain, prefix.as_deref())?, diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan.rs b/crates/benilla-formats/src/bin/benilla-extract/scan.rs index f612801d..4163d04b 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan.rs @@ -3121,3 +3121,134 @@ pub fn skyboxscan(chain: &mut Chain) -> Result<()> { } Ok(()) } + +/// `AnimationData.dbc` ids of the effect-model lifecycle triple (names read from the real DBC). +const ANIM_STAND: u16 = 0; +const ANIM_HOLD: u16 = 158; +const ANIM_DECAY: u16 = 159; + +/// Sweep every `.m2` (optionally under a path prefix) and census the **effect-model animation +/// lifecycle**: which models author the `Stand`(0) → `Hold`(158) → `Decay`(159) triple, and what a +/// consumer that arms ONE sequence and never advances would render for each. +/// +/// The reference arms an effect instance's default track — `animationLookup[0]`, i.e. `Stand` — and +/// a model that also owns `Hold` authors `Stand` as a **birth** (grow-in, clamp flag set) with the +/// sustained pulse living in the separate looping `Hold` sequence, and the fade-out in `Decay`. +/// A one-sequence consumer therefore FREEZES on the last frame of the birth for the effect's whole +/// life — no pulse, no fade — which is exactly what "the Ice Barrier shield is frozen" looks like. +/// +/// The classification is that failure, made countable: +/// - **FREEZE** — owns `Hold`, and the armed `Stand` clamps: the reference pulses, we hold a pose. +/// - **hold-loops** — owns `Hold` but `Stand` itself loops: still wrong (the wrong clip), but moving. +/// - **decay-only** — owns `Decay` and no `Hold`: only the reap leg is unrendered. +/// +/// It also counts the **file-order divergence**: the reference arms `animationLookup[0]`, so a +/// consumer that arms the file's *first slot* instead is additionally wrong on any model whose slot +/// 0 is not its `Stand` (the `DuelingFlag.m2` Spawn/Stand/Despawn shape, decision 0637). The two +/// bugs are independent and the closing line separates them. +/// +/// The population instrument for the mechanism, so it is closed corpus-wide rather than spell by +/// spell; `m2seq` then explains one model in full. +pub fn fxlifescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); + let names: Vec = chain + .list() + .context("listing chain contents")? + .into_iter() + .map(|e| e.name) + .filter(|n| { + let l = n.to_ascii_lowercase(); + l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) + }) + .collect(); + let (mut scanned, mut freeze, mut hold_loops, mut decay_only) = (0u32, 0u32, 0u32, 0u32); + let (mut no_stand, mut slot0_not_stand) = (0u32, 0u32); + let mut by_dir: BTreeMap = BTreeMap::new(); + let mut rows: Vec<(String, String, String, f32, f32, f32)> = Vec::new(); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let seqs = benilla_formats::parse_m2_animations(&bytes); + if seqs.is_empty() { + continue; + } + // What the reference arms: `animationLookup[0]` — Stand — never the file-order-first slot + // (they diverge on any model whose sequence 0 is not its Stand). + let armed = seqs.iter().find(|s| s.anim_id == ANIM_STAND); + let hold = seqs.iter().find(|s| s.anim_id == ANIM_HOLD); + let decay = seqs.iter().find(|s| s.anim_id == ANIM_DECAY); + if hold.is_none() && decay.is_none() { + continue; // neither leg authored — no lifecycle to miss + } + let class = match (hold.is_some(), armed) { + (true, Some(a)) if !a.looping => { + freeze += 1; + "FREEZE" + } + (true, _) => { + hold_loops += 1; + "hold-loops" + } + (false, _) => { + decay_only += 1; + "decay-only" + } + }; + // The file-order divergence, independent of the lifecycle bug: what a slot-0 consumer arms + // versus the reference's `animationLookup[0]`. + let arm = match (armed, seqs[0].anim_id) { + (None, first) => { + no_stand += 1; + format!("no-Stand(slot0={first})") + } + (Some(_), ANIM_STAND) => "slot0".to_string(), + (Some(_), first) => { + slot0_not_stand += 1; + format!("slot0={first}!") + } + }; + let top = name.split_once('\\').map(|(d, _)| d).unwrap_or(""); + *by_dir.entry(top.to_ascii_lowercase()).or_default() += 1; + rows.push(( + name, + class.to_string(), + arm, + armed.map_or(0.0, |a| a.duration), + hold.map_or(0.0, |s| s.duration), + decay.map_or(0.0, |s| s.duration), + )); + } + rows.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0))); + println!( + "model class arm stand hold decay" + ); + for (name, class, arm, stand, hold, decay) in rows.iter().take(80) { + println!("{name:<60} {class:<10} {arm:<10} {stand:>5.2} {hold:>5.2} {decay:>5.2}"); + } + if rows.len() > 80 { + println!("… and {} more", rows.len() - 80); + } + println!( + "\n{} of {scanned} models author a Hold(158)/Decay(159) lifecycle leg", + rows.len() + ); + println!(" FREEZE {freeze:>5} (owns Hold; the armed Stand clamps — a pose, where the reference pulses)"); + println!(" hold-loops {hold_loops:>5} (owns Hold; the armed Stand loops — moving, but the wrong clip)"); + println!(" decay-only {decay_only:>5} (owns Decay only — just the reap leg unrendered)"); + println!( + "of those, the file-order divergence (a slot-0 consumer arms the wrong sequence outright): \ + {slot0_not_stand} with slot 0 != Stand, {no_stand} with no Stand at all" + ); + // Named in full, never left to the row cap: this set is small and each member is a distinct + // "arms the wrong sequence from frame one" case. + for (name, _, arm, ..) in rows.iter().filter(|r| r.2 != "slot0") { + println!(" {name:<58} {arm}"); + } + println!("by top-level directory:"); + for (dir, n) in &by_dir { + println!(" {dir:<16} {n:>5}"); + } + Ok(()) +} diff --git a/crates/benilla-formats/src/spell_visual.rs b/crates/benilla-formats/src/spell_visual.rs index 3dfc0edf..92cbd6c2 100644 --- a/crates/benilla-formats/src/spell_visual.rs +++ b/crates/benilla-formats/src/spell_visual.rs @@ -151,6 +151,25 @@ pub mod char_proc_type { /// is `baseAlpha × Π(node alphas)` (`0x60d180`), ramped to over 1000 ms by /// `StartAlphaFade 0x614f80`. Stealth's 0.3 and the ghost aura's 0.5 are both this proc. pub const ALPHA: i32 = 14; + /// **Body ANIMATION RATE** (`0x60db7e`) — the freeze. `params[0]` is a playback *rate*, written + /// straight onto the unit's model clocks by `SetBoneAnimSpeed 0x712910`: the mount's bone 0 + /// (`[unit+0xdc]`, `-1`), the body's **key-bone 4** (SpineLow — the upper-body split track that + /// carries cast/emote one-shots) when the model has one (`0x711d20(4)` gates it), and the body's + /// bone 0 (`-1`). The previous rates are saved in the node (`+0x60`/`+0x64`/`+0x68`) and written + /// back when it expires with the aura (`0x6203e0`, gated on the node's own `+0x2c & 0x4`). + /// + /// The rate is the per-bone `+0xb0` the animation clock multiplies its window by + /// (`timebase.md` §2: `t = trunc(f32(window) × rate) + bias`), and **arming an animation never + /// touches it** — op4 `0x7121a0`'s success leg writes `+0xa4`/cursors and leaves `+0xb0` alone + /// (only its disarm leg and `0x712910` write it). So the value persists across every re-arm for + /// the aura's whole life, and `0x712910` re-bases `bias` so the *current frame* is preserved + /// across the rate change: **rate 0 holds the pose exactly where it was.** + /// + /// 15 kits carry it; 14 ship `params[0] = 0` — Ice Block (kit 3709), Freeze, Freeze Solid, + /// Flash Freeze, Petrify, Encage, Stilled. Kit 1744 (Stoned / Petrification / Thadius Spawn) + /// ships `8947848.0` = `0x888888`, which reads like a packed grey that landed in the rate + /// column; it is passed through, not special-cased. + pub const ANIM_RATE: i32 = 11; } /// One `SpellVisual.dbc` row: the five lifecycle-stage `SpellVisualKit` ids a cast may fire diff --git a/crates/benilla-ui/src/order.rs b/crates/benilla-ui/src/order.rs index a056c311..0c93786f 100644 --- a/crates/benilla-ui/src/order.rs +++ b/crates/benilla-ui/src/order.rs @@ -5,30 +5,51 @@ //! ## Ground truth (wow-5875-re, binary-verified) //! //! The 1.12.1 client draws in a **flat painter's order**, *not* a hierarchical walk: every visible -//! frame — children included — is an independent entry in a per-`(strata, level)` bucket, and -//! `render_traverse_order 0x765650` walks **strata 0→8, level 0→N, then the level's insertion-ordered -//! frame list**, calling each frame's Draw (`propagation.md`, `propagation-anchors.md`). Within a -//! frame, its regions draw by **draw layer** (BACKGROUND→HIGHLIGHT), then the `textureSubLevel`, then -//! textures-before-fontstrings, then declaration order. A child frame with a *lower* strata/level than -//! its parent therefore draws *before* the parent — the order is global, keyed on the tuple below, not +//! frame — children included — is an independent entry in a per-`(strata, level)` bucket +//! (`propagation.md`, `propagation-anchors.md`). A child frame with a *lower* strata/level than its +//! parent therefore draws *before* the parent — the order is global, keyed on the tuple below, not //! on the tree shape. //! +//! **Inside one `(strata, level)` bucket, the DRAW LAYER outranks the frame.** The five render +//! batches belong to the level node (`levelNode+0x1c`), not to any frame, and the emitter +//! `0x765920` loops `layer = 0..4` outside and frames inside. So a bucket emits every frame's +//! BACKGROUND, then every frame's BORDER, and so on — regions are *not* grouped behind their owning +//! frame. Within one layer the same is true of kind: the batch holds a quad sub-array and a text +//! sub-array, and `0x76fb00` drains all quads before any text, so **all** textures of a +//! `(strata, level, layer)` precede **all** its font strings. (Decision 0884, wow-re +//! `ui/scratch/draw-order-law.md`. This supersedes the earlier recorded key, which had no layer +//! term at all and read the last term backwards.) +//! //! ## The total order (most- to least-significant) //! -//! `(stratum, frame level, frame insertion order, is-region, draw layer, sub-level, texture` takes the default +// **ARTWORK** layer and whose only OnLoad is `SetFrameLevel(GetFrameLevel() - 1)`. That line does +// **not** put the child below the parent: a child is born at `parent.level + 1` (`SetParent +// 0x76ab10` @`0x76ab65`), so the `-1` lands it at *exactly* the parent's level. Its whole job is to +// **create the tie**, so the layer key can decide — ARTWORK(2) before OVERLAY(3), fill under +// chrome. Ranked below the frame, there was no tie to resolve and the child always trailed. +// +// **`texture < fontstring` is bucket-wide too, and outranks the frame.** A layer's batch holds two +// independent sub-arrays — the quad array `[batch+0x10]` (every `CSimpleTexture` appends via +// `0x7706e0` → `0x772fd0`) and the text sub-batch `[batch+0x18]` (`0x772e50` → `0x773080`) — and +// `0x76fb00` drains the WHOLE quad array before it touches the text one. So for two frames A, B at +// one `(strata, level, layer)`, each with a texture and a font string, the client emits +// `A.tex, B.tex, A.text, B.text` — never `A.tex, A.text, B.tex, B.text`. +// +// **The frame link-stamp below that is exact for font strings and a deterministic stand-in for +// textures.** The client re-sorts a layer's quad array by *texture handle* (`0x7731a0`'s qsort, +// comparator `0x7731c0`) and never sorts the text sub-batch. The handle is an internal allocation +// our texture manager assigns differently, so reproducing that sort is neither achievable nor +// desirable; we reproduce the client's PRE-sort array, which agrees wherever a layer's quads share +// a texture or don't overlap (the common case). **No content may depend on the relative order of +// two overlapping textures within one layer** — that is not a fidelity invariant. +// +// wow-re's own previously-recorded key — `(strata, level, insertion order)` — is superseded by the +// same §5: it carried no layer term at all. +// +// A frame's own entry zeroes the layer and everything below `is-region`, so it precedes its own +// BACKGROUND regions. That slot is benilla's backdrop/scissor hook, not the client's — in the +// binary a frame has no drawable of its own — so it sits *below* the layer key, never above it. +// +// **`sub-level` is inert on 1.12**: §5 found no sub-level in 5875 at all (`0x76a860` takes only +// `(region, layer)`, head-inserts with no comparator, and the per-layer header is a plain 0xc-byte +// triple with nowhere to keep a sort key). The field stays as the modeled Era delta; no 1.12 +// content sets it, so it is always the `0` bias. const STRATUM_SHIFT: u32 = 60; const LEVEL_SHIFT: u32 = 44; -const INSERTION_SHIFT: u32 = 25; -const IS_REGION_SHIFT: u32 = 24; -const LAYER_SHIFT: u32 = 21; -const SUBLEVEL_SHIFT: u32 = 13; -const FONTSTRING_SHIFT: u32 = 12; +const LAYER_SHIFT: u32 = 41; +const FONTSTRING_SHIFT: u32 = 40; +const INSERTION_SHIFT: u32 = 21; +const IS_REGION_SHIFT: u32 = 20; +const SUBLEVEL_SHIFT: u32 = 12; const DECL_SHIFT: u32 = 0; pub(crate) const INSERTION_BITS: u32 = 19; @@ -268,9 +329,10 @@ pub enum ZTarget { /// (`propagation.md`) — a child of a hidden frame already carries `effective_visible == false` and is /// skipped here. Each visible frame emits its own [`ZTarget::Frame`] entry followed by one /// [`ZTarget::Region`] per owned region; the returned vec is sorted ascending by [`ZKey`], which *is* -/// the total draw order (strata → level → frame insertion → layer → sub-level → texture mk(DrawLayer::Background, 127, true, 4095) ); - // within a layer, sub-level dominates kind - assert!(mk(DrawLayer::Artwork, 1, false, 0) > mk(DrawLayer::Artwork, 0, true, 4095)); - // at equal layer+sub-level, textures precede fontstrings - assert!(mk(DrawLayer::Artwork, 0, true, 0) > mk(DrawLayer::Artwork, 0, false, 4095)); - // at equal layer+sub-level+kind, declaration order decides + // Within a layer, KIND is next: all textures precede all fontstrings — and it now outranks + // sub-level (0884: the layer batch's two sub-arrays, quads drained before text). + assert!( + mk(DrawLayer::Artwork, i8::MIN, true, 0) > mk(DrawLayer::Artwork, 127, false, 4095) + ); + // Below kind, declaration order decides within one frame's layer. assert!(mk(DrawLayer::Artwork, 0, false, 2) > mk(DrawLayer::Artwork, 0, false, 1)); - // negative sub-level draws before positive + // Sub-level still orders below kind — inert on 1.12 (nothing sets it), kept for Era. assert!(mk(DrawLayer::Artwork, -1, false, 0) < mk(DrawLayer::Artwork, 0, false, 0)); } + /// The law 0884 **replaced**, kept as a test so it cannot come back: a frame's regions are not + /// glued behind it. The draw layer is bucket-wide, so at one `(strata, level)` every frame's + /// BACKGROUND draws before any frame's OVERLAY — frame A's OVERLAY sorts *after* frame B's + /// BACKGROUND even though A linked first. That interleave is what puts the mirror timer's + /// ARTWORK fill under its own parent frame's OVERLAY border and caption. #[test] - fn regions_of_a_frame_stay_grouped_behind_it() { - // Two adjacent frames; frame B's frame-entry must sort after ALL of frame A's regions. + fn the_layer_interleaves_frames_it_does_not_group_them() { + let a_overlay = ZKey::region(Strata::Medium, 0, 10, DrawLayer::Overlay, 0, false, 0); + let b_background = ZKey::region(Strata::Medium, 0, 11, DrawLayer::Background, 0, false, 0); + assert!( + b_background < a_overlay, + "a later frame's BACKGROUND still draws before an earlier frame's OVERLAY" + ); + + // Same layer + same kind: the frame link-stamp decides, later on top. + let a_art = ZKey::region(Strata::Medium, 0, 10, DrawLayer::Artwork, 0, false, 0); + let b_art = ZKey::region(Strata::Medium, 0, 11, DrawLayer::Artwork, 0, false, 0); + assert!( + a_art < b_art, + "at equal layer+kind, later link draws on top" + ); + + // Kind spans frames: ALL textures of a (strata, level, layer) precede ALL its fontstrings. + let a_text = ZKey::region(Strata::Medium, 0, 10, DrawLayer::Artwork, 0, true, 0); + assert!( + b_art < a_text, + "a later frame's texture still precedes an earlier frame's fontstring" + ); + + // A frame's own slot is benilla's backdrop/scissor hook: it sits BELOW the layer key, so + // it precedes only its own regions, starting with its BACKGROUND. let a_frame = ZKey::frame(Strata::Medium, 0, 10); - let a_region_top = - ZKey::region(Strata::Medium, 0, 10, DrawLayer::Highlight, 127, true, 4095); - let b_frame = ZKey::frame(Strata::Medium, 0, 11); - assert!(a_frame < a_region_top); - assert!(a_region_top < b_frame); + let a_bg = ZKey::region(Strata::Medium, 0, 10, DrawLayer::Background, 0, false, 0); + assert!(a_frame < a_bg); } // ── Traversal over a synthetic tree ──────────────────────────────────────────────────────── diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index a77b3c22..79c209e9 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -287,13 +287,19 @@ pub struct Region { /// The **screen root** (`CSimpleTop` / `UIParent`) is *not* a node here — a frame with /// `parent == None` is a top-level frame anchored to the always-visible, unit-scale screen root. /// -/// **Insertion / draw order.** Each frame gets a monotonic `insertion_seq` at creation, used as the -/// draw-order tiebreak within a `(strata, level)` bucket — and, like the client, a frame is -/// re-sequenced to its bucket's **tail** whenever it *becomes visible* (`effective_visible_show -/// 0x76ae10` re-ADDS to the intrusive level list) or a visible frame changes strata/level (both -/// setters remove-then-add; `propagation.md`). Show order IS draw order within a bucket — how the -/// reference's late-shown `MiniMapTrackingFrame` draws over the earlier-declared `MinimapBackdrop` -/// ring despite the XML declaring it first (decision 0557; the mutators live in [`propagation`]). +/// **Link-stamp / draw order.** Each frame gets a monotonic `insertion_seq` at creation, used as the +/// draw-order tiebreak **below the draw layer** within a `(strata, level)` bucket (decision 0884 — +/// the layer outranks the frame; see [`crate::order`]). Like the client, a frame is re-stamped to +/// its bucket's **tail** whenever it *becomes visible* (`effective_visible_show 0x76ae10` re-ADDS to +/// the intrusive level list) or a visible frame changes strata/level (both setters remove-then-add; +/// `propagation.md`). Show order IS draw order within a bucket — how the reference's late-shown +/// `MiniMapTrackingFrame` draws over the earlier-declared `MinimapBackdrop` ring despite the XML +/// declaring it first (decision 0557; the mutators live in [`propagation`]). +/// +/// The client's own list is a *head* insert walked tail→head, i.e. FIFO by link time with the +/// newest-linked frame emitted last — order-equivalent to this ascending counter, which is why the +/// counter stays (0884's §5 refuted the intuition that a head insert draws *first*). A same-value +/// `SetFrameLevel` early-outs before re-stamping, matching `0x76a4f0`'s `je` at `0x76a509`. pub struct WidgetArena { frames: Arena, regions: Arena, diff --git a/crates/benilla/assets/ui/BagFrame.xml b/crates/benilla/assets/ui/BagFrame.xml index f1d57d69..0b70ecd7 100644 --- a/crates/benilla/assets/ui/BagFrame.xml +++ b/crates/benilla/assets/ui/BagFrame.xml @@ -850,6 +850,126 @@ end end + -- ── The item-push drop animation (decision 0887) ───────────────────────────────────────── + -- An item lands in a bag: its icon pops in above the bag bar, hangs there, then falls into + -- the button that took it and shrinks away. ref ItemAnim (MainMenuBarBagButtons.xml l.4-16 + + -- .lua l.68-80, .lua l.92-94): each bag button owns a hidden child that, on ITEM_PUSH + -- naming its own container, swaps in the pushed item's icon, rewinds to sequence time 0 and + -- shows itself; OnAnimFinished hides it again. A second push on the same button RESTARTS it + -- (SetSequenceTime(0,0)); pushes into different bags run independently. That is exactly the + -- machine below, with the reference's ITEM_PUSH `arg1` translated from its inventory-slot + -- vocabulary (20..23) into ours (bagId 1..4) app-side — ui_loot.rs `push_container` carries + -- the byte-verified selector. + -- + -- The reference's is a playing Interface\ItemAnimations\ForcedBackpackItem.mdx — a + -- single 4-vertex quad on one animated bone, i.e. a 2D icon card with keyframed scale, alpha + -- and offset. benilla has no Model widget (decision 0137's Cooldown took the same posture: + -- model the mechanism, let our own renderer draw it), so it is a Texture driven by the + -- model's OWN tracks, read out of the shipped .m2 with `benilla-extract m2anim`/`m2bones`: + -- + -- sequence 0, CLAMP (plays once), duration 1.000 s + -- texture-weight (alpha) 0.000→0.0 0.133→1.0 0.267→1.0 1.000→0.0 linear + -- bone 0 scale 0.000→1.0 0.133→1.2 0.267→1.0 1.000→0.014 linear + -- bone 0 translation 0.000→(0,0) 0.500→(0,0) 1.000→(+0.010,-0.040) + -- the quad itself: 0.03 x 0.03 model units, blend Alpha, unlit, no depth write + -- + -- So: fade in over the first 133 ms while swelling to 1.2x; settle back to full size by + -- 267 ms; from there dwindle and fade continuously for the remaining 733 ms — while the + -- POSITION holds until the half-second and only then falls. The three tracks deliberately + -- do not share a schedule: the card is already down to ~0.69 size and ~0.68 alpha when it + -- starts moving, so the drop reads as the icon being drawn into the bag rather than as a + -- solid tile sliding. Those numbers are the file's, not ours. + -- + -- THE ONE AUTHORED NUMBER is the model-unit → pixel scale, because a frame's 3D + -- projection is not RE-recorded (the ref's ItemAnim frame has no at all, so the rect + -- cannot be it) — see decision 0887's named gap. 1200 px/unit is the sibling calibration: + -- CooldownFrameTemplate is the same kind of degenerate UI model, its four quads span 0.04 + -- units, and at its scale="0.75" that sweep covers a 36px action button ⇒ 0.04·K·0.75 = 36. + -- At K = 1200 this model's own numbers land on 36 px of icon (exactly a bag-bar button), + -- a 48 px fall and a 12 px sideways drift — which is why the sizes below are stated as the + -- model's ratios times the icon, not as free pixels: correct the scale in one place and the + -- whole animation stays in proportion. + BENILLA_ITEM_PUSH_PX_PER_UNIT = 1200 + BENILLA_ITEM_PUSH_DURATION = 1.0 + BENILLA_ITEM_PUSH_SIZE = 0.03 * BENILLA_ITEM_PUSH_PX_PER_UNIT -- 36 + BENILLA_ITEM_PUSH_FALL = 0.04 * BENILLA_ITEM_PUSH_PX_PER_UNIT -- 48 + BENILLA_ITEM_PUSH_DRIFT = 0.01 * BENILLA_ITEM_PUSH_PX_PER_UNIT -- 12 + -- {time, value} keyframes, linear between (M2 track interpolation 1) — the file's own keys. + BENILLA_ITEM_PUSH_ALPHA = { {0.000, 0.0}, {0.133, 1.0}, {0.267, 1.0}, {1.000, 0.0} } + BENILLA_ITEM_PUSH_SCALE = { {0.000, 1.0}, {0.133, 1.2}, {0.267, 1.0}, {1.000, 0.014} } + -- The translation track as a 0..1 fraction of the fall (both components move together, so + -- one curve drives both axes — the file keys (0,0) at 0.000 and 0.500, the full offset at + -- 1.000, i.e. it hangs for the first half second and then drops). + BENILLA_ITEM_PUSH_DROP = { {0.000, 0.0}, {0.500, 0.0}, {1.000, 1.0} } + + -- Sample a {time, value} keyframe list at `t`, linear between keys, clamped at both ends + -- (the M2 sampler's own shape for a CLAMP sequence). + function BenillaItemPushAnim_Sample(track, t) + local last = table.getn(track) + if t <= track[1][1] then return track[1][2] end + for i = 2, last do + local key = track[i] + if t <= key[1] then + local prev = track[i - 1] + local span = key[1] - prev[1] + if span <= 0 then return key[2] end + return prev[2] + (key[2] - prev[2]) * (t - prev[1]) / span + end + end + return track[last][2] + end + + -- Place/size/fade the card for the current clock. The card LANDS centred on its button and + -- STARTS a fall's height above it (and a drift's width to the left), so the icon reads as + -- floating clear above the bar and then dropping into the bag — the model's own relative + -- motion, anchored to the button rather than to the ref's unresolvable model origin. + function BenillaItemPushAnim_Paint(anim) + local t = anim.elapsed + local drop = BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_DROP, t) + local size = BENILLA_ITEM_PUSH_SIZE * BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_SCALE, t) + anim:SetWidth(size) + anim:SetHeight(size) + anim:SetPoint("CENTER", anim:GetParent(), "CENTER", + BENILLA_ITEM_PUSH_DRIFT * (drop - 1), + BENILLA_ITEM_PUSH_FALL * (1 - drop)) + anim:SetAlpha(BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_ALPHA, t)) + end + + -- ref ItemAnim_OnLoad (l.68-70): the card listens for ITEM_PUSH and nothing else. It is + -- authored hidden, so it costs no OnUpdate until a push shows it (events reach hidden + -- frames; OnUpdate does not — the reference's own split). + function BenillaItemPushAnim_OnLoad(anim, containerId) + anim.containerId = containerId + anim.icon = getglobal(anim:GetName() .. "Icon") + anim.elapsed = 0 + anim:RegisterEvent("ITEM_PUSH") + end + + -- ref ItemAnim_OnEvent (l.72-80): `if this:GetParent():GetID() == arg1`, then + -- ReplaceIconTexture(arg2) / SetSequence(0) / SetSequenceTime(0,0) / Show. A push into a bag + -- with no button (a bank bag) matches nothing and animates nowhere, there and here. + function BenillaItemPushAnim_OnEvent(anim) + if arg1 ~= anim.containerId then return end + anim.icon:SetTexture(arg2) + anim.elapsed = 0 + -- Seat the card above its button rather than at load: the bag bar's own level is set in + -- the BUTTON's OnLoad (BenillaActionBarArt_SeatAbove), which the loader may run after + -- this child exists. Re-deriving it per push is one call and can never read it stale. + anim:SetFrameLevel(anim:GetParent():GetFrameLevel() + 1) + BenillaItemPushAnim_Paint(anim) + anim:Show() + end + + -- The CLAMP sequence's end is the ref's OnAnimFinished → this:Hide() (l.92-94). + function BenillaItemPushAnim_OnUpdate(anim, elapsed) + anim.elapsed = anim.elapsed + (elapsed or 0) + if anim.elapsed >= BENILLA_ITEM_PUSH_DURATION then + anim:Hide() + return + end + BenillaItemPushAnim_Paint(anim) + end + -- The bag BAR's five buttons (the backpack toggle + the four equipped-bag slots) — the row -- Disable_/Enable_BagButtons greys as a unit. Distinct from UiPanels.xml's BAG_WINDOWS, -- which is the five bag *windows* those buttons open. @@ -1432,6 +1552,24 @@ + + + + + + + + + + + + BenillaItemPushAnim_OnLoad(self, 0) + BenillaItemPushAnim_OnEvent(self) + BenillaItemPushAnim_OnUpdate(self, arg1) + + + @@ -1464,6 +1602,24 @@ + + + + + + + + + + + + BenillaItemPushAnim_OnLoad(self, 1) + BenillaItemPushAnim_OnEvent(self) + BenillaItemPushAnim_OnUpdate(self, arg1) + + + @@ -1501,6 +1657,24 @@ + + + + + + + + + + + + BenillaItemPushAnim_OnLoad(self, 2) + BenillaItemPushAnim_OnEvent(self) + BenillaItemPushAnim_OnUpdate(self, arg1) + + + @@ -1537,6 +1711,24 @@ + + + + + + + + + + + + BenillaItemPushAnim_OnLoad(self, 3) + BenillaItemPushAnim_OnEvent(self) + BenillaItemPushAnim_OnUpdate(self, arg1) + + + @@ -1573,6 +1765,24 @@ + + + + + + + + + + + + BenillaItemPushAnim_OnLoad(self, 4) + BenillaItemPushAnim_OnEvent(self) + BenillaItemPushAnim_OnUpdate(self, arg1) + + + @@ -1625,6 +1835,26 @@ + + + + + + + + + + + + BenillaItemPushAnim_OnLoad(self, -2) + BenillaItemPushAnim_OnEvent(self) + BenillaItemPushAnim_OnUpdate(self, arg1) + + + diff --git a/crates/benilla/src/aura_visual.rs b/crates/benilla/src/aura_visual.rs index 8f6c295e..d817d457 100644 --- a/crates/benilla/src/aura_visual.rs +++ b/crates/benilla/src/aura_visual.rs @@ -1,5 +1,6 @@ -//! **The aura-state CharProc layer** — what an aura does to the *body itself*: its translucency and -//! its tint, for exactly as long as the aura lives. This is the half of a stage-2 "state kit" that +//! **The aura-state CharProc layer** — what an aura does to the *body itself*: its translucency, its +//! tint and its animation clock, for exactly as long as the aura lives. This is the half of a stage-2 +//! "state kit" that //! isn't an attach-point emitter (those are `creature_anim::spell_visual`'s //! [`arm_aura_state_fx`](crate::creature_anim::arm_aura_state_fx), decision 0393): where that //! watcher hangs *models* on the unit, this one changes how the unit's own model **renders**. @@ -84,10 +85,17 @@ //! [`crate::model_fade::ParentModel`] link up to the unit — the recursion itself, and the same walk //! `ModelAlphas` already does for alpha. //! -//! Types 2, 7, 8, 11 and 13 also appear on state kits (Berserk/Bloodlust's 2, the Sap/Feign-Death 7, -//! the Freeze/Ice-Block 11, the "Glowy (Red)" 13) and have **no verified mechanism** — the wow-re -//! dispatcher note names only the cases above. They fall through [`node_for`]'s single `_` arm, which -//! is where each lands as one match arm the day its RE returns. +//! **Proc 11 ships too** (decision 0889): the **freeze**. Its param is a playback *rate* written onto +//! the unit's own animation clocks (`0x60db7e` → `SetBoneAnimSpeed 0x712910` on the mount's bone 0, +//! the body's key-bone 4 — the upper-body split — and the body's bone 0), old rates saved in the node +//! and written back when it expires (`0x6203e0`). Rate 0 is Ice Block: the pose holds exactly where +//! it was, *including* a cast one-shot that had just been armed, which is why the caster never gets +//! to raise their hand. [`apply_aura_anim_rate`] is that leg, and the reference's save/restore is +//! [`AnimRateFreeze`]. +//! +//! Types 2, 7 and 13 also appear on state kits (Berserk/Bloodlust's 2, the Sap/Feign-Death 7, the +//! "Glowy (Red)" 13) and have **no verified mechanism** — they fall through [`node_for`]'s single `_` +//! arm, which is where each lands as one match arm the day its RE returns. use benilla_protocol::EntityKind; use bevy::mesh::MeshTag; @@ -120,6 +128,9 @@ pub(crate) enum AuraNode { /// Proc 1: this aura's body tint, unpacked from the param's `0x00RRGGBB`. Modelled, not yet /// rendered (module docs). Tint([u8; 3]), + /// Proc 11: this aura's animation playback **rate** for the unit's own model — `0.0` for every + /// member of the freeze family (Ice Block, Freeze, Petrify, Entangle, Stilled). + AnimRate(f32), } /// The CharProc node lists of one unit — the ECS twin of the reference's per-unit `unit+0xb50` @@ -137,6 +148,10 @@ pub(crate) struct AuraNodes { /// `(spell id, rgb)` — proc-1 nodes, newest at the front like the alpha list; the head is what /// the per-frame apply reads. tint: Vec<(u32, [u8; 3])>, + /// `(spell id, rate)` — proc-11 nodes, head-first like the other two. The head is the rate the + /// unit's animation clocks run at ([`apply_aura_anim_rate`]); empty = the clocks are the + /// driver's own again. + rate: Vec<(u32, f32)>, /// `baseAlpha`: the display row's `CreatureModelAlpha / 255` — for EVERY unit, players /// included; the getter `0x60d2d0` has no type fork, a normal character's row just says 255 /// (`base-render-alpha.md` §6, correcting the "players 1.0" gloss). Owned by @@ -211,6 +226,12 @@ impl AuraNodes { pub(crate) fn head_tint(&self) -> Option<[u8; 3]> { self.tint.first().map(|(_, rgb)| *rgb) } + + /// The head proc-11 node's rate — what this unit's animation clocks are being held at, or `None` + /// when no freeze aura is on it. Head-node-wins like the other two lists. + pub(crate) fn head_anim_rate(&self) -> Option { + self.rate.first().map(|(_, r)| *r) + } } /// Publish every rig's body tint to the per-instance channel ([`crate::instance_tint`], decision @@ -298,6 +319,105 @@ fn tint_probe() -> Option<[u8; 3]> { }) } +/// On a rig whose clocks a proc-11 aura is holding: the playback speeds we took over, so the release +/// can hand them back — the reference's own `+0x60`/`+0x64`/`+0x68` (`0x6201d0` saves the three bone +/// rates before overwriting them; `0x6203e0` writes them back when the node expires with the aura). +/// +/// Keyed by animation node because that is what a speed belongs to here: the reference saves per +/// *bone* because a rate is a bone-clock property there, and ours is per armed clip. +#[derive(Component, Default, Debug)] +pub(crate) struct AnimRateFreeze { + saved: Vec<(AnimationNodeIndex, f32)>, +} + +/// Hold every unit whose head proc-11 node says so at that node's rate — its own rig and its mount's +/// (`[unit+0xd8]` and `[unit+0xdc]`, the two models `0x6201d0` writes) — and hand the speeds back +/// when the aura goes. +/// +/// **Reasserted every frame, and that is the faithful shape, not a belt-and-braces clamp.** The +/// reference's rate lives on the bone (`+0xb0`, the factor `timebase.md` §2's clock multiplies its +/// window by) and **arming an animation does not touch it**: op4 `0x7121a0`'s success leg writes the +/// track index and the cursors and leaves `+0xb0` alone (only its disarm leg and `SetBoneAnimSpeed +/// 0x712910` write it). So "rate 0 until the aura is reaped" is literally the reference's state +/// across every re-arm in between, and re-writing it each frame is how a per-clip speed says the +/// same thing. +/// +/// It runs **after the driver**, so this frame's arms are already in — which is the whole Ice Block +/// observable: the cast one-shot is armed and then frozen at the frame it was on, so the caster never +/// gets to raise their hand (`0x712910` re-bases `bias` to preserve the current frame, so rate 0 +/// holds the pose exactly where it stood). +/// +/// What it deliberately does **not** touch: attached effect models (the ice block's own +/// `icebarrier_state.mdx` keeps shimmering — the reference writes the unit's two models only) and +/// global sequences (a different clock there, and a different writer here). +pub(crate) fn apply_aura_anim_rate( + units: Query<( + Entity, + &AuraNodes, + Option<&crate::entities::mount::MountChild>, + )>, + mut rigs: Query<(&mut AnimationPlayer, Option<&mut AnimRateFreeze>)>, + mut commands: Commands, +) { + for (entity, nodes, mount) in &units { + let rate = nodes.head_anim_rate(); + for rig in [Some(entity), mount.map(|m| m.0)].into_iter().flatten() { + let Ok((mut player, freeze)) = rigs.get_mut(rig) else { + continue; + }; + match (rate, freeze) { + (Some(rate), Some(mut freeze)) => hold(&mut player, &mut freeze, rate), + (Some(rate), None) => { + let mut fresh = AnimRateFreeze::default(); + hold(&mut player, &mut fresh, rate); + trace_clock_edge("freeze", rig, rate, &fresh); + commands.entity(rig).try_insert(fresh); + } + (None, Some(freeze)) => { + for (node, speed) in &freeze.saved { + if let Some(anim) = player.animation_mut(*node) { + anim.set_speed(*speed); + } + } + trace_clock_edge("thaw", rig, 1.0, &freeze); + commands.entity(rig).try_remove::(); + } + (None, None) => {} + } + } + } +} + +/// Take every playing animation's clock to `rate`, remembering the speed each one was running at the +/// first time we touch it — a clip armed *during* the freeze is saved on its own first frame, so the +/// release hands back what the driver meant rather than a blanket 1.0. +fn hold(player: &mut AnimationPlayer, freeze: &mut AnimRateFreeze, rate: f32) { + for (node, anim) in player.playing_animations_mut() { + if !freeze.saved.iter().any(|(n, _)| n == node) { + freeze.saved.push((*node, anim.speed())); + } + anim.set_speed(rate); + } +} + +/// The freeze's own instrument (`WOW_MOVE_TRACE`, tag `aur`): one line per rig at each edge, naming +/// the clips it took over and the speeds it is holding — the difference between "the clocks stopped" +/// and "nothing was playing anyway", which no count of *absent* anim events can tell apart. +fn trace_clock_edge(what: &str, rig: Entity, rate: f32, freeze: &AnimRateFreeze) { + if !crate::dbg_trace::enabled_for("aur") { + return; + } + let clips: Vec = freeze + .saved + .iter() + .map(|(n, s)| format!("{}@{s}", n.index())) + .collect(); + crate::dbg_trace::line( + "aur", + &format!("{what} rig={rig} rate={rate} clips=[{}]", clips.join(" ")), + ); +} + /// One aura's CharProc edge, written by the aura-slot watcher /// ([`crate::creature_anim::arm_aura_state_fx`], which owns the slot diff and the state-kit resolve /// for both halves of a kit) and drained by [`drain_aura_procs`]. @@ -332,6 +452,10 @@ pub(crate) fn node_for(proc: benilla_formats::CharProc) -> Option { packed as u8, ])) } + // `params[0]` is a playback rate, straight through — `0x60db7e` hands it to + // `SetBoneAnimSpeed` unexamined, so the one outlier (kit 1744's `8947848.0`, a packed grey + // that landed in the rate column) is passed through rather than special-cased. + ty::ANIM_RATE => Some(AuraNode::AnimRate(proc.params[0])), _ => None, } } @@ -407,6 +531,7 @@ pub(crate) fn drain_aura_procs( fn reap(n: &mut AuraNodes, spell_id: u32, now: f32) { n.alpha.retain(|(s, _)| *s != spell_id); n.tint.retain(|(s, _)| *s != spell_id); + n.rate.retain(|(s, _)| *s != spell_id); n.retarget(now); } @@ -418,10 +543,12 @@ fn reap(n: &mut AuraNodes, spell_id: u32, now: f32) { fn install(n: &mut AuraNodes, spell_id: u32, procs: &[AuraNode], now: f32) { n.alpha.retain(|(s, _)| *s != spell_id); n.tint.retain(|(s, _)| *s != spell_id); + n.rate.retain(|(s, _)| *s != spell_id); for node in procs { match *node { AuraNode::Alpha(f) => n.alpha.insert(0, (spell_id, f)), AuraNode::Tint(rgb) => n.tint.insert(0, (spell_id, rgb)), + AuraNode::AnimRate(r) => n.rate.insert(0, (spell_id, r)), } } n.retarget(now); @@ -444,10 +571,13 @@ fn trace_edge(what: &str, entity: Entity, spell_id: u32, n: &AuraNodes) { let tint = n.head_tint().map_or_else(String::new, |[r, g, b]| { format!(" tint=#{r:02x}{g:02x}{b:02x}") }); + let rate = n + .head_anim_rate() + .map_or_else(String::new, |r| format!(" animrate={r}")); crate::dbg_trace::line( "aur", &format!( - "{what} e={entity} spell={spell_id} alpha={alphas:?}{tint} base {:.2} cur {:.2} target {:.2}", + "{what} e={entity} spell={spell_id} alpha={alphas:?}{tint}{rate} base {:.2} cur {:.2} target {:.2}", n.base, n.current, n.target() diff --git a/crates/benilla/src/aura_visual/tests.rs b/crates/benilla/src/aura_visual/tests.rs index 0dc904b9..42ecf0f6 100644 --- a/crates/benilla/src/aura_visual/tests.rs +++ b/crates/benilla/src/aura_visual/tests.rs @@ -25,6 +25,12 @@ const STEALTH_KIT: u32 = 312; const GHOST: u32 = 8326; const GHOST_VISUAL: u32 = 886; const GHOST_KIT: u32 = 989; +/// **Ice Block** → `SpellVisual` 4325 → state kit 3709: proc 1 tint `9074175.0` + **proc 11 @ 0.0** +/// (plus `icebarrier_state.mdx` at attach 0x13, which this layer doesn't own). The freeze, from the +/// shipped table — `benilla-extract spellvis 11958`. +const ICE_BLOCK: u32 = 11958; +const ICE_BLOCK_VISUAL: u32 = 4325; +const ICE_BLOCK_KIT: u32 = 3709; fn proc(ty: i32, param0: f32) -> CharProc { CharProc { @@ -72,9 +78,21 @@ fn the_dispatch_names_only_the_verified_procs() { node_for(proc(char_proc_type::TINT, 65_280.0)), Some(AuraNode::Tint([0x00, 0xFF, 0x00])), ); - // The five other types that ride real state kits have no verified mechanism yet: no node, so + // Proc 11 is a playback RATE, straight through (`0x60db7e` → `SetBoneAnimSpeed 0x712910`). + // Ice Block's 0.0 is the freeze; kit 1744's 8947848.0 goes through unexamined too, exactly as + // the reference hands the column on. + assert_eq!( + node_for(proc(char_proc_type::ANIM_RATE, 0.0)), + Some(AuraNode::AnimRate(0.0)), + "the freeze family's rate rides params[0]" + ); + assert_eq!( + node_for(proc(char_proc_type::ANIM_RATE, 8_947_848.0)), + Some(AuraNode::AnimRate(8_947_848.0)), + ); + // The four other types that ride real state kits have no verified mechanism yet: no node, so // nothing is invented and the kit doesn't arm on them alone. - for ty in [2, 7, 8, 11, 13] { + for ty in [2, 7, 8, 13] { assert_eq!(node_for(proc(ty, 1.0)), None, "type {ty} is unmodelled"); } } @@ -245,6 +263,13 @@ fn app_with_chains() -> App { ..Default::default() }, ), + ( + ICE_BLOCK_VISUAL, + VisualStages { + state: ICE_BLOCK_KIT, + ..Default::default() + }, + ), ]), HashMap::from([ // Kit 312's real slots: the unmodelled proc 7 FIRST, then proc 14 — so this also pins @@ -260,6 +285,13 @@ fn app_with_chains() -> App { proc(char_proc_type::ALPHA, 0.5), ]), ), + ( + ICE_BLOCK_KIT, + proc_kit(&[ + proc(char_proc_type::TINT, 9_074_175.0), + proc(char_proc_type::ANIM_RATE, 0.0), + ]), + ), ]), ))); app.insert_resource(crate::ui_action::Spells { @@ -278,6 +310,13 @@ fn app_with_chains() -> App { ..Default::default() }, ), + ( + ICE_BLOCK, + benilla_formats::SpellDisplay { + visual: ICE_BLOCK_VISUAL, + ..Default::default() + }, + ), ])), ..crate::ui_action::Spells::empty_for_tests() }); @@ -721,3 +760,171 @@ fn the_rig_free_hook_clears_a_dead_units_tint() { "the slot is clean before anyone can reallocate it", ); } + +// ── The freeze (proc 11) ───────────────────────────────────────────────────────────────────────── + +/// The arming world plus the clock-hold leg — the third proc's apply, ordered after the drain the +/// way the real schedule orders it after the drain *and* the driver. +fn app_with_freeze() -> App { + let mut app = app_with_chains(); + app.add_systems(Update, apply_aura_anim_rate.after(drain_aura_procs)); + app +} + +/// A rig mid-animation: two clips playing at the speeds the driver picked for them (a gait scaled to +/// the mover's speed, and a cast one-shot at 1.0 — the pair Ice Block catches). +fn rig_playing(speeds: [(u32, f32); 2]) -> AnimationPlayer { + let mut player = AnimationPlayer::default(); + for (node, speed) in speeds { + player + .play(AnimationNodeIndex::new(node as usize)) + .set_speed(speed); + } + player +} + +fn speed_of(app: &App, rig: Entity, node: u32) -> f32 { + app.world() + .entity(rig) + .get::() + .expect("the rig kept its player") + .animation(AnimationNodeIndex::new(node as usize)) + .expect("the clip is still armed") + .speed() +} + +/// **The director's retest, at the mechanism.** Ice Block's state kit carries proc 11 at rate 0, so +/// the unit's clocks stop dead: the run cycle it was mid-stride in and the cast one-shot that had +/// just been armed both hold the frame they were on — which is why the caster never gets to raise +/// their hand. Dropping the aura hands each clip back the speed the driver had given it, not a +/// blanket 1.0. +#[test] +fn ice_block_holds_the_clocks_and_the_drop_hands_them_back() { + let mut app = app_with_freeze(); + let unit = app + .world_mut() + .spawn(( + crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)])), + rig_playing([(4, 1.35), (54, 1.0)]), + )) + .id(); + app.update(); + + let n = app.world().entity(unit).get::().unwrap(); + assert_eq!(n.head_anim_rate(), Some(0.0), "kit 3709's proc 11 is 0"); + // 9074175 = 0x8A75FF — the ice-blue the same kit tints the body with, beside the freeze. + assert_eq!(n.head_tint(), Some([0x8A, 0x75, 0xFF]), "kit 3709's proc 1"); + assert_eq!(speed_of(&app, unit, 4), 0.0, "the gait stops mid-stride"); + assert_eq!(speed_of(&app, unit, 54), 0.0, "so does the cast one-shot"); + + // A second frame changes nothing — the hold is idempotent, and it must not re-save the zero it + // wrote itself (that is what would make the release hand back 0.0 forever). + app.update(); + assert_eq!(speed_of(&app, unit, 4), 0.0); + + // The aura leaves the slots: the reference writes the saved rates back (`0x6203e0`). + app.world_mut() + .entity_mut(unit) + .insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[( + 95, 0, + )]))); + app.update(); + assert!(app + .world() + .entity(unit) + .get::() + .unwrap() + .rate + .is_empty()); + assert_eq!( + speed_of(&app, unit, 4), + 1.35, + "the gait's own rate, restored" + ); + assert_eq!(speed_of(&app, unit, 54), 1.0); + assert!( + app.world().entity(unit).get::().is_none(), + "the release drops the save with it" + ); +} + +/// A clip armed *during* the freeze is saved on the frame we first touch it, so the release hands +/// back what the driver meant for it rather than the frozen 0. +#[test] +fn a_clip_armed_under_the_freeze_is_saved_before_it_is_held() { + let mut app = app_with_freeze(); + let unit = app + .world_mut() + .spawn(( + crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)])), + rig_playing([(4, 1.35), (54, 1.0)]), + )) + .id(); + app.update(); + + // The driver arms Stand at 1.0 a frame later (the root wiped the direction bits). + app.world_mut() + .entity_mut(unit) + .get_mut::() + .unwrap() + .play(AnimationNodeIndex::new(0)) + .set_speed(1.0); + app.update(); + assert_eq!(speed_of(&app, unit, 0), 0.0, "held the frame it arrived on"); + + app.world_mut() + .entity_mut(unit) + .insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[( + 95, 0, + )]))); + app.update(); + assert_eq!(speed_of(&app, unit, 0), 1.0); +} + +/// The mount comes with the rider: `0x6201d0` writes `[unit+0xdc]` before it writes `[unit+0xd8]`, +/// so a frozen rider's mount stops under them rather than running on the spot. +#[test] +fn the_freeze_reaches_the_mount_body() { + let mut app = app_with_freeze(); + let mount = app + .world_mut() + .spawn(rig_playing([(5, 2.0), (91, 1.0)])) + .id(); + let unit = app + .world_mut() + .spawn(( + crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, ICE_BLOCK), (95, 0x0E)])), + rig_playing([(91, 1.0), (54, 1.0)]), + crate::entities::mount::MountChild(mount), + )) + .id(); + app.update(); + + assert_eq!(speed_of(&app, unit, 91), 0.0, "the rider"); + assert_eq!(speed_of(&app, mount, 5), 0.0, "and the mount under them"); + + app.world_mut() + .entity_mut(unit) + .insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[( + 95, 0, + )]))); + app.update(); + assert_eq!(speed_of(&app, mount, 5), 2.0); +} + +/// An aura with no proc 11 — Stealth — leaves the clocks entirely alone. The freeze is the kit's, +/// never "any aura", and never the stun flag: nothing in the `0x40000` census touches animation. +#[test] +fn an_aura_without_the_proc_never_touches_a_clock() { + let mut app = app_with_freeze(); + let unit = app + .world_mut() + .spawn(( + crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, STEALTH), (95, 0x0E)])), + rig_playing([(4, 1.35), (54, 1.0)]), + )) + .id(); + app.update(); + assert_eq!(speed_of(&app, unit, 4), 1.35); + assert!(app.world().entity(unit).get::().is_none()); +} diff --git a/crates/benilla/src/capture/fixtures.rs b/crates/benilla/src/capture/fixtures.rs index 235456e9..10ee5780 100644 --- a/crates/benilla/src/capture/fixtures.rs +++ b/crates/benilla/src/capture/fixtures.rs @@ -1152,11 +1152,25 @@ fn seed_bag_window( // Seed a few chat lines into ChatFrame1 (the bottom-left docked window) — the look-pass // instrument for the chat-line drop shadow + the ChatFontNormal wiring (Slice C). This rides the // Bag/Tooltip fixtures, NOT the merchant/gossip A/B pair, so the DPI before/after captures stay - // identical. The pinned per-type colors: SYSTEM yellow, SAY white, LOOT green. + // identical. The pinned per-type colors: SYSTEM yellow, SAY white, LOOT green. The LOOT pair is + // also the regression baseline for the item link inside a coloured line (`ui_loot::receive_line` + // — the quality escape wins over the line colour for the bracketed name, and the `x2` after the + // `|r` falls back to LOOT green): a common/white Tough Jerky and an uncommon/green stack. for (text, r, g, b) in [ ("Welcome to Northshire Valley.", 1.0, 1.0, 0.0), ("[Marshal McBride] says: Well met, citizen.", 1.0, 1.0, 1.0), - ("You receive loot: [Tough Jerky].", 0.0, 170.0 / 255.0, 0.0), + ( + "You receive loot: |cffffffff|Hitem:117:0:0:0|h[Tough Jerky]|h|r.", + 0.0, + 170.0 / 255.0, + 0.0, + ), + ( + "You receive loot: |cff1eff00|Hitem:4306:0:0:0|h[Silk Cloth]|h|rx2.", + 0.0, + 170.0 / 255.0, + 0.0, + ), ] { script.add_chat_message("ChatFrame1", text, r, g, b); } diff --git a/crates/benilla/src/creature_anim.rs b/crates/benilla/src/creature_anim.rs index 209c130b..3e945cdd 100644 --- a/crates/benilla/src/creature_anim.rs +++ b/crates/benilla/src/creature_anim.rs @@ -510,7 +510,8 @@ use spell_visual::{ #[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, FxClass, KitPush, MissileSpawn, SpellKitFx, SpellKitSound, SpellVisuals, + held_strike_sound, FxClass, FxStage, KitPush, MissileSpawn, SpellKitFx, SpellKitSound, + SpellVisuals, }; /// The per-unit animation state machine. @@ -805,6 +806,16 @@ impl Plugin for CreatureAnimPlugin { // 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), ); } } diff --git a/crates/benilla/src/creature_anim/blood.rs b/crates/benilla/src/creature_anim/blood.rs index 65c74b8f..533f980f 100644 --- a/crates/benilla/src/creature_anim/blood.rs +++ b/crates/benilla/src/creature_anim/blood.rs @@ -129,6 +129,9 @@ pub(super) fn blood_spurts( spell_id: 0, // no spell — a self-terminating effect is never reaped by id persistent: false, class: super::FxClass::Hold, + // The blood spurt is `CEffect::AddEffect` off the melee path, not a kit stage — but it + // is the same self-terminating shape (one pass, then gone). + stage: super::FxStage::OneShot, effects: vec![( if front { ATTACH_FRONT } else { ATTACH_BACK }, path.to_string(), diff --git a/crates/benilla/src/creature_anim/driver.rs b/crates/benilla/src/creature_anim/driver.rs index c33b9684..1d25d9c5 100644 --- a/crates/benilla/src/creature_anim/driver.rs +++ b/crates/benilla/src/creature_anim/driver.rs @@ -468,11 +468,6 @@ pub(super) fn drive_animations( // right by construction, since the rider's body holds Mount(91) regardless and stealth and // mounts are mutually exclusive anyway. mv.stealthed = store.is_some_and(|s| s.0.unit_is_stealthed()); - // Stunned — read the same way and for the same reason (decision 0880): the fidget bail is a - // descriptor test in the reference too (`0x5eb4f2`/`0x5ec219`), on every unit it animates, - // so a watched player under Ice Block goes as still as ours does. - mv.stunned = - store.is_some_and(|s| s.0.unit_flags() & crate::player::UNIT_FLAG_STUNNED != 0); let moving = mv.flags & move_flags::ANY_MOVE != 0; // The airborne arc's bookkeeping (wow-re land-anim-height-gate + rf57b §2): on the arc's // FIRST airborne frame, its launch vertical speed splits a **jump** (upward — the client's @@ -508,14 +503,13 @@ pub(super) fn drive_animations( // Whether a looping base arm this frame may ROLL its variation (the client's base-arm // `variationIdx = −1`, decision 0123) or is forced to the head: decided from the state // *before* this frame's transitions (the client tests the outgoing armed id). + // (0880 also gated this on a stun. That gate cited `0x5eb4f2`/`0x5ec219` as "the idle/fidget + // selectors bail on UNIT_FLAG_STUNNED" — re-read at the bytes, those two sites are + // `ToggleSheath 0x5eb480` and `CanLootNow 0x5ec110`, and NO site in the whole `0x40000` + // census touches animation selection. The stun does not quiet the fidget; the freeze does, + // by stopping the clock (decision 0889), so the invented gate is gone.) let outgoing = drv.active_anim().unwrap_or(STAND); - // …and never while stunned (decision 0880): the reference's idle/fidget selectors bail on - // `UNIT_FLAG_STUNNED` before they get to choose anything, so the re-arm holds the head and - // even the idle twitch stops. Its own gate, not `arm_forces_head`'s combat/cast re-zero — - // Ice Block is neither engaged nor casting, and the two suppressions sit at different sites - // in the binary (`0x5eb4f2`/`0x5ec219` vs `0x5fdba0`). - let relaxed = - !mv.stunned && !select::arm_forces_head(engaged, cast_hold.is_some(), outgoing); + let relaxed = !select::arm_forces_head(engaged, cast_hold.is_some(), outgoing); wound_upkeep(&mut drv, &mut player); diff --git a/crates/benilla/src/creature_anim/select.rs b/crates/benilla/src/creature_anim/select.rs index 7cdff653..2fbd9476 100644 --- a/crates/benilla/src/creature_anim/select.rs +++ b/crates/benilla/src/creature_anim/select.rs @@ -267,18 +267,6 @@ pub(crate) struct MovementState { /// legs never touch it: unlike the stand state there is nothing to predict controller-side — /// the crouch waits on the server's aura, exactly as the reference does. pub(crate) stealthed: bool, - /// **Stunned** — `UNIT_FIELD_FLAGS & 0x40000`, stamped from the unit's OWN descriptor every - /// frame like [`Self::stealthed`] (self and remote alike). It gates the **idle fidget**: the - /// reference's idle/fidget selectors bail on the bit outright (`0x5eb4f2`, `0x5ec219` — - /// `test dword ptr [eax+0xa0], 0x40000` → `jne` out), so a stunned unit holds one deterministic - /// idle instead of looking around. - /// - /// That is the *whole* of the stun's animation effect — there is no stun clip anywhere in the - /// `0x40000` census (wow-re `unit-flags-movement-gates.md` §3). The rest of the frozen look is - /// the ROOT half doing its job: a stun always arrives with one, and a rooted unit carries no - /// direction bit, so the movement-anim resolver takes its not-moving exit and the body stands - /// (decision 0880). - pub(crate) stunned: bool, /// Riding a **flying** server spline (the taxi flight): the client's selector reads the active /// CMovement's spline flags (`[[unit+0x118]+0xa4]+0x18`) and plays Fly 135 when the wire Flying /// bit (0x200) is set — RF-0057 `0x5fd19c`. Stamped by [`unify`] from the entity's live diff --git a/crates/benilla/src/creature_anim/spell_visual.rs b/crates/benilla/src/creature_anim/spell_visual.rs index 2468bfbf..4fb0062d 100644 --- a/crates/benilla/src/creature_anim/spell_visual.rs +++ b/crates/benilla/src/creature_anim/spell_visual.rs @@ -87,6 +87,31 @@ pub(crate) enum FxClass { 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 @@ -105,6 +130,10 @@ pub(crate) enum SpellKitFx { 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 @@ -333,15 +362,18 @@ 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. + /// 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, }; } @@ -378,6 +410,7 @@ fn play_kit( spell_id, persistent: play.persistent, class: FxClass::Hold, + stage: play.stage, effects, }); } @@ -595,7 +628,8 @@ pub(super) fn route_cast_visuals( }); } // The precast's attach-point effect models (the glowing hands) — persistent, - // the client's stage-4 lifetime: they live until the cast resolves. + // 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 { @@ -603,6 +637,7 @@ pub(super) fn route_cast_visuals( spell_id: ev.spell_id, persistent: true, class: FxClass::Hold, + stage: FxStage::Relive, effects, }); } @@ -839,7 +874,8 @@ pub(super) fn route_cast_visuals( out.sounds.write(SpellKitSound::Play { entity, kit_sound }); } // The channel kit's effect models — persistent while the field holds (the - // client's stage-2 lifetime). + // 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 { @@ -847,6 +883,7 @@ pub(super) fn route_cast_visuals( spell_id: cur, persistent: true, class: FxClass::Hold, + stage: FxStage::State, effects, }); } @@ -967,6 +1004,7 @@ pub(crate) fn arm_aura_state_fx( spell_id, persistent: true, class: FxClass::AuraState, + stage: FxStage::State, effects, }); } @@ -1021,6 +1059,12 @@ pub(super) fn arm_loot_fx( 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) { @@ -1070,6 +1114,8 @@ pub(super) fn arm_level_up_fx( 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())], }); } diff --git a/crates/benilla/src/doodad_anim/mat_anim.rs b/crates/benilla/src/doodad_anim/mat_anim.rs index ef1d8785..8eb228fb 100644 --- a/crates/benilla/src/doodad_anim/mat_anim.rs +++ b/crates/benilla/src/doodad_anim/mat_anim.rs @@ -98,6 +98,20 @@ impl MatAnim { m } + /// Read the sequence (and its clock) from `host`'s live `AnimationPlayer` each frame instead of + /// staying pinned to the slot this instance opened on — for a spell-effect instance that + /// **advances** through its authored lifecycle (`Stand` → `Hold` → `Decay`, wow-re + /// `ceffect-anim-lifecycle.md`), because each leg has its own authored alpha loops: Ice + /// Barrier's pulse is as much the `Hold` band's oscillating transparency weights as its bone + /// scale. `None` leaves the instance pinned (a lane with no rig has no player to ask). + /// + /// It keeps [`Self::drives_tag`], so this stays an effect part writing its own tag — it borrows + /// the unit lane's *sequence source*, not its tag-compose ownership. + pub(crate) fn following_host(mut self, host: Option) -> Self { + self.host = host; + self + } + /// The **unit-lane** constructor: the sequence (and its clock) come from `host`'s live /// `AnimationPlayer`, so a creature's batches appear and disappear with the animation exactly /// as authored. The tag is composed by [`crate::entities::apply_unit_mat_alpha`], not driven diff --git a/crates/benilla/src/entities.rs b/crates/benilla/src/entities.rs index b712f5a3..e5b8c92b 100644 --- a/crates/benilla/src/entities.rs +++ b/crates/benilla/src/entities.rs @@ -460,7 +460,12 @@ impl Plugin for EntitiesPlugin { spell_fx::tick_fx_tint, // Fire each live instance's crossed event keyframes (decision 0304) — after // attach so a just-spawned instance's head window [0, cur] fires this frame. - spell_fx::fire_fx_anim_events, + // Beside it (one nested, unordered element — the outer tuple is at `chain()`'s + // 20-element ceiling): the effect-model completion callbacks, which advance an + // instance birth → Hold and arm the reap's Decay. Independent of the event + // scan; both only read a player Bevy already advanced in `PreUpdate`, and both + // want to run after the attach passes so a fresh instance is covered at once. + (spell_fx::fire_fx_anim_events, spell_fx::advance_fx_anim), // A gear change re-dresses the standing visual in place — a re-composited atlas // on the same parts, the equipment geosets re-selected, every attachment left // alone (decision 0835, the reference's own shape). diff --git a/crates/benilla/src/entities/dest_fx.rs b/crates/benilla/src/entities/dest_fx.rs index af8d472c..5f39928c 100644 --- a/crates/benilla/src/entities/dest_fx.rs +++ b/crates/benilla/src/entities/dest_fx.rs @@ -294,6 +294,9 @@ pub(super) fn attach_ground_fx_models( // A free world model standing at the point, chained to nothing: its trail stays // world-frozen and its pool finishes in place when the effect ends. super::spell_fx::EffectHost::default(), + // The dest one-shot is not a `CEffect` on a unit: it plants at the packet's point and + // runs its own span clock, so it keeps the plain single-clip arm. + None, &mut wow_materials, &mut tint_reg, &ibps, diff --git a/crates/benilla/src/entities/item_glow.rs b/crates/benilla/src/entities/item_glow.rs index 627507da..deb0b805 100644 --- a/crates/benilla/src/entities/item_glow.rs +++ b/crates/benilla/src/entities/item_glow.rs @@ -295,6 +295,9 @@ pub(super) fn attach_item_glows( attached: true, parent: Some(root), }, + // An `ItemVisuals` glow is armed by a different leg than `PlaySpellVisualKit` + // (0805) and carries no kit stage — the plain single-clip arm, as before. + None, &mut wow_materials, &mut tint_reg, &ibps, diff --git a/crates/benilla/src/entities/missile.rs b/crates/benilla/src/entities/missile.rs index 11d08e75..4e900b05 100644 --- a/crates/benilla/src/entities/missile.rs +++ b/crates/benilla/src/entities/missile.rs @@ -554,7 +554,7 @@ pub(super) fn attach_missile_models( // (Thrown_1H_*.m2) tumbles end-over-end through its InFlight bone rotation AND keys its // trail ribbon's per-sequence visibility ON — the worn item sits in Stand with neither. // A projectile with no InFlight sequence (an arrow, a fireball whose tumble is a global - // sequence) falls to the file-order-first clip inside the rig, unchanged. + // sequence) falls to the model's own `Stand` inside the rig, unchanged. if !attach_effect_visuals( &mut commands, entity, @@ -564,6 +564,9 @@ pub(super) fn attach_missile_models( // A missile is a FREE world model: its trail stays world-frozen, and its pool // finishes in place when it impacts (0202's drain). EffectHost::default(), + // A projectile is the separate `CMissile` TU, not a `CEffect`: it has no kit stage and + // no Birth/Hold/Decay lifecycle — it flies its one sequence and dies on arrival. + None, &mut wow_materials, &mut tint_reg, &ibps, diff --git a/crates/benilla/src/entities/spell_fx/lifecycle.rs b/crates/benilla/src/entities/spell_fx/lifecycle.rs new file mode 100644 index 00000000..216d1de2 --- /dev/null +++ b/crates/benilla/src/entities/spell_fx/lifecycle.rs @@ -0,0 +1,320 @@ +//! The effect-model **animation lifecycle**: `Stand` → `Hold` → `Decay`, the state machine a +//! spell-visual `CEffect`'s attached model runs for as long as it lives (VERIFIED — wow-re +//! `ceffect-anim-lifecycle.md`, §5 trio + corpus census 2026-08-02). Split from [`super`] along the +//! concern seam: that file owns the *instances* (the model cache, the attach cascade, the reap, the +//! world plants); this one owns *what they play*. +//! +//! ## The mechanism +//! +//! A `CEffect`'s model is armed **by `AnimationData.dbc` id, never by file order**. The model-load +//! bootstrap (`0x710153`–`0x71019b`) arms **id 0 `Stand`, variation 0** — the *birth* +//! ([`benilla_assets::ModelAnimations::preferred_clip`] is that resolve). When the armed sequence's +//! authored span elapses, the CM2 Advance `0x719370` latches once (`block+0xc0`) and enqueues the +//! model's registered completion callback; the scene tick drains it at `0x707595` at the **end of +//! the same tick**, passing **the id that just completed** as its first argument. `Which` callback +//! is registered is chosen by `PlaySpellVisualKit 0x60edf0` **per stage** — that table is +//! [`FxStage`], and this module is its three arms: +//! +//! - **[`FxStage::OneShot`]** (stages 0/1, `0x5fbf50`) — destroy at the first completion. Modelled +//! by the instance's own span clock in [`super::attach_spell_fx`], so nothing here watches it. +//! - **[`FxStage::State`]** (stage 2, `0x5ff170`) — iff the model authors **`Hold` (158)**, arm it +//! and keep it running for the effect's whole life; if it does not, do **nothing at all** (no +//! destroy, no re-loop — the model simply stays parked on its birth sequence). The reference's +//! "keep running" is a literal re-arm every span by `0x5ff1d0`, gated on a deadline at +//! `node+0x58` that is **nonzero only at stage 3** — so for an aura state or a channel it never +//! fires and Hold repeats forever. **This is Ice Barrier's pulse.** +//! - **[`FxStage::Relive`]** (stages 3/4, `0x60ed00`) — re-arm **the id that just completed**, +//! forever, with no `Hold` lookup, no deadline and no destroy. A precast whose birth sequence +//! clamps therefore still repeats it. +//! +//! Reap ([`FxDecay`]) is the same shape from the other end (`0x614150` → `0x6141c0`): if the model +//! authors **`Decay` (159)**, arm it and the instance **keeps rendering for that sequence's +//! authored span** before it despawns; otherwise it goes immediately. `0x6203e0` — the destructor — +//! plays nothing itself (its `0x4` bit is owner-attachment teardown, not a decay-out; wow-re §8 +//! corrects `ceffect-selfterm.md` on exactly this). +//! +//! ## What this replaces, and how wrong it was +//! +//! Every effect instance used to arm **one** clip for its whole life — the model's *file-order-first* +//! sequence, repeated only if that sequence's own loop flag was clear. For `Spells\IceShield_State` +//! (Ice Barrier's state kit) that is the 0.70 s clamping birth, so the shield grew in and then held +//! its last frame for the aura's entire duration. `benilla-extract fxlifescan` sizes the class: +//! **163 of 9691 models author a `Hold`/`Decay` leg** (158 of them under `Spells\`), **116 of which +//! froze** exactly this way — Mana Shield, Lightning Shield, Divine Shield, Frost Nova, the Fire and +//! Frost Wards, Immolate, Net, the healing auras. +//! +//! ## Named divergences +//! +//! - The reference re-arms `Hold` with variation `-1` (a `_rand`-weighted walk of the alias chain), +//! so a model authoring several `Hold` variations could pick a different one each pass. We run one +//! repeating play instead. Corpus: of the 692 `Spells\` models, 507 have a single sequence, 119 +//! two, 65 three and **one** has four or more — so a multi-variation `Hold` is at most that single +//! model, and it is not worth a per-pass re-roll's complexity until one shows. +//! - A **ribbon**'s per-sequence visibility is still decided once at spawn ([`crate::ribbons`], +//! which spawns per fixed-sequence entity). A trail authored dark in `Stand` and lit in `Hold` +//! would stay dark. Left as a residual: the ribbon lane's own spawn shape has to change for it, +//! and no reported effect turns on it. + +use benilla_assets::ModelAnimations; +use bevy::animation::{graph::AnimationNodeIndex, RepeatAnimation}; +use bevy::prelude::*; + +use crate::creature_anim::FxStage; + +/// `AnimationData.dbc` **158 `Hold`** — the sustained pulse leg (`0x9e` at `0x5ff188`/`0x5ff1bb`). +pub(crate) const ANIM_HOLD: u16 = 158; +/// `AnimationData.dbc` **159 `Decay`** — the fade-out leg (`0x9f` at `0x5ff233`/`0x6141c0`). +pub(crate) const ANIM_DECAY: u16 = 159; + +/// The completion callback one effect-model instance carries — the ECS twin of the `model+0x70` +/// registration `0x711bb0` writes, and of the callback *swap* `0x5ff170` performs when it hands the +/// birth over to `Hold`. +/// +/// Present on every kit-effect instance root that armed a rig, whatever its stage: the reap +/// ([`arm_decay`]) needs a handle on the player of an instance whose lifecycle is otherwise +/// finished. A missile, an item glow or the `fxview` fixture arms none — they are not `CEffect`s +/// (the missile is the separate `CMissile` TU) and keep the plain single-clip arm. +#[derive(Component)] +pub(crate) enum FxAnimLife { + /// Stage 2's birth, waiting to hand over — `0x5ff170` is the only callback that watches for a + /// completion and then does something *other* than re-arm or destroy. + Birth(AnimationNodeIndex), + /// Nothing left to advance: the birth handed over to `Hold` (whose "re-arm every span" is the + /// repeating play), or this stage never had a handover at all — `0x5fbf50`'s destroy is the + /// instance's own span clock, and `0x60ed00`'s re-arm-forever is the repeat armed below. The + /// node is still carried so a reap can stop it before arming `Decay`. + Settled(AnimationNodeIndex), +} + +impl FxAnimLife { + /// Arm `clip` on `player` for `stage` and return the watcher that finishes the job. + /// + /// The repeat policy is the stage's, not the sequence flag's alone: a [`FxStage::Relive`] + /// instance re-arms unconditionally (`0x60ed00` consults no flag, so a clamping precast still + /// repeats), while the other stages play the sequence exactly as the M2 sampler would — + /// wrapping on a bit0-CLEAR sequence (`0x71462a`'s modulo), holding the last frame on a + /// bit0-SET one (`0x7145db`'s clamp). + pub(super) fn arm( + player: &mut AnimationPlayer, + clip: &benilla_assets::AnimClip, + stage: FxStage, + ) -> Self { + let play = player.play(clip.node); + if clip.looping || stage == FxStage::Relive { + play.repeat(); + } + match stage { + FxStage::State => Self::Birth(clip.node), + FxStage::OneShot | FxStage::Relive => Self::Settled(clip.node), + } + } + + /// The graph node currently armed. + fn armed(&self) -> AnimationNodeIndex { + match self { + Self::Birth(n) | Self::Settled(n) => *n, + } + } +} + +/// Marker: this instance has been reaped and should play its **`Decay`** out (`0x614150`'s +/// `0x6141c0`). Written by [`super::resolve_spell_fx`], which also sets the instance's expiry to +/// the decay span; consumed once by [`advance_fx_anim`], which arms the clip through [`arm_decay`]. +#[derive(Component)] +pub(crate) struct FxDecay; + +/// Run the completion callbacks of every live effect instance — the birth → `Hold` handover, and +/// the reap's `Decay` arm. +/// +/// The reference dispatches these at the END of the tick that advanced the models (`0x707595` in +/// the scene tick `0x7074b0`); Bevy advances `AnimationPlayer`s in `PreUpdate`, so an `Update` +/// system reading [`bevy::animation::ActiveAnimation::completions`] fires in the same frame the +/// span elapsed. Completion is read as `completions() >= 1` rather than `is_finished()` precisely +/// because a wrapping birth never "finishes" — the reference's latch fires at the first span end +/// either way (`0x7194bc`, LOOP-flag-independent). +pub(crate) fn advance_fx_anim( + mut commands: Commands, + mut instances: Query<( + Entity, + &mut FxAnimLife, + &mut AnimationPlayer, + &ModelAnimations, + Has, + )>, +) { + for (root, mut life, mut player, anims, decaying) in &mut instances { + if decaying { + arm_decay(&mut player, life.armed(), anims); + // The lifecycle is over: the instance's expiry clock owns the despawn from here, and + // nothing may re-arm over the decay. + commands.entity(root).try_remove::(); + continue; + } + let FxAnimLife::Birth(armed) = *life else { + continue; // settled — no callback of this stage's changes anything on completion + }; + // `0x719370`'s fire-once latch: one notification per authored span. + if !player + .animation(armed) + .is_some_and(|a| a.completions() >= 1) + { + continue; + } + // `0x5ff170`: the birth is over. Iff the model authors Hold, arm it and keep it running — + // `0x5ff1d0` then re-arms it every span while the deadline `node+0x58` is unset, and that + // deadline is nonzero at stage 3 alone. Otherwise do nothing whatsoever: the model is left + // parked on its birth, which is both the reference's behaviour and what we already did. + match anims.find(ANIM_HOLD) { + Some(hold) => { + player.stop(armed); + player.play(hold.node).set_repeat(RepeatAnimation::Forever); + *life = FxAnimLife::Settled(hold.node); + trace_leg("hold", root, ANIM_HOLD); + } + None => { + *life = FxAnimLife::Settled(armed); + trace_leg("park", root, 0); + } + } + } +} + +/// The lifecycle's instrument (`WOW_MOVE_TRACE=`, tag `fx`) — one line per leg change, beside +/// the `kit spawn` / `kit expire` lines the instance lane already writes. +/// +/// This is how "the Ice Barrier shield is frozen" is *closed by measurement* rather than by eye +/// (method.md §4/§5): with the lifecycle dead the trace shows `kit spawn` and nothing after; with it +/// live the same cast prints `fx leg hold e=… anim=158` one birth-span (0.70 s) later, and the aura +/// drop prints `fx leg decay` followed by the `kit expire` a further 1.10 s on. A duration question +/// is answered from timestamps, never from watching. +pub(super) fn trace_leg(leg: &str, root: Entity, anim_id: u16) { + if !crate::dbg_trace::enabled() { + return; + } + crate::dbg_trace::line("fx", &format!("leg {leg} e={root} anim={anim_id}")); +} + +/// The reap's decay-out (`0x614150`, gates at `0x614187`–`0x6141a1`): arm `Decay` if the model +/// authors it. When it does not, nothing is armed and the caller's immediate expiry stands — the +/// reference's `je 0x6141d6` straight to the destructor. +fn arm_decay(player: &mut AnimationPlayer, armed: AnimationNodeIndex, anims: &ModelAnimations) { + let Some(decay) = anims.find(ANIM_DECAY) else { + return; + }; + player.stop(armed); + // Never repeated, whatever the sequence flags say: the instance is destroyed at this + // sequence's completion, and 61 of the corpus's 62 Decay sequences clamp anyway. + player + .play(decay.node) + .set_repeat(RepeatAnimation::Never) + .replay(); +} + +/// The authored span of a model's `Decay` sequence — how long a reaped instance keeps rendering +/// before it despawns (wow-re §8: the node is *not* torn down synchronously). `None` when the model +/// authors no `Decay`, which is the reference's immediate-destroy gate. +pub(crate) fn decay_span(anims: Option<&ModelAnimations>) -> Option { + anims?.find(ANIM_DECAY).map(|c| c.duration) +} + +#[cfg(test)] +mod tests { + use super::*; + use benilla_assets::AnimClip; + use bevy::animation::RepeatAnimation; + + /// One clip of `anim_id`, with the M2 loop flag `looping`, on graph node `node`. + fn clip(anim_id: u16, node: usize, looping: bool) -> AnimClip { + AnimClip { + anim_id, + seq_index: node, + node: AnimationNodeIndex::new(node), + looping, + duration: 1.0, + move_speed: 0.0, + blend_time: 0.0, + bounds_center: Vec3::ZERO, + bounds_radius: 0.0, + bounds_min: Vec3::ZERO, + bounds_max: Vec3::ZERO, + events: Vec::new().into(), + arm_nodes: None, + upper_node: None, + frequency: 0, + replay: (0, 0), + } + } + + fn repeat(player: &AnimationPlayer, node: usize) -> RepeatAnimation { + player + .animation(AnimationNodeIndex::new(node)) + .expect("armed") + .repeat_mode() + } + + /// A stage-2 instance opens on its birth and keeps a watcher: `0x5ff170` is the one callback + /// that has a handover to make. `IceShield_State`'s birth clamps, so it is armed unrepeated — + /// the freeze the whole fix is about is *correct* right up until the completion fires. + #[test] + fn state_arms_the_birth_and_watches_for_the_handover() { + let mut player = AnimationPlayer::default(); + let life = FxAnimLife::arm(&mut player, &clip(0, 0, false), FxStage::State); + assert!(matches!(life, FxAnimLife::Birth(n) if n == AnimationNodeIndex::new(0))); + assert_eq!(repeat(&player, 0), RepeatAnimation::Never); + } + + /// Stages 3/4 (`0x60ed00`) re-arm the completed id **unguarded** — the repeat does not consult + /// the sequence's own clamp bit, so a precast whose `Stand` clamps still repeats where a + /// loop-flag-only arm would freeze it. And there is nothing left to watch. + #[test] + fn relive_repeats_even_a_clamping_sequence() { + let mut player = AnimationPlayer::default(); + let life = FxAnimLife::arm(&mut player, &clip(0, 0, false), FxStage::Relive); + assert!(matches!(life, FxAnimLife::Settled(_))); + assert_eq!(repeat(&player, 0), RepeatAnimation::Forever); + } + + /// A stage-0/1 one-shot plays its sequence exactly as the M2 sampler would — the clamp bit + /// decides — and self-terminates on the instance's span clock, not here. + #[test] + fn oneshot_follows_the_sequence_flag_only() { + let mut clamped = AnimationPlayer::default(); + let life = FxAnimLife::arm(&mut clamped, &clip(0, 0, false), FxStage::OneShot); + assert!(matches!(life, FxAnimLife::Settled(_))); + assert_eq!(repeat(&clamped, 0), RepeatAnimation::Never); + + let mut wrapping = AnimationPlayer::default(); + FxAnimLife::arm(&mut wrapping, &clip(0, 0, true), FxStage::OneShot); + assert_eq!(repeat(&wrapping, 0), RepeatAnimation::Forever); + } + + /// The reap's decay-out and its gate (`0x6141a1`): a model authoring `Decay` reports the span + /// the instance must keep rendering for; one that does not reports `None`, which is the + /// reference's straight-to-destructor branch. + #[test] + fn decay_span_is_the_gate_and_the_lifetime() { + let with = test_anims(&[clip(0, 0, false), clip(ANIM_HOLD, 1, true), { + let mut c = clip(ANIM_DECAY, 2, false); + c.duration = 1.1; + c + }]); + assert_eq!(decay_span(Some(&with)), Some(1.1)); + + let without = test_anims(&[clip(0, 0, false), clip(ANIM_HOLD, 1, true)]); + assert_eq!(decay_span(Some(&without)), None); + assert_eq!(decay_span(None), None); + } + + fn test_anims(clips: &[AnimClip]) -> ModelAnimations { + ModelAnimations { + graph: Handle::default(), + clips: clips.to_vec(), + hand_close: [None, None], + playable_animation_lookup: Vec::new(), + animation_lookup: Vec::new(), + global_bones: Vec::new(), + first_seq: None, + pose: Default::default(), + } + } +} diff --git a/crates/benilla/src/entities/spell_fx.rs b/crates/benilla/src/entities/spell_fx/mod.rs similarity index 85% rename from crates/benilla/src/entities/spell_fx.rs rename to crates/benilla/src/entities/spell_fx/mod.rs index 641e4c51..9279b3a9 100644 --- a/crates/benilla/src/entities/spell_fx.rs +++ b/crates/benilla/src/entities/spell_fx/mod.rs @@ -21,8 +21,10 @@ //! attach cascade for a model lacking the requested point is the client's: tag → `0xf` → `0x13` //! → the unit's base (wow-re §5, `0x61ceb0`). //! -//! Effect models run their **bone rigs** ([`arm_effect_rig`] — the first clip + global sequences -//! pose the joints that meshes skin to and emitters/ribbons/cards ride) and their **material +//! Effect models run their **bone rigs** ([`arm_effect_rig`] — the birth clip + global sequences +//! pose the joints that meshes skin to and emitters/ribbons/cards ride), advance them through the +//! authored `Stand` → `Hold` → `Decay` lifecycle ([`lifecycle`], which is where the Ice Barrier +//! shield's pulse comes from), and run their **material //! animation**: each part's colour-alpha × transparency-weight loops sample per instance on the //! attach clock (a [`MatAnim`](crate::doodad_anim::MatAnim) that owns the part's render-alpha //! tag — Battle Shout's staggered crescent pulses), and an animated M2Color RGB ticks a @@ -42,6 +44,8 @@ //! callback; and the kit sound keeps playing unconditionally where the client gates it on //! no-visual-attached. +mod lifecycle; + use std::collections::HashMap; use benilla_assets::bone_target_id; @@ -49,13 +53,15 @@ use bevy::animation::AnimatedBy; use bevy::ecs::entity::EntityHashMap; use bevy::prelude::*; -use crate::creature_anim::{scan_events, AnimSoundEvent, FxClass, SpellKitFx}; +use crate::creature_anim::{scan_events, AnimSoundEvent, FxClass, FxStage, SpellKitFx}; use crate::debug_panel::{ModelKind, ModelPart}; use crate::model_render::m2_url; use crate::particles; use crate::terrain::WowModelMaterial; use super::{BoneAttach, DisplayModel, EntityPart, ModelHandle}; +pub(super) use lifecycle::advance_fx_anim; +use lifecycle::{decay_span, FxAnimLife, FxDecay}; /// The client's attach fallback cascade when a model lacks the requested point (wow-re /// `spell-visual-apply.md` §5, `0x61ceb0`/`0x61fae0`): retry `0xf`, then `0x13`, then the unit's @@ -296,11 +302,20 @@ pub(crate) fn drive_fx_view( attached: true, parent: None, }, + // `WOW_FX_HOLD=1` previews a PERSISTENT instance, so it must show the persistent + // lifecycle — birth then `Hold` — or the instrument would report a freeze the game + // does not have. Without it the fixture previews a one-shot, which runs its birth once + // and is reaped by the fixture's own span clock below. + Some(if req.hold { + FxStage::State + } else { + FxStage::OneShot + }), &mut wow_materials, &mut tint_reg, &ibps, &mut palettes, - None, // the fixture previews a kit effect at its default (first) clip + None, // the fixture previews a kit effect on its model's own `Stand` ) { state.attached_at = Some(time.elapsed_secs()); } @@ -340,6 +355,13 @@ pub(super) struct EffectHost { /// emitters' attach frame, whose render alpha they inherit, and what becomes of them when the /// instance goes. Returns `false` while the model's parts are still building — call again next /// frame. +/// +/// `stage` is the instance's **animation lifecycle** ([`lifecycle`]): `Some` for a spell-visual +/// `CEffect`, which advances `Stand` → `Hold` → `Decay` and therefore reads its per-sequence riders +/// (each part's alpha loops, each emitter's rate/enable windows) off its own live `AnimationPlayer` +/// rather than off a slot pinned at spawn. `None` for the lanes that are not `CEffect`s and never +/// advance — a missile (the separate `CMissile` TU), an item glow, the `fxview` preview — which +/// keep the pinned single-clip arm they have always had. #[allow(clippy::too_many_arguments)] pub(super) fn attach_effect_visuals( commands: &mut Commands, @@ -348,6 +370,7 @@ pub(super) fn attach_effect_visuals( now: f32, ground_anchor: bool, host: EffectHost, + stage: Option, wow_materials: &mut Assets, tint_reg: &mut FxTintAnims, ibps: &Assets, @@ -389,7 +412,7 @@ pub(super) fn attach_effect_visuals( } }) .collect(); - let joints = arm_effect_rig(commands, root, dm, preferred_anim); + let (joints, armed) = arm_effect_rig(commands, root, dm, preferred_anim, stage); // The owned palette rig (decision 0720): allocated when the effect draws skinned parts; the // hook frees the slot when the instance despawns (impact reap, missile arrival). let rig_slot = match (&dm.inverse_bindposes, joints.is_empty()) { @@ -406,7 +429,7 @@ pub(super) fn attach_effect_visuals( _ => 0, }; let rigged = rig_slot != 0; - // The clip this instance runs (the missile's InFlight, else the file-order-first) — the same + // The clip this instance OPENS on (the missile's InFlight, else the model's `Stand`) — the same // pick `arm_effect_rig` armed. Its **file sequence slot** is the key into each batch's // per-sequence material-alpha loops: an effect that plays a non-first sequence must read that // sequence's authored batch visibility, not sequence 0's. @@ -415,6 +438,13 @@ pub(super) fn attach_effect_visuals( .as_ref() .and_then(|a| a.preferred_clip(preferred_anim)); let played_seq = played.map(|c| c.seq_index); + // …and for a `CEffect`, that is only the OPENING slot: the instance advances to `Hold` and then + // to `Decay`, each with its own authored alpha loops and emitter windows (for IceShield the + // pulse is as much the transparency-weight tracks as the bone scale). So its per-sequence + // riders read the live sequence off this instance's own player each frame — the same + // `playing_seq` lane a creature's batches already use — instead of the slot they opened on. + // A lane with no rig has no player to ask and stays pinned, as before. + let seq_host = (armed && stage.is_some()).then_some(root); commands.entity(root).with_children(|children| { for (part, material) in parts.iter().zip(&part_materials) { if part.billboard.is_some() { @@ -453,7 +483,8 @@ pub(super) fn attach_effect_visuals( // The rig field rides the whole-tag seed (decision 0720). if let Some(anim) = &part.alpha_anim { let mat_anim = - crate::doodad_anim::MatAnim::driving_tag(anim.clone(), now, played_seq); + crate::doodad_anim::MatAnim::driving_tag(anim.clone(), now, played_seq) + .following_host(seq_host); let rig_tag = if rigged && part.skinned_mesh.is_some() { crate::mesh_tag::rig_bits(rig_slot) } else { @@ -493,7 +524,8 @@ pub(super) fn attach_effect_visuals( } // A card shares its batch's material-alpha loops (the billboard split copies them). if let Some(anim) = &part.alpha_anim { - let mat_anim = crate::doodad_anim::MatAnim::driving_tag(anim.clone(), now, played_seq); + let mat_anim = crate::doodad_anim::MatAnim::driving_tag(anim.clone(), now, played_seq) + .following_host(seq_host); spawned.insert(( bevy::mesh::MeshTag(crate::mesh_tag::alpha_bits(mat_anim.current)), mat_anim, @@ -543,7 +575,8 @@ pub(super) fn attach_effect_visuals( ibp, ); if let Some(anim) = &part.alpha_anim { - let mat_anim = crate::doodad_anim::MatAnim::driving_tag(anim.clone(), now, played_seq); + let mat_anim = crate::doodad_anim::MatAnim::driving_tag(anim.clone(), now, played_seq) + .following_host(seq_host); commands.entity(decal).insert(mat_anim); } } @@ -568,10 +601,16 @@ pub(super) fn attach_effect_visuals( // carries the host's fade down to them — decision 0833. alpha: Some(root), }, - // The effect's rig arms ONE clip; the emitters' rate/enabled windows ride that slot - // (a missile's InFlight is not file-order-first) on the spawn clock, and gseq loops - // ride the pooled scene clock (0856). - particles::EmitClock::Effect(played_seq), + // The emitters' rate/enabled windows ride the played sequence: a `CEffect` ADVANCES + // (`Stand` → `Hold` → `Decay`), so it reads the live one off its own player like a + // unit's do — the reference's `m2_animate` phase samples the CURRENT sequence record + // either way. A lane that never advances (a missile, whose InFlight is not the model's + // Stand) keeps its slot pinned on the spawn clock. gseq loops ride the instance's own + // spawn age in both (0856/0858 — an effect instance is fresh per play). + match seq_host { + Some(h) => particles::EmitClock::Host(h), + None => particles::EmitClock::Effect(played_seq), + }, ); } // The same pick, as an `AnimationData` id — the ribbons' per-sequence visibility keys off it, @@ -600,23 +639,32 @@ pub(super) fn attach_effect_visuals( } /// Arm an effect-model instance's **authored rig** under `root` (a missile entity / fx instance -/// root): the joint hierarchy, an `AnimationPlayer` on the file-order-first clip, and the +/// root): the joint hierarchy, an `AnimationPlayer` on the model's birth clip, and the /// free-running global-sequence channels. Effect models pose their emitter/ribbon/mesh bones with /// this rig — the fireball missile's constant bone keys turn its authored long-axis-up frame into /// flight-forward and set its two trail ribbons 90° apart, and its global-sequence bone tumbles -/// the molten core. The first clip plays regardless of the doodad content gate +/// the molten core. The clip plays regardless of the doodad content gate /// (`ModelAnimations::first_seq`, which skips constant-pose sequences as render-identical — true /// for skinned MESHES at bind pose, but an effect's emitters read the posed joints, so the -/// constant keys are load-bearing here). Returns the joint entities; empty for a boneless model -/// (everything then rides `root` directly, as before). +/// constant keys are load-bearing here). +/// +/// The clip is the reference's model-load bootstrap — **animation id 0 `Stand`**, not the +/// file-order-first slot ([`benilla_assets::ModelAnimations::preferred_clip`]) — and for a +/// `CEffect` (`stage` is `Some`) it is only the OPENING leg: [`FxAnimLife`] carries the completion +/// callback that advances it to `Hold` and, at the reap, to `Decay` ([`lifecycle`]). +/// +/// Returns the joint entities (empty for a boneless model — everything then rides `root` directly, +/// as before) and whether an `AnimationPlayer` was actually armed, which is what decides whether +/// the per-sequence riders have a live sequence to follow. pub(super) fn arm_effect_rig( commands: &mut Commands, root: Entity, dm: &DisplayModel, preferred_anim: Option, -) -> Vec { + stage: Option, +) -> (Vec, bool) { if dm.skeleton.joints.is_empty() { - return Vec::new(); + return (Vec::new(), false); } let joints = super::spawn_joints(commands, root, root, &dm.skeleton); // Billboard bones face the camera at the PALETTE level, children inheriting (the frost-armor @@ -624,25 +672,32 @@ pub(super) fn arm_effect_rig( if let Some(bb) = crate::billboard::BillboardJointRig::new(&dm.skeleton, &joints, root) { commands.entity(root).insert(bb); } + let mut armed = false; if let Some(anims) = dm.animations.as_ref() { // A caller can name the sequence this instance should run (the thrown-weapon missile asks // for InFlight, so its authored spin plays and its ribbon's per-sequence visibility keys // ON); absent that — or when the model has no such sequence (an arrow, a fireball whose - // tumble is a global sequence) — fall to the file-order-first clip, as before. + // tumble is a global sequence) — the bootstrap's `Stand`. if let Some(clip) = anims.preferred_clip(preferred_anim) { let mut player = AnimationPlayer::default(); - let play = player.play(clip.node); - if clip.looping { - play.repeat(); - } + // The stage owns the repeat policy, not the sequence flag alone: stages 3/4 re-arm + // unconditionally (`0x60ed00`), so a clamping precast still repeats. + let life = FxAnimLife::arm(&mut player, clip, stage.unwrap_or(FxStage::OneShot)); commands .entity(root) .insert((player, AnimationGraphHandle(anims.graph.clone()))); + // The lifecycle watcher, and the `ModelAnimations` it (and the per-sequence riders) + // resolve the live sequence through — only on the `CEffect` lanes; a missile or an + // item glow neither advances nor is ever decay-reaped. + if stage.is_some() { + commands.entity(root).insert((life, anims.clone())); + } for (i, &j) in joints.iter().enumerate() { commands .entity(j) .insert((bone_target_id(i as u16), AnimatedBy(root))); } + armed = true; } if let Some(drive) = crate::creature_anim::GlobalSeqDrive::new(&anims.global_bones, &joints) { @@ -652,7 +707,7 @@ pub(super) fn arm_effect_rig( commands.entity(root).insert(drive); } } - joints + (joints, armed) } /// One live (or pending) effect-model instance on a unit. @@ -665,6 +720,10 @@ struct FxInstance { /// Which owner's reap can kill a persistent instance (cast router vs aura watcher) — the /// client's reap-walk discriminator next to the spell id ([`FxClass`]). class: FxClass, + /// Which animation lifecycle its model runs ([`FxStage`]) — a separate axis from `class`, and + /// carried per instance because the same `class` covers a stage-4 precast and a stage-2 + /// channel. + stage: FxStage, /// The M2 attachment id to hang from ([`benilla_formats::KIT_SLOT_TAGS`]), or /// [`benilla_formats::WORLD_EFFECT_TAG`] for the field-12 world-plant slot (0848/0850). tag: u16, @@ -673,8 +732,13 @@ struct FxInstance { /// The spawned instance root (a child of the attach joint), `None` while the model loads. root: Option, /// The self-termination deadline (`time.elapsed_secs()` clock), set at spawn for a - /// non-persistent instance. + /// non-persistent instance — and, for a reaped persistent one, the end of its `Decay` span. expires: Option, + /// Reaped, and playing its `Decay` out (wow-re `ceffect-anim-lifecycle.md` §8: the node is not + /// torn down synchronously — it keeps rendering for that sequence's authored duration). Such an + /// instance is already dying, so it no longer answers a reap or a replacing `Begin`: the + /// reference's walk can't reach it either, having moved it to the pending-destroy list. + decaying: bool, } /// A unit's effect instances (the client's per-unit `+0xb4` effect list). @@ -755,9 +819,11 @@ pub(super) fn resolve_spell_fx( mut events: MessageReader, mut units: Query<&mut FxAttached>, fx: Option>, + time: Res