wowlab_engine_combat/pipeline/
channel.rs1use super::{
2 CombatCtx, Event, SimTime, SpellData, SpellIdx, break_stealth_if_active, current_haste_mult,
3 fire_cast_hook, fire_player_cast_hooks, process_channel_start_payload, ready_after_gcd,
4 schedule_ready_after_cast, start_cooldown_in_slot, update_history,
5};
6
7pub(super) fn process_channel_cast(
8 ctx: &mut CombatCtx<'_>,
9 candidate: crate::handler::can_cast::CastCandidate,
10 tick_cost_aura: Option<crate::state::CastAuraRequirement>,
11 tick_damage_aura: Option<crate::state::CastAuraRequirement>,
12 spell: &SpellData,
13 empower_rank: u8,
14) {
15 let spell_local = candidate.effective_local;
16 let cooldown_local = candidate.cooldown_local;
17 let spell_handle = candidate.effective_slot(ctx.program);
18 let history_handle = candidate.effective_history(ctx.program);
19 let now = ctx.now;
20 let spell_id = spell.spell_id;
21
22 if spell.cooldown.has_cooldown {
23 let cooldown_ms =
24 start_cooldown_in_slot(ctx.program, ctx.instance, spell_local, cooldown_local, now);
25
26 ctx.sink
27 .emit_cooldown_start(spell_id, cooldown_ms, now.as_millis());
28 }
29
30 let haste_mult = current_haste_mult(&ctx.view());
31 let channel_ms = if spell.channel.behavior.duration_hasted {
32 wowlab_types::numeric::f64_to_u32_saturating_trunc(
33 f64::from(spell.cast_time_ms) / haste_mult,
34 )
35 } else {
36 spell.cast_time_ms
37 };
38
39 if channel_ms == 0 {
40 tracing::warn!(
41 spell_id,
42 "channel spell has cast_time_ms=0; treating as instant no-op"
43 );
44 schedule_ready_after_cast(ctx, spell, now);
45
46 return;
47 }
48
49 let channel_end = now.saturating_add(SimTime::from_millis(channel_ms));
50 let channel_end_secs = channel_end.as_secs_f64();
51
52 ctx.instance.buffer.player_mut().channel_end = channel_end_secs;
53 ctx.instance.runtime.casting.channel_generation = ctx
54 .instance
55 .runtime
56 .casting
57 .channel_generation
58 .wrapping_add(1);
59 let channel_generation = ctx.instance.runtime.casting.channel_generation;
60
61 ctx.instance.runtime.casting.active_channel_target = Some(ctx.target);
62 ctx.instance.runtime.casting.active_channel_damage_mult = 1.0;
63 let channel_identity = crate::state::ActiveChannelIdentity {
64 local: spell_local,
65 spell: SpellIdx::from_raw(spell_id),
66 handle: spell_handle,
67 tick_cost_aura,
68 tick_damage_aura,
69 };
70
71 ctx.instance.runtime.casting.active_channel = Some(channel_identity);
72 ctx.instance.runtime.casting.active_channel_pushback_count = 0;
73 let s = ctx.instance.buffer.spell_mut_at(channel_identity.handle);
74
75 s.is_channeling = 1;
76 s.channel_end = channel_end_secs;
77
78 #[cfg(feature = "rotation-optimizations")]
79 ctx.instance.runtime.rotation_mutations.record_semantic(
80 crate::state::runtime::RotationSemanticMutation::SpellState,
81 crate::state::runtime::RotationMutationDirection::Rise,
82 now,
83 );
84
85 let tick_count = spell.channel.tick_count;
86
87 if tick_count == 0 {
88 tracing::warn!(
89 spell_id,
90 "channel has tick_count=0; no tick damage will be delivered"
91 );
92 } else {
93 let base_interval_ms = if spell.channel.tick_interval_ms > 0 {
94 spell.channel.tick_interval_ms
95 } else {
96 let elapsed_intervals =
97 tick_count.saturating_sub(u8::from(spell.channel.timing.tick_zero));
98
99 spell.cast_time_ms / u32::from(elapsed_intervals.max(1))
100 };
101 let interval_ms = if spell.channel.timing.hasted_ticks {
102 wowlab_types::numeric::f64_to_u32_saturating_trunc(
103 f64::from(base_interval_ms) / haste_mult,
104 )
105 } else {
106 base_interval_ms
107 };
108
109 if interval_ms == 0 {
110 tracing::warn!(
111 spell_id,
112 tick_count,
113 channel_ms,
114 "channel tick interval rounds to zero; skipping ticks"
115 );
116 } else {
117 ctx.instance.runtime.casting.active_channel_last_tick = now;
118 ctx.instance.runtime.casting.active_channel_tick_interval_ms = interval_ms;
119 ctx.instance.runtime.casting.active_channel_on_last_tick = false;
120 let mut scheduled_ticks = 0_u8;
121
122 visit_channel_tick_offsets(spell, channel_ms, interval_ms, |offset_ms| {
123 let tick_t = now.saturating_add(SimTime::from_millis(offset_ms));
124
125 ctx.instance.schedule(Event::ChannelTick {
126 t: tick_t,
127 spell: ctx.program.scheduled_spell(spell_local),
128 source: ctx.source,
129 target: ctx.target,
130 generation: channel_generation,
131 });
132 scheduled_ticks = scheduled_ticks.saturating_add(1);
133 });
134 ctx.instance.runtime.casting.active_channel_ticks_remaining = scheduled_ticks;
135 }
136 }
137
138 ctx.instance.schedule(Event::PlayerReady {
139 t: ready_after_gcd(ctx.instance.buffer.player().gcd_end, channel_end).saturating_add(
140 SimTime::from_millis(
141 u32::from(spell.channel.completion.apply_lag)
142 * ctx.program.config.cast_latency.channel_ms,
143 ),
144 ),
145 });
146
147 process_channel_start_payload(ctx, spell_local, spell);
148
149 fire_cast_hook(ctx, spell_local, empower_rank);
150 fire_player_cast_hooks(ctx, spell.spell_id, empower_rank);
151
152 if spell.behavior.breaks_stealth {
153 break_stealth_if_active(&mut ctx.hook_ctx());
154 }
155
156 ctx.instance.buffer.spell_mut_at(spell_handle).last_used = now.as_secs_f64();
157
158 update_history(ctx.instance, history_handle, spell.behavior.off_gcd, now);
159}
160
161pub(super) fn visit_channel_tick_offsets(
162 spell: &SpellData,
163 channel_ms: u32,
164 interval_ms: u32,
165 mut visit: impl FnMut(u32),
166) {
167 if !spell.channel.timing.hasted_ticks || spell.channel.behavior.duration_hasted {
168 let first_tick = u32::from(!spell.channel.timing.tick_zero);
169
170 for tick in first_tick..first_tick + u32::from(spell.channel.tick_count) {
171 visit(interval_ms * tick);
172 }
173
174 return;
175 }
176
177 let initial_ticks = u32::from(spell.channel.timing.tick_zero);
178 let interval_ticks = channel_ms.saturating_sub(1) / interval_ms;
179 let interval_ticks = interval_ticks.min(u32::from(u8::MAX) - initial_ticks - 1);
180
181 if spell.channel.timing.tick_zero {
182 visit(0);
183 }
184
185 for tick in 1..=interval_ticks {
186 visit(interval_ms * tick);
187 }
188
189 visit(channel_ms);
190}