Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0ec4d28
feat(launching): surface native launch failures and set the loader se…
bobtista Jul 29, 2026
d0bbc6b
test(launching): verify the real native client launches through GenHub
bobtista Jul 29, 2026
7083aa2
test(launching): serialize real-client launches around the engine ins…
bobtista Jul 29, 2026
ad273e7
feat(launching): reach retail archives via install-path environment i…
bobtista Jul 29, 2026
602fd2f
fix(launching): stop setting DYLD_LIBRARY_PATH and LD_LIBRARY_PATH
bobtista Jul 29, 2026
caf278a
feat(launching): validate retail archive roots before spawning the en…
bobtista Jul 29, 2026
b41e5ee
fix(launching): capture complete stderr when a launch fails
bobtista Jul 29, 2026
58429d5
chore: add stderr capture regression test and clear stack build warnings
bobtista Jul 29, 2026
8120e5e
chore(workspace): order static helper before instance members
bobtista Jul 29, 2026
3e40fa8
chore(workspace): remove trailing class separator
bobtista Jul 29, 2026
461dcdb
chore: clear native launch style warnings
bobtista Jul 29, 2026
08d5bac
chore(workspace): keep member order valid after the executable materi…
bobtista Jul 30, 2026
bdb0b19
fix(launching): scope archive-root validation to non-Windows and norm…
bobtista Jul 30, 2026
5ac6b3a
test(launching): scope the archive-root rejection tests to non-Windows
bobtista Jul 30, 2026
00a2bc6
fix(launching): scope archive validation to the launching game and re…
bobtista Jul 30, 2026
aeaa1c0
fix(launching): validate the base Generals root when launching Zero Hour
bobtista Jul 30, 2026
7e6f8ce
docs(launching): correct why an absent Generals root is tolerated and…
bobtista Jul 30, 2026
1da85e5
docs(launching): correct the engine-behaviour claim behind archive-ro…
bobtista Jul 30, 2026
69460ea
test(launching): validate the fixture override and recognise versione…
bobtista Jul 30, 2026
3eccb33
fix(launching): match retail archives case-insensitively when validat…
bobtista Jul 30, 2026
cf056e7
fix(launching): ground archive-root failure messages in the verified …
bobtista Jul 30, 2026
d1adeeb
chore(launching): reference launch constants in tests and fix stale n…
bobtista Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions GenHub/GenHub.Core/Constants/RetailArchiveConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace GenHub.Core.Constants;

/// <summary>
/// The contract between GenHub and a non-Windows engine build for locating retail archives.
/// </summary>
/// <remarks>
/// A non-Windows engine reads <c>InstallPath</c> through <c>GetStringFromRegistry</c>, which
/// on these platforms consults these variables before anything else, then mounts
/// <c>*.big</c> 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.
/// <para>
/// 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.
/// </para>
/// </remarks>
public static class RetailArchiveConstants
{
/// <summary>Environment variable naming the Zero Hour retail directory.</summary>
public const string ZeroHourInstallPathVariable = "CNC_ZH_INSTALLPATH";

/// <summary>Environment variable naming the Generals retail directory.</summary>
public const string GeneralsInstallPathVariable = "CNC_GENERALS_INSTALLPATH";

/// <summary>
/// Search pattern for the archives the engine mounts from a retail root.
/// </summary>
/// <remarks>
/// Used as an existence sentinel rather than matching a specific filename, which varies
/// by localisation and version.
/// </remarks>
public const string ArchiveSearchPattern = "*.big";

/// <summary>
/// Every retail archive root variable, Zero Hour first.
/// </summary>
public static readonly string[] InstallPathVariables =
[
ZeroHourInstallPathVariable,
GeneralsInstallPathVariable,
];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using System.Reflection;
using GenHub.Features.GameProfiles.Infrastructure;
using Xunit;

namespace GenHub.Tests.Core.Features.GameProfiles;

/// <summary>
/// Tests for the bounded stderr capture used to explain a failed launch.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public class BoundedErrorBufferTests
{
private static readonly Type BufferType =
typeof(GameProcessManager).GetNestedType("BoundedErrorBuffer", BindingFlags.NonPublic)!;

/// <summary>
/// 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.
/// </summary>
[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);
}

/// <summary>
/// A single pathological line must not be retained in full.
/// </summary>
[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.");
}

/// <summary>
/// A null line is the framework's end-of-stream signal, not content.
/// </summary>
[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());
}

/// <summary>
/// Total retained output stays bounded regardless of how much arrives.
/// </summary>
[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)!;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System.Runtime.InteropServices;

namespace GenHub.Tests.Core.Features.GameProfiles;

/// <summary>
/// Locates the local native client these integration tests launch.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public static class NativeClientFixture
{
/// <summary>Environment variable overriding the discovered directory.</summary>
public const string EnvironmentOverride = "GENHUB_NATIVE_CLIENT_DIR";

/// <summary>The engine executable's filename.</summary>
public const string BinaryName = "generalszh";

/// <summary>
/// Gets the native client directory, or <c>null</c> when these tests should skip.
/// </summary>
public static string? Directory
{
get
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return null;
}

var configured = Environment.GetEnvironmentVariable(EnvironmentOverride);
if (!string.IsNullOrWhiteSpace(configured))
{
return System.IO.Directory.Exists(configured) ? configured : null;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var defaultDirectory = Path.Combine(home, "TheSuperHackers", "GeneralsZH");

return File.Exists(Path.Combine(defaultDirectory, BinaryName)) ? defaultDirectory : null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Xunit;

using System.Runtime.InteropServices;

namespace GenHub.Tests.Core.Features.GameProfiles;

/// <summary>
/// Serialises the tests that launch a real native game client.
/// <para>
/// 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.
/// </para>
/// </summary>
[CollectionDefinition(Name, DisableParallelization = true)]
public class NativeClientLaunchCollection
{
/// <summary>The xUnit collection name.</summary>
public const string Name = "Native client launch";
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// End-to-end launch of a real native Zero Hour client through GenHub's own process
/// manager, rather than a stand-in script.
/// <para>
/// 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.
/// </para>
/// <para>
/// Skipped unless a native install is present, so CI and other machines stay green.
/// Point <c>GENHUB_NATIVE_CLIENT_DIR</c> at an install directory to run it; the default
/// is the deploy script's own default location.
/// </para>
/// </summary>
[Collection(NativeClientLaunchCollection.Name)]
public class NativeClientLaunchIntegrationTests
{
/// <summary>How long the engine must stay up to count as a successful launch.</summary>
private static readonly TimeSpan LaunchSettleTime = TimeSpan.FromSeconds(12);

private readonly GameProcessManager _processManager = new(NullLogger<GameProcessManager>.Instance);

/// <summary>
/// Launches the engine with the install directory as the working directory, exactly
/// as a workspace launch would, and requires it to survive startup.
/// <para>
/// Windowed mode is requested so a test run cannot take over the display.
/// </para>
/// </summary>
/// <returns>A task representing the asynchronous test.</returns>
[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);
}
}

/// <summary>
/// The working directory is load-bearing, not cosmetic. The engine discovers its
/// <c>.big</c> 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.
/// <para>
/// This is why <c>WorkspaceStrategyBase</c> sets <c>WorkingDirectory</c> to the
/// workspace root, and why a native client cannot simply be launched in place.
/// </para>
/// </summary>
/// <returns>A task representing the asynchronous test.</returns>
[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.
}
}
}
}
Loading
Loading