wow_wmo/
validator.rs

1use crate::error::Result;
2use crate::version::WmoVersion;
3use crate::wmo_group_types::WmoGroup;
4use crate::wmo_types::{WmoFlags, WmoRoot};
5
6// Use WmoGroupFlags from wmo_group_types since that's where WmoGroupHeader uses it
7use crate::wmo_group_types::WmoGroupFlags;
8
9/// Validator for WMO files
10pub struct WmoValidator;
11
12impl Default for WmoValidator {
13    fn default() -> Self {
14        Self::new()
15    }
16}
17
18impl WmoValidator {
19    /// Create a new WMO validator
20    pub fn new() -> Self {
21        Self
22    }
23
24    /// Validate a WMO root file
25    pub fn validate_root(&self, wmo: &WmoRoot) -> Result<ValidationReport> {
26        let mut report = ValidationReport::new();
27
28        // Check version supported
29        if wmo.version < WmoVersion::min_supported() || wmo.version > WmoVersion::max_supported() {
30            report.add_error(ValidationError::UnsupportedVersion(wmo.version.to_raw()));
31        }
32
33        // Validate header counts match actual counts
34        if wmo.materials.len() != wmo.header.n_materials as usize {
35            report.add_error(ValidationError::CountMismatch {
36                field: "materials".to_string(),
37                expected: wmo.header.n_materials,
38                actual: wmo.materials.len() as u32,
39            });
40        }
41
42        if wmo.groups.len() != wmo.header.n_groups as usize {
43            report.add_error(ValidationError::CountMismatch {
44                field: "groups".to_string(),
45                expected: wmo.header.n_groups,
46                actual: wmo.groups.len() as u32,
47            });
48        }
49
50        if wmo.portals.len() != wmo.header.n_portals as usize {
51            report.add_error(ValidationError::CountMismatch {
52                field: "portals".to_string(),
53                expected: wmo.header.n_portals,
54                actual: wmo.portals.len() as u32,
55            });
56        }
57
58        if wmo.lights.len() != wmo.header.n_lights as usize {
59            report.add_error(ValidationError::CountMismatch {
60                field: "lights".to_string(),
61                expected: wmo.header.n_lights,
62                actual: wmo.lights.len() as u32,
63            });
64        }
65
66        if wmo.doodad_defs.len() != wmo.header.n_doodad_defs as usize {
67            report.add_error(ValidationError::CountMismatch {
68                field: "doodad_defs".to_string(),
69                expected: wmo.header.n_doodad_defs,
70                actual: wmo.doodad_defs.len() as u32,
71            });
72        }
73
74        if wmo.doodad_sets.len() != wmo.header.n_doodad_sets as usize {
75            report.add_error(ValidationError::CountMismatch {
76                field: "doodad_sets".to_string(),
77                expected: wmo.header.n_doodad_sets,
78                actual: wmo.doodad_sets.len() as u32,
79            });
80        }
81
82        // Validate material references
83        for (i, material) in wmo.materials.iter().enumerate() {
84            // Check texture indices are valid
85            // Special values above 0xFF000000 are used as markers (e.g., no texture)
86            const SPECIAL_TEXTURE_THRESHOLD: u32 = 0xFF000000;
87
88            if material.texture1 != 0
89                && material.texture1 < SPECIAL_TEXTURE_THRESHOLD
90                && material.texture1 as usize >= wmo.textures.len()
91            {
92                report.add_error(ValidationError::InvalidReference {
93                    field: format!("material[{i}].texture1"),
94                    value: material.texture1,
95                    max: wmo.textures.len() as u32 - 1,
96                });
97            }
98
99            if material.texture2 != 0
100                && material.texture2 < SPECIAL_TEXTURE_THRESHOLD
101                && material.texture2 as usize >= wmo.textures.len()
102            {
103                report.add_error(ValidationError::InvalidReference {
104                    field: format!("material[{i}].texture2"),
105                    value: material.texture2,
106                    max: wmo.textures.len() as u32 - 1,
107                });
108            }
109
110            // Check shader ID is valid
111            // Special values above 0xFF000000 are used as markers
112            if material.shader > 20 && material.shader < SPECIAL_TEXTURE_THRESHOLD {
113                report.add_warning(ValidationWarning::UnusualValue {
114                    field: format!("material[{i}].shader"),
115                    value: material.shader,
116                    explanation: "Shader ID is unusually high".to_string(),
117                });
118            }
119        }
120
121        // Validate doodad sets
122        for (i, set) in wmo.doodad_sets.iter().enumerate() {
123            let end_index = set.start_doodad + set.n_doodads;
124            if end_index > wmo.doodad_defs.len() as u32 {
125                report.add_error(ValidationError::InvalidReference {
126                    field: format!("doodad_set[{i}]"),
127                    value: end_index,
128                    max: wmo.doodad_defs.len() as u32,
129                });
130            }
131        }
132
133        // Validate portal references
134        for (i, portal_ref) in wmo.portal_references.iter().enumerate() {
135            if portal_ref.portal_index as usize >= wmo.portals.len() {
136                report.add_error(ValidationError::InvalidReference {
137                    field: format!("portal_reference[{i}].portal_index"),
138                    value: portal_ref.portal_index as u32,
139                    max: wmo.portals.len() as u32 - 1,
140                });
141            }
142
143            if portal_ref.group_index as usize >= wmo.groups.len() {
144                report.add_error(ValidationError::InvalidReference {
145                    field: format!("portal_reference[{i}].group_index"),
146                    value: portal_ref.group_index as u32,
147                    max: wmo.groups.len() as u32 - 1,
148                });
149            }
150
151            if portal_ref.side > 1 {
152                report.add_error(ValidationError::InvalidValue {
153                    field: format!("portal_reference[{i}].side"),
154                    value: portal_ref.side as u32,
155                    explanation: "Portal side must be 0 or 1".to_string(),
156                });
157            }
158        }
159
160        // Check skybox flag consistency
161        if wmo.header.flags.contains(WmoFlags::HAS_SKYBOX) && wmo.skybox.is_none() {
162            report.add_warning(ValidationWarning::FlagInconsistency {
163                flag: "HAS_SKYBOX".to_string(),
164                field: "skybox".to_string(),
165                explanation: "HAS_SKYBOX flag is set but no skybox model is defined".to_string(),
166            });
167        }
168
169        if !wmo.header.flags.contains(WmoFlags::HAS_SKYBOX) && wmo.skybox.is_some() {
170            report.add_warning(ValidationWarning::FlagInconsistency {
171                flag: "HAS_SKYBOX".to_string(),
172                field: "skybox".to_string(),
173                explanation: "Skybox model is defined but HAS_SKYBOX flag is not set".to_string(),
174            });
175        }
176
177        // Check for portals with no vertices
178        for (i, portal) in wmo.portals.iter().enumerate() {
179            if portal.vertices.is_empty() {
180                report.add_warning(ValidationWarning::UnusualStructure {
181                    field: format!("portal[{i}]"),
182                    explanation: "Portal has no vertices".to_string(),
183                });
184            }
185        }
186
187        // Check for empty or missing visible block lists
188        if wmo.visible_block_lists.is_empty() && !wmo.portals.is_empty() {
189            report.add_warning(ValidationWarning::MissingData {
190                field: "visible_block_lists".to_string(),
191                explanation: "No visible block lists defined but portals exist".to_string(),
192            });
193        }
194
195        // Check for non-normalized normals in portals
196        for (i, portal) in wmo.portals.iter().enumerate() {
197            let normal = &portal.normal;
198            let length_squared = normal.x * normal.x + normal.y * normal.y + normal.z * normal.z;
199
200            // Check if length is significantly different from 1.0
201            if (length_squared - 1.0).abs() > 0.01 {
202                report.add_warning(ValidationWarning::UnusualValue {
203                    field: format!("portal[{i}].normal"),
204                    value: length_squared as u32,
205                    explanation: "Portal normal is not normalized".to_string(),
206                });
207            }
208        }
209
210        // Check bounding box validity
211        if wmo.bounding_box.min.x > wmo.bounding_box.max.x
212            || wmo.bounding_box.min.y > wmo.bounding_box.max.y
213            || wmo.bounding_box.min.z > wmo.bounding_box.max.z
214        {
215            report.add_error(ValidationError::InvalidBoundingBox {
216                min: format!(
217                    "({}, {}, {})",
218                    wmo.bounding_box.min.x, wmo.bounding_box.min.y, wmo.bounding_box.min.z
219                ),
220                max: format!(
221                    "({}, {}, {})",
222                    wmo.bounding_box.max.x, wmo.bounding_box.max.y, wmo.bounding_box.max.z
223                ),
224            });
225        }
226
227        Ok(report)
228    }
229
230    /// Validate a WMO group file
231    pub fn validate_group(&self, group: &WmoGroup) -> Result<ValidationReport> {
232        let mut report = ValidationReport::new();
233
234        // Check for empty vertices
235        if group.vertices.is_empty() {
236            report.add_error(ValidationError::EmptyData {
237                field: "vertices".to_string(),
238                explanation: "Group has no vertices".to_string(),
239            });
240        }
241
242        // Check for empty indices
243        if group.indices.is_empty() {
244            report.add_error(ValidationError::EmptyData {
245                field: "indices".to_string(),
246                explanation: "Group has no indices".to_string(),
247            });
248        }
249
250        // Check for empty batches
251        if group.batches.is_empty() {
252            report.add_warning(ValidationWarning::EmptyData {
253                field: "batches".to_string(),
254                explanation: "Group has no batches".to_string(),
255            });
256        }
257
258        // Check index references
259        for (i, batch) in group.batches.iter().enumerate() {
260            let end_index = batch.start_index + batch.count as u32;
261            if end_index > group.indices.len() as u32 {
262                report.add_error(ValidationError::InvalidReference {
263                    field: format!("batch[{i}].indices"),
264                    value: end_index,
265                    max: group.indices.len() as u32,
266                });
267            }
268
269            if batch.end_vertex as usize > group.vertices.len() {
270                report.add_error(ValidationError::InvalidReference {
271                    field: format!("batch[{i}].end_vertex"),
272                    value: batch.end_vertex as u32,
273                    max: group.vertices.len() as u32,
274                });
275            }
276        }
277
278        // Check vertex indices are in range
279        for (i, &index) in group.indices.iter().enumerate() {
280            if index as usize >= group.vertices.len() {
281                report.add_error(ValidationError::InvalidReference {
282                    field: format!("indices[{i}]"),
283                    value: index as u32,
284                    max: group.vertices.len() as u32 - 1,
285                });
286            }
287        }
288
289        // Check normals count matches vertices if present
290        if !group.normals.is_empty() && group.normals.len() != group.vertices.len() {
291            report.add_error(ValidationError::CountMismatch {
292                field: "normals".to_string(),
293                expected: group.vertices.len() as u32,
294                actual: group.normals.len() as u32,
295            });
296        }
297
298        // Check texture coordinates count matches vertices if present
299        if !group.tex_coords.is_empty() && group.tex_coords.len() != group.vertices.len() {
300            report.add_error(ValidationError::CountMismatch {
301                field: "tex_coords".to_string(),
302                expected: group.vertices.len() as u32,
303                actual: group.tex_coords.len() as u32,
304            });
305        }
306
307        // Check vertex colors count matches vertices if present
308        if let Some(colors) = &group.vertex_colors
309            && colors.len() != group.vertices.len()
310        {
311            report.add_error(ValidationError::CountMismatch {
312                field: "vertex_colors".to_string(),
313                expected: group.vertices.len() as u32,
314                actual: colors.len() as u32,
315            });
316        }
317
318        // Check flags consistency for normals
319        if group.header.flags.contains(WmoGroupFlags::HAS_NORMALS) && group.normals.is_empty() {
320            report.add_warning(ValidationWarning::FlagInconsistency {
321                flag: "HAS_NORMALS".to_string(),
322                field: "normals".to_string(),
323                explanation: "HAS_NORMALS flag is set but no normals are present".to_string(),
324            });
325        }
326
327        if !group.header.flags.contains(WmoGroupFlags::HAS_NORMALS) && !group.normals.is_empty() {
328            report.add_warning(ValidationWarning::FlagInconsistency {
329                flag: "HAS_NORMALS".to_string(),
330                field: "normals".to_string(),
331                explanation: "Normals are present but HAS_NORMALS flag is not set".to_string(),
332            });
333        }
334
335        // Check flags consistency for vertex colors
336        if group
337            .header
338            .flags
339            .contains(WmoGroupFlags::HAS_VERTEX_COLORS)
340            && group.vertex_colors.is_none()
341        {
342            report.add_warning(ValidationWarning::FlagInconsistency {
343                flag: "HAS_VERTEX_COLORS".to_string(),
344                field: "vertex_colors".to_string(),
345                explanation: "HAS_VERTEX_COLORS flag is set but no vertex colors are present"
346                    .to_string(),
347            });
348        }
349
350        if !group
351            .header
352            .flags
353            .contains(WmoGroupFlags::HAS_VERTEX_COLORS)
354            && group.vertex_colors.is_some()
355        {
356            report.add_warning(ValidationWarning::FlagInconsistency {
357                flag: "HAS_VERTEX_COLORS".to_string(),
358                field: "vertex_colors".to_string(),
359                explanation: "Vertex colors are present but HAS_VERTEX_COLORS flag is not set"
360                    .to_string(),
361            });
362        }
363
364        // Check flags consistency for doodads
365        if group.header.flags.contains(WmoGroupFlags::HAS_DOODADS) && group.doodad_refs.is_none() {
366            report.add_warning(ValidationWarning::FlagInconsistency {
367                flag: "HAS_DOODADS".to_string(),
368                field: "doodad_refs".to_string(),
369                explanation: "HAS_DOODADS flag is set but no doodad references are present"
370                    .to_string(),
371            });
372        }
373
374        if !group.header.flags.contains(WmoGroupFlags::HAS_DOODADS) && group.doodad_refs.is_some() {
375            report.add_warning(ValidationWarning::FlagInconsistency {
376                flag: "HAS_DOODADS".to_string(),
377                field: "doodad_refs".to_string(),
378                explanation: "Doodad references are present but HAS_DOODADS flag is not set"
379                    .to_string(),
380            });
381        }
382
383        // Check flags consistency for water
384        if group.header.flags.contains(WmoGroupFlags::HAS_WATER) && group.liquid.is_none() {
385            report.add_warning(ValidationWarning::FlagInconsistency {
386                flag: "HAS_WATER".to_string(),
387                field: "liquid".to_string(),
388                explanation: "HAS_WATER flag is set but no liquid data is present".to_string(),
389            });
390        }
391
392        if !group.header.flags.contains(WmoGroupFlags::HAS_WATER) && group.liquid.is_some() {
393            report.add_warning(ValidationWarning::FlagInconsistency {
394                flag: "HAS_WATER".to_string(),
395                field: "liquid".to_string(),
396                explanation: "Liquid data is present but HAS_WATER flag is not set".to_string(),
397            });
398        }
399
400        // Check bounding box validity
401        if group.header.bounding_box.min.x > group.header.bounding_box.max.x
402            || group.header.bounding_box.min.y > group.header.bounding_box.max.y
403            || group.header.bounding_box.min.z > group.header.bounding_box.max.z
404        {
405            report.add_error(ValidationError::InvalidBoundingBox {
406                min: format!(
407                    "({}, {}, {})",
408                    group.header.bounding_box.min.x,
409                    group.header.bounding_box.min.y,
410                    group.header.bounding_box.min.z
411                ),
412                max: format!(
413                    "({}, {}, {})",
414                    group.header.bounding_box.max.x,
415                    group.header.bounding_box.max.y,
416                    group.header.bounding_box.max.z
417                ),
418            });
419        }
420
421        // Check if vertices fit in bounding box
422        for (i, vertex) in group.vertices.iter().enumerate() {
423            if vertex.x < group.header.bounding_box.min.x
424                || vertex.x > group.header.bounding_box.max.x
425                || vertex.y < group.header.bounding_box.min.y
426                || vertex.y > group.header.bounding_box.max.y
427                || vertex.z < group.header.bounding_box.min.z
428                || vertex.z > group.header.bounding_box.max.z
429            {
430                report.add_warning(ValidationWarning::OutOfBounds {
431                    field: format!("vertex[{i}]"),
432                    value: format!("({}, {}, {})", vertex.x, vertex.y, vertex.z),
433                    bounds: format!(
434                        "({}, {}, {}) - ({}, {}, {})",
435                        group.header.bounding_box.min.x,
436                        group.header.bounding_box.min.y,
437                        group.header.bounding_box.min.z,
438                        group.header.bounding_box.max.x,
439                        group.header.bounding_box.max.y,
440                        group.header.bounding_box.max.z
441                    ),
442                });
443            }
444        }
445
446        Ok(report)
447    }
448}
449
450/// Report of validation results
451#[derive(Debug)]
452pub struct ValidationReport {
453    /// Validation errors (severe issues)
454    pub errors: Vec<ValidationError>,
455
456    /// Validation warnings (potential issues)
457    pub warnings: Vec<ValidationWarning>,
458}
459
460impl Default for ValidationReport {
461    fn default() -> Self {
462        Self::new()
463    }
464}
465
466impl ValidationReport {
467    /// Create a new empty validation report
468    pub fn new() -> Self {
469        Self {
470            errors: Vec::new(),
471            warnings: Vec::new(),
472        }
473    }
474
475    /// Add an error to the report
476    pub fn add_error(&mut self, error: ValidationError) {
477        self.errors.push(error);
478    }
479
480    /// Add a warning to the report
481    pub fn add_warning(&mut self, warning: ValidationWarning) {
482        self.warnings.push(warning);
483    }
484
485    /// Check if the report has any errors
486    pub fn has_errors(&self) -> bool {
487        !self.errors.is_empty()
488    }
489
490    /// Check if the report has any warnings
491    pub fn has_warnings(&self) -> bool {
492        !self.warnings.is_empty()
493    }
494
495    /// Count the number of errors
496    pub fn error_count(&self) -> usize {
497        self.errors.len()
498    }
499
500    /// Count the number of warnings
501    pub fn warning_count(&self) -> usize {
502        self.warnings.len()
503    }
504
505    /// Print the report to the console
506    pub fn print(&self) {
507        if self.has_errors() {
508            println!("Validation Errors:");
509            for error in &self.errors {
510                println!("  - {error}");
511            }
512        }
513
514        if self.has_warnings() {
515            println!("Validation Warnings:");
516            for warning in &self.warnings {
517                println!("  - {warning}");
518            }
519        }
520
521        if !self.has_errors() && !self.has_warnings() {
522            println!("No validation issues found.");
523        }
524    }
525}
526
527/// Validation error types
528#[derive(Debug)]
529pub enum ValidationError {
530    /// Unsupported WMO version
531    UnsupportedVersion(u32),
532
533    /// Count mismatch between header and actual data
534    CountMismatch {
535        field: String,
536        expected: u32,
537        actual: u32,
538    },
539
540    /// Invalid reference
541    InvalidReference { field: String, value: u32, max: u32 },
542
543    /// Invalid value
544    InvalidValue {
545        field: String,
546        value: u32,
547        explanation: String,
548    },
549
550    /// Invalid bounding box
551    InvalidBoundingBox { min: String, max: String },
552
553    /// Empty required data
554    EmptyData { field: String, explanation: String },
555}
556
557impl std::fmt::Display for ValidationError {
558    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
559        match self {
560            Self::UnsupportedVersion(version) => write!(f, "Unsupported WMO version: {version}"),
561            Self::CountMismatch {
562                field,
563                expected,
564                actual,
565            } => {
566                write!(
567                    f,
568                    "Count mismatch for {field}: expected {expected}, found {actual}"
569                )
570            }
571            Self::InvalidReference { field, value, max } => {
572                write!(
573                    f,
574                    "Invalid reference in {field}: {value}, max allowed: {max}"
575                )
576            }
577            Self::InvalidValue {
578                field,
579                value,
580                explanation,
581            } => {
582                write!(f, "Invalid value in {field}: {value} ({explanation})")
583            }
584            Self::InvalidBoundingBox { min, max } => {
585                write!(f, "Invalid bounding box: min {min} exceeds max {max}")
586            }
587            Self::EmptyData { field, explanation } => {
588                write!(f, "Empty data for {field}: {explanation}")
589            }
590        }
591    }
592}
593
594/// Validation warning types
595#[derive(Debug)]
596pub enum ValidationWarning {
597    /// Flag inconsistency
598    FlagInconsistency {
599        flag: String,
600        field: String,
601        explanation: String,
602    },
603
604    /// Unusual value
605    UnusualValue {
606        field: String,
607        value: u32,
608        explanation: String,
609    },
610
611    /// Unusual structure
612    UnusualStructure { field: String, explanation: String },
613
614    /// Missing data
615    MissingData { field: String, explanation: String },
616
617    /// Empty data (non-critical)
618    EmptyData { field: String, explanation: String },
619
620    /// Out of bounds
621    OutOfBounds {
622        field: String,
623        value: String,
624        bounds: String,
625    },
626}
627
628impl std::fmt::Display for ValidationWarning {
629    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
630        match self {
631            Self::FlagInconsistency {
632                flag,
633                field,
634                explanation,
635            } => {
636                write!(f, "Flag inconsistency with {flag}: {field} ({explanation})")
637            }
638            Self::UnusualValue {
639                field,
640                value,
641                explanation,
642            } => {
643                write!(f, "Unusual value in {field}: {value} ({explanation})")
644            }
645            Self::UnusualStructure { field, explanation } => {
646                write!(f, "Unusual structure in {field}: {explanation}")
647            }
648            Self::MissingData { field, explanation } => {
649                write!(f, "Missing data for {field}: {explanation}")
650            }
651            Self::EmptyData { field, explanation } => {
652                write!(f, "Empty data for {field}: {explanation}")
653            }
654            Self::OutOfBounds {
655                field,
656                value,
657                bounds,
658            } => {
659                write!(f, "{field} is out of bounds: {value} not within {bounds}")
660            }
661        }
662    }
663}