diff --git a/GenHub/GenHub.Core/Constants/CatalogConstants.cs b/GenHub/GenHub.Core/Constants/CatalogConstants.cs
index 0e0ff3f5d..2e23578b4 100644
--- a/GenHub/GenHub.Core/Constants/CatalogConstants.cs
+++ b/GenHub/GenHub.Core/Constants/CatalogConstants.cs
@@ -1,8 +1,26 @@
namespace GenHub.Core.Constants;
///
-/// Constants for publisher catalog system.
+/// Constants for the modular publisher-catalog system.
///
+///
+/// Layering (see Publisher Studio architecture):
+///
+/// -
+/// Provider Definition — static publisher metadata + catalog endpoint(s)
+/// (bundled *.provider.json today; user-hosted definitions via Publisher Studio later).
+///
+/// -
+/// Catalog — dynamic content listing (catalog.json / remote endpoint), updated on each release.
+///
+/// -
+/// Artifacts — downloadable files referenced by catalog releases.
+///
+///
+/// Anyone can author a GenHub-schema catalog, host it, and share
+/// genhub://subscribe?url=.... Discovery uses
+/// for catalog-direct subscriptions without per-publisher code.
+///
public static class CatalogConstants
{
///
@@ -11,12 +29,17 @@ public static class CatalogConstants
public const int CatalogSchemaVersion = 1;
///
- /// Filename for subscriptions storage.
+ /// Filename for user subscription storage under application data.
///
public const string SubscriptionFileName = "subscriptions.json";
///
- /// Resolver ID for generic catalog resolver.
+ /// Sidebar / discoverer category for user-subscribed catalogs (vs built-in static/dynamic).
+ ///
+ public const string SubscribedPublisherCategory = "subscribed";
+
+ ///
+ /// Resolver / pipeline ID for the generic catalog pipeline (any GenHub-schema catalog).
///
public const string GenericCatalogResolverId = "generic-catalog";
@@ -29,4 +52,44 @@ public static class CatalogConstants
/// Maximum catalog size in bytes (10 MB).
///
public const long MaxCatalogSizeBytes = 10 * 1024 * 1024;
+
+ ///
+ /// Maximum number of entries allowed when extracting publisher catalog archives.
+ ///
+ public const int MaxZipEntryCount = 50_000;
+
+ ///
+ /// Maximum cumulative uncompressed size allowed when extracting publisher catalog archives (5 GB).
+ ///
+ public const long MaxZipUncompressedSizeBytes = 5L * 1024 * 1024 * 1024;
+
+ ///
+ /// Resolver metadata key for serialized publisher profile JSON.
+ ///
+ public const string PublisherProfileJsonMetadataKey = "publisherProfileJson";
+
+ ///
+ /// Resolver metadata key for serialized catalog item JSON.
+ ///
+ public const string CatalogItemJsonMetadataKey = "catalogItemJson";
+
+ ///
+ /// Resolver metadata key for serialized release JSON.
+ ///
+ public const string ReleaseJsonMetadataKey = "releaseJson";
+
+ ///
+ /// Resolver metadata key for the stable catalog content id (not the display name).
+ ///
+ public const string CatalogContentIdMetadataKey = "catalogContentId";
+
+ ///
+ /// Resolver metadata key for serialized bundle component descriptors.
+ ///
+ public const string BundleComponentsJsonMetadataKey = "bundleComponentsJson";
+
+ ///
+ /// Resolver metadata key for serialized publisher referrals JSON.
+ ///
+ public const string CatalogReferralsJsonMetadataKey = "catalogReferralsJson";
}
diff --git a/GenHub/GenHub.Core/Constants/GameContentConstants.cs b/GenHub/GenHub.Core/Constants/GameContentConstants.cs
new file mode 100644
index 000000000..146ef84c8
--- /dev/null
+++ b/GenHub/GenHub.Core/Constants/GameContentConstants.cs
@@ -0,0 +1,191 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace GenHub.Core.Constants;
+
+///
+/// Constants for game content structure, archive payload normalization, and recognized game assets.
+///
+public static class GameContentConstants
+{
+ ///
+ /// Maximum recursive wrapper directory stripping depth.
+ ///
+ public const int MaxWrapperNormalizationDepth = 10;
+
+ ///
+ /// Supported archive file extensions.
+ ///
+ public static readonly IReadOnlyList ArchiveExtensions =
+ [
+ ".zip",
+ ".7z",
+ ".rar",
+ ".dat",
+ ];
+
+ ///
+ /// Canonical directory names used at the game workspace root.
+ ///
+ public static readonly IReadOnlyList RecognizedGameDirectories =
+ [
+ "Data",
+ "Art",
+ "Window",
+ "Audio",
+ "Maps",
+ "INI",
+ "Scripts",
+ "Textures",
+ "W3D",
+ "English",
+ "German",
+ "French",
+ "Italian",
+ "Spanish",
+ "Korean",
+ "Polish",
+ "Chinese",
+ ];
+
+ ///
+ /// Canonical file extensions for game assets, binaries, and configurations.
+ ///
+ public static readonly IReadOnlyList RecognizedGameFileExtensions =
+ [
+ ".big",
+ ".exe",
+ ".dll",
+ ".str",
+ ".csf",
+ ".ini",
+ ".map",
+ ".bik",
+ ".asi",
+ ];
+
+ ///
+ /// Extensions for loose non-game documentation and metadata files.
+ ///
+ public static readonly IReadOnlyList DocumentationExtensions =
+ [
+ ".txt",
+ ".url",
+ ".md",
+ ".htm",
+ ".html",
+ ".pdf",
+ ".lnk",
+ ".jpg",
+ ".jpeg",
+ ".png",
+ ".gif",
+ ".bmp",
+ ];
+
+ ///
+ /// System junk file or directory names to purge during payload normalization.
+ ///
+ public static readonly IReadOnlyList SystemJunkNames =
+ [
+ ".ds_store",
+ "thumbs.db",
+ "desktop.ini",
+ "__macosx",
+ ];
+
+ ///
+ /// Subfolder aliases that denote Zero Hour specific game content.
+ ///
+ public static readonly IReadOnlyList ZeroHourSubfolderAliases =
+ [
+ "Zero Hour",
+ "ZH",
+ "Command and Conquer Generals Zero Hour",
+ "Command & Conquer Generals - Zero Hour",
+ "Command & Conquer: Generals - Zero Hour",
+ "C&C Generals Zero Hour",
+ "ZeroHour",
+ ];
+
+ ///
+ /// Subfolder aliases that denote Generals specific game content.
+ ///
+ public static readonly IReadOnlyList GeneralsSubfolderAliases =
+ [
+ "Generals",
+ "CCG",
+ "Command and Conquer Generals",
+ "Command & Conquer Generals",
+ "C&C Generals",
+ ];
+
+ ///
+ /// Default variant resolution for control bar packages.
+ ///
+ public const string DefaultControlBarVariant = "1080p";
+
+ ///
+ /// Base filename for standard Control Bar Pro BIG archive.
+ ///
+ public const string ControlBarProBaseFileName = "340_ControlBarProZH.big";
+
+ ///
+ /// Base filename for Lemon Edition Control Bar Pro BIG archive.
+ ///
+ public const string ControlBarProLemonBaseFileName = "340_ControlBarProLemonEditionZH.big";
+
+ ///
+ /// Standard subfolder name for English BIG files.
+ ///
+ public const string BigEnDirectoryName = "BIG EN";
+
+ ///
+ /// Standard subfolder name for BIG files.
+ ///
+ public const string BigDirectoryName = "BIG";
+
+ ///
+ /// GenTool directory name.
+ ///
+ public const string GenToolDirectoryName = "GenTool";
+
+ ///
+ /// Window directory name.
+ ///
+ public const string WindowDirectoryName = "Window";
+
+ ///
+ /// Determines whether the specified directory name is a recognized canonical game directory.
+ ///
+ /// The directory name to check.
+ /// true if recognized; otherwise, false.
+ public static bool IsRecognizedGameDirectory(string? directoryName)
+ {
+ return !string.IsNullOrEmpty(directoryName) &&
+ RecognizedGameDirectories.Contains(directoryName, StringComparer.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Determines whether the specified file extension or file name represents a recognized game asset.
+ ///
+ /// The file name or extension to check.
+ /// true if recognized; otherwise, false.
+ public static bool IsRecognizedGameFile(string? fileNameOrExtension)
+ {
+ if (string.IsNullOrEmpty(fileNameOrExtension))
+ {
+ return false;
+ }
+
+ var ext = Path.GetExtension(fileNameOrExtension);
+ if (string.IsNullOrEmpty(ext))
+ {
+ ext = fileNameOrExtension;
+ }
+
+ return RecognizedGameFileExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase);
+ }
+}
diff --git a/GenHub/GenHub.Core/Constants/IoConstants.cs b/GenHub/GenHub.Core/Constants/IoConstants.cs
index 5b99c5710..9fc9be4f8 100644
--- a/GenHub/GenHub.Core/Constants/IoConstants.cs
+++ b/GenHub/GenHub.Core/Constants/IoConstants.cs
@@ -10,6 +10,11 @@ public static class IoConstants
///
public const int DefaultFileBufferSize = 4096;
+ ///
+ /// Buffer size used when scanning binary streams for embedded signatures (8KB).
+ ///
+ public const int SignatureScanBufferSize = 8192;
+
///
/// How many times a path may be re-resolved while following symbolic links whose targets are
/// themselves reached through links. Bounds the walk on a filesystem that contains a cycle.
diff --git a/GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs b/GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs
new file mode 100644
index 000000000..a165d5b67
--- /dev/null
+++ b/GenHub/GenHub.Core/Helpers/ContentPathPolicy.cs
@@ -0,0 +1,185 @@
+using System;
+using System.IO;
+using GenHub.Core.Models.Results;
+
+namespace GenHub.Core.Helpers;
+
+///
+/// Policy and validation helper for ensuring file system paths remain safely contained
+/// within a target root directory, preventing directory traversal and zip slip attacks across OS platforms.
+///
+public static class ContentPathPolicy
+{
+ ///
+ /// Resolves a candidate relative path within a designated root directory, ensuring that the
+ /// resolved canonical path is strictly contained within that root directory.
+ ///
+ /// The trusted root directory.
+ /// The relative path to validate and resolve.
+ ///
+ /// An containing the normalized absolute destination path if safe,
+ /// or a failure result if the path escapes the root directory or contains illegal rooted/traversal components.
+ ///
+ public static OperationResult ResolveContainedFile(string? rootDirectory, string? relativePath)
+ {
+ if (string.IsNullOrWhiteSpace(rootDirectory))
+ {
+ return OperationResult.CreateFailure("Root directory cannot be null or empty.");
+ }
+
+ if (string.IsNullOrWhiteSpace(relativePath))
+ {
+ return OperationResult.CreateFailure("Relative path cannot be null or empty.");
+ }
+
+ if (Path.IsPathRooted(relativePath) ||
+ (relativePath.Length >= 2 && relativePath[1] == ':' && char.IsLetter(relativePath[0])) ||
+ relativePath.StartsWith("\\\\", StringComparison.Ordinal) ||
+ relativePath.StartsWith("//", StringComparison.Ordinal))
+ {
+ return OperationResult.CreateFailure($"Relative path cannot be rooted or absolute: {relativePath}");
+ }
+
+ // Normalize directory separators
+ var normalizedRelative = relativePath.Replace('/', Path.DirectorySeparatorChar)
+ .Replace('\\', Path.DirectorySeparatorChar)
+ .TrimStart(Path.DirectorySeparatorChar);
+
+ if (string.IsNullOrWhiteSpace(normalizedRelative))
+ {
+ return OperationResult.CreateFailure("Normalized relative path cannot be empty.");
+ }
+
+ var normalizedRoot = rootDirectory.Replace('\\', Path.DirectorySeparatorChar)
+ .Replace('/', Path.DirectorySeparatorChar);
+ var fullRoot = Path.GetFullPath(normalizedRoot);
+ var fullCandidate = Path.GetFullPath(Path.Combine(fullRoot, normalizedRelative));
+
+ if (!IsContainedInternal(fullRoot, fullCandidate))
+ {
+ return OperationResult.CreateFailure(
+ $"Path '{relativePath}' escapes target root directory '{rootDirectory}'.");
+ }
+
+ return OperationResult.CreateSuccess(fullCandidate);
+ }
+
+ ///
+ /// Validates whether a candidate path is strictly contained within a designated root directory.
+ ///
+ /// The root directory.
+ /// The candidate path to check.
+ /// if the candidate path is contained within the root; otherwise .
+ public static bool IsContained(string? rootDirectory, string? candidatePath)
+ {
+ if (string.IsNullOrWhiteSpace(rootDirectory) || string.IsNullOrWhiteSpace(candidatePath))
+ {
+ return false;
+ }
+
+ try
+ {
+ var normalizedRoot = rootDirectory.Replace('\\', Path.DirectorySeparatorChar)
+ .Replace('/', Path.DirectorySeparatorChar);
+ var normalizedCandidate = candidatePath.Replace('\\', Path.DirectorySeparatorChar)
+ .Replace('/', Path.DirectorySeparatorChar);
+
+ var fullRoot = Path.GetFullPath(normalizedRoot);
+ var fullCandidate = Path.GetFullPath(normalizedCandidate);
+
+ return IsContainedInternal(fullRoot, fullCandidate);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static bool IsContainedInternal(string fullRoot, string fullCandidate)
+ {
+ var rootPrefix = fullRoot.EndsWith(Path.DirectorySeparatorChar)
+ ? fullRoot
+ : fullRoot + Path.DirectorySeparatorChar;
+
+ if (!fullCandidate.StartsWith(rootPrefix, PathHelper.PathComparison) &&
+ !fullCandidate.Equals(fullRoot, PathHelper.PathComparison))
+ {
+ return false;
+ }
+
+ var realRoot = ResolveRealPath(fullRoot);
+ var realCandidate = ResolveRealPath(fullCandidate);
+
+ var realRootPrefix = realRoot.EndsWith(Path.DirectorySeparatorChar)
+ ? realRoot
+ : realRoot + Path.DirectorySeparatorChar;
+
+ return realCandidate.StartsWith(realRootPrefix, PathHelper.PathComparison) ||
+ realCandidate.Equals(realRoot, PathHelper.PathComparison);
+ }
+
+ private static string ResolveRealPath(string path)
+ {
+ try
+ {
+ var current = path;
+ while (!string.IsNullOrEmpty(current))
+ {
+ if (TryResolveLink(current, path, out var resolvedPath))
+ {
+ return resolvedPath;
+ }
+
+ if (File.Exists(current))
+ {
+ break;
+ }
+
+ current = Path.GetDirectoryName(current);
+ }
+ }
+ catch
+ {
+ // Fallback to path if resolution fails
+ }
+
+ return path;
+ }
+
+ private static bool TryResolveLink(string current, string originalPath, out string resolvedPath)
+ {
+ resolvedPath = string.Empty;
+ var targetFullName = TryGetLinkTargetFullName(current);
+ if (string.IsNullOrEmpty(targetFullName))
+ {
+ return false;
+ }
+
+ var relativeSuffix = Path.GetRelativePath(current, originalPath);
+ resolvedPath = relativeSuffix == "."
+ ? targetFullName
+ : Path.GetFullPath(Path.Combine(targetFullName, relativeSuffix));
+ return true;
+ }
+
+ private static string? TryGetLinkTargetFullName(string path)
+ {
+ if (File.Exists(path))
+ {
+ var fileInfo = new FileInfo(path);
+ return fileInfo.LinkTarget != null
+ ? fileInfo.ResolveLinkTarget(returnFinalTarget: true)?.FullName
+ : null;
+ }
+
+ if (Directory.Exists(path))
+ {
+ var dirInfo = new DirectoryInfo(path);
+ return dirInfo.LinkTarget != null
+ ? dirInfo.ResolveLinkTarget(returnFinalTarget: true)?.FullName
+ : null;
+ }
+
+ return null;
+ }
+}
diff --git a/GenHub/GenHub.Core/Interfaces/Content/IArchivePayloadProcessor.cs b/GenHub/GenHub.Core/Interfaces/Content/IArchivePayloadProcessor.cs
new file mode 100644
index 000000000..de2a70b5f
--- /dev/null
+++ b/GenHub/GenHub.Core/Interfaces/Content/IArchivePayloadProcessor.cs
@@ -0,0 +1,52 @@
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Models.Enums;
+
+namespace GenHub.Core.Interfaces.Content;
+
+///
+/// Service for safely extracting archives and normalizing payload directory structures for game workspaces.
+///
+public interface IArchivePayloadProcessor
+{
+ ///
+ /// Extracts all archives located within the directory safely, recursively removing archive files after extraction.
+ ///
+ /// The directory containing extracted or downloaded content.
+ /// Optional content type to constrain executable archive extraction.
+ /// Cancellation token.
+ /// A task representing the asynchronous extraction operation.
+ Task ExtractArchivesSafelyAsync(
+ string extractedDirectory,
+ ContentType? contentType = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Normalizes the directory structure of an extracted payload, removing extraneous wrapper directories
+ /// and reconciling the content root with the workspace/target directory.
+ ///
+ /// The directory containing extracted files.
+ /// The content type (e.g. Mod, Map, GameClient, etc.).
+ /// The target game type (Generals or ZeroHour).
+ /// Cancellation token.
+ /// A task representing the asynchronous normalization operation.
+ Task NormalizeDirectoryStructureAsync(
+ string extractedDirectory,
+ ContentType contentType,
+ GameType targetGame,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Extracts archives safely and normalizes the payload directory structure in one coordinated operation.
+ ///
+ /// The directory containing extracted or downloaded content.
+ /// The content type (e.g. Mod, Map, GameClient, etc.).
+ /// The target game type (Generals or ZeroHour).
+ /// Cancellation token.
+ /// A task representing the asynchronous processing operation.
+ Task ProcessPayloadAsync(
+ string extractedDirectory,
+ ContentType contentType,
+ GameType targetGame,
+ CancellationToken cancellationToken = default);
+}
diff --git a/GenHub/GenHub.Core/Interfaces/Content/IControlBarPackageProcessor.cs b/GenHub/GenHub.Core/Interfaces/Content/IControlBarPackageProcessor.cs
new file mode 100644
index 000000000..1b416b594
--- /dev/null
+++ b/GenHub/GenHub.Core/Interfaces/Content/IControlBarPackageProcessor.cs
@@ -0,0 +1,58 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Models.Manifest;
+
+namespace GenHub.Core.Interfaces.Content;
+
+///
+/// Service for detecting, isolating, converting, and packaging Control Bar content into SAGE-compatible .big archives.
+///
+public interface IControlBarPackageProcessor
+{
+ ///
+ /// Checks whether the extracted directory or manifest represents a Control Bar mod or UI addon that needs repacking.
+ ///
+ /// The directory containing extracted files.
+ /// The content manifest.
+ /// True if the content is a Control Bar that requires processing.
+ bool IsControlBarContent(string extractedDirectory, ContentManifest manifest);
+
+ ///
+ /// Processes extracted Control Bar content: isolates the requested resolution variant, converts AVIF/WebP textures to TGA,
+ /// repacks Art/Data folders into .big archives, ensures metadata BIG is present, and cleans up raw sources.
+ ///
+ /// The directory containing extracted files.
+ /// The content manifest.
+ /// Optional explicit variant identifier (e.g. "1080p").
+ /// Cancellation token.
+ /// A list of generated or included .big file names.
+ Task> ProcessAndRepackControlBarAsync(
+ string extractedDirectory,
+ ContentManifest manifest,
+ string? requestedVariant = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Finds the variant BIG root directory within extracted content.
+ ///
+ /// The extracted root directory.
+ /// The variant identifier (e.g. "1080p").
+ /// The path to the variant root directory, or null if not found.
+ string? FindControlBarVariantBigRoot(string extractedDirectory, string variantId);
+
+ ///
+ /// Gets the normalized suffix for a variant identifier (e.g. "1080p" -> "1080").
+ ///
+ /// The variant identifier.
+ /// The normalized variant suffix.
+ string GetControlBarVariantSuffix(string variantId);
+
+ ///
+ /// Checks if a file is an allowed Control Bar .big archive for the given variant suffix.
+ ///
+ /// The file name.
+ /// The variant suffix.
+ /// True if the file is allowed.
+ bool IsAllowedControlBarBig(string fileName, string variantSuffix);
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Common/ArchivePayloadProcessorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Common/ArchivePayloadProcessorTests.cs
new file mode 100644
index 000000000..398f01d5c
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Common/ArchivePayloadProcessorTests.cs
@@ -0,0 +1,546 @@
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Threading.Tasks;
+using GenHub.Core.Models.Enums;
+using GenHub.Features.Content.Services.Common;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+using ContentType = GenHub.Core.Models.Enums.ContentType;
+
+namespace GenHub.Tests.Core.Features.Content.Common;
+
+///
+/// Unit tests for archive payload processing and directory structure normalization.
+///
+public sealed class ArchivePayloadProcessorTests : IDisposable
+{
+ private readonly string _stagingDirectory = Path.Combine(Path.GetTempPath(), "GenHubPayloadTests", Guid.NewGuid().ToString("N"));
+
+ ///
+ /// Verifies that extracting a valid ZIP archive unpacks all entries and removes the archive file.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task ExtractArchivesSafelyAsync_ValidZip_ExtractsAllEntriesAndDeletesZipAsync()
+ {
+ // Arrange
+ Directory.CreateDirectory(_stagingDirectory);
+ var zipPath = Path.Combine(_stagingDirectory, "test.zip");
+ {
+ using var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create);
+ {
+ using var writer1 = new StreamWriter(archive.CreateEntry("Data/INI/GameData.ini").Open());
+ await writer1.WriteAsync("GameData=1");
+ }
+
+ {
+ using var writer2 = new StreamWriter(archive.CreateEntry("Art/Textures/test.tga").Open());
+ await writer2.WriteAsync("Texture");
+ }
+ }
+
+ var processor = CreateProcessor();
+
+ // Act
+ await processor.ExtractArchivesSafelyAsync(_stagingDirectory);
+
+ // Assert
+ Assert.False(File.Exists(zipPath));
+ Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "INI", "GameData.ini")));
+ Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Art", "Textures", "test.tga")));
+ }
+
+ ///
+ /// Verifies that multi-level nested wrapper directories (e.g. ModDB mods like C&C Generals Undone)
+ /// are recursively flattened so game assets end up directly at the workspace root.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task NormalizeDirectoryStructureAsync_MultiLevelSingleWrapper_FlattensToRootAsync()
+ {
+ // Arrange
+ var nestedDir = Path.Combine(_stagingDirectory, "C&C Generals Undone v1.0", "C&C Generals Undone v1.0");
+ Directory.CreateDirectory(Path.Combine(nestedDir, "Art", "Textures"));
+ Directory.CreateDirectory(Path.Combine(nestedDir, "Data", "INI"));
+ Directory.CreateDirectory(Path.Combine(nestedDir, "Window"));
+
+ await File.WriteAllTextAsync(Path.Combine(nestedDir, "Readme.txt"), "Generals Undone Readme");
+ await File.WriteAllTextAsync(Path.Combine(nestedDir, "Art", "Textures", "test.tga"), "texture data");
+ await File.WriteAllTextAsync(Path.Combine(nestedDir, "Data", "INI", "GameData.ini"), "data");
+ await File.WriteAllTextAsync(Path.Combine(nestedDir, "Window", "MainMenu.wnd"), "window");
+
+ var processor = CreateProcessor();
+
+ // Act
+ await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour);
+
+ // Assert
+ Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Readme.txt")));
+ Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Art", "Textures", "test.tga")));
+ Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "INI", "GameData.ini")));
+ Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Window", "MainMenu.wnd")));
+
+ // Old wrapper paths should no longer exist
+ Assert.False(Directory.Exists(Path.Combine(_stagingDirectory, "C&C Generals Undone v1.0")));
+ }
+
+ ///
+ /// Verifies that loose documentation files at root alongside a single mod wrapper directory
+ /// are reconciled by promoting the mod contents to the root and keeping the documentation files.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task NormalizeDirectoryStructureAsync_LooseReadmeWithModWrapper_FlattensModWrapperAlongsideReadmeAsync()
+ {
+ // Arrange
+ Directory.CreateDirectory(_stagingDirectory);
+ await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "Readme.txt"), "Important instructions");
+ await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "ModDB_Link.url"), "https://www.moddb.com");
+
+ var modDir = Path.Combine(_stagingDirectory, "GeneralsUndone");
+ Directory.CreateDirectory(Path.Combine(modDir, "Data", "INI"));
+ Directory.CreateDirectory(Path.Combine(modDir, "Art", "Textures"));
+ await File.WriteAllTextAsync(Path.Combine(modDir, "Data", "INI", "GameData.ini"), "inidata");
+ await File.WriteAllTextAsync(Path.Combine(modDir, "Art", "Textures", "unit.tga"), "tgadata");
+
+ var processor = CreateProcessor();
+
+ // Act
+ await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour);
+
+ // Assert
+ Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Readme.txt")));
+ Assert.True(File.Exists(Path.Combine(_stagingDirectory, "ModDB_Link.url")));
+ Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "INI", "GameData.ini")));
+ Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Art", "Textures", "unit.tga")));
+ Assert.False(Directory.Exists(modDir));
+ }
+
+ ///
+ /// Verifies that game-specific subdirectories matching the target game (e.g. "Zero Hour")
+ /// are promoted to the payload root.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task NormalizeDirectoryStructureAsync_GameSpecificSubdirectory_PromotesMatchingTargetGameFolderAsync()
+ {
+ // Arrange
+ var zhDir = Path.Combine(_stagingDirectory, "Zero Hour", "Data", "INI");
+ Directory.CreateDirectory(zhDir);
+ await File.WriteAllTextAsync(Path.Combine(zhDir, "ZHData.ini"), "zh config");
+
+ var processor = CreateProcessor();
+
+ // Act
+ await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour);
+
+ // Assert
+ Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "INI", "ZHData.ini")));
+ Assert.False(Directory.Exists(Path.Combine(_stagingDirectory, "Zero Hour")));
+ }
+
+ ///
+ /// Verifies that single map directories for ContentType.Map are preserved with their map folder.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task NormalizeDirectoryStructureAsync_MapContent_PreservesSingleMapDirectoryAsync()
+ {
+ // Arrange
+ var mapDir = Path.Combine(_stagingDirectory, "Lemuria");
+ Directory.CreateDirectory(mapDir);
+ await File.WriteAllTextAsync(Path.Combine(mapDir, "Lemuria.map"), "map payload");
+ await File.WriteAllTextAsync(Path.Combine(mapDir, "Lemuria.tga"), "preview payload");
+
+ var processor = CreateProcessor();
+
+ // Act
+ await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Map, GameType.ZeroHour);
+
+ // Assert
+ Assert.True(Directory.Exists(mapDir));
+ Assert.True(File.Exists(Path.Combine(mapDir, "Lemuria.map")));
+ Assert.True(File.Exists(Path.Combine(mapDir, "Lemuria.tga")));
+ }
+
+ ///
+ /// Verifies that double-wrapped map archives (e.g. MapDownload/MapName/MapName.map)
+ /// strip only the outer wrapper while preserving the inner map folder.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task NormalizeDirectoryStructureAsync_MapContentWithDoubleWrapper_FlattensOuterWrapperOnlyAsync()
+ {
+ // Arrange
+ var outerWrapper = Path.Combine(_stagingDirectory, "MapDownloadWrapper");
+ var mapDir = Path.Combine(outerWrapper, "Lemuria");
+ Directory.CreateDirectory(mapDir);
+ await File.WriteAllTextAsync(Path.Combine(mapDir, "Lemuria.map"), "map payload");
+ await File.WriteAllTextAsync(Path.Combine(mapDir, "Lemuria.tga"), "preview payload");
+
+ var processor = CreateProcessor();
+
+ // Act
+ await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Map, GameType.ZeroHour);
+
+ // Assert
+ Assert.False(Directory.Exists(outerWrapper));
+ Assert.True(Directory.Exists(Path.Combine(_stagingDirectory, "Lemuria")));
+ Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Lemuria", "Lemuria.map")));
+ }
+
+ ///
+ /// Verifies that system junk files (.DS_Store, Thumbs.db, desktop.ini, __MACOSX)
+ /// are purged during normalization.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task NormalizeDirectoryStructureAsync_PurgesSystemJunkAsync()
+ {
+ // Arrange
+ Directory.CreateDirectory(Path.Combine(_stagingDirectory, "__MACOSX"));
+ Directory.CreateDirectory(Path.Combine(_stagingDirectory, "Data"));
+
+ await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, ".DS_Store"), "junk");
+ await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "Thumbs.db"), "junk");
+ await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "desktop.ini"), "junk");
+ await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "__MACOSX", "._something"), "junk");
+ await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "Data", "GameData.ini"), "real data");
+
+ var processor = CreateProcessor();
+
+ // Act
+ await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour);
+
+ // Assert
+ Assert.False(File.Exists(Path.Combine(_stagingDirectory, ".DS_Store")));
+ Assert.False(File.Exists(Path.Combine(_stagingDirectory, "Thumbs.db")));
+ Assert.False(File.Exists(Path.Combine(_stagingDirectory, "desktop.ini")));
+ Assert.False(Directory.Exists(Path.Combine(_stagingDirectory, "__MACOSX")));
+ Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "GameData.ini")));
+ }
+
+ ///
+ /// Verifies that an HTML error page pretending to be an archive is rejected.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task ExtractArchivesSafelyAsync_HtmlErrorPayload_ThrowsInvalidDataExceptionAsync()
+ {
+ // Arrange
+ Directory.CreateDirectory(_stagingDirectory);
+ var fakeZip = Path.Combine(_stagingDirectory, "broken.zip");
+ await File.WriteAllTextAsync(fakeZip, "Error 404 Not Found");
+
+ var processor = CreateProcessor();
+
+ // Act & Assert
+ var ex = await Assert.ThrowsAsync(
+ () => processor.ExtractArchivesSafelyAsync(_stagingDirectory));
+ Assert.Contains("HTML", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Verifies that a self-extracting .exe archive for a Mod is extracted safely and the source .exe is removed.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task ExtractArchivesSafelyAsync_SelfExtractingExeMod_ExtractsAndDeletesExeAsync()
+ {
+ // Arrange
+ Directory.CreateDirectory(_stagingDirectory);
+ var sfxExePath = Path.Combine(_stagingDirectory, "ShockWaveV1201.exe");
+ using (var archive = ZipFile.Open(sfxExePath, ZipArchiveMode.Create))
+ {
+ var entry = archive.CreateEntry("!ShockWave.big");
+ using var writer = new StreamWriter(entry.Open());
+ await writer.WriteAsync("BIG data payload");
+ }
+
+ var processor = CreateProcessor();
+
+ // Act
+ await processor.ExtractArchivesSafelyAsync(_stagingDirectory, ContentType.Mod);
+
+ // Assert
+ Assert.False(File.Exists(sfxExePath));
+ Assert.True(File.Exists(Path.Combine(_stagingDirectory, "!ShockWave.big")));
+ }
+
+ ///
+ /// Verifies that executable files for tools or executables are never extracted or deleted even if they are zip containers.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task ExtractArchivesSafelyAsync_ExecutableTool_DoesNotExtractOrDeleteExeAsync()
+ {
+ // Arrange
+ Directory.CreateDirectory(_stagingDirectory);
+ var toolExePath = Path.Combine(_stagingDirectory, "WorldBuilder.exe");
+ using (var archive = ZipFile.Open(toolExePath, ZipArchiveMode.Create))
+ {
+ var entry = archive.CreateEntry("internal.dll");
+ using var writer = new StreamWriter(entry.Open());
+ await writer.WriteAsync("dll");
+ }
+
+ var processor = CreateProcessor();
+
+ // Act
+ await processor.ExtractArchivesSafelyAsync(_stagingDirectory, ContentType.ModdingTool);
+
+ // Assert: Tool executable is preserved intact and NOT extracted
+ Assert.True(File.Exists(toolExePath));
+ Assert.False(File.Exists(Path.Combine(_stagingDirectory, "internal.dll")));
+ }
+
+ ///
+ /// Verifies that non-archive game.dat files are skipped and preserved.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task ExtractArchivesSafelyAsync_GameDatBinary_PreservedWithoutThrowingAsync()
+ {
+ // Arrange
+ Directory.CreateDirectory(_stagingDirectory);
+ var gameDatPath = Path.Combine(_stagingDirectory, "game.dat");
+ await File.WriteAllTextAsync(gameDatPath, "MZ_Binary_Executable_Payload_Not_Archive");
+
+ var processor = CreateProcessor();
+
+ // Act
+ await processor.ExtractArchivesSafelyAsync(_stagingDirectory, ContentType.Patch);
+
+ // Assert
+ Assert.True(File.Exists(gameDatPath));
+ }
+
+ ///
+ /// Verifies that valid .dat archives (e.g. 10zh.dat) are extracted.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task ExtractArchivesSafelyAsync_ValidDatArchive_ExtractsAndDeletesDatAsync()
+ {
+ // Arrange
+ Directory.CreateDirectory(_stagingDirectory);
+ var datArchivePath = Path.Combine(_stagingDirectory, "10zh.dat");
+ using (var archive = ZipFile.Open(datArchivePath, ZipArchiveMode.Create))
+ {
+ var entry = archive.CreateEntry("ZH/game.dat");
+ using var writer = new StreamWriter(entry.Open());
+ await writer.WriteAsync("ZH game binary");
+ }
+
+ var processor = CreateProcessor();
+
+ // Act
+ await processor.ExtractArchivesSafelyAsync(_stagingDirectory, ContentType.Patch);
+
+ // Assert
+ Assert.False(File.Exists(datArchivePath));
+ Assert.True(File.Exists(Path.Combine(_stagingDirectory, "ZH", "game.dat")));
+ }
+
+ ///
+ /// Verifies that inactive .gib mod files are renamed to .big during normalization.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task NormalizeDirectoryStructureAsync_GibFiles_NormalizesToBigAsync()
+ {
+ // Arrange
+ Directory.CreateDirectory(_stagingDirectory);
+ var gibPath = Path.Combine(_stagingDirectory, "!ShwAudio.gib");
+ await File.WriteAllTextAsync(gibPath, "Audio BIG payload");
+
+ var processor = CreateProcessor();
+
+ // Act
+ await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour);
+
+ // Assert
+ Assert.False(File.Exists(gibPath));
+ Assert.True(File.Exists(Path.Combine(_stagingDirectory, "!ShwAudio.big")));
+ }
+
+ ///
+ /// Verifies that self-extracting executable archives (e.g. ShockWaveV1201.exe with PE header followed by ZIP central directory)
+ /// are detected and extracted safely for mod content types.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task ExtractArchivesSafelyAsync_SelfExtractingExeArchive_ExtractsAndDeletesExeAsync()
+ {
+ // Arrange
+ Directory.CreateDirectory(_stagingDirectory);
+ var sfxExePath = Path.Combine(_stagingDirectory, "ShockWaveV1201.exe");
+
+ using (var memoryStream = new MemoryStream())
+ {
+ var peHeader = new byte[512];
+ peHeader[0] = 0x4D; // 'M'
+ peHeader[1] = 0x5A; // 'Z'
+ memoryStream.Write(peHeader, 0, peHeader.Length);
+
+ using (var zipArchive = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ {
+ var entry1 = zipArchive.CreateEntry("Data/INI/ShockWave.ini");
+ using var writer1 = new StreamWriter(entry1.Open());
+ await writer1.WriteAsync("ModName=ShockWave");
+ }
+
+ {
+ var entry2 = zipArchive.CreateEntry("!ShwAudio.gib");
+ using var writer2 = new StreamWriter(entry2.Open());
+ await writer2.WriteAsync("Audio content");
+ }
+ }
+
+ await File.WriteAllBytesAsync(sfxExePath, memoryStream.ToArray());
+ }
+
+ var processor = CreateProcessor();
+
+ // Act
+ await processor.ExtractArchivesSafelyAsync(_stagingDirectory, ContentType.Mod);
+
+ // Assert
+ Assert.False(File.Exists(sfxExePath));
+ Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Data", "INI", "ShockWave.ini")));
+ Assert.True(File.Exists(Path.Combine(_stagingDirectory, "!ShwAudio.gib")));
+ }
+
+ ///
+ /// Verifies that Smart Install Maker SFX executables (e.g. ShockWave) are safely extracted and normalized.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExtractArchivesSafelyAsync_WithSmartInstallMakerExecutable_ExtractsAndNormalizesSuccessfully()
+ {
+ var casPath = @"A:\Steam\steamapps\common\.genhub-cas\objects\f4\f45e14d6b4a1e6e6feaa2ad737528b385586ad81ab7535bf9a330972db834c4e";
+ if (!File.Exists(casPath))
+ {
+ return;
+ }
+
+ var testDir = Path.Combine(_stagingDirectory, "sim_test_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(testDir);
+
+ var installerPath = Path.Combine(testDir, "ShockWaveV1201.exe");
+ File.Copy(casPath, installerPath, overwrite: true);
+
+ var processor = CreateProcessor();
+
+ // 1. Extract archive safely
+ await processor.ExtractArchivesSafelyAsync(testDir, ContentType.Mod);
+
+ // 2. Original installer .exe should have been deleted after extraction
+ Assert.False(File.Exists(installerPath), "Installer executable should be removed after successful extraction.");
+
+ // 3. Normalize directory structure
+ await processor.NormalizeDirectoryStructureAsync(testDir, ContentType.Mod, GameType.ZeroHour);
+
+ // 4. Verify extracted and normalized game files exist with full uncompressed size
+ var textureBigPath = Path.Combine(testDir, "!ShwTextures.big");
+ Assert.True(File.Exists(textureBigPath), "Expected !ShwTextures.big to exist after normalization.");
+ var textureInfo = new FileInfo(textureBigPath);
+ Assert.True(textureInfo.Length > 60_000_000, $"Expected full textures >60MB, got {textureInfo.Length} bytes.");
+
+ Assert.True(
+ File.Exists(Path.Combine(testDir, "!!0ShwPtchIcon.big")),
+ "Expected !!0ShwPtchIcon.big to exist.");
+ Assert.True(
+ File.Exists(Path.Combine(testDir, "!ShwAudio.big")),
+ "Expected !ShwAudio.big to exist.");
+ Assert.True(
+ File.Exists(Path.Combine(testDir, "ShockWaveLauncher.exe")),
+ "Expected ShockWaveLauncher.exe to exist.");
+ }
+
+ ///
+ /// Verifies that payloads containing nested archives exceeding maximum extraction depth throw InvalidDataException.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExtractArchivesSafelyAsync_ExceedsMaxNestedDepth_ThrowsInvalidDataExceptionAsync()
+ {
+ // Arrange: create 6 layers of nested zips
+ Directory.CreateDirectory(_stagingDirectory);
+ var currentZip = Path.Combine(_stagingDirectory, "nested_level_6.zip");
+ {
+ using var archive = ZipFile.Open(currentZip, ZipArchiveMode.Create);
+ using var writer = new StreamWriter(archive.CreateEntry("Data/test.ini").Open());
+ await writer.WriteAsync("data=1");
+ }
+
+ for (var i = 5; i >= 1; i--)
+ {
+ var nextZip = Path.Combine(_stagingDirectory, $"nested_level_{i}.zip");
+ using (var archive = ZipFile.Open(nextZip, ZipArchiveMode.Create))
+ {
+ archive.CreateEntryFromFile(currentZip, Path.GetFileName(currentZip));
+ }
+
+ File.Delete(currentZip);
+ currentZip = nextZip;
+ }
+
+ var processor = CreateProcessor();
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ processor.ExtractArchivesSafelyAsync(_stagingDirectory));
+ }
+
+ ///
+ /// Verifies that wrapper promotion with colliding files preserving both files when content differs.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task NormalizeDirectoryStructureAsync_WrapperCollisionWithDifferentContent_PreservesBothFilesAsync()
+ {
+ // Arrange
+ Directory.CreateDirectory(_stagingDirectory);
+ var wrapperDir = Path.Combine(_stagingDirectory, "WrapperFolder");
+ Directory.CreateDirectory(Path.Combine(wrapperDir, "Data"));
+
+ // File at root
+ await File.WriteAllTextAsync(Path.Combine(_stagingDirectory, "Readme.txt"), "Root Readme content");
+
+ // File inside wrapper with same name but different content
+ await File.WriteAllTextAsync(Path.Combine(wrapperDir, "Readme.txt"), "Wrapper Readme content");
+ await File.WriteAllTextAsync(Path.Combine(wrapperDir, "Data", "GameData.ini"), "data=1");
+
+ var processor = CreateProcessor();
+
+ // Act
+ await processor.NormalizeDirectoryStructureAsync(_stagingDirectory, ContentType.Mod, GameType.ZeroHour);
+
+ // Assert
+ Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Readme.txt")));
+ Assert.True(File.Exists(Path.Combine(_stagingDirectory, "Readme_1.txt")));
+ var rootText = await File.ReadAllTextAsync(Path.Combine(_stagingDirectory, "Readme.txt"));
+ var wrapperText = await File.ReadAllTextAsync(Path.Combine(_stagingDirectory, "Readme_1.txt"));
+ Assert.Contains("Readme content", rootText);
+ Assert.Contains("Readme content", wrapperText);
+ Assert.NotEqual(rootText, wrapperText);
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (Directory.Exists(_stagingDirectory))
+ {
+ Directory.Delete(_stagingDirectory, recursive: true);
+ }
+ }
+
+ private static ArchivePayloadProcessor CreateProcessor()
+ {
+ return new ArchivePayloadProcessor(new Mock>().Object);
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostManifestFactoryTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostManifestFactoryTests.cs
index b976ece3e..c3d836dd0 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostManifestFactoryTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostManifestFactoryTests.cs
@@ -1,4 +1,5 @@
using GenHub.Core.Interfaces.Common;
+using GenHub.Core.Interfaces.Content;
using GenHub.Core.Models.CommunityOutpost;
using GenHub.Core.Models.Enums;
using GenHub.Core.Models.Manifest;
@@ -22,6 +23,7 @@ public class CommunityOutpostManifestFactoryTests : IDisposable
{
private readonly Mock> _loggerMock;
private readonly Mock _hashProviderMock;
+ private readonly Mock _controlBarProcessorMock;
private readonly CommunityOutpostManifestFactory _factory;
private readonly string _tempDir;
@@ -32,11 +34,12 @@ public CommunityOutpostManifestFactoryTests()
{
_loggerMock = new Mock>();
_hashProviderMock = new Mock();
+ _controlBarProcessorMock = new Mock();
_hashProviderMock.Setup(x => x.ComputeFileHashAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync("abc123hash");
- _factory = new CommunityOutpostManifestFactory(_loggerMock.Object, _hashProviderMock.Object, null!);
+ _factory = new CommunityOutpostManifestFactory(_loggerMock.Object, _hashProviderMock.Object, _controlBarProcessorMock.Object);
_tempDir = Path.Combine(Path.GetTempPath(), "GenHubTest_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs
index 8ecfe2931..112d9df4e 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs
@@ -1,6 +1,7 @@
using GenHub.Core.Interfaces.Content;
using GenHub.Core.Interfaces.GitHub;
using GenHub.Core.Models.Content;
+using GenHub.Core.Models.Enums;
using GenHub.Core.Models.Manifest;
using GenHub.Core.Models.Providers;
using GenHub.Core.Models.Results;
@@ -9,6 +10,7 @@
using GenHub.Features.Content.Services.GitHub;
using Microsoft.Extensions.Logging;
using Moq;
+using ContentType = GenHub.Core.Models.Enums.ContentType;
namespace GenHub.Tests.Core.Features.Content;
@@ -22,6 +24,7 @@ public class GitHubContentProviderTests
private readonly Mock _delivererMock;
private readonly Mock _validatorMock;
private readonly Mock> _loggerMock;
+ private readonly Mock _archiveProcessorMock;
private readonly GitHubContentProvider _provider;
///
@@ -34,6 +37,7 @@ public GitHubContentProviderTests()
_delivererMock = new Mock();
_validatorMock = new Mock();
_loggerMock = new Mock>();
+ _archiveProcessorMock = new Mock();
// Setup mocks to be correctly identified by the provider
_discovererMock.Setup(d => d.SourceName).Returns("GitHub");
@@ -62,7 +66,8 @@ public GitHubContentProviderTests()
[_delivererMock.Object],
_loggerMock.Object,
_validatorMock.Object,
- instructionsMock.Object);
+ instructionsMock.Object,
+ _archiveProcessorMock.Object);
}
///
@@ -139,5 +144,6 @@ public async Task PrepareContentAsync_CallsDelivererAndValidator_SuccessfullyAsy
// The base class should orchestrate the calls
_delivererMock.Verify(d => d.CanDeliver(It.IsAny()), Times.AtLeastOnce());
_delivererMock.Verify(d => d.DeliverContentAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Once());
+ _archiveProcessorMock.Verify(a => a.ProcessPayloadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once());
}
}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Common/ControlBarPackageProcessorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Common/ControlBarPackageProcessorTests.cs
new file mode 100644
index 000000000..9a21da8a5
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Common/ControlBarPackageProcessorTests.cs
@@ -0,0 +1,196 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.Manifest;
+using GenHub.Features.Content.Services.Common;
+using GenHub.Features.Content.Services.CommunityOutpost;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+using ContentType = GenHub.Core.Models.Enums.ContentType;
+
+namespace GenHub.Tests.Core.Features.Content.Services.Common;
+
+///
+/// Unit tests for .
+///
+public sealed class ControlBarPackageProcessorTests : IDisposable
+{
+ private readonly string _testDir = Path.Combine(Path.GetTempPath(), "GenHubTests", Guid.NewGuid().ToString("N"));
+
+ ///
+ public void Dispose()
+ {
+ if (Directory.Exists(_testDir))
+ {
+ try
+ {
+ Directory.Delete(_testDir, recursive: true);
+ }
+ catch
+ {
+ // Best effort
+ }
+ }
+ }
+
+ ///
+ /// Verifies that IsControlBarContent detects Control Bar manifests by identifier and name.
+ ///
+ [Fact]
+ public void IsControlBarContent_WithControlBarManifest_ReturnsTrue()
+ {
+ var converter = new CompressedImageToTgaConverter(NullLogger.Instance);
+ var processor = new ControlBarPackageProcessor(converter, NullLogger.Instance);
+
+ var manifest = new ContentManifest
+ {
+ Id = ManifestId.Create("1.103.github.addon.lemoncontrolbar1080p"),
+ Name = "Control Bar Pro Lemon Edition ZH (1080p)",
+ ContentType = ContentType.Addon,
+ };
+
+ var result = processor.IsControlBarContent(_testDir, manifest);
+
+ Assert.True(result);
+ }
+
+ ///
+ /// Verifies that nested resolution folder structure (ZH/1080p/BIG/...) is repacked into SAGE BIG archives.
+ ///
+ /// A completed task.
+ [Fact]
+ public async Task ProcessAndRepackControlBarAsync_WithNestedVariantStructure_RepacksToBigArchivesAsync()
+ {
+ // Arrange
+ var variantRoot = Path.Combine(_testDir, "ZH", "1080p", "BIG");
+ var windowDir = Path.Combine(variantRoot, "Window");
+ var artDir = Path.Combine(variantRoot, "Art", "Textures");
+ var genToolDir = Path.Combine(variantRoot, "GenTool");
+
+ Directory.CreateDirectory(windowDir);
+ Directory.CreateDirectory(artDir);
+ Directory.CreateDirectory(genToolDir);
+
+ await File.WriteAllTextAsync(Path.Combine(windowDir, "ControlBarPro.wnd"), "Window data");
+ await File.WriteAllTextAsync(Path.Combine(artDir, "test.tga"), "TGA Texture data");
+ await File.WriteAllTextAsync(Path.Combine(genToolDir, "fullviewport.dat"), "Viewport data");
+
+ var converter = new CompressedImageToTgaConverter(NullLogger.Instance);
+ var processor = new ControlBarPackageProcessor(converter, NullLogger.Instance);
+
+ var manifest = new ContentManifest
+ {
+ Id = ManifestId.Create("1.103.github.addon.lemoncontrolbar1080p"),
+ Name = "Control Bar Pro Lemon Edition ZH (1080p)",
+ ContentType = ContentType.Addon,
+ };
+
+ // Act
+ var outputFiles = await processor.ProcessAndRepackControlBarAsync(_testDir, manifest);
+
+ // Assert
+ Assert.Contains("340_ControlBarProArt1080ZH.big", outputFiles);
+ Assert.Contains("340_ControlBarProData1080ZH.big", outputFiles);
+ Assert.Contains("340_ControlBarProZH.big", outputFiles);
+
+ Assert.True(File.Exists(Path.Combine(_testDir, "340_ControlBarProArt1080ZH.big")));
+ Assert.True(File.Exists(Path.Combine(_testDir, "340_ControlBarProData1080ZH.big")));
+ Assert.True(File.Exists(Path.Combine(_testDir, "340_ControlBarProZH.big")));
+
+ // Verify that raw source folder was cleaned up
+ Assert.False(Directory.Exists(Path.Combine(_testDir, "ZH")));
+ }
+
+ ///
+ /// Verifies that flat prebuilt BIG files are identified and retained along with metadata BIG.
+ ///
+ /// A completed task.
+ [Fact]
+ public async Task ProcessAndRepackControlBarAsync_WithPrebuiltBigFiles_RetainsMatchingFilesAsync()
+ {
+ // Arrange
+ Directory.CreateDirectory(_testDir);
+ await File.WriteAllTextAsync(Path.Combine(_testDir, "340_ControlBarProArt1080ZH.big"), "BIG content");
+ await File.WriteAllTextAsync(Path.Combine(_testDir, "340_ControlBarProData1080ZH.big"), "BIG content");
+
+ var converter = new CompressedImageToTgaConverter(NullLogger.Instance);
+ var processor = new ControlBarPackageProcessor(converter, NullLogger.Instance);
+
+ var manifest = new ContentManifest
+ {
+ Id = ManifestId.Create("1.103.github.addon.lemoncontrolbar1080p"),
+ Name = "Control Bar Pro Lemon Edition ZH (1080p)",
+ ContentType = ContentType.Addon,
+ };
+
+ // Act
+ var outputFiles = await processor.ProcessAndRepackControlBarAsync(_testDir, manifest);
+
+ // Assert
+ Assert.Contains("340_ControlBarProArt1080ZH.big", outputFiles);
+ Assert.Contains("340_ControlBarProData1080ZH.big", outputFiles);
+ Assert.Contains("340_ControlBarProZH.big", outputFiles);
+ }
+
+ ///
+ /// Verifies that flat prebuilt Lemon Edition BIG files are identified and retained with existing Lemon Edition metadata.
+ ///
+ /// A completed task.
+ [Fact]
+ public async Task ProcessAndRepackControlBarAsync_WithLemonEditionPrebuiltBigFiles_RetainsLemonEditionFilesAsync()
+ {
+ // Arrange
+ Directory.CreateDirectory(_testDir);
+ await File.WriteAllTextAsync(Path.Combine(_testDir, "340_ControlBarProLemonEditionArt1080ZH.big"), "BIG art content");
+ await File.WriteAllTextAsync(Path.Combine(_testDir, "340_ControlBarProLemonEditionData1080ZH.big"), "BIG data content");
+ await File.WriteAllTextAsync(Path.Combine(_testDir, "340_ControlBarProLemonEditionZH.big"), "BIG base content");
+ await File.WriteAllTextAsync(Path.Combine(_testDir, "339_ControlBarProLemonEditionHideIpZH.big.BAK"), "BAK file");
+ await File.WriteAllTextAsync(Path.Combine(_testDir, "ReadMe.txt"), "readme");
+
+ var converter = new CompressedImageToTgaConverter(NullLogger.Instance);
+ var processor = new ControlBarPackageProcessor(converter, NullLogger.Instance);
+
+ var manifest = new ContentManifest
+ {
+ Id = ManifestId.Create("1.103.github.addon.lemoncontrolbar1080p"),
+ Name = "Control Bar Pro Lemon Edition ZH (1080p)",
+ ContentType = ContentType.Addon,
+ };
+
+ // Act
+ var outputFiles = await processor.ProcessAndRepackControlBarAsync(_testDir, manifest);
+
+ // Assert
+ Assert.Contains("340_ControlBarProLemonEditionArt1080ZH.big", outputFiles);
+ Assert.Contains("340_ControlBarProLemonEditionData1080ZH.big", outputFiles);
+ Assert.Contains("340_ControlBarProLemonEditionZH.big", outputFiles);
+ Assert.DoesNotContain("340_ControlBarProZH.big", outputFiles);
+ }
+
+ ///
+ /// Verifies that generic game folders like ZH without Control Bar markers or assets do not trigger Control Bar classification.
+ ///
+ [Fact]
+ public void IsControlBarContent_WithGenericGameDirectoryAndNoMarker_ReturnsFalse()
+ {
+ var zhDir = Path.Combine(_testDir, "ZH");
+ Directory.CreateDirectory(zhDir);
+ File.WriteAllText(Path.Combine(zhDir, "mod.big"), "some mod content");
+
+ var converter = new CompressedImageToTgaConverter(NullLogger.Instance);
+ var processor = new ControlBarPackageProcessor(converter, NullLogger.Instance);
+
+ var manifest = new ContentManifest
+ {
+ Id = ManifestId.Create("1.0.github.mod.somemod"),
+ Name = "Regular Mod (ZH)",
+ ContentType = ContentType.Mod,
+ };
+
+ var result = processor.IsControlBarContent(_testDir, manifest);
+
+ Assert.False(result);
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CommunityOutpost/CommunityOutpostDelivererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CommunityOutpost/CommunityOutpostDelivererTests.cs
index ee1ba5497..897625c84 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CommunityOutpost/CommunityOutpostDelivererTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CommunityOutpost/CommunityOutpostDelivererTests.cs
@@ -10,6 +10,7 @@
using GenHub.Core.Models.Content;
using GenHub.Core.Models.Manifest;
using GenHub.Core.Models.Results;
+using GenHub.Features.Content.Services.Common;
using GenHub.Features.Content.Services.CommunityOutpost;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
@@ -326,10 +327,13 @@ private static CommunityOutpostDeliverer CreateDeliverer(
IContentManifestPool manifestPool)
{
var converter = new CompressedImageToTgaConverter(NullLogger.Instance);
+ var controlBarProcessor = new ControlBarPackageProcessor(
+ converter,
+ NullLogger.Instance);
var manifestFactory = new CommunityOutpostManifestFactory(
NullLogger.Instance,
new Mock().Object,
- converter);
+ controlBarProcessor);
return new CommunityOutpostDeliverer(
downloadService,
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/ContentPathPolicyTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/ContentPathPolicyTests.cs
new file mode 100644
index 000000000..01fff88e1
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/ContentPathPolicyTests.cs
@@ -0,0 +1,94 @@
+using System;
+using System.IO;
+using GenHub.Core.Helpers;
+using Xunit;
+
+namespace GenHub.Tests.Core.Helpers;
+
+///
+/// Unit tests for .
+///
+public sealed class ContentPathPolicyTests : IDisposable
+{
+ private readonly string _tempRoot = Path.Combine(Path.GetTempPath(), "GenHubTests_PathPolicy_" + Guid.NewGuid().ToString("N"));
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ContentPathPolicyTests()
+ {
+ Directory.CreateDirectory(_tempRoot);
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (Directory.Exists(_tempRoot))
+ {
+ try
+ {
+ Directory.Delete(_tempRoot, recursive: true);
+ }
+ catch
+ {
+ // Best effort cleanup
+ }
+ }
+ }
+
+ ///
+ /// Verifies that valid contained relative paths resolve successfully.
+ ///
+ [Fact]
+ public void ResolveContainedFile_ValidRelativePath_ResolvesCorrectly()
+ {
+ var result = ContentPathPolicy.ResolveContainedFile(_tempRoot, "sub/file.txt");
+ var expected = Path.GetFullPath(Path.Combine(_tempRoot, "sub", "file.txt"));
+ Assert.True(result.Success);
+ Assert.Equal(expected, result.Data);
+ }
+
+ ///
+ /// Verifies that directory traversal sequences return a failure result.
+ ///
+ /// The traversal path to test.
+ [Theory]
+ [InlineData("../outside.txt")]
+ [InlineData("sub/../../outside.txt")]
+ [InlineData("..\\outside.txt")]
+ [InlineData("/etc/passwd")]
+ [InlineData("C:\\Windows\\System32\\cmd.exe")]
+ [InlineData("\\\\server\\share\\file.txt")]
+ public void ResolveContainedFile_PathEscapesRoot_ReturnsFailure(string maliciousPath)
+ {
+ var result = ContentPathPolicy.ResolveContainedFile(_tempRoot, maliciousPath);
+ Assert.False(result.Success);
+ }
+
+ ///
+ /// Verifies that null or whitespace inputs return a failure result.
+ ///
+ /// The invalid path to test.
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void ResolveContainedFile_NullOrEmptyRelativePath_ReturnsFailure(string? invalidPath)
+ {
+ var result = ContentPathPolicy.ResolveContainedFile(_tempRoot, invalidPath);
+ Assert.False(result.Success);
+ }
+
+ ///
+ /// Verifies that accurately detects containment.
+ ///
+ [Fact]
+ public void IsContained_ValidAndInvalidPaths_ReturnsExpectedBoolean()
+ {
+ var inside = Path.Combine(_tempRoot, "nested", "file.dll");
+ var outside = Path.Combine(Path.GetTempPath(), "other_dir", "file.dll");
+
+ Assert.True(ContentPathPolicy.IsContained(_tempRoot, inside));
+ Assert.False(ContentPathPolicy.IsContained(_tempRoot, outside));
+ }
+}
diff --git a/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs b/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs
new file mode 100644
index 000000000..77c7973ed
--- /dev/null
+++ b/GenHub/GenHub/Features/Content/Services/Common/ArchivePayloadProcessor.cs
@@ -0,0 +1,1549 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Helpers;
+using GenHub.Core.Interfaces.Content;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Utilities;
+using Microsoft.Extensions.Logging;
+using SharpCompress.Archives;
+using SharpCompress.Common;
+
+namespace GenHub.Features.Content.Services.Common;
+
+///
+/// Service for safely extracting archives and normalizing payload directory structures for game workspaces.
+///
+public class ArchivePayloadProcessor(ILogger logger) : IArchivePayloadProcessor
+{
+ private const int MaxNestedExtractionDepth = 5;
+ private static readonly byte[] SevenZipSignature = [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C];
+ private static readonly byte[] RarSignature = [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07];
+ private static readonly byte[] SmartInstallMakerSignature = [0x77, 0x77, 0x67, 0x54, 0x29, 0x48, 0x35, 0x14];
+
+ ///
+ public Task ExtractArchivesSafelyAsync(
+ string extractedDirectory,
+ ContentType? contentType = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (!Directory.Exists(extractedDirectory))
+ {
+ return Task.CompletedTask;
+ }
+
+ return Task.Run(
+ () =>
+ {
+ var depth = 0;
+ while (depth < MaxNestedExtractionDepth)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ depth++;
+
+ var archiveFiles = FindArchiveFiles(extractedDirectory, contentType);
+ if (archiveFiles.Count == 0)
+ {
+ break;
+ }
+
+ logger.LogInformation(
+ "Found {Count} archive(s) to extract in payload directory: {Directory} (pass {Pass})",
+ archiveFiles.Count,
+ extractedDirectory,
+ depth);
+
+ foreach (var archivePath in archiveFiles)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ EnsureValidArchivePayload(archivePath);
+ logger.LogInformation("Extracting archive safely: {ArchivePath}", archivePath);
+
+ ExtractSingleArchive(archivePath, extractedDirectory, cancellationToken);
+ File.Delete(archivePath);
+ logger.LogInformation("Extracted archive and removed archive source: {ArchivePath}", archivePath);
+ }
+ }
+
+ var remainingArchives = FindArchiveFiles(extractedDirectory, contentType);
+ if (remainingArchives.Count > 0)
+ {
+ throw new InvalidDataException(
+ $"Payload contains nested archives exceeding maximum extraction depth of {MaxNestedExtractionDepth}: {string.Join(", ", remainingArchives.Select(Path.GetFileName))}");
+ }
+ },
+ cancellationToken);
+ }
+
+ ///
+ public Task NormalizeDirectoryStructureAsync(
+ string extractedDirectory,
+ ContentType contentType,
+ GameType targetGame,
+ CancellationToken cancellationToken = default)
+ {
+ if (!Directory.Exists(extractedDirectory))
+ {
+ return Task.CompletedTask;
+ }
+
+ return Task.Run(
+ () =>
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // 1. Purge system junk files and folders
+ PurgeSystemJunk(extractedDirectory);
+
+ // 2. Iteratively strip single wrapper directories
+ StripSingleWrapperDirectories(extractedDirectory, contentType, cancellationToken);
+
+ // 3. Handle game-specific subdirectories (e.g. ZH, Zero Hour, Generals, CCG)
+ RouteGameSpecificSubdirectories(extractedDirectory, targetGame, cancellationToken);
+
+ // 4. Heuristic root content detection (single mod directory alongside loose documentation files)
+ ReconcileContentRootWithDocumentation(extractedDirectory, contentType, cancellationToken);
+
+ // 5. Normalize inactive .gib mod archive files to .big
+ NormalizeGibExtensions(extractedDirectory, contentType);
+
+ // 6. Cleanup empty directories
+ CleanupEmptyDirectories(extractedDirectory);
+ },
+ cancellationToken);
+ }
+
+ ///
+ public async Task ProcessPayloadAsync(
+ string extractedDirectory,
+ ContentType contentType,
+ GameType targetGame,
+ CancellationToken cancellationToken = default)
+ {
+ await ExtractArchivesSafelyAsync(extractedDirectory, contentType, cancellationToken);
+ await NormalizeDirectoryStructureAsync(extractedDirectory, contentType, targetGame, cancellationToken);
+ }
+
+ private static bool ShouldAttemptExecutableExtraction(ContentType? contentType)
+ {
+ if (!contentType.HasValue)
+ {
+ return false;
+ }
+
+ return contentType.Value switch
+ {
+ ContentType.ModdingTool => false,
+ ContentType.Executable => false,
+ ContentType.GameClient => false,
+ ContentType.GameInstallation => false,
+ _ => true,
+ };
+ }
+
+ private static bool IsArchiveFile(string filePath, ContentType? contentType = null)
+ {
+ if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
+ {
+ return false;
+ }
+
+ try
+ {
+ var info = new FileInfo(filePath);
+ if (info.Length == 0)
+ {
+ return false;
+ }
+
+ var extension = Path.GetExtension(filePath);
+
+ if (extension.Equals(".zip", StringComparison.OrdinalIgnoreCase) ||
+ extension.Equals(".7z", StringComparison.OrdinalIgnoreCase) ||
+ extension.Equals(".rar", StringComparison.OrdinalIgnoreCase) ||
+ extension.Equals(".tar", StringComparison.OrdinalIgnoreCase) ||
+ extension.Equals(".gz", StringComparison.OrdinalIgnoreCase) ||
+ extension.Equals(".tgz", StringComparison.OrdinalIgnoreCase) ||
+ extension.Equals(".bz2", StringComparison.OrdinalIgnoreCase) ||
+ extension.Equals(".xz", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ if (extension.Equals(".dat", StringComparison.OrdinalIgnoreCase))
+ {
+ return ArchiveFactory.IsArchive(filePath, out _) || ZipValidation.IsValidZipFile(filePath);
+ }
+
+ if (extension.Equals(".exe", StringComparison.OrdinalIgnoreCase))
+ {
+ if (!ShouldAttemptExecutableExtraction(contentType))
+ {
+ return false;
+ }
+
+ return IsSelfExtractingArchive(filePath);
+ }
+
+ if (string.IsNullOrEmpty(extension))
+ {
+ return ArchiveFactory.IsArchive(filePath, out _) || ZipValidation.IsValidZipFile(filePath);
+ }
+
+ return false;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static bool IsSelfExtractingArchive(string filePath)
+ {
+ try
+ {
+ using var zipArchive = ZipFile.OpenRead(filePath);
+ if (zipArchive.Entries.Count > 0)
+ {
+ return true;
+ }
+ }
+ catch
+ {
+ // Not a zip sfx
+ }
+
+ try
+ {
+ if (ArchiveFactory.IsArchive(filePath, out _) || ZipValidation.IsValidZipFile(filePath))
+ {
+ return true;
+ }
+ }
+ catch
+ {
+ // Ignore
+ }
+
+ try
+ {
+ using var stream = File.OpenRead(filePath);
+ if (FindSignatureOffset(stream, SevenZipSignature) >= 0)
+ {
+ return true;
+ }
+
+ stream.Position = 0;
+ if (FindSignatureOffset(stream, RarSignature) >= 0)
+ {
+ return true;
+ }
+
+ stream.Position = 0;
+ if (FindSignatureOffset(stream, SmartInstallMakerSignature) >= 0)
+ {
+ return true;
+ }
+ }
+ catch
+ {
+ // Ignore
+ }
+
+ return false;
+ }
+
+ private static long FindSignatureOffset(Stream stream, byte[] signature)
+ {
+ if (signature.Length == 0)
+ {
+ return -1;
+ }
+
+ // Keep the last partial match across chunk boundaries so a signature
+ // split between two reads is still detected.
+ var overlap = signature.Length - 1;
+ var buffer = new byte[IoConstants.SignatureScanBufferSize];
+ long streamOffset = 0;
+ var buffered = 0;
+ var read = 0;
+
+ while ((read = stream.Read(buffer.AsSpan(buffered))) > 0)
+ {
+ var available = buffered + read;
+ var index = buffer.AsSpan(0, available).IndexOf(signature);
+ if (index >= 0)
+ {
+ return streamOffset + index;
+ }
+
+ buffered = Math.Min(available, overlap);
+ buffer.AsSpan(available - buffered, buffered).CopyTo(buffer);
+ streamOffset += available - buffered;
+ }
+
+ return -1;
+ }
+
+ private static IReadOnlyList FindArchiveFiles(string rootDirectory, ContentType? contentType = null)
+ {
+ return Directory.GetFiles(rootDirectory, "*", SearchOption.AllDirectories)
+ .Where(file => IsArchiveFile(file, contentType))
+ .ToList();
+ }
+
+ private static void EnsureValidArchivePayload(string archivePath)
+ {
+ var info = new FileInfo(archivePath);
+ if (!info.Exists || info.Length == 0)
+ {
+ throw new InvalidDataException($"Archive file is missing or empty: {archivePath}");
+ }
+
+ Span header = stackalloc byte[16];
+ using (var stream = File.OpenRead(archivePath))
+ {
+ var read = stream.Read(header);
+ if (read == 0)
+ {
+ throw new InvalidDataException($"Archive file is empty: {archivePath}");
+ }
+
+ header = header[..read];
+ }
+
+ if (LooksLikeHtml(header))
+ {
+ var preview = ReadTextPreview(archivePath, maxChars: 120);
+ throw new InvalidDataException(
+ $"Downloaded file is HTML, not an archive (likely a broken download URL or HTTP error page): {archivePath}. Preview: {preview}");
+ }
+ }
+
+ private static bool LooksLikeHtml(ReadOnlySpan header)
+ {
+ if (header.Length >= 3 && header[0] == 0xEF && header[1] == 0xBB && header[2] == 0xBF)
+ {
+ header = header[3..];
+ }
+
+ while (header.Length > 0 && (header[0] == (byte)' ' || header[0] == (byte)'\t' || header[0] == (byte)'\r' || header[0] == (byte)'\n'))
+ {
+ header = header[1..];
+ }
+
+ if (header.Length < 5)
+ {
+ return false;
+ }
+
+ Span ascii = stackalloc char[Math.Min(header.Length, 9)];
+ for (var i = 0; i < ascii.Length; i++)
+ {
+ ascii[i] = (char)header[i];
+ }
+
+ ReadOnlySpan prefix = ascii;
+ return prefix.StartsWith(" !e.IsDirectory))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (string.IsNullOrEmpty(entry.Key))
+ {
+ continue;
+ }
+
+ entryCount++;
+ if (entryCount > CatalogConstants.MaxZipEntryCount)
+ {
+ throw new InvalidDataException(
+ $"Archive exceeds maximum entry count of {CatalogConstants.MaxZipEntryCount}");
+ }
+
+ if (Path.IsPathRooted(entry.Key))
+ {
+ throw new InvalidDataException($"Archive entry has an unsafe path: {entry.Key}");
+ }
+
+ var pathResult = ContentPathPolicy.ResolveContainedFile(extractRoot, entry.Key);
+ if (!pathResult.Success || string.IsNullOrEmpty(pathResult.Data))
+ {
+ throw new InvalidDataException($"Archive entry has an unsafe path: {entry.Key}");
+ }
+
+ var destinationPath = pathResult.Data;
+ var destinationDir = Path.GetDirectoryName(destinationPath);
+ if (!string.IsNullOrEmpty(destinationDir))
+ {
+ Directory.CreateDirectory(destinationDir);
+ }
+
+ using var entryStream = entry.OpenEntryStream();
+ CopyEntryWithCap(entryStream, destinationPath, ref totalUncompressedSize, cancellationToken);
+ }
+ }
+
+ private static void CopyEntryWithCap(
+ Stream source,
+ string destinationPath,
+ ref long totalBytesWritten,
+ CancellationToken cancellationToken)
+ {
+ using var dest = File.Create(destinationPath);
+ var buffer = new byte[81920];
+ var read = 0;
+ while ((read = source.Read(buffer, 0, buffer.Length)) > 0)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ totalBytesWritten += read;
+ if (totalBytesWritten > CatalogConstants.MaxZipUncompressedSizeBytes)
+ {
+ throw new InvalidDataException(
+ $"Archive exceeds maximum uncompressed size of {CatalogConstants.MaxZipUncompressedSizeBytes} bytes");
+ }
+
+ dest.Write(buffer, 0, read);
+ }
+ }
+
+ private static bool TryExtractZipArchive(
+ string archivePath,
+ string extractPath,
+ CancellationToken cancellationToken)
+ {
+ if (!ZipValidation.IsValidZipFile(archivePath))
+ {
+ return false;
+ }
+
+ try
+ {
+ using var zip = ZipFile.OpenRead(archivePath);
+ if (zip.Entries.Count == 0)
+ {
+ return false;
+ }
+
+ var entryCount = 0;
+ long totalUncompressedSize = 0;
+ var extractRoot = Path.GetFullPath(extractPath);
+
+ foreach (var entry in zip.Entries)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (string.IsNullOrEmpty(entry.FullName) || entry.FullName.EndsWith('/') || entry.FullName.EndsWith('\\'))
+ {
+ continue;
+ }
+
+ entryCount++;
+ if (entryCount > CatalogConstants.MaxZipEntryCount)
+ {
+ throw new InvalidDataException(
+ $"Archive exceeds maximum entry count of {CatalogConstants.MaxZipEntryCount}");
+ }
+
+ ExtractSingleZipEntry(entry, extractRoot, ref totalUncompressedSize, cancellationToken);
+ }
+
+ return true;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (InvalidDataException)
+ {
+ throw;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static void ExtractSingleZipEntry(
+ ZipArchiveEntry entry,
+ string extractRoot,
+ ref long totalUncompressedSize,
+ CancellationToken cancellationToken)
+ {
+ if (Path.IsPathRooted(entry.FullName))
+ {
+ throw new InvalidDataException($"Archive entry has an unsafe path: {entry.FullName}");
+ }
+
+ var pathResult = ContentPathPolicy.ResolveContainedFile(extractRoot, entry.FullName);
+ if (!pathResult.Success || string.IsNullOrEmpty(pathResult.Data))
+ {
+ throw new InvalidDataException($"Archive entry has an unsafe path: {entry.FullName}");
+ }
+
+ var destinationPath = pathResult.Data;
+ var destinationDir = Path.GetDirectoryName(destinationPath);
+ if (!string.IsNullOrEmpty(destinationDir))
+ {
+ Directory.CreateDirectory(destinationDir);
+ }
+
+ using var entryStream = entry.Open();
+ CopyEntryWithCap(entryStream, destinationPath, ref totalUncompressedSize, cancellationToken);
+ }
+
+ private static bool TryExtractSubStreamArchive(
+ string archivePath,
+ string extractPath,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ using var stream = File.OpenRead(archivePath);
+ var offset = FindSignatureOffset(stream, SevenZipSignature);
+ if (offset < 0)
+ {
+ stream.Position = 0;
+ offset = FindSignatureOffset(stream, RarSignature);
+ }
+
+ if (offset < 0)
+ {
+ return false;
+ }
+
+ stream.Position = offset;
+ using var subStream = new SubStream(stream, offset, stream.Length - offset);
+ using var archive = ArchiveFactory.OpenArchive(subStream);
+ ExtractSharpCompressArchive(archive, extractPath, cancellationToken);
+ return true;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (InvalidDataException)
+ {
+ throw;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static bool TryExtractSmartInstallMakerArchive(
+ string archivePath,
+ string extractPath,
+ CancellationToken cancellationToken)
+ {
+ var stagingDir = Path.Combine(extractPath, "_sim_staging_" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ using var stream = File.OpenRead(archivePath);
+ var sigOffset = FindSignatureOffset(stream, SmartInstallMakerSignature);
+ if (sigOffset < 0)
+ {
+ return false;
+ }
+
+ stream.Position = sigOffset + SmartInstallMakerSignature.Length;
+ var (fileTableData, payloadOffset) = ReadSmartInstallMakerMetadata(stream);
+ if (fileTableData == null || fileTableData.Length == 0 || payloadOffset < 0)
+ {
+ return false;
+ }
+
+ var records = ParseSmartInstallMakerFileTable(fileTableData, stream, payloadOffset);
+ if (records.Count == 0)
+ {
+ return false;
+ }
+
+ Directory.CreateDirectory(stagingDir);
+ var stagingRoot = Path.GetFullPath(stagingDir);
+ var extractedCount = ExtractSmartInstallMakerPayload(stream, payloadOffset, records, stagingRoot, cancellationToken);
+ if (extractedCount != records.Count)
+ {
+ throw new InvalidDataException(
+ $"Smart Install Maker extraction incomplete: extracted {extractedCount} of {records.Count} entries.");
+ }
+
+ PromoteDirectoryContents(stagingDir, extractPath);
+ return true;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (InvalidDataException)
+ {
+ throw;
+ }
+ catch
+ {
+ return false;
+ }
+ finally
+ {
+ try
+ {
+ if (Directory.Exists(stagingDir))
+ {
+ Directory.Delete(stagingDir, recursive: true);
+ }
+ }
+ catch
+ {
+ // Best effort cleanup
+ }
+ }
+ }
+
+ private static (byte[]? TableData, long PayloadOffset) ReadSmartInstallMakerMetadata(Stream stream)
+ {
+ var (secondToLastBlock, lastBlock) = WalkSmartInstallMakerBlocks(stream);
+ if (secondToLastBlock == null || lastBlock == null)
+ {
+ return (null, -1);
+ }
+
+ var payloadOffset = lastBlock.Value.DataStart;
+ var tableBlock = secondToLastBlock.Value;
+ if (tableBlock.CompType == 1)
+ {
+ var tableData = DecompressSimTableBlock(stream, tableBlock.DataStart);
+ return (tableData, payloadOffset);
+ }
+
+ return (null, payloadOffset);
+ }
+
+ private static ((long Pos, int CompSize, byte CompType, long DataStart)? SecondToLast, (long Pos, int CompSize, byte CompType, long DataStart)? Last) WalkSmartInstallMakerBlocks(Stream stream)
+ {
+ using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true);
+ (long Pos, int CompSize, byte CompType, long DataStart)? secondToLastBlock = null;
+ (long Pos, int CompSize, byte CompType, long DataStart)? lastBlock = null;
+ var blockCount = 0;
+ const int MaxBlockWalkCount = 100_000;
+
+ while (stream.Position < stream.Length - 13 && blockCount < MaxBlockWalkCount)
+ {
+ var pos = stream.Position;
+ _ = blockCount == 0 ? reader.ReadInt16() : reader.ReadInt32();
+ var compSize = reader.ReadInt32();
+ _ = reader.ReadInt32();
+ var compType = reader.ReadByte();
+ var dataLength = compSize - 5;
+ var dataStart = stream.Position;
+
+ secondToLastBlock = lastBlock;
+ lastBlock = (pos, compSize, compType, dataStart);
+ blockCount++;
+
+ if (dataLength > 0 && stream.Position + dataLength <= stream.Length)
+ {
+ stream.Position += dataLength;
+ }
+ else
+ {
+ break;
+ }
+ }
+
+ return (secondToLastBlock, lastBlock);
+ }
+
+ private static byte[] DecompressSimTableBlock(Stream stream, long dataStart)
+ {
+ stream.Position = dataStart + 2; // skip zlib 78-DA header
+ using var def = new DeflateStream(stream, CompressionMode.Decompress, leaveOpen: true);
+ using var ms = new MemoryStream();
+ var buf = new byte[8192];
+ var r = 0;
+ var totalDecompressed = 0L;
+ while ((r = def.Read(buf, 0, buf.Length)) > 0)
+ {
+ totalDecompressed += r;
+ if (totalDecompressed > CatalogConstants.MaxCatalogSizeBytes)
+ {
+ throw new InvalidDataException("Smart Install Maker metadata table exceeds maximum allowed size.");
+ }
+
+ ms.Write(buf, 0, r);
+ }
+
+ return ms.ToArray();
+ }
+
+ private static int ExtractSmartInstallMakerPayload(
+ Stream stream,
+ long payloadOffset,
+ List<(string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize)> records,
+ string extractRoot,
+ CancellationToken cancellationToken)
+ {
+ var extractedCount = 0;
+ var copyBuffer = new byte[65536];
+
+ foreach (var rec in records)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ ExtractSingleSmartInstallMakerRecord(stream, payloadOffset, rec, extractRoot, copyBuffer);
+ extractedCount++;
+ }
+
+ return extractedCount;
+ }
+
+ private static void ExtractSingleSmartInstallMakerRecord(
+ Stream stream,
+ long payloadOffset,
+ (string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize) rec,
+ string extractRoot,
+ byte[] copyBuffer)
+ {
+ var pathResult = ContentPathPolicy.ResolveContainedFile(extractRoot, rec.Name);
+ if (!pathResult.Success || string.IsNullOrEmpty(pathResult.Data))
+ {
+ throw new InvalidDataException($"Smart Install Maker entry has an unsafe path: {rec.Name}");
+ }
+
+ var destinationPath = pathResult.Data;
+ var destinationDir = Path.GetDirectoryName(destinationPath);
+ if (!string.IsNullOrEmpty(destinationDir))
+ {
+ Directory.CreateDirectory(destinationDir);
+ }
+
+ var filePos = payloadOffset + rec.StreamOffset;
+ if (filePos < 0 || filePos + rec.CompressedSize > stream.Length)
+ {
+ throw new InvalidDataException($"Smart Install Maker entry '{rec.Name}' compressed range exceeds stream bounds.");
+ }
+
+ stream.Position = filePos;
+ var header = new byte[2];
+ var headerRead = stream.Read(header, 0, 2);
+ stream.Position = filePos;
+
+ var written = TryDecompressSmartInstallMakerRecord(stream, filePos, header, headerRead, destinationPath, rec.UncompressedSize, copyBuffer);
+
+ if (written != rec.UncompressedSize && filePos + rec.UncompressedSize <= stream.Length)
+ {
+ // Fallback to raw copy if sniffed decompressor failed but raw payload is available
+ stream.Position = filePos;
+ using var outStream = File.Create(destinationPath);
+ written = 0;
+ while (written < rec.UncompressedSize)
+ {
+ var toRead = (int)Math.Min(copyBuffer.Length, rec.UncompressedSize - written);
+ var readBytes = stream.Read(copyBuffer, 0, toRead);
+ if (readBytes <= 0)
+ {
+ break;
+ }
+
+ outStream.Write(copyBuffer, 0, readBytes);
+ written += readBytes;
+ }
+ }
+
+ if (written != rec.UncompressedSize)
+ {
+ throw new InvalidDataException(
+ $"Smart Install Maker entry '{rec.Name}' decompressed size mismatch: expected {rec.UncompressedSize} bytes, got {written} bytes.");
+ }
+ }
+
+ private static long TryDecompressSmartInstallMakerRecord(
+ Stream stream,
+ long filePos,
+ byte[] header,
+ int headerRead,
+ string destinationPath,
+ uint uncompressedSize,
+ byte[] copyBuffer)
+ {
+ if (headerRead >= 2 && header[0] == 'B' && header[1] == 'Z')
+ {
+ return DecompressBz2SmartInstallMakerRecord(stream, destinationPath, uncompressedSize, copyBuffer);
+ }
+
+ if (headerRead >= 2 && header[0] == 0x78 && (header[1] == 0xDA || header[1] == 0x9C || header[1] == 0x01 || header[1] == 0x5E))
+ {
+ return DecompressDeflateSmartInstallMakerRecord(stream, filePos, destinationPath, uncompressedSize, copyBuffer);
+ }
+
+ return DecompressRawSmartInstallMakerRecord(stream, destinationPath, uncompressedSize, copyBuffer);
+ }
+
+ private static long DecompressBz2SmartInstallMakerRecord(Stream stream, string destinationPath, uint uncompressedSize, byte[] copyBuffer)
+ {
+ using var bz2 = SharpCompress.Compressors.BZip2.BZip2Stream.Create(
+ stream,
+ SharpCompress.Compressors.CompressionMode.Decompress,
+ decompressConcatenated: false,
+ leaveOpen: true);
+
+ using var outStream = File.Create(destinationPath);
+ long written = 0;
+ while (written < uncompressedSize)
+ {
+ var toRead = (int)Math.Min(copyBuffer.Length, uncompressedSize - written);
+ var readBytes = bz2.Read(copyBuffer, 0, toRead);
+ if (readBytes <= 0)
+ {
+ break;
+ }
+
+ outStream.Write(copyBuffer, 0, readBytes);
+ written += readBytes;
+ }
+
+ return written;
+ }
+
+ private static long DecompressDeflateSmartInstallMakerRecord(Stream stream, long filePos, string destinationPath, uint uncompressedSize, byte[] copyBuffer)
+ {
+ stream.Position = filePos + 2; // skip zlib header
+ using var def = new DeflateStream(stream, CompressionMode.Decompress, leaveOpen: true);
+ using var outStream = File.Create(destinationPath);
+ long written = 0;
+ while (written < uncompressedSize)
+ {
+ var toRead = (int)Math.Min(copyBuffer.Length, uncompressedSize - written);
+ var readBytes = def.Read(copyBuffer, 0, toRead);
+ if (readBytes <= 0)
+ {
+ break;
+ }
+
+ outStream.Write(copyBuffer, 0, readBytes);
+ written += readBytes;
+ }
+
+ return written;
+ }
+
+ private static long DecompressRawSmartInstallMakerRecord(Stream stream, string destinationPath, uint uncompressedSize, byte[] copyBuffer)
+ {
+ using var outStream = File.Create(destinationPath);
+ long written = 0;
+ while (written < uncompressedSize)
+ {
+ var toRead = (int)Math.Min(copyBuffer.Length, uncompressedSize - written);
+ var readBytes = stream.Read(copyBuffer, 0, toRead);
+ if (readBytes <= 0)
+ {
+ break;
+ }
+
+ outStream.Write(copyBuffer, 0, readBytes);
+ written += readBytes;
+ }
+
+ return written;
+ }
+
+ private static List<(string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize)> ParseSmartInstallMakerFileTable(
+ byte[] tableData,
+ Stream stream,
+ long payloadOffset)
+ {
+ var records = new List<(string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize)>();
+ var cumulativeUncompressedSize = 0L;
+ var index = 0;
+
+ while (index < tableData.Length - 4)
+ {
+ index = ProcessNextSimCandidate(tableData, index, stream, payloadOffset, records, ref cumulativeUncompressedSize);
+ }
+
+ return records;
+ }
+
+ private static int ProcessNextSimCandidate(
+ byte[] tableData,
+ int index,
+ Stream stream,
+ long payloadOffset,
+ List<(string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize)> records,
+ ref long cumulativeUncompressedSize)
+ {
+ if (tableData[index] != '.' || index < 40)
+ {
+ return index + 1;
+ }
+
+ if (!TryExtractSimCandidateName(tableData, index, out var name, out var nextIndex, out var startOffset))
+ {
+ return index + 1;
+ }
+
+ if (IsValidSimEntryName(name) &&
+ TryReadSimRecord(tableData, startOffset, name, stream, payloadOffset, records, out var record))
+ {
+ ValidateAndAddSimRecord(record, records, ref cumulativeUncompressedSize);
+ }
+
+ return nextIndex;
+ }
+
+ private static void ValidateAndAddSimRecord(
+ (string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize) record,
+ List<(string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize)> records,
+ ref long cumulativeUncompressedSize)
+ {
+ if (records.Count >= CatalogConstants.MaxZipEntryCount)
+ {
+ throw new InvalidDataException(
+ $"Smart Install Maker archive exceeds maximum entry count of {CatalogConstants.MaxZipEntryCount}");
+ }
+
+ cumulativeUncompressedSize += record.UncompressedSize;
+ if (cumulativeUncompressedSize > CatalogConstants.MaxZipUncompressedSizeBytes)
+ {
+ throw new InvalidDataException(
+ $"Smart Install Maker archive exceeds maximum uncompressed size of {CatalogConstants.MaxZipUncompressedSizeBytes} bytes");
+ }
+
+ records.Add(record);
+ }
+
+ private static bool TryExtractSimCandidateName(
+ byte[] tableData,
+ int dotIndex,
+ out string name,
+ out int nextIndex,
+ out int startOffset)
+ {
+ var start = dotIndex;
+ while (start > 0 && tableData[start - 1] != 0 && tableData[start - 1] >= 32 && tableData[start - 1] <= 126)
+ {
+ start--;
+ }
+
+ var end = dotIndex;
+ while (end < tableData.Length && tableData[end] != 0 && tableData[end] >= 32 && tableData[end] <= 126)
+ {
+ end++;
+ }
+
+ startOffset = start;
+ nextIndex = end;
+
+ if (start < 40 || end - start <= 3)
+ {
+ name = string.Empty;
+ return false;
+ }
+
+ name = Encoding.Latin1.GetString(tableData, start, end - start);
+ return true;
+ }
+
+ private static bool IsValidSimEntryName(string name)
+ {
+ if (!name.Contains('.') || name.StartsWith(' ') || name.Length <= 3 || name.Contains(".."))
+ {
+ return false;
+ }
+
+ if (name.EndsWith(".lnk", StringComparison.OrdinalIgnoreCase) ||
+ name.EndsWith("Intrnl.exe", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ var invalidChars = Path.GetInvalidPathChars().Concat([':', '"', '<', '>', '|', '*', '?']).ToArray();
+ if (name.IndexOfAny(invalidChars) >= 0)
+ {
+ return false;
+ }
+
+ var ext = Path.GetExtension(name);
+ return !string.IsNullOrEmpty(ext) && ext.Length <= 5;
+ }
+
+ private static bool TryReadSimRecord(
+ byte[] tableData,
+ int startOffset,
+ string name,
+ Stream stream,
+ long payloadOffset,
+ List<(string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize)> existingRecords,
+ out (string Name, uint UncompressedSize, uint StreamOffset, uint CompressedSize) record)
+ {
+ record = default;
+ var uncompSize = BitConverter.ToUInt32(tableData, startOffset - 40);
+ var streamOffset = BitConverter.ToUInt32(tableData, startOffset - 36);
+ var compSize = BitConverter.ToUInt32(tableData, startOffset - 32);
+
+ if (uncompSize == 0 || compSize == 0)
+ {
+ return false;
+ }
+
+ if ((ulong)uncompSize > (ulong)CatalogConstants.MaxZipUncompressedSizeBytes ||
+ payloadOffset + streamOffset + compSize > stream.Length)
+ {
+ return false;
+ }
+
+ if (existingRecords.Exists(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
+ {
+ return false;
+ }
+
+ record = (name, uncompSize, streamOffset, compSize);
+ return true;
+ }
+
+ private static void PurgeSystemJunk(string directory)
+ {
+ try
+ {
+ if (!Directory.Exists(directory))
+ {
+ return;
+ }
+
+ foreach (var subDir in Directory.GetDirectories(directory, "*", SearchOption.AllDirectories))
+ {
+ if (!Directory.Exists(subDir))
+ {
+ continue;
+ }
+
+ var dirName = Path.GetFileName(subDir);
+ if (GameContentConstants.SystemJunkNames.Contains(dirName, StringComparer.OrdinalIgnoreCase))
+ {
+ Directory.Delete(subDir, recursive: true);
+ }
+ }
+
+ foreach (var file in Directory.GetFiles(directory, "*", SearchOption.AllDirectories))
+ {
+ if (!File.Exists(file))
+ {
+ continue;
+ }
+
+ var fileName = Path.GetFileName(file);
+ if (GameContentConstants.SystemJunkNames.Contains(fileName, StringComparer.OrdinalIgnoreCase))
+ {
+ File.Delete(file);
+ }
+ }
+ }
+ catch
+ {
+ // Ignore system junk removal failures
+ }
+ }
+
+ private static bool ContainsRecognizedGameContent(string directory)
+ {
+ var subDirs = Directory.GetDirectories(directory, "*", SearchOption.TopDirectoryOnly)
+ .Select(Path.GetFileName)
+ .Where(name => !string.IsNullOrEmpty(name));
+
+ if (subDirs.Any(name => GameContentConstants.RecognizedGameDirectories.Contains(name, StringComparer.OrdinalIgnoreCase)))
+ {
+ return true;
+ }
+
+ var files = Directory.GetFiles(directory, "*", SearchOption.TopDirectoryOnly)
+ .Select(Path.GetExtension)
+ .Where(ext => !string.IsNullOrEmpty(ext));
+
+ return files.Any(ext => GameContentConstants.RecognizedGameFileExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase));
+ }
+
+ private static bool DirectoryContainsMapFilesDirectly(string directory)
+ {
+ return Directory.GetFiles(directory, "*.map", SearchOption.TopDirectoryOnly).Length > 0;
+ }
+
+ private static void PromoteDirectoryContents(string sourceDirectory, string targetDirectory)
+ {
+ if (!Directory.Exists(sourceDirectory) ||
+ string.Equals(Path.GetFullPath(sourceDirectory), Path.GetFullPath(targetDirectory), StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ var tempStaging = Path.Combine(Path.GetDirectoryName(sourceDirectory) ?? targetDirectory, Path.GetFileName(sourceDirectory))
+ + "_staging_" + Guid.NewGuid().ToString("N");
+
+ try
+ {
+ Directory.Move(sourceDirectory, tempStaging);
+
+ foreach (var subFile in Directory.GetFiles(tempStaging, "*", SearchOption.AllDirectories))
+ {
+ PromoteSingleStagedFile(subFile, tempStaging, targetDirectory);
+ }
+ }
+ catch
+ {
+ RollbackStaging(tempStaging, sourceDirectory);
+ throw;
+ }
+ finally
+ {
+ CleanupStaging(tempStaging);
+ }
+ }
+
+ private static void PromoteSingleStagedFile(string subFile, string tempStaging, string targetDirectory)
+ {
+ var relativePath = Path.GetRelativePath(tempStaging, subFile);
+ var destinationPath = Path.Combine(targetDirectory, relativePath);
+ var destinationDir = Path.GetDirectoryName(destinationPath);
+ if (!string.IsNullOrEmpty(destinationDir))
+ {
+ Directory.CreateDirectory(destinationDir);
+ }
+
+ if (File.Exists(destinationPath))
+ {
+ var destInfo = new FileInfo(destinationPath);
+ var srcInfo = new FileInfo(subFile);
+
+ if (destInfo.Length == srcInfo.Length && FilesHaveIdenticalContent(subFile, destinationPath))
+ {
+ File.Delete(subFile);
+ return;
+ }
+
+ var newDestPath = GetNonCollidingDestinationPath(destinationPath);
+ File.Move(subFile, newDestPath);
+ }
+ else
+ {
+ File.Move(subFile, destinationPath);
+ }
+ }
+
+ private static void RollbackStaging(string tempStaging, string sourceDirectory)
+ {
+ try
+ {
+ if (!Directory.Exists(tempStaging))
+ {
+ return;
+ }
+
+ if (!Directory.Exists(sourceDirectory))
+ {
+ Directory.Move(tempStaging, sourceDirectory);
+ return;
+ }
+
+ foreach (var remainingFile in Directory.GetFiles(tempStaging, "*", SearchOption.AllDirectories))
+ {
+ var rel = Path.GetRelativePath(tempStaging, remainingFile);
+ var backPath = Path.Combine(sourceDirectory, rel);
+ var dir = Path.GetDirectoryName(backPath);
+ if (!string.IsNullOrEmpty(dir))
+ {
+ Directory.CreateDirectory(dir);
+ }
+
+ File.Move(remainingFile, backPath, overwrite: true);
+ }
+ }
+ catch
+ {
+ // Best effort rollback
+ }
+ }
+
+ private static void CleanupStaging(string tempStaging)
+ {
+ if (Directory.Exists(tempStaging))
+ {
+ try
+ {
+ Directory.Delete(tempStaging, recursive: true);
+ }
+ catch
+ {
+ // Best effort cleanup
+ }
+ }
+ }
+
+ private static string GetNonCollidingDestinationPath(string destinationPath)
+ {
+ var dir = Path.GetDirectoryName(destinationPath) ?? string.Empty;
+ var fileNameWithoutExt = Path.GetFileNameWithoutExtension(destinationPath);
+ var ext = Path.GetExtension(destinationPath);
+ var counter = 1;
+ var newDestPath = Path.Combine(dir, $"{fileNameWithoutExt}_{counter}{ext}");
+ while (File.Exists(newDestPath))
+ {
+ counter++;
+ newDestPath = Path.Combine(dir, $"{fileNameWithoutExt}_{counter}{ext}");
+ }
+
+ return newDestPath;
+ }
+
+ private static bool FilesHaveIdenticalContent(string file1, string file2)
+ {
+ const int bufferSize = 65536;
+ var buffer1 = new byte[bufferSize];
+ var buffer2 = new byte[bufferSize];
+
+ using var s1 = File.OpenRead(file1);
+ using var s2 = File.OpenRead(file2);
+
+ if (s1.Length != s2.Length)
+ {
+ return false;
+ }
+
+ while (true)
+ {
+ var bytesRead1 = s1.Read(buffer1, 0, bufferSize);
+ if (bytesRead1 <= 0)
+ {
+ break;
+ }
+
+ var bytesRead2 = s2.Read(buffer2, 0, bufferSize);
+ if (bytesRead1 != bytesRead2)
+ {
+ return false;
+ }
+
+ if (!buffer1.AsSpan(0, bytesRead1).SequenceEqual(buffer2.AsSpan(0, bytesRead2)))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static void CleanupEmptyDirectories(string rootDirectory)
+ {
+ try
+ {
+ foreach (var subDir in Directory.GetDirectories(rootDirectory, "*", SearchOption.AllDirectories)
+ .Where(d => Directory.Exists(d) && !Directory.EnumerateFileSystemEntries(d).Any())
+ .OrderByDescending(d => d.Length))
+ {
+ Directory.Delete(subDir);
+ }
+ }
+ catch
+ {
+ // Ignore directory cleanup exceptions
+ }
+ }
+
+ private void StripSingleWrapperDirectories(
+ string extractedDirectory,
+ ContentType contentType,
+ CancellationToken cancellationToken)
+ {
+ var depth = 0;
+ while (depth < GameContentConstants.MaxWrapperNormalizationDepth)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ depth++;
+
+ var rootFiles = Directory.GetFiles(extractedDirectory, "*", SearchOption.TopDirectoryOnly);
+ var rootDirs = Directory.GetDirectories(extractedDirectory, "*", SearchOption.TopDirectoryOnly);
+
+ if (rootFiles.Length != 0 || rootDirs.Length != 1)
+ {
+ break;
+ }
+
+ var singleDir = rootDirs[0];
+ var dirName = Path.GetFileName(singleDir);
+
+ // For map content, if the single directory contains .map files directly, preserve this directory
+ if (contentType is ContentType.Map or ContentType.MapPack && DirectoryContainsMapFilesDirectly(singleDir))
+ {
+ logger.LogInformation("Preserving map folder structure for: {MapDir}", singleDir);
+ break;
+ }
+
+ // If the single directory is a canonical game directory (e.g. Data, Art, Window, Maps, Audio),
+ // it is already at the game root level (e.g. /Data/INI/...) and should NOT be flattened.
+ if (GameContentConstants.IsRecognizedGameDirectory(dirName))
+ {
+ logger.LogInformation("Preserving canonical game root directory: {SingleDir}", singleDir);
+ break;
+ }
+
+ logger.LogInformation("Flattening single wrapper directory: {SingleDir} into {Root}", singleDir, extractedDirectory);
+ PromoteDirectoryContents(singleDir, extractedDirectory);
+ }
+ }
+
+ private void RouteGameSpecificSubdirectories(
+ string extractedDirectory,
+ GameType targetGame,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var rootDirs = Directory.GetDirectories(extractedDirectory, "*", SearchOption.TopDirectoryOnly);
+ if (rootDirs.Length == 0)
+ {
+ return;
+ }
+
+ var matchingAliases = targetGame switch
+ {
+ GameType.ZeroHour => GameContentConstants.ZeroHourSubfolderAliases,
+ GameType.Generals => GameContentConstants.GeneralsSubfolderAliases,
+ _ => null,
+ };
+
+ if (matchingAliases == null)
+ {
+ return;
+ }
+
+ foreach (var subDir in rootDirs)
+ {
+ var dirName = Path.GetFileName(subDir);
+ if (matchingAliases.Contains(dirName, StringComparer.OrdinalIgnoreCase))
+ {
+ logger.LogInformation(
+ "Detected matching game-specific subdirectory '{DirName}' for game {Game}. Promoting contents to root.",
+ dirName,
+ targetGame);
+
+ PromoteDirectoryContents(subDir, extractedDirectory);
+ }
+ }
+ }
+
+ private void ReconcileContentRootWithDocumentation(
+ string extractedDirectory,
+ ContentType contentType,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (contentType is ContentType.Map or ContentType.MapPack)
+ {
+ return;
+ }
+
+ var rootFiles = Directory.GetFiles(extractedDirectory, "*", SearchOption.TopDirectoryOnly);
+ var rootDirs = Directory.GetDirectories(extractedDirectory, "*", SearchOption.TopDirectoryOnly);
+
+ if (rootDirs.Length != 1)
+ {
+ return;
+ }
+
+ var singleDir = rootDirs[0];
+ var dirName = Path.GetFileName(singleDir);
+
+ // If the single directory is already a canonical game directory (e.g. Data), it should remain as is
+ if (GameContentConstants.IsRecognizedGameDirectory(dirName))
+ {
+ return;
+ }
+
+ // Check if all files at the root level are loose documentation/metadata files
+ var allRootFilesAreDocs = rootFiles.All(file =>
+ {
+ var ext = Path.GetExtension(file);
+ return GameContentConstants.DocumentationExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase);
+ });
+
+ if (!allRootFilesAreDocs)
+ {
+ return;
+ }
+
+ // Check if the single directory contains recognizable game root folders or files
+ if (ContainsRecognizedGameContent(singleDir))
+ {
+ logger.LogInformation(
+ "Promoting game content root from wrapper '{SingleDir}' to payload root alongside documentation",
+ singleDir);
+
+ PromoteDirectoryContents(singleDir, extractedDirectory);
+ }
+ }
+
+ private void NormalizeGibExtensions(string extractedDirectory, ContentType contentType)
+ {
+ if (contentType is ContentType.ModdingTool or ContentType.Executable or ContentType.GameClient or ContentType.GameInstallation)
+ {
+ return;
+ }
+
+ try
+ {
+ foreach (var gibFile in Directory.GetFiles(extractedDirectory, "*.gib", SearchOption.AllDirectories))
+ {
+ var bigFile = Path.ChangeExtension(gibFile, ".big");
+ if (File.Exists(bigFile))
+ {
+ if (FilesHaveIdenticalContent(gibFile, bigFile))
+ {
+ File.Delete(gibFile);
+ logger.LogInformation("Removed duplicate identical inactive file '{GibFile}' as '{BigFile}' already exists", gibFile, bigFile);
+ }
+ else
+ {
+ var nonCollidingBigPath = GetNonCollidingDestinationPath(bigFile);
+ File.Move(gibFile, nonCollidingBigPath);
+ logger.LogInformation("Preserved differing inactive file '{GibFile}' by renaming to '{NewBigFile}'", gibFile, nonCollidingBigPath);
+ }
+ }
+ else
+ {
+ File.Move(gibFile, bigFile);
+ logger.LogInformation("Normalized inactive mod archive '{GibFile}' to '{BigFile}'", gibFile, bigFile);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to normalize .gib file extensions in: {Directory}", extractedDirectory);
+ }
+ }
+
+ private sealed class SubStream(Stream baseStream, long streamOffset, long length) : Stream
+ {
+ private long _position;
+
+ public override bool CanRead => baseStream.CanRead;
+
+ public override bool CanSeek => baseStream.CanSeek;
+
+ public override bool CanWrite => false;
+
+ public override long Length => length;
+
+ public override long Position
+ {
+ get => _position;
+ set
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(value);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(value, length);
+ _position = value;
+ }
+ }
+
+ public override void Flush() => baseStream.Flush();
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ if (_position >= length)
+ {
+ return 0;
+ }
+
+ var toRead = (int)Math.Min(count, length - _position);
+ baseStream.Position = streamOffset + _position;
+ var read = baseStream.Read(buffer, offset, toRead);
+ _position += read;
+ return read;
+ }
+
+ public override long Seek(long offset, SeekOrigin origin)
+ {
+ var target = origin switch
+ {
+ SeekOrigin.Begin => offset,
+ SeekOrigin.Current => _position + offset,
+ SeekOrigin.End => length + offset,
+ _ => throw new ArgumentOutOfRangeException(nameof(origin)),
+ };
+ Position = target;
+ return _position;
+ }
+
+ public override void SetLength(long value) => throw new NotSupportedException();
+
+ public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+ }
+}
diff --git a/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs b/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs
new file mode 100644
index 000000000..68fc6559a
--- /dev/null
+++ b/GenHub/GenHub/Features/Content/Services/Common/ControlBarPackageProcessor.cs
@@ -0,0 +1,595 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Interfaces.Content;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.Manifest;
+using GenHub.Features.Content.Services.CommunityOutpost;
+using Microsoft.Extensions.Logging;
+
+namespace GenHub.Features.Content.Services.Common;
+
+///
+/// Service for detecting, isolating, converting, and packaging Control Bar content into SAGE-compatible .big archives.
+///
+public class ControlBarPackageProcessor(
+ CompressedImageToTgaConverter avifConverter,
+ ILogger logger) : IControlBarPackageProcessor
+{
+ private const string ControlBarMetadataBigBase64 = "QklHRngBAAAAAAACAAAAUwAAAFMAAAEkQ29udHJvbEJhclByby50eHQAAAABdwAAAAFHZW5Ub29sXGZ1bGx2aWV3cG9ydC5kYXQAAAAAAAAAAABDb250cm9sIEJhciBQcm8gZm9yIENPTU1BTkQgQU5EIENPTlFVRVIgR0VORVJBTFM6IFpFUk8gSE9VUg0KDQpBVVRIT1I6DQpFQSBHYW1lcywgRkFTLCB4ZXpvbg0KDQpPUklHSU5BTCBET1dOTE9BRCBVUkw6DQpodHRwOi8vZ2VudG9vbC5uZXQvZG93bmxvYWQvY29udHJvbGJhcnBybw0KDQpTT1VSQ0UgQ09ERSAmIEFTU0VUUzoNCmh0dHBzOi8vZ2l0aHViLmNvbS9UaGVTdXBlckhhY2tlcnMvR2VuZXJhbHNDb250cm9sQmFyDQoNCkRPTkFUSU9OIExJTks6DQpodHRwczovL3d3dy5wYXlwYWwubWUvZ2VudG9vbA0KMQ==";
+
+ private static readonly string[] KnownResolutionVariants = ["720p", "900p", "1080p", "1440p", "4k", "2160p"];
+ private static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(1);
+ private static readonly Regex WordVariantRegex = new(@"\b(720p?|900p?|1080p?|1440p?|2160p?|4k)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled, RegexMatchTimeout);
+ private static readonly Regex InlineVariantRegex = new(@"(720p|900p|1080p|1440p|2160p|4k)", RegexOptions.IgnoreCase | RegexOptions.Compiled, RegexMatchTimeout);
+
+ ///
+ public bool IsControlBarContent(string extractedDirectory, ContentManifest manifest)
+ {
+ return HasControlBarManifestMetadata(manifest) || HasControlBarFiles(extractedDirectory);
+ }
+
+ ///
+ public async Task> ProcessAndRepackControlBarAsync(
+ string extractedDirectory,
+ ContentManifest manifest,
+ string? requestedVariant = null,
+ CancellationToken cancellationToken = default)
+ {
+ logger.LogInformation(
+ "Processing Control Bar packaging in {Directory} for manifest {ManifestId}",
+ extractedDirectory,
+ manifest.Id);
+
+ var variantId = DetermineVariantId(extractedDirectory, manifest, requestedVariant);
+ var variantSuffix = GetControlBarVariantSuffix(variantId);
+ var repackedOutputs = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ var variantBigRoot = FindControlBarVariantBigRoot(extractedDirectory, variantId);
+ if (!string.IsNullOrEmpty(variantBigRoot))
+ {
+ await ProcessVariantBigRootAsync(variantBigRoot, extractedDirectory, variantId, variantSuffix, repackedOutputs, cancellationToken);
+ }
+ else
+ {
+ CollectFlatPrebuiltBigs(extractedDirectory, variantSuffix, repackedOutputs);
+ }
+
+ await EnsureMetadataBigIncludedAsync(extractedDirectory, variantId, repackedOutputs, cancellationToken);
+ CleanupSourceDirectories(extractedDirectory, repackedOutputs);
+
+ return [.. repackedOutputs];
+ }
+
+ ///
+ public string? FindControlBarVariantBigRoot(string extractedDirectory, string variantId)
+ {
+ var rawSuffix = GetControlBarVariantSuffix(variantId);
+ var candidates = new[]
+ {
+ Path.Combine(extractedDirectory, "ZH", variantId, GameContentConstants.BigEnDirectoryName),
+ Path.Combine(extractedDirectory, "ZH", variantId, GameContentConstants.BigDirectoryName),
+ Path.Combine(extractedDirectory, "ZH", variantId),
+ Path.Combine(extractedDirectory, "ZH", rawSuffix, GameContentConstants.BigEnDirectoryName),
+ Path.Combine(extractedDirectory, "ZH", rawSuffix, GameContentConstants.BigDirectoryName),
+ Path.Combine(extractedDirectory, "ZH", rawSuffix),
+ Path.Combine(extractedDirectory, "CCG", variantId, GameContentConstants.BigEnDirectoryName),
+ Path.Combine(extractedDirectory, "CCG", variantId, GameContentConstants.BigDirectoryName),
+ Path.Combine(extractedDirectory, "CCG", variantId),
+ Path.Combine(extractedDirectory, "CCG", rawSuffix, GameContentConstants.BigEnDirectoryName),
+ Path.Combine(extractedDirectory, "CCG", rawSuffix, GameContentConstants.BigDirectoryName),
+ Path.Combine(extractedDirectory, "CCG", rawSuffix),
+ Path.Combine(extractedDirectory, variantId, GameContentConstants.BigEnDirectoryName),
+ Path.Combine(extractedDirectory, variantId, GameContentConstants.BigDirectoryName),
+ Path.Combine(extractedDirectory, variantId),
+ Path.Combine(extractedDirectory, rawSuffix, GameContentConstants.BigEnDirectoryName),
+ Path.Combine(extractedDirectory, rawSuffix, GameContentConstants.BigDirectoryName),
+ Path.Combine(extractedDirectory, rawSuffix),
+ };
+
+ var existingCandidate = candidates.FirstOrDefault(Directory.Exists);
+ if (existingCandidate != null)
+ {
+ return existingCandidate;
+ }
+
+ if (Directory.Exists(Path.Combine(extractedDirectory, GameContentConstants.WindowDirectoryName)) ||
+ Directory.Exists(Path.Combine(extractedDirectory, "Art")) ||
+ Directory.Exists(Path.Combine(extractedDirectory, "Data")) ||
+ Directory.Exists(Path.Combine(extractedDirectory, GameContentConstants.GenToolDirectoryName)))
+ {
+ return extractedDirectory;
+ }
+
+ return null;
+ }
+
+ ///
+ public string GetControlBarVariantSuffix(string variantId)
+ {
+ if (variantId.EndsWith("p", StringComparison.OrdinalIgnoreCase))
+ {
+ return variantId[..^1];
+ }
+
+ if (variantId.Equals("4k", StringComparison.OrdinalIgnoreCase))
+ {
+ return "4K";
+ }
+
+ return variantId;
+ }
+
+ ///
+ public bool IsAllowedControlBarBig(string fileName, string variantSuffix)
+ {
+ return fileName.Equals($"340_ControlBarProArt{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase)
+ || fileName.Equals($"340_ControlBarProData{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase)
+ || fileName.Equals($"340_ControlBarPro{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase)
+ || fileName.Equals($"340_ControlBarPro-Fix{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase)
+ || fileName.Equals(GameContentConstants.ControlBarProBaseFileName, StringComparison.OrdinalIgnoreCase)
+ || fileName.Equals($"340_ControlBarProLemonEditionArt{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase)
+ || fileName.Equals($"340_ControlBarProLemonEditionData{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase)
+ || fileName.Equals($"340_ControlBarProLemonEdition{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase)
+ || fileName.Equals($"340_ControlBarProLemonEdition-Fix{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase)
+ || fileName.Equals(GameContentConstants.ControlBarProLemonBaseFileName, StringComparison.OrdinalIgnoreCase)
+ || fileName.Equals("400_ControlBarHDEnglishZH.big", StringComparison.OrdinalIgnoreCase)
+ || fileName.Equals("400_ControlBarProCoreZH.big", StringComparison.OrdinalIgnoreCase)
+ || fileName.Equals("400_ControlBarHDBaseZH.big", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool HasControlBarManifestMetadata(ContentManifest manifest)
+ {
+ if (manifest.ContentType is not (ContentType.Addon or ContentType.Mod))
+ {
+ return false;
+ }
+
+ var id = manifest.Id.Value;
+ if (id.Contains("controlbar", StringComparison.OrdinalIgnoreCase) ||
+ id.Contains("cbpr", StringComparison.OrdinalIgnoreCase) ||
+ id.Contains("cbpx", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ var name = manifest.Name;
+ if (name.Contains("controlbar", StringComparison.OrdinalIgnoreCase) ||
+ name.Contains("control bar", StringComparison.OrdinalIgnoreCase) ||
+ name.Contains("control-bar", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ return manifest.Metadata?.Tags != null &&
+ manifest.Metadata.Tags.Any(t =>
+ t.Contains("controlbar", StringComparison.OrdinalIgnoreCase) ||
+ t.Contains("control-bar", StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static bool HasControlBarFiles(string extractedDirectory)
+ {
+ if (!Directory.Exists(extractedDirectory))
+ {
+ return false;
+ }
+
+ return Directory.EnumerateFiles(extractedDirectory, "*ControlBar*.big", SearchOption.AllDirectories).Any() ||
+ Directory.EnumerateFiles(extractedDirectory, "*ControlBar*.wnd", SearchOption.AllDirectories).Any();
+ }
+
+ private async Task ProcessVariantBigRootAsync(
+ string variantBigRoot,
+ string extractedDirectory,
+ string variantId,
+ string variantSuffix,
+ HashSet repackedOutputs,
+ CancellationToken cancellationToken)
+ {
+ var prebuiltBigs = Directory.GetFiles(variantBigRoot, "*.big", SearchOption.TopDirectoryOnly)
+ .Where(path => IsAllowedControlBarBig(Path.GetFileName(path), variantSuffix))
+ .ToArray();
+
+ if (prebuiltBigs.Length > 0)
+ {
+ await CopyPrebuiltBigsAsync(prebuiltBigs, extractedDirectory, repackedOutputs);
+ }
+ else
+ {
+ await RepackArtAndDataBigsAsync(variantBigRoot, extractedDirectory, variantId, variantSuffix, repackedOutputs, cancellationToken);
+ }
+ }
+
+ private async Task CopyPrebuiltBigsAsync(
+ IReadOnlyList prebuiltBigs,
+ string extractedDirectory,
+ HashSet repackedOutputs)
+ {
+ logger.LogInformation("Using prebuilt Control Bar BIG files");
+ foreach (var prebuiltBig in prebuiltBigs)
+ {
+ var bigName = Path.GetFileName(prebuiltBig);
+ var targetPath = Path.Combine(extractedDirectory, bigName);
+
+ if (!string.Equals(Path.GetFullPath(prebuiltBig), Path.GetFullPath(targetPath), StringComparison.OrdinalIgnoreCase))
+ {
+ await TryCopyFileWithRetryAsync(prebuiltBig, targetPath, logger);
+ }
+
+ repackedOutputs.Add(bigName);
+ }
+ }
+
+ private async Task RepackArtAndDataBigsAsync(
+ string variantBigRoot,
+ string extractedDirectory,
+ string variantId,
+ string variantSuffix,
+ HashSet repackedOutputs,
+ CancellationToken cancellationToken)
+ {
+ var artBigName = $"340_ControlBarProArt{variantSuffix}ZH.big";
+ var dataBigName = $"340_ControlBarProData{variantSuffix}ZH.big";
+
+ var artBigPath = Path.Combine(extractedDirectory, artBigName);
+ var dataBigPath = Path.Combine(extractedDirectory, dataBigName);
+
+ if (!File.Exists(artBigPath) || !File.Exists(dataBigPath))
+ {
+ await BuildAndPackArtAndDataBigsAsync(variantBigRoot, extractedDirectory, variantId, artBigPath, dataBigPath, cancellationToken);
+ }
+
+ if (File.Exists(artBigPath))
+ {
+ repackedOutputs.Add(artBigName);
+ }
+
+ if (File.Exists(dataBigPath))
+ {
+ repackedOutputs.Add(dataBigName);
+ }
+ }
+
+ private async Task BuildAndPackArtAndDataBigsAsync(
+ string variantBigRoot,
+ string extractedDirectory,
+ string variantId,
+ string artBigPath,
+ string dataBigPath,
+ CancellationToken cancellationToken)
+ {
+ logger.LogInformation("Repacking Control Bar variant {Variant} into Art/Data BIG files", variantId);
+
+ var tempRoot = Path.Combine(extractedDirectory, $"cbpro-pack-{variantId}");
+ var artPackRoot = Path.Combine(tempRoot, "ArtPack");
+ var dataPackRoot = Path.Combine(tempRoot, "DataPack");
+
+ if (Directory.Exists(tempRoot))
+ {
+ Directory.Delete(tempRoot, recursive: true);
+ }
+
+ Directory.CreateDirectory(artPackRoot);
+ Directory.CreateDirectory(dataPackRoot);
+
+ CopySourceDirectoriesToPacks(variantBigRoot, artPackRoot, dataPackRoot);
+
+ try
+ {
+ // Convert AVIF/WebP images to TGA prior to packing
+ await avifConverter.ConvertDirectoryAsync(artPackRoot, cancellationToken);
+ await avifConverter.ConvertDirectoryAsync(dataPackRoot, cancellationToken);
+
+ var tempArtBig = Path.Combine(tempRoot, "temp_art.big");
+ var tempDataBig = Path.Combine(tempRoot, "temp_data.big");
+
+ await BigFilePacker.PackAsync(artPackRoot, tempArtBig);
+ await BigFilePacker.PackAsync(dataPackRoot, tempDataBig);
+
+ File.Move(tempArtBig, artBigPath, overwrite: true);
+ File.Move(tempDataBig, dataBigPath, overwrite: true);
+ }
+ finally
+ {
+ try
+ {
+ if (Directory.Exists(tempRoot))
+ {
+ Directory.Delete(tempRoot, recursive: true);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to cleanup temporary pack directory {TempRoot}", tempRoot);
+ }
+ }
+ }
+
+ private static void CopySourceDirectoriesToPacks(string variantBigRoot, string artPackRoot, string dataPackRoot)
+ {
+ var artSource = Path.Combine(variantBigRoot, "Art");
+ var dataSource = Path.Combine(variantBigRoot, "Data");
+ var windowSource = Path.Combine(variantBigRoot, GameContentConstants.WindowDirectoryName);
+ var genToolSource = Path.Combine(variantBigRoot, GameContentConstants.GenToolDirectoryName);
+
+ if (Directory.Exists(artSource))
+ {
+ CopyDirectory(artSource, Path.Combine(artPackRoot, "Art"));
+ }
+
+ if (Directory.Exists(dataSource))
+ {
+ CopyDirectory(dataSource, Path.Combine(dataPackRoot, "Data"));
+ }
+
+ if (Directory.Exists(windowSource))
+ {
+ CopyDirectory(windowSource, Path.Combine(dataPackRoot, GameContentConstants.WindowDirectoryName));
+ }
+
+ if (Directory.Exists(genToolSource))
+ {
+ CopyDirectory(genToolSource, Path.Combine(dataPackRoot, GameContentConstants.GenToolDirectoryName));
+ }
+ }
+
+ private void CollectFlatPrebuiltBigs(
+ string extractedDirectory,
+ string variantSuffix,
+ HashSet repackedOutputs)
+ {
+ logger.LogInformation("Control Bar has flat structure, searching for prebuilt BIG files in root");
+ var prebuiltCandidates = Directory.GetFiles(extractedDirectory, "*ControlBarPro*ZH.big", SearchOption.TopDirectoryOnly)
+ .Where(path => IsAllowedControlBarBig(Path.GetFileName(path), variantSuffix))
+ .ToArray();
+
+ var hasArtDataSplit = prebuiltCandidates.Any(p =>
+ Path.GetFileName(p).Contains("Art", StringComparison.OrdinalIgnoreCase) ||
+ Path.GetFileName(p).Contains("Data", StringComparison.OrdinalIgnoreCase));
+
+ if (hasArtDataSplit)
+ {
+ prebuiltCandidates = [.. prebuiltCandidates.Where(p =>
+ {
+ var name = Path.GetFileName(p);
+ return name.Contains("Art", StringComparison.OrdinalIgnoreCase) ||
+ name.Contains("Data", StringComparison.OrdinalIgnoreCase) ||
+ name.Contains("-Fix", StringComparison.OrdinalIgnoreCase) ||
+ name.Equals(GameContentConstants.ControlBarProBaseFileName, StringComparison.OrdinalIgnoreCase) ||
+ name.Equals(GameContentConstants.ControlBarProLemonBaseFileName, StringComparison.OrdinalIgnoreCase);
+ })];
+ }
+
+ foreach (var candidate in prebuiltCandidates)
+ {
+ repackedOutputs.Add(Path.GetFileName(candidate));
+ }
+ }
+
+ private static bool IsMetadataOnlyBig(string fileName)
+ {
+ return fileName.Equals(GameContentConstants.ControlBarProBaseFileName, StringComparison.OrdinalIgnoreCase) ||
+ fileName.Equals(GameContentConstants.ControlBarProLemonBaseFileName, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private async Task EnsureMetadataBigIncludedAsync(
+ string extractedDirectory,
+ string variantId,
+ HashSet repackedOutputs,
+ CancellationToken cancellationToken)
+ {
+ var existingMetadataFileName = repackedOutputs.FirstOrDefault(IsMetadataOnlyBig);
+
+ if (existingMetadataFileName != null)
+ {
+ logger.LogInformation("Using existing Control Bar metadata file {FileName}", existingMetadataFileName);
+ return;
+ }
+
+ var metadataFileName = GameContentConstants.ControlBarProBaseFileName;
+ var metadataTargetPath = Path.Combine(extractedDirectory, metadataFileName);
+
+ if (!File.Exists(metadataTargetPath))
+ {
+ await TryLocateAndCopyMetadataBigAsync(extractedDirectory, variantId, metadataFileName, metadataTargetPath);
+ }
+
+ if (File.Exists(metadataTargetPath))
+ {
+ repackedOutputs.Add(metadataFileName);
+ logger.LogInformation("Including Control Bar metadata file {FileName} in outputs", metadataFileName);
+ return;
+ }
+
+ await WriteFallbackMetadataBigAsync(metadataTargetPath, metadataFileName, repackedOutputs, cancellationToken);
+ }
+
+ private async Task TryLocateAndCopyMetadataBigAsync(
+ string extractedDirectory,
+ string variantId,
+ string metadataFileName,
+ string metadataTargetPath)
+ {
+ var metadataSearchPaths = new[]
+ {
+ Path.Combine(extractedDirectory, "ZH", metadataFileName),
+ Path.Combine(extractedDirectory, "CCG", metadataFileName),
+ Path.Combine(extractedDirectory, "ZH", variantId, metadataFileName),
+ Path.Combine(extractedDirectory, "CCG", variantId, metadataFileName),
+ Path.Combine(extractedDirectory, "ZH", variantId, GameContentConstants.BigEnDirectoryName, metadataFileName),
+ Path.Combine(extractedDirectory, "ZH", variantId, GameContentConstants.BigDirectoryName, metadataFileName),
+ Path.Combine(extractedDirectory, "CCG", variantId, GameContentConstants.BigEnDirectoryName, metadataFileName),
+ Path.Combine(extractedDirectory, "CCG", variantId, GameContentConstants.BigDirectoryName, metadataFileName),
+ };
+
+ var foundSearchPath = metadataSearchPaths.FirstOrDefault(File.Exists);
+ if (foundSearchPath != null)
+ {
+ logger.LogInformation("Found Control Bar metadata file at {SourcePath}, copying to root", foundSearchPath);
+ await TryCopyFileWithRetryAsync(foundSearchPath, metadataTargetPath, logger);
+ }
+ }
+
+ private async Task WriteFallbackMetadataBigAsync(
+ string metadataTargetPath,
+ string metadataFileName,
+ HashSet repackedOutputs,
+ CancellationToken cancellationToken)
+ {
+ logger.LogWarning("Control Bar metadata file not found, writing embedded fallback");
+ try
+ {
+ var metadataBytes = Convert.FromBase64String(ControlBarMetadataBigBase64);
+ await File.WriteAllBytesAsync(metadataTargetPath, metadataBytes, cancellationToken);
+ repackedOutputs.Add(metadataFileName);
+ logger.LogInformation("Created Control Bar metadata file {FileName} from fallback", metadataFileName);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to create fallback Control Bar metadata file");
+ }
+ }
+
+ private static string DetermineVariantId(string extractedDirectory, ContentManifest manifest, string? requestedVariant)
+ {
+ if (!string.IsNullOrWhiteSpace(requestedVariant))
+ {
+ return requestedVariant;
+ }
+
+ var match = ExtractVariantToken(manifest.Id.Value) ?? ExtractVariantToken(manifest.Name);
+ if (!string.IsNullOrEmpty(match))
+ {
+ return match;
+ }
+
+ if (manifest.Metadata?.Tags != null)
+ {
+ var tagMatch = manifest.Metadata.Tags
+ .Select(ExtractVariantToken)
+ .FirstOrDefault(t => !string.IsNullOrEmpty(t));
+
+ if (!string.IsNullOrEmpty(tagMatch))
+ {
+ return tagMatch;
+ }
+ }
+
+ var existingResolution = KnownResolutionVariants.FirstOrDefault(candidate =>
+ Directory.Exists(Path.Combine(extractedDirectory, "ZH", candidate)) ||
+ Directory.Exists(Path.Combine(extractedDirectory, candidate)));
+
+ return existingResolution ?? GameContentConstants.DefaultControlBarVariant;
+ }
+
+ private static string? ExtractVariantToken(string? input)
+ {
+ if (string.IsNullOrWhiteSpace(input))
+ {
+ return null;
+ }
+
+ var match = WordVariantRegex.Match(input);
+ if (match.Success)
+ {
+ var token = match.Value.ToLowerInvariant();
+ return token switch
+ {
+ "720" => "720p",
+ "900" => "900p",
+ "1080" => "1080p",
+ "1440" => "1440p",
+ "2160" => "4k",
+ _ => token,
+ };
+ }
+
+ var inlineMatch = InlineVariantRegex.Match(input);
+ return inlineMatch.Success ? inlineMatch.Value.ToLowerInvariant() : null;
+ }
+
+ private static void CopyDirectory(string sourceDir, string targetDir)
+ {
+ Directory.CreateDirectory(targetDir);
+
+ foreach (var file in Directory.GetFiles(sourceDir))
+ {
+ File.Copy(file, Path.Combine(targetDir, Path.GetFileName(file)), overwrite: true);
+ }
+
+ foreach (var dir in Directory.GetDirectories(sourceDir))
+ {
+ CopyDirectory(dir, Path.Combine(targetDir, Path.GetFileName(dir)));
+ }
+ }
+
+ private static async Task TryCopyFileWithRetryAsync(string source, string destination, ILogger logger, int maxRetries = 3, int delayMs = 100)
+ {
+ for (var attempt = 1; attempt <= maxRetries; attempt++)
+ {
+ try
+ {
+ File.Copy(source, destination, overwrite: true);
+ return;
+ }
+ catch (IOException ex) when (attempt < maxRetries)
+ {
+ logger.LogWarning(
+ ex,
+ "File copy attempt {Attempt}/{MaxRetries} failed for {Source}: {Message}. Retrying...",
+ attempt,
+ maxRetries,
+ Path.GetFileName(source),
+ ex.Message);
+ await Task.Delay(delayMs);
+ }
+ }
+ }
+
+ private void CleanupSourceDirectories(string extractedDirectory, HashSet repackedOutputs)
+ {
+ // Destructive cleanup must never run when only the fallback metadata BIG was
+ // produced; otherwise source content that failed to package would be deleted.
+ var hasPackagedContent = repackedOutputs.Any(name => !IsMetadataOnlyBig(name));
+
+ if (!hasPackagedContent)
+ {
+ logger.LogWarning(
+ "Skipping Control Bar source cleanup because no content BIG files were produced for {Directory}",
+ extractedDirectory);
+ return;
+ }
+
+ try
+ {
+ var targetSourceDirNames = new[] { "ZH", "CCG", "Art", "Data", GameContentConstants.WindowDirectoryName, GameContentConstants.GenToolDirectoryName, "720p", "900p", "1080p", "1440p", "2160p", "4k" };
+ foreach (var dirName in targetSourceDirNames)
+ {
+ var dirPath = Path.Combine(extractedDirectory, dirName);
+ if (Directory.Exists(dirPath))
+ {
+ Directory.Delete(dirPath, recursive: true);
+ }
+ }
+
+ var looseFiles = Directory.GetFiles(extractedDirectory, "*.*", SearchOption.TopDirectoryOnly);
+ foreach (var file in looseFiles)
+ {
+ var fileName = Path.GetFileName(file);
+ if (!repackedOutputs.Contains(fileName) && !fileName.EndsWith(".big", StringComparison.OrdinalIgnoreCase))
+ {
+ File.Delete(file);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to clean up control bar source directories in {Directory}", extractedDirectory);
+ }
+ }
+}
diff --git a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostManifestFactory.cs
index ea79c8d72..47e09cdb5 100644
--- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostManifestFactory.cs
+++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostManifestFactory.cs
@@ -24,9 +24,8 @@ namespace GenHub.Features.Content.Services.CommunityOutpost;
public class CommunityOutpostManifestFactory(
ILogger logger,
IFileHashProvider hashProvider,
- CompressedImageToTgaConverter avifConverter) : IPublisherManifestFactory
+ IControlBarPackageProcessor controlBarProcessor) : IPublisherManifestFactory
{
- private const string ControlBarMetadataBigBase64 = "QklHRngBAAAAAAACAAAAUwAAAFMAAAEkQ29udHJvbEJhclByby50eHQAAAABdwAAAAFHZW5Ub29sXGZ1bGx2aWV3cG9ydC5kYXQAAAAAAAAAAABDb250cm9sIEJhciBQcm8gZm9yIENPTU1BTkQgQU5EIENPTlFVRVIgR0VORVJBTFM6IFpFUk8gSE9VUg0KDQpBVVRIT1I6DQpFQSBHYW1lcywgRkFTLCB4ZXpvbg0KDQpPUklHSU5BTCBET1dOTE9BRCBVUkw6DQpodHRwOi8vZ2VudG9vbC5uZXQvZG93bmxvYWQvY29udHJvbGJhcnBybw0KDQpTT1VSQ0UgQ09ERSAmIEFTU0VUUzoNCmh0dHBzOi8vZ2l0aHViLmNvbS9UaGVTdXBlckhhY2tlcnMvR2VuZXJhbHNDb250cm9sQmFyDQoNCkRPTkFUSU9OIExJTks6DQpodHRwczovL3d3dy5wYXlwYWwubWUvZ2VudG9vbA0KMQ==";
private static readonly ConcurrentDictionary RegexCache = new();
private static Regex GetCachedRegex(string pattern)
@@ -244,136 +243,6 @@ private static ContentInstallTarget DetermineFileInstallTarget(
return defaultTarget;
}
- private static string? FindControlBarVariantBigRoot(string extractedDirectory, string variantId)
- {
- var candidates = new[]
- {
- Path.Combine(extractedDirectory, "ZH", variantId, "BIG EN"),
- Path.Combine(extractedDirectory, "ZH", variantId, "BIG"),
- Path.Combine(extractedDirectory, "CCG", variantId, "BIG EN"),
- Path.Combine(extractedDirectory, "CCG", variantId, "BIG"),
- };
-
- foreach (var candidate in candidates)
- {
- if (Directory.Exists(candidate))
- {
- return candidate;
- }
- }
-
- return null;
- }
-
- private static string GetControlBarVariantSuffix(string variantId)
- {
- return variantId.EndsWith("p", StringComparison.OrdinalIgnoreCase)
- ? variantId[..^1]
- : variantId;
- }
-
- private static bool IsAllowedControlBarBig(string fileName, string variantSuffix)
- {
- return fileName.Equals($"340_ControlBarProArt{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase)
- || fileName.Equals($"340_ControlBarProData{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase)
- || fileName.Equals($"340_ControlBarPro{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase)
- || fileName.Equals($"340_ControlBarPro-Fix{variantSuffix}ZH.big", StringComparison.OrdinalIgnoreCase)
- || fileName.Equals("340_ControlBarProZH.big", StringComparison.OrdinalIgnoreCase)
- || fileName.Equals("400_ControlBarHDEnglishZH.big", StringComparison.OrdinalIgnoreCase)
- || fileName.Equals("400_ControlBarProCoreZH.big", StringComparison.OrdinalIgnoreCase);
- }
-
- ///
- /// Attempts to copy a file with retry logic for transient file lock issues.
- ///
- private static async Task TryCopyFileWithRetryAsync(string source, string destination, ILogger logger, int maxRetries = 3, int delayMs = 100)
- {
- for (var attempt = 1; attempt <= maxRetries; attempt++)
- {
- try
- {
- File.Copy(source, destination, overwrite: true);
- return;
- }
- catch (IOException ex) when (attempt < maxRetries)
- {
- logger.LogWarning(
- "File copy attempt {Attempt}/{MaxRetries} failed for {Source}: {Message}. Retrying...",
- attempt,
- maxRetries,
- Path.GetFileName(source),
- ex.Message);
- await Task.Delay(delayMs * attempt);
- }
- }
-
- // Final attempt without catch - let it throw if it fails
- File.Copy(source, destination, overwrite: true);
- }
-
- private static void CopyDirectory(string sourceDir, string destinationDir)
- {
- // Recursion guard
- var sourceInfo = new DirectoryInfo(sourceDir);
- var destInfo = new DirectoryInfo(destinationDir);
- if (destInfo.FullName.StartsWith(sourceInfo.FullName, StringComparison.OrdinalIgnoreCase))
- {
- throw new InvalidOperationException($"Cannot copy directory into itself: Source={sourceDir}, Dest={destinationDir}");
- }
-
- Directory.CreateDirectory(destinationDir);
-
- foreach (var file in Directory.GetFiles(sourceDir))
- {
- try
- {
- var targetFile = Path.Combine(destinationDir, Path.GetFileName(file));
- File.Copy(file, targetFile, overwrite: true);
- }
- catch (IOException)
- {
- throw;
- }
- catch (UnauthorizedAccessException)
- {
- throw;
- }
- }
-
- foreach (var dir in Directory.GetDirectories(sourceDir))
- {
- var targetDir = Path.Combine(destinationDir, Path.GetFileName(dir));
- CopyDirectory(dir, targetDir);
- }
- }
-
- private static HashSet CollectDependencyBigFiles(GenPatcherContentMetadata contentMetadata, GameType targetGame)
- {
- var dependencyBigFiles = new HashSet(StringComparer.OrdinalIgnoreCase);
- foreach (var dependency in contentMetadata.GetDependencies()
- .Where(d => d.InstallBehavior == DependencyInstallBehavior.AutoInstall))
- {
- var depId = dependency.Id.Value;
- var lastDot = depId.LastIndexOf('.');
- if (lastDot > -1 && lastDot < depId.Length - 1)
- {
- var depCode = depId[(lastDot + 1)..];
- var depMetadata = GenPatcherContentRegistry.GetMetadata(depCode);
- if (depMetadata.TargetGame != GameType.Unknown && depMetadata.TargetGame != targetGame)
- {
- continue;
- }
-
- if (!string.IsNullOrEmpty(depMetadata.OutputFilename))
- {
- dependencyBigFiles.Add(depMetadata.OutputFilename);
- }
- }
- }
-
- return dependencyBigFiles;
- }
-
///
/// Builds a manifest with all files from the extracted directory.
/// If variant is provided, filters files based on variant's IncludePatterns and ExcludePatterns.
@@ -415,9 +284,20 @@ private static HashSet CollectDependencyBigFiles(GenPatcherContentMetada
contentMetadata.SupportsVariants &&
variant != null;
- var controlBarRepackedOutputs = isControlBarVariant
- ? await PrepareControlBarVariantAsync(extractedDirectory, contentMetadata, variant!, cancellationToken)
- : new HashSet(StringComparer.OrdinalIgnoreCase);
+ HashSet controlBarRepackedOutputs;
+ if (isControlBarVariant)
+ {
+ var outputs = await controlBarProcessor.ProcessAndRepackControlBarAsync(
+ extractedDirectory,
+ originalManifest,
+ variant?.Id,
+ cancellationToken);
+ controlBarRepackedOutputs = new HashSet(outputs, StringComparer.OrdinalIgnoreCase);
+ }
+ else
+ {
+ controlBarRepackedOutputs = new HashSet(StringComparer.OrdinalIgnoreCase);
+ }
if (controlBarRepackedOutputs.Count > 0)
{
@@ -573,218 +453,31 @@ private static HashSet CollectDependencyBigFiles(GenPatcherContentMetada
}
}
- private async Task> PrepareControlBarVariantAsync(
- string extractedDirectory,
- GenPatcherContentMetadata contentMetadata,
- ContentVariant variant,
- CancellationToken cancellationToken)
+ private static HashSet CollectDependencyBigFiles(GenPatcherContentMetadata contentMetadata, GameType targetGame)
{
- var controlBarRepackedOutputs = new HashSet(StringComparer.OrdinalIgnoreCase);
- var variantSuffix = GetControlBarVariantSuffix(variant.Id);
- var variantBigRoot = FindControlBarVariantBigRoot(extractedDirectory, variant.Id);
-
- if (!string.IsNullOrEmpty(variantBigRoot))
- {
- var prebuiltBigs = Directory.GetFiles(variantBigRoot, "*.big", SearchOption.TopDirectoryOnly)
- .Where(path => IsAllowedControlBarBig(Path.GetFileName(path), variantSuffix))
- .ToArray();
-
- if (prebuiltBigs.Length > 0)
- {
- logger.LogInformation("Using prebuilt control bar BIG files from {VariantRoot}", variantBigRoot);
- foreach (var prebuiltBig in prebuiltBigs)
- {
- var bigName = Path.GetFileName(prebuiltBig);
- var targetPath = Path.Combine(extractedDirectory, bigName);
-
- if (!string.Equals(Path.GetFullPath(prebuiltBig), Path.GetFullPath(targetPath), StringComparison.OrdinalIgnoreCase))
- {
- await TryCopyFileWithRetryAsync(prebuiltBig, targetPath, logger);
- }
-
- controlBarRepackedOutputs.Add(bigName);
- }
- }
- else
- {
- var artBigName = $"340_ControlBarProArt{variantSuffix}ZH.big";
- var dataBigName = $"340_ControlBarProData{variantSuffix}ZH.big";
- var artBigPath = Path.Combine(extractedDirectory, artBigName);
- var dataBigPath = Path.Combine(extractedDirectory, dataBigName);
-
- if (!File.Exists(artBigPath) || !File.Exists(dataBigPath))
- {
- logger.LogInformation("Repacking control bar variant {Variant} into Art/Data BIG files", variant.Name);
- var artSource = Path.Combine(variantBigRoot, "Art");
- var dataSource = Path.Combine(variantBigRoot, "Data");
- var windowSource = Path.Combine(variantBigRoot, "Window");
- var genToolSource = Path.Combine(variantBigRoot, "GenTool");
-
- var tempRoot = Path.Combine(extractedDirectory, $"cbpro-pack-{variant.Id}");
- var artPackRoot = Path.Combine(tempRoot, "ArtPack");
- var dataPackRoot = Path.Combine(tempRoot, "DataPack");
-
- if (Directory.Exists(tempRoot))
- {
- Directory.Delete(tempRoot, recursive: true);
- }
-
- Directory.CreateDirectory(artPackRoot);
- Directory.CreateDirectory(dataPackRoot);
-
- if (Directory.Exists(artSource))
- {
- CopyDirectory(artSource, Path.Combine(artPackRoot, "Art"));
- }
-
- if (Directory.Exists(dataSource))
- {
- CopyDirectory(dataSource, Path.Combine(dataPackRoot, "Data"));
- }
-
- if (Directory.Exists(windowSource))
- {
- CopyDirectory(windowSource, Path.Combine(dataPackRoot, "Window"));
- }
-
- if (Directory.Exists(genToolSource))
- {
- CopyDirectory(genToolSource, Path.Combine(dataPackRoot, "GenTool"));
- }
-
- try
- {
- await avifConverter.ConvertDirectoryAsync(artPackRoot, cancellationToken);
- await avifConverter.ConvertDirectoryAsync(dataPackRoot, cancellationToken);
-
- await BigFilePacker.PackAsync(artPackRoot, artBigPath);
- await BigFilePacker.PackAsync(dataPackRoot, dataBigPath);
- }
- finally
- {
- try
- {
- if (Directory.Exists(tempRoot))
- {
- Directory.Delete(tempRoot, recursive: true);
- }
- }
- catch (Exception ex)
- {
- logger.LogWarning(ex, "Failed to cleanup temp root {TempRoot}", tempRoot);
- }
- }
- }
-
- if (File.Exists(artBigPath))
- {
- controlBarRepackedOutputs.Add(artBigName);
- }
-
- if (File.Exists(dataBigPath))
- {
- controlBarRepackedOutputs.Add(dataBigName);
- }
- }
- }
- else
+ var dependencyBigFiles = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var dependency in contentMetadata.GetDependencies()
+ .Where(d => d.InstallBehavior == DependencyInstallBehavior.AutoInstall))
{
- logger.LogInformation("Control bar has flat structure (cbpx-style), searching for prebuilt BIG files in root");
- var prebuiltCandidates = Directory.GetFiles(extractedDirectory, "*ControlBarPro*ZH.big", SearchOption.TopDirectoryOnly)
- .Where(path => IsAllowedControlBarBig(Path.GetFileName(path), variantSuffix))
- .ToArray();
-
- var hasArtDataSplit = prebuiltCandidates.Any(p =>
- Path.GetFileName(p).StartsWith("340_ControlBarProArt", StringComparison.OrdinalIgnoreCase) ||
- Path.GetFileName(p).StartsWith("340_ControlBarProData", StringComparison.OrdinalIgnoreCase));
-
- if (hasArtDataSplit)
- {
- prebuiltCandidates = [.. prebuiltCandidates
- .Where(p =>
- {
- var name = Path.GetFileName(p);
- if (name.StartsWith("340_ControlBarProArt", StringComparison.OrdinalIgnoreCase) ||
- name.StartsWith("340_ControlBarProData", StringComparison.OrdinalIgnoreCase) ||
- name.Contains("-Fix", StringComparison.OrdinalIgnoreCase) ||
- name.Equals("340_ControlBarProZH.big", StringComparison.OrdinalIgnoreCase))
- {
- return true;
- }
-
- logger.LogDebug("Excluding monolithic BIG {Name} in favor of Art/Data split files", name);
- return false;
- })];
- }
-
- if (prebuiltCandidates.Length > 0)
+ var depId = dependency.Id.Value;
+ var lastDot = depId.LastIndexOf('.');
+ if (lastDot > -1 && lastDot < depId.Length - 1)
{
- logger.LogInformation(
- "Using {Count} prebuilt control bar BIG files from flat structure: {Files}",
- prebuiltCandidates.Length,
- string.Join(", ", prebuiltCandidates.Select(Path.GetFileName)));
-
- foreach (var candidate in prebuiltCandidates)
+ var depCode = depId[(lastDot + 1)..];
+ var depMetadata = GenPatcherContentRegistry.GetMetadata(depCode);
+ if (depMetadata.TargetGame != GameType.Unknown && depMetadata.TargetGame != targetGame)
{
- controlBarRepackedOutputs.Add(Path.GetFileName(candidate));
+ continue;
}
- }
- else
- {
- logger.LogWarning("No prebuilt control bar BIG files found for variant {Variant} in flat structure", variant.Name);
- }
- }
- var metadataFileName = "340_ControlBarProZH.big";
- var metadataTargetPath = Path.Combine(extractedDirectory, metadataFileName);
-
- if (!File.Exists(metadataTargetPath))
- {
- var metadataSearchPaths = new[]
- {
- Path.Combine(extractedDirectory, "ZH", metadataFileName),
- Path.Combine(extractedDirectory, "CCG", metadataFileName),
- Path.Combine(extractedDirectory, "ZH", variant.Id, metadataFileName),
- Path.Combine(extractedDirectory, "CCG", variant.Id, metadataFileName),
- Path.Combine(extractedDirectory, "ZH", variant.Id, "BIG EN", metadataFileName),
- Path.Combine(extractedDirectory, "ZH", variant.Id, "BIG", metadataFileName),
- Path.Combine(extractedDirectory, "CCG", variant.Id, "BIG EN", metadataFileName),
- Path.Combine(extractedDirectory, "CCG", variant.Id, "BIG", metadataFileName),
- };
-
- foreach (var searchPath in metadataSearchPaths)
- {
- if (File.Exists(searchPath))
+ if (!string.IsNullOrEmpty(depMetadata.OutputFilename))
{
- logger.LogInformation("Found Control Bar metadata file at {SourcePath}, copying to root", searchPath);
- await TryCopyFileWithRetryAsync(searchPath, metadataTargetPath, logger);
- break;
+ dependencyBigFiles.Add(depMetadata.OutputFilename);
}
}
}
- if (File.Exists(metadataTargetPath))
- {
- controlBarRepackedOutputs.Add(metadataFileName);
- logger.LogInformation("Including Control Bar metadata file {FileName} in manifest", metadataFileName);
- }
- else
- {
- logger.LogWarning("Control Bar metadata file {FileName} not found in extracted content - creating fallback version", metadataFileName);
- try
- {
- var metadataBytes = Convert.FromBase64String(ControlBarMetadataBigBase64);
- await File.WriteAllBytesAsync(metadataTargetPath, metadataBytes, cancellationToken);
- controlBarRepackedOutputs.Add(metadataFileName);
- logger.LogInformation("Created Control Bar metadata file {FileName} from embedded fallback", metadataFileName);
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "Failed to create Control Bar metadata file - manifest will be incomplete");
- }
- }
-
- return controlBarRepackedOutputs;
+ return dependencyBigFiles;
}
private bool HasVariantBigFiles(
diff --git a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs
index 366cb6e41..ba3eba888 100644
--- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs
+++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs
@@ -25,7 +25,8 @@ public class GitHubContentProvider(
IEnumerable deliverers,
ILogger logger,
IContentValidator contentValidator,
- IInstallationInstructionsService installationInstructionsService)
+ IInstallationInstructionsService installationInstructionsService,
+ IArchivePayloadProcessor archiveProcessor)
: BaseContentProvider(contentValidator, installationInstructionsService, logger)
{
///
@@ -106,6 +107,13 @@ protected override async Task> PrepareContentIn
// Ensure we have valid data before validation
var resultManifest = deliveryResult.Data ?? manifest;
+ // Process payload archives and normalize directory structure safely
+ await archiveProcessor.ProcessPayloadAsync(
+ workingDirectory,
+ resultManifest.ContentType,
+ resultManifest.TargetGame,
+ cancellationToken);
+
// Validate the delivered content (full validation)
// Forward the provider progress reporter to the validator for user-visible progress
IProgress? validationProgress = null;
diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs
index acd40541f..cb64014f6 100644
--- a/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs
+++ b/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs
@@ -10,6 +10,7 @@
using GenHub.Core.Services.Providers;
using GenHub.Core.Services.Providers.VersionSchemes;
using GenHub.Features.Content.Services;
+using GenHub.Features.Content.Services.Common;
using GenHub.Features.Content.Services.CommunityOutpost;
using GenHub.Features.Content.Services.ContentDeliverers;
using GenHub.Features.Content.Services.ContentDiscoverers;
@@ -385,5 +386,13 @@ private static void AddSharedComponents(IServiceCollection services)
// Register installation instructions execution service
services.AddSingleton();
+
+ // Register archive payload processor
+ services.AddSingleton();
+ services.AddSingleton(sp => sp.GetRequiredService());
+
+ // Register control bar packaging processor
+ services.AddSingleton();
+ services.AddSingleton(sp => sp.GetRequiredService());
}
}
diff --git a/scripts/build-check.sh b/scripts/build-check.sh
new file mode 100755
index 000000000..aa985c90c
--- /dev/null
+++ b/scripts/build-check.sh
@@ -0,0 +1,184 @@
+#!/usr/bin/env bash
+# build-check.sh - Serialized build/check script for GenHub on Linux/macOS.
+# Linux counterpart to scripts/build-check.ps1. Uses flock to ensure only one
+# build runs at a time and refuses to build while output DLLs are locked.
+
+set -euo pipefail
+
+MODE="check"
+PROJECT=""
+TIMEOUT_SECONDS=120
+VERBOSITY="quiet"
+
+log_status() {
+ local message="$1"
+ printf '\033[2m[build-check]\033[0m %s\n' "$message"
+ return 0
+}
+
+log_err() {
+ local message="$1"
+ printf '\033[2m[build-check]\033[0m \033[31mERROR: %s\033[0m\n' "$message" >&2
+ return 0
+}
+
+usage() {
+ cat << 'EOF'
+Usage:
+ ./scripts/build-check.sh # quick compile check on full solution
+ ./scripts/build-check.sh -p GenHub.Core/GenHub.Core.csproj
+ ./scripts/build-check.sh -m build # full build with output
+ ./scripts/build-check.sh -m restore # NuGet restore only
+ ./scripts/build-check.sh -t 300 # longer lock timeout
+EOF
+ return 1
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ check|build|restore)
+ MODE="$1"
+ shift
+ ;;
+ -m|--mode)
+ MODE="${2:-}"
+ shift 2
+ ;;
+ -p|--project)
+ PROJECT="${2:-}"
+ shift 2
+ ;;
+ -t|--timeout)
+ TIMEOUT_SECONDS="${2:-120}"
+ shift 2
+ ;;
+ -v|--verbosity)
+ VERBOSITY="${2:-quiet}"
+ shift 2
+ ;;
+ -h|--help)
+ usage || exit 1
+ ;;
+ *)
+ log_err "Unknown argument: $1"
+ usage || exit 1
+ ;;
+ esac
+done
+
+case "$MODE" in
+ check|build|restore) ;;
+ *)
+ log_err "Invalid mode '$MODE' (expected check, build, or restore)."
+ exit 1
+ ;;
+esac
+
+case "$VERBOSITY" in
+ quiet|minimal|normal|detailed|diagnostic) ;;
+ *)
+ log_err "Invalid verbosity '$VERBOSITY' (expected quiet, minimal, normal, detailed, or diagnostic)."
+ exit 1
+ ;;
+esac
+
+case "$TIMEOUT_SECONDS" in
+ ''|*[!0-9]*|0*)
+ log_err "Timeout must be a positive integer."
+ exit 1
+ ;;
+ *)
+ ;;
+esac
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SOLUTION_DIR="$(cd "$SCRIPT_DIR/.." && pwd)/GenHub"
+SOLUTION_FILE="$SOLUTION_DIR/GenHub.sln"
+LOCK_FILE="$SOLUTION_DIR/build.lock"
+LOCK_TIMEOUT="$TIMEOUT_SECONDS"
+
+trap 'rm -f "$LOCK_FILE"; exec 9>&- 2>/dev/null || true' EXIT
+
+if ! command -v dotnet >/dev/null 2>&1; then
+ if [[ -x "$HOME/.dotnet/dotnet" ]]; then
+ export PATH="$HOME/.dotnet:$PATH"
+ else
+ log_err "dotnet CLI not found in PATH or ~/.dotnet."
+ exit 1
+ fi
+fi
+
+if [[ ! -f "$SOLUTION_FILE" ]]; then
+ log_err "Solution not found at: $SOLUTION_FILE"
+ exit 1
+fi
+
+if ! command -v flock >/dev/null 2>&1; then
+ log_err "flock not found. Install util-linux (usually preinstalled on Linux)."
+ exit 1
+fi
+
+TARGET="$SOLUTION_FILE"
+NO_DEPENDENCIES=()
+if [[ -n "$PROJECT" ]]; then
+ case "$PROJECT" in
+ ..*|*..*|/*)
+ log_err "Project must be a relative path under $SOLUTION_DIR (no '..' segments or absolute paths)."
+ exit 1
+ ;;
+ *)
+ TARGET="$SOLUTION_DIR/$PROJECT"
+ ;;
+ esac
+
+ if [[ ! -f "$TARGET" ]]; then
+ log_err "Project not found: $TARGET"
+ exit 1
+ fi
+ NO_DEPENDENCIES=(--no-dependencies)
+fi
+
+log_status "Acquiring build lock (timeout: ${TIMEOUT_SECONDS}s)..."
+
+exec 9>"$LOCK_FILE"
+if ! flock -w "$LOCK_TIMEOUT" 9; then
+ log_err "Timed out waiting for build lock after ${TIMEOUT_SECONDS}s."
+ log_err "Another agent or process is currently building."
+ exit 3
+fi
+
+CURRENT_TIME="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
+printf '{"pid": %d, "mode": "%s", "project": "%s", "startedAt": "%s"}\n' \
+ "$$" "$MODE" "${PROJECT:-GenHub.sln}" "$CURRENT_TIME" >"$LOCK_FILE"
+
+log_status "Build lock acquired."
+
+DOTNET_ARGS=(--nologo --verbosity "$VERBOSITY" -maxcpucount:2)
+
+EXIT_CODE=0
+case "$MODE" in
+ check)
+ log_status "Running compile check on: $(basename "$TARGET")"
+ dotnet build "$TARGET" --no-restore "${DOTNET_ARGS[@]}" "${NO_DEPENDENCIES[@]}" || EXIT_CODE=$?
+ ;;
+ build)
+ log_status "Running full build on: $(basename "$TARGET")"
+ dotnet build "$TARGET" "${DOTNET_ARGS[@]}" || EXIT_CODE=$?
+ ;;
+ restore)
+ log_status "Running NuGet restore on: $(basename "$TARGET")"
+ dotnet restore "$TARGET" --verbosity "$VERBOSITY" || EXIT_CODE=$?
+ ;;
+ *)
+ log_err "Unhandled mode: $MODE"
+ EXIT_CODE=1
+ ;;
+esac
+
+if [[ $EXIT_CODE -eq 0 ]]; then
+ log_status "Completed successfully with no errors."
+else
+ log_err "Build/check failed with exit code: $EXIT_CODE"
+fi
+
+exit "$EXIT_CODE"