Skip to main content

wowlab_engine_combat/state/program/
rotation.rs

1// #t(file: rust_too_many_lines_in_file) rotation binding finalization and its cold dependency proof share the same validated spell/profile coordinates.
2
3//! Finalized rotation spell bindings and availability partitions.
4
5#[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/// Prevalidated replacement coordinates for one rotation-referenced button.
124#[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/// One finalized spell identity used by a rotation field or cast action.
134#[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/// Exact concrete aura coordinate used by a finalized cast predicate.
148#[derive(Clone, Copy, Debug)]
149pub(crate) enum CastAuraCoordinate {
150    Player(AuraSlotHandle),
151    Target { offset: u32, len: u16 },
152}
153
154/// One finalized aura requirement, including the diagnostic identity and typed coordinate.
155#[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/// Required and excluded aura-state coordinates for caster and selected target.
164#[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/// Finalized range inside the exact active-aura handle table for one spell predicate.
173#[derive(Clone, Copy, Debug, Default)]
174pub(crate) struct CastAuraHandleRange {
175    offset: u32,
176    len: u16,
177}
178
179/// Finalized cast scalars and typed coordinates indexed directly by local spell identity.
180#[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/// One exact typed spell slot whose target-dependent fields are observed by the rotation.
223#[derive(Clone, Copy, Debug)]
224pub(crate) struct RotationProjectionBinding {
225    pub(crate) spell: RotationSpellBinding,
226    pub(crate) usage: SpellProjectionUsage,
227}
228
229/// One finalized item slot and its directly executable cast action.
230#[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    // #t(rust_lossy_cast) `GearSlot` has a `u8` representation, so this conversion is exact.
416    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        // BOUNDS: the table has one entry for every possible `u8` gear-slot discriminant.
440        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/// Immutable rotation bindings derived from [`PreparedRotation`] reference metadata.
453#[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    // #t(fn: rust_cyclomatic_complexity) cold finalization validates and partitions every prepared reference while preserving stable order.
477    // #t(fn: rust_max_fn_lines) construction compiles the complete rotation relationship graph in one auditable pass.
478    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        // BOUNDS: finalization creates exactly one profile for every stable local spell index.
689        &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        // BOUNDS: finalization creates exactly one binding for every stable local spell index.
758        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        // BOUNDS: every decision-mode discriminant maps to its fixed subscription slot.
779        &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")]
1026// #t(fn: rust_cyclomatic_complexity) cold compilation validates each exact lock mode and rejects every incomplete action before runtime.
1027fn 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    // Every candidate observes requested/effective spell gates, the current target,
1270    // actor control/movement state, and range. These coarse semantic families are
1271    // transaction-local fixed bits; they do not restore source-based wakeups.
1272    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    // #t(rust_floating_point_eq) finalized cost fields use exact zero as the absent sentinel.
1294    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    // #t(rust_floating_point_eq) finalized cost fields use exact zero as the absent sentinel.
1302    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// #t(fn: rust_large_fn_params) cold profile compilation joins independent spell, aura, binding, resource, encounter, and game-data inputs.
1317// #t(fn: rust_max_fn_lines) the profile is the single auditable inventory of every cast predicate coordinate.
1318// #t(fn: rust_cyclomatic_complexity) each independent authored cast requirement contributes one explicit finalized coordinate.
1319#[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// #t(fn: rust_large_fn_params) cold aura binding keeps the diagnostic identity and every finalized binding input explicit.
1609#[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// #t(fn: rust_large_fn_params) cold aura binding keeps definition, actor, encounter, binding, and destination inputs explicit.
1651#[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
1774// #t(fn: rust_large_fn_params) cold spell binding joins stable definition, profile, aura, and buffer-coordinate tables.
1775fn 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
1810// #t(fn: rust_large_fn_params) cold override binding joins the base and replacement definition/profile/aura coordinate tables.
1811fn 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;