1#![deny(missing_docs)]
17
18use crate::ecosystem::{Ecosystem, GameProfile, InstanceType, LoaderKind};
19use crate::error::{Error, Result};
20use std::path::{Path, PathBuf};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum HostOs {
26 Windows,
28 Linux,
31 MacOs,
35}
36
37impl HostOs {
38 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum StorePlatform {
60 Steam,
62 SteamDirect,
64 Other,
66}
67
68impl std::str::FromStr for StorePlatform {
69 type Err = Error;
70
71 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87#[non_exhaustive]
88pub enum Runtime {
89 Native,
91 Proton,
94}
95
96impl std::str::FromStr for Runtime {
97 type Err = Error;
98
99 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113#[non_exhaustive]
114pub enum LaunchMode {
115 Modded,
117 Vanilla,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
129#[non_exhaustive]
130pub enum LaunchProgram {
131 GameExe {
134 exe_names: Vec<String>,
136 },
137 Steam {
139 steam_app_id: u32,
141 },
142 ProfileScript {
147 path: PathBuf,
149 exe_names: Vec<String>,
152 },
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
171#[non_exhaustive]
172pub struct SteamWrapper {
173 pub target_dir: PathBuf,
177 pub loader: LoaderKind,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
189#[non_exhaustive]
190pub struct LaunchPlan {
191 pub program: LaunchProgram,
193 pub args: Vec<String>,
195 pub env: Vec<(String, String)>,
208 pub working_dir: Option<PathBuf>,
210 pub steam_wrapper: Option<SteamWrapper>,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
219#[non_exhaustive]
220pub struct LaunchContext {
221 pub os: HostOs,
224 pub store: StorePlatform,
227 pub runtime: Runtime,
230 pub mode: LaunchMode,
232 pub extra_args: Vec<String>,
234}
235
236impl LaunchContext {
237 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 pub fn with_extra_args(self, extra_args: Vec<String>) -> Self {
257 Self { extra_args, ..self }
258 }
259}
260
261pub 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
271pub const LOADER_SCRIPTS: [&str; 3] = [
281 "start_server_bepinex.sh",
282 "run_bepinex.sh",
283 "start_game_bepinex.sh",
284];
285
286const SERVER_SCRIPT: &str = LOADER_SCRIPTS[0];
288
289const MACOS_UNSUPPORTED: &str =
292 "macOS launch is not supported: the injection chain requires r2modman's closed-source proxy";
293
294pub 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
320fn wine_path(path: &Path) -> String {
322 format!("Z:{}", path.to_string_lossy().replace('\\', "/"))
323}
324
325pub 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
346pub fn has_corlibs(target_dir: &Path) -> bool {
349 target_dir.join("unstripped_corlib").is_dir()
350}
351
352fn arg_path(target_dir: &Path, relative: &str) -> String {
355 target_dir
356 .join(relative)
357 .to_string_lossy()
358 .replace('\\', "/")
359}
360
361pub 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 _ => Vec::new(),
467 };
468
469 Ok(args)
470}
471
472#[derive(Debug, Clone, Copy, PartialEq, Eq)]
474enum DoorstopTarget {
475 BepInEx,
476 Umm,
477}
478
479fn 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
554fn 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
590fn 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
613pub fn proxy_dll(loader: LoaderKind) -> &'static str {
615 match loader {
616 LoaderKind::GdWeave => "winmm",
617 _ => "winhttp",
618 }
619}
620
621#[derive(Debug, Clone, PartialEq, Eq)]
627enum ScriptInjection {
628 NotApplicable,
631 Found(PathBuf),
633 Missing,
635}
636
637impl ScriptInjection {
638 fn applies(&self) -> bool {
643 !matches!(self, Self::NotApplicable)
644 }
645}
646
647fn 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
659fn 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
691fn 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
707pub 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 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 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
819fn 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
860pub 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
877pub const WRAPPER_TARGET_FLAG: &str = "--ts-target";
883
884pub 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
897pub 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 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 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
1000pub 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
1027pub 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
1068fn 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
1103const SHIMLOADER_STAGED: [&str; 3] = ["ue4ss.dll", "dwmapi.dll", "ue4ss-settings.ini"];
1105
1106pub 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
1147pub 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
1249pub 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
1268fn 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
1308pub 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
1335pub 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
1350pub fn wrapper_path(base: &Path, game: &str) -> PathBuf {
1357 base.join(game).join("steam-wrapper.sh")
1358}
1359
1360fn 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 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 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 std::fs::write(root.join(".doorstop_version"), "5.1.0").unwrap();
1451 assert_eq!(doorstop_version(root), 5);
1452
1453 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 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 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 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 assert_eq!(plan.args.last().unwrap(), "-nolog");
1879 assert!(plan.env.is_empty());
1880 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 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 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 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 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 assert!(script.contains(r#"TARGET="""#));
1996 assert!(script.contains(r#"[ -z "$TARGET" ]"#));
1997
1998 assert!(script.contains(&format!(r#"[ "$argument" = "{WRAPPER_TARGET_FLAG}" ]"#)));
2001 assert!(script.contains(r#"[ -d "$candidate" ]"#));
2002 assert!(!script.contains("eval"));
2003
2004 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 assert!(script.contains("$TS_START_SERVER"));
2018
2019 assert!(script.contains("else"));
2021 assert!(script.contains("fi"));
2022
2023 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 #[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 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 assert!(ensure_loader_scripts_executable(root).unwrap().is_empty());
2088 }
2089
2090 #[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 #[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 #[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 #[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 #[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 #[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 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 assert_eq!(lines[0], selected.join("run_bepinex.sh").to_string_lossy());
2239 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 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 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 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 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 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 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 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 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 assert_eq!(options, r#""/data/launch_wrapper.sh" %command%"#);
2427
2428 assert!(!options.contains("WINEDLLOVERRIDES"));
2432 assert!(!options.contains("winhttp"));
2433
2434 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 assert_eq!(
2451 stage_destination(LoaderKind::BepInEx, "mods.yml", game, None),
2452 None
2453 );
2454
2455 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 assert_eq!(
2466 stage_destination(LoaderKind::Shimloader, "ue4ss.dll", game, None),
2467 None
2468 );
2469
2470 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 assert!(!game_dir.join("mods.yml").exists());
2508 assert!(!game_dir.join("BepInEx").exists());
2509 assert_eq!(written.len(), 2);
2510
2511 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 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 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 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 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 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 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 assert!(written.is_empty());
2695 }
2696
2697 #[test]
2698 fn stage_for_launch_in_place_survives_a_dot_dot_aliased_game_dir() {
2699 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 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 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 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 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 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 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 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 other => panic!("expected ProfileScript, got {other:?}"),
2895 }
2896
2897 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 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 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 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 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 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 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 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 assert_eq!(std::fs::read_dir(&game_dir).unwrap().count(), 0);
3078
3079 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 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 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 #[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 let eco = Ecosystem::bundled();
3153 let plan =
3154 launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Vanilla)).unwrap();
3155
3156 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 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 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 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 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 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 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 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 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 #[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 #[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}