Command Palette

Search for a command to run...

Sign in

Spell Data

The SpellDataFlat type and its SpellEffect rows. The field groups the engine reads to resolve costs, coefficients, timing, and aura behaviour

3 min read

A spell in WoW is not one row. The client splits a single ability across a dozen tables: its name, its timing, its costs, its school, and a variable number of effects each with their own coefficients. The transform layer joins all of that back together into one struct, SpellDataFlat, so the rest of the system can treat a spell as a single value. The simplest mental model: SpellDataFlat is "everything the engine could ever want to know about one spell, flattened into one record."

This is the largest and most consulted type in the data layer, so it is worth walking through its field groups rather than dumping the whole struct. Because SpellDataFlat derives serde snake_case, the exact same struct deserializes from a CSV-derived transform, a Supabase JSON row, or a JS-bridge value. That single-shape property is what lets three very different backends feed one engine; I return to it in Data Resolution.

Field groups

Rather than 80-odd fields in arbitrary order, the struct is organized into logical groups. These are the groups the engine actually reads during resolve_game_data (covered in Codegen).

Table 4
SpellDataFlat Field Groups
GroupRepresentative fields
Identity / textid, name, description, aura_description, is_passive, knowledge_source
Timing / costcast_time, recovery_time, category_recovery_time, start_recovery_time (GCD), power_costs, charge_recovery_time, max_charges
Range / AoErange_max_0/1, range_min_0/1, cone_degrees, radius_max, radius_min
School / coefficientdefense_type, school_mask, bonus_coefficient_from_ap, effect_bonus_coefficient, min_scaling_level, max_scaling_level
Interruptsinterrupt_aura_0/1, interrupt_channel_0/1, interrupt_flags
Duration / empowerduration, max_duration, can_empower, empower_stages
Vec columnsattributes, effect_trigger_spell, implicit_target, learn_spells, effects
Aura propsmax_stacks, periodic_type, tick_period_ms, refresh_behavior, pandemic_refresh, tick_may_crit, tick_on_application
RPPM / labelsrppm_base_rate, rppm_flags, rppm_mods, labels
The logical field groups of SpellDataFlat (identity/text, timing/cost, range/AoE, school/coefficient, interrupts, duration/empower, vec columns, aura props, RPPM/labels) with representative fields.

A couple of fields carry contracts that are not obvious from their names, and getting them wrong silently produces wrong cooldowns:

  • Cooldown is two columns. The engine must use max(recovery_time, category_recovery_time). Picking either one alone is wrong for spells that put their cooldown on a shared category.
  • start_recovery_time is the GCD, not a cooldown.
  • A non-zero interrupt_channel_0 means the spell is a channel, which changes how its "cast time" is interpreted. For channels the duration field is the channel length, not the cast bar.

The resolution pass spells the cooldown rule out, with one extra wrinkle: a single-charge spell can hide its real cooldown in charge_recovery_time instead:

rust
let mut cooldown_ms = spell.recovery_time.max(spell.category_recovery_time);
if spell.max_charges <= 1 && spell.charge_recovery_time > cooldown_ms {
    cooldown_ms = spell.charge_recovery_time;
}

Effects

The payload of a spell lives in its effects. effects is a Vec<SpellEffect>, and each SpellEffect is one DBC effect row carrying its own type, aura sub-type, base points, and the two coefficients that drive damage:

rust
pub struct SpellEffect {
    /// 0-indexed; `$s1` etc. are 1-indexed in descriptions.
    pub index: i32,
    pub effect: i32,
    /// Aura type if this is an apply-aura effect.
    pub aura: i32,
    /// The `$s` value.
    pub base_points: f64,
    /// Aura tick period in ms - the `$t` value.
    pub period: i32,
    /// The `$x` value.
    pub chain_targets: i32,
    pub trigger_spell: i32,
    /// School, mechanic, etc. depending on effect type.
    pub misc_value_0: i32,
    pub misc_value_1: i32,
    /// For the `$a` value.
    pub radius_min: f32,
    /// For the `$a` value.
    pub radius_max: f32,
    pub coefficient: f32,
    pub variance: f32,
    /// Bonus coefficient from spell power.
    pub bonus_coefficient: f64,
    /// Bonus coefficient from attack power.
    pub bonus_coefficient_from_ap: f64,
    pub amplitude: f32,
    pub pvp_multiplier: f32,
}

A few of those fields carry conventions worth calling out. index is 0-based in the flat row even though the same effect is $s1 (1-based) in descriptions. effect is the effect type id and aura is the aura sub-type id. coefficient and variance are the direct-damage roll, while bonus_coefficient is the spell-power coefficient and bonus_coefficient_from_ap is the attack-power coefficient.

The coefficient naming is the one trap here. When the resolution pass builds the engine's damage view it reads bonus_coefficient as the SP coefficient and bonus_coefficient_from_ap as the AP coefficient. An effect whose direct coefficients are both zero but which redirects to a trigger spell is followed down a bounded chain to find the real payload. I cover that trigger-chain walk in Codegen.

There is a subtle index mismatch to keep in your head. The flat SpellEffect.index is 0-based, but the resolver's get_spell_effect(spell_id, effect_index) takes a 1-based index because that is the author-facing convention used in manifests and overrides. The local resolvers translate between the two via validate_effect_index, which subtracts one and treats index 0 as "not found":

rust
pub(crate) fn validate_effect_index(
    spell_id: SpellId,
    effect_index: u8,
    effects: &[SpellEffect],
) -> Result<SpellEffect, ResolverError> {
    if effect_index == 0 {
        return Err(ResolverError::SpellEffectNotFound {
            spell_id,
            effect_index,
        });
    }
    effects
        .get((effect_index as usize) - 1)
        .cloned()
        .ok_or(ResolverError::SpellEffectNotFound {
            spell_id,
            effect_index,
        })
}

Mixing the two conventions is the single easiest way to read the wrong effect.

The remaining sub-types are small: PowerCostEntry { power_type, cost, cost_pct, optional_cost } describes one resource cost, EmpowerStage { stage, duration_ms } one empower tier, and LearnSpell { learn_spell_id, overrides_spell_id } the learn/override linkage. None of them need their own page; they exist only to keep the per-spell record self-contained.

Next steps