Skip to content

Commit 200d051

Browse files
committed
feat: fix CD disk detection; add manual folder picker
- Fixed game recognition for CD/ISO installations. - Added manual directory selection fallback. - Added missing Zero Hour retail paths. - Updated .gitignore for releases.
1 parent f156e5e commit 200d051

File tree

8 files changed

+303
-211
lines changed

8 files changed

+303
-211
lines changed

.gitignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,5 +169,5 @@ _NCrunch*
169169
**/.idea/**/modules.xml
170170

171171
# Velopack releases
172-
/releases/
173-
/Releases/
172+
releases/
173+
Releases/

GenHub/GenHub.Core/Constants/GameClientConstants.cs

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System.Collections.Generic;
2+
13
namespace GenHub.Core.Constants;
24

35
/// <summary>
@@ -47,6 +49,15 @@ public static class GameClientConstants
4749
/// <summary>Zero Hour directory name abbreviated form.</summary>
4850
public const string ZeroHourDirectoryNameAbbreviated = "C&C Generals Zero Hour";
4951

52+
/// <summary>EA Games parent directory name.</summary>
53+
public const string EaGamesParentDirectoryName = "EA Games";
54+
55+
/// <summary>Standard retail Generals directory name.</summary>
56+
public const string GeneralsRetailDirectoryName = "Command & Conquer Generals";
57+
58+
/// <summary>Standard retail Zero Hour directory name.</summary>
59+
public const string ZeroHourRetailDirectoryName = "Command & Conquer Generals Zero Hour";
60+
5061
// ===== GeneralsOnline Client Detection =====
5162

5263
/// <summary>GeneralsOnline 30Hz client executable name.</summary>
@@ -113,24 +124,23 @@ public static class GameClientConstants
113124
/// </summary>
114125
public const string ZeroHourShortName = "Zero Hour";
115126

116-
// ===== Required DLLs =====
117-
118127
/// <summary>
119128
/// DLLs required for standard game installations.
120129
/// </summary>
121-
public static readonly string[] RequiredDlls = new[]
122-
{
130+
public static readonly string[] RequiredDlls =
131+
[
123132
"steam_api.dll", // Steam integration
124133
"binkw32.dll", // Bink video codec
125134
"mss32.dll", // Miles Sound System
126135
"eauninstall.dll", // EA App integration
127-
};
136+
];
128137

129138
/// <summary>
130139
/// DLLs specific to GeneralsOnline installations.
131140
/// </summary>
132-
public static readonly string[] GeneralsOnlineDlls = new[]
133-
{
141+
public static readonly string[] GeneralsOnlineDlls =
142+
[
143+
134144
// Core runtime DLLs (required for GeneralsOnline client)
135145
"abseil_dll.dll", // Abseil C++ library for networking
136146
"GameNetworkingSockets.dll", // Valve networking library
@@ -145,38 +155,48 @@ public static class GameClientConstants
145155
"binkw32.dll", // Bink video codec
146156
"mss32.dll", // Miles Sound System
147157
"wsock32.dll", // Network socket library
148-
};
158+
];
159+
160+
/// <summary>Common registry value names for installation paths.</summary>
161+
public static readonly string[] InstallationPathRegistryValues =
162+
[
163+
"Install Dir",
164+
"InstallPath",
165+
"Install Path",
166+
"Folder",
167+
"Path"
168+
];
149169

150170
// ===== Configuration Files =====
151171

152172
/// <summary>
153173
/// Configuration files used by game installations.
154174
/// </summary>
155-
public static readonly string[] ConfigFiles = new[]
156-
{
175+
public static readonly string[] ConfigFiles =
176+
[
157177
"options.ini", // Legacy game options
158178
"skirmish.ini", // Skirmish settings
159179
"network.ini", // Network configuration
160-
};
180+
];
161181

162182
/// <summary>
163183
/// List of GeneralsOnline executable names to detect.
164184
/// Only includes 30Hz and 60Hz variants as these are the primary clients.
165185
/// GeneralsOnline provides auto-updated clients for Command &amp; Conquer Generals and Zero Hour.
166186
/// </summary>
167-
public static readonly IReadOnlyList<string> GeneralsOnlineExecutableNames = new[]
168-
{
187+
public static readonly IReadOnlyList<string> GeneralsOnlineExecutableNames =
188+
[
169189
GeneralsOnline30HzExecutable,
170190
GeneralsOnline60HzExecutable,
171-
};
191+
];
172192

