Skip to main content

wowlab_engine_combat/systems/auras/
application_state.rs

1//! Aura instance projection, snapshot, refresh, and rolling-periodic state.
2
3use wowlab_engine_domain::{constants::AURA_PANDEMIC_THRESHOLD, rotation::DenseBuffer};
4use wowlab_engine_ports::Event;
5use wowlab_engine_telemetry::TelemetrySink;
6use wowlab_types::{
7    constants::MS_PER_SECOND,
8    sim::{
9        ActorId, AuraIdx, AuraKey, AuraOn, AuraProjectionKey, EnemyIdx, PetIdx, SimTime, SpellIdx,
10    },
11};
12
13use super::{MAX_AURA_APPLICATION_ACTORS, PERMANENT_AURA_EXPIRES_AT_S, expire_aura};
14use crate::{
15    context::HookCtx,
16    state::{CombatState, DamageEffectRef, LocalAuraIdx, SpellGroup, SpellGroupRule},
17    systems::{
18        buffs::{self, accumulate_buffs},
19        damage_pipeline::{
20            DamageFlags, DamageSnapshot, SpellModifierQuery, dynamic_base_powers,
21            read_player_stats_for_damage, school_of, spell_scoped_mods_for,
22            typed_attack_power_with,
23        },
24        mastery::MasteryCtx,
25        sync_max_charges, sync_player_health_from_stamina, sync_recharge_rates, sync_spell_costs,
26        synchronize_active_spell_gates,
27    },
28};
29
30#[cfg(test)]
31pub(super) fn pandemic_carry(base_duration: f64, remaining: f64) -> f64 {
32    remaining.min(base_duration * AURA_PANDEMIC_THRESHOLD)
33}
34
35pub(super) fn pandemic_carry_ms(base_duration_ms: u32, remaining_ms: u32) -> u32 {
36    let carry_limit = wowlab_types::numeric::f64_to_u32_saturating_trunc(
37        f64::from(base_duration_ms) * AURA_PANDEMIC_THRESHOLD,
38    );
39
40    remaining_ms.min(carry_limit)
41}
42
43#[derive(Clone, Copy)]
44pub(super) struct AuraSlotRequest<'a> {
45    pub aura: &'a crate::state::AuraData,
46    pub key: AuraKey,
47    pub base_dur_ms: u32,
48    pub base_dur: SimTime,
49    pub is_permanent: bool,
50}
51
52#[inline]
53fn resolved_aura_key(aura: &crate::state::AuraData, source: ActorId, affected: ActorId) -> AuraKey {
54    AuraKey::new(AuraIdx(aura.aura_id), source, affected, aura.on)
55}
56
57#[inline]
58pub(crate) fn aura_key_for(
59    state: &CombatState,
60    local: LocalAuraIdx,
61    source: ActorId,
62    target: Option<EnemyIdx>,
63) -> Option<AuraKey> {
64    let aura = state.defs.auras.get(local.as_usize()).copied()?;
65    let affected = match aura.on {
66        AuraOn::Player => ActorId::Player,
67        AuraOn::Target => ActorId::Enemy(target?),
68        AuraOn::Pet => ActorId::Pet(match source {
69            ActorId::Player => PetIdx::PRIMARY,
70            ActorId::Pet(pet) => pet,
71            ActorId::External | ActorId::Enemy(_) => return None,
72        }),
73    };
74
75    Some(resolved_aura_key(&aura, source, affected))
76}
77
78pub(crate) fn aura_keys_for_application(
79    state: &CombatState,
80    local: LocalAuraIdx,
81    source: ActorId,
82    target: Option<EnemyIdx>,
83) -> [Option<AuraKey>; MAX_AURA_APPLICATION_ACTORS] {
84    let primary = aura_key_for(state, local, source, target);
85    let secondary = state
86        .defs
87        .auras
88        .get(local.as_usize())
89        .filter(|aura| aura.propagates_to_pet)
90        .map(|aura| {
91            AuraKey::new(
92                AuraIdx(aura.aura_id),
93                source,
94                ActorId::Pet(PetIdx::PRIMARY),
95                AuraOn::Pet,
96            )
97        });
98
99    [primary, secondary]
100}
101
102pub(crate) fn aura_keys_for_actor_application(
103    state: &CombatState,
104    local: LocalAuraIdx,
105    source: ActorId,
106    affected: ActorId,
107) -> [Option<AuraKey>; MAX_AURA_APPLICATION_ACTORS] {
108    let Some(aura) = state.defs.auras.get(local.as_usize()) else {
109        return [None, None];
110    };
111    let primary = Some(resolved_aura_key(aura, source, affected));
112    let secondary = aura.propagates_to_pet.then(|| {
113        AuraKey::new(
114            AuraIdx(aura.aura_id),
115            source,
116            ActorId::Pet(PetIdx::PRIMARY),
117            AuraOn::Pet,
118        )
119    });
120
121    [primary, secondary]
122}
123
124pub(super) fn aura_query_key_for(
125    state: &CombatState,
126    local: LocalAuraIdx,
127    source: ActorId,
128    target: Option<EnemyIdx>,
129) -> Option<AuraKey> {
130    let aura = state.defs.auras.get(local.as_usize()).copied()?;
131    let query_source = if aura.on == AuraOn::Player && matches!(source, ActorId::Pet(_)) {
132        ActorId::Player
133    } else {
134        source
135    };
136
137    aura_key_for(state, local, query_source, target)
138}
139
140#[inline]
141pub(super) fn context_aura_key(ctx: &HookCtx<'_>, local: LocalAuraIdx) -> Option<AuraKey> {
142    if let Some(affected) = ctx.effect_target {
143        let aura = ctx.state.defs.auras.get(local.as_usize())?;
144
145        return Some(resolved_aura_key(aura, ctx.source, affected));
146    }
147
148    aura_key_for(ctx.state, local, ctx.source, ctx.target)
149}
150
151fn emit_aura_event(
152    state: &CombatState,
153    sink: &mut TelemetrySink,
154    key: AuraKey,
155    now: SimTime,
156    emit: impl FnOnce(&mut TelemetrySink, u32, wowlab_engine_telemetry::AuraTelemetryScope),
157) {
158    emit(sink, now.as_millis(), state.aura_telemetry_scope(key));
159}
160
161pub(crate) fn emit_aura_apply_event(
162    state: &CombatState,
163    sink: &mut TelemetrySink,
164    key: AuraKey,
165    aura_id: u32,
166    stacks: u8,
167    now: SimTime,
168) {
169    emit_aura_event(state, sink, key, now, |sink, at_ms, scope| {
170        sink.emit_aura_apply(aura_id, stacks, at_ms, scope);
171    });
172}
173
174pub(crate) fn emit_aura_refresh_event(
175    state: &CombatState,
176    sink: &mut TelemetrySink,
177    key: AuraKey,
178    aura_id: u32,
179    stacks: u8,
180    now: SimTime,
181) {
182    emit_aura_event(state, sink, key, now, |sink, at_ms, scope| {
183        sink.emit_aura_refresh(aura_id, stacks, at_ms, scope);
184    });
185}
186
187pub(super) fn emit_aura_expire_event(
188    state: &CombatState,
189    sink: &mut TelemetrySink,
190    key: AuraKey,
191    aura_id: u32,
192    now: SimTime,
193) {
194    emit_aura_event(state, sink, key, now, |sink, at_ms, scope| {
195        sink.emit_aura_expire(aura_id, at_ms, scope);
196    });
197}
198
199pub(super) fn exact_key_for_projection(
200    projection: AuraProjectionKey,
201    current_target: Option<EnemyIdx>,
202) -> Option<AuraKey> {
203    let (source, affected) = match projection.on() {
204        AuraOn::Player => (ActorId::Player, ActorId::Player),
205        AuraOn::Target => (ActorId::Player, ActorId::Enemy(current_target?)),
206        AuraOn::Pet => {
207            let pet = ActorId::Pet(PetIdx::PRIMARY);
208
209            (pet, pet)
210        }
211    };
212
213    Some(AuraKey::new(
214        projection.aura(),
215        source,
216        affected,
217        projection.on(),
218    ))
219}
220
221/// Refresh every one-way APL category projection from exact runtime aura state.
222pub(crate) fn refresh_aura_projections(buf: &mut DenseBuffer, current_target: Option<EnemyIdx>) {
223    let projections: Vec<_> = buf.aura_projection_keys().collect();
224
225    for projection in projections {
226        let exact = exact_key_for_projection(projection, current_target)
227            .and_then(|key| buf.aura(key).copied())
228            .unwrap_or_default();
229
230        if let Some(slot) = buf.aura_projection_mut(projection) {
231            *slot = exact;
232        }
233    }
234}
235
236// #t(fn: rust_unchecked_indexing) i bounded by state.defs.auras.len()
237pub(super) fn expire_conflicting_auras(ctx: &mut HookCtx<'_>, local: LocalAuraIdx, group_idx: u8) {
238    let mut to_expire: Vec<LocalAuraIdx> = Vec::with_capacity(ctx.state.defs.auras.len());
239
240    for i in 0..ctx.state.defs.auras.len() {
241        let i_local = LocalAuraIdx::new(u8::try_from(i).expect("aura count fits in u8"));
242
243        if i_local == local {
244            continue;
245        }
246
247        if let Some(group) = ctx.state.defs.auras[i].spell_group {
248            if group.group_idx == group_idx {
249                let Some(other_key) = context_aura_key(ctx, i_local) else {
250                    continue;
251                };
252
253                if ctx
254                    .buf
255                    .aura(other_key)
256                    .is_some_and(wowlab_engine_domain::rotation::AuraSlot::is_occupied)
257                {
258                    to_expire.push(i_local);
259                }
260            }
261        }
262    }
263
264    for idx in to_expire {
265        expire_aura(ctx, idx);
266    }
267}
268
269// #t(fn: rust_unchecked_indexing) i bounded by state.defs.spells.len() and auras.len()
270pub(super) fn sync_exclusive_group_enabled(
271    ctx: &mut HookCtx<'_>,
272    applied_aura_local: LocalAuraIdx,
273    group_idx: u8,
274) {
275    for spell in &ctx.state.defs.spells {
276        let start = spell.followup_effects.start as usize;
277        let len = usize::from(spell.followup_effects.len);
278        let followups = (start..start.saturating_add(len)).filter_map(|index| {
279            match ctx.state.defs.spell_effects.get(index) {
280                Some(crate::state::SpellEffectData::ApplyAura { aura_local }) => Some(*aura_local),
281                _ => None,
282            }
283        });
284
285        for aura_local in spell.applies_aura.into_iter().chain(followups) {
286            let aura = &ctx.state.defs.auras[aura_local.as_usize()];
287            let Some(SpellGroup {
288                group_idx: other_group,
289                rule: SpellGroupRule::Exclusive,
290            }) = aura.spell_group
291            else {
292                continue;
293            };
294
295            if other_group != group_idx {
296                continue;
297            }
298
299            let enabled = i32::from(aura_local == applied_aura_local);
300
301            if let Some(s) = ctx.buf.spell_mut(SpellIdx(spell.spell_id)) {
302                s.is_enabled = enabled;
303            }
304        }
305    }
306}
307
308#[inline]
309pub(super) fn refreshed_stacks(aura: &crate::state::AuraData, current: i32) -> i32 {
310    let max = i32::from(aura.max_stacks);
311
312    if aura.apply_at_max_stacks {
313        max
314    } else {
315        (current + i32::from(aura.doses)).min(max)
316    }
317}
318
319pub(crate) fn capture_snapshot_for(
320    state: &CombatState,
321    buf: &DenseBuffer,
322    effect: DamageEffectRef,
323    source: ActorId,
324    target: Option<EnemyIdx>,
325    now: SimTime,
326) -> DamageSnapshot {
327    let spell_id = effect.spell_id;
328    let (crit, vers) = read_player_stats_for_damage(buf);
329    let totals = accumulate_buffs(state, buf, target);
330    let spell_mods = spell_scoped_mods_for(
331        state,
332        buf,
333        source,
334        SpellModifierQuery::new(spell_id, effect, DamageFlags::PERIODIC),
335        target,
336        now,
337    );
338    let mastery_mult = target.map_or(1.0, |target| {
339        (state.config.mastery_hook)(&MasteryCtx {
340            state,
341            buf,
342            spell_id,
343            school: school_of(state, spell_id),
344            mastery: buffs::current_mastery_points(&crate::CombatView::new(state, buf)),
345            player_crit_pct: crit + totals.crit_pct,
346            mastery_spell: state.config.mastery_spell,
347            is_periodic: true,
348            is_pet: matches!(source, ActorId::Pet(_)),
349            source,
350            target: Some(target),
351            now,
352        })
353    });
354
355    DamageSnapshot {
356        ap: typed_attack_power_with(state, buf, crate::state::WeaponApType::MainHand, &totals),
357        sp: dynamic_base_powers(state, buf, &totals).1,
358        crit_pct: crit + totals.crit_pct + spell_mods.crit_chance_pct,
359        vers_pct: vers + totals.vers_pct,
360        mastery_mult,
361        damage_mult: totals.damage_mult * spell_mods.damage_mult,
362    }
363}
364
365#[cfg(test)]
366pub(crate) fn capture_snapshot_fixture(
367    view: crate::context::HookView<'_>,
368    spell_id: u32,
369    source: ActorId,
370) -> DamageSnapshot {
371    capture_snapshot_for(
372        view.state,
373        view.buf,
374        DamageEffectRef::new(spell_id, 0),
375        source,
376        view.target,
377        view.now,
378    )
379}
380
381pub(super) fn store_stronger_snapshot(
382    buf: &mut DenseBuffer,
383    key: AuraKey,
384    snapshot: DamageSnapshot,
385) -> bool {
386    let Some(aura) = buf.aura_mut(key) else {
387        return false;
388    };
389
390    if aura.snapshot_has != 0 && snapshot.damage_mult <= aura.snapshot_damage_mult {
391        return false;
392    }
393
394    aura.snapshot_has = 1;
395    aura.snapshot_ap = snapshot.ap;
396    aura.snapshot_sp = snapshot.sp;
397    aura.snapshot_crit = snapshot.crit_pct;
398    aura.snapshot_vers = snapshot.vers_pct;
399    aura.snapshot_mastery_mult = snapshot.mastery_mult;
400    aura.snapshot_damage_mult = snapshot.damage_mult;
401
402    true
403}
404
405/// Refreshes a snapshot with a persistent damage multiplier without compounding it.
406pub(crate) fn refresh_aura_snapshot_multiplier(
407    ctx: &mut HookCtx<'_>,
408    local: LocalAuraIdx,
409    multiplier: f64,
410) -> bool {
411    // BOUNDS: local indices are created from the registered aura definition table.
412    let aura = ctx.state.defs.auras[local.as_usize()];
413
414    if aura.periodic.is_none() || multiplier <= 0.0 {
415        return false;
416    }
417
418    let Some(key) = context_aura_key(ctx, local) else {
419        return false;
420    };
421
422    if !ctx
423        .buf
424        .aura(key)
425        .is_some_and(wowlab_engine_domain::rotation::AuraSlot::is_occupied)
426    {
427        return false;
428    }
429
430    if aura.is_snapshot {
431        let damage_spell_id = aura.periodic.map_or(aura.aura_id, |periodic| {
432            periodic.damage_spell_id_or(aura.aura_id)
433        });
434        let mut snapshot = capture_snapshot_for(
435            ctx.state,
436            ctx.buf,
437            aura.periodic
438                .and_then(|periodic| periodic.effect_ref)
439                .unwrap_or_else(|| DamageEffectRef::new(damage_spell_id, 0)),
440            ctx.source,
441            ctx.target,
442            ctx.now,
443        );
444
445        snapshot.damage_mult *= multiplier;
446        let changed = store_stronger_snapshot(ctx.buf, key, snapshot);
447
448        if changed {
449            refresh_aura_projections(ctx.buf, ctx.state.current_target());
450        }
451
452        return changed;
453    }
454
455    let Some(slot) = ctx.buf.aura_mut(key) else {
456        return false;
457    };
458
459    if slot.snapshot_has != 0 && multiplier <= slot.snapshot_damage_mult {
460        return false;
461    }
462
463    slot.snapshot_has = 1;
464    slot.snapshot_damage_mult = multiplier;
465    refresh_aura_projections(ctx.buf, ctx.state.current_target());
466
467    true
468}
469
470pub(super) fn revive_tick_chain(
471    ctx: &mut HookCtx<'_>,
472    aura: &crate::state::AuraData,
473    key: AuraKey,
474    previous_expiry: Option<f64>,
475) {
476    if aura.periodic.is_none() {
477        return;
478    }
479
480    let Some(a) = ctx.buf.aura_mut(key) else {
481        return;
482    };
483
484    let terminal_at_previous_expiry = previous_expiry.is_some_and(|expiry| a.next_tick >= expiry);
485
486    if !a.is_occupied()
487        || (a.next_tick > ctx.now.as_secs_f64() && !terminal_at_previous_expiry)
488        || a.tick_interval <= 0.0
489    {
490        return;
491    }
492
493    let next_tick = SimTime::from_secs_f64(ctx.now.as_secs_f64() + a.tick_interval);
494
495    if next_tick.as_secs_f64() <= a.expires_at {
496        a.next_tick = next_tick.as_secs_f64();
497        ctx.state.schedule(Event::AuraTick {
498            t: next_tick,
499            key,
500            target: ctx.target,
501        });
502    }
503}
504
505pub(super) fn update_aura_slot(
506    ctx: &mut HookCtx<'_>,
507    request: AuraSlotRequest<'_>,
508) -> Option<bool> {
509    let AuraSlotRequest { key, .. } = request;
510    let a = ctx.buf.aura_mut(key)?;
511    let is_fresh = !a.is_occupied();
512
513    if is_fresh {
514        initialize_aura_slot(ctx, request);
515    } else {
516        refresh_aura_slot(ctx, request);
517    }
518
519    Some(is_fresh)
520}
521
522pub(super) fn initialize_aura_slot(ctx: &mut HookCtx<'_>, request: AuraSlotRequest<'_>) {
523    let AuraSlotRequest {
524        aura,
525        key,
526        base_dur_ms,
527        base_dur,
528        is_permanent,
529    } = request;
530    let Some(a) = ctx.buf.aura_mut(key) else {
531        return;
532    };
533
534    a.expires_at = if is_permanent {
535        PERMANENT_AURA_EXPIRES_AT_S
536    } else {
537        ctx.now.saturating_add(base_dur).as_secs_f64()
538    };
539    a.stacks = if aura.apply_at_max_stacks {
540        i32::from(aura.max_stacks)
541    } else {
542        i32::from(aura.doses).min(i32::from(aura.max_stacks))
543    };
544    a.base_duration = f64::from(base_dur_ms) / MS_PER_SECOND;
545    a.max_stacks = i32::from(aura.max_stacks);
546    let stacks = wowlab_types::numeric::i32_to_u8_saturating(a.stacks);
547
548    emit_aura_apply_event(ctx.state, ctx.sink, key, aura.aura_id, stacks, ctx.now);
549}
550
551pub(super) fn refresh_aura_slot(ctx: &mut HookCtx<'_>, request: AuraSlotRequest<'_>) {
552    let AuraSlotRequest {
553        aura,
554        key,
555        base_dur_ms,
556        base_dur,
557        is_permanent,
558    } = request;
559    let Some(a) = ctx.buf.aura_mut(key) else {
560        return;
561    };
562
563    if !is_permanent {
564        let expires_ms =
565            wowlab_types::numeric::f64_to_u32_saturating_trunc(a.expires_at * MS_PER_SECOND);
566        let remaining_ms = expires_ms.saturating_sub(ctx.now.as_millis());
567        let carry_ms = match aura.refresh_behavior {
568            wowlab_types::data::RefreshBehavior::Pandemic => {
569                pandemic_carry_ms(base_dur_ms, remaining_ms)
570            }
571            wowlab_types::data::RefreshBehavior::Extend => remaining_ms,
572            wowlab_types::data::RefreshBehavior::Tick => {
573                let interval_ms = wowlab_types::numeric::f64_to_u32_saturating_round(
574                    a.tick_interval * MS_PER_SECOND,
575                );
576
577                if interval_ms == 0 {
578                    0
579                } else {
580                    remaining_ms % interval_ms
581                }
582            }
583            wowlab_types::data::RefreshBehavior::Clip
584            | wowlab_types::data::RefreshBehavior::None
585            | wowlab_types::data::RefreshBehavior::Duration => 0,
586        };
587        // docref:start auras-pandemic-carry
588        a.expires_at = ctx
589            .now
590            .saturating_add(base_dur)
591            .saturating_add(SimTime::from_millis(carry_ms))
592            .as_secs_f64();
593        // docref:end auras-pandemic-carry
594    }
595
596    a.stacks = refreshed_stacks(aura, a.stacks);
597    let stacks = wowlab_types::numeric::i32_to_u8_saturating(a.stacks);
598
599    emit_aura_refresh_event(ctx.state, ctx.sink, key, aura.aura_id, stacks, ctx.now);
600}
601
602pub(super) fn initialize_or_revive_periodic(
603    ctx: &mut HookCtx<'_>,
604    request: AuraSlotRequest<'_>,
605    is_fresh: bool,
606    previous_expiry: Option<f64>,
607) {
608    let AuraSlotRequest {
609        aura,
610        key,
611        base_dur_ms,
612        ..
613    } = request;
614
615    let Some(periodic) = aura.periodic.as_ref() else {
616        return;
617    };
618
619    if !is_fresh {
620        revive_tick_chain(ctx, aura, key, previous_expiry);
621
622        return;
623    }
624
625    let effective_tick_ms = buffs::effective_periodic_tick_ms(
626        &ctx.view().combat(),
627        aura.aura_id,
628        periodic.tick_interval_ms,
629    );
630    let base_interval_s = f64::from(effective_tick_ms) / MS_PER_SECOND;
631    let tick_interval_ms = buffs::effective_periodic_interval_ms(
632        &ctx.view().combat(),
633        aura.aura_id,
634        periodic.tick_interval_ms,
635        periodic.hasted_ticks,
636    );
637    let tick_interval_now = f64::from(tick_interval_ms) / MS_PER_SECOND;
638
639    if let Some(a) = ctx.buf.aura_mut(key) {
640        let time_rate = if a.time_rate_multiplier > 0.0 {
641            a.time_rate_multiplier
642        } else {
643            1.0
644        };
645
646        a.base_tick_interval = base_interval_s;
647        a.tick_interval = tick_interval_now * time_rate;
648        a.next_tick = if periodic.tick_on_application {
649            ctx.now.as_secs_f64()
650        } else {
651            ctx.now.as_secs_f64() + tick_interval_now * time_rate
652        };
653        a.remaining_ticks = wowlab_types::numeric::f64_to_i32_saturating_ceil(
654            f64::from(base_dur_ms) / MS_PER_SECOND / tick_interval_now,
655        );
656    }
657
658    if let Some(a) = ctx.buf.aura(key) {
659        ctx.state.schedule(Event::AuraTick {
660            t: SimTime::from_secs_f64(a.next_tick),
661            key,
662            target: ctx.target,
663        });
664    }
665}
666
667pub(super) fn refresh_snapshot(ctx: &mut HookCtx<'_>, request: AuraSlotRequest<'_>) {
668    let AuraSlotRequest { aura, key, .. } = request;
669
670    if !aura.is_snapshot || aura.periodic.is_none() {
671        return;
672    }
673
674    let damage_spell_id = aura
675        .periodic
676        .map_or(aura.aura_id, |p| p.damage_spell_id_or(aura.aura_id));
677    let snapshot = capture_snapshot_for(
678        ctx.state,
679        ctx.buf,
680        aura.periodic
681            .and_then(|periodic| periodic.effect_ref)
682            .unwrap_or_else(|| DamageEffectRef::new(damage_spell_id, 0)),
683        ctx.source,
684        ctx.target,
685        ctx.now,
686    );
687
688    store_stronger_snapshot(ctx.buf, key, snapshot);
689}
690
691pub(super) fn is_rolling_periodic(aura: &crate::state::AuraData) -> bool {
692    aura.periodic.is_some_and(|periodic| {
693        matches!(
694            periodic.effect,
695            crate::state::PeriodicKind::RollingDamageAp { .. }
696                | crate::state::PeriodicKind::RollingDamageSp { .. }
697        )
698    })
699}
700
701/// Ticks left on the aura slot at `now`; `0.0` when absent or expired.
702pub(super) fn rolling_ticks_left(ctx: &HookCtx<'_>, key: AuraKey) -> f64 {
703    ctx.buf.aura(key).map_or(0.0, |slot| {
704        if slot.tick_interval <= 0.0 {
705            return 0.0;
706        }
707
708        ((slot.expires_at - ctx.now.as_secs_f64()) / slot.tick_interval).max(0.0)
709    })
710}
711
712/// Blends this application into the rolling per-tick multiplier so total damage is preserved without diluting tick value.
713pub(super) fn combine_rolling_multiplier(
714    ctx: &mut HookCtx<'_>,
715    request: AuraSlotRequest<'_>,
716    old_ticks_left: f64,
717    new_multiplier: f64,
718) {
719    let AuraSlotRequest {
720        aura,
721        key,
722        base_dur_ms,
723        ..
724    } = request;
725
726    if !is_rolling_periodic(aura) {
727        return;
728    }
729    // The slot's tick_interval is the cadence ticks actually fire at (haste
730    // and cadence modifiers applied) — the same units rolling_ticks_left uses.
731
732    let Some(tick_interval) = ctx
733        .buf
734        .aura(key)
735        .map(|slot| slot.tick_interval)
736        .filter(|interval| *interval > 0.0)
737    else {
738        return;
739    };
740    let new_base_ticks = f64::from(base_dur_ms) / MS_PER_SECOND / tick_interval;
741    let new_ticks_left = rolling_ticks_left(ctx, key);
742
743    ctx.state.runtime.pools.rolling_tick_mult.combine(
744        key,
745        old_ticks_left,
746        new_base_ticks,
747        new_ticks_left,
748        new_multiplier,
749    );
750}
751
752pub(super) fn synchronize_aura_dependents(ctx: &mut HookCtx<'_>) {
753    refresh_aura_projections(ctx.buf, ctx.state.current_target());
754    synchronize_active_spell_gates(ctx.state, ctx.buf);
755    sync_spell_costs(ctx.state, ctx.buf);
756    sync_player_health_from_stamina(ctx.state, ctx.buf);
757    sync_recharge_rates(ctx.state, ctx.buf, ctx.now);
758    sync_max_charges(ctx.state, ctx.buf, ctx.now);
759    super::reproject_aura_time_rates(ctx);
760    super::super::auto_attack::reproject_player_auto_attack_speeds(ctx.state, ctx.buf, ctx.now);
761}
762
763pub(super) fn restore_active_shapeshift_form(ctx: &mut HookCtx<'_>) {
764    let restored = ctx
765        .state
766        .defs
767        .auras
768        .iter()
769        .enumerate()
770        .rev()
771        .find_map(|(index, aura)| {
772            if aura.shapeshift_form <= 0 {
773                return None;
774            }
775
776            let local = LocalAuraIdx::new(u8::try_from(index).ok()?);
777            let key = aura_key_for(ctx.state, local, ActorId::Player, None)?;
778
779            ctx.buf
780                .aura(key)
781                .is_some_and(wowlab_engine_domain::rotation::AuraSlot::is_occupied)
782                .then_some(aura.shapeshift_form)
783        })
784        .unwrap_or(0);
785
786    ctx.buf.player_mut().shapeshift_form = restored;
787    tracing::trace!(form = restored, "restored active shapeshift form");
788}