From eb942ac78fb8a42bc514d51b9caa230c9f0ed1b1 Mon Sep 17 00:00:00 2001 From: Gary Tierney Date: Sun, 9 Aug 2026 01:58:56 +0100 Subject: [PATCH] feat(cli): Conversion of modengine2.toml files to profiles Signed-off-by: Gary Tierney --- crates/cli/src/commands/profile.rs | 47 ++++- crates/cli/src/commands/profile/modengine2.rs | 168 ++++++++++++++++++ crates/cli/src/main.rs | 1 + crates/mod-protocol/src/lib.rs | 24 ++- 4 files changed, 230 insertions(+), 10 deletions(-) create mode 100644 crates/cli/src/commands/profile/modengine2.rs diff --git a/crates/cli/src/commands/profile.rs b/crates/cli/src/commands/profile.rs index 3ae3ed82..ff0c42b8 100644 --- a/crates/cli/src/commands/profile.rs +++ b/crates/cli/src/commands/profile.rs @@ -1,3 +1,5 @@ +mod modengine2; + use std::{fs, path::PathBuf}; use clap::{ArgAction, Args, Subcommand}; @@ -11,7 +13,15 @@ use me3_mod_protocol::{ }; use tracing::error; -use crate::{config::Config, db::DbContext, output::OutputBuilder, Game}; +use crate::{ + commands::profile::modengine2::ModEngine2Config, config::Config, db::DbContext, + output::OutputBuilder, Game, +}; + +#[derive(Copy, Clone, Debug, clap::ValueEnum)] +pub enum ProfileConvertFormat { + ModEngine2, +} #[derive(Subcommand, Debug)] #[command(flatten_help = true)] @@ -19,6 +29,9 @@ pub enum ProfileCommands { /// Create a new ModProfile. Create(ProfileCreateArgs), + /// Convert an alternative mod loader configuration file to a me3 ModProfile. + Convert(ProfileConvertArgs), + /// List profiles in the profile dir. #[clap(disable_help_flag = true)] List, @@ -27,6 +40,28 @@ pub enum ProfileCommands { Show(#[clap(flatten)] ProfileNameArgs), } +#[derive(Args, Debug)] +pub struct ProfileConvertArgs { + /// A format identifier of the mod loader, currently only "modengine2" is supported. + format: ProfileConvertFormat, + + /// Game to associate with this profile for one-click launches, if it cannot be inferred from the configuration file. + #[clap( + short('g'), + long, + hide_possible_values = false, + help_heading = "Game selection" + )] + #[arg(value_enum)] + game: Option, + + /// Path to the configuration file to read. + input: PathBuf, + + /// Output path to the me3 profile to be written. + output: PathBuf, +} + #[derive(Args, Debug)] pub struct ProfileCreateArgs { #[clap(flatten)] @@ -219,6 +254,16 @@ pub fn create(config: Config, args: ProfileCreateArgs) -> color_eyre::Result<()> Ok(()) } +#[tracing::instrument(err, skip_all)] +pub fn convert(args: ProfileConvertArgs) -> color_eyre::Result<()> { + let config = std::fs::read_to_string(args.input)?; + let me2: ModEngine2Config = toml::from_str(&config)?; + let me3_toml = toml::to_string_pretty(&me2.into_mod_profile())?; + std::fs::write(args.output, me3_toml)?; + + Ok(()) +} + #[tracing::instrument(err, skip_all)] pub fn show(db: DbContext, config: Config, name: ProfileNameArgs) -> color_eyre::Result<()> { let profile_path = name.into_profile_path(&config)?; diff --git a/crates/cli/src/commands/profile/modengine2.rs b/crates/cli/src/commands/profile/modengine2.rs new file mode 100644 index 00000000..a2c970ea --- /dev/null +++ b/crates/cli/src/commands/profile/modengine2.rs @@ -0,0 +1,168 @@ +use std::{ffi::OsStr, path::PathBuf}; + +use me3_mod_protocol::{native::Native, package::Package, ModProfile, ModProfileV1}; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct ModEngine2Mod { + pub name: String, + pub path: PathBuf, + pub enabled: Option, +} + +#[derive(Default, Deserialize)] +pub struct ModEngine2Global { + #[serde(default)] + pub external_dlls: Vec, +} + +#[derive(Default, Deserialize)] +pub struct ModEngine2ModLoader { + #[serde(default)] + pub mods: Vec, +} + +#[derive(Default, Deserialize)] +pub struct ModEngine2ScyllaHide { + #[serde(default)] + pub enabled: bool, +} + +#[derive(Default, Deserialize)] +pub struct ModEngine2Extensions { + #[serde(default)] + pub mod_loader: ModEngine2ModLoader, + + #[serde(default)] + pub scylla_hide: ModEngine2ScyllaHide, +} + +#[derive(Default, Deserialize)] +pub struct ModEngine2Config { + #[serde(default)] + pub modengine: ModEngine2Global, + #[serde(default)] + pub extension: ModEngine2Extensions, +} + +impl ModEngine2Config { + pub fn into_mod_profile(self) -> ModProfile { + let mut profile = ModProfileV1::default(); + + // Loose heuristic to make sure SC gets load_early = true + fn is_seamless_coop(file_name: &OsStr) -> bool { + file_name == "ersc.dll" || file_name == "nrsc.dll" + } + + for external_dll in self.modengine.external_dlls { + let native_name = external_dll.file_name().unwrap(); + let load_early = is_seamless_coop(native_name); + let mut native = Native::new(external_dll); + native.load_early = load_early; + + profile.natives.push(native); + } + + for me2_mod in self.extension.mod_loader.mods { + let mut package = Package::new(me2_mod.path.clone()).with_id(me2_mod.name); + package.enabled = me2_mod.enabled.unwrap_or(true); + + profile.packages.push(package); + } + + if self.extension.scylla_hide.enabled { + profile.disable_arxan = Some(true); + } + + profile.into() + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use me3_mod_protocol::{dependency::Dependency, package::WithPackageSource}; + + use super::*; + + #[test] + fn default_config() { + const DEFAULT_CONFIG: &str = r#" + [modengine] + debug = false + external_dlls = [] + + [extension.mod_loader] + enabled = true + loose_params = false + mods = [ + { enabled = true, name = "default", path = "mod" } + ] + + [extension.scylla_hide] + enabled = false + "#; + + let me2: ModEngine2Config = toml::from_str(DEFAULT_CONFIG).unwrap(); + let profile = me2.into_mod_profile(); + assert_eq!(profile.disable_arxan(), None); + + let packages = profile.packages(); + assert_eq!(packages.len(), 1); + assert_eq!(packages[0].id(), "default"); + assert_eq!(packages[0].source().as_path(), Path::new("mod")); + assert!(packages[0].enabled); + } + + #[test] + fn example_config() { + const EXAMPLE_CONFIG: &str = r#" + [modengine] + debug = false + external_dlls = [ + "mods\\SeamlessCoop\\ersc.dll", + "elden_ring_practice_tool.dll", + ] + + [extension.mod_loader] + enabled = true + loose_params = false + mods = [ + { enabled = true, name = "convergence", path = "mods\\convergence" }, + { enabled = false, name = "clever", path = "mods\\clever" }, + ] + + [extension.scylla_hide] + enabled = true + "#; + + let me2: ModEngine2Config = toml::from_str(EXAMPLE_CONFIG).unwrap(); + let profile = me2.into_mod_profile(); + assert_eq!(profile.disable_arxan(), Some(true)); + + let packages = profile.packages(); + assert_eq!(packages.len(), 2); + assert_eq!(packages[0].id(), "convergence"); + assert_eq!( + packages[0].source().as_path(), + Path::new("mods\\convergence") + ); + assert!(packages[0].enabled); + assert_eq!(packages[1].id(), "clever"); + assert!(!packages[1].enabled); + + let natives = profile.natives(); + assert_eq!(natives.len(), 2); + assert_eq!( + natives[0].path.as_path(), + Path::new("mods\\SeamlessCoop\\ersc.dll") + ); + assert!(natives[0].load_early); + assert_eq!( + natives[1].path.as_path(), + Path::new("elden_ring_practice_tool.dll") + ); + assert!(natives.iter().all(|native| native.enabled)); + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index ad109708..f8b61590 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -143,6 +143,7 @@ fn main() { commands::launch::launch(db, config, args, tmp_log_file_path.clone()) } Commands::Profile(ProfileCommands::Create(args)) => commands::profile::create(config, args), + Commands::Profile(ProfileCommands::Convert(args)) => commands::profile::convert(args), Commands::Profile(ProfileCommands::List) => commands::profile::list(db), Commands::Profile(ProfileCommands::Show(name)) => commands::profile::show(db, config, name), #[cfg(target_os = "windows")] diff --git a/crates/mod-protocol/src/lib.rs b/crates/mod-protocol/src/lib.rs index dc1f0f74..468ef34a 100644 --- a/crates/mod-protocol/src/lib.rs +++ b/crates/mod-protocol/src/lib.rs @@ -136,48 +136,54 @@ impl ModProfile { } } +impl Into for ModProfileV1 { + fn into(self) -> ModProfile { + ModProfile::V1(self) + } +} + #[derive(Debug, Default, Deserialize, Serialize, JsonSchema)] pub struct ModProfileV1 { /// The games that this profile supports. #[serde(default)] - supports: Vec, + pub supports: Vec, /// Native modules (DLLs) that will be loaded. #[serde(default)] #[serde(alias = "native")] - natives: Vec, + pub natives: Vec, /// A collection of packages containing assets that should be considered for loading /// before the DVDBND. #[serde(default)] #[serde(alias = "package")] - packages: Vec, + pub packages: Vec, /// Name of an alternative savefile to use (in the default savefile directory). #[serde(default)] - savefile: Option, + pub savefile: Option, /// Starts the game with multiplayer server connectivity enabled. #[serde(default)] - start_online: Option, + pub start_online: Option, /// Try to neutralize Arxan GuardIT code protection to improve mod stability. #[serde(default)] - disable_arxan: Option, + pub disable_arxan: Option, /// Patch memory limits for supported games to improve mod stability. #[serde(default)] #[serde(alias = "patch_mem")] - mem_patch: Option, + pub mem_patch: Option, /// Override how many megabytes of memory the supported game should allocate /// (with `mem_patch = true`). #[serde(default)] - mem_patch_heap_size: Option, + pub mem_patch_heap_size: Option, /// Debug game property overrides. #[serde(default)] - debug_properties: DebugProperties, + pub debug_properties: DebugProperties, } #[cfg(test)]