Skip to main content

wowlab_engine_combat/builder/combat_builder/build/
overrides.rs

1use super::*;
2
3pub(super) struct ActionOverrideRegistration<'a> {
4    pub(super) definition: &'a mut CombatSystemBuilder,
5}
6
7impl ActionOverrideRegistration<'_> {
8    pub(super) fn apply_dbc_action_overrides(&mut self) {
9        let mut registrations = Vec::with_capacity(self.definition.aura_defs.len());
10
11        for aura in &self.definition.aura_defs {
12            let aura_id = SpellIdx::from_raw(aura.aura_id);
13
14            for effect_index in 1..=self.definition.game_data.max_effect_index(aura_id) {
15                let Some(replacement) = self
16                    .definition
17                    .game_data
18                    .override_action_spell(aura_id, effect_index)
19                else {
20                    continue;
21                };
22
23                let explicit_base = self
24                    .definition
25                    .game_data
26                    .effect_misc_value_0(aura_id, effect_index);
27
28                registrations.push((aura.aura_id, effect_index, replacement, explicit_base));
29            }
30        }
31
32        for (aura_id, effect_index, replacement, explicit_base) in registrations {
33            // Redirecting a base action onto a replacement the loadout never unlocked makes the
34            // base action uncastable for the whole aura window.
35            if self.is_unselected_talent_spell(replacement.as_u32()) {
36                continue;
37            }
38
39            let base_spell_indices: Vec<usize> = self
40                .definition
41                .spells
42                .iter()
43                .enumerate()
44                .filter_map(|(index, spell)| {
45                    let directly_named = explicit_base > 0
46                        && u32::try_from(explicit_base).ok() == Some(spell.spell_id);
47                    let affect_listed = self.definition.game_data.effect_affects_spell(
48                        SpellIdx::from_raw(aura_id),
49                        effect_index,
50                        SpellIdx::from_raw(spell.spell_id),
51                    );
52
53                    (directly_named || affect_listed).then_some(index)
54                })
55                .collect();
56
57            let Some(&template_index) = base_spell_indices.first() else {
58                continue;
59            };
60
61            self.ensure_replacement_spell(template_index, replacement);
62
63            if self.definition.pending_error.is_some()
64                || !self
65                    .definition
66                    .spells
67                    .iter()
68                    .any(|spell| spell.spell_id == replacement.as_u32())
69            {
70                continue;
71            }
72
73            let charges = self
74                .definition
75                .game_data
76                .proc_charges(SpellIdx::from_raw(aura_id))
77                .unwrap_or(0);
78            // A charged override aura grants exactly one replacement cast and is the only gate on
79            // it, so the replacement runs on its own recovery and the cast spends the charge. A
80            // chargeless form/stance aura only re-skins the button for its whole duration, so the
81            // base recovery governs and the aura survives the cast.
82            let persistent_form = charges == 0;
83
84            for index in base_spell_indices {
85                // BOUNDS: every index was produced by enumerating definition.spells above.
86                let spell = &mut self.definition.spells[index];
87
88                if spell.override_aura_id == 0 {
89                    spell.override_aura_id = aura_id;
90                    spell.override_spell_id = replacement.as_u32();
91                    spell.override_shares_base_cooldown = persistent_form;
92                    spell.override_preserves_aura = persistent_form;
93                }
94            }
95        }
96    }
97
98    /// Whether `spell_id` is a talent-gated spell the decoded loadout did not select.
99    ///
100    /// These are exactly the spells `populate_spell_slots` initializes with `is_enabled = 0`.
101    fn is_unselected_talent_spell(&self, spell_id: u32) -> bool {
102        self.definition.talent_spell_ids.contains(&spell_id)
103            && !self
104                .definition
105                .selected_talent_spell_ids
106                .contains(&spell_id)
107    }
108
109    fn replacement_aura(
110        &mut self,
111        base_spell_index: usize,
112        replacement: SpellIdx,
113    ) -> Option<crate::state::LocalAuraIdx> {
114        // BOUNDS: callers pass indices produced by enumerating definition.spells.
115        let base_spell = &self.definition.spells[base_spell_index];
116        let base_aura_local = base_spell.applies_aura?;
117        // BOUNDS: applies_aura indices are assigned from definition.aura_defs during construction.
118        let base_aura = &self.definition.aura_defs[base_aura_local.as_usize()];
119
120        if base_aura.aura_id != base_spell.spell_id {
121            return Some(base_aura_local);
122        }
123
124        if let Some(index) = self
125            .definition
126            .aura_defs
127            .iter()
128            .position(|aura| aura.aura_id == replacement.as_u32())
129        {
130            return Some(crate::state::LocalAuraIdx::new(
131                u8::try_from(index).expect("validated aura count fits in u8"),
132            ));
133        }
134
135        let has_applied_aura =
136            (1..=self.definition.game_data.max_effect_index(replacement)).any(|effect_index| {
137                wowlab_engine_domain::dbc::spell_effect_is(
138                    self.definition
139                        .game_data
140                        .effect_type(replacement, effect_index),
141                    wowlab_engine_domain::dbc::SpellEffectKind::ApplyAura,
142                )
143            });
144
145        if !has_applied_aura {
146            return Some(base_aura_local);
147        }
148
149        let name = format!("{} override", base_aura.name);
150        let draft = match AuraDefinitionDraft::new(&name, replacement.as_u32())
151            .apply_base_from_data(&self.definition.game_data, replacement.as_u32())
152        {
153            Ok(draft) => draft,
154            Err(error) => {
155                self.definition.pending_error = Some(error);
156
157                return None;
158            }
159        };
160        let draft = match base_aura.on {
161            AuraOn::Player => draft.on_player(),
162            AuraOn::Target => draft.on_target(),
163            AuraOn::Pet => draft.on_pet(),
164        };
165        let local = crate::state::LocalAuraIdx::new(
166            u8::try_from(self.definition.aura_defs.len()).expect("validated aura count fits in u8"),
167        );
168
169        self.definition.aura_ids.insert(name, replacement.as_u32());
170        self.definition.aura_defs.push(draft.finalize());
171
172        Some(local)
173    }
174
175    fn ensure_replacement_spell(&mut self, base_spell_index: usize, replacement: SpellIdx) {
176        if let Some(index) = self
177            .definition
178            .spells
179            .iter()
180            .position(|spell| spell.spell_id == replacement.as_u32())
181        {
182            // A replacement the manifest already declares still executes as the base action, so
183            // the base spell's cast hook runs for both. Only inherit when the declaration brings no
184            // hook of its own.
185            // BOUNDS: hook vectors are kept positionally aligned with definition.spells.
186            if self.definition.cast_hooks[index].is_none() {
187                // BOUNDS: index and base_spell_index both refer to aligned spell/hook entries.
188                self.definition.cast_hooks[index] = self.definition.cast_hooks[base_spell_index];
189            }
190
191            return;
192        }
193
194        let replacement_aura = self.replacement_aura(base_spell_index, replacement);
195
196        if self.definition.pending_error.is_some() {
197            return;
198        }
199
200        // BOUNDS: callers pass indices produced by enumerating definition.spells.
201
202        let base_spell_id = self.definition.spells[base_spell_index].spell_id;
203        // BOUNDS: the same validated index selects the replacement template.
204        let mut spell = self.definition.spells[base_spell_index].clone();
205
206        spell.spell_id = replacement.as_u32();
207        spell.name = format!("{} override", spell.name);
208        spell.applies_aura = replacement_aura;
209        spell.override_aura_id = 0;
210        spell.override_spell_id = 0;
211        spell.override_aura_min_stacks = 0;
212        spell.override_shares_base_cooldown = false;
213        spell.override_preserves_aura = false;
214
215        if spell.damage_effect.spell_id == base_spell_id {
216            spell.damage_effect.spell_id = replacement.as_u32();
217        }
218
219        if spell.channel_tick_spell_id == base_spell_id {
220            spell.channel_tick_spell_id = replacement.as_u32();
221        }
222
223        // BOUNDS: hook vectors are kept positionally aligned with definition.spells.
224
225        let hook = self.definition.cast_hooks[base_spell_index];
226        // BOUNDS: empower-release hooks are kept positionally aligned with definition.spells.
227        let empower_release_hook = self.definition.empower_release_hooks[base_spell_index];
228        // BOUNDS: tick hooks are kept positionally aligned with definition.spells.
229        let tick_hook = self.definition.tick_hooks[base_spell_index];
230
231        self.definition.spells.push(spell);
232        self.definition.cast_hooks.push(hook);
233        self.definition
234            .empower_release_hooks
235            .push(empower_release_hook);
236        self.definition.tick_hooks.push(tick_hook);
237    }
238}