pub mod gamemode;
pub mod mangohud;
pub mod optiscaler;
pub mod proton;
pub mod release;
pub mod reshade;
pub mod vkbasalt;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use smallvec::SmallVec;
use crate::detection::{DetectedGame, LauncherSource};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolCategory {
Overlay,
PostProcess,
Performance,
Upscaler,
}
impl std::fmt::Display for ToolCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Overlay => write!(f, "Overlay"),
Self::PostProcess => write!(f, "Post-Processing"),
Self::Performance => write!(f, "Performance"),
Self::Upscaler => write!(f, "Upscaler"),
}
}
}
#[derive(Debug, Clone)]
pub enum ToolAvailability {
Available { version: Option<String> },
NotInstalled { install_hint: String },
}
impl ToolAvailability {
#[must_use]
pub fn is_available(&self) -> bool {
matches!(self, Self::Available { .. })
}
}
#[derive(Debug, Clone)]
pub struct WrapperEntry {
pub exe: String,
pub args: String,
}
#[derive(Debug, Clone, Default)]
pub struct AppliedFiles {
pub files: Vec<PathBuf>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ToolApplyPreview {
pub planned_files: Vec<PathBuf>,
pub changed_files: Vec<PathBuf>,
pub unchanged_files: Vec<PathBuf>,
pub missing_inputs: Vec<String>,
}
impl ToolApplyPreview {
#[must_use]
pub fn has_changes(&self) -> bool {
!self.changed_files.is_empty()
}
pub fn record_file(&mut self, rel_path: PathBuf, changed: bool) {
self.planned_files.push(rel_path.clone());
if changed {
self.changed_files.push(rel_path);
} else {
self.unchanged_files.push(rel_path);
}
}
}
#[derive(Debug, Clone)]
pub struct GeneratedConfig {
pub path: PathBuf,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolReleaseSummary {
pub tag: String,
pub name: Option<String>,
pub published_at: Option<String>,
pub assets: Vec<ToolReleaseAsset>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolReleaseAsset {
pub name: String,
pub download_url: String,
pub size: u64,
}
pub type ToolReleaseListFuture<'a> =
Pin<Box<dyn Future<Output = Result<Vec<ToolReleaseSummary>>> + Send + 'a>>;
pub type ToolReleaseInstallFuture<'a> =
Pin<Box<dyn Future<Output = Result<ToolConfig>> + Send + 'a>>;
#[derive(Debug, Clone)]
pub struct ToolGameContext {
pub game_id: String,
pub display_name: String,
pub install_path: Option<PathBuf>,
pub launcher_source: Option<LauncherSource>,
pub steam_app_id: Option<String>,
pub executable_dir: Option<PathBuf>,
}
impl ToolGameContext {
#[must_use]
pub fn from_parts(
game_id: &str,
display_name: impl Into<String>,
install_path: Option<PathBuf>,
detected: Option<&DetectedGame>,
) -> Self {
let plugin = crate::resolve_game_plugin(game_id);
let executable_dir = install_path
.as_deref()
.and_then(|path| plugin.map(|plugin| plugin.executable_dir(path)));
let launcher_source = detected.map(|game| game.source.clone());
let steam_app_id = launcher_source.as_ref().and_then(|source| match source {
LauncherSource::Steam { app_id, .. } => Some(app_id.clone()),
LauncherSource::HeroicGog { .. }
| LauncherSource::HeroicEpic { .. }
| LauncherSource::HeroicSideload { .. } => None,
});
Self {
game_id: game_id.to_string(),
display_name: display_name.into(),
install_path,
launcher_source,
steam_app_id,
executable_dir,
}
}
#[must_use]
pub fn launcher_label(&self) -> String {
self.launcher_source
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| "Not detected".to_string())
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ToolSettingKind {
Bool,
TriStateBool,
Text,
Path,
Select { options: Vec<ToolSelectOption> },
Number { min: f64, max: f64, step: f64 },
ReadOnly,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolSelectOption {
pub value: String,
pub label: String,
}
impl ToolSelectOption {
#[must_use]
pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
Self {
value: value.into(),
label: label.into(),
}
}
#[must_use]
pub fn value_label(value: impl Into<String>) -> Self {
let value = value.into();
Self {
label: value.clone(),
value,
}
}
}
impl std::fmt::Display for ToolSelectOption {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.label)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ToolSettingSpec {
pub key: &'static str,
pub label: &'static str,
pub description: &'static str,
pub section: &'static str,
pub advanced: bool,
pub kind: ToolSettingKind,
}
impl ToolSettingSpec {
const DEFAULT_SECTION: &'static str = "General";
#[must_use]
pub fn bool(key: &'static str, label: &'static str, description: &'static str) -> Self {
Self {
key,
label,
description,
section: Self::DEFAULT_SECTION,
advanced: false,
kind: ToolSettingKind::Bool,
}
}
#[must_use]
pub fn tri_state_bool(
key: &'static str,
label: &'static str,
description: &'static str,
) -> Self {
Self {
key,
label,
description,
section: Self::DEFAULT_SECTION,
advanced: false,
kind: ToolSettingKind::TriStateBool,
}
}
#[must_use]
pub fn text(key: &'static str, label: &'static str, description: &'static str) -> Self {
Self {
key,
label,
description,
section: Self::DEFAULT_SECTION,
advanced: false,
kind: ToolSettingKind::Text,
}
}
#[must_use]
pub fn path(key: &'static str, label: &'static str, description: &'static str) -> Self {
Self {
key,
label,
description,
section: Self::DEFAULT_SECTION,
advanced: false,
kind: ToolSettingKind::Path,
}
}
#[must_use]
pub fn select(
key: &'static str,
label: &'static str,
description: &'static str,
options: &[&str],
) -> Self {
Self {
key,
label,
description,
section: Self::DEFAULT_SECTION,
advanced: false,
kind: ToolSettingKind::Select {
options: options
.iter()
.map(|option| ToolSelectOption::value_label(*option))
.collect(),
},
}
}
#[must_use]
pub fn labeled_select(
key: &'static str,
label: &'static str,
description: &'static str,
options: &[(&str, &str)],
) -> Self {
Self {
key,
label,
description,
section: Self::DEFAULT_SECTION,
advanced: false,
kind: ToolSettingKind::Select {
options: options
.iter()
.map(|(value, label)| ToolSelectOption::new(*value, *label))
.collect(),
},
}
}
#[must_use]
pub fn number(
key: &'static str,
label: &'static str,
description: &'static str,
min: f64,
max: f64,
step: f64,
) -> Self {
Self {
key,
label,
description,
section: Self::DEFAULT_SECTION,
advanced: false,
kind: ToolSettingKind::Number { min, max, step },
}
}
#[must_use]
pub fn read_only(key: &'static str, label: &'static str, description: &'static str) -> Self {
Self {
key,
label,
description,
section: Self::DEFAULT_SECTION,
advanced: false,
kind: ToolSettingKind::ReadOnly,
}
}
#[must_use]
pub fn section(mut self, section: &'static str) -> Self {
self.section = section;
self
}
#[must_use]
pub fn advanced(mut self) -> Self {
self.advanced = true;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolConfig {
pub tool_id: String,
pub enabled: bool,
pub settings: serde_json::Value,
}
impl ToolConfig {
pub fn new(tool_id: impl Into<String>) -> Self {
Self {
tool_id: tool_id.into(),
enabled: false,
settings: serde_json::Value::Object(serde_json::Map::new()),
}
}
#[must_use]
pub fn get_str(&self, key: &str) -> Option<&str> {
self.settings.get(key).and_then(|v| v.as_str())
}
#[must_use]
pub fn get_bool(&self, key: &str) -> bool {
self.settings
.get(key)
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
#[must_use]
pub fn get_i64(&self, key: &str) -> Option<i64> {
self.settings.get(key).and_then(serde_json::Value::as_i64)
}
pub fn set(&mut self, key: impl Into<String>, value: serde_json::Value) {
if let serde_json::Value::Object(ref mut map) = self.settings {
map.insert(key.into(), value);
}
}
}
pub trait GameTool: Send + Sync {
fn tool_id(&self) -> &'static str;
fn display_name(&self) -> &'static str;
fn category(&self) -> ToolCategory;
fn description(&self) -> &'static str {
"Game-specific tool integration."
}
fn settings_schema(&self) -> Vec<ToolSettingSpec> {
Vec::new()
}
fn settings_schema_for(
&self,
_context: Option<&ToolGameContext>,
_config: &ToolConfig,
) -> Vec<ToolSettingSpec> {
self.settings_schema()
}
fn detect_available(&self) -> ToolAvailability;
fn env_vars(&self, config: &ToolConfig) -> SmallVec<[(String, String); 4]>;
fn env_vars_for(
&self,
_context: Option<&ToolGameContext>,
config: &ToolConfig,
) -> SmallVec<[(String, String); 4]> {
self.env_vars(config)
}
fn wrapper_command(&self, _config: &ToolConfig) -> Option<WrapperEntry> {
None
}
fn wine_dll_overrides(&self, _config: &ToolConfig) -> SmallVec<[String; 4]> {
SmallVec::new()
}
fn wine_dll_overrides_for(
&self,
_context: Option<&ToolGameContext>,
config: &ToolConfig,
) -> SmallVec<[String; 4]> {
self.wine_dll_overrides(config)
}
fn apply(&self, _game_dir: &Path, _config: &ToolConfig) -> Result<AppliedFiles> {
Ok(AppliedFiles::default())
}
fn apply_for(
&self,
game_dir: &Path,
_context: Option<&ToolGameContext>,
config: &ToolConfig,
) -> Result<AppliedFiles> {
self.apply(game_dir, config)
}
fn preview_apply_for(
&self,
_game_dir: &Path,
_context: Option<&ToolGameContext>,
_config: &ToolConfig,
) -> Result<ToolApplyPreview> {
Ok(ToolApplyPreview::default())
}
fn revert(&self, game_dir: &Path, applied: &AppliedFiles) -> Result<()> {
for rel in &applied.files {
let path = game_dir.join(rel);
if path.exists() {
std::fs::remove_file(&path)
.with_context(|| format!("failed to remove {}", path.display()))?;
}
}
Ok(())
}
fn generate_config(&self, _config: &ToolConfig) -> Option<GeneratedConfig> {
None
}
fn generate_config_for(
&self,
_context: Option<&ToolGameContext>,
config: &ToolConfig,
) -> Option<GeneratedConfig> {
self.generate_config(config)
}
fn default_config(&self) -> ToolConfig;
fn default_config_for(&self, _context: Option<&ToolGameContext>) -> ToolConfig {
self.default_config()
}
fn supports_releases(&self) -> bool {
false
}
fn list_releases(&self) -> ToolReleaseListFuture<'_> {
Box::pin(async {
anyhow::bail!("{} does not support release selection", self.display_name())
})
}
fn installable_release_assets(&self, _release: &ToolReleaseSummary) -> Vec<String> {
Vec::new()
}
fn install_release<'a>(
&'a self,
_game_id: &'a str,
_config: ToolConfig,
_tag: &'a str,
_asset: &'a str,
) -> ToolReleaseInstallFuture<'a> {
Box::pin(async {
anyhow::bail!(
"{} does not support release installation",
self.display_name()
)
})
}
fn install_release_from_path<'a>(
&'a self,
_game_id: &'a str,
_config: ToolConfig,
_tag: &'a str,
_asset: &'a str,
_path: PathBuf,
) -> ToolReleaseInstallFuture<'a> {
Box::pin(async {
anyhow::bail!("{} does not support release pinning", self.display_name())
})
}
}
static ALL_TOOLS: [&dyn GameTool; 6] = [
&mangohud::MANGOHUD,
&vkbasalt::VKBASALT,
&gamemode::GAMEMODE,
&reshade::RESHADE,
&optiscaler::OPTISCALER,
&proton::PROTON,
];
#[must_use]
pub fn all_tools() -> &'static [&'static dyn GameTool] {
&ALL_TOOLS
}
#[must_use]
pub fn resolve_tool(tool_id: &str) -> Option<&'static dyn GameTool> {
all_tools().iter().find(|t| t.tool_id() == tool_id).copied()
}
#[must_use]
pub fn tool_config_dir(game_id: &str) -> PathBuf {
modde_core::paths::modde_data_dir()
.join("tools")
.join(game_id)
}
pub(crate) fn which(binary: &str) -> Option<PathBuf> {
which::which(binary).ok()
}
#[cfg(test)]
mod tests {
use std::fs;
use std::io::Write;
use std::sync::OnceLock;
#[test]
fn plain_select_options_use_value_as_label() {
let spec = super::ToolSettingSpec::select("mode", "Mode", "", &["0", "1"]);
let super::ToolSettingKind::Select { options } = spec.kind else {
panic!("expected select");
};
assert_eq!(options[0].value, "0");
assert_eq!(options[0].label, "0");
assert_eq!(options[1].to_string(), "1");
}
#[test]
fn labeled_select_options_keep_distinct_value_and_label() {
let spec = super::ToolSettingSpec::labeled_select(
"mode",
"Mode",
"",
&[("0", "0 - Conservative"), ("1", "1 - Aggressive")],
);
let super::ToolSettingKind::Select { options } = spec.kind else {
panic!("expected select");
};
assert_eq!(options[0].value, "0");
assert_eq!(options[0].label, "0 - Conservative");
assert_eq!(options[1].to_string(), "1 - Aggressive");
}
#[test]
fn setting_specs_are_not_advanced_by_default() {
let plain = super::ToolSettingSpec::text("mode", "Mode", "");
let advanced = plain.clone().advanced();
assert!(!plain.advanced);
assert!(advanced.advanced);
}
#[test]
fn every_tool_has_ui_metadata_and_serializable_defaults() {
for tool in super::all_tools() {
assert!(!tool.description().trim().is_empty(), "{}", tool.tool_id());
assert!(
!tool.settings_schema().is_empty(),
"{} should expose UI settings",
tool.tool_id()
);
serde_json::to_string(&tool.default_config()).expect("default config serializes");
}
}
#[test]
fn proton_is_registered() {
let tool = super::resolve_tool("proton").expect("proton tool should resolve");
assert_eq!(tool.display_name(), "Proton");
}
#[test]
fn proton_launch_integration_is_exposed() {
let tool = super::resolve_tool("proton").expect("proton tool should resolve");
let mut config = tool.default_config();
config.enabled = true;
config.set("extra_env", serde_json::json!("DXVK_ASYNC=1\nPROTON_LOG=1"));
config.set("proton_enable_hdr", serde_json::json!(true));
config.set("radv_perftest_rt", serde_json::json!(true));
config.set("dll_override_mode", serde_json::json!("forced"));
config.set("forced_dll_overrides", serde_json::json!("dxgi,winmm"));
let env = tool.env_vars(&config);
assert!(
env.iter()
.any(|(key, value)| key == "DXVK_ASYNC" && value == "1")
);
assert!(
env.iter()
.any(|(key, value)| key == "PROTON_LOG" && value == "1")
);
assert!(
env.iter()
.any(|(key, value)| key == "PROTON_ENABLE_HDR" && value == "1")
);
assert!(
env.iter()
.any(|(key, value)| key == "RADV_PERFTEST" && value == "rt,emulate_rt")
);
let overrides = tool.wine_dll_overrides(&config);
assert!(overrides.iter().any(|value| value == "dxgi"));
assert!(overrides.iter().any(|value| value == "winmm"));
}
#[test]
fn mangohud_exposes_goverlay_config_keys() {
let tool = super::resolve_tool("mangohud").expect("mangohud tool should resolve");
let specs = tool.settings_schema();
for key in [
"custom_text_center",
"background_alpha",
"fps_limit_method",
"gpu_junction_temp",
"winesync",
"media_player",
"upload_logs",
"display_server",
] {
assert!(
specs.iter().any(|spec| spec.key == key),
"missing MangoHud key {key}"
);
}
let mut config = tool.default_config();
config.set("_game_id", serde_json::json!("skyrim-se"));
config.set("custom_text_center", serde_json::json!("modde"));
config.set("gpu_junction_temp", serde_json::json!(true));
let generated = tool.generate_config(&config).expect("generated config");
assert!(generated.content.contains("custom_text_center=modde"));
assert!(generated.content.contains("gpu_junction_temp"));
}
#[test]
fn optiscaler_release_provider_filters_installable_assets() {
let tool = super::resolve_tool("optiscaler").expect("optiscaler tool should resolve");
assert!(tool.supports_releases());
let release = super::ToolReleaseSummary {
tag: "v1".to_string(),
name: None,
published_at: None,
assets: vec![
super::ToolReleaseAsset {
name: "OptiScaler.7z".to_string(),
download_url: "https://example.test/OptiScaler.7z".to_string(),
size: 1,
},
super::ToolReleaseAsset {
name: "notes.txt".to_string(),
download_url: "https://example.test/notes.txt".to_string(),
size: 1,
},
],
};
assert_eq!(
tool.installable_release_assets(&release),
vec!["OptiScaler.7z".to_string()]
);
}
#[test]
fn tool_context_derives_executable_dir_from_game_plugin() {
let root = std::path::PathBuf::from("/tmp/modde-test-cyberpunk");
let context =
super::ToolGameContext::from_parts("cyberpunk2077", "Cyberpunk 2077", Some(root), None);
assert!(
context
.executable_dir
.expect("executable dir")
.ends_with("bin/x64")
);
}
#[test]
fn optiscaler_restore_commands_use_supplied_executable_dir() {
let tmp = tempfile::tempdir().expect("tempdir");
let game_dir = tmp.path().join("game");
let staging = tmp.path().join("staging");
let mod_bin = staging.join("mods/test/bin/x64");
fs::create_dir_all(&mod_bin).expect("mod bin");
fs::write(mod_bin.join("winmm.dll"), b"dll").expect("dll");
let exe_dir = game_dir.join("Binaries/Win64");
let commands = super::optiscaler::fgmod_restore_commands_for_executable_dir(
&game_dir, &staging, &exe_dir,
);
assert_eq!(commands.len(), 1);
assert!(commands[0].1.ends_with("Binaries/Win64/winmm.dll"));
}
#[test]
fn protonup_rs_install_args_are_non_interactive() {
let args = super::proton::protonup_rs_install_args("GE-Proton10-34", "steam");
assert_eq!(
args,
vec![
"--tool",
"GEProton",
"--version",
"GE-Proton10-34",
"--for",
"steam"
]
);
}
#[test]
fn ge_proton_release_filter_accepts_real_tags() {
assert!(super::proton::is_ge_proton_version("GE-Proton10-34"));
assert!(super::proton::is_ge_proton_version("Proton-GE-Proton8-32"));
assert!(!super::proton::is_ge_proton_version(""));
assert!(!super::proton::is_ge_proton_version("notes"));
assert!(!super::proton::is_ge_proton_version("Proton-Experimental"));
}
#[test]
fn proton_version_options_preserve_catalog_order_and_dedup() {
let options = super::proton::merge_proton_version_options(
vec![
"GE-Proton10-34".to_string(),
"GE-Proton10-33".to_string(),
"GE-Proton10-34".to_string(),
],
vec!["GE-Proton10-32".to_string(), "GE-Proton10-33".to_string()],
);
assert_eq!(
options,
vec![
"latest".to_string(),
"GE-Proton10-34".to_string(),
"GE-Proton10-33".to_string(),
"GE-Proton10-32".to_string(),
]
);
}
fn ensure_test_data_dir() -> std::path::PathBuf {
static DATA_DIR: OnceLock<std::path::PathBuf> = OnceLock::new();
DATA_DIR
.get_or_init(|| {
let default_dir = modde_core::paths::data_dir().join("modde");
if modde_core::paths::modde_data_dir() != default_dir {
return modde_core::paths::modde_data_dir();
}
let tempdir = tempfile::TempDir::new().expect("create tempdir");
let data_dir = tempdir.path().join("data");
std::fs::create_dir_all(&data_dir).expect("create data dir");
modde_core::paths::set_data_dir(data_dir);
std::mem::forget(tempdir);
modde_core::paths::modde_data_dir()
})
.clone()
}
#[tokio::test]
async fn optiscaler_install_release_from_path_extracts_local_archive() {
let _ = ensure_test_data_dir();
let tempdir = tempfile::TempDir::new().expect("create tempdir");
let archive_path = tempdir.path().join("OptiScaler.zip");
let file = std::fs::File::create(&archive_path).expect("create archive");
let mut zip = zip::ZipWriter::new(file);
zip.start_file("OptiScaler.dll", zip::write::FileOptions::<()>::default())
.expect("start file");
zip.write_all(b"dll-bytes").expect("write dll");
zip.finish().expect("finish archive");
let tool = super::resolve_tool("optiscaler").expect("optiscaler tool should resolve");
let config = tool.default_config();
let updated = tool
.install_release_from_path(
"stellar-blade",
config,
"v1.0",
"OptiScaler.zip",
archive_path,
)
.await
.expect("install from path");
assert_eq!(updated.get_str("release_tag"), Some("official:v1.0"));
assert_eq!(updated.get_str("release_asset"), Some("OptiScaler.zip"));
assert!(
super::optiscaler::cached_release_dir("official:v1.0")
.join("OptiScaler.dll")
.exists()
);
}
}