Skip to main content

wowlab_engine_combat/builder/
buffer_init.rs

1//! Initial dense-buffer population from the assembled combat program.
2
3use 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        // Cooldown enforcement is a runtime invariant, independent of APL reads.
122
123        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/// Broad pre-plan projection retained only as a counter-backed semantic oracle.
308#[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    // docref:start pets-seed-slot
408    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    // docref:end pets-seed-slot
416
417    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;