diff --git a/GenHub/GenHub.Core/Constants/LanguageFilePatterns.cs b/GenHub/GenHub.Core/Constants/LanguageFilePatterns.cs
index 7e8b42629..3685583a4 100644
--- a/GenHub/GenHub.Core/Constants/LanguageFilePatterns.cs
+++ b/GenHub/GenHub.Core/Constants/LanguageFilePatterns.cs
@@ -219,4 +219,14 @@ public static class LanguageFilePatterns
/// File pattern for Portuguese ZH BIG files: "PortugueseZH.big".
///
public const string PortugueseZHBig = "PortugueseZH.big";
+
+ ///
+ /// File pattern for Zero Hour audio BIG files: "AudioZH.big".
+ ///
+ public const string AudioZHBig = "AudioZH.big";
+
+ ///
+ /// Wildcard file pattern for any Zero Hour BIG files: "*ZH.big".
+ ///
+ public const string AnyZeroHourBig = "*ZH.big";
}
diff --git a/GenHub/GenHub.Core/Features/GameInstallations/LanguageDetector.cs b/GenHub/GenHub.Core/Features/GameInstallations/LanguageDetector.cs
index dcc7557ed..e8c84637b 100644
--- a/GenHub/GenHub.Core/Features/GameInstallations/LanguageDetector.cs
+++ b/GenHub/GenHub.Core/Features/GameInstallations/LanguageDetector.cs
@@ -1,3 +1,5 @@
+using GenHub.Core.Constants;
+using GenHub.Core.Models.Content;
using System;
using System.IO;
using System.Linq;
@@ -19,125 +21,148 @@ public class LanguageDetector : ILanguageDetector
/// The detected language code in uppercase (e.g., "EN", "DE"), or "EN" as fallback.
public Task DetectAsync(string installationPath, CancellationToken cancellationToken = default)
{
- if (!Directory.Exists(installationPath))
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (string.IsNullOrWhiteSpace(installationPath) || !Directory.Exists(installationPath))
{
- return Task.FromResult("EN"); // Fallback
+ return Task.FromResult(CsvConstants.LanguageEn);
}
- // Check for language-specific directories and files
- var languageMappings = new[]
+ // Check for language-specific directories
+ var directoryMappings = new (string RelativeDir, string Language)[]
{
- new { Pattern = "Data\\english", Language = "EN" },
- new { Pattern = "Data\\English", Language = "EN" },
- new { Pattern = "Data\\german", Language = "DE" },
- new { Pattern = "Data\\deutsch", Language = "DE" },
- new { Pattern = "Data\\french", Language = "FR" },
- new { Pattern = "Data\\spanish", Language = "ES" },
- new { Pattern = "Data\\italian", Language = "IT" },
- new { Pattern = "Data\\korean", Language = "KO" },
- new { Pattern = "Data\\polish", Language = "PL" },
- new { Pattern = "Data\\portuguese", Language = "PT-BR" },
- new { Pattern = "Data\\chinese", Language = "ZH-CN" },
- new { Pattern = "Data\\chinese-traditional", Language = "ZH-TW" },
+ (LanguageDirectoryNames.DataEnglish, CsvConstants.LanguageEn),
+ (LanguageDirectoryNames.DataEnglishUppercase, CsvConstants.LanguageEn),
+ (LanguageDirectoryNames.DataGerman, CsvConstants.LanguageDe),
+ (LanguageDirectoryNames.DataDeutsch, CsvConstants.LanguageDe),
+ (LanguageDirectoryNames.DataFrench, CsvConstants.LanguageFr),
+ (LanguageDirectoryNames.DataSpanish, CsvConstants.LanguageEs),
+ (LanguageDirectoryNames.DataItalian, CsvConstants.LanguageIt),
+ (LanguageDirectoryNames.DataKorean, CsvConstants.LanguageKo),
+ (LanguageDirectoryNames.DataPolish, CsvConstants.LanguagePl),
+ (LanguageDirectoryNames.DataPortuguese, CsvConstants.LanguagePtBr),
+ (LanguageDirectoryNames.DataChinese, CsvConstants.LanguageZhCn),
+ (LanguageDirectoryNames.DataChineseTraditional, CsvConstants.LanguageZhTw),
};
- foreach (var mapping in languageMappings)
+ foreach (var (relativeDir, language) in directoryMappings)
{
- if (Directory.Exists(Path.Combine(installationPath, mapping.Pattern)))
+ var dirPath = CombineRelativePath(installationPath, relativeDir);
+ if (Directory.Exists(dirPath))
{
- return Task.FromResult(mapping.Language);
+ return Task.FromResult(ContentSearchQuery.NormalizeLanguage(language));
}
}
// Check for language-specific files
- var fileMappings = new[]
+ var fileMappings = new (string FileName, string Language)[]
{
// English
- new { Pattern = "English.big", Language = "EN" },
- new { Pattern = "AudioEnglish.big", Language = "EN" },
- new { Pattern = "SpeechEnglish.big", Language = "EN" },
+ (LanguageFilePatterns.EnglishBig, CsvConstants.LanguageEn),
+ (LanguageFilePatterns.AudioEnglishBig, CsvConstants.LanguageEn),
+ (LanguageFilePatterns.SpeechEnglishBig, CsvConstants.LanguageEn),
// German
- new { Pattern = "German.big", Language = "DE" },
- new { Pattern = "AudioGerman.big", Language = "DE" },
+ (LanguageFilePatterns.GermanBig, CsvConstants.LanguageDe),
+ (LanguageFilePatterns.AudioGermanBig, CsvConstants.LanguageDe),
// French
- new { Pattern = "French.big", Language = "FR" },
- new { Pattern = "AudioFrench.big", Language = "FR" },
+ (LanguageFilePatterns.FrenchBig, CsvConstants.LanguageFr),
+ (LanguageFilePatterns.AudioFrenchBig, CsvConstants.LanguageFr),
// Spanish
- new { Pattern = "Spanish.big", Language = "ES" },
- new { Pattern = "AudioSpanish.big", Language = "ES" },
+ (LanguageFilePatterns.SpanishBig, CsvConstants.LanguageEs),
+ (LanguageFilePatterns.AudioSpanishBig, CsvConstants.LanguageEs),
// Italian
- new { Pattern = "Italian.big", Language = "IT" },
- new { Pattern = "AudioItalian.big", Language = "IT" },
+ (LanguageFilePatterns.ItalianBig, CsvConstants.LanguageIt),
+ (LanguageFilePatterns.AudioItalianBig, CsvConstants.LanguageIt),
// Korean
- new { Pattern = "Korean.big", Language = "KO" },
- new { Pattern = "AudioKorean.big", Language = "KO" },
+ (LanguageFilePatterns.KoreanBig, CsvConstants.LanguageKo),
+ (LanguageFilePatterns.AudioKoreanBig, CsvConstants.LanguageKo),
// Polish
- new { Pattern = "Polish.big", Language = "PL" },
- new { Pattern = "AudioPolish.big", Language = "PL" },
+ (LanguageFilePatterns.PolishBig, CsvConstants.LanguagePl),
+ (LanguageFilePatterns.AudioPolishBig, CsvConstants.LanguagePl),
// Portuguese-Brazil
- new { Pattern = "PortugueseBrazil.big", Language = "PT-BR" },
- new { Pattern = "AudioPortugueseBrazil.big", Language = "PT-BR" },
+ (LanguageFilePatterns.PortugueseBrazilBig, CsvConstants.LanguagePtBr),
+ (LanguageFilePatterns.AudioPortugueseBrazilBig, CsvConstants.LanguagePtBr),
// Chinese Simplified
- new { Pattern = "Chinese.big", Language = "ZH-CN" },
- new { Pattern = "AudioChinese.big", Language = "ZH-CN" },
+ (LanguageFilePatterns.ChineseBig, CsvConstants.LanguageZhCn),
+ (LanguageFilePatterns.AudioChineseBig, CsvConstants.LanguageZhCn),
// Chinese Traditional
- new { Pattern = "ChineseTraditional.big", Language = "ZH-TW" },
- new { Pattern = "AudioChineseTraditional.big", Language = "ZH-TW" },
+ (LanguageFilePatterns.ChineseTraditionalBig, CsvConstants.LanguageZhTw),
+ (LanguageFilePatterns.AudioChineseTraditionalBig, CsvConstants.LanguageZhTw),
};
- foreach (var mapping in fileMappings)
+ foreach (var (fileName, language) in fileMappings)
{
- if (File.Exists(Path.Combine(installationPath, mapping.Pattern)))
+ var filePath = Path.Combine(installationPath, fileName);
+ if (File.Exists(filePath))
{
- return Task.FromResult(mapping.Language);
+ return Task.FromResult(ContentSearchQuery.NormalizeLanguage(language));
}
}
// Check for Zero Hour specific patterns
- var zhPatterns = new[]
+ var zhPatterns = new (string Pattern, string Language)[]
{
- new { Pattern = "EnglishZH.big", Language = "EN" },
- new { Pattern = "AudioZH.big", Language = "EN" },
- new { Pattern = "INIZH.big", Language = "EN" },
- new { Pattern = "*ZH.big", Language = "EN" }, // Generic ZH files
- new { Pattern = "GeneralsOnlineZH", Language = "EN" }, // Executables
- new { Pattern = "GermanZH.big", Language = "DE" },
- new { Pattern = "FrenchZH.big", Language = "FR" },
- new { Pattern = "SpanishZH.big", Language = "ES" },
- new { Pattern = "ItalianZH.big", Language = "IT" },
- new { Pattern = "KoreanZH.big", Language = "KO" },
- new { Pattern = "PolishZH.big", Language = "PL" },
- new { Pattern = "PortugueseZH.big", Language = "PT-BR" },
- new { Pattern = "ChineseZH.big", Language = "ZH-CN" },
+ (LanguageFilePatterns.EnglishZHBig, CsvConstants.LanguageEn),
+ (LanguageFilePatterns.AudioZHBig, CsvConstants.LanguageEn),
+ (GameClientConstants.ZeroHourIniBig, CsvConstants.LanguageEn),
+ (LanguageFilePatterns.GermanZHBig, CsvConstants.LanguageDe),
+ (LanguageFilePatterns.FrenchZHBig, CsvConstants.LanguageFr),
+ (LanguageFilePatterns.SpanishZHBig, CsvConstants.LanguageEs),
+ (LanguageFilePatterns.ItalianZHBig, CsvConstants.LanguageIt),
+ (LanguageFilePatterns.KoreanZHBig, CsvConstants.LanguageKo),
+ (LanguageFilePatterns.PolishZHBig, CsvConstants.LanguagePl),
+ (LanguageFilePatterns.PortugueseZHBig, CsvConstants.LanguagePtBr),
+ (LanguageFilePatterns.ChineseZHBig, CsvConstants.LanguageZhCn),
+ (LanguageFilePatterns.AnyZeroHourBig, CsvConstants.LanguageEn),
};
- foreach (var mapping in zhPatterns)
+ foreach (var (pattern, language) in zhPatterns)
{
- if (mapping.Pattern.Contains("*"))
+ if (pattern.Contains('*'))
{
- // Handle wildcard
- var files = Directory.GetFiles(installationPath, mapping.Pattern, SearchOption.AllDirectories);
- if (files.Length > 0)
+ try
+ {
+ var files = Directory.GetFiles(installationPath, pattern, SearchOption.AllDirectories);
+ if (files.Length > 0)
+ {
+ return Task.FromResult(ContentSearchQuery.NormalizeLanguage(language));
+ }
+ }
+ catch (IOException)
+ {
+ // Fall through on IO issues
+ }
+ catch (UnauthorizedAccessException)
{
- return Task.FromResult(mapping.Language);
+ // Fall through on permission issues
}
}
- else if (File.Exists(Path.Combine(installationPath, mapping.Pattern)))
+ else
{
- return Task.FromResult(mapping.Language);
+ var filePath = Path.Combine(installationPath, pattern);
+ if (File.Exists(filePath))
+ {
+ return Task.FromResult(ContentSearchQuery.NormalizeLanguage(language));
+ }
}
}
// Fallback to English
- return Task.FromResult("EN");
+ return Task.FromResult(CsvConstants.LanguageEn);
+ }
+
+ private static string CombineRelativePath(string basePath, string relativePath)
+ {
+ var segments = relativePath.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries);
+ return Path.Combine(segments.Prepend(basePath).ToArray());
}
}
diff --git a/GenHub/GenHub.Core/Interfaces/Validation/IGameInstallationValidator.cs b/GenHub/GenHub.Core/Interfaces/Validation/IGameInstallationValidator.cs
index 47d9f14f7..60f2a6648 100644
--- a/GenHub/GenHub.Core/Interfaces/Validation/IGameInstallationValidator.cs
+++ b/GenHub/GenHub.Core/Interfaces/Validation/IGameInstallationValidator.cs
@@ -1,3 +1,7 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Models.Enums;
using GenHub.Core.Models.GameInstallations;
using GenHub.Core.Models.Results;
using GenHub.Core.Models.Validation;
@@ -24,5 +28,31 @@ public interface IGameInstallationValidator
/// Progress reporter for MVVM integration.
/// A cancellation token.
/// A representing the outcome of the validation.
- Task ValidateAsync(GameInstallation installation, IProgress? progress = null, CancellationToken cancellationToken = default);
+ Task ValidateAsync(GameInstallation installation, IProgress? progress, CancellationToken cancellationToken = default);
+
+ ///
+ /// Validates a game installation with an explicit language and optional progress reporting.
+ ///
+ /// The game installation to validate.
+ /// The explicit language code (e.g., "EN", "DE").
+ /// Progress reporter for MVVM integration.
+ /// A cancellation token.
+ /// A representing the outcome of the validation.
+ Task ValidateAsync(GameInstallation installation, string language, IProgress? progress = null, CancellationToken cancellationToken = default);
+
+ ///
+ /// Validates a specific game installation directory by path, game type, and optional language.
+ ///
+ /// The path to the game directory.
+ /// The target game type (Generals or ZeroHour).
+ /// Optional explicit language code. If null, language is auto-detected.
+ /// Progress reporter for MVVM integration.
+ /// A cancellation token.
+ /// A representing the outcome of the validation.
+ Task ValidateInstallationAsync(
+ string installationPath,
+ GameType gameType,
+ string? language = null,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default);
}
\ No newline at end of file
diff --git a/GenHub/GenHub.Core/Models/Results/Validation/ValidationResult.cs b/GenHub/GenHub.Core/Models/Results/Validation/ValidationResult.cs
index 910df9aaa..ff0cf6611 100644
--- a/GenHub/GenHub.Core/Models/Results/Validation/ValidationResult.cs
+++ b/GenHub/GenHub.Core/Models/Results/Validation/ValidationResult.cs
@@ -5,7 +5,7 @@ namespace GenHub.Core.Models.Results;
using GenHub.Core.Models.Validation;
/// Encapsulates the result of a validation operation for a game version or installation.
-public class ValidationResult(string validatedTargetId, List? issues, TimeSpan elapsed = default)
+public class ValidationResult(string validatedTargetId, List? issues, TimeSpan elapsed = default, int totalFilesValidated = 0)
: ResultBase(DetermineSuccess(issues), ExtractErrorMessages(issues), elapsed)
{
/// Gets the unique ID of the target that was validated (e.g., a GameClient ID or a GameInstallation ID).
@@ -17,6 +17,18 @@ public class ValidationResult(string validatedTargetId, List? i
/// Gets a value indicating whether the target is considered valid.
public bool IsValid => Success;
+ /// Gets the total number of files validated.
+ public int TotalFilesValidated { get; init; } = totalFilesValidated;
+
+ /// Gets the count of missing files.
+ public int MissingFilesCount => Issues.Count(i => i.IssueType == ValidationIssueType.MissingFile);
+
+ /// Gets the count of corrupted or size-mismatched files.
+ public int CorruptedFilesCount => Issues.Count(i => i.IssueType == ValidationIssueType.CorruptedFile || i.IssueType == ValidationIssueType.MismatchedFileSize);
+
+ /// Gets the count of extra or unexpected files.
+ public int ExtraFilesCount => Issues.Count(i => i.IssueType == ValidationIssueType.UnexpectedFile);
+
/// Gets the count of critical issues that prevent the target from being considered valid.
public int CriticalIssueCount => Issues.Count(i => i.Severity == ValidationSeverity.Error || i.Severity == ValidationSeverity.Critical);
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/LanguageDetectorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/LanguageDetectorTests.cs
new file mode 100644
index 000000000..cb06e2c3b
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/LanguageDetectorTests.cs
@@ -0,0 +1,179 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Features.GameInstallations;
+using Xunit;
+
+namespace GenHub.Tests.Features.GameInstallations;
+
+///
+/// Unit tests for LanguageDetector.
+///
+public class LanguageDetectorTests
+{
+ private readonly LanguageDetector _detector = new();
+
+ ///
+ /// Tests that invalid or non-existent paths return English fallback.
+ ///
+ /// The invalid path to test.
+ /// A representing the asynchronous unit test.
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("non_existent_directory_xyz_123")]
+ public async Task DetectAsync_WithInvalidPath_ReturnsEnglishFallbackAsync(string? path)
+ {
+ var result = await _detector.DetectAsync(path!);
+ Assert.Equal(CsvConstants.LanguageEn, result);
+ }
+
+ ///
+ /// Tests that a cancelled token throws OperationCanceledException.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task DetectAsync_WithCancelledToken_ThrowsOperationCanceledExceptionAsync()
+ {
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ await Assert.ThrowsAsync(() => _detector.DetectAsync("some_path", cts.Token));
+ }
+
+ ///
+ /// Tests that language directory presence detects the corresponding language code.
+ ///
+ /// The relative directory name.
+ /// The expected detected language code.
+ /// A representing the asynchronous unit test.
+ [Theory]
+ [InlineData(LanguageDirectoryNames.DataEnglish, CsvConstants.LanguageEn)]
+ [InlineData(LanguageDirectoryNames.DataEnglishUppercase, CsvConstants.LanguageEn)]
+ [InlineData(LanguageDirectoryNames.DataGerman, CsvConstants.LanguageDe)]
+ [InlineData(LanguageDirectoryNames.DataDeutsch, CsvConstants.LanguageDe)]
+ [InlineData(LanguageDirectoryNames.DataFrench, CsvConstants.LanguageFr)]
+ [InlineData(LanguageDirectoryNames.DataSpanish, CsvConstants.LanguageEs)]
+ [InlineData(LanguageDirectoryNames.DataItalian, CsvConstants.LanguageIt)]
+ [InlineData(LanguageDirectoryNames.DataKorean, CsvConstants.LanguageKo)]
+ [InlineData(LanguageDirectoryNames.DataPolish, CsvConstants.LanguagePl)]
+ [InlineData(LanguageDirectoryNames.DataPortuguese, CsvConstants.LanguagePtBr)]
+ [InlineData(LanguageDirectoryNames.DataChinese, CsvConstants.LanguageZhCn)]
+ [InlineData(LanguageDirectoryNames.DataChineseTraditional, CsvConstants.LanguageZhTw)]
+ public async Task DetectAsync_WithLanguageDirectory_DetectsCorrectLanguageAsync(string relativeDir, string expectedLanguage)
+ {
+ var tempDir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var segments = relativeDir.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries);
+ var dirPath = Path.Combine(segments.Prepend(tempDir.FullName).ToArray());
+ Directory.CreateDirectory(dirPath);
+
+ var result = await _detector.DetectAsync(tempDir.FullName);
+ Assert.Equal(expectedLanguage, result);
+ }
+ finally
+ {
+ tempDir.Delete(true);
+ }
+ }
+
+ ///
+ /// Tests that language-specific BIG files detect the corresponding language code.
+ ///
+ /// The BIG file name.
+ /// The expected detected language code.
+ /// A representing the asynchronous unit test.
+ [Theory]
+ [InlineData(LanguageFilePatterns.GermanBig, CsvConstants.LanguageDe)]
+ [InlineData(LanguageFilePatterns.AudioGermanBig, CsvConstants.LanguageDe)]
+ [InlineData(LanguageFilePatterns.FrenchBig, CsvConstants.LanguageFr)]
+ [InlineData(LanguageFilePatterns.AudioFrenchBig, CsvConstants.LanguageFr)]
+ [InlineData(LanguageFilePatterns.SpanishBig, CsvConstants.LanguageEs)]
+ [InlineData(LanguageFilePatterns.AudioSpanishBig, CsvConstants.LanguageEs)]
+ [InlineData(LanguageFilePatterns.ItalianBig, CsvConstants.LanguageIt)]
+ [InlineData(LanguageFilePatterns.AudioItalianBig, CsvConstants.LanguageIt)]
+ [InlineData(LanguageFilePatterns.KoreanBig, CsvConstants.LanguageKo)]
+ [InlineData(LanguageFilePatterns.AudioKoreanBig, CsvConstants.LanguageKo)]
+ [InlineData(LanguageFilePatterns.PolishBig, CsvConstants.LanguagePl)]
+ [InlineData(LanguageFilePatterns.AudioPolishBig, CsvConstants.LanguagePl)]
+ [InlineData(LanguageFilePatterns.PortugueseBrazilBig, CsvConstants.LanguagePtBr)]
+ [InlineData(LanguageFilePatterns.AudioPortugueseBrazilBig, CsvConstants.LanguagePtBr)]
+ [InlineData(LanguageFilePatterns.ChineseBig, CsvConstants.LanguageZhCn)]
+ [InlineData(LanguageFilePatterns.AudioChineseBig, CsvConstants.LanguageZhCn)]
+ [InlineData(LanguageFilePatterns.ChineseTraditionalBig, CsvConstants.LanguageZhTw)]
+ [InlineData(LanguageFilePatterns.AudioChineseTraditionalBig, CsvConstants.LanguageZhTw)]
+ public async Task DetectAsync_WithLanguageBigFile_DetectsCorrectLanguageAsync(string fileName, string expectedLanguage)
+ {
+ var tempDir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var filePath = Path.Combine(tempDir.FullName, fileName);
+ await File.WriteAllTextAsync(filePath, "dummy big content");
+
+ var result = await _detector.DetectAsync(tempDir.FullName);
+ Assert.Equal(expectedLanguage, result);
+ }
+ finally
+ {
+ tempDir.Delete(true);
+ }
+ }
+
+ ///
+ /// Tests that Zero Hour specific language BIG files detect the corresponding language code.
+ ///
+ /// The Zero Hour BIG file name.
+ /// The expected detected language code.
+ /// A representing the asynchronous unit test.
+ [Theory]
+ [InlineData(LanguageFilePatterns.GermanZHBig, CsvConstants.LanguageDe)]
+ [InlineData(LanguageFilePatterns.FrenchZHBig, CsvConstants.LanguageFr)]
+ [InlineData(LanguageFilePatterns.SpanishZHBig, CsvConstants.LanguageEs)]
+ [InlineData(LanguageFilePatterns.ItalianZHBig, CsvConstants.LanguageIt)]
+ [InlineData(LanguageFilePatterns.KoreanZHBig, CsvConstants.LanguageKo)]
+ [InlineData(LanguageFilePatterns.PolishZHBig, CsvConstants.LanguagePl)]
+ [InlineData(LanguageFilePatterns.PortugueseZHBig, CsvConstants.LanguagePtBr)]
+ [InlineData(LanguageFilePatterns.ChineseZHBig, CsvConstants.LanguageZhCn)]
+ [InlineData(LanguageFilePatterns.EnglishZHBig, CsvConstants.LanguageEn)]
+ public async Task DetectAsync_WithZeroHourPatterns_DetectsCorrectLanguageAsync(string fileName, string expectedLanguage)
+ {
+ var tempDir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var filePath = Path.Combine(tempDir.FullName, fileName);
+ await File.WriteAllTextAsync(filePath, "dummy zh big content");
+
+ var result = await _detector.DetectAsync(tempDir.FullName);
+ Assert.Equal(expectedLanguage, result);
+ }
+ finally
+ {
+ tempDir.Delete(true);
+ }
+ }
+
+ ///
+ /// Tests that directory containing only unknown files falls back to English.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task DetectAsync_WithUnknownFiles_FallsBackToEnglishAsync()
+ {
+ var tempDir = Directory.CreateTempSubdirectory();
+ try
+ {
+ await File.WriteAllTextAsync(Path.Combine(tempDir.FullName, "random_mod_file.big"), "data");
+
+ var result = await _detector.DetectAsync(tempDir.FullName);
+ Assert.Equal(CsvConstants.LanguageEn, result);
+ }
+ finally
+ {
+ tempDir.Delete(true);
+ }
+ }
+}
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 bdb971a03..483e9bb2d 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/GameInstallationValidatorTests.cs
@@ -1,14 +1,27 @@
+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.Features.GameInstallations;
using GenHub.Core.Interfaces.Common;
using GenHub.Core.Interfaces.Content;
using GenHub.Core.Interfaces.Manifest;
+using GenHub.Core.Models.Content;
using GenHub.Core.Models.Enums;
using GenHub.Core.Models.GameInstallations;
using GenHub.Core.Models.Manifest;
using GenHub.Core.Models.Results;
+using GenHub.Core.Models.Results.Content;
using GenHub.Core.Models.Validation;
using GenHub.Features.Validation;
using Microsoft.Extensions.Logging;
using Moq;
+using Xunit;
+using ContentType = GenHub.Core.Models.Enums.ContentType;
+using GameType = GenHub.Core.Models.Enums.GameType;
namespace GenHub.Tests.Features.Validation;
@@ -388,6 +401,513 @@ public async Task ValidateAsync_ContentValidatorException_HandlesGracefullyAsync
}
}
+ ///
+ /// Tests that ValidateAsync validates a multi-language installation using CsvContentProvider.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task ValidateAsync_WithCsvContentProvider_ValidatesMultiLanguageInstallationSuccessfullyAsync()
+ {
+ var tempDir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var manifest = new ContentManifest
+ {
+ Id = new ManifestId("csv-generals-1.08-de"),
+ Name = "Generals 1.08 (DE)",
+ Version = "1.08",
+ ContentType = ContentType.GameInstallation,
+ TargetGame = GameType.Generals,
+ Files = new List
+ {
+ new() { RelativePath = "generals.exe", Size = 100, Hash = "abc", SourceType = ContentSourceType.GameInstallation, IsRequired = true },
+ new() { RelativePath = "German.big", Size = 200, Hash = "def", SourceType = ContentSourceType.GameInstallation, IsRequired = true },
+ },
+ };
+
+ var searchResult = new ContentSearchResult
+ {
+ Id = "csv-generals-1.08-de",
+ Name = "Generals 1.08 (DE)",
+ Version = "1.08",
+ ContentType = ContentType.GameInstallation,
+ TargetGame = GameType.Generals,
+ };
+ searchResult.SetData(manifest);
+
+ var mockContentProvider = new Mock();
+ mockContentProvider.Setup(p => p.SourceName).Returns(PublisherTypeConstants.CsvRegistry);
+ mockContentProvider
+ .Setup(p => p.SearchAsync(It.Is(q => q.TargetGame == GameType.Generals && q.Language == CsvConstants.LanguageDe), It.IsAny()))
+ .ReturnsAsync(OperationResult>.CreateSuccess([searchResult]));
+
+ var mockLanguageDetector = new Mock();
+ mockLanguageDetector
+ .Setup(d => d.DetectAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(CsvConstants.LanguageDe);
+
+ var validator = new GameInstallationValidator(
+ _loggerMock.Object,
+ null,
+ _contentValidatorMock.Object,
+ _hashProviderMock.Object,
+ mockLanguageDetector.Object,
+ null,
+ [mockContentProvider.Object]);
+
+ var installation = new GameInstallation(
+ tempDir.FullName,
+ GameInstallationType.Steam,
+ new Mock>().Object);
+ installation.SetPaths(tempDir.FullName, null);
+
+ var result = await validator.ValidateAsync(installation, CancellationToken.None);
+
+ Assert.True(result.IsValid);
+ Assert.Equal(2, result.TotalFilesValidated);
+ Assert.Empty(result.Issues);
+ mockContentProvider.Verify(
+ p => p.SearchAsync(It.Is(q => q.TargetGame == GameType.Generals && q.Language == CsvConstants.LanguageDe), It.IsAny()),
+ Times.Once);
+ }
+ finally
+ {
+ tempDir.Delete(true);
+ }
+ }
+
+ ///
+ /// Tests that ValidateAsync with an explicit language overrides auto-detection.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task ValidateAsync_WithExplicitLanguage_OverridesAutoDetectionAsync()
+ {
+ var tempDir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var manifest = new ContentManifest
+ {
+ Id = new ManifestId("csv-generals-1.08-fr"),
+ Name = "Generals 1.08 (FR)",
+ Version = "1.08",
+ ContentType = ContentType.GameInstallation,
+ TargetGame = GameType.Generals,
+ Files = [new ManifestFile { RelativePath = "French.big", Size = 100, Hash = "abc", SourceType = ContentSourceType.GameInstallation }],
+ };
+
+ var searchResult = new ContentSearchResult { Id = "csv-generals-1.08-fr" };
+ searchResult.SetData(manifest);
+
+ var mockContentProvider = new Mock();
+ mockContentProvider.Setup(p => p.SourceName).Returns(PublisherTypeConstants.CsvRegistry);
+ mockContentProvider
+ .Setup(p => p.SearchAsync(It.Is(q => q.Language == CsvConstants.LanguageFr), It.IsAny()))
+ .ReturnsAsync(OperationResult>.CreateSuccess([searchResult]));
+
+ var mockLanguageDetector = new Mock();
+ mockLanguageDetector
+ .Setup(d => d.DetectAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(CsvConstants.LanguageDe); // Auto-detect would say DE, but explicit is FR
+
+ var validator = new GameInstallationValidator(
+ _loggerMock.Object,
+ null,
+ _contentValidatorMock.Object,
+ _hashProviderMock.Object,
+ mockLanguageDetector.Object,
+ null,
+ [mockContentProvider.Object]);
+
+ var installation = new GameInstallation(
+ tempDir.FullName,
+ GameInstallationType.Steam,
+ new Mock>().Object);
+ installation.SetPaths(tempDir.FullName, null);
+
+ var result = await validator.ValidateAsync(installation, "fr");
+
+ Assert.True(result.IsValid);
+ Assert.Equal(1, result.TotalFilesValidated);
+ mockContentProvider.Verify(
+ p => p.SearchAsync(It.Is(q => q.Language == CsvConstants.LanguageFr), It.IsAny()),
+ Times.Once);
+ mockLanguageDetector.Verify(d => d.DetectAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+ finally
+ {
+ tempDir.Delete(true);
+ }
+ }
+
+ ///
+ /// Tests that ValidateInstallationAsync validates direct path and game type with language normalization.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task ValidateInstallationAsync_DirectPathAndGameType_ResolvesAndValidatesAsync()
+ {
+ var tempDir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var manifest = new ContentManifest
+ {
+ Id = new ManifestId("csv-zerohour-1.04-zh-cn"),
+ Name = "Zero Hour 1.04 (ZH-CN)",
+ Version = "1.04",
+ ContentType = ContentType.GameInstallation,
+ TargetGame = GameType.ZeroHour,
+ Files = [new ManifestFile { RelativePath = "ChineseZH.big", Size = 50, Hash = "xyz", SourceType = ContentSourceType.GameInstallation }],
+ };
+
+ var searchResult = new ContentSearchResult { Id = "csv-zerohour-1.04-zh-cn" };
+ searchResult.SetData(manifest);
+
+ var mockContentProvider = new Mock();
+ mockContentProvider.Setup(p => p.SourceName).Returns(PublisherTypeConstants.CsvRegistry);
+ mockContentProvider
+ .Setup(p => p.SearchAsync(It.Is(q => q.TargetGame == GameType.ZeroHour && q.Language == CsvConstants.LanguageZhCn), It.IsAny()))
+ .ReturnsAsync(OperationResult>.CreateSuccess([searchResult]));
+
+ var validator = new GameInstallationValidator(
+ _loggerMock.Object,
+ null,
+ _contentValidatorMock.Object,
+ _hashProviderMock.Object,
+ new LanguageDetector(),
+ null,
+ [mockContentProvider.Object]);
+
+ var result = await validator.ValidateInstallationAsync(tempDir.FullName, GameType.ZeroHour, "zh-cn");
+
+ Assert.True(result.IsValid);
+ Assert.Equal(1, result.TotalFilesValidated);
+ mockContentProvider.Verify(
+ p => p.SearchAsync(It.Is(q => q.TargetGame == GameType.ZeroHour && q.Language == CsvConstants.LanguageZhCn), It.IsAny()),
+ Times.Once);
+ }
+ finally
+ {
+ tempDir.Delete(true);
+ }
+ }
+
+ ///
+ /// Tests that ValidateAsync reports detailed issue counts on ValidationResult.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task ValidateAsync_DetailedCounts_ReportsCorrectMissingCorruptedAndExtraCountsAsync()
+ {
+ var tempDir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var manifest = new ContentManifest
+ {
+ Id = new ManifestId("csv-generals-1.08-en"),
+ Files =
+ [
+ new ManifestFile { RelativePath = "missing1.txt", Size = 10, Hash = "h1" },
+ new ManifestFile { RelativePath = "corrupted1.txt", Size = 20, Hash = "h2" },
+ new ManifestFile { RelativePath = "valid1.txt", Size = 30, Hash = "h3" },
+ ],
+ };
+
+ var searchResult = new ContentSearchResult { Id = "csv-generals-1.08-en" };
+ searchResult.SetData(manifest);
+
+ var mockContentProvider = new Mock();
+ mockContentProvider.Setup(p => p.SourceName).Returns(PublisherTypeConstants.CsvRegistry);
+ mockContentProvider
+ .Setup(p => p.SearchAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult>.CreateSuccess([searchResult]));
+
+ var mockContentValidator = new Mock();
+ mockContentValidator
+ .Setup(c => c.ValidateManifestAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new ValidationResult("test", []));
+
+ mockContentValidator
+ .Setup(c => c.ValidateAllAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new ValidationResult(
+ "test",
+ [
+ new ValidationIssue { IssueType = ValidationIssueType.MissingFile, Message = "Missing file 1", Severity = ValidationSeverity.Error },
+ new ValidationIssue { IssueType = ValidationIssueType.CorruptedFile, Message = "Corrupted file 1", Severity = ValidationSeverity.Error },
+ new ValidationIssue { IssueType = ValidationIssueType.MismatchedFileSize, Message = "Size mismatch", Severity = ValidationSeverity.Warning },
+ new ValidationIssue { IssueType = ValidationIssueType.UnexpectedFile, Message = "Extra file 1", Severity = ValidationSeverity.Warning },
+ new ValidationIssue { IssueType = ValidationIssueType.UnexpectedFile, Message = "Extra file 2", Severity = ValidationSeverity.Warning },
+ ]));
+
+ var validator = new GameInstallationValidator(
+ _loggerMock.Object,
+ null,
+ mockContentValidator.Object,
+ _hashProviderMock.Object,
+ null,
+ null,
+ [mockContentProvider.Object]);
+
+ var installation = new GameInstallation(
+ tempDir.FullName,
+ GameInstallationType.Steam,
+ new Mock>().Object);
+ installation.SetPaths(tempDir.FullName, null);
+
+ var result = await validator.ValidateAsync(installation, CancellationToken.None);
+
+ Assert.False(result.IsValid);
+ Assert.Equal(3, result.TotalFilesValidated);
+ Assert.Equal(1, result.MissingFilesCount);
+ Assert.Equal(2, result.CorruptedFilesCount); // CorruptedFile + MismatchedFileSize
+ Assert.Equal(2, result.ExtraFilesCount);
+ Assert.Equal(2, result.CriticalIssueCount);
+ Assert.Equal(3, result.WarningIssueCount);
+ }
+ finally
+ {
+ tempDir.Delete(true);
+ }
+ }
+
+ ///
+ /// Tests that ValidateAsync returns a language-specific error message when CSV provider search fails.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task ValidateAsync_WithCsvProviderFailure_ReturnsLanguageSpecificErrorMessageAsync()
+ {
+ var tempDir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var mockContentProvider = new Mock();
+ mockContentProvider.Setup(p => p.SourceName).Returns(PublisherTypeConstants.CsvRegistry);
+ mockContentProvider
+ .Setup(p => p.SearchAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult>.CreateFailure("Network timeout"));
+
+ var validator = new GameInstallationValidator(
+ _loggerMock.Object,
+ null,
+ _contentValidatorMock.Object,
+ _hashProviderMock.Object,
+ null,
+ null,
+ [mockContentProvider.Object]);
+
+ var installation = new GameInstallation(
+ tempDir.FullName,
+ GameInstallationType.Steam,
+ new Mock>().Object);
+ installation.SetPaths(tempDir.FullName, null);
+
+ var result = await validator.ValidateAsync(installation, "PL");
+
+ Assert.False(result.IsValid);
+ Assert.Contains(result.Issues, i => i.Message.Contains("PL") && i.Message.Contains("Network timeout"));
+ }
+ finally
+ {
+ tempDir.Delete(true);
+ }
+ }
+
+ ///
+ /// Tests multi-language normalization and support for all supported language codes.
+ ///
+ /// The raw input language code.
+ /// The expected normalized uppercase language code.
+ /// A representing the asynchronous unit test.
+ [Theory]
+ [InlineData("en", CsvConstants.LanguageEn)]
+ [InlineData("de", CsvConstants.LanguageDe)]
+ [InlineData("fr", CsvConstants.LanguageFr)]
+ [InlineData("es", CsvConstants.LanguageEs)]
+ [InlineData("it", CsvConstants.LanguageIt)]
+ [InlineData("ko", CsvConstants.LanguageKo)]
+ [InlineData("pl", CsvConstants.LanguagePl)]
+ [InlineData("pt-br", CsvConstants.LanguagePtBr)]
+ [InlineData("zh-cn", CsvConstants.LanguageZhCn)]
+ [InlineData("zh-tw", CsvConstants.LanguageZhTw)]
+ public async Task ValidateAsync_MultiLanguageSupport_NormalizesLanguageAndValidatesAsync(string inputLanguage, string expectedNormalized)
+ {
+ var tempDir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var manifest = new ContentManifest
+ {
+ Id = new ManifestId($"csv-generals-1.08-{inputLanguage}"),
+ Files = [new ManifestFile { RelativePath = "test.txt", Size = 10, Hash = "h" }],
+ };
+
+ var searchResult = new ContentSearchResult { Id = $"csv-generals-1.08-{inputLanguage}" };
+ searchResult.SetData(manifest);
+
+ var mockContentProvider = new Mock();
+ mockContentProvider.Setup(p => p.SourceName).Returns(PublisherTypeConstants.CsvRegistry);
+ mockContentProvider
+ .Setup(p => p.SearchAsync(It.Is(q => q.Language == expectedNormalized), It.IsAny()))
+ .ReturnsAsync(OperationResult>.CreateSuccess([searchResult]));
+
+ var validator = new GameInstallationValidator(
+ _loggerMock.Object,
+ null,
+ _contentValidatorMock.Object,
+ _hashProviderMock.Object,
+ null,
+ null,
+ [mockContentProvider.Object]);
+
+ var installation = new GameInstallation(
+ tempDir.FullName,
+ GameInstallationType.Steam,
+ new Mock>().Object);
+ installation.SetPaths(tempDir.FullName, null);
+
+ var result = await validator.ValidateAsync(installation, inputLanguage);
+
+ Assert.True(result.IsValid);
+ mockContentProvider.Verify(
+ p => p.SearchAsync(It.Is(q => q.Language == expectedNormalized), It.IsAny()),
+ Times.Once);
+ }
+ finally
+ {
+ tempDir.Delete(true);
+ }
+ }
+
+ ///
+ /// Tests that ValidateAsync throws ArgumentNullException when installation is null.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task ValidateAsync_NullInstallation_ThrowsArgumentNullExceptionAsync()
+ {
+ await Assert.ThrowsAsync(() => _validator.ValidateAsync(null!, CancellationToken.None));
+ }
+
+ ///
+ /// Tests that when CSV provider fails to find a manifest, fallback to IManifestProvider succeeds without retaining CSV failure issues.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task ValidateAsync_CsvFails_FallbackManifestProviderSucceeds_DoesNotPreserveCsvFailureAsync()
+ {
+ var tempDir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var fallbackManifest = new ContentManifest
+ {
+ Id = new ManifestId("fallback-manifest"),
+ Name = "Fallback Manifest",
+ Version = "1.0",
+ Files = [new ManifestFile { RelativePath = "test.big", Size = 50, Hash = "abc" }],
+ };
+
+ var mockManifestProvider = new Mock();
+ mockManifestProvider
+ .Setup(m => m.GetManifestAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(fallbackManifest);
+
+ var mockContentProvider = new Mock();
+ mockContentProvider.Setup(p => p.SourceName).Returns(PublisherTypeConstants.CsvRegistry);
+ mockContentProvider
+ .Setup(p => p.SearchAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult>.CreateFailure("Catalog not found"));
+
+ _contentValidatorMock
+ .Setup(c => c.ValidateAllAsync(It.IsAny(), fallbackManifest, It.IsAny>(), It.IsAny()))
+ .ReturnsAsync(new ValidationResult(tempDir.FullName, [], TimeSpan.FromSeconds(1), 1));
+
+ var validator = new GameInstallationValidator(
+ _loggerMock.Object,
+ mockManifestProvider.Object,
+ _contentValidatorMock.Object,
+ _hashProviderMock.Object,
+ null,
+ null,
+ [mockContentProvider.Object]);
+
+ var installation = new GameInstallation(
+ tempDir.FullName,
+ GameInstallationType.Steam,
+ new Mock>().Object);
+ installation.SetPaths(tempDir.FullName, null);
+
+ var result = await validator.ValidateAsync(installation);
+
+ Assert.True(result.IsValid);
+ Assert.Empty(result.Issues);
+ Assert.Equal(1, result.TotalFilesValidated);
+ }
+ finally
+ {
+ tempDir.Delete(true);
+ }
+ }
+
+ ///
+ /// Tests that when content validator throws an exception, TotalFilesValidated reports 0 rather than full manifest count.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task ValidateAsync_ContentValidatorThrows_ReportsZeroTotalFilesValidatedAsync()
+ {
+ var tempDir = Directory.CreateTempSubdirectory();
+ try
+ {
+ var manifest = new ContentManifest
+ {
+ Id = new ManifestId("test-manifest"),
+ Name = "Test Manifest",
+ Files =
+ [
+ new ManifestFile { RelativePath = "file1.big", Size = 10, Hash = "h1" },
+ new ManifestFile { RelativePath = "file2.big", Size = 20, Hash = "h2" },
+ ],
+ };
+
+ var mockManifestProvider = new Mock();
+ mockManifestProvider
+ .Setup(m => m.GetManifestAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(manifest);
+
+ _contentValidatorMock
+ .Setup(c => c.ValidateAllAsync(It.IsAny(), manifest, It.IsAny>(), It.IsAny()))
+ .ThrowsAsync(new IOException("Disk read error"));
+
+ var validator = new GameInstallationValidator(
+ _loggerMock.Object,
+ mockManifestProvider.Object,
+ _contentValidatorMock.Object,
+ _hashProviderMock.Object,
+ null,
+ null,
+ null);
+
+ var installation = new GameInstallation(
+ tempDir.FullName,
+ GameInstallationType.Steam,
+ new Mock>().Object);
+ installation.SetPaths(tempDir.FullName, null);
+
+ var result = await validator.ValidateAsync(installation);
+
+ Assert.False(result.IsValid);
+ Assert.Equal(0, result.TotalFilesValidated);
+ Assert.Contains(result.Issues, i => i.Message.Contains("Disk read error"));
+ }
+ 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..481a8fcb3 100644
--- a/GenHub/GenHub/Features/Content/Services/ContentValidator.cs
+++ b/GenHub/GenHub/Features/Content/Services/ContentValidator.cs
@@ -84,7 +84,7 @@ public async Task ValidateAllAsync(string contentPath, Content
progress?.Report(new ValidationProgress(3, 3, "Validation Complete"));
_logger.LogDebug("Full content validation for {ManifestId} completed with {IssueCount} issues.", manifest.Id, issues.Count);
- return new ValidationResult(manifest.Id, issues);
+ return new ValidationResult(manifest.Id, issues, totalFilesValidated: integrityResult.TotalFilesValidated);
}
///
@@ -175,7 +175,7 @@ public async Task ValidateContentIntegrityAsync(string content
}
_logger.LogDebug("Content integrity validation for {ManifestId} completed with {IssueCount} issues.", manifest.Id, issues.Count);
- return new ValidationResult(manifest.Id, issues);
+ return new ValidationResult(manifest.Id, issues, totalFilesValidated: totalFiles);
}
///
diff --git a/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs b/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs
index 7288e74e6..42a02a41f 100644
--- a/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs
+++ b/GenHub/GenHub/Features/Validation/GameInstallationValidator.cs
@@ -1,40 +1,57 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Features.GameInstallations;
using GenHub.Core.Interfaces.Common;
using GenHub.Core.Interfaces.Content;
using GenHub.Core.Interfaces.Manifest;
using GenHub.Core.Interfaces.Validation;
+using GenHub.Core.Models.Content;
+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.ContentProviders;
using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
namespace GenHub.Features.Validation;
///
/// Validates the integrity of a game installation directory (e.g., from Steam, EA App).
-/// Focuses on installation-specific validation concerns.
+/// Integrates with the CSV content pipeline for manifest-driven multi-language validation.
///
public class GameInstallationValidator(
ILogger logger,
- IManifestProvider manifestProvider,
+ IManifestProvider? manifestProvider,
IContentValidator contentValidator,
- IFileHashProvider hashProvider)
+ IFileHashProvider hashProvider,
+ ILanguageDetector? languageDetector = null,
+ CsvContentProvider? csvContentProvider = null,
+ IEnumerable? contentProviders = null)
: FileSystemValidator(logger, hashProvider),
IGameInstallationValidator, IValidator
{
+ private readonly ILanguageDetector _languageDetector = languageDetector ?? new LanguageDetector();
+ private readonly IContentProvider? _resolvedCsvProvider = csvContentProvider ??
+ contentProviders?.OfType().FirstOrDefault() ??
+ contentProviders?.FirstOrDefault(p => string.Equals(p.SourceName, PublisherTypeConstants.CsvRegistry, StringComparison.OrdinalIgnoreCase));
+
///
- /// Validates the specified game installation.
+ /// Validates the specified game installation against expected files and checksums.
///
/// The game installation to validate.
/// A cancellation token.
/// A representing the validation outcome.
- public async Task ValidateAsync(GameInstallation installation, CancellationToken cancellationToken = default)
+ public Task ValidateAsync(GameInstallation installation, CancellationToken cancellationToken = default)
{
- return await ValidateAsync(installation, null, cancellationToken);
+ return ValidateInternalAsync(installation, null, null, cancellationToken);
}
///
@@ -44,76 +61,320 @@ public async Task ValidateAsync(GameInstallation installation,
/// Progress reporter for MVVM integration.
/// A cancellation token.
/// A representing the validation outcome.
- public async Task ValidateAsync(GameInstallation installation, IProgress? progress = null, CancellationToken cancellationToken = default)
+ public Task ValidateAsync(GameInstallation installation, IProgress? progress, CancellationToken cancellationToken = default)
+ {
+ return ValidateInternalAsync(installation, null, progress, cancellationToken);
+ }
+
+ ///
+ /// Validates the specified game installation with explicit language and optional progress reporting.
+ ///
+ /// The game installation to validate.
+ /// The explicit language code (e.g. "EN", "DE").
+ /// Progress reporter for MVVM integration.
+ /// A cancellation token.
+ /// A representing the validation outcome.
+ public Task ValidateAsync(
+ GameInstallation installation,
+ string language,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ return ValidateInternalAsync(installation, language, progress, cancellationToken);
+ }
+
+ ///
+ /// Validates a specific game installation directory by path, game type, and optional language.
+ ///
+ /// The path to the game directory.
+ /// The target game type (Generals or ZeroHour).
+ /// Optional explicit language code. If null, language is auto-detected.
+ /// Progress reporter for MVVM integration.
+ /// A cancellation token.
+ /// A representing the outcome of the validation.
+ public Task ValidateInstallationAsync(
+ string installationPath,
+ GameType gameType,
+ string? language = null,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(installationPath))
+ {
+ throw new ArgumentException("Installation path cannot be null or empty.", nameof(installationPath));
+ }
+
+ return ValidateInstallationCoreAsync(
+ installationPath,
+ gameType,
+ language,
+ installation: null,
+ progress: progress,
+ cancellationToken: cancellationToken);
+ }
+
+ private async Task ValidateInternalAsync(
+ GameInstallation installation,
+ string? language,
+ IProgress? progress,
+ CancellationToken cancellationToken)
{
+ ArgumentNullException.ThrowIfNull(installation);
cancellationToken.ThrowIfCancellationRequested();
+
logger.LogInformation("Starting validation for installation '{Path}'", installation.InstallationPath);
+ var stopwatch = Stopwatch.StartNew();
var issues = new List();
+ int totalFiles = 0;
+
+ var targets = new List<(string Path, GameType GameType)>();
+
+ if (installation.HasGenerals && !string.IsNullOrWhiteSpace(installation.GeneralsPath))
+ {
+ targets.Add((installation.GeneralsPath, GameType.Generals));
+ }
+
+ if (installation.HasZeroHour && !string.IsNullOrWhiteSpace(installation.ZeroHourPath))
+ {
+ targets.Add((installation.ZeroHourPath, GameType.ZeroHour));
+ }
- // 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++;
+ if (targets.Count == 0)
+ {
+ var fallbackGameType = installation.HasZeroHour ? GameType.ZeroHour : GameType.Generals;
+ targets.Add((installation.InstallationPath, fallbackGameType));
+ }
- int currentStep = 0;
+ int targetIndex = 0;
+ foreach (var (targetPath, targetGame) in targets)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ targetIndex++;
+
+ logger.LogDebug("Validating target {Index}/{Total}: {GameType} at '{Path}'", targetIndex, targets.Count, targetGame, targetPath);
- progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Fetching manifest"));
+ var result = await ValidateInstallationCoreAsync(
+ targetPath,
+ targetGame,
+ language,
+ installation,
+ progress,
+ cancellationToken);
- // Fetch manifest for this installation type
- var manifest = await manifestProvider.GetManifestAsync(installation, cancellationToken);
+ issues.AddRange(result.Issues);
+ totalFiles += result.TotalFilesValidated;
+ }
+
+ stopwatch.Stop();
+ logger.LogInformation(
+ "Installation validation for '{Path}' completed with {IssueCount} issues ({CriticalCount} critical, {TotalFiles} files validated).",
+ installation.InstallationPath,
+ issues.Count,
+ issues.Count(i => i.Severity == ValidationSeverity.Error || i.Severity == ValidationSeverity.Critical),
+ totalFiles);
+
+ return new ValidationResult(installation.InstallationPath, issues, stopwatch.Elapsed, totalFiles);
+ }
+
+ private async Task ValidateInstallationCoreAsync(
+ string installationPath,
+ GameType gameType,
+ string? language,
+ GameInstallation? installation,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
cancellationToken.ThrowIfCancellationRequested();
- if (manifest == null)
+ var stopwatch = Stopwatch.StartNew();
+ var issues = new List();
+
+ var detectedLanguage = string.IsNullOrWhiteSpace(language)
+ ? await _languageDetector.DetectAsync(installationPath, cancellationToken)
+ : language;
+
+ var normalizedLanguage = ContentSearchQuery.NormalizeLanguage(detectedLanguage);
+ logger.LogInformation(
+ "Validating installation at '{Path}' for game {GameType} in language {Language}",
+ installationPath,
+ gameType,
+ normalizedLanguage);
+
+ progress?.Report(new ValidationProgress(1, 4, "Resolving manifest"));
+
+ ContentManifest? manifest = null;
+ var csvIssues = new List();
+ if (_resolvedCsvProvider != null)
{
- 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);
+ manifest = await ResolveManifestFromCsvProviderAsync(
+ installationPath,
+ gameType,
+ normalizedLanguage,
+ csvIssues,
+ cancellationToken);
+ }
+
+ if (manifest == null && manifestProvider != null)
+ {
+ logger.LogDebug("Attempting fallback manifest lookup via IManifestProvider for '{Path}' ({GameType})", installationPath, gameType);
+ var targetInstall = new GameInstallation(installationPath, installation?.InstallationType ?? GameInstallationType.Unknown, NullLogger.Instance);
+ if (gameType == GameType.ZeroHour)
+ {
+ targetInstall.SetPaths(generalsPath: null, zeroHourPath: installationPath);
+ }
+ else
+ {
+ targetInstall.SetPaths(generalsPath: installationPath, zeroHourPath: null);
+ }
+
+ manifest = await manifestProvider.GetManifestAsync(targetInstall, cancellationToken);
}
- progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Core manifest validation"));
+ if (manifest == null)
+ {
+ if (csvIssues.Count > 0)
+ {
+ issues.AddRange(csvIssues);
+ }
+ else
+ {
+ issues.Add(new ValidationIssue
+ {
+ IssueType = ValidationIssueType.MissingFile,
+ Path = installationPath,
+ Message = $"Manifest not found for {gameType} ({normalizedLanguage}) installation at '{installationPath}'.",
+ Severity = ValidationSeverity.Error,
+ });
+ }
+ progress?.Report(new ValidationProgress(4, 4, "Validation complete"));
+ stopwatch.Stop();
+ return new ValidationResult(installationPath, issues, stopwatch.Elapsed, 0);
+ }
+
+ progress?.Report(new ValidationProgress(2, 4, "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)
+ progress?.Report(new ValidationProgress(3, 4, "Validating content files"));
+ int totalFiles = 0;
try
{
- var fullValidation = await contentValidator.ValidateAllAsync(installation.InstallationPath, manifest, progress, cancellationToken);
+ var fullValidation = await contentValidator.ValidateAllAsync(
+ installationPath,
+ manifest,
+ progress,
+ cancellationToken);
issues.AddRange(fullValidation.Issues);
+ totalFiles = fullValidation.TotalFilesValidated > 0
+ ? fullValidation.TotalFilesValidated
+ : manifest.Files?.Count ?? 0;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
}
catch (Exception ex)
{
- logger.LogError(ex, "Content validation failed for installation '{Path}'", installation.InstallationPath);
+ logger.LogError(ex, "Content validation failed for installation '{Path}' ({GameType}, {Language})", installationPath, gameType, normalizedLanguage);
issues.Add(new ValidationIssue
{
IssueType = ValidationIssueType.CorruptedFile,
- Path = installation.InstallationPath,
- Message = $"Content validation failed: {ex.Message}",
+ Path = installationPath,
+ Message = $"Content validation failed for {gameType} ({normalizedLanguage}): {ex.Message}",
Severity = ValidationSeverity.Error,
});
+ totalFiles = 0;
}
- // Installation-specific validations (directories, etc.)
var requiredDirs = manifest.RequiredDirectories ?? Enumerable.Empty();
if (requiredDirs.Any())
{
- if (installation.HasGenerals)
+ var dirIssues = await ValidateDirectoriesAsync(installationPath, requiredDirs, cancellationToken);
+ issues.AddRange(dirIssues);
+ }
+
+ progress?.Report(new ValidationProgress(4, 4, "Validation complete"));
+
+ stopwatch.Stop();
+ return new ValidationResult(installationPath, issues, stopwatch.Elapsed, totalFiles);
+ }
+
+ private async Task ResolveManifestFromCsvProviderAsync(
+ string installationPath,
+ GameType gameType,
+ string language,
+ List issues,
+ CancellationToken cancellationToken)
+ {
+ if (_resolvedCsvProvider == null)
+ {
+ return null;
+ }
+
+ try
+ {
+ var query = new ContentSearchQuery
{
- progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Validating Generals directories"));
- issues.AddRange(await ValidateDirectoriesAsync(installation.GeneralsPath, requiredDirs, cancellationToken));
- }
+ TargetGame = gameType,
+ Language = language,
+ ContentType = ContentType.GameInstallation,
+ };
- if (installation.HasZeroHour)
+ var searchResult = await _resolvedCsvProvider.SearchAsync(query, cancellationToken);
+ if (!searchResult.Success || searchResult.Data == null || !searchResult.Data.Any())
{
- progress?.Report(new ValidationProgress(++currentStep, totalSteps, "Validating Zero Hour directories"));
- issues.AddRange(await ValidateDirectoriesAsync(installation.ZeroHourPath, requiredDirs, cancellationToken));
+ logger.LogWarning(
+ "CSV provider search returned no results for {GameType} ({Language}): {Error}",
+ gameType,
+ language,
+ searchResult.FirstError ?? "No matching items");
+
+ issues.Add(new ValidationIssue
+ {
+ IssueType = ValidationIssueType.MissingFile,
+ Path = installationPath,
+ Message = $"No CSV manifest found for {gameType} ({language}): {searchResult.FirstError ?? "No matching catalog entries"}",
+ Severity = ValidationSeverity.Error,
+ });
+ return null;
}
- }
- progress?.Report(new ValidationProgress(totalSteps, totalSteps, "Validation complete"));
+ var matchingItem = searchResult.Data.FirstOrDefault();
+ var manifest = matchingItem?.GetData();
+ if (manifest == null)
+ {
+ logger.LogWarning(
+ "CSV provider search result did not contain valid manifest data for {GameType} ({Language})",
+ gameType,
+ language);
- logger.LogInformation("Installation validation for '{Path}' completed with {Count} issues.", installation.InstallationPath, issues.Count);
- return new ValidationResult(installation.InstallationPath, issues);
+ issues.Add(new ValidationIssue
+ {
+ IssueType = ValidationIssueType.MissingFile,
+ Path = installationPath,
+ Message = $"Failed to parse CSV manifest for {gameType} ({language}).",
+ Severity = ValidationSeverity.Error,
+ });
+ return null;
+ }
+
+ return manifest;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to resolve CSV manifest for {GameType} ({Language})", gameType, language);
+ issues.Add(new ValidationIssue
+ {
+ IssueType = ValidationIssueType.MissingFile,
+ Path = installationPath,
+ Message = $"Error retrieving CSV manifest for {gameType} ({language}): {ex.Message}",
+ Severity = ValidationSeverity.Error,
+ });
+ return null;
+ }
}
}
\ No newline at end of file
diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs
index acd40541f..bed3b02a9 100644
--- a/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs
+++ b/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs
@@ -351,7 +351,8 @@ private static void AddLocalFileSystemPipeline(IServiceCollection services)
private static void AddCsvPipeline(IServiceCollection services)
{
// Register CSV content provider
- services.AddTransient();
+ services.AddTransient();
+ services.AddTransient(sp => sp.GetRequiredService());
// Register CSV discoverer (concrete and interface)
services.AddTransient();
diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/GameInstallationModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/GameInstallationModule.cs
index fb6e18f93..94e3769fd 100644
--- a/GenHub/GenHub/Infrastructure/DependencyInjection/GameInstallationModule.cs
+++ b/GenHub/GenHub/Infrastructure/DependencyInjection/GameInstallationModule.cs
@@ -1,3 +1,4 @@
+using GenHub.Core.Features.GameInstallations;
using GenHub.Core.Interfaces.GameInstallations;
using GenHub.Features.GameInstallations;
using Microsoft.Extensions.DependencyInjection;
@@ -16,6 +17,7 @@ public static class GameInstallationModule
/// The updated service collection.
public static IServiceCollection AddGameInstallation(this IServiceCollection services)
{
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddScoped();
diff --git a/docs/features/content/csv-validation.md b/docs/features/content/csv-validation.md
new file mode 100644
index 000000000..5e0d5e04d
--- /dev/null
+++ b/docs/features/content/csv-validation.md
@@ -0,0 +1,129 @@
+---
+title: CSV Validation Pipeline & Game Installation Validator Integration
+description: Architecture, multi-language validation, and manifest generation using CSV catalogs for Command & Conquer Generals and Zero Hour installations
+---
+
+# CSV Validation Pipeline
+
+The **CSV Validation Pipeline** provides a high-performance, manifest-driven mechanism for validating vanilla *Command & Conquer: Generals* (v1.08) and *Zero Hour* (v1.04) game installations against unified remote or cached CSV catalogs across all 10 official game language editions.
+
+---
+
+## Architecture Overview
+
+The pipeline integrates with GenHub's modular content provider architecture and validation engine:
+
+```mermaid
+flowchart TD
+ A[GameInstallationValidator] -->|Language Detection| B[LanguageDetector]
+ A -->|ContentSearchQuery| C[CsvContentProvider]
+ C -->|DiscoverAsync| D[CsvDiscoverer]
+ D -->|index.json / Catalogs| E[Catalog URLs]
+ C -->|ResolveAsync| F[CsvResolver]
+ F -->|Streaming Parse & Filter| G[ContentManifest]
+ A -->|ValidateManifestAsync & ValidateAllAsync| H[ContentValidator]
+ H -->|Detailed Issue Aggregation| I[ValidationResult]
+```
+
+---
+
+## Core Components
+
+### 1. `LanguageDetector` (`ILanguageDetector`)
+Located in `GenHub.Core.Features.GameInstallations`:
+- Analyzes game directory layout and file patterns to determine the installed language.
+- Checks language directories: `Data\english\`, `Data\german\`, `Data\deutsch\`, `Data\french\`, `Data\spanish\`, `Data\italian\`, `Data\korean\`, `Data\polish\`, `Data\PortugueseBrazil\`, `Data\chinese\`, `Data\chinesetraditional\`.
+- Checks BIG archive patterns: `German.big`, `French.big`, `Spanish.big`, `Italian.big`, `Korean.big`, `Polish.big`, `PortugueseBrazil.big`, `Chinese.big`, `ChineseTraditional.big`, and their Zero Hour counterparts (`GermanZH.big`, `FrenchZH.big`, etc.).
+- Normalizes language codes to uppercase (`EN`, `DE`, `FR`, `ES`, `IT`, `KO`, `PL`, `PT-BR`, `ZH-CN`, `ZH-TW`) with fallback to `EN`.
+
+### 2. `CsvDiscoverer` (`IContentDiscoverer`)
+Located in `GenHub.Features.Content.Services.ContentDiscoverers`:
+- Discovers remote CSV catalogs matching the requested game type (`Generals` or `ZeroHour`) and language.
+- First queries remote `index.json` metadata if available, falling back to configuration catalog URLs (`CsvConstants.DefaultGeneralsCsvUrl` / `DefaultZeroHourCsvUrl`).
+- Generates language-specific manifest IDs (e.g., `csv-generals-1.08-de`).
+
+### 3. `CsvResolver` (`IContentResolver`)
+Located in `GenHub.Features.Content.Services.ContentResolvers`:
+- Streams and parses RFC-4180 compliant CSV catalogs using `CsvHelper`.
+- Filters rows matching the requested `TargetGame` and `Language`.
+- Always includes shared files tagged with language `All` (such as `game.dat` or shared executables) alongside the language-specific assets.
+- Produces a strongly typed `ContentManifest`.
+
+### 4. `CsvContentProvider` (`IContentProvider`)
+Located in `GenHub.Features.Content.Services.ContentProviders`:
+- Facade registered under source name `csv-registry` (`PublisherTypeConstants.CsvRegistry`).
+- Exposes `SearchAsync(ContentSearchQuery)` returning `ContentSearchResult` objects populated with `ContentManifest`.
+
+### 5. `GameInstallationValidator` (`IGameInstallationValidator`)
+Located in `GenHub.Features.Validation`:
+- Orchestrates multi-target validation across both Generals and Zero Hour directories within an installation.
+- Auto-detects language if not specified explicitly, or accepts an explicit language code.
+- Normalizes input language parameters regardless of casing.
+- Performs manifest validation, hash verification, file size checks, and directory structure validation.
+- Aggregates issues and calculates detailed counts in `ValidationResult`.
+
+---
+
+## Detailed Validation Result Metrics
+
+`ValidationResult` includes comprehensive metrics for diagnosing game installation health:
+
+```csharp
+public sealed record ValidationResult(
+ string Path,
+ IReadOnlyList Issues,
+ TimeSpan Elapsed = default,
+ int TotalFilesValidated = 0)
+{
+ public bool IsValid => Issues.All(i => i.Severity != ValidationSeverity.Error && i.Severity != ValidationSeverity.Critical);
+ public int CriticalIssueCount => Issues.Count(i => i.Severity == ValidationSeverity.Critical || i.Severity == ValidationSeverity.Error);
+ public int WarningIssueCount => Issues.Count(i => i.Severity == ValidationSeverity.Warning);
+
+ public int MissingFilesCount => Issues.Count(i => i.IssueType == ValidationIssueType.MissingFile);
+ public int CorruptedFilesCount => Issues.Count(i => i.IssueType == ValidationIssueType.CorruptedFile || i.IssueType == ValidationIssueType.MismatchedFileSize);
+ public int ExtraFilesCount => Issues.Count(i => i.IssueType == ValidationIssueType.UnexpectedFile);
+}
+```
+
+---
+
+## Supported Language Matrix
+
+| Language Code | Display Name | Directory Marker | Primary BIG File Marker |
+| :--- | :--- | :--- | :--- |
+| `EN` | English | `Data\English\` | `English.big` / `EnglishZH.big` |
+| `DE` | German | `Data\German\`, `Data\Deutsch\` | `German.big` / `GermanZH.big` |
+| `FR` | French | `Data\French\` | `French.big` / `FrenchZH.big` |
+| `ES` | Spanish | `Data\Spanish\` | `Spanish.big` / `SpanishZH.big` |
+| `IT` | Italian | `Data\Italian\` | `Italian.big` / `ItalianZH.big` |
+| `KO` | Korean | `Data\Korean\` | `Korean.big` / `KoreanZH.big` |
+| `PL` | Polish | `Data\Polish\` | `Polish.big` / `PolishZH.big` |
+| `PT-BR` | Portuguese (Brazil) | `Data\PortugueseBrazil\` | `PortugueseBrazil.big` / `PortugueseZH.big` |
+| `ZH-CN` | Chinese (Simplified) | `Data\Chinese\` | `Chinese.big` / `ChineseZH.big` |
+| `ZH-TW` | Chinese (Traditional) | `Data\ChineseTraditional\` | `ChineseTraditional.big` |
+
+---
+
+## Dependency Injection Setup
+
+In `ValidationModule.cs`:
+```csharp
+services.AddTransient();
+services.AddTransient();
+```
+
+In `GameInstallationModule.cs`:
+```csharp
+services.AddSingleton();
+services.AddSingleton();
+```
+
+In `ContentPipelineModule.cs`:
+```csharp
+services.AddTransient();
+services.AddTransient(sp => sp.GetRequiredService());
+services.AddTransient();
+services.AddTransient();
+services.AddTransient();
+services.AddTransient();
+```
diff --git a/docs/features/content/index.md b/docs/features/content/index.md
index d07e0e7eb..8a6871222 100644
--- a/docs/features/content/index.md
+++ b/docs/features/content/index.md
@@ -9,6 +9,7 @@ The GenHub content system provides a flexible, extensible architecture for disco
## Core Documentation
+- [CSV Validation Pipeline](./csv-validation.md) - Multi-language game installation validation via unified CSV catalogs
- [Publisher Configuration](./publisher-configuration.md) - Data-driven publisher configuration for flexible content pipeline customization
- [Publisher Infrastructure](./publisher-infrastructure.md) - Extensible architecture for publisher-specific content handling