Skip to main content

wowlab_engine_domain/rotation/context/
schema.rs

1// #t(file: rust_multiple_inherent_impl) the construction capability must sit between the schema's public allocation API and its ordinary metadata methods.
2
3use std::sync::Arc;
4
5use wowlab_types::sim::{AuraOn, FastMap};
6
7use crate::rotation::{
8    buffer::{
9        AuraProjectionKey, AuraProjectionSlotHandle, AuraSlot, AuraSlotHandle, BoundDenseBuffer,
10        CooldownSlot, CooldownSlotHandle, DenseBuffer, DescriptorId, DescriptorTable, EvalKind,
11        FrozenBufferLayout, HistorySlotHandle, ItemSlotHandle, LayoutFingerprint, ResourceSlot,
12        ResourceSlotHandle, SpellSlot, SpellSlotHandle, SwingSlotHandle, TalentSlotHandle,
13        UnitSlotHandle, WeaponImbueSlotHandle,
14    },
15    condition::{ResolvedFieldRead, ResolvedKey},
16    error::{Error, Result},
17    expr::{FieldType, FieldTypeLayout as _},
18};
19
20/// Extra offsets used by the composite `spell.is_usable` evaluation.
21#[derive(Clone, Copy, Debug)]
22pub(crate) struct SpellUsableOffsets {
23    pub(crate) resource_current: Option<usize>,
24    pub(crate) secondary_resource_current: Option<usize>,
25    pub(crate) cooldown_base: Option<usize>,
26    pub(crate) gating_aura_offset: Option<usize>,
27    pub(crate) gating_aura_min_stacks: u8,
28    pub(crate) cost_bypass_aura_offset: Option<usize>,
29    pub(crate) cooldown_bypass_aura_offset: Option<usize>,
30    pub(crate) override_spell_offset: Option<usize>,
31    pub(crate) override_cooldown_base: Option<usize>,
32    pub(crate) override_aura_offset: Option<usize>,
33}
34
35/// Fully resolved runtime context layout.
36#[derive(Clone, Debug)]
37pub struct ContextSchema {
38    pub(crate) fields: Vec<ContextField>,
39    pub(crate) layout: Arc<FrozenBufferLayout>,
40    pub(super) user_var_offsets: FastMap<String, usize>,
41    pub(super) user_var_types: FastMap<String, FieldType>,
42    pub(super) spell_usable_offsets: FastMap<usize, SpellUsableOffsets>,
43    pub(super) cooldown_bypass_offsets: FastMap<usize, usize>,
44}
45
46/// Construction-only capability for freezing layout-local engine bindings.
47#[derive(Clone, Copy, Debug)]
48#[doc(hidden)]
49pub struct BufferFinalizer<'a> {
50    schema: &'a ContextSchema,
51}
52
53/// One resolved context field and its byte offset.
54#[derive(Clone, Debug)]
55pub(crate) struct ContextField {
56    pub(crate) descriptor_id: DescriptorId,
57    pub(crate) offset: usize,
58    pub(crate) field_type: FieldType,
59    pub(crate) key: Option<ResolvedKey>,
60    pub(crate) on: Option<AuraOn>,
61}
62
63impl ContextSchema {
64    #[must_use]
65    pub fn new_buffer(&self) -> DenseBuffer {
66        DenseBuffer::new(Arc::clone(&self.layout))
67    }
68
69    /// Allocate the finalized capability used by engine mechanics for typed slot access.
70    #[must_use]
71    pub fn new_bound_buffer(&self) -> BoundDenseBuffer {
72        BoundDenseBuffer::from_schema(self.new_buffer())
73    }
74
75    #[must_use]
76    pub fn buffer_size(&self) -> usize {
77        self.layout.byte_len()
78    }
79
80    pub fn layout_fingerprint(&self) -> LayoutFingerprint {
81        self.layout.fingerprint()
82    }
83
84    /// Begin the one-shot construction transaction that resolves layout-local handles.
85    #[doc(hidden)]
86    #[must_use]
87    pub const fn buffer_finalizer(&self) -> BufferFinalizer<'_> {
88        BufferFinalizer { schema: self }
89    }
90}
91
92impl BufferFinalizer<'_> {
93    #[doc(hidden)]
94    #[must_use]
95    pub fn bind_spell(&self, key: wowlab_types::sim::SpellIdx) -> Option<SpellSlotHandle> {
96        self.schema.layout.spell_handle(key)
97    }
98
99    #[doc(hidden)]
100    #[must_use]
101    pub fn bind_cooldown(&self, key: wowlab_types::sim::SpellIdx) -> Option<CooldownSlotHandle> {
102        self.schema.layout.cooldown_handle(key)
103    }
104
105    #[doc(hidden)]
106    #[must_use]
107    pub fn bind_history(&self, key: wowlab_types::sim::SpellIdx) -> Option<HistorySlotHandle> {
108        self.schema.layout.history_handle(key)
109    }
110
111    #[doc(hidden)]
112    #[must_use]
113    pub fn bind_aura(&self, key: wowlab_types::sim::AuraKey) -> Option<AuraSlotHandle> {
114        self.schema.layout.aura_handle(key)
115    }
116
117    #[doc(hidden)]
118    pub fn visit_aura_projections(
119        &self,
120        mut visit: impl FnMut(AuraProjectionKey, AuraProjectionSlotHandle),
121    ) {
122        for (key, handle) in self.schema.layout.bindings().aura_projections() {
123            visit(key, handle);
124        }
125    }
126
127    #[doc(hidden)]
128    pub fn visit_spells(
129        &self,
130        mut visit: impl FnMut(wowlab_types::sim::SpellIdx, SpellSlotHandle),
131    ) {
132        for (key, handle) in self.schema.layout.bindings().spells() {
133            visit(key, handle);
134        }
135    }
136
137    #[doc(hidden)]
138    pub fn visit_cooldowns(
139        &self,
140        mut visit: impl FnMut(wowlab_types::sim::SpellIdx, CooldownSlotHandle),
141    ) {
142        for (key, handle) in self.schema.layout.bindings().cooldowns() {
143            visit(key, handle);
144        }
145    }
146
147    #[doc(hidden)]
148    pub fn visit_resources(
149        &self,
150        mut visit: impl FnMut(wowlab_types::combat::ResourceType, ResourceSlotHandle),
151    ) {
152        for (key, handle) in self.schema.layout.bindings().resources() {
153            visit(key, handle);
154        }
155    }
156
157    #[doc(hidden)]
158    pub fn visit_units(&self, mut visit: impl FnMut(&str, UnitSlotHandle)) {
159        for (key, handle) in self.schema.layout.bindings().units() {
160            visit(key, handle);
161        }
162    }
163
164    #[doc(hidden)]
165    #[must_use]
166    pub fn bind_resource(
167        &self,
168        key: wowlab_types::combat::ResourceType,
169    ) -> Option<ResourceSlotHandle> {
170        self.schema.layout.resource_handle(key)
171    }
172
173    #[doc(hidden)]
174    #[must_use]
175    pub fn bind_talent(&self, key: &str) -> Option<TalentSlotHandle> {
176        self.schema.layout.talent_handle(key)
177    }
178
179    #[doc(hidden)]
180    #[must_use]
181    pub fn bind_item(&self, key: &str) -> Option<ItemSlotHandle> {
182        self.schema
183            .layout
184            .bindings()
185            .items()
186            .find_map(|(candidate, handle)| (candidate == key).then_some(handle))
187    }
188
189    #[doc(hidden)]
190    #[must_use]
191    pub fn bind_swing(&self, key: &str) -> Option<SwingSlotHandle> {
192        self.schema
193            .layout
194            .bindings()
195            .swings()
196            .find_map(|(candidate, handle)| (candidate == key).then_some(handle))
197    }
198
199    #[doc(hidden)]
200    #[must_use]
201    pub fn bind_unit(&self, key: &str) -> Option<UnitSlotHandle> {
202        self.schema.layout.unit_handle(key)
203    }
204
205    #[doc(hidden)]
206    #[must_use]
207    pub fn bind_weapon_imbue(&self, key: &str) -> Option<WeaponImbueSlotHandle> {
208        self.schema
209            .layout
210            .bindings()
211            .weapon_imbues()
212            .find_map(|(candidate, handle)| (candidate == key).then_some(handle))
213    }
214}
215
216impl ContextSchema {
217    #[must_use]
218    pub fn field_count(&self) -> usize {
219        self.fields.len()
220    }
221
222    #[must_use]
223    pub fn has_spell_usable_fields(&self) -> bool {
224        !self.spell_usable_offsets.is_empty()
225    }
226
227    #[must_use]
228    pub(crate) fn field_offset(&self, resolved: &ResolvedFieldRead) -> Option<usize> {
229        self.fields
230            .iter()
231            .find(|field| {
232                field.descriptor_id == resolved.descriptor_id
233                    && field.key == resolved.key
234                    && field.on == resolved.on
235            })
236            .map(|field| field.offset)
237    }
238
239    #[must_use]
240    pub(crate) fn user_var_offset(&self, name: &str) -> Option<usize> {
241        self.user_var_offsets.get(name).copied()
242    }
243
244    #[must_use]
245    pub(crate) fn user_var_type(&self, name: &str) -> Option<FieldType> {
246        self.user_var_types.get(name).copied()
247    }
248
249    #[must_use]
250    pub(crate) fn spell_usable_offsets(&self, field_offset: usize) -> Option<&SpellUsableOffsets> {
251        self.spell_usable_offsets.get(&field_offset)
252    }
253
254    #[must_use]
255    pub(crate) fn cooldown_bypass_aura_offset(&self, cooldown_offset: usize) -> Option<usize> {
256        self.cooldown_bypass_offsets.get(&cooldown_offset).copied()
257    }
258
259    pub(in crate::rotation) fn validate(&self, table: &DescriptorTable) -> Result<()> {
260        let byte_len = self.layout.byte_len();
261
262        self.validate_fields(table, byte_len)?;
263        self.validate_variables(byte_len)?;
264        self.validate_spell_usability(byte_len)?;
265
266        self.validate_cooldown_bypasses(byte_len)
267    }
268
269    fn validate_fields(&self, table: &DescriptorTable, byte_len: usize) -> Result<()> {
270        for field in &self.fields {
271            let descriptor = table.get(field.descriptor_id).ok_or_else(|| {
272                Error::validation("prepared rotation references a missing descriptor")
273            })?;
274
275            if descriptor.field_type != field.field_type {
276                return Err(Error::validation(
277                    "prepared rotation descriptor has a mismatched field kind",
278                ));
279            }
280
281            validate_coordinate(
282                field.offset,
283                field.field_type.size(),
284                field.field_type.alignment(),
285                byte_len,
286            )?;
287
288            if descriptor.eval_kind == EvalKind::SpellUsable
289                && !self.spell_usable_offsets.contains_key(&field.offset)
290            {
291                return Err(Error::validation(
292                    "prepared rotation spell usability field has no composite coordinates",
293                ));
294            }
295        }
296
297        Ok(())
298    }
299
300    fn validate_variables(&self, byte_len: usize) -> Result<()> {
301        for (name, &offset) in &self.user_var_offsets {
302            let field_type =
303                self.user_var_types.get(name).copied().ok_or_else(|| {
304                    Error::validation("prepared rotation variable has no field kind")
305                })?;
306
307            validate_coordinate(offset, field_type.size(), field_type.alignment(), byte_len)?;
308        }
309
310        Ok(())
311    }
312
313    fn validate_spell_usability(&self, byte_len: usize) -> Result<()> {
314        for offsets in self.spell_usable_offsets.values() {
315            validate_optional_slot::<ResourceSlot>(offsets.resource_current, byte_len)?;
316            validate_optional_slot::<ResourceSlot>(offsets.secondary_resource_current, byte_len)?;
317            validate_optional_slot::<CooldownSlot>(offsets.cooldown_base, byte_len)?;
318            validate_optional_slot::<AuraSlot>(offsets.gating_aura_offset, byte_len)?;
319            validate_optional_slot::<AuraSlot>(offsets.cost_bypass_aura_offset, byte_len)?;
320            validate_optional_slot::<AuraSlot>(offsets.cooldown_bypass_aura_offset, byte_len)?;
321            validate_optional_slot::<SpellSlot>(offsets.override_spell_offset, byte_len)?;
322            validate_optional_slot::<CooldownSlot>(offsets.override_cooldown_base, byte_len)?;
323            validate_optional_slot::<AuraSlot>(offsets.override_aura_offset, byte_len)?;
324        }
325
326        Ok(())
327    }
328
329    fn validate_cooldown_bypasses(&self, byte_len: usize) -> Result<()> {
330        for (&cooldown, &aura) in &self.cooldown_bypass_offsets {
331            validate_coordinate(
332                cooldown,
333                size_of::<CooldownSlot>(),
334                align_of::<CooldownSlot>(),
335                byte_len,
336            )?;
337            validate_coordinate(
338                aura,
339                size_of::<AuraSlot>(),
340                align_of::<AuraSlot>(),
341                byte_len,
342            )?;
343        }
344
345        Ok(())
346    }
347}
348
349fn validate_optional_slot<T>(offset: Option<usize>, byte_len: usize) -> Result<()> {
350    if let Some(offset) = offset {
351        validate_coordinate(offset, size_of::<T>(), align_of::<T>(), byte_len)?;
352    }
353
354    Ok(())
355}
356
357fn validate_coordinate(
358    offset: usize,
359    width: usize,
360    alignment: usize,
361    byte_len: usize,
362) -> Result<()> {
363    if !offset.is_multiple_of(alignment) {
364        return Err(Error::validation(format!(
365            "prepared rotation buffer coordinate {offset} is misaligned for {alignment}-byte alignment",
366        )));
367    }
368
369    if offset.checked_add(width).is_none_or(|end| end > byte_len) {
370        return Err(Error::validation(format!(
371            "prepared rotation buffer coordinate {offset} with width {width} exceeds layout size {byte_len}",
372        )));
373    }
374
375    Ok(())
376}
377
378#[cfg(test)]
379mod tests {
380    use googletest::prelude::*;
381
382    use super::*;
383    use crate::rotation::{BufferRequirements, SpecResolver, parse_and_validate};
384
385    fn spell_schema() -> (ContextSchema, DescriptorTable) {
386        let rotation = parse_and_validate(
387            r#"{
388                "version":1,
389                "name":"schema-validation",
390                "variables":{"counter":{"type":"int","value":1}},
391                "actions":[{"type":"cast","spell":"primary"}],
392                "lists":{}
393            }"#,
394        )
395        .expect("parse schema fixture");
396        let resolver = SpecResolver::new("schema-validation").spell("primary", 1);
397        let table = DescriptorTable::build();
398        let schema = crate::rotation::lower::prepare(
399            &rotation,
400            &resolver,
401            &table,
402            &BufferRequirements::new(),
403        )
404        .expect("prepare schema fixture");
405
406        (schema, table)
407    }
408
409    #[gtest]
410    fn missing_composite_coordinate_is_rejected() {
411        let (mut schema, table) = spell_schema();
412
413        schema.spell_usable_offsets.clear();
414
415        expect_that!(schema.validate(&table).is_err(), is_true());
416    }
417
418    #[gtest]
419    fn mismatched_coordinate_kind_is_rejected() {
420        let (mut schema, table) = spell_schema();
421        let field = schema
422            .fields
423            .first_mut()
424            .expect("fixture has a context field");
425
426        field.field_type = match field.field_type {
427            FieldType::Bool => FieldType::Float,
428            _ => FieldType::Bool,
429        };
430
431        expect_that!(schema.validate(&table).is_err(), is_true());
432    }
433
434    #[gtest]
435    fn misaligned_coordinate_is_rejected() {
436        let (mut schema, table) = spell_schema();
437        let field = schema
438            .fields
439            .iter_mut()
440            .find(|field| field.field_type.alignment() > 1)
441            .expect("fixture has an aligned context field");
442
443        field.offset += 1;
444
445        expect_that!(schema.validate(&table).is_err(), is_true());
446    }
447
448    #[gtest]
449    fn out_of_layout_coordinate_is_rejected() {
450        let (mut schema, table) = spell_schema();
451        let byte_len = schema.layout.byte_len();
452        let field = schema
453            .fields
454            .first_mut()
455            .expect("fixture has a context field");
456
457        field.offset = byte_len;
458
459        expect_that!(schema.validate(&table).is_err(), is_true());
460    }
461
462    #[gtest]
463    fn variable_coordinate_without_a_kind_is_rejected() {
464        let (mut schema, table) = spell_schema();
465
466        schema.user_var_types.clear();
467
468        expect_that!(schema.validate(&table).is_err(), is_true());
469    }
470}