-
Notifications
You must be signed in to change notification settings - Fork 20
feat(launching): launch the native BGFX client against retail archives #332
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 d0bbc6b
test(launching): verify the real native client launches through GenHub
bobtista 7083aa2
test(launching): serialize real-client launches around the engine ins…
bobtista ad273e7
feat(launching): reach retail archives via install-path environment i…
bobtista 602fd2f
fix(launching): stop setting DYLD_LIBRARY_PATH and LD_LIBRARY_PATH
bobtista caf278a
feat(launching): validate retail archive roots before spawning the en…
bobtista b41e5ee
fix(launching): capture complete stderr when a launch fails
bobtista 58429d5
chore: add stderr capture regression test and clear stack build warnings
bobtista 8120e5e
chore(workspace): order static helper before instance members
bobtista 3e40fa8
chore(workspace): remove trailing class separator
bobtista 461dcdb
chore: clear native launch style warnings
bobtista 08d5bac
chore(workspace): keep member order valid after the executable materi…
bobtista bdb0b19
fix(launching): scope archive-root validation to non-Windows and norm…
bobtista 5ac6b3a
test(launching): scope the archive-root rejection tests to non-Windows
bobtista 00a2bc6
fix(launching): scope archive validation to the launching game and re…
bobtista aeaa1c0
fix(launching): validate the base Generals root when launching Zero Hour
bobtista 7e6f8ce
docs(launching): correct why an absent Generals root is tolerated and…
bobtista 1da85e5
docs(launching): correct the engine-behaviour claim behind archive-ro…
bobtista 69460ea
test(launching): validate the fixture override and recognise versione…
bobtista 3eccb33
fix(launching): match retail archives case-insensitively when validat…
bobtista cf056e7
fix(launching): ground archive-root failure messages in the verified …
bobtista d1adeeb
chore(launching): reference launch constants in tests and fix stale n…
bobtista File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ]; | ||
| } |
104 changes: 104 additions & 0 deletions
104
GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/BoundedErrorBufferTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)!; | ||
| } |
45 changes: 45 additions & 0 deletions
45
GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientFixture.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
|
|
||
| var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); | ||
| var defaultDirectory = Path.Combine(home, "TheSuperHackers", "GeneralsZH"); | ||
|
|
||
| return File.Exists(Path.Combine(defaultDirectory, BinaryName)) ? defaultDirectory : null; | ||
| } | ||
| } | ||
| } | ||
21 changes: 21 additions & 0 deletions
21
GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientLaunchCollection.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"; | ||
| } |
152 changes: 152 additions & 0 deletions
152
...enHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientLaunchIntegrationTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
| } | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.