173193
/// <summary>
174194
/// List of SuperHackers executable names to detect.
175195
/// SuperHackers releases weekly game client builds for Generals and Zero Hour.
176196
/// </summary>
177-
public static readonly IReadOnlyList<string> SuperHackersExecutableNames = new[]
178-
{
197+
public static readonly IReadOnlyList<string> SuperHackersExecutableNames =
198+
[
179199
SuperHackersGeneralsExecutable, // generalsv.exe
180200
SuperHackersZeroHourExecutable, // generalszh.exe
181-
};
201+
];
182202
}

GenHub/GenHub.Linux/GameInstallations/CdisoInstallation.cs

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -294,57 +294,85 @@ private bool TryGetCdisoPathFromWineRegistry(string winePrefix, out string? inst
294294
Path.Combine(winePrefix, "user.reg"),
295295
};
296296

297+
// Registry value names to look for
298+
var valueNames = GameClientConstants.InstallationPathRegistryValues;
299+
297300
foreach (var regFile in registryFiles.Where(File.Exists))
298301
{
299302
logger?.LogDebug("Searching Wine registry file: {RegFile}", regFile);
300303

301304
var lines = File.ReadAllLines(regFile);
302305
bool inEaGamesSection = false;
306+
var foundValues = new List<string>();
303307

304308
for (int i = 0; i < lines.Length; i++)
305309
{
306310
var line = lines[i].Trim();
307311

308312
// Look for the EA Games registry section
309-
if (line.Contains("EA Games\\\\Command and Conquer Generals Zero Hour", StringComparison.OrdinalIgnoreCase))
313+
if (line.Contains($"{GameClientConstants.EaGamesParentDirectoryName}\\\\{GameClientConstants.ZeroHourRetailDirectoryName}", StringComparison.OrdinalIgnoreCase))
310314
{
311315
inEaGamesSection = true;
312316
logger?.LogDebug("Found EA Games section in Wine registry");
313317
continue;
314318
}
315319

316-
// If we're in the EA Games section, look for Install Dir
320+
// If we're in the EA Games section, look for installation path values
317321
if (inEaGamesSection)
318322
{
319323
if (line.StartsWith('[') && !line.Contains("EA Games", StringComparison.OrdinalIgnoreCase))
320324
{
321325
// We've moved to a different section
326+
if (foundValues.Count > 0)
327+
{
328+
logger?.LogDebug("EA Games section contained values: {Values}", string.Join(", ", foundValues));
329+
}
330+
322331
inEaGamesSection = false;
323332
continue;
324333
}
325334

326-
if (line.Contains("\"Install Dir\"", StringComparison.OrdinalIgnoreCase))
335+
// Check for any of the possible value names
336+
foreach (var valueName in valueNames)
327337
{
328-
// Extract the path value
329-
var parts = line.Split('=');
330-
if (parts.Length >= 2)
338+
if (line.Contains($"\"{valueName}\"", StringComparison.OrdinalIgnoreCase))
331339
{
332-
var pathValue = parts[1].Trim().Trim('"');
340+
foundValues.Add(valueName);
333341

334-
// Convert Windows path to Wine path
335-
if (pathValue.StartsWith("C:\\\\", StringComparison.OrdinalIgnoreCase) || pathValue.StartsWith("C:/", StringComparison.OrdinalIgnoreCase))
342+
// Extract the path value
343+
var parts = line.Split('=');
344+
if (parts.Length >= 2)
336345
{
337-
// Remove C:\ or C:/ and replace backslashes with forward slashes
338-
pathValue = pathValue[3..].Replace("\\\\", "/").Replace("\\", "/");
339-
installPath = Path.Combine(winePrefix, "drive_c", pathValue);
340-
341-
logger?.LogDebug("Extracted CD/ISO path from Wine registry: {InstallPath}", installPath);
342-
return !string.IsNullOrEmpty(installPath) && Directory.Exists(installPath);
346+
var pathValue = parts[1].Trim().Trim('"');
347+
348+
// Convert Windows path to Wine path
349+
if (pathValue.StartsWith("C:\\\\", StringComparison.OrdinalIgnoreCase) || pathValue.StartsWith("C:/", StringComparison.OrdinalIgnoreCase))
350+
{
351+
// Remove C:\ or C:/ and replace backslashes with forward slashes
352+
pathValue = pathValue[3..].Replace("\\\\", "/").Replace("\\", "/");
353+
installPath = Path.Combine(winePrefix, "drive_c", pathValue);
354+
355+
if (!string.IsNullOrEmpty(installPath) && Directory.Exists(installPath))
356+
{
357+
logger?.LogInformation("CD/ISO path found in Wine registry using value '{ValueName}': {InstallPath}", valueName, installPath);
358+
return true;
359+
}
360+
else
361+
{
362+
logger?.LogDebug("Found registry value '{ValueName}' but path does not exist: {InstallPath}", valueName, installPath);
363+
}
364+
}
343365
}
344366
}
345367
}
346368
}
347369
}
370+
371+
// Log if we found the section but no valid paths
372+
if (foundValues.Count > 0)
373+
{
374+
logger?.LogWarning("Found EA Games section in Wine registry with values {Values} but no valid installation path", string.Join(", ", foundValues));
375+
}
348376
}
349377
}
350378
catch (Exception ex)

GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs

Lines changed: 0 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -116,45 +116,6 @@ public void SelectTabCommand_SetsSelectedTab(NavigationTab tab)
116116
Assert.Equal(tab, vm.SelectedTab);
117117
}
118118

119-
/// <summary>
120-
/// Verifies ScanAndCreateProfilesAsync can be called.
121-
/// </summary>
122-
/// <returns>A task representing the asynchronous test operation.</returns>
123-
[Fact]
124-
public async Task ScanAndCreateProfilesAsync_CanBeCalled()
125-
{
126-
// Arrange
127-
var mockOrchestrator = new Mock<IGameInstallationDetectionOrchestrator>();
128-
var (settingsVm, userSettingsMock) = CreateSettingsVm();
129-
var toolsVm = CreateToolsVm();
130-
var configProvider = CreateConfigProviderMock();
131-
var mockProfileEditorFacade = new Mock<IProfileEditorFacade>();
132-
var mockVelopackUpdateManager = new Mock<IVelopackUpdateManager>();
133-
var mockLogger = new Mock<ILogger<MainViewModel>>();
134-
var mockNotificationService = CreateNotificationServiceMock();
135-
var mockNotificationManager = new Mock<NotificationManagerViewModel>(
136-
mockNotificationService.Object,
137-
Mock.Of<ILogger<NotificationManagerViewModel>>(),
138-
Mock.Of<ILogger<NotificationItemViewModel>>());
139-
var viewModel = new MainViewModel(
140-
CreateGameProfileLauncherViewModel(),
141-
CreateDownloadsViewModel(),
142-
toolsVm,
143-
settingsVm,
144-
mockNotificationManager.Object,
145-
mockOrchestrator.Object,
146-
configProvider,
147-
userSettingsMock.Object,
148-
mockProfileEditorFacade.Object,
149-
mockVelopackUpdateManager.Object,
150-
CreateProfileResourceService(),
151-
mockLogger.Object);
152-
153-
// Act & Assert
154-
await viewModel.ScanAndCreateProfilesAsync();
155-
Assert.True(true); // Test passes if no exception is thrown
156-
}
157-
158119
/// <summary>
159120
/// Tests that multiple calls to <see cref="MainViewModel.InitializeAsync"/> are safe.
160121
/// </summary>

GenHub/GenHub.Windows/GameInstallations/CdisoInstallation.cs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,23 @@ private bool TryGetCdisoGamesGeneralsPath(out string? path)
204204
return false;
205205
}
206206

