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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -169,5 +169,5 @@ _NCrunch*
**/.idea/**/modules.xml

# Velopack releases
/releases/
/Releases/
releases/
Releases/
54 changes: 37 additions & 17 deletions GenHub/GenHub.Core/Constants/GameClientConstants.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Collections.Generic;

namespace GenHub.Core.Constants;

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

/// <summary>EA Games parent directory name.</summary>
public const string EaGamesParentDirectoryName = "EA Games";

/// <summary>Standard retail Generals directory name.</summary>
public const string GeneralsRetailDirectoryName = "Command & Conquer Generals";

/// <summary>Standard retail Zero Hour directory name.</summary>
public const string ZeroHourRetailDirectoryName = "Command & Conquer Generals Zero Hour";

// ===== GeneralsOnline Client Detection =====

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

// ===== Required DLLs =====

/// <summary>
/// DLLs required for standard game installations.
/// </summary>
public static readonly string[] RequiredDlls = new[]
{
public static readonly string[] RequiredDlls =
[
"steam_api.dll", // Steam integration
"binkw32.dll", // Bink video codec
"mss32.dll", // Miles Sound System
"eauninstall.dll", // EA App integration
};
];

/// <summary>
/// DLLs specific to GeneralsOnline installations.
/// </summary>
public static readonly string[] GeneralsOnlineDlls = new[]
{
public static readonly string[] GeneralsOnlineDlls =
[

// Core runtime DLLs (required for GeneralsOnline client)
"abseil_dll.dll", // Abseil C++ library for networking
"GameNetworkingSockets.dll", // Valve networking library
Expand All @@ -145,38 +155,48 @@ public static class GameClientConstants
"binkw32.dll", // Bink video codec
"mss32.dll", // Miles Sound System
"wsock32.dll", // Network socket library
};
];

/// <summary>Common registry value names for installation paths.</summary>
public static readonly string[] InstallationPathRegistryValues =
[
"Install Dir",
"InstallPath",
"Install Path",
"Folder",
"Path"
];

// ===== Configuration Files =====

/// <summary>
/// Configuration files used by game installations.
/// </summary>
public static readonly string[] ConfigFiles = new[]
{
public static readonly string[] ConfigFiles =
[
"options.ini", // Legacy game options
"skirmish.ini", // Skirmish settings
"network.ini", // Network configuration
};
];

/// <summary>
/// List of GeneralsOnline executable names to detect.
/// Only includes 30Hz and 60Hz variants as these are the primary clients.
/// GeneralsOnline provides auto-updated clients for Command &amp; Conquer Generals and Zero Hour.
/// </summary>
public static readonly IReadOnlyList<string> GeneralsOnlineExecutableNames = new[]
{
public static readonly IReadOnlyList<string> GeneralsOnlineExecutableNames =
[
GeneralsOnline30HzExecutable,
GeneralsOnline60HzExecutable,
};
];

/// <summary>
/// List of SuperHackers executable names to detect.
/// SuperHackers releases weekly game client builds for Generals and Zero Hour.
/// </summary>
public static readonly IReadOnlyList<string> SuperHackersExecutableNames = new[]
{
public static readonly IReadOnlyList<string> SuperHackersExecutableNames =
[
SuperHackersGeneralsExecutable, // generalsv.exe
SuperHackersZeroHourExecutable, // generalszh.exe
};
];
}
58 changes: 43 additions & 15 deletions GenHub/GenHub.Linux/GameInstallations/CdisoInstallation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -294,57 +294,85 @@ private bool TryGetCdisoPathFromWineRegistry(string winePrefix, out string? inst
Path.Combine(winePrefix, "user.reg"),
};

// Registry value names to look for
var valueNames = GameClientConstants.InstallationPathRegistryValues;

