Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/external/proton.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,18 @@ mod tests {
parse_proton_dir_name("Proton 7.0"),
Some(ProtonVersion::Numbered(7, 0))
);
assert_eq!(
parse_proton_dir_name("Proton 9"),
Some(ProtonVersion::Numbered(9, 0))
);
assert_eq!(
parse_proton_dir_name("Proton 9.0 (Beta)"),
Some(ProtonVersion::Numbered(9, 0))
);
assert_eq!(
parse_proton_dir_name("Proton 8.0-4"),
Some(ProtonVersion::Numbered(8, 0))
);
}

#[test]
Expand Down Expand Up @@ -295,6 +307,26 @@ mod tests {
assert_eq!(result, common.join("Proton - Experimental"));
}

#[test]
fn test_find_proton_ignores_higher_version_without_wine_binary() {
let tmp = tempfile::tempdir().unwrap();
let common = tmp.path().join("steamapps/common");

// Higher version directory exists, but no wine binary.
std::fs::create_dir_all(common.join("Proton 9.0/files/bin")).unwrap();

// Experimental has a working wine binary and should be selected.
let exp = common.join("Proton - Experimental/files/bin");
std::fs::create_dir_all(&exp).unwrap();
std::fs::write(exp.join("wine64"), "").unwrap();

let game_path = common.join("Sonic Adventure DX");
std::fs::create_dir_all(&game_path).unwrap();

let result = find_proton(&game_path).unwrap();
assert_eq!(result, common.join("Proton - Experimental"));
}

