Skip to main content

forge/manifest_ledger/
semantics.rs

1// #t(file: rust_alloc_in_loop) semantic IDs are retained as stable report evidence.
2// #t(file: rust_clone_in_loop) semantic IDs are copied from a bounded resolver cache into report rows.
3
4#![expect(
5    clippy::cast_possible_wrap,
6    reason = "manifest spell IDs are constrained to the signed DBC identifier domain"
7)]
8
9use std::collections::BTreeMap;
10
11use wowlab_engine_domain::dbc::{
12    AuraSubtypeKind, SpellEffectKind, aura_subtype_modifier, spell_effect_kind,
13};
14use wowlab_engine_ports::{DataResolver, SpellId};
15
16use super::{
17    collect,
18    types::{LedgerDisposition, LedgerRow},
19};
20
21const MAX_EFFECT_SEMANTICS: usize = 3;
22
23pub(super) async fn attach(rows: &mut [LedgerRow], resolver: &impl DataResolver) {
24    let mut cache: BTreeMap<(u32, u8), Vec<String>> = BTreeMap::new();
25    let mut direct_aura_cache: BTreeMap<u32, Vec<String>> = BTreeMap::new();
26    let mut triggered_aura_cache: BTreeMap<(u32, u32), Vec<String>> = BTreeMap::new();
27    let mut direct_remove_cache: BTreeMap<(u32, u32), Vec<String>> = BTreeMap::new();
28    let mut periodic_energize_cache: BTreeMap<(u32, u8), Vec<String>> = BTreeMap::new();
29    let mut semantic_ids = Vec::with_capacity(MAX_EFFECT_SEMANTICS);
30
31    for row in rows {
32        for &(spell_id, effect_index) in &row.effect_coordinates {
33            let coordinate = (spell_id, effect_index);
34            let ids = if let Some(ids) = cache.get(&coordinate) {
35                ids.clone()
36            } else {
37                semantic_ids.clear();
38                let ids = resolver
39                    .get_spell_effect(SpellId::new(spell_id as i32), effect_index)
40                    .await
41                    .map(|effect| {
42                        semantic_ids.push(format!("SE{}", effect.effect));
43
44                        if effect.effect == SpellEffectKind::ApplyAura as i32
45                            || effect.aura != AuraSubtypeKind::None as i32
46                        {
47                            semantic_ids.push(format!("AU{}", effect.aura));
48                        }
49
50                        if aura_subtype_modifier(effect.aura).is_some() {
51                            semantic_ids.push(format!("MP{}", effect.misc_value_0));
52                        }
53
54                        semantic_ids.clone()
55                    })
56                    .unwrap_or_default();
57
58                cache.insert(coordinate, ids.clone());
59
60                ids
61            };
62
63            row.semantic_ids
64                .extend(ids.into_iter().map(String::into_boxed_str));
65        }
66
67        attach_direct_aura_semantics(row, resolver, &mut direct_aura_cache).await;
68        attach_triggered_aura_semantics(row, resolver, &mut triggered_aura_cache).await;
69        attach_direct_remove_semantics(row, resolver, &mut direct_remove_cache).await;
70        attach_periodic_energize_semantics(row, resolver, &mut periodic_energize_cache).await;
71        row.semantic_ids.sort();
72        row.semantic_ids.dedup();
73        row.owning_spell_ids.sort_unstable();
74        row.owning_spell_ids.dedup();
75        row.child_spell_ids.sort_unstable();
76        row.child_spell_ids.dedup();
77    }
78}
79
80async fn attach_periodic_energize_semantics(
81    row: &mut LedgerRow,
82    resolver: &impl DataResolver,
83    cache: &mut BTreeMap<(u32, u8), Vec<String>>,
84) {
85    let candidate = matches!(
86        row.evidence.as_str(),
87        "DBC-backed periodic resource cadence" | "DBC-backed periodic resource amount"
88    ) && row.owning_spell_ids.len() == 1
89        && row.effect_coordinates.len() == 1;
90    let Some((_, coordinate)) = candidate
91        .then(|| (row.owning_spell_ids[0], row.effect_coordinates[0]))
92        .filter(|(owner, (effect_spell, _))| owner == effect_spell)
93    else {
94        return;
95    };
96    let semantics = if let Some(semantics) = cache.get(&coordinate) {
97        semantics.clone()
98    } else {
99        let semantics = resolver
100            .get_spell_effect(SpellId::new(coordinate.0 as i32), coordinate.1)
101            .await
102            .ok()
103            .filter(|effect| {
104                effect.effect == SpellEffectKind::ApplyAura as i32
105                    && effect.aura == AuraSubtypeKind::PeriodicEnergize as i32
106            })
107            .map(|_| vec!["AU24".to_string(), "SE6".to_string()])
108            .unwrap_or_default();
109
110        cache.insert(coordinate, semantics.clone());
111
112        semantics
113    };
114
115    if semantics.is_empty() {
116        return;
117    }
118
119    row.semantic_ids
120        .extend(semantics.into_iter().map(String::into_boxed_str));
121    row.disposition = LedgerDisposition::RedundantGeneric;
122    row.evidence = format!(
123        "production DBC AU24 supplies periodic resource {} through shared aura lowering",
124        if row.manifest_key.ends_with(".tick_ms") {
125            "cadence"
126        } else {
127            "amount and power type"
128        }
129    );
130}
131
132async fn attach_direct_remove_semantics(
133    row: &mut LedgerRow,
134    resolver: &impl DataResolver,
135    cache: &mut BTreeMap<(u32, u32), Vec<String>>,
136) {
137    let edge = (row.disposition == LedgerDisposition::GenericGap
138        && row.evidence == "hook operation `expire_aura`"
139        && row.manifest_key.starts_with("spells.")
140        && row.owning_spell_ids.len() == 1
141        && row.child_spell_ids.len() == 1)
142        .then(|| (row.owning_spell_ids[0], row.child_spell_ids[0]));
143    let Some((owner, child)) = edge else {
144        return;
145    };
146    let semantics = if let Some(semantics) = cache.get(&(owner, child)) {
147        semantics.clone()
148    } else {
149        let mut semantics = resolver
150            .get_spell_effects(SpellId::new(owner as i32))
151            .await
152            .unwrap_or_default()
153            .into_iter()
154            .filter(|effect| {
155                effect.trigger_spell == child as i32
156                    && matches!(
157                        spell_effect_kind(effect.effect),
158                        Some(SpellEffectKind::CancelAura | SpellEffectKind::RemoveAura)
159                    )
160            })
161            .map(|effect| format!("SE{}", effect.effect))
162            .collect::<Vec<_>>();
163
164        semantics.sort();
165        semantics.dedup();
166        cache.insert((owner, child), semantics.clone());
167
168        semantics
169    };
170
171    if semantics.is_empty() {
172        return;
173    }
174
175    row.semantic_ids
176        .extend(semantics.into_iter().map(String::into_boxed_str));
177    row.disposition = LedgerDisposition::RedundantGeneric;
178    row.evidence =
179        "unconditional hook expiration duplicates production DBC aura-removal lowering".to_string();
180}
181
182async fn attach_direct_aura_semantics(
183    row: &mut LedgerRow,
184    resolver: &impl DataResolver,
185    cache: &mut BTreeMap<u32, Vec<String>>,
186) {
187    let explicit_edge = row.evidence == collect::EXPLICIT_AURA_EDGE_EVIDENCE;
188    let unconditional_hook_edge = row.disposition == LedgerDisposition::GenericGap
189        && row.evidence == "hook operation `apply_aura`"
190        && row.manifest_key.starts_with("spells.");
191    let Some(spell_id) = row.owning_spell_ids.first().copied().filter(|owner| {
192        (explicit_edge || unconditional_hook_edge)
193            && row.owning_spell_ids.len() == 1
194            && row.child_spell_ids.as_slice() == [*owner]
195    }) else {
196        return;
197    };
198    let semantics = if let Some(semantics) = cache.get(&spell_id) {
199        semantics.clone()
200    } else {
201        let mut semantics = resolver
202            .get_spell_effects(SpellId::new(spell_id as i32))
203            .await
204            .map(|effects| {
205                effects
206                    .into_iter()
207                    .filter(|effect| effect.effect == SpellEffectKind::ApplyAura as i32)
208                    .flat_map(|effect| ["SE6".to_string(), format!("AU{}", effect.aura)])
209                    .collect::<Vec<_>>()
210            })
211            .unwrap_or_default();
212
213        semantics.sort();
214        semantics.dedup();
215        cache.insert(spell_id, semantics.clone());
216
217        semantics
218    };
219
220    if semantics.iter().all(|semantic| semantic != "SE6") {
221        return;
222    }
223
224    row.semantic_ids
225        .extend(semantics.into_iter().map(String::into_boxed_str));
226    row.disposition = LedgerDisposition::RedundantGeneric;
227    row.evidence = if explicit_edge {
228        "production DBC SE6 applies the source spell aura through shared effect-program lowering"
229    } else {
230        "unconditional hook application duplicates production DBC SE6 source-aura lowering"
231    }
232    .to_string();
233}
234
235async fn attach_triggered_aura_semantics(
236    row: &mut LedgerRow,
237    resolver: &impl DataResolver,
238    cache: &mut BTreeMap<(u32, u32), Vec<String>>,
239) {
240    let edge = (row.disposition == LedgerDisposition::GenericGap
241        && row.evidence == "hook operation `apply_aura`"
242        && row.manifest_key.starts_with("spells.")
243        && row.owning_spell_ids.len() == 1
244        && row.child_spell_ids.len() == 1)
245        .then(|| (row.owning_spell_ids[0], row.child_spell_ids[0]));
246    let Some((owner, child)) = edge.filter(|(owner, child)| owner != child) else {
247        return;
248    };
249    let semantics = if let Some(semantics) = cache.get(&(owner, child)) {
250        semantics.clone()
251    } else {
252        let semantics = pure_triggered_aura_semantics(resolver, owner, child).await;
253
254        cache.insert((owner, child), semantics.clone());
255
256        semantics
257    };
258
259    if semantics.is_empty() {
260        return;
261    }
262
263    row.semantic_ids
264        .extend(semantics.into_iter().map(String::into_boxed_str));
265    row.disposition = LedgerDisposition::RedundantGeneric;
266    row.evidence =
267        "unconditional hook application duplicates production DBC trigger child-aura lowering"
268            .to_string();
269}
270
271async fn pure_triggered_aura_semantics(
272    resolver: &impl DataResolver,
273    owner: u32,
274    child: u32,
275) -> Vec<String> {
276    let owner_effects = resolver
277        .get_spell_effects(SpellId::new(owner as i32))
278        .await
279        .unwrap_or_default();
280    let mut semantics: Vec<_> = owner_effects
281        .into_iter()
282        .filter(|effect| {
283            effect.trigger_spell == child as i32
284                && matches!(
285                    spell_effect_kind(effect.effect),
286                    Some(
287                        SpellEffectKind::TriggerMissile
288                            | SpellEffectKind::TriggerSpell
289                            | SpellEffectKind::TriggerSpellWithValue
290                            | SpellEffectKind::TriggerSpell2
291                    )
292                )
293        })
294        .map(|effect| format!("SE{}", effect.effect))
295        .collect();
296
297    if semantics.is_empty() {
298        return semantics;
299    }
300
301    let child_effects = resolver
302        .get_spell_effects(SpellId::new(child as i32))
303        .await
304        .unwrap_or_default();
305
306    if child_effects.is_empty()
307        || child_effects
308            .iter()
309            .any(|effect| effect.effect != SpellEffectKind::ApplyAura as i32)
310    {
311        return Vec::new();
312    }
313
314    semantics.extend(
315        child_effects
316            .into_iter()
317            .flat_map(|effect| ["SE6".to_string(), format!("AU{}", effect.aura)]),
318    );
319    semantics.sort();
320    semantics.dedup();
321
322    semantics
323}