Skip to main content

wowlab_engine_combat/state/program/
effects.rs

1// #t(file: rust_duplicate_strings) finalization bound diagnostics deliberately share the same invariant wording across finalized plans.
2// #t(file: rust_similar_fns) actor-range indexing intentionally mirrors dependency plans while retaining effect-specific element types.
3// #t(file: rust_similar_structs) the effect plan and dependency plan keep separate typed flattened actor storage across ownership boundaries.
4// #t(file: rust_unchecked_indexing) construction indexes only dense arrays whose coordinates were produced from those exact finalized registries.
5
6//! Finalized buff-effect capabilities, positional masks, and relevant-effect lists.
7
8#[cfg(feature = "rotation-optimizations")]
9mod active_aura;
10mod bindings;
11mod families;
12
13use std::ops::Range;
14
15use wowlab_engine_domain::{
16    dbc::{
17        MechanicMask, ModifierPropertyKind, ResolvedGameDataSemanticExt as _,
18        SpellAuraInterruptFlags,
19    },
20    rotation::AuraSlotHandle,
21};
22use wowlab_engine_gamedata::ResolvedGameData;
23use wowlab_types::sim::{ActorId, AuraKey, PetIdx};
24
25use self::{
26    bindings::{ActionSpellModifierBindings, RelevantEffectList},
27    families::{family_indexes, single_family_index},
28};
29use super::BufferBindings;
30use crate::state::{
31    AuraData, BuffEffect, EffectConsumer, EffectFamily, LocalAuraIdx, MAX_AURA_BUFF_EFFECTS,
32    PassiveDriverEffect,
33};
34
35#[rustfmt::skip]
36#[cfg(feature = "rotation-optimizations")]
37pub(crate) use active_aura::{ActiveAuraBreakPlans, ActiveAuraEffectPlan};
38
39const _: () = assert!(
40    MAX_AURA_BUFF_EFFECTS <= u16::BITS as usize,
41    "aura effect positions must fit in each u16 consumer mask"
42);
43const AURA_ORDER_LOCAL_SHIFT: u32 = 48;
44const AURA_ORDER_BINDING_SHIFT: u32 = 8;
45
46/// Positional effect masks for one finalized aura's fixed effect array.
47#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
48pub(crate) struct AuraEffectMasks {
49    general_totals: u16,
50    dynamic_totals: u16,
51    spell_modifiers: u16,
52    costs: u16,
53    timing: u16,
54    targeting: u16,
55    gates: u16,
56    recharge_charges: u16,
57    stats: u16,
58    speed_projection: u16,
59    snapshot_totals: u16,
60    player_scaling: u16,
61}
62
63#[derive(Clone, Copy, Debug)]
64pub(crate) struct PlannedAuraEffects {
65    pub(crate) key: AuraKey,
66    pub(crate) handle: AuraSlotHandle,
67    pub(crate) local: LocalAuraIdx,
68    pub(crate) static_totals_mask: u16,
69    pub(crate) dynamic_totals_mask: u16,
70    pub(crate) dispel_type: i32,
71    pub(crate) dispel_resistance_flat: f64,
72    pub(crate) dispel_resistance_multiplier: f64,
73    pub(crate) mechanic_mask: MechanicMask,
74    pub(crate) granted_aura_state: Option<crate::state::AuraState>,
75}
76
77#[derive(Clone, Copy, Debug)]
78pub(crate) struct PlannedAuraEffect {
79    pub(crate) key: AuraKey,
80    pub(crate) handle: AuraSlotHandle,
81    pub(crate) local: LocalAuraIdx,
82    pub(crate) position: u8,
83    pub(crate) order: u64,
84    families: EffectFamily,
85}
86
87#[derive(Debug)]
88struct ActorRanges<T> {
89    entries: Box<[T]>,
90    player: Range<usize>,
91    external: Range<usize>,
92    pets: Box<[Range<usize>]>,
93    enemies: Box<[Range<usize>]>,
94}
95
96impl<T> ActorRanges<T> {
97    fn for_actor(&self, actor: ActorId) -> &[T] {
98        let range = match actor {
99            ActorId::Player => Some(self.player.clone()),
100            ActorId::External => Some(self.external.clone()),
101            ActorId::Pet(pet) => self.pets.get(pet.as_usize()).cloned(),
102            ActorId::Enemy(enemy) => self.enemies.get(enemy.as_usize()).cloned(),
103        };
104
105        range.map_or(&[], |range| {
106            // BOUNDS: every actor range is assembled from offsets into this exact flattened array.
107            &self.entries[range]
108        })
109    }
110
111    fn maximum_actor_len(&self) -> usize {
112        std::iter::once(&self.player)
113            .chain(std::iter::once(&self.external))
114            .chain(self.pets.iter())
115            .chain(self.enemies.iter())
116            .map(Range::len)
117            .max()
118            .unwrap_or(0)
119    }
120}
121
122impl<T> Default for ActorRanges<T> {
123    fn default() -> Self {
124        Self {
125            entries: Box::default(),
126            player: 0..0,
127            external: 0..0,
128            pets: Box::default(),
129            enemies: Box::default(),
130        }
131    }
132}
133
134type ActorEffectRanges = ActorRanges<PlannedAuraEffects>;
135type ActorExactEffectRanges = ActorRanges<PlannedAuraEffect>;
136
137#[derive(Debug, Default)]
138struct ActorEffectBindings {
139    all: ActorEffectRanges,
140    static_totals: ActorEffectRanges,
141    dynamic_totals: ActorEffectRanges,
142    by_family: Box<[ActorExactEffectRanges]>,
143    action_break: ActorEffectRanges,
144    periodic_damage_break: ActorEffectRanges,
145    direct_damage_break: ActorEffectRanges,
146    aura_state: ActorEffectRanges,
147    shapeshift: ActorEffectRanges,
148    dispellable: ActorEffectRanges,
149    mechanic: ActorEffectRanges,
150}
151
152impl AuraEffectMasks {
153    pub(crate) const fn totals_relevant(self) -> bool {
154        self.general_totals != 0
155    }
156
157    pub(crate) const fn snapshot_totals_relevant(self) -> bool {
158        self.snapshot_totals != 0
159    }
160
161    pub(crate) const fn player_scaling_relevant(self) -> bool {
162        self.player_scaling != 0
163    }
164
165    #[cfg(test)]
166    pub(super) const fn mask(self, class: EffectConsumerClass) -> u16 {
167        match class {
168            EffectConsumerClass::GeneralTotals => self.general_totals,
169            EffectConsumerClass::DynamicTotals => self.dynamic_totals,
170            EffectConsumerClass::SpellModifiers => self.spell_modifiers,
171            EffectConsumerClass::Costs => self.costs,
172            EffectConsumerClass::Timing => self.timing,
173            EffectConsumerClass::Targeting => self.targeting,
174            EffectConsumerClass::Gates => self.gates,
175            EffectConsumerClass::RechargeCharges => self.recharge_charges,
176            EffectConsumerClass::Stats => self.stats,
177            EffectConsumerClass::SpeedProjection => self.speed_projection,
178        }
179    }
180
181    fn insert(&mut self, position: usize, consumers: EffectConsumer) {
182        let bit = 1_u16 << position;
183
184        if consumers.contains(EffectConsumer::GENERAL_TOTALS) {
185            self.general_totals |= bit;
186        }
187
188        if consumers.contains(EffectConsumer::DYNAMIC_TOTALS) {
189            self.dynamic_totals |= bit;
190        }
191
192        if consumers.contains(EffectConsumer::SPELL_MODIFIERS) {
193            self.spell_modifiers |= bit;
194        }
195
196        if consumers.contains(EffectConsumer::COSTS) {
197            self.costs |= bit;
198        }
199
200        if consumers.contains(EffectConsumer::TIMING) {
201            self.timing |= bit;
202        }
203
204        if consumers.contains(EffectConsumer::TARGETING) {
205            self.targeting |= bit;
206        }
207
208        if consumers.contains(EffectConsumer::GATES) {
209            self.gates |= bit;
210        }
211
212        if consumers.contains(EffectConsumer::RECHARGE_CHARGES) {
213            self.recharge_charges |= bit;
214        }
215
216        if consumers.contains(EffectConsumer::STATS) {
217            self.stats |= bit;
218        }
219
220        if consumers.contains(EffectConsumer::SPEED_PROJECTION) {
221            self.speed_projection |= bit;
222        }
223
224        if consumers.contains(EffectConsumer::SNAPSHOT_TOTALS) {
225            self.snapshot_totals |= bit;
226        }
227    }
228
229    fn insert_player_scaling(&mut self, position: usize, effect: &BuffEffect) {
230        if matches!(
231            effect,
232            BuffEffect::Haste(..)
233                | BuffEffect::HasteMult(..)
234                | BuffEffect::HasteMultFromEffect { .. }
235                | BuffEffect::SpellCastSpeed(..)
236                | BuffEffect::AttackSpeed(..)
237                | BuffEffect::Crit(..)
238                | BuffEffect::CritFromEffect { .. }
239                | BuffEffect::CritActive(..)
240        ) {
241            self.player_scaling |= 1_u16 << position;
242        }
243    }
244
245    const fn mask_for_build(self, class: EffectConsumerClass) -> u16 {
246        match class {
247            EffectConsumerClass::GeneralTotals => self.general_totals,
248            EffectConsumerClass::DynamicTotals => self.dynamic_totals,
249            EffectConsumerClass::SpellModifiers => self.spell_modifiers,
250            EffectConsumerClass::Costs => self.costs,
251            EffectConsumerClass::Timing => self.timing,
252            EffectConsumerClass::Targeting => self.targeting,
253            EffectConsumerClass::Gates => self.gates,
254            EffectConsumerClass::RechargeCharges => self.recharge_charges,
255            EffectConsumerClass::Stats => self.stats,
256            EffectConsumerClass::SpeedProjection => self.speed_projection,
257        }
258    }
259}
260
261const EFFECT_CONSUMER_CLASS_COUNT: usize = 10;
262const EFFECT_FAMILY_COUNT: usize = 85;
263const MODIFIER_PROPERTY_SLOT_COUNT: usize = ModifierPropertyKind::MaxTargets as usize + 1;
264const FIXED_ACTOR_BUCKET_COUNT: usize = 2;
265const PLAYER_BUCKET: usize = 0;
266const EXTERNAL_BUCKET: usize = 1;
267const PET_BUCKETS_START: usize = FIXED_ACTOR_BUCKET_COUNT;
268
269#[derive(Clone, Copy, Debug, Eq, PartialEq)]
270#[repr(u8)]
271pub(crate) enum EffectConsumerClass {
272    GeneralTotals,
273    DynamicTotals,
274    SpellModifiers,
275    Costs,
276    Timing,
277    Targeting,
278    Gates,
279    RechargeCharges,
280    Stats,
281    SpeedProjection,
282}
283
284impl EffectConsumerClass {
285    const ALL: [Self; EFFECT_CONSUMER_CLASS_COUNT] = [
286        Self::GeneralTotals,
287        Self::DynamicTotals,
288        Self::SpellModifiers,
289        Self::Costs,
290        Self::Timing,
291        Self::Targeting,
292        Self::Gates,
293        Self::RechargeCharges,
294        Self::Stats,
295        Self::SpeedProjection,
296    ];
297
298    const fn consumer(self) -> EffectConsumer {
299        match self {
300            Self::GeneralTotals => EffectConsumer::GENERAL_TOTALS,
301            Self::DynamicTotals => EffectConsumer::DYNAMIC_TOTALS,
302            Self::SpellModifiers => EffectConsumer::SPELL_MODIFIERS,
303            Self::Costs => EffectConsumer::COSTS,
304            Self::Timing => EffectConsumer::TIMING,
305            Self::Targeting => EffectConsumer::TARGETING,
306            Self::Gates => EffectConsumer::GATES,
307            Self::RechargeCharges => EffectConsumer::RECHARGE_CHARGES,
308            Self::Stats => EffectConsumer::STATS,
309            Self::SpeedProjection => EffectConsumer::SPEED_PROJECTION,
310        }
311    }
312
313    const fn index(self) -> usize {
314        self as usize
315    }
316}
317
318#[derive(Debug)]
319pub(crate) struct EffectPlan {
320    global_capabilities: EffectFamily,
321    aura_masks: Box<[AuraEffectMasks]>,
322    #[cfg(feature = "rotation-optimizations")]
323    static_total_auras: ActiveAuraEffectPlan,
324    #[cfg(feature = "rotation-optimizations")]
325    dynamic_total_auras: ActiveAuraEffectPlan,
326    #[cfg(feature = "rotation-optimizations")]
327    break_auras: ActiveAuraBreakPlans,
328    #[cfg(feature = "rotation-optimizations")]
329    family_auras: Box<[ActiveAuraEffectPlan]>,
330    relevant: [RelevantEffectList; EFFECT_CONSUMER_CLASS_COUNT],
331    passive_by_family: Box<[Box<[usize]>]>,
332    owner_duplicates: Box<[Box<[AuraSlotHandle]>]>,
333    actor_bindings: ActorEffectBindings,
334    action_spell_modifiers: ActionSpellModifierBindings,
335}
336
337impl EffectPlan {
338    // #t(fn: rust_padding) rustfmt keeps the first inherent method adjacent to the impl opening.
339    pub(crate) fn compile(auras: &[AuraData], passive_drivers: &[PassiveDriverEffect]) -> Self {
340        let mut global_capabilities = EffectFamily::empty();
341        let mut aura_masks = Vec::with_capacity(auras.len());
342        #[cfg(feature = "rotation-optimizations")]
343        let mut family_effect_masks = vec![vec![0_u16; auras.len()]; EFFECT_FAMILY_COUNT];
344        let mut relevant_auras: [Vec<LocalAuraIdx>; EFFECT_CONSUMER_CLASS_COUNT] =
345            std::array::from_fn(|_| Vec::new());
346        let mut relevant_passives: [Vec<usize>; EFFECT_CONSUMER_CLASS_COUNT] =
347            std::array::from_fn(|_| Vec::new());
348        let mut passive_by_family = vec![Vec::new(); EFFECT_FAMILY_COUNT];
349
350        for (raw_local, aura) in auras.iter().enumerate() {
351            let local = LocalAuraIdx::new(
352                u8::try_from(raw_local).expect("finalized aura count is validated below 256"),
353            );
354            let mut masks = AuraEffectMasks::default();
355
356            for (position, effect) in aura.effects.iter().enumerate() {
357                let Some(effect) = effect else {
358                    continue;
359                };
360                let classification = effect.classification();
361
362                global_capabilities |= classification.families();
363                masks.insert(position, classification.consumers());
364                masks.insert_player_scaling(position, effect);
365
366                #[cfg(feature = "rotation-optimizations")]
367                for family_index in family_indexes(classification.families()) {
368                    family_effect_masks[family_index][raw_local] |= 1_u16 << position;
369                }
370            }
371
372            for class in EffectConsumerClass::ALL {
373                if masks.mask_for_build(class) != 0 {
374                    // BOUNDS: EffectConsumerClass discriminants index the same fixed-count array.
375                    relevant_auras[class.index()].push(local);
376                }
377            }
378
379            aura_masks.push(masks);
380        }
381
382        for (index, driver) in passive_drivers.iter().enumerate() {
383            let classification = driver.effect.classification();
384
385            global_capabilities |= classification.families();
386
387            for class in EffectConsumerClass::ALL {
388                if classification.consumers().contains(class.consumer()) {
389                    // BOUNDS: EffectConsumerClass discriminants index the same fixed-count array.
390                    relevant_passives[class.index()].push(index);
391                }
392            }
393
394            for family_index in family_indexes(classification.families()) {
395                // BOUNDS: family_indexes yields only finalized EffectFamily bit positions.
396                passive_by_family[family_index].push(index);
397            }
398        }
399
400        let relevant = std::array::from_fn(|index| {
401            // BOUNDS: from_fn supplies indexes within both same-length fixed-count arrays.
402            let aura_locals = std::mem::take(&mut relevant_auras[index]).into_boxed_slice();
403            let passive_driver_indexes = {
404                // BOUNDS: from_fn supplies indexes within both same-length fixed-count arrays.
405                std::mem::take(&mut relevant_passives[index]).into_boxed_slice()
406            };
407
408            RelevantEffectList {
409                aura_locals,
410                passive_driver_indexes,
411            }
412        });
413
414        #[cfg(feature = "rotation-optimizations")]
415        let static_total_auras = ActiveAuraEffectPlan::from_masks(
416            aura_masks
417                .iter()
418                .map(|masks| masks.general_totals & !masks.dynamic_totals)
419                .collect(),
420        );
421        #[cfg(feature = "rotation-optimizations")]
422        let dynamic_total_auras = ActiveAuraEffectPlan::from_masks(
423            aura_masks
424                .iter()
425                .map(|masks| masks.dynamic_totals)
426                .collect(),
427        );
428        #[cfg(feature = "rotation-optimizations")]
429        let break_auras = ActiveAuraBreakPlans::compile(auras);
430
431        Self {
432            global_capabilities,
433            aura_masks: aura_masks.into_boxed_slice(),
434            #[cfg(feature = "rotation-optimizations")]
435            static_total_auras,
436            #[cfg(feature = "rotation-optimizations")]
437            dynamic_total_auras,
438            #[cfg(feature = "rotation-optimizations")]
439            break_auras,
440            #[cfg(feature = "rotation-optimizations")]
441            family_auras: family_effect_masks
442                .into_iter()
443                .map(ActiveAuraEffectPlan::from_masks)
444                .collect(),
445            relevant,
446            passive_by_family: passive_by_family
447                .into_iter()
448                .map(Vec::into_boxed_slice)
449                .collect(),
450            owner_duplicates: (0..auras.len())
451                .map(|_| Vec::new().into_boxed_slice())
452                .collect(),
453            actor_bindings: ActorEffectBindings {
454                by_family: (0..EFFECT_FAMILY_COUNT)
455                    .map(|_| ActorExactEffectRanges::default())
456                    .collect(),
457                ..ActorEffectBindings::default()
458            },
459            action_spell_modifiers: ActionSpellModifierBindings::default(),
460        }
461    }
462
463    // #t(fn: rust_max_fn_lines) one cold transaction assembles all exact concrete aura/effect consumer slices from the same stable binding order.
464    pub(crate) fn bind_aura_slots(
465        &mut self,
466        bindings: &BufferBindings,
467        auras: &[AuraData],
468        data: &ResolvedGameData,
469        enemy_count: usize,
470    ) {
471        let bucket_count = FIXED_ACTOR_BUCKET_COUNT + PetIdx::MAX + enemy_count;
472        let mut buckets = vec![Vec::new(); bucket_count];
473        let mut family_buckets = vec![vec![Vec::new(); bucket_count]; EFFECT_FAMILY_COUNT];
474
475        for (raw_local, aura) in auras.iter().enumerate() {
476            let local = LocalAuraIdx::new(
477                u8::try_from(raw_local).expect("finalized aura count is validated below 256"),
478            );
479            let masks = self.aura(local);
480
481            for binding in bindings.aura_bindings() {
482                if binding.local != local {
483                    continue;
484                }
485
486                let key = binding.key;
487                let handle = binding.handle;
488                let spell = wowlab_types::sim::SpellIdx::from_raw(aura.aura_id);
489                let props = data.spell_props_for_damage_plan(spell);
490
491                let Some(bucket) = actor_bucket(key.affected(), enemy_count) else {
492                    continue;
493                };
494
495                // BOUNDS: actor_bucket validates every returned index against bucket_count.
496
497                buckets[bucket].push(PlannedAuraEffects {
498                    key,
499                    handle,
500                    local,
501                    static_totals_mask: masks.general_totals & !masks.dynamic_totals,
502                    dynamic_totals_mask: masks.dynamic_totals,
503                    dispel_type: props.dispel_type,
504                    dispel_resistance_flat: props.dispel_resistance_flat,
505                    dispel_resistance_multiplier: props.dispel_resistance_multiplier,
506                    mechanic_mask: data.all_effect_mechanic_mask(spell),
507                    granted_aura_state: aura.granted_aura_state,
508                });
509
510                for (position, effect) in aura.effects.iter().enumerate() {
511                    let Some(effect) = effect else {
512                        continue;
513                    };
514                    let effect_families = effect.classification().families();
515                    let planned = PlannedAuraEffect {
516                        key,
517                        handle,
518                        local,
519                        position: u8::try_from(position)
520                            .expect("fixed aura effect positions fit in u8"),
521                        order: (u64::from(local.raw()) << AURA_ORDER_LOCAL_SHIFT)
522                            | (u64::try_from(binding.index().as_usize())
523                                .expect("binding ordinal fits in u64")
524                                << AURA_ORDER_BINDING_SHIFT)
525                            | u64::try_from(position).expect("effect position fits in u64"),
526                        families: effect_families,
527                    };
528
529                    for family_index in family_indexes(effect_families) {
530                        // BOUNDS: family_indexes and actor_bucket both validate their dimensions.
531                        family_buckets[family_index][bucket].push(planned);
532                    }
533                }
534            }
535        }
536
537        let static_totals = buckets
538            .iter()
539            .map(|bucket| {
540                bucket
541                    .iter()
542                    .copied()
543                    .filter(|entry| entry.static_totals_mask != 0)
544                    .collect()
545            })
546            .collect();
547        let dynamic_totals = buckets
548            .iter()
549            .map(|bucket| {
550                bucket
551                    .iter()
552                    .copied()
553                    .filter(|entry| entry.dynamic_totals_mask != 0)
554                    .collect()
555            })
556            .collect();
557        let select = |predicate: fn(&PlannedAuraEffects, &AuraData) -> bool| {
558            buckets
559                .iter()
560                .map(|bucket| {
561                    bucket
562                        .iter()
563                        .copied()
564                        .filter(|entry| predicate(entry, finalized_aura(auras, entry.local)))
565                        .collect::<Vec<_>>()
566                })
567                .collect::<Vec<_>>()
568        };
569        let action_break = select(|_, aura| {
570            aura.interrupt_flags
571                .contains(SpellAuraInterruptFlags::ACTION)
572        });
573        let periodic_damage_break = select(|_, aura| {
574            aura.interrupt_flags
575                .contains(SpellAuraInterruptFlags::DAMAGE)
576        });
577        let direct_damage_break = select(|_, aura| {
578            aura.interrupt_flags.intersects(
579                SpellAuraInterruptFlags::DAMAGE | SpellAuraInterruptFlags::NON_PERIODIC_DAMAGE,
580            )
581        });
582        let mut aura_state = select(|_, aura| aura.granted_aura_state.is_some());
583
584        for bucket in &mut aura_state {
585            bucket.sort_unstable_by_key(|entry| {
586                entry.granted_aura_state.map_or(0, |state| state as i32)
587            });
588        }
589
590        let shapeshift = select(|_, aura| aura.shapeshift_form > 0);
591        let mut dispellable = select(|entry, _| entry.dispel_type != 0);
592
593        for bucket in &mut dispellable {
594            bucket.sort_unstable_by_key(|entry| entry.dispel_type);
595        }
596
597        let mechanic = select(|entry, _| !entry.mechanic_mask.is_empty());
598
599        self.owner_duplicates = (0..auras.len())
600            .map(|raw_local| {
601                let local = local_aura(raw_local);
602
603                bindings
604                    .aura_bindings()
605                    .filter(|binding| {
606                        binding.local == local && binding.key.affected() == ActorId::Player
607                    })
608                    .map(|binding| binding.handle)
609                    .collect::<Vec<_>>()
610                    .into_boxed_slice()
611            })
612            .collect();
613
614        self.actor_bindings = ActorEffectBindings {
615            all: flatten_actor_buckets(buckets),
616            static_totals: flatten_actor_buckets(static_totals),
617            dynamic_totals: flatten_actor_buckets(dynamic_totals),
618            by_family: family_buckets
619                .into_iter()
620                .map(flatten_actor_buckets)
621                .collect(),
622            action_break: flatten_actor_buckets(action_break),
623            periodic_damage_break: flatten_actor_buckets(periodic_damage_break),
624            direct_damage_break: flatten_actor_buckets(direct_damage_break),
625            aura_state: flatten_actor_buckets(aura_state),
626            shapeshift: flatten_actor_buckets(shapeshift),
627            dispellable: flatten_actor_buckets(dispellable),
628            mechanic: flatten_actor_buckets(mechanic),
629        };
630    }
631
632    /// Prebind each affected-spell property to entries that can modify that damage action.
633    // #t(fn: rust_cyclomatic_complexity) cold finalization transaction explicitly binds each runtime consumer category.
634    // #t(fn: rust_max_fn_lines) keeping the binding categories together preserves one auditable immutable-plan assembly boundary.
635    pub(crate) fn bind_spell_modifier_actions(
636        &mut self,
637        damage: &super::DamagePlan,
638        auras: &[AuraData],
639        passive_drivers: &[PassiveDriverEffect],
640    ) {
641        let action_count = damage.action_locals().count();
642        let binding_count = action_count.saturating_mul(MODIFIER_PROPERTY_SLOT_COUNT);
643        let mut passive_indexes = vec![Vec::new(); binding_count];
644        let mut player_auras = vec![Vec::new(); binding_count];
645        let mut scoped_passive_indexes = vec![Vec::new(); action_count];
646        let mut damage_passive_indexes = vec![Vec::new(); action_count];
647        let mut cost_passive_indexes = vec![Vec::new(); action_count];
648
649        for &passive_index in self.passive_family_indexes(EffectFamily::SPELL_MODIFIER) {
650            let effect = &passive_drivers[passive_index].effect;
651            let binding = damage
652                .passive_buff_binding(passive_index)
653                .expect("every finalized passive effect has a damage binding record");
654
655            for action in damage.action_locals() {
656                if spell_scoped_effect_applies_to_action(effect, binding, action, damage) {
657                    scoped_passive_indexes[action.index()].push(passive_index);
658                }
659
660                if damage_scoped_effect_applies_to_action(effect, binding, action, damage) {
661                    damage_passive_indexes[action.index()].push(passive_index);
662                }
663
664                if let Some(property) = action_modifier_property(effect)
665                    && binding
666                        .source_effect
667                        .is_some_and(|source| damage.effect_affects_action(source, action))
668                {
669                    passive_indexes[spell_modifier_binding_index(action, property)]
670                        .push(passive_index);
671                }
672            }
673        }
674
675        for family in [EffectFamily::CAST_TIME, EffectFamily::GLOBAL_COOLDOWN] {
676            for &passive_index in self.passive_family_indexes(family) {
677                let effect = &passive_drivers[passive_index].effect;
678                let Some(property) = configured_timing_property(effect) else {
679                    continue;
680                };
681                let binding = damage
682                    .passive_buff_binding(passive_index)
683                    .expect("every finalized passive effect has a damage binding record");
684
685                for action in damage.action_locals() {
686                    if binding.lists_action(action) {
687                        passive_indexes[spell_modifier_binding_index(action, property)]
688                            .push(passive_index);
689                    }
690                }
691            }
692        }
693
694        for family in [
695            EffectFamily::COOLDOWN_RECHARGE_RATE,
696            EffectFamily::COOLDOWN_DURATION,
697        ] {
698            for &passive_index in self.passive_family_indexes(family) {
699                let effect = &passive_drivers[passive_index].effect;
700                let binding = damage
701                    .passive_buff_binding(passive_index)
702                    .expect("every finalized passive effect has a damage binding record");
703
704                for action in damage.action_locals() {
705                    if cooldown_effect_applies_to_action(effect, binding, action, damage) {
706                        passive_indexes
707                            [spell_modifier_binding_index(action, ModifierPropertyKind::Cooldown)]
708                        .push(passive_index);
709                    }
710                }
711            }
712        }
713
714        for &passive_index in self.passive_family_indexes(EffectFamily::RESOURCE_COST) {
715            let effect = &passive_drivers[passive_index].effect;
716            let binding = damage
717                .passive_buff_binding(passive_index)
718                .expect("every finalized passive effect has a damage binding record");
719
720            for action in damage.action_locals() {
721                if cost_effect_applies_to_action(effect, binding, action, damage) {
722                    cost_passive_indexes[action.index()].push(passive_index);
723                }
724            }
725        }
726
727        let player_entries = self
728            .actor_family_effects(ActorId::Player, EffectFamily::SPELL_MODIFIER)
729            .to_vec();
730
731        for entry in player_entries {
732            let Some(effect) = auras
733                .get(entry.local.as_usize())
734                .and_then(|aura| aura.effects.get(usize::from(entry.position)))
735                .and_then(Option::as_ref)
736            else {
737                continue;
738            };
739            let binding = damage
740                .aura_buff_binding(entry.local, entry.position)
741                .expect("every finalized aura effect has a damage binding record");
742
743            for action in damage.action_locals() {
744                if let Some(property) = action_modifier_property(effect)
745                    && binding
746                        .source_effect
747                        .is_some_and(|source| damage.effect_affects_action(source, action))
748                {
749                    player_auras[spell_modifier_binding_index(action, property)].push(entry);
750                }
751            }
752        }
753
754        for family in [EffectFamily::CAST_TIME, EffectFamily::GLOBAL_COOLDOWN] {
755            let player_entries = self.actor_family_effects(ActorId::Player, family).to_vec();
756
757            for entry in player_entries {
758                let Some(effect) = auras
759                    .get(entry.local.as_usize())
760                    .and_then(|aura| aura.effects.get(usize::from(entry.position)))
761                    .and_then(Option::as_ref)
762                else {
763                    continue;
764                };
765                let Some(property) = configured_timing_property(effect) else {
766                    continue;
767                };
768                let binding = damage
769                    .aura_buff_binding(entry.local, entry.position)
770                    .expect("every finalized aura effect has a damage binding record");
771
772                for action in damage.action_locals() {
773                    if binding.lists_action(action) {
774                        player_auras[spell_modifier_binding_index(action, property)].push(entry);
775                    }
776                }
777            }
778        }
779
780        for family in [
781            EffectFamily::COOLDOWN_RECHARGE_RATE,
782            EffectFamily::COOLDOWN_DURATION,
783        ] {
784            let player_entries = self.actor_family_effects(ActorId::Player, family).to_vec();
785
786            for entry in player_entries {
787                let effect = auras[entry.local.as_usize()].effects[usize::from(entry.position)]
788                    .as_ref()
789                    .expect("planned effect position remains occupied after finalization");
790                let binding = damage
791                    .aura_buff_binding(entry.local, entry.position)
792                    .expect("every finalized aura effect has a damage binding record");
793
794                for action in damage.action_locals() {
795                    if cooldown_effect_applies_to_action(effect, binding, action, damage) {
796                        player_auras
797                            [spell_modifier_binding_index(action, ModifierPropertyKind::Cooldown)]
798                        .push(entry);
799                    }
800                }
801            }
802        }
803
804        let scoped_source = self
805            .actor_bindings
806            .by_family
807            .get(
808                single_family_index(EffectFamily::SPELL_MODIFIER)
809                    .expect("spell modifiers occupy one finalized family"),
810            )
811            .expect("spell-modifier actor bindings are finalized");
812        let mut scoped_actor_auras = Vec::with_capacity(action_count);
813        let mut damage_actor_auras = Vec::with_capacity(action_count);
814        let cost_source = self
815            .actor_bindings
816            .by_family
817            .get(
818                single_family_index(EffectFamily::RESOURCE_COST)
819                    .expect("resource costs occupy one finalized family"),
820            )
821            .expect("resource-cost actor bindings are finalized");
822        let mut cost_actor_auras = Vec::with_capacity(action_count);
823
824        for action in damage.action_locals() {
825            scoped_actor_auras.push(filter_actor_ranges(scoped_source, |entry| {
826                let effect = auras[entry.local.as_usize()].effects[usize::from(entry.position)]
827                    .as_ref()
828                    .expect("planned effect position remains occupied after finalization");
829                let binding = damage
830                    .aura_buff_binding(entry.local, entry.position)
831                    .expect("every finalized aura effect has a damage binding record");
832
833                spell_scoped_effect_applies_to_action(effect, binding, action, damage)
834            }));
835            damage_actor_auras.push(filter_actor_ranges(scoped_source, |entry| {
836                let effect = auras[entry.local.as_usize()].effects[usize::from(entry.position)]
837                    .as_ref()
838                    .expect("planned effect position remains occupied after finalization");
839                let binding = damage
840                    .aura_buff_binding(entry.local, entry.position)
841                    .expect("every finalized aura effect has a damage binding record");
842
843                damage_scoped_effect_applies_to_action(effect, binding, action, damage)
844            }));
845            cost_actor_auras.push(filter_actor_ranges(cost_source, |entry| {
846                let effect = auras[entry.local.as_usize()].effects[usize::from(entry.position)]
847                    .as_ref()
848                    .expect("planned effect position remains occupied after finalization");
849                let binding = damage
850                    .aura_buff_binding(entry.local, entry.position)
851                    .expect("every finalized aura effect has a damage binding record");
852
853                cost_effect_applies_to_action(effect, binding, action, damage)
854            }));
855        }
856
857        self.action_spell_modifiers = ActionSpellModifierBindings {
858            passive_indexes: passive_indexes
859                .into_iter()
860                .map(Vec::into_boxed_slice)
861                .collect(),
862            #[cfg(feature = "rotation-optimizations")]
863            player_aura_plans: player_auras
864                .into_iter()
865                .map(|entries| ActiveAuraEffectPlan::compile(&entries, auras.len()))
866                .collect(),
867            #[cfg(not(feature = "rotation-optimizations"))]
868            player_auras: player_auras
869                .into_iter()
870                .map(Vec::into_boxed_slice)
871                .collect(),
872            scoped_passive_indexes: scoped_passive_indexes
873                .into_iter()
874                .map(Vec::into_boxed_slice)
875                .collect(),
876            #[cfg(feature = "rotation-optimizations")]
877            scoped_aura_plans: scoped_actor_auras
878                .iter()
879                .map(|ranges| ActiveAuraEffectPlan::compile(&ranges.entries, auras.len()))
880                .collect(),
881            #[cfg(not(feature = "rotation-optimizations"))]
882            scoped_actor_auras: scoped_actor_auras.into_boxed_slice(),
883            damage_passive_indexes: damage_passive_indexes
884                .into_iter()
885                .map(Vec::into_boxed_slice)
886                .collect(),
887            #[cfg(feature = "rotation-optimizations")]
888            damage_aura_plans: damage_actor_auras
889                .iter()
890                .map(|ranges| ActiveAuraEffectPlan::compile(&ranges.entries, auras.len()))
891                .collect(),
892            #[cfg(not(feature = "rotation-optimizations"))]
893            damage_actor_auras: damage_actor_auras.into_boxed_slice(),
894            cost_passive_indexes: cost_passive_indexes
895                .into_iter()
896                .map(Vec::into_boxed_slice)
897                .collect(),
898            #[cfg(feature = "rotation-optimizations")]
899            cost_aura_plans: cost_actor_auras
900                .iter()
901                .map(|ranges| ActiveAuraEffectPlan::compile(&ranges.entries, auras.len()))
902                .collect(),
903            #[cfg(not(feature = "rotation-optimizations"))]
904            cost_actor_auras: cost_actor_auras.into_boxed_slice(),
905        };
906    }
907
908    /// Complete concrete aura-slot traversal reserved for reset/despawn lifecycle work.
909    #[cfg(any(test, not(feature = "rotation-optimizations")))]
910    pub(crate) fn lifecycle_actor_auras(&self, actor: ActorId) -> &[PlannedAuraEffects] {
911        self.actor_bindings.all.for_actor(actor)
912    }
913
914    pub(crate) fn actor_family_effects(
915        &self,
916        actor: ActorId,
917        family: EffectFamily,
918    ) -> &[PlannedAuraEffect] {
919        let Some(index) = single_family_index(family) else {
920            return &[];
921        };
922
923        // BOUNDS: single_family_index validates the finalized family dimension.
924
925        self.actor_bindings.by_family[index].for_actor(actor)
926    }
927
928    #[cfg(feature = "rotation-optimizations")]
929    pub(crate) fn family_aura_plan(&self, family: EffectFamily) -> &ActiveAuraEffectPlan {
930        let index = single_family_index(family).expect("effect family plan requires one family");
931
932        &self.family_auras[index]
933    }
934
935    #[cfg(feature = "rotation-optimizations")]
936    pub(crate) const fn static_total_aura_plan(&self) -> &ActiveAuraEffectPlan {
937        &self.static_total_auras
938    }
939
940    #[cfg(feature = "rotation-optimizations")]
941    pub(crate) const fn dynamic_total_aura_plan(&self) -> &ActiveAuraEffectPlan {
942        &self.dynamic_total_auras
943    }
944
945    pub(crate) fn passive_family_indexes(&self, family: EffectFamily) -> &[usize] {
946        single_family_index(family).map_or(&[], |index| {
947            // BOUNDS: single_family_index validates the finalized family dimension.
948            &self.passive_by_family[index]
949        })
950    }
951
952    pub(crate) fn health_threshold_capacity(&self) -> usize {
953        let index = single_family_index(EffectFamily::HEALTH_THRESHOLD_TRIGGER)
954            .expect("health thresholds occupy one finalized family");
955        let auras = &self.actor_bindings.by_family[index];
956        let player = auras.for_actor(ActorId::Player).len().saturating_add(
957            self.passive_family_indexes(EffectFamily::HEALTH_THRESHOLD_TRIGGER)
958                .len(),
959        );
960
961        auras.maximum_actor_len().max(player)
962    }
963
964    pub(crate) fn spell_modifier_passive_indexes(
965        &self,
966        action: super::LocalDamageActionIdx,
967        property: ModifierPropertyKind,
968    ) -> &[usize] {
969        self.action_spell_modifiers
970            .passive_indexes
971            .get(spell_modifier_binding_index(action, property))
972            .map_or(&[], Box::as_ref)
973    }
974
975    #[cfg(feature = "rotation-optimizations")]
976    pub(crate) fn spell_modifier_player_aura_plan(
977        &self,
978        action: super::LocalDamageActionIdx,
979        property: ModifierPropertyKind,
980    ) -> &ActiveAuraEffectPlan {
981        self.action_spell_modifiers
982            .player_aura_plans
983            .get(spell_modifier_binding_index(action, property))
984            .expect("finalized action/property has an active-aura effect plan")
985    }
986
987    #[cfg(not(feature = "rotation-optimizations"))]
988    pub(crate) fn spell_modifier_player_auras(
989        &self,
990        action: super::LocalDamageActionIdx,
991        property: ModifierPropertyKind,
992    ) -> &[PlannedAuraEffect] {
993        self.action_spell_modifiers
994            .player_auras
995            .get(spell_modifier_binding_index(action, property))
996            .map_or(&[], Box::as_ref)
997    }
998
999    pub(crate) fn scoped_spell_modifier_passive_indexes(
1000        &self,
1001        action: super::LocalDamageActionIdx,
1002    ) -> &[usize] {
1003        self.action_spell_modifiers
1004            .scoped_passive_indexes
1005            .get(action.index())
1006            .map_or(&[], Box::as_ref)
1007    }
1008
1009    #[cfg(feature = "rotation-optimizations")]
1010    pub(crate) fn scoped_spell_modifier_aura_plan(
1011        &self,
1012        action: super::LocalDamageActionIdx,
1013    ) -> &ActiveAuraEffectPlan {
1014        self.action_spell_modifiers
1015            .scoped_aura_plans
1016            .get(action.index())
1017            .expect("finalized action has a scoped active-aura effect plan")
1018    }
1019
1020    #[cfg(not(feature = "rotation-optimizations"))]
1021    pub(crate) fn scoped_spell_modifier_actor_auras(
1022        &self,
1023        actor: ActorId,
1024        action: super::LocalDamageActionIdx,
1025    ) -> &[PlannedAuraEffect] {
1026        self.action_spell_modifiers
1027            .scoped_actor_auras
1028            .get(action.index())
1029            .map_or(&[], |ranges| ranges.for_actor(actor))
1030    }
1031
1032    pub(crate) fn damage_spell_modifier_passive_indexes(
1033        &self,
1034        action: super::LocalDamageActionIdx,
1035    ) -> &[usize] {
1036        self.action_spell_modifiers
1037            .damage_passive_indexes
1038            .get(action.index())
1039            .map_or(&[], Box::as_ref)
1040    }
1041
1042    #[cfg(feature = "rotation-optimizations")]
1043    pub(crate) fn damage_spell_modifier_aura_plan(
1044        &self,
1045        action: super::LocalDamageActionIdx,
1046    ) -> &ActiveAuraEffectPlan {
1047        self.action_spell_modifiers
1048            .damage_aura_plans
1049            .get(action.index())
1050            .expect("finalized action has a damage active-aura effect plan")
1051    }
1052
1053    #[cfg(not(feature = "rotation-optimizations"))]
1054    pub(crate) fn damage_spell_modifier_actor_auras(
1055        &self,
1056        actor: ActorId,
1057        action: super::LocalDamageActionIdx,
1058    ) -> &[PlannedAuraEffect] {
1059        self.action_spell_modifiers
1060            .damage_actor_auras
1061            .get(action.index())
1062            .map_or(&[], |ranges| ranges.for_actor(actor))
1063    }
1064
1065    pub(crate) fn cost_modifier_passive_indexes(
1066        &self,
1067        action: super::LocalDamageActionIdx,
1068    ) -> &[usize] {
1069        self.action_spell_modifiers
1070            .cost_passive_indexes
1071            .get(action.index())
1072            .map_or(&[], Box::as_ref)
1073    }
1074
1075    #[cfg(feature = "rotation-optimizations")]
1076    pub(crate) fn cost_modifier_aura_plan(
1077        &self,
1078        action: super::LocalDamageActionIdx,
1079    ) -> &ActiveAuraEffectPlan {
1080        self.action_spell_modifiers
1081            .cost_aura_plans
1082            .get(action.index())
1083            .expect("finalized action has a cost active-aura effect plan")
1084    }
1085
1086    #[cfg(not(feature = "rotation-optimizations"))]
1087    pub(crate) fn cost_modifier_actor_auras(
1088        &self,
1089        actor: ActorId,
1090        action: super::LocalDamageActionIdx,
1091    ) -> &[PlannedAuraEffect] {
1092        self.action_spell_modifiers
1093            .cost_actor_auras
1094            .get(action.index())
1095            .map_or(&[], |ranges| ranges.for_actor(actor))
1096    }
1097
1098    #[cfg(not(feature = "rotation-optimizations"))]
1099    pub(crate) fn action_break_auras(&self, actor: ActorId) -> &[PlannedAuraEffects] {
1100        self.actor_bindings.action_break.for_actor(actor)
1101    }
1102
1103    #[cfg(not(feature = "rotation-optimizations"))]
1104    pub(crate) fn damage_break_auras(
1105        &self,
1106        actor: ActorId,
1107        periodic: bool,
1108    ) -> &[PlannedAuraEffects] {
1109        if periodic {
1110            self.actor_bindings.periodic_damage_break.for_actor(actor)
1111        } else {
1112            self.actor_bindings.direct_damage_break.for_actor(actor)
1113        }
1114    }
1115
1116    pub(crate) fn aura_state_auras(
1117        &self,
1118        actor: ActorId,
1119        required: crate::state::AuraState,
1120    ) -> &[PlannedAuraEffects] {
1121        let entries = self.actor_bindings.aura_state.for_actor(actor);
1122        let raw = required as i32;
1123        let state = |entry: &PlannedAuraEffects| {
1124            entry
1125                .granted_aura_state
1126                .map_or(0, |candidate| candidate as i32)
1127        };
1128        let start = entries.partition_point(|entry| state(entry) < raw);
1129        let end = entries.partition_point(|entry| state(entry) <= raw);
1130
1131        // BOUNDS: both partition points were computed from this exact sorted actor slice.
1132
1133        &entries[start..end]
1134    }
1135
1136    pub(crate) fn shapeshift_auras(&self) -> &[PlannedAuraEffects] {
1137        self.actor_bindings.shapeshift.for_actor(ActorId::Player)
1138    }
1139
1140    pub(crate) fn dispellable_auras(
1141        &self,
1142        actor: ActorId,
1143        dispel_type: i32,
1144    ) -> &[PlannedAuraEffects] {
1145        let entries = self.actor_bindings.dispellable.for_actor(actor);
1146        let start = entries.partition_point(|entry| entry.dispel_type < dispel_type);
1147        let end = entries.partition_point(|entry| entry.dispel_type <= dispel_type);
1148
1149        // BOUNDS: both partition points were computed from this exact sorted actor slice.
1150
1151        &entries[start..end]
1152    }
1153
1154    pub(crate) fn mechanic_auras(&self, actor: ActorId) -> &[PlannedAuraEffects] {
1155        self.actor_bindings.mechanic.for_actor(actor)
1156    }
1157
1158    pub(crate) fn owner_duplicate_handles(&self, local: LocalAuraIdx) -> &[AuraSlotHandle] {
1159        self.owner_duplicates
1160            .get(local.as_usize())
1161            .map_or(&[], Box::as_ref)
1162    }
1163
1164    #[cfg(not(feature = "rotation-optimizations"))]
1165    pub(crate) fn static_total_actor_effects(&self, actor: ActorId) -> &[PlannedAuraEffects] {
1166        self.actor_bindings.static_totals.for_actor(actor)
1167    }
1168
1169    #[cfg(not(feature = "rotation-optimizations"))]
1170    pub(crate) fn dynamic_total_actor_effects(&self, actor: ActorId) -> &[PlannedAuraEffects] {
1171        self.actor_bindings.dynamic_totals.for_actor(actor)
1172    }
1173
1174    pub(crate) fn passive_driver_indexes(&self, class: EffectConsumerClass) -> &[usize] {
1175        &self.relevant(class).passive_driver_indexes
1176    }
1177
1178    pub(crate) fn static_total_passive_driver_indexes(&self) -> &[usize] {
1179        self.passive_driver_indexes(EffectConsumerClass::GeneralTotals)
1180    }
1181
1182    pub(crate) fn dynamic_total_passive_driver_indexes(&self) -> &[usize] {
1183        self.passive_driver_indexes(EffectConsumerClass::DynamicTotals)
1184    }
1185
1186    pub(crate) fn aura(&self, local: LocalAuraIdx) -> AuraEffectMasks {
1187        // BOUNDS: local aura indices and this plan are produced from the same finalized slice.
1188        self.aura_masks[local.as_usize()]
1189    }
1190
1191    pub(crate) const fn global_capabilities(&self) -> EffectFamily {
1192        self.global_capabilities
1193    }
1194
1195    pub(crate) fn validate(&self, aura_count: usize, passive_count: usize) -> bool {
1196        self.aura_masks.len() == aura_count
1197            && self.owner_duplicates.len() == aura_count
1198            && self.optimized_plans_valid(aura_count, passive_count)
1199            && self.actor_bindings.by_family.len() == EFFECT_FAMILY_COUNT
1200            && self.actor_bindings.by_family.iter().all(|ranges| {
1201                ranges.entries.iter().all(|entry| {
1202                    entry.local.as_usize() < aura_count
1203                        && usize::from(entry.position) < MAX_AURA_BUFF_EFFECTS
1204                })
1205            })
1206            && [
1207                &self.actor_bindings.all,
1208                &self.actor_bindings.static_totals,
1209                &self.actor_bindings.dynamic_totals,
1210                &self.actor_bindings.action_break,
1211                &self.actor_bindings.periodic_damage_break,
1212                &self.actor_bindings.direct_damage_break,
1213                &self.actor_bindings.aura_state,
1214                &self.actor_bindings.shapeshift,
1215                &self.actor_bindings.dispellable,
1216                &self.actor_bindings.mechanic,
1217            ]
1218            .into_iter()
1219            .all(|ranges| {
1220                ranges
1221                    .entries
1222                    .iter()
1223                    .all(|entry| entry.local.as_usize() < aura_count)
1224            })
1225            && EffectConsumerClass::ALL.into_iter().all(|class| {
1226                let relevant = self.relevant(class);
1227
1228                relevant
1229                    .aura_locals
1230                    .iter()
1231                    .all(|local| local.as_usize() < aura_count)
1232                    && relevant
1233                        .passive_driver_indexes
1234                        .iter()
1235                        .all(|index| *index < passive_count)
1236            })
1237            && self.passive_by_family.len() == EFFECT_FAMILY_COUNT
1238            && self
1239                .passive_by_family
1240                .iter()
1241                .flatten()
1242                .all(|index| *index < passive_count)
1243    }
1244
1245    #[cfg(test)]
1246    pub(super) fn relevant_auras(&self, class: EffectConsumerClass) -> &[LocalAuraIdx] {
1247        &self.relevant(class).aura_locals
1248    }
1249
1250    #[cfg(test)]
1251    pub(super) fn relevant_passives(&self, class: EffectConsumerClass) -> &[usize] {
1252        &self.relevant(class).passive_driver_indexes
1253    }
1254
1255    #[cfg(not(feature = "rotation-optimizations"))]
1256    #[expect(
1257        clippy::unused_self,
1258        reason = "feature variants share one validation API"
1259    )]
1260    const fn optimized_plans_valid(&self, _aura_count: usize, _passive_count: usize) -> bool {
1261        true
1262    }
1263
1264    fn relevant(&self, class: EffectConsumerClass) -> &RelevantEffectList {
1265        // BOUNDS: EffectConsumerClass has exactly EFFECT_CONSUMER_CLASS_COUNT variants.
1266        &self.relevant[class.index()]
1267    }
1268}
1269
1270fn spell_modifier_binding_index(
1271    action: super::LocalDamageActionIdx,
1272    property: ModifierPropertyKind,
1273) -> usize {
1274    action
1275        .index()
1276        .saturating_mul(MODIFIER_PROPERTY_SLOT_COUNT)
1277        .saturating_add(property as usize)
1278}
1279
1280fn action_modifier_property(effect: &BuffEffect) -> Option<ModifierPropertyKind> {
1281    match effect {
1282        BuffEffect::SpellModifierFromEffect { property, .. }
1283        | BuffEffect::SpellTargetingFromEffect { property, .. } => Some(*property),
1284        BuffEffect::SpellCastTimeFromEffect { .. } => Some(ModifierPropertyKind::CastTime),
1285        BuffEffect::SpellGcdFromEffect { .. } => Some(ModifierPropertyKind::GlobalCooldown),
1286        BuffEffect::SpellDurationFromEffect { .. } => Some(ModifierPropertyKind::Duration),
1287        BuffEffect::SpellTickTimeFromEffect { .. } => Some(ModifierPropertyKind::TickTime),
1288        BuffEffect::ResistPushbackFromEffect { .. } => Some(ModifierPropertyKind::ResistPushback),
1289        _ => None,
1290    }
1291}
1292
1293fn configured_timing_property(effect: &BuffEffect) -> Option<ModifierPropertyKind> {
1294    match effect {
1295        BuffEffect::CastTimePercent(..) | BuffEffect::CastTimePercentPerStack(..) => {
1296            Some(ModifierPropertyKind::CastTime)
1297        }
1298        BuffEffect::GcdPercent(..) => Some(ModifierPropertyKind::GlobalCooldown),
1299        _ => None,
1300    }
1301}
1302
1303fn cooldown_effect_applies_to_action(
1304    effect: &BuffEffect,
1305    binding: &super::FinalizedBuffDamageBinding,
1306    action: super::LocalDamageActionIdx,
1307    damage: &super::DamagePlan,
1308) -> bool {
1309    match effect {
1310        BuffEffect::RechargeRatePercent(..) => binding.lists_action(action),
1311        BuffEffect::CooldownRechargeRateFromEffect { .. }
1312        | BuffEffect::CooldownDurationPercent { .. } => binding
1313            .source_effect
1314            .is_some_and(|source| damage.effect_affects_action(source, action)),
1315        BuffEffect::ChargeRechargeRateCategory { .. } => true,
1316        _ => false,
1317    }
1318}
1319
1320fn cost_effect_applies_to_action(
1321    effect: &BuffEffect,
1322    binding: &super::FinalizedBuffDamageBinding,
1323    action: super::LocalDamageActionIdx,
1324    damage: &super::DamagePlan,
1325) -> bool {
1326    match effect {
1327        BuffEffect::ResourceCostPercent(..) => true,
1328        BuffEffect::SpellCostPercent(..) | BuffEffect::SpellCostFlat(..) => {
1329            binding.lists_action(action)
1330        }
1331        BuffEffect::SpellCostFromEffect { .. }
1332        | BuffEffect::AdditionalPowerCostFromEffect { .. } => binding
1333            .source_effect
1334            .is_some_and(|source| damage.effect_affects_action(source, action)),
1335        _ => false,
1336    }
1337}
1338
1339fn spell_scoped_effect_applies_to_action(
1340    effect: &BuffEffect,
1341    binding: &super::FinalizedBuffDamageBinding,
1342    action: super::LocalDamageActionIdx,
1343    damage: &super::DamagePlan,
1344) -> bool {
1345    if matches!(effect, BuffEffect::SpellModifierFromEffect { .. }) {
1346        return false;
1347    }
1348
1349    if effect.listed_spell_ids().is_some() {
1350        return binding.lists_action(action);
1351    }
1352
1353    match effect {
1354        BuffEffect::SpellDamageFromEffect { .. }
1355        | BuffEffect::DamageTakenFromSourceSpells { .. }
1356        | BuffEffect::SpellCritChanceFromEffect { .. }
1357        | BuffEffect::SpellCritDamageFromEffect { .. } => binding
1358            .source_effect
1359            .is_some_and(|source| damage.effect_affects_action(source, action)),
1360        _ => true,
1361    }
1362}
1363
1364fn damage_scoped_effect_applies_to_action(
1365    effect: &BuffEffect,
1366    binding: &super::FinalizedBuffDamageBinding,
1367    action: super::LocalDamageActionIdx,
1368    damage: &super::DamagePlan,
1369) -> bool {
1370    if !matches!(
1371        effect,
1372        BuffEffect::DamageMultSpells(..)
1373            | BuffEffect::DamageMultSpellsActive(..)
1374            | BuffEffect::SpellDamageFromEffect { .. }
1375            | BuffEffect::DamageMultSpellsLinear(..)
1376            | BuffEffect::DynamicDamageMultSpells(..)
1377            | BuffEffect::DamageToTargetAuraMechanicPercent { .. }
1378            | BuffEffect::DamageTakenFromSource(..)
1379            | BuffEffect::DamageTakenFromSourceSpells { .. }
1380            | BuffEffect::MechanicDamageTakenPercent { .. }
1381            | BuffEffect::CreatureAoeDamageAvoidance { .. }
1382            | BuffEffect::IncomingDamagePercent { .. }
1383            | BuffEffect::SpellCritChanceFromEffect { .. }
1384            | BuffEffect::SpellCritDamageFromEffect { .. }
1385            | BuffEffect::CritChanceSpells(..)
1386            | BuffEffect::CritDamageSpells(..)
1387    ) {
1388        return false;
1389    }
1390
1391    spell_scoped_effect_applies_to_action(effect, binding, action, damage)
1392}
1393
1394impl PlannedAuraEffect {
1395    pub(crate) const fn is_canonical_for(
1396        self,
1397        requested: EffectFamily,
1398        selected: EffectFamily,
1399    ) -> bool {
1400        let matching = self.families.intersection(requested).bits();
1401
1402        matching != 0 && matching.trailing_zeros() == selected.bits().trailing_zeros()
1403    }
1404}
1405
1406fn flatten_actor_buckets<T>(buckets: Vec<Vec<T>>) -> ActorRanges<T> {
1407    let mut entries = Vec::new();
1408    let mut ranges = Vec::with_capacity(buckets.len());
1409
1410    for bucket in buckets {
1411        let start = entries.len();
1412
1413        entries.extend(bucket);
1414        ranges.push(start..entries.len());
1415    }
1416
1417    // BOUNDS: fixed player/external buckets are always created by bind_aura_slots.
1418
1419    let player = ranges[PLAYER_BUCKET].clone();
1420    // BOUNDS: fixed player/external buckets are always created by bind_aura_slots.
1421    let external = ranges[EXTERNAL_BUCKET].clone();
1422    let enemies_start = PET_BUCKETS_START + PetIdx::MAX;
1423    // BOUNDS: ranges has one entry for every fixed pet bucket plus all enemy buckets.
1424    let pets = ranges[PET_BUCKETS_START..enemies_start]
1425        .to_vec()
1426        .into_boxed_slice();
1427    // BOUNDS: enemies_start follows the complete fixed bucket prefix.
1428    let enemies = ranges[enemies_start..].to_vec().into_boxed_slice();
1429
1430    ActorRanges {
1431        entries: entries.into_boxed_slice(),
1432        player,
1433        external,
1434        pets,
1435        enemies,
1436    }
1437}
1438
1439fn filter_actor_ranges<T>(
1440    source: &ActorRanges<T>,
1441    mut keep: impl FnMut(&T) -> bool,
1442) -> ActorRanges<T>
1443where
1444    T: Copy,
1445{
1446    let mut buckets =
1447        Vec::with_capacity(FIXED_ACTOR_BUCKET_COUNT + source.pets.len() + source.enemies.len());
1448
1449    buckets.push(
1450        source.entries[source.player.clone()]
1451            .iter()
1452            .copied()
1453            .filter(&mut keep)
1454            .collect(),
1455    );
1456    buckets.push(
1457        source.entries[source.external.clone()]
1458            .iter()
1459            .copied()
1460            .filter(&mut keep)
1461            .collect(),
1462    );
1463
1464    for range in source.pets.iter().chain(&source.enemies) {
1465        buckets.push(
1466            source.entries[range.start..range.end]
1467                .iter()
1468                .copied()
1469                .filter(&mut keep)
1470                .collect(),
1471        );
1472    }
1473
1474    flatten_actor_buckets(buckets)
1475}
1476
1477fn local_aura(raw: usize) -> LocalAuraIdx {
1478    LocalAuraIdx::new(u8::try_from(raw).expect("finalized aura count is validated below 256"))
1479}
1480
1481fn finalized_aura(auras: &[AuraData], local: LocalAuraIdx) -> &AuraData {
1482    // BOUNDS: local aura identities are finalized against this exact definition slice.
1483    &auras[local.as_usize()]
1484}
1485
1486fn actor_bucket(actor: ActorId, enemy_count: usize) -> Option<usize> {
1487    match actor {
1488        ActorId::Player => Some(PLAYER_BUCKET),
1489        ActorId::External => Some(EXTERNAL_BUCKET),
1490        ActorId::Pet(pet) => {
1491            (pet.as_usize() < PetIdx::MAX).then_some(PET_BUCKETS_START + pet.as_usize())
1492        }
1493        ActorId::Enemy(enemy) => (enemy.as_usize() < enemy_count)
1494            .then_some(PET_BUCKETS_START + PetIdx::MAX + enemy.as_usize()),
1495    }
1496}
1497
1498#[cfg(test)]
1499#[path = "effects/tests.rs"]
1500mod tests;