From dc39257c9d4970f248a2528bf078bbc66b1b9cfc Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Thu, 30 Jul 2026 13:58:59 +0100 Subject: [PATCH 1/3] feat(installations): detect retail installations by archive presence, not executable name --- .../Constants/GameClientConstants.cs | 6 + .../Constants/RetailArchiveConstants.cs | 45 +++++ .../Helpers/RetailArchiveClassifier.cs | 76 +++++++ .../Interfaces/Manifest/IManifestProvider.cs | 16 ++ .../GameInstallations/GameInstallation.cs | 147 ++++++-------- .../RetailArchiveClassification.cs | 19 ++ .../MacOSInstallationDetector.cs | 151 +++++++++----- .../GameClients/GameClientDetectorTests.cs | 51 +++++ .../Manifest/ManifestProviderTests.cs | 49 ++++- .../GameInstallationValidatorTests.cs | 44 +++++ .../Helpers/RetailArchiveClassifierTests.cs | 177 +++++++++++++++++ .../GameInstallationTests.cs | 185 ++++++++++++++++++ .../MacOSInstallationDetectorTests.cs | 140 +++++++++++++ .../WindowsInstallationDetector.cs | 81 ++++++-- .../GameClients/GameClientDetector.cs | 53 +++-- .../InstallationPathResolver.cs | 23 +-- .../Features/Manifest/ManifestProvider.cs | 26 ++- .../Validation/GameInstallationValidator.cs | 106 +++++----- 18 files changed, 1164 insertions(+), 231 deletions(-) create mode 100644 GenHub/GenHub.Core/Helpers/RetailArchiveClassifier.cs create mode 100644 GenHub/GenHub.Core/Models/GameInstallations/RetailArchiveClassification.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/RetailArchiveClassifierTests.cs 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..8fd7c3717 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,57 @@ 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. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task DetectGameClientsFromInstallationsAsync_WithCombinedDirectory_YieldsSingleClient() + { + // Arrange + var combinedPath = Path.Combine(_tempDirectory, "Combined"); + Directory.CreateDirectory(combinedPath); + var executablePath = Path.Combine(combinedPath, "generals.exe"); + await File.WriteAllTextAsync(executablePath, "dummy content"); + + 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); + var client = Assert.Single(result.Items); + Assert.Equal(GameType.ZeroHour, client.GameType); + Assert.Equal(executablePath, client.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..b5ae404d6 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs @@ -390,6 +390,50 @@ 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); + } + } + /// /// 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/GameClients/GameClientDetector.cs b/GenHub/GenHub/Features/GameClients/GameClientDetector.cs index 2004fe320..4a3704e0b 100644 --- a/GenHub/GenHub/Features/GameClients/GameClientDetector.cs +++ b/GenHub/GenHub/Features/GameClients/GameClientDetector.cs @@ -55,28 +55,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 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..c88a23e5a 100644 --- a/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs +++ b/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs @@ -7,6 +7,7 @@ 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 +51,79 @@ 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")); - - // Fetch manifest for this installation type - var manifest = await manifestProvider.GetManifestAsync(installation, cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - if (manifest == null) + // 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) { - 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.Generals, string.IsNullOrEmpty(installation.GeneralsPath) ? installation.InstallationPath : installation.GeneralsPath)); } - 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 + if (installation.HasZeroHour) { - var fullValidation = await contentValidator.ValidateAllAsync(installation.InstallationPath, manifest, progress, cancellationToken); - issues.AddRange(fullValidation.Issues); + passes.Add((GameType.ZeroHour, string.IsNullOrEmpty(installation.ZeroHourPath) ? installation.InstallationPath : installation.ZeroHourPath)); } - catch (Exception ex) + + if (passes.Count == 0) { - logger.LogError(ex, "Content validation failed for installation '{Path}'", installation.InstallationPath); - issues.Add(new ValidationIssue - { - IssueType = ValidationIssueType.CorruptedFile, - Path = installation.InstallationPath, - Message = $"Content validation failed: {ex.Message}", - Severity = ValidationSeverity.Error, - }); + // 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)); } - // Installation-specific validations (directories, etc.) - var requiredDirs = manifest.RequiredDirectories ?? Enumerable.Empty(); - if (requiredDirs.Any()) + // Four steps per pass: manifest fetch, manifest validation, content files, directories. + int totalSteps = passes.Count * 4; + int currentStep = 0; + + foreach (var (gameType, sourcePath) in passes) { - if (installation.HasGenerals) + 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) { - progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Validating Generals directories")); - issues.AddRange(await ValidateDirectoriesAsync(installation.GeneralsPath, requiredDirs, cancellationToken)); + issues.Add(new ValidationIssue { IssueType = ValidationIssueType.MissingFile, Path = sourcePath, Message = "Manifest not found for installation." }); + currentStep += 3; + continue; } - if (installation.HasZeroHour) + 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); + issues.AddRange(fullValidation.Issues); + } + catch (Exception ex) + { + 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, + }); + } + + // 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)); } } From cbe3d1dff5a03bce886ff997832bcde3b0db16a5 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Thu, 30 Jul 2026 15:08:35 +0100 Subject: [PATCH 2/3] fix(detection): scan launch candidates via classifier and tolerate sibling archives in combined-directory validation --- .../GameClients/GameClientDetectorTests.cs | 43 +++++++++-- .../GameInstallationValidatorTests.cs | 75 +++++++++++++++++++ .../Content/Services/ContentValidator.cs | 9 ++- .../Publishers/SuperHackersManifestFactory.cs | 43 +++++++++-- .../GameClients/GameClientDetector.cs | 25 ++++++- .../Validation/GameInstallationValidator.cs | 52 ++++++++++++- 6 files changed, 231 insertions(+), 16 deletions(-) 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 8fd7c3717..d5390438b 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientDetectorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientDetectorTests.cs @@ -620,11 +620,13 @@ public async Task DetectGameClientsFromInstallationsAsync_WithMissingGeneralsOnl /// /// 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. + /// 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_YieldsSingleClient() + public async Task DetectGameClientsFromInstallationsAsync_WithCombinedDirectory_YieldsSingleStandardClientAndSeesNativeBinary() { // Arrange var combinedPath = Path.Combine(_tempDirectory, "Combined"); @@ -632,6 +634,29 @@ public async Task DetectGameClientsFromInstallationsAsync_WithCombinedDirectory_ 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. + var nativeBinaryPath = Path.Combine(combinedPath, "GeneralsZH"); + await File.WriteAllTextAsync(nativeBinaryPath, "dummy content"); + + 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, @@ -659,13 +684,19 @@ public async Task DetectGameClientsFromInstallationsAsync_WithCombinedDirectory_ .ReturnsAsync(OperationResult.CreateSuccess(true)); // Act - var result = await _detector.DetectGameClientsFromInstallationsAsync(installations); + var result = await detector.DetectGameClientsFromInstallationsAsync(installations); // Assert Assert.True(result.Success); - var client = Assert.Single(result.Items); - Assert.Equal(GameType.ZeroHour, client.GameType); - Assert.Equal(executablePath, client.ExecutablePath); + 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); } /// 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 b5ae404d6..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; @@ -434,6 +437,78 @@ public async Task ValidateAsync_CombinedInstallation_ValidatesBothGames() } } + /// + /// 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/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..aaca3f599 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(ExecutableFileClassifier.IsLegacyLaunchCandidate); 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 4a3704e0b..820725791 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; @@ -191,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 , + /// today a name-based rule that keeps .exe results identical while admitting + /// extensionless files; a content-based (magic-byte) classification slots in at that + /// same call without this site changing. + /// + private static string[] GetLaunchCandidateFiles(string directoryPath) + { + return Directory.EnumerateFiles(directoryPath, "*", SearchOption.TopDirectoryOnly) + .Where(ExecutableFileClassifier.IsLegacyLaunchCandidate) + .ToArray(); + } + /// /// Detects a game client from a specific executable file using hash analysis. /// @@ -584,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) { @@ -760,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/Validation/GameInstallationValidator.cs b/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs index c88a23e5a..864a630d3 100644 --- a/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs +++ b/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs @@ -1,8 +1,10 @@ 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; @@ -73,6 +75,18 @@ public async Task ValidateAsync(GameInstallation installation, passes.Add((null, installation.InstallationPath)); } + // 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); + // Four steps per pass: manifest fetch, manifest validation, content files, directories. int totalSteps = passes.Count * 4; int currentStep = 0; @@ -103,7 +117,10 @@ public async Task ValidateAsync(GameInstallation installation, try { var fullValidation = await contentValidator.ValidateAllAsync(sourcePath, manifest, progress, cancellationToken); - issues.AddRange(fullValidation.Issues); + var contentIssues = isCombinedDirectory && gameType is not null + ? fullValidation.Issues.Where(issue => !IsSiblingGameRootArchive(issue, gameType.Value)) + : fullValidation.Issues; + issues.AddRange(contentIssues); } catch (Exception ex) { @@ -132,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); + } +} From 31f0b248db6001c2bdc0a836321f25750d508ffe Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Mon, 3 Aug 2026 13:53:01 +0100 Subject: [PATCH 3/3] fix(detection): classify launch candidates by content after the magic-byte split --- .../Features/GameClients/GameClientDetectorTests.cs | 3 ++- .../Services/Publishers/SuperHackersManifestFactory.cs | 2 +- .../GenHub/Features/GameClients/GameClientDetector.cs | 10 +++++----- 3 files changed, 8 insertions(+), 7 deletions(-) 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 d5390438b..fcdb7d3a9 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientDetectorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientDetectorTests.cs @@ -635,8 +635,9 @@ public async Task DetectGameClientsFromInstallationsAsync_WithCombinedDirectory_ 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.WriteAllTextAsync(nativeBinaryPath, "dummy content"); + await File.WriteAllBytesAsync(nativeBinaryPath, [0x7F, 0x45, 0x4C, 0x46, 0x02, 0x01, 0x01, 0x00]); var nativeIdentifierMock = new Mock(); nativeIdentifierMock.Setup(x => x.PublisherId).Returns("TestNativePublisher"); diff --git a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersManifestFactory.cs index aaca3f599..d89c9d7a4 100644 --- a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersManifestFactory.cs +++ b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersManifestFactory.cs @@ -227,7 +227,7 @@ private Dictionary DetectGameExecutables(string directory) return result; var allFiles = Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories) - .Where(ExecutableFileClassifier.IsLegacyLaunchCandidate); + .Where(path => ExecutableFileClassifier.IsLegacyLaunchCandidate(path, path)); foreach (var filePath in allFiles) { diff --git a/GenHub/GenHub/Features/GameClients/GameClientDetector.cs b/GenHub/GenHub/Features/GameClients/GameClientDetector.cs index 820725791..ffac74dfb 100644 --- a/GenHub/GenHub/Features/GameClients/GameClientDetector.cs +++ b/GenHub/GenHub/Features/GameClients/GameClientDetector.cs @@ -200,15 +200,15 @@ public Task ValidateGameClientAsync( /// /// 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 , - /// today a name-based rule that keeps .exe results identical while admitting - /// extensionless files; a content-based (magic-byte) classification slots in at that - /// same call without this site changing. + /// 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(ExecutableFileClassifier.IsLegacyLaunchCandidate) + .Where(path => ExecutableFileClassifier.IsLegacyLaunchCandidate(path, path)) .ToArray(); }