Skip to content

Commit 8f6ec00

Browse files
feat(magos-modificus): implement Phase 1 Steam discovery library (#18)
## What Phase 1 Steam library for Magos Modificus — discover everything needed to launch Darktide modded (Steam install, Darktide install, compatdata, Proton version) + an escape hatch (report missing pieces for the UI to prompt) + game-running detection. Enginseer-client (Phase 1 capstone) consumes the discoveries to set the Proton env vars + invoke the launcher; the UI (Phase 3) consumes the discovery result + game-running state. **Steam discovers; Enginseer-client acts.** Spec: `_local/phase1-steam-spec.md` (approved). Contract: `docs/architecture/MAGOS-MODIFICUS.md` (Launch → Linux). ## What's in it - **`ISteamService.Discover() → DiscoveryResult`** + **`IsGameRunning()`**. `DiscoveryResult` is a flat record of nullables (`SteamInstallPath`, `DarktideGameBinaryPath`, `CompatdataPath`, `ProtonBinaryPath`, `ProtonVersion`) + `Status` (Complete/Partial/Failed) + `Warnings` — the null fields drive the escape-hatch prompt. - **Linux discovery:** Steam install (default `~/.local/share/Steam` → Flatpak fallback), libraries via `libraryfolders.vdf`, Darktide install, compatdata (`steamapps/compatdata/1361210/`), Proton (`Proton - Experimental` → highest-versioned real Proton → `compatibilitytools.d/` → null/escape-hatch). - **Windows discovery:** registry `HKCU\Software\Valve\Steam\SteamPath` → default; same VDF/Darktide search; no compatdata/Proton (native). - **Testability seams:** `SteamDiscoveryOptions` (injectable roots + `DiscoveryPlatform`), `ISteamRegistryReader`, `IProcessLookup` — Windows logic runs on Linux CI; game-running is mockable. - **Arch-doc fix folded in:** Launch → Linux now states Magos sets **both** `STEAM_COMPAT_DATA_PATH` and `STEAM_COMPAT_CLIENT_INSTALL_PATH` (the live-validated finding — the working invocation set both). - **38 tests** (synthetic-layout discovery, VDF parsing, Proton selection, Flatpak, Windows-via-abstraction, game-running, escape-hatch fidelity). ## Notable bug caught during impl The coder caught a subtle real bug: a literal "highest-versioned `Proton X.Y` in `steamapps/common`" would parse the **Darktide game dir itself** (`Warhammer 40,000 DARKTIDE`) as "Proton 40.0" and pick it over every real Proton. Fixed by requiring an actual `proton` script in the dir (the defining trait of a Proton install) + a regression test (`Darktide_game_dir_is_not_mistaken_for_a_proton_build`). qa independently verified the bug + the fix. ## Verification trail - **coder** — implemented to spec; 87 tests pass (38 Steam + 49 existing); ~90%+ coverage on the testable surface; **no new NuGet deps** (initially added `Microsoft.Win32.Registry`, removed it as NU1510-redundant — the type is framework-provided on net10.0, `OperatingSystem.IsWindows()`-gated, no-ops on Linux). - **qa → PASS** — all 8 acceptance criteria met; all 4 deviations sound; **independently verified the Darktide-Proton bug is real + the fix covers it**; no `Microsoft.Win32.Registry` dep; all package pins latest-stable. - **code-review → APPROVE WITH NITS (merge as-is)** — confirmed the Proton-script gate robust against other `steamapps/common` tooling, the version parser correct (hand-traced), the VDF parser sound, the escape-hatch honest, the Windows registry path sound, the testability seams clean. All findings are optional nits; reviewer recommends merging as-is + tracking follow-ups. - **CI** — gates Win + Linux on this PR; Linux build/test green locally (87/87). ## Tracked follow-ups (not blockers) - **`IsGameRunning` Linux/Proton accuracy** — `Process.GetProcessesByName("Darktide")` may false-negative under Proton; **fix before Phase 3 UI consumes it** (a false "not running" could permit a double-launch). Informational only for Phase 1. - Legacy pre-2019 `LibraryFolders` VDF format not parsed (graceful fallback; doc note warranted). - Windows library dedup is `Ordinal` (registry lowercase/forward-slash vs VDF backslash) → Steam root double-counted (harmless redundant probe + misleading count). - `Failed` status carries empty `Warnings` (diagnostic in log only) — Phase 3 UI would want a human-readable warning. - Flatpak + `compatibilitytools.d` edge (escape-hatch-covered; proper fix = discover Flatpak's own compat-tools dir). ## Notes - Builds on merged #16 (scaffold) + #17 (Profiles). Steam is independent of Profiles (parallel Phase 1 track). - Enginseer-client (next track) consumes: `SteamInstallPath` + `CompatdataPath` → env vars; `ProtonBinaryPath` → `proton run`; `DarktideGameBinaryPath` → `--game-binary` (Z:\-translated).
1 parent f355ceb commit 8f6ec00

22 files changed

Lines changed: 1732 additions & 32 deletions

docs/architecture/MAGOS-MODIFICUS.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,11 @@ a Windows binary, so to run it Magos invokes it under **Proton**, using
207207
runs, to decide which prefix to use. By the time the launcher executes it's
208208
already inside that prefix; it cannot relocate itself, and Darktide inherits
209209
the prefix regardless. So the compatdata must be set by whoever invokes Proton
210-
— it is not passable as a launcher flag. **Magos sets
211-
`STEAM_COMPAT_DATA_PATH` in the environment when it invokes Proton.**
210+
— it is not passable as a launcher flag. **Magos sets both
211+
`STEAM_COMPAT_DATA_PATH` (the Wine prefix) and
212+
`STEAM_COMPAT_CLIENT_INSTALL_PATH` (the Steam install dir) in the environment
213+
when it invokes Proton.** (The live-validated working invocation set both env
214+
vars.) Steam discovers both; Enginseer-client sets both.
212215

213216
Responsibilities:
214217

@@ -228,12 +231,14 @@ Responsibilities:
228231
- Translate the profile's native mod-path → `Z:\...` (and confirm
229232
`--game-binary` is the in-prefix Windows path).
230233
- Assemble the launcher args.
231-
- `Process.Start` with `STEAM_COMPAT_DATA_PATH = <compatdata>` in env,
234+
- `Process.Start` with `STEAM_COMPAT_DATA_PATH = <compatdata>` and
235+
`STEAM_COMPAT_CLIENT_INSTALL_PATH = <steam-install>` in env,
232236
command = `<proton> run <runtime-dir>/magos_launcher.exe <args>`.
233237

