Skip to main content

thunderstore_engine/profile/
launch.rs

1//! Compute how to launch a game with a target directory's mods applied.
2//!
3//! The engine produces a [`LaunchPlan`](crate::profile::launch::LaunchPlan) — the program to start, its argv, its
4//! environment, and a working directory — and the **caller** spawns it. The engine
5//! never discovers a game directory, never reads the host platform, and never
6//! starts a process; platform facts arrive as explicit [`LaunchContext`](crate::profile::launch::LaunchContext) fields so
7//! every combination is testable.
8//!
9//! Injection is command-line arguments, not config files: a loader package ships
10//! its own `doorstop_config.ini` / proxy DLL / `run_bepinex.sh` into the target at
11//! install time, and the arguments computed here override that ini at runtime.
12//!
13//! Argv is returned as a real argv vector and is **never shell-quoted** — pass it
14//! to `Command::args`, not to a shell.
15
16#![deny(missing_docs)]
17
18use crate::ecosystem::{Ecosystem, GameProfile, InstanceType, LoaderKind};
19use crate::error::{Error, Result};
20use std::path::{Path, PathBuf};
21
22/// The operating system the game will run on.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum HostOs {
26  /// Windows. Loader paths handed to the game are Windows-shaped natively.
27  Windows,
28  /// Linux. The only host where [`LaunchPlan::steam_wrapper`] can apply, and the
29  /// only one where [`Runtime::Proton`] is meaningful.
30  Linux,
31  /// macOS. Rejected by [`launch_plan_in`], which cannot compute a correct plan
32  /// because r2modman injects through a compiled proxy binary with no available
33  /// source.
34  MacOs,
35}
36
37impl HostOs {
38  /// The operating system this build is running on, or `None` where launching is
39  /// not supported.
40  ///
41  /// Reads the compile-time target rather than a runtime probe, because a build
42  /// cannot run on a platform it was not compiled for.
43  pub fn detect() -> Option<Self> {
44    if cfg!(target_os = "windows") {
45      Some(Self::Windows)
46    } else if cfg!(target_os = "linux") {
47      Some(Self::Linux)
48    } else if cfg!(target_os = "macos") {
49      Some(Self::MacOs)
50    } else {
51      None
52    }
53  }
54}
55
56/// How the game is distributed, which decides what process is started.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum StorePlatform {
60  /// Launch through Steam (`-applaunch <id>`).
61  Steam,
62  /// A Steam copy launched by executing the game binary directly.
63  SteamDirect,
64  /// Any other store: execute the game binary directly.
65  Other,
66}
67
68impl std::str::FromStr for StorePlatform {
69  type Err = Error;
70
71  /// Parses the spelling a client's configuration uses.
72  fn from_str(value: &str) -> Result<Self> {
73    match value {
74      "steam" => Ok(Self::Steam),
75      "steam-direct" => Ok(Self::SteamDirect),
76      "other" => Ok(Self::Other),
77      other => Err(Error::Profile(format!(
78        "{other:?} is not a known store platform; expected steam, steam-direct, \
79         or other"
80      ))),
81    }
82  }
83}
84
85/// Whether the game runs natively or under Proton/Wine.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87#[non_exhaustive]
88pub enum Runtime {
89  /// The game runs as a native binary for [`HostOs`].
90  Native,
91  /// The game is a Windows build running under Proton or Wine, so paths handed
92  /// to it are Windows-shaped.
93  Proton,
94}
95
96impl std::str::FromStr for Runtime {
97  type Err = Error;
98
99  /// Parses the spelling a client's configuration uses.
100  fn from_str(value: &str) -> Result<Self> {
101    match value {
102      "native" => Ok(Self::Native),
103      "proton" => Ok(Self::Proton),
104      other => Err(Error::Profile(format!(
105        "{other:?} is not a known runtime; expected native or proton"
106      ))),
107    }
108  }
109}
110
111/// Launch with mods applied, or without.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113#[non_exhaustive]
114pub enum LaunchMode {
115  /// Inject the loader so the target directory's mods are applied.
116  Modded,
117  /// Start the game with the loader explicitly disabled.
118  ///
119  /// Installed files stay on disk untouched, so this is a per-launch toggle
120  /// rather than an uninstall. Most loaders take an explicit off switch here
121  /// (`--vanilla`, `-vanilla`, `--gdweave-disable`) rather than simply omitting
122  /// the on switch, because a loader already staged into the game directory
123  /// would otherwise still initialise.
124  Vanilla,
125}
126
127/// The process the caller should start.
128#[derive(Debug, Clone, PartialEq, Eq)]
129#[non_exhaustive]
130pub enum LaunchProgram {
131  /// Execute the game binary. The caller resolves which of `exe_names` exists in
132  /// its game directory.
133  GameExe {
134    /// Candidate binary names, in preference order.
135    exe_names: Vec<String>,
136  },
137  /// Execute Steam with `-applaunch <steam_app_id>`.
138  Steam {
139    /// The Steam application id to hand to `-applaunch`.
140    steam_app_id: u32,
141  },
142  /// Execute a script the loader package shipped into the target directory (one
143  /// of [`LOADER_SCRIPTS`]), passing the game binary and then [`LaunchPlan::args`]
144  /// after it. The caller resolves which of `exe_names` exists in its game
145  /// directory, exactly as for [`LaunchProgram::GameExe`].
146  ProfileScript {
147    /// The loader script inside the target directory to execute.
148    path: PathBuf,
149    /// Candidate game binary names, in preference order, passed to the script
150    /// ahead of [`LaunchPlan::args`].
151    exe_names: Vec<String>,
152  },
153}
154
155/// What a caller needs to install the wrapper script for a Linux native Steam
156/// launch, carried by [`LaunchPlan::steam_wrapper`] when that combination applies.
157///
158/// Steam's launch options are a single fixed `%command%` string, so a file must sit
159/// between Steam and the game; [`LaunchPlan::program`] alone cannot express that.
160/// Neither [`write_wrapper_script`] nor [`steam_launch_options`] needs anything
161/// from here — both take only a path, because the script names no target and the
162/// options string names no loader. This type is what tells a caller the wrapper is
163/// required at all.
164///
165/// The wrapper is written **once** and never rewritten: it resolves its target at
166/// run time from [`WRAPPER_TARGET_FLAG`], which [`launch_plan_in`] has already
167/// appended to [`LaunchPlan::args`] whenever it populates this field. A caller
168/// spawns the plan as computed and the correct target follows automatically, so
169/// the launch-options string a user pasted stays valid across every target.
170#[derive(Debug, Clone, PartialEq, Eq)]
171#[non_exhaustive]
172pub struct SteamWrapper {
173  /// The target directory the wrapper chainloads from, which reaches it as a
174  /// run-time argument already present in [`LaunchPlan::args`]. Reported here so
175  /// a caller can display or log it, not because the wrapper needs writing for it.
176  pub target_dir: PathBuf,
177  /// The loader this target injects through, reported for display and logging.
178  ///
179  /// No longer feeds [`steam_launch_options`], which dropped its `WINEDLLOVERRIDES`
180  /// and with it the need for a loader. [`proxy_dll`] still maps this to a DLL name
181  /// for a caller setting up a Wine prefix by hand.
182  pub loader: LoaderKind,
183}
184
185/// A fully computed launch invocation.
186///
187/// `#[non_exhaustive]`: fields may be added as more platforms are supported.
188#[derive(Debug, Clone, PartialEq, Eq)]
189#[non_exhaustive]
190pub struct LaunchPlan {
191  /// What to execute.
192  pub program: LaunchProgram,
193  /// Arguments, in order, already resolved. Never shell-quoted.
194  pub args: Vec<String>,
195  /// Environment variables to set for the child process.
196  ///
197  /// **Only honoured for a program the caller spawns directly.** When
198  /// [`Self::program`] is [`LaunchProgram::Steam`], these are set on the
199  /// short-lived `steam` process, and `steam -applaunch <id>` merely messages an
200  /// already-running Steam client, which starts the game with *its own*
201  /// environment. Nothing here reaches the game in that case. The visible
202  /// consequence is a Proton + Steam launch silently starting unmodded: without
203  /// `WINEDLLOVERRIDES`, Wine loads its builtin proxy instead of the loader's and
204  /// BepInEx never initialises, leaving no log to explain why. A caller needing the
205  /// override under Steam must put it in the Wine prefix ([`proxy_dll`] gives the
206  /// name), not here.
207  pub env: Vec<(String, String)>,
208  /// Working directory, when it matters.
209  pub working_dir: Option<PathBuf>,
210  /// `Some` when starting [`Self::program`] is not sufficient on its own: a Linux
211  /// native Steam launch injects through a loader script that Steam will not run
212  /// unless a wrapper script is installed in its launch options. See
213  /// [`SteamWrapper`], [`write_wrapper_script`], and [`steam_launch_options`].
214  pub steam_wrapper: Option<SteamWrapper>,
215}
216
217/// The platform facts a launch computation needs, supplied by the caller.
218#[derive(Debug, Clone, PartialEq, Eq)]
219#[non_exhaustive]
220pub struct LaunchContext {
221  /// The operating system the game will run on. Supplied rather than detected so
222  /// every combination stays testable; [`HostOs::detect`] is the caller's opt-in.
223  pub os: HostOs,
224  /// How the game is distributed, which decides what process is started and
225  /// whether the environment in [`LaunchPlan::env`] can reach the game at all.
226  pub store: StorePlatform,
227  /// Whether the game runs natively or under Proton/Wine, which decides whether
228  /// paths in the computed argv are Unix- or Windows-shaped.
229  pub runtime: Runtime,
230  /// Whether to inject the loader or start the game vanilla.
231  pub mode: LaunchMode,
232  /// Caller/user-supplied arguments, appended after the loader's own.
233  pub extra_args: Vec<String>,
234}
235
236impl LaunchContext {
237  /// Builds a [`LaunchContext`] with an empty `extra_args`.
238  ///
239  /// `LaunchContext` is `#[non_exhaustive]`, so outside this crate it cannot be
240  /// built with a struct literal, nor updated with functional-update (`..`)
241  /// syntax — both are struct-literal forms and `#[non_exhaustive]` forbids both
242  /// from other crates. This constructor is the supported way for a downstream
243  /// crate (including this crate's own `tests/*.rs`, which compile as separate
244  /// crates) to construct one; use [`Self::with_extra_args`] to add extras.
245  pub fn new(os: HostOs, store: StorePlatform, runtime: Runtime, mode: LaunchMode) -> Self {
246    Self {
247      os,
248      store,
249      runtime,
250      mode,
251      extra_args: Vec::new(),
252    }
253  }
254
255  /// Returns `self` with `extra_args` replaced, for chaining after [`Self::new`].
256  pub fn with_extra_args(self, extra_args: Vec<String>) -> Self {
257    Self { extra_args, ..self }
258  }
259}
260
261/// Preloader assembly names a BepInEx package may ship, in the order r2modman
262/// lists them. The first one present in `<target>/BepInEx/core` wins.
263pub const PRELOADER_CANDIDATES: [&str; 5] = [
264  "BepInEx.Unity.Mono.Preloader.dll",
265  "BepInEx.Unity.IL2CPP.dll",
266  "BepInEx.Preloader.dll",
267  "BepInEx.IL2CPP.dll",
268  "BepInEx.NET.CoreCLR.dll",
269];
270
271/// The loader scripts a BepInEx/BepisLoader package may ship into the target, in
272/// precedence order. Both the resolver behind [`launch_plan_in`] and the wrapper
273/// script from [`wrapper_script_contents`] chainload the first one present, so the
274/// two agree by construction.
275///
276/// `start_server_bepinex.sh` is only ever selected for an
277/// [`InstanceType::Server`] profile — running it for a game instance would start a
278/// dedicated server. The wrapper, which has no ecosystem profile to consult at run
279/// time, gates it on the `TS_START_SERVER` environment variable instead.
280pub const LOADER_SCRIPTS: [&str; 3] = [
281  "start_server_bepinex.sh",
282  "run_bepinex.sh",
283  "start_game_bepinex.sh",
284];
285
286/// The dedicated-server entry of [`LOADER_SCRIPTS`].
287const SERVER_SCRIPT: &str = LOADER_SCRIPTS[0];
288
289/// The refusal both [`launch_plan_in`] and [`stage_for_launch_in`] return for macOS:
290/// no plan can be computed, so nothing may be written into a game directory either.
291const MACOS_UNSUPPORTED: &str =
292  "macOS launch is not supported: the injection chain requires r2modman's closed-source proxy";
293
294/// Reads the Doorstop major version from `<target_dir>/.doorstop_version`,
295/// defaulting to `3` when the file is missing or unparseable.
296///
297/// A major of `4` or higher selects the v4 argument spellings. r2modman instead
298/// falls back to v3 for anything above 4, which mis-launches a future Doorstop;
299/// we deliberately do not reproduce that.
300pub fn doorstop_version(target_dir: &Path) -> u32 {
301  let path = target_dir.join(".doorstop_version");
302
303  let text = match std::fs::read_to_string(&path) {
304    Ok(text) => text,
305    Err(_) => return 3,
306  };
307
308  let major = text
309    .trim()
310    .split('.')
311    .next()
312    .and_then(|part| part.parse::<u32>().ok());
313
314  match major {
315    Some(version) if version >= 3 => version,
316    _ => 3,
317  }
318}
319
320/// Renders `path` for a Wine/Proton prefix: forward slashes, `Z:` root mapping.
321fn wine_path(path: &Path) -> String {
322  format!("Z:{}", path.to_string_lossy().replace('\\', "/"))
323}
324
325/// Locates the BepInEx preloader assembly inside `<target_dir>/BepInEx/core`.
326///
327/// Under [`Runtime::Proton`] the path is rendered for the Wine prefix (`Z:` +
328/// forward slashes), which is how the Windows-side Doorstop resolves a Linux path.
329pub fn bepinex_preloader(target_dir: &Path, runtime: Runtime) -> Result<String> {
330  let core = target_dir.join("BepInEx").join("core");
331
332  let found = PRELOADER_CANDIDATES
333    .iter()
334    .map(|name| core.join(name))
335    .find(|candidate| candidate.is_file())
336    .ok_or_else(|| Error::Profile(format!("no BepInEx preloader found in {}", core.display())))?;
337
338  let rendered = match runtime {
339    Runtime::Proton => wine_path(&found),
340    Runtime::Native => found.to_string_lossy().replace('\\', "/"),
341  };
342
343  Ok(rendered)
344}
345
346/// Whether the target ships unstripped corlibs, which need a Doorstop search-path
347/// override.
348pub fn has_corlibs(target_dir: &Path) -> bool {
349  target_dir.join("unstripped_corlib").is_dir()
350}
351
352/// Renders a target-relative path as an argument value, using forward slashes so
353/// a Windows-side loader reads it consistently.
354fn arg_path(target_dir: &Path, relative: &str) -> String {
355  target_dir
356    .join(relative)
357    .to_string_lossy()
358    .replace('\\', "/")
359}
360
361/// The loader's own arguments for the requested mode, before any caller-supplied
362/// extras. An unrecognized loader contributes nothing rather than erroring, so a
363/// newer ecosystem schema degrades to a vanilla launch.
364///
365/// For [`LoaderKind::BepInEx`] and [`LoaderKind::Umm`], the Doorstop flag
366/// spellings chosen depend on more than `.doorstop_version`: this function also
367/// stats `target_dir` (via `is_script_injected`) to check whether a loader
368/// script is present, because a script-injected launch forces the v4 spellings
369/// regardless of the detected version (see `doorstop_args`'s doc comment).
370/// `launch_plan_in` separately computes the same `ScriptInjection` fact for
371/// `resolve_program`/[`LaunchPlan::steam_wrapper`], so a single call to
372/// [`launch_plan_in`] reads that presence off disk twice — same rule, two
373/// moments, harmless for a synchronous, non-hot-path computation.
374pub fn loader_args(
375  target_dir: &Path,
376  profile: &GameProfile,
377  ctx: &LaunchContext,
378) -> Result<Vec<String>> {
379  let target = target_dir.to_string_lossy().replace('\\', "/");
380
381  let args = match profile.package_loader {
382    LoaderKind::BepInEx => {
383      let script_injected = is_script_injected(target_dir, profile, ctx);
384
385      doorstop_args(
386        target_dir,
387        profile,
388        ctx,
389        DoorstopTarget::BepInEx,
390        script_injected,
391      )?
392    }
393    LoaderKind::BepisLoader => bepis_args(target_dir, profile, ctx)?,
394    LoaderKind::Umm => {
395      let script_injected = is_script_injected(target_dir, profile, ctx);
396
397      doorstop_args(
398        target_dir,
399        profile,
400        ctx,
401        DoorstopTarget::Umm,
402        script_injected,
403      )?
404    }
405    LoaderKind::MelonLoader | LoaderKind::RecursiveMelonLoader => {
406      melonloader_args(target_dir, ctx, &target)
407    }
408    LoaderKind::GdWeave => match ctx.mode {
409      LaunchMode::Modded => vec![format!(
410        "--gdweave-folder-override={}",
411        arg_path(target_dir, "GDWeave")
412      )],
413      LaunchMode::Vanilla => vec!["--gdweave-disable".to_string()],
414    },
415    LoaderKind::Lovely => match ctx.mode {
416      LaunchMode::Modded => vec!["--mod-dir".to_string(), arg_path(target_dir, "mods")],
417      LaunchMode::Vanilla => vec!["--vanilla".to_string()],
418    },
419    LoaderKind::Northstar => match ctx.mode {
420      LaunchMode::Modded => vec![
421        "-northstar".to_string(),
422        format!("-profile={}", arg_path(target_dir, "R2Northstar")),
423      ],
424      LaunchMode::Vanilla => vec!["-vanilla".to_string()],
425    },
426    LoaderKind::GodotMl => match ctx.mode {
427      LaunchMode::Modded => vec![
428        "--script".to_string(),
429        "res://addons/mod_loader/mod_loader_setup.gd".to_string(),
430        "--enable-mods".to_string(),
431        "--mods-path".to_string(),
432        arg_path(target_dir, "mods"),
433      ],
434      LaunchMode::Vanilla => Vec::new(),
435    },
436    LoaderKind::Shimloader => match ctx.mode {
437      LaunchMode::Modded => vec![
438        "--mod-dir".to_string(),
439        arg_path(target_dir, "shimloader/mod"),
440        "--pak-dir".to_string(),
441        arg_path(target_dir, "shimloader/pak"),
442        "--cfg-dir".to_string(),
443        arg_path(target_dir, "shimloader/cfg"),
444        "--overlay-dir".to_string(),
445        arg_path(target_dir, "shimloader/overlay"),
446      ],
447      LaunchMode::Vanilla => Vec::new(),
448    },
449    LoaderKind::ReturnOfModding => match ctx.mode {
450      LaunchMode::Modded => vec!["--rom_modding_root_folder".to_string(), target.clone()],
451      LaunchMode::Vanilla => vec!["--rom_enabled".to_string(), "false".to_string()],
452    },
453    LoaderKind::Rivet => match ctx.mode {
454      LaunchMode::Modded => vec![
455        "-rivetEnable".to_string(),
456        "true".to_string(),
457        "-rivetTarget".to_string(),
458        arg_path(target_dir, "Rivet/Loader.dll"),
459        "-rivetDirectory".to_string(),
460        arg_path(target_dir, "Rivet/Mods"),
461      ],
462      LaunchMode::Vanilla => vec!["-rivetEnable".to_string(), "false".to_string()],
463    },
464    // `None`, `Other`, and any loader a newer schema adds inject nothing: their
465    // files simply sit in the game directory.
466    _ => Vec::new(),
467  };
468
469  Ok(args)
470}
471
472/// Which assembly a Doorstop-based loader targets.
473#[derive(Debug, Clone, Copy, PartialEq, Eq)]
474enum DoorstopTarget {
475  BepInEx,
476  Umm,
477}
478
479/// Builds Doorstop arguments for BepInEx or UMM, using the flag spellings of the
480/// version detected in the target.
481///
482/// Unlike r2modman, the *vanilla* flag matches the detected version too (r2modman
483/// emits the v3 spelling even under v4).
484///
485/// `script_injected` overrides that version match: when this argv is headed for
486/// the loader script (`run_bepinex.sh` / `start_game_bepinex.sh` /
487/// `start_server_bepinex.sh`) rather than straight to Doorstop's own
488/// command-line parser, the v4 spellings are used unconditionally, regardless
489/// of what `.doorstop_version` says. That script is a Doorstop-v4-style front
490/// end no matter which Doorstop generation it wraps: verified against BepInEx's
491/// source, it recognizes only the v4 flags (`--doorstop-enabled`,
492/// `--doorstop-target-assembly`, `--doorstop-mono-dll-search-path-override`,
493/// …) and silently drops the v3 spellings (`--doorstop-enable`,
494/// `--doorstop-target`, `--doorstop-dll-search-override`) as unmatched
495/// passthrough arguments before `exec`-ing the game. Keeping the version-matched
496/// v3 spelling here for a v3-detected target would leave the script's own
497/// `enabled` default (`"1"`) in effect and its own default target assembly
498/// unset by us — silently launching modded when vanilla was requested, and the
499/// wrong assembly when modded was requested. This exception does not apply
500/// off the script path (Windows, Proton, or a Linux native direct-exe launch),
501/// where the version match above is intentional and must be preserved.
502fn doorstop_args(
503  target_dir: &Path,
504  profile: &GameProfile,
505  ctx: &LaunchContext,
506  target: DoorstopTarget,
507  script_injected: bool,
508) -> Result<Vec<String>> {
509  let version = doorstop_version(target_dir);
510  let v4 = script_injected || version >= 4;
511
512  let (enable_flag, target_flag, search_flag) = if v4 {
513    (
514      "--doorstop-enabled",
515      "--doorstop-target-assembly",
516      "--doorstop-mono-dll-search-path-override",
517    )
518  } else {
519    (
520      "--doorstop-enable",
521      "--doorstop-target",
522      "--doorstop-dll-search-override",
523    )
524  };
525
526  if ctx.mode == LaunchMode::Vanilla {
527    return Ok(vec![enable_flag.to_string(), "false".to_string()]);
528  }
529
530  let assembly = match target {
531    DoorstopTarget::BepInEx => bepinex_preloader(target_dir, ctx.runtime)?,
532    DoorstopTarget::Umm => arg_path(target_dir, "UMM/Core/UnityModManager.dll"),
533  };
534
535  let mut args = vec![
536    enable_flag.to_string(),
537    "true".to_string(),
538    target_flag.to_string(),
539    assembly,
540  ];
541
542  if target == DoorstopTarget::BepInEx && profile.instance_type == InstanceType::Server {
543    args.push("--server".to_string());
544  }
545
546  if has_corlibs(target_dir) {
547    args.push(search_flag.to_string());
548    args.push(arg_path(target_dir, "unstripped_corlib"));
549  }
550
551  Ok(args)
552}
553
554/// BepisLoader (Resonite): a hookfxr hook plus a hardcoded Doorstop v4 target.
555fn bepis_args(
556  target_dir: &Path,
557  profile: &GameProfile,
558  ctx: &LaunchContext,
559) -> Result<Vec<String>> {
560  if ctx.mode == LaunchMode::Vanilla {
561    return Ok(vec![
562      "--hookfxr-disable".to_string(),
563      "--doorstop-enabled".to_string(),
564      "false".to_string(),
565    ]);
566  }
567
568  let mut args = vec![
569    "--hookfxr-enable".to_string(),
570    "--bepinex-target".to_string(),
571    arg_path(target_dir, "BepInEx"),
572    "--doorstop-enabled".to_string(),
573    "true".to_string(),
574    "--doorstop-target-assembly".to_string(),
575    bepinex_preloader(target_dir, ctx.runtime)?,
576  ];
577
578  if profile.instance_type == InstanceType::Server {
579    args.push("--server".to_string());
580  }
581
582  if has_corlibs(target_dir) {
583    args.push("--doorstop-mono-dll-search-path-override".to_string());
584    args.push(arg_path(target_dir, "unstripped_corlib"));
585  }
586
587  Ok(args)
588}
589
590/// MelonLoader: point it at the target, and ask it to regenerate its assembly
591/// cache when neither the ML 0.5 nor the ML 0.6 assembly layout is present.
592fn melonloader_args(target_dir: &Path, ctx: &LaunchContext, target: &str) -> Vec<String> {
593  if ctx.mode == LaunchMode::Vanilla {
594    return vec!["--no-mods".to_string()];
595  }
596
597  let mut args = vec!["--melonloader.basedir".to_string(), target.to_string()];
598
599  let ml_five = target_dir
600    .join("MelonLoader/Managed/Assembly-CSharp.dll")
601    .is_file();
602  let ml_six = target_dir
603    .join("MelonLoader/Il2CppAssemblies/Assembly-CSharp.dll")
604    .is_file();
605
606  if !ml_five && !ml_six {
607    args.push("--melonloader.agfregenerate".to_string());
608  }
609
610  args
611}
612
613/// The Wine proxy DLL a loader hooks through under Proton.
614pub fn proxy_dll(loader: LoaderKind) -> &'static str {
615  match loader {
616    LoaderKind::GdWeave => "winmm",
617    _ => "winhttp",
618  }
619}
620
621/// Whether this launch injects through a loader script, and which one.
622///
623/// The single fact both `resolve_program` and [`stage_for_launch_in`] decide
624/// from, so a plan can never assume script injection while staging assumes the
625/// proxy-DLL path, or the reverse.
626#[derive(Debug, Clone, PartialEq, Eq)]
627enum ScriptInjection {
628  /// Not a script-injected combination: the proxy DLL beside the executable does
629  /// the injecting, and staging must put it there.
630  NotApplicable,
631  /// Script injection applies and the target ships this script.
632  Found(PathBuf),
633  /// Script injection applies but the target ships none of the scripts looked for.
634  Missing,
635}
636
637impl ScriptInjection {
638  /// Whether script injection applies at all — true even for [`Self::Missing`],
639  /// because a Linux native script-injected launch never wants proxy files copied
640  /// into its game directory regardless of which scripts the target happens to
641  /// ship.
642  fn applies(&self) -> bool {
643    !matches!(self, Self::NotApplicable)
644  }
645}
646
647/// The scripts to look for, in precedence order, for this profile's instance type.
648fn script_candidates(profile: &GameProfile) -> Vec<&'static str> {
649  match profile.instance_type {
650    InstanceType::Server => LOADER_SCRIPTS.to_vec(),
651    _ => LOADER_SCRIPTS
652      .iter()
653      .copied()
654      .filter(|name| *name != SERVER_SCRIPT)
655      .collect(),
656  }
657}
658
659/// Resolves whether this loader injects through a shell script the package ships
660/// into the target, rather than through a proxy DLL next to the executable, and
661/// which script that is.
662///
663/// The store is deliberately *not* part of this decision: under Steam the same
664/// script is chainloaded, just by the wrapper Steam invokes rather than by the
665/// caller (see [`SteamWrapper`]).
666fn script_injection(
667  target_dir: &Path,
668  profile: &GameProfile,
669  ctx: &LaunchContext,
670) -> ScriptInjection {
671  let script_loader = matches!(
672    profile.package_loader,
673    LoaderKind::BepInEx | LoaderKind::BepisLoader
674  );
675
676  if !script_loader || ctx.os != HostOs::Linux || ctx.runtime != Runtime::Native {
677    return ScriptInjection::NotApplicable;
678  }
679
680  let found = script_candidates(profile)
681    .into_iter()
682    .map(|name| target_dir.join(name))
683    .find(|candidate| candidate.is_file());
684
685  match found {
686    Some(path) => ScriptInjection::Found(path),
687    None => ScriptInjection::Missing,
688  }
689}
690
691/// Whether `doorstop_args` must use the v4 flag spellings unconditionally
692/// because a loader script — not Doorstop's own parser — is what will read
693/// this argv. See `doorstop_args`'s doc comment for why that overrides the
694/// version match.
695///
696/// `false` for [`ScriptInjection::Missing`] as well as [`ScriptInjection::NotApplicable`]:
697/// when the target ships none of [`LOADER_SCRIPTS`], the argv reaches the game
698/// (or, under Steam, the wrapper's `exec "$@"` fallthrough) directly, so the
699/// version-matched spelling is the correct one there too.
700fn is_script_injected(target_dir: &Path, profile: &GameProfile, ctx: &LaunchContext) -> bool {
701  matches!(
702    script_injection(target_dir, profile, ctx),
703    ScriptInjection::Found(_)
704  )
705}
706
707/// Computes the launch invocation for `target_dir`.
708///
709/// The caller supplies the platform facts in `ctx` and spawns the returned
710/// [`LaunchPlan`]; the engine neither detects the host nor starts a process.
711///
712/// A Linux native Steam launch cannot be expressed by [`LaunchPlan::program`]
713/// alone — that plan carries a [`LaunchPlan::steam_wrapper`] the user must install
714/// into Steam's launch options first, and the caller need not re-derive when.
715/// Whenever that field is populated, [`wrapper_target_args`] is already appended
716/// to [`LaunchPlan::args`], so the once-pasted wrapper resolves this call's
717/// `target_dir` and not whichever one was current when it was written.
718///
719/// Under [`LaunchMode::Modded`], a combination that injects through a loader script
720/// whose target ships none of [`LOADER_SCRIPTS`] is an error, not a silent
721/// fallthrough to a proxy-DLL plan that would launch the game unmodded.
722///
723/// macOS is not supported: r2modman injects through a compiled proxy binary with
724/// no available source, so a correct plan cannot be computed (see the design
725/// spec's non-goals).
726///
727/// # Errors
728///
729/// - [`Error::Profile`] if `ctx.os` is
730///   [`HostOs::MacOs`], if `game` is unknown to `eco`, or if a
731///   [`LaunchMode::Modded`] plan needs a loader script that `target_dir` does
732///   not contain.
733///
734/// # Examples
735///
736/// The engine computes the invocation; the caller spawns it:
737///
738/// ```no_run
739/// use std::path::Path;
740/// use std::process::Command;
741/// use thunderstore_engine::ecosystem::Ecosystem;
742/// use thunderstore_engine::profile::launch::{
743///   HostOs, LaunchContext, LaunchMode, Runtime, StorePlatform, launch_plan_in,
744/// };
745///
746/// let eco = Ecosystem::bundled();
747///
748/// let ctx = LaunchContext::new(
749///   HostOs::Linux,
750///   StorePlatform::SteamDirect,
751///   Runtime::Proton,
752///   LaunchMode::Modded,
753/// );
754///
755/// let plan = launch_plan_in(Path::new("/games/valheim/profile"), &eco, "valheim", &ctx)?;
756///
757/// let mut command = plan.to_command(Path::new("/games/valheim"))?;
758///
759/// command.spawn()?;
760/// # Ok::<(), thunderstore_engine::Error>(())
761/// ```
762pub fn launch_plan_in(
763  target_dir: &Path,
764  eco: &Ecosystem,
765  game: &str,
766  ctx: &LaunchContext,
767) -> Result<LaunchPlan> {
768  if ctx.os == HostOs::MacOs {
769    return Err(Error::Profile(MACOS_UNSUPPORTED.to_string()));
770  }
771
772  let profile = eco
773    .game(game)
774    .and_then(|g| g.profile())
775    .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
776
777  // Computed before the argv so the loader-script decision is available for
778  // `loader_args` to fold into its Doorstop flag spellings (see
779  // `is_script_injected`), not just for `resolve_program` below.
780  let injection = script_injection(target_dir, profile, ctx);
781  let mut args = loader_args(target_dir, profile, ctx)?;
782
783  args.extend(ctx.extra_args.iter().cloned());
784
785  let program = resolve_program(&injection, target_dir, profile, ctx)?;
786  let needs_wrapper = injection.applies() && ctx.store == StorePlatform::Steam;
787
788  // The wrapper is written once and never rewritten, so it learns its target from
789  // this argv rather than from its own contents. Appending here — rather than
790  // documenting that the caller must — is what keeps a stale paste in Steam's
791  // launch options from silently launching the previous target's mods.
792  if needs_wrapper {
793    args.extend(wrapper_target_args(target_dir));
794  }
795
796  let steam_wrapper = needs_wrapper.then(|| SteamWrapper {
797    target_dir: target_dir.to_path_buf(),
798    loader: profile.package_loader,
799  });
800
801  let mut env = Vec::new();
802
803  if ctx.runtime == Runtime::Proton {
804    env.push((
805      "WINEDLLOVERRIDES".to_string(),
806      format!("{}=n,b", proxy_dll(profile.package_loader)),
807    ));
808  }
809
810  Ok(LaunchPlan {
811    program,
812    args,
813    env,
814    working_dir: None,
815    steam_wrapper,
816  })
817}
818
819/// Chooses what process to start for this store/platform combination.
820///
821/// Under Steam the loader script is not started directly even when one is present:
822/// Steam's launch options are a fixed `%command%` string, so the wrapper script
823/// reported through [`LaunchPlan::steam_wrapper`] is what chainloads it.
824fn resolve_program(
825  injection: &ScriptInjection,
826  target_dir: &Path,
827  profile: &GameProfile,
828  ctx: &LaunchContext,
829) -> Result<LaunchProgram> {
830  if *injection == ScriptInjection::Missing && ctx.mode == LaunchMode::Modded {
831    return Err(Error::Profile(format!(
832      "modded launch injects through a loader script, but none of {} exist in {}",
833      script_candidates(profile).join(", "),
834      target_dir.display(),
835    )));
836  }
837
838  if let ScriptInjection::Found(path) = injection
839    && ctx.store != StorePlatform::Steam
840  {
841    return Ok(LaunchProgram::ProfileScript {
842      path: path.clone(),
843      exe_names: profile.exe_names.clone(),
844    });
845  }
846
847  if ctx.store == StorePlatform::Steam {
848    let steam_app_id = profile.steam_app_id().ok_or_else(|| {
849      Error::Profile("Steam launch requested but the game has no Steam app id".to_string())
850    })?;
851
852    return Ok(LaunchProgram::Steam { steam_app_id });
853  }
854
855  Ok(LaunchProgram::GameExe {
856    exe_names: profile.exe_names.clone(),
857  })
858}
859
860/// Computes the launch invocation for an r2modman-layout profile.
861/// See [`launch_plan_in`].
862pub fn launch_plan(
863  base: &Path,
864  game: &str,
865  profile_name: &str,
866  eco: &Ecosystem,
867  ctx: &LaunchContext,
868) -> Result<LaunchPlan> {
869  launch_plan_in(
870    &crate::profile::layout::profile_dir(base, game, profile_name),
871    eco,
872    game,
873    ctx,
874  )
875}
876
877/// The flag that tells a generated wrapper which target directory to chainload.
878///
879/// Appended, with the target directory after it, to the argv a caller sends
880/// through Steam — see [`wrapper_target_args`]. The wrapper consumes both words
881/// and forwards everything else untouched, so the game never sees this flag.
882pub const WRAPPER_TARGET_FLAG: &str = "--ts-target";
883
884/// The arguments that make a generated wrapper chainload `target_dir`.
885///
886/// [`launch_plan_in`] already appends these to [`LaunchPlan::args`] whenever it
887/// populates [`LaunchPlan::steam_wrapper`], so a caller spawning a computed plan
888/// never needs to call this itself. It is public for callers assembling an argv
889/// by hand, and for tests.
890pub fn wrapper_target_args(target_dir: &Path) -> Vec<String> {
891  vec![
892    WRAPPER_TARGET_FLAG.to_string(),
893    target_dir.to_string_lossy().replace('\\', "/"),
894  ]
895}
896
897/// Builds the POSIX shell wrapper that Steam invokes on Linux native launches.
898///
899/// Steam launch options are a single fixed `%command%` string, so a file must sit
900/// between Steam and the game. Steam also stores exactly **one** such string per
901/// game, so this script is written once and must stay correct for every target
902/// afterwards. It therefore carries no target of its own — nothing about it is
903/// specific to a profile, which is why it never needs regenerating:
904///
905/// - `--ts-target <dir>` ([`WRAPPER_TARGET_FLAG`]) anywhere in the argv Steam
906///   forwards selects that directory. [`launch_plan_in`] appends it for the
907///   caller, so switching targets needs no change to the script or to Steam.
908/// - Absent that flag — a bare launch straight from Steam, with no manager
909///   involved — the game starts **unmodded**. There is no default profile: a
910///   wrapper that quietly loaded one would make "launch from Steam" mean
911///   whichever target happened to be baked in, which is the ambiguity this whole
912///   mechanism exists to remove. A value that names something other than a
913///   directory is likewise treated as no target at all rather than chainloaded
914///   blindly, so a deleted profile yields a vanilla launch, not a broken one.
915///
916/// This matches r2modman's `linux_wrapper.sh` — which reads `--r2profile` from
917/// argv and launches vanilla without it — with one deliberate difference: it
918/// passes a profile *name* and rebuilds `<root>/profiles/<name>`, assuming the
919/// r2modman layout, whereas this takes a resolved absolute directory and so also
920/// serves a bare target such as a dedicated server's game directory.
921///
922/// The flag and its value are consumed here, never forwarded; every other
923/// argument reaches the loader script in its original order, quoted, so a target
924/// or game argument containing spaces survives.
925///
926/// The branches are generated from [`LOADER_SCRIPTS`], the same list the resolver
927/// behind [`launch_plan_in`] searches, so the wrapper and the plan cannot disagree
928/// about which scripts count. Falls through to `exec "$@"` — a vanilla launch —
929/// when the resolved target ships none of them.
930pub fn wrapper_script_contents() -> String {
931  let mut branches = String::new();
932
933  for (index, name) in LOADER_SCRIPTS.iter().enumerate() {
934    let keyword = if index == 0 { "if" } else { "elif" };
935    // The wrapper has no ecosystem profile at run time, so an env var stands in
936    // for `InstanceType::Server`.
937    let server_gate = if *name == SERVER_SCRIPT {
938      r#" && [ -n "$TS_START_SERVER" ]"#
939    } else {
940      ""
941    };
942
943    branches.push_str(&format!(
944      "{keyword} [ -f \"$TARGET/{name}\" ]{server_gate}; then\n  exec \"$TARGET/{name}\" \"$@\"\n"
945    ));
946  }
947
948  // The loop rotates the positional parameters exactly `$#` times: each argument
949  // is shifted off the front and, unless it is the flag or the flag's value,
950  // appended to the back. `remaining` counts only the not-yet-inspected ones, so
951  // already-appended arguments are never re-inspected and the surviving argv ends
952  // up in its original order. This is the POSIX way to filter "$@" without
953  // arrays and without `eval`.
954  format!(
955    r#"#!/bin/sh
956# Generated by thunderstore-engine. Chainloads the mod loader for a target.
957#
958# Paste this into Steam's launch options once; it never needs regenerating, and
959# it names no profile. The target directory is read from
960# `{WRAPPER_TARGET_FLAG} <dir>` when a manager appends it to the launch
961# arguments. Without it — launching straight from Steam — the game starts
962# unmodded. The flag is consumed here and never reaches the game.
963TARGET=""
964
965remaining=$#
966
967while [ "$remaining" -gt 0 ]; do
968  argument="$1"
969  shift
970  remaining=$((remaining - 1))
971
972  if [ "$argument" = "{WRAPPER_TARGET_FLAG}" ]; then
973    if [ "$remaining" -gt 0 ]; then
974      candidate="$1"
975      shift
976      remaining=$((remaining - 1))
977
978      if [ -d "$candidate" ]; then
979        TARGET="$candidate"
980      fi
981    fi
982
983    continue
984  fi
985
986  set -- "$@" "$argument"
987done
988
989if [ -z "$TARGET" ]; then
990  exec "$@"
991fi
992
993{branches}else
994  exec "$@"
995fi
996"#
997  )
998}
999
1000/// Writes the wrapper script to `script_path`, marking it executable on Unix (the
1001/// loader scripts fail to chainload otherwise).
1002///
1003/// The script names no target, so this is written once per game and never
1004/// rewritten — see [`wrapper_script_contents`].
1005pub fn write_wrapper_script(script_path: &Path) -> Result<()> {
1006  if let Some(parent) = script_path.parent() {
1007    std::fs::create_dir_all(parent)
1008      .map_err(|e| Error::Profile(format!("creating {}: {}", parent.display(), e)))?;
1009  }
1010
1011  std::fs::write(script_path, wrapper_script_contents())
1012    .map_err(|e| Error::Profile(format!("writing {}: {}", script_path.display(), e)))?;
1013
1014  #[cfg(unix)]
1015  {
1016    use std::os::unix::fs::PermissionsExt;
1017
1018    let permissions = std::fs::Permissions::from_mode(0o755);
1019
1020    std::fs::set_permissions(script_path, permissions)
1021      .map_err(|e| Error::Profile(format!("chmod {}: {}", script_path.display(), e)))?;
1022  }
1023
1024  Ok(())
1025}
1026
1027/// Marks every [`LOADER_SCRIPTS`] entry present in `target_dir` executable,
1028/// returning the paths whose mode this call actually changed.
1029///
1030/// Thunderstore loader packages ship these scripts **without** an execute bit:
1031/// `BepInExPack_Valheim` 5.4.2333 records `start_game_bepinex.sh` as `0666` in its
1032/// archive. Both [`LaunchProgram::ProfileScript`] and the wrapper from
1033/// [`wrapper_script_contents`] `exec` one of them directly, so without this that
1034/// `exec` fails with `Permission denied`, and under Steam that error reaches only
1035/// Steam's own console log: the launch presents to the user as the game silently
1036/// never starting. The bit therefore has to come from the manager, exactly as
1037/// r2modman sets it. Preserving the archive's recorded mode is no substitute,
1038/// because there is no execute bit in the archive to preserve.
1039///
1040/// Applied at launch time rather than at install time so a target that arrived by
1041/// any route is covered, whether that is a fresh install, an import, an export
1042/// round-trip, a restored backup, or one already on disk from an older version,
1043/// instead of only freshly installed ones. Kept out of [`launch_plan_in`], which
1044/// stays a pure computation, so the caller decides when the target is written to.
1045/// Keyed on the same [`LOADER_SCRIPTS`] list the plan and the wrapper both resolve
1046/// from, so all three agree about which files have to be runnable.
1047///
1048/// A script that is already executable is left untouched, so repeated launches do
1049/// not rewrite modes.
1050pub fn ensure_loader_scripts_executable(target_dir: &Path) -> Result<Vec<PathBuf>> {
1051  let mut changed = Vec::new();
1052
1053  for name in LOADER_SCRIPTS {
1054    let path = target_dir.join(name);
1055
1056    if !path.is_file() {
1057      continue;
1058    }
1059
1060    if make_executable(&path)? {
1061      changed.push(path);
1062    }
1063  }
1064
1065  Ok(changed)
1066}
1067
1068/// Grants the execute bit to exactly those classes that can already read `path`,
1069/// returning whether the mode changed. A no-op off Unix, where there is no such
1070/// bit to set.
1071///
1072/// Mirroring the read bits rather than forcing `0o755` keeps a deliberately
1073/// private script private: `0o640` becomes `0o750`, not world-readable. It can only
1074/// ever *add* execute bits, never widen read or write access.
1075fn make_executable(path: &Path) -> Result<bool> {
1076  #[cfg(unix)]
1077  {
1078    use std::os::unix::fs::PermissionsExt;
1079
1080    let metadata = std::fs::metadata(path)
1081      .map_err(|e| Error::Profile(format!("inspecting {}: {}", path.display(), e)))?;
1082    let mode = metadata.permissions().mode();
1083    let wanted = mode | ((mode & 0o444) >> 2);
1084
1085    if wanted == mode {
1086      return Ok(false);
1087    }
1088
1089    std::fs::set_permissions(path, std::fs::Permissions::from_mode(wanted))
1090      .map_err(|e| Error::Profile(format!("chmod {}: {}", path.display(), e)))?;
1091
1092    Ok(true)
1093  }
1094
1095  #[cfg(not(unix))]
1096  {
1097    let _ = path;
1098
1099    Ok(false)
1100  }
1101}
1102
1103/// Files Shimloader stages, and only these, into the UE binaries folder.
1104const SHIMLOADER_STAGED: [&str; 3] = ["ue4ss.dll", "dwmapi.dll", "ue4ss-settings.ini"];
1105
1106/// Where a profile-root file belongs in the game directory, or `None` when it is
1107/// not staged at all. Pure — no filesystem access.
1108pub fn stage_destination(
1109  loader: LoaderKind,
1110  file_name: &str,
1111  game_dir: &Path,
1112  data_folder: Option<&str>,
1113) -> Option<PathBuf> {
1114  let lowercased = file_name.to_ascii_lowercase();
1115
1116  if lowercased == "mods.yml" {
1117    return None;
1118  }
1119
1120  match loader {
1121    LoaderKind::Shimloader => {
1122      if !SHIMLOADER_STAGED.iter().any(|name| *name == lowercased) {
1123        return None;
1124      }
1125
1126      let data = data_folder?;
1127
1128      Some(
1129        game_dir
1130          .join(data)
1131          .join("Binaries")
1132          .join("Win64")
1133          .join(file_name),
1134      )
1135    }
1136    LoaderKind::Rivet => {
1137      if lowercased != "version.dll" {
1138        return None;
1139      }
1140
1141      Some(game_dir.join("Release").join(file_name))
1142    }
1143    _ => Some(game_dir.join(file_name)),
1144  }
1145}
1146
1147/// Copies the target's root-level loader files into `game_dir` so the proxy sits
1148/// beside the executable, returning the absolute paths written.
1149///
1150/// This is the only place the engine writes into a game directory, and it does so
1151/// only against an explicitly supplied `game_dir` — never one it discovered. Only
1152/// root-level *files* are staged; subdirectories (`BepInEx/`, `mods/`, …) stay in
1153/// the target, which is where the loader reads them from. Staging is skipped
1154/// entirely for a Linux native script-injected loader, whose game directory must
1155/// stay untouched.
1156///
1157/// `target_dir` and `game_dir` are frequently the same directory — a consumer
1158/// with no r2modman profile layout (e.g. one installing mods straight into the
1159/// game directory) always calls this with the two equal — and for most loaders
1160/// that makes a file's computed destination the exact file already sitting in
1161/// the target. `stage_one_file` detects that case by filesystem identity, not
1162/// path text, so it survives a `.`/`..`-relative `game_dir` or one reached
1163/// through a symlinked directory too, and leaves the file untouched rather than
1164/// copying it onto itself (which would truncate it to zero bytes). A file left
1165/// in place this way is **not** included in the returned `Vec<PathBuf>`: this
1166/// call did not write it, so reporting it as written would let a caller that
1167/// later deletes everything in that list destroy a file it never created. Note
1168/// that this per-file check is what actually matters — Shimloader routes into
1169/// `<game_dir>/<dataFolderName>/Binaries/Win64` and Rivet into
1170/// `<game_dir>/Release`, so even with `target_dir == game_dir` those
1171/// destinations genuinely differ from their sources and must still be copied.
1172///
1173/// A destination that is already a symlink is refused rather than followed, so a
1174/// pre-planted link cannot redirect a write outside `game_dir` (final path
1175/// component only, matching [`crate::install::apply_install`]) — including when
1176/// the symlink happens to point back at the source file itself; that refusal is
1177/// checked before the same-file identity check, so it always wins.
1178///
1179/// macOS is rejected here for the same reason [`launch_plan_in`] rejects it: no
1180/// correct launch can be computed for it, so the game directory must not be
1181/// touched on its behalf either.
1182///
1183/// This does not itself detect "nothing to stage": when `game_dir` does not
1184/// exist yet, listing it fails and that `read_dir` error is returned as is,
1185/// even for a loader whose destinations are identical to their sources. A
1186/// caller should check [`crate::profile::target::InstallTarget::needs_staging`]
1187/// first rather than rely on this function to turn that case into an empty
1188/// list.
1189pub fn stage_for_launch_in(
1190  target_dir: &Path,
1191  eco: &Ecosystem,
1192  game: &str,
1193  game_dir: &Path,
1194  ctx: &LaunchContext,
1195) -> Result<Vec<PathBuf>> {
1196  if ctx.os == HostOs::MacOs {
1197    return Err(Error::Profile(MACOS_UNSUPPORTED.to_string()));
1198  }
1199
1200  let profile = eco
1201    .game(game)
1202    .and_then(|g| g.profile())
1203    .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
1204
1205  if script_injection(target_dir, profile, ctx).applies() {
1206    return Ok(Vec::new());
1207  }
1208
1209  let mut written = Vec::new();
1210
1211  for entry in std::fs::read_dir(target_dir)
1212    .map_err(|e| Error::Profile(format!("reading {}: {}", target_dir.display(), e)))?
1213  {
1214    let entry = entry.map_err(|e| Error::Profile(format!("reading an entry: {e}")))?;
1215    let metadata = std::fs::symlink_metadata(entry.path())
1216      .map_err(|e| Error::Profile(format!("inspecting {}: {}", entry.path().display(), e)))?;
1217
1218    if !metadata.is_file() {
1219      continue;
1220    }
1221
1222    let file_name = match entry.file_name().to_str() {
1223      Some(name) => name.to_string(),
1224      None => continue,
1225    };
1226
1227    let dest = match stage_destination(
1228      profile.package_loader,
1229      &file_name,
1230      game_dir,
1231      profile.data_folder_name.as_deref(),
1232    ) {
1233      Some(dest) => dest,
1234      None => continue,
1235    };
1236
1237    let staged = stage_one_file(&entry.path(), &dest)?;
1238
1239    if staged {
1240      written.push(dest);
1241    }
1242  }
1243
1244  written.sort();
1245
1246  Ok(written)
1247}
1248
1249/// Stages an r2modman-layout profile's loader files into `game_dir`.
1250/// See [`stage_for_launch_in`].
1251pub fn stage_for_launch(
1252  base: &Path,
1253  game: &str,
1254  profile_name: &str,
1255  eco: &Ecosystem,
1256  game_dir: &Path,
1257  ctx: &LaunchContext,
1258) -> Result<Vec<PathBuf>> {
1259  stage_for_launch_in(
1260    &crate::profile::layout::profile_dir(base, game, profile_name),
1261    eco,
1262    game,
1263    game_dir,
1264    ctx,
1265  )
1266}
1267
1268/// Copies one staged file, creating parent directories and refusing to write
1269/// through a pre-existing symlink at the destination.
1270///
1271/// Returns whether a copy actually happened: `false` means `source` and `dest`
1272/// were already [`same_file`], so nothing was written. [`stage_for_launch_in`]
1273/// uses that to decide whether `dest` belongs in its returned `Vec<PathBuf>` —
1274/// see that function's doc comment for why an already-in-place file must not be
1275/// reported as written.
1276///
1277/// The symlink refusal is checked first and unconditionally, before the
1278/// same-file check: a destination symlink that happens to point at `source`
1279/// must still be refused rather than treated as "already in place", so a
1280/// pre-planted link can never be silently followed.
1281fn stage_one_file(source: &Path, dest: &Path) -> Result<bool> {
1282  let dest_is_symlink = std::fs::symlink_metadata(dest)
1283    .map(|metadata| metadata.file_type().is_symlink())
1284    .unwrap_or(false);
1285
1286  if dest_is_symlink {
1287    return Err(Error::Profile(format!(
1288      "refusing to stage through a symlink at {}",
1289      dest.display()
1290    )));
1291  }
1292
1293  if crate::util::same_file(source, dest) {
1294    return Ok(false);
1295  }
1296
1297  if let Some(parent) = dest.parent() {
1298    std::fs::create_dir_all(parent)
1299      .map_err(|e| Error::Profile(format!("creating {}: {}", parent.display(), e)))?;
1300  }
1301
1302  std::fs::copy(source, dest)
1303    .map_err(|e| Error::Profile(format!("staging {}: {}", dest.display(), e)))?;
1304
1305  Ok(true)
1306}
1307
1308/// The string a user pastes into Steam's launch options for a Linux native launch.
1309///
1310/// The engine only computes this: Steam stores launch options in `localconfig.vdf`,
1311/// which no manager writes (r2modman reads it to verify, never writes it), so
1312/// installing this is necessarily a user action.
1313///
1314/// This is the **Linux native** string, and only that, matching r2modman's
1315/// `WrapperArguments.ts` exactly. It carries no `WINEDLLOVERRIDES`: that is a Wine
1316/// setting, and no Wine is in this launch path. Nor could adding it buy Proton
1317/// coverage — what `script_path` names is a POSIX shell script, which is not a
1318/// valid `%command%` front end for a Windows binary in a Wine prefix regardless of
1319/// what environment variables precede it. A Proton launch cannot reach this
1320/// function anyway: [`LaunchPlan::steam_wrapper`] is populated only when
1321/// `script_injection` applies, which requires [`Runtime::Native`].
1322///
1323/// Under Proton the override *is* required — Wine prefers its own builtin
1324/// `winhttp` over the loader's, so the proxy never loads without it — but launch
1325/// options are the wrong channel for it. [`LaunchPlan::env`] carries it for the
1326/// direct-spawn path, and [`proxy_dll`] exposes the name for a caller writing it
1327/// into a prefix itself, as r2modman does via `user.reg`.
1328pub fn steam_launch_options(script_path: &Path) -> String {
1329  format!(
1330    r#""{script}" %command%"#,
1331    script = script_path.to_string_lossy().replace('\\', "/"),
1332  )
1333}
1334
1335/// The first of `exe_names` present in `game_dir`.
1336pub fn resolve_game_exe(game_dir: &Path, exe_names: &[String]) -> Result<PathBuf> {
1337  exe_names
1338    .iter()
1339    .map(|name| game_dir.join(name))
1340    .find(|candidate| candidate.is_file())
1341    .ok_or_else(|| {
1342      Error::Profile(format!(
1343        "no game executable found in {}: none of {} exist",
1344        game_dir.display(),
1345        exe_names.join(", ")
1346      ))
1347    })
1348}
1349
1350/// Where a game's Steam wrapper script is written.
1351///
1352/// One shared file for every target rather than one per profile: the script
1353/// resolves which directory to chainload from its argv at run time, and Steam
1354/// stores exactly one launch-options string per game naming this path. A
1355/// per-profile path would go stale the moment a user switched profiles.
1356pub fn wrapper_path(base: &Path, game: &str) -> PathBuf {
1357  base.join(game).join("steam-wrapper.sh")
1358}
1359
1360impl LaunchPlan {
1361  /// Translates this plan into the process to spawn.
1362  ///
1363  /// Always an argv array, never a shell, so a path containing spaces or quotes
1364  /// cannot become an injection. Separate from spawning so the program,
1365  /// arguments, and environment can be asserted without starting anything.
1366  ///
1367  /// Every [`LaunchProgram`] variant is handled, with no wildcard arm.
1368  /// `LaunchProgram` is `#[non_exhaustive]`, but that only constrains other
1369  /// crates, so a wildcard here would be an unreachable pattern and fail
1370  /// `-D warnings`. Adding a variant therefore breaks this build deliberately,
1371  /// which is the point: a new launch shape must be translated, not silently
1372  /// rejected at run time.
1373  pub fn to_command(&self, game_dir: &Path) -> Result<std::process::Command> {
1374    let mut command = match &self.program {
1375      LaunchProgram::GameExe { exe_names } => {
1376        std::process::Command::new(resolve_game_exe(game_dir, exe_names)?)
1377      }
1378      LaunchProgram::Steam { steam_app_id } => {
1379        let mut command = std::process::Command::new("steam");
1380
1381        command.arg("-applaunch").arg(steam_app_id.to_string());
1382
1383        command
1384      }
1385      LaunchProgram::ProfileScript { path, exe_names } => {
1386        let mut command = std::process::Command::new(path);
1387
1388        command.arg(resolve_game_exe(game_dir, exe_names)?);
1389
1390        command
1391      }
1392    };
1393
1394    command.args(&self.args);
1395
1396    for (key, value) in &self.env {
1397      command.env(key, value);
1398    }
1399
1400    if let Some(dir) = &self.working_dir {
1401      command.current_dir(dir);
1402    }
1403
1404    Ok(command)
1405  }
1406}
1407
1408#[cfg(test)]
1409mod tests {
1410  use super::*;
1411
1412  #[test]
1413  fn doorstop_version_reads_major_and_defaults_to_three() {
1414    let dir = tempfile::tempdir().unwrap();
1415    let root = dir.path();
1416
1417    // Missing file => v3.
1418    assert_eq!(doorstop_version(root), 3);
1419
1420    std::fs::write(root.join(".doorstop_version"), "4.0.0").unwrap();
1421    assert_eq!(doorstop_version(root), 4);
1422
1423    std::fs::write(root.join(".doorstop_version"), "3.7.1").unwrap();
1424    assert_eq!(doorstop_version(root), 3);
1425
1426    // r2modman falls back to v3 for major >= 5; we treat it as v4 (its own bug).
1427    std::fs::write(root.join(".doorstop_version"), "5.1.0").unwrap();
1428    assert_eq!(doorstop_version(root), 5);
1429
1430    // Unparseable => v3.
1431    std::fs::write(root.join(".doorstop_version"), "garbage").unwrap();
1432    assert_eq!(doorstop_version(root), 3);
1433  }
1434
1435  #[test]
1436  fn bepinex_preloader_finds_candidate_and_prefixes_under_proton() {
1437    let dir = tempfile::tempdir().unwrap();
1438    let root = dir.path();
1439    let core = root.join("BepInEx/core");
1440
1441    std::fs::create_dir_all(&core).unwrap();
1442    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1443    std::fs::write(core.join("Unrelated.dll"), b"x").unwrap();
1444
1445    let native = bepinex_preloader(root, Runtime::Native).unwrap();
1446
1447    assert!(native.ends_with("BepInEx/core/BepInEx.Preloader.dll"));
1448    assert!(!native.starts_with("Z:"));
1449
1450    let proton = bepinex_preloader(root, Runtime::Proton).unwrap();
1451
1452    assert!(proton.starts_with("Z:"));
1453    assert!(proton.ends_with("BepInEx/core/BepInEx.Preloader.dll"));
1454  }
1455
1456  #[test]
1457  fn bepinex_preloader_errors_when_absent() {
1458    let dir = tempfile::tempdir().unwrap();
1459
1460    assert!(bepinex_preloader(dir.path(), Runtime::Native).is_err());
1461  }
1462
1463  #[test]
1464  fn has_corlibs_detects_the_directory() {
1465    let dir = tempfile::tempdir().unwrap();
1466    let root = dir.path();
1467
1468    assert!(!has_corlibs(root));
1469
1470    std::fs::create_dir_all(root.join("unstripped_corlib")).unwrap();
1471
1472    assert!(has_corlibs(root));
1473  }
1474
1475  fn ctx(mode: LaunchMode) -> LaunchContext {
1476    LaunchContext {
1477      os: HostOs::Windows,
1478      store: StorePlatform::Steam,
1479      runtime: Runtime::Native,
1480      mode,
1481      extra_args: Vec::new(),
1482    }
1483  }
1484
1485  fn profile_with(loader: LoaderKind) -> GameProfile {
1486    let eco = Ecosystem::bundled();
1487    let mut profile = eco.game("valheim").unwrap().profile().unwrap().clone();
1488    profile.package_loader = loader;
1489
1490    profile
1491  }
1492
1493  #[test]
1494  fn bepinex_v3_and_v4_use_the_right_flag_spellings() {
1495    let dir = tempfile::tempdir().unwrap();
1496    let root = dir.path();
1497    let core = root.join("BepInEx/core");
1498
1499    std::fs::create_dir_all(&core).unwrap();
1500    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1501
1502    let profile = profile_with(LoaderKind::BepInEx);
1503
1504    let v3 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1505
1506    assert_eq!(v3[0], "--doorstop-enable");
1507    assert_eq!(v3[1], "true");
1508    assert_eq!(v3[2], "--doorstop-target");
1509
1510    std::fs::write(root.join(".doorstop_version"), "4.0.0").unwrap();
1511
1512    let v4 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1513
1514    assert_eq!(v4[0], "--doorstop-enabled");
1515    assert_eq!(v4[2], "--doorstop-target-assembly");
1516
1517    // Vanilla uses the matching spelling for the detected version — r2modman
1518    // emits the v3 spelling here even on v4, which we deliberately correct.
1519    let vanilla = loader_args(root, &profile, &ctx(LaunchMode::Vanilla)).unwrap();
1520
1521    assert_eq!(
1522      vanilla,
1523      vec!["--doorstop-enabled".to_string(), "false".to_string()]
1524    );
1525  }
1526
1527  #[test]
1528  fn melonloader_adds_regenerate_only_when_assemblies_are_absent() {
1529    let dir = tempfile::tempdir().unwrap();
1530    let root = dir.path();
1531    let profile = profile_with(LoaderKind::MelonLoader);
1532
1533    let bare = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1534
1535    assert_eq!(bare[0], "--melonloader.basedir");
1536    assert!(bare.contains(&"--melonloader.agfregenerate".to_string()));
1537
1538    let managed = root.join("MelonLoader/Managed");
1539    std::fs::create_dir_all(&managed).unwrap();
1540    std::fs::write(managed.join("Assembly-CSharp.dll"), b"x").unwrap();
1541
1542    let with_assembly = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1543
1544    assert!(!with_assembly.contains(&"--melonloader.agfregenerate".to_string()));
1545    assert_eq!(
1546      loader_args(root, &profile, &ctx(LaunchMode::Vanilla)).unwrap(),
1547      vec!["--no-mods".to_string()]
1548    );
1549  }
1550
1551  #[test]
1552  fn single_element_flags_stay_single_elements() {
1553    let dir = tempfile::tempdir().unwrap();
1554    let root = dir.path();
1555
1556    let gdweave = loader_args(
1557      root,
1558      &profile_with(LoaderKind::GdWeave),
1559      &ctx(LaunchMode::Modded),
1560    )
1561    .unwrap();
1562
1563    assert_eq!(gdweave.len(), 1);
1564    assert!(gdweave[0].starts_with("--gdweave-folder-override="));
1565    assert!(gdweave[0].ends_with("/GDWeave"));
1566
1567    let northstar = loader_args(
1568      root,
1569      &profile_with(LoaderKind::Northstar),
1570      &ctx(LaunchMode::Modded),
1571    )
1572    .unwrap();
1573
1574    assert_eq!(northstar[0], "-northstar");
1575    assert!(northstar[1].starts_with("-profile="));
1576  }
1577
1578  #[test]
1579  fn shimloader_and_rivet_and_rom_shapes() {
1580    let dir = tempfile::tempdir().unwrap();
1581    let root = dir.path();
1582
1583    let shim = loader_args(
1584      root,
1585      &profile_with(LoaderKind::Shimloader),
1586      &ctx(LaunchMode::Modded),
1587    )
1588    .unwrap();
1589
1590    assert_eq!(shim.len(), 8);
1591    assert_eq!(shim[0], "--mod-dir");
1592    assert!(shim.contains(&"--overlay-dir".to_string()));
1593
1594    let rivet = loader_args(
1595      root,
1596      &profile_with(LoaderKind::Rivet),
1597      &ctx(LaunchMode::Modded),
1598    )
1599    .unwrap();
1600
1601    assert_eq!(rivet[0], "-rivetEnable");
1602    assert_eq!(rivet[1], "true");
1603    assert!(rivet.contains(&"-rivetDirectory".to_string()));
1604    assert_eq!(
1605      loader_args(
1606        root,
1607        &profile_with(LoaderKind::Rivet),
1608        &ctx(LaunchMode::Vanilla)
1609      )
1610      .unwrap(),
1611      vec!["-rivetEnable".to_string(), "false".to_string()]
1612    );
1613
1614    let rom = loader_args(
1615      root,
1616      &profile_with(LoaderKind::ReturnOfModding),
1617      &ctx(LaunchMode::Modded),
1618    )
1619    .unwrap();
1620
1621    assert_eq!(rom[0], "--rom_modding_root_folder");
1622  }
1623
1624  #[test]
1625  fn unknown_and_none_loaders_produce_no_args() {
1626    let dir = tempfile::tempdir().unwrap();
1627    let root = dir.path();
1628
1629    assert!(
1630      loader_args(
1631        root,
1632        &profile_with(LoaderKind::None),
1633        &ctx(LaunchMode::Modded)
1634      )
1635      .unwrap()
1636      .is_empty()
1637    );
1638    assert!(
1639      loader_args(
1640        root,
1641        &profile_with(LoaderKind::Other),
1642        &ctx(LaunchMode::Modded)
1643      )
1644      .unwrap()
1645      .is_empty()
1646    );
1647  }
1648
1649  #[test]
1650  fn server_instance_appends_server_flag_for_bepinex() {
1651    let dir = tempfile::tempdir().unwrap();
1652    let root = dir.path();
1653    let core = root.join("BepInEx/core");
1654
1655    std::fs::create_dir_all(&core).unwrap();
1656    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1657
1658    let mut profile = profile_with(LoaderKind::BepInEx);
1659    profile.instance_type = InstanceType::Server;
1660
1661    let args = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1662
1663    assert!(args.contains(&"--server".to_string()));
1664  }
1665
1666  #[test]
1667  fn umm_uses_doorstop_flags_and_never_gets_server_flag() {
1668    let dir = tempfile::tempdir().unwrap();
1669    let root = dir.path();
1670    let profile = profile_with(LoaderKind::Umm);
1671
1672    let v3 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1673
1674    assert_eq!(v3[0], "--doorstop-enable");
1675    assert_eq!(v3[1], "true");
1676    assert_eq!(v3[2], "--doorstop-target");
1677    assert!(v3[3].ends_with("UMM/Core/UnityModManager.dll"));
1678
1679    std::fs::write(root.join(".doorstop_version"), "4.0.0").unwrap();
1680
1681    let v4 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1682
1683    assert_eq!(v4[0], "--doorstop-enabled");
1684    assert_eq!(v4[2], "--doorstop-target-assembly");
1685    assert!(v4[3].ends_with("UMM/Core/UnityModManager.dll"));
1686
1687    let vanilla = loader_args(root, &profile, &ctx(LaunchMode::Vanilla)).unwrap();
1688
1689    assert_eq!(
1690      vanilla,
1691      vec!["--doorstop-enabled".to_string(), "false".to_string()]
1692    );
1693
1694    // Unlike BepInEx, UMM's `DoorstopTarget` arm never appends `--server`, even
1695    // when the profile's instance type is `Server`.
1696    let mut server_profile = profile_with(LoaderKind::Umm);
1697    server_profile.instance_type = InstanceType::Server;
1698
1699    let server_args = loader_args(root, &server_profile, &ctx(LaunchMode::Modded)).unwrap();
1700
1701    assert!(!server_args.contains(&"--server".to_string()));
1702  }
1703
1704  #[test]
1705  fn bepis_loader_modded_and_vanilla_shapes() {
1706    let dir = tempfile::tempdir().unwrap();
1707    let root = dir.path();
1708    let core = root.join("BepInEx/core");
1709
1710    std::fs::create_dir_all(&core).unwrap();
1711    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1712
1713    let profile = profile_with(LoaderKind::BepisLoader);
1714
1715    let modded = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1716
1717    assert_eq!(modded[0], "--hookfxr-enable");
1718    assert_eq!(modded[1], "--bepinex-target");
1719    assert!(modded.contains(&"--doorstop-enabled".to_string()));
1720    assert!(modded.contains(&"--doorstop-target-assembly".to_string()));
1721
1722    let vanilla = loader_args(root, &profile, &ctx(LaunchMode::Vanilla)).unwrap();
1723
1724    assert_eq!(
1725      vanilla,
1726      vec![
1727        "--hookfxr-disable".to_string(),
1728        "--doorstop-enabled".to_string(),
1729        "false".to_string()
1730      ]
1731    );
1732  }
1733
1734  #[test]
1735  fn lovely_modded_and_vanilla_shapes() {
1736    let dir = tempfile::tempdir().unwrap();
1737    let root = dir.path();
1738
1739    let modded = loader_args(
1740      root,
1741      &profile_with(LoaderKind::Lovely),
1742      &ctx(LaunchMode::Modded),
1743    )
1744    .unwrap();
1745
1746    assert_eq!(modded[0], "--mod-dir");
1747    assert!(modded[1].ends_with("/mods"));
1748
1749    let vanilla = loader_args(
1750      root,
1751      &profile_with(LoaderKind::Lovely),
1752      &ctx(LaunchMode::Vanilla),
1753    )
1754    .unwrap();
1755
1756    assert_eq!(vanilla, vec!["--vanilla".to_string()]);
1757  }
1758
1759  #[test]
1760  fn godot_ml_modded_and_vanilla_shapes() {
1761    let dir = tempfile::tempdir().unwrap();
1762    let root = dir.path();
1763
1764    let modded = loader_args(
1765      root,
1766      &profile_with(LoaderKind::GodotMl),
1767      &ctx(LaunchMode::Modded),
1768    )
1769    .unwrap();
1770
1771    assert_eq!(modded[0], "--script");
1772    assert_eq!(modded[1], "res://addons/mod_loader/mod_loader_setup.gd");
1773    assert!(modded.contains(&"--mods-path".to_string()));
1774
1775    let vanilla = loader_args(
1776      root,
1777      &profile_with(LoaderKind::GodotMl),
1778      &ctx(LaunchMode::Vanilla),
1779    )
1780    .unwrap();
1781
1782    assert!(vanilla.is_empty());
1783  }
1784
1785  #[test]
1786  fn corlib_override_appends_search_flag_pair_matching_version() {
1787    let dir = tempfile::tempdir().unwrap();
1788    let root = dir.path();
1789    let core = root.join("BepInEx/core");
1790
1791    std::fs::create_dir_all(&core).unwrap();
1792    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1793    std::fs::create_dir_all(root.join("unstripped_corlib")).unwrap();
1794
1795    let profile = profile_with(LoaderKind::BepInEx);
1796
1797    let v3 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1798    let v3_idx = v3
1799      .iter()
1800      .position(|a| a == "--doorstop-dll-search-override")
1801      .unwrap();
1802
1803    assert!(v3[v3_idx + 1].ends_with("/unstripped_corlib"));
1804
1805    std::fs::write(root.join(".doorstop_version"), "4.0.0").unwrap();
1806
1807    let v4 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1808    let v4_idx = v4
1809      .iter()
1810      .position(|a| a == "--doorstop-mono-dll-search-path-override")
1811      .unwrap();
1812
1813    assert!(v4[v4_idx + 1].ends_with("/unstripped_corlib"));
1814
1815    // BepisLoader hardcodes the v4 search-flag spelling regardless of the
1816    // detected `.doorstop_version`.
1817    std::fs::remove_file(root.join(".doorstop_version")).unwrap();
1818
1819    let bepis = loader_args(
1820      root,
1821      &profile_with(LoaderKind::BepisLoader),
1822      &ctx(LaunchMode::Modded),
1823    )
1824    .unwrap();
1825    let bepis_idx = bepis
1826      .iter()
1827      .position(|a| a == "--doorstop-mono-dll-search-path-override")
1828      .unwrap();
1829
1830    assert!(bepis[bepis_idx + 1].ends_with("/unstripped_corlib"));
1831  }
1832
1833  #[test]
1834  fn windows_steam_plan_targets_steam_with_loader_args() {
1835    let dir = tempfile::tempdir().unwrap();
1836    let root = dir.path();
1837    let core = root.join("BepInEx/core");
1838
1839    std::fs::create_dir_all(&core).unwrap();
1840    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1841
1842    let eco = Ecosystem::bundled();
1843    let mut context = ctx(LaunchMode::Modded);
1844    context.extra_args = vec!["-nolog".to_string()];
1845
1846    let plan = launch_plan_in(root, &eco, "valheim", &context).unwrap();
1847
1848    match &plan.program {
1849      LaunchProgram::Steam { steam_app_id } => assert_eq!(*steam_app_id, 892970),
1850      other => panic!("expected Steam, got {other:?}"),
1851    }
1852
1853    assert_eq!(plan.args[0], "--doorstop-enable");
1854    // User args come last.
1855    assert_eq!(plan.args.last().unwrap(), "-nolog");
1856    assert!(plan.env.is_empty());
1857    // Windows injects through the proxy DLL, so Steam needs no wrapper file.
1858    assert_eq!(plan.steam_wrapper, None);
1859  }
1860
1861  #[test]
1862  fn proton_sets_wine_dll_overrides_and_prefixes_the_preloader() {
1863    let dir = tempfile::tempdir().unwrap();
1864    let root = dir.path();
1865    let core = root.join("BepInEx/core");
1866
1867    std::fs::create_dir_all(&core).unwrap();
1868    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1869
1870    let eco = Ecosystem::bundled();
1871    let mut context = ctx(LaunchMode::Modded);
1872    context.os = HostOs::Linux;
1873    context.runtime = Runtime::Proton;
1874
1875    let plan = launch_plan_in(root, &eco, "valheim", &context).unwrap();
1876
1877    assert_eq!(
1878      plan.env,
1879      vec![("WINEDLLOVERRIDES".to_string(), "winhttp=n,b".to_string())]
1880    );
1881    assert!(plan.args.iter().any(|a| a.starts_with("Z:")));
1882    // Proton injects through the proxy DLL too: no script, so no wrapper.
1883    assert_eq!(plan.steam_wrapper, None);
1884  }
1885
1886  #[test]
1887  fn linux_native_bepinex_runs_the_profile_script() {
1888    let dir = tempfile::tempdir().unwrap();
1889    let root = dir.path();
1890    let core = root.join("BepInEx/core");
1891
1892    std::fs::create_dir_all(&core).unwrap();
1893    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1894    std::fs::write(root.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
1895
1896    let eco = Ecosystem::bundled();
1897    let mut context = ctx(LaunchMode::Modded);
1898    context.os = HostOs::Linux;
1899    context.store = StorePlatform::SteamDirect;
1900    context.runtime = Runtime::Native;
1901
1902    let plan = launch_plan_in(root, &eco, "valheim", &context).unwrap();
1903
1904    match &plan.program {
1905      // `exe_names` travels with the script: the caller execs the script *with*
1906      // the game binary, so it needs the same candidates `GameExe` hands over.
1907      LaunchProgram::ProfileScript { path, exe_names } => {
1908        assert!(path.ends_with("run_bepinex.sh"));
1909        assert_eq!(
1910          *exe_names,
1911          eco.game("valheim").unwrap().profile().unwrap().exe_names
1912        );
1913        assert!(!exe_names.is_empty());
1914      }
1915      other => panic!("expected ProfileScript, got {other:?}"),
1916    }
1917
1918    // A directly launched script is not the Steam wrapper case.
1919    assert_eq!(plan.steam_wrapper, None);
1920  }
1921
1922  #[test]
1923  fn macos_is_rejected() {
1924    let dir = tempfile::tempdir().unwrap();
1925    let root = dir.path();
1926    let core = root.join("BepInEx/core");
1927
1928    std::fs::create_dir_all(&core).unwrap();
1929    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1930
1931    let eco = Ecosystem::bundled();
1932    let mut context = ctx(LaunchMode::Modded);
1933    context.os = HostOs::MacOs;
1934
1935    let err = launch_plan_in(root, &eco, "valheim", &context).unwrap_err();
1936
1937    assert!(err.to_string().contains("macOS launch is not supported"));
1938
1939    // Control: the exact same directory and profile succeed once `os` is not
1940    // macOS, proving the error above comes from the macOS branch itself and
1941    // not, say, a missing BepInEx preloader fixture.
1942    context.os = HostOs::Windows;
1943
1944    assert!(launch_plan_in(root, &eco, "valheim", &context).is_ok());
1945  }
1946
1947  #[test]
1948  fn unknown_game_errors() {
1949    let dir = tempfile::tempdir().unwrap();
1950    let eco = Ecosystem::bundled();
1951
1952    let err = launch_plan_in(dir.path(), &eco, "not-a-game", &ctx(LaunchMode::Modded)).unwrap_err();
1953
1954    assert!(err.to_string().contains("no ecosystem profile"));
1955  }
1956
1957  #[test]
1958  fn proxy_dll_is_winmm_for_gdweave() {
1959    assert_eq!(proxy_dll(LoaderKind::GdWeave), "winmm");
1960    assert_eq!(proxy_dll(LoaderKind::BepInEx), "winhttp");
1961  }
1962
1963  #[test]
1964  fn wrapper_script_names_no_target_and_execs_the_loader() {
1965    let script = wrapper_script_contents();
1966
1967    assert!(script.starts_with("#!/bin/sh"));
1968
1969    // No target of any kind is baked in — that is what makes one script correct
1970    // for every profile, and what makes a bare launch unmodded rather than
1971    // whichever profile was current when it was written.
1972    assert!(script.contains(r#"TARGET="""#));
1973    assert!(script.contains(r#"[ -z "$TARGET" ]"#));
1974
1975    // The run-time flag is parsed, not `eval`-ed, and its value is only adopted
1976    // when it names a directory.
1977    assert!(script.contains(&format!(r#"[ "$argument" = "{WRAPPER_TARGET_FLAG}" ]"#)));
1978    assert!(script.contains(r#"[ -d "$candidate" ]"#));
1979    assert!(!script.contains("eval"));
1980
1981    // Verify all three branches are present in the correct order.
1982    let pos_server = script
1983      .find("start_server_bepinex.sh")
1984      .expect("server branch missing");
1985    let pos_run = script.find("run_bepinex.sh").expect("run branch missing");
1986    let pos_game = script
1987      .find("start_game_bepinex.sh")
1988      .expect("game branch missing");
1989
1990    assert!(pos_server < pos_run, "server branch must come before run");
1991    assert!(pos_run < pos_game, "run branch must come before game");
1992
1993    // The server branch must gate on TS_START_SERVER.
1994    assert!(script.contains("$TS_START_SERVER"));
1995
1996    // The script must have the else fallthrough and be well-formed.
1997    assert!(script.contains("else"));
1998    assert!(script.contains("fi"));
1999
2000    // The game command is forwarded, not swallowed.
2001    assert!(script.contains("\"$@\""));
2002  }
2003
2004  #[test]
2005  fn write_wrapper_script_creates_an_executable_file() {
2006    let dir = tempfile::tempdir().unwrap();
2007    let root = dir.path();
2008    let script_path = root.join("launch_wrapper.sh");
2009
2010    write_wrapper_script(&script_path).unwrap();
2011
2012    assert!(script_path.is_file());
2013
2014    #[cfg(unix)]
2015    {
2016      use std::os::unix::fs::PermissionsExt;
2017
2018      let mode = std::fs::metadata(&script_path)
2019        .unwrap()
2020        .permissions()
2021        .mode();
2022
2023      assert_eq!(mode & 0o111, 0o111);
2024    }
2025  }
2026
2027  /// The regression this exists for: a Thunderstore loader package ships its
2028  /// scripts non-executable, so a freshly installed profile cannot be launched at
2029  /// all until something sets the bit. Under Steam the failure is silent, so the
2030  /// only defense is that this runs before the wrapper `exec`s anything.
2031  #[test]
2032  #[cfg(unix)]
2033  fn ensure_loader_scripts_executable_adds_the_bit_and_reports_what_changed() {
2034    use std::os::unix::fs::PermissionsExt;
2035
2036    let dir = tempfile::tempdir().unwrap();
2037    let root = dir.path();
2038
2039    let game = root.join("start_game_bepinex.sh");
2040    let server = root.join("start_server_bepinex.sh");
2041
2042    std::fs::write(&game, b"#!/bin/sh\n").unwrap();
2043    std::fs::write(&server, b"#!/bin/sh\n").unwrap();
2044
2045    // Exactly what the package ships: readable, not executable.
2046    std::fs::set_permissions(&game, std::fs::Permissions::from_mode(0o644)).unwrap();
2047    std::fs::set_permissions(&server, std::fs::Permissions::from_mode(0o644)).unwrap();
2048
2049    let changed = ensure_loader_scripts_executable(root).unwrap();
2050
2051    assert_eq!(changed.len(), 2, "got: {changed:?}");
2052
2053    for name in ["start_game_bepinex.sh", "start_server_bepinex.sh"] {
2054      let mode = std::fs::metadata(root.join(name))
2055        .unwrap()
2056        .permissions()
2057        .mode();
2058
2059      assert_eq!(mode & 0o777, 0o755, "{name} got mode {mode:o}");
2060    }
2061
2062    // Idempotent: a second launch must not report a change, so repeated launches
2063    // do not keep rewriting modes.
2064    assert!(ensure_loader_scripts_executable(root).unwrap().is_empty());
2065  }
2066
2067  /// Read access is mirrored rather than forced to `0o755`, so a target whose
2068  /// scripts are deliberately owner-only does not become world-readable just to
2069  /// be launchable.
2070  #[test]
2071  #[cfg(unix)]
2072  fn ensure_loader_scripts_executable_mirrors_read_access() {
2073    use std::os::unix::fs::PermissionsExt;
2074
2075    let dir = tempfile::tempdir().unwrap();
2076    let root = dir.path();
2077    let script = root.join("run_bepinex.sh");
2078
2079    std::fs::write(&script, b"#!/bin/sh\n").unwrap();
2080    std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o640)).unwrap();
2081
2082    ensure_loader_scripts_executable(root).unwrap();
2083
2084    let mode = std::fs::metadata(&script).unwrap().permissions().mode();
2085
2086    assert_eq!(mode & 0o777, 0o750, "got mode {mode:o}");
2087  }
2088
2089  /// A target with no loader scripts at all, such as a Proton profile or one
2090  /// whose loader injects through a proxy DLL, is not an error.
2091  #[test]
2092  fn ensure_loader_scripts_executable_ignores_a_target_with_no_scripts() {
2093    let dir = tempfile::tempdir().unwrap();
2094
2095    assert!(
2096      ensure_loader_scripts_executable(dir.path())
2097        .unwrap()
2098        .is_empty()
2099    );
2100  }
2101
2102  #[test]
2103  fn wrapper_target_args_name_the_flag_and_the_directory() {
2104    assert_eq!(
2105      wrapper_target_args(Path::new("/data/profiles/Main")),
2106      vec![
2107        WRAPPER_TARGET_FLAG.to_string(),
2108        "/data/profiles/Main".to_string()
2109      ]
2110    );
2111  }
2112
2113  // --- Executing the generated wrapper.
2114  //
2115  // The wrapper is a shell script, and the bugs that matter in one — a lost
2116  // quote, a flag leaking through to the game, an argument reordered by the
2117  // filtering loop — are invisible to assertions about its text. These tests run
2118  // `/bin/sh` against it for real and inspect the argv the chainloaded script
2119  // actually received. ---
2120
2121  /// Marks `path` executable, as [`write_wrapper_script`] does for the wrapper
2122  /// itself and as the loader scripts must be to be `exec`-ed.
2123  #[cfg(unix)]
2124  fn make_executable(path: &Path) {
2125    use std::os::unix::fs::PermissionsExt;
2126
2127    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
2128  }
2129
2130  /// Writes a stand-in loader script at `path` that records the argv it was
2131  /// invoked with into `log`, one argument per line, prefixed by its own `$0`.
2132  ///
2133  /// One line per argument is what makes the assertions meaningful: a lost quote
2134  /// shows up as an argument split across two lines rather than as a string that
2135  /// merely looks similar.
2136  #[cfg(unix)]
2137  fn recording_loader(path: &Path, log: &Path) {
2138    let contents = format!(
2139      "#!/bin/sh\nprintf '%s\\n' \"$0\" > \"{log}\"\nfor argument in \"$@\"; do\n  printf '%s\\n' \"$argument\" >> \"{log}\"\ndone\n",
2140      log = log.display()
2141    );
2142
2143    std::fs::write(path, contents).unwrap();
2144    make_executable(path);
2145  }
2146
2147  /// The lines `recording_loader` wrote: the chainloaded script's own path, then
2148  /// each argument it received.
2149  #[cfg(unix)]
2150  fn recorded(log: &Path) -> Vec<String> {
2151    std::fs::read_to_string(log)
2152      .unwrap()
2153      .lines()
2154      .map(str::to_string)
2155      .collect()
2156  }
2157
2158  /// Runs `wrapper` with `args`, asserting it exited successfully.
2159  #[cfg(unix)]
2160  fn run_wrapper(wrapper: &Path, args: &[&str], server: bool) {
2161    let mut command = std::process::Command::new(wrapper);
2162
2163    command.args(args);
2164
2165    if server {
2166      command.env("TS_START_SERVER", "1");
2167    } else {
2168      command.env_remove("TS_START_SERVER");
2169    }
2170
2171    let output = command.output().unwrap();
2172
2173    assert!(
2174      output.status.success(),
2175      "wrapper failed: {}",
2176      String::from_utf8_lossy(&output.stderr)
2177    );
2178  }
2179
2180  #[cfg(unix)]
2181  #[test]
2182  fn wrapper_chainloads_the_target_given_at_run_time_and_consumes_only_the_flag() {
2183    let dir = tempfile::tempdir().unwrap();
2184    let fallback = dir.path().join("fallback");
2185    // A space in the target directory is the case that catches quoting bugs, and
2186    // it is a realistic profile name.
2187    let selected = dir.path().join("My Profile 2");
2188    let log = dir.path().join("argv.log");
2189    let wrapper = dir.path().join("steam-wrapper.sh");
2190
2191    std::fs::create_dir_all(&fallback).unwrap();
2192    std::fs::create_dir_all(&selected).unwrap();
2193    recording_loader(&selected.join("run_bepinex.sh"), &log);
2194    write_wrapper_script(&wrapper).unwrap();
2195
2196    let target = selected.to_string_lossy().to_string();
2197
2198    run_wrapper(
2199      &wrapper,
2200      &[
2201        "/games/valheim/valheim.x86_64",
2202        "--doorstop-enabled",
2203        "true",
2204        WRAPPER_TARGET_FLAG,
2205        &target,
2206        "-name",
2207        "a server with spaces",
2208      ],
2209      false,
2210    );
2211
2212    let lines = recorded(&log);
2213
2214    // The script inside the run-time target ran — not the fallback's.
2215    assert_eq!(lines[0], selected.join("run_bepinex.sh").to_string_lossy());
2216    // Every non-flag argument, in its original order, with spaces intact; the
2217    // flag and its value are gone.
2218    assert_eq!(
2219      &lines[1..],
2220      [
2221        "/games/valheim/valheim.x86_64",
2222        "--doorstop-enabled",
2223        "true",
2224        "-name",
2225        "a server with spaces",
2226      ]
2227    );
2228  }
2229
2230  #[cfg(unix)]
2231  #[test]
2232  fn wrapper_without_the_flag_launches_the_game_unmodded() {
2233    let dir = tempfile::tempdir().unwrap();
2234    let log = dir.path().join("argv.log");
2235    let wrapper = dir.path().join("steam-wrapper.sh");
2236    let game_exe = dir.path().join("valheim.x86_64");
2237    // A profile sitting right beside the wrapper, complete with a loader script.
2238    // Nothing may reach for it: no flag means no mods, not "whichever profile
2239    // happens to be there".
2240    let unrequested = dir.path().join("profile");
2241    let unrequested_log = dir.path().join("must-not-run.log");
2242
2243    std::fs::create_dir_all(&unrequested).unwrap();
2244    recording_loader(&unrequested.join("run_bepinex.sh"), &unrequested_log);
2245    recording_loader(&game_exe, &log);
2246    write_wrapper_script(&wrapper).unwrap();
2247
2248    // A launch straight from Steam, with no manager appending anything: exactly
2249    // what `%command%` expands to on its own.
2250    run_wrapper(&wrapper, &[&game_exe.to_string_lossy()], false);
2251
2252    assert!(
2253      !unrequested_log.exists(),
2254      "a bare launch must not load any profile's mods"
2255    );
2256
2257    let lines = recorded(&log);
2258
2259    assert_eq!(lines[0], game_exe.to_string_lossy());
2260    assert_eq!(lines.len(), 1, "the game got extra arguments: {lines:?}");
2261  }
2262
2263  #[cfg(unix)]
2264  #[test]
2265  fn wrapper_launches_unmodded_when_the_flags_value_is_not_a_directory() {
2266    let dir = tempfile::tempdir().unwrap();
2267    let log = dir.path().join("argv.log");
2268    let wrapper = dir.path().join("steam-wrapper.sh");
2269    let game_exe = dir.path().join("valheim.x86_64");
2270
2271    recording_loader(&game_exe, &log);
2272    write_wrapper_script(&wrapper).unwrap();
2273
2274    // A profile deleted between the plan and the launch must not defeat the
2275    // launch, and must not be chainloaded as if it existed.
2276    let missing = dir.path().join("deleted-profile");
2277
2278    run_wrapper(
2279      &wrapper,
2280      &[
2281        &game_exe.to_string_lossy(),
2282        WRAPPER_TARGET_FLAG,
2283        &missing.to_string_lossy(),
2284      ],
2285      false,
2286    );
2287
2288    let lines = recorded(&log);
2289
2290    // The flag is consumed even when its value is rejected: the game must never
2291    // see it.
2292    assert_eq!(lines[0], game_exe.to_string_lossy());
2293    assert_eq!(lines.len(), 1, "the game got extra arguments: {lines:?}");
2294  }
2295
2296  #[cfg(unix)]
2297  #[test]
2298  fn wrapper_consumes_a_trailing_flag_that_has_no_value() {
2299    let dir = tempfile::tempdir().unwrap();
2300    let log = dir.path().join("argv.log");
2301    let wrapper = dir.path().join("steam-wrapper.sh");
2302    let game_exe = dir.path().join("valheim.x86_64");
2303
2304    recording_loader(&game_exe, &log);
2305    write_wrapper_script(&wrapper).unwrap();
2306
2307    run_wrapper(
2308      &wrapper,
2309      &[&game_exe.to_string_lossy(), WRAPPER_TARGET_FLAG],
2310      false,
2311    );
2312
2313    let lines = recorded(&log);
2314
2315    // A valueless flag names no target, so this is a vanilla launch — and the
2316    // dangling flag is still swallowed rather than handed to the game.
2317    assert_eq!(lines[0], game_exe.to_string_lossy());
2318    assert_eq!(lines.len(), 1, "the game got extra arguments: {lines:?}");
2319  }
2320
2321  #[cfg(unix)]
2322  #[test]
2323  fn wrapper_execs_the_game_directly_when_the_target_ships_no_loader_script() {
2324    let dir = tempfile::tempdir().unwrap();
2325    let selected = dir.path().join("profile");
2326    let log = dir.path().join("argv.log");
2327    let wrapper = dir.path().join("steam-wrapper.sh");
2328    // Stand in for the game binary Steam puts at the head of `%command%`.
2329    let game_exe = dir.path().join("valheim.x86_64");
2330
2331    std::fs::create_dir_all(&selected).unwrap();
2332    recording_loader(&game_exe, &log);
2333    write_wrapper_script(&wrapper).unwrap();
2334
2335    run_wrapper(
2336      &wrapper,
2337      &[
2338        &game_exe.to_string_lossy(),
2339        WRAPPER_TARGET_FLAG,
2340        &selected.to_string_lossy(),
2341        "-window-mode",
2342      ],
2343      false,
2344    );
2345
2346    let lines = recorded(&log);
2347
2348    // `exec "$@"` reached the game itself, and the flag still did not survive
2349    // into its argv.
2350    assert_eq!(lines[0], game_exe.to_string_lossy());
2351    assert_eq!(&lines[1..], ["-window-mode"]);
2352  }
2353
2354  #[cfg(unix)]
2355  #[test]
2356  fn wrapper_selects_the_server_script_only_when_ts_start_server_is_set() {
2357    let dir = tempfile::tempdir().unwrap();
2358    let target = dir.path().join("target");
2359    let server_log = dir.path().join("server.log");
2360    let game_log = dir.path().join("game.log");
2361    let wrapper = dir.path().join("steam-wrapper.sh");
2362
2363    std::fs::create_dir_all(&target).unwrap();
2364    // Both scripts present, which is the only way the gate is observable: the
2365    // server script has the higher precedence in `LOADER_SCRIPTS`, so without
2366    // the gate it would always win.
2367    recording_loader(&target.join(SERVER_SCRIPT), &server_log);
2368    recording_loader(&target.join("run_bepinex.sh"), &game_log);
2369    write_wrapper_script(&wrapper).unwrap();
2370
2371    let selected = target.to_string_lossy().to_string();
2372    let argv = [
2373      "/games/valheim/valheim.x86_64",
2374      WRAPPER_TARGET_FLAG,
2375      &selected,
2376    ];
2377
2378    run_wrapper(&wrapper, &argv, false);
2379
2380    assert!(
2381      !server_log.exists(),
2382      "the server script must not run for a game instance"
2383    );
2384    assert_eq!(
2385      recorded(&game_log)[0],
2386      target.join("run_bepinex.sh").to_string_lossy()
2387    );
2388
2389    run_wrapper(&wrapper, &argv, true);
2390
2391    assert_eq!(
2392      recorded(&server_log)[0],
2393      target.join(SERVER_SCRIPT).to_string_lossy()
2394    );
2395  }
2396
2397  #[test]
2398  fn steam_launch_options_are_only_the_wrapper_and_command_placeholder() {
2399    let options = steam_launch_options(Path::new("/data/launch_wrapper.sh"));
2400
2401    // Byte-for-byte what r2modman's `WrapperArguments.ts` emits: a quoted path
2402    // and `%command%`, and nothing else.
2403    assert_eq!(options, r#""/data/launch_wrapper.sh" %command%"#);
2404
2405    // No Wine setting rides along. This string is only ever produced for a Linux
2406    // native launch, where nothing in the path touches Wine, and it could not buy
2407    // Proton coverage anyway — see `steam_launch_options`'s doc comment.
2408    assert!(!options.contains("WINEDLLOVERRIDES"));
2409    assert!(!options.contains("winhttp"));
2410
2411    // A space in the script path stays inside the quotes.
2412    assert_eq!(
2413      steam_launch_options(Path::new("/data/My Games/wrapper.sh")),
2414      r#""/data/My Games/wrapper.sh" %command%"#
2415    );
2416  }
2417
2418  #[test]
2419  fn stage_destination_routes_per_loader() {
2420    let game = Path::new("/game");
2421
2422    assert_eq!(
2423      stage_destination(LoaderKind::BepInEx, "winhttp.dll", game, None),
2424      Some(PathBuf::from("/game/winhttp.dll"))
2425    );
2426    // mods.yml is manager state, never staged.
2427    assert_eq!(
2428      stage_destination(LoaderKind::BepInEx, "mods.yml", game, None),
2429      None
2430    );
2431
2432    // Shimloader: only its three files, into the UE binaries folder.
2433    assert_eq!(
2434      stage_destination(LoaderKind::Shimloader, "ue4ss.dll", game, Some("Palworld")),
2435      Some(PathBuf::from("/game/Palworld/Binaries/Win64/ue4ss.dll"))
2436    );
2437    assert_eq!(
2438      stage_destination(LoaderKind::Shimloader, "other.dll", game, Some("Palworld")),
2439      None
2440    );
2441    // No `dataFolderName` at all => nothing is staged.
2442    assert_eq!(
2443      stage_destination(LoaderKind::Shimloader, "ue4ss.dll", game, None),
2444      None
2445    );
2446
2447    // Rivet: only version.dll, into Release.
2448    assert_eq!(
2449      stage_destination(LoaderKind::Rivet, "version.dll", game, None),
2450      Some(PathBuf::from("/game/Release/version.dll"))
2451    );
2452    assert_eq!(
2453      stage_destination(LoaderKind::Rivet, "winhttp.dll", game, None),
2454      None
2455    );
2456  }
2457
2458  #[test]
2459  fn stage_for_launch_copies_root_files_only() {
2460    let dir = tempfile::tempdir().unwrap();
2461    let target = dir.path().join("target");
2462    let game_dir = dir.path().join("game");
2463
2464    std::fs::create_dir_all(target.join("BepInEx/core")).unwrap();
2465    std::fs::create_dir_all(&game_dir).unwrap();
2466    std::fs::write(target.join("winhttp.dll"), b"proxy").unwrap();
2467    std::fs::write(target.join("doorstop_config.ini"), b"[UnityDoorstop]").unwrap();
2468    std::fs::write(target.join("mods.yml"), b"[]").unwrap();
2469    std::fs::write(target.join("BepInEx/core/BepInEx.Preloader.dll"), b"x").unwrap();
2470
2471    let eco = Ecosystem::bundled();
2472    let written = stage_for_launch_in(
2473      &target,
2474      &eco,
2475      "valheim",
2476      &game_dir,
2477      &ctx(LaunchMode::Modded),
2478    )
2479    .unwrap();
2480
2481    assert!(game_dir.join("winhttp.dll").exists());
2482    assert!(game_dir.join("doorstop_config.ini").exists());
2483    // Manager state and subdirectories are not staged.
2484    assert!(!game_dir.join("mods.yml").exists());
2485    assert!(!game_dir.join("BepInEx").exists());
2486    assert_eq!(written.len(), 2);
2487
2488    // The returned paths are exactly what was written, and sorted.
2489    assert!(written.contains(&game_dir.join("winhttp.dll")));
2490    assert!(written.contains(&game_dir.join("doorstop_config.ini")));
2491
2492    let mut sorted = written.clone();
2493    sorted.sort();
2494
2495    assert_eq!(written, sorted);
2496  }
2497
2498  #[test]
2499  fn stage_for_launch_stages_shimloader_files_into_nested_binaries_dir() {
2500    // Palworld is a bundled Shimloader game (`dataFolderName` = "Pal"), so this
2501    // exercises `stage_for_launch_in` end to end through a nested destination
2502    // that does not exist yet, forcing `stage_one_file`'s `create_dir_all`.
2503    let dir = tempfile::tempdir().unwrap();
2504    let target = dir.path().join("target");
2505    let game_dir = dir.path().join("game");
2506
2507    std::fs::create_dir_all(&target).unwrap();
2508    std::fs::create_dir_all(&game_dir).unwrap();
2509    std::fs::write(target.join("ue4ss.dll"), b"loader").unwrap();
2510    std::fs::write(target.join("dwmapi.dll"), b"proxy").unwrap();
2511    std::fs::write(target.join("ue4ss-settings.ini"), b"[UE4SS]").unwrap();
2512    std::fs::write(target.join("other.dll"), b"unrelated").unwrap();
2513
2514    let eco = Ecosystem::bundled();
2515    let written = stage_for_launch_in(
2516      &target,
2517      &eco,
2518      "palworld",
2519      &game_dir,
2520      &ctx(LaunchMode::Modded),
2521    )
2522    .unwrap();
2523
2524    let nested = game_dir.join("Pal").join("Binaries").join("Win64");
2525
2526    assert_eq!(std::fs::read(nested.join("ue4ss.dll")).unwrap(), b"loader");
2527    assert!(nested.join("dwmapi.dll").exists());
2528    assert!(nested.join("ue4ss-settings.ini").exists());
2529    // Every other root file is skipped for Shimloader.
2530    assert!(!game_dir.join("other.dll").exists());
2531    assert!(!nested.join("other.dll").exists());
2532
2533    assert_eq!(written.len(), 3);
2534    assert!(written.contains(&nested.join("ue4ss.dll")));
2535    assert!(written.contains(&nested.join("dwmapi.dll")));
2536    assert!(written.contains(&nested.join("ue4ss-settings.ini")));
2537
2538    let mut sorted = written.clone();
2539    sorted.sort();
2540
2541    assert_eq!(written, sorted);
2542  }
2543
2544  #[test]
2545  fn stage_for_launch_stages_rivet_version_dll_into_release_dir() {
2546    // Scrap Mechanic is a bundled Rivet game, so this exercises
2547    // `stage_for_launch_in` end to end through the `<game>/Release` destination,
2548    // forcing `stage_one_file`'s `create_dir_all` for that nested directory too.
2549    let dir = tempfile::tempdir().unwrap();
2550    let target = dir.path().join("target");
2551    let game_dir = dir.path().join("game");
2552
2553    std::fs::create_dir_all(&target).unwrap();
2554    std::fs::create_dir_all(&game_dir).unwrap();
2555    std::fs::write(target.join("version.dll"), b"rivet-loader").unwrap();
2556    std::fs::write(target.join("other.dll"), b"unrelated").unwrap();
2557
2558    let eco = Ecosystem::bundled();
2559    let written = stage_for_launch_in(
2560      &target,
2561      &eco,
2562      "scrap-mechanic",
2563      &game_dir,
2564      &ctx(LaunchMode::Modded),
2565    )
2566    .unwrap();
2567
2568    let dest = game_dir.join("Release").join("version.dll");
2569
2570    assert_eq!(std::fs::read(&dest).unwrap(), b"rivet-loader");
2571    // Every other root file is skipped for Rivet.
2572    assert!(!game_dir.join("other.dll").exists());
2573    assert!(!game_dir.join("Release").join("other.dll").exists());
2574
2575    assert_eq!(written, vec![dest]);
2576  }
2577
2578  #[test]
2579  fn stage_for_launch_skips_linux_native_bepinex() {
2580    let dir = tempfile::tempdir().unwrap();
2581    let target = dir.path().join("target");
2582    let game_dir = dir.path().join("game");
2583
2584    std::fs::create_dir_all(&target).unwrap();
2585    std::fs::create_dir_all(&game_dir).unwrap();
2586    std::fs::write(target.join("winhttp.dll"), b"proxy").unwrap();
2587
2588    let eco = Ecosystem::bundled();
2589    let mut context = ctx(LaunchMode::Modded);
2590    context.os = HostOs::Linux;
2591    context.runtime = Runtime::Native;
2592
2593    let written = stage_for_launch_in(&target, &eco, "valheim", &game_dir, &context).unwrap();
2594
2595    assert!(written.is_empty());
2596    assert!(!game_dir.join("winhttp.dll").exists());
2597  }
2598
2599  #[cfg(unix)]
2600  #[test]
2601  fn stage_for_launch_refuses_to_write_through_a_symlink() {
2602    use std::os::unix::fs::symlink;
2603
2604    let dir = tempfile::tempdir().unwrap();
2605    let target = dir.path().join("target");
2606    let game_dir = dir.path().join("game");
2607    let outside = dir.path().join("secret.dll");
2608
2609    std::fs::create_dir_all(&target).unwrap();
2610    std::fs::create_dir_all(&game_dir).unwrap();
2611    std::fs::write(&outside, b"original").unwrap();
2612    std::fs::write(target.join("winhttp.dll"), b"proxy").unwrap();
2613    symlink(&outside, game_dir.join("winhttp.dll")).unwrap();
2614
2615    let eco = Ecosystem::bundled();
2616
2617    assert!(
2618      stage_for_launch_in(
2619        &target,
2620        &eco,
2621        "valheim",
2622        &game_dir,
2623        &ctx(LaunchMode::Modded)
2624      )
2625      .is_err()
2626    );
2627    assert_eq!(std::fs::read(&outside).unwrap(), b"original");
2628  }
2629
2630  #[test]
2631  fn stage_for_launch_in_place_leaves_root_files_byte_for_byte_untouched() {
2632    // `target_dir == game_dir` is the layout a consumer with no r2modman
2633    // profile uses when it installs mods straight into the game directory
2634    // (see `stage_for_launch_in`'s doc comment). Before the fix, `stage_one_file`
2635    // called `std::fs::copy(path, path)` here, which truncates the destination
2636    // to zero bytes: `std::fs::copy` opens the destination with `O_TRUNC`
2637    // before it ever reads the source.
2638    let dir = tempfile::tempdir().unwrap();
2639    let root = dir.path().join("game");
2640
2641    std::fs::create_dir_all(root.join("BepInEx/core")).unwrap();
2642
2643    let winhttp_contents = b"MZ-fake-proxy-dll-18b";
2644    let ini_contents = b"[UnityDoorstop]\nenabled=true\n";
2645
2646    std::fs::write(root.join("winhttp.dll"), winhttp_contents).unwrap();
2647    std::fs::write(root.join("doorstop_config.ini"), ini_contents).unwrap();
2648    std::fs::write(root.join("mods.yml"), b"[]").unwrap();
2649    std::fs::write(root.join("BepInEx/core/BepInEx.Preloader.dll"), b"x").unwrap();
2650
2651    let eco = Ecosystem::bundled();
2652    let written =
2653      stage_for_launch_in(&root, &eco, "valheim", &root, &ctx(LaunchMode::Modded)).unwrap();
2654
2655    // Contents, not mere existence — a zero-byte file still "exists", which is
2656    // precisely how the truncation bug hid behind every prior test.
2657    assert_eq!(
2658      std::fs::read(root.join("winhttp.dll")).unwrap(),
2659      winhttp_contents
2660    );
2661    assert_eq!(
2662      std::fs::read(root.join("doorstop_config.ini")).unwrap(),
2663      ini_contents
2664    );
2665
2666    // Neither file was actually written by this call — both were already
2667    // correctly in place — so neither is reported as written. See
2668    // `stage_for_launch_in`'s doc comment: over-reporting here would let a
2669    // caller that deletes everything in this list destroy a file it never
2670    // created.
2671    assert!(written.is_empty());
2672  }
2673
2674  #[test]
2675  fn stage_for_launch_in_place_survives_a_dot_dot_aliased_game_dir() {
2676    // `target/BepInEx/..` is not lexically equal to `target`, but resolves to
2677    // the exact same directory. The identity check must recognize this via
2678    // canonicalization rather than string comparison, or this differently
2679    // spelled `game_dir` would reintroduce the truncation bug.
2680    let dir = tempfile::tempdir().unwrap();
2681    let target = dir.path().join("target");
2682
2683    std::fs::create_dir_all(target.join("BepInEx/core")).unwrap();
2684
2685    let contents = b"proxy-bytes-not-truncated";
2686
2687    std::fs::write(target.join("winhttp.dll"), contents).unwrap();
2688
2689    let aliased_game_dir = target.join("BepInEx").join("..");
2690    let eco = Ecosystem::bundled();
2691
2692    let written = stage_for_launch_in(
2693      &target,
2694      &eco,
2695      "valheim",
2696      &aliased_game_dir,
2697      &ctx(LaunchMode::Modded),
2698    )
2699    .unwrap();
2700
2701    assert_eq!(std::fs::read(target.join("winhttp.dll")).unwrap(), contents);
2702    assert!(written.is_empty());
2703  }
2704
2705  #[cfg(unix)]
2706  #[test]
2707  fn stage_for_launch_in_place_survives_a_symlinked_game_dir() {
2708    use std::os::unix::fs::symlink;
2709
2710    // A `game_dir` reached through a symlinked directory must alias `target_dir`
2711    // exactly as the `..`-relative case above does, even though it is not
2712    // string-comparable to `target_dir` at all.
2713    let dir = tempfile::tempdir().unwrap();
2714    let target = dir.path().join("target");
2715    let link = dir.path().join("game-link");
2716
2717    std::fs::create_dir_all(target.join("BepInEx/core")).unwrap();
2718
2719    let contents = b"proxy-bytes-via-symlink";
2720
2721    std::fs::write(target.join("winhttp.dll"), contents).unwrap();
2722    symlink(&target, &link).unwrap();
2723
2724    let eco = Ecosystem::bundled();
2725    let written =
2726      stage_for_launch_in(&target, &eco, "valheim", &link, &ctx(LaunchMode::Modded)).unwrap();
2727
2728    assert_eq!(std::fs::read(target.join("winhttp.dll")).unwrap(), contents);
2729    assert!(written.is_empty());
2730  }
2731
2732  #[test]
2733  fn stage_for_launch_in_place_still_copies_shimloader_files_to_a_distinct_destination() {
2734    // Palworld is a bundled Shimloader game (`dataFolderName` = "Pal"). Even
2735    // with `target_dir == game_dir`, Shimloader's destination
2736    // (`<game_dir>/Pal/Binaries/Win64/<file>`) genuinely differs from its
2737    // source, so the identity check must not skip these — only a destination
2738    // identical to its source is exempted from copying.
2739    let dir = tempfile::tempdir().unwrap();
2740    let root = dir.path().join("game");
2741
2742    std::fs::create_dir_all(&root).unwrap();
2743    std::fs::write(root.join("ue4ss.dll"), b"loader").unwrap();
2744    std::fs::write(root.join("dwmapi.dll"), b"proxy").unwrap();
2745    std::fs::write(root.join("ue4ss-settings.ini"), b"[UE4SS]").unwrap();
2746
2747    let eco = Ecosystem::bundled();
2748    let written =
2749      stage_for_launch_in(&root, &eco, "palworld", &root, &ctx(LaunchMode::Modded)).unwrap();
2750
2751    let nested = root.join("Pal").join("Binaries").join("Win64");
2752
2753    assert_eq!(std::fs::read(nested.join("ue4ss.dll")).unwrap(), b"loader");
2754    assert_eq!(std::fs::read(nested.join("dwmapi.dll")).unwrap(), b"proxy");
2755    // The source files are untouched by their own copy, and still present at
2756    // the target root (which is also `game_dir` here).
2757    assert_eq!(std::fs::read(root.join("ue4ss.dll")).unwrap(), b"loader");
2758
2759    assert_eq!(written.len(), 3);
2760    assert!(written.contains(&nested.join("ue4ss.dll")));
2761    assert!(written.contains(&nested.join("dwmapi.dll")));
2762    assert!(written.contains(&nested.join("ue4ss-settings.ini")));
2763  }
2764
2765  #[test]
2766  fn stage_for_launch_in_place_still_copies_rivet_version_dll_to_a_distinct_destination() {
2767    // Scrap Mechanic is a bundled Rivet game. Its destination
2768    // (`<game_dir>/Release/version.dll`) differs from its source even when
2769    // `target_dir == game_dir`, so it must still be copied rather than skipped.
2770    let dir = tempfile::tempdir().unwrap();
2771    let root = dir.path().join("game");
2772
2773    std::fs::create_dir_all(&root).unwrap();
2774    std::fs::write(root.join("version.dll"), b"rivet-loader").unwrap();
2775
2776    let eco = Ecosystem::bundled();
2777    let written = stage_for_launch_in(
2778      &root,
2779      &eco,
2780      "scrap-mechanic",
2781      &root,
2782      &ctx(LaunchMode::Modded),
2783    )
2784    .unwrap();
2785
2786    let dest = root.join("Release").join("version.dll");
2787
2788    assert_eq!(std::fs::read(&dest).unwrap(), b"rivet-loader");
2789    assert_eq!(
2790      std::fs::read(root.join("version.dll")).unwrap(),
2791      b"rivet-loader"
2792    );
2793    assert_eq!(written, vec![dest]);
2794  }
2795
2796  #[test]
2797  fn stage_for_launch_wraps_stage_for_launch_in_over_a_layout_profile() {
2798    let dir = tempfile::tempdir().unwrap();
2799    let base = dir.path().join("base");
2800    let game_dir = dir.path().join("game");
2801
2802    let profile_dir = crate::profile::layout::create(&base, "valheim", "Main").unwrap();
2803    let core = profile_dir.join("BepInEx/core");
2804
2805    std::fs::create_dir_all(&core).unwrap();
2806    std::fs::create_dir_all(&game_dir).unwrap();
2807    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
2808    std::fs::write(profile_dir.join("winhttp.dll"), b"proxy").unwrap();
2809    std::fs::write(profile_dir.join("mods.yml"), b"[]").unwrap();
2810
2811    let eco = Ecosystem::bundled();
2812    let written = stage_for_launch(
2813      &base,
2814      "valheim",
2815      "Main",
2816      &eco,
2817      &game_dir,
2818      &ctx(LaunchMode::Modded),
2819    )
2820    .unwrap();
2821
2822    assert!(game_dir.join("winhttp.dll").exists());
2823    assert!(!game_dir.join("mods.yml").exists());
2824    assert_eq!(written, vec![game_dir.join("winhttp.dll")]);
2825  }
2826
2827  /// A Linux native `LaunchContext` for a directly executed (non-Steam) game.
2828  fn linux_native(mode: LaunchMode) -> LaunchContext {
2829    let mut context = ctx(mode);
2830
2831    context.os = HostOs::Linux;
2832    context.store = StorePlatform::SteamDirect;
2833    context.runtime = Runtime::Native;
2834
2835    context
2836  }
2837
2838  /// A target that looks like a real Linux BepInEx install, minus any loader
2839  /// script: preloader, proxy DLL, and Doorstop ini.
2840  fn linux_bepinex_target(root: &Path) {
2841    let core = root.join("BepInEx/core");
2842
2843    std::fs::create_dir_all(&core).unwrap();
2844    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
2845    std::fs::write(root.join("winhttp.dll"), b"proxy").unwrap();
2846    std::fs::write(root.join("doorstop_config.ini"), b"[UnityDoorstop]").unwrap();
2847  }
2848
2849  #[test]
2850  fn linux_native_modded_selects_any_shipped_script_not_only_run_bepinex() {
2851    let dir = tempfile::tempdir().unwrap();
2852    let target = dir.path().join("target");
2853    let game_dir = dir.path().join("game");
2854
2855    linux_bepinex_target(&target);
2856    std::fs::create_dir_all(&game_dir).unwrap();
2857    // This package ships `start_game_bepinex.sh`; there is no `run_bepinex.sh`.
2858    std::fs::write(target.join("start_game_bepinex.sh"), b"#!/bin/sh\n").unwrap();
2859
2860    let eco = Ecosystem::bundled();
2861    let context = linux_native(LaunchMode::Modded);
2862
2863    let plan = launch_plan_in(&target, &eco, "valheim", &context).unwrap();
2864
2865    match &plan.program {
2866      LaunchProgram::ProfileScript { path, .. } => {
2867        assert!(path.ends_with("start_game_bepinex.sh"))
2868      }
2869      // Falling through to `GameExe` here is the silent-vanilla bug: Doorstop
2870      // argv nothing reads, and no proxy staged into the game directory.
2871      other => panic!("expected ProfileScript, got {other:?}"),
2872    }
2873
2874    // Staging must agree with the plan rather than decide independently.
2875    let written = stage_for_launch_in(&target, &eco, "valheim", &game_dir, &context).unwrap();
2876
2877    assert!(written.is_empty());
2878    assert!(!game_dir.join("winhttp.dll").exists());
2879  }
2880
2881  #[test]
2882  fn server_instance_prefers_the_server_script_and_a_game_instance_ignores_it() {
2883    let dir = tempfile::tempdir().unwrap();
2884    let all_three = dir.path().join("all-three");
2885    let server_only = dir.path().join("server-only");
2886
2887    linux_bepinex_target(&all_three);
2888    linux_bepinex_target(&server_only);
2889
2890    for name in LOADER_SCRIPTS {
2891      std::fs::write(all_three.join(name), b"#!/bin/sh\n").unwrap();
2892    }
2893
2894    std::fs::write(server_only.join(SERVER_SCRIPT), b"#!/bin/sh\n").unwrap();
2895
2896    let context = linux_native(LaunchMode::Modded);
2897    let game_profile = profile_with(LoaderKind::BepInEx);
2898    let mut server_profile = profile_with(LoaderKind::BepInEx);
2899
2900    server_profile.instance_type = InstanceType::Server;
2901
2902    let server = resolve_program(
2903      &script_injection(&all_three, &server_profile, &context),
2904      &all_three,
2905      &server_profile,
2906      &context,
2907    )
2908    .unwrap();
2909
2910    match &server {
2911      LaunchProgram::ProfileScript { path, .. } => {
2912        assert!(path.ends_with("start_server_bepinex.sh"))
2913      }
2914      other => panic!("expected ProfileScript, got {other:?}"),
2915    }
2916
2917    // A game instance skips the server script even when it is present and first
2918    // in precedence — exec'ing it would start a dedicated server.
2919    let game = resolve_program(
2920      &script_injection(&all_three, &game_profile, &context),
2921      &all_three,
2922      &game_profile,
2923      &context,
2924    )
2925    .unwrap();
2926
2927    match &game {
2928      LaunchProgram::ProfileScript { path, .. } => assert!(path.ends_with("run_bepinex.sh")),
2929      other => panic!("expected ProfileScript, got {other:?}"),
2930    }
2931
2932    // With only the server script on disk, a game instance has no script at all.
2933    let err = resolve_program(
2934      &script_injection(&server_only, &game_profile, &context),
2935      &server_only,
2936      &game_profile,
2937      &context,
2938    )
2939    .unwrap_err();
2940
2941    assert!(err.to_string().contains("run_bepinex.sh"));
2942  }
2943
2944  #[test]
2945  fn modded_script_injection_without_a_script_errors_but_vanilla_falls_through() {
2946    let dir = tempfile::tempdir().unwrap();
2947    let target = dir.path().join("target");
2948
2949    linux_bepinex_target(&target);
2950
2951    let eco = Ecosystem::bundled();
2952    let modded = linux_native(LaunchMode::Modded);
2953
2954    let err = launch_plan_in(&target, &eco, "valheim", &modded).unwrap_err();
2955    let message = err.to_string();
2956
2957    assert!(message.contains("run_bepinex.sh"), "{message}");
2958    assert!(message.contains("start_game_bepinex.sh"), "{message}");
2959    assert!(message.contains(&target.display().to_string()), "{message}");
2960
2961    // Vanilla asks for no injection at all, so a missing injector is not a
2962    // failure: the game binary runs with the loader's disable flag.
2963    let vanilla =
2964      launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Vanilla)).unwrap();
2965
2966    assert!(matches!(vanilla.program, LaunchProgram::GameExe { .. }));
2967    assert_eq!(
2968      vanilla.args,
2969      vec!["--doorstop-enable".to_string(), "false".to_string()]
2970    );
2971  }
2972
2973  #[test]
2974  fn linux_native_steam_launches_steam_and_reports_the_wrapper_requirement() {
2975    let dir = tempfile::tempdir().unwrap();
2976    let target = dir.path().join("target");
2977    let game_dir = dir.path().join("game");
2978    let script_path = dir.path().join("launch_wrapper.sh");
2979
2980    linux_bepinex_target(&target);
2981    std::fs::create_dir_all(&game_dir).unwrap();
2982    std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
2983
2984    let eco = Ecosystem::bundled();
2985    let mut context = linux_native(LaunchMode::Modded);
2986
2987    context.store = StorePlatform::Steam;
2988
2989    let plan = launch_plan_in(&target, &eco, "valheim", &context).unwrap();
2990
2991    // Steam cannot be handed a script as its program: it takes `-applaunch`.
2992    match &plan.program {
2993      LaunchProgram::Steam { steam_app_id } => assert_eq!(*steam_app_id, 892970),
2994      other => panic!("expected Steam, got {other:?}"),
2995    }
2996
2997    // The wrapper requirement is discoverable from the plan itself, and carries
2998    // everything the two wrapper functions need — no reimplementing the rule and
2999    // no second ecosystem lookup.
3000    let wrapper = plan.steam_wrapper.clone().expect("wrapper requirement");
3001
3002    assert_eq!(wrapper.target_dir, target);
3003    assert_eq!(wrapper.loader, LoaderKind::BepInEx);
3004
3005    write_wrapper_script(&script_path).unwrap();
3006
3007    let options = steam_launch_options(&script_path);
3008
3009    assert!(options.contains(&script_path.display().to_string()));
3010    assert!(!options.contains("WINEDLLOVERRIDES"));
3011    assert!(
3012      std::fs::read_to_string(&script_path)
3013        .unwrap()
3014        .contains("run_bepinex.sh")
3015    );
3016
3017    // The plan already carries the target flag, so a caller that spawns it
3018    // verbatim reaches this target through a wrapper written for any other one.
3019    assert!(
3020      plan
3021        .args
3022        .windows(2)
3023        .any(|pair| pair == wrapper_target_args(&target).as_slice()),
3024      "plan args must carry the wrapper target flag: {:?}",
3025      plan.args
3026    );
3027
3028    // Injection is still script-based, so the game directory stays untouched.
3029    let written = stage_for_launch_in(&target, &eco, "valheim", &game_dir, &context).unwrap();
3030
3031    assert!(written.is_empty());
3032    assert!(!game_dir.join("winhttp.dll").exists());
3033  }
3034
3035  #[test]
3036  fn stage_for_launch_rejects_macos() {
3037    let dir = tempfile::tempdir().unwrap();
3038    let target = dir.path().join("target");
3039    let game_dir = dir.path().join("game");
3040
3041    linux_bepinex_target(&target);
3042    std::fs::create_dir_all(&game_dir).unwrap();
3043
3044    let eco = Ecosystem::bundled();
3045    let mut context = ctx(LaunchMode::Modded);
3046
3047    context.os = HostOs::MacOs;
3048
3049    let err = stage_for_launch_in(&target, &eco, "valheim", &game_dir, &context).unwrap_err();
3050
3051    assert!(err.to_string().contains("macOS launch is not supported"));
3052    // Nothing was written: `launch_plan_in` refuses macOS, so staging must not
3053    // dirty the game directory on its behalf.
3054    assert_eq!(std::fs::read_dir(&game_dir).unwrap().count(), 0);
3055
3056    // Control: the same target and game directory stage fine once `os` is not
3057    // macOS, proving the error comes from the macOS branch itself.
3058    context.os = HostOs::Windows;
3059
3060    let written = stage_for_launch_in(&target, &eco, "valheim", &game_dir, &context).unwrap();
3061
3062    assert_eq!(written.len(), 2);
3063    assert!(game_dir.join("winhttp.dll").exists());
3064  }
3065
3066  #[test]
3067  fn only_a_wrapper_bound_plan_carries_the_target_flag() {
3068    let dir = tempfile::tempdir().unwrap();
3069    let target = dir.path().join("target");
3070
3071    linux_bepinex_target(&target);
3072    std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
3073
3074    let eco = Ecosystem::bundled();
3075
3076    // Linux native, but launched by executing the script directly: no wrapper
3077    // stands between the caller and the loader, so the flag would be a stray
3078    // argument the game has to tolerate.
3079    let direct =
3080      launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Modded)).unwrap();
3081
3082    assert_eq!(direct.steam_wrapper, None);
3083    assert!(!direct.args.contains(&WRAPPER_TARGET_FLAG.to_string()));
3084
3085    // Steam, but injecting through the proxy DLL rather than a script: still no
3086    // wrapper, still no flag.
3087    let mut proton = linux_native(LaunchMode::Modded);
3088
3089    proton.store = StorePlatform::Steam;
3090    proton.runtime = Runtime::Proton;
3091
3092    let proton_plan = launch_plan_in(&target, &eco, "valheim", &proton).unwrap();
3093
3094    assert_eq!(proton_plan.steam_wrapper, None);
3095    assert!(!proton_plan.args.contains(&WRAPPER_TARGET_FLAG.to_string()));
3096  }
3097
3098  #[test]
3099  fn wrapper_branches_are_generated_from_the_shared_script_list() {
3100    let script = wrapper_script_contents();
3101
3102    let mut previous = 0;
3103
3104    for name in LOADER_SCRIPTS {
3105      let position = script
3106        .find(&format!("[ -f \"$TARGET/{name}\" ]"))
3107        .unwrap_or_else(|| panic!("wrapper is missing a branch for {name}"));
3108
3109      assert!(position >= previous, "{name} is out of precedence order");
3110
3111      previous = position;
3112    }
3113  }
3114
3115  // --- Script-path Doorstop spelling (v3-detected target injected via a loader
3116  // script must still use v4 flags, because the script itself only understands
3117  // v4 spellings; see `doorstop_args`'s doc comment) ---
3118
3119  #[test]
3120  fn linux_native_script_present_vanilla_runs_the_script_with_v4_enabled_spelling() {
3121    let dir = tempfile::tempdir().unwrap();
3122    let target = dir.path().join("target");
3123
3124    linux_bepinex_target(&target);
3125    std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
3126    // No `.doorstop_version` file: `doorstop_version` defaults to 3, but the
3127    // script only recognizes the v4 spelling — see `doorstop_args`'s doc comment.
3128
3129    let eco = Ecosystem::bundled();
3130    let plan =
3131      launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Vanilla)).unwrap();
3132
3133    // Vanilla still runs the script: the script itself honours the disable
3134    // flag, so there is no reason to fall back to the bare game binary.
3135    match &plan.program {
3136      LaunchProgram::ProfileScript { path, .. } => assert!(path.ends_with("run_bepinex.sh")),
3137      other => panic!("expected ProfileScript, got {other:?}"),
3138    }
3139
3140    assert_eq!(
3141      plan.args,
3142      vec!["--doorstop-enabled".to_string(), "false".to_string()]
3143    );
3144  }
3145
3146  #[test]
3147  fn linux_native_script_present_modded_uses_v4_target_assembly_spelling() {
3148    let dir = tempfile::tempdir().unwrap();
3149    let target = dir.path().join("target");
3150
3151    linux_bepinex_target(&target);
3152    std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
3153    // No `.doorstop_version` file here either: same v3-defaults-but-script-wants-v4
3154    // situation as the vanilla case above.
3155
3156    let eco = Ecosystem::bundled();
3157    let plan = launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Modded)).unwrap();
3158
3159    assert!(matches!(plan.program, LaunchProgram::ProfileScript { .. }));
3160    assert_eq!(plan.args[0], "--doorstop-enabled");
3161    assert_eq!(plan.args[1], "true");
3162    assert_eq!(plan.args[2], "--doorstop-target-assembly");
3163    assert!(plan.args[3].ends_with("BepInEx/core/BepInEx.Preloader.dll"));
3164  }
3165
3166  #[test]
3167  fn linux_native_script_present_v3_detected_corlib_override_uses_v4_search_flag_spelling() {
3168    // The third of the three doorstop-flag pairs, and the one the fix's other
3169    // script-path tests don't touch: `linux_bepinex_target` never creates
3170    // `unstripped_corlib`, so without this test the corlib search-path flag on
3171    // the script-injected + v3-detected path is only correct by inference from
3172    // the shared `if v4` tuple in `doorstop_args`, not by a direct assertion.
3173    let dir = tempfile::tempdir().unwrap();
3174    let target = dir.path().join("target");
3175
3176    linux_bepinex_target(&target);
3177    std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
3178    std::fs::create_dir_all(target.join("unstripped_corlib")).unwrap();
3179    // No `.doorstop_version` file: detected version defaults to v3, but the
3180    // script on disk forces the v4 spellings — see `doorstop_args`'s doc comment.
3181
3182    let eco = Ecosystem::bundled();
3183    let plan = launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Modded)).unwrap();
3184
3185    assert!(matches!(plan.program, LaunchProgram::ProfileScript { .. }));
3186
3187    let idx = plan
3188      .args
3189      .iter()
3190      .position(|a| a == "--doorstop-mono-dll-search-path-override")
3191      .expect("v4 corlib search-path flag missing");
3192
3193    assert!(plan.args[idx + 1].ends_with("/unstripped_corlib"));
3194    assert!(
3195      !plan
3196        .args
3197        .iter()
3198        .any(|a| a == "--doorstop-dll-search-override"),
3199      "v3 corlib search-path spelling must not appear on the script-injected path: {:?}",
3200      plan.args
3201    );
3202  }
3203
3204  #[test]
3205  fn linux_native_script_present_v4_detected_target_is_unchanged() {
3206    let dir = tempfile::tempdir().unwrap();
3207    let target = dir.path().join("target");
3208
3209    linux_bepinex_target(&target);
3210    std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
3211    std::fs::write(target.join(".doorstop_version"), "4.0.0").unwrap();
3212
3213    let eco = Ecosystem::bundled();
3214
3215    let vanilla =
3216      launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Vanilla)).unwrap();
3217
3218    assert_eq!(
3219      vanilla.args,
3220      vec!["--doorstop-enabled".to_string(), "false".to_string()]
3221    );
3222
3223    let modded =
3224      launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Modded)).unwrap();
3225
3226    assert_eq!(modded.args[0], "--doorstop-enabled");
3227    assert_eq!(modded.args[2], "--doorstop-target-assembly");
3228  }
3229
3230  #[test]
3231  fn proton_v3_detected_target_still_uses_v3_spellings_even_with_a_script_on_disk() {
3232    // Regression guard: script-path spelling must key off actually running
3233    // through the script (Linux + native + a found script), not merely off
3234    // `HostOs::Linux` or the script file's mere presence on disk. A Proton
3235    // (Wine) launch never goes through the loader script — `script_injection`
3236    // requires `Runtime::Native` — so even with `run_bepinex.sh` sitting in the
3237    // target and no `.doorstop_version` file, the argv must still get the
3238    // version-matched (v3) spelling, because it is Doorstop's own parser, not
3239    // the script, that will read it under Proton.
3240    let dir = tempfile::tempdir().unwrap();
3241    let target = dir.path().join("target");
3242
3243    linux_bepinex_target(&target);
3244    std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
3245
3246    let eco = Ecosystem::bundled();
3247    let mut context = linux_native(LaunchMode::Modded);
3248
3249    context.runtime = Runtime::Proton;
3250
3251    let plan = launch_plan_in(&target, &eco, "valheim", &context).unwrap();
3252
3253    assert_eq!(plan.args[0], "--doorstop-enable");
3254    assert_eq!(plan.args[2], "--doorstop-target");
3255
3256    let mut vanilla_context = linux_native(LaunchMode::Vanilla);
3257
3258    vanilla_context.runtime = Runtime::Proton;
3259
3260    let vanilla = launch_plan_in(&target, &eco, "valheim", &vanilla_context).unwrap();
3261
3262    assert_eq!(
3263      vanilla.args,
3264      vec!["--doorstop-enable".to_string(), "false".to_string()]
3265    );
3266  }
3267
3268  #[test]
3269  fn windows_v3_detected_target_still_uses_v3_spellings_regression_guard() {
3270    // Regression guard for the non-script paths named in the fix: a
3271    // v3-detected target launched via argv (not a script) must keep the v3
3272    // spellings, on Windows exactly as before this fix.
3273    let dir = tempfile::tempdir().unwrap();
3274    let root = dir.path();
3275    let core = root.join("BepInEx/core");
3276
3277    std::fs::create_dir_all(&core).unwrap();
3278    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
3279
3280    let eco = Ecosystem::bundled();
3281    let plan = launch_plan_in(root, &eco, "valheim", &ctx(LaunchMode::Modded)).unwrap();
3282
3283    assert_eq!(plan.args[0], "--doorstop-enable");
3284    assert_eq!(plan.args[2], "--doorstop-target");
3285
3286    let vanilla = launch_plan_in(root, &eco, "valheim", &ctx(LaunchMode::Vanilla)).unwrap();
3287
3288    assert_eq!(
3289      vanilla.args,
3290      vec!["--doorstop-enable".to_string(), "false".to_string()]
3291    );
3292  }
3293
3294  #[test]
3295  fn store_and_runtime_parse_from_their_config_spellings() {
3296    assert_eq!(
3297      "steam".parse::<StorePlatform>().unwrap(),
3298      StorePlatform::Steam
3299    );
3300    assert_eq!(
3301      "steam-direct".parse::<StorePlatform>().unwrap(),
3302      StorePlatform::SteamDirect
3303    );
3304    assert_eq!(
3305      "other".parse::<StorePlatform>().unwrap(),
3306      StorePlatform::Other
3307    );
3308    assert_eq!("native".parse::<Runtime>().unwrap(), Runtime::Native);
3309    assert_eq!("proton".parse::<Runtime>().unwrap(), Runtime::Proton);
3310
3311    // An unknown value names itself, so a caller can quote it back.
3312    let error = "wat".parse::<StorePlatform>().unwrap_err().to_string();
3313
3314    assert!(error.contains("wat"), "got: {error}");
3315  }
3316
3317  #[test]
3318  fn resolve_game_exe_returns_the_first_present_candidate() {
3319    let dir = tempfile::tempdir().unwrap();
3320    let game_dir = dir.path();
3321
3322    std::fs::write(game_dir.join("second.x86_64"), b"elf").unwrap();
3323
3324    let names = vec!["first.exe".to_string(), "second.x86_64".to_string()];
3325    let found = resolve_game_exe(game_dir, &names).unwrap();
3326
3327    assert_eq!(found, game_dir.join("second.x86_64"));
3328
3329    // Nothing present names every candidate it looked for.
3330    let error = resolve_game_exe(game_dir, &["absent.exe".to_string()])
3331      .unwrap_err()
3332      .to_string();
3333
3334    assert!(error.contains("absent.exe"), "got: {error}");
3335  }
3336
3337  #[test]
3338  fn a_plan_becomes_a_spawnable_command() {
3339    let dir = tempfile::tempdir().unwrap();
3340    let game_dir = dir.path();
3341
3342    std::fs::write(game_dir.join("valheim.x86_64"), b"elf").unwrap();
3343
3344    // `LaunchPlan` derives no `Default`, so every field is named. It is
3345    // `#[non_exhaustive]`, which only restricts other crates, so a struct literal
3346    // is fine inside this module.
3347    let plan = LaunchPlan {
3348      program: LaunchProgram::GameExe {
3349        exe_names: vec!["valheim.x86_64".to_string()],
3350      },
3351      args: vec!["-console".to_string()],
3352      env: vec![("KEY".to_string(), "VALUE".to_string())],
3353      working_dir: Some(game_dir.to_path_buf()),
3354      steam_wrapper: None,
3355    };
3356
3357    let command = plan.to_command(game_dir).unwrap();
3358
3359    assert_eq!(command.get_program(), game_dir.join("valheim.x86_64"));
3360    assert_eq!(
3361      command.get_args().collect::<Vec<_>>(),
3362      vec![std::ffi::OsStr::new("-console")]
3363    );
3364    assert_eq!(command.get_current_dir(), Some(game_dir));
3365    assert!(
3366      command
3367        .get_envs()
3368        .any(|(key, value)| key == std::ffi::OsStr::new("KEY")
3369          && value == Some(std::ffi::OsStr::new("VALUE")))
3370    );
3371  }
3372}