From a5ee729d71faa54fd82c2ca7ed435b3862a9aa98 Mon Sep 17 00:00:00 2001 From: ModifAmorphic Date: Tue, 30 Jun 2026 18:25:04 -0700 Subject: [PATCH 1/2] feat(magos-modificus): implement Phase 1 Steam discovery library Replace the Phase 0 Steam stub with the real ISteamService: discovers the Steam install, Darktide install, compatdata, and Proton version for the current OS, reports missing pieces via DiscoveryStatus + nullable fields (the escape hatch), and detects whether the game is running. Designed for testability per the architecture: the OS-specific search roots, the Windows registry read (ISteamRegistryReader), and the process lookup (IProcessLookup) are all injected so the full discovery pipeline runs against synthetic Steam layouts in temp dirs. AddSteam() wires production defaults via TryAdd, so tests (and hosts with custom paths) override by pre-registering. Linux: native root ~/.local/share/Steam then Flatpak fallback (warned); libraryfolders.vdf parsed for multi-library; Darktide located under steamapps/common; compatdata under steamapps/compatdata/1361210; Proton heuristic = Proton - Experimental, then highest-versioned Proton X.Y (requires a proton script, so the Darktide game dir is never mistaken for a Proton build), then compatibilitytools.d, then null (escape hatch). Windows: HKCU\Software\Valve\Steam\SteamPath then the default path; Proton/compatdata null by design. No new NuGet dependency: Microsoft.Win32.Registry is framework-provided on net10.0, guarded by OperatingSystem.IsWindows() so it compiles on Linux and is a no-op there. 38 xUnit tests cover Complete/Partial/Failed discovery, Flatpak detection, all four Proton-selection branches (+ a regression guard), the VDF parser, game-running, and Windows registry/default resolution via a fake reader. --- magos-modificus/magos-modificus.sln | 15 + magos-modificus/steam/IProcessLookup.cs | 16 + magos-modificus/steam/ISteamRegistryReader.cs | 17 + magos-modificus/steam/ISteamService.cs | 85 ++++ magos-modificus/steam/LibraryFoldersVdf.cs | 60 +++ .../steam/Magos.Modificus.Steam.csproj | 17 + magos-modificus/steam/ProcessLookup.cs | 36 ++ .../steam/ServiceCollectionExtensions.cs | 29 ++ .../steam/SteamDiscoveryOptions.cs | 91 +++++ magos-modificus/steam/SteamModule.cs | 28 -- magos-modificus/steam/SteamRegistryReader.cs | 43 +++ magos-modificus/steam/SteamService.cs | 365 ++++++++++++++++++ .../FlatpakDiscoveryTests.cs | 42 ++ .../GameRunningTests.cs | 38 ++ .../LibraryFoldersVdfTests.cs | 150 +++++++ .../LinuxDiscoveryTests.cs | 155 ++++++++ .../Magos.Modificus.Steam.Tests.csproj | 30 ++ .../ProtonSelectionTests.cs | 133 +++++++ .../SteamFixture.cs | 200 ++++++++++ .../SteamServiceCollectionExtensionsTests.cs | 92 +++++ .../WindowsDiscoveryTests.cs | 109 ++++++ 21 files changed, 1723 insertions(+), 28 deletions(-) create mode 100644 magos-modificus/steam/IProcessLookup.cs create mode 100644 magos-modificus/steam/ISteamRegistryReader.cs create mode 100644 magos-modificus/steam/ISteamService.cs create mode 100644 magos-modificus/steam/LibraryFoldersVdf.cs create mode 100644 magos-modificus/steam/ProcessLookup.cs create mode 100644 magos-modificus/steam/ServiceCollectionExtensions.cs create mode 100644 magos-modificus/steam/SteamDiscoveryOptions.cs delete mode 100644 magos-modificus/steam/SteamModule.cs create mode 100644 magos-modificus/steam/SteamRegistryReader.cs create mode 100644 magos-modificus/steam/SteamService.cs create mode 100644 magos-modificus/tests/Magos.Modificus.Steam.Tests/FlatpakDiscoveryTests.cs create mode 100644 magos-modificus/tests/Magos.Modificus.Steam.Tests/GameRunningTests.cs create mode 100644 magos-modificus/tests/Magos.Modificus.Steam.Tests/LibraryFoldersVdfTests.cs create mode 100644 magos-modificus/tests/Magos.Modificus.Steam.Tests/LinuxDiscoveryTests.cs create mode 100644 magos-modificus/tests/Magos.Modificus.Steam.Tests/Magos.Modificus.Steam.Tests.csproj create mode 100644 magos-modificus/tests/Magos.Modificus.Steam.Tests/ProtonSelectionTests.cs create mode 100644 magos-modificus/tests/Magos.Modificus.Steam.Tests/SteamFixture.cs create mode 100644 magos-modificus/tests/Magos.Modificus.Steam.Tests/SteamServiceCollectionExtensionsTests.cs create mode 100644 magos-modificus/tests/Magos.Modificus.Steam.Tests/WindowsDiscoveryTests.cs diff --git a/magos-modificus/magos-modificus.sln b/magos-modificus/magos-modificus.sln index dca93d2a..430738c1 100644 --- a/magos-modificus/magos-modificus.sln +++ b/magos-modificus/magos-modificus.sln @@ -41,6 +41,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Magos.Modificus.General.Tes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Magos.Modificus.Profiles.Tests", "tests\Magos.Modificus.Profiles.Tests\Magos.Modificus.Profiles.Tests.csproj", "{B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Magos.Modificus.Steam.Tests", "tests\Magos.Modificus.Steam.Tests\Magos.Modificus.Steam.Tests.csproj", "{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -171,6 +173,18 @@ Global {B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}.Release|x64.Build.0 = Release|Any CPU {B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}.Release|x86.ActiveCfg = Release|Any CPU {B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}.Release|x86.Build.0 = Release|Any CPU + {2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Debug|x64.ActiveCfg = Debug|Any CPU + {2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Debug|x64.Build.0 = Debug|Any CPU + {2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Debug|x86.ActiveCfg = Debug|Any CPU + {2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Debug|x86.Build.0 = Debug|Any CPU + {2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Release|Any CPU.Build.0 = Release|Any CPU + {2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Release|x64.ActiveCfg = Release|Any CPU + {2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Release|x64.Build.0 = Release|Any CPU + {2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Release|x86.ActiveCfg = Release|Any CPU + {2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -186,5 +200,6 @@ Global {5952A950-2AE3-43BC-8891-28D81B2B638E} = {FA674B5A-3394-926C-2B1E-70E5B00E4A5C} {A3D79190-AD1E-428E-A0B0-224AE2A5A2BF} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {2E1647E7-8D82-44EB-ADB6-FAFA9AC42200} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/magos-modificus/steam/IProcessLookup.cs b/magos-modificus/steam/IProcessLookup.cs new file mode 100644 index 00000000..7fd52616 --- /dev/null +++ b/magos-modificus/steam/IProcessLookup.cs @@ -0,0 +1,16 @@ +namespace Magos.Modificus.Steam; + +/// +/// Process lookup used by . Abstracted +/// so the game-running check is deterministic and mockable in tests — the real +/// check (Process.GetProcessesByName) would be non-deterministic against +/// CI runners and platform-dependent in its naming rules. +/// +public interface IProcessLookup +{ + /// + /// True if at least one running process matches . + /// Never throws — process enumeration failures degrade to "not running." + /// + bool IsRunning(string processName); +} diff --git a/magos-modificus/steam/ISteamRegistryReader.cs b/magos-modificus/steam/ISteamRegistryReader.cs new file mode 100644 index 00000000..686f192f --- /dev/null +++ b/magos-modificus/steam/ISteamRegistryReader.cs @@ -0,0 +1,17 @@ +namespace Magos.Modificus.Steam; + +/// +/// Reads the Windows registry for the Steam install path. Abstracted so the +/// discoverer's Windows path resolution is unit-testable on Linux (where the +/// real registry is unavailable). Production implementation is +/// SteamRegistryReader (Windows-only; returns null elsewhere). +/// +public interface ISteamRegistryReader +{ + /// + /// Returns the Steam install path from + /// HKCU\Software\Valve\Steam\SteamPath, or null on non-Windows / if + /// the value is absent / unreadable. + /// + string? GetSteamPath(); +} diff --git a/magos-modificus/steam/ISteamService.cs b/magos-modificus/steam/ISteamService.cs new file mode 100644 index 00000000..48f8aa24 --- /dev/null +++ b/magos-modificus/steam/ISteamService.cs @@ -0,0 +1,85 @@ +namespace Magos.Modificus.Steam; + +/// +/// Steam discovery + game-running detection. Steam **discovers** everything +/// needed to launch Darktide modded on the current OS (Steam install, Darktide +/// install, compatdata, Proton version) and reports missing pieces via +/// ; it does NOT set env vars or invoke +/// Proton — that is Enginseer-client's job (consuming the ). +/// +/// +/// Phase 1 → Phase 3 stability: the discovery result is a flat +/// record of nullables — Phase 3 (UI) reads it and the null fields drive the +/// escape-hatch prompt form. A future Phase (non-steam shortcuts, Phase 5) adds +/// methods here; the interface is designed to grow cleanly. +/// +public interface ISteamService +{ + /// + /// Probes the OS-appropriate Steam install locations and resolves the + /// Steam install, Darktide install, compatdata, and Proton version. Never + /// throws on missing pieces — those are reported via + /// + the nullable fields (the escape hatch). + /// + DiscoveryResult Discover(); + + /// + /// Whether Darktide is currently running. Cross-platform best-effort check + /// against the game's process name; Phase 1 uses the simple name match + /// (Linux-under-Proton naming may differ — refine if it proves wrong). + /// + bool IsGameRunning(); +} + +/// +/// The outcome of a Steam discovery pass. Fields are nullable: a null means +/// "couldn't resolve this — the UI should prompt for it" (the escape hatch). +/// summarizes whether everything critical for the current +/// OS was found. +/// +/// Steam client dir → STEAM_COMPAT_CLIENT_INSTALL_PATH. +/// Native path to Darktide.exe +/// (Enginseer-client Z:\-translates on Linux for --game-binary). +/// Wine prefix → STEAM_COMPAT_DATA_PATH (Linux only). +/// The proton script for proton run (Linux only). +/// Informational label (e.g. "Proton - Experimental"). +/// Complete / Partial / Failed — see . +/// Non-fatal notes (e.g. "Flatpak Steam detected", Proton-selection reason). +public sealed record DiscoveryResult( + string? SteamInstallPath, + string? DarktideGameBinaryPath, + string? CompatdataPath, + string? ProtonBinaryPath, + string? ProtonVersion, + DiscoveryStatus Status, + IReadOnlyList Warnings); + +/// +/// Coarse status of a discovery pass: +/// +/// CompleteEvery critical field for the current OS is non-null. +/// PartialSteam was located but some critical fields are missing +/// (the nullables indicate what the UI should prompt for). +/// FailedCould not even locate Steam (UI prompts for the Steam dir). +/// +/// +public enum DiscoveryStatus +{ + Complete, + Partial, + Failed, +} + +/// +/// The platform discovery runs against. Production picks this from the runtime +/// OS; tests can force a platform to exercise cross-platform logic on one OS. +/// Darktide ships on Windows (native) and Linux (Proton) only. +/// +public enum DiscoveryPlatform +{ + /// Linux: discovers Steam + Darktide + compatdata + Proton. + Linux, + + /// Windows: discovers Steam + Darktide only (native; Proton/compatdata unused). + Windows, +} diff --git a/magos-modificus/steam/LibraryFoldersVdf.cs b/magos-modificus/steam/LibraryFoldersVdf.cs new file mode 100644 index 00000000..38f23413 --- /dev/null +++ b/magos-modificus/steam/LibraryFoldersVdf.cs @@ -0,0 +1,60 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Magos.Modificus.Steam; + +/// +/// Minimal parser for Steam's libraryfolders.vdf. The file maps numbered +/// library entries to their root "path" values; this extracts those +/// paths in document order — enough to drive multi-library Darktide discovery +/// without pulling in a heavyweight VDF dependency. +/// +/// +/// VDF stores Windows paths with C-style escapes (\\ for a single +/// backslash); the parser unescapes \\\ and \" → +/// ". Linux Steam writes forward slashes (no escapes). +/// The match is case-insensitive on the key (Steam writes lowercase +/// "path") to be forgiving of hand-edited fixtures, and anchored to the +/// "path" key so it won't match arbitrary path-like values elsewhere in +/// the structure. +/// +internal static class LibraryFoldersVdf +{ + // Matches: "path" "" (whitespace between key and value, value is quote-delimited) + private static readonly Regex PathPattern = new( + @"""path""\s+""(?(?:\\.|[^""\\])*)""", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + /// + /// Parses libraryfolders.vdf content → the library root paths, in + /// document order. Empty / whitespace input yields an empty list. Duplicate + /// paths are preserved (the caller de-dups if it cares). + /// + public static IReadOnlyList Parse(string content) + { + if (string.IsNullOrWhiteSpace(content)) + { + return Array.Empty(); + } + + var paths = new List(); + foreach (Match match in PathPattern.Matches(content)) + { + var raw = match.Groups["value"].Value; + var unescaped = Unescape(raw); + if (!string.IsNullOrWhiteSpace(unescaped)) + { + paths.Add(unescaped.Trim()); + } + } + + return paths; + } + + // Unescapes the minimal VDF escapes that can appear in a path value. + private static string Unescape(string value) => + value + .Replace("\\\\", "\u0000", StringComparison.Ordinal) // protect \\ first + .Replace("\\\"", "\"", StringComparison.Ordinal) + .Replace("\u0000", "\\", StringComparison.Ordinal); +} diff --git a/magos-modificus/steam/Magos.Modificus.Steam.csproj b/magos-modificus/steam/Magos.Modificus.Steam.csproj index 5b9856ff..a41762b6 100644 --- a/magos-modificus/steam/Magos.Modificus.Steam.csproj +++ b/magos-modificus/steam/Magos.Modificus.Steam.csproj @@ -5,9 +5,26 @@ Magos.Modificus.Steam + + + + + + diff --git a/magos-modificus/steam/ProcessLookup.cs b/magos-modificus/steam/ProcessLookup.cs new file mode 100644 index 00000000..7fefdce6 --- /dev/null +++ b/magos-modificus/steam/ProcessLookup.cs @@ -0,0 +1,36 @@ +using System.ComponentModel; +using System.Diagnostics; + +namespace Magos.Modificus.Steam; + +/// +/// Production backed by +/// . Swallows enumeration +/// failures (e.g. permission denied on some Linux setups) as "not running" +/// rather than surfacing them through . +/// +internal sealed class ProcessLookup : IProcessLookup +{ + public bool IsRunning(string processName) + { + if (string.IsNullOrEmpty(processName)) + { + return false; + } + + try + { + return Process.GetProcessesByName(processName).Length > 0; + } + catch (Win32Exception) + { + // Process enumeration can be denied (e.g. restricted Linux runners); + // treat as "not running" so a launch isn't blocked on a false negative. + return false; + } + catch (InvalidOperationException) + { + return false; + } + } +} diff --git a/magos-modificus/steam/ServiceCollectionExtensions.cs b/magos-modificus/steam/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..344d581b --- /dev/null +++ b/magos-modificus/steam/ServiceCollectionExtensions.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Magos.Modificus.Steam; + +/// DI registration for the Steam library. +public static class ServiceCollectionExtensions +{ + /// + /// Registers and its + /// supporting services (discovery options + platform seams). Resolves the + /// real OS defaults via . + /// + /// + /// Supporting services (, + /// , ) are + /// registered with TryAdd so tests (and hosts with custom paths) can + /// pre-register overrides — the discovery pipeline is then fully exercisable + /// against fixture layouts. + /// + public static IServiceCollection AddSteam(this IServiceCollection services) + { + services.TryAddSingleton(_ => SteamDiscoveryOptions.CreateDefault()); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/magos-modificus/steam/SteamDiscoveryOptions.cs b/magos-modificus/steam/SteamDiscoveryOptions.cs new file mode 100644 index 00000000..cf3b8814 --- /dev/null +++ b/magos-modificus/steam/SteamDiscoveryOptions.cs @@ -0,0 +1,91 @@ +namespace Magos.Modificus.Steam; + +/// +/// OS-specific inputs to Steam discovery. Carries the candidate Steam install +/// roots + auxiliary paths so the discoverer never hardcodes +/// ~/.local/share/Steam — production wires the real OS defaults via +/// , tests inject fixture paths. +/// controls which fields are consulted and whether Proton/compatdata are +/// discovered (Linux only). +/// +/// +/// This is the testability seam mandated by the architecture: by injecting the +/// search roots + platform, the full discovery pipeline can be exercised +/// against synthetic layouts in temp dirs on any OS. +/// +public sealed class SteamDiscoveryOptions +{ + /// The platform to discover for. Production: detected at runtime. + public DiscoveryPlatform Platform { get; set; } = DetectPlatform(); + + /// + /// The default native Linux Steam root (typically + /// ~/.local/share/Steam). Probed first on Linux. + /// + public string? LinuxDefaultSteamRoot { get; set; } + + /// + /// The Flatpak Linux Steam root (typically + /// ~/.var/app/com.valvesoftware.Steam/data/Steam). Probed as a Linux + /// fallback; resolving here raises a Flatpak warning. + /// + public string? LinuxFlatpakSteamRoot { get; set; } + + /// + /// The ProtonUp-GE / custom-builds dir probed as a Proton fallback + /// (typically ~/.local/share/Steam/compatibilitytools.d). Linux only. + /// + public string? LinuxCompatibilityToolsDir { get; set; } + + /// + /// The Windows Steam install fallback path used when the registry yields + /// nothing (typically C:\Program Files (x86)\Steam). Windows only. + /// + public string? WindowsDefaultSteamRoot { get; set; } + + /// Steam's Darktide app id. Constant; overridable for tests. + public int DarktideAppId { get; set; } = 1361210; + + /// + /// Darktide's directory name under steamapps/common/. Overridable for + /// tests so fixtures can use a short name. + /// + public string DarktideCommonDir { get; set; } = "Warhammer 40,000 DARKTIDE"; + + /// + /// The game binary name under <common>/binaries/. Overridable + /// for tests. + /// + public string GameBinaryName { get; set; } = "Darktide.exe"; + + /// + /// The process-name stem used by . + /// Overridable for tests / future Linux-naming refinement. + /// + public string GameProcessName { get; set; } = "Darktide"; + + /// + /// Builds the default options for the current OS. Resolves the real user + /// profile + standard Steam locations; picks from the + /// runtime OS. + /// + public static SteamDiscoveryOptions CreateDefault() => new() + { + Platform = DetectPlatform(), + LinuxDefaultSteamRoot = HomeSubpath(".local/share/Steam"), + LinuxFlatpakSteamRoot = HomeSubpath(".var/app/com.valvesoftware.Steam/data/Steam"), + LinuxCompatibilityToolsDir = HomeSubpath(".local/share/Steam/compatibilitytools.d"), + WindowsDefaultSteamRoot = @"C:\Program Files (x86)\Steam", + }; + + private static DiscoveryPlatform DetectPlatform() => + // Darktide ships on Windows + Linux only; treat anything else as Windows + // (the only other realistic host) so discovery is well-defined. + OperatingSystem.IsLinux() ? DiscoveryPlatform.Linux : DiscoveryPlatform.Windows; + + private static string? HomeSubpath(string subpath) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, Environment.SpecialFolderOption.None); + return string.IsNullOrEmpty(home) ? null : Path.Combine(home, subpath); + } +} diff --git a/magos-modificus/steam/SteamModule.cs b/magos-modificus/steam/SteamModule.cs deleted file mode 100644 index 3a9ddba7..00000000 --- a/magos-modificus/steam/SteamModule.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace Magos.Modificus.Steam; - -/// -/// Steam operations outside Enginseer: locate Steam, find the Darktide install -/// + compatdata + Proton version, add/remove non-steam shortcuts, detect -/// whether the game is running. Stub — implemented in a later phase. See -/// docs/architecture/MAGOS-MODIFICUS.md. -/// -public interface ISteamService -{ -} - -internal sealed class SteamService : ISteamService -{ -} - -/// DI registration for the Steam library. -public static class ServiceCollectionExtensions -{ - /// Registers the Steam library services. - public static IServiceCollection AddSteam(this IServiceCollection services) - { - services.AddSingleton(); - return services; - } -} diff --git a/magos-modificus/steam/SteamRegistryReader.cs b/magos-modificus/steam/SteamRegistryReader.cs new file mode 100644 index 00000000..603c6bd3 --- /dev/null +++ b/magos-modificus/steam/SteamRegistryReader.cs @@ -0,0 +1,43 @@ +using System.Security; +using Microsoft.Win32; + +namespace Magos.Modificus.Steam; + +/// +/// Windows-only backed by +/// HKCU\Software\Valve\Steam\SteamPath. Returns null on non-Windows +/// platforms (where the registry is unavailable) and swallows permission / IO +/// failures as "not found" — discovery treats a missing registry value as a +/// signal to fall back to the default path. +/// +internal sealed class SteamRegistryReader : ISteamRegistryReader +{ + private const string SteamSubKey = @"HKEY_CURRENT_USER\Software\Valve\Steam"; + private const string SteamPathValue = "SteamPath"; + + public string? GetSteamPath() + { + if (!OperatingSystem.IsWindows()) + { + return null; + } + + try + { + return Registry.GetValue(SteamSubKey, SteamPathValue, null) as string; + } + catch (SecurityException) + { + // No permission to read the key — fall back to the default path. + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + catch (IOException) + { + return null; + } + } +} diff --git a/magos-modificus/steam/SteamService.cs b/magos-modificus/steam/SteamService.cs new file mode 100644 index 00000000..dd3a4752 --- /dev/null +++ b/magos-modificus/steam/SteamService.cs @@ -0,0 +1,365 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; + +namespace Magos.Modificus.Steam; + +/// +/// implementation. Resolves a Steam install from +/// injected (OS-specific, test-overridable) candidate roots, then derives the +/// Darktide install, compatdata, and Proton version relative to it. All +/// filesystem misses are reported via + +/// nullable fields — never throws on missing pieces. +/// +/// +/// Registered as a singleton: the service holds no per-call state. All +/// OS-specific inputs () + platform seams +/// (, ) are +/// injected so the full pipeline is exercisable against fixture layouts. +/// +internal sealed class SteamService : ISteamService +{ + private readonly SteamDiscoveryOptions _options; + private readonly ISteamRegistryReader _registry; + private readonly IProcessLookup _processes; + private readonly ILogger _logger; + + public SteamService( + SteamDiscoveryOptions options, + ISteamRegistryReader registry, + IProcessLookup processes, + ILogger logger) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + _processes = processes ?? throw new ArgumentNullException(nameof(processes)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public DiscoveryResult Discover() => _options.Platform == DiscoveryPlatform.Linux + ? DiscoverLinux() + : DiscoverWindows(); + + /// + public bool IsGameRunning() => _processes.IsRunning(_options.GameProcessName); + + // ---- Linux ------------------------------------------------------------- + + private DiscoveryResult DiscoverLinux() + { + var warnings = new List(); + + // Ordered candidates: native default first, then Flatpak. The first one + // whose libraryfolders.vdf exists wins; Flatpak is flagged for a warning. + var resolved = ResolveRoot( + new RootCandidate(_options.LinuxDefaultSteamRoot, IsFlatpak: false, FromRegistry: false), + new RootCandidate(_options.LinuxFlatpakSteamRoot, IsFlatpak: true, FromRegistry: false)); + + if (resolved.Path is null) + { + _logger.LogWarning("Steam install not found (no candidate carried a valid libraryfolders.vdf)."); + return Failed(warnings); + } + + if (resolved.IsFlatpak) + { + warnings.Add("Flatpak Steam detected; some Steam integrations may be limited."); + } + + var libraries = ReadLibraries(resolved.Path, warnings); + var darktide = FindDarktide(libraries, warnings); + var compatdata = FindCompatdata(resolved.Path); + var proton = FindProton(resolved.Path, _options.LinuxCompatibilityToolsDir, warnings); + + var status = StatusForLinux(resolved.Path, darktide, compatdata, proton?.Path); + _logger.LogInformation( + "Linux discovery: {Status} (steam={Steam}, darktide={Darktide}, compatdata={Compatdata}, proton={Proton}).", + status, resolved.Path, darktide ?? "(missing)", compatdata ?? "(missing)", proton?.Path ?? "(missing)"); + + return new DiscoveryResult( + SteamInstallPath: resolved.Path, + DarktideGameBinaryPath: darktide, + CompatdataPath: compatdata, + ProtonBinaryPath: proton?.Path, + ProtonVersion: proton?.Version, + Status: status, + Warnings: warnings); + } + + // ---- Windows ----------------------------------------------------------- + + private DiscoveryResult DiscoverWindows() + { + var warnings = new List(); + + // Registry first (authoritative when present), then the default path. + var registryPath = _registry.GetSteamPath(); + var resolved = ResolveRoot( + new RootCandidate(registryPath, IsFlatpak: false, FromRegistry: true), + new RootCandidate(_options.WindowsDefaultSteamRoot, IsFlatpak: false, FromRegistry: false)); + + if (resolved.Path is null) + { + _logger.LogWarning("Steam install not found (registry + default both invalid)."); + return Failed(warnings); + } + + warnings.Add(resolved.FromRegistry + ? "Steam install resolved from registry." + : "Steam install resolved from default path (registry yielded nothing)."); + + var libraries = ReadLibraries(resolved.Path, warnings); + var darktide = FindDarktide(libraries, warnings); + + var status = StatusForWindows(resolved.Path, darktide); + _logger.LogInformation( + "Windows discovery: {Status} (steam={Steam}, darktide={Darktide}).", + status, resolved.Path, darktide ?? "(missing)"); + + // Compatdata/Proton are null by design on Windows (native — not used). + return new DiscoveryResult( + SteamInstallPath: resolved.Path, + DarktideGameBinaryPath: darktide, + CompatdataPath: null, + ProtonBinaryPath: null, + ProtonVersion: null, + Status: status, + Warnings: warnings); + } + + // ---- shared resolution steps ------------------------------------------ + + private ResolvedRoot ResolveRoot(params RootCandidate[] candidates) + { + foreach (var candidate in candidates) + { + if (!string.IsNullOrWhiteSpace(candidate.Path) && SteamRootIsValid(candidate.Path)) + { + return new ResolvedRoot(candidate.Path, candidate.IsFlatpak, candidate.FromRegistry); + } + } + return new ResolvedRoot(Path: null, IsFlatpak: false, FromRegistry: false); + } + + private bool SteamRootIsValid(string root) => + Directory.Exists(root) + && File.Exists(Path.Combine(root, "steamapps", "libraryfolders.vdf")); + + private IReadOnlyList ReadLibraries(string steamRoot, List warnings) + { + var vdf = Path.Combine(steamRoot, "steamapps", "libraryfolders.vdf"); + try + { + var content = File.ReadAllText(vdf); + var libs = LibraryFoldersVdf.Parse(content); + + // The Steam install root is always a usable library even if the VDF + // omits it (it normally lists itself as library "0"); ensure it's + // probed so a missing/malformed VDF doesn't hide a locally-installed + // Darktide. De-dup against what the VDF already provided. + if (!libs.Any(l => string.Equals(l, steamRoot, StringComparison.Ordinal))) + { + libs = libs.Append(steamRoot).ToList(); + } + + if (libs.Count > 1) + { + warnings.Add($"Searched {libs.Count} Steam libraries for Darktide."); + } + + return libs; + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Could not read {Vdf}; falling back to Steam root only.", vdf); + warnings.Add("Could not read libraryfolders.vdf; searched Steam root only."); + return new[] { steamRoot }; + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Permission denied reading {Vdf}; falling back to Steam root only.", vdf); + warnings.Add("Permission denied reading libraryfolders.vdf; searched Steam root only."); + return new[] { steamRoot }; + } + } + + private string? FindDarktide(IReadOnlyList libraries, List warnings) + { + foreach (var lib in libraries) + { + var exe = Path.Combine( + lib, "steamapps", "common", + _options.DarktideCommonDir, "binaries", _options.GameBinaryName); + + if (File.Exists(exe)) + { + return exe; + } + } + + _logger.LogInformation("Darktide not found under any Steam library."); + return null; + } + + private string? FindCompatdata(string steamRoot) + { + var dir = Path.Combine( + steamRoot, "steamapps", "compatdata", _options.DarktideAppId.ToString(CultureInfo.InvariantCulture)); + + return Directory.Exists(dir) ? dir : null; + } + + /// + /// Phase 1 Proton heuristic (deep Steam per-game config parsing is deferred): + /// + /// 1Proton - Experimental in steamapps/common (common default). + /// 2The highest-versioned Proton X.Y in steamapps/common. + /// 3The highest-versioned build in the injected compatibilitytools.d (ProtonUp-GE). + /// 4Nothing → null (escape hatch; UI prompts). + /// + /// The chosen source is recorded in . + /// + private (string Path, string Version)? FindProton( + string steamRoot, string? compatToolsDir, List warnings) + { + var common = Path.Combine(steamRoot, "steamapps", "common"); + + // (1) Proton - Experimental — the common Steam default. + const string ExperimentalDir = "Proton - Experimental"; + var experimental = Path.Combine(common, ExperimentalDir, "proton"); + if (File.Exists(experimental)) + { + warnings.Add("Selected Proton - Experimental (default heuristic)."); + return (experimental, ExperimentalDir); + } + + // (2) Highest-versioned Proton X.Y in steamapps/common. + if (BestProton(common) is { } commonBest) + { + warnings.Add($"Selected {commonBest.Dir} (highest-versioned Proton in steamapps/common)."); + return (Path.Combine(common, commonBest.Dir, "proton"), commonBest.Dir); + } + + // (3) A custom build in compatibilitytools.d (ProtonUp-GE). + if (!string.IsNullOrWhiteSpace(compatToolsDir) && BestProton(compatToolsDir) is { } geBest) + { + warnings.Add($"Selected {geBest.Dir} (from compatibilitytools.d)."); + return (Path.Combine(compatToolsDir, geBest.Dir, "proton"), geBest.Dir); + } + + // (4) Nothing found → escape hatch. + warnings.Add("No Proton build found; user will be prompted (escape hatch)."); + return null; + } + + /// + /// Picks the highest-versioned Proton build (by parsed major.minor) under + /// . A candidate must carry a proton entry + /// script (the defining trait of a Proton install — this also excludes the + /// Darktide game dir, which sits in steamapps/common but has no + /// proton). Dir names like Proton 9.0, Proton 5.13, + /// GE-Proton9-3; Proton - Experimental (no version) is + /// intentionally excluded here — it's handled explicitly upstream. Ties keep + /// directory-enumeration order. + /// + private static (string Dir, Version Version)? BestProton(string parent) + { + if (!Directory.Exists(parent)) + { + return null; + } + + IEnumerable dirs; + try + { + dirs = Directory.EnumerateDirectories(parent); + } + catch (DirectoryNotFoundException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + + (string Dir, Version Ver) best = default; + var found = false; + foreach (var dir in dirs) + { + if (!File.Exists(Path.Combine(dir, "proton"))) + { + continue; + } + + var name = Path.GetFileName(dir); + var version = TryParseProtonVersion(name); + if (version is null) + { + continue; + } + + if (!found || version.CompareTo(best.Ver) > 0) + { + best = (name, version); + found = true; + } + } + + return found ? best : null; + } + + // Matches the first ()? in a Proton dir name, where + // is '.' (official "Proton 9.0") or '-' (custom "GE-Proton9-3"). e.g. + // "Proton 9.0" -> 9.0, "Proton 5.13" -> 5.13, "Proton 5.0-10" -> 5.0 + // (build patch ignored), "GE-Proton9-3" -> 9.3. "Proton - Experimental" + // (no digits) -> null. + private static Version? TryParseProtonVersion(string name) + { + var match = Regex.Match(name, @"(\d+)(?:[.-](\d+))?", RegexOptions.CultureInvariant); + if (!match.Success) + { + return null; + } + + var major = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + var minor = match.Groups[2].Success + ? int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture) + : 0; + return new Version(major, minor); + } + + // ---- status + failure helpers ----------------------------------------- + + private static DiscoveryStatus StatusForLinux(string? steam, string? darktide, string? compatdata, string? proton) => + (steam, darktide, compatdata, proton) switch + { + (null, _, _, _) => DiscoveryStatus.Failed, + (_, null, _, _) => DiscoveryStatus.Partial, + (_, _, null, _) => DiscoveryStatus.Partial, + (_, _, _, null) => DiscoveryStatus.Partial, + _ => DiscoveryStatus.Complete, + }; + + private static DiscoveryStatus StatusForWindows(string? steam, string? darktide) => + steam is null ? DiscoveryStatus.Failed + : darktide is null ? DiscoveryStatus.Partial + : DiscoveryStatus.Complete; + + private static DiscoveryResult Failed(IReadOnlyList warnings) => + new( + SteamInstallPath: null, + DarktideGameBinaryPath: null, + CompatdataPath: null, + ProtonBinaryPath: null, + ProtonVersion: null, + Status: DiscoveryStatus.Failed, + Warnings: warnings); + + // ---- small private types ---------------------------------------------- + + private sealed record RootCandidate(string? Path, bool IsFlatpak, bool FromRegistry); + + private sealed record ResolvedRoot(string? Path, bool IsFlatpak, bool FromRegistry); +} diff --git a/magos-modificus/tests/Magos.Modificus.Steam.Tests/FlatpakDiscoveryTests.cs b/magos-modificus/tests/Magos.Modificus.Steam.Tests/FlatpakDiscoveryTests.cs new file mode 100644 index 00000000..2ada558d --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.Steam.Tests/FlatpakDiscoveryTests.cs @@ -0,0 +1,42 @@ +namespace Magos.Modificus.Steam.Tests; + +/// +/// Flatpak Steam detection: when the resolved Steam install is the Flatpak +/// candidate, a non-fatal warning surfaces (the UI can flag it — some Steam +/// integrations are limited under Flatpak). +/// +public sealed class FlatpakDiscoveryTests +{ + [Fact] + public void Flatpak_root_resolving_emits_flatpak_warning() + { + using var fx = new SteamFixture(); + // Only the Flatpak root is a valid Steam install. + fx.WithLibraryFoldersAtFlatpakRoot(); + fx.WithDarktide(fx.FlatpakRoot); + fx.WithCompatdata(fx.FlatpakRoot); + fx.WithProtonInCommon(fx.FlatpakRoot, "Proton - Experimental"); + + var result = fx.Service.Discover(); + + Assert.Equal(DiscoveryStatus.Complete, result.Status); + Assert.Equal(fx.FlatpakRoot, result.SteamInstallPath); + Assert.Contains(result.Warnings, w => w.Contains("Flatpak", StringComparison.Ordinal)); + } + + [Fact] + public void Native_root_resolving_emits_no_flatpak_warning() + { + using var fx = new SteamFixture(); + fx.WithLibraryFoldersAtSteamRoot(); + fx.WithDarktide(fx.SteamRoot); + fx.WithCompatdata(fx.SteamRoot); + fx.WithProtonInCommon(fx.SteamRoot, "Proton - Experimental"); + + var result = fx.Service.Discover(); + + Assert.Equal(DiscoveryStatus.Complete, result.Status); + Assert.Equal(fx.SteamRoot, result.SteamInstallPath); + Assert.DoesNotContain(result.Warnings, w => w.Contains("Flatpak", StringComparison.Ordinal)); + } +} diff --git a/magos-modificus/tests/Magos.Modificus.Steam.Tests/GameRunningTests.cs b/magos-modificus/tests/Magos.Modificus.Steam.Tests/GameRunningTests.cs new file mode 100644 index 00000000..858013b4 --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.Steam.Tests/GameRunningTests.cs @@ -0,0 +1,38 @@ +namespace Magos.Modificus.Steam.Tests; + +/// +/// against the injected +/// : known-absent name → false (deterministic on +/// CI), known-present → true. Proves the check is mockable + wired through. +/// +public sealed class GameRunningTests +{ + [Fact] + public void Known_absent_process_returns_false() + { + using var fx = new SteamFixture(); + // FakeProcessLookup defaults to nothing running. + + Assert.False(fx.Service.IsGameRunning()); + } + + [Fact] + public void Known_present_process_returns_true() + { + using var fx = new SteamFixture(); + fx.Processes.Running.Add("Darktide"); // matches the default GameProcessName + + Assert.True(fx.Service.IsGameRunning()); + } + + [Fact] + public void Custom_process_name_is_honored() + { + // A future Linux-under-Proton refinement might use a different name; prove + // the option flows through to the lookup. + using var fx = new SteamFixture(configure: o => o.GameProcessName = "darktide.exe"); + fx.Processes.Running.Add("darktide.exe"); + + Assert.True(fx.Service.IsGameRunning()); + } +} diff --git a/magos-modificus/tests/Magos.Modificus.Steam.Tests/LibraryFoldersVdfTests.cs b/magos-modificus/tests/Magos.Modificus.Steam.Tests/LibraryFoldersVdfTests.cs new file mode 100644 index 00000000..73918dd2 --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.Steam.Tests/LibraryFoldersVdfTests.cs @@ -0,0 +1,150 @@ +namespace Magos.Modificus.Steam.Tests; + +/// +/// Focused unit tests for the minimal libraryfolders.vdf parser: +/// extracts library root paths from realistic Steam output, handles Windows +/// backslash escaping + whitespace, and degrades gracefully on empty input. +/// +public sealed class LibraryFoldersVdfTests +{ + [Fact] + public void Parses_realistic_single_library_vdf() + { + var vdf = """ + "libraryfolders" + { + "0" + { + "path" "/home/user/.local/share/Steam" + "label" "" + "contentid" "1234567890123456789" + "apps" + { + "1361210" "12345678" + } + } + } + """; + + var libs = LibraryFoldersVdf.Parse(vdf); + + var lib = Assert.Single(libs); + Assert.Equal("/home/user/.local/share/Steam", lib); + } + + [Fact] + public void Parses_multiple_libraries_in_document_order() + { + var vdf = """ + "libraryfolders" + { + "0" + { + "path" "/home/user/.local/share/Steam" + } + "1" + { + "path" "/mnt/games/steamlibrary" + } + "2" + { + "path" "/media/external/SteamLibrary" + } + } + """; + + var libs = LibraryFoldersVdf.Parse(vdf); + + Assert.Equal(3, libs.Count); + Assert.Equal("/home/user/.local/share/Steam", libs[0]); + Assert.Equal("/mnt/games/steamlibrary", libs[1]); + Assert.Equal("/media/external/SteamLibrary", libs[2]); + } + + [Fact] + public void Unescapes_windows_backslash_paths() + { + // Windows Steam writes paths with VDF backslash escaping. + var vdf = """ + "libraryfolders" + { + "0" + { + "path" "C:\\Program Files (x86)\\Steam" + } + } + """; + + var libs = LibraryFoldersVdf.Parse(vdf); + + var lib = Assert.Single(libs); + Assert.Equal(@"C:\Program Files (x86)\Steam", lib); + } + + [Fact] + public void Handles_irregular_whitespace_between_key_and_value() + { + var vdf = """ + "libraryfolders" + { + "0" + { + "path" "/home/user/Steam" + } + } + """; + + var libs = LibraryFoldersVdf.Parse(vdf); + + var lib = Assert.Single(libs); + Assert.Equal("/home/user/Steam", lib); + } + + [Fact] + public void Only_path_values_are_extracted_other_keys_ignored() + { + // "contentid" / app ids / numbered library keys must not leak in. + var vdf = """ + "libraryfolders" + { + "0" + { + "path" "/a/Steam" + "contentid" "999" + "apps" + { + "1361210" "1" + } + } + } + """; + + var libs = LibraryFoldersVdf.Parse(vdf); + + var lib = Assert.Single(libs); + Assert.Equal("/a/Steam", lib); + } + + [Fact] + public void Empty_or_whitespace_input_yields_empty_list() + { + Assert.Empty(LibraryFoldersVdf.Parse("")); + Assert.Empty(LibraryFoldersVdf.Parse(" \n\t ")); + } + + [Fact] + public void No_path_keys_yields_empty_list() + { + var vdf = """ + "libraryfolders" + { + "0" + { + "label" "" + } + } + """; + + Assert.Empty(LibraryFoldersVdf.Parse(vdf)); + } +} diff --git a/magos-modificus/tests/Magos.Modificus.Steam.Tests/LinuxDiscoveryTests.cs b/magos-modificus/tests/Magos.Modificus.Steam.Tests/LinuxDiscoveryTests.cs new file mode 100644 index 00000000..e1c7a4c7 --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.Steam.Tests/LinuxDiscoveryTests.cs @@ -0,0 +1,155 @@ +namespace Magos.Modificus.Steam.Tests; + +/// +/// Linux discovery contract against a synthetic Steam layout: Complete happy +/// path, Partial (missing compatdata / missing Darktide / missing Proton), +/// Failed (no Steam), and multi-library search. +/// +public sealed class LinuxDiscoveryTests +{ + [Fact] + public void Complete_layout_returns_Complete_with_correct_paths() + { + using var fx = new SteamFixture(); + fx.WithLibraryFoldersAtSteamRoot(); + fx.WithDarktide(fx.SteamRoot); + fx.WithCompatdata(fx.SteamRoot); + fx.WithProtonInCommon(fx.SteamRoot, "Proton - Experimental"); + + var result = fx.Service.Discover(); + + Assert.Equal(DiscoveryStatus.Complete, result.Status); + Assert.Equal(fx.SteamRoot, result.SteamInstallPath); + Assert.Equal(fx.ExpectedDarktidePath(fx.SteamRoot), result.DarktideGameBinaryPath); + Assert.Equal(fx.ExpectedCompatdataPath(fx.SteamRoot), result.CompatdataPath); + Assert.Equal(fx.ExpectedProtonPath(fx.SteamRoot, "Proton - Experimental"), result.ProtonBinaryPath); + Assert.Equal("Proton - Experimental", result.ProtonVersion); + Assert.NotNull(result.Warnings); + Assert.Contains(result.Warnings, w => w.Contains("Proton - Experimental", StringComparison.Ordinal)); + } + + [Fact] + public void Missing_compatdata_returns_Partial_with_null_CompatdataPath() + { + using var fx = new SteamFixture(); + fx.WithLibraryFoldersAtSteamRoot(); + fx.WithDarktide(fx.SteamRoot); + fx.WithProtonInCommon(fx.SteamRoot, "Proton - Experimental"); + // No compatdata → the only gap. + + var result = fx.Service.Discover(); + + Assert.Equal(DiscoveryStatus.Partial, result.Status); + Assert.NotNull(result.SteamInstallPath); + Assert.NotNull(result.DarktideGameBinaryPath); + Assert.Null(result.CompatdataPath); + Assert.NotNull(result.ProtonBinaryPath); + } + + [Fact] + public void Missing_darktide_returns_Partial_with_null_DarktideGameBinaryPath() + { + using var fx = new SteamFixture(); + fx.WithLibraryFoldersAtSteamRoot(); + fx.WithCompatdata(fx.SteamRoot); + fx.WithProtonInCommon(fx.SteamRoot, "Proton - Experimental"); + + var result = fx.Service.Discover(); + + Assert.Equal(DiscoveryStatus.Partial, result.Status); + Assert.NotNull(result.SteamInstallPath); + Assert.Null(result.DarktideGameBinaryPath); + } + + [Fact] + public void Missing_proton_returns_Partial_with_null_ProtonBinaryPath() + { + using var fx = new SteamFixture(); + fx.WithLibraryFoldersAtSteamRoot(); + fx.WithDarktide(fx.SteamRoot); + fx.WithCompatdata(fx.SteamRoot); + // No Proton anywhere. + + var result = fx.Service.Discover(); + + Assert.Equal(DiscoveryStatus.Partial, result.Status); + Assert.Null(result.ProtonBinaryPath); + Assert.Null(result.ProtonVersion); + // Escape-hatch warning surfaces so the UI knows to prompt. + Assert.Contains(result.Warnings, w => w.Contains("No Proton build found", StringComparison.Ordinal)); + } + + [Fact] + public void No_steam_at_all_returns_Failed_with_all_nulls() + { + using var fx = new SteamFixture(); + // Nothing scaffolded — no native root, no flatpak. + + var result = fx.Service.Discover(); + + Assert.Equal(DiscoveryStatus.Failed, result.Status); + Assert.Null(result.SteamInstallPath); + Assert.Null(result.DarktideGameBinaryPath); + Assert.Null(result.CompatdataPath); + Assert.Null(result.ProtonBinaryPath); + } + + [Fact] + public void Darktide_in_secondary_library_is_found() + { + // Two libraries: Darktide lives in the secondary one — proves the VDF is + // parsed + each library is probed in order. + using var fx = new SteamFixture(); + var secondary = Path.Combine(fx.TempRoot, "secondary-lib"); + Directory.CreateDirectory(secondary); + fx.WithLibraryFoldersAtSteamRoot(fx.SteamRoot, secondary); + fx.WithDarktide(secondary); + fx.WithCompatdata(fx.SteamRoot); + fx.WithProtonInCommon(fx.SteamRoot, "Proton - Experimental"); + + var result = fx.Service.Discover(); + + Assert.Equal(DiscoveryStatus.Complete, result.Status); + Assert.Equal(fx.ExpectedDarktidePath(secondary), result.DarktideGameBinaryPath); + // Multi-library search surfaces a non-fatal warning. + Assert.Contains(result.Warnings, w => w.Contains("Searched 2 Steam libraries", StringComparison.Ordinal)); + } + + [Fact] + public void Missing_steam_root_falls_back_to_flatpak_when_valid() + { + // Native root absent; flatpak root is a valid Steam install → resolves there + // (and flags Flatpak — covered explicitly in FlatpakDiscoveryTests too). + using var fx = new SteamFixture(); + fx.WithLibraryFoldersAtFlatpakRoot(); + fx.WithDarktide(fx.FlatpakRoot); + fx.WithCompatdata(fx.FlatpakRoot); + fx.WithProtonInCommon(fx.FlatpakRoot, "Proton - Experimental"); + + var result = fx.Service.Discover(); + + Assert.Equal(DiscoveryStatus.Complete, result.Status); + Assert.Equal(fx.FlatpakRoot, result.SteamInstallPath); + } + + [Fact] + public void Steam_root_still_searched_when_vdf_omits_it() + { + // The VDF lists only a secondary library (a malformed/incomplete VDF that + // omits the Steam install root itself). Darktide lives in the Steam root's + // own steamapps/common — discovery must still find it by treating the + // resolved Steam root as an implicit library. + using var fx = new SteamFixture(); + var secondary = Path.Combine(fx.TempRoot, "listed-lib"); + Directory.CreateDirectory(secondary); + fx.WithLibraryFoldersAtSteamRoot(secondary); // VDF omits fx.SteamRoot + fx.WithDarktide(fx.SteamRoot); // Darktide is at the Steam root + fx.WithCompatdata(fx.SteamRoot); + fx.WithProtonInCommon(fx.SteamRoot, "Proton - Experimental"); + + var result = fx.Service.Discover(); + + Assert.Equal(DiscoveryStatus.Complete, result.Status); + Assert.Equal(fx.ExpectedDarktidePath(fx.SteamRoot), result.DarktideGameBinaryPath); + } +} diff --git a/magos-modificus/tests/Magos.Modificus.Steam.Tests/Magos.Modificus.Steam.Tests.csproj b/magos-modificus/tests/Magos.Modificus.Steam.Tests/Magos.Modificus.Steam.Tests.csproj new file mode 100644 index 00000000..d3f6319e --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.Steam.Tests/Magos.Modificus.Steam.Tests.csproj @@ -0,0 +1,30 @@ + + + + false + true + Magos.Modificus.Steam.Tests + Magos.Modificus.Steam.Tests + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + diff --git a/magos-modificus/tests/Magos.Modificus.Steam.Tests/ProtonSelectionTests.cs b/magos-modificus/tests/Magos.Modificus.Steam.Tests/ProtonSelectionTests.cs new file mode 100644 index 00000000..79279ff9 --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.Steam.Tests/ProtonSelectionTests.cs @@ -0,0 +1,133 @@ +namespace Magos.Modificus.Steam.Tests; + +/// +/// The Phase 1 Proton selection heuristic, step by step: +/// 1. Proton - Experimental in steamapps/common wins when present. +/// 2. Else the highest-versioned Proton X.Y in steamapps/common. +/// 3. Else a build under compatibilitytools.d (ProtonUp-GE). +/// 4. Else null (escape hatch). +/// Each chosen source is recorded in . +/// +public sealed class ProtonSelectionTests +{ + [Fact] + public void Experimental_present_is_chosen_over_numbered_versions() + { + using var fx = new SteamFixture(); + fx.WithLibraryFoldersAtSteamRoot(); + fx.WithDarktide(fx.SteamRoot); + fx.WithCompatdata(fx.SteamRoot); + fx.WithProtonInCommon(fx.SteamRoot, "Proton - Experimental"); + fx.WithProtonInCommon(fx.SteamRoot, "Proton 9.0"); // would be "highest version" + fx.WithProtonInCommon(fx.SteamRoot, "Proton 8.0"); + + var result = fx.Service.Discover(); + + Assert.Equal(fx.ExpectedProtonPath(fx.SteamRoot, "Proton - Experimental"), result.ProtonBinaryPath); + Assert.Equal("Proton - Experimental", result.ProtonVersion); + Assert.Contains(result.Warnings, w => w.Contains("Experimental", StringComparison.Ordinal)); + } + + [Fact] + public void Absent_experimental_picks_highest_versioned_proton() + { + using var fx = new SteamFixture(); + fx.WithLibraryFoldersAtSteamRoot(); + fx.WithDarktide(fx.SteamRoot); + fx.WithCompatdata(fx.SteamRoot); + fx.WithProtonInCommon(fx.SteamRoot, "Proton 5.13"); + fx.WithProtonInCommon(fx.SteamRoot, "Proton 9.0"); + fx.WithProtonInCommon(fx.SteamRoot, "Proton 8.0"); + + var result = fx.Service.Discover(); + + Assert.Equal(fx.ExpectedProtonPath(fx.SteamRoot, "Proton 9.0"), result.ProtonBinaryPath); + Assert.Equal("Proton 9.0", result.ProtonVersion); + Assert.Contains(result.Warnings, w => w.Contains("highest-versioned", StringComparison.Ordinal)); + } + + [Fact] + public void Version_ranking_is_numeric_not_lexicographic() + { + // Lexicographic would put "Proton 5.13" above "Proton 9.0" ("5" < "9" + // but "5.13" > "9.0" string-compared). Numeric: 9.0 > 5.13. + using var fx = new SteamFixture(); + fx.WithLibraryFoldersAtSteamRoot(); + fx.WithDarktide(fx.SteamRoot); + fx.WithCompatdata(fx.SteamRoot); + fx.WithProtonInCommon(fx.SteamRoot, "Proton 5.13"); + fx.WithProtonInCommon(fx.SteamRoot, "Proton 9.0"); + + var result = fx.Service.Discover(); + + Assert.Equal(fx.ExpectedProtonPath(fx.SteamRoot, "Proton 9.0"), result.ProtonBinaryPath); + } + + [Fact] + public void No_common_proton_falls_back_to_compatibility_tools() + { + using var fx = new SteamFixture(); + fx.WithLibraryFoldersAtSteamRoot(); + fx.WithDarktide(fx.SteamRoot); + fx.WithCompatdata(fx.SteamRoot); + // No Proton in steamapps/common; a GE build in compatibilitytools.d. + fx.WithProtonInCompatTools("GE-Proton9-3"); + + var result = fx.Service.Discover(); + + Assert.Equal(fx.ExpectedCompatToolsProtonPath("GE-Proton9-3"), result.ProtonBinaryPath); + Assert.Equal("GE-Proton9-3", result.ProtonVersion); + Assert.Contains(result.Warnings, w => w.Contains("compatibilitytools.d", StringComparison.Ordinal)); + } + + [Fact] + public void Compatibility_tools_ranked_by_parsed_version() + { + using var fx = new SteamFixture(); + fx.WithLibraryFoldersAtSteamRoot(); + fx.WithDarktide(fx.SteamRoot); + fx.WithCompatdata(fx.SteamRoot); + fx.WithProtonInCompatTools("GE-Proton8-26"); + fx.WithProtonInCompatTools("GE-Proton9-3"); + + var result = fx.Service.Discover(); + + Assert.Equal(fx.ExpectedCompatToolsProtonPath("GE-Proton9-3"), result.ProtonBinaryPath); + } + + [Fact] + public void No_proton_anywhere_yields_null_and_escape_hatch_warning() + { + using var fx = new SteamFixture(); + fx.WithLibraryFoldersAtSteamRoot(); + fx.WithDarktide(fx.SteamRoot); + fx.WithCompatdata(fx.SteamRoot); + // No Proton in common, none in compat tools. + + var result = fx.Service.Discover(); + + Assert.Equal(DiscoveryStatus.Partial, result.Status); + Assert.Null(result.ProtonBinaryPath); + Assert.Null(result.ProtonVersion); + Assert.Contains(result.Warnings, w => w.Contains("No Proton build found", StringComparison.Ordinal)); + } + + [Fact] + public void Darktide_game_dir_is_not_mistaken_for_a_proton_build() + { + // Regression guard: "Warhammer 40,000 DARKTIDE" sits in steamapps/common + // and contains digits ("40"), so a naive version-parse would rank it + // (as 40.0) above every real Proton. Proton selection must require a + // `proton` script and so ignore the game dir. + using var fx = new SteamFixture(); + fx.WithLibraryFoldersAtSteamRoot(); + fx.WithDarktide(fx.SteamRoot); + fx.WithCompatdata(fx.SteamRoot); + fx.WithProtonInCommon(fx.SteamRoot, "Proton 9.0"); + + var result = fx.Service.Discover(); + + Assert.Equal(fx.ExpectedProtonPath(fx.SteamRoot, "Proton 9.0"), result.ProtonBinaryPath); + Assert.DoesNotContain(result.ProtonBinaryPath!, "Warhammer", StringComparison.Ordinal); + } +} diff --git a/magos-modificus/tests/Magos.Modificus.Steam.Tests/SteamFixture.cs b/magos-modificus/tests/Magos.Modificus.Steam.Tests/SteamFixture.cs new file mode 100644 index 00000000..732f9e09 --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.Steam.Tests/SteamFixture.cs @@ -0,0 +1,200 @@ +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Magos.Modificus.Steam.Tests; + +/// +/// Per-test fixture: scaffolds a synthetic Steam layout in a fresh temp dir + +/// builds a real through AddSteam() with the +/// discovery options + platform seams pointed at the fixture. Disposes the temp +/// tree + the service provider on teardown so tests are isolated regardless of +/// outcome. +/// +/// +/// Resolving via DI (rather than constructing the internal implementation +/// directly) keeps tests black-box against and +/// proves the real registration path — the same approach the Profiles fixture +/// uses. / +/// / are pre-registered so AddSteam()'s +/// TryAdd defaults are skipped in favor of the fixture's fakes. +/// +internal sealed class SteamFixture : IDisposable +{ + private readonly ServiceProvider _provider; + private readonly SteamDiscoveryOptions _options; + + public string TempRoot { get; } + public string SteamRoot { get; } // the "native" Linux / Windows fixture Steam install + public string FlatpakRoot { get; } // the Flatpak candidate + public string CompatToolsDir { get; } // compatibilitytools.d candidate + public FakeRegistryReader Registry { get; } = new(); + public FakeProcessLookup Processes { get; } = new(); + public ISteamService Service { get; } + + public SteamFixture( + DiscoveryPlatform platform = DiscoveryPlatform.Linux, + Action? configure = null) + { + TempRoot = Path.Combine(Path.GetTempPath(), "magos-steam-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(TempRoot); + SteamRoot = Path.Combine(TempRoot, "Steam"); + FlatpakRoot = Path.Combine(TempRoot, "flatpak-Steam"); + CompatToolsDir = Path.Combine(TempRoot, "compatibilitytools.d"); + + _options = new SteamDiscoveryOptions + { + Platform = platform, + LinuxDefaultSteamRoot = SteamRoot, + LinuxFlatpakSteamRoot = FlatpakRoot, + LinuxCompatibilityToolsDir = CompatToolsDir, + // Reuse the fixture root for Windows tests (registry supplies it via + // a fake rather than a real second path). + WindowsDefaultSteamRoot = SteamRoot, + }; + configure?.Invoke(_options); + + var services = new ServiceCollection(); + services.AddSingleton(_options); + services.AddSingleton(Registry); + services.AddSingleton(Processes); + services.AddLogging(b => b.SetMinimumLevel(LogLevel.Warning)); // quiet by default + services.AddSteam(); + _provider = services.BuildServiceProvider(); + + Service = _provider.GetRequiredService(); + } + + // ---- layout helpers (fluent; return this) ----------------------------- + + /// + /// Writes a libraryfolders.vdf under the native Steam root listing the + /// given library roots. With no args, lists the Steam root itself (a valid + /// single-library layout). + /// + public SteamFixture WithLibraryFoldersAtSteamRoot(params string[] libraryPaths) + { + Directory.CreateDirectory(Path.Combine(SteamRoot, "steamapps")); + var libs = libraryPaths.Length == 0 ? new[] { SteamRoot } : libraryPaths; + File.WriteAllText( + Path.Combine(SteamRoot, "steamapps", "libraryfolders.vdf"), + BuildLibraryFoldersVdf(libs)); + return this; + } + + /// Same as but writes + /// under the Flatpak root so the Flatpak candidate is the one that resolves. + public SteamFixture WithLibraryFoldersAtFlatpakRoot(params string[] libraryPaths) + { + Directory.CreateDirectory(Path.Combine(FlatpakRoot, "steamapps")); + var libs = libraryPaths.Length == 0 ? new[] { FlatpakRoot } : libraryPaths; + File.WriteAllText( + Path.Combine(FlatpakRoot, "steamapps", "libraryfolders.vdf"), + BuildLibraryFoldersVdf(libs)); + return this; + } + + /// Creates an empty Darktide.exe under <libraryRoot>/steamapps/common/<DarktideCommonDir>/binaries/. + public SteamFixture WithDarktide(string libraryRoot) + { + var exe = Path.Combine( + libraryRoot, "steamapps", "common", + _options.DarktideCommonDir, "binaries", _options.GameBinaryName); + Directory.CreateDirectory(Path.GetDirectoryName(exe)!); + File.WriteAllText(exe, string.Empty); + return this; + } + + /// Creates the compatdata dir for the configured app id under the given Steam root. + public SteamFixture WithCompatdata(string steamRoot) + { + Directory.CreateDirectory(Path.Combine( + steamRoot, "steamapps", "compatdata", _options.DarktideAppId.ToString())); + return this; + } + + /// Creates a proton file under <steamRoot>/steamapps/common/<dirName>/. + public SteamFixture WithProtonInCommon(string steamRoot, string dirName) + { + var proton = Path.Combine(steamRoot, "steamapps", "common", dirName, "proton"); + Directory.CreateDirectory(Path.GetDirectoryName(proton)!); + File.WriteAllText(proton, string.Empty); + return this; + } + + /// Creates a proton file under compatibilitytools.d/<dirName>/. + public SteamFixture WithProtonInCompatTools(string dirName) + { + var proton = Path.Combine(CompatToolsDir, dirName, "proton"); + Directory.CreateDirectory(Path.GetDirectoryName(proton)!); + File.WriteAllText(proton, string.Empty); + return this; + } + + // ---- expected-path helpers (assertions) ------------------------------- + + public string ExpectedDarktidePath(string libraryRoot) => Path.Combine( + libraryRoot, "steamapps", "common", + _options.DarktideCommonDir, "binaries", _options.GameBinaryName); + + public string ExpectedCompatdataPath(string steamRoot) => Path.Combine( + steamRoot, "steamapps", "compatdata", _options.DarktideAppId.ToString()); + + public string ExpectedProtonPath(string parent, string dirName) => + Path.Combine(parent, "steamapps", "common", dirName, "proton"); + + public string ExpectedCompatToolsProtonPath(string dirName) => + Path.Combine(CompatToolsDir, dirName, "proton"); + + // ---- static VDF builder ------------------------------------------------ + + /// Builds a realistic minimal libraryfolders.vdf body listing the given library roots. + public static string BuildLibraryFoldersVdf(params string[] libraryPaths) + { + var sb = new StringBuilder(); + sb.AppendLine("\"libraryfolders\""); + sb.AppendLine("{"); + for (var i = 0; i < libraryPaths.Length; i++) + { + sb.AppendLine($"\t\"{i}\""); + sb.AppendLine("\t{"); + sb.AppendLine($"\t\t\"path\"\t\t\"{EscapeVdfValue(libraryPaths[i])}\""); + sb.AppendLine("\t\t\"label\"\t\t\"\""); + sb.AppendLine("\t\t\"contentid\"\t\t\"0\""); + sb.AppendLine("\t\t\"apps\""); + sb.AppendLine("\t\t{"); + sb.AppendLine("\t\t}"); + sb.AppendLine("\t}"); + } + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string EscapeVdfValue(string value) => + value.Replace("\\", "\\\\", StringComparison.Ordinal); + + public void Dispose() + { + _provider.Dispose(); + if (Directory.Exists(TempRoot)) + { + // Best-effort; temp dirs under the OS temp are harmless if left. + try { Directory.Delete(TempRoot, recursive: true); } + catch (IOException) { /* ignored */ } + } + } +} + +/// Test double for ; returns . +internal sealed class FakeRegistryReader : ISteamRegistryReader +{ + public string? SteamPath { get; set; } + public string? GetSteamPath() => SteamPath; +} + +/// Test double for ; reports the names in as running. +internal sealed class FakeProcessLookup : IProcessLookup +{ + public HashSet Running { get; } = new(StringComparer.Ordinal); + public bool IsRunning(string processName) => Running.Contains(processName); +} diff --git a/magos-modificus/tests/Magos.Modificus.Steam.Tests/SteamServiceCollectionExtensionsTests.cs b/magos-modificus/tests/Magos.Modificus.Steam.Tests/SteamServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..ae501a16 --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.Steam.Tests/SteamServiceCollectionExtensionsTests.cs @@ -0,0 +1,92 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Magos.Modificus.Steam.Tests; + +/// +/// Proves AddSteam() registers (and its +/// supporting services) so the whole thing is resolvable from DI with the +/// production defaults — and that pre-registered overrides (the fixture's fakes) +/// win over the defaults via TryAdd. +/// +public sealed class SteamServiceCollectionExtensionsTests +{ + [Fact] + public void AddSteam_registers_resolvable_ISteamService_with_defaults() + { + var services = new ServiceCollection(); + services.AddLogging(b => b.SetMinimumLevel(LogLevel.Warning)); + services.AddSteam(); + using var provider = services.BuildServiceProvider(); + + var service = provider.GetService(); + + Assert.NotNull(service); + Assert.IsAssignableFrom(service); + } + + [Fact] + public void AddSteam_resolves_supporting_services() + { + var services = new ServiceCollection(); + services.AddLogging(b => b.SetMinimumLevel(LogLevel.Warning)); + services.AddSteam(); + using var provider = services.BuildServiceProvider(); + + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + } + + [Fact] + public void AddSteam_pre_registered_options_win_over_defaults() + { + // A host/tests can override the discovery inputs; TryAdd must defer. + var custom = new SteamDiscoveryOptions { Platform = DiscoveryPlatform.Windows }; + + var services = new ServiceCollection(); + services.AddLogging(b => b.SetMinimumLevel(LogLevel.Warning)); + services.AddSingleton(custom); + services.AddSteam(); + using var provider = services.BuildServiceProvider(); + + var resolved = provider.GetRequiredService(); + Assert.Same(custom, resolved); + } + + [Fact] + public void AddSteam_is_idempotent_and_returns_same_collection() + { + var services = new ServiceCollection(); + + var returned = services.AddSteam(); + + Assert.Same(services, returned); + } + + [Fact] + public void CreateDefault_populates_paths_for_current_os() + { + var opts = SteamDiscoveryOptions.CreateDefault(); + + // Platform reflects the runtime; both OSes get their default paths wired. + if (OperatingSystem.IsLinux()) + { + Assert.Equal(DiscoveryPlatform.Linux, opts.Platform); + Assert.NotNull(opts.LinuxDefaultSteamRoot); + Assert.NotNull(opts.LinuxFlatpakSteamRoot); + Assert.NotNull(opts.LinuxCompatibilityToolsDir); + } + else + { + Assert.Equal(DiscoveryPlatform.Windows, opts.Platform); + Assert.NotNull(opts.WindowsDefaultSteamRoot); + } + + // Darktide identity is the real one regardless of OS. + Assert.Equal(1361210, opts.DarktideAppId); + Assert.Equal("Warhammer 40,000 DARKTIDE", opts.DarktideCommonDir); + Assert.Equal("Darktide.exe", opts.GameBinaryName); + Assert.Equal("Darktide", opts.GameProcessName); + } +} diff --git a/magos-modificus/tests/Magos.Modificus.Steam.Tests/WindowsDiscoveryTests.cs b/magos-modificus/tests/Magos.Modificus.Steam.Tests/WindowsDiscoveryTests.cs new file mode 100644 index 00000000..d798b237 --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.Steam.Tests/WindowsDiscoveryTests.cs @@ -0,0 +1,109 @@ +namespace Magos.Modificus.Steam.Tests; + +/// +/// Windows discovery: registry resolution (mockable on Linux), default-path +/// fallback, Failed when neither resolves, Complete/Partial by Darktide, and +/// Compatdata/Proton null by design (Windows native). +/// +/// +/// These force with a fake +/// so the Windows path logic runs on Linux CI. +/// +public sealed class WindowsDiscoveryTests +{ + [Fact] + public void Registry_path_resolves_and_is_marked_as_from_registry() + { + using var fx = new SteamFixture(platform: DiscoveryPlatform.Windows); + fx.WithLibraryFoldersAtSteamRoot(); // the fixture SteamRoot is what the fake registry returns + fx.Registry.SteamPath = fx.SteamRoot; + fx.WithDarktide(fx.SteamRoot); + + var result = fx.Service.Discover(); + + Assert.Equal(DiscoveryStatus.Complete, result.Status); + Assert.Equal(fx.SteamRoot, result.SteamInstallPath); + Assert.Equal(fx.ExpectedDarktidePath(fx.SteamRoot), result.DarktideGameBinaryPath); + Assert.Contains(result.Warnings, w => w.Contains("registry", StringComparison.Ordinal)); + // Windows never discovers Proton/compatdata. + Assert.Null(result.CompatdataPath); + Assert.Null(result.ProtonBinaryPath); + Assert.Null(result.ProtonVersion); + } + + [Fact] + public void Registry_null_falls_back_to_default_path() + { + using var fx = new SteamFixture(platform: DiscoveryPlatform.Windows); + fx.WithLibraryFoldersAtSteamRoot(); + fx.Registry.SteamPath = null; // registry yields nothing + fx.WithDarktide(fx.SteamRoot); + + var result = fx.Service.Discover(); + + // Default path (WindowsDefaultSteamRoot) is the fixture SteamRoot here. + Assert.Equal(DiscoveryStatus.Complete, result.Status); + Assert.Equal(fx.SteamRoot, result.SteamInstallPath); + Assert.Contains(result.Warnings, w => w.Contains("default path", StringComparison.Ordinal)); + } + + [Fact] + public void Registry_path_invalid_falls_back_to_default() + { + using var fx = new SteamFixture(platform: DiscoveryPlatform.Windows); + fx.WithLibraryFoldersAtSteamRoot(); + fx.Registry.SteamPath = Path.Combine(fx.TempRoot, "does-not-exist"); // invalid + fx.WithDarktide(fx.SteamRoot); + + var result = fx.Service.Discover(); + + Assert.Equal(DiscoveryStatus.Complete, result.Status); + Assert.Equal(fx.SteamRoot, result.SteamInstallPath); + Assert.Contains(result.Warnings, w => w.Contains("default path", StringComparison.Ordinal)); + } + + [Fact] + public void Both_registry_and_default_invalid_returns_Failed() + { + // Nothing scaffolded: the fixture SteamRoot dir isn't created unless a + // layout helper runs, so it (the Windows default) + the (null) registry + // are both invalid. + using var fx = new SteamFixture(platform: DiscoveryPlatform.Windows); + fx.Registry.SteamPath = null; + + var result = fx.Service.Discover(); + + Assert.Equal(DiscoveryStatus.Failed, result.Status); + Assert.Null(result.SteamInstallPath); + } + + [Fact] + public void Missing_darktide_returns_Partial() + { + using var fx = new SteamFixture(platform: DiscoveryPlatform.Windows); + fx.WithLibraryFoldersAtSteamRoot(); + fx.Registry.SteamPath = fx.SteamRoot; + // No Darktide. + + var result = fx.Service.Discover(); + + Assert.Equal(DiscoveryStatus.Partial, result.Status); + Assert.Null(result.DarktideGameBinaryPath); + } + + [Fact] + public void Darktide_in_secondary_library_is_found() + { + using var fx = new SteamFixture(platform: DiscoveryPlatform.Windows); + var secondary = Path.Combine(fx.TempRoot, "win-secondary-lib"); + Directory.CreateDirectory(secondary); + fx.WithLibraryFoldersAtSteamRoot(fx.SteamRoot, secondary); + fx.Registry.SteamPath = fx.SteamRoot; + fx.WithDarktide(secondary); + + var result = fx.Service.Discover(); + + Assert.Equal(DiscoveryStatus.Complete, result.Status); + Assert.Equal(fx.ExpectedDarktidePath(secondary), result.DarktideGameBinaryPath); + } +} From 5c51dbc00120d66976e3b7e9183adb582db8b4f6 Mon Sep 17 00:00:00 2001 From: ModifAmorphic Date: Tue, 30 Jun 2026 18:25:13 -0700 Subject: [PATCH 2/2] docs(magos-modificus): set both Proton env vars in Linux launch section The Launch > Linux section previously stated Magos sets only STEAM_COMPAT_DATA_PATH when invoking Proton. The live-validated working invocation sets both STEAM_COMPAT_DATA_PATH (the Wine prefix) and STEAM_COMPAT_CLIENT_INSTALL_PATH (the Steam install dir). Steam discovers both; Enginseer-client sets both. Update the constraint sentence, the invocation bullet, and the Enginseer-unchanged closing. --- docs/architecture/MAGOS-MODIFICUS.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/architecture/MAGOS-MODIFICUS.md b/docs/architecture/MAGOS-MODIFICUS.md index 8022d376..03f5972f 100644 --- a/docs/architecture/MAGOS-MODIFICUS.md +++ b/docs/architecture/MAGOS-MODIFICUS.md @@ -207,8 +207,11 @@ a Windows binary, so to run it Magos invokes it under **Proton**, using runs, to decide which prefix to use. By the time the launcher executes it's already inside that prefix; it cannot relocate itself, and Darktide inherits the prefix regardless. So the compatdata must be set by whoever invokes Proton -— it is not passable as a launcher flag. **Magos sets -`STEAM_COMPAT_DATA_PATH` in the environment when it invokes Proton.** +— it is not passable as a launcher flag. **Magos sets both +`STEAM_COMPAT_DATA_PATH` (the Wine prefix) and +`STEAM_COMPAT_CLIENT_INSTALL_PATH` (the Steam install dir) in the environment +when it invokes Proton.** (The live-validated working invocation set both env +vars.) Steam discovers both; Enginseer-client sets both. Responsibilities: @@ -228,12 +231,14 @@ Responsibilities: - Translate the profile's native mod-path → `Z:\...` (and confirm `--game-binary` is the in-prefix Windows path). - Assemble the launcher args. - - `Process.Start` with `STEAM_COMPAT_DATA_PATH = ` in env, + - `Process.Start` with `STEAM_COMPAT_DATA_PATH = ` and + `STEAM_COMPAT_CLIENT_INSTALL_PATH = ` in env, command = ` run /magos_launcher.exe `. **Enginseer is unchanged on Linux** — no Linux helper, no Steam/Proton discovery, no new flag. It remains the Windows launcher + shell + mod_loader, -run under Proton with `STEAM_COMPAT_DATA_PATH` set by Magos. +run under Proton with `STEAM_COMPAT_DATA_PATH` + `STEAM_COMPAT_CLIENT_INSTALL_PATH` +set by Magos. **Known characteristic (not a defect):** when Magos launches directly, Steam isn't supervising the session (no overlay / playtime tracking). The **Steam