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
28 changes: 0 additions & 28 deletions magos-modificus/enginseer-client/EnginseerClientModule.cs

This file was deleted.

265 changes: 265 additions & 0 deletions magos-modificus/enginseer-client/EnginseerLaunchService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
using System.Runtime.InteropServices;
using Magos.Modificus.Config;
using Magos.Modificus.Profiles;
using Magos.Modificus.Steam;
using Microsoft.Extensions.Logging;

namespace Magos.Modificus.EnginseerClient;

/// <summary>
/// Default <see cref="IEnginseerLaunchService"/>. Assembles the
/// <c>magos_launcher.exe</c> argument list from the profile (the
/// <c>--mod-path</c> via <see cref="IProfileService.PrepareModRoot"/>) and Steam
/// discovery (the <c>--game-binary</c>, plus the Proton wrapper + compat env vars
/// on Linux), then spawns the launcher through <see cref="IProcessLauncher"/>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Windows:</b> the launcher is invoked directly —
/// <c>Process.Start(launcher.exe, args)</c>, no Proton, no path translation.</para>
/// <para>
/// <b>Linux:</b> native Magos invokes
/// <c>&lt;proton&gt; run &lt;launcher.exe&gt; &lt;args&gt;</c> with
/// <c>STEAM_COMPAT_DATA_PATH</c> + <c>STEAM_COMPAT_CLIENT_INSTALL_PATH</c> set
/// from discovery, and the launcher's path-valued flags <c>Z:\</c>-translated
/// (the launcher runs under Wine and needs Windows paths).</para>
/// <para>
/// Registered as a singleton: it holds no per-launch state. The platform is
/// resolved once at construction (from <see cref="RuntimeInformation"/>); the OS
/// does not change at runtime. Tests force the platform via the internal
/// constructor to exercise both code paths on any CI OS.</para>
/// </remarks>
internal sealed class EnginseerLaunchService : IEnginseerLaunchService
{
/// <summary>The launcher executable filename (a Windows binary, run under
/// Proton on Linux). Lives in <see cref="MagosConfig.EnginseerRuntimeDir"/>.</summary>
internal const string LauncherExecutableName = "magos_launcher.exe";

/// <summary>
/// The Steam app id for Darktide. The launcher defaults to this value when
/// <c>--steam-app-id</c> is omitted; Magos relies on that default and only
/// emits <c>--steam-app-id</c> to override it (which the current config does
/// not surface — see <c>ServiceCollectionExtensions</c> / future config work).
/// </summary>
internal const int DarktideSteamAppId = 1361210;

private readonly IProfileService _profiles;
private readonly ISteamService _steam;
private readonly MagosConfig _config;
private readonly IProcessLauncher _launcher;
private readonly ILogger<EnginseerLaunchService> _logger;
private readonly LaunchPlatform _platform;

/// <summary>DI constructor — resolves the current OS for platform branching.</summary>
public EnginseerLaunchService(
IProfileService profiles,
ISteamService steam,
MagosConfig config,
IProcessLauncher launcher,
ILogger<EnginseerLaunchService> logger)
: this(profiles, steam, config, launcher, logger, DetectPlatform())
{
}

/// <summary>Test constructor — forces the platform so both code paths are
/// exercisable on any CI OS (Windows-arg tests run on Linux CI, etc.).</summary>
internal EnginseerLaunchService(
IProfileService profiles,
ISteamService steam,
MagosConfig config,
IProcessLauncher launcher,
ILogger<EnginseerLaunchService> 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));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_platform = platform;
}

/// <inheritdoc />
public LaunchResult Launch(Guid profileId)
{
try
{
// 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);
if (missing.Count > 0)
{
_logger.LogWarning(
"Discovery incomplete ({Platform}); missing: {Fields}.",
_platform, string.Join(", ", missing));
return new LaunchResult(
LaunchStatus.DiscoveryIncomplete,
Message: $"Steam discovery is missing required fields: {string.Join(", ", missing)}.",
MissingDiscoveryFields: missing);
}

// PrepareModRoot writes mods.lst + ensures the mod root exists and
// returns the --mod-path. KeyNotFoundException (unknown profile) is
// caught below and mapped to LaunchStatus.Error.
var modPath = _profiles.PrepareModRoot(profileId);

var launcherPath = Path.Combine(_config.EnginseerRuntimeDir, LauncherExecutableName);
if (!File.Exists(launcherPath))
{
_logger.LogError("Enginseer runtime launcher not found at {Path}.", launcherPath);
return ErrorResult($"Enginseer runtime launcher not found at '{launcherPath}'.");
}

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);

if (!started)
{
return ErrorResult($"Failed to start the Enginseer launcher at '{launcherPath}'.");
}

_logger.LogInformation("Launched profile {Id} via the {Platform} path.", profileId, _platform);
return new LaunchResult(LaunchStatus.Launched, Message: null, MissingDiscoveryFields: Array.Empty<string>());
}
catch (KeyNotFoundException ex)
{
// Unknown profile (PrepareModRoot) — surfaced as Error, not thrown.
_logger.LogError(ex, "Launch failed: profile {Id} not found.", profileId);
return ErrorResult($"Profile '{profileId}' was not found.");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogError(ex, "Launch failed for profile {Id}: I/O error.", profileId);
return ErrorResult($"Launch failed: {ex.Message}");
}
catch (Exception ex)
{
// Catch-all: the façade's contract is to always return a result
// rather than push failure handling onto the caller.
_logger.LogError(ex, "Launch failed for profile {Id}: unexpected error.", profileId);
return ErrorResult($"Launch failed: {ex.Message}");
}
}

// ---- 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<string>(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<string, string>(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 ----------------------------------------------

