Game Settings Architecture
This document explains the architecture behind game settings in GenHub, detailing why multiple layers exist and providing a step-by-step guide for adding new settings.
Settings Persistence Strategy
Profile as Single Source of Truth
When a profile is launched, GenHub applies the profile's settings to Options.ini and settings.json. The profile is the single source of truth for that launch session.
Key Principle: Settings changed in-game are preserved in Options.ini AdditionalProperties and will persist across launches as long as GenHub doesn't overwrite them.
AdditionalProperties Preservation (Critical Fix)
Many game settings (like UseDoubleClickAttackMove, ScrollFactor, Retaliation, StaticGameLOD) are not explicitly modeled in GenHub but are stored in Video.AdditionalProperties or AdditionalSections["TheSuperHackers"].
The Fix: When GenHub saves settings via CreateOptionsFromViewModel(), it now updates existing dictionaries instead of replacing them:
// BEFORE (WRONG):
var tshDict = new Dictionary<string, string> { ... };
options.AdditionalSections["TheSuperHackers"] = tshDict; // REPLACES entire section!
// AFTER (CORRECT):
if (!options.AdditionalSections.TryGetValue("TheSuperHackers", out var tshDict))
{
tshDict = new Dictionary<string, string>();
options.AdditionalSections["TheSuperHackers"] = tshDict;
}
// Update only managed settings, preserve all others
tshDict["ArchiveReplays"] = BoolToString(TshArchiveReplays);This ensures that settings not in GenHub's UI are preserved when the user saves profile settings.
Additional Video Settings
The following settings are stored in Video.AdditionalProperties and are fully integrated into GenHub:
| Setting | Property Name | Type | Default | Options.ini Key |
|---|---|---|---|---|
| Detail Level | VideoStaticGameLOD | string | "High" | StaticGameLOD |
| Ideal Detail | VideoIdealStaticGameLOD | string | "VeryHigh" | IdealStaticGameLOD |
| Double Click Guard | VideoUseDoubleClickAttackMove | bool | true | UseDoubleClickAttackMove |
| Scroll Speed | VideoScrollFactor | int | 50 | ScrollFactor |
| Retaliation | VideoRetaliation | bool | true | Retaliation |
| Dynamic LOD | VideoDynamicLOD | bool | false | DynamicLOD |
| Max Particles | VideoMaxParticleCount | int | 5000 | MaxParticleCount |
| Anti-Aliasing | VideoAntiAliasing | int | 1 | AntiAliasing |
These settings are:
- Stored in
GameProfileas nullable properties - Mapped through
UpdateProfileRequestandCreateProfileRequest - Handled by
GameSettingsViewModelwith appropriate defaults - Written to
Options.iniviaAdditionalPropertiesbyGameSettingsMapper.ApplyToOptions() - Preserved when GenHub saves settings via
CreateOptionsFromViewModel()
Troubleshooting
Settings Reset After Saving Profile
Symptom: Settings like Double Click Guard or Scroll Speed reset when you save profile settings in GenHub.
Cause: The CreateOptionsFromViewModel() method was replacing entire dictionaries instead of updating them.
Fix: Implemented in GameSettingsViewModel.CreateOptionsFromViewModel() - now preserves existing AdditionalProperties and AdditionalSections.
GeneralsOnline Client Settings Reset After Launching
Symptom: Options configured inside the GeneralsOnline client (including ones GenHub has no UI for) revert after launching a profile through GenHub.
Cause: ApplyToGeneralsOnlineSettings() coalesced every field with ?? default, so a launch wrote GenHub's defaults over each option the profile said nothing about, and the write started from a fresh GeneralsOnlineSettings instance, which dropped every key the model does not declare.
Fix: settings.json is loaded first and merged into, and only the fields the profile actually declares are written:
if (profile.GoShowFps.HasValue) settings.ShowFps = profile.GoShowFps.Value; // Merges into what was loadedAnything the profile leaves unset stays as the client wrote it, and unmodelled keys survive through [JsonExtensionData]. The load must succeed before the file is rewritten: a missing file loads as defaults and reports success, so a failed load means the client's file exists and is unreadable, and both GameLauncher and GameSettingsViewModel skip the write in that case.
ApplyToOptions() writes Options.ini the same way, only conditionally, and the keys GenHub does not model are preserved there through AdditionalProperties and AdditionalSections rather than [JsonExtensionData].
GameSettingsViewModel reads settings.json again immediately before each save rather than keeping the copy it read when the editor opened, so a save cannot revert what the client (or another GenHub window) wrote in between. Its GeneralsOnline properties have no unset state, so all of them are written on save; if the read that seeds them fails, the view model skips the rewrite entirely rather than writing its own defaults over the client's values. The save itself is written to a file beside settings.json and moved over it, so an interrupted or overlapping write cannot leave a half-written file behind.
Overview
Adding a single game setting in GenHub involves modifying approximately 7-8 files. While this may seem complex, it adheres to a strict Separation of Concerns to ensure robustness, testability, and clear boundaries between data persistence, API contracts, and user interface.
The 7 Layers of a Setting
Data flows from the disk (Options.ini) through the application to the UI (GameSettingsView.axaml) and back.
- Physical Storage:
Options.ini(The raw file on disk) - INI Model:
IniOptions.cs/VideoSettings.cs(Representation of the file structure) - Domain Entities:
GameProfile.cs(Database/Storage model for a profile) - Data Transfer Objects (DTOs):
CreateProfileRequest.cs/UpdateProfileRequest.cs(API contracts for moving data) - Mapper:
GameSettingsMapper.cs(The "glue" translating between standard INI models and GenHub's internal profiles) - Service Layer:
GameSettingsService.cs(Business logic for reading/writing/parsing) - View Model:
GameSettingsViewModel.cs(State management for the UI) - View:
GameSettingsView.axaml(User Interface)
Why so many layers?
1. Persistence != Transport
The format used to save data to the database (or JSON profile file) in GameProfile.cs is often different from how we want to receive updates from the UI (UpdateProfileRequest.cs). Separation allows us to change the API without breaking the database, or vice versa.
2. Domain != INI Format
Options.ini is a legacy format with specific quirks (e.g., "yes"/"no" strings, flat structures). Our Domain Model (GameProfile) should use clean C# types (bool, int). The Mapper layer handles this translation so the rest of the app doesn't have to deal with parsing strings.
3. Separation of UI and Logic
The ViewModel decouples the UI from the business logic. We can test GameSettingsViewModel without launching the app window. It also handles formatting (e.g., converting a backend boolean to a checkbox state).
How to Add a New Setting
Follow this checklist to add a new setting (e.g., NewFeature).
1. Core Models (The Data)
- [ ]
GenHub.Core\Models\GameSettings\VideoSettings.cs(orAudio, etc.)- Add the property matching the
Options.inikey. - Example:
public bool NewFeature { get; set; }
- Add the property matching the
- [ ]
GenHub.Core\Models\GameProfile\GameProfile.cs- Add a nullable property to store this in the profile. Use a clear prefix (e.g.,
Video...). - Example:
public bool? VideoNewFeature { get; set; }
- Add a nullable property to store this in the profile. Use a clear prefix (e.g.,
- [ ]
GenHub.Core\Models\GameProfile\CreateProfileRequest.cs- Add the property to allow setting it during creation.
- [ ]
GenHub.Core\Models\GameProfile\UpdateProfileRequest.cs- Add the property to allow updating it.
2. Business Logic (The Glue)
- [ ]
GenHub.Core\Helpers\GameSettingsMapper.cs- Update 6 methods:
ApplyFromOptions:profile.VideoNewFeature = options.Video.NewFeature;ApplyToOptions:options.Video.NewFeature = profile.VideoNewFeature ?? default;PopulateGameProfile: Map request -> profile.PatchGameProfile: Map request -> profile (for updates).UpdateFromRequest: Map request -> profile.PopulateRequest: Map profile -> request.
- Update 6 methods:
- [ ]
GenHub\Features\GameSettings\GameSettingsService.cs- Parsing: Update
ParseVideoSection(or relevant section) to read the key from the INI file. - Serialization: Update
SerializeOptionsInito write the key back to the file. - Categorization: Add the key to
videoKeysor relevant list inCategorizeRootSettingsto ensure it's not treated as an "unknown" setting.
- Parsing: Update
3. User Interface (The Visuals)
- [ ]
GenHub\Features\GameProfiles\ViewModels\GameSettingsViewModel.cs- Add
[ObservableProperty] private bool _newFeature; - Update
LoadSettingsFromProfile:if (profile.VideoNewFeature.HasValue) NewFeature = profile.VideoNewFeature.Value; - Update
GetProfileSettings:VideoNewFeature = NewFeature, - Update
ApplyOptionsToViewModel:NewFeature = options.Video.NewFeature; - Update
CreateOptionsFromViewModel:options.Video.NewFeature = NewFeature;
- Add
- [ ]
GenHub\Features\GameProfiles\Views\GameSettingsView.axaml- Add the control (e.g.,
<CheckBox Content="New Feature" IsChecked="{Binding NewFeature}" />).
- Add the control (e.g.,
Custom GenHub Settings
Sometimes we need to save settings that don't exist in the standard Options.ini (e.g., BuildingAnimations).
- We store these in
AdditionalPropertieswith aGenHubprefix (e.g.,GenHubBuildingAnimations).
Technical Implementation Reference
This section documents the specific classes and files involved in the GeneralsOnline settings pipeline.
Core Files & Responsibilities
There are 7 key files that handle the lifecycle of a GeneralsOnline setting.
| Component | File Path | Class Name | Responsibility |
|---|---|---|---|
| DTO (Request) | GenHub.Core\Models\GameProfile\UpdateProfileRequest.cs | UpdateProfileRequest | Carries user input from UI. Has nullable fields (e.g., GoShowFps, TshArchiveReplays). |
| Mapper | GenHub.Core\Helpers\GameSettingsMapper.cs | GameSettingsMapper | Moves data from DTO -> Profile, and Profile -> INI/JSON Models. |
| Model (DB) | GenHub.Core\Models\GameProfile\GameProfile.cs | GameProfile | Stores the "Source of Truth". Contains persistent properties for all settings. |
| Model (JSON) | GenHub.Core\Models\GameSettings\GeneralsOnlineSettings.cs | GeneralsOnlineSettings | The exact structure serialized to settings.json. Inherits TheSuperHackersSettings. |
| Model (INI) | GenHub.Core\Models\GameSettings\IniOptions.cs | IniOptions | The structure serialized to Options.ini. Stores TSH settings in AdditionalSections. |
| IO Service | GenHub\Features\GameSettings\GameSettingsService.cs | GameSettingsService | Handles physical file writes. Methods: SaveOptionsAsync and SaveGeneralsOnlineSettingsAsync. |
| Orchestrator | GenHub\Features\Launching\GameLauncher.cs | GameLauncher | Triggers the write operation immediately before game start. |
Data Flow Pipeline
Tracing a setting change (e.g., "Show FPS") from User to Disk:
UI Request: The frontend sends an
UpdateProfileRequestcontainingGoShowFps = true.Mapping to Profile:
GameProfileManagercallsGameSettingsMapper.PopulateGameProfile(profile, request).- Code:
profile.GoShowFps = request.GoShowFps ?? profile.GoShowFps; - Result: database now stores the user's preference.
Launch Sequence:
- User clicks "Launch".
GameLauncher.csexecutes two parallel operations:
Path A: To Options.ini (Legacy/TSH)
- Calls
ApplyProfileSettingsToIniOptionsAsync. GameSettingsMapper.ApplyToOptionsmapsprofile.Tsh...properties intoIniOptions.AdditionalSections["TheSuperHackers"].GameSettingsServicewritesOptions.ini. Note: It manually adds the[TheSuperHackers]header.
Path B: To settings.json (GeneralsOnline)
- Calls
ApplyGeneralsOnlineSettingsAsync, which runs only for GeneralsOnline profiles:settings.jsonis a single global file owned by that client, so a retail, TheSuperHackers or CommunityOutpost profile must leave it alone. - Loads the existing
settings.jsoninto aGeneralsOnlineSettings, and skips the write if it could not be read. - Merges the declared properties into it:
if (profile.GoShowFps.HasValue) settings.ShowFps = profile.GoShowFps.Value; GameSettingsServicewritessettings.jsonusingSystem.Text.Json.
Inheritance Detail
GeneralsOnlineSettings.cs inherits from TheSuperHackersSettings.cs.
public class GeneralsOnlineSettings : TheSuperHackersSettings
{
public bool ShowFps { get; set; }
// ... other GO settings
}This inheritance explains why settings.json contains keys like ArchiveReplays (a TSH setting). The GameLauncher maps TSH properties from the profile into the GeneralsOnlineSettings object before saving, effectively duplicating them for the GO client.
