Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 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
66 changes: 66 additions & 0 deletions GenHub/GenHub.Core/Constants/RetailArchiveConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using System.IO;

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>
/// How <see cref="ArchiveSearchPattern"/> is matched within a retail root.
/// </summary>
/// <remarks>
/// 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. <c>INIZH.BIG</c> 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.
/// <para>
/// <see cref="EnumerationOptions.IgnoreInaccessible"/> is set back to <c>false</c> because
/// it defaults to <c>true</c> here, unlike the <see cref="SearchOption"/> overload. Left at
/// the default it turns an unreadable root into "no archives found", reporting a permission
/// problem as missing content.
/// </para>
/// </remarks>
public static readonly EnumerationOptions ArchiveSearch = new()
{
MatchCasing = MatchCasing.CaseInsensitive,
RecurseSubdirectories = false,
IgnoreInaccessible = false,
};

/// <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,74 @@
using System.Linq;
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))
{
// 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;
}
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;
}
}

/// <summary>
/// Determines whether a filename is one of the engine's dynamic libraries.
/// </summary>
/// <remarks>
/// Covers macOS <c>.dylib</c> and both Linux forms: unversioned <c>.so</c> and versioned
/// <c>.so.0</c> / <c>.so.0.1.0</c>, which are the common shape of a shipped Linux library
/// and which a plain <c>.so</c> suffix test misses.
/// </remarks>
/// <param name="fileName">The filename to test.</param>
/// <returns><c>true</c> when the file is a dynamic library.</returns>
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 == '.');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using GenHub.Tests.Core.Features.GameProfiles;
using Xunit;

namespace GenHub.Tests.Core.Features.GameProfiles;

/// <summary>
/// Tests for the shared native-client fixture helper.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public class NativeClientFixtureTests
{
/// <summary>
/// Recognises macOS and both Linux shapes, unversioned and versioned.
/// </summary>
/// <param name="fileName">The candidate filename.</param>
[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));

/// <summary>
/// Rejects the engine binary, data files, and names that merely contain ".so".
/// </summary>
/// <param name="fileName">The candidate filename.</param>
[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));
}
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";
}
Loading
Loading