use crate::config::LaunchConfig;
use crate::error::{AppError, AppResult};
use crate::target::{GAME, Target};
use std::path::{Path, PathBuf};
use thunderstore_engine::ecosystem::Ecosystem;
use thunderstore_engine::profile::launch::{
self, HostOs, LaunchContext, LaunchMode, Runtime, StorePlatform, bepinex_preloader,
ensure_loader_scripts_executable, launch_plan_in, stage_for_launch_in, steam_launch_options,
write_wrapper_script,
};
use thunderstore_engine::steam::{Severity, Visibility, visibility};
pub fn combined_args(config: &LaunchConfig, extra_args: &[String]) -> Vec<String> {
let mut args = config.extra_args.clone();
args.extend(extra_args.iter().cloned());
args
}
pub fn stage_if_needed(
target: &Target,
eco: &Ecosystem,
ctx: &LaunchContext,
) -> AppResult<Vec<PathBuf>> {
if !target.needs_staging() {
return Ok(Vec::new());
}
let staged = stage_for_launch_in(&target.dir, eco, GAME, &target.game_dir, ctx)?;
for path in &staged {
tracing::info!("staged {}", path.display());
}
Ok(staged)
}
fn check_steam_can_see(script: &Path, target_dir: &Path) -> AppResult<()> {
for (label, path) in [
("wrapper script", script),
("profile directory", target_dir),
] {
apply_visibility(label, path, visibility(path))?;
}
Ok(())
}
fn apply_visibility(label: &str, path: &Path, verdict: Visibility) -> AppResult<()> {
match verdict.severity() {
Severity::Ok => Ok(()),
Severity::Warn => {
eprintln!("\nvmm: warning: {}\n", suspect_message(label, path));
Ok(())
}
Severity::Refuse => {
let Visibility::Unreachable { steam_pid } = verdict else {
unreachable!("only an Unreachable verdict refuses");
};
eprintln!("\nvmm: {}\n", unreachable_message(label, path, steam_pid));
Err(AppError::advice(
format!("Steam cannot reach the {label}."),
format!(
"{} is not visible to the running Steam client, so a launch through \
Steam would silently do nothing. The explanation above says why. \
Nothing was changed.",
path.display()
),
&[],
))
}
}
}
fn unreachable_message(label: &str, path: &Path, steam_pid: u32) -> String {
format!(
"Steam cannot see the {label} it would be told to use:\n\n {path}\n\n\
The running Steam client (pid {steam_pid}) resolves paths in its own mount\n\
namespace, and that path does not exist inside it. Launching would appear to\n\
do nothing: Steam would fail to run it and record the failure only in its own\n\
console log (~/.steam/steam/logs/console-linux.txt).\n\n\
A sandboxed Steam does this to /tmp in particular, which it replaces with a\n\
private empty filesystem. Point `data_dir` in your config at a location Steam\n\
can see, such as somewhere under your home directory, then paste the new\n\
`vmm launch --print-steam-options` string into this game's Steam launch\n\
options once.",
path = path.display(),
)
}
fn suspect_message(label: &str, path: &Path) -> String {
format!(
"the {label} is under a path a sandboxed Steam replaces with a private\n\
filesystem of its own:\n\n {path}\n\n\
Steam is not running, so this could not be confirmed against its actual mount\n\
namespace. If the game does not start, this is why: move `data_dir` somewhere\n\
under your home directory and re-paste the launch options.",
path = path.display(),
)
}
fn require_loader(target: &Target, runtime: Runtime) -> AppResult<()> {
if bepinex_preloader(&target.dir, runtime).is_ok() {
return Ok(());
}
Err(AppError::advice(
format!(
"there is no mod loader installed in {}.",
crate::target::describe(target)
),
"vmm loads mods through BepInEx, so a modded launch needs it present. \
Installing any mod pulls the loader in as a dependency. To play without \
mods, start the game from Steam as usual, or launch vanilla through vmm.",
&["vmm install <Owner-ModName>", "vmm launch --vanilla"],
))
}
fn unsupported_os_error() -> AppError {
AppError::advice(
"launching is not supported on this operating system.",
"Launching is supported on Windows and Linux. Installing and managing mods \
still works here; only `vmm launch` does not.",
&[],
)
}
fn steam_exe_in(program_files: &[PathBuf]) -> Option<PathBuf> {
program_files
.iter()
.map(|root| root.join("Steam").join("steam.exe"))
.find(|candidate| candidate.is_file())
}
fn steam_exe() -> Option<PathBuf> {
if !cfg!(windows) {
return None;
}
let roots: Vec<PathBuf> = ["ProgramFiles(x86)", "ProgramFiles"]
.iter()
.filter_map(std::env::var_os)
.map(PathBuf::from)
.collect();
steam_exe_in(&roots)
}
fn steam_command(steam_exe: &Path, steam_app_id: u32, args: &[String]) -> std::process::Command {
let mut command = std::process::Command::new(steam_exe);
command.arg("-applaunch").arg(steam_app_id.to_string());
command.args(args);
command
}
fn plan_command(plan: &launch::LaunchPlan, game_dir: &Path) -> AppResult<std::process::Command> {
if let launch::LaunchProgram::Steam { steam_app_id } = plan.program
&& let Some(exe) = steam_exe()
{
return Ok(steam_command(&exe, steam_app_id, &plan.args));
}
Ok(plan.to_command(game_dir)?)
}
pub fn run(
eco: &Ecosystem,
target: &Target,
config: &LaunchConfig,
vanilla: bool,
print_steam_options: bool,
extra_args: &[String],
) -> AppResult<()> {
let mode = if vanilla || print_steam_options {
LaunchMode::Vanilla
} else {
LaunchMode::Modded
};
let runtime = config.runtime.parse::<Runtime>()?;
let host = HostOs::detect().ok_or_else(unsupported_os_error)?;
let ctx = LaunchContext::new(host, config.store.parse::<StorePlatform>()?, runtime, mode)
.with_extra_args(combined_args(config, extra_args));
if mode == LaunchMode::Modded {
require_loader(target, runtime)?;
}
let plan = launch_plan_in(&target.dir, eco, GAME, &ctx)?;
match &plan.steam_wrapper {
Some(_) => {
let script = launch::wrapper_path(&target.base, GAME);
write_wrapper_script(&script)?;
let options = steam_launch_options(&script);
if print_steam_options {
println!("{options}");
return Ok(());
}
check_steam_can_see(&script, &target.dir)?;
if vanilla {
eprintln!(
"vmm: this launch routes through a wrapper script that Steam must invoke.\n\
This launch is unmodded, as requested. If the options below are not already\n\
pasted into this game's Steam launch options, paste them once. The script\n\
resolves its target at run time, so that paste stays correct across every\n\
later profile switch:\n\n {options}\n"
);
} else {
eprintln!(
"vmm: this launch routes through a wrapper script that Steam must invoke.\n\
Mods load only once the options below are pasted into this game's Steam\n\
launch options. That paste is one-time: the script resolves its target at\n\
run time, so it stays correct across every later profile switch:\n\n {options}\n"
);
}
}
None if print_steam_options => {
return Err(AppError::advice(
"there are no Steam launch options to paste on this platform.",
"vmm injects into the game directly here rather than through a wrapper \
script, so Steam needs nothing configured. Just launch normally.",
&["vmm launch"],
));
}
None => {}
}
for path in ensure_loader_scripts_executable(&target.dir)? {
tracing::info!("marked {} executable", path.display());
}
stage_if_needed(target, eco, &ctx)?;
let mut command = plan_command(&plan, &target.game_dir)?;
let program = command.get_program().to_os_string();
let status = command.status().map_err(|error| {
AppError::advice(
format!("couldn't start {}.", program.to_string_lossy()),
format!("{error}\n\nCheck that the file exists and is executable."),
&[],
)
})?;
if !status.success() {
return Err(AppError::advice(
format!("the game exited with {status}."),
"vmm started it successfully, so this is the game's own failure rather \
than a problem with vmm or your mods list. Check the game's log, and try \
a vanilla launch to tell a game problem from a mod problem.",
&["vmm launch --vanilla"],
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::Fixture;
use tempfile::tempdir;
use thunderstore_engine::ecosystem::Ecosystem;
use thunderstore_engine::profile::launch::WRAPPER_TARGET_FLAG;
#[test]
fn steam_exe_in_picks_the_first_root_that_actually_has_steam() {
let dir = tempdir().unwrap();
let empty = dir.path().join("Program Files (x86)");
let real = dir.path().join("Program Files");
std::fs::create_dir_all(empty.join("Steam")).unwrap();
std::fs::create_dir_all(real.join("Steam")).unwrap();
std::fs::write(real.join("Steam").join("steam.exe"), b"steam").unwrap();
assert_eq!(
steam_exe_in(&[empty, real.clone()]),
Some(real.join("Steam").join("steam.exe"))
);
}
#[test]
fn steam_exe_in_is_none_when_no_root_holds_steam() {
let dir = tempdir().unwrap();
assert_eq!(steam_exe_in(&[dir.path().to_path_buf()]), None);
}
#[test]
fn the_steam_command_passes_applaunch_the_app_id_and_then_the_plan_args() {
use std::ffi::OsStr;
let args = vec!["--doorstop-enabled".to_string(), "true".to_string()];
let command = steam_command(Path::new("/steam/steam.exe"), 892970, &args);
assert_eq!(command.get_program(), OsStr::new("/steam/steam.exe"));
let passed: Vec<String> = command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
assert_eq!(
passed,
vec!["-applaunch", "892970", "--doorstop-enabled", "true"]
);
}
#[cfg(not(windows))]
#[test]
fn no_steam_executable_is_resolved_off_windows() {
assert_eq!(steam_exe(), None);
}
#[cfg(not(windows))]
#[test]
fn plan_command_defers_to_the_engine_when_no_steam_executable_is_resolved() {
use std::ffi::OsString;
let target = tempdir().unwrap();
let game = tempdir().unwrap();
bepinex_target(target.path());
game_dir_with_exe(game.path());
let eco = Ecosystem::bundled();
let ctx = LaunchContext::new(
HostOs::Windows,
StorePlatform::Steam,
Runtime::Native,
LaunchMode::Modded,
);
let plan = launch_plan_in(target.path(), &eco, GAME, &ctx).unwrap();
let engine = plan.to_command(game.path()).unwrap();
let chosen = plan_command(&plan, game.path()).unwrap();
assert_eq!(chosen.get_program(), engine.get_program());
let chosen_args: Vec<OsString> = chosen.get_args().map(|arg| arg.to_owned()).collect();
let engine_args: Vec<OsString> = engine.get_args().map(|arg| arg.to_owned()).collect();
assert_eq!(chosen_args, engine_args);
}
#[test]
fn the_unsupported_os_refusal_does_not_advertise_macos() {
let message = unsupported_os_error().to_string();
assert!(
!message.to_ascii_lowercase().contains("macos"),
"the refusal must not offer an OS whose launch is refused; got: {message}"
);
assert!(message.contains("Windows"), "got: {message}");
assert!(message.contains("Linux"), "got: {message}");
}
fn bepinex_target(dir: &Path) {
let core = dir.join("BepInEx/core");
std::fs::create_dir_all(&core).unwrap();
std::fs::write(core.join("BepInEx.Preloader.dll"), b"dll").unwrap();
}
fn game_dir_with_exe(dir: &Path) {
std::fs::write(dir.join("valheim.x86_64"), b"exe").unwrap();
}
#[test]
fn to_command_targets_steam_with_the_app_id() {
let target = tempdir().unwrap();
let game = tempdir().unwrap();
bepinex_target(target.path());
game_dir_with_exe(game.path());
let eco = Ecosystem::bundled();
let ctx = LaunchContext::new(
HostOs::Windows,
StorePlatform::Steam,
Runtime::Native,
LaunchMode::Modded,
);
let plan = launch_plan_in(target.path(), &eco, GAME, &ctx).unwrap();
let command = plan.to_command(game.path()).unwrap();
assert_eq!(command.get_program(), std::ffi::OsStr::new("steam"));
let args: Vec<String> = command
.get_args()
.map(|arg| arg.to_string_lossy().to_string())
.collect();
assert_eq!(args[0], "-applaunch");
assert_eq!(args[1], "892970");
}
#[test]
fn extra_args_come_from_config_then_the_command_line() {
let config = crate::config::LaunchConfig {
store: "other".to_string(),
runtime: "proton".to_string(),
extra_args: vec!["-from-config".to_string()],
};
let args = combined_args(&config, &["-from-cli".to_string()]);
assert_eq!(
args,
vec!["-from-config".to_string(), "-from-cli".to_string()]
);
}
#[test]
fn apply_visibility_refuses_a_proven_unreachable_path() {
let path = Path::new("/tmp/t/d/valheim/steam-wrapper.sh");
let error = apply_visibility(
"wrapper script",
path,
Visibility::Unreachable { steam_pid: 32372 },
)
.unwrap_err()
.to_string();
assert!(error.contains("Steam cannot reach"), "got: {error}");
assert!(error.contains("wrapper script"), "got: {error}");
assert!(error.contains(&path.display().to_string()), "got: {error}");
}
#[test]
fn apply_visibility_warns_but_permits_an_unconfirmed_suspicion() {
assert!(
apply_visibility(
"wrapper script",
Path::new("/tmp/x/steam-wrapper.sh"),
Visibility::Unknown { suspect: true },
)
.is_ok()
);
}
#[test]
fn apply_visibility_permits_a_reachable_or_unremarkable_path() {
let path = Path::new("/home/u/.local/share/vmm/valheim/steam-wrapper.sh");
assert!(apply_visibility("wrapper script", path, Visibility::Reachable).is_ok());
assert!(
apply_visibility(
"wrapper script",
path,
Visibility::Unknown { suspect: false }
)
.is_ok()
);
}
#[test]
fn unreachable_message_names_the_path_and_the_evidence() {
let message = unreachable_message(
"wrapper script",
Path::new("/tmp/t/d/valheim/steam-wrapper.sh"),
32372,
);
assert!(message.contains("wrapper script"), "got: {message}");
assert!(message.contains("/tmp/t/d/valheim/steam-wrapper.sh"));
assert!(message.contains("32372"), "must name the pid inspected");
assert!(message.contains("data_dir"), "must say how to fix it");
}
#[test]
fn suspect_message_says_it_is_unconfirmed() {
let message = suspect_message("wrapper script", Path::new("/tmp/x/steam-wrapper.sh"));
assert!(message.contains("/tmp/x/steam-wrapper.sh"));
assert!(message.contains("not running"), "got: {message}");
}
#[test]
fn check_steam_can_see_permits_a_home_directory_target() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/home".to_string());
let script = PathBuf::from(&home);
assert!(check_steam_can_see(&script, &script).is_ok());
}
#[test]
fn game_dir_mode_never_stages_over_itself() {
let base = tempdir().unwrap();
let game = tempdir().unwrap();
bepinex_target(game.path());
game_dir_with_exe(game.path());
let proxy = game.path().join("winhttp.dll");
std::fs::write(&proxy, b"proxy-bytes").unwrap();
let target = Target::resolve(
base.path().to_path_buf(),
game.path().to_path_buf(),
GAME,
None,
)
.unwrap();
let eco = Ecosystem::bundled();
let ctx = LaunchContext::new(
HostOs::Linux,
StorePlatform::Other,
Runtime::Proton,
LaunchMode::Modded,
);
stage_if_needed(&target, &eco, &ctx).unwrap();
assert_eq!(std::fs::read(&proxy).unwrap(), b"proxy-bytes");
let missing = Target::resolve(
base.path().to_path_buf(),
base.path().join("does-not-exist"),
GAME,
None,
)
.unwrap();
assert_eq!(
stage_if_needed(&missing, &eco, &ctx).unwrap(),
Vec::<PathBuf>::new()
);
}
#[test]
fn profile_mode_stages_root_files_into_the_game_dir() {
let base = tempdir().unwrap();
let game = tempdir().unwrap();
thunderstore_engine::profile::layout::create(base.path(), GAME, "experiment").unwrap();
let target = Target::resolve(
base.path().to_path_buf(),
game.path().to_path_buf(),
GAME,
Some("experiment"),
)
.unwrap();
bepinex_target(&target.dir);
std::fs::write(target.dir.join("winhttp.dll"), b"proxy-bytes").unwrap();
let eco = Ecosystem::bundled();
let ctx = LaunchContext::new(
HostOs::Linux,
StorePlatform::Other,
Runtime::Proton,
LaunchMode::Modded,
);
stage_if_needed(&target, &eco, &ctx).unwrap();
assert_eq!(
std::fs::read(game.path().join("winhttp.dll")).unwrap(),
b"proxy-bytes"
);
}
fn loader_script(dir: &Path) {
std::fs::write(dir.join("run_bepinex.sh"), b"#!/bin/sh\n").unwrap();
}
#[test]
fn launch_plan_in_appends_the_wrapper_target_flag_for_a_linux_native_steam_plan() {
let target = tempdir().unwrap();
bepinex_target(target.path());
loader_script(target.path());
let eco = Ecosystem::bundled();
let ctx = LaunchContext::new(
HostOs::Linux,
StorePlatform::Steam,
Runtime::Native,
LaunchMode::Modded,
);
let plan = launch_plan_in(target.path(), &eco, GAME, &ctx).unwrap();
assert!(plan.steam_wrapper.is_some());
let flag_index = plan
.args
.iter()
.position(|arg| arg == WRAPPER_TARGET_FLAG)
.expect("WRAPPER_TARGET_FLAG missing from plan.args");
assert_eq!(
plan.args[flag_index + 1],
target.path().to_string_lossy().to_string()
);
}
#[test]
#[cfg(target_os = "linux")]
fn run_print_steam_options_writes_the_shared_wrapper_and_returns_ok() {
let base = tempdir().unwrap();
let game = tempdir().unwrap();
bepinex_target(game.path());
loader_script(game.path());
let target = Target::resolve(
base.path().to_path_buf(),
game.path().to_path_buf(),
GAME,
None,
)
.unwrap();
let eco = Ecosystem::bundled();
let config = crate::config::LaunchConfig {
store: "steam".to_string(),
runtime: "native".to_string(),
extra_args: Vec::new(),
};
let result = run(&eco, &target, &config, false, true, &[]);
assert!(result.is_ok());
let script = launch::wrapper_path(&target.base, GAME);
assert_eq!(script, base.path().join(GAME).join("steam-wrapper.sh"));
let contents = std::fs::read_to_string(&script).unwrap();
assert!(contents.contains("--ts-target"), "got: {contents}");
}
#[test]
#[cfg(target_os = "linux")]
fn run_print_steam_options_answers_before_any_loader_is_installed() {
let base = tempdir().unwrap();
let game = tempdir().unwrap();
let target = Target::resolve(
base.path().to_path_buf(),
game.path().to_path_buf(),
GAME,
None,
)
.unwrap();
let eco = Ecosystem::bundled();
let config = crate::config::LaunchConfig {
store: "steam".to_string(),
runtime: "native".to_string(),
extra_args: Vec::new(),
};
let result = run(&eco, &target, &config, false, true, &[]);
assert!(
result.is_ok(),
"the query must not require a loader; got: {:?}",
result.err().map(|error| error.to_string())
);
let script = launch::wrapper_path(&target.base, GAME);
assert!(script.is_file(), "the wrapper must still be written");
}
#[test]
#[cfg(target_os = "linux")]
fn run_print_steam_options_errors_when_the_plan_carries_no_wrapper() {
let base = tempdir().unwrap();
let game = tempdir().unwrap();
let target = Target::resolve(
base.path().to_path_buf(),
game.path().to_path_buf(),
GAME,
None,
)
.unwrap();
let eco = Ecosystem::bundled();
let config = crate::config::LaunchConfig {
store: "steam".to_string(),
runtime: "proton".to_string(),
extra_args: Vec::new(),
};
let error = run(&eco, &target, &config, true, true, &[])
.unwrap_err()
.to_string();
assert!(error.contains("no Steam launch options"), "got: {error}");
}
#[test]
fn launching_a_target_with_no_mod_loader_explains_what_to_do() {
let fixture = Fixture::new();
let target = fixture.target();
let eco = Ecosystem::bundled();
let result = run(
&eco,
&target,
&crate::config::LaunchConfig::default(),
false,
false,
&[],
);
let message = result.unwrap_err().to_string();
assert!(
message.contains("mod loader"),
"the error must say the loader is missing; got: {message}"
);
assert!(
message.contains("vmm install"),
"the error must say how to get one; got: {message}"
);
}
#[test]
fn require_loader_accepts_a_target_that_has_the_preloader() {
let base = tempdir().unwrap();
let game = tempdir().unwrap();
bepinex_target(game.path());
let target = Target::resolve(
base.path().to_path_buf(),
game.path().to_path_buf(),
GAME,
None,
)
.unwrap();
assert!(require_loader(&target, Runtime::Native).is_ok());
}
}