wowlab_engine_combat/pipeline/
channel.rs1use super::{
2 CombatCtx, Event, LocalSpellIdx, SimTime, SpellData, SpellIdx, break_stealth_if_active,
3 current_haste_mult, fire_cast_hook, fire_player_cast_hooks, process_channel_start_payload,
4 ready_after_gcd, schedule_ready_after_cast, start_cooldown_in_slot, update_history,
5};
6
7pub(super) fn process_channel_cast(
8 ctx: &mut CombatCtx<'_>,
9 spell_local: LocalSpellIdx,
10 cooldown_local: LocalSpellIdx,
11 spell: &SpellData,
12 empower_rank: u8,
13 now: SimTime,
14) {
15 let spell_id = spell.spell_id;
16
17 if spell.cooldown.has_cooldown {
18 let cooldown_ms =
19 start_cooldown_in_slot(ctx.state, ctx.buf, spell_local, cooldown_local, now);
20
21 ctx.sink
22 .emit_cooldown_start(spell_id, cooldown_ms, now.as_millis());
23 }
24
25 let haste_mult = current_haste_mult(&ctx.view());
26 let channel_ms = if spell.channel.behavior.duration_hasted {
27 wowlab_types::numeric::f64_to_u32_saturating_trunc(
28 f64::from(spell.cast_time_ms) / haste_mult,
29 )
30 } else {
31 spell.cast_time_ms
32 };
33
34 if channel_ms == 0 {
35 tracing::warn!(
36 spell_id,
37 "channel spell has cast_time_ms=0; treating as instant no-op"
38 );
39 schedule_ready_after_cast(ctx, spell, now);
40
41 return;
42 }
43
44 let channel_end = now.saturating_add(SimTime::from_millis(channel_ms));
45 let channel_end_secs = channel_end.as_secs_f64();
46
47 ctx.buf.player_mut().channel_end = channel_end_secs;
48 ctx.state.runtime.casting.channel_generation =
49 ctx.state.runtime.casting.channel_generation.wrapping_add(1);
50 let channel_generation = ctx.state.runtime.casting.channel_generation;
51
52 ctx.state.runtime.casting.active_channel_target = Some(ctx.target);
53 ctx.state.runtime.casting.active_channel_damage_mult = 1.0;
54 ctx.state.runtime.casting.active_channel_spell_id = Some(spell_id);
55 ctx.state.runtime.casting.active_channel_pushback_count = 0;
56 let spell_idx = SpellIdx(spell_id);
57
58 if let Some(s) = ctx.buf.spell_mut(spell_idx) {
59 s.is_channeling = 1;
60 s.channel_end = channel_end_secs;
61 }
62
63 let tick_count = spell.channel.tick_count;
64
65 if tick_count == 0 {
66 tracing::warn!(
67 spell_id,
68 "channel has tick_count=0; no tick damage will be delivered"
69 );
70 } else {
71 let base_interval_ms = if spell.channel.tick_interval_ms > 0 {
72 spell.channel.tick_interval_ms
73 } else {
74 let elapsed_intervals =
75 tick_count.saturating_sub(u8::from(spell.channel.timing.tick_zero));
76
77 spell.cast_time_ms / u32::from(elapsed_intervals.max(1))
78 };
79 let interval_ms = if spell.channel.timing.hasted_ticks {
80 wowlab_types::numeric::f64_to_u32_saturating_trunc(
81 f64::from(base_interval_ms) / haste_mult,
82 )
83 } else {
84 base_interval_ms
85 };
86
87 if interval_ms == 0 {
88 tracing::warn!(
89 spell_id,
90 tick_count,
91 channel_ms,
92 "channel tick interval rounds to zero; skipping ticks"
93 );
94 } else {
95 let tick_offsets = channel_tick_offsets(spell, channel_ms, interval_ms);
96
97 ctx.state.runtime.casting.active_channel_last_tick = now;
98 ctx.state.runtime.casting.active_channel_tick_interval_ms = interval_ms;
99 ctx.state.runtime.casting.active_channel_ticks_remaining =
100 u8::try_from(tick_offsets.len()).unwrap_or(u8::MAX);
101 ctx.state.runtime.casting.active_channel_on_last_tick = false;
102
103 for offset_ms in tick_offsets {
104 let tick_t = now.saturating_add(SimTime::from_millis(offset_ms));
105
106 ctx.state.schedule(Event::ChannelTick {
107 t: tick_t,
108 spell_id: SpellIdx::from_raw(spell_id),
109 source: ctx.source,
110 target: ctx.target,
111 generation: channel_generation,
112 });
113 }
114 }
115 }
116
117 ctx.state.schedule(Event::PlayerReady {
118 t: ready_after_gcd(ctx.buf.player().gcd_end, channel_end).saturating_add(
119 SimTime::from_millis(
120 u32::from(spell.channel.completion.apply_lag)
121 * ctx.state.config.cast_latency.channel_ms,
122 ),
123 ),
124 });
125
126 process_channel_start_payload(ctx, spell);
127
128 fire_cast_hook(ctx, spell_local, empower_rank);
129 fire_player_cast_hooks(ctx, spell.spell_id, empower_rank);
130
131 if spell.behavior.breaks_stealth {
132 break_stealth_if_active(&mut ctx.hook_ctx());
133 }
134
135 if let Some(s) = ctx.buf.spell_mut(spell_idx) {
136 s.last_used = now.as_secs_f64();
137 }
138
139 update_history(ctx.buf, spell_idx, spell.behavior.off_gcd);
140}
141
142pub(super) fn channel_tick_offsets(
143 spell: &SpellData,
144 channel_ms: u32,
145 interval_ms: u32,
146) -> Vec<u32> {
147 if !spell.channel.timing.hasted_ticks || spell.channel.behavior.duration_hasted {
148 let first_tick = u32::from(!spell.channel.timing.tick_zero);
149
150 return (first_tick..first_tick + u32::from(spell.channel.tick_count))
151 .map(|tick| interval_ms * tick)
152 .collect();
153 }
154
155 let initial_ticks = u32::from(spell.channel.timing.tick_zero);
156 let interval_ticks = channel_ms.saturating_sub(1) / interval_ms;
157 let interval_ticks = interval_ticks.min(u32::from(u8::MAX) - initial_ticks - 1);
158 let capacity = usize::try_from(initial_ticks + interval_ticks + 1)
159 .expect("channel tick count is clamped below usize capacity");
160 let mut offsets = Vec::with_capacity(capacity);
161
162 if spell.channel.timing.tick_zero {
163 offsets.push(0);
164 }
165
166 offsets.extend((1..=interval_ticks).map(|tick| interval_ms * tick));
167 offsets.push(channel_ms);
168
169 offsets
170}