diff --git a/.gitignore b/.gitignore index 1f6ad04..4e6e5ae 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ Data Files/* Maps/* .idea merged_lands.log -Morrowind.ini \ No newline at end of file +*.ini +Conflicts/* diff --git a/Cargo.lock b/Cargo.lock index 5748756..8469bbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,15 +49,6 @@ version = "1.0.60" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c794e162a5eff65c72ef524dfe393eb923c354e350bb78b9c7383df13f3bc142" -[[package]] -name = "argfile" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f3a889586e8b0753fd320a319e713b8ef6a2693259604db10eca22bc92e8810" -dependencies = [ - "os_str_bytes", -] - [[package]] name = "atty" version = "0.2.14" @@ -175,13 +166,28 @@ checksum = "a3dbbb6653e7c55cc8595ad3e1f7be8f32aba4eb7ff7f0fd1163d4f3d137c0a9" dependencies = [ "atty", "bitflags", + "clap_derive", "clap_lex", "indexmap", + "once_cell", "strsim", "termcolor", "textwrap", ] +[[package]] +name = "clap_derive" +version = "3.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ba52acd3b0a5c33aeada5cdaa3267cdc7c594a98731d4268cdc1532f4264cb4" +dependencies = [ + "heck", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "clap_lex" version = "0.2.4" @@ -528,6 +534,12 @@ dependencies = [ "rayon", ] +[[package]] +name = "heck" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" + [[package]] name = "hermit-abi" version = "0.1.19" @@ -737,7 +749,6 @@ name = "merged_lands" version = "0.1.0" dependencies = [ "anyhow", - "argfile", "bitflags", "clap", "const-default", @@ -846,9 +857,6 @@ name = "os_str_bytes" version = "6.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "648001efe5d5c0102d8cea768e348da85d90af8ba91f0bea908f157951493cd4" -dependencies = [ - "memchr", -] [[package]] name = "owo-colors" @@ -929,6 +937,30 @@ dependencies = [ "toml", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.43" diff --git a/Cargo.toml b/Cargo.toml index beef2a1..02238b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,11 +3,15 @@ name = "merged_lands" version = "0.1.0" edition = "2021" rust-version = "1.64.0" +build = "build.rs" + +[build-dependencies] +# Build metadata. +shadow-rs = "0.16.1" [dependencies] -# -clap = "3.2.16" -argfile = "0.1.4" +# Command line interface. +clap = { version = "3.2.16", features = ["derive"] } wild = "2.0.4" shadow-rs = "0.16.1" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a35ad87 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 David Von Derau + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a03f630 --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ +# Merged Lands + +`merged_lands.exe` is a tool for merging land in TES3 mods. + +The output of the tool is a plugin called `Merged Lands.esp` that should go at the end of your load order. +Yes, that includes after `Merged Objects.esp` if you're using `TES3Merge`. + +The plugin contains a merged representation of any `LAND`, `LTEX`, and `CELL` records edited by mods. + +## How? + +1. The tool builds a "reference" landmass by merging all `.ESM` plugins using a similar algorithm as Morrowind. +2. The tool calculates a "difference" landmass for each mod _with respect to the reference landmass_. +3. The tool copies the "reference" landmass into a new "merged" landmass. +4. For each "difference" landmass from a plugin, the tool merges it into the "merged" landmass. If mods do not overlap with their changes, the resulting terrain will perfectly match both mods' intended changes. If there _is_ overlap, the tool will attempt to resolve the conflicts in an intelligent manner. +5. The "merged" landmass is checked for seams and repaired if necessary. +6. The "merged" landmass is converted into the `TES3` format and saved as a plugin. + +## Limitations + +- The tool does NOT move entities within the cell. This may result in floating or buried objects. This may include grass from any grass mods, or similar landscape detailing. +- The tool does NOT perform magic. If one mod puts a hill in the exact same spot another mod tries to put a valley, the resulting land will likely be less than appealing. + +## Installation & Usage + +1. Create a folder for the tool's executable, e.g. `merged_lands_bin`. +2. Create a directory in that folder called `Conflicts`. +3. Place the executable in the `merged_lands_bin` folder. + +You should have a directory tree that looks like the following: + +``` +merged_lands_bin\ + merged_lands.exe + Conflicts\ +``` + +To run the tool, open a terminal (e.g. `cmd`) in the `merged_lands` directory and pass the path to your Morrowind `Data Files` directory with the `--data-files-dir` flag. + +```bash +# Example of running the tool +merged_lands_bin> .\merged_lands.exe --data-files-dir "C:\Program Files (x86)\Steam\steamapps\common\Morrowind\Data Files" +``` + +An example configuration for `MO2` is shown below. + +![example MO2 config](./docs/images/mo2_config.png) + +### Outputs + +By default, the tool will save the output `Merged Lands.esp` in the `Data Files` directory. + +This can be changed with the `--output-file-dir` and `--output-file` arguments. + +### Troubleshooting Merges + +The tool will save the log file to the `--merged-lands-dir`. This defaults to `.`, or "the current directory". + +The tool will save images to a folder `Conflicts` in the `--merged-lands-dir`. + +``` +merged_lands_bin\ + merged_lands.exe + merged_lands.log <-- Log file. + Conflicts\ + ... <-- Images of conflicts. +``` + +A conflict image shows `green` where changes were merged without any conflicts, whereas `yellow` means a minor conflict occurred, and `red` means a major conflict occurred. +In addition, the tool creates `MERGED` map showing the final result. + +**Note:** Each conflict image is created relative to a specific plugin. This makes it easier to understand how the final land differs from the expectation of each plugin. + +![conflict_image](./docs/images/conflict_images.png) + +In addition, the tool can be run with the `--add-debug-vertex-colors` switch to color the actual `LAND` records saved in the output file. +This feature can help with understanding where a conflict shown in the `Conflicts` folder actually exists in-game and the severity of it with respect to the world. + +![conflict_colors](./docs/images/conflict_vertex_colors.png) + +### Other Configuration + +Run the tool with `--help` to see a full list of supported arguments. + +## Supporting Patches + +The tool will automatically read `.mergedlands.toml` files from the `Data Files` directory. + +```bash +Data Files\ + Cantons_on_the_Global_Map_v1.1.esp + Cantons_on_the_Global_Map_v1.1.mergedlands.toml +``` + +These files are used to control the tool's behavior. + +### Example 1. `Cantons_on_the_Global_Map_v1.1.mergedlands.toml` + +This patch file would instruct the tool to ignore all changes made by the mod except for those related to `world_map_data`. +Then, for those changes only, the mod would resolve any conflicts with other mods by using the changes from `Cantons on the Global Map` instead. + +```toml +version = "0" +meta_type = "Patch" + +[height_map] +included = false + +[vertex_colors] +included = false + +[texture_indices] +included = false + +[world_map_data] +conflict_strategy = "Overwrite" +``` + +### Example 2. `BCOM_Suran Expansion.mergedlands.toml` + +The Beautiful Cities of Morrowind Suran Expansion mod should load after `BCoM`. It modifies the same land, and we would like to prefer the changes from Suran Expanson over the normal `BCoM` edits. We can set each field to `"Overwrite"`. + +```toml +version = "0" +meta_type = "Patch" + +[height_map] +conflict_strategy = "Overwrite" + +[vertex_colors] +conflict_strategy = "Overwrite" + +[texture_indices] +conflict_strategy = "Overwrite" + +[world_map_data] +conflict_strategy = "Overwrite" +``` + +The example conflict shown above in [Troubleshooting Merges](#troubleshooting-merges) is now fixed. + +![conflict_colors](./docs/images/conflict_vertex_colors_resolved.png) + +### Example 3. Ignoring Changes + +If we'd like a mod to load after another mod and _not_ try to merge changes where those mods conflict, we can use the `"Ignore"` setting. +For example, if we knew that some mod would overwrite texture changes from an earlier mod, and we wanted to prevent that, we could do the following: + +```toml +version = "0" +meta_type = "Patch" + +[texture_indices] +conflict_strategy = "Ignore" +``` + +### Defaults + +Each type of `LAND` record is `included = true` and `conflict_strategy = "Auto"` by default. `"Auto"` allows the tool to determine an "optimal" way to resolve conflicts -- whether that means merging, overwriting, or even ignoring the conflict. +You should not write a `.mergedlands.toml` file until it is known to be necessary. \ No newline at end of file diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..4a0dfc4 --- /dev/null +++ b/build.rs @@ -0,0 +1,3 @@ +fn main() -> shadow_rs::SdResult<()> { + shadow_rs::new() +} diff --git a/docs/images/conflict_images.png b/docs/images/conflict_images.png new file mode 100644 index 0000000..116d63b Binary files /dev/null and b/docs/images/conflict_images.png differ diff --git a/docs/images/conflict_vertex_colors.png b/docs/images/conflict_vertex_colors.png new file mode 100644 index 0000000..068c205 Binary files /dev/null and b/docs/images/conflict_vertex_colors.png differ diff --git a/docs/images/conflict_vertex_colors_resolved.png b/docs/images/conflict_vertex_colors_resolved.png new file mode 100644 index 0000000..cb1d55e Binary files /dev/null and b/docs/images/conflict_vertex_colors_resolved.png differ diff --git a/docs/images/mo2_config.png b/docs/images/mo2_config.png new file mode 100644 index 0000000..7a86dba Binary files /dev/null and b/docs/images/mo2_config.png differ diff --git a/src/io/parsed_plugins.rs b/src/io/parsed_plugins.rs index 0a0a266..4573a7c 100644 --- a/src/io/parsed_plugins.rs +++ b/src/io/parsed_plugins.rs @@ -1,3 +1,4 @@ +use crate::cli::SortOrder; use crate::io::meta_schema::{PluginMeta, VersionedPluginMeta}; use anyhow::{anyhow, bail, Context, Result}; use filetime::FileTime; @@ -15,11 +16,11 @@ use std::sync::Arc; use tes3::esp::{Cell, Header, Landscape, LandscapeTexture, Plugin, TES3Object}; /// Parse a [Plugin] named `plugin_name` from the `data_files` directory. -fn parse_records(data_files: &str, plugin_name: &str) -> Result { - ParsedPlugins::check_data_files(data_files) +fn parse_records(data_files: &Path, plugin_name: &str) -> Result { + ParsedPlugins::check_dir_exists(data_files) .with_context(|| anyhow!("Unable to find plugin {}", plugin_name))?; - let file_path: PathBuf = [data_files, plugin_name].iter().collect(); + let file_path: PathBuf = [data_files, Path::new(plugin_name)].iter().collect(); let mut plugin = Plugin::new(); plugin @@ -58,12 +59,20 @@ fn is_esm(path: &str) -> bool { } /// Sorts `plugin_list` by using the last modified date of the files in `data_files`. -pub fn sort_plugins(data_files: &str, plugin_list: &mut [String]) -> Result<()> { - ParsedPlugins::check_data_files(data_files) +pub fn sort_plugins( + data_files: &Path, + plugin_list: &mut [String], + sort_order: SortOrder, +) -> Result<()> { + if matches!(sort_order, SortOrder::None) { + return Ok(()); + } + + ParsedPlugins::check_dir_exists(data_files) .with_context(|| anyhow!("Unable to sort load order with last modified date"))?; for plugin_name in plugin_list.iter() { - let file_path: PathBuf = [data_files, plugin_name].iter().collect(); + let file_path: PathBuf = [data_files, Path::new(plugin_name)].iter().collect(); file_path .metadata() .map(|metadata| FileTime::from_last_modification_time(&metadata)) @@ -73,7 +82,7 @@ pub fn sort_plugins(data_files: &str, plugin_list: &mut [String]) -> Result<()> let order = |plugin_name: &str| { // Order by modified time, with ESMs given priority. let is_esm = is_esm(plugin_name); - let file_path: PathBuf = [data_files, plugin_name].iter().collect(); + let file_path: PathBuf = [data_files, Path::new(plugin_name)].iter().collect(); let last_modified_time = file_path .metadata() .map(|metadata| FileTime::from_last_modification_time(&metadata)) @@ -148,8 +157,8 @@ pub struct ParsedPlugins { /// Returns a [Vec] of plugin names by reading the `.ini` file located at /// `path`. Each plugin name is checked for existence in `data_files`. -fn read_ini_file(data_files: &str, path: &Path) -> Result> { - ParsedPlugins::check_data_files(data_files) +fn read_ini_file(data_files: &Path, path: &Path) -> Result> { + ParsedPlugins::check_dir_exists(data_files) .with_context(|| anyhow!("Unable to parse plugins from ini file"))?; let lines = read_lines(path).with_context(|| anyhow!("Unable to read Morrowind.ini"))?; @@ -185,13 +194,17 @@ fn read_ini_file(data_files: &str, path: &Path) -> Result> { .trim_start_matches(QUOTE_CHARS) .trim_end_matches(QUOTE_CHARS); - let file_path: PathBuf = [data_files, plugin_name].iter().collect(); + let file_path: PathBuf = [data_files, Path::new(plugin_name)].iter().collect(); match file_path.try_exists() { Ok(true) => all_plugins.push(plugin_name.to_string()), Ok(false) => error!( "{} {}", format!("Plugin {}", plugin_name.bold()).bright_red(), - format!("does not exist in `{}` directory", data_files).bright_red() + format!( + "does not exist in `{}` directory", + data_files.to_string_lossy() + ) + .bright_red() ), Err(e) => error!( "{} {}", @@ -210,13 +223,14 @@ fn read_ini_file(data_files: &str, path: &Path) -> Result> { impl ParsedPlugins { /// Helper function for returning an `Err` if the `data_files` does not exist /// or is otherwise inaccessible. - pub fn check_data_files(data_files: &str) -> Result<()> { - let exists = Path::new(data_files) + pub fn check_dir_exists(dir: impl AsRef) -> Result<()> { + let path = dir.as_ref(); + let exists = path .try_exists() - .with_context(|| anyhow!("Unable to find `{}` directory", data_files))?; + .with_context(|| anyhow!("Unable to find `{}` directory", path.to_string_lossy()))?; if !exists { - bail!("The `{}` directory does not exist", data_files); + bail!("The `{}` directory does not exist", path.to_string_lossy()); } Ok(()) @@ -225,8 +239,12 @@ impl ParsedPlugins { /// Creates a new [ParsedPlugins] from the `data_files` directory. /// If `plugin_names` is [None], then the `.ini` file will be read from /// the parent directory above `data_files` and used for the list instead. - pub fn new(data_files: &str, plugin_names: Option<&[&str]>) -> Result { - ParsedPlugins::check_data_files(data_files) + pub fn new( + data_files: &Path, + plugin_names: Option<&[String]>, + sort_order: SortOrder, + ) -> Result { + ParsedPlugins::check_dir_exists(data_files) .with_context(|| anyhow!("Unable to parse plugins"))?; let mut all_plugins = plugin_names @@ -244,7 +262,10 @@ impl ParsedPlugins { trace!("Parsing Morrowind.ini for plugins"); let parent_directory = Path::new(data_files).parent().with_context(|| { - anyhow!("Unable to find parent of `{}` directory", data_files) + anyhow!( + "Unable to find parent of `{}` directory", + data_files.to_string_lossy() + ) })?; let file_path: PathBuf = [parent_directory, Path::new("Morrowind.ini")] @@ -263,8 +284,7 @@ impl ParsedPlugins { }) .with_context(|| anyhow!("Unable to parse plugins"))?; - // TODO(dvd): #feature Control this via config file. - sort_plugins(data_files, &mut all_plugins) + sort_plugins(data_files, &mut all_plugins, sort_order) .with_context(|| anyhow!("Unknown load order for plugins"))?; let mut masters = Vec::new(); @@ -274,7 +294,8 @@ impl ParsedPlugins { match parse_records(data_files, &plugin_name) { Ok(records) => { let meta_name = meta_name(&plugin_name); - let meta_file_path: PathBuf = [data_files, &meta_name].iter().collect(); + let meta_file_path: PathBuf = + [data_files, Path::new(&meta_name)].iter().collect(); let data = fs::read_to_string(meta_file_path) .with_context(|| anyhow!("Failed to read meta file.")) diff --git a/src/io/save_to_plugin.rs b/src/io/save_to_plugin.rs index e7a481e..8aebb20 100644 --- a/src/io/save_to_plugin.rs +++ b/src/io/save_to_plugin.rs @@ -1,3 +1,4 @@ +use crate::cli::SortOrder; use crate::io::meta_schema::{MetaType, PluginMeta, VersionedPluginMeta}; use crate::io::parsed_plugins::{meta_name, sort_plugins, ParsedPlugin, ParsedPlugins}; use crate::land::conversions::convert_terrain_map; @@ -17,7 +18,7 @@ use log::{debug, trace, warn}; use owo_colors::OwoColorize; use std::default::default; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use tes3::esp::{ FixedString, Header, Landscape, LandscapeFlags, Plugin, TES3Object, TextureIndices, @@ -119,28 +120,24 @@ pub fn convert_landmass_diff_to_landmass( /// Creates a master record for plugin `name` by appending the size /// of the file in bytes to the tuple `(name, file_size)`. -fn to_master_record(data_files: &str, name: String) -> (String, u64) { - let merged_filepath: PathBuf = [data_files, &name].iter().collect(); +fn to_master_record(data_files: &Path, name: String) -> (String, u64) { + let merged_filepath: PathBuf = [data_files, Path::new(&name)].iter().collect(); let file_size = file_real_size(merged_filepath).unwrap_or(0); (name, file_size) } /// Saves the [Landmass] with [KnownTextures]. pub fn save_plugin( - data_files: &str, - name: &str, + data_files: &Path, + output_file_dir: &Path, + output_name: &str, + sort_order: SortOrder, landmass: &Landmass, known_textures: &KnownTextures, cells: &HashMap, ModifiedCell>, ) -> Result<()> { - ParsedPlugins::check_data_files(data_files) - .with_context(|| anyhow!("Unable to save file {}", name))?; - - let merged_filepath: PathBuf = [data_files, name].iter().collect(); - let last_modified_time = merged_filepath - .metadata() - .map(|metadata| FileTime::from_last_modification_time(&metadata)) - .unwrap_or_else(|_| FileTime::now()); + ParsedPlugins::check_dir_exists(output_file_dir) + .with_context(|| anyhow!("Unable to save file {}", output_name))?; let mut plugin = Plugin::new(); @@ -189,8 +186,8 @@ pub fn save_plugin( let mut masters = dependencies.drain().collect_vec(); - sort_plugins(data_files, &mut masters) - .with_context(|| anyhow!("Unknown load order for {} dependencies", name))?; + sort_plugins(data_files, &mut masters, sort_order) + .with_context(|| anyhow!("Unknown load order for {} dependencies", output_name))?; Some( masters @@ -255,8 +252,8 @@ pub fn save_plugin( plugin.objects.push(TES3Object::Landscape(land.clone())); } - let meta_name = meta_name(name); - let merged_meta: PathBuf = [data_files, &meta_name].iter().collect(); + let meta_name = meta_name(output_name); + let merged_meta: PathBuf = [output_file_dir, Path::new(&meta_name)].iter().collect(); let meta = VersionedPluginMeta::V0(PluginMeta { meta_type: MetaType::MergedLands, @@ -270,16 +267,22 @@ pub fn save_plugin( fs::write(merged_meta, toml::to_string(&meta).expect("safe")) .with_context(|| anyhow!("Unable to save plugin meta {}", meta_name))?; - trace!("Saving file {}", name); + let merged_filepath: PathBuf = [output_file_dir, Path::new(output_name)].iter().collect(); + let last_modified_time = merged_filepath + .metadata() + .map(|metadata| FileTime::from_last_modification_time(&metadata)) + .unwrap_or_else(|_| FileTime::now()); + + trace!("Saving file {}", output_name); plugin .save_path(&merged_filepath) - .with_context(|| anyhow!("Unable to save plugin {}", name))?; + .with_context(|| anyhow!("Unable to save plugin {}", output_name))?; trace!(" - Description: {}", description); - trace!("Updating last modified time on {}", name); + trace!("Updating last modified time on {}", output_name); filetime::set_file_mtime(merged_filepath, last_modified_time) - .with_context(|| anyhow!("Unable to set last modified date on plugin {}", name))?; + .with_context(|| anyhow!("Unable to set last modified date on plugin {}", output_name))?; Ok(()) } diff --git a/src/main.rs b/src/main.rs index a3197dd..a3e186f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,11 +6,6 @@ #![feature(map_many_mut)] #![feature(const_for)] -mod io; -mod land; -mod merge; -mod repair; - use crate::io::meta_schema::MetaType; use crate::io::parsed_plugins::{ParsedPlugin, ParsedPlugins}; use crate::io::save_to_image::save_landmass_images; @@ -25,8 +20,7 @@ use crate::merge::relative_terrain_map::{IsModified, RelativeTerrainMap}; use crate::repair::cleaning::{clean_known_textures, clean_landmass_diff}; use crate::repair::debugging::add_debug_vertex_colors_to_landmass; use crate::repair::seam_detection::repair_landmass_seams; -use anyhow::Result; -use clap::Command; +use anyhow::{anyhow, Context, Result}; use hashbrown::HashMap; use itertools::Itertools; use log::{debug, error, info, trace, warn}; @@ -44,6 +38,11 @@ use std::sync::Arc; use std::time::Instant; use tes3::esp::{Landscape, LandscapeFlags, LandscapeTexture, ObjectFlags}; +mod io; +mod land; +mod merge; +mod repair; + #[global_allocator] static GLOBAL: MiMalloc = MiMalloc; @@ -94,21 +93,158 @@ impl LandmassDiff { } } +mod cli { + use crate::ParsedPlugins; + use anyhow::{anyhow, Context, Result}; + use clap::{AppSettings, ArgEnum, Parser}; + use log::LevelFilter; + use shadow_rs::shadow; + use std::path::PathBuf; + + shadow!(build); + + #[derive(Copy, PartialEq, Eq, Debug, Hash, Clone, ArgEnum)] + pub enum CliLevelFilter { + Off, + Error, + Warn, + Info, + Debug, + Trace, + } + + #[derive(Copy, PartialEq, Eq, Debug, Hash, Clone, ArgEnum)] + pub enum SortOrder { + Default, + None, + } + + impl From for LevelFilter { + fn from(v: CliLevelFilter) -> Self { + match v { + CliLevelFilter::Off => LevelFilter::Off, + CliLevelFilter::Error => LevelFilter::Error, + CliLevelFilter::Warn => LevelFilter::Warn, + CliLevelFilter::Info => LevelFilter::Info, + CliLevelFilter::Debug => LevelFilter::Debug, + CliLevelFilter::Trace => LevelFilter::Trace, + } + } + } + + #[derive(Parser, Debug)] + #[clap(author = "DVD")] + #[clap(about = "Merges lands.")] + #[clap(version = build::CLAP_LONG_VERSION)] + #[clap(long_about = None)] // Read from `Cargo.toml` + #[clap(global_setting(AppSettings::DeriveDisplayOrder))] + pub struct Cli { + #[clap(long, value_parser, default_value_t = String::from("."))] + /// The directory containing the `Conflicts` folder. + /// This is also where the `log_file` will be stored. + merged_lands_dir: String, + + #[clap(long, value_parser, default_value_t = String::from("Data Files"))] + /// The absolute or relative path to the `Data Files` folder containing plugins. + data_files_dir: String, + + #[clap(long, value_parser, default_value_t = String::from("Merged Lands.esp"))] + /// The name of the output file. This will be written to `output_file_dir`. + pub output_file: String, + + #[clap(long, value_parser)] + /// The directory for the `output_file`. + /// If not provided, this is the same as `data_files_dir`. + output_file_dir: Option, + + #[clap(value_parser, required = false)] + /// An ordered list of plugins. + /// If this is not provided, the tool will look for an `.ini` file + /// in the directory above the `Data Files` and parse that for plugins. + input_file_names: Vec, + + #[clap(long, arg_enum, value_parser, default_value_t = SortOrder::Default)] + /// The method of sorting plugins. + /// `none` is only valid if `input_file_names` are provided. + pub sort_order: SortOrder, + + #[clap(long, value_parser, default_value_t = String::from("merged_lands.log"))] + /// The name of the log file. This will be written to `merged_lands_dir`. + pub log_file: String, + + #[clap(long, arg_enum, value_parser, default_value_t = CliLevelFilter::Debug)] + /// The level of logging. + /// If set to Off, no log will will be written. + pub log_level: CliLevelFilter, + + #[clap(long, value_parser, default_value_t = 8)] + /// The size of the application's stack in MB. + stack_size_mb: u8, + + #[clap(long, value_parser)] + /// The application will color the LAND vertex colors to show conflicts. + pub add_debug_vertex_colors: bool, + + #[clap(long, value_parser)] + /// The application will wait for the user to hit the ENTER key before closing. + pub wait_for_exit: bool, + } + + impl Cli { + pub fn read_args() -> Cli { + let args = wild::args(); + Cli::parse_from(args) + } + + pub fn plugins(&self) -> Option<&[String]> { + (!self.input_file_names.is_empty()).then_some(&self.input_file_names) + } + + pub fn should_write_log_file(&self) -> bool { + self.log_level != CliLevelFilter::Off + } + + pub fn merged_lands_dir(&self) -> Result { + let dir = &self.merged_lands_dir; + Ok(PathBuf::from(dir)) + } + + pub fn data_files_dir(&self) -> Result { + let dir = &self.data_files_dir; + ParsedPlugins::check_dir_exists(dir) + .with_context(|| anyhow!("Invalid `Data Files` directory"))?; + Ok(PathBuf::from(dir)) + } + + pub fn output_file_dir(&self) -> Result { + let dir = self + .output_file_dir + .as_ref() + .unwrap_or(&self.data_files_dir); + ParsedPlugins::check_dir_exists(dir) + .with_context(|| anyhow!("Invalid output file directory"))?; + Ok(PathBuf::from(dir)) + } + + pub fn stack_size(&self) -> usize { + (self.stack_size_mb as usize) * 1024 * 1024 + } + } +} + +use cli::Cli; + /// Handles CLI arguments, log initialization, and the creation of a worker thread /// for running the actual [merge_all] function. fn main() -> Result<()> { - Command::new("merged_lands").get_matches(); - - let merged_lands_dir = PathBuf::from("Merged Lands"); - let log_file_name = "merged_lands.log"; - - let has_log_file = init_log(&merged_lands_dir, Some(log_file_name)); + let cli = Cli::read_args(); + let wait_for_exit = cli.wait_for_exit; - const STACK_SIZE: usize = 8 * 1024 * 1024; + init_log(&cli); let work_thread = std::thread::Builder::new() - .stack_size(STACK_SIZE) - .spawn(|| merge_all(merged_lands_dir)) + .stack_size(cli.stack_size()) + .spawn(move || merge_all(&cli)) .expect("unable to create worker thread"); if let Err(e) = work_thread.join().expect("unable to join worker thread") { @@ -117,16 +253,16 @@ fn main() -> Result<()> { format!("An unexpected error occurred: {:?}", e.bold()).bright_red() ); - wait_for_user_exit(has_log_file); + wait_for_user_exit(wait_for_exit); exit(1); } - wait_for_user_exit(has_log_file); + wait_for_user_exit(wait_for_exit); Ok(()) } -fn wait_for_user_exit(has_log_file: bool) { - if has_log_file { +fn wait_for_user_exit(wait_for_exit: bool) { + if !wait_for_exit { return; } @@ -137,13 +273,11 @@ fn wait_for_user_exit(has_log_file: bool) { } /// The main function. -fn merge_all(merged_lands_dir: PathBuf) -> Result<()> { +fn merge_all(cli: &Cli) -> Result<()> { let start = Instant::now(); let mut known_textures = KnownTextures::new(); - // TODO(dvd): #mvp Read CLI args, or config args. - // STEP 1: // For each Plugin, ordered by last modified: // - Get or create reference landmass. @@ -163,11 +297,9 @@ fn merge_all(merged_lands_dir: PathBuf) -> Result<()> { // optional `.mergedlands.toml` if it existed. The Arc<...> is copied into each LandscapeDiff. info!(":: Parsing Plugins ::"); - let data_files = "Data Files"; - let file_name = "Merged Lands.esp"; - let plugin_names = None; - - let parsed_plugins = ParsedPlugins::new(data_files, plugin_names)?; + let data_files = cli.data_files_dir()?; + let plugin_names = cli.plugins(); + let parsed_plugins = ParsedPlugins::new(&data_files, plugin_names, cli.sort_order)?; let reference_landmass = Arc::new(create_tes3_landmass( "ReferenceLandmass.esp", @@ -235,12 +367,12 @@ fn merge_all(merged_lands_dir: PathBuf) -> Result<()> { // - Produce images of the final merge results. info!(":: Summarizing Conflicts ::"); + let merged_lands_dir = cli.merged_lands_dir()?; for modded_landmass in modded_landmasses.iter() { save_landmass_images(&merged_lands_dir, &merged_lands, modded_landmass); } - // TODO(dvd): #mvp Read from config. - let debug_vertex_colors = true; + let debug_vertex_colors = cli.add_debug_vertex_colors; if debug_vertex_colors { warn!(":: Adding Debug Colors ::"); for modded_landmass in modded_landmasses.iter() { @@ -282,7 +414,18 @@ fn merge_all(merged_lands_dir: PathBuf) -> Result<()> { info!(":: Saving ::"); let cells = merge_cells(&parsed_plugins); - save_plugin(data_files, file_name, &landmass, &known_textures, &cells)?; + + let output_file_dir = cli.output_file_dir()?; + let file_name = &cli.output_file; + save_plugin( + &data_files, + &output_file_dir, + file_name, + cli.sort_order, + &landmass, + &known_textures, + &cells, + )?; info!(":: Finished ::"); info!("Time Elapsed: {:?}", Instant::now().duration_since(start)); @@ -292,7 +435,7 @@ fn merge_all(merged_lands_dir: PathBuf) -> Result<()> { /// Initializes a [TermLogger] and [WriteLogger]. If the [WriteLogger] cannot be initialized, /// then the program will continue with only the [TermLogger]. -fn init_log(merged_lands_dir: &PathBuf, log_file_name: Option<&str>) -> bool { +fn init_log(cli: &Cli) -> bool { let config = ConfigBuilder::default() .set_time_level(LevelFilter::Off) .set_thread_level(LevelFilter::Off) @@ -301,16 +444,30 @@ fn init_log(merged_lands_dir: &PathBuf, log_file_name: Option<&str>) -> bool { .set_level_padding(LevelPadding::Right) .build(); - let log_file_path: Option = - log_file_name.map(|name| [merged_lands_dir, &PathBuf::from(name)].iter().collect()); - - let write_logger = log_file_path.as_ref().map(|file_path| { - File::create(file_path) - .map(|file| WriteLogger::new(LevelFilter::Trace, config.clone(), file)) + let get_log_file_path = || { + let merged_lands_dir = cli.merged_lands_dir(); + let log_file_name = &cli.log_file; + let log_file_path: Result = match merged_lands_dir { + Ok(path) => Ok([path, PathBuf::from(log_file_name)].iter().collect()), + Err(e) => Err(e), + }; + log_file_path + }; + + let write_logger = cli.should_write_log_file().then(|| { + let log_file_path = get_log_file_path()?; + File::create(&log_file_path) + .map(|file| WriteLogger::new(cli.log_level.into(), config.clone(), file)) + .with_context(|| { + anyhow!( + "Unable to create log file at {}", + log_file_path.to_string_lossy() + ) + }) }); let term_logger = TermLogger::new( - LevelFilter::Trace, + LevelFilter::Debug, config, TerminalMode::Mixed, ColorChoice::Auto, @@ -321,7 +478,7 @@ fn init_log(merged_lands_dir: &PathBuf, log_file_name: Option<&str>) -> bool { CombinedLogger::init(vec![term_logger, write_logger]).expect("safe"); trace!( "Log file will be saved to {}", - log_file_path.expect("safe").to_string_lossy() + get_log_file_path().expect("safe").to_string_lossy() ); true @@ -332,7 +489,10 @@ fn init_log(merged_lands_dir: &PathBuf, log_file_name: Option<&str>) -> bool { "{} {}", format!( "Failed to create log file at {}", - log_file_path.expect("safe").to_string_lossy().bold() + get_log_file_path() + .unwrap_or_else(|_| PathBuf::from(&cli.log_file)) + .to_string_lossy() + .bold() ) .bright_red(), format!("due to: {:?}", e.bold()).bright_red()