Skip to main content

wowlab_engine_combat/state/runtime/
auras.rs

1//! Revision-aware semantic mutation of finalized aura instances.
2
3#[cfg(feature = "rotation-optimizations")]
4use std::num::NonZeroU64;
5
6use wowlab_engine_domain::rotation::{AuraSlot, AuraTransferState as BufferAuraTransferState};
7#[cfg(test)]
8use wowlab_types::sim::AuraKey;
9use wowlab_types::sim::{ActorId, PetIdx, SimTime};
10
11use super::CombatInstance;
12#[cfg(feature = "rotation-optimizations")]
13use super::{RotationMutationDirection, RotationSemanticMutation};
14use crate::state::{CombatProgram, program::ConcreteAuraBinding};
15
16const AURA_SLOT_FLOAT_FIELDS: usize = 12;
17const AURA_SLOT_INTEGER_FIELDS: usize = 4;
18#[cfg(feature = "rotation-optimizations")]
19const ACTIVE_FIXED_ACTOR_COUNT: usize = 2;
20#[cfg(feature = "rotation-optimizations")]
21const ACTIVE_PLAYER_INDEX: usize = 0;
22#[cfg(feature = "rotation-optimizations")]
23const ACTIVE_EXTERNAL_INDEX: usize = 1;
24#[cfg(feature = "rotation-optimizations")]
25const ACTIVE_PET_START: usize = ACTIVE_FIXED_ACTOR_COUNT;
26#[cfg(feature = "rotation-optimizations")]
27const ACTIVE_SUMMARY_WORD: &str = "active aura summary points to its actor bitset";
28#[cfg(feature = "rotation-optimizations")]
29const ACTIVE_NONEMPTY_WORD: &str = "active aura summary points to a nonempty actor word";
30#[cfg(feature = "rotation-optimizations")]
31const ACTIVE_LOCAL_GROUP: &str = "active local bit belongs to the local-group lookup";
32#[cfg(feature = "rotation-optimizations")]
33const ACTIVE_CONCRETE_GROUP: &str = "active local lookup points to its concrete group";
34
35#[derive(Debug)]
36pub(super) struct AuraTotalsRevisions {
37    player: u64,
38    external: u64,
39    pets: Box<[u64]>,
40    enemies: Box<[u64]>,
41    buff_cache_generation: u64,
42    player_scaling: u64,
43}
44
45#[derive(Debug)]
46#[cfg(feature = "rotation-optimizations")]
47pub(super) struct ActiveAuraBindings {
48    actors: Box<[ActiveAuraActor]>,
49    coordinates: Box<[(usize, usize, usize)]>,
50    enemy_count: usize,
51}
52
53#[derive(Debug)]
54#[cfg(feature = "rotation-optimizations")]
55struct ActiveAuraActor {
56    groups: Box<[ActiveAuraSet]>,
57    local_groups: Box<[ActiveAuraLocalGroup]>,
58    active_locals: Box<[u64]>,
59}
60
61#[derive(Clone, Copy, Debug)]
62#[cfg(feature = "rotation-optimizations")]
63struct ActiveAuraLocalGroup {
64    group_index: u16,
65    static_totals_mask: u16,
66}
67
68#[cfg(feature = "rotation-optimizations")]
69struct ActiveAuraCoordinateSink<'a> {
70    coordinates: &'a mut [(usize, usize, usize)],
71    actor_index: usize,
72}
73
74#[derive(Debug)]
75#[cfg(feature = "rotation-optimizations")]
76struct ActiveAuraSet {
77    local: crate::state::LocalAuraIdx,
78    bindings: Box<[ConcreteAuraBinding]>,
79    words: Box<[u64]>,
80    summary: Box<[u64]>,
81}
82
83#[cfg(feature = "rotation-optimizations")]
84impl ActiveAuraSet {
85    fn new(local: crate::state::LocalAuraIdx, bindings: Vec<ConcreteAuraBinding>) -> Self {
86        let word_count = bindings.len().div_ceil(u64::BITS as usize);
87
88        Self {
89            local,
90            bindings: bindings.into_boxed_slice(),
91            words: vec![0; word_count].into_boxed_slice(),
92            summary: vec![0; word_count.div_ceil(u64::BITS as usize)].into_boxed_slice(),
93        }
94    }
95
96    fn set_occupied(&mut self, index: usize, occupied: bool) {
97        let word_index = index / u64::BITS as usize;
98        let word = self
99            .words
100            .get_mut(word_index)
101            .expect("active aura binding index belongs to its actor bitset");
102        let bit = 1_u64 << index % u64::BITS as usize;
103
104        if occupied {
105            *word |= bit;
106        } else {
107            *word &= !bit;
108        }
109
110        let summary_word = self
111            .summary
112            .get_mut(word_index / u64::BITS as usize)
113            .expect("active aura word belongs to its summary bitset");
114        let summary_bit = 1_u64 << word_index % u64::BITS as usize;
115
116        if *word == 0 {
117            *summary_word &= !summary_bit;
118        } else {
119            *summary_word |= summary_bit;
120        }
121    }
122
123    fn is_empty(&self) -> bool {
124        self.summary.iter().all(|word| *word == 0)
125    }
126
127    fn for_each(&self, mut visit: impl FnMut(ConcreteAuraBinding)) {
128        for (summary_index, &summary) in self.summary.iter().enumerate() {
129            let mut nonempty_words = summary;
130
131            while nonempty_words != 0 {
132                let word_bit = nonempty_words.trailing_zeros() as usize;
133                let word_index = summary_index * u64::BITS as usize + word_bit;
134                let mut occupied = self
135                    .words
136                    .get(word_index)
137                    .copied()
138                    .expect(ACTIVE_SUMMARY_WORD);
139
140                nonempty_words &= nonempty_words - 1;
141
142                while occupied != 0 {
143                    let binding_bit = occupied.trailing_zeros() as usize;
144
145                    occupied &= occupied - 1;
146                    let index = word_index * u64::BITS as usize + binding_bit;
147
148                    let binding = self
149                        .bindings
150                        .get(index)
151                        .copied()
152                        .expect("occupied active aura bit has a concrete binding");
153
154                    visit(binding);
155                }
156            }
157        }
158    }
159
160    fn pop_first(&mut self) -> Option<ConcreteAuraBinding> {
161        let (summary_index, summary) = self
162            .summary
163            .iter()
164            .copied()
165            .enumerate()
166            .find(|(_, word)| *word != 0)?;
167        let word_index = summary_index * u64::BITS as usize + summary.trailing_zeros() as usize;
168        let occupied = NonZeroU64::new(
169            self.words
170                .get(word_index)
171                .copied()
172                .expect(ACTIVE_SUMMARY_WORD),
173        )
174        .expect(ACTIVE_NONEMPTY_WORD);
175        let index = word_index * u64::BITS as usize + occupied.trailing_zeros() as usize;
176        let binding = self
177            .bindings
178            .get(index)
179            .copied()
180            .expect("occupied active aura bit has a concrete binding");
181
182        self.set_occupied(index, false);
183
184        Some(binding)
185    }
186
187    fn first(&self) -> Option<ConcreteAuraBinding> {
188        let (summary_index, summary) = self
189            .summary
190            .iter()
191            .copied()
192            .enumerate()
193            .find(|(_, word)| *word != 0)?;
194        let word_index = summary_index * u64::BITS as usize + summary.trailing_zeros() as usize;
195        let occupied = NonZeroU64::new(
196            self.words
197                .get(word_index)
198                .copied()
199                .expect(ACTIVE_SUMMARY_WORD),
200        )
201        .expect(ACTIVE_NONEMPTY_WORD);
202        let index = word_index * u64::BITS as usize + occupied.trailing_zeros() as usize;
203
204        self.bindings.get(index).copied()
205    }
206}
207
208#[cfg(feature = "rotation-optimizations")]
209impl ActiveAuraActor {
210    fn new(
211        local_bindings: Vec<Vec<ConcreteAuraBinding>>,
212        coordinates: ActiveAuraCoordinateSink<'_>,
213        static_total_plan: &crate::state::program::ActiveAuraEffectPlan,
214    ) -> Self {
215        let ActiveAuraCoordinateSink {
216            coordinates,
217            actor_index,
218        } = coordinates;
219        let aura_count = local_bindings.len();
220        let mut groups = Vec::new();
221        let mut local_groups = vec![
222            ActiveAuraLocalGroup {
223                group_index: u16::MAX,
224                static_totals_mask: 0,
225            };
226            aura_count
227        ];
228
229        for (raw_local, bindings) in local_bindings.into_iter().enumerate() {
230            if bindings.is_empty() {
231                continue;
232            }
233
234            let local = crate::state::LocalAuraIdx::new(
235                u8::try_from(raw_local).expect("finalized aura count fits the local identity"),
236            );
237            let group_index = groups.len();
238
239            *local_groups
240                .get_mut(raw_local)
241                .expect("enumerated local belongs to the local-group lookup") =
242                ActiveAuraLocalGroup {
243                    group_index: u16::try_from(group_index)
244                        .expect("active aura group count fits the finalized local identity"),
245                    static_totals_mask: static_total_plan.mask(local),
246                };
247
248            for (binding_index, binding) in bindings.iter().copied().enumerate() {
249                *coordinates
250                    .get_mut(binding.index().as_usize())
251                    .expect("concrete binding index belongs to this registry") =
252                    (actor_index, group_index, binding_index);
253            }
254
255            groups.push(ActiveAuraSet::new(local, bindings));
256        }
257
258        Self {
259            groups: groups.into_boxed_slice(),
260            local_groups: local_groups.into_boxed_slice(),
261            active_locals: vec![0; aura_count.div_ceil(u64::BITS as usize)].into_boxed_slice(),
262        }
263    }
264
265    fn set_occupied(&mut self, group_index: usize, binding_index: usize, occupied: bool) {
266        let group = self
267            .groups
268            .get_mut(group_index)
269            .expect("active aura group belongs to its actor");
270
271        group.set_occupied(binding_index, occupied);
272
273        let local = group.local.as_usize();
274        let word = self
275            .active_locals
276            .get_mut(local / u64::BITS as usize)
277            .expect("active aura local belongs to the finalized local bitset");
278        let bit = 1_u64 << local % u64::BITS as usize;
279
280        if group.is_empty() {
281            *word &= !bit;
282        } else {
283            *word |= bit;
284        }
285    }
286
287    fn for_each_intersection(
288        &self,
289        relevant: (&[u64], &[u64]),
290        mut visit: impl FnMut(crate::state::LocalAuraIdx, ConcreteAuraBinding),
291    ) {
292        let (left_relevant, right_relevant) = relevant;
293
294        for (word_index, &active) in self.active_locals.iter().enumerate() {
295            let relevant = left_relevant.get(word_index).copied().unwrap_or(0)
296                | right_relevant.get(word_index).copied().unwrap_or(0);
297            let mut matching = active & relevant;
298
299            while matching != 0 {
300                let bit = matching.trailing_zeros() as usize;
301                let raw_local = word_index * u64::BITS as usize + bit;
302                let local_group = self.local_groups.get(raw_local).expect(ACTIVE_LOCAL_GROUP);
303                let group = self
304                    .groups
305                    .get(usize::from(local_group.group_index))
306                    .expect(ACTIVE_CONCRETE_GROUP);
307
308                matching &= matching - 1;
309                group.for_each(|binding| visit(group.local, binding));
310            }
311        }
312    }
313
314    fn for_each_static_total(
315        &self,
316        relevant: &[u64],
317        mut visit: impl FnMut(crate::state::LocalAuraIdx, ConcreteAuraBinding, u16),
318    ) {
319        for (word_index, &active) in self.active_locals.iter().enumerate() {
320            let mut matching = active & relevant.get(word_index).copied().unwrap_or(0);
321
322            while matching != 0 {
323                let bit = matching.trailing_zeros() as usize;
324                let raw_local = word_index * u64::BITS as usize + bit;
325                let local_group = self.local_groups.get(raw_local).expect(ACTIVE_LOCAL_GROUP);
326                let group = self
327                    .groups
328                    .get(usize::from(local_group.group_index))
329                    .expect(ACTIVE_CONCRETE_GROUP);
330
331                matching &= matching - 1;
332                debug_assert_ne!(
333                    local_group.static_totals_mask, 0,
334                    "static-total relevant local carries a nonzero prebound effect mask"
335                );
336                group.for_each(|binding| {
337                    visit(group.local, binding, local_group.static_totals_mask);
338                });
339            }
340        }
341    }
342
343    fn for_each_local(
344        &self,
345        local: crate::state::LocalAuraIdx,
346        visit: impl FnMut(ConcreteAuraBinding),
347    ) {
348        let raw_local = local.as_usize();
349        let active = self
350            .active_locals
351            .get(raw_local / u64::BITS as usize)
352            .copied()
353            .unwrap_or(0);
354
355        if active & (1_u64 << raw_local % u64::BITS as usize) == 0 {
356            return;
357        }
358
359        let local_group = self.local_groups.get(raw_local).expect(ACTIVE_LOCAL_GROUP);
360
361        self.groups
362            .get(usize::from(local_group.group_index))
363            .expect(ACTIVE_CONCRETE_GROUP)
364            .for_each(visit);
365    }
366
367    #[cfg(test)]
368    fn for_each(&self, mut visit: impl FnMut(ConcreteAuraBinding)) {
369        for group in &self.groups {
370            group.for_each(&mut visit);
371        }
372    }
373
374    fn pop_first(&mut self) -> Option<ConcreteAuraBinding> {
375        let (word_index, word) = self
376            .active_locals
377            .iter()
378            .copied()
379            .enumerate()
380            .find(|(_, word)| *word != 0)?;
381        let raw_local = word_index * u64::BITS as usize + word.trailing_zeros() as usize;
382        let local_group = self.local_groups.get(raw_local).expect(ACTIVE_LOCAL_GROUP);
383        let group = self
384            .groups
385            .get_mut(usize::from(local_group.group_index))
386            .expect(ACTIVE_CONCRETE_GROUP);
387        let binding = group
388            .pop_first()
389            .expect("active aura local points to a nonempty concrete group");
390
391        if group.is_empty() {
392            *self
393                .active_locals
394                .get_mut(word_index)
395                .expect("active local word was selected from this exact bitset") &=
396                !(1_u64 << raw_local % u64::BITS as usize);
397        }
398
399        Some(binding)
400    }
401
402    fn first_intersection(&self, relevant: &[u64]) -> Option<ConcreteAuraBinding> {
403        for (word_index, &active) in self.active_locals.iter().enumerate() {
404            let matching = active & relevant.get(word_index).copied().unwrap_or(0);
405
406            if matching == 0 {
407                continue;
408            }
409
410            let raw_local = word_index * u64::BITS as usize + matching.trailing_zeros() as usize;
411            let local_group = self.local_groups.get(raw_local).expect(ACTIVE_LOCAL_GROUP);
412
413            return self
414                .groups
415                .get(usize::from(local_group.group_index))
416                .expect(ACTIVE_CONCRETE_GROUP)
417                .first();
418        }
419
420        None
421    }
422}
423
424#[cfg(feature = "rotation-optimizations")]
425impl ActiveAuraBindings {
426    pub(super) fn new(
427        bindings: &crate::state::BufferBindings,
428        static_total_plan: &crate::state::program::ActiveAuraEffectPlan,
429        enemy_count: usize,
430    ) -> Self {
431        let actor_count = ACTIVE_FIXED_ACTOR_COUNT + PetIdx::MAX + enemy_count;
432        let aura_count = bindings.aura_local_count();
433        let mut actor_bindings = vec![vec![Vec::new(); aura_count]; actor_count];
434        let mut coordinates =
435            vec![(usize::MAX, usize::MAX, usize::MAX); bindings.aura_binding_count()];
436
437        for binding in bindings.aura_bindings() {
438            let actor_index = active_actor_index(binding.key.affected(), enemy_count)
439                .expect("concrete aura binding affected actor belongs to the finalized encounter");
440            let bucket = actor_bindings
441                .get_mut(actor_index)
442                .and_then(|locals| locals.get_mut(binding.local.as_usize()))
443                .expect("validated active aura actor and local indexes");
444
445            bucket.push(binding);
446        }
447
448        let actors = actor_bindings
449            .into_iter()
450            .enumerate()
451            .map(|(actor_index, locals)| {
452                ActiveAuraActor::new(
453                    locals,
454                    ActiveAuraCoordinateSink {
455                        coordinates: &mut coordinates,
456                        actor_index,
457                    },
458                    static_total_plan,
459                )
460            })
461            .collect();
462
463        Self {
464            actors,
465            coordinates: coordinates.into_boxed_slice(),
466            enemy_count,
467        }
468    }
469
470    fn set_occupied(&mut self, binding: ConcreteAuraBinding, occupied: bool) {
471        let &(actor_index, group_index, binding_index) = self
472            .coordinates
473            .get(binding.index().as_usize())
474            .expect("concrete aura binding index belongs to the active registry");
475        let actor = self
476            .actors
477            .get_mut(actor_index)
478            .expect("concrete aura binding actor belongs to the active registry");
479
480        actor.set_occupied(group_index, binding_index, occupied);
481    }
482
483    #[cfg(test)]
484    fn for_each(&self, actor: ActorId, visit: impl FnMut(ConcreteAuraBinding)) {
485        if let Some(index) = active_actor_index(actor, self.enemy_count)
486            && let Some(bindings) = self.actors.get(index)
487        {
488            bindings.for_each(visit);
489        }
490    }
491
492    fn for_each_intersection(
493        &self,
494        actor: ActorId,
495        relevant: (&[u64], &[u64]),
496        visit: impl FnMut(crate::state::LocalAuraIdx, ConcreteAuraBinding),
497    ) {
498        if let Some(index) = active_actor_index(actor, self.enemy_count)
499            && let Some(bindings) = self.actors.get(index)
500        {
501            bindings.for_each_intersection(relevant, visit);
502        }
503    }
504
505    fn for_each_static_total(
506        &self,
507        actor: ActorId,
508        relevant: &[u64],
509        visit: impl FnMut(crate::state::LocalAuraIdx, ConcreteAuraBinding, u16),
510    ) {
511        if let Some(index) = active_actor_index(actor, self.enemy_count)
512            && let Some(bindings) = self.actors.get(index)
513        {
514            bindings.for_each_static_total(relevant, visit);
515        }
516    }
517
518    fn for_each_local(
519        &self,
520        actor: ActorId,
521        local: crate::state::LocalAuraIdx,
522        visit: impl FnMut(ConcreteAuraBinding),
523    ) {
524        if let Some(index) = active_actor_index(actor, self.enemy_count)
525            && let Some(bindings) = self.actors.get(index)
526        {
527            bindings.for_each_local(local, visit);
528        }
529    }
530
531    fn pop_first(&mut self, actor: ActorId) -> Option<ConcreteAuraBinding> {
532        let index = active_actor_index(actor, self.enemy_count)?;
533
534        self.actors
535            .get_mut(index)
536            .expect("validated active aura actor index")
537            .pop_first()
538    }
539
540    fn first_intersection(&self, actor: ActorId, relevant: &[u64]) -> Option<ConcreteAuraBinding> {
541        let index = active_actor_index(actor, self.enemy_count)?;
542
543        self.actors
544            .get(index)
545            .expect("validated active aura actor index")
546            .first_intersection(relevant)
547    }
548
549    fn drain_actor(&mut self, actor: ActorId, mut visit: impl FnMut(ConcreteAuraBinding)) {
550        while let Some(binding) = self.pop_first(actor) {
551            visit(binding);
552        }
553    }
554
555    fn drain_all(&mut self, mut visit: impl FnMut(ConcreteAuraBinding)) {
556        for actor in &mut self.actors {
557            while let Some(binding) = actor.pop_first() {
558                visit(binding);
559            }
560        }
561    }
562}
563
564#[cfg(feature = "rotation-optimizations")]
565fn active_actor_index(actor: ActorId, enemy_count: usize) -> Option<usize> {
566    match actor {
567        ActorId::Player => Some(ACTIVE_PLAYER_INDEX),
568        ActorId::External => Some(ACTIVE_EXTERNAL_INDEX),
569        ActorId::Pet(pet) => {
570            (pet.as_usize() < PetIdx::MAX).then_some(ACTIVE_PET_START + pet.as_usize())
571        }
572        ActorId::Enemy(enemy) => (enemy.as_usize() < enemy_count)
573            .then_some(ACTIVE_PET_START + PetIdx::MAX + enemy.as_usize()),
574    }
575}
576
577impl AuraTotalsRevisions {
578    pub(super) fn new(enemy_count: usize) -> Self {
579        Self {
580            player: 0,
581            external: 0,
582            pets: vec![0; PetIdx::MAX].into_boxed_slice(),
583            enemies: vec![0; enemy_count].into_boxed_slice(),
584            buff_cache_generation: 0,
585            player_scaling: 0,
586        }
587    }
588
589    fn get(&self, actor: ActorId) -> Option<u64> {
590        match actor {
591            ActorId::Player => Some(self.player),
592            ActorId::External => Some(self.external),
593            ActorId::Pet(pet) => self.pets.get(pet.as_usize()).copied(),
594            ActorId::Enemy(enemy) => self.enemies.get(enemy.as_usize()).copied(),
595        }
596    }
597
598    fn get_mut(&mut self, actor: ActorId) -> Option<&mut u64> {
599        match actor {
600            ActorId::Player => Some(&mut self.player),
601            ActorId::External => Some(&mut self.external),
602            ActorId::Pet(pet) => self.pets.get_mut(pet.as_usize()),
603            ActorId::Enemy(enemy) => self.enemies.get_mut(enemy.as_usize()),
604        }
605    }
606
607    fn bump(&mut self, actor: ActorId) -> bool {
608        let Some(revision) = self.get_mut(actor) else {
609            return false;
610        };
611
612        *revision = revision.wrapping_add(1);
613
614        if *revision == 0 {
615            self.invalidate_all_buff_caches();
616
617            return true;
618        }
619
620        false
621    }
622
623    fn invalidate_all_buff_caches(&mut self) {
624        // Phase 4 installs the caches. The generation is already advanced here so a u64
625        // actor-revision wrap can never make a future cached value appear current.
626        self.buff_cache_generation = self.buff_cache_generation.wrapping_add(1);
627    }
628
629    fn bump_player_scaling(&mut self) -> bool {
630        self.player_scaling = self.player_scaling.wrapping_add(1);
631
632        self.player_scaling == 0
633    }
634
635    fn reset(&mut self) {
636        self.player = 0;
637        self.external = 0;
638        self.pets.fill(0);
639        self.enemies.fill(0);
640        self.invalidate_all_buff_caches();
641        self.bump_player_scaling();
642    }
643
644    fn reset_actor(&mut self, actor: ActorId) {
645        if let Some(revision) = self.get_mut(actor) {
646            *revision = 0;
647        }
648
649        self.invalidate_all_buff_caches();
650
651        if actor == ActorId::Player {
652            self.bump_player_scaling();
653        }
654    }
655}
656
657#[derive(Clone, Copy, Debug)]
658pub(crate) struct AuraActivation {
659    pub(crate) expires_at: f64,
660    pub(crate) base_duration: f64,
661    pub(crate) stacks: i32,
662    pub(crate) max_stacks: i32,
663}
664
665#[derive(Clone, Copy, Debug)]
666pub(crate) struct AuraRefresh {
667    pub(crate) expires_at: Option<f64>,
668    pub(crate) stacks: i32,
669}
670
671#[derive(Clone, Copy, Debug, Default, PartialEq)]
672pub(crate) struct AuraSchedule {
673    pub(crate) next_tick: f64,
674    pub(crate) tick_interval: f64,
675    pub(crate) base_tick_interval: f64,
676    pub(crate) time_rate_multiplier: f64,
677    pub(crate) remaining_ticks: i32,
678}
679
680#[derive(Clone, Copy, Debug, Default, PartialEq)]
681pub(crate) struct AuraSnapshot {
682    pub(crate) has_snapshot: bool,
683    pub(crate) attack_power: f64,
684    pub(crate) spell_power: f64,
685    pub(crate) critical_chance: f64,
686    pub(crate) versatility: f64,
687    pub(crate) mastery_multiplier: f64,
688    pub(crate) damage_multiplier: f64,
689}
690
691pub(crate) type AuraTransferState = BufferAuraTransferState;
692
693/// One requested semantic or focused metadata mutation for a finalized aura binding.
694#[derive(Clone, Copy, Debug)]
695pub(crate) enum AuraMutation {
696    Activate(AuraActivation),
697    Refresh(AuraRefresh),
698    Expire,
699    SetStacks(i32),
700    SetExpiry(f64),
701    SetSchedule(AuraSchedule),
702    SetNextTick(f64),
703    SetTimeRate(f64),
704    SetSnapshot(AuraSnapshot),
705    Transfer(AuraTransferState),
706    SetTiming {
707        expires_at: f64,
708        schedule: AuraSchedule,
709    },
710}
711
712#[derive(Clone, Copy, Debug, Eq, PartialEq)]
713struct SlotState {
714    floats: [u64; AURA_SLOT_FLOAT_FIELDS],
715    integers: [i32; AURA_SLOT_INTEGER_FIELDS],
716}
717
718impl SlotState {
719    fn capture(slot: &AuraSlot) -> Self {
720        Self {
721            floats: [
722                slot.expires_at.to_bits(),
723                slot.base_duration.to_bits(),
724                slot.next_tick.to_bits(),
725                slot.tick_interval.to_bits(),
726                slot.base_tick_interval.to_bits(),
727                slot.time_rate_multiplier.to_bits(),
728                slot.snapshot_ap.to_bits(),
729                slot.snapshot_sp.to_bits(),
730                slot.snapshot_crit.to_bits(),
731                slot.snapshot_vers.to_bits(),
732                slot.snapshot_mastery_mult.to_bits(),
733                slot.snapshot_damage_mult.to_bits(),
734            ],
735            integers: [
736                slot.stacks,
737                slot.max_stacks,
738                slot.remaining_ticks,
739                slot.snapshot_has,
740            ],
741        }
742    }
743}
744
745#[derive(Clone, Copy, Debug, Eq, PartialEq)]
746struct TotalsState {
747    occupied: bool,
748    stacks: i32,
749    snapshot_has: i32,
750    snapshot_ap: u64,
751    snapshot_sp: u64,
752    snapshot_crit: u64,
753    snapshot_vers: u64,
754    snapshot_mastery_mult: u64,
755    snapshot_damage_mult: u64,
756}
757
758impl TotalsState {
759    fn capture(slot: &AuraSlot) -> Self {
760        Self {
761            occupied: slot.is_occupied(),
762            stacks: slot.stacks,
763            snapshot_has: slot.snapshot_has,
764            snapshot_ap: slot.snapshot_ap.to_bits(),
765            snapshot_sp: slot.snapshot_sp.to_bits(),
766            snapshot_crit: slot.snapshot_crit.to_bits(),
767            snapshot_vers: slot.snapshot_vers.to_bits(),
768            snapshot_mastery_mult: slot.snapshot_mastery_mult.to_bits(),
769            snapshot_damage_mult: slot.snapshot_damage_mult.to_bits(),
770        }
771    }
772
773    fn totals_changed(self, after: Self, snapshot_relevant: bool) -> bool {
774        self.occupied != after.occupied
775            || (after.occupied && self.stacks != after.stacks)
776            || (after.occupied
777                && snapshot_relevant
778                && (self.snapshot_has != after.snapshot_has
779                    || self.snapshot_ap != after.snapshot_ap
780                    || self.snapshot_sp != after.snapshot_sp
781                    || self.snapshot_crit != after.snapshot_crit
782                    || self.snapshot_vers != after.snapshot_vers
783                    || self.snapshot_mastery_mult != after.snapshot_mastery_mult
784                    || self.snapshot_damage_mult != after.snapshot_damage_mult))
785    }
786
787    #[cfg(feature = "rotation-optimizations")]
788    fn rotation_direction(self, after: Self) -> Option<RotationMutationDirection> {
789        if self.occupied != after.occupied {
790            return Some(if after.occupied {
791                RotationMutationDirection::Rise
792            } else {
793                RotationMutationDirection::Fall
794            });
795        }
796
797        if !after.occupied || self.stacks == after.stacks {
798            return None;
799        }
800
801        Some(if after.stacks > self.stacks {
802            RotationMutationDirection::Rise
803        } else {
804            RotationMutationDirection::Fall
805        })
806    }
807}
808
809#[cfg(feature = "rotation-optimizations")]
810fn record_rotation_aura_state(
811    instance: &mut CombatInstance,
812    before: TotalsState,
813    after: TotalsState,
814    now: SimTime,
815) {
816    if let Some(direction) = before.rotation_direction(after) {
817        instance.runtime.rotation_mutations.record_semantic(
818            RotationSemanticMutation::AuraState,
819            direction,
820            now,
821        );
822    }
823}
824
825#[cfg(feature = "rotation-optimizations")]
826fn record_rotation_aura_mutations(
827    instance: &mut CombatInstance,
828    before_slot: AuraSlot,
829    after_slot: AuraSlot,
830    before_totals: TotalsState,
831    after_totals: TotalsState,
832    now: SimTime,
833) {
834    record_rotation_aura_state(instance, before_totals, after_totals, now);
835
836    if !before_slot.is_occupied()
837        || !after_slot.is_occupied()
838        || before_slot.expires_at.to_bits() == after_slot.expires_at.to_bits()
839    {
840        return;
841    }
842
843    let direction = if after_slot.expires_at > before_slot.expires_at {
844        RotationMutationDirection::Rise
845    } else {
846        RotationMutationDirection::Fall
847    };
848
849    instance.runtime.rotation_mutations.record_semantic(
850        RotationSemanticMutation::AuraTiming,
851        direction,
852        now,
853    );
854}
855
856impl CombatProgram {
857    #[expect(
858        clippy::unused_self,
859        reason = "the program method preserves provenance for program-bound aura bindings"
860    )]
861    pub(crate) fn capture_aura_transfer(
862        &self,
863        instance: &CombatInstance,
864        binding: ConcreteAuraBinding,
865    ) -> Option<AuraTransferState> {
866        BufferAuraTransferState::capture(instance.buffer.aura_at(binding.handle))
867    }
868
869    /// Atomically mutates one concrete aura and dispatches all finalized consumers.
870    pub(crate) fn mutate_aura(
871        &self,
872        instance: &mut CombatInstance,
873        binding: ConcreteAuraBinding,
874        mutation: AuraMutation,
875        now: SimTime,
876    ) -> bool {
877        let masks = self.effects.aura(binding.local);
878        let before_slot = *instance.buffer.aura_at(binding.handle);
879        let before = SlotState::capture(&before_slot);
880        let before_totals = TotalsState::capture(&before_slot);
881
882        apply_mutation(instance, binding, mutation);
883
884        let after_slot = *instance.buffer.aura_at(binding.handle);
885        let after = SlotState::capture(&after_slot);
886
887        if before == after {
888            return false;
889        }
890
891        let after_totals = TotalsState::capture(&after_slot);
892
893        #[cfg(feature = "rotation-optimizations")]
894        record_rotation_aura_mutations(
895            instance,
896            before_slot,
897            after_slot,
898            before_totals,
899            after_totals,
900            now,
901        );
902
903        wowlab_types::bench_metrics::record_planned_aura_bindings(1);
904
905        #[cfg(feature = "rotation-optimizations")]
906        if before_totals.occupied != after_totals.occupied {
907            instance
908                .active_aura_bindings
909                .set_occupied(binding, after_totals.occupied);
910        }
911
912        let classified_totals_change = masks.totals_relevant()
913            && before_totals.totals_changed(after_totals, masks.snapshot_totals_relevant());
914
915        if classified_totals_change && instance.aura_totals_revisions.bump(binding.key.affected()) {
916            instance.buff_totals_caches.clear();
917        }
918
919        if classified_totals_change
920            && binding.key.affected() == ActorId::Player
921            && masks.player_scaling_relevant()
922            && instance.aura_totals_revisions.bump_player_scaling()
923        {
924            instance.buff_totals_caches.clear_player_scaling();
925        }
926
927        self.mark_changed_aura_projections(instance, binding);
928
929        if before_totals.occupied != after_totals.occupied
930            || (after_totals.occupied && before_totals.stacks != after_totals.stacks)
931            || classified_totals_change
932        {
933            self.dispatch_aura_consumers(instance, binding, now);
934        }
935
936        true
937    }
938
939    #[cfg(test)]
940    #[expect(
941        clippy::unused_self,
942        reason = "the test query stays on the program API that owns aura revision semantics"
943    )]
944    pub(crate) fn aura_totals_revision(
945        &self,
946        instance: &CombatInstance,
947        actor: ActorId,
948    ) -> Option<u64> {
949        instance.aura_totals_revisions.get(actor)
950    }
951
952    pub(crate) fn reset_aura_slots(
953        &self,
954        instance: &mut CombatInstance,
955        token: crate::handler::AuraResetToken,
956    ) {
957        let scope = token.scope();
958
959        #[cfg(feature = "rotation-optimizations")]
960        {
961            let active = &mut instance.active_aura_bindings;
962            let buffer = &mut instance.buffer;
963            let expire = |binding: ConcreteAuraBinding| {
964                buffer
965                    .aura_at(binding.handle)
966                    .is_occupied()
967                    .then_some(())
968                    .expect("active aura registry contains only occupied concrete bindings");
969                buffer.expire_aura_at(binding.handle);
970            };
971
972            match scope {
973                crate::handler::AuraResetScope::All => active.drain_all(expire),
974                crate::handler::AuraResetScope::Player => {
975                    active.drain_actor(ActorId::Player, expire);
976                }
977            }
978        }
979
980        #[cfg(not(feature = "rotation-optimizations"))]
981        for binding in self.buffer_bindings.aura_bindings() {
982            let key = binding.key;
983            let selected = match scope {
984                crate::handler::AuraResetScope::All => true,
985                crate::handler::AuraResetScope::Player => key.affected() == ActorId::Player,
986            };
987
988            if selected {
989                instance.buffer.expire_aura_at(binding.handle);
990            }
991        }
992
993        match scope {
994            crate::handler::AuraResetScope::All => {
995                instance.aura_totals_revisions.reset();
996            }
997            crate::handler::AuraResetScope::Player => {
998                instance.aura_totals_revisions.reset_actor(ActorId::Player);
999            }
1000        }
1001
1002        instance.buff_totals_caches.clear();
1003        instance.buff_totals_caches.clear_player_scaling();
1004    }
1005
1006    fn mark_changed_aura_projections(
1007        &self,
1008        instance: &mut CombatInstance,
1009        binding: ConcreteAuraBinding,
1010    ) {
1011        wowlab_types::bench_metrics::record_planned_aura_projection_bindings(
1012            self.aura_dependencies.projections(binding.local).len(),
1013        );
1014
1015        for dependency in self.aura_dependencies.projections(binding.local) {
1016            let selected = self
1017                .aura_dependencies
1018                .projection_source(*dependency, instance.current_target());
1019
1020            if selected == Some(binding.handle) {
1021                instance.mark_aura_projection_dirty(
1022                    self.aura_dependencies.projection_index(*dependency),
1023                );
1024            }
1025        }
1026    }
1027
1028    fn dispatch_aura_consumers(
1029        &self,
1030        instance: &mut CombatInstance,
1031        binding: ConcreteAuraBinding,
1032        now: SimTime,
1033    ) {
1034        use crate::state::program::AuraDependencies as Dependency;
1035
1036        let local = binding.local;
1037        let key = binding.key;
1038        let consumers = self.aura_dependencies.consumers(local);
1039        let planned_spell_bindings = self
1040            .aura_dependencies
1041            .gate_spells(local)
1042            .len()
1043            .saturating_add(self.aura_dependencies.cost_spells(local).len())
1044            .saturating_add(self.aura_dependencies.recharge_spells(local).len())
1045            .saturating_add(self.aura_dependencies.maximum_charge_spells(local).len());
1046
1047        wowlab_types::bench_metrics::record_planned_aura_spell_bindings(planned_spell_bindings);
1048
1049        crate::systems::refresh_spell_gates_for(
1050            self,
1051            instance,
1052            self.aura_dependencies.gate_spells(local),
1053        );
1054        crate::systems::refresh_spell_costs_for(
1055            self,
1056            instance,
1057            self.aura_dependencies.cost_spells(local),
1058        );
1059        crate::systems::refresh_recharge_rates_for(
1060            self,
1061            instance,
1062            now,
1063            self.aura_dependencies.recharge_spells(local),
1064        );
1065        crate::systems::refresh_maximum_charges_for(
1066            self,
1067            instance,
1068            now,
1069            self.aura_dependencies.maximum_charge_spells(local),
1070        );
1071
1072        if consumers.contains(Dependency::STAMINA) {
1073            crate::systems::sync_player_health_from_stamina(self, instance);
1074        }
1075
1076        if consumers.intersects(Dependency::MAXIMUM_RESOURCE | Dependency::RESOURCE_REGEN) {
1077            crate::systems::refresh_primary_resource_projection(self, instance, now);
1078        }
1079
1080        if consumers.contains(Dependency::AURA_TIME) {
1081            crate::systems::refresh_aura_time_rates_for(
1082                self,
1083                instance,
1084                now,
1085                self.aura_dependencies
1086                    .aura_timing_bindings(local, key.affected()),
1087            );
1088        }
1089
1090        if consumers.contains(Dependency::PLAYER_SPEED) && key.affected() == ActorId::Player {
1091            crate::systems::reproject_player_auto_attack_speeds(self, instance, now);
1092        }
1093
1094        if consumers.intersects(Dependency::PET_STATS | Dependency::PRIMARY_STAT) {
1095            crate::systems::refresh_pet_stat_snapshots(self, instance);
1096        }
1097
1098        if consumers.intersects(
1099            Dependency::PET_ACTIONS | Dependency::RESOURCE_REGEN | Dependency::COMPANION_SPEED,
1100        ) {
1101            crate::systems::refresh_pet_actions_after_aura_change(self, instance, now);
1102        }
1103
1104        if consumers.contains(Dependency::COMPANION_SPEED) {
1105            crate::systems::reproject_pet_auto_attack_speeds(self, instance, now);
1106            crate::systems::reproject_guardian_action_speeds(self, instance, now);
1107        }
1108
1109        if consumers.contains(Dependency::TARGET_RANGE) && key.affected() == ActorId::Player {
1110            instance.mark_projection_dirty(
1111                crate::state::ProjectionDirty::TARGET_IDENTITY_RANGE_TRAVEL,
1112                now,
1113            );
1114        }
1115
1116        tracing::trace!(
1117            aura_id = key.aura().0,
1118            source = ?key.source(),
1119            affected = ?key.affected(),
1120            ?consumers,
1121            costs = self.aura_dependencies.cost_spells(local).len(),
1122            recharge = self.aura_dependencies.recharge_spells(local).len(),
1123            maximum_charges = self.aura_dependencies.maximum_charge_spells(local).len(),
1124            gates = self.aura_dependencies.gate_spells(local).len(),
1125            aura_timing = self.aura_dependencies.aura_timing(local).len(),
1126            projections = self.aura_dependencies.projections(local).len(),
1127            "AURA_MUTATION_DISPATCH"
1128        );
1129    }
1130}
1131
1132fn apply_mutation(
1133    instance: &mut CombatInstance,
1134    binding: ConcreteAuraBinding,
1135    mutation: AuraMutation,
1136) {
1137    let handle = binding.handle;
1138
1139    match mutation {
1140        AuraMutation::Activate(activation) => instance.buffer.activate_aura_at(
1141            handle,
1142            activation.expires_at,
1143            activation.base_duration,
1144            activation.stacks,
1145            activation.max_stacks,
1146        ),
1147        AuraMutation::Refresh(refresh) => {
1148            instance
1149                .buffer
1150                .refresh_aura_at(handle, refresh.expires_at, refresh.stacks);
1151        }
1152        AuraMutation::Expire => instance.buffer.expire_aura_at(handle),
1153        AuraMutation::SetStacks(stacks) => instance.buffer.set_aura_stacks_at(handle, stacks),
1154        AuraMutation::SetExpiry(expires_at) => {
1155            instance.buffer.set_aura_expiry_at(handle, expires_at);
1156        }
1157        AuraMutation::SetSchedule(schedule) => set_schedule(instance, handle, schedule),
1158        AuraMutation::SetNextTick(next_tick) => {
1159            instance.buffer.set_aura_next_tick_at(handle, next_tick);
1160        }
1161        AuraMutation::SetTimeRate(multiplier) => {
1162            instance.buffer.set_aura_time_rate_at(handle, multiplier);
1163        }
1164        AuraMutation::SetSnapshot(snapshot) => instance.buffer.set_aura_snapshot_at(
1165            handle,
1166            i32::from(snapshot.has_snapshot),
1167            snapshot.attack_power,
1168            snapshot.spell_power,
1169            snapshot.critical_chance,
1170            snapshot.versatility,
1171            snapshot.mastery_multiplier,
1172            snapshot.damage_multiplier,
1173        ),
1174        AuraMutation::Transfer(state) => instance.buffer.transfer_aura_at(handle, state),
1175        AuraMutation::SetTiming {
1176            expires_at,
1177            schedule,
1178        } => {
1179            instance.buffer.set_aura_expiry_at(handle, expires_at);
1180            set_schedule(instance, handle, schedule);
1181        }
1182    }
1183}
1184
1185fn set_schedule(
1186    instance: &mut CombatInstance,
1187    handle: wowlab_engine_domain::rotation::AuraSlotHandle,
1188    schedule: AuraSchedule,
1189) {
1190    instance.buffer.set_aura_schedule_at(
1191        handle,
1192        schedule.next_tick,
1193        schedule.tick_interval,
1194        schedule.base_tick_interval,
1195        schedule.time_rate_multiplier,
1196        schedule.remaining_ticks,
1197    );
1198}
1199
1200impl CombatInstance {
1201    #[cfg(feature = "rotation-optimizations")]
1202    pub(crate) fn pop_first_active_aura_binding(
1203        &mut self,
1204        actor: ActorId,
1205    ) -> Option<ConcreteAuraBinding> {
1206        self.active_aura_bindings.pop_first(actor)
1207    }
1208
1209    #[cfg(all(test, feature = "rotation-optimizations"))]
1210    pub(crate) fn for_each_active_aura_binding(
1211        &self,
1212        actor: ActorId,
1213        visit: impl FnMut(ConcreteAuraBinding),
1214    ) {
1215        self.active_aura_bindings.for_each(actor, visit);
1216    }
1217
1218    #[cfg(feature = "rotation-optimizations")]
1219    pub(crate) fn for_each_active_aura_intersection(
1220        &self,
1221        actor: ActorId,
1222        relevant: (&[u64], &[u64]),
1223        visit: impl FnMut(crate::state::LocalAuraIdx, ConcreteAuraBinding),
1224    ) {
1225        self.active_aura_bindings
1226            .for_each_intersection(actor, relevant, visit);
1227    }
1228
1229    #[cfg(feature = "rotation-optimizations")]
1230    pub(crate) fn for_each_active_static_total_aura(
1231        &self,
1232        actor: ActorId,
1233        relevant: &[u64],
1234        visit: impl FnMut(crate::state::LocalAuraIdx, ConcreteAuraBinding, u16),
1235    ) {
1236        self.active_aura_bindings
1237            .for_each_static_total(actor, relevant, visit);
1238    }
1239
1240    #[cfg(feature = "rotation-optimizations")]
1241    pub(crate) fn for_each_active_aura_local(
1242        &self,
1243        actor: ActorId,
1244        local: crate::state::LocalAuraIdx,
1245        visit: impl FnMut(ConcreteAuraBinding),
1246    ) {
1247        self.active_aura_bindings
1248            .for_each_local(actor, local, visit);
1249    }
1250
1251    #[cfg(feature = "rotation-optimizations")]
1252    pub(crate) fn first_active_aura_intersection(
1253        &self,
1254        actor: ActorId,
1255        relevant: &[u64],
1256    ) -> Option<ConcreteAuraBinding> {
1257        if relevant.is_empty() {
1258            return None;
1259        }
1260
1261        self.active_aura_bindings
1262            .first_intersection(actor, relevant)
1263    }
1264
1265    #[cfg(all(test, feature = "rotation-optimizations"))]
1266    pub(crate) fn test_only_active_static_total_auras(
1267        &self,
1268        actor: ActorId,
1269        relevant: &[u64],
1270    ) -> Vec<(crate::state::LocalAuraIdx, ConcreteAuraBinding, u16)> {
1271        let mut entries = Vec::new();
1272
1273        self.for_each_active_static_total_aura(actor, relevant, |local, binding, mask| {
1274            entries.push((local, binding, mask));
1275        });
1276
1277        entries
1278    }
1279
1280    pub(crate) fn actor_aura_revision(&self, actor: ActorId) -> Option<u64> {
1281        self.aura_totals_revisions.get(actor)
1282    }
1283
1284    #[cfg(all(test, feature = "rotation-optimizations"))]
1285    pub(crate) fn test_only_active_aura_locals(
1286        &self,
1287        actor: ActorId,
1288    ) -> Vec<crate::state::LocalAuraIdx> {
1289        let mut locals = Vec::new();
1290
1291        self.for_each_active_aura_binding(actor, |binding| locals.push(binding.local));
1292
1293        locals
1294    }
1295
1296    pub(crate) fn player_scaling_revision(&self) -> u64 {
1297        self.aura_totals_revisions.player_scaling
1298    }
1299
1300    pub(crate) fn cached_player_scaling_buffs(
1301        &self,
1302        revision: u64,
1303    ) -> Option<crate::state::buff_effect::PlayerScalingBuffs> {
1304        self.buff_totals_caches.player_scaling(revision)
1305    }
1306
1307    pub(crate) fn cache_player_scaling_buffs(
1308        &self,
1309        revision: u64,
1310        buffs: crate::state::buff_effect::PlayerScalingBuffs,
1311    ) {
1312        self.buff_totals_caches
1313            .store_player_scaling(revision, buffs);
1314    }
1315
1316    pub(crate) fn cached_actor_buff_totals(
1317        &self,
1318        actor: ActorId,
1319        revision: u64,
1320    ) -> Option<crate::state::BuffTotals> {
1321        self.buff_totals_caches.actor(actor, revision)
1322    }
1323
1324    pub(crate) fn cache_actor_buff_totals(
1325        &self,
1326        actor: ActorId,
1327        revision: u64,
1328        totals: &crate::state::BuffTotals,
1329    ) {
1330        self.buff_totals_caches.store_actor(actor, revision, totals);
1331    }
1332
1333    pub(crate) fn cached_actor_only_buff_totals(
1334        &self,
1335        actor: ActorId,
1336        revision: u64,
1337        owner_revision: u64,
1338    ) -> Option<crate::state::BuffTotals> {
1339        self.buff_totals_caches
1340            .actor_only(actor, revision, owner_revision)
1341    }
1342
1343    pub(crate) fn cache_actor_only_buff_totals(
1344        &self,
1345        actor: ActorId,
1346        revision: u64,
1347        owner_revision: u64,
1348        totals: &crate::state::BuffTotals,
1349    ) {
1350        self.buff_totals_caches
1351            .store_actor_only(actor, revision, owner_revision, totals);
1352    }
1353}
1354
1355#[cfg(test)]
1356impl CombatInstance {
1357    pub(crate) fn test_only_set_aura_revision(&mut self, actor: ActorId, revision: u64) {
1358        if let Some(current) = self.aura_totals_revisions.get_mut(actor) {
1359            *current = revision;
1360        }
1361    }
1362
1363    pub(crate) fn test_only_buff_cache_generation(&self) -> u64 {
1364        self.aura_totals_revisions.buff_cache_generation
1365    }
1366}
1367
1368/// Independent full-scan oracle for tests of the incremental actor revision contract.
1369#[cfg(test)]
1370fn reference_recompute_actor_totals_state(
1371    program: &CombatProgram,
1372    instance: &CombatInstance,
1373    actor: ActorId,
1374) -> Vec<(AuraKey, i32, Option<[u64; 7]>)> {
1375    program
1376        .buffer_bindings
1377        .aura_bindings()
1378        .filter(|binding| binding.key.affected() == actor)
1379        .filter_map(|binding| {
1380            let masks = program.effects.aura(binding.local);
1381            let slot = instance.buffer.aura_at(binding.handle);
1382
1383            if !masks.totals_relevant() || !slot.is_occupied() {
1384                return None;
1385            }
1386
1387            let snapshot = masks.snapshot_totals_relevant().then(|| {
1388                [
1389                    u64::from(slot.snapshot_has != 0),
1390                    slot.snapshot_ap.to_bits(),
1391                    slot.snapshot_sp.to_bits(),
1392                    slot.snapshot_crit.to_bits(),
1393                    slot.snapshot_vers.to_bits(),
1394                    slot.snapshot_mastery_mult.to_bits(),
1395                    slot.snapshot_damage_mult.to_bits(),
1396                ]
1397            });
1398
1399            Some((binding.key, slot.stacks, snapshot))
1400        })
1401        .collect()
1402}
1403
1404#[cfg(test)]
1405#[path = "auras/tests.rs"]
1406mod tests;