From 5d5d15bc0579f6896c6a8547dae7b211e0739bb2 Mon Sep 17 00:00:00 2001
From: Jaredl-Dev <260281143+Jaredl-Dev@users.noreply.github.com>
Date: Tue, 19 May 2026 16:12:01 -0700
Subject: [PATCH] refactor!: complete application rewrite and modernization
* update to .NET 10 and the latest C# version
* overhaul the application architecture to follow layered architecture, MVVM, and dependency injection principles
* modernize and improve the codebase
* add logging throughout the codebase
* greatly improve download performance and reliability
* support only GeneralsOnline and SuperHackers clients
* add support for SuperHackers world builders
* replace deployment with a manifest-driven system that uses hard links, with copy/move operations as a fallback
* redesign the launcher options menu
* remove support for legacy launcher workflows and functionality related to GenTool, modded.exe, Vulkan, unsupported World Builder features, recommended game options, game option tweaking, custom camera height adjustment, and other features that are unnecessary or incompatible with GeneralsOnline and SuperHackers
* rework content validation to be always enforced, more performant, more reliable, and stricter, and add a UI dialog to display validation deviations
* switch archive extraction to SharpCompress
* consolidate runtime folder creation into a single subfolder within the game directory and add support for running the application directly from that subfolder
* add several quality-of-life improvements, including download ETA and speed indicators, and active addon and patch counts in tab titles
* remove the application's self-updater to prevent fork installations from being overwritten by updates from the upstream repository
---
.codex/config.toml | 2 +
.config/dotnet-tools.json | 13 +
.editorconfig | 160 +
.github/ISSUE_TEMPLATE/bug_report.md | 28 -
.github/ISSUE_TEMPLATE/feature_request.md | 20 -
.github/workflows/ci.yml | 60 +
.gitignore | 409 +-
.mcp.json | 8 +
AGENTS.md | 64 +
Directory.Build.props | 15 +
Directory.Build.targets | 22 +
Directory.Packages.props | 28 +
GenLauncher.sln | 31 -
GenLauncherGO.Core/AGENTS.md | 9 +
GenLauncherGO.Core/GenLauncherGO.Core.csproj | 5 +
GenLauncherGO.Core/IO/LexicalPath.cs | 154 +
.../Integrity/Models/ContentIntegrityIssue.cs | 11 +
.../Models/ContentIntegrityReport.cs | 25 +
.../Models/ContentIntegrityTarget.cs | 50 +
.../Integrity/Models/ContentSourceKind.cs | 27 +
.../Integrity/Models/IntegrityIssueAction.cs | 37 +
.../Integrity/Models/IntegrityIssueKind.cs | 42 +
.../IGameExecutableDiscoveryService.cs | 25 +
.../Contracts/IGameProcessLaunchOperation.cs | 30 +
.../Contracts/IGameProcessLauncher.cs | 18 +
...LaunchContentIntegrityResolutionService.cs | 55 +
.../Contracts/ILaunchPreparationService.cs | 35 +
.../Launching/LauncherGameArgumentService.cs | 143 +
.../Launching/Models/GameClientExecutable.cs | 22 +
.../Models/GameClientExecutableKind.cs | 8 +
.../Launching/Models/GameLaunchRequest.cs | 57 +
.../Launching/Models/GameLaunchTargetKind.cs | 8 +
...aunchContentIntegrityResolutionProgress.cs | 42 +
...LaunchContentIntegrityResolutionRequest.cs | 30 +
.../LaunchContentIntegrityTargetContext.cs | 27 +
.../LaunchContentIntegrityTargetRequest.cs | 35 +
...aunchContentIntegrityVerificationResult.cs | 24 +
.../LaunchContentIntegrityVersionRequest.cs | 35 +
.../Models/LaunchPreparationRequest.cs | 32 +
.../Models/WorldBuilderExecutable.cs | 22 +
.../Models/WorldBuilderExecutableKind.cs | 8 +
.../Mods/Contracts/ILauncherContentCatalog.cs | 90 +
.../Contracts/IManualModificationImporter.cs | 19 +
.../IModificationImageFileService.cs | 39 +
.../LauncherContentPersistenceException.cs | 12 +
.../Mods/Models/LauncherContent.cs | 162 +
...cherContentCatalogInitializationRequest.cs | 11 +
.../Models/LauncherContentInstallation.cs | 19 +
.../Mods/Models/LauncherContentKey.cs | 134 +
.../Mods/Models/LauncherContentVersion.cs | 118 +
.../Models/LauncherContentVersionComparer.cs | 55 +
.../Mods/Models/LauncherData.cs | 238 +
.../ModificationImageReplacementRequest.cs | 26 +
.../Mods/Models/ModificationType.cs | 27 +
.../Mods/Models/OwnedContentPath.cs | 39 +
.../Services/LauncherContentPathResolver.cs | 113 +
.../Remote/IRemoteConnectionProbe.cs | 16 +
.../Contracts/ILauncherPreferencesService.cs | 30 +
...LauncherPreferencesPersistenceException.cs | 12 +
.../Models/LauncherCustomExecutable.cs | 19 +
.../Models/LauncherGamePreferences.cs | 23 +
.../Models/LauncherGamePreferencesSet.cs | 33 +
.../Settings/Models/LauncherInstallations.cs | 57 +
.../Settings/Models/LauncherPreferences.cs | 14 +
.../Models/LauncherSharedPreferences.cs | 10 +
.../Shell/Contracts/ILauncherShellService.cs | 20 +
.../Contracts/IGameInstallationService.cs | 87 +
.../ILauncherHostEnvironmentService.cs | 40 +
.../Contracts/ILauncherSingleInstanceGuard.cs | 14 +
.../Startup/ILauncherPathResolver.cs | 22 +
.../Startup/LauncherFileSystemLayout.cs | 128 +
GenLauncherGO.Core/Startup/LauncherPaths.cs | 161 +
.../Startup/LauncherRuntimePathContext.cs | 64 +
.../Startup/LauncherStoragePaths.cs | 49 +
.../Models/GameInstallationLocation.cs | 23 +
.../GameInstallationValidationFailure.cs | 42 +
.../GameInstallationValidationResult.cs | 42 +
.../LauncherInstallationsValidationResult.cs | 35 +
.../Startup/Models/LauncherRestartResult.cs | 25 +
GenLauncherGO.Core/Startup/SupportedGame.cs | 22 +
.../Contracts/IPackageDownloadService.cs | 23 +
.../Contracts/IRemotePackageSizeResolver.cs | 21 +
.../Models/PackageDownloadPauseController.cs | 75 +
.../Updating/Models/PackageDownloadResult.cs | 53 +
.../Updating/Models/PackageDownloadStatus.cs | 27 +
.../Updating/Models/PackageUpdateProgress.cs | 11 +
GenLauncherGO.Infrastructure/AGENTS.md | 9 +
.../Archives/ArchiveExtractor.cs | 115 +
.../Archives/ArchiveFileSupport.cs | 18 +
.../Archives/Contracts/IArchiveExtractor.cs | 32 +
.../Common/BigFileVariantPath.cs | 110 +
.../Common/FileSystemPathSafety.cs | 159 +
.../Common/ManifestPathResolver.cs | 135 +
.../Common/OwnedDirectoryTree.cs | 381 +
.../Common/PhysicalDirectoryPath.cs | 164 +
.../GenLauncherGO.Infrastructure.csproj | 21 +
...frastructureServiceCollectionExtensions.cs | 77 +
.../Contracts/IContentIntegrityService.cs | 52 +
.../FileSystemContentIntegrityService.cs | 482 +
.../Integrity/Support/ContentIntegrityPath.cs | 47 +
.../Support/ContentIntegrityScanner.cs | 157 +
.../Support/ContentIntegritySnapshotStore.cs | 147 +
.../ILaunchContentIntegrityTargetBuilder.cs | 10 +
.../DeploymentLaunchPreparationService.cs | 125 +
.../Services/FileSystemDeploymentService.cs | 1138 +
...LaunchContentIntegrityResolutionService.cs | 547 +
...stemLaunchContentIntegrityTargetBuilder.cs | 194 +
.../WindowsGameExecutableDiscoveryService.cs | 103 +
.../Services/WindowsGameProcessLauncher.cs | 154 +
.../Services/WindowsProcessFamilyLauncher.cs | 664 +
.../Support/DeploymentFilePlanner.cs | 106 +
.../Support/DeploymentPathResolver.cs | 59 +
.../Launching/Support/DeploymentResult.cs | 53 +
.../Launching/Support/DeploymentStateStore.cs | 700 +
.../Launching/Support/IHardLinkCreator.cs | 12 +
.../Support/IProcessFamilyLaunchOperation.cs | 30 +
.../Support/IProcessFamilyLauncher.cs | 20 +
.../Support/WindowsHardLinkCreator.cs | 43 +
.../LoggingServiceCollectionExtensions.cs | 92 +
.../SensitiveDataRedactingTextFormatter.cs | 90 +
.../Contracts/ILauncherContentStateStore.cs | 20 +
.../Contracts/ILocalLauncherContentService.cs | 38 +
.../Mods/Models/LauncherContentEntryState.cs | 28 +
.../Mods/Models/LauncherContentState.cs | 15 +
.../Models/LauncherContentVersionState.cs | 24 +
.../LegacyCatalogAdvertisingReference.cs | 15 +
.../LegacyCatalogModificationReference.cs | 17 +
.../Mods/Models/LegacyContentManifest.cs | 46 +
.../Models/LegacyLauncherCatalogDocument.cs | 29 +
.../Mods/Models/RemoteAdvertisingReference.cs | 26 +
.../RemoteCatalogModificationReference.cs | 30 +
.../Models/RemoteChildManifestLoadResult.cs | 25 +
.../Mods/Models/RemoteLauncherCatalog.cs | 36 +
.../Mods/Models/RemoteModificationManifest.cs | 27 +
.../FileSystemLocalLauncherContentService.cs | 374 +
.../FileSystemManualModificationImporter.cs | 154 +
.../FileSystemModificationImageFileService.cs | 220 +
.../Services/LauncherCatalogImageCache.cs | 174 +
.../Services/LauncherContentCatalogService.cs | 534 +
.../Services/LauncherContentStateMapper.cs | 186 +
.../LauncherLocalContentReconciler.cs | 177 +
.../Services/RemoteLauncherCatalogClient.cs | 258 +
.../Services/YamlLauncherContentStateStore.cs | 49 +
.../Support/ModificationImageCachePath.cs | 85 +
.../Support/RemoteLauncherCatalogMapper.cs | 112 +
.../Persistence/Services/AtomicFileWriter.cs | 148 +
.../Persistence/Services/IAtomicFileWriter.cs | 32 +
.../Services/IYamlDocumentStore.cs | 19 +
.../Persistence/Services/YamlDocumentStore.cs | 85 +
.../Properties/AssemblyInfo.cs | 3 +
.../Contracts/IRemoteAssetDownloader.cs | 16 +
.../Contracts/IRemoteYamlDocumentReader.cs | 10 +
.../Remote/HttpRemoteAssetDownloader.cs | 69 +
.../Remote/HttpRemoteConnectionProbe.cs | 82 +
.../Remote/HttpRemoteYamlDocumentReader.cs | 70 +
.../Remote/SharedHttpClientFactory.cs | 34 +
...frastructureServiceCollectionExtensions.cs | 40 +
.../Models/LauncherPreferencesDocument.cs | 110 +
.../Settings/Services/PreferencesService.cs | 124 +
.../LauncherPreferencesDocumentMapper.cs | 307 +
.../Services/WindowsLauncherShellService.cs | 157 +
.../Startup/FileSystemLauncherPathResolver.cs | 69 +
.../Startup/IGameInstallationRegistry.cs | 12 +
.../WindowsGameInstallationRegistry.cs | 121 +
.../Startup/WindowsGameInstallationService.cs | 255 +
.../WindowsLauncherHostEnvironmentService.cs | 186 +
.../Clients/HttpDownloadFileMetadataReader.cs | 129 +
.../Updating/Clients/MinioClientFactory.cs | 46 +
.../Clients/MinioS3ObjectManifestReader.cs | 116 +
.../Clients/ResumableHttpFileDownloader.cs | 402 +
.../Contracts/IDownloadFileMetadataReader.cs | 13 +
.../Updating/Contracts/IFileHashService.cs | 12 +
.../Contracts/IResumableFileDownloader.cs | 14 +
.../Contracts/IS3ObjectManifestReader.cs | 13 +
.../Updating/Contracts/IS3PackageUpdater.cs | 24 +
.../Contracts/ISingleFilePackageUpdater.cs | 17 +
.../Updating/Models/DownloadFileMetadata.cs | 8 +
.../Updating/Models/DownloadFileRequest.cs | 11 +
.../Updating/Models/DownloadProgress.cs | 6 +
.../Updating/Models/PackageUpdatePathSet.cs | 49 +
.../Models/RemoteFileManifestEntry.cs | 6 +
.../Models/S3ObjectManifestRequest.cs | 16 +
.../Models/S3PackageFileRepairRequest.cs | 13 +
.../Updating/Models/S3PackageUpdateRequest.cs | 12 +
.../Updating/Services/Md5FileHashService.cs | 51 +
.../Services/PackageDownloadService.cs | 278 +
.../Services/RemotePackageSizeResolver.cs | 138 +
.../Updating/Services/S3PackageUpdater.cs | 566 +
.../Services/SingleFilePackageUpdater.cs | 153 +
.../Updating/Support/DownloadLinkResolver.cs | 76 +
.../Updating/Support/InlineProgress.cs | 21 +
.../Support/MonotonicPackageProgress.cs | 65 +
.../Support/PackageInstallFolderReplacer.cs | 302 +
.../Support/PackageProgressTracker.cs | 120 +
.../Support/PackageStagingFolderCleaner.cs | 172 +
.../Updating/Support/S3CatalogDefaults.cs | 74 +
.../Support/S3HashValidationPolicy.cs | 44 +
.../Support/S3ReusablePackageFileCopier.cs | 193 +
GenLauncherGO.Tests/AGENTS.md | 11 +
.../Core/IO/LexicalPathTests.cs | 63 +
.../Models/ContentIntegrityReportTests.cs | 54 +
.../Models/ContentIntegrityTargetTests.cs | 40 +
.../LauncherGameArgumentServiceTests.cs | 61 +
.../Mods/Models/LauncherContentKeyTests.cs | 100 +
.../Core/Mods/Models/LauncherContentTests.cs | 136 +
.../Models/LauncherContentVersionTests.cs | 64 +
.../Core/Mods/Models/LauncherDataTests.cs | 283 +
.../Core/Mods/Models/OwnedContentPathTests.cs | 36 +
.../LauncherContentPathResolverTests.cs | 195 +
.../Models/LauncherPreferencesTests.cs | 68 +
.../GameInstallationServiceExtensionsTests.cs | 97 +
.../Core/Startup/LauncherPathsTests.cs | 147 +
.../LauncherRuntimePathContextTests.cs | 38 +
.../Core/Startup/LauncherStoragePathsTests.cs | 40 +
.../GenLauncherGO.Tests.csproj | 46 +
GenLauncherGO.Tests/GlobalUsings.cs | 5 +
.../Infrastructure/ArchiveExtractorTests.cs | 72 +
.../Common/BigFileVariantPathTests.cs | 91 +
.../Common/FileSystemPathSafetyTests.cs | 99 +
.../Common/ManifestPathResolverTests.cs | 42 +
.../Common/OwnedDirectoryTreeTests.cs | 97 +
.../FileSystemContentIntegrityServiceTests.cs | 800 +
...DeploymentLaunchPreparationServiceTests.cs | 188 +
.../FileSystemDeploymentServiceTests.cs | 1130 +
...hContentIntegrityResolutionServiceTests.cs | 710 +
...aunchContentIntegrityTargetBuilderTests.cs | 63 +
...dowsGameExecutableDiscoveryServiceTests.cs | 208 +
.../WindowsGameProcessLauncherTests.cs | 172 +
.../WindowsProcessFamilyLauncherTests.cs | 241 +
.../Support/DeploymentFilePlannerTests.cs | 28 +
.../Support/DeploymentPathResolverTests.cs | 130 +
.../Support/WindowsHardLinkCreatorTests.cs | 45 +
...LoggingServiceCollectionExtensionsTests.cs | 128 +
...eSystemLocalLauncherContentServiceTests.cs | 259 +
...leSystemManualModificationImporterTests.cs | 264 +
...SystemModificationImageFileServiceTests.cs | 347 +
.../LauncherCatalogImageCacheTests.cs | 285 +
.../LauncherContentCatalogServiceTests.cs | 1415 +
.../LauncherContentStateMapperTests.cs | 270 +
.../LauncherLocalContentReconcilerTests.cs | 295 +
.../RemoteLauncherCatalogClientTests.cs | 229 +
.../YamlLauncherContentStateStoreTests.cs | 258 +
.../RemoteLauncherCatalogMapperTests.cs | 176 +
.../Services/YamlDocumentStoreTests.cs | 137 +
.../Remote/HttpRemoteAssetDownloaderTests.cs | 53 +
.../Remote/HttpRemoteConnectionProbeTests.cs | 80 +
.../HttpRemoteYamlDocumentReaderTests.cs | 55 +
.../Services/PreferencesServiceTests.cs | 401 +
.../WindowsLauncherShellServiceTests.cs | 173 +
.../FileSystemLauncherPathResolverTests.cs | 74 +
.../WindowsGameInstallationRegistryTests.cs | 101 +
.../WindowsGameInstallationServiceTests.cs | 293 +
...dowsLauncherHostEnvironmentServiceTests.cs | 123 +
.../HttpDownloadFileMetadataReaderTests.cs | 109 +
.../Clients/MinioClientFactoryTests.cs | 23 +
.../MinioS3ObjectManifestReaderTests.cs | 66 +
.../ResumableHttpFileDownloaderTests.cs | 513 +
.../Updating/Models/S3RequestDefaultsTests.cs | 55 +
.../Services/Md5FileHashServiceTests.cs | 24 +
.../Services/PackageDownloadServiceTests.cs | 509 +
.../RemotePackageSizeResolverTests.cs | 194 +
.../Services/S3PackageUpdaterBehaviorTests.cs | 459 +
.../Services/SingleFilePackageUpdaterTests.cs | 292 +
.../Support/DownloadLinkResolverTests.cs | 39 +
.../PackageInstallFolderReplacerTests.cs | 300 +
.../Support/PackageProgressTrackerTests.cs | 100 +
.../PackageStagingFolderCleanerTests.cs | 175 +
.../Support/S3HashValidationPolicyTests.cs | 79 +
.../Testing/FakeLauncherContentCatalog.cs | 166 +
.../Testing/ManualTimeProvider.cs | 23 +
.../Testing/QueueHttpMessageHandler.cs | 35 +
.../Testing/RecordingAtomicFileWriter.cs | 39 +
.../RecordingLocalLauncherContentService.cs | 46 +
.../Testing/RecordingRemoteAssetDownloader.cs | 26 +
GenLauncherGO.Tests/Testing/StaTestRunner.cs | 56 +
.../Testing/StaTestRunnerTests.cs | 39 +
.../Testing/StubLauncherContentStateStore.cs | 40 +
.../Testing/StubRemoteYamlDocumentReader.cs | 98 +
.../Testing/SymbolicLinkFactAttribute.cs | 13 +
.../Testing/SymbolicLinkTestSupport.cs | 125 +
GenLauncherGO.Tests/Testing/TestDirectory.cs | 76 +
.../Testing/TestLauncherLaunchCoordinator.cs | 117 +
.../Testing/TestLauncherPaths.cs | 49 +
.../Testing/TestLauncherRuntimeContext.cs | 24 +
.../Testing/TestLauncherTheme.cs | 31 +
.../Testing/TestStringLocalizer.cs | 57 +
.../ManualModificationDialogRequestTests.cs | 18 +
.../AvaloniaLauncherDialogServiceTests.cs | 162 +
.../LaunchContentIntegrityCoordinatorTests.cs | 612 +
.../LauncherPackageActivityServiceTests.cs | 156 +
.../IntegrityReviewViewModelTests.cs | 181 +
.../Services/LauncherCloseGuardTests.cs | 84 +
...LauncherExecutableSelectionServiceTests.cs | 141 +
.../LauncherGameSessionCoordinatorTests.cs | 455 +
.../LauncherLaunchCoordinatorTests.cs | 814 +
...LauncherLaunchReadinessCoordinatorTests.cs | 602 +
.../LauncherManualImportCoordinatorTests.cs | 397 +
...herModificationDownloadCoordinatorTests.cs | 374 +
.../LauncherTileActionServiceTests.cs | 267 +
.../LauncherWindowWorkflowCoordinatorTests.cs | 1846 ++
.../LauncherDragDropControllerTests.cs | 379 +
.../LauncherWindowListControllerTests.cs | 606 +
.../ViewModels/MainWindowViewModelTests.cs | 1238 +
.../ModificationImageSourceFactoryTests.cs | 92 +
.../Mods/ModificationViewModelTests.cs | 415 +
.../AddModificationViewModelTests.cs | 221 +
.../ModificationTileImageProviderTests.cs | 172 +
.../ViewModels/ModsDialogViewModelTests.cs | 402 +
...ncherExecutableManagementViewModelTests.cs | 336 +
.../LauncherSettingsViewModelTests.cs | 253 +
.../Settings/LauncherSettingsWindowTests.cs | 169 +
.../LauncherApplicationCompositionTests.cs | 41 +
.../Startup/LauncherApplicationHostTests.cs | 312 +
.../Startup/LauncherElevationManifestTests.cs | 30 +
.../Startup/LauncherStartupCultureTests.cs | 34 +
.../ViewModels/InitWindowViewModelTests.cs | 304 +
.../StandaloneStartupViewModelTests.cs | 559 +
.../UI/LauncherThemeRenderedStateTests.cs | 54 +
.../UI/NativeAxamlSmokeTests.cs | 167 +
.../AvaloniaUiExceptionBoundaryTests.cs | 94 +
.../Formatting/ByteSizeFormatterTests.cs | 50 +
.../PackageProgressTextFormatterTests.cs | 109 +
.../AvaloniaLauncherStringLocalizerTests.cs | 46 +
.../LocalizationResourceParityTests.cs | 136 +
.../LauncherThemeResourceApplierTests.cs | 276 +
GenLauncherGO.UI/AGENTS.md | 11 +
GenLauncherGO.UI/App.axaml | 301 +
.../Contracts/ILauncherDialogService.cs | 60 +
.../Models/LauncherInfoDialogRequest.cs | 18 +
.../Models/ManualModificationDialogRequest.cs | 15 +
.../Models/ManualModificationDialogResult.cs | 22 +
.../Services/AvaloniaLauncherDialogService.cs | 196 +
.../ILaunchContentIntegrityProgressTarget.cs | 37 +
.../Integrity/IntegrityReviewDialog.axaml | 171 +
.../Integrity/IntegrityReviewDialog.axaml.cs | 63 +
.../LaunchContentIntegrityCoordinator.cs | 463 +
.../LauncherPackageActivityService.cs | 478 +
.../ViewModels/IntegrityReviewViewModel.cs | 232 +
.../Launcher/Contracts/ILauncherFilePicker.cs | 42 +
.../Launcher/Models/ExecutableOption.cs | 34 +
.../Launcher/Models/LauncherCloseReason.cs | 8 +
.../Models/LauncherContentViewKind.cs | 12 +
.../Models/LauncherLaunchFailureKind.cs | 14 +
.../Launcher/Models/LauncherLaunchRequest.cs | 22 +
.../Launcher/Models/LauncherLaunchResult.cs | 17 +
.../Models/LauncherManualImportRequest.cs | 15 +
.../Models/LauncherManualImportResult.cs | 7 +
.../Models/LauncherTaskbarProgressState.cs | 8 +
.../Launcher/Models/LauncherTileLinkAction.cs | 5 +
.../Services/AvaloniaLauncherFilePicker.cs | 176 +
.../Launcher/Services/LauncherCloseGuard.cs | 127 +
.../LauncherExecutableSelectionService.cs | 152 +
.../LauncherGameSessionCoordinator.cs | 357 +
.../Services/LauncherLaunchCoordinator.cs | 449 +
.../LauncherLaunchReadinessCoordinator.cs | 247 +
.../LauncherManualImportCoordinator.cs | 233 +
...LauncherModificationDownloadCoordinator.cs | 276 +
.../Services/LauncherTileActionService.cs | 166 +
.../LauncherWindowWorkflowCoordinator.cs | 1007 +
.../Services/WindowsTaskbarProgress.cs | 120 +
.../Support/LauncherDragDropController.cs | 265 +
.../Support/LauncherWindowListController.cs | 224 +
.../ViewModels/MainWindowViewModel.cs | 1280 +
.../Features/Launcher/Views/MainWindow.axaml | 1302 +
.../Launcher/Views/MainWindow.axaml.cs | 577 +
.../Mods/ModificationImageSourceFactory.cs | 207 +
.../Mods/ModificationVersionSelection.cs | 26 +
.../Features/Mods/ModificationViewModel.cs | 832 +
.../Resources/UserAddedModBannerGenerals.jpg | Bin
.../Resources/UserAddedModBannerZeroHour.jpg | Bin
.../AddModificationItemViewModel.cs | 58 +
.../ViewModels/AddModificationViewModel.cs | 276 +
.../Mods/ViewModels/InfoDialogKind.cs | 10 +
.../Mods/ViewModels/InfoDialogViewModel.cs | 100 +
.../ManualAddModificationViewModel.cs | 248 +
.../ModificationTileImageProvider.cs | 205 +
.../Mods/Views/AddModificationWindow.axaml | 128 +
.../Mods/Views/AddModificationWindow.axaml.cs | 72 +
.../Features/Mods/Views/InfoWindow.axaml | 203 +
.../Features/Mods/Views/InfoWindow.axaml.cs | 70 +
.../Views/ManualAddModificationWindow.axaml | 118 +
.../ManualAddModificationWindow.axaml.cs | 67 +
.../LauncherExecutableManagementKind.cs | 7 +
.../LauncherExecutableManagementViewModel.cs | 442 +
.../ViewModels/LauncherSettingsViewModel.cs | 236 +
.../LauncherExecutableEditorWindow.axaml | 124 +
.../LauncherExecutableEditorWindow.axaml.cs | 80 +
.../LauncherExecutableManagementWindow.axaml | 210 +
...auncherExecutableManagementWindow.axaml.cs | 122 +
.../Views/LauncherSettingsWindow.axaml | 318 +
.../Views/LauncherSettingsWindow.axaml.cs | 255 +
.../Contracts/IStandaloneStartupWorkflow.cs | 26 +
.../Contracts/IStartupDialogService.cs | 20 +
.../Features/Startup/EntryPoint.cs | 27 +
.../Startup/LauncherApplicationDefaults.cs | 14 +
.../Startup/LauncherApplicationHost.cs | 468 +
.../Startup/LauncherAvaloniaApplication.cs | 52 +
.../Startup/LauncherRuntimeContext.cs | 73 +
.../LauncherUiServiceCollectionExtensions.cs | 73 +
.../Startup/Models/StandaloneStartupResult.cs | 37 +
.../AvaloniaStandaloneStartupWorkflow.cs | 170 +
.../Services/AvaloniaStartupDialogService.cs | 83 +
.../Services/LauncherRestartCoordinator.cs | 61 +
.../Services/LauncherStartupCulture.cs | 25 +
.../InitWindowStartupCompletedEventArgs.cs | 13 +
.../Startup/ViewModels/InitWindowViewModel.cs | 184 +
.../LauncherGameSelectionViewModel.cs | 88 +
.../LauncherInstallationsViewModel.cs | 332 +
.../ViewModels/LauncherSetupViewModel.cs | 75 +
.../Features/Startup/Views/InitWindow.axaml | 18 +
.../Startup/Views/InitWindow.axaml.cs | 133 +
.../Views/LauncherGameSelectionWindow.axaml | 210 +
.../LauncherGameSelectionWindow.axaml.cs | 90 +
.../Views/LauncherLocationWarningWindow.axaml | 105 +
.../LauncherLocationWarningWindow.axaml.cs | 37 +
.../Startup/Views/LauncherSetupWindow.axaml | 292 +
.../Views/LauncherSetupWindow.axaml.cs | 155 +
GenLauncherGO.UI/GenLauncherGO.UI.csproj | 60 +
GenLauncherGO.UI/Properties/AssemblyInfo.cs | 9 +
.../WinX64SelfContained.pubxml | 15 +
GenLauncherGO.UI/Resources/Strings.ar.resx | 752 +
GenLauncherGO.UI/Resources/Strings.cs | 24 +
GenLauncherGO.UI/Resources/Strings.de.resx | 754 +
GenLauncherGO.UI/Resources/Strings.es.resx | 752 +
GenLauncherGO.UI/Resources/Strings.fr.resx | 752 +
GenLauncherGO.UI/Resources/Strings.hr.resx | 752 +
GenLauncherGO.UI/Resources/Strings.pt.resx | 752 +
GenLauncherGO.UI/Resources/Strings.resx | 809 +
GenLauncherGO.UI/Resources/Strings.ru.resx | 752 +
GenLauncherGO.UI/Resources/Strings.tr.resx | 752 +
GenLauncherGO.UI/Resources/Strings.uk.resx | 752 +
GenLauncherGO.UI/Resources/Strings.zh.resx | 752 +
.../Controls/LauncherLoadingIndicator.axaml | 36 +
.../LauncherLoadingIndicator.axaml.cs | 17 +
.../Controls/LauncherTextBoxFeedback.cs | 101 +
.../Shared/Controls/UpdateButton.cs | 48 +
.../Shared/Dialogs/AvaloniaDialog.cs | 90 +
.../Errors/AvaloniaUiExceptionBoundary.cs | 105 +
.../Shared/Errors/IUiExceptionBoundary.cs | 35 +
.../Shared/Errors/UiOperationOutcome.cs | 11 +
.../Shared/Formatting/ByteSizeFormatter.cs | 56 +
.../PackageProgressTextFormatter.cs | 83 +
.../AvaloniaLauncherStringLocalizer.cs | 21 +
.../Localization/ILauncherStringLocalizer.cs | 9 +
.../Shared/Localization/LocExtension.cs | 30 +
.../Shared/Resources/Icons/GenLauncherGo.ico | Bin
.../Images/LauncherBackgroundGenerals.png | Bin 0 -> 2730652 bytes
.../Images/LauncherBackgroundZeroHour.png | Bin 0 -> 3979816 bytes
.../Images/LauncherEmblemCompact.png | Bin
.../Resources/Images/LauncherEmblemFramed.png | Bin
.../Images/OfficialCoverGenerals.png | Bin 0 -> 2219212 bytes
.../Images/OfficialCoverZeroHour.jpg | Bin 0 -> 32094 bytes
GenLauncherGO.UI/Shared/Themes/ColorsInfo.cs | 109 +
.../Shared/Themes/GenLauncherStyles.axaml | 185 +
.../Shared/Themes/LauncherThemePresets.cs | 91 +
.../Themes/LauncherThemeResourceApplier.cs | 132 +
GenLauncherGO.UI/app.manifest | 10 +
GenLauncherGO.sln | 43 +
GenLauncherGO.slnLaunch | 11 +
GenLauncherNet/App.config | 14 -
GenLauncherNet/App.xaml | 13 -
GenLauncherNet/App.xaml.cs | 20 -
GenLauncherNet/Background.png | Bin 979816 -> 0 bytes
GenLauncherNet/DataClasses/ColorsInfo.cs | 121 -
GenLauncherNet/DataClasses/ComboBoxData.cs | 28 -
.../DataClasses/GameModification.cs | 68 -
GenLauncherNet/DataClasses/LauncherData.cs | 109 -
.../DataClasses/ModificationFileInfo.cs | 51 -
.../DataClasses/ModificationVersion.cs | 167 -
.../DataClasses/ModificationViewModel.cs | 555 -
.../DataClasses/ReposModificationsVersion.cs | 80 -
GenLauncherNet/DataClasses/ReposModsData.cs | 50 -
.../DataClasses/SessionInformation.cs | 21 -
.../DataClasses/StringConcurrentDictionary.cs | 45 -
GenLauncherNet/DataClasses/StringHashSet.cs | 19 -
GenLauncherNet/DataClasses/VulkanData.cs | 14 -
GenLauncherNet/DataHandler.cs | 806 -
GenLauncherNet/Dlls/Minio.dll | Bin 305664 -> 0 bytes
GenLauncherNet/Dlls/RestSharp.dll | Bin 185856 -> 0 bytes
GenLauncherNet/Dlls/SevenZipExtractor.dll | Bin 31744 -> 0 bytes
GenLauncherNet/Dlls/SymbolicLinkSupport.dll | Bin 8704 -> 0 bytes
GenLauncherNet/Dlls/System.Reactive.dll | Bin 1211392 -> 0 bytes
GenLauncherNet/Dlls/WPFLocalizeExtension.dll | Bin 90624 -> 0 bytes
GenLauncherNet/Dlls/XAMLMarkupExtensions.dll | Bin 36352 -> 0 bytes
GenLauncherNet/Dlls/YamlDotNet.dll | Bin 222208 -> 0 bytes
.../Dlls/ar/GenLauncher.resources.dll | Bin 15360 -> 0 bytes
.../Dlls/de/GenLauncher.resources.dll | Bin 14336 -> 0 bytes
.../Dlls/es/GenLauncher.resources.dll | Bin 14336 -> 0 bytes
.../Dlls/fr/GenLauncher.resources.dll | Bin 14336 -> 0 bytes
.../Dlls/hr/GenLauncher.resources.dll | Bin 13824 -> 0 bytes
.../Dlls/pt/GenLauncher.resources.dll | Bin 13824 -> 0 bytes
.../Dlls/ru/GenLauncher.resources.dll | Bin 16384 -> 0 bytes
.../Dlls/tr/GenLauncher.resources.dll | Bin 13824 -> 0 bytes
.../Dlls/uk/GenLauncher.resources.dll | Bin 17408 -> 0 bytes
GenLauncherNet/Dlls/x64/7z.dll | Bin 1710080 -> 0 bytes
GenLauncherNet/Dlls/x86/7z.dll | Bin 1167872 -> 0 bytes
.../Dlls/zh/GenLauncher.resources.dll | Bin 12800 -> 0 bytes
GenLauncherNet/EntryPoint.cs | 289 -
GenLauncherNet/FodyWeavers.xml | 3 -
GenLauncherNet/GameLauncher.cs | 353 -
GenLauncherNet/GenLauncher.csproj | 483 -
GenLauncherNet/GenLauncher.csproj.user | 13 -
.../HttpHandlers/ContentDownloader.cs | 170 -
.../HttpHandlers/GitHubMainDataReader.cs | 167 -
.../HttpHandlers/GitHubYamlReader.cs | 88 -
GenLauncherNet/Images/Background.png | Bin 979816 -> 0 bytes
GenLauncherNet/Images/BackgroundGenerals.png | Bin 967995 -> 0 bytes
GenLauncherNet/Images/vulkan.png | Bin 19474 -> 0 bytes
GenLauncherNet/ModificationsFileHandler.cs | 47 -
GenLauncherNet/Options/options.ini | 32 -
GenLauncherNet/Properties/AssemblyInfo.cs | 55 -
.../Properties/Resources.Designer.cs | 63 -
GenLauncherNet/Properties/Resources.resx | 117 -
.../Properties/Settings.Designer.cs | 26 -
GenLauncherNet/Properties/Settings.settings | 7 -
GenLauncherNet/Resources/Strings.Designer.cs | 1314 -
GenLauncherNet/Resources/Strings.ar.resx | 535 -
GenLauncherNet/Resources/Strings.de.resx | 537 -
GenLauncherNet/Resources/Strings.es.resx | 537 -
GenLauncherNet/Resources/Strings.fr.resx | 537 -
GenLauncherNet/Resources/Strings.hr.resx | 535 -
GenLauncherNet/Resources/Strings.pt.resx | 537 -
GenLauncherNet/Resources/Strings.resx | 537 -
.../Resources/Strings.ru.Designer.cs | 0
GenLauncherNet/Resources/Strings.ru.resx | 537 -
GenLauncherNet/Resources/Strings.tr.resx | 537 -
GenLauncherNet/Resources/Strings.uk.resx | 538 -
GenLauncherNet/Resources/Strings.zh.resx | 538 -
GenLauncherNet/S3StorageHandler.cs | 65 -
GenLauncherNet/Updaters/DownloadReadiness.cs | 20 -
GenLauncherNet/Updaters/DownloadResult.cs | 16 -
GenLauncherNet/Updaters/FTPUpdater.cs | 56 -
.../Updaters/HttpSingleFileUpdater.cs | 361 -
GenLauncherNet/Updaters/IUpdater.cs | 21 -
GenLauncherNet/Updaters/IUpdaterFactory.cs | 13 -
GenLauncherNet/Updaters/S3Updater.cs | 456 -
GenLauncherNet/Updaters/UpdaterFactory.cs | 35 -
GenLauncherNet/Utility/BigHandler.cs | 193 -
.../Utility/BlackWhiteImageGenerator.cs | 49 -
GenLauncherNet/Utility/DownloadLinkParser.cs | 48 -
GenLauncherNet/Utility/FilesHandler.cs | 40 -
GenLauncherNet/Utility/GameOptionsHandler.cs | 154 -
GenLauncherNet/Utility/GeneralUtilities.cs | 56 -
GenLauncherNet/Utility/GentoolHandler.cs | 157 -
GenLauncherNet/Utility/LocalizedStrings.cs | 39 -
.../Utility/MD5ChecksumCalculator.cs | 26 -
GenLauncherNet/Utility/SymbolicLinkHandler.cs | 157 -
GenLauncherNet/Utility/TimeUtility.cs | 140 -
GenLauncherNet/Utility/Unpacker.cs | 155 -
GenLauncherNet/Utility/VulkanDllsHandler.cs | 80 -
GenLauncherNet/WPFElements/ChangeLogButton.cs | 15 -
GenLauncherNet/WPFElements/GridControls.cs | 57 -
GenLauncherNet/WPFElements/InfoButton.cs | 13 -
GenLauncherNet/WPFElements/InfoTextBlock.cs | 13 -
GenLauncherNet/WPFElements/NameTextBox.cs | 13 -
.../WPFElements/NetworkInfoButton.cs | 15 -
GenLauncherNet/WPFElements/UpdateButton.cs | 85 -
GenLauncherNet/WPFElements/VersionTextBox.cs | 13 -
.../Windows/AddModificationWindow.xaml | 378 -
.../Windows/AddModificationWindow.xaml.cs | 62 -
GenLauncherNet/Windows/ColorsDictionary.xaml | 20 -
GenLauncherNet/Windows/InfoWindow.xaml | 90 -
GenLauncherNet/Windows/InfoWindow.xaml.cs | 78 -
GenLauncherNet/Windows/InitWindow.xaml | 33 -
GenLauncherNet/Windows/InitWindow.xaml.cs | 345 -
GenLauncherNet/Windows/MainWindow.xaml | 875 -
GenLauncherNet/Windows/MainWindow.xaml.cs | 2608 --
.../Windows/ManualAddMidificationWindow.xaml | 111 -
.../ManualAddMidificationWindow.xaml.cs | 131 -
GenLauncherNet/Windows/OptionsWindow.xaml | 972 -
GenLauncherNet/Windows/OptionsWindow.xaml.cs | 575 -
GenLauncherNet/Windows/UpdateAvailable.xaml | 86 -
.../Windows/UpdateAvailable.xaml.cs | 52 -
GenLauncherNet/Windows/VisualDictionary.xaml | 807 -
GenLauncherNet/app.manifest | 79 -
GenLauncherNet/app1.manifest | 76 -
GenLauncherNet/d3d8.cfg | 15 -
GenLauncherNet/packages.config | 61 -
ModificationContainer.cs | 544 -
README.md | 184 +-
WpfSurface.dxvk-cache | Bin 1464 -> 0 bytes
coverage.runsettings | 22 +
eng/coverage.proj | 24 +
global.json | 7 +
packages/Crc32.NET.1.2.0/.signature.p7s | Bin 9461 -> 0 bytes
.../Crc32.NET.1.2.0/Crc32.NET.1.2.0.nupkg | Bin 26123 -> 0 bytes
.../Crc32.NET.1.2.0/lib/net20/Crc32.NET.dll | Bin 7680 -> 0 bytes
.../Crc32.NET.1.2.0/lib/net20/Crc32.NET.xml | 220 -
.../lib/netstandard1.3/Crc32.NET.dll | Bin 7680 -> 0 bytes
.../lib/netstandard1.3/Crc32.NET.xml | 220 -
.../lib/netstandard2.0/Crc32.NET.dll | Bin 7680 -> 0 bytes
.../lib/netstandard2.0/Crc32.NET.xml | 220 -
packages/Minio.3.1.13/.signature.p7s | Bin 9461 -> 0 bytes
packages/Minio.3.1.13/Minio.3.1.13.nupkg | Bin 422303 -> 0 bytes
packages/Minio.3.1.13/lib/net46/Minio.dll | Bin 305664 -> 0 bytes
packages/Minio.3.1.13/lib/net46/Minio.xml | 1517 -
.../Minio.3.1.13/lib/netstandard2.0/Minio.dll | Bin 305152 -> 0 bytes
.../Minio.3.1.13/lib/netstandard2.0/Minio.xml | 1517 -
packages/RestSharp.106.10.1/.signature.p7s | Bin 9489 -> 0 bytes
.../RestSharp.106.10.1.nupkg | Bin 213774 -> 0 bytes
.../lib/net452/RestSharp.dll | Bin 185856 -> 0 bytes
.../lib/net452/RestSharp.xml | 3722 ---
.../lib/netstandard2.0/RestSharp.dll | Bin 185856 -> 0 bytes
.../lib/netstandard2.0/RestSharp.xml | 3722 ---
.../SymbolicLinkSupport.1.2.0/.signature.p7s | Bin 9472 -> 0 bytes
.../SymbolicLinkSupport.1.2.0.nupkg | Bin 22850 -> 0 bytes
.../lib/net35/SymbolicLinkSupport.dll | Bin 8704 -> 0 bytes
.../lib/net35/SymbolicLinkSupport.xml | 122 -
.../netstandard1.3/SymbolicLinkSupport.dll | Bin 9216 -> 0 bytes
.../netstandard1.3/SymbolicLinkSupport.xml | 122 -
packages/System.Reactive.4.0.0/.signature.p7s | Bin 18549 -> 0 bytes
.../System.Reactive.4.0.0.nupkg | Bin 2300907 -> 0 bytes
.../lib/net46/System.Reactive.dll | Bin 1211392 -> 0 bytes
.../lib/net46/System.Reactive.xml | 26072 ---------------
.../lib/netstandard2.0/System.Reactive.dll | Bin 1196544 -> 0 bytes
.../lib/netstandard2.0/System.Reactive.xml | 25670 ---------------
.../lib/uap10.0.16299/System.Reactive.dll | Bin 1226240 -> 0 bytes
.../lib/uap10.0.16299/System.Reactive.pri | Bin 688 -> 0 bytes
.../lib/uap10.0.16299/System.Reactive.xml | 26234 ----------------
.../lib/uap10.0/System.Reactive.dll | Bin 1375232 -> 0 bytes
.../lib/uap10.0/System.Reactive.pri | Bin 688 -> 0 bytes
.../lib/uap10.0/System.Reactive.xml | 26220 ---------------
.../System.Reactive.Linq.4.0.0/.signature.p7s | Bin 18549 -> 0 bytes
.../System.Reactive.Linq.4.0.0.nupkg | Bin 45179 -> 0 bytes
.../lib/net46/System.Reactive.Linq.dll | Bin 14336 -> 0 bytes
.../lib/net46/System.Reactive.Linq.xml | 8 -
.../netstandard2.0/System.Reactive.Linq.dll | Bin 14336 -> 0 bytes
.../netstandard2.0/System.Reactive.Linq.xml | 8 -
.../lib/uap10.0/System.Reactive.Linq.dll | Bin 14336 -> 0 bytes
.../lib/uap10.0/System.Reactive.Linq.pri | Bin 704 -> 0 bytes
.../lib/uap10.0/System.Reactive.Linq.xml | 8 -
packages/YamlDotNet.11.2.1/.signature.p7s | Bin 9462 -> 0 bytes
packages/YamlDotNet.11.2.1/LICENSE.txt | 19 -
.../YamlDotNet.11.2.1/YamlDotNet.11.2.1.nupkg | Bin 707795 -> 0 bytes
.../YamlDotNet.11.2.1/images/yamldotnet.png | Bin 2669 -> 0 bytes
.../lib/net20/YamlDotNet.dll | Bin 246784 -> 0 bytes
.../lib/net20/YamlDotNet.xml | 4913 ---
.../lib/net35-client/YamlDotNet.dll | Bin 223744 -> 0 bytes
.../lib/net35-client/YamlDotNet.xml | 4929 ---
.../lib/net35/YamlDotNet.dll | Bin 225792 -> 0 bytes
.../lib/net35/YamlDotNet.xml | 4936 ---
.../lib/net45/YamlDotNet.dll | Bin 222208 -> 0 bytes
.../lib/net45/YamlDotNet.xml | 4936 ---
.../lib/netstandard1.3/YamlDotNet.dll | Bin 224256 -> 0 bytes
.../lib/netstandard1.3/YamlDotNet.xml | 4929 ---
.../lib/netstandard2.1/YamlDotNet.dll | Bin 220160 -> 0 bytes
.../lib/netstandard2.1/YamlDotNet.xml | 4793 ---
test.txt | 6 -
648 files changed, 71728 insertions(+), 167437 deletions(-)
create mode 100644 .codex/config.toml
create mode 100644 .config/dotnet-tools.json
create mode 100644 .editorconfig
delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md
delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md
create mode 100644 .github/workflows/ci.yml
create mode 100644 .mcp.json
create mode 100644 AGENTS.md
create mode 100644 Directory.Build.props
create mode 100644 Directory.Build.targets
create mode 100644 Directory.Packages.props
delete mode 100644 GenLauncher.sln
create mode 100644 GenLauncherGO.Core/AGENTS.md
create mode 100644 GenLauncherGO.Core/GenLauncherGO.Core.csproj
create mode 100644 GenLauncherGO.Core/IO/LexicalPath.cs
create mode 100644 GenLauncherGO.Core/Integrity/Models/ContentIntegrityIssue.cs
create mode 100644 GenLauncherGO.Core/Integrity/Models/ContentIntegrityReport.cs
create mode 100644 GenLauncherGO.Core/Integrity/Models/ContentIntegrityTarget.cs
create mode 100644 GenLauncherGO.Core/Integrity/Models/ContentSourceKind.cs
create mode 100644 GenLauncherGO.Core/Integrity/Models/IntegrityIssueAction.cs
create mode 100644 GenLauncherGO.Core/Integrity/Models/IntegrityIssueKind.cs
create mode 100644 GenLauncherGO.Core/Launching/Contracts/IGameExecutableDiscoveryService.cs
create mode 100644 GenLauncherGO.Core/Launching/Contracts/IGameProcessLaunchOperation.cs
create mode 100644 GenLauncherGO.Core/Launching/Contracts/IGameProcessLauncher.cs
create mode 100644 GenLauncherGO.Core/Launching/Contracts/ILaunchContentIntegrityResolutionService.cs
create mode 100644 GenLauncherGO.Core/Launching/Contracts/ILaunchPreparationService.cs
create mode 100644 GenLauncherGO.Core/Launching/LauncherGameArgumentService.cs
create mode 100644 GenLauncherGO.Core/Launching/Models/GameClientExecutable.cs
create mode 100644 GenLauncherGO.Core/Launching/Models/GameClientExecutableKind.cs
create mode 100644 GenLauncherGO.Core/Launching/Models/GameLaunchRequest.cs
create mode 100644 GenLauncherGO.Core/Launching/Models/GameLaunchTargetKind.cs
create mode 100644 GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionProgress.cs
create mode 100644 GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionRequest.cs
create mode 100644 GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetContext.cs
create mode 100644 GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetRequest.cs
create mode 100644 GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVerificationResult.cs
create mode 100644 GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVersionRequest.cs
create mode 100644 GenLauncherGO.Core/Launching/Models/LaunchPreparationRequest.cs
create mode 100644 GenLauncherGO.Core/Launching/Models/WorldBuilderExecutable.cs
create mode 100644 GenLauncherGO.Core/Launching/Models/WorldBuilderExecutableKind.cs
create mode 100644 GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalog.cs
create mode 100644 GenLauncherGO.Core/Mods/Contracts/IManualModificationImporter.cs
create mode 100644 GenLauncherGO.Core/Mods/Contracts/IModificationImageFileService.cs
create mode 100644 GenLauncherGO.Core/Mods/Exceptions/LauncherContentPersistenceException.cs
create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherContent.cs
create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherContentCatalogInitializationRequest.cs
create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherContentInstallation.cs
create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherContentKey.cs
create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherContentVersion.cs
create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherContentVersionComparer.cs
create mode 100644 GenLauncherGO.Core/Mods/Models/LauncherData.cs
create mode 100644 GenLauncherGO.Core/Mods/Models/ModificationImageReplacementRequest.cs
create mode 100644 GenLauncherGO.Core/Mods/Models/ModificationType.cs
create mode 100644 GenLauncherGO.Core/Mods/Models/OwnedContentPath.cs
create mode 100644 GenLauncherGO.Core/Mods/Services/LauncherContentPathResolver.cs
create mode 100644 GenLauncherGO.Core/Remote/IRemoteConnectionProbe.cs
create mode 100644 GenLauncherGO.Core/Settings/Contracts/ILauncherPreferencesService.cs
create mode 100644 GenLauncherGO.Core/Settings/Exceptions/LauncherPreferencesPersistenceException.cs
create mode 100644 GenLauncherGO.Core/Settings/Models/LauncherCustomExecutable.cs
create mode 100644 GenLauncherGO.Core/Settings/Models/LauncherGamePreferences.cs
create mode 100644 GenLauncherGO.Core/Settings/Models/LauncherGamePreferencesSet.cs
create mode 100644 GenLauncherGO.Core/Settings/Models/LauncherInstallations.cs
create mode 100644 GenLauncherGO.Core/Settings/Models/LauncherPreferences.cs
create mode 100644 GenLauncherGO.Core/Settings/Models/LauncherSharedPreferences.cs
create mode 100644 GenLauncherGO.Core/Shell/Contracts/ILauncherShellService.cs
create mode 100644 GenLauncherGO.Core/Startup/Contracts/IGameInstallationService.cs
create mode 100644 GenLauncherGO.Core/Startup/Contracts/ILauncherHostEnvironmentService.cs
create mode 100644 GenLauncherGO.Core/Startup/Contracts/ILauncherSingleInstanceGuard.cs
create mode 100644 GenLauncherGO.Core/Startup/ILauncherPathResolver.cs
create mode 100644 GenLauncherGO.Core/Startup/LauncherFileSystemLayout.cs
create mode 100644 GenLauncherGO.Core/Startup/LauncherPaths.cs
create mode 100644 GenLauncherGO.Core/Startup/LauncherRuntimePathContext.cs
create mode 100644 GenLauncherGO.Core/Startup/LauncherStoragePaths.cs
create mode 100644 GenLauncherGO.Core/Startup/Models/GameInstallationLocation.cs
create mode 100644 GenLauncherGO.Core/Startup/Models/GameInstallationValidationFailure.cs
create mode 100644 GenLauncherGO.Core/Startup/Models/GameInstallationValidationResult.cs
create mode 100644 GenLauncherGO.Core/Startup/Models/LauncherInstallationsValidationResult.cs
create mode 100644 GenLauncherGO.Core/Startup/Models/LauncherRestartResult.cs
create mode 100644 GenLauncherGO.Core/Startup/SupportedGame.cs
create mode 100644 GenLauncherGO.Core/Updating/Contracts/IPackageDownloadService.cs
create mode 100644 GenLauncherGO.Core/Updating/Contracts/IRemotePackageSizeResolver.cs
create mode 100644 GenLauncherGO.Core/Updating/Models/PackageDownloadPauseController.cs
create mode 100644 GenLauncherGO.Core/Updating/Models/PackageDownloadResult.cs
create mode 100644 GenLauncherGO.Core/Updating/Models/PackageDownloadStatus.cs
create mode 100644 GenLauncherGO.Core/Updating/Models/PackageUpdateProgress.cs
create mode 100644 GenLauncherGO.Infrastructure/AGENTS.md
create mode 100644 GenLauncherGO.Infrastructure/Archives/ArchiveExtractor.cs
create mode 100644 GenLauncherGO.Infrastructure/Archives/ArchiveFileSupport.cs
create mode 100644 GenLauncherGO.Infrastructure/Archives/Contracts/IArchiveExtractor.cs
create mode 100644 GenLauncherGO.Infrastructure/Common/BigFileVariantPath.cs
create mode 100644 GenLauncherGO.Infrastructure/Common/FileSystemPathSafety.cs
create mode 100644 GenLauncherGO.Infrastructure/Common/ManifestPathResolver.cs
create mode 100644 GenLauncherGO.Infrastructure/Common/OwnedDirectoryTree.cs
create mode 100644 GenLauncherGO.Infrastructure/Common/PhysicalDirectoryPath.cs
create mode 100644 GenLauncherGO.Infrastructure/GenLauncherGO.Infrastructure.csproj
create mode 100644 GenLauncherGO.Infrastructure/InfrastructureServiceCollectionExtensions.cs
create mode 100644 GenLauncherGO.Infrastructure/Integrity/Contracts/IContentIntegrityService.cs
create mode 100644 GenLauncherGO.Infrastructure/Integrity/Services/FileSystemContentIntegrityService.cs
create mode 100644 GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityPath.cs
create mode 100644 GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityScanner.cs
create mode 100644 GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegritySnapshotStore.cs
create mode 100644 GenLauncherGO.Infrastructure/Launching/Contracts/ILaunchContentIntegrityTargetBuilder.cs
create mode 100644 GenLauncherGO.Infrastructure/Launching/Services/DeploymentLaunchPreparationService.cs
create mode 100644 GenLauncherGO.Infrastructure/Launching/Services/FileSystemDeploymentService.cs
create mode 100644 GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionService.cs
create mode 100644 GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilder.cs
create mode 100644 GenLauncherGO.Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryService.cs
create mode 100644 GenLauncherGO.Infrastructure/Launching/Services/WindowsGameProcessLauncher.cs
create mode 100644 GenLauncherGO.Infrastructure/Launching/Services/WindowsProcessFamilyLauncher.cs
create mode 100644 GenLauncherGO.Infrastructure/Launching/Support/DeploymentFilePlanner.cs
create mode 100644 GenLauncherGO.Infrastructure/Launching/Support/DeploymentPathResolver.cs
create mode 100644 GenLauncherGO.Infrastructure/Launching/Support/DeploymentResult.cs
create mode 100644 GenLauncherGO.Infrastructure/Launching/Support/DeploymentStateStore.cs
create mode 100644 GenLauncherGO.Infrastructure/Launching/Support/IHardLinkCreator.cs
create mode 100644 GenLauncherGO.Infrastructure/Launching/Support/IProcessFamilyLaunchOperation.cs
create mode 100644 GenLauncherGO.Infrastructure/Launching/Support/IProcessFamilyLauncher.cs
create mode 100644 GenLauncherGO.Infrastructure/Launching/Support/WindowsHardLinkCreator.cs
create mode 100644 GenLauncherGO.Infrastructure/Logging/LoggingServiceCollectionExtensions.cs
create mode 100644 GenLauncherGO.Infrastructure/Logging/SensitiveDataRedactingTextFormatter.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherContentStateStore.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Contracts/ILocalLauncherContentService.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/LauncherContentEntryState.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/LauncherContentState.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/LauncherContentVersionState.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/LegacyCatalogAdvertisingReference.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/LegacyCatalogModificationReference.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/LegacyContentManifest.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/LegacyLauncherCatalogDocument.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/RemoteAdvertisingReference.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/RemoteCatalogModificationReference.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/RemoteChildManifestLoadResult.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/RemoteLauncherCatalog.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Models/RemoteModificationManifest.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Services/FileSystemLocalLauncherContentService.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Services/FileSystemManualModificationImporter.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Services/FileSystemModificationImageFileService.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Services/LauncherCatalogImageCache.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Services/LauncherContentCatalogService.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Services/LauncherContentStateMapper.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Services/LauncherLocalContentReconciler.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Services/RemoteLauncherCatalogClient.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Services/YamlLauncherContentStateStore.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Support/ModificationImageCachePath.cs
create mode 100644 GenLauncherGO.Infrastructure/Mods/Support/RemoteLauncherCatalogMapper.cs
create mode 100644 GenLauncherGO.Infrastructure/Persistence/Services/AtomicFileWriter.cs
create mode 100644 GenLauncherGO.Infrastructure/Persistence/Services/IAtomicFileWriter.cs
create mode 100644 GenLauncherGO.Infrastructure/Persistence/Services/IYamlDocumentStore.cs
create mode 100644 GenLauncherGO.Infrastructure/Persistence/Services/YamlDocumentStore.cs
create mode 100644 GenLauncherGO.Infrastructure/Properties/AssemblyInfo.cs
create mode 100644 GenLauncherGO.Infrastructure/Remote/Contracts/IRemoteAssetDownloader.cs
create mode 100644 GenLauncherGO.Infrastructure/Remote/Contracts/IRemoteYamlDocumentReader.cs
create mode 100644 GenLauncherGO.Infrastructure/Remote/HttpRemoteAssetDownloader.cs
create mode 100644 GenLauncherGO.Infrastructure/Remote/HttpRemoteConnectionProbe.cs
create mode 100644 GenLauncherGO.Infrastructure/Remote/HttpRemoteYamlDocumentReader.cs
create mode 100644 GenLauncherGO.Infrastructure/Remote/SharedHttpClientFactory.cs
create mode 100644 GenLauncherGO.Infrastructure/Settings/Composition/SettingsInfrastructureServiceCollectionExtensions.cs
create mode 100644 GenLauncherGO.Infrastructure/Settings/Models/LauncherPreferencesDocument.cs
create mode 100644 GenLauncherGO.Infrastructure/Settings/Services/PreferencesService.cs
create mode 100644 GenLauncherGO.Infrastructure/Settings/Support/LauncherPreferencesDocumentMapper.cs
create mode 100644 GenLauncherGO.Infrastructure/Shell/Services/WindowsLauncherShellService.cs
create mode 100644 GenLauncherGO.Infrastructure/Startup/FileSystemLauncherPathResolver.cs
create mode 100644 GenLauncherGO.Infrastructure/Startup/IGameInstallationRegistry.cs
create mode 100644 GenLauncherGO.Infrastructure/Startup/WindowsGameInstallationRegistry.cs
create mode 100644 GenLauncherGO.Infrastructure/Startup/WindowsGameInstallationService.cs
create mode 100644 GenLauncherGO.Infrastructure/Startup/WindowsLauncherHostEnvironmentService.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Clients/HttpDownloadFileMetadataReader.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Clients/MinioClientFactory.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Clients/MinioS3ObjectManifestReader.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Clients/ResumableHttpFileDownloader.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Contracts/IDownloadFileMetadataReader.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Contracts/IFileHashService.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Contracts/IResumableFileDownloader.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Contracts/IS3ObjectManifestReader.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Contracts/IS3PackageUpdater.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Contracts/ISingleFilePackageUpdater.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Models/DownloadFileMetadata.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Models/DownloadFileRequest.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Models/DownloadProgress.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Models/PackageUpdatePathSet.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Models/RemoteFileManifestEntry.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Models/S3ObjectManifestRequest.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Models/S3PackageFileRepairRequest.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Models/S3PackageUpdateRequest.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Services/Md5FileHashService.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Services/PackageDownloadService.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Services/RemotePackageSizeResolver.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Services/S3PackageUpdater.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Services/SingleFilePackageUpdater.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Support/DownloadLinkResolver.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Support/InlineProgress.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Support/MonotonicPackageProgress.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Support/PackageInstallFolderReplacer.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Support/PackageProgressTracker.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Support/PackageStagingFolderCleaner.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Support/S3CatalogDefaults.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Support/S3HashValidationPolicy.cs
create mode 100644 GenLauncherGO.Infrastructure/Updating/Support/S3ReusablePackageFileCopier.cs
create mode 100644 GenLauncherGO.Tests/AGENTS.md
create mode 100644 GenLauncherGO.Tests/Core/IO/LexicalPathTests.cs
create mode 100644 GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityReportTests.cs
create mode 100644 GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityTargetTests.cs
create mode 100644 GenLauncherGO.Tests/Core/Launching/LauncherGameArgumentServiceTests.cs
create mode 100644 GenLauncherGO.Tests/Core/Mods/Models/LauncherContentKeyTests.cs
create mode 100644 GenLauncherGO.Tests/Core/Mods/Models/LauncherContentTests.cs
create mode 100644 GenLauncherGO.Tests/Core/Mods/Models/LauncherContentVersionTests.cs
create mode 100644 GenLauncherGO.Tests/Core/Mods/Models/LauncherDataTests.cs
create mode 100644 GenLauncherGO.Tests/Core/Mods/Models/OwnedContentPathTests.cs
create mode 100644 GenLauncherGO.Tests/Core/Mods/Services/LauncherContentPathResolverTests.cs
create mode 100644 GenLauncherGO.Tests/Core/Settings/Models/LauncherPreferencesTests.cs
create mode 100644 GenLauncherGO.Tests/Core/Startup/GameInstallationServiceExtensionsTests.cs
create mode 100644 GenLauncherGO.Tests/Core/Startup/LauncherPathsTests.cs
create mode 100644 GenLauncherGO.Tests/Core/Startup/LauncherRuntimePathContextTests.cs
create mode 100644 GenLauncherGO.Tests/Core/Startup/LauncherStoragePathsTests.cs
create mode 100644 GenLauncherGO.Tests/GenLauncherGO.Tests.csproj
create mode 100644 GenLauncherGO.Tests/GlobalUsings.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/ArchiveExtractorTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Common/BigFileVariantPathTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Common/FileSystemPathSafetyTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Common/ManifestPathResolverTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Common/OwnedDirectoryTreeTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Integrity/Services/FileSystemContentIntegrityServiceTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Launching/Services/DeploymentLaunchPreparationServiceTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemDeploymentServiceTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionServiceTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilderTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryServiceTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameProcessLauncherTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsProcessFamilyLauncherTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Launching/Support/DeploymentFilePlannerTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Launching/Support/DeploymentPathResolverTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Launching/Support/WindowsHardLinkCreatorTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/LoggingServiceCollectionExtensionsTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemLocalLauncherContentServiceTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemManualModificationImporterTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationImageFileServiceTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherCatalogImageCacheTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentCatalogServiceTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentStateMapperTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherLocalContentReconcilerTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/RemoteLauncherCatalogClientTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Services/YamlLauncherContentStateStoreTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Mods/Support/RemoteLauncherCatalogMapperTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Persistence/Services/YamlDocumentStoreTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteAssetDownloaderTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteConnectionProbeTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteYamlDocumentReaderTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Settings/Services/PreferencesServiceTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Shell/Services/WindowsLauncherShellServiceTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Startup/FileSystemLauncherPathResolverTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Startup/WindowsGameInstallationRegistryTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Startup/WindowsGameInstallationServiceTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Startup/WindowsLauncherHostEnvironmentServiceTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Clients/HttpDownloadFileMetadataReaderTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioClientFactoryTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioS3ObjectManifestReaderTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Clients/ResumableHttpFileDownloaderTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Models/S3RequestDefaultsTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Services/Md5FileHashServiceTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Services/PackageDownloadServiceTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Services/RemotePackageSizeResolverTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Services/S3PackageUpdaterBehaviorTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Services/SingleFilePackageUpdaterTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Support/DownloadLinkResolverTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageInstallFolderReplacerTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageProgressTrackerTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageStagingFolderCleanerTests.cs
create mode 100644 GenLauncherGO.Tests/Infrastructure/Updating/Support/S3HashValidationPolicyTests.cs
create mode 100644 GenLauncherGO.Tests/Testing/FakeLauncherContentCatalog.cs
create mode 100644 GenLauncherGO.Tests/Testing/ManualTimeProvider.cs
create mode 100644 GenLauncherGO.Tests/Testing/QueueHttpMessageHandler.cs
create mode 100644 GenLauncherGO.Tests/Testing/RecordingAtomicFileWriter.cs
create mode 100644 GenLauncherGO.Tests/Testing/RecordingLocalLauncherContentService.cs
create mode 100644 GenLauncherGO.Tests/Testing/RecordingRemoteAssetDownloader.cs
create mode 100644 GenLauncherGO.Tests/Testing/StaTestRunner.cs
create mode 100644 GenLauncherGO.Tests/Testing/StaTestRunnerTests.cs
create mode 100644 GenLauncherGO.Tests/Testing/StubLauncherContentStateStore.cs
create mode 100644 GenLauncherGO.Tests/Testing/StubRemoteYamlDocumentReader.cs
create mode 100644 GenLauncherGO.Tests/Testing/SymbolicLinkFactAttribute.cs
create mode 100644 GenLauncherGO.Tests/Testing/SymbolicLinkTestSupport.cs
create mode 100644 GenLauncherGO.Tests/Testing/TestDirectory.cs
create mode 100644 GenLauncherGO.Tests/Testing/TestLauncherLaunchCoordinator.cs
create mode 100644 GenLauncherGO.Tests/Testing/TestLauncherPaths.cs
create mode 100644 GenLauncherGO.Tests/Testing/TestLauncherRuntimeContext.cs
create mode 100644 GenLauncherGO.Tests/Testing/TestLauncherTheme.cs
create mode 100644 GenLauncherGO.Tests/Testing/TestStringLocalizer.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Dialogs/Models/ManualModificationDialogRequestTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Dialogs/Services/AvaloniaLauncherDialogServiceTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Integrity/LaunchContentIntegrityCoordinatorTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Integrity/LauncherPackageActivityServiceTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Integrity/ViewModels/IntegrityReviewViewModelTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherCloseGuardTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherExecutableSelectionServiceTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherGameSessionCoordinatorTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherLaunchCoordinatorTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherLaunchReadinessCoordinatorTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherManualImportCoordinatorTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherModificationDownloadCoordinatorTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherTileActionServiceTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherWindowWorkflowCoordinatorTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Support/LauncherDragDropControllerTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/Support/LauncherWindowListControllerTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Launcher/ViewModels/MainWindowViewModelTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Mods/ModificationImageSourceFactoryTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Mods/ModificationViewModelTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Mods/ViewModels/AddModificationViewModelTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Mods/ViewModels/ModificationTileImageProviderTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Mods/ViewModels/ModsDialogViewModelTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Settings/LauncherExecutableManagementViewModelTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Settings/LauncherSettingsViewModelTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Settings/LauncherSettingsWindowTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Startup/LauncherApplicationCompositionTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Startup/LauncherApplicationHostTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Startup/LauncherElevationManifestTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Startup/LauncherStartupCultureTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Startup/ViewModels/InitWindowViewModelTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Features/Startup/ViewModels/StandaloneStartupViewModelTests.cs
create mode 100644 GenLauncherGO.Tests/UI/LauncherThemeRenderedStateTests.cs
create mode 100644 GenLauncherGO.Tests/UI/NativeAxamlSmokeTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Shared/Errors/AvaloniaUiExceptionBoundaryTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Shared/Formatting/ByteSizeFormatterTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Shared/Formatting/PackageProgressTextFormatterTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Shared/Localization/AvaloniaLauncherStringLocalizerTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Shared/Localization/LocalizationResourceParityTests.cs
create mode 100644 GenLauncherGO.Tests/UI/Shared/Themes/LauncherThemeResourceApplierTests.cs
create mode 100644 GenLauncherGO.UI/AGENTS.md
create mode 100644 GenLauncherGO.UI/App.axaml
create mode 100644 GenLauncherGO.UI/Features/Dialogs/Contracts/ILauncherDialogService.cs
create mode 100644 GenLauncherGO.UI/Features/Dialogs/Models/LauncherInfoDialogRequest.cs
create mode 100644 GenLauncherGO.UI/Features/Dialogs/Models/ManualModificationDialogRequest.cs
create mode 100644 GenLauncherGO.UI/Features/Dialogs/Models/ManualModificationDialogResult.cs
create mode 100644 GenLauncherGO.UI/Features/Dialogs/Services/AvaloniaLauncherDialogService.cs
create mode 100644 GenLauncherGO.UI/Features/Integrity/ILaunchContentIntegrityProgressTarget.cs
create mode 100644 GenLauncherGO.UI/Features/Integrity/IntegrityReviewDialog.axaml
create mode 100644 GenLauncherGO.UI/Features/Integrity/IntegrityReviewDialog.axaml.cs
create mode 100644 GenLauncherGO.UI/Features/Integrity/LaunchContentIntegrityCoordinator.cs
create mode 100644 GenLauncherGO.UI/Features/Integrity/LauncherPackageActivityService.cs
create mode 100644 GenLauncherGO.UI/Features/Integrity/ViewModels/IntegrityReviewViewModel.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Contracts/ILauncherFilePicker.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/ExecutableOption.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherCloseReason.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherContentViewKind.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherLaunchFailureKind.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherLaunchRequest.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherLaunchResult.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherManualImportRequest.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherManualImportResult.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherTaskbarProgressState.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Models/LauncherTileLinkAction.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/AvaloniaLauncherFilePicker.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherCloseGuard.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherExecutableSelectionService.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherGameSessionCoordinator.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherLaunchCoordinator.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherLaunchReadinessCoordinator.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherManualImportCoordinator.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherModificationDownloadCoordinator.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherTileActionService.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/LauncherWindowWorkflowCoordinator.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Services/WindowsTaskbarProgress.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Support/LauncherDragDropController.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Support/LauncherWindowListController.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/ViewModels/MainWindowViewModel.cs
create mode 100644 GenLauncherGO.UI/Features/Launcher/Views/MainWindow.axaml
create mode 100644 GenLauncherGO.UI/Features/Launcher/Views/MainWindow.axaml.cs
create mode 100644 GenLauncherGO.UI/Features/Mods/ModificationImageSourceFactory.cs
create mode 100644 GenLauncherGO.UI/Features/Mods/ModificationVersionSelection.cs
create mode 100644 GenLauncherGO.UI/Features/Mods/ModificationViewModel.cs
rename GenLauncherNet/Images/uamG.jpg => GenLauncherGO.UI/Features/Mods/Resources/UserAddedModBannerGenerals.jpg (100%)
rename GenLauncherNet/Images/uamZH.jpg => GenLauncherGO.UI/Features/Mods/Resources/UserAddedModBannerZeroHour.jpg (100%)
create mode 100644 GenLauncherGO.UI/Features/Mods/ViewModels/AddModificationItemViewModel.cs
create mode 100644 GenLauncherGO.UI/Features/Mods/ViewModels/AddModificationViewModel.cs
create mode 100644 GenLauncherGO.UI/Features/Mods/ViewModels/InfoDialogKind.cs
create mode 100644 GenLauncherGO.UI/Features/Mods/ViewModels/InfoDialogViewModel.cs
create mode 100644 GenLauncherGO.UI/Features/Mods/ViewModels/ManualAddModificationViewModel.cs
create mode 100644 GenLauncherGO.UI/Features/Mods/ViewModels/ModificationTileImageProvider.cs
create mode 100644 GenLauncherGO.UI/Features/Mods/Views/AddModificationWindow.axaml
create mode 100644 GenLauncherGO.UI/Features/Mods/Views/AddModificationWindow.axaml.cs
create mode 100644 GenLauncherGO.UI/Features/Mods/Views/InfoWindow.axaml
create mode 100644 GenLauncherGO.UI/Features/Mods/Views/InfoWindow.axaml.cs
create mode 100644 GenLauncherGO.UI/Features/Mods/Views/ManualAddModificationWindow.axaml
create mode 100644 GenLauncherGO.UI/Features/Mods/Views/ManualAddModificationWindow.axaml.cs
create mode 100644 GenLauncherGO.UI/Features/Settings/Models/LauncherExecutableManagementKind.cs
create mode 100644 GenLauncherGO.UI/Features/Settings/ViewModels/LauncherExecutableManagementViewModel.cs
create mode 100644 GenLauncherGO.UI/Features/Settings/ViewModels/LauncherSettingsViewModel.cs
create mode 100644 GenLauncherGO.UI/Features/Settings/Views/LauncherExecutableEditorWindow.axaml
create mode 100644 GenLauncherGO.UI/Features/Settings/Views/LauncherExecutableEditorWindow.axaml.cs
create mode 100644 GenLauncherGO.UI/Features/Settings/Views/LauncherExecutableManagementWindow.axaml
create mode 100644 GenLauncherGO.UI/Features/Settings/Views/LauncherExecutableManagementWindow.axaml.cs
create mode 100644 GenLauncherGO.UI/Features/Settings/Views/LauncherSettingsWindow.axaml
create mode 100644 GenLauncherGO.UI/Features/Settings/Views/LauncherSettingsWindow.axaml.cs
create mode 100644 GenLauncherGO.UI/Features/Startup/Contracts/IStandaloneStartupWorkflow.cs
create mode 100644 GenLauncherGO.UI/Features/Startup/Contracts/IStartupDialogService.cs
create mode 100644 GenLauncherGO.UI/Features/Startup/EntryPoint.cs
create mode 100644 GenLauncherGO.UI/Features/Startup/LauncherApplicationDefaults.cs
create mode 100644 GenLauncherGO.UI/Features/Startup/LauncherApplicationHost.cs
create mode 100644 GenLauncherGO.UI/Features/Startup/LauncherAvaloniaApplication.cs
create mode 100644 GenLauncherGO.UI/Features/Startup/LauncherRuntimeContext.cs
create mode 100644 GenLauncherGO.UI/Features/Startup/LauncherUiServiceCollectionExtensions.cs
create mode 100644 GenLauncherGO.UI/Features/Startup/Models/StandaloneStartupResult.cs
create mode 100644 GenLauncherGO.UI/Features/Startup/Services/AvaloniaStandaloneStartupWorkflow.cs
create mode 100644 GenLauncherGO.UI/Features/Startup/Services/AvaloniaStartupDialogService.cs
create mode 100644 GenLauncherGO.UI/Features/Startup/Services/LauncherRestartCoordinator.cs
create mode 100644 GenLauncherGO.UI/Features/Startup/Services/LauncherStartupCulture.cs
create mode 100644 GenLauncherGO.UI/Features/Startup/ViewModels/InitWindowStartupCompletedEventArgs.cs
create mode 100644 GenLauncherGO.UI/Features/Startup/ViewModels/InitWindowViewModel.cs
create mode 100644 GenLauncherGO.UI/Features/Startup/ViewModels/LauncherGameSelectionViewModel.cs
create mode 100644 GenLauncherGO.UI/Features/Startup/ViewModels/LauncherInstallationsViewModel.cs
create mode 100644 GenLauncherGO.UI/Features/Startup/ViewModels/LauncherSetupViewModel.cs
create mode 100644 GenLauncherGO.UI/Features/Startup/Views/InitWindow.axaml
create mode 100644 GenLauncherGO.UI/Features/Startup/Views/InitWindow.axaml.cs
create mode 100644 GenLauncherGO.UI/Features/Startup/Views/LauncherGameSelectionWindow.axaml
create mode 100644 GenLauncherGO.UI/Features/Startup/Views/LauncherGameSelectionWindow.axaml.cs
create mode 100644 GenLauncherGO.UI/Features/Startup/Views/LauncherLocationWarningWindow.axaml
create mode 100644 GenLauncherGO.UI/Features/Startup/Views/LauncherLocationWarningWindow.axaml.cs
create mode 100644 GenLauncherGO.UI/Features/Startup/Views/LauncherSetupWindow.axaml
create mode 100644 GenLauncherGO.UI/Features/Startup/Views/LauncherSetupWindow.axaml.cs
create mode 100644 GenLauncherGO.UI/GenLauncherGO.UI.csproj
create mode 100644 GenLauncherGO.UI/Properties/AssemblyInfo.cs
create mode 100644 GenLauncherGO.UI/Properties/PublishProfiles/WinX64SelfContained.pubxml
create mode 100644 GenLauncherGO.UI/Resources/Strings.ar.resx
create mode 100644 GenLauncherGO.UI/Resources/Strings.cs
create mode 100644 GenLauncherGO.UI/Resources/Strings.de.resx
create mode 100644 GenLauncherGO.UI/Resources/Strings.es.resx
create mode 100644 GenLauncherGO.UI/Resources/Strings.fr.resx
create mode 100644 GenLauncherGO.UI/Resources/Strings.hr.resx
create mode 100644 GenLauncherGO.UI/Resources/Strings.pt.resx
create mode 100644 GenLauncherGO.UI/Resources/Strings.resx
create mode 100644 GenLauncherGO.UI/Resources/Strings.ru.resx
create mode 100644 GenLauncherGO.UI/Resources/Strings.tr.resx
create mode 100644 GenLauncherGO.UI/Resources/Strings.uk.resx
create mode 100644 GenLauncherGO.UI/Resources/Strings.zh.resx
create mode 100644 GenLauncherGO.UI/Shared/Controls/LauncherLoadingIndicator.axaml
create mode 100644 GenLauncherGO.UI/Shared/Controls/LauncherLoadingIndicator.axaml.cs
create mode 100644 GenLauncherGO.UI/Shared/Controls/LauncherTextBoxFeedback.cs
create mode 100644 GenLauncherGO.UI/Shared/Controls/UpdateButton.cs
create mode 100644 GenLauncherGO.UI/Shared/Dialogs/AvaloniaDialog.cs
create mode 100644 GenLauncherGO.UI/Shared/Errors/AvaloniaUiExceptionBoundary.cs
create mode 100644 GenLauncherGO.UI/Shared/Errors/IUiExceptionBoundary.cs
create mode 100644 GenLauncherGO.UI/Shared/Errors/UiOperationOutcome.cs
create mode 100644 GenLauncherGO.UI/Shared/Formatting/ByteSizeFormatter.cs
create mode 100644 GenLauncherGO.UI/Shared/Formatting/PackageProgressTextFormatter.cs
create mode 100644 GenLauncherGO.UI/Shared/Localization/AvaloniaLauncherStringLocalizer.cs
create mode 100644 GenLauncherGO.UI/Shared/Localization/ILauncherStringLocalizer.cs
create mode 100644 GenLauncherGO.UI/Shared/Localization/LocExtension.cs
rename GenLauncherNet/fd.ico => GenLauncherGO.UI/Shared/Resources/Icons/GenLauncherGo.ico (100%)
create mode 100644 GenLauncherGO.UI/Shared/Resources/Images/LauncherBackgroundGenerals.png
create mode 100644 GenLauncherGO.UI/Shared/Resources/Images/LauncherBackgroundZeroHour.png
rename GenLauncherNet/Images/gl01.png => GenLauncherGO.UI/Shared/Resources/Images/LauncherEmblemCompact.png (100%)
rename GenLauncherNet/Images/gl02.png => GenLauncherGO.UI/Shared/Resources/Images/LauncherEmblemFramed.png (100%)
create mode 100644 GenLauncherGO.UI/Shared/Resources/Images/OfficialCoverGenerals.png
create mode 100644 GenLauncherGO.UI/Shared/Resources/Images/OfficialCoverZeroHour.jpg
create mode 100644 GenLauncherGO.UI/Shared/Themes/ColorsInfo.cs
create mode 100644 GenLauncherGO.UI/Shared/Themes/GenLauncherStyles.axaml
create mode 100644 GenLauncherGO.UI/Shared/Themes/LauncherThemePresets.cs
create mode 100644 GenLauncherGO.UI/Shared/Themes/LauncherThemeResourceApplier.cs
create mode 100644 GenLauncherGO.UI/app.manifest
create mode 100644 GenLauncherGO.sln
create mode 100644 GenLauncherGO.slnLaunch
delete mode 100644 GenLauncherNet/App.config
delete mode 100644 GenLauncherNet/App.xaml
delete mode 100644 GenLauncherNet/App.xaml.cs
delete mode 100644 GenLauncherNet/Background.png
delete mode 100644 GenLauncherNet/DataClasses/ColorsInfo.cs
delete mode 100644 GenLauncherNet/DataClasses/ComboBoxData.cs
delete mode 100644 GenLauncherNet/DataClasses/GameModification.cs
delete mode 100644 GenLauncherNet/DataClasses/LauncherData.cs
delete mode 100644 GenLauncherNet/DataClasses/ModificationFileInfo.cs
delete mode 100644 GenLauncherNet/DataClasses/ModificationVersion.cs
delete mode 100644 GenLauncherNet/DataClasses/ModificationViewModel.cs
delete mode 100644 GenLauncherNet/DataClasses/ReposModificationsVersion.cs
delete mode 100644 GenLauncherNet/DataClasses/ReposModsData.cs
delete mode 100644 GenLauncherNet/DataClasses/SessionInformation.cs
delete mode 100644 GenLauncherNet/DataClasses/StringConcurrentDictionary.cs
delete mode 100644 GenLauncherNet/DataClasses/StringHashSet.cs
delete mode 100644 GenLauncherNet/DataClasses/VulkanData.cs
delete mode 100644 GenLauncherNet/DataHandler.cs
delete mode 100644 GenLauncherNet/Dlls/Minio.dll
delete mode 100644 GenLauncherNet/Dlls/RestSharp.dll
delete mode 100644 GenLauncherNet/Dlls/SevenZipExtractor.dll
delete mode 100644 GenLauncherNet/Dlls/SymbolicLinkSupport.dll
delete mode 100644 GenLauncherNet/Dlls/System.Reactive.dll
delete mode 100644 GenLauncherNet/Dlls/WPFLocalizeExtension.dll
delete mode 100644 GenLauncherNet/Dlls/XAMLMarkupExtensions.dll
delete mode 100644 GenLauncherNet/Dlls/YamlDotNet.dll
delete mode 100644 GenLauncherNet/Dlls/ar/GenLauncher.resources.dll
delete mode 100644 GenLauncherNet/Dlls/de/GenLauncher.resources.dll
delete mode 100644 GenLauncherNet/Dlls/es/GenLauncher.resources.dll
delete mode 100644 GenLauncherNet/Dlls/fr/GenLauncher.resources.dll
delete mode 100644 GenLauncherNet/Dlls/hr/GenLauncher.resources.dll
delete mode 100644 GenLauncherNet/Dlls/pt/GenLauncher.resources.dll
delete mode 100644 GenLauncherNet/Dlls/ru/GenLauncher.resources.dll
delete mode 100644 GenLauncherNet/Dlls/tr/GenLauncher.resources.dll
delete mode 100644 GenLauncherNet/Dlls/uk/GenLauncher.resources.dll
delete mode 100644 GenLauncherNet/Dlls/x64/7z.dll
delete mode 100644 GenLauncherNet/Dlls/x86/7z.dll
delete mode 100644 GenLauncherNet/Dlls/zh/GenLauncher.resources.dll
delete mode 100644 GenLauncherNet/EntryPoint.cs
delete mode 100644 GenLauncherNet/FodyWeavers.xml
delete mode 100644 GenLauncherNet/GameLauncher.cs
delete mode 100644 GenLauncherNet/GenLauncher.csproj
delete mode 100644 GenLauncherNet/GenLauncher.csproj.user
delete mode 100644 GenLauncherNet/HttpHandlers/ContentDownloader.cs
delete mode 100644 GenLauncherNet/HttpHandlers/GitHubMainDataReader.cs
delete mode 100644 GenLauncherNet/HttpHandlers/GitHubYamlReader.cs
delete mode 100644 GenLauncherNet/Images/Background.png
delete mode 100644 GenLauncherNet/Images/BackgroundGenerals.png
delete mode 100644 GenLauncherNet/Images/vulkan.png
delete mode 100644 GenLauncherNet/ModificationsFileHandler.cs
delete mode 100644 GenLauncherNet/Options/options.ini
delete mode 100644 GenLauncherNet/Properties/AssemblyInfo.cs
delete mode 100644 GenLauncherNet/Properties/Resources.Designer.cs
delete mode 100644 GenLauncherNet/Properties/Resources.resx
delete mode 100644 GenLauncherNet/Properties/Settings.Designer.cs
delete mode 100644 GenLauncherNet/Properties/Settings.settings
delete mode 100644 GenLauncherNet/Resources/Strings.Designer.cs
delete mode 100644 GenLauncherNet/Resources/Strings.ar.resx
delete mode 100644 GenLauncherNet/Resources/Strings.de.resx
delete mode 100644 GenLauncherNet/Resources/Strings.es.resx
delete mode 100644 GenLauncherNet/Resources/Strings.fr.resx
delete mode 100644 GenLauncherNet/Resources/Strings.hr.resx
delete mode 100644 GenLauncherNet/Resources/Strings.pt.resx
delete mode 100644 GenLauncherNet/Resources/Strings.resx
delete mode 100644 GenLauncherNet/Resources/Strings.ru.Designer.cs
delete mode 100644 GenLauncherNet/Resources/Strings.ru.resx
delete mode 100644 GenLauncherNet/Resources/Strings.tr.resx
delete mode 100644 GenLauncherNet/Resources/Strings.uk.resx
delete mode 100644 GenLauncherNet/Resources/Strings.zh.resx
delete mode 100644 GenLauncherNet/S3StorageHandler.cs
delete mode 100644 GenLauncherNet/Updaters/DownloadReadiness.cs
delete mode 100644 GenLauncherNet/Updaters/DownloadResult.cs
delete mode 100644 GenLauncherNet/Updaters/FTPUpdater.cs
delete mode 100644 GenLauncherNet/Updaters/HttpSingleFileUpdater.cs
delete mode 100644 GenLauncherNet/Updaters/IUpdater.cs
delete mode 100644 GenLauncherNet/Updaters/IUpdaterFactory.cs
delete mode 100644 GenLauncherNet/Updaters/S3Updater.cs
delete mode 100644 GenLauncherNet/Updaters/UpdaterFactory.cs
delete mode 100644 GenLauncherNet/Utility/BigHandler.cs
delete mode 100644 GenLauncherNet/Utility/BlackWhiteImageGenerator.cs
delete mode 100644 GenLauncherNet/Utility/DownloadLinkParser.cs
delete mode 100644 GenLauncherNet/Utility/FilesHandler.cs
delete mode 100644 GenLauncherNet/Utility/GameOptionsHandler.cs
delete mode 100644 GenLauncherNet/Utility/GeneralUtilities.cs
delete mode 100644 GenLauncherNet/Utility/GentoolHandler.cs
delete mode 100644 GenLauncherNet/Utility/LocalizedStrings.cs
delete mode 100644 GenLauncherNet/Utility/MD5ChecksumCalculator.cs
delete mode 100644 GenLauncherNet/Utility/SymbolicLinkHandler.cs
delete mode 100644 GenLauncherNet/Utility/TimeUtility.cs
delete mode 100644 GenLauncherNet/Utility/Unpacker.cs
delete mode 100644 GenLauncherNet/Utility/VulkanDllsHandler.cs
delete mode 100644 GenLauncherNet/WPFElements/ChangeLogButton.cs
delete mode 100644 GenLauncherNet/WPFElements/GridControls.cs
delete mode 100644 GenLauncherNet/WPFElements/InfoButton.cs
delete mode 100644 GenLauncherNet/WPFElements/InfoTextBlock.cs
delete mode 100644 GenLauncherNet/WPFElements/NameTextBox.cs
delete mode 100644 GenLauncherNet/WPFElements/NetworkInfoButton.cs
delete mode 100644 GenLauncherNet/WPFElements/UpdateButton.cs
delete mode 100644 GenLauncherNet/WPFElements/VersionTextBox.cs
delete mode 100644 GenLauncherNet/Windows/AddModificationWindow.xaml
delete mode 100644 GenLauncherNet/Windows/AddModificationWindow.xaml.cs
delete mode 100644 GenLauncherNet/Windows/ColorsDictionary.xaml
delete mode 100644 GenLauncherNet/Windows/InfoWindow.xaml
delete mode 100644 GenLauncherNet/Windows/InfoWindow.xaml.cs
delete mode 100644 GenLauncherNet/Windows/InitWindow.xaml
delete mode 100644 GenLauncherNet/Windows/InitWindow.xaml.cs
delete mode 100644 GenLauncherNet/Windows/MainWindow.xaml
delete mode 100644 GenLauncherNet/Windows/MainWindow.xaml.cs
delete mode 100644 GenLauncherNet/Windows/ManualAddMidificationWindow.xaml
delete mode 100644 GenLauncherNet/Windows/ManualAddMidificationWindow.xaml.cs
delete mode 100644 GenLauncherNet/Windows/OptionsWindow.xaml
delete mode 100644 GenLauncherNet/Windows/OptionsWindow.xaml.cs
delete mode 100644 GenLauncherNet/Windows/UpdateAvailable.xaml
delete mode 100644 GenLauncherNet/Windows/UpdateAvailable.xaml.cs
delete mode 100644 GenLauncherNet/Windows/VisualDictionary.xaml
delete mode 100644 GenLauncherNet/app.manifest
delete mode 100644 GenLauncherNet/app1.manifest
delete mode 100644 GenLauncherNet/d3d8.cfg
delete mode 100644 GenLauncherNet/packages.config
delete mode 100644 ModificationContainer.cs
delete mode 100644 WpfSurface.dxvk-cache
create mode 100644 coverage.runsettings
create mode 100644 eng/coverage.proj
create mode 100644 global.json
delete mode 100644 packages/Crc32.NET.1.2.0/.signature.p7s
delete mode 100644 packages/Crc32.NET.1.2.0/Crc32.NET.1.2.0.nupkg
delete mode 100644 packages/Crc32.NET.1.2.0/lib/net20/Crc32.NET.dll
delete mode 100644 packages/Crc32.NET.1.2.0/lib/net20/Crc32.NET.xml
delete mode 100644 packages/Crc32.NET.1.2.0/lib/netstandard1.3/Crc32.NET.dll
delete mode 100644 packages/Crc32.NET.1.2.0/lib/netstandard1.3/Crc32.NET.xml
delete mode 100644 packages/Crc32.NET.1.2.0/lib/netstandard2.0/Crc32.NET.dll
delete mode 100644 packages/Crc32.NET.1.2.0/lib/netstandard2.0/Crc32.NET.xml
delete mode 100644 packages/Minio.3.1.13/.signature.p7s
delete mode 100644 packages/Minio.3.1.13/Minio.3.1.13.nupkg
delete mode 100644 packages/Minio.3.1.13/lib/net46/Minio.dll
delete mode 100644 packages/Minio.3.1.13/lib/net46/Minio.xml
delete mode 100644 packages/Minio.3.1.13/lib/netstandard2.0/Minio.dll
delete mode 100644 packages/Minio.3.1.13/lib/netstandard2.0/Minio.xml
delete mode 100644 packages/RestSharp.106.10.1/.signature.p7s
delete mode 100644 packages/RestSharp.106.10.1/RestSharp.106.10.1.nupkg
delete mode 100644 packages/RestSharp.106.10.1/lib/net452/RestSharp.dll
delete mode 100644 packages/RestSharp.106.10.1/lib/net452/RestSharp.xml
delete mode 100644 packages/RestSharp.106.10.1/lib/netstandard2.0/RestSharp.dll
delete mode 100644 packages/RestSharp.106.10.1/lib/netstandard2.0/RestSharp.xml
delete mode 100644 packages/SymbolicLinkSupport.1.2.0/.signature.p7s
delete mode 100644 packages/SymbolicLinkSupport.1.2.0/SymbolicLinkSupport.1.2.0.nupkg
delete mode 100644 packages/SymbolicLinkSupport.1.2.0/lib/net35/SymbolicLinkSupport.dll
delete mode 100644 packages/SymbolicLinkSupport.1.2.0/lib/net35/SymbolicLinkSupport.xml
delete mode 100644 packages/SymbolicLinkSupport.1.2.0/lib/netstandard1.3/SymbolicLinkSupport.dll
delete mode 100644 packages/SymbolicLinkSupport.1.2.0/lib/netstandard1.3/SymbolicLinkSupport.xml
delete mode 100644 packages/System.Reactive.4.0.0/.signature.p7s
delete mode 100644 packages/System.Reactive.4.0.0/System.Reactive.4.0.0.nupkg
delete mode 100644 packages/System.Reactive.4.0.0/lib/net46/System.Reactive.dll
delete mode 100644 packages/System.Reactive.4.0.0/lib/net46/System.Reactive.xml
delete mode 100644 packages/System.Reactive.4.0.0/lib/netstandard2.0/System.Reactive.dll
delete mode 100644 packages/System.Reactive.4.0.0/lib/netstandard2.0/System.Reactive.xml
delete mode 100644 packages/System.Reactive.4.0.0/lib/uap10.0.16299/System.Reactive.dll
delete mode 100644 packages/System.Reactive.4.0.0/lib/uap10.0.16299/System.Reactive.pri
delete mode 100644 packages/System.Reactive.4.0.0/lib/uap10.0.16299/System.Reactive.xml
delete mode 100644 packages/System.Reactive.4.0.0/lib/uap10.0/System.Reactive.dll
delete mode 100644 packages/System.Reactive.4.0.0/lib/uap10.0/System.Reactive.pri
delete mode 100644 packages/System.Reactive.4.0.0/lib/uap10.0/System.Reactive.xml
delete mode 100644 packages/System.Reactive.Linq.4.0.0/.signature.p7s
delete mode 100644 packages/System.Reactive.Linq.4.0.0/System.Reactive.Linq.4.0.0.nupkg
delete mode 100644 packages/System.Reactive.Linq.4.0.0/lib/net46/System.Reactive.Linq.dll
delete mode 100644 packages/System.Reactive.Linq.4.0.0/lib/net46/System.Reactive.Linq.xml
delete mode 100644 packages/System.Reactive.Linq.4.0.0/lib/netstandard2.0/System.Reactive.Linq.dll
delete mode 100644 packages/System.Reactive.Linq.4.0.0/lib/netstandard2.0/System.Reactive.Linq.xml
delete mode 100644 packages/System.Reactive.Linq.4.0.0/lib/uap10.0/System.Reactive.Linq.dll
delete mode 100644 packages/System.Reactive.Linq.4.0.0/lib/uap10.0/System.Reactive.Linq.pri
delete mode 100644 packages/System.Reactive.Linq.4.0.0/lib/uap10.0/System.Reactive.Linq.xml
delete mode 100644 packages/YamlDotNet.11.2.1/.signature.p7s
delete mode 100644 packages/YamlDotNet.11.2.1/LICENSE.txt
delete mode 100644 packages/YamlDotNet.11.2.1/YamlDotNet.11.2.1.nupkg
delete mode 100644 packages/YamlDotNet.11.2.1/images/yamldotnet.png
delete mode 100644 packages/YamlDotNet.11.2.1/lib/net20/YamlDotNet.dll
delete mode 100644 packages/YamlDotNet.11.2.1/lib/net20/YamlDotNet.xml
delete mode 100644 packages/YamlDotNet.11.2.1/lib/net35-client/YamlDotNet.dll
delete mode 100644 packages/YamlDotNet.11.2.1/lib/net35-client/YamlDotNet.xml
delete mode 100644 packages/YamlDotNet.11.2.1/lib/net35/YamlDotNet.dll
delete mode 100644 packages/YamlDotNet.11.2.1/lib/net35/YamlDotNet.xml
delete mode 100644 packages/YamlDotNet.11.2.1/lib/net45/YamlDotNet.dll
delete mode 100644 packages/YamlDotNet.11.2.1/lib/net45/YamlDotNet.xml
delete mode 100644 packages/YamlDotNet.11.2.1/lib/netstandard1.3/YamlDotNet.dll
delete mode 100644 packages/YamlDotNet.11.2.1/lib/netstandard1.3/YamlDotNet.xml
delete mode 100644 packages/YamlDotNet.11.2.1/lib/netstandard2.1/YamlDotNet.dll
delete mode 100644 packages/YamlDotNet.11.2.1/lib/netstandard2.1/YamlDotNet.xml
delete mode 100644 test.txt
diff --git a/.codex/config.toml b/.codex/config.toml
new file mode 100644
index 00000000..d17d1cff
--- /dev/null
+++ b/.codex/config.toml
@@ -0,0 +1,2 @@
+[mcp_servers.avalonia-docs]
+url = "https://docs-mcp.avaloniaui.net/mcp"
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
new file mode 100644
index 00000000..4082aed8
--- /dev/null
+++ b/.config/dotnet-tools.json
@@ -0,0 +1,13 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "dotnet-reportgenerator-globaltool": {
+ "version": "5.5.10",
+ "commands": [
+ "reportgenerator"
+ ],
+ "rollForward": false
+ }
+ }
+}
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..3b7e7d34
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,160 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = crlf
+insert_final_newline = true
+trim_trailing_whitespace = true
+indent_style = space
+indent_size = 4
+
+[*.{csproj,props,targets,slnx}]
+indent_size = 2
+
+[*.cs]
+indent_size = 4
+
+# Baseline C# style. Repository-wide preferences remain advisory where possible;
+# the active GenLauncherGO projects use the stricter severities below.
+dotnet_sort_system_directives_first = true
+dotnet_separate_import_directive_groups = false
+
+csharp_style_namespace_declarations = file_scoped:suggestion
+csharp_style_prefer_method_group_conversion = true:suggestion
+csharp_style_prefer_primary_constructors = false:suggestion
+csharp_style_prefer_null_check_over_type_check = true:suggestion
+csharp_style_prefer_switch_expression = true:suggestion
+csharp_style_prefer_pattern_matching = true:suggestion
+csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
+csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
+csharp_style_prefer_not_pattern = true:suggestion
+csharp_style_prefer_extended_property_pattern = true:suggestion
+csharp_style_prefer_readonly_struct = true:suggestion
+csharp_style_prefer_readonly_struct_member = true:suggestion
+csharp_style_expression_bodied_methods = false:suggestion
+csharp_style_expression_bodied_constructors = false:suggestion
+csharp_style_expression_bodied_operators = false:suggestion
+csharp_style_expression_bodied_properties = true:suggestion
+csharp_style_expression_bodied_indexers = true:suggestion
+csharp_style_expression_bodied_accessors = true:suggestion
+csharp_style_throw_expression = true:suggestion
+csharp_style_conditional_delegate_call = true:suggestion
+csharp_prefer_braces = true:suggestion
+csharp_prefer_simple_using_statement = true:suggestion
+csharp_prefer_static_local_function = true:suggestion
+
+csharp_style_var_for_built_in_types = false:suggestion
+csharp_style_var_when_type_is_apparent = true:suggestion
+csharp_style_var_elsewhere = false:suggestion
+
+dotnet_style_qualification_for_field = false:suggestion
+dotnet_style_qualification_for_property = false:suggestion
+dotnet_style_qualification_for_method = false:suggestion
+dotnet_style_qualification_for_event = false:suggestion
+dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
+dotnet_style_predefined_type_for_member_access = true:suggestion
+dotnet_style_object_initializer = true:suggestion
+dotnet_style_collection_initializer = true:suggestion
+dotnet_style_coalesce_expression = true:suggestion
+dotnet_style_null_propagation = true:suggestion
+dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
+dotnet_style_prefer_auto_properties = true:suggestion
+dotnet_style_prefer_inferred_tuple_names = true:suggestion
+dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
+dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
+dotnet_style_prefer_conditional_expression_over_assignment = false:suggestion
+dotnet_style_prefer_conditional_expression_over_return = false:suggestion
+dotnet_style_readonly_field = true:suggestion
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion
+
+# Naming rules.
+dotnet_naming_rule.types_are_pascal_case.symbols = types
+dotnet_naming_rule.types_are_pascal_case.style = pascal_case
+dotnet_naming_rule.types_are_pascal_case.severity = suggestion
+dotnet_naming_symbols.types.applicable_kinds = class, struct, enum, delegate
+dotnet_naming_style.pascal_case.capitalization = pascal_case
+
+dotnet_naming_rule.interfaces_start_with_i.symbols = interfaces
+dotnet_naming_rule.interfaces_start_with_i.style = prefix_interface_with_i
+dotnet_naming_rule.interfaces_start_with_i.severity = suggestion
+dotnet_naming_symbols.interfaces.applicable_kinds = interface
+dotnet_naming_style.prefix_interface_with_i.required_prefix = I
+dotnet_naming_style.prefix_interface_with_i.capitalization = pascal_case
+
+dotnet_naming_rule.members_are_pascal_case.symbols = members
+dotnet_naming_rule.members_are_pascal_case.style = pascal_case
+dotnet_naming_rule.members_are_pascal_case.severity = suggestion
+dotnet_naming_symbols.members.applicable_kinds = property, method, event
+
+dotnet_naming_rule.non_private_fields_are_pascal_case.symbols = non_private_fields
+dotnet_naming_rule.non_private_fields_are_pascal_case.style = pascal_case
+dotnet_naming_rule.non_private_fields_are_pascal_case.severity = suggestion
+dotnet_naming_symbols.non_private_fields.applicable_kinds = field
+dotnet_naming_symbols.non_private_fields.applicable_accessibilities = public, internal, protected, protected_internal, private_protected
+
+dotnet_naming_rule.private_fields_are_camel_case.symbols = private_fields
+dotnet_naming_rule.private_fields_are_camel_case.style = underscore_camel_case
+dotnet_naming_rule.private_fields_are_camel_case.severity = suggestion
+dotnet_naming_symbols.private_fields.applicable_kinds = field
+dotnet_naming_symbols.private_fields.applicable_accessibilities = private
+dotnet_naming_style.underscore_camel_case.required_prefix = _
+dotnet_naming_style.underscore_camel_case.capitalization = camel_case
+
+dotnet_naming_rule.constants_are_pascal_case.symbols = constants
+dotnet_naming_rule.constants_are_pascal_case.style = pascal_case
+dotnet_naming_rule.constants_are_pascal_case.severity = suggestion
+dotnet_naming_symbols.constants.applicable_kinds = field
+dotnet_naming_symbols.constants.required_modifiers = const
+
+dotnet_naming_rule.async_methods_end_in_async.symbols = async_methods
+dotnet_naming_rule.async_methods_end_in_async.style = suffix_async
+dotnet_naming_rule.async_methods_end_in_async.severity = suggestion
+dotnet_naming_symbols.async_methods.applicable_kinds = method
+dotnet_naming_symbols.async_methods.required_modifiers = async
+dotnet_naming_style.suffix_async.required_suffix = Async
+dotnet_naming_style.suffix_async.capitalization = pascal_case
+
+dotnet_naming_rule.parameters_are_camel_case.symbols = parameters
+dotnet_naming_rule.parameters_are_camel_case.style = camel_case
+dotnet_naming_rule.parameters_are_camel_case.severity = suggestion
+dotnet_naming_symbols.parameters.applicable_kinds = parameter
+dotnet_naming_style.camel_case.capitalization = camel_case
+
+dotnet_naming_rule.type_parameters_start_with_t.symbols = type_parameters
+dotnet_naming_rule.type_parameters_start_with_t.style = prefix_type_parameter_with_t
+dotnet_naming_rule.type_parameters_start_with_t.severity = suggestion
+dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter
+dotnet_naming_style.prefix_type_parameter_with_t.required_prefix = T
+dotnet_naming_style.prefix_type_parameter_with_t.capitalization = pascal_case
+
+# Active GenLauncherGO architecture code follows these conventions as
+# build-enforced warnings or errors.
+[GenLauncherGO.*/**.cs]
+csharp_style_namespace_declarations = file_scoped:warning
+csharp_prefer_braces = true:warning
+csharp_style_var_for_built_in_types = false:warning
+csharp_style_var_when_type_is_apparent = true:warning
+csharp_style_var_elsewhere = false:warning
+dotnet_style_readonly_field = true:warning
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
+dotnet_naming_rule.types_are_pascal_case.severity = error
+dotnet_naming_rule.interfaces_start_with_i.severity = error
+dotnet_naming_rule.members_are_pascal_case.severity = error
+dotnet_naming_rule.non_private_fields_are_pascal_case.severity = error
+dotnet_naming_rule.private_fields_are_camel_case.severity = error
+dotnet_naming_rule.constants_are_pascal_case.severity = error
+dotnet_naming_rule.async_methods_end_in_async.severity = error
+dotnet_naming_rule.parameters_are_camel_case.severity = error
+dotnet_naming_rule.type_parameters_start_with_t.severity = error
+dotnet_diagnostic.IDE1006.severity = error
+dotnet_diagnostic.IDE0130.severity = warning
+
+# Keep intentional XML documentation valid. Documentation is required for
+# cross-project contracts and non-obvious behavior, side effects, invariants,
+# safety rules, or platform constraints—not every self-explanatory member.
+dotnet_diagnostic.CS1570.severity = warning
+dotnet_diagnostic.CS1572.severity = warning
+dotnet_diagnostic.CS1573.severity = warning
+
+[*.xaml]
+indent_size = 4
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
deleted file mode 100644
index 4d4f0dc8..00000000
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ /dev/null
@@ -1,28 +0,0 @@
----
-name: Bug report
-about: Report a bug with GenLauncher.
-title: ''
-labels: ''
-assignees: ''
-
----
-
-**Describe the bug**
-A clear and concise description of what the bug is.
-
-**To Reproduce**
-Steps to reproduce the behavior:
-
-**Expected behavior**
-A clear and concise description of what you expected to happen.
-
-**Screenshots**
-If applicable, add screenshots to help explain your problem.
-
-**Desktop (please complete the following information):**
- - Operating System
- - GenLauncher Version
- - Game being managed (Generals or Zero Hour)
-
-**Additional context**
-Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
deleted file mode 100644
index 9adf2b42..00000000
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-name: Feature request
-about: Suggest a feature or enhancement to GenLauncher.
-title: ''
-labels: ''
-assignees: ''
-
----
-
-**Is your feature request related to a problem? Please describe.**
-A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
-
-**Describe the solution you'd like**
-A clear and concise description of what you want to happen.
-
-**Describe alternatives you've considered**
-A clear and concise description of any alternative solutions or features you've considered.
-
-**Additional context**
-Add any other context or screenshots about the feature request here.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..03d4731d
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,60 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ build-and-test:
+ name: Build, test, and publish
+ runs-on: windows-2022
+ timeout-minutes: 20
+ env:
+ DOTNET_CLI_TELEMETRY_OPTOUT: true
+ DOTNET_NOLOGO: true
+ GENLAUNCHERGO_REQUIRE_SYMBOLIC_LINK_TESTS: true
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v6
+
+ - name: Set up .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 10.0.3xx
+ dotnet-quality: ga
+
+ - name: Verify .NET SDK
+ shell: pwsh
+ run: |
+ $sdkVersion = dotnet --version
+ if (-not $sdkVersion.StartsWith("10.")) {
+ throw "Expected a .NET 10 SDK, but dotnet selected $sdkVersion."
+ }
+
+ - name: Restore
+ run: dotnet restore .\GenLauncherGO.sln
+
+ - name: Build
+ run: dotnet build .\GenLauncherGO.sln --configuration Release --no-restore
+
+ - name: Test
+ run: dotnet test .\GenLauncherGO.sln --configuration Release --no-build --no-restore
+
+ - name: Publish supported single-file executable
+ shell: pwsh
+ run: |
+ $publishDirectory = Join-Path $env:RUNNER_TEMP "GenLauncherGO-publish"
+ dotnet publish .\GenLauncherGO.UI\GenLauncherGO.UI.csproj -p:PublishProfile=WinX64SelfContained --output $publishDirectory
+ $launcherPath = Join-Path $publishDirectory "GenLauncherGO.exe"
+ if (-not (Test-Path -LiteralPath $launcherPath -PathType Leaf)) {
+ throw "Expected the supported launcher executable at $launcherPath."
+ }
+ $looseLibraries = @(Get-ChildItem -LiteralPath $publishDirectory -Filter "*.dll" -File -Recurse)
+ if ($looseLibraries.Count -ne 0) {
+ throw "The supported single-file publish unexpectedly produced loose DLL files."
+ }
diff --git a/.gitignore b/.gitignore
index 4f3be99f..7809bb51 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,398 +1,31 @@
-## Ignore Visual Studio temporary files, build results, and
-## files generated by popular Visual Studio add-ons.
-##
-## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
+# .NET and Avalonia build/publish output
+[Bb]in/
+[Oo]bj/
+/artifacts/
+/publish/
+*.binlog
+
+# Test results and coverage output
+/TestResults/
+*.trx
+*.coverage
+*.coveragexml
-# User-specific files
+# Visual Studio
+/.vs/
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
-# User-specific files (MonoDevelop/Xamarin Studio)
-*.userprefs
-
-# Mono auto generated files
-mono_crash.*
-
-# Build results
-[Dd]ebug/
-[Dd]ebugPublic/
-[Rr]elease/
-[Rr]eleases/
-x64/
-x86/
-[Ww][Ii][Nn]32/
-[Aa][Rr][Mm]/
-[Aa][Rr][Mm]64/
-bld/
-[Bb]in/
-[Oo]bj/
-[Ll]og/
-[Ll]ogs/
-
-# Visual Studio 2015/2017 cache/options directory
-.vs/
-# Uncomment if you have tasks that create the project's static files in wwwroot
-#wwwroot/
-
-# Visual Studio 2017 auto generated files
-Generated\ Files/
-
-# MSTest test Results
-[Tt]est[Rr]esult*/
-[Bb]uild[Ll]og.*
-
-# NUnit
-*.VisualState.xml
-TestResult.xml
-nunit-*.xml
-
-# Build Results of an ATL Project
-[Dd]ebugPS/
-[Rr]eleasePS/
-dlldata.c
-
-# Benchmark Results
-BenchmarkDotNet.Artifacts/
-
-# .NET Core
-project.lock.json
-project.fragment.lock.json
-artifacts/
-
-# ASP.NET Scaffolding
-ScaffoldingReadMe.txt
-
-# StyleCop
-StyleCopReport.xml
-
-# Files built by Visual Studio
-*_i.c
-*_p.c
-*_h.h
-*.ilk
-*.meta
-*.obj
-*.iobj
-*.pch
-*.pdb
-*.ipdb
-*.pgc
-*.pgd
-*.rsp
-*.sbr
-*.tlb
-*.tli
-*.tlh
-*.tmp
-*.tmp_proj
-*_wpftmp.csproj
-*.log
-*.tlog
-*.vspscc
-*.vssscc
-.builds
-*.pidb
-*.svclog
-*.scc
-
-# Chutzpah Test files
-_Chutzpah*
-
-# Visual C++ cache files
-ipch/
-*.aps
-*.ncb
-*.opendb
-*.opensdf
-*.sdf
-*.cachefile
-*.VC.db
-*.VC.VC.opendb
-
-# Visual Studio profiler
-*.psess
-*.vsp
-*.vspx
-*.sap
-
-# Visual Studio Trace Files
-*.e2e
-
-# TFS 2012 Local Workspace
-$tf/
-
-# Guidance Automation Toolkit
-*.gpState
-
-# ReSharper is a .NET coding add-in
-_ReSharper*/
-*.[Rr]e[Ss]harper
+# JetBrains Rider/ReSharper
+/.idea/
+_ReSharper.Caches/
+*.sln.iml
*.DotSettings.user
-# TeamCity is a build add-in
-_TeamCity*
-
-# DotCover is a Code Coverage Tool
-*.dotCover
-
-# AxoCover is a Code Coverage Tool
-.axoCover/*
-!.axoCover/settings.json
-
-# Coverlet is a free, cross platform Code Coverage Tool
-coverage*.json
-coverage*.xml
-coverage*.info
-
-# Visual Studio code coverage results
-*.coverage
-*.coveragexml
-
-# NCrunch
-_NCrunch_*
-.*crunch*.local.xml
-nCrunchTemp_*
-
-# MightyMoose
-*.mm.*
-AutoTest.Net/
-
-# Web workbench (sass)
-.sass-cache/
-
-# Installshield output folder
-[Ee]xpress/
-
-# DocProject is a documentation generator add-in
-DocProject/buildhelp/
-DocProject/Help/*.HxT
-DocProject/Help/*.HxC
-DocProject/Help/*.hhc
-DocProject/Help/*.hhk
-DocProject/Help/*.hhp
-DocProject/Help/Html2
-DocProject/Help/html
-
-# Click-Once directory
-publish/
-
-# Publish Web Output
-*.[Pp]ublish.xml
-*.azurePubxml
-# Note: Comment the next line if you want to checkin your web deploy settings,
-# but database connection strings (with potential passwords) will be unencrypted
-*.pubxml
-*.publishproj
-
-# Microsoft Azure Web App publish settings. Comment the next line if you want to
-# checkin your Azure Web App publish settings, but sensitive information contained
-# in these scripts will be unencrypted
-PublishScripts/
-
-# NuGet Packages
-*.nupkg
-# NuGet Symbol Packages
-*.snupkg
-# The packages folder can be ignored because of Package Restore
-**/[Pp]ackages/*
-# except build/, which is used as an MSBuild target.
-!**/[Pp]ackages/build/
-# Uncomment if necessary however generally it will be regenerated when needed
-#!**/[Pp]ackages/repositories.config
-# NuGet v3's project.json files produces more ignorable files
-*.nuget.props
-*.nuget.targets
-
-# Microsoft Azure Build Output
-csx/
-*.build.csdef
-
-# Microsoft Azure Emulator
-ecf/
-rcf/
-
-# Windows Store app package directories and files
-AppPackages/
-BundleArtifacts/
-Package.StoreAssociation.xml
-_pkginfo.txt
-*.appx
-*.appxbundle
-*.appxupload
-
-# Visual Studio cache files
-# files ending in .cache can be ignored
-*.[Cc]ache
-# but keep track of directories ending in .cache
-!?*.[Cc]ache/
-
-# Others
-ClientBin/
-~$*
+# Local editor and user files
+*.userprefs
*~
-*.dbmdl
-*.dbproj.schemaview
-*.jfm
-*.pfx
-*.publishsettings
-orleans.codegen.cs
-
-# Including strong name files can present a security risk
-# (https://github.com/github/gitignore/pull/2483#issue-259490424)
-#*.snk
-
-# Since there are multiple workflows, uncomment next line to ignore bower_components
-# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
-#bower_components/
-
-# RIA/Silverlight projects
-Generated_Code/
-
-# Backup & report files from converting an old project file
-# to a newer Visual Studio version. Backup files are not needed,
-# because we have git ;-)
-_UpgradeReport_Files/
-Backup*/
-UpgradeLog*.XML
-UpgradeLog*.htm
-ServiceFabricBackup/
-*.rptproj.bak
-
-# SQL Server files
-*.mdf
-*.ldf
-*.ndf
-
-# Business Intelligence projects
-*.rdl.data
-*.bim.layout
-*.bim_*.settings
-*.rptproj.rsuser
-*- [Bb]ackup.rdl
-*- [Bb]ackup ([0-9]).rdl
-*- [Bb]ackup ([0-9][0-9]).rdl
-
-# Microsoft Fakes
-FakesAssemblies/
-
-# GhostDoc plugin setting file
-*.GhostDoc.xml
-
-# Node.js Tools for Visual Studio
-.ntvs_analysis.dat
-node_modules/
-
-# Visual Studio 6 build log
-*.plg
-
-# Visual Studio 6 workspace options file
-*.opt
-
-# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
-*.vbw
-
-# Visual Studio 6 auto-generated project file (contains which files were open etc.)
-*.vbp
-
-# Visual Studio 6 workspace and project file (working project files containing files to include in project)
-*.dsw
-*.dsp
-
-# Visual Studio 6 technical files
-*.ncb
-*.aps
-
-# Visual Studio LightSwitch build output
-**/*.HTMLClient/GeneratedArtifacts
-**/*.DesktopClient/GeneratedArtifacts
-**/*.DesktopClient/ModelManifest.xml
-**/*.Server/GeneratedArtifacts
-**/*.Server/ModelManifest.xml
-_Pvt_Extensions
-
-# Paket dependency manager
-.paket/paket.exe
-paket-files/
-
-# FAKE - F# Make
-.fake/
-
-# CodeRush personal settings
-.cr/personal
-
-# Python Tools for Visual Studio (PTVS)
-__pycache__/
-*.pyc
-
-# Cake - Uncomment if you are using it
-# tools/**
-# !tools/packages.config
-
-# Tabs Studio
-*.tss
-
-# Telerik's JustMock configuration file
-*.jmconfig
-
-# BizTalk build output
-*.btp.cs
-*.btm.cs
-*.odx.cs
-*.xsd.cs
-
-# OpenCover UI analysis results
-OpenCover/
-
-# Azure Stream Analytics local run output
-ASALocalRun/
-
-# MSBuild Binary and Structured Log
-*.binlog
-
-# NVidia Nsight GPU debugger configuration file
-*.nvuser
-
-# MFractors (Xamarin productivity tool) working folder
-.mfractor/
-
-# Local History for Visual Studio
-.localhistory/
-
-# Visual Studio History (VSHistory) files
-.vshistory/
-
-# BeatPulse healthcheck temp database
-healthchecksdb
-
-# Backup folder for Package Reference Convert tool in Visual Studio 2017
-MigrationBackup/
-
-# Ionide (cross platform F# VS Code tools) working folder
-.ionide/
-
-# Fody - auto-generated XML schema
-FodyWeavers.xsd
-
-# VS Code files for those working on multiple tools
-.vscode/*
-!.vscode/settings.json
-!.vscode/tasks.json
-!.vscode/launch.json
-!.vscode/extensions.json
-*.code-workspace
-
-# Local History for Visual Studio Code
-.history/
-
-# Windows Installer files from build outputs
-*.cab
-*.msi
-*.msix
-*.msm
-*.msp
-
-# JetBrains Rider
-*.sln.iml
\ No newline at end of file
+~$*
diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 00000000..2be80f2c
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,8 @@
+{
+ "mcpServers": {
+ "avalonia-docs": {
+ "type": "http",
+ "url": "https://docs-mcp.avaloniaui.net/mcp"
+ }
+ }
+}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..8b36893f
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,64 @@
+# GenLauncherGO Agent Guidelines
+
+GenLauncherGO is a small Windows launcher with a native Avalonia UI for Generals and Zero Hour community clients.
+
+## Workflow
+
+- Preserve existing behavior unless the user explicitly requests a change.
+- Preserve user changes; avoid unrelated cleanup.
+- Search for the current owner and callers before adding or replacing shared behavior.
+- For Avalonia or WPF-to-Avalonia work, use the `avalonia-docs` MCP server and load its expert rules first.
+- The official Avalonia Build MCP documentation, expert-rule, API, mapping, and native-migration tools are allowed, as are the open-source framework and free legacy tooling. Do not call `migrate_diagnostics` or `recreate-ui`, and skip any Developer Tools setup suggested by `new`: those workflows lead to the license-gated Developer Tools application or DevTools MCP. Do not configure those products, Avalonia XPF, or another commercial feature unless the owner explicitly supplies a license and requests it.
+- Build with `dotnet build GenLauncherGO.sln`.
+- Run `dotnet test GenLauncherGO.sln` when behavior or tests change; use narrow commands for iteration, then the full solution before handoff.
+- Tests must protect application-owned behavior, safety, compatibility, or important invariants. Do not add tests for exact layout coordinates, framework behavior, trivial properties or guards, or other implementation details that can change without affecting users.
+
+## Project Boundaries
+
+| Project | Owns | May depend on |
+| --- | --- | --- |
+| `GenLauncherGO.Core/` | Domain rules, values, validation, intentional cross-project contracts | .NET only |
+| `GenLauncherGO.Infrastructure/` | Disk, network, archives, processes, hashing, persistence, logging adapters | Core |
+| `GenLauncherGO.UI/` | Native Avalonia presentation and the composition root | Core, Infrastructure |
+| `GenLauncherGO.Tests/` | Observable behavior, safety, compatibility, and invariant tests | Projects under test |
+
+Read the nearest nested `AGENTS.md` before editing a project. There is intentionally no `src/` folder.
+
+## Design Gates
+
+- Optimize for a small launcher: prefer direct calls and concrete `internal sealed` types.
+- Core has no external consumers. Do not keep unused public APIs, old names, adapters, or compatibility shims.
+- Maintain one authority for content identity, executable names, type mapping, owned paths, settings, and other shared rules. Reuse or move it; never copy it.
+- Do not add mediator, CQRS, service-locator, or similar frameworks.
+- Do not add speculative extension points or edge cases; require current behavior, an external contract, a reproduced defect, or a safety invariant.
+- Keep production code feature-first. Do not add a folder or layer for file count, symmetry, or anticipated growth.
+- Fixed arguments, localization keys, or one forwarded call do not justify a type.
+
+| New artifact | Allowed only when |
+| --- | --- |
+| Interface | It is an external or side-effect boundary, or has multiple production implementations. Testing convenience alone is insufficient. |
+| Request | It validates a stable operation boundary or is genuinely shared; never just bundle arguments for one internal call. |
+| Result | Callers branch on named outcomes or need structured failure data; never just mirror returned properties. |
+| Factory | It selects implementations or owns meaningful construction or lifetime policy; never merely call `new`. |
+| Coordinator | It owns sequencing, state, rollback, or lifecycle; never merely forward calls or group dependencies. |
+| Mapper or DTO | It crosses an external or persistence boundary. Map once; do not add an intermediate mirror model. |
+| Wrapper | It adds an invariant, ownership, or policy. Otherwise call the existing type directly. |
+
+## Non-Negotiable Constraints
+
+- The remote YAML/backend contract is external. Preserve its accepted keys, shapes, defaults, and semantics at the Infrastructure boundary.
+- Launch preparation mutates a user's game folder. Preserve ownership, containment, reparse-point, rollback, and recovery defenses.
+- Production must never create symbolic links. Hard links with copy fallback are allowed and are not symbolic links.
+- The application must never set or synchronize the Windows system clock.
+- Package versions belong only in `Directory.Packages.props`; project `PackageReference` entries remain versionless.
+- Follow `.editorconfig` and `Directory.Build.props`.
+- Document cross-project contracts and non-obvious side effects, invariants, compatibility constraints, or platform behavior. Do not document obvious implementation details.
+- Use `GenLauncherGO` for new names. Do not add a license or release/deployment automation without an explicit owner decision.
+
+## Completion
+
+- Remove superseded code in the same change; do not leave parallel paths without a current caller.
+- Inspect the final diff for duplicate logic, avoidable types, widened visibility, and tests coupled to implementation details.
+- In the handoff, list every new production interface/request/result/factory/coordinator/wrapper and the gate that justified it; say explicitly when none were added.
+- Report reused or changed canonical authorities and all verification run.
+- Use Conventional Commits when committing: `type(scope): short imperative summary`.
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 00000000..dc351a19
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,15 @@
+
+
+ 14.0
+ enable
+ disable
+ latest
+ true
+ false
+ true
+
+
+
+ $(WarningsAsErrors);IDE1006
+
+
diff --git a/Directory.Build.targets b/Directory.Build.targets
new file mode 100644
index 00000000..c03da93c
--- /dev/null
+++ b/Directory.Build.targets
@@ -0,0 +1,22 @@
+
+
+
+ <_DisallowedProjectReference Include="@(ProjectReference)"
+ Condition="
+ '$(MSBuildProjectName)' == 'GenLauncherGO.Core'
+ Or ('$(MSBuildProjectName)' == 'GenLauncherGO.Infrastructure'
+ And '%(ProjectReference.Filename)' != 'GenLauncherGO.Core')
+ Or ('$(MSBuildProjectName)' == 'GenLauncherGO.UI'
+ And '%(ProjectReference.Filename)' != 'GenLauncherGO.Core'
+ And '%(ProjectReference.Filename)' != 'GenLauncherGO.Infrastructure')
+ Or ('$(MSBuildProjectName)' == 'GenLauncherGO.Tests'
+ And '%(ProjectReference.Filename)' != 'GenLauncherGO.Core'
+ And '%(ProjectReference.Filename)' != 'GenLauncherGO.Infrastructure'
+ And '%(ProjectReference.Filename)' != 'GenLauncherGO.UI')" />
+
+
+
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 00000000..769fefe6
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,28 @@
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GenLauncher.sln b/GenLauncher.sln
deleted file mode 100644
index 0a07d21f..00000000
--- a/GenLauncher.sln
+++ /dev/null
@@ -1,31 +0,0 @@
-
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.3.32901.215
-MinimumVisualStudioVersion = 10.0.40219.1
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenLauncher", "GenLauncherNet\GenLauncher.csproj", "{4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}"
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug for Generals|Any CPU = Debug for Generals|Any CPU
- Debug for Zero Hour|Any CPU = Debug for Zero Hour|Any CPU
- Debug|Any CPU = Debug|Any CPU
- Release|Any CPU = Release|Any CPU
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Debug for Generals|Any CPU.ActiveCfg = Debug for Generals|Any CPU
- {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Debug for Generals|Any CPU.Build.0 = Debug for Generals|Any CPU
- {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Debug for Zero Hour|Any CPU.ActiveCfg = Debug for Zero Hour|Any CPU
- {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Debug for Zero Hour|Any CPU.Build.0 = Debug for Zero Hour|Any CPU
- {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Release|Any CPU.Build.0 = Release|Any CPU
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
- GlobalSection(ExtensibilityGlobals) = postSolution
- SolutionGuid = {D7CECCC4-7CE9-476B-9874-1B6F7FA506C0}
- EndGlobalSection
-EndGlobal
diff --git a/GenLauncherGO.Core/AGENTS.md b/GenLauncherGO.Core/AGENTS.md
new file mode 100644
index 00000000..3f34d4b8
--- /dev/null
+++ b/GenLauncherGO.Core/AGENTS.md
@@ -0,0 +1,9 @@
+# GenLauncherGO.Core Guidance
+
+- Keep Core dependency-light and side-effect free: no Avalonia or other UI frameworks/resources, Infrastructure, Windows APIs, disk, network, processes, archives, hashing implementations, remote DTOs, or logging packages.
+- Make a type `public` only when another production project consumes it. Public means intra-solution contract, not external compatibility.
+- Model durable identity, configuration, and domain facts as immutable values when practical; mutable workflow and UI state do not belong here.
+- Keep remote YAML names and serialization shapes in Infrastructure; Core receives normalized concepts.
+- Pass `CancellationToken` through new asynchronous contracts.
+- Keep expected failures explicit only when callers must act on distinct outcomes; otherwise use the simplest normal .NET mechanism.
+- Add focused documentation for cross-project contracts and non-obvious invariants or rationale.
diff --git a/GenLauncherGO.Core/GenLauncherGO.Core.csproj b/GenLauncherGO.Core/GenLauncherGO.Core.csproj
new file mode 100644
index 00000000..555ae434
--- /dev/null
+++ b/GenLauncherGO.Core/GenLauncherGO.Core.csproj
@@ -0,0 +1,5 @@
+
+
+ net10.0
+
+
diff --git a/GenLauncherGO.Core/IO/LexicalPath.cs b/GenLauncherGO.Core/IO/LexicalPath.cs
new file mode 100644
index 00000000..d5d4a8ca
--- /dev/null
+++ b/GenLauncherGO.Core/IO/LexicalPath.cs
@@ -0,0 +1,154 @@
+using System;
+using System.IO;
+
+namespace GenLauncherGO.Core.IO;
+
+///
+/// Provides side-effect-free Windows path normalization, relative-path, and containment operations.
+///
+///
+/// These operations are lexical only. Callers that traverse or mutate the filesystem must separately inspect the
+/// physical path for reparse points and other unsafe entries.
+///
+public static class LexicalPath
+{
+ ///
+ /// Returns a fully qualified path without a non-root trailing directory separator.
+ ///
+ public static string NormalizeFullPath(string path)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+
+ return Path.TrimEndingDirectorySeparator(Path.GetFullPath(path));
+ }
+
+ ///
+ /// Normalizes a relative path to slash separators for persisted metadata and comparisons.
+ ///
+ public static string NormalizeRelativePath(string path)
+ {
+ ArgumentNullException.ThrowIfNull(path);
+
+ return path.Replace('\\', '/').Trim('/');
+ }
+
+ ///
+ /// Gets a normalized slash-separated path from a root to another path.
+ ///
+ ///
+ /// The returned path can identify the root itself or leave it. Call or a
+ /// containment operation when a caller requires an ownership boundary.
+ ///
+ public static string GetRelativePath(string root, string path)
+ {
+ return NormalizeRelativePath(Path.GetRelativePath(
+ NormalizeFullPath(root),
+ NormalizeFullPath(path)));
+ }
+
+ ///
+ /// Resolves a path against a root without inspecting the physical filesystem.
+ ///
+ ///
+ /// Rooted or traversing inputs can resolve outside . Call
+ /// when containment is required.
+ ///
+ public static string ResolvePath(string root, string path)
+ {
+ ArgumentNullException.ThrowIfNull(path);
+
+ return NormalizeFullPath(Path.Combine(
+ NormalizeFullPath(root),
+ path.Replace('/', Path.DirectorySeparatorChar)));
+ }
+
+ ///
+ /// Determines whether a path is a directory or one of its children using Windows case semantics.
+ ///
+ public static bool IsPathInDirectory(string path, string directory)
+ {
+ string normalizedDirectory = NormalizeFullPath(directory);
+ string normalizedPath = NormalizeFullPath(path);
+ if (string.Equals(normalizedPath, normalizedDirectory, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ string directoryPrefix = Path.EndsInDirectorySeparator(normalizedDirectory)
+ ? normalizedDirectory
+ : normalizedDirectory + Path.DirectorySeparatorChar;
+ return normalizedPath.StartsWith(directoryPrefix, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Determines whether a path is strictly below a directory using Windows case semantics.
+ ///
+ internal static bool IsPathBelowDirectory(string path, string directory)
+ {
+ string normalizedDirectory = NormalizeFullPath(directory);
+ string normalizedPath = NormalizeFullPath(path);
+ return !string.Equals(normalizedPath, normalizedDirectory, StringComparison.OrdinalIgnoreCase) &&
+ IsPathInDirectory(normalizedPath, normalizedDirectory);
+ }
+
+ ///
+ /// Determines whether a relative path identifies an entry outside its origin.
+ ///
+ public static bool RelativePathLeavesRoot(string relativePath)
+ {
+ ArgumentNullException.ThrowIfNull(relativePath);
+
+ string normalizedPath = NormalizeRelativePath(relativePath);
+ return string.Equals(normalizedPath, "..", StringComparison.Ordinal) ||
+ normalizedPath.StartsWith("../", StringComparison.Ordinal) ||
+ Path.IsPathRooted(relativePath);
+ }
+
+ ///
+ /// Normalizes one user-supplied Windows path segment and rejects reserved or traversing names.
+ ///
+ internal static string NormalizePathSegment(string? segment, string paramName)
+ {
+ if (string.IsNullOrWhiteSpace(segment))
+ {
+ throw new ArgumentException("Path segments must not be empty.", paramName);
+ }
+
+ string normalizedSegment = segment.Trim();
+ if (Path.IsPathRooted(normalizedSegment) ||
+ normalizedSegment.Contains(Path.DirectorySeparatorChar, StringComparison.Ordinal) ||
+ normalizedSegment.Contains(Path.AltDirectorySeparatorChar, StringComparison.Ordinal) ||
+ normalizedSegment.Contains(':', StringComparison.Ordinal) ||
+ normalizedSegment.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
+ {
+ throw new ArgumentException("Path segments must not contain rooted paths or directory separators.", paramName);
+ }
+
+ if (string.Equals(normalizedSegment, ".", StringComparison.Ordinal) ||
+ string.Equals(normalizedSegment, "..", StringComparison.Ordinal) ||
+ normalizedSegment.EndsWith(".", StringComparison.Ordinal) ||
+ IsReservedDeviceName(normalizedSegment))
+ {
+ throw new ArgumentException("Path segments must not use reserved file-system names.", paramName);
+ }
+
+ return normalizedSegment;
+ }
+
+ private static bool IsReservedDeviceName(string segment)
+ {
+ string name = segment.Split('.')[0];
+ if (string.Equals(name, "CON", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(name, "PRN", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(name, "AUX", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(name, "NUL", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ return name.Length == 4 &&
+ (name.StartsWith("COM", StringComparison.OrdinalIgnoreCase) ||
+ name.StartsWith("LPT", StringComparison.OrdinalIgnoreCase)) &&
+ name[3] is >= '1' and <= '9';
+ }
+}
diff --git a/GenLauncherGO.Core/Integrity/Models/ContentIntegrityIssue.cs b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityIssue.cs
new file mode 100644
index 00000000..d4a57d7b
--- /dev/null
+++ b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityIssue.cs
@@ -0,0 +1,11 @@
+namespace GenLauncherGO.Core.Integrity.Models;
+
+public sealed record ContentIntegrityIssue(
+ string TargetId,
+ string TargetDisplayName,
+ ContentSourceKind SourceKind,
+ IntegrityIssueKind Kind,
+ IntegrityIssueAction Action,
+ string RelativePath,
+ string? Message = null,
+ long? ExpectedSizeBytes = null);
diff --git a/GenLauncherGO.Core/Integrity/Models/ContentIntegrityReport.cs b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityReport.cs
new file mode 100644
index 00000000..0cdd05b5
--- /dev/null
+++ b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityReport.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace GenLauncherGO.Core.Integrity.Models;
+
+///
+/// Contains all issues found while verifying active launch content.
+///
+public sealed record ContentIntegrityReport
+{
+ public ContentIntegrityReport(IReadOnlyList issues)
+ {
+ ArgumentNullException.ThrowIfNull(issues);
+ Issues = Array.AsReadOnly(issues.ToArray());
+ }
+
+ public IReadOnlyList Issues { get; }
+
+ public bool HasIssues => Issues.Count > 0;
+
+ public bool HasUnknownLegacyIssues => Issues.Any(issue => issue.Action == IntegrityIssueAction.TrustAsManual);
+
+ public bool HasBlockingIssues => Issues.Any(issue => issue.Action == IntegrityIssueAction.Block);
+}
diff --git a/GenLauncherGO.Core/Integrity/Models/ContentIntegrityTarget.cs b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityTarget.cs
new file mode 100644
index 00000000..e6721678
--- /dev/null
+++ b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityTarget.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Collections.Frozen;
+using System.Collections.Generic;
+using System.Linq;
+using GenLauncherGO.Core.IO;
+
+namespace GenLauncherGO.Core.Integrity.Models;
+
+///
+/// Describes one launcher-owned directory that must be verified.
+///
+public sealed record ContentIntegrityTarget
+{
+ public ContentIntegrityTarget(
+ string id,
+ string displayName,
+ string rootDirectory,
+ ContentSourceKind sourceKind,
+ IReadOnlySet ignoredRelativePaths)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(id);
+ ArgumentException.ThrowIfNullOrWhiteSpace(displayName);
+ ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory);
+ ArgumentNullException.ThrowIfNull(ignoredRelativePaths);
+
+ Id = id;
+ DisplayName = displayName;
+ RootDirectory = rootDirectory;
+ SourceKind = sourceKind;
+ IgnoredRelativePaths = ignoredRelativePaths
+ .Select(LexicalPath.NormalizeRelativePath)
+ .ToFrozenSet(StringComparer.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Gets the stable identifier used for snapshot persistence.
+ ///
+ public string Id { get; init; }
+
+ public string DisplayName { get; init; }
+
+ public string RootDirectory { get; init; }
+
+ public ContentSourceKind SourceKind { get; init; }
+
+ ///
+ /// Gets known owned paths that belong to inactive content and must be preserved without verification.
+ ///
+ public IReadOnlySet IgnoredRelativePaths { get; }
+}
diff --git a/GenLauncherGO.Core/Integrity/Models/ContentSourceKind.cs b/GenLauncherGO.Core/Integrity/Models/ContentSourceKind.cs
new file mode 100644
index 00000000..cb864e5d
--- /dev/null
+++ b/GenLauncherGO.Core/Integrity/Models/ContentSourceKind.cs
@@ -0,0 +1,27 @@
+namespace GenLauncherGO.Core.Integrity.Models;
+
+///
+/// Describes the authoritative source for installed launcher content.
+///
+public enum ContentSourceKind
+{
+ ///
+ /// The source of the installed content has not yet been classified.
+ ///
+ UnknownLegacy,
+
+ ///
+ /// The content is managed from an S3-compatible remote manifest.
+ ///
+ ManagedS3,
+
+ ///
+ /// The content is managed from a remotely downloaded package file.
+ ///
+ ManagedSingleFile,
+
+ ///
+ /// The content was manually imported or explicitly trusted by the user.
+ ///
+ Manual,
+}
diff --git a/GenLauncherGO.Core/Integrity/Models/IntegrityIssueAction.cs b/GenLauncherGO.Core/Integrity/Models/IntegrityIssueAction.cs
new file mode 100644
index 00000000..7bcd139e
--- /dev/null
+++ b/GenLauncherGO.Core/Integrity/Models/IntegrityIssueAction.cs
@@ -0,0 +1,37 @@
+namespace GenLauncherGO.Core.Integrity.Models;
+
+///
+/// Describes the resolution offered for an integrity issue.
+///
+public enum IntegrityIssueAction
+{
+ ///
+ /// Launch remains blocked and no automatic resolution is available.
+ ///
+ Block,
+
+ ///
+ /// The unexpected managed entry will be deleted.
+ ///
+ Delete,
+
+ ///
+ /// The managed content will be repaired from its remote manifest.
+ ///
+ Repair,
+
+ ///
+ /// The managed package will be downloaded and installed again.
+ ///
+ Redownload,
+
+ ///
+ /// The current manual content will replace its trusted snapshot.
+ ///
+ Absorb,
+
+ ///
+ /// The legacy content will be permanently classified and snapshotted as manual content.
+ ///
+ TrustAsManual,
+}
diff --git a/GenLauncherGO.Core/Integrity/Models/IntegrityIssueKind.cs b/GenLauncherGO.Core/Integrity/Models/IntegrityIssueKind.cs
new file mode 100644
index 00000000..eaeb36e8
--- /dev/null
+++ b/GenLauncherGO.Core/Integrity/Models/IntegrityIssueKind.cs
@@ -0,0 +1,42 @@
+namespace GenLauncherGO.Core.Integrity.Models;
+
+///
+/// Describes a detected content-integrity problem.
+///
+public enum IntegrityIssueKind
+{
+ ///
+ /// No trusted snapshot exists for the content.
+ ///
+ Untracked,
+
+ ///
+ /// A required file is missing.
+ ///
+ MissingFile,
+
+ ///
+ /// A file differs from its trusted SHA-256 snapshot.
+ ///
+ ModifiedFile,
+
+ ///
+ /// A file is present but is not part of the trusted snapshot.
+ ///
+ UnexpectedFile,
+
+ ///
+ /// An unexpected empty directory is present.
+ ///
+ EmptyDirectory,
+
+ ///
+ /// A reparse point or symbolic link was found inside verified content.
+ ///
+ UnsafeLink,
+
+ ///
+ /// Verification could not complete for an entry.
+ ///
+ VerificationError,
+}
diff --git a/GenLauncherGO.Core/Launching/Contracts/IGameExecutableDiscoveryService.cs b/GenLauncherGO.Core/Launching/Contracts/IGameExecutableDiscoveryService.cs
new file mode 100644
index 00000000..da44e9ac
--- /dev/null
+++ b/GenLauncherGO.Core/Launching/Contracts/IGameExecutableDiscoveryService.cs
@@ -0,0 +1,25 @@
+using System.Collections.Generic;
+using GenLauncherGO.Core.Launching.Models;
+
+namespace GenLauncherGO.Core.Launching.Contracts;
+
+///
+/// Discovers game and World Builder executables available to the current launcher session.
+///
+public interface IGameExecutableDiscoveryService
+{
+ ///
+ /// Gets the built-in game client executables for the active game installation.
+ ///
+ IReadOnlyList GetGameClients();
+
+ ///
+ /// Gets the built-in World Builder executables for the active game installation.
+ ///
+ IReadOnlyList GetWorldBuilders();
+
+ ///
+ /// Determines whether a root-level executable file name is currently available and safe to launch.
+ ///
+ bool IsExecutableAvailable(string? executableName);
+}
diff --git a/GenLauncherGO.Core/Launching/Contracts/IGameProcessLaunchOperation.cs b/GenLauncherGO.Core/Launching/Contracts/IGameProcessLaunchOperation.cs
new file mode 100644
index 00000000..0f7dac43
--- /dev/null
+++ b/GenLauncherGO.Core/Launching/Contracts/IGameProcessLaunchOperation.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Threading.Tasks;
+
+namespace GenLauncherGO.Core.Launching.Contracts;
+
+///
+/// Represents a launched game or tool process family that can be observed and force closed.
+///
+public interface IGameProcessLaunchOperation
+{
+ ///
+ /// Gets the executable name for the currently running tracked process.
+ ///
+ string CurrentExecutableName { get; }
+
+ ///
+ /// Occurs when changes.
+ ///
+ event EventHandler? CurrentExecutableNameChanged;
+
+ ///
+ /// Gets the task that completes when every tracked process in the launched process family has exited.
+ ///
+ Task Completion { get; }
+
+ ///
+ /// Force closes the tracked process family.
+ ///
+ void ForceClose();
+}
diff --git a/GenLauncherGO.Core/Launching/Contracts/IGameProcessLauncher.cs b/GenLauncherGO.Core/Launching/Contracts/IGameProcessLauncher.cs
new file mode 100644
index 00000000..83a346f8
--- /dev/null
+++ b/GenLauncherGO.Core/Launching/Contracts/IGameProcessLauncher.cs
@@ -0,0 +1,18 @@
+using System.Threading;
+using System.Threading.Tasks;
+using GenLauncherGO.Core.Launching.Models;
+
+namespace GenLauncherGO.Core.Launching.Contracts;
+
+///
+/// Launches supported game and tool processes for a prepared game directory.
+///
+public interface IGameProcessLauncher
+{
+ ///
+ /// Starts the requested game or tool process and returns an operation that tracks its process family.
+ ///
+ Task StartAsync(
+ GameLaunchRequest request,
+ CancellationToken cancellationToken);
+}
diff --git a/GenLauncherGO.Core/Launching/Contracts/ILaunchContentIntegrityResolutionService.cs b/GenLauncherGO.Core/Launching/Contracts/ILaunchContentIntegrityResolutionService.cs
new file mode 100644
index 00000000..0de2ce72
--- /dev/null
+++ b/GenLauncherGO.Core/Launching/Contracts/ILaunchContentIntegrityResolutionService.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using GenLauncherGO.Core.Launching.Models;
+
+namespace GenLauncherGO.Core.Launching.Contracts;
+
+///
+/// Verifies and resolves launch-readiness integrity state for selected launcher content.
+///
+public interface ILaunchContentIntegrityResolutionService
+{
+ ///
+ /// Verifies active launch content and returns the target contexts used for any later resolution.
+ ///
+ Task VerifyAsync(
+ LaunchContentIntegrityTargetRequest request,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Captures initial snapshots for matching managed remote caches and reports whether any target was initialized.
+ ///
+ Task InitializeUntrackedManagedCachesAsync(
+ LaunchContentIntegrityResolutionRequest request,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Applies confirmed launch-integrity resolutions, including snapshots, cleanup, package repair, and cache refresh.
+ ///
+ Task ResolveAsync(
+ LaunchContentIntegrityResolutionRequest request,
+ IProgress? progress,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Marks a manually imported version as manual content and captures its initial package and cache snapshots.
+ ///
+ Task RegisterManualImportAsync(
+ LaunchContentIntegrityVersionRequest request,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Captures initial snapshots for a newly installed managed remote version.
+ ///
+ Task CaptureManagedInstallSnapshotAsync(
+ LaunchContentIntegrityVersionRequest request,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Captures a trusted snapshot for a manually managed cached image target.
+ ///
+ Task CaptureManualImageSnapshotAsync(
+ LaunchContentIntegrityVersionRequest request,
+ CancellationToken cancellationToken);
+}
diff --git a/GenLauncherGO.Core/Launching/Contracts/ILaunchPreparationService.cs b/GenLauncherGO.Core/Launching/Contracts/ILaunchPreparationService.cs
new file mode 100644
index 00000000..bf16e65a
--- /dev/null
+++ b/GenLauncherGO.Core/Launching/Contracts/ILaunchPreparationService.cs
@@ -0,0 +1,35 @@
+using System.Threading;
+using GenLauncherGO.Core.Launching.Models;
+using GenLauncherGO.Core.Startup;
+
+namespace GenLauncherGO.Core.Launching.Contracts;
+
+///
+/// Prepares, cleans, and recovers launch-time game-directory state.
+///
+public interface ILaunchPreparationService
+{
+ ///
+ /// Prepares the game directory for launching the selected content.
+ ///
+ /// when preparation completed successfully.
+ bool Prepare(
+ LaunchPreparationRequest request,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Cleans launch-time game-directory state after a launched process exits.
+ ///
+ /// when cleanup completed successfully.
+ bool Cleanup(
+ LauncherPaths paths,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Recovers interrupted launch-time game-directory state during launcher startup.
+ ///
+ /// when recovery completed successfully.
+ bool Recover(
+ LauncherPaths paths,
+ CancellationToken cancellationToken);
+}
diff --git a/GenLauncherGO.Core/Launching/LauncherGameArgumentService.cs b/GenLauncherGO.Core/Launching/LauncherGameArgumentService.cs
new file mode 100644
index 00000000..beaac3be
--- /dev/null
+++ b/GenLauncherGO.Core/Launching/LauncherGameArgumentService.cs
@@ -0,0 +1,143 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace GenLauncherGO.Core.Launching;
+
+///
+/// Updates game executable command-line arguments controlled by launcher settings.
+///
+public static class LauncherGameArgumentService
+{
+ ///
+ /// The argument that starts the game in windowed mode.
+ ///
+ public const string WindowedArgument = "-win";
+
+ ///
+ /// The argument that skips the normal game startup sequence.
+ ///
+ public const string QuickStartArgument = "-quickstart";
+
+ ///
+ /// Adds or removes a command-line argument.
+ ///
+ public static string SetArgumentEnabled(string arguments, string argument, bool enabled)
+ {
+ return enabled
+ ? AddArgument(arguments, argument)
+ : RemoveArgument(arguments, argument);
+ }
+
+ ///
+ /// Determines whether the argument string contains a standalone command-line argument.
+ ///
+ public static bool ContainsArgument(string arguments, string argument)
+ {
+ EnsureArgument(argument);
+
+ return EnumerateTokens(arguments)
+ .Any(token => String.Equals(token.Value, argument, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static string AddArgument(string arguments, string argument)
+ {
+ EnsureArgument(argument);
+
+ if (ContainsArgument(arguments, argument))
+ {
+ return arguments ?? string.Empty;
+ }
+
+ if (string.IsNullOrWhiteSpace(arguments))
+ {
+ return argument;
+ }
+
+ return $"{arguments.Trim()} {argument}";
+ }
+
+ private static string RemoveArgument(string arguments, string argument)
+ {
+ EnsureArgument(argument);
+
+ if (string.IsNullOrWhiteSpace(arguments))
+ {
+ return string.Empty;
+ }
+
+ return String.Join(
+ ' ',
+ EnumerateTokens(arguments)
+ .Where(token => !String.Equals(token.Value, argument, StringComparison.OrdinalIgnoreCase))
+ .Select(token => token.Raw));
+ }
+
+ private static void EnsureArgument(string argument)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(argument);
+ }
+
+ ///
+ /// Enumerates whitespace-delimited command-line tokens while preserving quoted token text.
+ ///
+ private static IEnumerable<(string Raw, string Value)> EnumerateTokens(string arguments)
+ {
+ if (string.IsNullOrWhiteSpace(arguments))
+ {
+ yield break;
+ }
+
+ int index = 0;
+ while (index < arguments.Length)
+ {
+ while (index < arguments.Length && Char.IsWhiteSpace(arguments[index]))
+ {
+ index++;
+ }
+
+ if (index >= arguments.Length)
+ {
+ yield break;
+ }
+
+ int start = index;
+ bool isQuoted = arguments[index] == '"';
+ bool inQuotes = isQuoted;
+ if (isQuoted)
+ {
+ index++;
+ }
+
+ while (index < arguments.Length)
+ {
+ char current = arguments[index];
+ if (current == '"')
+ {
+ inQuotes = !inQuotes;
+ index++;
+ continue;
+ }
+
+ if (!inQuotes && Char.IsWhiteSpace(current))
+ {
+ break;
+ }
+
+ index++;
+ }
+
+ int end = index;
+ string raw = arguments[start..end];
+ string value = GetComparableTokenValue(raw, isQuoted);
+ yield return (raw, value);
+ }
+ }
+
+ private static string GetComparableTokenValue(string raw, bool isQuoted)
+ {
+ return isQuoted && raw.Length >= 2 && raw[^1] == '"'
+ ? raw[1..^1]
+ : raw;
+ }
+}
diff --git a/GenLauncherGO.Core/Launching/Models/GameClientExecutable.cs b/GenLauncherGO.Core/Launching/Models/GameClientExecutable.cs
new file mode 100644
index 00000000..d5f4ff7c
--- /dev/null
+++ b/GenLauncherGO.Core/Launching/Models/GameClientExecutable.cs
@@ -0,0 +1,22 @@
+using GenLauncherGO.Core.Startup;
+
+namespace GenLauncherGO.Core.Launching.Models;
+
+public sealed class GameClientExecutable
+{
+ public GameClientExecutable(
+ string executableName,
+ GameClientExecutableKind kind,
+ bool isAvailable)
+ {
+ ExecutableName = LauncherFileSystemLayout.NormalizeExecutableFileName(executableName);
+ Kind = kind;
+ IsAvailable = isAvailable;
+ }
+
+ public string ExecutableName { get; }
+
+ public GameClientExecutableKind Kind { get; }
+
+ public bool IsAvailable { get; }
+}
diff --git a/GenLauncherGO.Core/Launching/Models/GameClientExecutableKind.cs b/GenLauncherGO.Core/Launching/Models/GameClientExecutableKind.cs
new file mode 100644
index 00000000..7852a348
--- /dev/null
+++ b/GenLauncherGO.Core/Launching/Models/GameClientExecutableKind.cs
@@ -0,0 +1,8 @@
+namespace GenLauncherGO.Core.Launching.Models;
+
+public enum GameClientExecutableKind
+{
+ Community,
+
+ GeneralsOnline,
+}
diff --git a/GenLauncherGO.Core/Launching/Models/GameLaunchRequest.cs b/GenLauncherGO.Core/Launching/Models/GameLaunchRequest.cs
new file mode 100644
index 00000000..36020953
--- /dev/null
+++ b/GenLauncherGO.Core/Launching/Models/GameLaunchRequest.cs
@@ -0,0 +1,57 @@
+using System;
+using GenLauncherGO.Core.IO;
+using GenLauncherGO.Core.Startup;
+
+namespace GenLauncherGO.Core.Launching.Models;
+
+///
+/// Describes a game or World Builder process launch request.
+///
+public sealed record GameLaunchRequest
+{
+ private GameLaunchRequest(
+ GameLaunchTargetKind targetKind,
+ string gameDirectory,
+ string executableName,
+ string? arguments)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(gameDirectory);
+
+ TargetKind = targetKind;
+ GameDirectory = LexicalPath.NormalizeFullPath(gameDirectory);
+ ExecutableName = LauncherFileSystemLayout.NormalizeExecutableFileName(executableName);
+ Arguments = arguments ?? string.Empty;
+ }
+
+ public GameLaunchTargetKind TargetKind { get; }
+
+ public string GameDirectory { get; }
+
+ public string ExecutableName { get; }
+
+ public string Arguments { get; }
+
+ public static GameLaunchRequest ForGameClient(
+ string gameDirectory,
+ string executableName,
+ string? arguments)
+ {
+ return new GameLaunchRequest(
+ GameLaunchTargetKind.GameClient,
+ gameDirectory,
+ executableName,
+ arguments);
+ }
+
+ public static GameLaunchRequest ForWorldBuilder(
+ string gameDirectory,
+ string executableName,
+ string? arguments)
+ {
+ return new GameLaunchRequest(
+ GameLaunchTargetKind.WorldBuilder,
+ gameDirectory,
+ executableName,
+ arguments);
+ }
+}
diff --git a/GenLauncherGO.Core/Launching/Models/GameLaunchTargetKind.cs b/GenLauncherGO.Core/Launching/Models/GameLaunchTargetKind.cs
new file mode 100644
index 00000000..5474695b
--- /dev/null
+++ b/GenLauncherGO.Core/Launching/Models/GameLaunchTargetKind.cs
@@ -0,0 +1,8 @@
+namespace GenLauncherGO.Core.Launching.Models;
+
+public enum GameLaunchTargetKind
+{
+ GameClient,
+
+ WorldBuilder
+}
diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionProgress.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionProgress.cs
new file mode 100644
index 00000000..1cba5548
--- /dev/null
+++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionProgress.cs
@@ -0,0 +1,42 @@
+using System;
+using GenLauncherGO.Core.Updating.Models;
+
+namespace GenLauncherGO.Core.Launching.Models;
+
+///
+/// Reports package progress or completion for one launch-integrity resolution target.
+///
+public sealed record LaunchContentIntegrityResolutionProgress
+{
+ private LaunchContentIntegrityResolutionProgress(
+ string targetId,
+ PackageUpdateProgress? packageProgress,
+ bool completed)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(targetId);
+
+ TargetId = targetId;
+ PackageProgress = packageProgress;
+ Completed = completed;
+ }
+
+ public string TargetId { get; }
+
+ public PackageUpdateProgress? PackageProgress { get; }
+
+ public bool Completed { get; }
+
+ public static LaunchContentIntegrityResolutionProgress Package(
+ string targetId,
+ PackageUpdateProgress packageProgress)
+ {
+ ArgumentNullException.ThrowIfNull(packageProgress);
+
+ return new LaunchContentIntegrityResolutionProgress(targetId, packageProgress, completed: false);
+ }
+
+ public static LaunchContentIntegrityResolutionProgress Complete(string targetId)
+ {
+ return new LaunchContentIntegrityResolutionProgress(targetId, packageProgress: null, completed: true);
+ }
+}
diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionRequest.cs
new file mode 100644
index 00000000..29ef1538
--- /dev/null
+++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionRequest.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using GenLauncherGO.Core.Integrity.Models;
+using GenLauncherGO.Core.Startup;
+
+namespace GenLauncherGO.Core.Launching.Models;
+
+public sealed record LaunchContentIntegrityResolutionRequest
+{
+ public LaunchContentIntegrityResolutionRequest(
+ LauncherPaths paths,
+ ContentIntegrityReport report,
+ IReadOnlyList targetContexts)
+ {
+ ArgumentNullException.ThrowIfNull(paths);
+ ArgumentNullException.ThrowIfNull(report);
+ ArgumentNullException.ThrowIfNull(targetContexts);
+
+ Paths = paths;
+ Report = report;
+ TargetContexts = targetContexts.ToArray();
+ }
+
+ public LauncherPaths Paths { get; init; }
+
+ public ContentIntegrityReport Report { get; init; }
+
+ public IReadOnlyList TargetContexts { get; init; }
+}
diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetContext.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetContext.cs
new file mode 100644
index 00000000..dc71cd3a
--- /dev/null
+++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetContext.cs
@@ -0,0 +1,27 @@
+using System;
+using GenLauncherGO.Core.Integrity.Models;
+using GenLauncherGO.Core.Mods.Models;
+
+namespace GenLauncherGO.Core.Launching.Models;
+
+public sealed record LaunchContentIntegrityTargetContext
+{
+ public LaunchContentIntegrityTargetContext(
+ ContentIntegrityTarget target,
+ LauncherContentVersion version,
+ bool isCache)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+ ArgumentNullException.ThrowIfNull(version);
+
+ Target = target;
+ Version = version;
+ IsCache = isCache;
+ }
+
+ public ContentIntegrityTarget Target { get; init; }
+
+ public LauncherContentVersion Version { get; init; }
+
+ public bool IsCache { get; init; }
+}
diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetRequest.cs
new file mode 100644
index 00000000..785e31bd
--- /dev/null
+++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetRequest.cs
@@ -0,0 +1,35 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using GenLauncherGO.Core.Mods.Models;
+using GenLauncherGO.Core.Startup;
+
+namespace GenLauncherGO.Core.Launching.Models;
+
+public sealed record LaunchContentIntegrityTargetRequest
+{
+ public LaunchContentIntegrityTargetRequest(
+ LauncherPaths paths,
+ IReadOnlyList activeVersions,
+ IReadOnlyList allVersions,
+ string cacheDisplayNameSuffix)
+ {
+ ArgumentNullException.ThrowIfNull(paths);
+ ArgumentNullException.ThrowIfNull(activeVersions);
+ ArgumentNullException.ThrowIfNull(allVersions);
+ ArgumentException.ThrowIfNullOrWhiteSpace(cacheDisplayNameSuffix);
+
+ Paths = paths;
+ ActiveVersions = activeVersions.ToArray();
+ AllVersions = allVersions.ToArray();
+ CacheDisplayNameSuffix = cacheDisplayNameSuffix;
+ }
+
+ public LauncherPaths Paths { get; init; }
+
+ public IReadOnlyList ActiveVersions { get; init; }
+
+ public IReadOnlyList AllVersions { get; init; }
+
+ public string CacheDisplayNameSuffix { get; init; }
+}
diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVerificationResult.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVerificationResult.cs
new file mode 100644
index 00000000..e8e30835
--- /dev/null
+++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVerificationResult.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using GenLauncherGO.Core.Integrity.Models;
+
+namespace GenLauncherGO.Core.Launching.Models;
+
+public sealed record LaunchContentIntegrityVerificationResult
+{
+ public LaunchContentIntegrityVerificationResult(
+ ContentIntegrityReport report,
+ IReadOnlyList targetContexts)
+ {
+ ArgumentNullException.ThrowIfNull(report);
+ ArgumentNullException.ThrowIfNull(targetContexts);
+
+ Report = report;
+ TargetContexts = targetContexts.ToArray();
+ }
+
+ public ContentIntegrityReport Report { get; init; }
+
+ public IReadOnlyList TargetContexts { get; init; }
+}
diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVersionRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVersionRequest.cs
new file mode 100644
index 00000000..4a87a944
--- /dev/null
+++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVersionRequest.cs
@@ -0,0 +1,35 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using GenLauncherGO.Core.Mods.Models;
+using GenLauncherGO.Core.Startup;
+
+namespace GenLauncherGO.Core.Launching.Models;
+
+public sealed record LaunchContentIntegrityVersionRequest
+{
+ public LaunchContentIntegrityVersionRequest(
+ LauncherPaths paths,
+ LauncherContentVersion version,
+ IReadOnlyList allVersions,
+ string cacheDisplayNameSuffix)
+ {
+ ArgumentNullException.ThrowIfNull(paths);
+ ArgumentNullException.ThrowIfNull(version);
+ ArgumentNullException.ThrowIfNull(allVersions);
+ ArgumentException.ThrowIfNullOrWhiteSpace(cacheDisplayNameSuffix);
+
+ Paths = paths;
+ Version = version;
+ AllVersions = allVersions.ToArray();
+ CacheDisplayNameSuffix = cacheDisplayNameSuffix;
+ }
+
+ public LauncherPaths Paths { get; init; }
+
+ public LauncherContentVersion Version { get; init; }
+
+ public IReadOnlyList AllVersions { get; init; }
+
+ public string CacheDisplayNameSuffix { get; init; }
+}
diff --git a/GenLauncherGO.Core/Launching/Models/LaunchPreparationRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchPreparationRequest.cs
new file mode 100644
index 00000000..7afb3469
--- /dev/null
+++ b/GenLauncherGO.Core/Launching/Models/LaunchPreparationRequest.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using GenLauncherGO.Core.Mods.Models;
+using GenLauncherGO.Core.Startup;
+
+namespace GenLauncherGO.Core.Launching.Models;
+
+///
+/// Carries selected installed content and the base-script deployment policy from UI to Infrastructure.
+///
+public sealed record LaunchPreparationRequest
+{
+ public LaunchPreparationRequest(
+ LauncherPaths paths,
+ IReadOnlyList versions,
+ bool disableBaseGameScriptFiles)
+ {
+ ArgumentNullException.ThrowIfNull(paths);
+ ArgumentNullException.ThrowIfNull(versions);
+
+ Paths = paths;
+ Versions = versions.ToArray();
+ DisableBaseGameScriptFiles = disableBaseGameScriptFiles;
+ }
+
+ public LauncherPaths Paths { get; init; }
+
+ public IReadOnlyList Versions { get; init; }
+
+ public bool DisableBaseGameScriptFiles { get; init; }
+}
diff --git a/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutable.cs b/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutable.cs
new file mode 100644
index 00000000..538c943c
--- /dev/null
+++ b/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutable.cs
@@ -0,0 +1,22 @@
+using GenLauncherGO.Core.Startup;
+
+namespace GenLauncherGO.Core.Launching.Models;
+
+public sealed class WorldBuilderExecutable
+{
+ public WorldBuilderExecutable(
+ string executableName,
+ WorldBuilderExecutableKind kind,
+ bool isAvailable)
+ {
+ ExecutableName = LauncherFileSystemLayout.NormalizeExecutableFileName(executableName);
+ Kind = kind;
+ IsAvailable = isAvailable;
+ }
+
+ public string ExecutableName { get; }
+
+ public WorldBuilderExecutableKind Kind { get; }
+
+ public bool IsAvailable { get; }
+}
diff --git a/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutableKind.cs b/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutableKind.cs
new file mode 100644
index 00000000..a1d7d16e
--- /dev/null
+++ b/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutableKind.cs
@@ -0,0 +1,8 @@
+namespace GenLauncherGO.Core.Launching.Models;
+
+public enum WorldBuilderExecutableKind
+{
+ Vanilla,
+
+ Community,
+}
diff --git a/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalog.cs b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalog.cs
new file mode 100644
index 00000000..eae97ed0
--- /dev/null
+++ b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalog.cs
@@ -0,0 +1,90 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using GenLauncherGO.Core.Mods.Exceptions;
+using GenLauncherGO.Core.Mods.Models;
+
+namespace GenLauncherGO.Core.Mods.Contracts;
+
+///
+/// Owns the active launcher content aggregate and coordinates its loading, mutation, and persistence.
+///
+public interface ILauncherContentCatalog
+{
+ ///
+ /// Gets the active in-memory launcher content aggregate.
+ ///
+ LauncherData Data { get; }
+
+ ///
+ /// Gets the active advertising content, or when none is available.
+ ///
+ LauncherContentVersion? Advertising { get; }
+
+ ///
+ /// Gets modification names advertised by the remote repository, or when unavailable.
+ ///
+ IReadOnlyList? RepositoryModificationNames { get; }
+
+ ///
+ /// Initializes the catalog from local state and, when available, the remote repository.
+ ///
+ Task InitDataAsync(
+ LauncherContentCatalogInitializationRequest request,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Reads add-ons and patches that belong to the original game.
+ ///
+ Task ReadOriginalGameAddonsAndPatchesAsync(CancellationToken cancellationToken);
+
+ ///
+ /// Reads one repository modification's normalized metadata without adding it to the active catalog.
+ ///
+ Task GetRepositoryModificationMetadataAsync(
+ string name,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Downloads one repository modification, caches its images, and adds it to the active catalog.
+ ///
+ Task AddRepositoryModificationAsync(
+ string name,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Reads remote patches and add-ons for a modification.
+ ///
+ Task ReadPatchesAndAddonsForModAsync(
+ LauncherContentKey modificationKey,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Deletes one installed version and reconciles local state while retaining available catalog metadata.
+ ///
+ void UninstallVersion(LauncherContentKey contentKey);
+
+ ///
+ /// Deletes one installed version, discards that version's catalog metadata, and reconciles local state.
+ ///
+ void DiscardVersion(LauncherContentKey contentKey);
+
+ ///
+ /// Deletes all installed files for a content card, discards the whole card, and reconciles local state.
+ ///
+ void DiscardContent(LauncherContentKey contentKey);
+
+ ///
+ /// Refreshes the catalog from locally installed content and removes stale local-only cards.
+ ///
+ void UpdateLocalModificationsData();
+
+ ///
+ /// Saves the current catalog selection and installed state.
+ ///
+ ///
+ /// Thrown when the current state cannot be persisted. The in-memory catalog remains authoritative so callers can
+ /// retry without rolling back completed file-system work.
+ ///
+ void SaveLauncherData();
+}
diff --git a/GenLauncherGO.Core/Mods/Contracts/IManualModificationImporter.cs b/GenLauncherGO.Core/Mods/Contracts/IManualModificationImporter.cs
new file mode 100644
index 00000000..61c10803
--- /dev/null
+++ b/GenLauncherGO.Core/Mods/Contracts/IManualModificationImporter.cs
@@ -0,0 +1,19 @@
+using System.Collections.Generic;
+using System.Threading;
+using GenLauncherGO.Core.Mods.Models;
+
+namespace GenLauncherGO.Core.Mods.Contracts;
+
+///
+/// Imports user-selected modification files into a launcher-managed content folder.
+///
+public interface IManualModificationImporter
+{
+ ///
+ /// Imports the source files into the explicitly owned destination directory.
+ ///
+ void Import(
+ IReadOnlyList sourceFilePaths,
+ OwnedContentPath destinationPath,
+ CancellationToken cancellationToken = default);
+}
diff --git a/GenLauncherGO.Core/Mods/Contracts/IModificationImageFileService.cs b/GenLauncherGO.Core/Mods/Contracts/IModificationImageFileService.cs
new file mode 100644
index 00000000..46be946c
--- /dev/null
+++ b/GenLauncherGO.Core/Mods/Contracts/IModificationImageFileService.cs
@@ -0,0 +1,39 @@
+using System.Threading;
+using System.Threading.Tasks;
+using GenLauncherGO.Core.Mods.Models;
+
+namespace GenLauncherGO.Core.Mods.Contracts;
+
+///
+/// Provides launcher modification image cache file operations.
+///
+public interface IModificationImageFileService
+{
+ ///
+ /// Finds an existing cached modification image with any extension.
+ ///
+ string? FindExistingImageFilePath(string modificationName, string imageBaseName);
+
+ ///
+ /// Counts cached image files for a modification.
+ ///
+ int CountImageFiles(string modificationName);
+
+ ///
+ /// Determines whether a path inside the active launcher-owned image cache points to an existing file.
+ ///
+ bool ImageExists(string? imageFilePath);
+
+ ///
+ /// Removes cached images for a logical image identity and reports whether no matching image remains.
+ ///
+ /// The implementation resolves the logical identity inside the active image cache ownership boundary.
+ bool TryDeleteImage(string modificationName, string imageBaseName);
+
+ ///
+ /// Replaces cached images for a modification image base name with a selected source image.
+ ///
+ Task ReplaceImageAsync(
+ ModificationImageReplacementRequest request,
+ CancellationToken cancellationToken);
+}
diff --git a/GenLauncherGO.Core/Mods/Exceptions/LauncherContentPersistenceException.cs b/GenLauncherGO.Core/Mods/Exceptions/LauncherContentPersistenceException.cs
new file mode 100644
index 00000000..913e1606
--- /dev/null
+++ b/GenLauncherGO.Core/Mods/Exceptions/LauncherContentPersistenceException.cs
@@ -0,0 +1,12 @@
+using System;
+
+namespace GenLauncherGO.Core.Mods.Exceptions;
+
+public sealed class LauncherContentPersistenceException : Exception
+{
+ public LauncherContentPersistenceException(Exception innerException)
+ : base("Launcher content state could not be persisted.", innerException)
+ {
+ ArgumentNullException.ThrowIfNull(innerException);
+ }
+}
diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContent.cs b/GenLauncherGO.Core/Mods/Models/LauncherContent.cs
new file mode 100644
index 00000000..2473eb57
--- /dev/null
+++ b/GenLauncherGO.Core/Mods/Models/LauncherContent.cs
@@ -0,0 +1,162 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using GenLauncherGO.Core.Integrity.Models;
+
+namespace GenLauncherGO.Core.Mods.Models;
+
+///
+/// Owns one launcher content card, its versions, and the single policy for merging catalog and local state.
+///
+public sealed class LauncherContent
+{
+ private readonly List _versions = new();
+
+ public LauncherContent(LauncherContentVersion version)
+ {
+ ArgumentNullException.ThrowIfNull(version);
+
+ ContentKey = version.ContentKey.WithoutVersion();
+ Versions = _versions.AsReadOnly();
+ AddOrMergeVersion(version);
+ }
+
+ public LauncherContentKey ContentKey { get; }
+
+ public IReadOnlyList Versions { get; }
+
+ ///
+ /// Gets the latest known version, which is the card's presentation metadata authority.
+ ///
+ public LauncherContentVersion LatestVersion =>
+ _versions.OrderBy(version => version).Last();
+
+ ///
+ /// Gets the latest installed version, or when no version is installed.
+ ///
+ public LauncherContentVersion? LatestInstalledVersion =>
+ _versions
+ .Where(version => version.Installation.Installed)
+ .OrderBy(version => version)
+ .LastOrDefault();
+
+ public ModificationType ModificationType => ContentKey.ContentType;
+
+ public string Name => ContentKey.Name;
+
+ public bool Installed => _versions.Any(version => version.Installation.Installed);
+
+ public bool IsSelected { get; set; }
+
+ public int NumberInList { get; set; }
+
+ ///
+ /// Gets the persisted installed selection, falling back to the earliest installed or known version.
+ ///
+ ///
+ /// The fallback preserves the launcher's legacy behavior when saved selection state is missing.
+ ///
+ public LauncherContentVersion? GetSelectedVersion()
+ {
+ return _versions.FirstOrDefault(version =>
+ version.Installation.Installed &&
+ version.Installation.IsSelected) ??
+ _versions
+ .Where(version => version.Installation.Installed)
+ .OrderBy(version => version)
+ .FirstOrDefault() ??
+ _versions
+ .OrderBy(version => version)
+ .FirstOrDefault();
+ }
+
+ ///
+ /// Adds a new version or merges metadata and local installation state into the matching version.
+ ///
+ internal void AddOrMergeVersion(LauncherContentVersion version)
+ {
+ ArgumentNullException.ThrowIfNull(version);
+
+ if (version.ContentKey.WithoutVersion() != ContentKey)
+ {
+ throw new ArgumentException(
+ "A launcher content version cannot be merged into a different content card.",
+ nameof(version));
+ }
+
+ int versionIndex = _versions.FindIndex(candidate =>
+ candidate.ContentKey == version.ContentKey);
+ if (versionIndex < 0)
+ {
+ _versions.Add(version);
+ }
+ else
+ {
+ _versions[versionIndex] = MergeVersion(_versions[versionIndex], version);
+ }
+
+ IsSelected |= version.Installation.IsSelected;
+ }
+
+ ///
+ /// Removes the matching version from this content card.
+ ///
+ internal bool RemoveVersion(LauncherContentKey contentKey)
+ {
+ int versionIndex = _versions.FindIndex(candidate =>
+ candidate.ContentKey == contentKey);
+ if (versionIndex < 0)
+ {
+ return false;
+ }
+
+ _versions.RemoveAt(versionIndex);
+ return true;
+ }
+
+ ///
+ /// Applies the legacy-compatible catalog merge precedence once while retaining one shared local-state object.
+ ///
+ private static LauncherContentVersion MergeVersion(
+ LauncherContentVersion existing,
+ LauncherContentVersion incoming)
+ {
+ LauncherContentInstallation installation = existing.Installation;
+ installation.Installed |= incoming.Installation.Installed;
+ installation.IsSelected |= incoming.Installation.IsSelected;
+ if (installation.ContentSourceKind == ContentSourceKind.UnknownLegacy &&
+ incoming.Installation.ContentSourceKind != ContentSourceKind.UnknownLegacy)
+ {
+ installation.ContentSourceKind = incoming.Installation.ContentSourceKind;
+ }
+
+ var merged = new LauncherContentVersion(installation)
+ {
+ ModificationType = existing.ModificationType,
+ Name = existing.Name,
+ Version = existing.Version,
+ SimpleDownloadLink = FirstNonEmpty(existing.SimpleDownloadLink, incoming.SimpleDownloadLink),
+ UIImageSourceLink = FirstNonEmpty(existing.UIImageSourceLink, incoming.UIImageSourceLink),
+ DiscordLink = FirstNonEmpty(existing.DiscordLink, incoming.DiscordLink),
+ ModDBLink = FirstNonEmpty(existing.ModDBLink, incoming.ModDBLink),
+ NewsLink = FirstNonEmpty(existing.NewsLink, incoming.NewsLink),
+ ParentContentName = existing.ParentContentName,
+ S3HostLink = FirstNonEmpty(existing.S3HostLink, incoming.S3HostLink),
+ S3BucketName = FirstNonEmpty(existing.S3BucketName, incoming.S3BucketName),
+ S3FolderName = FirstNonEmpty(existing.S3FolderName, incoming.S3FolderName),
+ S3HostPublicKey = FirstNonEmpty(existing.S3HostPublicKey, incoming.S3HostPublicKey),
+ S3HostSecretKey = FirstNonEmpty(existing.S3HostSecretKey, incoming.S3HostSecretKey),
+ NetworkInfo = FirstNonEmpty(existing.NetworkInfo, incoming.NetworkInfo),
+ Deprecated = incoming.Deprecated,
+ SupportLink = FirstNonEmpty(existing.SupportLink, incoming.SupportLink),
+ };
+
+ installation.ContentSourceKind = merged.EffectiveContentSourceKind;
+ return merged;
+ }
+
+ private static string FirstNonEmpty(string existing, string incoming)
+ {
+ return !String.IsNullOrEmpty(existing) ? existing : incoming;
+ }
+}
diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentCatalogInitializationRequest.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentCatalogInitializationRequest.cs
new file mode 100644
index 00000000..1280f71e
--- /dev/null
+++ b/GenLauncherGO.Core/Mods/Models/LauncherContentCatalogInitializationRequest.cs
@@ -0,0 +1,11 @@
+using System;
+using GenLauncherGO.Core.Startup;
+
+namespace GenLauncherGO.Core.Mods.Models;
+
+///
+/// Initializes one game catalog; a missing remote manifest URI selects local-only mode.
+///
+public sealed record LauncherContentCatalogInitializationRequest(
+ Uri? RemoteManifestUri,
+ LauncherPaths Paths);
diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentInstallation.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentInstallation.cs
new file mode 100644
index 00000000..cabee70b
--- /dev/null
+++ b/GenLauncherGO.Core/Mods/Models/LauncherContentInstallation.cs
@@ -0,0 +1,19 @@
+using GenLauncherGO.Core.Integrity.Models;
+
+namespace GenLauncherGO.Core.Mods.Models;
+
+///
+/// Stores mutable local state for one launcher content version.
+///
+///
+/// Remote metadata is immutable on . Installation discovery, selection, and
+/// integrity trust decisions mutate this separate state and are the only values persisted locally for a version.
+///
+public sealed class LauncherContentInstallation
+{
+ public bool Installed { get; set; }
+
+ public bool IsSelected { get; set; }
+
+ public ContentSourceKind ContentSourceKind { get; set; } = ContentSourceKind.UnknownLegacy;
+}
diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentKey.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentKey.cs
new file mode 100644
index 00000000..17641514
--- /dev/null
+++ b/GenLauncherGO.Core/Mods/Models/LauncherContentKey.cs
@@ -0,0 +1,134 @@
+using System;
+
+namespace GenLauncherGO.Core.Mods.Models;
+
+///
+/// Identifies launcher content by type, parent identity, name, and version.
+///
+///
+/// Identity text retains its supplied representation and is compared using ordinal, case-insensitive semantics.
+/// Missing text is equivalent to an empty string. The original-game key is a stable nonlocalized relationship
+/// identity and must not be used as user-visible display text.
+///
+public readonly struct LauncherContentKey : IEquatable
+{
+ private readonly string? _parentIdentity;
+
+ private readonly string? _name;
+
+ private readonly string? _version;
+
+ public LauncherContentKey(
+ ModificationType contentType,
+ string? parentIdentity,
+ string? name,
+ string? version)
+ {
+ ContentType = contentType;
+ _parentIdentity = parentIdentity;
+ _name = name;
+ _version = version;
+ }
+
+ public static LauncherContentKey OriginalGame { get; } =
+ new(ModificationType.Mod, string.Empty, "Original Game", string.Empty);
+
+ ///
+ /// Creates the name-only identity used by the top-level modification catalog.
+ ///
+ public static LauncherContentKey ForModificationName(string? name)
+ {
+ return new LauncherContentKey(ModificationType.Mod, string.Empty, name, string.Empty);
+ }
+
+ public ModificationType ContentType { get; }
+
+ public string ParentIdentity => _parentIdentity ?? string.Empty;
+
+ public string Name => _name ?? string.Empty;
+
+ public string Version => _version ?? string.Empty;
+
+ ///
+ /// Gets the card identity for this content, omitting its version.
+ ///
+ internal LauncherContentKey WithoutVersion()
+ {
+ return new LauncherContentKey(ContentType, ParentIdentity, Name, string.Empty);
+ }
+
+ ///
+ /// Determines whether this content belongs to the supplied parent.
+ ///
+ public bool IsChildOf(LauncherContentKey parent)
+ {
+ return IdentityTextEquals(ParentIdentity, parent.Name);
+ }
+
+ ///
+ /// Determines whether this key has the supplied content name.
+ ///
+ public bool HasName(string? name)
+ {
+ return IdentityTextEquals(Name, name);
+ }
+
+ ///
+ /// Determines whether this key has the supplied version identity.
+ ///
+ public bool HasVersion(string? version)
+ {
+ return IdentityTextEquals(Version, version);
+ }
+
+ ///
+ /// Formats the legacy-compatible lowercase identity used by launcher-owned integrity records.
+ ///
+ public string ToStableString()
+ {
+ return string.Join(
+ ":",
+ ContentType,
+ ParentIdentity,
+ Name,
+ Version).ToLowerInvariant();
+ }
+
+ public bool Equals(LauncherContentKey other)
+ {
+ return ContentType == other.ContentType &&
+ IdentityTextEquals(ParentIdentity, other.ParentIdentity) &&
+ IdentityTextEquals(Name, other.Name) &&
+ IdentityTextEquals(Version, other.Version);
+ }
+
+ public override bool Equals(object? obj)
+ {
+ return obj is LauncherContentKey other && Equals(other);
+ }
+
+ public override int GetHashCode()
+ {
+ var hashCode = new HashCode();
+ hashCode.Add(ContentType);
+ hashCode.Add(ParentIdentity, StringComparer.OrdinalIgnoreCase);
+ hashCode.Add(Name, StringComparer.OrdinalIgnoreCase);
+ hashCode.Add(Version, StringComparer.OrdinalIgnoreCase);
+ return hashCode.ToHashCode();
+ }
+
+ public static bool operator ==(LauncherContentKey left, LauncherContentKey right)
+ {
+ return left.Equals(right);
+ }
+
+ public static bool operator !=(LauncherContentKey left, LauncherContentKey right)
+ {
+ return !left.Equals(right);
+ }
+
+ private static bool IdentityTextEquals(string? left, string? right)
+ {
+ return StringComparer.OrdinalIgnoreCase.Equals(left ?? string.Empty, right ?? string.Empty);
+ }
+}
diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentVersion.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentVersion.cs
new file mode 100644
index 00000000..48c9e229
--- /dev/null
+++ b/GenLauncherGO.Core/Mods/Models/LauncherContentVersion.cs
@@ -0,0 +1,118 @@
+using System;
+using System.Linq;
+using GenLauncherGO.Core.Integrity.Models;
+
+namespace GenLauncherGO.Core.Mods.Models;
+
+///
+/// Describes immutable metadata for one launcher content version and its separate mutable local installation state.
+///
+///
+/// Infrastructure maps third-party backend documents into this normalized domain model. Canonical identity is always
+/// provided by ; object equality is intentionally not an identity mechanism.
+///
+public sealed class LauncherContentVersion : IComparable
+{
+ public LauncherContentVersion()
+ : this(new LauncherContentInstallation())
+ {
+ }
+
+ public LauncherContentVersion(LauncherContentInstallation installation)
+ {
+ Installation = installation ?? throw new ArgumentNullException(nameof(installation));
+ }
+
+ public ModificationType ModificationType { get; init; }
+
+ public string Name { get; init; } = string.Empty;
+
+ public string Version { get; init; } = string.Empty;
+
+ public string SimpleDownloadLink { get; init; } = string.Empty;
+
+ public string UIImageSourceLink { get; init; } = string.Empty;
+
+ public string DiscordLink { get; init; } = string.Empty;
+
+ public string ModDBLink { get; init; } = string.Empty;
+
+ public string NewsLink { get; init; } = string.Empty;
+
+ public string ParentContentName { get; init; } = string.Empty;
+
+ public string S3HostLink { get; init; } = string.Empty;
+
+ public string S3BucketName { get; init; } = string.Empty;
+
+ public string S3FolderName { get; init; } = string.Empty;
+
+ public string S3HostPublicKey { get; init; } = string.Empty;
+
+ public string S3HostSecretKey { get; init; } = string.Empty;
+
+ public string NetworkInfo { get; init; } = string.Empty;
+
+ public bool Deprecated { get; init; }
+
+ public string SupportLink { get; init; } = string.Empty;
+
+ public LauncherContentInstallation Installation { get; init; }
+
+ ///
+ /// Gets the content source kind after applying package metadata precedence.
+ ///
+ public ContentSourceKind EffectiveContentSourceKind =>
+ ResolveContentSourceKind(
+ S3HostLink,
+ S3BucketName,
+ S3FolderName,
+ SimpleDownloadLink,
+ Installation.ContentSourceKind);
+
+ public string DisplayName => String.Join(" ", new[] { Name, Version }
+ .Where(value => !String.IsNullOrWhiteSpace(value)));
+
+ ///
+ /// Gets the canonical identity of this content version.
+ ///
+ public LauncherContentKey ContentKey =>
+ new(ModificationType, ParentContentName, Name, Version);
+
+ public int CompareTo(LauncherContentVersion? other)
+ {
+ return other is null
+ ? 1
+ : LauncherContentVersionComparer.Instance.Compare(Version, other.Version);
+ }
+
+ public override string ToString()
+ {
+ return Name;
+ }
+
+ ///
+ /// Resolves the content source kind from package metadata.
+ ///
+ public static ContentSourceKind ResolveContentSourceKind(
+ string? s3HostLink,
+ string? s3BucketName,
+ string? s3FolderName,
+ string? simpleDownloadLink,
+ ContentSourceKind fallbackSourceKind)
+ {
+ if (!String.IsNullOrWhiteSpace(s3HostLink) &&
+ !String.IsNullOrWhiteSpace(s3BucketName) &&
+ !String.IsNullOrWhiteSpace(s3FolderName))
+ {
+ return ContentSourceKind.ManagedS3;
+ }
+
+ if (!String.IsNullOrWhiteSpace(simpleDownloadLink))
+ {
+ return ContentSourceKind.ManagedSingleFile;
+ }
+
+ return fallbackSourceKind;
+ }
+}
diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentVersionComparer.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentVersionComparer.cs
new file mode 100644
index 00000000..d7ab854a
--- /dev/null
+++ b/GenLauncherGO.Core/Mods/Models/LauncherContentVersionComparer.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace GenLauncherGO.Core.Mods.Models;
+
+///
+/// Compares launcher content version labels without converting their digits to a bounded integer.
+///
+///
+/// The remote catalog does not define a semantic-version contract. For compatibility, comparison retains the legacy
+/// numeric projection: ASCII digits are compared in encounter order, the shorter projection is right-padded with
+/// zeroes, labels without ASCII digits compare below numeric labels, and two labels without digits compare equally.
+/// Punctuation and suffix text therefore do not define precedence. This comparer makes that boundary explicit while
+/// avoiding integer overflow for arbitrarily long digit sequences.
+///
+internal sealed class LauncherContentVersionComparer : IComparer
+{
+ private LauncherContentVersionComparer()
+ {
+ }
+
+ ///
+ /// Gets the shared launcher content version comparer.
+ ///
+ public static LauncherContentVersionComparer Instance { get; } = new();
+
+ public int Compare(string? x, string? y)
+ {
+ string leftDigits = ExtractAsciiDigits(x);
+ string rightDigits = ExtractAsciiDigits(y);
+
+ if (leftDigits.Length == 0 || rightDigits.Length == 0)
+ {
+ return leftDigits.Length.CompareTo(rightDigits.Length);
+ }
+
+ int projectedLength = Math.Max(leftDigits.Length, rightDigits.Length);
+ return String.CompareOrdinal(
+ leftDigits.PadRight(projectedLength, '0'),
+ rightDigits.PadRight(projectedLength, '0'));
+ }
+
+ private static string ExtractAsciiDigits(string? value)
+ {
+ return value is null
+ ? String.Empty
+ : new string(value.Where(IsAsciiDigit).ToArray());
+ }
+
+ private static bool IsAsciiDigit(char character)
+ {
+ return character is >= '0' and <= '9';
+ }
+}
diff --git a/GenLauncherGO.Core/Mods/Models/LauncherData.cs b/GenLauncherGO.Core/Mods/Models/LauncherData.cs
new file mode 100644
index 00000000..d889684b
--- /dev/null
+++ b/GenLauncherGO.Core/Mods/Models/LauncherData.cs
@@ -0,0 +1,238 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace GenLauncherGO.Core.Mods.Models;
+
+///
+/// Stores the active launcher content catalog and local state.
+///
+public sealed class LauncherData
+{
+ private readonly List _addons = new();
+ private readonly List _modifications = new();
+ private readonly List _patches = new();
+
+ public LauncherData()
+ {
+ Addons = _addons.AsReadOnly();
+ Modifications = _modifications.AsReadOnly();
+ Patches = _patches.AsReadOnly();
+ }
+
+ public IReadOnlyList Addons { get; }
+
+ public IReadOnlyList Modifications { get; }
+
+ public IReadOnlyList Patches { get; }
+
+ public LauncherContent? GetSelectedMod()
+ {
+ return _modifications.FirstOrDefault(modification => modification.IsSelected);
+ }
+
+ ///
+ /// Gets patches associated with the supplied modification, or the original game when none is supplied.
+ ///
+ public IReadOnlyList GetPatchesFor(LauncherContent? modification)
+ {
+ LauncherContentKey parentKey = modification?.ContentKey ?? LauncherContentKey.OriginalGame;
+
+ return _patches
+ .Where(patch => patch.ContentKey.IsChildOf(parentKey))
+ .ToList();
+ }
+
+ ///
+ /// Gets add-ons associated with the supplied modification or patch.
+ ///
+ public IReadOnlyList GetAddonsFor(
+ LauncherContent? modification,
+ LauncherContent? patch)
+ {
+ LauncherContentKey parentKey = modification?.ContentKey ?? LauncherContentKey.OriginalGame;
+ LauncherContentKey? patchKey = patch?.ContentKey;
+
+ return _addons
+ .Where(addon => addon.ContentKey.IsChildOf(parentKey))
+ .Union(_addons.Where(addon =>
+ patchKey.HasValue &&
+ addon.ContentKey.IsChildOf(patchKey.Value)))
+ .ToList();
+ }
+
+ public IReadOnlyList GetAllModsVersionsList()
+ {
+ return _modifications
+ .SelectMany(modification => modification.Versions)
+ .ToList();
+ }
+
+ public LauncherContent? FindContent(LauncherContentKey contentKey)
+ {
+ List? contentStorage = GetContentStorage(contentKey.ContentType);
+ LauncherContentKey cardKey = contentKey.WithoutVersion();
+ return contentStorage?.FirstOrDefault(content => content.ContentKey == cardKey);
+ }
+
+ ///
+ /// Adds a supported content version or merges it into the matching content card.
+ ///
+ public void AddOrUpdate(LauncherContentVersion modificationVersion)
+ {
+ ArgumentNullException.ThrowIfNull(modificationVersion);
+
+ if (modificationVersion.ModificationType == ModificationType.Addon &&
+ String.IsNullOrEmpty(modificationVersion.ParentContentName))
+ {
+ return;
+ }
+
+ List? contentStorage = GetContentStorage(modificationVersion.ModificationType);
+ if (contentStorage != null)
+ {
+ AddOrUpdateModificationVersion(contentStorage, modificationVersion);
+ }
+ }
+
+ ///
+ /// Deletes a version and removes dependent patch or add-on cards when their parent is removed.
+ ///
+ public void DeleteVersion(LauncherContentKey contentKey)
+ {
+ List? contentStorage = GetContentStorage(contentKey.ContentType);
+ if (contentStorage is null)
+ {
+ return;
+ }
+
+ bool removedContentCard = DeleteModificationVersion(contentStorage, contentKey);
+ if (!removedContentCard)
+ {
+ return;
+ }
+
+ DeleteDependentContent(contentKey);
+ }
+
+ ///
+ /// Deletes an entire content card and any patch or add-on cards that depend on it.
+ ///
+ public void DeleteContent(LauncherContentKey contentKey)
+ {
+ List? contentStorage = GetContentStorage(contentKey.ContentType);
+ if (contentStorage is null)
+ {
+ return;
+ }
+
+ LauncherContentKey cardKey = contentKey.WithoutVersion();
+ int removedCount = contentStorage.RemoveAll(content => content.ContentKey == cardKey);
+ if (removedCount == 0)
+ {
+ return;
+ }
+
+ DeleteDependentContent(contentKey);
+ }
+
+ private void DeleteDependentContent(LauncherContentKey contentKey)
+ {
+ if (contentKey.ContentType == ModificationType.Mod)
+ {
+ DeleteDependentContent(contentKey, _addons, _patches);
+ }
+ else if (contentKey.ContentType == ModificationType.Patch)
+ {
+ DeleteDependentAddons(contentKey, _addons);
+ }
+ }
+
+ private static void AddOrUpdateModificationVersion(
+ List modificationStorage,
+ LauncherContentVersion modificationVersion)
+ {
+ LauncherContentKey cardKey = modificationVersion.ContentKey.WithoutVersion();
+ int modificationIndex = modificationStorage.FindIndex(savedModification =>
+ savedModification.ContentKey == cardKey);
+
+ if (modificationIndex >= 0)
+ {
+ LauncherContent savedModificationData = modificationStorage[modificationIndex];
+ savedModificationData.AddOrMergeVersion(modificationVersion);
+ }
+ else
+ {
+ modificationStorage.Add(new LauncherContent(modificationVersion));
+ }
+ }
+
+ private static bool DeleteModificationVersion(
+ List modificationStorage,
+ LauncherContentKey contentKey)
+ {
+ LauncherContentKey cardKey = contentKey.WithoutVersion();
+ int modificationIndex = modificationStorage.FindIndex(savedModification =>
+ savedModification.ContentKey == cardKey);
+
+ if (modificationIndex < 0)
+ {
+ return false;
+ }
+
+ LauncherContent savedModificationData = modificationStorage[modificationIndex];
+ savedModificationData.RemoveVersion(contentKey);
+
+ if (savedModificationData.Versions.Count == 0)
+ {
+ modificationStorage.RemoveAt(modificationIndex);
+ return true;
+ }
+
+ return false;
+ }
+
+ private static void DeleteDependentContent(
+ LauncherContentKey contentKey,
+ List addons,
+ List patches)
+ {
+ var dependentPatches = patches
+ .Where(patch => IsDependentOn(patch, contentKey))
+ .ToList();
+
+ foreach (LauncherContent patch in dependentPatches)
+ {
+ DeleteDependentAddons(patch.ContentKey, addons);
+ patches.Remove(patch);
+ }
+
+ DeleteDependentAddons(contentKey, addons);
+ }
+
+ private static void DeleteDependentAddons(
+ LauncherContentKey parentKey,
+ List addons)
+ {
+ addons.RemoveAll(addon => IsDependentOn(addon, parentKey));
+ }
+
+ private static bool IsDependentOn(
+ LauncherContent modification,
+ LauncherContentKey parentKey)
+ {
+ return !String.IsNullOrWhiteSpace(parentKey.Name) &&
+ modification.ContentKey.IsChildOf(parentKey);
+ }
+
+ private List? GetContentStorage(ModificationType contentType)
+ {
+ return contentType switch
+ {
+ ModificationType.Mod => _modifications,
+ ModificationType.Addon => _addons,
+ ModificationType.Patch => _patches,
+ _ => null
+ };
+ }
+}
diff --git a/GenLauncherGO.Core/Mods/Models/ModificationImageReplacementRequest.cs b/GenLauncherGO.Core/Mods/Models/ModificationImageReplacementRequest.cs
new file mode 100644
index 00000000..ca6cb097
--- /dev/null
+++ b/GenLauncherGO.Core/Mods/Models/ModificationImageReplacementRequest.cs
@@ -0,0 +1,26 @@
+using System;
+
+namespace GenLauncherGO.Core.Mods.Models;
+
+public sealed class ModificationImageReplacementRequest
+{
+ public ModificationImageReplacementRequest(
+ string modificationName,
+ string imageBaseName,
+ string sourceImagePath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(modificationName);
+ ArgumentException.ThrowIfNullOrWhiteSpace(imageBaseName);
+ ArgumentException.ThrowIfNullOrWhiteSpace(sourceImagePath);
+
+ ModificationName = modificationName;
+ ImageBaseName = imageBaseName;
+ SourceImagePath = sourceImagePath;
+ }
+
+ public string ModificationName { get; }
+
+ public string ImageBaseName { get; }
+
+ public string SourceImagePath { get; }
+}
diff --git a/GenLauncherGO.Core/Mods/Models/ModificationType.cs b/GenLauncherGO.Core/Mods/Models/ModificationType.cs
new file mode 100644
index 00000000..f7cbfed6
--- /dev/null
+++ b/GenLauncherGO.Core/Mods/Models/ModificationType.cs
@@ -0,0 +1,27 @@
+namespace GenLauncherGO.Core.Mods.Models;
+
+///
+/// Identifies a launcher content category.
+///
+public enum ModificationType
+{
+ ///
+ /// A game modification.
+ ///
+ Mod = 0,
+
+ ///
+ /// An add-on for a game modification or patch.
+ ///
+ Addon = 1,
+
+ ///
+ /// A patch for a game modification or the original game.
+ ///
+ Patch = 2,
+
+ ///
+ /// Advertising content displayed in the launcher.
+ ///
+ Advertising = 3
+}
diff --git a/GenLauncherGO.Core/Mods/Models/OwnedContentPath.cs b/GenLauncherGO.Core/Mods/Models/OwnedContentPath.cs
new file mode 100644
index 00000000..4b617b2b
--- /dev/null
+++ b/GenLauncherGO.Core/Mods/Models/OwnedContentPath.cs
@@ -0,0 +1,39 @@
+using System;
+using GenLauncherGO.Core.IO;
+
+namespace GenLauncherGO.Core.Mods.Models;
+
+///
+/// Identifies one normalized launcher-owned content path together with the root that owns it.
+///
+public sealed record OwnedContentPath
+{
+ ///
+ /// Initializes a new instance of the record.
+ ///
+ ///
+ /// Thrown when either path is missing or is not below
+ /// .
+ ///
+ public OwnedContentPath(string ownerRoot, string fullPath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(ownerRoot);
+ ArgumentException.ThrowIfNullOrWhiteSpace(fullPath);
+
+ string normalizedOwnerRoot = LexicalPath.NormalizeFullPath(ownerRoot);
+ string normalizedFullPath = LexicalPath.NormalizeFullPath(fullPath);
+ if (!LexicalPath.IsPathBelowDirectory(normalizedFullPath, normalizedOwnerRoot))
+ {
+ throw new ArgumentException("An owned content path must be below its owning root.", nameof(fullPath));
+ }
+
+ OwnerRoot = normalizedOwnerRoot;
+ FullPath = normalizedFullPath;
+ }
+
+ public string OwnerRoot { get; }
+
+ public string FullPath { get; }
+
+ public string RelativePath => LexicalPath.GetRelativePath(OwnerRoot, FullPath);
+}
diff --git a/GenLauncherGO.Core/Mods/Services/LauncherContentPathResolver.cs b/GenLauncherGO.Core/Mods/Services/LauncherContentPathResolver.cs
new file mode 100644
index 00000000..b901ee09
--- /dev/null
+++ b/GenLauncherGO.Core/Mods/Services/LauncherContentPathResolver.cs
@@ -0,0 +1,113 @@
+using System;
+using System.IO;
+using System.Linq;
+using GenLauncherGO.Core.IO;
+using GenLauncherGO.Core.Mods.Models;
+using GenLauncherGO.Core.Startup;
+
+namespace GenLauncherGO.Core.Mods.Services;
+
+///
+/// Resolves launcher-owned content paths from canonical content identities.
+///
+public static class LauncherContentPathResolver
+{
+ ///
+ /// Resolves the owned installed version directory, or returns for an incomplete or
+ /// unsupported identity.
+ ///
+ public static OwnedContentPath? ResolveVersionPath(
+ LauncherPaths paths,
+ LauncherContentKey contentKey)
+ {
+ ArgumentNullException.ThrowIfNull(paths);
+ if (string.IsNullOrWhiteSpace(contentKey.Name) || string.IsNullOrWhiteSpace(contentKey.Version))
+ {
+ return null;
+ }
+
+ return contentKey.ContentType switch
+ {
+ ModificationType.Addon when !string.IsNullOrWhiteSpace(contentKey.ParentIdentity) => ResolvePackagePath(
+ paths.ModsDirectory,
+ contentKey.ParentIdentity,
+ LauncherFileSystemLayout.AddonsFolderName,
+ contentKey.Name,
+ contentKey.Version),
+ ModificationType.Mod => ResolvePackagePath(
+ paths.ModsDirectory,
+ contentKey.Name,
+ contentKey.Version),
+ ModificationType.Patch when !string.IsNullOrWhiteSpace(contentKey.ParentIdentity) => ResolvePackagePath(
+ paths.ModsDirectory,
+ contentKey.ParentIdentity,
+ LauncherFileSystemLayout.PatchesFolderName,
+ contentKey.Name,
+ contentKey.Version),
+ _ => null
+ };
+ }
+
+ ///
+ /// Resolves the owned content-card directory, or returns for an incomplete identity.
+ ///
+ public static OwnedContentPath? ResolveContentPath(
+ LauncherPaths paths,
+ LauncherContentKey contentKey)
+ {
+ ArgumentNullException.ThrowIfNull(paths);
+ if (string.IsNullOrWhiteSpace(contentKey.Name))
+ {
+ return null;
+ }
+
+ return contentKey.ContentType switch
+ {
+ ModificationType.Addon when !string.IsNullOrWhiteSpace(contentKey.ParentIdentity) => ResolvePackagePath(
+ paths.ModsDirectory,
+ contentKey.ParentIdentity,
+ LauncherFileSystemLayout.AddonsFolderName,
+ contentKey.Name),
+ ModificationType.Patch when !string.IsNullOrWhiteSpace(contentKey.ParentIdentity) => ResolvePackagePath(
+ paths.ModsDirectory,
+ contentKey.ParentIdentity,
+ LauncherFileSystemLayout.PatchesFolderName,
+ contentKey.Name),
+ ModificationType.Mod => ResolvePackagePath(paths.ModsDirectory, contentKey.Name),
+ _ => null
+ };
+ }
+
+ ///
+ /// Resolves the owned cleanup root, or returns for an incomplete identity.
+ ///
+ public static OwnedContentPath? ResolveCleanupRootPath(
+ LauncherPaths paths,
+ LauncherContentKey contentKey)
+ {
+ ArgumentNullException.ThrowIfNull(paths);
+ if (string.IsNullOrWhiteSpace(contentKey.Name))
+ {
+ return null;
+ }
+
+ return contentKey.ContentType switch
+ {
+ ModificationType.Addon when !string.IsNullOrWhiteSpace(contentKey.ParentIdentity) =>
+ ResolvePackagePath(paths.ModsDirectory, contentKey.ParentIdentity),
+ ModificationType.Patch when !string.IsNullOrWhiteSpace(contentKey.ParentIdentity) =>
+ ResolvePackagePath(paths.ModsDirectory, contentKey.ParentIdentity),
+ ModificationType.Mod => ResolvePackagePath(paths.ModsDirectory, contentKey.Name),
+ _ => null
+ };
+ }
+
+ private static OwnedContentPath ResolvePackagePath(string modsDirectory, params string?[] segments)
+ {
+ string[] safeSegments = segments
+ .Select((segment, index) => LexicalPath.NormalizePathSegment(segment, $"segment{index}"))
+ .ToArray();
+ string fullPath = LexicalPath.ResolvePath(modsDirectory, Path.Combine(safeSegments));
+ return new OwnedContentPath(modsDirectory, fullPath);
+ }
+}
diff --git a/GenLauncherGO.Core/Remote/IRemoteConnectionProbe.cs b/GenLauncherGO.Core/Remote/IRemoteConnectionProbe.cs
new file mode 100644
index 00000000..ccb46b58
--- /dev/null
+++ b/GenLauncherGO.Core/Remote/IRemoteConnectionProbe.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace GenLauncherGO.Core.Remote;
+
+///
+/// Checks whether a remote HTTP endpoint can be reached.
+///
+public interface IRemoteConnectionProbe
+{
+ ///
+ /// Returns whether the endpoint responds successfully.
+ ///
+ Task CanConnectAsync(Uri endpointUri, CancellationToken cancellationToken);
+}
diff --git a/GenLauncherGO.Core/Settings/Contracts/ILauncherPreferencesService.cs b/GenLauncherGO.Core/Settings/Contracts/ILauncherPreferencesService.cs
new file mode 100644
index 00000000..9e2cbb66
--- /dev/null
+++ b/GenLauncherGO.Core/Settings/Contracts/ILauncherPreferencesService.cs
@@ -0,0 +1,30 @@
+using System;
+using GenLauncherGO.Core.Settings.Exceptions;
+using GenLauncherGO.Core.Settings.Models;
+
+namespace GenLauncherGO.Core.Settings.Contracts;
+
+///
+/// Provides the current launcher preferences and persists preference updates.
+///
+public interface ILauncherPreferencesService
+{
+ ///
+ /// Occurs after launcher preferences have changed.
+ ///
+ event EventHandler? PreferencesChanged;
+
+ ///
+ /// Gets the current launcher preferences.
+ ///
+ LauncherPreferences Current { get; }
+
+ ///
+ /// Persists the supplied launcher preferences and publishes the updated state.
+ ///
+ ///
+ /// Thrown when the requested preferences cannot be persisted. In that case,
+ /// and remain unchanged.
+ ///
+ void Update(LauncherPreferences preferences);
+}
diff --git a/GenLauncherGO.Core/Settings/Exceptions/LauncherPreferencesPersistenceException.cs b/GenLauncherGO.Core/Settings/Exceptions/LauncherPreferencesPersistenceException.cs
new file mode 100644
index 00000000..ff59bf01
--- /dev/null
+++ b/GenLauncherGO.Core/Settings/Exceptions/LauncherPreferencesPersistenceException.cs
@@ -0,0 +1,12 @@
+using System;
+
+namespace GenLauncherGO.Core.Settings.Exceptions;
+
+public sealed class LauncherPreferencesPersistenceException : Exception
+{
+ public LauncherPreferencesPersistenceException(Exception innerException)
+ : base("Launcher preferences could not be persisted.", innerException)
+ {
+ ArgumentNullException.ThrowIfNull(innerException);
+ }
+}
diff --git a/GenLauncherGO.Core/Settings/Models/LauncherCustomExecutable.cs b/GenLauncherGO.Core/Settings/Models/LauncherCustomExecutable.cs
new file mode 100644
index 00000000..080aa912
--- /dev/null
+++ b/GenLauncherGO.Core/Settings/Models/LauncherCustomExecutable.cs
@@ -0,0 +1,19 @@
+using System;
+using GenLauncherGO.Core.Startup;
+
+namespace GenLauncherGO.Core.Settings.Models;
+
+public sealed record LauncherCustomExecutable
+{
+ public LauncherCustomExecutable(string displayName, string executableName)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(displayName);
+
+ DisplayName = displayName.Trim();
+ ExecutableName = LauncherFileSystemLayout.NormalizeExecutableFileName(executableName);
+ }
+
+ public string DisplayName { get; }
+
+ public string ExecutableName { get; }
+}
diff --git a/GenLauncherGO.Core/Settings/Models/LauncherGamePreferences.cs b/GenLauncherGO.Core/Settings/Models/LauncherGamePreferences.cs
new file mode 100644
index 00000000..b07bbc5d
--- /dev/null
+++ b/GenLauncherGO.Core/Settings/Models/LauncherGamePreferences.cs
@@ -0,0 +1,23 @@
+using System;
+using System.Collections.Generic;
+
+namespace GenLauncherGO.Core.Settings.Models;
+
+public sealed record LauncherGamePreferences
+{
+ public int LaunchesCount { get; init; }
+
+ public string SelectedGameClient { get; init; } = string.Empty;
+
+ public string SelectedWorldBuilder { get; init; } = string.Empty;
+
+ public string GameArguments { get; init; } = string.Empty;
+
+ public string WorldBuilderArguments { get; init; } = string.Empty;
+
+ public IReadOnlyList CustomGameClients { get; init; } =
+ Array.Empty();
+
+ public IReadOnlyList CustomWorldBuilders { get; init; } =
+ Array.Empty();
+}
diff --git a/GenLauncherGO.Core/Settings/Models/LauncherGamePreferencesSet.cs b/GenLauncherGO.Core/Settings/Models/LauncherGamePreferencesSet.cs
new file mode 100644
index 00000000..db51c35a
--- /dev/null
+++ b/GenLauncherGO.Core/Settings/Models/LauncherGamePreferencesSet.cs
@@ -0,0 +1,33 @@
+using System;
+using GenLauncherGO.Core.Startup;
+
+namespace GenLauncherGO.Core.Settings.Models;
+
+public sealed record LauncherGamePreferencesSet
+{
+ public LauncherGamePreferences Generals { get; init; } = new();
+
+ public LauncherGamePreferences ZeroHour { get; init; } = new();
+
+ public LauncherGamePreferences Get(SupportedGame game)
+ {
+ return game switch
+ {
+ SupportedGame.Generals => Generals,
+ SupportedGame.ZeroHour => ZeroHour,
+ _ => throw new ArgumentOutOfRangeException(nameof(game), game, "A supported game is required."),
+ };
+ }
+
+ public LauncherGamePreferencesSet With(SupportedGame game, LauncherGamePreferences preferences)
+ {
+ ArgumentNullException.ThrowIfNull(preferences);
+
+ return game switch
+ {
+ SupportedGame.Generals => this with { Generals = preferences },
+ SupportedGame.ZeroHour => this with { ZeroHour = preferences },
+ _ => throw new ArgumentOutOfRangeException(nameof(game), game, "A supported game is required."),
+ };
+ }
+}
diff --git a/GenLauncherGO.Core/Settings/Models/LauncherInstallations.cs b/GenLauncherGO.Core/Settings/Models/LauncherInstallations.cs
new file mode 100644
index 00000000..89695640
--- /dev/null
+++ b/GenLauncherGO.Core/Settings/Models/LauncherInstallations.cs
@@ -0,0 +1,57 @@
+using System;
+using GenLauncherGO.Core.Startup;
+
+namespace GenLauncherGO.Core.Settings.Models;
+
+public sealed record LauncherInstallations
+{
+ public string? Generals { get; init; }
+
+ public string? ZeroHour { get; init; }
+
+ public string? GetPath(SupportedGame game)
+ {
+ return game switch
+ {
+ SupportedGame.Generals => Generals,
+ SupportedGame.ZeroHour => ZeroHour,
+ _ => throw new ArgumentOutOfRangeException(nameof(game), game, "A supported game is required."),
+ };
+ }
+
+ public LauncherInstallations WithPath(SupportedGame game, string? path)
+ {
+ return game switch
+ {
+ SupportedGame.Generals => this with { Generals = path },
+ SupportedGame.ZeroHour => this with { ZeroHour = path },
+ _ => throw new ArgumentOutOfRangeException(nameof(game), game, "A supported game is required."),
+ };
+ }
+
+ ///
+ /// Resolves the preferred configured game, falling back when exactly one installation is available.
+ ///
+ public SupportedGame? ResolvePreferredGame(SupportedGame? preferredGame)
+ {
+ bool hasGenerals = !String.IsNullOrWhiteSpace(Generals);
+ bool hasZeroHour = !String.IsNullOrWhiteSpace(ZeroHour);
+
+ if (preferredGame == SupportedGame.Generals && hasGenerals)
+ {
+ return SupportedGame.Generals;
+ }
+
+ if (preferredGame == SupportedGame.ZeroHour && hasZeroHour)
+ {
+ return SupportedGame.ZeroHour;
+ }
+
+ if (hasGenerals == hasZeroHour)
+ {
+ return null;
+ }
+
+ return hasGenerals ? SupportedGame.Generals : SupportedGame.ZeroHour;
+ }
+}
diff --git a/GenLauncherGO.Core/Settings/Models/LauncherPreferences.cs b/GenLauncherGO.Core/Settings/Models/LauncherPreferences.cs
new file mode 100644
index 00000000..cc09415a
--- /dev/null
+++ b/GenLauncherGO.Core/Settings/Models/LauncherPreferences.cs
@@ -0,0 +1,14 @@
+using GenLauncherGO.Core.Startup;
+
+namespace GenLauncherGO.Core.Settings.Models;
+
+public sealed record LauncherPreferences
+{
+ public LauncherInstallations Installations { get; init; } = new();
+
+ public SupportedGame? LastSelectedGame { get; init; }
+
+ public LauncherSharedPreferences Shared { get; init; } = new();
+
+ public LauncherGamePreferencesSet Games { get; init; } = new();
+}
diff --git a/GenLauncherGO.Core/Settings/Models/LauncherSharedPreferences.cs b/GenLauncherGO.Core/Settings/Models/LauncherSharedPreferences.cs
new file mode 100644
index 00000000..5c990c8b
--- /dev/null
+++ b/GenLauncherGO.Core/Settings/Models/LauncherSharedPreferences.cs
@@ -0,0 +1,10 @@
+namespace GenLauncherGO.Core.Settings.Models;
+
+public sealed record LauncherSharedPreferences
+{
+ public bool AutoDeleteOldVersions { get; init; }
+
+ public bool HideLauncherAfterGameStart { get; init; }
+
+ public bool UseEnglishLanguage { get; init; }
+}
diff --git a/GenLauncherGO.Core/Shell/Contracts/ILauncherShellService.cs b/GenLauncherGO.Core/Shell/Contracts/ILauncherShellService.cs
new file mode 100644
index 00000000..d74f6f0b
--- /dev/null
+++ b/GenLauncherGO.Core/Shell/Contracts/ILauncherShellService.cs
@@ -0,0 +1,20 @@
+namespace GenLauncherGO.Core.Shell.Contracts;
+
+///
+/// Opens launcher-related external targets through the operating system shell.
+///
+public interface ILauncherShellService
+{
+ ///
+ /// Opens an absolute URI with the operating system shell.
+ ///
+ void OpenUri(string uri);
+
+ ///
+ /// Opens a folder with the operating system shell.
+ ///
+ void OpenFolder(
+ string folderPath,
+ bool requireFiles = false,
+ bool createIfMissing = false);
+}
diff --git a/GenLauncherGO.Core/Startup/Contracts/IGameInstallationService.cs b/GenLauncherGO.Core/Startup/Contracts/IGameInstallationService.cs
new file mode 100644
index 00000000..ccd9c8ba
--- /dev/null
+++ b/GenLauncherGO.Core/Startup/Contracts/IGameInstallationService.cs
@@ -0,0 +1,87 @@
+using System;
+using GenLauncherGO.Core.Settings.Models;
+using GenLauncherGO.Core.Startup.Models;
+
+namespace GenLauncherGO.Core.Startup.Contracts;
+
+///
+/// Validates user-selected game directories and discovers valid Windows installations.
+///
+public interface IGameInstallationService
+{
+ ///
+ /// Finds a supported game root at or above the launcher executable directory, or returns
+ /// for a standalone launcher.
+ ///
+ GameInstallationLocation? FindContainingInstallation(string executableDirectory);
+
+ ///
+ /// Validates one installation root, including its relationship to the launcher executable directory.
+ ///
+ GameInstallationValidationResult Validate(
+ SupportedGame game,
+ string? directory,
+ string executableDirectory);
+
+ ///
+ /// Retains valid configured paths and fills only missing or invalid paths from trusted registry views.
+ ///
+ LauncherInstallations DiscoverValidInstallations(
+ LauncherInstallations current,
+ string executableDirectory);
+}
+
+///
+/// Applies installation-set invariants on top of the canonical per-game validation boundary.
+///
+public static class GameInstallationServiceExtensions
+{
+ ///
+ /// Validates and canonicalizes the complete supported installation set.
+ /// Empty paths remain optional, but every nonempty path must be valid, at least one installation must remain,
+ /// and the two games must not resolve to the same physical directory.
+ ///
+ public static LauncherInstallationsValidationResult ValidateInstallations(
+ this IGameInstallationService installationService,
+ LauncherInstallations installations,
+ string executableDirectory)
+ {
+ ArgumentNullException.ThrowIfNull(installationService);
+ ArgumentNullException.ThrowIfNull(installations);
+ ArgumentException.ThrowIfNullOrWhiteSpace(executableDirectory);
+
+ GameInstallationValidationResult generals = installationService.Validate(
+ SupportedGame.Generals,
+ installations.Generals,
+ executableDirectory);
+ GameInstallationValidationResult zeroHour = installationService.Validate(
+ SupportedGame.ZeroHour,
+ installations.ZeroHour,
+ executableDirectory);
+
+ bool hasInvalidNonemptyPath =
+ (!String.IsNullOrWhiteSpace(installations.Generals) && !generals.IsValid) ||
+ (!String.IsNullOrWhiteSpace(installations.ZeroHour) && !zeroHour.IsValid);
+ bool hasDuplicatePath = generals is { IsValid: true, CanonicalPath: not null } &&
+ zeroHour is { IsValid: true, CanonicalPath: not null } &&
+ String.Equals(
+ generals.CanonicalPath,
+ zeroHour.CanonicalPath,
+ StringComparison.OrdinalIgnoreCase);
+ var canonicalInstallations = new LauncherInstallations
+ {
+ Generals = generals.IsValid ? generals.CanonicalPath : null,
+ ZeroHour = zeroHour.IsValid ? zeroHour.CanonicalPath : null,
+ };
+ bool isValid = !hasInvalidNonemptyPath &&
+ !hasDuplicatePath &&
+ (generals.IsValid || zeroHour.IsValid);
+
+ return new LauncherInstallationsValidationResult(
+ generals,
+ zeroHour,
+ canonicalInstallations,
+ hasDuplicatePath,
+ isValid);
+ }
+}
diff --git a/GenLauncherGO.Core/Startup/Contracts/ILauncherHostEnvironmentService.cs b/GenLauncherGO.Core/Startup/Contracts/ILauncherHostEnvironmentService.cs
new file mode 100644
index 00000000..881dfb87
--- /dev/null
+++ b/GenLauncherGO.Core/Startup/Contracts/ILauncherHostEnvironmentService.cs
@@ -0,0 +1,40 @@
+using System;
+using GenLauncherGO.Core.Startup.Models;
+
+namespace GenLauncherGO.Core.Startup.Contracts;
+
+///
+/// Provides host-process and operating-system operations needed by launcher startup.
+///
+public interface ILauncherHostEnvironmentService
+{
+ ///
+ /// Brings the first visible window for the current process name to the foreground when possible.
+ ///
+ void ActivateCurrentProcessWindow();
+
+ ///
+ /// Gets the directory containing the running launcher executable.
+ ///
+ string GetExecutableDirectory();
+
+ ///
+ /// Returns whether the current process is running with elevated administrator privileges.
+ ///
+ bool IsCurrentProcessElevated();
+
+ ///
+ /// Returns whether a directory is under a protected Program Files location.
+ ///
+ bool IsProtectedProgramFilesDirectory(string directory);
+
+ ///
+ /// Attempts to start a replacement instance of the current launcher process.
+ ///
+ LauncherRestartResult TryRestartCurrentProcess();
+
+ ///
+ /// Attempts to acquire the launcher single-instance guard; the returned guard reports whether startup may continue.
+ ///
+ ILauncherSingleInstanceGuard TryAcquireSingleInstance(string instanceName, TimeSpan retryDelay);
+}
diff --git a/GenLauncherGO.Core/Startup/Contracts/ILauncherSingleInstanceGuard.cs b/GenLauncherGO.Core/Startup/Contracts/ILauncherSingleInstanceGuard.cs
new file mode 100644
index 00000000..23c6b966
--- /dev/null
+++ b/GenLauncherGO.Core/Startup/Contracts/ILauncherSingleInstanceGuard.cs
@@ -0,0 +1,14 @@
+using System;
+
+namespace GenLauncherGO.Core.Startup.Contracts;
+
+///
+/// Represents ownership of the launcher single-instance guard.
+///
+public interface ILauncherSingleInstanceGuard : IDisposable
+{
+ ///
+ /// Gets a value indicating whether the guard was acquired by the current process.
+ ///
+ bool IsAcquired { get; }
+}
diff --git a/GenLauncherGO.Core/Startup/ILauncherPathResolver.cs b/GenLauncherGO.Core/Startup/ILauncherPathResolver.cs
new file mode 100644
index 00000000..db8d9a12
--- /dev/null
+++ b/GenLauncherGO.Core/Startup/ILauncherPathResolver.cs
@@ -0,0 +1,22 @@
+namespace GenLauncherGO.Core.Startup;
+
+///
+/// Resolves and prepares the launcher-owned directories for a GenLauncherGO session.
+///
+public interface ILauncherPathResolver
+{
+ ///
+ /// Resolves launcher paths from the executable directory.
+ ///
+ LauncherStoragePaths Resolve(string executableDirectory);
+
+ ///
+ /// Creates the shared launcher-owned directories.
+ ///
+ void PrepareLauncherDirectories(LauncherStoragePaths paths);
+
+ ///
+ /// Creates launcher-owned directories for one supported game and optionally clears its temporary files.
+ ///
+ void PrepareGameDirectories(LauncherPaths paths, bool cleanTemporaryDirectory);
+}
diff --git a/GenLauncherGO.Core/Startup/LauncherFileSystemLayout.cs b/GenLauncherGO.Core/Startup/LauncherFileSystemLayout.cs
new file mode 100644
index 00000000..68c111f7
--- /dev/null
+++ b/GenLauncherGO.Core/Startup/LauncherFileSystemLayout.cs
@@ -0,0 +1,128 @@
+using System;
+using System.IO;
+using GenLauncherGO.Core.IO;
+
+namespace GenLauncherGO.Core.Startup;
+
+///
+/// Defines the canonical launcher-owned folder layout and supported game file names.
+///
+public static class LauncherFileSystemLayout
+{
+ public const string LauncherDataFolderName = "GenLauncherGO Data";
+
+ internal const string GeneralsDataFolderName = "C&C Generals Data";
+
+ internal const string ZeroHourDataFolderName = "C&C Zero Hour Data";
+
+ internal const string RuntimeFolderName = "Runtime";
+
+ internal const string CacheFolderName = "Cache";
+
+ internal const string ImagesFolderName = "Images";
+
+ internal const string ModsFolderName = "Mods";
+
+ internal const string LogsFolderName = "Logs";
+
+ internal const string TempFolderName = "Temp";
+
+ internal const string DeploymentFolderName = "Deployment";
+
+ internal const string IntegrityFolderName = "Integrity";
+
+ internal const string StateFolderName = "State";
+
+ internal const string PackageBackupsFolderName = "PackageBackups";
+
+ public const string PackagesFolderName = "Packages";
+
+ public const string AddonsFolderName = "Addons";
+
+ public const string PatchesFolderName = "Patches";
+
+ public const string BinkLibraryFileName = "BINKW32.DLL";
+
+ public const string ZeroHourWindowArchiveFileName = "WindowZH.big";
+
+ public const string GeneralsWindowArchiveFileName = "Window.big";
+
+ public const string ZeroHourCommunityExecutableFileName = "generalszh.exe";
+
+ public const string GeneralsCommunityExecutableFileName = "generalsv.exe";
+
+ public const string GeneralsOnlineExecutableFileName = "generalsonlinezh.exe";
+
+ public const string ZeroHourCommunityWorldBuilderExecutableFileName = "worldbuilderzh.exe";
+
+ public const string GeneralsCommunityWorldBuilderExecutableFileName = "worldbuilderv.exe";
+
+ public const string VanillaWorldBuilderExecutableFileName = "WorldBuilder.exe";
+
+ ///
+ /// Normalizes a root-level Windows executable file name.
+ ///
+ ///
+ /// Thrown when the value is not a safe root-level .exe file name.
+ ///
+ public static string NormalizeExecutableFileName(string? executableName)
+ {
+ string normalizedName = LexicalPath.NormalizePathSegment(
+ executableName,
+ nameof(executableName));
+ if (!string.Equals(Path.GetExtension(normalizedName), ".exe", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new ArgumentException("Executable file names must use the .exe extension.", nameof(executableName));
+ }
+
+ return normalizedName;
+ }
+
+ ///
+ /// Gets the community game executable for a managed game.
+ ///
+ public static string GetCommunityGameExecutableName(SupportedGame managedGame)
+ {
+ return managedGame switch
+ {
+ SupportedGame.Generals => GeneralsCommunityExecutableFileName,
+ SupportedGame.ZeroHour => ZeroHourCommunityExecutableFileName,
+ _ => throw new ArgumentOutOfRangeException(
+ nameof(managedGame),
+ managedGame,
+ "A supported game is required."),
+ };
+ }
+
+ ///
+ /// Gets the community World Builder executable for a managed game.
+ ///
+ public static string GetCommunityWorldBuilderExecutableName(SupportedGame managedGame)
+ {
+ return managedGame switch
+ {
+ SupportedGame.Generals => GeneralsCommunityWorldBuilderExecutableFileName,
+ SupportedGame.ZeroHour => ZeroHourCommunityWorldBuilderExecutableFileName,
+ _ => throw new ArgumentOutOfRangeException(
+ nameof(managedGame),
+ managedGame,
+ "A supported game is required."),
+ };
+ }
+
+ ///
+ /// Gets the launcher-owned per-title directory name for a supported game.
+ ///
+ internal static string GetGameDataFolderName(SupportedGame managedGame)
+ {
+ return managedGame switch
+ {
+ SupportedGame.Generals => GeneralsDataFolderName,
+ SupportedGame.ZeroHour => ZeroHourDataFolderName,
+ _ => throw new ArgumentOutOfRangeException(
+ nameof(managedGame),
+ managedGame,
+ "A supported game is required."),
+ };
+ }
+}
diff --git a/GenLauncherGO.Core/Startup/LauncherPaths.cs b/GenLauncherGO.Core/Startup/LauncherPaths.cs
new file mode 100644
index 00000000..f08f3f3d
--- /dev/null
+++ b/GenLauncherGO.Core/Startup/LauncherPaths.cs
@@ -0,0 +1,161 @@
+using System;
+using System.IO;
+using GenLauncherGO.Core.IO;
+using GenLauncherGO.Core.Mods.Models;
+
+namespace GenLauncherGO.Core.Startup;
+
+///
+/// Describes one supported game installation and its isolated launcher-owned data directory.
+///
+public sealed record LauncherPaths
+{
+ internal LauncherPaths(SupportedGame game, string gameDirectory, string ownedGameDataDirectory)
+ {
+ if (game is not SupportedGame.Generals and not SupportedGame.ZeroHour)
+ {
+ throw new ArgumentOutOfRangeException(nameof(game), game, "A supported game is required.");
+ }
+
+ Game = game;
+ GameDirectory = LexicalPath.NormalizeFullPath(gameDirectory);
+ OwnedGameDataDirectory = LexicalPath.NormalizeFullPath(ownedGameDataDirectory);
+ }
+
+ private const string LauncherDataFileName = "LauncherData.yaml";
+
+ public SupportedGame Game { get; }
+
+ public string GameDirectory { get; }
+
+ public string OwnedGameDataDirectory { get; }
+
+ public string RuntimeDirectory => Path.Combine(OwnedGameDataDirectory, LauncherFileSystemLayout.RuntimeFolderName);
+
+ public string CacheDirectory => Path.Combine(RuntimeDirectory, LauncherFileSystemLayout.CacheFolderName);
+
+ public string ImagesDirectory => Path.Combine(CacheDirectory, LauncherFileSystemLayout.ImagesFolderName);
+
+ public string ModsDirectory => Path.Combine(OwnedGameDataDirectory, LauncherFileSystemLayout.ModsFolderName);
+
+ public string TempDirectory => Path.Combine(RuntimeDirectory, LauncherFileSystemLayout.TempFolderName);
+
+ public string DeploymentDirectory => Path.Combine(RuntimeDirectory, LauncherFileSystemLayout.DeploymentFolderName);
+
+ public string StateDirectory => Path.Combine(RuntimeDirectory, LauncherFileSystemLayout.StateFolderName);
+
+ public string IntegrityDirectory => Path.Combine(RuntimeDirectory, LauncherFileSystemLayout.IntegrityFolderName);
+
+ public string PackagesDirectory => Path.Combine(TempDirectory, LauncherFileSystemLayout.PackagesFolderName);
+
+ public string LauncherDataFilePath => Path.Combine(StateDirectory, LauncherDataFileName);
+
+ ///
+ /// Builds the image cache directory path for a mod, add-on, patch, or advertisement entry.
+ ///
+ ///
+ /// Thrown when is empty, whitespace, or unsafe for a path segment.
+ ///
+ public string GetModificationImagesDirectory(string modificationName)
+ {
+ string safeModificationName = LexicalPath.NormalizePathSegment(modificationName, nameof(modificationName));
+
+ return ResolveOwnedPath(ImagesDirectory, safeModificationName);
+ }
+
+ ///
+ /// Builds an image cache file path for a mod, add-on, patch, or advertisement entry.
+ ///
+ ///
+ /// Thrown when or is empty, whitespace, or
+ /// unsafe for a path segment.
+ ///
+ public string GetModificationImageFilePath(string modificationName, string imageFileName)
+ {
+ string safeImageFileName = LexicalPath.NormalizePathSegment(imageFileName, nameof(imageFileName));
+
+ return ResolveOwnedPath(GetModificationImagesDirectory(modificationName), safeImageFileName);
+ }
+
+ ///
+ /// Builds a package staging folder path below the launcher temporary directory.
+ ///
+ ///
+ /// Thrown when is empty, whitespace, or cannot be staged below the
+ /// launcher temporary package directory.
+ ///
+ private string GetPackageTemporaryFolderPath(string installedFolderPath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(installedFolderPath);
+
+ string installedFullPath = LexicalPath.NormalizeFullPath(installedFolderPath);
+ string relativePath = LexicalPath.GetRelativePath(ModsDirectory, installedFullPath);
+
+ if (LexicalPath.RelativePathLeavesRoot(relativePath))
+ {
+ relativePath = LexicalPath.NormalizePathSegment(
+ Path.GetFileName(installedFullPath),
+ nameof(installedFolderPath));
+ }
+
+ string temporaryPackagesRoot = PackagesDirectory;
+ string temporaryPath = LexicalPath.ResolvePath(temporaryPackagesRoot, relativePath);
+ if (!LexicalPath.IsPathInDirectory(temporaryPath, temporaryPackagesRoot))
+ {
+ throw new ArgumentException(
+ "The installed package path cannot be staged outside the launcher temporary package folder.",
+ nameof(installedFolderPath));
+ }
+
+ return temporaryPath;
+ }
+
+ ///
+ /// Builds an owned package staging path for an installed content path.
+ ///
+ public OwnedContentPath GetPackageTemporaryPath(OwnedContentPath installedPath)
+ {
+ ArgumentNullException.ThrowIfNull(installedPath);
+
+ string temporaryFolderPath = GetPackageTemporaryFolderPath(installedPath.FullPath);
+ return new OwnedContentPath(PackagesDirectory, temporaryFolderPath);
+ }
+
+ ///
+ /// Builds a durable recovery-backup path that mirrors an installed package below the canonical Mods directory.
+ ///
+ ///
+ /// Recovery backups live below State rather than Temp so startup cleanup cannot erase an interrupted replacement.
+ ///
+ ///
+ /// Thrown when is not below the canonical Mods directory.
+ ///
+ public OwnedContentPath GetPackageBackupPath(OwnedContentPath installedPath)
+ {
+ ArgumentNullException.ThrowIfNull(installedPath);
+
+ if (!LexicalPath.IsPathBelowDirectory(installedPath.FullPath, ModsDirectory))
+ {
+ throw new ArgumentException(
+ "Package recovery backups can only be created for content below the launcher Mods directory.",
+ nameof(installedPath));
+ }
+
+ string relativePath = LexicalPath.GetRelativePath(ModsDirectory, installedPath.FullPath);
+ string backupRoot = Path.Combine(StateDirectory, LauncherFileSystemLayout.PackageBackupsFolderName);
+ string backupPath = ResolveOwnedPath(backupRoot, relativePath);
+ return new OwnedContentPath(backupRoot, backupPath);
+ }
+
+ // Canonicalize before checking containment so relative traversal cannot escape the launcher-owned root.
+ private static string ResolveOwnedPath(string rootDirectory, string relativePath)
+ {
+ string candidatePath = LexicalPath.ResolvePath(rootDirectory, relativePath);
+ if (!LexicalPath.IsPathInDirectory(candidatePath, rootDirectory))
+ {
+ throw new ArgumentException("The resolved path must stay inside the launcher-owned directory.");
+ }
+
+ return candidatePath;
+ }
+}
diff --git a/GenLauncherGO.Core/Startup/LauncherRuntimePathContext.cs b/GenLauncherGO.Core/Startup/LauncherRuntimePathContext.cs
new file mode 100644
index 00000000..8b0d8880
--- /dev/null
+++ b/GenLauncherGO.Core/Startup/LauncherRuntimePathContext.cs
@@ -0,0 +1,64 @@
+using System;
+using System.Threading;
+using GenLauncherGO.Core.IO;
+
+namespace GenLauncherGO.Core.Startup;
+
+///
+/// Owns the active immutable game-path snapshot for a running standalone launcher session.
+///
+///
+/// Long-running operations must read once and retain that snapshot for their full
+/// lifetime. Replacing the active paths never redirects an operation that is already in progress.
+///
+public sealed class LauncherRuntimePathContext
+{
+ private LauncherPaths _activePaths;
+
+ public LauncherRuntimePathContext(
+ LauncherStoragePaths storagePaths,
+ LauncherPaths initialPaths)
+ {
+ StoragePaths = storagePaths ?? throw new ArgumentNullException(nameof(storagePaths));
+ _activePaths = ValidateOwnedGamePaths(initialPaths);
+ }
+
+ public LauncherStoragePaths StoragePaths { get; }
+
+ public LauncherPaths ActivePaths => Volatile.Read(ref _activePaths);
+
+ ///
+ /// Atomically replaces the active game-path snapshot.
+ ///
+ public void SwitchActive(LauncherPaths newPaths)
+ {
+ Interlocked.Exchange(ref _activePaths, ValidateOwnedGamePaths(newPaths));
+ }
+
+ private LauncherPaths ValidateOwnedGamePaths(LauncherPaths paths)
+ {
+ ArgumentNullException.ThrowIfNull(paths);
+
+ if (paths.Game is not SupportedGame.Generals and not SupportedGame.ZeroHour)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(paths),
+ paths.Game,
+ "The active path set must represent a supported game.");
+ }
+
+ string expectedDataDirectory = LexicalPath.NormalizeFullPath(
+ StoragePaths.GetGameDataDirectory(paths.Game));
+ if (!string.Equals(
+ paths.OwnedGameDataDirectory,
+ expectedDataDirectory,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ throw new ArgumentException(
+ "The active path set must use the standalone launcher's canonical per-game data directory.",
+ nameof(paths));
+ }
+
+ return paths;
+ }
+}
diff --git a/GenLauncherGO.Core/Startup/LauncherStoragePaths.cs b/GenLauncherGO.Core/Startup/LauncherStoragePaths.cs
new file mode 100644
index 00000000..c9064390
--- /dev/null
+++ b/GenLauncherGO.Core/Startup/LauncherStoragePaths.cs
@@ -0,0 +1,49 @@
+using System;
+using System.IO;
+using GenLauncherGO.Core.IO;
+
+namespace GenLauncherGO.Core.Startup;
+
+///
+/// Defines the standalone launcher's shared storage root and isolated per-title data roots.
+///
+///
+/// The executable directory is chosen by the user. All durable launcher state stays below
+/// and never uses a game installation as an ownership boundary.
+///
+public sealed record LauncherStoragePaths
+{
+ private const string PreferencesFileName = "LauncherPreferences.yaml";
+
+ public LauncherStoragePaths(string executableDirectory)
+ {
+ ExecutableDirectory = LexicalPath.NormalizeFullPath(executableDirectory);
+ }
+
+ public string ExecutableDirectory { get; }
+
+ public string DataDirectory =>
+ Path.Combine(ExecutableDirectory, LauncherFileSystemLayout.LauncherDataFolderName);
+
+ public string LogsDirectory => Path.Combine(DataDirectory, LauncherFileSystemLayout.LogsFolderName);
+
+ public string PreferencesFilePath => Path.Combine(DataDirectory, PreferencesFileName);
+
+ ///
+ /// Gets the launcher-owned data root isolated to one supported game.
+ ///
+ internal string GetGameDataDirectory(SupportedGame game)
+ {
+ return Path.Combine(DataDirectory, LauncherFileSystemLayout.GetGameDataFolderName(game));
+ }
+
+ ///
+ /// Creates the sole canonical path set for a validated game installation.
+ ///
+ public LauncherPaths CreateGamePaths(SupportedGame game, string validatedGameRoot)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(validatedGameRoot);
+
+ return new LauncherPaths(game, validatedGameRoot, GetGameDataDirectory(game));
+ }
+}
diff --git a/GenLauncherGO.Core/Startup/Models/GameInstallationLocation.cs b/GenLauncherGO.Core/Startup/Models/GameInstallationLocation.cs
new file mode 100644
index 00000000..c65a7431
--- /dev/null
+++ b/GenLauncherGO.Core/Startup/Models/GameInstallationLocation.cs
@@ -0,0 +1,23 @@
+using System;
+using GenLauncherGO.Core.IO;
+
+namespace GenLauncherGO.Core.Startup.Models;
+
+public sealed record GameInstallationLocation
+{
+ public GameInstallationLocation(SupportedGame game, string directory)
+ {
+ if (game is not SupportedGame.Generals and not SupportedGame.ZeroHour)
+ {
+ throw new ArgumentOutOfRangeException(nameof(game), game, "A supported game is required.");
+ }
+
+ ArgumentException.ThrowIfNullOrWhiteSpace(directory);
+ Game = game;
+ Directory = LexicalPath.NormalizeFullPath(directory);
+ }
+
+ public SupportedGame Game { get; }
+
+ public string Directory { get; }
+}
diff --git a/GenLauncherGO.Core/Startup/Models/GameInstallationValidationFailure.cs b/GenLauncherGO.Core/Startup/Models/GameInstallationValidationFailure.cs
new file mode 100644
index 00000000..b8b6d77c
--- /dev/null
+++ b/GenLauncherGO.Core/Startup/Models/GameInstallationValidationFailure.cs
@@ -0,0 +1,42 @@
+namespace GenLauncherGO.Core.Startup.Models;
+
+///
+/// Identifies why a selected game installation cannot be used.
+///
+public enum GameInstallationValidationFailure
+{
+ ///
+ /// The installation is valid.
+ ///
+ None = 0,
+
+ ///
+ /// No installation directory was supplied.
+ ///
+ PathMissing = 1,
+
+ ///
+ /// The supplied directory does not exist.
+ ///
+ DirectoryNotFound = 2,
+
+ ///
+ /// The directory does not contain the canonical files required by the selected game.
+ ///
+ RequiredFilesMissing = 3,
+
+ ///
+ /// The launcher executable directory is the game installation or one of its descendants.
+ ///
+ LauncherLocationOverlapsGame = 4,
+
+ ///
+ /// The path traverses a reparse point and is unsafe for launcher mutations.
+ ///
+ UnsafeFileSystemPath = 5,
+
+ ///
+ /// Windows could not safely resolve or inspect the directory.
+ ///
+ PathUnavailable = 6,
+}
diff --git a/GenLauncherGO.Core/Startup/Models/GameInstallationValidationResult.cs b/GenLauncherGO.Core/Startup/Models/GameInstallationValidationResult.cs
new file mode 100644
index 00000000..d2ab0d54
--- /dev/null
+++ b/GenLauncherGO.Core/Startup/Models/GameInstallationValidationResult.cs
@@ -0,0 +1,42 @@
+using System;
+
+namespace GenLauncherGO.Core.Startup.Models;
+
+///
+/// Describes an actionable game-installation validation outcome.
+///
+public sealed record GameInstallationValidationResult
+{
+ private GameInstallationValidationResult(
+ GameInstallationValidationFailure failure,
+ string? canonicalPath)
+ {
+ Failure = failure;
+ CanonicalPath = canonicalPath;
+ }
+
+ public bool IsValid => Failure == GameInstallationValidationFailure.None;
+
+ public GameInstallationValidationFailure Failure { get; }
+
+ public string? CanonicalPath { get; }
+
+ public static GameInstallationValidationResult Valid(string canonicalPath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(canonicalPath);
+
+ return new GameInstallationValidationResult(
+ GameInstallationValidationFailure.None,
+ canonicalPath);
+ }
+
+ public static GameInstallationValidationResult Invalid(GameInstallationValidationFailure failure)
+ {
+ if (failure == GameInstallationValidationFailure.None)
+ {
+ throw new ArgumentOutOfRangeException(nameof(failure), failure, "A validation failure is required.");
+ }
+
+ return new GameInstallationValidationResult(failure, null);
+ }
+}
diff --git a/GenLauncherGO.Core/Startup/Models/LauncherInstallationsValidationResult.cs b/GenLauncherGO.Core/Startup/Models/LauncherInstallationsValidationResult.cs
new file mode 100644
index 00000000..84f3fefd
--- /dev/null
+++ b/GenLauncherGO.Core/Startup/Models/LauncherInstallationsValidationResult.cs
@@ -0,0 +1,35 @@
+using System;
+using GenLauncherGO.Core.Settings.Models;
+
+namespace GenLauncherGO.Core.Startup.Models;
+
+///
+/// Describes validated per-game paths and the complete canonical installation-set outcome.
+///
+public sealed record LauncherInstallationsValidationResult
+{
+ internal LauncherInstallationsValidationResult(
+ GameInstallationValidationResult generalsValidation,
+ GameInstallationValidationResult zeroHourValidation,
+ LauncherInstallations canonicalInstallations,
+ bool hasDuplicatePath,
+ bool isValid)
+ {
+ GeneralsValidation = generalsValidation ?? throw new ArgumentNullException(nameof(generalsValidation));
+ ZeroHourValidation = zeroHourValidation ?? throw new ArgumentNullException(nameof(zeroHourValidation));
+ CanonicalInstallations = canonicalInstallations ??
+ throw new ArgumentNullException(nameof(canonicalInstallations));
+ HasDuplicatePath = hasDuplicatePath;
+ IsValid = isValid;
+ }
+
+ public GameInstallationValidationResult GeneralsValidation { get; }
+
+ public GameInstallationValidationResult ZeroHourValidation { get; }
+
+ public LauncherInstallations CanonicalInstallations { get; }
+
+ public bool HasDuplicatePath { get; }
+
+ public bool IsValid { get; }
+}
diff --git a/GenLauncherGO.Core/Startup/Models/LauncherRestartResult.cs b/GenLauncherGO.Core/Startup/Models/LauncherRestartResult.cs
new file mode 100644
index 00000000..956399a2
--- /dev/null
+++ b/GenLauncherGO.Core/Startup/Models/LauncherRestartResult.cs
@@ -0,0 +1,25 @@
+namespace GenLauncherGO.Core.Startup.Models;
+
+///
+/// Reports whether launching the replacement process for an application restart succeeded.
+///
+public sealed record LauncherRestartResult
+{
+ private LauncherRestartResult(bool succeeded, string? errorMessage)
+ {
+ Succeeded = succeeded;
+ ErrorMessage = errorMessage;
+ }
+
+ public bool Succeeded { get; }
+
+ public string? ErrorMessage { get; }
+
+ public static LauncherRestartResult Success { get; } = new(succeeded: true, errorMessage: null);
+
+ public static LauncherRestartResult Failure(string errorMessage)
+ {
+ System.ArgumentException.ThrowIfNullOrWhiteSpace(errorMessage);
+ return new LauncherRestartResult(succeeded: false, errorMessage);
+ }
+}
diff --git a/GenLauncherGO.Core/Startup/SupportedGame.cs b/GenLauncherGO.Core/Startup/SupportedGame.cs
new file mode 100644
index 00000000..6cc311f0
--- /dev/null
+++ b/GenLauncherGO.Core/Startup/SupportedGame.cs
@@ -0,0 +1,22 @@
+namespace GenLauncherGO.Core.Startup;
+
+///
+/// Identifies the supported Command & Conquer game variants GenLauncherGO can manage.
+///
+public enum SupportedGame
+{
+ ///
+ /// No supported game variant has been detected for this launcher session.
+ ///
+ Unknown = 0,
+
+ ///
+ /// Command & Conquer: Generals - Zero Hour.
+ ///
+ ZeroHour = 1,
+
+ ///
+ /// Command & Conquer: Generals.
+ ///
+ Generals = 2
+}
diff --git a/GenLauncherGO.Core/Updating/Contracts/IPackageDownloadService.cs b/GenLauncherGO.Core/Updating/Contracts/IPackageDownloadService.cs
new file mode 100644
index 00000000..3037a1de
--- /dev/null
+++ b/GenLauncherGO.Core/Updating/Contracts/IPackageDownloadService.cs
@@ -0,0 +1,23 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using GenLauncherGO.Core.Mods.Models;
+using GenLauncherGO.Core.Updating.Models;
+
+namespace GenLauncherGO.Core.Updating.Contracts;
+
+///
+/// Downloads and installs launcher-managed modification packages.
+///
+public interface IPackageDownloadService
+{
+ ///
+ /// Downloads and installs one package, reporting progress until the returned task completes.
+ ///
+ Task DownloadAsync(
+ LauncherContent modification,
+ LauncherContentVersion version,
+ IProgress? progress,
+ CancellationToken cancellationToken,
+ PackageDownloadPauseController? pauseController = null);
+}
diff --git a/GenLauncherGO.Core/Updating/Contracts/IRemotePackageSizeResolver.cs b/GenLauncherGO.Core/Updating/Contracts/IRemotePackageSizeResolver.cs
new file mode 100644
index 00000000..66defda3
--- /dev/null
+++ b/GenLauncherGO.Core/Updating/Contracts/IRemotePackageSizeResolver.cs
@@ -0,0 +1,21 @@
+using System.Threading;
+using System.Threading.Tasks;
+using GenLauncherGO.Core.Mods.Models;
+
+namespace GenLauncherGO.Core.Updating.Contracts;
+
+///
+/// Resolves the total fresh-install payload size advertised by a remote launcher package source.
+///
+///
+/// Implementations inspect remote metadata only and must not start or stage a package download.
+///
+public interface IRemotePackageSizeResolver
+{
+ ///
+ /// Resolves the total remote payload size, or returns when the source cannot provide it.
+ ///
+ Task GetTotalBytesAsync(
+ LauncherContentVersion version,
+ CancellationToken cancellationToken);
+}
diff --git a/GenLauncherGO.Core/Updating/Models/PackageDownloadPauseController.cs b/GenLauncherGO.Core/Updating/Models/PackageDownloadPauseController.cs
new file mode 100644
index 00000000..9f6ddaae
--- /dev/null
+++ b/GenLauncherGO.Core/Updating/Models/PackageDownloadPauseController.cs
@@ -0,0 +1,75 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace GenLauncherGO.Core.Updating.Models;
+
+///
+/// Provides cooperative asynchronous pause and resume control for one package download.
+///
+public sealed class PackageDownloadPauseController
+{
+ private readonly object _syncRoot = new();
+
+ private TaskCompletionSource? _resumeCompletion;
+
+ public bool IsPaused
+ {
+ get
+ {
+ lock (_syncRoot)
+ {
+ return _resumeCompletion != null;
+ }
+ }
+ }
+
+ ///
+ /// Pauses cooperative download work at its next checkpoint and reports whether the state changed.
+ ///
+ public bool Pause()
+ {
+ lock (_syncRoot)
+ {
+ if (_resumeCompletion != null)
+ {
+ return false;
+ }
+
+ _resumeCompletion = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ return true;
+ }
+ }
+
+ ///
+ /// Resumes paused download work and reports whether the state changed.
+ ///
+ public bool Resume()
+ {
+ TaskCompletionSource? resumeCompletion;
+ lock (_syncRoot)
+ {
+ resumeCompletion = _resumeCompletion;
+ _resumeCompletion = null;
+ }
+
+ return resumeCompletion?.TrySetResult() == true;
+ }
+
+ ///
+ /// Asynchronously waits until download work is resumed.
+ ///
+ public async ValueTask WaitWhilePausedAsync(CancellationToken cancellationToken)
+ {
+ Task? resumeTask;
+ lock (_syncRoot)
+ {
+ resumeTask = _resumeCompletion?.Task;
+ }
+
+ if (resumeTask != null)
+ {
+ await resumeTask.WaitAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/GenLauncherGO.Core/Updating/Models/PackageDownloadResult.cs b/GenLauncherGO.Core/Updating/Models/PackageDownloadResult.cs
new file mode 100644
index 00000000..fabe0e74
--- /dev/null
+++ b/GenLauncherGO.Core/Updating/Models/PackageDownloadResult.cs
@@ -0,0 +1,53 @@
+namespace GenLauncherGO.Core.Updating.Models;
+
+///
+/// Describes the single, terminal outcome of a launcher package download.
+///
+public sealed record PackageDownloadResult
+{
+ private PackageDownloadResult(
+ PackageDownloadStatus status,
+ string message)
+ {
+ Status = status;
+ Message = message;
+ }
+
+ public PackageDownloadStatus Status { get; }
+
+ public string Message { get; }
+
+ public static PackageDownloadResult Succeeded()
+ {
+ return new PackageDownloadResult(
+ PackageDownloadStatus.Succeeded,
+ string.Empty);
+ }
+
+ public static PackageDownloadResult Canceled()
+ {
+ return new PackageDownloadResult(
+ PackageDownloadStatus.Canceled,
+ string.Empty);
+ }
+
+ ///
+ /// Creates an expected failure result that can normally be retried or corrected by the user.
+ ///
+ public static PackageDownloadResult RecoverableFailure(string message)
+ {
+ return new PackageDownloadResult(
+ PackageDownloadStatus.RecoverableFailure,
+ message ?? string.Empty);
+ }
+
+ ///
+ /// Creates an unexpected failure result while preserving diagnostic detail.
+ ///
+ public static PackageDownloadResult UnexpectedFailure(string message)
+ {
+ return new PackageDownloadResult(
+ PackageDownloadStatus.UnexpectedFailure,
+ message ?? string.Empty);
+ }
+}
diff --git a/GenLauncherGO.Core/Updating/Models/PackageDownloadStatus.cs b/GenLauncherGO.Core/Updating/Models/PackageDownloadStatus.cs
new file mode 100644
index 00000000..790cf42a
--- /dev/null
+++ b/GenLauncherGO.Core/Updating/Models/PackageDownloadStatus.cs
@@ -0,0 +1,27 @@
+namespace GenLauncherGO.Core.Updating.Models;
+
+///
+/// Identifies the single terminal status of a package download.
+///
+public enum PackageDownloadStatus
+{
+ ///
+ /// The package was downloaded, verified, and installed.
+ ///
+ Succeeded,
+
+ ///
+ /// The caller cooperatively canceled the operation before installation committed.
+ ///
+ Canceled,
+
+ ///
+ /// An expected provider, package, or local-environment condition prevented installation.
+ ///
+ RecoverableFailure,
+
+ ///
+ /// An unexpected failure prevented installation and was recorded for diagnostics.
+ ///
+ UnexpectedFailure,
+}
diff --git a/GenLauncherGO.Core/Updating/Models/PackageUpdateProgress.cs b/GenLauncherGO.Core/Updating/Models/PackageUpdateProgress.cs
new file mode 100644
index 00000000..0f9b23fa
--- /dev/null
+++ b/GenLauncherGO.Core/Updating/Models/PackageUpdateProgress.cs
@@ -0,0 +1,11 @@
+using System;
+
+namespace GenLauncherGO.Core.Updating.Models;
+
+public sealed record PackageUpdateProgress(
+ long? TotalBytes,
+ long BytesRead,
+ double? ProgressPercentage,
+ string? FileName,
+ double? DownloadSpeedBytesPerSecond = null,
+TimeSpan? EstimatedTimeRemaining = null);
diff --git a/GenLauncherGO.Infrastructure/AGENTS.md b/GenLauncherGO.Infrastructure/AGENTS.md
new file mode 100644
index 00000000..3d3c32f1
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/AGENTS.md
@@ -0,0 +1,9 @@
+# GenLauncherGO.Infrastructure Guidance
+
+- Keep concrete disk, network, archive, process, hashing, persistence, and logging implementations here; do not drive Avalonia or other UI workflows.
+- Bind the external YAML contract with exact transport DTOs, then map it once into normalized concepts. Preserve accepted legacy keys, defaults, nesting, and values.
+- Before traversing or mutating owned content, reuse the existing containment and reparse-point primitives and fail closed when safety cannot be proven.
+- Preserve atomic writes, staging cleanup, deployment journaling, rollback, recovery, and hard-link-to-copy fallback behavior.
+- Use structured `ILogger` diagnostics around meaningful side effects and failures. Do not log credentials, tokens, or unnecessary full user paths.
+- Document mutation effects, safety invariants, external compatibility, and Windows/platform quirks; omit routine private-member summaries.
+- Infrastructure tests use isolated temporary directories and substituted external services, never a user's real game installation or credentials.
diff --git a/GenLauncherGO.Infrastructure/Archives/ArchiveExtractor.cs b/GenLauncherGO.Infrastructure/Archives/ArchiveExtractor.cs
new file mode 100644
index 00000000..b715a530
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Archives/ArchiveExtractor.cs
@@ -0,0 +1,115 @@
+using System;
+using System.IO;
+using System.Threading;
+using GenLauncherGO.Core.IO;
+using GenLauncherGO.Infrastructure.Archives.Contracts;
+using GenLauncherGO.Infrastructure.Common;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using SharpCompress.Archives;
+using SharpCompress.Common;
+using SharpCompress.Readers;
+
+namespace GenLauncherGO.Infrastructure.Archives;
+
+///
+/// Extracts archive files using the configured infrastructure archive library, creating destination directories,
+/// overwriting extracted files, and optionally renaming extracted .big entries to .gib.
+///
+internal sealed class ArchiveExtractor : IArchiveExtractor
+{
+ private readonly ILogger _logger;
+
+ public ArchiveExtractor(ILogger? logger = null)
+ {
+ _logger = logger ?? NullLogger.Instance;
+ }
+
+ public void ExtractToDirectory(
+ string archiveFilePath,
+ string destinationDirectory,
+ bool convertBigFilesToGib = false,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(archiveFilePath);
+ ArgumentException.ThrowIfNullOrWhiteSpace(destinationDirectory);
+
+ string destinationRoot = LexicalPath.NormalizeFullPath(destinationDirectory);
+ Directory.CreateDirectory(destinationRoot);
+
+ _logger.LogInformation(
+ "Extracting archive {ArchiveFilePath} to {DestinationDirectory}. Convert .big files to .gib: {ConvertBigFilesToGib}",
+ Path.GetFileName(archiveFilePath),
+ Path.GetFileName(destinationRoot),
+ convertBigFilesToGib);
+
+ using FileStream archiveStream = File.OpenRead(archiveFilePath);
+ using IArchive archive = ArchiveFactory.OpenArchive(
+ archiveStream,
+ new ReaderOptions { LeaveStreamOpen = false });
+
+ int extractedEntryCount = 0;
+ foreach (IArchiveEntry entry in archive.Entries)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (entry.IsDirectory)
+ {
+ continue;
+ }
+
+ string entryPath = GetDestinationPath(destinationRoot, entry.Key, convertBigFilesToGib);
+ string? entryDirectory = Path.GetDirectoryName(entryPath);
+ if (!string.IsNullOrWhiteSpace(entryDirectory))
+ {
+ Directory.CreateDirectory(entryDirectory);
+ }
+
+ entry.WriteToFile(entryPath, new ExtractionOptions
+ {
+ Overwrite = true,
+ PreserveFileTime = true
+ });
+
+ extractedEntryCount++;
+ }
+
+ _logger.LogInformation(
+ "Extracted {EntryCount} archive entries to {DestinationDirectory}",
+ extractedEntryCount,
+ Path.GetFileName(destinationRoot));
+ }
+
+ ///
+ /// Resolves the destination path for an archive entry and rejects paths outside the extraction root.
+ ///
+ ///
+ /// Thrown when the archive entry has no usable file name or would extract outside the destination directory.
+ ///
+ private static string GetDestinationPath(
+ string destinationRoot,
+ string? entryKey,
+ bool convertBigFilesToGib)
+ {
+ if (string.IsNullOrWhiteSpace(entryKey))
+ {
+ throw new InvalidDataException("Archive entry is missing a file name.");
+ }
+
+ string normalizedEntryKey = entryKey.Replace('\\', Path.DirectorySeparatorChar)
+ .Replace('/', Path.DirectorySeparatorChar);
+
+ string destinationPath = LexicalPath.ResolvePath(destinationRoot, normalizedEntryKey);
+ if (convertBigFilesToGib)
+ {
+ destinationPath = BigFileVariantPath.GetInstalledPath(destinationPath);
+ }
+
+ if (!LexicalPath.IsPathInDirectory(destinationPath, destinationRoot))
+ {
+ throw new InvalidDataException($"Archive entry '{entryKey}' would extract outside the destination folder.");
+ }
+
+ return destinationPath;
+ }
+}
diff --git a/GenLauncherGO.Infrastructure/Archives/ArchiveFileSupport.cs b/GenLauncherGO.Infrastructure/Archives/ArchiveFileSupport.cs
new file mode 100644
index 00000000..ae80a448
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Archives/ArchiveFileSupport.cs
@@ -0,0 +1,18 @@
+using System;
+using System.IO;
+
+namespace GenLauncherGO.Infrastructure.Archives;
+
+///
+/// Defines archive formats accepted consistently by legacy manual import and managed package updates.
+///
+internal static class ArchiveFileSupport
+{
+ public static bool IsSupported(string filePath)
+ {
+ string extension = Path.GetExtension(filePath);
+ return string.Equals(extension, ".zip", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(extension, ".rar", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(extension, ".7z", StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/GenLauncherGO.Infrastructure/Archives/Contracts/IArchiveExtractor.cs b/GenLauncherGO.Infrastructure/Archives/Contracts/IArchiveExtractor.cs
new file mode 100644
index 00000000..703b3e21
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Archives/Contracts/IArchiveExtractor.cs
@@ -0,0 +1,32 @@
+using System;
+using System.IO;
+using System.Threading;
+
+namespace GenLauncherGO.Infrastructure.Archives.Contracts;
+
+internal interface IArchiveExtractor
+{
+ ///
+ /// Extracts an archive into the specified destination directory, creating directories and overwriting existing
+ /// extracted files when needed. Entries may not escape the destination, and extraction can optionally rename
+ /// .big files to .gib.
+ ///
+ ///
+ /// Thrown when or is empty or
+ /// whitespace.
+ ///
+ ///
+ /// Thrown when the archive or destination files cannot be read or written.
+ ///
+ ///
+ /// Thrown when the archive is invalid or contains an entry that would extract outside the destination directory.
+ ///
+ ///
+ /// Thrown when the current process does not have access to read the archive or write extracted files.
+ ///
+ void ExtractToDirectory(
+ string archiveFilePath,
+ string destinationDirectory,
+ bool convertBigFilesToGib = false,
+ CancellationToken cancellationToken = default);
+}
diff --git a/GenLauncherGO.Infrastructure/Common/BigFileVariantPath.cs b/GenLauncherGO.Infrastructure/Common/BigFileVariantPath.cs
new file mode 100644
index 00000000..92e82665
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Common/BigFileVariantPath.cs
@@ -0,0 +1,110 @@
+using System;
+using System.IO;
+
+namespace GenLauncherGO.Infrastructure.Common;
+
+///
+/// Resolves manifest, installed, deployment, and in-progress path variants for package .big files.
+///
+internal static class BigFileVariantPath
+{
+ ///
+ /// The extension used by remote manifests and game deployment targets.
+ ///
+ public const string BigExtension = ".big";
+
+ ///
+ /// The extension used for launcher-managed installed package files.
+ ///
+ public const string GibExtension = ".gib";
+
+ ///
+ /// Returns the installed path, converting a .big path to its .gib variant.
+ ///
+ public static string GetInstalledPath(string filePath)
+ {
+ return IsBigFilePath(filePath)
+ ? GetGibVariantPath(filePath)
+ : filePath;
+ }
+
+ ///
+ /// Returns the deployment path, converting an installed .gib path to its game-facing .big variant.
+ ///
+ public static string GetDeploymentPath(string filePath)
+ {
+ return IsGibFilePath(filePath)
+ ? Path.ChangeExtension(filePath, BigExtension)
+ : filePath;
+ }
+
+ ///
+ /// Returns the same-base .gib variant for a requested path.
+ ///
+ public static string GetGibVariantPath(string filePath)
+ {
+ return Path.ChangeExtension(filePath, GibExtension);
+ }
+
+ ///
+ /// Returns the existing downloaded path, preferring the requested path and then the converted .gib path.
+ ///
+ public static string GetExistingDownloadedPath(string destinationFilePath)
+ {
+ if (File.Exists(destinationFilePath))
+ {
+ return destinationFilePath;
+ }
+
+ string gibFilePath = GetGibVariantPath(destinationFilePath);
+ return File.Exists(gibFilePath) ? gibFilePath : string.Empty;
+ }
+
+ ///
+ /// Converts a downloaded .big file to its installed .gib path.
+ ///
+ public static void ConvertBigFileToGib(string destinationFilePath)
+ {
+ if (!IsBigFilePath(destinationFilePath))
+ {
+ return;
+ }
+
+ string gibFilePath = GetGibVariantPath(destinationFilePath);
+ if (File.Exists(gibFilePath))
+ {
+ File.Delete(gibFilePath);
+ }
+
+ File.Move(destinationFilePath, gibFilePath);
+ }
+
+ ///
+ /// Moves an existing .gib file back to .big so a resumed download can append to it.
+ ///
+ public static void PrepareBigFileResumePath(string destinationFilePath)
+ {
+ if (!IsBigFilePath(destinationFilePath))
+ {
+ return;
+ }
+
+ string gibFilePath = GetGibVariantPath(destinationFilePath);
+ if (!File.Exists(gibFilePath) || File.Exists(destinationFilePath))
+ {
+ return;
+ }
+
+ File.Move(gibFilePath, destinationFilePath);
+ }
+
+ private static bool IsBigFilePath(string filePath)
+ {
+ return string.Equals(Path.GetExtension(filePath), BigExtension, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsGibFilePath(string filePath)
+ {
+ return string.Equals(Path.GetExtension(filePath), GibExtension, StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/GenLauncherGO.Infrastructure/Common/FileSystemPathSafety.cs b/GenLauncherGO.Infrastructure/Common/FileSystemPathSafety.cs
new file mode 100644
index 00000000..6fc5cbdf
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Common/FileSystemPathSafety.cs
@@ -0,0 +1,159 @@
+using System;
+using System.IO;
+using GenLauncherGO.Core.IO;
+
+namespace GenLauncherGO.Infrastructure.Common;
+
+///
+/// Provides shared filesystem path-safety checks for infrastructure services.
+///
+internal static class FileSystemPathSafety
+{
+ ///
+ /// Resolves a candidate path and verifies that it stays within an owned root without traversing existing links.
+ ///
+ public static string ResolveOwnedSubpath(
+ string ownedRoot,
+ string candidatePath,
+ string outsideRootMessage,
+ string linkedPathMessage)
+ {
+ string normalizedRoot = LexicalPath.NormalizeFullPath(ownedRoot);
+ string normalizedCandidate = LexicalPath.NormalizeFullPath(candidatePath);
+ if (!LexicalPath.IsPathInDirectory(normalizedCandidate, normalizedRoot))
+ {
+ throw new InvalidDataException(outsideRootMessage);
+ }
+
+ EnsureExistingPathChainHasNoReparsePoints(
+ normalizedRoot,
+ "An owned root path must be rooted.",
+ linkedPathMessage);
+ EnsureExistingPathChainHasNoReparsePoints(
+ normalizedCandidate,
+ "An owned candidate path must be rooted.",
+ linkedPathMessage);
+
+ return normalizedCandidate;
+ }
+
+ ///
+ /// Rejects paths whose existing filesystem chain contains a reparse point.
+ ///
+ public static void EnsureExistingPathChainHasNoReparsePoints(
+ string path,
+ string unrootedPathMessage,
+ string linkedPathMessage)
+ {
+ if (ExistingPathChainContainsReparsePoint(path, unrootedPathMessage))
+ {
+ throw new InvalidDataException(linkedPathMessage);
+ }
+ }
+
+ ///
+ /// Rejects a directory tree whose root or child entries contain a reparse point.
+ ///
+ public static void EnsureDirectoryTreeHasNoReparsePoints(
+ string directoryPath,
+ string linkedPathMessage)
+ {
+ string rootPath = LexicalPath.NormalizeFullPath(directoryPath);
+ if (IsReparsePoint(rootPath))
+ {
+ throw new InvalidDataException(linkedPathMessage);
+ }
+
+ EnsureDirectoryChildrenHaveNoReparsePoints(rootPath, linkedPathMessage);
+ }
+
+ ///
+ /// Determines whether an existing path chain contains a reparse point.
+ ///
+ public static bool ExistingPathChainContainsReparsePoint(string path, string unrootedPathMessage)
+ {
+ string fullPath = LexicalPath.NormalizeFullPath(path);
+ string root = Path.GetPathRoot(fullPath)
+ ?? throw new InvalidDataException(unrootedPathMessage);
+ string relativePath = LexicalPath.GetRelativePath(root, fullPath);
+ if (relativePath == ".")
+ {
+ return false;
+ }
+
+ string currentPath = root;
+ string[] segments = relativePath.Split(
+ new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar },
+ StringSplitOptions.RemoveEmptyEntries);
+ foreach (string segment in segments)
+ {
+ currentPath = Path.Combine(currentPath, segment);
+ if (!TryGetAttributes(currentPath, out FileAttributes attributes))
+ {
+ return false;
+ }
+
+ if ((attributes & FileAttributes.ReparsePoint) != 0)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool TryGetAttributes(string path, out FileAttributes attributes)
+ {
+ try
+ {
+ attributes = File.GetAttributes(path);
+ return true;
+ }
+ catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException)
+ {
+ attributes = default;
+ return false;
+ }
+ }
+
+ ///
+ /// Determines whether a filesystem entry is a reparse point.
+ ///
+ public static bool IsReparsePoint(string path)
+ {
+ return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0;
+ }
+
+ ///
+ /// Creates recursive enumeration options that never traverse reparse points.
+ ///
+ public static EnumerationOptions CreateRecursiveNoLinksOptions()
+ {
+ return new EnumerationOptions
+ {
+ AttributesToSkip = FileAttributes.ReparsePoint,
+ IgnoreInaccessible = false,
+ RecurseSubdirectories = true,
+ ReturnSpecialDirectories = false,
+ };
+ }
+
+ private static void EnsureDirectoryChildrenHaveNoReparsePoints(
+ string directoryPath,
+ string linkedPathMessage)
+ {
+ foreach (string entryPath in Directory.EnumerateFileSystemEntries(directoryPath))
+ {
+ FileAttributes attributes = File.GetAttributes(entryPath);
+ if ((attributes & FileAttributes.ReparsePoint) != 0)
+ {
+ throw new InvalidDataException(linkedPathMessage);
+ }
+
+ if ((attributes & FileAttributes.Directory) != 0)
+ {
+ EnsureDirectoryChildrenHaveNoReparsePoints(entryPath, linkedPathMessage);
+ }
+ }
+ }
+}
diff --git a/GenLauncherGO.Infrastructure/Common/ManifestPathResolver.cs b/GenLauncherGO.Infrastructure/Common/ManifestPathResolver.cs
new file mode 100644
index 00000000..7d32fbad
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Common/ManifestPathResolver.cs
@@ -0,0 +1,135 @@
+using System;
+using System.IO;
+using GenLauncherGO.Core.IO;
+
+namespace GenLauncherGO.Infrastructure.Common;
+
+///
+/// Defines the relative-path grammar shared by remote package manifests and durable deployment manifests.
+///
+internal static class ManifestPathResolver
+{
+ ///
+ /// Resolves a remote manifest file name to a full path under the specified root directory.
+ ///
+ ///
+ /// Thrown when the root directory or manifest file name is empty, rooted, drive-qualified, or contains a current
+ /// or parent directory segment.
+ ///
+ ///
+ /// Thrown when the resolved path would leave .
+ ///
+ public static string ResolvePath(string rootDirectory, string manifestFileName)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory);
+ ArgumentException.ThrowIfNullOrWhiteSpace(manifestFileName);
+
+ string normalizedFileName = NormalizeRelativePath(manifestFileName);
+ string rootPath = LexicalPath.NormalizeFullPath(rootDirectory);
+ string candidatePath = LexicalPath.ResolvePath(rootPath, normalizedFileName);
+
+ if (!LexicalPath.IsPathInDirectory(candidatePath, rootPath))
+ {
+ throw new InvalidDataException(
+ $"Manifest file '{manifestFileName}' would resolve outside the package directory.");
+ }
+
+ return candidatePath;
+ }
+
+ ///
+ /// Normalizes a remote manifest path to the current platform directory separator after validation.
+ ///
+ ///
+ /// Thrown when the path is rooted, drive-qualified, empty, or contains a current or parent directory segment.
+ ///
+ public static string NormalizeRelativePath(string manifestFileName)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(manifestFileName);
+
+ return NormalizeRelativePathCore(
+ manifestFileName.Trim(),
+ useDeploymentErrors: false,
+ nameof(manifestFileName));
+ }
+
+ ///
+ /// Normalizes a remote manifest path to slash separators for manifest index lookups.
+ ///
+ public static string NormalizeForManifestIndex(string manifestFileName)
+ {
+ return LexicalPath.NormalizeRelativePath(NormalizeRelativePath(manifestFileName));
+ }
+
+ ///
+ /// Normalizes a deployment manifest path to slash separators while preserving its durable-state error contract.
+ ///
+ /// Thrown when the path is empty or whitespace.
+ ///
+ /// Thrown when the path is rooted, drive-qualified, or contains a current or parent directory segment.
+ ///
+ public static string NormalizeForDeploymentManifest(string relativePath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(relativePath);
+
+ return LexicalPath.NormalizeRelativePath(
+ NormalizeRelativePathCore(
+ relativePath,
+ useDeploymentErrors: true,
+ nameof(relativePath)));
+ }
+
+ private static string NormalizeRelativePathCore(
+ string relativePath,
+ bool useDeploymentErrors,
+ string parameterName)
+ {
+ if (Path.IsPathRooted(relativePath) ||
+ relativePath.Contains(':', StringComparison.Ordinal))
+ {
+ throw CreateValidationException(
+ useDeploymentErrors,
+ "Manifest file paths must be relative.",
+ "Deployment manifest paths must be relative.",
+ parameterName);
+ }
+
+ string[] segments = relativePath.Split(
+ new[] { '/', '\\' },
+ StringSplitOptions.RemoveEmptyEntries);
+ if (segments.Length == 0)
+ {
+ throw CreateValidationException(
+ useDeploymentErrors,
+ "Manifest file paths must include a file name.",
+ "Deployment manifest paths must include a file name.",
+ parameterName);
+ }
+
+ foreach (string segment in segments)
+ {
+ if (string.Equals(segment, ".", StringComparison.Ordinal) ||
+ string.Equals(segment, "..", StringComparison.Ordinal))
+ {
+ throw CreateValidationException(
+ useDeploymentErrors,
+ "Manifest file paths must not contain current or parent directory segments.",
+ "Deployment manifest paths must not contain parent directory segments.",
+ parameterName);
+ }
+ }
+
+ return Path.Combine(segments);
+ }
+
+ private static Exception CreateValidationException(
+ bool useDeploymentErrors,
+ string manifestMessage,
+ string deploymentMessage,
+ string parameterName)
+ {
+ return useDeploymentErrors
+ ? new InvalidDataException(deploymentMessage)
+ : new ArgumentException(manifestMessage, parameterName);
+ }
+}
diff --git a/GenLauncherGO.Infrastructure/Common/OwnedDirectoryTree.cs b/GenLauncherGO.Infrastructure/Common/OwnedDirectoryTree.cs
new file mode 100644
index 00000000..68ca5289
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Common/OwnedDirectoryTree.cs
@@ -0,0 +1,381 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using GenLauncherGO.Core.IO;
+using GenLauncherGO.Core.Mods.Models;
+
+namespace GenLauncherGO.Infrastructure.Common;
+
+///
+/// Mutates explicitly launcher-owned directory trees without traversing reparse points.
+///
+internal static class OwnedDirectoryTree
+{
+ ///
+ /// Creates an owned directory when it does not exist and rejects an existing linked entry.
+ ///
+ public static string EnsureExists(string ownedRoot, string directoryPath)
+ {
+ string normalizedPath = ResolveOwnedDirectoryPath(ownedRoot, directoryPath);
+ EnsureSafeAncestors(ownedRoot, normalizedPath);
+ if (TryGetAttributes(normalizedPath, out FileAttributes attributes))
+ {
+ if ((attributes & FileAttributes.ReparsePoint) != 0)
+ {
+ throw new InvalidDataException("Owned directories must not be reparse points.");
+ }
+
+ if ((attributes & FileAttributes.Directory) == 0)
+ {
+ throw new IOException("An owned directory path is occupied by a file.");
+ }
+
+ return normalizedPath;
+ }
+
+ Directory.CreateDirectory(normalizedPath);
+ FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints(
+ normalizedPath,
+ "Owned directory paths must be rooted.",
+ "Owned directory paths must not cross reparse points.");
+ return normalizedPath;
+ }
+
+ ///
+ /// Ensures an owned path is a real directory, replacing a linked leaf with an empty real directory.
+ ///
+ public static string EnsureRealDirectory(string ownedRoot, string directoryPath)
+ {
+ string normalizedPath = ResolveOwnedDirectoryPath(ownedRoot, directoryPath);
+ EnsureSafeAncestors(ownedRoot, normalizedPath);
+
+ if (!TryGetAttributes(normalizedPath, out FileAttributes attributes))
+ {
+ Directory.CreateDirectory(normalizedPath);
+ return normalizedPath;
+ }
+
+ if ((attributes & FileAttributes.ReparsePoint) != 0)
+ {
+ DeleteEntryWithoutFollowing(normalizedPath, attributes);
+ Directory.CreateDirectory(normalizedPath);
+ return normalizedPath;
+ }
+
+ if ((attributes & FileAttributes.Directory) == 0)
+ {
+ throw new IOException("An owned directory path is occupied by a file.");
+ }
+
+ return normalizedPath;
+ }
+
+ ///
+ /// Deletes an owned directory tree when it exists.
+ ///
+ ///
+ /// Nested links are deleted as entries without traversing them, so their targets remain untouched.
+ ///
+ public static bool DeleteIfExists(OwnedContentPath ownedPath)
+ {
+ ArgumentNullException.ThrowIfNull(ownedPath);
+
+ return DeleteIfExists(ownedPath.OwnerRoot, ownedPath.FullPath);
+ }
+
+ ///
+ /// Deletes a directory tree below an explicit owned root when it exists.
+ ///
+ ///
+ /// Nested links are deleted as entries without traversing them, so their targets remain untouched.
+ ///
+ public static bool DeleteIfExists(string ownedRoot, string directoryPath)
+ {
+ string normalizedPath = ResolveOwnedDirectoryPath(ownedRoot, directoryPath);
+ EnsureSafeAncestors(ownedRoot, normalizedPath);
+ if (!TryGetAttributes(normalizedPath, out FileAttributes attributes))
+ {
+ return false;
+ }
+
+ DeleteEntryWithoutFollowing(normalizedPath, attributes);
+ return true;
+ }
+
+ ///
+ /// Creates an owned directory when necessary and deletes all of its existing child entries.
+ ///
+ ///
+ /// When the directory itself is a link, the link is deleted and replaced by a real directory. Nested links are
+ /// deleted as entries without traversing them.
+ ///
+ public static string PrepareEmpty(string ownedRoot, string directoryPath)
+ {
+ string normalizedPath = ResolveOwnedDirectoryPath(ownedRoot, directoryPath);
+ EnsureSafeAncestors(ownedRoot, normalizedPath);
+
+ if (TryGetAttributes(normalizedPath, out FileAttributes attributes))
+ {
+ if ((attributes & FileAttributes.ReparsePoint) != 0)
+ {
+ DeleteEntryWithoutFollowing(normalizedPath, attributes);
+ Directory.CreateDirectory(normalizedPath);
+ return normalizedPath;
+ }
+
+ if ((attributes & FileAttributes.Directory) == 0)
+ {
+ throw new IOException("An owned directory path is occupied by a file.");
+ }
+
+ DeleteDirectoryChildren(normalizedPath);
+ return normalizedPath;
+ }
+
+ Directory.CreateDirectory(normalizedPath);
+ return normalizedPath;
+ }
+
+ ///
+ /// Deletes empty parent directories between a child path and its exclusive ownership boundary.
+ ///
+ public static IReadOnlyList DeleteEmptyParents(string ownedRoot, string childPath)
+ {
+ string normalizedRoot = LexicalPath.NormalizeFullPath(ownedRoot);
+ string normalizedChild = LexicalPath.NormalizeFullPath(childPath);
+ if (!LexicalPath.IsPathInDirectory(normalizedChild, normalizedRoot))
+ {
+ throw new InvalidOperationException("Refusing to prune directories outside the owned root.");
+ }
+
+ FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints(
+ normalizedRoot,
+ "Owned directory roots must be rooted.",
+ "Owned directory roots must not contain reparse points.");
+
+ var deletedDirectories = new List();
+ DirectoryInfo? current = Directory.GetParent(normalizedChild);
+ while (current is not null &&
+ !string.Equals(current.FullName, normalizedRoot, StringComparison.OrdinalIgnoreCase))
+ {
+ string currentPath = current.FullName;
+ DirectoryInfo? parent = current.Parent;
+ if (!LexicalPath.IsPathInDirectory(currentPath, normalizedRoot))
+ {
+ break;
+ }
+
+ if (!TryGetAttributes(currentPath, out FileAttributes attributes))
+ {
+ current = parent;
+ continue;
+ }
+
+ if ((attributes & FileAttributes.ReparsePoint) != 0)
+ {
+ throw new InvalidDataException("Owned directory parents must not contain reparse points.");
+ }
+
+ if ((attributes & FileAttributes.Directory) == 0 ||
+ Directory.EnumerateFileSystemEntries(currentPath).Any())
+ {
+ break;
+ }
+
+ Directory.Delete(currentPath, recursive: false);
+ deletedDirectories.Add(currentPath);
+ current = parent;
+ }
+
+ return deletedDirectories;
+ }
+
+ ///
+ /// Recursively deletes empty real directories below an owned content path without traversing or deleting links.
+ ///
+ public static bool DeleteEmptyDirectories(OwnedContentPath ownedPath)
+ {
+ ArgumentNullException.ThrowIfNull(ownedPath);
+
+ EnsureSafeAncestors(ownedPath.OwnerRoot, ownedPath.FullPath);
+ return DeleteEmptyDirectoriesCore(ownedPath.FullPath);
+ }
+
+ ///
+ /// Deletes all reparse-point entries below an owned real directory without traversing their targets.
+ ///
+ public static IReadOnlyList DeleteReparsePoints(OwnedContentPath ownedPath)
+ {
+ ArgumentNullException.ThrowIfNull(ownedPath);
+
+ EnsureSafeAncestors(ownedPath.OwnerRoot, ownedPath.FullPath);
+ var deletedPaths = new List();
+ DeleteReparsePointsCore(ownedPath.FullPath, deletedPaths);
+ return deletedPaths;
+ }
+
+ private static string ResolveOwnedDirectoryPath(string ownedRoot, string directoryPath)
+ {
+ return new OwnedContentPath(ownedRoot, directoryPath).FullPath;
+ }
+
+ private static void EnsureSafeAncestors(string ownedRoot, string directoryPath)
+ {
+ FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints(
+ ownedRoot,
+ "Owned directory roots must be rooted.",
+ "Owned directory roots must not contain reparse points.");
+
+ string? parentDirectory = Path.GetDirectoryName(directoryPath);
+ if (!string.IsNullOrWhiteSpace(parentDirectory))
+ {
+ FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints(
+ parentDirectory,
+ "Owned directory paths must be rooted.",
+ "Owned directory paths must not cross reparse points.");
+ }
+ }
+
+ private static void DeleteDirectoryChildren(string directoryPath)
+ {
+ FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints(
+ directoryPath,
+ "Owned directory paths must be rooted.",
+ "Owned directory paths must not cross reparse points.");
+
+ foreach (string entryPath in Directory.EnumerateFileSystemEntries(directoryPath).ToList())
+ {
+ if (!TryGetAttributes(entryPath, out FileAttributes attributes))
+ {
+ continue;
+ }
+
+ DeleteEntryWithoutFollowing(entryPath, attributes);
+ }
+ }
+
+ private static bool DeleteEmptyDirectoriesCore(string directoryPath)
+ {
+ if (!TryGetAttributes(directoryPath, out FileAttributes attributes) ||
+ (attributes & FileAttributes.ReparsePoint) != 0 ||
+ (attributes & FileAttributes.Directory) == 0)
+ {
+ return false;
+ }
+
+ FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints(
+ directoryPath,
+ "Owned directory paths must be rooted.",
+ "Owned directory paths must not cross reparse points.");
+
+ foreach (string entryPath in Directory.EnumerateDirectories(directoryPath).ToList())
+ {
+ if (!TryGetAttributes(entryPath, out FileAttributes childAttributes) ||
+ (childAttributes & FileAttributes.ReparsePoint) != 0)
+ {
+ continue;
+ }
+
+ DeleteEmptyDirectoriesCore(entryPath);
+ }
+
+ if (Directory.EnumerateFileSystemEntries(directoryPath).Any())
+ {
+ return false;
+ }
+
+ Directory.Delete(directoryPath, recursive: false);
+ return true;
+ }
+
+ private static void DeleteReparsePointsCore(string directoryPath, List deletedPaths)
+ {
+ if (!TryGetAttributes(directoryPath, out FileAttributes directoryAttributes) ||
+ (directoryAttributes & FileAttributes.ReparsePoint) != 0 ||
+ (directoryAttributes & FileAttributes.Directory) == 0)
+ {
+ throw new InvalidDataException("Owned directory traversal requires a real directory.");
+ }
+
+ FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints(
+ directoryPath,
+ "Owned directory paths must be rooted.",
+ "Owned directory paths must not cross reparse points.");
+
+ foreach (string entryPath in Directory.EnumerateFileSystemEntries(directoryPath).ToList())
+ {
+ if (!TryGetAttributes(entryPath, out FileAttributes attributes))
+ {
+ continue;
+ }
+
+ if ((attributes & FileAttributes.ReparsePoint) != 0)
+ {
+ DeleteLinkEntry(entryPath, attributes);
+ deletedPaths.Add(entryPath);
+ continue;
+ }
+
+ if ((attributes & FileAttributes.Directory) != 0)
+ {
+ DeleteReparsePointsCore(entryPath, deletedPaths);
+ }
+ }
+ }
+
+ private static void DeleteEntryWithoutFollowing(string entryPath, FileAttributes observedAttributes)
+ {
+ if ((observedAttributes & FileAttributes.ReparsePoint) != 0)
+ {
+ DeleteLinkEntry(entryPath, observedAttributes);
+ return;
+ }
+
+ if ((observedAttributes & FileAttributes.Directory) != 0)
+ {
+ if (!TryGetAttributes(entryPath, out FileAttributes currentAttributes))
+ {
+ return;
+ }
+
+ if ((currentAttributes & FileAttributes.ReparsePoint) != 0)
+ {
+ DeleteLinkEntry(entryPath, currentAttributes);
+ return;
+ }
+
+ DeleteDirectoryChildren(entryPath);
+ Directory.Delete(entryPath, recursive: false);
+ return;
+ }
+
+ File.Delete(entryPath);
+ }
+
+ private static void DeleteLinkEntry(string entryPath, FileAttributes attributes)
+ {
+ if ((attributes & FileAttributes.Directory) != 0)
+ {
+ Directory.Delete(entryPath, recursive: false);
+ }
+ else
+ {
+ File.Delete(entryPath);
+ }
+ }
+
+ private static bool TryGetAttributes(string path, out FileAttributes attributes)
+ {
+ try
+ {
+ attributes = File.GetAttributes(path);
+ return true;
+ }
+ catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException)
+ {
+ attributes = default;
+ return false;
+ }
+ }
+}
diff --git a/GenLauncherGO.Infrastructure/Common/PhysicalDirectoryPath.cs b/GenLauncherGO.Infrastructure/Common/PhysicalDirectoryPath.cs
new file mode 100644
index 00000000..10a5a6f2
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Common/PhysicalDirectoryPath.cs
@@ -0,0 +1,164 @@
+using System;
+using System.ComponentModel;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Text;
+using Microsoft.Win32.SafeHandles;
+
+namespace GenLauncherGO.Infrastructure.Common;
+
+///
+/// Identifies an existing directory independently of aliases in its path spelling.
+///
+internal readonly record struct PhysicalDirectoryIdentity(uint VolumeSerialNumber, ulong FileIndex);
+
+///
+/// Resolves existing Windows directories through handles for security-sensitive comparisons and recovery metadata.
+///
+internal static class PhysicalDirectoryPath
+{
+ private const uint FileReadAttributes = 0x0080;
+ private const uint FileShareRead = 0x00000001;
+ private const uint FileShareWrite = 0x00000002;
+ private const uint FileShareDelete = 0x00000004;
+ private const uint OpenExisting = 3;
+ private const uint FileFlagBackupSemantics = 0x02000000;
+ private const uint FileNameNormalized = 0x0;
+ private const uint VolumeNameDos = 0x0;
+ private const string ExtendedPathPrefix = @"\\?\";
+ private const string ExtendedUncPrefix = @"\\?\UNC\";
+
+ ///
+ /// Returns the canonical path observed through a handle to an existing directory.
+ ///
+ public static string ResolveExisting(string path)
+ {
+ using SafeFileHandle handle = OpenDirectory(path);
+ var buffer = new StringBuilder(512);
+ uint requiredLength = GetFinalPathNameByHandle(
+ handle,
+ buffer,
+ (uint)buffer.Capacity,
+ FileNameNormalized | VolumeNameDos);
+ if (requiredLength == 0)
+ {
+ throw new Win32Exception(Marshal.GetLastWin32Error());
+ }
+
+ if (requiredLength >= buffer.Capacity)
+ {
+ buffer.EnsureCapacity(checked((int)requiredLength + 1));
+ requiredLength = GetFinalPathNameByHandle(
+ handle,
+ buffer,
+ (uint)buffer.Capacity,
+ FileNameNormalized | VolumeNameDos);
+ if (requiredLength == 0 || requiredLength >= buffer.Capacity)
+ {
+ throw new Win32Exception(Marshal.GetLastWin32Error());
+ }
+ }
+
+ return NormalizeHandlePath(buffer.ToString());
+ }
+
+ ///
+ /// Returns the stable volume and file-index identity of an existing directory.
+ ///
+ public static PhysicalDirectoryIdentity GetIdentity(string path)
+ {
+ using SafeFileHandle handle = OpenDirectory(path);
+ if (!GetFileInformationByHandle(handle, out ByHandleFileInformation information))
+ {
+ throw new Win32Exception(Marshal.GetLastWin32Error());
+ }
+
+ ulong fileIndex = ((ulong)information.FileIndexHigh << 32) | information.FileIndexLow;
+ return new PhysicalDirectoryIdentity(information.VolumeSerialNumber, fileIndex);
+ }
+
+ private static SafeFileHandle OpenDirectory(string path)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+
+ string fullPath = Path.GetFullPath(path);
+ if (!Directory.Exists(fullPath))
+ {
+ throw new DirectoryNotFoundException("The directory does not exist.");
+ }
+
+ SafeFileHandle handle = CreateFile(
+ fullPath,
+ FileReadAttributes,
+ FileShareRead | FileShareWrite | FileShareDelete,
+ IntPtr.Zero,
+ OpenExisting,
+ FileFlagBackupSemantics,
+ IntPtr.Zero);
+ if (handle.IsInvalid)
+ {
+ int error = Marshal.GetLastWin32Error();
+ handle.Dispose();
+ throw new Win32Exception(error);
+ }
+
+ return handle;
+ }
+
+ private static string NormalizeHandlePath(string path)
+ {
+ string normalizedPath;
+ if (path.StartsWith(ExtendedUncPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ normalizedPath = @"\\" + path[ExtendedUncPrefix.Length..];
+ }
+ else if (path.StartsWith(ExtendedPathPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ normalizedPath = path[ExtendedPathPrefix.Length..];
+ }
+ else
+ {
+ normalizedPath = path;
+ }
+
+ return Path.TrimEndingDirectorySeparator(Path.GetFullPath(normalizedPath));
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct ByHandleFileInformation
+ {
+ public uint FileAttributes;
+ public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime;
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime;
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime;
+ public uint VolumeSerialNumber;
+ public uint FileSizeHigh;
+ public uint FileSizeLow;
+ public uint NumberOfLinks;
+ public uint FileIndexHigh;
+ public uint FileIndexLow;
+ }
+
+ [DllImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, CharSet = CharSet.Unicode)]
+ private static extern SafeFileHandle CreateFile(
+ string fileName,
+ uint desiredAccess,
+ uint shareMode,
+ IntPtr securityAttributes,
+ uint creationDisposition,
+ uint flagsAndAttributes,
+ IntPtr templateFile);
+
+ [DllImport("kernel32.dll", EntryPoint = "GetFinalPathNameByHandleW", SetLastError = true, CharSet = CharSet.Unicode)]
+ private static extern uint GetFinalPathNameByHandle(
+ SafeFileHandle file,
+ StringBuilder filePath,
+ uint filePathLength,
+ uint flags);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool GetFileInformationByHandle(
+ SafeFileHandle file,
+ out ByHandleFileInformation fileInformation);
+}
diff --git a/GenLauncherGO.Infrastructure/GenLauncherGO.Infrastructure.csproj b/GenLauncherGO.Infrastructure/GenLauncherGO.Infrastructure.csproj
new file mode 100644
index 00000000..5967758d
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/GenLauncherGO.Infrastructure.csproj
@@ -0,0 +1,21 @@
+
+
+ net10.0-windows
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GenLauncherGO.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/GenLauncherGO.Infrastructure/InfrastructureServiceCollectionExtensions.cs
new file mode 100644
index 00000000..97e6a317
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/InfrastructureServiceCollectionExtensions.cs
@@ -0,0 +1,77 @@
+using System;
+using GenLauncherGO.Core.Launching.Contracts;
+using GenLauncherGO.Core.Mods.Contracts;
+using GenLauncherGO.Core.Remote;
+using GenLauncherGO.Core.Shell.Contracts;
+using GenLauncherGO.Core.Updating.Contracts;
+using GenLauncherGO.Infrastructure.Archives;
+using GenLauncherGO.Infrastructure.Archives.Contracts;
+using GenLauncherGO.Infrastructure.Integrity.Contracts;
+using GenLauncherGO.Infrastructure.Integrity.Services;
+using GenLauncherGO.Infrastructure.Launching.Contracts;
+using GenLauncherGO.Infrastructure.Launching.Services;
+using GenLauncherGO.Infrastructure.Launching.Support;
+using GenLauncherGO.Infrastructure.Mods.Contracts;
+using GenLauncherGO.Infrastructure.Mods.Services;
+using GenLauncherGO.Infrastructure.Persistence.Services;
+using GenLauncherGO.Infrastructure.Remote;
+using GenLauncherGO.Infrastructure.Remote.Contracts;
+using GenLauncherGO.Infrastructure.Shell.Services;
+using GenLauncherGO.Infrastructure.Updating.Clients;
+using GenLauncherGO.Infrastructure.Updating.Contracts;
+using GenLauncherGO.Infrastructure.Updating.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace GenLauncherGO.Infrastructure;
+
+///
+/// Registers the launcher runtime's Infrastructure services after storage and logging bootstrap is complete.
+///
+public static class InfrastructureServiceCollectionExtensions
+{
+ public static IServiceCollection AddGenLauncherGoInfrastructure(this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ services.TryAddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ services.AddSingleton();
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ services.AddSingleton();
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ return services;
+ }
+}
diff --git a/GenLauncherGO.Infrastructure/Integrity/Contracts/IContentIntegrityService.cs b/GenLauncherGO.Infrastructure/Integrity/Contracts/IContentIntegrityService.cs
new file mode 100644
index 00000000..dc8952b3
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Integrity/Contracts/IContentIntegrityService.cs
@@ -0,0 +1,52 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using GenLauncherGO.Core.Integrity.Models;
+using GenLauncherGO.Core.Startup;
+
+namespace GenLauncherGO.Infrastructure.Integrity.Contracts;
+
+///
+/// Verifies, snapshots, and cleans launcher-owned content.
+///
+internal interface IContentIntegrityService
+{
+ ///
+ /// Verifies all targets against trusted snapshots owned by the supplied immutable game namespace.
+ ///
+ Task VerifyAsync(
+ LauncherPaths paths,
+ IReadOnlyList targets,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Captures a trusted snapshot in the supplied immutable game namespace only when a target currently contains
+ /// exactly the expected safe file set.
+ ///
+ ///
+ /// when a snapshot was captured; otherwise, when the current
+ /// file set contains extras, missing files, empty directories, unsafe links, or unreadable entries.
+ ///
+ Task CaptureSnapshotIfMatchesExpectedFileSetAsync(
+ LauncherPaths paths,
+ ContentIntegrityTarget target,
+ IReadOnlySet expectedRelativePaths,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Replaces a target's trusted snapshot with its current safe directory contents.
+ ///
+ Task CaptureSnapshotAsync(
+ LauncherPaths paths,
+ ContentIntegrityTarget target,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Deletes managed entries explicitly listed for deletion in a verification report, resolving them only within
+ /// the verified targets.
+ ///
+ Task ApplyCleanupAsync(
+ ContentIntegrityReport report,
+ IReadOnlyList targets,
+ CancellationToken cancellationToken);
+}
diff --git a/GenLauncherGO.Infrastructure/Integrity/Services/FileSystemContentIntegrityService.cs b/GenLauncherGO.Infrastructure/Integrity/Services/FileSystemContentIntegrityService.cs
new file mode 100644
index 00000000..86ec42d5
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Integrity/Services/FileSystemContentIntegrityService.cs
@@ -0,0 +1,482 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using GenLauncherGO.Core.Integrity.Models;
+using GenLauncherGO.Core.IO;
+using GenLauncherGO.Core.Startup;
+using GenLauncherGO.Infrastructure.Common;
+using GenLauncherGO.Infrastructure.Integrity.Contracts;
+using GenLauncherGO.Infrastructure.Integrity.Support;
+using GenLauncherGO.Infrastructure.Persistence.Services;
+using Microsoft.Extensions.Logging;
+
+namespace GenLauncherGO.Infrastructure.Integrity.Services;
+
+///
+/// Verifies launcher-owned content with SHA-256 snapshots and applies confirmed managed-content cleanup.
+///
+internal sealed class FileSystemContentIntegrityService : IContentIntegrityService
+{
+ private readonly ILogger _logger;
+
+ private readonly IAtomicFileWriter _atomicFileWriter;
+
+ public FileSystemContentIntegrityService(
+ IAtomicFileWriter atomicFileWriter,
+ ILogger logger)
+ {
+ _atomicFileWriter = atomicFileWriter ?? throw new ArgumentNullException(nameof(atomicFileWriter));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task VerifyAsync(
+ LauncherPaths paths,
+ IReadOnlyList targets,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(paths);
+ ArgumentNullException.ThrowIfNull(targets);
+ ContentIntegritySnapshotStore snapshotStore = CreateSnapshotStore(paths);
+
+ if (targets.Count > 0)
+ {
+ _logger.LogInformation(
+ "Starting content integrity verification for {TargetCount} target(s).",
+ targets.Count);
+ }
+ else
+ {
+ _logger.LogDebug("Skipped content integrity verification because no targets were supplied.");
+ }
+
+ List issues = new();
+ foreach (ContentIntegrityTarget target in targets)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ ContentIntegritySnapshotDocument? snapshot;
+ try
+ {
+ snapshot = await snapshotStore.ReadSnapshotAsync(target.Id, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception exception) when (
+ exception is IOException or UnauthorizedAccessException or JsonException or InvalidDataException)
+ {
+ _logger.LogError(
+ exception,
+ "Failed to read integrity snapshot for {TargetName}.",
+ target.DisplayName);
+ issues.Add(CreateIssue(
+ target,
+ IntegrityIssueKind.VerificationError,
+ IntegrityIssueAction.Block,
+ ".",
+ exception.Message));
+ continue;
+ }
+
+ if (snapshot is null || snapshot.SourceKind != target.SourceKind)
+ {
+ _logger.LogWarning(
+ "Content integrity target {TargetName} is untracked or has changed source kind. Current source kind: {SourceKind}.",
+ target.DisplayName,
+ target.SourceKind);
+ issues.Add(CreateIssue(
+ target,
+ IntegrityIssueKind.Untracked,
+ GetUntrackedAction(target.SourceKind),
+ "."));
+
+ try
+ {
+ ContentIntegrityScanResult untrackedScan =
+ await ContentIntegrityScanner.ScanAsync(target, cancellationToken).ConfigureAwait(false);
+ AddScanSafetyIssues(target, untrackedScan, issues);
+ }
+ catch (Exception exception) when (
+ exception is IOException or UnauthorizedAccessException or InvalidDataException)
+ {
+ _logger.LogError(
+ exception,
+ "Failed to verify untracked content safety for {TargetName}.",
+ target.DisplayName);
+ issues.Add(CreateIssue(
+ target,
+ IntegrityIssueKind.VerificationError,
+ IntegrityIssueAction.Block,
+ ".",
+ exception.Message));
+ }
+
+ continue;
+ }
+
+ try
+ {
+ ContentIntegrityScanResult scan =
+ await ContentIntegrityScanner.ScanAsync(target, cancellationToken).ConfigureAwait(false);
+ AddScanIssues(target, snapshot, scan, issues);
+ }
+ catch (Exception exception) when (
+ exception is IOException or UnauthorizedAccessException or InvalidDataException)
+ {
+ _logger.LogError(
+ exception,
+ "Failed to verify content for {TargetName}.",
+ target.DisplayName);
+ issues.Add(CreateIssue(
+ target,
+ IntegrityIssueKind.VerificationError,
+ IntegrityIssueAction.Block,
+ ".",
+ exception.Message));
+ }
+ }
+
+ if (targets.Count > 0)
+ {
+ _logger.LogInformation(
+ "Completed content integrity verification for {TargetCount} target(s); issues: {IssueCount}.",
+ targets.Count,
+ issues.Count);
+ }
+
+ return new ContentIntegrityReport(issues);
+ }
+
+ public async Task CaptureSnapshotIfMatchesExpectedFileSetAsync(
+ LauncherPaths paths,
+ ContentIntegrityTarget target,
+ IReadOnlySet expectedRelativePaths,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(paths);
+ ArgumentNullException.ThrowIfNull(target);
+ ArgumentNullException.ThrowIfNull(expectedRelativePaths);
+ ContentIntegritySnapshotStore snapshotStore = CreateSnapshotStore(paths);
+
+ ContentIntegrityScanResult scan =
+ await ContentIntegrityScanner.ScanAsync(target, cancellationToken).ConfigureAwait(false);
+ if (!ContentIntegrityScanner.MatchesExpectedFileSet(scan, expectedRelativePaths))
+ {
+ _logger.LogWarning(
+ "Skipped integrity snapshot capture for {TargetName} because the scanned file set did not match the expected package manifest.",
+ target.DisplayName);
+ return false;
+ }
+
+ await snapshotStore.WriteSnapshotAsync(target, scan, cancellationToken).ConfigureAwait(false);
+ return true;
+ }
+
+ public async Task CaptureSnapshotAsync(
+ LauncherPaths paths,
+ ContentIntegrityTarget target,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(paths);
+ ArgumentNullException.ThrowIfNull(target);
+ ContentIntegritySnapshotStore snapshotStore = CreateSnapshotStore(paths);
+
+ ContentIntegrityScanResult scan =
+ await ContentIntegrityScanner.ScanAsync(target, cancellationToken).ConfigureAwait(false);
+ if (scan.UnsafeLinks.Count > 0 || scan.Errors.Count > 0)
+ {
+ _logger.LogWarning(
+ "Blocked integrity snapshot capture for {TargetName}; unsafe links: {UnsafeLinkCount}; errors: {ErrorCount}.",
+ target.DisplayName,
+ scan.UnsafeLinks.Count,
+ scan.Errors.Count);
+ throw new IOException("Content containing unsafe links or unreadable entries cannot be trusted.");
+ }
+
+ await snapshotStore.WriteSnapshotAsync(target, scan, cancellationToken).ConfigureAwait(false);
+ }
+
+ public Task ApplyCleanupAsync(
+ ContentIntegrityReport report,
+ IReadOnlyList targets,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(report);
+ ArgumentNullException.ThrowIfNull(targets);
+
+ var targetIndex =
+ targets.ToDictionary(target => target.Id, StringComparer.Ordinal);
+ int deleteIssueCount = report.Issues.Count(issue => issue.Action == IntegrityIssueAction.Delete);
+
+ _logger.LogInformation(
+ "Applying content integrity cleanup for {DeleteIssueCount} delete issue(s).",
+ deleteIssueCount);
+
+ foreach (ContentIntegrityIssue issue in report.Issues.Where(issue =>
+ issue.Action == IntegrityIssueAction.Delete))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!targetIndex.TryGetValue(issue.TargetId, out ContentIntegrityTarget? target))
+ {
+ throw new InvalidDataException("The cleanup report references an unknown integrity target.");
+ }
+
+ string path = ContentIntegrityPath.ResolveRelativePath(target.RootDirectory, issue.RelativePath);
+ string? parentDirectory = Path.GetDirectoryName(path);
+ if (!string.IsNullOrWhiteSpace(parentDirectory))
+ {
+ FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints(
+ parentDirectory,
+ "Integrity cleanup paths must be rooted.",
+ "Integrity cleanup paths must not cross reparse points.");
+ }
+
+ DeleteEntry(path, issue, target);
+ }
+
+ foreach (ContentIntegrityTarget target in targets.Where(target =>
+ report.Issues.Any(issue =>
+ issue.TargetId == target.Id &&
+ issue.Action == IntegrityIssueAction.Delete)))
+ {
+ DeleteUnexpectedEmptyDirectories(target, cancellationToken);
+ }
+
+ _logger.LogInformation(
+ "Completed content integrity cleanup for {DeleteIssueCount} delete issue(s).",
+ deleteIssueCount);
+ return Task.CompletedTask;
+ }
+
+ private ContentIntegritySnapshotStore CreateSnapshotStore(LauncherPaths paths)
+ {
+ return new ContentIntegritySnapshotStore(
+ paths.IntegrityDirectory,
+ _atomicFileWriter,
+ _logger);
+ }
+
+ private static void AddScanIssues(
+ ContentIntegrityTarget target,
+ ContentIntegritySnapshotDocument snapshot,
+ ContentIntegrityScanResult scan,
+ List issues)
+ {
+ var expectedFiles =
+ snapshot.Files.ToDictionary(file => file.RelativePath, StringComparer.OrdinalIgnoreCase);
+
+ foreach (ContentIntegritySnapshotFileEntry expected in expectedFiles.Values)
+ {
+ if (!scan.Files.TryGetValue(expected.RelativePath, out ContentIntegrityScannedFile? current))
+ {
+ issues.Add(CreateIssue(
+ target,
+ IntegrityIssueKind.MissingFile,
+ GetManagedOrManualAction(target.SourceKind, IntegrityIssueKind.MissingFile),
+ expected.RelativePath,
+ expectedSizeBytes: expected.Size));
+ continue;
+ }
+
+ if (current.Size != expected.Size ||
+ !string.Equals(current.Sha256, expected.Sha256, StringComparison.OrdinalIgnoreCase))
+ {
+ issues.Add(CreateIssue(
+ target,
+ IntegrityIssueKind.ModifiedFile,
+ GetManagedOrManualAction(target.SourceKind, IntegrityIssueKind.ModifiedFile),
+ expected.RelativePath,
+ expectedSizeBytes: expected.Size));
+ }
+ }
+
+ foreach (ContentIntegrityScannedFile current in scan.Files.Values)
+ {
+ if (!expectedFiles.ContainsKey(current.RelativePath))
+ {
+ issues.Add(CreateIssue(
+ target,
+ IntegrityIssueKind.UnexpectedFile,
+ GetManagedOrManualAction(target.SourceKind, IntegrityIssueKind.UnexpectedFile),
+ current.RelativePath));
+ }
+ }
+
+ HashSet expectedEmptyDirectories =
+ new(snapshot.EmptyDirectories, StringComparer.OrdinalIgnoreCase);
+ foreach (string directory in scan.EmptyDirectories)
+ {
+ if (!expectedEmptyDirectories.Contains(directory))
+ {
+ issues.Add(CreateIssue(
+ target,
+ IntegrityIssueKind.EmptyDirectory,
+ GetManagedOrManualAction(target.SourceKind, IntegrityIssueKind.EmptyDirectory),
+ directory));
+ }
+ }
+
+ AddScanSafetyIssues(target, scan, issues);
+ }
+
+ private static void AddScanSafetyIssues(
+ ContentIntegrityTarget target,
+ ContentIntegrityScanResult scan,
+ List issues)
+ {
+ foreach (string unsafeLink in scan.UnsafeLinks)
+ {
+ issues.Add(CreateIssue(
+ target,
+ IntegrityIssueKind.UnsafeLink,
+ target.SourceKind is ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile
+ ? IntegrityIssueAction.Delete
+ : IntegrityIssueAction.Block,
+ unsafeLink));
+ }
+
+ foreach (ContentIntegrityScanError error in scan.Errors)
+ {
+ issues.Add(CreateIssue(
+ target,
+ IntegrityIssueKind.VerificationError,
+ IntegrityIssueAction.Block,
+ error.RelativePath,
+ error.Message));
+ }
+ }
+
+ private static ContentIntegrityIssue CreateIssue(
+ ContentIntegrityTarget target,
+ IntegrityIssueKind kind,
+ IntegrityIssueAction action,
+ string relativePath,
+ string? message = null,
+ long? expectedSizeBytes = null)
+ {
+ return new ContentIntegrityIssue(
+ target.Id,
+ target.DisplayName,
+ target.SourceKind,
+ kind,
+ action,
+ LexicalPath.NormalizeRelativePath(relativePath),
+ message,
+ expectedSizeBytes);
+ }
+
+ private static IntegrityIssueAction GetUntrackedAction(ContentSourceKind sourceKind)
+ {
+ return sourceKind switch
+ {
+ ContentSourceKind.ManagedS3 => IntegrityIssueAction.Repair,
+ ContentSourceKind.ManagedSingleFile => IntegrityIssueAction.Redownload,
+ ContentSourceKind.Manual => IntegrityIssueAction.Absorb,
+ _ => IntegrityIssueAction.TrustAsManual,
+ };
+ }
+
+ private static IntegrityIssueAction GetManagedOrManualAction(
+ ContentSourceKind sourceKind,
+ IntegrityIssueKind issueKind)
+ {
+ if (sourceKind == ContentSourceKind.Manual)
+ {
+ return IntegrityIssueAction.Absorb;
+ }
+
+ if (sourceKind == ContentSourceKind.ManagedSingleFile)
+ {
+ return issueKind is IntegrityIssueKind.UnexpectedFile or IntegrityIssueKind.EmptyDirectory
+ ? IntegrityIssueAction.Delete
+ : IntegrityIssueAction.Redownload;
+ }
+
+ if (sourceKind == ContentSourceKind.ManagedS3)
+ {
+ return issueKind is IntegrityIssueKind.UnexpectedFile or IntegrityIssueKind.EmptyDirectory
+ ? IntegrityIssueAction.Delete
+ : IntegrityIssueAction.Repair;
+ }
+
+ return IntegrityIssueAction.TrustAsManual;
+ }
+
+ ///
+ /// Deletes one confirmed managed-content entry without following links.
+ ///
+ private void DeleteEntry(
+ string path,
+ ContentIntegrityIssue issue,
+ ContentIntegrityTarget target)
+ {
+ FileAttributes attributes;
+ try
+ {
+ attributes = File.GetAttributes(path);
+ }
+ catch (FileNotFoundException)
+ {
+ return;
+ }
+ catch (DirectoryNotFoundException)
+ {
+ return;
+ }
+
+ if ((attributes & FileAttributes.Directory) != 0)
+ {
+ Directory.Delete(path, recursive: false);
+ }
+ else
+ {
+ File.Delete(path);
+ }
+
+ _logger.LogInformation(
+ "Deleted confirmed unexpected integrity entry {RelativePath} from {TargetName}.",
+ issue.RelativePath,
+ target.DisplayName);
+ }
+
+ private void DeleteUnexpectedEmptyDirectories(
+ ContentIntegrityTarget target,
+ CancellationToken cancellationToken)
+ {
+ if (!Directory.Exists(target.RootDirectory))
+ {
+ return;
+ }
+
+ if (FileSystemPathSafety.IsReparsePoint(target.RootDirectory))
+ {
+ return;
+ }
+
+ foreach (string directory in Directory
+ .EnumerateDirectories(
+ target.RootDirectory,
+ "*",
+ FileSystemPathSafety.CreateRecursiveNoLinksOptions())
+ .OrderByDescending(path => path.Length)
+ .ToList())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ string relativePath = ContentIntegrityPath.GetRelativePath(target.RootDirectory, directory);
+ if (ContentIntegrityPath.IsIgnored(target, relativePath) ||
+ FileSystemPathSafety.IsReparsePoint(directory) ||
+ Directory.EnumerateFileSystemEntries(directory).Any())
+ {
+ continue;
+ }
+
+ Directory.Delete(directory);
+ _logger.LogInformation(
+ "Deleted empty integrity cleanup directory {RelativePath} from {TargetName}.",
+ relativePath,
+ target.DisplayName);
+ }
+ }
+
+}
diff --git a/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityPath.cs b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityPath.cs
new file mode 100644
index 00000000..916f75a0
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityPath.cs
@@ -0,0 +1,47 @@
+using System.IO;
+using GenLauncherGO.Core.Integrity.Models;
+using GenLauncherGO.Core.IO;
+
+namespace GenLauncherGO.Infrastructure.Integrity.Support;
+
+///
+/// Applies integrity-target containment and ignore policy to canonical lexical paths.
+///
+internal static class ContentIntegrityPath
+{
+ ///
+ /// Gets a normalized relative path after proving that it does not leave the verified target.
+ ///
+ public static string GetRelativePath(string root, string path)
+ {
+ string relativePath = LexicalPath.GetRelativePath(root, path);
+ if (LexicalPath.RelativePathLeavesRoot(relativePath))
+ {
+ throw new InvalidDataException("A scanned entry resolved outside the verified target.");
+ }
+
+ return relativePath;
+ }
+
+ ///
+ /// Resolves an integrity issue path after proving that it remains in the verified target.
+ ///
+ public static string ResolveRelativePath(string root, string relativePath)
+ {
+ string candidate = LexicalPath.ResolvePath(root, relativePath);
+ if (!LexicalPath.IsPathInDirectory(candidate, root))
+ {
+ throw new InvalidDataException("An integrity issue path resolved outside its target root.");
+ }
+
+ return candidate;
+ }
+
+ ///
+ /// Determines whether a target-relative path belongs to preserved inactive content.
+ ///
+ public static bool IsIgnored(ContentIntegrityTarget target, string relativePath)
+ {
+ return target.IgnoredRelativePaths.Contains(LexicalPath.NormalizeRelativePath(relativePath));
+ }
+}
diff --git a/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityScanner.cs b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityScanner.cs
new file mode 100644
index 00000000..74aea7d1
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityScanner.cs
@@ -0,0 +1,157 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Threading;
+using System.Threading.Tasks;
+using GenLauncherGO.Core.Integrity.Models;
+using GenLauncherGO.Core.IO;
+using GenLauncherGO.Infrastructure.Common;
+
+namespace GenLauncherGO.Infrastructure.Integrity.Support;
+
+///
+/// Scans content integrity targets without following reparse points.
+///
+internal static class ContentIntegrityScanner
+{
+ ///
+ /// Scans one target without following reparse points.
+ ///
+ public static async Task ScanAsync(
+ ContentIntegrityTarget target,
+ CancellationToken cancellationToken)
+ {
+ Dictionary files = new(StringComparer.OrdinalIgnoreCase);
+ List emptyDirectories = new();
+ List unsafeLinks = new();
+ List errors = new();
+
+ string root = LexicalPath.NormalizeFullPath(target.RootDirectory);
+ if (!Directory.Exists(root))
+ {
+ return new ContentIntegrityScanResult(files, emptyDirectories, unsafeLinks, errors);
+ }
+
+ if (FileSystemPathSafety.IsReparsePoint(root))
+ {
+ unsafeLinks.Add(".");
+ return new ContentIntegrityScanResult(files, emptyDirectories, unsafeLinks, errors);
+ }
+
+ Stack pendingDirectories = new();
+ pendingDirectories.Push(root);
+
+ while (pendingDirectories.Count > 0)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ string directory = pendingDirectories.Pop();
+ string directoryRelativePath = ContentIntegrityPath.GetRelativePath(root, directory);
+
+ try
+ {
+ if (!string.Equals(directory, root, StringComparison.OrdinalIgnoreCase) &&
+ FileSystemPathSafety.IsReparsePoint(directory))
+ {
+ unsafeLinks.Add(directoryRelativePath);
+ continue;
+ }
+
+ var entries = Directory.EnumerateFileSystemEntries(directory).ToList();
+ if (entries.Count == 0 &&
+ !string.Equals(directory, root, StringComparison.OrdinalIgnoreCase) &&
+ !ContentIntegrityPath.IsIgnored(target, directoryRelativePath))
+ {
+ emptyDirectories.Add(directoryRelativePath);
+ }
+
+ foreach (string entry in entries)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ string relativePath = ContentIntegrityPath.GetRelativePath(root, entry);
+ FileAttributes attributes = File.GetAttributes(entry);
+ if (ContentIntegrityPath.IsIgnored(target, relativePath))
+ {
+ if ((attributes & FileAttributes.ReparsePoint) != 0)
+ {
+ unsafeLinks.Add(relativePath);
+ }
+
+ continue;
+ }
+
+ if ((attributes & FileAttributes.ReparsePoint) != 0)
+ {
+ unsafeLinks.Add(relativePath);
+ continue;
+ }
+
+ if ((attributes & FileAttributes.Directory) != 0)
+ {
+ pendingDirectories.Push(entry);
+ continue;
+ }
+
+ FileInfo fileInfo = new(entry);
+ string sha256 = await ComputeSha256Async(fileInfo.FullName, cancellationToken)
+ .ConfigureAwait(false);
+ files[relativePath] = new ContentIntegrityScannedFile(relativePath, fileInfo.Length, sha256);
+ }
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
+ {
+ errors.Add(new ContentIntegrityScanError(directoryRelativePath, exception.Message));
+ }
+ }
+
+ return new ContentIntegrityScanResult(files, emptyDirectories, unsafeLinks, errors);
+ }
+
+ ///
+ /// Determines whether a completed scan exactly matches an expected safe file set.
+ ///
+ public static bool MatchesExpectedFileSet(
+ ContentIntegrityScanResult scan,
+ IReadOnlySet expectedRelativePaths)
+ {
+ if (scan.EmptyDirectories.Count > 0 ||
+ scan.UnsafeLinks.Count > 0 ||
+ scan.Errors.Count > 0)
+ {
+ return false;
+ }
+
+ var normalizedExpectedPaths = expectedRelativePaths
+ .Select(LexicalPath.NormalizeRelativePath)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ return scan.Files.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase)
+ .SetEquals(normalizedExpectedPaths);
+ }
+
+ private static async Task ComputeSha256Async(
+ string filePath,
+ CancellationToken cancellationToken)
+ {
+ await using FileStream stream = new(
+ filePath,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.Read,
+ 1024 * 1024,
+ FileOptions.Asynchronous | FileOptions.SequentialScan);
+ byte[] hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
+ return Convert.ToHexString(hash);
+ }
+
+}
+
+internal sealed record ContentIntegrityScannedFile(string RelativePath, long Size, string Sha256);
+
+internal sealed record ContentIntegrityScanError(string RelativePath, string Message);
+
+internal sealed record ContentIntegrityScanResult(
+ IReadOnlyDictionary Files,
+ IReadOnlyList EmptyDirectories,
+ IReadOnlyList UnsafeLinks,
+ IReadOnlyList Errors);
diff --git a/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegritySnapshotStore.cs b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegritySnapshotStore.cs
new file mode 100644
index 00000000..79f6b1df
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegritySnapshotStore.cs
@@ -0,0 +1,147 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using GenLauncherGO.Core.Integrity.Models;
+using GenLauncherGO.Core.IO;
+using GenLauncherGO.Infrastructure.Persistence.Services;
+using Microsoft.Extensions.Logging;
+
+namespace GenLauncherGO.Infrastructure.Integrity.Support;
+
+///
+/// Persists content integrity snapshots for verified targets.
+///
+internal sealed class ContentIntegritySnapshotStore
+{
+ private const int SnapshotSchemaVersion = 1;
+
+ private static readonly JsonSerializerOptions _jsonOptions = new()
+ {
+ WriteIndented = true,
+ };
+
+ private readonly IAtomicFileWriter _atomicFileWriter;
+
+ private readonly ILogger _logger;
+
+ private readonly string _snapshotDirectory;
+
+ public ContentIntegritySnapshotStore(
+ string snapshotDirectory,
+ IAtomicFileWriter atomicFileWriter,
+ ILogger logger)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(snapshotDirectory);
+ _snapshotDirectory = LexicalPath.NormalizeFullPath(snapshotDirectory);
+ _atomicFileWriter = atomicFileWriter ?? throw new ArgumentNullException(nameof(atomicFileWriter));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// Writes a trusted snapshot for a previously completed safe scan.
+ ///
+ public async Task WriteSnapshotAsync(
+ ContentIntegrityTarget target,
+ ContentIntegrityScanResult scan,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ ContentIntegritySnapshotDocument snapshot = new(
+ SnapshotSchemaVersion,
+ target.Id,
+ target.SourceKind,
+ scan.Files.Values
+ .OrderBy(file => file.RelativePath, StringComparer.OrdinalIgnoreCase)
+ .Select(file => new ContentIntegritySnapshotFileEntry(file.RelativePath, file.Size, file.Sha256))
+ .ToList(),
+ target.SourceKind == ContentSourceKind.Manual
+ ? scan.EmptyDirectories
+ .OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
+ .ToList()
+ : Array.Empty());
+
+ string snapshotPath = GetSnapshotPath(target.Id);
+ await _atomicFileWriter.WriteAsync(
+ snapshotPath,
+ (stream, token) => JsonSerializer.SerializeAsync(stream, snapshot, _jsonOptions, token),
+ cancellationToken)
+ .ConfigureAwait(false);
+ _logger.LogInformation(
+ "Captured SHA-256 integrity snapshot for {TargetName}; files: {FileCount}.",
+ target.DisplayName,
+ snapshot.Files.Count);
+ }
+
+ ///
+ /// Reads a persisted snapshot, returning when none exists.
+ ///
+ public async Task ReadSnapshotAsync(
+ string targetId,
+ CancellationToken cancellationToken)
+ {
+ string path = GetSnapshotPath(targetId);
+ if (!File.Exists(path))
+ {
+ _logger.LogDebug(
+ "No integrity snapshot exists for target {TargetId}.",
+ targetId);
+ return null;
+ }
+
+ await using FileStream stream = new(
+ path,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.Read,
+ 64 * 1024,
+ FileOptions.Asynchronous | FileOptions.SequentialScan);
+ ContentIntegritySnapshotDocument? snapshot =
+ await JsonSerializer.DeserializeAsync(
+ stream,
+ _jsonOptions,
+ cancellationToken).ConfigureAwait(false);
+ if (snapshot is null ||
+ snapshot.SchemaVersion != SnapshotSchemaVersion ||
+ !string.Equals(snapshot.TargetId, targetId, StringComparison.Ordinal))
+ {
+ _logger.LogWarning(
+ "Integrity snapshot for target {TargetId} has unsupported schema or ownership.",
+ targetId);
+ throw new InvalidDataException("The integrity snapshot schema or ownership is not supported.");
+ }
+
+ _logger.LogDebug(
+ "Loaded integrity snapshot for target {TargetId}; files: {FileCount}.",
+ targetId,
+ snapshot.Files.Count);
+ return snapshot;
+ }
+
+ private string GetSnapshotPath(string targetId)
+ {
+ byte[] identifierHash = SHA256.HashData(Encoding.UTF8.GetBytes(targetId));
+ return Path.Combine(_snapshotDirectory, Convert.ToHexString(identifierHash) + ".json");
+ }
+}
+
+///
+/// Describes one trusted file entry in a snapshot document.
+///
+internal sealed record ContentIntegritySnapshotFileEntry(string RelativePath, long Size, string Sha256);
+
+///
+/// Describes a persisted trusted content snapshot.
+///
+internal sealed record ContentIntegritySnapshotDocument(
+ int SchemaVersion,
+ string TargetId,
+ ContentSourceKind SourceKind,
+ IReadOnlyList Files,
+ IReadOnlyList EmptyDirectories);
diff --git a/GenLauncherGO.Infrastructure/Launching/Contracts/ILaunchContentIntegrityTargetBuilder.cs b/GenLauncherGO.Infrastructure/Launching/Contracts/ILaunchContentIntegrityTargetBuilder.cs
new file mode 100644
index 00000000..c490c913
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Launching/Contracts/ILaunchContentIntegrityTargetBuilder.cs
@@ -0,0 +1,10 @@
+using System.Collections.Generic;
+using GenLauncherGO.Core.Launching.Models;
+
+namespace GenLauncherGO.Infrastructure.Launching.Contracts;
+
+internal interface ILaunchContentIntegrityTargetBuilder
+{
+ IReadOnlyList BuildTargets(
+ LaunchContentIntegrityTargetRequest request);
+}
diff --git a/GenLauncherGO.Infrastructure/Launching/Services/DeploymentLaunchPreparationService.cs b/GenLauncherGO.Infrastructure/Launching/Services/DeploymentLaunchPreparationService.cs
new file mode 100644
index 00000000..37023434
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Launching/Services/DeploymentLaunchPreparationService.cs
@@ -0,0 +1,125 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using GenLauncherGO.Core.Launching.Contracts;
+using GenLauncherGO.Core.Launching.Models;
+using GenLauncherGO.Core.Mods.Models;
+using GenLauncherGO.Core.Mods.Services;
+using GenLauncherGO.Core.Startup;
+using GenLauncherGO.Infrastructure.Launching.Support;
+using Microsoft.Extensions.Logging;
+
+namespace GenLauncherGO.Infrastructure.Launching.Services;
+
+///
+/// Orchestrates launch preparation by translating selected content into deployment packages.
+///
+internal sealed class DeploymentLaunchPreparationService : ILaunchPreparationService
+{
+ ///
+ /// The base game script files that must be hidden while a modded game launch is deployed.
+ ///
+ private static readonly IReadOnlyList _baseGameScriptRelativePaths =
+ [
+ "Data/Scripts/MultiplayerScripts.scb",
+ "Data/Scripts/SkirmishScripts.scb",
+ "Data/Scripts/Scripts.ini",
+ ];
+
+ private readonly FileSystemDeploymentService _deploymentEngine;
+
+ private readonly ILogger _logger;
+
+ public DeploymentLaunchPreparationService(
+ FileSystemDeploymentService deploymentEngine,
+ ILogger logger)
+ {
+ _deploymentEngine = deploymentEngine ?? throw new ArgumentNullException(nameof(deploymentEngine));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public bool Prepare(
+ LaunchPreparationRequest request,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ IReadOnlyList packages = CreateDeploymentPackages(request);
+ IReadOnlyList disabledTargetRelativePaths = request.DisableBaseGameScriptFiles
+ ? _baseGameScriptRelativePaths
+ : Array.Empty();
+ DeploymentResult result = _deploymentEngine.Prepare(
+ request.Paths,
+ packages,
+ disabledTargetRelativePaths,
+ cancellationToken);
+ LogDeploymentFailures("prepare launch content", result);
+ return result.Succeeded;
+ }
+
+ public bool Cleanup(
+ LauncherPaths paths,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(paths);
+
+ DeploymentResult result = _deploymentEngine.Cleanup(paths, cancellationToken);
+ LogDeploymentFailures("clean up launch content", result);
+ return result.Succeeded;
+ }
+
+ public bool Recover(
+ LauncherPaths paths,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(paths);
+
+ DeploymentResult result = _deploymentEngine.Recover(paths, cancellationToken);
+ LogDeploymentFailures("recover launch content", result);
+ return result.Succeeded;
+ }
+
+ private IReadOnlyList CreateDeploymentPackages(LaunchPreparationRequest request)
+ {
+ return request.Versions
+ .Select((version, index) => CreateDeploymentPackage(request, version, index))
+ .ToList();
+ }
+
+ private DeploymentPackage CreateDeploymentPackage(
+ LaunchPreparationRequest request,
+ LauncherContentVersion version,
+ int index)
+ {
+ ArgumentNullException.ThrowIfNull(version);
+
+ return new DeploymentPackage(ResolvePackageRoot(request, version), index);
+ }
+
+ private string ResolvePackageRoot(
+ LaunchPreparationRequest request,
+ LauncherContentVersion version)
+ {
+ return LauncherContentPathResolver.ResolveVersionPath(request.Paths, version.ContentKey)?.FullPath
+ ?? string.Empty;
+ }
+
+ private void LogDeploymentFailures(string operationName, DeploymentResult result)
+ {
+ if (result.Succeeded)
+ {
+ return;
+ }
+
+ foreach (DeploymentFailure failure in result.Failures)
+ {
+ _logger.LogError(
+ "Failed to {OperationName}. Kind: {FailureKind}; Path: {Path}; Message: {Message}",
+ operationName,
+ failure.Kind,
+ failure.Path,
+ failure.Message);
+ }
+ }
+}
diff --git a/GenLauncherGO.Infrastructure/Launching/Services/FileSystemDeploymentService.cs b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemDeploymentService.cs
new file mode 100644
index 00000000..6d70d8d0
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemDeploymentService.cs
@@ -0,0 +1,1138 @@
+using System;
+using System.Buffers;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Threading;
+using GenLauncherGO.Core.IO;
+using GenLauncherGO.Core.Startup;
+using GenLauncherGO.Infrastructure.Common;
+using GenLauncherGO.Infrastructure.Launching.Support;
+using Microsoft.Extensions.Logging;
+
+namespace GenLauncherGO.Infrastructure.Launching.Services;
+
+///
+/// Deploys selected package files into the game directory and persists enough manifest state to undo the deployment.
+///
+internal sealed class FileSystemDeploymentService
+{
+ private const int FileBufferSize = 1024 * 128;
+
+ private readonly IHardLinkCreator _hardLinkCreator;
+
+ private readonly ILogger _logger;
+
+ private readonly DeploymentStateStore _stateStore;
+
+ public FileSystemDeploymentService(
+ IHardLinkCreator hardLinkCreator,
+ ILogger logger)
+ {
+ _hardLinkCreator = hardLinkCreator ?? throw new ArgumentNullException(nameof(hardLinkCreator));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _stateStore = new DeploymentStateStore(_logger);
+ }
+
+ ///
+ /// Prepares the game directory by deploying the selected packages.
+ ///
+ public DeploymentResult Prepare(
+ LauncherPaths paths,
+ IReadOnlyList packages,
+ IReadOnlyList disabledTargetRelativePaths,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(paths);
+ ArgumentNullException.ThrowIfNull(packages);
+ ArgumentNullException.ThrowIfNull(disabledTargetRelativePaths);
+
+ IReadOnlyList normalizedDisabledTargetRelativePaths = disabledTargetRelativePaths
+ .Where(path => !string.IsNullOrWhiteSpace(path))
+ .Select(path => LexicalPath.NormalizeRelativePath(path.Trim()))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ try
+ {
+ using FileStream deploymentLock = DeploymentStateStore.AcquireDeploymentLock(paths);
+ return PrepareWithLock(
+ paths,
+ packages,
+ normalizedDisabledTargetRelativePaths,
+ cancellationToken);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogError(ex, "Deployment preparation failed before deployment recovery could run.");
+ return DeploymentResult.Failure(
+ new[]
+ {
+ new DeploymentFailure(
+ DeploymentFailureKind.FileSystem,
+ paths.GameDirectory,
+ ex.Message),
+ });
+ }
+ }
+
+ ///
+ /// Cleans the active deployment from the game directory.
+ ///
+ public DeploymentResult Cleanup(LauncherPaths paths, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(paths);
+
+ try
+ {
+ using FileStream deploymentLock = DeploymentStateStore.AcquireDeploymentLock(paths);
+ return CleanupCore(paths, cancellationToken);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogError(ex, "Deployment cleanup failed.");
+ return DeploymentResult.Failure(
+ new[]
+ {
+ new DeploymentFailure(
+ DeploymentFailureKind.FileSystem,
+ paths.GameDirectory,
+ ex.Message),
+ });
+ }
+ }
+
+ ///
+ /// Recovers interrupted deployment work from the persisted manifest or journal.
+ ///
+ public DeploymentResult Recover(LauncherPaths paths, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(paths);
+
+ try
+ {
+ using FileStream deploymentLock = DeploymentStateStore.AcquireDeploymentLock(paths);
+ return RecoverCore(paths, cancellationToken);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogError(ex, "Deployment recovery failed.");
+ return DeploymentResult.Failure(
+ new[]
+ {
+ new DeploymentFailure(
+ DeploymentFailureKind.Manifest,
+ paths.GameDirectory,
+ ex.Message),
+ });
+ }
+ }
+
+ ///
+ /// Prepares a deployment while the deployment operation lock is held.
+ ///
+ private DeploymentResult PrepareWithLock(
+ LauncherPaths launcherPaths,
+ IReadOnlyList packages,
+ IReadOnlyList disabledTargetRelativePaths,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ DeploymentResult cleanupResult = CleanupCore(launcherPaths, cancellationToken);
+ if (!cleanupResult.Succeeded)
+ {
+ return cleanupResult;
+ }
+
+ string deploymentId = Guid.NewGuid().ToString("N");
+ DeploymentStatePaths paths = DeploymentStateStore.CreatePaths(launcherPaths, deploymentId);
+ OwnedDirectoryTree.EnsureExists(launcherPaths.OwnedGameDataDirectory, paths.DeploymentDirectory);
+ OwnedDirectoryTree.EnsureExists(paths.DeploymentDirectory, paths.BackupDirectory);
+ if (File.Exists(paths.JournalPath))
+ {
+ File.Delete(paths.JournalPath);
+ }
+
+ string gameRoot = PhysicalDirectoryPath.ResolveExisting(launcherPaths.GameDirectory);
+ string gameRootIdentity = DeploymentStateStore.GetGameRootIdentity(gameRoot);
+ DeploymentStateStore.AppendJournal(
+ paths.JournalPath,
+ DeploymentJournalRecord.DeploymentStarted(
+ deploymentId,
+ gameRoot,
+ gameRootIdentity,
+ launcherPaths.Game));
+
+ IReadOnlyList files =
+ DeploymentFilePlanner.ResolveDeploymentFiles(packages);
+ var createdDirectories = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var entries = new List();
+ var backedUpTargetPaths =
+ new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var deployedTargetPaths = files
+ .Select(file => file.TargetRelativePath)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ BackupDisabledTargets(
+ launcherPaths,
+ disabledTargetRelativePaths,
+ paths,
+ deploymentId,
+ deployedTargetPaths,
+ backedUpTargetPaths,
+ entries,
+ cancellationToken);
+
+ foreach (ResolvedDeploymentFile file in files)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ string targetPath = DeploymentPathResolver.ResolveGamePath(launcherPaths, file.TargetRelativePath);
+ EnsureSafeGameMutationPath(launcherPaths, targetPath);
+ string targetDirectory = Path.GetDirectoryName(targetPath) ?? launcherPaths.GameDirectory;
+ foreach (string directory in DeploymentFilePlanner.GetDirectoriesToCreate(
+ launcherPaths.GameDirectory,
+ targetDirectory))
+ {
+ if (!Directory.Exists(directory))
+ {
+ EnsureSafeGameMutationPath(launcherPaths, directory);
+ string relativeDirectory = DeploymentPathResolver.ToRelativeManifestPath(
+ launcherPaths.GameDirectory,
+ directory);
+ DeploymentStateStore.AppendJournal(
+ paths.JournalPath,
+ DeploymentJournalRecord.DirectoryCreated(relativeDirectory));
+ Directory.CreateDirectory(directory);
+ EnsureSafeGameMutationPath(launcherPaths, directory);
+ createdDirectories.Add(relativeDirectory);
+ }
+ }
+
+ EnsureSafeGameMutationPath(launcherPaths, targetPath);
+ DeploymentBackupDocument? backup;
+ if (!backedUpTargetPaths.TryGetValue(file.TargetRelativePath, out backup) &&
+ File.Exists(targetPath))
+ {
+ backup = BackupTargetFile(
+ paths,
+ deploymentId,
+ file.TargetRelativePath,
+ targetPath);
+ backedUpTargetPaths[file.TargetRelativePath] = backup;
+ }
+
+ DeploymentFileFingerprint sourceFingerprint = ComputeFileFingerprint(file.SourcePath);
+ string stagingPath = CreateSiblingStagingPath(targetPath, deploymentId, "deploy");
+ string stagingRelativePath = DeploymentPathResolver.ToRelativeManifestPath(
+ launcherPaths.GameDirectory,
+ stagingPath);
+ DeploymentStateStore.AppendJournal(paths.JournalPath, DeploymentJournalRecord.FileDeploymentStarted(
+ file.TargetRelativePath,
+ backup?.RelativePath,
+ sourceFingerprint,
+ backup?.Fingerprint,
+ stagingRelativePath));
+
+ EnsureSafeGameMutationPath(launcherPaths, targetPath);
+ (DeploymentMethod Method, DeploymentFileFingerprint Fingerprint) deployment = DeployFile(
+ file.SourcePath,
+ targetPath,
+ stagingPath,
+ sourceFingerprint);
+ DeploymentStateStore.AppendJournal(paths.JournalPath, DeploymentJournalRecord.FileDeployed(
+ file.TargetRelativePath,
+ deployment.Method,
+ backup?.RelativePath,
+ deployment.Fingerprint,
+ backup?.Fingerprint,
+ stagingRelativePath));
+ cancellationToken.ThrowIfCancellationRequested();
+
+ entries.Add(new DeploymentFileDocument(
+ file.TargetRelativePath,
+ deployment.Method,
+ backup?.RelativePath,
+ deployment.Fingerprint,
+ backup?.Fingerprint,
+ stagingRelativePath,
+ backup?.StagingRelativePath));
+ }
+
+ DeploymentManifestDocument document = new(
+ SchemaVersion: DeploymentStateStore.CurrentSchemaVersion,
+ deploymentId,
+ entries,
+ createdDirectories.OrderByDescending(path => path.Length).ToList(),
+ gameRoot,
+ gameRootIdentity,
+ launcherPaths.Game);
+ cancellationToken.ThrowIfCancellationRequested();
+ DeploymentStateStore.WriteManifest(paths.ActiveManifestPath, document);
+ _logger.LogInformation("Prepared deployment {DeploymentId} with {FileCount} file(s).", deploymentId,
+ entries.Count);
+ return DeploymentResult.Success();
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.LogInformation("Deployment preparation was canceled; recovering any partial game-folder mutation.");
+ RecoverCore(launcherPaths, CancellationToken.None);
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Deployment preparation failed.");
+ DeploymentFailure prepareFailure = new(
+ DeploymentFailureKind.FileSystem,
+ launcherPaths.GameDirectory,
+ ex.Message);
+
+ DeploymentResult recoveryResult;
+ try
+ {
+ recoveryResult = RecoverCore(launcherPaths, CancellationToken.None);
+ }
+ catch (Exception recoveryException)
+ {
+ _logger.LogError(recoveryException, "Deployment recovery failed after preparation failure.");
+ recoveryResult = DeploymentResult.Failure(
+ new[]
+ {
+ new DeploymentFailure(
+ DeploymentFailureKind.Manifest,
+ launcherPaths.GameDirectory,
+ recoveryException.Message),
+ });
+ }
+
+ if (recoveryResult.Succeeded)
+ {
+ return DeploymentResult.Failure(new[] { prepareFailure });
+ }
+
+ return DeploymentResult.Failure(
+ new[] { prepareFailure }.Concat(recoveryResult.Failures).ToArray());
+ }
+ }
+
+ ///
+ /// Cleans a deployment while the deployment operation lock is held.
+ ///
+ private DeploymentResult CleanupCore(LauncherPaths paths, CancellationToken cancellationToken)
+ {
+ DeploymentManifestDocument? manifest = RestoreActiveDeployment(paths, cancellationToken);
+ if (manifest is not null)
+ {
+ _logger.LogInformation("Cleaned deployment {DeploymentId}.", manifest.DeploymentId);
+ }
+
+ return DeploymentResult.Success();
+ }
+
+ ///
+ /// Recovers deployment state while the deployment operation lock is held.
+ ///
+ private DeploymentResult RecoverCore(LauncherPaths paths, CancellationToken cancellationToken)
+ {
+ DeploymentManifestDocument? manifest = RestoreActiveDeployment(paths, cancellationToken);
+ if (manifest is not null)
+ {
+ _logger.LogInformation("Recovered deployment state for {DeploymentId}.", manifest.DeploymentId);
+ }
+
+ return DeploymentResult.Success();
+ }
+
+ ///
+ /// Restores game files and removes the durable state for one active or interrupted deployment.
+ ///
+ private DeploymentManifestDocument? RestoreActiveDeployment(
+ LauncherPaths paths,
+ CancellationToken cancellationToken)
+ {
+ DeploymentStatePaths deploymentPaths = DeploymentStateStore.CreatePaths(paths, deploymentId: string.Empty);
+ DeploymentManifestDocument? manifest = _stateStore.ReadManifestOrJournal(paths, deploymentPaths);
+
+ if (manifest is null)
+ {
+ DeploymentStateStore.DeleteDeploymentStateFiles(deploymentPaths);
+ return null;
+ }
+
+ CleanupManifest(paths, deploymentPaths, manifest, cancellationToken);
+ DeleteEmptyBackupDirectories(deploymentPaths, cancellationToken);
+ DeploymentStateStore.DeleteDeploymentStateFiles(deploymentPaths);
+ return manifest;
+ }
+
+ ///
+ /// Deploys a file with a hard link first and copy fallback.
+ ///
+ private (DeploymentMethod Method, DeploymentFileFingerprint Fingerprint) DeployFile(
+ string sourcePath,
+ string targetPath,
+ string stagingPath,
+ DeploymentFileFingerprint expectedFingerprint)
+ {
+ FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints(
+ sourcePath,
+ "Deployment source paths must be rooted.",
+ "Deployment source paths must not contain reparse points.");
+
+ if (File.Exists(targetPath))
+ {
+ throw new IOException("A deployment target appeared after its original content was backed up.");
+ }
+
+ DeploymentMethod method;
+ try
+ {
+ bool sourceIsReadOnly = (File.GetAttributes(sourcePath) & FileAttributes.ReadOnly) != 0;
+ if (!sourceIsReadOnly &&
+ ArePathsOnSameVolume(sourcePath, stagingPath) &&
+ _hardLinkCreator.TryCreateHardLink(stagingPath, sourcePath))
+ {
+ method = DeploymentMethod.HardLink;
+ EnsureFingerprintMatches(stagingPath, expectedFingerprint, "The staged hard link did not match its source.");
+ }
+ else
+ {
+ if (File.Exists(stagingPath))
+ {
+ throw new IOException("A deployment staging path was occupied unexpectedly.");
+ }
+
+ DeploymentFileFingerprint copiedFingerprint = CopyFileAndFlush(sourcePath, stagingPath);
+ File.SetLastWriteTimeUtc(stagingPath, File.GetLastWriteTimeUtc(sourcePath));
+ if (copiedFingerprint != expectedFingerprint)
+ {
+ throw new IOException("The source file changed while it was staged for deployment.");
+ }
+
+ EnsureFingerprintMatches(stagingPath, expectedFingerprint, "The staged copy did not match its source.");
+ method = DeploymentMethod.Copy;
+ _logger.LogInformation(
+ "Hard-link deployment was unavailable for {FileName}; used a verified file copy.",
+ Path.GetFileName(targetPath));
+ }
+
+ File.Move(stagingPath, targetPath, overwrite: false);
+ EnsureFingerprintMatches(
+ targetPath,
+ expectedFingerprint,
+ "The deployed target did not match its staged content.");
+ return (method, expectedFingerprint);
+ }
+ catch
+ {
+ DeleteOwnedStagingFileIfExpected(stagingPath, expectedFingerprint, clearReadOnly: false);
+ throw;
+ }
+ }
+
+ ///
+ /// Backs up deployment targets that should be hidden without deploying a replacement file.
+ ///
+ private void BackupDisabledTargets(
+ LauncherPaths launcherPaths,
+ IReadOnlyList disabledTargetRelativePaths,
+ DeploymentStatePaths deploymentPaths,
+ string deploymentId,
+ IReadOnlySet deployedTargetPaths,
+ Dictionary backedUpTargetPaths,
+ List entries,
+ CancellationToken cancellationToken)
+ {
+ foreach (string disabledTargetRelativePath in disabledTargetRelativePaths)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ string targetRelativePath = DeploymentPathResolver.NormalizeManifestPath(disabledTargetRelativePath);
+ if (backedUpTargetPaths.ContainsKey(targetRelativePath))
+ {
+ continue;
+ }
+
+ string targetPath = DeploymentPathResolver.ResolveGamePath(launcherPaths, targetRelativePath);
+ EnsureSafeGameMutationPath(launcherPaths, targetPath);
+ if (!File.Exists(targetPath))
+ {
+ _logger.LogDebug(
+ "Skipped disabling base game file {FileName} because it does not exist.",
+ Path.GetFileName(targetPath));
+ continue;
+ }
+
+ DeploymentBackupDocument backup = BackupTargetFile(
+ deploymentPaths,
+ deploymentId,
+ targetRelativePath,
+ targetPath);
+ backedUpTargetPaths[targetRelativePath] = backup;
+
+ if (!deployedTargetPaths.Contains(targetRelativePath))
+ {
+ entries.Add(new DeploymentFileDocument(
+ targetRelativePath,
+ DeploymentMethod.Copy,
+ backup.RelativePath,
+ DeployedFingerprint: null,
+ BackupFingerprint: backup.Fingerprint,
+ StagingRelativePath: null,
+ BackupStagingRelativePath: backup.StagingRelativePath));
+ }
+
+ _logger.LogInformation(
+ "Temporarily disabled base game file {FileName} for modded launch deployment.",
+ Path.GetFileName(targetPath));
+ }
+ }
+
+ ///
+ /// Journals intent and completion around committing a verified launcher-owned backup before removing a target.
+ ///
+ private static DeploymentBackupDocument BackupTargetFile(
+ DeploymentStatePaths deploymentPaths,
+ string deploymentId,
+ string targetRelativePath,
+ string targetPath)
+ {
+ string backupRelativePath = CreateBackupRelativePath(deploymentId, targetRelativePath);
+ string backupPath = DeploymentPathResolver.ResolveDeploymentStatePath(
+ deploymentPaths.DeploymentDirectory,
+ backupRelativePath);
+ backupPath = FileSystemPathSafety.ResolveOwnedSubpath(
+ deploymentPaths.DeploymentDirectory,
+ backupPath,
+ "Deployment backup paths must stay inside the deployment directory.",
+ "Deployment backup paths must not contain reparse points.");
+ Directory.CreateDirectory(Path.GetDirectoryName(backupPath) ?? deploymentPaths.BackupDirectory);
+ FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints(
+ Path.GetDirectoryName(backupPath) ?? deploymentPaths.BackupDirectory,
+ "Deployment backup paths must be rooted.",
+ "Deployment backup paths must not contain reparse points.");
+ bool canMoveOriginal = ArePathsOnSameVolume(targetPath, backupPath);
+ string backupStagingPath = canMoveOriginal
+ ? string.Empty
+ : backupPath + $".partial-{Guid.NewGuid():N}";
+ string backupStagingRelativePath = canMoveOriginal
+ ? string.Empty
+ : DeploymentPathResolver.ToRelativeManifestPath(
+ deploymentPaths.DeploymentDirectory,
+ backupStagingPath);
+ DeploymentStateStore.AppendJournal(
+ deploymentPaths.JournalPath,
+ DeploymentJournalRecord.FileBackupStarted(
+ targetRelativePath,
+ backupRelativePath,
+ backupStagingRelativePath));
+
+ DeploymentFileFingerprint backupFingerprint;
+ try
+ {
+ if (canMoveOriginal)
+ {
+ File.Move(targetPath, backupPath, overwrite: false);
+ backupFingerprint = ComputeFileFingerprint(backupPath);
+ }
+ else
+ {
+ backupFingerprint = CopyFileWithMetadataAndFlush(targetPath, backupStagingPath);
+ EnsureFingerprintMatches(
+ backupStagingPath,
+ backupFingerprint,
+ "The launcher-owned backup did not match the original game file.");
+ File.Move(backupStagingPath, backupPath, overwrite: false);
+ }
+
+ EnsureFingerprintMatches(
+ backupPath,
+ backupFingerprint,
+ "The launcher-owned backup did not match the original game file.");
+ }
+ catch
+ {
+ if (!string.IsNullOrWhiteSpace(backupStagingPath))
+ {
+ DeleteFileClearingReadOnly(backupStagingPath);
+ }
+
+ throw;
+ }
+
+ DeploymentStateStore.AppendJournal(
+ deploymentPaths.JournalPath,
+ DeploymentJournalRecord.FileBackedUp(
+ targetRelativePath,
+ backupRelativePath,
+ backupFingerprint,
+ backupStagingRelativePath));
+
+ if (!canMoveOriginal)
+ {
+ EnsureFingerprintMatches(
+ targetPath,
+ backupFingerprint,
+ "The original game file changed while its backup was being committed.");
+ DeleteFileClearingReadOnly(targetPath);
+ }
+
+ return new DeploymentBackupDocument(
+ backupRelativePath,
+ backupFingerprint,
+ backupStagingRelativePath);
+ }
+
+ private static string CreateBackupRelativePath(string deploymentId, string targetRelativePath)
+ {
+ return LexicalPath.NormalizeRelativePath(Path.Combine(
+ DeploymentStateStore.BackupsDirectoryName,
+ deploymentId,
+ targetRelativePath));
+ }
+
+ ///
+ /// Replays persisted deployment state to remove deployed files and restore original backups.
+ ///
+ private void CleanupManifest(
+ LauncherPaths paths,
+ DeploymentStatePaths deploymentPaths,
+ DeploymentManifestDocument manifest,
+ CancellationToken cancellationToken)
+ {
+ foreach (DeploymentFileDocument file in manifest.Files)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ string targetPath = DeploymentPathResolver.ResolveGamePath(paths, file.TargetRelativePath);
+ EnsureSafeGameMutationPath(paths, targetPath);
+ CleanupGameStagingFile(
+ paths,
+ file.StagingRelativePath,
+ file.DeployedFingerprint,
+ clearReadOnly: false);
+ CleanupGameStagingFile(
+ paths,
+ file.RestoreStagingRelativePath,
+ file.BackupFingerprint,
+ clearReadOnly: true);
+ CleanupBackupStagingFile(deploymentPaths, file.BackupStagingRelativePath);
+
+ if (string.IsNullOrWhiteSpace(file.BackupRelativePath))
+ {
+ if (File.Exists(targetPath))
+ {
+ RequireExpectedTargetFingerprint(
+ targetPath,
+ file.DeployedFingerprint,
+ "A deployed game file was modified after launch preparation; cleanup left it untouched.");
+ DeleteDeployedTarget(targetPath, file);
+ DeploymentStateStore.AppendJournal(
+ deploymentPaths.JournalPath,
+ DeploymentJournalRecord.FileCleanupDeleted(file.TargetRelativePath));
+ }
+
+ continue;
+ }
+
+ string backupPath = DeploymentPathResolver.ResolveDeploymentStatePath(
+ deploymentPaths.DeploymentDirectory,
+ file.BackupRelativePath);
+ backupPath = FileSystemPathSafety.ResolveOwnedSubpath(
+ deploymentPaths.DeploymentDirectory,
+ backupPath,
+ "Deployment backup paths must stay inside the deployment directory.",
+ "Deployment backup paths must not contain reparse points.");
+ DeploymentFileFingerprint? backupFingerprint = file.BackupFingerprint;
+ bool backupExists = File.Exists(backupPath);
+ if (backupExists)
+ {
+ DeploymentFileFingerprint observedBackupFingerprint = ComputeFileFingerprint(backupPath);
+ if (backupFingerprint is not null && observedBackupFingerprint != backupFingerprint)
+ {
+ throw new InvalidDataException(
+ "A launcher-owned deployment backup changed unexpectedly; the game file was left untouched.");
+ }
+
+ backupFingerprint = observedBackupFingerprint;
+ }
+
+ if (backupFingerprint is null)
+ {
+ throw new InvalidDataException(
+ "Deployment recovery cannot verify the original game file because its backup fingerprint is missing.");
+ }
+
+ if (File.Exists(targetPath))
+ {
+ DeploymentFileFingerprint targetFingerprint = ComputeFileFingerprint(targetPath);
+ if (!backupExists && targetFingerprint == backupFingerprint)
+ {
+ continue;
+ }
+
+ if (targetFingerprint != backupFingerprint &&
+ (file.DeployedFingerprint is null || targetFingerprint != file.DeployedFingerprint))
+ {
+ throw new InvalidDataException(
+ "A game file was modified after launch preparation; its original backup was preserved.");
+ }
+ }
+
+ if (!backupExists)
+ {
+ throw new InvalidDataException(
+ "The original game-file backup is missing and the target is not already restored.");
+ }
+
+ EnsureSafeGameMutationPath(paths, targetPath);
+ Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? paths.GameDirectory);
+ EnsureSafeGameMutationPath(paths, targetPath);
+ bool canMoveOriginal = ArePathsOnSameVolume(backupPath, targetPath);
+ string restoreStagingPath = canMoveOriginal
+ ? string.Empty
+ : CreateSiblingStagingPath(targetPath, manifest.DeploymentId, "restore");
+ string restoreStagingRelativePath = canMoveOriginal
+ ? string.Empty
+ : DeploymentPathResolver.ToRelativeManifestPath(
+ paths.GameDirectory,
+ restoreStagingPath);
+ DeploymentStateStore.AppendJournal(
+ deploymentPaths.JournalPath,
+ DeploymentJournalRecord.FileCleanupRestoreStarted(
+ file.TargetRelativePath,
+ file.BackupRelativePath,
+ restoreStagingRelativePath));
+
+ try
+ {
+ if (File.Exists(targetPath))
+ {
+ RequireExpectedRestoreTargetFingerprint(
+ targetPath,
+ file.DeployedFingerprint,
+ backupFingerprint,
+ "A deployed game file changed while its original was being restored.");
+ DeleteDeployedTarget(targetPath, file);
+ }
+
+ if (canMoveOriginal)
+ {
+ File.Move(backupPath, targetPath, overwrite: false);
+ }
+ else
+ {
+ StageVerifiedFile(backupPath, restoreStagingPath, backupFingerprint);
+ File.Move(restoreStagingPath, targetPath, overwrite: false);
+ DeleteFileClearingReadOnly(backupPath);
+ }
+
+ EnsureFingerprintMatches(
+ targetPath,
+ backupFingerprint,
+ "The restored game file did not match its launcher-owned backup.");
+ DeploymentStateStore.AppendJournal(
+ deploymentPaths.JournalPath,
+ DeploymentJournalRecord.FileCleanupRestored(
+ file.TargetRelativePath,
+ file.BackupRelativePath));
+ }
+ catch
+ {
+ if (!string.IsNullOrWhiteSpace(restoreStagingPath))
+ {
+ DeleteOwnedStagingFileIfExpected(
+ restoreStagingPath,
+ backupFingerprint,
+ clearReadOnly: true);
+ }
+
+ throw;
+ }
+ }
+
+ foreach (string relativeDirectory in manifest.CreatedDirectories.OrderByDescending(path => path.Length))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ string directoryPath = DeploymentPathResolver.ResolveGamePath(paths, relativeDirectory);
+ EnsureSafeGameMutationPath(paths, directoryPath);
+ if (!Directory.Exists(directoryPath))
+ {
+ continue;
+ }
+
+ if (!Directory.EnumerateFileSystemEntries(directoryPath).Any())
+ {
+ Directory.Delete(directoryPath);
+ continue;
+ }
+
+ _logger.LogInformation(
+ "Left deployment-created directory {DirectoryName} because it contains non-deployed files.",
+ Path.GetFileName(directoryPath));
+ }
+ }
+
+ private void StageVerifiedFile(
+ string sourcePath,
+ string stagingPath,
+ DeploymentFileFingerprint expectedFingerprint)
+ {
+ FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints(
+ sourcePath,
+ "Deployment source paths must be rooted.",
+ "Deployment source paths must not contain reparse points.");
+
+ if (ArePathsOnSameVolume(sourcePath, stagingPath) &&
+ _hardLinkCreator.TryCreateHardLink(stagingPath, sourcePath))
+ {
+ EnsureFingerprintMatches(
+ stagingPath,
+ expectedFingerprint,
+ "The staged hard link did not match its source.");
+ return;
+ }
+
+ if (File.Exists(stagingPath))
+ {
+ throw new IOException("A deployment staging path was occupied unexpectedly.");
+ }
+
+ DeploymentFileFingerprint copiedFingerprint = CopyFileWithMetadataAndFlush(sourcePath, stagingPath);
+ if (copiedFingerprint != expectedFingerprint)
+ {
+ throw new IOException("The source file changed while it was copied into the game directory.");
+ }
+
+ EnsureFingerprintMatches(
+ stagingPath,
+ expectedFingerprint,
+ "The staged file copy did not match its source.");
+ }
+
+ private static DeploymentFileFingerprint CopyFileAndFlush(string sourcePath, string destinationPath)
+ {
+ byte[] buffer = ArrayPool.Shared.Rent(FileBufferSize);
+ try
+ {
+ using FileStream source = new(
+ sourcePath,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.Read,
+ FileBufferSize,
+ FileOptions.SequentialScan);
+ using FileStream destination = new(
+ destinationPath,
+ FileMode.CreateNew,
+ FileAccess.Write,
+ FileShare.None,
+ FileBufferSize,
+ FileOptions.SequentialScan | FileOptions.WriteThrough);
+ using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
+
+ long length = 0;
+ int bytesRead;
+ while ((bytesRead = source.Read(buffer, 0, buffer.Length)) != 0)
+ {
+ destination.Write(buffer, 0, bytesRead);
+ hash.AppendData(buffer, 0, bytesRead);
+ length += bytesRead;
+ }
+
+ destination.Flush(flushToDisk: true);
+ return new DeploymentFileFingerprint(length, Convert.ToHexString(hash.GetHashAndReset()));
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+
+ ///
+ /// Uses the Windows file-copy path so alternate streams and security data are retained, then restores
+ /// timestamps and mutable attributes that the platform copy operation does not preserve exactly.
+ ///
+ private static DeploymentFileFingerprint CopyFileWithMetadataAndFlush(
+ string sourcePath,
+ string destinationPath)
+ {
+ FileAttributes sourceAttributes = File.GetAttributes(sourcePath);
+ DateTime creationTimeUtc = File.GetCreationTimeUtc(sourcePath);
+ DateTime lastAccessTimeUtc = File.GetLastAccessTimeUtc(sourcePath);
+ DateTime lastWriteTimeUtc = File.GetLastWriteTimeUtc(sourcePath);
+
+ try
+ {
+ File.Copy(sourcePath, destinationPath, overwrite: false);
+ FileAttributes destinationAttributes = File.GetAttributes(destinationPath);
+ if ((destinationAttributes & FileAttributes.ReadOnly) != 0)
+ {
+ File.SetAttributes(destinationPath, destinationAttributes & ~FileAttributes.ReadOnly);
+ }
+
+ using (FileStream destination = new(
+ destinationPath,
+ FileMode.Open,
+ FileAccess.ReadWrite,
+ FileShare.Read))
+ {
+ destination.Flush(flushToDisk: true);
+ }
+
+ DeploymentFileFingerprint fingerprint = ComputeFileFingerprint(destinationPath);
+
+ File.SetCreationTimeUtc(destinationPath, creationTimeUtc);
+ File.SetLastAccessTimeUtc(destinationPath, lastAccessTimeUtc);
+ File.SetLastWriteTimeUtc(destinationPath, lastWriteTimeUtc);
+
+ const FileAttributes mutableAttributes =
+ FileAttributes.ReadOnly |
+ FileAttributes.Hidden |
+ FileAttributes.System |
+ FileAttributes.Archive |
+ FileAttributes.Temporary |
+ FileAttributes.Offline |
+ FileAttributes.NotContentIndexed;
+ destinationAttributes = File.GetAttributes(destinationPath);
+ File.SetAttributes(
+ destinationPath,
+ (destinationAttributes & ~mutableAttributes) | (sourceAttributes & mutableAttributes));
+ return fingerprint;
+ }
+ catch
+ {
+ DeleteFileClearingReadOnly(destinationPath);
+ throw;
+ }
+ }
+
+ private static DeploymentFileFingerprint ComputeFileFingerprint(string path)
+ {
+ using FileStream stream = new(
+ path,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.Read,
+ FileBufferSize,
+ FileOptions.SequentialScan);
+ return new DeploymentFileFingerprint(stream.Length, Convert.ToHexString(SHA256.HashData(stream)));
+ }
+
+ private static void EnsureFingerprintMatches(
+ string path,
+ DeploymentFileFingerprint expectedFingerprint,
+ string errorMessage)
+ {
+ if (ComputeFileFingerprint(path) != expectedFingerprint)
+ {
+ throw new InvalidDataException(errorMessage);
+ }
+ }
+
+ private static void RequireExpectedTargetFingerprint(
+ string targetPath,
+ DeploymentFileFingerprint? expectedFingerprint,
+ string conflictMessage)
+ {
+ if (expectedFingerprint is null || ComputeFileFingerprint(targetPath) != expectedFingerprint)
+ {
+ throw new InvalidDataException(conflictMessage);
+ }
+ }
+
+ private static void RequireExpectedRestoreTargetFingerprint(
+ string targetPath,
+ DeploymentFileFingerprint? deployedFingerprint,
+ DeploymentFileFingerprint backupFingerprint,
+ string conflictMessage)
+ {
+ DeploymentFileFingerprint targetFingerprint = ComputeFileFingerprint(targetPath);
+ if (targetFingerprint != backupFingerprint &&
+ (deployedFingerprint is null || targetFingerprint != deployedFingerprint))
+ {
+ throw new InvalidDataException(conflictMessage);
+ }
+ }
+
+ private static void DeleteFileClearingReadOnly(string path)
+ {
+ if (!File.Exists(path))
+ {
+ return;
+ }
+
+ FileAttributes attributes = File.GetAttributes(path);
+ if ((attributes & FileAttributes.ReadOnly) != 0)
+ {
+ File.SetAttributes(path, attributes & ~FileAttributes.ReadOnly);
+ }
+
+ File.Delete(path);
+ }
+
+ private static void DeleteDeployedTarget(string targetPath, DeploymentFileDocument file)
+ {
+ if (file.Method == DeploymentMethod.HardLink)
+ {
+ File.Delete(targetPath);
+ return;
+ }
+
+ DeleteFileClearingReadOnly(targetPath);
+ }
+
+ private void CleanupGameStagingFile(
+ LauncherPaths paths,
+ string? stagingRelativePath,
+ DeploymentFileFingerprint? expectedFingerprint,
+ bool clearReadOnly)
+ {
+ if (string.IsNullOrWhiteSpace(stagingRelativePath))
+ {
+ return;
+ }
+
+ string stagingPath = DeploymentPathResolver.ResolveGamePath(paths, stagingRelativePath);
+ EnsureSafeGameMutationPath(paths, stagingPath);
+ if (!File.Exists(stagingPath))
+ {
+ return;
+ }
+
+ if (expectedFingerprint is not null &&
+ ComputeFileFingerprint(stagingPath) != expectedFingerprint)
+ {
+ _logger.LogWarning(
+ "Removed incomplete transaction staging file {FileName} during deployment recovery.",
+ Path.GetFileName(stagingPath));
+ }
+
+ if (clearReadOnly)
+ {
+ DeleteFileClearingReadOnly(stagingPath);
+ }
+ else
+ {
+ File.Delete(stagingPath);
+ }
+ }
+
+ private static void CleanupBackupStagingFile(
+ DeploymentStatePaths deploymentPaths,
+ string? stagingRelativePath)
+ {
+ if (string.IsNullOrWhiteSpace(stagingRelativePath))
+ {
+ return;
+ }
+
+ string stagingPath = DeploymentPathResolver.ResolveDeploymentStatePath(
+ deploymentPaths.DeploymentDirectory,
+ stagingRelativePath);
+ stagingPath = FileSystemPathSafety.ResolveOwnedSubpath(
+ deploymentPaths.DeploymentDirectory,
+ stagingPath,
+ "Deployment backup staging paths must stay inside the deployment directory.",
+ "Deployment backup staging paths must not contain reparse points.");
+ if (File.Exists(stagingPath))
+ {
+ DeleteFileClearingReadOnly(stagingPath);
+ }
+ }
+
+ private void DeleteOwnedStagingFileIfExpected(
+ string stagingPath,
+ DeploymentFileFingerprint expectedFingerprint,
+ bool clearReadOnly)
+ {
+ if (!File.Exists(stagingPath))
+ {
+ return;
+ }
+
+ try
+ {
+ if (ComputeFileFingerprint(stagingPath) == expectedFingerprint)
+ {
+ if (clearReadOnly)
+ {
+ DeleteFileClearingReadOnly(stagingPath);
+ }
+ else
+ {
+ File.Delete(stagingPath);
+ }
+
+ return;
+ }
+
+ _logger.LogWarning(
+ "Left deployment staging file {FileName} untouched because its contents changed unexpectedly.",
+ Path.GetFileName(stagingPath));
+ }
+ catch (IOException ex)
+ {
+ _logger.LogWarning(ex, "Could not inspect deployment staging file {FileName}.", Path.GetFileName(stagingPath));
+ }
+ }
+
+ private static string CreateSiblingStagingPath(string targetPath, string deploymentId, string operation)
+ {
+ string directory = Path.GetDirectoryName(targetPath)
+ ?? throw new InvalidOperationException("Deployment target paths must have a parent directory.");
+ string fileName = Path.GetFileName(targetPath);
+ return Path.Combine(
+ directory,
+ $".{fileName}.GenLauncherGO-{operation}-{deploymentId}-{Guid.NewGuid():N}.tmp");
+ }
+
+ private static bool ArePathsOnSameVolume(string firstPath, string secondPath)
+ {
+ string firstDirectory = Directory.Exists(firstPath)
+ ? firstPath
+ : Path.GetDirectoryName(firstPath)
+ ?? throw new InvalidOperationException("Deployment file paths must have a parent directory.");
+ string secondDirectory = Directory.Exists(secondPath)
+ ? secondPath
+ : Path.GetDirectoryName(secondPath)
+ ?? throw new InvalidOperationException("Deployment file paths must have a parent directory.");
+ return PhysicalDirectoryPath.GetIdentity(firstDirectory).VolumeSerialNumber ==
+ PhysicalDirectoryPath.GetIdentity(secondDirectory).VolumeSerialNumber;
+ }
+
+ ///
+ /// Verifies that a game-directory mutation target stays in the game folder and does not cross child reparse points.
+ ///
+ private static void EnsureSafeGameMutationPath(LauncherPaths paths, string targetPath)
+ {
+ _ = FileSystemPathSafety.ResolveOwnedSubpath(
+ paths.GameDirectory,
+ targetPath,
+ "Deployment target paths must stay inside the game directory.",
+ "Deployment target paths must not contain reparse points.");
+ }
+
+ private static void DeleteEmptyBackupDirectories(
+ DeploymentStatePaths deploymentPaths,
+ CancellationToken cancellationToken)
+ {
+ string backupRoot = Path.Combine(
+ deploymentPaths.DeploymentDirectory,
+ DeploymentStateStore.BackupsDirectoryName);
+ if (!Directory.Exists(backupRoot))
+ {
+ return;
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+ OwnedDirectoryTree.DeleteEmptyDirectories(
+ new GenLauncherGO.Core.Mods.Models.OwnedContentPath(
+ deploymentPaths.DeploymentDirectory,
+ backupRoot));
+ }
+
+}
diff --git a/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionService.cs b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionService.cs
new file mode 100644
index 00000000..07b3faf8
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionService.cs
@@ -0,0 +1,547 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using GenLauncherGO.Core.Integrity.Models;
+using GenLauncherGO.Core.IO;
+using GenLauncherGO.Core.Launching.Contracts;
+using GenLauncherGO.Core.Launching.Models;
+using GenLauncherGO.Core.Mods.Contracts;
+using GenLauncherGO.Core.Mods.Models;
+using GenLauncherGO.Core.Updating.Models;
+using GenLauncherGO.Infrastructure.Common;
+using GenLauncherGO.Infrastructure.Integrity.Contracts;
+using GenLauncherGO.Infrastructure.Integrity.Support;
+using GenLauncherGO.Infrastructure.Launching.Contracts;
+using GenLauncherGO.Infrastructure.Mods.Support;
+using GenLauncherGO.Infrastructure.Remote.Contracts;
+using GenLauncherGO.Infrastructure.Updating.Contracts;
+using GenLauncherGO.Infrastructure.Updating.Models;
+using GenLauncherGO.Infrastructure.Updating.Support;
+using Microsoft.Extensions.Logging;
+
+namespace GenLauncherGO.Infrastructure.Launching.Services;
+
+///
+/// Resolves launch-readiness integrity issues using persisted snapshots, package repair, and cache refresh.
+///
+internal sealed class FileSystemLaunchContentIntegrityResolutionService : ILaunchContentIntegrityResolutionService
+{
+ private readonly IContentIntegrityService _integrityService;
+
+ private readonly ILaunchContentIntegrityTargetBuilder _targetBuilder;
+
+ private readonly IS3ObjectManifestReader _manifestReader;
+
+ private readonly IS3PackageUpdater _s3PackageUpdater;
+
+ private readonly ISingleFilePackageUpdater _singleFilePackageUpdater;
+
+ private readonly IRemoteAssetDownloader _assetDownloader;
+
+ private readonly ILauncherContentCatalog _catalog;
+
+ private readonly ILogger _logger;
+
+ public FileSystemLaunchContentIntegrityResolutionService(
+ IContentIntegrityService integrityService,
+ ILaunchContentIntegrityTargetBuilder targetBuilder,
+ IS3ObjectManifestReader manifestReader,
+ IS3PackageUpdater s3PackageUpdater,
+ ISingleFilePackageUpdater singleFilePackageUpdater,
+ IRemoteAssetDownloader assetDownloader,
+ ILauncherContentCatalog catalog,
+ ILogger logger)
+ {
+ _integrityService = integrityService ?? throw new ArgumentNullException(nameof(integrityService));
+ _targetBuilder = targetBuilder ?? throw new ArgumentNullException(nameof(targetBuilder));
+ _manifestReader = manifestReader ?? throw new ArgumentNullException(nameof(manifestReader));
+ _s3PackageUpdater = s3PackageUpdater ?? throw new ArgumentNullException(nameof(s3PackageUpdater));
+ _singleFilePackageUpdater = singleFilePackageUpdater ??
+ throw new ArgumentNullException(nameof(singleFilePackageUpdater));
+ _assetDownloader = assetDownloader ?? throw new ArgumentNullException(nameof(assetDownloader));
+ _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task VerifyAsync(
+ LaunchContentIntegrityTargetRequest request,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ IReadOnlyList contexts = _targetBuilder.BuildTargets(request);
+ ContentIntegrityReport report = await _integrityService.VerifyAsync(
+ request.Paths,
+ contexts.Select(context => context.Target).ToList(),
+ cancellationToken);
+ return new LaunchContentIntegrityVerificationResult(report, contexts);
+ }
+
+ public async Task InitializeUntrackedManagedCachesAsync(
+ LaunchContentIntegrityResolutionRequest request,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var untrackedManagedCacheIds = request.Report.Issues
+ .Where(issue =>
+ issue.Kind == IntegrityIssueKind.Untracked &&
+ issue.SourceKind is ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile)
+ .Select(issue => issue.TargetId)
+ .Where(targetId => request.Report.Issues
+ .Where(issue => issue.TargetId == targetId)
+ .All(issue => issue.Kind == IntegrityIssueKind.Untracked))
+ .ToHashSet(StringComparer.Ordinal);
+ var cacheContexts = request.TargetContexts
+ .Where(context =>
+ context.IsCache &&
+ untrackedManagedCacheIds.Contains(context.Target.Id))
+ .ToList();
+
+ bool initializedAny = false;
+ foreach (LaunchContentIntegrityTargetContext context in cacheContexts)
+ {
+ if (!await _integrityService.CaptureSnapshotIfMatchesExpectedFileSetAsync(
+ request.Paths,
+ context.Target,
+ BuildExpectedRemoteCachePaths(context),
+ cancellationToken))
+ {
+ continue;
+ }
+
+ _logger.LogInformation(
+ "Initialized managed remote image integrity for {ContentName}.",
+ context.Version.DisplayName);
+ initializedAny = true;
+ }
+
+ return initializedAny;
+ }
+
+ public async Task ResolveAsync(
+ LaunchContentIntegrityResolutionRequest request,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var contextIndex =
+ request.TargetContexts.ToDictionary(context => context.Target.Id, StringComparer.Ordinal);
+
+ foreach (LaunchContentIntegrityTargetContext context in request.TargetContexts.Where(context =>
+ request.Report.Issues.Any(issue =>
+ issue.TargetId == context.Target.Id &&
+ issue.Action == IntegrityIssueAction.TrustAsManual)))
+ {
+ context.Version.Installation.ContentSourceKind = ContentSourceKind.Manual;
+ ContentIntegrityTarget manualTarget = context.Target with
+ {
+ SourceKind = ContentSourceKind.Manual,
+ };
+ await _integrityService.CaptureSnapshotAsync(request.Paths, manualTarget, cancellationToken);
+ }
+
+ _catalog.SaveLauncherData();
+
+ foreach (LaunchContentIntegrityTargetContext context in request.TargetContexts.Where(context =>
+ request.Report.Issues.Any(issue =>
+ issue.TargetId == context.Target.Id &&
+ issue.Action == IntegrityIssueAction.Absorb)))
+ {
+ await _integrityService.CaptureSnapshotAsync(request.Paths, context.Target, cancellationToken);
+ }
+
+ await _integrityService.ApplyCleanupAsync(
+ request.Report,
+ request.TargetContexts.Select(context => context.Target).ToList(),
+ cancellationToken);
+
+ foreach (LaunchContentIntegrityTargetContext context in request.TargetContexts.Where(context =>
+ request.Report.Issues.Any(issue =>
+ issue.TargetId == context.Target.Id &&
+ issue.Action is IntegrityIssueAction.Repair or IntegrityIssueAction.Redownload)))
+ {
+ if (context.IsCache)
+ {
+ await RefreshManagedCacheAsync(context, cancellationToken);
+ progress?.Report(LaunchContentIntegrityResolutionProgress.Complete(context.Target.Id));
+ }
+ else
+ {
+ await RepairManagedPackageAsync(
+ request.Paths,
+ context,
+ request.Report,
+ new TargetPackageProgress(context.Target.Id, progress),
+ cancellationToken);
+ }
+ }
+
+ foreach (string targetId in request.Report.Issues
+ .Where(issue =>
+ issue.SourceKind is ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile)
+ .Select(issue => issue.TargetId)
+ .Distinct(StringComparer.Ordinal))
+ {
+ if (contextIndex.TryGetValue(targetId, out LaunchContentIntegrityTargetContext? context))
+ {
+ await _integrityService.CaptureSnapshotAsync(request.Paths, context.Target, cancellationToken);
+ }
+ }
+ }
+
+ public async Task RegisterManualImportAsync(
+ LaunchContentIntegrityVersionRequest request,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ request.Version.Installation.ContentSourceKind = ContentSourceKind.Manual;
+ _catalog.SaveLauncherData();
+
+ IReadOnlyList contexts = BuildSingleVersionContexts(request);
+ await _integrityService.CaptureSnapshotAsync(
+ request.Paths,
+ contexts.First(context => !context.IsCache).Target,
+ cancellationToken);
+
+ if (request.Version.ModificationType == ModificationType.Mod)
+ {
+ await _integrityService.CaptureSnapshotAsync(
+ request.Paths,
+ contexts.First(context => context.IsCache).Target,
+ cancellationToken);
+ }
+ }
+
+ public async Task CaptureManagedInstallSnapshotAsync(
+ LaunchContentIntegrityVersionRequest request,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ if (request.Version.EffectiveContentSourceKind is not
+ (ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile))
+ {
+ return;
+ }
+
+ IReadOnlyList contexts = BuildSingleVersionContexts(request);
+ await _integrityService.CaptureSnapshotAsync(
+ request.Paths,
+ contexts.First(context => !context.IsCache).Target,
+ cancellationToken);
+
+ if (request.Version.ModificationType == ModificationType.Mod)
+ {
+ LaunchContentIntegrityTargetContext cacheContext = contexts.First(context => context.IsCache);
+ if (!await _integrityService.CaptureSnapshotIfMatchesExpectedFileSetAsync(
+ request.Paths,
+ cacheContext.Target,
+ BuildExpectedRemoteCachePaths(cacheContext),
+ cancellationToken))
+ {
+ await RefreshManagedCacheAsync(cacheContext, cancellationToken);
+ await _integrityService.CaptureSnapshotAsync(request.Paths, cacheContext.Target, cancellationToken);
+ }
+ }
+ }
+
+ public async Task CaptureManualImageSnapshotAsync(
+ LaunchContentIntegrityVersionRequest request,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ if (request.Version.EffectiveContentSourceKind != ContentSourceKind.Manual)
+ {
+ return;
+ }
+
+ await _integrityService.CaptureSnapshotAsync(
+ request.Paths,
+ BuildSingleVersionContexts(request).First(context => context.IsCache).Target,
+ cancellationToken);
+ }
+
+ private IReadOnlyList BuildSingleVersionContexts(
+ LaunchContentIntegrityVersionRequest request)
+ {
+ return _targetBuilder.BuildTargets(
+ new LaunchContentIntegrityTargetRequest(
+ request.Paths,
+ new[] { request.Version },
+ request.AllVersions,
+ request.CacheDisplayNameSuffix));
+ }
+
+ ///
+ /// Repairs a managed remote package.
+ ///
+ private async Task RepairManagedPackageAsync(
+ GenLauncherGO.Core.Startup.LauncherPaths paths,
+ LaunchContentIntegrityTargetContext context,
+ ContentIntegrityReport report,
+ IProgress progress,
+ CancellationToken cancellationToken)
+ {
+ ContentSourceKind sourceKind = context.Version.EffectiveContentSourceKind;
+ var installedPath = new OwnedContentPath(paths.ModsDirectory, context.Target.RootDirectory);
+ var packagePaths = PackageUpdatePathSet.Create(
+ paths,
+ installedPath,
+ installedPath);
+ if (sourceKind == ContentSourceKind.ManagedS3)
+ {
+ S3ObjectManifestRequest manifestRequest = S3CatalogDefaults.CreateManifestRequest(context.Version);
+ IReadOnlyList files = await _manifestReader.ReadManifestAsync(
+ manifestRequest,
+ cancellationToken);
+ var hashCheckedExtensions = files
+ .Select(file => Path.GetExtension(file.FileName))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ hashCheckedExtensions.Add(BigFileVariantPath.GibExtension);
+
+ IReadOnlyList repairFiles = SelectS3FileRepairEntries(
+ report,
+ context.Target.Id,
+ files);
+ if (repairFiles.Count > 0)
+ {
+ _logger.LogInformation(
+ "Repairing {FileCount} S3 package file(s) in place for {ContentName}.",
+ repairFiles.Count,
+ context.Version.DisplayName);
+ await _s3PackageUpdater.RepairFilesAsync(
+ new S3PackageFileRepairRequest(
+ repairFiles,
+ manifestRequest,
+ installedPath,
+ hashCheckedExtensions),
+ progress,
+ cancellationToken);
+ return;
+ }
+
+ _logger.LogInformation(
+ "Repairing S3 package {ContentName} with full package replacement.",
+ context.Version.DisplayName);
+
+ await _s3PackageUpdater.UpdateAsync(
+ new S3PackageUpdateRequest(
+ files,
+ manifestRequest,
+ packagePaths,
+ hashCheckedExtensions),
+ progress,
+ cancellationToken);
+ return;
+ }
+
+ if (sourceKind == ContentSourceKind.ManagedSingleFile)
+ {
+ await _singleFilePackageUpdater.UpdateAsync(
+ DownloadLinkResolver.ResolveDownloadUri(context.Version.SimpleDownloadLink),
+ packagePaths,
+ progress,
+ cancellationToken);
+ return;
+ }
+
+ throw new InvalidOperationException("Only managed remote content can be repaired automatically.");
+ }
+
+ ///
+ /// Selects the S3 manifest entries that correspond to file-level repair issues for one integrity target.
+ ///
+ /// The complete integrity report.
+ /// The target identifier to inspect.
+ /// The remote manifest entries.
+ ///
+ /// The manifest entries that can be repaired in place, or an empty collection when the issue set requires a full
+ /// package repair.
+ ///
+ private static IReadOnlyList SelectS3FileRepairEntries(
+ ContentIntegrityReport report,
+ string targetId,
+ IReadOnlyList files)
+ {
+ var repairIssues = report.Issues
+ .Where(issue =>
+ string.Equals(issue.TargetId, targetId, StringComparison.Ordinal) &&
+ issue.Action is IntegrityIssueAction.Repair or IntegrityIssueAction.Redownload)
+ .ToList();
+ if (repairIssues.Count == 0 ||
+ repairIssues.Any(issue =>
+ issue.Action != IntegrityIssueAction.Repair ||
+ issue.Kind is not (IntegrityIssueKind.MissingFile or IntegrityIssueKind.ModifiedFile)))
+ {
+ return Array.Empty();
+ }
+
+ var remainingIssuePaths = repairIssues
+ .Select(issue => LexicalPath.NormalizeRelativePath(issue.RelativePath))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ List selectedFiles = new();
+ foreach (RemoteFileManifestEntry file in files)
+ {
+ string manifestRelativePath = ManifestPathResolver.NormalizeForManifestIndex(file.FileName);
+ string installedRelativePath = GetInstalledS3RelativePath(file.FileName);
+ bool matchesIssue = remainingIssuePaths.Remove(manifestRelativePath);
+ matchesIssue |= remainingIssuePaths.Remove(installedRelativePath);
+ if (matchesIssue)
+ {
+ selectedFiles.Add(file);
+ }
+ }
+
+ return remainingIssuePaths.Count == 0
+ ? selectedFiles
+ : Array.Empty();
+ }
+
+ ///
+ /// Converts a manifest file name into the relative path expected in an installed package snapshot.
+ ///
+ private static string GetInstalledS3RelativePath(string manifestFileName)
+ {
+ string normalizedPath = ManifestPathResolver.NormalizeForManifestIndex(manifestFileName);
+ return LexicalPath.NormalizeRelativePath(
+ BigFileVariantPath.GetInstalledPath(normalizedPath));
+ }
+
+ ///
+ /// Refreshes a managed launcher-owned cache target from remote asset links.
+ ///
+ private async Task RefreshManagedCacheAsync(
+ LaunchContentIntegrityTargetContext context,
+ CancellationToken cancellationToken)
+ {
+ ContentIntegrityTarget target = context.Target;
+ FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints(
+ target.RootDirectory,
+ "Content metadata must resolve to a rooted path.",
+ "Content metadata resolved through a linked launcher-owned directory.");
+ Directory.CreateDirectory(target.RootDirectory);
+ FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints(
+ target.RootDirectory,
+ "Content metadata must resolve to a rooted path.",
+ "Content metadata resolved through a linked launcher-owned directory.");
+
+ IReadOnlyList assets = BuildRemoteCacheAssets(context.Version, target.RootDirectory);
+ foreach (string filePath in EnumerateFilesWithoutLinks(target.RootDirectory).ToList())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ string relativePath = ContentIntegrityPath.GetRelativePath(target.RootDirectory, filePath);
+ if (ContentIntegrityPath.IsIgnored(target, relativePath))
+ {
+ continue;
+ }
+
+ File.Delete(filePath);
+ }
+
+ foreach (string directory in EnumerateDirectoriesWithoutLinks(target.RootDirectory)
+ .OrderByDescending(path => path.Length)
+ .ToList())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!Directory.EnumerateFileSystemEntries(directory).Any())
+ {
+ Directory.Delete(directory);
+ }
+ }
+
+ foreach (RemoteCacheAsset asset in assets)
+ {
+ await _assetDownloader.DownloadIfMissingAsync(
+ asset.SourceUri,
+ asset.DestinationPath,
+ cancellationToken);
+ }
+ }
+
+ private static HashSet BuildExpectedRemoteCachePaths(LaunchContentIntegrityTargetContext context)
+ {
+ return BuildRemoteCacheAssets(context.Version, context.Target.RootDirectory)
+ .Select(asset => ContentIntegrityPath.GetRelativePath(
+ context.Target.RootDirectory,
+ asset.DestinationPath))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ }
+
+ private static IReadOnlyList BuildRemoteCacheAssets(
+ LauncherContentVersion version,
+ string cacheDirectory)
+ {
+ if (!Uri.TryCreate(version.UIImageSourceLink, UriKind.Absolute, out Uri? sourceUri))
+ {
+ return Array.Empty();
+ }
+
+ return new[]
+ {
+ new RemoteCacheAsset(
+ sourceUri,
+ ModificationImageCachePath.ResolveRemoteImagePath(
+ cacheDirectory,
+ version.Version,
+ sourceUri)),
+ };
+ }
+
+ ///
+ /// Enumerates files without following linked directories into paths the launcher does not own.
+ ///
+ private static IEnumerable EnumerateFilesWithoutLinks(string rootDirectory)
+ {
+ return Directory.EnumerateFiles(
+ rootDirectory,
+ "*",
+ FileSystemPathSafety.CreateRecursiveNoLinksOptions());
+ }
+
+ ///
+ /// Enumerates directories without following linked directories into paths the launcher does not own.
+ ///
+ private static IEnumerable EnumerateDirectoriesWithoutLinks(string rootDirectory)
+ {
+ return Directory.EnumerateDirectories(
+ rootDirectory,
+ "*",
+ FileSystemPathSafety.CreateRecursiveNoLinksOptions());
+ }
+
+ ///
+ /// Bridges package updater progress to launch integrity progress by target id.
+ ///
+ private sealed class TargetPackageProgress : IProgress
+ {
+ private readonly string _targetId;
+
+ private readonly IProgress? _progress;
+
+ public TargetPackageProgress(
+ string targetId,
+ IProgress? progress)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(targetId);
+
+ _targetId = targetId;
+ _progress = progress;
+ }
+
+ public void Report(PackageUpdateProgress value)
+ {
+ _progress?.Report(LaunchContentIntegrityResolutionProgress.Package(_targetId, value));
+ }
+ }
+
+ private sealed record RemoteCacheAsset(
+ Uri SourceUri,
+ string DestinationPath);
+}
diff --git a/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilder.cs b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilder.cs
new file mode 100644
index 00000000..563412c3
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilder.cs
@@ -0,0 +1,194 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using GenLauncherGO.Core.Integrity.Models;
+using GenLauncherGO.Core.IO;
+using GenLauncherGO.Core.Launching.Models;
+using GenLauncherGO.Core.Mods.Models;
+using GenLauncherGO.Core.Mods.Services;
+using GenLauncherGO.Infrastructure.Common;
+using GenLauncherGO.Infrastructure.Launching.Contracts;
+using GenLauncherGO.Infrastructure.Mods.Support;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace GenLauncherGO.Infrastructure.Launching.Services;
+
+///
+/// Builds launch-readiness integrity targets from launcher-owned file-system paths.
+///
+internal sealed class FileSystemLaunchContentIntegrityTargetBuilder : ILaunchContentIntegrityTargetBuilder
+{
+ private static readonly HashSet _emptyIgnoredPaths = new(StringComparer.OrdinalIgnoreCase);
+
+ private readonly ILogger _logger;
+
+ public FileSystemLaunchContentIntegrityTargetBuilder(
+ ILogger? logger = null)
+ {
+ _logger = logger ?? NullLogger.Instance;
+ }
+
+ public IReadOnlyList BuildTargets(
+ LaunchContentIntegrityTargetRequest request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var contexts = new List();
+ foreach (LauncherContentVersion version in request.ActiveVersions)
+ {
+ contexts.Add(new LaunchContentIntegrityTargetContext(
+ CreatePackageTarget(request, version),
+ version,
+ isCache: false));
+
+ if (version.ModificationType == ModificationType.Mod)
+ {
+ contexts.Add(new LaunchContentIntegrityTargetContext(
+ CreateCacheTarget(request, version),
+ version,
+ isCache: true));
+ }
+ }
+
+ if (contexts.Count > 0)
+ {
+ _logger.LogInformation(
+ "Built {TargetCount} launch content integrity target(s) for {VersionCount} active version(s).",
+ contexts.Count,
+ request.ActiveVersions.Count);
+ }
+ else
+ {
+ _logger.LogDebug("Skipped launch content integrity target construction because no active versions were selected.");
+ }
+
+ return contexts;
+ }
+
+ private ContentIntegrityTarget CreatePackageTarget(
+ LaunchContentIntegrityTargetRequest request,
+ LauncherContentVersion version)
+ {
+ OwnedContentPath packagePath = LauncherContentPathResolver.ResolveVersionPath(
+ request.Paths,
+ version.ContentKey)
+ ?? throw new InvalidDataException(
+ "Content metadata did not resolve to a supported launcher content path.");
+ string packageDirectory = FileSystemPathSafety.ResolveOwnedSubpath(
+ packagePath.OwnerRoot,
+ packagePath.FullPath,
+ "Content metadata resolved outside a launcher-owned directory.",
+ "Content metadata resolved through a linked launcher-owned directory.");
+
+ return new ContentIntegrityTarget(
+ CreateTargetId("package", version.ContentKey),
+ version.DisplayName,
+ packageDirectory,
+ version.EffectiveContentSourceKind,
+ _emptyIgnoredPaths);
+ }
+
+ private ContentIntegrityTarget CreateCacheTarget(
+ LaunchContentIntegrityTargetRequest request,
+ LauncherContentVersion version)
+ {
+ string cacheDirectory = ModificationImageCachePath.ResolveDirectory(request.Paths, version.Name);
+ HashSet ignoredPaths = BuildInactiveCacheIgnoredPaths(request, version, cacheDirectory);
+
+ return new ContentIntegrityTarget(
+ CreateTargetId("cache", version.ContentKey),
+ version.DisplayName + " " + request.CacheDisplayNameSuffix,
+ cacheDirectory,
+ version.EffectiveContentSourceKind,
+ ignoredPaths);
+ }
+
+ ///
+ /// Builds ignored cache paths that belong to inactive versions of the same modification.
+ ///
+ private HashSet BuildInactiveCacheIgnoredPaths(
+ LaunchContentIntegrityTargetRequest request,
+ LauncherContentVersion version,
+ string cacheDirectory)
+ {
+ var ignoredPaths = new HashSet(StringComparer.OrdinalIgnoreCase);
+ if (!Directory.Exists(cacheDirectory))
+ {
+ _logger.LogDebug(
+ "Skipped inactive image-cache ignore discovery for {ContentName} {ContentVersion} because the cache directory does not exist.",
+ version.Name,
+ version.Version);
+ return ignoredPaths;
+ }
+
+ if (FileSystemPathSafety.IsReparsePoint(cacheDirectory))
+ {
+ _logger.LogWarning(
+ "Skipped inactive image-cache ignore discovery for {ContentName} {ContentVersion} because the cache directory is a reparse point.",
+ version.Name,
+ version.Version);
+ return ignoredPaths;
+ }
+
+ var inactiveBaseNames = request.AllVersions
+ .Where(candidate =>
+ !IsExactVersion(candidate, version) &&
+ candidate.ContentKey.HasName(version.Name))
+ .SelectMany(candidate => new[]
+ {
+ candidate.Version,
+ candidate.Version + "-background",
+ })
+ .Where(value => !string.IsNullOrWhiteSpace(value))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (string filePath in EnumerateFilesWithoutLinks(cacheDirectory))
+ {
+ string relativePath = LexicalPath.GetRelativePath(cacheDirectory, filePath);
+ if (inactiveBaseNames.Contains(Path.GetFileNameWithoutExtension(filePath)))
+ {
+ ignoredPaths.Add(relativePath);
+ }
+ }
+
+ if (ignoredPaths.Count > 0)
+ {
+ _logger.LogDebug(
+ "Ignored {IgnoredPathCount} inactive image-cache file(s) while building integrity target for {ContentName} {ContentVersion}.",
+ ignoredPaths.Count,
+ version.Name,
+ version.Version);
+ }
+
+ return ignoredPaths;
+ }
+
+ ///
+ /// Creates a stable target identifier.
+ ///
+ private static string CreateTargetId(string prefix, LauncherContentKey contentKey)
+ {
+ return string.Concat(prefix, ":", contentKey.ToStableString());
+ }
+
+ private static bool IsExactVersion(
+ LauncherContentVersion candidate,
+ LauncherContentVersion version)
+ {
+ return candidate.ContentKey == version.ContentKey;
+ }
+
+ ///
+ /// Enumerates files without following linked directories.
+ ///
+ private static IEnumerable EnumerateFilesWithoutLinks(string rootDirectory)
+ {
+ return Directory.EnumerateFiles(
+ rootDirectory,
+ "*",
+ FileSystemPathSafety.CreateRecursiveNoLinksOptions());
+ }
+
+}
diff --git a/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryService.cs b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryService.cs
new file mode 100644
index 00000000..3a55b6b5
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryService.cs
@@ -0,0 +1,103 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using GenLauncherGO.Core.Launching.Contracts;
+using GenLauncherGO.Core.Launching.Models;
+using GenLauncherGO.Core.Startup;
+using GenLauncherGO.Infrastructure.Common;
+using Microsoft.Extensions.Logging;
+
+namespace GenLauncherGO.Infrastructure.Launching.Services;
+
+///
+/// Discovers Windows game and World Builder executables through file-system probes.
+///
+internal sealed class WindowsGameExecutableDiscoveryService : IGameExecutableDiscoveryService
+{
+ private readonly LauncherRuntimePathContext _runtimePathContext;
+
+ private readonly ILogger _logger;
+
+ public WindowsGameExecutableDiscoveryService(
+ LauncherRuntimePathContext runtimePathContext,
+ ILogger logger)
+ {
+ _runtimePathContext = runtimePathContext ?? throw new ArgumentNullException(nameof(runtimePathContext));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public IReadOnlyList GetGameClients()
+ {
+ LauncherPaths paths = _runtimePathContext.ActivePaths;
+ var executables = new List();
+ string communityExecutable = LauncherFileSystemLayout.GetCommunityGameExecutableName(paths.Game);
+ executables.Add(new GameClientExecutable(
+ communityExecutable,
+ GameClientExecutableKind.Community,
+ IsExecutableAvailable(communityExecutable, paths)));
+
+ if (paths.Game == SupportedGame.ZeroHour)
+ {
+ executables.Add(new GameClientExecutable(
+ LauncherFileSystemLayout.GeneralsOnlineExecutableFileName,
+ GameClientExecutableKind.GeneralsOnline,
+ IsExecutableAvailable(LauncherFileSystemLayout.GeneralsOnlineExecutableFileName, paths)));
+ }
+
+ return executables;
+ }
+
+ public IReadOnlyList GetWorldBuilders()
+ {
+ LauncherPaths paths = _runtimePathContext.ActivePaths;
+ var executables = new List();
+
+ executables.Add(new WorldBuilderExecutable(
+ LauncherFileSystemLayout.VanillaWorldBuilderExecutableFileName,
+ WorldBuilderExecutableKind.Vanilla,
+ IsExecutableAvailable(LauncherFileSystemLayout.VanillaWorldBuilderExecutableFileName, paths)));
+
+ string communityExecutable = LauncherFileSystemLayout.GetCommunityWorldBuilderExecutableName(paths.Game);
+ executables.Add(new WorldBuilderExecutable(
+ communityExecutable,
+ WorldBuilderExecutableKind.Community,
+ IsExecutableAvailable(communityExecutable, paths)));
+
+ return executables;
+ }
+
+ public bool IsExecutableAvailable(string? executableName)
+ {
+ if (string.IsNullOrWhiteSpace(executableName))
+ {
+ return false;
+ }
+
+ LauncherPaths paths = _runtimePathContext.ActivePaths;
+ return IsExecutableAvailable(executableName, paths);
+ }
+
+ ///
+ /// Probes one executable against an immutable active-path snapshot.
+ ///
+ private bool IsExecutableAvailable(
+ string executableName,
+ LauncherPaths paths)
+ {
+ try
+ {
+ string normalizedName = LauncherFileSystemLayout.NormalizeExecutableFileName(executableName);
+ string executablePath = Path.Combine(paths.GameDirectory, normalizedName);
+ return File.Exists(executablePath) && !FileSystemPathSafety.IsReparsePoint(executablePath);
+ }
+ catch (Exception exception) when (
+ exception is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException)
+ {
+ _logger.LogWarning(
+ exception,
+ "Could not inspect executable availability for {ExecutableName}.",
+ Path.GetFileName(executableName));
+ return false;
+ }
+ }
+}
diff --git a/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameProcessLauncher.cs b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameProcessLauncher.cs
new file mode 100644
index 00000000..b7f51fae
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameProcessLauncher.cs
@@ -0,0 +1,154 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using GenLauncherGO.Core.Launching.Contracts;
+using GenLauncherGO.Core.Launching.Models;
+using GenLauncherGO.Infrastructure.Common;
+using GenLauncherGO.Infrastructure.Launching.Support;
+using Microsoft.Extensions.Logging;
+
+namespace GenLauncherGO.Infrastructure.Launching.Services;
+
+///
+/// Launches Windows game and World Builder processes for supported Command & Conquer clients.
+///
+internal sealed class WindowsGameProcessLauncher : IGameProcessLauncher
+{
+ ///
+ /// The observed game-process running time required to treat a launch as successful.
+ ///
+ private const int SuccessfulLaunchThresholdMilliseconds = 12000;
+
+ private readonly IProcessFamilyLauncher _processFamilyLauncher;
+
+ private readonly ILogger _logger;
+
+ public WindowsGameProcessLauncher(
+ IProcessFamilyLauncher processFamilyLauncher,
+ ILogger logger)
+ {
+ _processFamilyLauncher =
+ processFamilyLauncher ?? throw new ArgumentNullException(nameof(processFamilyLauncher));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task StartAsync(
+ GameLaunchRequest request,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ string executableName = Path.Combine(request.GameDirectory, request.ExecutableName);
+ try
+ {
+ EnsureExecutableCanLaunch(executableName);
+ IProcessFamilyLaunchOperation operation = await _processFamilyLauncher.StartAsync(
+ executableName,
+ request.Arguments,
+ request.GameDirectory,
+ cancellationToken).ConfigureAwait(false);
+ return new WindowsGameProcessLaunchOperation(
+ request.TargetKind,
+ executableName,
+ operation,
+ _logger);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogError(ex, "Failed to launch {ExecutableName}.", executableName);
+ throw;
+ }
+ }
+
+ private static void EnsureExecutableCanLaunch(string executablePath)
+ {
+ if (!File.Exists(executablePath))
+ {
+ throw new FileNotFoundException("The selected executable is no longer available.", executablePath);
+ }
+
+ if (FileSystemPathSafety.IsReparsePoint(executablePath))
+ {
+ throw new IOException("The selected executable must not be a symbolic link or other reparse point.");
+ }
+ }
+
+ ///
+ /// Adapts an infrastructure process-family operation to the Core game-launch operation contract.
+ ///
+ private sealed class WindowsGameProcessLaunchOperation : IGameProcessLaunchOperation
+ {
+ private readonly GameLaunchTargetKind _targetKind;
+
+ private readonly string _executableName;
+
+ private readonly IProcessFamilyLaunchOperation _processFamilyOperation;
+
+ private readonly ILogger _logger;
+
+ public WindowsGameProcessLaunchOperation(
+ GameLaunchTargetKind targetKind,
+ string executableName,
+ IProcessFamilyLaunchOperation processFamilyOperation,
+ ILogger logger)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(executableName);
+
+ _targetKind = targetKind;
+ _executableName = executableName;
+ _processFamilyOperation = processFamilyOperation ??
+ throw new ArgumentNullException(nameof(processFamilyOperation));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _processFamilyOperation.CurrentExecutableNameChanged += ProcessFamilyOperation_CurrentExecutableNameChanged;
+ Completion = CompleteAsync();
+ }
+
+ public string CurrentExecutableName => _processFamilyOperation.CurrentExecutableName;
+
+ public event EventHandler? CurrentExecutableNameChanged;
+
+ public Task Completion { get; }
+
+ public void ForceClose()
+ {
+ _processFamilyOperation.ForceClose();
+ }
+
+ ///
+ /// Determines whether the process-family completion satisfies the launch success policy.
+ ///
+ private async Task CompleteAsync()
+ {
+ try
+ {
+ TimeSpan runningDuration = await _processFamilyOperation.Completion.ConfigureAwait(false);
+ if (_targetKind == GameLaunchTargetKind.GameClient &&
+ runningDuration.TotalMilliseconds < SuccessfulLaunchThresholdMilliseconds)
+ {
+ _logger.LogInformation(
+ "Launch of {ExecutableName} ended after {RunningDurationMs}ms, below the success threshold.",
+ _executableName,
+ runningDuration.TotalMilliseconds);
+ return false;
+ }
+
+ return true;
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogError(ex, "Failed while waiting for launched process {ExecutableName}.", _executableName);
+ throw;
+ }
+ finally
+ {
+ _processFamilyOperation.CurrentExecutableNameChanged -= ProcessFamilyOperation_CurrentExecutableNameChanged;
+ }
+ }
+
+ private void ProcessFamilyOperation_CurrentExecutableNameChanged(object? sender, EventArgs e)
+ {
+ CurrentExecutableNameChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
+}
diff --git a/GenLauncherGO.Infrastructure/Launching/Services/WindowsProcessFamilyLauncher.cs b/GenLauncherGO.Infrastructure/Launching/Services/WindowsProcessFamilyLauncher.cs
new file mode 100644
index 00000000..b179e791
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Launching/Services/WindowsProcessFamilyLauncher.cs
@@ -0,0 +1,664 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using GenLauncherGO.Infrastructure.Launching.Support;
+using Microsoft.Extensions.Logging;
+
+namespace GenLauncherGO.Infrastructure.Launching.Services;
+
+///
+/// Starts Windows processes and waits until the launched process family has exited.
+///
+internal sealed class WindowsProcessFamilyLauncher : IProcessFamilyLauncher
+{
+ private const int ProcessPollMilliseconds = 500;
+
+ // Some launchers exit before their replacement child becomes visible in a process snapshot.
+ private const int ProcessHandoffGraceMilliseconds = 5000;
+
+ private const uint Th32csSnapprocess = 0x00000002;
+
+ private static readonly IntPtr _invalidHandleValue = new(-1);
+
+ private readonly ILogger _logger;
+
+ public WindowsProcessFamilyLauncher(ILogger logger)
+ {
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public Task StartAsync(
+ string executableName,
+ string arguments,
+ string workingDirectory,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(executableName);
+ ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory);
+
+ return Task.Run(
+ () => StartLaunchOperation(
+ executableName,
+ arguments ?? string.Empty,
+ workingDirectory,
+ cancellationToken),
+ cancellationToken);
+ }
+
+ private IProcessFamilyLaunchOperation StartLaunchOperation(
+ string executableName,
+ string arguments,
+ string workingDirectory,
+ CancellationToken cancellationToken)
+ {
+ Process process = StartExecutable(executableName, arguments, workingDirectory);
+ return new WindowsProcessFamilyLaunchOperation(
+ executableName,
+ process,
+ new ProcessFamilyTracker(process.Id, executableName, _logger),
+ cancellationToken,
+ _logger);
+ }
+
+ private static Process StartExecutable(
+ string executableName,
+ string arguments,
+ string workingDirectory)
+ {
+ var process = Process.Start(new ProcessStartInfo
+ {
+ FileName = executableName,
+ Arguments = arguments,
+ WorkingDirectory = workingDirectory,
+ UseShellExecute = false,
+ });
+ if (process == null)
+ {
+ throw new InvalidOperationException($"Failed to start {executableName}.");
+ }
+
+ return process;
+ }
+
+ ///
+ /// Returns when ToolHelp cannot capture a snapshot so tracking can fall back to the root process.
+ ///
+ private static IReadOnlyList? TryCaptureProcessSnapshot()
+ {
+ IntPtr snapshotHandle = CreateToolhelp32Snapshot(Th32csSnapprocess, 0);
+ if (snapshotHandle == _invalidHandleValue)
+ {
+ return null;
+ }
+
+ try
+ {
+ var entries = new List();
+ var nativeEntry = new NativeProcessEntry
+ {
+ Size = (uint)Marshal.SizeOf(typeof(NativeProcessEntry)),
+ };
+ if (!Process32First(snapshotHandle, ref nativeEntry))
+ {
+ return entries;
+ }
+
+ do
+ {
+ entries.Add(new ProcessSnapshotEntry(
+ unchecked((int)nativeEntry.ProcessId),
+ unchecked((int)nativeEntry.ParentProcessId),
+ nativeEntry.ExecutableFileName ?? string.Empty));
+ } while (Process32Next(snapshotHandle, ref nativeEntry));
+
+ return entries;
+ }
+ finally
+ {
+ CloseHandle(snapshotHandle);
+ }
+ }
+
+ // ToolHelp provides parent process ids that System.Diagnostics.Process does not expose reliably.
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint processId);
+
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
+ private static extern bool Process32First(IntPtr snapshotHandle, ref NativeProcessEntry processEntry);
+
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
+ private static extern bool Process32Next(IntPtr snapshotHandle, ref NativeProcessEntry processEntry);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool CloseHandle(IntPtr handle);
+
+ ///
+ /// Observes a started Windows process family and exposes a force-close command for it.
+ ///
+ private sealed class WindowsProcessFamilyLaunchOperation : IProcessFamilyLaunchOperation
+ {
+ private readonly ILogger _logger;
+
+ private readonly string _executableName;
+
+ private readonly Process _rootProcess;
+
+ private readonly ProcessFamilyTracker _processFamily;
+
+ private readonly CancellationToken _cancellationToken;
+
+ private readonly object _syncRoot = new();
+
+ private bool _disposed;
+
+ public WindowsProcessFamilyLaunchOperation(
+ string executableName,
+ Process rootProcess,
+ ProcessFamilyTracker processFamily,
+ CancellationToken cancellationToken,
+ ILogger logger)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(executableName);
+
+ _executableName = executableName;
+ _rootProcess = rootProcess ?? throw new ArgumentNullException(nameof(rootProcess));
+ _processFamily = processFamily ?? throw new ArgumentNullException(nameof(processFamily));
+ _cancellationToken = cancellationToken;
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ Completion = Task.Run(WaitForProcessFamilyExit);
+ }
+
+ public string CurrentExecutableName => _processFamily.CurrentExecutableName;
+
+ public event EventHandler? CurrentExecutableNameChanged;
+
+ public Task Completion { get; }
+
+ public void ForceClose()
+ {
+ _logger.LogWarning(
+ "Force close requested for launched process family {ExecutableName}.",
+ _executableName);
+ _processFamily.ForceClose();
+ }
+
+ private TimeSpan WaitForProcessFamilyExit()
+ {
+ string currentExecutableName = CurrentExecutableName;
+ try
+ {
+ while (_processFamily.IsRunning())
+ {
+ RaiseCurrentExecutableNameChangedIfNeeded(ref currentExecutableName);
+ if (_cancellationToken.WaitHandle.WaitOne(ProcessPollMilliseconds))
+ {
+ _cancellationToken.ThrowIfCancellationRequested();
+ }
+ }
+
+ RaiseCurrentExecutableNameChangedIfNeeded(ref currentExecutableName);
+ return _processFamily.RunningDuration;
+ }
+ finally
+ {
+ DisposeRootProcess();
+ }
+ }
+
+ private void RaiseCurrentExecutableNameChangedIfNeeded(ref string currentExecutableName)
+ {
+ string updatedExecutableName = CurrentExecutableName;
+ if (String.Equals(updatedExecutableName, currentExecutableName, StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ currentExecutableName = updatedExecutableName;
+ CurrentExecutableNameChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void DisposeRootProcess()
+ {
+ lock (_syncRoot)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _rootProcess.Dispose();
+ _disposed = true;
+ }
+ }
+ }
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
+ private struct NativeProcessEntry
+ {
+ public uint Size;
+
+ public uint UsageCount;
+
+ public uint ProcessId;
+
+ public IntPtr DefaultHeapId;
+
+ public uint ModuleId;
+
+ public uint ThreadCount;
+
+ public uint ParentProcessId;
+
+ public int PriorityClassBase;
+
+ public uint Flags;
+
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
+ public string? ExecutableFileName;
+ }
+
+ ///
+ /// Tracks descendants across launcher handoffs until the complete Windows process family exits.
+ ///
+ internal sealed class ProcessFamilyTracker
+ {
+ private readonly Func?> _captureProcessSnapshot;
+
+ private readonly Func _isProcessRunning;
+
+ private readonly Func _getUtcNow;
+
+ private readonly Action _forceCloseProcess;
+
+ private readonly object _syncRoot = new();
+
+ private readonly TimeSpan _handoffGracePeriod;
+
+ // Retain recently exited parents so a replacement child appearing in a later snapshot is still discovered.
+ private readonly Dictionary _trackedProcesses = new();
+
+ private readonly int _rootProcessId;
+
+ private readonly DateTime _startedAtUtc;
+
+ private readonly ILogger _logger;
+
+ // An empty snapshot after a child was seen may be a handoff gap rather than the end of the family.
+ private DateTime? _emptyFamilyObservedAtUtc;
+
+ private DateTime _lastObservedRunningAtUtc;
+
+ private bool _childProcessObserved;
+
+ private bool _snapshotFailureLogged;
+
+ private string _currentExecutableName;
+
+ private int _nextProcessOrder;
+
+ public ProcessFamilyTracker(
+ int rootProcessId,
+ string rootExecutableName,
+ ILogger logger)
+ : this(
+ rootProcessId,
+ rootExecutableName,
+ logger,
+ TryCaptureProcessSnapshot,
+ IsProcessRunning,
+ () => DateTime.UtcNow,
+ TimeSpan.FromMilliseconds(ProcessHandoffGraceMilliseconds),
+ ForceCloseProcess)
+ {
+ }
+
+ internal ProcessFamilyTracker(
+ int rootProcessId,
+ string rootExecutableName,
+ ILogger logger,
+ Func?> captureProcessSnapshot,
+ Func isProcessRunning,
+ Func getUtcNow,
+ TimeSpan handoffGracePeriod,
+ Action forceCloseProcess)
+ {
+ _rootProcessId = rootProcessId;
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _captureProcessSnapshot = captureProcessSnapshot ??
+ throw new ArgumentNullException(nameof(captureProcessSnapshot));
+ _isProcessRunning = isProcessRunning ?? throw new ArgumentNullException(nameof(isProcessRunning));
+ _getUtcNow = getUtcNow ?? throw new ArgumentNullException(nameof(getUtcNow));
+ _handoffGracePeriod = handoffGracePeriod;
+ _forceCloseProcess = forceCloseProcess ?? throw new ArgumentNullException(nameof(forceCloseProcess));
+ _startedAtUtc = _getUtcNow();
+ _lastObservedRunningAtUtc = _startedAtUtc;
+ _currentExecutableName = NormalizeExecutableName(rootExecutableName);
+ _trackedProcesses.Add(
+ rootProcessId,
+ new TrackedProcess(_currentExecutableName, depth: 0, order: _nextProcessOrder++));
+ }
+
+ public TimeSpan RunningDuration => _lastObservedRunningAtUtc - _startedAtUtc;
+
+ public string CurrentExecutableName
+ {
+ get
+ {
+ lock (_syncRoot)
+ {
+ return _currentExecutableName;
+ }
+ }
+ }
+
+ public bool IsRunning()
+ {
+ lock (_syncRoot)
+ {
+ return IsRunningCore();
+ }
+ }
+
+ public void ForceClose()
+ {
+ IReadOnlyList processIds;
+ lock (_syncRoot)
+ {
+ processIds = GetTrackedRunningProcessIds();
+ }
+
+ foreach (int processId in processIds)
+ {
+ TryForceCloseProcess(processId);
+ }
+ }
+
+ private bool IsRunningCore()
+ {
+ IReadOnlyList? entries = _captureProcessSnapshot();
+ if (entries == null)
+ {
+ LogSnapshotFailureOnce();
+ return IsRootProcessRunning();
+ }
+
+ DateTime nowUtc = _getUtcNow();
+ ExpireRetiredProcessIds(nowUtc);
+ TrackDescendants(entries);
+
+ var runningProcessIds = entries
+ .Select(entry => entry.ProcessId)
+ .ToHashSet();
+
+ IReadOnlyList knownRunningProcessIds = UpdateKnownProcessState(runningProcessIds, nowUtc);
+ UpdateCurrentExecutableName(knownRunningProcessIds);
+ if (HasActiveTrackedProcess(knownRunningProcessIds))
+ {
+ _emptyFamilyObservedAtUtc = null;
+ _lastObservedRunningAtUtc = nowUtc;
+ return true;
+ }
+
+ if (!_childProcessObserved)
+ {
+ return false;
+ }
+
+ _emptyFamilyObservedAtUtc ??= nowUtc;
+ return nowUtc - _emptyFamilyObservedAtUtc.Value < _handoffGracePeriod;
+ }
+
+ private IReadOnlyList GetTrackedRunningProcessIds()
+ {
+ IReadOnlyList? entries = _captureProcessSnapshot();
+ if (entries == null)
+ {
+ LogSnapshotFailureOnce();
+ return _trackedProcesses.Keys
+ .Where(_isProcessRunning)
+ .ToList();
+ }
+
+ TrackDescendants(entries);
+ var runningProcessIds = entries
+ .Select(entry => entry.ProcessId)
+ .ToHashSet();
+ return _trackedProcesses
+ .Where(process => !process.Value.RetiredAtUtc.HasValue)
+ .Select(process => process.Key)
+ .Where(runningProcessIds.Contains)
+ .ToList();
+ }
+
+ private void TryForceCloseProcess(int processId)
+ {
+ try
+ {
+ _forceCloseProcess(processId);
+ _logger.LogWarning("Force closed launched process {ProcessId}.", processId);
+ }
+ catch (ArgumentException)
+ {
+ _logger.LogInformation(
+ "Tracked launched process {ProcessId} exited before force close completed.",
+ processId);
+ }
+ catch (InvalidOperationException)
+ {
+ _logger.LogInformation(
+ "Tracked launched process {ProcessId} exited before force close completed.",
+ processId);
+ }
+ catch (Win32Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to force close launched process {ProcessId}.", processId);
+ }
+ catch (NotSupportedException ex)
+ {
+ _logger.LogWarning(ex, "Failed to force close launched process {ProcessId}.", processId);
+ }
+ }
+
+ private void ExpireRetiredProcessIds(DateTime nowUtc)
+ {
+ foreach (KeyValuePair retiredProcess in _trackedProcesses.ToList())
+ {
+ if (retiredProcess.Value.RetiredAtUtc is not DateTime retiredAtUtc ||
+ nowUtc - retiredAtUtc < _handoffGracePeriod)
+ {
+ continue;
+ }
+
+ _trackedProcesses.Remove(retiredProcess.Key);
+ }
+ }
+
+ private void TrackDescendants(IReadOnlyList entries)
+ {
+ bool addedProcess;
+ do
+ {
+ addedProcess = false;
+ foreach (ProcessSnapshotEntry entry in entries)
+ {
+ if (!_trackedProcesses.TryGetValue(
+ entry.ParentProcessId,
+ out TrackedProcess? parentProcess) ||
+ _trackedProcesses.ContainsKey(entry.ProcessId))
+ {
+ continue;
+ }
+
+ addedProcess = true;
+ _childProcessObserved = true;
+ var trackedProcess = new TrackedProcess(
+ NormalizeExecutableName(entry.ExecutableFileName),
+ parentProcess.Depth + 1,
+ _nextProcessOrder++);
+ _trackedProcesses.Add(entry.ProcessId, trackedProcess);
+
+ _logger.LogInformation(
+ "Tracking launched child process {ProcessId} ({ExecutableName}) for cleanup wait.",
+ entry.ProcessId,
+ trackedProcess.ExecutableName);
+ }
+ } while (addedProcess);
+ }
+
+ private IReadOnlyList UpdateKnownProcessState(
+ HashSet runningProcessIds,
+ DateTime nowUtc)
+ {
+ var knownRunningProcessIds = new List();
+ foreach ((int processId, TrackedProcess process) in _trackedProcesses)
+ {
+ if (runningProcessIds.Contains(processId) && !process.RetiredAtUtc.HasValue)
+ {
+ knownRunningProcessIds.Add(processId);
+ continue;
+ }
+
+ if (!process.RetiredAtUtc.HasValue)
+ {
+ process.RetiredAtUtc = nowUtc;
+ }
+ }
+
+ return knownRunningProcessIds;
+ }
+
+ private bool HasActiveTrackedProcess(IReadOnlyList knownRunningProcessIds)
+ {
+ if (!_childProcessObserved)
+ {
+ return knownRunningProcessIds.Contains(_rootProcessId);
+ }
+
+ return knownRunningProcessIds.Any(processId => processId != _rootProcessId);
+ }
+
+ private void UpdateCurrentExecutableName(IReadOnlyList knownRunningProcessIds)
+ {
+ IEnumerable candidates = _childProcessObserved
+ ? knownRunningProcessIds.Where(processId => processId != _rootProcessId)
+ : knownRunningProcessIds;
+ int? currentProcessId = candidates
+ .OrderByDescending(GetProcessDepth)
+ .ThenByDescending(GetProcessOrder)
+ .Cast()
+ .FirstOrDefault();
+ if (!currentProcessId.HasValue)
+ {
+ return;
+ }
+
+ if (_trackedProcesses.TryGetValue(currentProcessId.Value, out TrackedProcess? process) &&
+ !String.IsNullOrWhiteSpace(process.ExecutableName))
+ {
+ _currentExecutableName = process.ExecutableName;
+ }
+ }
+
+ private int GetProcessDepth(int processId)
+ {
+ return _trackedProcesses.TryGetValue(processId, out TrackedProcess? process)
+ ? process.Depth
+ : 0;
+ }
+
+ private int GetProcessOrder(int processId)
+ {
+ return _trackedProcesses.TryGetValue(processId, out TrackedProcess? process)
+ ? process.Order
+ : 0;
+ }
+
+ private bool IsRootProcessRunning()
+ {
+ if (!_isProcessRunning(_rootProcessId))
+ {
+ return false;
+ }
+
+ if (_trackedProcesses.TryGetValue(_rootProcessId, out TrackedProcess? process) &&
+ !String.IsNullOrWhiteSpace(process.ExecutableName))
+ {
+ _currentExecutableName = process.ExecutableName;
+ }
+
+ _lastObservedRunningAtUtc = _getUtcNow();
+ return true;
+ }
+
+ private void LogSnapshotFailureOnce()
+ {
+ if (_snapshotFailureLogged)
+ {
+ return;
+ }
+
+ _logger.LogWarning(
+ "Could not inspect launched child processes; falling back to the root launch process only.");
+ _snapshotFailureLogged = true;
+ }
+
+ private static string NormalizeExecutableName(string? executableName)
+ {
+ return executableName?.Trim() ?? string.Empty;
+ }
+
+ private sealed class TrackedProcess
+ {
+ public TrackedProcess(string executableName, int depth, int order)
+ {
+ ExecutableName = executableName;
+ Depth = depth;
+ Order = order;
+ }
+
+ public string ExecutableName { get; }
+
+ public int Depth { get; }
+
+ public int Order { get; }
+
+ public DateTime? RetiredAtUtc { get; set; }
+ }
+ }
+
+ internal sealed record ProcessSnapshotEntry(
+ int ProcessId,
+ int ParentProcessId,
+ string ExecutableFileName = "");
+
+ private static bool IsProcessRunning(int processId)
+ {
+ try
+ {
+ using var process = Process.GetProcessById(processId);
+ return !process.HasExited;
+ }
+ catch (ArgumentException)
+ {
+ return false;
+ }
+ catch (InvalidOperationException)
+ {
+ return false;
+ }
+ }
+
+ private static void ForceCloseProcess(int processId)
+ {
+ using var process = Process.GetProcessById(processId);
+ if (!process.HasExited)
+ {
+ process.Kill(entireProcessTree: true);
+ }
+ }
+}
diff --git a/GenLauncherGO.Infrastructure/Launching/Support/DeploymentFilePlanner.cs b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentFilePlanner.cs
new file mode 100644
index 00000000..f434eb20
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentFilePlanner.cs
@@ -0,0 +1,106 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using GenLauncherGO.Core.IO;
+using GenLauncherGO.Infrastructure.Common;
+
+namespace GenLauncherGO.Infrastructure.Launching.Support;
+
+///
+/// Resolves package files and directories for deployment without mutating the filesystem.
+///
+internal static class DeploymentFilePlanner
+{
+ ///
+ /// Resolves deployable package files and applies package precedence. Windows executable and library binaries remain
+ /// in launcher-owned package storage and are never copied into the user's game directory.
+ ///
+ public static IReadOnlyList ResolveDeploymentFiles(
+ IReadOnlyList packages)
+ {
+ ArgumentNullException.ThrowIfNull(packages);
+
+ var filesByTarget = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (DeploymentPackage package in packages.OrderBy(package => package.Precedence))
+ {
+ string packageRoot = LexicalPath.NormalizeFullPath(package.RootDirectory);
+ if (!Directory.Exists(packageRoot))
+ {
+ throw new DirectoryNotFoundException(
+ $"Deployment package directory was not found: {package.RootDirectory}");
+ }
+
+ FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints(
+ packageRoot,
+ "Deployment package paths must be rooted.",
+ "Deployment package directories must not contain reparse points.");
+ FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints(
+ packageRoot,
+ "Deployment package directories must not contain reparse points.");
+
+ foreach (string sourcePath in Directory.EnumerateFiles(
+ packageRoot,
+ "*",
+ FileSystemPathSafety.CreateRecursiveNoLinksOptions()))
+ {
+ string extension = Path.GetExtension(sourcePath);
+ // Community packages can include their own launchers and tools. Treat those binaries as package-owned
+ // code, not game-directory payload, so deployment cannot replace executable code in the user's install.
+ if (string.Equals(extension, ".exe", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(extension, ".dll", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ string relativePath = DeploymentPathResolver.ToRelativeManifestPath(packageRoot, sourcePath);
+ string targetRelativePath = LexicalPath.NormalizeRelativePath(
+ BigFileVariantPath.GetDeploymentPath(relativePath));
+ string normalizedTargetPath = DeploymentPathResolver.NormalizeManifestPath(targetRelativePath);
+ filesByTarget[normalizedTargetPath] = new ResolvedDeploymentFile(
+ sourcePath,
+ normalizedTargetPath);
+ }
+ }
+
+ return filesByTarget.Values.OrderBy(file => file.TargetRelativePath, StringComparer.OrdinalIgnoreCase).ToList();
+ }
+
+ ///
+ /// Returns missing directories between a game root and target directory in parent-first order.
+ ///
+ public static IEnumerable GetDirectoriesToCreate(string gameRoot, string targetDirectory)
+ {
+ var directories = new Stack();
+ string root = LexicalPath.NormalizeFullPath(gameRoot);
+ string? current = LexicalPath.NormalizeFullPath(targetDirectory);
+ while (!string.IsNullOrWhiteSpace(current) &&
+ !string.Equals(LexicalPath.NormalizeFullPath(current), root, StringComparison.OrdinalIgnoreCase) &&
+ !Directory.Exists(current))
+ {
+ directories.Push(current);
+ current = Directory.GetParent(current)?.FullName;
+ }
+
+ return directories;
+ }
+}
+
+internal sealed record ResolvedDeploymentFile(
+ string SourcePath,
+ string TargetRelativePath);
+
+internal sealed record DeploymentPackage
+{
+ public DeploymentPackage(string rootDirectory, int precedence)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory);
+
+ RootDirectory = rootDirectory;
+ Precedence = precedence;
+ }
+
+ public string RootDirectory { get; }
+
+ public int Precedence { get; }
+}
diff --git a/GenLauncherGO.Infrastructure/Launching/Support/DeploymentPathResolver.cs b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentPathResolver.cs
new file mode 100644
index 00000000..978cddd9
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentPathResolver.cs
@@ -0,0 +1,59 @@
+using System.IO;
+using GenLauncherGO.Core.IO;
+using GenLauncherGO.Core.Startup;
+using GenLauncherGO.Infrastructure.Common;
+
+namespace GenLauncherGO.Infrastructure.Launching.Support;
+
+///
+/// Resolves deployment manifest paths inside launcher-owned deployment roots.
+///
+internal static class DeploymentPathResolver
+{
+ ///
+ /// Resolves a game-directory-relative manifest path.
+ ///
+ public static string ResolveGamePath(LauncherPaths paths, string relativePath)
+ {
+ string normalizedPath = NormalizeManifestPath(relativePath);
+ string gameRoot = LexicalPath.NormalizeFullPath(paths.GameDirectory);
+ string candidatePath = LexicalPath.ResolvePath(gameRoot, normalizedPath);
+ string ownedGameDataRoot = LexicalPath.NormalizeFullPath(paths.OwnedGameDataDirectory);
+
+ if (!LexicalPath.IsPathInDirectory(candidatePath, gameRoot) ||
+ LexicalPath.IsPathInDirectory(candidatePath, ownedGameDataRoot))
+ {
+ throw new InvalidDataException($"Deployment target path '{relativePath}' is outside the game directory.");
+ }
+
+ return candidatePath;
+ }
+
+ public static string NormalizeManifestPath(string relativePath)
+ {
+ return ManifestPathResolver.NormalizeForDeploymentManifest(relativePath);
+ }
+
+ public static string ToRelativeManifestPath(string rootDirectory, string path)
+ {
+ return NormalizeManifestPath(LexicalPath.GetRelativePath(rootDirectory, path));
+ }
+
+ ///
+ /// Resolves a deployment-state-relative path.
+ ///
+ public static string ResolveDeploymentStatePath(string deploymentDirectory, string relativePath)
+ {
+ string normalizedPath = NormalizeManifestPath(relativePath);
+ string deploymentRoot = LexicalPath.NormalizeFullPath(deploymentDirectory);
+ string candidatePath = LexicalPath.ResolvePath(deploymentRoot, normalizedPath);
+
+ if (!LexicalPath.IsPathInDirectory(candidatePath, deploymentRoot))
+ {
+ throw new InvalidDataException(
+ $"Deployment state path '{relativePath}' is outside the deployment directory.");
+ }
+
+ return candidatePath;
+ }
+}
diff --git a/GenLauncherGO.Infrastructure/Launching/Support/DeploymentResult.cs b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentResult.cs
new file mode 100644
index 00000000..eae49d04
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentResult.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace GenLauncherGO.Infrastructure.Launching.Support;
+
+///
+/// Carries infrastructure diagnostics to the launch-preparation boundary for logging.
+///
+internal sealed record DeploymentResult
+{
+ private DeploymentResult(IReadOnlyList failures)
+ {
+ Failures = failures.ToArray();
+ }
+
+ public bool Succeeded => Failures.Count == 0;
+
+ public IReadOnlyList Failures { get; }
+
+ public static DeploymentResult Success()
+ {
+ return new DeploymentResult(Array.Empty());
+ }
+
+ public static DeploymentResult Failure(IReadOnlyList failures)
+ {
+ ArgumentNullException.ThrowIfNull(failures);
+ if (failures.Count == 0)
+ {
+ throw new ArgumentException("At least one deployment failure is required.", nameof(failures));
+ }
+
+ return new DeploymentResult(failures);
+ }
+}
+
+internal sealed record DeploymentFailure(DeploymentFailureKind Kind, string Path, string Message);
+
+internal enum DeploymentFailureKind
+{
+ FileSystem,
+ Manifest
+}
+
+///
+/// Records whether cleanup must account for a deployed hard link or copy.
+///
+internal enum DeploymentMethod
+{
+ HardLink,
+ Copy
+}
diff --git a/GenLauncherGO.Infrastructure/Launching/Support/DeploymentStateStore.cs b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentStateStore.cs
new file mode 100644
index 00000000..c928f235
--- /dev/null
+++ b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentStateStore.cs
@@ -0,0 +1,700 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using GenLauncherGO.Core.IO;
+using GenLauncherGO.Core.Startup;
+using GenLauncherGO.Infrastructure.Common;
+using GenLauncherGO.Infrastructure.Persistence.Services;
+using Microsoft.Extensions.Logging;
+
+namespace GenLauncherGO.Infrastructure.Launching.Support;
+
+///
+/// Persists deployment manifest and journal state used to recover file-system deployment side effects.
+///
+internal sealed class DeploymentStateStore
+{
+ public const string BackupsDirectoryName = "Backups";
+
+ internal const int CurrentSchemaVersion = 2;
+
+ private const string ActiveManifestFileName = "active.json";
+
+ private const string JournalFileName = "journal.jsonl";
+
+ private const string LockFileName = "deployment.lock";
+
+ private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web)
+ {
+ // Schema-v2 manifests may contain retired reporting fields that are irrelevant to safe recovery.
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ WriteIndented = true
+ };
+
+ private static readonly JsonSerializerOptions _journalJsonOptions = new(JsonSerializerDefaults.Web)
+ {
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip
+ };
+
+ private readonly ILogger _logger;
+
+ public DeploymentStateStore(ILogger logger)
+ {
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public static DeploymentStatePaths CreatePaths(LauncherPaths paths, string deploymentId)
+ {
+ string deploymentDirectory = paths.DeploymentDirectory;
+ string backupDirectory = string.IsNullOrWhiteSpace(deploymentId)
+ ? Path.Combine(deploymentDirectory, BackupsDirectoryName)
+ : Path.Combine(deploymentDirectory, BackupsDirectoryName, deploymentId);
+
+ return new DeploymentStatePaths(
+ deploymentDirectory,
+ Path.Combine(deploymentDirectory, ActiveManifestFileName),
+ Path.Combine(deploymentDirectory, JournalFileName),
+ Path.Combine(deploymentDirectory, LockFileName),
+ backupDirectory);
+ }
+
+ ///
+ /// Holds an exclusive file lock so deployment preparation, cleanup, and recovery cannot overlap.
+ ///
+ public static FileStream AcquireDeploymentLock(LauncherPaths paths)
+ {
+ DeploymentStatePaths deploymentPaths = CreatePaths(paths, deploymentId: string.Empty);
+ OwnedDirectoryTree.EnsureExists(paths.OwnedGameDataDirectory, deploymentPaths.DeploymentDirectory);
+ FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints(
+ deploymentPaths.LockPath,
+ "Deployment lock paths must be rooted.",
+ "Deployment lock paths must not contain reparse points.");
+
+ return new FileStream(
+ deploymentPaths.LockPath,
+ FileMode.OpenOrCreate,
+ FileAccess.ReadWrite,
+ FileShare.None);
+ }
+
+ ///
+ /// Commits the completed deployment manifest with atomic replacement semantics.
+ ///
+ public static void WriteManifest(string manifestPath, DeploymentManifestDocument manifest)
+ {
+ new AtomicFileWriter().WriteText(manifestPath, JsonSerializer.Serialize(manifest, _jsonOptions));
+ }
+
+ ///
+ /// Deletes persisted active deployment state after cleanup or recovery.
+ ///
+ public static void DeleteDeploymentStateFiles(DeploymentStatePaths paths)
+ {
+ FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints(
+ paths.ActiveManifestPath,
+ "Deployment manifest paths must be rooted.",
+ "Deployment manifest paths must not contain reparse points.");
+ FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints(
+ paths.JournalPath,
+ "Deployment journal paths must be rooted.",
+ "Deployment journal paths must not contain reparse points.");
+
+ if (File.Exists(paths.ActiveManifestPath))
+ {
+ File.Delete(paths.ActiveManifestPath);
+ }
+
+ if (File.Exists(paths.JournalPath))
+ {
+ File.Delete(paths.JournalPath);
+ }
+ }
+
+ ///
+ /// Durably appends one record before or after each recoverable file-system mutation.
+ ///
+ public static void AppendJournal(string journalPath, DeploymentJournalRecord record)
+ {
+ string journalDirectory = Path.GetDirectoryName(journalPath)
+ ?? throw new InvalidOperationException(
+ "Deployment journal paths must have a parent directory.");
+ FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints(
+ journalDirectory,
+ "Deployment journal paths must be rooted.",
+ "Deployment journal paths must not contain reparse points.");
+ Directory.CreateDirectory(journalDirectory);
+ FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints(
+ journalPath,
+ "Deployment journal paths must be rooted.",
+ "Deployment journal paths must not contain reparse points.");
+
+ byte[] bytes = Encoding.UTF8.GetBytes(
+ JsonSerializer.Serialize(record, _journalJsonOptions) + Environment.NewLine);
+ using FileStream stream = new(
+ journalPath,
+ FileMode.Append,
+ FileAccess.Write,
+ FileShare.Read,
+ bufferSize: 4096,
+ FileOptions.WriteThrough);
+ stream.Write(bytes, 0, bytes.Length);
+ stream.Flush(flushToDisk: true);
+ }
+
+ ///
+ /// Reads the active manifest or reconstructs it from the deployment journal.
+ ///
+ public DeploymentManifestDocument? ReadManifestOrJournal(
+ LauncherPaths launcherPaths,
+ DeploymentStatePaths paths)
+ {
+ DeploymentManifestDocument? manifest = TryReadManifest(paths.ActiveManifestPath, out Exception? readException);
+ FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints(
+ paths.JournalPath,
+ "Deployment journal paths must be rooted.",
+ "Deployment journal paths must not contain reparse points.");
+ if (File.Exists(paths.JournalPath))
+ {
+ DeploymentManifestDocument? journalManifest = RebuildManifestFromJournal(paths);
+ if (journalManifest is not null)
+ {
+ ValidateGameRoot(launcherPaths, journalManifest);
+ return journalManifest;
+ }
+
+ if (manifest is null && readException is not null)
+ {
+ throw new InvalidDataException(
+ "The deployment manifest could not be read and the journal did not contain recoverable deployment state.",
+ readException);
+ }
+
+ if (manifest is not null)
+ {
+ ValidateGameRoot(launcherPaths, manifest);
+ }
+
+ return manifest;
+ }
+
+ if (manifest is not null)
+ {
+ ValidateGameRoot(launcherPaths, manifest);
+ return manifest;
+ }
+
+ if (readException is not null)
+ {
+ throw new InvalidDataException(
+ "The deployment manifest could not be read and no journal was available for recovery.",
+ readException);
+ }
+
+ return null;
+ }
+
+ ///
+ /// Tries to read a completed deployment manifest without preventing journal fallback.
+ ///
+ private DeploymentManifestDocument? TryReadManifest(string manifestPath, out Exception? readException)
+ {
+ readException = null;
+ FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints(
+ manifestPath,
+ "Deployment manifest paths must be rooted.",
+ "Deployment manifest paths must not contain reparse points.");
+ if (!File.Exists(manifestPath))
+ {
+ return null;
+ }
+
+ try
+ {
+ DeploymentManifestDocument? manifest =
+ JsonSerializer.Deserialize(File.ReadAllText(manifestPath), _jsonOptions);
+ if (manifest is null)
+ {
+ readException = new InvalidDataException("The deployment manifest did not contain manifest data.");
+ _logger.LogWarning(
+ "Deployment manifest did not contain manifest data; journal recovery will be attempted.");
+ }
+
+ return manifest;
+ }
+ catch (Exception ex) when (ex is IOException or JsonException or NotSupportedException)
+ {
+ readException = ex;
+ _logger.LogWarning(ex, "Deployment manifest could not be read; journal recovery will be attempted.");
+ return null;
+ }
+ }
+
+ ///
+ /// Rebuilds the effective deployment state from intent and completion records after an interrupted mutation.
+ ///
+ private DeploymentManifestDocument? RebuildManifestFromJournal(DeploymentStatePaths paths)
+ {
+ var filesByTarget = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var backupsByTarget = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var backupStartsByTarget = new Dictionary