Skip to main content

rpfm_extensions/optimizer/
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 optimization system for reducing size and improving compatibility.
12//!
13//! This module provides tools to clean up and optimize mod packs by removing
14//! unnecessary data, duplicate entries, and files that are identical to vanilla.
15//! Optimization helps reduce mod size, improve load times, and increase
16//! compatibility with other mods.
17//!
18//! # Optimization Types
19//!
20//! ## Pack-Level Optimizations
21//!
22//! - **ITM File Removal**: Remove files that are byte-for-byte identical to
23//!   vanilla game files. These provide no benefit and can cause conflicts.
24//! - **Apply Compression**: Turn on the pack's `compress` flag using the
25//!   most modern compression format the active game supports (overriding
26//!   whatever the pack had configured), so the next save actually compresses
27//!   the files. No-op if the game supports no compression formats. Intended
28//!   as a final step before release on a pack you've been editing uncompressed.
29//! - **Apply Encryption**: Turn on both the index and data encryption flags
30//!   of the pack, so the next save encrypts it. No-op on packs older than PFH4.
31//!
32//! ## Table Optimizations (DB/Loc)
33//!
34//! - **Duplicate Removal**: Remove rows that appear multiple times
35//! - **ITM Row Removal**: Remove rows identical to vanilla data
36//! - **ITNR Row Removal**: Remove rows identical to the "new row" default
37//! - **Empty File Removal**: Delete tables with no remaining rows
38//! - **Datacore Import**: Generate `twad_key_deletes` entries for datacored tables
39//!
40//! ## Portrait Settings Optimizations
41//!
42//! - **Unused Art Set Removal**: Remove art sets not referenced by any unit
43//! - **Unused Variant Removal**: Remove variants not used in any art set
44//! - **Empty Mask Removal**: Remove portrait masks that are empty/transparent
45//! - **Empty File Removal**: Delete portrait settings with no remaining data
46//!
47//! ## Text File Optimizations
48//!
49//! - **Unused XML Removal**: Remove unused XML files in map/prefab folders
50//! - **AGF File Removal**: Remove unnecessary AGF files
51//! - **Model Statistics Removal**: Remove debug/statistics files
52//!
53//! # Configuration
54//!
55//! Use [`OptimizerOptions`] to control which optimizations are applied:
56//!
57//! ```ignore
58//! let mut options = OptimizerOptions::default();
59//! options.set_pack_remove_itm_files(true);
60//! options.set_table_remove_itm_entries(true);
61//! options.set_pts_remove_empty_masks(true);
62//! ```
63//!
64//! # Usage Example
65//!
66//! ```ignore
67//! use rpfm_extensions::optimizer::OptimizableContainer;
68//!
69//! let (deleted, optimized) = pack.optimize(
70//!     None,  // Optimize all paths
71//!     &mut dependencies,
72//!     &schema,
73//!     &game_info,
74//!     &options,
75//! )?;
76//!
77//! println!("Deleted {} files, optimized {} files", deleted.len(), optimized.len());
78//! ```
79//!
80//! # Traits
81//!
82//! - [`Optimizable`]: For individual file types that can be optimized
83//! - [`OptimizableContainer`]: For containers (like [`Pack`]) that can optimize their contents
84
85use getset::{Getters, Setters};
86use serde::{Deserialize, Serialize};
87
88use std::collections::{BTreeMap, HashMap, HashSet};
89
90use rpfm_lib::error::{RLibError, Result};
91use rpfm_lib::files::{Container, ContainerPath, db::DB, EncodeableExtraData, FileType, loc::Loc, pack::{Pack, PFHFlags}, portrait_settings::PortraitSettings, RFile, RFileDecoded, table::DecodedData, text::TextFormat};
92use rpfm_lib::games::{GameInfo, supported_games::KEY_WARHAMMER_3};
93use rpfm_lib::schema::Schema;
94
95use crate::dependencies::{Dependencies, KEY_DELETES_TABLE_NAME};
96
97/// Filename suffix used to identify empty mask images.
98const EMPTY_MASK_PATH_END: &str = "empty_mask.png";
99
100/// Default path for generated key deletes table.
101const DEFAULT_KEY_DELETES_FILE: &str = "db/twad_key_deletes_tables/generated_deletes";
102
103//-------------------------------------------------------------------------------//
104//                             Trait definitions
105//-------------------------------------------------------------------------------//
106
107/// Trait for file types that can be optimized to reduce size.
108///
109/// Implementors define how their specific file format should be cleaned up
110/// and what constitutes an "empty" state that allows safe deletion.
111pub trait Optimizable {
112
113    /// Optimizes this file to reduce size and improve compatibility.
114    ///
115    /// # Arguments
116    ///
117    /// * `dependencies` - Dependencies cache for comparing against vanilla data
118    /// * `container` - Optional reference to the containing pack for cross-file operations
119    /// * `options` - Configuration controlling which optimizations to apply
120    ///
121    /// # Returns
122    ///
123    /// `true` if the file is now empty and can be safely deleted, `false` otherwise.
124    fn optimize(&mut self, dependencies: &mut Dependencies, container: Option<&BTreeMap<String, Pack>>, options: &OptimizerOptions) -> bool;
125}
126
127/// Trait for containers (like [`Pack`]) that can optimize their contents.
128///
129/// This trait provides pack-wide optimization capabilities, processing
130/// multiple files and handling deletions.
131pub trait OptimizableContainer: Container {
132
133    /// Optimizes the container's contents.
134    ///
135    /// # Arguments
136    ///
137    /// * `paths_to_optimize` - Specific paths to optimize, or `None` for all files
138    /// * `dependencies` - Dependencies cache for vanilla data comparison
139    /// * `schema` - Schema for decoding tables
140    /// * `game` - Game information for format-specific handling
141    /// * `options` - Configuration controlling which optimizations to apply
142    ///
143    /// # Returns
144    ///
145    /// A tuple of `(deleted_files, optimized_files)` containing the paths of
146    /// files that were deleted and files that were modified.
147    fn optimize(&mut self,
148        paths_to_optimize: Option<Vec<ContainerPath>>,
149        dependencies: &mut Dependencies,
150        schema: &Schema,
151        game: &GameInfo,
152        options: &OptimizerOptions,
153    ) -> Result<(HashSet<String>, HashSet<String>)>;
154}
155
156/// Configuration options for the pack optimizer.
157///
158/// Controls which optimization operations are enabled. Each option can be
159/// individually toggled to customize the optimization behavior.
160///
161/// # Default Behavior
162///
163/// By default, safe optimizations are enabled (duplicate removal, ITM removal)
164/// while potentially destructive ones are disabled (datacored table optimization,
165/// unused art set removal).
166#[derive(Clone, Debug, Getters, Setters, Deserialize, Serialize)]
167#[getset(get = "pub", set = "pub")]
168pub struct OptimizerOptions {
169
170    /// Allow the optimizer to remove files unchanged from vanilla, reducing the pack size.
171    pack_remove_itm_files: bool,
172
173    /// Allow the optimizer to apply the most modern compression format the active game supports, so the next save compresses the files.
174    ///
175    /// Overrides whatever format the pack had configured. Intended as a final step before release on a pack
176    /// that's been edited uncompressed. No-op if the active game supports no compression formats.
177    pack_apply_compression: bool,
178
179    /// Allow the optimizer to enable both index and data encryption on the pack, so the next save encrypts it.
180    ///
181    /// No-op on packs whose version doesn't support encryption.
182    pack_apply_encryption: bool,
183
184    /// Allow the optimizer to remove files that only differ from another file in their path's casing (what the FileDuplicated diagnostic flags).
185    ///
186    /// Only files with byte-for-byte identical contents are removed. When a group of such files is found the all-lowercase
187    /// one is kept (or the last one if none is lowercase) and the rest are deleted. Files with differing contents are left untouched.
188    pack_remove_duplicated_files: bool,
189
190    /// Allows the optimizer to update the twad_key_deletes table using the data cored tables in your pack to guess the keys.
191    ///
192    /// IT DOESN'T DELETE THE DATACORED TABLES.
193    db_import_datacores_into_twad_key_deletes: bool,
194
195    /// Allow the optimizer to optimize datacored tables. THIS IS NOT RECOMMENDED, as datacored tables usually are they way they are for a reason.
196    ///
197    /// THIS IS NOT RECOMMENDED, as datacored tables usually are the way they are for a reason.
198    db_optimize_datacored_tables: bool,
199
200    /// Allows the optimizer to remove duplicated rows from db and loc files.
201    table_remove_duplicated_entries: bool,
202
203    /// Allows the optimizer to remove ITM (Identical To Master) rows from db and loc files.
204    table_remove_itm_entries: bool,
205
206    /// Allows the optimizer to remove ITNR (Identical To New Row) rows from db and loc files.
207    table_remove_itnr_entries: bool,
208
209    /// Allows the optimizer to remove empty db and loc files.
210    table_remove_empty_file: bool,
211
212    /// Allows the optimizer to remove unused xml files in map folders.
213    text_remove_unused_xml_map_folders: bool,
214
215    /// Allows the optimizer to remove unused xml files in the prefab folder.
216    text_remove_unused_xml_prefab_folder: bool,
217
218    /// Allows the optimizer to remove unused agf files.
219    text_remove_agf_files: bool,
220
221    /// Allows the optimizer to remove unused model_statistics files.
222    text_remove_model_statistics_files: bool,
223
224    /// Allow the optimizer to remove unused art sets in Portrait Settings files.
225    ///
226    /// Only use this after you have confirmed the unused art sets are actually unused and not caused by a typo.
227    pts_remove_unused_art_sets: bool,
228
229    /// Allow the optimizer to remove unused variants from art sets in Portrait Settings files.
230    ///
231    /// Only use this after you have confirmed the unused variants are actually unused and not caused by a typo.
232    pts_remove_unused_variants: bool,
233
234    /// Allow the optimizer to remove empty masks in Portrait Settings file, reducing their side.
235    ///
236    /// Ingame there's no difference between an empty mask and an invalid one, so it's better to remove them to reduce their size.
237    pts_remove_empty_masks: bool,
238
239    /// Allows the optimizer to remove empty Portrait Settings files.
240    pts_remove_empty_file: bool,
241}
242
243//-------------------------------------------------------------------------------//
244//                           Trait implementations
245//-------------------------------------------------------------------------------//
246
247impl Default for OptimizerOptions {
248    fn default() -> Self {
249        Self {
250            pack_remove_itm_files: true,
251            pack_apply_compression: true,
252            pack_apply_encryption: false,
253            pack_remove_duplicated_files: false,
254            db_import_datacores_into_twad_key_deletes: false,
255            db_optimize_datacored_tables: false,
256            table_remove_duplicated_entries: true,
257            table_remove_itm_entries: true,
258            table_remove_itnr_entries: true,
259            table_remove_empty_file: true,
260            text_remove_unused_xml_map_folders: true,
261            text_remove_unused_xml_prefab_folder: true,
262            text_remove_agf_files: true,
263            text_remove_model_statistics_files: true,
264            pts_remove_unused_art_sets: false,
265            pts_remove_unused_variants: false,
266            pts_remove_empty_masks: false,
267            pts_remove_empty_file: true,
268        }
269    }
270}
271
272impl OptimizableContainer for Pack {
273
274    /// This function optimizes the provided [Pack] file in order to make it smaller and more compatible.
275    ///
276    /// Specifically, it performs the following optimizations:
277    ///
278    /// - DB/Loc tables (except if the table has the same name as his vanilla/parent counterpart and `optimize_datacored_tables` is false):
279    ///     - Removal of duplicated entries.
280    ///     - Removal of ITM (Identical To Master) entries.
281    ///     - Removal of ITNR (Identical To New Row) entries.
282    ///     - Removal of empty tables.
283    ///     - Conversion of datacores into twad_key_deletes_entries.
284    /// - Text files:
285    ///     - Removal of XML files in map folders (extra files resulting of Terry export process).
286    ///     - Removal of XML files in prefabs folder (extra files resulting of Terry export process).
287    ///     - Removal of .agf files (byproduct of bob exporting models).
288    ///     - Removal of .model_statistics files (byproduct of bob exporting models).
289    /// - Portrait Settings files:
290    ///     - Removal of variants not present in the variants table (unused data).
291    ///     - Removal of art sets not present in the campaign_character_arts table (unused data).
292    ///     - Removal of empty masks.
293    ///     - Removal of empty Portrait Settings files.
294    /// - Pack:
295    ///     - Remove files identical to parent/vanilla.
296    ///     - Remove case-insensitively duplicated files with identical contents.
297    ///     - Apply the most modern compression format the active game supports, so the next save compresses the files.
298    ///     - Enable index and data encryption, so the next save encrypts the pack.
299    fn optimize(&mut self,
300        paths_to_optimize: Option<Vec<ContainerPath>>,
301        dependencies: &mut Dependencies,
302        schema: &Schema,
303        game: &GameInfo,
304        options: &OptimizerOptions
305    ) -> Result<(HashSet<String>, HashSet<String>)> {
306        let mut files_to_add: HashSet<String> = HashSet::new();
307        let mut files_to_delete: HashSet<String> = HashSet::new();
308
309        // We can only optimize if we have vanilla data available.
310        if !dependencies.is_vanilla_data_loaded(false) {
311            return Err(RLibError::DependenciesCacheNotGeneratedorOutOfDate);
312        }
313
314        // If we're importing the datacored deletions, create the file for them if it doesn't exist.
315        if options.db_import_datacores_into_twad_key_deletes && game.key() == KEY_WARHAMMER_3 {
316            if let Some(def) = schema.definitions_by_table_name(KEY_DELETES_TABLE_NAME) {
317                if !def.is_empty() {
318                    let table = DB::new(&def[0], None, KEY_DELETES_TABLE_NAME);
319                    let _ = self.insert(RFile::new_from_decoded(&RFileDecoded::DB(table), 0, DEFAULT_KEY_DELETES_FILE));
320                    files_to_add.insert(DEFAULT_KEY_DELETES_FILE.to_owned());
321                }
322            }
323        }
324
325        // Cache the pack paths for the text file checks.
326        let pack_paths = self.paths().keys().map(|x| x.to_owned()).collect::<HashSet<String>>();
327        let self_copy = self.clone();
328        let self_copy_map = BTreeMap::from([("main".to_string(), self_copy)]);
329
330        // Pass to remove case-insensitively duplicated files (what the FileDuplicated diagnostic flags).
331        // The paths cache groups files by their lowercased path, so any entry with two or more real paths is a casing collision.
332        if options.pack_remove_duplicated_files {
333            let extra_data = Some(EncodeableExtraData::new_from_game_info(game));
334            let duplicated_groups = self.paths().values()
335                .filter(|paths| paths.len() >= 2)
336                .map(|paths| paths.to_vec())
337                .collect::<Vec<Vec<String>>>();
338
339            for group in &duplicated_groups {
340
341                // Hash every file in the group so we can tell identical contents from a mere name clash.
342                // If any file is missing or fails to hash, leave the whole group untouched.
343                let mut hashes = Vec::with_capacity(group.len());
344                for path in group {
345                    match self.files_mut().get_mut(path).and_then(|rfile| rfile.data_hash(&extra_data).ok()) {
346                        Some(hash) => hashes.push(hash),
347                        None => break,
348                    }
349                }
350
351                if hashes.len() != group.len() || hashes.iter().any(|hash| *hash != hashes[0]) {
352                    continue;
353                }
354
355                // Identical contents: keep the all-lowercase path if there's one, otherwise the last path, and delete the rest.
356                let to_keep = group.iter()
357                    .find(|path| **path == path.to_lowercase())
358                    .unwrap_or_else(|| group.last().unwrap());
359
360                for path in group {
361                    if path != to_keep {
362                        files_to_delete.insert(path.to_owned());
363                    }
364                }
365            }
366        }
367
368        // List of files to optimize.
369        let mut files_to_optimize = match paths_to_optimize {
370            Some(paths) => self.files_by_paths_mut(&paths, false),
371            None => self.files_mut().values_mut().collect::<Vec<_>>(),
372        };
373
374
375        // Import into twad_key_deletes is only supported in wh3, as that table is only in that game... for now.
376        if options.db_import_datacores_into_twad_key_deletes && game.key() == KEY_WARHAMMER_3 {
377            let mut generated_rows = vec![];
378            let datacores = files_to_optimize.iter()
379                .filter(|x| x.file_type() == FileType::DB && dependencies.file_exists(x.path_in_container_raw(), true, true, true))
380                .collect::<Vec<_>>();
381
382            for datacore in datacores {
383                if let Ok(dep_file) = dependencies.file(datacore.path_in_container_raw(), true, true, true) {
384                    if let Ok(RFileDecoded::DB(dep_table)) = dep_file.decoded() {
385                        if let Ok(RFileDecoded::DB(datacore_table)) = datacore.decoded() {
386                            let mut datacore_keys: HashSet<String> = HashSet::new();
387                            datacore_table.generate_twad_key_deletes_keys(&mut datacore_keys);
388
389                            let mut dep_keys = HashSet::new();
390                            dep_table.generate_twad_key_deletes_keys(&mut dep_keys);
391
392                            let table_name_dec_data = DecodedData::StringU8(datacore_table.table_name_without_tables().to_owned());
393                            for key in dep_keys {
394                                if !datacore_keys.contains(&key) {
395                                    generated_rows.push(vec![DecodedData::StringU8(key.to_owned()), table_name_dec_data.clone()]);
396                                }
397                            }
398                        }
399                    }
400                }
401            }
402
403            if let Some(file) = files_to_optimize.iter_mut().find(|x| x.path_in_container_raw() == DEFAULT_KEY_DELETES_FILE) {
404                if let Ok(RFileDecoded::DB(db)) = file.decoded_mut() {
405                    let _ = db.set_data(&generated_rows);
406                }
407            }
408        }
409
410        // Pass to identify and remove itms.
411        if options.pack_remove_itm_files {
412            let extra_data = Some(EncodeableExtraData::new_from_game_info(game));
413            for rfile in &mut files_to_optimize {
414                if let Ok(dep_file) = dependencies.file_mut(rfile.path_in_container_raw(), true, true) {
415                    if let Ok(local_hash) = rfile.data_hash(&extra_data) {
416                        if let Ok(dependency_hash) = dep_file.data_hash(&extra_data) {
417                            if local_hash == dependency_hash {
418                                files_to_delete.insert(rfile.path_in_container_raw().to_string());
419                            }
420                        }
421                    }
422                }
423            }
424        }
425
426        // Then, do a second pass, this time over the decodeable files that we can optimize.
427        files_to_delete.extend(files_to_optimize.iter_mut().filter_map(|rfile| {
428
429            // Only check it if it's not already marked for deletion.
430            let path = rfile.path_in_container_raw().to_owned();
431            if !files_to_delete.contains(&path) {
432
433                match rfile.file_type() {
434
435                    // Unless we specifically wanted to, ignore the same-name-as-vanilla-or-parent files,
436                    // as those are probably intended to overwrite vanilla files, not to be optimized.
437                    FileType::DB if options.db_optimize_datacored_tables || !dependencies.file_exists(&path, true, true, true) => {
438                        if let Ok(RFileDecoded::DB(db)) = rfile.decoded_mut() {
439                            if db.optimize(dependencies, Some(&self_copy_map), options) && options.table_remove_empty_file {
440                                return Some(path);
441                            }
442                        }
443                    }
444
445                    // Same as with tables, don't optimize them if they're overwriting.
446                    FileType::Loc if options.db_optimize_datacored_tables || !dependencies.file_exists(&path, true, true, true) => {
447                        if let Ok(RFileDecoded::Loc(loc)) = rfile.decoded_mut() {
448                            if loc.optimize(dependencies, Some(&self_copy_map), options) && options.table_remove_empty_file {
449                                return Some(path);
450                            }
451                        }
452                    }
453
454                    FileType::Text => {
455
456                        // agf and model_statistics are debug files outputed by bob in older games.
457                        if (options.text_remove_agf_files && path.ends_with(".agf")) ||
458                            (options.text_remove_model_statistics_files && path.ends_with(".model_statistics")) {
459                            if let Ok(Some(RFileDecoded::Text(_))) = rfile.decode(&None, false, true) {
460                                return Some(path);
461                            }
462                        }
463
464                        else if !path.is_empty() && (
465                                (options.text_remove_unused_xml_prefab_folder && path.starts_with("prefabs/")) ||
466                                (options.text_remove_unused_xml_map_folders && (
467                                    path.starts_with("terrain/battles/") ||
468                                    path.starts_with("terrain/tiles/battle/")
469                                ))
470                            )
471                            && !path.ends_with(".wsmodel")
472                            && !path.ends_with(".environment")
473                            && !path.ends_with(".environment_group")
474                            && !path.ends_with(".environment_group.override")
475
476                            // Delete all xml files that match a bin file.
477                            && (
478                                path.ends_with(".xml") && (
479                                    pack_paths.contains(&path[..path.len() - 4].to_lowercase()) ||
480                                    pack_paths.contains(&(path[..path.len() - 4].to_lowercase() + ".bin"))
481                                )
482                            )
483                         {
484                            if let Ok(Some(RFileDecoded::Text(text))) = rfile.decode(&None, false, true) {
485                                if *text.format() == TextFormat::Xml {
486                                    return Some(path);
487
488                                }
489                            }
490                        }
491                    }
492
493                    FileType::PortraitSettings => {
494
495                        // In portrait settings file we look to cleanup variants and art sets that are not referenced by the game tables.
496                        // Meaning they are not used by the game.
497                        if let Ok(RFileDecoded::PortraitSettings(ps)) = rfile.decoded_mut() {
498                            if ps.optimize(dependencies, Some(&self_copy_map), options) && options.pts_remove_empty_file {
499                                return Some(path);
500                            }
501                        }
502                    }
503
504                    // Ignore the rest.
505                    _ => {}
506                }
507            }
508
509            None
510        }).collect::<Vec<String>>());
511
512        // If a table added is also marked for deletion, don't add it.
513        files_to_add.retain(|x| !files_to_delete.contains(x));
514
515        // Delete all the files marked for deletion.
516        files_to_delete.iter().for_each(|x| { self.remove(&ContainerPath::File(x.to_owned())); });
517
518        // Apply the most modern compression format the game supports, overriding whatever the pack had set.
519        // `set_compression_format` handles the "no formats supported" case (falls back to None → compress=false).
520        if options.pack_apply_compression {
521            let cf = game.compression_formats_supported().first().cloned().unwrap_or_default();
522            self.set_compression_format(cf, game);
523        }
524
525        if options.pack_apply_encryption && self.pfh_version().supports_encryption() {
526            self.set_bitmask(self.bitmask() | PFHFlags::HAS_ENCRYPTED_INDEX | PFHFlags::HAS_ENCRYPTED_DATA);
527        }
528
529        // Return the deleted files, so the caller can know what got removed.
530        Ok((files_to_delete, files_to_add))
531    }
532}
533
534impl Optimizable for DB {
535
536    /// This function optimizes the provided [DB] file in order to make it smaller and more compatible.
537    ///
538    /// Specifically, it performs the following optimizations:
539    ///
540    /// - Removal of duplicated entries.
541    /// - Removal of ITM (Identical To Master) entries.
542    /// - Removal of ITNR (Identical To New Row) entries.
543    ///
544    /// It returns if the DB is empty, meaning it can be safetly deleted.
545    fn optimize(&mut self, dependencies: &mut Dependencies, container: Option<&BTreeMap<String, Pack>>, options: &OptimizerOptions) -> bool {
546        let container = match container {
547            Some(container) => container,
548            None => return false,
549        };
550
551        // Get a manipulable copy of all the entries, so we can optimize it.
552        let mut entries = self.data().to_vec();
553
554        match dependencies.db_data_datacored(self.table_name(), container, true, true) {
555            Ok(mut vanilla_tables) => {
556
557                // First, merge all vanilla and parent db fragments into a single HashSet.
558                let vanilla_table = vanilla_tables.iter_mut()
559                    .filter_map(|file| {
560                        if let Ok(RFileDecoded::DB(table)) = file.decoded() {
561                            Some(table.data().to_vec())
562                        } else { None }
563                    })
564                    .flatten()
565                    .map(|x| {
566
567                        // We map all floats here to string representations of floats, so we can actually compare them reliably.
568                        let json = x.iter().map(|data|
569                            if let DecodedData::F32(value) = data {
570                                DecodedData::StringU8(format!("{value:.4}"))
571                            } else if let DecodedData::F64(value) = data {
572                                DecodedData::StringU8(format!("{value:.4}"))
573                            } else {
574                                data.to_owned()
575                            }
576                        ).collect::<Vec<DecodedData>>();
577                        serde_json::to_string(&json).unwrap()
578                    })
579                    .collect::<HashSet<String>>();
580
581                // Remove ITM and ITNR entries.
582                let new_row = self.new_row().iter().map(|data|
583                    if let DecodedData::F32(value) = data {
584                        DecodedData::StringU8(format!("{value:.4}"))
585                    } else if let DecodedData::F64(value) = data {
586                        DecodedData::StringU8(format!("{value:.4}"))
587                    } else {
588                        data.to_owned()
589                    }
590                ).collect::<Vec<DecodedData>>();
591
592                entries.retain(|entry| {
593                    let entry_json = entry.iter().map(|data|
594                        if let DecodedData::F32(value) = data {
595                            DecodedData::StringU8(format!("{value:.4}"))
596                        } else if let DecodedData::F64(value) = data {
597                            DecodedData::StringU8(format!("{value:.4}"))
598                        } else {
599                            data.to_owned()
600                        }
601                    ).collect::<Vec<DecodedData>>();
602
603                    (!options.table_remove_itm_entries || !vanilla_table.contains(&serde_json::to_string(&entry_json).unwrap())) &&
604                    (!options.table_remove_itnr_entries || entry != &new_row)
605                });
606
607                // Dedupper. This is slower than a normal dedup, but it doesn't reorder rows.
608                if options.table_remove_duplicated_entries {
609                    let mut dummy_set = HashSet::new();
610                    entries.retain(|x| dummy_set.insert(x.clone()));
611                }
612
613                // Then we overwrite the entries and return if the table is empty or now, so we can optimize it further at the Container level.
614                //
615                // NOTE: This may fail, but in that case the table will not be left empty, which we check in the next line.
616                let _ = self.set_data(&entries);
617                self.data().is_empty()
618            }
619            Err(_) => false,
620        }
621    }
622}
623
624impl Optimizable for Loc {
625
626    /// This function optimizes the provided [Loc] file in order to make it smaller and more compatible.
627    ///
628    /// Specifically, it performs the following optimizations:
629    ///
630    /// - Removal of duplicated entries.
631    /// - Removal of ITM (Identical To Master) entries.
632    /// - Removal of ITNR (Identical To New Row) entries.
633    ///
634    /// It returns if the Loc is empty, meaning it can be safetly deleted.
635    fn optimize(&mut self, dependencies: &mut Dependencies, _container: Option<&BTreeMap<String, Pack>>, options: &OptimizerOptions) -> bool {
636
637        // Get a manipulable copy of all the entries, so we can optimize it.
638        let mut entries = self.data().to_vec();
639        match dependencies.loc_data(true, true) {
640            Ok(mut vanilla_tables) => {
641
642                // First, merge all vanilla and parent locs into a single HashMap<key, value>. We don't care about the third column.
643                let vanilla_table = vanilla_tables.iter_mut()
644                    .filter_map(|file| {
645                        if let Ok(RFileDecoded::Loc(table)) = file.decoded() {
646                            Some(table.data().to_vec())
647                        } else { None }
648                    })
649                    .flat_map(|data| data.iter()
650                        .map(|data| (data[0].data_to_string().to_string(), data[1].data_to_string().to_string()))
651                        .collect::<Vec<(String, String)>>())
652                    .collect::<HashMap<String, String>>();
653
654                // Remove ITM and ITNR entries.
655                let new_row = self.new_row();
656                entries.retain(|entry| {
657                    if options.table_remove_itnr_entries && entry == &new_row {
658                        return false;
659                    }
660
661                    if options.table_remove_itm_entries {
662                        match vanilla_table.get(&*entry[0].data_to_string()) {
663                            Some(vanilla_value) => return &*entry[1].data_to_string() != vanilla_value,
664                            None => return true
665                        }
666                    }
667
668                    true
669                });
670
671                // Dedupper. This is slower than a normal dedup, but it doesn't reorder rows.
672                if options.table_remove_duplicated_entries {
673                    let mut dummy_set = HashSet::new();
674                    entries.retain(|x| dummy_set.insert(x.clone()));
675                }
676
677                // Then we overwrite the entries and return if the table is empty or now, so we can optimize it further at the Container level.
678                //
679                // NOTE: This may fail, but in that case the table will not be left empty, which we check in the next line.
680                let _ = self.set_data(&entries);
681                self.data().is_empty()
682            }
683            Err(_) => false,
684        }
685    }
686}
687
688impl Optimizable for PortraitSettings {
689
690    /// This function optimizes the provided [PortraitSettings] file in order to make it smaller.
691    ///
692    /// Specifically, it performs the following optimizations:
693    ///
694    /// - Removal of variants not present in the variants table (unused data).
695    /// - Removal of art sets not present in the campaign_character_arts table (unused data).
696    ///
697    /// It returns if the PortraitSettings is empty, meaning it can be safetly deleted.
698    fn optimize(&mut self, dependencies: &mut Dependencies, container: Option<&BTreeMap<String, Pack>>, options: &OptimizerOptions) -> bool {
699
700        // Get a manipulable copy of all the entries, so we can optimize it.
701        let mut entries = self.entries().to_vec();
702
703        // Get the list of art set ids and variant filenames to check against.
704        let art_set_ids = dependencies.db_values_from_table_name_and_column_name(container, "campaign_character_arts_tables", "art_set_id", true, true);
705        let mut variant_filenames = dependencies.db_values_from_table_name_and_column_name(container, "variants_tables", "variant_filename", true, true);
706        if variant_filenames.is_empty() {
707            variant_filenames = dependencies.db_values_from_table_name_and_column_name(container, "variants_tables", "variant_name", true, true);
708        }
709
710        // Do not do anything if we don't have ids and variants.
711        if art_set_ids.is_empty() || variant_filenames.is_empty() {
712            return false;
713        }
714
715        entries.retain_mut(|entry| {
716            entry.variants_mut().retain_mut(|variant| {
717                if options.pts_remove_empty_masks {
718                    if variant.file_mask_1().ends_with(EMPTY_MASK_PATH_END) {
719                        variant.file_mask_1_mut().clear();
720                    }
721                    if variant.file_mask_2().ends_with(EMPTY_MASK_PATH_END) {
722                        variant.file_mask_2_mut().clear();
723                    }
724                    if variant.file_mask_3().ends_with(EMPTY_MASK_PATH_END) {
725                        variant.file_mask_3_mut().clear();
726                    }
727                }
728
729                if options.pts_remove_unused_variants {
730                    variant_filenames.contains(variant.filename())
731                } else {
732                    true
733                }
734            });
735
736            if options.pts_remove_unused_art_sets {
737                art_set_ids.contains(entry.id())
738            } else {
739                true
740            }
741        });
742
743        self.set_entries(entries);
744        self.entries().is_empty()
745    }
746}