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 /// Allow the optimizer to remove files that only differ from another file in their path's casing (what the FileDuplicated diagnostic flags).
178 ///
179 /// Only files with byte-for-byte identical contents are removed. When a group of such files is found the all-lowercase
180 /// one is kept (or the last one if none is lowercase) and the rest are deleted. Files with differing contents are left untouched.
181 pack_remove_duplicated_files: bool,
182
183 /// Allows the optimizer to update the twad_key_deletes table using the data cored tables in your pack to guess the keys.
184 ///
185 /// IT DOESN'T DELETE THE DATACORED TABLES.
186 db_import_datacores_into_twad_key_deletes: bool,
187
188 /// Allow the optimizer to optimize datacored tables. THIS IS NOT RECOMMENDED, as datacored tables usually are they way they are for a reason.
189 ///
190 /// THIS IS NOT RECOMMENDED, as datacored tables usually are the way they are for a reason.
191 db_optimize_datacored_tables: bool,
192
193 /// Allows the optimizer to remove duplicated rows from db and loc files.
194 table_remove_duplicated_entries: bool,
195
196 /// Allows the optimizer to remove ITM (Identical To Master) rows from db and loc files.
197 table_remove_itm_entries: bool,
198
199 /// Allows the optimizer to remove ITNR (Identical To New Row) rows from db and loc files.
200 table_remove_itnr_entries: bool,
201
202 /// Allows the optimizer to remove empty db and loc files.
203 table_remove_empty_file: bool,
204
205 /// Allows the optimizer to remove unused xml files in map folders.
206 text_remove_unused_xml_map_folders: bool,
207
208 /// Allows the optimizer to remove unused xml files in the prefab folder.
209 text_remove_unused_xml_prefab_folder: bool,
210
211 /// Allows the optimizer to remove unused agf files.
212 text_remove_agf_files: bool,
213
214 /// Allows the optimizer to remove unused model_statistics files.
215 text_remove_model_statistics_files: bool,
216
217 /// Allow the optimizer to remove unused art sets in Portrait Settings files.
218 ///
219 /// Only use this after you have confirmed the unused art sets are actually unused and not caused by a typo.
220 pts_remove_unused_art_sets: bool,
221
222 /// Allow the optimizer to remove unused variants from art sets in Portrait Settings files.
223 ///
224 /// Only use this after you have confirmed the unused variants are actually unused and not caused by a typo.
225 pts_remove_unused_variants: bool,
226
227 /// Allow the optimizer to remove empty masks in Portrait Settings file, reducing their side.
228 ///
229 /// Ingame there's no difference between an empty mask and an invalid one, so it's better to remove them to reduce their size.
230 pts_remove_empty_masks: bool,
231
232 /// Allows the optimizer to remove empty Portrait Settings files.
233 pts_remove_empty_file: bool,
234}
235
236//-------------------------------------------------------------------------------//
237// Trait implementations
238//-------------------------------------------------------------------------------//
239
240impl Default for OptimizerOptions {
241 fn default() -> Self {
242 Self {
243 pack_remove_itm_files: true,
244 pack_apply_compression: true,
245 pack_remove_duplicated_files: false,
246 db_import_datacores_into_twad_key_deletes: false,
247 db_optimize_datacored_tables: false,
248 table_remove_duplicated_entries: true,
249 table_remove_itm_entries: true,
250 table_remove_itnr_entries: true,
251 table_remove_empty_file: true,
252 text_remove_unused_xml_map_folders: true,
253 text_remove_unused_xml_prefab_folder: true,
254 text_remove_agf_files: true,
255 text_remove_model_statistics_files: true,
256 pts_remove_unused_art_sets: false,
257 pts_remove_unused_variants: false,
258 pts_remove_empty_masks: false,
259 pts_remove_empty_file: true,
260 }
261 }
262}
263
264impl OptimizableContainer for Pack {
265
266 /// This function optimizes the provided [Pack] file in order to make it smaller and more compatible.
267 ///
268 /// Specifically, it performs the following optimizations:
269 ///
270 /// - DB/Loc tables (except if the table has the same name as his vanilla/parent counterpart and `optimize_datacored_tables` is false):
271 /// - Removal of duplicated entries.
272 /// - Removal of ITM (Identical To Master) entries.
273 /// - Removal of ITNR (Identical To New Row) entries.
274 /// - Removal of empty tables.
275 /// - Conversion of datacores into twad_key_deletes_entries.
276 /// - Text files:
277 /// - Removal of XML files in map folders (extra files resulting of Terry export process).
278 /// - Removal of XML files in prefabs folder (extra files resulting of Terry export process).
279 /// - Removal of .agf files (byproduct of bob exporting models).
280 /// - Removal of .model_statistics files (byproduct of bob exporting models).
281 /// - Portrait Settings files:
282 /// - Removal of variants not present in the variants table (unused data).
283 /// - Removal of art sets not present in the campaign_character_arts table (unused data).
284 /// - Removal of empty masks.
285 /// - Removal of empty Portrait Settings files.
286 /// - Pack:
287 /// - Remove files identical to parent/vanilla.
288 /// - Remove case-insensitively duplicated files with identical contents.
289 /// - Apply the most modern compression format the active game supports, so the next save compresses the files.
290 fn optimize(&mut self,
291 paths_to_optimize: Option<Vec<ContainerPath>>,
292 dependencies: &mut Dependencies,
293 schema: &Schema,
294 game: &GameInfo,
295 options: &OptimizerOptions
296 ) -> Result<(HashSet<String>, HashSet<String>)> {
297 let mut files_to_add: HashSet<String> = HashSet::new();
298 let mut files_to_delete: HashSet<String> = HashSet::new();
299
300 // We can only optimize if we have vanilla data available.
301 if !dependencies.is_vanilla_data_loaded(false) {
302 return Err(RLibError::DependenciesCacheNotGeneratedorOutOfDate);
303 }
304
305 // If we're importing the datacored deletions, create the file for them if it doesn't exist.
306 if options.db_import_datacores_into_twad_key_deletes && game.key() == KEY_WARHAMMER_3 {
307 if let Some(def) = schema.definitions_by_table_name(KEY_DELETES_TABLE_NAME) {
308 if !def.is_empty() {
309 let table = DB::new(&def[0], None, KEY_DELETES_TABLE_NAME);
310 let _ = self.insert(RFile::new_from_decoded(&RFileDecoded::DB(table), 0, DEFAULT_KEY_DELETES_FILE));
311 files_to_add.insert(DEFAULT_KEY_DELETES_FILE.to_owned());
312 }
313 }
314 }
315
316 // Cache the pack paths for the text file checks.
317 let pack_paths = self.paths().keys().map(|x| x.to_owned()).collect::<HashSet<String>>();
318 let self_copy = self.clone();
319 let self_copy_map = BTreeMap::from([("main".to_string(), self_copy)]);
320
321 // Pass to remove case-insensitively duplicated files (what the FileDuplicated diagnostic flags).
322 // The paths cache groups files by their lowercased path, so any entry with two or more real paths is a casing collision.
323 if options.pack_remove_duplicated_files {
324 let extra_data = Some(EncodeableExtraData::new_from_game_info(game));
325 let duplicated_groups = self.paths().values()
326 .filter(|paths| paths.len() >= 2)
327 .map(|paths| paths.to_vec())
328 .collect::<Vec<Vec<String>>>();
329
330 for group in &duplicated_groups {
331
332 // Hash every file in the group so we can tell identical contents from a mere name clash.
333 // If any file is missing or fails to hash, leave the whole group untouched.
334 let mut hashes = Vec::with_capacity(group.len());
335 for path in group {
336 match self.files_mut().get_mut(path).and_then(|rfile| rfile.data_hash(&extra_data).ok()) {
337 Some(hash) => hashes.push(hash),
338 None => break,
339 }
340 }
341
342 if hashes.len() != group.len() || hashes.iter().any(|hash| *hash != hashes[0]) {
343 continue;
344 }
345
346 // Identical contents: keep the all-lowercase path if there's one, otherwise the last path, and delete the rest.
347 let to_keep = group.iter()
348 .find(|path| **path == path.to_lowercase())
349 .unwrap_or_else(|| group.last().unwrap());
350
351 for path in group {
352 if path != to_keep {
353 files_to_delete.insert(path.to_owned());
354 }
355 }
356 }
357 }
358
359 // List of files to optimize.
360 let mut files_to_optimize = match paths_to_optimize {
361 Some(paths) => self.files_by_paths_mut(&paths, false),
362 None => self.files_mut().values_mut().collect::<Vec<_>>(),
363 };
364
365
366 // Import into twad_key_deletes is only supported in wh3, as that table is only in that game... for now.
367 if options.db_import_datacores_into_twad_key_deletes && game.key() == KEY_WARHAMMER_3 {
368 let mut generated_rows = vec![];
369 let datacores = files_to_optimize.iter()
370 .filter(|x| x.file_type() == FileType::DB && dependencies.file_exists(x.path_in_container_raw(), true, true, true))
371 .collect::<Vec<_>>();
372
373 for datacore in datacores {
374 if let Ok(dep_file) = dependencies.file(datacore.path_in_container_raw(), true, true, true) {
375 if let Ok(RFileDecoded::DB(dep_table)) = dep_file.decoded() {
376 if let Ok(RFileDecoded::DB(datacore_table)) = datacore.decoded() {
377 let mut datacore_keys: HashSet<String> = HashSet::new();
378 datacore_table.generate_twad_key_deletes_keys(&mut datacore_keys);
379
380 let mut dep_keys = HashSet::new();
381 dep_table.generate_twad_key_deletes_keys(&mut dep_keys);
382
383 let table_name_dec_data = DecodedData::StringU8(datacore_table.table_name_without_tables().to_owned());
384 for key in dep_keys {
385 if !datacore_keys.contains(&key) {
386 generated_rows.push(vec![DecodedData::StringU8(key.to_owned()), table_name_dec_data.clone()]);
387 }
388 }
389 }
390 }
391 }
392 }
393
394 if let Some(file) = files_to_optimize.iter_mut().find(|x| x.path_in_container_raw() == DEFAULT_KEY_DELETES_FILE) {
395 if let Ok(RFileDecoded::DB(db)) = file.decoded_mut() {
396 let _ = db.set_data(&generated_rows);
397 }
398 }
399 }
400
401 // Pass to identify and remove itms.
402 if options.pack_remove_itm_files {
403 let extra_data = Some(EncodeableExtraData::new_from_game_info(game));
404 for rfile in &mut files_to_optimize {
405 if let Ok(dep_file) = dependencies.file_mut(rfile.path_in_container_raw(), true, true) {
406 if let Ok(local_hash) = rfile.data_hash(&extra_data) {
407 if let Ok(dependency_hash) = dep_file.data_hash(&extra_data) {
408 if local_hash == dependency_hash {
409 files_to_delete.insert(rfile.path_in_container_raw().to_string());
410 }
411 }
412 }
413 }
414 }
415 }
416
417 // Then, do a second pass, this time over the decodeable files that we can optimize.
418 files_to_delete.extend(files_to_optimize.iter_mut().filter_map(|rfile| {
419
420 // Only check it if it's not already marked for deletion.
421 let path = rfile.path_in_container_raw().to_owned();
422 if !files_to_delete.contains(&path) {
423
424 match rfile.file_type() {
425
426 // Unless we specifically wanted to, ignore the same-name-as-vanilla-or-parent files,
427 // as those are probably intended to overwrite vanilla files, not to be optimized.
428 FileType::DB if options.db_optimize_datacored_tables || !dependencies.file_exists(&path, true, true, true) => {
429 if let Ok(RFileDecoded::DB(db)) = rfile.decoded_mut() {
430 if db.optimize(dependencies, Some(&self_copy_map), options) && options.table_remove_empty_file {
431 return Some(path);
432 }
433 }
434 }
435
436 // Same as with tables, don't optimize them if they're overwriting.
437 FileType::Loc if options.db_optimize_datacored_tables || !dependencies.file_exists(&path, true, true, true) => {
438 if let Ok(RFileDecoded::Loc(loc)) = rfile.decoded_mut() {
439 if loc.optimize(dependencies, Some(&self_copy_map), options) && options.table_remove_empty_file {
440 return Some(path);
441 }
442 }
443 }
444
445 FileType::Text => {
446
447 // agf and model_statistics are debug files outputed by bob in older games.
448 if (options.text_remove_agf_files && path.ends_with(".agf")) ||
449 (options.text_remove_model_statistics_files && path.ends_with(".model_statistics")) {
450 if let Ok(Some(RFileDecoded::Text(_))) = rfile.decode(&None, false, true) {
451 return Some(path);
452 }
453 }
454
455 else if !path.is_empty() && (
456 (options.text_remove_unused_xml_prefab_folder && path.starts_with("prefabs/")) ||
457 (options.text_remove_unused_xml_map_folders && (
458 path.starts_with("terrain/battles/") ||
459 path.starts_with("terrain/tiles/battle/")
460 ))
461 )
462 && !path.ends_with(".wsmodel")
463 && !path.ends_with(".environment")
464 && !path.ends_with(".environment_group")
465 && !path.ends_with(".environment_group.override")
466
467 // Delete all xml files that match a bin file.
468 && (
469 path.ends_with(".xml") && (
470 pack_paths.contains(&path[..path.len() - 4].to_lowercase()) ||
471 pack_paths.contains(&(path[..path.len() - 4].to_lowercase() + ".bin"))
472 )
473 )
474 {
475 if let Ok(Some(RFileDecoded::Text(text))) = rfile.decode(&None, false, true) {
476 if *text.format() == TextFormat::Xml {
477 return Some(path);
478
479 }
480 }
481 }
482 }
483
484 FileType::PortraitSettings => {
485
486 // In portrait settings file we look to cleanup variants and art sets that are not referenced by the game tables.
487 // Meaning they are not used by the game.
488 if let Ok(RFileDecoded::PortraitSettings(ps)) = rfile.decoded_mut() {
489 if ps.optimize(dependencies, Some(&self_copy_map), options) && options.pts_remove_empty_file {
490 return Some(path);
491 }
492 }
493 }
494
495 // Ignore the rest.
496 _ => {}
497 }
498 }
499
500 None
501 }).collect::<Vec<String>>());
502
503 // If a table added is also marked for deletion, don't add it.
504 files_to_add.retain(|x| !files_to_delete.contains(x));
505
506 // Delete all the files marked for deletion.
507 files_to_delete.iter().for_each(|x| { self.remove(&ContainerPath::File(x.to_owned())); });
508
509 // Apply the most modern compression format the game supports, overriding whatever the pack had set.
510 // `set_compression_format` handles the "no formats supported" case (falls back to None → compress=false).
511 if options.pack_apply_compression {
512 let cf = game.compression_formats_supported().first().cloned().unwrap_or_default();
513 self.set_compression_format(cf, game);
514 }
515
516 // Return the deleted files, so the caller can know what got removed.
517 Ok((files_to_delete, files_to_add))
518 }
519}
520
521impl Optimizable for DB {
522
523 /// This function optimizes the provided [DB] file in order to make it smaller and more compatible.
524 ///
525 /// Specifically, it performs the following optimizations:
526 ///
527 /// - Removal of duplicated entries.
528 /// - Removal of ITM (Identical To Master) entries.
529 /// - Removal of ITNR (Identical To New Row) entries.
530 ///
531 /// It returns if the DB is empty, meaning it can be safetly deleted.
532 fn optimize(&mut self, dependencies: &mut Dependencies, container: Option<&BTreeMap<String, Pack>>, options: &OptimizerOptions) -> bool {
533 let container = match container {
534 Some(container) => container,
535 None => return false,
536 };
537
538 // Get a manipulable copy of all the entries, so we can optimize it.
539 let mut entries = self.data().to_vec();
540
541 match dependencies.db_data_datacored(self.table_name(), container, true, true) {
542 Ok(mut vanilla_tables) => {
543
544 // First, merge all vanilla and parent db fragments into a single HashSet.
545 let vanilla_table = vanilla_tables.iter_mut()
546 .filter_map(|file| {
547 if let Ok(RFileDecoded::DB(table)) = file.decoded() {
548 Some(table.data().to_vec())
549 } else { None }
550 })
551 .flatten()
552 .map(|x| {
553
554 // We map all floats here to string representations of floats, so we can actually compare them reliably.
555 let json = x.iter().map(|data|
556 if let DecodedData::F32(value) = data {
557 DecodedData::StringU8(format!("{value:.4}"))
558 } else if let DecodedData::F64(value) = data {
559 DecodedData::StringU8(format!("{value:.4}"))
560 } else {
561 data.to_owned()
562 }
563 ).collect::<Vec<DecodedData>>();
564 serde_json::to_string(&json).unwrap()
565 })
566 .collect::<HashSet<String>>();
567
568 // Remove ITM and ITNR entries.
569 let new_row = self.new_row().iter().map(|data|
570 if let DecodedData::F32(value) = data {
571 DecodedData::StringU8(format!("{value:.4}"))
572 } else if let DecodedData::F64(value) = data {
573 DecodedData::StringU8(format!("{value:.4}"))
574 } else {
575 data.to_owned()
576 }
577 ).collect::<Vec<DecodedData>>();
578
579 entries.retain(|entry| {
580 let entry_json = entry.iter().map(|data|
581 if let DecodedData::F32(value) = data {
582 DecodedData::StringU8(format!("{value:.4}"))
583 } else if let DecodedData::F64(value) = data {
584 DecodedData::StringU8(format!("{value:.4}"))
585 } else {
586 data.to_owned()
587 }
588 ).collect::<Vec<DecodedData>>();
589
590 (!options.table_remove_itm_entries || !vanilla_table.contains(&serde_json::to_string(&entry_json).unwrap())) &&
591 (!options.table_remove_itnr_entries || entry != &new_row)
592 });
593
594 // Dedupper. This is slower than a normal dedup, but it doesn't reorder rows.
595 if options.table_remove_duplicated_entries {
596 let mut dummy_set = HashSet::new();
597 entries.retain(|x| dummy_set.insert(x.clone()));
598 }
599
600 // Then we overwrite the entries and return if the table is empty or now, so we can optimize it further at the Container level.
601 //
602 // NOTE: This may fail, but in that case the table will not be left empty, which we check in the next line.
603 let _ = self.set_data(&entries);
604 self.data().is_empty()
605 }
606 Err(_) => false,
607 }
608 }
609}
610
611impl Optimizable for Loc {
612
613 /// This function optimizes the provided [Loc] file in order to make it smaller and more compatible.
614 ///
615 /// Specifically, it performs the following optimizations:
616 ///
617 /// - Removal of duplicated entries.
618 /// - Removal of ITM (Identical To Master) entries.
619 /// - Removal of ITNR (Identical To New Row) entries.
620 ///
621 /// It returns if the Loc is empty, meaning it can be safetly deleted.
622 fn optimize(&mut self, dependencies: &mut Dependencies, _container: Option<&BTreeMap<String, Pack>>, options: &OptimizerOptions) -> bool {
623
624 // Get a manipulable copy of all the entries, so we can optimize it.
625 let mut entries = self.data().to_vec();
626 match dependencies.loc_data(true, true) {
627 Ok(mut vanilla_tables) => {
628
629 // First, merge all vanilla and parent locs into a single HashMap<key, value>. We don't care about the third column.
630 let vanilla_table = vanilla_tables.iter_mut()
631 .filter_map(|file| {
632 if let Ok(RFileDecoded::Loc(table)) = file.decoded() {
633 Some(table.data().to_vec())
634 } else { None }
635 })
636 .flat_map(|data| data.iter()
637 .map(|data| (data[0].data_to_string().to_string(), data[1].data_to_string().to_string()))
638 .collect::<Vec<(String, String)>>())
639 .collect::<HashMap<String, String>>();
640
641 // Remove ITM and ITNR entries.
642 let new_row = self.new_row();
643 entries.retain(|entry| {
644 if options.table_remove_itnr_entries && entry == &new_row {
645 return false;
646 }
647
648 if options.table_remove_itm_entries {
649 match vanilla_table.get(&*entry[0].data_to_string()) {
650 Some(vanilla_value) => return &*entry[1].data_to_string() != vanilla_value,
651 None => return true
652 }
653 }
654
655 true
656 });
657
658 // Dedupper. This is slower than a normal dedup, but it doesn't reorder rows.
659 if options.table_remove_duplicated_entries {
660 let mut dummy_set = HashSet::new();
661 entries.retain(|x| dummy_set.insert(x.clone()));
662 }
663
664 // Then we overwrite the entries and return if the table is empty or now, so we can optimize it further at the Container level.
665 //
666 // NOTE: This may fail, but in that case the table will not be left empty, which we check in the next line.
667 let _ = self.set_data(&entries);
668 self.data().is_empty()
669 }
670 Err(_) => false,
671 }
672 }
673}
674
675impl Optimizable for PortraitSettings {
676
677 /// This function optimizes the provided [PortraitSettings] file in order to make it smaller.
678 ///
679 /// Specifically, it performs the following optimizations:
680 ///
681 /// - Removal of variants not present in the variants table (unused data).
682 /// - Removal of art sets not present in the campaign_character_arts table (unused data).
683 ///
684 /// It returns if the PortraitSettings is empty, meaning it can be safetly deleted.
685 fn optimize(&mut self, dependencies: &mut Dependencies, container: Option<&BTreeMap<String, Pack>>, options: &OptimizerOptions) -> bool {
686
687 // Get a manipulable copy of all the entries, so we can optimize it.
688 let mut entries = self.entries().to_vec();
689
690 // Get the list of art set ids and variant filenames to check against.
691 let art_set_ids = dependencies.db_values_from_table_name_and_column_name(container, "campaign_character_arts_tables", "art_set_id", true, true);
692 let mut variant_filenames = dependencies.db_values_from_table_name_and_column_name(container, "variants_tables", "variant_filename", true, true);
693 if variant_filenames.is_empty() {
694 variant_filenames = dependencies.db_values_from_table_name_and_column_name(container, "variants_tables", "variant_name", true, true);
695 }
696
697 // Do not do anything if we don't have ids and variants.
698 if art_set_ids.is_empty() || variant_filenames.is_empty() {
699 return false;
700 }
701
702 entries.retain_mut(|entry| {
703 entry.variants_mut().retain_mut(|variant| {
704 if options.pts_remove_empty_masks {
705 if variant.file_mask_1().ends_with(EMPTY_MASK_PATH_END) {
706 variant.file_mask_1_mut().clear();
707 }
708 if variant.file_mask_2().ends_with(EMPTY_MASK_PATH_END) {
709 variant.file_mask_2_mut().clear();
710 }
711 if variant.file_mask_3().ends_with(EMPTY_MASK_PATH_END) {
712 variant.file_mask_3_mut().clear();
713 }
714 }
715
716 if options.pts_remove_unused_variants {
717 variant_filenames.contains(variant.filename())
718 } else {
719 true
720 }
721 });
722
723 if options.pts_remove_unused_art_sets {
724 art_set_ids.contains(entry.id())
725 } else {
726 true
727 }
728 });
729
730 self.set_entries(entries);
731 self.entries().is_empty()
732 }
733}