234238
**Enginseer is unchanged on Linux** — no Linux helper, no Steam/Proton
235239
discovery, no new flag. It remains the Windows launcher + shell + mod_loader,
236-
run under Proton with `STEAM_COMPAT_DATA_PATH` set by Magos.
240+
run under Proton with `STEAM_COMPAT_DATA_PATH` + `STEAM_COMPAT_CLIENT_INSTALL_PATH`
241+
set by Magos.
237242

238243
**Known characteristic (not a defect):** when Magos launches directly, Steam
239244
isn't supervising the session (no overlay / playtime tracking). The **Steam

magos-modificus/magos-modificus.sln

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Magos.Modificus.General.Tes
4141
EndProject
4242
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}"
4343
EndProject
44+
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}"
45+
EndProject
4446
Global
4547
GlobalSection(SolutionConfigurationPlatforms) = preSolution
4648
Debug|Any CPU = Debug|Any CPU
@@ -171,6 +173,18 @@ Global
171173
{B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}.Release|x64.Build.0 = Release|Any CPU
172174
{B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}.Release|x86.ActiveCfg = Release|Any CPU
173175
{B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}.Release|x86.Build.0 = Release|Any CPU
176+
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
177+
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Debug|Any CPU.Build.0 = Debug|Any CPU
178+
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Debug|x64.ActiveCfg = Debug|Any CPU
179+
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Debug|x64.Build.0 = Debug|Any CPU
180+
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Debug|x86.ActiveCfg = Debug|Any CPU
181+
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Debug|x86.Build.0 = Debug|Any CPU
182+
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Release|Any CPU.ActiveCfg = Release|Any CPU
183+
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Release|Any CPU.Build.0 = Release|Any CPU
184+
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Release|x64.ActiveCfg = Release|Any CPU
185+
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Release|x64.Build.0 = Release|Any CPU
186+
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Release|x86.ActiveCfg = Release|Any CPU
187+
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Release|x86.Build.0 = Release|Any CPU
174188
EndGlobalSection
175189
GlobalSection(SolutionProperties) = preSolution
176190
HideSolutionNode = FALSE
@@ -186,5 +200,6 @@ Global
186200
{5952A950-2AE3-43BC-8891-28D81B2B638E} = {FA674B5A-3394-926C-2B1E-70E5B00E4A5C}
187201
{A3D79190-AD1E-428E-A0B0-224AE2A5A2BF} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
188202
{B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
203+
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
189204
EndGlobalSection
190205
EndGlobal
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace Magos.Modificus.Steam;
2+
3+
/// <summary>
4+
/// Process lookup used by <see cref="ISteamService.IsGameRunning"/>. Abstracted
5+
/// so the game-running check is deterministic and mockable in tests — the real
6+
/// check (<c>Process.GetProcessesByName</c>) would be non-deterministic against
7+
/// CI runners and platform-dependent in its naming rules.
8+
/// </summary>
9+
public interface IProcessLookup
10+
{
11+
/// <summary>
12+
/// True if at least one running process matches <paramref name="processName"/>.
13+
/// Never throws — process enumeration failures degrade to "not running."
14+
/// </summary>
15+
bool IsRunning(string processName);
16+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace Magos.Modificus.Steam;
2+
3+
/// <summary>
4+
/// Reads the Windows registry for the Steam install path. Abstracted so the
5+
/// discoverer's Windows path resolution is unit-testable on Linux (where the
6+
/// real registry is unavailable). Production implementation is
7+
/// <c>SteamRegistryReader</c> (Windows-only; returns null elsewhere).
8+
/// </summary>
9+
public interface ISteamRegistryReader
10+
{
11+
/// <summary>
12+
/// Returns the Steam install path from
13+
/// <c>HKCU\Software\Valve\Steam\SteamPath</c>, or null on non-Windows / if
14+
/// the value is absent / unreadable.
15+
/// </summary>
16+
string? GetSteamPath();
17+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
namespace Magos.Modificus.Steam;
2+
3+
/// <summary>
4+
/// Steam discovery + game-running detection. Steam **discovers** everything
5+
/// needed to launch Darktide modded on the current OS (Steam install, Darktide
6+
/// install, compatdata, Proton version) and reports missing pieces via
7+
/// <see cref="DiscoveryResult.Status"/>; it does NOT set env vars or invoke
8+
/// Proton — that is Enginseer-client's job (consuming the <see cref="DiscoveryResult"/>).
9+
/// </summary>
10+
/// <remarks>
11+
/// <para><b>Phase 1 → Phase 3 stability:</b> the discovery result is a flat
12+
/// record of nullables — Phase 3 (UI) reads it and the null fields drive the
13+
/// escape-hatch prompt form. A future Phase (non-steam shortcuts, Phase 5) adds
14+
/// methods here; the interface is designed to grow cleanly.</para>
15+
/// </remarks>
16+
public interface ISteamService
17+
{
18+
/// <summary>
19+
/// Probes the OS-appropriate Steam install locations and resolves the
20+
/// Steam install, Darktide install, compatdata, and Proton version. Never
21+
/// throws on missing pieces — those are reported via <see cref="DiscoveryResult.Status"/>
22+
/// + the nullable fields (the escape hatch).
23+
/// </summary>
24+
DiscoveryResult Discover();
25+
26+
/// <summary>
27+
/// Whether Darktide is currently running. Cross-platform best-effort check
28+
/// against the game's process name; Phase 1 uses the simple name match
29+
/// (Linux-under-Proton naming may differ — refine if it proves wrong).
30+
/// </summary>
31+
bool IsGameRunning();
32+
}
33+
34+
/// <summary>
35+
/// The outcome of a Steam discovery pass. Fields are nullable: a null means
36+
/// "couldn't resolve this — the UI should prompt for it" (the escape hatch).
37+
/// <see cref="Status"/> summarizes whether everything critical for the current
38+
/// OS was found.
39+
/// </summary>
40+
/// <param name="SteamInstallPath">Steam client dir → <c>STEAM_COMPAT_CLIENT_INSTALL_PATH</c>.</param>
41+
/// <param name="DarktideGameBinaryPath">Native path to <c>Darktide.exe</c>
42+
/// (Enginseer-client Z:\-translates on Linux for <c>--game-binary</c>).</param>
43+
/// <param name="CompatdataPath">Wine prefix → <c>STEAM_COMPAT_DATA_PATH</c> (Linux only).</param>
44+
/// <param name="ProtonBinaryPath">The <c>proton</c> script for <c>proton run</c> (Linux only).</param>
45+
/// <param name="ProtonVersion">Informational label (e.g. "Proton - Experimental").</param>
46+
/// <param name="Status">Complete / Partial / Failed — see <see cref="DiscoveryStatus"/>.</param>
47+
/// <param name="Warnings">Non-fatal notes (e.g. "Flatpak Steam detected", Proton-selection reason).</param>
48+
public sealed record DiscoveryResult(
49+
string? SteamInstallPath,
50+
string? DarktideGameBinaryPath,
51+
string? CompatdataPath,
52+
string? ProtonBinaryPath,
53+
string? ProtonVersion,
54+
DiscoveryStatus Status,
55+
IReadOnlyList<string> Warnings);
56+
57+
/// <summary>
58+
/// Coarse status of a discovery pass:
59+
/// <list type="bullet">
60+
/// <item><term>Complete</term><description>Every critical field for the current OS is non-null.</description></item>
61+
/// <item><term>Partial</term><description>Steam was located but some critical fields are missing
62+
/// (the nullables indicate what the UI should prompt for).</description></item>
63+
/// <item><term>Failed</term><description>Could not even locate Steam (UI prompts for the Steam dir).</description></item>
64+
/// </list>
65+
/// </summary>
66+
public enum DiscoveryStatus
67+
{
68+
Complete,
69+
Partial,
70+
Failed,
71+
}
72+
73+
/// <summary>
74+
/// The platform discovery runs against. Production picks this from the runtime
75+
/// OS; tests can force a platform to exercise cross-platform logic on one OS.
76+
/// Darktide ships on Windows (native) and Linux (Proton) only.
77+
/// </summary>
78+
public enum DiscoveryPlatform
79+
{
80+
/// <summary>Linux: discovers Steam + Darktide + compatdata + Proton.</summary>
81+
Linux,
82+
83+
/// <summary>Windows: discovers Steam + Darktide only (native; Proton/compatdata unused).</summary>
84+
Windows,
85+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using System.Globalization;
2+
using System.Text.RegularExpressions;
3+
4+
namespace Magos.Modificus.Steam;
5+
6+
/// <summary>
7+
/// Minimal parser for Steam's <c>libraryfolders.vdf</c>. The file maps numbered
8+
/// library entries to their root <c>"path"</c> values; this extracts those
9+
/// paths in document order — enough to drive multi-library Darktide discovery
10+
/// without pulling in a heavyweight VDF dependency.
11+
/// </summary>
12+
/// <remarks>
13+
/// <para>VDF stores Windows paths with C-style escapes (<c>\\</c> for a single
14+
/// backslash); the parser unescapes <c>\\</c> → <c>\</c> and <c>\"</c> →
15+
/// <c>"</c>. Linux Steam writes forward slashes (no escapes).</para>
16+
/// <para>The match is case-insensitive on the key (Steam writes lowercase
17+
/// <c>"path"</c>) to be forgiving of hand-edited fixtures, and anchored to the
18+
/// <c>"path"</c> key so it won't match arbitrary path-like values elsewhere in
19+
/// the structure.</para>
20+
/// </remarks>
21+
internal static class LibraryFoldersVdf
22+
{
23+
// Matches: "path" "<value>" (whitespace between key and value, value is quote-delimited)
24+
private static readonly Regex PathPattern = new(
25+
@"""path""\s+""(?<value>(?:\\.|[^""\\])*)""",
26+
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
27+
28+
/// <summary>
29+
/// Parses <c>libraryfolders.vdf</c> content → the library root paths, in
30+
/// document order. Empty / whitespace input yields an empty list. Duplicate
31+
/// paths are preserved (the caller de-dups if it cares).
32+
/// </summary>
33+
public static IReadOnlyList<string> Parse(string content)
34+
{
35+
if (string.IsNullOrWhiteSpace(content))
36+
{
37+
return Array.Empty<string>();
38+
}
39+
40+
var paths = new List<string>();
41+
foreach (Match match in PathPattern.Matches(content))
42+
{
43+
var raw = match.Groups["value"].Value;
44+
var unescaped = Unescape(raw);
45+
if (!string.IsNullOrWhiteSpace(unescaped))
46+
{
47+
paths.Add(unescaped.Trim());
48+
}
49+
}
50+
51+
return paths;
52+
}
53+
54+
// Unescapes the minimal VDF escapes that can appear in a path value.
55+
private static string Unescape(string value) =>
56+
value
57+
.Replace("\\\\", "\u0000", StringComparison.Ordinal) // protect \\ first
58+
.Replace("\\\"", "\"", StringComparison.Ordinal)
59+
.Replace("\u0000", "\\", StringComparison.Ordinal);
60+
}

magos-modificus/steam/Magos.Modificus.Steam.csproj

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,26 @@
55
<AssemblyName>Magos.Modificus.Steam</AssemblyName>
66
</PropertyGroup>
77

8+
<!--
9+
Exposes internal helpers (the VDF parser) to the test assembly so the parser
10+
can be unit-tested in isolation without widening the library's public surface.
11+
Implementation services (SteamService, SteamRegistryReader, ProcessLookup)
12+
stay internal and are exercised through ISteamService via DI in tests.
13+
-->
14+
<ItemGroup>
15+
<InternalsVisibleTo Include="Magos.Modificus.Steam.Tests" />
16+
</ItemGroup>
17+
818
<ItemGroup>
919
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
1020
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
21+
<!--
22+
No Microsoft.Win32.Registry package: on net10.0 the Registry type is in the
23+
reference assembly, gated behind [SupportedOSPlatform("windows")] (runtime
24+
throws on non-Windows). SteamRegistryReader guards every call with
25+
OperatingSystem.IsWindows(), so it compiles cleanly on Linux and is a no-op
26+
there. No new NuGet dependency required.
27+
-->
1128
</ItemGroup>
1229

1330
</Project>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using System.ComponentModel;
2+
using System.Diagnostics;
3+
4+
namespace Magos.Modificus.Steam;
5+
6+
/// <summary>
7+
/// Production <see cref="IProcessLookup"/> backed by
8+
/// <see cref="Process.GetProcessesByName(string)"/>. Swallows enumeration
9+
/// failures (e.g. permission denied on some Linux setups) as "not running"
10+
/// rather than surfacing them through <see cref="ISteamService.IsGameRunning"/>.
11+
/// </summary>
12+
internal sealed class ProcessLookup : IProcessLookup
13+
{
14+
public bool IsRunning(string processName)
15+
{
16+
if (string.IsNullOrEmpty(processName))
17+
{
18+
return false;
19+
}
20+
21+
try
22+
{
23+
return Process.GetProcessesByName(processName).Length > 0;
24+
}
25+
catch (Win32Exception)
26+
{
27+
// Process enumeration can be denied (e.g. restricted Linux runners);
28+
// treat as "not running" so a launch isn't blocked on a false negative.
29+
return false;
30+
}
31+
catch (InvalidOperationException)
32+
{
33+
return false;
34+
}
35+
}
36+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using Microsoft.Extensions.DependencyInjection.Extensions;
3+
4+
namespace Magos.Modificus.Steam;
5+
6+
/// <summary>DI registration for the Steam library.</summary>
7+
public static class ServiceCollectionExtensions
8+
{
9+
/// <summary>
10+
/// Registers <see cref="ISteamService"/> → <see cref="SteamService"/> and its
11+
/// supporting services (discovery options + platform seams). Resolves the
12+
/// real OS defaults via <see cref="SteamDiscoveryOptions.CreateDefault"/>.
13+
/// </summary>
14+
/// <remarks>
15+
/// Supporting services (<see cref="SteamDiscoveryOptions"/>,
16+
/// <see cref="ISteamRegistryReader"/>, <see cref="IProcessLookup"/>) are
17+
/// registered with <c>TryAdd</c> so tests (and hosts with custom paths) can
18+
/// pre-register overrides — the discovery pipeline is then fully exercisable
19+
/// against fixture layouts.
20+
/// </remarks>
21+
public static IServiceCollection AddSteam(this IServiceCollection services)
22+
{
23+
services.TryAddSingleton(_ => SteamDiscoveryOptions.CreateDefault());
24+
services.TryAddSingleton<ISteamRegistryReader, SteamRegistryReader>();
25+
services.TryAddSingleton<IProcessLookup, ProcessLookup>();
26+
services.AddSingleton<ISteamService, SteamService>();
27+
return services;
28+
}
29+
}

0 commit comments

Comments
 (0)