Skip to main content

wowlab_engine_ports/
spec_metadata.rs

1//! Static spec declarations emitted from composed manifests.
2
3use wowlab_types::{combat::ResourceType, sim::FastSet};
4
5/// Static description of one hero talent tree.
6#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7pub struct HeroTalentTreeDesc {
8    pub name: &'static str,
9    pub spells: &'static [(&'static str, u32)],
10    pub auras: &'static [(&'static str, u32)],
11}
12
13/// One manifest-authored server `SpellProcEntry` overlay.
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub struct DeclaredServerProcEntry {
16    pub spell_id: u32,
17    pub attributes: u32,
18}
19
20/// The replacement value one passive effect really applies at, as `register_passive_effect_override`.
21///
22/// The result is the `source` effect's post-rescale value times `multiplier`.
23///   With no source named it is `value` times `multiplier` instead.
24#[derive(Clone, Copy, Debug, PartialEq)]
25pub struct DeclaredPassiveEffectOverride {
26    pub spell_id: u32,
27    pub effect: u8,
28    pub source: Option<(u32, u8)>,
29    pub value: f64,
30    pub multiplier: f64,
31}
32
33/// Complete static metadata declared by one composed spec manifest.
34#[derive(Clone, Copy, Debug, PartialEq)]
35pub struct DeclaredSpecMetadata {
36    pub primary_resource: ResourceType,
37    pub secondary_resource: Option<ResourceType>,
38    pub spell_ids: &'static [u32],
39    pub aura_ids: &'static [u32],
40    pub server_proc_entries: &'static [DeclaredServerProcEntry],
41    pub talents: &'static [(&'static str, u32)],
42    pub rotation_field_schema: &'static [&'static str],
43    pub metrics_plugin_id: Option<u32>,
44    pub hero_talent_trees: &'static [HeroTalentTreeDesc],
45    pub reported_spells: &'static [(&'static str, u32)],
46    /// Passive spell effects this specialization does not receive, as `(spell_id, effect_index)`.
47    ///   The static passive fold skips them, mirroring `SimC`'s per-spec
48    ///   `register_passive_effect_mask` and `deregister_passive_effect` calls.
49    pub masked_passive_effects: &'static [(u32, u8)],
50    /// Replacement values for passive effects whose stored value the game never applies.
51    ///   They mirror `SimC`'s per-spec `register_passive_effect_override` calls for the shapes a
52    ///   spell's own description cannot state.
53    pub passive_effect_overrides: &'static [DeclaredPassiveEffectOverride],
54}
55
56wowlab_engine_macros::define_error! {
57/// Invalid static metadata supplied to a spec descriptor.
58#[derive(Debug, Eq, PartialEq)]
59pub struct DeclaredSpecMetadataError {
60    kind: DeclaredSpecMetadataErrorKind,
61}
62
63#[derive(Debug, thiserror::Error, Eq, PartialEq)]
64enum DeclaredSpecMetadataErrorKind {
65    #[error("declared spec metadata contains no spell IDs")]
66    EmptySpellIds,
67    #[error("declared {category} contains reserved spell ID 0")]
68    ZeroSpellId { category: &'static str },
69    #[error("declared aura ID {spell_id} is absent from the spell-resolution set")]
70    AuraMissingFromSpells { spell_id: u32 },
71    #[error("declared server proc spell ID {spell_id} is absent from the spell-resolution set")]
72    ServerProcMissingFromSpells { spell_id: u32 },
73    #[error("declared server proc spell ID {spell_id} appears more than once")]
74    DuplicateServerProc { spell_id: u32 },
75    #[error("declared server proc spell ID {spell_id} has no attributes")]
76    EmptyServerProcAttributes { spell_id: u32 },
77    #[error(
78        "declared {category} '{name}' with ID {spell_id} is absent from the spell-resolution set"
79    )]
80    NamedSpellMissingFromSpells {
81        category: &'static str,
82        name: &'static str,
83        spell_id: u32,
84    },
85    #[error(
86        "declared {category} '{name}' with ID {spell_id} is absent from the aura-resolution set"
87    )]
88    NamedAuraMissingFromAuras {
89        category: &'static str,
90        name: &'static str,
91        spell_id: u32,
92    },
93    #[error("declared {category} contains an empty symbolic name")]
94    EmptyName { category: &'static str },
95    #[error("declared {category} contains duplicate symbolic name '{name}'")]
96    DuplicateName {
97        category: &'static str,
98        name: &'static str,
99    },
100    #[error("primary and secondary resources are both {resource}")]
101    DuplicateResource { resource: ResourceType },
102}
103}
104
105impl DeclaredSpecMetadataError {
106    fn new(kind: DeclaredSpecMetadataErrorKind) -> Self {
107        Self { kind }
108    }
109}
110
111impl DeclaredSpecMetadata {
112    /// Validate cross-field declaration invariants (duplicate IDs are allowed; symbolic names must be unique per category).
113    /// # Errors
114    /// Returns a typed error when required IDs, names, or resource relationships are invalid.
115    pub fn validate(&self) -> Result<(), DeclaredSpecMetadataError> {
116        self.validate_resolution_sets()?;
117
118        self.validate_named_declarations()
119    }
120
121    fn validate_resolution_sets(&self) -> Result<(), DeclaredSpecMetadataError> {
122        if self.spell_ids.is_empty() {
123            return Err(DeclaredSpecMetadataError::new(
124                DeclaredSpecMetadataErrorKind::EmptySpellIds,
125            ));
126        }
127
128        ensure_nonzero_ids("spell IDs", self.spell_ids)?;
129        ensure_nonzero_ids("aura IDs", self.aura_ids)?;
130
131        if self.secondary_resource == Some(self.primary_resource) {
132            return Err(DeclaredSpecMetadataError::new(
133                DeclaredSpecMetadataErrorKind::DuplicateResource {
134                    resource: self.primary_resource,
135                },
136            ));
137        }
138
139        let spells: FastSet<u32> = self.spell_ids.iter().copied().collect();
140        let auras: FastSet<u32> = self.aura_ids.iter().copied().collect();
141
142        for &spell_id in self.aura_ids {
143            if !spells.contains(&spell_id) {
144                return Err(DeclaredSpecMetadataError::new(
145                    DeclaredSpecMetadataErrorKind::AuraMissingFromSpells { spell_id },
146                ));
147            }
148        }
149
150        validate_server_proc_entries(self.server_proc_entries, &spells)?;
151
152        validate_named_spells("reported spells", self.reported_spells, &spells)?;
153        validate_hero_talent_trees(self.hero_talent_trees, &spells, &auras)?;
154
155        Ok(())
156    }
157
158    fn validate_named_declarations(&self) -> Result<(), DeclaredSpecMetadataError> {
159        ensure_unique_names("talents", self.talents.iter().map(|&(name, _)| name))?;
160        ensure_nonzero_named_ids("talents", self.talents)?;
161        ensure_unique_names(
162            "rotation fields",
163            self.rotation_field_schema.iter().copied(),
164        )?;
165        ensure_unique_names(
166            "hero talent trees",
167            self.hero_talent_trees.iter().map(|tree| tree.name),
168        )?;
169
170        Ok(())
171    }
172}
173
174fn validate_server_proc_entries(
175    entries: &[DeclaredServerProcEntry],
176    spells: &FastSet<u32>,
177) -> Result<(), DeclaredSpecMetadataError> {
178    let mut seen = FastSet::default();
179
180    for entry in entries {
181        if !spells.contains(&entry.spell_id) {
182            return Err(DeclaredSpecMetadataError::new(
183                DeclaredSpecMetadataErrorKind::ServerProcMissingFromSpells {
184                    spell_id: entry.spell_id,
185                },
186            ));
187        }
188
189        if entry.attributes == 0 {
190            return Err(DeclaredSpecMetadataError::new(
191                DeclaredSpecMetadataErrorKind::EmptyServerProcAttributes {
192                    spell_id: entry.spell_id,
193                },
194            ));
195        }
196
197        if !seen.insert(entry.spell_id) {
198            return Err(DeclaredSpecMetadataError::new(
199                DeclaredSpecMetadataErrorKind::DuplicateServerProc {
200                    spell_id: entry.spell_id,
201                },
202            ));
203        }
204    }
205
206    Ok(())
207}
208
209fn validate_hero_talent_trees(
210    trees: &[HeroTalentTreeDesc],
211    spells: &FastSet<u32>,
212    auras: &FastSet<u32>,
213) -> Result<(), DeclaredSpecMetadataError> {
214    for tree in trees {
215        validate_named_spells("hero talent spells", tree.spells, spells)?;
216        validate_named_auras("hero talent auras", tree.auras, spells, auras)?;
217    }
218
219    Ok(())
220}
221
222fn ensure_nonzero_ids(
223    category: &'static str,
224    ids: &[u32],
225) -> Result<(), DeclaredSpecMetadataError> {
226    if ids.contains(&0) {
227        return Err(DeclaredSpecMetadataError::new(
228            DeclaredSpecMetadataErrorKind::ZeroSpellId { category },
229        ));
230    }
231
232    Ok(())
233}
234
235fn ensure_nonzero_named_ids(
236    category: &'static str,
237    entries: &[(&'static str, u32)],
238) -> Result<(), DeclaredSpecMetadataError> {
239    if entries.iter().any(|&(_, spell_id)| spell_id == 0) {
240        return Err(DeclaredSpecMetadataError::new(
241            DeclaredSpecMetadataErrorKind::ZeroSpellId { category },
242        ));
243    }
244
245    Ok(())
246}
247
248fn ensure_unique_names(
249    category: &'static str,
250    names: impl IntoIterator<Item = &'static str>,
251) -> Result<(), DeclaredSpecMetadataError> {
252    let mut seen = FastSet::default();
253
254    for name in names {
255        if name.is_empty() {
256            return Err(DeclaredSpecMetadataError::new(
257                DeclaredSpecMetadataErrorKind::EmptyName { category },
258            ));
259        }
260
261        if !seen.insert(name) {
262            return Err(DeclaredSpecMetadataError::new(
263                DeclaredSpecMetadataErrorKind::DuplicateName { category, name },
264            ));
265        }
266    }
267
268    Ok(())
269}
270
271fn validate_named_spells(
272    category: &'static str,
273    entries: &[(&'static str, u32)],
274    spells: &FastSet<u32>,
275) -> Result<(), DeclaredSpecMetadataError> {
276    ensure_unique_names(category, entries.iter().map(|&(name, _)| name))?;
277    ensure_nonzero_named_ids(category, entries)?;
278
279    for &(name, spell_id) in entries {
280        if !spells.contains(&spell_id) {
281            return Err(DeclaredSpecMetadataError::new(
282                DeclaredSpecMetadataErrorKind::NamedSpellMissingFromSpells {
283                    category,
284                    name,
285                    spell_id,
286                },
287            ));
288        }
289    }
290
291    Ok(())
292}
293
294fn validate_named_auras(
295    category: &'static str,
296    entries: &[(&'static str, u32)],
297    spells: &FastSet<u32>,
298    auras: &FastSet<u32>,
299) -> Result<(), DeclaredSpecMetadataError> {
300    validate_named_spells(category, entries, spells)?;
301
302    for &(name, spell_id) in entries {
303        if !auras.contains(&spell_id) {
304            return Err(DeclaredSpecMetadataError::new(
305                DeclaredSpecMetadataErrorKind::NamedAuraMissingFromAuras {
306                    category,
307                    name,
308                    spell_id,
309                },
310            ));
311        }
312    }
313
314    Ok(())
315}
316
317#[cfg(test)]
318mod tests;