#![deny(missing_docs)]
use crate::ecosystem::{Ecosystem, GameProfile, InstanceType, LoaderKind};
use crate::error::{Error, Result};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum HostOs {
Windows,
Linux,
MacOs,
}
impl HostOs {
pub fn detect() -> Option<Self> {
if cfg!(target_os = "windows") {
Some(Self::Windows)
} else if cfg!(target_os = "linux") {
Some(Self::Linux)
} else if cfg!(target_os = "macos") {
Some(Self::MacOs)
} else {
None
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum StorePlatform {
Steam,
SteamDirect,
Other,
}
impl std::str::FromStr for StorePlatform {
type Err = Error;
fn from_str(value: &str) -> Result<Self> {
match value {
"steam" => Ok(Self::Steam),
"steam-direct" => Ok(Self::SteamDirect),
"other" => Ok(Self::Other),
other => Err(Error::Profile(format!(
"{other:?} is not a known store platform; expected steam, steam-direct, \
or other"
))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Runtime {
Native,
Proton,
}
impl std::str::FromStr for Runtime {
type Err = Error;
fn from_str(value: &str) -> Result<Self> {
match value {
"native" => Ok(Self::Native),
"proton" => Ok(Self::Proton),
other => Err(Error::Profile(format!(
"{other:?} is not a known runtime; expected native or proton"
))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum LaunchMode {
Modded,
Vanilla,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum LaunchProgram {
GameExe {
exe_names: Vec<String>,
},
Steam {
steam_app_id: u32,
},
ProfileScript {
path: PathBuf,
exe_names: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct SteamWrapper {
pub target_dir: PathBuf,
pub loader: LoaderKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct LaunchPlan {
pub program: LaunchProgram,
pub args: Vec<String>,
pub env: Vec<(String, String)>,
pub working_dir: Option<PathBuf>,
pub steam_wrapper: Option<SteamWrapper>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct LaunchContext {
pub os: HostOs,
pub store: StorePlatform,
pub runtime: Runtime,
pub mode: LaunchMode,
pub extra_args: Vec<String>,
}
impl LaunchContext {
pub fn new(os: HostOs, store: StorePlatform, runtime: Runtime, mode: LaunchMode) -> Self {
Self {
os,
store,
runtime,
mode,
extra_args: Vec::new(),
}
}
pub fn with_extra_args(self, extra_args: Vec<String>) -> Self {
Self { extra_args, ..self }
}
}
pub const PRELOADER_CANDIDATES: [&str; 5] = [
"BepInEx.Unity.Mono.Preloader.dll",
"BepInEx.Unity.IL2CPP.dll",
"BepInEx.Preloader.dll",
"BepInEx.IL2CPP.dll",
"BepInEx.NET.CoreCLR.dll",
];
pub const LOADER_SCRIPTS: [&str; 3] = [
"start_server_bepinex.sh",
"run_bepinex.sh",
"start_game_bepinex.sh",
];
const SERVER_SCRIPT: &str = LOADER_SCRIPTS[0];
const MACOS_UNSUPPORTED: &str =
"macOS launch is not supported: the injection chain requires r2modman's closed-source proxy";
pub fn doorstop_version(target_dir: &Path) -> u32 {
let path = target_dir.join(".doorstop_version");
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(_) => return 3,
};
let major = text
.trim()
.split('.')
.next()
.and_then(|part| part.parse::<u32>().ok());
match major {
Some(version) if version >= 3 => version,
_ => 3,
}
}
fn wine_path(path: &Path) -> String {
format!("Z:{}", path.to_string_lossy().replace('\\', "/"))
}
pub fn bepinex_preloader(target_dir: &Path, runtime: Runtime) -> Result<String> {
let core = target_dir.join("BepInEx").join("core");
let found = PRELOADER_CANDIDATES
.iter()
.map(|name| core.join(name))
.find(|candidate| candidate.is_file())
.ok_or_else(|| Error::Profile(format!("no BepInEx preloader found in {}", core.display())))?;
let rendered = match runtime {
Runtime::Proton => wine_path(&found),
Runtime::Native => found.to_string_lossy().replace('\\', "/"),
};
Ok(rendered)
}
pub fn has_corlibs(target_dir: &Path) -> bool {
target_dir.join("unstripped_corlib").is_dir()
}
fn arg_path(target_dir: &Path, relative: &str) -> String {
target_dir
.join(relative)
.to_string_lossy()
.replace('\\', "/")
}
pub fn loader_args(
target_dir: &Path,
profile: &GameProfile,
ctx: &LaunchContext,
) -> Result<Vec<String>> {
let target = target_dir.to_string_lossy().replace('\\', "/");
let args = match profile.package_loader {
LoaderKind::BepInEx => {
let script_injected = is_script_injected(target_dir, profile, ctx);
doorstop_args(
target_dir,
profile,
ctx,
DoorstopTarget::BepInEx,
script_injected,
)?
}
LoaderKind::BepisLoader => bepis_args(target_dir, profile, ctx)?,
LoaderKind::Umm => {
let script_injected = is_script_injected(target_dir, profile, ctx);
doorstop_args(
target_dir,
profile,
ctx,
DoorstopTarget::Umm,
script_injected,
)?
}
LoaderKind::MelonLoader | LoaderKind::RecursiveMelonLoader => {
melonloader_args(target_dir, ctx, &target)
}
LoaderKind::GdWeave => match ctx.mode {
LaunchMode::Modded => vec![format!(
"--gdweave-folder-override={}",
arg_path(target_dir, "GDWeave")
)],
LaunchMode::Vanilla => vec!["--gdweave-disable".to_string()],
},
LoaderKind::Lovely => match ctx.mode {
LaunchMode::Modded => vec!["--mod-dir".to_string(), arg_path(target_dir, "mods")],
LaunchMode::Vanilla => vec!["--vanilla".to_string()],
},
LoaderKind::Northstar => match ctx.mode {
LaunchMode::Modded => vec![
"-northstar".to_string(),
format!("-profile={}", arg_path(target_dir, "R2Northstar")),
],
LaunchMode::Vanilla => vec!["-vanilla".to_string()],
},
LoaderKind::GodotMl => match ctx.mode {
LaunchMode::Modded => vec![
"--script".to_string(),
"res://addons/mod_loader/mod_loader_setup.gd".to_string(),
"--enable-mods".to_string(),
"--mods-path".to_string(),
arg_path(target_dir, "mods"),
],
LaunchMode::Vanilla => Vec::new(),
},
LoaderKind::Shimloader => match ctx.mode {
LaunchMode::Modded => vec![
"--mod-dir".to_string(),
arg_path(target_dir, "shimloader/mod"),
"--pak-dir".to_string(),
arg_path(target_dir, "shimloader/pak"),
"--cfg-dir".to_string(),
arg_path(target_dir, "shimloader/cfg"),
"--overlay-dir".to_string(),
arg_path(target_dir, "shimloader/overlay"),
],
LaunchMode::Vanilla => Vec::new(),
},
LoaderKind::ReturnOfModding => match ctx.mode {
LaunchMode::Modded => vec!["--rom_modding_root_folder".to_string(), target.clone()],
LaunchMode::Vanilla => vec!["--rom_enabled".to_string(), "false".to_string()],
},
LoaderKind::Rivet => match ctx.mode {
LaunchMode::Modded => vec![
"-rivetEnable".to_string(),
"true".to_string(),
"-rivetTarget".to_string(),
arg_path(target_dir, "Rivet/Loader.dll"),
"-rivetDirectory".to_string(),
arg_path(target_dir, "Rivet/Mods"),
],
LaunchMode::Vanilla => vec!["-rivetEnable".to_string(), "false".to_string()],
},
_ => Vec::new(),
};
Ok(args)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DoorstopTarget {
BepInEx,
Umm,
}
fn doorstop_args(
target_dir: &Path,
profile: &GameProfile,
ctx: &LaunchContext,
target: DoorstopTarget,
script_injected: bool,
) -> Result<Vec<String>> {
let version = doorstop_version(target_dir);
let v4 = script_injected || version >= 4;
let (enable_flag, target_flag, search_flag) = if v4 {
(
"--doorstop-enabled",
"--doorstop-target-assembly",
"--doorstop-mono-dll-search-path-override",
)
} else {
(
"--doorstop-enable",
"--doorstop-target",
"--doorstop-dll-search-override",
)
};
if ctx.mode == LaunchMode::Vanilla {
return Ok(vec![enable_flag.to_string(), "false".to_string()]);
}
let assembly = match target {
DoorstopTarget::BepInEx => bepinex_preloader(target_dir, ctx.runtime)?,
DoorstopTarget::Umm => arg_path(target_dir, "UMM/Core/UnityModManager.dll"),
};
let mut args = vec![
enable_flag.to_string(),
"true".to_string(),
target_flag.to_string(),
assembly,
];
if target == DoorstopTarget::BepInEx && profile.instance_type == InstanceType::Server {
args.push("--server".to_string());
}
if has_corlibs(target_dir) {
args.push(search_flag.to_string());
args.push(arg_path(target_dir, "unstripped_corlib"));
}
Ok(args)
}
fn bepis_args(
target_dir: &Path,
profile: &GameProfile,
ctx: &LaunchContext,
) -> Result<Vec<String>> {
if ctx.mode == LaunchMode::Vanilla {
return Ok(vec![
"--hookfxr-disable".to_string(),
"--doorstop-enabled".to_string(),
"false".to_string(),
]);
}
let mut args = vec![
"--hookfxr-enable".to_string(),
"--bepinex-target".to_string(),
arg_path(target_dir, "BepInEx"),
"--doorstop-enabled".to_string(),
"true".to_string(),
"--doorstop-target-assembly".to_string(),
bepinex_preloader(target_dir, ctx.runtime)?,
];
if profile.instance_type == InstanceType::Server {
args.push("--server".to_string());
}
if has_corlibs(target_dir) {
args.push("--doorstop-mono-dll-search-path-override".to_string());
args.push(arg_path(target_dir, "unstripped_corlib"));
}
Ok(args)
}
fn melonloader_args(target_dir: &Path, ctx: &LaunchContext, target: &str) -> Vec<String> {
if ctx.mode == LaunchMode::Vanilla {
return vec!["--no-mods".to_string()];
}
let mut args = vec!["--melonloader.basedir".to_string(), target.to_string()];
let ml_five = target_dir
.join("MelonLoader/Managed/Assembly-CSharp.dll")
.is_file();
let ml_six = target_dir
.join("MelonLoader/Il2CppAssemblies/Assembly-CSharp.dll")
.is_file();
if !ml_five && !ml_six {
args.push("--melonloader.agfregenerate".to_string());
}
args
}
pub fn proxy_dll(loader: LoaderKind) -> &'static str {
match loader {
LoaderKind::GdWeave => "winmm",
_ => "winhttp",
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ScriptInjection {
NotApplicable,
Found(PathBuf),
Missing,
}
impl ScriptInjection {
fn applies(&self) -> bool {
!matches!(self, Self::NotApplicable)
}
}
fn script_candidates(profile: &GameProfile) -> Vec<&'static str> {
match profile.instance_type {
InstanceType::Server => LOADER_SCRIPTS.to_vec(),
_ => LOADER_SCRIPTS
.iter()
.copied()
.filter(|name| *name != SERVER_SCRIPT)
.collect(),
}
}
fn script_injection(
target_dir: &Path,
profile: &GameProfile,
ctx: &LaunchContext,
) -> ScriptInjection {
let script_loader = matches!(
profile.package_loader,
LoaderKind::BepInEx | LoaderKind::BepisLoader
);
if !script_loader || ctx.os != HostOs::Linux || ctx.runtime != Runtime::Native {
return ScriptInjection::NotApplicable;
}
let found = script_candidates(profile)
.into_iter()
.map(|name| target_dir.join(name))
.find(|candidate| candidate.is_file());
match found {
Some(path) => ScriptInjection::Found(path),
None => ScriptInjection::Missing,
}
}
fn is_script_injected(target_dir: &Path, profile: &GameProfile, ctx: &LaunchContext) -> bool {
matches!(
script_injection(target_dir, profile, ctx),
ScriptInjection::Found(_)
)
}
pub fn launch_plan_in(
target_dir: &Path,
eco: &Ecosystem,
game: &str,
ctx: &LaunchContext,
) -> Result<LaunchPlan> {
if ctx.os == HostOs::MacOs {
return Err(Error::Profile(MACOS_UNSUPPORTED.to_string()));
}
let profile = eco
.game(game)
.and_then(|g| g.profile())
.ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
let injection = script_injection(target_dir, profile, ctx);
let mut args = loader_args(target_dir, profile, ctx)?;
args.extend(ctx.extra_args.iter().cloned());
let program = resolve_program(&injection, target_dir, profile, ctx)?;
let needs_wrapper = injection.applies() && ctx.store == StorePlatform::Steam;
if needs_wrapper {
args.extend(wrapper_target_args(target_dir));
}
let steam_wrapper = needs_wrapper.then(|| SteamWrapper {
target_dir: target_dir.to_path_buf(),
loader: profile.package_loader,
});
let mut env = Vec::new();
if ctx.runtime == Runtime::Proton {
env.push((
"WINEDLLOVERRIDES".to_string(),
format!("{}=n,b", proxy_dll(profile.package_loader)),
));
}
Ok(LaunchPlan {
program,
args,
env,
working_dir: None,
steam_wrapper,
})
}
fn resolve_program(
injection: &ScriptInjection,
target_dir: &Path,
profile: &GameProfile,
ctx: &LaunchContext,
) -> Result<LaunchProgram> {
if *injection == ScriptInjection::Missing && ctx.mode == LaunchMode::Modded {
return Err(Error::Profile(format!(
"modded launch injects through a loader script, but none of {} exist in {}",
script_candidates(profile).join(", "),
target_dir.display(),
)));
}
if let ScriptInjection::Found(path) = injection
&& ctx.store != StorePlatform::Steam
{
return Ok(LaunchProgram::ProfileScript {
path: path.clone(),
exe_names: profile.exe_names.clone(),
});
}
if ctx.store == StorePlatform::Steam {
let steam_app_id = profile.steam_app_id().ok_or_else(|| {
Error::Profile("Steam launch requested but the game has no Steam app id".to_string())
})?;
return Ok(LaunchProgram::Steam { steam_app_id });
}
Ok(LaunchProgram::GameExe {
exe_names: profile.exe_names.clone(),
})
}
pub fn launch_plan(
base: &Path,
game: &str,
profile_name: &str,
eco: &Ecosystem,
ctx: &LaunchContext,
) -> Result<LaunchPlan> {
launch_plan_in(
&crate::profile::layout::profile_dir(base, game, profile_name),
eco,
game,
ctx,
)
}
pub const WRAPPER_TARGET_FLAG: &str = "--ts-target";
pub fn wrapper_target_args(target_dir: &Path) -> Vec<String> {
vec![
WRAPPER_TARGET_FLAG.to_string(),
target_dir.to_string_lossy().replace('\\', "/"),
]
}
pub fn wrapper_script_contents() -> String {
let mut branches = String::new();
for (index, name) in LOADER_SCRIPTS.iter().enumerate() {
let keyword = if index == 0 { "if" } else { "elif" };
let server_gate = if *name == SERVER_SCRIPT {
r#" && [ -n "$TS_START_SERVER" ]"#
} else {
""
};
branches.push_str(&format!(
"{keyword} [ -f \"$TARGET/{name}\" ]{server_gate}; then\n exec \"$TARGET/{name}\" \"$@\"\n"
));
}
format!(
r#"#!/bin/sh
# Generated by thunderstore-engine. Chainloads the mod loader for a target.
#
# Paste this into Steam's launch options once; it never needs regenerating, and
# it names no profile. The target directory is read from
# `{WRAPPER_TARGET_FLAG} <dir>` when a manager appends it to the launch
# arguments. Without it — launching straight from Steam — the game starts
# unmodded. The flag is consumed here and never reaches the game.
TARGET=""
remaining=$#
while [ "$remaining" -gt 0 ]; do
argument="$1"
shift
remaining=$((remaining - 1))
if [ "$argument" = "{WRAPPER_TARGET_FLAG}" ]; then
if [ "$remaining" -gt 0 ]; then
candidate="$1"
shift
remaining=$((remaining - 1))
if [ -d "$candidate" ]; then
TARGET="$candidate"
fi
fi
continue
fi
set -- "$@" "$argument"
done
if [ -z "$TARGET" ]; then
exec "$@"
fi
{branches}else
exec "$@"
fi
"#
)
}
pub fn write_wrapper_script(script_path: &Path) -> Result<()> {
if let Some(parent) = script_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| Error::Profile(format!("creating {}: {}", parent.display(), e)))?;
}
std::fs::write(script_path, wrapper_script_contents())
.map_err(|e| Error::Profile(format!("writing {}: {}", script_path.display(), e)))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let permissions = std::fs::Permissions::from_mode(0o755);
std::fs::set_permissions(script_path, permissions)
.map_err(|e| Error::Profile(format!("chmod {}: {}", script_path.display(), e)))?;
}
Ok(())
}
pub fn ensure_loader_scripts_executable(target_dir: &Path) -> Result<Vec<PathBuf>> {
let mut changed = Vec::new();
for name in LOADER_SCRIPTS {
let path = target_dir.join(name);
if !path.is_file() {
continue;
}
if make_executable(&path)? {
changed.push(path);
}
}
Ok(changed)
}
fn make_executable(path: &Path) -> Result<bool> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = std::fs::metadata(path)
.map_err(|e| Error::Profile(format!("inspecting {}: {}", path.display(), e)))?;
let mode = metadata.permissions().mode();
let wanted = mode | ((mode & 0o444) >> 2);
if wanted == mode {
return Ok(false);
}
std::fs::set_permissions(path, std::fs::Permissions::from_mode(wanted))
.map_err(|e| Error::Profile(format!("chmod {}: {}", path.display(), e)))?;
Ok(true)
}
#[cfg(not(unix))]
{
let _ = path;
Ok(false)
}
}
const SHIMLOADER_STAGED: [&str; 3] = ["ue4ss.dll", "dwmapi.dll", "ue4ss-settings.ini"];
pub fn stage_destination(
loader: LoaderKind,
file_name: &str,
game_dir: &Path,
data_folder: Option<&str>,
) -> Option<PathBuf> {
let lowercased = file_name.to_ascii_lowercase();
if lowercased == "mods.yml" {
return None;
}
match loader {
LoaderKind::Shimloader => {
if !SHIMLOADER_STAGED.iter().any(|name| *name == lowercased) {
return None;
}
let data = data_folder?;
Some(
game_dir
.join(data)
.join("Binaries")
.join("Win64")
.join(file_name),
)
}
LoaderKind::Rivet => {
if lowercased != "version.dll" {
return None;
}
Some(game_dir.join("Release").join(file_name))
}
_ => Some(game_dir.join(file_name)),
}
}
pub fn stage_for_launch_in(
target_dir: &Path,
eco: &Ecosystem,
game: &str,
game_dir: &Path,
ctx: &LaunchContext,
) -> Result<Vec<PathBuf>> {
if ctx.os == HostOs::MacOs {
return Err(Error::Profile(MACOS_UNSUPPORTED.to_string()));
}
let profile = eco
.game(game)
.and_then(|g| g.profile())
.ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
if script_injection(target_dir, profile, ctx).applies() {
return Ok(Vec::new());
}
let mut written = Vec::new();
for entry in std::fs::read_dir(target_dir)
.map_err(|e| Error::Profile(format!("reading {}: {}", target_dir.display(), e)))?
{
let entry = entry.map_err(|e| Error::Profile(format!("reading an entry: {e}")))?;
let metadata = std::fs::symlink_metadata(entry.path())
.map_err(|e| Error::Profile(format!("inspecting {}: {}", entry.path().display(), e)))?;
if !metadata.is_file() {
continue;
}
let file_name = match entry.file_name().to_str() {
Some(name) => name.to_string(),
None => continue,
};
let dest = match stage_destination(
profile.package_loader,
&file_name,
game_dir,
profile.data_folder_name.as_deref(),
) {
Some(dest) => dest,
None => continue,
};
let staged = stage_one_file(&entry.path(), &dest)?;
if staged {
written.push(dest);
}
}
written.sort();
Ok(written)
}
pub fn stage_for_launch(
base: &Path,
game: &str,
profile_name: &str,
eco: &Ecosystem,
game_dir: &Path,
ctx: &LaunchContext,
) -> Result<Vec<PathBuf>> {
stage_for_launch_in(
&crate::profile::layout::profile_dir(base, game, profile_name),
eco,
game,
game_dir,
ctx,
)
}
fn stage_one_file(source: &Path, dest: &Path) -> Result<bool> {
let dest_is_symlink = std::fs::symlink_metadata(dest)
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false);
if dest_is_symlink {
return Err(Error::Profile(format!(
"refusing to stage through a symlink at {}",
dest.display()
)));
}
if crate::util::same_file(source, dest) {
return Ok(false);
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| Error::Profile(format!("creating {}: {}", parent.display(), e)))?;
}
std::fs::copy(source, dest)
.map_err(|e| Error::Profile(format!("staging {}: {}", dest.display(), e)))?;
Ok(true)
}
pub fn steam_launch_options(script_path: &Path) -> String {
format!(
r#""{script}" %command%"#,
script = script_path.to_string_lossy().replace('\\', "/"),
)
}
pub fn resolve_game_exe(game_dir: &Path, exe_names: &[String]) -> Result<PathBuf> {
exe_names
.iter()
.map(|name| game_dir.join(name))
.find(|candidate| candidate.is_file())
.ok_or_else(|| {
Error::Profile(format!(
"no game executable found in {}: none of {} exist",
game_dir.display(),
exe_names.join(", ")
))
})
}
pub fn wrapper_path(base: &Path, game: &str) -> PathBuf {
base.join(game).join("steam-wrapper.sh")
}
fn steam_command(program: &Path, steam_app_id: u32) -> std::process::Command {
let mut command = std::process::Command::new(program);
command.arg("-applaunch").arg(steam_app_id.to_string());
command
}
impl LaunchPlan {
pub fn to_command(&self, game_dir: &Path) -> Result<std::process::Command> {
let mut command = match &self.program {
LaunchProgram::GameExe { exe_names } => {
std::process::Command::new(resolve_game_exe(game_dir, exe_names)?)
}
LaunchProgram::Steam { steam_app_id } => {
steam_command(&crate::steam::steam_exe(), *steam_app_id)
}
LaunchProgram::ProfileScript { path, exe_names } => {
let mut command = std::process::Command::new(path);
command.arg(resolve_game_exe(game_dir, exe_names)?);
command
}
};
command.args(&self.args);
for (key, value) in &self.env {
command.env(key, value);
}
if let Some(dir) = &self.working_dir {
command.current_dir(dir);
}
Ok(command)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn doorstop_version_reads_major_and_defaults_to_three() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
assert_eq!(doorstop_version(root), 3);
std::fs::write(root.join(".doorstop_version"), "4.0.0").unwrap();
assert_eq!(doorstop_version(root), 4);
std::fs::write(root.join(".doorstop_version"), "3.7.1").unwrap();
assert_eq!(doorstop_version(root), 3);
std::fs::write(root.join(".doorstop_version"), "5.1.0").unwrap();
assert_eq!(doorstop_version(root), 5);
std::fs::write(root.join(".doorstop_version"), "garbage").unwrap();
assert_eq!(doorstop_version(root), 3);
}
#[test]
fn bepinex_preloader_finds_candidate_and_prefixes_under_proton() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let core = root.join("BepInEx/core");
std::fs::create_dir_all(&core).unwrap();
std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
std::fs::write(core.join("Unrelated.dll"), b"x").unwrap();
let native = bepinex_preloader(root, Runtime::Native).unwrap();
assert!(native.ends_with("BepInEx/core/BepInEx.Preloader.dll"));
assert!(!native.starts_with("Z:"));
let proton = bepinex_preloader(root, Runtime::Proton).unwrap();
assert!(proton.starts_with("Z:"));
assert!(proton.ends_with("BepInEx/core/BepInEx.Preloader.dll"));
}
#[test]
fn bepinex_preloader_errors_when_absent() {
let dir = tempfile::tempdir().unwrap();
assert!(bepinex_preloader(dir.path(), Runtime::Native).is_err());
}
#[test]
fn has_corlibs_detects_the_directory() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
assert!(!has_corlibs(root));
std::fs::create_dir_all(root.join("unstripped_corlib")).unwrap();
assert!(has_corlibs(root));
}
fn ctx(mode: LaunchMode) -> LaunchContext {
LaunchContext {
os: HostOs::Windows,
store: StorePlatform::Steam,
runtime: Runtime::Native,
mode,
extra_args: Vec::new(),
}
}
fn profile_with(loader: LoaderKind) -> GameProfile {
let eco = Ecosystem::bundled();
let mut profile = eco.game("valheim").unwrap().profile().unwrap().clone();
profile.package_loader = loader;
profile
}
#[test]
fn bepinex_v3_and_v4_use_the_right_flag_spellings() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let core = root.join("BepInEx/core");
std::fs::create_dir_all(&core).unwrap();
std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
let profile = profile_with(LoaderKind::BepInEx);
let v3 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
assert_eq!(v3[0], "--doorstop-enable");
assert_eq!(v3[1], "true");
assert_eq!(v3[2], "--doorstop-target");
std::fs::write(root.join(".doorstop_version"), "4.0.0").unwrap();
let v4 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
assert_eq!(v4[0], "--doorstop-enabled");
assert_eq!(v4[2], "--doorstop-target-assembly");
let vanilla = loader_args(root, &profile, &ctx(LaunchMode::Vanilla)).unwrap();
assert_eq!(
vanilla,
vec!["--doorstop-enabled".to_string(), "false".to_string()]
);
}
#[test]
fn melonloader_adds_regenerate_only_when_assemblies_are_absent() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let profile = profile_with(LoaderKind::MelonLoader);
let bare = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
assert_eq!(bare[0], "--melonloader.basedir");
assert!(bare.contains(&"--melonloader.agfregenerate".to_string()));
let managed = root.join("MelonLoader/Managed");
std::fs::create_dir_all(&managed).unwrap();
std::fs::write(managed.join("Assembly-CSharp.dll"), b"x").unwrap();
let with_assembly = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
assert!(!with_assembly.contains(&"--melonloader.agfregenerate".to_string()));
assert_eq!(
loader_args(root, &profile, &ctx(LaunchMode::Vanilla)).unwrap(),
vec!["--no-mods".to_string()]
);
}
#[test]
fn single_element_flags_stay_single_elements() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let gdweave = loader_args(
root,
&profile_with(LoaderKind::GdWeave),
&ctx(LaunchMode::Modded),
)
.unwrap();
assert_eq!(gdweave.len(), 1);
assert!(gdweave[0].starts_with("--gdweave-folder-override="));
assert!(gdweave[0].ends_with("/GDWeave"));
let northstar = loader_args(
root,
&profile_with(LoaderKind::Northstar),
&ctx(LaunchMode::Modded),
)
.unwrap();
assert_eq!(northstar[0], "-northstar");
assert!(northstar[1].starts_with("-profile="));
}
#[test]
fn shimloader_and_rivet_and_rom_shapes() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let shim = loader_args(
root,
&profile_with(LoaderKind::Shimloader),
&ctx(LaunchMode::Modded),
)
.unwrap();
assert_eq!(shim.len(), 8);
assert_eq!(shim[0], "--mod-dir");
assert!(shim.contains(&"--overlay-dir".to_string()));
let rivet = loader_args(
root,
&profile_with(LoaderKind::Rivet),
&ctx(LaunchMode::Modded),
)
.unwrap();
assert_eq!(rivet[0], "-rivetEnable");
assert_eq!(rivet[1], "true");
assert!(rivet.contains(&"-rivetDirectory".to_string()));
assert_eq!(
loader_args(
root,
&profile_with(LoaderKind::Rivet),
&ctx(LaunchMode::Vanilla)
)
.unwrap(),
vec!["-rivetEnable".to_string(), "false".to_string()]
);
let rom = loader_args(
root,
&profile_with(LoaderKind::ReturnOfModding),
&ctx(LaunchMode::Modded),
)
.unwrap();
assert_eq!(rom[0], "--rom_modding_root_folder");
}
#[test]
fn unknown_and_none_loaders_produce_no_args() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
assert!(
loader_args(
root,
&profile_with(LoaderKind::None),
&ctx(LaunchMode::Modded)
)
.unwrap()
.is_empty()
);
assert!(
loader_args(
root,
&profile_with(LoaderKind::Other),
&ctx(LaunchMode::Modded)
)
.unwrap()
.is_empty()
);
}
#[test]
fn server_instance_appends_server_flag_for_bepinex() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let core = root.join("BepInEx/core");
std::fs::create_dir_all(&core).unwrap();
std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
let mut profile = profile_with(LoaderKind::BepInEx);
profile.instance_type = InstanceType::Server;
let args = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
assert!(args.contains(&"--server".to_string()));
}
#[test]
fn umm_uses_doorstop_flags_and_never_gets_server_flag() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let profile = profile_with(LoaderKind::Umm);
let v3 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
assert_eq!(v3[0], "--doorstop-enable");
assert_eq!(v3[1], "true");
assert_eq!(v3[2], "--doorstop-target");
assert!(v3[3].ends_with("UMM/Core/UnityModManager.dll"));
std::fs::write(root.join(".doorstop_version"), "4.0.0").unwrap();
let v4 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
assert_eq!(v4[0], "--doorstop-enabled");
assert_eq!(v4[2], "--doorstop-target-assembly");
assert!(v4[3].ends_with("UMM/Core/UnityModManager.dll"));
let vanilla = loader_args(root, &profile, &ctx(LaunchMode::Vanilla)).unwrap();
assert_eq!(
vanilla,
vec!["--doorstop-enabled".to_string(), "false".to_string()]
);
let mut server_profile = profile_with(LoaderKind::Umm);
server_profile.instance_type = InstanceType::Server;
let server_args = loader_args(root, &server_profile, &ctx(LaunchMode::Modded)).unwrap();
assert!(!server_args.contains(&"--server".to_string()));
}
#[test]
fn bepis_loader_modded_and_vanilla_shapes() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let core = root.join("BepInEx/core");
std::fs::create_dir_all(&core).unwrap();
std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
let profile = profile_with(LoaderKind::BepisLoader);
let modded = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
assert_eq!(modded[0], "--hookfxr-enable");
assert_eq!(modded[1], "--bepinex-target");
assert!(modded.contains(&"--doorstop-enabled".to_string()));
assert!(modded.contains(&"--doorstop-target-assembly".to_string()));
let vanilla = loader_args(root, &profile, &ctx(LaunchMode::Vanilla)).unwrap();
assert_eq!(
vanilla,
vec![
"--hookfxr-disable".to_string(),
"--doorstop-enabled".to_string(),
"false".to_string()
]
);
}
#[test]
fn lovely_modded_and_vanilla_shapes() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let modded = loader_args(
root,
&profile_with(LoaderKind::Lovely),
&ctx(LaunchMode::Modded),
)
.unwrap();
assert_eq!(modded[0], "--mod-dir");
assert!(modded[1].ends_with("/mods"));
let vanilla = loader_args(
root,
&profile_with(LoaderKind::Lovely),
&ctx(LaunchMode::Vanilla),
)
.unwrap();
assert_eq!(vanilla, vec!["--vanilla".to_string()]);
}
#[test]
fn godot_ml_modded_and_vanilla_shapes() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let modded = loader_args(
root,
&profile_with(LoaderKind::GodotMl),
&ctx(LaunchMode::Modded),
)
.unwrap();
assert_eq!(modded[0], "--script");
assert_eq!(modded[1], "res://addons/mod_loader/mod_loader_setup.gd");
assert!(modded.contains(&"--mods-path".to_string()));
let vanilla = loader_args(
root,
&profile_with(LoaderKind::GodotMl),
&ctx(LaunchMode::Vanilla),
)
.unwrap();
assert!(vanilla.is_empty());
}
#[test]
fn corlib_override_appends_search_flag_pair_matching_version() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let core = root.join("BepInEx/core");
std::fs::create_dir_all(&core).unwrap();
std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
std::fs::create_dir_all(root.join("unstripped_corlib")).unwrap();
let profile = profile_with(LoaderKind::BepInEx);
let v3 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
let v3_idx = v3
.iter()
.position(|a| a == "--doorstop-dll-search-override")
.unwrap();
assert!(v3[v3_idx + 1].ends_with("/unstripped_corlib"));
std::fs::write(root.join(".doorstop_version"), "4.0.0").unwrap();
let v4 = loader_args(root, &profile, &ctx(LaunchMode::Modded)).unwrap();
let v4_idx = v4
.iter()
.position(|a| a == "--doorstop-mono-dll-search-path-override")
.unwrap();
assert!(v4[v4_idx + 1].ends_with("/unstripped_corlib"));
std::fs::remove_file(root.join(".doorstop_version")).unwrap();
let bepis = loader_args(
root,
&profile_with(LoaderKind::BepisLoader),
&ctx(LaunchMode::Modded),
)
.unwrap();
let bepis_idx = bepis
.iter()
.position(|a| a == "--doorstop-mono-dll-search-path-override")
.unwrap();
assert!(bepis[bepis_idx + 1].ends_with("/unstripped_corlib"));
}
#[test]
fn windows_steam_plan_targets_steam_with_loader_args() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let core = root.join("BepInEx/core");
std::fs::create_dir_all(&core).unwrap();
std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
let eco = Ecosystem::bundled();
let mut context = ctx(LaunchMode::Modded);
context.extra_args = vec!["-nolog".to_string()];
let plan = launch_plan_in(root, &eco, "valheim", &context).unwrap();
match &plan.program {
LaunchProgram::Steam { steam_app_id } => assert_eq!(*steam_app_id, 892970),
other => panic!("expected Steam, got {other:?}"),
}
assert_eq!(plan.args[0], "--doorstop-enable");
assert_eq!(plan.args.last().unwrap(), "-nolog");
assert!(plan.env.is_empty());
assert_eq!(plan.steam_wrapper, None);
}
#[test]
fn proton_sets_wine_dll_overrides_and_prefixes_the_preloader() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let core = root.join("BepInEx/core");
std::fs::create_dir_all(&core).unwrap();
std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
let eco = Ecosystem::bundled();
let mut context = ctx(LaunchMode::Modded);
context.os = HostOs::Linux;
context.runtime = Runtime::Proton;
let plan = launch_plan_in(root, &eco, "valheim", &context).unwrap();
assert_eq!(
plan.env,
vec![("WINEDLLOVERRIDES".to_string(), "winhttp=n,b".to_string())]
);
assert!(plan.args.iter().any(|a| a.starts_with("Z:")));
assert_eq!(plan.steam_wrapper, None);
}
#[test]
fn linux_native_bepinex_runs_the_profile_script() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let core = root.join("BepInEx/core");
std::fs::create_dir_all(&core).unwrap();
std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
std::fs::write(root.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
let eco = Ecosystem::bundled();
let mut context = ctx(LaunchMode::Modded);
context.os = HostOs::Linux;
context.store = StorePlatform::SteamDirect;
context.runtime = Runtime::Native;
let plan = launch_plan_in(root, &eco, "valheim", &context).unwrap();
match &plan.program {
LaunchProgram::ProfileScript { path, exe_names } => {
assert!(path.ends_with("run_bepinex.sh"));
assert_eq!(
*exe_names,
eco.game("valheim").unwrap().profile().unwrap().exe_names
);
assert!(!exe_names.is_empty());
}
other => panic!("expected ProfileScript, got {other:?}"),
}
assert_eq!(plan.steam_wrapper, None);
}
#[test]
fn macos_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let core = root.join("BepInEx/core");
std::fs::create_dir_all(&core).unwrap();
std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
let eco = Ecosystem::bundled();
let mut context = ctx(LaunchMode::Modded);
context.os = HostOs::MacOs;
let err = launch_plan_in(root, &eco, "valheim", &context).unwrap_err();
assert!(err.to_string().contains("macOS launch is not supported"));
context.os = HostOs::Windows;
assert!(launch_plan_in(root, &eco, "valheim", &context).is_ok());
}
#[test]
fn unknown_game_errors() {
let dir = tempfile::tempdir().unwrap();
let eco = Ecosystem::bundled();
let err = launch_plan_in(dir.path(), &eco, "not-a-game", &ctx(LaunchMode::Modded)).unwrap_err();
assert!(err.to_string().contains("no ecosystem profile"));
}
#[test]
fn proxy_dll_is_winmm_for_gdweave() {
assert_eq!(proxy_dll(LoaderKind::GdWeave), "winmm");
assert_eq!(proxy_dll(LoaderKind::BepInEx), "winhttp");
}
#[test]
fn wrapper_script_names_no_target_and_execs_the_loader() {
let script = wrapper_script_contents();
assert!(script.starts_with("#!/bin/sh"));
assert!(script.contains(r#"TARGET="""#));
assert!(script.contains(r#"[ -z "$TARGET" ]"#));
assert!(script.contains(&format!(r#"[ "$argument" = "{WRAPPER_TARGET_FLAG}" ]"#)));
assert!(script.contains(r#"[ -d "$candidate" ]"#));
assert!(!script.contains("eval"));
let pos_server = script
.find("start_server_bepinex.sh")
.expect("server branch missing");
let pos_run = script.find("run_bepinex.sh").expect("run branch missing");
let pos_game = script
.find("start_game_bepinex.sh")
.expect("game branch missing");
assert!(pos_server < pos_run, "server branch must come before run");
assert!(pos_run < pos_game, "run branch must come before game");
assert!(script.contains("$TS_START_SERVER"));
assert!(script.contains("else"));
assert!(script.contains("fi"));
assert!(script.contains("\"$@\""));
}
#[test]
fn write_wrapper_script_creates_an_executable_file() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let script_path = root.join("launch_wrapper.sh");
write_wrapper_script(&script_path).unwrap();
assert!(script_path.is_file());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&script_path)
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o111, 0o111);
}
}
#[test]
#[cfg(unix)]
fn ensure_loader_scripts_executable_adds_the_bit_and_reports_what_changed() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let game = root.join("start_game_bepinex.sh");
let server = root.join("start_server_bepinex.sh");
std::fs::write(&game, b"#!/bin/sh\n").unwrap();
std::fs::write(&server, b"#!/bin/sh\n").unwrap();
std::fs::set_permissions(&game, std::fs::Permissions::from_mode(0o644)).unwrap();
std::fs::set_permissions(&server, std::fs::Permissions::from_mode(0o644)).unwrap();
let changed = ensure_loader_scripts_executable(root).unwrap();
assert_eq!(changed.len(), 2, "got: {changed:?}");
for name in ["start_game_bepinex.sh", "start_server_bepinex.sh"] {
let mode = std::fs::metadata(root.join(name))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o755, "{name} got mode {mode:o}");
}
assert!(ensure_loader_scripts_executable(root).unwrap().is_empty());
}
#[test]
#[cfg(unix)]
fn ensure_loader_scripts_executable_mirrors_read_access() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let script = root.join("run_bepinex.sh");
std::fs::write(&script, b"#!/bin/sh\n").unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o640)).unwrap();
ensure_loader_scripts_executable(root).unwrap();
let mode = std::fs::metadata(&script).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o750, "got mode {mode:o}");
}
#[test]
fn ensure_loader_scripts_executable_ignores_a_target_with_no_scripts() {
let dir = tempfile::tempdir().unwrap();
assert!(
ensure_loader_scripts_executable(dir.path())
.unwrap()
.is_empty()
);
}
#[test]
fn wrapper_target_args_name_the_flag_and_the_directory() {
assert_eq!(
wrapper_target_args(Path::new("/data/profiles/Main")),
vec![
WRAPPER_TARGET_FLAG.to_string(),
"/data/profiles/Main".to_string()
]
);
}
#[cfg(unix)]
fn make_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
#[cfg(unix)]
fn recording_loader(path: &Path, log: &Path) {
let contents = format!(
"#!/bin/sh\nprintf '%s\\n' \"$0\" > \"{log}\"\nfor argument in \"$@\"; do\n printf '%s\\n' \"$argument\" >> \"{log}\"\ndone\n",
log = log.display()
);
std::fs::write(path, contents).unwrap();
make_executable(path);
}
#[cfg(unix)]
fn recorded(log: &Path) -> Vec<String> {
std::fs::read_to_string(log)
.unwrap()
.lines()
.map(str::to_string)
.collect()
}
#[cfg(unix)]
fn run_wrapper(wrapper: &Path, args: &[&str], server: bool) {
let mut command = std::process::Command::new(wrapper);
command.args(args);
if server {
command.env("TS_START_SERVER", "1");
} else {
command.env_remove("TS_START_SERVER");
}
let output = command.output().unwrap();
assert!(
output.status.success(),
"wrapper failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[cfg(unix)]
#[test]
fn wrapper_chainloads_the_target_given_at_run_time_and_consumes_only_the_flag() {
let dir = tempfile::tempdir().unwrap();
let fallback = dir.path().join("fallback");
let selected = dir.path().join("My Profile 2");
let log = dir.path().join("argv.log");
let wrapper = dir.path().join("steam-wrapper.sh");
std::fs::create_dir_all(&fallback).unwrap();
std::fs::create_dir_all(&selected).unwrap();
recording_loader(&selected.join("run_bepinex.sh"), &log);
write_wrapper_script(&wrapper).unwrap();
let target = selected.to_string_lossy().to_string();
run_wrapper(
&wrapper,
&[
"/games/valheim/valheim.x86_64",
"--doorstop-enabled",
"true",
WRAPPER_TARGET_FLAG,
&target,
"-name",
"a server with spaces",
],
false,
);
let lines = recorded(&log);
assert_eq!(lines[0], selected.join("run_bepinex.sh").to_string_lossy());
assert_eq!(
&lines[1..],
[
"/games/valheim/valheim.x86_64",
"--doorstop-enabled",
"true",
"-name",
"a server with spaces",
]
);
}
#[cfg(unix)]
#[test]
fn wrapper_without_the_flag_launches_the_game_unmodded() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("argv.log");
let wrapper = dir.path().join("steam-wrapper.sh");
let game_exe = dir.path().join("valheim.x86_64");
let unrequested = dir.path().join("profile");
let unrequested_log = dir.path().join("must-not-run.log");
std::fs::create_dir_all(&unrequested).unwrap();
recording_loader(&unrequested.join("run_bepinex.sh"), &unrequested_log);
recording_loader(&game_exe, &log);
write_wrapper_script(&wrapper).unwrap();
run_wrapper(&wrapper, &[&game_exe.to_string_lossy()], false);
assert!(
!unrequested_log.exists(),
"a bare launch must not load any profile's mods"
);
let lines = recorded(&log);
assert_eq!(lines[0], game_exe.to_string_lossy());
assert_eq!(lines.len(), 1, "the game got extra arguments: {lines:?}");
}
#[cfg(unix)]
#[test]
fn wrapper_launches_unmodded_when_the_flags_value_is_not_a_directory() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("argv.log");
let wrapper = dir.path().join("steam-wrapper.sh");
let game_exe = dir.path().join("valheim.x86_64");
recording_loader(&game_exe, &log);
write_wrapper_script(&wrapper).unwrap();
let missing = dir.path().join("deleted-profile");
run_wrapper(
&wrapper,
&[
&game_exe.to_string_lossy(),
WRAPPER_TARGET_FLAG,
&missing.to_string_lossy(),
],
false,
);
let lines = recorded(&log);
assert_eq!(lines[0], game_exe.to_string_lossy());
assert_eq!(lines.len(), 1, "the game got extra arguments: {lines:?}");
}
#[cfg(unix)]
#[test]
fn wrapper_consumes_a_trailing_flag_that_has_no_value() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("argv.log");
let wrapper = dir.path().join("steam-wrapper.sh");
let game_exe = dir.path().join("valheim.x86_64");
recording_loader(&game_exe, &log);
write_wrapper_script(&wrapper).unwrap();
run_wrapper(
&wrapper,
&[&game_exe.to_string_lossy(), WRAPPER_TARGET_FLAG],
false,
);
let lines = recorded(&log);
assert_eq!(lines[0], game_exe.to_string_lossy());
assert_eq!(lines.len(), 1, "the game got extra arguments: {lines:?}");
}
#[cfg(unix)]
#[test]
fn wrapper_execs_the_game_directly_when_the_target_ships_no_loader_script() {
let dir = tempfile::tempdir().unwrap();
let selected = dir.path().join("profile");
let log = dir.path().join("argv.log");
let wrapper = dir.path().join("steam-wrapper.sh");
let game_exe = dir.path().join("valheim.x86_64");
std::fs::create_dir_all(&selected).unwrap();
recording_loader(&game_exe, &log);
write_wrapper_script(&wrapper).unwrap();
run_wrapper(
&wrapper,
&[
&game_exe.to_string_lossy(),
WRAPPER_TARGET_FLAG,
&selected.to_string_lossy(),
"-window-mode",
],
false,
);
let lines = recorded(&log);
assert_eq!(lines[0], game_exe.to_string_lossy());
assert_eq!(&lines[1..], ["-window-mode"]);
}
#[cfg(unix)]
#[test]
fn wrapper_selects_the_server_script_only_when_ts_start_server_is_set() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
let server_log = dir.path().join("server.log");
let game_log = dir.path().join("game.log");
let wrapper = dir.path().join("steam-wrapper.sh");
std::fs::create_dir_all(&target).unwrap();
recording_loader(&target.join(SERVER_SCRIPT), &server_log);
recording_loader(&target.join("run_bepinex.sh"), &game_log);
write_wrapper_script(&wrapper).unwrap();
let selected = target.to_string_lossy().to_string();
let argv = [
"/games/valheim/valheim.x86_64",
WRAPPER_TARGET_FLAG,
&selected,
];
run_wrapper(&wrapper, &argv, false);
assert!(
!server_log.exists(),
"the server script must not run for a game instance"
);
assert_eq!(
recorded(&game_log)[0],
target.join("run_bepinex.sh").to_string_lossy()
);
run_wrapper(&wrapper, &argv, true);
assert_eq!(
recorded(&server_log)[0],
target.join(SERVER_SCRIPT).to_string_lossy()
);
}
#[test]
fn steam_launch_options_are_only_the_wrapper_and_command_placeholder() {
let options = steam_launch_options(Path::new("/data/launch_wrapper.sh"));
assert_eq!(options, r#""/data/launch_wrapper.sh" %command%"#);
assert!(!options.contains("WINEDLLOVERRIDES"));
assert!(!options.contains("winhttp"));
assert_eq!(
steam_launch_options(Path::new("/data/My Games/wrapper.sh")),
r#""/data/My Games/wrapper.sh" %command%"#
);
}
#[test]
fn stage_destination_routes_per_loader() {
let game = Path::new("/game");
assert_eq!(
stage_destination(LoaderKind::BepInEx, "winhttp.dll", game, None),
Some(PathBuf::from("/game/winhttp.dll"))
);
assert_eq!(
stage_destination(LoaderKind::BepInEx, "mods.yml", game, None),
None
);
assert_eq!(
stage_destination(LoaderKind::Shimloader, "ue4ss.dll", game, Some("Palworld")),
Some(PathBuf::from("/game/Palworld/Binaries/Win64/ue4ss.dll"))
);
assert_eq!(
stage_destination(LoaderKind::Shimloader, "other.dll", game, Some("Palworld")),
None
);
assert_eq!(
stage_destination(LoaderKind::Shimloader, "ue4ss.dll", game, None),
None
);
assert_eq!(
stage_destination(LoaderKind::Rivet, "version.dll", game, None),
Some(PathBuf::from("/game/Release/version.dll"))
);
assert_eq!(
stage_destination(LoaderKind::Rivet, "winhttp.dll", game, None),
None
);
}
#[test]
fn stage_for_launch_copies_root_files_only() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
let game_dir = dir.path().join("game");
std::fs::create_dir_all(target.join("BepInEx/core")).unwrap();
std::fs::create_dir_all(&game_dir).unwrap();
std::fs::write(target.join("winhttp.dll"), b"proxy").unwrap();
std::fs::write(target.join("doorstop_config.ini"), b"[UnityDoorstop]").unwrap();
std::fs::write(target.join("mods.yml"), b"[]").unwrap();
std::fs::write(target.join("BepInEx/core/BepInEx.Preloader.dll"), b"x").unwrap();
let eco = Ecosystem::bundled();
let written = stage_for_launch_in(
&target,
&eco,
"valheim",
&game_dir,
&ctx(LaunchMode::Modded),
)
.unwrap();
assert!(game_dir.join("winhttp.dll").exists());
assert!(game_dir.join("doorstop_config.ini").exists());
assert!(!game_dir.join("mods.yml").exists());
assert!(!game_dir.join("BepInEx").exists());
assert_eq!(written.len(), 2);
assert!(written.contains(&game_dir.join("winhttp.dll")));
assert!(written.contains(&game_dir.join("doorstop_config.ini")));
let mut sorted = written.clone();
sorted.sort();
assert_eq!(written, sorted);
}
#[test]
fn stage_for_launch_stages_shimloader_files_into_nested_binaries_dir() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
let game_dir = dir.path().join("game");
std::fs::create_dir_all(&target).unwrap();
std::fs::create_dir_all(&game_dir).unwrap();
std::fs::write(target.join("ue4ss.dll"), b"loader").unwrap();
std::fs::write(target.join("dwmapi.dll"), b"proxy").unwrap();
std::fs::write(target.join("ue4ss-settings.ini"), b"[UE4SS]").unwrap();
std::fs::write(target.join("other.dll"), b"unrelated").unwrap();
let eco = Ecosystem::bundled();
let written = stage_for_launch_in(
&target,
&eco,
"palworld",
&game_dir,
&ctx(LaunchMode::Modded),
)
.unwrap();
let nested = game_dir.join("Pal").join("Binaries").join("Win64");
assert_eq!(std::fs::read(nested.join("ue4ss.dll")).unwrap(), b"loader");
assert!(nested.join("dwmapi.dll").exists());
assert!(nested.join("ue4ss-settings.ini").exists());
assert!(!game_dir.join("other.dll").exists());
assert!(!nested.join("other.dll").exists());
assert_eq!(written.len(), 3);
assert!(written.contains(&nested.join("ue4ss.dll")));
assert!(written.contains(&nested.join("dwmapi.dll")));
assert!(written.contains(&nested.join("ue4ss-settings.ini")));
let mut sorted = written.clone();
sorted.sort();
assert_eq!(written, sorted);
}
#[test]
fn stage_for_launch_stages_rivet_version_dll_into_release_dir() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
let game_dir = dir.path().join("game");
std::fs::create_dir_all(&target).unwrap();
std::fs::create_dir_all(&game_dir).unwrap();
std::fs::write(target.join("version.dll"), b"rivet-loader").unwrap();
std::fs::write(target.join("other.dll"), b"unrelated").unwrap();
let eco = Ecosystem::bundled();
let written = stage_for_launch_in(
&target,
&eco,
"scrap-mechanic",
&game_dir,
&ctx(LaunchMode::Modded),
)
.unwrap();
let dest = game_dir.join("Release").join("version.dll");
assert_eq!(std::fs::read(&dest).unwrap(), b"rivet-loader");
assert!(!game_dir.join("other.dll").exists());
assert!(!game_dir.join("Release").join("other.dll").exists());
assert_eq!(written, vec![dest]);
}
#[test]
fn stage_for_launch_skips_linux_native_bepinex() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
let game_dir = dir.path().join("game");
std::fs::create_dir_all(&target).unwrap();
std::fs::create_dir_all(&game_dir).unwrap();
std::fs::write(target.join("winhttp.dll"), b"proxy").unwrap();
let eco = Ecosystem::bundled();
let mut context = ctx(LaunchMode::Modded);
context.os = HostOs::Linux;
context.runtime = Runtime::Native;
let written = stage_for_launch_in(&target, &eco, "valheim", &game_dir, &context).unwrap();
assert!(written.is_empty());
assert!(!game_dir.join("winhttp.dll").exists());
}
#[cfg(unix)]
#[test]
fn stage_for_launch_refuses_to_write_through_a_symlink() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
let game_dir = dir.path().join("game");
let outside = dir.path().join("secret.dll");
std::fs::create_dir_all(&target).unwrap();
std::fs::create_dir_all(&game_dir).unwrap();
std::fs::write(&outside, b"original").unwrap();
std::fs::write(target.join("winhttp.dll"), b"proxy").unwrap();
symlink(&outside, game_dir.join("winhttp.dll")).unwrap();
let eco = Ecosystem::bundled();
assert!(
stage_for_launch_in(
&target,
&eco,
"valheim",
&game_dir,
&ctx(LaunchMode::Modded)
)
.is_err()
);
assert_eq!(std::fs::read(&outside).unwrap(), b"original");
}
#[test]
fn stage_for_launch_in_place_leaves_root_files_byte_for_byte_untouched() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("game");
std::fs::create_dir_all(root.join("BepInEx/core")).unwrap();
let winhttp_contents = b"MZ-fake-proxy-dll-18b";
let ini_contents = b"[UnityDoorstop]\nenabled=true\n";
std::fs::write(root.join("winhttp.dll"), winhttp_contents).unwrap();
std::fs::write(root.join("doorstop_config.ini"), ini_contents).unwrap();
std::fs::write(root.join("mods.yml"), b"[]").unwrap();
std::fs::write(root.join("BepInEx/core/BepInEx.Preloader.dll"), b"x").unwrap();
let eco = Ecosystem::bundled();
let written =
stage_for_launch_in(&root, &eco, "valheim", &root, &ctx(LaunchMode::Modded)).unwrap();
assert_eq!(
std::fs::read(root.join("winhttp.dll")).unwrap(),
winhttp_contents
);
assert_eq!(
std::fs::read(root.join("doorstop_config.ini")).unwrap(),
ini_contents
);
assert!(written.is_empty());
}
#[test]
fn stage_for_launch_in_place_survives_a_dot_dot_aliased_game_dir() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
std::fs::create_dir_all(target.join("BepInEx/core")).unwrap();
let contents = b"proxy-bytes-not-truncated";
std::fs::write(target.join("winhttp.dll"), contents).unwrap();
let aliased_game_dir = target.join("BepInEx").join("..");
let eco = Ecosystem::bundled();
let written = stage_for_launch_in(
&target,
&eco,
"valheim",
&aliased_game_dir,
&ctx(LaunchMode::Modded),
)
.unwrap();
assert_eq!(std::fs::read(target.join("winhttp.dll")).unwrap(), contents);
assert!(written.is_empty());
}
#[cfg(unix)]
#[test]
fn stage_for_launch_in_place_survives_a_symlinked_game_dir() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
let link = dir.path().join("game-link");
std::fs::create_dir_all(target.join("BepInEx/core")).unwrap();
let contents = b"proxy-bytes-via-symlink";
std::fs::write(target.join("winhttp.dll"), contents).unwrap();
symlink(&target, &link).unwrap();
let eco = Ecosystem::bundled();
let written =
stage_for_launch_in(&target, &eco, "valheim", &link, &ctx(LaunchMode::Modded)).unwrap();
assert_eq!(std::fs::read(target.join("winhttp.dll")).unwrap(), contents);
assert!(written.is_empty());
}
#[test]
fn stage_for_launch_in_place_still_copies_shimloader_files_to_a_distinct_destination() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("game");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("ue4ss.dll"), b"loader").unwrap();
std::fs::write(root.join("dwmapi.dll"), b"proxy").unwrap();
std::fs::write(root.join("ue4ss-settings.ini"), b"[UE4SS]").unwrap();
let eco = Ecosystem::bundled();
let written =
stage_for_launch_in(&root, &eco, "palworld", &root, &ctx(LaunchMode::Modded)).unwrap();
let nested = root.join("Pal").join("Binaries").join("Win64");
assert_eq!(std::fs::read(nested.join("ue4ss.dll")).unwrap(), b"loader");
assert_eq!(std::fs::read(nested.join("dwmapi.dll")).unwrap(), b"proxy");
assert_eq!(std::fs::read(root.join("ue4ss.dll")).unwrap(), b"loader");
assert_eq!(written.len(), 3);
assert!(written.contains(&nested.join("ue4ss.dll")));
assert!(written.contains(&nested.join("dwmapi.dll")));
assert!(written.contains(&nested.join("ue4ss-settings.ini")));
}
#[test]
fn stage_for_launch_in_place_still_copies_rivet_version_dll_to_a_distinct_destination() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("game");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("version.dll"), b"rivet-loader").unwrap();
let eco = Ecosystem::bundled();
let written = stage_for_launch_in(
&root,
&eco,
"scrap-mechanic",
&root,
&ctx(LaunchMode::Modded),
)
.unwrap();
let dest = root.join("Release").join("version.dll");
assert_eq!(std::fs::read(&dest).unwrap(), b"rivet-loader");
assert_eq!(
std::fs::read(root.join("version.dll")).unwrap(),
b"rivet-loader"
);
assert_eq!(written, vec![dest]);
}
#[test]
fn stage_for_launch_wraps_stage_for_launch_in_over_a_layout_profile() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path().join("base");
let game_dir = dir.path().join("game");
let profile_dir = crate::profile::layout::create(&base, "valheim", "Main").unwrap();
let core = profile_dir.join("BepInEx/core");
std::fs::create_dir_all(&core).unwrap();
std::fs::create_dir_all(&game_dir).unwrap();
std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
std::fs::write(profile_dir.join("winhttp.dll"), b"proxy").unwrap();
std::fs::write(profile_dir.join("mods.yml"), b"[]").unwrap();
let eco = Ecosystem::bundled();
let written = stage_for_launch(
&base,
"valheim",
"Main",
&eco,
&game_dir,
&ctx(LaunchMode::Modded),
)
.unwrap();
assert!(game_dir.join("winhttp.dll").exists());
assert!(!game_dir.join("mods.yml").exists());
assert_eq!(written, vec![game_dir.join("winhttp.dll")]);
}
fn linux_native(mode: LaunchMode) -> LaunchContext {
let mut context = ctx(mode);
context.os = HostOs::Linux;
context.store = StorePlatform::SteamDirect;
context.runtime = Runtime::Native;
context
}
fn linux_bepinex_target(root: &Path) {
let core = root.join("BepInEx/core");
std::fs::create_dir_all(&core).unwrap();
std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
std::fs::write(root.join("winhttp.dll"), b"proxy").unwrap();
std::fs::write(root.join("doorstop_config.ini"), b"[UnityDoorstop]").unwrap();
}
#[test]
fn linux_native_modded_selects_any_shipped_script_not_only_run_bepinex() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
let game_dir = dir.path().join("game");
linux_bepinex_target(&target);
std::fs::create_dir_all(&game_dir).unwrap();
std::fs::write(target.join("start_game_bepinex.sh"), b"#!/bin/sh\n").unwrap();
let eco = Ecosystem::bundled();
let context = linux_native(LaunchMode::Modded);
let plan = launch_plan_in(&target, &eco, "valheim", &context).unwrap();
match &plan.program {
LaunchProgram::ProfileScript { path, .. } => {
assert!(path.ends_with("start_game_bepinex.sh"))
}
other => panic!("expected ProfileScript, got {other:?}"),
}
let written = stage_for_launch_in(&target, &eco, "valheim", &game_dir, &context).unwrap();
assert!(written.is_empty());
assert!(!game_dir.join("winhttp.dll").exists());
}
#[test]
fn server_instance_prefers_the_server_script_and_a_game_instance_ignores_it() {
let dir = tempfile::tempdir().unwrap();
let all_three = dir.path().join("all-three");
let server_only = dir.path().join("server-only");
linux_bepinex_target(&all_three);
linux_bepinex_target(&server_only);
for name in LOADER_SCRIPTS {
std::fs::write(all_three.join(name), b"#!/bin/sh\n").unwrap();
}
std::fs::write(server_only.join(SERVER_SCRIPT), b"#!/bin/sh\n").unwrap();
let context = linux_native(LaunchMode::Modded);
let game_profile = profile_with(LoaderKind::BepInEx);
let mut server_profile = profile_with(LoaderKind::BepInEx);
server_profile.instance_type = InstanceType::Server;
let server = resolve_program(
&script_injection(&all_three, &server_profile, &context),
&all_three,
&server_profile,
&context,
)
.unwrap();
match &server {
LaunchProgram::ProfileScript { path, .. } => {
assert!(path.ends_with("start_server_bepinex.sh"))
}
other => panic!("expected ProfileScript, got {other:?}"),
}
let game = resolve_program(
&script_injection(&all_three, &game_profile, &context),
&all_three,
&game_profile,
&context,
)
.unwrap();
match &game {
LaunchProgram::ProfileScript { path, .. } => assert!(path.ends_with("run_bepinex.sh")),
other => panic!("expected ProfileScript, got {other:?}"),
}
let err = resolve_program(
&script_injection(&server_only, &game_profile, &context),
&server_only,
&game_profile,
&context,
)
.unwrap_err();
assert!(err.to_string().contains("run_bepinex.sh"));
}
#[test]
fn modded_script_injection_without_a_script_errors_but_vanilla_falls_through() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
linux_bepinex_target(&target);
let eco = Ecosystem::bundled();
let modded = linux_native(LaunchMode::Modded);
let err = launch_plan_in(&target, &eco, "valheim", &modded).unwrap_err();
let message = err.to_string();
assert!(message.contains("run_bepinex.sh"), "{message}");
assert!(message.contains("start_game_bepinex.sh"), "{message}");
assert!(message.contains(&target.display().to_string()), "{message}");
let vanilla =
launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Vanilla)).unwrap();
assert!(matches!(vanilla.program, LaunchProgram::GameExe { .. }));
assert_eq!(
vanilla.args,
vec!["--doorstop-enable".to_string(), "false".to_string()]
);
}
#[test]
fn linux_native_steam_launches_steam_and_reports_the_wrapper_requirement() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
let game_dir = dir.path().join("game");
let script_path = dir.path().join("launch_wrapper.sh");
linux_bepinex_target(&target);
std::fs::create_dir_all(&game_dir).unwrap();
std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
let eco = Ecosystem::bundled();
let mut context = linux_native(LaunchMode::Modded);
context.store = StorePlatform::Steam;
let plan = launch_plan_in(&target, &eco, "valheim", &context).unwrap();
match &plan.program {
LaunchProgram::Steam { steam_app_id } => assert_eq!(*steam_app_id, 892970),
other => panic!("expected Steam, got {other:?}"),
}
let wrapper = plan.steam_wrapper.clone().expect("wrapper requirement");
assert_eq!(wrapper.target_dir, target);
assert_eq!(wrapper.loader, LoaderKind::BepInEx);
write_wrapper_script(&script_path).unwrap();
let options = steam_launch_options(&script_path);
assert!(options.contains(&script_path.display().to_string()));
assert!(!options.contains("WINEDLLOVERRIDES"));
assert!(
std::fs::read_to_string(&script_path)
.unwrap()
.contains("run_bepinex.sh")
);
assert!(
plan
.args
.windows(2)
.any(|pair| pair == wrapper_target_args(&target).as_slice()),
"plan args must carry the wrapper target flag: {:?}",
plan.args
);
let written = stage_for_launch_in(&target, &eco, "valheim", &game_dir, &context).unwrap();
assert!(written.is_empty());
assert!(!game_dir.join("winhttp.dll").exists());
}
#[test]
fn stage_for_launch_rejects_macos() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
let game_dir = dir.path().join("game");
linux_bepinex_target(&target);
std::fs::create_dir_all(&game_dir).unwrap();
let eco = Ecosystem::bundled();
let mut context = ctx(LaunchMode::Modded);
context.os = HostOs::MacOs;
let err = stage_for_launch_in(&target, &eco, "valheim", &game_dir, &context).unwrap_err();
assert!(err.to_string().contains("macOS launch is not supported"));
assert_eq!(std::fs::read_dir(&game_dir).unwrap().count(), 0);
context.os = HostOs::Windows;
let written = stage_for_launch_in(&target, &eco, "valheim", &game_dir, &context).unwrap();
assert_eq!(written.len(), 2);
assert!(game_dir.join("winhttp.dll").exists());
}
#[test]
fn only_a_wrapper_bound_plan_carries_the_target_flag() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
linux_bepinex_target(&target);
std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
let eco = Ecosystem::bundled();
let direct =
launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Modded)).unwrap();
assert_eq!(direct.steam_wrapper, None);
assert!(!direct.args.contains(&WRAPPER_TARGET_FLAG.to_string()));
let mut proton = linux_native(LaunchMode::Modded);
proton.store = StorePlatform::Steam;
proton.runtime = Runtime::Proton;
let proton_plan = launch_plan_in(&target, &eco, "valheim", &proton).unwrap();
assert_eq!(proton_plan.steam_wrapper, None);
assert!(!proton_plan.args.contains(&WRAPPER_TARGET_FLAG.to_string()));
}
#[test]
fn wrapper_branches_are_generated_from_the_shared_script_list() {
let script = wrapper_script_contents();
let mut previous = 0;
for name in LOADER_SCRIPTS {
let position = script
.find(&format!("[ -f \"$TARGET/{name}\" ]"))
.unwrap_or_else(|| panic!("wrapper is missing a branch for {name}"));
assert!(position >= previous, "{name} is out of precedence order");
previous = position;
}
}
#[test]
fn linux_native_script_present_vanilla_runs_the_script_with_v4_enabled_spelling() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
linux_bepinex_target(&target);
std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
let eco = Ecosystem::bundled();
let plan =
launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Vanilla)).unwrap();
match &plan.program {
LaunchProgram::ProfileScript { path, .. } => assert!(path.ends_with("run_bepinex.sh")),
other => panic!("expected ProfileScript, got {other:?}"),
}
assert_eq!(
plan.args,
vec!["--doorstop-enabled".to_string(), "false".to_string()]
);
}
#[test]
fn linux_native_script_present_modded_uses_v4_target_assembly_spelling() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
linux_bepinex_target(&target);
std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
let eco = Ecosystem::bundled();
let plan = launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Modded)).unwrap();
assert!(matches!(plan.program, LaunchProgram::ProfileScript { .. }));
assert_eq!(plan.args[0], "--doorstop-enabled");
assert_eq!(plan.args[1], "true");
assert_eq!(plan.args[2], "--doorstop-target-assembly");
assert!(plan.args[3].ends_with("BepInEx/core/BepInEx.Preloader.dll"));
}
#[test]
fn linux_native_script_present_v3_detected_corlib_override_uses_v4_search_flag_spelling() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
linux_bepinex_target(&target);
std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
std::fs::create_dir_all(target.join("unstripped_corlib")).unwrap();
let eco = Ecosystem::bundled();
let plan = launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Modded)).unwrap();
assert!(matches!(plan.program, LaunchProgram::ProfileScript { .. }));
let idx = plan
.args
.iter()
.position(|a| a == "--doorstop-mono-dll-search-path-override")
.expect("v4 corlib search-path flag missing");
assert!(plan.args[idx + 1].ends_with("/unstripped_corlib"));
assert!(
!plan
.args
.iter()
.any(|a| a == "--doorstop-dll-search-override"),
"v3 corlib search-path spelling must not appear on the script-injected path: {:?}",
plan.args
);
}
#[test]
fn linux_native_script_present_v4_detected_target_is_unchanged() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
linux_bepinex_target(&target);
std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
std::fs::write(target.join(".doorstop_version"), "4.0.0").unwrap();
let eco = Ecosystem::bundled();
let vanilla =
launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Vanilla)).unwrap();
assert_eq!(
vanilla.args,
vec!["--doorstop-enabled".to_string(), "false".to_string()]
);
let modded =
launch_plan_in(&target, &eco, "valheim", &linux_native(LaunchMode::Modded)).unwrap();
assert_eq!(modded.args[0], "--doorstop-enabled");
assert_eq!(modded.args[2], "--doorstop-target-assembly");
}
#[test]
fn proton_v3_detected_target_still_uses_v3_spellings_even_with_a_script_on_disk() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
linux_bepinex_target(&target);
std::fs::write(target.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
let eco = Ecosystem::bundled();
let mut context = linux_native(LaunchMode::Modded);
context.runtime = Runtime::Proton;
let plan = launch_plan_in(&target, &eco, "valheim", &context).unwrap();
assert_eq!(plan.args[0], "--doorstop-enable");
assert_eq!(plan.args[2], "--doorstop-target");
let mut vanilla_context = linux_native(LaunchMode::Vanilla);
vanilla_context.runtime = Runtime::Proton;
let vanilla = launch_plan_in(&target, &eco, "valheim", &vanilla_context).unwrap();
assert_eq!(
vanilla.args,
vec!["--doorstop-enable".to_string(), "false".to_string()]
);
}
#[test]
fn windows_v3_detected_target_still_uses_v3_spellings_regression_guard() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let core = root.join("BepInEx/core");
std::fs::create_dir_all(&core).unwrap();
std::fs::write(core.join("BepInEx.Preloader.dll"), b"x").unwrap();
let eco = Ecosystem::bundled();
let plan = launch_plan_in(root, &eco, "valheim", &ctx(LaunchMode::Modded)).unwrap();
assert_eq!(plan.args[0], "--doorstop-enable");
assert_eq!(plan.args[2], "--doorstop-target");
let vanilla = launch_plan_in(root, &eco, "valheim", &ctx(LaunchMode::Vanilla)).unwrap();
assert_eq!(
vanilla.args,
vec!["--doorstop-enable".to_string(), "false".to_string()]
);
}
#[test]
fn store_and_runtime_parse_from_their_config_spellings() {
assert_eq!(
"steam".parse::<StorePlatform>().unwrap(),
StorePlatform::Steam
);
assert_eq!(
"steam-direct".parse::<StorePlatform>().unwrap(),
StorePlatform::SteamDirect
);
assert_eq!(
"other".parse::<StorePlatform>().unwrap(),
StorePlatform::Other
);
assert_eq!("native".parse::<Runtime>().unwrap(), Runtime::Native);
assert_eq!("proton".parse::<Runtime>().unwrap(), Runtime::Proton);
let error = "wat".parse::<StorePlatform>().unwrap_err().to_string();
assert!(error.contains("wat"), "got: {error}");
}
#[test]
fn resolve_game_exe_returns_the_first_present_candidate() {
let dir = tempfile::tempdir().unwrap();
let game_dir = dir.path();
std::fs::write(game_dir.join("second.x86_64"), b"elf").unwrap();
let names = vec!["first.exe".to_string(), "second.x86_64".to_string()];
let found = resolve_game_exe(game_dir, &names).unwrap();
assert_eq!(found, game_dir.join("second.x86_64"));
let error = resolve_game_exe(game_dir, &["absent.exe".to_string()])
.unwrap_err()
.to_string();
assert!(error.contains("absent.exe"), "got: {error}");
}
#[test]
fn a_plan_becomes_a_spawnable_command() {
let dir = tempfile::tempdir().unwrap();
let game_dir = dir.path();
std::fs::write(game_dir.join("valheim.x86_64"), b"elf").unwrap();
let plan = LaunchPlan {
program: LaunchProgram::GameExe {
exe_names: vec!["valheim.x86_64".to_string()],
},
args: vec!["-console".to_string()],
env: vec![("KEY".to_string(), "VALUE".to_string())],
working_dir: Some(game_dir.to_path_buf()),
steam_wrapper: None,
};
let command = plan.to_command(game_dir).unwrap();
assert_eq!(command.get_program(), game_dir.join("valheim.x86_64"));
assert_eq!(
command.get_args().collect::<Vec<_>>(),
vec![std::ffi::OsStr::new("-console")]
);
assert_eq!(command.get_current_dir(), Some(game_dir));
assert!(
command
.get_envs()
.any(|(key, value)| key == std::ffi::OsStr::new("KEY")
&& value == Some(std::ffi::OsStr::new("VALUE")))
);
}
#[test]
fn a_steam_command_leads_with_applaunch_and_keeps_the_plan_argv_after_it() {
let program = Path::new(r"C:\Program Files (x86)\Steam\steam.exe");
let mut command = steam_command(program, 892970);
command.args(["-console".to_string(), "--ts-target".to_string()]);
assert_eq!(command.get_program(), program.as_os_str());
assert_eq!(
command.get_args().collect::<Vec<_>>(),
vec![
std::ffi::OsStr::new("-applaunch"),
std::ffi::OsStr::new("892970"),
std::ffi::OsStr::new("-console"),
std::ffi::OsStr::new("--ts-target"),
]
);
}
#[test]
fn a_steam_plan_spawns_the_resolved_steam_executable() {
let dir = tempfile::tempdir().unwrap();
let game_dir = dir.path();
let plan = LaunchPlan {
program: LaunchProgram::Steam {
steam_app_id: 892970,
},
args: vec!["-console".to_string()],
env: vec![("KEY".to_string(), "VALUE".to_string())],
working_dir: None,
steam_wrapper: None,
};
let command = plan.to_command(game_dir).unwrap();
assert_eq!(
command.get_program(),
crate::steam::steam_exe().as_os_str(),
"the Steam arm must use the resolved executable"
);
assert_eq!(
command.get_args().collect::<Vec<_>>(),
vec![
std::ffi::OsStr::new("-applaunch"),
std::ffi::OsStr::new("892970"),
std::ffi::OsStr::new("-console"),
]
);
let envs = command.get_envs().collect::<Vec<_>>();
assert_eq!(
envs,
vec![(
std::ffi::OsStr::new("KEY"),
Some(std::ffi::OsStr::new("VALUE"))
)],
"the plan's env must reach the command unchanged, with nothing added"
);
}
}