Skip to main content

modde_games/tools/
mod.rs

1//! Per-game tool/overlay management.
2//!
3//! Each tool (`MangoHud`, vkBasalt, `GameMode`, `ReShade`, `OptiScaler`, Proton) implements the
4//! [`GameTool`] trait. Tools are registered via [`all_tools`] and resolved by ID
5//! via [`resolve_tool`], following the same pattern as [`crate::resolve_game_plugin`].
6
7pub mod gamemode;
8pub mod mangohud;
9pub mod optiscaler;
10pub mod proton;
11pub mod release;
12pub mod reshade;
13pub mod vkbasalt;
14
15use std::future::Future;
16use std::path::{Path, PathBuf};
17use std::pin::Pin;
18
19use anyhow::{Context, Result};
20use serde::{Deserialize, Serialize};
21use smallvec::SmallVec;
22
23use crate::detection::{DetectedGame, LauncherSource};
24
25// ── Types ──────────────────────────────────────────────────────────────────
26
27/// Category of gaming tool.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum ToolCategory {
31    /// Performance HUD overlay (`MangoHud`).
32    Overlay,
33    /// Post-processing / shader injection (vkBasalt, `ReShade`).
34    PostProcess,
35    /// System performance tuning (`GameMode`).
36    Performance,
37    /// Upscaling / frame generation (`OptiScaler`).
38    Upscaler,
39}
40
41impl std::fmt::Display for ToolCategory {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            Self::Overlay => write!(f, "Overlay"),
45            Self::PostProcess => write!(f, "Post-Processing"),
46            Self::Performance => write!(f, "Performance"),
47            Self::Upscaler => write!(f, "Upscaler"),
48        }
49    }
50}
51
52/// Whether a tool is available on the system.
53#[derive(Debug, Clone)]
54pub enum ToolAvailability {
55    Available { version: Option<String> },
56    NotInstalled { install_hint: String },
57}
58
59impl ToolAvailability {
60    #[must_use]
61    pub fn is_available(&self) -> bool {
62        matches!(self, Self::Available { .. })
63    }
64}
65
66/// A wrapper binary to chain before the game executable.
67#[derive(Debug, Clone)]
68pub struct WrapperEntry {
69    pub exe: String,
70    pub args: String,
71}
72
73/// Files applied by a tool to a game directory (for revert tracking).
74#[derive(Debug, Clone, Default)]
75pub struct AppliedFiles {
76    /// Paths relative to the game directory.
77    pub files: Vec<PathBuf>,
78}
79
80/// Non-mutating preview of what a tool apply would write.
81#[derive(Debug, Clone, Default, PartialEq, Eq)]
82pub struct ToolApplyPreview {
83    /// Files the apply operation would manage, relative to the game directory.
84    pub planned_files: Vec<PathBuf>,
85    /// Planned files whose destination is missing or byte-different.
86    pub changed_files: Vec<PathBuf>,
87    /// Planned files whose destination already matches the expected bytes.
88    pub unchanged_files: Vec<PathBuf>,
89    /// Required inputs that are unavailable, such as a missing source DLL.
90    pub missing_inputs: Vec<String>,
91}
92
93impl ToolApplyPreview {
94    #[must_use]
95    pub fn has_changes(&self) -> bool {
96        !self.changed_files.is_empty()
97    }
98
99    pub fn record_file(&mut self, rel_path: PathBuf, changed: bool) {
100        self.planned_files.push(rel_path.clone());
101        if changed {
102            self.changed_files.push(rel_path);
103        } else {
104            self.unchanged_files.push(rel_path);
105        }
106    }
107}
108
109/// A config file generated by a tool.
110#[derive(Debug, Clone)]
111pub struct GeneratedConfig {
112    /// Absolute path where the config should be written.
113    pub path: PathBuf,
114    /// File content.
115    pub content: String,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct ToolReleaseSummary {
120    pub tag: String,
121    pub name: Option<String>,
122    pub published_at: Option<String>,
123    pub assets: Vec<ToolReleaseAsset>,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct ToolReleaseAsset {
128    pub name: String,
129    pub download_url: String,
130    pub size: u64,
131}
132
133pub type ToolReleaseListFuture<'a> =
134    Pin<Box<dyn Future<Output = Result<Vec<ToolReleaseSummary>>> + Send + 'a>>;
135
136pub type ToolReleaseInstallFuture<'a> =
137    Pin<Box<dyn Future<Output = Result<ToolConfig>> + Send + 'a>>;
138
139/// Game metadata available to per-game tools.
140#[derive(Debug, Clone)]
141pub struct ToolGameContext {
142    pub game_id: String,
143    pub display_name: String,
144    pub install_path: Option<PathBuf>,
145    pub launcher_source: Option<LauncherSource>,
146    pub steam_app_id: Option<String>,
147    pub executable_dir: Option<PathBuf>,
148}
149
150impl ToolGameContext {
151    #[must_use]
152    pub fn from_parts(
153        game_id: &str,
154        display_name: impl Into<String>,
155        install_path: Option<PathBuf>,
156        detected: Option<&DetectedGame>,
157    ) -> Self {
158        let plugin = crate::resolve_game_plugin(game_id);
159        let executable_dir = install_path
160            .as_deref()
161            .and_then(|path| plugin.map(|plugin| plugin.executable_dir(path)));
162        let launcher_source = detected.map(|game| game.source.clone());
163        let steam_app_id = launcher_source.as_ref().and_then(|source| match source {
164            LauncherSource::Steam { app_id, .. } => Some(app_id.clone()),
165            LauncherSource::HeroicGog { .. }
166            | LauncherSource::HeroicEpic { .. }
167            | LauncherSource::HeroicSideload { .. } => None,
168        });
169
170        Self {
171            game_id: game_id.to_string(),
172            display_name: display_name.into(),
173            install_path,
174            launcher_source,
175            steam_app_id,
176            executable_dir,
177        }
178    }
179
180    #[must_use]
181    pub fn launcher_label(&self) -> String {
182        self.launcher_source
183            .as_ref()
184            .map(ToString::to_string)
185            .unwrap_or_else(|| "Not detected".to_string())
186    }
187}
188
189/// Declarative field type for rendering per-tool settings in the UI.
190#[derive(Debug, Clone, PartialEq)]
191pub enum ToolSettingKind {
192    Bool,
193    TriStateBool,
194    Text,
195    Path,
196    Select { options: Vec<ToolSelectOption> },
197    Number { min: f64, max: f64, step: f64 },
198    ReadOnly,
199}
200
201/// One selectable option for a tool setting.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct ToolSelectOption {
204    pub value: String,
205    pub label: String,
206}
207
208impl ToolSelectOption {
209    #[must_use]
210    pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
211        Self {
212            value: value.into(),
213            label: label.into(),
214        }
215    }
216
217    #[must_use]
218    pub fn value_label(value: impl Into<String>) -> Self {
219        let value = value.into();
220        Self {
221            label: value.clone(),
222            value,
223        }
224    }
225}
226
227impl std::fmt::Display for ToolSelectOption {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        f.write_str(&self.label)
230    }
231}
232
233/// One user-facing setting exposed by a [`GameTool`].
234#[derive(Debug, Clone, PartialEq)]
235pub struct ToolSettingSpec {
236    pub key: &'static str,
237    pub label: &'static str,
238    pub description: &'static str,
239    pub section: &'static str,
240    pub advanced: bool,
241    pub kind: ToolSettingKind,
242}
243
244impl ToolSettingSpec {
245    const DEFAULT_SECTION: &'static str = "General";
246
247    #[must_use]
248    pub fn bool(key: &'static str, label: &'static str, description: &'static str) -> Self {
249        Self {
250            key,
251            label,
252            description,
253            section: Self::DEFAULT_SECTION,
254            advanced: false,
255            kind: ToolSettingKind::Bool,
256        }
257    }
258
259    #[must_use]
260    pub fn tri_state_bool(
261        key: &'static str,
262        label: &'static str,
263        description: &'static str,
264    ) -> Self {
265        Self {
266            key,
267            label,
268            description,
269            section: Self::DEFAULT_SECTION,
270            advanced: false,
271            kind: ToolSettingKind::TriStateBool,
272        }
273    }
274
275    #[must_use]
276    pub fn text(key: &'static str, label: &'static str, description: &'static str) -> Self {
277        Self {
278            key,
279            label,
280            description,
281            section: Self::DEFAULT_SECTION,
282            advanced: false,
283            kind: ToolSettingKind::Text,
284        }
285    }
286
287    #[must_use]
288    pub fn path(key: &'static str, label: &'static str, description: &'static str) -> Self {
289        Self {
290            key,
291            label,
292            description,
293            section: Self::DEFAULT_SECTION,
294            advanced: false,
295            kind: ToolSettingKind::Path,
296        }
297    }
298
299    #[must_use]
300    pub fn select(
301        key: &'static str,
302        label: &'static str,
303        description: &'static str,
304        options: &[&str],
305    ) -> Self {
306        Self {
307            key,
308            label,
309            description,
310            section: Self::DEFAULT_SECTION,
311            advanced: false,
312            kind: ToolSettingKind::Select {
313                options: options
314                    .iter()
315                    .map(|option| ToolSelectOption::value_label(*option))
316                    .collect(),
317            },
318        }
319    }
320
321    #[must_use]
322    pub fn labeled_select(
323        key: &'static str,
324        label: &'static str,
325        description: &'static str,
326        options: &[(&str, &str)],
327    ) -> Self {
328        Self {
329            key,
330            label,
331            description,
332            section: Self::DEFAULT_SECTION,
333            advanced: false,
334            kind: ToolSettingKind::Select {
335                options: options
336                    .iter()
337                    .map(|(value, label)| ToolSelectOption::new(*value, *label))
338                    .collect(),
339            },
340        }
341    }
342
343    #[must_use]
344    pub fn number(
345        key: &'static str,
346        label: &'static str,
347        description: &'static str,
348        min: f64,
349        max: f64,
350        step: f64,
351    ) -> Self {
352        Self {
353            key,
354            label,
355            description,
356            section: Self::DEFAULT_SECTION,
357            advanced: false,
358            kind: ToolSettingKind::Number { min, max, step },
359        }
360    }
361
362    #[must_use]
363    pub fn read_only(key: &'static str, label: &'static str, description: &'static str) -> Self {
364        Self {
365            key,
366            label,
367            description,
368            section: Self::DEFAULT_SECTION,
369            advanced: false,
370            kind: ToolSettingKind::ReadOnly,
371        }
372    }
373
374    #[must_use]
375    pub fn section(mut self, section: &'static str) -> Self {
376        self.section = section;
377        self
378    }
379
380    #[must_use]
381    pub fn advanced(mut self) -> Self {
382        self.advanced = true;
383        self
384    }
385}
386
387/// Per-game tool configuration stored in the database.
388#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct ToolConfig {
390    pub tool_id: String,
391    pub enabled: bool,
392    pub settings: serde_json::Value,
393}
394
395impl ToolConfig {
396    pub fn new(tool_id: impl Into<String>) -> Self {
397        Self {
398            tool_id: tool_id.into(),
399            enabled: false,
400            settings: serde_json::Value::Object(serde_json::Map::new()),
401        }
402    }
403
404    /// Get a string setting.
405    #[must_use]
406    pub fn get_str(&self, key: &str) -> Option<&str> {
407        self.settings.get(key).and_then(|v| v.as_str())
408    }
409
410    /// Get a bool setting, defaulting to `false`.
411    #[must_use]
412    pub fn get_bool(&self, key: &str) -> bool {
413        self.settings
414            .get(key)
415            .and_then(serde_json::Value::as_bool)
416            .unwrap_or(false)
417    }
418
419    /// Get an integer setting.
420    #[must_use]
421    pub fn get_i64(&self, key: &str) -> Option<i64> {
422        self.settings.get(key).and_then(serde_json::Value::as_i64)
423    }
424
425    /// Set a setting value.
426    pub fn set(&mut self, key: impl Into<String>, value: serde_json::Value) {
427        if let serde_json::Value::Object(ref mut map) = self.settings {
428            map.insert(key.into(), value);
429        }
430    }
431}
432
433// ── Trait ───────────────────────────────────────────────────────────────────
434
435/// A gaming tool/overlay that can be managed per-game.
436pub trait GameTool: Send + Sync {
437    /// Unique identifier (e.g. `"mangohud"`, `"gamemode"`).
438    fn tool_id(&self) -> &'static str;
439
440    /// Human-readable display name.
441    fn display_name(&self) -> &'static str;
442
443    /// Tool category.
444    fn category(&self) -> ToolCategory;
445
446    /// Short user-facing description for the tool tab.
447    fn description(&self) -> &'static str {
448        "Game-specific tool integration."
449    }
450
451    /// Declarative settings rendered by the UI.
452    fn settings_schema(&self) -> Vec<ToolSettingSpec> {
453        Vec::new()
454    }
455
456    /// Declarative settings rendered by the UI with game context.
457    fn settings_schema_for(
458        &self,
459        _context: Option<&ToolGameContext>,
460        _config: &ToolConfig,
461    ) -> Vec<ToolSettingSpec> {
462        self.settings_schema()
463    }
464
465    /// Check if the tool is installed on the system.
466    fn detect_available(&self) -> ToolAvailability;
467
468    /// Environment variables to set when launching the game.
469    fn env_vars(&self, config: &ToolConfig) -> SmallVec<[(String, String); 4]>;
470
471    /// Environment variables to set when launching the game with game context.
472    fn env_vars_for(
473        &self,
474        _context: Option<&ToolGameContext>,
475        config: &ToolConfig,
476    ) -> SmallVec<[(String, String); 4]> {
477        self.env_vars(config)
478    }
479
480    /// Wrapper command to chain before the game (e.g. `gamemoderun`).
481    fn wrapper_command(&self, _config: &ToolConfig) -> Option<WrapperEntry> {
482        None
483    }
484
485    /// Wine DLL overrides needed (e.g. `"dxgi"`, `"version"`).
486    fn wine_dll_overrides(&self, _config: &ToolConfig) -> SmallVec<[String; 4]> {
487        SmallVec::new()
488    }
489
490    /// Wine DLL overrides needed with game context.
491    fn wine_dll_overrides_for(
492        &self,
493        _context: Option<&ToolGameContext>,
494        config: &ToolConfig,
495    ) -> SmallVec<[String; 4]> {
496        self.wine_dll_overrides(config)
497    }
498
499    /// Apply/install files into the game directory (DLLs, shaders, etc.).
500    /// Returns a manifest of files written for revert tracking.
501    fn apply(&self, _game_dir: &Path, _config: &ToolConfig) -> Result<AppliedFiles> {
502        Ok(AppliedFiles::default())
503    }
504
505    /// Apply/install files with game context.
506    fn apply_for(
507        &self,
508        game_dir: &Path,
509        _context: Option<&ToolGameContext>,
510        config: &ToolConfig,
511    ) -> Result<AppliedFiles> {
512        self.apply(game_dir, config)
513    }
514
515    /// Preview an apply without mutating the game directory.
516    fn preview_apply_for(
517        &self,
518        _game_dir: &Path,
519        _context: Option<&ToolGameContext>,
520        _config: &ToolConfig,
521    ) -> Result<ToolApplyPreview> {
522        Ok(ToolApplyPreview::default())
523    }
524
525    /// Revert files previously applied by [`Self::apply`].
526    fn revert(&self, game_dir: &Path, applied: &AppliedFiles) -> Result<()> {
527        for rel in &applied.files {
528            let path = game_dir.join(rel);
529            if path.exists() {
530                std::fs::remove_file(&path)
531                    .with_context(|| format!("failed to remove {}", path.display()))?;
532            }
533        }
534        Ok(())
535    }
536
537    /// Generate a per-game config file (e.g. MangoHud.conf).
538    fn generate_config(&self, _config: &ToolConfig) -> Option<GeneratedConfig> {
539        None
540    }
541
542    /// Generate a per-game config file with game context.
543    fn generate_config_for(
544        &self,
545        _context: Option<&ToolGameContext>,
546        config: &ToolConfig,
547    ) -> Option<GeneratedConfig> {
548        self.generate_config(config)
549    }
550
551    /// Default configuration for a fresh enable.
552    fn default_config(&self) -> ToolConfig;
553
554    /// Default configuration for a fresh enable with game context.
555    fn default_config_for(&self, _context: Option<&ToolGameContext>) -> ToolConfig {
556        self.default_config()
557    }
558
559    /// Whether this tool supports explicit per-game release selection.
560    fn supports_releases(&self) -> bool {
561        false
562    }
563
564    /// List upstream releases for release-backed tools.
565    fn list_releases(&self) -> ToolReleaseListFuture<'_> {
566        Box::pin(async {
567            anyhow::bail!("{} does not support release selection", self.display_name())
568        })
569    }
570
571    /// Return installable asset names for a release.
572    fn installable_release_assets(&self, _release: &ToolReleaseSummary) -> Vec<String> {
573        Vec::new()
574    }
575
576    /// Install a selected release asset and return the updated per-game config.
577    fn install_release<'a>(
578        &'a self,
579        _game_id: &'a str,
580        _config: ToolConfig,
581        _tag: &'a str,
582        _asset: &'a str,
583    ) -> ToolReleaseInstallFuture<'a> {
584        Box::pin(async {
585            anyhow::bail!(
586                "{} does not support release installation",
587                self.display_name()
588            )
589        })
590    }
591
592    /// Install a selected release asset from a local path and return the updated per-game config.
593    fn install_release_from_path<'a>(
594        &'a self,
595        _game_id: &'a str,
596        _config: ToolConfig,
597        _tag: &'a str,
598        _asset: &'a str,
599        _path: PathBuf,
600    ) -> ToolReleaseInstallFuture<'a> {
601        Box::pin(async {
602            anyhow::bail!("{} does not support release pinning", self.display_name())
603        })
604    }
605}
606
607// ── Registry ───────────────────────────────────────────────────────────────
608
609/// All registered tools.
610static ALL_TOOLS: [&dyn GameTool; 6] = [
611    &mangohud::MANGOHUD,
612    &vkbasalt::VKBASALT,
613    &gamemode::GAMEMODE,
614    &reshade::RESHADE,
615    &optiscaler::OPTISCALER,
616    &proton::PROTON,
617];
618
619#[must_use]
620pub fn all_tools() -> &'static [&'static dyn GameTool] {
621    &ALL_TOOLS
622}
623
624/// Resolve a tool by its ID string.
625#[must_use]
626pub fn resolve_tool(tool_id: &str) -> Option<&'static dyn GameTool> {
627    all_tools().iter().find(|t| t.tool_id() == tool_id).copied()
628}
629
630// ── Helpers ────────────────────────────────────────────────────────────────
631
632/// Directory where modde stores per-game tool configs.
633#[must_use]
634pub fn tool_config_dir(game_id: &str) -> PathBuf {
635    modde_core::paths::modde_data_dir()
636        .join("tools")
637        .join(game_id)
638}
639
640/// Check if a binary is on `$PATH`.
641///
642/// Uses the `which` crate for cross-platform support (handles Windows
643/// `%PATHEXT%` extensions like `.exe`, `.cmd`, `.bat` automatically).
644pub(crate) fn which(binary: &str) -> Option<PathBuf> {
645    which::which(binary).ok()
646}
647
648#[cfg(test)]
649mod tests {
650    use std::fs;
651    use std::io::Write;
652    use std::sync::OnceLock;
653
654    #[test]
655    fn plain_select_options_use_value_as_label() {
656        let spec = super::ToolSettingSpec::select("mode", "Mode", "", &["0", "1"]);
657
658        let super::ToolSettingKind::Select { options } = spec.kind else {
659            panic!("expected select");
660        };
661        assert_eq!(options[0].value, "0");
662        assert_eq!(options[0].label, "0");
663        assert_eq!(options[1].to_string(), "1");
664    }
665
666    #[test]
667    fn labeled_select_options_keep_distinct_value_and_label() {
668        let spec = super::ToolSettingSpec::labeled_select(
669            "mode",
670            "Mode",
671            "",
672            &[("0", "0 - Conservative"), ("1", "1 - Aggressive")],
673        );
674
675        let super::ToolSettingKind::Select { options } = spec.kind else {
676            panic!("expected select");
677        };
678        assert_eq!(options[0].value, "0");
679        assert_eq!(options[0].label, "0 - Conservative");
680        assert_eq!(options[1].to_string(), "1 - Aggressive");
681    }
682
683    #[test]
684    fn setting_specs_are_not_advanced_by_default() {
685        let plain = super::ToolSettingSpec::text("mode", "Mode", "");
686        let advanced = plain.clone().advanced();
687
688        assert!(!plain.advanced);
689        assert!(advanced.advanced);
690    }
691
692    #[test]
693    fn every_tool_has_ui_metadata_and_serializable_defaults() {
694        for tool in super::all_tools() {
695            assert!(!tool.description().trim().is_empty(), "{}", tool.tool_id());
696            assert!(
697                !tool.settings_schema().is_empty(),
698                "{} should expose UI settings",
699                tool.tool_id()
700            );
701            serde_json::to_string(&tool.default_config()).expect("default config serializes");
702        }
703    }
704
705    #[test]
706    fn proton_is_registered() {
707        let tool = super::resolve_tool("proton").expect("proton tool should resolve");
708        assert_eq!(tool.display_name(), "Proton");
709    }
710
711    #[test]
712    fn proton_launch_integration_is_exposed() {
713        let tool = super::resolve_tool("proton").expect("proton tool should resolve");
714        let mut config = tool.default_config();
715        config.enabled = true;
716        config.set("extra_env", serde_json::json!("DXVK_ASYNC=1\nPROTON_LOG=1"));
717        config.set("proton_enable_hdr", serde_json::json!(true));
718        config.set("radv_perftest_rt", serde_json::json!(true));
719        config.set("dll_override_mode", serde_json::json!("forced"));
720        config.set("forced_dll_overrides", serde_json::json!("dxgi,winmm"));
721
722        let env = tool.env_vars(&config);
723        assert!(
724            env.iter()
725                .any(|(key, value)| key == "DXVK_ASYNC" && value == "1")
726        );
727        assert!(
728            env.iter()
729                .any(|(key, value)| key == "PROTON_LOG" && value == "1")
730        );
731        assert!(
732            env.iter()
733                .any(|(key, value)| key == "PROTON_ENABLE_HDR" && value == "1")
734        );
735        assert!(
736            env.iter()
737                .any(|(key, value)| key == "RADV_PERFTEST" && value == "rt,emulate_rt")
738        );
739
740        let overrides = tool.wine_dll_overrides(&config);
741        assert!(overrides.iter().any(|value| value == "dxgi"));
742        assert!(overrides.iter().any(|value| value == "winmm"));
743    }
744
745    #[test]
746    fn mangohud_exposes_goverlay_config_keys() {
747        let tool = super::resolve_tool("mangohud").expect("mangohud tool should resolve");
748        let specs = tool.settings_schema();
749        for key in [
750            "custom_text_center",
751            "background_alpha",
752            "fps_limit_method",
753            "gpu_junction_temp",
754            "winesync",
755            "media_player",
756            "upload_logs",
757            "display_server",
758        ] {
759            assert!(
760                specs.iter().any(|spec| spec.key == key),
761                "missing MangoHud key {key}"
762            );
763        }
764
765        let mut config = tool.default_config();
766        config.set("_game_id", serde_json::json!("skyrim-se"));
767        config.set("custom_text_center", serde_json::json!("modde"));
768        config.set("gpu_junction_temp", serde_json::json!(true));
769        let generated = tool.generate_config(&config).expect("generated config");
770        assert!(generated.content.contains("custom_text_center=modde"));
771        assert!(generated.content.contains("gpu_junction_temp"));
772    }
773
774    #[test]
775    fn optiscaler_release_provider_filters_installable_assets() {
776        let tool = super::resolve_tool("optiscaler").expect("optiscaler tool should resolve");
777        assert!(tool.supports_releases());
778        let release = super::ToolReleaseSummary {
779            tag: "v1".to_string(),
780            name: None,
781            published_at: None,
782            assets: vec![
783                super::ToolReleaseAsset {
784                    name: "OptiScaler.7z".to_string(),
785                    download_url: "https://example.test/OptiScaler.7z".to_string(),
786                    size: 1,
787                },
788                super::ToolReleaseAsset {
789                    name: "notes.txt".to_string(),
790                    download_url: "https://example.test/notes.txt".to_string(),
791                    size: 1,
792                },
793            ],
794        };
795        assert_eq!(
796            tool.installable_release_assets(&release),
797            vec!["OptiScaler.7z".to_string()]
798        );
799    }
800
801    #[test]
802    fn tool_context_derives_executable_dir_from_game_plugin() {
803        let root = std::path::PathBuf::from("/tmp/modde-test-cyberpunk");
804        let context =
805            super::ToolGameContext::from_parts("cyberpunk2077", "Cyberpunk 2077", Some(root), None);
806        assert!(
807            context
808                .executable_dir
809                .expect("executable dir")
810                .ends_with("bin/x64")
811        );
812    }
813
814    #[test]
815    fn optiscaler_restore_commands_use_supplied_executable_dir() {
816        let tmp = tempfile::tempdir().expect("tempdir");
817        let game_dir = tmp.path().join("game");
818        let staging = tmp.path().join("staging");
819        let mod_bin = staging.join("mods/test/bin/x64");
820        fs::create_dir_all(&mod_bin).expect("mod bin");
821        fs::write(mod_bin.join("winmm.dll"), b"dll").expect("dll");
822        let exe_dir = game_dir.join("Binaries/Win64");
823        let commands = super::optiscaler::fgmod_restore_commands_for_executable_dir(
824            &game_dir, &staging, &exe_dir,
825        );
826        assert_eq!(commands.len(), 1);
827        assert!(commands[0].1.ends_with("Binaries/Win64/winmm.dll"));
828    }
829
830    #[test]
831    fn protonup_rs_install_args_are_non_interactive() {
832        let args = super::proton::protonup_rs_install_args("GE-Proton10-34", "steam");
833        assert_eq!(
834            args,
835            vec![
836                "--tool",
837                "GEProton",
838                "--version",
839                "GE-Proton10-34",
840                "--for",
841                "steam"
842            ]
843        );
844    }
845
846    #[test]
847    fn ge_proton_release_filter_accepts_real_tags() {
848        assert!(super::proton::is_ge_proton_version("GE-Proton10-34"));
849        assert!(super::proton::is_ge_proton_version("Proton-GE-Proton8-32"));
850        assert!(!super::proton::is_ge_proton_version(""));
851        assert!(!super::proton::is_ge_proton_version("notes"));
852        assert!(!super::proton::is_ge_proton_version("Proton-Experimental"));
853    }
854
855    #[test]
856    fn proton_version_options_preserve_catalog_order_and_dedup() {
857        let options = super::proton::merge_proton_version_options(
858            vec![
859                "GE-Proton10-34".to_string(),
860                "GE-Proton10-33".to_string(),
861                "GE-Proton10-34".to_string(),
862            ],
863            vec!["GE-Proton10-32".to_string(), "GE-Proton10-33".to_string()],
864        );
865        assert_eq!(
866            options,
867            vec![
868                "latest".to_string(),
869                "GE-Proton10-34".to_string(),
870                "GE-Proton10-33".to_string(),
871                "GE-Proton10-32".to_string(),
872            ]
873        );
874    }
875
876    fn ensure_test_data_dir() -> std::path::PathBuf {
877        static DATA_DIR: OnceLock<std::path::PathBuf> = OnceLock::new();
878        DATA_DIR
879            .get_or_init(|| {
880                let default_dir = modde_core::paths::data_dir().join("modde");
881                if modde_core::paths::modde_data_dir() != default_dir {
882                    return modde_core::paths::modde_data_dir();
883                }
884
885                let tempdir = tempfile::TempDir::new().expect("create tempdir");
886                let data_dir = tempdir.path().join("data");
887                std::fs::create_dir_all(&data_dir).expect("create data dir");
888                // set_data_dir is idempotent (first caller wins); return whatever
889                // actually became the override — ours, or a parallel test's.
890                modde_core::paths::set_data_dir(data_dir);
891                std::mem::forget(tempdir);
892                modde_core::paths::modde_data_dir()
893            })
894            .clone()
895    }
896
897    #[tokio::test]
898    async fn optiscaler_install_release_from_path_extracts_local_archive() {
899        let _ = ensure_test_data_dir();
900        let tempdir = tempfile::TempDir::new().expect("create tempdir");
901        let archive_path = tempdir.path().join("OptiScaler.zip");
902        let file = std::fs::File::create(&archive_path).expect("create archive");
903        let mut zip = zip::ZipWriter::new(file);
904        zip.start_file("OptiScaler.dll", zip::write::FileOptions::<()>::default())
905            .expect("start file");
906        zip.write_all(b"dll-bytes").expect("write dll");
907        zip.finish().expect("finish archive");
908
909        let tool = super::resolve_tool("optiscaler").expect("optiscaler tool should resolve");
910        let config = tool.default_config();
911        let updated = tool
912            .install_release_from_path(
913                "stellar-blade",
914                config,
915                "v1.0",
916                "OptiScaler.zip",
917                archive_path,
918            )
919            .await
920            .expect("install from path");
921
922        assert_eq!(updated.get_str("release_tag"), Some("official:v1.0"));
923        assert_eq!(updated.get_str("release_asset"), Some("OptiScaler.zip"));
924        assert!(
925            super::optiscaler::cached_release_dir("official:v1.0")
926                .join("OptiScaler.dll")
927                .exists()
928        );
929    }
930}