/// <summary>
/// Builds the launcher's own argument list (the flags AFTER
/// <c>magos_launcher.exe</c> / <c>... proton run launcher.exe</c>). When
/// <paramref name="translate"/> is set (Linux), the path-valued flags are
/// converted to Wine <c>Z:\</c> form so the launcher-under-Wine can resolve them.
/// </summary>
/// <remarks>
/// <c>--log-level</c> is intentionally NOT emitted: <c>MagosConfig.Logging.Level</c>
/// is a Serilog level name (<c>Verbose</c>/<c>Information</c>/<c>Warning</c>/<c>Fatal</c>)
/// for Magos's own log, but the Enginseer shell's level vocabulary is
/// <c>error</c>/<c>warn</c>/<c>info</c>/<c>debug</c>/<c>trace</c> — forwarding the
/// Serilog name silently mis-resolved 4/6 levels (e.g. <c>Warning</c> → shell
/// <c>info</c>, more noise than intended). The two logs serve different purposes;
/// the shell log level is now decoupled and the launcher's <c>info</c> default
/// is used. A dedicated shell-level config field can be added if a future need arises.
/// </remarks>
private static List<string> 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<string>
{
"--game-binary", game,
"--mod-path", mod,
"--log-file", log,
};
}

/// <summary>
/// The discovery fields the current OS requires but discovery could not
/// resolve. Field names mirror <see cref="DiscoveryResult"/>'s properties so
/// the UI can map them to prompt fields. By the Steam service's construction
/// this is equivalent to <see cref="DiscoveryStatus"/> != Complete (Complete
/// ⟺ every OS-required field is non-null) — derived from the fields directly
/// so the result and the missing-field list cannot diverge.
/// </summary>
private static IReadOnlyList<string> MissingDiscoveryFields(DiscoveryResult d, LaunchPlatform platform)
{
var missing = new List<string>();

// 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<string>());

private static LaunchPlatform DetectPlatform() =>
RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? LaunchPlatform.Windows : LaunchPlatform.Linux;

private static string FormatArgs(IReadOnlyList<string> args) => string.Join(' ', args);
}
71 changes: 71 additions & 0 deletions magos-modificus/enginseer-client/IEnginseerLaunchService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
namespace Magos.Modificus.EnginseerClient;

/// <summary>
/// The v1 launch façade over the Enginseer runtime. Resolves the profile +
/// Steam discovery, assembles the launcher args, and invokes
/// <c>magos_launcher.exe</c> — directly on Windows, under <c>proton run</c> on
/// Linux. Fire-and-forget in v1: <see cref="Launch"/> starts the launcher and
/// returns; it does not track the game process.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="Launch"/> resolves the profile (via
/// <c>IProfileService.PrepareModRoot</c> — writes <c>mods.lst</c> and returns the
/// <c>--mod-path</c>) and Steam discovery (via <c>ISteamService.Discover</c>)
/// internally, so the caller just says "launch this profile."</para>
/// <para>
/// Enginseer-client does NOT prompt — on incomplete discovery it returns
/// <see cref="LaunchStatus.DiscoveryIncomplete"/> carrying the missing field
/// names so the UI (a later phase) can drive an escape-hatch prompt.</para>
/// <para>
/// A future overload accepting a cached <c>DiscoveryResult</c> (to avoid
/// redundant discovery on repeated launches) is an intended clean addition:
/// <see cref="Launch"/> is designed so a <c>Launch(Guid, DiscoveryResult)</c>
/// sibling slots in without breaking existing callers.</para>
/// </remarks>
public interface IEnginseerLaunchService
{
/// <summary>
/// Launches the given profile modded. Always returns a
/// <see cref="LaunchResult"/> (never throws for expected conditions):
/// <list type="bullet">
/// <item><term><see cref="LaunchStatus.Launched"/></term><description>the launcher process was started.</description></item>
/// <item><term><see cref="LaunchStatus.DiscoveryIncomplete"/></term><description>Steam discovery is missing required fields for the current OS; <see cref="LaunchResult.MissingDiscoveryFields"/> lists them.</description></item>
/// <item><term><see cref="LaunchStatus.Error"/></term><description>unknown profile, missing runtime dir, or process-start failure — see <see cref="LaunchResult.Message"/>.</description></item>
/// </list>
/// </summary>
LaunchResult Launch(Guid profileId);
}

/// <summary>
/// The outcome of <see cref="IEnginseerLaunchService.Launch"/>.
/// </summary>
/// <param name="Status">One of <see cref="LaunchStatus.Launched"/>,
/// <see cref="LaunchStatus.DiscoveryIncomplete"/>, <see cref="LaunchStatus.Error"/>.</param>
/// <param name="Message">Human-readable detail; populated for
/// <see cref="LaunchStatus.Error"/> (null otherwise).</param>
/// <param name="MissingDiscoveryFields">The discovery fields the current OS
/// requires but could not be resolved; populated only for
/// <see cref="LaunchStatus.DiscoveryIncomplete"/> (empty otherwise). Field names
/// mirror the <c>DiscoveryResult</c> properties so the UI can map them to a prompt.</param>
public sealed record LaunchResult(
LaunchStatus Status,
string? Message,
IReadOnlyList<string> MissingDiscoveryFields);

/// <summary>
/// Coarse outcome of a launch attempt.
/// </summary>
public enum LaunchStatus
{
/// <summary>The launcher process was started (fire-and-forget — no game-process tracking in v1).</summary>
Launched,

/// <summary>Steam discovery is missing required fields for the current OS;
/// <see cref="LaunchResult.MissingDiscoveryFields"/> lists them.</summary>
DiscoveryIncomplete,

/// <summary>Anything else: unknown profile, missing runtime dir, or a
/// process-start failure. See <see cref="LaunchResult.Message"/>.</summary>
Error,
}
Loading
Loading