#[test]
fn test_find_proton_no_proton_fails() {
let tmp = tempfile::tempdir().unwrap();
Expand Down
16 changes: 14 additions & 2 deletions src/external/runtime_installer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ const VCREDIST_URL: &str = "https://aka.ms/vs/17/release/vc_redist.x64.exe";
/// during Wine execution.
const DOTNET_DESKTOP_8_URL: &str = "https://download.visualstudio.microsoft.com/download/pr/27bcdd70-ce64-4049-ba24-2b1971545497/5e58f0e5e0b8b33825c3caef1fae00a4/windowsdesktop-runtime-8.0.14-win-x64.exe";

fn is_success_or_reboot_code(code: i32) -> bool {
code == 0 || code == 3010
}

/// Check whether the VC++ runtime is already installed in the prefix.
pub fn is_vcrun_installed(prefix: &Path) -> bool {
prefix
Expand Down Expand Up @@ -63,7 +67,7 @@ pub fn install_runtimes(game_path: &Path, app_id: u32) -> Result<()> {
let stderr = String::from_utf8_lossy(&output.stderr);
let code = output.status.code().unwrap_or(-1);
// VC++ installer may return 3010 (reboot required) which is success.
if code != 3010 {
if !is_success_or_reboot_code(code) {
anyhow::bail!("VC++ 2022 installation failed (code {code}): {stderr}");
}
}
Expand All @@ -87,7 +91,7 @@ pub fn install_runtimes(game_path: &Path, app_id: u32) -> Result<()> {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let code = output.status.code().unwrap_or(-1);
if code != 3010 {
if !is_success_or_reboot_code(code) {
anyhow::bail!(".NET Desktop Runtime 8 installation failed (code {code}): {stderr}");
}
}
Expand Down Expand Up @@ -135,4 +139,12 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
assert!(!is_dotnet_installed(tmp.path()));
}

#[test]
fn test_is_success_or_reboot_code() {
assert!(is_success_or_reboot_code(0));
assert!(is_success_or_reboot_code(3010));
assert!(!is_success_or_reboot_code(1603));
assert!(!is_success_or_reboot_code(-1));
}
}
58 changes: 54 additions & 4 deletions src/setup/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,15 +417,19 @@ fn find_mod_root(staging: &Path) -> Option<std::path::PathBuf> {
}
// Check one level deep
if let Ok(entries) = std::fs::read_dir(staging) {
for entry in entries.flatten() {
let mut first_level: Vec<_> = entries.flatten().collect();
first_level.sort_by_key(|e| e.file_name());
for entry in first_level {
let p = entry.path();
if p.is_dir() {
if p.join("mod.ini").is_file() {
return Some(p);
}
// Check two levels deep
if let Ok(inner) = std::fs::read_dir(&p) {
for inner_entry in inner.flatten() {
let mut second_level: Vec<_> = inner.flatten().collect();
second_level.sort_by_key(|e| e.file_name());
for inner_entry in second_level {
let ip = inner_entry.path();
if ip.is_dir() && ip.join("mod.ini").is_file() {
return Some(ip);
Expand All @@ -444,6 +448,9 @@ fn move_dir_contents(src: &Path, dest: &Path) -> Result<()> {
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let target = dest.join(entry.file_name());
if target.exists() {
remove_path(&target)?;
}
if std::fs::rename(entry.path(), &target).is_err() {
// rename fails across filesystems; fall back to copy + remove
if entry.path().is_dir() {
Expand All @@ -458,6 +465,15 @@ fn move_dir_contents(src: &Path, dest: &Path) -> Result<()> {
Ok(())
}

fn remove_path(path: &Path) -> Result<()> {
if path.is_dir() {
std::fs::remove_dir_all(path)?;
} else {
std::fs::remove_file(path)?;
}
Ok(())
}

/// Find a file in a directory by case-insensitive name match.
///
/// Returns `Some(path)` with the actual on-disk casing, or `None` if no match.
Expand Down Expand Up @@ -677,6 +693,36 @@ mod tests {
assert!(mods_dir.join("SomeMod").join("mod.ini").is_file());
}

#[test]
fn test_find_mod_root_prefers_deterministic_order() {
let tmp = tempfile::tempdir().unwrap();
let staging = tmp.path().join("staging");
let b_dir = staging.join("b_mod");
let a_dir = staging.join("a_mod");
std::fs::create_dir_all(&b_dir).unwrap();
std::fs::create_dir_all(&a_dir).unwrap();
std::fs::write(b_dir.join("mod.ini"), b"[mod]").unwrap();
std::fs::write(a_dir.join("mod.ini"), b"[mod]").unwrap();

let root = find_mod_root(&staging).unwrap();
assert_eq!(root, a_dir);
}

#[test]
fn test_move_dir_contents_overwrites_existing_file() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
let dest = tmp.path().join("dest");
std::fs::create_dir_all(&src).unwrap();
std::fs::create_dir_all(&dest).unwrap();
std::fs::write(src.join("shared.txt"), b"new").unwrap();
std::fs::write(dest.join("shared.txt"), b"old").unwrap();

move_dir_contents(&src, &dest).unwrap();
assert_eq!(std::fs::read(dest.join("shared.txt")).unwrap(), b"new");
assert!(!src.join("shared.txt").exists());
}

/// Helper: simulate the Steam exe replacement logic from `install_mod_manager`.
/// Creates `SAModManager.exe` in the game dir and runs the replacement logic.
fn run_exe_replacement(game_path: &std::path::Path) {
Expand Down Expand Up @@ -937,7 +983,9 @@ mod tests {
// When dir_name is None, the name field is used as the directory name
let mod_entry = ModEntry {
name: "MyMod",
source: ModSource::DirectUrl { url: "https://example.com/mod.7z" },
source: ModSource::DirectUrl {
url: "https://example.com/mod.7z",
},
description: "A test mod",
full_description: None,
pictures: &[],
Expand All @@ -952,7 +1000,9 @@ mod tests {
fn test_mod_entry_explicit_dir_name() {
let mod_entry = ModEntry {
name: "Display Name",
source: ModSource::DirectUrl { url: "https://example.com/mod.7z" },
source: ModSource::DirectUrl {
url: "https://example.com/mod.7z",
},
description: "A test mod",
full_description: None,
pictures: &[],
Expand Down
66 changes: 60 additions & 6 deletions src/steam/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@ fn find_all_games_in_libraries(

if apps.contains_key(&app_id) {
let lib_path = match folder_map.get("path").and_then(|v| v.as_str()) {
Some(p) => Path::new(p),
Some(p) if !p.trim().is_empty() => Path::new(p),
None => continue,
Some(_) => continue,
};

if !lib_path.exists() {
Expand Down Expand Up @@ -106,9 +107,13 @@ fn find_game_in_library_path(lib_path: &Path, kind: GameKind) -> Option<PathBuf>
};

let exe_path = game_path.join(executable);
let alt_exe = game_path.join("sonic.exe");
let alt_exe = match kind {
GameKind::SADX => Some(game_path.join("sonic.exe")),
GameKind::SA2 => None,
};

if exe_path.exists() || alt_exe.exists() {
let has_alt = alt_exe.as_ref().is_some_and(|p| p.exists());
if exe_path.exists() || has_alt {
let real_path = game_path
.canonicalize()
.unwrap_or_else(|_| game_path.clone());
Expand Down Expand Up @@ -826,8 +831,8 @@ mod tests {
}

#[test]
fn test_sa2_alt_exe_sonic_exe_detected() {
// SA2 directory with only sonic.exe (the fallback executable)
fn test_sa2_alt_exe_sonic_exe_not_detected() {
// SA2 should require sonic2app.exe. sonic.exe is a SADX fallback only.
let tmp = tempfile::tempdir().unwrap();
let game_dir = tmp
.path()
Expand All @@ -838,10 +843,59 @@ mod tests {

let vdf = mock_vdf(tmp.path().to_str().unwrap(), &["213610"]);
let (paths, inaccessible) = find_all_games_in_libraries(&vdf, GameKind::SA2);
assert_eq!(paths, vec![game_dir]);
assert!(paths.is_empty());
assert!(inaccessible.is_empty());
}

#[test]
fn test_skips_whitespace_only_library_path() {
let mut apps = HashMap::new();
apps.insert("71250".to_string(), vdf::VdfValue::String("0".to_string()));

let mut folder = HashMap::new();
folder.insert("path".to_string(), vdf::VdfValue::String(" ".to_string()));
folder.insert("apps".to_string(), vdf::VdfValue::Map(apps));

let mut folders = HashMap::new();
folders.insert("0".to_string(), vdf::VdfValue::Map(folder));
let mut root = HashMap::new();
root.insert("libraryfolders".to_string(), vdf::VdfValue::Map(folders));

let (paths, inaccessible) =
find_all_games_in_libraries(&vdf::VdfValue::Map(root), GameKind::SADX);
assert!(paths.is_empty());
assert!(inaccessible.is_empty());
}

#[cfg(unix)]
#[test]
fn test_detect_games_dedupes_symlinked_library_paths() {
use std::os::unix::fs::symlink;

let tmp = tempfile::tempdir().unwrap();
let real_lib = tmp.path().join("real-lib");
let link_lib = tmp.path().join("link-lib");

std::fs::create_dir_all(real_lib.join("steamapps/common/Sonic Adventure DX")).unwrap();
std::fs::write(
real_lib.join("steamapps/common/Sonic Adventure DX/Sonic Adventure DX.exe"),
"",
)
.unwrap();
symlink(&real_lib, &link_lib).unwrap();

let root_a = mock_vdf(real_lib.to_str().unwrap(), &["71250"]);
let root_b = mock_vdf(link_lib.to_str().unwrap(), &["71250"]);
let result = detect_games_from_parsed_vdfs(&[root_a, root_b], &[]);

let sadx: Vec<_> = result
.games
.iter()
.filter(|g| g.kind == GameKind::SADX)
.collect();
assert_eq!(sadx.len(), 1);
}

#[test]
fn test_empty_vdf_roots_and_empty_extra_libraries() {
let result = detect_games_from_parsed_vdfs(&[], &[]);
Expand Down
16 changes: 16 additions & 0 deletions src/steam/vdf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ impl<'a> Parser<'a> {
}

pub fn parse(input: &str) -> Option<VdfValue> {
let input = input.strip_prefix('\u{FEFF}').unwrap_or(input);
let mut parser = Parser::new(input);
let value = parser.parse_root()?;
parser.skip_whitespace();
Expand Down Expand Up @@ -318,6 +319,21 @@ mod tests {
assert!(parse("// just a comment\n").is_none());
}

#[test]
fn test_parse_with_utf8_bom() {
let input = "\u{FEFF}\"root\" { \"key\" \"value\" }";
let root = parse(input).unwrap();
assert_eq!(
root.get("root")
.unwrap()
.get("key")
.unwrap()
.as_str()
.unwrap(),
"value"
);
}

#[test]
fn test_vdfvalue_accessors() {
let string_val = VdfValue::String("hello".to_string());
Expand Down