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
1360/// Builds the invocation that asks a running Steam client to start
1361/// `steam_app_id`.
1362///
1363/// Takes the program as a parameter rather than resolving it, so the argv this
1364/// produces can be asserted on any host — including one with no Steam installed,
1365/// which is every CI runner this crate is tested on.
1366///
1367/// Sets no environment: `steam -applaunch <id>` only messages an already-running
1368/// client, which starts the game with its own environment, so anything set here
1369/// would silently reach nothing. See [`LaunchPlan::env`].
1370fn steam_command(program: &Path, steam_app_id: u32) -> std::process::Command {
1371  let mut command = std::process::Command::new(program);
1372
1373  command.arg("-applaunch").arg(steam_app_id.to_string());
1374
1375  command
1376}
1377
1378impl LaunchPlan {
1379  /// Translates this plan into the process to spawn.
1380  ///
1381  /// Always an argv array, never a shell, so a path containing spaces or quotes
1382  /// cannot become an injection. Separate from spawning so the program,
1383  /// arguments, and environment can be asserted without starting anything.
1384  ///
1385  /// Every [`LaunchProgram`] variant is handled, with no wildcard arm.
1386  /// `LaunchProgram` is `#[non_exhaustive]`, but that only constrains other
1387  /// crates, so a wildcard here would be an unreachable pattern and fail
1388  /// `-D warnings`. Adding a variant therefore breaks this build deliberately,
1389  /// which is the point: a new launch shape must be translated, not silently
1390  /// rejected at run time.
1391  ///
1392  /// This is where the host is read, which is why the two variants that need a
1393  /// real path both resolve one here rather than in [`launch_plan_in`]: the plan
1394  /// stays a pure computation, and translating it into a process is the step
1395  /// allowed to look at the machine. [`LaunchProgram::GameExe`] and
1396  /// [`LaunchProgram::ProfileScript`] resolve their binary out of `game_dir`, and
1397  /// [`LaunchProgram::Steam`] resolves the Steam client through
1398  /// [`crate::steam::steam_exe`] — a bare `steam` resolves on Linux but never on
1399  /// Windows, whose Steam installer adds nothing to `PATH`.
1400  pub fn to_command(&self, game_dir: &Path) -> Result<std::process::Command> {
1401    let mut command = match &self.program {
1402      LaunchProgram::GameExe { exe_names } => {
1403        std::process::Command::new(resolve_game_exe(game_dir, exe_names)?)
1404      }
1405      LaunchProgram::Steam { steam_app_id } => {
1406        steam_command(&crate::steam::steam_exe(), *steam_app_id)
1407      }
1408      LaunchProgram::ProfileScript { path, exe_names } => {
1409        let mut command = std::process::Command::new(path);
1410
1411        command.arg(resolve_game_exe(game_dir, exe_names)?);
1412
1413        command
1414      }
1415    };
1416
1417    command.args(&self.args);
1418
1419    for (key, value) in &self.env {
1420      command.env(key, value);
1421    }
1422
1423    if let Some(dir) = &self.working_dir {
1424      command.current_dir(dir);
1425    }
1426
1427    Ok(command)
1428  }
1429}
1430
1431#[cfg(test)]
1432mod tests {
1433  use super::*;
1434
1435  #[test]
1436  fn doorstop_version_reads_major_and_defaults_to_three() {
1437    let dir = tempfile::tempdir().unwrap();
1438    let root = dir.path();
1439
1440    // Missing file => v3.
1441    assert_eq!(doorstop_version(root), 3);
1442
1443    std::fs::write(root.join(".doorstop_version"), "4.0.0").unwrap();
1444    assert_eq!(doorstop_version(root), 4);
1445
1446    std::fs::write(root.join(".doorstop_version"), "3.7.1").unwrap();
1447    assert_eq!(doorstop_version(root), 3);
1448
1449    // r2modman falls back to v3 for major >= 5; we treat it as v4 (its own bug).
1450    std::fs::write(root.join(".doorstop_version"), "5.1.0").unwrap();
1451    assert_eq!(doorstop_version(root), 5);
1452
1453    // Unparseable => v3.
1454    std::fs::write(root.join(".doorstop_version"), "garbage").unwrap();
1455    assert_eq!(doorstop_version(root), 3);
1456  }
1457
1458  #[test]
1459  fn bepinex_preloader_finds_candidate_and_prefixes_under_proton() {
1460    let dir = tempfile::tempdir().unwrap();
1461    let root = dir.path();
1462    let core = root.join("BepInEx/core");
1463
1464    std::fs::create_dir_all(&core).unwrap();
1465    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1466    std::fs::write(core.join("Unrelated.dll"), b"x").unwrap();
1467
1468    let native = bepinex_preloader(root, Runtime::Native).unwrap();
1469
1470    assert!(native.ends_with("BepInEx/core/BepInEx.Preloader.dll"));
1471    assert!(!native.starts_with("Z:"));
1472
1473    let proton = bepinex_preloader(root, Runtime::Proton).unwrap();
1474
1475    assert!(proton.starts_with("Z:"));
1476    assert!(proton.ends_with("BepInEx/core/BepInEx.Preloader.dll"));
1477  }
1478
1479  #[test]
1480  fn bepinex_preloader_errors_when_absent() {
1481    let dir = tempfile::tempdir().unwrap();
1482
1483    assert!(bepinex_preloader(dir.path(), Runtime::Native).is_err());
1484  }
1485
1486  #[test]
1487  fn has_corlibs_detects_the_directory() {
1488    let dir = tempfile::tempdir().unwrap();
1489    let root = dir.path();
1490
1491    assert!(!has_corlibs(root));
1492
1493    std::fs::create_dir_all(root.join("unstripped_corlib")).unwrap();
1494
1495    assert!(has_corlibs(root));
1496  }
1497
1498  fn ctx(mode: LaunchMode) -> LaunchContext {
1499    LaunchContext {
1500      os: HostOs::Windows,
1501      store: StorePlatform::Steam,
1502      runtime: Runtime::Native,
1503      mode,
1504      extra_args: Vec::new(),
1505    }
1506  }
1507
1508  fn profile_with(loader: LoaderKind) -> GameProfile {
1509    let eco = Ecosystem::bundled();
1510    let mut profile = eco.game("valheim").unwrap().profile().unwrap().clone();
1511    profile.package_loader = loader;
1512
1513    profile
1514  }
1515
1516  #[test]
1517  fn bepinex_v3_and_v4_use_the_right_flag_spellings() {
1518    let dir = tempfile::tempdir().unwrap();
1519    let root = dir.path();
1520    let core = root.join("BepInEx/core");
1521
1522    std::fs::create_dir_all(&core).unwrap();
1523    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1524
1525    let profile = profile_with(LoaderKind::BepInEx);
1526
1527    let v3 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1528
1529    assert_eq!(v3[0], "--doorstop-enable");
1530    assert_eq!(v3[1], "true");
1531    assert_eq!(v3[2], "--doorstop-target");
1532
1533    std::fs::write(root.join(".doorstop_version"), "4.0.0").unwrap();
1534
1535    let v4 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1536
1537    assert_eq!(v4[0], "--doorstop-enabled");
1538    assert_eq!(v4[2], "--doorstop-target-assembly");
1539
1540    // Vanilla uses the matching spelling for the detected version — r2modman
1541    // emits the v3 spelling here even on v4, which we deliberately correct.
1542    let vanilla = loader_args(root, &profile, &ctx(LaunchMode::Vanilla)).unwrap();
1543
1544    assert_eq!(
1545      vanilla,
1546      vec!["--doorstop-enabled".to_string(), "false".to_string()]
1547    );
1548  }
1549
1550  #[test]
1551  fn melonloader_adds_regenerate_only_when_assemblies_are_absent() {
1552    let dir = tempfile::tempdir().unwrap();
1553    let root = dir.path();
1554    let profile = profile_with(LoaderKind::MelonLoader);
1555
1556    let bare = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1557
1558    assert_eq!(bare[0], "--melonloader.basedir");
1559    assert!(bare.contains(&"--melonloader.agfregenerate".to_string()));
1560
1561    let managed = root.join("MelonLoader/Managed");
1562    std::fs::create_dir_all(&managed).unwrap();
1563    std::fs::write(managed.join("Assembly-CSharp.dll"), b"x").unwrap();
1564
1565    let with_assembly = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1566
1567    assert!(!with_assembly.contains(&"--melonloader.agfregenerate".to_string()));
1568    assert_eq!(
1569      loader_args(root, &profile, &ctx(LaunchMode::Vanilla)).unwrap(),
1570      vec!["--no-mods".to_string()]
1571    );
1572  }
1573
1574  #[test]
1575  fn single_element_flags_stay_single_elements() {
1576    let dir = tempfile::tempdir().unwrap();
1577    let root = dir.path();
1578
1579    let gdweave = loader_args(
1580      root,
1581      &profile_with(LoaderKind::GdWeave),
1582      &ctx(LaunchMode::Modded),
1583    )
1584    .unwrap();
1585
1586    assert_eq!(gdweave.len(), 1);
1587    assert!(gdweave[0].starts_with("--gdweave-folder-override="));
1588    assert!(gdweave[0].ends_with("/GDWeave"));
1589
1590    let northstar = loader_args(
1591      root,
1592      &profile_with(LoaderKind::Northstar),
1593      &ctx(LaunchMode::Modded),
1594    )
1595    .unwrap();
1596
1597    assert_eq!(northstar[0], "-northstar");
1598    assert!(northstar[1].starts_with("-profile="));
1599  }
1600
1601  #[test]
1602  fn shimloader_and_rivet_and_rom_shapes() {
1603    let dir = tempfile::tempdir().unwrap();
1604    let root = dir.path();
1605
1606    let shim = loader_args(
1607      root,
1608      &profile_with(LoaderKind::Shimloader),
1609      &ctx(LaunchMode::Modded),
1610    )
1611    .unwrap();
1612
1613    assert_eq!(shim.len(), 8);
1614    assert_eq!(shim[0], "--mod-dir");
1615    assert!(shim.contains(&"--overlay-dir".to_string()));
1616
1617    let rivet = loader_args(
1618      root,
1619      &profile_with(LoaderKind::Rivet),
1620      &ctx(LaunchMode::Modded),
1621    )
1622    .unwrap();
1623
1624    assert_eq!(rivet[0], "-rivetEnable");
1625    assert_eq!(rivet[1], "true");
1626    assert!(rivet.contains(&"-rivetDirectory".to_string()));
1627    assert_eq!(
1628      loader_args(
1629        root,
1630        &profile_with(LoaderKind::Rivet),
1631        &ctx(LaunchMode::Vanilla)
1632      )
1633      .unwrap(),
1634      vec!["-rivetEnable".to_string(), "false".to_string()]
1635    );
1636
1637    let rom = loader_args(
1638      root,
1639      &profile_with(LoaderKind::ReturnOfModding),
1640      &ctx(LaunchMode::Modded),
1641    )
1642    .unwrap();
1643
1644    assert_eq!(rom[0], "--rom_modding_root_folder");
1645  }
1646
1647  #[test]
1648  fn unknown_and_none_loaders_produce_no_args() {
1649    let dir = tempfile::tempdir().unwrap();
1650    let root = dir.path();
1651
1652    assert!(
1653      loader_args(
1654        root,
1655        &profile_with(LoaderKind::None),
1656        &ctx(LaunchMode::Modded)
1657      )
1658      .unwrap()
1659      .is_empty()
1660    );
1661    assert!(
1662      loader_args(
1663        root,
1664        &profile_with(LoaderKind::Other),
1665        &ctx(LaunchMode::Modded)
1666      )
1667      .unwrap()
1668      .is_empty()
1669    );
1670  }
1671
1672  #[test]
1673  fn server_instance_appends_server_flag_for_bepinex() {
1674    let dir = tempfile::tempdir().unwrap();
1675    let root = dir.path();
1676    let core = root.join("BepInEx/core");
1677
1678    std::fs::create_dir_all(&core).unwrap();
1679    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1680
1681    let mut profile = profile_with(LoaderKind::BepInEx);
1682    profile.instance_type = InstanceType::Server;
1683
1684    let args = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1685
1686    assert!(args.contains(&"--server".to_string()));
1687  }
1688
1689  #[test]
1690  fn umm_uses_doorstop_flags_and_never_gets_server_flag() {
1691    let dir = tempfile::tempdir().unwrap();
1692    let root = dir.path();
1693    let profile = profile_with(LoaderKind::Umm);
1694
1695    let v3 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1696
1697    assert_eq!(v3[0], "--doorstop-enable");
1698    assert_eq!(v3[1], "true");
1699    assert_eq!(v3[2], "--doorstop-target");
1700    assert!(v3[3].ends_with("UMM/Core/UnityModManager.dll"));
1701
1702    std::fs::write(root.join(".doorstop_version"), "4.0.0").unwrap();
1703
1704    let v4 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1705
1706    assert_eq!(v4[0], "--doorstop-enabled");
1707    assert_eq!(v4[2], "--doorstop-target-assembly");
1708    assert!(v4[3].ends_with("UMM/Core/UnityModManager.dll"));
1709
1710    let vanilla = loader_args(root, &profile, &ctx(LaunchMode::Vanilla)).unwrap();
1711
1712    assert_eq!(
1713      vanilla,
1714      vec!["--doorstop-enabled".to_string(), "false".to_string()]
1715    );
1716
1717    // Unlike BepInEx, UMM's `DoorstopTarget` arm never appends `--server`, even
1718    // when the profile's instance type is `Server`.
1719    let mut server_profile = profile_with(LoaderKind::Umm);
1720    server_profile.instance_type = InstanceType::Server;
1721
1722    let server_args = loader_args(root, &server_profile, &ctx(LaunchMode::Modded)).unwrap();
1723
1724    assert!(!server_args.contains(&"--server".to_string()));
1725  }
1726
1727  #[test]
1728  fn bepis_loader_modded_and_vanilla_shapes() {
1729    let dir = tempfile::tempdir().unwrap();
1730    let root = dir.path();
1731    let core = root.join("BepInEx/core");
1732
1733    std::fs::create_dir_all(&core).unwrap();
1734    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1735
1736    let profile = profile_with(LoaderKind::BepisLoader);
1737
1738    let modded = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1739
1740    assert_eq!(modded[0], "--hookfxr-enable");
1741    assert_eq!(modded[1], "--bepinex-target");
1742    assert!(modded.contains(&"--doorstop-enabled".to_string()));
1743    assert!(modded.contains(&"--doorstop-target-assembly".to_string()));
1744
1745    let vanilla = loader_args(root, &profile, &ctx(LaunchMode::Vanilla)).unwrap();
1746
1747    assert_eq!(
1748      vanilla,
1749      vec![
1750        "--hookfxr-disable".to_string(),
1751        "--doorstop-enabled".to_string(),
1752        "false".to_string()
1753      ]
1754    );
1755  }
1756
1757  #[test]
1758  fn lovely_modded_and_vanilla_shapes() {
1759    let dir = tempfile::tempdir().unwrap();
1760    let root = dir.path();
1761
1762    let modded = loader_args(
1763      root,
1764      &profile_with(LoaderKind::Lovely),
1765      &ctx(LaunchMode::Modded),
1766    )
1767    .unwrap();
1768
1769    assert_eq!(modded[0], "--mod-dir");
1770    assert!(modded[1].ends_with("/mods"));
1771
1772    let vanilla = loader_args(
1773      root,
1774      &profile_with(LoaderKind::Lovely),
1775      &ctx(LaunchMode::Vanilla),
1776    )
1777    .unwrap();
1778
1779    assert_eq!(vanilla, vec!["--vanilla".to_string()]);
1780  }
1781
1782  #[test]
1783  fn godot_ml_modded_and_vanilla_shapes() {
1784    let dir = tempfile::tempdir().unwrap();
1785    let root = dir.path();
1786
1787    let modded = loader_args(
1788      root,
1789      &profile_with(LoaderKind::GodotMl),
1790      &ctx(LaunchMode::Modded),
1791    )
1792    .unwrap();
1793
1794    assert_eq!(modded[0], "--script");
1795    assert_eq!(modded[1], "res://addons/mod_loader/mod_loader_setup.gd");
1796    assert!(modded.contains(&"--mods-path".to_string()));
1797
1798    let vanilla = loader_args(
1799      root,
1800      &profile_with(LoaderKind::GodotMl),
1801      &ctx(LaunchMode::Vanilla),
1802    )
1803    .unwrap();
1804
1805    assert!(vanilla.is_empty());
1806  }
1807
1808  #[test]
1809  fn corlib_override_appends_search_flag_pair_matching_version() {
1810    let dir = tempfile::tempdir().unwrap();
1811    let root = dir.path();
1812    let core = root.join("BepInEx/core");
1813
1814    std::fs::create_dir_all(&core).unwrap();
1815    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1816    std::fs::create_dir_all(root.join("unstripped_corlib")).unwrap();
1817
1818    let profile = profile_with(LoaderKind::BepInEx);
1819
1820    let v3 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1821    let v3_idx = v3
1822      .iter()
1823      .position(|a| a == "--doorstop-dll-search-override")
1824      .unwrap();
1825
1826    assert!(v3[v3_idx + 1].ends_with("/unstripped_corlib"));
1827
1828    std::fs::write(root.join(".doorstop_version"), "4.0.0").unwrap();
1829
1830    let v4 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1831    let v4_idx = v4
1832      .iter()
1833      .position(|a| a == "--doorstop-mono-dll-search-path-override")
1834      .unwrap();
1835
1836    assert!(v4[v4_idx + 1].ends_with("/unstripped_corlib"));
1837
1838    // BepisLoader hardcodes the v4 search-flag spelling regardless of the
1839    // detected `.doorstop_version`.
1840    std::fs::remove_file(root.join(".doorstop_version")).unwrap();
1841
1842    let bepis = loader_args(
1843      root,
1844      &profile_with(LoaderKind::BepisLoader),
1845      &ctx(LaunchMode::Modded),
1846    )
1847    .unwrap();
1848    let bepis_idx = bepis
1849      .iter()
1850      .position(|a| a == "--doorstop-mono-dll-search-path-override")
1851      .unwrap();
1852
1853    assert!(bepis[bepis_idx + 1].ends_with("/unstripped_corlib"));
1854  }
1855
1856  #[test]
1857  fn windows_steam_plan_targets_steam_with_loader_args() {
1858    let dir = tempfile::tempdir().unwrap();
1859    let root = dir.path();
1860    let core = root.join("BepInEx/core");
1861
1862    std::fs::create_dir_all(&core).unwrap();
1863    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1864
1865    let eco = Ecosystem::bundled();
1866    let mut context = ctx(LaunchMode::Modded);
1867    context.extra_args = vec!["-nolog".to_string()];
1868
1869    let plan = launch_plan_in(root, &eco, "valheim", &context).unwrap();
1870
1871    match &plan.program {
1872      LaunchProgram::Steam { steam_app_id } => assert_eq!(*steam_app_id, 892970),
1873      other => panic!("expected Steam, got {other:?}"),
1874    }
1875
1876    assert_eq!(plan.args[0], "--doorstop-enable");
1877    // User args come last.
1878    assert_eq!(plan.args.last().unwrap(), "-nolog");
1879    assert!(plan.env.is_empty());
1880    // Windows injects through the proxy DLL, so Steam needs no wrapper file.
1881    assert_eq!(plan.steam_wrapper, None);
1882  }
1883
1884  #[test]
1885  fn proton_sets_wine_dll_overrides_and_prefixes_the_preloader() {
1886    let dir = tempfile::tempdir().unwrap();
1887    let root = dir.path();
1888    let core = root.join("BepInEx/core");
1889
1890    std::fs::create_dir_all(&core).unwrap();
1891    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1892
1893    let eco = Ecosystem::bundled();
1894    let mut context = ctx(LaunchMode::Modded);
1895    context.os = HostOs::Linux;
1896    context.runtime = Runtime::Proton;
1897
1898    let plan = launch_plan_in(root, &eco, "valheim", &context).unwrap();
1899
1900    assert_eq!(
1901      plan.env,
1902      vec![("WINEDLLOVERRIDES".to_string(), "winhttp=n,b".to_string())]
1903    );
1904    assert!(plan.args.iter().any(|a| a.starts_with("Z:")));
1905    // Proton injects through the proxy DLL too: no script, so no wrapper.
1906    assert_eq!(plan.steam_wrapper, None);
1907  }
1908
1909  #[test]
1910  fn linux_native_bepinex_runs_the_profile_script() {
1911    let dir = tempfile::tempdir().unwrap();
1912    let root = dir.path();
1913    let core = root.join("BepInEx/core");
1914
1915    std::fs::create_dir_all(&core).unwrap();
1916    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1917    std::fs::write(root.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
1918
1919    let eco = Ecosystem::bundled();
1920    let mut context = ctx(LaunchMode::Modded);
1921    context.os = HostOs::Linux;
1922    context.store = StorePlatform::SteamDirect;
1923    context.runtime = Runtime::Native;
1924
1925    let plan = launch_plan_in(root, &eco, "valheim", &context).unwrap();
1926
1927    match &plan.program {
1928      // `exe_names` travels with the script: the caller execs the script *with*
1929      // the game binary, so it needs the same candidates `GameExe` hands over.
1930      LaunchProgram::ProfileScript { path, exe_names } => {
1931        assert!(path.ends_with("run_bepinex.sh"));
1932        assert_eq!(
1933          *exe_names,
1934          eco.game("valheim").unwrap().profile().unwrap().exe_names
1935        );
1936        assert!(!exe_names.is_empty());
1937      }
1938      other => panic!("expected ProfileScript, got {other:?}"),
1939    }
1940
1941    // A directly launched script is not the Steam wrapper case.
1942    assert_eq!(plan.steam_wrapper, None);
1943  }
1944
1945  #[test]
1946  fn macos_is_rejected() {
1947    let dir = tempfile::tempdir().unwrap();
1948    let root = dir.path();
1949    let core = root.join("BepInEx/core");
1950
1951    std::fs::create_dir_all(&core).unwrap();
1952    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1953
1954    let eco = Ecosystem::bundled();
1955    let mut context = ctx(LaunchMode::Modded);
1956    context.os = HostOs::MacOs;
1957
1958    let err = launch_plan_in(root, &eco, "valheim", &context).unwrap_err();
1959
1960    assert!(err.to_string().contains("macOS launch is not supported"));
1961
1962    // Control: the exact same directory and profile succeed once `os` is not
1963    // macOS, proving the error above comes from the macOS branch itself and
1964    // not, say, a missing BepInEx preloader fixture.
1965    context.os = HostOs::Windows;
1966
1967    assert!(launch_plan_in(root, &eco, "valheim", &context).is_ok());
1968  }
1969
1970  #[test]
1971  fn unknown_game_errors() {
1972    let dir = tempfile::tempdir().unwrap();
1973    let eco = Ecosystem::bundled();
1974
1975    let err = launch_plan_in(dir.path(), &eco, "not-a-game", &ctx(LaunchMode::Modded)).unwrap_err();
1976
1977    assert!(err.to_string().contains("no ecosystem profile"));
1978  }
1979
1980  #[test]
1981  fn proxy_dll_is_winmm_for_gdweave() {
1982    assert_eq!(proxy_dll(LoaderKind::GdWeave), "winmm");
1983    assert_eq!(proxy_dll(LoaderKind::BepInEx), "winhttp");
1984  }
1985
1986  #[test]
1987  fn wrapper_script_names_no_target_and_execs_the_loader() {
1988    let script = wrapper_script_contents();
1989
1990    assert!(script.starts_with("#!/bin/sh"));
1991
1992    // No target of any kind is baked in — that is what makes one script correct
1993    // for every profile, and what makes a bare launch unmodded rather than
1994    // whichever profile was current when it was written.
1995    assert!(script.contains(r#"TARGET="""#));
1996    assert!(script.contains(r#"[ -z "$TARGET" ]"#));
1997
1998    // The run-time flag is parsed, not `eval`-ed, and its value is only adopted
1999    // when it names a directory.
2000    assert!(script.contains(&format!(r#"[ "$argument" = "{WRAPPER_TARGET_FLAG}" ]"#)));
2001    assert!(script.contains(r#"[ -d "$candidate" ]"#));
2002    assert!(!script.contains("eval"));
2003
2004    // Verify all three branches are present in the correct order.
2005    let pos_server = script
2006      .find("start_server_bepinex.sh")
2007      .expect("server branch missing");
2008    let pos_run = script.find("run_bepinex.sh").expect("run branch missing");
2009    let pos_game = script
2010      .find("start_game_bepinex.sh")
2011      .expect("game branch missing");
2012
2013    assert!(pos_server < pos_run, "server branch must come before run");
2014    assert!(pos_run < pos_game, "run branch must come before game");
2015
2016    // The server branch must gate on TS_START_SERVER.
2017    assert!(script.contains("$TS_START_SERVER"));
2018
2019    // The script must have the else fallthrough and be well-formed.
2020    assert!(script.contains("else"));
2021    assert!(script.contains("fi"));
2022
2023    // The game command is forwarded, not swallowed.
2024    assert!(script.contains("\"$@\""));
2025  }
2026
2027  #[test]
2028  fn write_wrapper_script_creates_an_executable_file() {
2029    let dir = tempfile::tempdir().unwrap();
2030    let root = dir.path();
2031    let script_path = root.join("launch_wrapper.sh");
2032
2033    write_wrapper_script(&script_path).unwrap();
2034
2035    assert!(script_path.is_file());
2036
2037    #[cfg(unix)]
2038    {
2039      use std::os::unix::fs::PermissionsExt;
2040
2041      let mode = std::fs::metadata(&script_path)
2042        .unwrap()
2043        .permissions()
2044        .mode();
2045
2046      assert_eq!(mode & 0o111, 0o111);
2047    }
2048  }
2049
2050  /// The regression this exists for: a Thunderstore loader package ships its
2051  /// scripts non-executable, so a freshly installed profile cannot be launched at
2052  /// all until something sets the bit. Under Steam the failure is silent, so the
2053  /// only defense is that this runs before the wrapper `exec`s anything.
2054  #[test]
2055  #[cfg(unix)]
2056  fn ensure_loader_scripts_executable_adds_the_bit_and_reports_what_changed() {
2057    use std::os::unix::fs::PermissionsExt;
2058
2059    let dir = tempfile::tempdir().unwrap();
2060    let root = dir.path();
2061
2062    let game = root.join("start_game_bepinex.sh");
2063    let server = root.join("start_server_bepinex.sh");
2064
2065    std::fs::write(&game, b"#!/bin/sh\n").unwrap();
2066    std::fs::write(&server, b"#!/bin/sh\n").unwrap();
2067
2068    // Exactly what the package ships: readable, not executable.
2069    std::fs::set_permissions(&game, std::fs::Permissions::from_mode(0o644)).unwrap();
2070    std::fs::set_permissions(&server, std::fs::Permissions::from_mode(0o644)).unwrap();
2071
2072    let changed = ensure_loader_scripts_executable(root).unwrap();
2073
2074    assert_eq!(changed.len(), 2, "got: {changed:?}");
2075
2076    for name in ["start_game_bepinex.sh", "start_server_bepinex.sh"] {
2077      let mode = std::fs::metadata(root.join(name))
2078        .unwrap()
2079        .permissions()
2080        .mode();
2081
2082      assert_eq!(mode & 0o777, 0o755, "{name} got mode {mode:o}");
2083    }
2084
2085    // Idempotent: a second launch must not report a change, so repeated launches
2086    // do not keep rewriting modes.
2087    assert!(ensure_loader_scripts_executable(root).unwrap().is_empty());
2088  }
2089
2090  /// Read access is mirrored rather than forced to `0o755`, so a target whose
2091  /// scripts are deliberately owner-only does not become world-readable just to
2092  /// be launchable.
2093  #[test]
2094  #[cfg(unix)]
2095  fn ensure_loader_scripts_executable_mirrors_read_access() {
2096    use std::os::unix::fs::PermissionsExt;
2097
2098    let dir = tempfile::tempdir().unwrap();
2099    let root = dir.path();
2100    let script = root.join("run_bepinex.sh");
2101
2102    std::fs::write(&script, b"#!/bin/sh\n").unwrap();
2103    std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o640)).unwrap();
2104
2105    ensure_loader_scripts_executable(root).unwrap();
2106
2107    let mode = std::fs::metadata(&script).unwrap().permissions().mode();
2108
2109    assert_eq!(mode & 0o777, 0o750, "got mode {mode:o}");
2110  }
2111
2112  /// A target with no loader scripts at all, such as a Proton profile or one
2113  /// whose loader injects through a proxy DLL, is not an error.
2114  #[test]
2115  fn ensure_loader_scripts_executable_ignores_a_target_with_no_scripts() {
2116    let dir = tempfile::tempdir().unwrap();
2117
2118    assert!(
2119      ensure_loader_scripts_executable(dir.path())
2120        .unwrap()
2121        .is_empty()
2122    );
2123  }
2124
2125  #[test]
2126  fn wrapper_target_args_name_the_flag_and_the_directory() {
2127    assert_eq!(
2128      wrapper_target_args(Path::new("/data/profiles/Main")),
2129      vec![
2130        WRAPPER_TARGET_FLAG.to_string(),
2131        "/data/profiles/Main".to_string()
2132      ]
2133    );
2134  }
2135
2136  // --- Executing the generated wrapper.
2137  //
2138  // The wrapper is a shell script, and the bugs that matter in one — a lost
2139  // quote, a flag leaking through to the game, an argument reordered by the
2140  // filtering loop — are invisible to assertions about its text. These tests run
2141  // `/bin/sh` against it for real and inspect the argv the chainloaded script
2142  // actually received. ---
2143
2144  /// Marks `path` executable, as [`write_wrapper_script`] does for the wrapper
2145  /// itself and as the loader scripts must be to be `exec`-ed.
2146  #[cfg(unix)]
2147  fn make_executable(path: &Path) {
2148    use std::os::unix::fs::PermissionsExt;
2149
2150    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
2151  }
2152
2153  /// Writes a stand-in loader script at `path` that records the argv it was
2154  /// invoked with into `log`, one argument per line, prefixed by its own `$0`.
2155  ///
2156  /// One line per argument is what makes the assertions meaningful: a lost quote
2157  /// shows up as an argument split across two lines rather than as a string that
2158  /// merely looks similar.
2159  #[cfg(unix)]
2160  fn recording_loader(path: &Path, log: &Path) {
2161    let contents = format!(
2162      "#!/bin/sh\nprintf '%s\\n' \"$0\" > \"{log}\"\nfor argument in \"$@\"; do\n  printf '%s\\n' \"$argument\" >> \"{log}\"\ndone\n",
2163      log = log.display()
2164    );
2165
2166    std::fs::write(path, contents).unwrap();
2167    make_executable(path);
2168  }
2169
2170  /// The lines `recording_loader` wrote: the chainloaded script's own path, then
2171  /// each argument it received.
2172  #[cfg(unix)]
2173  fn recorded(log: &Path) -> Vec<String> {
2174    std::fs::read_to_string(log)
2175      .unwrap()
2176      .lines()
2177      .map(str::to_string)
2178      .collect()
2179  }
2180
2181  /// Runs `wrapper` with `args`, asserting it exited successfully.
2182  #[cfg(unix)]
2183  fn run_wrapper(wrapper: &Path, args: &[&str], server: bool) {
2184    let mut command = std::process::Command::new(wrapper);
2185
2186    command.args(args);
2187
2188    if server {
2189      command.env("TS_START_SERVER", "1");
2190    } else {
2191      command.env_remove("TS_START_SERVER");
2192    }
2193
2194    let output = command.output().unwrap();
2195
2196    assert!(
2197      output.status.success(),
2198      "wrapper failed: {}",
2199      String::from_utf8_lossy(&output.stderr)
2200    );
2201  }
2202
2203  #[cfg(unix)]
2204  #[test]
2205  fn wrapper_chainloads_the_target_given_at_run_time_and_consumes_only_the_flag() {
2206    let dir = tempfile::tempdir().unwrap();
2207    let fallback = dir.path().join("fallback");
2208    // A space in the target directory is the case that catches quoting bugs, and
2209    // it is a realistic profile name.
2210    let selected = dir.path().join("My Profile 2");
2211    let log = dir.path().join("argv.log");
2212    let wrapper = dir.path().join("steam-wrapper.sh");
2213
2214    std::fs::create_dir_all(&fallback).unwrap();
2215    std::fs::create_dir_all(&selected).unwrap();
2216    recording_loader(&selected.join("run_bepinex.sh"), &log);
2217    write_wrapper_script(&wrapper).unwrap();
2218
2219    let target = selected.to_string_lossy().to_string();
2220
2221    run_wrapper(
2222      &wrapper,
2223      &[
2224        "/games/valheim/valheim.x86_64",
2225        "--doorstop-enabled",
2226        "true",
2227        WRAPPER_TARGET_FLAG,
2228        &target,
2229        "-name",
2230        "a server with spaces",
2231      ],
2232      false,
2233    );
2234
2235    let lines = recorded(&log);
2236
2237    // The script inside the run-time target ran — not the fallback's.
2238    assert_eq!(lines[0], selected.join("run_bepinex.sh").to_string_lossy());
2239    // Every non-flag argument, in its original order, with spaces intact; the
2240    // flag and its value are gone.
2241    assert_eq!(
2242      &lines[1..],
2243      [
2244        "/games/valheim/valheim.x86_64",
2245        "--doorstop-enabled",
2246        "true",
2247        "-name",
2248        "a server with spaces",
2249      ]
2250    );
2251  }
2252
2253  #[cfg(unix)]
2254  #[test]
2255  fn wrapper_without_the_flag_launches_the_game_unmodded() {
2256    let dir = tempfile::tempdir().unwrap();
2257    let log = dir.path().join("argv.log");
2258    let wrapper = dir.path().join("steam-wrapper.sh");
2259    let game_exe = dir.path().join("valheim.x86_64");
2260    // A profile sitting right beside the wrapper, complete with a loader script.
2261    // Nothing may reach for it: no flag means no mods, not "whichever profile
2262    // happens to be there".
2263    let unrequested = dir.path().join("profile");
2264    let unrequested_log = dir.path().join("must-not-run.log");
2265
2266    std::fs::create_dir_all(&unrequested).unwrap();
2267    recording_loader(&unrequested.join("run_bepinex.sh"), &unrequested_log);
2268    recording_loader(&game_exe, &log);
2269    write_wrapper_script(&wrapper).unwrap();
2270
2271    // A launch straight from Steam, with no manager appending anything: exactly
2272    // what `%command%` expands to on its own.
2273    run_wrapper(&wrapper, &[&game_exe.to_string_lossy()], false);
2274
2275    assert!(
2276      !unrequested_log.exists(),
2277      "a bare launch must not load any profile's mods"
2278    );
2279
2280    let lines = recorded(&log);
2281
2282    assert_eq!(lines[0], game_exe.to_string_lossy());
2283    assert_eq!(lines.len(), 1, "the game got extra arguments: {lines:?}");
2284  }
2285
2286  #[cfg(unix)]
2287  #[test]
2288  fn wrapper_launches_unmodded_when_the_flags_value_is_not_a_directory() {
2289    let dir = tempfile::tempdir().unwrap();
2290    let log = dir.path().join("argv.log");
2291    let wrapper = dir.path().join("steam-wrapper.sh");
2292    let game_exe = dir.path().join("valheim.x86_64");
2293
2294    recording_loader(&game_exe, &log);
2295    write_wrapper_script(&wrapper).unwrap();
2296
2297    // A profile deleted between the plan and the launch must not defeat the
2298    // launch, and must not be chainloaded as if it existed.
2299    let missing = dir.path().join("deleted-profile");
2300
2301    run_wrapper(
2302      &wrapper,
2303      &[
2304        &game_exe.to_string_lossy(),
2305        WRAPPER_TARGET_FLAG,
2306        &missing.to_string_lossy(),
2307      ],
2308      false,
2309    );
2310
2311    let lines = recorded(&log);
2312
2313    // The flag is consumed even when its value is rejected: the game must never
2314    // see it.
2315    assert_eq!(lines[0], game_exe.to_string_lossy());
2316    assert_eq!(lines.len(), 1, "the game got extra arguments: {lines:?}");
2317  }
2318
2319  #[cfg(unix)]
2320  #[test]
2321  fn wrapper_consumes_a_trailing_flag_that_has_no_value() {
2322    let dir = tempfile::tempdir().unwrap();
2323    let log = dir.path().join("argv.log");
2324    let wrapper = dir.path().join("steam-wrapper.sh");
2325    let game_exe = dir.path().join("valheim.x86_64");
2326
2327    recording_loader(&game_exe, &log);
2328    write_wrapper_script(&wrapper).unwrap();
2329
2330    run_wrapper(
2331      &wrapper,
2332      &[&game_exe.to_string_lossy(), WRAPPER_TARGET_FLAG],
2333      false,
2334    );
2335
2336    let lines = recorded(&log);
2337
2338    // A valueless flag names no target, so this is a vanilla launch — and the
2339    // dangling flag is still swallowed rather than handed to the game.
2340    assert_eq!(lines[0], game_exe.to_string_lossy());
2341    assert_eq!(lines.len(), 1, "the game got extra arguments: {lines:?}");
2342  }
2343
2344  #[cfg(unix)]
2345  #[test]
2346  fn wrapper_execs_the_game_directly_when_the_target_ships_no_loader_script() {
2347    let dir = tempfile::tempdir().unwrap();
2348    let selected = dir.path().join("profile");
2349    let log = dir.path().join("argv.log");
2350    let wrapper = dir.path().join("steam-wrapper.sh");
2351    // Stand in for the game binary Steam puts at the head of `%command%`.
2352    let game_exe = dir.path().join("valheim.x86_64");
2353
2354    std::fs::create_dir_all(&selected).unwrap();
2355    recording_loader(&game_exe, &log);
2356    write_wrapper_script(&wrapper).unwrap();
2357
2358    run_wrapper(
2359      &wrapper,
2360      &[
2361        &game_exe.to_string_lossy(),
2362        WRAPPER_TARGET_FLAG,
2363        &selected.to_string_lossy(),
2364        "-window-mode",
2365      ],
2366      false,
2367    );
2368
2369    let lines = recorded(&log);
2370
2371    // `exec "$@"` reached the game itself, and the flag still did not survive
2372    // into its argv.
2373    assert_eq!(lines[0], game_exe.to_string_lossy());
2374    assert_eq!(&lines[1..], ["-window-mode"]);
2375  }
2376
2377  #[cfg(unix)]
2378  #[test]
2379  fn wrapper_selects_the_server_script_only_when_ts_start_server_is_set() {
2380    let dir = tempfile::tempdir().unwrap();
2381    let target = dir.path().join("target");
2382    let server_log = dir.path().join("server.log");
2383    let game_log = dir.path().join("game.log");
2384    let wrapper = dir.path().join("steam-wrapper.sh");
2385
2386    std::fs::create_dir_all(&target).unwrap();
2387    // Both scripts present, which is the only way the gate is observable: the
2388    // server script has the higher precedence in `LOADER_SCRIPTS`, so without
2389    // the gate it would always win.
2390    recording_loader(&target.join(SERVER_SCRIPT), &server_log);
2391    recording_loader(&target.join("run_bepinex.sh"), &game_log);
2392    write_wrapper_script(&wrapper).unwrap();
2393
2394    let selected = target.to_string_lossy().to_string();
2395    let argv = [
2396      "/games/valheim/valheim.x86_64",
2397      WRAPPER_TARGET_FLAG,
2398      &selected,
2399    ];
2400
2401    run_wrapper(&wrapper, &argv, false);
2402
2403    assert!(
2404      !server_log.exists(),
2405      "the server script must not run for a game instance"
2406    );
2407    assert_eq!(
2408      recorded(&game_log)[0],
2409      target.join("run_bepinex.sh").to_string_lossy()
2410    );
2411
2412    run_wrapper(&wrapper, &argv, true);
2413
2414    assert_eq!(
2415      recorded(&server_log)[0],
2416      target.join(SERVER_SCRIPT).to_string_lossy()
2417    );
2418  }
2419
2420  #[test]
2421  fn steam_launch_options_are_only_the_wrapper_and_command_placeholder() {
2422    let options = steam_launch_options(Path::new("/data/launch_wrapper.sh"));
2423
2424    // Byte-for-byte what r2modman's `WrapperArguments.ts` emits: a quoted path
2425    // and `%command%`, and nothing else.
2426    assert_eq!(options, r#""/data/launch_wrapper.sh" %command%"#);
2427
2428    // No Wine setting rides along. This string is only ever produced for a Linux
2429    // native launch, where nothing in the path touches Wine, and it could not buy
2430    // Proton coverage anyway — see `steam_launch_options`'s doc comment.
2431    assert!(!options.contains("WINEDLLOVERRIDES"));
2432    assert!(!options.contains("winhttp"));
2433
2434    // A space in the script path stays inside the quotes.
2435    assert_eq!(
2436      steam_launch_options(Path::new("/data/My Games/wrapper.sh")),
2437      r#""/data/My Games/wrapper.sh" %command%"#
2438    );
2439  }
2440
2441  #[test]
2442  fn stage_destination_routes_per_loader() {
2443    let game = Path::new("/game");
2444
2445    assert_eq!(
2446      stage_destination(LoaderKind::BepInEx, "winhttp.dll", game, None),
2447      Some(PathBuf::from("/game/winhttp.dll"))
2448    );
2449    // mods.yml is manager state, never staged.
2450    assert_eq!(
2451      stage_destination(LoaderKind::BepInEx, "mods.yml", game, None),
2452      None
2453    );
2454
2455    // Shimloader: only its three files, into the UE binaries folder.
2456    assert_eq!(
2457      stage_destination(LoaderKind::Shimloader, "ue4ss.dll", game, Some("Palworld")),
2458      Some(PathBuf::from("/game/Palworld/Binaries/Win64/ue4ss.dll"))
2459    );
2460    assert_eq!(
2461      stage_destination(LoaderKind::Shimloader, "other.dll", game, Some("Palworld")),
2462      None
2463    );
2464    // No `dataFolderName` at all => nothing is staged.
2465    assert_eq!(
2466      stage_destination(LoaderKind::Shimloader, "ue4ss.dll", game, None),
2467      None
2468    );
2469
2470    // Rivet: only version.dll, into Release.
2471    assert_eq!(
2472      stage_destination(LoaderKind::Rivet, "version.dll", game, None),
2473      Some(PathBuf::from("/game/Release/version.dll"))
2474    );
2475    assert_eq!(
2476      stage_destination(LoaderKind::Rivet, "winhttp.dll", game, None),
2477      None
2478    );
2479  }
2480
2481  #[test]
2482  fn stage_for_launch_copies_root_files_only() {
2483    let dir = tempfile::tempdir().unwrap();
2484    let target = dir.path().join("target");
2485    let game_dir = dir.path().join("game");
2486
2487    std::fs::create_dir_all(target.join("BepInEx/core")).unwrap();
2488    std::fs::create_dir_all(&game_dir).unwrap();
2489    std::fs::write(target.join("winhttp.dll"), b"proxy").unwrap();
2490    std::fs::write(target.join("doorstop_config.ini"), b"[UnityDoorstop]").unwrap();
2491    std::fs::write(target.join("mods.yml"), b"[]").unwrap();
2492    std::fs::write(target.join("BepInEx/core/BepInEx.Preloader.dll"), b"x").unwrap();
2493
2494    let eco = Ecosystem::bundled();
2495    let written = stage_for_launch_in(
2496      &target,
2497      &eco,
2498      "valheim",
2499      &game_dir,
2500      &ctx(LaunchMode::Modded),
2501    )
2502    .unwrap();
2503
2504    assert!(game_dir.join("winhttp.dll").exists());
2505    assert!(game_dir.join("doorstop_config.ini").exists());
2506    // Manager state and subdirectories are not staged.
2507    assert!(!game_dir.join("mods.yml").exists());
2508    assert!(!game_dir.join("BepInEx").exists());
2509    assert_eq!(written.len(), 2);
2510
2511    // The returned paths are exactly what was written, and sorted.
2512    assert!(written.contains(&game_dir.join("winhttp.dll")));
2513    assert!(written.contains(&game_dir.join("doorstop_config.ini")));
2514
2515    let mut sorted = written.clone();
2516    sorted.sort();
2517
2518    assert_eq!(written, sorted);
2519  }
2520
2521  #[test]
2522  fn stage_for_launch_stages_shimloader_files_into_nested_binaries_dir() {
2523    // Palworld is a bundled Shimloader game (`dataFolderName` = "Pal"), so this
2524    // exercises `stage_for_launch_in` end to end through a nested destination
2525    // that does not exist yet, forcing `stage_one_file`'s `create_dir_all`.
2526    let dir = tempfile::tempdir().unwrap();
2527    let target = dir.path().join("target");
2528    let game_dir = dir.path().join("game");
2529
2530    std::fs::create_dir_all(&target).unwrap();
2531    std::fs::create_dir_all(&game_dir).unwrap();
2532    std::fs::write(target.join("ue4ss.dll"), b"loader").unwrap();
2533    std::fs::write(target.join("dwmapi.dll"), b"proxy").unwrap();
2534    std::fs::write(target.join("ue4ss-settings.ini"), b"[UE4SS]").unwrap();
2535    std::fs::write(target.join("other.dll"), b"unrelated").unwrap();
2536
2537    let eco = Ecosystem::bundled();
2538    let written = stage_for_launch_in(
2539      &target,
2540      &eco,
2541      "palworld",
2542      &game_dir,
2543      &ctx(LaunchMode::Modded),
2544    )
2545    .unwrap();
2546
2547    let nested = game_dir.join("Pal").join("Binaries").join("Win64");
2548
2549    assert_eq!(std::fs::read(nested.join("ue4ss.dll")).unwrap(), b"loader");
2550    assert!(nested.join("dwmapi.dll").exists());
2551    assert!(nested.join("ue4ss-settings.ini").exists());
2552    // Every other root file is skipped for Shimloader.
2553    assert!(!game_dir.join("other.dll").exists());
2554    assert!(!nested.join("other.dll").exists());
2555
2556    assert_eq!(written.len(), 3);
2557    assert!(written.contains(&nested.join("ue4ss.dll")));
2558    assert!(written.contains(&nested.join("dwmapi.dll")));
2559    assert!(written.contains(&nested.join("ue4ss-settings.ini")));
2560
2561    let mut sorted = written.clone();
2562    sorted.sort();
2563
2564    assert_eq!(written, sorted);
2565  }
2566
2567  #[test]
2568  fn stage_for_launch_stages_rivet_version_dll_into_release_dir() {
2569    // Scrap Mechanic is a bundled Rivet game, so this exercises
2570    // `stage_for_launch_in` end to end through the `<game>/Release` destination,
2571    // forcing `stage_one_file`'s `create_dir_all` for that nested directory too.
2572    let dir = tempfile::tempdir().unwrap();
2573    let target = dir.path().join("target");
2574    let game_dir = dir.path().join("game");
2575
2576    std::fs::create_dir_all(&target).unwrap();
2577    std::fs::create_dir_all(&game_dir).unwrap();
2578    std::fs::write(target.join("version.dll"), b"rivet-loader").unwrap();
2579    std::fs::write(target.join("other.dll"), b"unrelated").unwrap();
2580
2581    let eco = Ecosystem::bundled();
2582    let written = stage_for_launch_in(
2583      &target,
2584      &eco,
2585      "scrap-mechanic",
2586      &game_dir,
2587      &ctx(LaunchMode::Modded),
2588    )
2589    .unwrap();
2590
2591    let dest = game_dir.join("Release").join("version.dll");
2592
2593    assert_eq!(std::fs::read(&dest).unwrap(), b"rivet-loader");
2594    // Every other root file is skipped for Rivet.
2595    assert!(!game_dir.join("other.dll").exists());
2596    assert!(!game_dir.join("Release").join("other.dll").exists());
2597
2598    assert_eq!(written, vec![dest]);
2599  }
2600
2601  #[test]
2602  fn stage_for_launch_skips_linux_native_bepinex() {
2603    let dir = tempfile::tempdir().unwrap();
2604    let target = dir.path().join("target");
2605    let game_dir = dir.path().join("game");
2606
2607    std::fs::create_dir_all(&target).unwrap();
2608    std::fs::create_dir_all(&game_dir).unwrap();
2609    std::fs::write(target.join("winhttp.dll"), b"proxy").unwrap();
2610
2611    let eco = Ecosystem::bundled();
2612    let mut context = ctx(LaunchMode::Modded);
2613    context.os = HostOs::Linux;
2614    context.runtime = Runtime::Native;
2615
2616    let written = stage_for_launch_in(&target, &eco, "valheim", &game_dir, &context).unwrap();
2617
2618    assert!(written.is_empty());
2619    assert!(!game_dir.join("winhttp.dll").exists());
2620  }
2621
2622  #[cfg(unix)]
2623  #[test]
2624  fn stage_for_launch_refuses_to_write_through_a_symlink() {
2625    use std::os::unix::fs::symlink;
2626
2627    let dir = tempfile::tempdir().unwrap();
2628    let target = dir.path().join("target");
2629    let game_dir = dir.path().join("game");
2630    let outside = dir.path().join("secret.dll");
2631
2632    std::fs::create_dir_all(&target).unwrap();
2633    std::fs::create_dir_all(&game_dir).unwrap();
2634    std::fs::write(&outside, b"original").unwrap();
2635    std::fs::write(target.join("winhttp.dll"), b"proxy").unwrap();
2636    symlink(&outside, game_dir.join("winhttp.dll")).unwrap();
2637
2638    let eco = Ecosystem::bundled();
2639
2640    assert!(
2641      stage_for_launch_in(
2642        &target,
2643        &eco,
2644        "valheim",
2645        &game_dir,
2646        &ctx(LaunchMode::Modded)
2647      )
2648      .is_err()
2649    );
2650    assert_eq!(std::fs::read(&outside).unwrap(), b"original");
2651  }
2652
2653  #[test]
2654  fn stage_for_launch_in_place_leaves_root_files_byte_for_byte_untouched() {
2655    // `target_dir == game_dir` is the layout a consumer with no r2modman
2656    // profile uses when it installs mods straight into the game directory
2657    // (see `stage_for_launch_in`'s doc comment). Before the fix, `stage_one_file`
2658    // called `std::fs::copy(path, path)` here, which truncates the destination
2659    // to zero bytes: `std::fs::copy` opens the destination with `O_TRUNC`
2660    // before it ever reads the source.
2661    let dir = tempfile::tempdir().unwrap();
2662    let root = dir.path().join("game");
2663
2664    std::fs::create_dir_all(root.join("BepInEx/core")).unwrap();
2665
2666    let winhttp_contents = b"MZ-fake-proxy-dll-18b";
2667    let ini_contents = b"[UnityDoorstop]\nenabled=true\n";
2668
2669    std::fs::write(root.join("winhttp.dll"), winhttp_contents).unwrap();
2670    std::fs::write(root.join("doorstop_config.ini"), ini_contents).unwrap();
2671    std::fs::write(root.join("mods.yml"), b"[]").unwrap();
2672    std::fs::write(root.join("BepInEx/core/BepInEx.Preloader.dll"), b"x").unwrap();
2673
2674    let eco = Ecosystem::bundled();
2675    let written =
2676      stage_for_launch_in(&root, &eco, "valheim", &root, &ctx(LaunchMode::Modded)).unwrap();
2677
2678    // Contents, not mere existence — a zero-byte file still "exists", which is
2679    // precisely how the truncation bug hid behind every prior test.
2680    assert_eq!(
2681      std::fs::read(root.join("winhttp.dll")).unwrap(),
2682      winhttp_contents
2683    );
2684    assert_eq!(
2685      std::fs::read(root.join("doorstop_config.ini")).unwrap(),
2686      ini_contents
2687    );
2688
2689    // Neither file was actually written by this call — both were already
2690    // correctly in place — so neither is reported as written. See
2691    // `stage_for_launch_in`'s doc comment: over-reporting here would let a
2692    // caller that deletes everything in this list destroy a file it never
2693    // created.
2694    assert!(written.is_empty());
2695  }
2696
2697  #[test]
2698  fn stage_for_launch_in_place_survives_a_dot_dot_aliased_game_dir() {
2699    // `target/BepInEx/..` is not lexically equal to `target`, but resolves to
2700    // the exact same directory. The identity check must recognize this via
2701    // canonicalization rather than string comparison, or this differently
2702    // spelled `game_dir` would reintroduce the truncation bug.
2703    let dir = tempfile::tempdir().unwrap();
2704    let target = dir.path().join("target");
2705
2706    std::fs::create_dir_all(target.join("BepInEx/core")).unwrap();
2707
2708    let contents = b"proxy-bytes-not-truncated";
2709
2710    std::fs::write(target.join("winhttp.dll"), contents).unwrap();
2711
2712    let aliased_game_dir = target.join("BepInEx").join("..");
2713    let eco = Ecosystem::bundled();
2714
2715    let written = stage_for_launch_in(
2716      &target,
2717      &eco,
2718      "valheim",
2719      &aliased_game_dir,
2720      &ctx(LaunchMode::Modded),
2721    )
2722    .unwrap();
2723
2724    assert_eq!(std::fs::read(target.join("winhttp.dll")).unwrap(), contents);
2725    assert!(written.is_empty());
2726  }
2727
2728  #[cfg(unix)]
2729  #[test]
2730  fn stage_for_launch_in_place_survives_a_symlinked_game_dir() {
2731    use std::os::unix::fs::symlink;
2732
2733    // A `game_dir` reached through a symlinked directory must alias `target_dir`
2734    // exactly as the `..`-relative case above does, even though it is not
2735    // string-comparable to `target_dir` at all.
2736    let dir = tempfile::tempdir().unwrap();
2737    let target = dir.path().join("target");
2738    let link = dir.path().join("game-link");
2739
2740    std::fs::create_dir_all(target.join("BepInEx/core")).unwrap();
2741
2742    let contents = b"proxy-bytes-via-symlink";
2743
2744    std::fs::write(target.join("winhttp.dll"), contents).unwrap();
2745    symlink(&target, &link).unwrap();
2746
2747    let eco = Ecosystem::bundled();
2748    let written =
2749      stage_for_launch_in(&target, &eco, "valheim", &link, &ctx(LaunchMode::Modded)).unwrap();
2750
2751    assert_eq!(std::fs::read(target.join("winhttp.dll")).unwrap(), contents);
2752    assert!(written.is_empty());
2753  }
2754
2755  #[test]
2756  fn stage_for_launch_in_place_still_copies_shimloader_files_to_a_distinct_destination() {
2757    // Palworld is a bundled Shimloader game (`dataFolderName` = "Pal"). Even
2758    // with `target_dir == game_dir`, Shimloader's destination
2759    // (`<game_dir>/Pal/Binaries/Win64/<file>`) genuinely differs from its
2760    // source, so the identity check must not skip these — only a destination
2761    // identical to its source is exempted from copying.
2762    let dir = tempfile::tempdir().unwrap();
2763    let root = dir.path().join("game");
2764
2765    std::fs::create_dir_all(&root).unwrap();
2766    std::fs::write(root.join("ue4ss.dll"), b"loader").unwrap();
2767    std::fs::write(root.join("dwmapi.dll"), b"proxy").unwrap();
2768    std::fs::write(root.join("ue4ss-settings.ini"), b"[UE4SS]").unwrap();
2769
2770    let eco = Ecosystem::bundled();
2771    let written =
2772      stage_for_launch_in(&root, &eco, "palworld", &root, &ctx(LaunchMode::Modded)).unwrap();
2773
2774    let nested = root.join("Pal").join("Binaries").join("Win64");
2775
2776    assert_eq!(std::fs::read(nested.join("ue4ss.dll")).unwrap(), b"loader");
2777    assert_eq!(std::fs::read(nested.join("dwmapi.dll")).unwrap(), b"proxy");
2778    // The source files are untouched by their own copy, and still present at
2779    // the target root (which is also `game_dir` here).
2780    assert_eq!(std::fs::read(root.join("ue4ss.dll")).unwrap(), b"loader");
2781
2782    assert_eq!(written.len(), 3);
2783    assert!(written.contains(&nested.join("ue4ss.dll")));
2784    assert!(written.contains(&nested.join("dwmapi.dll")));
2785    assert!(written.contains(&nested.join("ue4ss-settings.ini")));
2786  }
2787
2788  #[test]
2789  fn stage_for_launch_in_place_still_copies_rivet_version_dll_to_a_distinct_destination() {
2790    // Scrap Mechanic is a bundled Rivet game. Its destination
2791    // (`<game_dir>/Release/version.dll`) differs from its source even when
2792    // `target_dir == game_dir`, so it must still be copied rather than skipped.
2793    let dir = tempfile::tempdir().unwrap();
2794    let root = dir.path().join("game");
2795
2796    std::fs::create_dir_all(&root).unwrap();
2797    std::fs::write(root.join("version.dll"), b"rivet-loader").unwrap();
2798
2799    let eco = Ecosystem::bundled();
2800    let written = stage_for_launch_in(
2801      &root,
2802      &eco,
2803      "scrap-mechanic",
2804      &root,
2805      &ctx(LaunchMode::Modded),
2806    )
2807    .unwrap();
2808
2809    let dest = root.join("Release").join("version.dll");
2810
2811    assert_eq!(std::fs::read(&dest).unwrap(), b"rivet-loader");
2812    assert_eq!(
2813      std::fs::read(root.join("version.dll")).unwrap(),
2814      b"rivet-loader"
2815    );
2816    assert_eq!(written, vec![dest]);
2817  }
2818
2819  #[test]
2820  fn stage_for_launch_wraps_stage_for_launch_in_over_a_layout_profile() {
2821    let dir = tempfile::tempdir().unwrap();
2822    let base = dir.path().join("base");
2823    let game_dir = dir.path().join("game");
2824
2825    let profile_dir = crate::profile::layout::create(&base, "valheim", "Main").unwrap();
2826    let core = profile_dir.join("BepInEx/core");
2827
2828    std::fs::create_dir_all(&core).unwrap();
2829    std::fs::create_dir_all(&game_dir).unwrap();
2830    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
2831    std::fs::write(profile_dir.join("winhttp.dll"), b"proxy").unwrap();
2832    std::fs::write(profile_dir.join("mods.yml"), b"[]").unwrap();
2833
2834    let eco = Ecosystem::bundled();
2835    let written = stage_for_launch(
2836      &base,
2837      "valheim",
2838      "Main",
2839      &eco,
2840      &game_dir,
2841      &ctx(LaunchMode::Modded),
2842    )
2843    .unwrap();
2844
2845    assert!(game_dir.join("winhttp.dll").exists());
2846    assert!(!game_dir.join("mods.yml").exists());
2847    assert_eq!(written, vec![game_dir.join("winhttp.dll")]);
2848  }
2849
2850  /// A Linux native `LaunchContext` for a directly executed (non-Steam) game.
2851  fn linux_native(mode: LaunchMode) -> LaunchContext {
2852    let mut context = ctx(mode);
2853
2854    context.os = HostOs::Linux;
2855    context.store = StorePlatform::SteamDirect;
2856    context.runtime = Runtime::Native;
2857
2858    context
2859  }
2860
2861  /// A target that looks like a real Linux BepInEx install, minus any loader
2862  /// script: preloader, proxy DLL, and Doorstop ini.
2863  fn linux_bepinex_target(root: &Path) {
2864    let core = root.join("BepInEx/core");
2865
2866    std::fs::create_dir_all(&core).unwrap();
2867    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
2868    std::fs::write(root.join("winhttp.dll"), b"proxy").unwrap();
2869    std::fs::write(root.join("doorstop_config.ini"), b"[UnityDoorstop]").unwrap();
2870  }
2871
2872  #[test]
2873  fn linux_native_modded_selects_any_shipped_script_not_only_run_bepinex() {
2874    let dir = tempfile::tempdir().unwrap();
2875    let target = dir.path().join("target");
2876    let game_dir = dir.path().join("game");
2877
2878    linux_bepinex_target(&target);
2879    std::fs::create_dir_all(&game_dir).unwrap();
2880    // This package ships `start_game_bepinex.sh`; there is no `run_bepinex.sh`.
2881    std::fs::write(target.join("start_game_bepinex.sh"), b"#!/bin/sh\n").unwrap();
2882
2883    let eco = Ecosystem::bundled();
2884    let context = linux_native(LaunchMode::Modded);
2885
2886    let plan = launch_plan_in(&target, &eco, "valheim", &context).unwrap();
2887
2888    match &plan.program {
2889      LaunchProgram::ProfileScript { path, .. } => {
2890        assert!(path.ends_with("start_game_bepinex.sh"))
2891      }
2892      // Falling through to `GameExe` here is the silent-vanilla bug: Doorstop
2893      // argv nothing reads, and no proxy staged into the game directory.
2894      other => panic!("expected ProfileScript, got {other:?}"),
2895    }
2896
2897    // Staging must agree with the plan rather than decide independently.
2898    let written = stage_for_launch_in(&target, &eco, "valheim", &game_dir, &context).unwrap();
2899
2900    assert!(written.is_empty());
2901    assert!(!game_dir.join("winhttp.dll").exists());
2902  }
2903
2904  #[test]
2905  fn server_instance_prefers_the_server_script_and_a_game_instance_ignores_it() {
2906    let dir = tempfile::tempdir().unwrap();
2907    let all_three = dir.path().join("all-three");
2908    let server_only = dir.path().join("server-only");
2909
2910    linux_bepinex_target(&all_three);
2911    linux_bepinex_target(&server_only);
2912
2913    for name in LOADER_SCRIPTS {
2914      std::fs::write(all_three.join(name), b"#!/bin/sh\n").unwrap();
2915    }
2916
2917    std::fs::write(server_only.join(SERVER_SCRIPT), b"#!/bin/sh\n").unwrap();
2918
2919    let context = linux_native(LaunchMode::Modded);
2920    let game_profile = profile_with(LoaderKind::BepInEx);
2921    let mut server_profile = profile_with(LoaderKind::BepInEx);
2922
2923    server_profile.instance_type = InstanceType::Server;
2924
2925    let server = resolve_program(
2926      &script_injection(&all_three, &server_profile, &context),
2927      &all_three,
2928      &server_profile,
2929      &context,
2930    )
2931    .unwrap();
2932
2933    match &server {
2934      LaunchProgram::ProfileScript { path, .. } => {
2935        assert!(path.ends_with("start_server_bepinex.sh"))
2936      }
2937      other => panic!("expected ProfileScript, got {other:?}"),
2938    }
2939
2940    // A game instance skips the server script even when it is present and first
2941    // in precedence — exec'ing it would start a dedicated server.
2942    let game = resolve_program(
2943      &script_injection(&all_three, &game_profile, &context),
2944      &all_three,
2945      &game_profile,
2946      &context,
2947    )
2948    .unwrap();
2949
2950    match &game {
2951      LaunchProgram::ProfileScript { path, .. } => assert!(path.ends_with("run_bepinex.sh")),
2952      other => panic!("expected ProfileScript, got {other:?}"),
2953    }
2954
2955    // With only the server script on disk, a game instance has no script at all.
2956    let err = resolve_program(
2957      &script_injection(&server_only, &game_profile, &context),
2958      &server_only,
2959      &game_profile,
2960      &context,
2961    )
2962    .unwrap_err();
2963
2964    assert!(err.to_string().contains("run_bepinex.sh"));
2965  }
2966
2967  #[test]
2968  fn modded_script_injection_without_a_script_errors_but_vanilla_falls_through() {
2969    let dir = tempfile::tempdir().unwrap();
2970    let target = dir.path().join("target");
2971
2972    linux_bepinex_target(&target);
2973
2974    let eco = Ecosystem::bundled();
2975    let modded = linux_native(LaunchMode::Modded);
2976
2977    let err = launch_plan_in(&target, &eco, "valheim", &modded).unwrap_err();
2978    let message = err.to_string();
2979
2980    assert!(message.contains("run_bepinex.sh"), "{message}");
2981    assert!(message.contains("start_game_bepinex.sh"), "{message}");
2982    assert!(message.contains(&target.display().to_string()), "{message}");
2983
2984    // Vanilla asks for no injection at all, so a missing injector is not a
2985    // failure: the game binary runs with the loader's disable flag.
2986    let vanilla =
2987      launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Vanilla)).unwrap();
2988
2989    assert!(matches!(vanilla.program, LaunchProgram::GameExe { .. }));
2990    assert_eq!(
2991      vanilla.args,
2992      vec!["--doorstop-enable".to_string(), "false".to_string()]
2993    );
2994  }
2995
2996  #[test]
2997  fn linux_native_steam_launches_steam_and_reports_the_wrapper_requirement() {
2998    let dir = tempfile::tempdir().unwrap();
2999    let target = dir.path().join("target");
3000    let game_dir = dir.path().join("game");
3001    let script_path = dir.path().join("launch_wrapper.sh");
3002
3003    linux_bepinex_target(&target);
3004    std::fs::create_dir_all(&game_dir).unwrap();
3005    std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
3006
3007    let eco = Ecosystem::bundled();
3008    let mut context = linux_native(LaunchMode::Modded);
3009
3010    context.store = StorePlatform::Steam;
3011
3012    let plan = launch_plan_in(&target, &eco, "valheim", &context).unwrap();
3013
3014    // Steam cannot be handed a script as its program: it takes `-applaunch`.
3015    match &plan.program {
3016      LaunchProgram::Steam { steam_app_id } => assert_eq!(*steam_app_id, 892970),
3017      other => panic!("expected Steam, got {other:?}"),
3018    }
3019
3020    // The wrapper requirement is discoverable from the plan itself, and carries
3021    // everything the two wrapper functions need — no reimplementing the rule and
3022    // no second ecosystem lookup.
3023    let wrapper = plan.steam_wrapper.clone().expect("wrapper requirement");
3024
3025    assert_eq!(wrapper.target_dir, target);
3026    assert_eq!(wrapper.loader, LoaderKind::BepInEx);
3027
3028    write_wrapper_script(&script_path).unwrap();
3029
3030    let options = steam_launch_options(&script_path);
3031
3032    assert!(options.contains(&script_path.display().to_string()));
3033    assert!(!options.contains("WINEDLLOVERRIDES"));
3034    assert!(
3035      std::fs::read_to_string(&script_path)
3036        .unwrap()
3037        .contains("run_bepinex.sh")
3038    );
3039
3040    // The plan already carries the target flag, so a caller that spawns it
3041    // verbatim reaches this target through a wrapper written for any other one.
3042    assert!(
3043      plan
3044        .args
3045        .windows(2)
3046        .any(|pair| pair == wrapper_target_args(&target).as_slice()),
3047      "plan args must carry the wrapper target flag: {:?}",
3048      plan.args
3049    );
3050
3051    // Injection is still script-based, so the game directory stays untouched.
3052    let written = stage_for_launch_in(&target, &eco, "valheim", &game_dir, &context).unwrap();
3053
3054    assert!(written.is_empty());
3055    assert!(!game_dir.join("winhttp.dll").exists());
3056  }
3057
3058  #[test]
3059  fn stage_for_launch_rejects_macos() {
3060    let dir = tempfile::tempdir().unwrap();
3061    let target = dir.path().join("target");
3062    let game_dir = dir.path().join("game");
3063
3064    linux_bepinex_target(&target);
3065    std::fs::create_dir_all(&game_dir).unwrap();
3066
3067    let eco = Ecosystem::bundled();
3068    let mut context = ctx(LaunchMode::Modded);
3069
3070    context.os = HostOs::MacOs;
3071
3072    let err = stage_for_launch_in(&target, &eco, "valheim", &game_dir, &context).unwrap_err();
3073
3074    assert!(err.to_string().contains("macOS launch is not supported"));
3075    // Nothing was written: `launch_plan_in` refuses macOS, so staging must not
3076    // dirty the game directory on its behalf.
3077    assert_eq!(std::fs::read_dir(&game_dir).unwrap().count(), 0);
3078
3079    // Control: the same target and game directory stage fine once `os` is not
3080    // macOS, proving the error comes from the macOS branch itself.
3081    context.os = HostOs::Windows;
3082
3083    let written = stage_for_launch_in(&target, &eco, "valheim", &game_dir, &context).unwrap();
3084
3085    assert_eq!(written.len(), 2);
3086    assert!(game_dir.join("winhttp.dll").exists());
3087  }
3088
3089  #[test]
3090  fn only_a_wrapper_bound_plan_carries_the_target_flag() {
3091    let dir = tempfile::tempdir().unwrap();
3092    let target = dir.path().join("target");
3093
3094    linux_bepinex_target(&target);
3095    std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
3096
3097    let eco = Ecosystem::bundled();
3098
3099    // Linux native, but launched by executing the script directly: no wrapper
3100    // stands between the caller and the loader, so the flag would be a stray
3101    // argument the game has to tolerate.
3102    let direct =
3103      launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Modded)).unwrap();
3104
3105    assert_eq!(direct.steam_wrapper, None);
3106    assert!(!direct.args.contains(&WRAPPER_TARGET_FLAG.to_string()));
3107
3108    // Steam, but injecting through the proxy DLL rather than a script: still no
3109    // wrapper, still no flag.
3110    let mut proton = linux_native(LaunchMode::Modded);
3111
3112    proton.store = StorePlatform::Steam;
3113    proton.runtime = Runtime::Proton;
3114
3115    let proton_plan = launch_plan_in(&target, &eco, "valheim", &proton).unwrap();
3116
3117    assert_eq!(proton_plan.steam_wrapper, None);
3118    assert!(!proton_plan.args.contains(&WRAPPER_TARGET_FLAG.to_string()));
3119  }
3120
3121  #[test]
3122  fn wrapper_branches_are_generated_from_the_shared_script_list() {
3123    let script = wrapper_script_contents();
3124
3125    let mut previous = 0;
3126
3127    for name in LOADER_SCRIPTS {
3128      let position = script
3129        .find(&format!("[ -f \"$TARGET/{name}\" ]"))
3130        .unwrap_or_else(|| panic!("wrapper is missing a branch for {name}"));
3131
3132      assert!(position >= previous, "{name} is out of precedence order");
3133
3134      previous = position;
3135    }
3136  }
3137
3138  // --- Script-path Doorstop spelling (v3-detected target injected via a loader
3139  // script must still use v4 flags, because the script itself only understands
3140  // v4 spellings; see `doorstop_args`'s doc comment) ---
3141
3142  #[test]
3143  fn linux_native_script_present_vanilla_runs_the_script_with_v4_enabled_spelling() {
3144    let dir = tempfile::tempdir().unwrap();
3145    let target = dir.path().join("target");
3146
3147    linux_bepinex_target(&target);
3148    std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
3149    // No `.doorstop_version` file: `doorstop_version` defaults to 3, but the
3150    // script only recognizes the v4 spelling — see `doorstop_args`'s doc comment.
3151
3152    let eco = Ecosystem::bundled();
3153    let plan =
3154      launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Vanilla)).unwrap();
3155
3156    // Vanilla still runs the script: the script itself honours the disable
3157    // flag, so there is no reason to fall back to the bare game binary.
3158    match &plan.program {
3159      LaunchProgram::ProfileScript { path, .. } => assert!(path.ends_with("run_bepinex.sh")),
3160      other => panic!("expected ProfileScript, got {other:?}"),
3161    }
3162
3163    assert_eq!(
3164      plan.args,
3165      vec!["--doorstop-enabled".to_string(), "false".to_string()]
3166    );
3167  }
3168
3169  #[test]
3170  fn linux_native_script_present_modded_uses_v4_target_assembly_spelling() {
3171    let dir = tempfile::tempdir().unwrap();
3172    let target = dir.path().join("target");
3173
3174    linux_bepinex_target(&target);
3175    std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
3176    // No `.doorstop_version` file here either: same v3-defaults-but-script-wants-v4
3177    // situation as the vanilla case above.
3178
3179    let eco = Ecosystem::bundled();
3180    let plan = launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Modded)).unwrap();
3181
3182    assert!(matches!(plan.program, LaunchProgram::ProfileScript { .. }));
3183    assert_eq!(plan.args[0], "--doorstop-enabled");
3184    assert_eq!(plan.args[1], "true");
3185    assert_eq!(plan.args[2], "--doorstop-target-assembly");
3186    assert!(plan.args[3].ends_with("BepInEx/core/BepInEx.Preloader.dll"));
3187  }
3188
3189  #[test]
3190  fn linux_native_script_present_v3_detected_corlib_override_uses_v4_search_flag_spelling() {
3191    // The third of the three doorstop-flag pairs, and the one the fix's other
3192    // script-path tests don't touch: `linux_bepinex_target` never creates
3193    // `unstripped_corlib`, so without this test the corlib search-path flag on
3194    // the script-injected + v3-detected path is only correct by inference from
3195    // the shared `if v4` tuple in `doorstop_args`, not by a direct assertion.
3196    let dir = tempfile::tempdir().unwrap();
3197    let target = dir.path().join("target");
3198
3199    linux_bepinex_target(&target);
3200    std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
3201    std::fs::create_dir_all(target.join("unstripped_corlib")).unwrap();
3202    // No `.doorstop_version` file: detected version defaults to v3, but the
3203    // script on disk forces the v4 spellings — see `doorstop_args`'s doc comment.
3204
3205    let eco = Ecosystem::bundled();
3206    let plan = launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Modded)).unwrap();
3207
3208    assert!(matches!(plan.program, LaunchProgram::ProfileScript { .. }));
3209
3210    let idx = plan
3211      .args
3212      .iter()
3213      .position(|a| a == "--doorstop-mono-dll-search-path-override")
3214      .expect("v4 corlib search-path flag missing");
3215
3216    assert!(plan.args[idx + 1].ends_with("/unstripped_corlib"));
3217    assert!(
3218      !plan
3219        .args
3220        .iter()
3221        .any(|a| a == "--doorstop-dll-search-override"),
3222      "v3 corlib search-path spelling must not appear on the script-injected path: {:?}",
3223      plan.args
3224    );
3225  }
3226
3227  #[test]
3228  fn linux_native_script_present_v4_detected_target_is_unchanged() {
3229    let dir = tempfile::tempdir().unwrap();
3230    let target = dir.path().join("target");
3231
3232    linux_bepinex_target(&target);
3233    std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
3234    std::fs::write(target.join(".doorstop_version"), "4.0.0").unwrap();
3235
3236    let eco = Ecosystem::bundled();
3237
3238    let vanilla =
3239      launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Vanilla)).unwrap();
3240
3241    assert_eq!(
3242      vanilla.args,
3243      vec!["--doorstop-enabled".to_string(), "false".to_string()]
3244    );
3245
3246    let modded =
3247      launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Modded)).unwrap();
3248
3249    assert_eq!(modded.args[0], "--doorstop-enabled");
3250    assert_eq!(modded.args[2], "--doorstop-target-assembly");
3251  }
3252
3253  #[test]
3254  fn proton_v3_detected_target_still_uses_v3_spellings_even_with_a_script_on_disk() {
3255    // Regression guard: script-path spelling must key off actually running
3256    // through the script (Linux + native + a found script), not merely off
3257    // `HostOs::Linux` or the script file's mere presence on disk. A Proton
3258    // (Wine) launch never goes through the loader script — `script_injection`
3259    // requires `Runtime::Native` — so even with `run_bepinex.sh` sitting in the
3260    // target and no `.doorstop_version` file, the argv must still get the
3261    // version-matched (v3) spelling, because it is Doorstop's own parser, not
3262    // the script, that will read it under Proton.
3263    let dir = tempfile::tempdir().unwrap();
3264    let target = dir.path().join("target");
3265
3266    linux_bepinex_target(&target);
3267    std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
3268
3269    let eco = Ecosystem::bundled();
3270    let mut context = linux_native(LaunchMode::Modded);
3271
3272    context.runtime = Runtime::Proton;
3273
3274    let plan = launch_plan_in(&target, &eco, "valheim", &context).unwrap();
3275
3276    assert_eq!(plan.args[0], "--doorstop-enable");
3277    assert_eq!(plan.args[2], "--doorstop-target");
3278
3279    let mut vanilla_context = linux_native(LaunchMode::Vanilla);
3280
3281    vanilla_context.runtime = Runtime::Proton;
3282
3283    let vanilla = launch_plan_in(&target, &eco, "valheim", &vanilla_context).unwrap();
3284
3285    assert_eq!(
3286      vanilla.args,
3287      vec!["--doorstop-enable".to_string(), "false".to_string()]
3288    );
3289  }
3290
3291  #[test]
3292  fn windows_v3_detected_target_still_uses_v3_spellings_regression_guard() {
3293    // Regression guard for the non-script paths named in the fix: a
3294    // v3-detected target launched via argv (not a script) must keep the v3
3295    // spellings, on Windows exactly as before this fix.
3296    let dir = tempfile::tempdir().unwrap();
3297    let root = dir.path();
3298    let core = root.join("BepInEx/core");
3299
3300    std::fs::create_dir_all(&core).unwrap();
3301    std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
3302
3303    let eco = Ecosystem::bundled();
3304    let plan = launch_plan_in(root, &eco, "valheim", &ctx(LaunchMode::Modded)).unwrap();
3305
3306    assert_eq!(plan.args[0], "--doorstop-enable");
3307    assert_eq!(plan.args[2], "--doorstop-target");
3308
3309    let vanilla = launch_plan_in(root, &eco, "valheim", &ctx(LaunchMode::Vanilla)).unwrap();
3310
3311    assert_eq!(
3312      vanilla.args,
3313      vec!["--doorstop-enable".to_string(), "false".to_string()]
3314    );
3315  }
3316
3317  #[test]
3318  fn store_and_runtime_parse_from_their_config_spellings() {
3319    assert_eq!(
3320      "steam".parse::<StorePlatform>().unwrap(),
3321      StorePlatform::Steam
3322    );
3323    assert_eq!(
3324      "steam-direct".parse::<StorePlatform>().unwrap(),
3325      StorePlatform::SteamDirect
3326    );
3327    assert_eq!(
3328      "other".parse::<StorePlatform>().unwrap(),
3329      StorePlatform::Other
3330    );
3331    assert_eq!("native".parse::<Runtime>().unwrap(), Runtime::Native);
3332    assert_eq!("proton".parse::<Runtime>().unwrap(), Runtime::Proton);
3333
3334    // An unknown value names itself, so a caller can quote it back.
3335    let error = "wat".parse::<StorePlatform>().unwrap_err().to_string();
3336
3337    assert!(error.contains("wat"), "got: {error}");
3338  }
3339
3340  #[test]
3341  fn resolve_game_exe_returns_the_first_present_candidate() {
3342    let dir = tempfile::tempdir().unwrap();
3343    let game_dir = dir.path();
3344
3345    std::fs::write(game_dir.join("second.x86_64"), b"elf").unwrap();
3346
3347    let names = vec!["first.exe".to_string(), "second.x86_64".to_string()];
3348    let found = resolve_game_exe(game_dir, &names).unwrap();
3349
3350    assert_eq!(found, game_dir.join("second.x86_64"));
3351
3352    // Nothing present names every candidate it looked for.
3353    let error = resolve_game_exe(game_dir, &["absent.exe".to_string()])
3354      .unwrap_err()
3355      .to_string();
3356
3357    assert!(error.contains("absent.exe"), "got: {error}");
3358  }
3359
3360  #[test]
3361  fn a_plan_becomes_a_spawnable_command() {
3362    let dir = tempfile::tempdir().unwrap();
3363    let game_dir = dir.path();
3364
3365    std::fs::write(game_dir.join("valheim.x86_64"), b"elf").unwrap();
3366
3367    // `LaunchPlan` derives no `Default`, so every field is named. It is
3368    // `#[non_exhaustive]`, which only restricts other crates, so a struct literal
3369    // is fine inside this module.
3370    let plan = LaunchPlan {
3371      program: LaunchProgram::GameExe {
3372        exe_names: vec!["valheim.x86_64".to_string()],
3373      },
3374      args: vec!["-console".to_string()],
3375      env: vec![("KEY".to_string(), "VALUE".to_string())],
3376      working_dir: Some(game_dir.to_path_buf()),
3377      steam_wrapper: None,
3378    };
3379
3380    let command = plan.to_command(game_dir).unwrap();
3381
3382    assert_eq!(command.get_program(), game_dir.join("valheim.x86_64"));
3383    assert_eq!(
3384      command.get_args().collect::<Vec<_>>(),
3385      vec![std::ffi::OsStr::new("-console")]
3386    );
3387    assert_eq!(command.get_current_dir(), Some(game_dir));
3388    assert!(
3389      command
3390        .get_envs()
3391        .any(|(key, value)| key == std::ffi::OsStr::new("KEY")
3392          && value == Some(std::ffi::OsStr::new("VALUE")))
3393    );
3394  }
3395
3396  /// `-applaunch <id>` must lead, with the plan's own argv after it, whatever
3397  /// path the Steam executable resolved to. Asserted against an explicit program
3398  /// so the ordering is checked on any host, including one with no Steam at all.
3399  #[test]
3400  fn a_steam_command_leads_with_applaunch_and_keeps_the_plan_argv_after_it() {
3401    let program = Path::new(r"C:\Program Files (x86)\Steam\steam.exe");
3402    let mut command = steam_command(program, 892970);
3403
3404    command.args(["-console".to_string(), "--ts-target".to_string()]);
3405
3406    assert_eq!(command.get_program(), program.as_os_str());
3407    assert_eq!(
3408      command.get_args().collect::<Vec<_>>(),
3409      vec![
3410        std::ffi::OsStr::new("-applaunch"),
3411        std::ffi::OsStr::new("892970"),
3412        std::ffi::OsStr::new("-console"),
3413        std::ffi::OsStr::new("--ts-target"),
3414      ]
3415    );
3416  }
3417
3418  /// The Steam arm resolves its program through [`crate::steam::steam_exe`]
3419  /// rather than hardcoding `steam`, which never resolves on Windows.
3420  ///
3421  /// `LaunchPlan::env` is asserted *unchanged* here: it is documented as not
3422  /// reaching a game started through `-applaunch`, so this fix must keep setting
3423  /// exactly what the plan carried and add nothing that would silently do
3424  /// nothing.
3425  #[test]
3426  fn a_steam_plan_spawns_the_resolved_steam_executable() {
3427    let dir = tempfile::tempdir().unwrap();
3428    let game_dir = dir.path();
3429
3430    let plan = LaunchPlan {
3431      program: LaunchProgram::Steam {
3432        steam_app_id: 892970,
3433      },
3434      args: vec!["-console".to_string()],
3435      env: vec![("KEY".to_string(), "VALUE".to_string())],
3436      working_dir: None,
3437      steam_wrapper: None,
3438    };
3439
3440    let command = plan.to_command(game_dir).unwrap();
3441
3442    assert_eq!(
3443      command.get_program(),
3444      crate::steam::steam_exe().as_os_str(),
3445      "the Steam arm must use the resolved executable"
3446    );
3447    assert_eq!(
3448      command.get_args().collect::<Vec<_>>(),
3449      vec![
3450        std::ffi::OsStr::new("-applaunch"),
3451        std::ffi::OsStr::new("892970"),
3452        std::ffi::OsStr::new("-console"),
3453      ]
3454    );
3455
3456    let envs = command.get_envs().collect::<Vec<_>>();
3457
3458    assert_eq!(
3459      envs,
3460      vec![(
3461        std::ffi::OsStr::new("KEY"),
3462        Some(std::ffi::OsStr::new("VALUE"))
3463      )],
3464      "the plan's env must reach the command unchanged, with nothing added"
3465    );
3466  }
3467}