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