diff --git a/GenHub/GenHub.Core/Constants/RetailArchiveConstants.cs b/GenHub/GenHub.Core/Constants/RetailArchiveConstants.cs
new file mode 100644
index 00000000..0b9bbf11
--- /dev/null
+++ b/GenHub/GenHub.Core/Constants/RetailArchiveConstants.cs
@@ -0,0 +1,66 @@
+using System.IO;
+
+namespace GenHub.Core.Constants;
+
+///
+/// The contract between GenHub and a non-Windows engine build for locating retail archives.
+///
+///
+/// A non-Windows engine reads InstallPath through GetStringFromRegistry, which
+/// on these platforms consults these variables before anything else, then mounts
+/// *.big from those roots in addition to the working directory. The names are
+/// therefore an external contract: changing one silently stops the engine finding content,
+/// with no error from either side.
+///
+/// Windows resolves install paths from the registry and does not read these, so nothing is
+/// set — and nothing should be validated against them — on that platform.
+///
+///
+public static class RetailArchiveConstants
+{
+ /// Environment variable naming the Zero Hour retail directory.
+ public const string ZeroHourInstallPathVariable = "CNC_ZH_INSTALLPATH";
+
+ /// Environment variable naming the Generals retail directory.
+ public const string GeneralsInstallPathVariable = "CNC_GENERALS_INSTALLPATH";
+
+ ///
+ /// Search pattern for the archives the engine mounts from a retail root.
+ ///
+ ///
+ /// Used as an existence sentinel rather than matching a specific filename, which varies
+ /// by localisation and version.
+ ///
+ public const string ArchiveSearchPattern = "*.big";
+
+ ///
+ /// How is matched within a retail root.
+ ///
+ ///
+ /// Case-insensitive because retail data copied from a disc or a Windows machine is
+ /// frequently upper-cased, while the default glob is case-sensitive on Linux volumes and on
+ /// case-sensitive APFS. INIZH.BIG would otherwise read as no archives at all and
+ /// block a launch that would have worked — the opposite of what the check exists to do.
+ ///
+ /// is set back to false because
+ /// it defaults to true here, unlike the overload. Left at
+ /// the default it turns an unreadable root into "no archives found", reporting a permission
+ /// problem as missing content.
+ ///
+ ///
+ public static readonly EnumerationOptions ArchiveSearch = new()
+ {
+ MatchCasing = MatchCasing.CaseInsensitive,
+ RecurseSubdirectories = false,
+ IgnoreInaccessible = false,
+ };
+
+ ///
+ /// Every retail archive root variable, Zero Hour first.
+ ///
+ public static readonly string[] InstallPathVariables =
+ [
+ ZeroHourInstallPathVariable,
+ GeneralsInstallPathVariable,
+ ];
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/BoundedErrorBufferTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/BoundedErrorBufferTests.cs
new file mode 100644
index 00000000..74e36efc
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/BoundedErrorBufferTests.cs
@@ -0,0 +1,104 @@
+using System.Reflection;
+using GenHub.Features.GameProfiles.Infrastructure;
+using Xunit;
+
+namespace GenHub.Tests.Core.Features.GameProfiles;
+
+///
+/// Tests for the bounded stderr capture used to explain a failed launch.
+///
+///
+/// The buffer is a private nested type; it is exercised through reflection rather than
+/// being made public, because it is an implementation detail of process management and
+/// its behaviour only matters through the diagnostics it produces.
+///
+public class BoundedErrorBufferTests
+{
+ private static readonly Type BufferType =
+ typeof(GameProcessManager).GetNestedType("BoundedErrorBuffer", BindingFlags.NonPublic)!;
+
+ ///
+ /// The startup context is where the cause usually is, so the first lines must survive
+ /// even when far more output follows than the buffer retains.
+ ///
+ [Fact]
+ public void Append_RetainsBothTheHeadAndTheTail()
+ {
+ var buffer = CreateBuffer();
+
+ Append(buffer, "dyld: library not loaded");
+ for (var i = 0; i < 200; i++)
+ {
+ Append(buffer, $"noise line {i}");
+ }
+
+ Append(buffer, "Abort trap: 6");
+
+ var text = buffer.ToString()!;
+
+ Assert.Contains("dyld: library not loaded", text);
+ Assert.Contains("Abort trap: 6", text);
+ Assert.Contains("omitted", text);
+ }
+
+ ///
+ /// A single pathological line must not be retained in full.
+ ///
+ [Fact]
+ public void Append_TruncatesAnOverlongLine()
+ {
+ var buffer = CreateBuffer();
+
+ Append(buffer, new string('x', 10_000));
+
+ var text = buffer.ToString()!;
+
+ Assert.Contains("line truncated", text);
+ Assert.True(text.Length < 10_000, "The overlong line was retained in full.");
+ }
+
+ ///
+ /// A null line is the framework's end-of-stream signal, not content.
+ ///
+ [Fact]
+ public void Append_TreatsNullAsEndOfStreamRatherThanContent()
+ {
+ var buffer = CreateBuffer();
+
+ Assert.False(EndOfStreamReached(buffer));
+
+ Append(buffer, "something failed");
+ Append(buffer, null);
+
+ Assert.True(EndOfStreamReached(buffer));
+ Assert.Equal("something failed", buffer.ToString());
+ }
+
+ ///
+ /// Total retained output stays bounded regardless of how much arrives.
+ ///
+ [Fact]
+ public void Append_BoundsTotalRetainedOutput()
+ {
+ var buffer = CreateBuffer();
+
+ for (var i = 0; i < 5_000; i++)
+ {
+ Append(buffer, new string('y', 500));
+ }
+
+ Assert.True(
+ buffer.ToString()!.Length < 128 * 1024,
+ "Retained output grew beyond the cap.");
+ }
+
+ private static object CreateBuffer() => Activator.CreateInstance(BufferType, nonPublic: true)!;
+
+ private static void Append(object buffer, string? line) =>
+ BufferType.GetMethod("Append", BindingFlags.NonPublic | BindingFlags.Instance)!
+ .Invoke(buffer, [line]);
+
+ private static bool EndOfStreamReached(object buffer) =>
+ (bool)BufferType.GetProperty("EndOfStreamReached", BindingFlags.NonPublic | BindingFlags.Instance)!
+ .GetValue(buffer)!;
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientFixture.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientFixture.cs
new file mode 100644
index 00000000..e22c3adf
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientFixture.cs
@@ -0,0 +1,74 @@
+using System.Linq;
+using System.Runtime.InteropServices;
+
+namespace GenHub.Tests.Core.Features.GameProfiles;
+
+///
+/// Locates the local native client these integration tests launch.
+///
+///
+/// Shared by every test that spawns the real engine, so discovery stays in one place: all
+/// of them skip unless a client is present, and diverging on how it is found would make
+/// some skip while others fail.
+///
+public static class NativeClientFixture
+{
+ /// Environment variable overriding the discovered directory.
+ public const string EnvironmentOverride = "GENHUB_NATIVE_CLIENT_DIR";
+
+ /// The engine executable's filename.
+ public const string BinaryName = "generalszh";
+
+ ///
+ /// Gets the native client directory, or null when these tests should skip.
+ ///
+ public static string? Directory
+ {
+ get
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return null;
+ }
+
+ var configured = Environment.GetEnvironmentVariable(EnvironmentOverride);
+ if (!string.IsNullOrWhiteSpace(configured))
+ {
+ // Validated the same way as the discovered default below. A directory that
+ // exists but holds no engine binary is a misconfigured override, and these
+ // tests should skip rather than fail on it.
+ return File.Exists(Path.Combine(configured, BinaryName)) ? configured : null;
+ }
+
+ var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ var defaultDirectory = Path.Combine(home, "TheSuperHackers", "GeneralsZH");
+
+ return File.Exists(Path.Combine(defaultDirectory, BinaryName)) ? defaultDirectory : null;
+ }
+ }
+
+ ///
+ /// Determines whether a filename is one of the engine's dynamic libraries.
+ ///
+ ///
+ /// Covers macOS .dylib and both Linux forms: unversioned .so and versioned
+ /// .so.0 / .so.0.1.0, which are the common shape of a shipped Linux library
+ /// and which a plain .so suffix test misses.
+ ///
+ /// The filename to test.
+ /// true when the file is a dynamic library.
+ public static bool IsDynamicLibrary(string fileName)
+ {
+ if (fileName.EndsWith(".dylib", StringComparison.OrdinalIgnoreCase)
+ || fileName.EndsWith(".so", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ // Matched on ".so." rather than ".so" so an earlier coincidental ".so" — inside
+ // ".sound", say — cannot shadow the real suffix and end the search early.
+ var versioned = fileName.IndexOf(".so.", StringComparison.OrdinalIgnoreCase);
+ return versioned >= 0
+ && fileName[(versioned + 4)..].All(c => char.IsDigit(c) || c == '.');
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientFixtureTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientFixtureTests.cs
new file mode 100644
index 00000000..ff5b4860
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientFixtureTests.cs
@@ -0,0 +1,43 @@
+using GenHub.Tests.Core.Features.GameProfiles;
+using Xunit;
+
+namespace GenHub.Tests.Core.Features.GameProfiles;
+
+///
+/// Tests for the shared native-client fixture helper.
+///
+///
+/// The integration tests that use it all skip without a local engine install, so the
+/// library predicate would otherwise never be exercised on CI — including the versioned
+/// Linux forms, which a plain ".so" suffix test silently misses.
+///
+public class NativeClientFixtureTests
+{
+ ///
+ /// Recognises macOS and both Linux shapes, unversioned and versioned.
+ ///
+ /// The candidate filename.
+ [Theory]
+ [InlineData("libSDL3.dylib")]
+ [InlineData("libSDL3.so")]
+ [InlineData("libSDL3.so.0")]
+ [InlineData("libSDL3.so.0.1.0")]
+ [InlineData("libstdc++.so.6")]
+ [InlineData("libavcodec.58.so.4")]
+ [InlineData("mylib.sound.so.1")]
+ public void IsDynamicLibrary_WithLibraryNames_ReturnsTrue(string fileName)
+ => Assert.True(NativeClientFixture.IsDynamicLibrary(fileName));
+
+ ///
+ /// Rejects the engine binary, data files, and names that merely contain ".so".
+ ///
+ /// The candidate filename.
+ [Theory]
+ [InlineData("generalszh")]
+ [InlineData("INIZH.big")]
+ [InlineData("readme.txt")]
+ [InlineData("resources.sound")]
+ [InlineData("libfoo.sox")]
+ public void IsDynamicLibrary_WithNonLibraryNames_ReturnsFalse(string fileName)
+ => Assert.False(NativeClientFixture.IsDynamicLibrary(fileName));
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientLaunchCollection.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientLaunchCollection.cs
new file mode 100644
index 00000000..69fe14a9
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientLaunchCollection.cs
@@ -0,0 +1,21 @@
+using Xunit;
+
+using System.Runtime.InteropServices;
+
+namespace GenHub.Tests.Core.Features.GameProfiles;
+
+///
+/// Serialises the tests that launch a real native game client.
+///
+/// The engine enforces a single running instance, so two of these in parallel produce a
+/// spurious failure: the second launch is refused by the first. That is engine behaviour
+/// rather than a test defect, and it has a product consequence — GenHub cannot run two
+/// native profiles simultaneously.
+///
+///
+[CollectionDefinition(Name, DisableParallelization = true)]
+public class NativeClientLaunchCollection
+{
+ /// The xUnit collection name.
+ public const string Name = "Native client launch";
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientLaunchIntegrationTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientLaunchIntegrationTests.cs
new file mode 100644
index 00000000..209152f3
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientLaunchIntegrationTests.cs
@@ -0,0 +1,152 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using GenHub.Core.Models.Launching;
+using GenHub.Core.Models.Manifest;
+using GenHub.Features.GameProfiles.Infrastructure;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace GenHub.Tests.Core.Features.GameProfiles;
+
+///
+/// End-to-end launch of a real native Zero Hour client through GenHub's own process
+/// manager, rather than a stand-in script.
+///
+/// Everything else in the native-client work is verified against synthetic binaries.
+/// This is the one test that answers the actual question: can GenHub start the real
+ /// engine, against real retail data, and have it remain running.
+///
+///
+/// Skipped unless a native install is present, so CI and other machines stay green.
+/// Point GENHUB_NATIVE_CLIENT_DIR at an install directory to run it; the default
+/// is the deploy script's own default location.
+///
+///
+[Collection(NativeClientLaunchCollection.Name)]
+public class NativeClientLaunchIntegrationTests
+{
+ /// How long the engine must stay up to count as a successful launch.
+ private static readonly TimeSpan LaunchSettleTime = TimeSpan.FromSeconds(12);
+
+ private readonly GameProcessManager _processManager = new(NullLogger.Instance);
+
+ ///
+ /// Launches the engine with the install directory as the working directory, exactly
+ /// as a workspace launch would, and requires it to survive startup.
+ ///
+ /// Windowed mode is requested so a test run cannot take over the display.
+ ///
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task RealNativeClient_LaunchesThroughGameProcessManager()
+ {
+ var installDirectory = NativeClientFixture.Directory;
+ if (installDirectory is null)
+ {
+ return;
+ }
+
+ var configuration = new GameLaunchConfiguration
+ {
+ ExecutablePath = Path.Combine(installDirectory, NativeClientFixture.BinaryName),
+ WorkingDirectory = installDirectory,
+ Arguments = new() { ["-win"] = string.Empty },
+ };
+
+ var result = await _processManager.StartProcessAsync(configuration);
+
+ Assert.True(result.Success, $"Launch failed: {string.Join(" ", result.Errors)}");
+ Assert.NotNull(result.Data);
+
+ try
+ {
+ // StartProcessAsync only waits out the launcher-detection delay. Give the
+ // engine long enough to fail the way it fails for real: mounting archives and
+ // initialising the renderer, both of which happen after the process exists.
+ await Task.Delay(LaunchSettleTime);
+
+ var info = await _processManager.GetProcessInfoAsync(result.Data!.ProcessId);
+ const string failureMessage =
+ "The engine started and then exited during initialisation. That is the failure "
+ + "mode this work exists to make visible; check the captured output.";
+
+ Assert.True(info.Success, failureMessage);
+ }
+ finally
+ {
+ await _processManager.TerminateProcessAsync(result.Data!.ProcessId);
+ }
+ }
+
+ ///
+ /// The working directory is load-bearing, not cosmetic. The engine discovers its
+ /// .big archives relative to the current directory, so launching from anywhere
+ /// else fails within about a second — it finds no archives, or worse, mounts unrelated
+ /// ones it happens to encounter.
+ ///
+ /// This is why WorkspaceStrategyBase sets WorkingDirectory to the
+ /// workspace root, and why a native client cannot simply be launched in place.
+ ///
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task RealNativeClient_RequiresItsInstallDirectoryAsWorkingDirectory()
+ {
+ var installDirectory = NativeClientFixture.Directory;
+ if (installDirectory is null)
+ {
+ return;
+ }
+
+ var isolatedDirectory = Path.Combine(
+ Path.GetTempPath(),
+ $"genhub-wrong-cwd-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(isolatedDirectory);
+
+ try
+ {
+ var configuration = new GameLaunchConfiguration
+ {
+ ExecutablePath = Path.Combine(installDirectory, NativeClientFixture.BinaryName),
+ WorkingDirectory = isolatedDirectory,
+ Arguments = new() { ["-win"] = string.Empty },
+ };
+
+ var result = await _processManager.StartProcessAsync(configuration);
+
+ if (result.Success && result.Data is not null)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(6));
+ var info = await _processManager.GetProcessInfoAsync(result.Data.ProcessId);
+ await _processManager.TerminateProcessAsync(result.Data.ProcessId);
+ const string failureMessage =
+ "The engine survived being launched from an unrelated working directory. If it "
+ + "no longer resolves archives relative to the current directory, the workspace "
+ + "model can be relaxed.";
+
+ Assert.False(info.Success, failureMessage);
+ return;
+ }
+
+ // The expected path: it dies during startup and the failure names the reason
+ // rather than reporting a bare exit code.
+ Assert.False(result.Success);
+ Assert.Contains("exited immediately", string.Join(" ", result.Errors), StringComparison.OrdinalIgnoreCase);
+ }
+ finally
+ {
+ try
+ {
+ Directory.Delete(isolatedDirectory, recursive: true);
+ }
+ catch (IOException)
+ {
+ // Cleanup failure is not a test failure.
+ }
+ }
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientManifestLaunchTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientManifestLaunchTests.cs
new file mode 100644
index 00000000..8c9ec31f
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientManifestLaunchTests.cs
@@ -0,0 +1,156 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using GenHub.Core.Models.Launching;
+using GenHub.Core.Models.Manifest;
+using GenHub.Core.Utilities;
+using GenHub.Features.GameProfiles.Infrastructure;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace GenHub.Tests.Core.Features.GameProfiles;
+
+///
+/// Closes the loop between the manifest model and a real launch.
+///
+/// The variant and entry-point model is otherwise only exercised against hand-built
+/// manifests. This builds a manifest from an actual native install, resolves the entry
+/// point the way the workspace does, and launches whatever comes out — so a resolver
+/// that picks the wrong file fails here rather than in production.
+///
+///
+/// Skipped unless a native install is present. Set GENHUB_NATIVE_CLIENT_DIR to
+/// override the default location.
+///
+///
+[Collection(NativeClientLaunchCollection.Name)]
+public class NativeClientManifestLaunchTests
+{
+ private readonly GameProcessManager _processManager = new(NullLogger.Instance);
+
+ ///
+ /// Builds a manifest describing a real install and launches the entry point the
+ /// resolver selects.
+ ///
+ /// The manifest deliberately includes the engine's dynamic libraries. That is the
+ /// shape that used to break resolution: several files qualify as executable, so
+ /// picking the first one was picking by enumeration order.
+ ///
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ManifestResolvedEntryPoint_LaunchesTheRealEngine()
+ {
+ var installDirectory = NativeClientFixture.Directory;
+ if (installDirectory is null)
+ {
+ return;
+ }
+
+ var manifest = BuildManifestFromInstall(installDirectory);
+
+ // Sanity-check the fixture actually reproduces the ambiguous case; otherwise this
+ // test would pass for the wrong reason.
+ Assert.True(
+ manifest.Variants.Single().Files.Count(f => f.IsExecutable) >= 1,
+ "Expected the install to contain at least one file requiring execute permission.");
+
+ // Without this the fixture could contain only the binary and still satisfy the
+ // assertions, leaving the library-handling path untested on either platform.
+ Assert.Contains(
+ manifest.Variants.Single().Files,
+ file => NativeClientFixture.IsDynamicLibrary(Path.GetFileName(file.RelativePath)));
+
+ var resolution = ManifestVariantResolver.ResolveEntryPoint(manifest);
+
+ Assert.True(resolution.Success, resolution.ToString());
+ Assert.Equal(NativeClientFixture.BinaryName, resolution.RelativePath);
+
+ var configuration = new GameLaunchConfiguration
+ {
+ ExecutablePath = Path.Combine(installDirectory, resolution.RelativePath!),
+ WorkingDirectory = installDirectory,
+ Arguments = new() { ["-win"] = string.Empty },
+ };
+
+ var result = await _processManager.StartProcessAsync(configuration);
+ Assert.True(result.Success, $"Launch failed: {string.Join(" ", result.Errors)}");
+
+ try
+ {
+ await Task.Delay(TimeSpan.FromSeconds(12));
+ var info = await _processManager.GetProcessInfoAsync(result.Data!.ProcessId);
+ Assert.True(info.Success, "The engine exited during initialisation.");
+ }
+ finally
+ {
+ await _processManager.TerminateProcessAsync(result.Data!.ProcessId);
+ }
+ }
+
+ ///
+ /// A manifest whose variant does not target this runtime must resolve to nothing,
+ /// so a Windows-only client is never offered on macOS.
+ ///
+ [Fact]
+ public void ManifestForAnotherRuntime_IsNotOfferedHere()
+ {
+ var installDirectory = NativeClientFixture.Directory;
+ if (installDirectory is null)
+ {
+ return;
+ }
+
+ var manifest = BuildManifestFromInstall(installDirectory);
+ manifest.Variants.Single().RuntimeIdentifiers = ["win-x64"];
+
+ Assert.False(ManifestVariantResolver.SupportsRuntime(manifest));
+ Assert.Empty(ManifestVariantResolver.ResolveFiles(manifest));
+ }
+
+ ///
+ /// Builds a single-variant manifest describing the engine binary and its libraries,
+ /// classified by the shared rules rather than by hand.
+ ///
+ /// The native install directory.
+ /// A manifest targeting the current runtime.
+ private static ContentManifest BuildManifestFromInstall(string installDirectory)
+ {
+ var engineFiles = new List();
+
+ foreach (var path in Directory.EnumerateFiles(installDirectory, "*", SearchOption.TopDirectoryOnly))
+ {
+ var name = Path.GetFileName(path);
+
+ // The engine and its bundled libraries; retail archives are the user's own
+ // content and belong to the installation, not the client manifest.
+ if (name != NativeClientFixture.BinaryName && !NativeClientFixture.IsDynamicLibrary(name))
+ {
+ continue;
+ }
+
+ engineFiles.Add(new ManifestFile
+ {
+ RelativePath = name,
+ IsExecutable = ExecutableFileClassifier.RequiresExecutePermission(name),
+ });
+ }
+
+ return new ContentManifest
+ {
+ Name = "Native Zero Hour (BGFX)",
+ Variants =
+ [
+ new ArtifactVariant
+ {
+ RuntimeIdentifiers = [ManifestVariantResolver.CurrentRuntimeIdentifier],
+ EntryPoint = NativeClientFixture.BinaryName,
+ Files = engineFiles,
+ },
+ ],
+ };
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeLaunchDiagnosticsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeLaunchDiagnosticsTests.cs
new file mode 100644
index 00000000..c2efd342
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeLaunchDiagnosticsTests.cs
@@ -0,0 +1,164 @@
+using System;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using GenHub.Core.Models.Launching;
+using GenHub.Features.GameProfiles.Infrastructure;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace GenHub.Tests.Core.Features.GameProfiles;
+
+///
+/// Verifies that a native client which fails to start says why.
+///
+/// The failure this guards against is silence. A binary missing its execute bit, or one
+/// that dies in the dynamic loader, previously produced no window and no error: the
+/// process started, exited, and the launcher reported an exit code with no context. On a
+/// native client whose libraries sit beside it in the workspace, that is the most likely
+/// first-run failure of all.
+///
+///
+public class NativeLaunchDiagnosticsTests : IDisposable
+{
+ private readonly string _tempDir = Path.Combine(
+ Path.GetTempPath(),
+ $"genhub-launchdiag-{Guid.NewGuid():N}");
+
+ private readonly GameProcessManager _processManager = new(NullLogger.Instance);
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public NativeLaunchDiagnosticsTests() => Directory.CreateDirectory(_tempDir);
+
+ private static bool OnUnix => !RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+
+ ///
+ /// A file without the execute bit must be refused before launch, naming the file.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task NonExecutableFile_IsRefusedWithANamedError()
+ {
+ if (!OnUnix)
+ {
+ return;
+ }
+
+ var binary = Path.Combine(_tempDir, NativeClientFixture.BinaryName);
+ await File.WriteAllTextAsync(binary, "#!/bin/sh\nexit 0\n");
+ if (!OperatingSystem.IsWindows())
+ {
+ File.SetUnixFileMode(binary, UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ }
+
+ var result = await _processManager.StartProcessAsync(new GameLaunchConfiguration
+ {
+ ExecutablePath = binary,
+ WorkingDirectory = _tempDir,
+ });
+
+ Assert.False(result.Success);
+ Assert.Contains("execute permission", string.Join(" ", result.Errors), StringComparison.OrdinalIgnoreCase);
+ Assert.Contains(NativeClientFixture.BinaryName, string.Join(" ", result.Errors));
+ }
+
+ ///
+ /// A process that dies during startup must surface what it wrote to standard error,
+ /// not just its exit code. "dyld: library not loaded" identifies the problem; "exit
+ /// code 1" does not.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ProcessThatDiesAtStartup_SurfacesItsStderr()
+ {
+ if (!OnUnix)
+ {
+ return;
+ }
+
+ var binary = Path.Combine(_tempDir, NativeClientFixture.BinaryName);
+ await File.WriteAllTextAsync(
+ binary,
+ "#!/bin/sh\necho \"dyld: Library not loaded: @rpath/libSDL3.dylib\" >&2\nexit 1\n");
+ if (!OperatingSystem.IsWindows())
+ {
+ File.SetUnixFileMode(
+ binary,
+ UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ }
+
+ var result = await _processManager.StartProcessAsync(new GameLaunchConfiguration
+ {
+ ExecutablePath = binary,
+ WorkingDirectory = _tempDir,
+ });
+
+ Assert.False(result.Success);
+
+ var message = string.Join(" ", result.Errors);
+ Assert.Contains("libSDL3.dylib", message);
+ Assert.Contains("Library not loaded", message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// A healthy process must still launch normally with stderr redirection enabled.
+ /// Redirecting a stream that is never drained is a classic way to deadlock a chatty
+ /// child process, so this confirms the drain works.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ChattyProcess_StillLaunchesWithoutDeadlocking()
+ {
+ if (!OnUnix)
+ {
+ return;
+ }
+
+ var binary = Path.Combine(_tempDir, NativeClientFixture.BinaryName);
+
+ // Writes far more than a pipe buffer holds, then keeps running.
+ await File.WriteAllTextAsync(
+ binary,
+ "#!/bin/sh\ni=0\nwhile [ $i -lt 2000 ]; do echo \"log line $i padding padding padding\" >&2; i=$((i+1)); done\nsleep 30\n");
+ if (!OperatingSystem.IsWindows())
+ {
+ File.SetUnixFileMode(
+ binary,
+ UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ }
+
+ var result = await _processManager.StartProcessAsync(new GameLaunchConfiguration
+ {
+ ExecutablePath = binary,
+ WorkingDirectory = _tempDir,
+ });
+
+ Assert.True(result.Success, $"Launch failed: {string.Join(" ", result.Errors)}");
+
+ if (result.Data is not null)
+ {
+ await _processManager.TerminateProcessAsync(result.Data.ProcessId);
+ }
+ }
+
+ ///
+ /// Releases the temporary directory.
+ ///
+ public void Dispose()
+ {
+ GC.SuppressFinalize(this);
+ try
+ {
+ if (Directory.Exists(_tempDir))
+ {
+ Directory.Delete(_tempDir, recursive: true);
+ }
+ }
+ catch (IOException)
+ {
+ // A leftover temp directory is not worth failing a test over.
+ }
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/RetailArchiveRootTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/RetailArchiveRootTests.cs
new file mode 100644
index 00000000..a743de6b
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/RetailArchiveRootTests.cs
@@ -0,0 +1,192 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Models.Launching;
+using GenHub.Features.GameProfiles.Infrastructure;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace GenHub.Tests.Core.Features.GameProfiles;
+
+///
+/// Verifies that a workspace containing only the engine can reach the user's retail
+/// archives through the environment, instead of materialising ~3 GB per profile.
+///
+/// Zero Hour mounts *.big from the working directory plus any root named by
+/// InstallPath, which the non-Windows engine resolves from
+/// $CNC_ZH_INSTALLPATH and $CNC_GENERALS_INSTALLPATH. Zero Hour needs the
+/// base Generals archives as well as its own, so without this every profile workspace
+/// has to contain both games in full.
+///
+///
+/// Skipped unless a native install is present. Set GENHUB_NATIVE_CLIENT_DIR to
+/// override the default location.
+///
+///
+[Collection(NativeClientLaunchCollection.Name)]
+public class RetailArchiveRootTests : IDisposable
+{
+ private readonly string _engineOnlyWorkspace = Path.Combine(
+ Path.GetTempPath(),
+ $"genhub-engine-only-{Guid.NewGuid():N}");
+
+ private readonly GameProcessManager _processManager = new(NullLogger.Instance);
+
+ ///
+ /// An engine-only workspace plus environment-supplied archive roots must launch and
+ /// stay up, with no .big file anywhere in the workspace.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task EngineOnlyWorkspace_ReachesRetailArchivesThroughEnvironment()
+ {
+ var installDirectory = NativeClientFixture.Directory;
+ if (installDirectory is null)
+ {
+ return;
+ }
+
+ StageEngineOnly(installDirectory);
+
+ Assert.Empty(Directory.GetFiles(_engineOnlyWorkspace, "*.big"));
+
+ var configuration = new GameLaunchConfiguration
+ {
+ ExecutablePath = Path.Combine(_engineOnlyWorkspace, NativeClientFixture.BinaryName),
+ WorkingDirectory = _engineOnlyWorkspace,
+ Arguments = new() { ["-win"] = string.Empty },
+ EnvironmentVariables = new()
+ {
+ // Trailing separator is required; see AddArchiveRoot in GameLauncher.
+ [RetailArchiveConstants.ZeroHourInstallPathVariable] = installDirectory + Path.DirectorySeparatorChar,
+ [RetailArchiveConstants.GeneralsInstallPathVariable] = installDirectory + Path.DirectorySeparatorChar,
+ },
+ };
+
+ var result = await _processManager.StartProcessAsync(configuration);
+ Assert.True(result.Success, $"Launch failed: {string.Join(" ", result.Errors)}");
+
+ try
+ {
+ await Task.Delay(TimeSpan.FromSeconds(14));
+ var info = await _processManager.GetProcessInfoAsync(result.Data!.ProcessId);
+ const string failureMessage =
+ "The engine exited despite the archive roots being supplied, so retail data "
+ + "was not reached through the environment.";
+
+ Assert.True(info.Success, failureMessage);
+ }
+ finally
+ {
+ await _processManager.TerminateProcessAsync(result.Data!.ProcessId);
+ }
+ }
+
+ ///
+ /// Without the archive roots the same workspace must fail, which is what makes the
+ /// test above meaningful rather than a coincidence.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task EngineOnlyWorkspace_WithoutArchiveRoots_DoesNotSurvive()
+ {
+ var installDirectory = NativeClientFixture.Directory;
+ if (installDirectory is null)
+ {
+ return;
+ }
+
+ StageEngineOnly(installDirectory);
+
+ var configuration = new GameLaunchConfiguration
+ {
+ ExecutablePath = Path.Combine(_engineOnlyWorkspace, NativeClientFixture.BinaryName),
+ WorkingDirectory = _engineOnlyWorkspace,
+ Arguments = new() { ["-win"] = string.Empty },
+ };
+
+ var result = await _processManager.StartProcessAsync(configuration);
+
+ if (!result.Success)
+ {
+ return;
+ }
+
+ try
+ {
+ await Task.Delay(TimeSpan.FromSeconds(14));
+ var info = await _processManager.GetProcessInfoAsync(result.Data!.ProcessId);
+ const string failureMessage =
+ "An engine-only workspace survived with no archive roots at all. If the engine "
+ + "now locates retail data by some other means, the environment plumbing in "
+ + "GameLauncher.AddRetailArchiveRoots may be unnecessary.";
+
+ Assert.False(info.Success, failureMessage);
+ }
+ finally
+ {
+ await _processManager.TerminateProcessAsync(result.Data!.ProcessId);
+ }
+ }
+
+ ///
+ /// Releases the staged workspace.
+ ///
+ public void Dispose()
+ {
+ GC.SuppressFinalize(this);
+ try
+ {
+ if (Directory.Exists(_engineOnlyWorkspace))
+ {
+ Directory.Delete(_engineOnlyWorkspace, recursive: true);
+ }
+ }
+ catch (IOException)
+ {
+ // A leftover temp directory is not worth failing a test over.
+ }
+ }
+
+ ///
+ /// Copies just the engine and its dynamic libraries, deliberately leaving out every
+ /// archive and data directory.
+ ///
+ /// The source install.
+ private void StageEngineOnly(string installDirectory)
+ {
+ Directory.CreateDirectory(_engineOnlyWorkspace);
+
+ var engineFiles = Directory
+ .EnumerateFiles(installDirectory, "*", SearchOption.TopDirectoryOnly)
+ .Where(path =>
+ {
+ // Both Unix shared-library extensions: the engine ships .dylib on macOS and
+ // .so on Linux, and staging only one leaves the workspace incomplete there.
+ var name = Path.GetFileName(path);
+ return name == NativeClientFixture.BinaryName
+ || NativeClientFixture.IsDynamicLibrary(name);
+ });
+
+ foreach (var source in engineFiles)
+ {
+ var destination = Path.Combine(_engineOnlyWorkspace, Path.GetFileName(source));
+ File.Copy(source, destination, overwrite: true);
+ }
+
+ const UnixFileMode executableMode =
+ UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
+ UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
+ UnixFileMode.OtherRead | UnixFileMode.OtherExecute;
+
+ if (!OperatingSystem.IsWindows())
+ {
+ File.SetUnixFileMode(
+ Path.Combine(_engineOnlyWorkspace, NativeClientFixture.BinaryName),
+ executableMode);
+ }
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/StderrCaptureRaceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/StderrCaptureRaceTests.cs
new file mode 100644
index 00000000..3aef47b3
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/StderrCaptureRaceTests.cs
@@ -0,0 +1,74 @@
+using System.Runtime.InteropServices;
+using GenHub.Core.Models.Launching;
+using GenHub.Features.GameProfiles.Infrastructure;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace GenHub.Tests.Core.Features.GameProfiles;
+
+///
+/// End-to-end check that stderr from a process which dies immediately is still captured.
+///
+///
+/// Covers the capture end to end: a real process fails, and both the first and last of
+/// its stderr survive into the reported error. The buffer's own tests exercise retention
+/// in isolation; this proves the wiring — handler, drain, buffer and error message —
+/// actually delivers them to the caller.
+///
+/// It does not deterministically pin the end-of-stream drain. Removing the
+/// WaitForExit() call leaves this test passing on macOS and .NET 8, because the
+/// handlers happen to complete before the capture is read even at twenty thousand lines.
+/// The drain is retained on the documented contract — the parameterless overload waits
+/// for redirected-output handlers, the timed ones do not — rather than on a failing test
+/// here. A platform with different scheduling may well expose it.
+///
+///
+public class StderrCaptureRaceTests
+{
+ private const string HeadLine = "GENHUB-HEAD-MARKER";
+ private const string TailLine = "GENHUB-TAIL-MARKER";
+
+ ///
+ /// A process that writes a head line, floods the buffer, writes a tail line and exits
+ /// non-zero must have both ends of its output reported in the failure.
+ ///
+ /// A task representing the asynchronous test operation.
+ [Fact]
+ public async Task StartProcessAsync_WithImmediateFailure_CapturesBothEndsOfStderr()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return;
+ }
+
+ // Arguments are key/value pairs; a leading '-' key is emitted as a flag followed
+ // by its value, which produces `/bin/sh -c