Skip to main content

wowlab_engine_combat/builder/
draft.rs

1// #t(file: rust_too_many_lines_in_file) the construction draft owns the single cold freeze boundary and its invariants are kept together for auditability.
2
3use wowlab_engine_domain::{
4    encounter::EnemyActorDefinition,
5    pool::PoolIndex,
6    rotation::{BufferRequirements, CatalogHints},
7};
8use wowlab_engine_ports::EngineError;
9use wowlab_types::{
10    data::CooldownCategoryId,
11    game::GearSlot,
12    sim::{ActorId, AuraIdx, AuraKey, AuraOn, FastMap, IntMap, PetIdx, SimTime, SpellIdx},
13};
14
15use super::{
16    BuilderError, BuiltCombatSystem,
17    buffer_init::{InitialBufferConfig, populate_initial_buffer},
18    compile::{
19        CooldownBypass, CostBypass, RotationCompileInput, SpellGate, SpellOverride, bind_rotation,
20        emit_rotation, prepare_rotation,
21    },
22};
23use crate::{
24    AccumulatingImpactProc, AuraBinding, CastHookFn, DriverSpellModifier, ImpactProc,
25    LandedImpactProc, LocalAuraIdx, LocalSpellIdx, LocalThresholdIdx, PassiveDriverEffect,
26    PeriodicDriver, ResourceGainProc, RppmBinding, RppmTracker, SpellBinding, SpellEffectData,
27    SystemRppmBinding, ThresholdTracker, WeaponEnchantBinding,
28    state::{
29        AuraData, AutoAttackData, BaseStats, BuffEffect, BufferBindings, CombatInstance,
30        CombatProgram, CombatRuntime, EmpowerReleaseHookFn, ImpactEffectProc, LocalRppmIdx,
31        PetActionBar, ProgramConfig, ResolvedSystemRppmBinding, ScratchBuffers, SpellData,
32        WeaponImbueBinding, try_push_effect,
33    },
34};
35
36#[derive(Debug)]
37pub(crate) struct DraftDefinitions {
38    pub(crate) enemies: Vec<EnemyActorDefinition>,
39    pub(crate) spells: Vec<SpellData>,
40    pub(crate) empower_cast_times_ms: Vec<u32>,
41    pub(crate) spell_effects: Vec<SpellEffectData>,
42    pub(crate) auras: Vec<AuraData>,
43    pub(crate) aura_bindings: Vec<(AuraBinding, u32)>,
44    pub(crate) spell_bindings: Vec<(SpellBinding, u32)>,
45    pub(crate) auto_attacks: Vec<AutoAttackData>,
46    pub(crate) cast_hooks: Vec<Option<CastHookFn>>,
47    pub(crate) empower_release_hooks: Vec<Option<EmpowerReleaseHookFn>>,
48    pub(crate) tick_hooks: Vec<Option<CastHookFn>>,
49    pub(crate) player_cast_hooks: Vec<CastHookFn>,
50    pub(crate) resource_threshold_triggers: Vec<crate::state::ResourceThresholdTrigger>,
51    pub(crate) impact_procs: Vec<ImpactProc>,
52    pub(crate) landed_impact_procs: Vec<LandedImpactProc>,
53    pub(crate) accumulating_impact_procs: Vec<AccumulatingImpactProc>,
54    pub(crate) impact_effect_procs: Vec<ImpactEffectProc>,
55    pub(crate) driver_spell_modifiers: Vec<DriverSpellModifier>,
56    pub(crate) passive_driver_effects: Vec<PassiveDriverEffect>,
57    pub(crate) periodic_drivers: Vec<PeriodicDriver>,
58    pub(crate) talent_ranks: IntMap<u32, u8>,
59    pub(crate) resource_gain_procs: Vec<ResourceGainProc>,
60    pub(crate) item_use_spells: Vec<(GearSlot, u32)>,
61    pub(crate) rppm_trackers: Vec<RppmTracker>,
62    pub(crate) rppm_bindings: Vec<(RppmBinding, LocalRppmIdx)>,
63    pub(crate) system_rppm_bindings: Vec<(SystemRppmBinding, ResolvedSystemRppmBinding)>,
64    pub(crate) threshold_trackers: Vec<ThresholdTracker>,
65    pub(crate) weapon_enchant_bindings: Vec<WeaponEnchantBinding>,
66    pub(crate) weapon_imbue_bindings: Vec<WeaponImbueBinding>,
67    pub(crate) precombat_auras: Vec<LocalAuraIdx>,
68    pub(crate) talent_aura_stacks: Vec<(LocalAuraIdx, u8)>,
69    pub(crate) stealth_aura_id: Option<u32>,
70}
71
72impl DraftDefinitions {
73    pub(crate) fn new(enemies: Vec<EnemyActorDefinition>) -> Self {
74        Self {
75            enemies,
76            spells: Vec::new(),
77            empower_cast_times_ms: Vec::new(),
78            spell_effects: Vec::new(),
79            auras: Vec::new(),
80            aura_bindings: Vec::new(),
81            spell_bindings: Vec::new(),
82            auto_attacks: Vec::new(),
83            cast_hooks: Vec::new(),
84            empower_release_hooks: Vec::new(),
85            tick_hooks: Vec::new(),
86            player_cast_hooks: Vec::new(),
87            resource_threshold_triggers: Vec::new(),
88            impact_procs: Vec::new(),
89            landed_impact_procs: Vec::new(),
90            accumulating_impact_procs: Vec::new(),
91            impact_effect_procs: Vec::new(),
92            driver_spell_modifiers: Vec::new(),
93            passive_driver_effects: Vec::new(),
94            periodic_drivers: Vec::new(),
95            talent_ranks: IntMap::default(),
96            resource_gain_procs: Vec::new(),
97            item_use_spells: Vec::new(),
98            rppm_trackers: Vec::new(),
99            rppm_bindings: Vec::new(),
100            system_rppm_bindings: Vec::new(),
101            threshold_trackers: Vec::new(),
102            weapon_enchant_bindings: Vec::new(),
103            weapon_imbue_bindings: Vec::new(),
104            precombat_auras: Vec::new(),
105            talent_aura_stacks: Vec::new(),
106            stealth_aura_id: None,
107        }
108    }
109}
110
111#[derive(Debug)]
112pub(crate) struct DraftIndexes {
113    pub(crate) spell_by_id: IntMap<u32, LocalSpellIdx>,
114    pub(crate) aura_by_id: IntMap<u32, LocalAuraIdx>,
115    pub(crate) aura_by_identity: FastMap<(u32, AuraOn), LocalAuraIdx>,
116    pub(crate) cooldown_categories: PoolIndex<CooldownCategoryId, LocalSpellIdx>,
117    pub(crate) charge_categories: PoolIndex<CooldownCategoryId, LocalSpellIdx>,
118    pub(crate) item_rppm_indices: IntMap<u32, LocalRppmIdx>,
119    pub(crate) system_rppm_indices: IntMap<u32, LocalRppmIdx>,
120    pub(crate) enchant_rppm_indices: IntMap<u32, LocalRppmIdx>,
121    pub(crate) enchant_buff_spell_ids: IntMap<u32, u32>,
122}
123
124#[derive(Debug)]
125struct FrozenProgramBindings {
126    scheduled_spells: wowlab_engine_event_authority::ScheduledSpellCatalog,
127    runtime_events: wowlab_engine_event_authority::RuntimeEventCatalog,
128    scheduled_spell_executions: Box<[crate::state::program::ScheduledSpellExecution]>,
129    layout_fingerprint: wowlab_engine_domain::rotation::LayoutFingerprint,
130    buffer_bindings: BufferBindings,
131}
132
133/// Mutable composition stage before combat-system finalization.
134#[derive(Debug)]
135#[must_use]
136// docref:start spec-handlers-built-combat-system
137pub struct CombatSystemDraft {
138    pub(crate) spec_identity_brand: Option<u32>,
139    pub(crate) definitions: DraftDefinitions,
140    pub(crate) indexes: DraftIndexes,
141    pub(crate) runtime: CombatRuntime,
142    pub(crate) config: ProgramConfig,
143    pub(crate) names: Vec<Box<str>>,
144    pub(crate) spell_ids: FastMap<String, u32>,
145    pub(crate) damage_spell_ids: Vec<u32>,
146    pub(crate) effect_spell_ids: Vec<u32>,
147    pub(crate) generated_damage_identities: Vec<crate::DamageActionId>,
148    pub(crate) aura_ids: FastMap<String, u32>,
149    pub(crate) hints: CatalogHints,
150    pub(crate) gating: Vec<SpellGate>,
151    pub(crate) cost_bypass: Vec<CostBypass>,
152    pub(crate) cooldown_bypass: Vec<CooldownBypass>,
153    pub(crate) overrides: Vec<SpellOverride>,
154    pub(crate) resource_max: f64,
155    pub(crate) talent_ranks: Vec<(String, u8)>,
156    pub(crate) talent_spell_ids: Vec<u32>,
157    pub(crate) selected_talent_spell_ids: Vec<u32>,
158    pub(crate) selected_replaced_spell_ids: Vec<u32>,
159    pub(crate) disabled_spell_ids: Vec<u32>,
160    pub(crate) initial_weapon_imbues: Vec<(&'static str, bool)>,
161    pub(crate) ground_effects: Vec<crate::GroundEffectDefinition>,
162    pub(crate) hook_flat_damage: FastMap<u32, f64>,
163    pub(crate) hook_scalars: FastMap<u32, f64>,
164}
165// docref:end spec-handlers-built-combat-system
166
167impl CombatSystemDraft {
168    /// Bind a codegen-owned semantic aura coordinate during late composition.
169    pub fn bind_aura(&mut self, binding: AuraBinding, aura_id: u32) {
170        self.definitions.aura_bindings.push((binding, aura_id));
171    }
172
173    /// Bind a codegen-owned semantic spell coordinate during late composition.
174    pub fn bind_spell(&mut self, binding: SpellBinding, spell_id: u32) {
175        self.definitions.spell_bindings.push((binding, spell_id));
176    }
177
178    /// Replace the immutable specialization configuration before finalization.
179    pub fn set_spec_config<T>(&mut self, config: T)
180    where
181        T: wowlab_engine_ports::SpecConfig,
182    {
183        self.config.spec_config = Box::new(config);
184    }
185
186    /// Replace the resettable specialization runtime before finalization.
187    pub fn set_spec_runtime<T>(&mut self, runtime: T)
188    where
189        T: wowlab_engine_ports::SpecRuntime,
190    {
191        self.runtime.set_spec_runtime(Box::new(runtime));
192    }
193
194    pub fn set_bug_toggles(&mut self, bugs: wowlab_engine_ports::BugToggles) {
195        self.config.bugs = bugs;
196    }
197
198    pub fn set_cast_latency(&mut self, cast_latency: wowlab_engine_ports::CastLatency) {
199        self.config.cast_latency = cast_latency;
200    }
201
202    pub fn set_raid_events(&mut self, raid_events: &[wowlab_engine_ports::RaidEventConfig]) {
203        self.config.raid_events = raid_events.to_vec();
204    }
205
206    pub fn set_external_buffs(
207        &mut self,
208        external_buffs: &[wowlab_engine_ports::ExternalBuffConfig],
209    ) {
210        self.config.external_buffs = external_buffs.to_vec();
211    }
212
213    /// Attach an action bar to the persistent pet identified by its creature id.
214    pub fn set_pet_action_bar(&mut self, npc_id: u32, action_bar: PetActionBar) -> bool {
215        let Some(attack) = self
216            .definitions
217            .auto_attacks
218            .iter_mut()
219            .find(|attack| attack.is_pet && attack.npc_id == Some(npc_id))
220        else {
221            return false;
222        };
223
224        attack.pet_action_bar = Some(action_bar);
225
226        true
227    }
228
229    pub fn disable_spell(&mut self, spell_id: u32) {
230        if !self.disabled_spell_ids.contains(&spell_id) {
231            self.disabled_spell_ids.push(spell_id);
232        }
233    }
234
235    /// Declares a non-castable damage identity referenced by late composition wiring.
236    pub fn register_damage_identity(
237        &mut self,
238        _name: impl Into<String>,
239        identity: crate::DamageActionId,
240    ) {
241        let spell_id = identity.spell_id();
242
243        if !self.damage_spell_ids.contains(&spell_id) {
244            self.damage_spell_ids.push(spell_id);
245        }
246
247        if !self.generated_damage_identities.contains(&identity) {
248            self.generated_damage_identities.push(identity);
249        }
250    }
251
252    /// Registers the branded action owning one generated effect coordinate.
253    pub fn register_damage_effect_identity(
254        &mut self,
255        name: impl Into<String>,
256        effect: crate::DamageEffectId,
257    ) {
258        self.register_damage_identity(name, effect.action());
259    }
260
261    #[cfg(any(test, feature = "test-support"))]
262    #[doc(hidden)]
263    pub fn register_diagnostic_damage_identity(&mut self, name: impl Into<String>, spell_id: u32) {
264        wowlab_types::bench_metrics::record_keyed_lookup();
265        self.register_damage_identity(name, crate::DamageActionId::new(0, spell_id));
266    }
267
268    /// Apply a build-time spell patch and atomically refresh its buffer projections.
269    pub fn patch_spell(
270        &mut self,
271        spell: LocalSpellIdx,
272        patch: impl FnOnce(&mut SpellData),
273    ) -> bool {
274        let Some(data) = self.definitions.spells.get_mut(spell.as_usize()) else {
275            return false;
276        };
277        let identity = (data.spell_id, data.name_idx);
278
279        patch(data);
280        (data.spell_id, data.name_idx) = identity;
281
282        true
283    }
284
285    /// Apply a build-time patch to every finalized spell definition.
286    pub fn patch_spells(&mut self, mut patch: impl FnMut(&mut SpellData)) {
287        for spell in &mut self.definitions.spells {
288            let identity = (spell.spell_id, spell.name_idx);
289
290            patch(spell);
291            (spell.spell_id, spell.name_idx) = identity;
292        }
293    }
294
295    /// Apply a build-time aura patch to the authoritative definition.
296    pub fn patch_aura(&mut self, aura: LocalAuraIdx, patch: impl FnOnce(&mut AuraData)) -> bool {
297        let Some(data) = self.definitions.auras.get_mut(aura.as_usize()) else {
298            return false;
299        };
300        let identity = (data.aura_id, data.name_idx, data.on, data.propagates_to_pet);
301
302        patch(data);
303        (data.aura_id, data.name_idx, data.on, data.propagates_to_pet) = identity;
304
305        true
306    }
307
308    /// Register an aura added after the declarative combat system has been built.
309    ///
310    /// This synchronizes the name table, definition, aura-id index, and identity index.
311    /// Persistent buffer slots are allocated by the consuming finalization transaction.
312    ///
313    /// # Errors
314    ///
315    /// Returns an error when the runtime aura registry exceeds its local-index capacity.
316    pub fn register_aura(
317        &mut self,
318        name: impl Into<Box<str>>,
319        mut aura: AuraData,
320    ) -> Result<LocalAuraIdx, EngineError> {
321        aura.name_idx = u16::try_from(self.names.len()).map_err(|error| {
322            EngineError::spec_construction(format!(
323                "combat definition name registry exceeds u16 capacity: {error}"
324            ))
325        })?;
326        let local =
327            LocalAuraIdx::new(u8::try_from(self.definitions.auras.len()).map_err(|error| {
328                EngineError::spec_construction(format!(
329                    "runtime aura registry exceeds u8 capacity: {error}"
330                ))
331            })?);
332        let aura_id = aura.aura_id;
333        let on = aura.on;
334        let propagates_to_pet = aura.propagates_to_pet;
335
336        self.names.push(name.into());
337        self.definitions.auras.push(aura);
338        self.indexes.aura_by_id.insert(aura_id, local);
339        self.indexes
340            .aura_by_identity
341            .entry((aura_id, on))
342            .or_insert(local);
343
344        if propagates_to_pet {
345            self.indexes
346                .aura_by_identity
347                .entry((aura_id, AuraOn::Pet))
348                .or_insert(local);
349        }
350
351        Ok(local)
352    }
353
354    /// Registers an impact proc during composition.
355    pub fn register_impact_proc(&mut self, proc: ImpactProc) {
356        self.definitions.impact_procs.push(proc);
357    }
358
359    /// Registers a landed-impact proc during composition.
360    pub fn register_landed_impact_proc(&mut self, proc: LandedImpactProc) {
361        self.definitions.landed_impact_procs.push(proc);
362    }
363
364    /// Registers an accumulating impact proc during composition.
365    pub fn register_accumulating_impact_proc(&mut self, proc: AccumulatingImpactProc) {
366        self.definitions.accumulating_impact_procs.push(proc);
367    }
368
369    /// Registers a resource-gain proc during composition.
370    pub fn register_resource_gain_proc(&mut self, proc: ResourceGainProc) {
371        self.definitions.resource_gain_procs.push(proc);
372    }
373
374    /// Registers an RPPM tracker during composition.
375    ///
376    /// # Errors
377    ///
378    /// Returns an error when the local tracker-index capacity is exhausted.
379    pub fn register_rppm_tracker(
380        &mut self,
381        tracker: RppmTracker,
382    ) -> Result<LocalRppmIdx, EngineError> {
383        let local = LocalRppmIdx::new(u8::try_from(self.definitions.rppm_trackers.len()).map_err(
384            |error| {
385                EngineError::spec_construction(format!(
386                    "RPPM tracker registry exceeds u8 capacity: {error}"
387                ))
388            },
389        )?);
390
391        self.definitions.rppm_trackers.push(tracker);
392
393        Ok(local)
394    }
395
396    /// Register one equipped weapon-enchant source and its shared RPPM tracker.
397    ///
398    /// Duplicate enchantment IDs return `false`; the first binding installs the impact dispatcher.
399    ///
400    /// # Errors
401    ///
402    /// Returns an error when the local tracker-index capacity is exhausted.
403    pub fn register_weapon_enchant_binding(
404        &mut self,
405        binding: WeaponEnchantBinding,
406        tracker: RppmTracker,
407    ) -> Result<bool, EngineError> {
408        if self
409            .indexes
410            .enchant_rppm_indices
411            .contains_key(&binding.enchantment_id)
412        {
413            return Ok(false);
414        }
415
416        let first = self.definitions.weapon_enchant_bindings.is_empty();
417        let tracker = self.register_rppm_tracker(tracker)?;
418
419        self.indexes
420            .enchant_rppm_indices
421            .insert(binding.enchantment_id, tracker);
422        self.indexes
423            .enchant_buff_spell_ids
424            .insert(binding.enchantment_id, binding.buff_spell_id);
425        self.definitions.weapon_enchant_bindings.push(binding);
426
427        if first {
428            self.register_impact_proc(ImpactProc::new(
429                crate::systems::weapon_enchants_on_player_impact,
430            ));
431        }
432
433        Ok(true)
434    }
435
436    /// Registers an accumulated-threshold tracker during composition.
437    ///
438    /// # Errors
439    ///
440    /// Returns an error when the local tracker-index capacity is exhausted.
441    pub fn register_threshold_tracker(
442        &mut self,
443        tracker: ThresholdTracker,
444    ) -> Result<LocalThresholdIdx, EngineError> {
445        let local = LocalThresholdIdx::new(
446            u8::try_from(self.definitions.threshold_trackers.len()).map_err(|error| {
447                EngineError::spec_construction(format!(
448                    "threshold tracker registry exceeds u8 capacity: {error}"
449                ))
450            })?,
451        );
452
453        self.definitions.threshold_trackers.push(tracker);
454
455        Ok(local)
456    }
457
458    /// Appends an aura to the precombat application set.
459    pub fn push_precombat_aura(&mut self, aura: LocalAuraIdx) {
460        self.definitions.precombat_auras.push(aura);
461    }
462
463    /// Include or exclude an aura from the precombat application set.
464    pub fn set_precombat_aura(&mut self, aura: LocalAuraIdx, enabled: bool) {
465        if enabled {
466            if !self.definitions.precombat_auras.contains(&aura) {
467                self.definitions.precombat_auras.push(aura);
468            }
469        } else {
470            self.definitions
471                .precombat_auras
472                .retain(|candidate| *candidate != aura);
473        }
474    }
475
476    /// Removes an aura from every precombat talent projection.
477    pub fn remove_precombat_aura(&mut self, aura: LocalAuraIdx) {
478        self.definitions
479            .precombat_auras
480            .retain(|candidate| *candidate != aura);
481        self.definitions
482            .talent_aura_stacks
483            .retain(|(candidate, _)| *candidate != aura);
484    }
485
486    /// Registers a hook fired after every player cast.
487    pub fn register_player_cast_hook(&mut self, hook: CastHookFn) {
488        self.definitions.player_cast_hooks.push(hook);
489    }
490
491    /// Returns a registered aura definition.
492    #[must_use]
493    pub fn aura(&self, aura: LocalAuraIdx) -> Option<&AuraData> {
494        self.definitions.auras.get(aura.as_usize())
495    }
496
497    pub fn auras(&self) -> &[AuraData] {
498        &self.definitions.auras
499    }
500
501    pub fn precombat_auras(&self) -> &[LocalAuraIdx] {
502        &self.definitions.precombat_auras
503    }
504
505    /// Returns a registered RPPM tracker.
506    #[must_use]
507    pub fn rppm_tracker(&self, tracker: LocalRppmIdx) -> Option<&RppmTracker> {
508        self.definitions.rppm_trackers.get(tracker.as_usize())
509    }
510
511    /// Returns a registered impact proc.
512    #[must_use]
513    pub fn impact_proc(&self, index: usize) -> Option<&ImpactProc> {
514        self.definitions.impact_procs.get(index)
515    }
516
517    /// Returns the number of registered impact procs.
518    #[must_use]
519    pub fn impact_proc_count(&self) -> usize {
520        self.definitions.impact_procs.len()
521    }
522
523    /// Returns a registered lowered impact-effect proc.
524    #[must_use]
525    pub fn impact_effect_proc(&self, index: usize) -> Option<&ImpactEffectProc> {
526        self.definitions.impact_effect_procs.get(index)
527    }
528
529    /// Returns the number of registered lowered impact-effect procs.
530    #[must_use]
531    pub fn impact_effect_proc_count(&self) -> usize {
532        self.definitions.impact_effect_procs.len()
533    }
534
535    /// Returns the lowered spell-effect program arena for read-only inspection.
536    #[must_use]
537    pub fn spell_effects(&self) -> &[SpellEffectData] {
538        &self.definitions.spell_effects
539    }
540
541    /// Returns the selected rank of a talent spell.
542    #[must_use]
543    pub fn talent_rank(&self, spell_id: u32) -> Option<u8> {
544        self.definitions.talent_ranks.get(&spell_id).copied()
545    }
546
547    /// Returns the number of registered RPPM trackers.
548    #[must_use]
549    pub fn rppm_tracker_count(&self) -> usize {
550        self.definitions.rppm_trackers.len()
551    }
552
553    /// Returns the number of registered player-cast hooks.
554    #[must_use]
555    pub fn player_cast_hook_count(&self) -> usize {
556        self.definitions.player_cast_hooks.len()
557    }
558
559    /// Returns the number of registered spells.
560    #[must_use]
561    pub fn spell_count(&self) -> usize {
562        self.definitions.spells.len()
563    }
564
565    /// Returns the first auto-attack definition.
566    #[must_use]
567    pub fn first_auto_attack(&self) -> Option<&AutoAttackData> {
568        self.definitions.auto_attacks.first()
569    }
570
571    /// Applies a build-time patch to the registered auto-attack definitions.
572    pub fn patch_auto_attacks(&mut self, patch: impl FnOnce(&mut [AutoAttackData])) {
573        patch(&mut self.definitions.auto_attacks);
574    }
575
576    /// Register a system-level RPPM source from its DBC driver spell.
577    ///
578    /// The definition and driver lookup are committed together.
579    /// Re-registering the same driver is idempotent and returns its existing local index.
580    ///
581    /// # Errors
582    ///
583    /// Returns a typed spec-construction error when the driver has no finite positive RPPM rate.
584    /// It also errors when the local tracker-index capacity is exhausted.
585    pub fn register_system_rppm_from_data(
586        &mut self,
587        driver_spell_id: u32,
588    ) -> Result<LocalRppmIdx, EngineError> {
589        if let Some(local) = self
590            .indexes
591            .system_rppm_indices
592            .get(&driver_spell_id)
593            .copied()
594        {
595            return Ok(local);
596        }
597
598        let driver = SpellIdx::from_raw(driver_spell_id);
599        let Some((rppm, _)) = self.config.game_data.rppm(driver) else {
600            return Err(EngineError::spec_construction_source(
601                BuilderError::missing_system_rppm_data(driver_spell_id),
602            ));
603        };
604
605        if !rppm.is_finite() || rppm <= 0.0 {
606            return Err(EngineError::spec_construction_source(
607                BuilderError::invalid_system_rppm_data(driver_spell_id, rppm),
608            ));
609        }
610
611        let tracker = RppmTracker {
612            rppm,
613            last_attempt_time: 0.0,
614            last_proc_time: 0.0,
615            accumulated_blp: 0.0,
616            haste_scales: self
617                .config
618                .game_data
619                .rppm_haste_scales(driver)
620                .unwrap_or(false),
621            crit_scales: self
622                .config
623                .game_data
624                .rppm_crit_scales(driver)
625                .unwrap_or(false),
626            auto_attack_speed_scales: false,
627            blp_enabled: true,
628        };
629
630        let tracker_count = self.definitions.rppm_trackers.len();
631
632        if let Err(source) = u8::try_from(tracker_count) {
633            return Err(EngineError::spec_construction_source(
634                BuilderError::system_rppm_registry_full(driver_spell_id, tracker_count, source),
635            ));
636        }
637
638        let local = self.register_rppm_tracker(tracker)?;
639
640        self.indexes
641            .system_rppm_indices
642            .insert(driver_spell_id, local);
643
644        Ok(local)
645    }
646
647    /// Freeze a system RPPM tracker and its category recovery behind a semantic token.
648    ///
649    /// # Errors
650    ///
651    /// Returns an error when the driver spell has no valid RPPM data or the tracker registry is full.
652    pub fn bind_system_rppm(
653        &mut self,
654        binding: SystemRppmBinding,
655        driver_spell_id: u32,
656    ) -> Result<(), EngineError> {
657        let tracker = self.register_system_rppm_from_data(driver_spell_id)?;
658        let recovery_ms = self
659            .config
660            .game_data
661            .proc_category_recovery_ms(SpellIdx::from_raw(driver_spell_id))
662            .unwrap_or(0);
663
664        self.definitions.system_rppm_bindings.push((
665            binding,
666            ResolvedSystemRppmBinding {
667                tracker,
668                recovery_ms,
669            },
670        ));
671
672        Ok(())
673    }
674
675    /// Apply a build-time base-stat patch and refresh the primary resource projection.
676    pub fn patch_base_stats(&mut self, patch: impl FnOnce(&mut BaseStats)) {
677        patch(&mut self.config.base_stats);
678        self.resource_max = self.config.base_stats.resource_max;
679    }
680
681    /// Return the resolved baseline combat stats used by content composition.
682    #[must_use]
683    pub fn base_combat_stats(&self) -> &wowlab_engine_ports::CombatStats {
684        &self.config.base_stats.stats
685    }
686
687    #[must_use]
688    pub fn base_stats(&self) -> &BaseStats {
689        &self.config.base_stats
690    }
691
692    #[must_use]
693    pub fn game_data(&self) -> &wowlab_engine_gamedata::ResolvedGameData {
694        &self.config.game_data
695    }
696
697    #[must_use]
698    pub fn spell_local(&self, spell_id: u32) -> Option<LocalSpellIdx> {
699        self.indexes.spell_by_id.get(&spell_id).copied()
700    }
701
702    #[must_use]
703    pub fn aura_local(&self, aura_id: u32) -> Option<LocalAuraIdx> {
704        self.indexes.aura_by_id.get(&aura_id).copied()
705    }
706
707    #[must_use]
708    pub fn bug_enabled(&self, bug: &wowlab_engine_ports::GameBug) -> bool {
709        self.config.bugs.enabled(bug)
710    }
711
712    #[must_use]
713    pub fn spec_config<T>(&self) -> Option<&T>
714    where
715        T: wowlab_engine_ports::SpecConfig,
716    {
717        self.config
718            .spec_config
719            .as_ref()
720            .as_any()
721            .downcast_ref::<T>()
722    }
723
724    /// Appends an effect to a draft aura before finalization.
725    ///
726    /// # Panics
727    ///
728    /// Panics in debug builds if `aura` is not registered, or in all builds if its fixed-capacity effect list is full.
729    pub fn set_aura_effect(&mut self, aura: LocalAuraIdx, effect: BuffEffect) {
730        let Some(data) = self.definitions.auras.get_mut(aura.as_usize()) else {
731            debug_assert!(false, "aura index {} out of bounds", aura.raw());
732
733            return;
734        };
735        let result = try_push_effect(&mut data.effects, effect);
736
737        assert!(
738            result.is_ok(),
739            "no free effect slot for aura {}",
740            aura.raw()
741        );
742    }
743
744    /// Removes talent-dependent DBC-derived rows to prevent duplicate modifiers and returns the number cleared.
745    pub fn mask_aura_dbc_effect(
746        &mut self,
747        aura: LocalAuraIdx,
748        source_spell_id: u32,
749        effect_index: u8,
750    ) -> usize {
751        let Some(data) = self.definitions.auras.get_mut(aura.as_usize()) else {
752            debug_assert!(false, "aura index {} out of bounds", aura.raw());
753
754            return 0;
755        };
756        let mut masked = 0;
757
758        for slot in &mut data.effects {
759            if slot
760                .as_ref()
761                .and_then(BuffEffect::dbc_source)
762                .is_some_and(|source| source == (source_spell_id, effect_index))
763            {
764                *slot = None;
765                masked += 1;
766            }
767        }
768
769        masked
770    }
771
772    /// Duplicates rows lowered from one DBC effect while preserving their resolved payload.
773    ///
774    /// Intended for modeled live-game bugs where the server applies the same modifier twice.
775    /// Content identifies only the source coordinate.
776    pub fn duplicate_aura_dbc_effect(
777        &mut self,
778        aura: LocalAuraIdx,
779        source_spell_id: u32,
780        effect_index: u8,
781    ) -> usize {
782        let Some(data) = self.definitions.auras.get(aura.as_usize()) else {
783            debug_assert!(false, "aura index {} out of bounds", aura.raw());
784
785            return 0;
786        };
787        let effects: Vec<_> = data
788            .effects
789            .iter()
790            .flatten()
791            .copied()
792            .filter(|effect| effect.dbc_source() == Some((source_spell_id, effect_index)))
793            .collect();
794        let duplicated = effects.len();
795
796        for effect in effects {
797            self.set_aura_effect(aura, effect);
798        }
799
800        duplicated
801    }
802
803    /// Register an always-present spell modifier under its real spell/passive driver.
804    pub fn register_driver_spell_modifier(&mut self, modifier: DriverSpellModifier) {
805        self.definitions.driver_spell_modifiers.push(modifier);
806    }
807
808    /// Register an always-present player effect under its real spell/passive driver.
809    pub fn register_passive_driver_effect(&mut self, effect: PassiveDriverEffect) {
810        self.definitions.passive_driver_effects.push(effect);
811    }
812
813    /// Register a real weapon-imbue action and initialize its persistent equipment state.
814    pub fn register_weapon_imbue(
815        &mut self,
816        spell_id: u32,
817        key: &'static str,
818        initially_active: bool,
819    ) {
820        self.definitions
821            .weapon_imbue_bindings
822            .push(WeaponImbueBinding { spell_id, key });
823        self.initial_weapon_imbues.push((key, initially_active));
824    }
825
826    /// Register encounter-lifetime periodic work under its real spell/passive driver.
827    pub fn register_periodic_driver(&mut self, driver: PeriodicDriver) {
828        self.definitions.periodic_drivers.push(driver);
829    }
830
831    /// Registers an authored ground effect for binding during finalization.
832    ///
833    /// # Errors
834    ///
835    /// Returns an error when the ground-effect registry exceeds its `u16` identity space.
836    pub fn register_ground_effect(
837        &mut self,
838        definition: crate::GroundEffectDefinition,
839    ) -> Result<crate::GroundEffectId, EngineError> {
840        let local = u16::try_from(self.ground_effects.len()).map_err(|error| {
841            EngineError::spec_construction(format!(
842                "ground-effect registry exceeds u16 capacity: {error}"
843            ))
844        })?;
845
846        self.ground_effects.push(definition);
847
848        Ok(crate::GroundEffectId(local))
849    }
850
851    /// Freezes an authored flat-damage payload into the finalized action plan.
852    pub fn set_hook_flat_damage(&mut self, action: crate::DamageActionId, amount: f64) {
853        self.hook_flat_damage.insert(action.spell_id(), amount);
854    }
855
856    /// Freezes an authored scalar into the finalized action plan.
857    pub fn set_hook_scalar(&mut self, action: crate::DamageActionId, value: f64) {
858        self.hook_scalars.insert(action.spell_id(), value);
859    }
860
861    /// Consume this composition draft and create the immutable program and mutable instance.
862    ///
863    /// # Errors
864    ///
865    /// Returns an error when validation, game-data resolution, rotation compilation, or final binding fails.
866    // #t(fn: rust_map_err_pure_wrap) rotation compilation must retain the EngineError rotation category and its typed source.
867    // #t(fn: rust_cyclomatic_complexity) finalization is one ordered validation, freeze, bind, and instance-construction transaction.
868    // #t(fn: rust_max_fn_lines) finalization keeps the one-way draft-to-program freeze transaction auditable in construction order.
869    pub fn finalize(
870        mut self,
871        rotation: &wowlab_types::sim::Rotation,
872    ) -> Result<BuiltCombatSystem, EngineError> {
873        self.validate_identities()?;
874        let mut scheduled_spell_ids = self
875            .definitions
876            .spells
877            .iter()
878            .map(SpellData::idx)
879            .collect::<Vec<_>>();
880
881        let mut missing_health_trigger = None;
882
883        for effect in self
884            .definitions
885            .auras
886            .iter()
887            .flat_map(|aura| aura.effects.iter().flatten())
888        {
889            if let BuffEffect::HealthThresholdTrigger { spell_id, .. } = effect {
890                let spell = SpellIdx::from_raw(*spell_id);
891
892                if !scheduled_spell_ids.contains(&spell) {
893                    scheduled_spell_ids.push(spell);
894                }
895            }
896        }
897
898        let scheduled_spells = wowlab_engine_event_authority::ScheduledSpellCatalog::new(
899            scheduled_spell_ids.iter().copied(),
900        );
901
902        for effect in self
903            .definitions
904            .auras
905            .iter_mut()
906            .flat_map(|aura| aura.effects.iter_mut().flatten())
907        {
908            if let BuffEffect::HealthThresholdTrigger {
909                spell_id,
910                scheduled,
911                ..
912            } = effect
913            {
914                *scheduled = scheduled_spells.bind_spell_id(SpellIdx::from_raw(*spell_id));
915
916                if scheduled.is_none() {
917                    missing_health_trigger = Some(*spell_id);
918                }
919            }
920        }
921
922        if let Some(spell_id) = missing_health_trigger {
923            return Err(EngineError::spec_construction(format!(
924                "health trigger {spell_id} is absent from the finalized event-spell catalog"
925            )));
926        }
927
928        let mut effects = crate::state::program::EffectPlan::compile(
929            &self.definitions.auras,
930            &self.definitions.passive_driver_effects,
931        );
932        let aura_dependency_graph = crate::state::program::AuraDependencyGraph::compile(
933            &self.definitions.auras,
934            &self.definitions.spells,
935            &self.definitions.spell_effects,
936            &self.config,
937        );
938
939        self.size_scratch_buffers();
940        let reachable_damage_spell_ids = self.reachable_damage_spell_ids();
941        let damage = crate::state::program::DamagePlan::compile(
942            &self.config,
943            &self.definitions.spells,
944            &self.definitions.auras,
945            &self.definitions.passive_driver_effects,
946            &self.definitions.spell_effects,
947            &self.effect_spell_ids,
948            &self.generated_damage_identities,
949            &reachable_damage_spell_ids,
950            &self.damage_spell_ids,
951            &effects,
952            &self.definitions.driver_spell_modifiers,
953            &self.definitions.impact_effect_procs,
954            &self.definitions.periodic_drivers,
955            &self.definitions.landed_impact_procs,
956            &self.definitions.auto_attacks,
957            &self.hook_flat_damage,
958            &self.hook_scalars,
959        )
960        .map_err(EngineError::spec_construction)?;
961        let scheduled_spell_executions = scheduled_spell_ids
962            .iter()
963            .enumerate()
964            .map(|(index, spell)| -> Result<_, EngineError> {
965                if index < self.definitions.spells.len() {
966                    Ok(crate::state::program::ScheduledSpellExecution::Registered(
967                        LocalSpellIdx::new(u8::try_from(index).map_err(|error| {
968                            EngineError::spec_construction(format!(
969                                "scheduled spell registry exceeds local-spell capacity: {error}"
970                            ))
971                        })?),
972                    ))
973                } else {
974                    Ok(damage.finalize_action_for_spell(spell.as_u32()).map_or(
975                        crate::state::program::ScheduledSpellExecution::TriggerOnlyEmpty,
976                        crate::state::program::ScheduledSpellExecution::TriggerOnly,
977                    ))
978                }
979            })
980            .collect::<Result<Box<[_]>, _>>()?;
981        let ground_effects =
982            crate::state::program::compile_ground_effect_plans(&self.ground_effects, &damage)
983                .map_err(|error| EngineError::spec_construction(error.to_string()))?;
984
985        let rotation_preparation = prepare_rotation(&RotationCompileInput {
986            rotation,
987            spell_ids: &self.spell_ids,
988            aura_ids: &self.aura_ids,
989            secondary_resource_name: self.config.base_stats.secondary_resource_name.as_deref(),
990            primary_resource_type: self.config.base_stats.resource_type,
991            secondary_resource_type: self.config.base_stats.secondary_resource_type,
992            hints: &self.hints,
993            gating: &self.gating,
994            cost_bypass: &self.cost_bypass,
995            cooldown_bypass: &self.cooldown_bypass,
996            overrides: &self.overrides,
997        })
998        .map_err(EngineError::rotation_compile)?;
999        let buffer_requirements = self.buffer_requirements();
1000        let prepared = bind_rotation(rotation_preparation, &buffer_requirements)
1001            .map_err(EngineError::rotation_compile)?;
1002
1003        let disabled_spell_ids = std::mem::take(&mut self.disabled_spell_ids);
1004        let initial_weapon_imbues = std::mem::take(&mut self.initial_weapon_imbues);
1005        let resource_max = self.resource_max;
1006        let talent_ranks = std::mem::take(&mut self.talent_ranks);
1007        let talent_spell_ids = std::mem::take(&mut self.talent_spell_ids);
1008        let selected_talent_spell_ids = std::mem::take(&mut self.selected_talent_spell_ids);
1009        let selected_replaced_spell_ids = std::mem::take(&mut self.selected_replaced_spell_ids);
1010        let buffer_bindings = BufferBindings::build(
1011            prepared.schema(),
1012            &buffer_requirements,
1013            &self.definitions,
1014            &talent_ranks,
1015            &initial_weapon_imbues,
1016        )
1017        .map_err(EngineError::spec_construction_source)?;
1018        let (absorb_capacity, damage_transfer_capacity) = buffer_bindings
1019            .aura_bindings()
1020            .map(|binding| {
1021                // BOUNDS: concrete aura bindings are built from this finalized definition slice.
1022                self.definitions.auras[binding.local.as_usize()]
1023                    .effects
1024                    .iter()
1025                    .flatten()
1026                    .fold((0, 0), |(absorbs, transfers), effect| match effect {
1027                        BuffEffect::Absorb(_) => (absorbs + 1, transfers),
1028                        BuffEffect::DamageTransfer(_) => (absorbs, transfers + 1),
1029                        _ => (absorbs, transfers),
1030                    })
1031            })
1032            .fold((0, 0), |(absorbs, transfers), counts| {
1033                (absorbs + counts.0, transfers + counts.1)
1034            });
1035
1036        self.runtime.scratch.size_absorb_scratch(absorb_capacity);
1037        self.runtime
1038            .scratch
1039            .size_damage_transfer_scratch(damage_transfer_capacity);
1040        self.runtime.health.active_absorbs = crate::state::BoundedVec::new(absorb_capacity);
1041        let rotation_plan = crate::state::program::RotationPlan::compile(
1042            &prepared,
1043            crate::state::program::RotationPlanInputs {
1044                spells: &self.definitions.spells,
1045                spell_by_id: &self.indexes.spell_by_id,
1046                auras: &self.definitions.auras,
1047                aura_by_id: &self.indexes.aura_by_id,
1048                bindings: &buffer_bindings,
1049                passive_driver_effects: &self.definitions.passive_driver_effects,
1050                primary_resource_type: self.config.base_stats.resource_type,
1051                secondary_resource_type: self.config.base_stats.secondary_resource_type,
1052                enemy_count: self.definitions.enemies.len(),
1053                item_use_spells: &self.definitions.item_use_spells,
1054            },
1055            &self.config.game_data,
1056        )
1057        .map_err(EngineError::spec_construction_source)?;
1058
1059        self.runtime
1060            .scratch
1061            .size_rotation_masks(rotation_plan.availability().len());
1062
1063        effects.bind_aura_slots(
1064            &buffer_bindings,
1065            &self.definitions.auras,
1066            &self.config.game_data,
1067            self.definitions.enemies.len(),
1068        );
1069        self.runtime.scratch.health_threshold_triggers =
1070            Vec::with_capacity(effects.health_threshold_capacity());
1071        effects.bind_spell_modifier_actions(
1072            &damage,
1073            &self.definitions.auras,
1074            &self.definitions.passive_driver_effects,
1075        );
1076
1077        if !effects.validate(
1078            self.definitions.auras.len(),
1079            self.definitions.passive_driver_effects.len(),
1080        ) {
1081            return Err(EngineError::spec_construction(
1082                "finalized effect plan contains an out-of-range identity".to_string(),
1083            ));
1084        }
1085
1086        let aura_dependencies = crate::state::program::AuraDependencyPlan::bind(
1087            aura_dependency_graph,
1088            &self.definitions.auras,
1089            prepared.schema(),
1090            &buffer_bindings,
1091            self.definitions.enemies.len(),
1092        );
1093
1094        if !aura_dependencies.validate(self.definitions.auras.len(), self.definitions.spells.len())
1095        {
1096            return Err(EngineError::spec_construction(
1097                "finalized aura dependency plan contains an out-of-range identity".to_string(),
1098            ));
1099        }
1100
1101        let engine = emit_rotation(prepared).map_err(EngineError::rotation_compile)?;
1102        let layout_fingerprint = engine.schema().layout_fingerprint();
1103        let buffer = engine.schema().new_bound_buffer();
1104
1105        let (program, mut runtime) = self.into_program_and_runtime(
1106            FrozenProgramBindings {
1107                scheduled_spells,
1108                runtime_events: wowlab_engine_event_authority::RuntimeEventCatalog::new(),
1109                scheduled_spell_executions,
1110                layout_fingerprint,
1111                buffer_bindings,
1112            },
1113            effects,
1114            (damage, ground_effects),
1115            aura_dependencies,
1116            rotation_plan,
1117        );
1118
1119        runtime.companions.pet_actions = vec![None; program.auto_attacks.len()];
1120        runtime.pools.resize_finalized(
1121            program.buffer_bindings.aura_binding_count(),
1122            program.spells.len(),
1123        );
1124        let mut instance = CombatInstance::new(&program, runtime, buffer)?;
1125
1126        validate_layout_fingerprints(&program, &instance)?;
1127
1128        populate_initial_buffer(
1129            &program,
1130            &mut instance,
1131            &InitialBufferConfig {
1132                resource_max,
1133                talent_ranks: &talent_ranks,
1134                talent_spell_ids: &talent_spell_ids,
1135                selected_talent_spell_ids: &selected_talent_spell_ids,
1136                selected_replaced_spell_ids: &selected_replaced_spell_ids,
1137            },
1138        );
1139
1140        #[cfg(feature = "rotation-optimizations")]
1141        crate::systems::initialize_cooldown_projections(&program, &mut instance);
1142
1143        for spell_id in disabled_spell_ids {
1144            if let Some(local) = program.spell_local(spell_id) {
1145                let slot = instance
1146                    .buffer
1147                    .spell_mut_at(program.buffer_bindings.spell(local));
1148
1149                slot.is_enabled = 0;
1150            }
1151        }
1152
1153        for (key, active) in initial_weapon_imbues {
1154            if let Some(slot) = program.buffer_weapon_imbue_mut(&mut instance, key) {
1155                slot.is_active = i32::from(active);
1156                slot.is_inactive = i32::from(!active);
1157            }
1158        }
1159
1160        Ok(BuiltCombatSystem {
1161            program,
1162            instance,
1163            rotation: engine,
1164        })
1165    }
1166
1167    pub(crate) fn size_scratch_buffers(&mut self) {
1168        self.runtime.scratch = ScratchBuffers {
1169            absorb_indices: crate::state::BoundedVec::default(),
1170            active_damage_transfers: Vec::new(),
1171            hooks: Vec::with_capacity(self.definitions.player_cast_hooks.len()),
1172            impacts: Vec::with_capacity(self.definitions.impact_procs.len()),
1173            landed_impacts: Vec::with_capacity(self.definitions.landed_impact_procs.len()),
1174            landed_impact_events: Vec::with_capacity(u8::MAX as usize + 1),
1175            accumulating_impacts: Vec::with_capacity(
1176                self.definitions.accumulating_impact_procs.len(),
1177            ),
1178            impact_effects: Vec::with_capacity(self.definitions.impact_effect_procs.len()),
1179            resource_gains: Vec::with_capacity(self.definitions.resource_gain_procs.len()),
1180            rotation_masks: Vec::new(),
1181            locked_action_masks: Vec::new(),
1182            rotation_candidates: Vec::new(),
1183            rotation_trace_reasons: None,
1184            effect_program_frames: Vec::new(),
1185            effect_program_frame_count: 0,
1186            target_workspaces: Vec::new(),
1187            health_threshold_triggers: Vec::new(),
1188        };
1189        self.runtime.scratch.size_execution_scratch(
1190            self.definitions.spell_effects.len(),
1191            self.definitions.spells.len(),
1192            self.definitions.enemies.len(),
1193        );
1194
1195        for (capacity, required) in [
1196            (
1197                self.runtime.scratch.hooks.capacity(),
1198                self.definitions.player_cast_hooks.len(),
1199            ),
1200            (
1201                self.runtime.scratch.impacts.capacity(),
1202                self.definitions.impact_procs.len(),
1203            ),
1204            (
1205                self.runtime.scratch.landed_impacts.capacity(),
1206                self.definitions.landed_impact_procs.len(),
1207            ),
1208            (
1209                self.runtime.scratch.accumulating_impacts.capacity(),
1210                self.definitions.accumulating_impact_procs.len(),
1211            ),
1212            (
1213                self.runtime.scratch.impact_effects.capacity(),
1214                self.definitions.impact_effect_procs.len(),
1215            ),
1216            (
1217                self.runtime.scratch.resource_gains.capacity(),
1218                self.definitions.resource_gain_procs.len(),
1219            ),
1220        ] {
1221            debug_assert!(
1222                capacity >= required,
1223                "scratch capacity must cover its finalized plan"
1224            );
1225        }
1226    }
1227
1228    // #t(fn: rust_cyclomatic_complexity) cold reachability exhaustively visits each distinct finalized definition family.
1229    fn reachable_damage_spell_ids(&self) -> Vec<u32> {
1230        let mut spell_ids = self.spell_ids.values().copied().collect::<Vec<_>>();
1231
1232        spell_ids.extend_from_slice(&self.damage_spell_ids);
1233        spell_ids.extend_from_slice(&self.effect_spell_ids);
1234
1235        for spell in &self.definitions.spells {
1236            spell_ids.extend([
1237                spell.spell_id,
1238                spell.damage_effect.spell_id,
1239                spell.channel.tick_spell_id,
1240                spell.channel.tick_damage_alt_spell_id,
1241            ]);
1242        }
1243
1244        for attack in &self.definitions.auto_attacks {
1245            spell_ids.push(attack.spell_id);
1246        }
1247
1248        for effect in &self.definitions.spell_effects {
1249            match effect {
1250                SpellEffectData::Damage { effect, .. } => spell_ids.push(effect.spell_id),
1251                SpellEffectData::Heal { effect, .. } | SpellEffectData::Energize { effect, .. } => {
1252                    if let Some(effect) = effect {
1253                        spell_ids.push(effect.spell_id);
1254                    }
1255                }
1256                SpellEffectData::Delayed {
1257                    profile_spell_id, ..
1258                } => spell_ids.push(*profile_spell_id),
1259                SpellEffectData::Conditional { .. }
1260                | SpellEffectData::ApplyAura { .. }
1261                | SpellEffectData::AddAuraStack { .. }
1262                | SpellEffectData::RemoveAura { .. }
1263                | SpellEffectData::InterruptCast { .. }
1264                | SpellEffectData::Dispel { .. }
1265                | SpellEffectData::EnergizePercent { .. }
1266                | SpellEffectData::ExtendAura { .. }
1267                | SpellEffectData::MutateCooldown { .. } => {}
1268            }
1269        }
1270
1271        for aura in &self.definitions.auras {
1272            spell_ids.extend([
1273                aura.aura_id,
1274                aura.apply_profile_spell_id,
1275                aura.tick_profile_spell_id,
1276                aura.expire_trigger_spell_id,
1277            ]);
1278
1279            if let Some(periodic) = aura.periodic {
1280                spell_ids.push(periodic.damage_spell_id_or(aura.aura_id));
1281
1282                if let Some(effect) = periodic.effect_ref {
1283                    spell_ids.push(effect.spell_id);
1284                }
1285            }
1286
1287            for effect in aura.effects.iter().flatten() {
1288                if let Some((spell_id, _)) = effect.dbc_source() {
1289                    spell_ids.push(spell_id);
1290                }
1291
1292                if let BuffEffect::DamageReplication(replication) = effect {
1293                    spell_ids.push(replication.effect.spell_id);
1294                    spell_ids.extend_from_slice(replication.source_spells);
1295                }
1296            }
1297        }
1298
1299        for modifier in &self.definitions.driver_spell_modifiers {
1300            spell_ids.push(modifier.driver_spell_id);
1301            spell_ids.extend_from_slice(modifier.spells);
1302        }
1303
1304        for driver in &self.definitions.passive_driver_effects {
1305            spell_ids.push(driver.driver_spell_id);
1306
1307            if let Some((spell_id, _)) = driver.effect.dbc_source() {
1308                spell_ids.push(spell_id);
1309            }
1310
1311            if let BuffEffect::DamageReplication(replication) = driver.effect {
1312                spell_ids.push(replication.effect.spell_id);
1313                spell_ids.extend_from_slice(replication.source_spells);
1314            }
1315        }
1316
1317        for driver in &self.definitions.periodic_drivers {
1318            spell_ids.push(driver.driver_spell_id);
1319        }
1320
1321        for proc in &self.definitions.impact_effect_procs {
1322            spell_ids.extend([proc.driver_spell_id, proc.profile_spell_id]);
1323
1324            if let crate::state::ImpactSource::Spell(spell_id) = proc.source {
1325                spell_ids.push(spell_id);
1326            }
1327        }
1328
1329        spell_ids.retain(|spell_id| *spell_id != 0);
1330        spell_ids.sort_unstable();
1331        spell_ids.dedup();
1332
1333        spell_ids
1334    }
1335
1336    // #t(fn: rust_cyclomatic_complexity) construction exhaustively binds each typed runtime identity and supported actor-source shape before freeze.
1337    fn buffer_requirements(&self) -> BufferRequirements {
1338        let mut requirements = BufferRequirements::new();
1339
1340        for spell in &self.definitions.spells {
1341            let spell = SpellIdx::from_raw(spell.spell_id);
1342
1343            requirements.spell(spell);
1344            requirements.cooldown(spell);
1345            requirements.history(spell);
1346        }
1347
1348        if let Some(resource) = self.config.base_stats.resource_type {
1349            requirements.resource(resource);
1350        }
1351
1352        if let Some(resource) = self.config.base_stats.secondary_resource_type {
1353            requirements.resource(resource);
1354        }
1355
1356        let pets = PetIdx::iter(PetIdx::MAX)
1357            .map(ActorId::Pet)
1358            .collect::<Vec<_>>();
1359        let base_actors = [ActorId::Player, ActorId::External];
1360        let mut actors =
1361            Vec::with_capacity(base_actors.len() + pets.len() + self.definitions.enemies.len());
1362
1363        actors.extend(base_actors);
1364
1365        actors.extend(pets.iter().copied());
1366
1367        actors.extend(
1368            self.definitions
1369                .enemies
1370                .iter()
1371                .map(|enemy| ActorId::Enemy(enemy.id())),
1372        );
1373
1374        for aura in &self.definitions.auras {
1375            let aura_idx = AuraIdx(aura.aura_id);
1376
1377            match aura.on {
1378                AuraOn::Player => {
1379                    for &source in &actors {
1380                        requirements.aura(AuraKey::new(
1381                            aura_idx,
1382                            source,
1383                            ActorId::Player,
1384                            AuraOn::Player,
1385                        ));
1386                    }
1387                }
1388                AuraOn::Target => {
1389                    for enemy in &self.definitions.enemies {
1390                        for &source in &actors {
1391                            requirements.aura(AuraKey::new(
1392                                aura_idx,
1393                                source,
1394                                ActorId::Enemy(enemy.id()),
1395                                AuraOn::Target,
1396                            ));
1397                        }
1398                    }
1399                }
1400                AuraOn::Pet => {
1401                    requirements.aura(AuraKey::new(
1402                        aura_idx,
1403                        ActorId::Player,
1404                        ActorId::Pet(PetIdx::PRIMARY),
1405                        AuraOn::Pet,
1406                    ));
1407
1408                    for &pet in &pets {
1409                        requirements.aura(AuraKey::new(aura_idx, pet, pet, AuraOn::Pet));
1410                    }
1411                }
1412            }
1413
1414            // Actor-directed effects can override the aura's ordinary affected actor while
1415            // retaining its projection family. Pre-bind the supported combat-source shapes.
1416
1417            for &affected in &actors {
1418                requirements.aura(AuraKey::new(aura_idx, ActorId::Player, affected, aura.on));
1419            }
1420
1421            for &affected in &actors {
1422                requirements.aura(AuraKey::new(aura_idx, ActorId::External, affected, aura.on));
1423            }
1424
1425            for &pet in &pets {
1426                requirements.aura(AuraKey::new(aura_idx, pet, pet, aura.on));
1427                requirements.aura(AuraKey::new(aura_idx, pet, ActorId::Player, aura.on));
1428
1429                for enemy in &self.definitions.enemies {
1430                    requirements.aura(AuraKey::new(
1431                        aura_idx,
1432                        pet,
1433                        ActorId::Enemy(enemy.id()),
1434                        aura.on,
1435                    ));
1436                }
1437            }
1438
1439            for enemy in &self.definitions.enemies {
1440                let source = ActorId::Enemy(enemy.id());
1441
1442                for &affected in &actors {
1443                    requirements.aura(AuraKey::new(aura_idx, source, affected, aura.on));
1444                }
1445            }
1446
1447            // Spellsteal transfers the concrete value into the player's projection family.
1448
1449            requirements.aura(AuraKey::new(
1450                aura_idx,
1451                ActorId::Player,
1452                ActorId::Player,
1453                AuraOn::Player,
1454            ));
1455
1456            if aura.propagates_to_pet {
1457                for &source in &actors {
1458                    requirements.aura(AuraKey::new(
1459                        aura_idx,
1460                        source,
1461                        ActorId::Pet(PetIdx::PRIMARY),
1462                        AuraOn::Pet,
1463                    ));
1464                }
1465            }
1466        }
1467
1468        for (name, _) in &self.talent_ranks {
1469            requirements.talent(name.as_str());
1470        }
1471
1472        for (index, _) in self.definitions.auto_attacks.iter().enumerate() {
1473            requirements.swing(crate::keys::auto_attack_key(index));
1474        }
1475
1476        for &(slot, _) in &self.definitions.item_use_spells {
1477            requirements.item(slot.slug());
1478        }
1479
1480        for &(key, _) in &self.initial_weapon_imbues {
1481            requirements.weapon_imbue(key);
1482        }
1483
1484        requirements.unit(crate::keys::UnitKey::TARGET);
1485
1486        requirements
1487    }
1488
1489    // #t(fn: rust_alloc_in_loop) duplicate and unresolved construction diagnostics include the concrete conflicting identity and local indexes.
1490    fn validate_identities(&self) -> Result<(), EngineError> {
1491        let mut spell_ids = IntMap::<u32, LocalSpellIdx>::default();
1492
1493        for (raw, spell) in self.definitions.spells.iter().enumerate() {
1494            let local = LocalSpellIdx::new(u8::try_from(raw).map_err(|error| {
1495                EngineError::spec_construction(format!("spell index {raw} exceeds u8: {error}"))
1496            })?);
1497
1498            if let Some(previous) = spell_ids.insert(spell.spell_id, local) {
1499                return Err(EngineError::spec_construction(format!(
1500                    "duplicate spell identity {} at local indexes {} and {}",
1501                    spell.spell_id,
1502                    previous.raw(),
1503                    local.raw()
1504                )));
1505            }
1506        }
1507
1508        let mut aura_ids = FastMap::<(u32, AuraOn), LocalAuraIdx>::default();
1509
1510        for (raw, aura) in self.definitions.auras.iter().enumerate() {
1511            let local = LocalAuraIdx::new(u8::try_from(raw).map_err(|error| {
1512                EngineError::spec_construction(format!("aura index {raw} exceeds u8: {error}"))
1513            })?);
1514
1515            if let Some(previous) = aura_ids.insert((aura.aura_id, aura.on), local) {
1516                return Err(EngineError::spec_construction(format!(
1517                    "duplicate aura identity ({}, {:?}) at local indexes {} and {}",
1518                    aura.aura_id,
1519                    aura.on,
1520                    previous.raw(),
1521                    local.raw()
1522                )));
1523            }
1524        }
1525
1526        for (&spell_id, &local) in &self.indexes.spell_by_id {
1527            if self
1528                .definitions
1529                .spells
1530                .get(local.as_usize())
1531                .is_none_or(|spell| spell.spell_id != spell_id)
1532            {
1533                return Err(EngineError::spec_construction(format!(
1534                    "unresolved spell index binding {spell_id} -> {}",
1535                    local.raw()
1536                )));
1537            }
1538        }
1539
1540        Ok(())
1541    }
1542
1543    // #t(fn: rust_max_fn_lines) finalization freezes every related runtime coordinate in one ordered construction transaction.
1544    // #t(fn: rust_fallible_in_iterator) finalized registries guarantee the coordinates consumed by these cold construction iterators.
1545    fn into_program_and_runtime(
1546        self,
1547        bindings: FrozenProgramBindings,
1548        effects: crate::state::program::EffectPlan,
1549        damage_plans: (
1550            crate::state::program::DamagePlan,
1551            Box<[crate::state::program::GroundEffectPlan]>,
1552        ),
1553        aura_dependencies: crate::state::program::AuraDependencyPlan,
1554        rotation: crate::state::program::RotationPlan,
1555    ) -> (CombatProgram, CombatRuntime) {
1556        let (damage, ground_effects) = damage_plans;
1557        let spec_identity_brand = self.spec_identity_brand;
1558        let FrozenProgramBindings {
1559            scheduled_spells,
1560            runtime_events,
1561            scheduled_spell_executions,
1562            layout_fingerprint,
1563            buffer_bindings,
1564        } = bindings;
1565
1566        let DraftDefinitions {
1567            enemies,
1568            spells,
1569            empower_cast_times_ms,
1570            spell_effects,
1571            auras,
1572            aura_bindings,
1573            spell_bindings,
1574            auto_attacks,
1575            cast_hooks,
1576            empower_release_hooks,
1577            tick_hooks,
1578            player_cast_hooks,
1579            resource_threshold_triggers,
1580            impact_procs,
1581            landed_impact_procs,
1582            accumulating_impact_procs,
1583            impact_effect_procs,
1584            driver_spell_modifiers,
1585            passive_driver_effects,
1586            periodic_drivers,
1587            talent_ranks,
1588            resource_gain_procs,
1589            item_use_spells,
1590            rppm_trackers,
1591            rppm_bindings,
1592            system_rppm_bindings,
1593            threshold_trackers,
1594            weapon_enchant_bindings,
1595            weapon_imbue_bindings,
1596            precombat_auras,
1597            talent_aura_stacks,
1598            stealth_aura_id,
1599        } = self.definitions;
1600        let DraftIndexes {
1601            spell_by_id,
1602            aura_by_id,
1603            cooldown_categories,
1604            charge_categories,
1605            item_rppm_indices,
1606            system_rppm_indices,
1607            enchant_rppm_indices,
1608            enchant_buff_spell_ids,
1609            ..
1610        } = self.indexes;
1611
1612        let mut runtime = self.runtime;
1613
1614        runtime.procs.rppm_trackers = rppm_trackers;
1615        runtime.procs.threshold_trackers = threshold_trackers;
1616        runtime.procs.accumulating_impact_accumulators = accumulating_impact_procs
1617            .iter()
1618            .map(|proc| proc.accumulator)
1619            .collect();
1620        runtime.procs.resource_gain_attempts = resource_gain_procs
1621            .iter()
1622            .map(|proc| proc.attempts)
1623            .collect();
1624        runtime.procs.impact_effect_ready_at = vec![0.0; impact_effect_procs.len()];
1625        runtime
1626            .procs
1627            .resize_target_icds_for_known_targets(impact_effect_procs.len());
1628        runtime.procs.impact_effect_charges = impact_effect_procs
1629            .iter()
1630            .map(|proc| {
1631                if proc.uses_stacks_for_charges {
1632                    0
1633                } else {
1634                    proc.charges
1635                }
1636            })
1637            .collect();
1638        runtime.procs.impact_effect_rng = impact_effect_procs
1639            .iter()
1640            .map(crate::state::ImpactEffectRngState::from_proc)
1641            .collect();
1642
1643        let telemetry_targets = crate::state::program::finalize_target_metadata(&enemies);
1644        let talent_ranks = damage.dense_talent_ranks(&talent_ranks);
1645        let weapon_enchant_bindings = weapon_enchant_bindings
1646            .into_iter()
1647            .map(|binding| crate::state::ResolvedWeaponEnchantBinding {
1648                rppm: *enchant_rppm_indices
1649                    .get(&binding.enchantment_id)
1650                    .expect("registered weapon enchant has a finalized RPPM coordinate"),
1651                buff_aura: *aura_by_id
1652                    .get(&binding.buff_spell_id)
1653                    .expect("registered weapon enchant has a finalized buff-aura coordinate"),
1654            })
1655            .collect();
1656        let aura_bindings = {
1657            let len = aura_bindings
1658                .iter()
1659                .map(|(binding, _)| binding.as_usize() + 1)
1660                .max()
1661                .unwrap_or(0);
1662            let mut resolved = vec![None; len];
1663
1664            for (binding, aura_id) in aura_bindings {
1665                // BOUNDS: len is derived from the maximum binding in this exact input collection.
1666                resolved[binding.as_usize()] = aura_by_id.get(&aura_id).copied();
1667            }
1668
1669            resolved.into_boxed_slice()
1670        };
1671        let spell_bindings = {
1672            let len = spell_bindings
1673                .iter()
1674                .map(|(binding, _)| binding.as_usize() + 1)
1675                .max()
1676                .unwrap_or(0);
1677            let mut resolved = vec![None; len];
1678
1679            for (binding, spell_id) in spell_bindings {
1680                // BOUNDS: len is derived from the maximum binding in this exact input collection.
1681                resolved[binding.as_usize()] = spell_by_id.get(&spell_id).copied();
1682            }
1683
1684            resolved.into_boxed_slice()
1685        };
1686        let rppm_bindings = {
1687            let len = rppm_bindings
1688                .iter()
1689                .map(|(binding, _)| binding.as_usize() + 1)
1690                .max()
1691                .unwrap_or(0);
1692            let mut resolved = vec![None; len];
1693
1694            for (binding, local) in rppm_bindings {
1695                // BOUNDS: len is derived from the maximum binding in this exact input collection.
1696                resolved[binding.as_usize()] = Some(local);
1697            }
1698
1699            resolved.into_boxed_slice()
1700        };
1701        let system_rppm_bindings = {
1702            let len = system_rppm_bindings
1703                .iter()
1704                .map(|(binding, _)| binding.as_usize() + 1)
1705                .max()
1706                .unwrap_or(0);
1707            let mut resolved = vec![None; len];
1708
1709            for (binding, value) in system_rppm_bindings {
1710                // BOUNDS: len is derived from the maximum binding in this exact input collection.
1711                resolved[binding.as_usize()] = Some(value);
1712            }
1713
1714            resolved.into_boxed_slice()
1715        };
1716        let weapon_imbue_bindings = weapon_imbue_bindings
1717            .into_iter()
1718            .map(|binding| crate::state::ResolvedWeaponImbueBinding {
1719                spell_local: *spell_by_id
1720                    .get(&binding.spell_id)
1721                    .expect("registered weapon imbue has a finalized spell coordinate"),
1722                slot: buffer_bindings
1723                    .weapon_imbue(binding.key)
1724                    .expect("registered weapon imbue has a finalized buffer coordinate"),
1725            })
1726            .collect::<Box<[_]>>();
1727        let mut weapon_imbue_by_spell = vec![None; spells.len()];
1728
1729        for binding in &weapon_imbue_bindings {
1730            // BOUNDS: spell_local is compiled from this exact finalized spell table.
1731            weapon_imbue_by_spell[binding.spell_local.as_usize()] = Some(binding.slot);
1732        }
1733
1734        let weapon_imbue_by_spell = weapon_imbue_by_spell.into_boxed_slice();
1735
1736        runtime.procs.system_rppm_ready_at = vec![SimTime::ZERO; system_rppm_bindings.len()];
1737        let aura_followups = auras
1738            .iter()
1739            .map(|aura| {
1740                (aura.application_followup_aura_id != 0)
1741                    .then(|| aura_by_id.get(&aura.application_followup_aura_id).copied())
1742                    .flatten()
1743            })
1744            .collect::<Vec<_>>()
1745            .into_boxed_slice();
1746        let aura_exclusions = auras
1747            .iter()
1748            .map(|aura| {
1749                std::array::from_fn(|index| {
1750                    // BOUNDS: from_fn supplies indices within application_excluded_auras.
1751                    let aura_id = aura.application_excluded_auras[index];
1752
1753                    (aura_id != 0)
1754                        .then(|| aura_by_id.get(&aura_id).copied())
1755                        .flatten()
1756                })
1757            })
1758            .collect::<Vec<_>>()
1759            .into_boxed_slice();
1760        let stealth_aura = stealth_aura_id.and_then(|aura_id| aura_by_id.get(&aura_id).copied());
1761        let external_buff_auras = self
1762            .config
1763            .external_buffs
1764            .iter()
1765            .map(|config| aura_by_id.get(&config.spell_id).copied())
1766            .collect::<Vec<_>>()
1767            .into_boxed_slice();
1768        let proc_category_recovery_ms = crate::generated_identities::proc_category::ALL
1769            .iter()
1770            .map(|(_, driver)| {
1771                self.config
1772                    .game_data
1773                    .proc_category_recovery_ms(SpellIdx::from_raw(*driver))
1774                    .unwrap_or(0)
1775            })
1776            .collect::<Vec<_>>()
1777            .into_boxed_slice();
1778
1779        runtime.procs.proc_category_ready_at_dense =
1780            vec![SimTime::ZERO; proc_category_recovery_ms.len()];
1781        let periodic_driver_bindings = crate::generated_identities::periodic_driver::ALL
1782            .iter()
1783            .map(|driver_spell_id| {
1784                periodic_drivers
1785                    .iter()
1786                    .position(|driver| driver.driver_spell_id == *driver_spell_id)
1787            })
1788            .collect::<Vec<_>>()
1789            .into_boxed_slice();
1790        let empty_category_members: &[LocalSpellIdx] = &[];
1791        let cooldown_category_members = spells
1792            .iter()
1793            .map(|spell| {
1794                spell
1795                    .cooldown
1796                    .cooldown_category
1797                    .map_or(empty_category_members, |category| {
1798                        cooldown_categories.members(&category)
1799                    })
1800                    .to_vec()
1801                    .into_boxed_slice()
1802            })
1803            .collect();
1804        let charge_category_members = spells
1805            .iter()
1806            .map(|spell| {
1807                spell
1808                    .cooldown
1809                    .charge_category
1810                    .map_or(empty_category_members, |category| {
1811                        charge_categories.members(&category)
1812                    })
1813                    .to_vec()
1814                    .into_boxed_slice()
1815            })
1816            .collect();
1817        let charge_category_owners = spells
1818            .iter()
1819            .enumerate()
1820            .map(|(index, spell)| {
1821                spell
1822                    .cooldown
1823                    .charge_category
1824                    .and_then(|category| charge_categories.owner(&category).copied())
1825                    .unwrap_or_else(|| {
1826                        LocalSpellIdx::new(
1827                            u8::try_from(index).expect("finalized spell count fits local identity"),
1828                        )
1829                    })
1830            })
1831            .collect();
1832        let start_recovery_category_owners = spells
1833            .iter()
1834            .enumerate()
1835            .map(|(index, spell)| {
1836                let owner = spell.cooldown.start_recovery_category.and_then(|category| {
1837                    spells.iter().position(|candidate| {
1838                        candidate.cooldown.start_recovery_category == Some(category)
1839                    })
1840                });
1841                let owner = owner.unwrap_or(index);
1842
1843                LocalSpellIdx::new(
1844                    u8::try_from(owner).expect("finalized spell count fits local identity"),
1845                )
1846            })
1847            .collect();
1848        let aura_expire_trigger_spells = auras
1849            .iter()
1850            .map(|aura| {
1851                (aura.expire_trigger_spell_id != 0)
1852                    .then(|| spell_by_id.get(&aura.expire_trigger_spell_id).copied())
1853                    .flatten()
1854            })
1855            .collect();
1856        let pet_action_plans: Box<[u16]> = auto_attacks
1857            .iter()
1858            .enumerate()
1859            .filter(|(_, attack)| attack.pet_action_bar.is_some())
1860            .map(|(index, _)| u16::try_from(index).expect("finalized auto-attack count fits u16"))
1861            .collect();
1862        let primary_pet_action_auto = pet_action_plans.first().copied();
1863        let pet_auto_attacks = auto_attacks
1864            .iter()
1865            .enumerate()
1866            .filter(|(_, attack)| attack.is_pet && attack.swing_ms > 0)
1867            .map(|(index, _)| u16::try_from(index).expect("finalized auto-attack count fits u16"))
1868            .collect();
1869        let mut pet_action_plan_by_auto = vec![None; auto_attacks.len()];
1870
1871        for (plan, auto) in pet_action_plans.iter().copied().enumerate() {
1872            // #t(rust_unchecked_indexing) auto comes from this exact finalized auto-attacks enumeration.
1873            pet_action_plan_by_auto[usize::from(auto)] =
1874                Some(u16::try_from(plan).expect("finalized pet plan count fits u16"));
1875        }
1876
1877        let program = CombatProgram {
1878            spec_identity_brand,
1879            enemies,
1880            telemetry_targets,
1881            spells,
1882            scheduled_spells,
1883            runtime_events,
1884            scheduled_spell_executions,
1885            empower_cast_times_ms,
1886            spell_effects,
1887            auras,
1888            aura_bindings,
1889            spell_bindings,
1890            rppm_bindings,
1891            system_rppm_bindings,
1892            aura_followups,
1893            aura_exclusions,
1894            aura_expire_trigger_spells,
1895            external_buff_auras,
1896            proc_category_recovery_ms,
1897            auto_attacks,
1898            pet_action_plans,
1899            pet_action_plan_by_auto: pet_action_plan_by_auto.into_boxed_slice(),
1900            primary_pet_action_auto,
1901            pet_auto_attacks,
1902            cast_hooks,
1903            empower_release_hooks,
1904            tick_hooks,
1905            player_cast_hooks,
1906            resource_threshold_triggers,
1907            impact_procs,
1908            landed_impact_procs,
1909            accumulating_impact_procs,
1910            impact_effect_procs,
1911            driver_spell_modifiers,
1912            passive_driver_effects,
1913            effects,
1914            damage,
1915            ground_effects,
1916            aura_dependencies,
1917            rotation,
1918            periodic_drivers,
1919            periodic_driver_bindings,
1920            talent_ranks,
1921            resource_gain_procs,
1922            item_use_spells,
1923            weapon_enchant_bindings,
1924            weapon_imbue_bindings,
1925            weapon_imbue_by_spell,
1926            precombat_auras,
1927            talent_aura_stacks,
1928            stealth_aura,
1929            spell_by_id,
1930            #[cfg(any(test, feature = "test-support"))]
1931            aura_by_id,
1932            cooldown_category_members,
1933            charge_category_members,
1934            charge_category_owners,
1935            start_recovery_category_owners,
1936            item_rppm_indices,
1937            system_rppm_indices,
1938            enchant_rppm_indices,
1939            enchant_buff_spell_ids,
1940            config: self.config,
1941            names: self.names,
1942            layout_fingerprint,
1943            buffer_bindings,
1944        };
1945
1946        (program, runtime)
1947    }
1948}
1949
1950fn validate_layout_fingerprints(
1951    program: &CombatProgram,
1952    instance: &CombatInstance,
1953) -> Result<(), EngineError> {
1954    if program.layout_fingerprint == instance.buffer.layout_fingerprint() {
1955        return Ok(());
1956    }
1957
1958    Err(EngineError::spec_construction(format!(
1959        "program/buffer layout fingerprint mismatch: program={}, buffer={}",
1960        program.layout_fingerprint.as_u64(),
1961        instance.buffer.layout_fingerprint().as_u64(),
1962    )))
1963}
1964
1965#[cfg(test)]
1966#[path = "draft_tests.rs"]
1967mod tests;