diff --git a/GenHub/GenHub.Core/Constants/GameClientConstants.cs b/GenHub/GenHub.Core/Constants/GameClientConstants.cs
index 4260feb43..74822f923 100644
--- a/GenHub/GenHub.Core/Constants/GameClientConstants.cs
+++ b/GenHub/GenHub.Core/Constants/GameClientConstants.cs
@@ -61,6 +61,12 @@ public static class GameClientConstants
/// Standard retail Zero Hour directory name.
public const string ZeroHourRetailDirectoryName = "Command & Conquer Generals Zero Hour";
+ /// Parent directory of the native engine's default deploy tree, under the user's home.
+ public const string NativeDeployParentDirectoryName = "TheSuperHackers";
+
+ /// Directory name of the native engine's default Zero Hour deploy tree.
+ public const string NativeDeployZeroHourDirectoryName = "GeneralsZH";
+
// ===== GeneralsOnline Client Detection =====
/// GeneralsOnline 60Hz client executable name.
diff --git a/GenHub/GenHub.Core/Constants/RetailArchiveConstants.cs b/GenHub/GenHub.Core/Constants/RetailArchiveConstants.cs
index 0b9bbf11c..7165391ff 100644
--- a/GenHub/GenHub.Core/Constants/RetailArchiveConstants.cs
+++ b/GenHub/GenHub.Core/Constants/RetailArchiveConstants.cs
@@ -1,3 +1,5 @@
+using System;
+using System.Collections.Generic;
using System.IO;
namespace GenHub.Core.Constants;
@@ -33,6 +35,19 @@ public static class RetailArchiveConstants
///
public const string ArchiveSearchPattern = "*.big";
+ ///
+ /// Filename suffix that marks an archive as Zero Hour content.
+ ///
+ ///
+ /// A retail fact, not an engine one: a retail Zero Hour installation ships its
+ /// archives with this suffix (INIZH.big, AudioZH.big, …), verifiable
+ /// against a real installation, so any such archive marks Zero Hour data. Deliberately
+ /// not derived from any engine build's loading code, so the rule stays valid for a
+ /// stock retail install with no community client. Compare case-insensitively — retail
+ /// data copied from a disc or a Windows machine is frequently upper-cased.
+ ///
+ public const string ZeroHourArchiveSuffix = "zh.big";
+
///
/// How is matched within a retail root.
///
@@ -63,4 +78,34 @@ public static class RetailArchiveConstants
ZeroHourInstallPathVariable,
GeneralsInstallPathVariable,
];
+
+ ///
+ /// The canonical archive filenames of a retail Generals installation.
+ ///
+ ///
+ /// A retail fact: these are the archives present in a retail Generals installation,
+ /// verifiable against a real one. Deliberately not derived from any engine build's
+ /// loading code, so the set stays valid for a stock retail install with no community
+ /// client. Any one of them marks a directory as holding Generals data — localised SKUs
+ /// vary in which language archives they carry, so requiring the full set would reject
+ /// valid installs. The comparer is case-insensitive for the same reason as
+ /// .
+ ///
+ public static readonly IReadOnlySet GeneralsArchiveNames = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "audio.big",
+ "audioenglish.big",
+ "english.big",
+ "gensec.big",
+ "ini.big",
+ "maps.big",
+ "music.big",
+ "shaders.big",
+ "speech.big",
+ "speechenglish.big",
+ "terrain.big",
+ "textures.big",
+ "w3d.big",
+ "window.big",
+ };
}
diff --git a/GenHub/GenHub.Core/Helpers/RetailArchiveClassifier.cs b/GenHub/GenHub.Core/Helpers/RetailArchiveClassifier.cs
new file mode 100644
index 000000000..f71d4e675
--- /dev/null
+++ b/GenHub/GenHub.Core/Helpers/RetailArchiveClassifier.cs
@@ -0,0 +1,76 @@
+using System;
+using System.IO;
+using GenHub.Core.Constants;
+using GenHub.Core.Models.GameInstallations;
+
+namespace GenHub.Core.Helpers;
+
+///
+/// Classifies a directory by the retail game archives it holds.
+///
+///
+/// This is the detection-side predicate on top of the
+/// vocabulary: it decides which games' retail data a directory carries. It is
+/// deliberately separate from the launch-side any-archive check
+/// (GameLauncher.ValidateRetailArchiveRoots), which validates a root that has
+/// already been chosen and stays game-agnostic so it cannot reject a valid root over a
+/// localisation difference. Both build on
+/// so every call site matches archive files identically, case-insensitivity included.
+///
+public static class RetailArchiveClassifier
+{
+ ///
+ /// Determines which games' retail archives are present in .
+ ///
+ /// The directory to classify.
+ ///
+ /// The classification; an absent or null directory classifies as holding neither game.
+ /// A directory holding only unrecognised archives (mods, hotkey packs, control bars)
+ /// also classifies as neither — an arbitrary .big proves nothing about retail
+ /// data, which is exactly why the executable-name proxy this replaces was retired.
+ ///
+ ///
+ /// Only the directory root is examined, never subdirectories: Data/INI/INIZH.big
+ /// is a duplicate shipped in the English, Chinese and Korean SKUs and must not be
+ /// counted twice. Filesystem errors (an unreadable directory, an I/O failure) propagate
+ /// to the caller rather than reading as "no archives" — converting a permission problem
+ /// into missing content is the failure mode
+ /// exists to prevent.
+ ///
+ public static RetailArchiveClassification ClassifyArchives(string? directory)
+ {
+ if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory))
+ {
+ return default;
+ }
+
+ var hasGenerals = false;
+ var hasZeroHour = false;
+
+ foreach (var archivePath in Directory.EnumerateFiles(
+ directory,
+ RetailArchiveConstants.ArchiveSearchPattern,
+ RetailArchiveConstants.ArchiveSearch))
+ {
+ var archiveName = Path.GetFileName(archivePath);
+
+ if (!hasZeroHour &&
+ archiveName.EndsWith(RetailArchiveConstants.ZeroHourArchiveSuffix, StringComparison.OrdinalIgnoreCase))
+ {
+ hasZeroHour = true;
+ }
+
+ if (!hasGenerals && RetailArchiveConstants.GeneralsArchiveNames.Contains(archiveName))
+ {
+ hasGenerals = true;
+ }
+
+ if (hasGenerals && hasZeroHour)
+ {
+ break;
+ }
+ }
+
+ return new RetailArchiveClassification(hasGenerals, hasZeroHour);
+ }
+}
diff --git a/GenHub/GenHub.Core/Interfaces/Manifest/IManifestProvider.cs b/GenHub/GenHub.Core/Interfaces/Manifest/IManifestProvider.cs
index 2e42b5ca1..db38e7dea 100644
--- a/GenHub/GenHub.Core/Interfaces/Manifest/IManifestProvider.cs
+++ b/GenHub/GenHub.Core/Interfaces/Manifest/IManifestProvider.cs
@@ -1,3 +1,4 @@
+using GenHub.Core.Models.Enums;
using GenHub.Core.Models.GameClients;
using GenHub.Core.Models.GameInstallations;
using GenHub.Core.Models.Manifest;
@@ -23,5 +24,20 @@ public interface IManifestProvider
/// The game installation for which to retrieve the manifest.
/// A cancellation token.
/// The ContentManifest, or null if not found.
+ ///
+ /// Surfaces one manifest only, preferring Zero Hour when both games are flagged.
+ /// For a combined installation carrying both games, use
+ ///
+ /// per game so Generals is not skipped.
+ ///
Task GetManifestAsync(GameInstallation gameInstallation, CancellationToken cancellationToken = default);
+
+ ///
+ /// Asynchronously retrieves the manifest for one game of a game installation.
+ ///
+ /// The game installation for which to retrieve the manifest.
+ /// The game whose manifest is requested.
+ /// A cancellation token.
+ /// The ContentManifest, or null if not found.
+ Task GetManifestAsync(GameInstallation gameInstallation, GameType gameType, CancellationToken cancellationToken = default);
}
\ No newline at end of file
diff --git a/GenHub/GenHub.Core/Models/GameInstallations/GameInstallation.cs b/GenHub/GenHub.Core/Models/GameInstallations/GameInstallation.cs
index d697bc3d1..8732882a4 100644
--- a/GenHub/GenHub.Core/Models/GameInstallations/GameInstallation.cs
+++ b/GenHub/GenHub.Core/Models/GameInstallations/GameInstallation.cs
@@ -1,5 +1,5 @@
using GenHub.Core.Constants;
-using GenHub.Core.Extensions.GameInstallations;
+using GenHub.Core.Helpers;
using GenHub.Core.Interfaces.GameInstallations;
using GenHub.Core.Models.Enums;
using GenHub.Core.Models.GameClients;
@@ -105,17 +105,25 @@ public GameInstallation(
///
/// The path to Generals, or null if not present.
/// The path to Zero Hour, or null if not present.
+ ///
+ /// Each game's flag turns on when that game's retail archives are present in its
+ /// directory, not when an executable with a known name is. The executable name was
+ /// only ever a proxy for "the archives are here" — it rejects the canonical native
+ /// deploy, whose binary is extensionless — while the archives are the direct signal
+ /// and the thing a retail root actually has to supply. A combined directory carrying
+ /// both games' archives may legitimately be passed as both paths and sets both flags.
+ ///
public void SetPaths(string? generalsPath, string? zeroHourPath)
{
if (!string.IsNullOrEmpty(generalsPath))
{
- HasGenerals = Directory.Exists(generalsPath) && HasValidExecutable(generalsPath);
+ HasGenerals = ClassifyArchivesSafely(generalsPath).HasGeneralsArchives;
GeneralsPath = generalsPath;
}
if (!string.IsNullOrEmpty(zeroHourPath))
{
- HasZeroHour = Directory.Exists(zeroHourPath) && HasValidExecutable(zeroHourPath);
+ HasZeroHour = ClassifyArchivesSafely(zeroHourPath).HasZeroHourArchives;
ZeroHourPath = zeroHourPath;
}
@@ -139,9 +147,9 @@ public void PopulateGameClients(IEnumerable clients)
}
///
- /// Initializes the installation by scanning for game directories and executables.
- /// This method performs automatic detection of Generals and Zero Hour installations
- /// within the installation path using standard directory naming conventions.
+ /// Initializes the installation by scanning for each game's retail archives.
+ /// Standard subdirectories are checked first, then the installation root itself,
+ /// which covers flat manual installs and combined directories holding both games.
///
///
/// This method is primarily used for testing and initialization purposes.
@@ -158,97 +166,51 @@ public void Fetch()
bool foundZeroHour = false;
// 1. Check strict subdirectories first (standard structure)
- var generalsPath = Path.Combine(InstallationPath, "Command and Conquer Generals");
- if (Directory.Exists(generalsPath))
+ var generalsPath = Path.Combine(InstallationPath, GameClientConstants.GeneralsDirectoryName);
+ if (RetailArchiveClassifier.ClassifyArchives(generalsPath).HasGeneralsArchives)
{
- var generalsExe = Path.Combine(generalsPath, GameClientConstants.GeneralsExecutable);
- if (generalsExe.FileExistsCaseInsensitive())
- {
- HasGenerals = true;
- GeneralsPath = generalsPath;
- foundGenerals = true;
- _logger?.LogDebug("Found Generals installation at {GeneralsPath}", GeneralsPath);
- }
+ HasGenerals = true;
+ GeneralsPath = generalsPath;
+ foundGenerals = true;
+ _logger?.LogDebug("Found Generals installation at {GeneralsPath}", GeneralsPath);
}
var zeroHourPath = Path.Combine(InstallationPath, GameClientConstants.ZeroHourDirectoryName);
- if (Directory.Exists(zeroHourPath))
+ if (RetailArchiveClassifier.ClassifyArchives(zeroHourPath).HasZeroHourArchives)
{
- var zeroHourExe = Path.Combine(zeroHourPath, GameClientConstants.ZeroHourExecutable);
- if (zeroHourExe.FileExistsCaseInsensitive())
- {
- HasZeroHour = true;
- ZeroHourPath = zeroHourPath;
- foundZeroHour = true;
- _logger?.LogDebug("Found Zero Hour installation at {ZeroHourPath}", ZeroHourPath);
- }
+ HasZeroHour = true;
+ ZeroHourPath = zeroHourPath;
+ foundZeroHour = true;
+ _logger?.LogDebug("Found Zero Hour installation at {ZeroHourPath}", ZeroHourPath);
}
- // 2. If not found in subdirectories, check the root path (common for manual installs/repacks)
- if (!foundGenerals)
+ // 2. If not found in subdirectories, check the root path itself. Archive
+ // classification tells the games apart even in one flat directory, so a
+ // combined root legitimately sets both flags to the same path — the earlier
+ // executable-based scan had to guess here, because both games ship the same
+ // executable name.
+ var rootClassification = RetailArchiveClassifier.ClassifyArchives(InstallationPath);
+
+ if (!foundGenerals && rootClassification.HasGeneralsArchives)
{
- var rootGeneralsExe = Path.Combine(InstallationPath, GameClientConstants.GeneralsExecutable);
-
- // Note: Zero Hour also has a generals.exe, so we need to be careful.
- // If checking for valid installation, presence of generals.exe usually implies Generals capability.
- if (rootGeneralsExe.FileExistsCaseInsensitive())
- {
- HasGenerals = true;
- GeneralsPath = InstallationPath;
- foundGenerals = true;
- _logger?.LogDebug("Found Generals installation at root {GeneralsPath}", GeneralsPath);
- }
+ HasGenerals = true;
+ GeneralsPath = InstallationPath;
+ foundGenerals = true;
+ _logger?.LogDebug("Found Generals installation at root {GeneralsPath}", GeneralsPath);
}
- if (!foundZeroHour && string.IsNullOrEmpty(ZeroHourPath))
+ if (!foundZeroHour && rootClassification.HasZeroHourArchives)
{
- // Zero Hour usually has generals.exe AND specific files like "generals.zh.exe" (sometimes) or just "generals.exe" with different hash/version.
- // Detection primarily relies on folder name or presence of expansion files.
- // Checking for generals.exe in root can map to both if the user selected a merged directory.
- var rootGeneralsExe = Path.Combine(InstallationPath, GameClientConstants.GeneralsExecutable);
-
- if (rootGeneralsExe.FileExistsCaseInsensitive())
- {
- // Check if Generals is already set to this path to avoid duplicate detection
- // This prevents setting both GeneralsPath and ZeroHourPath to the same directory
- // when platform-specific detectors (Steam/EA/etc) have already identified Generals here
- bool isGeneralsAlreadySetToRoot =
- !string.IsNullOrEmpty(GeneralsPath) &&
- Path.GetFullPath(GeneralsPath).Equals(
- Path.GetFullPath(InstallationPath),
- StringComparison.OrdinalIgnoreCase);
-
- if (!isGeneralsAlreadySetToRoot)
- {
- // If we are in root and found generals.exe, it could be ZH.
- // Check for something specific to ZH if possible, or just assume if user pointed here it might be combined.
- // For safety, let's treat root install as potentially containing both if we can't distinguish.
-
- // Ideally we check for a ZH specific file, but standard detection often just looks for exe.
- // Let's assume if the user pointed us here and it has the exe, it's valid.
- // Standard Retail ZH has "generals.exe" but also usually lives in its own folder.
- // If user pointed to "C:\Games\ZH", it has generals.exe.
- HasZeroHour = true;
- ZeroHourPath = InstallationPath;
- foundZeroHour = true;
- _logger?.LogDebug("Found Zero Hour installation at root {ZeroHourPath}", ZeroHourPath);
- }
- else
- {
- _logger?.LogDebug(
- "Skipping Zero Hour detection at root {InstallationPath} - Generals already detected here",
- InstallationPath);
- }
- }
+ HasZeroHour = true;
+ ZeroHourPath = InstallationPath;
+ foundZeroHour = true;
+ _logger?.LogDebug("Found Zero Hour installation at root {ZeroHourPath}", ZeroHourPath);
}
- // Logic improvement: If we found generals.exe in root, we might have set BOTH to true/root.
- // This is acceptable for some "All in One" repacks or if the user manually merged them.
-
// Log warnings only if absolutely nothing found
if (!foundGenerals && !foundZeroHour)
{
- _logger?.LogWarning("No game executables found in {InstallationPath} or standard subdirectories", InstallationPath);
+ _logger?.LogWarning("No retail game archives found in {InstallationPath} or standard subdirectories", InstallationPath);
}
_logger?.LogInformation(
@@ -283,9 +245,26 @@ public override int GetHashCode()
return Id?.GetHashCode() ?? 0;
}
- private static bool HasValidExecutable(string path)
+ ///
+ /// Classifies a directory's archives without letting a filesystem error escape.
+ ///
+ /// The directory to classify.
+ /// The classification, or neither game when the directory cannot be read.
+ ///
+ /// is called from every platform detector, so it must not
+ /// throw. An unreadable directory is logged rather than silently reading as "no
+ /// archives" — the flag still ends up false, but the log names the real cause.
+ ///
+ private RetailArchiveClassification ClassifyArchivesSafely(string path)
{
- var possibleExes = new[] { GameClientConstants.SteamGameDatExecutable, GameClientConstants.GeneralsExecutable, GameClientConstants.ZeroHourExecutable };
- return possibleExes.Any(exe => Path.Combine(path, exe).FileExistsCaseInsensitive());
+ try
+ {
+ return RetailArchiveClassifier.ClassifyArchives(path);
+ }
+ catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
+ {
+ _logger?.LogWarning(ex, "Could not read {Path} while classifying retail archives; treating it as holding none", path);
+ return default;
+ }
}
}
\ No newline at end of file
diff --git a/GenHub/GenHub.Core/Models/GameInstallations/RetailArchiveClassification.cs b/GenHub/GenHub.Core/Models/GameInstallations/RetailArchiveClassification.cs
new file mode 100644
index 000000000..24757c39e
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/GameInstallations/RetailArchiveClassification.cs
@@ -0,0 +1,19 @@
+namespace GenHub.Core.Models.GameInstallations;
+
+///
+/// Which games' retail archives a directory holds.
+///
+/// Whether any canonical Generals archive is present.
+/// Whether any Zero Hour archive is present.
+///
+/// Both flags true is a real state, not a conflict: a combined directory (for example a
+/// flat native deploy holding INI.big and INIZH.big side by side) carries
+/// retail data for both games and must be treated as one installation with both paths set.
+///
+public readonly record struct RetailArchiveClassification(bool HasGeneralsArchives, bool HasZeroHourArchives)
+{
+ ///
+ /// Gets a value indicating whether either game's archives are present.
+ ///
+ public bool HasAnyGame => HasGeneralsArchives || HasZeroHourArchives;
+}
diff --git a/GenHub/GenHub.MacOS/GameInstallations/MacOSInstallationDetector.cs b/GenHub/GenHub.MacOS/GameInstallations/MacOSInstallationDetector.cs
index c9a06a897..b15118696 100644
--- a/GenHub/GenHub.MacOS/GameInstallations/MacOSInstallationDetector.cs
+++ b/GenHub/GenHub.MacOS/GameInstallations/MacOSInstallationDetector.cs
@@ -7,7 +7,7 @@
using System.Threading;
using System.Threading.Tasks;
using GenHub.Core.Constants;
-using GenHub.Core.Extensions.GameInstallations;
+using GenHub.Core.Helpers;
using GenHub.Core.Interfaces.GameInstallations;
using GenHub.Core.Models.Enums;
using GenHub.Core.Models.GameInstallations;
@@ -80,29 +80,18 @@ public Task> DetectInstallationsAsync(Cancella
{
cancellationToken.ThrowIfCancellationRequested();
- var (generalsPath, zeroHourPath, accessDenied) = FindGameDirectories(root);
+ var (installation, accessDenied) = InspectRoot(root);
if (accessDenied)
{
deniedRoots.Add(root);
}
- if (generalsPath is null && zeroHourPath is null)
+ if (installation is null)
{
continue;
}
- var installation = new GameInstallation(root, GameInstallationType.Retail, null);
- installation.SetPaths(generalsPath, zeroHourPath);
-
- // SetPaths only sets Has* when a valid executable is present, so a
- // directory that merely has the right name is discarded here.
- if (!installation.HasGenerals && !installation.HasZeroHour)
- {
- logger.LogDebug("Directory under {Root} matched by name but has no game executable", root);
- continue;
- }
-
installs.Add(installation);
logger.LogInformation(
"Detected retail installation under {Root}: Generals={HasGenerals}, ZeroHour={HasZeroHour}",
@@ -166,6 +155,70 @@ internal static DetectionResult CreateDetectionResult(
return DetectionResult.CreateSuccess(installs, elapsed);
}
+ ///
+ /// Inspects one candidate root: first the directory itself, then name-matched children.
+ ///
+ /// Directory to inspect.
+ ///
+ /// The installation found under , or null, and whether access
+ /// was denied.
+ ///
+ ///
+ /// The candidate directory is tested by archive classification before its children
+ /// are, because retail data can be the directory itself: the native engine's deploy
+ /// is one flat tree whose name matches nothing. Child matching by name remains for
+ /// copied retail trees that do keep their Windows directory names. In both cases
+ /// makes the final call from the archives
+ /// present, so a directory that merely has the right name is discarded here.
+ ///
+ internal static (GameInstallation? Installation, bool AccessDenied) InspectRoot(string root)
+ {
+ string? generalsPath;
+ string? zeroHourPath;
+
+ try
+ {
+ var rootClassification = RetailArchiveClassifier.ClassifyArchives(root);
+ if (rootClassification.HasAnyGame)
+ {
+ // A combined flat directory sets both paths to the same root.
+ generalsPath = rootClassification.HasGeneralsArchives ? root : null;
+ zeroHourPath = rootClassification.HasZeroHourArchives ? root : null;
+ }
+ else
+ {
+ (generalsPath, zeroHourPath) = FindGameDirectories(root);
+ }
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // Reported separately: on macOS this is how a declined TCC prompt surfaces for
+ // a protected location such as ~/Documents. Treating it as "nothing here"
+ // would tell the user they own no games when we were simply not allowed to look.
+ return (null, true);
+ }
+ catch (Exception)
+ {
+ // A vanished directory is not a detection failure.
+ return (null, false);
+ }
+
+ if (generalsPath is null && zeroHourPath is null)
+ {
+ return (null, false);
+ }
+
+ var installation = new GameInstallation(root, GameInstallationType.Retail, null);
+ installation.SetPaths(generalsPath, zeroHourPath);
+
+ if (!installation.HasGenerals && !installation.HasZeroHour)
+ {
+ return (null, false);
+ }
+
+ return (installation, false);
+ }
+
///
/// Builds the list of directories worth scanning for a copied retail tree.
///
@@ -178,6 +231,15 @@ private static IEnumerable GetSearchRoots()
yield break;
}
+ // The native engine's macOS deploy produces a flat tree here by default: engine
+ // binary, bundled dylibs, source-controlled data directories, and the user's own
+ // retail archives merged into one directory. It is not a name-matched child of
+ // anything, so it must be a candidate root in its own right.
+ yield return Path.Combine(
+ home,
+ GameClientConstants.NativeDeployParentDirectoryName,
+ GameClientConstants.NativeDeployZeroHourDirectoryName);
+
var documents = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
if (!string.IsNullOrEmpty(documents))
{
@@ -257,51 +319,36 @@ private static IEnumerable GetBottleDriveCPaths(string home, string appl
///
/// Directory to search within.
///
- /// The matching paths and whether access was denied. Matching is case-insensitive
- /// because macOS volumes can be case-sensitive while retail trees are Windows-cased.
+ /// The matching paths. Matching is case-insensitive because macOS volumes can be
+ /// case-sensitive while retail trees are Windows-cased. Filesystem errors propagate
+ /// to the caller, which distinguishes denied access from a vanished directory.
///
- private static (string? GeneralsPath, string? ZeroHourPath, bool AccessDenied)
- FindGameDirectories(string root)
+ private static (string? GeneralsPath, string? ZeroHourPath) FindGameDirectories(string root)
{
- try
- {
- string? generalsPath = null;
- string? zeroHourPath = null;
+ string? generalsPath = null;
+ string? zeroHourPath = null;
- foreach (var directory in Directory.EnumerateDirectories(root))
+ foreach (var directory in Directory.EnumerateDirectories(root))
+ {
+ var directoryName = Path.GetFileName(directory);
+ if (generalsPath is null &&
+ GeneralsDirectoryNames.Contains(directoryName, StringComparer.OrdinalIgnoreCase))
{
- var directoryName = Path.GetFileName(directory);
- if (generalsPath is null &&
- GeneralsDirectoryNames.Contains(directoryName, StringComparer.OrdinalIgnoreCase))
- {
- generalsPath = directory;
- }
-
- if (zeroHourPath is null &&
- ZeroHourDirectoryNames.Contains(directoryName, StringComparer.OrdinalIgnoreCase))
- {
- zeroHourPath = directory;
- }
+ generalsPath = directory;
+ }
- if (generalsPath is not null && zeroHourPath is not null)
- {
- break;
- }
+ if (zeroHourPath is null &&
+ ZeroHourDirectoryNames.Contains(directoryName, StringComparer.OrdinalIgnoreCase))
+ {
+ zeroHourPath = directory;
}
- return (generalsPath, zeroHourPath, false);
- }
- catch (UnauthorizedAccessException)
- {
- // Reported separately: on macOS this is how a declined TCC prompt surfaces for
- // a protected location such as ~/Documents. Treating it as "nothing here"
- // would tell the user they own no games when we were simply not allowed to look.
- return (null, null, true);
- }
- catch (Exception)
- {
- // A vanished directory is not a detection failure.
- return (null, null, false);
+ if (generalsPath is not null && zeroHourPath is not null)
+ {
+ break;
+ }
}
+
+ return (generalsPath, zeroHourPath);
}
}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientDetectorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientDetectorTests.cs
index 90f9aa908..fcdb7d3a9 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientDetectorTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientDetectorTests.cs
@@ -617,6 +617,89 @@ public async Task DetectGameClientsFromInstallationsAsync_WithMissingGeneralsOnl
// Verify CreateGeneralsOnlineClientManifestAsync was NOT called (no GeneralsOnline files)
}
+ ///
+ /// A combined directory — both games flagged at the same path — holds one executable
+ /// set and must yield one standard client (Zero Hour), not a duplicate Generals
+ /// client wrapping the same executable. An extensionless native binary sitting
+ /// alongside generals.exe must reach publisher identification: the earlier *.exe
+ /// glob hid it from the scan entirely.
+ ///
+ /// A representing the asynchronous test operation.
+ [Fact]
+ public async Task DetectGameClientsFromInstallationsAsync_WithCombinedDirectory_YieldsSingleStandardClientAndSeesNativeBinary()
+ {
+ // Arrange
+ var combinedPath = Path.Combine(_tempDirectory, "Combined");
+ Directory.CreateDirectory(combinedPath);
+ var executablePath = Path.Combine(combinedPath, "generals.exe");
+ await File.WriteAllTextAsync(executablePath, "dummy content");
+
+ // An extensionless native client binary, the shape a Mach-O or ELF build has.
+ // Real ELF magic, because selection classifies extensionless files by content.
+ var nativeBinaryPath = Path.Combine(combinedPath, "GeneralsZH");
+ await File.WriteAllBytesAsync(nativeBinaryPath, [0x7F, 0x45, 0x4C, 0x46, 0x02, 0x01, 0x01, 0x00]);
+
+ var nativeIdentifierMock = new Mock();
+ nativeIdentifierMock.Setup(x => x.PublisherId).Returns("TestNativePublisher");
+ nativeIdentifierMock.Setup(x => x.CanIdentify(It.Is(p => p.EndsWith("GeneralsZH")))).Returns(true);
+ nativeIdentifierMock.Setup(x => x.CanIdentify(It.Is(p => !p.EndsWith("GeneralsZH")))).Returns(false);
+ nativeIdentifierMock.Setup(x => x.Identify(It.IsAny())).Returns(new GameClientIdentification(
+ "TestNativePublisher",
+ "Native",
+ "Native Zero Hour Client",
+ GameType.ZeroHour,
+ GameClientConstants.UnknownVersion));
+
+ var detector = new GameClientDetector(
+ _manifestGenerationServiceMock.Object,
+ _contentManifestPoolMock.Object,
+ _hashProviderMock.Object,
+ _hashRegistryMock.Object,
+ [nativeIdentifierMock.Object],
+ NullLogger.Instance);
+
+ var installation = new GameInstallation("C:\\TestInstall", GameInstallationType.Retail)
+ {
+ HasGenerals = true,
+ GeneralsPath = combinedPath,
+ HasZeroHour = true,
+ ZeroHourPath = combinedPath,
+ };
+
+ List installations = [installation];
+
+ // Setup hash provider to recognise the executable as Zero Hour
+ _hashProviderMock.Setup(x => x.ComputeFileHashAsync(executablePath, It.IsAny()))
+ .ReturnsAsync(GameClientHashRegistry.ZeroHour105HashPublic);
+
+ // Setup manifest generation
+ var manifestBuilderMock = new Mock();
+ var manifest = new ContentManifest { Id = ManifestId.Create("1.105.retail.gameclient.zerohour") };
+ manifestBuilderMock.Setup(x => x.Build()).Returns(manifest);
+
+ _manifestGenerationServiceMock.Setup(x => x.CreateGameClientManifestAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(manifestBuilderMock.Object);
+
+ _contentManifestPoolMock.Setup(x => x.AddManifestAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(true));
+
+ // Act
+ var result = await detector.DetectGameClientsFromInstallationsAsync(installations);
+
+ // Assert
+ Assert.True(result.Success);
+ Assert.Equal(2, result.Items.Count);
+
+ var standardClient = Assert.Single(result.Items, c => string.IsNullOrEmpty(c.PublisherType));
+ Assert.Equal(GameType.ZeroHour, standardClient.GameType);
+ Assert.Equal(executablePath, standardClient.ExecutablePath);
+
+ var nativeClient = Assert.Single(result.Items, c => c.PublisherType == "TestNativePublisher");
+ Assert.Equal(GameType.ZeroHour, nativeClient.GameType);
+ Assert.Equal(nativeBinaryPath, nativeClient.ExecutablePath);
+ }
+
///
public void Dispose()
{
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ManifestProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ManifestProviderTests.cs
index 09bc2a68a..465dac48e 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ManifestProviderTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ManifestProviderTests.cs
@@ -179,8 +179,10 @@ public async Task GetManifestAsync_WithZeroHourInstallation_UsesZeroHourId()
// Arrange
var tempZeroHourPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempZeroHourPath);
- var zeroHourExe = Path.Combine(tempZeroHourPath, "generals.exe");
- File.WriteAllText(zeroHourExe, "dummy");
+
+ // Zero Hour is flagged by its retail archives, not by an executable name.
+ var zeroHourArchive = Path.Combine(tempZeroHourPath, "INIZH.big");
+ File.WriteAllText(zeroHourArchive, "archive");
try
{
var installation = new GameInstallation(
@@ -218,6 +220,49 @@ public async Task GetManifestAsync_WithZeroHourInstallation_UsesZeroHourId()
}
}
+ ///
+ /// The single-manifest overload prefers Zero Hour for a combined installation, so
+ /// Generals can only be reached through the game-typed overload. This asserts that
+ /// overload requests the Generals manifest id for such an installation.
+ ///
+ /// A task representing the asynchronous operation.
+ [Fact]
+ public async Task GetManifestAsync_WithCombinedInstallationAndExplicitGenerals_UsesGeneralsId()
+ {
+ var combinedPath = Directory.CreateTempSubdirectory("GenHub.CombinedManifest.").FullName;
+ File.WriteAllText(Path.Combine(combinedPath, "INI.big"), "archive");
+ File.WriteAllText(Path.Combine(combinedPath, "INIZH.big"), "archive");
+ try
+ {
+ var installation = new GameInstallation(
+ installationPath: combinedPath,
+ installationType: GameInstallationType.Steam,
+ logger: null);
+ installation.SetPaths(combinedPath, combinedPath);
+ Assert.True(installation.HasGenerals);
+ Assert.True(installation.HasZeroHour);
+
+ var expectedManifest = new ContentManifest
+ {
+ Id = ManifestId.Create("1.108.steam.gameinstallation.generals"),
+ Name = "Test Manifest",
+ };
+
+ _poolMock.Setup(x => x.GetManifestAsync(ManifestId.Create("1.108.steam.gameinstallation.generals"), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(expectedManifest));
+
+ var result = await _manifestProvider.GetManifestAsync(installation, GameType.Generals);
+
+ Assert.NotNull(result);
+ Assert.Equal("1.108.steam.gameinstallation.generals", result.Id);
+ _poolMock.Verify(x => x.GetManifestAsync(ManifestId.Create("1.108.steam.gameinstallation.generals"), It.IsAny()), Times.Once);
+ }
+ finally
+ {
+ Directory.Delete(combinedPath, true);
+ }
+ }
+
///
/// Tests that GetManifestAsync returns null when manifest is not found in cache or resources.
///
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs
index 9edda2fed..6c27b855c 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs
@@ -1,11 +1,14 @@
using GenHub.Core.Interfaces.Common;
using GenHub.Core.Interfaces.Content;
using GenHub.Core.Interfaces.Manifest;
+using GenHub.Core.Interfaces.Storage;
+using GenHub.Core.Interfaces.Workspace;
using GenHub.Core.Models.Enums;
using GenHub.Core.Models.GameInstallations;
using GenHub.Core.Models.Manifest;
using GenHub.Core.Models.Results;
using GenHub.Core.Models.Validation;
+using GenHub.Features.Content.Services;
using GenHub.Features.Validation;
using Microsoft.Extensions.Logging;
using Moq;
@@ -390,6 +393,122 @@ public async Task ValidateAsync_ContentValidatorException_HandlesGracefully()
}
}
+ ///
+ /// A combined installation — both games flagged at the same directory — must be
+ /// validated once per game, so a Generals manifest is requested too instead of
+ /// silently never being fetched behind the Zero Hour preference.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task ValidateAsync_CombinedInstallation_ValidatesBothGames()
+ {
+ var tempDir = Directory.CreateTempSubdirectory("GenHub.CombinedValidation.");
+ try
+ {
+ File.WriteAllText(Path.Combine(tempDir.FullName, "INI.big"), "archive");
+ File.WriteAllText(Path.Combine(tempDir.FullName, "INIZH.big"), "archive");
+
+ var installation = new GameInstallation(
+ tempDir.FullName,
+ GameInstallationType.Retail,
+ new Mock>().Object);
+ installation.SetPaths(tempDir.FullName, tempDir.FullName);
+ Assert.True(installation.HasGenerals);
+ Assert.True(installation.HasZeroHour);
+
+ var manifest = new ContentManifest { Files = new List() };
+ _manifestProviderMock
+ .Setup(m => m.GetManifestAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(manifest);
+
+ var result = await _validator.ValidateAsync(installation, null, default);
+
+ Assert.True(result.IsValid);
+ _manifestProviderMock.Verify(
+ m => m.GetManifestAsync(It.IsAny(), GameType.Generals, It.IsAny()),
+ Times.Once);
+ _manifestProviderMock.Verify(
+ m => m.GetManifestAsync(It.IsAny(), GameType.ZeroHour, It.IsAny()),
+ Times.Once);
+ }
+ finally
+ {
+ tempDir.Delete(true);
+ }
+ }
+
+ ///
+ /// Validating a combined directory once per game must not report the sibling game's
+ /// retail root archives as extraneous: each per-game manifest lists only its own
+ /// game's files, yet both games legitimately share the directory. Uses the real
+ /// so the extraneous-file scan actually runs.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task ValidateAsync_CombinedInstallation_DoesNotReportSiblingArchivesAsExtraneous()
+ {
+ var tempDir = Directory.CreateTempSubdirectory("GenHub.CombinedExtraneous.");
+ try
+ {
+ var generalsArchive = Path.Combine(tempDir.FullName, "INI.big");
+ var zeroHourArchive = Path.Combine(tempDir.FullName, "INIZH.big");
+ await File.WriteAllTextAsync(generalsArchive, "archive");
+ await File.WriteAllTextAsync(zeroHourArchive, "archive");
+
+ var installation = new GameInstallation(
+ tempDir.FullName,
+ GameInstallationType.Retail,
+ new Mock>().Object);
+ installation.SetPaths(tempDir.FullName, tempDir.FullName);
+ Assert.True(installation.HasGenerals);
+ Assert.True(installation.HasZeroHour);
+
+ // Each per-game manifest lists only that game's archive, as a canonical
+ // (CAS/embedded) manifest would — the other game's files are not in it.
+ var generalsManifest = new ContentManifest
+ {
+ Id = ManifestId.Create("1.108.retail.gameinstallation.generals"),
+ Name = "Generals",
+ Version = "1.08",
+ Files = new() { new ManifestFile { RelativePath = "INI.big", Hash = string.Empty } },
+ };
+ var zeroHourManifest = new ContentManifest
+ {
+ Id = ManifestId.Create("1.104.retail.gameinstallation.zerohour"),
+ Name = "Zero Hour",
+ Version = "1.04",
+ Files = new() { new ManifestFile { RelativePath = "INIZH.big", Hash = string.Empty } },
+ };
+ _manifestProviderMock
+ .Setup(m => m.GetManifestAsync(It.IsAny(), GameType.Generals, It.IsAny()))
+ .ReturnsAsync(generalsManifest);
+ _manifestProviderMock
+ .Setup(m => m.GetManifestAsync(It.IsAny(), GameType.ZeroHour, It.IsAny()))
+ .ReturnsAsync(zeroHourManifest);
+
+ var realContentValidator = new ContentValidator(
+ new Mock().Object,
+ new Mock().Object,
+ new Mock>().Object);
+ var validator = new GameInstallationValidator(
+ _loggerMock.Object,
+ _manifestProviderMock.Object,
+ realContentValidator,
+ _hashProviderMock.Object);
+
+ var result = await validator.ValidateAsync(installation, null, default);
+
+ // A retail-consistent combined directory must validate clean: no pass may
+ // flag the other pass's root archives.
+ Assert.Empty(result.Issues);
+ Assert.True(result.IsValid);
+ }
+ finally
+ {
+ tempDir.Delete(true);
+ }
+ }
+
///
/// Custom progress implementation that captures reports synchronously.
///
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/RetailArchiveClassifierTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/RetailArchiveClassifierTests.cs
new file mode 100644
index 000000000..790905a7b
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/RetailArchiveClassifierTests.cs
@@ -0,0 +1,177 @@
+using GenHub.Core.Helpers;
+using Xunit;
+
+namespace GenHub.Tests.Core.Helpers;
+
+///
+/// Tests for .
+///
+///
+/// Fixtures use the retail archive filenames a real installation holds: any
+/// *zh.big marks Zero Hour data, any archive from the canonical Generals set
+/// marks Generals data. An arbitrary .big proves neither.
+///
+public class RetailArchiveClassifierTests : IDisposable
+{
+ private readonly string _tempDir;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public RetailArchiveClassifierTests()
+ {
+ _tempDir = Directory.CreateTempSubdirectory("GenHub.ClassifierTests.").FullName;
+ }
+
+ ///
+ /// A directory holding only Zero Hour archives classifies as Zero Hour alone.
+ ///
+ [Fact]
+ public void ClassifyArchives_ZeroHourOnlyDirectory_IsZeroHourOnly()
+ {
+ var dir = CreateDirectoryWithArchives("zh-only", "INIZH.big", "AudioZH.big");
+
+ var classification = RetailArchiveClassifier.ClassifyArchives(dir);
+
+ Assert.True(classification.HasZeroHourArchives);
+ Assert.False(classification.HasGeneralsArchives);
+ }
+
+ ///
+ /// A directory holding only canonical Generals archives classifies as Generals alone.
+ ///
+ [Fact]
+ public void ClassifyArchives_GeneralsOnlyDirectory_IsGeneralsOnly()
+ {
+ var dir = CreateDirectoryWithArchives("gen-only", "INI.big", "W3D.big", "Audio.big");
+
+ var classification = RetailArchiveClassifier.ClassifyArchives(dir);
+
+ Assert.True(classification.HasGeneralsArchives);
+ Assert.False(classification.HasZeroHourArchives);
+ }
+
+ ///
+ /// A combined flat directory — both games' archives side by side — classifies as both.
+ /// This is the state the issue's acceptance criteria centre on: it is real, not a
+ /// conflict, and must not collapse into Zero Hour alone.
+ ///
+ [Fact]
+ public void ClassifyArchives_CombinedDirectory_IsBothGames()
+ {
+ var dir = CreateDirectoryWithArchives("combined", "INI.big", "INIZH.big");
+
+ var classification = RetailArchiveClassifier.ClassifyArchives(dir);
+
+ Assert.True(classification.HasGeneralsArchives);
+ Assert.True(classification.HasZeroHourArchives);
+ Assert.True(classification.HasAnyGame);
+ }
+
+ ///
+ /// A directory without archives classifies as neither game.
+ ///
+ [Fact]
+ public void ClassifyArchives_EmptyDirectory_IsNeitherGame()
+ {
+ var dir = CreateDirectoryWithArchives("empty");
+
+ var classification = RetailArchiveClassifier.ClassifyArchives(dir);
+
+ Assert.False(classification.HasGeneralsArchives);
+ Assert.False(classification.HasZeroHourArchives);
+ Assert.False(classification.HasAnyGame);
+ }
+
+ ///
+ /// Archives that are neither Zero Hour suffixed nor in the canonical Generals set —
+ /// mod content, hotkey packs, control bars — must not make a directory read as a game.
+ /// An arbitrary .big proves nothing about retail data, which is why the
+ /// launch-side any-archive sentinel cannot be reused for classification.
+ ///
+ [Fact]
+ public void ClassifyArchives_ModArchivesOnly_IsNeitherGame()
+ {
+ var dir = CreateDirectoryWithArchives("mods-only", "somemod.big", "hotkeypack.big", "controlbarpro.big");
+
+ var classification = RetailArchiveClassifier.ClassifyArchives(dir);
+
+ Assert.False(classification.HasGeneralsArchives);
+ Assert.False(classification.HasZeroHourArchives);
+ }
+
+ ///
+ /// A nonexistent directory classifies as neither game rather than throwing.
+ ///
+ [Fact]
+ public void ClassifyArchives_NonexistentDirectory_IsNeitherGame()
+ {
+ var missing = Path.Combine(_tempDir, "gone");
+
+ Assert.False(RetailArchiveClassifier.ClassifyArchives(missing).HasAnyGame);
+ Assert.False(RetailArchiveClassifier.ClassifyArchives(null).HasAnyGame);
+ }
+
+ ///
+ /// Only the directory root is classified, never subdirectories: Data/INI/INIZH.big is
+ /// a duplicate shipped in the English, Chinese and Korean SKUs and must not be
+ /// counted twice.
+ ///
+ [Fact]
+ public void ClassifyArchives_ArchiveInSubdirectory_IsNotCounted()
+ {
+ var dir = Path.Combine(_tempDir, "nested");
+ var nested = Path.Combine(dir, "Data", "INI");
+ Directory.CreateDirectory(nested);
+ File.WriteAllText(Path.Combine(nested, "INIZH.big"), "archive");
+
+ var classification = RetailArchiveClassifier.ClassifyArchives(dir);
+
+ Assert.False(classification.HasZeroHourArchives);
+ Assert.False(classification.HasGeneralsArchives);
+ }
+
+ ///
+ /// Upper-cased archives — retail data copied from a disc or a Windows machine — must
+ /// still classify.
+ ///
+ ///
+ /// CAVEAT: this only exercises the case-insensitivity fix on a case-sensitive volume,
+ /// in practice Linux CI. On macOS and Windows the default filesystem matching is
+ /// already case-insensitive, so this test passes there whether or not
+ /// RetailArchiveConstants.ArchiveSearch carries its MatchCasing setting —
+ /// a local pass implies no coverage of the regression.
+ ///
+ [Fact]
+ public void ClassifyArchives_UpperCasedArchives_StillClassify()
+ {
+ var dir = CreateDirectoryWithArchives("upper", "INIZH.BIG", "TEXTURES.BIG");
+
+ var classification = RetailArchiveClassifier.ClassifyArchives(dir);
+
+ Assert.True(classification.HasZeroHourArchives);
+ Assert.True(classification.HasGeneralsArchives);
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (Directory.Exists(_tempDir))
+ {
+ Directory.Delete(_tempDir, true);
+ }
+
+ GC.SuppressFinalize(this);
+ }
+
+ private string CreateDirectoryWithArchives(string name, params string[] archiveNames)
+ {
+ var dir = Directory.CreateDirectory(Path.Combine(_tempDir, name)).FullName;
+ foreach (var archiveName in archiveNames)
+ {
+ File.WriteAllText(Path.Combine(dir, archiveName), "archive");
+ }
+
+ return dir;
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/GameInstallations/GameInstallationTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/GameInstallations/GameInstallationTests.cs
index d639b95c5..0f377dd7b 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/GameInstallations/GameInstallationTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/GameInstallations/GameInstallationTests.cs
@@ -75,4 +75,189 @@ public void GameInstallation_IsValid_ReturnsTrue_WhenGeneralsPathExists()
Directory.Delete(tempDir, true);
}
}
+
+ ///
+ /// SetPaths flags each game from its retail archives: a directory holding the
+ /// canonical Generals set is Generals, one holding *zh.big archives is Zero Hour.
+ ///
+ [Fact]
+ public void SetPaths_FlagsGamesFromArchivePresence()
+ {
+ var tempDir = Directory.CreateTempSubdirectory("GenHub.SetPathsTests.").FullName;
+ try
+ {
+ var generalsPath = Directory.CreateDirectory(Path.Combine(tempDir, "generals")).FullName;
+ File.WriteAllText(Path.Combine(generalsPath, "INI.big"), "archive");
+ var zeroHourPath = Directory.CreateDirectory(Path.Combine(tempDir, "zerohour")).FullName;
+ File.WriteAllText(Path.Combine(zeroHourPath, "INIZH.big"), "archive");
+
+ var installation = new GameInstallation(tempDir, GameInstallationType.Retail, NullLogger.Instance);
+ installation.SetPaths(generalsPath, zeroHourPath);
+
+ Assert.True(installation.HasGenerals);
+ Assert.True(installation.HasZeroHour);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ ///
+ /// A directory holding only the other game's archives must not flag: a Zero Hour
+ /// directory passed as the Generals path is not a Generals installation, and an
+ /// executable name proves nothing either way.
+ ///
+ [Fact]
+ public void SetPaths_DirectoryWithWrongGamesArchives_DoesNotFlag()
+ {
+ var tempDir = Directory.CreateTempSubdirectory("GenHub.SetPathsTests.").FullName;
+ try
+ {
+ var zeroHourOnly = Directory.CreateDirectory(Path.Combine(tempDir, "zh")).FullName;
+ File.WriteAllText(Path.Combine(zeroHourOnly, "INIZH.big"), "archive");
+ File.WriteAllText(Path.Combine(zeroHourOnly, "generals.exe"), "binary");
+
+ var installation = new GameInstallation(tempDir, GameInstallationType.Retail, NullLogger.Instance);
+ installation.SetPaths(zeroHourOnly, null);
+
+ Assert.False(installation.HasGenerals);
+ Assert.Equal(zeroHourOnly, installation.GeneralsPath);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ ///
+ /// A combined directory passed as both paths sets both flags to the same directory —
+ /// one installation, both games, per the issue's acceptance criteria.
+ ///
+ [Fact]
+ public void SetPaths_CombinedDirectory_FlagsBothGames()
+ {
+ var tempDir = Directory.CreateTempSubdirectory("GenHub.SetPathsTests.").FullName;
+ try
+ {
+ File.WriteAllText(Path.Combine(tempDir, "INI.big"), "archive");
+ File.WriteAllText(Path.Combine(tempDir, "INIZH.big"), "archive");
+
+ var installation = new GameInstallation(tempDir, GameInstallationType.Retail, NullLogger.Instance);
+ installation.SetPaths(tempDir, tempDir);
+
+ Assert.True(installation.HasGenerals);
+ Assert.True(installation.HasZeroHour);
+ Assert.Equal(installation.GeneralsPath, installation.ZeroHourPath);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ ///
+ /// Fetch on a flat root holding both games' archives yields both paths set to the
+ /// root. The earlier executable-based scan had to guess in this layout because both
+ /// games ship the same executable name.
+ ///
+ [Fact]
+ public void Fetch_FlatCombinedRoot_FlagsBothGamesAtRoot()
+ {
+ var tempDir = Directory.CreateTempSubdirectory("GenHub.FetchTests.").FullName;
+ try
+ {
+ File.WriteAllText(Path.Combine(tempDir, "INI.big"), "archive");
+ File.WriteAllText(Path.Combine(tempDir, "INIZH.big"), "archive");
+
+ var installation = new GameInstallation(tempDir, GameInstallationType.Retail, NullLogger.Instance);
+ installation.Fetch();
+
+ Assert.True(installation.HasGenerals);
+ Assert.True(installation.HasZeroHour);
+ Assert.Equal(tempDir, installation.GeneralsPath);
+ Assert.Equal(tempDir, installation.ZeroHourPath);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ ///
+ /// Fetch prefers the standard subdirectories when they hold archives, and a flat
+ /// Zero Hour-only root no longer reads as Generals too.
+ ///
+ [Fact]
+ public void Fetch_ZeroHourOnlyRoot_DoesNotFlagGenerals()
+ {
+ var tempDir = Directory.CreateTempSubdirectory("GenHub.FetchTests.").FullName;
+ try
+ {
+ File.WriteAllText(Path.Combine(tempDir, "INIZH.big"), "archive");
+ File.WriteAllText(Path.Combine(tempDir, "generals.exe"), "binary");
+
+ var installation = new GameInstallation(tempDir, GameInstallationType.Retail, NullLogger.Instance);
+ installation.Fetch();
+
+ Assert.True(installation.HasZeroHour);
+ Assert.Equal(tempDir, installation.ZeroHourPath);
+ Assert.False(installation.HasGenerals);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ ///
+ /// Fetch finds each game in its standard subdirectory by that game's archives.
+ ///
+ [Fact]
+ public void Fetch_StandardSubdirectories_FlagsEachGameInItsDirectory()
+ {
+ var tempDir = Directory.CreateTempSubdirectory("GenHub.FetchTests.").FullName;
+ try
+ {
+ var generalsDir = Directory.CreateDirectory(Path.Combine(tempDir, "Command and Conquer Generals")).FullName;
+ File.WriteAllText(Path.Combine(generalsDir, "INI.big"), "archive");
+ var zeroHourDir = Directory.CreateDirectory(Path.Combine(tempDir, "Command and Conquer Generals Zero Hour")).FullName;
+ File.WriteAllText(Path.Combine(zeroHourDir, "INIZH.big"), "archive");
+
+ var installation = new GameInstallation(tempDir, GameInstallationType.Retail, NullLogger.Instance);
+ installation.Fetch();
+
+ Assert.True(installation.HasGenerals);
+ Assert.Equal(generalsDir, installation.GeneralsPath);
+ Assert.True(installation.HasZeroHour);
+ Assert.Equal(zeroHourDir, installation.ZeroHourPath);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ ///
+ /// A root holding only unrecognised archives — mod content — must not read as a game.
+ ///
+ [Fact]
+ public void Fetch_ModArchivesOnlyRoot_FlagsNothing()
+ {
+ var tempDir = Directory.CreateTempSubdirectory("GenHub.FetchTests.").FullName;
+ try
+ {
+ File.WriteAllText(Path.Combine(tempDir, "somemod.big"), "archive");
+
+ var installation = new GameInstallation(tempDir, GameInstallationType.Retail, NullLogger.Instance);
+ installation.Fetch();
+
+ Assert.False(installation.HasGenerals);
+ Assert.False(installation.HasZeroHour);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
}
\ No newline at end of file
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.MacOS/GameInstallations/MacOSInstallationDetectorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.MacOS/GameInstallations/MacOSInstallationDetectorTests.cs
index 1bf37073a..151f6a966 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.MacOS/GameInstallations/MacOSInstallationDetectorTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.MacOS/GameInstallations/MacOSInstallationDetectorTests.cs
@@ -52,4 +52,144 @@ public void CreateDetectionResult_WithoutDeniedRoot_ReturnsSuccess()
Assert.Same(installation, Assert.Single(result.Items));
Assert.Equal(elapsed, result.Elapsed);
}
+
+ ///
+ /// A candidate root that is itself a flat retail tree — the native engine's default
+ /// deploy layout — is detected directly, without being a name-matched child of
+ /// anything. This is the layout the executable-name check made undetectable: its
+ /// binary is extensionless, but its archives are unambiguous.
+ ///
+ [Fact]
+ public void InspectRoot_FlatCombinedRoot_DetectsBothGamesAtRoot()
+ {
+ var root = Directory.CreateTempSubdirectory("GenHub.MacDetector.").FullName;
+ try
+ {
+ File.WriteAllText(Path.Combine(root, "INI.big"), "archive");
+ File.WriteAllText(Path.Combine(root, "INIZH.big"), "archive");
+
+ var (installation, accessDenied) = MacOSInstallationDetector.InspectRoot(root);
+
+ Assert.False(accessDenied);
+ Assert.NotNull(installation);
+ Assert.True(installation.HasGenerals);
+ Assert.True(installation.HasZeroHour);
+ Assert.Equal(root, installation.GeneralsPath);
+ Assert.Equal(root, installation.ZeroHourPath);
+ }
+ finally
+ {
+ Directory.Delete(root, true);
+ }
+ }
+
+ ///
+ /// A flat root holding only Zero Hour archives is a Zero Hour installation alone.
+ ///
+ [Fact]
+ public void InspectRoot_FlatZeroHourRoot_DetectsZeroHourOnly()
+ {
+ var root = Directory.CreateTempSubdirectory("GenHub.MacDetector.").FullName;
+ try
+ {
+ File.WriteAllText(Path.Combine(root, "INIZH.big"), "archive");
+
+ var (installation, _) = MacOSInstallationDetector.InspectRoot(root);
+
+ Assert.NotNull(installation);
+ Assert.True(installation.HasZeroHour);
+ Assert.False(installation.HasGenerals);
+ }
+ finally
+ {
+ Directory.Delete(root, true);
+ }
+ }
+
+ ///
+ /// Name-matched children still work for copied retail trees that keep their Windows
+ /// directory names, with the games flagged from the archives each child holds.
+ ///
+ [Fact]
+ public void InspectRoot_NameMatchedChildren_DetectsGamesFromArchives()
+ {
+ var root = Directory.CreateTempSubdirectory("GenHub.MacDetector.").FullName;
+ try
+ {
+ var generalsDir = Directory.CreateDirectory(Path.Combine(root, "Command and Conquer Generals")).FullName;
+ File.WriteAllText(Path.Combine(generalsDir, "INI.big"), "archive");
+ var zeroHourDir = Directory.CreateDirectory(Path.Combine(root, "Command and Conquer Generals Zero Hour")).FullName;
+ File.WriteAllText(Path.Combine(zeroHourDir, "INIZH.big"), "archive");
+
+ var (installation, _) = MacOSInstallationDetector.InspectRoot(root);
+
+ Assert.NotNull(installation);
+ Assert.True(installation.HasGenerals);
+ Assert.Equal(generalsDir, installation.GeneralsPath);
+ Assert.True(installation.HasZeroHour);
+ Assert.Equal(zeroHourDir, installation.ZeroHourPath);
+ }
+ finally
+ {
+ Directory.Delete(root, true);
+ }
+ }
+
+ ///
+ /// A name-matched child without retail archives is discarded: the right directory
+ /// name proves nothing about content.
+ ///
+ [Fact]
+ public void InspectRoot_NameMatchedChildWithoutArchives_FindsNothing()
+ {
+ var root = Directory.CreateTempSubdirectory("GenHub.MacDetector.").FullName;
+ try
+ {
+ Directory.CreateDirectory(Path.Combine(root, "Command and Conquer Generals Zero Hour"));
+
+ var (installation, accessDenied) = MacOSInstallationDetector.InspectRoot(root);
+
+ Assert.False(accessDenied);
+ Assert.Null(installation);
+ }
+ finally
+ {
+ Directory.Delete(root, true);
+ }
+ }
+
+ ///
+ /// A root holding only unrecognised archives — mod content — must not read as a game.
+ ///
+ [Fact]
+ public void InspectRoot_ModArchivesOnlyRoot_FindsNothing()
+ {
+ var root = Directory.CreateTempSubdirectory("GenHub.MacDetector.").FullName;
+ try
+ {
+ File.WriteAllText(Path.Combine(root, "somemod.big"), "archive");
+
+ var (installation, _) = MacOSInstallationDetector.InspectRoot(root);
+
+ Assert.Null(installation);
+ }
+ finally
+ {
+ Directory.Delete(root, true);
+ }
+ }
+
+ ///
+ /// A nonexistent root finds nothing and is not access-denied.
+ ///
+ [Fact]
+ public void InspectRoot_NonexistentRoot_FindsNothing()
+ {
+ var missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+
+ var (installation, accessDenied) = MacOSInstallationDetector.InspectRoot(missing);
+
+ Assert.Null(installation);
+ Assert.False(accessDenied);
+ }
}
diff --git a/GenHub/GenHub.Windows/GameInstallations/WindowsInstallationDetector.cs b/GenHub/GenHub.Windows/GameInstallations/WindowsInstallationDetector.cs
index f98e9e528..04d9bddb3 100644
--- a/GenHub/GenHub.Windows/GameInstallations/WindowsInstallationDetector.cs
+++ b/GenHub/GenHub.Windows/GameInstallations/WindowsInstallationDetector.cs
@@ -8,6 +8,7 @@
using System.Threading.Tasks;
using GenHub.Core.Constants;
using GenHub.Core.Extensions.GameInstallations;
+using GenHub.Core.Helpers;
using GenHub.Core.Interfaces.GameInstallations;
using GenHub.Core.Models.Enums;
using GenHub.Core.Models.GameInstallations;
@@ -120,6 +121,22 @@ public Task> DetectInstallationsAsync(Cancella
return Task.FromResult(result);
}
+ ///
+ /// Determines whether both games are flagged at the same directory.
+ ///
+ /// The installation to inspect.
+ /// True when Generals and Zero Hour share one path.
+ private static bool IsCombinedDirectory(GameInstallation installation)
+ {
+ return installation.HasGenerals
+ && installation.HasZeroHour
+ && !string.IsNullOrEmpty(installation.GeneralsPath)
+ && !string.IsNullOrEmpty(installation.ZeroHourPath)
+ && Path.GetFullPath(installation.GeneralsPath).Equals(
+ Path.GetFullPath(installation.ZeroHourPath),
+ StringComparison.OrdinalIgnoreCase);
+ }
+
private List DetectRetailInstallations()
{
var retailInstalls = new List();
@@ -140,16 +157,12 @@ private List DetectRetailInstallations()
if (Directory.Exists(basePath))
{
// Check if this is a "flat" installation (base path IS the game directory)
- // This is common for "ZH" folders or custom repacks
- var zeroHourExecutables = new[]
- {
- GameClientConstants.ZeroHourExecutable,
- GameClientConstants.GeneralsExecutable,
- GameClientConstants.SuperHackersZeroHourExecutable,
- };
-
- // If check for valid ZH executables in the root
- if (zeroHourExecutables.Any(exe => File.Exists(Path.Combine(basePath, exe))))
+ // This is common for "ZH" folders or custom repacks. Archive classification
+ // identifies which games' retail data the root holds, so a combined flat
+ // directory yields one installation with both paths set while a Zero
+ // Hour-only folder no longer reads as Generals too.
+ var rootClassification = ClassifyArchivesSafely(basePath);
+ if (rootClassification.HasAnyGame)
{
// Check if standard subdirectories exist. If NOT, then assume flat install.
bool hasGeneralsSubdir = Directory.Exists(Path.Combine(basePath, GameClientConstants.GeneralsDirectoryName));
@@ -158,11 +171,9 @@ private List DetectRetailInstallations()
if (!hasGeneralsSubdir && !hasZeroHourSubdir)
{
var installation = new GameInstallation(basePath, GameInstallationType.Retail, null);
-
- // For a flat install, both paths point to the base path (assuming merged)
- // Or just set ZeroHour if only ZH is present.
- // Safe bet: If generals.exe exists, assume base path covers both capabilities in a flat structure.
- installation.SetPaths(basePath, basePath);
+ installation.SetPaths(
+ rootClassification.HasGeneralsArchives ? basePath : null,
+ rootClassification.HasZeroHourArchives ? basePath : null);
retailInstalls.Add(installation);
logger.LogInformation("Detected standalone/flat Retail installation at {BasePath}", basePath);
@@ -208,6 +219,28 @@ private List DeduplicateInstallations(List i
foreach (var installation in orderedInstallations)
{
+ // A combined directory — both games flagged at the same path — is one unit.
+ // Splitting it across sources by clearing whichever game another source
+ // already claimed would leave the same directory owned by two installations
+ // and scanned twice for clients, so it is kept whole or dropped whole.
+ if (IsCombinedDirectory(installation))
+ {
+ var combinedPath = Path.GetFullPath(installation.GeneralsPath);
+ if (seenGeneralsPaths.Contains(combinedPath) || seenZeroHourPaths.Contains(combinedPath))
+ {
+ logger.LogWarning(
+ "Skipping combined {InstallationType} installation at {CombinedPath} (directory already detected from another source)",
+ installation.InstallationType,
+ combinedPath);
+ continue;
+ }
+
+ seenGeneralsPaths.Add(combinedPath);
+ seenZeroHourPaths.Add(combinedPath);
+ deduplicated.Add(installation);
+ continue;
+ }
+
var hasUniqueGenerals = false;
var hasUniqueZeroHour = false;
@@ -278,4 +311,22 @@ private List DeduplicateInstallations(List i
return deduplicated;
}
+
+ ///
+ /// Classifies a directory's archives without letting a filesystem error abort detection.
+ ///
+ /// The directory to classify.
+ /// The classification, or neither game when the directory cannot be read.
+ private RetailArchiveClassification ClassifyArchivesSafely(string path)
+ {
+ try
+ {
+ return RetailArchiveClassifier.ClassifyArchives(path);
+ }
+ catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
+ {
+ logger.LogWarning(ex, "Could not read {Path} while classifying retail archives; treating it as holding none", path);
+ return default;
+ }
+ }
}
diff --git a/GenHub/GenHub/Features/Content/Services/ContentValidator.cs b/GenHub/GenHub/Features/Content/Services/ContentValidator.cs
index 92711ffcc..d2a829ecc 100644
--- a/GenHub/GenHub/Features/Content/Services/ContentValidator.cs
+++ b/GenHub/GenHub/Features/Content/Services/ContentValidator.cs
@@ -245,7 +245,14 @@ await Task.Run(
{
issues.Add(new ValidationIssue(
$"Extraneous file detected (not in manifest): {extraneousFile}",
- ValidationSeverity.Warning));
+ ValidationSeverity.Warning)
+ {
+ // Typed and pathed so consumers can recognise these structurally;
+ // the untyped form defaulted to MissingFile, the opposite of what
+ // an extra file is.
+ IssueType = ValidationIssueType.UnexpectedFile,
+ Path = extraneousFile,
+ });
}
_logger.LogDebug("Extraneous file detection for {ManifestId} found {ExtraneousCount} files.", manifest.Id, extraneousFiles.Count);
diff --git a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersManifestFactory.cs
index aed724b23..d89c9d7a4 100644
--- a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersManifestFactory.cs
+++ b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersManifestFactory.cs
@@ -3,6 +3,7 @@
using GenHub.Core.Interfaces.Content;
using GenHub.Core.Models.Enums;
using GenHub.Core.Models.Manifest;
+using GenHub.Core.Utilities;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
@@ -39,6 +40,31 @@ private static int ExtractVersionFromManifestId(string manifestId)
return 0;
}
+ ///
+ /// Determines whether a file carries a known executable name, with or without its
+ /// .exe extension.
+ ///
+ /// The candidate file path.
+ /// The Windows name of the executable, ending in .exe.
+ /// True when the file is that executable in Windows or native form.
+ private static bool MatchesExecutableName(string filePath, string windowsExecutableName)
+ {
+ var fileName = Path.GetFileName(filePath);
+
+ if (string.Equals(fileName, windowsExecutableName, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ // A native build of the same client drops the extension: generalszh.exe on
+ // Windows is GeneralsZH as a Mach-O or ELF binary.
+ return !Path.HasExtension(fileName)
+ && string.Equals(
+ fileName,
+ Path.GetFileNameWithoutExtension(windowsExecutableName),
+ StringComparison.OrdinalIgnoreCase);
+ }
+
///
public string PublisherId => PublisherTypeConstants.TheSuperHackers;
@@ -185,6 +211,14 @@ public async Task> CreateManifestsFromLocalInstallAsync(
///
/// Detects SuperHackers game executables in the extracted directory.
///
+ ///
+ /// Candidates are selected via
+ /// instead of a *.exe glob, because a native Mach-O or ELF build of the same
+ /// client is extensionless and the glob hid it entirely. The name match then accepts
+ /// either the Windows executable name or its extensionless form; a content-based
+ /// (magic-byte) classification slots in at the classifier call without this site
+ /// changing. Windows .exe results are unaffected.
+ ///
private Dictionary DetectGameExecutables(string directory)
{
var result = new Dictionary();
@@ -192,19 +226,18 @@ private Dictionary DetectGameExecutables(string directory)
if (!Directory.Exists(directory))
return result;
- var allFiles = Directory.GetFiles(directory, "*.exe", SearchOption.AllDirectories);
+ var allFiles = Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)
+ .Where(path => ExecutableFileClassifier.IsLegacyLaunchCandidate(path, path));
foreach (var filePath in allFiles)
{
- var fileName = Path.GetFileName(filePath).ToLowerInvariant();
-
// Check for SuperHackers executables
- if (string.Equals(fileName, GameClientConstants.SuperHackersGeneralsExecutable, StringComparison.OrdinalIgnoreCase))
+ if (MatchesExecutableName(filePath, GameClientConstants.SuperHackersGeneralsExecutable))
{
result[GameType.Generals] = filePath;
logger.LogInformation("Detected SuperHackers Generals executable: {Path}", filePath);
}
- else if (string.Equals(fileName, GameClientConstants.SuperHackersZeroHourExecutable, StringComparison.OrdinalIgnoreCase))
+ else if (MatchesExecutableName(filePath, GameClientConstants.SuperHackersZeroHourExecutable))
{
result[GameType.ZeroHour] = filePath;
logger.LogInformation("Detected SuperHackers Zero Hour executable: {Path}", filePath);
diff --git a/GenHub/GenHub/Features/GameClients/GameClientDetector.cs b/GenHub/GenHub/Features/GameClients/GameClientDetector.cs
index 2004fe320..ffac74dfb 100644
--- a/GenHub/GenHub/Features/GameClients/GameClientDetector.cs
+++ b/GenHub/GenHub/Features/GameClients/GameClientDetector.cs
@@ -15,6 +15,7 @@
using GenHub.Core.Models.GameInstallations;
using GenHub.Core.Models.Manifest;
using GenHub.Core.Models.Results;
+using GenHub.Core.Utilities;
using Microsoft.Extensions.Logging;
namespace GenHub.Features.GameClients;
@@ -55,28 +56,49 @@ public async Task> DetectGameClientsFromInstallation
foreach (var inst in installations)
{
+ // A combined directory flags both games at one path. Its single executable
+ // set must be scanned once, as Zero Hour: running the Generals scan over the
+ // same directory would wrap the very same executable in a second, duplicate
+ // GameClient. Publisher detection below is unaffected — identifiers match
+ // per-game executables and filter on the identified game type themselves.
+ var isCombinedDirectory = inst.HasGenerals
+ && inst.HasZeroHour
+ && !string.IsNullOrEmpty(inst.GeneralsPath)
+ && !string.IsNullOrEmpty(inst.ZeroHourPath)
+ && Path.GetFullPath(inst.GeneralsPath).Equals(Path.GetFullPath(inst.ZeroHourPath), StringComparison.OrdinalIgnoreCase);
+
if (inst.HasGenerals && !string.IsNullOrEmpty(inst.GeneralsPath) && Directory.Exists(inst.GeneralsPath))
{
- // First, detect the standard installation client (priority over GeneralsOnline for auto-selection)
- var (version, actualExePath) = await DetectVersionFromInstallationAsync(inst.GeneralsPath, GameType.Generals, cancellationToken);
- if (File.Exists(actualExePath))
+ if (isCombinedDirectory)
{
- var generalsVersion = new GameClient
- {
- Name = $"Generals {version}",
- Id = string.Empty, // Set later by manifest
- Version = version,
- ExecutablePath = actualExePath,
- GameType = GameType.Generals,
- InstallationId = inst.Id,
- WorkingDirectory = inst.GeneralsPath,
- };
- await GenerateClientManifestAndSetIdAsync(generalsVersion, inst.GeneralsPath, inst, GameType.Generals);
- gameClients.Add(generalsVersion);
+ logger.LogDebug(
+ "Skipping standard Generals client scan for {InstallationId}: {GeneralsPath} is a combined directory scanned once as Zero Hour",
+ inst.Id,
+ inst.GeneralsPath);
}
else
{
- logger.LogWarning("Skipping Generals game client for {InstallationId}: no valid executable found at {ExePath}", inst.Id, actualExePath);
+ // First, detect the standard installation client (priority over GeneralsOnline for auto-selection)
+ var (version, actualExePath) = await DetectVersionFromInstallationAsync(inst.GeneralsPath, GameType.Generals, cancellationToken);
+ if (File.Exists(actualExePath))
+ {
+ var generalsVersion = new GameClient
+ {
+ Name = $"Generals {version}",
+ Id = string.Empty, // Set later by manifest
+ Version = version,
+ ExecutablePath = actualExePath,
+ GameType = GameType.Generals,
+ InstallationId = inst.Id,
+ WorkingDirectory = inst.GeneralsPath,
+ };
+ await GenerateClientManifestAndSetIdAsync(generalsVersion, inst.GeneralsPath, inst, GameType.Generals);
+ gameClients.Add(generalsVersion);
+ }
+ else
+ {
+ logger.LogWarning("Skipping Generals game client for {InstallationId}: no valid executable found at {ExePath}", inst.Id, actualExePath);
+ }
}
// Detect publisher clients (GeneralsOnline, SuperHackers, etc.) using registered identifiers
@@ -170,6 +192,26 @@ public Task ValidateGameClientAsync(
return Task.FromResult(isValid);
}
+ ///
+ /// Enumerates the top-level files of a directory that could be launched.
+ ///
+ /// The directory to scan.
+ /// Full paths of the launch candidates.
+ ///
+ /// Replaces the old *.exe glob, which hid extensionless binaries — the shape
+ /// of a native Mach-O or ELF game client — from publisher detection entirely.
+ /// Selection goes through ,
+ /// which keeps .exe results identical while classifying extensionless files by
+ /// their magic bytes. These paths are on disk, so the absolute path is supplied and the
+ /// content-based rule applies rather than the name-only fallback.
+ ///
+ private static string[] GetLaunchCandidateFiles(string directoryPath)
+ {
+ return Directory.EnumerateFiles(directoryPath, "*", SearchOption.TopDirectoryOnly)
+ .Where(path => ExecutableFileClassifier.IsLegacyLaunchCandidate(path, path))
+ .ToArray();
+ }
+
///
/// Detects a game client from a specific executable file using hash analysis.
///
@@ -563,7 +605,7 @@ private Task DetectPublisherClientsFromLocalFilesAsync(
HashSet publishersHandledFromPool,
List detectedClients)
{
- var executableFiles = Directory.GetFiles(installationPath, "*.exe", SearchOption.TopDirectoryOnly);
+ var executableFiles = GetLaunchCandidateFiles(installationPath);
foreach (var identifier in gameClientIdentifiers)
{
@@ -739,7 +781,7 @@ private Task> DetectPublisherExecutablesAsync(
return Task.FromResult(detectedPublishers);
}
- var executableFiles = Directory.GetFiles(installationPath, "*.exe", SearchOption.TopDirectoryOnly);
+ var executableFiles = GetLaunchCandidateFiles(installationPath);
foreach (var executablePath in executableFiles)
{
diff --git a/GenHub/GenHub/Features/GameInstallations/InstallationPathResolver.cs b/GenHub/GenHub/Features/GameInstallations/InstallationPathResolver.cs
index 4fc30bde6..74ecc7a96 100644
--- a/GenHub/GenHub/Features/GameInstallations/InstallationPathResolver.cs
+++ b/GenHub/GenHub/Features/GameInstallations/InstallationPathResolver.cs
@@ -6,6 +6,7 @@
using System.Threading;
using System.Threading.Tasks;
using GenHub.Core.Constants;
+using GenHub.Core.Helpers;
using GenHub.Core.Interfaces.GameInstallations;
using GenHub.Core.Models.Enums;
using GenHub.Core.Models.GameInstallations;
@@ -93,25 +94,21 @@ public Task> ValidateInstallationPathAsync(
return Task.FromResult(OperationResult.CreateSuccess(false));
}
- // Check if it contains expected game files
+ // Check if it still holds the games' retail archives — the same signal that
+ // flagged the games at detection time, so a native install whose binary is
+ // extensionless does not read as vanished.
var hasValidFiles = false;
- if (installation.HasGenerals && !string.IsNullOrEmpty(installation.GeneralsPath))
+ if (installation.HasGenerals && !string.IsNullOrEmpty(installation.GeneralsPath)
+ && RetailArchiveClassifier.ClassifyArchives(installation.GeneralsPath).HasGeneralsArchives)
{
- var generalsExe = Path.Combine(installation.GeneralsPath, "generals.exe");
- if (File.Exists(generalsExe))
- {
- hasValidFiles = true;
- }
+ hasValidFiles = true;
}
- if (installation.HasZeroHour && !string.IsNullOrEmpty(installation.ZeroHourPath))
+ if (installation.HasZeroHour && !string.IsNullOrEmpty(installation.ZeroHourPath)
+ && RetailArchiveClassifier.ClassifyArchives(installation.ZeroHourPath).HasZeroHourArchives)
{
- var zhExe = Path.Combine(installation.ZeroHourPath, "generals.exe");
- if (File.Exists(zhExe))
- {
- hasValidFiles = true;
- }
+ hasValidFiles = true;
}
if (!hasValidFiles)
diff --git a/GenHub/GenHub/Features/Manifest/ManifestProvider.cs b/GenHub/GenHub/Features/Manifest/ManifestProvider.cs
index 94bedd49a..0970a5165 100644
--- a/GenHub/GenHub/Features/Manifest/ManifestProvider.cs
+++ b/GenHub/GenHub/Features/Manifest/ManifestProvider.cs
@@ -173,14 +173,32 @@ public class ManifestProvider(ILogger logger, IContentManifest
/// The installation to get a manifest for.
/// A cancellation token.
/// The manifest if found or generated; otherwise null.
- public async Task GetManifestAsync(GameInstallation installation, CancellationToken cancellationToken = default)
+ ///
+ /// This single-manifest entry point can only surface one game, and it prefers Zero
+ /// Hour when both are flagged — a combined installation carries both games, so a
+ /// caller that relies on this overload never sees a Generals manifest for it. Callers
+ /// that know which game they are asking about must use
+ /// .
+ ///
+ public Task GetManifestAsync(GameInstallation installation, CancellationToken cancellationToken = default)
+ {
+ var gameType = installation.HasZeroHour ? GameType.ZeroHour : GameType.Generals;
+ return GetManifestAsync(installation, gameType, cancellationToken);
+ }
+
+ ///
+ /// Gets or generates a manifest for one game of a .
+ ///
+ /// The installation to get a manifest for.
+ /// The game whose manifest is requested.
+ /// A cancellation token.
+ /// The manifest if found or generated; otherwise null.
+ public async Task GetManifestAsync(GameInstallation installation, GameType gameType, CancellationToken cancellationToken = default)
{
// Prefer a deterministic manifest id for installations so tests and embedded resources can
// reference stable ids instead of runtime GUIDs. Generate using ManifestIdGenerator.
var tempInstallForId = new GameInstallation(installation.InstallationPath, installation.InstallationType, null);
- var gameType = installation.HasZeroHour ? GameType.ZeroHour : GameType.Generals;
-
// Use appropriate manifest version for generated installation manifests
var manifestVersion = gameType == GameType.ZeroHour
? ManifestConstants.ZeroHourManifestVersion
@@ -233,7 +251,7 @@ public class ManifestProvider(ILogger logger, IContentManifest
logger.LogInformation("Generating fallback manifest for installation {Id}", installation.Id);
// Determine the correct source path based on the game type
- var manifestGameType = installation.HasZeroHour ? GameType.ZeroHour : GameType.Generals;
+ var manifestGameType = gameType;
var sourcePath = manifestGameType == GameType.ZeroHour
? (!string.IsNullOrEmpty(installation.ZeroHourPath) ? installation.ZeroHourPath : installation.InstallationPath)
: (!string.IsNullOrEmpty(installation.GeneralsPath) ? installation.GeneralsPath : installation.InstallationPath);
diff --git a/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs b/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs
index 7288e74e6..864a630d3 100644
--- a/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs
+++ b/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs
@@ -1,12 +1,15 @@
using System;
using System.Collections.Generic;
+using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using GenHub.Core.Constants;
using GenHub.Core.Interfaces.Common;
using GenHub.Core.Interfaces.Content;
using GenHub.Core.Interfaces.Manifest;
using GenHub.Core.Interfaces.Validation;
+using GenHub.Core.Models.Enums;
using GenHub.Core.Models.GameInstallations;
using GenHub.Core.Models.Results;
using GenHub.Core.Models.Validation;
@@ -50,64 +53,94 @@ public async Task ValidateAsync(GameInstallation installation,
logger.LogInformation("Starting validation for installation '{Path}'", installation.InstallationPath);
var issues = new List();
- // Calculate total steps dynamically based on installation
- int totalSteps = 4; // Base steps: manifest fetch, manifest validation, integrity, extraneous files
- if (installation.HasGenerals) totalSteps++;
- if (installation.HasZeroHour) totalSteps++;
-
- int currentStep = 0;
-
- progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Fetching manifest"));
+ // Each flagged game is validated as its own pass against its own manifest and
+ // directory. A combined installation carries both games — asking for "the"
+ // manifest of such an installation would surface Zero Hour only and Generals
+ // would silently never be validated.
+ var passes = new List<(GameType? GameType, string SourcePath)>();
+ if (installation.HasGenerals)
+ {
+ passes.Add((GameType.Generals, string.IsNullOrEmpty(installation.GeneralsPath) ? installation.InstallationPath : installation.GeneralsPath));
+ }
- // Fetch manifest for this installation type
- var manifest = await manifestProvider.GetManifestAsync(installation, cancellationToken);
- cancellationToken.ThrowIfCancellationRequested();
- if (manifest == null)
+ if (installation.HasZeroHour)
{
- issues.Add(new ValidationIssue { IssueType = ValidationIssueType.MissingFile, Path = installation.InstallationPath, Message = "Manifest not found for installation." });
- progress?.Report(new ValidationProgress(totalSteps, totalSteps, "Validation complete"));
- return new ValidationResult(installation.InstallationPath, issues);
+ passes.Add((GameType.ZeroHour, string.IsNullOrEmpty(installation.ZeroHourPath) ? installation.InstallationPath : installation.ZeroHourPath));
}
- progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Core manifest validation"));
+ if (passes.Count == 0)
+ {
+ // No game flagged: fall back to the installation-level manifest lookup so a
+ // bare registration still gets a definite answer instead of no validation.
+ passes.Add((null, installation.InstallationPath));
+ }
- var manifestValidationResult = await contentValidator.ValidateManifestAsync(manifest, cancellationToken);
- issues.AddRange(manifestValidationResult.Issues);
+ // In a combined directory both games' retail archives sit side by side, so each
+ // per-game pass sees the sibling game's root archives as files its manifest never
+ // mentions. Those are not extraneous — they are the other half of the same
+ // installation — and flagging them would indict every combined install.
+ var isCombinedDirectory = installation.HasGenerals
+ && installation.HasZeroHour
+ && !string.IsNullOrEmpty(installation.GeneralsPath)
+ && !string.IsNullOrEmpty(installation.ZeroHourPath)
+ && Path.GetFullPath(installation.GeneralsPath).Equals(
+ Path.GetFullPath(installation.ZeroHourPath),
+ StringComparison.OrdinalIgnoreCase);
- progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Validating content files"));
+ // Four steps per pass: manifest fetch, manifest validation, content files, directories.
+ int totalSteps = passes.Count * 4;
+ int currentStep = 0;
- // Use ContentValidator for full content validation (integrity + extraneous files)
- try
- {
- var fullValidation = await contentValidator.ValidateAllAsync(installation.InstallationPath, manifest, progress, cancellationToken);
- issues.AddRange(fullValidation.Issues);
- }
- catch (Exception ex)
+ foreach (var (gameType, sourcePath) in passes)
{
- logger.LogError(ex, "Content validation failed for installation '{Path}'", installation.InstallationPath);
- issues.Add(new ValidationIssue
+ progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Fetching manifest"));
+
+ var manifest = gameType is null
+ ? await manifestProvider.GetManifestAsync(installation, cancellationToken)
+ : await manifestProvider.GetManifestAsync(installation, gameType.Value, cancellationToken);
+ cancellationToken.ThrowIfCancellationRequested();
+ if (manifest == null)
{
- IssueType = ValidationIssueType.CorruptedFile,
- Path = installation.InstallationPath,
- Message = $"Content validation failed: {ex.Message}",
- Severity = ValidationSeverity.Error,
- });
- }
+ issues.Add(new ValidationIssue { IssueType = ValidationIssueType.MissingFile, Path = sourcePath, Message = "Manifest not found for installation." });
+ currentStep += 3;
+ continue;
+ }
- // Installation-specific validations (directories, etc.)
- var requiredDirs = manifest.RequiredDirectories ?? Enumerable.Empty();
- if (requiredDirs.Any())
- {
- if (installation.HasGenerals)
+ progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Core manifest validation"));
+
+ var manifestValidationResult = await contentValidator.ValidateManifestAsync(manifest, cancellationToken);
+ issues.AddRange(manifestValidationResult.Issues);
+
+ progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Validating content files"));
+
+ // Use ContentValidator for full content validation (integrity + extraneous files)
+ try
+ {
+ var fullValidation = await contentValidator.ValidateAllAsync(sourcePath, manifest, progress, cancellationToken);
+ var contentIssues = isCombinedDirectory && gameType is not null
+ ? fullValidation.Issues.Where(issue => !IsSiblingGameRootArchive(issue, gameType.Value))
+ : fullValidation.Issues;
+ issues.AddRange(contentIssues);
+ }
+ catch (Exception ex)
{
- progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Validating Generals directories"));
- issues.AddRange(await ValidateDirectoriesAsync(installation.GeneralsPath, requiredDirs, cancellationToken));
+ logger.LogError(ex, "Content validation failed for installation '{Path}'", sourcePath);
+ issues.Add(new ValidationIssue
+ {
+ IssueType = ValidationIssueType.CorruptedFile,
+ Path = sourcePath,
+ Message = $"Content validation failed: {ex.Message}",
+ Severity = ValidationSeverity.Error,
+ });
}
- if (installation.HasZeroHour)
+ // Installation-specific validations (directories, etc.)
+ progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Validating game directories"));
+
+ var requiredDirs = manifest.RequiredDirectories ?? Enumerable.Empty();
+ if (requiredDirs.Any() && gameType is not null)
{
- progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Validating Zero Hour directories"));
- issues.AddRange(await ValidateDirectoriesAsync(installation.ZeroHourPath, requiredDirs, cancellationToken));
+ issues.AddRange(await ValidateDirectoriesAsync(sourcePath, requiredDirs, cancellationToken));
}
}
@@ -116,4 +149,35 @@ public async Task ValidateAsync(GameInstallation installation,
logger.LogInformation("Installation validation for '{Path}' completed with {Count} issues.", installation.InstallationPath, issues.Count);
return new ValidationResult(installation.InstallationPath, issues);
}
-}
\ No newline at end of file
+
+ ///
+ /// Determines whether an extraneous-file issue actually names a root archive of the
+ /// sibling game in a combined directory.
+ ///
+ /// The issue reported by content validation.
+ /// The game whose pass produced the issue.
+ /// True when the issue refers to the other game's known root archive.
+ ///
+ /// Recognition uses the same retail vocabulary that classified the directory in the
+ /// first place: in the Generals pass any root-level *zh.big belongs to Zero
+ /// Hour, and in the Zero Hour pass any canonical Generals archive name belongs to
+ /// Generals. Only the directory root is tolerated — deeper files are outside the
+ /// vocabulary and stay reported.
+ ///
+ private static bool IsSiblingGameRootArchive(ValidationIssue issue, GameType gameType)
+ {
+ if (issue.IssueType != ValidationIssueType.UnexpectedFile || string.IsNullOrEmpty(issue.Path))
+ {
+ return false;
+ }
+
+ if (issue.Path.Contains(Path.DirectorySeparatorChar) || issue.Path.Contains(Path.AltDirectorySeparatorChar))
+ {
+ return false;
+ }
+
+ return gameType == GameType.Generals
+ ? issue.Path.EndsWith(RetailArchiveConstants.ZeroHourArchiveSuffix, StringComparison.OrdinalIgnoreCase)
+ : RetailArchiveConstants.GeneralsArchiveNames.Contains(issue.Path);
+ }
+}