Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions GenHub/GenHub.Core/Assets/Registries/Generals-1.08.csv

Large diffs are not rendered by default.

176 changes: 176 additions & 0 deletions GenHub/GenHub.Core/Assets/Registries/ZeroHour-1.04.csv

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions GenHub/GenHub.Core/Assets/Registries/index.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"version": "1.0.0",
"lastUpdated": "2026-08-30T17:40:00Z",
"description": "Index of CSV registries for Command & Conquer Generals and Zero Hour validation",
"registries": [
{
"id": "generals-1.08",
"gameType": "Generals",
"version": "1.08",
"url": "https://raw.githubusercontent.com/community-outpost/GenHub/main/docs/GameInstallationFilesRegistry/Generals-1.08.csv",
"fileCount": 164,
"totalSizeBytes": 48166,
"languages": ["All", "EN", "DE", "FR", "ES", "IT", "KO", "PL", "PT-BR", "ZH-CN", "ZH-TW"],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

11. Advertised languages lack records 🐞 Bug ≡ Correctness

The embedded index advertises DE, FR, ES, IT, KO, PL, PT-BR, ZH-CN, and ZH-TW, but both new
registries contain only All and EN rows. Automatic detection or selection of an advertised
non-English language therefore retains only language-neutral files, omits required localized
archives and movies, and still returns a successful but incomplete manifest that cannot materialize
the detected language's base game.
Agent Prompt
## Issue description
Language filtering is enabled for detected or selected non-English installations, but the embedded registries contain no records for most languages advertised by the index. Filtering consequently retains only `All` entries and excludes required language-specific files, producing an incomplete manifest.

## Issue Context
The index declares support for DE, FR, ES, IT, KO, PL, PT-BR, ZH-CN, and ZH-TW in addition to the registry's available language values, and `LanguageDetector` can detect such codes from game directories and archives. Add authoritative language-specific rows for every advertised language or restrict each index language list so unsupported languages are neither advertised nor accepted; manifest generation should also reject a requested language when the selected registry does not support it.

## Fix Focus Areas
- GenHub/GenHub/Features/Manifest/ManifestGenerationService.cs[523-565]
- GenHub/GenHub.Core/Assets/Registries/index.json[7-16]
- GenHub/GenHub.Core/Assets/Registries/index.json[23-32]
- GenHub/GenHub.Core/Assets/Registries/Generals-1.08.csv[1-165]
- GenHub/GenHub.Core/Assets/Registries/ZeroHour-1.04.csv[1-176]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

"checksum": {
"md5": "41e3f06a608156eaea960d432d6be682",
"sha256": "97c72beab9b92918ccf2629cf104034007337873783f2f7f03855e9857fa6267"
},
"generatedAt": "2025-09-17T09:15:00Z",
"generatorVersion": "1.0.0",
"isActive": true
},
{
"id": "zerohour-1.04",
"gameType": "ZeroHour",
"version": "1.04",
"url": "https://raw.githubusercontent.com/community-outpost/GenHub/main/docs/GameInstallationFilesRegistry/ZeroHour-1.04.csv",
"fileCount": 175,
"totalSizeBytes": 51722,
"languages": ["All", "EN", "DE", "FR", "ES", "IT", "KO", "PL", "PT-BR", "ZH-CN", "ZH-TW"],
"checksum": {
"md5": "f2dc5255bc48636449e146666fb1b53a",
"sha256": "41bd40d8f477eeba73c5c7543a902542394034d995c8efc27467d5938b9836c6"
},
"generatedAt": "2025-09-17T09:20:00Z",
"generatorVersion": "1.0.0",
"isActive": true
}
]
}
35 changes: 35 additions & 0 deletions GenHub/GenHub.Core/Constants/CsvConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,39 @@ public static class CsvConstants
/// Canonical language code for Traditional Chinese.
/// </summary>
public const string LanguageZhTw = "ZH-TW";

/// <summary>
/// File name for Generals 1.08 authoritative CSV registry.
/// </summary>
public const string GeneralsCsvFileName = "Generals-1.08.csv";

/// <summary>
/// File name for Zero Hour 1.04 authoritative CSV registry.
/// </summary>
public const string ZeroHourCsvFileName = "ZeroHour-1.04.csv";

/// <summary>
/// File name for game installation files index JSON.
/// </summary>
public const string RegistryIndexFileName = "index.json";

