1#[cfg(feature = "rotation-optimizations")]
6use wowlab_engine_domain::rotation::AuraProjectionSlotHandle;
7#[cfg(feature = "rotation-optimizations")]
8use wowlab_engine_domain::rotation::{
9 PreparedActionDependencyPlan, PreparedRotationChangeDirection, PreparedRotationChangeKey,
10};
11use wowlab_engine_domain::{
12 dbc::{ResolvedGameDataSemanticExt as _, SpellSchoolMask, SpellShapeshiftFormFlags},
13 rotation::{
14 AuraSlotHandle, ItemSlotHandle, PreparedRotation, ResourceSlotHandle, SpellProjectionUsage,
15 SpellReferenceUsage, SpellSlotHandle,
16 },
17};
18use wowlab_engine_gamedata::ResolvedGameData;
19#[cfg(feature = "rotation-optimizations")]
20use wowlab_types::sim::{AuraProjectionKey, SimTime};
21use wowlab_types::{
22 combat::ResourceType,
23 game::GearSlot,
24 sim::{ActorId, AuraIdx, AuraKey, AuraOn, EnemyIdx, IntMap, SpellIdx},
25};
26
27use super::BufferBindings;
28#[cfg(feature = "rotation-optimizations")]
29use super::{
30 DECISION_ACTOR_STATE, DECISION_AURA_STATE, DECISION_AURA_TIMING, DECISION_ENCOUNTER_POPULATION,
31 DECISION_EQUIPMENT_STATE, DECISION_HISTORY_STATE, DECISION_PLAYER_HEALTH, DECISION_SPATIAL,
32 DECISION_SPELL_STATE, DECISION_TARGET_HEALTH, DECISION_TARGET_IDENTITY,
33 EFFECTIVE_CANDIDATE_COUNT, ROTATION_DECISION_MODE_COUNT, ROTATION_DECISION_MODES,
34 RotationDecisionMode, RotationDecisionSubscription,
35};
36use crate::state::{AuraData, LocalAuraIdx, LocalSpellIdx, SpellData};
37
38#[derive(Debug, thiserror::Error)]
39pub(crate) enum RotationPlanError {
40 #[error("rotation plan contains {count} spells, exceeding local u8 capacity")]
41 TooManySpells {
42 count: usize,
43 #[source]
44 error: std::num::TryFromIntError,
45 },
46 #[error("rotation references unresolved spell {spell_id}")]
47 UnresolvedSpell { spell_id: u32 },
48 #[error(
49 "rotation spell {spell_id} resolves to local index {local}, whose definition is spell {actual_spell_id}"
50 )]
51 InconsistentSpell {
52 spell_id: u32,
53 local: u8,
54 actual_spell_id: u32,
55 },
56 #[error("rotation spell {spell_id} has inconsistent override coordinates")]
57 InconsistentOverride { spell_id: u32 },
58 #[error("rotation spell {spell_id} references unresolved override spell {override_spell_id}")]
59 UnresolvedOverride {
60 spell_id: u32,
61 override_spell_id: u32,
62 },
63 #[error("rotation spell {spell_id} references unresolved override aura {aura_id}")]
64 UnresolvedOverrideAura { spell_id: u32, aura_id: u32 },
65 #[error("rotation spell {spell_id} override aura {aura_id} is not a player aura")]
66 UnsupportedOverrideAura { spell_id: u32, aura_id: u32 },
67 #[error("cast profile for spell {spell_id} references unresolved {kind} aura {aura_id}")]
68 UnresolvedCastAura {
69 spell_id: u32,
70 kind: &'static str,
71 aura_id: u32,
72 },
73 #[error(
74 "cast profile for spell {spell_id} references {kind} aura {aura_id} with unsupported target {on:?}"
75 )]
76 UnsupportedCastAura {
77 spell_id: u32,
78 kind: &'static str,
79 aura_id: u32,
80 on: AuraOn,
81 },
82 #[error("cast profile for spell {spell_id} is missing concrete {kind} aura {aura_id} binding")]
83 MissingCastAuraBinding {
84 spell_id: u32,
85 kind: &'static str,
86 aura_id: u32,
87 },
88 #[error("cast profile target-aura handle range exceeds the supported finalized range")]
89 CastAuraRangeOverflow,
90 #[error("rotation spell references are not strictly ordered at spell {spell_id}")]
91 InconsistentReferenceOrder { spell_id: u32 },
92 #[error("cast reference for spell {spell_id} is missing its availability usage")]
93 CastWithoutAvailability { spell_id: u32 },
94 #[error("item-use slot {slot:?} references unresolved spell {spell_id}")]
95 UnresolvedItemUse { slot: GearSlot, spell_id: u32 },
96 #[error("item-use slot {slot:?} has no frozen item-slot binding")]
97 MissingItemSlot { slot: GearSlot },
98 #[error("item-use slot {slot:?} is registered more than once")]
99 DuplicateItemSlot { slot: GearSlot },
100 #[cfg(feature = "rotation-optimizations")]
101 #[error("rotation decision dependencies are incomplete for {mode} mode: {reason}")]
102 IncompleteDecisionMode {
103 mode: RotationDecisionMode,
104 reason: &'static str,
105 },
106 #[cfg(feature = "rotation-optimizations")]
107 #[error(
108 "rotation decision dependencies are incomplete for spell {spell_id} in {mode} mode: {reason}"
109 )]
110 IncompleteDecisionCandidate {
111 mode: RotationDecisionMode,
112 spell_id: u32,
113 reason: &'static str,
114 },
115 #[cfg(feature = "rotation-optimizations")]
116 #[error("rotation decision threshold references missing {on:?} aura projection {aura_id}")]
117 MissingDecisionAuraProjection { aura_id: u32, on: AuraOn },
118 #[cfg(feature = "rotation-optimizations")]
119 #[error("rotation decision threshold references missing {resource:?} resource slot")]
120 MissingDecisionResource { resource: ResourceType },
121}
122
123#[derive(Clone, Copy, Debug)]
125pub(crate) struct RotationOverrideBinding {
126 pub(crate) aura_to_consume: Option<LocalAuraIdx>,
127 pub(crate) aura_min_stacks: u8,
128 pub(crate) aura_handle: AuraSlotHandle,
129 pub(crate) effective_local: LocalSpellIdx,
130 pub(crate) shares_base_cooldown: bool,
131}
132
133#[derive(Clone, Copy, Debug)]
135pub(crate) struct RotationSpellBinding {
136 pub(crate) spell: SpellIdx,
137 pub(crate) local: LocalSpellIdx,
138 pub(crate) spell_slot: SpellSlotHandle,
139 pub(crate) override_binding: Option<RotationOverrideBinding>,
140}
141
142const _: () = assert!(
143 size_of::<RotationSpellBinding>() <= 64,
144 "rotation spell bindings must remain compact finalized identities"
145);
146
147#[derive(Clone, Copy, Debug)]
149pub(crate) enum CastAuraCoordinate {
150 Player(AuraSlotHandle),
151 Target { offset: u32, len: u16 },
152}
153
154#[derive(Clone, Copy, Debug)]
156pub(crate) struct CastAuraRequirement {
157 pub(crate) aura_id: u32,
158 pub(crate) local: LocalAuraIdx,
159 pub(crate) coordinate: CastAuraCoordinate,
160 pub(crate) min_stacks: u8,
161}
162
163#[derive(Clone, Copy, Debug, Default)]
165pub(crate) struct CastAuraStateProfile {
166 pub(crate) required_caster: Option<crate::state::AuraState>,
167 pub(crate) required_target: Option<crate::state::AuraState>,
168 pub(crate) excluded_caster: Option<crate::state::AuraState>,
169 pub(crate) excluded_target: Option<crate::state::AuraState>,
170}
171
172#[derive(Clone, Copy, Debug, Default)]
174pub(crate) struct CastAuraHandleRange {
175 offset: u32,
176 len: u16,
177}
178
179#[derive(Clone, Copy, Debug)]
181#[expect(
182 clippy::struct_excessive_bools,
183 reason = "the cast profile stores independent finalized predicate flags for direct evaluation"
184)]
185pub(crate) struct CastProfile {
186 pub(crate) local: LocalSpellIdx,
187 pub(crate) spell_id: u32,
188 pub(crate) hostile_min_range: f64,
189 pub(crate) hostile_max_range: f64,
190 pub(crate) requires_hostile_target: bool,
191 pub(crate) ignores_line_of_sight: bool,
192 pub(crate) school_mask: SpellSchoolMask,
193 pub(crate) damage_pushback: bool,
194 pub(crate) channel_duration_pushback: bool,
195 pub(crate) main_hand_available: bool,
196 pub(crate) off_hand_available: bool,
197 pub(crate) equipped_item_matches: bool,
198 pub(crate) primary_resource: Option<ResourceSlotHandle>,
199 pub(crate) secondary_resource: Option<ResourceSlotHandle>,
200 pub(crate) gating_aura: Option<CastAuraRequirement>,
201 pub(crate) cost_bypass_aura: Option<CastAuraRequirement>,
202 pub(crate) cooldown_bypass_aura: Option<CastAuraRequirement>,
203 pub(crate) consume_aura_on_cast: Option<CastAuraRequirement>,
204 pub(crate) max_empower_aura: Option<CastAuraRequirement>,
205 pub(crate) instant_cast_aura: Option<CastAuraRequirement>,
206 pub(crate) channel_tick_cost_aura: Option<CastAuraRequirement>,
207 pub(crate) channel_tick_damage_aura: Option<CastAuraRequirement>,
208 pub(crate) required_caster_aura: Option<CastAuraRequirement>,
209 pub(crate) required_target_aura: Option<CastAuraRequirement>,
210 pub(crate) excluded_caster_aura: Option<CastAuraRequirement>,
211 pub(crate) excluded_target_aura: Option<CastAuraRequirement>,
212 pub(crate) aura_states: CastAuraStateProfile,
213 pub(crate) shapeshift_required_mask: u64,
214 pub(crate) shapeshift_excluded_mask: u64,
215 pub(crate) allow_while_unshifted: bool,
216 pub(crate) requires_unshifted: bool,
217 pub(crate) requires_stealth: bool,
218 stance_exceptions: CastAuraHandleRange,
219 passive_stance_exception: bool,
220}
221
222#[derive(Clone, Copy, Debug)]
224pub(crate) struct RotationProjectionBinding {
225 pub(crate) spell: RotationSpellBinding,
226 pub(crate) usage: SpellProjectionUsage,
227}
228
229#[derive(Clone, Copy, Debug)]
231pub(crate) struct ItemUseBinding {
232 pub(crate) item_slot: ItemSlotHandle,
233 pub(crate) action: RotationSpellBinding,
234}
235
236#[derive(Clone, Copy, Debug, Eq, PartialEq)]
237#[cfg(feature = "rotation-optimizations")]
238enum RotationDecisionThreshold {
239 ResourceRise(ResourceDecisionThreshold),
240 TargetHealthFall {
241 percent_bits: u64,
242 strict: bool,
243 },
244 AuraRemainingFall {
245 handle: AuraProjectionSlotHandle,
246 seconds_bits: u64,
247 strict: bool,
248 },
249 CooldownRemainingFall {
250 local: LocalSpellIdx,
251 seconds_bits: u64,
252 strict: bool,
253 },
254}
255
256#[derive(Clone, Copy, Debug, Eq, PartialEq)]
257#[cfg(feature = "rotation-optimizations")]
258struct ResourceDecisionThreshold {
259 handle: ResourceSlotHandle,
260 resource: ResourceType,
261 value_bits: u64,
262 strict: bool,
263}
264
265#[cfg(feature = "rotation-optimizations")]
266impl RotationDecisionThreshold {
267 fn ready_at(
268 self,
269 program: &crate::state::CombatProgram,
270 instance: &crate::state::CombatInstance,
271 now: SimTime,
272 ) -> Option<SimTime> {
273 match self {
274 Self::ResourceRise(threshold) => {
275 resource_threshold_ready_at(program, instance, threshold, now)
276 }
277 Self::TargetHealthFall {
278 percent_bits,
279 strict,
280 } => target_health_threshold_ready_at(instance, percent_bits, strict, now),
281 Self::AuraRemainingFall {
282 handle,
283 seconds_bits,
284 strict,
285 } => aura_remaining_threshold_ready_at(instance, handle, seconds_bits, strict, now),
286 Self::CooldownRemainingFall {
287 local,
288 seconds_bits,
289 strict,
290 } => cooldown_remaining_threshold_ready_at(
291 program,
292 instance,
293 local,
294 seconds_bits,
295 strict,
296 now,
297 ),
298 }
299 }
300}
301
302#[cfg(feature = "rotation-optimizations")]
303fn resource_threshold_ready_at(
304 program: &crate::state::CombatProgram,
305 instance: &crate::state::CombatInstance,
306 threshold: ResourceDecisionThreshold,
307 now: SimTime,
308) -> Option<SimTime> {
309 let value = f64::from_bits(threshold.value_bits);
310 let state = instance.buffer.resource_at(threshold.handle);
311
312 if value > state.max || (threshold.strict && value >= state.max) || state.regen_per_sec <= 0.0 {
313 return None;
314 }
315
316 if threshold.resource == ResourceType::Runes {
317 let required = if threshold.strict {
318 value.floor() + 1.0
319 } else {
320 value.ceil()
321 };
322 let deficit = (required - state.current).max(0.0);
323 let needed =
324 usize::try_from(wowlab_types::numeric::f64_to_u32_saturating_ceil(deficit)).ok()?;
325 let seconds = crate::systems::rune_time_to_regen(program, instance, needed)?;
326
327 return decision_crossing_time(now.as_secs_f64() + seconds, false, now);
328 }
329
330 if state.current > value || (!threshold.strict && state.current >= value) {
331 return None;
332 }
333
334 let seconds = (value - state.current) / state.regen_per_sec;
335
336 decision_crossing_time(now.as_secs_f64() + seconds, threshold.strict, now)
337}
338
339#[cfg(feature = "rotation-optimizations")]
340fn target_health_threshold_ready_at(
341 instance: &crate::state::CombatInstance,
342 percent_bits: u64,
343 strict: bool,
344 now: SimTime,
345) -> Option<SimTime> {
346 let target = instance.current_target()?;
347 let enemy = instance.enemy(target)?;
348 let seconds = enemy.scripted_health_threshold_seconds(f64::from_bits(percent_bits))?;
349
350 decision_crossing_time(seconds, strict, now)
351}
352
353#[cfg(feature = "rotation-optimizations")]
354fn aura_remaining_threshold_ready_at(
355 instance: &crate::state::CombatInstance,
356 handle: AuraProjectionSlotHandle,
357 seconds_bits: u64,
358 strict: bool,
359 now: SimTime,
360) -> Option<SimTime> {
361 let aura = instance.buffer.aura_projection_at(handle);
362
363 if !aura.is_occupied() {
364 return None;
365 }
366
367 let seconds = aura.expires_at - f64::from_bits(seconds_bits).max(0.0);
368
369 decision_crossing_time(seconds, strict, now)
370}
371
372#[cfg(feature = "rotation-optimizations")]
373fn cooldown_remaining_threshold_ready_at(
374 program: &crate::state::CombatProgram,
375 instance: &crate::state::CombatInstance,
376 local: LocalSpellIdx,
377 seconds_bits: u64,
378 strict: bool,
379 now: SimTime,
380) -> Option<SimTime> {
381 let cooldown = program.buffer_cooldown(instance, local)?;
382 let seconds = cooldown.ready_at - f64::from_bits(seconds_bits).max(0.0);
383
384 decision_crossing_time(seconds, strict, now)
385}
386
387#[cfg(feature = "rotation-optimizations")]
388fn decision_crossing_time(seconds: f64, strict: bool, now: SimTime) -> Option<SimTime> {
389 let rounded = SimTime::from_secs_f64(seconds);
390 let rounded_seconds = rounded.as_secs_f64();
391 let ready_at = if rounded_seconds < seconds || (strict && rounded_seconds <= seconds) {
392 rounded.saturating_add(SimTime::from_millis(1))
393 } else {
394 rounded
395 };
396
397 (ready_at > now).then_some(ready_at)
398}
399
400#[derive(Clone, Copy)]
401pub(crate) struct RotationPlanInputs<'a> {
402 pub(crate) spells: &'a [SpellData],
403 pub(crate) spell_by_id: &'a IntMap<u32, LocalSpellIdx>,
404 pub(crate) auras: &'a [AuraData],
405 pub(crate) aura_by_id: &'a IntMap<u32, LocalAuraIdx>,
406 pub(crate) bindings: &'a BufferBindings,
407 pub(crate) passive_driver_effects: &'a [crate::state::PassiveDriverEffect],
408 pub(crate) primary_resource_type: Option<ResourceType>,
409 pub(crate) secondary_resource_type: Option<ResourceType>,
410 pub(crate) enemy_count: usize,
411 pub(crate) item_use_spells: &'a [(GearSlot, u32)],
412}
413
414fn gear_slot_index(slot: GearSlot) -> usize {
415 usize::from(slot as u8)
417}
418
419fn compile_item_uses(
420 definitions: &[(GearSlot, u32)],
421 spell_by_id: &IntMap<u32, LocalSpellIdx>,
422 local_bindings: &[RotationSpellBinding],
423 bindings: &BufferBindings,
424) -> Result<Box<[Option<ItemUseBinding>]>, RotationPlanError> {
425 let mut item_uses = vec![None; usize::from(u8::MAX) + 1];
426
427 for &(slot, spell_id) in definitions {
428 let local = spell_by_id
429 .get(&spell_id)
430 .copied()
431 .ok_or(RotationPlanError::UnresolvedItemUse { slot, spell_id })?;
432 let action = local_bindings
433 .get(local.as_usize())
434 .copied()
435 .ok_or(RotationPlanError::UnresolvedItemUse { slot, spell_id })?;
436 let item_slot = bindings
437 .item(slot.slug())
438 .ok_or(RotationPlanError::MissingItemSlot { slot })?;
439 let entry = &mut item_uses[gear_slot_index(slot)];
441
442 if entry.is_some() {
443 return Err(RotationPlanError::DuplicateItemSlot { slot });
444 }
445
446 *entry = Some(ItemUseBinding { item_slot, action });
447 }
448
449 Ok(item_uses.into_boxed_slice())
450}
451
452#[derive(Debug)]
454pub(crate) struct RotationPlan {
455 local_bindings: Box<[RotationSpellBinding]>,
456 #[cfg(test)]
457 bindings: Box<[RotationSpellBinding]>,
458 availability: Box<[RotationSpellBinding]>,
459 #[cfg(test)]
460 cast_actions: Box<[RotationSpellBinding]>,
461 cast_profiles: Box<[CastProfile]>,
462 cast_target_aura_handles: Box<[AuraSlotHandle]>,
463 stance_exception_handles: Box<[AuraSlotHandle]>,
464 shapeshift_form_flags: Box<[i32]>,
465 projections: Box<[RotationProjectionBinding]>,
466 item_uses: Box<[Option<ItemUseBinding>]>,
467 #[cfg(feature = "rotation-optimizations")]
468 decision_subscriptions: [RotationDecisionSubscription; ROTATION_DECISION_MODE_COUNT],
469 #[cfg(feature = "rotation-optimizations")]
470 decision_thresholds: Box<[RotationDecisionThreshold]>,
471 #[cfg(feature = "rotation-optimizations")]
472 may_cast_off_gcd: bool,
473}
474
475impl RotationPlan {
476 pub(crate) fn compile(
479 prepared: &PreparedRotation,
480 inputs: RotationPlanInputs<'_>,
481 game_data: &ResolvedGameData,
482 ) -> Result<Self, RotationPlanError> {
483 let RotationPlanInputs {
484 spells,
485 spell_by_id,
486 auras,
487 aura_by_id,
488 bindings,
489 passive_driver_effects,
490 primary_resource_type,
491 secondary_resource_type,
492 enemy_count,
493 item_use_spells,
494 } = inputs;
495
496 let references = prepared.spell_references();
497 let mut cast_target_aura_handles = Vec::new();
498 let mut stance_exception_handles = Vec::new();
499 let cast_profiles = spells
500 .iter()
501 .enumerate()
502 .map(|(index, spell)| {
503 let local =
504 u8::try_from(index).map_err(|error| RotationPlanError::TooManySpells {
505 count: index + 1,
506 error,
507 })?;
508
509 compile_cast_profile(
510 LocalSpellIdx::new(local),
511 spell,
512 auras,
513 aura_by_id,
514 bindings,
515 passive_driver_effects,
516 primary_resource_type,
517 secondary_resource_type,
518 enemy_count,
519 game_data,
520 &mut cast_target_aura_handles,
521 &mut stance_exception_handles,
522 )
523 })
524 .collect::<Result<Vec<_>, _>>()?;
525 let local_bindings = spells
526 .iter()
527 .map(|spell| {
528 bind_spell(
529 SpellIdx::from_raw(spell.spell_id),
530 spells,
531 spell_by_id,
532 auras,
533 aura_by_id,
534 bindings,
535 )
536 })
537 .collect::<Result<Vec<_>, _>>()?;
538 let item_uses = compile_item_uses(item_use_spells, spell_by_id, &local_bindings, bindings)?;
539 #[cfg(test)]
540 let mut all = Vec::with_capacity(references.len());
541 let mut availability = Vec::with_capacity(references.len());
542 #[cfg(test)]
543 let mut cast_actions = Vec::with_capacity(references.len());
544 let mut projections = Vec::with_capacity(references.len());
545 let mut previous = None;
546 #[cfg(feature = "rotation-optimizations")]
547 let mut may_cast_off_gcd = false;
548
549 for reference in references {
550 let spell = reference.spell();
551
552 if previous.is_some_and(|previous| previous >= spell.as_u32()) {
553 return Err(RotationPlanError::InconsistentReferenceOrder {
554 spell_id: spell.as_u32(),
555 });
556 }
557
558 previous = Some(spell.as_u32());
559 let local =
560 spell_by_id
561 .get(&spell.as_u32())
562 .ok_or(RotationPlanError::UnresolvedSpell {
563 spell_id: spell.as_u32(),
564 })?;
565 let binding = local_bindings.get(local.as_usize()).copied().ok_or(
566 RotationPlanError::UnresolvedSpell {
567 spell_id: spell.as_u32(),
568 },
569 )?;
570 let usage = reference.usage();
571
572 if usage.contains(SpellReferenceUsage::CAST)
573 && !usage.contains(SpellReferenceUsage::AVAILABILITY)
574 {
575 return Err(RotationPlanError::CastWithoutAvailability {
576 spell_id: spell.as_u32(),
577 });
578 }
579
580 #[cfg(test)]
581 all.push(binding);
582
583 if usage.contains(SpellReferenceUsage::AVAILABILITY) {
584 availability.push(binding);
585 }
586
587 #[cfg(test)]
588 if usage.contains(SpellReferenceUsage::CAST) {
589 cast_actions.push(binding);
590 }
591
592 #[cfg(feature = "rotation-optimizations")]
593 if usage.contains(SpellReferenceUsage::CAST) {
594 let (off_gcd, _) = binding_lock_capabilities(binding, spells);
595
596 may_cast_off_gcd |= off_gcd;
597 }
598
599 if !reference.projection_usage().is_empty() {
600 projections.push(RotationProjectionBinding {
601 spell: binding,
602 usage: reference.projection_usage(),
603 });
604 }
605 }
606
607 let mut shapeshift_form_flags = vec![0_i32; usize::from(u8::MAX) + 1];
608
609 for aura in auras {
610 if aura.shapeshift_form == 0 {
611 continue;
612 }
613
614 if let Ok(form) = usize::try_from(aura.shapeshift_form)
615 && let Some(flags) = shapeshift_form_flags.get_mut(form)
616 {
617 *flags = aura.shapeshift_form_flags;
618 }
619 }
620
621 #[cfg(feature = "rotation-optimizations")]
622 for &slot in prepared.item_use_slots() {
623 if let Some(item_use) = item_uses.get(gear_slot_index(slot)).copied().flatten() {
624 let (off_gcd, _) = binding_lock_capabilities(item_use.action, spells);
625
626 may_cast_off_gcd |= off_gcd;
627 }
628 }
629
630 #[cfg(feature = "rotation-optimizations")]
631 let decision_subscriptions = compile_decision_subscriptions(DecisionSubscriptionInputs {
632 prepared,
633 spells,
634 spell_by_id,
635 bindings: &local_bindings,
636 profiles: &cast_profiles,
637 primary_resource_type,
638 secondary_resource_type,
639 })?;
640 #[cfg(feature = "rotation-optimizations")]
641 let decision_thresholds =
642 compile_decision_thresholds(prepared, spells, spell_by_id, &local_bindings)?;
643
644 Ok(Self {
645 local_bindings: local_bindings.into_boxed_slice(),
646 #[cfg(test)]
647 bindings: all.into_boxed_slice(),
648 availability: availability.into_boxed_slice(),
649 #[cfg(test)]
650 cast_actions: cast_actions.into_boxed_slice(),
651 cast_profiles: cast_profiles.into_boxed_slice(),
652 cast_target_aura_handles: cast_target_aura_handles.into_boxed_slice(),
653 stance_exception_handles: stance_exception_handles.into_boxed_slice(),
654 shapeshift_form_flags: shapeshift_form_flags.into_boxed_slice(),
655 projections: projections.into_boxed_slice(),
656 item_uses,
657 #[cfg(feature = "rotation-optimizations")]
658 decision_subscriptions,
659 #[cfg(feature = "rotation-optimizations")]
660 decision_thresholds,
661 #[cfg(feature = "rotation-optimizations")]
662 may_cast_off_gcd,
663 })
664 }
665
666 #[cfg(test)]
667 pub(crate) fn bindings(&self) -> &[RotationSpellBinding] {
668 &self.bindings
669 }
670
671 pub(crate) fn availability(&self) -> &[RotationSpellBinding] {
672 &self.availability
673 }
674
675 #[cfg(feature = "rotation-optimizations")]
676 pub(crate) fn availability_binding(&self, spell_id: u32) -> Option<RotationSpellBinding> {
677 self.availability
678 .binary_search_by_key(&spell_id, |binding| binding.spell.as_u32())
679 .ok()
680 .and_then(|index| self.availability.get(index).copied())
681 }
682
683 pub(crate) fn cast_profile(&self, local: LocalSpellIdx) -> CastProfile {
684 *self.cast_profile_ref(local)
685 }
686
687 pub(crate) fn cast_profile_ref(&self, local: LocalSpellIdx) -> &CastProfile {
688 &self.cast_profiles[local.as_usize()]
690 }
691
692 pub(crate) fn aura_stacks(
693 &self,
694 instance: &crate::state::CombatInstance,
695 requirement: CastAuraRequirement,
696 target: EnemyIdx,
697 ) -> i32 {
698 let handle = match requirement.coordinate {
699 CastAuraCoordinate::Player(handle) => Some(handle),
700 CastAuraCoordinate::Target { offset, len } => {
701 let index = target.as_usize();
702
703 (index < usize::from(len))
704 .then(|| usize::try_from(offset).ok()?.checked_add(index))
705 .flatten()
706 .and_then(|index| self.cast_target_aura_handles.get(index).copied())
707 }
708 };
709
710 handle.map_or(0, |handle| {
711 let aura = instance.buffer.aura_at(handle);
712
713 if aura.is_occupied() { aura.stacks } else { 0 }
714 })
715 }
716
717 pub(crate) fn aura_active(
718 &self,
719 instance: &crate::state::CombatInstance,
720 requirement: CastAuraRequirement,
721 target: EnemyIdx,
722 ) -> bool {
723 self.aura_stacks(instance, requirement, target) >= i32::from(requirement.min_stacks.max(1))
724 }
725
726 pub(crate) fn stance_exception_active(
727 &self,
728 instance: &crate::state::CombatInstance,
729 profile: &CastProfile,
730 ) -> bool {
731 if profile.passive_stance_exception {
732 return true;
733 }
734
735 let start = usize::try_from(profile.stance_exceptions.offset).unwrap_or(usize::MAX);
736 let end = start.saturating_add(usize::from(profile.stance_exceptions.len));
737
738 self.stance_exception_handles
739 .get(start..end)
740 .is_some_and(|handles| {
741 handles
742 .iter()
743 .any(|&handle| instance.buffer.aura_at(handle).is_occupied())
744 })
745 }
746
747 pub(crate) fn shapeshift_form_flags(&self, form: u8) -> SpellShapeshiftFormFlags {
748 self.shapeshift_form_flags
749 .get(usize::from(form))
750 .copied()
751 .map_or_else(SpellShapeshiftFormFlags::empty, |flags| {
752 SpellShapeshiftFormFlags::from_bits_retain(flags)
753 })
754 }
755
756 pub(crate) fn local_binding(&self, local: LocalSpellIdx) -> RotationSpellBinding {
757 self.local_bindings[local.as_usize()]
759 }
760
761 pub(crate) fn projections(&self) -> &[RotationProjectionBinding] {
762 &self.projections
763 }
764
765 pub(crate) fn item_use(&self, slot: GearSlot) -> Option<ItemUseBinding> {
766 self.item_uses.get(gear_slot_index(slot)).copied().flatten()
767 }
768
769 pub(crate) fn item_uses(&self) -> impl Iterator<Item = ItemUseBinding> + '_ {
770 self.item_uses.iter().copied().flatten()
771 }
772
773 #[cfg(feature = "rotation-optimizations")]
774 pub(crate) const fn decision_subscription(
775 &self,
776 mode: RotationDecisionMode,
777 ) -> &RotationDecisionSubscription {
778 &self.decision_subscriptions[mode.index()]
780 }
781
782 #[cfg(feature = "rotation-optimizations")]
783 pub(crate) fn exact_decision_horizon(
784 &self,
785 program: &crate::state::CombatProgram,
786 instance: &crate::state::CombatInstance,
787 now: SimTime,
788 ) -> Option<SimTime> {
789 self.decision_thresholds
790 .iter()
791 .filter_map(|threshold| threshold.ready_at(program, instance, now))
792 .min()
793 }
794
795 #[cfg(feature = "rotation-optimizations")]
796 pub(crate) const fn may_cast_off_gcd(&self) -> bool {
797 self.may_cast_off_gcd
798 }
799
800 #[cfg(test)]
801 pub(crate) fn cast_actions(&self) -> &[RotationSpellBinding] {
802 &self.cast_actions
803 }
804
805 #[cfg(test)]
806 pub(crate) fn binding(&self, spell_id: u32) -> Option<RotationSpellBinding> {
807 self.bindings
808 .binary_search_by_key(&spell_id, |binding| binding.spell.as_u32())
809 .ok()
810 .and_then(|index| self.bindings.get(index).copied())
811 }
812
813 #[cfg(test)]
814 pub(crate) fn cast_action(&self, spell_id: u32) -> Option<RotationSpellBinding> {
815 self.cast_actions
816 .binary_search_by_key(&spell_id, |binding| binding.spell.as_u32())
817 .ok()
818 .and_then(|index| self.cast_actions.get(index).copied())
819 }
820}
821
822#[cfg(feature = "rotation-optimizations")]
823fn binding_lock_capabilities(binding: RotationSpellBinding, spells: &[SpellData]) -> (bool, bool) {
824 let mut off_gcd = false;
825 let mut while_casting = false;
826 let locals = std::iter::once(binding.local).chain(
827 binding
828 .override_binding
829 .map(|override_binding| override_binding.effective_local),
830 );
831
832 for local in locals {
833 let spell = spells
834 .get(local.as_usize())
835 .expect("finalized rotation binding has a registered local spell");
836
837 off_gcd |= spell.behavior.off_gcd;
838 while_casting |= spell.requirements.availability.usable_while_casting;
839 }
840
841 (off_gcd, while_casting)
842}
843
844#[cfg(feature = "rotation-optimizations")]
845struct DecisionSubscriptionInputs<'a> {
846 prepared: &'a PreparedRotation,
847 spells: &'a [SpellData],
848 spell_by_id: &'a IntMap<u32, LocalSpellIdx>,
849 bindings: &'a [RotationSpellBinding],
850 profiles: &'a [CastProfile],
851 primary_resource_type: Option<ResourceType>,
852 secondary_resource_type: Option<ResourceType>,
853}
854
855#[cfg(feature = "rotation-optimizations")]
856fn compile_decision_thresholds(
857 prepared: &PreparedRotation,
858 spells: &[SpellData],
859 spell_by_id: &IntMap<u32, LocalSpellIdx>,
860 bindings: &[RotationSpellBinding],
861) -> Result<Box<[RotationDecisionThreshold]>, RotationPlanError> {
862 let mut thresholds = Vec::new();
863
864 compile_authored_decision_thresholds(prepared, spell_by_id, &mut thresholds)?;
865 compile_execute_decision_thresholds(prepared, spells, spell_by_id, bindings, &mut thresholds)?;
866
867 Ok(thresholds.into_boxed_slice())
868}
869
870#[cfg(feature = "rotation-optimizations")]
871fn compile_authored_decision_thresholds(
872 prepared: &PreparedRotation,
873 spell_by_id: &IntMap<u32, LocalSpellIdx>,
874 thresholds: &mut Vec<RotationDecisionThreshold>,
875) -> Result<(), RotationPlanError> {
876 for action in prepared.action_dependency_plans() {
877 for change in action.changes() {
878 let threshold = match (change.key(), change.direction()) {
879 (
880 PreparedRotationChangeKey::ResourceThreshold {
881 resource,
882 value_bits,
883 strict,
884 },
885 PreparedRotationChangeDirection::Rise,
886 ) => {
887 let handle = prepared
888 .schema()
889 .buffer_finalizer()
890 .bind_resource(resource)
891 .ok_or(RotationPlanError::MissingDecisionResource { resource })?;
892
893 Some(RotationDecisionThreshold::ResourceRise(
894 ResourceDecisionThreshold {
895 handle,
896 resource,
897 value_bits,
898 strict,
899 },
900 ))
901 }
902 (
903 PreparedRotationChangeKey::TargetHealthThreshold {
904 percent_bits,
905 strict,
906 },
907 PreparedRotationChangeDirection::Fall,
908 ) => Some(RotationDecisionThreshold::TargetHealthFall {
909 percent_bits,
910 strict,
911 }),
912 (
913 PreparedRotationChangeKey::AuraRemainingThreshold {
914 aura,
915 on,
916 seconds_bits,
917 strict,
918 },
919 PreparedRotationChangeDirection::Fall,
920 ) => {
921 let projection = AuraProjectionKey::new(aura, on);
922 let mut handle = None;
923
924 prepared.schema().buffer_finalizer().visit_aura_projections(
925 |candidate, candidate_handle| {
926 if candidate == projection {
927 handle = Some(candidate_handle);
928 }
929 },
930 );
931 let handle =
932 handle.ok_or(RotationPlanError::MissingDecisionAuraProjection {
933 aura_id: aura.as_u32(),
934 on,
935 })?;
936
937 Some(RotationDecisionThreshold::AuraRemainingFall {
938 handle,
939 seconds_bits,
940 strict,
941 })
942 }
943 (
944 PreparedRotationChangeKey::CooldownRemainingThreshold {
945 spell,
946 seconds_bits,
947 strict,
948 },
949 PreparedRotationChangeDirection::Fall,
950 ) => {
951 let spell_id = spell.as_u32();
952 let local = spell_by_id
953 .get(&spell_id)
954 .copied()
955 .ok_or(RotationPlanError::UnresolvedSpell { spell_id })?;
956
957 Some(RotationDecisionThreshold::CooldownRemainingFall {
958 local,
959 seconds_bits,
960 strict,
961 })
962 }
963 _ => None,
964 };
965
966 if let Some(threshold) = threshold
967 && !thresholds.contains(&threshold)
968 {
969 thresholds.push(threshold);
970 }
971 }
972 }
973
974 Ok(())
975}
976
977#[cfg(feature = "rotation-optimizations")]
978fn compile_execute_decision_thresholds(
979 prepared: &PreparedRotation,
980 spells: &[SpellData],
981 spell_by_id: &IntMap<u32, LocalSpellIdx>,
982 bindings: &[RotationSpellBinding],
983 thresholds: &mut Vec<RotationDecisionThreshold>,
984) -> Result<(), RotationPlanError> {
985 for action in prepared.action_dependency_plans() {
986 let spell_id = action.spell().as_u32();
987 let local = spell_by_id
988 .get(&spell_id)
989 .copied()
990 .ok_or(RotationPlanError::UnresolvedSpell { spell_id })?;
991 let binding = bindings
992 .get(local.as_usize())
993 .copied()
994 .ok_or(RotationPlanError::UnresolvedSpell { spell_id })?;
995 let candidates = std::iter::once(binding.local).chain(
996 binding
997 .override_binding
998 .map(|override_binding| override_binding.effective_local),
999 );
1000
1001 for effective_local in candidates {
1002 let spell = spells
1003 .get(effective_local.as_usize())
1004 .ok_or(RotationPlanError::UnresolvedSpell { spell_id })?;
1005 let percent = spell.requirements.execute_below_pct;
1006
1007 if percent <= 0.0 {
1008 continue;
1009 }
1010
1011 let threshold = RotationDecisionThreshold::TargetHealthFall {
1012 percent_bits: percent.to_bits(),
1013 strict: false,
1014 };
1015
1016 if !thresholds.contains(&threshold) {
1017 thresholds.push(threshold);
1018 }
1019 }
1020 }
1021
1022 Ok(())
1023}
1024
1025#[cfg(feature = "rotation-optimizations")]
1026fn compile_decision_subscriptions(
1028 inputs: DecisionSubscriptionInputs<'_>,
1029) -> Result<[RotationDecisionSubscription; ROTATION_DECISION_MODE_COUNT], RotationPlanError> {
1030 let DecisionSubscriptionInputs {
1031 prepared,
1032 spells,
1033 spell_by_id,
1034 bindings,
1035 profiles,
1036 primary_resource_type,
1037 secondary_resource_type,
1038 } = inputs;
1039 let mut subscriptions =
1040 [RotationDecisionSubscription::complete(); ROTATION_DECISION_MODE_COUNT];
1041
1042 for mode in ROTATION_DECISION_MODES {
1043 let Some(subscription) = subscriptions.get_mut(mode.index()) else {
1044 return Err(RotationPlanError::IncompleteDecisionMode {
1045 mode,
1046 reason: "the fixed decision-mode subscription table is inconsistent",
1047 });
1048 };
1049
1050 for action in prepared.action_dependency_plans() {
1051 let spell_id = action.spell().as_u32();
1052 let local = spell_by_id
1053 .get(&spell_id)
1054 .copied()
1055 .ok_or(RotationPlanError::UnresolvedSpell { spell_id })?;
1056 let binding = bindings
1057 .get(local.as_usize())
1058 .copied()
1059 .ok_or(RotationPlanError::UnresolvedSpell { spell_id })?;
1060 let candidates = eligible_effective_candidates(binding, spells, mode);
1061
1062 if candidates.iter().all(Option::is_none) {
1063 continue;
1064 }
1065
1066 subscription.has_eligible_actions = true;
1067
1068 if !action.complete() {
1069 subscription.complete = false;
1070
1071 return Err(RotationPlanError::IncompleteDecisionCandidate {
1072 mode,
1073 spell_id,
1074 reason: "the action condition uses unsupported dynamic state",
1075 });
1076 }
1077
1078 if binding.override_binding.is_some() {
1079 insert_semantic_both(subscription, DECISION_AURA_STATE);
1080 }
1081
1082 insert_prepared_changes(subscription, action, spell_by_id, mode)?;
1083
1084 for effective_local in candidates.into_iter().flatten() {
1085 let cooldown_local = binding
1086 .override_binding
1087 .filter(|override_binding| {
1088 override_binding.effective_local == effective_local
1089 && override_binding.shares_base_cooldown
1090 })
1091 .map_or(effective_local, |_| binding.local);
1092 let spell = spells
1093 .get(effective_local.as_usize())
1094 .ok_or(RotationPlanError::UnresolvedSpell { spell_id })?;
1095 let profile = profiles
1096 .get(effective_local.as_usize())
1097 .ok_or(RotationPlanError::UnresolvedSpell { spell_id })?;
1098
1099 insert_implicit_candidate_changes(
1100 subscription,
1101 cooldown_local,
1102 spell,
1103 profile,
1104 primary_resource_type,
1105 secondary_resource_type,
1106 );
1107 }
1108 }
1109
1110 if subscription.has_eligible_actions && !prepared.action_dependencies_complete() {
1111 subscription.complete = false;
1112
1113 return Err(RotationPlanError::IncompleteDecisionMode {
1114 mode,
1115 reason: "reachable control flow contains a non-cast or conditioned list action",
1116 });
1117 }
1118 }
1119
1120 Ok(subscriptions)
1121}
1122
1123#[cfg(feature = "rotation-optimizations")]
1124fn eligible_effective_candidates(
1125 binding: RotationSpellBinding,
1126 spells: &[SpellData],
1127 mode: RotationDecisionMode,
1128) -> [Option<LocalSpellIdx>; EFFECTIVE_CANDIDATE_COUNT] {
1129 let effective = binding
1130 .override_binding
1131 .map(|override_binding| override_binding.effective_local);
1132 let base = mode_allows_candidate(binding.local, spells, mode).then_some(binding.local);
1133 let effective = effective.filter(|&local| mode_allows_candidate(local, spells, mode));
1134
1135 [base, effective.filter(|&local| Some(local) != base)]
1136}
1137
1138#[cfg(feature = "rotation-optimizations")]
1139fn mode_allows_candidate(
1140 local: LocalSpellIdx,
1141 spells: &[SpellData],
1142 mode: RotationDecisionMode,
1143) -> bool {
1144 let spell = spells
1145 .get(local.as_usize())
1146 .expect("finalized rotation binding has a registered local spell");
1147 let off_gcd = spell.behavior.off_gcd;
1148 let while_casting = spell.requirements.availability.usable_while_casting;
1149
1150 match mode {
1151 RotationDecisionMode::Unlocked => true,
1152 RotationDecisionMode::Gcd => off_gcd,
1153 RotationDecisionMode::Casting => while_casting,
1154 RotationDecisionMode::Both => off_gcd && while_casting,
1155 }
1156}
1157
1158#[cfg(feature = "rotation-optimizations")]
1159fn insert_prepared_changes(
1160 subscription: &mut RotationDecisionSubscription,
1161 action: &PreparedActionDependencyPlan,
1162 spell_by_id: &IntMap<u32, LocalSpellIdx>,
1163 mode: RotationDecisionMode,
1164) -> Result<(), RotationPlanError> {
1165 for change in action.changes() {
1166 let direction = change.direction();
1167
1168 if !matches!(
1169 direction,
1170 PreparedRotationChangeDirection::Rise | PreparedRotationChangeDirection::Fall
1171 ) {
1172 subscription.complete = false;
1173
1174 return Err(RotationPlanError::IncompleteDecisionCandidate {
1175 mode,
1176 spell_id: action.spell().as_u32(),
1177 reason: "the prepared dependency direction is not supported by combat",
1178 });
1179 }
1180
1181 if insert_prepared_cooldown_change(subscription, change.key(), spell_by_id, direction)? {
1182 continue;
1183 }
1184
1185 match change.key() {
1186 PreparedRotationChangeKey::ResourceThreshold { resource, .. } => {
1187 subscription.insert_resource(resource, direction);
1188 }
1189 PreparedRotationChangeKey::AuraState => {
1190 subscription.insert_semantic(DECISION_AURA_STATE, direction);
1191 }
1192 PreparedRotationChangeKey::TargetHealthThreshold { .. } => {
1193 subscription.insert_semantic(DECISION_TARGET_HEALTH, direction);
1194 }
1195 PreparedRotationChangeKey::AuraRemainingThreshold { .. } => {
1196 subscription.insert_semantic(DECISION_AURA_STATE, direction);
1197 subscription.insert_semantic(DECISION_AURA_TIMING, direction);
1198 }
1199 PreparedRotationChangeKey::Spatial => {
1200 subscription.insert_semantic(DECISION_SPATIAL, direction);
1201 }
1202 PreparedRotationChangeKey::EncounterPopulation => {
1203 subscription.insert_semantic(DECISION_ENCOUNTER_POPULATION, direction);
1204 }
1205 PreparedRotationChangeKey::SpellState => {
1206 subscription.insert_semantic(DECISION_SPELL_STATE, direction);
1207 }
1208 PreparedRotationChangeKey::EquipmentState => {
1209 subscription.insert_semantic(DECISION_EQUIPMENT_STATE, direction);
1210 }
1211 PreparedRotationChangeKey::HistoryState => {
1212 subscription.insert_semantic(DECISION_HISTORY_STATE, direction);
1213 }
1214 _ => {
1215 subscription.complete = false;
1216
1217 return Err(RotationPlanError::IncompleteDecisionCandidate {
1218 mode,
1219 spell_id: action.spell().as_u32(),
1220 reason: "the prepared dependency kind is not supported by combat",
1221 });
1222 }
1223 }
1224 }
1225
1226 Ok(())
1227}
1228
1229#[cfg(feature = "rotation-optimizations")]
1230fn insert_prepared_cooldown_change(
1231 subscription: &mut RotationDecisionSubscription,
1232 key: PreparedRotationChangeKey,
1233 spell_by_id: &IntMap<u32, LocalSpellIdx>,
1234 direction: PreparedRotationChangeDirection,
1235) -> Result<bool, RotationPlanError> {
1236 let (spell, direction) = match key {
1237 PreparedRotationChangeKey::SpellCooldownReady(spell) => (spell, direction),
1238 PreparedRotationChangeKey::CooldownRemainingThreshold { spell, .. } => {
1239 let reversed = match direction {
1240 PreparedRotationChangeDirection::Rise => PreparedRotationChangeDirection::Fall,
1241 PreparedRotationChangeDirection::Fall => PreparedRotationChangeDirection::Rise,
1242 _ => direction,
1243 };
1244
1245 (spell, reversed)
1246 }
1247 _ => return Ok(false),
1248 };
1249 let spell_id = spell.as_u32();
1250 let local = spell_by_id
1251 .get(&spell_id)
1252 .copied()
1253 .ok_or(RotationPlanError::UnresolvedSpell { spell_id })?;
1254
1255 subscription.insert_cooldown(local, direction);
1256
1257 Ok(true)
1258}
1259
1260#[cfg(feature = "rotation-optimizations")]
1261fn insert_implicit_candidate_changes(
1262 subscription: &mut RotationDecisionSubscription,
1263 cooldown_local: LocalSpellIdx,
1264 spell: &SpellData,
1265 profile: &CastProfile,
1266 primary_resource_type: Option<ResourceType>,
1267 secondary_resource_type: Option<ResourceType>,
1268) {
1269 insert_semantic_both(subscription, DECISION_AURA_STATE);
1273 insert_semantic_both(subscription, DECISION_ACTOR_STATE);
1274 insert_semantic_both(subscription, DECISION_SPATIAL);
1275 insert_semantic_both(subscription, DECISION_ENCOUNTER_POPULATION);
1276 insert_semantic_both(subscription, DECISION_TARGET_IDENTITY);
1277 subscription.insert_cooldown(cooldown_local, PreparedRotationChangeDirection::Rise);
1278
1279 if spell.requirements.execute_below_pct > 0.0 {
1280 subscription.insert_semantic(
1281 DECISION_TARGET_HEALTH,
1282 PreparedRotationChangeDirection::Fall,
1283 );
1284 }
1285
1286 if spell.cost.health_cost > 0.0 || spell.cost.health_cost_pct > 0.0 {
1287 subscription.insert_semantic(
1288 DECISION_PLAYER_HEALTH,
1289 PreparedRotationChangeDirection::Rise,
1290 );
1291 }
1292
1293 if (spell.cost.resource_cost != 0.0 || spell.cost.resource_cost_pct.percent != 0.0)
1295 && profile.primary_resource.is_some()
1296 && let Some(resource) = primary_resource_type
1297 {
1298 subscription.insert_resource(resource, PreparedRotationChangeDirection::Rise);
1299 }
1300
1301 if spell.cost.secondary_resource_cost != 0.0
1303 && profile.secondary_resource.is_some()
1304 && let Some(resource) = secondary_resource_type
1305 {
1306 subscription.insert_resource(resource, PreparedRotationChangeDirection::Rise);
1307 }
1308}
1309
1310#[cfg(feature = "rotation-optimizations")]
1311fn insert_semantic_both(subscription: &mut RotationDecisionSubscription, semantic: u16) {
1312 subscription.insert_semantic(semantic, PreparedRotationChangeDirection::Rise);
1313 subscription.insert_semantic(semantic, PreparedRotationChangeDirection::Fall);
1314}
1315
1316#[expect(
1320 clippy::too_many_arguments,
1321 reason = "cold profile compilation keeps independent binding inputs explicit"
1322)]
1323fn compile_cast_profile(
1324 local: LocalSpellIdx,
1325 spell: &SpellData,
1326 auras: &[AuraData],
1327 aura_by_id: &IntMap<u32, LocalAuraIdx>,
1328 bindings: &BufferBindings,
1329 passive_driver_effects: &[crate::state::PassiveDriverEffect],
1330 primary_resource_type: Option<ResourceType>,
1331 secondary_resource_type: Option<ResourceType>,
1332 enemy_count: usize,
1333 data: &ResolvedGameData,
1334 target_aura_handles: &mut Vec<AuraSlotHandle>,
1335 stance_exception_handles: &mut Vec<AuraSlotHandle>,
1336) -> Result<CastProfile, RotationPlanError> {
1337 let spell_id = SpellIdx::from_raw(spell.spell_id);
1338 let requirement = spell.requirements.equipped_item_requirement;
1339 let hand = if spell.requirements.equipment.requires_off_hand {
1340 wowlab_engine_gamedata::ResolvedDamageWeapon::OffHand
1341 } else {
1342 wowlab_engine_gamedata::ResolvedDamageWeapon::MainHand
1343 };
1344 let equipped_item_matches = requirement.is_none_or(|requirement| {
1345 wowlab_engine_domain::dbc::equipped_weapon_matches(data, hand, requirement)
1346 });
1347
1348 let gating_aura = bind_cast_aura(
1349 spell,
1350 "gating",
1351 spell.requirements.gating_aura_id,
1352 spell.requirements.gating_aura_min_stacks,
1353 None,
1354 auras,
1355 aura_by_id,
1356 bindings,
1357 enemy_count,
1358 target_aura_handles,
1359 )?;
1360 let cost_bypass_aura = bind_cast_aura(
1361 spell,
1362 "cost bypass",
1363 spell.requirements.cost_bypass_aura_id,
1364 1,
1365 None,
1366 auras,
1367 aura_by_id,
1368 bindings,
1369 enemy_count,
1370 target_aura_handles,
1371 )?;
1372 let cooldown_bypass_aura = bind_cast_aura(
1373 spell,
1374 "cooldown bypass",
1375 spell.requirements.cooldown_bypass_aura_id,
1376 1,
1377 None,
1378 auras,
1379 aura_by_id,
1380 bindings,
1381 enemy_count,
1382 target_aura_handles,
1383 )?;
1384 let consume_aura_on_cast = bind_cast_aura(
1385 spell,
1386 "consume on cast",
1387 spell.requirements.consume_aura_on_cast_id,
1388 1,
1389 None,
1390 auras,
1391 aura_by_id,
1392 bindings,
1393 enemy_count,
1394 target_aura_handles,
1395 )?;
1396 let max_empower_aura = bind_cast_aura(
1397 spell,
1398 "max empower",
1399 spell.max_empower_aura_id,
1400 1,
1401 None,
1402 auras,
1403 aura_by_id,
1404 bindings,
1405 enemy_count,
1406 target_aura_handles,
1407 )?;
1408 let instant_cast_aura = bind_cast_aura(
1409 spell,
1410 "instant cast",
1411 spell.requirements.instant_cast_aura_id,
1412 1,
1413 None,
1414 auras,
1415 aura_by_id,
1416 bindings,
1417 enemy_count,
1418 target_aura_handles,
1419 )?;
1420 let channel_tick_cost_aura = bind_cast_aura(
1421 spell,
1422 "channel tick cost",
1423 spell.channel.tick_cost_aura,
1424 1,
1425 None,
1426 auras,
1427 aura_by_id,
1428 bindings,
1429 enemy_count,
1430 target_aura_handles,
1431 )?;
1432 let channel_tick_damage_aura = bind_cast_aura(
1433 spell,
1434 "channel tick damage",
1435 spell.channel.tick_damage_aura,
1436 1,
1437 None,
1438 auras,
1439 aura_by_id,
1440 bindings,
1441 enemy_count,
1442 target_aura_handles,
1443 )?;
1444 let required_caster_aura = bind_signed_cast_aura(
1445 spell,
1446 "required caster",
1447 spell.requirements.caster_aura_spell,
1448 AuraOn::Player,
1449 auras,
1450 aura_by_id,
1451 bindings,
1452 enemy_count,
1453 target_aura_handles,
1454 )?;
1455 let required_target_aura = bind_signed_cast_aura(
1456 spell,
1457 "required target",
1458 spell.requirements.target_aura_spell,
1459 AuraOn::Target,
1460 auras,
1461 aura_by_id,
1462 bindings,
1463 enemy_count,
1464 target_aura_handles,
1465 )?;
1466 let excluded_caster_aura = bind_signed_cast_aura(
1467 spell,
1468 "excluded caster",
1469 spell.requirements.exclude_caster_aura_spell,
1470 AuraOn::Player,
1471 auras,
1472 aura_by_id,
1473 bindings,
1474 enemy_count,
1475 target_aura_handles,
1476 )?;
1477 let excluded_target_aura = bind_signed_cast_aura(
1478 spell,
1479 "excluded target",
1480 spell.requirements.exclude_target_aura_spell,
1481 AuraOn::Target,
1482 auras,
1483 aura_by_id,
1484 bindings,
1485 enemy_count,
1486 target_aura_handles,
1487 )?;
1488 let stance_start = stance_exception_handles.len();
1489 let mut passive_stance_exception = false;
1490
1491 for aura in auras {
1492 let affects = aura.effects.iter().flatten().any(|effect| {
1493 let crate::state::BuffEffect::SpellStanceMaskFromEffect {
1494 source_spell_id,
1495 effect_index,
1496 } = effect
1497 else {
1498 return false;
1499 };
1500
1501 data.effect_affects_spell(
1502 SpellIdx::from_raw(*source_spell_id),
1503 *effect_index,
1504 spell_id,
1505 )
1506 });
1507
1508 if !affects || aura.on != AuraOn::Player {
1509 continue;
1510 }
1511
1512 let &local =
1513 aura_by_id
1514 .get(&aura.aura_id)
1515 .ok_or(RotationPlanError::UnresolvedCastAura {
1516 spell_id: spell.spell_id,
1517 kind: "stance exception",
1518 aura_id: aura.aura_id,
1519 })?;
1520 let key = AuraKey::new(
1521 AuraIdx(aura.aura_id),
1522 ActorId::Player,
1523 ActorId::Player,
1524 AuraOn::Player,
1525 );
1526
1527 if let Some(binding) = bindings.aura(local, key) {
1528 stance_exception_handles.push(binding.handle);
1529 }
1530 }
1531
1532 for driver in passive_driver_effects {
1533 let crate::state::BuffEffect::SpellStanceMaskFromEffect {
1534 source_spell_id,
1535 effect_index,
1536 } = &driver.effect
1537 else {
1538 continue;
1539 };
1540
1541 passive_stance_exception |= data.effect_affects_spell(
1542 SpellIdx::from_raw(*source_spell_id),
1543 *effect_index,
1544 spell_id,
1545 );
1546 }
1547
1548 let stance_exceptions = cast_handle_range(stance_start, stance_exception_handles.len())?;
1549 let interrupt_flags = wowlab_engine_domain::dbc::SpellInterruptFlags::from_dbc(
1550 data.spell_interrupt_flags(spell_id).unwrap_or_default(),
1551 );
1552 let channel_interrupt_flags = wowlab_engine_domain::dbc::SpellAuraInterruptFlags::from_dbc(
1553 data.channel_interrupt_flags(spell_id).unwrap_or_default(),
1554 );
1555
1556 Ok(CastProfile {
1557 local,
1558 spell_id: spell.spell_id,
1559 hostile_min_range: data.hostile_min_range(spell_id).unwrap_or(0.0),
1560 hostile_max_range: data.hostile_max_range(spell_id).unwrap_or(0.0),
1561 requires_hostile_target: wowlab_engine_domain::dbc::SpellCastTargetFlags::from_bits_retain(
1562 spell.targeting.required_explicit_target_mask,
1563 )
1564 .requires_hostile_unit(),
1565 ignores_line_of_sight: data.ignores_line_of_sight(spell_id).unwrap_or(false),
1566 school_mask: SpellSchoolMask::from_dbc(data.school_mask(spell_id).unwrap_or_default()),
1567 damage_pushback: interrupt_flags
1568 .contains(wowlab_engine_domain::dbc::SpellInterruptFlags::DAMAGE_PUSHBACK)
1569 || interrupt_flags.contains(
1570 wowlab_engine_domain::dbc::SpellInterruptFlags::DAMAGE_PUSHBACK_PLAYER_ONLY,
1571 ),
1572 channel_duration_pushback: channel_interrupt_flags
1573 .contains(wowlab_engine_domain::dbc::SpellAuraInterruptFlags::DAMAGE_CHANNEL_DURATION),
1574 main_hand_available: data.main_hand().speed_ms != 0,
1575 off_hand_available: data.off_hand().is_some(),
1576 equipped_item_matches,
1577 primary_resource: primary_resource_type.and_then(|resource| bindings.resource(resource)),
1578 secondary_resource: secondary_resource_type
1579 .and_then(|resource| bindings.resource(resource)),
1580 gating_aura,
1581 cost_bypass_aura,
1582 cooldown_bypass_aura,
1583 consume_aura_on_cast,
1584 max_empower_aura,
1585 instant_cast_aura,
1586 channel_tick_cost_aura,
1587 channel_tick_damage_aura,
1588 required_caster_aura,
1589 required_target_aura,
1590 excluded_caster_aura,
1591 excluded_target_aura,
1592 aura_states: CastAuraStateProfile {
1593 required_caster: spell.requirements.caster_aura_state,
1594 required_target: spell.requirements.target_aura_state,
1595 excluded_caster: spell.requirements.exclude_caster_aura_state,
1596 excluded_target: spell.requirements.exclude_target_aura_state,
1597 },
1598 shapeshift_required_mask: spell.requirements.shapeshift_required_mask,
1599 shapeshift_excluded_mask: spell.requirements.shapeshift_excluded_mask,
1600 allow_while_unshifted: spell.requirements.form.allow_while_unshifted,
1601 requires_unshifted: spell.requirements.availability.requires_unshifted,
1602 requires_stealth: spell.requirements.form.requires_stealth,
1603 stance_exceptions,
1604 passive_stance_exception,
1605 })
1606}
1607
1608#[expect(
1610 clippy::too_many_arguments,
1611 reason = "cold aura binding keeps validation and destination inputs explicit"
1612)]
1613fn bind_signed_cast_aura(
1614 spell: &SpellData,
1615 kind: &'static str,
1616 raw_aura_id: i32,
1617 expected_on: AuraOn,
1618 auras: &[AuraData],
1619 aura_by_id: &IntMap<u32, LocalAuraIdx>,
1620 bindings: &BufferBindings,
1621 enemy_count: usize,
1622 target_aura_handles: &mut Vec<AuraSlotHandle>,
1623) -> Result<Option<CastAuraRequirement>, RotationPlanError> {
1624 if raw_aura_id <= 0 {
1625 return Ok(None);
1626 }
1627
1628 let aura_id = u32::try_from(raw_aura_id)
1629 .ok()
1630 .ok_or(RotationPlanError::UnresolvedCastAura {
1631 spell_id: spell.spell_id,
1632 kind,
1633 aura_id: 0,
1634 })?;
1635
1636 bind_cast_aura(
1637 spell,
1638 kind,
1639 aura_id,
1640 1,
1641 Some(expected_on),
1642 auras,
1643 aura_by_id,
1644 bindings,
1645 enemy_count,
1646 target_aura_handles,
1647 )
1648}
1649
1650#[expect(
1652 clippy::too_many_arguments,
1653 reason = "cold aura binding keeps validation and destination inputs explicit"
1654)]
1655fn bind_cast_aura(
1656 spell: &SpellData,
1657 kind: &'static str,
1658 aura_id: u32,
1659 min_stacks: u8,
1660 expected_on: Option<AuraOn>,
1661 auras: &[AuraData],
1662 aura_by_id: &IntMap<u32, LocalAuraIdx>,
1663 bindings: &BufferBindings,
1664 enemy_count: usize,
1665 target_aura_handles: &mut Vec<AuraSlotHandle>,
1666) -> Result<Option<CastAuraRequirement>, RotationPlanError> {
1667 if aura_id == 0 {
1668 return Ok(None);
1669 }
1670
1671 let &local = aura_by_id
1672 .get(&aura_id)
1673 .ok_or(RotationPlanError::UnresolvedCastAura {
1674 spell_id: spell.spell_id,
1675 kind,
1676 aura_id,
1677 })?;
1678 let aura = auras
1679 .get(local.as_usize())
1680 .ok_or(RotationPlanError::UnresolvedCastAura {
1681 spell_id: spell.spell_id,
1682 kind,
1683 aura_id,
1684 })?;
1685
1686 if expected_on.is_some_and(|expected| aura.on != expected)
1687 || !matches!(aura.on, AuraOn::Player | AuraOn::Target)
1688 {
1689 return Err(RotationPlanError::UnsupportedCastAura {
1690 spell_id: spell.spell_id,
1691 kind,
1692 aura_id,
1693 on: aura.on,
1694 });
1695 }
1696
1697 let coordinate = match aura.on {
1698 AuraOn::Player => {
1699 let key = AuraKey::new(
1700 AuraIdx(aura_id),
1701 ActorId::Player,
1702 ActorId::Player,
1703 AuraOn::Player,
1704 );
1705 let handle = bindings
1706 .aura(local, key)
1707 .ok_or(RotationPlanError::MissingCastAuraBinding {
1708 spell_id: spell.spell_id,
1709 kind,
1710 aura_id,
1711 })?
1712 .handle;
1713
1714 CastAuraCoordinate::Player(handle)
1715 }
1716 AuraOn::Target => {
1717 let offset = target_aura_handles.len();
1718
1719 for index in 0..enemy_count {
1720 let enemy = EnemyIdx::from_raw(
1721 u16::try_from(index)
1722 .ok()
1723 .ok_or(RotationPlanError::CastAuraRangeOverflow)?,
1724 );
1725 let key = AuraKey::new(
1726 AuraIdx(aura_id),
1727 ActorId::Player,
1728 ActorId::Enemy(enemy),
1729 AuraOn::Target,
1730 );
1731 let handle = bindings
1732 .aura(local, key)
1733 .ok_or(RotationPlanError::MissingCastAuraBinding {
1734 spell_id: spell.spell_id,
1735 kind,
1736 aura_id,
1737 })?
1738 .handle;
1739
1740 target_aura_handles.push(handle);
1741 }
1742
1743 CastAuraCoordinate::Target {
1744 offset: u32::try_from(offset)
1745 .ok()
1746 .ok_or(RotationPlanError::CastAuraRangeOverflow)?,
1747 len: u16::try_from(enemy_count)
1748 .ok()
1749 .ok_or(RotationPlanError::CastAuraRangeOverflow)?,
1750 }
1751 }
1752 AuraOn::Pet => unreachable!("unsupported pet cast aura was rejected before binding"),
1753 };
1754
1755 Ok(Some(CastAuraRequirement {
1756 aura_id,
1757 local,
1758 coordinate,
1759 min_stacks,
1760 }))
1761}
1762
1763fn cast_handle_range(start: usize, end: usize) -> Result<CastAuraHandleRange, RotationPlanError> {
1764 Ok(CastAuraHandleRange {
1765 offset: u32::try_from(start)
1766 .ok()
1767 .ok_or(RotationPlanError::CastAuraRangeOverflow)?,
1768 len: u16::try_from(end.saturating_sub(start))
1769 .ok()
1770 .ok_or(RotationPlanError::CastAuraRangeOverflow)?,
1771 })
1772}
1773
1774fn bind_spell(
1776 spell: SpellIdx,
1777 spells: &[SpellData],
1778 spell_by_id: &IntMap<u32, LocalSpellIdx>,
1779 auras: &[AuraData],
1780 aura_by_id: &IntMap<u32, LocalAuraIdx>,
1781 bindings: &BufferBindings,
1782) -> Result<RotationSpellBinding, RotationPlanError> {
1783 let spell_id = spell.as_u32();
1784 let &local = spell_by_id
1785 .get(&spell_id)
1786 .ok_or(RotationPlanError::UnresolvedSpell { spell_id })?;
1787 let data = spells
1788 .get(local.as_usize())
1789 .ok_or(RotationPlanError::UnresolvedSpell { spell_id })?;
1790
1791 if data.spell_id != spell_id {
1792 return Err(RotationPlanError::InconsistentSpell {
1793 spell_id,
1794 local: local.raw(),
1795 actual_spell_id: data.spell_id,
1796 });
1797 }
1798
1799 let spell_slot = bindings.spell(local);
1800 let override_binding = bind_override(data, spells, spell_by_id, auras, aura_by_id, bindings)?;
1801
1802 Ok(RotationSpellBinding {
1803 spell,
1804 local,
1805 spell_slot,
1806 override_binding,
1807 })
1808}
1809
1810fn bind_override(
1812 data: &SpellData,
1813 spells: &[SpellData],
1814 spell_by_id: &IntMap<u32, LocalSpellIdx>,
1815 auras: &[AuraData],
1816 aura_by_id: &IntMap<u32, LocalAuraIdx>,
1817 bindings: &BufferBindings,
1818) -> Result<Option<RotationOverrideBinding>, RotationPlanError> {
1819 let spell_id = data.spell_id;
1820 let has_override_aura = data.overrides.aura_id != 0;
1821 let has_override_spell = data.overrides.spell_id != 0;
1822
1823 if has_override_aura != has_override_spell {
1824 return Err(RotationPlanError::InconsistentOverride { spell_id });
1825 }
1826
1827 if !has_override_spell {
1828 return Ok(None);
1829 }
1830
1831 let override_spell_id = data.overrides.spell_id;
1832 let aura_id = data.overrides.aura_id;
1833 let &aura_local = aura_by_id
1834 .get(&aura_id)
1835 .ok_or(RotationPlanError::UnresolvedOverrideAura { spell_id, aura_id })?;
1836 let aura = auras
1837 .get(aura_local.as_usize())
1838 .ok_or(RotationPlanError::UnresolvedOverrideAura { spell_id, aura_id })?;
1839
1840 if aura.aura_id != aura_id || aura.on != AuraOn::Player {
1841 return Err(RotationPlanError::UnsupportedOverrideAura { spell_id, aura_id });
1842 }
1843
1844 let aura_key = AuraKey::new(
1845 AuraIdx(aura_id),
1846 ActorId::Player,
1847 ActorId::Player,
1848 AuraOn::Player,
1849 );
1850 let aura_handle = bindings
1851 .aura(aura_local, aura_key)
1852 .ok_or(RotationPlanError::UnresolvedOverrideAura { spell_id, aura_id })?
1853 .handle;
1854 let &effective_local =
1855 spell_by_id
1856 .get(&override_spell_id)
1857 .ok_or(RotationPlanError::UnresolvedOverride {
1858 spell_id,
1859 override_spell_id,
1860 })?;
1861 let effective =
1862 spells
1863 .get(effective_local.as_usize())
1864 .ok_or(RotationPlanError::UnresolvedOverride {
1865 spell_id,
1866 override_spell_id,
1867 })?;
1868
1869 if effective.spell_id != override_spell_id {
1870 return Err(RotationPlanError::InconsistentSpell {
1871 spell_id: override_spell_id,
1872 local: effective_local.raw(),
1873 actual_spell_id: effective.spell_id,
1874 });
1875 }
1876
1877 Ok(Some(RotationOverrideBinding {
1878 aura_to_consume: (!data.overrides.preserves_aura).then_some(aura_local),
1879 aura_min_stacks: data.overrides.aura_min_stacks,
1880 aura_handle,
1881 effective_local,
1882 shares_base_cooldown: data.overrides.shares_base_cooldown,
1883 }))
1884}
1885
1886#[cfg(test)]
1887#[path = "rotation/tests.rs"]
1888mod tests;