Skip to content
Open
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
130 changes: 130 additions & 0 deletions BannerlordModEditor.Common.Tests/Services/GameDirectoryScannerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Xunit;
using BannerlordModEditor.Common.Services;

namespace BannerlordModEditor.Common.Tests.Services
{
public class GameDirectoryScannerTests
{
private readonly GameDirectoryScanner _scanner;

public GameDirectoryScannerTests()
{
_scanner = new GameDirectoryScanner();
}

[Fact]
public async Task ScanForGameDirectoriesAsync_ReturnsEmptyListWhenNoGameInstalled()
{
var result = await _scanner.ScanForGameDirectoriesAsync();
Assert.NotNull(result);
}
Comment on lines +18 to +23
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test name claims the result is empty “when no game installed”, but it only asserts NotNull and also depends on the machine’s real Steam/GOG/Epic state (making it non-deterministic and potentially slow). To make it a real unit test, inject filesystem/environment dependencies (or an abstraction) and assert the expected contents (e.g., empty list) using a controlled fake setup.

Copilot uses AI. Check for mistakes.

[Fact]
public void IsValidGameDirectory_ReturnsFalseForNullPath()
{
var result = _scanner.IsValidGameDirectory(null!);
Assert.False(result);
}

[Fact]
public void IsValidGameDirectory_ReturnsFalseForEmptyPath()
{
var result = _scanner.IsValidGameDirectory(string.Empty);
Assert.False(result);
}

[Fact]
public void IsValidGameDirectory_ReturnsFalseForNonExistentPath()
{
var result = _scanner.IsValidGameDirectory("/nonexistent/path");
Assert.False(result);
}

[Fact]
public void IsValidGameDirectory_ReturnsFalseForDirectoryWithoutModuleData()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(tempDir);

try
{
var result = _scanner.IsValidGameDirectory(tempDir);
Assert.False(result);
}
finally
{
Directory.Delete(tempDir, true);
}
}

[Fact]
public void IsValidGameDirectory_ReturnsTrueForValidBannerlordDirectory()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
var modulesDir = Path.Combine(tempDir, "Modules");
var nativeDir = Path.Combine(modulesDir, "Native");

Directory.CreateDirectory(nativeDir);

try
{
var result = _scanner.IsValidGameDirectory(tempDir);
Assert.True(result);
}
finally
{
Directory.Delete(tempDir, true);
}
}

[Fact]
public void GetGameVersion_ReturnsNullForNonExistentDirectory()
{
var result = _scanner.GetGameVersion("/nonexistent/path");
Assert.Null(result);
}

[Fact]
public void GetGameVersion_ReadsFromVersionFile()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(tempDir);

try
{
var versionFile = Path.Combine(tempDir, "version.txt");
File.WriteAllText(versionFile, "1.2.9.0");

var result = _scanner.GetGameVersion(tempDir);
Assert.Equal("1.2.9.0", result);
}
finally
{
Directory.Delete(tempDir, true);
}
}

[Fact]
public void GetGameVersion_ReadsFromConfigFile()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(tempDir);

try
{
var configFile = Path.Combine(tempDir, "config.txt");
File.WriteAllText(configFile, "version=1.3.15.0\nother_setting=value");

var result = _scanner.GetGameVersion(tempDir);
Assert.Equal("1.3.15.0", result);
}
finally
{
Directory.Delete(tempDir, true);
}
}
}
}
251 changes: 251 additions & 0 deletions BannerlordModEditor.Common/Services/GameDirectoryScanner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks;

