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