foreach (var regFile in registryFiles.Where(File.Exists))
{
logger?.LogDebug("Searching Wine registry file: {RegFile}", regFile);

var lines = File.ReadAllLines(regFile);
bool inEaGamesSection = false;
var foundValues = new List<string>();

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

// Look for the EA Games registry section
if (line.Contains("EA Games\\\\Command and Conquer Generals Zero Hour", StringComparison.OrdinalIgnoreCase))
if (line.Contains($"{GameClientConstants.EaGamesParentDirectoryName}\\\\{GameClientConstants.ZeroHourRetailDirectoryName}", StringComparison.OrdinalIgnoreCase))
{
inEaGamesSection = true;
logger?.LogDebug("Found EA Games section in Wine registry");
continue;
}

// If we're in the EA Games section, look for Install Dir
// If we're in the EA Games section, look for installation path values
if (inEaGamesSection)
{
if (line.StartsWith('[') && !line.Contains("EA Games", StringComparison.OrdinalIgnoreCase))
{
// We've moved to a different section
if (foundValues.Count > 0)
{
logger?.LogDebug("EA Games section contained values: {Values}", string.Join(", ", foundValues));
}

inEaGamesSection = false;
continue;
}

if (line.Contains("\"Install Dir\"", StringComparison.OrdinalIgnoreCase))
// Check for any of the possible value names
foreach (var valueName in valueNames)
{
// Extract the path value
var parts = line.Split('=');
if (parts.Length >= 2)
if (line.Contains($"\"{valueName}\"", StringComparison.OrdinalIgnoreCase))
{
var pathValue = parts[1].Trim().Trim('"');
foundValues.Add(valueName);

// Convert Windows path to Wine path
if (pathValue.StartsWith("C:\\\\", StringComparison.OrdinalIgnoreCase) || pathValue.StartsWith("C:/", StringComparison.OrdinalIgnoreCase))
// Extract the path value
var parts = line.Split('=');
if (parts.Length >= 2)
{
// Remove C:\ or C:/ and replace backslashes with forward slashes
pathValue = pathValue[3..].Replace("\\\\", "/").Replace("\\", "/");
installPath = Path.Combine(winePrefix, "drive_c", pathValue);

logger?.LogDebug("Extracted CD/ISO path from Wine registry: {InstallPath}", installPath);
return !string.IsNullOrEmpty(installPath) && Directory.Exists(installPath);
var pathValue = parts[1].Trim().Trim('"');

// Convert Windows path to Wine path
if (pathValue.StartsWith("C:\\\\", StringComparison.OrdinalIgnoreCase) || pathValue.StartsWith("C:/", StringComparison.OrdinalIgnoreCase))
{
// Remove C:\ or C:/ and replace backslashes with forward slashes
pathValue = pathValue[3..].Replace("\\\\", "/").Replace("\\", "/");
installPath = Path.Combine(winePrefix, "drive_c", pathValue);

if (!string.IsNullOrEmpty(installPath) && Directory.Exists(installPath))
{
logger?.LogInformation("CD/ISO path found in Wine registry using value '{ValueName}': {InstallPath}", valueName, installPath);
return true;
}
else
{
logger?.LogDebug("Found registry value '{ValueName}' but path does not exist: {InstallPath}", valueName, installPath);
}
}
}
}
}
}
}

// Log if we found the section but no valid paths
if (foundValues.Count > 0)
{
logger?.LogWarning("Found EA Games section in Wine registry with values {Values} but no valid installation path", string.Join(", ", foundValues));
}
}
}
catch (Exception ex)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,45 +116,6 @@ public void SelectTabCommand_SetsSelectedTab(NavigationTab tab)
Assert.Equal(tab, vm.SelectedTab);
}

/// <summary>
/// Verifies ScanAndCreateProfilesAsync can be called.
/// </summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[Fact]
public async Task ScanAndCreateProfilesAsync_CanBeCalled()
{
// Arrange
var mockOrchestrator = new Mock<IGameInstallationDetectionOrchestrator>();
var (settingsVm, userSettingsMock) = CreateSettingsVm();
var toolsVm = CreateToolsVm();
var configProvider = CreateConfigProviderMock();
var mockProfileEditorFacade = new Mock<IProfileEditorFacade>();
var mockVelopackUpdateManager = new Mock<IVelopackUpdateManager>();
var mockLogger = new Mock<ILogger<MainViewModel>>();
var mockNotificationService = CreateNotificationServiceMock();
var mockNotificationManager = new Mock<NotificationManagerViewModel>(
mockNotificationService.Object,
Mock.Of<ILogger<NotificationManagerViewModel>>(),
Mock.Of<ILogger<NotificationItemViewModel>>());
var viewModel = new MainViewModel(
CreateGameProfileLauncherViewModel(),
CreateDownloadsViewModel(),
toolsVm,
settingsVm,
mockNotificationManager.Object,
mockOrchestrator.Object,
configProvider,
userSettingsMock.Object,
mockProfileEditorFacade.Object,
mockVelopackUpdateManager.Object,
CreateProfileResourceService(),
mockLogger.Object);

// Act & Assert
await viewModel.ScanAndCreateProfilesAsync();
Assert.True(true); // Test passes if no exception is thrown
}