207-
path = key.GetValue("Install Dir") as string;
208-
var success = !string.IsNullOrEmpty(path);
209-
logger?.LogDebug("CD/ISO Games Generals path lookup: {Success}, Path: {Path}", success, path);
210-
return success;
207+
// Log all registry values for diagnostic purposes
208+
var valueNames = key.GetValueNames();
209+
logger?.LogDebug("CD/ISO registry key found with {Count} values: {Values}", valueNames.Length, string.Join(", ", valueNames));
210+
211+
// Check multiple common registry value names in order of preference
212+
foreach (var valueName in GameClientConstants.InstallationPathRegistryValues)
213+
{
214+
path = key.GetValue(valueName) as string;
215+
if (!string.IsNullOrEmpty(path))
216+
{
217+
logger?.LogInformation("CD/ISO Games Generals path found using registry value '{ValueName}': {Path}", valueName, path);
218+
return true;
219+
}
220+
}
221+
222+
logger?.LogWarning("CD/ISO registry key exists but none of the expected value names contain a valid path. Available values: {Values}", string.Join(", ", valueNames));
223+
return false;
211224
}
212225
catch (Exception ex)
213226
{
@@ -224,7 +237,7 @@ private bool TryGetCdisoGamesGeneralsPath(out string? path)
224237
{
225238
try
226239
{
227-
var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\WOW6432Node\EA Games\Command and Conquer Generals Zero Hour");
240+
var key = Registry.LocalMachine.OpenSubKey($@"SOFTWARE\WOW6432Node\{GameClientConstants.EaGamesParentDirectoryName}\{GameClientConstants.ZeroHourRetailDirectoryName}");
228241
if (key != null)
229242
{
230243
logger?.LogDebug("Found CD/ISO Games Generals registry key");

GenHub/GenHub.Windows/GameInstallations/WindowsInstallationDetector.cs

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,19 +118,66 @@ public Task<DetectionResult<GameInstallation>> DetectInstallationsAsync(Cancella
118118
return Task.FromResult(result);
119119
}
120120

121+
/// <summary>
122+
/// Checks if any of the specified executables exist in the given directory.
123+
/// </summary>
124+
/// <param name="directory">The directory to check.</param>
125+
/// <param name="executableNames">The list of executable names to look for.</param>
126+
/// <returns>True if any of the executables exist.</returns>
127+
private static bool HasAnyExecutable(string directory, string[] executableNames)
128+
{
129+
foreach (var exe in executableNames)
130+
{
131+
if (File.Exists(Path.Combine(directory, exe)))
132+
{
133+
return true;
134+
}
135+
}
136+
137+
return false;
138+
}
139+
121140
private List<GameInstallation> DetectRetailInstallations()
122141
{
123142
var retailInstalls = new List<GameInstallation>();
143+
144+
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
145+
var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
146+
124147
var possiblePaths = new[]
125148
{
126-
@"C:\Program Files\EA Games\Command & Conquer Generals",
127-
@"C:\Program Files (x86)\EA Games\Command & Conquer Generals",
149+
Path.Combine(programFiles, GameClientConstants.EaGamesParentDirectoryName, GameClientConstants.GeneralsRetailDirectoryName),
150+
Path.Combine(programFilesX86, GameClientConstants.EaGamesParentDirectoryName, GameClientConstants.GeneralsRetailDirectoryName),
151+
Path.Combine(programFiles, GameClientConstants.EaGamesParentDirectoryName, GameClientConstants.ZeroHourRetailDirectoryName),
152+
Path.Combine(programFilesX86, GameClientConstants.EaGamesParentDirectoryName, GameClientConstants.ZeroHourRetailDirectoryName),
128153
};
129154

130155
foreach (var basePath in possiblePaths)
131156
{
132157
if (Directory.Exists(basePath))
133158
{
159+
// Check if this is a Zero Hour-only installation (base path IS the game directory)
160+
if (basePath.EndsWith(GameClientConstants.ZeroHourRetailDirectoryName, StringComparison.OrdinalIgnoreCase))
161+
{
162+
// Check if Zero Hour executables exist directly in this directory
163+
var zeroHourExecutables = new[]
164+
{
165+
GameClientConstants.ZeroHourExecutable,
166+
GameClientConstants.GeneralsExecutable,
167+
GameClientConstants.SuperHackersZeroHourExecutable,
168+
};
169+
170+
if (HasAnyExecutable(basePath, zeroHourExecutables))
171+
{
172+
var installation = new GameInstallation(basePath, GameInstallationType.Retail, null);
173+
installation.SetPaths(null, basePath);
174+
retailInstalls.Add(installation);
175+
logger.LogInformation("Detected standalone Zero Hour Retail installation at {BasePath}", basePath);
176+
continue;
177+
}
178+
}
179+
180+
// Standard detection: check for subdirectories
134181
var generalsPath = Path.Combine(basePath, GameClientConstants.GeneralsDirectoryName);
135182
var zeroHourPath = Path.Combine(basePath, GameClientConstants.ZeroHourDirectoryName);
136183

0 commit comments

Comments
 (0)