1use wowlab_engine_domain::{
4 constants::OFF_HAND_DAMAGE_MULT,
5 dbc::{AuraSubtypeKind, SpellEffectKind, spell_effect_is},
6};
7
8use super::{TriggerProgramLowering, TriggerProgramRegistration};
9use crate::builder::def::{BuilderDamageDef, BuilderSpellEffect, PeriodicEffectDef};
10
11#[derive(Debug, thiserror::Error)]
12pub(super) enum TriggerProgramLowerError {
13 #[error("trigger program references unregistered aura {aura_id}")]
14 UnregisteredAura { aura_id: u32 },
15 #[error("trigger program references unregistered cooldown category {category}")]
16 UnregisteredCooldownCategory { category: i32 },
17 #[error("trigger program references unregistered cooldown spell {spell_id}")]
18 UnregisteredCooldownSpell { spell_id: u32 },
19 #[error("trigger program damage uses an unsupported damage kind")]
20 UnsupportedDamageKind,
21}
22
23wowlab_engine_macros::define_error! {
24#[derive(Debug)]
25struct TriggerProgramError {
26 #[source]
27 kind: TriggerProgramErrorKind,
28}
29
30#[derive(Debug, thiserror::Error)]
31enum TriggerProgramErrorKind {
32 #[error("{0}")]
33 Resolve(#[source] wowlab_engine_domain::dbc::TriggerResolveError),
34 #[error("{0}")]
35 Lower(#[source] TriggerProgramLowerError),
36}
37}
38
39#[cfg(test)]
40impl TriggerProgramError {
41 const fn is_resolve(&self) -> bool {
42 matches!(self.kind, TriggerProgramErrorKind::Resolve(_))
43 }
44
45 const fn is_lower(&self) -> bool {
46 matches!(self.kind, TriggerProgramErrorKind::Lower(_))
47 }
48}
49
50impl From<TriggerProgramLowerError> for TriggerProgramError {
51 fn from(source: TriggerProgramLowerError) -> Self {
52 Self {
53 kind: TriggerProgramErrorKind::Lower(source),
54 }
55 }
56}
57
58impl From<wowlab_engine_domain::dbc::TriggerResolveError> for TriggerProgramError {
59 fn from(source: wowlab_engine_domain::dbc::TriggerResolveError) -> Self {
60 Self {
61 kind: TriggerProgramErrorKind::Resolve(source),
62 }
63 }
64}
65
66fn lower_trigger_damage(
67 triggered: wowlab_engine_domain::dbc::TriggeredDamage,
68) -> Result<(BuilderDamageDef, f64), TriggerProgramLowerError> {
69 let damage = BuilderDamageDef::from_resolved(triggered.damage, OFF_HAND_DAMAGE_MULT)
70 .ok_or(TriggerProgramLowerError::UnsupportedDamageKind)?;
71 let base_points = crate::state::resolved_damage_base_points(triggered.damage, 1.0);
72
73 Ok((damage, base_points))
74}
75
76impl TriggerProgramLowering<'_> {
77 pub(super) fn lower_trigger_program(
78 &self,
79 program: &wowlab_engine_domain::dbc::TriggerProgram,
80 ) -> Result<Vec<BuilderSpellEffect>, TriggerProgramLowerError> {
81 self.lower_trigger_operations(&program.operations)
82 }
83
84 fn lower_trigger_operations(
86 &self,
87 operations: &[wowlab_engine_domain::dbc::TriggerOperation],
88 ) -> Result<Vec<BuilderSpellEffect>, TriggerProgramLowerError> {
89 let mut effects = Vec::with_capacity(operations.len());
90
91 for operation in operations {
92 match operation {
93 wowlab_engine_domain::dbc::TriggerOperation::Damage(triggered) => {
94 let (damage, base_points) = lower_trigger_damage(*triggered)?;
95
96 effects.push(BuilderSpellEffect::Damage {
97 effect: crate::state::DamageEffectRef::new(
98 triggered.spell_id.as_u32(),
99 triggered.damage.effect_index,
100 ),
101 damage,
102 base_points,
103 may_crit: !self
104 .definition
105 .game_data
106 .cannot_crit(triggered.spell_id)
107 .unwrap_or(false),
108 attribute_flags: crate::systems::damage_flags_from_data(
109 &self.definition.game_data,
110 triggered.spell_id,
111 triggered.damage.is_physical,
112 ),
113 requires_main_hand: triggered.requires_main_hand,
114 requires_off_hand: triggered.requires_off_hand,
115 equipped_item_requirement: triggered.equipped_item_requirement,
116 });
117 }
118 wowlab_engine_domain::dbc::TriggerOperation::Delayed {
119 delay_ms,
120 profile_spell_id,
121 operations,
122 } => effects.push(BuilderSpellEffect::Delayed {
123 delay_ms: *delay_ms,
124 profile_spell_id: profile_spell_id.as_u32(),
125 effects: self.lower_trigger_operations(operations)?,
126 }),
127 wowlab_engine_domain::dbc::TriggerOperation::ApplyAura { aura_id } => {
128 let index = self
129 .definition
130 .aura_defs
131 .iter()
132 .position(|aura| aura.aura_id == aura_id.as_u32())
133 .ok_or(TriggerProgramLowerError::UnregisteredAura {
134 aura_id: aura_id.as_u32(),
135 })?;
136 let local = u8::try_from(index).map(crate::LocalAuraIdx).ok().ok_or(
137 TriggerProgramLowerError::UnregisteredAura {
138 aura_id: aura_id.as_u32(),
139 },
140 )?;
141
142 effects.push(BuilderSpellEffect::ApplyAura { aura_local: local });
143 }
144 wowlab_engine_domain::dbc::TriggerOperation::RemoveAura { aura_id } => {
145 let index = self
146 .definition
147 .aura_defs
148 .iter()
149 .position(|aura| aura.aura_id == aura_id.as_u32())
150 .ok_or(TriggerProgramLowerError::UnregisteredAura {
151 aura_id: aura_id.as_u32(),
152 })?;
153 let local = u8::try_from(index).map(crate::LocalAuraIdx).ok().ok_or(
154 TriggerProgramLowerError::UnregisteredAura {
155 aura_id: aura_id.as_u32(),
156 },
157 )?;
158
159 effects.push(BuilderSpellEffect::RemoveAura { aura_local: local });
160 }
161 wowlab_engine_domain::dbc::TriggerOperation::EnergizePrimary { amount } => {
162 effects.push(BuilderSpellEffect::Energize {
163 effect: None,
164 pool: crate::state::ResourcePool::Primary,
165 amount: *amount,
166 });
167 }
168 wowlab_engine_domain::dbc::TriggerOperation::EnergizeSecondary { amount } => {
169 effects.push(BuilderSpellEffect::Energize {
170 effect: None,
171 pool: crate::state::ResourcePool::Secondary,
172 amount: *amount,
173 });
174 }
175 wowlab_engine_domain::dbc::TriggerOperation::EnergizePercent {
176 resource_type,
177 percent,
178 effect_index,
179 } => effects.push(BuilderSpellEffect::EnergizePercent {
180 resource_type: *resource_type,
181 percent: *percent,
182 effect_index: *effect_index,
183 }),
184 wowlab_engine_domain::dbc::TriggerOperation::MutateCooldown {
185 target,
186 operation,
187 } => {
188 let (index, error) = match target {
189 wowlab_engine_domain::dbc::TriggerCooldownTarget::Spell(spell_id) => (
190 self.definition
191 .spells
192 .iter()
193 .position(|spell| spell.spell_id == spell_id.as_u32()),
194 TriggerProgramLowerError::UnregisteredCooldownSpell {
195 spell_id: spell_id.as_u32(),
196 },
197 ),
198 wowlab_engine_domain::dbc::TriggerCooldownTarget::ChargeCategory(
199 category,
200 ) => (
201 self.definition.spells.iter().position(|spell| {
202 spell
203 .cooldown
204 .as_ref()
205 .and_then(|cooldown| cooldown.charge_category)
206 == Some(*category)
207 }),
208 TriggerProgramLowerError::UnregisteredCooldownCategory {
209 category: category.raw(),
210 },
211 ),
212 };
213 let local = index
214 .and_then(|index| u8::try_from(index).ok())
215 .map(crate::state::LocalSpellIdx)
216 .ok_or(error)?;
217
218 effects.push(BuilderSpellEffect::MutateCooldown {
219 spell_local: local,
220 operation: *operation,
221 });
222 }
223 _ => return Err(TriggerProgramLowerError::UnsupportedDamageKind),
224 }
225 }
226
227 Ok(effects)
228 }
229 fn lower_dbc_effect_program(
230 &self,
231 source: wowlab_types::sim::SpellIdx,
232 effect_index: u8,
233 ) -> Result<Option<(u32, Vec<BuilderSpellEffect>)>, TriggerProgramError> {
234 let Some(program) = wowlab_engine_domain::dbc::resolve_effect_program(
235 wowlab_engine_domain::dbc::EffectLookup::new(
236 &self.definition.game_data,
237 wowlab_types::sim::EffectRef::new(source, effect_index),
238 ),
239 None,
240 )?
241 else {
242 return Ok(None);
243 };
244 let child_spell_id = program.child_spell_id.as_u32();
245 let effects = self.lower_trigger_program(&program)?;
246
247 Ok(Some((child_spell_id, effects)))
248 }
249}
250
251fn effect_matches(left: &BuilderSpellEffect, right: &BuilderSpellEffect) -> bool {
253 match (left, right) {
254 (
255 BuilderSpellEffect::Conditional {
256 predicate: left_predicate,
257 effects: left_effects,
258 },
259 BuilderSpellEffect::Conditional {
260 predicate: right_predicate,
261 effects: right_effects,
262 },
263 ) => {
264 std::ptr::fn_addr_eq(*left_predicate, *right_predicate)
265 && left_effects.len() == right_effects.len()
266 && left_effects
267 .iter()
268 .zip(right_effects)
269 .all(|(left, right)| effect_matches(left, right))
270 }
271 (
272 BuilderSpellEffect::Damage { effect: left, .. },
273 BuilderSpellEffect::Damage { effect: right, .. },
274 ) => left == right,
275 (
276 BuilderSpellEffect::ApplyAura { aura_local: left },
277 BuilderSpellEffect::ApplyAura { aura_local: right },
278 )
279 | (
280 BuilderSpellEffect::RemoveAura { aura_local: left },
281 BuilderSpellEffect::RemoveAura { aura_local: right },
282 ) => left == right,
283 (
284 BuilderSpellEffect::MutateCooldown {
285 spell_local: left_spell,
286 operation: left_operation,
287 },
288 BuilderSpellEffect::MutateCooldown {
289 spell_local: right_spell,
290 operation: right_operation,
291 },
292 ) => left_spell == right_spell && left_operation == right_operation,
293 (
294 BuilderSpellEffect::Delayed {
295 delay_ms: left_delay,
296 profile_spell_id: left_profile,
297 effects: left_effects,
298 },
299 BuilderSpellEffect::Delayed {
300 delay_ms: right_delay,
301 profile_spell_id: right_profile,
302 effects: right_effects,
303 },
304 ) => {
305 left_delay == right_delay
306 && left_profile == right_profile
307 && left_effects.len() == right_effects.len()
308 && left_effects
309 .iter()
310 .zip(right_effects)
311 .all(|(left, right)| effect_matches(left, right))
312 }
313 (BuilderSpellEffect::Delayed { effects, .. }, right) if effects.len() == 1 => {
314 effect_matches(
315 effects
316 .first()
317 .expect("single-effect delayed program contains one effect"),
318 right,
319 )
320 }
321 _ => false,
322 }
323}
324
325fn retain_inferred_delay(authored: &mut BuilderSpellEffect, inferred: &BuilderSpellEffect) -> bool {
327 let BuilderSpellEffect::Delayed {
328 delay_ms,
329 profile_spell_id,
330 effects,
331 } = inferred
332 else {
333 return false;
334 };
335 let [inferred_child] = effects.as_slice() else {
336 return false;
337 };
338 let mut authored_child = authored.clone();
339
340 if !effect_matches(&authored_child, inferred_child)
341 && !retain_inferred_delay(&mut authored_child, inferred_child)
342 {
343 return false;
344 }
345
346 *authored = BuilderSpellEffect::Delayed {
347 delay_ms: *delay_ms,
348 profile_spell_id: *profile_spell_id,
349 effects: vec![authored_child],
350 };
351
352 true
353}
354
355fn extend_missing_occurrences(
356 destination: &mut Vec<BuilderSpellEffect>,
357 source: Vec<BuilderSpellEffect>,
358) {
359 let existing_len = destination.len();
360 let mut matched = vec![false; existing_len];
361
362 for effect in source {
363 let mut existing = destination
364 .iter()
365 .take(existing_len)
366 .enumerate()
367 .find(|(index, candidate)| {
368 !matched.get(*index).copied().unwrap_or(false) && effect_matches(candidate, &effect)
369 })
370 .map(|(index, _)| index);
371
372 if existing.is_none() {
373 existing = destination
374 .iter_mut()
375 .take(existing_len)
376 .enumerate()
377 .find_map(|(index, candidate)| {
378 (!matched.get(index).copied().unwrap_or(false)
379 && retain_inferred_delay(candidate, &effect))
380 .then_some(index)
381 });
382 }
383
384 if let Some(index) = existing {
385 let slot = matched
386 .get_mut(index)
387 .expect("matched indices originate from destination iteration");
388
389 *slot = true;
390 } else {
391 destination.push(effect);
392 }
393 }
394}
395
396fn should_strip_program_damage(
398 spell: &crate::builder::def::BuilderSpellDef,
399 lookup: wowlab_engine_domain::dbc::EffectLookup<'_>,
400) -> bool {
401 let spell_id = lookup.effect.spell.as_u32();
402 let effect_index = lookup.effect.effect_index;
403
404 if !spell.infer_data_damage {
405 return true;
406 }
407
408 if matches!(spell.damage, BuilderDamageDef::None) {
409 return false;
410 }
411
412 if spell.damage_effect == crate::state::DamageEffectRef::new(spell_id, effect_index) {
413 return true;
414 }
415
416 wowlab_engine_domain::dbc::resolve_triggered_damage(lookup).is_ok_and(|triggered| {
417 spell.damage_effect
418 == crate::state::DamageEffectRef::new(
419 triggered.spell_id.as_u32(),
420 triggered.damage.effect_index,
421 )
422 })
423}
424
425fn retain_non_damage_operations(effects: &mut Vec<BuilderSpellEffect>) {
427 effects.retain_mut(|effect| match effect {
428 BuilderSpellEffect::Damage { .. } => false,
429 BuilderSpellEffect::Conditional { effects, .. }
430 | BuilderSpellEffect::Delayed { effects, .. } => {
431 retain_non_damage_operations(effects);
432
433 !effects.is_empty()
434 }
435 _ => true,
436 });
437}
438
439impl TriggerProgramRegistration<'_> {
440 pub(super) fn auto_register_dbc_trigger_programs(&mut self) {
441 self.auto_register_dbc_spell_trigger_programs();
442 let aura_ids: Vec<_> = self
443 .definition
444 .aura_defs
445 .iter()
446 .map(|aura| aura.aura_id)
447 .collect();
448 let mut tick_programs = Vec::new();
449 let mut apply_programs = vec![Vec::new(); aura_ids.len()];
450 let mut apply_children = vec![Vec::new(); aura_ids.len()];
451
452 for (aura_index, aura_id) in aura_ids.into_iter().enumerate() {
453 let has_periodic_hook = matches!(
454 self.definition
455 .aura_defs
456 .get(aura_index)
457 .expect("aura index originates from aura_ids")
458 .periodic
459 .as_ref()
460 .map(|periodic| &periodic.effect),
461 Some(PeriodicEffectDef::Hook)
462 );
463 let source = wowlab_types::sim::SpellIdx::from_raw(aura_id);
464
465 for effect_index in 1..=self.definition.game_data.max_effect_index(source) {
466 let effect_type = self.definition.game_data.effect_type(source, effect_index);
467
468 if !spell_effect_is(effect_type, SpellEffectKind::ApplyAura) {
469 match self
470 .lowering()
471 .lower_dbc_effect_program(source, effect_index)
472 {
473 Ok(Some((child_spell_id, effects))) => {
474 apply_programs
475 .get_mut(aura_index)
476 .expect("program slots mirror aura_ids")
477 .extend(effects);
478 apply_children
479 .get_mut(aura_index)
480 .expect("child slots mirror aura_ids")
481 .push(child_spell_id);
482 continue;
483 }
484 Ok(None) => {}
485 Err(error) => tracing::debug!(
487 aura_id,
488 effect_index,
489 %error,
490 "DBC_AURA_EFFECT_PROGRAM_GAP"
491 ),
492 }
493 }
494
495 let aura_subtype = self.definition.game_data.effect_aura(source, effect_index);
496 let is_periodic = has_periodic_hook
497 && aura_subtype == AuraSubtypeKind::PeriodicTriggerSpell as i32;
498 let is_apply = aura_subtype == AuraSubtypeKind::CastLinkedSpellWithValue as i32;
499
500 if !is_periodic && !is_apply {
501 continue;
502 }
503
504 let forwarded_value =
505 is_apply.then(|| self.definition.game_data.base_points(source, effect_index));
506 let result = self.lower_dbc_trigger_program(source, effect_index, forwarded_value);
507
508 match result {
509 Ok((child_spell_id, effects)) if !effects.is_empty() => {
510 if is_apply {
511 apply_programs
512 .get_mut(aura_index)
513 .expect("program slots mirror aura_ids")
514 .extend(effects);
515 apply_children
516 .get_mut(aura_index)
517 .expect("child slots mirror aura_ids")
518 .push(child_spell_id);
519 } else {
520 tick_programs.push((aura_index, child_spell_id, effects));
521 }
522 }
523 Ok(_) => {}
524 Err(error) => tracing::debug!(
526 aura_id,
527 effect_index,
528 %error,
529 "DBC_AURA_TRIGGER_PROGRAM_GAP"
530 ),
531 }
532 }
533 }
534
535 for (aura_index, child_spell_id, effects) in tick_programs {
536 let aura = self
537 .definition
538 .aura_defs
539 .get_mut(aura_index)
540 .expect("tick programs retain an index from aura_ids");
541
542 extend_missing_occurrences(&mut aura.tick_effects, effects);
543 aura.tick_profile_spell_id = child_spell_id;
544 tracing::debug!(
546 aura_id = aura.aura_id,
547 child_spell_id,
548 "registered periodic DBC trigger program"
549 );
550 }
551
552 for (aura_index, effects) in apply_programs.into_iter().enumerate() {
553 if effects.is_empty() {
554 continue;
555 }
556
557 let aura = self
558 .definition
559 .aura_defs
560 .get_mut(aura_index)
561 .expect("application programs mirror registered auras");
562
563 extend_missing_occurrences(&mut aura.apply_effects, effects);
564 let child_spell_ids = apply_children
565 .get(aura_index)
566 .expect("child slots mirror registered auras");
567
568 aura.apply_profile_spell_id = *child_spell_ids
569 .first()
570 .expect("non-empty application effects retain a child spell");
571 tracing::debug!(
573 aura_id = aura.aura_id,
574 ?child_spell_ids,
575 "registered aura-application DBC trigger program"
576 );
577 }
578 }
579
580 fn lowering(&self) -> TriggerProgramLowering<'_> {
581 TriggerProgramLowering {
582 definition: &*self.definition,
583 }
584 }
585
586 fn lower_dbc_trigger_program(
587 &self,
588 source: wowlab_types::sim::SpellIdx,
589 effect_index: u8,
590 forwarded_value: Option<f64>,
591 ) -> Result<(u32, Vec<BuilderSpellEffect>), TriggerProgramError> {
592 let program = wowlab_engine_domain::dbc::resolve_trigger_program(
593 wowlab_engine_domain::dbc::EffectLookup::new(
594 &self.definition.game_data,
595 wowlab_types::sim::EffectRef::new(source, effect_index),
596 ),
597 forwarded_value,
598 )?;
599 let child_spell_id = program.child_spell_id.as_u32();
600 let effects = self.lowering().lower_trigger_program(&program)?;
601
602 Ok((child_spell_id, effects))
603 }
604 fn auto_register_dbc_spell_trigger_programs(&mut self) {
607 let spell_ids: Vec<_> = self
608 .definition
609 .spells
610 .iter()
611 .map(|spell| spell.spell_id)
612 .collect();
613 let mut spell_programs = vec![Vec::new(); spell_ids.len()];
614 let mut spell_children = vec![Vec::new(); spell_ids.len()];
615
616 for (spell_index, spell_id) in spell_ids.into_iter().enumerate() {
617 let source = wowlab_types::sim::SpellIdx::from_raw(spell_id);
618
619 for effect_index in 1..=self.definition.game_data.max_effect_index(source) {
620 let predicate = self
621 .definition
622 .spells
623 .get(spell_index)
624 .expect("spell index originates from spell_ids")
625 .trigger_program_predicates
626 .iter()
627 .rev()
628 .find(|entry| entry.effect_index == effect_index)
629 .map(|entry| entry.predicate);
630
631 match self
632 .lowering()
633 .lower_dbc_effect_program(source, effect_index)
634 {
635 Ok(Some((child_spell_id, effects))) => {
636 let mut accepted = Vec::new();
637
638 for effect in effects {
639 let duplicate_primary_aura = matches!(
640 effect,
641 BuilderSpellEffect::ApplyAura { aura_local }
642 if self
643 .definition
644 .spells
645 .get(spell_index)
646 .expect("spell index originates from spell_ids")
647 .applies_aura
648 == Some(aura_local)
649 );
650 let duplicate_program_aura = matches!(
651 effect,
652 BuilderSpellEffect::ApplyAura { aura_local }
653 if spell_programs
654 .get(spell_index)
655 .expect("program slots mirror spell_ids")
656 .iter()
657 .any(|existing| matches!(
658 existing,
659 BuilderSpellEffect::ApplyAura { aura_local: existing_local }
660 if *existing_local == aura_local
661 ))
662 || accepted.iter().any(|existing| matches!(
663 existing,
664 BuilderSpellEffect::ApplyAura { aura_local: existing_local }
665 if *existing_local == aura_local
666 ))
667 );
668
669 if !duplicate_primary_aura && !duplicate_program_aura {
670 accepted.push(effect);
671 }
672 }
673
674 if !accepted.is_empty() {
675 let programs = spell_programs
676 .get_mut(spell_index)
677 .expect("program slots mirror spell_ids");
678
679 if let Some(predicate) = predicate {
680 programs.push(BuilderSpellEffect::Conditional {
681 predicate,
682 effects: accepted,
683 });
684 } else {
685 programs.extend(accepted);
686 }
687 }
688
689 spell_children
690 .get_mut(spell_index)
691 .expect("child slots mirror spell_ids")
692 .push(child_spell_id);
693 continue;
694 }
695 Ok(None) => {}
696 Err(error) => tracing::debug!(
698 spell_id,
699 effect_index,
700 %error,
701 "DBC_EFFECT_PROGRAM_GAP"
702 ),
703 }
704
705 let effect_type = self.definition.game_data.effect_type(source, effect_index);
706 let is_trigger_effect = wowlab_engine_domain::dbc::spell_effect_is_any(
707 effect_type,
708 &[
709 SpellEffectKind::TriggerMissile,
710 SpellEffectKind::TriggerSpell,
711 SpellEffectKind::TriggerSpellWithValue,
712 SpellEffectKind::TriggerSpell2,
713 ],
714 );
715
716 if !is_trigger_effect {
717 continue;
718 }
719
720 let forwarded_value =
721 spell_effect_is(effect_type, SpellEffectKind::TriggerSpellWithValue)
722 .then(|| self.definition.game_data.base_points(source, effect_index));
723 let result = self.lower_dbc_trigger_program(source, effect_index, forwarded_value);
724
725 match result {
726 Ok((child_spell_id, mut effects)) => {
727 let spell = self
728 .definition
729 .spells
730 .get(spell_index)
731 .expect("spell index originates from spell_ids");
732
733 if should_strip_program_damage(
734 spell,
735 wowlab_engine_domain::dbc::EffectLookup::new(
736 &self.definition.game_data,
737 wowlab_types::sim::EffectRef::new(source, effect_index),
738 ),
739 ) {
740 retain_non_damage_operations(&mut effects);
741 }
742
743 if effects.is_empty() {
744 continue;
745 }
746
747 let delay_ms = u32::try_from(
748 self.definition
749 .game_data
750 .effect_misc_value_0(source, effect_index),
751 )
752 .unwrap_or(0);
753
754 let effects = vec![BuilderSpellEffect::Delayed {
755 delay_ms,
756 profile_spell_id: child_spell_id,
757 effects,
758 }];
759 let programs = spell_programs
760 .get_mut(spell_index)
761 .expect("program slots mirror spell_ids");
762
763 if let Some(predicate) = predicate {
764 programs.push(BuilderSpellEffect::Conditional { predicate, effects });
765 } else {
766 programs.extend(effects);
767 }
768
769 spell_children
770 .get_mut(spell_index)
771 .expect("child slots mirror spell_ids")
772 .push(child_spell_id);
773 }
774 Err(error) => tracing::debug!(
776 spell_id,
777 effect_index,
778 %error,
779 "DBC_TRIGGER_PROGRAM_GAP"
780 ),
781 }
782 }
783 }
784
785 for (spell_index, effects) in spell_programs.into_iter().enumerate() {
786 if effects.is_empty() {
787 continue;
788 }
789
790 let spell = self
791 .definition
792 .spells
793 .get_mut(spell_index)
794 .expect("program slots mirror registered spells");
795
796 extend_missing_occurrences(&mut spell.followup_effects, effects);
797 let child_spell_ids = spell_children
798 .get(spell_index)
799 .expect("child slots mirror registered spells");
800
801 tracing::debug!(
803 spell_id = spell.spell_id,
804 ?child_spell_ids,
805 "registered immediate DBC trigger program"
806 );
807 }
808 }
809}
810
811#[cfg(test)]
812mod error_tests {
813 use std::error::Error as _;
814
815 use googletest::prelude::*;
816 use wowlab_engine_gamedata::ResolvedGameData;
817 use wowlab_engine_ports::CombatStats;
818 use wowlab_types::sim::SpellIdx;
819
820 use super::*;
821 use crate::{DamageFlags, builder::CombatSystemBuilder, state::DamageEffectRef};
822
823 fn damage_effect(spell_id: u32) -> BuilderSpellEffect {
824 BuilderSpellEffect::Damage {
825 effect: DamageEffectRef::new(spell_id, 1),
826 damage: BuilderDamageDef::Flat(1.0),
827 base_points: 1.0,
828 may_crit: true,
829 attribute_flags: DamageFlags::empty(),
830 requires_main_hand: false,
831 requires_off_hand: false,
832 equipped_item_requirement: None,
833 }
834 }
835
836 fn lowering(data: ResolvedGameData) -> CombatSystemBuilder {
837 CombatSystemBuilder::new_for_test(CombatStats::default()).game_data(data)
838 }
839
840 #[gtest]
841 fn effect_program_resolution_failure_retains_trigger_source_and_edge_context() -> Result<()> {
842 const SOURCE: SpellIdx = SpellIdx(91_001);
843 let mut data = ResolvedGameData::builder();
844
845 data.insert_effect_type(
846 wowlab_types::sim::EffectRef::new(SOURCE, 1),
847 SpellEffectKind::RemoveAura as i32,
848 );
849 let definition = lowering(data.build());
850
851 let error = TriggerProgramLowering {
852 definition: &definition,
853 }
854 .lower_dbc_effect_program(SOURCE, 1)
855 .expect_err("missing trigger edge must fail resolution");
856
857 verify_true!(error.is_resolve())?;
858 verify_that!(
859 error.to_string(),
860 eq("trigger child spell 91001 effect 1 names no aura to remove")
861 )?;
862 verify_true!(
863 error
864 .source()
865 .and_then(std::error::Error::source)
866 .is_some_and(
867 <dyn std::error::Error>::is::<wowlab_engine_domain::dbc::TriggerResolveError>
868 )
869 )?;
870
871 Ok(())
872 }
873
874 #[gtest]
875 fn authored_child_damage_retains_inferred_nested_delay_and_profile() -> Result<()> {
876 let mut authored = damage_effect(93_001);
877 let BuilderSpellEffect::Damage { base_points, .. } = &mut authored else {
878 unreachable!("fixture is damage");
879 };
880
881 *base_points = 7.0;
882 let mut effects = vec![authored];
883
884 extend_missing_occurrences(
885 &mut effects,
886 vec![BuilderSpellEffect::Delayed {
887 delay_ms: 300,
888 profile_spell_id: 93_001,
889 effects: vec![BuilderSpellEffect::Delayed {
890 delay_ms: 700,
891 profile_spell_id: 93_002,
892 effects: vec![damage_effect(93_001)],
893 }],
894 }],
895 );
896
897 let [
898 BuilderSpellEffect::Delayed {
899 delay_ms: outer_delay,
900 profile_spell_id: outer_profile,
901 effects: outer_effects,
902 },
903 ] = effects.as_slice()
904 else {
905 unreachable!("expected one outer delayed effect");
906 };
907 let [
908 BuilderSpellEffect::Delayed {
909 delay_ms: inner_delay,
910 profile_spell_id: inner_profile,
911 effects: inner_effects,
912 },
913 ] = outer_effects.as_slice()
914 else {
915 unreachable!("expected one inner delayed effect");
916 };
917 let [BuilderSpellEffect::Damage { base_points, .. }] = inner_effects.as_slice() else {
918 unreachable!("expected authored damage payload");
919 };
920
921 verify_that!(
922 (
923 *outer_delay,
924 *outer_profile,
925 *inner_delay,
926 *inner_profile,
927 *base_points,
928 ),
929 eq((300, 93_001, 700, 93_002, 7.0))
930 )
931 }
932
933 #[gtest]
934 fn effect_program_lowering_failure_retains_typed_unregistered_aura_source() -> Result<()> {
935 const SOURCE: SpellIdx = SpellIdx(92_001);
936 let mut data = ResolvedGameData::builder();
937
938 data.insert_effect_type(
939 wowlab_types::sim::EffectRef::new(SOURCE, 1),
940 SpellEffectKind::ApplyAura as i32,
941 );
942 let definition = lowering(data.build());
943
944 let error = TriggerProgramLowering {
945 definition: &definition,
946 }
947 .lower_dbc_effect_program(SOURCE, 1)
948 .expect_err("unregistered child aura must fail lowering");
949
950 verify_true!(error.is_lower())?;
951 verify_that!(
952 error.to_string(),
953 eq("trigger program references unregistered aura 92001")
954 )?;
955 verify_true!(
956 error
957 .source()
958 .and_then(std::error::Error::source)
959 .is_some_and(<dyn std::error::Error>::is::<TriggerProgramLowerError>)
960 )?;
961
962 Ok(())
963 }
964}