Skip to main content

rpfm_server/state/
tools.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//! Tool operations: optimizer, map packing, startpos and CEO building, translations, MyMods,
12//! animation ids, glTF export and plugin scripts.
13
14use anyhow::{anyhow, Result};
15
16
17use std::collections::{BTreeMap, HashMap, HashSet};
18use std::env::temp_dir;
19use std::fs::{DirBuilder, File};
20use std::io::{BufWriter, Write};
21use std::path::{Component, Path, PathBuf};
22
23use rpfm_extensions::dependencies::{GAMES_NEEDING_VICTORY_OBJECTIVES, VICTORY_OBJECTIVES_FILE_NAME};
24use rpfm_extensions::gltf::{gltf_from_rigid, save_gltf_to_disk};
25use rpfm_extensions::optimizer::{OptimizableContainer, OptimizerOptions};
26use rpfm_extensions::startpos::extract::{extract_tables, ExtractedTables};
27use rpfm_extensions::startpos::extract::compare::compare_tables;
28use rpfm_extensions::startpos::generator::{generate_startpos, load_startpos, load_tables, GameLayouts, StartposSources, SUPPORTED_GAMES as GENERATOR_GAMES};
29use rpfm_extensions::startpos::layout::StartposLayout;
30use rpfm_extensions::startpos::split::CampaignRows;
31use rpfm_extensions::startpos::tables::StartposTables;
32use rpfm_extensions::startpos::validation::{validate, Severity};
33use rpfm_extensions::translator::PackTranslation;
34
35use rpfm_ipc::settings_keys::ASSEMBLY_KIT_SUFFIX;
36
37use rpfm_lib::files::{Container, ContainerPath, DecodeableExtraData, FileType, RFile, RFileDecoded, rigidmodel::RigidModel};
38use rpfm_lib::games::supported_games::KEY_THREE_KINGDOMS;
39use rpfm_lib::integrations::git::GitIntegration;
40
41use rpfm_telemetry::{error, info, warn};
42
43use crate::ceo_builder::{build_ceo, build_ceo_entries, build_ceo_post, get_trait_ceos};
44use rpfm_ipc::settings::{lua_autogen_game_path, scripts_path, startpos_layouts_path, translations_local_path, translations_remote_path, Settings};
45
46use rpfm_ipc::api::ApiError;
47use rpfm_ipc::api::diagnostics::DiagnosticLevel;
48use rpfm_ipc::api::files::FileRef;
49use rpfm_ipc::api::tables::FilesEdited;
50use rpfm_ipc::api::translations::{DEFAULT_TRANSLATIONS_LIMIT, ListTranslations, TranslationEntry, TranslationFilter, Translations};
51use rpfm_ipc::api::tools::{optimizer_option_values, CeoEntryData, CompareStartposTables, ExtractStartposTables, FilePaths, KeepStartposCampaign, FilesChanged, GenerateStartpos, InitMyMod, StartposColumnComparison, StartposComparison,
52    StartposIssue, StartposTableComparison, StartposValidation, StartposValueDifference, ValidateStartpos, MyModCreated, PluginScriptRun, RunPluginScript, SiegeAiPatched, StartStartpos, StartposCampaigns};
53
54use super::{DecodedFile, ExtractOptions, SessionState, encode_extra_data, loaded_schema, pack, pack_mut};
55use super::files::{container_path, pack_key_and_data_source, raw_paths};
56
57/// Filename prefix for community-maintained vanilla loc fix TSVs in the
58/// [Total War Translation Hub][tlh] repo (e.g. `vanilla_fixes_es.tsv`).
59/// Each one carries fixes for vanilla loc bugs in a specific language;
60/// the suffix is the language code.
61///
62/// [tlh]: https://github.com/Frodo45127/total_war_translation_hub
63const VANILLA_FIXES_NAME: &str = "vanilla_fixes_";
64
65/// Sub-startpos each Three Kingdoms campaign has.
66const THREE_KINGDOMS_SUB_STARTPOS: [&str; 2] = ["historical", "romance"];
67
68/// Start of the paths of the `start_pos_*` tables in a pack.
69const STARTPOS_TABLES_FOLDER_PREFIX: &str = "db/start_pos_";
70
71/// Suffix of the file name of the tables extracted from a startpos, after the campaign key.
72const EXTRACTED_TABLES_SUFFIX: &str = "_startpos";
73
74/// Pack setting with the campaign the last startpos of a pack was built for.
75const STARTPOS_LAST_CAMPAIGN_SETTING: &str = "starpos_last_campaign";
76
77/// A startpos build started, waiting for the game to build it.
78#[derive(Debug, Clone)]
79pub(super) struct PendingStartpos {
80    pack: String,
81    campaign: String,
82    process_hlp_spd_data: bool,
83}
84
85/// Editor support to set up in a new MyMod folder.
86#[derive(Debug, Clone, Default)]
87pub struct MyModOptions {
88
89    /// If a Sublime Text project is created.
90    pub sublime_support: bool,
91
92    /// If a VSCode config is created.
93    pub vscode_support: bool,
94
95    /// If set, a git repository is created with this `.gitignore`.
96    pub gitignore: Option<String>,
97}
98
99impl SessionState {
100
101    /// Removes unchanged and duplicated data from a pack.
102    ///
103    /// # Returns
104    ///
105    /// The paths deleted and the paths added by the optimizer.
106    pub fn optimize_pack(&mut self, pack_key: &str, options: &OptimizerOptions) -> Result<(HashSet<String>, HashSet<String>)> {
107        let pack = pack_mut(&mut self.packs, pack_key)?;
108        let schema = loaded_schema(&self.schema)?;
109        Ok(pack.optimize(None, &mut self.dependencies, schema, &self.game, options)?)
110    }
111
112    /// Patches the SiegeAI of the siege maps of a pack, for Warhammer games.
113    ///
114    /// # Returns
115    ///
116    /// A message with the result, and the paths deleted.
117    pub fn patch_siege_ai(&mut self, pack_key: &str) -> Result<(String, Vec<ContainerPath>)> {
118        Ok(pack_mut(&mut self.packs, pack_key)?.patch_siege_ai()?)
119    }
120
121    /// Packs map tiles and tile maps into a pack.
122    ///
123    /// # Arguments
124    ///
125    /// * `pack_key` - Key of the pack to add the maps to.
126    /// * `tile_maps` - Paths on disk of the tile maps.
127    /// * `tiles` - Paths on disk of the tiles, and their subfolder in the pack.
128    /// * `options` - Options to optimize the added files.
129    ///
130    /// # Returns
131    ///
132    /// The paths added and the paths deleted.
133    pub fn pack_map(&mut self, pack_key: &str, tile_maps: Vec<PathBuf>, tiles: Vec<(PathBuf, String)>, options: OptimizerOptions) -> Result<(Vec<ContainerPath>, Vec<ContainerPath>)> {
134        let schema = loaded_schema(&self.schema)?;
135        Ok(self.dependencies.add_tile_maps_and_tiles(&mut self.packs, Some(pack_key), &self.game, schema, options, tile_maps, tiles)?)
136    }
137
138    /// Adds the loc entries missing from the tables of the open packs.
139    ///
140    /// # Returns
141    ///
142    /// The paths of the files with the new entries.
143    pub fn generate_missing_loc_data(&mut self) -> Result<Vec<ContainerPath>> {
144        Ok(self.dependencies.generate_missing_loc_data(&mut self.packs)?)
145    }
146
147    /// Creates the folder of a new MyMod, with the editor configs requested.
148    ///
149    /// # Arguments
150    ///
151    /// * `mymod_base_path` - Folder containing the MyMods of all games.
152    /// * `mod_game` - Folder name of the game of the MyMod.
153    /// * `mod_name` - Name of the MyMod.
154    /// * `options` - Editor support to set up.
155    ///
156    /// # Returns
157    ///
158    /// The path of the MyMod's pack.
159    pub fn initialize_mymod_folder(&self, mymod_base_path: &Path, mod_game: &str, mod_name: &str, options: &MyModOptions) -> Result<PathBuf> {
160        if !mymod_base_path.is_dir() {
161            return Err(anyhow!("MyMod path is not configured. Configure it in the settings and try again."));
162        }
163
164        let mut mymod_path = mymod_base_path.join(mod_game);
165        DirBuilder::new().recursive(true).create(&mymod_path)
166            .map_err(|error| anyhow!("Error while creating the MyMod's Game folder: {}.", error))?;
167
168        // We need to create another folder inside the game's folder with the name of the new "MyMod", to store extracted files.
169        mymod_path.push(mod_name);
170        DirBuilder::new().recursive(true).create(&mymod_path)
171            .map_err(|error| anyhow!("Error while creating the MyMod's Assets folder: {}.", error))?;
172
173        if let Some(ref gitignore) = options.gitignore {
174            let git_integration = GitIntegration::new(&mymod_path, "", "", "");
175            git_integration.init()?;
176            git_integration.add_gitignore(gitignore)?;
177        }
178
179        // If the tw_autogen supports the game, create the vscode and sublime configs for lua mods.
180        if options.sublime_support || options.vscode_support {
181            if let Ok(lua_autogen_folder) = lua_autogen_game_path(&self.game) {
182                let lua_autogen_folder = lua_autogen_folder.to_string_lossy().replace('\\', "/");
183                write_lua_editor_configs(&mymod_path, mod_name, &lua_autogen_folder, options)?;
184            }
185        }
186
187        mymod_path.set_extension("pack");
188        Ok(mymod_path)
189    }
190
191    /// Returns the translation of a pack to a language, with the vanilla texts and their community fixes as base.
192    ///
193    /// # Arguments
194    ///
195    /// * `pack_key` - Key of the pack to translate.
196    /// * `src_lang` - Language the pack's texts are written in.
197    /// * `language` - Language to translate the pack to.
198    pub fn pack_translation(&self, pack_key: &str, src_lang: &str, language: &str) -> Result<PackTranslation> {
199        let game_key = self.game.key();
200        let local_path = translations_local_path()?;
201        let remote_path = translations_remote_path()?;
202
203        let mut base_english = HashMap::new();
204        let mut base_local_fixes = HashMap::new();
205
206        // Vanilla texts generated from the game files live in the local folder. The Hub only ships English ones.
207        let vanilla_loc_name = PackTranslation::vanilla_loc_file_name(src_lang);
208        let vanilla_loc = [&local_path, &remote_path].iter()
209            .find_map(|path| RFile::tsv_import_from_path(&path.join(game_key).join(&vanilla_loc_name), &None).ok());
210
211        if let Some(mut vanilla_loc) = vanilla_loc {
212            let _ = vanilla_loc.guess_file_type();
213            if let Ok(RFileDecoded::Loc(vanilla_loc)) = vanilla_loc.decoded() {
214
215                // If we have a fixes file for the vanilla translation, apply it before everything else.
216                let fixes_loc_path = remote_path.join(format!("{}/{}{}.tsv", game_key, VANILLA_FIXES_NAME, language));
217                if let Ok(mut fixes_loc) = RFile::tsv_import_from_path(&fixes_loc_path, &None) {
218                    let _ = fixes_loc.guess_file_type();
219                    if let Ok(RFileDecoded::Loc(fixes_loc)) = fixes_loc.decoded() {
220                        base_local_fixes.extend(fixes_loc.data().iter().map(|row| (row[0].data_to_string().to_string(), row[1].data_to_string().to_string())));
221                    }
222                }
223
224                base_english.extend(vanilla_loc.data().iter().map(|row| (row[0].data_to_string().to_string(), row[1].data_to_string().to_string())));
225            }
226        }
227
228        let pack = pack(&self.packs, pack_key)?;
229        Ok(PackTranslation::new(&[local_path, remote_path], pack, game_key, src_lang, language, &self.dependencies, &base_english, &base_local_fixes)?)
230    }
231
232    /// Generates the vanilla texts of a language from the game's locale packs.
233    ///
234    /// # Arguments
235    ///
236    /// * `src_lang` - Language to generate the texts of.
237    /// * `settings` - Settings, to find the game's install folder.
238    ///
239    /// # Returns
240    ///
241    /// If vanilla texts for that language are available, generated now or before.
242    pub fn generate_vanilla_translation_source(&self, src_lang: &str, settings: &Settings) -> Result<bool> {
243        let local_path = translations_local_path()?;
244        let remote_path = translations_remote_path()?;
245
246        let game_path = settings.path_buf(self.game.key());
247        if let Err(error) = PackTranslation::generate_vanilla_loc(&self.game, &game_path, src_lang, &local_path.join(self.game.key())) {
248            warn!("Failed to generate the vanilla {src_lang} texts from the game files: {error}");
249        }
250
251        let vanilla_loc_name = PackTranslation::vanilla_loc_file_name(src_lang);
252        Ok([local_path, remote_path].iter().any(|path| path.join(self.game.key()).join(&vanilla_loc_name).is_file()))
253    }
254
255    /// Checks that a pack has the victory objectives file, for the games that need it to build a startpos.
256    pub fn check_starpos_victory_conditions(&self, pack_key: &str) -> Result<()> {
257        let pack = pack(&self.packs, pack_key)?;
258        if GAMES_NEEDING_VICTORY_OBJECTIVES.contains(&self.game.key()) && pack.file(VICTORY_OBJECTIVES_FILE_NAME, false).is_none() {
259            return Err(anyhow!("Missing \"db/victory_objectives.txt\" file. Processing the startpos without this file will result in issues in campaign. Add the file to the pack and try again."));
260        }
261
262        Ok(())
263    }
264
265    /// Prepares the Assembly Kit to build the startpos of a campaign, with the tables of a pack.
266    ///
267    /// # Arguments
268    ///
269    /// * `pack_key` - Key of the pack with the startpos tables.
270    /// * `campaign_id` - Campaign to build the startpos for.
271    /// * `process_hlp_spd_data` - If the HLP and SPD data are also built.
272    /// * `settings` - Settings, to find the game's install folder and Assembly Kit.
273    pub fn build_starpos(&mut self, pack_key: &str, campaign_id: &str, process_hlp_spd_data: bool, settings: &Settings) -> Result<()> {
274        let game_path = settings.path_buf(self.game.key());
275        let asskit_path = Some(settings.path_buf(&format!("{}{}", self.game.key(), ASSEMBLY_KIT_SUFFIX)));
276
277        // 3K needs two passes, one per startpos, and there are two per campaign. The HLP and SPD data is only built once.
278        if self.game.key() == KEY_THREE_KINGDOMS {
279            for (index, sub_start_pos) in THREE_KINGDOMS_SUB_STARTPOS.iter().enumerate() {
280                self.dependencies.build_starpos_pre(&mut self.packs, Some(pack_key), &self.game, &game_path, asskit_path.clone(), campaign_id, process_hlp_spd_data && index == 0, sub_start_pos)?;
281            }
282        } else {
283            self.dependencies.build_starpos_pre(&mut self.packs, Some(pack_key), &self.game, &game_path, asskit_path, campaign_id, process_hlp_spd_data, "")?;
284        }
285
286        Ok(())
287    }
288
289    /// Imports the startpos built by the Assembly Kit into a pack, or cleans up the files used to build it.
290    ///
291    /// # Arguments
292    ///
293    /// * `pack_key` - Key of the pack to import the startpos into.
294    /// * `campaign_id` - Campaign the startpos was built for.
295    /// * `process_hlp_spd_data` - If the HLP and SPD data were also built.
296    /// * `cleanup` - If this only cleans up the build files, instead of importing the startpos.
297    /// * `settings` - Settings, to find the game's install folder and Assembly Kit.
298    ///
299    /// # Returns
300    ///
301    /// The paths added to the pack.
302    pub fn build_starpos_post(&mut self, pack_key: &str, campaign_id: &str, process_hlp_spd_data: bool, cleanup: bool, settings: &Settings) -> Result<Vec<ContainerPath>> {
303        let game_path = settings.path_buf(self.game.key());
304        let asskit_path = Some(settings.path_buf(&format!("{}{}", self.game.key(), ASSEMBLY_KIT_SUFFIX)));
305        let sub_start_pos = if self.game.key() == KEY_THREE_KINGDOMS {
306            THREE_KINGDOMS_SUB_STARTPOS.iter().map(|name| name.to_string()).collect()
307        } else {
308            vec![]
309        };
310
311        Ok(self.dependencies.build_starpos_post(&mut self.packs, Some(pack_key), &self.game, &game_path, asskit_path, campaign_id, process_hlp_spd_data, cleanup, &sub_start_pos)?)
312    }
313
314    /// Builds `ceo_data.ccd` in the Assembly Kit from the CEO tables of a pack. See [`build_ceo`].
315    pub fn build_ceo(&mut self, pack_key: &str, akit_path: &Path, bob_exe_path: &Path) -> Result<()> {
316        info!("[BuildCeo] handler entered: pack={pack_key}, akit={}, bob={}", akit_path.display(), bob_exe_path.display());
317        let pack = pack_mut(&mut self.packs, pack_key)?;
318        build_ceo(pack, &self.schema, akit_path, bob_exe_path)
319    }
320
321    /// Imports the `ceo_data.ccd` built by BOB into a pack.
322    ///
323    /// # Returns
324    ///
325    /// The paths added to the pack.
326    pub fn build_ceo_post(&mut self, pack_key: &str, akit_path: &Path) -> Result<FilesEdited> {
327        let added = build_ceo_post(pack_mut(&mut self.packs, pack_key)?, &akit_path.to_string_lossy())?;
328        Ok(FilesEdited { edited: raw_paths(&added) })
329    }
330
331    /// Adds CEO entries (armour, career, traits and their locs) to a pack.
332    ///
333    /// # Returns
334    ///
335    /// The paths added to the pack.
336    pub fn build_ceo_entries(&mut self, pack_key: &str, entries: &[CeoEntryData]) -> Result<FilesEdited> {
337        let schema = loaded_schema(&self.schema)?;
338        let added = build_ceo_entries(pack_mut(&mut self.packs, pack_key)?, schema, entries)?;
339        Ok(FilesEdited { edited: raw_paths(&added) })
340    }
341
342    /// Returns the key and name of the trait CEOs in the Assembly Kit.
343    pub fn trait_ceos(&self) -> Vec<(String, String)> {
344        get_trait_ceos(&self.dependencies)
345    }
346
347    /// Offsets the animation ids of a pack.
348    ///
349    /// # Arguments
350    ///
351    /// * `pack_key` - Key of the pack to edit.
352    /// * `starting_id` - Only ids from this one are changed.
353    /// * `offset` - Amount to add to each id.
354    ///
355    /// # Returns
356    ///
357    /// The paths of the edited files.
358    pub fn update_anim_ids(&mut self, pack_key: &str, starting_id: i32, offset: i32) -> Result<Vec<ContainerPath>> {
359        Ok(pack_mut(&mut self.packs, pack_key)?.update_anim_ids(&self.game, starting_id, offset)?)
360    }
361
362    /// Exports a RigidModel to a glTF file.
363    pub fn export_rigid_to_gltf(&mut self, rigid_model: &RigidModel, path: &Path) -> Result<()> {
364        let gltf = gltf_from_rigid(rigid_model, &mut self.dependencies)?;
365        save_gltf_to_disk(&gltf, path)?;
366        Ok(())
367    }
368
369    /// Runs a plugin script over files of a pack, reading back into the pack the files it changes.
370    ///
371    /// The files are extracted to a temp folder, keeping their paths in the pack, and passed to the script as arguments.
372    /// Tables are extracted as TSV. Files the script deletes are left untouched in the pack.
373    ///
374    /// # Arguments
375    ///
376    /// * `request` - The script, its pack, and the paths of the files and folders to pass to it.
377    /// * `options` - Options for extracting the files.
378    ///
379    /// # Returns
380    ///
381    /// The paths read back into the pack, and an error message if the script failed.
382    pub fn run_plugin_script(&mut self, request: &RunPluginScript, options: ExtractOptions) -> Result<PluginScriptRun> {
383        let script_path = &request.script;
384        let interpreter = plugin_script_interpreter(script_path)
385            .ok_or_else(|| anyhow!("Unsupported plugin script type: {}", script_path.display()))?;
386
387        let pack = pack_mut(&mut self.packs, &request.pack)?;
388        let container_paths = request.paths.iter().map(|path| container_path(|path| pack.has_file(path), path)).collect::<Vec<_>>();
389        let base_folder = temp_dir().join("rpfm_plugins").join(pack.disk_file_name());
390        let _ = std::fs::remove_dir_all(&base_folder);
391
392        let extra_data = encode_extra_data(&self.game, pack.compression_format(), options.disable_uuid_regeneration);
393        let mut extracted_paths = vec![];
394        for container_path in container_paths {
395            let mut paths = pack.extract(container_path, &base_folder, true, &self.schema, false, options.tsv_keys_first, &extra_data)
396                .map_err(|error| anyhow!("Error extracting files for the plugin script: {}", error))?;
397            extracted_paths.append(&mut paths);
398        }
399
400        let output = std::process::Command::new(interpreter)
401            .arg(script_path)
402            .args(&extracted_paths)
403            .current_dir(&base_folder)
404            .output()
405            .map_err(|error| {
406                error!("Failed to run the plugin script {}: {}", script_path.display(), error);
407                anyhow!("Failed to run the plugin script: {}", error)
408            })?;
409
410        let stdout = String::from_utf8_lossy(&output.stdout);
411        let stderr = String::from_utf8_lossy(&output.stderr);
412
413        // Record the run so users can debug their scripts: a `last_run.log` in the
414        // scripts folder (overwritten each run), plus the standard terminal logger.
415        let report = format!("Script: {}\nStatus: {}\n\n--- stdout ---\n{}\n--- stderr ---\n{}\n", script_path.display(), output.status, stdout, stderr);
416        if let Ok(folder) = scripts_path() {
417            let _ = std::fs::write(folder.join("last_run.log"), report.as_bytes());
418        }
419
420        info!("Plugin script {} finished with {}.", script_path.display(), output.status);
421        if !stderr.trim().is_empty() {
422            warn!("Plugin script stderr:\n{}", stderr.trim());
423        }
424
425        let message = if output.status.success() {
426            None
427        } else {
428            Some(format!("The plugin script finished with errors. See last_run.log in the scripts folder.\n\n{}", stderr.trim()))
429        };
430
431        let mut reimported_paths = vec![];
432        for disk_path in extracted_paths.iter().filter(|path| path.is_file()) {
433
434            // TSV-exported DB/Loc files have a `.tsv` suffix appended to their in-pack name;
435            // strip it to find the real file when the direct path doesn't match anything.
436            let direct_path = container_path_from_disk_path(disk_path, &base_folder);
437            let container_path = match direct_path.strip_suffix(".tsv") {
438                Some(stripped) if pack.file_mut(&direct_path, false).is_none() => stripped.to_owned(),
439                _ => direct_path,
440            };
441
442            if let Some(file) = pack.file_mut(&container_path, false) {
443                if file.encode_from_external_data(&self.schema, disk_path).is_ok() {
444                    reimported_paths.push(container_path);
445                }
446            }
447        }
448
449        let _ = std::fs::remove_dir_all(&base_folder);
450        Ok(PluginScriptRun { edited: reimported_paths, error: message })
451    }
452}
453
454impl SessionState {
455
456    /// Removes unneeded data from a pack, returning the paths changed.
457    pub fn optimize_pack_files(&mut self, pack_key: &str, options: &OptimizerOptions) -> Result<FilesChanged> {
458        let (deleted, added) = self.optimize_pack(pack_key, options)?;
459        Ok(FilesChanged { added: sorted(added), deleted: sorted(deleted) })
460    }
461
462    /// Patches the siege maps of a pack, returning what was patched.
463    pub fn patch_siege_ai_files(&mut self, pack_key: &str) -> Result<SiegeAiPatched> {
464        let (message, deleted) = self.patch_siege_ai(pack_key)?;
465        Ok(SiegeAiPatched { message, deleted: raw_paths(&deleted) })
466    }
467
468    /// Packs map tiles and tile maps into a pack, returning the paths changed.
469    pub fn pack_map_files(&mut self, pack_key: &str, tile_maps: Vec<PathBuf>, tiles: Vec<(PathBuf, String)>, options: OptimizerOptions) -> Result<FilesChanged> {
470        let (added, deleted) = self.pack_map(pack_key, tile_maps, tiles, options)?;
471        Ok(FilesChanged { added: raw_paths(&added), deleted: raw_paths(&deleted) })
472    }
473
474    /// Adds the missing loc entries of the tables of the open packs, returning the paths edited.
475    pub fn generate_missing_locs(&mut self) -> Result<FilesEdited> {
476        Ok(FilesEdited { edited: raw_paths(&self.generate_missing_loc_data()?) })
477    }
478
479    /// Offsets the animation ids of a pack, returning the paths edited.
480    pub fn update_anim_id_files(&mut self, pack_key: &str, starting_id: i32, offset: i32) -> Result<FilesEdited> {
481        Ok(FilesEdited { edited: raw_paths(&self.update_anim_ids(pack_key, starting_id, offset)?) })
482    }
483
484    /// Returns the sorted paths of the animations using a skeleton.
485    pub fn anims_by_skeleton(&mut self, skeleton: &str) -> FilePaths {
486        FilePaths { paths: sorted(self.anim_paths_by_skeleton(skeleton)) }
487    }
488
489    /// Exports a RigidModel from any source to a glTF file.
490    ///
491    /// # Errors
492    ///
493    /// Fails if the file is not a RigidModel, or if it can't be exported.
494    pub fn export_gltf(&mut self, file: &FileRef, destination: &Path) -> Result<()> {
495        let (pack_key, data_source) = pack_key_and_data_source(&file.source);
496        let pack_key = pack_key.to_owned();
497        match self.decode_file(&pack_key, &file.path, data_source, false)? {
498            DecodedFile::Decoded(decoded, _) => match *decoded {
499                RFileDecoded::RigidModel(rigid_model) => self.export_rigid_to_gltf(&rigid_model, destination),
500                _ => Err(ApiError::InvalidParams(format!("The file {} is not a RigidModel.", file.path)).into()),
501            },
502            _ => Err(ApiError::InvalidParams(format!("The file {} is not a RigidModel.", file.path)).into()),
503        }
504    }
505
506    /// Creates the folder of a new MyMod.
507    ///
508    /// # Arguments
509    ///
510    /// * `mymod_base_path` - Folder containing the MyMods of all games.
511    /// * `request` - The MyMod to create.
512    pub fn init_mymod(&self, mymod_base_path: &Path, request: &InitMyMod) -> Result<MyModCreated> {
513        let options = MyModOptions {
514            sublime_support: request.sublime_support,
515            vscode_support: request.vscode_support,
516            gitignore: request.gitignore.clone(),
517        };
518
519        Ok(MyModCreated { pack_path: self.initialize_mymod_folder(mymod_base_path, &request.game_folder, &request.name, &options)? })
520    }
521
522    /// Returns a page of the translation of the texts of a pack, sorted by key.
523    pub fn list_translations(&self, request: &ListTranslations) -> Result<Translations> {
524        let translation = self.pack_translation(&request.pack, &request.source_language, &request.language)?;
525        let mut entries = translation.translations().iter()
526            .filter(|(_, entry)| match request.filter {
527                TranslationFilter::All => true,
528                TranslationFilter::Untranslated => entry.dst().is_empty() && !*entry.rem(),
529                TranslationFilter::NeedsReview => *entry.retr() || *entry.aut(),
530            })
531            .map(|(key, entry)| TranslationEntry {
532                key: key.clone(),
533                source: entry.src().clone(),
534                translation: entry.dst().clone(),
535                needs_retranslation: *entry.retr(),
536                automatic: *entry.aut(),
537                removed: *entry.rem(),
538            })
539            .collect::<Vec<_>>();
540        entries.sort_unstable_by(|a, b| a.key.cmp(&b.key));
541
542        let total = entries.len();
543        let entries = entries.into_iter()
544            .skip(request.offset)
545            .take(request.limit.unwrap_or(DEFAULT_TRANSLATIONS_LIMIT))
546            .collect();
547
548        Ok(Translations { entries, total })
549    }
550
551    /// Returns the campaigns a startpos can be built for, and the one the last startpos of a pack was built for.
552    pub fn startpos_campaigns(&self, pack_key: &str) -> Result<StartposCampaigns> {
553        let last_used = pack(&self.packs, pack_key)?.settings().settings_text().get(STARTPOS_LAST_CAMPAIGN_SETTING).cloned();
554        let campaigns = sorted(self.column_values("campaigns_tables", "campaign_name", true, true));
555        Ok(StartposCampaigns { campaigns, last_used })
556    }
557
558    /// Starts building a startpos: checks the pack, prepares the Assembly Kit and launches the game.
559    ///
560    /// The campaign is saved in the pack's settings, to suggest it next time.
561    pub fn start_startpos(&mut self, request: &StartStartpos, settings: &Settings) -> Result<()> {
562        self.check_starpos_victory_conditions(&request.pack)?;
563        self.build_starpos(&request.pack, &request.campaign, request.process_hlp_spd_data, settings)?;
564
565        pack_mut(&mut self.packs, &request.pack)?.settings_mut().settings_text_mut().insert(STARTPOS_LAST_CAMPAIGN_SETTING.to_owned(), request.campaign.clone());
566        self.pending_startpos = Some(PendingStartpos {
567            pack: request.pack.clone(),
568            campaign: request.campaign.clone(),
569            process_hlp_spd_data: request.process_hlp_spd_data,
570        });
571
572        Ok(())
573    }
574
575    /// Checks the tables a campaign's startpos is built from.
576    pub fn validate_startpos(&self, request: &ValidateStartpos) -> Result<StartposValidation> {
577        let schema = loaded_schema(&self.schema)?;
578        let tables = load_tables(&self.dependencies, pack(&self.packs, &request.pack)?, schema)?;
579        let issues = validate(&tables, schema, &request.campaign).into_iter()
580            .map(|issue| StartposIssue {
581                level: match issue.severity {
582                    Severity::Error => DiagnosticLevel::Error,
583                    Severity::Warning => DiagnosticLevel::Warning,
584                },
585                table: issue.table,
586                row: issue.row,
587                column: issue.column,
588                message: issue.message,
589            })
590            .collect();
591        Ok(StartposValidation { issues })
592    }
593
594    /// Generates the startpos of a campaign from the tables of a pack and the game's files, and adds it to the pack.
595    ///
596    /// The campaign is saved in the pack's settings, to suggest it next time.
597    ///
598    /// # Errors
599    ///
600    /// Fails if the game isn't supported, the dependencies aren't loaded, the tables have errors, or the pack or game
601    /// lack data the build needs.
602    pub fn generate_startpos(&mut self, request: &GenerateStartpos, settings: &Settings) -> Result<FilesEdited> {
603        self.check_startpos_generator_support()?;
604        let schema = loaded_schema(&self.schema)?;
605        let pack_to_build = pack(&self.packs, &request.pack)?;
606        let tables = load_tables(&self.dependencies, pack_to_build, schema)?;
607        let errors = validate(&tables, schema, &request.campaign).into_iter()
608            .filter(|issue| issue.severity == Severity::Error)
609            .map(|issue| issue.to_string())
610            .collect::<Vec<_>>();
611        if !errors.is_empty() {
612            return Err(anyhow!("The startpos tables have errors that would break the build:\n{}", errors.join("\n")));
613        }
614
615        let layout = self.startpos_layout(&tables, &request.campaign, settings)?;
616        let sources = StartposSources::from_tables(tables, &self.dependencies, pack_to_build, &request.campaign)?;
617        let startpos = generate_startpos(&sources, &layout)?;
618
619        let path = format!("campaigns/{}/startpos.esf", request.campaign);
620        let pack = pack_mut(&mut self.packs, &request.pack)?;
621        pack.insert(RFile::new_from_decoded(&RFileDecoded::ESF(startpos), 0, &path))?;
622        pack.settings_mut().settings_text_mut().insert(STARTPOS_LAST_CAMPAIGN_SETTING.to_owned(), request.campaign.clone());
623        Ok(FilesEdited { edited: vec![path] })
624    }
625
626    /// Extracts the `start_pos_*` tables and the victory objectives of a campaign from its startpos (the pack's, or the
627    /// game's), and adds the tables with rows and the objectives to the pack.
628    ///
629    /// # Errors
630    ///
631    /// Fails if the game isn't supported, the dependencies aren't loaded, or the startpos is missing or doesn't fit the
632    /// record layouts.
633    pub fn extract_startpos_tables(&mut self, request: &ExtractStartposTables, settings: &Settings) -> Result<FilesEdited> {
634        let (extracted, _) = self.startpos_extraction(&request.pack, &request.campaign, settings)?;
635        let dbs = extracted.to_dbs(loaded_schema(&self.schema)?)?;
636
637        let pack = pack_mut(&mut self.packs, &request.pack)?;
638        let mut edited = vec![];
639        for db in dbs.into_iter().filter(|db| !db.is_empty()) {
640            let path = format!("db/{}/{}{EXTRACTED_TABLES_SUFFIX}", db.table_name(), request.campaign);
641            pack.insert(RFile::new_from_decoded(&RFileDecoded::DB(db), 0, &path))?;
642            edited.push(path);
643        }
644
645        if !extracted.victory_objectives().is_empty() {
646            let mut file = RFile::new_from_vec(extracted.victory_objectives_script().as_bytes(), FileType::Text, 0, VICTORY_OBJECTIVES_FILE_NAME);
647            file.guess_file_type()?;
648            pack.insert(file)?;
649            edited.push(VICTORY_OBJECTIVES_FILE_NAME.to_owned());
650        }
651        Ok(FilesEdited { edited })
652    }
653
654    /// Removes the rows of a pack's `start_pos_*` tables that don't belong to a campaign, deleting the tables left
655    /// without rows.
656    ///
657    /// # Errors
658    ///
659    /// Fails if the schema isn't loaded, a table can't be decoded, or no faction of the tables is in the campaign.
660    pub fn keep_startpos_campaign(&mut self, request: &KeepStartposCampaign) -> Result<FilesChanged> {
661        let schema = loaded_schema(&self.schema)?;
662        let tables = load_tables(&self.dependencies, pack(&self.packs, &request.pack)?, schema)?;
663        let has_factions = tables.table("start_pos_factions").is_ok_and(|factions| factions.rows().any(|row| row.str("campaign").is_ok_and(|campaign| campaign == request.campaign)));
664        if !has_factions {
665            return Err(ApiError::InvalidParams(format!("No faction of the startpos tables is in the campaign {}.", request.campaign)).into());
666        }
667        let rows = CampaignRows::new(&tables, schema, &request.campaign)?;
668
669        let pack = pack_mut(&mut self.packs, &request.pack)?;
670        let mut paths = pack.files().keys().filter(|path| path.starts_with(STARTPOS_TABLES_FOLDER_PREFIX)).cloned().collect::<Vec<_>>();
671        paths.sort();
672
673        let mut extra_data = DecodeableExtraData::default();
674        extra_data.set_schema(Some(schema));
675        let extra_data = Some(extra_data);
676        let mut changed = FilesChanged { added: vec![], deleted: vec![] };
677        for path in paths {
678            let Some(file) = pack.file_mut(&path, false) else { continue };
679            file.decode(&extra_data, true, false)?;
680            let Ok(RFileDecoded::DB(db)) = file.decoded_mut() else { continue };
681            if rows.retain(db)? == 0 {
682                continue;
683            }
684            if db.is_empty() {
685                pack.remove(&ContainerPath::File(path.to_owned()));
686                changed.deleted.push(path);
687            } else {
688                changed.added.push(path);
689            }
690        }
691        Ok(changed)
692    }
693
694    /// Compares the tables extracted from a campaign's startpos with the tables it's built from.
695    ///
696    /// # Errors
697    ///
698    /// Fails like [`Self::extract_startpos_tables`], or if the extracted values don't fit the schema.
699    pub fn compare_startpos_tables(&self, request: &CompareStartposTables, settings: &Settings) -> Result<StartposComparison> {
700        let (extracted, tables) = self.startpos_extraction(&request.pack, &request.campaign, settings)?;
701        let tables = compare_tables(&tables, &extracted, loaded_schema(&self.schema)?)?.into_iter()
702            .map(|comparison| StartposTableComparison {
703                table: comparison.table().to_owned(),
704                match_ratio: comparison.match_ratio(),
705                matched_rows: *comparison.matched_rows(),
706                missing_rows: comparison.missing_rows().to_vec(),
707                extra_rows: comparison.extra_rows().to_vec(),
708                columns: comparison.columns().iter()
709                    .filter(|column| *column.different() > 0)
710                    .map(|column| StartposColumnComparison {
711                        column: column.column().to_owned(),
712                        equal: *column.equal(),
713                        different: *column.different(),
714                        examples: column.examples().iter()
715                            .map(|(row, original, extracted)| StartposValueDifference { row: row.to_owned(), original: original.to_owned(), extracted: extracted.to_owned() })
716                            .collect(),
717                    })
718                    .collect(),
719            })
720            .collect();
721        Ok(StartposComparison { tables })
722    }
723
724    /// Extracts the tables of a campaign's startpos, returning them with the tables the startpos is built from.
725    fn startpos_extraction(&self, pack_key: &str, campaign: &str, settings: &Settings) -> Result<(ExtractedTables, StartposTables)> {
726        self.check_startpos_generator_support()?;
727        let pack = pack(&self.packs, pack_key)?;
728        let tables = load_tables(&self.dependencies, pack, loaded_schema(&self.schema)?)?;
729        let layout = self.startpos_layout(&tables, campaign, settings)?;
730        let startpos = load_startpos(&self.dependencies, pack, campaign)?;
731        Ok((extract_tables(&startpos, &layout, &tables)?, tables))
732    }
733
734    /// Checks that the startpos generator supports the game and has its files loaded.
735    fn check_startpos_generator_support(&self) -> Result<()> {
736        if !GENERATOR_GAMES.contains(&self.game.key()) {
737            return Err(ApiError::InvalidParams(format!("Startpos generation isn't supported for {} yet.", self.game.display_name())).into());
738        }
739        if !self.dependencies.is_vanilla_data_loaded(false) {
740            return Err(anyhow!("The dependencies cache isn't loaded. Generate it, then try again."));
741        }
742        Ok(())
743    }
744
745    /// Returns the record layouts to write or read a campaign's startpos with.
746    fn startpos_layout(&self, tables: &StartposTables, campaign: &str, settings: &Settings) -> Result<StartposLayout> {
747        let game_data_path = self.game.data_path(&settings.path_buf(self.game.key()))?;
748        Ok(GameLayouts::load_or_extract(&startpos_layouts_path()?, &self.game, &game_data_path)?.for_campaign(tables, campaign))
749    }
750
751    /// Finishes building a startpos: imports it into its pack, or cancels the build, and cleans up the build files.
752    ///
753    /// # Errors
754    ///
755    /// Fails if there's no build started.
756    pub fn finish_startpos(&mut self, cancel: bool, settings: &Settings) -> Result<FilesEdited> {
757        let pending = self.pending_startpos.take()
758            .ok_or_else(|| ApiError::InvalidParams("There's no startpos build started. Call startpos.start first.".to_owned()))?;
759
760        let added = self.build_starpos_post(&pending.pack, &pending.campaign, pending.process_hlp_spd_data, cancel, settings)?;
761        Ok(FilesEdited { edited: raw_paths(&added) })
762    }
763}
764
765/// Returns optimizer options with some of them changed, by name.
766///
767/// # Errors
768///
769/// Fails if a name isn't an optimizer option.
770pub fn optimizer_options_with(base: &OptimizerOptions, changes: &BTreeMap<String, bool>) -> Result<OptimizerOptions, ApiError> {
771
772    // The options are set by name through serde, as the optimizer has a field per option.
773    let mut values = optimizer_option_values(base);
774    for (name, value) in changes {
775        match values.get_mut(name) {
776            Some(option) => *option = *value,
777            None => return Err(ApiError::InvalidParams(format!("Unknown optimizer option: {name}. Valid ones: {}.", values.keys().cloned().collect::<Vec<_>>().join(", ")))),
778        }
779    }
780
781    serde_json::from_value(serde_json::to_value(values).map_err(|error| ApiError::Internal(error.to_string()))?)
782        .map_err(|error| ApiError::Internal(error.to_string()))
783}
784
785/// Returns the items of a collection, sorted.
786fn sorted(items: impl IntoIterator<Item = String>) -> Vec<String> {
787    let mut items = items.into_iter().collect::<Vec<_>>();
788    items.sort_unstable();
789    items
790}
791
792/// Returns the paths of the plugin scripts in the config's scripts folder.
793pub fn plugin_scripts() -> Result<Vec<PathBuf>> {
794    let mut scripts = std::fs::read_dir(scripts_path()?)
795        .map(|entries| entries.flatten()
796            .map(|entry| entry.path())
797            .filter(|path| path.is_file() && plugin_script_interpreter(path).is_some())
798            .collect::<Vec<_>>())
799        .unwrap_or_default();
800
801    scripts.sort();
802    Ok(scripts)
803}
804
805/// Returns the interpreter command for a plugin script, based on its extension.
806///
807/// Returns `None` for unsupported extensions, which is also how we filter the scripts folder.
808fn plugin_script_interpreter(path: &Path) -> Option<&'static str> {
809    match path.extension().and_then(|extension| extension.to_str()) {
810        Some("py") => Some("python"),
811        Some("lua") => Some("lua"),
812        _ => None,
813    }
814}
815
816/// Rebuilds the in-pack container path of a file extracted under `base_folder`.
817///
818/// The extraction keeps the in-pack structure, so the container path is just the file's path
819/// relative to `base_folder` with forward slashes (the separator container paths use).
820fn container_path_from_disk_path(disk_path: &Path, base_folder: &Path) -> String {
821    disk_path.strip_prefix(base_folder)
822        .unwrap_or(disk_path)
823        .components()
824        .filter_map(|component| match component {
825            Component::Normal(part) => Some(part.to_string_lossy().to_string()),
826            _ => None,
827        })
828        .collect::<Vec<_>>()
829        .join("/")
830}
831
832/// Writes the editor configs for Lua scripting in a MyMod folder.
833///
834/// # Arguments
835///
836/// * `mymod_path` - Folder of the MyMod.
837/// * `mod_name` - Name of the MyMod.
838/// * `lua_autogen_folder` - Folder with the Lua type definitions of the game, with forward slashes.
839/// * `options` - Editors to set up.
840fn write_lua_editor_configs(mymod_path: &Path, mod_name: &str, lua_autogen_folder: &str, options: &MyModOptions) -> Result<()> {
841    if options.vscode_support {
842        let vscode_config_path = mymod_path.join(".vscode");
843        DirBuilder::new().recursive(true).create(&vscode_config_path)
844            .map_err(|error| anyhow!("Error while creating the VSCode Config folder: {}.", error))?;
845
846        if let Ok(file) = File::create(vscode_config_path.join("extensions.json")) {
847            let mut file = BufWriter::new(file);
848            let _ = file.write_all("
849{
850    \"recommendations\": [
851        \"sumneko.lua\",
852        \"formulahendry.code-runner\"
853    ],
854}".as_bytes());
855        }
856    }
857
858    if options.sublime_support {
859        if let Ok(file) = File::create(mymod_path.join(format!("{mod_name}.sublime-project"))) {
860            let mut file = BufWriter::new(file);
861            let _ = file.write_all("
862{
863    \"folders\":
864    [
865        {
866            \"path\": \".\"
867        }
868    ]
869}".as_bytes());
870        }
871    }
872
873    // Generic lua support.
874    if let Ok(file) = File::create(mymod_path.join(".luarc.json")) {
875        let mut file = BufWriter::new(file);
876        let _ = file.write_all(format!("
877{{
878    \"workspace.library\": [
879        \"{lua_autogen_folder}/global/\",
880        \"{lua_autogen_folder}/campaign/\",
881        \"{lua_autogen_folder}/frontend/\",
882        \"{lua_autogen_folder}/battle/\"
883    ],
884    \"runtime.version\": \"Lua 5.1\",
885    \"completion.autoRequire\": false,
886    \"workspace.preloadFileSize\": 1500,
887    \"workspace.ignoreSubmodules\": false,
888    \"diagnostics.workspaceDelay\": 500,
889    \"diagnostics.workspaceRate\": 40,
890    \"diagnostics.disable\": [
891        \"lowercase-global\",
892        \"trailing-space\"
893    ],
894    \"hint.setType\": true,
895    \"workspace.ignoreDir\": [
896        \".vscode\",
897        \".git\"
898    ]
899}}").as_bytes());
900    }
901
902    Ok(())
903}
904
905//-------------------------------------------------------------------------------//
906//                                   Tests
907//-------------------------------------------------------------------------------//
908
909#[cfg(test)]
910#[path = "tools_test.rs"]
911mod tools_test;