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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions docs/architecture/MAGOS-MODIFICUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,11 @@ a Windows binary, so to run it Magos invokes it under **Proton**, using
runs, to decide which prefix to use. By the time the launcher executes it's
already inside that prefix; it cannot relocate itself, and Darktide inherits
the prefix regardless. So the compatdata must be set by whoever invokes Proton
— it is not passable as a launcher flag. **Magos sets
`STEAM_COMPAT_DATA_PATH` in the environment when it invokes Proton.**
— it is not passable as a launcher flag. **Magos sets both
`STEAM_COMPAT_DATA_PATH` (the Wine prefix) and
`STEAM_COMPAT_CLIENT_INSTALL_PATH` (the Steam install dir) in the environment
when it invokes Proton.** (The live-validated working invocation set both env
vars.) Steam discovers both; Enginseer-client sets both.

Responsibilities:

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

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

**Known characteristic (not a defect):** when Magos launches directly, Steam
isn't supervising the session (no overlay / playtime tracking). The **Steam
Expand Down
15 changes: 15 additions & 0 deletions magos-modificus/magos-modificus.sln
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Magos.Modificus.General.Tes
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Magos.Modificus.Profiles.Tests", "tests\Magos.Modificus.Profiles.Tests\Magos.Modificus.Profiles.Tests.csproj", "{B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Magos.Modificus.Steam.Tests", "tests\Magos.Modificus.Steam.Tests\Magos.Modificus.Steam.Tests.csproj", "{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -171,6 +173,18 @@ Global
{B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}.Release|x64.Build.0 = Release|Any CPU
{B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}.Release|x86.ActiveCfg = Release|Any CPU
{B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23}.Release|x86.Build.0 = Release|Any CPU
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Debug|x64.ActiveCfg = Debug|Any CPU
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Debug|x64.Build.0 = Debug|Any CPU
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Debug|x86.ActiveCfg = Debug|Any CPU
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Debug|x86.Build.0 = Debug|Any CPU
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Release|Any CPU.Build.0 = Release|Any CPU
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Release|x64.ActiveCfg = Release|Any CPU
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Release|x64.Build.0 = Release|Any CPU
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Release|x86.ActiveCfg = Release|Any CPU
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -186,5 +200,6 @@ Global
{5952A950-2AE3-43BC-8891-28D81B2B638E} = {FA674B5A-3394-926C-2B1E-70E5B00E4A5C}
{A3D79190-AD1E-428E-A0B0-224AE2A5A2BF} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
16 changes: 16 additions & 0 deletions magos-modificus/steam/IProcessLookup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Magos.Modificus.Steam;

/// <summary>
/// Process lookup used by <see cref="ISteamService.IsGameRunning"/>. Abstracted
/// so the game-running check is deterministic and mockable in tests — the real
/// check (<c>Process.GetProcessesByName</c>) would be non-deterministic against
/// CI runners and platform-dependent in its naming rules.
/// </summary>
public interface IProcessLookup
{
/// <summary>
/// True if at least one running process matches <paramref name="processName"/>.
/// Never throws — process enumeration failures degrade to "not running."
/// </summary>
bool IsRunning(string processName);
}
17 changes: 17 additions & 0 deletions magos-modificus/steam/ISteamRegistryReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Magos.Modificus.Steam;

/// <summary>
/// Reads the Windows registry for the Steam install path. Abstracted so the
/// discoverer's Windows path resolution is unit-testable on Linux (where the
/// real registry is unavailable). Production implementation is
/// <c>SteamRegistryReader</c> (Windows-only; returns null elsewhere).
/// </summary>
public interface ISteamRegistryReader
{
/// <summary>
/// Returns the Steam install path from
/// <c>HKCU\Software\Valve\Steam\SteamPath</c>, or null on non-Windows / if
/// the value is absent / unreadable.
/// </summary>
string? GetSteamPath();
}
85 changes: 85 additions & 0 deletions magos-modificus/steam/ISteamService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
namespace Magos.Modificus.Steam;

/// <summary>
/// Steam discovery + game-running detection. Steam **discovers** everything
/// needed to launch Darktide modded on the current OS (Steam install, Darktide
/// install, compatdata, Proton version) and reports missing pieces via
/// <see cref="DiscoveryResult.Status"/>; it does NOT set env vars or invoke
/// Proton — that is Enginseer-client's job (consuming the <see cref="DiscoveryResult"/>).
/// </summary>
/// <remarks>
/// <para><b>Phase 1 → Phase 3 stability:</b> the discovery result is a flat
/// record of nullables — Phase 3 (UI) reads it and the null fields drive the
/// escape-hatch prompt form. A future Phase (non-steam shortcuts, Phase 5) adds
/// methods here; the interface is designed to grow cleanly.</para>
/// </remarks>
public interface ISteamService
{
/// <summary>
/// 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 <see cref="DiscoveryResult.Status"/>
/// + the nullable fields (the escape hatch).
/// </summary>
DiscoveryResult Discover();

/// <summary>
/// Whether Darktide is currently running. Cross-platform best-effort check
/// against the game's process name; Phase 1 uses the simple name match
/// (Linux-under-Proton naming may differ — refine if it proves wrong).
/// </summary>
bool IsGameRunning();
}

/// <summary>
/// The outcome of a Steam discovery pass. Fields are nullable: a null means
/// "couldn't resolve this — the UI should prompt for it" (the escape hatch).
/// <see cref="Status"/> summarizes whether everything critical for the current
/// OS was found.
/// </summary>
/// <param name="SteamInstallPath">Steam client dir → <c>STEAM_COMPAT_CLIENT_INSTALL_PATH</c>.</param>
/// <param name="DarktideGameBinaryPath">Native path to <c>Darktide.exe</c>
/// (Enginseer-client Z:\-translates on Linux for <c>--game-binary</c>).</param>
/// <param name="CompatdataPath">Wine prefix → <c>STEAM_COMPAT_DATA_PATH</c> (Linux only).</param>
/// <param name="ProtonBinaryPath">The <c>proton</c> script for <c>proton run</c> (Linux only).</param>
/// <param name="ProtonVersion">Informational label (e.g. "Proton - Experimental").</param>
/// <param name="Status">Complete / Partial / Failed — see <see cref="DiscoveryStatus"/>.</param>
/// <param name="Warnings">Non-fatal notes (e.g. "Flatpak Steam detected", Proton-selection reason).</param>
public sealed record DiscoveryResult(
string? SteamInstallPath,
string? DarktideGameBinaryPath,
string? CompatdataPath,
string? ProtonBinaryPath,
string? ProtonVersion,
DiscoveryStatus Status,
IReadOnlyList<string> Warnings);

/// <summary>
/// Coarse status of a discovery pass:
/// <list type="bullet">
/// <item><term>Complete</term><description>Every critical field for the current OS is non-null.</description></item>
/// <item><term>Partial</term><description>Steam was located but some critical fields are missing
/// (the nullables indicate what the UI should prompt for).</description></item>
/// <item><term>Failed</term><description>Could not even locate Steam (UI prompts for the Steam dir).</description></item>
/// </list>
/// </summary>
public enum DiscoveryStatus
{
Complete,
Partial,
Failed,
}

/// <summary>
/// The platform discovery runs against. Production picks this from the runtime
/// OS; tests can force a platform to exercise cross-platform logic on one OS.
/// Darktide ships on Windows (native) and Linux (Proton) only.
/// </summary>
public enum DiscoveryPlatform
{
/// <summary>Linux: discovers Steam + Darktide + compatdata + Proton.</summary>
Linux,

/// <summary>Windows: discovers Steam + Darktide only (native; Proton/compatdata unused).</summary>
Windows,
}
60 changes: 60 additions & 0 deletions magos-modificus/steam/LibraryFoldersVdf.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System.Globalization;
using System.Text.RegularExpressions;

namespace Magos.Modificus.Steam;

/// <summary>
/// Minimal parser for Steam's <c>libraryfolders.vdf</c>. The file maps numbered
/// library entries to their root <c>"path"</c> values; this extracts those
/// paths in document order — enough to drive multi-library Darktide discovery
/// without pulling in a heavyweight VDF dependency.
/// </summary>
/// <remarks>
/// <para>VDF stores Windows paths with C-style escapes (<c>\\</c> for a single
/// backslash); the parser unescapes <c>\\</c> → <c>\</c> and <c>\"</c> →
/// <c>"</c>. Linux Steam writes forward slashes (no escapes).</para>
/// <para>The match is case-insensitive on the key (Steam writes lowercase
/// <c>"path"</c>) to be forgiving of hand-edited fixtures, and anchored to the
/// <c>"path"</c> key so it won't match arbitrary path-like values elsewhere in
/// the structure.</para>
/// </remarks>
internal static class LibraryFoldersVdf
{
// Matches: "path" "<value>" (whitespace between key and value, value is quote-delimited)
private static readonly Regex PathPattern = new(
@"""path""\s+""(?<value>(?:\\.|[^""\\])*)""",
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);

/// <summary>
/// Parses <c>libraryfolders.vdf</c> content → the library root paths, in
/// document order. Empty / whitespace input yields an empty list. Duplicate
/// paths are preserved (the caller de-dups if it cares).
/// </summary>
public static IReadOnlyList<string> Parse(string content)
{
if (string.IsNullOrWhiteSpace(content))
{
return Array.Empty<string>();
}

var paths = new List<string>();
foreach (Match match in PathPattern.Matches(content))
{
var raw = match.Groups["value"].Value;
var unescaped = Unescape(raw);
if (!string.IsNullOrWhiteSpace(unescaped))
{
paths.Add(unescaped.Trim());
}
}

return paths;
}

// Unescapes the minimal VDF escapes that can appear in a path value.
private static string Unescape(string value) =>
value
.Replace("\\\\", "\u0000", StringComparison.Ordinal) // protect \\ first
.Replace("\\\"", "\"", StringComparison.Ordinal)
.Replace("\u0000", "\\", StringComparison.Ordinal);
}
17 changes: 17 additions & 0 deletions magos-modificus/steam/Magos.Modificus.Steam.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,26 @@
<AssemblyName>Magos.Modificus.Steam</AssemblyName>
</PropertyGroup>

<!--
Exposes internal helpers (the VDF parser) to the test assembly so the parser
can be unit-tested in isolation without widening the library's public surface.
Implementation services (SteamService, SteamRegistryReader, ProcessLookup)
stay internal and are exercised through ISteamService via DI in tests.
-->
<ItemGroup>
<InternalsVisibleTo Include="Magos.Modificus.Steam.Tests" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
<!--
No Microsoft.Win32.Registry package: on net10.0 the Registry type is in the
reference assembly, gated behind [SupportedOSPlatform("windows")] (runtime
throws on non-Windows). SteamRegistryReader guards every call with
OperatingSystem.IsWindows(), so it compiles cleanly on Linux and is a no-op
there. No new NuGet dependency required.
-->
</ItemGroup>

</Project>
36 changes: 36 additions & 0 deletions magos-modificus/steam/ProcessLookup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System.ComponentModel;
using System.Diagnostics;

namespace Magos.Modificus.Steam;

/// <summary>
/// Production <see cref="IProcessLookup"/> backed by
/// <see cref="Process.GetProcessesByName(string)"/>. Swallows enumeration
/// failures (e.g. permission denied on some Linux setups) as "not running"
/// rather than surfacing them through <see cref="ISteamService.IsGameRunning"/>.
/// </summary>
internal sealed class ProcessLookup : IProcessLookup
{
public bool IsRunning(string processName)
{
if (string.IsNullOrEmpty(processName))
{
return false;
}

try
{
return Process.GetProcessesByName(processName).Length > 0;
}
catch (Win32Exception)
{
// Process enumeration can be denied (e.g. restricted Linux runners);
// treat as "not running" so a launch isn't blocked on a false negative.
return false;
}
catch (InvalidOperationException)
{
return false;
}
}
}
29 changes: 29 additions & 0 deletions magos-modificus/steam/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

namespace Magos.Modificus.Steam;

/// <summary>DI registration for the Steam library.</summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers <see cref="ISteamService"/> → <see cref="SteamService"/> and its
/// supporting services (discovery options + platform seams). Resolves the
/// real OS defaults via <see cref="SteamDiscoveryOptions.CreateDefault"/>.
/// </summary>
/// <remarks>
/// Supporting services (<see cref="SteamDiscoveryOptions"/>,
/// <see cref="ISteamRegistryReader"/>, <see cref="IProcessLookup"/>) are
/// registered with <c>TryAdd</c> so tests (and hosts with custom paths) can
/// pre-register overrides — the discovery pipeline is then fully exercisable
/// against fixture layouts.
/// </remarks>
public static IServiceCollection AddSteam(this IServiceCollection services)
{
services.TryAddSingleton(_ => SteamDiscoveryOptions.CreateDefault());
services.TryAddSingleton<ISteamRegistryReader, SteamRegistryReader>();
services.TryAddSingleton<IProcessLookup, ProcessLookup>();
services.AddSingleton<ISteamService, SteamService>();
return services;
}
}
Loading
Loading