Skip to main content

modde_games/
launcher.rs

1//! Launcher detection and configuration for Wine/Proton DLL overrides.
2//!
3//! After deploying mods that include proxy DLLs (e.g. `version.dll` for CET),
4//! Wine/Proton needs `WINEDLLOVERRIDES` set so it loads the native (mod) version
5//! instead of its built-in stub. This module detects the game launcher and
6//! updates its configuration automatically.
7
8use std::path::{Path, PathBuf};
9
10use anyhow::{Context, Result};
11use serde_json::Value;
12use tracing::{debug, info, warn};
13
14/// Detected game launcher type.
15#[derive(Debug)]
16pub enum Launcher {
17    /// Heroic Games Launcher — config at `~/.config/heroic/GamesConfig/<id>.json`
18    Heroic { config_path: PathBuf, game_id: String },
19    /// Steam — uses launch options in Steam client
20    Steam { app_id: String },
21    /// Unknown launcher — print instructions for manual setup
22    Unknown,
23}
24
25/// Detect which launcher manages a game at the given install path.
26pub fn detect_launcher(game_dir: &Path) -> Launcher {
27    // Check for Heroic: game paths typically contain "heroic" or match a Heroic library
28    if let Some(launcher) = detect_heroic(game_dir) {
29        return launcher;
30    }
31
32    // Check for Steam: game is under steamapps/common/
33    if let Some(app_id) = detect_steam(game_dir) {
34        return Launcher::Steam { app_id };
35    }
36
37    Launcher::Unknown
38}
39
40/// Try to detect Heroic launcher by scanning its GamesConfig directory.
41fn detect_heroic(game_dir: &Path) -> Option<Launcher> {
42    let config_dir = modde_core::paths::heroic_config_dir()?;
43    let games_config = config_dir.join("GamesConfig");
44
45    if !games_config.is_dir() {
46        return None;
47    }
48
49    // Read each game config and check if its install path matches
50    let entries = std::fs::read_dir(&games_config).ok()?;
51    for entry in entries.flatten() {
52        let path = entry.path();
53        if path.extension().and_then(|e| e.to_str()) != Some("json") {
54            continue;
55        }
56
57        // The filename is the game ID (e.g., "1423049311.json")
58        let game_id = path.file_stem()?.to_string_lossy().to_string();
59
60        // Also check the Heroic library for the install path
61        if heroic_game_matches(&config_dir, &game_id, game_dir) {
62            return Some(Launcher::Heroic {
63                config_path: path,
64                game_id,
65            });
66        }
67    }
68
69    None
70}
71
72/// Check if a Heroic game entry matches the given game directory.
73fn heroic_game_matches(config_dir: &Path, game_id: &str, game_dir: &Path) -> bool {
74    // Check installed.json files for each store (GOG, Epic/Legendary, etc.)
75    let installed_files = [
76        config_dir.join("gog_store/installed.json"),
77        config_dir.join("legendary_store/installed.json"),
78        config_dir.join("sideload_apps/installed.json"),
79    ];
80
81    for installed_path in &installed_files {
82        let data = match std::fs::read_to_string(installed_path) {
83            Ok(d) => d,
84            Err(e) => {
85                debug!(error = %e, path = %installed_path.display(), "failed to read Heroic installed file");
86                continue;
87            }
88        };
89        let val: Value = match serde_json::from_str(&data) {
90            Ok(v) => v,
91            Err(e) => {
92                warn!(error = %e, path = %installed_path.display(), "failed to parse Heroic installed JSON");
93                continue;
94            }
95        };
96        if let Some(games) = val.get("installed").and_then(|v| v.as_array()) {
97            for game in games {
98                if game.get("appName").and_then(|v| v.as_str()) == Some(game_id) {
99                    if let Some(install_path) = game.get("install_path").and_then(|v| v.as_str()) {
100                        let canonical_game = game_dir.canonicalize().unwrap_or_else(|_| game_dir.to_path_buf());
101                        let canonical_install = PathBuf::from(install_path).canonicalize().unwrap_or_else(|_| PathBuf::from(install_path));
102                        return canonical_game == canonical_install;
103                    }
104                }
105            }
106        }
107    }
108
109    false
110}
111
112
113/// Try to detect Steam by checking if the game is under steamapps/common/.
114fn detect_steam(game_dir: &Path) -> Option<String> {
115    let path_str = game_dir.to_string_lossy().replace('\\', "/");
116    if path_str.contains("steamapps/common/") {
117        // Try to find the appmanifest to get the app ID
118        if let Some(steamapps) = game_dir.ancestors().find(|p| p.file_name().and_then(|f| f.to_str()) == Some("common")).and_then(|p| p.parent()) {
119            let game_name = game_dir.file_name()?.to_string_lossy();
120            let manifests = std::fs::read_dir(steamapps).ok()?;
121            for entry in manifests.flatten() {
122                let name = entry.file_name();
123                let name_str = name.to_string_lossy();
124                if name_str.starts_with("appmanifest_") && name_str.ends_with(".acf") {
125                    if let Ok(content) = std::fs::read_to_string(entry.path()) {
126                        if content.contains(&*game_name) {
127                            let app_id = name_str
128                                .strip_prefix("appmanifest_")?
129                                .strip_suffix(".acf")?
130                                .to_string();
131                            return Some(app_id);
132                        }
133                    }
134                }
135            }
136        }
137    }
138    None
139}
140
141/// Generate a Unix (Linux/macOS) bash wrapper script.
142#[cfg(unix)]
143fn generate_wrapper_unix(
144    wrapper_dir: &Path,
145    restore_commands: &[(String, String)],
146    tool_env_vars: &[(String, String)],
147    game_id: &str,
148    modde_bin: &str,
149) -> (PathBuf, String) {
150    let wrapper_path = wrapper_dir.join("modde-launch-wrapper.sh");
151
152    let mut script = String::from("#!/usr/bin/env bash\n");
153    script.push_str("# Auto-generated by modde — tool env vars + fgmod DLL restoration\n");
154    script.push_str("# Do not edit manually; re-run `modde deploy` to regenerate.\n\n");
155
156    if !tool_env_vars.is_empty() {
157        script.push_str("# Tool environment variables\n");
158        for (key, value) in tool_env_vars {
159            script.push_str(&format!("export {key}=\"{value}\"\n"));
160        }
161        script.push('\n');
162    }
163
164    if !restore_commands.is_empty() {
165        script.push_str("# Restore mod DLLs that fgmod deletes\n");
166        for (src, dest) in restore_commands {
167            script.push_str(&format!(
168                "cp -f \"{src}\" \"{dest}\" 2>/dev/null && echo \"modde: restored $(basename \"{dest}\")\"\n"
169            ));
170        }
171        script.push('\n');
172    }
173
174    script.push_str("\"$@\"\n");
175    script.push_str("status=$?\n\n");
176    script.push_str(&format!(
177        "# Auto-capture saves after game exit\n\
178         \"{modde_bin}\" save auto-capture --game {game_id} 2>/dev/null &\n\n\
179         exit $status\n"
180    ));
181
182    (wrapper_path, script)
183}
184
185/// Generate a Windows `.cmd` wrapper script.
186#[cfg(windows)]
187fn generate_wrapper_windows(
188    wrapper_dir: &Path,
189    restore_commands: &[(String, String)],
190    tool_env_vars: &[(String, String)],
191    game_id: &str,
192    modde_bin: &str,
193) -> (PathBuf, String) {
194    let wrapper_path = wrapper_dir.join("modde-launch-wrapper.cmd");
195
196    let mut script = String::from("@echo off\r\n");
197    script.push_str("REM Auto-generated by modde — tool env vars + fgmod DLL restoration\r\n");
198    script.push_str("REM Do not edit manually; re-run `modde deploy` to regenerate.\r\n\r\n");
199
200    if !tool_env_vars.is_empty() {
201        script.push_str("REM Tool environment variables\r\n");
202        for (key, value) in tool_env_vars {
203            script.push_str(&format!("set \"{key}={value}\"\r\n"));
204        }
205        script.push_str("\r\n");
206    }
207
208    if !restore_commands.is_empty() {
209        script.push_str("REM Restore mod DLLs that fgmod deletes\r\n");
210        for (src, dest) in restore_commands {
211            script.push_str(&format!(
212                "copy /Y \"{src}\" \"{dest}\" >nul 2>nul && echo modde: restored {dest}\r\n"
213            ));
214        }
215        script.push_str("\r\n");
216    }
217
218    script.push_str("%*\r\n");
219    script.push_str("set status=%ERRORLEVEL%\r\n\r\n");
220    script.push_str(&format!(
221        "REM Auto-capture saves after game exit\r\n\
222         start \"\" /B \"{modde_bin}\" save auto-capture --game {game_id} 2>nul\r\n\r\n\
223         exit /b %status%\r\n"
224    ));
225
226    (wrapper_path, script)
227}
228
229/// Generate a modde launch wrapper script that restores mod DLLs deleted by fgmod
230/// and exports tool environment variables.
231///
232/// fgmod cleans up certain DLLs (winmm.dll, dxgi.dll, etc.) before installing OptiScaler.
233/// If mods deploy those same DLLs (e.g. RED4ext uses winmm.dll), we need to restore them
234/// after fgmod runs but before the game starts.
235///
236/// Additionally, the wrapper exports env vars for any enabled tools (MangoHud, vkBasalt, etc.)
237/// so they take effect even when launched via Steam (where we can't modify the launcher config).
238pub fn generate_launch_wrapper(
239    game_dir: &Path,
240    staging_dir: &Path,
241    game_id: &str,
242    tool_env_vars: &[(String, String)],
243) -> Result<Option<PathBuf>> {
244    // Delegate fgmod restore scanning to the optiscaler module
245    let restore_commands = crate::tools::optiscaler::fgmod_restore_commands(game_dir, staging_dir);
246
247    if restore_commands.is_empty() && tool_env_vars.is_empty() {
248        return Ok(None);
249    }
250
251    // Generate the wrapper script
252    let wrapper_dir = modde_core::paths::modde_data_dir().join("bin");
253    std::fs::create_dir_all(&wrapper_dir)
254        .context("failed to create modde bin directory")?;
255
256    let modde_bin = std::env::current_exe()
257        .map(|p| p.to_string_lossy().to_string())
258        .unwrap_or_else(|_| "modde".to_string());
259
260    #[cfg(unix)]
261    let (wrapper_path, script) = generate_wrapper_unix(
262        &wrapper_dir,
263        &restore_commands,
264        tool_env_vars,
265        game_id,
266        &modde_bin,
267    );
268
269    #[cfg(windows)]
270    let (wrapper_path, script) = generate_wrapper_windows(
271        &wrapper_dir,
272        &restore_commands,
273        tool_env_vars,
274        game_id,
275        &modde_bin,
276    );
277
278    std::fs::write(&wrapper_path, &script)
279        .with_context(|| format!("failed to write launch wrapper: {}", wrapper_path.display()))?;
280
281    // Make executable
282    #[cfg(unix)]
283    {
284        use std::os::unix::fs::PermissionsExt;
285        std::fs::set_permissions(&wrapper_path, std::fs::Permissions::from_mode(0o755))
286            .context("failed to set wrapper script permissions")?;
287    }
288
289    info!(
290        wrapper = %wrapper_path.display(),
291        restores = restore_commands.len(),
292        tool_vars = tool_env_vars.len(),
293        "generated modde launch wrapper"
294    );
295
296    if !restore_commands.is_empty() {
297        println!(
298            "  Launch wrapper: restores {} DLL(s)",
299            restore_commands.len(),
300        );
301    }
302    if !tool_env_vars.is_empty() {
303        println!(
304            "  Launch wrapper: exports {} tool env var(s)",
305            tool_env_vars.len(),
306        );
307    }
308
309    Ok(Some(wrapper_path))
310}
311
312/// Format a list of DLL names as a `WINEDLLOVERRIDES` value string.
313///
314/// Wine DLL overrides are only relevant on Linux (where games run via Wine/Proton).
315#[cfg(target_os = "linux")]
316fn format_wine_overrides(overrides: &[String]) -> String {
317    overrides.iter()
318        .map(|dll| format!("{dll}=n,b"))
319        .collect::<Vec<_>>()
320        .join(";")
321}
322
323/// Apply Wine DLL overrides to the detected launcher's configuration.
324///
325/// Returns `true` if the config was updated, `false` if no changes were needed.
326///
327/// Only relevant on Linux where games run via Wine/Proton.
328#[cfg(target_os = "linux")]
329pub fn apply_wine_overrides(launcher: &Launcher, overrides: &[String]) -> Result<bool> {
330    if overrides.is_empty() {
331        return Ok(false);
332    }
333
334    match launcher {
335        Launcher::Heroic { config_path, game_id } => {
336            apply_heroic_overrides(config_path, game_id, overrides)
337        }
338        Launcher::Steam { app_id } => {
339            let override_str = format_wine_overrides(overrides);
340            warn!(
341                "Steam game (app {app_id}): add to launch options:\n  \
342                 WINEDLLOVERRIDES=\"{override_str}\" %command%"
343            );
344            println!(
345                "\nSteam: Add this to your launch options:\n  \
346                 WINEDLLOVERRIDES=\"{override_str}\" %command%"
347            );
348            Ok(false)
349        }
350        Launcher::Unknown => {
351            let override_str = format_wine_overrides(overrides);
352            warn!(
353                "Unknown launcher: set WINEDLLOVERRIDES=\"{override_str}\" before launching"
354            );
355            println!(
356                "\nSet this environment variable before launching:\n  \
357                 WINEDLLOVERRIDES=\"{override_str}\""
358            );
359            Ok(false)
360        }
361    }
362}
363
364/// Update Heroic's GamesConfig JSON to include WINEDLLOVERRIDES.
365#[cfg(target_os = "linux")]
366fn apply_heroic_overrides(
367    config_path: &Path,
368    game_id: &str,
369    overrides: &[String],
370) -> Result<bool> {
371    let data = std::fs::read_to_string(config_path)
372        .with_context(|| format!("failed to read Heroic config: {}", config_path.display()))?;
373
374    let mut config: Value = serde_json::from_str(&data)
375        .with_context(|| format!("failed to parse Heroic config JSON: {}", config_path.display()))?;
376
377    let game_config = config
378        .get_mut(game_id)
379        .context("game entry not found in Heroic config")?;
380
381    // Build the override string: "version=n,b;winmm=n,b" etc.
382    // We don't include dxgi here since fgmod handles it at launch time.
383    let new_overrides: Vec<String> = overrides
384        .iter()
385        .filter(|dll| *dll != "dxgi") // fgmod handles dxgi
386        .map(|dll| format!("{dll}=n,b"))
387        .collect();
388
389    if new_overrides.is_empty() {
390        return Ok(false);
391    }
392
393    let override_value = new_overrides.join(";");
394
395    // Get or create enviromentOptions (Heroic uses this spelling)
396    let env_options = game_config
397        .get_mut("enviromentOptions")
398        .context("enviromentOptions not found in game config")?;
399
400    let env_array = env_options
401        .as_array_mut()
402        .context("enviromentOptions is not an array")?;
403
404    // Check if WINEDLLOVERRIDES is already set
405    let existing_idx = env_array.iter().position(|entry| {
406        entry.get("key").and_then(|k| k.as_str()) == Some("WINEDLLOVERRIDES")
407    });
408
409    if let Some(idx) = existing_idx {
410        // Update existing entry — merge with existing overrides
411        let existing_value = env_array[idx]
412            .get("value")
413            .and_then(|v| v.as_str())
414            .unwrap_or("");
415
416        // Parse existing overrides and merge (split on ';' only — commas are part of values like "n,b")
417        let mut all_overrides: Vec<String> = existing_value
418            .split(';')
419            .filter(|s| !s.is_empty())
420            .map(|s| s.to_string())
421            .collect();
422
423        for new_ov in &new_overrides {
424            let dll_name = new_ov.split('=').next().unwrap_or("");
425            // Remove any existing entry for this DLL
426            all_overrides.retain(|ov| {
427                let existing_name = ov.split('=').next().unwrap_or("");
428                existing_name != dll_name
429            });
430            all_overrides.push(new_ov.clone());
431        }
432
433        let merged = all_overrides.join(";");
434        env_array[idx] = serde_json::json!({
435            "key": "WINEDLLOVERRIDES",
436            "value": merged
437        });
438
439        info!(overrides = %merged, "updated existing WINEDLLOVERRIDES in Heroic config");
440    } else {
441        // Add new entry
442        env_array.push(serde_json::json!({
443            "key": "WINEDLLOVERRIDES",
444            "value": override_value
445        }));
446
447        info!(overrides = %override_value, "added WINEDLLOVERRIDES to Heroic config");
448    }
449
450    // Write back
451    let output = serde_json::to_string_pretty(&config)
452        .context("failed to serialize Heroic config")?;
453    std::fs::write(config_path, output)
454        .with_context(|| format!("failed to write Heroic config: {}", config_path.display()))?;
455
456    println!("  Updated Heroic config with WINEDLLOVERRIDES: {}",
457        if existing_idx.is_some() { "merged with existing" } else { &override_value });
458
459    Ok(true)
460}
461
462/// Register the modde launch wrapper in Heroic's wrapper chain.
463///
464/// The wrapper is inserted **after** fgmod (if present) so it can restore DLLs
465/// that fgmod deletes before the game launches.
466pub fn register_heroic_wrapper(launcher: &Launcher, wrapper_path: &Path) -> Result<bool> {
467    let Launcher::Heroic { config_path, game_id } = launcher else {
468        let wrapper_str = wrapper_path.display();
469        println!(
470            "\nAdd this wrapper before your game launcher:\n  {wrapper_str} --"
471        );
472        return Ok(false);
473    };
474
475    let data = std::fs::read_to_string(config_path)
476        .with_context(|| format!("failed to read Heroic config: {}", config_path.display()))?;
477
478    let mut config: Value = serde_json::from_str(&data)
479        .with_context(|| format!("failed to parse Heroic config JSON: {}", config_path.display()))?;
480
481    let game_config = config
482        .get_mut(game_id)
483        .context("game entry not found in Heroic config")?;
484
485    let wrapper_options = game_config
486        .get_mut("wrapperOptions")
487        .context("wrapperOptions not found in game config")?;
488
489    let wrappers = wrapper_options
490        .as_array_mut()
491        .context("wrapperOptions is not an array")?;
492
493    let wrapper_exe = wrapper_path.to_string_lossy().to_string();
494
495    // Check if modde wrapper is already registered
496    let already_registered = wrappers.iter().any(|w| {
497        w.get("exe").and_then(|e| e.as_str()) == Some(&wrapper_exe)
498    });
499
500    if already_registered {
501        info!("modde launch wrapper already registered in Heroic config");
502        return Ok(false);
503    }
504
505    // Insert the modde wrapper. It should go after fgmod (which modifies files)
506    // but before gamemoderun/umu-run. In Heroic, wrappers are chained in order,
507    // so we insert at position 1 (after fgmod at position 0) or at the end.
508    let fgmod_idx = wrappers.iter().position(|w| {
509        w.get("exe")
510            .and_then(|e| e.as_str())
511            .map(|e| e.contains("fgmod"))
512            .unwrap_or(false)
513    });
514
515    let insert_idx = match fgmod_idx {
516        Some(idx) => idx + 1, // After fgmod
517        None => wrappers.len(), // At the end
518    };
519
520    let wrapper_entry = serde_json::json!({
521        "exe": wrapper_exe,
522        "args": "--"
523    });
524
525    wrappers.insert(insert_idx, wrapper_entry);
526
527    // Write back
528    let output = serde_json::to_string_pretty(&config)
529        .context("failed to serialize Heroic config")?;
530    std::fs::write(config_path, output)
531        .with_context(|| format!("failed to write Heroic config: {}", config_path.display()))?;
532
533    println!("  Registered modde launch wrapper in Heroic (position: after fgmod)");
534    info!(wrapper = %wrapper_exe, "registered modde wrapper in Heroic config");
535
536    Ok(true)
537}
538
539// ── Tool environment integration ────────────────────────────────────────
540
541/// Collect all environment variables from enabled tools for a game.
542///
543/// Reads tool configs from the database and calls each tool's `env_vars()`.
544/// Returns a flat list of `(KEY, VALUE)` pairs.
545pub fn collect_tool_env_vars(
546    game_id: &str,
547    db: &modde_core::db::ModdeDb,
548) -> Result<Vec<(String, String)>> {
549    let rows = db.load_tool_configs(game_id)?;
550    let mut all_vars = Vec::new();
551
552    for row in &rows {
553        if !row.enabled {
554            continue;
555        }
556
557        let Some(tool) = crate::tools::resolve_tool(&row.tool_id) else {
558            continue;
559        };
560
561        let mut config = crate::tools::ToolConfig {
562            tool_id: row.tool_id.clone(),
563            enabled: true,
564            settings: serde_json::from_str(&row.settings_json).unwrap_or_default(),
565        };
566        // Inject game_id so tools can build per-game config paths
567        config.set("_game_id", serde_json::json!(game_id));
568
569        all_vars.extend(tool.env_vars(&config));
570    }
571
572    Ok(all_vars)
573}
574
575/// Collect all Wine DLL overrides from enabled tools for a game.
576pub fn collect_tool_dll_overrides(
577    game_id: &str,
578    db: &modde_core::db::ModdeDb,
579) -> Result<Vec<String>> {
580    let rows = db.load_tool_configs(game_id)?;
581    let mut overrides = Vec::new();
582
583    for row in &rows {
584        if !row.enabled {
585            continue;
586        }
587
588        let Some(tool) = crate::tools::resolve_tool(&row.tool_id) else {
589            continue;
590        };
591
592        let config = crate::tools::ToolConfig {
593            tool_id: row.tool_id.clone(),
594            enabled: true,
595            settings: serde_json::from_str(&row.settings_json).unwrap_or_default(),
596        };
597
598        overrides.extend(tool.wine_dll_overrides(&config).into_iter());
599    }
600
601    Ok(overrides)
602}
603
604/// Collect wrapper commands from enabled tools.
605pub fn collect_tool_wrappers(
606    game_id: &str,
607    db: &modde_core::db::ModdeDb,
608) -> Result<Vec<crate::tools::WrapperEntry>> {
609    let rows = db.load_tool_configs(game_id)?;
610    let mut wrappers = Vec::new();
611
612    for row in &rows {
613        if !row.enabled {
614            continue;
615        }
616
617        let Some(tool) = crate::tools::resolve_tool(&row.tool_id) else {
618            continue;
619        };
620
621        let config = crate::tools::ToolConfig {
622            tool_id: row.tool_id.clone(),
623            enabled: true,
624            settings: serde_json::from_str(&row.settings_json).unwrap_or_default(),
625        };
626
627        if let Some(wrapper) = tool.wrapper_command(&config) {
628            wrappers.push(wrapper);
629        }
630    }
631
632    Ok(wrappers)
633}
634
635/// Generate per-game config files for all enabled tools.
636///
637/// Writes configs to `~/.local/share/modde/tools/{game_id}/`.
638pub fn generate_tool_configs(
639    game_id: &str,
640    db: &modde_core::db::ModdeDb,
641) -> Result<()> {
642    let rows = db.load_tool_configs(game_id)?;
643
644    for row in &rows {
645        if !row.enabled {
646            continue;
647        }
648
649        let Some(tool) = crate::tools::resolve_tool(&row.tool_id) else {
650            continue;
651        };
652
653        let mut config = crate::tools::ToolConfig {
654            tool_id: row.tool_id.clone(),
655            enabled: true,
656            settings: serde_json::from_str(&row.settings_json).unwrap_or_default(),
657        };
658        config.set("_game_id", serde_json::json!(game_id));
659
660        if let Some(generated) = tool.generate_config(&config) {
661            if let Some(parent) = generated.path.parent() {
662                std::fs::create_dir_all(parent)?;
663            }
664            std::fs::write(&generated.path, &generated.content)
665                .with_context(|| format!("failed to write tool config: {}", generated.path.display()))?;
666            info!(tool = tool.tool_id(), path = %generated.path.display(), "wrote tool config");
667        }
668    }
669
670    Ok(())
671}
672
673/// Apply tool environment to a Heroic launcher config.
674///
675/// Merges env vars from tools into Heroic's `enviromentOptions` and
676/// registers wrapper commands in `wrapperOptions`.
677pub fn apply_tool_environment_heroic(
678    config_path: &Path,
679    game_id_heroic: &str,
680    env_vars: &[(String, String)],
681    wrappers: &[crate::tools::WrapperEntry],
682) -> Result<()> {
683    if env_vars.is_empty() && wrappers.is_empty() {
684        return Ok(());
685    }
686
687    let data = std::fs::read_to_string(config_path)
688        .with_context(|| format!("failed to read Heroic config: {}", config_path.display()))?;
689
690    let mut config: Value = serde_json::from_str(&data)
691        .with_context(|| format!("failed to parse Heroic config JSON: {}", config_path.display()))?;
692
693    let game_config = config
694        .get_mut(game_id_heroic)
695        .context("game entry not found in Heroic config")?;
696
697    // Merge env vars
698    if !env_vars.is_empty() {
699        let env_options = game_config
700            .get_mut("enviromentOptions")
701            .context("enviromentOptions not found in game config")?;
702        let env_array = env_options
703            .as_array_mut()
704            .context("enviromentOptions is not an array")?;
705
706        for (key, value) in env_vars {
707            // Remove existing entry for this key
708            env_array.retain(|entry| {
709                entry.get("key").and_then(|k| k.as_str()) != Some(key)
710            });
711            env_array.push(serde_json::json!({ "key": key, "value": value }));
712        }
713    }
714
715    // Register wrapper commands
716    if !wrappers.is_empty() {
717        let wrapper_options = game_config
718            .get_mut("wrapperOptions")
719            .context("wrapperOptions not found in game config")?;
720        let wrapper_array = wrapper_options
721            .as_array_mut()
722            .context("wrapperOptions is not an array")?;
723
724        for wrapper in wrappers {
725            let already = wrapper_array.iter().any(|w| {
726                w.get("exe").and_then(|e| e.as_str()) == Some(&wrapper.exe)
727            });
728            if !already {
729                wrapper_array.push(serde_json::json!({
730                    "exe": wrapper.exe,
731                    "args": wrapper.args,
732                }));
733            }
734        }
735    }
736
737    let output = serde_json::to_string_pretty(&config)
738        .context("failed to serialize Heroic config")?;
739    std::fs::write(config_path, output)
740        .with_context(|| format!("failed to write Heroic config: {}", config_path.display()))?;
741
742    if !env_vars.is_empty() {
743        println!("  Applied {} tool env var(s) to Heroic config", env_vars.len());
744    }
745    if !wrappers.is_empty() {
746        println!("  Registered {} tool wrapper(s) in Heroic config", wrappers.len());
747    }
748
749    Ok(())
750}