ice block freezes you solid, and loot lines link their item

- ice block freezes the body the frame it lands, before the arm even rises
- the loot message links the item, so the name wears its quality colour
- a looted item drops into the bag that actually took it
- effect models live their authored life, born, held, decayed, and bar fills sit under their borders

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-02 18:01:42 +07:00
co-authored by Claude Fable 5
parent a3c5b20698
commit 2f40b6a9a0
37 changed files with 2249 additions and 254 deletions
+47 -3
View File
@@ -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<u16>) -> 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.
@@ -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)",
}
}
@@ -218,6 +218,17 @@ enum Command {
/// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted.
prefix: Option<String>,
},
/// 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<String>,
},
/// 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())?,
@@ -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<String> = 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<String, u32> = 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("<root>");
*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(())
}
@@ -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
+133 -45
View File
@@ -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<fontstring,
//! `(stratum, frame level, draw layer, texture<fontstring, frame link-stamp, is-region, sub-level,
//! declaration order)` — packed big-endian-by-priority into one sortable [`ZKey`] (`u64`), so the
//! whole render list is produced by one sort on the key. The `is-region` bit sits just below the frame
//! identity so a frame's own entry always precedes all of its regions (useful for backdrop/scissor
//! setup), independent of any region's sub-level.
//! whole render list is produced by one sort on the key. The `is-region` bit is benilla's own: the
//! binary has no frame-level drawable (only regions draw), so it sits *below* the layer key purely
//! to keep a frame's backdrop/scissor slot ahead of that frame's own regions.
//!
//! The **link-stamp** is the client's live intrusive-list position, not a creation index: a frame is
//! (re)linked at its bucket on becoming visible, on a strata change, and on a *changing* level set
//! — later link = drawn on top. A monotonic global counter bumped at those same moments is
//! order-equivalent within any one bucket, which is what [`crate::widget::WidgetArena`] keeps.
//!
//! **Below the layer, the frame term is exact for font strings and a deterministic stand-in for
//! textures.** The client re-sorts each layer's quad array by *texture handle* (`0x7731a0`) and
//! never sorts the text sub-array. A texture handle is an internal allocation our texture manager
//! assigns differently, so we reproduce the client's pre-sort array instead — identical wherever a
//! layer's quads share a texture or don't overlap. **No content may depend on the relative order of
//! two overlapping textures within one layer.**
//!
//! ## Era deltas (designed-for, not invented)
//!
//! - **`BLIZZARD` stratum** — the modern engine adds exactly one stratum *above* `TOOLTIP`
//! (decision 0068 §"strict superset"; the 1.12 `UI.xsd`↔Era diff). It is the last [`Strata`]
//! variant; 1.12 content never selects it.
//! - **`textureSubLevel`** — an i8 ordering knob *within* a draw layer. Present in the 1.12 struct's
//! region lists (the `~0x1c0` layer lists, `frame-model.md`) and first-class in Era; modeled as the
//! `sub_level` field of every region.
//! - **`textureSubLevel`** — an i8 ordering knob *within* a draw layer, first-class in Era. 0884's
//! §5 found **no sub-level in 5875 at all** (`0x76a860` takes only `(region, layer)` and
//! head-inserts with no comparator; the per-layer header is a plain 0xc-byte triple with nowhere
//! to keep a sort key), correcting the earlier note that placed one in the 1.12 region lists. The
//! `sub_level` field stays as the Era delta and is inert for 1.12 content.
use crate::widget::{FrameHandle, RegionHandle, RegionKind, WidgetArena};
@@ -138,25 +159,65 @@ impl Default for DrawLayer {
//
// bits 60..=63 (4) stratum Strata::index() 0..=9 (< 16)
// bits 44..=59 (16) frame level u16 (< 65536)
// bits 25..=43 (19) frame insertion monotonic create seq (< 524288)
// bit 24 (1) is-region 0 = the frame itself, 1 = one of its regions
// bits 21..=23 (3) draw layer DrawLayer::index() 0..=4 (< 8)
// bits 13..=20 (8) sub-level i8 biased by +128 (0..=255)
// bit 12 (1) fontstring 0 = texture, 1 = fontstring (textures draw first)
// bits 41..=43 (3) draw layer DrawLayer::index() 0..=4 (< 8)
// bit 40 (1) fontstring 0 = texture, 1 = fontstring — BUCKET-WIDE, above the frame
// bits 21..=39 (19) frame link-stamp monotonic (re)link seq (< 524288)
// bit 20 (1) is-region 0 = the frame itself, 1 = one of its regions
// bits 12..=19 (8) sub-level i8 biased by +128 (0..=255) — INERT on 1.12
// bits 0..=11 (12) declaration seq region index within its owner frame (< 4096)
//
// A frame's own entry zeroes every field below `is-region`, so it precedes all of its regions.
// Regions of one frame share the frame's (stratum, level, insertion) prefix, so they stay grouped
// immediately behind their frame and order among themselves by (layer, sub-level, texture<fontstring,
// decl) — exactly the client's within-frame region order.
// ## Why the draw layer outranks the frame (decision 0884 — §5-VERIFIED)
//
// **The five render batches belong to the LEVEL NODE, not to the frame** (`levelNode+0x1c`, five
// 0x30-byte batches), and the emitter `0x765920` loops `layer = 0..4` on the OUTSIDE and frames on
// the inside. So the draw layer is a **bucket-wide** key: every frame sharing a `(strata, level)`
// emits its BACKGROUND regions, then every frame its BORDER, then ARTWORK, then OVERLAY, then
// HIGHLIGHT. It is not a within-frame ordering — which is what benilla modeled until 0884, with
// the frame term ranked above the layer so a frame's regions stayed glued behind it.
//
// That inversion is what put the mirror timer's blue fill over its own border art and caption
// (director report, 2026-08-02). The reference builds those bars as a Frame whose OVERLAY layer
// carries the border + caption, with a child StatusBar whose `<BarTexture>` 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<fontstring →
/// decl). Ties are impossible: distinct frames differ in insertion, a frame and its regions differ in
/// the is-region bit, and a frame's regions differ in the remaining fields.
/// the total draw order (strata → **layer** → frame link-stamp → is-region → sub-level →
/// texture<fontstring → decl; the layer outranks the frame — see the `ZKey` bit-layout note and
/// decision 0884). Ties are impossible: distinct frames differ in link-stamp, a frame and its
/// regions differ in the is-region bit, and a frame's regions differ in the remaining fields.
///
/// Ordering only: this emits an entry for *every* region of a visible frame. Region-level
/// `Show`/`Hide` (the VisibleRegion bit, `region+0xc4`) is applied one layer up, where paint lives —
@@ -386,31 +448,57 @@ mod tests {
}
#[test]
fn within_frame_layer_sublevel_kind_decl_order() {
fn layer_then_kind_then_frame_then_decl() {
let mk = |layer, sub, fs, decl| ZKey::region(Strata::Medium, 3, 42, layer, sub, fs, decl);
// layer dominates sub-level
// The layer is the top term below (strata, level) — it dominates everything under it.
assert!(
mk(DrawLayer::Border, i8::MIN, false, 0) > 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 ────────────────────────────────────────────────────────
+13 -7
View File
@@ -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<Frame>,
regions: Arena<Region>,
+230
View File
@@ -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 <Model> 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 <Model> frame's 3D
-- projection is not RE-recorded (the ref's ItemAnim frame has no <Size> 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 @@
<Texture name="$parentIcon" file="Interface\Buttons\Button-Backpack-Up"/>
</Layer>
</Layers>
<!-- The item-push drop card (decision 0887, BenillaItemPushAnim_* above) — the ref's
$parentItemAnim, one per bag button, container id 0 = the backpack. -->
<Frames>
<Frame name="$parentItemAnim" hidden="true">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="$parentIcon" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnLoad>BenillaItemPushAnim_OnLoad(self, 0)</OnLoad>
<OnEvent>BenillaItemPushAnim_OnEvent(self)</OnEvent>
<OnUpdate>BenillaItemPushAnim_OnUpdate(self, arg1)</OnUpdate>
</Scripts>
</Frame>
</Frames>
<HighlightTexture file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD"/>
<!-- The lit ring while the backpack window is open (ref MainMenuBarBackpackButton
l.104) — a CheckButton like the ref's; OnShow/OnHide of the window drive it. -->
@@ -1464,6 +1602,24 @@
<Anchor point="RIGHT" relativeTo="BenillaBagToggle" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
</Anchors>
<Layers><Layer level="BACKGROUND"><Texture name="$parentIcon"/></Layer></Layers>
<!-- The item-push drop card (decision 0887, BenillaItemPushAnim_* above) — the ref's
$parentItemAnim, one per bag button; container id 1. -->
<Frames>
<Frame name="$parentItemAnim" hidden="true">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="$parentIcon" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnLoad>BenillaItemPushAnim_OnLoad(self, 1)</OnLoad>
<OnEvent>BenillaItemPushAnim_OnEvent(self)</OnEvent>
<OnUpdate>BenillaItemPushAnim_OnUpdate(self, arg1)</OnUpdate>
</Scripts>
</Frame>
</Frames>
<NormalTexture file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="66" y="66"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="CENTER"/></Anchors>
@@ -1501,6 +1657,24 @@
<Anchor point="RIGHT" relativeTo="BenillaBagBarSlot1" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
</Anchors>
<Layers><Layer level="BACKGROUND"><Texture name="$parentIcon"/></Layer></Layers>
<!-- The item-push drop card (decision 0887, BenillaItemPushAnim_* above) — the ref's
$parentItemAnim, one per bag button; container id 2. -->
<Frames>
<Frame name="$parentItemAnim" hidden="true">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="$parentIcon" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnLoad>BenillaItemPushAnim_OnLoad(self, 2)</OnLoad>
<OnEvent>BenillaItemPushAnim_OnEvent(self)</OnEvent>
<OnUpdate>BenillaItemPushAnim_OnUpdate(self, arg1)</OnUpdate>
</Scripts>
</Frame>
</Frames>
<NormalTexture file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="66" y="66"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="CENTER"/></Anchors>
@@ -1537,6 +1711,24 @@
<Anchor point="RIGHT" relativeTo="BenillaBagBarSlot2" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
</Anchors>
<Layers><Layer level="BACKGROUND"><Texture name="$parentIcon"/></Layer></Layers>
<!-- The item-push drop card (decision 0887, BenillaItemPushAnim_* above) — the ref's
$parentItemAnim, one per bag button; container id 3. -->
<Frames>
<Frame name="$parentItemAnim" hidden="true">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="$parentIcon" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnLoad>BenillaItemPushAnim_OnLoad(self, 3)</OnLoad>
<OnEvent>BenillaItemPushAnim_OnEvent(self)</OnEvent>
<OnUpdate>BenillaItemPushAnim_OnUpdate(self, arg1)</OnUpdate>
</Scripts>
</Frame>
</Frames>
<NormalTexture file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="66" y="66"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="CENTER"/></Anchors>
@@ -1573,6 +1765,24 @@
<Anchor point="RIGHT" relativeTo="BenillaBagBarSlot3" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
</Anchors>
<Layers><Layer level="BACKGROUND"><Texture name="$parentIcon"/></Layer></Layers>
<!-- The item-push drop card (decision 0887, BenillaItemPushAnim_* above) — the ref's
$parentItemAnim, one per bag button; container id 4. -->
<Frames>
<Frame name="$parentItemAnim" hidden="true">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="$parentIcon" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnLoad>BenillaItemPushAnim_OnLoad(self, 4)</OnLoad>
<OnEvent>BenillaItemPushAnim_OnEvent(self)</OnEvent>
<OnUpdate>BenillaItemPushAnim_OnUpdate(self, arg1)</OnUpdate>
</Scripts>
</Frame>
</Frames>
<NormalTexture file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="66" y="66"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="CENTER"/></Anchors>
@@ -1625,6 +1835,26 @@
<Anchors>
<Anchor point="RIGHT" relativeTo="BenillaBagBarSlot4" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
</Anchors>
<!-- The item-push drop card (decision 0887, BenillaItemPushAnim_* above) — the ref's
$parentItemAnim, one per bag button; container id -2 (KEYRING_CONTAINER). The ref gives
the keyring one too (MainMenuBarBagButtons.xml l.152-160), and the byte selector really
does reach it: a push landing in player-array slots 81..112 resolves to -2, not 0. -->
<Frames>
<Frame name="$parentItemAnim" hidden="true">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="$parentIcon" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnLoad>BenillaItemPushAnim_OnLoad(self, -2)</OnLoad>
<OnEvent>BenillaItemPushAnim_OnEvent(self)</OnEvent>
<OnUpdate>BenillaItemPushAnim_OnUpdate(self, arg1)</OnUpdate>
</Scripts>
</Frame>
</Frames>
<NormalTexture file="Interface\Buttons\UI-Button-KeyRing">
<TexCoords left="0" right="0.5625" top="0" bottom="0.609375"/>
</NormalTexture>
+137 -7
View File
@@ -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<f32> {
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::<AnimRateFreeze>();
}
(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<String> = 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<AuraNode> {
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()
+209 -2
View File
@@ -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::<AnimationPlayer>()
.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::<AuraNodes>().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::<AuraNodes>()
.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::<AnimRateFreeze>().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::<AnimationPlayer>()
.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::<AnimRateFreeze>().is_none());
}
+16 -2
View File
@@ -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);
}
+12 -1
View File
@@ -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),
);
}
}
@@ -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(),
+6 -12
View File
@@ -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);
@@ -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
@@ -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())],
});
}
@@ -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<Entity>) -> 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
+6 -1
View File
@@ -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).
+3
View File
@@ -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,
+3
View File
@@ -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,
+4 -1
View File
@@ -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,
@@ -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<FxDecay>,
)>,
) {
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::<FxAnimLife>();
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=<path>`, 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<f32> {
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(),
}
}
}
@@ -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<FxStage>,
wow_materials: &mut Assets<WowModelMaterial>,
tint_reg: &mut FxTintAnims,
ibps: &Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>,
@@ -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<u16>,
) -> Vec<Entity> {
stage: Option<FxStage>,
) -> (Vec<Entity>, 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<Entity>,
/// 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<f32>,
/// 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<SpellKitFx>,
mut units: Query<&mut FxAttached>,
fx: Option<ResMut<SpellFx>>,
time: Res<Time>,
asset_server: Res<AssetServer>,
) {
let Some(mut fx) = fx else { return };
let now = time.elapsed_secs();
// Group the edges per unit, preserving order, so each unit's instance list is written once.
let mut ops: EntityHashMap<Vec<&SpellKitFx>> = EntityHashMap::default();
for ev in events.read() {
@@ -782,23 +848,17 @@ pub(super) fn resolve_spell_fx(
spell_id,
persistent,
class,
stage,
effects,
..
} => {
// A persistent Begin REPLACES the unit's live persistent instances of the
// same (spell, class) — despawn + re-begin at one sync point, no visible
// gap — so an aura whose remove/add edges land across frames (a re-apply)
// re-arms cleanly instead of stacking.
// same (spell, class) — so an aura whose remove/add edges land across frames
// (a re-apply) re-arms cleanly instead of stacking. The replaced instance
// decays out exactly as a reaped one does: in the reference these are two
// nodes, the old one dying on its own clock while the new one is born.
if *persistent {
instances.retain(|i| {
let dies = i.persistent && i.spell_id == *spell_id && i.class == *class;
if dies {
if let Some(root) = i.root {
commands.entity(root).despawn();
}
}
!dies
});
reap_matching(&mut instances, *spell_id, *class, &fx, now, &mut commands);
}
for (tag, path) in effects {
fx.models
@@ -811,25 +871,19 @@ pub(super) fn resolve_spell_fx(
spell_id: *spell_id,
persistent: *persistent,
class: *class,
stage: *stage,
tag: *tag,
path: path.clone(),
root: None,
expires: None,
decaying: false,
});
}
}
SpellKitFx::Reap {
spell_id, class, ..
} => {
instances.retain(|i| {
let dies = i.persistent && i.spell_id == *spell_id && i.class == *class;
if dies {
if let Some(root) = i.root {
commands.entity(root).despawn();
}
}
!dies
});
reap_matching(&mut instances, *spell_id, *class, &fx, now, &mut commands);
}
}
}
@@ -842,6 +896,44 @@ pub(super) fn resolve_spell_fx(
}
}
/// The spell-id-keyed reap walk (`0x614150`): every live persistent instance of `(spell_id, class)`
/// **plays its `Decay` out** and is torn down when that span ends — the reference's three gates in
/// order (`0x614187`–`0x6141a1`: a model handle, a ready model, and a `Decay` the model actually
/// authors), with an immediate destroy whenever any of them fails.
///
/// An instance already decaying is skipped: it is dying, and in the reference it has been moved off
/// the unit's `+0xb4` list to the pending-destroy list, so a second reap cannot reach it either.
/// One that never spawned a root has nothing to play and goes at once.
fn reap_matching(
instances: &mut Vec<FxInstance>,
spell_id: u32,
class: FxClass,
fx: &SpellFx,
now: f32,
commands: &mut Commands,
) {
instances.retain_mut(|i| {
if i.decaying || !i.persistent || i.spell_id != spell_id || i.class != class {
return true;
}
let span = i
.root
.and_then(|_| fx.models.get(&i.path))
.and_then(|dm| decay_span(dm.animations.as_ref()));
let (Some(root), Some(span)) = (i.root, span) else {
if let Some(root) = i.root {
commands.entity(root).despawn();
}
return false;
};
commands.entity(root).try_insert(FxDecay);
i.decaying = true;
i.expires = Some(now + span);
lifecycle::trace_leg("decay", root, lifecycle::ANIM_DECAY);
true
});
}
/// Spawn pending instances whose model finished building, and run the self-termination clock.
/// The instance root is a child of the attach joint at the attachment offset (the cascade above),
/// so the whole effect — meshes and particle emitters alike — rides the animating bone. The one
@@ -962,11 +1054,13 @@ pub(super) fn attach_spell_fx(
attached: true,
parent: Some(unit),
},
// A kit effect IS a `CEffect`: it runs the stage's animation lifecycle.
Some(inst.stage),
&mut wow_materials,
&mut tint_reg,
&ibps,
&mut palettes,
None, // an attach-point kit effect runs its default (first) clip
None, // an attach-point kit effect opens on its model's own `Stand`
);
inst.root = Some(root);
if !inst.persistent {
+1 -1
View File
@@ -807,7 +807,7 @@ pub(super) fn apply_net_updates(
SessionEvent::LootReleaseResponse { guid } => {
loot_release_response(guid, &mut loot, &mut loot_latch)
}
SessionEvent::ItemPushResult(p) => item_push_result(p, &mut loot),
SessionEvent::ItemPushResult(p) => item_push_result(p, &self_guid, &mut loot),
// ── The group-loot roll family (decision 0591) — the GroupLootFrame feed ───────────
SessionEvent::LootStartRoll(p) => loot_start_roll(p, &mut loot_rolls),
SessionEvent::LootRoll(p) => loot_roll(p, &mut loot_rolls),
+73 -4
View File
@@ -10,6 +10,7 @@ use benilla_protocol::messages::{
use benilla_protocol::ItemInfo;
use bevy::prelude::*;
use super::super::SelfGuid;
use crate::items::Items;
use crate::pending_item_ops::{LockClearedByFailure, PendingItemOps};
use crate::ui_items::EquipErrors;
@@ -116,14 +117,41 @@ pub(super) fn loot_all_passed(p: LootAllPassed, rolls: &mut LootRolls) {
rolls.all_passed(p);
}
/// Is this push **ours**? The outermost gate in `CGGameUI::OnItemPush` (1.12.1 `WoW.exe` 5875,
/// `0x491a60`): `0x491b56`/`0x491b61` compare the packet guid against the active player's and fork,
/// and the self arm is the only one that prints "You receive …" *or* animates a bag button. A group
/// member's push takes the `0x491d9f` arm and prints `LOOT_ITEM` ("%s receives loot: %s.") with
/// *their* name — a line we do not build yet, so a foreign push is dropped rather than mislabelled
/// ours.
///
/// The wire's **`showInChat`** used to be folded in here, which read correctly while the chat line
/// was this packet's only output. It is a *later*, narrower gate — `0x491bf3` (self) / `0x491db1`
/// (other) skip the chat formatter alone, after the `ITEM_PUSH` fire at `0x491be8` — so it now
/// rides into [`LootState::push_receive`] as `PendingReceive::in_chat` and silences the line
/// without touching the animation (decision 0887). vmangos always sends 1, so it is inert against
/// our server either way; it is the client's own gate, kept where the client keeps it.
fn is_our_push(p: &ItemPushResult, self_guid: &SelfGuid) -> bool {
self_guid.0 == Some(p.player_guid)
}
/// An item landed in our bags — looted or received from an NPC (`SMSG_ITEM_PUSH_RESULT`); drives
/// the "You receive loot: …" chat line.
pub(super) fn item_push_result(p: ItemPushResult, loot: &mut LootState) {
/// the "You receive loot: …" chat line (gated by [`prints_receive_line`]) **and** the bag-bar drop
/// animation (decision 0887), which is NOT so gated — hence the whole packet going through, and the
/// self check being the only thing that can stop a push here. The reference's
/// `CGGameUI::OnItemPush 0x491a60` emits both from this one packet: it returns early only on a guid
/// mismatch, and tests `showInChat` further down, after the `ITEM_PUSH` fire.
pub(super) fn item_push_result(p: ItemPushResult, self_guid: &SelfGuid, loot: &mut LootState) {
// Solo loot never gets a MONEY_NOTIFY from this server (vmangos comments it out;
// the purse rides the ordinary COINAGE field flush) — so this push line is the
// one reliable "it landed" signal, surfaced as the "You receive loot/item" line.
debug!("net: item push {} x{}", p.item_entry, p.count);
loot.push_receive(p.item_entry, p.count, p.from_npc, p.created);
debug!(
"net: item push {} x{} → bag {} slot {:#x}",
p.item_entry, p.count, p.bag_slot, p.item_slot
);
if !is_our_push(&p, self_guid) {
return;
}
loot.push_receive(&p);
}
/// An item template's display head (`SMSG_ITEM_QUERY_SINGLE_RESPONSE`, answering our
@@ -156,3 +184,44 @@ pub(super) fn inventory_failure(
equip_errors.0.push((reason, required_level));
lock_cleared.0.extend(pending.clear_by_failure(item_guid));
}
#[cfg(test)]
mod tests {
use super::*;
const ME: u64 = 0x0000_0000_0000_002A;
const THEM: u64 = 0x0000_0000_0000_00FF;
fn push(player_guid: u64, show_in_chat: bool) -> ItemPushResult {
ItemPushResult {
player_guid,
from_npc: false,
created: false,
show_in_chat,
bag_slot: 0xFF,
item_slot: 0,
item_entry: 2589,
suffix_factor: 0,
random_property_id: 0,
count: 1,
}
}
/// `OnItemPush`'s outermost gate: the push must be **ours** to produce anything at all. A party
/// member's drop must NOT print as "You receive loot" — the real client takes its other-player
/// arm and names them instead.
#[test]
fn only_our_own_pushes_reach_the_receive_queue() {
let me = SelfGuid(Some(ME));
assert!(is_our_push(&push(ME, true), &me));
assert!(!is_our_push(&push(THEM, true), &me));
// Before login lands a guid there is no active player to match against.
assert!(!is_our_push(&push(ME, true), &SelfGuid(None)));
// `showInChat` is NOT this gate (decision 0887): a silent push still queues, and still
// animates — it only loses its chat line, downstream in `drain_receives`.
assert!(is_our_push(&push(ME, false), &me));
let mut loot = LootState::default();
item_push_result(push(ME, false), &me, &mut loot);
assert_eq!(loot.pending_receive_count(), 1);
}
}
+3 -13
View File
@@ -44,7 +44,7 @@ use crate::items::Items;
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
use crate::pending_item_ops::PendingItemOps;
use crate::portrait::PaperDollBooth;
use crate::ui_items::{find_equip_slot, quality_color};
use crate::ui_items::{find_equip_slot, item_link};
use crate::ui_script::UiInput;
/// Equipment slots, 0-based (`EQUIPMENT_SLOT_*`): the inv-slot array index of the main hand,
@@ -431,12 +431,7 @@ fn slot_view(
Some(t.name.clone()),
t.quality as i32,
t.display_info_id,
Some(format!(
"|c{}|Hitem:{}:0:0:0|h[{}]|h|r",
quality_color(t.quality),
entry,
t.name
)),
Some(item_link(entry, &t.name, t.quality)),
find_equip_slot(t.inventory_type),
t.class,
t.placeable_on_action_bar(),
@@ -528,12 +523,7 @@ fn inventory_slots(
Some(t.name.clone()),
t.quality as i32,
t.display_info_id,
Some(format!(
"|c{}|Hitem:{}:0:0:0|h[{}]|h|r",
quality_color(t.quality),
ammo_id,
t.name
)),
Some(item_link(ammo_id, &t.name, t.quality)),
),
None => (None, 0, 0, None),
};
+7 -2
View File
@@ -90,13 +90,18 @@ fn system_and_loot_lines_are_verbatim() {
.unwrap(),
"Additem: Wool Cloth added."
);
// A LOOT line arrives already composed by `ui_loot::receive_line`, item link and all; compose
// must pass the escapes through untouched (the quality colour is the link's, not the line's).
assert_eq!(
compose(
&ChatEvent::text_only(K::Loot, "You receive loot: [Tough Jerky].".into()),
&ChatEvent::text_only(
K::Loot,
"You receive loot: |cffffffff|Hitem:117:0:0:0|h[Tough Jerky]|h|r.".into()
),
K::Loot
)
.unwrap(),
"You receive loot: [Tough Jerky]."
"You receive loot: |cffffffff|Hitem:117:0:0:0|h[Tough Jerky]|h|r."
);
}
+2 -9
View File
@@ -49,7 +49,7 @@ use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore};
use crate::portrait::InspectBooth;
use crate::ui_items::quality_color;
use crate::ui_items::item_link;
use crate::ui_script::UiInput;
/// The unit tokens a UnitPopup row can name for another player — the popup menus' own set
@@ -119,14 +119,7 @@ fn inspect_slot_view(
Some(t) => (Some(t.name.clone()), t.quality, t.display_info_id),
None => (None, 0, 0),
};
let link = name.as_ref().map(|n| {
format!(
"|c{}|Hitem:{}:0:0:0|h[{}]|h|r",
quality_color(quality),
entry,
n
)
});
let link = name.as_ref().map(|n| item_link(entry, n, quality));
Some(InvSlotView {
item_id: entry,
icon: icons
+2 -7
View File
@@ -16,7 +16,7 @@ use crate::pending_item_ops::{LockClearedByFailure, PendingItemOps};
use super::equip_error::equip_error_key;
use super::{
find_equip_slot, has_key, keyring_size, quality_color, slot_guid_count, EquipErrors, BAGS,
find_equip_slot, has_key, item_link, keyring_size, slot_guid_count, EquipErrors, BAGS,
BAG_SLOT_FIRST, BANK_BAGS, BANK_BAG_ID_FIRST, BANK_CONTAINER, BANK_SLOTS, KEYRING_CONTAINER,
KEYRING_SLOTS, PACK_SLOTS,
};
@@ -475,12 +475,7 @@ fn resolve_slot(
durability,
quality: Some(t.quality),
item_id: entry,
link: Some(format!(
"|c{}|Hitem:{}:0:0:0|h[{}]|h|r",
quality_color(t.quality),
entry,
t.name
)),
link: Some(item_link(entry, &t.name, t.quality)),
locked: false,
readable,
creator,
+39 -1
View File
@@ -494,7 +494,11 @@ pub(crate) fn item_use_command(
}
/// The client's quality→color escape for an item link (`GetItemQualityColor`'s table) — shared by
/// [`feed`]'s bag links and [`crate::ui_char`]'s doll-slot links (one table, no drifting twins).
/// [`item_link`] and everything downstream of it (one table, no drifting twins).
///
/// VERIFIED against the 1.12.1 binary (`WoW.exe` 5875): `0x52ad90` indexes the seven-pointer table
/// at `0x854124` into the `|cffRRGGBB` literals at `0x8546dc`, and **clamps anything `>= 7` to
/// index 1** (white) — which is what the catch-all arm below is, not a defensive default.
pub(super) fn quality_color(quality: u32) -> &'static str {
match quality {
0 => "ff9d9d9d",
@@ -507,6 +511,40 @@ pub(super) fn quality_color(quality: u32) -> &'static str {
}
}
/// Build one item hyperlink — **the** item-link builder, the single owner of the escape shape.
///
/// VERIFIED against the 1.12.1 binary: `0x52adb0` is `SStrPrintf(dst, 0x400, fmt, …)` over
/// `fmt @0x8549c8 = "%s|Hitem:%d:%d:%d:%d|h[%s]|h%s"`, with the leading `%s` the [`quality_color`]
/// escape, the four `%d` = (item id, enchant id, random-property id, suffix factor), and the
/// trailing `%s` the `"|r"` reset (`0x844538`). Every link the client shows — bag, paperdoll,
/// inspect, loot-roll announcement, "You receive loot" — comes out of this one function, so ours
/// does too: five hand-rolled `format!` twins of this string is how one site (the receive line)
/// silently shipped a bare, uncoloured name.
pub(super) fn item_link_full(
item_id: u32,
enchant_id: u32,
random_property_id: u32,
suffix_factor: u32,
name: &str,
quality: u32,
) -> String {
format!(
"|c{}|Hitem:{item_id}:{enchant_id}:{random_property_id}:{suffix_factor}|h[{name}]|h|r",
quality_color(quality)
)
}
/// [`item_link_full`] for a caller that has no enchant/random-property ids in hand.
///
/// **Stated approximation (documented gap).** Bag, paperdoll and inspect items *do* carry an
/// enchant and a random property on the wire; we do not thread either into the link yet, and the
/// real client additionally appends the `ItemRandomProperties.dbc` suffix to the **name**
/// (`0x5d8b00`) so a random-suffix green reads "Chipped Claw of the Bear". Both are one
/// random-suffix arc, not per-call-site drift — which is why the zeros live here, once.
pub(super) fn item_link(item_id: u32, name: &str, quality: u32) -> String {
item_link_full(item_id, 0, 0, 0, name, quality)
}
/// `INVTYPE_AMMO` — the projectile/ammo inventory type (arrows, bullets). Loaded via
/// `CMSG_SET_AMMO`, not the equip-swap wire (decision 0526); the equip drains fork on it.
pub(super) const INVTYPE_AMMO: u32 = 24;
+278 -37
View File
@@ -21,14 +21,15 @@
//! → coin ? [`ClientCommand::LootMoney`] : [`ClientCommand::AutostoreLootItem`] (the clicked 1-based
//! row mapped to the item's **wire** loot slot); `CloseLoot` → [`ClientCommand::LootRelease`].
use benilla_protocol::messages::LootItem;
use benilla_protocol::messages::{ItemPushResult, LootItem, BAG_PLAYER_INVENTORY, SLOT_BAG_FIRST};
use bevy::prelude::*;
use benilla_ui::script::{LootRow, LootState as LootSnapshot, UiScript};
use benilla_ui::script::{LootRow, LootState as LootSnapshot, ScriptValue, UiScript};
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{ClientCommand, NetCommands};
use crate::ui_items::KEYRING_CONTAINER;
use crate::ui_script::UiInput;
/// The coin-pile row icons (direct `Interface\Icons` paths — `SetTexture` takes them as-is, no DBC),
@@ -53,18 +54,77 @@ const COPPER_WORD: &str = "Copper";
/// Give up re-checking a pending receive line's item name after this many frames (a negative-cached
/// or genuinely-unknown entry never resolves; ~2s at 60fps is well past a normal template round-trip).
const RECEIVE_MAX_TRIES: u16 = 120;
/// The player-array slots the reference calls "the keyring" when deciding which bag button a push
/// animates — the literal `0x51`/`0x70` bounds compiled into `OnItemPush` (`0x491bc3`/`0x491bc8`).
/// It is the **descriptor array's** full 32-guid width (vmangos `KEYRING_SLOT_START 81` + 32), not
/// the 16 addressable positions `ui_items`'s `KEYRING_SLOTS` names (81..96) — the client tests the
/// wider range, so this transcribes the client rather than deriving from the server's stricter one.
const PUSH_KEYRING_SLOTS: std::ops::RangeInclusive<u32> = 81..=112;
/// One deferred "You receive loot/item" line: the pushed item entry + count, plus the source flag
/// (looted vs from an NPC — the wording differs) and a retry budget for the item name resolving.
/// One deferred push (`SMSG_ITEM_PUSH_RESULT`) awaiting its item template — the whole
/// `CGGameUI::OnItemPush 0x491a60` tail, not just the chat line. Carries the pushed entry + count,
/// the source flags (looted / from an NPC / created — the wording differs), the two random-property
/// wire fields the item link carries, whether the line is spoken at all, the destination container
/// ([`push_container`], for the bag-bar drop animation) and a retry budget.
///
/// Deferring **both** outputs on the template is the reference's own shape: `OnItemPush` opens with
/// an item-cache lookup (`0x491a93`) and, on a miss, copies its nine arguments into a heap record and
/// re-enters itself from the cache callback (`0x491aa7`-`0x491b05`, callback `0x491ee0`) — nothing is
/// emitted until the item is known.
struct PendingReceive {
/// `SMSG_ITEM_PUSH_RESULT`'s created flag — a crafted/conjured item ("You create: …").
created: bool,
entry: u32,
count: u32,
from_npc: bool,
/// `SMSG_ITEM_PUSH_RESULT`'s `randomPropertyId` — link field 3 (see [`receive_line`]).
random_property_id: u32,
/// `SMSG_ITEM_PUSH_RESULT`'s `suffixFactor` — link field 4 (see [`receive_line`]).
suffix_factor: u32,
/// The wire's `showInChat` — whether the chat LINE is spoken. It gates the line ONLY: the
/// reference fires `ITEM_PUSH` at `0x491be8`, before the `[ebx+0x24]` test at `0x491bf3` that
/// guards the whole chat block, so a silent push still animates. Which is why this rides in the
/// record instead of short-circuiting at the net bridge (decision 0887).
in_chat: bool,
/// Which bag-bar button the drop animation plays on — see [`push_container`].
container: i64,
tries: u16,
}
/// The wire push's `(bag, slot)` → the live-API **container id** the bag bar speaks (`0` backpack,
/// `1..=4` an equipped bag, [`KEYRING_CONTAINER`] the keyring) — `ITEM_PUSH`'s `arg1`.
///
/// **Byte-VERIFIED** at `CGGameUI::OnItemPush` `0x491bb5`-`0x491bd6`, which is the whole selector:
///
/// ```text
/// 491bb5 cmp edi,0xff ; edi = the wire `bag` byte
/// 491bbb lea eax,[edi+1] ; bag != 255 ⇒ bag + 1
/// 491bbe jne 491bd6
/// 491bc0 mov eax,[ebp-0x4] ; else look at the wire `slot`
/// 491bc3 cmp eax,0x51 ; 81 = vmangos KEYRING_SLOT_START
/// 491bc6 jl 491bd4
/// 491bc8 cmp eax,0x70 ; 112 = the last keyring position
/// 491bcb jg 491bd4
/// 491bcd mov eax,-2 ; ⇒ KEYRING_CONTAINER
/// 491bd4 xor eax,eax ; ⇒ 0 (the backpack)
/// ```
///
/// The one translation: the reference emits `bag + 1`, which is the **inventory-slot** id its bag
/// buttons carry (`CharacterBag0Slot` = 20 … `Bag3Slot` = 23, `GetInventorySlotInfo`); benilla's bag
/// bar is keyed by container id like every other bag surface here (`BAG_UPDATE(bagID)`,
/// `BenillaBagBarSlot_InvSlot` = `19 + bagId`), so the same wire slots 19..22 become `1..=4`. Both
/// vocabularies agree on `0` and `-2`, and both leave a non-equipped-bag container (a bank bag,
/// wire 63..68) landing on an id no bag-bar button carries — no animation, exactly as there.
fn push_container(bag: u8, slot: u32) -> i64 {
if bag != BAG_PLAYER_INVENTORY {
return i64::from(bag) - i64::from(SLOT_BAG_FIRST) + 1;
}
if PUSH_KEYRING_SLOTS.contains(&slot) {
return KEYRING_CONTAINER;
}
0
}
/// The open loot, filled by the net bridge and read by [`feed_loot`]. Holds the looted guid, the coin
/// pile, and the rows exactly as the wire delivered them (`SMSG_LOOT_RESPONSE`); the feed resolves
/// each to a display row and the drain maps a clicked 1-based row to its wire loot slot. Cleared on
@@ -138,13 +198,20 @@ impl LootState {
std::mem::take(&mut self.auto_release)
}
/// Queue a deferred "You receive …" line for a pushed item (`SMSG_ITEM_PUSH_RESULT`).
pub(crate) fn push_receive(&mut self, entry: u32, count: u32, from_npc: bool, created: bool) {
/// Queue a deferred push (`SMSG_ITEM_PUSH_RESULT`) — the "You receive …" line *and* the bag-bar
/// drop animation, emitted together once the item template lands (see [`PendingReceive`]). The
/// **self** gate lives at the net bridge ([`crate::net::apply`]); the `showInChat` gate does
/// not — it rides in as `in_chat` and silences the line alone, leaving the animation to play.
pub(crate) fn push_receive(&mut self, p: &ItemPushResult) {
self.receives.push(PendingReceive {
created,
entry,
count,
from_npc,
created: p.created,
entry: p.item_entry,
count: p.count,
from_npc: p.from_npc,
random_property_id: p.random_property_id,
suffix_factor: p.suffix_factor,
in_chat: p.show_in_chat,
container: push_container(p.bag_slot, p.item_slot),
tries: 0,
});
}
@@ -165,6 +232,14 @@ impl LootState {
self.receives.clear();
}
/// How many pushes are queued awaiting their item template — the net bridge's test hook for
/// "did this packet get through the self gate", since neither output is emitted until the
/// template lands.
#[cfg(test)]
pub(crate) fn pending_receive_count(&self) -> usize {
self.receives.len()
}
/// Whether a coin row is shown (position 1 when present).
fn has_coin(&self) -> bool {
self.gold > 0
@@ -350,40 +425,97 @@ fn snapshot(
Some(LootSnapshot { rows })
}
/// Surface any pending receive lines (`SMSG_ITEM_PUSH_RESULT`) in the chat window (decision 0084's
/// chat arc — these migrated off the ErrorsFrame stopgap) once their item name resolves, colored
/// `LOOT` green; unresolved lines retry up to [`RECEIVE_MAX_TRIES`] frames, then drop.
/// Compose one `CHAT_MSG_LOOT` receive line — the whole of `CGGameUI::OnItemPush`'s self branch,
/// VERIFIED against the 1.12.1 client binary (`WoW.exe` 5875, `0x491a60`, self arm `0x491bfb`).
///
/// The mechanic the line hangs on, and the reason the item name is **not** LOOT-green: the `%s` the
/// GlobalString takes is a full **item link**, not a bare name. `0x491c43`/`0x491ca3` call the link
/// builder `0x52adb0` ([`crate::ui_items::item_link_full`] is our transcription of it) with the item
/// id, enchant `0`, the wire `randomPropertyId`, the wire `suffixFactor`, and the resolved name. The
/// link's `|r` closes right after the `]`, so the count that follows is drawn in the line's own
/// colour — exactly the reference client's `[Chipped Claw]x2.`
///
/// The format strings are QUOTED from the extracted `Interface\FrameXML\GlobalStrings.lua`
/// (patch-2.MPQ, l.2599-2605): `LOOT_ITEM_SELF = "You receive loot: %s."` /
/// `LOOT_ITEM_SELF_MULTIPLE = "You receive loot: %sx%d."` and the PUSHED_SELF / CREATED_SELF twins —
/// note there is **no space** before the `x%d`. The key is selected by the wire's (created,
/// received) pair with created winning (`0x491c04`-`0x491c1b` / `0x491c60`-`0x491c77`), exactly as
/// the server sets them (vmangos `SendNewItem`: crafting → created, vendor/quest → received,
/// loot → neither).
fn receive_line(r: &PendingReceive, name: &str, quality: u32) -> String {
let verb = if r.created {
"You create"
} else if r.from_npc {
"You receive item"
} else {
"You receive loot"
};
let link = crate::ui_items::item_link_full(
r.entry,
0,
r.random_property_id,
r.suffix_factor,
name,
quality,
);
if r.count > 1 {
format!("{verb}: {link}x{}.", r.count)
} else {
format!("{verb}: {link}.")
}
}
/// Emit any pending pushes (`SMSG_ITEM_PUSH_RESULT`) once their item template resolves — the whole
/// `CGGameUI::OnItemPush` tail, in the reference's own order: the bag-bar drop animation first
/// (`ITEM_PUSH(container, icon)`, fired at `0x491be8` *before* the chat block, decision 0887), then —
/// if the wire asked for it — the "You receive …" line in the chat window (decision 0084's chat arc),
/// a `LOOT`-green line carrying a quality-coloured item link ([`receive_line`], decision 0888).
/// Unresolved pushes retry up to [`RECEIVE_MAX_TRIES`] frames, then drop (the reference instead
/// sleeps on the item-cache callback, so it never gives up — a stated divergence that only shows on
/// an entry the server never answers for).
fn drain_receives(
loot: &mut LootState,
items: &mut Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
chat: &mut crate::ui_chat::ChatLog,
script: &mut UiScript,
) {
let pending = std::mem::take(&mut loot.receives);
let mut still = Vec::new();
for mut r in pending {
match items.template(r.entry, 0, commands).map(|t| t.name.clone()) {
Some(name) => {
// The three 1.12 verbs (GlobalStrings LOOT_ITEM_CREATED_SELF /
// LOOT_ITEM_PUSHED_SELF / LOOT_ITEM_SELF), selected by the wire's
// (created, received) pair exactly as the server sets them (vmangos
// SendNewItem: crafting → created, vendor/quest → received, loot → neither).
let verb = if r.created {
"You create"
} else if r.from_npc {
"You receive item"
} else {
"You receive loot"
};
let line = if r.count > 1 {
format!("{verb}: [{name}] x{}.", r.count)
} else {
format!("{verb}: [{name}].")
};
chat.push_event(crate::ui_chat::ChatEvent::text_only(
crate::ui_chat::ChatEventKind::Loot,
line,
));
// One template read serves all three outputs: the name and quality for the line, the
// display id for the animation's icon.
let resolved = items
.template(r.entry, 0, commands)
.map(|t| (t.name.clone(), t.quality, t.display_info_id));
match resolved {
Some((name, quality, display_id)) => {
// `ITEM_PUSH` is UNGATED by `show_in_chat` and by the created/received pair: the
// fire at `0x491be8` precedes the `[ebx+0x24]` chat test at `0x491bf3`, so every
// push that reaches us animates, including a silent one. `arg2` is the item's icon
// path — the reference composes it from the icon directory + the display record's
// own name (`0x491baa`, "%s%s%s"); ours is the same path out of the
// `ItemDisplayInfo.dbc` catalog the bags and the loot window already read.
let icon = icons
.and_then(|i| i.catalog.get(display_id))
.and_then(|d| d.icon.clone());
if let Some(icon) = icon {
debug!(
"ui_loot: ITEM_PUSH container {} icon {icon} (item {})",
r.container, r.entry
);
script.fire_event(
"ITEM_PUSH",
vec![ScriptValue::Int(r.container), ScriptValue::Str(icon)],
);
}
if r.in_chat {
chat.push_event(crate::ui_chat::ChatEvent::text_only(
crate::ui_chat::ChatEventKind::Loot,
receive_line(&r, &name, quality),
));
}
}
None => {
r.tries += 1;
@@ -422,7 +554,14 @@ fn feed_loot(
loot_error_text(reason),
));
}
drain_receives(&mut loot, &mut items, &commands, &mut chat);
drain_receives(
&mut loot,
&mut items,
icons.as_deref(),
&commands,
&mut chat,
&mut script,
);
let fresh = snapshot(&loot, &mut items, icons.as_deref(), &commands);
if fresh == *last {
@@ -519,6 +658,29 @@ mod tests {
}
}
/// One `SMSG_ITEM_PUSH_RESULT` as the loot path sees it (self, shown in chat, no random
/// property) — the net bridge's guid/`showInChat` gates are tested at their own seam.
fn push(entry: u32, count: u32, from_npc: bool, created: bool) -> ItemPushResult {
ItemPushResult {
player_guid: 0x1,
from_npc,
created,
show_in_chat: true,
bag_slot: 0xFF,
item_slot: 0,
item_entry: entry,
suffix_factor: 0,
random_property_id: 0,
count,
}
}
fn pending(entry: u32, count: u32, from_npc: bool, created: bool) -> PendingReceive {
let mut loot = LootState::default();
loot.push_receive(&push(entry, count, from_npc, created));
loot.receives.pop().expect("queued")
}
#[test]
fn action_maps_coin_first_then_items_by_wire_slot() {
let mut loot = LootState::default();
@@ -655,7 +817,7 @@ mod tests {
fn clear_closes_but_keeps_receives() {
let mut loot = LootState::default();
loot.open(0x42, 0, vec![item(0, 117, 1)]);
loot.push_receive(117, 1, false, false);
loot.push_receive(&push(117, 1, false, false));
loot.clear();
assert!(loot.source.is_none());
assert_eq!(loot.receives.len(), 1, "a receive line outlives the window");
@@ -663,6 +825,85 @@ mod tests {
assert!(loot.receives.is_empty(), "disconnect drops receive lines");
}
/// The receive line is an item **link**, not a bare name: the quality escape opens before the
/// `[` and `|r` closes after the `]`, so the count that follows falls back to the line's own
/// LOOT green. This is the shape the reference client prints (`[Chipped Claw]x2.` — white name,
/// green `x2`), and the reason the name must not inherit the chat colour.
#[test]
fn receive_line_carries_a_quality_colored_item_link() {
// Common/white, single — LOOT_ITEM_SELF = "You receive loot: %s.".
assert_eq!(
receive_line(&pending(2589, 1, false, false), "Linen Cloth", 1),
"You receive loot: |cffffffff|Hitem:2589:0:0:0|h[Linen Cloth]|h|r."
);
// Poor/grey, stacked — LOOT_ITEM_SELF_MULTIPLE = "You receive loot: %sx%d." and the `x2`
// sits OUTSIDE the escape, with no space before it (GlobalStrings.lua l.2605, verbatim).
assert_eq!(
receive_line(&pending(7092, 2, false, false), "Chipped Claw", 0),
"You receive loot: |cff9d9d9d|Hitem:7092:0:0:0|h[Chipped Claw]|h|rx2."
);
// The (created, received) pair picks the verb, created winning over received.
assert_eq!(
receive_line(&pending(4306, 1, true, false), "Silk Cloth", 2),
"You receive item: |cff1eff00|Hitem:4306:0:0:0|h[Silk Cloth]|h|r."
);
assert_eq!(
receive_line(&pending(2320, 4, true, true), "Coarse Thread", 1),
"You create: |cffffffff|Hitem:2320:0:0:0|h[Coarse Thread]|h|rx4."
);
}
/// The link's two random-property fields ride the wire straight through
/// (`|Hitem:id:0:randomPropertyId:suffixFactor|h` — `0x52adb0`'s arg order).
#[test]
fn receive_line_carries_the_wire_random_property_fields() {
let mut loot = LootState::default();
loot.push_receive(&ItemPushResult {
random_property_id: 862,
suffix_factor: 1234,
..push(15268, 1, false, false)
});
let r = loot.receives.pop().expect("queued");
assert_eq!(
receive_line(&r, "Bloodrazor", 3),
"You receive loot: |cff0070dd|Hitem:15268:0:862:1234|h[Bloodrazor]|h|r."
);
}
/// The client clamps a quality outside the table to index 1 (white) — `0x52ad90`'s
/// `cmpl $0x7 / jb` arm.
#[test]
fn receive_line_clamps_an_out_of_range_quality_to_white() {
assert_eq!(
receive_line(&pending(1, 1, false, false), "Odd Thing", 9),
"You receive loot: |cffffffff|Hitem:1:0:0:0|h[Odd Thing]|h|r."
);
}
/// [`push_container`] against the reference selector at `0x491bb5`-`0x491bd6`, arm by arm — the
/// value that decides which bag button the drop animation plays on.
#[test]
fn push_container_maps_the_wire_destination_onto_a_bag_bar_button() {
// `bag != 255` — an equipped bag. Wire 19..22 are the four bag inventory slots; the
// reference emits 20..23 (its buttons' inventory-slot ids), ours the container ids 1..=4.
assert_eq!(push_container(19, 0), 1);
assert_eq!(push_container(22, 5), 4);
// `bag == 255` with a keyring slot (the client's own 0x51..=0x70 window) — the keyring.
assert_eq!(push_container(BAG_PLAYER_INVENTORY, 81), KEYRING_CONTAINER);
assert_eq!(push_container(BAG_PLAYER_INVENTORY, 96), KEYRING_CONTAINER);
assert_eq!(push_container(BAG_PLAYER_INVENTORY, 112), KEYRING_CONTAINER);
// `bag == 255` anywhere else — the backpack. 23..38 are its own slots; 80 and 113 are the
// positions just outside the keyring window, which the client resolves to 0, not -2.
assert_eq!(push_container(BAG_PLAYER_INVENTORY, 23), 0);
assert_eq!(push_container(BAG_PLAYER_INVENTORY, 80), 0);
assert_eq!(push_container(BAG_PLAYER_INVENTORY, 113), 0);
// A stack-merge reports no slot at all (`0xFFFF_FFFF`) — still the backpack, still animates.
assert_eq!(push_container(BAG_PLAYER_INVENTORY, u32::MAX), 0);
// A bank bag (wire 63..68) lands on an id no bag-bar button carries: no animation, which is
// exactly what the reference's `bag + 1` does with the same push.
assert!(!(0..=4).contains(&push_container(63, 0)));
}
#[test]
fn money_formats_with_real_words_dropping_zero_denominations() {
assert_eq!(format_money(0), "0 Copper");
+1 -11
View File
@@ -64,6 +64,7 @@ use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands, SelfGuid};
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
use crate::ui_items::item_link;
use crate::ui_script::UiInput;
/// Give up re-checking a pending announcement's names after this many frames — the same budget and
@@ -237,17 +238,6 @@ impl Plugin for UiLootRollPlugin {
}
}
/// The item link a roll line embeds — the `%s|Hitem:%d:%d:%d:%d|h[%s]|h%s` the `LOOT_ROLL_*`
/// GlobalStrings all share, with the leading `%s` the quality color escape and the trailing `%s`
/// the `|r` reset (the same link shape [`crate::ui_items`]'s bag links and [`crate::ui_char`]'s
/// doll-slot links build — one helper each, one table behind them).
fn item_link(item_id: u32, name: &str, quality: u32) -> String {
format!(
"|c{}|Hitem:{item_id}:0:0:0|h[{name}]|h|r",
crate::ui_items::quality_color(quality)
)
}
/// Pick and fill the announcement's chat string, given the pieces already resolved: the roller's
/// `name` (present whenever the chosen string needs one — see [`render`]), whether the line is about
/// *us* (`is_self`), and the item `link`.
+76 -23
View File
@@ -21,6 +21,7 @@ use bevy::prelude::*;
use benilla_protocol::messages::{MirrorTimerKind, MirrorTimerStart};
use crate::ui_action::Spells;
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
@@ -57,38 +58,75 @@ fn script_name(kind: MirrorTimerKind) -> &'static str {
}
}
/// The bar's caption — **arg6** of `MIRROR_TIMER_START`.
/// The static caption for a timer with **no owning spell** — the `GlobalStrings.lua` value the
/// reference's `<NAME>_LABEL` lookup resolves to.
///
/// The reference builds the global-string name by formatting `"%s_LABEL"` (`WoW.exe` @`0x460540`,
/// sitting immediately after the name table above) with the timer name and looking the result up
/// in the FrameScript globals. The 1.12 `GlobalStrings.lua` defines exactly two of the three —
/// `BREATH_LABEL = "Breath"` and `EXHAUSTION_LABEL = "Fatigue"`, each with the comment "Used as
/// the label for the … status bar" — and **no** `FEIGNDEATH_LABEL`, so that timer's caption comes
/// back empty in the reference too.
/// The 1.12 `GlobalStrings.lua` defines exactly two of the three: `BREATH_LABEL = "Breath"` and
/// `EXHAUSTION_LABEL = "Fatigue"` (each commented "Used as the label for the … status bar"), and
/// **no** `FEIGNDEATH_LABEL`. The reference's `GetGlobalString 0x703bf0` never returns NULL — it
/// pre-seeds a static empty string (`0x882748`) — so the missing one is the **empty caption**, not
/// a nil the Lua would trip over.
///
/// The strings are inlined here rather than looked up through the VM for the same reason
/// `CastingBar.xml` inlines `FAILED`/`INTERRUPTED`: benilla loads no `GlobalStrings.lua` yet. When
/// it does, this becomes the lookup the reference does.
fn caption(kind: MirrorTimerKind) -> &'static str {
/// Inlined here rather than looked up through the VM for the same reason `CastingBar.xml` inlines
/// `FAILED`/`INTERRUPTED`: benilla loads no `GlobalStrings.lua` yet.
fn global_string_label(kind: MirrorTimerKind) -> &'static str {
match kind {
MirrorTimerKind::Fatigue => "Fatigue",
MirrorTimerKind::Breath => "Breath",
// No FEIGNDEATH_LABEL exists in the 1.12 GlobalStrings — the reference shows no caption.
MirrorTimerKind::FeignDeath => "",
}
}
/// The bar's caption — **arg6** of `MIRROR_TIMER_START`, and it is **not** a fixed word.
///
/// §5-VERIFIED (wow-re `object-layer/scratch/mirror-timer.md`, the 2026-08-02 cross-check of
/// handler `0x5e7990` and its label helper `0x5e7b10`): the client tries the **owning spell's
/// localized name first** — `Spell.dbc` `SpellRec + 0x1e0 + 4*locale`, indexed by the START
/// packet's `spellId` — and only falls back to the `"<NAME>_LABEL"` global string when there is
/// no spell (`spellId == 0`).
///
/// That correction matters in play, and 0874 had it wrong: a water-breathing effect owns the
/// breath timer for its duration (vmangos `UpdateMirrorTimers` starts the timer from
/// `GetMirrorTimerBuff(type)` and passes `buff->GetId()`), so while it is up the bar is captioned
/// with **the spell's own name**, not "Breath". The bar's colour still keys off the timer name,
/// so only the word changes.
///
/// `spell_name` is the already-resolved catalog lookup (the `ui_cast` idiom: the script VM has no
/// spell-catalog binding, so the drain resolves it — one lookup face, decision 0107). `None`
/// covers both "no owning spell" and "spell not in the catalog"; the reference's fallback chain
/// ends at the global string either way.
fn caption(kind: MirrorTimerKind, spell_name: Option<&str>) -> String {
spell_name
.filter(|n| !n.is_empty())
.unwrap_or_else(|| global_string_label(kind))
.to_string()
}
/// Drain the queue into the script VM, one FrameScript event per edge.
///
/// A `kind` the client has no bar for is dropped: the server's own `NUM_CLIENT_TIMERS` gate means
/// vanilla never sends one (its fourth, `ENVIRONMENTAL`, drives lava damage with no bar), and the
/// reference would index past the end of a 3-entry table if one arrived.
fn feed_mirror_timers(script: Option<NonSendMut<UiScript>>, mut feed: ResMut<MirrorTimerFeed>) {
/// A `kind` the client has no bar for is **dropped**. The reference does fire the event for one —
/// its type→name switch (`0x5e7ae0`) answers `"UNKNOWN"` for anything outside 0..2 — but
/// `MirrorTimerColors["UNKNOWN"]` is nil, so `MirrorTimer_Show` errors at the colour read
/// *before* `dialog:Show()` and no bar ever appears. Dropping it is the same observable without
/// the Lua error, and vanilla never sends one anyway (the server's `NUM_CLIENT_TIMERS` gate keeps
/// its fourth timer, `ENVIRONMENTAL`, off the wire entirely).
fn feed_mirror_timers(
script: Option<NonSendMut<UiScript>>,
mut feed: ResMut<MirrorTimerFeed>,
spells: Option<Res<Spells>>,
) {
let Some(mut script) = script else {
// No VM (a capture/headless run): drop the edges rather than let them pile up unbounded.
feed.0.clear();
return;
};
// The owning spell's name, resolved here because the script VM has no spell-catalog binding
// (the `ui_cast` idiom, decision 0107). `0` = no spell, which is the common case.
let spell_name = |id: u32| -> Option<String> {
(id != 0)
.then(|| spells.as_ref()?.catalog.get(id).map(|d| d.name.clone()))
.flatten()
};
for edge in feed.0.drain(..) {
let raw = match edge {
MirrorTimerEdge::Start(start) => start.kind,
@@ -107,7 +145,7 @@ fn feed_mirror_timers(script: Option<NonSendMut<UiScript>>, mut feed: ResMut<Mir
ScriptValue::Int(i64::from(start.duration_ms)),
ScriptValue::Int(i64::from(start.scale)),
ScriptValue::Int(i64::from(start.paused)),
ScriptValue::Str(caption(kind).into()),
ScriptValue::Str(caption(kind, spell_name(start.spell_id).as_deref())),
],
),
MirrorTimerEdge::Pause { paused, .. } => (
@@ -145,12 +183,27 @@ mod tests {
assert_eq!(script_name(MirrorTimerKind::FeignDeath), "FEIGNDEATH");
}
/// The captions are the 1.12 `GlobalStrings.lua` values the `%s_LABEL` lookup resolves to —
/// and the feign-death one is empty because that global does not exist.
/// With no owning spell the caption is the 1.12 `GlobalStrings.lua` value the `<NAME>_LABEL`
/// lookup resolves to — and the feign-death one is empty because that global does not exist
/// (the reference's `GetGlobalString` hands back a static empty string, never nil).
#[test]
fn captions_are_the_globalstrings_values() {
assert_eq!(caption(MirrorTimerKind::Fatigue), "Fatigue");
assert_eq!(caption(MirrorTimerKind::Breath), "Breath");
assert_eq!(caption(MirrorTimerKind::FeignDeath), "");
fn a_spell_less_timer_captions_from_the_global_string() {
assert_eq!(caption(MirrorTimerKind::Fatigue, None), "Fatigue");
assert_eq!(caption(MirrorTimerKind::Breath, None), "Breath");
assert_eq!(caption(MirrorTimerKind::FeignDeath, None), "");
}
/// The correction 0874 got wrong (§5, wow-re `mirror-timer.md`): the client tries the OWNING
/// SPELL's localized name first and only falls back to the global string. A water-breathing
/// effect owns the breath timer while it is up, so the bar reads with the spell's name.
#[test]
fn an_owning_spell_captions_the_bar_with_its_own_name() {
assert_eq!(
caption(MirrorTimerKind::Breath, Some("Water Breathing")),
"Water Breathing"
);
// An id the catalog can't resolve falls back exactly as a spell-less timer does.
assert_eq!(caption(MirrorTimerKind::Breath, None), "Breath");
assert_eq!(caption(MirrorTimerKind::Breath, Some("")), "Breath");
}
}
@@ -455,12 +455,13 @@ fn shipped_bag_frame_drives_end_to_end() {
report.errors
);
assert_eq!(
report.frames, 253,
report.frames, 259,
"backpack (window + 16 slots × 2 [button + Cooldown child] + 3 money coins \
+ close = 37) + 4 equipped-bag windows (window + 20 slots × 2 + close = 42 each = 168) \
+ the KEYRING window (another 42 — the same template, decision 0765) \
+ the bag-bar toggle (1) + its 4 bar slots (4) + the keyring button (1) \
— 0216 slice 2 + the slot Cooldown children"
— 0216 slice 2 + the slot Cooldown children \
+ one $parentItemAnim card per bag-bar button (6 — decision 0887)"
);
// The re-skinned bag purse reuses BenillaMoney_Set/_Clear (they live in MerchantFrame.xml); load
// it too so the shared helper is defined when BenillaBagFrame_Update runs (the same reuse the loot
+191
View File
@@ -1519,3 +1519,194 @@ fn a_key_dropped_on_the_button_files_itself() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The item-push drop animation (decision 0887): `ITEM_PUSH(container, icon)` runs the pushed item's
/// icon down into **that** container's bag-bar button and nobody else's, along the curves read out of
/// `ForcedBackpackItem.m2` — pop in, hang, fall, shrink away — and hides itself at the end.
///
/// This is the whole observable contract of `BenillaItemPushAnim_*`: the routing (which button), the
/// motion (starts a fall above the button, lands centred on it), the fade/scale shape (the file's
/// keys), and the CLAMP end (one play, then gone). It is also the regression net for the OnUpdate
/// gate — an anim frame left shown would keep ticking forever.
#[test]
fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1600.0, 900.0);
for file in [
"Fonts.xml",
"UiPanels.xml",
"MerchantFrame.xml",
"Cooldown.xml",
"ActionBar.xml",
"BagFrame.xml",
] {
load_xml(&s, file);
}
s.resolve();
let shown = |s: &UiScript, name: &str| {
s.eval::<bool>(&format!("return {name}ItemAnim:IsShown()"))
.unwrap()
};
// The card's centre relative to its button's centre, in screen px (y-up): (dx, dy).
let offset = |s: &UiScript, name: &str| -> (f32, f32) {
s.eval::<(f32, f32)>(&format!(
"local a, b = {name}ItemAnim, {name} \
local ax, ay = a:GetLeft() + a:GetWidth() / 2, a:GetBottom() + a:GetHeight() / 2 \
local bx, by = b:GetLeft() + b:GetWidth() / 2, b:GetBottom() + b:GetHeight() / 2 \
return ax - bx, ay - by"
))
.unwrap()
};
let size = |s: &UiScript, name: &str| {
s.eval::<f32>(&format!("return {name}ItemAnim:GetWidth()"))
.unwrap()
};
let alpha = |s: &UiScript, name: &str| {
s.eval::<f32>(&format!("return {name}ItemAnim:GetAlpha()"))
.unwrap()
};
// Nothing is animating until a push arrives.
for b in [
"BenillaBagToggle",
"BenillaBagBarSlot2",
"BenillaKeyRingButton",
] {
assert!(!shown(&s, b), "{b}'s card starts hidden");
}
// A push into equipped bag 2.
s.fire_event(
"ITEM_PUSH",
vec![
benilla_ui::script::ScriptValue::Int(2),
benilla_ui::script::ScriptValue::Str("Interface\\Icons\\INV_Misc_Bag_08".into()),
],
);
s.resolve();
assert!(shown(&s, "BenillaBagBarSlot2"), "bag 2's card plays");
for b in [
"BenillaBagToggle",
"BenillaBagBarSlot1",
"BenillaKeyRingButton",
] {
assert!(!shown(&s, b), "{b} took nothing, so {b} animates nothing");
}
// The pushed icon reaches the RENDERER, not just the Lua state — exactly one quad carries it.
let drawn = |s: &UiScript, icon: &str| {
s.extract()
.iter()
.filter(
|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == icon),
)
.count()
};
assert_eq!(
drawn(&s, "Interface\\Icons\\INV_Misc_Bag_08"),
1,
"the card wears the pushed item's icon (ITEM_PUSH's arg2)"
);
// t=0: invisible, full size, held a whole fall above the button (and a drift to its left) —
// the .m2's alpha key (0.000, 0.0), scale key (0.000, 1.0), translation (0.000, (0,0)).
let (dx, dy) = offset(&s, "BenillaBagBarSlot2");
assert!(
(dy - 48.0).abs() < 0.5 && (dx + 12.0).abs() < 0.5,
"starts one fall (48px) above and one drift (12px) left of the button: got ({dx}, {dy})"
);
assert!(
alpha(&s, "BenillaBagBarSlot2") < 0.01,
"fades in from nothing"
);
// t=0.133: fully faded in, at the 1.2x swell — the pop the director sees above the bar.
s.tick(0.133);
s.resolve();
assert!(
(alpha(&s, "BenillaBagBarSlot2") - 1.0).abs() < 0.02,
"opaque by the alpha track's second key"
);
assert!(
(size(&s, "BenillaBagBarSlot2") - 36.0 * 1.2).abs() < 0.5,
"swollen to 1.2x the 36px icon at the scale track's peak"
);
// t=0.267: back to the icon's own size, still opaque, still parked.
s.tick(0.134);
s.resolve();
assert!(
(size(&s, "BenillaBagBarSlot2") - 36.0).abs() < 0.5,
"settled back to the 36px icon at the scale track's third key"
);
let (_, dy) = offset(&s, "BenillaBagBarSlot2");
assert!((dy - 48.0).abs() < 0.5, "has not started falling: got {dy}");
// t=0.5: STILL PARKED — the translation track's second key is (0,0), so the whole first half
// second is a hang above the bar. The other two tracks are already running down, though: the
// three curves keep different schedules, which is the thing a "hold everything then drop"
// reading would get wrong (scale/alpha ≈ 0.686/0.682 here, the 0.267→1.000 ramps at t=0.5).
s.tick(0.233);
s.resolve();
let (_, dy) = offset(&s, "BenillaBagBarSlot2");
assert!(
(dy - 48.0).abs() < 0.5,
"has not moved yet at the half second: got {dy}"
);
assert!(
(size(&s, "BenillaBagBarSlot2") - 36.0 * 0.686).abs() < 0.5,
"but is already dwindling: got {}",
size(&s, "BenillaBagBarSlot2")
);
assert!(
(alpha(&s, "BenillaBagBarSlot2") - 0.682).abs() < 0.02,
"and already fading: got {}",
alpha(&s, "BenillaBagBarSlot2")
);
// t=0.75: halfway down.
s.tick(0.25);
s.resolve();
let (_, dy) = offset(&s, "BenillaBagBarSlot2");
assert!(
(dy - 24.0).abs() < 0.5,
"half the fall travelled by t=0.75: got {dy}"
);
// Just short of the end: landed on the button, shrunk to nothing, faded out.
s.tick(0.24);
s.resolve();
let (dx, dy) = offset(&s, "BenillaBagBarSlot2");
assert!(
dx.abs() < 1.0 && dy.abs() < 1.5,
"lands centred on the button it went into: got ({dx}, {dy})"
);
assert!(
size(&s, "BenillaBagBarSlot2") < 2.0,
"collapsed to the scale track's 0.014"
);
assert!(alpha(&s, "BenillaBagBarSlot2") < 0.05, "faded out");
// Past 1.000s the CLAMP sequence is over — the ref's OnAnimFinished → Hide.
s.tick(0.05);
assert!(
!shown(&s, "BenillaBagBarSlot2"),
"one play, then gone (and no OnUpdate left running)"
);
// The keyring is a real destination, not a rounding of the backpack.
s.fire_event(
"ITEM_PUSH",
vec![
benilla_ui::script::ScriptValue::Int(-2),
benilla_ui::script::ScriptValue::Str("Interface\\Icons\\INV_Misc_Key_03".into()),
],
);
assert!(shown(&s, "BenillaKeyRingButton"), "the keyring card plays");
assert!(
!shown(&s, "BenillaBagToggle"),
"and the backpack's does not"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -335,3 +335,38 @@ fn a_stopped_timer_paints_nothing() {
assert!(tex_quad(&quads, "UI-CastingBar-Border").is_none());
assert!(tex_quad(&quads, "UI-StatusBar").is_none());
}
/// The director's report (2026-08-02): *"the z of the bar is wrong, its overlaying the border"* —
/// the blue fill was painting over the border art AND over the caption, leaving "Breath"
/// unreadable.
///
/// The reference's own construction says the fill belongs underneath: the border texture and the
/// caption are OVERLAY regions of the timer **frame**, and the fill belongs to a **child**
/// StatusBar whose only OnLoad is `SetFrameLevel(GetFrameLevel() - 1)`. Both must draw after the
/// fill in the painter's order — i.e. strictly greater `z`.
#[test]
fn the_border_and_caption_draw_over_the_fill() {
let mut s = harness();
start(&mut s, "BREATH", 45_000, 60_000, -1, "Breath");
let quads = frame(&mut s, 0.016);
let fill = tex_quad(&quads, "UI-StatusBar").expect("the status-bar fill");
let border = tex_quad(&quads, "UI-CastingBar-Border").expect("the border chrome");
let caption = quads
.iter()
.find(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "Breath"))
.expect("the caption");
assert!(
border.z > fill.z,
"the border must paint OVER the fill (border z={:#x}, fill z={:#x})",
border.z,
fill.z
);
assert!(
caption.z > fill.z,
"the caption must paint OVER the fill (caption z={:#x}, fill z={:#x})",
caption.z,
fill.z
);
}
+28
View File
@@ -203,6 +203,34 @@ mod markup_tests {
assert_eq!(lines[0][2].color, WHITE);
}
/// The director-reported symptom, at the run level: a LOOT-green chat line whose item name
/// draws in the item's **quality** colour, not the line's. The whole "You receive loot" fix
/// hangs on the escape surviving intact from `ui_loot::receive_line` to here — this is the same
/// string that function emits, with LOOT green (0,170,0) as the line's base colour.
#[test]
fn a_loot_line_draws_its_item_name_in_the_quality_color_and_the_count_in_the_line_color() {
const LOOT_GREEN: [f32; 4] = [0.0, 170.0 / 255.0, 0.0, 1.0];
let lines = parse_markup(
"You receive loot: |cff9d9d9d|Hitem:7092:0:0:0|h[Chipped Claw]|h|rx2.",
LOOT_GREEN,
);
assert_eq!(lines[0].len(), 3);
assert_eq!(lines[0][0].text, "You receive loot: ");
assert_eq!(lines[0][0].color, LOOT_GREEN);
// The bracketed name: poor/grey, and clickable.
assert_eq!(lines[0][1].text, "[Chipped Claw]");
let grey = 0x9d as f32 / 255.0;
assert_eq!(lines[0][1].color, [grey, grey, grey, 1.0]);
assert_eq!(
lines[0][1].link.as_ref().expect("linked run").link,
"item:7092:0:0:0"
);
// The `|r` lands before the count, so `x2.` falls back to the line's own green.
assert_eq!(lines[0][2].text, "x2.");
assert_eq!(lines[0][2].color, LOOT_GREEN);
assert!(lines[0][2].link.is_none());
}
#[test]
fn newline_splits_lines() {
let lines = parse_markup("one\ntwo", WHITE);