/// <summary>
/// Tests that multiple calls to <see cref="MainViewModel.InitializeAsync"/> are safe.
/// </summary>
Expand Down
23 changes: 18 additions & 5 deletions GenHub/GenHub.Windows/GameInstallations/CdisoInstallation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -204,10 +204,23 @@ private bool TryGetCdisoGamesGeneralsPath(out string? path)
return false;
}

path = key.GetValue("Install Dir") as string;
var success = !string.IsNullOrEmpty(path);
logger?.LogDebug("CD/ISO Games Generals path lookup: {Success}, Path: {Path}", success, path);
return success;
// Log all registry values for diagnostic purposes
var valueNames = key.GetValueNames();
logger?.LogDebug("CD/ISO registry key found with {Count} values: {Values}", valueNames.Length, string.Join(", ", valueNames));

// Check multiple common registry value names in order of preference
foreach (var valueName in GameClientConstants.InstallationPathRegistryValues)
{
path = key.GetValue(valueName) as string;
if (!string.IsNullOrEmpty(path))
{
logger?.LogInformation("CD/ISO Games Generals path found using registry value '{ValueName}': {Path}", valueName, path);
return true;
}
}

logger?.LogWarning("CD/ISO registry key exists but none of the expected value names contain a valid path. Available values: {Values}", string.Join(", ", valueNames));
return false;
}
catch (Exception ex)
{
Expand All @@ -224,7 +237,7 @@ private bool TryGetCdisoGamesGeneralsPath(out string? path)
{
try
{
var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\WOW6432Node\EA Games\Command and Conquer Generals Zero Hour");
var key = Registry.LocalMachine.OpenSubKey($@"SOFTWARE\WOW6432Node\{GameClientConstants.EaGamesParentDirectoryName}\{GameClientConstants.ZeroHourRetailDirectoryName}");
if (key != null)
{
logger?.LogDebug("Found CD/ISO Games Generals registry key");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,16 +121,44 @@ public Task<DetectionResult<GameInstallation>> DetectInstallationsAsync(Cancella
private List<GameInstallation> DetectRetailInstallations()
{
var retailInstalls = new List<GameInstallation>();

var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);

var possiblePaths = new[]
{
@"C:\Program Files\EA Games\Command & Conquer Generals",
@"C:\Program Files (x86)\EA Games\Command & Conquer Generals",
Path.Combine(programFiles, GameClientConstants.EaGamesParentDirectoryName, GameClientConstants.GeneralsRetailDirectoryName),
Path.Combine(programFilesX86, GameClientConstants.EaGamesParentDirectoryName, GameClientConstants.GeneralsRetailDirectoryName),
Path.Combine(programFiles, GameClientConstants.EaGamesParentDirectoryName, GameClientConstants.ZeroHourRetailDirectoryName),
Path.Combine(programFilesX86, GameClientConstants.EaGamesParentDirectoryName, GameClientConstants.ZeroHourRetailDirectoryName),
};

foreach (var basePath in possiblePaths)
{
if (Directory.Exists(basePath))
{
// Check if this is a Zero Hour-only installation (base path IS the game directory)
if (basePath.EndsWith(GameClientConstants.ZeroHourRetailDirectoryName, StringComparison.OrdinalIgnoreCase))
{
// Check if Zero Hour executables exist directly in this directory
var zeroHourExecutables = new[]
{
GameClientConstants.ZeroHourExecutable,
GameClientConstants.GeneralsExecutable,
GameClientConstants.SuperHackersZeroHourExecutable,
};

if (zeroHourExecutables.Any(exe => File.Exists(Path.Combine(basePath, exe))))
{
var installation = new GameInstallation(basePath, GameInstallationType.Retail, null);
installation.SetPaths(null, basePath);
retailInstalls.Add(installation);
logger.LogInformation("Detected standalone Zero Hour Retail installation at {BasePath}", basePath);
continue;
}
}

// Standard detection: check for subdirectories
var generalsPath = Path.Combine(basePath, GameClientConstants.GeneralsDirectoryName);
var zeroHourPath = Path.Combine(basePath, GameClientConstants.ZeroHourDirectoryName);

Expand Down
Loading
Loading