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
1360impl LaunchPlan {
1361 pub fn to_command(&self, game_dir: &Path) -> Result<std::process::Command> {
1374 let mut command = match &self.program {
1375 LaunchProgram::GameExe { exe_names } => {
1376 std::process::Command::new(resolve_game_exe(game_dir, exe_names)?)
1377 }
1378 LaunchProgram::Steam { steam_app_id } => {
1379 let mut command = std::process::Command::new("steam");
1380
1381 command.arg("-applaunch").arg(steam_app_id.to_string());
1382
1383 command
1384 }
1385 LaunchProgram::ProfileScript { path, exe_names } => {
1386 let mut command = std::process::Command::new(path);
1387
1388 command.arg(resolve_game_exe(game_dir, exe_names)?);
1389
1390 command
1391 }
1392 };
1393
1394 command.args(&self.args);
1395
1396 for (key, value) in &self.env {
1397 command.env(key, value);
1398 }
1399
1400 if let Some(dir) = &self.working_dir {
1401 command.current_dir(dir);
1402 }
1403
1404 Ok(command)
1405 }
1406}
1407
1408#[cfg(test)]
1409mod tests {
1410 use super::*;
1411
1412 #[test]
1413 fn doorstop_version_reads_major_and_defaults_to_three() {
1414 let dir = tempfile::tempdir().unwrap();
1415 let root = dir.path();
1416
1417 assert_eq!(doorstop_version(root), 3);
1419
1420 std::fs::write(root.join(".doorstop_version"), "4.0.0").unwrap();
1421 assert_eq!(doorstop_version(root), 4);
1422
1423 std::fs::write(root.join(".doorstop_version"), "3.7.1").unwrap();
1424 assert_eq!(doorstop_version(root), 3);
1425
1426 std::fs::write(root.join(".doorstop_version"), "5.1.0").unwrap();
1428 assert_eq!(doorstop_version(root), 5);
1429
1430 std::fs::write(root.join(".doorstop_version"), "garbage").unwrap();
1432 assert_eq!(doorstop_version(root), 3);
1433 }
1434
1435 #[test]
1436 fn bepinex_preloader_finds_candidate_and_prefixes_under_proton() {
1437 let dir = tempfile::tempdir().unwrap();
1438 let root = dir.path();
1439 let core = root.join("BepInEx/core");
1440
1441 std::fs::create_dir_all(&core).unwrap();
1442 std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1443 std::fs::write(core.join("Unrelated.dll"), b"x").unwrap();
1444
1445 let native = bepinex_preloader(root, Runtime::Native).unwrap();
1446
1447 assert!(native.ends_with("BepInEx/core/BepInEx.Preloader.dll"));
1448 assert!(!native.starts_with("Z:"));
1449
1450 let proton = bepinex_preloader(root, Runtime::Proton).unwrap();
1451
1452 assert!(proton.starts_with("Z:"));
1453 assert!(proton.ends_with("BepInEx/core/BepInEx.Preloader.dll"));
1454 }
1455
1456 #[test]
1457 fn bepinex_preloader_errors_when_absent() {
1458 let dir = tempfile::tempdir().unwrap();
1459
1460 assert!(bepinex_preloader(dir.path(), Runtime::Native).is_err());
1461 }
1462
1463 #[test]
1464 fn has_corlibs_detects_the_directory() {
1465 let dir = tempfile::tempdir().unwrap();
1466 let root = dir.path();
1467
1468 assert!(!has_corlibs(root));
1469
1470 std::fs::create_dir_all(root.join("unstripped_corlib")).unwrap();
1471
1472 assert!(has_corlibs(root));
1473 }
1474
1475 fn ctx(mode: LaunchMode) -> LaunchContext {
1476 LaunchContext {
1477 os: HostOs::Windows,
1478 store: StorePlatform::Steam,
1479 runtime: Runtime::Native,
1480 mode,
1481 extra_args: Vec::new(),
1482 }
1483 }
1484
1485 fn profile_with(loader: LoaderKind) -> GameProfile {
1486 let eco = Ecosystem::bundled();
1487 let mut profile = eco.game("valheim").unwrap().profile().unwrap().clone();
1488 profile.package_loader = loader;
1489
1490 profile
1491 }
1492
1493 #[test]
1494 fn bepinex_v3_and_v4_use_the_right_flag_spellings() {
1495 let dir = tempfile::tempdir().unwrap();
1496 let root = dir.path();
1497 let core = root.join("BepInEx/core");
1498
1499 std::fs::create_dir_all(&core).unwrap();
1500 std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1501
1502 let profile = profile_with(LoaderKind::BepInEx);
1503
1504 let v3 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1505
1506 assert_eq!(v3[0], "--doorstop-enable");
1507 assert_eq!(v3[1], "true");
1508 assert_eq!(v3[2], "--doorstop-target");
1509
1510 std::fs::write(root.join(".doorstop_version"), "4.0.0").unwrap();
1511
1512 let v4 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1513
1514 assert_eq!(v4[0], "--doorstop-enabled");
1515 assert_eq!(v4[2], "--doorstop-target-assembly");
1516
1517 let vanilla = loader_args(root, &profile, &ctx(LaunchMode::Vanilla)).unwrap();
1520
1521 assert_eq!(
1522 vanilla,
1523 vec!["--doorstop-enabled".to_string(), "false".to_string()]
1524 );
1525 }
1526
1527 #[test]
1528 fn melonloader_adds_regenerate_only_when_assemblies_are_absent() {
1529 let dir = tempfile::tempdir().unwrap();
1530 let root = dir.path();
1531 let profile = profile_with(LoaderKind::MelonLoader);
1532
1533 let bare = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1534
1535 assert_eq!(bare[0], "--melonloader.basedir");
1536 assert!(bare.contains(&"--melonloader.agfregenerate".to_string()));
1537
1538 let managed = root.join("MelonLoader/Managed");
1539 std::fs::create_dir_all(&managed).unwrap();
1540 std::fs::write(managed.join("Assembly-CSharp.dll"), b"x").unwrap();
1541
1542 let with_assembly = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1543
1544 assert!(!with_assembly.contains(&"--melonloader.agfregenerate".to_string()));
1545 assert_eq!(
1546 loader_args(root, &profile, &ctx(LaunchMode::Vanilla)).unwrap(),
1547 vec!["--no-mods".to_string()]
1548 );
1549 }
1550
1551 #[test]
1552 fn single_element_flags_stay_single_elements() {
1553 let dir = tempfile::tempdir().unwrap();
1554 let root = dir.path();
1555
1556 let gdweave = loader_args(
1557 root,
1558 &profile_with(LoaderKind::GdWeave),
1559 &ctx(LaunchMode::Modded),
1560 )
1561 .unwrap();
1562
1563 assert_eq!(gdweave.len(), 1);
1564 assert!(gdweave[0].starts_with("--gdweave-folder-override="));
1565 assert!(gdweave[0].ends_with("/GDWeave"));
1566
1567 let northstar = loader_args(
1568 root,
1569 &profile_with(LoaderKind::Northstar),
1570 &ctx(LaunchMode::Modded),
1571 )
1572 .unwrap();
1573
1574 assert_eq!(northstar[0], "-northstar");
1575 assert!(northstar[1].starts_with("-profile="));
1576 }
1577
1578 #[test]
1579 fn shimloader_and_rivet_and_rom_shapes() {
1580 let dir = tempfile::tempdir().unwrap();
1581 let root = dir.path();
1582
1583 let shim = loader_args(
1584 root,
1585 &profile_with(LoaderKind::Shimloader),
1586 &ctx(LaunchMode::Modded),
1587 )
1588 .unwrap();
1589
1590 assert_eq!(shim.len(), 8);
1591 assert_eq!(shim[0], "--mod-dir");
1592 assert!(shim.contains(&"--overlay-dir".to_string()));
1593
1594 let rivet = loader_args(
1595 root,
1596 &profile_with(LoaderKind::Rivet),
1597 &ctx(LaunchMode::Modded),
1598 )
1599 .unwrap();
1600
1601 assert_eq!(rivet[0], "-rivetEnable");
1602 assert_eq!(rivet[1], "true");
1603 assert!(rivet.contains(&"-rivetDirectory".to_string()));
1604 assert_eq!(
1605 loader_args(
1606 root,
1607 &profile_with(LoaderKind::Rivet),
1608 &ctx(LaunchMode::Vanilla)
1609 )
1610 .unwrap(),
1611 vec!["-rivetEnable".to_string(), "false".to_string()]
1612 );
1613
1614 let rom = loader_args(
1615 root,
1616 &profile_with(LoaderKind::ReturnOfModding),
1617 &ctx(LaunchMode::Modded),
1618 )
1619 .unwrap();
1620
1621 assert_eq!(rom[0], "--rom_modding_root_folder");
1622 }
1623
1624 #[test]
1625 fn unknown_and_none_loaders_produce_no_args() {
1626 let dir = tempfile::tempdir().unwrap();
1627 let root = dir.path();
1628
1629 assert!(
1630 loader_args(
1631 root,
1632 &profile_with(LoaderKind::None),
1633 &ctx(LaunchMode::Modded)
1634 )
1635 .unwrap()
1636 .is_empty()
1637 );
1638 assert!(
1639 loader_args(
1640 root,
1641 &profile_with(LoaderKind::Other),
1642 &ctx(LaunchMode::Modded)
1643 )
1644 .unwrap()
1645 .is_empty()
1646 );
1647 }
1648
1649 #[test]
1650 fn server_instance_appends_server_flag_for_bepinex() {
1651 let dir = tempfile::tempdir().unwrap();
1652 let root = dir.path();
1653 let core = root.join("BepInEx/core");
1654
1655 std::fs::create_dir_all(&core).unwrap();
1656 std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1657
1658 let mut profile = profile_with(LoaderKind::BepInEx);
1659 profile.instance_type = InstanceType::Server;
1660
1661 let args = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1662
1663 assert!(args.contains(&"--server".to_string()));
1664 }
1665
1666 #[test]
1667 fn umm_uses_doorstop_flags_and_never_gets_server_flag() {
1668 let dir = tempfile::tempdir().unwrap();
1669 let root = dir.path();
1670 let profile = profile_with(LoaderKind::Umm);
1671
1672 let v3 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1673
1674 assert_eq!(v3[0], "--doorstop-enable");
1675 assert_eq!(v3[1], "true");
1676 assert_eq!(v3[2], "--doorstop-target");
1677 assert!(v3[3].ends_with("UMM/Core/UnityModManager.dll"));
1678
1679 std::fs::write(root.join(".doorstop_version"), "4.0.0").unwrap();
1680
1681 let v4 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1682
1683 assert_eq!(v4[0], "--doorstop-enabled");
1684 assert_eq!(v4[2], "--doorstop-target-assembly");
1685 assert!(v4[3].ends_with("UMM/Core/UnityModManager.dll"));
1686
1687 let vanilla = loader_args(root, &profile, &ctx(LaunchMode::Vanilla)).unwrap();
1688
1689 assert_eq!(
1690 vanilla,
1691 vec!["--doorstop-enabled".to_string(), "false".to_string()]
1692 );
1693
1694 let mut server_profile = profile_with(LoaderKind::Umm);
1697 server_profile.instance_type = InstanceType::Server;
1698
1699 let server_args = loader_args(root, &server_profile, &ctx(LaunchMode::Modded)).unwrap();
1700
1701 assert!(!server_args.contains(&"--server".to_string()));
1702 }
1703
1704 #[test]
1705 fn bepis_loader_modded_and_vanilla_shapes() {
1706 let dir = tempfile::tempdir().unwrap();
1707 let root = dir.path();
1708 let core = root.join("BepInEx/core");
1709
1710 std::fs::create_dir_all(&core).unwrap();
1711 std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1712
1713 let profile = profile_with(LoaderKind::BepisLoader);
1714
1715 let modded = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1716
1717 assert_eq!(modded[0], "--hookfxr-enable");
1718 assert_eq!(modded[1], "--bepinex-target");
1719 assert!(modded.contains(&"--doorstop-enabled".to_string()));
1720 assert!(modded.contains(&"--doorstop-target-assembly".to_string()));
1721
1722 let vanilla = loader_args(root, &profile, &ctx(LaunchMode::Vanilla)).unwrap();
1723
1724 assert_eq!(
1725 vanilla,
1726 vec![
1727 "--hookfxr-disable".to_string(),
1728 "--doorstop-enabled".to_string(),
1729 "false".to_string()
1730 ]
1731 );
1732 }
1733
1734 #[test]
1735 fn lovely_modded_and_vanilla_shapes() {
1736 let dir = tempfile::tempdir().unwrap();
1737 let root = dir.path();
1738
1739 let modded = loader_args(
1740 root,
1741 &profile_with(LoaderKind::Lovely),
1742 &ctx(LaunchMode::Modded),
1743 )
1744 .unwrap();
1745
1746 assert_eq!(modded[0], "--mod-dir");
1747 assert!(modded[1].ends_with("/mods"));
1748
1749 let vanilla = loader_args(
1750 root,
1751 &profile_with(LoaderKind::Lovely),
1752 &ctx(LaunchMode::Vanilla),
1753 )
1754 .unwrap();
1755
1756 assert_eq!(vanilla, vec!["--vanilla".to_string()]);
1757 }
1758
1759 #[test]
1760 fn godot_ml_modded_and_vanilla_shapes() {
1761 let dir = tempfile::tempdir().unwrap();
1762 let root = dir.path();
1763
1764 let modded = loader_args(
1765 root,
1766 &profile_with(LoaderKind::GodotMl),
1767 &ctx(LaunchMode::Modded),
1768 )
1769 .unwrap();
1770
1771 assert_eq!(modded[0], "--script");
1772 assert_eq!(modded[1], "res://addons/mod_loader/mod_loader_setup.gd");
1773 assert!(modded.contains(&"--mods-path".to_string()));
1774
1775 let vanilla = loader_args(
1776 root,
1777 &profile_with(LoaderKind::GodotMl),
1778 &ctx(LaunchMode::Vanilla),
1779 )
1780 .unwrap();
1781
1782 assert!(vanilla.is_empty());
1783 }
1784
1785 #[test]
1786 fn corlib_override_appends_search_flag_pair_matching_version() {
1787 let dir = tempfile::tempdir().unwrap();
1788 let root = dir.path();
1789 let core = root.join("BepInEx/core");
1790
1791 std::fs::create_dir_all(&core).unwrap();
1792 std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1793 std::fs::create_dir_all(root.join("unstripped_corlib")).unwrap();
1794
1795 let profile = profile_with(LoaderKind::BepInEx);
1796
1797 let v3 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1798 let v3_idx = v3
1799 .iter()
1800 .position(|a| a == "--doorstop-dll-search-override")
1801 .unwrap();
1802
1803 assert!(v3[v3_idx + 1].ends_with("/unstripped_corlib"));
1804
1805 std::fs::write(root.join(".doorstop_version"), "4.0.0").unwrap();
1806
1807 let v4 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
1808 let v4_idx = v4
1809 .iter()
1810 .position(|a| a == "--doorstop-mono-dll-search-path-override")
1811 .unwrap();
1812
1813 assert!(v4[v4_idx + 1].ends_with("/unstripped_corlib"));
1814
1815 std::fs::remove_file(root.join(".doorstop_version")).unwrap();
1818
1819 let bepis = loader_args(
1820 root,
1821 &profile_with(LoaderKind::BepisLoader),
1822 &ctx(LaunchMode::Modded),
1823 )
1824 .unwrap();
1825 let bepis_idx = bepis
1826 .iter()
1827 .position(|a| a == "--doorstop-mono-dll-search-path-override")
1828 .unwrap();
1829
1830 assert!(bepis[bepis_idx + 1].ends_with("/unstripped_corlib"));
1831 }
1832
1833 #[test]
1834 fn windows_steam_plan_targets_steam_with_loader_args() {
1835 let dir = tempfile::tempdir().unwrap();
1836 let root = dir.path();
1837 let core = root.join("BepInEx/core");
1838
1839 std::fs::create_dir_all(&core).unwrap();
1840 std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1841
1842 let eco = Ecosystem::bundled();
1843 let mut context = ctx(LaunchMode::Modded);
1844 context.extra_args = vec!["-nolog".to_string()];
1845
1846 let plan = launch_plan_in(root, &eco, "valheim", &context).unwrap();
1847
1848 match &plan.program {
1849 LaunchProgram::Steam { steam_app_id } => assert_eq!(*steam_app_id, 892970),
1850 other => panic!("expected Steam, got {other:?}"),
1851 }
1852
1853 assert_eq!(plan.args[0], "--doorstop-enable");
1854 assert_eq!(plan.args.last().unwrap(), "-nolog");
1856 assert!(plan.env.is_empty());
1857 assert_eq!(plan.steam_wrapper, None);
1859 }
1860
1861 #[test]
1862 fn proton_sets_wine_dll_overrides_and_prefixes_the_preloader() {
1863 let dir = tempfile::tempdir().unwrap();
1864 let root = dir.path();
1865 let core = root.join("BepInEx/core");
1866
1867 std::fs::create_dir_all(&core).unwrap();
1868 std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1869
1870 let eco = Ecosystem::bundled();
1871 let mut context = ctx(LaunchMode::Modded);
1872 context.os = HostOs::Linux;
1873 context.runtime = Runtime::Proton;
1874
1875 let plan = launch_plan_in(root, &eco, "valheim", &context).unwrap();
1876
1877 assert_eq!(
1878 plan.env,
1879 vec![("WINEDLLOVERRIDES".to_string(), "winhttp=n,b".to_string())]
1880 );
1881 assert!(plan.args.iter().any(|a| a.starts_with("Z:")));
1882 assert_eq!(plan.steam_wrapper, None);
1884 }
1885
1886 #[test]
1887 fn linux_native_bepinex_runs_the_profile_script() {
1888 let dir = tempfile::tempdir().unwrap();
1889 let root = dir.path();
1890 let core = root.join("BepInEx/core");
1891
1892 std::fs::create_dir_all(&core).unwrap();
1893 std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1894 std::fs::write(root.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
1895
1896 let eco = Ecosystem::bundled();
1897 let mut context = ctx(LaunchMode::Modded);
1898 context.os = HostOs::Linux;
1899 context.store = StorePlatform::SteamDirect;
1900 context.runtime = Runtime::Native;
1901
1902 let plan = launch_plan_in(root, &eco, "valheim", &context).unwrap();
1903
1904 match &plan.program {
1905 LaunchProgram::ProfileScript { path, exe_names } => {
1908 assert!(path.ends_with("run_bepinex.sh"));
1909 assert_eq!(
1910 *exe_names,
1911 eco.game("valheim").unwrap().profile().unwrap().exe_names
1912 );
1913 assert!(!exe_names.is_empty());
1914 }
1915 other => panic!("expected ProfileScript, got {other:?}"),
1916 }
1917
1918 assert_eq!(plan.steam_wrapper, None);
1920 }
1921
1922 #[test]
1923 fn macos_is_rejected() {
1924 let dir = tempfile::tempdir().unwrap();
1925 let root = dir.path();
1926 let core = root.join("BepInEx/core");
1927
1928 std::fs::create_dir_all(&core).unwrap();
1929 std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
1930
1931 let eco = Ecosystem::bundled();
1932 let mut context = ctx(LaunchMode::Modded);
1933 context.os = HostOs::MacOs;
1934
1935 let err = launch_plan_in(root, &eco, "valheim", &context).unwrap_err();
1936
1937 assert!(err.to_string().contains("macOS launch is not supported"));
1938
1939 context.os = HostOs::Windows;
1943
1944 assert!(launch_plan_in(root, &eco, "valheim", &context).is_ok());
1945 }
1946
1947 #[test]
1948 fn unknown_game_errors() {
1949 let dir = tempfile::tempdir().unwrap();
1950 let eco = Ecosystem::bundled();
1951
1952 let err = launch_plan_in(dir.path(), &eco, "not-a-game", &ctx(LaunchMode::Modded)).unwrap_err();
1953
1954 assert!(err.to_string().contains("no ecosystem profile"));
1955 }
1956
1957 #[test]
1958 fn proxy_dll_is_winmm_for_gdweave() {
1959 assert_eq!(proxy_dll(LoaderKind::GdWeave), "winmm");
1960 assert_eq!(proxy_dll(LoaderKind::BepInEx), "winhttp");
1961 }
1962
1963 #[test]
1964 fn wrapper_script_names_no_target_and_execs_the_loader() {
1965 let script = wrapper_script_contents();
1966
1967 assert!(script.starts_with("#!/bin/sh"));
1968
1969 assert!(script.contains(r#"TARGET="""#));
1973 assert!(script.contains(r#"[ -z "$TARGET" ]"#));
1974
1975 assert!(script.contains(&format!(r#"[ "$argument" = "{WRAPPER_TARGET_FLAG}" ]"#)));
1978 assert!(script.contains(r#"[ -d "$candidate" ]"#));
1979 assert!(!script.contains("eval"));
1980
1981 let pos_server = script
1983 .find("start_server_bepinex.sh")
1984 .expect("server branch missing");
1985 let pos_run = script.find("run_bepinex.sh").expect("run branch missing");
1986 let pos_game = script
1987 .find("start_game_bepinex.sh")
1988 .expect("game branch missing");
1989
1990 assert!(pos_server < pos_run, "server branch must come before run");
1991 assert!(pos_run < pos_game, "run branch must come before game");
1992
1993 assert!(script.contains("$TS_START_SERVER"));
1995
1996 assert!(script.contains("else"));
1998 assert!(script.contains("fi"));
1999
2000 assert!(script.contains("\"$@\""));
2002 }
2003
2004 #[test]
2005 fn write_wrapper_script_creates_an_executable_file() {
2006 let dir = tempfile::tempdir().unwrap();
2007 let root = dir.path();
2008 let script_path = root.join("launch_wrapper.sh");
2009
2010 write_wrapper_script(&script_path).unwrap();
2011
2012 assert!(script_path.is_file());
2013
2014 #[cfg(unix)]
2015 {
2016 use std::os::unix::fs::PermissionsExt;
2017
2018 let mode = std::fs::metadata(&script_path)
2019 .unwrap()
2020 .permissions()
2021 .mode();
2022
2023 assert_eq!(mode & 0o111, 0o111);
2024 }
2025 }
2026
2027 #[test]
2032 #[cfg(unix)]
2033 fn ensure_loader_scripts_executable_adds_the_bit_and_reports_what_changed() {
2034 use std::os::unix::fs::PermissionsExt;
2035
2036 let dir = tempfile::tempdir().unwrap();
2037 let root = dir.path();
2038
2039 let game = root.join("start_game_bepinex.sh");
2040 let server = root.join("start_server_bepinex.sh");
2041
2042 std::fs::write(&game, b"#!/bin/sh\n").unwrap();
2043 std::fs::write(&server, b"#!/bin/sh\n").unwrap();
2044
2045 std::fs::set_permissions(&game, std::fs::Permissions::from_mode(0o644)).unwrap();
2047 std::fs::set_permissions(&server, std::fs::Permissions::from_mode(0o644)).unwrap();
2048
2049 let changed = ensure_loader_scripts_executable(root).unwrap();
2050
2051 assert_eq!(changed.len(), 2, "got: {changed:?}");
2052
2053 for name in ["start_game_bepinex.sh", "start_server_bepinex.sh"] {
2054 let mode = std::fs::metadata(root.join(name))
2055 .unwrap()
2056 .permissions()
2057 .mode();
2058
2059 assert_eq!(mode & 0o777, 0o755, "{name} got mode {mode:o}");
2060 }
2061
2062 assert!(ensure_loader_scripts_executable(root).unwrap().is_empty());
2065 }
2066
2067 #[test]
2071 #[cfg(unix)]
2072 fn ensure_loader_scripts_executable_mirrors_read_access() {
2073 use std::os::unix::fs::PermissionsExt;
2074
2075 let dir = tempfile::tempdir().unwrap();
2076 let root = dir.path();
2077 let script = root.join("run_bepinex.sh");
2078
2079 std::fs::write(&script, b"#!/bin/sh\n").unwrap();
2080 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o640)).unwrap();
2081
2082 ensure_loader_scripts_executable(root).unwrap();
2083
2084 let mode = std::fs::metadata(&script).unwrap().permissions().mode();
2085
2086 assert_eq!(mode & 0o777, 0o750, "got mode {mode:o}");
2087 }
2088
2089 #[test]
2092 fn ensure_loader_scripts_executable_ignores_a_target_with_no_scripts() {
2093 let dir = tempfile::tempdir().unwrap();
2094
2095 assert!(
2096 ensure_loader_scripts_executable(dir.path())
2097 .unwrap()
2098 .is_empty()
2099 );
2100 }
2101
2102 #[test]
2103 fn wrapper_target_args_name_the_flag_and_the_directory() {
2104 assert_eq!(
2105 wrapper_target_args(Path::new("/data/profiles/Main")),
2106 vec![
2107 WRAPPER_TARGET_FLAG.to_string(),
2108 "/data/profiles/Main".to_string()
2109 ]
2110 );
2111 }
2112
2113 #[cfg(unix)]
2124 fn make_executable(path: &Path) {
2125 use std::os::unix::fs::PermissionsExt;
2126
2127 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
2128 }
2129
2130 #[cfg(unix)]
2137 fn recording_loader(path: &Path, log: &Path) {
2138 let contents = format!(
2139 "#!/bin/sh\nprintf '%s\\n' \"$0\" > \"{log}\"\nfor argument in \"$@\"; do\n printf '%s\\n' \"$argument\" >> \"{log}\"\ndone\n",
2140 log = log.display()
2141 );
2142
2143 std::fs::write(path, contents).unwrap();
2144 make_executable(path);
2145 }
2146
2147 #[cfg(unix)]
2150 fn recorded(log: &Path) -> Vec<String> {
2151 std::fs::read_to_string(log)
2152 .unwrap()
2153 .lines()
2154 .map(str::to_string)
2155 .collect()
2156 }
2157
2158 #[cfg(unix)]
2160 fn run_wrapper(wrapper: &Path, args: &[&str], server: bool) {
2161 let mut command = std::process::Command::new(wrapper);
2162
2163 command.args(args);
2164
2165 if server {
2166 command.env("TS_START_SERVER", "1");
2167 } else {
2168 command.env_remove("TS_START_SERVER");
2169 }
2170
2171 let output = command.output().unwrap();
2172
2173 assert!(
2174 output.status.success(),
2175 "wrapper failed: {}",
2176 String::from_utf8_lossy(&output.stderr)
2177 );
2178 }
2179
2180 #[cfg(unix)]
2181 #[test]
2182 fn wrapper_chainloads_the_target_given_at_run_time_and_consumes_only_the_flag() {
2183 let dir = tempfile::tempdir().unwrap();
2184 let fallback = dir.path().join("fallback");
2185 let selected = dir.path().join("My Profile 2");
2188 let log = dir.path().join("argv.log");
2189 let wrapper = dir.path().join("steam-wrapper.sh");
2190
2191 std::fs::create_dir_all(&fallback).unwrap();
2192 std::fs::create_dir_all(&selected).unwrap();
2193 recording_loader(&selected.join("run_bepinex.sh"), &log);
2194 write_wrapper_script(&wrapper).unwrap();
2195
2196 let target = selected.to_string_lossy().to_string();
2197
2198 run_wrapper(
2199 &wrapper,
2200 &[
2201 "/games/valheim/valheim.x86_64",
2202 "--doorstop-enabled",
2203 "true",
2204 WRAPPER_TARGET_FLAG,
2205 &target,
2206 "-name",
2207 "a server with spaces",
2208 ],
2209 false,
2210 );
2211
2212 let lines = recorded(&log);
2213
2214 assert_eq!(lines[0], selected.join("run_bepinex.sh").to_string_lossy());
2216 assert_eq!(
2219 &lines[1..],
2220 [
2221 "/games/valheim/valheim.x86_64",
2222 "--doorstop-enabled",
2223 "true",
2224 "-name",
2225 "a server with spaces",
2226 ]
2227 );
2228 }
2229
2230 #[cfg(unix)]
2231 #[test]
2232 fn wrapper_without_the_flag_launches_the_game_unmodded() {
2233 let dir = tempfile::tempdir().unwrap();
2234 let log = dir.path().join("argv.log");
2235 let wrapper = dir.path().join("steam-wrapper.sh");
2236 let game_exe = dir.path().join("valheim.x86_64");
2237 let unrequested = dir.path().join("profile");
2241 let unrequested_log = dir.path().join("must-not-run.log");
2242
2243 std::fs::create_dir_all(&unrequested).unwrap();
2244 recording_loader(&unrequested.join("run_bepinex.sh"), &unrequested_log);
2245 recording_loader(&game_exe, &log);
2246 write_wrapper_script(&wrapper).unwrap();
2247
2248 run_wrapper(&wrapper, &[&game_exe.to_string_lossy()], false);
2251
2252 assert!(
2253 !unrequested_log.exists(),
2254 "a bare launch must not load any profile's mods"
2255 );
2256
2257 let lines = recorded(&log);
2258
2259 assert_eq!(lines[0], game_exe.to_string_lossy());
2260 assert_eq!(lines.len(), 1, "the game got extra arguments: {lines:?}");
2261 }
2262
2263 #[cfg(unix)]
2264 #[test]
2265 fn wrapper_launches_unmodded_when_the_flags_value_is_not_a_directory() {
2266 let dir = tempfile::tempdir().unwrap();
2267 let log = dir.path().join("argv.log");
2268 let wrapper = dir.path().join("steam-wrapper.sh");
2269 let game_exe = dir.path().join("valheim.x86_64");
2270
2271 recording_loader(&game_exe, &log);
2272 write_wrapper_script(&wrapper).unwrap();
2273
2274 let missing = dir.path().join("deleted-profile");
2277
2278 run_wrapper(
2279 &wrapper,
2280 &[
2281 &game_exe.to_string_lossy(),
2282 WRAPPER_TARGET_FLAG,
2283 &missing.to_string_lossy(),
2284 ],
2285 false,
2286 );
2287
2288 let lines = recorded(&log);
2289
2290 assert_eq!(lines[0], game_exe.to_string_lossy());
2293 assert_eq!(lines.len(), 1, "the game got extra arguments: {lines:?}");
2294 }
2295
2296 #[cfg(unix)]
2297 #[test]
2298 fn wrapper_consumes_a_trailing_flag_that_has_no_value() {
2299 let dir = tempfile::tempdir().unwrap();
2300 let log = dir.path().join("argv.log");
2301 let wrapper = dir.path().join("steam-wrapper.sh");
2302 let game_exe = dir.path().join("valheim.x86_64");
2303
2304 recording_loader(&game_exe, &log);
2305 write_wrapper_script(&wrapper).unwrap();
2306
2307 run_wrapper(
2308 &wrapper,
2309 &[&game_exe.to_string_lossy(), WRAPPER_TARGET_FLAG],
2310 false,
2311 );
2312
2313 let lines = recorded(&log);
2314
2315 assert_eq!(lines[0], game_exe.to_string_lossy());
2318 assert_eq!(lines.len(), 1, "the game got extra arguments: {lines:?}");
2319 }
2320
2321 #[cfg(unix)]
2322 #[test]
2323 fn wrapper_execs_the_game_directly_when_the_target_ships_no_loader_script() {
2324 let dir = tempfile::tempdir().unwrap();
2325 let selected = dir.path().join("profile");
2326 let log = dir.path().join("argv.log");
2327 let wrapper = dir.path().join("steam-wrapper.sh");
2328 let game_exe = dir.path().join("valheim.x86_64");
2330
2331 std::fs::create_dir_all(&selected).unwrap();
2332 recording_loader(&game_exe, &log);
2333 write_wrapper_script(&wrapper).unwrap();
2334
2335 run_wrapper(
2336 &wrapper,
2337 &[
2338 &game_exe.to_string_lossy(),
2339 WRAPPER_TARGET_FLAG,
2340 &selected.to_string_lossy(),
2341 "-window-mode",
2342 ],
2343 false,
2344 );
2345
2346 let lines = recorded(&log);
2347
2348 assert_eq!(lines[0], game_exe.to_string_lossy());
2351 assert_eq!(&lines[1..], ["-window-mode"]);
2352 }
2353
2354 #[cfg(unix)]
2355 #[test]
2356 fn wrapper_selects_the_server_script_only_when_ts_start_server_is_set() {
2357 let dir = tempfile::tempdir().unwrap();
2358 let target = dir.path().join("target");
2359 let server_log = dir.path().join("server.log");
2360 let game_log = dir.path().join("game.log");
2361 let wrapper = dir.path().join("steam-wrapper.sh");
2362
2363 std::fs::create_dir_all(&target).unwrap();
2364 recording_loader(&target.join(SERVER_SCRIPT), &server_log);
2368 recording_loader(&target.join("run_bepinex.sh"), &game_log);
2369 write_wrapper_script(&wrapper).unwrap();
2370
2371 let selected = target.to_string_lossy().to_string();
2372 let argv = [
2373 "/games/valheim/valheim.x86_64",
2374 WRAPPER_TARGET_FLAG,
2375 &selected,
2376 ];
2377
2378 run_wrapper(&wrapper, &argv, false);
2379
2380 assert!(
2381 !server_log.exists(),
2382 "the server script must not run for a game instance"
2383 );
2384 assert_eq!(
2385 recorded(&game_log)[0],
2386 target.join("run_bepinex.sh").to_string_lossy()
2387 );
2388
2389 run_wrapper(&wrapper, &argv, true);
2390
2391 assert_eq!(
2392 recorded(&server_log)[0],
2393 target.join(SERVER_SCRIPT).to_string_lossy()
2394 );
2395 }
2396
2397 #[test]
2398 fn steam_launch_options_are_only_the_wrapper_and_command_placeholder() {
2399 let options = steam_launch_options(Path::new("/data/launch_wrapper.sh"));
2400
2401 assert_eq!(options, r#""/data/launch_wrapper.sh" %command%"#);
2404
2405 assert!(!options.contains("WINEDLLOVERRIDES"));
2409 assert!(!options.contains("winhttp"));
2410
2411 assert_eq!(
2413 steam_launch_options(Path::new("/data/My Games/wrapper.sh")),
2414 r#""/data/My Games/wrapper.sh" %command%"#
2415 );
2416 }
2417
2418 #[test]
2419 fn stage_destination_routes_per_loader() {
2420 let game = Path::new("/game");
2421
2422 assert_eq!(
2423 stage_destination(LoaderKind::BepInEx, "winhttp.dll", game, None),
2424 Some(PathBuf::from("/game/winhttp.dll"))
2425 );
2426 assert_eq!(
2428 stage_destination(LoaderKind::BepInEx, "mods.yml", game, None),
2429 None
2430 );
2431
2432 assert_eq!(
2434 stage_destination(LoaderKind::Shimloader, "ue4ss.dll", game, Some("Palworld")),
2435 Some(PathBuf::from("/game/Palworld/Binaries/Win64/ue4ss.dll"))
2436 );
2437 assert_eq!(
2438 stage_destination(LoaderKind::Shimloader, "other.dll", game, Some("Palworld")),
2439 None
2440 );
2441 assert_eq!(
2443 stage_destination(LoaderKind::Shimloader, "ue4ss.dll", game, None),
2444 None
2445 );
2446
2447 assert_eq!(
2449 stage_destination(LoaderKind::Rivet, "version.dll", game, None),
2450 Some(PathBuf::from("/game/Release/version.dll"))
2451 );
2452 assert_eq!(
2453 stage_destination(LoaderKind::Rivet, "winhttp.dll", game, None),
2454 None
2455 );
2456 }
2457
2458 #[test]
2459 fn stage_for_launch_copies_root_files_only() {
2460 let dir = tempfile::tempdir().unwrap();
2461 let target = dir.path().join("target");
2462 let game_dir = dir.path().join("game");
2463
2464 std::fs::create_dir_all(target.join("BepInEx/core")).unwrap();
2465 std::fs::create_dir_all(&game_dir).unwrap();
2466 std::fs::write(target.join("winhttp.dll"), b"proxy").unwrap();
2467 std::fs::write(target.join("doorstop_config.ini"), b"[UnityDoorstop]").unwrap();
2468 std::fs::write(target.join("mods.yml"), b"[]").unwrap();
2469 std::fs::write(target.join("BepInEx/core/BepInEx.Preloader.dll"), b"x").unwrap();
2470
2471 let eco = Ecosystem::bundled();
2472 let written = stage_for_launch_in(
2473 &target,
2474 &eco,
2475 "valheim",
2476 &game_dir,
2477 &ctx(LaunchMode::Modded),
2478 )
2479 .unwrap();
2480
2481 assert!(game_dir.join("winhttp.dll").exists());
2482 assert!(game_dir.join("doorstop_config.ini").exists());
2483 assert!(!game_dir.join("mods.yml").exists());
2485 assert!(!game_dir.join("BepInEx").exists());
2486 assert_eq!(written.len(), 2);
2487
2488 assert!(written.contains(&game_dir.join("winhttp.dll")));
2490 assert!(written.contains(&game_dir.join("doorstop_config.ini")));
2491
2492 let mut sorted = written.clone();
2493 sorted.sort();
2494
2495 assert_eq!(written, sorted);
2496 }
2497
2498 #[test]
2499 fn stage_for_launch_stages_shimloader_files_into_nested_binaries_dir() {
2500 let dir = tempfile::tempdir().unwrap();
2504 let target = dir.path().join("target");
2505 let game_dir = dir.path().join("game");
2506
2507 std::fs::create_dir_all(&target).unwrap();
2508 std::fs::create_dir_all(&game_dir).unwrap();
2509 std::fs::write(target.join("ue4ss.dll"), b"loader").unwrap();
2510 std::fs::write(target.join("dwmapi.dll"), b"proxy").unwrap();
2511 std::fs::write(target.join("ue4ss-settings.ini"), b"[UE4SS]").unwrap();
2512 std::fs::write(target.join("other.dll"), b"unrelated").unwrap();
2513
2514 let eco = Ecosystem::bundled();
2515 let written = stage_for_launch_in(
2516 &target,
2517 &eco,
2518 "palworld",
2519 &game_dir,
2520 &ctx(LaunchMode::Modded),
2521 )
2522 .unwrap();
2523
2524 let nested = game_dir.join("Pal").join("Binaries").join("Win64");
2525
2526 assert_eq!(std::fs::read(nested.join("ue4ss.dll")).unwrap(), b"loader");
2527 assert!(nested.join("dwmapi.dll").exists());
2528 assert!(nested.join("ue4ss-settings.ini").exists());
2529 assert!(!game_dir.join("other.dll").exists());
2531 assert!(!nested.join("other.dll").exists());
2532
2533 assert_eq!(written.len(), 3);
2534 assert!(written.contains(&nested.join("ue4ss.dll")));
2535 assert!(written.contains(&nested.join("dwmapi.dll")));
2536 assert!(written.contains(&nested.join("ue4ss-settings.ini")));
2537
2538 let mut sorted = written.clone();
2539 sorted.sort();
2540
2541 assert_eq!(written, sorted);
2542 }
2543
2544 #[test]
2545 fn stage_for_launch_stages_rivet_version_dll_into_release_dir() {
2546 let dir = tempfile::tempdir().unwrap();
2550 let target = dir.path().join("target");
2551 let game_dir = dir.path().join("game");
2552
2553 std::fs::create_dir_all(&target).unwrap();
2554 std::fs::create_dir_all(&game_dir).unwrap();
2555 std::fs::write(target.join("version.dll"), b"rivet-loader").unwrap();
2556 std::fs::write(target.join("other.dll"), b"unrelated").unwrap();
2557
2558 let eco = Ecosystem::bundled();
2559 let written = stage_for_launch_in(
2560 &target,
2561 &eco,
2562 "scrap-mechanic",
2563 &game_dir,
2564 &ctx(LaunchMode::Modded),
2565 )
2566 .unwrap();
2567
2568 let dest = game_dir.join("Release").join("version.dll");
2569
2570 assert_eq!(std::fs::read(&dest).unwrap(), b"rivet-loader");
2571 assert!(!game_dir.join("other.dll").exists());
2573 assert!(!game_dir.join("Release").join("other.dll").exists());
2574
2575 assert_eq!(written, vec![dest]);
2576 }
2577
2578 #[test]
2579 fn stage_for_launch_skips_linux_native_bepinex() {
2580 let dir = tempfile::tempdir().unwrap();
2581 let target = dir.path().join("target");
2582 let game_dir = dir.path().join("game");
2583
2584 std::fs::create_dir_all(&target).unwrap();
2585 std::fs::create_dir_all(&game_dir).unwrap();
2586 std::fs::write(target.join("winhttp.dll"), b"proxy").unwrap();
2587
2588 let eco = Ecosystem::bundled();
2589 let mut context = ctx(LaunchMode::Modded);
2590 context.os = HostOs::Linux;
2591 context.runtime = Runtime::Native;
2592
2593 let written = stage_for_launch_in(&target, &eco, "valheim", &game_dir, &context).unwrap();
2594
2595 assert!(written.is_empty());
2596 assert!(!game_dir.join("winhttp.dll").exists());
2597 }
2598
2599 #[cfg(unix)]
2600 #[test]
2601 fn stage_for_launch_refuses_to_write_through_a_symlink() {
2602 use std::os::unix::fs::symlink;
2603
2604 let dir = tempfile::tempdir().unwrap();
2605 let target = dir.path().join("target");
2606 let game_dir = dir.path().join("game");
2607 let outside = dir.path().join("secret.dll");
2608
2609 std::fs::create_dir_all(&target).unwrap();
2610 std::fs::create_dir_all(&game_dir).unwrap();
2611 std::fs::write(&outside, b"original").unwrap();
2612 std::fs::write(target.join("winhttp.dll"), b"proxy").unwrap();
2613 symlink(&outside, game_dir.join("winhttp.dll")).unwrap();
2614
2615 let eco = Ecosystem::bundled();
2616
2617 assert!(
2618 stage_for_launch_in(
2619 &target,
2620 &eco,
2621 "valheim",
2622 &game_dir,
2623 &ctx(LaunchMode::Modded)
2624 )
2625 .is_err()
2626 );
2627 assert_eq!(std::fs::read(&outside).unwrap(), b"original");
2628 }
2629
2630 #[test]
2631 fn stage_for_launch_in_place_leaves_root_files_byte_for_byte_untouched() {
2632 let dir = tempfile::tempdir().unwrap();
2639 let root = dir.path().join("game");
2640
2641 std::fs::create_dir_all(root.join("BepInEx/core")).unwrap();
2642
2643 let winhttp_contents = b"MZ-fake-proxy-dll-18b";
2644 let ini_contents = b"[UnityDoorstop]\nenabled=true\n";
2645
2646 std::fs::write(root.join("winhttp.dll"), winhttp_contents).unwrap();
2647 std::fs::write(root.join("doorstop_config.ini"), ini_contents).unwrap();
2648 std::fs::write(root.join("mods.yml"), b"[]").unwrap();
2649 std::fs::write(root.join("BepInEx/core/BepInEx.Preloader.dll"), b"x").unwrap();
2650
2651 let eco = Ecosystem::bundled();
2652 let written =
2653 stage_for_launch_in(&root, &eco, "valheim", &root, &ctx(LaunchMode::Modded)).unwrap();
2654
2655 assert_eq!(
2658 std::fs::read(root.join("winhttp.dll")).unwrap(),
2659 winhttp_contents
2660 );
2661 assert_eq!(
2662 std::fs::read(root.join("doorstop_config.ini")).unwrap(),
2663 ini_contents
2664 );
2665
2666 assert!(written.is_empty());
2672 }
2673
2674 #[test]
2675 fn stage_for_launch_in_place_survives_a_dot_dot_aliased_game_dir() {
2676 let dir = tempfile::tempdir().unwrap();
2681 let target = dir.path().join("target");
2682
2683 std::fs::create_dir_all(target.join("BepInEx/core")).unwrap();
2684
2685 let contents = b"proxy-bytes-not-truncated";
2686
2687 std::fs::write(target.join("winhttp.dll"), contents).unwrap();
2688
2689 let aliased_game_dir = target.join("BepInEx").join("..");
2690 let eco = Ecosystem::bundled();
2691
2692 let written = stage_for_launch_in(
2693 &target,
2694 &eco,
2695 "valheim",
2696 &aliased_game_dir,
2697 &ctx(LaunchMode::Modded),
2698 )
2699 .unwrap();
2700
2701 assert_eq!(std::fs::read(target.join("winhttp.dll")).unwrap(), contents);
2702 assert!(written.is_empty());
2703 }
2704
2705 #[cfg(unix)]
2706 #[test]
2707 fn stage_for_launch_in_place_survives_a_symlinked_game_dir() {
2708 use std::os::unix::fs::symlink;
2709
2710 let dir = tempfile::tempdir().unwrap();
2714 let target = dir.path().join("target");
2715 let link = dir.path().join("game-link");
2716
2717 std::fs::create_dir_all(target.join("BepInEx/core")).unwrap();
2718
2719 let contents = b"proxy-bytes-via-symlink";
2720
2721 std::fs::write(target.join("winhttp.dll"), contents).unwrap();
2722 symlink(&target, &link).unwrap();
2723
2724 let eco = Ecosystem::bundled();
2725 let written =
2726 stage_for_launch_in(&target, &eco, "valheim", &link, &ctx(LaunchMode::Modded)).unwrap();
2727
2728 assert_eq!(std::fs::read(target.join("winhttp.dll")).unwrap(), contents);
2729 assert!(written.is_empty());
2730 }
2731
2732 #[test]
2733 fn stage_for_launch_in_place_still_copies_shimloader_files_to_a_distinct_destination() {
2734 let dir = tempfile::tempdir().unwrap();
2740 let root = dir.path().join("game");
2741
2742 std::fs::create_dir_all(&root).unwrap();
2743 std::fs::write(root.join("ue4ss.dll"), b"loader").unwrap();
2744 std::fs::write(root.join("dwmapi.dll"), b"proxy").unwrap();
2745 std::fs::write(root.join("ue4ss-settings.ini"), b"[UE4SS]").unwrap();
2746
2747 let eco = Ecosystem::bundled();
2748 let written =
2749 stage_for_launch_in(&root, &eco, "palworld", &root, &ctx(LaunchMode::Modded)).unwrap();
2750
2751 let nested = root.join("Pal").join("Binaries").join("Win64");
2752
2753 assert_eq!(std::fs::read(nested.join("ue4ss.dll")).unwrap(), b"loader");
2754 assert_eq!(std::fs::read(nested.join("dwmapi.dll")).unwrap(), b"proxy");
2755 assert_eq!(std::fs::read(root.join("ue4ss.dll")).unwrap(), b"loader");
2758
2759 assert_eq!(written.len(), 3);
2760 assert!(written.contains(&nested.join("ue4ss.dll")));
2761 assert!(written.contains(&nested.join("dwmapi.dll")));
2762 assert!(written.contains(&nested.join("ue4ss-settings.ini")));
2763 }
2764
2765 #[test]
2766 fn stage_for_launch_in_place_still_copies_rivet_version_dll_to_a_distinct_destination() {
2767 let dir = tempfile::tempdir().unwrap();
2771 let root = dir.path().join("game");
2772
2773 std::fs::create_dir_all(&root).unwrap();
2774 std::fs::write(root.join("version.dll"), b"rivet-loader").unwrap();
2775
2776 let eco = Ecosystem::bundled();
2777 let written = stage_for_launch_in(
2778 &root,
2779 &eco,
2780 "scrap-mechanic",
2781 &root,
2782 &ctx(LaunchMode::Modded),
2783 )
2784 .unwrap();
2785
2786 let dest = root.join("Release").join("version.dll");
2787
2788 assert_eq!(std::fs::read(&dest).unwrap(), b"rivet-loader");
2789 assert_eq!(
2790 std::fs::read(root.join("version.dll")).unwrap(),
2791 b"rivet-loader"
2792 );
2793 assert_eq!(written, vec![dest]);
2794 }
2795
2796 #[test]
2797 fn stage_for_launch_wraps_stage_for_launch_in_over_a_layout_profile() {
2798 let dir = tempfile::tempdir().unwrap();
2799 let base = dir.path().join("base");
2800 let game_dir = dir.path().join("game");
2801
2802 let profile_dir = crate::profile::layout::create(&base, "valheim", "Main").unwrap();
2803 let core = profile_dir.join("BepInEx/core");
2804
2805 std::fs::create_dir_all(&core).unwrap();
2806 std::fs::create_dir_all(&game_dir).unwrap();
2807 std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
2808 std::fs::write(profile_dir.join("winhttp.dll"), b"proxy").unwrap();
2809 std::fs::write(profile_dir.join("mods.yml"), b"[]").unwrap();
2810
2811 let eco = Ecosystem::bundled();
2812 let written = stage_for_launch(
2813 &base,
2814 "valheim",
2815 "Main",
2816 &eco,
2817 &game_dir,
2818 &ctx(LaunchMode::Modded),
2819 )
2820 .unwrap();
2821
2822 assert!(game_dir.join("winhttp.dll").exists());
2823 assert!(!game_dir.join("mods.yml").exists());
2824 assert_eq!(written, vec![game_dir.join("winhttp.dll")]);
2825 }
2826
2827 fn linux_native(mode: LaunchMode) -> LaunchContext {
2829 let mut context = ctx(mode);
2830
2831 context.os = HostOs::Linux;
2832 context.store = StorePlatform::SteamDirect;
2833 context.runtime = Runtime::Native;
2834
2835 context
2836 }
2837
2838 fn linux_bepinex_target(root: &Path) {
2841 let core = root.join("BepInEx/core");
2842
2843 std::fs::create_dir_all(&core).unwrap();
2844 std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
2845 std::fs::write(root.join("winhttp.dll"), b"proxy").unwrap();
2846 std::fs::write(root.join("doorstop_config.ini"), b"[UnityDoorstop]").unwrap();
2847 }
2848
2849 #[test]
2850 fn linux_native_modded_selects_any_shipped_script_not_only_run_bepinex() {
2851 let dir = tempfile::tempdir().unwrap();
2852 let target = dir.path().join("target");
2853 let game_dir = dir.path().join("game");
2854
2855 linux_bepinex_target(&target);
2856 std::fs::create_dir_all(&game_dir).unwrap();
2857 std::fs::write(target.join("start_game_bepinex.sh"), b"#!/bin/sh\n").unwrap();
2859
2860 let eco = Ecosystem::bundled();
2861 let context = linux_native(LaunchMode::Modded);
2862
2863 let plan = launch_plan_in(&target, &eco, "valheim", &context).unwrap();
2864
2865 match &plan.program {
2866 LaunchProgram::ProfileScript { path, .. } => {
2867 assert!(path.ends_with("start_game_bepinex.sh"))
2868 }
2869 other => panic!("expected ProfileScript, got {other:?}"),
2872 }
2873
2874 let written = stage_for_launch_in(&target, &eco, "valheim", &game_dir, &context).unwrap();
2876
2877 assert!(written.is_empty());
2878 assert!(!game_dir.join("winhttp.dll").exists());
2879 }
2880
2881 #[test]
2882 fn server_instance_prefers_the_server_script_and_a_game_instance_ignores_it() {
2883 let dir = tempfile::tempdir().unwrap();
2884 let all_three = dir.path().join("all-three");
2885 let server_only = dir.path().join("server-only");
2886
2887 linux_bepinex_target(&all_three);
2888 linux_bepinex_target(&server_only);
2889
2890 for name in LOADER_SCRIPTS {
2891 std::fs::write(all_three.join(name), b"#!/bin/sh\n").unwrap();
2892 }
2893
2894 std::fs::write(server_only.join(SERVER_SCRIPT), b"#!/bin/sh\n").unwrap();
2895
2896 let context = linux_native(LaunchMode::Modded);
2897 let game_profile = profile_with(LoaderKind::BepInEx);
2898 let mut server_profile = profile_with(LoaderKind::BepInEx);
2899
2900 server_profile.instance_type = InstanceType::Server;
2901
2902 let server = resolve_program(
2903 &script_injection(&all_three, &server_profile, &context),
2904 &all_three,
2905 &server_profile,
2906 &context,
2907 )
2908 .unwrap();
2909
2910 match &server {
2911 LaunchProgram::ProfileScript { path, .. } => {
2912 assert!(path.ends_with("start_server_bepinex.sh"))
2913 }
2914 other => panic!("expected ProfileScript, got {other:?}"),
2915 }
2916
2917 let game = resolve_program(
2920 &script_injection(&all_three, &game_profile, &context),
2921 &all_three,
2922 &game_profile,
2923 &context,
2924 )
2925 .unwrap();
2926
2927 match &game {
2928 LaunchProgram::ProfileScript { path, .. } => assert!(path.ends_with("run_bepinex.sh")),
2929 other => panic!("expected ProfileScript, got {other:?}"),
2930 }
2931
2932 let err = resolve_program(
2934 &script_injection(&server_only, &game_profile, &context),
2935 &server_only,
2936 &game_profile,
2937 &context,
2938 )
2939 .unwrap_err();
2940
2941 assert!(err.to_string().contains("run_bepinex.sh"));
2942 }
2943
2944 #[test]
2945 fn modded_script_injection_without_a_script_errors_but_vanilla_falls_through() {
2946 let dir = tempfile::tempdir().unwrap();
2947 let target = dir.path().join("target");
2948
2949 linux_bepinex_target(&target);
2950
2951 let eco = Ecosystem::bundled();
2952 let modded = linux_native(LaunchMode::Modded);
2953
2954 let err = launch_plan_in(&target, &eco, "valheim", &modded).unwrap_err();
2955 let message = err.to_string();
2956
2957 assert!(message.contains("run_bepinex.sh"), "{message}");
2958 assert!(message.contains("start_game_bepinex.sh"), "{message}");
2959 assert!(message.contains(&target.display().to_string()), "{message}");
2960
2961 let vanilla =
2964 launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Vanilla)).unwrap();
2965
2966 assert!(matches!(vanilla.program, LaunchProgram::GameExe { .. }));
2967 assert_eq!(
2968 vanilla.args,
2969 vec!["--doorstop-enable".to_string(), "false".to_string()]
2970 );
2971 }
2972
2973 #[test]
2974 fn linux_native_steam_launches_steam_and_reports_the_wrapper_requirement() {
2975 let dir = tempfile::tempdir().unwrap();
2976 let target = dir.path().join("target");
2977 let game_dir = dir.path().join("game");
2978 let script_path = dir.path().join("launch_wrapper.sh");
2979
2980 linux_bepinex_target(&target);
2981 std::fs::create_dir_all(&game_dir).unwrap();
2982 std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
2983
2984 let eco = Ecosystem::bundled();
2985 let mut context = linux_native(LaunchMode::Modded);
2986
2987 context.store = StorePlatform::Steam;
2988
2989 let plan = launch_plan_in(&target, &eco, "valheim", &context).unwrap();
2990
2991 match &plan.program {
2993 LaunchProgram::Steam { steam_app_id } => assert_eq!(*steam_app_id, 892970),
2994 other => panic!("expected Steam, got {other:?}"),
2995 }
2996
2997 let wrapper = plan.steam_wrapper.clone().expect("wrapper requirement");
3001
3002 assert_eq!(wrapper.target_dir, target);
3003 assert_eq!(wrapper.loader, LoaderKind::BepInEx);
3004
3005 write_wrapper_script(&script_path).unwrap();
3006
3007 let options = steam_launch_options(&script_path);
3008
3009 assert!(options.contains(&script_path.display().to_string()));
3010 assert!(!options.contains("WINEDLLOVERRIDES"));
3011 assert!(
3012 std::fs::read_to_string(&script_path)
3013 .unwrap()
3014 .contains("run_bepinex.sh")
3015 );
3016
3017 assert!(
3020 plan
3021 .args
3022 .windows(2)
3023 .any(|pair| pair == wrapper_target_args(&target).as_slice()),
3024 "plan args must carry the wrapper target flag: {:?}",
3025 plan.args
3026 );
3027
3028 let written = stage_for_launch_in(&target, &eco, "valheim", &game_dir, &context).unwrap();
3030
3031 assert!(written.is_empty());
3032 assert!(!game_dir.join("winhttp.dll").exists());
3033 }
3034
3035 #[test]
3036 fn stage_for_launch_rejects_macos() {
3037 let dir = tempfile::tempdir().unwrap();
3038 let target = dir.path().join("target");
3039 let game_dir = dir.path().join("game");
3040
3041 linux_bepinex_target(&target);
3042 std::fs::create_dir_all(&game_dir).unwrap();
3043
3044 let eco = Ecosystem::bundled();
3045 let mut context = ctx(LaunchMode::Modded);
3046
3047 context.os = HostOs::MacOs;
3048
3049 let err = stage_for_launch_in(&target, &eco, "valheim", &game_dir, &context).unwrap_err();
3050
3051 assert!(err.to_string().contains("macOS launch is not supported"));
3052 assert_eq!(std::fs::read_dir(&game_dir).unwrap().count(), 0);
3055
3056 context.os = HostOs::Windows;
3059
3060 let written = stage_for_launch_in(&target, &eco, "valheim", &game_dir, &context).unwrap();
3061
3062 assert_eq!(written.len(), 2);
3063 assert!(game_dir.join("winhttp.dll").exists());
3064 }
3065
3066 #[test]
3067 fn only_a_wrapper_bound_plan_carries_the_target_flag() {
3068 let dir = tempfile::tempdir().unwrap();
3069 let target = dir.path().join("target");
3070
3071 linux_bepinex_target(&target);
3072 std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
3073
3074 let eco = Ecosystem::bundled();
3075
3076 let direct =
3080 launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Modded)).unwrap();
3081
3082 assert_eq!(direct.steam_wrapper, None);
3083 assert!(!direct.args.contains(&WRAPPER_TARGET_FLAG.to_string()));
3084
3085 let mut proton = linux_native(LaunchMode::Modded);
3088
3089 proton.store = StorePlatform::Steam;
3090 proton.runtime = Runtime::Proton;
3091
3092 let proton_plan = launch_plan_in(&target, &eco, "valheim", &proton).unwrap();
3093
3094 assert_eq!(proton_plan.steam_wrapper, None);
3095 assert!(!proton_plan.args.contains(&WRAPPER_TARGET_FLAG.to_string()));
3096 }
3097
3098 #[test]
3099 fn wrapper_branches_are_generated_from_the_shared_script_list() {
3100 let script = wrapper_script_contents();
3101
3102 let mut previous = 0;
3103
3104 for name in LOADER_SCRIPTS {
3105 let position = script
3106 .find(&format!("[ -f \"$TARGET/{name}\" ]"))
3107 .unwrap_or_else(|| panic!("wrapper is missing a branch for {name}"));
3108
3109 assert!(position >= previous, "{name} is out of precedence order");
3110
3111 previous = position;
3112 }
3113 }
3114
3115 #[test]
3120 fn linux_native_script_present_vanilla_runs_the_script_with_v4_enabled_spelling() {
3121 let dir = tempfile::tempdir().unwrap();
3122 let target = dir.path().join("target");
3123
3124 linux_bepinex_target(&target);
3125 std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
3126 let eco = Ecosystem::bundled();
3130 let plan =
3131 launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Vanilla)).unwrap();
3132
3133 match &plan.program {
3136 LaunchProgram::ProfileScript { path, .. } => assert!(path.ends_with("run_bepinex.sh")),
3137 other => panic!("expected ProfileScript, got {other:?}"),
3138 }
3139
3140 assert_eq!(
3141 plan.args,
3142 vec!["--doorstop-enabled".to_string(), "false".to_string()]
3143 );
3144 }
3145
3146 #[test]
3147 fn linux_native_script_present_modded_uses_v4_target_assembly_spelling() {
3148 let dir = tempfile::tempdir().unwrap();
3149 let target = dir.path().join("target");
3150
3151 linux_bepinex_target(&target);
3152 std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
3153 let eco = Ecosystem::bundled();
3157 let plan = launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Modded)).unwrap();
3158
3159 assert!(matches!(plan.program, LaunchProgram::ProfileScript { .. }));
3160 assert_eq!(plan.args[0], "--doorstop-enabled");
3161 assert_eq!(plan.args[1], "true");
3162 assert_eq!(plan.args[2], "--doorstop-target-assembly");
3163 assert!(plan.args[3].ends_with("BepInEx/core/BepInEx.Preloader.dll"));
3164 }
3165
3166 #[test]
3167 fn linux_native_script_present_v3_detected_corlib_override_uses_v4_search_flag_spelling() {
3168 let dir = tempfile::tempdir().unwrap();
3174 let target = dir.path().join("target");
3175
3176 linux_bepinex_target(&target);
3177 std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
3178 std::fs::create_dir_all(target.join("unstripped_corlib")).unwrap();
3179 let eco = Ecosystem::bundled();
3183 let plan = launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Modded)).unwrap();
3184
3185 assert!(matches!(plan.program, LaunchProgram::ProfileScript { .. }));
3186
3187 let idx = plan
3188 .args
3189 .iter()
3190 .position(|a| a == "--doorstop-mono-dll-search-path-override")
3191 .expect("v4 corlib search-path flag missing");
3192
3193 assert!(plan.args[idx + 1].ends_with("/unstripped_corlib"));
3194 assert!(
3195 !plan
3196 .args
3197 .iter()
3198 .any(|a| a == "--doorstop-dll-search-override"),
3199 "v3 corlib search-path spelling must not appear on the script-injected path: {:?}",
3200 plan.args
3201 );
3202 }
3203
3204 #[test]
3205 fn linux_native_script_present_v4_detected_target_is_unchanged() {
3206 let dir = tempfile::tempdir().unwrap();
3207 let target = dir.path().join("target");
3208
3209 linux_bepinex_target(&target);
3210 std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
3211 std::fs::write(target.join(".doorstop_version"), "4.0.0").unwrap();
3212
3213 let eco = Ecosystem::bundled();
3214
3215 let vanilla =
3216 launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Vanilla)).unwrap();
3217
3218 assert_eq!(
3219 vanilla.args,
3220 vec!["--doorstop-enabled".to_string(), "false".to_string()]
3221 );
3222
3223 let modded =
3224 launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Modded)).unwrap();
3225
3226 assert_eq!(modded.args[0], "--doorstop-enabled");
3227 assert_eq!(modded.args[2], "--doorstop-target-assembly");
3228 }
3229
3230 #[test]
3231 fn proton_v3_detected_target_still_uses_v3_spellings_even_with_a_script_on_disk() {
3232 let dir = tempfile::tempdir().unwrap();
3241 let target = dir.path().join("target");
3242
3243 linux_bepinex_target(&target);
3244 std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
3245
3246 let eco = Ecosystem::bundled();
3247 let mut context = linux_native(LaunchMode::Modded);
3248
3249 context.runtime = Runtime::Proton;
3250
3251 let plan = launch_plan_in(&target, &eco, "valheim", &context).unwrap();
3252
3253 assert_eq!(plan.args[0], "--doorstop-enable");
3254 assert_eq!(plan.args[2], "--doorstop-target");
3255
3256 let mut vanilla_context = linux_native(LaunchMode::Vanilla);
3257
3258 vanilla_context.runtime = Runtime::Proton;
3259
3260 let vanilla = launch_plan_in(&target, &eco, "valheim", &vanilla_context).unwrap();
3261
3262 assert_eq!(
3263 vanilla.args,
3264 vec!["--doorstop-enable".to_string(), "false".to_string()]
3265 );
3266 }
3267
3268 #[test]
3269 fn windows_v3_detected_target_still_uses_v3_spellings_regression_guard() {
3270 let dir = tempfile::tempdir().unwrap();
3274 let root = dir.path();
3275 let core = root.join("BepInEx/core");
3276
3277 std::fs::create_dir_all(&core).unwrap();
3278 std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
3279
3280 let eco = Ecosystem::bundled();
3281 let plan = launch_plan_in(root, &eco, "valheim", &ctx(LaunchMode::Modded)).unwrap();
3282
3283 assert_eq!(plan.args[0], "--doorstop-enable");
3284 assert_eq!(plan.args[2], "--doorstop-target");
3285
3286 let vanilla = launch_plan_in(root, &eco, "valheim", &ctx(LaunchMode::Vanilla)).unwrap();
3287
3288 assert_eq!(
3289 vanilla.args,
3290 vec!["--doorstop-enable".to_string(), "false".to_string()]
3291 );
3292 }
3293
3294 #[test]
3295 fn store_and_runtime_parse_from_their_config_spellings() {
3296 assert_eq!(
3297 "steam".parse::<StorePlatform>().unwrap(),
3298 StorePlatform::Steam
3299 );
3300 assert_eq!(
3301 "steam-direct".parse::<StorePlatform>().unwrap(),
3302 StorePlatform::SteamDirect
3303 );
3304 assert_eq!(
3305 "other".parse::<StorePlatform>().unwrap(),
3306 StorePlatform::Other
3307 );
3308 assert_eq!("native".parse::<Runtime>().unwrap(), Runtime::Native);
3309 assert_eq!("proton".parse::<Runtime>().unwrap(), Runtime::Proton);
3310
3311 let error = "wat".parse::<StorePlatform>().unwrap_err().to_string();
3313
3314 assert!(error.contains("wat"), "got: {error}");
3315 }
3316
3317 #[test]
3318 fn resolve_game_exe_returns_the_first_present_candidate() {
3319 let dir = tempfile::tempdir().unwrap();
3320 let game_dir = dir.path();
3321
3322 std::fs::write(game_dir.join("second.x86_64"), b"elf").unwrap();
3323
3324 let names = vec!["first.exe".to_string(), "second.x86_64".to_string()];
3325 let found = resolve_game_exe(game_dir, &names).unwrap();
3326
3327 assert_eq!(found, game_dir.join("second.x86_64"));
3328
3329 let error = resolve_game_exe(game_dir, &["absent.exe".to_string()])
3331 .unwrap_err()
3332 .to_string();
3333
3334 assert!(error.contains("absent.exe"), "got: {error}");
3335 }
3336
3337 #[test]
3338 fn a_plan_becomes_a_spawnable_command() {
3339 let dir = tempfile::tempdir().unwrap();
3340 let game_dir = dir.path();
3341
3342 std::fs::write(game_dir.join("valheim.x86_64"), b"elf").unwrap();
3343
3344 let plan = LaunchPlan {
3348 program: LaunchProgram::GameExe {
3349 exe_names: vec!["valheim.x86_64".to_string()],
3350 },
3351 args: vec!["-console".to_string()],
3352 env: vec![("KEY".to_string(), "VALUE".to_string())],
3353 working_dir: Some(game_dir.to_path_buf()),
3354 steam_wrapper: None,
3355 };
3356
3357 let command = plan.to_command(game_dir).unwrap();
3358
3359 assert_eq!(command.get_program(), game_dir.join("valheim.x86_64"));
3360 assert_eq!(
3361 command.get_args().collect::<Vec<_>>(),
3362 vec![std::ffi::OsStr::new("-console")]
3363 );
3364 assert_eq!(command.get_current_dir(), Some(game_dir));
3365 assert!(
3366 command
3367 .get_envs()
3368 .any(|(key, value)| key == std::ffi::OsStr::new("KEY")
3369 && value == Some(std::ffi::OsStr::new("VALUE")))
3370 );
3371 }
3372}