Files
benilla-octo/crates/benilla-formats/src/spell_visual.rs
T
SamandClaude Fable 5 3fbfebd604 stealth you can see, item glows, and smooth character-select skies
- stealth reads as stealth now: the body fades to the reference's 30 % and creeps with the proper stealth walk and stand poses
- the item and enchant glow subsystem exists: permanent weapon glows and enchant visuals show in the world and at character select
- the character-select background's colour banding (the reported "16-bit" look) is fixed at the render target, and the tauren stage's black corners with it
- aura body tints got a real per-instance channel, so a spell can tint the whole unit without touching its neighbours

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 19:40:54 +07:00

942 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `SpellVisual.dbc` + `SpellVisualKit.dbc` — the per-lifecycle-stage kit chain a cast plays
//! (decision 0099 phase 2's data plane; schemas pinned by decision 0107).
//!
//! Layout — VERIFIED against build 5875 (WDBC header dump of the extracted files, 2026-07-04;
//! wow-re `system/dbc/scratch/spellvisual-schema.md` @ commit `ff91e7eb`, corroborated by vmangos
//! `DBCStructure.h:613-640` `SpellVisualEntry` and our own header bytes):
//!
//! - **SpellVisual**: 2165 records × 16 fields × 64 B, all-`u32` (a 1-byte empty string block).
//! Field 0 = id; **field 1 = precastKit · field 2 = castKit · field 3 = impactKit · field 4 =
//! stateKit · field 5 = channelKit** — each a `SpellVisualKit` id, `0` = no kit at that stage.
//! The missile block (decision 0099 phase 4, wow-re `spell-visual-lifecycle.md` §Q4 — every
//! consumption byte-cited in the client's missile spawn `0x60a3d0`): **field 7 = the missile's
//! `SpellVisualEffectName` id** (`<1` → the ammo/weapon model; unresolvable → the client's
//! literal `Spells\ErrorCube.mdx`), **field 9 = the destination-attachment ordinal** (an index
//! into [`MISSILE_ATTACH_TABLE`], the client's `0x860a18` — dumped in wow-re
//! `spell-visual-apply.md` §5), and **field 10 = the missile's in-flight LOOP sound**
//! ([`SoundEntries`] id — Fireball's `FireMissileLoop`, the thrown dagger's `WeaponLoop`; the
//! client's per-missile loop handle `CMissile+0x44`, wow-re `w2f1.md`). Field 6 (`hasMissile`)
//! is never read by the missile spawn (its gate is `Spell.dbc` Speed alone) — its ONE reader
//! is the GO dest one-shot's suppressor ([`VisualStages::missile_gate`], 0797); field 8
//! (`missilePathType`) is dead-by-absence; **fields 11/12/13 are the dest-anchored block**
//! ([`VisualStages::area_gate`]/[`VisualStages::area_effect`]/[`VisualStages::area_kit`] —
//! wow-re `dynobject-visual-machine.md`, 0797). Stage
//! semantics (decision 0107 verdict 2): the stage sets *lifetime policy* only —
//! every populated slot on a reached row fires, precast persisting (reaped spell-id-keyed)
//! while cast/impact self-terminate.
//! - **SpellVisualKit**: 1772 records × 35 fields × 140 B, all-`u32`. Field 0 = id; **field 2 =
//! the `AnimationData.dbc` animation id** (fed to the same `PlayAnimation` one-shot/overlay
//! route as melee swings — decision 0107 verdict 3); **field 13 = a `SoundEntries.dbc` id**;
//! **fields 3–11 are the nine `SpellVisualEffectName` emitter slots** (attach-point VFX —
//! decision 0099 phase 3, read here since that phase). Each slot's attach tag is a compile-time
//! immediate in the client's slot loop (`0x60edf0`, byte-cited push sites — wow-re
//! `spell-visual-apply.md` §1.3) and a **direct M2 `AttachmentID`**: in kit-field order,
//! [`KIT_SLOT_TAGS`]. Field 12 is the missile effect slot (phase 4), field 14 a visual-group
//! fallback id — neither is read here. **Fields 15–34 are the four `CharProc` slots** — the
//! *character* half of a kit (the body's own alpha/tint, as opposed to the attach-point emitters):
//! five parallel 4-element arrays, `CharProcType[4]` @+0x3c then `CharParamZero/One/Two/Three[4]`
//! @+0x4c/+0x5c/+0x6c/+0x7c (wow-re `spellvisual-schema.md`, byte-pinned: all 20 consumed by the
//! dispatcher `0x60d7c0`, which `lea edi,[kit+0x6c]` walks four times reading `[edi-0x30]` as the
//! type key). Read here as [`VisualKit::char_procs`]; the type semantics are
//! [`char_proc_type`]'s.
//! - **SpellVisualEffectName**: 5 fields × 20 B. Field 0 = id; field 1 = a name string — a debug
//! label, and the lookup key for the client's boot-time HARDCODED-effect matcher (`0x61f5b0`
//! over a 14-string table: loot art, footsteps, breath, level-up…; wow-re
//! `loot-corpse-effect.md` + `levelup-ding.md`) — the `"HARDCODED *"` rows load into
//! [`SpellVisualCatalog::hardcoded_effect`]'s name→path map (two consumers today: the corpse
//! sparkle and the level-up ding); **field 2 = the effect model's `.mdx` path** (the
//! column every consumer reads — fields 3/4 are dead-by-absence, wow-re
//! `spellvisual-schema.md`; the emitter *scale* comes from kit CharProc params × the quality
//! tier, never from this table).
//!
//! **The none-sentinel, found empirically here (not pinned in the wow-re note):** on the real
//! table, "no value" for both field 2 (anim) and field 13 (sound) is written as **either `0` or
//! `0xFFFFFFFF`**, not consistently one or the other. Scanning all 1772 kits: 41 carry anim `0`,
//! 875 carry anim `0xFFFFFFFF`, 856 carry a real id (max 203, inside `AnimationData.dbc`'s 208
//! rows). Restricting to the 1433 kits actually reachable from a live `spell_template` visual
//! chain doesn't change the picture (692 are `-1` vs 26 `0` vs 715 real) — the `-1` form is the
//! majority for **impact**-stage kits specifically (966/1200: most magic impacts carry no body
//! reaction) while **cast**-stage kits are majority real (1168/1347: most casts do animate).
//! `sound` (field 13) shows the same dual encoding on a handful of rows (5 kits carry
//! `0xFFFFFFFF` alongside 647 carrying plain `0`). [`VisualKit::anim_id`]/[`VisualKit::sound`]
//! fold both forms to `None` so no consumer ever sees the raw sentinels.
//!
//! **Verified chain (Fireball, spell 133 → visual 67):** precast 30 / cast 38 / impact 286 /
//! state 0 / channel 0; kit 38 (cast) → anim 53 (`AnimationData` "SpellCastDirected") / sound
//! 1484; kit 286 (impact) → anim 9 (`AnimationData` "CombatWound" — a hit-reaction anim on the
//! target, corroborating decision 0107's wound-flinch ids 8–10) / sound 1507.
use std::collections::HashMap;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{f32_at, i32_at, parse, str_at, u32_at};
use crate::Chain;
const SPELL_VISUAL: &str = "DBFilesClient\\SpellVisual.dbc";
const SPELL_VISUAL_KIT: &str = "DBFilesClient\\SpellVisualKit.dbc";
const SPELL_VISUAL_EFFECT_NAME: &str = "DBFilesClient\\SpellVisualEffectName.dbc";
const SPELL_VISUAL_FIELDS: usize = 16;
const SPELL_VISUAL_KIT_FIELDS: usize = 35;
/// The nine kit emitter slots' attach tags, **in kit-field order** (fields 3–11 ↔ this array's
/// indices 0–8). Byte-pinned compile-time immediates in the client's slot loop (wow-re
/// `spell-visual-apply.md` §1.3), each a **direct M2 `AttachmentID`**: Head(0x14), Chest(0x22),
/// Base(0x13), LeftHand(0x15), RightHand(0x16), Breath(0x11), Special1–3(0x17–0x19).
pub const KIT_SLOT_TAGS: [u16; 9] = [0x14, 0x22, 0x13, 0x15, 0x16, 0x11, 0x17, 0x18, 0x19];
/// The missile **destination-attachment** ordinal table — the client's `0x860a18`, dumped
/// byte-for-byte (wow-re `spell-visual-apply.md` §5, 11 live entries): [`KIT_SLOT_TAGS`] in
/// kit-field order plus `0xf`/`0x10` at ordinals 9/10. `SpellVisual` field 9 indexes here to the
/// M2 attach tag the missile homes to on a live target ([`VisualStages::missile_attach`]).
pub const MISSILE_ATTACH_TABLE: [u16; 11] = [
0x14, 0x22, 0x13, 0x15, 0x16, 0x11, 0x17, 0x18, 0x19, 0xf, 0x10,
];
/// The DBC's alternate "no value" encoding for a foreign-key-ish column (module docs) — folded to
/// `None` alongside plain `0`.
const NONE_SENTINEL: u32 = u32::MAX;
/// The four parallel `CharProc` slots a `SpellVisualKit` row carries (module docs; the client's
/// dispatcher `0x60d7c0` walks exactly four).
pub const KIT_CHAR_PROCS: usize = 4;
/// One of a kit's four `CharProc` slots: a **type key + its four float params**, the character-half
/// of a kit (the body's own render properties, not an attach-point emitter). The client's dispatcher
/// `0x60d7c0` switches on [`Self::ty`] through a byte translation table (`0x60dc20`) into a 9-case
/// jump table (`0x60dbfc`); each case reads whichever params it wants — the alpha and tint procs
/// both read `params[0]` (wow-re `ghost-death-visuals.md` §2.3).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CharProc {
/// `CharProcType[i]` — the dispatch key ([`char_proc_type`] names the ones we model). Never a
/// none-sentinel: an empty slot is `None` at the [`VisualKit::char_procs`] level.
pub ty: i32,
/// `CharParamZero/One/Two/Three[i]`, in that order — float columns (the client loads them with
/// `fld` and rounds where it wants an integer, as the tint proc does with its packed RGB).
pub params: [f32; 4],
}
/// The `CharProc` type keys benilla models, by name. The full key space is the dispatcher's 9 cases
/// (`0x60d7c0`); these are the ones a live 5875 **state** kit uses — every other key is either
/// cast-stage-only or unused by the shipped table (see `charprocs` in `benilla-extract`).
pub mod char_proc_type {
/// **Body TINT** (`0x60d840`): `round(params[0])` is a packed `0x00RRGGBB`, OR'd with
/// `0xff000000` into the tint node (`node+0x7c`), list `unit+0xce0`. Per-frame the head node's
/// RGB goes `×1/255` into `model+0x184/188/18c` (the ghost's pale blue-white `0xFF8CB9FD`).
pub const TINT: i32 = 1;
/// **Body TRANSLUCENCY** (`0x60d972`): `params[0]` is this aura's alpha factor, stored in a
/// spell-id-keyed node (`node+0x78`) on the unit's list `unit+0xb50`. The unit's effective alpha
/// 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;
}
/// One `SpellVisual.dbc` row: the five lifecycle-stage `SpellVisualKit` ids a cast may fire
/// through (`0` = no kit at that stage, this crate's usual absent-FK convention) + the missile
/// block's live columns (model, dest-attach, flight sound — module docs; phase 4).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct VisualStages {
pub precast: u32,
pub cast: u32,
pub impact: u32,
pub state: u32,
pub channel: u32,
/// Field 7: the projectile's `SpellVisualEffectName` id (`0` here covers the client's `<1`
/// ammo-fallback gate — the raw column is never negative on the real table). The missile
/// *exists* whenever `Spell.dbc` Speed > 0, model or not.
pub missile_model: u32,
/// Field 9: the destination-attachment ordinal — index [`MISSILE_ATTACH_TABLE`] for the M2
/// attach tag the missile homes to on a live target.
pub missile_attach: u32,
/// Field 10: the `SoundEntries.dbc` id the missile sounds while it travels — a LOOPING whoosh
/// on the thrown/ranged projectiles (the thrown dagger's `WeaponLoop.wav`, id 3318), the
/// client's per-missile loop handle (`CMissile+0x44`, wow-re `w2f1.md`; its volume is proximity
/// -shaped by `0x61d790`). `None` = the row names no flight sound. Rung by
/// `crate::entities::missile`, stopped when the projectile arrives.
pub missile_sound: Option<u32>,
/// Field 14: the `SoundEntries.dbc` id the `$TRD` anim event rings at the work/craft strike
/// keyframe — the client's `$TRD` handler `0x62faa0` resolves the in-flight spell's visual to
/// this 16-dword row and plays its dword 14 (`[row+0x38]`, wow-re
/// `sound/scratch/gather-sound-anim-events.md`; decision 0562). Mining's visual 93 carries
/// 1143 "Mining Impact" (the pick clang), Herb's 91 carries 1142, the smithing crafts' 395
/// carries 1143 (the hammer). `None` = no strike sound.
pub strike_sound: Option<u32>,
/// Field 6 (`+0x18`): the missile gate the dest one-shot checks — `SMSG_SPELL_GO` spawns a
/// CEffect at the packet's DEST point only when this is **0** and [`Self::area_effect`] is
/// set (the client's `0x6e8088`–`0x6e8143`; wow-re `spell-go-dest-effect.md`). Nonzero =
/// the missile owns the arrival, no dest one-shot.
pub missile_gate: u32,
/// Field 11 (`+0x2c`): the gate on a DynamicObject's **own** `.mdx` (visual A) — the model
/// resolve at `0x5d57c0` requires this ≠ 0 before reading [`Self::area_effect`] (wow-re
/// `dynobject-visual-machine.md`).
pub area_gate: u32,
/// Field 12 (`+0x30`): the `SpellVisualEffectName` id of the **dest-anchored model** — a
/// DynamicObject's own `.mdx` (visual A, gated by [`Self::area_gate`]) and the GO dest
/// one-shot's model (gated by [`Self::missile_gate`] == 0). NOT the kit table.
pub area_effect: u32,
/// Field 13 (`+0x34`): the `SpellVisualKit` id of the **area kit** — a DynamicObject's
/// emitter chain (`0x5d55c0`: CharProcType scan for 9 → the hardcoded shard-model table)
/// and its looping area sound (the kit's own field-13 SoundEntries). `0` = none.
pub area_kit: u32,
}
/// One `SpellVisualKit.dbc` row's body-animation + sound + the nine attach-point emitter slots
/// (the columns consumed through decision 0099 phase 3; see the module doc for what remains).
// No `Eq`: the `CharProc` params are floats (the client's own `fld` columns).
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct VisualKit {
/// The `AnimationData.dbc` id this kit plays on the unit (`None` = no animation for this kit
/// — see the module doc's none-sentinel finding; common on impact kits).
pub anim_id: Option<u16>,
/// The `SoundEntries.dbc` kit id this stage rings (`None` = silent).
pub sound: Option<u32>,
/// The nine emitter slots (kit fields 3–11): each a `SpellVisualEffectName` id, in kit-field
/// order — slot `i` attaches at [`KIT_SLOT_TAGS`]`[i]`. Both none-sentinels fold to `None`
/// like [`Self::anim_id`]. Iterate populated slots via [`Self::effects`].
pub effect_slots: [Option<u32>; 9],
/// The four `CharProc` slots (kit fields 15–34, transposed out of the five parallel arrays).
/// An unfilled slot is `None` — see [`char_proc_slot`] for the sentinel law; iterate the filled
/// ones via [`Self::char_procs`]. **Two consumers, two halves of the same table:** the *body*
/// procs an aura's state kit installs (type 14 translucency / type 1 tint — `benilla`'s
/// `aura_visual`, decision 0806), and the **dynobj emitter chain**, which scans for the FIRST
/// type **9** slot — there `params[0]` encodes the shard-model table index (the exact small-int
/// decode `bits(params[0] + 512.0) >> 14 & 0xff`, `0x5d55c0`) and `params[1]` is the emit rate
/// the graphics-quality factor multiplies (wow-re `dynobject-visual-machine.md`, decision 0797).
pub char_proc_slots: [Option<CharProc>; KIT_CHAR_PROCS],
}
impl VisualKit {
/// The filled `CharProc` slots, in slot order. The client walks all four unconditionally and
/// lets the dispatch table drop the unnamed keys; we drop the empty *slots* here and leave the
/// key switch to the consumer.
pub fn char_procs(&self) -> impl Iterator<Item = CharProc> + '_ {
self.char_proc_slots.iter().flatten().copied()
}
/// The `params[0]` of this kit's first `CharProc` slot of type `ty`, if any — the shape both
/// modelled procs want (a single float: the alpha factor, the packed tint).
pub fn char_proc_param(&self, ty: i32) -> Option<f32> {
self.char_procs().find(|p| p.ty == ty).map(|p| p.params[0])
}
/// The populated emitter slots as `(M2 attachment id, SpellVisualEffectName id)` pairs, in
/// kit-field order — the client fires **all** populated slots at **every** stage (stage sets
/// lifetime policy only, wow-re `spell-visual-apply.md` §1.3).
pub fn effects(&self) -> impl Iterator<Item = (u16, u32)> + '_ {
self.effect_slots
.iter()
.enumerate()
.filter_map(|(i, e)| e.map(|id| (KIT_SLOT_TAGS[i], id)))
}
}
/// `SpellVisual.dbc` × `SpellVisualKit.dbc` × `SpellVisualEffectName.dbc`, each kept in its own
/// id space (three different tables' keys — never conflate them, mirroring
/// [`crate::AreaSoundCatalog`]'s per-table map split).
pub struct SpellVisualCatalog {
visuals: HashMap<u32, VisualStages>,
kits: HashMap<u32, VisualKit>,
/// `SpellVisualEffectName` id → the effect model's `.mdx` path (field 2 — the table's one
/// column the kit slots consume).
effect_paths: HashMap<u32, String>,
/// The `"HARDCODED *"`-named rows, name → path — the client's engine-spawned effect set,
/// resolved BY NAME once at boot exactly like this (`0x61f5b0` matches a 14-string baked
/// table against the name column: loot art, footsteps, breath, level-up…; wow-re
/// `loot-corpse-effect.md` + `levelup-ding.md`). Two consumers today: "HARDCODED Loot Art"
/// (id 14 → `Particles\LootFX.mdl`, the corpse sparkle) and "HARDCODED Unit Level Up"
/// (id 21 → `Spells\LevelUp\LevelUp.mdl`, the ding).
hardcoded: HashMap<String, String>,
}
impl SpellVisualCatalog {
/// Build a catalog from explicit visual/kit tables — for tests and synthetic fixtures (the
/// [`crate::SpellCatalog::from_displays`] convention). The live path is
/// [`load_spell_visual_catalog`]. Carries no effect paths and no hardcoded rows.
pub fn from_tables(visuals: HashMap<u32, VisualStages>, kits: HashMap<u32, VisualKit>) -> Self {
Self::from_tables_with_paths(visuals, kits, HashMap::new())
}
/// [`Self::from_tables`] plus effect paths, for fixtures exercising the kit → effect-model
/// chain. The live path is [`load_spell_visual_catalog`].
pub fn from_tables_with_paths(
visuals: HashMap<u32, VisualStages>,
kits: HashMap<u32, VisualKit>,
effect_paths: HashMap<u32, String>,
) -> Self {
Self {
visuals,
kits,
effect_paths,
hardcoded: HashMap::new(),
}
}
/// A `SpellVisual.dbc` row's stage kits by its id (`Spell.dbc` column 115, `spells::SpellDisplay::visual`).
pub fn stages(&self, visual_id: u32) -> Option<&VisualStages> {
self.visuals.get(&visual_id)
}
/// A `SpellVisualKit.dbc` row by its id (one of [`VisualStages`]'s five fields).
pub fn kit(&self, kit_id: u32) -> Option<&VisualKit> {
self.kits.get(&kit_id)
}
/// The number of `SpellVisual` rows loaded.
pub fn len(&self) -> usize {
self.visuals.len()
}
pub fn is_empty(&self) -> bool {
self.visuals.is_empty()
}
/// Every loaded `SpellVisualKit` id, ascending — for whole-table census instruments
/// (`benilla-extract charprocs`). Runtime consumers resolve a kit by id through [`Self::kit`].
pub fn kit_ids(&self) -> impl Iterator<Item = u32> + '_ {
let mut ids: Vec<u32> = self.kits.keys().copied().collect();
ids.sort_unstable();
ids.into_iter()
}
/// The number of `SpellVisualKit` rows loaded.
pub fn kit_len(&self) -> usize {
self.kits.len()
}
/// A `SpellVisualEffectName` id's effect-model `.mdx` path (one of a kit's
/// [`VisualKit::effects`] slot values). `None` for an unknown id or an empty path.
pub fn effect_path(&self, effect_id: u32) -> Option<&str> {
self.effect_paths
.get(&effect_id)
.map(String::as_str)
.filter(|p| !p.is_empty())
}
/// An engine-spawned hardcoded effect's model path, by the client's own baked name string
/// ([`SpellVisualCatalog::hardcoded`]). Mirrors the client's boot name-resolve
/// (`0x61f5b0`, stricmp-family — hence the case-insensitive compare over the tiny set);
/// `None` when the shipped table names no such row.
pub fn hardcoded_effect(&self, name: &str) -> Option<&str> {
self.hardcoded
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
.filter(|p| !p.is_empty())
}
/// The `"HARDCODED Loot Art"` row's model path — the lootable-corpse sparkle, riding the
/// shared hardcoded map (one name-resolve mechanism for the whole engine-spawned set).
pub fn loot_art_path(&self) -> Option<&str> {
self.hardcoded_effect("HARDCODED Loot Art")
}
}
fn n_u32_schema(name: &str, fields: usize) -> Schema {
let mut s = Schema::new(name);
for i in 0..fields {
s.add_field(SchemaField::new(format!("F{i}"), FieldType::UInt32));
}
s
}
/// `SpellVisualEffectName`'s 5-field schema: id · name (string — the HARDCODED-effect lookup
/// key) · model `.mdx` path (string) · two dead columns (module docs).
fn effect_name_schema() -> Schema {
let mut s = Schema::new("SpellVisualEffectName");
for i in 0..5 {
let ty = if i == 1 || i == 2 {
FieldType::String
} else {
FieldType::UInt32
};
s.add_field(SchemaField::new(format!("F{i}"), ty));
}
s
}
/// `SpellVisualKit`'s 35-field schema: fields 0–14 are the ids/FKs (`u32`), **15–18 the signed
/// `CharProcType` keys** and **19–34 the four float param arrays** — the client loads the params
/// with `fld` (wow-re `spellvisual-schema.md`), so they are typed here rather than bit-cast at
/// every read.
fn kit_schema() -> Schema {
let mut s = Schema::new("SpellVisualKit");
for i in 0..SPELL_VISUAL_KIT_FIELDS {
let ty = match i {
CHAR_PROC_TYPE_FIELD..CHAR_PROC_PARAM_FIELD => FieldType::Int32,
f if f >= CHAR_PROC_PARAM_FIELD => FieldType::Float32,
_ => FieldType::UInt32,
};
s.add_field(SchemaField::new(format!("F{i}"), ty));
}
s
}
/// Kit field 15 — `CharProcType[0]`, the first of the four type keys (@+0x3c).
const CHAR_PROC_TYPE_FIELD: usize = 15;
/// Kit field 19 — `CharParamZero[0]`, the first param column (@+0x4c); `CharParamOne/Two/Three[0]`
/// follow every [`KIT_CHAR_PROCS`] fields (@+0x5c/+0x6c/+0x7c).
const CHAR_PROC_PARAM_FIELD: usize = CHAR_PROC_TYPE_FIELD + KIT_CHAR_PROCS;
/// One kit's `CharProc` slot `i`, transposed out of the five parallel arrays.
///
/// **The empty-slot sentinel, read off the real table:** the type column uses **both** `-1` and `0`
/// for "no proc" — the same dual none-encoding this table already shows on its anim/sound FKs
/// (module docs). Every filled slot in 5875 carries a positive key, and the dispatcher's translation
/// table (`0x60dc20`) sends everything it doesn't name to the no-op case, so folding `<= 0` to
/// `None` here is behaviour-identical and keeps consumers from switching on a sentinel.
fn char_proc_slot(r: &benilla_dbc::Record, i: usize) -> Option<CharProc> {
let ty = i32_at(r, CHAR_PROC_TYPE_FIELD + i)?;
if ty <= 0 {
return None;
}
let mut params = [0.0; 4];
for (p, param) in params.iter_mut().enumerate() {
*param = f32_at(r, CHAR_PROC_PARAM_FIELD + p * KIT_CHAR_PROCS + i).unwrap_or(0.0);
}
Some(CharProc { ty, params })
}
/// Fold the DBC's dual none-encoding (module docs) to `Option<u32>`.
fn some_unless_none(v: u32) -> Option<u32> {
(v != 0 && v != NONE_SENTINEL).then_some(v)
}
/// Read `SpellVisual.dbc` + `SpellVisualKit.dbc` off the patch chain.
pub fn load_spell_visual_catalog(chain: &mut Chain) -> Result<SpellVisualCatalog> {
let sv_bytes = chain
.read_file(SPELL_VISUAL)
.context("reading SpellVisual.dbc")?;
let sv_set = parse(
&sv_bytes,
n_u32_schema("SpellVisual", SPELL_VISUAL_FIELDS),
"SpellVisual.dbc",
)?;
let mut visuals = HashMap::with_capacity(sv_set.records().len());
for r in sv_set.records() {
let Some(id) = u32_at(r, 0) else { continue };
let g = |i: usize| u32_at(r, i).unwrap_or(0);
visuals.insert(
id,
VisualStages {
precast: g(1),
cast: g(2),
impact: g(3),
state: g(4),
channel: g(5),
missile_model: g(7),
missile_attach: g(9),
missile_sound: u32_at(r, 10).and_then(some_unless_none),
strike_sound: u32_at(r, 14).and_then(some_unless_none),
missile_gate: g(6),
area_gate: g(11),
area_effect: g(12),
area_kit: g(13),
},
);
}
let svk_bytes = chain
.read_file(SPELL_VISUAL_KIT)
.context("reading SpellVisualKit.dbc")?;
let svk_set = parse(&svk_bytes, kit_schema(), "SpellVisualKit.dbc")?;
let mut kits = HashMap::with_capacity(svk_set.records().len());
for r in svk_set.records() {
let Some(id) = u32_at(r, 0) else { continue };
let anim_id = u32_at(r, 2).and_then(some_unless_none).map(|a| a as u16);
let sound = u32_at(r, 13).and_then(some_unless_none);
let mut effect_slots = [None; 9];
for (i, slot) in effect_slots.iter_mut().enumerate() {
*slot = u32_at(r, 3 + i).and_then(some_unless_none);
}
let mut char_proc_slots = [None; KIT_CHAR_PROCS];
for (i, slot) in char_proc_slots.iter_mut().enumerate() {
*slot = char_proc_slot(r, i);
}
kits.insert(
id,
VisualKit {
anim_id,
sound,
effect_slots,
char_proc_slots,
},
);
}
let sven_bytes = chain
.read_file(SPELL_VISUAL_EFFECT_NAME)
.context("reading SpellVisualEffectName.dbc")?;
let sven_set = parse(
&sven_bytes,
effect_name_schema(),
"SpellVisualEffectName.dbc",
)?;
let mut effect_paths = HashMap::with_capacity(sven_set.records().len());
let mut hardcoded = HashMap::new();
for r in sven_set.records() {
let Some(id) = u32_at(r, 0) else { continue };
if let Some(path) = str_at(&sven_set, r, 2) {
// The engine-spawned set is looked up by the name column — keep exactly the rows
// the client's own boot name-resolve can hit (the "HARDCODED " prefix; its
// matchers are stricmp-family, so lookups compare case-insensitively).
if let Some(name) = str_at(&sven_set, r, 1).filter(|n| n.starts_with("HARDCODED ")) {
hardcoded.insert(name, path.clone());
}
effect_paths.insert(id, path);
}
}
Ok(SpellVisualCatalog {
visuals,
kits,
effect_paths,
hardcoded,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// A minimal synthetic WDBC (20-byte header + fixed-width u32 records, no strings — both
/// tables are all-`u32`) — the same shape `benilla-dbc`'s own tests build (`items.rs`
/// reproduces it too), so this adapter is testable without a real client install.
fn build_wdbc(record_count: u32, field_count: u32, records: &[u8]) -> Vec<u8> {
let record_size = field_count * 4;
assert_eq!(records.len(), (record_count * record_size) as usize);
let mut b = Vec::new();
b.extend_from_slice(b"WDBC");
b.extend_from_slice(&record_count.to_le_bytes());
b.extend_from_slice(&field_count.to_le_bytes());
b.extend_from_slice(&record_size.to_le_bytes());
b.extend_from_slice(&0u32.to_le_bytes()); // empty string block
b.extend_from_slice(records);
b
}
fn u32le(v: u32) -> [u8; 4] {
v.to_le_bytes()
}
/// One `SpellVisual` row: id, the five stage kits, then the missile block — field 7 (model
/// effect id) and field 9 (dest-attach ordinal) live, the rest zeroed to fill 16 fields.
#[allow(clippy::too_many_arguments)]
fn spell_visual_row(
id: u32,
precast: u32,
cast: u32,
impact: u32,
state: u32,
channel: u32,
missile_model: u32,
missile_attach: u32,
) -> Vec<u8> {
let mut rec = Vec::new();
for v in [id, precast, cast, impact, state, channel] {
rec.extend(u32le(v));
}
for f in 6..SPELL_VISUAL_FIELDS {
rec.extend(u32le(match f {
7 => missile_model,
9 => missile_attach,
_ => 0,
}));
}
rec
}
/// One `SpellVisualKit` row: id, an unpinned field 1, anim (field 2), nine zeroed emitter
/// slots (3..12), sound (field 13), then zeroed trailing columns to fill 35 fields.
fn spell_visual_kit_row(id: u32, anim: u32, sound: u32) -> Vec<u8> {
let mut rec = Vec::new();
rec.extend(u32le(id));
rec.extend(u32le(0)); // field 1, unpinned
rec.extend(u32le(anim)); // field 2
for _ in 3..13 {
rec.extend(u32le(0)); // fields 3..12 (9 emitter slots + missile)
}
rec.extend(u32le(sound)); // field 13
for _ in 14..SPELL_VISUAL_KIT_FIELDS {
rec.extend(u32le(0));
}
rec
}
#[test]
fn header_shape_matches_the_pinned_layout() {
// A single-row file of each still has to satisfy the pinned field/record-size shape —
// exercises the schema's own field-count check against a header lying about it would be
// a separate (missing) test; this one just guards our row builders stay in lock-step with
// SPELL_VISUAL_FIELDS/SPELL_VISUAL_KIT_FIELDS.
let sv = spell_visual_row(1, 0, 0, 0, 0, 0, 0, 0);
assert_eq!(sv.len(), SPELL_VISUAL_FIELDS * 4, "64B record");
let svk = spell_visual_kit_row(1, 0, 0);
assert_eq!(svk.len(), SPELL_VISUAL_KIT_FIELDS * 4, "140B record");
}
#[test]
fn parses_stage_kits_and_zero_means_no_kit_at_that_stage() {
let mut records = Vec::new();
records.extend(spell_visual_row(67, 30, 38, 286, 0, 0, 365, 1)); // Fireball's visual row
records.extend(spell_visual_row(1, 0, 0, 0, 0, 0, 0, 0)); // an all-silent row
let bytes = build_wdbc(2, SPELL_VISUAL_FIELDS as u32, &records);
let rs = parse(
&bytes,
n_u32_schema("SpellVisual", SPELL_VISUAL_FIELDS),
"t",
)
.unwrap();
let mut visuals = HashMap::new();
for r in rs.records() {
let id = u32_at(r, 0).unwrap();
let g = |i: usize| u32_at(r, i).unwrap_or(0);
visuals.insert(
id,
VisualStages {
precast: g(1),
cast: g(2),
impact: g(3),
state: g(4),
channel: g(5),
missile_model: g(7),
missile_attach: g(9),
missile_sound: u32_at(r, 10).and_then(some_unless_none),
strike_sound: u32_at(r, 14).and_then(some_unless_none),
missile_gate: g(6),
area_gate: g(11),
area_effect: g(12),
area_kit: g(13),
},
);
}
assert_eq!(
visuals[&67],
VisualStages {
precast: 30,
cast: 38,
impact: 286,
state: 0,
channel: 0,
missile_model: 365,
missile_attach: 1,
missile_sound: None,
strike_sound: None,
// The synthetic row builder writes 0 into field 6 and the dest-anchored block
// (the REAL Fireball row's missile_gate = 1 is pinned in the real-data test).
..Default::default()
}
);
assert_eq!(
visuals[&1],
VisualStages::default(),
"an all-zero row is all-silent"
);
}
#[test]
fn kit_anim_and_sound_fold_both_none_sentinels_to_none() {
let mut records = Vec::new();
records.extend(spell_visual_kit_row(38, 53, 1484)); // Fireball's cast kit
records.extend(spell_visual_kit_row(1, 0, 0)); // plain-zero "none"
records.extend(spell_visual_kit_row(2, u32::MAX, u32::MAX)); // the -1 "none" form
let bytes = build_wdbc(3, SPELL_VISUAL_KIT_FIELDS as u32, &records);
let rs = parse(
&bytes,
n_u32_schema("SpellVisualKit", SPELL_VISUAL_KIT_FIELDS),
"t",
)
.unwrap();
let mut kits = HashMap::new();
for r in rs.records() {
let id = u32_at(r, 0).unwrap();
let anim_id = u32_at(r, 2).and_then(some_unless_none).map(|a| a as u16);
let sound = u32_at(r, 13).and_then(some_unless_none);
kits.insert(
id,
VisualKit {
anim_id,
sound,
..Default::default()
},
);
}
assert_eq!(
kits[&38],
VisualKit {
anim_id: Some(53),
sound: Some(1484),
..Default::default()
}
);
assert_eq!(kits[&1], VisualKit::default(), "plain 0 = none");
assert_eq!(kits[&2], VisualKit::default(), "0xFFFFFFFF = none too");
}
/// The emitter-slot surface: kit-field order maps to [`KIT_SLOT_TAGS`], both none-sentinels
/// fold out, and `effects()` yields only the populated pairs.
#[test]
fn kit_effect_slots_pair_with_their_attach_tags() {
let kit = VisualKit {
// Fireball's precast shape: LeftHand (slot index 3) + RightHand (slot index 4).
effect_slots: [
None,
None,
None,
Some(287),
Some(287),
None,
None,
None,
None,
],
..Default::default()
};
assert_eq!(
kit.effects().collect::<Vec<_>>(),
vec![(0x15, 287), (0x16, 287)],
"LeftHand then RightHand, kit-field order"
);
assert_eq!(VisualKit::default().effects().count(), 0);
}
/// The repo root's `WoW/Data` (gitignored; the real-data tests skip when absent) — this
/// crate's established gate (`anim_data.rs`, `spell_catalog.rs`, …).
fn vanilla_data_dir() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data")
}
/// End-to-end on the real build-5875 tables: the byte-verified header shape (2165×16/64B ·
/// 1772×35/140B) and the full Fireball chain (module doc's "Verified chain") — a schema drift
/// or column slip fails loudly. Skips without client data.
#[test]
fn real_spell_visual_chain_resolves_fireball() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_spell_visual_catalog(&mut chain).expect("load SpellVisual/SpellVisualKit");
assert_eq!(cat.len(), 2165, "all 5875 SpellVisual rows load");
assert_eq!(cat.kit_len(), 1772, "all 5875 SpellVisualKit rows load");
// Fireball (spell 133) → visual 67 (spells.rs's column-115 pin).
let stages = cat.stages(67).expect("Fireball's SpellVisual row");
assert_eq!(
*stages,
VisualStages {
precast: 30,
cast: 38,
impact: 286,
state: 0,
channel: 0,
missile_model: 365,
missile_attach: 1,
// Field 10: the fireball's in-flight loop (SoundEntries 3011 → FireMissileLoop.wav).
missile_sound: Some(3011),
strike_sound: None,
// Field 6 set = a missile owns the arrival: the GO dest one-shot gate is closed
// (0797); the dest-anchored columns are empty on a projectile nuke.
missile_gate: 1,
area_gate: 0,
area_effect: 0,
area_kit: 0,
}
);
// The gathering/work strike sounds (decision 0562): Mining's visual 93 carries the pick
// clang in field 14 (SoundEntries 1143 "Mining Impact" = MiningHitA-E), Herb's 91 the
// search rustle (1142) - the $TRD anim event's operands.
assert_eq!(
cat.stages(93).and_then(|s| s.strike_sound),
Some(1143),
"Mining's visual carries the field-14 strike clang"
);
assert_eq!(
cat.stages(91).and_then(|s| s.strike_sound),
Some(1142),
"Herb Gathering's visual carries the field-14 rustle"
);
// A basic thrown attack borrows the equipped weapon's substitute visual (98 — the thrown
// dagger's ItemDisplayInfo col 10): no missile MODEL (it flies the weapon itself) but a
// flight loop in field 10 (SoundEntries 3318 → WeaponLoop.wav) — the whoosh the projectile
// carries while it travels.
let thrown = cat
.stages(98)
.expect("the thrown-weapon substitute SpellVisual");
assert_eq!(
thrown.missile_model, 0,
"a thrown weapon flies its own model, not an effect"
);
assert_eq!(
thrown.missile_sound,
Some(3318),
"the thrown weapon's flight loop"
);
// The missile chain (phase 4): field 7 → the projectile's own model, field 9 → the
// chest attach the missile homes to (ordinal 1 → 0x22).
assert_eq!(
cat.effect_path(stages.missile_model),
Some("Spells\\Fireball_Missile_Low.mdx"),
"the flying fireball's model"
);
assert_eq!(
MISSILE_ATTACH_TABLE[stages.missile_attach as usize], 0x22,
"Fireball homes onto the target's chest"
);
// The engine-spawned hardcoded set resolves by the client's own baked names — the ding
// (row 21, byte-verified `0x61f5b0`/`0x8618e0`, decision 0304's §5 fold-back).
assert_eq!(
cat.hardcoded_effect("HARDCODED Unit Level Up"),
Some("Spells\\LevelUp\\LevelUp.mdl"),
"the level-up pillar resolves by name"
);
assert!(
cat.hardcoded_effect("HARDCODED Loot Art").is_some(),
"the sibling hardcoded rows ride the same map"
);
let cast_kit = cat.kit(stages.cast).expect("cast kit");
assert_eq!(
cast_kit.anim_id,
Some(53),
"AnimationData id 53 = SpellCastDirected"
);
assert_eq!(cast_kit.sound, Some(1484));
let impact_kit = cat.kit(stages.impact).expect("impact kit");
assert_eq!(
impact_kit.anim_id,
Some(9),
"AnimationData id 9 = CombatWound, the target's hit reaction"
);
assert_eq!(impact_kit.sound, Some(1507));
// The precast kit's emitter slots (phase 3): effect 287 on both hands, and its
// SpellVisualEffectName path — the glowing-hands chain end to end.
let precast_kit = cat.kit(stages.precast).expect("precast kit");
assert_eq!(
precast_kit.effects().collect::<Vec<_>>(),
vec![(0x15, 287), (0x16, 287)],
"Fire_Precast_Hand on LeftHand + RightHand"
);
assert_eq!(
cat.effect_path(287),
Some("Spells\\Fire_Precast_Hand.mdx"),
"SpellVisualEffectName field 2 = the effect model path"
);
// The impact kit's chest burst — the phase-4 arrival hand-off will play this.
assert_eq!(
cat.kit(stages.impact)
.unwrap()
.effects()
.collect::<Vec<_>>(),
vec![(0x22, 321)],
"MoltenBlast_Impact_Chest on the Chest attach"
);
}
/// The real 5875 `SpellVisualEffectName`: the boot-time HARDCODED name matcher's one consumed
/// row — `"HARDCODED Loot Art"` is id 14 → `Particles\LootFX.mdl` (values pre-checked against
/// the raw DBC bytes), and the `.m2` it names ships in the chain, so the lootable-corpse
/// sparkle can actually load (wow-re `loot-corpse-effect.md`). Skips without client data.
#[test]
fn real_effect_name_table_resolves_the_loot_art_row() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_spell_visual_catalog(&mut chain).expect("load the visual catalog");
assert_eq!(cat.loot_art_path(), Some("Particles\\LootFX.mdl"));
// The model the row names ships in the chain (consumers rewrite .mdl → .m2 to load it).
assert!(
chain.read_file("Particles\\LootFX.m2").is_ok(),
"LootFX.m2 must ship in the chain"
);
}
}
#[cfg(test)]
mod ground_fx_data_tests {
use super::*;
/// The dest-anchored chain on the REAL data — 0797's mandatory per-spell data check (the
/// wow-re read pinned the GATE; whether each spell passes it is a table fact). Skips
/// without client data. Every value here corroborates the live vmangos wire capture:
/// Blizzard's dynobj RADIUS was 8.0 (row 14), Flamestrike's 5.0 (row 8).
#[test]
fn ground_aoe_chain_reads_the_dest_anchored_block() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let catalog = crate::load_spell_catalog(&mut chain).expect("spells");
let visuals = load_spell_visual_catalog(&mut chain).expect("visuals");
let radii = crate::load_spell_radii(&mut chain).expect("radii");
// Blizzard 10 → visual 259: both visuals — its own model AND a type-9 shard emitter
// whose table index decodes to 0 (the same model), rate 5/s, sound 7.
let d = catalog.get(10).unwrap();
assert_eq!((d.visual, d.effect_radius_index), (259, [14, 0, 0]));
assert_eq!(
radii.get(14).map(|r| r.radius),
Some(8.0),
"the wire radius"
);
let v = visuals.stages(259).unwrap();
assert_eq!(
(v.missile_gate, v.area_gate, v.area_effect, v.area_kit),
(0, 1, 398, 609)
);
assert_eq!(
visuals.effect_path(398),
Some("Spells\\Blizzard_Impact_Base.mdx")
);
let k = visuals.kit(609).unwrap();
assert_eq!(k.sound, Some(7));
let proc = k.char_procs().find(|p| p.ty == 9).unwrap();
assert_eq!((proc.params[0], proc.params[1]), (0.0, 5.0));
// Flamestrike 2120 → visual 33: its own model + sound, NO type-9 proc (a burning
// patch has no falling shards) — the emitter half is data-absent, not code-gated.
let d = catalog.get(2120).unwrap();
assert_eq!((d.visual, d.effect_radius_index), (33, [8, 8, 0]));
assert_eq!(radii.get(8).map(|r| r.radius), Some(5.0), "the wire radius");
let v = visuals.stages(33).unwrap();
assert_eq!(
(v.missile_gate, v.area_gate, v.area_effect, v.area_kit),
(0, 1, 420, 695)
);
assert_eq!(
visuals.effect_path(420),
Some("Spells\\Flamestrike_Impact_Base.mdx")
);
let k = visuals.kit(695).unwrap();
assert_eq!(k.sound, Some(3077));
assert!(k.char_procs().all(|p| p.ty != 9));
// Rain of Fire 5740 → visual 329: shard-table index 1 (its own model again), rate 5/s.
let v = visuals.stages(catalog.get(5740).unwrap().visual).unwrap();
assert_eq!((v.missile_gate, v.area_gate, v.area_effect), (0, 1, 448));
let proc = visuals
.kit(v.area_kit)
.unwrap()
.char_procs()
.find(|p| p.ty == 9)
.unwrap();
assert_eq!((proc.params[0], proc.params[1]), (1.0, 5.0));
// All three pass the GO dest one-shot gate (field 6 == 0 ∧ field 12 ≠ 0) — the burst
// fires for each; Fireball (missile_gate 1, pinned in the loader test above) does not.
}
}