1use 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
73pub 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 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 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 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 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 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
1761pub 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
1772pub 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#[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#[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
2394mod 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 #[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 #[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}