Skip to main content

modde_games/tools/
optiscaler.rs

1//! `OptiScaler` — DLSS/FSR/XeSS upscaling and frame generation replacement.
2//!
3//! `OptiScaler` hooks into a game via proxy DLLs (typically `dxgi.dll` or
4//! `winmm.dll`). Some games need additional DLLs like `nvngx.dll`.
5//!
6//! This implementation also subsumes the old fgmod DLL restoration logic:
7//! when fgmod deletes certain DLLs at launch time, the launch wrapper
8//! restores them.
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::io::Read;
12use std::path::{Path, PathBuf};
13use std::process::{Command, Stdio};
14
15use anyhow::{Context, Result};
16use futures::StreamExt;
17use reqwest::Client;
18use smallvec::{SmallVec, smallvec};
19use tokio::io::AsyncWriteExt;
20use tracing::info;
21use xxhash_rust::xxh64::Xxh64;
22
23use crate::optiscaler::{
24    OptiScalerProfile, default_optiscaler_profile, resolve_optiscaler_profiles,
25};
26
27use super::{
28    AppliedFiles, GameTool, ToolApplyPreview, ToolAvailability, ToolCategory, ToolConfig,
29    ToolGameContext, ToolReleaseAsset, ToolReleaseInstallFuture, ToolReleaseListFuture,
30    ToolReleaseSummary,
31};
32
33const CUSTOM_OPTISCALER_PROFILE: &str = "custom";
34pub const OPTISCALER_SOURCE_OFFICIAL: &str = "github_release";
35pub const OPTISCALER_SOURCE_GOVERLAY_BUILDS: &str = "goverlay_builds";
36pub const OPTISCALER_SOURCE_GOVERLAY_FGMOD: &str = "goverlay_fgmod";
37pub const FSR4_VARIANT_LATEST_FP8: &str = "latest_fp8";
38pub const FSR4_VARIANT_INT8_402: &str = "int8_402";
39
40const FSR4_DLL_NAME: &str = "amd_fidelityfx_upscaler_dx12.dll";
41const FSR4_LATEST_DIR: &str = "FSR4_LATEST";
42const FSR4_INT8_DIR: &str = "FSR4_INT8";
43const OPTIPATCHER_REPO: &str = "optiscaler/OptiPatcher";
44const OPTIPATCHER_ASSET: &str = "OptiPatcher.asi";
45const FP8_EMULATION_ENV_KEY: &str = "DXIL_SPIRV_CONFIG";
46const FP8_EMULATION_ENV_VALUE: &str = "wmma_rdna3_workaround";
47
48pub static OPTISCALER: OptiScaler = OptiScaler;
49
50pub struct OptiScaler;
51
52pub const OPTISCALER_PROXY_DLLS: &[&str] = &[
53    "dxgi.dll",
54    "winmm.dll",
55    "d3d12.dll",
56    "dbghelp.dll",
57    "version.dll",
58    "wininet.dll",
59    "winhttp.dll",
60    "OptiScaler.asi",
61];
62
63pub const OPTISCALER_COMPANION_FILES: &[&str] = &[
64    "fakenvapi.dll",
65    "nvngx-wrapper.dll",
66    "nvngx.dll",
67    "_nvngx.dll",
68    "nvngx_dlss.dll",
69];
70
71pub const OPTISCALER_COMPANION_DIRS: &[&str] = &["D3D12_OptiScaler", "plugins"];
72
73/// DLLs that fgmod deletes at launch time. If any of these are deployed by
74/// mods or by `OptiScaler` itself, the launch wrapper must restore them.
75pub const FGMOD_DELETED_DLLS: &[&str] = &[
76    "dxgi.dll",
77    "winmm.dll",
78    "nvngx.dll",
79    "_nvngx.dll",
80    "nvngx-wrapper.dll",
81    "dlss-enabler.dll",
82    "OptiScaler.dll",
83];
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum OptiScalerInstallStatus {
87    Absent,
88    Managed,
89    Unmanaged,
90    PartiallyManaged,
91    Conflicted,
92}
93
94impl std::fmt::Display for OptiScalerInstallStatus {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        match self {
97            Self::Absent => write!(f, "absent"),
98            Self::Managed => write!(f, "managed"),
99            Self::Unmanaged => write!(f, "unmanaged"),
100            Self::PartiallyManaged => write!(f, "partially managed"),
101            Self::Conflicted => write!(f, "conflicted"),
102        }
103    }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum OptiScalerVersionIdentity {
108    CachedRelease(String),
109    FileMetadata(String),
110    ContentHash(String),
111    Unknown,
112}
113
114impl std::fmt::Display for OptiScalerVersionIdentity {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        match self {
117            Self::CachedRelease(tag) => write!(f, "{tag}"),
118            Self::FileMetadata(version) => write!(f, "{version}"),
119            Self::ContentHash(hash) => write!(f, "hash:{hash}"),
120            Self::Unknown => write!(f, "unknown"),
121        }
122    }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct OptiScalerDetectedFile {
127    pub rel_path: PathBuf,
128    pub hash: Option<String>,
129    pub managed: bool,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct OptiScalerInstallState {
134    pub status: OptiScalerInstallStatus,
135    pub executable_dir: PathBuf,
136    pub proxy_dlls: Vec<String>,
137    pub wine_dll_overrides: Vec<String>,
138    pub config_path: Option<PathBuf>,
139    pub ini_settings: BTreeMap<String, String>,
140    pub companion_files: Vec<PathBuf>,
141    pub recognized_files: Vec<OptiScalerDetectedFile>,
142    pub version: OptiScalerVersionIdentity,
143    pub latest_backup: Option<PathBuf>,
144}
145
146impl OptiScalerInstallState {
147    #[must_use]
148    pub fn summary(&self) -> String {
149        let proxy = if self.proxy_dlls.is_empty() {
150            "none".to_string()
151        } else {
152            self.proxy_dlls.join(", ")
153        };
154        format!("{}; version {}; proxy {}", self.status, self.version, proxy)
155    }
156}
157
158impl GameTool for OptiScaler {
159    fn tool_id(&self) -> &'static str {
160        "optiscaler"
161    }
162
163    fn display_name(&self) -> &'static str {
164        "OptiScaler"
165    }
166
167    fn category(&self) -> ToolCategory {
168        ToolCategory::Upscaler
169    }
170
171    fn description(&self) -> &'static str {
172        "OptiScaler / fgmod deployment for upscaling and frame-generation proxy DLLs."
173    }
174
175    fn settings_schema(&self) -> Vec<super::ToolSettingSpec> {
176        let config = self.default_config();
177        self.settings_schema_for(None, &config)
178    }
179
180    fn settings_schema_for(
181        &self,
182        context: Option<&ToolGameContext>,
183        config: &ToolConfig,
184    ) -> Vec<super::ToolSettingSpec> {
185        let mut specs = vec![
186            super::ToolSettingSpec::labeled_select(
187                "source_mode",
188                "Source",
189                "Where modde should get OptiScaler files from.",
190                &[
191                    (OPTISCALER_SOURCE_OFFICIAL, "Official GitHub releases"),
192                    (OPTISCALER_SOURCE_GOVERLAY_BUILDS, "GOverlay builds"),
193                    (OPTISCALER_SOURCE_GOVERLAY_FGMOD, "GOverlay fgmod directory"),
194                    ("local_dir", "Local OptiScaler directory"),
195                ],
196            )
197            .section("Source"),
198            super::ToolSettingSpec::labeled_select(
199                "goverlay_channel",
200                "GOverlay channel",
201                "GOverlay OptiScaler builds channel.",
202                &[
203                    ("edge", "Bleeding-edge"),
204                    ("stable", "Stable"),
205                    ("master", "Master"),
206                    ("any", "Any release branch"),
207                ],
208            )
209            .section("Source"),
210            super::ToolSettingSpec::select(
211                "release_tag",
212                "Release tag",
213                "OptiScaler release tag selected in the UI.",
214                std::slice::from_ref(&config.get_str("release_tag").unwrap_or("latest")),
215            )
216            .section("Source"),
217            super::ToolSettingSpec::select(
218                "release_asset",
219                "Release asset",
220                "Release asset selected from GitHub.",
221                std::slice::from_ref(&config.get_str("release_asset").unwrap_or("")),
222            )
223            .section("Source"),
224            super::ToolSettingSpec::labeled_select(
225                "proxy_dll",
226                "Proxy DLL",
227                "DLL name used to load OptiScaler for this game.",
228                &[
229                    ("dxgi.dll", "dxgi.dll - DirectX graphics proxy"),
230                    ("version.dll", "version.dll - Windows version proxy"),
231                    ("dbghelp.dll", "dbghelp.dll - Debug helper proxy"),
232                    ("d3d12.dll", "d3d12.dll - Direct3D 12 proxy"),
233                    ("wininet.dll", "wininet.dll - WinINet proxy"),
234                    ("winhttp.dll", "winhttp.dll - WinHTTP proxy"),
235                    ("winmm.dll", "winmm.dll - Multimedia proxy"),
236                    ("nvngx.dll", "nvngx.dll - NVIDIA NGX proxy"),
237                    ("OptiScaler.asi", "OptiScaler.asi - ASI plugin"),
238                ],
239            )
240            .section("Basic"),
241            super::ToolSettingSpec::text(
242                "dll_overrides",
243                "DLL overrides",
244                "Comma or whitespace separated Wine DLL override base names.",
245            )
246            .section("Basic"),
247            super::ToolSettingSpec::bool(
248                "copy_companion_files",
249                "Copy companion files",
250                "Copy fakenvapi, nvngx wrapper, and other DLLs found next to OptiScaler.",
251            )
252            .section("Basic"),
253            super::ToolSettingSpec::labeled_select(
254                "fsr4_variant",
255                "FSR4 variant",
256                "FSR4 payload copied as amd_fidelityfx_upscaler_dx12.dll.",
257                &[
258                    (FSR4_VARIANT_LATEST_FP8, "Latest (FP8)"),
259                    (FSR4_VARIANT_INT8_402, "4.0.2c (INT8)"),
260                ],
261            )
262            .section("Basic"),
263            super::ToolSettingSpec::bool(
264                "enable_optipatcher",
265                "OptiPatcher",
266                "Use OptiPatcher to unlock DLSS and DLSS frame generation inputs without whole-game spoofing in supported games.",
267            )
268            .section("Basic"),
269            super::ToolSettingSpec::bool(
270                "spoof_dlss",
271                "Spoof DLSS fallback",
272                "Fallback DXGI spoofing path for games that still need whole-game spoofing.",
273            )
274            .section("Basic"),
275            super::ToolSettingSpec::read_only(
276                "derived_executable_dir",
277                "Executable directory",
278                "Derived from the selected game's metadata.",
279            )
280            .section("Detected Game"),
281        ];
282
283        if let Some(context) = context {
284            let profiles = resolve_optiscaler_profiles(&context.game_id);
285            if !profiles.is_empty() {
286                let profile_options = std::iter::once(super::ToolSelectOption::new(
287                    CUSTOM_OPTISCALER_PROFILE,
288                    "Custom / no community profile",
289                ))
290                .chain(
291                    profiles
292                        .iter()
293                        .map(|profile| super::ToolSelectOption::new(profile.id, profile.name)),
294                )
295                .collect();
296                specs.insert(
297                    0,
298                    super::ToolSettingSpec {
299                        key: "optiscaler_profile",
300                        label: "Profile",
301                        description: "Community-tested OptiScaler profile to apply, or custom settings.",
302                        section: "Profile",
303                        advanced: false,
304                        kind: super::ToolSettingKind::Select { options: profile_options },
305                    },
306                );
307                specs.push(
308                    super::ToolSettingSpec::read_only(
309                        "optiscaler_profile_source_url",
310                        "Profile source",
311                        "Community compatibility source for the selected profile.",
312                    )
313                    .section("Profile"),
314                );
315                specs.push(
316                    super::ToolSettingSpec::read_only(
317                        "tested_optiscaler_version",
318                        "Tested version",
319                        "OptiScaler version reported by the selected profile.",
320                    )
321                    .section("Profile"),
322                );
323                specs.push(
324                    super::ToolSettingSpec::read_only(
325                        "optiscaler_profile_notes",
326                        "Profile notes",
327                        "Community notes for the selected profile.",
328                    )
329                    .section("Profile"),
330                );
331            }
332        }
333
334        if config.get_str("source_mode") == Some("local_dir") {
335            specs.insert(
336                1,
337                super::ToolSettingSpec::path(
338                    "local_source_dir",
339                    "Local source directory",
340                    "Directory containing OptiScaler.dll and companion files.",
341                )
342                .section("Source"),
343            );
344        }
345        if config.get_str("source_mode") != Some(OPTISCALER_SOURCE_GOVERLAY_BUILDS) {
346            specs.retain(|spec| spec.key != "goverlay_channel");
347        }
348
349        if config.get_str("fsr4_variant") == Some(FSR4_VARIANT_INT8_402) {
350            specs.push(
351                super::ToolSettingSpec::read_only(
352                    "emulate_fp8",
353                    "Emulate FP8",
354                    "Only applies to the Latest (FP8) FSR4 variant.",
355                )
356                .section("Basic"),
357            );
358        } else {
359            specs.push(
360                super::ToolSettingSpec::bool(
361                    "emulate_fp8",
362                    "Emulate FP8",
363                    "Set DXIL_SPIRV_CONFIG=wmma_rdna3_workaround for the Latest (FP8) FSR4 variant.",
364                )
365                .section("Basic"),
366            );
367        }
368
369        specs.extend(goverlay_optiscaler_ini_specs());
370        specs.extend(optiscaler_ini_specs(config));
371        specs
372    }
373
374    fn detect_available(&self) -> ToolAvailability {
375        // Check common locations for OptiScaler
376        let candidates = [
377            dirs::data_dir()
378                .unwrap_or_default()
379                .join("goverlay/fgmod/OptiScaler.dll"),
380            dirs::home_dir()
381                .unwrap_or_default()
382                .join(".local/share/goverlay/fgmod/OptiScaler.dll"),
383        ];
384
385        for path in &candidates {
386            if path.exists() {
387                return ToolAvailability::Available {
388                    version: Some("fgmod".into()),
389                };
390            }
391        }
392
393        // User can provide a custom path via settings
394        ToolAvailability::Available {
395            version: Some("user-provided".into()),
396        }
397    }
398
399    fn env_vars(&self, config: &ToolConfig) -> SmallVec<[(String, String); 4]> {
400        if config.get_bool("emulate_fp8")
401            && config.get_str("fsr4_variant") == Some(FSR4_VARIANT_LATEST_FP8)
402        {
403            smallvec![(
404                FP8_EMULATION_ENV_KEY.to_string(),
405                FP8_EMULATION_ENV_VALUE.to_string()
406            )]
407        } else {
408            SmallVec::new()
409        }
410    }
411
412    fn wine_dll_overrides(&self, config: &ToolConfig) -> SmallVec<[String; 4]> {
413        let primary = config
414            .get_str("proxy_dll")
415            .or_else(|| config.get_str("dll_name"))
416            .unwrap_or("dxgi.dll")
417            .trim_end_matches(".dll")
418            .to_string();
419        let mut overrides: SmallVec<[String; 4]> = smallvec![primary];
420
421        if config.get_bool("needs_winmm") && !overrides.iter().any(|value| value == "winmm") {
422            overrides.push("winmm".into());
423        }
424        if let Some(raw) = config.get_str("dll_overrides") {
425            for part in raw.split([',', ';', ' ', '\n', '\t']) {
426                let value = part.trim().trim_end_matches(".dll");
427                if !value.is_empty() && !overrides.iter().any(|existing| existing == value) {
428                    overrides.push(value.to_string());
429                }
430            }
431        }
432
433        overrides
434    }
435
436    fn apply(&self, game_dir: &Path, config: &ToolConfig) -> Result<AppliedFiles> {
437        self.apply_for(game_dir, None, config)
438    }
439
440    fn apply_for(
441        &self,
442        game_dir: &Path,
443        context: Option<&ToolGameContext>,
444        config: &ToolConfig,
445    ) -> Result<AppliedFiles> {
446        let source_dir = resolve_source_dir(config).context(
447            "optiscaler: choose a GitHub release, local directory, or install fgmod/goverlay",
448        )?;
449
450        let dll_name = config
451            .get_str("proxy_dll")
452            .or_else(|| config.get_str("dll_name"))
453            .unwrap_or("dxgi.dll");
454        let target_dir = context
455            .and_then(|context| context.executable_dir.clone())
456            .unwrap_or_else(|| legacy_target_dir(game_dir, config));
457
458        std::fs::create_dir_all(&target_dir)
459            .with_context(|| format!("failed to create {}", target_dir.display()))?;
460        let applied_paths = managed_paths_from_config(config);
461        let existing = scan_optiscaler_install_in_dir(&target_dir, &applied_paths)?;
462        if matches!(
463            existing.status,
464            OptiScalerInstallStatus::Unmanaged
465                | OptiScalerInstallStatus::PartiallyManaged
466                | OptiScalerInstallStatus::Conflicted
467        ) {
468            backup_optiscaler_install(context.map(|context| context.game_id.as_str()), &existing)?;
469        }
470
471        let mut applied = AppliedFiles::default();
472
473        // Copy OptiScaler as the requested DLL name
474        let optiscaler_dll = source_dir.join("OptiScaler.dll");
475        if optiscaler_dll.exists() {
476            let dest = target_dir.join(dll_name);
477            std::fs::copy(&optiscaler_dll, &dest)
478                .with_context(|| format!("failed to copy OptiScaler to {}", dest.display()))?;
479            let rel = relative_to_game(game_dir, &dest)?;
480            applied.files.push(rel);
481            info!(as_dll = %dll_name, "applied OptiScaler DLL");
482        }
483
484        // Copy OptiScaler.ini if present (or generate default)
485        let ini_src = source_dir.join("OptiScaler.ini");
486        let ini_dest = target_dir.join("OptiScaler.ini");
487        if ini_src.exists() {
488            let reset_reason = optiscaler_config_reset_reason(&existing, &ini_src, config);
489            if reset_reason.is_some() {
490                apply_ini_overrides_with_existing(&ini_src, None, &ini_dest, config)?;
491            } else {
492                apply_ini_overrides_with_existing(
493                    &ini_src,
494                    ini_dest.exists().then_some(&ini_dest),
495                    &ini_dest,
496                    config,
497                )?;
498            }
499            let rel = relative_to_game(game_dir, &ini_dest)?;
500            applied.files.push(rel);
501        }
502
503        // Copy additional DLLs from source (fakenvapi, nvngx-wrapper, etc.)
504        if config.get_bool("copy_companion_files") {
505            for entry in std::fs::read_dir(&source_dir)
506                .with_context(|| format!("failed to read directory: {}", source_dir.display()))?
507                .flatten()
508            {
509                let src = entry.path();
510                if !src.is_file() {
511                    continue;
512                }
513                let Some(name) = src.file_name().and_then(|name| name.to_str()) else {
514                    continue;
515                };
516                if !name.eq_ignore_ascii_case("OptiScaler.dll")
517                    && !name.eq_ignore_ascii_case("OptiScaler.ini")
518                    && !name.eq_ignore_ascii_case(FSR4_DLL_NAME)
519                    && name.to_ascii_lowercase().ends_with(".dll")
520                {
521                    let dest = target_dir.join(name);
522                    std::fs::copy(&src, &dest)
523                        .with_context(|| format!("failed to copy {}", dest.display()))?;
524                    let rel = relative_to_game(game_dir, &dest)?;
525                    applied.files.push(rel);
526                }
527            }
528        }
529
530        if let Some(fsr4_src) = selected_fsr4_variant_source(&source_dir, config) {
531            if !fsr4_src.is_file() {
532                anyhow::bail!(
533                    "optiscaler: selected FSR4 variant '{}' was not found at {}",
534                    fsr4_variant(config),
535                    fsr4_src.display()
536                );
537            }
538            let dest = target_dir.join(FSR4_DLL_NAME);
539            std::fs::copy(&fsr4_src, &dest)
540                .with_context(|| format!("failed to copy FSR4 variant to {}", dest.display()))?;
541            let rel = relative_to_game(game_dir, &dest)?;
542            applied.files.push(rel);
543        }
544
545        if config.get_bool("enable_optipatcher") {
546            let optipatcher_src = optipatcher_asi_source(&source_dir);
547            if !optipatcher_src.is_file() {
548                anyhow::bail!(
549                    "optiscaler: OptiPatcher.asi is required but not cached; install or update the selected OptiScaler release first"
550                );
551            }
552            let dest = target_dir.join("plugins").join(OPTIPATCHER_ASSET);
553            if let Some(parent) = dest.parent() {
554                std::fs::create_dir_all(parent)
555                    .with_context(|| format!("failed to create {}", parent.display()))?;
556            }
557            std::fs::copy(&optipatcher_src, &dest)
558                .with_context(|| format!("failed to copy OptiPatcher.asi to {}", dest.display()))?;
559            let rel = relative_to_game(game_dir, &dest)?;
560            applied.files.push(rel);
561        }
562
563        if source_dir.join("D3D12_OptiScaler").is_dir() {
564            let dest = target_dir.join("D3D12_OptiScaler");
565            copy_dir_recursive(&source_dir.join("D3D12_OptiScaler"), &dest)?;
566            collect_relative_files(game_dir, &dest, &mut applied.files)?;
567        }
568
569        Ok(applied)
570    }
571
572    fn preview_apply_for(
573        &self,
574        game_dir: &Path,
575        context: Option<&ToolGameContext>,
576        config: &ToolConfig,
577    ) -> Result<ToolApplyPreview> {
578        let Some(source_dir) = resolve_source_dir(config) else {
579            return Ok(optiscaler_missing_preview(
580                "optiscaler: choose a GitHub release, local directory, or install fgmod/goverlay",
581            ));
582        };
583
584        let dll_name = config
585            .get_str("proxy_dll")
586            .or_else(|| config.get_str("dll_name"))
587            .unwrap_or("dxgi.dll");
588        let target_dir = context
589            .and_then(|context| context.executable_dir.clone())
590            .unwrap_or_else(|| legacy_target_dir(game_dir, config));
591        let applied_paths = managed_paths_from_config(config);
592        let existing = scan_optiscaler_install_in_dir(&target_dir, &applied_paths)?;
593        let mut preview = ToolApplyPreview::default();
594
595        let optiscaler_dll = source_dir.join("OptiScaler.dll");
596        if optiscaler_dll.is_file() {
597            preview_source_file(
598                game_dir,
599                &optiscaler_dll,
600                &target_dir.join(dll_name),
601                &mut preview,
602            )?;
603        } else {
604            preview.missing_inputs.push(format!(
605                "optiscaler: OptiScaler.dll not found in {}",
606                source_dir.display()
607            ));
608        }
609
610        let ini_src = source_dir.join("OptiScaler.ini");
611        let ini_dest = target_dir.join("OptiScaler.ini");
612        if ini_src.is_file() {
613            let reset_reason = optiscaler_config_reset_reason(&existing, &ini_src, config);
614            let content = if reset_reason.is_some() {
615                build_ini_with_overrides(&ini_src, None, config)?.into_bytes()
616            } else {
617                build_ini_with_overrides(
618                    &ini_src,
619                    ini_dest.exists().then_some(ini_dest.as_path()),
620                    config,
621                )?
622                .into_bytes()
623            };
624            preview_bytes(game_dir, &ini_dest, &content, &mut preview);
625        } else {
626            preview.missing_inputs.push(format!(
627                "optiscaler: OptiScaler.ini not found in {}",
628                source_dir.display()
629            ));
630        }
631
632        if config.get_bool("copy_companion_files") {
633            for entry in std::fs::read_dir(&source_dir)
634                .with_context(|| format!("failed to read directory: {}", source_dir.display()))?
635                .flatten()
636            {
637                let src = entry.path();
638                if !src.is_file() {
639                    continue;
640                }
641                let Some(name) = src.file_name().and_then(|name| name.to_str()) else {
642                    continue;
643                };
644                if !name.eq_ignore_ascii_case("OptiScaler.dll")
645                    && !name.eq_ignore_ascii_case("OptiScaler.ini")
646                    && !name.eq_ignore_ascii_case(FSR4_DLL_NAME)
647                    && name.to_ascii_lowercase().ends_with(".dll")
648                {
649                    preview_source_file(game_dir, &src, &target_dir.join(name), &mut preview)?;
650                }
651            }
652        }
653
654        if let Some(fsr4_src) = selected_fsr4_variant_source(&source_dir, config) {
655            if fsr4_src.is_file() {
656                preview_source_file(
657                    game_dir,
658                    &fsr4_src,
659                    &target_dir.join(FSR4_DLL_NAME),
660                    &mut preview,
661                )?;
662            } else {
663                preview.missing_inputs.push(format!(
664                    "optiscaler: selected FSR4 variant '{}' not found at {}",
665                    fsr4_variant(config),
666                    fsr4_src.display()
667                ));
668            }
669        }
670
671        if config.get_bool("enable_optipatcher") {
672            let optipatcher_src = optipatcher_asi_source(&source_dir);
673            if optipatcher_src.is_file() {
674                preview_source_file(
675                    game_dir,
676                    &optipatcher_src,
677                    &target_dir.join("plugins").join(OPTIPATCHER_ASSET),
678                    &mut preview,
679                )?;
680            } else {
681                preview.missing_inputs.push(
682                    "optiscaler: OptiPatcher.asi is required but not cached; install or update the selected OptiScaler release first"
683                        .to_string(),
684                );
685            }
686        }
687
688        let d3d12_src = source_dir.join("D3D12_OptiScaler");
689        if d3d12_src.is_dir() {
690            preview_dir_recursive(
691                game_dir,
692                &d3d12_src,
693                &target_dir.join("D3D12_OptiScaler"),
694                &mut preview,
695            )?;
696        }
697
698        Ok(preview)
699    }
700
701    fn default_config(&self) -> ToolConfig {
702        let mut config = ToolConfig::new("optiscaler");
703        config.set(
704            "source_mode",
705            serde_json::json!(OPTISCALER_SOURCE_GOVERLAY_FGMOD),
706        );
707        config.set("goverlay_channel", serde_json::json!("edge"));
708        config.set("release_tag", serde_json::json!("latest"));
709        config.set("release_asset", serde_json::json!(""));
710        config.set("local_source_dir", serde_json::json!(""));
711        config.set("proxy_dll", serde_json::json!("dxgi.dll"));
712        config.set("dll_overrides", serde_json::json!(""));
713        config.set("copy_companion_files", serde_json::json!(true));
714        config.set("enable_optipatcher", serde_json::json!(false));
715        config.set("fsr4_variant", serde_json::json!(FSR4_VARIANT_LATEST_FP8));
716        config.set("emulate_fp8", serde_json::json!(false));
717        config.set("spoof_dlss", serde_json::json!(false));
718        config.set("ini_overrides", serde_json::json!({}));
719        config
720    }
721
722    fn default_config_for(&self, context: Option<&ToolGameContext>) -> ToolConfig {
723        let mut config = self.default_config();
724        apply_game_defaults(&mut config, context);
725        config
726    }
727
728    fn supports_releases(&self) -> bool {
729        true
730    }
731
732    fn list_releases(&self) -> ToolReleaseListFuture<'_> {
733        Box::pin(async { list_optiscaler_releases().await })
734    }
735
736    fn installable_release_assets(&self, release: &ToolReleaseSummary) -> Vec<String> {
737        release
738            .assets
739            .iter()
740            .filter(|asset| is_installable_release_asset(&asset.name))
741            .map(|asset| asset.name.clone())
742            .collect()
743    }
744
745    fn install_release<'a>(
746        &'a self,
747        _game_id: &'a str,
748        mut config: ToolConfig,
749        tag: &'a str,
750        asset: &'a str,
751    ) -> ToolReleaseInstallFuture<'a> {
752        Box::pin(async move {
753            install_optiscaler_release_asset(tag, asset).await?;
754            let normalized_tag = normalize_optiscaler_release_tag(tag);
755            apply_optiscaler_release_selection(&mut config, &normalized_tag, asset);
756            if config.get_bool("enable_optipatcher") {
757                install_latest_optipatcher().await?;
758            }
759            Ok(config)
760        })
761    }
762
763    fn install_release_from_path<'a>(
764        &'a self,
765        _game_id: &'a str,
766        mut config: ToolConfig,
767        tag: &'a str,
768        asset: &'a str,
769        path: PathBuf,
770    ) -> ToolReleaseInstallFuture<'a> {
771        Box::pin(async move {
772            install_optiscaler_release_asset_from_path(tag, asset, &path)?;
773            let normalized_tag = normalize_optiscaler_release_tag(tag);
774            apply_optiscaler_release_selection(&mut config, &normalized_tag, asset);
775            if config.get_bool("enable_optipatcher") {
776                install_latest_optipatcher().await?;
777            }
778            Ok(config)
779        })
780    }
781}
782
783pub async fn list_optiscaler_releases() -> Result<Vec<ToolReleaseSummary>> {
784    let mut releases = official_optiscaler_releases().await?;
785    releases.extend(goverlay_optiscaler_releases().await?);
786    Ok(releases)
787}
788
789#[must_use]
790pub fn normalize_optiscaler_release_config(config: &mut ToolConfig) -> bool {
791    let mut changed = false;
792    if let Some(tag) = config.get_str("release_tag").map(str::to_string)
793        && !tag.trim().is_empty()
794    {
795        let normalized = normalize_optiscaler_release_tag(&tag);
796        if normalized != tag {
797            config.set("release_tag", serde_json::json!(normalized));
798            changed = true;
799        }
800    }
801    if let Some(tag) = config.get_str("release_tag").map(str::to_string) {
802        if let Some(channel) = optiscaler_goverlay_channel_for_tag(&tag) {
803            if config.get_str("source_mode") != Some(OPTISCALER_SOURCE_GOVERLAY_BUILDS) {
804                config.set(
805                    "source_mode",
806                    serde_json::json!(OPTISCALER_SOURCE_GOVERLAY_BUILDS),
807                );
808                changed = true;
809            }
810            if config.get_str("goverlay_channel") != Some(channel) {
811                config.set("goverlay_channel", serde_json::json!(channel));
812                changed = true;
813            }
814        } else if !tag.trim().is_empty()
815            && optiscaler_release_is_official(&tag)
816            && config.get_str("source_mode").is_none()
817        {
818            config.set("source_mode", serde_json::json!(OPTISCALER_SOURCE_OFFICIAL));
819            changed = true;
820        }
821    }
822    changed
823}
824
825#[must_use]
826pub fn optiscaler_release_matches_config(
827    release: &ToolReleaseSummary,
828    config: &ToolConfig,
829) -> bool {
830    match config
831        .get_str("source_mode")
832        .unwrap_or(OPTISCALER_SOURCE_GOVERLAY_FGMOD)
833    {
834        OPTISCALER_SOURCE_OFFICIAL => optiscaler_release_is_official(&release.tag),
835        OPTISCALER_SOURCE_GOVERLAY_BUILDS => {
836            optiscaler_goverlay_channel_for_tag(&release.tag)
837                == Some(config.get_str("goverlay_channel").unwrap_or("edge"))
838        }
839        _ => false,
840    }
841}
842
843#[must_use]
844pub fn optiscaler_release_is_official(tag: &str) -> bool {
845    normalize_optiscaler_release_tag(tag).starts_with("official:")
846}
847
848#[must_use]
849pub fn optiscaler_goverlay_channel_for_tag(tag: &str) -> Option<&'static str> {
850    let encoded = normalize_optiscaler_release_tag(tag);
851    let channel = encoded
852        .strip_prefix("goverlay-")
853        .and_then(|rest| rest.split_once(':'))
854        .map(|(channel, _)| channel)?;
855    match channel {
856        "edge" => Some("edge"),
857        "stable" => Some("stable"),
858        "master" => Some("master"),
859        "any" => Some("any"),
860        _ => None,
861    }
862}
863
864pub async fn install_optiscaler_release_asset(tag: &str, asset_name: &str) -> Result<PathBuf> {
865    let releases = list_optiscaler_releases().await?;
866    let (normalized_tag, asset) = select_optiscaler_release_asset(&releases, tag, asset_name)?;
867    if !is_installable_release_asset(&asset.name) {
868        anyhow::bail!(
869            "selected asset '{}' is not a supported archive (.zip or .7z)",
870            asset.name
871        );
872    }
873
874    let cache_dir = cached_release_dir(&normalized_tag);
875    std::fs::create_dir_all(&cache_dir)
876        .with_context(|| format!("failed to create {}", cache_dir.display()))?;
877    let archive_path = cache_dir.join(&asset.name);
878    download_release_asset(asset, &archive_path).await?;
879    extract_optiscaler_archive_flat(&archive_path, &cache_dir)?;
880    Ok(cache_dir)
881}
882
883pub fn install_optiscaler_release_asset_from_path(
884    tag: &str,
885    asset_name: &str,
886    path: &Path,
887) -> Result<PathBuf> {
888    let normalized_tag = normalize_optiscaler_release_tag(tag);
889    if !is_installable_release_asset(asset_name) {
890        anyhow::bail!("selected asset '{asset_name}' is not a supported archive (.zip or .7z)");
891    }
892
893    let cache_dir = cached_release_dir(&normalized_tag);
894    std::fs::create_dir_all(&cache_dir)
895        .with_context(|| format!("failed to create {}", cache_dir.display()))?;
896    let archive_path = cache_dir.join(asset_name);
897    std::fs::copy(path, &archive_path).with_context(|| {
898        format!(
899            "failed to copy release asset {} to {}",
900            path.display(),
901            archive_path.display()
902        )
903    })?;
904    extract_optiscaler_archive_flat(&archive_path, &cache_dir)?;
905    Ok(cache_dir)
906}
907
908pub async fn install_latest_optipatcher() -> Result<PathBuf> {
909    let release = super::release::list_github_releases(OPTIPATCHER_REPO)
910        .await?
911        .into_iter()
912        .next()
913        .ok_or_else(|| anyhow::anyhow!("OptiPatcher release not found"))?;
914    let asset = release
915        .assets
916        .iter()
917        .find(|asset| asset.name.eq_ignore_ascii_case(OPTIPATCHER_ASSET))
918        .ok_or_else(|| {
919            anyhow::anyhow!(
920                "OptiPatcher release {} did not contain {}",
921                release.tag,
922                OPTIPATCHER_ASSET
923            )
924        })?;
925    let dest = cached_optipatcher_asi();
926    download_release_asset(asset, &dest).await?;
927    Ok(dest)
928}
929
930fn normalize_optiscaler_release_tag(tag: &str) -> String {
931    if tag.contains(':') {
932        tag.to_string()
933    } else {
934        encode_optiscaler_release_tag("official", tag)
935    }
936}
937
938fn select_optiscaler_release_asset<'a>(
939    releases: &'a [ToolReleaseSummary],
940    tag: &str,
941    asset_name: &str,
942) -> Result<(String, &'a ToolReleaseAsset)> {
943    let normalized_tag = normalize_optiscaler_release_tag(tag);
944    let release = releases
945        .iter()
946        .find(|release| release.tag == normalized_tag)
947        .ok_or_else(|| anyhow::anyhow!("OptiScaler release tag not found: {tag}"))?;
948    let asset = release
949        .assets
950        .iter()
951        .find(|asset| asset.name == asset_name)
952        .ok_or_else(|| anyhow::anyhow!("asset '{asset_name}' not found in release {tag}"))?;
953    Ok((normalized_tag, asset))
954}
955
956async fn official_optiscaler_releases() -> Result<Vec<ToolReleaseSummary>> {
957    Ok(
958        super::release::list_github_releases("optiscaler/OptiScaler")
959            .await?
960            .into_iter()
961            .map(|mut release| {
962                release.tag = encode_optiscaler_release_tag("official", &release.tag);
963                release
964            })
965            .collect(),
966    )
967}
968
969async fn goverlay_optiscaler_releases() -> Result<Vec<ToolReleaseSummary>> {
970    let mut releases: Vec<_> =
971        super::release::list_github_releases("benjamimgois/OptiScaler-builds")
972            .await?
973            .into_iter()
974            .filter_map(goverlay_release_summary)
975            .collect();
976    releases.sort_by(|left, right| right.published_at.cmp(&left.published_at));
977    Ok(releases)
978}
979
980fn goverlay_release_summary(mut release: ToolReleaseSummary) -> Option<ToolReleaseSummary> {
981    let channel = goverlay_release_channel(&release.tag)?;
982    let assets = goverlay_installable_assets(channel, &release);
983    if assets.is_empty() {
984        return None;
985    }
986    release.tag = encode_optiscaler_release_tag(channel, &release.tag);
987    release.assets = assets;
988    Some(release)
989}
990
991fn encode_optiscaler_release_tag(source: &str, tag: &str) -> String {
992    if tag.contains(':') {
993        tag.to_string()
994    } else {
995        format!("{source}:{tag}")
996    }
997}
998
999fn goverlay_release_channel(tag: &str) -> Option<&'static str> {
1000    if tag.starts_with("edge-") {
1001        Some("goverlay-edge")
1002    } else if tag.starts_with("master-") {
1003        Some("goverlay-master")
1004    } else if tag.starts_with("any-release-") {
1005        Some("goverlay-any")
1006    } else if is_goverlay_stable_tag(tag) {
1007        Some("goverlay-stable")
1008    } else {
1009        None
1010    }
1011}
1012
1013fn is_goverlay_stable_tag(tag: &str) -> bool {
1014    let (version, patch) = tag
1015        .split_once('-')
1016        .map_or((tag, None), |(version, patch)| (version, Some(patch)));
1017    if let Some(patch) = patch
1018        && (patch.is_empty() || !patch.chars().all(|ch| ch.is_ascii_digit()))
1019    {
1020        return false;
1021    }
1022    let mut parts = version.split('.');
1023    let Some(major) = parts.next() else {
1024        return false;
1025    };
1026    let Some(minor) = parts.next() else {
1027        return false;
1028    };
1029    let Some(patch) = parts.next() else {
1030        return false;
1031    };
1032    parts.next().is_none()
1033        && [major, minor, patch]
1034            .into_iter()
1035            .all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit()))
1036}
1037
1038fn goverlay_installable_assets(
1039    channel: &str,
1040    release: &ToolReleaseSummary,
1041) -> Vec<ToolReleaseAsset> {
1042    match channel {
1043        "goverlay-stable" => release_assets_named(release, "optiScaler-stable.7z"),
1044        "goverlay-edge" => release_assets_named(release, "optiscaler-edge.7z"),
1045        "goverlay-master" | "goverlay-any" => release
1046            .assets
1047            .iter()
1048            .filter(|asset| {
1049                let lower = asset.name.to_ascii_lowercase();
1050                lower.ends_with(".7z") && !lower.ends_with(".json")
1051            })
1052            .cloned()
1053            .collect(),
1054        _ => Vec::new(),
1055    }
1056}
1057
1058fn release_assets_named(
1059    release: &ToolReleaseSummary,
1060    expected_name: &str,
1061) -> Vec<ToolReleaseAsset> {
1062    release
1063        .assets
1064        .iter()
1065        .filter(|asset| asset.name.eq_ignore_ascii_case(expected_name))
1066        .cloned()
1067        .collect()
1068}
1069
1070async fn download_release_asset(asset: &ToolReleaseAsset, dest: &Path) -> Result<()> {
1071    if let Some(parent) = dest.parent() {
1072        std::fs::create_dir_all(parent)
1073            .with_context(|| format!("failed to create {}", parent.display()))?;
1074    }
1075    let client = Client::new();
1076    let response = client
1077        .get(&asset.download_url)
1078        .header("User-Agent", "modde")
1079        .send()
1080        .await?
1081        .error_for_status()?;
1082    let mut file = tokio::fs::File::create(dest)
1083        .await
1084        .with_context(|| format!("failed to create {}", dest.display()))?;
1085    let mut stream = response.bytes_stream();
1086    while let Some(chunk) = stream.next().await {
1087        file.write_all(&chunk?).await?;
1088    }
1089    file.flush().await?;
1090    Ok(())
1091}
1092
1093#[must_use]
1094pub fn is_installable_release_asset(name: &str) -> bool {
1095    let lower = name.to_ascii_lowercase();
1096    lower.ends_with(".zip") || lower.ends_with(".7z")
1097}
1098
1099fn legacy_target_dir(game_dir: &Path, config: &ToolConfig) -> PathBuf {
1100    let exe_subdir = config.get_str("exe_subdir").unwrap_or("");
1101    if exe_subdir.is_empty() {
1102        game_dir.to_path_buf()
1103    } else {
1104        game_dir.join(exe_subdir)
1105    }
1106}
1107
1108fn relative_to_game(game_dir: &Path, dest: &Path) -> Result<PathBuf> {
1109    dest.strip_prefix(game_dir)
1110        .map(Path::to_path_buf)
1111        .with_context(|| {
1112            format!(
1113                "optiscaler: destination {} is not under game dir {}",
1114                dest.display(),
1115                game_dir.display()
1116            )
1117        })
1118}
1119
1120#[must_use]
1121pub fn cached_release_dir(tag: &str) -> PathBuf {
1122    modde_core::paths::modde_data_dir()
1123        .join("tools")
1124        .join("optiscaler")
1125        .join(sanitize_tag(tag))
1126}
1127
1128#[must_use]
1129pub fn cached_optipatcher_dir() -> PathBuf {
1130    modde_core::paths::modde_data_dir()
1131        .join("tools")
1132        .join("optipatcher")
1133        .join("rolling")
1134}
1135
1136#[must_use]
1137pub fn cached_optipatcher_asi() -> PathBuf {
1138    cached_optipatcher_dir().join(OPTIPATCHER_ASSET)
1139}
1140
1141fn sanitize_tag(tag: &str) -> String {
1142    tag.chars()
1143        .map(|ch| {
1144            if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
1145                ch
1146            } else {
1147                '_'
1148            }
1149        })
1150        .collect()
1151}
1152
1153fn resolve_source_dir(config: &ToolConfig) -> Option<PathBuf> {
1154    match config.get_str("source_mode").unwrap_or("goverlay_fgmod") {
1155        OPTISCALER_SOURCE_OFFICIAL | OPTISCALER_SOURCE_GOVERLAY_BUILDS => {
1156            let tag = config.get_str("release_tag")?;
1157            cached_release_source_dir(tag)
1158        }
1159        "local_dir" => config
1160            .get_str("local_source_dir")
1161            .or_else(|| config.get_str("source_dir"))
1162            .filter(|value| !value.trim().is_empty())
1163            .map(PathBuf::from),
1164        _ => {
1165            let fgmod = dirs::home_dir()?.join(".local/share/goverlay/fgmod");
1166            fgmod.is_dir().then_some(fgmod)
1167        }
1168    }
1169}
1170
1171fn cached_release_source_dir(tag: &str) -> Option<PathBuf> {
1172    let candidates = if let Some(official_tag) = tag.strip_prefix("official:") {
1173        vec![cached_release_dir(tag), cached_release_dir(official_tag)]
1174    } else {
1175        let normalized = normalize_optiscaler_release_tag(tag);
1176        if normalized == tag {
1177            vec![cached_release_dir(tag)]
1178        } else {
1179            vec![cached_release_dir(tag), cached_release_dir(&normalized)]
1180        }
1181    };
1182    candidates
1183        .into_iter()
1184        .find(|dir| dir.join("OptiScaler.dll").exists())
1185}
1186
1187fn optiscaler_missing_preview(message: impl Into<String>) -> ToolApplyPreview {
1188    ToolApplyPreview {
1189        missing_inputs: vec![message.into()],
1190        ..ToolApplyPreview::default()
1191    }
1192}
1193
1194fn fsr4_variant(config: &ToolConfig) -> &str {
1195    match config.get_str("fsr4_variant") {
1196        Some(FSR4_VARIANT_INT8_402) => FSR4_VARIANT_INT8_402,
1197        _ => FSR4_VARIANT_LATEST_FP8,
1198    }
1199}
1200
1201fn selected_fsr4_variant_source(source_dir: &Path, config: &ToolConfig) -> Option<PathBuf> {
1202    let dir = match fsr4_variant(config) {
1203        FSR4_VARIANT_INT8_402 => FSR4_INT8_DIR,
1204        FSR4_VARIANT_LATEST_FP8 => FSR4_LATEST_DIR,
1205        _ => return None,
1206    };
1207    let selected = source_dir.join(dir).join(FSR4_DLL_NAME);
1208    let has_variant_payloads =
1209        source_dir.join(FSR4_LATEST_DIR).is_dir() || source_dir.join(FSR4_INT8_DIR).is_dir();
1210    let expects_goverlay_payloads = matches!(
1211        config.get_str("source_mode"),
1212        Some(OPTISCALER_SOURCE_GOVERLAY_BUILDS | OPTISCALER_SOURCE_GOVERLAY_FGMOD)
1213    );
1214    (selected.is_file() || has_variant_payloads || expects_goverlay_payloads).then_some(selected)
1215}
1216
1217fn optipatcher_asi_source(source_dir: &Path) -> PathBuf {
1218    [
1219        source_dir.join("plugins").join(OPTIPATCHER_ASSET),
1220        source_dir.join(OPTIPATCHER_ASSET),
1221        cached_optipatcher_asi(),
1222    ]
1223    .into_iter()
1224    .find(|path| path.is_file())
1225    .unwrap_or_else(cached_optipatcher_asi)
1226}
1227
1228fn preview_source_file(
1229    game_dir: &Path,
1230    src: &Path,
1231    dest: &Path,
1232    preview: &mut ToolApplyPreview,
1233) -> Result<()> {
1234    let expected =
1235        std::fs::read(src).with_context(|| format!("failed to read {}", src.display()))?;
1236    preview_bytes(game_dir, dest, &expected, preview);
1237    Ok(())
1238}
1239
1240fn preview_bytes(game_dir: &Path, dest: &Path, expected: &[u8], preview: &mut ToolApplyPreview) {
1241    let changed = std::fs::read(dest).map_or(true, |current| current != expected);
1242    let rel = dest.strip_prefix(game_dir).unwrap_or(dest).to_path_buf();
1243    preview.record_file(rel, changed);
1244}
1245
1246fn preview_dir_recursive(
1247    game_dir: &Path,
1248    src: &Path,
1249    dest: &Path,
1250    preview: &mut ToolApplyPreview,
1251) -> Result<()> {
1252    for entry in std::fs::read_dir(src)
1253        .with_context(|| format!("failed to read directory: {}", src.display()))?
1254        .flatten()
1255    {
1256        let ty = entry.file_type()?;
1257        let src_path = entry.path();
1258        let dest_path = dest.join(entry.file_name());
1259        if ty.is_dir() {
1260            preview_dir_recursive(game_dir, &src_path, &dest_path, preview)?;
1261        } else {
1262            preview_source_file(game_dir, &src_path, &dest_path, preview)?;
1263        }
1264    }
1265    Ok(())
1266}
1267
1268fn goverlay_optiscaler_ini_specs() -> Vec<super::ToolSettingSpec> {
1269    vec![
1270        super::ToolSettingSpec::labeled_select(
1271            "ini_overrides.Menu.ShortcutKey",
1272            "Menu shortcut",
1273            "OptiScaler [Menu] ShortcutKey override.",
1274            &[
1275                ("auto", "Auto"),
1276                ("INSERT", "Insert"),
1277                ("HOME", "Home"),
1278                ("END", "End"),
1279                ("DELETE", "Delete"),
1280                ("BACKQUOTE", "Backquote"),
1281                ("F1", "F1"),
1282                ("F2", "F2"),
1283                ("F3", "F3"),
1284                ("F4", "F4"),
1285                ("F5", "F5"),
1286                ("F6", "F6"),
1287                ("F7", "F7"),
1288                ("F8", "F8"),
1289                ("F9", "F9"),
1290                ("F10", "F10"),
1291                ("F11", "F11"),
1292                ("F12", "F12"),
1293            ],
1294        )
1295        .section("Menu"),
1296        super::ToolSettingSpec::number(
1297            "ini_overrides.Menu.Scale",
1298            "Menu scale",
1299            "OptiScaler [Menu] Scale override.",
1300            0.5,
1301            2.0,
1302            0.1,
1303        )
1304        .section("Menu"),
1305        super::ToolSettingSpec::tri_state_bool(
1306            "ini_overrides.NvApi.OverrideNvapiDll",
1307            "Override NVAPI DLL",
1308            "OptiScaler OverrideNvapiDll override.",
1309        )
1310        .section("Fakenvapi"),
1311        super::ToolSettingSpec::labeled_select(
1312            "ini_overrides.FSR.UpscalerIndex",
1313            "FSR upscaler backend",
1314            "OptiScaler [FSR] UpscalerIndex override.",
1315            &[
1316                ("auto", "Auto"),
1317                ("0", "0 - FSR 4.0.2"),
1318                ("1", "1 - FSR 3.1.5"),
1319                ("2", "2 - FSR 2.3.4"),
1320            ],
1321        )
1322        .section("Basic"),
1323        super::ToolSettingSpec::labeled_select(
1324            "ini_overrides.FSR.FGIndex",
1325            "FSR frame generation backend",
1326            "OptiScaler [FSR] FGIndex override.",
1327            &[
1328                ("auto", "Auto"),
1329                ("0", "0 - FSR 4.0.0"),
1330                ("1", "1 - FSR 3.1.6"),
1331            ],
1332        )
1333        .section("Basic"),
1334        super::ToolSettingSpec::labeled_select(
1335            "ini_overrides.fakenvapi.force_reflex",
1336            "Force Reflex",
1337            "fakenvapi force_reflex override.",
1338            &[
1339                ("0", "0 - Follow in-game setting"),
1340                ("1", "1 - Force disable"),
1341                ("2", "2 - Force enable"),
1342            ],
1343        )
1344        .section("Fakenvapi"),
1345        super::ToolSettingSpec::tri_state_bool(
1346            "ini_overrides.fakenvapi.force_latencyflex",
1347            "Force LatencyFlex",
1348            "fakenvapi force_latencyflex override.",
1349        )
1350        .section("Fakenvapi"),
1351        super::ToolSettingSpec::labeled_select(
1352            "ini_overrides.fakenvapi.latencyflex_mode",
1353            "LatencyFlex mode",
1354            "fakenvapi latencyflex_mode override.",
1355            &[
1356                ("0", "0 - Conservative"),
1357                ("1", "1 - Aggressive"),
1358                ("2", "2 - Use Reflex frame IDs"),
1359            ],
1360        )
1361        .section("Fakenvapi"),
1362        super::ToolSettingSpec::tri_state_bool(
1363            "ini_overrides.fakenvapi.enable_trace_logs",
1364            "Trace logs",
1365            "fakenvapi enable_trace_logs override.",
1366        )
1367        .section("Fakenvapi"),
1368    ]
1369}
1370
1371fn optiscaler_ini_specs(config: &ToolConfig) -> Vec<super::ToolSettingSpec> {
1372    let Some(source_dir) = resolve_source_dir(config) else {
1373        return Vec::new();
1374    };
1375    let ini = source_dir.join("OptiScaler.ini");
1376    let Ok(content) = std::fs::read_to_string(ini) else {
1377        return Vec::new();
1378    };
1379    parse_ini_keys(&content)
1380        .into_iter()
1381        .filter(|key| {
1382            !matches!(
1383                key.as_str(),
1384                "FSR.Fsr4Update" | "Spoofing.Dxgi" | "Plugins.LoadAsiPlugins"
1385            )
1386        })
1387        .take(24)
1388        .map(|key| {
1389            let leaked: &'static str = Box::leak(format!("ini_overrides.{key}").into_boxed_str());
1390            let label: &'static str = Box::leak(key.into_boxed_str());
1391            infer_optiscaler_ini_spec(leaked, label)
1392                .section("Advanced")
1393                .advanced()
1394        })
1395        .collect()
1396}
1397
1398fn infer_optiscaler_ini_spec(key: &'static str, label: &'static str) -> super::ToolSettingSpec {
1399    let lower = label.to_ascii_lowercase();
1400    if matches!(
1401        lower.as_str(),
1402        "fsr4update"
1403            | "dxgi"
1404            | "loadasiplugins"
1405            | "overridenvapidll"
1406            | "force_latencyflex"
1407            | "enable_trace_logs"
1408    ) {
1409        return super::ToolSettingSpec::tri_state_bool(key, label, "OptiScaler.ini override.");
1410    }
1411    if lower.ends_with("scale")
1412        || lower.ends_with("alpha")
1413        || lower.contains("sharpness")
1414        || lower.contains("bias")
1415    {
1416        return super::ToolSettingSpec::number(
1417            key,
1418            label,
1419            "OptiScaler.ini override.",
1420            0.0,
1421            10.0,
1422            0.05,
1423        );
1424    }
1425    super::ToolSettingSpec::text(key, label, "OptiScaler.ini override.")
1426}
1427
1428pub fn extract_optiscaler_archive_flat(archive_path: &Path, dest_dir: &Path) -> Result<()> {
1429    let lower = archive_path.to_string_lossy().to_ascii_lowercase();
1430    if lower.ends_with(".zip") {
1431        return extract_zip_flat(archive_path, dest_dir);
1432    }
1433    if lower.ends_with(".7z") {
1434        return extract_7z_flat(archive_path, dest_dir);
1435    }
1436    anyhow::bail!(
1437        "unsupported OptiScaler archive type: {}",
1438        archive_path.display()
1439    )
1440}
1441
1442fn extract_zip_flat(archive_path: &Path, dest_dir: &Path) -> Result<()> {
1443    let file = std::fs::File::open(archive_path)
1444        .with_context(|| format!("failed to open {}", archive_path.display()))?;
1445    let mut archive = zip::ZipArchive::new(file)?;
1446    let mut copied = 0usize;
1447    let mut copied_optiscaler = false;
1448    for index in 0..archive.len() {
1449        let mut entry = archive.by_index(index)?;
1450        if entry.is_dir() {
1451            continue;
1452        }
1453        let Some(enclosed) = entry.enclosed_name() else {
1454            continue;
1455        };
1456        let Some(name) = enclosed.file_name().map(ToOwned::to_owned) else {
1457            continue;
1458        };
1459        let Some(name_str) = name.to_str() else {
1460            continue;
1461        };
1462        let lower = name_str.to_ascii_lowercase();
1463        if !is_optiscaler_payload_file(&lower) {
1464            continue;
1465        }
1466        let out = optiscaler_payload_dest(dest_dir, &enclosed, &name);
1467        if let Some(parent) = out.parent() {
1468            std::fs::create_dir_all(parent)
1469                .with_context(|| format!("failed to create {}", parent.display()))?;
1470        }
1471        let mut output = std::fs::File::create(&out)
1472            .with_context(|| format!("failed to create {}", out.display()))?;
1473        std::io::copy(&mut entry, &mut output)?;
1474        copied += 1;
1475        copied_optiscaler |= lower == "optiscaler.dll";
1476    }
1477    if copied == 0 || !copied_optiscaler {
1478        anyhow::bail!("archive did not contain OptiScaler.dll");
1479    }
1480    Ok(())
1481}
1482
1483fn extract_7z_flat(archive_path: &Path, dest_dir: &Path) -> Result<()> {
1484    let tmp_base = modde_core::paths::modde_data_dir().join("tmp");
1485    std::fs::create_dir_all(&tmp_base)
1486        .with_context(|| format!("failed to create {}", tmp_base.display()))?;
1487    let extract_dir = tmp_base.join(format!(
1488        "modde-optiscaler-{}-{}",
1489        std::process::id(),
1490        std::time::SystemTime::now()
1491            .duration_since(std::time::UNIX_EPOCH)?
1492            .as_nanos()
1493    ));
1494    std::fs::create_dir_all(&extract_dir)
1495        .with_context(|| format!("failed to create {}", extract_dir.display()))?;
1496    let out_arg = format!("-o{}", extract_dir.display());
1497    let archive_arg = archive_path.to_string_lossy().to_string();
1498    let mut extracted = false;
1499    for bin in ["7zz", "7z"] {
1500        let status = Command::new(bin)
1501            .args(["x", "-y", &out_arg, &archive_arg])
1502            .stdout(Stdio::null())
1503            .stderr(Stdio::null())
1504            .status();
1505        if status.is_ok_and(|status| status.success()) {
1506            extracted = true;
1507            break;
1508        }
1509    }
1510    if !extracted {
1511        anyhow::bail!(
1512            "failed to extract {} (tried 7zz and 7z)",
1513            archive_path.display()
1514        );
1515    }
1516    let result = copy_optiscaler_payload_flat(&extract_dir, dest_dir);
1517    let _ = std::fs::remove_dir_all(&extract_dir);
1518    result
1519}
1520
1521fn copy_optiscaler_payload_flat(source_root: &Path, dest_dir: &Path) -> Result<()> {
1522    let mut stack = vec![source_root.to_path_buf()];
1523    let mut copied = 0usize;
1524    let mut copied_optiscaler = false;
1525    while let Some(dir) = stack.pop() {
1526        for entry in std::fs::read_dir(&dir)
1527            .with_context(|| format!("failed to read directory: {}", dir.display()))?
1528        {
1529            let entry = entry?;
1530            let path = entry.path();
1531            let metadata = path.symlink_metadata()?;
1532            if metadata.file_type().is_symlink() {
1533                continue;
1534            }
1535            if metadata.is_dir() {
1536                stack.push(path);
1537                continue;
1538            }
1539            let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
1540                continue;
1541            };
1542            let lower = name.to_ascii_lowercase();
1543            if is_optiscaler_payload_file(&lower) {
1544                let relative = path.strip_prefix(source_root).unwrap_or(&path);
1545                let dest = optiscaler_payload_dest(dest_dir, relative, std::ffi::OsStr::new(name));
1546                if let Some(parent) = dest.parent() {
1547                    std::fs::create_dir_all(parent)
1548                        .with_context(|| format!("failed to create {}", parent.display()))?;
1549                }
1550                std::fs::copy(&path, &dest)
1551                    .with_context(|| format!("failed to copy {}", dest.display()))?;
1552                copied += 1;
1553                copied_optiscaler |= lower == "optiscaler.dll";
1554            }
1555        }
1556    }
1557    if copied == 0 || !copied_optiscaler {
1558        anyhow::bail!("archive did not contain OptiScaler.dll");
1559    }
1560    Ok(())
1561}
1562
1563fn is_optiscaler_payload_file(lower_name: &str) -> bool {
1564    matches!(
1565        lower_name,
1566        "optiscaler.dll"
1567            | "optiscaler.ini"
1568            | "fakenvapi.dll"
1569            | "nvngx-wrapper.dll"
1570            | "optipatcher.asi"
1571    ) || lower_name.ends_with(".dll")
1572}
1573
1574fn optiscaler_payload_dest(
1575    dest_dir: &Path,
1576    relative_path: &Path,
1577    file_name: &std::ffi::OsStr,
1578) -> PathBuf {
1579    if file_name
1580        .to_str()
1581        .is_some_and(|name| name.eq_ignore_ascii_case(FSR4_DLL_NAME))
1582    {
1583        for component in relative_path.components() {
1584            let value = component.as_os_str().to_string_lossy();
1585            if value.eq_ignore_ascii_case(FSR4_LATEST_DIR) {
1586                return dest_dir.join(FSR4_LATEST_DIR).join(FSR4_DLL_NAME);
1587            }
1588            if value.eq_ignore_ascii_case(FSR4_INT8_DIR) {
1589                return dest_dir.join(FSR4_INT8_DIR).join(FSR4_DLL_NAME);
1590            }
1591        }
1592    }
1593    if file_name
1594        .to_str()
1595        .is_some_and(|name| name.eq_ignore_ascii_case(OPTIPATCHER_ASSET))
1596    {
1597        return dest_dir.join("plugins").join(OPTIPATCHER_ASSET);
1598    }
1599    dest_dir.join(file_name)
1600}
1601
1602#[must_use]
1603pub fn parse_optiscaler_ini(content: &str) -> BTreeMap<String, String> {
1604    let mut section = String::new();
1605    let mut values = BTreeMap::new();
1606    for line in content.lines() {
1607        let trimmed = line.trim();
1608        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
1609            continue;
1610        }
1611        if trimmed.starts_with('[') && trimmed.ends_with(']') {
1612            section = trimmed[1..trimmed.len() - 1].trim().to_string();
1613            continue;
1614        }
1615        if let Some((key, value)) = trimmed.split_once('=') {
1616            let key = key.trim();
1617            if !key.is_empty() {
1618                let path = if section.is_empty() {
1619                    key.to_string()
1620                } else {
1621                    format!("{section}.{key}")
1622                };
1623                values.insert(path, value.trim().to_string());
1624            }
1625        }
1626    }
1627    values
1628}
1629
1630fn parse_ini_keys(content: &str) -> Vec<String> {
1631    let mut section = String::new();
1632    let mut keys = Vec::new();
1633    for line in content.lines() {
1634        let trimmed = line.trim();
1635        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
1636            continue;
1637        }
1638        if trimmed.starts_with('[') && trimmed.ends_with(']') {
1639            section = trimmed[1..trimmed.len() - 1].trim().to_string();
1640            continue;
1641        }
1642        if let Some((key, _)) = trimmed.split_once('=') {
1643            let key = key.trim();
1644            if !key.is_empty() {
1645                if section.is_empty() {
1646                    keys.push(key.to_string());
1647                } else {
1648                    keys.push(format!("{section}.{key}"));
1649                }
1650            }
1651        }
1652    }
1653    keys
1654}
1655
1656fn apply_ini_overrides_with_existing(
1657    src: &Path,
1658    existing: Option<&Path>,
1659    dest: &Path,
1660    config: &ToolConfig,
1661) -> Result<()> {
1662    let content = build_ini_with_overrides(src, existing, config)?;
1663    std::fs::write(dest, content).with_context(|| format!("failed to write {}", dest.display()))?;
1664    Ok(())
1665}
1666
1667fn build_ini_with_overrides(
1668    src: &Path,
1669    existing: Option<&Path>,
1670    config: &ToolConfig,
1671) -> Result<String> {
1672    let mut content = std::fs::read_to_string(src)
1673        .with_context(|| format!("failed to read {}", src.display()))?;
1674    if let Some(existing) = existing
1675        && let Ok(existing_content) = std::fs::read_to_string(existing)
1676    {
1677        let source_keys: BTreeSet<String> = parse_ini_keys(&content).into_iter().collect();
1678        for (key, value) in parse_optiscaler_ini(&existing_content) {
1679            if source_keys.contains(&key) {
1680                content = set_ini_value(&content, &key, &value);
1681            }
1682        }
1683    }
1684    if let Some(overrides) = config
1685        .settings
1686        .get("ini_overrides")
1687        .and_then(serde_json::Value::as_object)
1688    {
1689        for (path, value) in flatten_ini_overrides(overrides) {
1690            let value = match value {
1691                serde_json::Value::String(value) => value,
1692                other => other.to_string(),
1693            };
1694            content = set_ini_value(&content, path.as_str(), value.as_str());
1695        }
1696    }
1697    for (path, value) in effective_optiscaler_ini_overrides(config) {
1698        content = set_ini_value(&content, path, value);
1699    }
1700    Ok(content)
1701}
1702
1703fn effective_optiscaler_ini_overrides(config: &ToolConfig) -> Vec<(&'static str, &'static str)> {
1704    let mut overrides = Vec::new();
1705    overrides.push((
1706        "FSR.Fsr4Update",
1707        match fsr4_variant(config) {
1708            FSR4_VARIANT_INT8_402 => "auto",
1709            _ => "True",
1710        },
1711    ));
1712    overrides.push((
1713        "Spoofing.Dxgi",
1714        if config.get_bool("spoof_dlss") {
1715            "auto"
1716        } else {
1717            "false"
1718        },
1719    ));
1720    overrides.push((
1721        "Plugins.LoadAsiPlugins",
1722        if config.get_bool("enable_optipatcher") {
1723            "true"
1724        } else {
1725            "auto"
1726        },
1727    ));
1728    overrides
1729}
1730
1731#[must_use]
1732pub fn managed_manifest_json(game_dir: &Path, applied: &AppliedFiles) -> serde_json::Value {
1733    let files = applied
1734        .files
1735        .iter()
1736        .map(|rel| {
1737            let abs = game_dir.join(rel);
1738            serde_json::json!({
1739                "path": rel.to_string_lossy().replace('\\', "/"),
1740                "hash": file_hash_hex(&abs).ok(),
1741                "size": abs.metadata().ok().map(|metadata| metadata.len()),
1742            })
1743        })
1744        .collect::<Vec<_>>();
1745    serde_json::json!(files)
1746}
1747
1748#[must_use]
1749pub fn managed_paths_from_config(config: &ToolConfig) -> BTreeSet<String> {
1750    config
1751        .settings
1752        .get("managed_manifest")
1753        .and_then(serde_json::Value::as_array)
1754        .into_iter()
1755        .flatten()
1756        .filter_map(|entry| entry.get("path").and_then(serde_json::Value::as_str))
1757        .map(normalize_rel_path)
1758        .collect()
1759}
1760
1761/// Apply game-specific `OptiScaler` defaults from community compatibility data.
1762pub fn apply_game_defaults(config: &mut ToolConfig, context: Option<&ToolGameContext>) {
1763    let Some(context) = context else {
1764        return;
1765    };
1766    let Some(profile) = selected_or_default_profile(&context.game_id, config) else {
1767        return;
1768    };
1769    apply_optiscaler_profile_metadata(config, profile);
1770}
1771
1772/// Apply a community-tested `OptiScaler` profile to a config.
1773pub fn apply_profile_by_id(config: &mut ToolConfig, game_id: &str, profile_id: &str) -> bool {
1774    if profile_id == CUSTOM_OPTISCALER_PROFILE {
1775        apply_custom_profile(config);
1776        return true;
1777    }
1778    let Some(profile) = resolve_optiscaler_profiles(game_id)
1779        .iter()
1780        .find(|profile| profile.id == profile_id)
1781    else {
1782        return false;
1783    };
1784    apply_optiscaler_profile_metadata(config, profile);
1785    true
1786}
1787
1788fn selected_or_default_profile(
1789    game_id: &str,
1790    config: &ToolConfig,
1791) -> Option<&'static OptiScalerProfile> {
1792    if config.get_str("optiscaler_profile") == Some(CUSTOM_OPTISCALER_PROFILE) {
1793        return None;
1794    }
1795    config
1796        .get_str("optiscaler_profile")
1797        .and_then(|selected| {
1798            resolve_optiscaler_profiles(game_id)
1799                .iter()
1800                .find(|profile| profile.id == selected)
1801        })
1802        .or_else(|| default_optiscaler_profile(game_id))
1803}
1804
1805fn apply_custom_profile(config: &mut ToolConfig) {
1806    config.set(
1807        "optiscaler_profile",
1808        serde_json::json!(CUSTOM_OPTISCALER_PROFILE),
1809    );
1810    config.set(
1811        "optiscaler_profile_name",
1812        serde_json::json!("Custom / no community profile"),
1813    );
1814    config.set("optiscaler_profile_source_url", serde_json::json!(""));
1815    config.set("tested_optiscaler_version", serde_json::json!(""));
1816    config.set(
1817        "optiscaler_profile_notes",
1818        serde_json::json!("Community profile guidance is not applied."),
1819    );
1820}
1821
1822fn apply_optiscaler_profile_metadata(config: &mut ToolConfig, profile: &OptiScalerProfile) {
1823    config.set("optiscaler_profile", serde_json::json!(profile.id));
1824    config.set("optiscaler_profile_name", serde_json::json!(profile.name));
1825    config.set(
1826        "optiscaler_profile_source_url",
1827        serde_json::json!(profile.source_url),
1828    );
1829    config.set(
1830        "tested_optiscaler_version",
1831        serde_json::json!(profile.tested_optiscaler_version),
1832    );
1833    config.set("optiscaler_profile_notes", serde_json::json!(profile.notes));
1834}
1835
1836fn apply_optiscaler_release_selection(config: &mut ToolConfig, normalized_tag: &str, asset: &str) {
1837    if let Some(channel) = optiscaler_goverlay_channel_for_tag(normalized_tag) {
1838        config.set(
1839            "source_mode",
1840            serde_json::json!(OPTISCALER_SOURCE_GOVERLAY_BUILDS),
1841        );
1842        config.set("goverlay_channel", serde_json::json!(channel));
1843    } else {
1844        config.set("source_mode", serde_json::json!(OPTISCALER_SOURCE_OFFICIAL));
1845    }
1846    config.set("release_tag", serde_json::json!(normalized_tag));
1847    config.set("release_asset", serde_json::json!(asset));
1848}
1849
1850pub fn scan_optiscaler_install(
1851    game_id: &str,
1852    game_dir: &Path,
1853    managed_paths: &BTreeSet<String>,
1854) -> Result<OptiScalerInstallState> {
1855    let executable_dir = crate::resolve_game_plugin(game_id)
1856        .map(|plugin| plugin.executable_dir(game_dir))
1857        .unwrap_or_else(|| game_dir.to_path_buf());
1858    let executable_managed_paths =
1859        managed_paths_for_executable_dir(game_dir, &executable_dir, managed_paths);
1860    let mut state = scan_optiscaler_install_in_dir(&executable_dir, &executable_managed_paths)?;
1861    state.latest_backup = latest_optiscaler_backup(Some(game_id));
1862    Ok(state)
1863}
1864
1865fn managed_paths_for_executable_dir(
1866    game_dir: &Path,
1867    executable_dir: &Path,
1868    managed_paths: &BTreeSet<String>,
1869) -> BTreeSet<String> {
1870    let mut out = managed_paths.clone();
1871    let Ok(executable_rel) = executable_dir.strip_prefix(game_dir) else {
1872        return out;
1873    };
1874    let executable_prefix = normalize_rel_path(executable_rel.to_string_lossy());
1875    let executable_prefix = executable_prefix.trim_end_matches('/');
1876    if executable_prefix.is_empty() {
1877        return out;
1878    }
1879    let prefix = format!("{executable_prefix}/");
1880    for path in managed_paths {
1881        if let Some(stripped) = path.strip_prefix(&prefix) {
1882            out.insert(stripped.to_string());
1883        }
1884    }
1885    out
1886}
1887
1888pub fn scan_optiscaler_install_in_dir(
1889    executable_dir: &Path,
1890    managed_paths: &BTreeSet<String>,
1891) -> Result<OptiScalerInstallState> {
1892    let mut recognized_files = Vec::new();
1893    let mut proxy_dlls = Vec::new();
1894    let mut companion_files = Vec::new();
1895    let config_path = executable_dir
1896        .join("OptiScaler.ini")
1897        .exists()
1898        .then(|| executable_dir.join("OptiScaler.ini"));
1899
1900    for &name in OPTISCALER_PROXY_DLLS {
1901        let path = executable_dir.join(name);
1902        if path.is_file() && is_likely_optiscaler_proxy(&path, name) {
1903            proxy_dlls.push(name.to_string());
1904            push_detected_file(executable_dir, &path, managed_paths, &mut recognized_files);
1905        }
1906    }
1907    if let Some(path) = &config_path {
1908        push_detected_file(executable_dir, path, managed_paths, &mut recognized_files);
1909    }
1910    for entry in std::fs::read_dir(executable_dir)
1911        .into_iter()
1912        .flatten()
1913        .flatten()
1914    {
1915        let path = entry.path();
1916        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
1917            continue;
1918        };
1919        let lower = name.to_ascii_lowercase();
1920        let is_companion = OPTISCALER_COMPANION_FILES
1921            .iter()
1922            .any(|known| known.eq_ignore_ascii_case(name))
1923            || lower.starts_with("libxess")
1924            || lower.starts_with("amd_fidelityfx");
1925        if path.is_file() && is_companion {
1926            companion_files.push(path.clone());
1927            push_detected_file(executable_dir, &path, managed_paths, &mut recognized_files);
1928        }
1929        if path.is_dir()
1930            && OPTISCALER_COMPANION_DIRS
1931                .iter()
1932                .any(|known| known.eq_ignore_ascii_case(name))
1933        {
1934            companion_files.push(path.clone());
1935            collect_detected_dir(executable_dir, &path, managed_paths, &mut recognized_files)?;
1936        }
1937    }
1938
1939    recognized_files.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
1940    recognized_files.dedup_by(|a, b| a.rel_path == b.rel_path);
1941    proxy_dlls.sort();
1942    proxy_dlls.dedup();
1943    companion_files.sort();
1944    companion_files.dedup();
1945
1946    let managed_count = recognized_files.iter().filter(|file| file.managed).count();
1947    let status = if proxy_dlls.len() > 1 {
1948        OptiScalerInstallStatus::Conflicted
1949    } else if recognized_files.is_empty() {
1950        OptiScalerInstallStatus::Absent
1951    } else if managed_count == recognized_files.len() {
1952        OptiScalerInstallStatus::Managed
1953    } else if managed_count == 0 {
1954        OptiScalerInstallStatus::Unmanaged
1955    } else {
1956        OptiScalerInstallStatus::PartiallyManaged
1957    };
1958
1959    let ini_settings = config_path
1960        .as_ref()
1961        .and_then(|path| std::fs::read_to_string(path).ok())
1962        .map(|content| parse_optiscaler_ini(&content))
1963        .unwrap_or_default();
1964    let version = identify_optiscaler_version(executable_dir, &recognized_files);
1965    let wine_dll_overrides = proxy_dlls
1966        .iter()
1967        .filter_map(|name| name.strip_suffix(".dll").map(ToOwned::to_owned))
1968        .collect();
1969    let latest_backup = latest_optiscaler_backup(None);
1970
1971    Ok(OptiScalerInstallState {
1972        status,
1973        executable_dir: executable_dir.to_path_buf(),
1974        proxy_dlls,
1975        wine_dll_overrides,
1976        config_path,
1977        ini_settings,
1978        companion_files,
1979        recognized_files,
1980        version,
1981        latest_backup,
1982    })
1983}
1984
1985pub fn backup_optiscaler_install(
1986    game_id: Option<&str>,
1987    state: &OptiScalerInstallState,
1988) -> Result<Option<PathBuf>> {
1989    if state.recognized_files.is_empty() {
1990        return Ok(None);
1991    }
1992    let backup_dir = optiscaler_backup_root(game_id).join(timestamp_slug());
1993    for file in &state.recognized_files {
1994        let src = state.executable_dir.join(&file.rel_path);
1995        let dst = backup_dir.join(&file.rel_path);
1996        if src.is_file() {
1997            if let Some(parent) = dst.parent() {
1998                std::fs::create_dir_all(parent)
1999                    .with_context(|| format!("failed to create {}", parent.display()))?;
2000            }
2001            std::fs::copy(&src, &dst)
2002                .with_context(|| format!("failed to copy {}", dst.display()))?;
2003        }
2004    }
2005    let manifest = serde_json::json!({
2006        "status": state.status.to_string(),
2007        "version": state.version.to_string(),
2008        "executable_dir": state.executable_dir.display().to_string(),
2009        "files": state.recognized_files.iter().map(|file| {
2010            serde_json::json!({
2011                "path": file.rel_path.to_string_lossy().replace('\\', "/"),
2012                "hash": file.hash,
2013                "managed": file.managed,
2014            })
2015        }).collect::<Vec<_>>(),
2016    });
2017    std::fs::create_dir_all(&backup_dir)
2018        .with_context(|| format!("failed to create {}", backup_dir.display()))?;
2019    let manifest_path = backup_dir.join("modde-optiscaler-backup.json");
2020    std::fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?)
2021        .with_context(|| format!("failed to write {}", manifest_path.display()))?;
2022    Ok(Some(backup_dir))
2023}
2024
2025pub fn latest_optiscaler_backup(game_id: Option<&str>) -> Option<PathBuf> {
2026    let root = optiscaler_backup_root(game_id);
2027    let mut entries = std::fs::read_dir(root)
2028        .ok()?
2029        .flatten()
2030        .filter_map(|entry| {
2031            let path = entry.path();
2032            entry.file_type().ok()?.is_dir().then_some(path)
2033        })
2034        .collect::<Vec<_>>();
2035    entries.sort();
2036    entries.pop()
2037}
2038
2039pub fn restore_latest_optiscaler_backup(game_id: &str, game_dir: &Path) -> Result<PathBuf> {
2040    let backup = latest_optiscaler_backup(Some(game_id))
2041        .ok_or_else(|| anyhow::anyhow!("no OptiScaler backup found for {game_id}"))?;
2042    let executable_dir = crate::resolve_game_plugin(game_id)
2043        .map(|plugin| plugin.executable_dir(game_dir))
2044        .unwrap_or_else(|| game_dir.to_path_buf());
2045    restore_dir_contents(&backup, &executable_dir)?;
2046    Ok(backup)
2047}
2048
2049fn optiscaler_backup_root(game_id: Option<&str>) -> PathBuf {
2050    modde_core::paths::modde_data_dir()
2051        .join("tool-backups")
2052        .join("optiscaler")
2053        .join(game_id.unwrap_or("_unknown"))
2054}
2055
2056fn timestamp_slug() -> String {
2057    let secs = std::time::SystemTime::now()
2058        .duration_since(std::time::UNIX_EPOCH)
2059        .map_or(0, |duration| duration.as_secs());
2060    format!("{secs}-{}", std::process::id())
2061}
2062
2063fn is_likely_optiscaler_proxy(path: &Path, name: &str) -> bool {
2064    if name.eq_ignore_ascii_case("OptiScaler.asi") {
2065        return true;
2066    }
2067    let Ok(hash) = file_hash_hex(path) else {
2068        return true;
2069    };
2070    cached_release_dirs().into_iter().any(|dir| {
2071        file_hash_hex(&dir.join("OptiScaler.dll")).ok().as_deref() == Some(hash.as_str())
2072    }) || path.metadata().is_ok_and(|metadata| metadata.len() > 0)
2073}
2074
2075fn identify_optiscaler_version(
2076    executable_dir: &Path,
2077    recognized_files: &[OptiScalerDetectedFile],
2078) -> OptiScalerVersionIdentity {
2079    let proxy_hashes = recognized_files
2080        .iter()
2081        .filter(|file| {
2082            file.rel_path
2083                .file_name()
2084                .and_then(|name| name.to_str())
2085                .is_some_and(|file_name| {
2086                    OPTISCALER_PROXY_DLLS
2087                        .iter()
2088                        .any(|name| file_name.eq_ignore_ascii_case(name))
2089                })
2090        })
2091        .filter_map(|file| file.hash.as_deref())
2092        .collect::<Vec<_>>();
2093    for dir in cached_release_dirs() {
2094        let Ok(hash) = file_hash_hex(&dir.join("OptiScaler.dll")) else {
2095            continue;
2096        };
2097        if proxy_hashes.iter().any(|candidate| *candidate == hash)
2098            && let Some(tag) = dir.file_name().and_then(|name| name.to_str())
2099        {
2100            return OptiScalerVersionIdentity::CachedRelease(tag.to_string());
2101        }
2102    }
2103    let version_file = executable_dir.join("version.txt");
2104    if let Ok(version) = std::fs::read_to_string(version_file) {
2105        let trimmed = version.trim();
2106        if !trimmed.is_empty() {
2107            return OptiScalerVersionIdentity::FileMetadata(trimmed.to_string());
2108        }
2109    }
2110    if let Some(hash) = proxy_hashes.first() {
2111        return OptiScalerVersionIdentity::ContentHash((*hash).to_string());
2112    }
2113    OptiScalerVersionIdentity::Unknown
2114}
2115
2116fn cached_release_dirs() -> Vec<PathBuf> {
2117    let root = modde_core::paths::modde_data_dir()
2118        .join("tools")
2119        .join("optiscaler");
2120    std::fs::read_dir(root)
2121        .into_iter()
2122        .flatten()
2123        .flatten()
2124        .filter_map(|entry| entry.file_type().ok()?.is_dir().then_some(entry.path()))
2125        .collect()
2126}
2127
2128fn push_detected_file(
2129    root: &Path,
2130    path: &Path,
2131    managed_paths: &BTreeSet<String>,
2132    out: &mut Vec<OptiScalerDetectedFile>,
2133) {
2134    let rel = path.strip_prefix(root).unwrap_or(path).to_path_buf();
2135    let normalized = normalize_rel_path(rel.to_string_lossy());
2136    out.push(OptiScalerDetectedFile {
2137        rel_path: rel,
2138        hash: file_hash_hex(path).ok(),
2139        managed: managed_paths.contains(&normalized),
2140    });
2141}
2142
2143fn collect_detected_dir(
2144    root: &Path,
2145    dir: &Path,
2146    managed_paths: &BTreeSet<String>,
2147    out: &mut Vec<OptiScalerDetectedFile>,
2148) -> Result<()> {
2149    for entry in std::fs::read_dir(dir)
2150        .with_context(|| format!("failed to read directory: {}", dir.display()))?
2151    {
2152        let entry = entry?;
2153        let path = entry.path();
2154        let metadata = path.symlink_metadata()?;
2155        if metadata.file_type().is_symlink() {
2156            continue;
2157        }
2158        if metadata.is_dir() {
2159            collect_detected_dir(root, &path, managed_paths, out)?;
2160        } else if metadata.is_file() {
2161            push_detected_file(root, &path, managed_paths, out);
2162        }
2163    }
2164    Ok(())
2165}
2166
2167fn collect_relative_files(game_dir: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
2168    for entry in std::fs::read_dir(dir)
2169        .with_context(|| format!("failed to read directory: {}", dir.display()))?
2170    {
2171        let entry = entry?;
2172        let path = entry.path();
2173        if path.is_dir() {
2174            collect_relative_files(game_dir, &path, out)?;
2175        } else if path.is_file() {
2176            out.push(relative_to_game(game_dir, &path)?);
2177        }
2178    }
2179    Ok(())
2180}
2181
2182fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
2183    std::fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
2184    for entry in std::fs::read_dir(src)
2185        .with_context(|| format!("failed to read directory: {}", src.display()))?
2186    {
2187        let entry = entry?;
2188        let src_path = entry.path();
2189        let dst_path = dst.join(entry.file_name());
2190        if src_path.is_dir() {
2191            copy_dir_recursive(&src_path, &dst_path)?;
2192        } else if src_path.is_file() {
2193            std::fs::copy(&src_path, &dst_path)
2194                .with_context(|| format!("failed to copy {}", dst_path.display()))?;
2195        }
2196    }
2197    Ok(())
2198}
2199
2200fn restore_dir_contents(src: &Path, dst: &Path) -> Result<()> {
2201    for entry in std::fs::read_dir(src)
2202        .with_context(|| format!("failed to read directory: {}", src.display()))?
2203    {
2204        let entry = entry?;
2205        if entry.file_name() == "modde-optiscaler-backup.json" {
2206            continue;
2207        }
2208        let src_path = entry.path();
2209        let dst_path = dst.join(entry.file_name());
2210        if src_path.is_dir() {
2211            restore_dir_contents(&src_path, &dst_path)?;
2212        } else if src_path.is_file() {
2213            if let Some(parent) = dst_path.parent() {
2214                std::fs::create_dir_all(parent)
2215                    .with_context(|| format!("failed to create {}", parent.display()))?;
2216            }
2217            std::fs::copy(&src_path, &dst_path)
2218                .with_context(|| format!("failed to copy {}", dst_path.display()))?;
2219        }
2220    }
2221    Ok(())
2222}
2223
2224fn normalize_rel_path(path: impl AsRef<str>) -> String {
2225    path.as_ref().replace('\\', "/").to_ascii_lowercase()
2226}
2227
2228fn file_hash_hex(path: &Path) -> Result<String> {
2229    let mut file =
2230        std::fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
2231    let mut hasher = Xxh64::new(0);
2232    let mut buf = [0_u8; 8192];
2233    loop {
2234        let read = file.read(&mut buf)?;
2235        if read == 0 {
2236            break;
2237        }
2238        hasher.update(&buf[..read]);
2239    }
2240    Ok(format!("{:016x}", hasher.digest()))
2241}
2242
2243fn optiscaler_config_reset_reason(
2244    existing: &OptiScalerInstallState,
2245    new_ini: &Path,
2246    config: &ToolConfig,
2247) -> Option<String> {
2248    if config.get_bool("force_config_reset") {
2249        return Some("forced by setting".to_string());
2250    }
2251    let Ok(new_content) = std::fs::read_to_string(new_ini) else {
2252        return None;
2253    };
2254    let new_keys: BTreeSet<String> = parse_ini_keys(&new_content).into_iter().collect();
2255    let old_unknown = existing
2256        .ini_settings
2257        .keys()
2258        .any(|key| !new_keys.contains(key));
2259    old_unknown.then_some("schema mismatch".to_string())
2260}
2261
2262fn flatten_ini_overrides(
2263    overrides: &serde_json::Map<String, serde_json::Value>,
2264) -> Vec<(String, serde_json::Value)> {
2265    fn walk(prefix: &str, value: &serde_json::Value, out: &mut Vec<(String, serde_json::Value)>) {
2266        if let serde_json::Value::Object(map) = value {
2267            for (key, child) in map {
2268                let path = if prefix.is_empty() {
2269                    key.clone()
2270                } else {
2271                    format!("{prefix}.{key}")
2272                };
2273                walk(&path, child, out);
2274            }
2275        } else {
2276            out.push((prefix.to_string(), value.clone()));
2277        }
2278    }
2279
2280    let mut out = Vec::new();
2281    for (key, value) in overrides {
2282        walk(key, value, &mut out);
2283    }
2284    out
2285}
2286
2287fn set_ini_value(content: &str, path: &str, value: &str) -> String {
2288    let Some((target_section, target_key)) = path.rsplit_once('.') else {
2289        tracing::warn!(
2290            ini_key = path,
2291            "optiscaler: ini override key has no section prefix; expected 'section.key'"
2292        );
2293        return content.to_string();
2294    };
2295    let mut current_section = "";
2296    let mut updated = false;
2297    let mut lines = Vec::new();
2298
2299    for line in content.lines() {
2300        let trimmed = line.trim();
2301        if trimmed.starts_with('[') && trimmed.ends_with(']') {
2302            if !updated && !target_section.is_empty() && current_section == target_section {
2303                lines.push(format!("{target_key}={value}"));
2304                updated = true;
2305            }
2306            current_section = trimmed[1..trimmed.len() - 1].trim();
2307        }
2308
2309        if current_section == target_section
2310            && let Some((key, _)) = trimmed.split_once('=')
2311            && key.trim() == target_key
2312        {
2313            lines.push(format!("{target_key}={value}"));
2314            updated = true;
2315            continue;
2316        }
2317        lines.push(line.to_string());
2318    }
2319
2320    if !updated && !target_section.is_empty() && current_section == target_section {
2321        lines.push(format!("{target_key}={value}"));
2322        updated = true;
2323    }
2324
2325    if !updated {
2326        if !target_section.is_empty() {
2327            lines.push(format!("[{target_section}]"));
2328        }
2329        lines.push(format!("{target_key}={value}"));
2330    }
2331
2332    lines.join("\n") + "\n"
2333}
2334
2335/// Build fgmod DLL restore commands for the launch wrapper.
2336///
2337/// Scans the staging mods directory for DLLs that fgmod will delete at launch,
2338/// and returns `(source, destination)` pairs for the wrapper to restore them.
2339#[must_use]
2340pub fn fgmod_restore_commands(game_dir: &Path, staging_dir: &Path) -> Vec<(String, String)> {
2341    let exe_dir = crate::resolve_game_plugin("cyberpunk2077")
2342        .map(|plugin| plugin.executable_dir(game_dir))
2343        .unwrap_or_else(|| game_dir.join("bin/x64"));
2344    fgmod_restore_commands_for_executable_dir(game_dir, staging_dir, &exe_dir)
2345}
2346
2347/// Build fgmod DLL restore commands for a known executable directory.
2348#[must_use]
2349pub fn fgmod_restore_commands_for_executable_dir(
2350    _game_dir: &Path,
2351    staging_dir: &Path,
2352    exe_dir: &Path,
2353) -> Vec<(String, String)> {
2354    let mut restore = Vec::new();
2355
2356    let mods_dir = staging_dir.join("mods");
2357    if !mods_dir.exists() {
2358        return restore;
2359    }
2360
2361    for entry in std::fs::read_dir(&mods_dir).into_iter().flatten().flatten() {
2362        if !entry.file_type().is_ok_and(|t| t.is_dir()) {
2363            continue;
2364        }
2365
2366        let mod_bin_x64 = entry.path().join("bin/x64");
2367        if !mod_bin_x64.exists() {
2368            continue;
2369        }
2370
2371        for dll_entry in std::fs::read_dir(&mod_bin_x64)
2372            .into_iter()
2373            .flatten()
2374            .flatten()
2375        {
2376            let dll_name = dll_entry.file_name().to_string_lossy().to_lowercase();
2377            if FGMOD_DELETED_DLLS
2378                .iter()
2379                .any(|d| d.to_lowercase() == dll_name)
2380            {
2381                let src = dll_entry.path();
2382                let dest = exe_dir.join(&*dll_name);
2383                restore.push((
2384                    src.to_string_lossy().to_string(),
2385                    dest.to_string_lossy().to_string(),
2386                ));
2387            }
2388        }
2389    }
2390
2391    restore
2392}
2393
2394/// Shim for `dirs::data_dir()` / `dirs::home_dir()` — we use a minimal
2395/// vendored version to avoid adding the full `dirs` crate.
2396mod dirs {
2397    use std::path::PathBuf;
2398
2399    pub fn data_dir() -> Option<PathBuf> {
2400        std::env::var_os("XDG_DATA_HOME")
2401            .map(PathBuf::from)
2402            .or_else(|| home_dir().map(|h| h.join(".local/share")))
2403    }
2404
2405    pub fn home_dir() -> Option<PathBuf> {
2406        std::env::var_os("HOME").map(PathBuf::from)
2407    }
2408}
2409
2410#[cfg(test)]
2411mod tests {
2412    use std::collections::{BTreeMap, BTreeSet};
2413    use std::path::PathBuf;
2414
2415    use crate::tools::ToolSettingKind;
2416
2417    use super::*;
2418
2419    #[test]
2420    fn parse_optiscaler_ini_preserves_section_paths() {
2421        let parsed = parse_optiscaler_ini(
2422            r"
2423            ; comment
2424            [OptiScaler]
2425            Dxgi=auto
2426            LoadAsiPlugins=true
2427            [Menu]
2428            Scale=1.25
2429            ",
2430        );
2431        assert_eq!(parsed.get("OptiScaler.Dxgi"), Some(&"auto".to_string()));
2432        assert_eq!(
2433            parsed.get("OptiScaler.LoadAsiPlugins"),
2434            Some(&"true".to_string())
2435        );
2436        assert_eq!(parsed.get("Menu.Scale"), Some(&"1.25".to_string()));
2437    }
2438
2439    #[test]
2440    fn preview_reports_changed_when_proxy_or_ini_differ() {
2441        let source = tempfile::tempdir().expect("source");
2442        let game = tempfile::tempdir().expect("game");
2443        std::fs::write(source.path().join("OptiScaler.dll"), b"new dll").expect("source dll");
2444        std::fs::write(
2445            source.path().join("OptiScaler.ini"),
2446            "[FSR]\nFGIndex=auto\n",
2447        )
2448        .expect("source ini");
2449        std::fs::write(game.path().join("dxgi.dll"), b"old dll").expect("dest dll");
2450        std::fs::write(game.path().join("OptiScaler.ini"), "[FSR]\nFGIndex=1\n").expect("dest ini");
2451        let mut config = OptiScaler.default_config();
2452        config.set("source_mode", serde_json::json!("local_dir"));
2453        config.set(
2454            "local_source_dir",
2455            serde_json::json!(source.path().display().to_string()),
2456        );
2457        config.set(
2458            "ini_overrides",
2459            serde_json::json!({ "FSR": { "FGIndex": "2" } }),
2460        );
2461
2462        let preview = OptiScaler
2463            .preview_apply_for(game.path(), None, &config)
2464            .expect("preview");
2465
2466        assert!(preview.changed_files.contains(&PathBuf::from("dxgi.dll")));
2467        assert!(
2468            preview
2469                .changed_files
2470                .contains(&PathBuf::from("OptiScaler.ini"))
2471        );
2472        assert!(preview.missing_inputs.is_empty());
2473    }
2474
2475    #[test]
2476    fn preview_reports_unchanged_and_does_not_create_target_dirs() {
2477        let source = tempfile::tempdir().expect("source");
2478        let game = tempfile::tempdir().expect("game");
2479        std::fs::write(source.path().join("OptiScaler.dll"), b"same dll").expect("source dll");
2480        std::fs::write(
2481            source.path().join("OptiScaler.ini"),
2482            "[FSR]\nFGIndex=auto\n",
2483        )
2484        .expect("source ini");
2485        let target = game.path().join("Bin");
2486        std::fs::create_dir(&target).expect("target");
2487        std::fs::write(target.join("dxgi.dll"), b"same dll").expect("dest dll");
2488        std::fs::write(
2489            target.join("OptiScaler.ini"),
2490            "[FSR]\nFGIndex=auto\nFsr4Update=True\n[Spoofing]\nDxgi=false\n[Plugins]\nLoadAsiPlugins=auto\n",
2491        )
2492        .expect("dest ini");
2493        let mut config = OptiScaler.default_config();
2494        config.set("source_mode", serde_json::json!("local_dir"));
2495        config.set(
2496            "local_source_dir",
2497            serde_json::json!(source.path().display().to_string()),
2498        );
2499        config.set("exe_subdir", serde_json::json!("Bin"));
2500
2501        let preview = OptiScaler
2502            .preview_apply_for(game.path(), None, &config)
2503            .expect("preview");
2504
2505        assert!(preview.changed_files.is_empty());
2506        assert!(
2507            preview
2508                .unchanged_files
2509                .contains(&PathBuf::from("Bin/dxgi.dll"))
2510        );
2511        assert!(
2512            preview
2513                .unchanged_files
2514                .contains(&PathBuf::from("Bin/OptiScaler.ini"))
2515        );
2516        assert!(!game.path().join("Other").exists());
2517    }
2518
2519    #[test]
2520    fn preview_does_not_create_missing_target_directory() {
2521        let source = tempfile::tempdir().expect("source");
2522        let game = tempfile::tempdir().expect("game");
2523        std::fs::write(source.path().join("OptiScaler.dll"), b"dll").expect("source dll");
2524        std::fs::write(source.path().join("OptiScaler.ini"), "[FSR]\n").expect("source ini");
2525        let mut config = OptiScaler.default_config();
2526        config.set("source_mode", serde_json::json!("local_dir"));
2527        config.set(
2528            "local_source_dir",
2529            serde_json::json!(source.path().display().to_string()),
2530        );
2531        config.set("exe_subdir", serde_json::json!("MissingBin"));
2532
2533        let preview = OptiScaler
2534            .preview_apply_for(game.path(), None, &config)
2535            .expect("preview");
2536
2537        assert!(preview.has_changes());
2538        assert!(!game.path().join("MissingBin").exists());
2539    }
2540
2541    #[test]
2542    fn goverlay_inspired_settings_expose_friendly_raw_value_selects() {
2543        let specs = OptiScaler.settings_schema();
2544        let shortcut = specs
2545            .iter()
2546            .find(|spec| spec.key == "ini_overrides.Menu.ShortcutKey")
2547            .expect("shortcut spec");
2548        assert_eq!(shortcut.section, "Menu");
2549        assert!(!shortcut.advanced);
2550        let ToolSettingKind::Select { options } = &shortcut.kind else {
2551            panic!("shortcut should be a select");
2552        };
2553        assert!(
2554            options
2555                .iter()
2556                .any(|option| option.value == "INSERT" && option.label == "Insert")
2557        );
2558
2559        let upscaler = specs
2560            .iter()
2561            .find(|spec| spec.key == "ini_overrides.FSR.UpscalerIndex")
2562            .expect("FSR upscaler spec");
2563        assert_eq!(upscaler.section, "Basic");
2564        let ToolSettingKind::Select { options } = &upscaler.kind else {
2565            panic!("FSR upscaler should be a select");
2566        };
2567        assert_eq!(options[0].value, "auto");
2568        assert_eq!(options[0].label, "Auto");
2569        assert!(
2570            options
2571                .iter()
2572                .any(|option| option.value == "0" && option.label == "0 - FSR 4.0.2")
2573        );
2574        assert!(
2575            options
2576                .iter()
2577                .any(|option| option.value == "2" && option.label == "2 - FSR 2.3.4")
2578        );
2579
2580        let fg = specs
2581            .iter()
2582            .find(|spec| spec.key == "ini_overrides.FSR.FGIndex")
2583            .expect("FSR FG spec");
2584        let ToolSettingKind::Select { options } = &fg.kind else {
2585            panic!("FSR FG should be a select");
2586        };
2587        assert!(
2588            options
2589                .iter()
2590                .any(|option| option.value == "1" && option.label == "1 - FSR 3.1.6")
2591        );
2592    }
2593
2594    #[test]
2595    fn goverlay_inspired_settings_mark_only_risky_entries_advanced() {
2596        let specs = OptiScaler.settings_schema();
2597        let spoof = specs
2598            .iter()
2599            .find(|spec| spec.key == "spoof_dlss")
2600            .expect("spoof fallback spec");
2601        assert_eq!(spoof.label, "Spoof DLSS fallback");
2602        assert_eq!(spoof.section, "Basic");
2603        assert!(!spoof.advanced);
2604
2605        let optipatcher = specs
2606            .iter()
2607            .find(|spec| spec.key == "enable_optipatcher")
2608            .expect("OptiPatcher spec");
2609        assert_eq!(optipatcher.section, "Basic");
2610        assert!(!optipatcher.advanced);
2611
2612        assert!(
2613            !specs
2614                .iter()
2615                .any(|spec| spec.key == "ini_overrides.Plugins.LoadAsiPlugins")
2616        );
2617    }
2618
2619    #[test]
2620    fn emulate_fp8_schema_is_read_only_for_int8_variant() {
2621        let mut config = OptiScaler.default_config();
2622        config.set("fsr4_variant", serde_json::json!(FSR4_VARIANT_INT8_402));
2623
2624        let specs = OptiScaler.settings_schema_for(None, &config);
2625        let emulate = specs
2626            .iter()
2627            .find(|spec| spec.key == "emulate_fp8")
2628            .expect("emulate fp8 spec");
2629
2630        assert_eq!(emulate.section, "Basic");
2631        assert!(matches!(emulate.kind, ToolSettingKind::ReadOnly));
2632    }
2633
2634    #[test]
2635    fn goverlay_channel_is_only_exposed_for_goverlay_build_source() {
2636        let mut config = OptiScaler.default_config();
2637        config.set("source_mode", serde_json::json!("github_release"));
2638        let specs = OptiScaler.settings_schema_for(None, &config);
2639        assert!(!specs.iter().any(|spec| spec.key == "goverlay_channel"));
2640
2641        config.set("source_mode", serde_json::json!("goverlay_builds"));
2642        let specs = OptiScaler.settings_schema_for(None, &config);
2643        assert!(specs.iter().any(|spec| spec.key == "goverlay_channel"));
2644    }
2645
2646    #[test]
2647    fn fsr_backend_overrides_write_raw_ini_values() {
2648        let tmp = tempfile::tempdir().expect("tempdir");
2649        let src = tmp.path().join("OptiScaler.ini");
2650        let dest = tmp.path().join("applied.ini");
2651        std::fs::write(
2652            &src,
2653            "[FSR]\nUpscalerIndex=auto\nFGIndex=auto\n[Menu]\nShortcutKey=auto\n",
2654        )
2655        .expect("source ini");
2656        let mut config = OptiScaler.default_config();
2657        config.set(
2658            "ini_overrides",
2659            serde_json::json!({
2660                "FSR": {
2661                    "UpscalerIndex": "0",
2662                    "FGIndex": "1"
2663                },
2664                "Menu": {
2665                    "ShortcutKey": "INSERT"
2666                }
2667            }),
2668        );
2669
2670        apply_ini_overrides_with_existing(&src, None, &dest, &config).expect("apply overrides");
2671
2672        let parsed = parse_optiscaler_ini(&std::fs::read_to_string(dest).expect("dest ini"));
2673        assert_eq!(parsed.get("FSR.UpscalerIndex"), Some(&"0".to_string()));
2674        assert_eq!(parsed.get("FSR.FGIndex"), Some(&"1".to_string()));
2675        assert_eq!(parsed.get("Menu.ShortcutKey"), Some(&"INSERT".to_string()));
2676    }
2677
2678    #[test]
2679    fn optiscaler_high_level_controls_write_ini_values() {
2680        let tmp = tempfile::tempdir().expect("tempdir");
2681        let src = tmp.path().join("OptiScaler.ini");
2682        let dest = tmp.path().join("applied.ini");
2683        std::fs::write(
2684            &src,
2685            "[FSR]\nFsr4Update=auto\n[Spoofing]\nDxgi=auto\n[Plugins]\nLoadAsiPlugins=auto\n",
2686        )
2687        .expect("source ini");
2688        let mut config = OptiScaler.default_config();
2689        config.set("enable_optipatcher", serde_json::json!(true));
2690        config.set("spoof_dlss", serde_json::json!(false));
2691        config.set("fsr4_variant", serde_json::json!(FSR4_VARIANT_LATEST_FP8));
2692
2693        apply_ini_overrides_with_existing(&src, None, &dest, &config).expect("apply overrides");
2694
2695        let parsed = parse_optiscaler_ini(&std::fs::read_to_string(dest).expect("dest ini"));
2696        assert_eq!(parsed.get("FSR.Fsr4Update"), Some(&"True".to_string()));
2697        assert_eq!(parsed.get("Spoofing.Dxgi"), Some(&"false".to_string()));
2698        assert_eq!(
2699            parsed.get("Plugins.LoadAsiPlugins"),
2700            Some(&"true".to_string())
2701        );
2702    }
2703
2704    #[test]
2705    fn optiscaler_fp8_variant_copies_selected_fsr4_dll() {
2706        let source = tempfile::tempdir().expect("source");
2707        let game = tempfile::tempdir().expect("game");
2708        std::fs::write(source.path().join("OptiScaler.dll"), b"dll").expect("source dll");
2709        std::fs::write(source.path().join("OptiScaler.ini"), "[FSR]\n").expect("source ini");
2710        std::fs::create_dir_all(source.path().join(FSR4_LATEST_DIR)).expect("latest dir");
2711        std::fs::write(
2712            source.path().join(FSR4_LATEST_DIR).join(FSR4_DLL_NAME),
2713            b"fp8",
2714        )
2715        .expect("fp8 dll");
2716        let mut config = OptiScaler.default_config();
2717        config.set("source_mode", serde_json::json!("local_dir"));
2718        config.set(
2719            "local_source_dir",
2720            serde_json::json!(source.path().display().to_string()),
2721        );
2722        config.set("fsr4_variant", serde_json::json!(FSR4_VARIANT_LATEST_FP8));
2723
2724        let applied = OptiScaler.apply(game.path(), &config).expect("apply");
2725
2726        assert_eq!(
2727            std::fs::read(game.path().join(FSR4_DLL_NAME)).expect("deployed FSR4"),
2728            b"fp8"
2729        );
2730        assert!(applied.files.contains(&PathBuf::from(FSR4_DLL_NAME)));
2731    }
2732
2733    #[test]
2734    fn optiscaler_int8_variant_copies_int8_and_ignores_fp8_env() {
2735        let source = tempfile::tempdir().expect("source");
2736        let game = tempfile::tempdir().expect("game");
2737        std::fs::write(source.path().join("OptiScaler.dll"), b"dll").expect("source dll");
2738        std::fs::write(source.path().join("OptiScaler.ini"), "[FSR]\n").expect("source ini");
2739        std::fs::create_dir_all(source.path().join(FSR4_INT8_DIR)).expect("int8 dir");
2740        std::fs::write(
2741            source.path().join(FSR4_INT8_DIR).join(FSR4_DLL_NAME),
2742            b"int8",
2743        )
2744        .expect("int8 dll");
2745        let mut config = OptiScaler.default_config();
2746        config.set("source_mode", serde_json::json!("local_dir"));
2747        config.set(
2748            "local_source_dir",
2749            serde_json::json!(source.path().display().to_string()),
2750        );
2751        config.set("fsr4_variant", serde_json::json!(FSR4_VARIANT_INT8_402));
2752        config.set("emulate_fp8", serde_json::json!(true));
2753
2754        OptiScaler.apply(game.path(), &config).expect("apply");
2755
2756        assert_eq!(
2757            std::fs::read(game.path().join(FSR4_DLL_NAME)).expect("deployed FSR4"),
2758            b"int8"
2759        );
2760        assert!(OptiScaler.env_vars(&config).is_empty());
2761    }
2762
2763    #[test]
2764    fn optiscaler_missing_optipatcher_is_reported_in_preview() {
2765        let source = tempfile::tempdir().expect("source");
2766        let game = tempfile::tempdir().expect("game");
2767        std::fs::write(source.path().join("OptiScaler.dll"), b"dll").expect("source dll");
2768        std::fs::write(source.path().join("OptiScaler.ini"), "[Plugins]\n").expect("source ini");
2769        let mut config = OptiScaler.default_config();
2770        config.set("source_mode", serde_json::json!("local_dir"));
2771        config.set(
2772            "local_source_dir",
2773            serde_json::json!(source.path().display().to_string()),
2774        );
2775        config.set("enable_optipatcher", serde_json::json!(true));
2776
2777        let preview = OptiScaler
2778            .preview_apply_for(game.path(), None, &config)
2779            .expect("preview");
2780
2781        let optipatcher_rel = PathBuf::from("plugins").join(OPTIPATCHER_ASSET);
2782        assert!(
2783            preview
2784                .missing_inputs
2785                .iter()
2786                .any(|input| input.contains("OptiPatcher.asi"))
2787                || preview.changed_files.contains(&optipatcher_rel)
2788                || preview.unchanged_files.contains(&optipatcher_rel)
2789        );
2790    }
2791
2792    #[test]
2793    fn optiscaler_uses_bundled_optipatcher_from_source_plugins() {
2794        let source = tempfile::tempdir().expect("source");
2795        let game = tempfile::tempdir().expect("game");
2796        std::fs::write(source.path().join("OptiScaler.dll"), b"dll").expect("source dll");
2797        std::fs::write(source.path().join("OptiScaler.ini"), "[Plugins]\n").expect("source ini");
2798        std::fs::create_dir_all(source.path().join("plugins")).expect("plugins dir");
2799        std::fs::write(
2800            source.path().join("plugins").join(OPTIPATCHER_ASSET),
2801            b"bundled asi",
2802        )
2803        .expect("source optipatcher");
2804        let mut config = OptiScaler.default_config();
2805        config.set("source_mode", serde_json::json!("local_dir"));
2806        config.set(
2807            "local_source_dir",
2808            serde_json::json!(source.path().display().to_string()),
2809        );
2810        config.set("enable_optipatcher", serde_json::json!(true));
2811
2812        OptiScaler.apply(game.path(), &config).expect("apply");
2813
2814        assert_eq!(
2815            std::fs::read(game.path().join("plugins").join(OPTIPATCHER_ASSET))
2816                .expect("deployed optipatcher"),
2817            b"bundled asi"
2818        );
2819    }
2820
2821    #[test]
2822    fn optiscaler_archive_payload_keeps_optipatcher_in_plugins() {
2823        assert!(is_optiscaler_payload_file("optipatcher.asi"));
2824        assert_eq!(
2825            optiscaler_payload_dest(
2826                Path::new("/cache"),
2827                Path::new("plugins/OptiPatcher.asi"),
2828                std::ffi::OsStr::new("OptiPatcher.asi"),
2829            ),
2830            PathBuf::from("/cache/plugins/OptiPatcher.asi")
2831        );
2832    }
2833
2834    #[test]
2835    fn optiscaler_fp8_env_only_for_latest_fp8_emulation() {
2836        let mut config = OptiScaler.default_config();
2837        config.set("fsr4_variant", serde_json::json!(FSR4_VARIANT_LATEST_FP8));
2838        config.set("emulate_fp8", serde_json::json!(true));
2839
2840        assert_eq!(
2841            OptiScaler.env_vars(&config).as_slice(),
2842            [(
2843                FP8_EMULATION_ENV_KEY.to_string(),
2844                FP8_EMULATION_ENV_VALUE.to_string()
2845            )]
2846        );
2847
2848        config.set("fsr4_variant", serde_json::json!(FSR4_VARIANT_INT8_402));
2849        assert!(OptiScaler.env_vars(&config).is_empty());
2850    }
2851
2852    #[test]
2853    fn goverlay_release_classification_maps_supported_channels() {
2854        assert_eq!(
2855            goverlay_release_channel("edge-0.9.12.0323"),
2856            Some("goverlay-edge")
2857        );
2858        assert_eq!(
2859            goverlay_release_channel("master-3ce61922"),
2860            Some("goverlay-master")
2861        );
2862        assert_eq!(
2863            goverlay_release_channel("any-release-0.9-2083b274"),
2864            Some("goverlay-any")
2865        );
2866        assert_eq!(goverlay_release_channel("0.9.1-0"), Some("goverlay-stable"));
2867        assert_eq!(goverlay_release_channel("fsr-int8"), None);
2868    }
2869
2870    #[test]
2871    fn goverlay_release_summary_requires_full_install_archive() {
2872        let edge = release_fixture("edge-0.9.12.0323", &["notes.json", "optiscaler-edge.7z"]);
2873        let edge = goverlay_release_summary(edge).expect("edge release");
2874        assert_eq!(edge.tag, "goverlay-edge:edge-0.9.12.0323");
2875        assert_eq!(edge.assets.len(), 1);
2876        assert_eq!(edge.assets[0].name, "optiscaler-edge.7z");
2877
2878        let master = release_fixture(
2879            "master-3ce61922",
2880            &["master-3ce61922.json", "OptiScaler_master_3ce61922.7z"],
2881        );
2882        let master = goverlay_release_summary(master).expect("master release");
2883        assert_eq!(master.tag, "goverlay-master:master-3ce61922");
2884        assert_eq!(master.assets[0].name, "OptiScaler_master_3ce61922.7z");
2885
2886        let fsr_int8 = release_fixture("fsr-int8", &["amd_fidelityfx_upscaler_dx12.dll"]);
2887        assert!(goverlay_release_summary(fsr_int8).is_none());
2888    }
2889
2890    #[test]
2891    fn optiscaler_release_tags_are_encoded_by_source() {
2892        assert_eq!(
2893            encode_optiscaler_release_tag("official", "v0.9.1"),
2894            "official:v0.9.1"
2895        );
2896        assert_eq!(
2897            normalize_optiscaler_release_tag("v0.9.1"),
2898            "official:v0.9.1"
2899        );
2900        assert_eq!(
2901            normalize_optiscaler_release_tag("goverlay-edge:edge-0.9.12.0323"),
2902            "goverlay-edge:edge-0.9.12.0323"
2903        );
2904    }
2905
2906    #[test]
2907    fn optiscaler_release_asset_selection_uses_encoded_source_keys() {
2908        let releases = vec![
2909            release_fixture("official:v0.9.1", &["Optiscaler_0.9.1-final.7z"]),
2910            release_fixture("goverlay-edge:edge-0.9.12.0323", &["optiscaler-edge.7z"]),
2911        ];
2912
2913        let (tag, asset) =
2914            select_optiscaler_release_asset(&releases, "v0.9.1", "Optiscaler_0.9.1-final.7z")
2915                .expect("legacy official tag resolves");
2916        assert_eq!(tag, "official:v0.9.1");
2917        assert_eq!(asset.name, "Optiscaler_0.9.1-final.7z");
2918
2919        let (tag, asset) = select_optiscaler_release_asset(
2920            &releases,
2921            "goverlay-edge:edge-0.9.12.0323",
2922            "optiscaler-edge.7z",
2923        )
2924        .expect("goverlay edge tag resolves");
2925        assert_eq!(tag, "goverlay-edge:edge-0.9.12.0323");
2926        assert_eq!(asset.name, "optiscaler-edge.7z");
2927
2928        assert!(
2929            select_optiscaler_release_asset(&releases, "edge-0.9.12.0323", "optiscaler-edge.7z")
2930                .is_err()
2931        );
2932    }
2933
2934    #[test]
2935    fn optiscaler_release_config_moves_legacy_goverlay_tag_to_goverlay_source() {
2936        let mut config = OptiScaler.default_config();
2937        config.set("source_mode", serde_json::json!("github_release"));
2938        config.set(
2939            "release_tag",
2940            serde_json::json!("goverlay-edge:edge-0.9.12.0323"),
2941        );
2942
2943        assert!(normalize_optiscaler_release_config(&mut config));
2944
2945        assert_eq!(config.get_str("source_mode"), Some("goverlay_builds"));
2946        assert_eq!(config.get_str("goverlay_channel"), Some("edge"));
2947        assert_eq!(
2948            config.get_str("release_tag"),
2949            Some("goverlay-edge:edge-0.9.12.0323")
2950        );
2951    }
2952
2953    #[test]
2954    fn optiscaler_release_matching_respects_source_and_channel() {
2955        let official = release_fixture("official:v0.9.1", &["Optiscaler.7z"]);
2956        let edge = release_fixture("goverlay-edge:edge-0.9.12.0323", &["optiscaler-edge.7z"]);
2957        let master = release_fixture(
2958            "goverlay-master:master-3ce61922",
2959            &["OptiScaler_master_3ce61922.7z"],
2960        );
2961        let mut config = OptiScaler.default_config();
2962
2963        config.set("source_mode", serde_json::json!("github_release"));
2964        assert!(optiscaler_release_matches_config(&official, &config));
2965        assert!(!optiscaler_release_matches_config(&edge, &config));
2966
2967        config.set("source_mode", serde_json::json!("goverlay_builds"));
2968        config.set("goverlay_channel", serde_json::json!("edge"));
2969        assert!(optiscaler_release_matches_config(&edge, &config));
2970        assert!(!optiscaler_release_matches_config(&master, &config));
2971
2972        config.set("goverlay_channel", serde_json::json!("master"));
2973        assert!(!optiscaler_release_matches_config(&edge, &config));
2974        assert!(optiscaler_release_matches_config(&master, &config));
2975    }
2976
2977    #[test]
2978    fn goverlay_release_sorts_newest_first_by_publish_date() {
2979        let mut releases = [
2980            release_fixture_with_date(
2981                "goverlay-edge:edge-old",
2982                &["optiscaler-edge.7z"],
2983                "2026-03-20T01:08:18Z",
2984            ),
2985            release_fixture_with_date(
2986                "goverlay-edge:edge-new",
2987                &["optiscaler-edge.7z"],
2988                "2026-03-24T00:18:25Z",
2989            ),
2990        ];
2991
2992        releases.sort_by(|left, right| right.published_at.cmp(&left.published_at));
2993
2994        assert_eq!(releases[0].tag, "goverlay-edge:edge-new");
2995        assert_eq!(releases[1].tag, "goverlay-edge:edge-old");
2996    }
2997
2998    #[test]
2999    fn scanner_reports_absent_install() {
3000        let tmp = tempfile::tempdir().expect("tempdir");
3001        let state = scan_optiscaler_install_in_dir(tmp.path(), &BTreeSet::new()).expect("scan");
3002        assert_eq!(state.status, OptiScalerInstallStatus::Absent);
3003        assert!(state.recognized_files.is_empty());
3004    }
3005
3006    #[test]
3007    fn scanner_reports_unmanaged_install_with_config_and_companions() {
3008        let tmp = tempfile::tempdir().expect("tempdir");
3009        std::fs::write(tmp.path().join("dxgi.dll"), b"optiscaler").expect("proxy");
3010        std::fs::write(
3011            tmp.path().join("OptiScaler.ini"),
3012            "[OptiScaler]\nDxgi=false\n",
3013        )
3014        .expect("ini");
3015        std::fs::write(tmp.path().join("fakenvapi.dll"), b"fake").expect("companion");
3016
3017        let state = scan_optiscaler_install_in_dir(tmp.path(), &BTreeSet::new()).expect("scan");
3018        assert_eq!(state.status, OptiScalerInstallStatus::Unmanaged);
3019        assert_eq!(state.proxy_dlls, vec!["dxgi.dll".to_string()]);
3020        assert_eq!(state.wine_dll_overrides, vec!["dxgi".to_string()]);
3021        assert_eq!(
3022            state.ini_settings.get("OptiScaler.Dxgi"),
3023            Some(&"false".to_string())
3024        );
3025        assert_eq!(state.recognized_files.len(), 3);
3026    }
3027
3028    #[test]
3029    fn scanner_distinguishes_managed_and_conflicted_installs() {
3030        let tmp = tempfile::tempdir().expect("tempdir");
3031        std::fs::write(tmp.path().join("dxgi.dll"), b"optiscaler").expect("proxy");
3032        let mut managed = BTreeSet::new();
3033        managed.insert("dxgi.dll".to_string());
3034        let state = scan_optiscaler_install_in_dir(tmp.path(), &managed).expect("scan");
3035        assert_eq!(state.status, OptiScalerInstallStatus::Managed);
3036
3037        std::fs::write(tmp.path().join("winmm.dll"), b"optiscaler").expect("proxy");
3038        let state = scan_optiscaler_install_in_dir(tmp.path(), &managed).expect("scan");
3039        assert_eq!(state.status, OptiScalerInstallStatus::Conflicted);
3040    }
3041
3042    #[test]
3043    fn scanner_matches_stellar_blade_root_relative_managed_manifest() {
3044        let tmp = tempfile::tempdir().expect("tempdir");
3045        let exe_dir = tmp.path().join("SB/Binaries/Win64");
3046        std::fs::create_dir_all(&exe_dir).expect("exe dir");
3047        std::fs::write(exe_dir.join("dxgi.dll"), b"optiscaler").expect("proxy");
3048        std::fs::write(exe_dir.join("OptiScaler.ini"), "[OptiScaler]\nDxgi=auto\n").expect("ini");
3049        std::fs::write(exe_dir.join("version.txt"), "v0.9.1\n").expect("version");
3050
3051        let unmanaged =
3052            scan_optiscaler_install("stellar-blade", tmp.path(), &BTreeSet::new()).expect("scan");
3053        assert_eq!(unmanaged.status, OptiScalerInstallStatus::Unmanaged);
3054        assert_eq!(
3055            unmanaged.summary(),
3056            "unmanaged; version v0.9.1; proxy dxgi.dll"
3057        );
3058        assert_eq!(unmanaged.wine_dll_overrides, vec!["dxgi".to_string()]);
3059
3060        let mut managed = BTreeSet::new();
3061        managed.insert("sb/binaries/win64/dxgi.dll".to_string());
3062        managed.insert("sb/binaries/win64/optiscaler.ini".to_string());
3063        let managed_state =
3064            scan_optiscaler_install("stellar-blade", tmp.path(), &managed).expect("scan");
3065        assert_eq!(managed_state.status, OptiScalerInstallStatus::Managed);
3066        assert_eq!(
3067            managed_state.summary(),
3068            "managed; version v0.9.1; proxy dxgi.dll"
3069        );
3070    }
3071
3072    #[test]
3073    fn stellar_blade_default_config_adds_community_metadata_only() {
3074        let context = ToolGameContext::from_parts(
3075            "stellar-blade",
3076            "Stellar Blade",
3077            Some(PathBuf::from("/fake/StellarBlade")),
3078            None,
3079        );
3080        let config = OptiScaler.default_config_for(Some(&context));
3081
3082        assert_eq!(
3083            config.get_str("source_mode"),
3084            Some(OPTISCALER_SOURCE_GOVERLAY_FGMOD)
3085        );
3086        assert_eq!(config.get_str("goverlay_channel"), Some("edge"));
3087        assert_eq!(config.get_str("release_tag"), Some("latest"));
3088        assert_eq!(config.get_str("release_asset"), Some(""));
3089        assert_eq!(config.get_str("proxy_dll"), Some("dxgi.dll"));
3090        assert_eq!(config.get_str("dll_overrides"), Some(""));
3091        assert!(config.get_bool("copy_companion_files"));
3092        assert!(!config.get_bool("enable_optipatcher"));
3093        assert_eq!(
3094            config.get_str("fsr4_variant"),
3095            Some(FSR4_VARIANT_LATEST_FP8)
3096        );
3097        assert!(!config.get_bool("emulate_fp8"));
3098        assert!(!config.get_bool("spoof_dlss"));
3099        assert_eq!(config.get_str("optiscaler_profile"), Some("community-dxgi"));
3100        assert_eq!(config.get_str("tested_optiscaler_version"), Some("0.9"));
3101        assert_eq!(
3102            config.get_str("optiscaler_profile_source_url"),
3103            Some("https://github.com/optiscaler/OptiScaler/wiki/Stellar-Blade")
3104        );
3105        assert_eq!(
3106            config.settings.get("ini_overrides"),
3107            Some(&serde_json::json!({}))
3108        );
3109    }
3110
3111    #[test]
3112    fn games_without_optiscaler_profiles_keep_generic_defaults() {
3113        let context =
3114            ToolGameContext::from_parts("skyrim-se", "Skyrim Special Edition", None, None);
3115        let config = OptiScaler.default_config_for(Some(&context));
3116
3117        assert_eq!(config.get_str("source_mode"), Some("goverlay_fgmod"));
3118        assert_eq!(config.get_str("release_tag"), Some("latest"));
3119        assert_eq!(config.get_str("proxy_dll"), Some("dxgi.dll"));
3120        assert_eq!(config.get_str("dll_overrides"), Some(""));
3121        assert_eq!(config.get_str("optiscaler_profile"), None);
3122    }
3123
3124    #[test]
3125    fn custom_profile_opt_out_prevents_community_defaults() {
3126        let context = ToolGameContext::from_parts(
3127            "stellar-blade",
3128            "Stellar Blade",
3129            Some(PathBuf::from("/fake/StellarBlade")),
3130            None,
3131        );
3132        let mut config = OptiScaler.default_config();
3133        config.set("optiscaler_profile", serde_json::json!("custom"));
3134        config.set("source_mode", serde_json::json!("local_dir"));
3135        config.set("release_tag", serde_json::json!("latest"));
3136        config.set("proxy_dll", serde_json::json!("winmm.dll"));
3137
3138        apply_game_defaults(&mut config, Some(&context));
3139
3140        assert_eq!(config.get_str("optiscaler_profile"), Some("custom"));
3141        assert_eq!(config.get_str("source_mode"), Some("local_dir"));
3142        assert_eq!(config.get_str("release_tag"), Some("latest"));
3143        assert_eq!(config.get_str("proxy_dll"), Some("winmm.dll"));
3144        assert_eq!(config.get_str("tested_optiscaler_version"), None);
3145    }
3146
3147    #[test]
3148    fn stellar_blade_game_defaults_preserve_custom_settings_for_profile() {
3149        let context = ToolGameContext::from_parts(
3150            "stellar-blade",
3151            "Stellar Blade",
3152            Some(PathBuf::from("/fake/StellarBlade")),
3153            None,
3154        );
3155        let mut config = OptiScaler.default_config();
3156        config.set("optiscaler_profile", serde_json::json!("community-dxgi"));
3157        config.set("source_mode", serde_json::json!("goverlay_builds"));
3158        config.set("goverlay_channel", serde_json::json!("edge"));
3159        config.set(
3160            "release_tag",
3161            serde_json::json!("goverlay-edge:edge-0.9.12.0323"),
3162        );
3163        config.set("release_asset", serde_json::json!("optiscaler-edge.7z"));
3164        config.set("proxy_dll", serde_json::json!("winmm.dll"));
3165        config.set("dll_overrides", serde_json::json!("winmm,nvngx"));
3166        config.set("copy_companion_files", serde_json::json!(false));
3167        config.set("enable_optipatcher", serde_json::json!(false));
3168        config.set("fsr4_variant", serde_json::json!(FSR4_VARIANT_INT8_402));
3169        config.set("emulate_fp8", serde_json::json!(true));
3170        config.set("spoof_dlss", serde_json::json!(true));
3171        config.set(
3172            "ini_overrides",
3173            serde_json::json!({"Spoofing": {"Dxgi": "false"}}),
3174        );
3175
3176        apply_game_defaults(&mut config, Some(&context));
3177
3178        assert_eq!(config.get_str("optiscaler_profile"), Some("community-dxgi"));
3179        assert_eq!(config.get_str("source_mode"), Some("goverlay_builds"));
3180        assert_eq!(config.get_str("goverlay_channel"), Some("edge"));
3181        assert_eq!(
3182            config.get_str("release_tag"),
3183            Some("goverlay-edge:edge-0.9.12.0323")
3184        );
3185        assert_eq!(config.get_str("release_asset"), Some("optiscaler-edge.7z"));
3186        assert_eq!(config.get_str("proxy_dll"), Some("winmm.dll"));
3187        assert_eq!(config.get_str("dll_overrides"), Some("winmm,nvngx"));
3188        assert!(!config.get_bool("copy_companion_files"));
3189        assert!(!config.get_bool("enable_optipatcher"));
3190        assert_eq!(config.get_str("fsr4_variant"), Some(FSR4_VARIANT_INT8_402));
3191        assert!(config.get_bool("emulate_fp8"));
3192        assert!(config.get_bool("spoof_dlss"));
3193        assert_eq!(
3194            config
3195                .settings
3196                .pointer("/ini_overrides/Spoofing/Dxgi")
3197                .and_then(serde_json::Value::as_str),
3198            Some("false")
3199        );
3200        assert_eq!(config.get_str("tested_optiscaler_version"), Some("0.9"));
3201    }
3202
3203    #[test]
3204    fn stellar_blade_game_defaults_preserve_existing_release_without_profile_marker() {
3205        let context = ToolGameContext::from_parts(
3206            "stellar-blade",
3207            "Stellar Blade",
3208            Some(PathBuf::from("/fake/StellarBlade")),
3209            None,
3210        );
3211        let mut config = OptiScaler.default_config();
3212        config.set("source_mode", serde_json::json!("github_release"));
3213        config.set("release_tag", serde_json::json!("official:v0.9.11"));
3214        config.set("release_asset", serde_json::json!("OptiScaler_0.9.11.7z"));
3215
3216        apply_game_defaults(&mut config, Some(&context));
3217
3218        assert_eq!(config.get_str("optiscaler_profile"), Some("community-dxgi"));
3219        assert_eq!(config.get_str("source_mode"), Some("github_release"));
3220        assert_eq!(config.get_str("release_tag"), Some("official:v0.9.11"));
3221        assert_eq!(
3222            config.get_str("release_asset"),
3223            Some("OptiScaler_0.9.11.7z")
3224        );
3225        assert_eq!(config.get_str("proxy_dll"), Some("dxgi.dll"));
3226        assert!(!config.get_bool("enable_optipatcher"));
3227    }
3228
3229    #[test]
3230    fn selecting_profile_after_custom_applies_metadata_only() {
3231        let mut config = OptiScaler.default_config();
3232        assert!(apply_profile_by_id(&mut config, "stellar-blade", "custom"));
3233        assert_eq!(config.get_str("optiscaler_profile"), Some("custom"));
3234        config.set("source_mode", serde_json::json!("local_dir"));
3235        config.set("release_tag", serde_json::json!("custom-build"));
3236        config.set("release_asset", serde_json::json!("CustomOptiScaler.7z"));
3237        config.set("proxy_dll", serde_json::json!("winmm.dll"));
3238        config.set("dll_overrides", serde_json::json!("winmm,nvngx"));
3239        config.set("copy_companion_files", serde_json::json!(false));
3240        config.set("enable_optipatcher", serde_json::json!(false));
3241        config.set("fsr4_variant", serde_json::json!(FSR4_VARIANT_INT8_402));
3242        config.set("emulate_fp8", serde_json::json!(true));
3243        config.set("spoof_dlss", serde_json::json!(true));
3244
3245        assert!(apply_profile_by_id(
3246            &mut config,
3247            "stellar-blade",
3248            "community-dxgi"
3249        ));
3250
3251        assert_eq!(config.get_str("optiscaler_profile"), Some("community-dxgi"));
3252        assert_eq!(config.get_str("source_mode"), Some("local_dir"));
3253        assert_eq!(config.get_str("goverlay_channel"), Some("edge"));
3254        assert_eq!(config.get_str("release_tag"), Some("custom-build"));
3255        assert_eq!(config.get_str("release_asset"), Some("CustomOptiScaler.7z"));
3256        assert_eq!(config.get_str("proxy_dll"), Some("winmm.dll"));
3257        assert_eq!(config.get_str("dll_overrides"), Some("winmm,nvngx"));
3258        assert!(!config.get_bool("copy_companion_files"));
3259        assert!(!config.get_bool("enable_optipatcher"));
3260        assert_eq!(config.get_str("fsr4_variant"), Some(FSR4_VARIANT_INT8_402));
3261        assert!(config.get_bool("emulate_fp8"));
3262        assert!(config.get_bool("spoof_dlss"));
3263        assert_eq!(config.get_str("tested_optiscaler_version"), Some("0.9"));
3264    }
3265
3266    #[test]
3267    fn optiscaler_profile_metadata_does_not_change_settings() {
3268        let profile = crate::optiscaler::OptiScalerProfile {
3269            id: "test-profile",
3270            name: "Test Profile",
3271            source_url: "https://example.test/profile",
3272            tested_optiscaler_version: "1.2.3",
3273            source_mode: None,
3274            goverlay_channel: None,
3275            proxy_dll: "winmm.dll",
3276            release_tag: Some("v1.2.3"),
3277            release_asset: Some("OptiScaler.7z"),
3278            wine_dll_overrides: &["winmm", "nvngx"],
3279            copy_companion_files: false,
3280            enable_optipatcher: true,
3281            fsr4_variant: Some(FSR4_VARIANT_INT8_402),
3282            emulate_fp8: true,
3283            spoof_dlss: true,
3284            ini_overrides: &[crate::optiscaler::OptiScalerIniOverride {
3285                key: "Spoofing.Dxgi",
3286                value: "false",
3287            }],
3288            notes: "Test notes",
3289        };
3290        let mut config = OptiScaler.default_config();
3291        config.set("source_mode", serde_json::json!("local_dir"));
3292        config.set("release_tag", serde_json::json!("custom-build"));
3293        config.set("release_asset", serde_json::json!("CustomOptiScaler.7z"));
3294        config.set("proxy_dll", serde_json::json!("dxgi.dll"));
3295        config.set("dll_overrides", serde_json::json!("dxgi"));
3296        config.set("copy_companion_files", serde_json::json!(true));
3297        config.set("enable_optipatcher", serde_json::json!(false));
3298        config.set("fsr4_variant", serde_json::json!(FSR4_VARIANT_LATEST_FP8));
3299        config.set("emulate_fp8", serde_json::json!(false));
3300        config.set("spoof_dlss", serde_json::json!(false));
3301        config.set(
3302            "ini_overrides",
3303            serde_json::json!({"OptiScaler": {"Dxgi": "auto"}}),
3304        );
3305
3306        apply_optiscaler_profile_metadata(&mut config, &profile);
3307
3308        assert_eq!(config.get_str("optiscaler_profile"), Some("test-profile"));
3309        assert_eq!(config.get_str("source_mode"), Some("local_dir"));
3310        assert_eq!(config.get_str("release_tag"), Some("custom-build"));
3311        assert_eq!(config.get_str("release_asset"), Some("CustomOptiScaler.7z"));
3312        assert_eq!(config.get_str("proxy_dll"), Some("dxgi.dll"));
3313        assert_eq!(config.get_str("dll_overrides"), Some("dxgi"));
3314        assert!(config.get_bool("copy_companion_files"));
3315        assert!(!config.get_bool("enable_optipatcher"));
3316        assert_eq!(
3317            config.get_str("fsr4_variant"),
3318            Some(FSR4_VARIANT_LATEST_FP8)
3319        );
3320        assert!(!config.get_bool("emulate_fp8"));
3321        assert!(!config.get_bool("spoof_dlss"));
3322        assert_eq!(
3323            config
3324                .settings
3325                .pointer("/ini_overrides/OptiScaler/Dxgi")
3326                .and_then(serde_json::Value::as_str),
3327            Some("auto")
3328        );
3329        assert_eq!(config.get_str("tested_optiscaler_version"), Some("1.2.3"));
3330        assert_eq!(
3331            config.get_str("optiscaler_profile_source_url"),
3332            Some("https://example.test/profile")
3333        );
3334    }
3335
3336    #[test]
3337    fn optiscaler_release_selection_preserves_custom_profile_deployment() {
3338        let mut config = OptiScaler.default_config();
3339        config.set("optiscaler_profile", serde_json::json!("community-dxgi"));
3340        config.set("proxy_dll", serde_json::json!("winmm.dll"));
3341        config.set("dll_overrides", serde_json::json!("winmm,nvngx"));
3342        config.set("copy_companion_files", serde_json::json!(false));
3343        config.set("enable_optipatcher", serde_json::json!(false));
3344        config.set("fsr4_variant", serde_json::json!(FSR4_VARIANT_INT8_402));
3345        config.set("emulate_fp8", serde_json::json!(true));
3346        config.set("spoof_dlss", serde_json::json!(true));
3347        config.set(
3348            "ini_overrides",
3349            serde_json::json!({"Spoofing": {"Dxgi": "false"}}),
3350        );
3351
3352        apply_optiscaler_release_selection(&mut config, "official:v0.9.11", "OptiScaler_0.9.11.7z");
3353
3354        assert_eq!(config.get_str("optiscaler_profile"), Some("community-dxgi"));
3355        assert_eq!(config.get_str("source_mode"), Some("github_release"));
3356        assert_eq!(config.get_str("release_tag"), Some("official:v0.9.11"));
3357        assert_eq!(
3358            config.get_str("release_asset"),
3359            Some("OptiScaler_0.9.11.7z")
3360        );
3361        assert_eq!(config.get_str("proxy_dll"), Some("winmm.dll"));
3362        assert_eq!(config.get_str("dll_overrides"), Some("winmm,nvngx"));
3363        assert!(!config.get_bool("copy_companion_files"));
3364        assert!(!config.get_bool("enable_optipatcher"));
3365        assert_eq!(config.get_str("fsr4_variant"), Some(FSR4_VARIANT_INT8_402));
3366        assert!(config.get_bool("emulate_fp8"));
3367        assert!(config.get_bool("spoof_dlss"));
3368        assert_eq!(
3369            config
3370                .settings
3371                .pointer("/ini_overrides/Spoofing/Dxgi")
3372                .and_then(serde_json::Value::as_str),
3373            Some("false")
3374        );
3375    }
3376
3377    #[test]
3378    fn incompatible_existing_ini_triggers_reset_reason() {
3379        let tmp = tempfile::tempdir().expect("tempdir");
3380        let new_ini = tmp.path().join("new.ini");
3381        std::fs::write(&new_ini, "[OptiScaler]\nDxgi=auto\n").expect("new ini");
3382        let state = OptiScalerInstallState {
3383            status: OptiScalerInstallStatus::Unmanaged,
3384            executable_dir: tmp.path().to_path_buf(),
3385            proxy_dlls: vec![],
3386            wine_dll_overrides: vec![],
3387            config_path: None,
3388            ini_settings: BTreeMap::from([("Removed.SectionKey".to_string(), "true".to_string())]),
3389            companion_files: vec![],
3390            recognized_files: vec![],
3391            version: OptiScalerVersionIdentity::Unknown,
3392            latest_backup: None,
3393        };
3394        let config = OptiScaler.default_config();
3395        assert_eq!(
3396            optiscaler_config_reset_reason(&state, &new_ini, &config).as_deref(),
3397            Some("schema mismatch")
3398        );
3399    }
3400
3401    fn release_fixture(tag: &str, asset_names: &[&str]) -> ToolReleaseSummary {
3402        ToolReleaseSummary {
3403            tag: tag.to_string(),
3404            name: None,
3405            published_at: None,
3406            assets: asset_names
3407                .iter()
3408                .map(|name| ToolReleaseAsset {
3409                    name: (*name).to_string(),
3410                    download_url: format!("https://example.test/{name}"),
3411                    size: 1,
3412                })
3413                .collect(),
3414        }
3415    }
3416
3417    fn release_fixture_with_date(
3418        tag: &str,
3419        asset_names: &[&str],
3420        published_at: &str,
3421    ) -> ToolReleaseSummary {
3422        let mut release = release_fixture(tag, asset_names);
3423        release.published_at = Some(published_at.to_string());
3424        release
3425    }
3426
3427    // ── set_ini_value ─────────────────────────────────────────────────
3428
3429    #[test]
3430    fn set_ini_value_updates_existing_key_in_section() {
3431        let content = "[OptiScaler]\nDxgi=auto\nLoadAsiPlugins=false\n";
3432        let updated = set_ini_value(content, "OptiScaler.Dxgi", "manual");
3433        assert!(updated.contains("Dxgi=manual"));
3434        assert!(updated.contains("LoadAsiPlugins=false"));
3435    }
3436
3437    #[test]
3438    fn set_ini_value_appends_section_when_missing() {
3439        let updated = set_ini_value("", "Menu.Scale", "1.25");
3440        assert!(updated.contains("[Menu]"));
3441        assert!(updated.contains("Scale=1.25"));
3442    }
3443
3444    #[test]
3445    fn set_ini_value_returns_content_unchanged_when_key_has_no_section() {
3446        let content = "[OptiScaler]\nDxgi=auto\n";
3447        let updated = set_ini_value(content, "no_section_prefix", "x");
3448        assert_eq!(updated, content, "malformed key should not mutate content");
3449    }
3450
3451    // ── relative_to_game ──────────────────────────────────────────────
3452
3453    #[test]
3454    fn relative_to_game_strips_game_prefix() {
3455        let game = PathBuf::from("/games/skyrim");
3456        let dest = PathBuf::from("/games/skyrim/Data/SKSE/plugins/foo.dll");
3457        let rel = relative_to_game(&game, &dest).expect("strips prefix");
3458        assert_eq!(rel, PathBuf::from("Data/SKSE/plugins/foo.dll"));
3459    }
3460
3461    #[test]
3462    fn relative_to_game_errors_when_dest_outside_game_dir() {
3463        let game = PathBuf::from("/games/skyrim");
3464        let dest = PathBuf::from("/elsewhere/foo.dll");
3465        let err = relative_to_game(&game, &dest).expect_err("must fail");
3466        let msg = format!("{err:#}");
3467        assert!(
3468            msg.contains("/elsewhere/foo.dll") && msg.contains("/games/skyrim"),
3469            "error must mention both paths: {msg}"
3470        );
3471    }
3472}