From 2600a2f292b46e3b94ad99cb25482a27979cc4a1 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Sat, 2 May 2026 02:23:00 +0000 Subject: [PATCH 1/7] fix: support extended GeneralsOnline version format with build suffixes The Generals Online CDN changed the version format from MMDDYY_QFE# to MMDDYY_QFE#_SUFFIX (e.g. "042826_QFE3_EAC"). The existing parser required exactly 2 underscore-delimited segments, causing update detection and version comparison to silently fail. Changes: - GameVersionHelper.ParseGeneralsOnlineVersion: accept 2+ segments (was: exactly 2). Extra segments are build metadata, ignored for date+QFE comparison. - VersionComparer: route GeneralsOnline publisher through dedicated CompareGeneralsOnlineVersions that parses the structured format before falling back to numeric comparison. - Update doc comments on version models and constants to document the extended format. - Add GameVersionHelperTests covering legacy, extended, and edge cases. - Add VersionComparer extended-format theory and catalog parser tests for manifest.json and latest.txt with the new format. Co-Authored-By: Claude Opus 4.6 --- .../Constants/GeneralsOnlineConstants.cs | 1 + .../GenHub.Core/Helpers/GameVersionHelper.cs | 11 +- GenHub/GenHub.Core/Helpers/VersionComparer.cs | 32 ++++- .../GeneralsOnlineApiResponse.cs | 5 +- .../GeneralsOnline/GeneralsOnlineRelease.cs | 2 +- .../GeneralsOnlineJsonCatalogParserTests.cs | 48 +++++++ .../Helpers/GameVersionHelperTests.cs | 134 ++++++++++++++++++ .../Helpers/VersionComparerTests.cs | 25 ++++ .../GeneralsOnlineUpdateService.cs | 2 +- 9 files changed, 250 insertions(+), 10 deletions(-) create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameVersionHelperTests.cs diff --git a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs index 242cb02ef..0179d126c 100644 --- a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs +++ b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs @@ -52,6 +52,7 @@ public static class GeneralsOnlineConstants public const string VersionDateFormat = "MMddyy"; /// Separator between date and QFE number in versions. + /// Used by catalog parser for date extraction. Version comparison uses underscore split. public const string QfeSeparator = "_QFE"; /// Prefix for QFE markers in version strings. diff --git a/GenHub/GenHub.Core/Helpers/GameVersionHelper.cs b/GenHub/GenHub.Core/Helpers/GameVersionHelper.cs index 60061eff6..43e055059 100644 --- a/GenHub/GenHub.Core/Helpers/GameVersionHelper.cs +++ b/GenHub/GenHub.Core/Helpers/GameVersionHelper.cs @@ -122,7 +122,9 @@ public static int NormalizeVersion(string? version) } /// - /// Parses a version string (MMDDYY_QFE#) used by Generals Online. + /// Parses a version string (MMDDYY_QFE#[_SUFFIX]) used by Generals Online. + /// Supports extended formats like "042826_QFE3_EAC" — extra segments after + /// the QFE number are build metadata and do not affect version ordering. /// /// The version string to parse. /// A tuple containing the extracted date and QFE number, or null if parsing fails. @@ -135,9 +137,9 @@ public static (DateTime Date, int Qfe)? ParseGeneralsOnlineVersion(string? versi try { - // Format: MMDDYY_QFE# or DDMMYY_QFE# (General Online CDN uses MMDDYY) + // Format: MMDDYY_QFE#[_SUFFIX...] (Generals Online CDN uses MMDDYY) var parts = version.Split('_', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (parts.Length != 2) + if (parts.Length < 2) { return null; } @@ -164,7 +166,8 @@ public static (DateTime Date, int Qfe)? ParseGeneralsOnlineVersion(string? versi /// /// Gets a sortable integer version for Generals Online versions. - /// Converts "101525_QFE2" to 1015252. + /// Converts "101525_QFE2" to 1015252, "042826_QFE3_EAC" to 428263. + /// Build suffixes (e.g. _EAC) are ignored — only date and QFE affect ordering. /// /// The version string to convert. /// A sortable integer, or 0 if parsing fails. diff --git a/GenHub/GenHub.Core/Helpers/VersionComparer.cs b/GenHub/GenHub.Core/Helpers/VersionComparer.cs index a3d1dc208..a9bc0ae57 100644 --- a/GenHub/GenHub.Core/Helpers/VersionComparer.cs +++ b/GenHub/GenHub.Core/Helpers/VersionComparer.cs @@ -41,14 +41,42 @@ public static int CompareVersions(string? version1, string? version2, string? pu } else if (string.Equals(publisherType, PublisherTypeConstants.GeneralsOnline, StringComparison.OrdinalIgnoreCase)) { - // GeneralsOnline uses numeric versions - return CompareNumericVersions(version1, version2); + // GeneralsOnline uses MMDDYY_QFE#[_SUFFIX] format — parse for accurate comparison + return CompareGeneralsOnlineVersions(version1, version2); } // Default: Use the robust numeric/semantic comparison logic return CompareNumericVersions(version1, version2); } + /// + /// Compares two GeneralsOnline version strings (MMDDYY_QFE#[_SUFFIX]). + /// Extra suffix segments (e.g. _EAC) are build metadata and do not affect ordering. + /// Falls back to when parsing fails. + /// + /// The first version. + /// The second version. + /// Comparison result. + private static int CompareGeneralsOnlineVersions(string version1, string version2) + { + var parsed1 = GameVersionHelper.ParseGeneralsOnlineVersion(version1); + var parsed2 = GameVersionHelper.ParseGeneralsOnlineVersion(version2); + + if (parsed1 != null && parsed2 != null) + { + int dateCompare = parsed1.Value.Date.CompareTo(parsed2.Value.Date); + if (dateCompare != 0) + { + return dateCompare; + } + + return parsed1.Value.Qfe.CompareTo(parsed2.Value.Qfe); + } + + // Fallback for unparseable versions (e.g. "Unknown", semantic versions) + return CompareNumericVersions(version1, version2); + } + /// /// Compares two date-based version strings in YYYY-MM-DD format. /// diff --git a/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineApiResponse.cs b/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineApiResponse.cs index a4cb03730..a8ce32557 100644 --- a/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineApiResponse.cs +++ b/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineApiResponse.cs @@ -8,8 +8,9 @@ namespace GenHub.Core.Models.GeneralsOnline; public class GeneralsOnlineApiResponse { /// - /// Gets or sets the version string (e.g., "111825_QFE2" for November 18, 2025). - /// Format: MMDDYY_QFE# where MM=month, DD=day, YY=year, #=QFE number. + /// Gets or sets the version string (e.g., "111825_QFE2" or "042826_QFE3_EAC"). + /// Format: MMDDYY_QFE#[_SUFFIX] where MM=month, DD=day, YY=year, #=QFE number. + /// The optional suffix (e.g. "_EAC") is build metadata that does not affect version ordering. /// [JsonPropertyName("version")] public string Version { get; set; } = string.Empty; diff --git a/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs b/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs index fd42ee2b2..8027f564e 100644 --- a/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs +++ b/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs @@ -10,7 +10,7 @@ namespace GenHub.Core.Models.GeneralsOnline; public class GeneralsOnlineRelease { /// - /// Gets version string in format: MMDDYY_QFE# (e.g., "101525_QFE5"). + /// Gets version string in format: MMDDYY_QFE#[_SUFFIX] (e.g., "101525_QFE5", "042826_QFE3_EAC"). /// public required string Version { get; init; } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs index e2e10254f..ef1e6dd44 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs @@ -1,5 +1,6 @@ using GenHub.Core.Constants; using GenHub.Core.Interfaces.Providers; +using GenHub.Core.Models.GeneralsOnline; using GenHub.Core.Models.Providers; using GenHub.Features.Content.Services.GeneralsOnline; using Microsoft.Extensions.Logging.Abstractions; @@ -91,4 +92,51 @@ public async Task ParseAsync_WithCamelCaseJson_ParsesCorrectly() var item = result.Data.First(); Assert.Equal("111825_QFE2", item.Version); } + + /// + /// Tests that ParseAsync correctly parses extended version format with build suffix. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task ParseAsync_WithExtendedVersion_ParsesCorrectly() + { + // Arrange + var json = @"{ + ""version"": ""042826_QFE3_EAC"", + ""download_url"": ""https://cdn.playgenerals.online/GeneralsOnline_portable_042826_QFE3_EAC.zip"", + ""size"": 28277693, + ""release_notes"": ""www.playgenerals.online/updates"", + ""sha256"": ""abc123"" + }"; + + var wrapper = $"{{\"source\":\"manifest\",\"data\":{json}}}"; + + // Act + var result = await _parser.ParseAsync(wrapper, _provider); + + // Assert + Assert.True(result.Success); + var item = result.Data!.First(); + Assert.Equal("042826_QFE3_EAC", item.Version); + Assert.Equal("https://cdn.playgenerals.online/GeneralsOnline_portable_042826_QFE3_EAC.zip", item.GetData()!.PortableUrl); + } + + /// + /// Tests that ParseAsync correctly handles latest.txt fallback with extended version. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task ParseAsync_LatestFallbackExtendedVersion_ParsesCorrectly() + { + // Arrange + var wrapper = "{\"source\":\"latest\",\"version\":\"042826_QFE3_EAC\"}"; + + // Act + var result = await _parser.ParseAsync(wrapper, _provider); + + // Assert + Assert.True(result.Success); + var item = result.Data!.First(); + Assert.Equal("042826_QFE3_EAC", item.Version); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameVersionHelperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameVersionHelperTests.cs new file mode 100644 index 000000000..bc614fccd --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameVersionHelperTests.cs @@ -0,0 +1,134 @@ +using GenHub.Core.Helpers; + +namespace GenHub.Tests.Core.Helpers; + +/// +/// Tests for Generals Online version parsing. +/// +public class GameVersionHelperTests +{ + // ==== ParseGeneralsOnlineVersion — legacy format (MMDDYY_QFE#) ==== + + /// + /// Verifies that legacy two-segment versions parse correctly. + /// + [Fact] + public void ParseGeneralsOnlineVersion_LegacyFormat_ParsesCorrectly() + { + var result = GameVersionHelper.ParseGeneralsOnlineVersion("101525_QFE2"); + + Assert.NotNull(result); + Assert.Equal(new DateTime(2025, 10, 15), result.Value.Date); + Assert.Equal(2, result.Value.Qfe); + } + + // ==== ParseGeneralsOnlineVersion — extended format (MMDDYY_QFE#_SUFFIX) ==== + + /// + /// Verifies that extended format with build suffix parses date and QFE correctly. + /// + [Fact] + public void ParseGeneralsOnlineVersion_ExtendedFormat_ParsesDateAndQfe() + { + var result = GameVersionHelper.ParseGeneralsOnlineVersion("042826_QFE3_EAC"); + + Assert.NotNull(result); + Assert.Equal(new DateTime(2026, 4, 28), result.Value.Date); + Assert.Equal(3, result.Value.Qfe); + } + + /// + /// Verifies that versions with multiple suffix segments still parse correctly. + /// + [Fact] + public void ParseGeneralsOnlineVersion_MultipleSuffixes_ParsesDateAndQfe() + { + var result = GameVersionHelper.ParseGeneralsOnlineVersion("011526_QFE1_EAC_X86"); + + Assert.NotNull(result); + Assert.Equal(new DateTime(2026, 1, 15), result.Value.Date); + Assert.Equal(1, result.Value.Qfe); + } + + // ==== ParseGeneralsOnlineVersion — edge cases ==== + + /// + /// Verifies that null or empty versions return null. + /// + /// The version string to test. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ParseGeneralsOnlineVersion_NullOrEmpty_ReturnsNull(string? version) + { + var result = GameVersionHelper.ParseGeneralsOnlineVersion(version); + Assert.Null(result); + } + + /// + /// Verifies that versions missing the underscore separator return null. + /// + [Fact] + public void ParseGeneralsOnlineVersion_NoUnderscore_ReturnsNull() + { + var result = GameVersionHelper.ParseGeneralsOnlineVersion("042826"); + Assert.Null(result); + } + + /// + /// Verifies that versions with malformed dates return null. + /// + [Fact] + public void ParseGeneralsOnlineVersion_InvalidDate_ReturnsNull() + { + var result = GameVersionHelper.ParseGeneralsOnlineVersion("ABCDEF_QFE1"); + Assert.Null(result); + } + + /// + /// Verifies that versions with non-numeric QFE return null. + /// + [Fact] + public void ParseGeneralsOnlineVersion_NonNumericQfe_ReturnsNull() + { + var result = GameVersionHelper.ParseGeneralsOnlineVersion("042826_QFEx"); + Assert.Null(result); + } + + // ==== GetGeneralsOnlineSortableVersion ==== + + /// + /// Verifies that sortable version is computed correctly for legacy format. + /// + [Fact] + public void GetGeneralsOnlineSortableVersion_LegacyFormat_ReturnsCorrectValue() + { + // "101525_QFE2" -> date=101525, qfe=2 -> 101525*10 + 2 = 1015252 + var result = GameVersionHelper.GetGeneralsOnlineSortableVersion("101525_QFE2"); + Assert.Equal(1015252, result); + } + + /// + /// Verifies that sortable version ignores build suffixes in extended format. + /// + [Fact] + public void GetGeneralsOnlineSortableVersion_ExtendedFormat_IgnoresSuffix() + { + // "042826_QFE3_EAC" -> date=042826, qfe=3 -> 42826*10 + 3 = 428263 + var result = GameVersionHelper.GetGeneralsOnlineSortableVersion("042826_QFE3_EAC"); + Assert.Equal(428263, result); + } + + /// + /// Verifies that legacy and extended formats with same date+QFE produce the same sortable value. + /// + [Fact] + public void GetGeneralsOnlineSortableVersion_SameVersionDifferentFormat_ProducesEqualValue() + { + var legacy = GameVersionHelper.GetGeneralsOnlineSortableVersion("042826_QFE3"); + var extended = GameVersionHelper.GetGeneralsOnlineSortableVersion("042826_QFE3_EAC"); + + Assert.Equal(legacy, extended); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/VersionComparerTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/VersionComparerTests.cs index ba59c408e..c0adccfb1 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/VersionComparerTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/VersionComparerTests.cs @@ -169,4 +169,29 @@ public void CompareVersions_UnknownPublisher_NumericExtraction_ReturnsCorrectCom // Assert Assert.Equal(expected, Math.Sign(result)); } + + /// + /// Verifies that GeneralsOnline versions with extended format (build suffix) compare correctly. + /// Versions like "042826_QFE3_EAC" should compare by date+QFE only, ignoring the suffix. + /// + /// The first version string. + /// The second version string. + /// The expected comparison result. + [Theory] + [InlineData("042826_QFE3_EAC", "042826_QFE3_EAC", 0)] // Same extended version + [InlineData("042826_QFE4_EAC", "042826_QFE3_EAC", 1)] // Same date, higher QFE + [InlineData("042826_QFE3_EAC", "042826_QFE2", 1)] // Extended vs legacy, higher QFE + [InlineData("042826_QFE2", "042826_QFE2_EAC", 0)] // Legacy vs extended, same date+QFE + [InlineData("042926_QFE1_EAC", "042826_QFE3_EAC", 1)] // Newer date wins regardless of QFE + public void CompareVersions_GeneralsOnline_ExtendedFormat_ReturnsCorrectComparison( + string version1, + string version2, + int expected) + { + // Act + var result = VersionComparer.CompareVersions(version1, version2, PublisherTypeConstants.GeneralsOnline); + + // Assert + Assert.Equal(expected, Math.Sign(result)); + } } diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineUpdateService.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineUpdateService.cs index 15c24c1d0..2a186c4a8 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineUpdateService.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineUpdateService.cs @@ -91,7 +91,7 @@ private static bool IsNewerVersion(string latestVersion, string? currentVersion) return true; // Any version is newer than nothing } - // Parse MMddyy_QFE# format + // Parse MMddyy_QFE#[_SUFFIX] format (suffixes like _EAC are build metadata, ignored for comparison) var latest = GameVersionHelper.ParseGeneralsOnlineVersion(latestVersion); var current = GameVersionHelper.ParseGeneralsOnlineVersion(currentVersion); From 5ef97dd63f289b085959619f8e23dab69d717d28 Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Sat, 2 May 2026 02:31:10 +0000 Subject: [PATCH 2/7] fix: handle asymmetric fallback in CompareGeneralsOnlineVersions When only one version parses (e.g. "042826_QFE3_EAC" vs "Unknown"), the previous fallback to CompareNumericVersions could produce counter-intuitive ordering via string-ordinal comparison. Now explicitly treats unparseable versions as always older, and only falls back to numeric comparison when both are unparseable. Co-Authored-By: Claude Opus 4.6 --- GenHub/GenHub.Core/Helpers/VersionComparer.cs | 22 ++++++----- .../Helpers/VersionComparerTests.cs | 38 +++++++++++++++++++ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/GenHub/GenHub.Core/Helpers/VersionComparer.cs b/GenHub/GenHub.Core/Helpers/VersionComparer.cs index a9bc0ae57..7a4fae477 100644 --- a/GenHub/GenHub.Core/Helpers/VersionComparer.cs +++ b/GenHub/GenHub.Core/Helpers/VersionComparer.cs @@ -62,19 +62,21 @@ private static int CompareGeneralsOnlineVersions(string version1, string version var parsed1 = GameVersionHelper.ParseGeneralsOnlineVersion(version1); var parsed2 = GameVersionHelper.ParseGeneralsOnlineVersion(version2); - if (parsed1 != null && parsed2 != null) - { - int dateCompare = parsed1.Value.Date.CompareTo(parsed2.Value.Date); - if (dateCompare != 0) - { - return dateCompare; - } + // Both unparseable — fall back to generic numeric comparison + if (parsed1 == null && parsed2 == null) + return CompareNumericVersions(version1, version2); - return parsed1.Value.Qfe.CompareTo(parsed2.Value.Qfe); + // Exactly one unparseable — treat unparseable as always older + if (parsed1 == null) return -1; + if (parsed2 == null) return 1; + + int dateCompare = parsed1.Value.Date.CompareTo(parsed2.Value.Date); + if (dateCompare != 0) + { + return dateCompare; } - // Fallback for unparseable versions (e.g. "Unknown", semantic versions) - return CompareNumericVersions(version1, version2); + return parsed1.Value.Qfe.CompareTo(parsed2.Value.Qfe); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/VersionComparerTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/VersionComparerTests.cs index c0adccfb1..d3d393eea 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/VersionComparerTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/VersionComparerTests.cs @@ -194,4 +194,42 @@ public void CompareVersions_GeneralsOnline_ExtendedFormat_ReturnsCorrectComparis // Assert Assert.Equal(expected, Math.Sign(result)); } + + /// + /// Verifies that when only one GeneralsOnline version is parseable, + /// the unparseable version is always treated as older. + /// + /// The first version string. + /// The second version string. + /// The expected comparison result. + [Theory] + [InlineData("042826_QFE3_EAC", "Unknown", 1)] // Parseable vs unparseable + [InlineData("Unknown", "042826_QFE3_EAC", -1)] // Unparseable vs parseable + [InlineData("042826_QFE2", "garbage", 1)] // Legacy parseable vs unparseable + [InlineData("garbage", "042826_QFE2", -1)] // Unparseable vs legacy parseable + public void CompareVersions_GeneralsOnline_MixedParseable_UnparseableIsOlder( + string version1, + string version2, + int expected) + { + // Act + var result = VersionComparer.CompareVersions(version1, version2, PublisherTypeConstants.GeneralsOnline); + + // Assert + Assert.Equal(expected, Math.Sign(result)); + } + + /// + /// Verifies that when both GeneralsOnline versions are unparseable, + /// the method falls back to numeric/string comparison. + /// + [Fact] + public void CompareVersions_GeneralsOnline_BothUnparseable_FallsBackToNumeric() + { + // Act + var result = VersionComparer.CompareVersions("Unknown", "Unknown", PublisherTypeConstants.GeneralsOnline); + + // Assert — same unparseable strings should compare as equal + Assert.Equal(0, result); + } } From 3702033bde7451c448f59657db0a2fa918f20cb0 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Sat, 2 May 2026 02:41:03 +0000 Subject: [PATCH 3/7] docs: document QFE encoding limitation in GetGeneralsOnlineSortableVersion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dateValue*10+QFE encoding limits QFE to 0-9 without collision. This is intentional to preserve manifest ID stability — changing it would break installed content references. Version ordering uses ParseGeneralsOnlineVersion directly via CompareGeneralsOnlineVersions, which has no such limitation. Co-Authored-By: Claude Opus 4.6 --- GenHub/GenHub.Core/Helpers/GameVersionHelper.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/GenHub/GenHub.Core/Helpers/GameVersionHelper.cs b/GenHub/GenHub.Core/Helpers/GameVersionHelper.cs index 43e055059..4dd7eee9a 100644 --- a/GenHub/GenHub.Core/Helpers/GameVersionHelper.cs +++ b/GenHub/GenHub.Core/Helpers/GameVersionHelper.cs @@ -168,6 +168,13 @@ public static (DateTime Date, int Qfe)? ParseGeneralsOnlineVersion(string? versi /// Gets a sortable integer version for Generals Online versions. /// Converts "101525_QFE2" to 1015252, "042826_QFE3_EAC" to 428263. /// Build suffixes (e.g. _EAC) are ignored — only date and QFE affect ordering. + /// + /// Note: The encoding uses dateValue * 10 + QFE, which limits QFE to 0–9 + /// without colliding with the next date. This is intentional — the value is embedded + /// in manifest IDs and changing the encoding would break installed content references. + /// Version ordering uses directly via + /// VersionComparer.CompareGeneralsOnlineVersions, which has no such limitation. + /// /// /// The version string to convert. /// A sortable integer, or 0 if parsing fails. From 295365739345fddc45139a26a9e94177df3aa46f Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Mon, 4 May 2026 02:49:18 +0000 Subject: [PATCH 4/7] refactor: use QfeMarkerPrefix constant instead of hardcoded string Replace hardcoded "QFE" literal in ParseGeneralsOnlineVersion with the existing GeneralsOnlineConstants.QfeMarkerPrefix constant for consistency. Co-Authored-By: Claude Opus 4.6 --- GenHub/GenHub.Core/Helpers/GameVersionHelper.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/GenHub/GenHub.Core/Helpers/GameVersionHelper.cs b/GenHub/GenHub.Core/Helpers/GameVersionHelper.cs index 4dd7eee9a..4a991bd20 100644 --- a/GenHub/GenHub.Core/Helpers/GameVersionHelper.cs +++ b/GenHub/GenHub.Core/Helpers/GameVersionHelper.cs @@ -1,5 +1,6 @@ using System.Linq; using System.Text.RegularExpressions; +using GenHub.Core.Constants; namespace GenHub.Core.Helpers; @@ -145,7 +146,7 @@ public static (DateTime Date, int Qfe)? ParseGeneralsOnlineVersion(string? versi } var datePart = parts[0]; - var qfePart = parts[1].Replace("QFE", string.Empty, StringComparison.OrdinalIgnoreCase); + var qfePart = parts[1].Replace(GeneralsOnlineConstants.QfeMarkerPrefix, string.Empty, StringComparison.OrdinalIgnoreCase); if (datePart.Length != 6 || !int.TryParse(qfePart, out var qfe)) { From afff57e7f2514a0f2e8feda8994abd2a0215234a Mon Sep 17 00:00:00 2001 From: undead2146 Date: Mon, 4 May 2026 08:59:41 +0000 Subject: [PATCH 5/7] fix: Include EAC plugin files in GeneralsOnline manifests - Add detection for plugins/ directory during manifest generation - Include plugin files (EAC DLLs) in GameClient manifests - Plugin files are tracked with InstallTarget.Workspace - Fixes 'Failed to load the AntiCheat plugin' error for _EAC versions - Backward compatible: no plugins dir = no files added This ensures EAC DLLs are properly copied to the workspace during game launch, resolving the incomplete plugin path error. --- .../GeneralsOnlineManifestFactory.cs | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs index 3afbac6c3..6830a8b5f 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs @@ -408,12 +408,16 @@ private async Task> UpdateManifestsWithExtractedFiles( var allFiles = Directory.GetFiles(extractPath, "*", SearchOption.AllDirectories); logger.LogInformation("Processing {Count} files", allFiles.Length); - List<(string RelativePath, FileInfo FileInfo, string Hash, bool IsMap)> filesWithHashes = []; + List<(string RelativePath, FileInfo FileInfo, string Hash, bool IsMap, bool IsPlugin)> filesWithHashes = []; // Detect Maps directory (case-insensitive) var mapsDirectory = Directory.GetDirectories(extractPath, "*", SearchOption.TopDirectoryOnly) .FirstOrDefault(d => Path.GetFileName(d).Equals(GeneralsOnlineConstants.MapsSubdirectory, StringComparison.OrdinalIgnoreCase)); + // Detect plugins directory (case-insensitive) for EAC and other plugins + var pluginsDirectory = Directory.GetDirectories(extractPath, "*", SearchOption.TopDirectoryOnly) + .FirstOrDefault(d => Path.GetFileName(d).Equals("plugins", StringComparison.OrdinalIgnoreCase)); + foreach (var filePath in allFiles) { if (cancellationToken.IsCancellationRequested) @@ -427,6 +431,9 @@ private async Task> UpdateManifestsWithExtractedFiles( // Determine if this file is inside the Maps directory var isMap = mapsDirectory != null && filePath.StartsWith(mapsDirectory, StringComparison.OrdinalIgnoreCase); + // Determine if this file is inside the plugins directory (EAC, etc.) + var isPlugin = pluginsDirectory != null && filePath.StartsWith(pluginsDirectory, StringComparison.OrdinalIgnoreCase); + string hash; using (var stream = File.OpenRead(filePath)) { @@ -434,8 +441,8 @@ private async Task> UpdateManifestsWithExtractedFiles( hash = Convert.ToHexString(hashBytes).ToLowerInvariant(); } - filesWithHashes.Add((relativePath, fileInfo, hash, isMap)); - logger.LogDebug("Processed file: {File} ({Size} bytes, hash: {Hash}, isMap: {IsMap})", relativePath, fileInfo.Length, hash[..8], isMap); + filesWithHashes.Add((relativePath, fileInfo, hash, isMap, isPlugin)); + logger.LogDebug("Processed file: {File} ({Size} bytes, hash: {Hash}, isMap: {IsMap}, isPlugin: {IsPlugin})", relativePath, fileInfo.Length, hash[..8], isMap, isPlugin); } List updatedManifests = []; @@ -448,7 +455,7 @@ private async Task> UpdateManifestsWithExtractedFiles( if (isMapPackManifest) { // MapPack manifest: only include map files with UserMapsDirectory install target - foreach (var (relativePath, fileInfo, hash, isMap) in filesWithHashes) + foreach (var (relativePath, fileInfo, hash, isMap, isPlugin) in filesWithHashes) { if (!isMap) { @@ -462,11 +469,12 @@ private async Task> UpdateManifestsWithExtractedFiles( } else { - // Game client manifest: include executables, shared files, AND map files - // Map files are included with UserMapsDirectory install target so they install to Documents + // Game client manifest: include executables, shared files, plugin files (EAC), but NOT maps + // Map files are handled by the MapPack manifest + // Plugin files (EAC DLLs, etc.) are included so they're copied to the workspace var targetExecutable = GameClientConstants.GeneralsOnline60HzExecutable; - foreach (var (relativePath, fileInfo, hash, isMap) in filesWithHashes) + foreach (var (relativePath, fileInfo, hash, isMap, isPlugin) in filesWithHashes) { var fileName = Path.GetFileName(relativePath); var isExecutable = false; @@ -498,7 +506,7 @@ private async Task> UpdateManifestsWithExtractedFiles( }); } - logger.LogInformation("GameClient manifest '{Name}' updated with {Count} files", manifest.Name, manifestFiles.Count); + logger.LogInformation("GameClient manifest '{Name}' updated with {Count} files (including plugins)", manifest.Name, manifestFiles.Count); } updatedManifests.Add(new ContentManifest From c88bdda0dd4deb22f8c72f9b67e4606e5b3912ea Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Mon, 4 May 2026 09:11:09 +0000 Subject: [PATCH 6/7] OpenClaw: Address code review feedback from @coderabbitai[bot]: 1. GenHub/GenHub.... --- GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs | 3 +++ .../Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs index 0179d126c..2181a2fc3 100644 --- a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs +++ b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs @@ -100,6 +100,9 @@ public static class GeneralsOnlineConstants /// Subdirectory within the portable ZIP containing maps. public const string MapsSubdirectory = "Maps"; + /// Subdirectory within the portable ZIP containing plugins (EAC, etc.). + public const string PluginsSubdirectory = "plugins"; + // ===== Component Identifiers ===== /// Source name for Generals Online discoverer. diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs index 6830a8b5f..e7b55ede4 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs @@ -408,6 +408,9 @@ private async Task> UpdateManifestsWithExtractedFiles( var allFiles = Directory.GetFiles(extractPath, "*", SearchOption.AllDirectories); logger.LogInformation("Processing {Count} files", allFiles.Length); + // Track file metadata including isPlugin flag for diagnostic logging. + // Note: isPlugin is currently used only for logging; all non-map files + // (including plugins) are routed to GameClient manifests with Workspace install target. List<(string RelativePath, FileInfo FileInfo, string Hash, bool IsMap, bool IsPlugin)> filesWithHashes = []; // Detect Maps directory (case-insensitive) @@ -416,7 +419,7 @@ private async Task> UpdateManifestsWithExtractedFiles( // Detect plugins directory (case-insensitive) for EAC and other plugins var pluginsDirectory = Directory.GetDirectories(extractPath, "*", SearchOption.TopDirectoryOnly) - .FirstOrDefault(d => Path.GetFileName(d).Equals("plugins", StringComparison.OrdinalIgnoreCase)); + .FirstOrDefault(d => Path.GetFileName(d).Equals(GeneralsOnlineConstants.PluginsSubdirectory, StringComparison.OrdinalIgnoreCase)); foreach (var filePath in allFiles) { From b981f10eee562cc42802db170f1beb264eaee8ea Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Tue, 23 Jun 2026 13:39:39 +0000 Subject: [PATCH 7/7] OpenClaw: Address review feedback from @bobtista - Move EAC plugin work out of this PR (revert isPlugin tracking, PluginsSubdirectory constant, plugins directory detection). These will be reintroduced in a dedicated PR with real behavioral change, tests, and verified end-to-end execution. - Fix inconsistent brace usage in single-line if blocks in VersionComparer.cs (all ifs now use braces). All 1232 Core tests pass. Co-Authored-By: Claude Sonnet 4 --- .../Constants/GeneralsOnlineConstants.cs | 3 -- GenHub/GenHub.Core/Helpers/VersionComparer.cs | 43 +++++++++++++++++-- .../GeneralsOnlineManifestFactory.cs | 27 ++++-------- 3 files changed, 48 insertions(+), 25 deletions(-) diff --git a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs index 2181a2fc3..0179d126c 100644 --- a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs +++ b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs @@ -100,9 +100,6 @@ public static class GeneralsOnlineConstants /// Subdirectory within the portable ZIP containing maps. public const string MapsSubdirectory = "Maps"; - /// Subdirectory within the portable ZIP containing plugins (EAC, etc.). - public const string PluginsSubdirectory = "plugins"; - // ===== Component Identifiers ===== /// Source name for Generals Online discoverer. diff --git a/GenHub/GenHub.Core/Helpers/VersionComparer.cs b/GenHub/GenHub.Core/Helpers/VersionComparer.cs index 7a4fae477..0b979e9e5 100644 --- a/GenHub/GenHub.Core/Helpers/VersionComparer.cs +++ b/GenHub/GenHub.Core/Helpers/VersionComparer.cs @@ -22,11 +22,19 @@ public static int CompareVersions(string? version1, string? version2, string? pu { // Handle null/empty cases if (string.IsNullOrWhiteSpace(version1) && string.IsNullOrWhiteSpace(version2)) + { return 0; + } + if (string.IsNullOrWhiteSpace(version1)) + { return -1; + } + if (string.IsNullOrWhiteSpace(version2)) + { return 1; + } // Determine comparison strategy based on publisher type if (string.Equals(publisherType, CommunityOutpostConstants.PublisherType, StringComparison.OrdinalIgnoreCase)) @@ -64,11 +72,20 @@ private static int CompareGeneralsOnlineVersions(string version1, string version // Both unparseable — fall back to generic numeric comparison if (parsed1 == null && parsed2 == null) + { return CompareNumericVersions(version1, version2); + } // Exactly one unparseable — treat unparseable as always older - if (parsed1 == null) return -1; - if (parsed2 == null) return 1; + if (parsed1 == null) + { + return -1; + } + + if (parsed2 == null) + { + return 1; + } int dateCompare = parsed1.Value.Date.CompareTo(parsed2.Value.Date); if (dateCompare != 0) @@ -200,22 +217,30 @@ private static int CompareNumericVersions(string ver1, string ver2) private static string NormalizeVersionString(string version) { if (string.IsNullOrWhiteSpace(version)) + { return string.Empty; + } // Remove common prefixes that publishers use var normalized = version; // Remove weekly- prefix (SuperHackers) if (normalized.StartsWith("weekly-", StringComparison.OrdinalIgnoreCase)) + { normalized = normalized.Substring(8); // Remove "weekly-" + } // Remove release- prefix if (normalized.StartsWith("release-", StringComparison.OrdinalIgnoreCase)) + { normalized = normalized.Substring(9); // Remove "release-" + } // Remove version- prefix if (normalized.StartsWith("version-", StringComparison.OrdinalIgnoreCase)) + { normalized = normalized.Substring(9); // Remove "version-" + } // Remove 'v' or 'V' prefix normalized = normalized.TrimStart('v', 'V'); @@ -267,13 +292,19 @@ private static int CompareSemanticVersions(string ver1, string ver2) if (isDigit1 && isDigit2 && long.TryParse(segment1, out var num1) && long.TryParse(segment2, out var num2)) { - if (num1 != num2) return num1.CompareTo(num2); + if (num1 != num2) + { + return num1.CompareTo(num2); + } } else { // For non-pure-numeric segments, compare the full original strings (case-insensitive) var strCompare = string.Compare(rawSegment1, rawSegment2, StringComparison.OrdinalIgnoreCase); - if (strCompare != 0) return strCompare; + if (strCompare != 0) + { + return strCompare; + } } } @@ -307,7 +338,9 @@ private static bool TryParseDateVersion(string dateStr, out DateTime result) private static long? ExtractNumericFromDate(string dateStr) { if (string.IsNullOrWhiteSpace(dateStr)) + { return null; + } // Remove common date separators var digits = dateStr.Replace("-", string.Empty) @@ -328,7 +361,9 @@ private static bool TryParseDateVersion(string dateStr, out DateTime result) private static string ExtractDigits(string version) { if (string.IsNullOrWhiteSpace(version)) + { return string.Empty; + } var result = new System.Text.StringBuilder(); foreach (var c in version) diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs index e7b55ede4..8417cfea3 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs @@ -408,19 +408,14 @@ private async Task> UpdateManifestsWithExtractedFiles( var allFiles = Directory.GetFiles(extractPath, "*", SearchOption.AllDirectories); logger.LogInformation("Processing {Count} files", allFiles.Length); - // Track file metadata including isPlugin flag for diagnostic logging. - // Note: isPlugin is currently used only for logging; all non-map files - // (including plugins) are routed to GameClient manifests with Workspace install target. - List<(string RelativePath, FileInfo FileInfo, string Hash, bool IsMap, bool IsPlugin)> filesWithHashes = []; + // Track file metadata for manifest generation. Maps are routed to the MapPack + // manifest; all other files (executables, shared files) go to the GameClient manifest. + List<(string RelativePath, FileInfo FileInfo, string Hash, bool IsMap)> filesWithHashes = []; // Detect Maps directory (case-insensitive) var mapsDirectory = Directory.GetDirectories(extractPath, "*", SearchOption.TopDirectoryOnly) .FirstOrDefault(d => Path.GetFileName(d).Equals(GeneralsOnlineConstants.MapsSubdirectory, StringComparison.OrdinalIgnoreCase)); - // Detect plugins directory (case-insensitive) for EAC and other plugins - var pluginsDirectory = Directory.GetDirectories(extractPath, "*", SearchOption.TopDirectoryOnly) - .FirstOrDefault(d => Path.GetFileName(d).Equals(GeneralsOnlineConstants.PluginsSubdirectory, StringComparison.OrdinalIgnoreCase)); - foreach (var filePath in allFiles) { if (cancellationToken.IsCancellationRequested) @@ -434,9 +429,6 @@ private async Task> UpdateManifestsWithExtractedFiles( // Determine if this file is inside the Maps directory var isMap = mapsDirectory != null && filePath.StartsWith(mapsDirectory, StringComparison.OrdinalIgnoreCase); - // Determine if this file is inside the plugins directory (EAC, etc.) - var isPlugin = pluginsDirectory != null && filePath.StartsWith(pluginsDirectory, StringComparison.OrdinalIgnoreCase); - string hash; using (var stream = File.OpenRead(filePath)) { @@ -444,8 +436,8 @@ private async Task> UpdateManifestsWithExtractedFiles( hash = Convert.ToHexString(hashBytes).ToLowerInvariant(); } - filesWithHashes.Add((relativePath, fileInfo, hash, isMap, isPlugin)); - logger.LogDebug("Processed file: {File} ({Size} bytes, hash: {Hash}, isMap: {IsMap}, isPlugin: {IsPlugin})", relativePath, fileInfo.Length, hash[..8], isMap, isPlugin); + filesWithHashes.Add((relativePath, fileInfo, hash, isMap)); + logger.LogDebug("Processed file: {File} ({Size} bytes, hash: {Hash}, isMap: {IsMap})", relativePath, fileInfo.Length, hash[..8], isMap); } List updatedManifests = []; @@ -458,7 +450,7 @@ private async Task> UpdateManifestsWithExtractedFiles( if (isMapPackManifest) { // MapPack manifest: only include map files with UserMapsDirectory install target - foreach (var (relativePath, fileInfo, hash, isMap, isPlugin) in filesWithHashes) + foreach (var (relativePath, fileInfo, hash, isMap) in filesWithHashes) { if (!isMap) { @@ -472,12 +464,11 @@ private async Task> UpdateManifestsWithExtractedFiles( } else { - // Game client manifest: include executables, shared files, plugin files (EAC), but NOT maps + // Game client manifest: include executables and shared files, but NOT maps // Map files are handled by the MapPack manifest - // Plugin files (EAC DLLs, etc.) are included so they're copied to the workspace var targetExecutable = GameClientConstants.GeneralsOnline60HzExecutable; - foreach (var (relativePath, fileInfo, hash, isMap, isPlugin) in filesWithHashes) + foreach (var (relativePath, fileInfo, hash, isMap) in filesWithHashes) { var fileName = Path.GetFileName(relativePath); var isExecutable = false; @@ -509,7 +500,7 @@ private async Task> UpdateManifestsWithExtractedFiles( }); } - logger.LogInformation("GameClient manifest '{Name}' updated with {Count} files (including plugins)", manifest.Name, manifestFiles.Count); + logger.LogInformation("GameClient manifest '{Name}' updated with {Count} files", manifest.Name, manifestFiles.Count); } updatedManifests.Add(new ContentManifest