/// <summary>
/// Documentation folder containing game installation files registry.
/// </summary>
public const string RegistryDocsFolder = "GameInstallationFilesRegistry";

/// <summary>
/// Embedded resource namespace in GenHub.Core containing authoritative registries.
/// </summary>
public const string EmbeddedResourceNamespace = "GenHub.Core.Assets.Registries";

/// <summary>
/// Default remote URL for Generals 1.08 CSV.
/// </summary>
public const string DefaultGeneralsCsvUrl = "https://raw.githubusercontent.com/community-outpost/GenHub/main/docs/GameInstallationFilesRegistry/Generals-1.08.csv";

/// <summary>
/// Default remote URL for Zero Hour 1.04 CSV.
/// </summary>
public const string DefaultZeroHourCsvUrl = "https://raw.githubusercontent.com/community-outpost/GenHub/main/docs/GameInstallationFilesRegistry/ZeroHour-1.04.csv";
}
4 changes: 3 additions & 1 deletion GenHub/GenHub.Core/GenHub.Core.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
Expand All @@ -18,5 +18,7 @@

<ItemGroup>
<EmbeddedResource Include="Assets\Manifests\*.csv" />
<EmbeddedResource Include="Assets\Registries\*.csv" />
<EmbeddedResource Include="Assets\Registries\*.json" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,18 @@ IContentManifestBuilder AddDependency(
/// <param name="sourcePath">The source path of the file in the game installation.</param>
/// <param name="isExecutable">Whether the file is executable.</param>
/// <param name="permissions">File permissions.</param>
/// <param name="hash">Optional pre-computed SHA256 content hash.</param>
/// <param name="size">Optional file size in bytes.</param>
/// <param name="isRequired">Whether the file is required.</param>
/// <returns>A task that yields the <see cref="IContentManifestBuilder"/> instance for chaining upon completion.</returns>
Task<IContentManifestBuilder> AddGameInstallationFileAsync(string relativePath, string sourcePath, bool isExecutable = false, FilePermissions? permissions = null);
Task<IContentManifestBuilder> AddGameInstallationFileAsync(
string relativePath,
string sourcePath,
bool isExecutable = false,
FilePermissions? permissions = null,
string? hash = null,
long? size = null,
bool isRequired = true);

/// <summary>
/// Adds a content-addressable file from the CAS system.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,14 @@ public interface IManifestGenerationService
/// <param name="gameType">The game type (Generals, ZeroHour).</param>
/// <param name="installationType">The installation type (Steam, EaApp).</param>
/// <param name="manifestVersion">The manifest version (e.g., "1.08", "1.04", or integer like 0, 1, 2). If null, defaults to 0.</param>
/// <param name="language">Optional explicit language code (e.g., "EN", "DE"). If null, language is detected automatically.</param>
/// <returns>A <see cref="Task"/> that returns a configured manifest builder.</returns>
Task<IContentManifestBuilder> CreateGameInstallationManifestAsync(string gameInstallationPath, GameType gameType, GameInstallationType installationType, string? manifestVersion = null);
Task<IContentManifestBuilder> CreateGameInstallationManifestAsync(
string gameInstallationPath,
GameType gameType,
GameInstallationType installationType,
string? manifestVersion = null,
string? language = null);

/// <summary>
/// Creates a manifest builder for a game installation with integer version.
Expand All @@ -25,8 +31,14 @@ public interface IManifestGenerationService
/// <param name="gameType">The game type (Generals, ZeroHour).</param>
/// <param name="installationType">The installation type (Steam, EaApp).</param>
/// <param name="manifestVersion">The manifest version (e.g., 1, 2, 20). Defaults to 0 for first version.</param>
/// <param name="language">Optional explicit language code (e.g., "EN", "DE"). If null, language is detected automatically.</param>
/// <returns>A <see cref="Task"/> that returns a configured manifest builder.</returns>
Task<IContentManifestBuilder> CreateGameInstallationManifestAsync(string gameInstallationPath, GameType gameType, GameInstallationType installationType, int manifestVersion = 0);
Task<IContentManifestBuilder> CreateGameInstallationManifestAsync(
string gameInstallationPath,
GameType gameType,
GameInstallationType installationType,
int manifestVersion = 0,
string? language = null);

/// <summary>
/// Creates a manifest builder for any content type (mod, patch, addon, etc).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ public async Task CreateGameInstallationManifestAsync_UsesCsvWhenAvailableAsync(
var installationPath = Path.Combine(_tempDirectory, "GeneralsInstall");
Directory.CreateDirectory(installationPath);

// Create some files that are in the generals.csv
// Create some files that are in the Generals registry
await File.WriteAllTextAsync(Path.Combine(installationPath, "generals.exe"), "dummy");
await File.WriteAllTextAsync(Path.Combine(installationPath, "AudioEnglish.big"), "dummy");

Expand All @@ -275,6 +275,167 @@ public async Task CreateGameInstallationManifestAsync_UsesCsvWhenAvailableAsync(
Assert.Contains(manifest.Files, f => f.RelativePath == "AudioEnglish.big");
}

/// <summary>
/// Tests that CreateGameInstallationManifestAsync generates authoritative manifest for Generals with SHA256 hashes and proper source type.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous test operation.</returns>
[Fact]
public async Task CreateGameInstallationManifestAsync_Generals_PopulatesAuthoritativeMetadataAsync()
{
// Arrange
var installationPath = Path.Combine(_tempDirectory, "GeneralsAuthoritative");
Directory.CreateDirectory(installationPath);

await File.WriteAllTextAsync(Path.Combine(installationPath, "generals.exe"), "executable binary");
await File.WriteAllTextAsync(Path.Combine(installationPath, "binkw32.dll"), "bink dll");
await File.WriteAllTextAsync(Path.Combine(installationPath, "Audio.big"), "audio archive");

// Act
var builder = await _service.CreateGameInstallationManifestAsync(
installationPath, GameType.Generals, GameInstallationType.Steam, "1.08", "EN");
var manifest = builder.Build();

// Assert
Assert.NotNull(manifest);
var exeFile = manifest.Files.FirstOrDefault(f => f.RelativePath == "generals.exe");
Assert.NotNull(exeFile);
Assert.True(exeFile.IsExecutable);
Assert.Equal(GenHub.Core.Models.Enums.ContentSourceType.GameInstallation, exeFile.SourceType);
Assert.Equal("e253361f457f2ec3290ccf4088aa5c4022fc4772a769fff5fb2fa8b9e5df842d", exeFile.Hash);

var dllFile = manifest.Files.FirstOrDefault(f => f.RelativePath.Equals("binkw32.dll", StringComparison.OrdinalIgnoreCase));
Assert.NotNull(dllFile);
Assert.False(dllFile.IsExecutable);
Assert.Equal("892a51c4056efcb22297a3b44a3491e3f5888f28b08ed1b17030f24acffedb44", dllFile.Hash);
}

/// <summary>
/// Tests that CreateGameInstallationManifestAsync generates authoritative manifest for Zero Hour.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous test operation.</returns>
[Fact]
public async Task CreateGameInstallationManifestAsync_ZeroHour_ResolvesAuthoritativeFilesAsync()
{
// Arrange
var installationPath = Path.Combine(_tempDirectory, "ZeroHourInstall");
Directory.CreateDirectory(installationPath);

await File.WriteAllTextAsync(Path.Combine(installationPath, "generals.exe"), "zh exe");
await File.WriteAllTextAsync(Path.Combine(installationPath, "AudioZH.big"), "zh audio");
await File.WriteAllTextAsync(Path.Combine(installationPath, "SpeechZH.big"), "zh speech");

// Act
var builder = await _service.CreateGameInstallationManifestAsync(
installationPath, GameType.ZeroHour, GameInstallationType.EaApp, "1.04", "EN");
var manifest = builder.Build();

// Assert
Assert.NotNull(manifest);
Assert.Contains(manifest.Files, f => f.RelativePath == "generals.exe");
Assert.Contains(manifest.Files, f => f.RelativePath == "AudioZH.big");
Assert.Contains(manifest.Files, f => f.RelativePath == "SpeechZH.big");
}

/// <summary>
/// Tests that CreateGameInstallationManifestAsync filters language files according to requested language.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous test operation.</returns>
[Fact]
public async Task CreateGameInstallationManifestAsync_LanguageFiltering_IncludesMatchingLanguageOnlyAsync()
{
// Arrange
var installationPath = Path.Combine(_tempDirectory, "LanguageGenerals");
Directory.CreateDirectory(installationPath);

await File.WriteAllTextAsync(Path.Combine(installationPath, "generals.exe"), "exe");
await File.WriteAllTextAsync(Path.Combine(installationPath, "AudioEnglish.big"), "english audio");
await File.WriteAllTextAsync(Path.Combine(installationPath, "English.big"), "english text");

// Act 1: Request English (EN)
var enBuilder = await _service.CreateGameInstallationManifestAsync(
installationPath, GameType.Generals, GameInstallationType.Steam, "1.08", "EN");
var enManifest = enBuilder.Build();

// Assert 1: English files are included
Assert.NotNull(enManifest);
Assert.Contains(enManifest.Files, f => f.RelativePath == "AudioEnglish.big");
Assert.Contains(enManifest.Files, f => f.RelativePath == "English.big");

// Act 2: Request German (DE) for the same folder
var deBuilder = await _service.CreateGameInstallationManifestAsync(
installationPath, GameType.Generals, GameInstallationType.Steam, "1.08", "DE");
var deManifest = deBuilder.Build();

// Assert 2: English-specific files are excluded when German is requested
Assert.NotNull(deManifest);
Assert.DoesNotContain(deManifest.Files, f => f.RelativePath == "AudioEnglish.big");
Assert.DoesNotContain(deManifest.Files, f => f.RelativePath == "English.big");
}

/// <summary>
/// Tests that CreateGameInstallationManifestAsync excludes extra non-vanilla files from the core manifest.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous test operation.</returns>
[Fact]
public async Task CreateGameInstallationManifestAsync_ExcludesExtraNonVanillaFilesAsync()
{
// Arrange
var installationPath = Path.Combine(_tempDirectory, "ModdedGenerals");
Directory.CreateDirectory(installationPath);

// Vanilla files
await File.WriteAllTextAsync(Path.Combine(installationPath, "generals.exe"), "exe");
await File.WriteAllTextAsync(Path.Combine(installationPath, "AudioEnglish.big"), "english audio");

// Extra non-vanilla mod files
await File.WriteAllTextAsync(Path.Combine(installationPath, "!Gentool.dll"), "gentool mod dll");
await File.WriteAllTextAsync(Path.Combine(installationPath, "CustomShockwave.big"), "mod archive");
await File.WriteAllTextAsync(Path.Combine(installationPath, "test_custom_config.ini"), "custom ini");

// Act
var builder = await _service.CreateGameInstallationManifestAsync(
installationPath, GameType.Generals, GameInstallationType.Steam, "1.08", "EN");
var manifest = builder.Build();

// Assert
Assert.NotNull(manifest);
Assert.Contains(manifest.Files, f => f.RelativePath == "generals.exe");
Assert.Contains(manifest.Files, f => f.RelativePath == "AudioEnglish.big");

// Untracked/mod files should NOT be in the pristine vanilla manifest
Assert.DoesNotContain(manifest.Files, f => f.RelativePath == "!Gentool.dll");
Assert.DoesNotContain(manifest.Files, f => f.RelativePath == "CustomShockwave.big");
Assert.DoesNotContain(manifest.Files, f => f.RelativePath == "test_custom_config.ini");
}

/// <summary>
/// Tests that CreateGameInstallationManifestAsync uses backup (.bak) file if available.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous test operation.</returns>
[Fact]
public async Task CreateGameInstallationManifestAsync_UsesBackupFileWhenPresentAsync()
{
// Arrange
var installationPath = Path.Combine(_tempDirectory, "BackupTest");
Directory.CreateDirectory(installationPath);

var originalExe = Path.Combine(installationPath, "generals.exe");
var backupExe = Path.Combine(installationPath, "generals.exe.bak");
await File.WriteAllTextAsync(originalExe, "modified exe");
await File.WriteAllTextAsync(backupExe, "pristine original backup exe");

// Act
var builder = await _service.CreateGameInstallationManifestAsync(
installationPath, GameType.Generals, GameInstallationType.Steam, "1.08", "EN");
var manifest = builder.Build();

// Assert
Assert.NotNull(manifest);
var exeFile = manifest.Files.FirstOrDefault(f => f.RelativePath == "generals.exe");
Assert.NotNull(exeFile);
Assert.Equal(backupExe, exeFile.SourcePath);
}

/// <summary>
/// Cleans up temporary test files.
/// </summary>
Expand Down
Loading
Loading