1#[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 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#[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 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#[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;