From 0ad4a983ce2a1e2e759f195e905128e17a0e1d8c Mon Sep 17 00:00:00 2001 From: T3 Code Date: Mon, 7 Sep 2026 17:18:30 +0000 Subject: [PATCH 1/7] fix(importer): restore build 5875 spell effects --- docs/data-ingestion.md | 47 +- importer/Cargo.toml | 2 +- importer/src/item_property.rs | 80 +-- importer/src/main.rs | 5 + importer/src/spell.rs | 1129 ++++++++++++++++++--------------- importer/src/spell_dbc.rs | 223 +++++++ 6 files changed, 893 insertions(+), 593 deletions(-) create mode 100644 importer/src/spell_dbc.rs diff --git a/docs/data-ingestion.md b/docs/data-ingestion.md index 5fc17e6ce..a206c3f19 100644 --- a/docs/data-ingestion.md +++ b/docs/data-ingestion.md @@ -330,13 +330,46 @@ dump. records what its rows were checked against, because "hand-authored" is not the same as "unverified" and the two must not be conflated. -**Reference source.** `DBFilesClient/Spell.dbc`, read out of a locally owned 1.12.1 client install -(`Data/dbc.MPQ` + `patch*.MPQ` patch chain, build 5875), through the same `wow-mpq` + `wow_dbc` read -path `importer/src/dbc.rs` already uses. **Revision:** the client's own shipped DBC — there is no -upstream revision to pin beyond the build number, and `wow_dbc` hard-asserts the 5875 record shape, so -a wrong-version client fails the read rather than producing silent drift. The probe was a throwaway -example, run once, deleted after; it printed derived facts (id, name, rank subtext, base points) and -wrote nothing. **No client bytes entered the repository tree**, matching the firewall above. +**Reference source.** The Operator supplies build 5875 client archives. The importer reads +`DBFilesClient/Spell.dbc` in memory through `wow-mpq`. `importer/src/spell_dbc.rs` checks the 173-field, +692-byte layout and reads the columns shared by spell and item enchantment import. Other DBC tables +still use `wow_dbc`. No client bytes enter the repository. + +The Spell layout follows the pinned [SpellEntry definition](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Server/DBCStructure.h). +`wow_dbc` 0.3 omits column 21, InterruptFlags. Its named fields stay shifted through the effect arrays. +An extra field restores alignment at the localized name, column 120. + +| Consumed fields | Build 5875 columns | +|---|---| +| ID, school, dispel, mechanic | 0, 1, 4, 5 | +| Attributes, AttributesEx1, AttributesEx2, stance mask | 6, 7, 8, 11 | +| Required spell focus, cast time, recovery times | 15, 18, 19, 20 | +| Aura interrupt, Proc mask, chance, charges | 22, 24, 25, 26 | +| Maximum level, spell level, duration, power, cost, range, stack count | 27, 29, 30, 31, 32, 36, 39 | +| Reagents and counts, equipment class and masks | 42..49, 50..57, 58..60 | +| Effects, die sides, base dice, dice scaling, level scaling, base points | 61..63, 64..66, 67..69, 70..72, 73..75, 76..78 | +| Mechanics, target A, target B, radii, auras, amplitudes | 79..81, 82..84, 85..87, 88..90, 91..93, 94..96 | +| Multipliers, chain targets, item references, miscellaneous values, triggered spells, combo scaling | 97..99, 100..102, 103..105, 106..108, 109..111, 112..114 | +| English name, family, complete 64-bit family mask | 120, 160, 161..162 | + +Spell output retains its existing magnitude convention, base points plus one, and auxiliary-table +range, radius, duration and cast-time resolution. Item enchantments retain base points plus base dice. +The normalized input preserves all three slots and raw aura numbers. Unsupported effects remain +`E_SCRIPTED`, and unsupported item enchantment effects remain Stat Kind 0. + +[Target definitions](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Spells/SpellTargetDefines.h) +define 6 as enemy, 21 and 57 as friendly units, and 22 as the caster's source location. Location targets +use the companion selection where present. The importer translates vanilla damage and movement +interrupt bits into the Module's compact mask. Gouge's control and Stealth's speed penalty come from +the third source effect. Their former synthetic additions are removed. The Human Spirit maps directly +to its percentage aura. Evocation's existing periodic-mana correction now addresses source slot 0. +The authored seal marker and Power Word: Shield's linked Weakened Soul rule remain because their +Module behavior is not an equivalent DBC effect. + +Every World Import Profile loads the same full Spell catalogue. After this importer change, each +World Shard and Instance Pool needs the normal Spell reimport and curated class-spell pass. Rebuild +Base Snapshots and review Package Deltas that name affected effect indices before replaying them. +Publishing the Module alone does not correct stored rows. No live Realm update is part of this change. **Not used, deliberately:** cmangos `spell_group` / `spell_group_stack_rules`. That dataset would answer the rule questions directly, but it is GPL-licensed content this project ships zero rows of, and diff --git a/importer/Cargo.toml b/importer/Cargo.toml index 02def9237..c5c794a12 100644 --- a/importer/Cargo.toml +++ b/importer/Cargo.toml @@ -35,7 +35,7 @@ wow-m2 = "=0.7.0" wow-mpq = "=0.7.0" wow-wmo = "=0.7.0" wow_dbc = { version = "0.3", features = ["vanilla"] } -# The Spell.dbc importer matches on the typed `AuraMod` enum + reads `Attributes` flags off `SpellRow`. +# The Spell.dbc importer maps numeric aura columns through the typed `AuraMod` enum. # Those types live in wow_world_base; wow_dbc 0.3 pins it to =0.2.0, so we depend on the SAME version # (same type instance) to name the variants by hand. Pure parser, ships ZERO Blizzard data. wow_world_base = { version = "=0.2.0", features = ["vanilla"] } diff --git a/importer/src/item_property.rs b/importer/src/item_property.rs index 845a728b0..30ca68ba6 100644 --- a/importer/src/item_property.rs +++ b/importer/src/item_property.rs @@ -1,5 +1,6 @@ //! Derive Random Property catalogues from the Operator's client and ClassicDB dump. +use crate::spell_dbc::{read_spells, Spell}; use crate::{dbc, field, parse_table, push_insert, sql_text}; use anyhow::{bail, Context, Result}; use lyracore_shared::item_property as kind; @@ -19,65 +20,6 @@ struct Effect { school_mask: u32, } -#[derive(Default)] -struct EquipSpell { - id: u32, - equipped_item_class: u32, - effect: [u32; 3], - effect_die_sides: [i32; 3], - effect_base_dice: [u32; 3], - effect_dice_per_level: [f32; 3], - effect_real_points_per_level: [f32; 3], - effect_base_points: [i32; 3], - effect_aura: [u32; 3], - effect_misc_value: [u32; 3], - effect_points_per_combo: [f32; 3], -} - -/// Read build 5875 column positions. wow_dbc 0.3 omits InterruptFlags and shifts the -/// equipment fields and effect arrays, so its named SpellRow fields cannot define this catalogue. -fn read_equip_spells(bytes: &[u8]) -> Result> { - if bytes.len() < 20 || &bytes[..4] != b"WDBC" { - bail!("Spell.dbc has no WDBC header"); - } - let header = |at: usize| u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap()) as usize; - let (count, fields, size, strings) = (header(4), header(8), header(12), header(16)); - if fields != 173 || size != 692 { - bail!("Spell.dbc requires the build 5875 layout: 173 fields, 692 bytes per row"); - } - let records_end = count.checked_mul(size).and_then(|n| n.checked_add(20)); - if records_end.and_then(|n| n.checked_add(strings)) != Some(bytes.len()) { - bail!("Spell.dbc record and string lengths do not match its header"); - } - let mut ids = BTreeSet::new(); - bytes[20..records_end.unwrap()] - .chunks_exact(size) - .map(|row| { - // Every referenced column fits the checked fixed-width record. - let column = - |at: usize| u32::from_le_bytes(row[at * 4..at * 4 + 4].try_into().unwrap()); - let array = |at: usize| std::array::from_fn(|i| column(at + i)); - let id = column(0); - if !ids.insert(id) { - bail!("duplicate Spell.dbc row {id}"); - } - Ok(EquipSpell { - id, - equipped_item_class: column(58), - effect: array(61), - effect_die_sides: array(64).map(|n| n as i32), - effect_base_dice: array(67), - effect_dice_per_level: array(70).map(f32::from_bits), - effect_real_points_per_level: array(73).map(f32::from_bits), - effect_base_points: array(76).map(|n| n as i32), - effect_aura: array(91), - effect_misc_value: array(106), - effect_points_per_combo: array(112).map(f32::from_bits), - }) - }) - .collect() -} - fn resistance_kind(school: u32) -> Option { match school { 0 => Some(kind::ARMOR), @@ -122,7 +64,7 @@ fn aura_kinds(aura: u32, misc: u32) -> Vec<(u8, u32)> { } } -fn spell_effects(slot: u8, spell: &EquipSpell) -> Result> { +fn spell_effects(slot: u8, spell: &Spell) -> Result> { let mut mapped = Vec::new(); for subeffect in 0..3 { if spell.effect[subeffect] == 0 { @@ -134,7 +76,7 @@ fn spell_effects(slot: u8, spell: &EquipSpell) -> Result> { && spell.effect_dice_per_level[subeffect] == 0.0 && spell.effect_points_per_combo[subeffect] == 0.0 && spell.effect_die_sides[subeffect] <= 1 - && spell.equipped_item_class == u32::MAX; + && spell.equipment[0] == u32::MAX; let kinds = if flat { aura_kinds(aura, spell.effect_misc_value[subeffect]) } else { @@ -174,14 +116,14 @@ fn spell_effects(slot: u8, spell: &EquipSpell) -> Result> { pub(crate) fn catalogue_sql(chain: &mut PatchChain) -> Result<(Vec, BTreeSet, u64)> { let properties: ItemRandomProperties = dbc::read_table(chain)?; let enchantments: SpellItemEnchantment = dbc::read_table(chain)?; - let spells = read_equip_spells(&chain.read_file("DBFilesClient\\Spell.dbc")?)?; + let spells = read_spells(&chain.read_file("DBFilesClient\\Spell.dbc")?)?; catalogue_rows_sql(&properties, &enchantments, &spells) } fn catalogue_rows_sql( properties: &ItemRandomProperties, enchantments: &SpellItemEnchantment, - spells: &[EquipSpell], + spells: &[Spell], ) -> Result<(Vec, BTreeSet, u64)> { for (name, count) in [ ("ItemRandomProperties.dbc", properties.rows().len()), @@ -471,7 +413,7 @@ mod tests { let at = 20 + column * 4; bytes[at..at + 4].copy_from_slice(&value.to_le_bytes()); } - let spells = read_equip_spells(&bytes).unwrap(); + let spells = read_spells(&bytes).unwrap(); let effects = spell_effects(0, &spells[0]).unwrap(); assert_eq!( effects, @@ -500,7 +442,7 @@ mod tests { ] ); bytes[12..16].copy_from_slice(&688u32.to_le_bytes()); - assert!(read_equip_spells(&bytes).is_err()); + assert!(read_spells(&bytes).is_err()); } // Authored zero records let tests exercise the real DBC parser without client bytes. @@ -513,7 +455,7 @@ mod tests { T::read(&mut std::io::Cursor::new(bytes)).unwrap() } - fn catalogues() -> (ItemRandomProperties, SpellItemEnchantment, Vec) { + fn catalogues() -> (ItemRandomProperties, SpellItemEnchantment, Vec) { let mut properties: ItemRandomProperties = synthetic_table(16); properties.rows[0].id.id = 509_0001; properties.rows[0].spell_item_enchantment = [509_0002, 0, 0, 0, 0]; @@ -523,7 +465,7 @@ mod tests { enchantments.rows[0].enchantment_type = [5, 5, 77]; enchantments.rows[0].effect_arg = [7, 4, 0]; enchantments.rows[0].effect_points_min = [7, 9, 11]; - (properties, enchantments, vec![EquipSpell::default()]) + (properties, enchantments, vec![Spell::default()]) } #[test] @@ -593,10 +535,10 @@ mod tests { #[test] fn equip_spell_expands_stats_and_retains_unmapped_effects() { - let mut spell = EquipSpell::default(); + let mut spell = Spell::default(); let spell = &mut spell; spell.id = 509_0003; - spell.equipped_item_class = u32::MAX; + spell.equipment[0] = u32::MAX; spell.effect = [6, 6, 2]; spell.effect_aura[0] = 29; spell.effect_base_dice = [1; 3]; diff --git a/importer/src/main.rs b/importer/src/main.rs index 1b19dc2ec..1c7533e44 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -60,6 +60,7 @@ mod nav; mod pack_client; mod package_delta; mod spell; +mod spell_dbc; mod spell_snapshot; mod talent; mod terrain; @@ -67,6 +68,10 @@ mod ui_transform; mod vmap; mod world_import_scope; +#[cfg(test)] +#[path = "../../module/tests/support/mod.rs"] +mod standalone_support; + use std::collections::HashMap; use std::io::Read; use std::process::Command; diff --git a/importer/src/spell.rs b/importer/src/spell.rs index 14a6624f3..09b3de4d6 100644 --- a/importer/src/spell.rs +++ b/importer/src/spell.rs @@ -11,14 +11,14 @@ //! Blizzard file) is ever written; only derived `game_spell`/`game_spell_effect` rows are emitted. //! //! The AuraMod variant names + instant-effect numeric IDs are matched by hand against -//! `wow_world_base::vanilla` (the enum `wow_dbc 0.3` carries in `SpellRow.effect_aura`). Both +//! `wow_world_base::vanilla`. The shared build 5875 reader supplies the numeric columns. Both //! mapping tables come from the design report; unmapped → `E_SCRIPTED` for graceful coverage. use std::collections::BTreeMap; +use crate::spell_dbc::{read_spells, Spell}; use anyhow::{Context, Result}; -use wow_dbc::vanilla_tables::spell::Spell as DbcSpell; -use wow_dbc::vanilla_tables::spell_cast_times::SpellCastTimes; +use wow_dbc::vanilla_tables::spell_cast_times::{SpellCastTimes, SpellCastTimesKey}; use wow_dbc::vanilla_tables::spell_duration::{SpellDuration, SpellDurationKey}; use wow_dbc::vanilla_tables::spell_radius::{SpellRadius, SpellRadiusKey}; use wow_dbc::vanilla_tables::spell_range::{SpellRange, SpellRangeKey}; @@ -228,9 +228,6 @@ const ATTR_EX2_AUTOREPEAT: u32 = 0x0000_0020; // the A_PERIODIC_TRIGGER aura kind (lockstep with module/src/spell/taxonomy.rs) — a CHANNEL's per-tick trigger. const A_PERIODIC_TRIGGER: u8 = 0x93; -// game_spell.aura_interrupt bit 0 = break-on-damage (the CC-breaks-when-hit flag). -const AURA_INTERRUPT_BREAK_ON_DAMAGE: u16 = 0x0001; - // vanilla SpellEffect numeric IDs that PLACE an aura → branch to the AuraMod map. const EFFECT_APPLY_AURA: i32 = 6; const EFFECT_PERSISTENT_AREA_AURA: i32 = 27; @@ -278,35 +275,14 @@ fn dbc_flat_amount(raw: i32) -> i32 { raw + 1 } -/// The header's `proc_flags`/`proc_chance`/`proc_charges` from the three raw column values the -/// off-by-one schema bug lands them under (see the SCHEMA BUG comment at the header build site — -/// real procFlags/procChance/procCharges, DBC columns 24/25/26, read out through the wow_dbc fields -/// NAMED `proc_chance`/`proc_charges`/`max_level`, not the ones named for them). `proc_chance` is a -/// vanilla percent (clamped into the column's `u8`, so a >255 value saturates at 100% intent — no -/// real spell exceeds 100); `proc_charges` is a count (0 = unlimited). Pure. [import] -fn proc_header_fields( - real_proc_flags: i32, - real_proc_chance: i32, - real_proc_charges: i32, -) -> (u32, u8, u8) { - ( - real_proc_flags as u32, - real_proc_chance.clamp(0, 255) as u8, - real_proc_charges.clamp(0, 255) as u8, - ) +/// Preserve the event mask and clamp chance and charges to the durable byte columns. +fn proc_header_fields(flags: u32, chance: u32, charges: u32) -> (u32, u8, u8) { + (flags, chance.min(255) as u8, charges.min(255) as u8) } -/// The importer's own `game_spell.aura_interrupt` bits: only bit 0 (break-on-damage) and bit 1 -/// (break-on-move) are read from the raw vanilla `AuraInterruptFlags` — every other vanilla bit -/// (including the underwater-cancel flag a land mount carries) is structurally dropped by this mask, -/// never reaching the engine. The curated incapacitate ids (Polymorph 118 / Gouge 1776 / Sap 6770) -/// additionally OR bit 0 on for synthetic CC whose DBC flag may be absent (Gouge). Pure. [import] -fn aura_interrupt_bits(raw: u16, spell_id: u32) -> u16 { - let mut bits = raw & 0x0003; - if matches!(spell_id, 118 | 1776 | 6770) { - bits |= AURA_INTERRUPT_BREAK_ON_DAMAGE; - } - bits +/// Translate vanilla damage and movement flags into the Module's compact mask. +fn aura_interrupt_bits(raw: u32) -> u16 { + u16::from(raw & 0x2 != 0) | (u16::from(raw & 0x8 != 0) << 1) } /// Curated load-time flag correction (the Spell.sql analog for header flags): OR our OWN cast-gate bits @@ -388,30 +364,6 @@ fn is_ranged_auto_repeat(attributes_ex2: u32, name: &str) -> bool { attributes_ex2 & ATTR_EX2_AUTOREPEAT != 0 || matches!(name, "Auto Shot" | "Shoot") } -/// A synthetic A_CONTROL incapacitate effect-row tuple to ADD for a spell whose CC is NOT in the DBC -/// effect rows (Gouge encodes its 4s incapacitate as a header SpellMechanic, not an aura effect — confirmed -/// by the raw-data investigation). Returns the row to inject at `effect_index`. The shape is the exact one -/// `control.rs` reads: kind = A_CONTROL, p0 = M_POLY (incapacitate family — `is_incapacitated` reads -/// M_STUN||M_POLY; NOT a true stun so it dodges stun-DR), p0_kind = P_MECHANIC, target = T_TARGET_ENEMY. -/// Duration rides the spell HEADER `duration_ms` (Gouge = 4000ms). The break-on-damage flag rides the -/// header `aura_interrupt` (forced on below). Spells that ALREADY carry a control row in the DBC (Sap 6770) -/// need NO synthetic add — return None. -fn synthetic_control_effect(spell_id: u32, name: &str, used_index: u8) -> Option { - // Gouge (real spell 1776) — DAMAGE + combo only in the DBC; ADD the incapacitate. The wrapper (1780) - // is a pure combo-point trigger → no control. Key on the real id so only the cast target gains the CC. - if !(spell_id == 1776 && name == "Gouge") { - return None; - } - Some(SpellEffectRow { - kind: A_CONTROL, - base_points: 1, - target: T_TARGET_ENEMY, - p0: M_POLY, - p0_kind: P_MECHANIC, - ..blank_effect(spell_id, used_index) - }) -} - /// An effect row with every column at its inert value, for the curated additions to fill in. Naming /// only the columns that carry meaning is what makes each synthetic row readable. fn blank_effect(spell_id: u32, effect_index: u8) -> SpellEffectRow { @@ -436,45 +388,8 @@ fn blank_effect(spell_id: u32, effect_index: u8) -> SpellEffectRow { } } -/// A synthetic A_MOD_SPEED(MOVE, −30%) effect-row tuple to ADD for Stealth — the sneak move-slow that the -/// DBC carries (eff2 of 1784) but the importer DROPS (the "Stealth" name-override collapsed it under -/// A_STEALTH; its raw aura isn't in the A_MOD_SPEED variant list, so it never surfaced as a speed aura). -/// Re-added here so the gateway's negative-A_MOD_SPEED(MOVE) sum (Phase 6) slows the stealthed rogue. The -/// existing A_STEALTH effect is UNTOUCHED — this is an ADDITION (Stealth stays a stealth presence). Self -/// aura: target = T_SELF; p0 = SPEED_MOVE, p0_kind = P_SPEED_KIND; amount = −30 (signed percent). -fn synthetic_stealth_slow_effect( - spell_id: u32, - name: &str, - used_index: u8, -) -> Option { - if !(spell_id == 1784 && name == "Stealth") { - return None; // only the real Stealth (1784); the wrapper 1789 just triggers it - } - Some(SpellEffectRow { - kind: A_MOD_SPEED, - base_points: -30, // signed percent slow - p0: SPEED_MOVE, - p0_kind: P_SPEED_KIND, - ..blank_effect(spell_id, used_index) - }) -} - -/// A synthetic `A_SEAL` effect-row tuple to ADD for Seal of the Crusader (21082) — the seal-taxonomy fix. -/// Live `game_spell_effect` for 21082 carries ONLY `ModAttackPower`(eff1, → A_MOD_COMBAT) + -/// `ModMeleeHaste`-family(eff2, → A_MOD_SPEED): a plain self-buff, not a `A_SEAL` marker like Seal of -/// Righteousness's inert-`A_FLAG`-turned-`A_SEAL` (there is no residue effect to reclassify by name — both -/// DBC slots are already real, correctly-mapped kinds, so `correct_script_effect_kind` never sees them). -/// Left alone, SoC neither displaces SoR (`cast.rs`'s single-active-seal rule keys on `eff_kind == A_SEAL`) -/// nor feeds Judgement (`seal_amount` sums `A_SEAL` auras only). ADD a third, SELF-targeted `A_SEAL` effect -/// at the first free slot (index 2 — SoC only fills 0/1) so SoC becomes a real seal through the EXACT SAME -/// generic machinery as SoR: single-active-seal exclusion in `aura_apply` + the `E_JUDGEMENT` burst-then- -/// consume in `apply_effect`. The AP (eff0) and haste (eff1) rows are UNTOUCHED — this is a pure addition, -/// so SoC keeps its own buff exactly as before. `base_points` is the Judgement-of-the-Crusader burst -/// magnitude — a curated flat holy-damage figure (DISTINCT from SoR's own per-swing `A_SEAL` base_points, -/// which rides straight off SoR's own DBC row), since there is no vanilla effect row to source it from; -/// tuned low (matching a lowbie SoR judgement burst) as this content targets the Elwynn 1-10 band. Keyed on -/// the real id (21082) so only THIS seal gains the synthetic row — a same-named higher rank would need its -/// own id here (none exists at the time of writing). Mirrors the Gouge/Stealth synthetic-effect precedent. +/// Preserve the curated seal marker and its Judgement amount. The third client effect +/// is a Dummy whose magnitude names another spell, not the Module's flat damage amount. fn synthetic_seal_effect(spell_id: u32, name: &str, used_index: u8) -> Option { if !(spell_id == 21082 && name == "Seal of the Crusader") { return None; @@ -486,14 +401,7 @@ fn synthetic_seal_effect(spell_id: u32, name: &str, used_index: u8) -> Option i32 { if spell_id == 17 && name == "Power Word: Shield" && kind == A_ABSORB { return 6788; // Weakened Soul @@ -991,95 +899,38 @@ fn resolve_instant_params(kind: u8, misc: u32, item_type: i32) -> (i32, u8) { match kind { E_ENERGIZE => ((misc & 0xFF) as i32, P_POWER_TYPE), E_DISPEL => ((misc & 0x7F) as i32, P_SCHOOL_MASK), // dispel category (misnomer kept by design) - // CreateItem: p0 is the created item's template entry (effect_item_type, NOT misc_value); the - // count rides in base_points. effect_item_type is in the post-resync effect-array block (no remap). E_CREATE_ITEM => (item_type, P_ITEM_ENTRY), - // Summon (Summon Imp): p0 is the summoned creature's template entry, carried in effect_misc_value - // (the post-resync effect-array block — reads clean despite the col-21 header off-by-one). Mirrors - // how E_CREATE_ITEM routes the item entry into p0. The engine's E_SUMMON_PET handler reads p0 as a - // game_creature_template entry (Imp = 416), despawns any existing pet, then spawns it owned by the caster. + // Summon names a creature template in EffectMiscValue. E_SUMMON_PET => (misc as i32, P_ENTRY), E_DUEL => (misc as i32, P_GAMEOBJECT_ENTRY), _ => (0, P_NONE), // damage/heal/trigger/taunt school is on the header; no p0 } } -/// Map a vanilla implicit-target code (`implicit_target_a`) → our small TargetKind. Vanilla has ~50 -/// implicit-target codes; we collapse to the handful the runtime resolves. `is_negative` biases the -/// "selected unit" code toward enemy/ally so a buff lands on an ally and a nuke on a foe. Anything we -/// don't recognize → T_SCRIPTED (the cast gate resolves it at runtime). -fn resolve_target(target_a: i32, is_negative: bool) -> u8 { - match target_a { - 0 => T_SELF, // NONE → caster-implicit - 1 | 18 | 19 | 21 | 24 | 38 | 39 | 52 => T_SELF, // SELF / caster-centered variants - // selected single unit (enemy-or-ally; polarity decides) - 6 | 25 => { +/// Map unit selections from the pinned vanilla target definitions to Module targets. +/// Location targets use their companion selection in `resolve_effect_target`. +fn resolve_target(target: i32, is_negative: bool) -> u8 { + match target { + 0 | 1 => T_SELF, + 6 => T_TARGET_ENEMY, + 21 | 35 | 37 | 45 | 57 | 61 => T_TARGET_ALLY, + 25 => { if is_negative { T_TARGET_ENEMY } else { T_TARGET_ALLY } } - 5 | 26 => T_TARGET_ANY, // chain/in-front + scripted-near → any single - 20 | 22 | 37 | 41 | 43 => T_TARGET_ALLY, // party/pet/master/minion friendly - // area-of-effect (enemy/ally by polarity) - 7 | 8 | 9 | 15 | 16 | 17 | 22000..=i32::MAX => { - if is_negative { - T_AREA_ENEMY - } else { - T_AREA_ALLY - } - } - _ if target_a < 0 => { + 15 | 16 | 28 => T_AREA_ENEMY, + 20 | 29 | 30 | 31 | 33 | 34 | 56 => T_AREA_ALLY, + 7 | 8 => { if is_negative { T_AREA_ENEMY } else { T_AREA_ALLY } } - _ => T_SCRIPTED, // unrecognized → runtime resolution - } -} - -/// Friendly single-target spells whose DBC implicit target collapses to T_SELF (implicit_target_a=0) even -/// though the spell is meant to be cast on an ally-or-self (heal/buff/cleanse). Force T_TARGET_ALLY so the -/// faction gate allows casting on a friendly target and `select_targets`'s T_TARGET_ALLY branch reads the -/// EXPLICIT target instead of the T_SELF branch's caster-only vec; the runtime still falls back to the -/// caster when no friendly target is selected (explicit==0 in `select_targets`), so solo self-cast is -/// unchanged. Established for Arcane Intellect (1459); extended to the paladin/priest friendly -/// single-target kit that hits the exact same DBC-collapse trap — Holy Light (635/639), Lay on Hands -/// (633), Blessing of Might (19740) and Purify (1152) all import with implicit_target_a=0 (work-item 007, "friendly-target-paladin-spells-affect-ally", archived). -/// Keyed on spell NAME (not id) like the sibling by-name overrides above (Slice and Dice, Thunder Clap, -/// Battle Shout) since the importer resolves targets per-effect before ids are threaded through here. -fn friendly_self_or_ally_target_override(name: &str, target: u8) -> u8 { - const FRIENDLY_SELF_OR_ALLY: &[&str] = &[ - "Arcane Intellect", - "Holy Light", - "Lay on Hands", - "Blessing of Might", - "Purify", - // 266/276 live-diagnosis: the priest/druid/shaman healer kits hit the SAME collapse — - // their effects imported T_SELF (or unresolved T_SCRIPTED for Power Word: Shield / - // Healing Wave), so every healer silently healed ITSELF: Renew "succeeded" each tick, - // landed on the caster, ate the GCD, and no party member was ever healed. Affects real - // players too (a clicked friendly heal resolved to the caster). - "Renew", - "Lesser Heal", - "Heal", - "Greater Heal", - "Power Word: Shield", - "Power Word: Fortitude", - "Rejuvenation", - "Healing Touch", - "Regrowth", - "Mark of the Wild", - "Healing Wave", - "Lesser Healing Wave", - ]; - if (target == T_SELF || target == T_SCRIPTED) && FRIENDLY_SELF_OR_ALLY.contains(&name) { - T_TARGET_ALLY - } else { - target + _ => T_SCRIPTED, } } @@ -1111,9 +962,16 @@ fn build_spell_rows( data_dir: &Path, only: &[u32], trainers: &[(u32, Vec)], +) -> Result<(SpellRows, Coverage, Vec)> { + derive_spell_rows(&open_spell_tables(data_dir)?, only, trainers) +} + +fn derive_spell_rows( + dbc: &SpellDbc, + only: &[u32], + trainers: &[(u32, Vec)], ) -> Result<(SpellRows, Coverage, Vec)> { let allow: std::collections::HashSet = only.iter().copied().collect(); - let dbc = open_spell_tables(data_dir)?; let mut acc = SpellAccumulator::default(); // Mount creature-template display resolution (entry → display id): the `--dbc --spells` CLI path // is DBC-only (no cmangos creature_template dump is loaded alongside it), so this is empty today — @@ -1122,8 +980,8 @@ fn build_spell_rows( // this map later is then a one-line change here, not a rewrite of the resolution logic itself. let creature_displays: BTreeMap = BTreeMap::new(); - for s in dbc.spells.rows() { - let spell_id = s.id.id; + for s in &dbc.spells { + let spell_id = s.id; if spell_id == 0 { continue; // the 0 placeholder } @@ -1139,7 +997,7 @@ fn build_spell_rows( // this data instead of a hardcoded id list. Deterministic id (spell_id<<3)|slot → // idempotent clear+reload. Skip empty slots (reagent 0 or count 0). for (slot, (&item, &count)) in s.reagent.iter().zip(s.reagent_count.iter()).enumerate() { - if item <= 0 || count <= 0 { + if item <= 0 || count == 0 { continue; } let id = ((spell_id as u64) << 3) | slot as u64; @@ -1147,8 +1005,8 @@ fn build_spell_rows( .push(format!("({id},{spell_id},{item},{count})")); } - let header = push_spell_header_row(s, &dbc, &allow, &mut acc); - push_spell_effect_rows(s, &header, &dbc, &creature_displays, &allow, &mut acc); + let header = push_spell_header_row(s, dbc, &allow, &mut acc); + push_spell_effect_rows(s, &header, dbc, &creature_displays, &allow, &mut acc); } let trainer_rows = trainer_offering_rows(trainers, &acc.spell_levels, &acc.wrapper_to_rank); @@ -1169,7 +1027,7 @@ fn build_spell_rows( /// The client tables the spell rows derive from, read once per import. struct SpellDbc { - spells: DbcSpell, + spells: Vec, cast_times: SpellCastTimes, ranges: SpellRange, durations: SpellDuration, @@ -1181,14 +1039,14 @@ fn open_spell_tables(data_dir: &Path) -> Result { let mut chain = open_chain(data_dir)?; eprintln!("spells: opened MPQ chain from {}", data_dir.display()); - let spells: DbcSpell = read_table(&mut chain)?; + let spells = read_spells(&chain.read_file("DBFilesClient\\Spell.dbc")?)?; let cast_times: SpellCastTimes = read_table(&mut chain)?; let ranges: SpellRange = read_table(&mut chain)?; let durations: SpellDuration = read_table(&mut chain)?; let radii: SpellRadius = read_table(&mut chain)?; eprintln!( "spells: parsed Spell({}) + SpellCastTimes({}) + SpellRange({}) + SpellDuration({}) + SpellRadius({})", - spells.rows().len(), + spells.len(), cast_times.rows().len(), ranges.rows().len(), durations.rows().len(), @@ -1241,7 +1099,7 @@ struct ResolvedEffect { /// Derive one spell's `game_spell` header row and push it, returning the values the effect mapping /// reads back. fn push_spell_header_row( - s: &wow_dbc::vanilla_tables::spell::SpellRow, + s: &Spell, dbc: &SpellDbc, allow: &std::collections::HashSet, acc: &mut SpellAccumulator, @@ -1254,50 +1112,23 @@ fn push_spell_header_row( spell_levels, .. } = acc; - let spell_id = s.id.id; - // --- header (game_spell) --- - // - // ⚠ wow_dbc 0.3 vanilla `Spell` SCHEMA BUG — off-by-one field NAMES from column 21 on. - // The crate's vanilla `SpellRow` is missing the `InterruptFlags` column (real col 21), so from - // there every field is NAMED as the NEXT real column. The BYTES are read sequentially and are - // correct (read position N == real column N); only the wow_dbc field *name* on each is wrong. - // It resyncs before the effect arrays (those read correctly). So to get a logically-correct - // value we read the wow_dbc field whose READ POSITION matches the real column: - // real powerType = s.mana_cost (col 31) - // real manaCost = s.mana_cost_per_level (col 32) - // real DurationIdx = s.power_type (col 30) - // real spellLevel = s.duration.id (col 29) - // real maxLevel = s.base_level (col 27) - // real rangeIndex = s.speed (int bytes read as f32 → recover via .to_bits()) (col 36) - // real stackAmount = s.totem[0] (col 39) - // real AuraIntFlags = s.channel_interrupt_flags (col 22) - // real procFlags = s.proc_chance (col 24) - // real procChance = s.proc_charges (col 25) - // real procCharges = s.max_level (col 26) - // VERIFIED by `--only` dry-run against known values: Fireball powerType=0(mana)/30 mana/4s DoT/ - // 35yd; Battle Shout rage/10/120s; Slam rage/15/L30/instant; Sunder rage/15/L10/30s/STACK=5; - // Frost Armor (168) procFlags=0x28/procChance=100/procCharges=0; Lightning Shield (324) - // procChance=100/procCharges=3 (its real procFlags is a richer taken-hit mask, not 0x28 — every - // "taken" bit, matching its real behavior of zapping back at any attack, not just melee). - // (cast_time/cooldown/gcd/school/dispel/mechanic/attributes/effects are pre-col-21 or post-resync - // → read directly.) Rage costs are stored ×10 in BOTH the DBC and our power bar, mana ×1 in both, - // so `manaCost` imports with no scaling. - let name = s.name.en_gb.clone(); - let power_type = s.mana_cost as u8; // real PowerType: 0 mana / 1 rage / 3 energy / 255 health - let cost = s.mana_cost_per_level.max(0) as u32; // real ManaCost (rage already ×10) + let spell_id = s.id; + let name = s.name.clone(); + let power_type = s.power_type as u8; + let cost = s.mana_cost; let cast_time_ms = dbc .cast_times - .get(s.casting_time_index) + .get(SpellCastTimesKey::new(s.casting_time_index)) .map(|r| r.base.max(0) as u32) .unwrap_or(0); // RecoveryTime is the spell-specific cooldown; CategoryRecoveryTime is the CATEGORY cooldown // (e.g. Hammer of Justice 853 = 60s, Divine Protection 498 = 5min). Both may carry the real // per-spell cooldown depending on how the DBC authored the spell, so take the max of both so // neither path is silently dropped. gcd_ms is computed below after `attributes` is known. - let cooldown_ms = s.recovery_time.max(0).max(s.category_recovery_time.max(0)) as u32; + let cooldown_ms = s.recovery_time.max(s.category_recovery_time); let range_yd = dbc .ranges - .get(SpellRangeKey::new(s.speed.to_bits())) // real rangeIndex (see schema-bug note) + .get(SpellRangeKey::new(s.range)) .map(|r| r.range_max.max(0.0) as u32) .unwrap_or(0); // DBC duration -1 means INFINITE (toggle auras like Devotion Aura 465, Battle Stance, etc.). @@ -1307,7 +1138,7 @@ fn push_spell_header_row( // entry falls back to 0, which expires immediately (fine — those spells have no aura effect). let duration_ms = dbc .durations - .get(SpellDurationKey::new(s.power_type.max(0) as u32)) // real DurationIndex + .get(SpellDurationKey::new(s.duration)) .map(|r| { if r.duration == -1 { u32::MAX @@ -1319,46 +1150,25 @@ fn push_spell_header_row( // Spell.dbc `school` is a Resistances.dbc INDEX (0=phys,1=holy,2=fire,3=nature,4=frost, // 5=shadow,6=arcane), NOT a bitmask — our `school_mask` is the bitmask (1< bool { - // Polarity: ATTR bit PASSIVE-or-not isn't a buff/debuff flag; vanilla marks debuffs with - // AttributesEx? Negative/CANT_CANCEL flags that we don't model — derive heuristically from the - // FIRST effect's target/aura (a CC/damage-on-enemy effect ⇒ negative). Refined below per-effect. +fn spell_is_negative(s: &Spell) -> bool { + // A self-only penalty belongs to its buff, such as Stealth's movement penalty. let mut is_negative = false; for i in 0..3 { let eff = s.effect[i]; - if eff == 0 { + if eff == 0 || (s.implicit_target_a[i] == 1 && s.implicit_target_b[i] == 0) { continue; } - let aura = s.effect_aura[i]; + let aura = AuraMod::from_int(s.effect_aura[i]).unwrap_or_default(); // damage / DoT / CC / a debuff-shaped aura on a non-self target ⇒ negative if matches!(eff, 2 | 17 | 58 | 121 | 31) { is_negative = true; @@ -1495,10 +1292,9 @@ fn spell_is_negative(s: &wow_dbc::vanilla_tables::spell::SpellRow) -> bool { is_negative } -/// Derive and push one spell's `game_spell_effect` rows, including the synthetic additions that -/// cover CC/speed/seal data the DBC lacks. +/// Derive effect rows and the curated seal marker from one normalized spell. fn push_spell_effect_rows( - s: &wow_dbc::vanilla_tables::spell::SpellRow, + s: &Spell, header: &SpellHeader, dbc: &SpellDbc, creature_displays: &BTreeMap, @@ -1512,12 +1308,10 @@ fn push_spell_effect_rows( wrapper_to_rank, .. } = acc; - let spell_id = s.id.id; + let spell_id = s.id; let name = header.name.as_str(); // --- effects (game_spell_effect) --- - // Track which effect_index slots the DBC populates, so a SYNTHETIC effect (an ADDED A_CONTROL / - // A_MOD_SPEED) can take the first FREE slot — keeping the deterministic id `(spell_id<<2)|index` - // unique and within the 2-bit (0..3) effect-index space. + // The curated seal marker takes the first unused slot within the packed 2-bit index. let mut used_slots = [false; 4]; // `i` is the DBC EFFECT INDEX, not merely a position in `used_slots`: it addresses four // parallel DBC arrays (`effect`, `effect_aura`, `effect_mechanic`, `effect_item_type`) AND @@ -1544,24 +1338,16 @@ fn push_spell_effect_rows( spell_id, effect_id, s.effect_misc_value[i], - s.requires_spell_focus.id, + s.requires_spell_focus, ); let (kind, p0, p0_kind) = match binding_entry { Some(entry) => (E_SUMMON_HOSTILE, entry, P_ENTRY), None => (kind, p0, p0_kind), }; let target = resolve_effect_target(s, i, kind, header); - // Evocation (12051): a channeled 8s self-buff that restores a PERCENT of max mana every 2s - // (~60% over the channel). Its DBC effects are inert markers (a +1500% ModPowerRegenPercent → - // A_FLAG, and a second no-op) that restore no real number. Reclassify the FIRST effect - // (effect_index 1) to a GENERIC A_PERIODIC_ENERGIZE self-tick: period 2000ms, amount 15 (a - // PERCENT — p0_kind P_PCT_MAX_POWER makes aura_apply convert it to an absolute per-tick off the - // caster's max mana), MANA power type (p0=0), self-targeted. The header's CHANNELED flag - // (is_channeled by name) holds the caster 8s; break_channel tears this aura down on move/cast/CC - // (the widened periodic-energize filter). The second no-op effect stays inert. Keyed on name + - // effect index, never engine code. (Mirrors the Consecration/Human-Spirit by-name effect fixes.) + // Evocation's regen-percent aura needs the Module's periodic max-mana effect. let (kind, period_ms, base_points, target, p0, p0_kind) = - if name == "Evocation" && effect_index == 1 { + if name == "Evocation" && effect_index == 0 { ( A_PERIODIC_ENERGIZE, 2000u32, @@ -1594,7 +1380,7 @@ fn push_spell_effect_rows( } let effect_mechanic = s.effect_mechanic[i] as u8; let p1 = if kind == E_SUMMON_HOSTILE { - s.requires_spell_focus.id as i32 + s.requires_spell_focus as i32 } else if kind == E_POWER_BURN { power_burn_ratio_bp(s.effect_multiple_values[i]) } else if kind == A_SPELLMOD_FLAT || kind == A_SPELLMOD_PCT { @@ -1654,17 +1440,9 @@ fn push_spell_effect_rows( } } - // --- SYNTHETIC effect ADDITIONS (the curated correction for CC/speed/seal data the DBC lacks) --- - // A synthetic A_CONTROL (Gouge) / A_MOD_SPEED (Stealth) / A_SEAL (Seal of the Crusader) is ADDED at - // the FIRST free effect_index (the lowest open slot, keeping the deterministic id - // `(spell_id<<2)|index` unique + in the 0..3 range). At most ONE synthetic per spell (Gouge/Stealth/ - // SoC are distinct ids), so a single free slot suffices. These are REAL kinds → counted toward - // coverage like any mapped effect. + // The curated seal marker uses the first slot after the client's populated effects. let synth_slot = used_slots.iter().position(|&u| !u).unwrap_or(3) as u8; - if let Some(synth) = synthetic_control_effect(spell_id, name, synth_slot) - .or_else(|| synthetic_stealth_slow_effect(spell_id, name, synth_slot)) - .or_else(|| synthetic_seal_effect(spell_id, name, synth_slot)) - { + if let Some(synth) = synthetic_seal_effect(spell_id, name, synth_slot) { cov.effects += 1; cov.real += 1; *cov.by_kind.entry(synth.kind).or_default() += 1; @@ -1678,21 +1456,23 @@ fn push_spell_effect_rows( /// Resolve one DBC effect slot to our kind + magnitudes: the aura/instant mapping, then every /// curated reclassify and p0 fix-up that rides on it. fn resolve_effect_kind( - s: &wow_dbc::vanilla_tables::spell::SpellRow, + s: &Spell, i: usize, header: &SpellHeader, creature_displays: &BTreeMap, cov: &mut Coverage, ) -> ResolvedEffect { let effect_id = s.effect[i]; - let aura = s.effect_aura[i]; + let aura = AuraMod::from_int(s.effect_aura[i]).unwrap_or_default(); let name = header.name.as_str(); let school_mask = header.school_mask; let channeled = header.channeled; - let (kind, (p0, p0_kind)) = if is_aura_effect(effect_id) || aura != AuraMod::None { + let (kind, (p0, p0_kind)) = if is_aura_effect(effect_id) || s.effect_aura[i] != 0 { let k = aura_mod_to_kind(aura); if k == E_SCRIPTED { - *cov.unmapped_aura.entry(format!("{aura:?}")).or_default() += 1; + *cov.unmapped_aura + .entry(format!("{} ({aura:?})", s.effect_aura[i])) + .or_default() += 1; } (k, resolve_aura_params(k, aura, s.effect_misc_value[i])) } else { @@ -1706,7 +1486,17 @@ fn resolve_effect_kind( ) }; // Curated correction (Spell.sql analog): reclassify the known script-effect-as-generic spells. - let kind = correct_script_effect_kind(name, kind); + let kind = if name == "Stealth" && aura != AuraMod::ModStealth { + kind + } else { + correct_script_effect_kind(name, kind) + }; + // Gouge uses the incapacitate family, although its client aura is ModStun. + let p0 = if s.id == 1776 && name == "Gouge" && kind == A_CONTROL { + M_POLY + } else { + p0 + }; // DISMOUNT reclassify (data-driven, never by name/id): a raw DISPEL_MECHANIC effect whose // misc value names the mount mechanic becomes E_DISMOUNT. Runs on the RAW effect id (not @@ -1766,16 +1556,8 @@ fn resolve_effect_kind( }; let die_sides = s.effect_die_sides[i]; let per_level = s.effect_real_points_per_level[i]; - // EffectAmplitude is an INTEGER ms in the real DBC, but wow_dbc 0.3 vanilla mis-declares it as - // f32 — so a value like 3000 arrives as the denormal 4.204e-42 and `as u32` would truncate to - // 0 (silently making EVERY imported DoT/HoT never tick). Recover the integer via `.to_bits()`, - // the SAME float-misdeclaration workaround the importer already uses for rangeIndex. Without - // this, Garrote's bleed (and Rend/SW:Pain/Corruption/Curse of Agony) sit dormant and expire. - let period_ms = s.effect_amplitude[i].to_bits(); // amplitude is an INTEGER ms misdeclared as f32 by wow_dbc - // ModRegen (Demon Skin/Armor's health-per-5, work-item 024) is force-ticked every 5000ms by - // vanilla regardless of the DBC's own EffectAmplitude (a behaviour the reference cores show too) — apply that - // override here so the reclassified A_PERIODIC_HEAL effect actually schedules a tick even if - // Spell.dbc carries 0/garbage amplitude for this aura kind. + let period_ms = s.effect_amplitude[i]; + // Vanilla ModRegen ticks every five seconds even when its amplitude is zero. let period_ms = if kind == A_PERIODIC_HEAL && aura == AuraMod::ModRegen { 5000 } else { @@ -1806,17 +1588,6 @@ fn resolve_effect_kind( } else { kind }; - // The Human Spirit: wow_dbc mis-decodes this racial's effect as a FLAT all-stat ModStat, but - // Classic's actual effect is "Mod Stat - %" = +5% SPIRIT (verified vs - // wowhead.com/classic/spell=20598). Force the percent kind + the Spirit stat by name; the - // decoded base_points (5) already carries the 5%. The A_MOD_STAT_PCT recompute fold then makes - // Spirit = round(base * 1.05), Spirit-only. [104] - let (kind, p0, p0_kind) = if name == "The Human Spirit" { - (A_MOD_STAT_PCT, 4, P_STAT_ID) // 4 = Spirit (UNIT_FIELD_STAT4) - } else { - (kind, p0, p0_kind) - }; - ResolvedEffect { kind, p0, @@ -1828,102 +1599,40 @@ fn resolve_effect_kind( } } -/// Resolve one effect's target: the DBC implicit target, then the curated by-name overrides that -/// correct the ones vanilla's data reads wrong. -fn resolve_effect_target( - s: &wow_dbc::vanilla_tables::spell::SpellRow, - i: usize, - kind: u8, - header: &SpellHeader, -) -> u8 { +/// Resolve the DBC target pair, then apply the existing caster and ground-handler conventions. +fn resolve_effect_target(s: &Spell, i: usize, kind: u8, header: &SpellHeader) -> u8 { let name = header.name.as_str(); let is_negative = header.is_negative; let channeled = header.channeled; - let target = resolve_target(s.implicit_target_a[i], is_negative); - // Charge/Judgement/Pick Pocket are inherently ENEMY-targeted; Resurrection is inherently - // ALLY-targeted (a dead friend) — the raw DBC implicit target reads wrong for all of these. - // E_TAUNT joined 2026-07-19 (266): Taunt/Growl carry implicit target 6|25 whose polarity - // fallback read ALLY (they're not is_negative), so the faction gate refused every yank. + let target_a = s.implicit_target_a[i]; + let target_b = s.implicit_target_b[i]; + let selection = if matches!(target_a, 0 | 18 | 22) && target_b != 0 { + target_b + } else { + target_a + }; + let target = resolve_target(selection, is_negative); + // These handlers operate on the caster or a ground location, regardless of unit selection. let target = match kind { - E_CHARGE | E_JUDGEMENT | E_PICKPOCKET | E_INTERRUPT | E_NEXT_SWING | E_TAUNT => { - T_TARGET_ENEMY - } + E_CHARGE | E_JUDGEMENT | E_PICKPOCKET | E_INTERRUPT | E_NEXT_SWING | E_TAUNT + | E_TAME_CREATURE => T_TARGET_ENEMY, E_RESURRECT => T_TARGET_ALLY, - // Feint's threat drop acts on the CASTER as source — force T_SELF so it self-targets - // (the handler reads caster_guid) and the faction gate never trips (self-cast bypass). - E_REDUCE_THREAT => T_SELF, - // Blink (116): a self-cast forward teleport — the handler reads caster_guid only and - // ignores any resolved target, so force T_SELF (fires once, bypasses the faction gate). - E_BLINK => T_SELF, - // Ground-AoE (118): anchor at the CASTER (Consecration is caster-centered) — force T_SELF so - // select_targets yields the caster once (the handler stamps the area at that position) and the - // faction gate is bypassed. A clicked-ground variant anchors at the dest coords instead. - E_PERSISTENT_AREA => T_SELF, - // Summon (Summon Imp): the pet is summoned at the CASTER — the handler reads caster_guid and - // ignores the resolved target. Force T_SELF so `select_targets` yields the caster (the - // summon fires exactly once) AND the faction gate is bypassed (a self-cast imposes no - // faction constraint), so casting it while an enemy is selected still summons the pet. - E_SUMMON_PET | E_SUMMON_HOSTILE => T_SELF, + E_REDUCE_THREAT | E_BLINK | E_PERSISTENT_AREA | E_SUMMON_PET | E_SUMMON_HOSTILE => T_SELF, E_DUEL => T_TARGET_ANY, - E_TAME_CREATURE => T_TARGET_ENEMY, _ => target, }; - // Slice and Dice (a combo FINISHER) is cast AT the enemy you built combo on (to read + spend - // it); its inert marker effect reads ally-typed in the DBC, which makes the faction gate reject - // the enemy cast. Force the marker enemy-targeted — the self-haste effect (T_SELF) is untouched. + // Slice and Dice's selected-unit marker permits spending combo points on the enemy. let target = if name == "Slice and Dice" && target == T_TARGET_ALLY { T_TARGET_ENEMY } else { target }; - // Mind Soothe (453, reduces a hostile creature's aggro radius) and Disarm (676, strips the - // enemy's weapon) are ENEMY debuffs, but their DBC implicit target reads ally/self-typed - // (Mind Soothe → T_SELF/T_TARGET_ALLY; Disarm imports as target=2 = T_TARGET_ALLY), so the - // faction gate would refuse the hostile cast + `select_targets` wouldn't reach the foe. Force - // both onto the enemy. Keyed on name (the Slice and Dice precedent above). - let target = if matches!(name, "Mind Soothe" | "Disarm") { - T_TARGET_ENEMY - } else { - target - }; - // Thunder Clap / Frost Nova are enemy PBAoEs (negative=true) but their DBC implicit - // target reads as a friendly-party code -> mapped T_TARGET_ALLY, so the AoE fan-out - // never fires and the faction gate refuses casting them at a hostile. Force all - // effects to T_AREA_ENEMY so they splash nearby hostiles. (Frost Nova: both effects - // (E_DAMAGE + A_CONTROL M_ROOT) carry target=2 and radius=10 in the DBC; the - // PBAoE fan-out + root already exist in the engine — this is purely a data fix.) - let target = if name == "Thunder Clap" || name == "Frost Nova" { - T_AREA_ENEMY - } else { - target - }; - // Flamestrike (262): the INITIAL-impact nuke (its E_DAMAGE effect) fans out around the - // CLICK — the 118 phase-2 select_targets anchors an area target on the cast's dest when - // one is present. Scoped to E_DAMAGE only: forcing all effects (the Thunder Clap shape) - // would drag the PATCH effect to T_AREA_ENEMY and spawn one ground area per hostile. + // Flamestrike's impact uses the clicked ground location supplied with the cast. let target = if name == "Flamestrike" && kind == E_DAMAGE { T_AREA_ENEMY } else { target }; - // Battle Shout is a party PBAoE buff (EFFECT_APPLY_AREA_AURA_PARTY in the DBC, - // 30yd radius) but its implicit_target_a=20 (TARGET_UNIT_PARTY_CASTER) maps to - // T_TARGET_ALLY (single ally), so only one party member is buffed in a group. - // Force T_AREA_ALLY so the fan-out engine splashes all nearby allies at the - // DBC-imported radius (30yd). Mirrors the Thunder Clap precedent above. - let target = if name == "Battle Shout" { - T_AREA_ALLY - } else { - target - }; - // Arcane Intellect (1459) imports with implicit_target_a=0 → T_SELF, but it is - // a friendly single-target buff (targets yourself OR an ally). Force T_TARGET_ALLY - // so the faction gate allows casting on a friendly target; the engine falls back to - // the caster when no friendly target is selected (same behaviour as Resurrection). - // Same DBC-collapse trap hits the paladin/priest friendly single-target kit — Holy - // Light (635/639), Lay on Hands (633), Blessing of Might (19740) and Purify (1152) all - // import with implicit_target_a=0 (see work-item 007, archived), so they share this override. - let target = friendly_self_or_ally_target_override(name, target); // CHANNEL self-marker: a channeled spell carries an inert A_FLAG "you are channeling" marker // (Arcane Missiles eff2) that the DBC reads as ALLY-targeted — but the channel is cast AT an // ENEMY, so an ally-typed effect would make the faction gate REJECT the enemy cast (the same @@ -2461,6 +2170,466 @@ pub fn run_spells(data_dir: &str, args: &Args) -> Result<()> { mod tests { use super::*; + fn source_spell_fixture() -> Vec { + let mut bytes = b"WDBC".to_vec(); + for value in [1u32, 173, 692, 9] { + bytes.extend_from_slice(&value.to_le_bytes()); + } + bytes.resize(712, 0); + bytes.extend_from_slice(b"\0Fixture\0"); + // Authored build 5875 columns, independent of the dependency's named fields. + for (column, value) in [ + (0, 509_0003), + (1, 4), + (4, 1), + (5, 9), + (6, 64), + (7, 0x0008_0000), + (8, 32), + (11, 1 << 16), + (18, 1), + (19, 1234), + (20, 2345), + (21, 99), + (22, 2), + (23, 88), + (24, 40), + (25, 75), + (26, 3), + (27, 60), + (28, 4), + (29, 7), + (30, 1), + (31, 0), + (32, 33), + (36, 1), + (39, 5), + (41, 987), + (42, 111), + (49, 888), + (50, 2), + (57, 9), + (58, u32::MAX), + (59, 31), + (60, 1_048_608), + (61, 6), + (62, 6), + (63, 64), + (64, 1), + (65, 2), + (66, 3), + (67, 1), + (68, 2), + (69, 3), + (70, 0.25f32.to_bits()), + (71, 0.5f32.to_bits()), + (72, 0.75f32.to_bits()), + (73, 1.5f32.to_bits()), + (74, 2.5f32.to_bits()), + (75, 3.5f32.to_bits()), + (76, 6), + (77, 14), + (78, 20), + (79, 1), + (80, 2), + (81, 3), + (82, 6), + (83, 1), + (84, 6), + (85, 21), + (86, 22), + (87, 23), + (88, 1), + (89, 2), + (90, 0), + (91, 29), + (92, 8), + (93, 0), + (94, 1000), + (95, 2000), + (96, 3000), + (97, 0.5f32.to_bits()), + (98, 1.5f32.to_bits()), + (99, 2.5f32.to_bits()), + (100, 4), + (101, 5), + (102, 6), + (103, 123), + (104, 456), + (105, 789), + (106, 2), + (107, 3), + (108, 4), + (109, 1011), + (110, 1012), + (111, 1013), + (112, 4.5f32.to_bits()), + (113, 5.5f32.to_bits()), + (114, 6.5f32.to_bits()), + (119, 999), + (120, 1), + (160, 3), + (161, 0x8000_0001), + (162, 0x1234_5678), + ] { + bytes[20 + column * 4..24 + column * 4].copy_from_slice(&value.to_le_bytes()); + } + bytes + } + + fn authored_table(fields: u32, rows: &[&[u32]]) -> T { + let mut bytes = b"WDBC".to_vec(); + for value in [rows.len() as u32, fields, fields * 4, 1] { + bytes.extend_from_slice(&value.to_le_bytes()); + } + for row in rows { + for column in 0..fields as usize { + bytes.extend_from_slice(&row.get(column).copied().unwrap_or(0).to_le_bytes()); + } + } + bytes.push(0); + T::read(&mut std::io::Cursor::new(bytes)).unwrap() + } + + fn fixture_tables() -> SpellDbc { + SpellDbc { + spells: read_spells(&source_spell_fixture()).unwrap(), + cast_times: authored_table(4, &[&[1, 1500, 0, 1500]]), + ranges: authored_table(22, &[&[1, 0, 35.0f32.to_bits()]]), + durations: authored_table(4, &[&[1, 12000, 0, 12000]]), + radii: authored_table(4, &[&[1, 7.0f32.to_bits()], &[2, 9.0f32.to_bits()]]), + } + } + + #[test] + #[ignore = "requires LYRACORE_TEST_DBC with the owned build 5875 client"] + fn actual_spell_import_keeps_restored_effects_and_curated_rules() { + let dir = std::env::var("LYRACORE_TEST_DBC").expect("set LYRACORE_TEST_DBC"); + let (rows, _, _) = build_spell_rows( + Path::new(&dir), + &[ + 17, 100, 139, 1459, 1776, 1784, 7728, 7747, 7748, 8674, 12051, 20598, 21082, + ], + &[], + ) + .unwrap(); + let effects = |id| { + rows.effects + .iter() + .filter(|e| e.spell_id == id) + .collect::>() + }; + for (id, entry) in [(7728, 5676), (8674, 5677)] { + let summon = effects(id) + .into_iter() + .find(|e| e.kind == E_SUMMON_HOSTILE) + .unwrap(); + assert_eq!( + (summon.effect_index, summon.p0, summon.p1, summon.target), + (0, entry, 83, T_SELF) + ); + let header = rows.headers.iter().find(|s| s.spell_id == id).unwrap(); + assert_eq!((header.duration_ms, header.cooldown_ms), (360_000, 180_000)); + } + let charge = effects(100); + assert_eq!(charge.len(), 3); + assert_eq!( + ( + charge[2].effect_index, + charge[2].kind, + charge[2].trigger_spell, + charge[2].target + ), + (2, E_TRIGGER, 7922, T_TARGET_ENEMY) + ); + assert_eq!(effects(7748).len(), 1); + assert_eq!( + (effects(7748)[0].effect_index, effects(7748)[0].kind), + (0, E_SCRIPTED) + ); + assert_eq!( + ( + effects(7747)[0].effect_index, + effects(7747)[0].kind, + effects(7747)[0].base_points + ), + (0, A_MOD_HEALTH_POWER, 15) + ); + let gouge = effects(1776); + assert_eq!(gouge.len(), 3); + assert_eq!((gouge[2].kind, gouge[2].p0), (A_CONTROL, M_POLY)); + let stealth = effects(1784); + assert!( + !rows + .headers + .iter() + .find(|s| s.spell_id == 1784) + .unwrap() + .is_negative + ); + assert_eq!(stealth.len(), 3); + assert_eq!(stealth.iter().filter(|e| e.kind == A_STEALTH).count(), 1); + assert_eq!( + (stealth[2].kind, stealth[2].base_points), + (A_MOD_SPEED, -50) + ); + assert_eq!( + ( + effects(12051)[0].kind, + effects(12051)[0].base_points, + effects(12051)[0].period_ms + ), + (A_PERIODIC_ENERGIZE, 15, 2000) + ); + assert_eq!( + ( + effects(20598)[0].kind, + effects(20598)[0].p0, + effects(20598)[0].base_points + ), + (A_MOD_STAT_PCT, 4, 5) + ); + assert_eq!( + (effects(17)[0].target, effects(17)[0].p1), + (T_TARGET_ALLY, 6788) + ); + assert_eq!( + ( + effects(139)[0].target, + effects(139)[0].base_points, + effects(139)[0].period_ms + ), + (T_TARGET_ALLY, 9, 3000) + ); + assert_eq!( + (effects(1459)[0].target, effects(1459)[0].p0), + (T_TARGET_ALLY, 3) + ); + let seal = effects(21082); + assert_eq!(seal.len(), 4); + assert_eq!((seal[3].kind, seal[3].base_points), (A_SEAL, 20)); + assert!(rows.reagents.contains(&"(61984,7748,10940,2)".to_owned())); + assert!(rows.reagents.contains(&"(61985,7748,10938,2)".to_owned())); + } + + fn imported_spell_fixture( + ids: &[u32], + ) -> (crate::standalone_support::Standalone, String, String) { + let dir = std::env::var("LYRACORE_TEST_DBC").expect("set LYRACORE_TEST_DBC"); + let (rows, _, _) = build_spell_rows(Path::new(&dir), ids, &[]).unwrap(); + let mut shard = crate::standalone_support::Standalone::start("imported-spell-columns"); + shard.publish_module(); + for sql in assemble_spell_sql(&rows, ids, false) { + shard.assert_sql(&sql); + } + shard.assert_sql("DELETE FROM game_creature_move_schedule"); + shard.assert_sql("DELETE FROM game_melee_schedule"); + shard.assert_call("debug_spawn_player_entity", &["1"]); + shard.assert_call("debug_set_level", &["1", "10"]); + // The friendly trainer and hostile wolf are this disposable Module's seeded fixtures. + let ally = ((0xf130u64 << 48) | (51001u64 << 24) | 1).to_string(); + let wolf = ((0xf130u64 << 48) | (51000u64 << 24) | 1).to_string(); + for guid in ["1", &ally, &wolf] { + shard.assert_sql(&format!("UPDATE game_world_entity SET x = -8949.95, y = -132.493, z = 83.5312, health = 100, max_health = 1000, power = 1000, max_power = 1000 WHERE guid = {guid}")); + } + (shard, ally, wolf) + } + + #[test] + #[ignore = "requires LYRACORE_TEST_DBC, SpacetimeDB 2.7.1 and the Wasm toolchain"] + fn actual_imported_friendly_spells_affect_the_selected_ally() { + use crate::standalone_support::poll_until; + use std::time::Duration; + let (shard, ally, _) = imported_spell_fixture(&[139, 1459]); + shard.assert_call("debug_cast_at", &["1", "139", &ally]); + let renew = shard.query_rows( + "SELECT target_guid, effect_id, amount, period_ms FROM game_aura WHERE spell_id = 139", + ); + assert_eq!(renew.len(), 1); + assert_eq!(renew[0]["target_guid"], ally); + assert_eq!(renew[0]["amount"], "9"); + assert_eq!(renew[0]["period_ms"], "3000"); + assert!(poll_until(Duration::from_secs(5), || shard.query_rows( + &format!("SELECT health FROM game_world_entity WHERE guid = {ally}") + )[0]["health"] + .parse::() + .unwrap() + >= 109)); + shard.assert_sql("DELETE FROM game_spell_cooldown"); + shard.assert_call("debug_cast_at", &["1", "1459", &ally]); + let intellect = shard + .query_rows("SELECT target_guid, amount, eff_p0 FROM game_aura WHERE spell_id = 1459"); + assert_eq!(intellect[0]["target_guid"], ally); + assert_eq!(intellect[0]["amount"], "2"); + assert_eq!(intellect[0]["eff_p0"], "3"); + } + + #[test] + #[ignore = "requires LYRACORE_TEST_DBC, SpacetimeDB 2.7.1 and the Wasm toolchain"] + fn actual_imported_gouge_control_ends_on_damage() { + let (shard, _, wolf) = imported_spell_fixture(&[686, 1776]); + shard.assert_call("debug_cast_at", &["1", "1776", &wolf]); + let control = + shard.query_rows("SELECT effect_id, eff_p0 FROM game_aura WHERE spell_id = 1776"); + assert_eq!(control.len(), 1); + assert_eq!(control[0]["effect_id"], "7106"); + assert_eq!(control[0]["eff_p0"], "4"); + shard.assert_sql("DELETE FROM game_spell_cooldown"); + shard.assert_call("debug_cast_at", &["1", "686", &wolf]); + assert!(shard + .query_rows("SELECT id FROM game_aura WHERE spell_id = 1776") + .is_empty()); + } + + #[test] + #[ignore = "requires LYRACORE_TEST_DBC, SpacetimeDB 2.7.1 and the Wasm toolchain"] + fn actual_imported_stealth_uses_its_third_source_effect() { + let (shard, _, _) = imported_spell_fixture(&[1784]); + shard.assert_call("debug_cast_at", &["1", "1784", "1"]); + let slow = shard.query_rows( + "SELECT effect_id, amount FROM game_aura WHERE spell_id = 1784 AND eff_kind = 164", + ); + assert_eq!(slow.len(), 1); + assert_eq!(slow[0]["effect_id"], "7138"); + assert_eq!(slow[0]["amount"], "-50"); + } + + #[test] + fn source_columns_reach_all_three_derived_effect_rows() { + let (rows, _, _) = derive_spell_rows(&fixture_tables(), &[], &[]).unwrap(); + assert_eq!(rows.effects.len(), 3); + assert_eq!( + rows.effects + .iter() + .map(|e| ( + e.effect_index, + e.kind, + e.base_points, + e.die_sides, + e.per_level, + e.period_ms, + e.trigger_spell, + e.effect_mechanic + )) + .collect::>(), + vec![ + (0, A_MOD_STAT, 7, 1, 1.5, 1000, 1011, 1), + (1, A_PERIODIC_HEAL, 15, 2, 2.5, 2000, 1012, 2), + (2, E_TRIGGER, 21, 3, 3.5, 3000, 1013, 3), + ] + ); + } + + #[test] + fn source_header_reagents_and_references_reach_the_derived_rows() { + let dbc = fixture_tables(); + let source = &dbc.spells[0]; + assert_eq!(source.equipment, [u32::MAX, 31, 1_048_608]); + assert_eq!(source.implicit_target_b, [21, 22, 23]); + assert_eq!(source.effect_base_dice, [1, 2, 3]); + assert_eq!(source.effect_dice_per_level, [0.25, 0.5, 0.75]); + assert_eq!(source.effect_points_per_combo, [4.5, 5.5, 6.5]); + assert_eq!(source.effect_multiple_values, [0.5, 1.5, 2.5]); + assert_eq!(source.effect_item_type, [123, 456, 789]); + let (rows, _, _) = derive_spell_rows(&dbc, &[], &[]).unwrap(); + let header = &rows.headers[0]; + assert_eq!(header.name, "Fixture"); + assert_eq!( + (header.school_mask, header.dispel_type, header.mechanic), + (16, 1, 9) + ); + assert_eq!( + (header.attributes, header.gcd_ms, header.stances), + (64, 0, 1) + ); + assert_eq!(header.cast_flags, SPELL_ATTR_RANGED_AUTO_REPEAT); + assert_eq!(rows.eventai_metadata, ["(5090003,true)"]); + assert_eq!( + (header.power_type, header.cost, header.cast_time_ms), + (0, 33, 1500) + ); + assert_eq!( + (header.cooldown_ms, header.range_yd, header.duration_ms), + (2345, 35, 12000) + ); + assert_eq!( + (header.spell_level, header.max_level, header.max_stacks), + (7, 60, 5) + ); + assert_eq!( + (header.proc_flags, header.proc_chance, header.proc_charges), + (40, 75, 3) + ); + assert_eq!(header.aura_interrupt, 1); + assert_eq!( + (header.family_name, header.family_flags), + (3, 0x1234_5678_8000_0001) + ); + assert_eq!( + rows.reagents, + ["(40720024,5090003,111,2)", "(40720031,5090003,888,9)"] + ); + assert_eq!( + rows.effects + .iter() + .map(|e| (e.target, e.radius_yd, e.chain_targets)) + .collect::>(), + [ + (T_TARGET_ENEMY, 7.0, 4), + (T_SELF, 9.0, 5), + (T_TARGET_ENEMY, 0.0, 6) + ] + ); + assert_eq!( + (rows.effects[0].p0, rows.effects[0].p0_kind), + (2, P_STAT_ID) + ); + } + + #[test] + fn source_references_and_fractional_magnitudes_reach_effect_parameters() { + let mut bytes = source_spell_fixture(); + for (column, value) in [(61, 62u32), (62, 24), (63, 6), (91, 0), (92, 0), (93, 107)] { + bytes[20 + column * 4..24 + column * 4].copy_from_slice(&value.to_le_bytes()); + } + let mut dbc = fixture_tables(); + dbc.spells = read_spells(&bytes).unwrap(); + let (rows, _, _) = derive_spell_rows(&dbc, &[], &[]).unwrap(); + assert_eq!( + (rows.effects[0].kind, rows.effects[0].p1), + (E_POWER_BURN, 50) + ); + assert_eq!( + ( + rows.effects[1].kind, + rows.effects[1].p0, + rows.effects[1].p0_kind + ), + (E_CREATE_ITEM, 456, P_ITEM_ENTRY) + ); + assert_eq!( + ( + rows.effects[2].kind, + rows.effects[2].p0, + rows.effects[2].p0_kind, + rows.effects[2].p1 + ), + (A_SPELLMOD_FLAT, 4, P_SPELLMOD_OP, 789) + ); + } + + #[test] + fn source_target_pairs_select_units_at_the_source_location() { + let mut dbc = fixture_tables(); + dbc.spells[0].implicit_target_a = [21, 22, 57]; + dbc.spells[0].implicit_target_b = [0, 15, 0]; + let (rows, _, _) = derive_spell_rows(&dbc, &[], &[]).unwrap(); + assert_eq!( + rows.effects.iter().map(|e| e.target).collect::>(), + [T_TARGET_ALLY, T_AREA_ENEMY, T_TARGET_ALLY] + ); + } + #[test] fn evasion_and_fade_map_to_combat_fields() { use AuraMod::*; @@ -2868,18 +3037,12 @@ mod tests { } #[test] - fn proc_header_fields_read_the_schema_bug_shift() { - // Real client DBC values (verified against /srv/wowclient/Data): Frost Armor (168) reads - // procFlags=40 (0x28, "melee hit taken | melee spell hit taken"), procChance=100, - // procCharges=0 out of the wow_dbc fields NAMED proc_chance/proc_charges/max_level — matching - // the values the issue's own worked example states for 168. Lightning Shield (324) reads - // procChance=100, procCharges=3 (its 3-charge shield) the same way. + fn proc_header_fields_preserve_masks_and_bound_byte_columns() { assert_eq!(proc_header_fields(40, 100, 0), (40, 100, 0)); assert_eq!(proc_header_fields(139944, 100, 3), (139944, 100, 3)); // A percent/charge count is clamped into its u8 column rather than silently truncating. assert_eq!(proc_header_fields(1, 999, 999), (1, 255, 255)); - // A negative raw value (should never occur in real data) clamps to 0 rather than wrapping. - assert_eq!(proc_header_fields(1, -5, -5), (1, 0, 0)); + assert_eq!(proc_header_fields(1, 0, 0), (1, 0, 0)); } #[test] @@ -2896,42 +3059,13 @@ mod tests { assert!(is_wrapper_rank_trigger(E_DAMAGE)); } - #[test] - fn synthetic_gouge_control_added_by_id_and_name() { - // Gouge (real 1776) gains an A_CONTROL incapacitate at the given slot; the wrapper (1780) does not. - let row = synthetic_control_effect(1776, "Gouge", 2).expect("Gouge gains a control effect"); - assert_eq!(row.row_id(), (1776u64 << 2) | 2); - assert_eq!(row.kind, A_CONTROL); - assert_eq!(row.p0, M_POLY); - assert_eq!(row.p0_kind, P_MECHANIC); - assert_eq!(row.target, T_TARGET_ENEMY); - assert!(synthetic_control_effect(1780, "Gouge", 2).is_none()); // the combo wrapper: no control - // Sap (6770) already carries A_CONTROL in the DBC → no synthetic add. - assert!(synthetic_control_effect(6770, "Sap", 2).is_none()); - assert!(synthetic_control_effect(53, "Backstab", 2).is_none()); - } - - #[test] - fn synthetic_stealth_slow_added_to_real_stealth_only() { - // Stealth (real 1784) gains A_MOD_SPEED(MOVE, -30) at the slot; the wrapper (1789) does not. - let row = - synthetic_stealth_slow_effect(1784, "Stealth", 2).expect("Stealth gains a move-slow"); - assert_eq!(row.row_id(), (1784u64 << 2) | 2); - assert_eq!(row.kind, A_MOD_SPEED); - assert_eq!(row.base_points, -30); - assert_eq!(row.target, T_SELF); // self aura - assert_eq!((row.p0, row.p0_kind), (SPEED_MOVE, P_SPEED_KIND)); // move-speed kind - assert!(synthetic_stealth_slow_effect(1789, "Stealth", 2).is_none()); // the rank wrapper - assert!(synthetic_stealth_slow_effect(1784, "Sap", 2).is_none()); // wrong name guard - } - #[test] fn synthetic_seal_added_to_seal_of_the_crusader_only() { // Seal of the Crusader (21082) gains a THIRD, SELF-targeted A_SEAL effect at the given slot — - // its own eff0 (AP)/eff1 (haste) are untouched by this synthetic add. - let row = synthetic_seal_effect(21082, "Seal of the Crusader", 2) + // The client fills three slots, so the curated marker uses the fourth. + let row = synthetic_seal_effect(21082, "Seal of the Crusader", 3) .expect("Seal of the Crusader gains a seal effect"); - assert_eq!(row.row_id(), (21082u64 << 2) | 2); + assert_eq!(row.row_id(), (21082u64 << 2) | 3); assert_eq!(row.kind, A_SEAL); assert_eq!(row.base_points, 20); assert_eq!(row.target, T_SELF); // self aura, like Seal of Righteousness @@ -2966,38 +3100,6 @@ mod tests { ); } - #[test] - fn friendly_single_target_override_forces_ally_targeting() { - // Holy Light / Lay on Hands / Blessing of Might / Purify all import with - // implicit_target_a=0 -> T_SELF, which made them heal/buff/cleanse only the CASTER when cast on - // an ally (work-item 007, archived). The override forces T_TARGET_ALLY so `select_targets` reads the - // explicit target instead. - for name in [ - "Arcane Intellect", - "Holy Light", - "Lay on Hands", - "Blessing of Might", - "Purify", - ] { - assert_eq!( - friendly_self_or_ally_target_override(name, T_SELF), - T_TARGET_ALLY, - "{name} should be forced to T_TARGET_ALLY" - ); - } - // Wrong-name guard: an unrelated self-only spell keeps T_SELF. - assert_eq!( - friendly_self_or_ally_target_override("Frostbolt", T_SELF), - T_SELF - ); - // Already-resolved guard: a spell that DIDN'T collapse to T_SELF (e.g. it already reads - // T_TARGET_ENEMY from another override upstream) is left untouched even if the name matches. - assert_eq!( - friendly_self_or_ally_target_override("Holy Light", T_TARGET_ENEMY), - T_TARGET_ENEMY - ); - } - #[test] fn instant_effects_map_to_real_kinds() { assert_eq!(instant_effect_to_kind(2), E_DAMAGE); // SchoolDamage @@ -3270,19 +3372,19 @@ mod tests { } #[test] - fn target_polarity_biases_selected_unit() { - // selected-unit code 6: a debuff → enemy, a buff → ally. + fn target_definitions_distinguish_enemy_ally_area_and_unsupported_selections() { + // Target 6 is explicitly hostile, independent of magnitude or aura polarity. assert_eq!(resolve_target(6, true), T_TARGET_ENEMY); - assert_eq!(resolve_target(6, false), T_TARGET_ALLY); + assert_eq!(resolve_target(6, false), T_TARGET_ENEMY); assert_eq!(resolve_target(1, false), T_SELF); assert_eq!(resolve_target(0, false), T_SELF); - // party/pet/master/minion friendly codes are ALWAYS ally, regardless of polarity. - assert_eq!(resolve_target(20, false), T_TARGET_ALLY); + // Target 20 selects the party within caster range. + assert_eq!(resolve_target(20, false), T_AREA_ALLY); // area-of-effect code 8: polarity still decides enemy-area vs ally-area. assert_eq!(resolve_target(8, true), T_AREA_ENEMY); assert_eq!(resolve_target(8, false), T_AREA_ALLY); - // negative (vanilla scripted-area) codes fall into the same polarity-biased area arm. - assert_eq!(resolve_target(-5, true), T_AREA_ENEMY); + // Unrecognized target codes retain the scripted fallback. + assert_eq!(resolve_target(-5, true), T_SCRIPTED); // an unrecognized positive code defers to runtime resolution. assert_eq!(resolve_target(999, false), T_SCRIPTED); } @@ -3452,47 +3554,42 @@ mod tests { } #[test] - fn aura_interrupt_bits_drops_the_underwater_cancel_bit_a_land_mount_carries() { - // Only bit0 (break-on-damage) / bit1 (break-on-move) survive from the raw vanilla - // AuraInterruptFlags — every other bit, including the underwater-cancel flag (0x80, - // NOT-ABOVEWATER) a real land mount's DBC row carries, is structurally dropped. - assert_eq!(aura_interrupt_bits(0x80, 458), 0); // Brown Horse-shaped: underwater bit only - assert_eq!(aura_interrupt_bits(0x80 | 0x1, 458), 0x1); // a hypothetical mount that DID carry damage too - assert_eq!(aura_interrupt_bits(0x1, 999), 0x1); // a genuine damage-break spell keeps its bit - assert_eq!(aura_interrupt_bits(0x2, 999), 0x2); // bit1 (break-on-move) passes through too - // The synthetic-incapacitate force-on list (Polymorph 118 / Gouge 1776 / Sap 6770) still ORs - // bit0 on regardless of the raw flags — untouched by the mount masking. - assert_eq!(aura_interrupt_bits(0, 1776), 0x1); - assert_eq!(aura_interrupt_bits(0, 458), 0); // a mount id is NOT on the force-on list + fn aura_interrupt_translates_damage_and_movement_without_other_flags() { + assert_eq!(aura_interrupt_bits(0x2), 1); + assert_eq!(aura_interrupt_bits(0x8), 2); + assert_eq!(aura_interrupt_bits(0xa), 3); + assert_eq!(aura_interrupt_bits(0x80), 0); + assert_eq!(aura_interrupt_bits(0x1), 0); } } #[cfg(test)] mod binding_tests { use super::*; - use wow_dbc::vanilla_tables::spell::SpellKey; use wow_dbc::vanilla_tables::spell_cast_times::{SpellCastTimesKey, SpellCastTimesRow}; - use wow_dbc::vanilla_tables::spell_focus_object::SpellFocusObjectKey; use wow_dbc::vanilla_tables::spell_range::SpellRangeRow; - fn source_spell(spell_id: u32, event: u32) -> wow_dbc::vanilla_tables::spell::SpellRow { + fn source_spell(spell_id: u32, event: u32) -> Spell { let mut bytes = b"WDBC".to_vec(); for value in [1u32, 173, 692, 1] { bytes.extend(value.to_le_bytes()); } bytes.resize(20 + 692 + 1, 0); - let mut row = DbcSpell::read(&mut bytes.as_slice()) - .unwrap() - .rows - .remove(0); - row.id = SpellKey::new(spell_id); - row.requires_spell_focus = SpellFocusObjectKey::new(83); - row.effect = [61, 86, 0]; - row.effect_misc_value = [event, 1, 0]; - row.implicit_target_a = [0, 40, 0]; - row.casting_time_index = SpellCastTimesKey::new(7); - row.speed = f32::from_bits(7); - row + for (column, value) in [ + (0, spell_id), + (15, 83), + (18, 7), + (36, 7), + (61, 61), + (62, 86), + (82, 0), + (83, 40), + (106, event), + (107, 1), + ] { + bytes[20 + column * 4..24 + column * 4].copy_from_slice(&value.to_le_bytes()); + } + read_spells(&bytes).unwrap().remove(0) } #[test] @@ -3502,7 +3599,7 @@ mod binding_tests { for (spell_id, event, entry) in [(7728, 1131, 5676), (8674, 1134, 5677)] { let row = source_spell(spell_id, event); let dbc = SpellDbc { - spells: DbcSpell { rows: vec![] }, + spells: vec![], cast_times: SpellCastTimes { rows: vec![SpellCastTimesRow { id: SpellCastTimesKey::new(7), @@ -3517,8 +3614,8 @@ mod binding_tests { range_min: 0.0, range_max: 10.0, flags: 0, - display_name: row.name.clone(), - display_name_short: row.name.clone(), + display_name: Default::default(), + display_name_short: Default::default(), }], }, durations: SpellDuration { rows: vec![] }, diff --git a/importer/src/spell_dbc.rs b/importer/src/spell_dbc.rs new file mode 100644 index 000000000..f657f7201 --- /dev/null +++ b/importer/src/spell_dbc.rs @@ -0,0 +1,223 @@ +//! Build 5875 Spell columns shared by spell import and item enchantment import. +//! +//! Layout: cmangos/mangos-classic 8ec338a1704e7dcb1c0213eb7ed58f9231ade40f, +//! src/game/Server/DBCStructure.h, SpellEntry columns 0..172. +//! wow_dbc 0.3 omits InterruptFlags at column 21. Its equipment, reagent and effect +//! fields stay shifted until an extra unknown_flag resynchronizes the name at column 120. + +use anyhow::{bail, Context, Result}; +use std::collections::BTreeSet; + +#[derive(Default, Debug)] +pub(crate) struct Spell { + pub(crate) id: u32, + pub(crate) school: u32, + pub(crate) dispel_type: u32, + pub(crate) mechanic: u32, + pub(crate) attributes: u32, + pub(crate) attributes_ex1: u32, + pub(crate) attributes_ex2: u32, + pub(crate) shapeshift_mask: u32, + pub(crate) requires_spell_focus: u32, + pub(crate) casting_time_index: u32, + pub(crate) recovery_time: u32, + pub(crate) category_recovery_time: u32, + pub(crate) aura_interrupt_flags: u32, + pub(crate) proc_flags: u32, + pub(crate) proc_chance: u32, + pub(crate) proc_charges: u32, + pub(crate) max_level: u32, + pub(crate) spell_level: u32, + pub(crate) duration: u32, + pub(crate) power_type: u32, + pub(crate) mana_cost: u32, + pub(crate) range: u32, + pub(crate) stack_amount: u32, + pub(crate) reagent: [i32; 8], + pub(crate) reagent_count: [u32; 8], + /// Class, subclass mask and inventory mask in source order. + pub(crate) equipment: [u32; 3], + pub(crate) effect: [i32; 3], + pub(crate) effect_die_sides: [i32; 3], + pub(crate) effect_base_dice: [u32; 3], + pub(crate) effect_dice_per_level: [f32; 3], + pub(crate) effect_real_points_per_level: [f32; 3], + pub(crate) effect_base_points: [i32; 3], + pub(crate) effect_mechanic: [u32; 3], + pub(crate) implicit_target_a: [i32; 3], + pub(crate) implicit_target_b: [i32; 3], + pub(crate) effect_radius: [u32; 3], + pub(crate) effect_aura: [u32; 3], + pub(crate) effect_amplitude: [u32; 3], + pub(crate) effect_multiple_values: [f32; 3], + pub(crate) effect_chain_target: [u32; 3], + pub(crate) effect_item_type: [i32; 3], + pub(crate) effect_misc_value: [u32; 3], + pub(crate) effect_trigger_spell: [u32; 3], + pub(crate) effect_points_per_combo: [f32; 3], + pub(crate) name: String, + pub(crate) family_name: u32, + pub(crate) family_flags: u64, +} + +/// Read only the supported fixed-width layout. Client bytes remain in memory. +pub(crate) fn read_spells(bytes: &[u8]) -> Result> { + if bytes.len() < 20 || &bytes[..4] != b"WDBC" { + bail!("Spell.dbc has no WDBC header"); + } + let header = |at: usize| u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap()) as usize; + let (count, fields, size, strings) = (header(4), header(8), header(12), header(16)); + if count == 0 { + bail!("Spell.dbc contains no rows"); + } + if fields != 173 || size != 692 { + bail!("Spell.dbc requires the build 5875 layout: 173 fields, 692 bytes per row"); + } + let records_end = count.checked_mul(size).and_then(|n| n.checked_add(20)); + if records_end.and_then(|n| n.checked_add(strings)) != Some(bytes.len()) { + bail!("Spell.dbc record and string lengths do not match its header"); + } + // The length check proves both the record boundary and every fixed column below fit. + let records_end = records_end.unwrap(); + let strings = &bytes[records_end..]; + let mut ids = BTreeSet::new(); + bytes[20..records_end] + .chunks_exact(size) + .map(|row| { + let column = + |at: usize| u32::from_le_bytes(row[at * 4..at * 4 + 4].try_into().unwrap()); + let array = |at: usize| std::array::from_fn(|i| column(at + i)); + let id = column(0); + if !ids.insert(id) { + bail!("duplicate Spell.dbc row {id}"); + } + let name = strings.get(column(120) as usize..).with_context(|| { + format!("Spell.dbc row {id} name offset exceeds the string block") + })?; + let end = name + .iter() + .position(|&b| b == 0) + .with_context(|| format!("Spell.dbc row {id} name has no terminator"))?; + let name = std::str::from_utf8(&name[..end]) + .with_context(|| format!("Spell.dbc row {id} name is not UTF-8"))?; + let spell = Spell { + id: column(0), + school: column(1), + dispel_type: column(4), + mechanic: column(5), + attributes: column(6), + attributes_ex1: column(7), + attributes_ex2: column(8), + shapeshift_mask: column(11), + requires_spell_focus: column(15), + casting_time_index: column(18), + recovery_time: column(19), + category_recovery_time: column(20), + aura_interrupt_flags: column(22), + proc_flags: column(24), + proc_chance: column(25), + proc_charges: column(26), + max_level: column(27), + spell_level: column(29), + duration: column(30), + power_type: column(31), + mana_cost: column(32), + range: column(36), + stack_amount: column(39), + reagent: std::array::from_fn(|i| column(42 + i) as i32), + reagent_count: std::array::from_fn(|i| column(50 + i)), + equipment: array(58), + effect: array(61).map(|n| n as i32), + effect_die_sides: array(64).map(|n| n as i32), + effect_base_dice: array(67), + effect_dice_per_level: array(70).map(f32::from_bits), + effect_real_points_per_level: array(73).map(f32::from_bits), + effect_base_points: array(76).map(|n| n as i32), + effect_mechanic: array(79), + implicit_target_a: array(82).map(|n| n as i32), + implicit_target_b: array(85).map(|n| n as i32), + effect_radius: array(88), + effect_aura: array(91), + effect_amplitude: array(94), + effect_multiple_values: array(97).map(f32::from_bits), + effect_chain_target: array(100), + effect_item_type: array(103).map(|n| n as i32), + effect_misc_value: array(106), + effect_trigger_spell: array(109), + effect_points_per_combo: array(112).map(f32::from_bits), + name: name.to_owned(), + family_name: column(160), + family_flags: u64::from(column(161)) | (u64::from(column(162)) << 32), + }; + if spell + .effect_dice_per_level + .iter() + .chain(&spell.effect_real_points_per_level) + .chain(&spell.effect_multiple_values) + .chain(&spell.effect_points_per_combo) + .any(|n| !n.is_finite()) + { + bail!("Spell.dbc row {id} contains a non-finite effect magnitude"); + } + Ok(spell) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn empty_spell() -> Vec { + let mut bytes = b"WDBC".to_vec(); + for n in [1u32, 173, 692, 1] { + bytes.extend_from_slice(&n.to_le_bytes()); + } + bytes.resize(713, 0); + bytes + } + + #[test] + fn malformed_layout_strings_and_magnitudes_return_errors() { + for bytes in [vec![], vec![0; 20]] { + assert!(read_spells(&bytes).is_err()); + } + for (at, value) in [ + (4, u32::MAX), + (8, 172), + (12, 688), + (16, 2), + (20 + 120 * 4, 2), + (20 + 73 * 4, f32::NAN.to_bits()), + ] { + let mut bytes = empty_spell(); + bytes[at..at + 4].copy_from_slice(&value.to_le_bytes()); + assert!(read_spells(&bytes).is_err(), "byte {at}"); + } + let mut bytes = empty_spell(); + bytes[4..8].copy_from_slice(&0u32.to_le_bytes()); + bytes.truncate(21); + assert!(read_spells(&bytes) + .unwrap_err() + .to_string() + .contains("no rows")); + let mut bytes = empty_spell(); + bytes[712] = b'x'; + assert!(read_spells(&bytes).is_err()); + bytes[16..20].copy_from_slice(&2u32.to_le_bytes()); + bytes[712] = 0xff; + bytes.push(0); + assert!(read_spells(&bytes).is_err()); + } + + #[test] + fn duplicate_spell_ids_return_an_error() { + let mut bytes = empty_spell(); + bytes[4..8].copy_from_slice(&2u32.to_le_bytes()); + bytes.resize(20 + 2 * 692 + 1, 0); + assert!(read_spells(&bytes) + .unwrap_err() + .to_string() + .contains("duplicate")); + } +} From d624772f05f518f9523477aef4002b2d549046b7 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Mon, 7 Sep 2026 17:39:07 +0000 Subject: [PATCH 2/7] test(importer): count pinned item catalogues in plan stamp --- importer/src/main.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/importer/src/main.rs b/importer/src/main.rs index 1c7533e44..87e3d655a 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -7867,6 +7867,34 @@ mod tests { } } + #[test] + #[ignore = "requires pinned LYRACORE_CLASSIC_DB_SQL and build 5875 LYRACORE_TEST_DBC"] + fn pinned_items_plan_counts_templates_and_property_catalogues() { + let path = std::env::var("LYRACORE_CLASSIC_DB_SQL").unwrap(); + let dbc = std::env::var("LYRACORE_TEST_DBC").unwrap(); + let dump = read_dump(&path).unwrap(); + let args = parse_args_from([ + "--dump", + path.as_str(), + "--dbc", + dbc.as_str(), + "--world-profile", + "alliance-eastern", + "--family", + "items", + ]) + .unwrap(); + let plan = build_dump_plan(&dump, &args, &None, &None).unwrap(); + let (templates, properties, enchantments, pool_members) = (17_718, 2_012, 1_685, 27_892); + assert_eq!( + plan.stamps, + vec![( + "items", + templates + properties + enchantments + pool_members + )] + ); + } + #[test] #[ignore = "requires LYRACORE_CLASSIC_DB_SQL pointing at the pinned decompressed SQL or gzip"] fn pinned_alliance_single_plan_is_apply_ready_with_relay_static_dependencies() { From e5bbc6b5faa7f640e719f1a8370d621028d73bf1 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Mon, 7 Sep 2026 18:15:22 +0000 Subject: [PATCH 3/7] test(importer): verify Gouge breaks on a guaranteed hit --- importer/src/spell.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/importer/src/spell.rs b/importer/src/spell.rs index 09b3de4d6..b032fd2e3 100644 --- a/importer/src/spell.rs +++ b/importer/src/spell.rs @@ -2467,15 +2467,24 @@ mod tests { #[test] #[ignore = "requires LYRACORE_TEST_DBC, SpacetimeDB 2.7.1 and the Wasm toolchain"] fn actual_imported_gouge_control_ends_on_damage() { - let (shard, _, wolf) = imported_spell_fixture(&[686, 1776]); + let (shard, _, wolf) = imported_spell_fixture(&[1776]); + shard.assert_sql("DELETE FROM game_aura_schedule"); shard.assert_call("debug_cast_at", &["1", "1776", &wolf]); let control = shard.query_rows("SELECT effect_id, eff_p0 FROM game_aura WHERE spell_id = 1776"); assert_eq!(control.len(), 1); assert_eq!(control[0]["effect_id"], "7106"); assert_eq!(control[0]["eff_p0"], "4"); - shard.assert_sql("DELETE FROM game_spell_cooldown"); - shard.assert_call("debug_cast_at", &["1", "686", &wolf]); + let health = || { + shard.query_rows(&format!( + "SELECT health FROM game_world_entity WHERE guid = {wolf}" + ))[0]["health"] + .parse::() + .unwrap() + }; + let before = health(); + shard.assert_call("debug_apply_damage", &[&wolf, "10", "1"]); + assert_eq!(health(), before - 10); assert!(shard .query_rows("SELECT id FROM game_aura WHERE spell_id = 1776") .is_empty()); From 90dec4424e55428662b4584537f55cbead879614 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Mon, 7 Sep 2026 18:26:24 +0000 Subject: [PATCH 4/7] test(quests): include item properties in the Binding fixture --- importer/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/importer/src/main.rs b/importer/src/main.rs index 87e3d655a..27fa64efa 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -69,6 +69,7 @@ mod vmap; mod world_import_scope; #[cfg(test)] +// The shared Wasm builder requires this package directly beneath the workspace root. #[path = "../../module/tests/support/mod.rs"] mod standalone_support; From c6fbbfbffccf5471d1da1be7f2812e66bd1bafee Mon Sep 17 00:00:00 2001 From: T3 Code Date: Mon, 7 Sep 2026 18:59:13 +0000 Subject: [PATCH 5/7] fix(importer): resolve target-none spell effects --- docs/data-ingestion.md | 4 +- importer/src/spell.rs | 135 ++++++++++++++++++++++++++++++++++++-- importer/src/spell_dbc.rs | 2 + 3 files changed, 134 insertions(+), 7 deletions(-) diff --git a/docs/data-ingestion.md b/docs/data-ingestion.md index a206c3f19..b3e38cb97 100644 --- a/docs/data-ingestion.md +++ b/docs/data-ingestion.md @@ -359,7 +359,9 @@ The normalized input preserves all three slots and raw aura numbers. Unsupported [Target definitions](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Spells/SpellTargetDefines.h) define 6 as enemy, 21 and 57 as friendly units, and 22 as the caster's source location. Location targets -use the companion selection where present. The importer translates vanilla damage and movement +use the companion selection where present. Code 0 means no implicit target. For a `(0, 0)` pair, the +importer uses compatible Spell header target flags, then the source effect's default target. An +unsupported effect keeps the scripted fallback. The importer translates vanilla damage and movement interrupt bits into the Module's compact mask. Gouge's control and Stealth's speed penalty come from the third source effect. Their former synthetic additions are removed. The Human Spirit maps directly to its percentage aura. Evocation's existing periodic-mana correction now addresses source slot 0. diff --git a/importer/src/spell.rs b/importer/src/spell.rs index b032fd2e3..283adcd0d 100644 --- a/importer/src/spell.rs +++ b/importer/src/spell.rs @@ -911,7 +911,8 @@ fn resolve_instant_params(kind: u8, misc: u32, item_type: i32) -> (i32, u8) { /// Location targets use their companion selection in `resolve_effect_target`. fn resolve_target(target: i32, is_negative: bool) -> u8 { match target { - 0 | 1 => T_SELF, + 0 => T_SCRIPTED, + 1 => T_SELF, 6 => T_TARGET_ENEMY, 21 | 35 | 37 | 45 | 57 | 61 => T_TARGET_ALLY, 25 => { @@ -934,6 +935,73 @@ fn resolve_target(target: i32, is_negative: bool) -> u8 { } } +const TARGET_FLAG_UNIT: u32 = 0x0002; +const TARGET_FLAG_UNIT_ENEMY: u32 = 0x0080; +const TARGET_FLAG_UNIT_ALLY: u32 = 0x0100; +const TARGET_FLAG_CORPSE_ENEMY: u32 = 0x0200; +const TARGET_FLAG_UNIT_DEAD: u32 = 0x0400; +const TARGET_FLAG_CORPSE_ALLY: u32 = 0x8000; + +fn polarity_target(is_negative: bool) -> u8 { + if is_negative { + T_TARGET_ENEMY + } else { + T_TARGET_ALLY + } +} + +/// Resolve an explicit target shape from the Spell header when the effect has no implicit target. +/// Only flags accepted by the source effect type participate. +fn compatible_header_target(effect_id: i32, kind: u8, flags: u32, is_negative: bool) -> Option { + if kind == E_SCRIPTED { + return None; + } + let accepted = match effect_id { + // Source effects whose required target type is Unit or UnitDest. + 1 | 2 | 5 | 6 | 8 | 9 | 10 | 11 | 16 | 17 | 19 | 24 | 29 | 30 | 31 | 34 | 36 | 38 | 40 + | 43 | 45 | 46 | 47 | 55 | 57 | 58 | 61 | 62 | 63 | 67 | 68 | 69 | 70 | 71 | 75 | 79 + | 80 | 82 | 83 | 85 | 92 | 95 | 96 | 98 | 100 | 102 | 103 | 108 | 111 | 114 | 115 | 117 + | 120 | 121 | 123 | 124 | 125 | 126 => { + flags + & (TARGET_FLAG_UNIT + | TARGET_FLAG_UNIT_ENEMY + | TARGET_FLAG_UNIT_ALLY + | TARGET_FLAG_UNIT_DEAD) + } + // Resurrection effects accept corpse flags rather than unit flags. + 18 | 113 | 116 => flags & (TARGET_FLAG_CORPSE_ENEMY | TARGET_FLAG_CORPSE_ALLY), + _ => 0, + }; + let enemy = accepted & (TARGET_FLAG_UNIT_ENEMY | TARGET_FLAG_CORPSE_ENEMY) != 0; + let ally = accepted & (TARGET_FLAG_UNIT_ALLY | TARGET_FLAG_CORPSE_ALLY) != 0; + match (enemy, ally) { + (true, false) => Some(T_TARGET_ENEMY), + (false, true) => Some(T_TARGET_ALLY), + (true, true) => Some(T_TARGET_ANY), + (false, false) if accepted != 0 => Some(polarity_target(is_negative)), + (false, false) => None, + } +} + +/// Apply the pinned source's effect default after a `(TARGET_NONE, TARGET_NONE)` pair. Unsupported +/// effects retain the runtime fallback instead of acquiring a unit target from a foreign effect. +fn supported_effect_default(effect_id: i32, kind: u8, is_negative: bool) -> u8 { + if kind == E_SCRIPTED { + return T_SCRIPTED; + } + let default = match effect_id { + 2 => 6, // enemy + 6 | 34 | 46 | 47 | 61 | 79 => 1, // caster + 11 | 18 | 24 | 36 | 44 | 85 | 116 | 118 | 123 => 25, // selected unit + 28 | 50 => 18, // caster destination + 57 => 5, // caster pet + // Source area auras use the caster when neither implicit target names a single unit. + 35 | 119 => return T_SELF, + _ => 0, + }; + resolve_target(default, is_negative) +} + /// Coverage accounting accumulated across the whole import — drives the COVERAGE REPORT. #[derive(Default)] struct Coverage { @@ -1606,12 +1674,17 @@ fn resolve_effect_target(s: &Spell, i: usize, kind: u8, header: &SpellHeader) -> let channeled = header.channeled; let target_a = s.implicit_target_a[i]; let target_b = s.implicit_target_b[i]; - let selection = if matches!(target_a, 0 | 18 | 22) && target_b != 0 { - target_b + let target = if target_a == 0 && target_b == 0 { + compatible_header_target(s.effect[i], kind, s.targets, is_negative) + .unwrap_or_else(|| supported_effect_default(s.effect[i], kind, is_negative)) } else { - target_a + let selection = if matches!(target_a, 0 | 18 | 22) && target_b != 0 { + target_b + } else { + target_a + }; + resolve_target(selection, is_negative) }; - let target = resolve_target(selection, is_negative); // These handlers operate on the caster or a ground location, regardless of unit selection. let target = match kind { E_CHARGE | E_JUDGEMENT | E_PICKPOCKET | E_INTERRUPT | E_NEXT_SWING | E_TAUNT @@ -2187,6 +2260,7 @@ mod tests { (7, 0x0008_0000), (8, 32), (11, 1 << 16), + (13, TARGET_FLAG_UNIT_ALLY), (18, 1), (19, 1234), (20, 2345), @@ -2412,6 +2486,29 @@ mod tests { assert!(rows.reagents.contains(&"(61985,7748,10938,2)".to_owned())); } + #[test] + #[ignore = "requires LYRACORE_TEST_DBC with the owned build 5875 client"] + fn actual_target_none_uses_flags_defaults_and_scripted_fallback() { + let dir = std::env::var("LYRACORE_TEST_DBC").expect("set LYRACORE_TEST_DBC"); + let (rows, _, _) = + build_spell_rows(Path::new(&dir), &[16456, 18435, 21403, 28374], &[]).unwrap(); + let effects = |spell_id| { + rows.effects + .iter() + .filter(|effect| effect.spell_id == spell_id) + .collect::>() + }; + + assert!(effects(16456) + .iter() + .all(|effect| effect.target == T_TARGET_ENEMY)); + assert!(effects(21403) + .iter() + .all(|effect| effect.target == T_TARGET_ALLY)); + assert_eq!(effects(18435)[1].target, T_SCRIPTED); + assert_eq!(effects(28374)[2].target, T_SCRIPTED); + } + fn imported_spell_fixture( ids: &[u32], ) -> (crate::standalone_support::Standalone, String, String) { @@ -2533,6 +2630,7 @@ mod tests { fn source_header_reagents_and_references_reach_the_derived_rows() { let dbc = fixture_tables(); let source = &dbc.spells[0]; + assert_eq!(source.targets, TARGET_FLAG_UNIT_ALLY); assert_eq!(source.equipment, [u32::MAX, 31, 1_048_608]); assert_eq!(source.implicit_target_b, [21, 22, 23]); assert_eq!(source.effect_base_dice, [1, 2, 3]); @@ -2639,6 +2737,31 @@ mod tests { ); } + #[test] + fn source_target_none_uses_compatible_flags_then_effect_defaults() { + let mut dbc = fixture_tables(); + dbc.spells[0].implicit_target_a = [0; 3]; + dbc.spells[0].implicit_target_b = [0; 3]; + let (rows, _, _) = derive_spell_rows(&dbc, &[], &[]).unwrap(); + assert_eq!( + rows.effects + .iter() + .map(|effect| effect.target) + .collect::>(), + [T_TARGET_ALLY, T_TARGET_ALLY, T_SCRIPTED] + ); + + dbc.spells[0].targets = 0; + let (rows, _, _) = derive_spell_rows(&dbc, &[], &[]).unwrap(); + assert_eq!( + rows.effects + .iter() + .map(|effect| effect.target) + .collect::>(), + [T_SELF, T_SELF, T_SCRIPTED] + ); + } + #[test] fn evasion_and_fade_map_to_combat_fields() { use AuraMod::*; @@ -3386,7 +3509,7 @@ mod tests { assert_eq!(resolve_target(6, true), T_TARGET_ENEMY); assert_eq!(resolve_target(6, false), T_TARGET_ENEMY); assert_eq!(resolve_target(1, false), T_SELF); - assert_eq!(resolve_target(0, false), T_SELF); + assert_eq!(resolve_target(0, false), T_SCRIPTED); // Target 20 selects the party within caster range. assert_eq!(resolve_target(20, false), T_AREA_ALLY); // area-of-effect code 8: polarity still decides enemy-area vs ally-area. diff --git a/importer/src/spell_dbc.rs b/importer/src/spell_dbc.rs index f657f7201..6862abbf3 100644 --- a/importer/src/spell_dbc.rs +++ b/importer/src/spell_dbc.rs @@ -18,6 +18,7 @@ pub(crate) struct Spell { pub(crate) attributes_ex1: u32, pub(crate) attributes_ex2: u32, pub(crate) shapeshift_mask: u32, + pub(crate) targets: u32, pub(crate) requires_spell_focus: u32, pub(crate) casting_time_index: u32, pub(crate) recovery_time: u32, @@ -109,6 +110,7 @@ pub(crate) fn read_spells(bytes: &[u8]) -> Result> { attributes_ex1: column(7), attributes_ex2: column(8), shapeshift_mask: column(11), + targets: column(13), requires_spell_focus: column(15), casting_time_index: column(18), recovery_time: column(19), From 693a763f45312d651a348357560a73768e23f99b Mon Sep 17 00:00:00 2001 From: T3 Code Date: Mon, 7 Sep 2026 19:55:20 +0000 Subject: [PATCH 6/7] fix(importer): classify every Gouge rank as incapacitate --- docs/data-ingestion.md | 1 + importer/src/spell.rs | 63 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/docs/data-ingestion.md b/docs/data-ingestion.md index b3e38cb97..0e7f2f676 100644 --- a/docs/data-ingestion.md +++ b/docs/data-ingestion.md @@ -343,6 +343,7 @@ An extra field restores alignment at the localized name, column 120. |---|---| | ID, school, dispel, mechanic | 0, 1, 4, 5 | | Attributes, AttributesEx1, AttributesEx2, stance mask | 6, 7, 8, 11 | +| Explicit target flags | 13 | | Required spell focus, cast time, recovery times | 15, 18, 19, 20 | | Aura interrupt, Proc mask, chance, charges | 22, 24, 25, 26 | | Maximum level, spell level, duration, power, cost, range, stack count | 27, 29, 30, 31, 32, 36, 39 | diff --git a/importer/src/spell.rs b/importer/src/spell.rs index 283adcd0d..91b9f1fb9 100644 --- a/importer/src/spell.rs +++ b/importer/src/spell.rs @@ -119,6 +119,7 @@ const M_STUN: i32 = 1; const M_ROOT: i32 = 2; const M_FEAR: i32 = 3; const M_POLY: i32 = 4; +const GOUGE_RANK_IDS: [u32; 5] = [1776, 1777, 8629, 11285, 11286]; // p0_kind tags const P_NONE: u8 = 0; @@ -1559,8 +1560,8 @@ fn resolve_effect_kind( } else { correct_script_effect_kind(name, kind) }; - // Gouge uses the incapacitate family, although its client aura is ModStun. - let p0 = if s.id == 1776 && name == "Gouge" && kind == A_CONTROL { + // Every build 5875 Gouge rank uses the incapacitate family, although its client aura is ModStun. + let p0 = if GOUGE_RANK_IDS.contains(&s.id) && name == "Gouge" && kind == A_CONTROL { M_POLY } else { p0 @@ -2379,10 +2380,25 @@ mod tests { #[ignore = "requires LYRACORE_TEST_DBC with the owned build 5875 client"] fn actual_spell_import_keeps_restored_effects_and_curated_rules() { let dir = std::env::var("LYRACORE_TEST_DBC").expect("set LYRACORE_TEST_DBC"); + let source = open_spell_tables(Path::new(&dir)).unwrap(); + for spell_id in [1776, 1777, 8629, 11285, 11286] { + let gouge = source + .spells + .iter() + .find(|spell| spell.id == spell_id) + .unwrap(); + assert_eq!(gouge.name, "Gouge"); + assert_eq!(gouge.effect[2], EFFECT_APPLY_AURA); + assert_eq!( + AuraMod::from_int(gouge.effect_aura[2]), + Ok(AuraMod::ModStun) + ); + } let (rows, _, _) = build_spell_rows( Path::new(&dir), &[ - 17, 100, 139, 1459, 1776, 1784, 7728, 7747, 7748, 8674, 12051, 20598, 21082, + 17, 100, 139, 1459, 1776, 1777, 1784, 7728, 7747, 7748, 8629, 8674, 11285, 11286, + 12051, 20598, 21082, ], &[], ) @@ -2429,9 +2445,11 @@ mod tests { ), (0, A_MOD_HEALTH_POWER, 15) ); - let gouge = effects(1776); - assert_eq!(gouge.len(), 3); - assert_eq!((gouge[2].kind, gouge[2].p0), (A_CONTROL, M_POLY)); + for spell_id in [1776, 1777, 8629, 11285, 11286] { + let gouge = effects(spell_id); + assert_eq!(gouge.len(), 3); + assert_eq!((gouge[2].kind, gouge[2].p0), (A_CONTROL, M_POLY)); + } let stealth = effects(1784); assert!( !rows @@ -2626,6 +2644,39 @@ mod tests { ); } + #[test] + fn every_gouge_rank_imports_incapacitate_while_other_mod_stuns_remain_stuns() { + let gouge = |id| Spell { + id, + name: "Gouge".into(), + effect: [0, 0, EFFECT_APPLY_AURA], + effect_aura: [0, 0, AuraMod::ModStun as u32], + implicit_target_a: [0, 0, 6], + ..Default::default() + }; + let mut dbc = fixture_tables(); + dbc.spells = [1776, 1777, 8629, 11285, 11286, 999_999] + .into_iter() + .map(gouge) + .collect(); + + let (rows, _, _) = derive_spell_rows(&dbc, &[], &[]).unwrap(); + for spell_id in [1776, 1777, 8629, 11285, 11286] { + let control = rows + .effects + .iter() + .find(|effect| effect.spell_id == spell_id) + .unwrap(); + assert_eq!((control.kind, control.p0), (A_CONTROL, M_POLY)); + } + let unrelated = rows + .effects + .iter() + .find(|effect| effect.spell_id == 999_999) + .unwrap(); + assert_eq!((unrelated.kind, unrelated.p0), (A_CONTROL, M_STUN)); + } + #[test] fn source_header_reagents_and_references_reach_the_derived_rows() { let dbc = fixture_tables(); From 5ce7d39d06bfe904e56542e9dbde4b69962b66a4 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Mon, 7 Sep 2026 20:29:50 +0000 Subject: [PATCH 7/7] fix(importer): follow companion targets at every location --- docs/data-ingestion.md | 18 +++++++----- importer/src/spell.rs | 63 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 68 insertions(+), 13 deletions(-) diff --git a/docs/data-ingestion.md b/docs/data-ingestion.md index 0e7f2f676..cf54959c4 100644 --- a/docs/data-ingestion.md +++ b/docs/data-ingestion.md @@ -359,13 +359,17 @@ The normalized input preserves all three slots and raw aura numbers. Unsupported `E_SCRIPTED`, and unsupported item enchantment effects remain Stat Kind 0. [Target definitions](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Spells/SpellTargetDefines.h) -define 6 as enemy, 21 and 57 as friendly units, and 22 as the caster's source location. Location targets -use the companion selection where present. Code 0 means no implicit target. For a `(0, 0)` pair, the -importer uses compatible Spell header target flags, then the source effect's default target. An -unsupported effect keeps the scripted fallback. The importer translates vanilla damage and movement -interrupt bits into the Module's compact mask. Gouge's control and Stealth's speed penalty come from -the third source effect. Their former synthetic additions are removed. The Human Spirit maps directly -to its percentage aura. Evocation's existing periodic-mana correction now addresses source slot 0. +define 6 as enemy and 21 and 57 as friendly units. The pinned +[target classification](https://github.com/cmangos/mangos-classic/blob/8ec338a1704e7dcb1c0213eb7ed58f9231ade40f/src/game/Spells/SpellTargets.cpp) +marks codes 9, 10, 17, 18, 22, 32, 39, 41 through 44, 46 through 50, 53, 55 and 63 as locations. +When a location in target A has a companion target B, target B decides the Module's unit routing. +Code 45 is a friendly chain unit and code 26 is a lock, so neither uses that rule. Code 0 means no +implicit target. For a `(0, 0)` pair, the importer uses compatible Spell header target flags, then +the source effect's default target. An unsupported effect keeps the scripted fallback. The importer +translates vanilla damage and movement interrupt bits into the Module's compact mask. Gouge's control +and Stealth's speed penalty come from the third source effect. Their former synthetic additions are +removed. The Human Spirit maps directly to its percentage aura. Evocation's existing periodic-mana +correction now addresses source slot 0. The authored seal marker and Power Word: Shield's linked Weakened Soul rule remain because their Module behavior is not an equivalent DBC effect. diff --git a/importer/src/spell.rs b/importer/src/spell.rs index 91b9f1fb9..ff9bce3e0 100644 --- a/importer/src/spell.rs +++ b/importer/src/spell.rs @@ -936,6 +936,20 @@ fn resolve_target(target: i32, is_negative: bool) -> u8 { } } +// Pinned SpellTargetInfoTable entries whose first target supplies a location. If the companion +// target names a unit selection, that selection decides the Module's unit routing. +const LOCATION_TARGET_CODES: [i32; 19] = [ + 9, 10, 17, 18, 22, 32, 39, 41, 42, 43, 44, 46, 47, 48, 49, 50, 53, 55, 63, +]; + +fn unit_selection_from_pair(target_a: i32, target_b: i32) -> i32 { + if (target_a == 0 || LOCATION_TARGET_CODES.contains(&target_a)) && target_b != 0 { + target_b + } else { + target_a + } +} + const TARGET_FLAG_UNIT: u32 = 0x0002; const TARGET_FLAG_UNIT_ENEMY: u32 = 0x0080; const TARGET_FLAG_UNIT_ALLY: u32 = 0x0100; @@ -1679,11 +1693,7 @@ fn resolve_effect_target(s: &Spell, i: usize, kind: u8, header: &SpellHeader) -> compatible_header_target(s.effect[i], kind, s.targets, is_negative) .unwrap_or_else(|| supported_effect_default(s.effect[i], kind, is_negative)) } else { - let selection = if matches!(target_a, 0 | 18 | 22) && target_b != 0 { - target_b - } else { - target_a - }; + let selection = unit_selection_from_pair(target_a, target_b); resolve_target(selection, is_negative) }; // These handlers operate on the caster or a ground location, regardless of unit selection. @@ -2381,6 +2391,29 @@ mod tests { fn actual_spell_import_keeps_restored_effects_and_curated_rules() { let dir = std::env::var("LYRACORE_TEST_DBC").expect("set LYRACORE_TEST_DBC"); let source = open_spell_tables(Path::new(&dir)).unwrap(); + let new_location_companions = source + .spells + .iter() + .flat_map(|spell| { + (0..3).filter_map(move |slot| { + let target_a = spell.implicit_target_a[slot]; + let target_b = spell.implicit_target_b[slot]; + (spell.effect[slot] != 0 + && LOCATION_TARGET_CODES.contains(&target_a) + && target_b != 0 + && !matches!(target_a, 18 | 22)) + .then_some((spell.id, slot, target_a, target_b)) + }) + }) + .collect::>(); + let mut location_codes = new_location_companions + .iter() + .map(|(_, _, target_a, _)| *target_a) + .collect::>(); + location_codes.sort_unstable(); + location_codes.dedup(); + assert_eq!(new_location_companions.len(), 170); + assert_eq!(location_codes, [17, 46, 53]); for spell_id in [1776, 1777, 8629, 11285, 11286] { let gouge = source .spells @@ -2398,7 +2431,7 @@ mod tests { Path::new(&dir), &[ 17, 100, 139, 1459, 1776, 1777, 1784, 7728, 7747, 7748, 8629, 8674, 11285, 11286, - 12051, 20598, 21082, + 12051, 17086, 17616, 18392, 20598, 21082, ], &[], ) @@ -2450,6 +2483,9 @@ mod tests { assert_eq!(gouge.len(), 3); assert_eq!((gouge[2].kind, gouge[2].p0), (A_CONTROL, M_POLY)); } + for spell_id in [17086, 17616, 18392] { + assert_eq!(effects(spell_id)[0].target, T_AREA_ENEMY); + } let stealth = effects(1784); assert!( !rows @@ -2788,6 +2824,21 @@ mod tests { ); } + #[test] + fn every_pinned_location_target_uses_its_companion_unit_selection() { + for target_a in LOCATION_TARGET_CODES { + assert_eq!( + resolve_target(unit_selection_from_pair(target_a, 6), true), + T_TARGET_ENEMY, + "location target {target_a} did not use its enemy companion" + ); + } + assert_eq!(unit_selection_from_pair(0, 6), 6); + assert_eq!(unit_selection_from_pair(45, 6), 45); + assert_eq!(resolve_target(45, false), T_TARGET_ALLY); + assert_eq!(unit_selection_from_pair(26, 6), 26); + } + #[test] fn source_target_none_uses_compatible_flags_then_effect_defaults() { let mut dbc = fixture_tables();