From 3430a6308c7a24d1c92cb3588ba7c0457ef20fa8 Mon Sep 17 00:00:00 2001 From: ModifAmorphic Date: Wed, 1 Jul 2026 19:27:32 -0700 Subject: [PATCH 1/4] =?UTF-8?q?feat(magos-modificus):=20implement=20Phase?= =?UTF-8?q?=201=20Enginseer-client=20launch=20fa=C3=A7ade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Phase-0 stub with the v1 launch façade over the Enginseer runtime. IEnginseerLaunchService.Launch(profileId) resolves the profile (via IProfileService.PrepareModRoot) + Steam discovery (via ISteamService.Discover) internally, assembles the launcher args, and invokes magos_launcher.exe — directly on Windows, under 'proton run' on Linux (with the Steam compat env vars + Z:\-translated paths). Outcome is reported via LaunchResult: Launched (fire-and-forget), DiscoveryIncomplete (carrying the missing field names for the UI's escape-hatch prompt — Enginseer-client does NOT prompt itself), or Error (unknown profile / missing runtime dir / process-start failure). Process invocation is abstracted behind IProcessLauncher (mirrors the Steam library's IProcessLookup seam) so the launch path is unit-testable without spawning a real process. The platform branches on a runtime detection (forced via an internal constructor in tests). Consumes Profiles (the mod-path) + Steam (DiscoveryResult) + MagosConfig (EnginseerRuntimeDir + Logging); AddEnginseerClient() registers IEnginseerLaunchService + the IProcessLauncher seam (TryAdd). --- .../enginseer-client/EnginseerClientModule.cs | 28 -- .../EnginseerLaunchService.cs | 253 ++++++++++++++++++ .../IEnginseerLaunchService.cs | 71 +++++ .../enginseer-client/IProcessLauncher.cs | 34 +++ .../enginseer-client/LaunchPlatform.cs | 17 ++ .../Magos.Modificus.EnginseerClient.csproj | 24 ++ .../enginseer-client/ProcessLauncher.cs | 95 +++++++ .../ServiceCollectionExtensions.cs | 30 +++ magos-modificus/enginseer-client/WinePath.cs | 26 ++ 9 files changed, 550 insertions(+), 28 deletions(-) delete mode 100644 magos-modificus/enginseer-client/EnginseerClientModule.cs create mode 100644 magos-modificus/enginseer-client/EnginseerLaunchService.cs create mode 100644 magos-modificus/enginseer-client/IEnginseerLaunchService.cs create mode 100644 magos-modificus/enginseer-client/IProcessLauncher.cs create mode 100644 magos-modificus/enginseer-client/LaunchPlatform.cs create mode 100644 magos-modificus/enginseer-client/ProcessLauncher.cs create mode 100644 magos-modificus/enginseer-client/ServiceCollectionExtensions.cs create mode 100644 magos-modificus/enginseer-client/WinePath.cs diff --git a/magos-modificus/enginseer-client/EnginseerClientModule.cs b/magos-modificus/enginseer-client/EnginseerClientModule.cs deleted file mode 100644 index 416397f2..00000000 --- a/magos-modificus/enginseer-client/EnginseerClientModule.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace Magos.Modificus.EnginseerClient; - -/// -/// v1 launch façade over the Enginseer runtime: assemble launcher args, invoke -/// magos_launcher.exe (under Proton on Linux), and track process exit. -/// Stub — implemented in a later phase. See -/// docs/architecture/MAGOS-MODIFICUS.md and the Enginseer contract. -/// -public interface IEnginseerLaunchService -{ -} - -internal sealed class EnginseerLaunchService : IEnginseerLaunchService -{ -} - -/// DI registration for the Enginseer-client library. -public static class ServiceCollectionExtensions -{ - /// Registers the Enginseer-client library services. - public static IServiceCollection AddEnginseerClient(this IServiceCollection services) - { - services.AddSingleton(); - return services; - } -} diff --git a/magos-modificus/enginseer-client/EnginseerLaunchService.cs b/magos-modificus/enginseer-client/EnginseerLaunchService.cs new file mode 100644 index 00000000..e68d4f4b --- /dev/null +++ b/magos-modificus/enginseer-client/EnginseerLaunchService.cs @@ -0,0 +1,253 @@ +using System.Runtime.InteropServices; +using Magos.Modificus.Config; +using Magos.Modificus.Profiles; +using Magos.Modificus.Steam; +using Microsoft.Extensions.Logging; + +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 . +/// +/// +/// +/// Windows: the launcher is invoked directly — +/// Process.Start(launcher.exe, args), no Proton, no path translation. +/// +/// 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. +/// +internal sealed class EnginseerLaunchService : IEnginseerLaunchService +{ + /// The launcher executable filename (a Windows binary, run under + /// Proton on Linux). Lives in . + internal const string LauncherExecutableName = "magos_launcher.exe"; + + /// + /// The Steam app id for Darktide. The launcher defaults to this value when + /// --steam-app-id is omitted; Magos relies on that default and only + /// emits --steam-app-id to override it (which the current config does + /// not surface — see ServiceCollectionExtensions / future config work). + /// + internal const int DarktideSteamAppId = 1361210; + + private readonly IProfileService _profiles; + private readonly ISteamService _steam; + private readonly MagosConfig _config; + private readonly IProcessLauncher _launcher; + 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, + 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)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _platform = platform; + } + + /// + 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 logLevel = _config.Logging.Level; + + var started = _platform == LaunchPlatform.Windows + ? LaunchWindows(launcherPath, gameBinary, modPath, logFile, logLevel) + : LaunchLinux(discovery, launcherPath, gameBinary, modPath, logFile, logLevel); + + 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()); + } + 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, string logLevel) + { + // Direct invocation — no Proton, no path translation (native Windows paths). + var args = BuildLauncherArgs(gameBinary, modPath, logFile, logLevel, 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, + string logLevel) + { + // The launcher's OWN args (--game-binary, --mod-path) 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, logLevel, 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. + /// + private static List BuildLauncherArgs( + string gameBinary, string modPath, string logFile, string logLevel, bool translate) + { + var game = translate ? WinePath.ToWine(gameBinary) : gameBinary; + var mod = translate ? WinePath.ToWine(modPath) : modPath; + + return new List + { + "--game-binary", game, + "--mod-path", mod, + "--log-file", logFile, + "--log-level", logLevel, + }; + } + + /// + /// 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/IEnginseerLaunchService.cs b/magos-modificus/enginseer-client/IEnginseerLaunchService.cs new file mode 100644 index 00000000..1eef8e6c --- /dev/null +++ b/magos-modificus/enginseer-client/IEnginseerLaunchService.cs @@ -0,0 +1,71 @@ +namespace Magos.Modificus.EnginseerClient; + +/// +/// The v1 launch façade over the Enginseer runtime. Resolves the profile + +/// Steam discovery, assembles the launcher args, and invokes +/// magos_launcher.exe — directly on Windows, under proton run on +/// Linux. Fire-and-forget in v1: starts the launcher and +/// returns; it does not track the game process. +/// +/// +/// +/// resolves the profile (via +/// IProfileService.PrepareModRoot — writes mods.lst and returns the +/// --mod-path) and Steam discovery (via ISteamService.Discover) +/// internally, so the caller just says "launch this profile." +/// +/// Enginseer-client does NOT prompt — on incomplete discovery it returns +/// carrying the missing field +/// names so the UI (a later phase) can drive an escape-hatch prompt. +/// +/// A future overload accepting a cached DiscoveryResult (to avoid +/// redundant discovery on repeated launches) is an intended clean addition: +/// is designed so a Launch(Guid, DiscoveryResult) +/// sibling slots in without breaking existing callers. +/// +public interface IEnginseerLaunchService +{ + /// + /// Launches the given profile modded. Always returns a + /// (never throws for expected conditions): + /// + /// the launcher process was started. + /// Steam discovery is missing required fields for the current OS; lists them. + /// unknown profile, missing runtime dir, or process-start failure — see . + /// + /// + LaunchResult Launch(Guid profileId); +} + +/// +/// The outcome of . +/// +/// One of , +/// , . +/// Human-readable detail; populated for +/// (null otherwise). +/// The discovery fields the current OS +/// requires but could not be resolved; populated only for +/// (empty otherwise). Field names +/// mirror the DiscoveryResult properties so the UI can map them to a prompt. +public sealed record LaunchResult( + LaunchStatus Status, + string? Message, + IReadOnlyList MissingDiscoveryFields); + +/// +/// Coarse outcome of a launch attempt. +/// +public enum LaunchStatus +{ + /// The launcher process was started (fire-and-forget — no game-process tracking in v1). + Launched, + + /// Steam discovery is missing required fields for the current OS; + /// lists them. + DiscoveryIncomplete, + + /// Anything else: unknown profile, missing runtime dir, or a + /// process-start failure. See . + Error, +} diff --git a/magos-modificus/enginseer-client/IProcessLauncher.cs b/magos-modificus/enginseer-client/IProcessLauncher.cs new file mode 100644 index 00000000..6c702fe9 --- /dev/null +++ b/magos-modificus/enginseer-client/IProcessLauncher.cs @@ -0,0 +1,34 @@ +namespace Magos.Modificus.EnginseerClient; + +/// +/// Process-launch abstraction used by to +/// spawn magos_launcher.exe — directly on Windows, under proton run +/// on Linux. Abstracted so the launch path is deterministic and mockable in +/// tests: the real (Process.Start) would +/// spawn a real process and fail against a CI runner with no game install. +/// +/// +/// Mirrors the Steam library's IProcessLookup pattern: the injectable +/// seam is the side-effect, leaving the service under test as pure argument +/// assembly + decision logic. +/// +public interface IProcessLauncher +{ + /// + /// Starts with the given arguments and optional + /// environment-variable overrides, fire-and-forget. Returns true if + /// the process was started; false if it could not be started (file + /// not found, permission denied, etc. — never throws for those). + /// + /// The executable to start. + /// The full argument list, already in argv form. + /// The implementation must add each verbatim (no re-shelling or + /// concatenation) so paths containing spaces survive unchanged. + /// Additional/overriding environment + /// variables for the child (e.g. the Steam compat vars on Linux); null + /// inherits the parent's environment. + bool Start( + string filePath, + IReadOnlyList arguments, + IReadOnlyDictionary? environmentVariables); +} diff --git a/magos-modificus/enginseer-client/LaunchPlatform.cs b/magos-modificus/enginseer-client/LaunchPlatform.cs new file mode 100644 index 00000000..65e27499 --- /dev/null +++ b/magos-modificus/enginseer-client/LaunchPlatform.cs @@ -0,0 +1,17 @@ +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/Magos.Modificus.EnginseerClient.csproj b/magos-modificus/enginseer-client/Magos.Modificus.EnginseerClient.csproj index aa1c585b..3b9ff05d 100644 --- a/magos-modificus/enginseer-client/Magos.Modificus.EnginseerClient.csproj +++ b/magos-modificus/enginseer-client/Magos.Modificus.EnginseerClient.csproj @@ -5,9 +5,33 @@ Magos.Modificus.EnginseerClient + + + + + + + + + + + + + + + diff --git a/magos-modificus/enginseer-client/ProcessLauncher.cs b/magos-modificus/enginseer-client/ProcessLauncher.cs new file mode 100644 index 00000000..011ff30b --- /dev/null +++ b/magos-modificus/enginseer-client/ProcessLauncher.cs @@ -0,0 +1,95 @@ +using System.ComponentModel; +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Magos.Modificus.EnginseerClient; + +/// +/// Default . Spawns the child via +/// using +/// (argv-correct, no shell), so paths +/// containing spaces survive verbatim and there is no shell-injection surface. +/// Environment overrides are applied directly to the child's environment block. +/// +/// +/// +/// Fire-and-forget: starts the process and returns without +/// waiting — the launcher + injected shell own their own lifecycle and the game +/// process is intentionally not tracked in v1. +/// +/// Registered as a singleton via AddEnginseerClient() with TryAdd +/// so tests (and hosts with a custom launch hook) can pre-register an override — +/// the same pattern the Steam library uses for IProcessLookup. +/// +internal sealed class ProcessLauncher : IProcessLauncher +{ + private readonly ILogger _logger; + + public ProcessLauncher(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public bool Start( + string filePath, + IReadOnlyList arguments, + IReadOnlyDictionary? environmentVariables) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + + var startInfo = new ProcessStartInfo + { + FileName = filePath, + // UseShellExecute=false is required both to set environment variables + // and to use ArgumentList (no shell). The launcher is an .exe even on + // Linux (Proton runs it), so we never want the OS shell in the middle. + UseShellExecute = false, + }; + + // ArgumentList quotes/escapes per-platform; callers hand us argv form, so + // add each entry verbatim. A null entry would corrupt the argv layout — + // coerce to "" defensively rather than throw (the launch façade never + // produces nulls, but this stays safe for any IProcessLauncher caller). + foreach (var arg in arguments) + { + startInfo.ArgumentList.Add(arg ?? string.Empty); + } + + if (environmentVariables is not null) + { + foreach (var pair in environmentVariables) + { + startInfo.Environment[pair.Key] = pair.Value; + } + } + + try + { + var process = Process.Start(startInfo); + if (process is null) + { + // Process.Start returns null only when a new process is reusing an + // already-running one's resources — rare, but treat as "not started." + _logger.LogWarning("Process.Start returned null for {File}.", filePath); + return false; + } + + _logger.LogInformation( + "Started process {Pid} for {File} ({Count} arguments).", + process.Id, filePath, arguments.Count); + return true; + } + catch (Exception ex) when (ex is InvalidOperationException + or FileNotFoundException + or Win32Exception + or PlatformNotSupportedException) + { + // The common start failures: missing file, permission denied + // (Win32Exception), or no platform support. Never throw — the service + // maps a false return to LaunchStatus.Error with a clear message. + _logger.LogWarning(ex, "Failed to start process {File}.", filePath); + return false; + } + } +} diff --git a/magos-modificus/enginseer-client/ServiceCollectionExtensions.cs b/magos-modificus/enginseer-client/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..15c6df66 --- /dev/null +++ b/magos-modificus/enginseer-client/ServiceCollectionExtensions.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Magos.Modificus.EnginseerClient; + +/// DI registration for the Enginseer-client library. +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()). + /// + /// + /// 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). + /// + public static IServiceCollection AddEnginseerClient(this IServiceCollection services) + { + services.TryAddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/magos-modificus/enginseer-client/WinePath.cs b/magos-modificus/enginseer-client/WinePath.cs new file mode 100644 index 00000000..2f150ac2 --- /dev/null +++ b/magos-modificus/enginseer-client/WinePath.cs @@ -0,0 +1,26 @@ +namespace Magos.Modificus.EnginseerClient; + +/// +/// Native POSIX → Wine path translation. Wine maps the POSIX root to the +/// Z: drive, so an absolute POSIX path /home/u/mods becomes the +/// Windows path Z:\home\u\mods (forward slashes → backslashes, Z: +/// prefix). The launcher runs under Wine on Linux and needs Windows-style paths +/// for its --game-binary / --mod-path flags. +/// +internal static class WinePath +{ + /// + /// Translates an absolute POSIX path to its Wine Z:\ form: replace + /// every forward slash with a backslash and prefix Z:. The only paths + /// that occur (--mod-path, --game-binary) are always absolute, + /// so the leading slash maps cleanly to the Wine drive root (e.g. + /// /home/u/modsZ:\home\u\mods, /Z:\). + /// + /// is null or whitespace. + internal static string ToWine(string posixPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(posixPath); + var backslashed = posixPath.Replace('/', '\\'); + return "Z:" + backslashed; + } +} From a92f8ff68bed387b7bd28d76660c93a80a6a3df6 Mon Sep 17 00:00:00 2001 From: ModifAmorphic Date: Wed, 1 Jul 2026 19:27:51 -0700 Subject: [PATCH 2/4] test(magos-modificus): add Enginseer-client tests + launch smoke harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit-test the launch path via mocked IProcessLauncher + fake IProfileService/ISteamService (no real process, no game): Windows arg assembly, Linux Z:\ translation + both Steam compat env vars + proton run invocation, DiscoveryIncomplete with the missing field names, profile integration (PrepareModRoot called), and the Error cases (unknown profile, missing runtime dir, process-start failure). WinePath is covered exhaustively; DI tests prove AddEnginseerClient() registers IEnginseerLaunchService + the IProcessLauncher seam (with TryAdd override semantics). The test project is dual-purpose: 'dotnet test' runs the xUnit suite, and 'dotnet run -- {discover,list,launch }' drives the launch smoke-test harness — a CLI hook over IEnginseerLaunchService.Launch that builds the REAL Magos composition so the user can validate an actual modded Darktide launch on their Win/Linux box (the agent env has no game/Proton). The harness is the user-facing smoke-test delivery per the spec's Acceptance. --- magos-modificus/magos-modificus.sln | 15 + ...rClientServiceCollectionExtensionsTests.cs | 81 +++++ .../EnginseerFixture.cs | 72 ++++ .../EnginseerLaunchServiceTests.cs | 320 ++++++++++++++++++ ...gos.Modificus.EnginseerClient.Tests.csproj | 51 +++ .../SmokeHarness/Program.cs | 243 +++++++++++++ .../TestDoubles.cs | 130 +++++++ .../WinePathTests.cs | 45 +++ 8 files changed, 957 insertions(+) create mode 100644 magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerClientServiceCollectionExtensionsTests.cs create mode 100644 magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerFixture.cs create mode 100644 magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerLaunchServiceTests.cs create mode 100644 magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/Magos.Modificus.EnginseerClient.Tests.csproj create mode 100644 magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/SmokeHarness/Program.cs create mode 100644 magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/TestDoubles.cs create mode 100644 magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/WinePathTests.cs diff --git a/magos-modificus/magos-modificus.sln b/magos-modificus/magos-modificus.sln index 611e4054..cf9b8f37 100644 --- a/magos-modificus/magos-modificus.sln +++ b/magos-modificus/magos-modificus.sln @@ -45,6 +45,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Magos.Modificus.Steam.Tests EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Magos.Modificus.Integrations.Tests", "tests\Magos.Modificus.Integrations.Tests\Magos.Modificus.Integrations.Tests.csproj", "{6DB44E7F-2EAA-484E-AFCA-22742857C817}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Magos.Modificus.EnginseerClient.Tests", "tests\Magos.Modificus.EnginseerClient.Tests\Magos.Modificus.EnginseerClient.Tests.csproj", "{2441E9E9-8D30-4BE9-AB19-CFB8AB8EF3F9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -199,6 +201,18 @@ Global {6DB44E7F-2EAA-484E-AFCA-22742857C817}.Release|x64.Build.0 = Release|Any CPU {6DB44E7F-2EAA-484E-AFCA-22742857C817}.Release|x86.ActiveCfg = Release|Any CPU {6DB44E7F-2EAA-484E-AFCA-22742857C817}.Release|x86.Build.0 = Release|Any CPU + {2441E9E9-8D30-4BE9-AB19-CFB8AB8EF3F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2441E9E9-8D30-4BE9-AB19-CFB8AB8EF3F9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2441E9E9-8D30-4BE9-AB19-CFB8AB8EF3F9}.Debug|x64.ActiveCfg = Debug|Any CPU + {2441E9E9-8D30-4BE9-AB19-CFB8AB8EF3F9}.Debug|x64.Build.0 = Debug|Any CPU + {2441E9E9-8D30-4BE9-AB19-CFB8AB8EF3F9}.Debug|x86.ActiveCfg = Debug|Any CPU + {2441E9E9-8D30-4BE9-AB19-CFB8AB8EF3F9}.Debug|x86.Build.0 = Debug|Any CPU + {2441E9E9-8D30-4BE9-AB19-CFB8AB8EF3F9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2441E9E9-8D30-4BE9-AB19-CFB8AB8EF3F9}.Release|Any CPU.Build.0 = Release|Any CPU + {2441E9E9-8D30-4BE9-AB19-CFB8AB8EF3F9}.Release|x64.ActiveCfg = Release|Any CPU + {2441E9E9-8D30-4BE9-AB19-CFB8AB8EF3F9}.Release|x64.Build.0 = Release|Any CPU + {2441E9E9-8D30-4BE9-AB19-CFB8AB8EF3F9}.Release|x86.ActiveCfg = Release|Any CPU + {2441E9E9-8D30-4BE9-AB19-CFB8AB8EF3F9}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -216,5 +230,6 @@ Global {B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {2E1647E7-8D82-44EB-ADB6-FAFA9AC42200} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {6DB44E7F-2EAA-484E-AFCA-22742857C817} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {2441E9E9-8D30-4BE9-AB19-CFB8AB8EF3F9} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerClientServiceCollectionExtensionsTests.cs b/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerClientServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..501e895b --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerClientServiceCollectionExtensionsTests.cs @@ -0,0 +1,81 @@ +using Magos.Modificus.Config; +using Magos.Modificus.Profiles; +using Magos.Modificus.Steam; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace Magos.Modificus.EnginseerClient.Tests; + +/// +/// Proves AddEnginseerClient() registers +/// (and the supporting seam) so it is resolvable +/// from DI with the production-style deps (IProfileService + +/// ISteamService + MagosConfig), and that pre-registered overrides +/// win over the defaults via TryAdd. +/// +public sealed class EnginseerClientServiceCollectionExtensionsTests +{ + [Fact] + public void AddEnginseerClient_registers_resolvable_IEnginseerLaunchService() + { + var services = BuildComposition(); + + using var provider = services.BuildServiceProvider(); + var service = provider.GetService(); + + Assert.NotNull(service); + Assert.IsAssignableFrom(service); + } + + [Fact] + public void AddEnginseerClient_registers_default_IProcessLauncher() + { + var services = BuildComposition(); + + using var provider = services.BuildServiceProvider(); + Assert.NotNull(provider.GetService()); + } + + [Fact] + public void AddEnginseerClient_pre_registered_IProcessLauncher_wins_over_default() + { + // A host/tests can inject a custom launch hook; TryAdd must defer. + var custom = new FakeProcessLauncher(); + + var services = BuildComposition(); + services.AddSingleton(custom); + + using var provider = services.BuildServiceProvider(); + var resolved = provider.GetRequiredService(); + + Assert.Same(custom, resolved); + } + + [Fact] + public void AddEnginseerClient_is_idempotent_and_returns_same_collection() + { + var services = new ServiceCollection(); + + var returned = services.AddEnginseerClient(); + + Assert.Same(services, returned); + } + + /// + /// Builds the minimal composition that makes + /// resolvable: fakes for the profile + + /// steam services, a default , logging, then + /// . + /// + private static ServiceCollection BuildComposition() + { + var services = new ServiceCollection(); + services.AddLogging(b => b.SetMinimumLevel(LogLevel.Warning)); + services.AddSingleton(MagosConfig.CreateDefault()); + services.AddSingleton(); + services.AddSingleton(); + services.AddEnginseerClient(); + return services; + } +} diff --git a/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerFixture.cs b/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerFixture.cs new file mode 100644 index 00000000..c47989ee --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerFixture.cs @@ -0,0 +1,72 @@ +using Magos.Modificus.Config; +using Magos.Modificus.Profiles; +using Magos.Modificus.Steam; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Magos.Modificus.EnginseerClient.Tests; + +/// +/// Per-test fixture: scaffolds a temp Enginseer runtime dir with a stub +/// 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. +/// +/// +/// 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 +/// covered separately in the service-collection tests. +/// +internal sealed class EnginseerFixture : IDisposable +{ + public string TempRoot { get; } + public string RuntimeDir { get; } + public FakeProfileService Profiles { get; } = new(); + public FakeSteamService Steam { get; } = new(); + public FakeProcessLauncher Launcher { get; } = new(); + public MagosConfig Config { get; } + + public EnginseerFixture() + { + TempRoot = Path.Combine(Path.GetTempPath(), "magos-enginseer-" + Guid.NewGuid().ToString("N")); + RuntimeDir = Path.Combine(TempRoot, "enginseer"); + Directory.CreateDirectory(RuntimeDir); + + // Deploy a stub launcher.exe so the runtime-dir existence check passes + // for the success-path tests. Tests that need it absent call DeleteLauncher(). + LauncherPath = Path.Combine(RuntimeDir, EnginseerLaunchService.LauncherExecutableName); + File.WriteAllText(LauncherPath, string.Empty); + + Config = MagosConfig.CreateDefault(); + Config.EnginseerRuntimeDir = RuntimeDir; + } + + /// 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); + + /// Removes the stub launcher so the runtime-dir check fails. + public void DeleteLauncher() + { + if (File.Exists(LauncherPath)) + { + File.Delete(LauncherPath); + } + } + + public void Dispose() + { + if (Directory.Exists(TempRoot)) + { + try { Directory.Delete(TempRoot, recursive: true); } + catch (IOException) { /* best-effort: temp dirs are harmless if left */ } + } + } +} diff --git a/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerLaunchServiceTests.cs b/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerLaunchServiceTests.cs new file mode 100644 index 00000000..cbd4c32c --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerLaunchServiceTests.cs @@ -0,0 +1,320 @@ +using Magos.Modificus.Steam; + +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. +/// +public sealed class EnginseerLaunchServiceTests +{ + // ---- Windows ------------------------------------------------------------ + + [Fact] + public void Windows_assembles_correct_args_and_invokes_launcher_directly() + { + using var fx = new EnginseerFixture(); + fx.Steam.Result = FakeDiscovery.CompleteWindows; + fx.Profiles.PrepareModRootResult = @"C:\magos\profiles\abc\mods"; + var profileId = Guid.NewGuid(); + var svc = fx.BuildService(LaunchPlatform.Windows); + + var result = svc.Launch(profileId); + + Assert.Equal(LaunchStatus.Launched, result.Status); + + // Invoked the launcher directly — not proton, no "run" prefix, no env. + Assert.Equal(fx.LauncherPath, fx.Launcher.FilePath); + Assert.Null(fx.Launcher.Environment); + Assert.DoesNotContain("run", fx.Launcher.Arguments!); + + Assert.Equal( + new[] { "--game-binary", FakeDiscovery.WindowsGameBinary, + "--mod-path", @"C:\magos\profiles\abc\mods", + "--log-file", fx.Config.Logging.LogFile, + "--log-level", fx.Config.Logging.Level }, + fx.Launcher.Arguments); + } + + [Fact] + public void Windows_paths_are_not_z_translated() + { + // Guard: a native Windows path must pass through unchanged (no Z:\ prefix). + using var fx = new EnginseerFixture(); + fx.Steam.Result = FakeDiscovery.CompleteWindows; + var svc = fx.BuildService(LaunchPlatform.Windows); + + svc.Launch(Guid.NewGuid()); + + var args = fx.Launcher.Arguments!; + var game = args[IndexOf(args, "--game-binary") + 1]; + Assert.Equal(FakeDiscovery.WindowsGameBinary, game); + Assert.DoesNotContain("Z:", game); + } + + [Fact] + 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 result = svc.Launch(Guid.NewGuid()); + + Assert.Equal(LaunchStatus.Launched, result.Status); + Assert.Null(result.Message); + } + + // ---- Linux -------------------------------------------------------------- + + [Fact] + 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); + + svc.Launch(Guid.NewGuid()); + + var args = fx.Launcher.Arguments!; + // The launcher's own flags start after "run" + launcherPath. + var launcherFlags = args.Skip(2).ToList(); + + var game = launcherFlags[IndexOf(launcherFlags, "--game-binary") + 1]; + var mod = launcherFlags[IndexOf(launcherFlags, "--mod-path") + 1]; + + Assert.Equal(@"Z:\home\u\.local\share\Magos Modificus\profiles\abc\mods", mod); + Assert.Equal( + @"Z:\home\u\.steam\steam\steamapps\common\Warhammer 40,000 DARKTIDE\binaries\Darktide.exe", + game); + } + + [Fact] + 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); + + svc.Launch(Guid.NewGuid()); + + var env = fx.Launcher.Environment; + Assert.NotNull(env); + Assert.Equal(FakeDiscovery.LinuxCompatdata, env!["STEAM_COMPAT_DATA_PATH"]); + Assert.Equal(FakeDiscovery.LinuxSteam, env!["STEAM_COMPAT_CLIENT_INSTALL_PATH"]); + } + + [Fact] + 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); + + svc.Launch(Guid.NewGuid()); + + // The launched command is , and its argv is [run, launcher.exe, ...flags]. + Assert.Equal(FakeDiscovery.LinuxProton, fx.Launcher.FilePath); + var args = fx.Launcher.Arguments!; + Assert.Equal("run", args[0]); + Assert.Equal(fx.LauncherPath, args[1]); // native Linux path — Proton resolves it + Assert.True(args.Count > 2, "expected launcher flags after the launcher path"); + } + + [Fact] + 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 result = svc.Launch(Guid.NewGuid()); + + Assert.Equal(LaunchStatus.Launched, result.Status); + } + + // ---- DiscoveryIncomplete ------------------------------------------------ + + [Fact] + public void DiscoveryIncomplete_linux_partial_returns_missing_field_names() + { + // Steam + Darktide found, but compatdata + Proton missing on Linux. + using var fx = new EnginseerFixture(); + fx.Steam.Result = FakeDiscovery.CompleteLinux with + { + CompatdataPath = null, + ProtonBinaryPath = null, + ProtonVersion = null, + Status = DiscoveryStatus.Partial, + }; + var svc = fx.BuildService(LaunchPlatform.Linux); + + var result = svc.Launch(Guid.NewGuid()); + + Assert.Equal(LaunchStatus.DiscoveryIncomplete, result.Status); + Assert.Equal( + new[] { nameof(DiscoveryResult.CompatdataPath), nameof(DiscoveryResult.ProtonBinaryPath) }, + result.MissingDiscoveryFields); + + // Short-circuit: PrepareModRoot must NOT run (we can't launch, so don't write mods.lst). + Assert.Equal(0, fx.Profiles.PrepareModRootCalls); + Assert.Equal(0, fx.Launcher.Calls); + } + + [Fact] + public void DiscoveryIncomplete_windows_partial_returns_missing_game_binary() + { + using var fx = new EnginseerFixture(); + fx.Steam.Result = FakeDiscovery.CompleteWindows with + { + DarktideGameBinaryPath = null, + Status = DiscoveryStatus.Partial, + }; + var svc = fx.BuildService(LaunchPlatform.Windows); + + var result = svc.Launch(Guid.NewGuid()); + + Assert.Equal(LaunchStatus.DiscoveryIncomplete, result.Status); + // Compatdata/Proton are NOT required on Windows — only the game binary is missing. + Assert.Equal( + new[] { nameof(DiscoveryResult.DarktideGameBinaryPath) }, + result.MissingDiscoveryFields); + } + + [Fact] + public void DiscoveryIncomplete_failed_returns_all_os_required_fields() + { + using var fx = new EnginseerFixture(); + fx.Steam.Result = new DiscoveryResult( + SteamInstallPath: null, + DarktideGameBinaryPath: null, + CompatdataPath: null, + ProtonBinaryPath: null, + ProtonVersion: null, + Status: DiscoveryStatus.Failed, + Warnings: Array.Empty()); + var svc = fx.BuildService(LaunchPlatform.Linux); + + var result = svc.Launch(Guid.NewGuid()); + + Assert.Equal(LaunchStatus.DiscoveryIncomplete, result.Status); + Assert.Equal( + new[] + { + nameof(DiscoveryResult.SteamInstallPath), + nameof(DiscoveryResult.DarktideGameBinaryPath), + nameof(DiscoveryResult.CompatdataPath), + nameof(DiscoveryResult.ProtonBinaryPath), + }, + result.MissingDiscoveryFields); + } + + // ---- Profile integration ------------------------------------------------ + + [Fact] + public void Launch_calls_PrepareModRoot_with_profile_id_before_invoking() + { + using var fx = new EnginseerFixture(); + fx.Steam.Result = FakeDiscovery.CompleteLinux; + const string PreparedRoot = "/tmp/prepared-mod-root"; + fx.Profiles.PrepareModRootResult = PreparedRoot; + var profileId = Guid.NewGuid(); + var svc = fx.BuildService(LaunchPlatform.Linux); + + svc.Launch(profileId); + + Assert.Equal(1, fx.Profiles.PrepareModRootCalls); + Assert.Equal(profileId, fx.Profiles.LastPrepareModRootId); + + // The returned path is the --mod-path (Z:\-translated on Linux). + var args = fx.Launcher.Arguments!; + var modIndex = IndexOf(args, "--mod-path"); + var modPath = args[modIndex + 1]; + Assert.Equal(WinePath.ToWine(PreparedRoot), modPath); + } + + // ---- Error --------------------------------------------------------------- + + [Fact] + public void Error_unknown_profile_returns_error_not_thrown() + { + using var fx = new EnginseerFixture(); + 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 result = svc.Launch(profileId); + + Assert.Equal(LaunchStatus.Error, result.Status); + Assert.Contains(profileId.ToString(), result.Message); + Assert.Equal(0, fx.Launcher.Calls); + } + + [Fact] + 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 result = svc.Launch(Guid.NewGuid()); + + Assert.Equal(LaunchStatus.Error, result.Status); + Assert.Contains("magos_launcher.exe", result.Message); + Assert.Contains("not found", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, fx.Launcher.Calls); + } + + [Fact] + 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 result = svc.Launch(Guid.NewGuid()); + + Assert.Equal(LaunchStatus.Error, result.Status); + Assert.NotNull(result.Message); + Assert.Equal(1, fx.Launcher.Calls); // it tried, but Start returned false + } + + [Fact] + public void Error_result_carries_empty_missing_fields() + { + // Error (not DiscoveryIncomplete) must always carry an empty missing-fields list. + using var fx = new EnginseerFixture(); + fx.Steam.Result = FakeDiscovery.CompleteLinux; + fx.DeleteLauncher(); + var svc = fx.BuildService(LaunchPlatform.Linux); + + var result = svc.Launch(Guid.NewGuid()); + + Assert.Equal(LaunchStatus.Error, result.Status); + Assert.Empty(result.MissingDiscoveryFields); + } + + /// + /// Ordinal index-of for (no IndexOf on that + /// interface; Array.IndexOf needs an Array). Used to locate flag positions. + /// + private static int IndexOf(IReadOnlyList list, string value) + { + for (var i = 0; i < list.Count; i++) + { + if (string.Equals(list[i], value, StringComparison.Ordinal)) + { + return i; + } + } + return -1; + } +} diff --git a/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/Magos.Modificus.EnginseerClient.Tests.csproj b/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/Magos.Modificus.EnginseerClient.Tests.csproj new file mode 100644 index 00000000..058fa7ee --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/Magos.Modificus.EnginseerClient.Tests.csproj @@ -0,0 +1,51 @@ + + + + false + true + + Exe + false + Magos.Modificus.EnginseerClient.Tests + Magos.Modificus.EnginseerClient.Tests + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + diff --git a/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/SmokeHarness/Program.cs b/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/SmokeHarness/Program.cs new file mode 100644 index 00000000..6e6def5f --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/SmokeHarness/Program.cs @@ -0,0 +1,243 @@ +using Magos.Modificus.Config; +using Magos.Modificus.EnginseerClient; +using Magos.Modificus.General; +using Magos.Modificus.Profiles; +using Magos.Modificus.Steam; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Magos.Modificus.EnginseerClient.Tests.Harness; + +// ============================================================================= +// Launch smoke-test harness — USER-machine validation. +// +// The agent env has no Darktide/Windows/Proton, so the launch smoke test is +// run by the user on their box. This harness builds the REAL Magos composition +// (no fakes) and exposes IEnginseerLaunchService.Launch(profileId) at the CLI: +// +// dotnet run --project magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests -- discover +// dotnet run --project magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests -- list +// dotnet run --project magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests -- launch +// +// See README at the bottom of this file for the full smoke-test workflow. +// ============================================================================= + +/// +/// Entry point for dotnet run. dotnet test ignores this (the VSTest +/// adapter runs the xUnit suite independently). Exits 0 on a launched/healthy +/// result, non-zero on DiscoveryIncomplete/Error so a CI/script can detect failure. +/// +internal static class Program +{ + private static int Main(string[] args) + { + if (args.Length == 0 || IsHelp(args[0])) + { + PrintUsage(); + return 0; + } + + var command = args[0].ToLowerInvariant(); + return command switch + { + "discover" => RunDiscover(), + "list" => RunList(), + "launch" => RunLaunch(args), + _ => UnknownCommand(command), + }; + } + + // ---- commands ----------------------------------------------------------- + + private static int RunDiscover() + { + using var provider = BuildComposition(); + var steam = provider.GetRequiredService(); + var config = provider.GetRequiredService(); + + Console.WriteLine($"Platform: {CurrentPlatformLabel()}"); + Console.WriteLine($"Runtime dir: {config.EnginseerRuntimeDir}"); + Console.WriteLine("Discovering Steam + Darktide + Proton..."); + var d = steam.Discover(); + + Console.WriteLine(); + Console.WriteLine($" Status: {d.Status}"); + Console.WriteLine($" SteamInstallPath: {NullOrValue(d.SteamInstallPath)}"); + Console.WriteLine($" DarktideGameBinary: {NullOrValue(d.DarktideGameBinaryPath)}"); + Console.WriteLine($" CompatdataPath: {NullOrValue(d.CompatdataPath)}"); + Console.WriteLine($" ProtonBinaryPath: {NullOrValue(d.ProtonBinaryPath)}"); + Console.WriteLine($" ProtonVersion: {NullOrValue(d.ProtonVersion)}"); + if (d.Warnings.Count > 0) + { + Console.WriteLine(" Warnings:"); + foreach (var w in d.Warnings) + { + Console.WriteLine($" - {w}"); + } + } + + Console.WriteLine(); + var gameRunning = steam.IsGameRunning(); + Console.WriteLine($" Darktide running? {gameRunning}"); + Console.WriteLine(); + Console.WriteLine(d.Status == DiscoveryStatus.Complete + ? "Discovery: OK — ready to launch." + : "Discovery: INCOMPLETE — fix the missing fields above (or run the Magos UI's escape hatch)."); + return d.Status == DiscoveryStatus.Complete ? 0 : 2; + } + + private static int RunList() + { + using var provider = BuildComposition(); + var profiles = provider.GetRequiredService(); + + Console.WriteLine("Profiles:"); + var list = profiles.ListProfiles(); + if (list.Count == 0) + { + Console.WriteLine(" (none — create one via the Magos UI first)"); + return 0; + } + + foreach (var p in list) + { + Console.WriteLine($" {p.Id} {p.Name}"); + } + + Console.WriteLine(); + Console.WriteLine("Pass a profile id to: dotnet run -- launch "); + return 0; + } + + private static int RunLaunch(string[] args) + { + if (args.Length < 2 || !Guid.TryParse(args[1], out var profileId)) + { + Console.Error.WriteLine("Usage: dotnet run -- launch "); + Console.Error.WriteLine("Run `dotnet run -- list` to see profile ids."); + return 64; // EX_USAGE + } + + using var provider = BuildComposition(); + var launch = provider.GetRequiredService(); + + Console.WriteLine($"Launching profile {profileId} on {CurrentPlatformLabel()}..."); + var result = launch.Launch(profileId); + + Console.WriteLine(); + Console.WriteLine($" Status: {result.Status}"); + if (result.Message is not null) + { + Console.WriteLine($" Message: {result.Message}"); + } + if (result.MissingDiscoveryFields.Count > 0) + { + Console.WriteLine($" MissingDiscovery: {string.Join(", ", result.MissingDiscoveryFields)}"); + } + + Console.WriteLine(); + return result.Status switch + { + LaunchStatus.Launched => Ok(result), + LaunchStatus.DiscoveryIncomplete => 2, + LaunchStatus.Error => 1, + _ => 1, + }; + } + + private static int Ok(LaunchResult result) + { + Console.WriteLine("Launcher started — watch the game window (and the Enginseer shell log: magos_enginseer.log)."); + return 0; + } + + // ---- composition -------------------------------------------------------- + + /// + /// Builds the REAL composition: loads the user's config.json, wires the + /// Serilog logger, and registers every library with its production + /// implementation (real ProfileService, real SteamService, real + /// ProcessLauncher). No fakes — this is the same wiring the Magos UI uses. + /// + private static ServiceProvider BuildComposition() + { + var config = new ConfigLoader().Load(); + var loggerFactory = LoggingBootstrap.CreateLoggerFactory(config); + + var services = new ServiceCollection(); + services.AddGeneral(config, loggerFactory); + services.AddProfiles(); + services.AddSteam(); + services.AddEnginseerClient(); + return services.BuildServiceProvider(); + } + + // ---- small helpers ------------------------------------------------------ + + private static bool IsHelp(string arg) => + arg.Equals("-h", StringComparison.OrdinalIgnoreCase) || + arg.Equals("--help", StringComparison.OrdinalIgnoreCase) || + arg.Equals("help", StringComparison.OrdinalIgnoreCase); + + private static string NullOrValue(string? value) => value ?? "(missing)"; + + private static string CurrentPlatformLabel() => + OperatingSystem.IsWindows() ? "Windows (native launcher)" + : OperatingSystem.IsLinux() ? "Linux (proton run)" + : Environment.OSVersion.Platform.ToString(); + + private static int UnknownCommand(string command) + { + Console.Error.WriteLine($"Unknown command: {command}"); + PrintUsage(); + return 64; + } + + private static void PrintUsage() + { + Console.WriteLine("Magos Enginseer launch smoke-test harness"); + Console.WriteLine(); + Console.WriteLine("Usage:"); + Console.WriteLine(" dotnet run --project .../Magos.Modificus.EnginseerClient.Tests -- discover"); + Console.WriteLine(" Resolves Steam + Darktide + Proton + compatdata and prints the result."); + Console.WriteLine(); + Console.WriteLine(" dotnet run --project .../Magos.Modificus.EnginseerClient.Tests -- list"); + Console.WriteLine(" Lists profile ids + names (use one with `launch`)."); + Console.WriteLine(); + Console.WriteLine(" dotnet run --project .../Magos.Modificus.EnginseerClient.Tests -- launch "); + Console.WriteLine(" Prepares the mod root (writes mods.lst) and launches Darktide modded."); + Console.WriteLine(); + Console.WriteLine("Exit codes: 0 = launched/discovery OK, 1 = launch error, 2 = discovery incomplete, 64 = bad usage."); + } +} + +// ============================================================================= +// How to run the launch smoke test (user machine) +// ============================================================================= +// +// Prereqs: +// - Darktide installed via Steam (Steam closed for a clean launch). +// - The Enginseer runtime deployed: /magos_launcher.exe +// + magos_shell.dll + mod_loader/. (EnginseerRuntimeDir defaults to +// /Magos Modificus/enginseer; override in config.json.) +// - At least one Magos profile (create it via the Magos UI, or drop a profile +// dir under /Magos Modificus/profiles//profile.json). +// +// Steps: +// 1. cd /magos-modificus +// 2. dotnet run --project tests/Magos.Modificus.EnginseerClient.Tests -- discover +// -> confirm Status: Complete (fix any "(missing)" field before launching). +// 3. dotnet run --project tests/Magos.Modificus.EnginseerClient.Tests -- list +// -> copy the profile id you want to launch. +// 4. dotnet run --project tests/Magos.Modificus.EnginseerClient.Tests -- launch +// -> expect: Status: Launched. Darktide should start modded. +// 5. Confirm the Enginseer shell log (magos_enginseer.log next to the +// launcher) shows the shell attaching + the mod loader running. +// +// Notes: +// - Linux: discovery must also resolve CompatdataPath + ProtonBinaryPath; the +// harness invokes ` run ...` with +// STEAM_COMPAT_DATA_PATH + STEAM_COMPAT_CLIENT_INSTALL_PATH set. +// - The harness launches fire-and-forget (it returns once the launcher starts). +// - This harness is a test-only convenience; the production surface is the +// Magos UI (Phase 3) calling the same IEnginseerLaunchService.Launch. diff --git a/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/TestDoubles.cs b/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/TestDoubles.cs new file mode 100644 index 00000000..18da76de --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/TestDoubles.cs @@ -0,0 +1,130 @@ +using Magos.Modificus.Profiles; +using Magos.Modificus.Steam; + +namespace Magos.Modificus.EnginseerClient.Tests; + +/// +/// Hand-rolled test double for . Only +/// is exercised by the launch path; the rest of the +/// surface throws to catch accidental misuse. +/// +internal sealed class FakeProfileService : IProfileService +{ + /// The path returned by (the --mod-path). + public string PrepareModRootResult { get; set; } = "/home/u/.local/share/Magos Modificus/profiles//mods"; + + /// When set, throws KeyNotFoundException (unknown profile). + public bool UnknownProfile { get; set; } + + public Guid LastPrepareModRootId { get; private set; } + public int PrepareModRootCalls { get; private set; } + + /// + public string PrepareModRoot(Guid id) + { + PrepareModRootCalls++; + LastPrepareModRootId = id; + if (UnknownProfile) + { + throw new KeyNotFoundException($"No profile exists with id '{id}'."); + } + return PrepareModRootResult; + } + + // The remainder of the surface is unused by the launch path. + public IReadOnlyList ListProfiles() => throw new NotSupportedException(); + public Profile GetProfile(Guid id) => throw new NotSupportedException(); + public Profile CreateProfile(string name) => throw new NotSupportedException(); + public void RenameProfile(Guid id, string newName) => throw new NotSupportedException(); + public void DeleteProfile(Guid id) => throw new NotSupportedException(); + public IReadOnlyList GetModList(Guid id) => throw new NotSupportedException(); + public void SetModOrder(Guid id, IReadOnlyList modNamesInOrder) => throw new NotSupportedException(); + public void SetModEnabled(Guid id, string modName, bool enabled) => throw new NotSupportedException(); + public void AddMod(Guid id, string modName) => throw new NotSupportedException(); + public void RemoveMod(Guid id, string modName) => throw new NotSupportedException(); +} + +/// Hand-rolled test double for . +internal sealed class FakeSteamService : ISteamService +{ + public DiscoveryResult Result { get; set; } = FakeDiscovery.CompleteLinux; + public int DiscoverCalls { get; private set; } + + /// + public DiscoveryResult Discover() + { + DiscoverCalls++; + return Result; + } + + /// + public bool IsGameRunning() => false; +} + +/// +/// Hand-rolled test double for . Records the last +/// invocation's filePath / arguments / environment and returns a configurable +/// boolean (default true = started). +/// +internal sealed class FakeProcessLauncher : IProcessLauncher +{ + /// The value returned by (default true = started). + public bool Returns { get; set; } = true; + + public string? FilePath { get; private set; } + public IReadOnlyList? Arguments { get; private set; } + public IReadOnlyDictionary? Environment { get; private set; } + public int Calls { get; private set; } + + /// + public bool Start( + string filePath, + IReadOnlyList arguments, + IReadOnlyDictionary? environmentVariables) + { + Calls++; + FilePath = filePath; + Arguments = arguments; + Environment = environmentVariables; + return Returns; + } +} + +/// +/// Realistic complete fixtures for each platform — +/// the values a real Steam discovery would yield on a healthy install. Tests +/// selectively null fields to exercise the DiscoveryIncomplete path. +/// +internal static class FakeDiscovery +{ + public const string LinuxSteam = "/home/u/.steam/steam"; + public const string LinuxGameBinary = + "/home/u/.steam/steam/steamapps/common/Warhammer 40,000 DARKTIDE/binaries/Darktide.exe"; + public const string LinuxCompatdata = + "/home/u/.steam/steam/steamapps/compatdata/1361210"; + public const string LinuxProton = + "/home/u/.steam/steam/steamapps/common/Proton - Experimental/proton"; + public const string LinuxProtonVersion = "Proton - Experimental"; + + public const string WindowsSteam = @"C:\Program Files (x86)\Steam"; + public const string WindowsGameBinary = + @"C:\Program Files (x86)\Steam\steamapps\common\Warhammer 40,000 DARKTIDE\binaries\Darktide.exe"; + + public static DiscoveryResult CompleteLinux { get; } = new( + SteamInstallPath: LinuxSteam, + DarktideGameBinaryPath: LinuxGameBinary, + CompatdataPath: LinuxCompatdata, + ProtonBinaryPath: LinuxProton, + ProtonVersion: LinuxProtonVersion, + Status: DiscoveryStatus.Complete, + Warnings: Array.Empty()); + + public static DiscoveryResult CompleteWindows { get; } = new( + SteamInstallPath: WindowsSteam, + DarktideGameBinaryPath: WindowsGameBinary, + CompatdataPath: null, + ProtonBinaryPath: null, + ProtonVersion: null, + Status: DiscoveryStatus.Complete, + Warnings: Array.Empty()); +} diff --git a/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/WinePathTests.cs b/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/WinePathTests.cs new file mode 100644 index 00000000..dcec73d4 --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/WinePathTests.cs @@ -0,0 +1,45 @@ +namespace Magos.Modificus.EnginseerClient.Tests; + +/// +/// Direct unit tests for the Z:\ translation helper — the Linux launch +/// path's correctness hinge. Translation is pure (no I/O), so these are +/// exhaustive on the shape. +/// +public sealed class WinePathTests +{ + [Theory] + [InlineData("/home/u/mods", @"Z:\home\u\mods")] + [InlineData("/home/u/.local/share/Magos Modificus/profiles/abc/mods", + @"Z:\home\u\.local\share\Magos Modificus\profiles\abc\mods")] + [InlineData("/home/u/.steam/steam/steamapps/common/Warhammer 40,000 DARKTIDE/binaries/Darktide.exe", + @"Z:\home\u\.steam\steam\steamapps\common\Warhammer 40,000 DARKTIDE\binaries\Darktide.exe")] + [InlineData("/opt/enginseer", @"Z:\opt\enginseer")] + public void ToWine_translates_absolute_posix_path(string posix, string expected) + { + Assert.Equal(expected, WinePath.ToWine(posix)); + } + + [Fact] + public void ToWine_root_maps_to_z_root() + { + // The POSIX root maps to the Wine Z: drive root. + Assert.Equal(@"Z:\", WinePath.ToWine("/")); + } + + [Fact] + public void ToWine_replaces_every_forward_slash() + { + // No forward slashes survive — the launcher (under Wine) needs backslashes. + var result = WinePath.ToWine("/a/b/c/d"); + Assert.DoesNotContain('/', result); + Assert.Equal(@"Z:\a\b\c\d", result); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ToWine_rejects_blank(string value) + { + Assert.Throws(() => WinePath.ToWine(value)); + } +} From 01c589cb109b6545239c971d649d071f86515904 Mon Sep 17 00:00:00 2001 From: ModifAmorphic Date: Wed, 1 Jul 2026 19:32:27 -0700 Subject: [PATCH 3/4] fix(magos-modificus): Z:\-translate --log-file for the Linux launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BuildLauncherArgs translated only --game-binary + --mod-path on Linux (per the spec's literal Z:\ enumeration). But magos_launcher.exe runs under Wine and opens --log-file itself, so a POSIX path there can't be opened under Wine — magos_enginseer.log wouldn't be written where Magos expects on Linux. Translate --log-file with the same flag (one line); --log-level is a level name, not a path, and stays as-is. Spec oversight confirmed by the lead (the lead wrote the spec). Tests: add Linux_translates_log_file_to_wine_path, and extend the Windows guard to assert --log-file passes through untranslated (no Z:\). --- .../EnginseerLaunchService.cs | 6 ++++- .../EnginseerLaunchServiceTests.cs | 27 ++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/magos-modificus/enginseer-client/EnginseerLaunchService.cs b/magos-modificus/enginseer-client/EnginseerLaunchService.cs index e68d4f4b..32f08522 100644 --- a/magos-modificus/enginseer-client/EnginseerLaunchService.cs +++ b/magos-modificus/enginseer-client/EnginseerLaunchService.cs @@ -207,12 +207,16 @@ private static List BuildLauncherArgs( { 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). --log-level is a level name, not a path. + var log = translate ? WinePath.ToWine(logFile) : logFile; return new List { "--game-binary", game, "--mod-path", mod, - "--log-file", logFile, + "--log-file", log, "--log-level", logLevel, }; } diff --git a/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerLaunchServiceTests.cs b/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerLaunchServiceTests.cs index cbd4c32c..bd7fde6f 100644 --- a/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerLaunchServiceTests.cs +++ b/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerLaunchServiceTests.cs @@ -41,17 +41,23 @@ public void Windows_assembles_correct_args_and_invokes_launcher_directly() [Fact] public void Windows_paths_are_not_z_translated() { - // Guard: a native Windows path must pass through unchanged (no Z:\ prefix). + // Guard: every path-valued flag must pass through unchanged on Windows + // (no Z:\ prefix) — translation is a Linux-only concern. using var fx = new EnginseerFixture(); fx.Steam.Result = FakeDiscovery.CompleteWindows; + const string LogFile = @"C:\magos\logs\magos.log"; + fx.Config.Logging.LogFile = LogFile; var svc = fx.BuildService(LaunchPlatform.Windows); svc.Launch(Guid.NewGuid()); var args = fx.Launcher.Arguments!; var game = args[IndexOf(args, "--game-binary") + 1]; + var log = args[IndexOf(args, "--log-file") + 1]; Assert.Equal(FakeDiscovery.WindowsGameBinary, game); + Assert.Equal(LogFile, log); Assert.DoesNotContain("Z:", game); + Assert.DoesNotContain("Z:", log); } [Fact] @@ -93,6 +99,25 @@ public void Linux_translates_mod_path_and_game_binary_to_wine_paths() game); } + [Fact] + public void Linux_translates_log_file_to_wine_path() + { + // The launcher runs under Wine and opens --log-file itself, so it must + // be Z:\-translated on Linux (else magos_enginseer.log can't be written + // where Magos expects). + using var fx = new EnginseerFixture(); + 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); + + svc.Launch(Guid.NewGuid()); + + var args = fx.Launcher.Arguments!; + var log = args[IndexOf(args, "--log-file") + 1]; + Assert.Equal(@"Z:\home\u\.local\share\Magos Modificus\logs\magos.log", log); + } + [Fact] public void Linux_sets_both_steam_compat_env_vars_from_discovery() { From 9a19636fbcda94e2274c0cfeeb430bb62e3b2e38 Mon Sep 17 00:00:00 2001 From: ModifAmorphic Date: Wed, 1 Jul 2026 19:54:03 -0700 Subject: [PATCH 4/4] fix(magos-modificus): don't forward Serilog log level to the shell (vocabulary mismatch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BuildLauncherArgs was forwarding MagosConfig.Logging.Level to the launcher's --log-level, but that value is a Serilog LogEventLevel name (Verbose/Debug/Information/Warning/Error/Fatal). The Enginseer shell's resolve_log_level recognizes only error/warn/info/debug/trace (case-insensitive) and falls back to info for unknown names — so 4/6 Serilog levels silently mis-resolved: Warning -> info (more noise than intended), Verbose -> info (wanted trace), Fatal -> info (wanted error- only). The bug was invisible to tests since they only asserted the value was forwarded verbatim, never that the shell understood it. The Magos Serilog log and the Enginseer shell log serve different purposes; coupling their levels was the mistake. Drop the --log-level flag entirely (and the now-unused logLevel parameter through Launch/LaunchWindows/ LaunchLinux) and rely on the launcher's info default — consistent with the --steam-app-id omission (both rely on the launcher's own default). A dedicated shell-level config field can be added if a future need arises. Tests: drop the --log-level assertions from the Windows/Linux arg-assembly tests and assert --log-level is NOT emitted (both platforms). --- .../EnginseerLaunchService.cs | 36 +++++++++++-------- .../EnginseerLaunchServiceTests.cs | 10 ++++-- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/magos-modificus/enginseer-client/EnginseerLaunchService.cs b/magos-modificus/enginseer-client/EnginseerLaunchService.cs index 32f08522..5a88c439 100644 --- a/magos-modificus/enginseer-client/EnginseerLaunchService.cs +++ b/magos-modificus/enginseer-client/EnginseerLaunchService.cs @@ -113,11 +113,10 @@ public LaunchResult Launch(Guid profileId) var gameBinary = discovery.DarktideGameBinaryPath!; var logFile = _config.Logging.LogFile; - var logLevel = _config.Logging.Level; var started = _platform == LaunchPlatform.Windows - ? LaunchWindows(launcherPath, gameBinary, modPath, logFile, logLevel) - : LaunchLinux(discovery, launcherPath, gameBinary, modPath, logFile, logLevel); + ? LaunchWindows(launcherPath, gameBinary, modPath, logFile) + : LaunchLinux(discovery, launcherPath, gameBinary, modPath, logFile); if (!started) { @@ -150,10 +149,10 @@ public LaunchResult Launch(Guid profileId) // ---- Windows ----------------------------------------------------------- private bool LaunchWindows( - string launcherPath, string gameBinary, string modPath, string logFile, string logLevel) + string launcherPath, string gameBinary, string modPath, string logFile) { // Direct invocation — no Proton, no path translation (native Windows paths). - var args = BuildLauncherArgs(gameBinary, modPath, logFile, logLevel, translate: false); + var args = BuildLauncherArgs(gameBinary, modPath, logFile, translate: false); _logger.LogInformation("Launching (Windows) {Launcher} {Args}", launcherPath, FormatArgs(args)); return _launcher.Start(launcherPath, args, environmentVariables: null); } @@ -165,13 +164,13 @@ private bool LaunchLinux( string launcherPath, string gameBinary, string modPath, - string logFile, - string logLevel) + string logFile) { - // The launcher's OWN args (--game-binary, --mod-path) 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, logLevel, translate: true); + // 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) { @@ -202,14 +201,24 @@ private bool LaunchLinux( /// 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, string logLevel, bool translate) + 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). --log-level is a level name, not a path. + // written where Magos expects). var log = translate ? WinePath.ToWine(logFile) : logFile; return new List @@ -217,7 +226,6 @@ private static List BuildLauncherArgs( "--game-binary", game, "--mod-path", mod, "--log-file", log, - "--log-level", logLevel, }; } diff --git a/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerLaunchServiceTests.cs b/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerLaunchServiceTests.cs index bd7fde6f..4b97b03d 100644 --- a/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerLaunchServiceTests.cs +++ b/magos-modificus/tests/Magos.Modificus.EnginseerClient.Tests/EnginseerLaunchServiceTests.cs @@ -33,9 +33,13 @@ public void Windows_assembles_correct_args_and_invokes_launcher_directly() Assert.Equal( new[] { "--game-binary", FakeDiscovery.WindowsGameBinary, "--mod-path", @"C:\magos\profiles\abc\mods", - "--log-file", fx.Config.Logging.LogFile, - "--log-level", fx.Config.Logging.Level }, + "--log-file", fx.Config.Logging.LogFile }, fx.Launcher.Arguments); + + // --log-level is intentionally NOT emitted: the shell's level vocabulary + // (error/warn/info/debug/trace) differs from Serilog's, so the launcher's + // info default is used (the two logs are decoupled). + Assert.DoesNotContain("--log-level", fx.Launcher.Arguments!); } [Fact] @@ -148,6 +152,8 @@ public void Linux_invokes_proton_run_with_launcher_not_launcher_alone() Assert.Equal("run", args[0]); Assert.Equal(fx.LauncherPath, args[1]); // native Linux path — Proton resolves it Assert.True(args.Count > 2, "expected launcher flags after the launcher path"); + // --log-level is not emitted (shell level vocabulary != Serilog's). + Assert.DoesNotContain("--log-level", args); } [Fact]