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::{DiagnosticIgnoreEntry, 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/// Per-file ignore state derived from the pack's `diagnostics_files_to_ignore` setting.
242///
243/// Tuple shape: `(ignored_fields, ignored_diagnostics, ignored_diagnostics_for_fields)`.
244/// `None` means the whole file is skipped.
245pub type FileIgnoreState = (Vec<String>, HashSet<String>, HashMap<String, Vec<String>>);
246
247//-------------------------------------------------------------------------------//
248// Implementations
249//-------------------------------------------------------------------------------//
250
251impl Default for DiagnosticType {
252 fn default() -> Self {
253 Self::Pack(PackDiagnostic::default())
254 }
255}
256
257impl DiagnosticType {
258 pub fn path(&self) -> &str {
259 match self {
260 Self::AnimFragmentBattle(ref diag) => diag.path(),
261 Self::DB(ref diag) |
262 Self::Loc(ref diag) => diag.path(),
263 Self::Pack(_) => "",
264 Self::PortraitSettings(diag) => diag.path(),
265 Self::Text(diag) => diag.path(),
266 Self::Dependency(diag) => diag.path(),
267 Self::Config(_) => "",
268 }
269 }
270
271 pub fn pack(&self) -> &str {
272 match self {
273 Self::AnimFragmentBattle(ref diag) => diag.pack(),
274 Self::DB(ref diag) |
275 Self::Loc(ref diag) => diag.pack(),
276 Self::Pack(diag) => diag.pack(),
277 Self::PortraitSettings(diag) => diag.pack(),
278 Self::Text(diag) => diag.pack(),
279 Self::Dependency(diag) => diag.pack(),
280 Self::Config(_) => "",
281 }
282 }
283}
284
285impl Diagnostics {
286
287 /// This function performs a search over the parts of the provided Packs, storing his results.
288 #[allow(clippy::too_many_arguments)]
289 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) {
290
291 // 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.
292 if paths_to_check.is_empty() {
293 self.results.clear();
294 } else {
295 self.results.retain(|diagnostic| !paths_to_check.contains(&ContainerPath::File(diagnostic.path().to_string())));
296 self.results.iter_mut().for_each(|x| {
297 if let DiagnosticType::Config(config) = x {
298 config.results_mut().retain(|x|
299 match x.report_type() {
300 ConfigDiagnosticReportType::DependenciesCacheNotGenerated |
301 ConfigDiagnosticReportType::DependenciesCacheOutdated |
302 ConfigDiagnosticReportType::DependenciesCacheCouldNotBeLoaded(_) |
303 ConfigDiagnosticReportType::IncorrectGamePath => false,
304 }
305 );
306 }
307 });
308 }
309
310 // First, check for config issues, as some of them may stop the checking prematurely.
311 if let Some(diagnostics) = ConfigDiagnostic::check(dependencies, game_info, game_path) {
312 let is_diagnostic_blocking = if let DiagnosticType::Config(ref diagnostic) = diagnostics {
313 diagnostic.results().iter().any(|diagnostic| matches!(diagnostic.report_type(),
314 ConfigDiagnosticReportType::IncorrectGamePath |
315 ConfigDiagnosticReportType::DependenciesCacheNotGenerated |
316 ConfigDiagnosticReportType::DependenciesCacheOutdated |
317 ConfigDiagnosticReportType::DependenciesCacheCouldNotBeLoaded(_)))
318 } else { false };
319
320 // If we have one of the blocking diagnostics, report it and return.
321 self.results.push(diagnostics);
322 if is_diagnostic_blocking {
323 return;
324 }
325 }
326
327 // TODO: Check if we should split this so each pack is only affected by their own ignored files.
328 let files_to_ignore = packs.values().find_map(|pack| pack.settings().diagnostics_files_to_ignore());
329
330 // To make sure we can read any non-db and non-loc file, we need to pre-decode them here.
331 {
332 // Extra data to decode animfragmentbattle files.
333 let mut extra_data = DecodeableExtraData::default();
334 extra_data.set_game_info(Some(game_info));
335 let extra_data = Some(extra_data);
336
337 for pack in packs.values_mut() {
338 pack.files_by_type_mut(&[FileType::AnimFragmentBattle, FileType::Text, FileType::PortraitSettings])
339 .par_iter_mut()
340 .for_each(|file| { let _ = file.decode(&extra_data, true, false); });
341 }
342 }
343
344 // 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.
345 // To do that, we have to sort/split the file list, the process that.
346 let files: Vec<(&str, &RFile)> = if paths_to_check.is_empty() {
347 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()
348 } else {
349 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()
350 };
351
352 let mut files_split: HashMap<&str, Vec<(&str, &RFile)>> = HashMap::new();
353 let mut we_need_loc_data = false;
354 for (pack_key, file) in &files {
355 match file.file_type() {
356 FileType::AnimFragmentBattle => {
357 if let Some(table_set) = files_split.get_mut("anim_fragment_battle") {
358 table_set.push((pack_key, file));
359 } else {
360 files_split.insert("anim_fragment_battle", vec![(pack_key, file)]);
361 }
362 },
363 FileType::DB => {
364 we_need_loc_data = true;
365
366 let path_split = file.path_in_container_split();
367 if path_split.len() > 2 {
368 if let Some(table_set) = files_split.get_mut(path_split[1]) {
369 table_set.push((pack_key, file));
370 } else {
371 files_split.insert(path_split[1], vec![(pack_key, file)]);
372 }
373 }
374 },
375 FileType::Loc => {
376 if let Some(table_set) = files_split.get_mut("locs") {
377 table_set.push((pack_key, file));
378 } else {
379 files_split.insert("locs", vec![(pack_key, file)]);
380 }
381 },
382 FileType::Text => {
383 if let Some(name) = file.file_name() {
384 if name.ends_with(".lua") {
385 if let Some(table_set) = files_split.get_mut("lua") {
386 table_set.push((pack_key, file));
387 } else {
388 files_split.insert("lua", vec![(pack_key, file)]);
389 }
390 }
391 }
392 },
393 FileType::PortraitSettings => {
394 if let Some(table_set) = files_split.get_mut("portrait_settings") {
395 table_set.push((pack_key, file));
396 } else {
397 files_split.insert("portrait_settings", vec![(pack_key, file)]);
398 }
399 },
400 _ => {},
401 }
402 }
403
404 // Getting this here speeds up a lot path-checking later.
405 let mut local_file_path_list = HashMap::new();
406 for pack in packs.values() {
407 local_file_path_list.extend(pack.paths_cache().iter().map(|(k, v)| (k.clone(), v.clone())));
408 }
409 let local_file_path_list = &local_file_path_list;
410
411 let loc_files: Vec<&RFile> = packs.values().flat_map(|pack| pack.files_by_type(&[FileType::Loc])).collect();
412 let loc_decoded = loc_files.iter()
413 .filter_map(|file| if let Ok(RFileDecoded::Loc(loc)) = file.decoded() { Some(loc) } else { None })
414 .map(|file| file.data())
415 .collect::<Vec<_>>();
416
417 // 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.
418 let loc_data = if we_need_loc_data {
419 Some(loc_decoded.par_iter()
420 .flat_map(|data| data.par_iter()
421 .map(|entry| (entry[0].data_to_string(), entry[1].data_to_string()))
422 .collect::<Vec<(_,_)>>()
423 ).collect::<HashMap<_,_>>())
424 } else {
425 None
426 };
427
428 // That way we can get it fast on the first try, and skip.
429 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<_>>();
430
431 // 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.
432 if !table_names.is_empty() || (table_names.is_empty() && paths_to_check.is_empty()) {
433 dependencies.generate_local_db_references(schema, packs, &table_names);
434 }
435
436 // Caches for Portrait Settings diagnostics. There are some alt lookups for tables with differently named columns between games.
437 let art_set_ids = dependencies.db_values_from_table_name_and_column_name(Some(packs), "campaign_character_arts_tables", "art_set_id", true, true);
438 let mut variant_filenames = dependencies.db_values_from_table_name_and_column_name(Some(packs), "variants_tables", "variant_filename", true, true);
439 if variant_filenames.is_empty() {
440 variant_filenames = dependencies.db_values_from_table_name_and_column_name(Some(packs), "variants_tables", "variant_name", true, true);
441 }
442
443 // Process the files in batches.
444 self.results.append(&mut files_split.par_iter().filter_map(|(_, files)| {
445 let mut diagnostics = Vec::with_capacity(files.len());
446
447 // Ignore empty groups, which should never happen, but just in case.
448 if let Some(file_type) = files.first().map(|(_, x)| x.file_type()) {
449
450 // DB groups are processed as a group, not per file, so we are able to detect duplicated lines between files.
451 // Same for locs.
452 match file_type {
453 FileType::DB => {
454 diagnostics.extend_from_slice(&TableDiagnostic::check_db(
455 files,
456 dependencies,
457 &self.diagnostics_ignored,
458 game_info,
459 local_file_path_list,
460 check_ak_only_refs,
461 &files_to_ignore,
462 packs,
463 schema,
464 &loc_data
465 ));
466 },
467 FileType::Loc => {
468 diagnostics.extend_from_slice(&TableDiagnostic::check_loc(
469 files,
470 &self.diagnostics_ignored,
471 &files_to_ignore,
472 ));
473 }
474 _ => {
475 for (pack_key, file) in files {
476 let (ignored_fields, ignored_diagnostics, ignored_diagnostics_for_fields) = Self::ignore_data_for_file(file, &files_to_ignore)?;
477
478 let diagnostic = match file.file_type() {
479 FileType::AnimFragmentBattle => AnimFragmentBattleDiagnostic::check(
480 pack_key,
481 file,
482 dependencies,
483 &self.diagnostics_ignored,
484 &ignored_fields,
485 &ignored_diagnostics,
486 &ignored_diagnostics_for_fields,
487 local_file_path_list,
488 ),
489
490 FileType::Text => TextDiagnostic::check(pack_key, file, packs, dependencies, &self.diagnostics_ignored, &ignored_fields, &ignored_diagnostics, &ignored_diagnostics_for_fields),
491 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),
492 _ => None,
493 };
494
495 if let Some(diagnostic) = diagnostic {
496 diagnostics.push(diagnostic);
497 }
498 }
499 }
500 }
501 }
502
503 Some(diagnostics)
504 }).flatten().collect());
505
506 // These two are global, so do not execute on file-specific runs.
507 if paths_to_check.is_empty() {
508 self.results_mut().extend(DependencyDiagnostic::check(packs));
509 self.results_mut().extend(PackDiagnostic::check(packs, dependencies, game_info, game_path));
510 }
511
512 self.results_mut().sort_by(|a, b| {
513 if !a.path().is_empty() && !b.path().is_empty() {
514 a.path().cmp(b.path())
515 } else if a.path().is_empty() && !b.path().is_empty() {
516 Ordering::Greater
517 } else if !a.path().is_empty() && b.path().is_empty() {
518 Ordering::Less
519 } else {
520 Ordering::Equal
521 }
522 });
523 }
524
525 /// Function to know if an specific field/diagnostic must be ignored.
526 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 {
527 let mut ignore_diagnostic = false;
528
529 if let Some(diagnostic) = diagnostic {
530 return global_ignored_diagnostics.iter().any(|x| x == diagnostic);
531 }
532
533 // If we have a field, and it's in the ignored list, ignore it.
534 if let Some(field_name) = field_name {
535 ignore_diagnostic = ignored_fields.iter().any(|x| x == field_name);
536 }
537
538 // If we have a diagnostic, and it's in the ignored list, ignore it.
539 else if let Some(diagnostic) = diagnostic {
540 ignore_diagnostic = ignored_diagnostics.get(diagnostic).is_some();
541 }
542
543 // If we have not yet being ignored, check for specific diagnostics for specific fields.
544 if !ignore_diagnostic {
545 if let Some(field_name) = field_name {
546 if let Some(diagnostic) = diagnostic {
547 if let Some(diags) = ignored_diagnostics_for_fields.get(field_name) {
548 ignore_diagnostic = diags.iter().any(|x| x == diagnostic);
549 }
550 }
551 }
552 }
553
554 ignore_diagnostic
555 }
556
557 /// 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.
558 fn ignore_data_for_file(file: &RFile, files_to_ignore: &Option<Vec<DiagnosticIgnoreEntry>>) -> Option<FileIgnoreState> {
559 let mut ignored_fields = vec![];
560 let mut ignored_diagnostics = HashSet::new();
561 let mut ignored_diagnostics_for_fields: HashMap<String, Vec<String>> = HashMap::new();
562 if let Some(ref files_to_ignore) = files_to_ignore {
563 for (path_to_ignore, fields, diags_to_ignore) in files_to_ignore {
564
565 // If the rule doesn't affect this PackedFile, ignore it.
566 if !path_to_ignore.is_empty() && file.path_in_container_raw().starts_with(path_to_ignore) {
567
568 // If we don't have either fields or diags specified, we ignore the entire file.
569 if fields.is_empty() && diags_to_ignore.is_empty() {
570 return None;
571 }
572
573 // If we have both, fields and diags, disable only those diags for those fields.
574 if !fields.is_empty() && !diags_to_ignore.is_empty() {
575 for field in fields {
576 match ignored_diagnostics_for_fields.get_mut(field) {
577 Some(diagnostics) => diagnostics.append(&mut diags_to_ignore.to_vec()),
578 None => { ignored_diagnostics_for_fields.insert(field.to_owned(), diags_to_ignore.to_vec()); },
579 }
580 }
581 }
582
583 // Otherwise, check if we only have fields or diags, and put them separately.
584 else if !fields.is_empty() {
585 ignored_fields.append(&mut fields.to_vec());
586 }
587
588 else if !diags_to_ignore.is_empty() {
589 ignored_diagnostics.extend(diags_to_ignore.to_vec());
590 }
591 }
592 }
593 }
594 Some((ignored_fields, ignored_diagnostics, ignored_diagnostics_for_fields))
595 }
596
597 /// This function converts an entire diagnostics struct into a JSon string.
598 pub fn json(&self) -> Result<String> {
599 serde_json::to_string_pretty(self).map_err(From::from)
600 }
601}
602
603impl Display for DiagnosticType {
604 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
605 Display::fmt(match self {
606 Self::AnimFragmentBattle(_) => "AnimFragmentBattle",
607 Self::Config(_) => "Config",
608 Self::DB(_) => "DB",
609 Self::Loc(_) => "Loc",
610 Self::Pack(_) => "Packfile",
611 Self::PortraitSettings(_) => "PortraitSettings",
612 Self::Text(_) => "Text",
613 Self::Dependency(_) => "DependencyManager",
614 }, f)
615 }
616}