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