diff --git a/Cargo.lock b/Cargo.lock index 3b87e6ed..b0e7b1d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1353,6 +1353,7 @@ checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" dependencies = [ "equivalent", "hashbrown", + "serde", ] [[package]] @@ -1633,6 +1634,7 @@ dependencies = [ "normpath", "open", "pretty_assertions", + "same-file", "semver", "serde", "serde_json", @@ -1670,6 +1672,7 @@ dependencies = [ "me3_telemetry", "minidump-writer", "sentry", + "serde_json", "toml 0.9.5", "tracing", "windows", @@ -1684,7 +1687,6 @@ dependencies = [ "eyre", "me3-mod-protocol", "serde", - "serde_derive", ] [[package]] @@ -2736,6 +2738,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/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 76986fd2..4a56d82a 100644 --- a/crates/cli/src/commands/launch.rs +++ b/crates/cli/src/commands/launch.rs @@ -18,10 +18,14 @@ 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::{ + native::Native, + package::Package, + profile::{builder::ModProfileBuilder, Profile}, +}; use normpath::PathExt; use serde::{Deserialize, Serialize}; use steamlocate::{CompatTool, Library, SteamDir}; @@ -31,13 +35,13 @@ 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, }; 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(); @@ -60,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, } @@ -99,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, } @@ -130,43 +134,55 @@ 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(long, action = ArgAction::SetTrue)] suspend: bool, - /// Name of a profile in the me3 profile dir, or path to a ModProfile (TOML or JSON). - #[arg( - short('p'), - long("profile"), - help_heading = "Mod configuration", - value_hint = clap::ValueHint::FilePath, - )] - profile: Option, + /// Name of a profile in the me3 profile dir, or path to a ModProfile (TOML or JSON) + /// [repeatable option] + #[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, + )] + mods: Vec, /// Path to package directory (asset override mod) [repeatable option] - #[arg( - long("package"), - action = clap::ArgAction::Append, - help_heading = "Mod configuration", - value_hint = clap::ValueHint::DirPath, - )] + #[clap( + 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] - #[arg( - short('n'), - long("native"), - action = clap::ArgAction::Append, - help_heading = "Mod configuration", - value_hint = clap::ValueHint::FilePath, - )] + #[clap( + 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). - #[arg(long("savefile"), help_heading = "Mod configuration")] + #[clap(long, help_heading = "Mod configuration")] savefile: Option, } @@ -223,8 +239,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() @@ -254,7 +269,6 @@ impl Launcher for CompatToolLauncher { struct LaunchContext { game: Game, - profile: Profile, game_options: GameOptions, profile_options: ProfileOptions, attach_config: AttachConfig, @@ -266,29 +280,54 @@ 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() }; - 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") - }?; + 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); + + 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_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) + .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 @@ -303,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, @@ -321,39 +360,16 @@ impl LaunchArgs { fn generate_attach_config( &self, + db: &DbContext, game: Game, opts: &GameOptions, - profile: &Profile, + profile: DbProfile, 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 profile_name = profile.name().to_owned(); + 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| { @@ -370,10 +386,13 @@ impl LaunchArgs { } } + let (natives, packages) = profile.compile(&db.profiles)?; + Ok(AttachConfig { + profile_name, game: game.into(), - packages, natives, + packages, savefile, cache_path: cache_path.map(|path| path.into_path_buf()), suspend: self.suspend, @@ -390,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, @@ -456,12 +474,12 @@ 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")?; - 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/commands/profile.rs b/crates/cli/src/commands/profile.rs index 78adcabb..1654b49c 100644 --- a/crates/cli/src/commands/profile.rs +++ b/crates/cli/src/commands/profile.rs @@ -3,12 +3,11 @@ 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, + package::Package, + profile::{builder::ModProfileBuilder, ModProfile, Profile}, }; -use tracing::error; +use tracing::{error, info}; use crate::{config::Config, db::DbContext, output::OutputBuilder, Game}; @@ -23,7 +22,10 @@ pub enum ProfileCommands { List, /// Show information on a profile. - Show(#[clap(flatten)] ProfileNameArgs), + Show(ProfileNameArgs), + + /// Upgrade a profile to the latest profile version. + Upgrade(ProfileNameArgs), } #[derive(Args, Debug)] @@ -33,21 +35,42 @@ 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" + 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, + )] + mods: Vec, + /// Path to package directory (asset override mod) [repeatable option] - #[clap(long("package"))] - packages: Vec, + #[clap( + long("native"), + action = clap::ArgAction::Append, + )] + natives: Vec, /// Path to DLL file (native DLL mod) [repeatable option] - #[clap(short('n'), long("native"))] - natives: Vec, + #[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"))] @@ -141,40 +164,32 @@ 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)); - } - - let natives = profile.natives_mut(); - for pkg in args.natives { - natives.push(Native::new(pkg)); - } - - 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)?; + 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_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) + .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 +204,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 +216,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 +227,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 +261,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_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) + .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..359027fd 100644 --- a/crates/cli/src/db/profile.rs +++ b/crates/cli/src/db/profile.rs @@ -1,17 +1,22 @@ 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::{sort_dependencies, Dependency, Dependent}, + mod_file::{AsModFile, ModFile}, native::Native, - package::{Package, WithPackageSource}, - Game, ModProfile, + package::Package, + profile::{ModProfile, ProfileMergeError}, + Game, }; use normpath::PathExt; +use serde::{Deserialize, Serialize}; use tracing::warn; use crate::commands::profile::ProfileOptions; @@ -28,10 +33,11 @@ impl ProfileDb { } } +#[derive(Debug)] pub struct Profile { name: String, path: PathBuf, - profile: ModProfile, + inner: ModProfile, } impl Profile { @@ -40,7 +46,7 @@ impl Profile { Self { name: "transient-profile".to_string(), path: Default::default(), - profile: Default::default(), + inner: Default::default(), } } @@ -54,77 +60,160 @@ 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.inner.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() + self.inner.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() + self.inner.packages().into_iter() + } + + /// Returns a list of profiles loaded by this profile. + pub fn profiles(&self) -> impl Iterator { + 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(), } } - /// Compile this profile into a load order of native DLLs and packages 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, + /// 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(), + 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, db: &ProfileDb) -> color_eyre::Result<(Vec, Vec)> { + 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 root = ProfileDependency::from_profile(self, None); + + let base_dir = root.profile.base_dir().unwrap_or(Path::new(".")); + + 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() + .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) { + // 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)); + + let base_dir = profile.profile.base_dir().unwrap_or(Path::new(".")); + + 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), + Dependent { + id: profile.path.clone(), + optional: next.optional, + }, + )); + } + + profiles.push(profile); + } } - let mut packages = self.profile.packages(); - let mut natives = self.profile.natives(); + let ordered_profiles = sort_dependencies(profiles)?; + + let mut ordered_natives = vec![]; + let mut ordered_packages = vec![]; - let base_dir = self.base_dir().unwrap_or(Path::new(".")); + for ordered in ordered_profiles { + let base_dir = ordered.profile.base_dir().unwrap_or(Path::new(".")); - canonicalize(base_dir, &mut packages); - canonicalize(base_dir, &mut natives); + let mut natives = ordered.profile.inner.natives(); + let mut packages = ordered.profile.inner.packages(); - let ordered_natives = sort_dependencies(natives)?; - let ordered_packages = sort_dependencies(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.inner + } +} + #[derive(thiserror::Error, Debug)] pub enum ProfileDbError { #[error("no profile named {0} could be found")] @@ -185,7 +274,7 @@ impl ProfileDb { Ok(Profile { name, path: normalized_path.into_path_buf(), - profile, + inner: profile, }) } @@ -206,6 +295,87 @@ impl ProfileDb { } } +#[derive(Clone, Debug, Hash)] +#[allow(clippy::derived_hash_with_manual_eq)] +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/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..a93ef85a 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 { @@ -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, @@ -54,8 +57,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..bf3ad7d9 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}, @@ -62,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() @@ -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,40 @@ 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..0965b20c 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..be29b2d1 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,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" = %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..11b5bb65 100644 --- a/crates/mod-host/src/host.rs +++ b/crates/mod-host/src/host.rs @@ -3,29 +3,33 @@ use std::{ ffi::CString, fmt::Debug, marker::Tuple, - panic, - path::Path, + panic::{self, AssertUnwindSafe}, ptr, 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 me3_mod_protocol::{ + native::{Native, NativeInitializerCondition}, + profile::ModProfile, + Game, +}; use pelite::pe::Pe; use regex::bytes::Regex; use retour::Function; -use tracing::{error, info, warn, Span}; +use tracing::{error, info, instrument, Span}; use windows::core::w; use self::hook::HookInstaller; use crate::{ detour::UntypedDetour, executable::Executable, - native::{ModEngineConnectorShim, ModEngineExtension, ModEngineInitializer}, + native::{ModEngineConnectorShim, ModEngineInitializer}, }; mod append; @@ -58,53 +62,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..b07506fe 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); @@ -155,18 +198,8 @@ 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.display(), @@ -174,28 +207,11 @@ fn deferred_attach( ); 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-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/dependency.rs b/crates/mod-protocol/src/dependency.rs index 9f464487..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); } @@ -242,13 +244,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 +253,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/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..eecf8afa --- /dev/null +++ b/crates/mod-protocol/src/mod_file.rs @@ -0,0 +1,121 @@ +use std::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 file. + pub name: String, + + /// A path to the source of this file. + pub path: PathBuf, + + /// 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 file result in a hard error? + #[serde( + default = "ModFile::optional_default", + skip_serializing_if = "ModFile::optional_is_default" + )] + 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); + } + } + + #[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 { + #[inline] + fn default() -> Self { + Self { + name: Default::default(), + path: Default::default(), + enabled: Self::enabled_default(), + optional: Self::optional_default(), + } + } +} + +impl AsRef for ModFile { + #[inline] + fn as_ref(&self) -> &Path { + &self.path + } +} + +impl From for ModFile { + #[inline] + fn from(path: PathBuf) -> Self { + Self { + name: path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(), + 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..bd660fc3 100644 --- a/crates/mod-protocol/src/native.rs +++ b/crates/mod-protocol/src/native.rs @@ -1,76 +1,52 @@ -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}, + mod_file::{AsModFile, ModFile}, }; -fn off() -> bool { - false -} - -fn on() -> bool { - true +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct NativeInitializerDelay { + pub ms: usize, } #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] -pub enum NativeInitializerCondition { - #[serde(rename = "delay")] - Delay { ms: usize }, - #[serde(rename = "function")] - Function(String), +pub struct NativeInitializerCondition { + #[serde(default)] + pub delay: Option, + #[serde(default)] + pub function: Option, } -#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +#[derive(Clone, Debug, Deserialize, Serialize)] 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, + #[serde(flatten)] + pub(crate) inner: ModFile, - /// Should this native be loaded? - #[serde(default = "on")] - pub enabled: bool, + pub initializer: Option, #[serde(default)] - load_before: Vec>, + pub(crate) load_before: Vec>, #[serde(default)] - load_after: Vec>, - - /// 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, + pub(crate) load_after: Vec>, } 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, - } - } -} - -impl WithPackageSource for Native { - fn source(&self) -> &crate::package::ModFile { - &self.path + #[inline] + pub fn new>(path: P) -> Self { + ModFile::new(path).into() } - fn source_mut(&mut self) -> &mut crate::package::ModFile { - &mut self.path + #[inline] + pub fn is_default(&self) -> bool { + self.inner.is_default() && self.initializer.is_none() } } @@ -79,17 +55,70 @@ impl Dependency for Native { fn id(&self) -> Self::UniqueId { self.path - .0 .file_name() - .map(|f| f.to_string_lossy().to_string()) + .map(|f| f.to_string_lossy().to_ascii_lowercase()) .expect("native had no file name") } - fn loads_after(&self) -> &[Dependent] { + fn load_before(&self) -> &[Dependent] { + &self.load_before + } + + fn load_after(&self) -> &[Dependent] { &self.load_after } +} + +impl AsRef for Native { + #[inline] + fn as_ref(&self) -> &Path { + self.as_mod_file().as_ref() + } +} - fn loads_before(&self) -> &[Dependent] { - &self.load_before +impl Deref for Native { + type Target = ModFile; + + #[inline] + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for Native { + #[inline] + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +impl AsModFile for Native { + #[inline] + fn as_mod_file(&self) -> &ModFile { + &self.inner + } + + #[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, + load_before: vec![], + load_after: vec![], + } + } +} + +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..9e38e7a9 100644 --- a/crates/mod-protocol/src/package.rs +++ b/crates/mod-protocol/src/package.rs @@ -1,126 +1,102 @@ 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::{ + 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, JsonSchema)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct Package { - /// The unique identifier for this package. - pub(crate) id: Option, + #[serde(flatten)] + pub(crate) inner: ModFile, - /// 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>, + pub(crate) load_before: Vec>, - /// A list of packages that this package should load before. #[serde(default)] - pub(crate) load_before: Vec>, + pub(crate) load_after: Vec>, } 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 Dependency for Package { + type UniqueId = String; + + fn id(&self) -> Self::UniqueId { + self.name.clone() } -} -impl WithPackageSource for Package { - fn source(&self) -> &ModFile { - &self.path + fn load_before(&self) -> &[crate::dependency::Dependent] { + &self.load_before } - fn source_mut(&mut self) -> &mut ModFile { - &mut self.path + fn load_after(&self) -> &[crate::dependency::Dependent] { + &self.load_after } } -impl Dependency for Package { - type UniqueId = String; +impl AsRef for Package { + #[inline] + fn as_ref(&self) -> &Path { + self.as_mod_file().as_ref() + } +} - fn id(&self) -> Self::UniqueId { - self.id - .clone() - .unwrap_or_else(|| self.path.to_string_lossy().into()) +impl Deref for Package { + type Target = ModFile; + + #[inline] + fn deref(&self) -> &Self::Target { + &self.inner } +} - fn loads_after(&self) -> &[crate::dependency::Dependent] { - &self.load_after +impl DerefMut for Package { + #[inline] + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner } +} - fn loads_before(&self) -> &[crate::dependency::Dependent] { - &self.load_before +impl AsModFile for Package { + #[inline] + fn as_mod_file(&self) -> &ModFile { + &self.inner + } + + #[inline] + fn as_mod_file_mut(&mut self) -> &mut ModFile { + &mut self.inner } } -pub trait AssetOverrideSource { - fn asset_path(&self) -> &Path; +impl From for Package { + #[inline] + fn from(item: ModFile) -> Self { + Self { + inner: item, + load_before: vec![], + load_after: vec![], + } + } } -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..1aa6c435 --- /dev/null +++ b/crates/mod-protocol/src/profile.rs @@ -0,0 +1,174 @@ +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; + +pub type Profile = ModFile; + +#[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_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(); + + 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..ff3a5889 --- /dev/null +++ b/crates/mod-protocol/src/profile/builder.rs @@ -0,0 +1,95 @@ +use std::{ + io, + path::{Path, PathBuf}, +}; + +use crate::{ + profile::{ + v2::{ModEntryV2, ModProfileV2}, + ModProfile, + }, + Game, +}; + +#[derive(Default)] +pub struct ModProfileBuilder { + supports: Option, + mods: Vec, + 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, + mods, + savefile, + start_online, + disable_arxan, + } = std::mem::take(self); + + let mut profile = ModProfileV2 { + supports, + savefile, + start_online, + disable_arxan, + ..Default::default() + }; + + for mod_entry in mods { + profile.push_mod_entry(mod_entry); + } + + 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.mods.extend(iter.into_iter().map(Into::into)); + self + } + + #[inline] + pub fn with_mods(&mut self, iter: I) -> &mut Self + where + I: IntoIterator>, + { + self.mods.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..4106d60c --- /dev/null +++ b/crates/mod-protocol/src/profile/v1.rs @@ -0,0 +1,192 @@ +use std::path::PathBuf; + +use schemars::{schema_for, JsonSchema}; +use serde::Deserialize; + +use crate::{ + dependency::Dependent, + 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); + +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 { + 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 { + initializer, + load_before: value.load_before, + load_after: value.load_after, + ..item.into() + } + } +} + +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 { + load_before: value.load_before, + load_after: value.load_after, + ..item.into() + } + } +} + +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..1815d1c7 --- /dev/null +++ b/crates/mod-protocol/src/profile/v2.rs @@ -0,0 +1,704 @@ +use std::{ + ops::BitXor, + 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, + profile::Profile, + 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_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), + } + } +} + +#[derive(Default, Deserialize, Serialize, JsonSchema)] +struct ModProfileV2Layout { + #[serde(default)] + game: GamePropertiesV2, + + #[serde(default, skip_serializing_if = "IndexMap::is_empty")] + mods: IndexMap, +} + +#[derive(Default, Deserialize, Serialize, JsonSchema)] +struct GamePropertiesV2 { + launch: Option, + savefile: Option, + start_online: Option, + disable_arxan: Option, +} + +#[derive(Clone, Debug)] +pub enum ModEntryV2 { + Native(Native), + Package(Package), + Profile(Profile), +} + +#[derive(Clone, Deserialize, Serialize, JsonSchema)] +#[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), + + #[serde(untagged)] + Untagged(UntaggedModEntryV2), +} + +#[derive(Clone, Deserialize, Serialize, JsonSchema)] +struct ModFileV2 { + path: PathBuf, + + #[serde( + default = "ModFile::enabled_default", + skip_serializing_if = "ModFile::enabled_is_default" + )] + enabled: bool, + + #[serde( + default = "ModFile::optional_default", + skip_serializing_if = "ModFile::optional_is_default" + )] + optional: bool, +} + +#[derive(Clone, Deserialize, Serialize, JsonSchema)] +struct UntaggedModEntryV2 { + #[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>, +} + +impl ModEntryV2 { + pub fn new>(path: P) -> Self { + path.as_ref().to_owned().into() + } +} + +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 { + 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(Profile { + 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()) + } + } + } + } +} + +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(), + }; + + let file_name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_ascii_lowercase(); + + 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 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") + { + ModFile::from(path).into() + } else { + Package::from(path).into() + } + } +} + +impl From for ModEntryV2 { + fn from(native: Native) -> Self { + Self::Native(native) + } +} + +impl From for ModEntryV2 { + fn from(package: Package) -> Self { + Self::Package(package) + } +} + +impl From for ModEntryV2 { + fn from(profile: Profile) -> Self { + Self::Profile(profile) + } +} + +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, + } = mod_entry; + + Self { + inner: ModFile { + name, + path, + enabled, + optional, + }, + load_before, + load_after, + initializer, + } + } +} + +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, + .. + } = mod_entry; + + Self { + inner: ModFile { + name, + path, + enabled, + optional, + }, + load_before, + load_after, + } + } +} + +impl From<(String, UntaggedModEntryV2)> for ModFile { + fn from((name, mod_entry): (String, UntaggedModEntryV2)) -> Self { + let UntaggedModEntryV2 { + inner: + ModFileV2 { + path, + enabled, + optional, + }, + .. + } = mod_entry; + + Self { + name, + path, + enabled, + optional, + } + } +} + +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(); + + fn push_unique_mods< + I: IntoIterator, IntoIter: ExactSizeIterator>, + >( + mods: &mut IndexMap, + i: I, + ) { + let iter = i.into_iter(); + mods.reserve_exact(iter.len()); + + 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(FNV_PRIME) + }) + }; + + for (i, (mut name, mod_entry)) in iter + .map(|e| <(String, ModEntryV2Layout)>::from(e.into())) + .enumerate() + { + let mut hash = None; + + while mods.get(&name).is_some() { + 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_str(&seeded_hash.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 { + launch: profile.supports, + savefile: profile.savefile, + start_online: profile.start_online, + disable_arxan: profile.disable_arxan, + }, + mods, + } + } +} + +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) + } +} + +impl JsonSchema for ModEntryV2 { + fn schema_name() -> std::borrow::Cow<'static, str> { + "ModEntryV2".into() + } + + fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + 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()); + } +} 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..4b5be06e --- /dev/null +++ b/crates/mod-protocol/test-data/v1/advanced_config.me3.expected @@ -0,0 +1,100 @@ +V1( + ModProfileV1 { + supports: [ + Supports { + game: Nightreign, + since_version: None, + }, + ], + natives: [ + Native { + inner: ModFile { + name: "my_native", + path: "my_native.dll", + enabled: true, + optional: true, + }, + initializer: None, + load_before: [], + load_after: [], + }, + Native { + inner: ModFile { + name: "my_other_native", + path: "./nr-mods/my_other_native.dll", + enabled: true, + optional: false, + }, + initializer: Some( + NativeInitializerCondition { + delay: None, + function: Some( + "init_my_dll", + ), + }, + ), + load_before: [], + load_after: [], + }, + ], + packages: [ + Package { + inner: ModFile { + name: "my-mod", + path: "./mod", + enabled: true, + optional: false, + }, + load_before: [], + load_after: [], + }, + Package { + inner: ModFile { + name: "unnamed-mod", + path: "./unnamed-mod", + enabled: true, + optional: false, + }, + load_before: [], + load_after: [ + Dependent { + id: "my-mod", + optional: false, + }, + ], + }, + Package { + inner: ModFile { + name: "my-other-mod", + path: "./nr-mods/mod", + enabled: true, + optional: false, + }, + load_before: [ + Dependent { + id: "my-mod", + optional: true, + }, + ], + 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( + 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/basic_config.me3.toml.expected b/crates/mod-protocol/test-data/v1/basic_config.me3.expected similarity index 52% rename from crates/mod-protocol/test-data/basic_config.me3.toml.expected rename to crates/mod-protocol/test-data/v1/basic_config.me3.expected index 5abfd505..9c8fa048 100644 --- a/crates/mod-protocol/test-data/basic_config.me3.toml.expected +++ b/crates/mod-protocol/test-data/v1/basic_config.me3.expected @@ -3,28 +3,27 @@ V1( supports: [], natives: [ Native { - path: ModFile( - "my_native.dll", - ), - optional: true, - enabled: true, + inner: ModFile { + name: "my_native", + path: "my_native.dll", + enabled: true, + optional: true, + }, + initializer: None, load_before: [], load_after: [], - initializer: None, - finalizer: None, }, ], packages: [ Package { - id: Some( - "my-mod", - ), - enabled: true, - path: ModFile( - "mod/", - ), - load_after: [], + inner: ModFile { + name: "my-mod", + path: "./mod", + enabled: true, + optional: false, + }, load_before: [], + load_after: [], }, ], savefile: 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/plural_packages.me3.expected b/crates/mod-protocol/test-data/v1/plural_packages.me3.expected similarity index 59% rename from crates/mod-protocol/test-data/plural_packages.me3.expected rename to crates/mod-protocol/test-data/v1/plural_packages.me3.expected index b99936e5..deff2b53 100644 --- a/crates/mod-protocol/test-data/plural_packages.me3.expected +++ b/crates/mod-protocol/test-data/v1/plural_packages.me3.expected @@ -4,15 +4,14 @@ V1( natives: [], packages: [ Package { - id: Some( - "test-pkg", - ), - enabled: true, - path: ModFile( - ".", - ), - load_after: [], + inner: ModFile { + name: "test-pkg", + path: ".", + enabled: true, + optional: false, + }, load_before: [], + load_after: [], }, ], savefile: 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/singular_package.me3.expected b/crates/mod-protocol/test-data/v1/singular_package.me3.expected similarity index 59% rename from crates/mod-protocol/test-data/singular_package.me3.expected rename to crates/mod-protocol/test-data/v1/singular_package.me3.expected index b99936e5..deff2b53 100644 --- a/crates/mod-protocol/test-data/singular_package.me3.expected +++ b/crates/mod-protocol/test-data/v1/singular_package.me3.expected @@ -4,15 +4,14 @@ V1( natives: [], packages: [ Package { - id: Some( - "test-pkg", - ), - enabled: true, - path: ModFile( - ".", - ), - load_after: [], + inner: ModFile { + name: "test-pkg", + path: ".", + enabled: true, + optional: false, + }, load_before: [], + load_after: [], }, ], savefile: 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..da460b89 --- /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 + +[mods] +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 } +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..3500f2de --- /dev/null +++ b/crates/mod-protocol/test-data/v2/advanced_config.me3.expected @@ -0,0 +1,92 @@ +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, + }, + ), + load_before: [], + load_after: [], + }, + 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", + ), + }, + ), + load_before: [], + load_after: [], + }, + ], + packages: [ + Package { + inner: ModFile { + name: "my_mod", + path: "./my-mod", + enabled: true, + optional: false, + }, + 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 { + 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..3bca095f --- /dev/null +++ b/crates/mod-protocol/test-data/v2/basic_config.me3 @@ -0,0 +1,6 @@ +profileVersion = "v2" + +[mods] +my_mod = './my-mod' +my_dll = './my-mod/my_dll.dll' +my_profile = '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..4b4fd220 --- /dev/null +++ b/crates/mod-protocol/test-data/v2/basic_config.me3.expected @@ -0,0 +1,41 @@ +V2( + ModProfileV2 { + supports: None, + natives: [ + Native { + inner: ModFile { + name: "my_dll", + path: "./my-mod/my_dll.dll", + enabled: true, + optional: false, + }, + initializer: None, + load_before: [], + load_after: [], + }, + ], + packages: [ + Package { + inner: ModFile { + name: "my_mod", + path: "./my-mod", + enabled: true, + optional: false, + }, + load_before: [], + load_after: [], + }, + ], + 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..5079dcd7 --- /dev/null +++ b/crates/mod-protocol/test-data/v2/merge_config.me3.expected @@ -0,0 +1,77 @@ +V2( + ModProfileV2 { + supports: Some( + EldenRing, + ), + natives: [ + Native { + inner: ModFile { + name: "my_dll", + path: "./my-mod/my_dll.dll", + enabled: true, + optional: false, + }, + initializer: None, + load_before: [], + load_after: [], + }, + Native { + inner: ModFile { + name: "test_dll", + path: "test.dll", + enabled: true, + optional: false, + }, + initializer: None, + load_before: [], + load_after: [], + }, + Native { + inner: ModFile { + name: "other_dll", + path: "./other_mods/mod.dll", + enabled: true, + optional: true, + }, + initializer: None, + load_before: [], + load_after: [], + }, + ], + packages: [ + Package { + inner: ModFile { + name: "my_mod", + path: "./my-mod", + enabled: true, + optional: false, + }, + load_before: [], + load_after: [], + }, + ], + 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..20ee55ec --- /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 + +[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 new file mode 100644 index 00000000..31e79deb --- /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 + +[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/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..98e9a730 100644 --- a/schemas/mod-profile.json +++ b/schemas/mod-profile.json @@ -14,276 +14,585 @@ "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" + } }, - "since": { + "natives": { + "type": "array", + "items": { + "$ref": "#/$defs/NativeV1" + } + }, + "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/ModFileV1" + }, + "optional": { + "type": "boolean", + "default": false + }, + "enabled": { + "type": "boolean", + "default": true + }, + "load_before": { + "type": "array", + "items": { + "$ref": "#/$defs/Dependent" + }, + "default": [] + }, + "load_after": { + "type": "array", + "items": { + "$ref": "#/$defs/Dependent" + }, + "default": [] + }, + "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": [] + "ModFileV1": { + "type": "string" }, - "load_after": { - "type": "array", - "items": { - "$ref": "#/$defs/Dependent" + "Dependent": { + "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/ModFileV1" + }, + "load_after": { + "type": "array", + "items": { + "$ref": "#/$defs/Dependent" + }, + "default": [] + }, + "load_before": { + "type": "array", + "items": { + "$ref": "#/$defs/Dependent" + }, + "default": [] + } + }, + "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/GamePropertiesV2", + "default": { + "launch": null, + "savefile": null, + "start_online": null, + "disable_arxan": null + } }, - "optional": { - "type": "boolean" + "mods": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/ModEntryV2Layout" + } } }, - "required": [ - "id", - "optional" - ] - }, - "NativeInitializerCondition": { - "oneOf": [ - { + "$defs": { + "GamePropertiesV2": { "type": "object", "properties": { - "delay": { + "launch": { + "anyOf": [ + { + "$ref": "#/$defs/Game" + }, + { + "type": "null" + } + ] + }, + "savefile": { + "type": [ + "string", + "null" + ] + }, + "start_online": { + "type": [ + "boolean", + "null" + ] + }, + "disable_arxan": { + "type": [ + "boolean", + "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" + ] + } + ] + }, + "ModEntryV2Layout": { + "anyOf": [ + { + "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" + ] + }, + { "type": "object", "properties": { - "ms": { - "type": "integer", - "format": "uint", - "minimum": 0 + "path": { + "type": "string" + }, + "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" } }, "required": [ - "ms" + "kind", + "path" ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "profile" + } + }, + "$ref": "#/$defs/ModFileV2", + "required": [ + "kind" + ] + }, + { + "type": "string" + }, + { + "$ref": "#/$defs/UntaggedModEntryV2" } - }, - "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" + "Dependent": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "optional": { + "type": "boolean" + } }, - "default": [] + "required": [ + "id", + "optional" + ] }, - "natives": { - "description": "Native modules (DLLs) that will be loaded.", - "type": "array", - "items": { - "$ref": "#/$defs/Native" + "ModFileV2": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "optional": { + "type": "boolean" + } }, - "default": [] + "required": [ + "path" + ] }, - "packages": { - "description": "A collection of packages containing assets that should be considered for loading\nbefore the DVDBND.", - "type": "array", - "items": { - "$ref": "#/$defs/Package" + "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" + } + } }, - "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 + "required": [ + "path" + ] } } }