namespace BannerlordModEditor.Common.Services
{
public class GameDirectoryScanner : IGameDirectoryScanner
{
private const string BannerlordAppId = "261550";
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BannerlordAppId is currently unused in this implementation, which adds noise and suggests incomplete/abandoned logic. Either remove it, or use it to improve Steam/Epic detection (e.g., by correlating app manifests) so the constant has a clear purpose.

Suggested change
private const string BannerlordAppId = "261550";

Copilot uses AI. Check for mistakes.
private const string BannerlordFolderName = "Mount & Blade II Bannerlord";
private const string ModuleDataRelativePath = "Modules";

public async Task<List<GameDirectoryInfo>> ScanForGameDirectoriesAsync()
{
return await Task.Run(() =>
{
var results = new List<GameDirectoryInfo>();

var steamPaths = GetSteamLibraryPaths();
foreach (var steamPath in steamPaths)
{
var bannerlordPath = Path.Combine(steamPath, "steamapps", "common", BannerlordFolderName);
if (Directory.Exists(bannerlordPath))
{
var info = CreateGameDirectoryInfo(bannerlordPath, "Steam");
results.Add(info);
}
}

var gogPaths = GetGogPaths();
foreach (var gogPath in gogPaths)
{
if (Directory.Exists(gogPath))
{
var info = CreateGameDirectoryInfo(gogPath, "GOG");
results.Add(info);
}
}

var epicPaths = GetEpicPaths();
foreach (var epicPath in epicPaths)
{
if (Directory.Exists(epicPath))
{
var info = CreateGameDirectoryInfo(epicPath, "Epic");
results.Add(info);
}
}

return results.Where(r => r.IsValid).ToList();
});
}

public async Task<string?> GetFirstGameDirectoryAsync()
{
var directories = await ScanForGameDirectoriesAsync();
return directories.FirstOrDefault()?.Path;
}

public bool IsValidGameDirectory(string path)
{
if (string.IsNullOrEmpty(path) || !Directory.Exists(path))
return false;

var moduleDataPath = Path.Combine(path, ModuleDataRelativePath);
if (!Directory.Exists(moduleDataPath))
return false;

var nativeModulePath = Path.Combine(moduleDataPath, "Native");
return Directory.Exists(nativeModulePath);
}

public string? GetGameVersion(string gameDirectory)
{
if (string.IsNullOrEmpty(gameDirectory) || !Directory.Exists(gameDirectory))
return null;

var versionFile = Path.Combine(gameDirectory, "version.txt");
if (File.Exists(versionFile))
{
return File.ReadAllText(versionFile).Trim();
}

var configFile = Path.Combine(gameDirectory, "config.txt");
if (File.Exists(configFile))
{
var lines = File.ReadAllLines(configFile);
var versionLine = lines.FirstOrDefault(l => l.StartsWith("version=", StringComparison.OrdinalIgnoreCase));
if (versionLine != null)
{
return versionLine["version=".Length..].Trim();
}
}

return null;
}

private GameDirectoryInfo CreateGameDirectoryInfo(string path, string source)
{
var info = new GameDirectoryInfo
{
Path = path,
Source = source,
IsValid = IsValidGameDirectory(path),
ModuleDataPath = Path.Combine(path, ModuleDataRelativePath),
Version = GetGameVersion(path)
};
return info;
}

private List<string> GetSteamLibraryPaths()
{
var paths = new List<string>();

if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
paths.Add(Path.Combine(programFiles, "Steam"));

var defaultPaths = new[]
{
@"C:\Program Files (x86)\Steam",
@"C:\Program Files\Steam",
@"D:\Steam",
@"E:\Steam",
@"F:\Steam"
};
paths.AddRange(defaultPaths);

var steamConfigFile = Path.Combine(programFiles, "Steam", "steamapps", "libraryfolders.vdf");
paths.AddRange(ParseSteamLibraryFolders(steamConfigFile));
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
paths.Add(Path.Combine(home, ".steam", "steam"));
paths.Add(Path.Combine(home, ".local", "share", "Steam"));

var steamConfigFile = Path.Combine(home, ".steam", "steam", "steamapps", "libraryfolders.vdf");
paths.AddRange(ParseSteamLibraryFolders(steamConfigFile));
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
paths.Add(Path.Combine(home, "Library", "Application Support", "Steam"));

var steamConfigFile = Path.Combine(home, "Library", "Application Support", "Steam", "steamapps", "libraryfolders.vdf");
paths.AddRange(ParseSteamLibraryFolders(steamConfigFile));
}

return paths.Where(p => !string.IsNullOrEmpty(p)).Distinct().ToList();
}

private List<string> ParseSteamLibraryFolders(string configFile)
{
var paths = new List<string>();

if (!File.Exists(configFile))
return paths;

try
{
var lines = File.ReadAllLines(configFile);
foreach (var line in lines)
{
var trimmed = line.Trim();
if (trimmed.StartsWith("\"path\"", StringComparison.OrdinalIgnoreCase))
{
var parts = trimmed.Split('"', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 4)
{
var path = parts[3].Replace("\\\\", "\\");
if (Directory.Exists(path))
{
paths.Add(path);
}
}
}
Comment on lines +169 to +181
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

libraryfolders.vdf lines like "path" "D:\\SteamLibrary" will typically split into 2 tokens (path, D:\\SteamLibrary) with the current Split('"', RemoveEmptyEntries) usage, so parts.Length >= 4 will never be satisfied and Steam libraries from the VDF won’t be discovered. Consider parsing the VDF with a more robust approach (e.g., regex to capture the second quoted string, or a minimal VDF parser) and extracting the value token directly.

Copilot uses AI. Check for mistakes.
}
}
catch
{
}
Comment on lines +184 to +186
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swallowing all exceptions silently makes detection failures hard to diagnose (e.g., malformed VDF, permission issues). At minimum, catch specific exceptions and either log/debug-trace them or include a comment explaining why it’s safe to ignore; otherwise troubleshooting “why auto-detect doesn’t work” becomes very difficult.

Copilot uses AI. Check for mistakes.

return paths;
}

private List<string> GetGogPaths()
{
var paths = new List<string>();

if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);

paths.Add(Path.Combine(programFiles, "GOG Galaxy", "Games", BannerlordFolderName));
paths.Add(Path.Combine(programFilesX86, "GOG Galaxy", "Games", BannerlordFolderName));
}

return paths;
}

private List<string> GetEpicPaths()
{
var paths = new List<string>();

if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
paths.Add(Path.Combine(programFiles, "Epic Games", BannerlordFolderName));

var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var epicManifestsDir = Path.Combine(localAppData, "EpicGamesLauncher", "Saved", "Config", "Windows");

if (Directory.Exists(epicManifestsDir))
{
var manifestFiles = Directory.GetFiles(epicManifestsDir, "*.item");
foreach (var manifest in manifestFiles)
{
try
{
var content = File.ReadAllText(manifest);
if (content.Contains("Mount", StringComparison.OrdinalIgnoreCase) &&
content.Contains("Blade", StringComparison.OrdinalIgnoreCase))
{
var installDirMatch = System.Text.RegularExpressions.Regex.Match(content, @"""InstallLocation""\s*:\s*""([^""]+)""");
if (installDirMatch.Success)
{
var installPath = installDirMatch.Groups[1].Value.Replace("\\\\", "\\");
if (Directory.Exists(installPath))
{
paths.Add(installPath);
}
}
}
}
catch
{
}
}
}
}

return paths;
}
}
}
Loading
Loading