diff --git a/docs/reference/magos-modificus/enginseer-client.md b/docs/reference/magos-modificus/enginseer-client.md index 87a51e9d..191b1719 100644 --- a/docs/reference/magos-modificus/enginseer-client.md +++ b/docs/reference/magos-modificus/enginseer-client.md @@ -23,13 +23,15 @@ expected conditions: - Resolves Steam discovery first (`ISteamService.Discover()`). If discovery is missing required fields for the current OS, returns `DiscoveryIncomplete` **without** writing the profile's mod root (no point writing `mods.lst` for a - launch that won't happen) — `MissingDiscoveryFields` lists them. + launch that won't happen) — `MissingDiscoveryFields` lists them. The + per-platform required set comes from the active `IPlatformLaunchStrategy`. - Prepares the mod root (`IProfileService.PrepareModRoot(profileId)` — writes `mods.lst` and returns the `--mod-path`). An unknown profile (`KeyNotFoundException` from PrepareModRoot) is caught and mapped to `Error`. - Checks that the launcher exists at `/magos_launcher.exe`. -- Assembles the launcher args and spawns the launcher via `IProcessLauncher` - (directly on Windows; under `proton run` on Linux). +- Spawns the launcher via the active `IPlatformLaunchStrategy` (directly on + Windows; under `proton run` on Linux) — the service itself contains no + per-launch OS branch. ```csharp public sealed record LaunchResult( @@ -49,28 +51,42 @@ public enum LaunchStatus { Launched, DiscoveryIncomplete, Error } `MissingDiscoveryFields` is derived from the `DiscoveryResult` fields directly (both platforms need Steam + the game binary; Linux additionally needs compatdata + Proton), so it and `DiscoveryStatus` cannot diverge — it is equivalent to -`Status != Complete`. +`Status != Complete`. The per-platform required set is owned by the active +`IPlatformLaunchStrategy` (`RequiredDiscoveryFields`). ### Injectable seams +- `IPlatformLaunchStrategy` (internal) — the per-platform launch surface: + - `RequiredDiscoveryFields(discovery)` — the discovery fields this platform + requires but could not resolve (Windows: Steam + game binary; Linux: + + compatdata + Proton). + - `Start(launcherPath, discovery, gameBinary, modPath, logFile) → bool` — the + spawn. Windows: a direct invocation of the launcher with native + (untranslated) args; Linux: ` run ` with both + `STEAM_COMPAT_*` env vars and the path-valued flags `Z:\`-translated. + - `Name` — a short label ("Windows" / "Linux") for log messages. + - Two implementations (`WindowsLaunchStrategy`, `LinuxLaunchStrategy`), + selected once at DI time from the host OS (see + [Cross-platform notes](#cross-platform-notes)). - `IProcessLauncher` — `Start(filePath, arguments, environmentVariables) → bool` (fire-and-forget; `true` if started, `false` if it could not start — never throws). Abstracted so the launch path is deterministic and mockable in tests - (the real `Process.Start` would spawn a real process). The default + (the real `Process.Start` would spawn a real process). Injected into the + strategy (not the service) so tests can fake the spawn. The default `ProcessLauncher` uses `ProcessStartInfo.ArgumentList` (argv-correct, no shell, no injection surface) and applies env overrides directly to the child's environment block. -- `LaunchPlatform` (internal enum `Windows` / `Linux`) — resolved once from the - runtime OS via `RuntimeInformation`; tests force it via an internal constructor - to exercise both branches on any CI OS. `WinePath.ToWine(posixPath)` (internal) translates an absolute POSIX path to its -Wine `Z:\` form (`/` → `\`, `Z:` prefix) for the launcher-under-Wine flags. +Wine `Z:\` form (`/` → `\`, `Z:` prefix) for the launcher-under-Wine flags; it is +used only by `LinuxLaunchStrategy`. ## Cross-platform notes -The launch path branches on `LaunchPlatform` (decided once at construction; the -OS does not change at runtime): +The launch path branches on platform via the active `IPlatformLaunchStrategy`, +selected once at DI registration from the host OS — the launch service contains +no per-launch OS branch. Each strategy owns the spawn (via `IProcessLauncher`), +its required discovery fields, and its own log label. ### Windows — direct invocation @@ -116,17 +132,25 @@ shell-level config field can be added if a future need arises. public static IServiceCollection AddEnginseerClient(this IServiceCollection services) { services.TryAddSingleton(); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + services.TryAddSingleton(); + else + services.TryAddSingleton(); + services.AddSingleton(); return services; } ``` -`IProcessLauncher` is `TryAdd` so tests (and hosts wiring a custom launch hook) -can pre-register an override before calling `AddEnginseerClient` — the same -pattern the Steam library uses for its platform seams. `IEnginseerLaunchService` -is `AddSingleton` (holds no per-launch state). Resolves `IProfileService`, -`ISteamService`, `MagosConfig`, `IProcessLauncher`, and -`ILogger` from the container. +`IProcessLauncher` and `IPlatformLaunchStrategy` are `TryAdd` so tests (and hosts +wiring a custom launch hook) can pre-register an override before calling +`AddEnginseerClient` — the same pattern the Steam library uses for its platform +seams. The strategy is selected once, here, from the host OS, so the launch +service contains no per-call OS branch. `IEnginseerLaunchService` is `AddSingleton` +(holds no per-launch state). Resolves `IProfileService`, `ISteamService`, +`MagosConfig`, `IPlatformLaunchStrategy`, and `ILogger` +from the container. ## Dependencies @@ -139,16 +163,17 @@ is `AddSingleton` (holds no per-launch state). Resolves `IProfileService`, `Magos.Modificus.EnginseerClient.Tests` is a **dual-purpose** project. `dotnet test` runs the xUnit suite — `EnginseerLaunchServiceTests` (Windows + Linux arg -assembly, `DiscoveryIncomplete` missing-field derivation, `Error` mapping, the -forced-platform internal constructor), `WinePathTests`, the `AddEnginseerClient` -DI wiring, all against a fake `IProcessLauncher` (`TestDoubles.cs`). -`dotnet run -- ` runs the **composition smoke harness** -under `SmokeHarness/Program.cs` — it composes the **real** services (general + -profiles + steam + enginseer-client, no fakes) via the same `Add()` -chain the UI uses. `launch ` invokes an actual launch against the -user's Steam/Darktide setup, for user-machine validation; `discover` reports the -resolved Steam/Darktide/Proton discovery + `IsGameRunning()`; `list` lists -profiles. +assembly via the concrete `WindowsLaunchStrategy` / `LinuxLaunchStrategy` + a +fake `IProcessLauncher`, `DiscoveryIncomplete` missing-field derivation, `Error` +mapping), `WinePathTests`, the `AddEnginseerClient` DI wiring, all against the +fakes in `TestDoubles.cs`. Tests inject the concrete strategy to exercise either +path on any CI OS. `dotnet run -- ` runs the **composition +smoke harness** under `SmokeHarness/Program.cs` — it composes the **real** +services (general + profiles + steam + enginseer-client, no fakes) via the same +`Add()` chain the UI uses. `launch ` invokes an actual launch +against the user's Steam/Darktide setup, for user-machine validation; `discover` +reports the resolved Steam/Darktide/Proton discovery + `IsGameRunning()`; `list` +lists profiles. ```sh dotnet test magos-modificus/magos-modificus.sln -c Release # xUnit suite diff --git a/docs/reference/magos-modificus/steam.md b/docs/reference/magos-modificus/steam.md index 51ce8404..84195d41 100644 --- a/docs/reference/magos-modificus/steam.md +++ b/docs/reference/magos-modificus/steam.md @@ -18,10 +18,13 @@ public interface ISteamService } ``` -- `Discover()` — 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 `DiscoveryResult.Status` - and the nullable fields (the escape hatch the UI prompts against). +- `Discover()` — delegates to the platform `ISteamDiscoverer` (selected once at + DI registration from `SteamDiscoveryOptions.Platform`), which 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 `DiscoveryResult.Status` and the nullable fields (the + escape hatch the UI prompts against). `SteamService` itself contains no + platform dispatch. - `IsGameRunning()` — cross-platform best-effort check against Darktide's process name. Delegates to the platform `IProcessLookup`; never throws — enumeration failures degrade to "not running." @@ -72,15 +75,31 @@ every OS-specific input + platform seam is injected: Notable fields: `LinuxDefaultSteamRoot`, `LinuxFlatpakSteamRoot`, `LinuxCompatibilityToolsDir`, `WindowsDefaultSteamRoot`, `DarktideAppId` (`1361210`), `DarktideCommonDir`, `GameBinaryName`, `GameProcessName`. +- `ISteamDiscoverer` (internal) — `Discover() → DiscoveryResult`. The + platform-specific discovery strategy. Two implementations + (`LinuxSteamDiscoverer`, `WindowsSteamDiscoverer`), selected once at DI time + from `SteamDiscoveryOptions.Platform` (see [Cross-platform notes](#cross-platform-notes)). +- `SteamDiscoveryCore` (internal) — the shared, platform-agnostic mechanics + (root resolution, `libraryfolders.vdf` reading, Darktide probing, the all-null + failure result) that both discoverers compose. This is composition, not + inheritance — each discoverer injects the core and layers its own platform + steps on top. - `ISteamRegistryReader` — reads the Windows registry for the Steam install path - (`GetSteamPath()` → `HKCU\Software\Valve\Steam\SteamPath`, or null on - non-Windows / unreadable). Abstracted so the Windows path resolves on Linux CI. + (`GetSteamPath()` → `HKCU\Software\Valve\Steam\SteamPath`, or null if + unreadable). Abstracted so the Windows discoverer's registry resolution is + mockable on Linux CI. The production `SteamRegistryReader` is Windows-only + (annotated `[SupportedOSPlatform("windows")]`) and is registered **only on + Windows** — on Linux it is intentionally absent so resolving it fails fast. - `IProcessLookup` — `IsRunning(processName)`; two production implementations, - selected once at DI time (see [Cross-platform notes](#cross-platform-notes)). + selected once at DI time from the host OS (see [Cross-platform notes](#cross-platform-notes)). ## Discovery behavior -### Linux (`DiscoverLinux`) +`SteamService.Discover()` is a one-line delegation to the active +`ISteamDiscoverer`; all platform logic lives in the discoverer + the shared +`SteamDiscoveryCore` it composes. + +### Linux (`LinuxSteamDiscoverer`) 1. **Steam root** — ordered candidates: native default (`~/.local/share/Steam`) first, then Flatpak (`~/.var/app/com.valvesoftware.Steam/data/Steam`). The @@ -88,9 +107,10 @@ every OS-specific input + platform seam is injected: raises a warning. A missing root (no candidate carries a valid VDF) → `Failed`. 2. **Libraries** — parses `libraryfolders.vdf` (multi-library) via the internal `LibraryFoldersVdf` parser; always includes the Steam root itself as a - fallback (the VDF usually lists itself as library "0"). + fallback (the VDF usually lists itself as library "0"). (Both steps are + `SteamDiscoveryCore` mechanics, shared with the Windows path.) 3. **Darktide** — `/steamapps/common//binaries/` - probed across every library; first hit wins. + probed across every library; first hit wins. (Shared `SteamDiscoveryCore` step.) 4. **Compatdata** — `steamapps/compatdata//` probed on the main install first, then each library in VDF order (the prefix frequently lives on a library drive, not the main install); first existing dir wins. @@ -104,12 +124,13 @@ every OS-specific input + platform seam is injected: The chosen source is recorded in `Warnings`. Status is `Complete` only if Steam + Darktide + compatdata + Proton all resolve. -### Windows (`DiscoverWindows`) +### Windows (`WindowsSteamDiscoverer`) Registry first (`ISteamRegistryReader` — authoritative when present), then the default path (`C:\Program Files (x86)\Steam`); the resolved source is recorded. -Same multi-library `libraryfolders.vdf` parse + Darktide probe. Compatdata/Proton -are null (native — unused). Status is `Complete` only if Steam + Darktide resolve. +Same multi-library `libraryfolders.vdf` parse + Darktide probe (shared core). +Compatdata/Proton are null (native — unused). Status is `Complete` only if Steam ++ Darktide resolve. ### VDF parsing (`LibraryFoldersVdf`, internal) @@ -121,9 +142,21 @@ to tests via `InternalsVisibleTo`. ## Cross-platform notes -`IProcessLookup` is selected **once, at DI registration** by `AddSteam()` from -`RuntimeInformation.IsOSPlatform` — there is no per-call OS branch inside the -check: +There are two independent platform selections, made once each at DI registration +by `AddSteam()` — neither leaves a per-call OS branch inside the service: + +| Collaborator | Selected from | Implementations | +| --- | --- | --- | +| `ISteamDiscoverer` | `SteamDiscoveryOptions.Platform` (overridable) | `LinuxSteamDiscoverer`, `WindowsSteamDiscoverer` | +| `IProcessLookup` | host runtime OS | `LinuxProcessLookup`, `WinProcessLookup` | + +The discoverer follows the **`Platform` option, not the runtime OS**, on purpose: +the `Platform` knob exists precisely so cross-OS testing works — a fixture forces +`Platform = Windows` and the Windows discoverer runs on Linux CI (and vice + versa). `IsGameRunning` has no such option, so `IProcessLookup` is picked from +the host OS. + +### `IProcessLookup` | Host | Implementation | How it matches | | --- | --- | --- | @@ -157,7 +190,17 @@ check raises there, not during enumeration). public static IServiceCollection AddSteam(this IServiceCollection services) { services.TryAddSingleton(_ => SteamDiscoveryOptions.CreateDefault()); - services.TryAddSingleton(); + services.TryAddSingleton(); + + // Discoverer follows the (overridable) Platform knob, NOT the runtime OS. + services.TryAddSingleton(sp => + sp.GetRequiredService().Platform == DiscoveryPlatform.Linux + ? new LinuxSteamDiscoverer(...) // core + options + logger + : new WindowsSteamDiscoverer(...)); // core + options + ISteamRegistryReader + logger + + // Windows-only capability: NOT registered on Linux (fail-fast if resolved). + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + services.TryAddSingleton(); if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) services.TryAddSingleton(); @@ -169,18 +212,23 @@ public static IServiceCollection AddSteam(this IServiceCollection services) } ``` -`SteamDiscoveryOptions`, `ISteamRegistryReader`, and `IProcessLookup` are all -`TryAdd` so tests (and hosts with custom paths) can pre-register overrides — the -discovery pipeline is then fully exercisable against fixture layouts. `ISteamService` -is `AddSingleton` (holds no per-call state). Resolves `ILogger` from -the container. +`SteamDiscoveryOptions`, `SteamDiscoveryCore`, `ISteamDiscoverer`, +`ISteamRegistryReader`, and `IProcessLookup` are all `TryAdd` so tests (and hosts +with custom paths) can pre-register overrides — the discovery pipeline is then +fully exercisable against fixture layouts (e.g. the Steam fixture pre-registers +its `FakeRegistryReader` + forces `Platform = Windows`, which drives the discoverer +selection so the Windows path runs on Linux CI). `ISteamService` is `AddSingleton` +(holds no per-call state). Note: this library does **not** reference `MagosConfig` — it reads OS-specific inputs entirely from the injected `SteamDiscoveryOptions`. No `Microsoft.Win32.Registry` package is required: on `net10.0` the `Registry` type -is in the reference assembly, gated behind `[SupportedOSPlatform("windows")]`; -`SteamRegistryReader` guards every call with `OperatingSystem.IsWindows()`, so it -compiles cleanly on Linux and is a no-op there. +is in the reference assembly, gated behind `[SupportedOSPlatform("windows")]`. +`SteamRegistryReader` is annotated `[SupportedOSPlatform("windows")]` (declaring +it Windows-only at the type level for CA1416, with no per-call runtime guard) and +is registered **only on Windows** — on Linux it is intentionally absent so +resolving `ISteamRegistryReader` fails fast (the honest outcome for a Windows-only +capability, rather than a silent no-op). ## Dependencies @@ -195,8 +243,11 @@ compiles cleanly on Linux and is a no-op there. selection (`ProtonSelectionTests`), the `libraryfolders.vdf` parser (`LibraryFoldersVdfTests`), game-running detection (`GameRunningTests`, `ArgvMatchTests` — the latter pinning the `MatchesArgv0` backslash normalization), -and the `AddSteam` DI wiring (including the `TryAdd` overrides + platform -`IProcessLookup` selection). +and the `AddSteam` DI wiring (the `TryAdd` overrides, the `ISteamDiscoverer` +selection by `SteamDiscoveryOptions.Platform`, and the platform `IProcessLookup` +selection). `WindowsDiscoveryTests` force `Platform = Windows` + a fake registry +reader so the Windows discoverer path runs on Linux CI — the load-bearing proof +that discoverer selection follows `Platform`, not the runtime OS. ```sh dotnet test magos-modificus/magos-modificus.sln -c Release diff --git a/magos-modificus/enginseer-client/EnginseerLaunchService.cs b/magos-modificus/enginseer-client/EnginseerLaunchService.cs index 5a88c439..4f070a34 100644 --- a/magos-modificus/enginseer-client/EnginseerLaunchService.cs +++ b/magos-modificus/enginseer-client/EnginseerLaunchService.cs @@ -1,4 +1,3 @@ -using System.Runtime.InteropServices; using Magos.Modificus.Config; using Magos.Modificus.Profiles; using Magos.Modificus.Steam; @@ -7,27 +6,23 @@ namespace Magos.Modificus.EnginseerClient; /// -/// Default . Assembles the -/// magos_launcher.exe argument list from the profile (the -/// --mod-path via ) and Steam -/// discovery (the --game-binary, plus the Proton wrapper + compat env vars -/// on Linux), then spawns the launcher through . +/// Default . A thin orchestrator: it runs +/// the platform-agnostic launch flow (discover → check completeness → prepare +/// mod root → launcher-exists → spawn → result mapping) and delegates the +/// platform-varying pieces to an selected +/// once at DI registration from the runtime OS. Contains no per-launch OS branch. /// /// /// -/// Windows: the launcher is invoked directly — -/// Process.Start(launcher.exe, args), no Proton, no path translation. +/// The strategy owns the spawn (direct Process.Start on Windows; +/// proton run + both STEAM_COMPAT_* env vars + Z:\-translated +/// args on Linux), the per-platform required discovery fields, and the launch +/// label for logging. /// -/// Linux: native Magos invokes -/// <proton> run <launcher.exe> <args> with -/// STEAM_COMPAT_DATA_PATH + STEAM_COMPAT_CLIENT_INSTALL_PATH set -/// from discovery, and the launcher's path-valued flags Z:\-translated -/// (the launcher runs under Wine and needs Windows paths). -/// -/// Registered as a singleton: it holds no per-launch state. The platform is -/// resolved once at construction (from ); the OS -/// does not change at runtime. Tests force the platform via the internal -/// constructor to exercise both code paths on any CI OS. +/// Registered as a singleton: it holds no per-launch state. The active strategy +/// does not change at runtime. Tests inject the concrete Windows/Linux strategy +/// (with a fake ) to exercise either path on any +/// CI OS. /// internal sealed class EnginseerLaunchService : IEnginseerLaunchService { @@ -46,37 +41,21 @@ internal sealed class EnginseerLaunchService : IEnginseerLaunchService private readonly IProfileService _profiles; private readonly ISteamService _steam; private readonly MagosConfig _config; - private readonly IProcessLauncher _launcher; + private readonly IPlatformLaunchStrategy _strategy; private readonly ILogger _logger; - private readonly LaunchPlatform _platform; - /// DI constructor — resolves the current OS for platform branching. public EnginseerLaunchService( IProfileService profiles, ISteamService steam, MagosConfig config, - IProcessLauncher launcher, + IPlatformLaunchStrategy strategy, ILogger logger) - : this(profiles, steam, config, launcher, logger, DetectPlatform()) - { - } - - /// Test constructor — forces the platform so both code paths are - /// exercisable on any CI OS (Windows-arg tests run on Linux CI, etc.). - internal EnginseerLaunchService( - IProfileService profiles, - ISteamService steam, - MagosConfig config, - IProcessLauncher launcher, - ILogger logger, - LaunchPlatform platform) { _profiles = profiles ?? throw new ArgumentNullException(nameof(profiles)); _steam = steam ?? throw new ArgumentNullException(nameof(steam)); _config = config ?? throw new ArgumentNullException(nameof(config)); - _launcher = launcher ?? throw new ArgumentNullException(nameof(launcher)); + _strategy = strategy ?? throw new ArgumentNullException(nameof(strategy)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _platform = platform; } /// @@ -87,12 +66,12 @@ public LaunchResult Launch(Guid profileId) // Discovery first: if we cannot launch, do not touch the profile's // mod root (no point writing mods.lst for a launch that won't happen). var discovery = _steam.Discover(); - var missing = MissingDiscoveryFields(discovery, _platform); + var missing = _strategy.RequiredDiscoveryFields(discovery); if (missing.Count > 0) { _logger.LogWarning( "Discovery incomplete ({Platform}); missing: {Fields}.", - _platform, string.Join(", ", missing)); + _strategy.Name, string.Join(", ", missing)); return new LaunchResult( LaunchStatus.DiscoveryIncomplete, Message: $"Steam discovery is missing required fields: {string.Join(", ", missing)}.", @@ -114,16 +93,14 @@ public LaunchResult Launch(Guid profileId) var gameBinary = discovery.DarktideGameBinaryPath!; var logFile = _config.Logging.LogFile; - var started = _platform == LaunchPlatform.Windows - ? LaunchWindows(launcherPath, gameBinary, modPath, logFile) - : LaunchLinux(discovery, launcherPath, gameBinary, modPath, logFile); + var started = _strategy.Start(launcherPath, discovery, gameBinary, modPath, logFile); if (!started) { return ErrorResult($"Failed to start the Enginseer launcher at '{launcherPath}'."); } - _logger.LogInformation("Launched profile {Id} via the {Platform} path.", profileId, _platform); + _logger.LogInformation("Launched profile {Id} via the {Platform} path.", profileId, _strategy.Name); return new LaunchResult(LaunchStatus.Launched, Message: null, MissingDiscoveryFields: Array.Empty()); } catch (KeyNotFoundException ex) @@ -146,120 +123,6 @@ public LaunchResult Launch(Guid profileId) } } - // ---- Windows ----------------------------------------------------------- - - private bool LaunchWindows( - string launcherPath, string gameBinary, string modPath, string logFile) - { - // Direct invocation — no Proton, no path translation (native Windows paths). - var args = BuildLauncherArgs(gameBinary, modPath, logFile, translate: false); - _logger.LogInformation("Launching (Windows) {Launcher} {Args}", launcherPath, FormatArgs(args)); - return _launcher.Start(launcherPath, args, environmentVariables: null); - } - - // ---- Linux ------------------------------------------------------------- - - private bool LaunchLinux( - DiscoveryResult discovery, - string launcherPath, - string gameBinary, - string modPath, - string logFile) - { - // The launcher's OWN args (--game-binary, --mod-path, --log-file) are - // Windows paths (the launcher runs under Wine); the proton command + - // the launcher.exe path are native Linux (Proton resolves the .exe from - // a native path). - var launcherArgs = BuildLauncherArgs(gameBinary, modPath, logFile, translate: true); - - var arguments = new List(capacity: launcherArgs.Count + 2) - { - "run", // proton's "run this Windows binary" subcommand - launcherPath, // native Linux path — Proton resolves it - }; - arguments.AddRange(launcherArgs); - - // Both Steam compat vars are required for Proton to use the right Wine - // prefix + find the Steam client; discovery guaranteed non-null above. - var env = new Dictionary(StringComparer.Ordinal) - { - ["STEAM_COMPAT_DATA_PATH"] = discovery.CompatdataPath!, - ["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = discovery.SteamInstallPath!, - }; - - _logger.LogInformation( - "Launching (Linux) {Proton} run {Launcher} {Args}", - discovery.ProtonBinaryPath, launcherPath, FormatArgs(arguments)); - return _launcher.Start(discovery.ProtonBinaryPath!, arguments, env); - } - - // ---- shared arg assembly ---------------------------------------------- - - /// - /// Builds the launcher's own argument list (the flags AFTER - /// magos_launcher.exe / ... proton run launcher.exe). When - /// is set (Linux), the path-valued flags are - /// converted to Wine Z:\ form so the launcher-under-Wine can resolve them. - /// - /// - /// --log-level is intentionally NOT emitted: MagosConfig.Logging.Level - /// is a Serilog level name (Verbose/Information/Warning/Fatal) - /// for Magos's own log, but the Enginseer shell's level vocabulary is - /// error/warn/info/debug/trace — forwarding the - /// Serilog name silently mis-resolved 4/6 levels (e.g. Warning → shell - /// info, more noise than intended). The two logs serve different purposes; - /// the shell log level is now decoupled and the launcher's info default - /// is used. A dedicated shell-level config field can be added if a future need arises. - /// - private static List BuildLauncherArgs( - string gameBinary, string modPath, string logFile, bool translate) - { - var game = translate ? WinePath.ToWine(gameBinary) : gameBinary; - var mod = translate ? WinePath.ToWine(modPath) : modPath; - // --log-file is a path the launcher-under-Wine opens, so it must be - // Z:\-translated on Linux too (otherwise magos_enginseer.log can't be - // written where Magos expects). - var log = translate ? WinePath.ToWine(logFile) : logFile; - - return new List - { - "--game-binary", game, - "--mod-path", mod, - "--log-file", log, - }; - } - - /// - /// The discovery fields the current OS requires but discovery could not - /// resolve. Field names mirror 's properties so - /// the UI can map them to prompt fields. By the Steam service's construction - /// this is equivalent to != Complete (Complete - /// ⟺ every OS-required field is non-null) — derived from the fields directly - /// so the result and the missing-field list cannot diverge. - /// - private static IReadOnlyList MissingDiscoveryFields(DiscoveryResult d, LaunchPlatform platform) - { - var missing = new List(); - - // Both platforms need Steam + the game binary. - if (d.SteamInstallPath is null) missing.Add(nameof(DiscoveryResult.SteamInstallPath)); - if (d.DarktideGameBinaryPath is null) missing.Add(nameof(DiscoveryResult.DarktideGameBinaryPath)); - - // Linux additionally needs the Wine prefix (compatdata) + Proton. - if (platform == LaunchPlatform.Linux) - { - if (d.CompatdataPath is null) missing.Add(nameof(DiscoveryResult.CompatdataPath)); - if (d.ProtonBinaryPath is null) missing.Add(nameof(DiscoveryResult.ProtonBinaryPath)); - } - - return missing; - } - private static LaunchResult ErrorResult(string message) => new(LaunchStatus.Error, Message: message, MissingDiscoveryFields: Array.Empty()); - - private static LaunchPlatform DetectPlatform() => - RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? LaunchPlatform.Windows : LaunchPlatform.Linux; - - private static string FormatArgs(IReadOnlyList args) => string.Join(' ', args); } diff --git a/magos-modificus/enginseer-client/IPlatformLaunchStrategy.cs b/magos-modificus/enginseer-client/IPlatformLaunchStrategy.cs new file mode 100644 index 00000000..152b32ab --- /dev/null +++ b/magos-modificus/enginseer-client/IPlatformLaunchStrategy.cs @@ -0,0 +1,51 @@ +using Magos.Modificus.Steam; + +namespace Magos.Modificus.EnginseerClient; + +/// +/// One platform's launch strategy: the spawn (via ), +/// the discovery fields that platform requires, and a label for logging. The +/// active implementation is selected once, at DI registration, from the runtime +/// OS — so orchestrates the platform-agnostic +/// launch flow and contains no per-launch OS branch. +/// +/// +/// Splitting the launch path behind this interface removes the prior +/// LaunchWindows/LaunchLinux dispatch in the launch service. The +/// strategy owns exactly what varies by platform; everything else (discovery, +/// PrepareModRoot, the launcher-existence check, result mapping, the +/// try/catch contract) stays in the orchestrator. +/// +internal interface IPlatformLaunchStrategy +{ + /// A short label ("Windows" / "Linux") for log messages. + string Name { get; } + + /// + /// The discovery fields this platform requires but discovery could not + /// resolve. Field names mirror 's properties so + /// the UI can map them to prompt fields. Equivalent to + /// != + /// for this platform — derived from the fields directly so the result and + /// the missing-field list cannot diverge. + /// + IReadOnlyList RequiredDiscoveryFields(DiscoveryResult discovery); + + /// + /// Spawns magos_launcher.exe for this platform. Windows: a direct + /// invocation of with native (untranslated) + /// args. Linux: <proton> run <launcherPath> <args> + /// with both STEAM_COMPAT_* env vars and the path-valued flags + /// Z:\-translated (the launcher runs under Wine). Fire-and-forget — + /// returns true if the process started. + /// + /// Native path to magos_launcher.exe. + /// The resolved discovery (Linux reads the Proton + + /// compat paths + Steam install from it; Windows ignores it — it already has + /// ). + /// The resolved Darktide game binary (non-null — + /// discovery completeness was checked by the caller). + /// The prepared mod root (the --mod-path). + /// The shell log file (the --log-file). + bool Start(string launcherPath, DiscoveryResult discovery, string gameBinary, string modPath, string logFile); +} diff --git a/magos-modificus/enginseer-client/LaunchPlatform.cs b/magos-modificus/enginseer-client/LaunchPlatform.cs deleted file mode 100644 index 65e27499..00000000 --- a/magos-modificus/enginseer-client/LaunchPlatform.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Magos.Modificus.EnginseerClient; - -/// -/// The platform the launch path branches on: Windows = native launcher, Linux = -/// Proton-wrapped. Production resolves this once from the runtime OS (via -/// ); tests force -/// it to exercise both branches on any CI OS. Darktide ships on Windows (native) -/// and Linux (Proton) only. -/// -internal enum LaunchPlatform -{ - /// Windows: launch magos_launcher.exe directly (native). - Windows, - - /// Linux: invoke <proton> run magos_launcher.exe with the Steam compat env vars. - Linux, -} diff --git a/magos-modificus/enginseer-client/LinuxLaunchStrategy.cs b/magos-modificus/enginseer-client/LinuxLaunchStrategy.cs new file mode 100644 index 00000000..40767526 --- /dev/null +++ b/magos-modificus/enginseer-client/LinuxLaunchStrategy.cs @@ -0,0 +1,93 @@ +using Magos.Modificus.Steam; +using Microsoft.Extensions.Logging; + +namespace Magos.Modificus.EnginseerClient; + +/// +/// Linux . Magos runs natively (not +/// Proton-wrapped); magos_launcher.exe is a Windows binary, so this +/// invokes it under <proton> run using Darktide's own compatdata as +/// the Wine prefix, sets both STEAM_COMPAT_* env vars, and +/// Z:\-translates the launcher's path-valued flags. Selected at DI +/// registration when the host is Linux. +/// +internal sealed class LinuxLaunchStrategy : IPlatformLaunchStrategy +{ + private readonly IProcessLauncher _launcher; + private readonly ILogger _logger; + + public LinuxLaunchStrategy(IProcessLauncher launcher, ILogger logger) + { + _launcher = launcher ?? throw new ArgumentNullException(nameof(launcher)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public string Name => "Linux"; + + /// + public IReadOnlyList RequiredDiscoveryFields(DiscoveryResult discovery) + { + var missing = new List(); + + // Linux needs Steam + the game binary AND the Wine prefix (compatdata) + Proton. + if (discovery.SteamInstallPath is null) missing.Add(nameof(DiscoveryResult.SteamInstallPath)); + if (discovery.DarktideGameBinaryPath is null) missing.Add(nameof(DiscoveryResult.DarktideGameBinaryPath)); + if (discovery.CompatdataPath is null) missing.Add(nameof(DiscoveryResult.CompatdataPath)); + if (discovery.ProtonBinaryPath is null) missing.Add(nameof(DiscoveryResult.ProtonBinaryPath)); + + return missing; + } + + /// + public bool Start(string launcherPath, DiscoveryResult discovery, string gameBinary, string modPath, string logFile) + { + // The launcher's OWN args (--game-binary, --mod-path, --log-file) are + // Windows paths (the launcher runs under Wine); the proton command + the + // launcher.exe path are native Linux (Proton resolves the .exe from a + // native path). + var launcherArgs = BuildLauncherArgs(gameBinary, modPath, logFile); + + var arguments = new List(capacity: launcherArgs.Count + 2) + { + "run", // proton's "run this Windows binary" subcommand + launcherPath, // native Linux path — Proton resolves it + }; + arguments.AddRange(launcherArgs); + + // Both Steam compat vars are required for Proton to use the right Wine + // prefix + find the Steam client; RequiredDiscoveryFields guaranteed both + // non-null above. + var env = new Dictionary(StringComparer.Ordinal) + { + ["STEAM_COMPAT_DATA_PATH"] = discovery.CompatdataPath!, + ["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = discovery.SteamInstallPath!, + }; + + _logger.LogInformation( + "Launching (Linux) {Proton} run {Launcher} {Args}", + discovery.ProtonBinaryPath, launcherPath, FormatArgs(arguments)); + return _launcher.Start(discovery.ProtonBinaryPath!, arguments, env); + } + + /// + /// Builds the launcher's own argument list (the flags AFTER + /// ... proton run launcher.exe). The path-valued flags are converted + /// to Wine Z:\ form so the launcher-under-Wine can resolve them. + /// + /// + /// --log-file is a path the launcher-under-Wine opens, so it must be + /// Z:\-translated too (otherwise magos_enginseer.log can't be + /// written where Magos expects). --log-level is intentionally NOT + /// emitted (the shell's level vocabulary differs from Serilog's). + /// + internal static List BuildLauncherArgs(string gameBinary, string modPath, string logFile) => + new() + { + "--game-binary", WinePath.ToWine(gameBinary), + "--mod-path", WinePath.ToWine(modPath), + "--log-file", WinePath.ToWine(logFile), + }; + + private static string FormatArgs(IReadOnlyList args) => string.Join(' ', args); +} diff --git a/magos-modificus/enginseer-client/ServiceCollectionExtensions.cs b/magos-modificus/enginseer-client/ServiceCollectionExtensions.cs index 15c6df66..163ce678 100644 --- a/magos-modificus/enginseer-client/ServiceCollectionExtensions.cs +++ b/magos-modificus/enginseer-client/ServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -8,22 +9,35 @@ public static class ServiceCollectionExtensions { /// /// Registers → - /// and the supporting - /// seam. The service resolves - /// IProfileService, ISteamService, MagosConfig, and - /// from the container (all provided by the - /// other Add<Library>() extensions + AddGeneral()). + /// and its supporting collaborators: the + /// spawn seam and the platform + /// . The service resolves + /// IProfileService, ISteamService, MagosConfig, the + /// strategy, and from the container (all + /// provided by the other Add<Library>() extensions + + /// AddGeneral()). /// /// - /// is registered with TryAdd so - /// tests (and hosts wiring a custom launch hook) can pre-register an override - /// before calling — the same pattern the - /// Steam library uses for its platform seams (ISteamRegistryReader, - /// IProcessLookup). + /// + /// is registered with TryAdd so tests + /// (and hosts wiring a custom launch hook) can pre-register an override before + /// calling — the same pattern the Steam + /// library uses for its platform seams. The + /// is selected once, here, from the host OS ( + /// on Windows, on Linux); the launch service + /// therefore contains no per-call OS branch. Both are TryAdd so tests + /// can pre-register a concrete strategy to exercise either path on any CI OS. + /// /// public static IServiceCollection AddEnginseerClient(this IServiceCollection services) { services.TryAddSingleton(); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + services.TryAddSingleton(); + else + services.TryAddSingleton(); + services.AddSingleton(); return services; } diff --git a/magos-modificus/enginseer-client/WindowsLaunchStrategy.cs b/magos-modificus/enginseer-client/WindowsLaunchStrategy.cs new file mode 100644 index 00000000..f1efb35f --- /dev/null +++ b/magos-modificus/enginseer-client/WindowsLaunchStrategy.cs @@ -0,0 +1,68 @@ +using Magos.Modificus.Steam; +using Microsoft.Extensions.Logging; + +namespace Magos.Modificus.EnginseerClient; + +/// +/// Windows . Invokes +/// magos_launcher.exe directly — no Proton, no path translation (native +/// Windows paths). Selected at DI registration when the host is Windows. +/// +internal sealed class WindowsLaunchStrategy : IPlatformLaunchStrategy +{ + private readonly IProcessLauncher _launcher; + private readonly ILogger _logger; + + public WindowsLaunchStrategy(IProcessLauncher launcher, ILogger logger) + { + _launcher = launcher ?? throw new ArgumentNullException(nameof(launcher)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public string Name => "Windows"; + + /// + public IReadOnlyList RequiredDiscoveryFields(DiscoveryResult discovery) + { + var missing = new List(); + + // Windows needs Steam + the game binary (compatdata/Proton are unused — native). + if (discovery.SteamInstallPath is null) missing.Add(nameof(DiscoveryResult.SteamInstallPath)); + if (discovery.DarktideGameBinaryPath is null) missing.Add(nameof(DiscoveryResult.DarktideGameBinaryPath)); + + return missing; + } + + /// + public bool Start(string launcherPath, DiscoveryResult discovery, string gameBinary, string modPath, string logFile) + { + // Direct invocation — no Proton, no path translation (native Windows paths). + // `discovery` is unused on Windows: the caller already extracted gameBinary + // from it, and Windows needs no Proton/compat context. + var args = BuildLauncherArgs(gameBinary, modPath, logFile); + _logger.LogInformation("Launching (Windows) {Launcher} {Args}", launcherPath, FormatArgs(args)); + return _launcher.Start(launcherPath, args, environmentVariables: null); + } + + /// + /// Builds the launcher's own argument list (the flags AFTER + /// magos_launcher.exe). Paths pass through verbatim — Windows needs no + /// Z:\ translation. + /// + /// + /// --log-level is intentionally NOT emitted: MagosConfig.Logging.Level + /// is a Serilog level name for Magos's own log, but the Enginseer shell's level + /// vocabulary differs — forwarding the Serilog name silently mis-resolved levels. + /// The two logs are decoupled; the launcher's info default is used. + /// + internal static List BuildLauncherArgs(string gameBinary, string modPath, string logFile) => + new() + { + "--game-binary", gameBinary, + "--mod-path", modPath, + "--log-file", logFile, + }; + + private static string FormatArgs(IReadOnlyList args) => string.Join(' ', args); +} diff --git a/magos-modificus/steam/ISteamDiscoverer.cs b/magos-modificus/steam/ISteamDiscoverer.cs new file mode 100644 index 00000000..dc9e8d0f --- /dev/null +++ b/magos-modificus/steam/ISteamDiscoverer.cs @@ -0,0 +1,28 @@ +namespace Magos.Modificus.Steam; + +/// +/// Steam + Darktide (+ Linux: compatdata + Proton) discovery for one platform. +/// Implementations are platform-specific (LinuxSteamDiscoverer, +/// WindowsSteamDiscoverer) and share the platform-agnostic mechanics via +/// (composition). The active implementation is +/// selected once, at DI registration, from +/// — so +/// itself never branches on platform. +/// +/// +/// Splitting discovery behind this interface removes the prior +/// SteamService.DiscoverLinux/DiscoverWindows dispatch: the +/// service delegates to whichever discoverer the container wired (the +/// Platform knob exists precisely so cross-platform logic is exercisable +/// on one OS, e.g. Windows discovery on Linux CI). +/// +internal interface ISteamDiscoverer +{ + /// + /// Probes the platform-appropriate Steam install locations and resolves the + /// Steam install, Darktide install, and (Linux) compatdata + Proton version. + /// Never throws on missing pieces — those are reported via + /// + the nullable fields (the escape hatch). + /// + DiscoveryResult Discover(); +} diff --git a/magos-modificus/steam/ISteamService.cs b/magos-modificus/steam/ISteamService.cs index 48f8aa24..2b865226 100644 --- a/magos-modificus/steam/ISteamService.cs +++ b/magos-modificus/steam/ISteamService.cs @@ -16,9 +16,11 @@ namespace Magos.Modificus.Steam; 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 + /// Delegates to the platform ISteamDiscoverer (selected once at DI + /// registration from ), which + /// 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(); diff --git a/magos-modificus/steam/LinuxSteamDiscoverer.cs b/magos-modificus/steam/LinuxSteamDiscoverer.cs new file mode 100644 index 00000000..11992650 --- /dev/null +++ b/magos-modificus/steam/LinuxSteamDiscoverer.cs @@ -0,0 +1,246 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; + +namespace Magos.Modificus.Steam; + +/// +/// Linux . Resolves the Steam install (native +/// default, then Flatpak), derives the Darktide install, Proton prefix +/// (compatdata), and Proton version. All platform-specific steps live here; the +/// shared mechanics (root resolution, library reading, Darktide probing) come +/// from . Selected at DI registration when +/// is . +/// +internal sealed class LinuxSteamDiscoverer : ISteamDiscoverer +{ + private readonly SteamDiscoveryCore _core; + private readonly SteamDiscoveryOptions _options; + private readonly ILogger _logger; + + public LinuxSteamDiscoverer( + SteamDiscoveryCore core, + SteamDiscoveryOptions options, + ILogger logger) + { + _core = core ?? throw new ArgumentNullException(nameof(core)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public DiscoveryResult Discover() + { + 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 = _core.ResolveRoot( + new SteamDiscoveryCore.RootCandidate(_options.LinuxDefaultSteamRoot, IsFlatpak: false, FromRegistry: false), + new SteamDiscoveryCore.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 SteamDiscoveryCore.Failed(warnings); + } + + if (resolved.IsFlatpak) + { + warnings.Add("Flatpak Steam detected; some Steam integrations may be limited."); + } + + var libraries = _core.ReadLibraries(resolved.Path, warnings); + var darktide = _core.FindDarktide(libraries); + var compatdata = FindCompatdata(resolved.Path, libraries); + 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); + } + + /// + /// Resolves the Darktide compatdata (Proton prefix) for the configured app + /// id. Probes the main Steam install first, then each library declared in + /// libraryfolders.vdf (in order); the first existing dir wins. + /// + /// + /// The prefix is created on whichever drive Steam chose at install time, so + /// it frequently lives under a Steam *library* rather than the main install + /// (e.g. /games/steamapps/compatdata/<appid>/). Probing the + /// main install first preserves prior behavior — when the prefix is there it + /// still wins — and the library scan is deterministic (VDF order). + /// + private string? FindCompatdata(string steamRoot, IReadOnlyList libraries) + { + var appId = _options.DarktideAppId.ToString(CultureInfo.InvariantCulture); + + // Main install first, then each library in VDF order — the main install + // is yielded explicitly so it's probed first even if the VDF lists it + // later (or omits it); the explicit duplicate is skipped below. + foreach (var root in CompatdataCandidateRoots(steamRoot, libraries)) + { + var dir = Path.Combine(root, "steamapps", "compatdata", appId); + if (Directory.Exists(dir)) + { + return dir; + } + } + + return null; + } + + private static IEnumerable CompatdataCandidateRoots(string steamRoot, IReadOnlyList libraries) + { + yield return steamRoot; + foreach (var lib in libraries) + { + // Skip the main install when the VDF lists it — it's yielded first above. + if (!string.Equals(lib, steamRoot, StringComparison.Ordinal)) + { + yield return lib; + } + } + } + + /// + /// 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); + } + + 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, + }; +} diff --git a/magos-modificus/steam/Magos.Modificus.Steam.csproj b/magos-modificus/steam/Magos.Modificus.Steam.csproj index b6b8bfea..05690f81 100644 --- a/magos-modificus/steam/Magos.Modificus.Steam.csproj +++ b/magos-modificus/steam/Magos.Modificus.Steam.csproj @@ -22,9 +22,11 @@ diff --git a/magos-modificus/steam/ServiceCollectionExtensions.cs b/magos-modificus/steam/ServiceCollectionExtensions.cs index 01d4b221..fc29521c 100644 --- a/magos-modificus/steam/ServiceCollectionExtensions.cs +++ b/magos-modificus/steam/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using System.Runtime.InteropServices; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; namespace Magos.Modificus.Steam; @@ -9,24 +10,55 @@ public static class ServiceCollectionExtensions { /// /// Registers and its - /// supporting services (discovery options + platform seams). Resolves the - /// real OS defaults via . + /// supporting services (the shared + the + /// platform discoverer + platform seams). Resolves the real OS defaults via + /// . /// /// + /// + /// The platform is selected by a factory keyed + /// on — NOT the runtime OS. This + /// preserves the test-injectable Platform knob: a fixture (or host) + /// overrides to force a platform, and the + /// discoverer follows it (so Windows discovery runs on Linux CI and vice + /// versa). therefore contains no platform dispatch. + /// + /// /// 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. The implementation - /// is selected once, here, from the host OS: - /// on Linux (matches /proc argv[0]-stem; kernel comm is - /// unreliable under Proton) and elsewhere - /// (matches process comm via ). + /// against fixture layouts. is registered + /// ONLY on Windows (fail-fast: resolving it on Linux surfaces the + /// misconfiguration rather than silently no-opping a Windows-only capability). + /// The implementation is selected once, here, + /// from the host OS: on Linux (matches + /// /proc argv[0]-stem; kernel comm is unreliable under Proton) + /// and elsewhere (matches process comm via + /// ). + /// /// public static IServiceCollection AddSteam(this IServiceCollection services) { services.TryAddSingleton(_ => SteamDiscoveryOptions.CreateDefault()); - services.TryAddSingleton(); + services.TryAddSingleton(); + + // Discoverer follows the (overridable) Platform knob, not the runtime OS. + services.TryAddSingleton(sp => + sp.GetRequiredService().Platform == DiscoveryPlatform.Linux + ? new LinuxSteamDiscoverer( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>()) + : new WindowsSteamDiscoverer( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + + // Windows-only capability: NOT registered on Linux (fail-fast if resolved). + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + services.TryAddSingleton(); if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) services.TryAddSingleton(); diff --git a/magos-modificus/steam/SteamDiscoveryCore.cs b/magos-modificus/steam/SteamDiscoveryCore.cs new file mode 100644 index 00000000..096c5a0e --- /dev/null +++ b/magos-modificus/steam/SteamDiscoveryCore.cs @@ -0,0 +1,128 @@ +using Microsoft.Extensions.Logging; + +namespace Magos.Modificus.Steam; + +/// +/// The platform-agnostic mechanics of Steam discovery: candidate-root +/// resolution, libraryfolders.vdf reading, Darktide probing, and the +/// all-null failure result. Shared by and +/// via composition — each discoverer +/// injects this and layers its own platform-specific steps (Linux: compatdata + +/// Proton; Windows: registry). This is composition, not inheritance. +/// +internal sealed class SteamDiscoveryCore +{ + private readonly SteamDiscoveryOptions _options; + private readonly ILogger _logger; + + public SteamDiscoveryCore(SteamDiscoveryOptions options, ILogger logger) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Picks the first candidate whose path is non-empty and carries a valid + /// libraryfolders.vdf; returns a null-path + /// when none qualifies. + /// + public 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); + } + + /// + /// Reads + parses steamapps/libraryfolders.vdf under + /// ; always includes the Steam root itself as a + /// fallback library (it's normally listed as library "0"). IO/permission + /// failures degrade to a root-only search + a warning. + /// + public 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 }; + } + } + + /// + /// Probes <lib>/steamapps/common/<DarktideCommonDir>/binaries/<GameBinaryName> + /// across every library; first hit wins. Returns null if Darktide is not + /// found under any library. + /// + public string? FindDarktide(IReadOnlyList libraries) + { + 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; + } + + /// A result with all paths null. + public static DiscoveryResult Failed(IReadOnlyList warnings) => + new( + SteamInstallPath: null, + DarktideGameBinaryPath: null, + CompatdataPath: null, + ProtonBinaryPath: null, + ProtonVersion: null, + Status: DiscoveryStatus.Failed, + Warnings: warnings); + + private static bool SteamRootIsValid(string root) => + Directory.Exists(root) + && File.Exists(Path.Combine(root, "steamapps", "libraryfolders.vdf")); + + /// A candidate Steam install root probed by . + public sealed record RootCandidate(string? Path, bool IsFlatpak, bool FromRegistry); + + /// The Steam root settled on (null-path when none qualified). + public sealed record ResolvedRoot(string? Path, bool IsFlatpak, bool FromRegistry); +} diff --git a/magos-modificus/steam/SteamRegistryReader.cs b/magos-modificus/steam/SteamRegistryReader.cs index 603c6bd3..ab87c95e 100644 --- a/magos-modificus/steam/SteamRegistryReader.cs +++ b/magos-modificus/steam/SteamRegistryReader.cs @@ -1,3 +1,4 @@ +using System.Runtime.Versioning; using System.Security; using Microsoft.Win32; @@ -5,23 +6,25 @@ 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. +/// HKCU\Software\Valve\Steam\SteamPath. The class is annotated +/// ("windows") to declare its +/// Windows-only nature to the platform analyzer (satisfies CA1416 at the type +/// level — no per-call runtime guard is needed). It is registered ONLY on +/// Windows hosts by AddSteam(); on Linux it is intentionally NOT +/// registered so resolving fails fast — the +/// honest outcome for a Windows-only capability rather than a silent no-op. +/// Swallows permission / IO failures as "not found" — discovery treats a missing +/// registry value as a signal to fall back to the default path. /// +[SupportedOSPlatform("windows")] 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; diff --git a/magos-modificus/steam/SteamService.cs b/magos-modificus/steam/SteamService.cs index b676de45..b193441a 100644 --- a/magos-modificus/steam/SteamService.cs +++ b/magos-modificus/steam/SteamService.cs @@ -1,401 +1,39 @@ -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. +/// implementation. A thin orchestrator: discovery is +/// delegated to the platform (selected once at DI +/// registration from ) and the +/// game-running check to . Holds no per-call state +/// and contains no platform dispatch — every OS-specific concern lives behind a +/// polymorphic collaborator wired at the composition root. /// /// -/// 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. +/// Registered as a singleton. never throws on missing +/// pieces — those are reported via + the +/// nullable fields. /// internal sealed class SteamService : ISteamService { + private readonly ISteamDiscoverer _discoverer; private readonly SteamDiscoveryOptions _options; - private readonly ISteamRegistryReader _registry; private readonly IProcessLookup _processes; - private readonly ILogger _logger; public SteamService( + ISteamDiscoverer discoverer, SteamDiscoveryOptions options, - ISteamRegistryReader registry, - IProcessLookup processes, - ILogger logger) + IProcessLookup processes) { + _discoverer = discoverer ?? throw new ArgumentNullException(nameof(discoverer)); _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 DiscoveryResult Discover() => _discoverer.Discover(); /// 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, libraries); - 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; - } - - /// - /// Resolves the Darktide compatdata (Proton prefix) for the configured app - /// id. Probes the main Steam install first, then each library declared in - /// libraryfolders.vdf (in order); the first existing dir wins. - /// - /// - /// The prefix is created on whichever drive Steam chose at install time, so - /// it frequently lives under a Steam *library* rather than the main install - /// (e.g. /games/steamapps/compatdata/<appid>/). Probing the - /// main install first preserves prior behavior — when the prefix is there it - /// still wins — and the library scan is deterministic (VDF order). - /// - private string? FindCompatdata(string steamRoot, IReadOnlyList libraries) - { - var appId = _options.DarktideAppId.ToString(CultureInfo.InvariantCulture); - - // Main install first, then each library in VDF order — the main install - // is yielded explicitly so it's probed first even if the VDF lists it - // later (or omits it); the explicit duplicate is skipped below. - foreach (var root in CompatdataCandidateRoots(steamRoot, libraries)) - { - var dir = Path.Combine(root, "steamapps", "compatdata", appId); - if (Directory.Exists(dir)) - { - return dir; - } - } - - return null; - } - - private static IEnumerable CompatdataCandidateRoots(string steamRoot, IReadOnlyList libraries) - { - yield return steamRoot; - foreach (var lib in libraries) - { - // Skip the main install when the VDF lists it — it's yielded first above. - if (!string.Equals(lib, steamRoot, StringComparison.Ordinal)) - { - yield return lib; - } - } - } - - /// - /// 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/steam/WindowsSteamDiscoverer.cs b/magos-modificus/steam/WindowsSteamDiscoverer.cs new file mode 100644 index 00000000..89b90b41 --- /dev/null +++ b/magos-modificus/steam/WindowsSteamDiscoverer.cs @@ -0,0 +1,77 @@ +using Microsoft.Extensions.Logging; + +namespace Magos.Modificus.Steam; + +/// +/// Windows . Resolves the Steam install (registry +/// first via , then the default path), derives +/// the Darktide install, and reports Compatdata/Proton as null (Windows is +/// native — they are unused). All platform-specific steps live here; the shared +/// mechanics (root resolution, library reading, Darktide probing) come from +/// . Selected at DI registration when +/// is . +/// +internal sealed class WindowsSteamDiscoverer : ISteamDiscoverer +{ + private readonly SteamDiscoveryCore _core; + private readonly SteamDiscoveryOptions _options; + private readonly ISteamRegistryReader _registry; + private readonly ILogger _logger; + + public WindowsSteamDiscoverer( + SteamDiscoveryCore core, + SteamDiscoveryOptions options, + ISteamRegistryReader registry, + ILogger logger) + { + _core = core ?? throw new ArgumentNullException(nameof(core)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public DiscoveryResult Discover() + { + var warnings = new List(); + + // Registry first (authoritative when present), then the default path. + var registryPath = _registry.GetSteamPath(); + var resolved = _core.ResolveRoot( + new SteamDiscoveryCore.RootCandidate(registryPath, IsFlatpak: false, FromRegistry: true), + new SteamDiscoveryCore.RootCandidate(_options.WindowsDefaultSteamRoot, IsFlatpak: false, FromRegistry: false)); + + if (resolved.Path is null) + { + _logger.LogWarning("Steam install not found (registry + default both invalid)."); + return SteamDiscoveryCore.Failed(warnings); + } + + warnings.Add(resolved.FromRegistry + ? "Steam install resolved from registry." + : "Steam install resolved from default path (registry yielded nothing)."); + + var libraries = _core.ReadLibraries(resolved.Path, warnings); + var darktide = _core.FindDarktide(libraries); + + 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); + } + + private static DiscoveryStatus StatusForWindows(string? steam, string? darktide) => + steam is null ? DiscoveryStatus.Failed + : darktide is null ? DiscoveryStatus.Partial + : DiscoveryStatus.Complete; +} diff --git a/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerFixture.cs b/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerFixture.cs index c47989ee..83be85e7 100644 --- a/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerFixture.cs +++ b/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerFixture.cs @@ -10,14 +10,15 @@ namespace Magos.Modificus.EnginseerClient.Tests; /// magos_launcher.exe (so the runtime-dir check passes), and supplies /// fakes for + + /// . Builds the internal -/// with a forced -/// so both code paths are exercisable on any CI OS. Disposes the temp tree on -/// teardown so tests are isolated regardless of outcome. +/// with a concrete +/// (backed by the fake launcher) so both +/// the Windows and Linux code paths are exercisable on any CI OS. Disposes the +/// temp tree on teardown so tests are isolated regardless of outcome. /// /// /// Mirrors the Steam library's SteamFixture: resolve the seams as fakes, drive /// the service under test, assert on the recorded side-effects. The service is -/// constructed via its internal (forced-platform) constructor; the DI path is +/// constructed via its DI constructor with the chosen strategy; the DI path is /// covered separately in the service-collection tests. /// internal sealed class EnginseerFixture : IDisposable @@ -47,10 +48,26 @@ public EnginseerFixture() /// The full path to the stub launcher in the temp runtime dir. public string LauncherPath { get; } - /// Builds the service under test with the given forced platform. - public EnginseerLaunchService BuildService(LaunchPlatform platform) => - new(Profiles, Steam, Config, Launcher, - NullLogger.Instance, platform); + /// + /// Builds the service under test wired for a Windows launch (direct + /// invocation, untranslated args) — the real + /// driven by the fixture's fake . + /// + public EnginseerLaunchService BuildWindowsService() => + BuildService(new WindowsLaunchStrategy(Launcher, NullLogger.Instance)); + + /// + /// Builds the service under test wired for a Linux launch (proton run + /// + both STEAM_COMPAT_* env vars + Z:\-translated args) — the + /// real driven by the fixture's fake + /// . + /// + public EnginseerLaunchService BuildLinuxService() => + BuildService(new LinuxLaunchStrategy(Launcher, NullLogger.Instance)); + + /// Builds the service under test with an explicit strategy. + public EnginseerLaunchService BuildService(IPlatformLaunchStrategy strategy) => + new(Profiles, Steam, Config, strategy, NullLogger.Instance); /// Removes the stub launcher so the runtime-dir check fails. public void DeleteLauncher() diff --git a/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerLaunchServiceTests.cs b/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerLaunchServiceTests.cs index 4b97b03d..07c3b0a6 100644 --- a/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerLaunchServiceTests.cs +++ b/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerLaunchServiceTests.cs @@ -5,8 +5,9 @@ namespace Magos.Modificus.EnginseerClient.Tests; /// /// Launch-path tests for . All via the fakes /// in : no real process is spawned and no game is -/// required. The platform is forced so both the Windows and Linux code paths -/// are exercised on any CI OS. +/// required. The concrete Windows/Linux +/// (driven by the fixture's fake ) is injected so +/// both code paths are exercised on any CI OS. /// public sealed class EnginseerLaunchServiceTests { @@ -19,7 +20,7 @@ public void Windows_assembles_correct_args_and_invokes_launcher_directly() fx.Steam.Result = FakeDiscovery.CompleteWindows; fx.Profiles.PrepareModRootResult = @"C:\magos\profiles\abc\mods"; var profileId = Guid.NewGuid(); - var svc = fx.BuildService(LaunchPlatform.Windows); + var svc = fx.BuildWindowsService(); var result = svc.Launch(profileId); @@ -51,7 +52,7 @@ public void Windows_paths_are_not_z_translated() fx.Steam.Result = FakeDiscovery.CompleteWindows; const string LogFile = @"C:\magos\logs\magos.log"; fx.Config.Logging.LogFile = LogFile; - var svc = fx.BuildService(LaunchPlatform.Windows); + var svc = fx.BuildWindowsService(); svc.Launch(Guid.NewGuid()); @@ -70,7 +71,7 @@ public void Windows_launch_returns_launched_when_process_starts() using var fx = new EnginseerFixture(); fx.Steam.Result = FakeDiscovery.CompleteWindows; fx.Launcher.Returns = true; - var svc = fx.BuildService(LaunchPlatform.Windows); + var svc = fx.BuildWindowsService(); var result = svc.Launch(Guid.NewGuid()); @@ -86,7 +87,7 @@ public void Linux_translates_mod_path_and_game_binary_to_wine_paths() using var fx = new EnginseerFixture(); fx.Steam.Result = FakeDiscovery.CompleteLinux; fx.Profiles.PrepareModRootResult = "/home/u/.local/share/Magos Modificus/profiles/abc/mods"; - var svc = fx.BuildService(LaunchPlatform.Linux); + var svc = fx.BuildLinuxService(); svc.Launch(Guid.NewGuid()); @@ -113,7 +114,7 @@ public void Linux_translates_log_file_to_wine_path() fx.Steam.Result = FakeDiscovery.CompleteLinux; const string LogFile = "/home/u/.local/share/Magos Modificus/logs/magos.log"; fx.Config.Logging.LogFile = LogFile; - var svc = fx.BuildService(LaunchPlatform.Linux); + var svc = fx.BuildLinuxService(); svc.Launch(Guid.NewGuid()); @@ -127,7 +128,7 @@ public void Linux_sets_both_steam_compat_env_vars_from_discovery() { using var fx = new EnginseerFixture(); fx.Steam.Result = FakeDiscovery.CompleteLinux; - var svc = fx.BuildService(LaunchPlatform.Linux); + var svc = fx.BuildLinuxService(); svc.Launch(Guid.NewGuid()); @@ -142,7 +143,7 @@ public void Linux_invokes_proton_run_with_launcher_not_launcher_alone() { using var fx = new EnginseerFixture(); fx.Steam.Result = FakeDiscovery.CompleteLinux; - var svc = fx.BuildService(LaunchPlatform.Linux); + var svc = fx.BuildLinuxService(); svc.Launch(Guid.NewGuid()); @@ -162,7 +163,7 @@ public void Linux_launch_returns_launched_when_process_starts() using var fx = new EnginseerFixture(); fx.Steam.Result = FakeDiscovery.CompleteLinux; fx.Launcher.Returns = true; - var svc = fx.BuildService(LaunchPlatform.Linux); + var svc = fx.BuildLinuxService(); var result = svc.Launch(Guid.NewGuid()); @@ -183,7 +184,7 @@ public void DiscoveryIncomplete_linux_partial_returns_missing_field_names() ProtonVersion = null, Status = DiscoveryStatus.Partial, }; - var svc = fx.BuildService(LaunchPlatform.Linux); + var svc = fx.BuildLinuxService(); var result = svc.Launch(Guid.NewGuid()); @@ -206,7 +207,7 @@ public void DiscoveryIncomplete_windows_partial_returns_missing_game_binary() DarktideGameBinaryPath = null, Status = DiscoveryStatus.Partial, }; - var svc = fx.BuildService(LaunchPlatform.Windows); + var svc = fx.BuildWindowsService(); var result = svc.Launch(Guid.NewGuid()); @@ -229,7 +230,7 @@ public void DiscoveryIncomplete_failed_returns_all_os_required_fields() ProtonVersion: null, Status: DiscoveryStatus.Failed, Warnings: Array.Empty()); - var svc = fx.BuildService(LaunchPlatform.Linux); + var svc = fx.BuildLinuxService(); var result = svc.Launch(Guid.NewGuid()); @@ -255,7 +256,7 @@ public void Launch_calls_PrepareModRoot_with_profile_id_before_invoking() const string PreparedRoot = "/tmp/prepared-mod-root"; fx.Profiles.PrepareModRootResult = PreparedRoot; var profileId = Guid.NewGuid(); - var svc = fx.BuildService(LaunchPlatform.Linux); + var svc = fx.BuildLinuxService(); svc.Launch(profileId); @@ -278,7 +279,7 @@ public void Error_unknown_profile_returns_error_not_thrown() fx.Steam.Result = FakeDiscovery.CompleteLinux; // discovery OK, but profile unknown fx.Profiles.UnknownProfile = true; var profileId = Guid.NewGuid(); - var svc = fx.BuildService(LaunchPlatform.Linux); + var svc = fx.BuildLinuxService(); var result = svc.Launch(profileId); @@ -293,7 +294,7 @@ public void Error_missing_runtime_launcher_returns_error() using var fx = new EnginseerFixture(); fx.Steam.Result = FakeDiscovery.CompleteLinux; fx.DeleteLauncher(); // Enginseer runtime not deployed - var svc = fx.BuildService(LaunchPlatform.Linux); + var svc = fx.BuildLinuxService(); var result = svc.Launch(Guid.NewGuid()); @@ -309,7 +310,7 @@ public void Error_process_start_failure_returns_error() using var fx = new EnginseerFixture(); fx.Steam.Result = FakeDiscovery.CompleteLinux; fx.Launcher.Returns = false; // process.Start failed (file missing, perms, etc.) - var svc = fx.BuildService(LaunchPlatform.Linux); + var svc = fx.BuildLinuxService(); var result = svc.Launch(Guid.NewGuid()); @@ -325,7 +326,7 @@ public void Error_result_carries_empty_missing_fields() using var fx = new EnginseerFixture(); fx.Steam.Result = FakeDiscovery.CompleteLinux; fx.DeleteLauncher(); - var svc = fx.BuildService(LaunchPlatform.Linux); + var svc = fx.BuildLinuxService(); var result = svc.Launch(Guid.NewGuid()); diff --git a/magos-modificus/tests/Magos.Modificus.Steam.Tests/SteamServiceCollectionExtensionsTests.cs b/magos-modificus/tests/Magos.Modificus.Steam.Tests/SteamServiceCollectionExtensionsTests.cs index ae501a16..793cf439 100644 --- a/magos-modificus/tests/Magos.Modificus.Steam.Tests/SteamServiceCollectionExtensionsTests.cs +++ b/magos-modificus/tests/Magos.Modificus.Steam.Tests/SteamServiceCollectionExtensionsTests.cs @@ -34,8 +34,16 @@ public void AddSteam_resolves_supporting_services() using var provider = services.BuildServiceProvider(); Assert.NotNull(provider.GetService()); - Assert.NotNull(provider.GetService()); Assert.NotNull(provider.GetService()); + + // ISteamRegistryReader is a Windows-only capability: registered ONLY on + // Windows. On Linux it is intentionally absent so resolving it fails fast + // (the honest outcome for a Windows-only registry reader) — the Windows + // discoverer path is exercised on Linux CI via the fixture's FakeRegistryReader. + if (OperatingSystem.IsWindows()) + Assert.NotNull(provider.GetService()); + else + Assert.Null(provider.GetService()); } [Fact]