Game Profiles are the user-facing units of configuration in GeneralsHub. A profile encapsulates everything needed to launch a specific game state: which mods are enabled, which game engine to use, and what settings (resolution, detail level) to apply.
Data Model
Profiles are serialized as JSON documents.
{
"id": "profile_12345",
"name": "RotR Competitive",
"gameInstallationId": "steam_zerohour",
"gameClient": {
"gameType": "ZeroHour",
"executablePath": "generals.exe"
},
"enabledContentIds": [
"1.87.swr.mod.rotr",
"1.0.community.patch.genpatcher"
],
"videoWidth": 1920,
"videoHeight": 1080,
"videoWindowed": true,
"videoSkipEALogo": true,
"environmentVariables": {
"gentool_monitor": "1"
}
}Persistence Layer
The GameProfileRepository handles storage.
- Format: Plain JSON files in the user's data directory.
- Naming:
{ProfileId}.json. - Resilience:
- Atomic writes (via
File.WriteAllTextAsync). - Corruption Handling: If a profile fails to deserialize, it is automatically renamed to
.corruptedto prevent the app from crashing, and a "Corrupted Profile" warning is logged.
- Atomic writes (via
Options.ini Generation
SAGE engine games rely on a global Options.ini file in Documents\Command and Conquer .... This creates a conflict when switching between mods (e.g., Mod A needs 800x600, Mod B needs 1080p).
GeneralsHub solves this with Dynamic Options Injection at launch time.
The Injection Process
Built into GameLauncher.cs, this process runs immediately before generals.exe starts:
- Load Existing: Reads the current
Options.inifrom disk.- Why? To preserve settings managed by third-party tools (like GenTool or TheSuperHackers' fixes) that GeneralsHub doesn't explicitly track.
- Apply Overrides: Maps
GameProfileproperties to the INI model.Profile.VideoWidth->ResolutionProfile.VideoReview->StaticGameLOD
- Windowed Mode: If
VideoWindowedis true, ensures-winis added to command arguments (required for the engine to actually respect the windowed flag). - Save: Writes the merged
Options.iniback to disk.
Generals Online Support
For the specialized Generals Online client, the system also injects settings into settings.json, ensuring that unique features of that community client (like 30FPS vs 60FPS toggles) are respected per-profile.
Copy Profile Feature
The Copy Profile feature allows users to duplicate an existing profile. This is useful for creating variations of a mod setup (e.g., "RotR" and "RotR (No Intro)") without manual reconfiguration.
Preserved Settings:
- Core Config: Name (suffixed with Copy), Game Installation, and Client.
- Content: All enabled Mod, Map, and Patch manifests.
- Game Settings: Resolutions, UI scaling, and Audio volumes.
- Client-Specifics: Generals Online and TheSuperHackers specific toggles.
The system automatically generates a unique name for the copy and assigns it a new workspace, ensuring complete isolation from the original.
Launch Options
Profiles support flexible launch configuration:
- Command Line Arguments: Sanitized strings passed to the process (e.g.,
-quickstart -nologo). - Environment Variables: Injected into the game process scope (useful for tools like GenTool that read env vars).
Content Selection from ManifestPool
The ManifestPool serves as the central repository of all installed content available for use in game profiles. Understanding how content flows from installation to profile configuration is essential.
How Users Browse Available Content
When creating or editing a profile, users interact with content through the GameProfileSettingsViewModel:
- Content Discovery: The
ProfileEditorFacade.DiscoverContentForClientAsync()method queries theIContentManifestPoolto retrieve all available manifests. - Filtering: Content is filtered by
GameType(Generals vs ZeroHour) andContentType(Mod, Map, Patch, etc.). - Display: Each manifest is presented as a
ContentDisplayItemwith metadata like name, version, publisher, and installation type.
// ProfileEditorFacade discovers content for a specific game client
var contentResult = await _manifestPool.GetAllManifestsAsync(cancellationToken);
var relevantContent = contentResult.Data?
.Where(m => m.TargetGame == profile.GameClient.GameType)
.ToList() ?? [];How enabledContentIds List is Populated
The enabledContentIds list in a GameProfile represents the user's content selection:
- User Selection: Users toggle content items in the UI, which updates the
SelectedContentIdscollection inGameProfileSettingsViewModel. - Dependency Resolution: When saving, the
DependencyResolverexpands the selection to include all transitive dependencies. - Profile Update: The resolved list is persisted to the profile's
EnabledContentIdsproperty.
{
"id": "profile_12345",
"enabledContentIds": [
"1.87.swr.mod.rotr",
"1.0.community.patch.genpatcher",
"1.104.steam.gameinstallation.zerohour"
]
}Relationship Between Installed Content and Profile Configuration
- Installation: Content is installed via the Downloads Browser, which stores files in Content-Addressable Storage (CAS) and registers a manifest in the pool.
- Profile Configuration: Profiles reference manifests by ID. The actual files remain in CAS until workspace preparation.
- Workspace Preparation: At launch time, the
WorkspaceManageruses the profile'senabledContentIdsto fetch manifests and map files from CAS to the game directory.
ManifestPool/ContentManifestPool Integration
The ContentManifestPool provides these key operations:
GetAllManifestsAsync(): Retrieves all installed manifests for browsing.GetManifestAsync(manifestId): Fetches a specific manifest by ID.GetContentDirectoryAsync(manifestId): Returns the source directory for a manifest's files (either CAS or original source path).IsManifestAcquiredAsync(manifestId): Checks if content files are available.
// Example: Loading content for profile editor
var manifestsResult = await _manifestPool.GetAllManifestsAsync(cancellationToken);
if (manifestsResult.Success && manifestsResult.Data != null)
{
var availableContent = manifestsResult.Data
.Where(m => m.ContentType != ContentType.GameInstallation)
.Select(m => new ContentDisplayItem
{
ManifestId = m.Id,
DisplayName = m.Name,
ContentType = m.ContentType,
Version = m.Version
});
}Profile Creation Workflow
Creating a game profile involves multiple coordinated steps across several services. Here's the complete user journey from "Create Profile" to "Launch".
Step-by-Step User Journey
ProfileEditorFacade Auto-Enabling Matching GameInstallation Content
When a profile is created, the ProfileEditorFacade automatically includes the base game installation in the content list:
- Installation Selection: User selects a
GameInstallation(e.g., "Steam Zero Hour"). - Auto-Enable: The facade queries the ManifestPool for the installation's manifest and adds it to
enabledContentIds. - Implicit Dependency: The game installation manifest is treated as a base dependency for all other content.
// ProfileEditorFacade automatically includes the game installation
var installationManifest = await _manifestPool.GetManifestAsync(
ManifestId.Create($"1.104.steam.gameinstallation.{gameType}"),
cancellationToken);
if (installationManifest.Success && installationManifest.Data != null)
{
profile.EnabledContentIds.Add(installationManifest.Data.Id.Value);
}How Workspace is Initially Prepared
Important: Workspace preparation is deferred until profile launch to avoid copying entire game installations during profile creation.
// ProfileEditorFacade.CreateProfileWithWorkspaceAsync
// NOTE: Workspace preparation is deferred until profile launch
// This prevents copying entire game installations during profile creation
_logger.LogInformation("Successfully created profile {ProfileId}", profile.Id);
return ProfileOperationResult<GameProfile>.CreateSuccess(profile);At launch time, the ProfileLauncherFacade triggers workspace preparation:
- Resolve Dependencies: Expand
enabledContentIdsto include all transitive dependencies. - Fetch Manifests: Retrieve full manifest objects from the pool.
- Resolve Source Paths: Query the pool for each manifest's content directory.
- Prepare Workspace: Call
WorkspaceManager.PrepareWorkspaceAsync()with the configuration.
How ActiveWorkspaceId is Set
The ActiveWorkspaceId is set after successful workspace preparation:
// ProfileEditorFacade.UpdateProfileWithWorkspaceAsync
var workspaceResult = await _workspaceManager.PrepareWorkspaceAsync(
workspaceConfig,
cancellationToken: cancellationToken);
if (workspaceResult.Success && workspaceResult.Data != null)
{
profile.ActiveWorkspaceId = workspaceResult.Data.Id;
// Persist ActiveWorkspaceId
var updateRequest = new UpdateProfileRequest
{
ActiveWorkspaceId = profile.ActiveWorkspaceId,
};
await _profileManager.UpdateProfileAsync(profile.Id, updateRequest, cancellationToken);
}The ActiveWorkspaceId is used on subsequent launches to reuse the existing workspace if no content changes have occurred.
Dependency Resolution During Launch
Dependency resolution ensures that all required content is available before launching a game profile. This process handles transitive dependencies, version constraints, and conflict prevention.
Automatic Dependency Resolution Through IContentManifestPool
The DependencyResolver service orchestrates dependency resolution:
public async Task<DependencyResolutionResult> ResolveDependenciesWithManifestsAsync(
IEnumerable<string> contentIds,
CancellationToken cancellationToken = default)
{
var resolvedIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var resolvedManifests = new List<ContentManifest>();
var toProcess = new Queue<string>(contentIds);
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (toProcess.Count > 0)
{
var contentId = toProcess.Dequeue();
if (!visited.Add(contentId)) continue;
var manifestResult = await _manifestPool.GetManifestAsync(
ManifestId.Create(contentId),
cancellationToken);
if (manifestResult.Success && manifestResult.Data != null)
{
var manifest = manifestResult.Data;
resolvedManifests.Add(manifest);
// Queue dependencies for processing
var relevantDeps = manifest.Dependencies
.Where(d => d.InstallBehavior == DependencyInstallBehavior.RequireExisting
|| d.InstallBehavior == DependencyInstallBehavior.AutoInstall);
foreach (var dep in relevantDeps)
{
if (!resolvedIds.Contains(dep.Id))
{
toProcess.Enqueue(dep.Id);
}
}
}
}
return DependencyResolutionResult.CreateSuccess(
[..resolvedIds],
resolvedManifests,
missingContentIds);
}How Transitive Dependencies are Handled
Transitive dependencies are resolved recursively using a breadth-first search:
- Initial Queue: Start with user-selected content IDs.
- Fetch Manifest: For each ID, retrieve the manifest from the pool.
- Extract Dependencies: Parse the manifest's
Dependenciescollection. - Filter Relevant: Only process dependencies with
RequireExistingorAutoInstallbehavior. - Queue Transitive: Add dependency IDs to the processing queue.
- Cycle Detection: Track visited IDs to prevent infinite loops.
Example Dependency Chain:
User selects: RotR Mod
├─ Depends on: GenPatcher (RequireExisting)
│ └─ Depends on: Zero Hour Installation (RequireExisting)
└─ Depends on: RotR Assets (AutoInstall)Content Conflict Prevention Mechanisms
The system prevents conflicts through several mechanisms:
- Strict Publisher Dependencies: Dependencies with
StrictPublisher = truerequire an exact manifest ID match. - Type-Based Dependencies: Dependencies with
StrictPublisher = falseallow any manifest of the matchingContentTypeandTargetGame. - Circular Dependency Detection: The resolver tracks the processing stack and logs warnings for circular references.
// Circular dependency detection
if (processingStack.Contains(contentId))
{
var circularWarning = $"Circular dependency detected: '{contentId}' is already in the resolution path";
warnings.Add(circularWarning);
_logger.LogWarning("Circular dependency detected: {ContentId}", contentId);
continue;
}- Version Constraints: Dependencies can specify
MinVersionto ensure compatibility.
Dependency Resolution Flow Diagram
Manifest Selection Process
Once dependencies are resolved, the system must fetch the actual manifest objects and prepare them for workspace creation.
How WorkspaceManager Receives Manifests from Profile's enabledContentIds
The ProfileLauncherFacade coordinates manifest selection:
// ProfileLauncherFacade.LaunchProfileAsync
var resolutionResult = await _dependencyResolver.ResolveDependenciesWithManifestsAsync(
profile.EnabledContentIds,
cancellationToken);
if (!resolutionResult.Success)
{
return ProfileLaunchResult.CreateFailure(
string.Join(", ", resolutionResult.Errors));
}
var workspaceConfig = new WorkspaceConfiguration
{
Id = profile.Id,
Manifests = [..resolutionResult.ResolvedManifests],
GameClient = profile.GameClient,
Strategy = profile.WorkspaceStrategy ?? _config.GetDefaultWorkspaceStrategy(),
BaseInstallationPath = installation.Data.InstallationPath,
WorkspaceRootPath = _config.GetWorkspacePath(),
};How Manifests are Resolved from the Pool
Manifests are resolved in two phases:
- Dependency Resolution Phase: The
DependencyResolvercalls_manifestPool.GetManifestAsync()for each content ID, building a complete list of required manifests. - Source Path Resolution Phase: For each manifest, the system queries
_manifestPool.GetContentDirectoryAsync()to determine where the content files are stored.
// ProfileEditorFacade.UpdateProfileWithWorkspaceAsync
var manifestSourcePaths = new Dictionary<string, string>();
foreach (var manifest in workspaceConfig.Manifests)
{
// Skip GameInstallation manifests - they use BaseInstallationPath
if (manifest.ContentType == ContentType.GameInstallation)
{
continue;
}
// For GameClient, use WorkingDirectory if available
if (manifest.ContentType == ContentType.GameClient &&
!string.IsNullOrEmpty(profile.GameClient?.WorkingDirectory))
{
manifestSourcePaths[manifest.Id.Value] = profile.GameClient.WorkingDirectory;
continue;
}
// For all other content types, query the manifest pool
var contentDirResult = await _manifestPool.GetContentDirectoryAsync(
manifest.Id,
cancellationToken);
if (contentDirResult.Success && !string.IsNullOrEmpty(contentDirResult.Data))
{
manifestSourcePaths[manifest.Id.Value] = contentDirResult.Data;
}
}
workspaceConfig.ManifestSourcePaths = manifestSourcePaths;What Happens When a Manifest is Missing or Incompatible
Missing Manifest:
- The
DependencyResolveradds the content ID to themissingContentIdslist. - Resolution fails with an error message listing all missing IDs.
- The profile launch is aborted, and the user is notified.
if (missingContentIds.Count > 0)
{
return DependencyResolutionResult.CreateFailure(
$"Missing or invalid content IDs: {string.Join(", ", missingContentIds)}");
}Incompatible Manifest:
- Version constraints are checked during dependency resolution.
- If a dependency specifies
MinVersionand the installed version is older, the resolution fails. - The user is prompted to update the content or remove the incompatible item.
Error Handling
The system provides detailed error messages at each stage:
- Manifest Not Found: "Manifest not found for content ID: {contentId}"
- Invalid Manifest ID: "Invalid manifest ID during dependency resolution: {contentId}"
- Circular Dependency: "Circular dependency detected: '{contentId}' is already in the resolution path"
- Missing Dependencies: "Missing or invalid content IDs: {list}"
Profile Launch Process
The profile launch process is the culmination of all previous workflows, bringing together dependency resolution, workspace preparation, settings injection, and game execution.
Complete Launch Flow
Detailed Step Breakdown
1. Resolve Dependencies
var resolutionResult = await _dependencyResolver.ResolveDependenciesWithManifestsAsync(
profile.EnabledContentIds,
cancellationToken);
if (!resolutionResult.Success)
{
return ProfileLaunchResult.CreateFailure(
string.Join(", ", resolutionResult.Errors));
}Output: A list of all required manifests, including transitive dependencies.
2. Acquire Files from CAS
Files are not explicitly "acquired" at this stage. Instead, the WorkspaceManager uses the manifest's file references to locate content in CAS during workspace preparation.
// WorkspaceStrategy (e.g., SymlinkStrategy) maps files from CAS to workspace
foreach (var file in manifest.Files)
{
if (file.SourceType == ContentSourceType.ContentAddressable)
{
var casPath = Path.Combine(casRoot, file.Hash);
var workspacePath = Path.Combine(workspaceDir, file.RelativePath);
// Create symlink from workspace to CAS
CreateSymbolicLink(workspacePath, casPath);
}
}3. Apply Workspace Strategy
The WorkspaceManager selects a strategy based on the profile's WorkspaceStrategy setting:
- SymlinkOnly: Creates symbolic links from workspace to CAS (fastest, requires admin on Windows).
- FullCopy: Copies all files to workspace (slowest, most compatible).
- HybridCopySymlink: Copies executables, symlinks data files (balanced).
- HardLink: Creates hard links (fast, but limited to same volume).
var strategy = strategies.FirstOrDefault(s => s.CanHandle(configuration));
var workspaceInfo = await strategy.PrepareAsync(configuration, progress, cancellationToken);4. Write Options.ini (Game Settings)
The GameSettingsMapper converts profile settings to the SAGE engine's Options.ini format:
// GameLauncher.LaunchAsync
var optionsIniPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
"Command and Conquer Generals Zero Hour Data",
"Options.ini");
var optionsIni = await _gameSettingsService.LoadOptionsIniAsync(optionsIniPath, cancellationToken);
_gameSettingsMapper.MapProfileToOptionsIni(profile, optionsIni);
await _gameSettingsService.SaveOptionsIniAsync(optionsIniPath, optionsIni, cancellationToken);Mapped Settings:
VideoWidth/VideoHeight→ResolutionVideoWindowed→Windowedflag +-wincommand argumentVideoSkipEALogo→SkipIntroAudioVolume→SoundVolume,MusicVolume,VoiceVolume
5. Launch Game Executable
var launchRequest = new GameLaunchRequest
{
ExecutablePath = profile.GameClient.ExecutablePath,
WorkingDirectory = workspaceInfo.WorkspacePath,
CommandLineArguments = profile.CommandLineArguments,
EnvironmentVariables = profile.EnvironmentVariables,
};
var launchResult = await _gameLauncher.LaunchAsync(launchRequest, cancellationToken);The GameLauncher starts the process and registers it in the LaunchRegistry for tracking.
Launch Flow Diagram
Profile Validation
Profile validation ensures that all required components are available and correctly configured before launch.
Content Availability Validation
The ProfileEditorFacade.ValidateProfileAsync() method checks that all enabled content manifests exist in the pool:
if (profile.EnabledContentIds != null && profile.EnabledContentIds.Count > 0)
{
var manifestsResult = await _manifestPool.GetAllManifestsAsync(cancellationToken);
if (manifestsResult.Success && manifestsResult.Data != null)
{
var availableManifestIds = manifestsResult.Data
.Select(m => m.Id.ToString())
.ToHashSet();
var missingContent = profile.EnabledContentIds
.Where(id => !availableManifestIds.Contains(id))
.ToList();
if (missingContent.Count > 0)
{
errors.Add($"Content manifests not found: {string.Join(", ", missingContent)}");
}
}
}Dependency Validation
Dependency validation is performed during the resolution phase:
- Existence Check: Verify that all dependency manifests are installed.
- Version Check: Ensure that installed versions meet
MinVersionconstraints. - Type Check: For type-based dependencies, verify that at least one manifest of the required type exists.
Workspace Validation
The WorkspaceValidator performs comprehensive checks:
- Configuration Validation: Ensures all required paths are set and valid.
- Prerequisite Validation: Checks that the selected strategy can be used (e.g., symlink support).
- Post-Preparation Validation: Verifies that the workspace was created correctly.
if (configuration.ValidateAfterPreparation)
{
var validationResult = await workspaceValidator.ValidateWorkspaceAsync(
workspaceInfo,
cancellationToken);
if (!validationResult.Success || !validationResult.Data!.IsValid)
{
var errors = validationResult.Data!.Issues
.Where(i => i.Severity == ValidationSeverity.Error)
.Select(i => i.Message);
return OperationResult<WorkspaceInfo>.CreateFailure(
$"Workspace validation failed: {string.Join(", ", errors)}");
}
}Settings Validation
Settings validation ensures that game settings are within acceptable ranges:
- Resolution: Must be a valid screen resolution.
- Audio Volumes: Must be between 0 and 100.
- Executable Path: Must point to a valid game executable.
Profile Migration
Profile migration handles updates to profile structure, content versions, and settings schemas.
Version Updates
When the profile schema version changes, the GameProfileRepository applies migrations:
// Example migration from v1 to v2
if (profile.SchemaVersion == 1)
{
// Add new WorkspaceStrategy field with default value
profile.WorkspaceStrategy = WorkspaceStrategy.SymlinkOnly;
profile.SchemaVersion = 2;
await _profileRepository.SaveProfileAsync(profile, cancellationToken);
}Content Updates
When content is updated (e.g., a mod releases a new version), the profile's enabledContentIds may need to be updated:
- Manifest Replacement: The
ManifestReplacedMessageis broadcast when content is updated. - Profile Update: The
GameProfileSettingsViewModellistens for this message and updates the profile's content list. - Workspace Invalidation: The
ActiveWorkspaceIdis cleared, forcing workspace recreation on next launch.
// GameProfileSettingsViewModel.Receive(ManifestReplacedMessage)
public void Receive(ManifestReplacedMessage message)
{
if (SelectedContentIds.Contains(message.OldManifestId))
{
SelectedContentIds.Remove(message.OldManifestId);
SelectedContentIds.Add(message.NewManifestId);
// Trigger profile save
SaveProfileAsync().FireAndForget();
}
}Settings Migration
Settings migration handles changes to the Options.ini schema or new game settings:
// Example: Migrating old "Resolution" field to separate Width/Height
if (profile.VideoWidth == 0 && profile.VideoHeight == 0 && !string.IsNullOrEmpty(profile.Resolution))
{
var parts = profile.Resolution.Split('x');
if (parts.Length == 2 && int.TryParse(parts[0], out var width) && int.TryParse(parts[1], out var height))
{
profile.VideoWidth = width;
profile.VideoHeight = height;
profile.Resolution = null; // Clear old field
}
}Migration Triggers:
- Application startup (automatic migration of all profiles).
- Profile load (on-demand migration if schema version is outdated).
- Content update (when manifest IDs change).
