From 0202847b0c860b0c6b24975acfcc7feb5ea11cb7 Mon Sep 17 00:00:00 2001 From: Dasaav-dsv Date: Fri, 29 Aug 2025 03:09:09 +0200 Subject: [PATCH 01/20] feat: Profile version 2 support with simplified syntax Signed-off-by: Dasaav-dsv --- Cargo.lock | 4 +- Cargo.toml | 11 +- crates/cli/src/commands/launch.rs | 121 ++-- crates/cli/src/commands/profile.rs | 134 ++-- crates/cli/src/db/profile.rs | 77 ++- crates/cli/src/main.rs | 5 +- crates/launcher-attach-protocol/Cargo.toml | 1 - crates/launcher-attach-protocol/src/lib.rs | 4 +- crates/launcher/Cargo.toml | 1 + crates/launcher/src/game.rs | 63 +- crates/launcher/src/main.rs | 13 +- crates/mod-host-assets/src/mapping.rs | 53 +- crates/mod-host-assets/src/wwise.rs | 6 +- crates/mod-host/src/asset_hooks.rs | 4 +- crates/mod-host/src/filesystem.rs | 12 +- crates/mod-host/src/host.rs | 122 ++-- crates/mod-host/src/lib.rs | 75 ++- crates/mod-host/src/native.rs | 2 +- crates/mod-protocol/Cargo.toml | 4 +- crates/mod-protocol/src/bin/schema.rs | 2 +- crates/mod-protocol/src/lib.rs | 186 ++---- crates/mod-protocol/src/mod_file.rs | 105 +++ crates/mod-protocol/src/native.rs | 126 ++-- crates/mod-protocol/src/package.rs | 132 ++-- crates/mod-protocol/src/profile.rs | 172 +++++ crates/mod-protocol/src/profile/builder.rs | 97 +++ crates/mod-protocol/src/profile/v1.rs | 190 ++++++ crates/mod-protocol/src/profile/v2.rs | 277 ++++++++ .../test-data/basic_config.me3.toml.expected | 34 - .../test-data/plural_packages.me3.expected | 22 - .../test-data/singular_package.me3.expected | 22 - .../test-data/v1/advanced_config.me3 | 34 + .../test-data/v1/advanced_config.me3.expected | 78 +++ .../basic_config.me3} | 2 +- .../test-data/v1/basic_config.me3.expected | 29 + .../test-data/{ => v1}/plural_packages.me3 | 0 .../test-data/v1/plural_packages.me3.expected | 19 + .../test-data/{ => v1}/singular_package.me3 | 0 .../v1/singular_package.me3.expected | 19 + .../test-data/v2/advanced_config.me3 | 16 + .../test-data/v2/advanced_config.me3.expected | 84 +++ .../test-data/v2/basic_config.me3 | 6 + .../test-data/v2/basic_config.me3.expected | 37 ++ .../test-data/v2/merge_config.me3.expected | 69 ++ .../test-data/v2/merge_config_a.me3 | 12 + .../test-data/v2/merge_config_b.me3 | 12 + installer.nsi | 8 +- schemas/mod-profile.json | 611 +++++++++++------- 48 files changed, 2272 insertions(+), 841 deletions(-) create mode 100644 crates/mod-protocol/src/mod_file.rs create mode 100644 crates/mod-protocol/src/profile.rs create mode 100644 crates/mod-protocol/src/profile/builder.rs create mode 100644 crates/mod-protocol/src/profile/v1.rs create mode 100644 crates/mod-protocol/src/profile/v2.rs delete mode 100644 crates/mod-protocol/test-data/basic_config.me3.toml.expected delete mode 100644 crates/mod-protocol/test-data/plural_packages.me3.expected delete mode 100644 crates/mod-protocol/test-data/singular_package.me3.expected create mode 100644 crates/mod-protocol/test-data/v1/advanced_config.me3 create mode 100644 crates/mod-protocol/test-data/v1/advanced_config.me3.expected rename crates/mod-protocol/test-data/{basic_config.me3.toml => v1/basic_config.me3} (85%) create mode 100644 crates/mod-protocol/test-data/v1/basic_config.me3.expected rename crates/mod-protocol/test-data/{ => v1}/plural_packages.me3 (100%) create mode 100644 crates/mod-protocol/test-data/v1/plural_packages.me3.expected rename crates/mod-protocol/test-data/{ => v1}/singular_package.me3 (100%) create mode 100644 crates/mod-protocol/test-data/v1/singular_package.me3.expected create mode 100644 crates/mod-protocol/test-data/v2/advanced_config.me3 create mode 100644 crates/mod-protocol/test-data/v2/advanced_config.me3.expected create mode 100644 crates/mod-protocol/test-data/v2/basic_config.me3 create mode 100644 crates/mod-protocol/test-data/v2/basic_config.me3.expected create mode 100644 crates/mod-protocol/test-data/v2/merge_config.me3.expected create mode 100644 crates/mod-protocol/test-data/v2/merge_config_a.me3 create mode 100644 crates/mod-protocol/test-data/v2/merge_config_b.me3 diff --git a/Cargo.lock b/Cargo.lock index 3b87e6ed..d44b459b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1353,6 +1353,7 @@ checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" dependencies = [ "equivalent", "hashbrown", + "serde", ] [[package]] @@ -1670,6 +1671,7 @@ dependencies = [ "me3_telemetry", "minidump-writer", "sentry", + "serde_json", "toml 0.9.5", "tracing", "windows", @@ -1684,7 +1686,6 @@ dependencies = [ "eyre", "me3-mod-protocol", "serde", - "serde_derive", ] [[package]] @@ -2736,6 +2737,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" dependencies = [ "dyn-clone", + "indexmap", "ref-cast", "schemars_derive", "serde", diff --git a/Cargo.toml b/Cargo.toml index e4a667ae..1a5b8e8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,11 @@ crash-context = "0.6" crash-handler = "0.6" ctrlc = "3" directories = "6" -dll-syringe = { version = "0.16", default-features = false, features = ["syringe", "rpc"] } +dll-syringe = { version = "0.16", default-features = false, features = [ + "syringe", + "rpc-raw", + "process-memory", +] } expect-test = "1" eyre = { version = "0.6", default-features = false } from-singleton = { version = "2", features = ["regex-unicode"] } @@ -61,9 +65,8 @@ regex = "1" rdvec = "0.2.1" schemars = "1.0" sentry = { version = "0.40", default-features = false } -serde = "1" -serde_derive = "1" -serde_json = "1" +serde = { version = "1.0.219", features = ["derive"] } +serde_json = { version = "1.0.143", features = ["preserve_order"] } steamlocate = "2" strum = "0.27" strum_macros = "0.27" diff --git a/crates/cli/src/commands/launch.rs b/crates/cli/src/commands/launch.rs index 76986fd2..684fd000 100644 --- a/crates/cli/src/commands/launch.rs +++ b/crates/cli/src/commands/launch.rs @@ -18,15 +18,15 @@ use clap::{ builder::{BoolValueParser, MapValueParser, TypedValueParser}, ArgAction, Args, }; -use color_eyre::eyre::{eyre, OptionExt}; +use color_eyre::eyre::{eyre, Context, OptionExt}; use me3_env::{CommandExt, LauncherVars, TelemetryVars}; use me3_launcher_attach_protocol::AttachConfig; -use me3_mod_protocol::{native::Native, package::Package}; +use me3_mod_protocol::profile::builder::ModProfileBuilder; use normpath::PathExt; use serde::{Deserialize, Serialize}; use steamlocate::{CompatTool, Library, SteamDir}; use tempfile::NamedTempFile; -use tracing::{error, info}; +use tracing::{error, info, warn}; use crate::{ commands::{launch::proton::CompatTools, profile::ProfileOptions}, @@ -146,8 +146,19 @@ pub struct LaunchArgs { )] profile: Option, - /// Path to package directory (asset override mod) [repeatable option] - #[arg( + /// Path to a native DLL, package, file or a profile to use [repeatable option] + #[clap( + short('u'), + long("use"), + action = clap::ArgAction::Append, + help_heading = "Mod configuration", + value_hint = clap::ValueHint::AnyPath, + )] + uses: Vec, + + /// (DEPRECATED, use "-u") /// Path to package directory (asset override mod) [repeatable option] + #[deprecated] + #[clap( long("package"), action = clap::ArgAction::Append, help_heading = "Mod configuration", @@ -155,8 +166,9 @@ pub struct LaunchArgs { )] packages: Vec, - /// Path to DLL file (native DLL mod) [repeatable option] - #[arg( + /// (DEPRECATED, use "-u") Path to DLL file (native DLL mod) [repeatable option] + #[deprecated] + #[clap( short('n'), long("native"), action = clap::ArgAction::Append, @@ -166,7 +178,7 @@ pub struct LaunchArgs { natives: Vec, /// Name of an alternative savefile to use (in the default savefile directory). - #[arg(long("savefile"), help_heading = "Mod configuration")] + #[clap(long("savefile"), help_heading = "Mod configuration")] savefile: Option, } @@ -272,23 +284,50 @@ impl LaunchArgs { Profile::transient() }; - let target_selector = self.target_selector.as_ref().unwrap_or(&Selector { - auto_detect: true, - game: None, - steam_id: None, - }); - - let game = if target_selector.auto_detect { - profile - .supported_game() - .map(crate::Game) - .ok_or_eyre("unable to determine which game to launch") - } else { - target_selector - .game - .or_else(|| target_selector.steam_id.and_then(Game::from_app_id)) - .ok_or_eyre("unable to determine game from name or app ID") - }?; + #[allow(deprecated)] + if !self.natives.is_empty() { + warn!("option \"--native\" is deprecated, use \"--use\" instead!"); + } + + #[allow(deprecated)] + if !self.packages.is_empty() { + warn!("option \"--package\" is deprecated, use \"--use\" instead!"); + } + + let game_from_args = self + .target_selector + .as_ref() + .and_then(|s| s.game.or_else(|| s.steam_id.and_then(Game::from_app_id))) + .map(Into::into); + + #[allow(deprecated)] + let uses_args = self.uses.iter().chain(&self.natives).chain(&self.packages); + + for path in uses_args.clone() { + if !path.exists() { + return Err(eyre!("{path:?} does not exist")); + } + } + + let profile_from_args = ModProfileBuilder::new() + .with_supported_game(game_from_args) + .with_paths(uses_args.cloned()) + .with_savefile(self.savefile.clone()) + .start_online(self.profile_options.start_online) + .disable_arxan(self.profile_options.disable_arxan) + .build(); + + let profile = profile.try_merge(&profile_from_args).wrap_err_with(|| { + eyre!( + "game ({game_from_args:?}) is not supported by profile ({:?})", + profile.supported_game() + ) + })?; + + let game = profile + .supported_game() + .map(Game) + .ok_or_eyre("unable to determine which game to launch")?; let game_options = config .options @@ -327,33 +366,9 @@ impl LaunchArgs { profile_options: &ProfileOptions, cache_path: Option>, ) -> color_eyre::Result { - for path in self.natives.iter().chain(&self.packages) { - if !path.exists() { - return Err(eyre!("{path:?} does not exist")); - } - } - - let mut packages = self - .packages - .iter() - .filter_map(|path| path.normalize().ok()) - .map(|normalized| Package::new(normalized.into_path_buf())) - .collect::>(); - - let mut natives = self - .natives - .iter() - .filter_map(|path| path.normalize().ok()) - .map(|normalized| Native::new(normalized.into_path_buf())) - .collect::>(); - - let (ordered_natives, ordered_packages) = profile.compile()?; - - packages.extend(ordered_packages); - natives.extend(ordered_natives); - - let savefile = self.savefile.clone().or_else(|| profile.savefile()); + let (natives, packages) = profile.compile()?; + let savefile = profile.savefile(); if let Some(savefile) = &savefile { // https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions let is_windows_path_reserved_char = |c: char| { @@ -372,8 +387,8 @@ impl LaunchArgs { Ok(AttachConfig { game: game.into(), - packages, natives, + packages, savefile, cache_path: cache_path.map(|path| path.into_path_buf()), suspend: self.suspend, @@ -456,7 +471,7 @@ pub fn launch(db: DbContext, config: Config, args: LaunchArgs) -> color_eyre::Re std::fs::create_dir_all(&attach_config_dir)?; let attach_config_file = NamedTempFile::new_in(&attach_config_dir)?; - std::fs::write(&attach_config_file, toml::to_string_pretty(&attach_config)?)?; + std::fs::write(&attach_config_file, toml::to_string(&attach_config)?)?; info!(?attach_config_file, ?attach_config, "wrote attach config"); let monitor_log_file = NamedTempFile::with_suffix(".log")?; diff --git a/crates/cli/src/commands/profile.rs b/crates/cli/src/commands/profile.rs index 78adcabb..a50ea6fe 100644 --- a/crates/cli/src/commands/profile.rs +++ b/crates/cli/src/commands/profile.rs @@ -2,13 +2,8 @@ use std::{fs, path::PathBuf}; use clap::{ArgAction, Args, Subcommand}; use color_eyre::eyre::{eyre, OptionExt}; -use me3_mod_protocol::{ - dependency::Dependency, - native::Native, - package::{Package, WithPackageSource}, - ModProfile, Supports, -}; -use tracing::error; +use me3_mod_protocol::profile::{builder::ModProfileBuilder, ModProfile}; +use tracing::{error, info, warn}; use crate::{config::Config, db::DbContext, output::OutputBuilder, Game}; @@ -23,7 +18,11 @@ pub enum ProfileCommands { List, /// Show information on a profile. - Show(#[clap(flatten)] ProfileNameArgs), + #[clap(name = "show")] + Show { + /// Name of the profile. + name: String, + }, } #[derive(Args, Debug)] @@ -41,14 +40,20 @@ pub struct ProfileCreateArgs { #[arg(value_enum)] game: Option, - /// Path to package directory (asset override mod) [repeatable option] - #[clap(long("package"))] - packages: Vec, + /// Path to a native DLL, package, file or profile [repeatable option] + #[clap(short('u'), long("use"))] + uses: Vec, - /// Path to DLL file (native DLL mod) [repeatable option] - #[clap(short('n'), long("native"))] + /// (DEPRECATED, use "-u") Path to package directory (asset override mod) [repeatable option] + #[deprecated] + #[clap(long("native"))] natives: Vec, + /// (DEPRECATED, use "-u") Path to DLL file (native DLL mod) [repeatable option] + #[deprecated] + #[clap(long("package"))] + packages: Vec, + /// Name of an alternative savefile to use (in the default savefile directory). #[clap(long("savefile"))] savefile: Option, @@ -141,40 +146,33 @@ pub fn create(config: Config, args: ProfileCreateArgs) -> color_eyre::Result<()> .ok_or_eyre("profile parent path was removed")?; fs::create_dir_all(profile_dir)?; - let mut profile = ModProfile::default(); - - if let Some(game) = args.game { - let supports = profile.supports_mut(); - - supports.push(Supports { - game: game.into(), - since_version: None, - }); - } - - let packages = profile.packages_mut(); - for pkg in args.packages { - packages.push(Package::new(pkg)); + #[allow(deprecated)] + if !args.natives.is_empty() { + warn!("option \"--native\" is deprecated, use \"--use\" instead!"); } - let natives = profile.natives_mut(); - for pkg in args.natives { - natives.push(Native::new(pkg)); + #[allow(deprecated)] + if !args.packages.is_empty() { + warn!("option \"--package\" is deprecated, use \"--use\" instead!"); } - let start_online = profile.start_online_mut(); - *start_online = args.options.start_online; - - let contents = toml::to_string_pretty(&profile)?; - - std::fs::write(profile_path, contents)?; + #[allow(deprecated)] + ModProfileBuilder::new() + .with_supported_game(args.game.map(Into::into)) + .with_paths(args.uses) + .with_paths(args.natives) + .with_paths(args.packages) + .with_savefile(args.savefile) + .start_online(args.options.start_online) + .disable_arxan(args.options.disable_arxan) + .write(profile_path)?; 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)?; +pub fn show(db: DbContext, config: Config, args: ProfileNameArgs) -> color_eyre::Result<()> { + let profile_path = args.into_profile_path(&config)?; let profile = db.profiles.load(profile_path)?; let mut output = OutputBuilder::new("Mod Profile"); @@ -189,6 +187,10 @@ pub fn show(db: DbContext, config: Config, name: ProfileNameArgs) -> color_eyre: }, ); + if let Some(savefile) = profile.savefile() { + output.property("Save", savefile); + } + output.section("Supports", |builder| { if let Some(game) = profile.supported_game() { builder.property(format!("{game:?}"), "Supported"); @@ -197,10 +199,9 @@ pub fn show(db: DbContext, config: Config, name: ProfileNameArgs) -> color_eyre: output.section("Natives", |builder| { for native in profile.natives() { - builder.section(native.id(), |builder| { + builder.section(&native.name, |builder| { builder.indent(2); - - builder.property("Path", native.source().to_string_lossy()); + builder.property("Path", native.path.to_string_lossy()); builder.property("Optional", native.optional.to_string()); builder.property("Enabled", native.enabled); }); @@ -209,17 +210,25 @@ pub fn show(db: DbContext, config: Config, name: ProfileNameArgs) -> color_eyre: output.section("Packages", |builder| { for package in profile.packages() { - builder.section(package.id(), |builder| { + builder.section(&package.name, |builder| { builder.indent(2); - builder.property("Path", package.source().to_string_lossy()); + builder.property("Path", package.path.to_string_lossy()); + builder.property("Optional", package.optional.to_string()); builder.property("Enabled", package.enabled); }); } }); - if let Some(savefile) = profile.savefile() { - output.property("Savefile", savefile); - } + output.section("Profiles", |builder| { + for profile in profile.profiles() { + builder.section(&profile.name, |builder| { + builder.indent(2); + builder.property("Path", profile.path.to_string_lossy()); + builder.property("Optional", profile.optional.to_string()); + builder.property("Enabled", profile.enabled); + }); + } + }); output.section("Options", |builder| { let opt_to_str = @@ -235,6 +244,39 @@ pub fn show(db: DbContext, config: Config, name: ProfileNameArgs) -> color_eyre: Ok(()) } +#[tracing::instrument(err, skip_all)] +pub fn upgrade(db: DbContext, config: Config, args: ProfileNameArgs) -> color_eyre::Result<()> { + let profile = args + .into_profile_path(&config) + .and_then(|path| db.profiles.load(path))?; + + if matches!(profile.as_ref(), ModProfile::V2(_)) { + info!("Profile is already using the latest profile version."); + return Ok(()); + } + + let profile_path = profile.path(); + + let mut backup_path = profile_path.to_owned(); + backup_path.as_mut_os_string().push(".bak"); + + fs::copy(profile.path(), &backup_path)?; + + ModProfileBuilder::new() + .with_supported_game(profile.supported_game()) + .with_dependencies(profile.natives()) + .with_dependencies(profile.packages()) + .with_dependencies(profile.profiles()) + .with_savefile(profile.savefile()) + .start_online(profile.options().start_online) + .disable_arxan(profile.options().disable_arxan) + .write(profile_path)?; + + info!("Successfully upgraded {profile_path:?} (wrote backup to {backup_path:?})."); + + Ok(()) +} + pub fn no_profile_dir() -> color_eyre::Report { eyre!( r#"No profile directory was configured and the default profile directory was inaccessible. diff --git a/crates/cli/src/db/profile.rs b/crates/cli/src/db/profile.rs index 3161e3e4..c127f3e8 100644 --- a/crates/cli/src/db/profile.rs +++ b/crates/cli/src/db/profile.rs @@ -6,10 +6,11 @@ use std::{ use color_eyre::eyre::Context; use me3_mod_protocol::{ - dependency::sort_dependencies, + mod_file::{AsModFile, ModFile}, native::Native, - package::{Package, WithPackageSource}, - Game, ModProfile, + package::Package, + profile::{ModProfile, ProfileMergeError}, + Game, }; use normpath::PathExt; use tracing::warn; @@ -54,30 +55,32 @@ impl Profile { self.path.parent() } + /// Returns the path to the profile file. + pub fn path(&self) -> &Path { + &self.path + } + /// Get the single game this profile supports, or None if it supports multiple games/omits /// support metadata. pub fn supported_game(&self) -> Option { - let supports = self.profile.supports(); - match &supports[..] { - [one_game] => Some(one_game.game), - _ => None, - } + self.profile.game() } - /// Get an unordered list of natives to be loaded by this profile. - /// - /// See [compile] to produce an ordered list. + /// Returns a list of natives to be loaded by this profile. pub fn natives(&self) -> impl Iterator { self.profile.natives().into_iter() } - /// Get an unordered list of packages loaded by this profile. - /// - /// See [compile] to produce an ordered list. + /// Returns a list of packages loaded by this profile. pub fn packages(&self) -> impl Iterator { self.profile.packages().into_iter() } + /// Returns a list of profiles loaded by this profile. + pub fn profiles(&self) -> impl Iterator { + self.profile.profiles().into_iter() + } + /// Get the savefile name that may be overridden by this profile. pub fn savefile(&self) -> Option { self.profile.savefile() @@ -91,23 +94,34 @@ impl Profile { } } - /// Compile this profile into a load order of native DLLs and packages to be loaded. + /// Attempt to apply the properties of another profile on top of this profile. + /// + /// Returns a profile that is a combination of both. + pub fn try_merge>(&self, other: &P) -> Result { + Ok(Self { + name: self.name.clone(), + path: self.path.clone(), + profile: self.profile.try_merge(other.as_ref())?, + }) + } + + /// Compile this profile into a load order of native DLLs, packages and files to be loaded. pub fn compile(&self) -> color_eyre::Result<(Vec, Vec)> { - fn exists(p: &S) -> bool { - match p.source().try_exists() { - Ok(true) => true, + fn canonicalize(base_dir: &Path, sources: &mut Vec) { + sources + .iter_mut() + .for_each(|i| i.as_mod_file_mut().make_absolute(base_dir)); + + sources.retain(|s| match s.as_mod_file().as_ref().try_exists() { + Ok(true) => s.as_mod_file().enabled, _ => { - warn!(path = %p.source().display(), "specified path does not exist or is inaccessible"); + warn!( + "path" = ?s.as_mod_file().as_ref(), + "specified path does not exist or is inaccessible" + ); false } - } - } - - fn canonicalize(base_dir: &Path, sources: &mut Vec) { - sources - .iter_mut() - .for_each(|pkg| pkg.source_mut().make_absolute(base_dir)); - sources.retain(exists); + }); } let mut packages = self.profile.packages(); @@ -118,10 +132,13 @@ impl Profile { canonicalize(base_dir, &mut packages); canonicalize(base_dir, &mut natives); - let ordered_natives = sort_dependencies(natives)?; - let ordered_packages = sort_dependencies(packages)?; + Ok((natives, packages)) + } +} - Ok((ordered_natives, ordered_packages)) +impl AsRef for Profile { + fn as_ref(&self) -> &ModProfile { + &self.profile } } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index b2694fb8..2c69ef89 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -139,7 +139,10 @@ fn main() { Commands::Launch(args) => commands::launch::launch(db, config, args), Commands::Profile(ProfileCommands::Create(args)) => commands::profile::create(config, args), Commands::Profile(ProfileCommands::List) => commands::profile::list(db), - Commands::Profile(ProfileCommands::Show(name)) => commands::profile::show(db, config, name), + Commands::Profile(ProfileCommands::Show(args)) => commands::profile::show(db, config, args), + Commands::Profile(ProfileCommands::Upgrade(args)) => { + commands::profile::upgrade(db, config, args) + } #[cfg(target_os = "windows")] Commands::AddToPath => commands::windows::add_to_path(), #[cfg(target_os = "windows")] diff --git a/crates/launcher-attach-protocol/Cargo.toml b/crates/launcher-attach-protocol/Cargo.toml index 2c3fe49b..2af493dc 100644 --- a/crates/launcher-attach-protocol/Cargo.toml +++ b/crates/launcher-attach-protocol/Cargo.toml @@ -17,7 +17,6 @@ eyre = { workspace = true, default-features = false, features = [ ] } me3-mod-protocol.workspace = true serde.workspace = true -serde_derive.workspace = true [lints] workspace = true diff --git a/crates/launcher-attach-protocol/src/lib.rs b/crates/launcher-attach-protocol/src/lib.rs index cea8cab5..37c9f0ba 100644 --- a/crates/launcher-attach-protocol/src/lib.rs +++ b/crates/launcher-attach-protocol/src/lib.rs @@ -6,7 +6,7 @@ use std::{ use bincode::{error::DecodeError, Decode, Encode}; use me3_mod_protocol::{native::Native, package::Package, Game}; -use serde_derive::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize}; #[derive(Debug, Deserialize, Serialize)] pub struct AttachRequest { @@ -54,8 +54,6 @@ pub struct Attachment; pub type AttachResult = Result; -pub type AttachFunction = fn(AttachRequest) -> AttachResult; - #[derive(Debug, Deserialize, Serialize)] pub struct AttachError(pub String); diff --git a/crates/launcher/Cargo.toml b/crates/launcher/Cargo.toml index b55ea7c0..4b469117 100644 --- a/crates/launcher/Cargo.toml +++ b/crates/launcher/Cargo.toml @@ -22,6 +22,7 @@ me3-mod-protocol.workspace = true me3-telemetry.workspace = true minidump-writer.workspace = true sentry = { workspace = true, optional = true } +serde_json.workspace = true toml.workspace = true tracing.workspace = true windows = { workspace = true, features = [ diff --git a/crates/launcher/src/game.rs b/crates/launcher/src/game.rs index d709af74..1a3172f0 100644 --- a/crates/launcher/src/game.rs +++ b/crates/launcher/src/game.rs @@ -10,13 +10,15 @@ use std::{ }; use dll_syringe::{ - process::{OwnedProcess, Process}, - rpc::RemotePayloadProcedure, + process::{ + memory::{ProcessMemoryBuffer, ProcessMemorySlice}, + BorrowedProcess, OwnedProcess, Process, + }, Syringe, }; use eyre::{eyre, OptionExt}; use me3_env::{deserialize_from_env, serialize_into_command, TelemetryVars}; -use me3_launcher_attach_protocol::{AttachFunction, AttachRequest, Attachment}; +use me3_launcher_attach_protocol::{AttachError, AttachRequest, Attachment}; use tracing::{info, instrument}; use windows::Win32::{ Foundation::{ERROR_ELEVATION_REQUIRED, HANDLE, WIN32_ERROR}, @@ -82,21 +84,27 @@ impl Game { // SAFETY: `process_handle` is a process handle that is exclusively owned. let process = unsafe { OwnedProcess::from_handle_unchecked(process_handle) }; - let injector = syringe_for_suspended_process(process)?; let module = injector.inject(dll_path)?; - let payload: RemotePayloadProcedure = unsafe { + let procedure = unsafe { injector - .get_payload_procedure::(module, "me_attach")? + .get_raw_procedure:: *mut u8>(module, "me_attach")? .ok_or_eyre("No symbol named `me_attach` found")? }; + let (attach_payload, attach_payload_len) = + serialize_attach_payload(injector.process(), &request)?; + if request.config.suspend { info!("Process will be suspended until a debugger is attached..."); } - let response = payload.call(&request)?.map_err(|e| eyre::eyre!(e.0))?; + let result_payload = procedure.call(attach_payload, attach_payload_len)?; + let response = unsafe { + deserialize_result_payload(injector.process(), result_payload)? + .map_err(|e| eyre!(e.0))? + }; unsafe { ResumeThread(HANDLE(thread_handle.as_raw_handle())); @@ -143,3 +151,44 @@ fn syringe_for_suspended_process(process: OwnedProcess) -> LauncherResult, + request: &AttachRequest, +) -> LauncherResult<(*mut u8, usize)> { + let serialized = serde_json::to_string(request)?; + let bytes = serialized.as_bytes(); + + let buffer = ProcessMemoryBuffer::allocate_data(process, bytes.len())?; + buffer.write(0, bytes)?; + + Ok((buffer.leak().as_ptr(), bytes.len())) +} + +unsafe fn deserialize_result_payload( + process: BorrowedProcess<'_>, + result_payload: *mut u8, +) -> LauncherResult> { + let payload_len = unsafe { + ProcessMemorySlice::from_raw_parts( + result_payload, + mem::size_of::(), + process, + ) + .read_struct::(0)? + }; + + let mut bytes = Vec::new(); + bytes.resize(payload_len, b' '); + + unsafe { + ProcessMemorySlice::from_raw_parts( + result_payload.add(mem::size_of::()), + payload_len, + process, + ) + .read(0, &mut bytes)?; + }; + + Ok(serde_json::from_slice(&bytes)?) +} diff --git a/crates/launcher/src/main.rs b/crates/launcher/src/main.rs index 0f4378b8..f4daf04e 100644 --- a/crates/launcher/src/main.rs +++ b/crates/launcher/src/main.rs @@ -5,7 +5,7 @@ use eyre::Context; use me3_env::{LauncherVars, TelemetryVars}; use me3_launcher_attach_protocol::{AttachConfig, AttachRequest}; use me3_telemetry::TelemetryConfig; -use tracing::{error, info, instrument, warn}; +use tracing::{info, instrument, warn}; use crate::{game::Game, steam::require_steam}; @@ -37,16 +37,17 @@ fn run() -> LauncherResult<()> { } let game_path = args.exe.parent(); - let game = Game::launch(&args.exe, game_path)?; + let mut game = Game::launch(&args.exe, game_path)?; let request = AttachRequest { config }; match game.attach(&args.host_dll, request) { Ok(_) => info!("attached to game successfully"), Err(error) => { - error!( - error = &*error, - "an error occurred while loading me3, modded content will not be available" - ); + let _ = game.child.kill(); + + return Err(error.wrap_err( + "an error occurred while loading me3, modded content will not be available", + )); } } diff --git a/crates/mod-host-assets/src/mapping.rs b/crates/mod-host-assets/src/mapping.rs index 18ceba03..337d39b5 100644 --- a/crates/mod-host-assets/src/mapping.rs +++ b/crates/mod-host-assets/src/mapping.rs @@ -10,7 +10,7 @@ use std::{ path::{Path, PathBuf, StripPrefixError}, }; -use me3_mod_protocol::package::{AssetOverrideSource, Package}; +use me3_mod_protocol::package::Package; use normpath::PathExt; use rayon::iter::{ParallelBridge, ParallelIterator}; use smallvec::{smallvec_inline, SmallVec}; @@ -30,6 +30,7 @@ pub struct VfsOverride { display: Box, path_c_str: Box, wide_c_str: Box<[u16]>, + source: Option<&'static str>, } #[derive(Debug, Error)] @@ -57,14 +58,15 @@ impl VfsOverrideMapping { }) } - /// Scans a set of directories, mapping discovered assets into itself. - pub fn scan_directories(&mut self, sources: I) -> Result<(), VfsOverrideMappingError> + /// Sequentially scans a set of packages, mapping discovered assets into itself. + pub fn map_packages<'a, I>(&mut self, packages: I) -> Result<(), VfsOverrideMappingError> where - I: Iterator, + I: Iterator, { - fn scan_directories_inner( + fn map_packages_inner( base_dir: &Path, root_key: &VfsKey, + source: &'static str, ) -> SmallVec<[Result<(VfsKey, VfsOverride), io::Error>; 1]> { let entries = match read_dir(base_dir) { Ok(entries) => entries, @@ -76,13 +78,17 @@ impl VfsOverrideMapping { .par_bridge() .flat_map_iter(|dir_entry| match dir_entry.file_type() { Ok(file_type) if file_type.is_dir() || file_type.is_symlink_dir() => { - scan_directories_inner(&dir_entry.path(), root_key) + map_packages_inner(&dir_entry.path(), root_key, source) } Ok(_) => { let path = dir_entry.path(); - let result = VfsKey::for_asset_path(&path, root_key) - .map(|vfs_key| (vfs_key, VfsOverride::new(&path))); + let result = VfsKey::for_asset_path(&path, root_key).map(|vfs_key| { + ( + vfs_key, + VfsOverride::new_with_package_source(&path, Some(source)), + ) + }); smallvec_inline![result] } @@ -93,12 +99,13 @@ impl VfsOverrideMapping { SmallVec::from_vec(result) } - for source in sources { - let source_path = source.asset_path(); + for package in packages { + let package_source = package.name.clone().leak() as &'static str; + let package_path = package.path.as_path(); let root_key = - VfsKey::for_disk_path(source_path).map_err(VfsOverrideMappingError::ReadDir)?; + VfsKey::for_disk_path(package_path).map_err(VfsOverrideMappingError::ReadDir)?; - let scanned_directories = scan_directories_inner(source_path, &root_key); + let scanned_directories = map_packages_inner(package_path, &root_key, &package_source); self.map.reserve(scanned_directories.len()); for result in scanned_directories { @@ -110,12 +117,11 @@ impl VfsOverrideMapping { Ok(()) } - pub fn scan_directory>( + pub fn map_package_sources( &mut self, - path: P, + package: &Package, ) -> Result<(), VfsOverrideMappingError> { - let package = Package::new(path.as_ref().to_owned()); - self.scan_directories(iter::once(&package)) + self.map_packages(iter::once(package)) } pub fn add_savefile_override(&mut self, savefile_dir: P, f: F) -> Result<(), io::Error> @@ -150,6 +156,10 @@ impl VfsOverrideMapping { impl VfsOverride { pub fn new>(path: P) -> Self { + Self::new_with_package_source(path, None) + } + + pub fn new_with_package_source>(path: P, source: Option<&'static str>) -> Self { let display = path.as_ref().display().to_string().into_boxed_str(); let (wide_c_str, path_c_str) = { @@ -166,9 +176,14 @@ impl VfsOverride { display, path_c_str, wide_c_str, + source, } } + pub fn source(&self) -> Option<&str> { + self.source + } + pub fn as_str_lossy(&self) -> &str { &self.display } @@ -302,6 +317,8 @@ impl Borrow for VfsKey { mod test { use std::path::Path; + use me3_mod_protocol::package::Package; + use super::{VfsKey, VfsOverrideMapping}; #[test] @@ -349,7 +366,9 @@ mod test { let mut asset_mapping = VfsOverrideMapping::new().unwrap(); let test_mod_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("test-data/test-mod"); - asset_mapping.scan_directory(test_mod_dir).unwrap(); + asset_mapping + .map_package_sources(&Package::new(test_mod_dir)) + .unwrap(); assert!( asset_mapping diff --git a/crates/mod-host-assets/src/wwise.rs b/crates/mod-host-assets/src/wwise.rs index 6802e997..482ef318 100644 --- a/crates/mod-host-assets/src/wwise.rs +++ b/crates/mod-host-assets/src/wwise.rs @@ -85,6 +85,8 @@ fn get_override<'a>(mapping: &'a VfsOverrideMapping, input: &str) -> Option<&'a mod test { use std::path::Path; + use me3_mod_protocol::package::Package; + use crate::{mapping::VfsOverrideMapping, wwise::find_override}; #[test] @@ -92,7 +94,9 @@ mod test { let mut asset_mapping = VfsOverrideMapping::new().unwrap(); let test_mod_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("test-data/test-mod"); - asset_mapping.scan_directory(test_mod_dir).unwrap(); + asset_mapping + .map_package_sources(&Package::new(test_mod_dir)) + .unwrap(); assert!( find_override(&asset_mapping, "sd:/init.bnk").is_some(), diff --git a/crates/mod-host/src/asset_hooks.rs b/crates/mod-host/src/asset_hooks.rs index 3b3b6302..8fa0184d 100644 --- a/crates/mod-host/src/asset_hooks.rs +++ b/crates/mod-host/src/asset_hooks.rs @@ -173,7 +173,7 @@ fn hook_device_manager( let mapped_override = mapping.vfs_override(OsString::from_wide(&expanded))?; - info!("override" = %mapped_override); + info!("override" = %path, "source" = mapped_override.source()); let mut path = path.clone(); @@ -513,7 +513,7 @@ fn try_hook_wwise( let path_string = unsafe { path.to_string().unwrap() }; if let Some(mapped_override) = wwise::find_override(&mapping, &path_string) { - info!("override" = %mapped_override); + info!("override" = path_string, "source" = mapped_override.source()); // Force lookup to wwise's ordinary read (from disk) mode instead of the EBL read. unsafe { diff --git a/crates/mod-host/src/filesystem.rs b/crates/mod-host/src/filesystem.rs index 0973e4e3..d456d3c4 100644 --- a/crates/mod-host/src/filesystem.rs +++ b/crates/mod-host/src/filesystem.rs @@ -93,9 +93,9 @@ fn hook_create_file(kb: HMODULE, mapping: Arc) -> Result<(), } if let Ok(path) = p1.to_string() - && let Some(mapped_override) = mapping.disk_override(path) + && let Some(mapped_override) = mapping.disk_override(&path) { - info!("override" = %mapped_override); + info!("override" = path, "source" = mapped_override.source()); return trampoline(mapped_override.into(), p2, p3, p4, p5, p6, p7); } @@ -118,8 +118,8 @@ fn hook_create_file(kb: HMODULE, mapping: Arc) -> Result<(), let path = OsString::from_wide(p1.as_wide()); - if let Some(mapped_override) = mapping.disk_override(path) { - info!("override" = %mapped_override); + if let Some(mapped_override) = mapping.disk_override(&path) { + info!("override" = %path.display(), "source" = mapped_override.source()); return trampoline(mapped_override.into(), p2, p3, p4, p5, p6, p7); } @@ -142,8 +142,8 @@ fn hook_create_file(kb: HMODULE, mapping: Arc) -> Result<(), let path = OsString::from_wide(p1.as_wide()); - if let Some(mapped_override) = mapping.disk_override(path) { - info!("override" = %mapped_override); + if let Some(mapped_override) = mapping.disk_override(&path) { + info!("override" = %path.display(), "source" = mapped_override.source()); return trampoline(mapped_override.into(), p2, p3, p4, p5); } diff --git a/crates/mod-host/src/host.rs b/crates/mod-host/src/host.rs index 45639a0d..0372888b 100644 --- a/crates/mod-host/src/host.rs +++ b/crates/mod-host/src/host.rs @@ -3,29 +3,28 @@ use std::{ ffi::CString, fmt::Debug, marker::Tuple, - panic, - path::Path, - ptr, + panic::{self, AssertUnwindSafe}, sync::{Arc, Mutex, OnceLock}, time::Duration, }; use closure_ffi::traits::FnPtr; +use eyre::eyre; use libloading::{Library, Symbol}; use me3_binary_analysis::pe; use me3_launcher_attach_protocol::AttachConfig; -use me3_mod_protocol::{native::NativeInitializerCondition, Game, ModProfile}; -use pelite::pe::Pe; -use regex::bytes::Regex; +use me3_mod_protocol::{ + native::{Native, NativeInitializerCondition}, + profile::ModProfile, + Game, +}; use retour::Function; -use tracing::{error, info, warn, Span}; -use windows::core::w; +use tracing::{error, info, instrument, Span}; use self::hook::HookInstaller; use crate::{ detour::UntypedDetour, - executable::Executable, - native::{ModEngineConnectorShim, ModEngineExtension, ModEngineInitializer}, + native::{ModEngineConnectorShim, ModEngineInitializer}, }; mod append; @@ -58,53 +57,92 @@ impl ModHost { Self::default() } - pub fn load_native( - &self, - path: &Path, - condition: &Option, - ) -> eyre::Result<()> { - let result = panic::catch_unwind(|| { - let module = unsafe { libloading::Library::new(path)? }; - - match &condition { - Some(NativeInitializerCondition::Delay { ms }) => { - std::thread::sleep(Duration::from_millis(*ms as u64)) - } - Some(NativeInitializerCondition::Function(symbol)) => unsafe { + #[instrument(skip_all)] + pub fn load_native(&self, native: &Native) -> eyre::Result<()> { + let load_native = { + let span = AssertUnwindSafe(Span::current()); + let native = native.clone(); + + move || unsafe { + let _span_guard = span.enter(); + let module = libloading::Library::new(&native.path)?; + + if let Some(NativeInitializerCondition { + function: Some(symbol), + .. + }) = &native.initializer + { let sym_name = CString::new(symbol.as_bytes())?; + let initializer: Symbol bool> = module.get(sym_name.as_bytes_with_nul())?; - if initializer() { - info!(?path, symbol, "native initialized successfully"); - } else { - error!(?path, symbol, "native failed to initialize"); + if !initializer() { + return Err(eyre!("native failed to initialize")); } - }, - None => { - let me2_initializer: Option> = - unsafe { module.get(b"modengine_ext_init\0").ok() }; + } - let mut extension_ptr: *mut ModEngineExtension = std::ptr::null_mut(); - if let Some(initializer) = me2_initializer { - unsafe { initializer(&ModEngineConnectorShim, &mut extension_ptr) }; + info!("native" = native.name, "loaded native"); - info!(?path, "loaded native with me2 compatibility shim"); + eyre::Ok(module) + } + }; + + let result = panic::catch_unwind(move || { + if let Some(NativeInitializerCondition { + delay: Some(delay), .. + }) = &native.initializer + { + let name = native.name.clone(); + let delay = delay.clone(); + + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(delay.ms as u64)); + + match load_native() { + Ok(module) => ModHost::get_attached() + .native_modules + .lock() + .unwrap() + .push(module), + Err(e) => { + error!( + "error" = &*e, + "native" = name, + "an error occurred while loading native" + ) + } } + }); + + return eyre::Ok(()); + } + + let module = load_native()?; + + if native.initializer.is_none() { + let me2_initializer = + unsafe { module.get::(b"modengine_ext_init\0") }; + + if let Ok(initializer) = me2_initializer { + unsafe { initializer(&ModEngineConnectorShim, &mut std::ptr::null_mut()) }; } } - Ok(module) + self.native_modules.lock().unwrap().push(module); + + eyre::Ok(()) }); match result { - Err(exception) => { - warn!("an error occurred while loading {path:?}, it may not work as expected"); - Ok(()) + Ok(result) => result, + Err(payload) => { + let payload = payload + .downcast::<&'static str>() + .map_or("unable to retrieve panic payload", |b| *b); + + Err(eyre!(payload)) } - Ok(result) => result.map(|module| { - self.native_modules.lock().unwrap().push(module); - }), } } diff --git a/crates/mod-host/src/lib.rs b/crates/mod-host/src/lib.rs index 4a78c3a5..8c6da227 100644 --- a/crates/mod-host/src/lib.rs +++ b/crates/mod-host/src/lib.rs @@ -4,8 +4,10 @@ #![feature(unboxed_closures)] use std::{ + alloc::{handle_alloc_error, GlobalAlloc, Layout, System}, fs::OpenOptions, io::stdout, + ptr, slice, sync::{Arc, OnceLock}, }; @@ -40,19 +42,6 @@ mod native; mod savefile; mod skip_logos; -static INSTANCE: OnceLock = OnceLock::new(); -static mut TELEMETRY_INSTANCE: OnceLock = OnceLock::new(); - -dll_syringe::payload_procedure! { - fn me_attach(request: AttachRequest) -> AttachResult { - if request.config.suspend { - debugger::suspend_for_debugger(); - } - - on_attach(request) - } -} - #[cfg(coverage)] #[unsafe(no_mangle)] #[allow(non_upper_case_globals)] @@ -64,7 +53,61 @@ unsafe extern "C" { fn __llvm_profile_initialize_file(); } +static INSTANCE: OnceLock = OnceLock::new(); +static mut TELEMETRY_INSTANCE: OnceLock = OnceLock::new(); + +#[unsafe(no_mangle)] +extern "C" fn me_attach(attach_payload: *mut u8, attach_payload_len: usize) -> *mut u8 { + let result = me_attach_inner(attach_payload, attach_payload_len); + serialize_result_payload(result) +} + +fn me_attach_inner(attach_payload: *mut u8, attach_payload_len: usize) -> AttachResult { + let request = unsafe { deserialize_attach_payload(attach_payload, attach_payload_len)? }; + on_attach(request) +} + +unsafe fn deserialize_attach_payload( + attach_payload: *mut u8, + attach_payload_len: usize, +) -> Result { + let bytes = unsafe { slice::from_raw_parts(attach_payload, attach_payload_len) }; + Ok(serde_json::from_slice(bytes)?) +} + +fn serialize_result_payload(result: AttachResult) -> *mut u8 { + let string = match serde_json::to_string(&result) { + Ok(string) => string, + Err(e) => format!("\"{e}\""), + }; + + unsafe { + let (layout, string_offset) = Layout::new::() + .extend(Layout::from_size_align_unchecked(string.len(), 1)) + .unwrap(); + + let length_prefixed_payload = System.alloc(layout); + if length_prefixed_payload.is_null() { + handle_alloc_error(layout); + } + + ptr::write(length_prefixed_payload as *mut usize, string.len()); + + ptr::copy( + string.as_ptr(), + length_prefixed_payload.add(string_offset), + string.len(), + ); + + length_prefixed_payload + } +} + fn on_attach(request: AttachRequest) -> AttachResult { + if request.config.suspend { + debugger::suspend_for_debugger(); + } + let _ = unsafe { SetConsoleOutputCP(CP_UTF8) }; me3_telemetry::install_error_handler(); @@ -113,7 +156,7 @@ fn on_attach(request: AttachRequest) -> AttachResult { let mut override_mapping = VfsOverrideMapping::new()?; - override_mapping.scan_directories(attach_config.packages.iter())?; + override_mapping.map_packages(attach_config.packages.iter())?; savefile::attach_override(&attach_config, &mut override_mapping)?; let override_mapping = Arc::new(override_mapping); @@ -168,8 +211,8 @@ fn deferred_attach( for native in immediate { if let Err(e) = ModHost::get_attached().load_native(&native.path, &native.initializer) { warn!( - error = &*e, - path = %native.path.display(), + "error" = &*e, + "path" = ?native.path, "failed to load native mod", ); diff --git a/crates/mod-host/src/native.rs b/crates/mod-host/src/native.rs index ca53e336..89b60d05 100644 --- a/crates/mod-host/src/native.rs +++ b/crates/mod-host/src/native.rs @@ -1,7 +1,7 @@ use std::ffi::c_char; pub type ModEngineInitializer = - unsafe extern "C" fn(&ModEngineConnectorShim, &mut *mut ModEngineExtension) -> bool; + unsafe extern "C" fn(*const ModEngineConnectorShim, *mut *mut ModEngineExtension) -> bool; pub struct ModEngineConnectorShim; diff --git a/crates/mod-protocol/Cargo.toml b/crates/mod-protocol/Cargo.toml index 805de4f8..ed7a3ade 100644 --- a/crates/mod-protocol/Cargo.toml +++ b/crates/mod-protocol/Cargo.toml @@ -9,8 +9,8 @@ description = "Schema definition for me3 mod profiles" publish = false [dependencies] -indexmap = "2.11.0" -schemars.workspace = true +indexmap = { version = "2.11.0", features = ["serde"] } +schemars = { workspace = true, features = ["indexmap2"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["preserve_order"] } strum.workspace = true diff --git a/crates/mod-protocol/src/bin/schema.rs b/crates/mod-protocol/src/bin/schema.rs index a79057bc..bb9c59ce 100644 --- a/crates/mod-protocol/src/bin/schema.rs +++ b/crates/mod-protocol/src/bin/schema.rs @@ -1,4 +1,4 @@ -use me3_mod_protocol::ModProfile; +use me3_mod_protocol::profile::ModProfile; use schemars::schema_for; pub fn main() { diff --git a/crates/mod-protocol/src/lib.rs b/crates/mod-protocol/src/lib.rs index ce03957b..d06ef836 100644 --- a/crates/mod-protocol/src/lib.rs +++ b/crates/mod-protocol/src/lib.rs @@ -1,151 +1,19 @@ -use std::{fs::File, io::Read, path::Path}; - -use native::Native; -use package::Package; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - pub mod dependency; pub mod game; +pub mod mod_file; pub mod native; pub mod package; +pub mod profile; pub use game::Game; -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -#[serde(tag = "profileVersion")] -pub enum ModProfile { - #[serde(rename = "v1")] - V1(ModProfileV1), -} - -#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] -pub struct Supports { - #[serde(rename = "game")] - pub game: Game, - - #[serde(rename = "since")] - pub since_version: Option, -} - -impl Default for ModProfile { - fn default() -> Self { - ModProfile::V1(ModProfileV1::default()) - } -} - -impl ModProfile { - pub fn from_file(path: &Path) -> Result { - let mut file = File::open(path)?; - - match path.extension().and_then(|path| path.to_str()) { - Some("toml") | Some("me3") | None => { - let mut file_contents = String::new(); - let _ = file.read_to_string(&mut file_contents)?; - - toml::from_str(file_contents.as_str()).map_err(std::io::Error::other) - } - Some("json") => serde_json::from_reader(file).map_err(std::io::Error::other), - Some(format) => Err(std::io::Error::other(format!("{format} is unsupported"))), - } - } - - pub fn natives_mut(&mut self) -> &mut Vec { - match self { - ModProfile::V1(v1) => &mut v1.natives, - } - } - - pub fn packages_mut(&mut self) -> &mut Vec { - match self { - ModProfile::V1(v1) => &mut v1.packages, - } - } - - pub fn supports_mut(&mut self) -> &mut Vec { - match self { - ModProfile::V1(v1) => &mut v1.supports, - } - } - - pub fn start_online_mut(&mut self) -> &mut Option { - match self { - ModProfile::V1(v1) => &mut v1.start_online, - } - } - - pub fn supports(&self) -> Vec { - match self { - ModProfile::V1(v1) => v1.supports.to_vec(), - } - } - - pub fn natives(&self) -> Vec { - match self { - ModProfile::V1(v1) => v1.natives.to_vec(), - } - } - - pub fn packages(&self) -> Vec { - match self { - ModProfile::V1(v1) => v1.packages.to_vec(), - } - } - - pub fn savefile(&self) -> Option { - match self { - ModProfile::V1(v1) => v1.savefile.clone(), - } - } - - pub fn start_online(&self) -> Option { - match self { - ModProfile::V1(v1) => v1.start_online, - } - } - - pub fn disable_arxan(&self) -> Option { - match self { - ModProfile::V1(v1) => v1.disable_arxan, - } - } -} - -#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)] -pub struct ModProfileV1 { - /// The games that this profile supports. - #[serde(default)] - supports: Vec, - - /// Native modules (DLLs) that will be loaded. - #[serde(default)] - #[serde(alias = "native")] - natives: Vec, - - /// A collection of packages containing assets that should be considered for loading - /// before the DVDBND. - #[serde(default)] - #[serde(alias = "package")] - packages: Vec, - - /// Name of an alternative savefile to use (in the default savefile directory). - #[serde(default)] - savefile: Option, - - /// Starts the game with multiplayer server connectivity enabled. - #[serde(default)] - start_online: Option, - - /// Try to neutralize Arxan GuardIT code protection to improve mod stability. - #[serde(default)] - disable_arxan: Option, -} - #[cfg(test)] mod tests { + use std::path::Path; + use expect_test::expect_file; - use super::*; + use crate::profile::ModProfile; fn check(test_case_name: &str) { let test_data_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("test-data"); @@ -159,17 +27,49 @@ mod tests { } #[test] - fn basic_config_toml() { - check("basic_config.me3.toml"); + fn v1_basic_config() { + check("v1/basic_config.me3"); + } + + #[test] + fn v1_advanced_config() { + check("v1/advanced_config.me3"); + } + + #[test] + fn v1_plural_packages_name() { + check("v1/plural_packages.me3"); + } + + #[test] + fn v1_singular_packages_name() { + check("v1/singular_package.me3"); } #[test] - fn plural_packages_name() { - check("plural_packages.me3"); + fn v2_basic_config() { + check("v2/basic_config.me3"); } #[test] - fn singular_packages_name() { - check("singular_package.me3"); + fn v2_advanced_config() { + check("v2/advanced_config.me3"); + } + + #[test] + fn v2_merge_configs() { + let test_data_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("test-data/v2"); + + let profile_a = + ModProfile::from_file(test_data_dir.join("merge_config_a.me3")).expect("parse failure"); + let profile_b = + ModProfile::from_file(test_data_dir.join("merge_config_b.me3")).expect("parse failure"); + + let merged_profile = profile_a + .try_merge(&profile_b) + .expect("failed to merge profiles"); + let expected_profile = expect_file![test_data_dir.join("merge_config.me3.expected")]; + + expected_profile.assert_debug_eq(&merged_profile); } } diff --git a/crates/mod-protocol/src/mod_file.rs b/crates/mod-protocol/src/mod_file.rs new file mode 100644 index 00000000..ed326a40 --- /dev/null +++ b/crates/mod-protocol/src/mod_file.rs @@ -0,0 +1,105 @@ +use std::{ + ops::BitXor, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; + +pub trait AsModFile { + fn as_mod_file(&self) -> &ModFile; + fn as_mod_file_mut(&mut self) -> &mut ModFile; +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ModFile { + /// Name associated with this item. + pub name: String, + + /// A path to the source of this item. + pub path: PathBuf, + + /// Does this item participate in dependency resolution? + pub enabled: bool, + + /// Should failing to find this item result in a hard error? + pub optional: bool, +} + +impl ModFile { + #[inline] + pub fn new>(path: P) -> Self { + path.as_ref().to_owned().into() + } + + #[inline] + pub fn is_relative(&self) -> bool { + self.path.is_relative() + } + + #[inline] + pub fn is_default(&self) -> bool { + self.enabled && !self.optional + } + + #[inline] + pub fn make_absolute>(&mut self, base: P) { + if self.path.is_relative() { + self.path = base.as_ref().join(&self.path); + } + } +} + +impl Default for ModFile { + #[inline] + fn default() -> Self { + Self { + name: Default::default(), + path: Default::default(), + enabled: true, + optional: false, + } + } +} + +impl AsRef for ModFile { + #[inline] + fn as_ref(&self) -> &Path { + &self.path + } +} + +impl From for ModFile { + #[inline] + fn from(path: PathBuf) -> Self { + let fnv1_a = |b: &[u8]| { + b.iter().fold(0x811c9dc5u32, |hash, byte| { + hash.bitxor(*byte as u32).wrapping_mul(0x01000193) + }) + }; + + Self { + name: format!( + "{}_{:x}", + path.file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(), + fnv1_a(path.as_os_str().as_encoded_bytes()) + ), + path, + ..Default::default() + } + } +} + +impl AsModFile for ModFile { + #[inline] + fn as_mod_file(&self) -> &ModFile { + self + } + + #[inline] + fn as_mod_file_mut(&mut self) -> &mut ModFile { + self + } +} diff --git a/crates/mod-protocol/src/native.rs b/crates/mod-protocol/src/native.rs index 5f98e261..f2703d04 100644 --- a/crates/mod-protocol/src/native.rs +++ b/crates/mod-protocol/src/native.rs @@ -1,95 +1,95 @@ -use std::path::PathBuf; +use std::{ + ops::{Deref, DerefMut}, + path::{Path, PathBuf}, +}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::{ - dependency::{Dependency, Dependent}, - package::{ModFile, WithPackageSource}, -}; - -fn off() -> bool { - false -} - -fn on() -> bool { - true -} +use crate::mod_file::{AsModFile, ModFile}; #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] -pub enum NativeInitializerCondition { - #[serde(rename = "delay")] - Delay { ms: usize }, - #[serde(rename = "function")] - Function(String), +pub struct NativeInitializerDelay { + pub ms: usize, } #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] -pub struct Native { - /// Path to the DLL. Can be relative to the mod profile. - pub path: ModFile, - - /// If this native fails to load and this value is false, treat it as a critical error. - #[serde(default = "off")] - pub optional: bool, - - /// Should this native be loaded? - #[serde(default = "on")] - pub enabled: bool, - +pub struct NativeInitializerCondition { #[serde(default)] - load_before: Vec>, - + pub delay: Option, #[serde(default)] - load_after: Vec>, + pub function: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Native { + #[serde(flatten)] + pub(crate) inner: ModFile, /// An optional symbol to be called after this native successfully loads. pub initializer: Option, - - /// An optional symbol to be called when this native successfully is queued for unload. - pub finalizer: Option, } impl Native { - pub fn new>(path: P) -> Self { - Self { - path: ModFile(path.into()), - optional: false, - enabled: true, - load_after: vec![], - load_before: vec![], - initializer: None, - finalizer: None, - } + #[inline] + pub fn new>(path: P) -> Self { + ModFile::new(path).into() + } + + #[inline] + pub fn is_default(&self) -> bool { + self.inner.is_default() && self.initializer.is_none() } } -impl WithPackageSource for Native { - fn source(&self) -> &crate::package::ModFile { - &self.path +impl AsRef for Native { + #[inline] + fn as_ref(&self) -> &Path { + self.as_mod_file().as_ref() } +} + +impl Deref for Native { + type Target = ModFile; - fn source_mut(&mut self) -> &mut crate::package::ModFile { - &mut self.path + #[inline] + fn deref(&self) -> &Self::Target { + &self.inner } } -impl Dependency for Native { - type UniqueId = String; +impl DerefMut for Native { + #[inline] + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} - fn id(&self) -> Self::UniqueId { - self.path - .0 - .file_name() - .map(|f| f.to_string_lossy().to_string()) - .expect("native had no file name") +impl AsModFile for Native { + #[inline] + fn as_mod_file(&self) -> &ModFile { + &self.inner } - fn loads_after(&self) -> &[Dependent] { - &self.load_after + #[inline] + fn as_mod_file_mut(&mut self) -> &mut ModFile { + &mut self.inner } +} + +impl From for Native { + #[inline] + fn from(item: ModFile) -> Self { + Self { + inner: item, + initializer: None, + } + } +} - fn loads_before(&self) -> &[Dependent] { - &self.load_before +impl From for Native { + #[inline] + fn from(path: PathBuf) -> Self { + ModFile::from(path).into() } } diff --git a/crates/mod-protocol/src/package.rs b/crates/mod-protocol/src/package.rs index bf7b6a8e..23369039 100644 --- a/crates/mod-protocol/src/package.rs +++ b/crates/mod-protocol/src/package.rs @@ -1,126 +1,70 @@ use std::{ - ops::Deref, + ops::{Deref, DerefMut}, path::{Path, PathBuf}, }; -use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::dependency::{Dependency, Dependent}; - -pub trait WithPackageSource { - fn source(&self) -> &ModFile; - - fn source_mut(&mut self) -> &mut ModFile; -} - -/// A filesystem path to the contents of a package. May be relative to the [ModProfile] containing -/// it. -#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] -pub struct ModFile(pub(crate) PathBuf); - -impl Deref for ModFile { - type Target = PathBuf; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl ModFile { - /// Returns whether or not the package's source description is relative to the mod profile. - pub fn is_relative(&self) -> bool { - self.0.is_relative() - } - - pub fn make_absolute(&mut self, base: &Path) { - if self.0.is_relative() { - self.0 = base.join(&self.0); - } - } -} - -fn on() -> bool { - true -} +use crate::mod_file::{AsModFile, ModFile}; /// A package is a source for files that override files within the existing games DVDBND archives. /// It points to a local path containing assets matching the hierarchy they would be served under in /// the DVDBND. -#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] -pub struct Package { - /// The unique identifier for this package. - pub(crate) id: Option, - - /// Enable this package? - #[serde(default = "on")] - pub enabled: bool, - - /// A path to the source of this package. - #[serde(alias = "source")] - pub(crate) path: ModFile, - - /// A list of package IDs that this package should load after. - #[serde(default)] - pub(crate) load_after: Vec>, - - /// A list of packages that this package should load before. - #[serde(default)] - pub(crate) load_before: Vec>, -} +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Package(pub(crate) ModFile); impl Package { - pub fn new(path: PathBuf) -> Self { - Self { - id: None, - path: ModFile(path), - enabled: true, - load_after: vec![], - load_before: vec![], - } + #[inline] + pub fn new>(path: P) -> Self { + ModFile::new(path).into() } +} - /// Makes the package's source absolute using a given base directory (this is usually the mod - /// profile's parent path). - pub fn make_absolute(&mut self, base: &Path) { - self.path = ModFile(base.join(&self.path.0)); +impl AsRef for Package { + #[inline] + fn as_ref(&self) -> &Path { + self.as_mod_file().as_ref() } } -impl WithPackageSource for Package { - fn source(&self) -> &ModFile { - &self.path - } +impl Deref for Package { + type Target = ModFile; - fn source_mut(&mut self) -> &mut ModFile { - &mut self.path + #[inline] + fn deref(&self) -> &Self::Target { + &self.0 } } -impl Dependency for Package { - type UniqueId = String; - - fn id(&self) -> Self::UniqueId { - self.id - .clone() - .unwrap_or_else(|| self.path.to_string_lossy().into()) +impl DerefMut for Package { + #[inline] + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 } +} - fn loads_after(&self) -> &[crate::dependency::Dependent] { - &self.load_after +impl AsModFile for Package { + #[inline] + fn as_mod_file(&self) -> &ModFile { + &self.0 } - fn loads_before(&self) -> &[crate::dependency::Dependent] { - &self.load_before + #[inline] + fn as_mod_file_mut(&mut self) -> &mut ModFile { + &mut self.0 } } -pub trait AssetOverrideSource { - fn asset_path(&self) -> &Path; +impl From for Package { + #[inline] + fn from(item: ModFile) -> Self { + Self(item) + } } -impl AssetOverrideSource for &Package { - fn asset_path(&self) -> &Path { - self.path.0.as_path() +impl From for Package { + #[inline] + fn from(path: PathBuf) -> Self { + ModFile::from(path).into() } } diff --git a/crates/mod-protocol/src/profile.rs b/crates/mod-protocol/src/profile.rs new file mode 100644 index 00000000..29cfc401 --- /dev/null +++ b/crates/mod-protocol/src/profile.rs @@ -0,0 +1,172 @@ +use std::{ + fs::File, + io::{self, Read}, + path::Path, +}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{ + mod_file::ModFile, + native::Native, + package::Package, + profile::{ + builder::ModProfileBuilder, + v1::{ModProfileV1, Supports}, + v2::ModProfileV2, + }, + Game, +}; + +pub mod builder; +mod v1; +mod v2; + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(tag = "profileVersion")] +pub enum ModProfile { + #[serde(skip_serializing, rename = "v1")] + V1(ModProfileV1), + #[serde(rename = "v2")] + V2(ModProfileV2), +} + +impl Default for ModProfile { + fn default() -> Self { + ModProfile::V2(ModProfileV2::default()) + } +} + +#[derive(Debug, Error)] +pub enum ProfileMergeError { + #[error("profiles do not support the same games")] + MismatchedSupports, +} + +impl ModProfile { + pub fn from_file>(path: P) -> Result { + let path = path.as_ref(); + let mut file = File::open(path)?; + + match path.extension().and_then(|ext| ext.to_str()) { + Some("toml") | Some("me3") => { + let mut file_contents = String::new(); + let _ = file.read_to_string(&mut file_contents)?; + + match path + .file_stem() + .and_then(|stem| Path::new(stem).extension()) + .and_then(|ext| ext.to_str()) + { + Some("json") => serde_json::from_str(&file_contents).map_err(io::Error::other), + _ => toml::from_str(&file_contents).map_err(io::Error::other), + } + } + Some("json") => serde_json::from_reader(file).map_err(io::Error::other), + ext => Err(io::Error::other(format!( + "\"{}\" is unsupported", + ext.unwrap_or("no file extension") + ))), + } + } + + pub fn try_merge(&self, other: &Self) -> Result { + let my_supports = self.supports(); + let other_supports = other.supports(); + + let game = if !my_supports.is_empty() && !other_supports.is_empty() { + if let Some(supports) = other_supports.iter().find(|s| my_supports.contains(s)) { + Some(supports) + } else { + return Err(ProfileMergeError::MismatchedSupports); + } + } else { + other_supports.first().or(my_supports.first()) + }; + + let either = |a: Option, b: Option| match (a, b) { + (Some(true), _) => Some(true), + (_, Some(true)) => Some(true), + _ => a.or(b), + }; + + let profile = ModProfileBuilder::new() + .with_supported_game(game.cloned()) + .with_savefile(self.savefile()) + .with_dependencies(self.natives().into_iter().chain(other.natives())) + .with_dependencies(self.packages().into_iter().chain(other.packages())) + .with_dependencies(self.profiles().into_iter().chain(other.profiles())) + .start_online(either(other.start_online(), self.start_online())) + .disable_arxan(either(other.disable_arxan(), self.disable_arxan())) + .build(); + + Ok(profile) + } + + pub fn game(&self) -> Option { + match self { + ModProfile::V1(v1) => match &v1.supports[..] { + [Supports { game, .. }] => Some(*game), + _ => None, + }, + ModProfile::V2(v2) => v2.supports, + } + } + + pub fn supports(&self) -> Vec { + match self { + ModProfile::V1(v1) => v1.supports.iter().map(|s| s.game).collect(), + ModProfile::V2(v2) => v2.supports.iter().cloned().collect(), + } + } + + pub fn natives(&self) -> Vec { + match self { + ModProfile::V1(v1) => v1.natives.clone(), + ModProfile::V2(v2) => v2.natives.clone(), + } + } + + pub fn packages(&self) -> Vec { + match self { + ModProfile::V1(v1) => v1.packages.clone(), + ModProfile::V2(v2) => v2.packages.clone(), + } + } + + pub fn profiles(&self) -> Vec { + match self { + ModProfile::V1(_) => vec![], + ModProfile::V2(v2) => v2.profiles.clone(), + } + } + + pub fn savefile(&self) -> Option { + match self { + ModProfile::V1(v1) => v1.savefile.clone(), + ModProfile::V2(v2) => v2.savefile.clone(), + } + } + + pub fn start_online(&self) -> Option { + match self { + ModProfile::V1(v1) => v1.start_online, + ModProfile::V2(v2) => v2.start_online, + } + } + + pub fn disable_arxan(&self) -> Option { + match self { + ModProfile::V1(v1) => v1.disable_arxan, + ModProfile::V2(v2) => v2.disable_arxan, + } + } +} + +impl AsRef for ModProfile { + fn as_ref(&self) -> &ModProfile { + self + } +} diff --git a/crates/mod-protocol/src/profile/builder.rs b/crates/mod-protocol/src/profile/builder.rs new file mode 100644 index 00000000..9b09a349 --- /dev/null +++ b/crates/mod-protocol/src/profile/builder.rs @@ -0,0 +1,97 @@ +use std::{ + io, + path::{Path, PathBuf}, +}; + +use crate::{ + mod_file::ModFile, + profile::{ + v2::{ModProfileV2, ProfileDependency}, + ModProfile, + }, + Game, +}; + +#[derive(Default)] +pub struct ModProfileBuilder { + supports: Option, + dependencies: Vec<(String, ProfileDependency)>, + savefile: Option, + start_online: Option, + disable_arxan: Option, +} + +impl ModProfileBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn build(&mut self) -> ModProfile { + let Self { + supports, + dependencies, + savefile, + start_online, + disable_arxan, + } = std::mem::take(self); + + let mut profile = ModProfileV2 { + supports, + savefile, + start_online, + disable_arxan, + ..Default::default() + }; + + for uses in dependencies { + profile.push_dependency(uses); + } + + ModProfile::V2(profile) + } + + pub fn write>(&mut self, path: P) -> io::Result<()> { + let profile = self.build(); + let contents = toml::to_string_pretty(&profile).map_err(io::Error::other)?; + std::fs::write(path, contents) + } + + pub fn with_supported_game(&mut self, game: Option) -> &mut Self { + self.supports = game; + self + } + + #[inline] + pub fn with_paths(&mut self, iter: I) -> &mut Self + where + I: IntoIterator, + { + self.dependencies + .extend(iter.into_iter().map(|i| ModFile::from(i).into())); + self + } + + #[inline] + pub fn with_dependencies(&mut self, iter: I) -> &mut Self + where + I: IntoIterator>, + { + self.dependencies.extend(iter.into_iter().map(Into::into)); + self + } + + pub fn with_savefile(&mut self, name: Option) -> &mut Self { + self.savefile = name; + self + } + + pub fn start_online(&mut self, start_online: Option) -> &mut Self { + self.start_online = start_online; + self + } + + pub fn disable_arxan(&mut self, disable_arxan: Option) -> &mut Self { + self.disable_arxan = disable_arxan; + self + } +} diff --git a/crates/mod-protocol/src/profile/v1.rs b/crates/mod-protocol/src/profile/v1.rs new file mode 100644 index 00000000..0c108358 --- /dev/null +++ b/crates/mod-protocol/src/profile/v1.rs @@ -0,0 +1,190 @@ +use std::path::PathBuf; + +use schemars::{schema_for, JsonSchema}; +use serde::Deserialize; + +use crate::{ + mod_file::ModFile, + native::{Native, NativeInitializerCondition, NativeInitializerDelay}, + package::Package, + Game, +}; + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(from = "ModProfileV1Layout")] +pub struct ModProfileV1 { + /// The games that this profile supports. + #[serde(default)] + pub supports: Vec, + + /// Native modules (DLLs) that will be loaded. + #[serde(default)] + #[serde(alias = "native")] + pub natives: Vec, + + /// A collection of packages containing assets that should be considered for loading + /// before the DVDBND. + #[serde(default)] + #[serde(alias = "package")] + pub packages: Vec, + + /// Name of an alternative savefile to use (in the default savefile directory). + #[serde(default)] + pub savefile: Option, + + /// Starts the game with multiplayer server connectivity enabled. + #[serde(default)] + pub start_online: Option, + + /// Try to neutralize Arxan GuardIT code protection to improve mod stability. + #[serde(default)] + pub disable_arxan: Option, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema)] +pub struct Supports { + #[serde(rename = "game")] + pub game: Game, + + #[serde(rename = "since")] + pub since_version: Option, +} + +#[derive(Default, Deserialize, JsonSchema)] +struct ModProfileV1Layout { + #[serde(default)] + pub supports: Vec, + #[serde(default)] + #[serde(alias = "native")] + pub natives: Vec, + #[serde(default)] + #[serde(alias = "package")] + pub packages: Vec, + #[serde(default)] + pub savefile: Option, + #[serde(default)] + pub start_online: Option, + #[serde(default)] + pub disable_arxan: Option, +} + +fn on() -> bool { + true +} + +fn off() -> bool { + false +} + +#[derive(Deserialize, JsonSchema)] +enum NativeInitializerConditionV1 { + #[serde(rename = "delay")] + Delay { ms: usize }, + #[serde(rename = "function")] + Function(String), +} + +#[allow(dead_code)] +#[derive(Deserialize, JsonSchema)] +struct NativeV1 { + path: ModFileV1, + #[serde(default = "off")] + optional: bool, + #[serde(default = "on")] + enabled: bool, + #[serde(default)] + load_before: Vec, + #[serde(default)] + load_after: Vec, + initializer: Option, + finalizer: Option, +} + +#[allow(dead_code)] +#[derive(Deserialize, JsonSchema)] +pub struct PackageV1 { + id: Option, + #[serde(default = "on")] + enabled: bool, + #[serde(alias = "source")] + path: ModFileV1, + #[serde(default)] + load_after: Vec, + #[serde(default)] + load_before: Vec, +} + +#[derive(Deserialize, JsonSchema)] +struct ModFileV1(PathBuf); + +#[allow(dead_code)] +#[derive(Deserialize, JsonSchema)] +struct DependentV1 { + id: String, + optional: bool, +} + +impl From for ModProfileV1 { + fn from(layout: ModProfileV1Layout) -> Self { + Self { + supports: layout.supports, + natives: layout.natives.into_iter().map(Into::into).collect(), + packages: layout.packages.into_iter().map(Into::into).collect(), + savefile: layout.savefile, + start_online: layout.start_online, + disable_arxan: layout.disable_arxan, + } + } +} + +impl From for Native { + fn from(value: NativeV1) -> Self { + Self { + inner: ModFile { + enabled: value.enabled, + optional: value.optional, + ..value.path.0.into() + }, + initializer: match value.initializer { + Some(NativeInitializerConditionV1::Delay { ms }) => { + Some(NativeInitializerCondition { + delay: Some(NativeInitializerDelay { ms }), + function: None, + }) + } + Some(NativeInitializerConditionV1::Function(name)) => { + Some(NativeInitializerCondition { + delay: None, + function: Some(name), + }) + } + None => None, + }, + } + } +} + +impl From for Package { + fn from(value: PackageV1) -> Self { + let mut item = ModFile { + enabled: value.enabled, + ..value.path.0.into() + }; + + if let Some(id) = value.id { + item.name = id; + } + + Self(item) + } +} + +impl JsonSchema for ModProfileV1 { + fn schema_name() -> std::borrow::Cow<'static, str> { + "ModProfileV1".into() + } + + fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + schema_for!(ModProfileV1Layout) + } +} diff --git a/crates/mod-protocol/src/profile/v2.rs b/crates/mod-protocol/src/profile/v2.rs new file mode 100644 index 00000000..1ae6d839 --- /dev/null +++ b/crates/mod-protocol/src/profile/v2.rs @@ -0,0 +1,277 @@ +use std::path::PathBuf; + +use indexmap::IndexMap; +use schemars::{schema_for, JsonSchema}; +use serde::{Deserialize, Serialize}; + +use crate::{ + mod_file::ModFile, + native::{Native, NativeInitializerCondition}, + package::Package, + Game, +}; + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(from = "ModProfileV2Layout", into = "ModProfileV2Layout")] +pub struct ModProfileV2 { + /// The game that this profile supports. + pub supports: Option, + + /// Native modules (DLLs) that will be loaded. + pub natives: Vec, + + /// A collection of packages containing assets to be added to the virtual file system. + pub packages: Vec, + + /// Other profiles listed as dependencies by this profile. + pub profiles: Vec, + + /// Name of an alternative savefile to use (in the default savefile directory). + pub savefile: Option, + + /// Starts the game with multiplayer server connectivity enabled. + pub start_online: Option, + + /// Try to neutralize Arxan GuardIT code protection to improve mod stability. + pub disable_arxan: Option, +} + +impl ModProfileV2 { + pub(super) fn push_dependency(&mut self, (name, dependency): (String, ProfileDependency)) { + let file_path = match &dependency { + ProfileDependency::Simple(path) => path, + ProfileDependency::Full { path, .. } => path, + }; + + let file_name = file_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_ascii_lowercase(); + + if file_name.ends_with(".dll") { + self.natives.push((name, dependency).into()); + } else if file_name.ends_with(".me3") + || file_name.ends_with(".me3.toml") + || file_name.ends_with(".me3.json") + { + self.profiles.push((name, dependency).into()); + } else { + self.packages.push((name, dependency).into()); + } + } +} + +#[derive(Default, Deserialize, Serialize, JsonSchema)] +struct ModProfileV2Layout { + #[serde(default)] + game: ProfileGame, + + #[serde(skip_serializing_if = "IndexMap::is_empty")] + dependencies: IndexMap, +} + +#[derive(Default, Deserialize, Serialize, JsonSchema)] +struct ProfileGame { + #[serde(default)] + launch: Option, + + #[serde(default)] + savefile: Option, + + #[serde(default)] + start_online: Option, + + #[serde(default)] + disable_arxan: Option, +} + +#[derive(Clone, Deserialize, Serialize, JsonSchema)] +#[serde(untagged)] +pub enum ProfileDependency { + Simple(PathBuf), + Full { + path: PathBuf, + + #[serde( + default = "ProfileDependency::enabled_default", + skip_serializing_if = "ProfileDependency::enabled_is_default" + )] + enabled: bool, + + #[serde( + default = "ProfileDependency::optional_default", + skip_serializing_if = "ProfileDependency::optional_is_default" + )] + optional: bool, + + #[serde(default)] + initializer: Option, + }, +} + +impl ProfileDependency { + fn into_parts(self) -> (PathBuf, bool, bool, Option) { + match self { + Self::Simple(path) => ( + path, + Self::enabled_default(), + Self::optional_default(), + None, + ), + Self::Full { + path, + enabled, + optional, + initializer, + } => (path, enabled, optional, initializer), + } + } + + fn from_parts( + path: PathBuf, + enabled: bool, + optional: bool, + initializer: Option, + ) -> Self { + if enabled == Self::enabled_default() + && optional == Self::optional_default() + && initializer.is_none() + { + Self::Simple(path) + } else { + Self::Full { + path, + enabled, + optional, + initializer, + } + } + } + + fn enabled_default() -> bool { + true + } + + fn enabled_is_default(enabled: &bool) -> bool { + *enabled == Self::enabled_default() + } + + fn optional_default() -> bool { + false + } + + fn optional_is_default(optional: &bool) -> bool { + *optional == Self::optional_default() + } +} + +impl From for ModProfileV2 { + fn from(layout: ModProfileV2Layout) -> Self { + let mut profile = Self { + supports: layout.game.launch, + savefile: layout.game.savefile, + start_online: layout.game.start_online, + disable_arxan: layout.game.disable_arxan, + ..Default::default() + }; + + for dep in layout.dependencies { + profile.push_dependency(dep); + } + + profile + } +} + +impl From for ModProfileV2Layout { + fn from(profile: ModProfileV2) -> Self { + let mut dependencies = IndexMap::new(); + + dependencies.extend(profile.natives.into_iter().map(Into::into)); + dependencies.extend(profile.packages.into_iter().map(Into::into)); + dependencies.extend(profile.profiles.into_iter().map(Into::into)); + + Self { + game: ProfileGame { + launch: profile.supports, + savefile: profile.savefile, + start_online: profile.start_online, + disable_arxan: profile.disable_arxan, + }, + dependencies, + } + } +} + +impl From<(String, ProfileDependency)> for Native { + fn from((name, dependency): (String, ProfileDependency)) -> Self { + let (path, enabled, optional, initializer) = dependency.into_parts(); + Self { + inner: ModFile { + name, + path, + enabled, + optional, + }, + initializer, + } + } +} + +impl From for (String, ProfileDependency) { + fn from(native: Native) -> Self { + ( + native.inner.name, + ProfileDependency::from_parts( + native.inner.path, + native.inner.enabled, + native.inner.optional, + native.initializer, + ), + ) + } +} + +impl From<(String, ProfileDependency)> for Package { + fn from(dependency: (String, ProfileDependency)) -> Self { + ModFile::from(dependency).into() + } +} + +impl From for (String, ProfileDependency) { + fn from(package: Package) -> Self { + package.0.into() + } +} + +impl From<(String, ProfileDependency)> for ModFile { + fn from((name, dependency): (String, ProfileDependency)) -> Self { + let (path, enabled, optional, _) = dependency.into_parts(); + Self { + name, + path, + enabled, + optional, + } + } +} + +impl From for (String, ProfileDependency) { + fn from(item: ModFile) -> Self { + ( + item.name, + ProfileDependency::from_parts(item.path, item.enabled, item.optional, None), + ) + } +} + +impl JsonSchema for ModProfileV2 { + fn schema_name() -> std::borrow::Cow<'static, str> { + "ModProfileV2".into() + } + + fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + schema_for!(ModProfileV2Layout) + } +} diff --git a/crates/mod-protocol/test-data/basic_config.me3.toml.expected b/crates/mod-protocol/test-data/basic_config.me3.toml.expected deleted file mode 100644 index 5abfd505..00000000 --- a/crates/mod-protocol/test-data/basic_config.me3.toml.expected +++ /dev/null @@ -1,34 +0,0 @@ -V1( - ModProfileV1 { - supports: [], - natives: [ - Native { - path: ModFile( - "my_native.dll", - ), - optional: true, - enabled: true, - load_before: [], - load_after: [], - initializer: None, - finalizer: None, - }, - ], - packages: [ - Package { - id: Some( - "my-mod", - ), - enabled: true, - path: ModFile( - "mod/", - ), - load_after: [], - load_before: [], - }, - ], - savefile: None, - start_online: None, - disable_arxan: None, - }, -) diff --git a/crates/mod-protocol/test-data/plural_packages.me3.expected b/crates/mod-protocol/test-data/plural_packages.me3.expected deleted file mode 100644 index b99936e5..00000000 --- a/crates/mod-protocol/test-data/plural_packages.me3.expected +++ /dev/null @@ -1,22 +0,0 @@ -V1( - ModProfileV1 { - supports: [], - natives: [], - packages: [ - Package { - id: Some( - "test-pkg", - ), - enabled: true, - path: ModFile( - ".", - ), - load_after: [], - load_before: [], - }, - ], - savefile: None, - start_online: None, - disable_arxan: None, - }, -) diff --git a/crates/mod-protocol/test-data/singular_package.me3.expected b/crates/mod-protocol/test-data/singular_package.me3.expected deleted file mode 100644 index b99936e5..00000000 --- a/crates/mod-protocol/test-data/singular_package.me3.expected +++ /dev/null @@ -1,22 +0,0 @@ -V1( - ModProfileV1 { - supports: [], - natives: [], - packages: [ - Package { - id: Some( - "test-pkg", - ), - enabled: true, - path: ModFile( - ".", - ), - load_after: [], - load_before: [], - }, - ], - savefile: None, - start_online: None, - disable_arxan: None, - }, -) diff --git a/crates/mod-protocol/test-data/v1/advanced_config.me3 b/crates/mod-protocol/test-data/v1/advanced_config.me3 new file mode 100644 index 00000000..868d33dc --- /dev/null +++ b/crates/mod-protocol/test-data/v1/advanced_config.me3 @@ -0,0 +1,34 @@ +profileVersion = "v1" +disable_arxan = false +start_online = true + +[[supports]] +game = "nightreign" + +[[packages]] +id = "my-mod" +source = "./mod" + +[[packages]] +source = "./unnamed-mod" +load_after = [{ id = "my-mod", optional = false }] + +[[packages]] +id = "my-other-mod" +path = "./nr-mods/mod" +enabled = true +optional = true +load_before = [{ id = "my-mod", optional = true }] + +[[packages]] +id = "my-disabled-mod" +source = "./unused-mod" +enabled = false + +[[natives]] +path = "my_native.dll" +optional = true + +[[natives]] +path = "./nr-mods/my_other_native.dll" +initializer.function = "init_my_dll" diff --git a/crates/mod-protocol/test-data/v1/advanced_config.me3.expected b/crates/mod-protocol/test-data/v1/advanced_config.me3.expected new file mode 100644 index 00000000..e7908919 --- /dev/null +++ b/crates/mod-protocol/test-data/v1/advanced_config.me3.expected @@ -0,0 +1,78 @@ +V1( + ModProfileV1 { + supports: [ + Supports { + game: Nightreign, + since_version: None, + }, + ], + natives: [ + Native { + inner: ModFile { + name: "my_native-ac5db8a5", + path: "my_native.dll", + enabled: true, + optional: true, + }, + initializer: None, + }, + Native { + inner: ModFile { + name: "my_other_native-eae16c4a", + path: "./nr-mods/my_other_native.dll", + enabled: true, + optional: false, + }, + initializer: Some( + NativeInitializerCondition { + delay: None, + function: Some( + "init_my_dll", + ), + }, + ), + }, + ], + packages: [ + Package( + ModFile { + name: "my-mod", + path: "./mod", + enabled: true, + optional: false, + }, + ), + Package( + ModFile { + name: "unnamed-mod-f0cb703b", + path: "./unnamed-mod", + enabled: true, + optional: false, + }, + ), + Package( + ModFile { + name: "my-other-mod", + path: "./nr-mods/mod", + enabled: true, + optional: false, + }, + ), + Package( + ModFile { + name: "my-disabled-mod", + path: "./unused-mod", + enabled: false, + optional: false, + }, + ), + ], + savefile: None, + start_online: Some( + true, + ), + disable_arxan: Some( + false, + ), + }, +) diff --git a/crates/mod-protocol/test-data/basic_config.me3.toml b/crates/mod-protocol/test-data/v1/basic_config.me3 similarity index 85% rename from crates/mod-protocol/test-data/basic_config.me3.toml rename to crates/mod-protocol/test-data/v1/basic_config.me3 index fd844500..fdc96d0a 100644 --- a/crates/mod-protocol/test-data/basic_config.me3.toml +++ b/crates/mod-protocol/test-data/v1/basic_config.me3 @@ -2,7 +2,7 @@ profileVersion = "v1" [[packages]] id = "my-mod" -source = "mod/" +source = "./mod" [[natives]] path = "my_native.dll" diff --git a/crates/mod-protocol/test-data/v1/basic_config.me3.expected b/crates/mod-protocol/test-data/v1/basic_config.me3.expected new file mode 100644 index 00000000..43428dd8 --- /dev/null +++ b/crates/mod-protocol/test-data/v1/basic_config.me3.expected @@ -0,0 +1,29 @@ +V1( + ModProfileV1 { + supports: [], + natives: [ + Native { + inner: ModFile { + name: "my_native-ac5db8a5", + path: "my_native.dll", + enabled: true, + optional: true, + }, + initializer: None, + }, + ], + packages: [ + Package( + ModFile { + name: "my-mod", + path: "./mod", + enabled: true, + optional: false, + }, + ), + ], + savefile: None, + start_online: None, + disable_arxan: None, + }, +) diff --git a/crates/mod-protocol/test-data/plural_packages.me3 b/crates/mod-protocol/test-data/v1/plural_packages.me3 similarity index 100% rename from crates/mod-protocol/test-data/plural_packages.me3 rename to crates/mod-protocol/test-data/v1/plural_packages.me3 diff --git a/crates/mod-protocol/test-data/v1/plural_packages.me3.expected b/crates/mod-protocol/test-data/v1/plural_packages.me3.expected new file mode 100644 index 00000000..eed4485d --- /dev/null +++ b/crates/mod-protocol/test-data/v1/plural_packages.me3.expected @@ -0,0 +1,19 @@ +V1( + ModProfileV1 { + supports: [], + natives: [], + packages: [ + Package( + ModFile { + name: "test-pkg", + path: ".", + enabled: true, + optional: false, + }, + ), + ], + savefile: None, + start_online: None, + disable_arxan: None, + }, +) diff --git a/crates/mod-protocol/test-data/singular_package.me3 b/crates/mod-protocol/test-data/v1/singular_package.me3 similarity index 100% rename from crates/mod-protocol/test-data/singular_package.me3 rename to crates/mod-protocol/test-data/v1/singular_package.me3 diff --git a/crates/mod-protocol/test-data/v1/singular_package.me3.expected b/crates/mod-protocol/test-data/v1/singular_package.me3.expected new file mode 100644 index 00000000..eed4485d --- /dev/null +++ b/crates/mod-protocol/test-data/v1/singular_package.me3.expected @@ -0,0 +1,19 @@ +V1( + ModProfileV1 { + supports: [], + natives: [], + packages: [ + Package( + ModFile { + name: "test-pkg", + path: ".", + enabled: true, + optional: false, + }, + ), + ], + savefile: None, + start_online: None, + disable_arxan: None, + }, +) diff --git a/crates/mod-protocol/test-data/v2/advanced_config.me3 b/crates/mod-protocol/test-data/v2/advanced_config.me3 new file mode 100644 index 00000000..fe88c6f3 --- /dev/null +++ b/crates/mod-protocol/test-data/v2/advanced_config.me3 @@ -0,0 +1,16 @@ +profileVersion = "v2" + +[game] +launch = "nightreign" +savefile = "NRMOD.sl2" +disable_arxan = true +start_online = true + +[dependencies] +my_mod.path = './my-mod' +my_dll = { path = './my-mod/my_dll.dll', initializer.delay.ms = 3000 } +hks_debug = { path = './hks_debug.me3', optional = true } +my_other_mod = { path = './my-other-mod', disabled = true } +my_profile = { path = 'my_profile.me3', optional = false, disabled = true } +my_other_dll.path = 'other_dll.dll' +my_other_dll.initializer.function = "init_my_dll" diff --git a/crates/mod-protocol/test-data/v2/advanced_config.me3.expected b/crates/mod-protocol/test-data/v2/advanced_config.me3.expected new file mode 100644 index 00000000..293cfae8 --- /dev/null +++ b/crates/mod-protocol/test-data/v2/advanced_config.me3.expected @@ -0,0 +1,84 @@ +V2( + ModProfileV2 { + supports: Some( + Nightreign, + ), + natives: [ + Native { + inner: ModFile { + name: "my_dll", + path: "./my-mod/my_dll.dll", + enabled: true, + optional: false, + }, + initializer: Some( + NativeInitializerCondition { + delay: Some( + NativeInitializerDelay { + ms: 3000, + }, + ), + function: None, + }, + ), + }, + Native { + inner: ModFile { + name: "my_other_dll", + path: "other_dll.dll", + enabled: true, + optional: false, + }, + initializer: Some( + NativeInitializerCondition { + delay: None, + function: Some( + "init_my_dll", + ), + }, + ), + }, + ], + packages: [ + Package( + ModFile { + name: "my_mod", + path: "./my-mod", + enabled: true, + optional: false, + }, + ), + Package( + ModFile { + name: "my_other_mod", + path: "./my-other-mod", + enabled: true, + optional: false, + }, + ), + ], + profiles: [ + ModFile { + name: "hks_debug", + path: "./hks_debug.me3", + enabled: true, + optional: true, + }, + ModFile { + name: "my_profile", + path: "my_profile.me3", + enabled: true, + optional: false, + }, + ], + savefile: Some( + "NRMOD.sl2", + ), + start_online: Some( + true, + ), + disable_arxan: Some( + true, + ), + }, +) diff --git a/crates/mod-protocol/test-data/v2/basic_config.me3 b/crates/mod-protocol/test-data/v2/basic_config.me3 new file mode 100644 index 00000000..8e8df5f3 --- /dev/null +++ b/crates/mod-protocol/test-data/v2/basic_config.me3 @@ -0,0 +1,6 @@ +profileVersion = "v2" + +[dependencies] +my_mod.path = './my-mod' +my_dll.path = './my-mod/my_dll.dll' +my_profile.path = 'my_profile.me3' diff --git a/crates/mod-protocol/test-data/v2/basic_config.me3.expected b/crates/mod-protocol/test-data/v2/basic_config.me3.expected new file mode 100644 index 00000000..c5844f89 --- /dev/null +++ b/crates/mod-protocol/test-data/v2/basic_config.me3.expected @@ -0,0 +1,37 @@ +V2( + ModProfileV2 { + supports: None, + natives: [ + Native { + inner: ModFile { + name: "my_dll", + path: "./my-mod/my_dll.dll", + enabled: true, + optional: false, + }, + initializer: None, + }, + ], + packages: [ + Package( + ModFile { + name: "my_mod", + path: "./my-mod", + enabled: true, + optional: false, + }, + ), + ], + profiles: [ + ModFile { + name: "my_profile", + path: "my_profile.me3", + enabled: true, + optional: false, + }, + ], + savefile: None, + start_online: None, + disable_arxan: None, + }, +) diff --git a/crates/mod-protocol/test-data/v2/merge_config.me3.expected b/crates/mod-protocol/test-data/v2/merge_config.me3.expected new file mode 100644 index 00000000..1dc719cd --- /dev/null +++ b/crates/mod-protocol/test-data/v2/merge_config.me3.expected @@ -0,0 +1,69 @@ +V2( + ModProfileV2 { + supports: Some( + EldenRing, + ), + natives: [ + Native { + inner: ModFile { + name: "my_dll", + path: "./my-mod/my_dll.dll", + enabled: true, + optional: false, + }, + initializer: None, + }, + Native { + inner: ModFile { + name: "test_dll", + path: "test.dll", + enabled: true, + optional: false, + }, + initializer: None, + }, + Native { + inner: ModFile { + name: "other_dll", + path: "./other_mods/mod.dll", + enabled: true, + optional: true, + }, + initializer: None, + }, + ], + packages: [ + Package( + ModFile { + name: "my_mod", + path: "./my-mod", + enabled: true, + optional: false, + }, + ), + ], + profiles: [ + ModFile { + name: "my_profile", + path: "my_profile.me3", + enabled: true, + optional: false, + }, + ModFile { + name: "big_overhaul", + path: "./other_mods/overhaul.me3", + enabled: true, + optional: false, + }, + ], + savefile: Some( + "ERMOD.sl2", + ), + start_online: Some( + true, + ), + disable_arxan: Some( + true, + ), + }, +) diff --git a/crates/mod-protocol/test-data/v2/merge_config_a.me3 b/crates/mod-protocol/test-data/v2/merge_config_a.me3 new file mode 100644 index 00000000..02e38648 --- /dev/null +++ b/crates/mod-protocol/test-data/v2/merge_config_a.me3 @@ -0,0 +1,12 @@ +profileVersion = "v2" + +[game] +launch = "eldenring" +savefile = "ERMOD.sl2" +disable_arxan = true +start_online = false + +[dependencies] +my_mod.path = './my-mod' +my_dll.path = './my-mod/my_dll.dll' +my_profile.path = 'my_profile.me3' diff --git a/crates/mod-protocol/test-data/v2/merge_config_b.me3 b/crates/mod-protocol/test-data/v2/merge_config_b.me3 new file mode 100644 index 00000000..a488e32c --- /dev/null +++ b/crates/mod-protocol/test-data/v2/merge_config_b.me3 @@ -0,0 +1,12 @@ +profileVersion = "v2" + +[game] +launch = "eldenring" +savefile = "TEST.sl2" +disable_arxan = false +start_online = true + +[dependencies] +test_dll.path = 'test.dll' +other_dll = { path = './other_mods/mod.dll', optional = true } +big_overhaul.path = './other_mods/overhaul.me3' diff --git a/installer.nsi b/installer.nsi index 35fa21ec..080e88b0 100644 --- a/installer.nsi +++ b/installer.nsi @@ -195,13 +195,13 @@ Section "Main Application" SEC01 nsExec::Exec '"$INSTDIR\bin\me3.exe" add-to-path' CreateDirectory "$LOCALAPPDATA\garyttierney\me3\config\profiles\darksouls3-mods" - nsExec::Exec '"$INSTDIR\bin\me3.exe" profile create -g ds3 --package darksouls3-mods darksouls3-default' + nsExec::Exec '"$INSTDIR\bin\me3.exe" profile create -g ds3 -u darksouls3-mods darksouls3-default' CreateDirectory "$LOCALAPPDATA\garyttierney\me3\config\profiles\eldenring-mods" - nsExec::Exec '"$INSTDIR\bin\me3.exe" profile create -g er --package eldenring-mods eldenring-default' - + nsExec::Exec '"$INSTDIR\bin\me3.exe" profile create -g er -u eldenring-mods eldenring-default' + CreateDirectory "$LOCALAPPDATA\garyttierney\me3\config\profiles\nightreign-mods" - nsExec::Exec '"$INSTDIR\bin\me3.exe" profile create -g nr --package nightreign-mods nightreign-default' + nsExec::Exec '"$INSTDIR\bin\me3.exe" profile create -g nr -u nightreign-mods nightreign-default' CreateDirectory "$SMPROGRAMS\me3" CreateShortCut "$SMPROGRAMS\me3\DARK SOULS III (me3).lnk" "$INSTDIR\bin\me3.exe" \ diff --git a/schemas/mod-profile.json b/schemas/mod-profile.json index a5121de0..29a71b26 100644 --- a/schemas/mod-profile.json +++ b/schemas/mod-profile.json @@ -14,276 +14,451 @@ "required": [ "profileVersion" ] + }, + { + "type": "object", + "properties": { + "profileVersion": { + "type": "string", + "const": "v2" + } + }, + "$ref": "#/$defs/ModProfileV2", + "required": [ + "profileVersion" + ] } ], "$defs": { - "Supports": { + "ModProfileV1": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ModProfileV1Layout", "type": "object", "properties": { - "game": { - "$ref": "#/$defs/Game" + "supports": { + "type": "array", + "items": { + "$ref": "#/$defs/Supports" + } + }, + "natives": { + "type": "array", + "items": { + "$ref": "#/$defs/NativeV1" + } }, - "since": { + "packages": { + "type": "array", + "items": { + "$ref": "#/$defs/PackageV1" + } + }, + "savefile": { "type": [ "string", "null" - ] - } - }, - "required": [ - "game" - ] - }, - "Game": { - "description": "List of games supported by me3", - "type": "string", - "oneOf": [ - { - "title": "Dark Souls III", - "description": "Dark Souls III (Steam App ID: 374320)", - "enum": [ - "darksouls3", - "ds3" - ] + ], + "default": null }, - { - "title": "Sekiro: Shadows Die Twice", - "description": "Sekiro: Shadows Die Twice (Steam App ID: 814380)", - "enum": [ - "sekiro", - "sdt" - ] + "start_online": { + "type": [ + "boolean", + "null" + ], + "default": null }, - { - "title": "Elden Ring", - "description": "Elden Ring (Steam App ID: 1245620)", - "enum": [ - "eldenring", - "er", - "elden-ring" + "disable_arxan": { + "type": [ + "boolean", + "null" + ], + "default": null + } + }, + "$defs": { + "Supports": { + "type": "object", + "properties": { + "game": { + "$ref": "#/$defs/Game" + }, + "since": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "game" ] }, - { - "title": "Armored Core VI: Fires of Rubicon", - "description": "Armored Core VI: Fires of Rubicon (Steam App ID: 1888160)", - "enum": [ - "armoredcore6", - "ac6" + "Game": { + "description": "List of games supported by me3", + "type": "string", + "oneOf": [ + { + "title": "Dark Souls III", + "description": "Dark Souls III (Steam App ID: 374320)", + "enum": [ + "darksouls3", + "ds3" + ] + }, + { + "title": "Sekiro: Shadows Die Twice", + "description": "Sekiro: Shadows Die Twice (Steam App ID: 814380)", + "enum": [ + "sekiro", + "sdt" + ] + }, + { + "title": "Elden Ring", + "description": "Elden Ring (Steam App ID: 1245620)", + "enum": [ + "eldenring", + "er", + "elden-ring" + ] + }, + { + "title": "Armored Core VI: Fires of Rubicon", + "description": "Armored Core VI: Fires of Rubicon (Steam App ID: 1888160)", + "enum": [ + "armoredcore6", + "ac6" + ] + }, + { + "title": "Elden Ring Nightreign", + "description": "Elden Ring Nightreign (Steam App ID: 2622380)", + "enum": [ + "nightreign", + "nr", + "nightrein" + ] + } ] }, - { - "title": "Elden Ring Nightreign", - "description": "Elden Ring Nightreign (Steam App ID: 2622380)", - "enum": [ - "nightreign", - "nr", - "nightrein" + "NativeV1": { + "type": "object", + "properties": { + "path": { + "$ref": "#/$defs/ModFile" + }, + "optional": { + "type": "boolean", + "default": false + }, + "enabled": { + "type": "boolean", + "default": true + }, + "load_before": { + "type": "array", + "items": { + "$ref": "#/$defs/DependentV1" + } + }, + "load_after": { + "type": "array", + "items": { + "$ref": "#/$defs/DependentV1" + } + }, + "initializer": { + "anyOf": [ + { + "$ref": "#/$defs/NativeInitializerConditionV1" + }, + { + "type": "null" + } + ] + }, + "finalizer": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "path" ] - } - ] - }, - "Native": { - "type": "object", - "properties": { - "path": { - "description": "Path to the DLL. Can be relative to the mod profile.", - "$ref": "#/$defs/ModFile" - }, - "optional": { - "description": "If this native fails to load and this value is false, treat it as a critical error.", - "type": "boolean", - "default": false }, - "enabled": { - "description": "Should this native be loaded?", - "type": "boolean", - "default": true - }, - "load_before": { - "type": "array", - "items": { - "$ref": "#/$defs/Dependent" - }, - "default": [] + "ModFile": { + "type": "string" }, - "load_after": { - "type": "array", - "items": { - "$ref": "#/$defs/Dependent" + "DependentV1": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "optional": { + "type": "boolean" + } }, - "default": [] + "required": [ + "id", + "optional" + ] }, - "initializer": { - "description": "An optional symbol to be called after this native successfully loads.", - "anyOf": [ + "NativeInitializerConditionV1": { + "oneOf": [ { - "$ref": "#/$defs/NativeInitializerCondition" + "type": "object", + "properties": { + "delay": { + "type": "object", + "properties": { + "ms": { + "type": "integer", + "format": "uint", + "minimum": 0 + } + }, + "required": [ + "ms" + ] + } + }, + "required": [ + "delay" + ], + "additionalProperties": false }, { - "type": "null" + "type": "object", + "properties": { + "function": { + "type": "string" + } + }, + "required": [ + "function" + ], + "additionalProperties": false } ] }, - "finalizer": { - "description": "An optional symbol to be called when this native successfully is queued for unload.", - "type": [ - "string", - "null" + "PackageV1": { + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "enabled": { + "type": "boolean", + "default": true + }, + "path": { + "$ref": "#/$defs/ModFile" + }, + "load_after": { + "type": "array", + "items": { + "$ref": "#/$defs/DependentV1" + } + }, + "load_before": { + "type": "array", + "items": { + "$ref": "#/$defs/DependentV1" + } + } + }, + "required": [ + "path" ] } - }, - "required": [ - "path" - ] - }, - "ModFile": { - "description": "A filesystem path to the contents of a package. May be relative to the [ModProfile] containing\nit.", - "type": "string" + } }, - "Dependent": { + "ModProfileV2": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ModProfileV2Layout", "type": "object", "properties": { - "id": { - "type": "string" + "game": { + "$ref": "#/$defs/ProfileGame", + "default": { + "launch": null, + "savefile": null, + "start_online": null, + "disable_arxan": null + } }, - "optional": { - "type": "boolean" + "dependencies": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/ProfileDependency" + } } }, "required": [ - "id", - "optional" - ] - }, - "NativeInitializerCondition": { - "oneOf": [ - { + "dependencies" + ], + "$defs": { + "ProfileGame": { "type": "object", "properties": { - "delay": { + "launch": { + "anyOf": [ + { + "$ref": "#/$defs/Game" + }, + { + "type": "null" + } + ], + "default": null + }, + "savefile": { + "type": [ + "string", + "null" + ], + "default": null + }, + "start_online": { + "type": [ + "boolean", + "null" + ], + "default": null + }, + "disable_arxan": { + "type": [ + "boolean", + "null" + ], + "default": null + } + } + }, + "Game": { + "description": "List of games supported by me3", + "type": "string", + "oneOf": [ + { + "title": "Dark Souls III", + "description": "Dark Souls III (Steam App ID: 374320)", + "enum": [ + "darksouls3", + "ds3" + ] + }, + { + "title": "Sekiro: Shadows Die Twice", + "description": "Sekiro: Shadows Die Twice (Steam App ID: 814380)", + "enum": [ + "sekiro", + "sdt" + ] + }, + { + "title": "Elden Ring", + "description": "Elden Ring (Steam App ID: 1245620)", + "enum": [ + "eldenring", + "er", + "elden-ring" + ] + }, + { + "title": "Armored Core VI: Fires of Rubicon", + "description": "Armored Core VI: Fires of Rubicon (Steam App ID: 1888160)", + "enum": [ + "armoredcore6", + "ac6" + ] + }, + { + "title": "Elden Ring Nightreign", + "description": "Elden Ring Nightreign (Steam App ID: 2622380)", + "enum": [ + "nightreign", + "nr", + "nightrein" + ] + } + ] + }, + "ProfileDependency": { + "anyOf": [ + { + "type": "string" + }, + { "type": "object", "properties": { - "ms": { - "type": "integer", - "format": "uint", - "minimum": 0 + "path": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "optional": { + "type": "boolean" + }, + "initializer": { + "anyOf": [ + { + "$ref": "#/$defs/NativeInitializerCondition" + }, + { + "type": "null" + } + ], + "default": null } }, "required": [ - "ms" + "path" ] } - }, - "required": [ - "delay" - ], - "additionalProperties": false + ] }, - { + "NativeInitializerCondition": { "type": "object", "properties": { + "delay": { + "anyOf": [ + { + "$ref": "#/$defs/NativeInitializerDelay" + }, + { + "type": "null" + } + ], + "default": null + }, "function": { - "type": "string" + "type": [ + "string", + "null" + ], + "default": null + } + } + }, + "NativeInitializerDelay": { + "type": "object", + "properties": { + "ms": { + "type": "integer", + "format": "uint", + "minimum": 0 } }, "required": [ - "function" - ], - "additionalProperties": false - } - ] - }, - "Package": { - "description": "A package is a source for files that override files within the existing games DVDBND archives.\nIt points to a local path containing assets matching the hierarchy they would be served under in\nthe DVDBND.", - "type": "object", - "properties": { - "id": { - "description": "The unique identifier for this package.", - "type": [ - "string", - "null" + "ms" ] - }, - "enabled": { - "description": "Enable this package?", - "type": "boolean", - "default": true - }, - "path": { - "description": "A path to the source of this package.", - "$ref": "#/$defs/ModFile" - }, - "load_after": { - "description": "A list of package IDs that this package should load after.", - "type": "array", - "items": { - "$ref": "#/$defs/Dependent" - }, - "default": [] - }, - "load_before": { - "description": "A list of packages that this package should load before.", - "type": "array", - "items": { - "$ref": "#/$defs/Dependent" - }, - "default": [] - } - }, - "required": [ - "path" - ] - }, - "ModProfileV1": { - "type": "object", - "properties": { - "supports": { - "description": "The games that this profile supports.", - "type": "array", - "items": { - "$ref": "#/$defs/Supports" - }, - "default": [] - }, - "natives": { - "description": "Native modules (DLLs) that will be loaded.", - "type": "array", - "items": { - "$ref": "#/$defs/Native" - }, - "default": [] - }, - "packages": { - "description": "A collection of packages containing assets that should be considered for loading\nbefore the DVDBND.", - "type": "array", - "items": { - "$ref": "#/$defs/Package" - }, - "default": [] - }, - "savefile": { - "description": "Name of an alternative savefile to use (in the default savefile directory).", - "type": [ - "string", - "null" - ], - "default": null - }, - "start_online": { - "description": "Starts the game with multiplayer server connectivity enabled.", - "type": [ - "boolean", - "null" - ], - "default": null - }, - "disable_arxan": { - "description": "Try to neutralize Arxan GuardIT code protection to improve mod stability.", - "type": [ - "boolean", - "null" - ], - "default": null } } } From 5626165c3bf3586a9b80be8edc9cccd0bab4709a Mon Sep 17 00:00:00 2001 From: Dasaav-dsv Date: Tue, 16 Sep 2025 23:31:49 +0200 Subject: [PATCH 02/20] chore: Fixup profile.rs Signed-off-by: Dasaav-dsv --- crates/cli/src/commands/profile.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/cli/src/commands/profile.rs b/crates/cli/src/commands/profile.rs index a50ea6fe..17519601 100644 --- a/crates/cli/src/commands/profile.rs +++ b/crates/cli/src/commands/profile.rs @@ -18,11 +18,10 @@ pub enum ProfileCommands { List, /// Show information on a profile. - #[clap(name = "show")] - Show { - /// Name of the profile. - name: String, - }, + Show(ProfileNameArgs), + + /// Upgrade a profile to the latest profile version. + Upgrade(ProfileNameArgs), } #[derive(Args, Debug)] From 583c30170dd4124dd5cfa8f0226d4abe15b40019 Mon Sep 17 00:00:00 2001 From: Dasaav-dsv Date: Wed, 17 Sep 2025 10:04:05 +0200 Subject: [PATCH 03/20] chore: Add back `load_before` and `load_after` fields Signed-off-by: Dasaav-dsv --- crates/mod-host/src/host.rs | 5 + crates/mod-host/src/lib.rs | 37 +--- crates/mod-protocol/src/dependency.rs | 14 +- crates/mod-protocol/src/native.rs | 33 ++- crates/mod-protocol/src/package.rs | 46 +++- crates/mod-protocol/src/profile/v1.rs | 60 +++--- crates/mod-protocol/src/profile/v2.rs | 204 ++++++++++++------ .../test-data/v1/advanced_config.me3.expected | 42 ++-- .../test-data/v1/basic_config.me3.expected | 12 +- .../test-data/v1/plural_packages.me3.expected | 8 +- .../v1/singular_package.me3.expected | 8 +- .../test-data/v2/advanced_config.me3.expected | 20 +- .../test-data/v2/basic_config.me3.expected | 10 +- .../test-data/v2/merge_config.me3.expected | 14 +- schemas/mod-profile.json | 106 ++++++--- 15 files changed, 394 insertions(+), 225 deletions(-) diff --git a/crates/mod-host/src/host.rs b/crates/mod-host/src/host.rs index 0372888b..11b5bb65 100644 --- a/crates/mod-host/src/host.rs +++ b/crates/mod-host/src/host.rs @@ -4,6 +4,7 @@ use std::{ fmt::Debug, marker::Tuple, panic::{self, AssertUnwindSafe}, + ptr, sync::{Arc, Mutex, OnceLock}, time::Duration, }; @@ -18,12 +19,16 @@ use me3_mod_protocol::{ profile::ModProfile, Game, }; +use pelite::pe::Pe; +use regex::bytes::Regex; use retour::Function; use tracing::{error, info, instrument, Span}; +use windows::core::w; use self::hook::HookInstaller; use crate::{ detour::UntypedDetour, + executable::Executable, native::{ModEngineConnectorShim, ModEngineInitializer}, }; diff --git a/crates/mod-host/src/lib.rs b/crates/mod-host/src/lib.rs index 8c6da227..b07506fe 100644 --- a/crates/mod-host/src/lib.rs +++ b/crates/mod-host/src/lib.rs @@ -198,47 +198,20 @@ fn deferred_attach( override_mapping.clone(), )?; - let first_delayed_offset = attach_config - .natives - .iter() - .enumerate() - .filter_map(|(idx, native)| native.initializer.is_some().then_some(idx)) - .next() - .unwrap_or(attach_config.natives.len()); - - let (immediate, delayed) = attach_config.natives.split_at(first_delayed_offset); - - for native in immediate { - if let Err(e) = ModHost::get_attached().load_native(&native.path, &native.initializer) { + for native in &attach_config.natives { + if let Err(e) = ModHost::get_attached().load_native(native) { warn!( - "error" = &*e, - "path" = ?native.path, + error = &*e, + path = %native.path.display(), "failed to load native mod", ); if !native.optional { - return Err(e.into()); + return Err(e); } } } - let delayed = delayed.to_vec(); - std::thread::spawn(move || { - for native in delayed { - if let Err(e) = ModHost::get_attached().load_native(&native.path, &native.initializer) { - warn!( - error = &*e, - path = %native.path.display(), - "failed to load native mod", - ); - - if !native.optional { - panic!("{:#?}", e); - } - } - } - }); - asset_hooks::attach_override( attach_config, exe, diff --git a/crates/mod-protocol/src/dependency.rs b/crates/mod-protocol/src/dependency.rs index 9f464487..d2d96ba3 100644 --- a/crates/mod-protocol/src/dependency.rs +++ b/crates/mod-protocol/src/dependency.rs @@ -242,13 +242,8 @@ pub fn sort_dependencies(items: Vec) -> Result, Depende #[cfg(test)] mod tests { - use std::path::PathBuf; - use super::{sort_dependencies, Dependent}; - use crate::{ - dependency::Dependency as _, - package::{ModFile, Package}, - }; + use crate::{dependency::Dependency as _, mod_file::ModFile, package::Package}; fn mock_package( id: &str, @@ -256,9 +251,10 @@ mod tests { load_before: Vec>, ) -> Package { Package { - id: Some(id.to_owned()), - enabled: true, - path: ModFile(PathBuf::from(id)), + inner: ModFile { + name: id.to_owned(), + ..ModFile::new("pkg") + }, load_after, load_before, } diff --git a/crates/mod-protocol/src/native.rs b/crates/mod-protocol/src/native.rs index f2703d04..a4c6ea4e 100644 --- a/crates/mod-protocol/src/native.rs +++ b/crates/mod-protocol/src/native.rs @@ -6,7 +6,10 @@ use std::{ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::mod_file::{AsModFile, ModFile}; +use crate::{ + dependency::{Dependency, Dependent}, + mod_file::{AsModFile, ModFile}, +}; #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] pub struct NativeInitializerDelay { @@ -26,8 +29,13 @@ pub struct Native { #[serde(flatten)] pub(crate) inner: ModFile, - /// An optional symbol to be called after this native successfully loads. pub initializer: Option, + + #[serde(default)] + pub(crate) load_before: Vec>, + + #[serde(default)] + pub(crate) load_after: Vec>, } impl Native { @@ -42,6 +50,25 @@ impl Native { } } +impl Dependency for Native { + type UniqueId = String; + + fn id(&self) -> Self::UniqueId { + self.path + .file_name() + .map(|f| f.to_string_lossy().to_ascii_lowercase()) + .expect("native had no file name") + } + + fn loads_after(&self) -> &[Dependent] { + &self.load_after + } + + fn loads_before(&self) -> &[Dependent] { + &self.load_before + } +} + impl AsRef for Native { #[inline] fn as_ref(&self) -> &Path { @@ -83,6 +110,8 @@ impl From for Native { Self { inner: item, initializer: None, + load_before: vec![], + load_after: vec![], } } } diff --git a/crates/mod-protocol/src/package.rs b/crates/mod-protocol/src/package.rs index 23369039..f349169e 100644 --- a/crates/mod-protocol/src/package.rs +++ b/crates/mod-protocol/src/package.rs @@ -5,13 +5,25 @@ use std::{ use serde::{Deserialize, Serialize}; -use crate::mod_file::{AsModFile, ModFile}; +use crate::{ + dependency::{Dependency, Dependent}, + mod_file::{AsModFile, ModFile}, +}; /// A package is a source for files that override files within the existing games DVDBND archives. /// It points to a local path containing assets matching the hierarchy they would be served under in /// the DVDBND. #[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Package(pub(crate) ModFile); +pub struct Package { + #[serde(flatten)] + pub(crate) inner: ModFile, + + #[serde(default)] + pub(crate) load_before: Vec>, + + #[serde(default)] + pub(crate) load_after: Vec>, +} impl Package { #[inline] @@ -20,6 +32,22 @@ impl Package { } } +impl Dependency for Package { + type UniqueId = String; + + fn id(&self) -> Self::UniqueId { + self.name.clone() + } + + fn loads_after(&self) -> &[crate::dependency::Dependent] { + &self.load_after + } + + fn loads_before(&self) -> &[crate::dependency::Dependent] { + &self.load_before + } +} + impl AsRef for Package { #[inline] fn as_ref(&self) -> &Path { @@ -32,33 +60,37 @@ impl Deref for Package { #[inline] fn deref(&self) -> &Self::Target { - &self.0 + &self.inner } } impl DerefMut for Package { #[inline] fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 + &mut self.inner } } impl AsModFile for Package { #[inline] fn as_mod_file(&self) -> &ModFile { - &self.0 + &self.inner } #[inline] fn as_mod_file_mut(&mut self) -> &mut ModFile { - &mut self.0 + &mut self.inner } } impl From for Package { #[inline] fn from(item: ModFile) -> Self { - Self(item) + Self { + inner: item, + load_before: vec![], + load_after: vec![], + } } } diff --git a/crates/mod-protocol/src/profile/v1.rs b/crates/mod-protocol/src/profile/v1.rs index 0c108358..4ff75660 100644 --- a/crates/mod-protocol/src/profile/v1.rs +++ b/crates/mod-protocol/src/profile/v1.rs @@ -4,6 +4,7 @@ use schemars::{schema_for, JsonSchema}; use serde::Deserialize; use crate::{ + dependency::Dependent, mod_file::ModFile, native::{Native, NativeInitializerCondition, NativeInitializerDelay}, package::Package, @@ -93,9 +94,9 @@ struct NativeV1 { #[serde(default = "on")] enabled: bool, #[serde(default)] - load_before: Vec, + load_before: Vec>, #[serde(default)] - load_after: Vec, + load_after: Vec>, initializer: Option, finalizer: Option, } @@ -109,21 +110,14 @@ pub struct PackageV1 { #[serde(alias = "source")] path: ModFileV1, #[serde(default)] - load_after: Vec, + load_after: Vec>, #[serde(default)] - load_before: Vec, + load_before: Vec>, } #[derive(Deserialize, JsonSchema)] struct ModFileV1(PathBuf); -#[allow(dead_code)] -#[derive(Deserialize, JsonSchema)] -struct DependentV1 { - id: String, - optional: bool, -} - impl From for ModProfileV1 { fn from(layout: ModProfileV1Layout) -> Self { Self { @@ -139,27 +133,29 @@ impl From for ModProfileV1 { impl From for Native { fn from(value: NativeV1) -> Self { + let item = ModFile { + enabled: value.enabled, + optional: value.optional, + ..value.path.0.into() + }; + + let initializer = match value.initializer { + Some(NativeInitializerConditionV1::Delay { ms }) => Some(NativeInitializerCondition { + delay: Some(NativeInitializerDelay { ms }), + function: None, + }), + Some(NativeInitializerConditionV1::Function(name)) => { + Some(NativeInitializerCondition { + delay: None, + function: Some(name), + }) + } + None => None, + }; + Self { - inner: ModFile { - enabled: value.enabled, - optional: value.optional, - ..value.path.0.into() - }, - initializer: match value.initializer { - Some(NativeInitializerConditionV1::Delay { ms }) => { - Some(NativeInitializerCondition { - delay: Some(NativeInitializerDelay { ms }), - function: None, - }) - } - Some(NativeInitializerConditionV1::Function(name)) => { - Some(NativeInitializerCondition { - delay: None, - function: Some(name), - }) - } - None => None, - }, + initializer, + ..item.into() } } } @@ -175,7 +171,7 @@ impl From for Package { item.name = id; } - Self(item) + item.into() } } diff --git a/crates/mod-protocol/src/profile/v2.rs b/crates/mod-protocol/src/profile/v2.rs index 1ae6d839..e176c581 100644 --- a/crates/mod-protocol/src/profile/v2.rs +++ b/crates/mod-protocol/src/profile/v2.rs @@ -1,10 +1,11 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use indexmap::IndexMap; use schemars::{schema_for, JsonSchema}; use serde::{Deserialize, Serialize}; use crate::{ + dependency::Dependent, mod_file::ModFile, native::{Native, NativeInitializerCondition}, package::Package, @@ -38,12 +39,8 @@ pub struct ModProfileV2 { impl ModProfileV2 { pub(super) fn push_dependency(&mut self, (name, dependency): (String, ProfileDependency)) { - let file_path = match &dependency { - ProfileDependency::Simple(path) => path, - ProfileDependency::Full { path, .. } => path, - }; - - let file_name = file_path + let file_name = dependency + .path() .file_name() .unwrap_or_default() .to_string_lossy() @@ -90,62 +87,40 @@ struct ProfileGame { #[serde(untagged)] pub enum ProfileDependency { Simple(PathBuf), - Full { - path: PathBuf, - - #[serde( - default = "ProfileDependency::enabled_default", - skip_serializing_if = "ProfileDependency::enabled_is_default" - )] - enabled: bool, - - #[serde( - default = "ProfileDependency::optional_default", - skip_serializing_if = "ProfileDependency::optional_is_default" - )] - optional: bool, - - #[serde(default)] - initializer: Option, - }, + Full(FullProfileDependency), +} + +#[derive(Clone, Deserialize, Serialize, JsonSchema)] +pub struct FullProfileDependency { + path: PathBuf, + + #[serde( + default = "ProfileDependency::enabled_default", + skip_serializing_if = "ProfileDependency::enabled_is_default" + )] + enabled: bool, + + #[serde( + default = "ProfileDependency::optional_default", + skip_serializing_if = "ProfileDependency::optional_is_default" + )] + optional: bool, + + #[serde(default)] + initializer: Option, + + #[serde(default)] + load_before: Vec>, + + #[serde(default)] + load_after: Vec>, } impl ProfileDependency { - fn into_parts(self) -> (PathBuf, bool, bool, Option) { + pub fn path(&self) -> &Path { match self { - Self::Simple(path) => ( - path, - Self::enabled_default(), - Self::optional_default(), - None, - ), - Self::Full { - path, - enabled, - optional, - initializer, - } => (path, enabled, optional, initializer), - } - } - - fn from_parts( - path: PathBuf, - enabled: bool, - optional: bool, - initializer: Option, - ) -> Self { - if enabled == Self::enabled_default() - && optional == Self::optional_default() - && initializer.is_none() - { - Self::Simple(path) - } else { - Self::Full { - path, - enabled, - optional, - initializer, - } + Self::Simple(path) => path, + Self::Full(full) => &full.path, } } @@ -166,6 +141,37 @@ impl ProfileDependency { } } +impl From for FullProfileDependency { + fn from(value: ProfileDependency) -> Self { + match value { + ProfileDependency::Simple(path) => Self { + path, + enabled: ProfileDependency::enabled_default(), + optional: ProfileDependency::optional_default(), + initializer: None, + load_before: vec![], + load_after: vec![], + }, + ProfileDependency::Full(full) => full, + } + } +} + +impl From for ProfileDependency { + fn from(value: FullProfileDependency) -> Self { + if value.enabled == Self::enabled_default() + && value.optional == Self::optional_default() + && value.initializer.is_none() + && value.load_before.is_empty() + && value.load_after.is_empty() + { + Self::Simple(value.path) + } else { + Self::Full(value) + } + } +} + impl From for ModProfileV2 { fn from(layout: ModProfileV2Layout) -> Self { let mut profile = Self { @@ -206,7 +212,15 @@ impl From for ModProfileV2Layout { impl From<(String, ProfileDependency)> for Native { fn from((name, dependency): (String, ProfileDependency)) -> Self { - let (path, enabled, optional, initializer) = dependency.into_parts(); + let FullProfileDependency { + path, + enabled, + optional, + initializer, + load_before, + load_after, + } = dependency.into(); + Self { inner: ModFile { name, @@ -214,6 +228,8 @@ impl From<(String, ProfileDependency)> for Native { enabled, optional, }, + load_before, + load_after, initializer, } } @@ -223,31 +239,69 @@ impl From for (String, ProfileDependency) { fn from(native: Native) -> Self { ( native.inner.name, - ProfileDependency::from_parts( - native.inner.path, - native.inner.enabled, - native.inner.optional, - native.initializer, - ), + FullProfileDependency { + path: native.inner.path, + enabled: native.inner.enabled, + optional: native.inner.optional, + initializer: native.initializer, + load_before: native.load_before, + load_after: native.load_after, + } + .into(), ) } } impl From<(String, ProfileDependency)> for Package { - fn from(dependency: (String, ProfileDependency)) -> Self { - ModFile::from(dependency).into() + fn from((name, dependency): (String, ProfileDependency)) -> Self { + let FullProfileDependency { + path, + enabled, + optional, + load_before, + load_after, + .. + } = dependency.into(); + + Self { + inner: ModFile { + name, + path, + enabled, + optional, + }, + load_before, + load_after, + } } } impl From for (String, ProfileDependency) { fn from(package: Package) -> Self { - package.0.into() + ( + package.inner.name, + FullProfileDependency { + path: package.inner.path, + enabled: package.inner.enabled, + optional: package.inner.optional, + initializer: None, + load_before: package.load_before, + load_after: package.load_after, + } + .into(), + ) } } impl From<(String, ProfileDependency)> for ModFile { fn from((name, dependency): (String, ProfileDependency)) -> Self { - let (path, enabled, optional, _) = dependency.into_parts(); + let FullProfileDependency { + path, + enabled, + optional, + .. + } = dependency.into(); + Self { name, path, @@ -261,7 +315,15 @@ impl From for (String, ProfileDependency) { fn from(item: ModFile) -> Self { ( item.name, - ProfileDependency::from_parts(item.path, item.enabled, item.optional, None), + FullProfileDependency { + path: item.path, + enabled: item.enabled, + optional: item.optional, + initializer: None, + load_before: vec![], + load_after: vec![], + } + .into(), ) } } diff --git a/crates/mod-protocol/test-data/v1/advanced_config.me3.expected b/crates/mod-protocol/test-data/v1/advanced_config.me3.expected index e7908919..4b9d5f65 100644 --- a/crates/mod-protocol/test-data/v1/advanced_config.me3.expected +++ b/crates/mod-protocol/test-data/v1/advanced_config.me3.expected @@ -9,16 +9,18 @@ V1( natives: [ Native { inner: ModFile { - name: "my_native-ac5db8a5", + name: "my_native_ac5db8a5", path: "my_native.dll", enabled: true, optional: true, }, initializer: None, + load_before: [], + load_after: [], }, Native { inner: ModFile { - name: "my_other_native-eae16c4a", + name: "my_other_native_eae16c4a", path: "./nr-mods/my_other_native.dll", enabled: true, optional: false, @@ -31,41 +33,51 @@ V1( ), }, ), + load_before: [], + load_after: [], }, ], packages: [ - Package( - ModFile { + Package { + inner: ModFile { name: "my-mod", path: "./mod", enabled: true, optional: false, }, - ), - Package( - ModFile { - name: "unnamed-mod-f0cb703b", + load_before: [], + load_after: [], + }, + Package { + inner: ModFile { + name: "unnamed-mod_f0cb703b", path: "./unnamed-mod", enabled: true, optional: false, }, - ), - Package( - ModFile { + load_before: [], + load_after: [], + }, + Package { + inner: ModFile { name: "my-other-mod", path: "./nr-mods/mod", enabled: true, optional: false, }, - ), - Package( - ModFile { + load_before: [], + load_after: [], + }, + Package { + inner: ModFile { name: "my-disabled-mod", path: "./unused-mod", enabled: false, optional: false, }, - ), + load_before: [], + load_after: [], + }, ], savefile: None, start_online: Some( diff --git a/crates/mod-protocol/test-data/v1/basic_config.me3.expected b/crates/mod-protocol/test-data/v1/basic_config.me3.expected index 43428dd8..830037ac 100644 --- a/crates/mod-protocol/test-data/v1/basic_config.me3.expected +++ b/crates/mod-protocol/test-data/v1/basic_config.me3.expected @@ -4,23 +4,27 @@ V1( natives: [ Native { inner: ModFile { - name: "my_native-ac5db8a5", + name: "my_native_ac5db8a5", path: "my_native.dll", enabled: true, optional: true, }, initializer: None, + load_before: [], + load_after: [], }, ], packages: [ - Package( - ModFile { + Package { + inner: ModFile { name: "my-mod", path: "./mod", enabled: true, optional: false, }, - ), + load_before: [], + load_after: [], + }, ], savefile: None, start_online: None, diff --git a/crates/mod-protocol/test-data/v1/plural_packages.me3.expected b/crates/mod-protocol/test-data/v1/plural_packages.me3.expected index eed4485d..deff2b53 100644 --- a/crates/mod-protocol/test-data/v1/plural_packages.me3.expected +++ b/crates/mod-protocol/test-data/v1/plural_packages.me3.expected @@ -3,14 +3,16 @@ V1( supports: [], natives: [], packages: [ - Package( - ModFile { + Package { + inner: ModFile { name: "test-pkg", path: ".", enabled: true, optional: false, }, - ), + load_before: [], + load_after: [], + }, ], savefile: None, start_online: None, diff --git a/crates/mod-protocol/test-data/v1/singular_package.me3.expected b/crates/mod-protocol/test-data/v1/singular_package.me3.expected index eed4485d..deff2b53 100644 --- a/crates/mod-protocol/test-data/v1/singular_package.me3.expected +++ b/crates/mod-protocol/test-data/v1/singular_package.me3.expected @@ -3,14 +3,16 @@ V1( supports: [], natives: [], packages: [ - Package( - ModFile { + Package { + inner: ModFile { name: "test-pkg", path: ".", enabled: true, optional: false, }, - ), + load_before: [], + load_after: [], + }, ], savefile: None, start_online: None, diff --git a/crates/mod-protocol/test-data/v2/advanced_config.me3.expected b/crates/mod-protocol/test-data/v2/advanced_config.me3.expected index 293cfae8..3500f2de 100644 --- a/crates/mod-protocol/test-data/v2/advanced_config.me3.expected +++ b/crates/mod-protocol/test-data/v2/advanced_config.me3.expected @@ -21,6 +21,8 @@ V2( function: None, }, ), + load_before: [], + load_after: [], }, Native { inner: ModFile { @@ -37,25 +39,31 @@ V2( ), }, ), + load_before: [], + load_after: [], }, ], packages: [ - Package( - ModFile { + Package { + inner: ModFile { name: "my_mod", path: "./my-mod", enabled: true, optional: false, }, - ), - Package( - ModFile { + load_before: [], + load_after: [], + }, + Package { + inner: ModFile { name: "my_other_mod", path: "./my-other-mod", enabled: true, optional: false, }, - ), + load_before: [], + load_after: [], + }, ], profiles: [ ModFile { diff --git a/crates/mod-protocol/test-data/v2/basic_config.me3.expected b/crates/mod-protocol/test-data/v2/basic_config.me3.expected index c5844f89..4b4fd220 100644 --- a/crates/mod-protocol/test-data/v2/basic_config.me3.expected +++ b/crates/mod-protocol/test-data/v2/basic_config.me3.expected @@ -10,17 +10,21 @@ V2( optional: false, }, initializer: None, + load_before: [], + load_after: [], }, ], packages: [ - Package( - ModFile { + Package { + inner: ModFile { name: "my_mod", path: "./my-mod", enabled: true, optional: false, }, - ), + load_before: [], + load_after: [], + }, ], profiles: [ ModFile { diff --git a/crates/mod-protocol/test-data/v2/merge_config.me3.expected b/crates/mod-protocol/test-data/v2/merge_config.me3.expected index 1dc719cd..5079dcd7 100644 --- a/crates/mod-protocol/test-data/v2/merge_config.me3.expected +++ b/crates/mod-protocol/test-data/v2/merge_config.me3.expected @@ -12,6 +12,8 @@ V2( optional: false, }, initializer: None, + load_before: [], + load_after: [], }, Native { inner: ModFile { @@ -21,6 +23,8 @@ V2( optional: false, }, initializer: None, + load_before: [], + load_after: [], }, Native { inner: ModFile { @@ -30,17 +34,21 @@ V2( optional: true, }, initializer: None, + load_before: [], + load_after: [], }, ], packages: [ - Package( - ModFile { + Package { + inner: ModFile { name: "my_mod", path: "./my-mod", enabled: true, optional: false, }, - ), + load_before: [], + load_after: [], + }, ], profiles: [ ModFile { diff --git a/schemas/mod-profile.json b/schemas/mod-profile.json index 29a71b26..eca995c7 100644 --- a/schemas/mod-profile.json +++ b/schemas/mod-profile.json @@ -145,7 +145,7 @@ "type": "object", "properties": { "path": { - "$ref": "#/$defs/ModFile" + "$ref": "#/$defs/ModFileV1" }, "optional": { "type": "boolean", @@ -158,14 +158,16 @@ "load_before": { "type": "array", "items": { - "$ref": "#/$defs/DependentV1" - } + "$ref": "#/$defs/Dependent" + }, + "default": [] }, "load_after": { "type": "array", "items": { - "$ref": "#/$defs/DependentV1" - } + "$ref": "#/$defs/Dependent" + }, + "default": [] }, "initializer": { "anyOf": [ @@ -188,10 +190,10 @@ "path" ] }, - "ModFile": { + "ModFileV1": { "type": "string" }, - "DependentV1": { + "Dependent": { "type": "object", "properties": { "id": { @@ -258,19 +260,21 @@ "default": true }, "path": { - "$ref": "#/$defs/ModFile" + "$ref": "#/$defs/ModFileV1" }, "load_after": { "type": "array", "items": { - "$ref": "#/$defs/DependentV1" - } + "$ref": "#/$defs/Dependent" + }, + "default": [] }, "load_before": { "type": "array", "items": { - "$ref": "#/$defs/DependentV1" - } + "$ref": "#/$defs/Dependent" + }, + "default": [] } }, "required": [ @@ -395,33 +399,50 @@ "type": "string" }, { - "type": "object", - "properties": { - "path": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "optional": { - "type": "boolean" + "$ref": "#/$defs/FullProfileDependency" + } + ] + }, + "FullProfileDependency": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "optional": { + "type": "boolean" + }, + "initializer": { + "anyOf": [ + { + "$ref": "#/$defs/NativeInitializerCondition" }, - "initializer": { - "anyOf": [ - { - "$ref": "#/$defs/NativeInitializerCondition" - }, - { - "type": "null" - } - ], - "default": null + { + "type": "null" } + ], + "default": null + }, + "load_before": { + "type": "array", + "items": { + "$ref": "#/$defs/Dependent" }, - "required": [ - "path" - ] + "default": [] + }, + "load_after": { + "type": "array", + "items": { + "$ref": "#/$defs/Dependent" + }, + "default": [] } + }, + "required": [ + "path" ] }, "NativeInitializerCondition": { @@ -459,6 +480,21 @@ "required": [ "ms" ] + }, + "Dependent": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "optional": { + "type": "boolean" + } + }, + "required": [ + "id", + "optional" + ] } } } From 814986427ea369769fdec07c92e0eba0a2733a85 Mon Sep 17 00:00:00 2001 From: Dasaav-dsv Date: Thu, 18 Sep 2025 12:20:39 +0200 Subject: [PATCH 04/20] chore: Add tagged mod variants for v2 layout Signed-off-by: Dasaav-dsv --- crates/cli/src/commands/launch.rs | 3 +- crates/cli/src/commands/profile.rs | 6 +- crates/launcher/src/game.rs | 8 +- crates/mod-host/src/asset_hooks.rs | 5 +- crates/mod-protocol/src/mod_file.rs | 40 +- crates/mod-protocol/src/profile.rs | 6 +- crates/mod-protocol/src/profile/builder.rs | 20 +- crates/mod-protocol/src/profile/v2.rs | 591 ++++++++++++------ .../test-data/v2/advanced_config.me3 | 2 +- .../test-data/v2/basic_config.me3 | 2 +- .../test-data/v2/merge_config_a.me3 | 2 +- .../test-data/v2/merge_config_b.me3 | 2 +- schemas/mod-profile.json | 207 ++++-- 13 files changed, 628 insertions(+), 266 deletions(-) diff --git a/crates/cli/src/commands/launch.rs b/crates/cli/src/commands/launch.rs index 684fd000..356ab7ad 100644 --- a/crates/cli/src/commands/launch.rs +++ b/crates/cli/src/commands/launch.rs @@ -156,7 +156,8 @@ pub struct LaunchArgs { )] uses: Vec, - /// (DEPRECATED, use "-u") /// Path to package directory (asset override mod) [repeatable option] + /// (DEPRECATED, use "-u") /// Path to package directory (asset override mod) [repeatable + /// option] #[deprecated] #[clap( long("package"), diff --git a/crates/cli/src/commands/profile.rs b/crates/cli/src/commands/profile.rs index 17519601..316ac28a 100644 --- a/crates/cli/src/commands/profile.rs +++ b/crates/cli/src/commands/profile.rs @@ -263,9 +263,9 @@ pub fn upgrade(db: DbContext, config: Config, args: ProfileNameArgs) -> color_ey ModProfileBuilder::new() .with_supported_game(profile.supported_game()) - .with_dependencies(profile.natives()) - .with_dependencies(profile.packages()) - .with_dependencies(profile.profiles()) + .with_mods(profile.natives()) + .with_mods(profile.packages()) + .with_mods(profile.profiles()) .with_savefile(profile.savefile()) .start_online(profile.options().start_online) .disable_arxan(profile.options().disable_arxan) diff --git a/crates/launcher/src/game.rs b/crates/launcher/src/game.rs index 1a3172f0..de223667 100644 --- a/crates/launcher/src/game.rs +++ b/crates/launcher/src/game.rs @@ -170,12 +170,8 @@ unsafe fn deserialize_result_payload( result_payload: *mut u8, ) -> LauncherResult> { let payload_len = unsafe { - ProcessMemorySlice::from_raw_parts( - result_payload, - mem::size_of::(), - process, - ) - .read_struct::(0)? + ProcessMemorySlice::from_raw_parts(result_payload, mem::size_of::(), process) + .read_struct::(0)? }; let mut bytes = Vec::new(); diff --git a/crates/mod-host/src/asset_hooks.rs b/crates/mod-host/src/asset_hooks.rs index 8fa0184d..be29b2d1 100644 --- a/crates/mod-host/src/asset_hooks.rs +++ b/crates/mod-host/src/asset_hooks.rs @@ -513,7 +513,10 @@ fn try_hook_wwise( let path_string = unsafe { path.to_string().unwrap() }; if let Some(mapped_override) = wwise::find_override(&mapping, &path_string) { - info!("override" = path_string, "source" = mapped_override.source()); + info!( + "override" = path_string, + "source" = mapped_override.source() + ); // Force lookup to wwise's ordinary read (from disk) mode instead of the EBL read. unsafe { diff --git a/crates/mod-protocol/src/mod_file.rs b/crates/mod-protocol/src/mod_file.rs index ed326a40..36b13e35 100644 --- a/crates/mod-protocol/src/mod_file.rs +++ b/crates/mod-protocol/src/mod_file.rs @@ -12,16 +12,24 @@ pub trait AsModFile { #[derive(Clone, Debug, Deserialize, Serialize)] pub struct ModFile { - /// Name associated with this item. + /// Name associated with this file. pub name: String, - /// A path to the source of this item. + /// A path to the source of this file. pub path: PathBuf, - /// Does this item participate in dependency resolution? + /// Does this file participate in dependency resolution? + #[serde( + default = "ModFile::enabled_default", + skip_serializing_if = "ModFile::enabled_is_default" + )] pub enabled: bool, - /// Should failing to find this item result in a hard error? + /// Should failing to find this file result in a hard error? + #[serde( + default = "ModFile::optional_default", + skip_serializing_if = "ModFile::optional_is_default" + )] pub optional: bool, } @@ -47,6 +55,26 @@ impl ModFile { self.path = base.as_ref().join(&self.path); } } + + #[inline] + pub(crate) fn enabled_default() -> bool { + true + } + + #[inline] + pub(crate) fn enabled_is_default(enabled: &bool) -> bool { + *enabled == Self::enabled_default() + } + + #[inline] + pub(crate) fn optional_default() -> bool { + false + } + + #[inline] + pub(crate) fn optional_is_default(optional: &bool) -> bool { + *optional == Self::optional_default() + } } impl Default for ModFile { @@ -55,8 +83,8 @@ impl Default for ModFile { Self { name: Default::default(), path: Default::default(), - enabled: true, - optional: false, + enabled: Self::enabled_default(), + optional: Self::optional_default(), } } } diff --git a/crates/mod-protocol/src/profile.rs b/crates/mod-protocol/src/profile.rs index 29cfc401..62359de8 100644 --- a/crates/mod-protocol/src/profile.rs +++ b/crates/mod-protocol/src/profile.rs @@ -95,9 +95,9 @@ impl ModProfile { let profile = ModProfileBuilder::new() .with_supported_game(game.cloned()) .with_savefile(self.savefile()) - .with_dependencies(self.natives().into_iter().chain(other.natives())) - .with_dependencies(self.packages().into_iter().chain(other.packages())) - .with_dependencies(self.profiles().into_iter().chain(other.profiles())) + .with_mods(self.natives().into_iter().chain(other.natives())) + .with_mods(self.packages().into_iter().chain(other.packages())) + .with_mods(self.profiles().into_iter().chain(other.profiles())) .start_online(either(other.start_online(), self.start_online())) .disable_arxan(either(other.disable_arxan(), self.disable_arxan())) .build(); diff --git a/crates/mod-protocol/src/profile/builder.rs b/crates/mod-protocol/src/profile/builder.rs index 9b09a349..ff3a5889 100644 --- a/crates/mod-protocol/src/profile/builder.rs +++ b/crates/mod-protocol/src/profile/builder.rs @@ -4,9 +4,8 @@ use std::{ }; use crate::{ - mod_file::ModFile, profile::{ - v2::{ModProfileV2, ProfileDependency}, + v2::{ModEntryV2, ModProfileV2}, ModProfile, }, Game, @@ -15,7 +14,7 @@ use crate::{ #[derive(Default)] pub struct ModProfileBuilder { supports: Option, - dependencies: Vec<(String, ProfileDependency)>, + mods: Vec, savefile: Option, start_online: Option, disable_arxan: Option, @@ -29,7 +28,7 @@ impl ModProfileBuilder { pub fn build(&mut self) -> ModProfile { let Self { supports, - dependencies, + mods, savefile, start_online, disable_arxan, @@ -43,8 +42,8 @@ impl ModProfileBuilder { ..Default::default() }; - for uses in dependencies { - profile.push_dependency(uses); + for mod_entry in mods { + profile.push_mod_entry(mod_entry); } ModProfile::V2(profile) @@ -66,17 +65,16 @@ impl ModProfileBuilder { where I: IntoIterator, { - self.dependencies - .extend(iter.into_iter().map(|i| ModFile::from(i).into())); + self.mods.extend(iter.into_iter().map(Into::into)); self } #[inline] - pub fn with_dependencies(&mut self, iter: I) -> &mut Self + pub fn with_mods(&mut self, iter: I) -> &mut Self where - I: IntoIterator>, + I: IntoIterator>, { - self.dependencies.extend(iter.into_iter().map(Into::into)); + self.mods.extend(iter.into_iter().map(Into::into)); self } diff --git a/crates/mod-protocol/src/profile/v2.rs b/crates/mod-protocol/src/profile/v2.rs index e176c581..f8dcfc30 100644 --- a/crates/mod-protocol/src/profile/v2.rs +++ b/crates/mod-protocol/src/profile/v2.rs @@ -38,23 +38,11 @@ pub struct ModProfileV2 { } impl ModProfileV2 { - pub(super) fn push_dependency(&mut self, (name, dependency): (String, ProfileDependency)) { - let file_name = dependency - .path() - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_ascii_lowercase(); - - if file_name.ends_with(".dll") { - self.natives.push((name, dependency).into()); - } else if file_name.ends_with(".me3") - || file_name.ends_with(".me3.toml") - || file_name.ends_with(".me3.json") - { - self.profiles.push((name, dependency).into()); - } else { - self.packages.push((name, dependency).into()); + pub(super) fn push_mod_entry>(&mut self, mod_entry: E) { + match mod_entry.into() { + ModEntryV2::Native(native) => self.natives.push(native), + ModEntryV2::Package(package) => self.packages.push(package), + ModEntryV2::Profile(profile) => self.profiles.push(profile), } } } @@ -62,164 +50,391 @@ impl ModProfileV2 { #[derive(Default, Deserialize, Serialize, JsonSchema)] struct ModProfileV2Layout { #[serde(default)] - game: ProfileGame, + game: GamePropertiesV2, #[serde(skip_serializing_if = "IndexMap::is_empty")] - dependencies: IndexMap, + mods: IndexMap, } #[derive(Default, Deserialize, Serialize, JsonSchema)] -struct ProfileGame { - #[serde(default)] +struct GamePropertiesV2 { launch: Option, - - #[serde(default)] savefile: Option, - - #[serde(default)] start_online: Option, - - #[serde(default)] disable_arxan: Option, } +#[derive(Clone, Debug)] +pub enum ModEntryV2 { + Native(Native), + Package(Package), + Profile(ModFile), +} + #[derive(Clone, Deserialize, Serialize, JsonSchema)] -#[serde(untagged)] -pub enum ProfileDependency { +#[serde(tag = "kind")] +enum ModEntryV2Layout { + #[serde(rename = "native")] + Native { + #[serde(flatten)] + inner: ModFileV2, + + initializer: Option, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + load_before: Vec>, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + load_after: Vec>, + }, + + #[serde(rename = "package")] + Package { + #[serde(flatten)] + inner: ModFileV2, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + load_before: Vec>, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + load_after: Vec>, + }, + + #[serde(rename = "profile")] + Profile(ModFileV2), + + #[serde(untagged)] Simple(PathBuf), - Full(FullProfileDependency), + + #[serde(untagged)] + Untagged(UntaggedModEntryV2), } #[derive(Clone, Deserialize, Serialize, JsonSchema)] -pub struct FullProfileDependency { +struct ModFileV2 { path: PathBuf, #[serde( - default = "ProfileDependency::enabled_default", - skip_serializing_if = "ProfileDependency::enabled_is_default" + default = "ModFile::enabled_default", + skip_serializing_if = "ModFile::enabled_is_default" )] enabled: bool, #[serde( - default = "ProfileDependency::optional_default", - skip_serializing_if = "ProfileDependency::optional_is_default" + default = "ModFile::optional_default", + skip_serializing_if = "ModFile::optional_is_default" )] optional: bool, +} + +#[derive(Clone, Deserialize, Serialize, JsonSchema)] +struct UntaggedModEntryV2 { + #[serde(flatten)] + inner: ModFileV2, - #[serde(default)] initializer: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] load_before: Vec>, - #[serde(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] load_after: Vec>, } -impl ProfileDependency { - pub fn path(&self) -> &Path { - match self { - Self::Simple(path) => path, - Self::Full(full) => &full.path, - } - } - - fn enabled_default() -> bool { - true +impl ModEntryV2 { + pub fn new>(path: P) -> Self { + path.as_ref().to_owned().into() } +} - fn enabled_is_default(enabled: &bool) -> bool { - *enabled == Self::enabled_default() +impl From<(String, ModEntryV2Layout)> for ModEntryV2 { + fn from((name, layout): (String, ModEntryV2Layout)) -> Self { + match layout { + ModEntryV2Layout::Native { + inner: + ModFileV2 { + path, + enabled, + optional, + }, + initializer, + load_before, + load_after, + } => Self::Native(Native { + inner: ModFile { + name, + path, + enabled, + optional, + }, + initializer, + load_before, + load_after, + }), + ModEntryV2Layout::Package { + inner: + ModFileV2 { + path, + enabled, + optional, + }, + load_before, + load_after, + } => Self::Package(Package { + inner: ModFile { + name, + path, + enabled, + optional, + }, + load_before, + load_after, + }), + ModEntryV2Layout::Profile(ModFileV2 { + path, + enabled, + optional, + }) => Self::Profile(ModFile { + name, + path, + enabled, + optional, + }), + ModEntryV2Layout::Simple(ref path) + | ModEntryV2Layout::Untagged(UntaggedModEntryV2 { + inner: ModFileV2 { ref path, .. }, + .. + }) => { + let file_name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_ascii_lowercase(); + + let untagged = match layout { + ModEntryV2Layout::Simple(path) => UntaggedModEntryV2 { + inner: ModFileV2 { + path, + enabled: ModFile::enabled_default(), + optional: ModFile::optional_default(), + }, + initializer: None, + load_before: vec![], + load_after: vec![], + }, + ModEntryV2Layout::Untagged(untagged) => untagged, + _ => unreachable!(), + }; + + if file_name.ends_with(".dll") { + Self::Native((name, untagged).into()) + } else if file_name.ends_with(".me3") + || file_name.ends_with(".me3.toml") + || file_name.ends_with(".me3.json") + { + Self::Profile((name, untagged).into()) + } else { + Self::Package((name, untagged).into()) + } + } + } } +} - fn optional_default() -> bool { - false - } +impl From for (String, ModEntryV2Layout) { + fn from(mod_entry: ModEntryV2) -> Self { + let path = match &mod_entry { + ModEntryV2::Native(native) => native.path.as_path(), + ModEntryV2::Package(package) => package.path.as_path(), + ModEntryV2::Profile(profile) => profile.path.as_path(), + }; - fn optional_is_default(optional: &bool) -> bool { - *optional == Self::optional_default() - } -} + let file_name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_ascii_lowercase(); -impl From for FullProfileDependency { - fn from(value: ProfileDependency) -> Self { - match value { - ProfileDependency::Simple(path) => Self { - path, - enabled: ProfileDependency::enabled_default(), - optional: ProfileDependency::optional_default(), - initializer: None, - load_before: vec![], - load_after: vec![], - }, - ProfileDependency::Full(full) => full, + match mod_entry { + ModEntryV2::Native(native) => { + if !file_name.ends_with(".dll") { + return ( + native.inner.name, + ModEntryV2Layout::Native { + inner: ModFileV2 { + path: native.inner.path, + enabled: native.inner.enabled, + optional: native.inner.optional, + }, + initializer: native.initializer, + load_before: native.load_before, + load_after: native.load_after, + }, + ); + } + + if native.inner.enabled == ModFile::enabled_default() + && native.inner.optional == ModFile::optional_default() + && native.initializer.is_none() + && native.load_before.is_empty() + && native.load_after.is_empty() + { + ( + native.inner.name, + ModEntryV2Layout::Simple(native.inner.path), + ) + } else { + ( + native.inner.name, + ModEntryV2Layout::Untagged(UntaggedModEntryV2 { + inner: ModFileV2 { + path: native.inner.path, + enabled: native.inner.enabled, + optional: native.inner.optional, + }, + initializer: native.initializer, + load_before: native.load_before, + load_after: native.load_after, + }), + ) + } + } + ModEntryV2::Package(package) => { + if file_name.ends_with(".dll") + || file_name.ends_with(".me3") + || file_name.ends_with(".me3.toml") + || file_name.ends_with(".me3.json") + { + return ( + package.inner.name, + ModEntryV2Layout::Package { + inner: ModFileV2 { + path: package.inner.path, + enabled: package.inner.enabled, + optional: package.inner.optional, + }, + load_before: package.load_before, + load_after: package.load_after, + }, + ); + } + + if package.inner.enabled == ModFile::enabled_default() + && package.inner.optional == ModFile::optional_default() + && package.load_before.is_empty() + && package.load_after.is_empty() + { + ( + package.inner.name, + ModEntryV2Layout::Simple(package.inner.path), + ) + } else { + ( + package.inner.name, + ModEntryV2Layout::Untagged(UntaggedModEntryV2 { + inner: ModFileV2 { + path: package.inner.path, + enabled: package.inner.enabled, + optional: package.inner.optional, + }, + initializer: None, + load_before: package.load_before, + load_after: package.load_after, + }), + ) + } + } + ModEntryV2::Profile(profile) => { + if !(file_name.ends_with(".me3") + || file_name.ends_with(".me3.toml") + || file_name.ends_with(".me3.json")) + { + return ( + profile.name, + ModEntryV2Layout::Profile(ModFileV2 { + path: profile.path, + enabled: profile.enabled, + optional: profile.optional, + }), + ); + } + + if profile.enabled == ModFile::enabled_default() + && profile.optional == ModFile::optional_default() + { + (profile.name, ModEntryV2Layout::Simple(profile.path)) + } else { + ( + profile.name, + ModEntryV2Layout::Untagged(UntaggedModEntryV2 { + inner: ModFileV2 { + path: profile.path, + enabled: profile.enabled, + optional: profile.optional, + }, + initializer: None, + load_before: vec![], + load_after: vec![], + }), + ) + } + } } } } -impl From for ProfileDependency { - fn from(value: FullProfileDependency) -> Self { - if value.enabled == Self::enabled_default() - && value.optional == Self::optional_default() - && value.initializer.is_none() - && value.load_before.is_empty() - && value.load_after.is_empty() +impl From for ModEntryV2 { + fn from(path: PathBuf) -> Self { + let file_name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_ascii_lowercase(); + + if file_name.ends_with(".dll") { + Native::from(path).into() + } else if file_name.ends_with(".me3") + || file_name.ends_with(".me3.toml") + || file_name.ends_with(".me3.json") { - Self::Simple(value.path) + ModFile::from(path).into() } else { - Self::Full(value) + Package::from(path).into() } } } -impl From for ModProfileV2 { - fn from(layout: ModProfileV2Layout) -> Self { - let mut profile = Self { - supports: layout.game.launch, - savefile: layout.game.savefile, - start_online: layout.game.start_online, - disable_arxan: layout.game.disable_arxan, - ..Default::default() - }; - - for dep in layout.dependencies { - profile.push_dependency(dep); - } - - profile +impl From for ModEntryV2 { + fn from(native: Native) -> Self { + Self::Native(native) } } -impl From for ModProfileV2Layout { - fn from(profile: ModProfileV2) -> Self { - let mut dependencies = IndexMap::new(); - - dependencies.extend(profile.natives.into_iter().map(Into::into)); - dependencies.extend(profile.packages.into_iter().map(Into::into)); - dependencies.extend(profile.profiles.into_iter().map(Into::into)); +impl From for ModEntryV2 { + fn from(package: Package) -> Self { + Self::Package(package) + } +} - Self { - game: ProfileGame { - launch: profile.supports, - savefile: profile.savefile, - start_online: profile.start_online, - disable_arxan: profile.disable_arxan, - }, - dependencies, - } +impl From for ModEntryV2 { + fn from(profile: ModFile) -> Self { + Self::Profile(profile) } } -impl From<(String, ProfileDependency)> for Native { - fn from((name, dependency): (String, ProfileDependency)) -> Self { - let FullProfileDependency { - path, - enabled, - optional, +impl From<(String, UntaggedModEntryV2)> for Native { + fn from((name, mod_entry): (String, UntaggedModEntryV2)) -> Self { + let UntaggedModEntryV2 { + inner: + ModFileV2 { + path, + enabled, + optional, + }, initializer, load_before, load_after, - } = dependency.into(); + } = mod_entry; Self { inner: ModFile { @@ -235,33 +450,19 @@ impl From<(String, ProfileDependency)> for Native { } } -impl From for (String, ProfileDependency) { - fn from(native: Native) -> Self { - ( - native.inner.name, - FullProfileDependency { - path: native.inner.path, - enabled: native.inner.enabled, - optional: native.inner.optional, - initializer: native.initializer, - load_before: native.load_before, - load_after: native.load_after, - } - .into(), - ) - } -} - -impl From<(String, ProfileDependency)> for Package { - fn from((name, dependency): (String, ProfileDependency)) -> Self { - let FullProfileDependency { - path, - enabled, - optional, +impl From<(String, UntaggedModEntryV2)> for Package { + fn from((name, mod_entry): (String, UntaggedModEntryV2)) -> Self { + let UntaggedModEntryV2 { + inner: + ModFileV2 { + path, + enabled, + optional, + }, load_before, load_after, .. - } = dependency.into(); + } = mod_entry; Self { inner: ModFile { @@ -276,31 +477,17 @@ impl From<(String, ProfileDependency)> for Package { } } -impl From for (String, ProfileDependency) { - fn from(package: Package) -> Self { - ( - package.inner.name, - FullProfileDependency { - path: package.inner.path, - enabled: package.inner.enabled, - optional: package.inner.optional, - initializer: None, - load_before: package.load_before, - load_after: package.load_after, - } - .into(), - ) - } -} - -impl From<(String, ProfileDependency)> for ModFile { - fn from((name, dependency): (String, ProfileDependency)) -> Self { - let FullProfileDependency { - path, - enabled, - optional, +impl From<(String, UntaggedModEntryV2)> for ModFile { + fn from((name, mod_entry): (String, UntaggedModEntryV2)) -> Self { + let UntaggedModEntryV2 { + inner: + ModFileV2 { + path, + enabled, + optional, + }, .. - } = dependency.into(); + } = mod_entry; Self { name, @@ -311,20 +498,58 @@ impl From<(String, ProfileDependency)> for ModFile { } } -impl From for (String, ProfileDependency) { - fn from(item: ModFile) -> Self { - ( - item.name, - FullProfileDependency { - path: item.path, - enabled: item.enabled, - optional: item.optional, - initializer: None, - load_before: vec![], - load_after: vec![], - } - .into(), - ) +impl From for ModProfileV2 { + fn from(layout: ModProfileV2Layout) -> Self { + let mut profile = Self { + supports: layout.game.launch, + savefile: layout.game.savefile, + start_online: layout.game.start_online, + disable_arxan: layout.game.disable_arxan, + ..Default::default() + }; + + for mod_entry in layout.mods { + profile.push_mod_entry(mod_entry); + } + + profile + } +} + +impl From for ModProfileV2Layout { + fn from(profile: ModProfileV2) -> Self { + let mut mods = IndexMap::new(); + + mods.extend( + profile + .natives + .into_iter() + .map(|native| ModEntryV2::from(native).into()), + ); + + mods.extend( + profile + .packages + .into_iter() + .map(|package| ModEntryV2::from(package).into()), + ); + + mods.extend( + profile + .profiles + .into_iter() + .map(|profile| ModEntryV2::from(profile).into()), + ); + + Self { + game: GamePropertiesV2 { + launch: profile.supports, + savefile: profile.savefile, + start_online: profile.start_online, + disable_arxan: profile.disable_arxan, + }, + mods, + } } } @@ -337,3 +562,13 @@ impl JsonSchema for ModProfileV2 { schema_for!(ModProfileV2Layout) } } + +impl JsonSchema for ModEntryV2 { + fn schema_name() -> std::borrow::Cow<'static, str> { + "Profilemod_entry".into() + } + + fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + schema_for!(ModEntryV2Layout) + } +} diff --git a/crates/mod-protocol/test-data/v2/advanced_config.me3 b/crates/mod-protocol/test-data/v2/advanced_config.me3 index fe88c6f3..e91ec4ce 100644 --- a/crates/mod-protocol/test-data/v2/advanced_config.me3 +++ b/crates/mod-protocol/test-data/v2/advanced_config.me3 @@ -6,7 +6,7 @@ savefile = "NRMOD.sl2" disable_arxan = true start_online = true -[dependencies] +[mods] my_mod.path = './my-mod' my_dll = { path = './my-mod/my_dll.dll', initializer.delay.ms = 3000 } hks_debug = { path = './hks_debug.me3', optional = true } diff --git a/crates/mod-protocol/test-data/v2/basic_config.me3 b/crates/mod-protocol/test-data/v2/basic_config.me3 index 8e8df5f3..2c996b97 100644 --- a/crates/mod-protocol/test-data/v2/basic_config.me3 +++ b/crates/mod-protocol/test-data/v2/basic_config.me3 @@ -1,6 +1,6 @@ profileVersion = "v2" -[dependencies] +[mods] my_mod.path = './my-mod' my_dll.path = './my-mod/my_dll.dll' my_profile.path = 'my_profile.me3' diff --git a/crates/mod-protocol/test-data/v2/merge_config_a.me3 b/crates/mod-protocol/test-data/v2/merge_config_a.me3 index 02e38648..20ee55ec 100644 --- a/crates/mod-protocol/test-data/v2/merge_config_a.me3 +++ b/crates/mod-protocol/test-data/v2/merge_config_a.me3 @@ -6,7 +6,7 @@ savefile = "ERMOD.sl2" disable_arxan = true start_online = false -[dependencies] +[mods] my_mod.path = './my-mod' my_dll.path = './my-mod/my_dll.dll' my_profile.path = 'my_profile.me3' diff --git a/crates/mod-protocol/test-data/v2/merge_config_b.me3 b/crates/mod-protocol/test-data/v2/merge_config_b.me3 index a488e32c..31e79deb 100644 --- a/crates/mod-protocol/test-data/v2/merge_config_b.me3 +++ b/crates/mod-protocol/test-data/v2/merge_config_b.me3 @@ -6,7 +6,7 @@ savefile = "TEST.sl2" disable_arxan = false start_online = true -[dependencies] +[mods] test_dll.path = 'test.dll' other_dll = { path = './other_mods/mod.dll', optional = true } big_overhaul.path = './other_mods/overhaul.me3' diff --git a/schemas/mod-profile.json b/schemas/mod-profile.json index eca995c7..8196a9d2 100644 --- a/schemas/mod-profile.json +++ b/schemas/mod-profile.json @@ -289,7 +289,7 @@ "type": "object", "properties": { "game": { - "$ref": "#/$defs/ProfileGame", + "$ref": "#/$defs/GamePropertiesV2", "default": { "launch": null, "savefile": null, @@ -297,18 +297,18 @@ "disable_arxan": null } }, - "dependencies": { + "mods": { "type": "object", "additionalProperties": { - "$ref": "#/$defs/ProfileDependency" + "$ref": "#/$defs/ModEntryV2Layout" } } }, "required": [ - "dependencies" + "mods" ], "$defs": { - "ProfileGame": { + "GamePropertiesV2": { "type": "object", "properties": { "launch": { @@ -319,29 +319,25 @@ { "type": "null" } - ], - "default": null + ] }, "savefile": { "type": [ "string", "null" - ], - "default": null + ] }, "start_online": { "type": [ "boolean", "null" - ], - "default": null + ] }, "disable_arxan": { "type": [ "boolean", "null" - ], - "default": null + ] } } }, @@ -393,56 +389,105 @@ } ] }, - "ProfileDependency": { + "ModEntryV2Layout": { "anyOf": [ { - "type": "string" + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "optional": { + "type": "boolean" + }, + "initializer": { + "anyOf": [ + { + "$ref": "#/$defs/NativeInitializerCondition" + }, + { + "type": "null" + } + ] + }, + "load_before": { + "type": "array", + "items": { + "$ref": "#/$defs/Dependent" + } + }, + "load_after": { + "type": "array", + "items": { + "$ref": "#/$defs/Dependent" + } + }, + "kind": { + "type": "string", + "const": "native" + } + }, + "required": [ + "kind", + "path" + ] }, { - "$ref": "#/$defs/FullProfileDependency" - } - ] - }, - "FullProfileDependency": { - "type": "object", - "properties": { - "path": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "optional": { - "type": "boolean" - }, - "initializer": { - "anyOf": [ - { - "$ref": "#/$defs/NativeInitializerCondition" + "type": "object", + "properties": { + "path": { + "type": "string" }, - { - "type": "null" + "enabled": { + "type": "boolean" + }, + "optional": { + "type": "boolean" + }, + "load_before": { + "type": "array", + "items": { + "$ref": "#/$defs/Dependent" + } + }, + "load_after": { + "type": "array", + "items": { + "$ref": "#/$defs/Dependent" + } + }, + "kind": { + "type": "string", + "const": "package" } - ], - "default": null - }, - "load_before": { - "type": "array", - "items": { - "$ref": "#/$defs/Dependent" }, - "default": [] + "required": [ + "kind", + "path" + ] }, - "load_after": { - "type": "array", - "items": { - "$ref": "#/$defs/Dependent" + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "profile" + } }, - "default": [] + "$ref": "#/$defs/ModFileV2", + "required": [ + "kind" + ] + }, + { + "type": "string" + }, + { + "$ref": "#/$defs/UntaggedModEntryV2" } - }, - "required": [ - "path" ] }, "NativeInitializerCondition": { @@ -495,6 +540,62 @@ "id", "optional" ] + }, + "ModFileV2": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "optional": { + "type": "boolean" + } + }, + "required": [ + "path" + ] + }, + "UntaggedModEntryV2": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "optional": { + "type": "boolean" + }, + "initializer": { + "anyOf": [ + { + "$ref": "#/$defs/NativeInitializerCondition" + }, + { + "type": "null" + } + ] + }, + "load_before": { + "type": "array", + "items": { + "$ref": "#/$defs/Dependent" + } + }, + "load_after": { + "type": "array", + "items": { + "$ref": "#/$defs/Dependent" + } + } + }, + "required": [ + "path" + ] } } } From bcb183ce4cf26bc7d481af80ac2eef909536d864 Mon Sep 17 00:00:00 2001 From: Dasaav-dsv Date: Thu, 18 Sep 2025 14:14:30 +0200 Subject: [PATCH 05/20] chore: Fix elevation check Signed-off-by: Dasaav-dsv --- crates/launcher/src/game.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/launcher/src/game.rs b/crates/launcher/src/game.rs index de223667..bf3ad7d9 100644 --- a/crates/launcher/src/game.rs +++ b/crates/launcher/src/game.rs @@ -64,7 +64,7 @@ impl Game { command.stdout(log_file); let child = command.spawn().map_err(|e| match e.raw_os_error().map(|i| WIN32_ERROR(i as u32)) { - Some(ERROR_ELEVATION_REQUIRED) => eyre!( + Some(e) if e == ERROR_ELEVATION_REQUIRED => eyre!( "Elevation is required to launch the game. Disable \"Run this program as an administrator\" and try again." ), _ => e.into() From d813f9dc3d88370a33a9bb054701178b54ad0b18 Mon Sep 17 00:00:00 2001 From: Dasaav-dsv Date: Thu, 18 Sep 2025 19:16:14 +0200 Subject: [PATCH 06/20] feat: Support nesting profile includes and use topological sorting Signed-off-by: Dasaav-dsv --- Cargo.lock | 1 + crates/cli/Cargo.toml | 1 + crates/cli/src/commands/launch.rs | 16 +- crates/cli/src/db/profile.rs | 184 ++++++++++++++++++--- crates/launcher-attach-protocol/src/lib.rs | 3 + crates/mod-protocol/src/dependency.rs | 18 +- crates/mod-protocol/src/native.rs | 8 +- crates/mod-protocol/src/package.rs | 8 +- 8 files changed, 197 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d44b459b..b0e7b1d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1634,6 +1634,7 @@ dependencies = [ "normpath", "open", "pretty_assertions", + "same-file", "semver", "serde", "serde_json", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 765043aa..c190740b 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -32,6 +32,7 @@ me3-mod-protocol.workspace = true me3-telemetry.workspace = true normpath.workspace = true open = { version = "5" } +same-file = "1.0.6" serde = { workspace = true, features = ["derive"] } serde_json.workspace = true steamlocate.workspace = true diff --git a/crates/cli/src/commands/launch.rs b/crates/cli/src/commands/launch.rs index 356ab7ad..5c4b9f87 100644 --- a/crates/cli/src/commands/launch.rs +++ b/crates/cli/src/commands/launch.rs @@ -267,7 +267,6 @@ impl Launcher for CompatToolLauncher { struct LaunchContext { game: Game, - profile: Profile, game_options: GameOptions, profile_options: ProfileOptions, attach_config: AttachConfig, @@ -343,16 +342,16 @@ impl LaunchArgs { info!(?game, ?game_options, ?profile_options, "resolved game"); let attach_config = self.generate_attach_config( + db, game, &game_options, - &profile, + profile, &profile_options, config.cache_dir(), )?; Ok(LaunchContext { game, - profile, game_options, profile_options, attach_config, @@ -361,13 +360,14 @@ impl LaunchArgs { fn generate_attach_config( &self, + db: &DbContext, game: Game, opts: &GameOptions, - profile: &Profile, + profile: Profile, profile_options: &ProfileOptions, cache_path: Option>, ) -> color_eyre::Result { - let (natives, packages) = profile.compile()?; + let profile_name = profile.name().to_owned(); let savefile = profile.savefile(); if let Some(savefile) = &savefile { @@ -386,7 +386,10 @@ impl LaunchArgs { } } + let (natives, packages) = profile.compile(&db.profiles)?; + Ok(AttachConfig { + profile_name, game: game.into(), natives, packages, @@ -406,7 +409,6 @@ impl LaunchArgs { pub fn launch(db: DbContext, config: Config, args: LaunchArgs) -> color_eyre::Result<()> { let LaunchContext { game, - profile, game_options, profile_options: _profile_options, attach_config, @@ -477,7 +479,7 @@ pub fn launch(db: DbContext, config: Config, args: LaunchArgs) -> color_eyre::Re let monitor_log_file = NamedTempFile::with_suffix(".log")?; - let log_file_path = db.logs.create_log_file(profile.name())?; + let log_file_path = db.logs.create_log_file(&attach_config.profile_name)?; // Ensure log file exists so `normalize()` succeeds on Unix let log_file = File::create(&log_file_path)?; drop(log_file); diff --git a/crates/cli/src/db/profile.rs b/crates/cli/src/db/profile.rs index c127f3e8..6aeae3f5 100644 --- a/crates/cli/src/db/profile.rs +++ b/crates/cli/src/db/profile.rs @@ -1,11 +1,14 @@ use std::{ ffi::OsStr, + fmt, fs::DirEntry, path::{Path, PathBuf}, + sync::Arc, }; use color_eyre::eyre::Context; use me3_mod_protocol::{ + dependency::{sort_dependencies, Dependency, Dependent}, mod_file::{AsModFile, ModFile}, native::Native, package::Package, @@ -13,6 +16,7 @@ use me3_mod_protocol::{ Game, }; use normpath::PathExt; +use serde::{Deserialize, Serialize}; use tracing::warn; use crate::commands::profile::ProfileOptions; @@ -29,10 +33,11 @@ impl ProfileDb { } } +#[derive(Debug)] pub struct Profile { name: String, path: PathBuf, - profile: ModProfile, + inner: ModProfile, } impl Profile { @@ -41,7 +46,7 @@ impl Profile { Self { name: "transient-profile".to_string(), path: Default::default(), - profile: Default::default(), + inner: Default::default(), } } @@ -63,34 +68,34 @@ impl Profile { /// Get the single game this profile supports, or None if it supports multiple games/omits /// support metadata. pub fn supported_game(&self) -> Option { - self.profile.game() + self.inner.game() } /// Returns a list of natives to be loaded by this profile. pub fn natives(&self) -> impl Iterator { - self.profile.natives().into_iter() + self.inner.natives().into_iter() } /// Returns a list of packages loaded by this profile. pub fn packages(&self) -> impl Iterator { - self.profile.packages().into_iter() + self.inner.packages().into_iter() } /// Returns a list of profiles loaded by this profile. pub fn profiles(&self) -> impl Iterator { - self.profile.profiles().into_iter() + self.inner.profiles().into_iter() } /// Get the savefile name that may be overridden by this profile. pub fn savefile(&self) -> Option { - self.profile.savefile() + self.inner.savefile() } /// Returns misc. options set by this profile. pub fn options(&self) -> ProfileOptions { ProfileOptions { - start_online: self.profile.start_online(), - disable_arxan: self.profile.disable_arxan(), + start_online: self.inner.start_online(), + disable_arxan: self.inner.disable_arxan(), } } @@ -101,12 +106,12 @@ impl Profile { Ok(Self { name: self.name.clone(), path: self.path.clone(), - profile: self.profile.try_merge(other.as_ref())?, + inner: self.inner.try_merge(other.as_ref())?, }) } /// Compile this profile into a load order of native DLLs, packages and files to be loaded. - pub fn compile(&self) -> color_eyre::Result<(Vec, Vec)> { + pub fn compile(self, db: &ProfileDb) -> color_eyre::Result<(Vec, Vec)> { fn canonicalize(base_dir: &Path, sources: &mut Vec) { sources .iter_mut() @@ -124,21 +129,82 @@ impl Profile { }); } - let mut packages = self.profile.packages(); - let mut natives = self.profile.natives(); + let root = ProfileDependency::from_profile(self, None); - let base_dir = self.base_dir().unwrap_or(Path::new(".")); + let base_dir = root.profile.base_dir().unwrap_or(Path::new(".")); - canonicalize(base_dir, &mut packages); - canonicalize(base_dir, &mut natives); + let mut children = root.profile.inner.profiles(); + canonicalize(base_dir, &mut children); - Ok((natives, packages)) + let mut remaining = children + .into_iter() + .rev() + .map(|p| { + ( + ProfilePath::from(&*p.path), + Dependent { + id: root.path.clone(), + optional: p.optional, + }, + ) + }) + .collect::>(); + + let mut profiles = vec![root]; + + while let Some((next, after)) = remaining.pop() { + if let Some(index) = profiles.iter().position(|p| p.path == next) { + let mut profile = profiles.remove(index); + profile.load_after = Some(after); + profiles.push(profile); + } else { + let profile = db.load(next.as_ref())?; + let profile = ProfileDependency::from_profile(profile, Some(after)); + + let base_dir = profile.profile.base_dir().unwrap_or(Path::new(".")); + + let mut children = profile.profile.inner.profiles(); + canonicalize(base_dir, &mut children); + + for next in children.into_iter().rev() { + remaining.push(( + ProfilePath::from(&*next.path), + Dependent { + id: profile.path.clone(), + optional: next.optional, + }, + )); + } + + profiles.push(profile); + } + } + + let ordered_profiles = sort_dependencies(profiles)?; + + let mut ordered_natives = vec![]; + let mut ordered_packages = vec![]; + + for ordered in ordered_profiles { + let base_dir = ordered.profile.base_dir().unwrap_or(Path::new(".")); + + let mut natives = ordered.profile.inner.natives(); + let mut packages = ordered.profile.inner.packages(); + + canonicalize(&base_dir, &mut natives); + canonicalize(&base_dir, &mut packages); + + ordered_natives.extend(sort_dependencies(natives)?); + ordered_packages.extend(sort_dependencies(packages)?); + } + + Ok((ordered_natives, ordered_packages)) } } impl AsRef for Profile { fn as_ref(&self) -> &ModProfile { - &self.profile + &self.inner } } @@ -202,7 +268,7 @@ impl ProfileDb { Ok(Profile { name, path: normalized_path.into_path_buf(), - profile, + inner: profile, }) } @@ -223,6 +289,86 @@ impl ProfileDb { } } +#[derive(Clone, Debug, Hash)] +struct ProfilePath(Arc); + +impl AsRef for ProfilePath { + fn as_ref(&self) -> &Path { + &self.0 + } +} + +impl From<&Path> for ProfilePath { + fn from(path: &Path) -> Self { + Self(Arc::from(path)) + } +} + +impl PartialEq for ProfilePath { + fn eq(&self, other: &Self) -> bool { + same_file::is_same_file(&self.0, &other.0).unwrap() + } +} + +impl Eq for ProfilePath {} + +impl fmt::Display for ProfilePath { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.display().fmt(f) + } +} + +impl Serialize for ProfilePath { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.as_ref().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ProfilePath { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + PathBuf::deserialize(deserializer).map(|p| p.as_path().into()) + } +} + +#[derive(Debug)] +struct ProfileDependency { + profile: Profile, + path: ProfilePath, + load_after: Option>, +} + +impl ProfileDependency { + fn from_profile(profile: Profile, load_after: Option>) -> Self { + Self { + path: profile.path.as_path().into(), + profile, + load_after, + } + } +} + +impl Dependency for ProfileDependency { + type UniqueId = ProfilePath; + + fn id(&self) -> Self::UniqueId { + self.path.clone() + } + + fn load_before(&self) -> &[Dependent] { + &[] + } + + fn load_after(&self) -> &[Dependent] { + self.load_after.as_slice() + } +} + #[cfg(test)] mod test { use std::error::Error; diff --git a/crates/launcher-attach-protocol/src/lib.rs b/crates/launcher-attach-protocol/src/lib.rs index 37c9f0ba..a93ef85a 100644 --- a/crates/launcher-attach-protocol/src/lib.rs +++ b/crates/launcher-attach-protocol/src/lib.rs @@ -15,6 +15,9 @@ pub struct AttachRequest { #[derive(Debug, Deserialize, Serialize)] pub struct AttachConfig { + /// Name of the profile that produced this config. + pub profile_name: String, + /// The attached to game. pub game: Game, diff --git a/crates/mod-protocol/src/dependency.rs b/crates/mod-protocol/src/dependency.rs index d2d96ba3..b25ed3fd 100644 --- a/crates/mod-protocol/src/dependency.rs +++ b/crates/mod-protocol/src/dependency.rs @@ -12,8 +12,8 @@ impl Deserialize<'de> + Serialize> D #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] pub struct Dependent { - id: T, - optional: bool, + pub id: T, + pub optional: bool, } impl Dependent { @@ -39,23 +39,23 @@ pub trait Dependency { fn id(&self) -> Self::UniqueId; fn dependencies(&self) -> impl Iterator> { - self.loads_after() + self.load_after() .iter() .map(|dep| DependencyLink { optional: dep.optional, order: DependencyOrder::After, id: dep.id(), }) - .chain(self.loads_before().iter().map(|dep| DependencyLink { + .chain(self.load_before().iter().map(|dep| DependencyLink { optional: dep.optional, order: DependencyOrder::Before, id: dep.id(), })) } - fn loads_after(&self) -> &[Dependent]; + fn load_before(&self) -> &[Dependent]; - fn loads_before(&self) -> &[Dependent]; + fn load_after(&self) -> &[Dependent]; } #[derive(Debug, thiserror::Error)] @@ -176,7 +176,9 @@ impl Ord for DependencyRun { } } -pub fn sort_dependencies(items: Vec) -> Result, DependencyError> { +pub fn sort_dependencies>( + items: I, +) -> Result, DependencyError> { let mut sorter = IndexMap::>::new(); let mut all = items .into_iter() @@ -210,7 +212,7 @@ pub fn sort_dependencies(items: Vec) -> Result, Depende while let Some((key, has_succ)) = sorter.pop_dependency() { let (item, index) = all.shift_remove(&key).expect("item already removed?"); - if max_index.is_none() && item.loads_after().is_empty() && item.loads_before().is_empty() { + if max_index.is_none() && item.load_after().is_empty() && item.load_before().is_empty() { max_index = Some(index); } diff --git a/crates/mod-protocol/src/native.rs b/crates/mod-protocol/src/native.rs index a4c6ea4e..bd660fc3 100644 --- a/crates/mod-protocol/src/native.rs +++ b/crates/mod-protocol/src/native.rs @@ -60,12 +60,12 @@ impl Dependency for Native { .expect("native had no file name") } - fn loads_after(&self) -> &[Dependent] { - &self.load_after + fn load_before(&self) -> &[Dependent] { + &self.load_before } - fn loads_before(&self) -> &[Dependent] { - &self.load_before + fn load_after(&self) -> &[Dependent] { + &self.load_after } } diff --git a/crates/mod-protocol/src/package.rs b/crates/mod-protocol/src/package.rs index f349169e..9e38e7a9 100644 --- a/crates/mod-protocol/src/package.rs +++ b/crates/mod-protocol/src/package.rs @@ -39,12 +39,12 @@ impl Dependency for Package { self.name.clone() } - fn loads_after(&self) -> &[crate::dependency::Dependent] { - &self.load_after + fn load_before(&self) -> &[crate::dependency::Dependent] { + &self.load_before } - fn loads_before(&self) -> &[crate::dependency::Dependent] { - &self.load_before + fn load_after(&self) -> &[crate::dependency::Dependent] { + &self.load_after } } From df982f1f00fa8485f34d96d186a76a8204423f02 Mon Sep 17 00:00:00 2001 From: Dasaav-dsv Date: Fri, 19 Sep 2025 01:05:34 +0200 Subject: [PATCH 07/20] chore: clippy lints Signed-off-by: Dasaav-dsv --- crates/cli/src/commands/launch.rs | 5 ++--- crates/cli/src/db/profile.rs | 5 +++-- crates/mod-host-assets/src/mapping.rs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/cli/src/commands/launch.rs b/crates/cli/src/commands/launch.rs index 5c4b9f87..d4308122 100644 --- a/crates/cli/src/commands/launch.rs +++ b/crates/cli/src/commands/launch.rs @@ -37,7 +37,7 @@ use crate::{ fn remap_slr_path(path: impl AsRef) -> PathBuf { // - const NON_SHARED_PATHS: [&'static str; 4] = ["/usr", "/etc", "/bin", "/lib"]; + const NON_SHARED_PATHS: [&str; 4] = ["/usr", "/etc", "/bin", "/lib"]; let path = path.as_ref(); @@ -236,8 +236,7 @@ impl Launcher for CompatToolLauncher { .library_paths()? .into_iter() .map(|path| path.join(format!("steamapps/compatdata/{}", self.app_id))) - .filter(|path| path.exists()) - .next() + .find(|path| path.exists()) .unwrap_or_else(|| { self.library .path() diff --git a/crates/cli/src/db/profile.rs b/crates/cli/src/db/profile.rs index 6aeae3f5..de5a4290 100644 --- a/crates/cli/src/db/profile.rs +++ b/crates/cli/src/db/profile.rs @@ -191,8 +191,8 @@ impl Profile { let mut natives = ordered.profile.inner.natives(); let mut packages = ordered.profile.inner.packages(); - canonicalize(&base_dir, &mut natives); - canonicalize(&base_dir, &mut packages); + canonicalize(base_dir, &mut natives); + canonicalize(base_dir, &mut packages); ordered_natives.extend(sort_dependencies(natives)?); ordered_packages.extend(sort_dependencies(packages)?); @@ -290,6 +290,7 @@ impl ProfileDb { } #[derive(Clone, Debug, Hash)] +#[allow(clippy::derived_hash_with_manual_eq)] struct ProfilePath(Arc); impl AsRef for ProfilePath { diff --git a/crates/mod-host-assets/src/mapping.rs b/crates/mod-host-assets/src/mapping.rs index 337d39b5..0965b20c 100644 --- a/crates/mod-host-assets/src/mapping.rs +++ b/crates/mod-host-assets/src/mapping.rs @@ -105,7 +105,7 @@ impl VfsOverrideMapping { let root_key = VfsKey::for_disk_path(package_path).map_err(VfsOverrideMappingError::ReadDir)?; - let scanned_directories = map_packages_inner(package_path, &root_key, &package_source); + let scanned_directories = map_packages_inner(package_path, &root_key, package_source); self.map.reserve(scanned_directories.len()); for result in scanned_directories { From 14bf387b6af5c86f11b458918d414a20b74c53e8 Mon Sep 17 00:00:00 2001 From: Dasaav-dsv Date: Fri, 19 Sep 2025 01:44:20 +0200 Subject: [PATCH 08/20] chore: Add `default` Signed-off-by: Dasaav-dsv --- crates/mod-protocol/src/profile/v2.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/mod-protocol/src/profile/v2.rs b/crates/mod-protocol/src/profile/v2.rs index f8dcfc30..3802d5bd 100644 --- a/crates/mod-protocol/src/profile/v2.rs +++ b/crates/mod-protocol/src/profile/v2.rs @@ -52,7 +52,7 @@ struct ModProfileV2Layout { #[serde(default)] game: GamePropertiesV2, - #[serde(skip_serializing_if = "IndexMap::is_empty")] + #[serde(default, skip_serializing_if = "IndexMap::is_empty")] mods: IndexMap, } From c9841095da90163a115432de5dc3e5ae72909235 Mon Sep 17 00:00:00 2001 From: Dasaav-dsv Date: Fri, 19 Sep 2025 01:47:49 +0200 Subject: [PATCH 09/20] chore: Update profile schema Signed-off-by: Dasaav-dsv --- schemas/mod-profile.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/schemas/mod-profile.json b/schemas/mod-profile.json index 8196a9d2..98e9a730 100644 --- a/schemas/mod-profile.json +++ b/schemas/mod-profile.json @@ -304,9 +304,6 @@ } } }, - "required": [ - "mods" - ], "$defs": { "GamePropertiesV2": { "type": "object", From 2846480693fa2a6eed82a3de665fbc3df2f2851f Mon Sep 17 00:00:00 2001 From: Dasaav-dsv Date: Tue, 23 Sep 2025 05:45:51 +0200 Subject: [PATCH 10/20] chore: Undeprecate `native` and `package` CLI flags, make `profile` repeatable Signed-off-by: Dasaav-dsv --- crates/cli/src/commands/launch.rs | 73 ++++++++++++++------------- crates/cli/src/commands/profile.rs | 64 ++++++++++++++--------- crates/mod-protocol/src/profile.rs | 2 + crates/mod-protocol/src/profile/v2.rs | 9 ++-- 4 files changed, 85 insertions(+), 63 deletions(-) diff --git a/crates/cli/src/commands/launch.rs b/crates/cli/src/commands/launch.rs index d4308122..086a019b 100644 --- a/crates/cli/src/commands/launch.rs +++ b/crates/cli/src/commands/launch.rs @@ -21,17 +21,21 @@ use clap::{ use color_eyre::eyre::{eyre, Context, OptionExt}; use me3_env::{CommandExt, LauncherVars, TelemetryVars}; use me3_launcher_attach_protocol::AttachConfig; -use me3_mod_protocol::profile::builder::ModProfileBuilder; +use me3_mod_protocol::{ + native::Native, + package::Package, + profile::{builder::ModProfileBuilder, Profile}, +}; use normpath::PathExt; use serde::{Deserialize, Serialize}; use steamlocate::{CompatTool, Library, SteamDir}; use tempfile::NamedTempFile; -use tracing::{error, info, warn}; +use tracing::{error, info}; use crate::{ commands::{launch::proton::CompatTools, profile::ProfileOptions}, config::Config, - db::{profile::Profile, DbContext}, + db::{profile::Profile as DbProfile, DbContext}, Game, }; @@ -130,35 +134,35 @@ pub struct LaunchArgs { profile_options: ProfileOptions, /// Enable diagnostics for this launch. - #[clap(short('d'), long("diagnostics"), action = ArgAction::SetTrue)] + #[clap(short, long, action = ArgAction::SetTrue)] diagnostics: bool, /// Suspend the game until a debugger is attached. - #[clap(long("suspend"), action = ArgAction::SetTrue)] + #[clap(action = ArgAction::SetTrue)] suspend: bool, - /// Name of a profile in the me3 profile dir, or path to a ModProfile (TOML or JSON). + /// Name of a profile in the me3 profile dir, or path to a ModProfile (TOML or JSON) + /// [repeatable option] #[arg( - short('p'), + short, long("profile"), + action = clap::ArgAction::Append, help_heading = "Mod configuration", value_hint = clap::ValueHint::FilePath, )] - profile: Option, + profiles: Vec, /// Path to a native DLL, package, file or a profile to use [repeatable option] #[clap( - short('u'), - long("use"), + short, + long("mod"), action = clap::ArgAction::Append, help_heading = "Mod configuration", value_hint = clap::ValueHint::AnyPath, )] - uses: Vec, + mods: Vec, - /// (DEPRECATED, use "-u") /// Path to package directory (asset override mod) [repeatable - /// option] - #[deprecated] + /// Path to package directory (asset override mod) [repeatable option] #[clap( long("package"), action = clap::ArgAction::Append, @@ -167,10 +171,9 @@ pub struct LaunchArgs { )] packages: Vec, - /// (DEPRECATED, use "-u") Path to DLL file (native DLL mod) [repeatable option] - #[deprecated] + /// Path to DLL file (native DLL mod) [repeatable option] #[clap( - short('n'), + short, long("native"), action = clap::ArgAction::Append, help_heading = "Mod configuration", @@ -179,7 +182,7 @@ pub struct LaunchArgs { natives: Vec, /// Name of an alternative savefile to use (in the default savefile directory). - #[clap(long("savefile"), help_heading = "Mod configuration")] + #[clap(help_heading = "Mod configuration")] savefile: Option, } @@ -277,40 +280,38 @@ impl LaunchArgs { db: &DbContext, config: &Config, ) -> color_eyre::Result { - let profile = if let Some(profile_name) = &self.profile { + let profile = if let Some(profile_name) = self.profiles.first() { db.profiles.load(profile_name)? } else { - Profile::transient() + DbProfile::transient() }; - #[allow(deprecated)] - if !self.natives.is_empty() { - warn!("option \"--native\" is deprecated, use \"--use\" instead!"); - } - - #[allow(deprecated)] - if !self.packages.is_empty() { - warn!("option \"--package\" is deprecated, use \"--use\" instead!"); - } - let game_from_args = self .target_selector .as_ref() .and_then(|s| s.game.or_else(|| s.steam_id.and_then(Game::from_app_id))) .map(Into::into); - #[allow(deprecated)] - let uses_args = self.uses.iter().chain(&self.natives).chain(&self.packages); - - for path in uses_args.clone() { + for path in self.mods.iter().chain(&self.natives).chain(&self.packages) { if !path.exists() { return Err(eyre!("{path:?} does not exist")); } } + let other_profiles = self + .profiles + .get(1..) + .into_iter() + .flatten() + .map(|profile| config.resolve_profile(profile)) + .collect::, _>>()?; + let profile_from_args = ModProfileBuilder::new() .with_supported_game(game_from_args) - .with_paths(uses_args.cloned()) + .with_mods(self.natives.iter().map(Native::new)) + .with_mods(self.packages.iter().map(Package::new)) + .with_mods(other_profiles.iter().map(Profile::new)) + .with_paths(self.mods.iter().cloned()) .with_savefile(self.savefile.clone()) .start_online(self.profile_options.start_online) .disable_arxan(self.profile_options.disable_arxan) @@ -362,7 +363,7 @@ impl LaunchArgs { db: &DbContext, game: Game, opts: &GameOptions, - profile: Profile, + profile: DbProfile, profile_options: &ProfileOptions, cache_path: Option>, ) -> color_eyre::Result { diff --git a/crates/cli/src/commands/profile.rs b/crates/cli/src/commands/profile.rs index 316ac28a..80039e31 100644 --- a/crates/cli/src/commands/profile.rs +++ b/crates/cli/src/commands/profile.rs @@ -2,8 +2,12 @@ use std::{fs, path::PathBuf}; use clap::{ArgAction, Args, Subcommand}; use color_eyre::eyre::{eyre, OptionExt}; -use me3_mod_protocol::profile::{builder::ModProfileBuilder, ModProfile}; -use tracing::{error, info, warn}; +use me3_mod_protocol::{ + native::Native, + package::Package, + profile::{builder::ModProfileBuilder, ModProfile, Profile}, +}; +use tracing::{error, info}; use crate::{config::Config, db::DbContext, output::OutputBuilder, Game}; @@ -31,7 +35,7 @@ pub struct ProfileCreateArgs { /// Game to associate with this profile for one-click launches. #[clap( - short('g'), + short, long, hide_possible_values = false, help_heading = "Game selection" @@ -40,19 +44,34 @@ pub struct ProfileCreateArgs { game: Option, /// Path to a native DLL, package, file or profile [repeatable option] - #[clap(short('u'), long("use"))] - uses: Vec, + #[clap( + short, + long("mod"), + action = clap::ArgAction::Append, + )] + mods: Vec, - /// (DEPRECATED, use "-u") Path to package directory (asset override mod) [repeatable option] - #[deprecated] - #[clap(long("native"))] + /// Path to package directory (asset override mod) [repeatable option] + #[clap( + long("native"), + action = clap::ArgAction::Append, + )] natives: Vec, - /// (DEPRECATED, use "-u") Path to DLL file (native DLL mod) [repeatable option] - #[deprecated] - #[clap(long("package"))] + /// Path to DLL file (native DLL mod) [repeatable option] + #[clap( + long("package"), + action = clap::ArgAction::Append, + )] packages: Vec, + /// Path to me3 profile [repeatable option] + #[clap( + long("profile"), + action = clap::ArgAction::Append, + )] + profiles: Vec, + /// Name of an alternative savefile to use (in the default savefile directory). #[clap(long("savefile"))] savefile: Option, @@ -145,22 +164,21 @@ pub fn create(config: Config, args: ProfileCreateArgs) -> color_eyre::Result<()> .ok_or_eyre("profile parent path was removed")?; fs::create_dir_all(profile_dir)?; - #[allow(deprecated)] - if !args.natives.is_empty() { - warn!("option \"--native\" is deprecated, use \"--use\" instead!"); - } - - #[allow(deprecated)] - if !args.packages.is_empty() { - warn!("option \"--package\" is deprecated, use \"--use\" instead!"); - } + let profiles = args + .profiles + .get(1..) + .into_iter() + .flatten() + .map(|profile| config.resolve_profile(profile)) + .collect::, _>>()?; #[allow(deprecated)] ModProfileBuilder::new() .with_supported_game(args.game.map(Into::into)) - .with_paths(args.uses) - .with_paths(args.natives) - .with_paths(args.packages) + .with_mods(args.natives.iter().map(Native::new)) + .with_mods(args.packages.iter().map(Package::new)) + .with_mods(profiles.iter().map(Profile::new)) + .with_paths(args.mods) .with_savefile(args.savefile) .start_online(args.options.start_online) .disable_arxan(args.options.disable_arxan) diff --git a/crates/mod-protocol/src/profile.rs b/crates/mod-protocol/src/profile.rs index 62359de8..1aa6c435 100644 --- a/crates/mod-protocol/src/profile.rs +++ b/crates/mod-protocol/src/profile.rs @@ -24,6 +24,8 @@ pub mod builder; mod v1; mod v2; +pub type Profile = ModFile; + #[derive(Debug, Deserialize, Serialize, JsonSchema)] #[serde(tag = "profileVersion")] pub enum ModProfile { diff --git a/crates/mod-protocol/src/profile/v2.rs b/crates/mod-protocol/src/profile/v2.rs index 3802d5bd..6affcd26 100644 --- a/crates/mod-protocol/src/profile/v2.rs +++ b/crates/mod-protocol/src/profile/v2.rs @@ -9,6 +9,7 @@ use crate::{ mod_file::ModFile, native::{Native, NativeInitializerCondition}, package::Package, + profile::Profile, Game, }; @@ -68,7 +69,7 @@ struct GamePropertiesV2 { pub enum ModEntryV2 { Native(Native), Package(Package), - Profile(ModFile), + Profile(Profile), } #[derive(Clone, Deserialize, Serialize, JsonSchema)] @@ -194,7 +195,7 @@ impl From<(String, ModEntryV2Layout)> for ModEntryV2 { path, enabled, optional, - }) => Self::Profile(ModFile { + }) => Self::Profile(Profile { name, path, enabled, @@ -416,8 +417,8 @@ impl From for ModEntryV2 { } } -impl From for ModEntryV2 { - fn from(profile: ModFile) -> Self { +impl From for ModEntryV2 { + fn from(profile: Profile) -> Self { Self::Profile(profile) } } From 1736c6750041aecfdc8fdda776c75e2674a16fb7 Mon Sep 17 00:00:00 2001 From: Dasaav-dsv Date: Tue, 23 Sep 2025 05:49:50 +0200 Subject: [PATCH 11/20] chore: Do not ignore `load_before` and `load_after` fields in profile v1 Signed-off-by: Dasaav-dsv --- crates/mod-protocol/src/profile/v1.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/mod-protocol/src/profile/v1.rs b/crates/mod-protocol/src/profile/v1.rs index 4ff75660..4106d60c 100644 --- a/crates/mod-protocol/src/profile/v1.rs +++ b/crates/mod-protocol/src/profile/v1.rs @@ -155,6 +155,8 @@ impl From for Native { Self { initializer, + load_before: value.load_before, + load_after: value.load_after, ..item.into() } } @@ -171,7 +173,11 @@ impl From for Package { item.name = id; } - item.into() + Self { + load_before: value.load_before, + load_after: value.load_after, + ..item.into() + } } } From bebf3be0977fad192c4ad9f1bb0251bbf79e2517 Mon Sep 17 00:00:00 2001 From: Dasaav-dsv Date: Tue, 23 Sep 2025 06:31:42 +0200 Subject: [PATCH 12/20] chore: Only add hash postfix for collisions Signed-off-by: Dasaav-dsv --- crates/mod-protocol/src/mod_file.rs | 24 +++------ crates/mod-protocol/src/profile/v2.rs | 74 +++++++++++++++++++-------- 2 files changed, 58 insertions(+), 40 deletions(-) diff --git a/crates/mod-protocol/src/mod_file.rs b/crates/mod-protocol/src/mod_file.rs index 36b13e35..eecf8afa 100644 --- a/crates/mod-protocol/src/mod_file.rs +++ b/crates/mod-protocol/src/mod_file.rs @@ -1,7 +1,4 @@ -use std::{ - ops::BitXor, - path::{Path, PathBuf}, -}; +use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; @@ -99,21 +96,12 @@ impl AsRef for ModFile { impl From for ModFile { #[inline] fn from(path: PathBuf) -> Self { - let fnv1_a = |b: &[u8]| { - b.iter().fold(0x811c9dc5u32, |hash, byte| { - hash.bitxor(*byte as u32).wrapping_mul(0x01000193) - }) - }; - Self { - name: format!( - "{}_{:x}", - path.file_stem() - .unwrap_or_default() - .to_string_lossy() - .to_lowercase(), - fnv1_a(path.as_os_str().as_encoded_bytes()) - ), + name: path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(), path, ..Default::default() } diff --git a/crates/mod-protocol/src/profile/v2.rs b/crates/mod-protocol/src/profile/v2.rs index 6affcd26..0eb470db 100644 --- a/crates/mod-protocol/src/profile/v2.rs +++ b/crates/mod-protocol/src/profile/v2.rs @@ -1,4 +1,7 @@ -use std::path::{Path, PathBuf}; +use std::{ + ops::BitXor, + path::{Path, PathBuf}, +}; use indexmap::IndexMap; use schemars::{schema_for, JsonSchema}; @@ -26,7 +29,7 @@ pub struct ModProfileV2 { pub packages: Vec, /// Other profiles listed as dependencies by this profile. - pub profiles: Vec, + pub profiles: Vec, /// Name of an alternative savefile to use (in the default savefile directory). pub savefile: Option, @@ -148,6 +151,18 @@ impl ModEntryV2 { } } +impl ModEntryV2Layout { + fn path(&self) -> &Path { + match self { + Self::Native { inner, .. } => &inner.path, + Self::Package { inner, .. } => &inner.path, + Self::Profile(inner) => &inner.path, + Self::Simple(path) => &path, + Self::Untagged(untagged) => &untagged.inner.path, + } + } +} + impl From<(String, ModEntryV2Layout)> for ModEntryV2 { fn from((name, layout): (String, ModEntryV2Layout)) -> Self { match layout { @@ -521,26 +536,41 @@ impl From for ModProfileV2Layout { fn from(profile: ModProfileV2) -> Self { let mut mods = IndexMap::new(); - mods.extend( - profile - .natives - .into_iter() - .map(|native| ModEntryV2::from(native).into()), - ); - - mods.extend( - profile - .packages - .into_iter() - .map(|package| ModEntryV2::from(package).into()), - ); - - mods.extend( - profile - .profiles - .into_iter() - .map(|profile| ModEntryV2::from(profile).into()), - ); + fn push_unique_mods< + I: IntoIterator, IntoIter: ExactSizeIterator>, + >( + mods: &mut IndexMap, + i: I, + ) { + let iter = i.into_iter(); + mods.reserve_exact(iter.len()); + + let fnv_offset_base = 0x811c9dc5; + let fnv1_a = |base: u32, bytes: &[u8]| { + bytes.iter().fold(base, |hash, byte| { + hash.bitxor(*byte as u32).wrapping_mul(0x01000193) + }) + }; + + for (i, (mut name, mod_entry)) in iter + .map(|e| <(String, ModEntryV2Layout)>::from(e.into())) + .enumerate() + { + while mods.get(&name).is_some() { + let seeded_hash = fnv1_a(fnv_offset_base, &i.to_ne_bytes()); + let path_bytes = mod_entry.path().as_os_str().as_encoded_bytes(); + + name.push('_'); + name.push_str(&fnv1_a(seeded_hash, path_bytes).to_string()); + } + + mods.insert(name, mod_entry); + } + } + + push_unique_mods(&mut mods, profile.natives); + push_unique_mods(&mut mods, profile.packages); + push_unique_mods(&mut mods, profile.profiles); Self { game: GamePropertiesV2 { From 06105b2f91135f92c319ebf4fd81c7fd834bb23b Mon Sep 17 00:00:00 2001 From: Dasaav-dsv Date: Tue, 23 Sep 2025 06:39:30 +0200 Subject: [PATCH 13/20] chore: Lints and tests Signed-off-by: Dasaav-dsv --- crates/cli/src/commands/launch.rs | 2 +- crates/mod-protocol/src/profile/v2.rs | 2 +- .../test-data/v1/advanced_config.me3.expected | 20 ++++++++++++++----- .../test-data/v1/basic_config.me3.expected | 2 +- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/crates/cli/src/commands/launch.rs b/crates/cli/src/commands/launch.rs index 086a019b..654ebab0 100644 --- a/crates/cli/src/commands/launch.rs +++ b/crates/cli/src/commands/launch.rs @@ -138,7 +138,7 @@ pub struct LaunchArgs { diagnostics: bool, /// Suspend the game until a debugger is attached. - #[clap(action = ArgAction::SetTrue)] + #[clap(long, action = ArgAction::SetTrue)] suspend: bool, /// Name of a profile in the me3 profile dir, or path to a ModProfile (TOML or JSON) diff --git a/crates/mod-protocol/src/profile/v2.rs b/crates/mod-protocol/src/profile/v2.rs index 0eb470db..018b19b6 100644 --- a/crates/mod-protocol/src/profile/v2.rs +++ b/crates/mod-protocol/src/profile/v2.rs @@ -157,7 +157,7 @@ impl ModEntryV2Layout { Self::Native { inner, .. } => &inner.path, Self::Package { inner, .. } => &inner.path, Self::Profile(inner) => &inner.path, - Self::Simple(path) => &path, + Self::Simple(path) => path, Self::Untagged(untagged) => &untagged.inner.path, } } diff --git a/crates/mod-protocol/test-data/v1/advanced_config.me3.expected b/crates/mod-protocol/test-data/v1/advanced_config.me3.expected index 4b9d5f65..4b5be06e 100644 --- a/crates/mod-protocol/test-data/v1/advanced_config.me3.expected +++ b/crates/mod-protocol/test-data/v1/advanced_config.me3.expected @@ -9,7 +9,7 @@ V1( natives: [ Native { inner: ModFile { - name: "my_native_ac5db8a5", + name: "my_native", path: "my_native.dll", enabled: true, optional: true, @@ -20,7 +20,7 @@ V1( }, Native { inner: ModFile { - name: "my_other_native_eae16c4a", + name: "my_other_native", path: "./nr-mods/my_other_native.dll", enabled: true, optional: false, @@ -50,13 +50,18 @@ V1( }, Package { inner: ModFile { - name: "unnamed-mod_f0cb703b", + name: "unnamed-mod", path: "./unnamed-mod", enabled: true, optional: false, }, load_before: [], - load_after: [], + load_after: [ + Dependent { + id: "my-mod", + optional: false, + }, + ], }, Package { inner: ModFile { @@ -65,7 +70,12 @@ V1( enabled: true, optional: false, }, - load_before: [], + load_before: [ + Dependent { + id: "my-mod", + optional: true, + }, + ], load_after: [], }, Package { diff --git a/crates/mod-protocol/test-data/v1/basic_config.me3.expected b/crates/mod-protocol/test-data/v1/basic_config.me3.expected index 830037ac..9c8fa048 100644 --- a/crates/mod-protocol/test-data/v1/basic_config.me3.expected +++ b/crates/mod-protocol/test-data/v1/basic_config.me3.expected @@ -4,7 +4,7 @@ V1( natives: [ Native { inner: ModFile { - name: "my_native_ac5db8a5", + name: "my_native", path: "my_native.dll", enabled: true, optional: true, From 2d62a21ebc01891866898d59f77b7acd3de548fc Mon Sep 17 00:00:00 2001 From: Dasaav-dsv Date: Tue, 23 Sep 2025 06:57:15 +0200 Subject: [PATCH 14/20] chore: Continue the hash instead of repeating it Signed-off-by: Dasaav-dsv --- crates/mod-protocol/src/profile/v2.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/mod-protocol/src/profile/v2.rs b/crates/mod-protocol/src/profile/v2.rs index 018b19b6..e059419a 100644 --- a/crates/mod-protocol/src/profile/v2.rs +++ b/crates/mod-protocol/src/profile/v2.rs @@ -545,10 +545,12 @@ impl From for ModProfileV2Layout { let iter = i.into_iter(); mods.reserve_exact(iter.len()); - let fnv_offset_base = 0x811c9dc5; + const FNV_BASE: u32 = 0x811c9dc5; + const FNV_PRIME: u32 = 0x01000193; + let fnv1_a = |base: u32, bytes: &[u8]| { bytes.iter().fold(base, |hash, byte| { - hash.bitxor(*byte as u32).wrapping_mul(0x01000193) + hash.bitxor(*byte as u32).wrapping_mul(FNV_PRIME) }) }; @@ -556,12 +558,18 @@ impl From for ModProfileV2Layout { .map(|e| <(String, ModEntryV2Layout)>::from(e.into())) .enumerate() { + let mut hash = None; + while mods.get(&name).is_some() { - let seeded_hash = fnv1_a(fnv_offset_base, &i.to_ne_bytes()); + let seeded_hash = hash.get_or_insert_with(|| { + name.push('_'); + fnv1_a(FNV_BASE, &i.to_ne_bytes()) + }); + let path_bytes = mod_entry.path().as_os_str().as_encoded_bytes(); + *seeded_hash = fnv1_a(*seeded_hash, path_bytes); - name.push('_'); - name.push_str(&fnv1_a(seeded_hash, path_bytes).to_string()); + name.push_str(&seeded_hash.to_string()); } mods.insert(name, mod_entry); From 0e161449f0d86489ced883e4d34f89c26cff407e Mon Sep 17 00:00:00 2001 From: Dasaav-dsv Date: Wed, 24 Sep 2025 13:52:35 +0200 Subject: [PATCH 15/20] chore: Add missing `long` to clap attrs Signed-off-by: Dasaav-dsv --- crates/cli/src/commands/launch.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cli/src/commands/launch.rs b/crates/cli/src/commands/launch.rs index 654ebab0..4ac030ee 100644 --- a/crates/cli/src/commands/launch.rs +++ b/crates/cli/src/commands/launch.rs @@ -182,7 +182,7 @@ pub struct LaunchArgs { natives: Vec, /// Name of an alternative savefile to use (in the default savefile directory). - #[clap(help_heading = "Mod configuration")] + #[clap(long, help_heading = "Mod configuration")] savefile: Option, } From 94c06384bcb2732e04a763a34fbedfd9d42e949d Mon Sep 17 00:00:00 2001 From: Dasaav-dsv Date: Tue, 21 Oct 2025 20:31:30 +0200 Subject: [PATCH 16/20] chore: Final clap style touch-ups Signed-off-by: Dasaav-dsv --- crates/cli/src/commands/launch.rs | 62 +++++++++++++++--------------- crates/cli/src/commands/profile.rs | 18 ++++----- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/crates/cli/src/commands/launch.rs b/crates/cli/src/commands/launch.rs index 4ac030ee..4a56d82a 100644 --- a/crates/cli/src/commands/launch.rs +++ b/crates/cli/src/commands/launch.rs @@ -64,24 +64,24 @@ pub struct Selector { /// Short name of a game to launch. #[clap( - short('g'), + short, long, hide_possible_values = false, help_heading = "Game selection", - required = false + required = false, + value_enum )] - #[arg(value_enum)] game: Option, /// Steam APPID of the game to launch. #[clap( - short('s'), + short, long, alias("steamid"), help_heading = "Game selection", - required = false + required = false, + value_parser = clap::value_parser!(u32) )] - #[arg(value_parser = clap::value_parser!(u32))] steam_id: Option, } @@ -103,7 +103,7 @@ pub struct GameOptions { pub(crate) skip_steam_init: Option, /// Custom path to the game executable. - #[clap(short('e'), long, help_heading = "Game selection", value_hint = clap::ValueHint::FilePath)] + #[clap(short, long, help_heading = "Game selection", value_hint = clap::ValueHint::FilePath)] pub(crate) exe: Option, } @@ -143,42 +143,42 @@ pub struct LaunchArgs { /// Name of a profile in the me3 profile dir, or path to a ModProfile (TOML or JSON) /// [repeatable option] - #[arg( - short, - long("profile"), - action = clap::ArgAction::Append, - help_heading = "Mod configuration", - value_hint = clap::ValueHint::FilePath, - )] + #[clap( + short, + long("profile"), + action = clap::ArgAction::Append, + help_heading = "Mod configuration", + value_hint = clap::ValueHint::FilePath, + )] profiles: Vec, /// Path to a native DLL, package, file or a profile to use [repeatable option] #[clap( - short, - long("mod"), - action = clap::ArgAction::Append, - help_heading = "Mod configuration", - value_hint = clap::ValueHint::AnyPath, - )] + short, + long("mod"), + action = clap::ArgAction::Append, + help_heading = "Mod configuration", + value_hint = clap::ValueHint::AnyPath, + )] mods: Vec, /// Path to package directory (asset override mod) [repeatable option] #[clap( - long("package"), - action = clap::ArgAction::Append, - help_heading = "Mod configuration", - value_hint = clap::ValueHint::DirPath, - )] + long("package"), + action = clap::ArgAction::Append, + help_heading = "Mod configuration", + value_hint = clap::ValueHint::DirPath, + )] packages: Vec, /// Path to DLL file (native DLL mod) [repeatable option] #[clap( - short, - long("native"), - action = clap::ArgAction::Append, - help_heading = "Mod configuration", - value_hint = clap::ValueHint::FilePath, - )] + short, + long("native"), + action = clap::ArgAction::Append, + help_heading = "Mod configuration", + value_hint = clap::ValueHint::FilePath, + )] natives: Vec, /// Name of an alternative savefile to use (in the default savefile directory). diff --git a/crates/cli/src/commands/profile.rs b/crates/cli/src/commands/profile.rs index 80039e31..1654b49c 100644 --- a/crates/cli/src/commands/profile.rs +++ b/crates/cli/src/commands/profile.rs @@ -38,24 +38,24 @@ pub struct ProfileCreateArgs { short, long, hide_possible_values = false, - help_heading = "Game selection" + help_heading = "Game selection", + value_enum )] - #[arg(value_enum)] game: Option, /// Path to a native DLL, package, file or profile [repeatable option] #[clap( - short, - long("mod"), - action = clap::ArgAction::Append, - )] + short, + long("mod"), + action = clap::ArgAction::Append, + )] mods: Vec, /// Path to package directory (asset override mod) [repeatable option] #[clap( - long("native"), - action = clap::ArgAction::Append, - )] + long("native"), + action = clap::ArgAction::Append, + )] natives: Vec, /// Path to DLL file (native DLL mod) [repeatable option] From f022cd1e3a9c4036e63d14011b29a45ae05d2270 Mon Sep 17 00:00:00 2001 From: Dasaav-dsv Date: Tue, 21 Oct 2025 21:56:28 +0200 Subject: [PATCH 17/20] chore: Fix schema name Signed-off-by: Dasaav-dsv --- crates/mod-protocol/src/profile/v2.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/mod-protocol/src/profile/v2.rs b/crates/mod-protocol/src/profile/v2.rs index e059419a..fda35d4d 100644 --- a/crates/mod-protocol/src/profile/v2.rs +++ b/crates/mod-protocol/src/profile/v2.rs @@ -604,7 +604,7 @@ impl JsonSchema for ModProfileV2 { impl JsonSchema for ModEntryV2 { fn schema_name() -> std::borrow::Cow<'static, str> { - "Profilemod_entry".into() + "ModEntryV2".into() } fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema { From 91f658af7799e7af7e135f50beab898182759024 Mon Sep 17 00:00:00 2001 From: Dasaav-dsv Date: Tue, 21 Oct 2025 21:56:41 +0200 Subject: [PATCH 18/20] chore: Deserialization tests Signed-off-by: Dasaav-dsv --- crates/mod-protocol/src/profile/v2.rs | 91 +++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/crates/mod-protocol/src/profile/v2.rs b/crates/mod-protocol/src/profile/v2.rs index fda35d4d..1815d1c7 100644 --- a/crates/mod-protocol/src/profile/v2.rs +++ b/crates/mod-protocol/src/profile/v2.rs @@ -611,3 +611,94 @@ impl JsonSchema for ModEntryV2 { schema_for!(ModEntryV2Layout) } } + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use indexmap::IndexMap; + + use crate::profile::v2::{ModEntryV2, ModEntryV2Layout}; + + #[test] + fn deserialize_natives() { + let map = toml::from_str::>( + r#" + native1 = "foo.dll" + native2.path = "some/path/bar.dll" + native3 = { kind = "native", path = "so.so", enabled = false } + native4 = { path = "foo2.dll", initializer.delay.ms = 1000, optional = true } + native5 = { path = "bar2.dll", load_before = [ + { id = "native2", optional = true } + ] } + "#, + ) + .unwrap(); + + let entries = map.into_iter().map(ModEntryV2::from).collect::>(); + + assert!(matches!(entries[0], ModEntryV2::Native(_))); + assert!(matches!(entries[1], ModEntryV2::Native(_))); + assert!(matches!(entries[2], ModEntryV2::Native(_))); + assert!(matches!(entries[3], ModEntryV2::Native(_))); + assert!(matches!(entries[4], ModEntryV2::Native(_))); + } + + #[test] + fn deserialize_packages() { + let map = toml::from_str::>( + r#" + package1 = "foo" + package2.path = "some/path/bar" + package3 = { kind = "package", path = "foo.dll", enabled = false } + package4 = { path = "foo2", optional = true } + package5 = { path = "bar2", load_before = [ + { id = "package2", optional = true } + ] } + "#, + ) + .unwrap(); + + let entries = map.into_iter().map(ModEntryV2::from).collect::>(); + + assert!(matches!(entries[0], ModEntryV2::Package(_))); + assert!(matches!(entries[1], ModEntryV2::Package(_))); + assert!(matches!(entries[2], ModEntryV2::Package(_))); + assert!(matches!(entries[3], ModEntryV2::Package(_))); + assert!(matches!(entries[4], ModEntryV2::Package(_))); + } + + #[test] + fn deserialize_profiles() { + let map = toml::from_str::>( + r#" + profile1 = "foo.me3" + profile2.path = "some/path/bar.me3.toml" + profile3 = { kind = "profile", path = "foo.toml", enabled = false } + profile4 = "foo.me3.json" + "#, + ) + .unwrap(); + + let entries = map.into_iter().map(ModEntryV2::from).collect::>(); + + assert!(matches!(entries[0], ModEntryV2::Profile(_))); + assert!(matches!(entries[1], ModEntryV2::Profile(_))); + assert!(matches!(entries[2], ModEntryV2::Profile(_))); + assert!(matches!(entries[3], ModEntryV2::Profile(_))); + } + + #[test] + fn deserialize_rejects() { + let no_name = + toml::from_str::>(r#"entry.kind = "native""#); + let empty = toml::from_str::>(r#"entry = {}"#); + let strict = toml::from_str::>( + r#"entry = { kind = "package", initializer.delay.ms = 1000 }"#, + ); + + assert!(no_name.is_err()); + assert!(empty.is_err()); + assert!(strict.is_err()); + } +} From 0d5dde6fa36b0b6c58934050fefaa33fddf4e441 Mon Sep 17 00:00:00 2001 From: Dasaav-dsv Date: Wed, 22 Oct 2025 00:20:24 +0200 Subject: [PATCH 19/20] chore: Add comments to `db::Profile::compile` Signed-off-by: Dasaav-dsv --- crates/cli/src/db/profile.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/cli/src/db/profile.rs b/crates/cli/src/db/profile.rs index de5a4290..359027fd 100644 --- a/crates/cli/src/db/profile.rs +++ b/crates/cli/src/db/profile.rs @@ -136,6 +136,8 @@ impl Profile { let mut children = root.profile.inner.profiles(); canonicalize(base_dir, &mut children); + // FIFO queue used to recursively walk child profiles depth first. + // Entries are collected in reverse order and popped. let mut remaining = children .into_iter() .rev() @@ -154,10 +156,12 @@ impl Profile { while let Some((next, after)) = remaining.pop() { if let Some(index) = profiles.iter().position(|p| p.path == next) { + // The profile has already been loaded and needs its load order adjusted. let mut profile = profiles.remove(index); profile.load_after = Some(after); profiles.push(profile); } else { + // The profile needs to be loaded and recursively walked. let profile = db.load(next.as_ref())?; let profile = ProfileDependency::from_profile(profile, Some(after)); @@ -166,6 +170,8 @@ impl Profile { let mut children = profile.profile.inner.profiles(); canonicalize(base_dir, &mut children); + // Depth first, so prioritize children (and children of children). + // Reverse to pop in FIFO order. for next in children.into_iter().rev() { remaining.push(( ProfilePath::from(&*next.path), From f5a0307bc07cbce38ac11f2259f36e60a586393c Mon Sep 17 00:00:00 2001 From: Dasaav-dsv Date: Wed, 22 Oct 2025 18:39:01 +0200 Subject: [PATCH 20/20] chore: Update test profiles with simplified syntax Signed-off-by: Dasaav-dsv --- crates/mod-protocol/test-data/v2/advanced_config.me3 | 2 +- crates/mod-protocol/test-data/v2/basic_config.me3 | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/mod-protocol/test-data/v2/advanced_config.me3 b/crates/mod-protocol/test-data/v2/advanced_config.me3 index e91ec4ce..da460b89 100644 --- a/crates/mod-protocol/test-data/v2/advanced_config.me3 +++ b/crates/mod-protocol/test-data/v2/advanced_config.me3 @@ -7,7 +7,7 @@ disable_arxan = true start_online = true [mods] -my_mod.path = './my-mod' +my_mod = './my-mod' my_dll = { path = './my-mod/my_dll.dll', initializer.delay.ms = 3000 } hks_debug = { path = './hks_debug.me3', optional = true } my_other_mod = { path = './my-other-mod', disabled = true } diff --git a/crates/mod-protocol/test-data/v2/basic_config.me3 b/crates/mod-protocol/test-data/v2/basic_config.me3 index 2c996b97..3bca095f 100644 --- a/crates/mod-protocol/test-data/v2/basic_config.me3 +++ b/crates/mod-protocol/test-data/v2/basic_config.me3 @@ -1,6 +1,6 @@ profileVersion = "v2" [mods] -my_mod.path = './my-mod' -my_dll.path = './my-mod/my_dll.dll' -my_profile.path = 'my_profile.me3' +my_mod = './my-mod' +my_dll = './my-mod/my_dll.dll' +my_profile = 'my_profile.me3'