rpfm_extensions/diagnostics/mod.rs
1//---------------------------------------------------------------------------//
2// Copyright (c) 2017-2026 Ismael Gutiérrez González. All rights reserved.
3//
4// This file is part of the Rusted PackFile Manager (RPFM) project,
5// which can be found here: https://github.com/Frodo45127/rpfm.
6//
7// This file is licensed under the MIT license, which can be found here:
8// https://github.com/Frodo45127/rpfm/blob/master/LICENSE.
9//---------------------------------------------------------------------------//
10
11//! Pack validation and diagnostic checking system.
12//!
13//! This module provides comprehensive validation for Total War mod packs, detecting
14//! common errors, potential issues, and best practice violations. Diagnostics help
15//! modders identify problems before they cause crashes or unexpected behavior in-game.
16//!
17//! # Diagnostic Types
18//!
19//! The system checks multiple aspects of a pack:
20//!
21//! - **Table Diagnostics** ([`table`]): DB and Loc table validation
22//! - Invalid foreign key references
23//! - Empty required fields (keys, values)
24//! - Duplicate rows
25//! - Orphaned localisation entries
26//!
27//! - **Pack Diagnostics** ([`pack`]): Pack-level checks
28//! - Files conflicting with vanilla
29//! - Missing declared dependencies
30//!
31//! - **Dependency Diagnostics** ([`dependency`]): Cross-pack validation
32//! - References to non-existent files
33//! - Circular dependencies
34//!
35//! - **Portrait Settings Diagnostics** ([`portrait_settings`]): Unit portrait validation
36//! - Invalid art set references
37//! - Missing variant definitions
38//!
39//! - **Animation Fragment Diagnostics** ([`anim_fragment_battle`]): Animation validation
40//! - Invalid animation references
41//! - Malformed fragment data
42//!
43//! - **Text Diagnostics** ([`text`]): Script validation
44//!
45//! - **Config Diagnostics** ([`config`]): Configuration file validation
46//!
47//! # Diagnostic Levels
48//!
49//! Each diagnostic has an associated severity level:
50//!
51//! - **Error**: Critical issues that will likely cause crashes or major problems
52//! - **Warning**: Issues that may cause problems or indicate mistakes
53//! - **Info**: Suggestions and informational notes
54//!
55//! # Cell Position Encoding
56//!
57//! For table diagnostics, the affected cells are encoded as (row, column) pairs:
58//!
59//! - `(-1, -1)`: Affects the entire table
60//! - `(row, -1)`: Affects all columns in a single row
61//! - `(-1, column)`: Affects all rows in a single column
62//! - `(row, column)`: Affects a specific cell
63//!
64//! # Filtering
65//!
66//! Diagnostics can be filtered by:
67//!
68//! - Ignored folders (skip entire directory trees)
69//! - Ignored files (skip specific files)
70//! - Ignored fields (skip specific table columns)
71//! - Ignored diagnostic types
72//!
73//! # Usage Example
74//!
75//! ```ignore
76//! use rpfm_extensions::diagnostics::Diagnostics;
77//!
78//! let mut diagnostics = Diagnostics::default();
79//! diagnostics.check(
80//! &mut pack,
81//! &mut dependencies,
82//! &schema,
83//! &game_info,
84//! game_path,
85//! &[], // Check all paths
86//! false, // Don't check AK-only references
87//! );
88//!
89//! for result in diagnostics.results() {
90//! println!("{}: {}", result.path(), result.message());
91//! }
92//! ```
93
94use getset::{Getters, MutGetters};
95use rayon::prelude::*;
96use serde_derive::{Serialize, Deserialize};
97
98use std::borrow::Cow;
99use std::collections::{BTreeMap, HashMap, HashSet};
100use std::cmp::Ordering;
101use std::{fmt, fmt::Display};
102use std::path::Path;
103
104use rpfm_lib::error::Result;
105use rpfm_lib::files::{ContainerPath, Container, DecodeableExtraData, FileType, pack::Pack, RFile, RFileDecoded};
106use rpfm_lib::games::{GameInfo, VanillaDBTableNameLogic};
107use rpfm_lib::schema::{FieldType, Schema};
108
109use crate::dependencies::Dependencies;
110
111use self::anim_fragment_battle::*;
112use self::config::*;
113use self::dependency::*;
114use self::pack::*;
115use self::portrait_settings::*;
116use self::table::*;
117use self::text::TextDiagnostic;
118
119pub mod anim_fragment_battle;
120pub mod config;
121pub mod dependency;
122pub mod pack;
123pub mod portrait_settings;
124pub mod table;
125pub mod text;
126
127//-------------------------------------------------------------------------------//
128// Trait definitions
129//-------------------------------------------------------------------------------//
130
131/// Trait for types that can report diagnostic information.
132///
133/// All diagnostic result types implement this trait to provide a consistent
134/// interface for accessing the diagnostic message and severity level.
135pub trait DiagnosticReport {
136
137 /// Returns the human-readable message describing this diagnostic.
138 ///
139 /// The message should clearly explain what the issue is and, where possible,
140 /// suggest how to fix it.
141 fn message(&self) -> String;
142
143 /// Returns the severity level of this diagnostic.
144 ///
145 /// Used for filtering and prioritizing diagnostic results.
146 fn level(&self) -> DiagnosticLevel;
147}
148
149//-------------------------------------------------------------------------------//
150// Enums & Structs
151//-------------------------------------------------------------------------------//
152
153/// Container for diagnostic check results and configuration.
154///
155/// This struct holds both the configuration for which diagnostics to run
156/// (via ignore lists) and the results of the diagnostic check.
157///
158/// # Filtering
159///
160/// Use the ignore fields to exclude certain items from diagnostic checks:
161///
162/// - `folders_ignored`: Skip entire folder trees (e.g., "db/deprecated_tables")
163/// - `files_ignored`: Skip specific files by path
164/// - `fields_ignored`: Skip specific table columns (format: "table_name/field_name")
165/// - `diagnostics_ignored`: Skip specific diagnostic types by identifier
166#[derive(Debug, Clone, Default, Getters, MutGetters, Serialize, Deserialize)]
167#[getset(get = "pub", get_mut = "pub")]
168pub struct Diagnostics {
169
170 /// Folder paths to exclude from diagnostic checks.
171 ///
172 /// Files within these folders (and subfolders) will not be checked.
173 folders_ignored: Vec<String>,
174
175 /// File paths to exclude from diagnostic checks.
176 files_ignored: Vec<String>,
177
178 /// Table fields to exclude from diagnostic checks.
179 ///
180 /// Format: "table_name/field_name" (e.g., "units_tables/key")
181 fields_ignored: Vec<String>,
182
183 /// Diagnostic type identifiers to skip.
184 ///
185 /// Use this to disable specific checks that produce false positives
186 /// or are not relevant to your mod.
187 diagnostics_ignored: Vec<String>,
188
189 /// The diagnostic results from the most recent check.
190 results: Vec<DiagnosticType>
191}
192
193/// Wrapper enum for all diagnostic result types.
194///
195/// Each variant corresponds to a different file type or check category,
196/// containing the specific diagnostic struct for that type.
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub enum DiagnosticType {
199 /// Diagnostics for animation fragment battle files.
200 AnimFragmentBattle(AnimFragmentBattleDiagnostic),
201 /// Diagnostics for configuration files.
202 Config(ConfigDiagnostic),
203 /// Diagnostics for dependency-related issues.
204 Dependency(DependencyDiagnostic),
205 /// Diagnostics for DB tables.
206 DB(TableDiagnostic),
207 /// Diagnostics for Loc (localisation) tables.
208 Loc(TableDiagnostic),
209 /// Diagnostics for pack-level issues.
210 Pack(PackDiagnostic),
211 /// Diagnostics for portrait settings files.
212 PortraitSettings(PortraitSettingsDiagnostic),
213 /// Diagnostics for text/script files.
214 Text(TextDiagnostic),
215}
216
217/// Severity level of a diagnostic result.
218///
219/// Used to categorize diagnostics by importance and filter results
220/// in the user interface.
221#[derive(Debug, Clone, Default, Serialize, Deserialize)]
222pub enum DiagnosticLevel {
223 /// Informational message or suggestion.
224 ///
225 /// These don't indicate errors but may highlight potential improvements
226 /// or provide useful information about the mod.
227 #[default]
228 Info,
229 /// Potential issue that may cause problems.
230 ///
231 /// Warnings indicate things that might be mistakes or could cause
232 /// issues in certain circumstances, but aren't definite errors.
233 Warning,
234 /// Critical issue that will likely cause problems.
235 ///
236 /// Errors indicate definite problems that should be fixed, such as
237 /// invalid references or malformed data that could crash the game.
238 Error,
239}
240
241//-------------------------------------------------------------------------------//
242// Implementations
243//-------------------------------------------------------------------------------//
244
245impl Default for DiagnosticType {
246 fn default() -> Self {
247 Self::Pack(PackDiagnostic::default())
248 }
249}
250
251impl DiagnosticType {
252 pub fn path(&self) -> &str {
253 match self {
254 Self::AnimFragmentBattle(ref diag) => diag.path(),
255 Self::DB(ref diag) |
256 Self::Loc(ref diag) => diag.path(),
257 Self::Pack(_) => "",
258 Self::PortraitSettings(diag) => diag.path(),
259 Self::Text(diag) => diag.path(),
260 Self::Dependency(diag) => diag.path(),
261 Self::Config(_) => "",
262 }
263 }
264
265 pub fn pack(&self) -> &str {
266 match self {
267 Self::AnimFragmentBattle(ref diag) => diag.pack(),
268 Self::DB(ref diag) |
269 Self::Loc(ref diag) => diag.pack(),
270 Self::Pack(diag) => diag.pack(),
271 Self::PortraitSettings(diag) => diag.pack(),
272 Self::Text(diag) => diag.pack(),
273 Self::Dependency(diag) => diag.pack(),
274 Self::Config(_) => "",
275 }
276 }
277}
278
279impl Diagnostics {
280
281 /// This function performs a search over the parts of the provided Packs, storing his results.
282 pub fn check(&mut self, packs: &mut BTreeMap<String, Pack>, dependencies: &mut Dependencies, schema: &Schema, game_info: &GameInfo, game_path: &Path, paths_to_check: &[ContainerPath], check_ak_only_refs: bool) {
283
284 // Clear the diagnostics first if we're doing a full check, or only the config ones and the ones for the path to update if we're doing a partial check.
285 if paths_to_check.is_empty() {
286 self.results.clear();
287 } else {
288 self.results.retain(|diagnostic| !paths_to_check.contains(&ContainerPath::File(diagnostic.path().to_string())));
289 self.results.iter_mut().for_each(|x| {
290 if let DiagnosticType::Config(config) = x {
291 config.results_mut().retain(|x|
292 match x.report_type() {
293 ConfigDiagnosticReportType::DependenciesCacheNotGenerated |
294 ConfigDiagnosticReportType::DependenciesCacheOutdated |
295 ConfigDiagnosticReportType::DependenciesCacheCouldNotBeLoaded(_) |
296 ConfigDiagnosticReportType::IncorrectGamePath => false,
297 }
298 );
299 }
300 });
301 }
302
303 // First, check for config issues, as some of them may stop the checking prematurely.
304 if let Some(diagnostics) = ConfigDiagnostic::check(dependencies, game_info, game_path) {
305 let is_diagnostic_blocking = if let DiagnosticType::Config(ref diagnostic) = diagnostics {
306 diagnostic.results().iter().any(|diagnostic| matches!(diagnostic.report_type(),
307 ConfigDiagnosticReportType::IncorrectGamePath |
308 ConfigDiagnosticReportType::DependenciesCacheNotGenerated |
309 ConfigDiagnosticReportType::DependenciesCacheOutdated |
310 ConfigDiagnosticReportType::DependenciesCacheCouldNotBeLoaded(_)))
311 } else { false };
312
313 // If we have one of the blocking diagnostics, report it and return.
314 self.results.push(diagnostics);
315 if is_diagnostic_blocking {
316 return;
317 }
318 }
319
320 // TODO: Check if we should split this so each pack is only affected by their own ignored files.
321 let files_to_ignore = packs.values().find_map(|pack| pack.settings().diagnostics_files_to_ignore());
322
323 // To make sure we can read any non-db and non-loc file, we need to pre-decode them here.
324 {
325 // Extra data to decode animfragmentbattle files.
326 let mut extra_data = DecodeableExtraData::default();
327 extra_data.set_game_info(Some(game_info));
328 let extra_data = Some(extra_data);
329
330 for pack in packs.values_mut() {
331 pack.files_by_type_mut(&[FileType::AnimFragmentBattle, FileType::Text, FileType::PortraitSettings])
332 .par_iter_mut()
333 .for_each(|file| { let _ = file.decode(&extra_data, true, false); });
334 }
335 }
336
337 // Logic here: we want to process the tables on batches containing all the tables of the same type, so we can check duplicates in different tables.
338 // To do that, we have to sort/split the file list, the process that.
339 let files: Vec<(&str, &RFile)> = if paths_to_check.is_empty() {
340 packs.iter().flat_map(|(key, pack)| pack.files_by_type(&[FileType::AnimFragmentBattle, FileType::DB, FileType::Loc, FileType::Text, FileType::PortraitSettings]).into_iter().map(move |file| (key.as_str(), file))).collect()
341 } else {
342 packs.iter().flat_map(|(key, pack)| pack.files_by_type_and_paths(&[FileType::AnimFragmentBattle, FileType::DB, FileType::Loc, FileType::Text, FileType::PortraitSettings], paths_to_check, false).into_iter().map(move |file| (key.as_str(), file))).collect()
343 };
344
345 let mut files_split: HashMap<&str, Vec<(&str, &RFile)>> = HashMap::new();
346 let mut we_need_loc_data = false;
347 for (pack_key, file) in &files {
348 match file.file_type() {
349 FileType::AnimFragmentBattle => {
350 if let Some(table_set) = files_split.get_mut("anim_fragment_battle") {
351 table_set.push((pack_key, file));
352 } else {
353 files_split.insert("anim_fragment_battle", vec![(pack_key, file)]);
354 }
355 },
356 FileType::DB => {
357 we_need_loc_data = true;
358
359 let path_split = file.path_in_container_split();
360 if path_split.len() > 2 {
361 if let Some(table_set) = files_split.get_mut(path_split[1]) {
362 table_set.push((pack_key, file));
363 } else {
364 files_split.insert(path_split[1], vec![(pack_key, file)]);
365 }
366 }
367 },
368 FileType::Loc => {
369 if let Some(table_set) = files_split.get_mut("locs") {
370 table_set.push((pack_key, file));
371 } else {
372 files_split.insert("locs", vec![(pack_key, file)]);
373 }
374 },
375 FileType::Text => {
376 if let Some(name) = file.file_name() {
377 if name.ends_with(".lua") {
378 if let Some(table_set) = files_split.get_mut("lua") {
379 table_set.push((pack_key, file));
380 } else {
381 files_split.insert("lua", vec![(pack_key, file)]);
382 }
383 }
384 }
385 },
386 FileType::PortraitSettings => {
387 if let Some(table_set) = files_split.get_mut("portrait_settings") {
388 table_set.push((pack_key, file));
389 } else {
390 files_split.insert("portrait_settings", vec![(pack_key, file)]);
391 }
392 },
393 _ => {},
394 }
395 }
396
397 // Getting this here speeds up a lot path-checking later.
398 let mut local_file_path_list = HashMap::new();
399 for pack in packs.values() {
400 local_file_path_list.extend(pack.paths_cache().iter().map(|(k, v)| (k.clone(), v.clone())));
401 }
402 let local_file_path_list = &local_file_path_list;
403
404 let loc_files: Vec<&RFile> = packs.values().flat_map(|pack| pack.files_by_type(&[FileType::Loc])).collect();
405 let loc_decoded = loc_files.iter()
406 .filter_map(|file| if let Ok(RFileDecoded::Loc(loc)) = file.decoded() { Some(loc) } else { None })
407 .map(|file| file.data())
408 .collect::<Vec<_>>();
409
410 // Loc data takes a few ms to get, and it's only needed if we're going to check on tables, for the lookup data. So only get it if we really need it.
411 let loc_data = if we_need_loc_data {
412 Some(loc_decoded.par_iter()
413 .flat_map(|data| data.par_iter()
414 .map(|entry| (entry[0].data_to_string(), entry[1].data_to_string()))
415 .collect::<Vec<(_,_)>>()
416 ).collect::<HashMap<_,_>>())
417 } else {
418 None
419 };
420
421 // That way we can get it fast on the first try, and skip.
422 let table_names = files_split.iter().filter(|(key, _)| **key != "anim_fragment_battle" && **key != "locs" && **key != "lua" && **key != "portrait_settings").map(|(key, _)| key.to_string()).collect::<Vec<_>>();
423
424 // If table names is empty this triggers a full regeneration, which is slow as fuck. So make sure to avoid that if we're only doing a partial check.
425 if !table_names.is_empty() || (table_names.is_empty() && paths_to_check.is_empty()) {
426 dependencies.generate_local_db_references(schema, packs, &table_names);
427 }
428
429 // Caches for Portrait Settings diagnostics. There are some alt lookups for tables with differently named columns between games.
430 let art_set_ids = dependencies.db_values_from_table_name_and_column_name(Some(packs), "campaign_character_arts_tables", "art_set_id", true, true);
431 let mut variant_filenames = dependencies.db_values_from_table_name_and_column_name(Some(packs), "variants_tables", "variant_filename", true, true);
432 if variant_filenames.is_empty() {
433 variant_filenames = dependencies.db_values_from_table_name_and_column_name(Some(packs), "variants_tables", "variant_name", true, true);
434 }
435
436 // Process the files in batches.
437 self.results.append(&mut files_split.par_iter().filter_map(|(_, files)| {
438 let mut diagnostics = Vec::with_capacity(files.len());
439
440 // Ignore empty groups, which should never happen, but just in case.
441 if let Some(file_type) = files.first().map(|(_, x)| x.file_type()) {
442
443 // DB groups are processed as a group, not per file, so we are able to detect duplicated lines between files.
444 // Same for locs.
445 match file_type {
446 FileType::DB => {
447 diagnostics.extend_from_slice(&TableDiagnostic::check_db(
448 files,
449 dependencies,
450 &self.diagnostics_ignored,
451 game_info,
452 local_file_path_list,
453 check_ak_only_refs,
454 &files_to_ignore,
455 packs,
456 schema,
457 &loc_data
458 ));
459 },
460 FileType::Loc => {
461 diagnostics.extend_from_slice(&TableDiagnostic::check_loc(
462 files,
463 &self.diagnostics_ignored,
464 &files_to_ignore,
465 ));
466 }
467 _ => {
468 for (pack_key, file) in files {
469 let (ignored_fields, ignored_diagnostics, ignored_diagnostics_for_fields) = Self::ignore_data_for_file(file, &files_to_ignore)?;
470
471 let diagnostic = match file.file_type() {
472 FileType::AnimFragmentBattle => AnimFragmentBattleDiagnostic::check(
473 pack_key,
474 file,
475 dependencies,
476 &self.diagnostics_ignored,
477 &ignored_fields,
478 &ignored_diagnostics,
479 &ignored_diagnostics_for_fields,
480 local_file_path_list,
481 ),
482
483 FileType::Text => TextDiagnostic::check(pack_key, file, packs, dependencies, &self.diagnostics_ignored, &ignored_fields, &ignored_diagnostics, &ignored_diagnostics_for_fields),
484 FileType::PortraitSettings => PortraitSettingsDiagnostic::check(pack_key, file, &art_set_ids, &variant_filenames, dependencies, &self.diagnostics_ignored, &ignored_fields, &ignored_diagnostics, &ignored_diagnostics_for_fields, local_file_path_list),
485 _ => None,
486 };
487
488 if let Some(diagnostic) = diagnostic {
489 diagnostics.push(diagnostic);
490 }
491 }
492 }
493 }
494 }
495
496 Some(diagnostics)
497 }).flatten().collect());
498
499 // These two are global, so do not execute on file-specific runs.
500 if paths_to_check.is_empty() {
501 self.results_mut().extend(DependencyDiagnostic::check(packs));
502 self.results_mut().extend(PackDiagnostic::check(packs, dependencies, game_info));
503 }
504
505 self.results_mut().sort_by(|a, b| {
506 if !a.path().is_empty() && !b.path().is_empty() {
507 a.path().cmp(b.path())
508 } else if a.path().is_empty() && !b.path().is_empty() {
509 Ordering::Greater
510 } else if !a.path().is_empty() && b.path().is_empty() {
511 Ordering::Less
512 } else {
513 Ordering::Equal
514 }
515 });
516 }
517
518 /// Function to know if an specific field/diagnostic must be ignored.
519 fn ignore_diagnostic(global_ignored_diagnostics: &[String], field_name: Option<&str>, diagnostic: Option<&str>, ignored_fields: &[String], ignored_diagnostics: &HashSet<String>, ignored_diagnostics_for_fields: &HashMap<String, Vec<String>>) -> bool {
520 let mut ignore_diagnostic = false;
521
522 if let Some(diagnostic) = diagnostic {
523 return global_ignored_diagnostics.iter().any(|x| x == diagnostic);
524 }
525
526 // If we have a field, and it's in the ignored list, ignore it.
527 if let Some(field_name) = field_name {
528 ignore_diagnostic = ignored_fields.iter().any(|x| x == field_name);
529 }
530
531 // If we have a diagnostic, and it's in the ignored list, ignore it.
532 else if let Some(diagnostic) = diagnostic {
533 ignore_diagnostic = ignored_diagnostics.get(diagnostic).is_some();
534 }
535
536 // If we have not yet being ignored, check for specific diagnostics for specific fields.
537 if !ignore_diagnostic {
538 if let Some(field_name) = field_name {
539 if let Some(diagnostic) = diagnostic {
540 if let Some(diags) = ignored_diagnostics_for_fields.get(field_name) {
541 ignore_diagnostic = diags.iter().any(|x| x == diagnostic);
542 }
543 }
544 }
545 }
546
547 ignore_diagnostic
548 }
549
550 /// Ignore entire tables if their path starts with the one we have (so we can do mass ignores) and we didn't specified a field to ignore.
551 fn ignore_data_for_file(file: &RFile, files_to_ignore: &Option<Vec<(String, Vec<String>, Vec<String>)>>) -> Option<(Vec<String>, HashSet<String>, HashMap<String, Vec<String>>)> {
552 let mut ignored_fields = vec![];
553 let mut ignored_diagnostics = HashSet::new();
554 let mut ignored_diagnostics_for_fields: HashMap<String, Vec<String>> = HashMap::new();
555 if let Some(ref files_to_ignore) = files_to_ignore {
556 for (path_to_ignore, fields, diags_to_ignore) in files_to_ignore {
557
558 // If the rule doesn't affect this PackedFile, ignore it.
559 if !path_to_ignore.is_empty() && file.path_in_container_raw().starts_with(path_to_ignore) {
560
561 // If we don't have either fields or diags specified, we ignore the entire file.
562 if fields.is_empty() && diags_to_ignore.is_empty() {
563 return None;
564 }
565
566 // If we have both, fields and diags, disable only those diags for those fields.
567 if !fields.is_empty() && !diags_to_ignore.is_empty() {
568 for field in fields {
569 match ignored_diagnostics_for_fields.get_mut(field) {
570 Some(diagnostics) => diagnostics.append(&mut diags_to_ignore.to_vec()),
571 None => { ignored_diagnostics_for_fields.insert(field.to_owned(), diags_to_ignore.to_vec()); },
572 }
573 }
574 }
575
576 // Otherwise, check if we only have fields or diags, and put them separately.
577 else if !fields.is_empty() {
578 ignored_fields.append(&mut fields.to_vec());
579 }
580
581 else if !diags_to_ignore.is_empty() {
582 ignored_diagnostics.extend(diags_to_ignore.to_vec());
583 }
584 }
585 }
586 }
587 Some((ignored_fields, ignored_diagnostics, ignored_diagnostics_for_fields))
588 }
589
590 /// This function converts an entire diagnostics struct into a JSon string.
591 pub fn json(&self) -> Result<String> {
592 serde_json::to_string_pretty(self).map_err(From::from)
593 }
594}
595
596impl Display for DiagnosticType {
597 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
598 Display::fmt(match self {
599 Self::AnimFragmentBattle(_) => "AnimFragmentBattle",
600 Self::Config(_) => "Config",
601 Self::DB(_) => "DB",
602 Self::Loc(_) => "Loc",
603 Self::Pack(_) => "Packfile",
604 Self::PortraitSettings(_) => "PortraitSettings",
605 Self::Text(_) => "Text",
606 Self::Dependency(_) => "DependencyManager",
607 }, f)
608 }
609}