wowlab_engine_combat/builder/
buffer_init.rs1use wowlab_engine_domain::{
4 encounter::EnemyMovementState,
5 rotation::{CombatSlot, CooldownSlot, PetSlot, ResourceSlot, SwingSlot},
6};
7use wowlab_engine_gamedata::ResolvedGameData;
8use wowlab_types::{
9 constants::MS_PER_SECOND,
10 sim::{ActorId, EnemyRole, IntSet, SimTime, SpellIdx},
11};
12
13use crate::state::{CombatInstance, ProjectionDirty, SpellData};
14
15pub(crate) fn projected_travel_time_ms(instance: &CombatInstance, spell: &SpellData) -> u32 {
16 let distance = instance
17 .current_target()
18 .and_then(|target| {
19 Some((
20 instance.actor_transform(ActorId::Player)?,
21 instance.actor_transform(ActorId::Enemy(target))?,
22 ))
23 })
24 .map_or(0.0, |(source, target)| {
25 source.position.distance(target.position)
26 });
27
28 crate::travel::impact_offset_ms(spell.travel, distance)
29}
30
31pub(crate) fn expand_spell_override_chains(ids: &mut Vec<u32>, data: &ResolvedGameData) {
32 let seeds = ids.clone();
33
34 for seed in seeds {
35 let mut current = SpellIdx::from_raw(seed);
36 let mut seen = IntSet::<SpellIdx>::default();
37
38 while seen.insert(current) {
39 let Some(replacement) = data.spell_override(current) else {
40 break;
41 };
42
43 if !ids.contains(&replacement.as_u32()) {
44 ids.push(replacement.as_u32());
45 }
46
47 current = replacement;
48 }
49 }
50}
51
52pub(crate) fn expand_spell_learn_chains(ids: &mut Vec<u32>, data: &ResolvedGameData) {
53 let mut pending = ids.clone();
54 let mut cursor = 0;
55
56 while let Some(&teacher) = pending.get(cursor) {
57 cursor += 1;
58
59 for learned in data.learned_spells(SpellIdx::from_raw(teacher)) {
60 let spell_id = learned.as_u32();
61
62 if !ids.contains(&spell_id) {
63 ids.push(spell_id);
64 pending.push(spell_id);
65 }
66 }
67 }
68}
69
70fn resource_type_id_i32(rt: Option<wowlab_types::combat::ResourceType>) -> i32 {
71 rt.map_or(0, |t| i32::from(u8::from(t)))
72}
73
74pub(crate) struct InitialBufferConfig<'a> {
75 pub(crate) resource_max: f64,
76 pub(crate) talent_ranks: &'a [(String, u8)],
77 pub(crate) talent_spell_ids: &'a [u32],
78 pub(crate) selected_talent_spell_ids: &'a [u32],
79 pub(crate) selected_replaced_spell_ids: &'a [u32],
80}
81
82fn init_resource_slots(
83 program: &crate::state::CombatProgram,
84 instance: &mut CombatInstance,
85 resource_max: f64,
86) {
87 let base = &program.config.base_stats;
88 let resource_start = if base.resource_start >= 0.0 {
89 base.resource_start
90 } else {
91 resource_max
92 };
93
94 if let Some(rt) = base.resource_type
95 && let Some(res) = program.buffer_resource_mut(instance, rt)
96 {
97 *res = ResourceSlot {
98 current: resource_start,
99 max: resource_max,
100 regen_per_sec: base.base_regen,
101 };
102 }
103
104 if let Some(rt) = base.secondary_resource_type
105 && let Some(res) = program.buffer_resource_mut(instance, rt)
106 {
107 *res = ResourceSlot {
108 current: 0.0,
109 max: base.secondary_resource_max,
110 regen_per_sec: 0.0,
111 };
112 }
113}
114
115fn init_cooldown_slots(program: &crate::state::CombatProgram, instance: &mut CombatInstance) {
116 for (local, spell) in program.spells.iter().enumerate() {
117 if !spell.cooldown.has_cooldown {
118 continue;
119 }
120
121 let local = crate::LocalSpellIdx::new(
124 u8::try_from(local).expect("finalized spell count fits local index"),
125 );
126 let cd = instance
127 .buffer
128 .cooldown_mut_at(program.buffer_bindings.cooldown(local));
129
130 {
131 if spell.cooldown.max_charges > 0 {
132 *cd = CooldownSlot {
133 ready_at: 0.0,
134 duration: f64::from(spell.cooldown.recharge_ms) / MS_PER_SECOND,
135 current_charges: i32::from(spell.cooldown.max_charges),
136 max_charges: i32::from(spell.cooldown.max_charges),
137 next_charge_at: 0.0,
138 recharge_time: f64::from(spell.cooldown.recharge_ms) / MS_PER_SECOND,
139 };
140 } else {
141 cd.ready_at = 0.0;
142 cd.duration = f64::from(spell.cooldown.cooldown_duration_ms) / MS_PER_SECOND;
143 }
144 }
145 }
146}
147
148fn init_spell_slots(
149 program: &crate::state::CombatProgram,
150 instance: &mut CombatInstance,
151 talent_spell_ids: &[u32],
152 selected_talent_spell_ids: &[u32],
153 selected_replaced_spell_ids: &[u32],
154) {
155 let rt_id = resource_type_id_i32(program.config.base_stats.resource_type);
156 let sec_rt_id = resource_type_id_i32(program.config.base_stats.secondary_resource_type);
157
158 for (local, spell) in program.spells.iter().enumerate() {
159 let primary_cost = crate::systems::base_primary_spell_cost(program, instance, spell);
160 let travel_time = f64::from(projected_travel_time_ms(instance, spell)) / MS_PER_SECOND;
161 let local = crate::LocalSpellIdx::new(
162 u8::try_from(local).expect("finalized spell count fits local index"),
163 );
164 let profile = program.rotation.cast_profile_ref(local);
165 let is_in_range = !profile.requires_hostile_target
166 || instance.current_target().is_some_and(|target| {
167 instance.spell_target_in_range_profile(program, profile, target)
168 });
169
170 let s = instance
171 .buffer
172 .spell_mut_at(program.buffer_bindings.spell(local));
173
174 {
175 let is_unselected_talent_spell = talent_spell_ids.contains(&spell.spell_id)
176 && !selected_talent_spell_ids.contains(&spell.spell_id);
177 let is_replaced_spell = selected_replaced_spell_ids.contains(&spell.spell_id);
178
179 s.cost = primary_cost;
180 s.secondary_cost = spell.cost.secondary_resource_cost;
181 s.cast_time = f64::from(spell.cast_time_ms) / MS_PER_SECOND;
182 s.travel_time = travel_time;
183 s.gcd = f64::from(spell.base_gcd_ms()) / MS_PER_SECOND;
184 s.is_enabled = i32::from(!is_unselected_talent_spell && !is_replaced_spell);
185 s.range = profile.hostile_max_range;
186 s.is_in_range = i32::from(is_in_range);
187 s.resource_type_id = rt_id;
188 s.secondary_resource_type_id = sec_rt_id;
189 }
190 }
191}
192
193fn init_swing_slots(program: &crate::state::CombatProgram, instance: &mut CombatInstance) {
194 for (i, aa) in program.auto_attacks.iter().enumerate() {
195 if let Some(sw) = program.buffer_swing_mut(instance, i) {
196 *sw = SwingSlot {
197 next_swing_at: if aa.starts_active { 0.0 } else { f64::INFINITY },
198 speed: f64::from(aa.swing_ms) / MS_PER_SECOND,
199 swing_ms: f64::from(aa.swing_ms),
200 };
201 }
202 }
203}
204
205pub(crate) fn project_target_health(
206 program: &crate::state::CombatProgram,
207 instance: &mut CombatInstance,
208 now: SimTime,
209) {
210 let Some(enemy) = instance.current_target() else {
211 return;
212 };
213 let Some(definition) = program.enemy_definition(enemy) else {
214 return;
215 };
216 let Some(actor) = instance.enemy(enemy) else {
217 return;
218 };
219 let actor_health = actor.current_health();
220 let time_to_die = instance.enemy_time_to_die(enemy, now).unwrap_or(-1.0);
221
222 let unit = program.target_unit_mut(instance);
223
224 unit.health = actor_health;
225 unit.max_health = definition.health_model().max_health();
226 unit.time_to_die = time_to_die;
227}
228
229pub(crate) fn project_target_identity_range_travel(
230 program: &crate::state::CombatProgram,
231 instance: &mut CombatInstance,
232) {
233 if let Some(enemy) = instance.current_target()
234 && let Some(definition) = program.enemy_definition(enemy)
235 && let Some(actor) = instance.enemy(enemy)
236 {
237 let actor_moving = matches!(actor.movement(), EnemyMovementState::Moving { .. });
238 let actor_transform = actor.current_transform();
239 let distance = instance
240 .runtime
241 .player_transform
242 .position
243 .distance(actor_transform.position);
244 let unit = program.target_unit_mut(instance);
245
246 unit.is_boss = i32::from(definition.role() == EnemyRole::Boss);
247 unit.is_casting = 0;
248 unit.cast_end = 0.0;
249 unit.is_moving = i32::from(actor_moving);
250 unit.is_add = i32::from(definition.role() == EnemyRole::Add);
251 unit.distance = distance;
252 }
253
254 for projection in program.rotation.projections() {
255 wowlab_types::bench_metrics::record_planned_rotation_projection_binding();
256 let binding = projection.spell;
257 let effective_local = binding
258 .override_binding
259 .filter(|override_binding| {
260 let aura = instance.buffer.aura_at(override_binding.aura_handle);
261
262 aura.is_occupied()
263 && aura.stacks >= i32::from(override_binding.aura_min_stacks.max(1))
264 })
265 .map_or(binding.local, |override_binding| {
266 override_binding.effective_local
267 });
268 let spell = program.spell(effective_local);
269 let profile = program.rotation.cast_profile_ref(effective_local);
270 let range = projection
271 .usage
272 .contains(wowlab_engine_domain::rotation::SpellProjectionUsage::RANGE)
273 .then(|| {
274 crate::systems::active_targeting_value(
275 crate::context::ActorView::new(program, instance, ActorId::Player),
276 program.damage.spell_action(profile.local),
277 wowlab_engine_domain::dbc::ModifierPropertyKind::Range,
278 profile.hostile_max_range,
279 )
280 });
281 let in_range = range.map(|range| {
282 !profile.requires_hostile_target
283 || instance.current_target().is_some_and(|target| {
284 instance
285 .spell_target_in_range_profile_with_maximum(program, profile, target, range)
286 })
287 });
288 let travel_time = projection
289 .usage
290 .contains(wowlab_engine_domain::rotation::SpellProjectionUsage::TRAVEL)
291 .then(|| projected_travel_time_ms(instance, spell));
292 let slot = instance.buffer.spell_mut_at(binding.spell_slot);
293
294 if let Some(travel_time) = travel_time {
295 slot.travel_time = f64::from(travel_time) / MS_PER_SECOND;
296 }
297
298 if let Some(range) = range {
299 slot.range = range;
300 slot.is_in_range = i32::from(in_range.unwrap_or(false));
301 }
302 }
303
304 crate::systems::refresh_all_aura_projections(program, instance);
305}
306
307#[cfg(all(test, feature = "bench-metrics"))]
309pub(crate) fn reference_project_all_rotation_spell_fields(
310 program: &crate::state::CombatProgram,
311 instance: &mut CombatInstance,
312) {
313 for index in 0..program.spells().len() {
314 wowlab_types::bench_metrics::record_scanned_rotation_projection_spell();
315 let local = crate::state::LocalSpellIdx::new(
316 u8::try_from(index).expect("finalized spell count fits the local-index domain"),
317 );
318 let binding = program.rotation.local_binding(local);
319 let spell = program.spell(local);
320 let profile = program.rotation.cast_profile_ref(local);
321 let range = crate::systems::active_targeting_value(
322 crate::context::ActorView::new(program, instance, ActorId::Player),
323 program.damage.spell_action(profile.local),
324 wowlab_engine_domain::dbc::ModifierPropertyKind::Range,
325 profile.hostile_max_range,
326 );
327 let in_range = !profile.requires_hostile_target
328 || instance.current_target().is_some_and(|target| {
329 instance.spell_target_in_range_profile_with_maximum(program, profile, target, range)
330 });
331 let travel_time = projected_travel_time_ms(instance, spell);
332 let slot = instance.buffer.spell_mut_at(binding.spell_slot);
333
334 slot.travel_time = f64::from(travel_time) / MS_PER_SECOND;
335 slot.range = range;
336 slot.is_in_range = i32::from(in_range);
337 }
338}
339
340pub(crate) fn project_population(
341 program: &crate::state::CombatProgram,
342 instance: &mut CombatInstance,
343) {
344 let enemy_count = instance.active_alive_enemy_count();
345 let combat = instance.buffer.combat_mut();
346
347 *combat = CombatSlot {
348 combat_start: 0.0,
349 combat_duration: program.config.encounter.fight_duration_secs(),
350 enemy_count,
351 enemy_spell_targets_hit: enemy_count,
352 };
353}
354
355pub(crate) fn project_initial_encounter(
356 program: &crate::state::CombatProgram,
357 instance: &mut CombatInstance,
358 now: SimTime,
359) {
360 project_population(program, instance);
361 project_target_health(program, instance, now);
362 project_target_identity_range_travel(program, instance);
363 instance.clear_projection_dirty();
364}
365
366pub(crate) fn flush_dirty_projections(
367 program: &crate::state::CombatProgram,
368 instance: &mut CombatInstance,
369) {
370 let encounter_dirty = instance.take_projection_dirty();
371
372 if encounter_dirty.is_none() && !instance.aura_projections_are_dirty() {
373 return;
374 }
375
376 #[cfg(test)]
377 instance.record_projection_flush();
378
379 if let Some((dirty, now)) = encounter_dirty {
380 if dirty.contains(ProjectionDirty::POPULATION) {
381 project_population(program, instance);
382 }
383
384 if dirty.contains(ProjectionDirty::TARGET_HEALTH) {
385 project_target_health(program, instance, now);
386 }
387
388 if dirty.contains(ProjectionDirty::TARGET_IDENTITY_RANGE_TRAVEL) {
389 project_target_identity_range_travel(program, instance);
390
391 return;
392 }
393 }
394
395 while let Some(index) = instance.pop_aura_projection_dirty() {
396 crate::systems::auras::refresh_planned_aura_projection(program, instance, index);
397 }
398}
399
400pub(crate) fn populate_initial_buffer(
401 program: &crate::state::CombatProgram,
402 instance: &mut CombatInstance,
403 cfg: &InitialBufferConfig<'_>,
404) {
405 instance.buffer.reset_and_defaults();
406
407 if program.config.base_stats.has_pet {
409 *instance.buffer.pet_mut() = PetSlot {
410 is_active: 1,
411 count: 0,
412 expires_at: 0.0,
413 };
414 }
415 init_resource_slots(program, instance, cfg.resource_max);
418 init_cooldown_slots(program, instance);
419 init_spell_slots(
420 program,
421 instance,
422 cfg.talent_spell_ids,
423 cfg.selected_talent_spell_ids,
424 cfg.selected_replaced_spell_ids,
425 );
426
427 for (name, rank) in cfg.talent_ranks {
428 if let Some(talent) = program.buffer_talent_mut(instance, name) {
429 talent.is_enabled = i32::from(*rank > 0);
430 talent.rank = i32::from(*rank);
431 talent.max_rank = i32::from((*rank).max(1));
432 }
433 }
434
435 init_swing_slots(program, instance);
436
437 for binding in program.rotation.item_uses() {
438 let item_slot = instance.buffer.item_mut_at(binding.item_slot);
439
440 item_slot.is_item = 1;
441 item_slot.has_cooldown = 1;
442 }
443
444 project_initial_encounter(program, instance, SimTime::ZERO);
445
446 write_initial_player_stats(program, instance);
447}
448
449pub(crate) fn write_initial_player_stats(
450 program: &crate::state::CombatProgram,
451 instance: &mut CombatInstance,
452) {
453 let stats = &program.config.base_stats.stats;
454 let p = instance.buffer.player_mut();
455
456 p.attack_power = stats.attack_power;
457 p.spell_power = stats.spell_power;
458 p.crit = stats.crit_chance;
459 p.set_haste(stats.haste);
460 p.mastery = stats.mastery;
461 p.versatility = stats.versatility;
462 p.armor = stats.armor;
463 p.stamina = stats.stamina;
464 p.primary_stat = stats.intellect.max(stats.agility).max(stats.strength);
465 p.is_alive = 1;
466 p.in_combat = 1;
467}
468
469#[cfg(test)]
470#[allow(
471 clippy::float_cmp,
472 reason = "buffer projection tests assert exact copied fixture values"
473)]
474mod tests;