diff --git a/GenHub/GenHub.Core/Constants/FileTypes.cs b/GenHub/GenHub.Core/Constants/FileTypes.cs
index 0e1249755..02f7fc118 100644
--- a/GenHub/GenHub.Core/Constants/FileTypes.cs
+++ b/GenHub/GenHub.Core/Constants/FileTypes.cs
@@ -84,4 +84,9 @@ public static class FileTypes
/// File extension for user data manifest files.
///
public const string UserDataManifestExtension = ".userdata.json";
+
+ ///
+ /// File name of the launch receipt written into a workspace; the latest launch wins.
+ ///
+ public const string LaunchReceiptFileName = "launch-receipt.json";
}
\ No newline at end of file
diff --git a/GenHub/GenHub.Core/Interfaces/Launching/ILaunchReceiptService.cs b/GenHub/GenHub.Core/Interfaces/Launching/ILaunchReceiptService.cs
new file mode 100644
index 000000000..bba9909ea
--- /dev/null
+++ b/GenHub/GenHub.Core/Interfaces/Launching/ILaunchReceiptService.cs
@@ -0,0 +1,48 @@
+using GenHub.Core.Models.Launching;
+using GenHub.Core.Models.Results;
+
+namespace GenHub.Core.Interfaces.Launching;
+
+///
+/// Records a receipt of what each launch consisted of and cheaply revalidates it before
+/// subsequent launches so drift is detected without a full re-scan.
+///
+public interface ILaunchReceiptService
+{
+ ///
+ /// Records a receipt for a launch into the workspace directory, replacing any previous one.
+ ///
+ /// What the launch consisted of.
+ /// A cancellation token to observe while waiting for the task to complete.
+ /// The recorded receipt, or a failure that must not block the launch.
+ Task> RecordLaunchAsync(LaunchReceiptContext context, CancellationToken cancellationToken = default);
+
+ ///
+ /// Cheaply compares the receipt in a workspace, if one exists, against the current
+ /// on-disk state. Only existence, counts, sizes and timestamps are recomputed; nothing
+ /// is hashed.
+ ///
+ /// The workspace directory the receipt would live in.
+ /// A cancellation token to observe while waiting for the task to complete.
+ /// A drift report; an absent receipt yields an empty report, not a failure.
+ Task> RevalidateAsync(string workspacePath, CancellationToken cancellationToken = default);
+
+ ///
+ /// Compares an upcoming launch's configuration against a previously recorded receipt:
+ /// game client, game type, executable path, manifest set and versions, and the archive
+ /// root paths about to be configured — the configuration itself, where
+ /// checks what is on disk. Touches no filesystem state.
+ ///
+ ///
+ /// A separate step because the two halves are known at different times: the receipt must
+ /// be read before workspace preparation rebuilds the workspace, while the upcoming
+ /// configuration — the resolved executable path in particular — exists only afterwards.
+ /// Profile identity is deliberately not compared: the receipt lives in the workspace and
+ /// the workspace is per-profile, so a mismatch cannot occur without the receipt being a
+ /// different file.
+ ///
+ /// The receipt from the previous launch.
+ /// The configuration of the launch about to happen.
+ /// A drift report naming each configuration field that changed.
+ LaunchReceiptDriftReport CompareUpcomingLaunch(LaunchReceipt receipt, LaunchReceiptContext upcoming);
+}
diff --git a/GenHub/GenHub.Core/Models/GameProfile/GameLaunchInfo.cs b/GenHub/GenHub.Core/Models/GameProfile/GameLaunchInfo.cs
index 2800efed2..86d9bd7fd 100644
--- a/GenHub/GenHub.Core/Models/GameProfile/GameLaunchInfo.cs
+++ b/GenHub/GenHub.Core/Models/GameProfile/GameLaunchInfo.cs
@@ -23,6 +23,13 @@ public class GameLaunchInfo
/// Gets or sets the termination timestamp.
public DateTime? TerminatedAt { get; set; }
+ ///
+ /// Gets or sets the launch receipt drift detected before this launch, one warning per
+ /// drifted field, so a UI can show it. Informational only — drift never blocks a
+ /// launch — and empty when no receipt existed or nothing drifted.
+ ///
+ public List ReceiptDriftWarnings { get; set; } = [];
+
/// Gets a value indicating whether the game is still running.
public bool IsRunning => TerminatedAt == null;
}
\ No newline at end of file
diff --git a/GenHub/GenHub.Core/Models/Launching/LaunchReceipt.cs b/GenHub/GenHub.Core/Models/Launching/LaunchReceipt.cs
new file mode 100644
index 000000000..fb70411e4
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceipt.cs
@@ -0,0 +1,87 @@
+using GenHub.Core.Models.Enums;
+
+namespace GenHub.Core.Models.Launching;
+
+///
+/// Record of what a launch consisted of, written into the workspace so subsequent launches
+/// can cheaply detect drift and misbehaving launches have something to compare against.
+///
+public class LaunchReceipt
+{
+ /// Gets or sets the receipt schema version.
+ public int SchemaVersion { get; set; } = 1;
+
+ /// Gets or sets when the receipt was recorded, in UTC.
+ public DateTime RecordedAtUtc { get; set; }
+
+ /// Gets or sets the launch identifier the receipt belongs to.
+ public string LaunchId { get; set; } = string.Empty;
+
+ /// Gets or sets the profile that was launched.
+ public string ProfileId { get; set; } = string.Empty;
+
+ /// Gets or sets the game client identifier, when the profile declared one.
+ public string? GameClientId { get; set; }
+
+ /// Gets or sets the game that was launched.
+ public GameType GameType { get; set; }
+
+ /// Gets or sets the workspace the launch ran from.
+ public string WorkspaceId { get; set; } = string.Empty;
+
+ /// Gets or sets the working directory the process was started in.
+ public string WorkingDirectory { get; set; } = string.Empty;
+
+ /// Gets or sets the fingerprint of the launched executable.
+ public LaunchReceiptExecutable Executable { get; set; } = new();
+
+ ///
+ /// Gets or sets the retail archive roots the engine was pointed at, keyed by the
+ /// environment variable that carried each root.
+ ///
+ public Dictionary ArchiveRoots { get; set; } = [];
+
+ ///
+ /// Gets or sets a hash per environment variable GenHub itself set for the child process:
+ /// the built launch environment — retail archive roots plus any profile-defined variables.
+ /// The inherited process environment is deliberately not recorded; it is large, differs
+ /// between hosts without meaning anything for the launch, and can carry secrets that a
+ /// receipt on disk must never capture.
+ ///
+ ///
+ /// Values are hashed rather than stored, because a profile-defined variable can itself
+ /// carry a secret and detecting drift only needs to know that a value changed, not what
+ /// it changed to. Archive root paths are exempt and recorded in full under
+ /// : they are locations, not credentials, and naming them is
+ /// what makes a misconfigured root actionable.
+ ///
+ public Dictionary EnvironmentVariableHashes { get; set; } = [];
+
+ ///
+ /// Gets or sets the random salt the environment value hashes were computed with, so the
+ /// same value hashes differently in every receipt.
+ ///
+ ///
+ /// Comparison always runs against the receipt that carries the salt, so drift detection is
+ /// unaffected. This does not defeat an attacker who holds the receipt and guesses likely
+ /// values — they hold the salt too — but it does stop precomputed tables, and it stops
+ /// receipts being compared across hosts or profiles to confirm that two installations share
+ /// a value without ever recovering it.
+ ///
+ public string EnvironmentHashSalt { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the resolved variant and entry-point identity that determined what was
+ /// launched. Null when the profile carried no game client manifest: the legacy fallback
+ /// resolves the executable by filename search and no variant machinery participates, so
+ /// there is no variant identity to record. Populated whenever a game client manifest is
+ /// part of the launch, which is what workspace preparation resolves the entry point from.
+ ///
+ public LaunchReceiptVariant? Variant { get; set; }
+
+ /// Gets or sets the manifest identifiers resolved for the launch.
+ public List ManifestIds { get; set; } = [];
+
+ /// Gets or sets the manifest versions resolved for the launch, keyed by manifest identifier.
+ public Dictionary ManifestVersions { get; set; } = [];
+}
diff --git a/GenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveEntry.cs b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveEntry.cs
new file mode 100644
index 000000000..39909ad46
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveEntry.cs
@@ -0,0 +1,18 @@
+namespace GenHub.Core.Models.Launching;
+
+///
+/// Cheap fingerprint of one archive within a retail root: name, size and timestamp, never
+/// content. An equal-size replacement is visible through the timestamp where a count and
+/// byte total alone could not see it.
+///
+public class LaunchReceiptArchiveEntry
+{
+ /// Gets or sets the archive file name, without its directory.
+ public string FileName { get; set; } = string.Empty;
+
+ /// Gets or sets the archive size in bytes.
+ public long SizeBytes { get; set; }
+
+ /// Gets or sets the archive's last write time, in UTC.
+ public DateTime LastWriteUtc { get; set; }
+}
diff --git a/GenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveRoot.cs b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveRoot.cs
new file mode 100644
index 000000000..b8fcb64cd
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveRoot.cs
@@ -0,0 +1,21 @@
+namespace GenHub.Core.Models.Launching;
+
+///
+/// Cheap fingerprint of one retail archive root: a per-archive list of name, size and
+/// timestamp from a single directory listing, never content hashes, so revalidation never
+/// rereads gigabytes of archives.
+///
+public class LaunchReceiptArchiveRoot
+{
+ /// Gets or sets the archive root path.
+ public string Path { get; set; } = string.Empty;
+
+ /// Gets or sets the number of archives in the root; a summary of .
+ public int ArchiveCount { get; set; }
+
+ /// Gets or sets the total size of the archives in the root, in bytes; a summary of .
+ public long TotalArchiveBytes { get; set; }
+
+ /// Gets or sets the fingerprint of each archive in the root.
+ public List Archives { get; set; } = [];
+}
diff --git a/GenHub/GenHub.Core/Models/Launching/LaunchReceiptContext.cs b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptContext.cs
new file mode 100644
index 000000000..a1ff9fe57
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptContext.cs
@@ -0,0 +1,51 @@
+using GenHub.Core.Models.Enums;
+
+namespace GenHub.Core.Models.Launching;
+
+///
+/// Everything a launch supplies for a receipt to be recorded from.
+///
+public class LaunchReceiptContext
+{
+ /// Gets or sets the launch identifier.
+ public string LaunchId { get; set; } = string.Empty;
+
+ /// Gets or sets the profile being launched.
+ public string ProfileId { get; set; } = string.Empty;
+
+ /// Gets or sets the game client identifier, when the profile declares one.
+ public string? GameClientId { get; set; }
+
+ /// Gets or sets the game being launched.
+ public GameType GameType { get; set; }
+
+ /// Gets or sets the workspace the launch runs from.
+ public string WorkspaceId { get; set; } = string.Empty;
+
+ /// Gets or sets the workspace directory the receipt is written into.
+ public string WorkspacePath { get; set; } = string.Empty;
+
+ /// Gets or sets the executable being started.
+ public string ExecutablePath { get; set; } = string.Empty;
+
+ /// Gets or sets the working directory the process is started in.
+ public string WorkingDirectory { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the environment GenHub built for the child process — retail archive
+ /// roots plus profile-defined variables, never the inherited process environment.
+ ///
+ public IReadOnlyDictionary EnvironmentVariables { get; set; } = new Dictionary();
+
+ ///
+ /// Gets or sets the resolved variant and entry-point identity, when a game client
+ /// manifest is part of the launch.
+ ///
+ public LaunchReceiptVariant? Variant { get; set; }
+
+ /// Gets or sets the manifest identifiers resolved for the launch.
+ public IReadOnlyList ManifestIds { get; set; } = [];
+
+ /// Gets or sets the manifest versions resolved for the launch, keyed by manifest identifier.
+ public IReadOnlyDictionary ManifestVersions { get; set; } = new Dictionary();
+}
diff --git a/GenHub/GenHub.Core/Models/Launching/LaunchReceiptDriftReport.cs b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptDriftReport.cs
new file mode 100644
index 000000000..1d53def11
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptDriftReport.cs
@@ -0,0 +1,29 @@
+namespace GenHub.Core.Models.Launching;
+
+///
+/// Outcome of cheaply revalidating a launch receipt against the current on-disk state.
+///
+public class LaunchReceiptDriftReport
+{
+ /// Gets or sets the path the receipt was looked for at.
+ public string ReceiptPath { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets a value indicating whether a receipt was present. An absent receipt is
+ /// not an error; there is simply nothing to compare against.
+ ///
+ public bool HasReceipt { get; set; }
+
+ ///
+ /// Gets or sets the parsed receipt when one was present and readable, so the upcoming
+ /// launch's configuration can be compared against it after the workspace — and the
+ /// receipt file with it — has been rebuilt.
+ ///
+ public LaunchReceipt? Receipt { get; set; }
+
+ /// Gets or sets the description of each field that drifted since the receipt was recorded.
+ public List DriftedFields { get; set; } = [];
+
+ /// Gets a value indicating whether any drift was detected.
+ public bool HasDrift => DriftedFields.Count > 0;
+}
diff --git a/GenHub/GenHub.Core/Models/Launching/LaunchReceiptExecutable.cs b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptExecutable.cs
new file mode 100644
index 000000000..cedff3b40
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptExecutable.cs
@@ -0,0 +1,19 @@
+namespace GenHub.Core.Models.Launching;
+
+///
+/// Fingerprint of the executable a launch started.
+///
+public class LaunchReceiptExecutable
+{
+ /// Gets or sets the executable path.
+ public string Path { get; set; } = string.Empty;
+
+ /// Gets or sets the executable size in bytes.
+ public long SizeBytes { get; set; }
+
+ /// Gets or sets the executable's last write time, in UTC.
+ public DateTime LastWriteUtc { get; set; }
+
+ /// Gets or sets the SHA-256 hash of the executable as a lowercase hex string.
+ public string Sha256 { get; set; } = string.Empty;
+}
diff --git a/GenHub/GenHub.Core/Models/Launching/LaunchReceiptVariant.cs b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptVariant.cs
new file mode 100644
index 000000000..43f1e7076
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/Launching/LaunchReceiptVariant.cs
@@ -0,0 +1,30 @@
+namespace GenHub.Core.Models.Launching;
+
+///
+/// The resolved variant and entry-point identity that determined what a launch started:
+/// the same ManifestVariantResolver resolution workspace preparation applies to the
+/// game client manifest, re-run against the same manifest and host runtime at receipt time.
+///
+public class LaunchReceiptVariant
+{
+ /// Gets or sets the game client manifest the resolution ran against.
+ public string GameClientManifestId { get; set; } = string.Empty;
+
+ /// Gets or sets the host runtime identifier the resolution ran on, for example osx-arm64.
+ public string RuntimeIdentifier { get; set; } = string.Empty;
+
+ /// Gets or sets a value indicating whether the manifest declares variants at all.
+ public bool HasVariants { get; set; }
+
+ ///
+ /// Gets or sets the runtime identifiers of the variant that matched; empty when the
+ /// matched variant is platform-neutral or the manifest declares no variants.
+ ///
+ public List VariantRuntimeIdentifiers { get; set; } = [];
+
+ /// Gets or sets the resolved entry point, relative to the workspace, when resolution succeeded.
+ public string? EntryPointRelativePath { get; set; }
+
+ /// Gets or sets the resolver's stated reason for the outcome.
+ public string? Resolution { get; set; }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs
index fea3b7161..1ce3945b2 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs
@@ -14,6 +14,7 @@
using GenHub.Core.Models.GameClients;
using GenHub.Core.Models.GameInstallations;
using GenHub.Core.Models.GameProfile;
+using GenHub.Core.Models.Launching;
using GenHub.Core.Models.Results;
using GenHub.Features.Content.Services.Publishers;
using GenHub.Features.GameProfiles.Services;
@@ -307,6 +308,80 @@ public void GenerateUniqueProfileName_CreatesUniqueName()
Assert.Equal($"Test Profile {string.Format(ProfileConstants.CopyNameNumberedFormat, 3)}", uniqueName);
}
+ ///
+ /// Verifies that a successful launch carrying receipt drift shows an informational
+ /// notice naming the drift, while the success presentation stays unchanged.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task LaunchProfileCommand_WithReceiptDrift_ShowsInformationalNotice()
+ {
+ var notificationService = new Mock();
+ var launcherFacade = new Mock();
+ var launchInfo = new GameLaunchInfo
+ {
+ LaunchId = "launch-1",
+ ProfileId = "profile-1",
+ WorkspaceId = "profile-1",
+ ProcessInfo = new GameProcessInfo { ProcessId = 123 },
+ ReceiptDriftWarnings = ["Executable size changed from 1 to 2 bytes: generalszh"],
+ };
+ launcherFacade.Setup(x => x.LaunchProfileAsync("profile-1", It.IsAny(), It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(launchInfo));
+
+ var vm = CreateLauncherViewModel(launcherFacade, notificationService);
+ var profileItem = CreateProfileItem("profile-1", "Test Profile");
+
+ await vm.LaunchProfileCommand.ExecuteAsync(profileItem);
+
+ Assert.Contains("launched successfully", vm.StatusMessage);
+ notificationService.Verify(
+ x => x.ShowSuccess("Game Launched", It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Once);
+ notificationService.Verify(
+ x => x.ShowInfo(
+ "Launch Configuration Changed",
+ It.Is(m =>
+ m.Contains("Launch configuration changed since the last run") &&
+ m.Contains("Executable size changed from 1 to 2 bytes")),
+ It.IsAny(),
+ It.IsAny()),
+ Times.Once);
+ notificationService.Verify(
+ x => x.ShowError(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ ///
+ /// Verifies that a successful launch without receipt drift shows no notice.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task LaunchProfileCommand_WithoutReceiptDrift_ShowsNoNotice()
+ {
+ var notificationService = new Mock();
+ var launcherFacade = new Mock();
+ var launchInfo = new GameLaunchInfo
+ {
+ LaunchId = "launch-1",
+ ProfileId = "profile-1",
+ WorkspaceId = "profile-1",
+ ProcessInfo = new GameProcessInfo { ProcessId = 123 },
+ };
+ launcherFacade.Setup(x => x.LaunchProfileAsync("profile-1", It.IsAny(), It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(launchInfo));
+
+ var vm = CreateLauncherViewModel(launcherFacade, notificationService);
+ var profileItem = CreateProfileItem("profile-1", "Test Profile");
+
+ await vm.LaunchProfileCommand.ExecuteAsync(profileItem);
+
+ Assert.Contains("launched successfully", vm.StatusMessage);
+ notificationService.Verify(
+ x => x.ShowInfo(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
private static ProfileResourceService CreateProfileResourceService()
{
return new ProfileResourceService(NullLogger.Instance);
@@ -336,6 +411,49 @@ private static SuperHackersProvider CreateSuperHackersProvider()
NullLogger.Instance);
}
+ ///
+ /// Creates a profile item bound to a mocked profile.
+ ///
+ /// The profile identifier.
+ /// The profile name.
+ /// The profile item.
+ private static GameProfileItemViewModel CreateProfileItem(string profileId, string name)
+ {
+ var profile = new Mock();
+ profile.Setup(x => x.Name).Returns(name);
+ return new GameProfileItemViewModel(profileId, profile.Object, "icon.png", "cover.jpg");
+ }
+
+ ///
+ /// Creates a GameProfileLauncherViewModel wired to the given launcher facade and
+ /// notification service, with everything else mocked.
+ ///
+ /// The launcher facade mock.
+ /// The notification service mock.
+ /// The view model.
+ private static GameProfileLauncherViewModel CreateLauncherViewModel(
+ Mock launcherFacade,
+ Mock notificationService)
+ {
+ return new GameProfileLauncherViewModel(
+ new Mock().Object,
+ new Mock().Object,
+ launcherFacade.Object,
+ null!,
+ new Mock().Object,
+ new Mock().Object,
+ new Mock().Object,
+ new Mock().Object,
+ new Mock().Object,
+ new Mock().Object,
+ CreateProfileResourceService(),
+ new Mock().Object,
+ notificationService.Object,
+ new Mock().Object,
+ new Mock().Object,
+ NullLogger.Instance);
+ }
+
///
/// Creates a GameProfileLauncherViewModel with mocked dependencies for testing.
///
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs
index 653652a44..3ae4c60cc 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs
@@ -45,6 +45,7 @@ public class GameLauncherTests : IDisposable
private readonly Mock _storageLocationServiceMock = new();
private readonly Mock _profileContentLinkerMock = new();
private readonly Mock _steamLauncherMock = new();
+ private readonly Mock _launchReceiptServiceMock = new();
private readonly GameLauncher _gameLauncher;
private readonly string _retailRoot;
@@ -117,6 +118,14 @@ public GameLauncherTests()
_profileContentLinkerMock.Setup(x => x.GetActiveProfileId())
.Returns((string?)null);
+ // Setup launch receipt service mock
+ _launchReceiptServiceMock.Setup(x => x.RevalidateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(new LaunchReceiptDriftReport()));
+ _launchReceiptServiceMock.Setup(x => x.RecordLaunchAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(new LaunchReceipt()));
+ _launchReceiptServiceMock.Setup(x => x.CompareUpcomingLaunch(It.IsAny(), It.IsAny()))
+ .Returns(new LaunchReceiptDriftReport { HasReceipt = true });
+
// Setup dependency resolver mock - returns resolved manifests including dependencies
_dependencyResolverMock.Setup(x => x.ResolveDependenciesWithManifestsAsync(
It.IsAny>(),
@@ -141,7 +150,8 @@ public GameLauncherTests()
_gameSettingsServiceMock.Object,
_profileContentLinkerMock.Object,
_steamLauncherMock.Object,
- _configurationProviderServiceMock.Object);
+ _configurationProviderServiceMock.Object,
+ _launchReceiptServiceMock.Object);
}
///
@@ -910,6 +920,263 @@ public async Task LaunchProfileAsync_WithoutProfileSettings_ShouldStillSaveOptio
Times.Once);
}
+ ///
+ /// Verifies a successful launch revalidates the previous receipt and records a new one.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task LaunchProfileAsync_WithValidProfile_RecordsLaunchReceipt()
+ {
+ // Arrange
+ var profile = CreateTestProfile();
+ var workspacePath = Path.Combine(_retailRoot, "workspace");
+ var workspaceInfo = new WorkspaceInfo
+ {
+ Id = profile.Id,
+ WorkspacePath = workspacePath,
+ ExecutablePath = Path.Combine(workspacePath, "generalszh"),
+ };
+ var processInfo = new GameProcessInfo { ProcessId = 123, ProcessName = "generals.exe" };
+ var manifest = new ContentManifest
+ {
+ Id = "1.0.genhub.mod.test",
+ Name = "Test Content",
+ Version = "1.0",
+ ContentType = GenHub.Core.Models.Enums.ContentType.GameClient,
+ EntryPoint = "generalszh",
+ Files = [new ManifestFile { RelativePath = "generalszh", IsExecutable = true }],
+ };
+
+ _profileManagerMock.Setup(x => x.GetProfileAsync(profile.Id, It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile));
+ _manifestPoolMock.Setup(x => x.GetContentDirectoryAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(_retailRoot));
+ _dependencyResolverMock.Setup(x => x.ResolveDependenciesWithManifestsAsync(
+ It.Is>(ids => ids.SequenceEqual(TestContentIds)),
+ It.IsAny()))
+ .ReturnsAsync(DependencyResolutionResult.CreateSuccess(TestContentIds, [manifest], []));
+ _workspaceManagerMock.Setup(x => x.PrepareWorkspaceAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(workspaceInfo));
+ _processManagerMock.Setup(x => x.StartProcessAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(processInfo));
+
+ // Act
+ var result = await _gameLauncher.LaunchProfileAsync(profile.Id);
+
+ // Assert
+ Assert.True(result.Success);
+ _launchReceiptServiceMock.Verify(
+ x => x.RevalidateAsync(It.IsAny(), It.IsAny()),
+ Times.Once);
+ _launchReceiptServiceMock.Verify(
+ x => x.RecordLaunchAsync(
+ It.Is(c =>
+ c.ProfileId == profile.Id &&
+ c.ExecutablePath == workspaceInfo.ExecutablePath &&
+ c.WorkspacePath == workspaceInfo.WorkspacePath &&
+ c.GameType == GameType.Generals &&
+ c.ManifestIds.Contains("1.0.genhub.mod.test") &&
+ c.Variant != null &&
+ c.Variant.GameClientManifestId == "1.0.genhub.mod.test" &&
+ c.Variant.EntryPointRelativePath == "generalszh"),
+ It.IsAny()),
+ Times.Once);
+ }
+
+ ///
+ /// Verifies receipt drift is surfaced without blocking the launch.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task LaunchProfileAsync_WithReceiptDrift_DoesNotBlockLaunch()
+ {
+ // Arrange
+ var profile = CreateTestProfile();
+ var workspacePath = Path.Combine(_retailRoot, "workspace");
+ var workspaceInfo = new WorkspaceInfo
+ {
+ Id = profile.Id,
+ WorkspacePath = workspacePath,
+ ExecutablePath = Path.Combine(workspacePath, "generalszh"),
+ };
+ var processInfo = new GameProcessInfo { ProcessId = 123, ProcessName = "generals.exe" };
+ var manifest = new ContentManifest { Id = "1.0.genhub.mod.test", Name = "Test Content" };
+ var driftReport = new LaunchReceiptDriftReport
+ {
+ HasReceipt = true,
+ DriftedFields = ["Executable size changed from 1 to 2 bytes: generals.exe"],
+ };
+
+ _profileManagerMock.Setup(x => x.GetProfileAsync(profile.Id, It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile));
+ _dependencyResolverMock.Setup(x => x.ResolveDependenciesWithManifestsAsync(
+ It.Is>(ids => ids.SequenceEqual(TestContentIds)),
+ It.IsAny()))
+ .ReturnsAsync(DependencyResolutionResult.CreateSuccess(TestContentIds, [manifest], []));
+ _workspaceManagerMock.Setup(x => x.PrepareWorkspaceAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(workspaceInfo));
+ _processManagerMock.Setup(x => x.StartProcessAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(processInfo));
+ _launchReceiptServiceMock.Setup(x => x.RevalidateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(driftReport));
+
+ // Act
+ var result = await _gameLauncher.LaunchProfileAsync(profile.Id);
+
+ // Assert
+ Assert.True(result.Success);
+ Assert.NotNull(result.Data);
+ Assert.Contains(driftReport.DriftedFields[0], result.Data.ReceiptDriftWarnings);
+ }
+
+ ///
+ /// Verifies a previous receipt is compared against the upcoming launch's configuration
+ /// once the launch configuration is built, without blocking the launch.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task LaunchProfileAsync_WithPreviousReceipt_ComparesUpcomingConfiguration()
+ {
+ // Arrange
+ var profile = CreateTestProfile();
+ var workspacePath = Path.Combine(_retailRoot, "workspace");
+ var workspaceInfo = new WorkspaceInfo
+ {
+ Id = profile.Id,
+ WorkspacePath = workspacePath,
+ ExecutablePath = Path.Combine(workspacePath, "generalszh"),
+ };
+ var processInfo = new GameProcessInfo { ProcessId = 123, ProcessName = "generals.exe" };
+ var manifest = new ContentManifest { Id = "1.0.genhub.mod.test", Name = "Test Content" };
+ var previousReceipt = new LaunchReceipt { ProfileId = profile.Id, GameClientId = "old-client" };
+
+ _profileManagerMock.Setup(x => x.GetProfileAsync(profile.Id, It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile));
+ _dependencyResolverMock.Setup(x => x.ResolveDependenciesWithManifestsAsync(
+ It.Is>(ids => ids.SequenceEqual(TestContentIds)),
+ It.IsAny()))
+ .ReturnsAsync(DependencyResolutionResult.CreateSuccess(TestContentIds, [manifest], []));
+ _workspaceManagerMock.Setup(x => x.PrepareWorkspaceAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(workspaceInfo));
+ _processManagerMock.Setup(x => x.StartProcessAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(processInfo));
+ _launchReceiptServiceMock.Setup(x => x.RevalidateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(
+ new LaunchReceiptDriftReport { HasReceipt = true, Receipt = previousReceipt }));
+ _launchReceiptServiceMock.Setup(x => x.CompareUpcomingLaunch(previousReceipt, It.IsAny()))
+ .Returns(new LaunchReceiptDriftReport
+ {
+ HasReceipt = true,
+ DriftedFields = ["Game client changed from old-client to version-1"],
+ });
+
+ // Act
+ var result = await _gameLauncher.LaunchProfileAsync(profile.Id);
+
+ // Assert
+ Assert.True(result.Success);
+ _launchReceiptServiceMock.Verify(
+ x => x.CompareUpcomingLaunch(
+ previousReceipt,
+ It.Is(c =>
+ c.GameClientId == "version-1" &&
+ c.ExecutablePath == workspaceInfo.ExecutablePath)),
+ Times.Once);
+ Assert.NotNull(result.Data);
+ Assert.Contains("Game client changed from old-client to version-1", result.Data.ReceiptDriftWarnings);
+ }
+
+ ///
+ /// Verifies a launch that has already started is not failed by a receipt-recording error.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task LaunchProfileAsync_WhenReceiptRecordingFails_StillSucceeds()
+ {
+ // Arrange
+ var profile = CreateTestProfile();
+ var workspacePath = Path.Combine(_retailRoot, "workspace");
+ var workspaceInfo = new WorkspaceInfo
+ {
+ Id = profile.Id,
+ WorkspacePath = workspacePath,
+ ExecutablePath = Path.Combine(workspacePath, "generalszh"),
+ };
+ var processInfo = new GameProcessInfo { ProcessId = 123, ProcessName = "generals.exe" };
+ var manifest = new ContentManifest { Id = "1.0.genhub.mod.test", Name = "Test Content" };
+
+ _profileManagerMock.Setup(x => x.GetProfileAsync(profile.Id, It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile));
+ _dependencyResolverMock.Setup(x => x.ResolveDependenciesWithManifestsAsync(
+ It.Is>(ids => ids.SequenceEqual(TestContentIds)),
+ It.IsAny()))
+ .ReturnsAsync(DependencyResolutionResult.CreateSuccess(TestContentIds, [manifest], []));
+ _workspaceManagerMock.Setup(x => x.PrepareWorkspaceAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(workspaceInfo));
+ _processManagerMock.Setup(x => x.StartProcessAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(processInfo));
+ _launchReceiptServiceMock.Setup(x => x.RecordLaunchAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateFailure("disk full"));
+
+ // Act
+ var result = await _gameLauncher.LaunchProfileAsync(profile.Id);
+
+ // Assert
+ Assert.True(result.Success);
+ }
+
+ ///
+ /// Verifies a launch that has already started is not failed by a receipt-recording
+ /// exception, including cancellation: the child process is running by then, so reporting
+ /// failure would leave the caller believing a running game never started.
+ ///
+ /// The exception the recorder throws.
+ /// The async task.
+ [Theory]
+ [MemberData(nameof(ReceiptRecordingExceptions))]
+ public async Task LaunchProfileAsync_WhenReceiptRecordingThrows_StillSucceeds(Exception thrown)
+ {
+ // Arrange
+ var profile = CreateTestProfile();
+ var workspacePath = Path.Combine(_retailRoot, "workspace");
+ var workspaceInfo = new WorkspaceInfo
+ {
+ Id = profile.Id,
+ WorkspacePath = workspacePath,
+ ExecutablePath = Path.Combine(workspacePath, "generalszh"),
+ };
+ var processInfo = new GameProcessInfo { ProcessId = 123, ProcessName = "generals.exe" };
+ var manifest = new ContentManifest { Id = "1.0.genhub.mod.test", Name = "Test Content" };
+
+ _profileManagerMock.Setup(x => x.GetProfileAsync(profile.Id, It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile));
+ _dependencyResolverMock.Setup(x => x.ResolveDependenciesWithManifestsAsync(
+ It.Is>(ids => ids.SequenceEqual(TestContentIds)),
+ It.IsAny()))
+ .ReturnsAsync(DependencyResolutionResult.CreateSuccess(TestContentIds, [manifest], []));
+ _workspaceManagerMock.Setup(x => x.PrepareWorkspaceAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(workspaceInfo));
+ _processManagerMock.Setup(x => x.StartProcessAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(processInfo));
+ _launchReceiptServiceMock.Setup(x => x.RecordLaunchAsync(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(thrown);
+
+ // Act
+ var result = await _gameLauncher.LaunchProfileAsync(profile.Id);
+
+ // Assert
+ Assert.True(result.Success);
+ }
+
+ ///
+ /// Gets the exceptions a post-spawn receipt write can realistically throw.
+ ///
+ public static TheoryData ReceiptRecordingExceptions => new()
+ {
+ new IOException("disk full"),
+ new OperationCanceledException(),
+ };
+
///
/// Removes the temporary retail root.
///
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs
new file mode 100644
index 000000000..e8b773aa2
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/LaunchReceiptServiceTests.cs
@@ -0,0 +1,671 @@
+using System.Text.Json;
+using GenHub.Common.Services;
+using GenHub.Core.Constants;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.Launching;
+using GenHub.Features.Launching;
+using Microsoft.Extensions.Logging;
+using Moq;
+
+namespace GenHub.Tests.Core.Features.Launching;
+
+///
+/// Tests for .
+///
+public class LaunchReceiptServiceTests : IDisposable
+{
+ private readonly LaunchReceiptService _service = new(
+ new Mock>().Object,
+ new Sha256HashProvider());
+
+ private readonly string _root;
+ private readonly string _workspacePath;
+ private readonly string _archiveRoot;
+ private readonly string _executablePath;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public LaunchReceiptServiceTests()
+ {
+ _root = Directory.CreateTempSubdirectory("GenHub.LaunchReceiptServiceTests.").FullName;
+ _workspacePath = Directory.CreateDirectory(Path.Combine(_root, "workspace")).FullName;
+ _archiveRoot = Directory.CreateDirectory(Path.Combine(_root, "retail")).FullName;
+ _executablePath = Path.Combine(_workspacePath, "generalszh");
+
+ File.WriteAllText(_executablePath, "executable bytes");
+ File.WriteAllText(Path.Combine(_archiveRoot, "INIZH.big"), "archive one");
+ File.WriteAllText(Path.Combine(_archiveRoot, "TexturesZH.big"), "archive two!");
+ }
+
+ ///
+ /// Recording writes a receipt into the workspace capturing the executable and archive roots.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task RecordLaunchAsync_WritesReceiptIntoWorkspace()
+ {
+ var result = await _service.RecordLaunchAsync(CreateContext());
+
+ Assert.True(result.Success);
+ var receiptPath = Path.Combine(_workspacePath, FileTypes.LaunchReceiptFileName);
+ Assert.True(File.Exists(receiptPath));
+
+ var receipt = JsonSerializer.Deserialize(
+ await File.ReadAllTextAsync(receiptPath),
+ new JsonSerializerOptions { Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } });
+ Assert.NotNull(receipt);
+ Assert.Equal("profile-1", receipt.ProfileId);
+ Assert.Equal(GameType.ZeroHour, receipt.GameType);
+ Assert.Equal(_executablePath, receipt.Executable.Path);
+ Assert.Equal(new FileInfo(_executablePath).Length, receipt.Executable.SizeBytes);
+ Assert.NotEmpty(receipt.Executable.Sha256);
+
+ var recordedRoot = Assert.Contains(RetailArchiveConstants.ZeroHourInstallPathVariable, receipt.ArchiveRoots);
+ Assert.Equal(2, recordedRoot.ArchiveCount);
+ Assert.Equal(
+ new FileInfo(Path.Combine(_archiveRoot, "INIZH.big")).Length +
+ new FileInfo(Path.Combine(_archiveRoot, "TexturesZH.big")).Length,
+ recordedRoot.TotalArchiveBytes);
+ Assert.Contains("1.0.genhub.mod.test", receipt.ManifestIds);
+ }
+
+ ///
+ /// A subsequent record replaces the previous receipt; the latest launch wins.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task RecordLaunchAsync_ReplacesPreviousReceipt()
+ {
+ await _service.RecordLaunchAsync(CreateContext(launchId: "first"));
+ await _service.RecordLaunchAsync(CreateContext(launchId: "second"));
+
+ var receipt = await ReadReceiptAsync();
+ Assert.Equal("second", receipt.LaunchId);
+ }
+
+ ///
+ /// Revalidating a workspace without a receipt is not an error and reports nothing.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task RevalidateAsync_WithoutReceipt_ReportsNothing()
+ {
+ var result = await _service.RevalidateAsync(_workspacePath);
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.Data);
+ Assert.False(result.Data.HasReceipt);
+ Assert.False(result.Data.HasDrift);
+ }
+
+ ///
+ /// Revalidating an unchanged state reports the receipt with no drift.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task RevalidateAsync_WithUnchangedState_ReportsNoDrift()
+ {
+ await _service.RecordLaunchAsync(CreateContext());
+
+ var result = await _service.RevalidateAsync(_workspacePath);
+
+ Assert.True(result.Success);
+ Assert.True(result.Data!.HasReceipt);
+ Assert.False(result.Data.HasDrift);
+ }
+
+ ///
+ /// An archive added to a root since the last launch is reported by name.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task RevalidateAsync_WithAddedArchive_ReportsDrift()
+ {
+ await _service.RecordLaunchAsync(CreateContext());
+ File.WriteAllText(Path.Combine(_archiveRoot, "ModZH.big"), "a third archive");
+
+ var result = await _service.RevalidateAsync(_workspacePath);
+
+ Assert.True(result.Success);
+ Assert.True(result.Data!.HasDrift);
+ Assert.Contains(result.Data.DriftedFields, f =>
+ f.Contains("Archive added") && f.Contains("ModZH.big") &&
+ f.Contains(RetailArchiveConstants.ZeroHourInstallPathVariable));
+ }
+
+ ///
+ /// A removed archive is reported by name.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task RevalidateAsync_WithRemovedArchive_ReportsDrift()
+ {
+ await _service.RecordLaunchAsync(CreateContext());
+ File.Delete(Path.Combine(_archiveRoot, "TexturesZH.big"));
+
+ var result = await _service.RevalidateAsync(_workspacePath);
+
+ Assert.True(result.Success);
+ Assert.True(result.Data!.HasDrift);
+ Assert.Contains(result.Data.DriftedFields, f =>
+ f.Contains("Archive removed") && f.Contains("TexturesZH.big"));
+ }
+
+ ///
+ /// A mutated archive with a different size is reported by name and both sizes.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task RevalidateAsync_WithMutatedArchiveBytes_ReportsDrift()
+ {
+ await _service.RecordLaunchAsync(CreateContext());
+ File.WriteAllText(Path.Combine(_archiveRoot, "INIZH.big"), "a much longer replacement archive body");
+
+ var result = await _service.RevalidateAsync(_workspacePath);
+
+ Assert.True(result.Success);
+ Assert.True(result.Data!.HasDrift);
+ Assert.Contains(result.Data.DriftedFields, f =>
+ f.Contains("INIZH.big") && f.Contains("changed size") &&
+ f.Contains(RetailArchiveConstants.ZeroHourInstallPathVariable));
+ }
+
+ ///
+ /// An equal-size archive replacement — invisible to a count and byte total — is
+ /// reported through its changed timestamp.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task RevalidateAsync_WithEqualSizeArchiveReplacement_ReportsDrift()
+ {
+ await _service.RecordLaunchAsync(CreateContext());
+ var archivePath = Path.Combine(_archiveRoot, "INIZH.big");
+ var originalLength = new FileInfo(archivePath).Length;
+ File.WriteAllText(archivePath, "swapped bod");
+ Assert.Equal(originalLength, new FileInfo(archivePath).Length);
+ File.SetLastWriteTimeUtc(archivePath, new FileInfo(archivePath).LastWriteTimeUtc.AddMinutes(1));
+
+ var result = await _service.RevalidateAsync(_workspacePath);
+
+ Assert.True(result.Success);
+ Assert.True(result.Data!.HasDrift);
+ Assert.Contains(result.Data.DriftedFields, f =>
+ f.Contains("INIZH.big") && f.Contains("changed last-write time"));
+ }
+
+ ///
+ /// A root that disappeared since the last launch is reported as drift.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task RevalidateAsync_WithMissingArchiveRoot_ReportsDrift()
+ {
+ await _service.RecordLaunchAsync(CreateContext());
+ Directory.Delete(_archiveRoot, recursive: true);
+
+ var result = await _service.RevalidateAsync(_workspacePath);
+
+ Assert.True(result.Success);
+ Assert.True(result.Data!.HasDrift);
+ Assert.Contains(result.Data.DriftedFields, f => f.Contains("no longer exists") && f.Contains(_archiveRoot));
+ }
+
+ ///
+ /// A swapped executable is reported by its changed size without rehashing.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task RevalidateAsync_WithSwappedExecutable_ReportsDrift()
+ {
+ await _service.RecordLaunchAsync(CreateContext());
+ File.WriteAllText(_executablePath, "a different executable with a different length");
+
+ var result = await _service.RevalidateAsync(_workspacePath);
+
+ Assert.True(result.Success);
+ Assert.True(result.Data!.HasDrift);
+ Assert.Contains(result.Data.DriftedFields, f => f.Contains("Executable size changed"));
+ }
+
+ ///
+ /// A deleted executable is reported as drift rather than an error.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task RevalidateAsync_WithMissingExecutable_ReportsDrift()
+ {
+ await _service.RecordLaunchAsync(CreateContext());
+ File.Delete(_executablePath);
+
+ var result = await _service.RevalidateAsync(_workspacePath);
+
+ Assert.True(result.Success);
+ Assert.True(result.Data!.HasDrift);
+ Assert.Contains(result.Data.DriftedFields, f => f.Contains("Executable no longer exists"));
+ }
+
+ ///
+ /// An unreadable receipt is reported as drift, not thrown.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task RevalidateAsync_WithCorruptReceipt_ReportsDrift()
+ {
+ await File.WriteAllTextAsync(Path.Combine(_workspacePath, FileTypes.LaunchReceiptFileName), "{ not json");
+
+ var result = await _service.RevalidateAsync(_workspacePath);
+
+ Assert.True(result.Success);
+ Assert.True(result.Data!.HasReceipt);
+ Assert.True(result.Data.HasDrift);
+ Assert.Contains(result.Data.DriftedFields, f => f.Contains("could not be read"));
+ }
+
+ ///
+ /// A receipt that parses but carries null fields is reported as drift, not thrown.
+ /// Revalidation is awaited on the launch path, so an escaping exception would fail the
+ /// launch — which drift is never allowed to do.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task RevalidateAsync_WithNullReceiptFields_ReportsDriftWithoutThrowing()
+ {
+ await File.WriteAllTextAsync(
+ Path.Combine(_workspacePath, FileTypes.LaunchReceiptFileName),
+ """{"SchemaVersion":1,"Executable":null,"ArchiveRoots":null,"EnvironmentVariableHashes":null}""");
+
+ var result = await _service.RevalidateAsync(_workspacePath);
+
+ Assert.True(result.Success);
+ Assert.True(result.Data!.HasReceipt);
+
+ // The comparison path runs on the same launch and reads the same null collections,
+ // so tolerating them on read alone would still fail the launch a step later.
+ var report = _service.CompareUpcomingLaunch(result.Data.Receipt!, CreateContext());
+ Assert.NotNull(report);
+ }
+
+ ///
+ /// An upcoming launch identical to the recorded one reports no configuration drift, and
+ /// revalidation hands back the parsed receipt for that comparison.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task CompareUpcomingLaunch_WithIdenticalConfiguration_ReportsNoDrift()
+ {
+ await _service.RecordLaunchAsync(CreateContext());
+ var revalidation = await _service.RevalidateAsync(_workspacePath);
+ Assert.NotNull(revalidation.Data!.Receipt);
+
+ var report = _service.CompareUpcomingLaunch(revalidation.Data.Receipt!, CreateContext(launchId: "launch-2"));
+
+ Assert.True(report.HasReceipt);
+ Assert.False(report.HasDrift);
+ }
+
+ ///
+ /// A changed manifest version is reported as drift naming the manifest and both versions.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task CompareUpcomingLaunch_WithChangedManifestVersion_ReportsDrift()
+ {
+ await _service.RecordLaunchAsync(CreateContext());
+ var receipt = await ReadReceiptAsync();
+
+ var report = _service.CompareUpcomingLaunch(receipt, CreateContext(manifestVersion: "2.0"));
+
+ Assert.True(report.HasDrift);
+ Assert.Contains(report.DriftedFields, f =>
+ f.Contains("1.0.genhub.mod.test") && f.Contains("version changed from 1.0 to 2.0"));
+ }
+
+ ///
+ /// A changed game client is reported as drift naming both clients.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task CompareUpcomingLaunch_WithChangedGameClient_ReportsDrift()
+ {
+ await _service.RecordLaunchAsync(CreateContext());
+ var receipt = await ReadReceiptAsync();
+
+ var report = _service.CompareUpcomingLaunch(receipt, CreateContext(gameClientId: "client-2"));
+
+ Assert.True(report.HasDrift);
+ Assert.Contains(report.DriftedFields, f => f.Contains("Game client changed from client-1 to client-2"));
+ }
+
+ ///
+ /// A changed game type is reported as drift.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task CompareUpcomingLaunch_WithChangedGameType_ReportsDrift()
+ {
+ await _service.RecordLaunchAsync(CreateContext());
+ var receipt = await ReadReceiptAsync();
+
+ var report = _service.CompareUpcomingLaunch(receipt, CreateContext(gameType: GameType.Generals));
+
+ Assert.True(report.HasDrift);
+ Assert.Contains(report.DriftedFields, f => f.Contains("Game type changed"));
+ }
+
+ ///
+ /// A changed executable path is reported as drift even when the file itself is fine.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task CompareUpcomingLaunch_WithChangedExecutablePath_ReportsDrift()
+ {
+ await _service.RecordLaunchAsync(CreateContext());
+ var receipt = await ReadReceiptAsync();
+
+ var report = _service.CompareUpcomingLaunch(
+ receipt, CreateContext(executablePath: Path.Combine(_workspacePath, "otherclient")));
+
+ Assert.True(report.HasDrift);
+ Assert.Contains(report.DriftedFields, f => f.Contains("Executable path changed"));
+ }
+
+ ///
+ /// A root moved to a different path with identical contents is reported as path drift;
+ /// the filesystem fingerprints alone could not tell the roots apart.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task CompareUpcomingLaunch_WithChangedRootPathAndIdenticalContents_ReportsDrift()
+ {
+ await _service.RecordLaunchAsync(CreateContext());
+ var receipt = await ReadReceiptAsync();
+
+ var relocatedRoot = Directory.CreateDirectory(Path.Combine(_root, "retail-copy")).FullName;
+ foreach (var archivePath in Directory.GetFiles(_archiveRoot, "*.big"))
+ {
+ File.Copy(archivePath, Path.Combine(relocatedRoot, Path.GetFileName(archivePath)));
+ }
+
+ var report = _service.CompareUpcomingLaunch(receipt, CreateContext(archiveRoot: relocatedRoot));
+
+ Assert.True(report.HasDrift);
+ Assert.Contains(report.DriftedFields, f =>
+ f.Contains("Archive root path") &&
+ f.Contains(RetailArchiveConstants.ZeroHourInstallPathVariable) &&
+ f.Contains(relocatedRoot));
+ }
+
+ ///
+ /// A changed manifest set is reported per added and removed manifest.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task CompareUpcomingLaunch_WithChangedManifestSet_ReportsDrift()
+ {
+ await _service.RecordLaunchAsync(CreateContext());
+ var receipt = await ReadReceiptAsync();
+
+ var upcoming = CreateContext();
+ upcoming.ManifestIds = ["1.0.genhub.mod.other"];
+
+ var report = _service.CompareUpcomingLaunch(receipt, upcoming);
+
+ Assert.True(report.HasDrift);
+ Assert.Contains(report.DriftedFields, f =>
+ f.Contains("no longer part of the launch") && f.Contains("1.0.genhub.mod.test"));
+ Assert.Contains(report.DriftedFields, f =>
+ f.Contains("added since the last launch") && f.Contains("1.0.genhub.mod.other"));
+ }
+
+ ///
+ /// Recording captures the GenHub-built environment and the variant identity, and both
+ /// round-trip through the receipt.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task RecordLaunchAsync_CapturesEnvironmentAndVariantIdentity()
+ {
+ await _service.RecordLaunchAsync(CreateContext());
+
+ var receipt = await ReadReceiptAsync();
+ Assert.Contains("GENHUB_TEST_VARIABLE", receipt.EnvironmentVariableHashes.Keys);
+ Assert.NotNull(receipt.Variant);
+ Assert.Equal("1.0.genhub.mod.test", receipt.Variant.GameClientManifestId);
+ Assert.Equal("generalszh", receipt.Variant.EntryPointRelativePath);
+ Assert.Equal(["osx-arm64"], receipt.Variant.VariantRuntimeIdentifiers);
+
+ var recordedRoot = Assert.Contains(RetailArchiveConstants.ZeroHourInstallPathVariable, receipt.ArchiveRoots);
+ Assert.Equal(2, recordedRoot.Archives.Count);
+ Assert.Contains(recordedRoot.Archives, a => a.FileName == "INIZH.big" && a.SizeBytes > 0);
+ }
+
+ ///
+ /// The same environment value hashes differently in two receipts, so receipts cannot be
+ /// compared across hosts or profiles to confirm a shared value. Drift still resolves,
+ /// because comparison runs against the receipt that carries the salt.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task RecordLaunchAsync_SaltsEnvironmentHashesPerReceipt()
+ {
+ await _service.RecordLaunchAsync(CreateContext());
+ var first = await ReadReceiptAsync();
+
+ await _service.RecordLaunchAsync(CreateContext());
+ var second = await ReadReceiptAsync();
+
+ Assert.NotEqual(first.EnvironmentHashSalt, second.EnvironmentHashSalt);
+ Assert.NotEmpty(first.EnvironmentHashSalt);
+ Assert.NotEqual(
+ first.EnvironmentVariableHashes["GENHUB_TEST_VARIABLE"],
+ second.EnvironmentVariableHashes["GENHUB_TEST_VARIABLE"]);
+
+ Assert.False(_service.CompareUpcomingLaunch(first, CreateContext()).HasDrift);
+ Assert.Contains(
+ _service.CompareUpcomingLaunch(first, CreateContext(environmentValue: "beta")).DriftedFields,
+ f => f.Contains("GENHUB_TEST_VARIABLE") && f.Contains("changed value"));
+ }
+
+ ///
+ /// A changed profile-defined environment variable is reported as drift naming the
+ /// variable, without either value appearing in the message.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task CompareUpcomingLaunch_WithChangedEnvironmentVariable_ReportsDrift()
+ {
+ await _service.RecordLaunchAsync(CreateContext());
+ var receipt = await ReadReceiptAsync();
+
+ var report = _service.CompareUpcomingLaunch(receipt, CreateContext(environmentValue: "beta"));
+
+ Assert.True(report.HasDrift);
+ Assert.Contains(report.DriftedFields, f =>
+ f.Contains("GENHUB_TEST_VARIABLE") && f.Contains("changed value"));
+ Assert.DoesNotContain(report.DriftedFields, f => f.Contains("alpha") || f.Contains("beta"));
+ }
+
+ ///
+ /// A profile-defined environment value never reaches the receipt on disk, nor the drift
+ /// messages that carry it into the log and the post-launch notice.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task RecordLaunchAsync_DoesNotPersistEnvironmentValues()
+ {
+ const string secret = "s3cr3t-token-value";
+
+ await _service.RecordLaunchAsync(CreateContext(environmentValue: secret));
+
+ var receiptJson = await File.ReadAllTextAsync(
+ Path.Combine(_workspacePath, FileTypes.LaunchReceiptFileName));
+ Assert.DoesNotContain(secret, receiptJson, StringComparison.Ordinal);
+ Assert.Contains("GENHUB_TEST_VARIABLE", receiptJson, StringComparison.Ordinal);
+
+ var receipt = await ReadReceiptAsync();
+ var report = _service.CompareUpcomingLaunch(receipt, CreateContext(environmentValue: "replacement"));
+ Assert.DoesNotContain(report.DriftedFields, f => f.Contains(secret));
+ }
+
+ ///
+ /// An environment variable that is no longer set, and one newly set, are each named.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task CompareUpcomingLaunch_WithChangedEnvironmentSet_ReportsDrift()
+ {
+ await _service.RecordLaunchAsync(CreateContext());
+ var receipt = await ReadReceiptAsync();
+
+ var upcoming = CreateContext();
+ upcoming.EnvironmentVariables = new Dictionary
+ {
+ [RetailArchiveConstants.ZeroHourInstallPathVariable] = _archiveRoot + Path.DirectorySeparatorChar,
+ ["GENHUB_OTHER_VARIABLE"] = "1",
+ };
+
+ var report = _service.CompareUpcomingLaunch(receipt, upcoming);
+
+ Assert.True(report.HasDrift);
+ Assert.Contains(report.DriftedFields, f =>
+ f.Contains("GENHUB_TEST_VARIABLE") && f.Contains("no longer set"));
+ Assert.Contains(report.DriftedFields, f =>
+ f.Contains("GENHUB_OTHER_VARIABLE") && f.Contains("newly set"));
+ }
+
+ ///
+ /// A changed resolved entry point is reported as variant drift.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task CompareUpcomingLaunch_WithChangedEntryPoint_ReportsDrift()
+ {
+ await _service.RecordLaunchAsync(CreateContext());
+ var receipt = await ReadReceiptAsync();
+
+ var report = _service.CompareUpcomingLaunch(receipt, CreateContext(entryPoint: "otherclient"));
+
+ Assert.True(report.HasDrift);
+ Assert.Contains(report.DriftedFields, f =>
+ f.Contains("Entry point changed from generalszh to otherclient"));
+ }
+
+ ///
+ /// A variant identity that stops being resolvable is reported, not ignored.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task CompareUpcomingLaunch_WithVariantNoLongerResolvable_ReportsDrift()
+ {
+ await _service.RecordLaunchAsync(CreateContext());
+ var receipt = await ReadReceiptAsync();
+
+ var upcoming = CreateContext();
+ upcoming.Variant = null;
+
+ var report = _service.CompareUpcomingLaunch(receipt, upcoming);
+
+ Assert.True(report.HasDrift);
+ Assert.Contains(report.DriftedFields, f => f.Contains("Variant identity no longer resolvable"));
+ }
+
+ ///
+ /// Recording into a missing workspace fails without throwing.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task RecordLaunchAsync_WithMissingWorkspace_ReturnsFailure()
+ {
+ var context = CreateContext();
+ context.WorkspacePath = Path.Combine(_root, "does-not-exist");
+
+ var result = await _service.RecordLaunchAsync(context);
+
+ Assert.False(result.Success);
+ Assert.Contains("Failed to record launch receipt", result.FirstError);
+ }
+
+ ///
+ /// Removes the temporary directories.
+ ///
+ public void Dispose()
+ {
+ try
+ {
+ Directory.Delete(_root, recursive: true);
+ }
+ catch (IOException)
+ {
+ // Best effort; a leftover temp directory is not worth failing the run over.
+ }
+
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ /// Reads the receipt back from the workspace.
+ ///
+ /// The deserialized receipt.
+ private async Task ReadReceiptAsync()
+ {
+ var json = await File.ReadAllTextAsync(Path.Combine(_workspacePath, FileTypes.LaunchReceiptFileName));
+ var receipt = JsonSerializer.Deserialize(
+ json,
+ new JsonSerializerOptions { Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } });
+ Assert.NotNull(receipt);
+ return receipt;
+ }
+
+ ///
+ /// Creates a context pointing at the fixture workspace, executable and archive root.
+ ///
+ /// The launch identifier to record.
+ /// The game client identifier.
+ /// The game type.
+ /// The executable path; the fixture executable when null.
+ /// The archive root; the fixture root when null.
+ /// The version of the single fixture manifest.
+ /// The value of the profile-defined environment variable.
+ /// The resolved entry point of the variant identity.
+ /// The context.
+ private LaunchReceiptContext CreateContext(
+ string launchId = "launch-1",
+ string gameClientId = "client-1",
+ GameType gameType = GameType.ZeroHour,
+ string? executablePath = null,
+ string? archiveRoot = null,
+ string manifestVersion = "1.0",
+ string environmentValue = "alpha",
+ string entryPoint = "generalszh")
+ {
+ return new LaunchReceiptContext
+ {
+ LaunchId = launchId,
+ ProfileId = "profile-1",
+ GameClientId = gameClientId,
+ GameType = gameType,
+ WorkspaceId = "profile-1",
+ WorkspacePath = _workspacePath,
+ ExecutablePath = executablePath ?? _executablePath,
+ WorkingDirectory = _workspacePath,
+ EnvironmentVariables = new Dictionary
+ {
+ [RetailArchiveConstants.ZeroHourInstallPathVariable] =
+ (archiveRoot ?? _archiveRoot) + Path.DirectorySeparatorChar,
+ ["GENHUB_TEST_VARIABLE"] = environmentValue,
+ },
+ ManifestIds = ["1.0.genhub.mod.test"],
+ ManifestVersions = new Dictionary { ["1.0.genhub.mod.test"] = manifestVersion },
+ Variant = new LaunchReceiptVariant
+ {
+ GameClientManifestId = "1.0.genhub.mod.test",
+ RuntimeIdentifier = "osx-arm64",
+ HasVariants = true,
+ VariantRuntimeIdentifiers = ["osx-arm64"],
+ EntryPointRelativePath = entryPoint,
+ Resolution = "declared entry point",
+ },
+ };
+ }
+}
diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs
index 838cb2241..a1cd2618e 100644
--- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs
+++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs
@@ -60,6 +60,8 @@ public partial class GameProfileLauncherViewModel(
IRecipient,
IRecipient
{
+ private const int MaxReceiptDriftNoticeLines = 5;
+
private readonly SemaphoreSlim _launchSemaphore = new(1, 1);
private readonly System.Timers.Timer _headerCollapseTimer = new(TimeIntervals.HeaderCollapseDelayMs);
private readonly System.Timers.Timer _headerExpansionTimer = new(TimeIntervals.HeaderExpansionDelayMs);
@@ -987,6 +989,25 @@ private async Task LaunchProfileAsync(GameProfileItemViewModel profile)
}
}
+ ///
+ /// Composes the informational receipt-drift notice: a lead line plus the drifted
+ /// fields, capped so a long list does not flood the notification. Full detail stays
+ /// in the logs.
+ ///
+ /// The drifted fields from the launch result.
+ /// The notice text.
+ private string BuildReceiptDriftNotice(IReadOnlyList driftWarnings)
+ {
+ var lines = new List { "Launch configuration changed since the last run:" };
+ lines.AddRange(driftWarnings.Take(MaxReceiptDriftNoticeLines));
+ if (driftWarnings.Count > MaxReceiptDriftNoticeLines)
+ {
+ lines.Add($"...and {driftWarnings.Count - MaxReceiptDriftNoticeLines} more; see the logs for full detail.");
+ }
+
+ return string.Join(Environment.NewLine, lines);
+ }
+
///
/// Executes the actual launch operation.
///
@@ -1010,6 +1031,15 @@ private async Task ExecuteLaunchAsync(GameProfileItemViewModel profile)
StatusMessage = $"{liveProfile.Name} launched successfully (Process ID: {launchResult.Data.ProcessInfo.ProcessId})";
notificationService.ShowSuccess("Game Launched", $"{liveProfile.Name} is now running.");
+
+ // Advisory by design: receipt drift never blocks or fails a launch, so it is
+ // surfaced as information beside the success, never through the error channel.
+ if (launchResult.Data.ReceiptDriftWarnings.Count > 0)
+ {
+ notificationService.ShowInfo(
+ "Launch Configuration Changed",
+ BuildReceiptDriftNotice(launchResult.Data.ReceiptDriftWarnings));
+ }
}
else
{
diff --git a/GenHub/GenHub/Features/Launching/GameLauncher.cs b/GenHub/GenHub/Features/Launching/GameLauncher.cs
index 10e2c0fe7..8d2755b97 100644
--- a/GenHub/GenHub/Features/Launching/GameLauncher.cs
+++ b/GenHub/GenHub/Features/Launching/GameLauncher.cs
@@ -21,6 +21,7 @@
using GenHub.Core.Interfaces.UserData;
using GenHub.Core.Interfaces.Workspace;
using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.GameClients;
using GenHub.Core.Models.GameInstallations;
using GenHub.Core.Models.GameProfile;
using GenHub.Core.Models.GameSettings;
@@ -49,7 +50,8 @@ public class GameLauncher(
IGameSettingsService gameSettingsService,
IProfileContentLinker profileContentLinker,
ISteamLauncher steamLauncher,
- IConfigurationProviderService configurationProvider) : IGameLauncher
+ IConfigurationProviderService configurationProvider,
+ ILaunchReceiptService launchReceiptService) : IGameLauncher
{
private static readonly ConcurrentDictionary _profileLaunchLocks = new();
private static readonly ConcurrentDictionary _steamInstallationLaunchLocks =
@@ -703,6 +705,79 @@ private static void AddArchiveRoot(
private static string EnsureTrailingSeparator(string path) =>
path.EndsWith(Path.DirectorySeparatorChar) ? path : path + Path.DirectorySeparatorChar;
+ ///
+ /// Builds the receipt context describing what this launch consists of, shared by the
+ /// configuration comparison before spawn and the recording after it.
+ ///
+ /// The profile being launched.
+ /// The game client being launched.
+ /// The prepared workspace.
+ /// The configuration the process is started with.
+ /// The manifests resolved for the launch.
+ /// The launch identifier.
+ /// The receipt context.
+ private static LaunchReceiptContext BuildLaunchReceiptContext(
+ GameProfile profile,
+ GameClient gameClient,
+ WorkspaceInfo workspaceInfo,
+ GameLaunchConfiguration launchConfig,
+ IReadOnlyList manifests,
+ string launchId)
+ {
+ var manifestVersions = new Dictionary();
+ foreach (var manifest in manifests)
+ {
+ manifestVersions[manifest.Id.Value] = manifest.Version;
+ }
+
+ return new LaunchReceiptContext
+ {
+ LaunchId = launchId,
+ ProfileId = profile.Id,
+ GameClientId = gameClient.Id,
+ GameType = gameClient.GameType,
+ WorkspaceId = workspaceInfo.Id,
+ WorkspacePath = workspaceInfo.WorkspacePath,
+ ExecutablePath = launchConfig.ExecutablePath,
+ WorkingDirectory = launchConfig.WorkingDirectory ?? workspaceInfo.WorkspacePath,
+ EnvironmentVariables = launchConfig.EnvironmentVariables,
+ ManifestIds = manifests.Select(m => m.Id.Value).ToList(),
+ ManifestVersions = manifestVersions,
+ Variant = ResolveVariantIdentity(manifests),
+ };
+ }
+
+ ///
+ /// Resolves the variant and entry-point identity for the receipt by re-running the
+ /// same resolution workspace preparation applies
+ /// to the game client manifest — same manifest, same host runtime, same outcome. Null
+ /// when no game client manifest is part of the launch: that is the legacy fallback,
+ /// which resolves the executable by filename search with no variant machinery involved.
+ ///
+ /// The manifests resolved for the launch.
+ /// The identity, or null when nothing variant-shaped participated.
+ private static LaunchReceiptVariant? ResolveVariantIdentity(IReadOnlyList manifests)
+ {
+ var gameClientManifest = manifests.FirstOrDefault(m => m.ContentType == ContentType.GameClient);
+ if (gameClientManifest is null)
+ {
+ return null;
+ }
+
+ var variant = ManifestVariantResolver.ResolveVariant(gameClientManifest);
+ var entryPoint = ManifestVariantResolver.ResolveEntryPoint(gameClientManifest);
+
+ return new LaunchReceiptVariant
+ {
+ GameClientManifestId = gameClientManifest.Id.Value,
+ RuntimeIdentifier = ManifestVariantResolver.CurrentRuntimeIdentifier,
+ HasVariants = gameClientManifest.Variants.Count > 0,
+ VariantRuntimeIdentifiers = variant is null ? [] : [.. variant.RuntimeIdentifiers],
+ EntryPointRelativePath = entryPoint.RelativePath,
+ Resolution = entryPoint.Reason,
+ };
+ }
+
private async Task> LaunchProfileAsync(GameProfile profile, bool skipUserDataCleanup, IProgress? progress, string launchId, CancellationToken cancellationToken)
{
IDisposable? steamInstallationLock = null;
@@ -922,6 +997,16 @@ private async Task> LaunchProfileAsync(Gam
}
}
+ // Revalidated before preparation because reconciliation removes files that are
+ // not in the manifests — the previous receipt does not survive it — and because
+ // drift since the last launch is what matters, not the effects of this one. The
+ // parsed receipt is carried forward so the upcoming launch's configuration can
+ // be compared against it once it is fully built, and every drift warning is
+ // collected so the launch result can surface it alongside the logs.
+ var receiptDriftWarnings = new List();
+ var previousReceipt = await RevalidateLaunchReceiptAsync(
+ Path.Combine(dynamicWorkspacePath, profile.Id), profile.Id, receiptDriftWarnings, cancellationToken);
+
logger.LogInformation("[GameLauncher] Preparing workspace at: {WorkspacePath}", workspaceConfig.WorkspaceRootPath);
var workspaceProgress = new Progress(
wp =>
@@ -1206,6 +1291,17 @@ private async Task> LaunchProfileAsync(Gam
return LaunchOperationResult.CreateFailure(archiveRootError, launchId, profile.Id);
}
+ // The configuration half of receipt revalidation: only now are the resolved
+ // executable path and the child environment known, so this is the earliest the
+ // upcoming launch can be compared against the receipt read before preparation.
+ var receiptContext = BuildLaunchReceiptContext(profile, gameClient, workspaceInfo, launchConfig, manifests, launchId);
+ if (previousReceipt is not null)
+ {
+ var configurationDrift = launchReceiptService.CompareUpcomingLaunch(previousReceipt, receiptContext);
+ LogReceiptDrift(profile.Id, configurationDrift);
+ receiptDriftWarnings.AddRange(configurationDrift.DriftedFields);
+ }
+
logger.LogInformation("[GameLauncher] Starting game process...");
OperationResult processResult;
@@ -1298,6 +1394,8 @@ private async Task> LaunchProfileAsync(Gam
var processInfo = processResult.Data;
logger.LogInformation("[GameLauncher] Process started successfully - PID: {ProcessId}", processInfo.ProcessId);
+ await RecordLaunchReceiptAsync(receiptContext);
+
// Update the placeholder launch entry with real process info
// (The placeholder was registered earlier to prevent deletion during launch)
var launchInfo = new GameLaunchInfo
@@ -1307,6 +1405,7 @@ private async Task> LaunchProfileAsync(Gam
WorkspaceId = workspaceInfo.Id,
ProcessInfo = processInfo,
LaunchedAt = DateTime.UtcNow,
+ ReceiptDriftWarnings = receiptDriftWarnings,
};
logger.LogDebug("[GameLauncher] Updating launch registry with real process info");
await launchRegistry.RegisterLaunchAsync(launchInfo);
@@ -1336,6 +1435,85 @@ private async Task> LaunchProfileAsync(Gam
}
}
+ ///
+ /// Cheaply revalidates the previous launch receipt against the filesystem, if any, and
+ /// logs a warning naming each drifted field. Drift never blocks the launch; blocking on
+ /// a misconfigured root remains the job of .
+ ///
+ /// The workspace directory the receipt would live in.
+ /// The profile being launched.
+ /// Collects the drifted fields for the launch result.
+ /// A cancellation token to observe while waiting for the task to complete.
+ /// The parsed receipt, when one was present and readable, for the later configuration comparison.
+ private async Task RevalidateLaunchReceiptAsync(string workspacePath, string profileId, List driftWarnings, CancellationToken cancellationToken)
+ {
+ var driftResult = await launchReceiptService.RevalidateAsync(workspacePath, cancellationToken);
+ if (!driftResult.Success)
+ {
+ logger.LogWarning("[GameLauncher] Launch receipt revalidation failed: {Error}", driftResult.FirstError);
+ return null;
+ }
+
+ if (driftResult.Data is not { HasReceipt: true } driftReport)
+ {
+ return null;
+ }
+
+ if (!driftReport.HasDrift)
+ {
+ logger.LogDebug("[GameLauncher] Launch receipt for profile {ProfileId} matches the current state", profileId);
+ }
+
+ LogReceiptDrift(profileId, driftReport);
+ driftWarnings.AddRange(driftReport.DriftedFields);
+ return driftReport.Receipt;
+ }
+
+ ///
+ /// Logs a structured warning per drifted field.
+ ///
+ /// The profile being launched.
+ /// The report to log.
+ private void LogReceiptDrift(string profileId, LaunchReceiptDriftReport driftReport)
+ {
+ foreach (var driftedField in driftReport.DriftedFields)
+ {
+ logger.LogWarning(
+ "[GameLauncher] Launch receipt drift for profile {ProfileId}: {DriftedField}",
+ profileId,
+ driftedField);
+ }
+ }
+
+ ///
+ /// Records a receipt of what this launch consisted of into the workspace. A failure to
+ /// record is logged and never fails a launch that has already started.
+ ///
+ ///
+ /// Deliberately not passed the launch cancellation token: the child process is already
+ /// running by the time this is called, so cancelling the launch operation must not abandon
+ /// the write half-done, and the resulting must not
+ /// reach the caller's catch and report a running game as a failed launch. Everything else
+ /// is caught here for the same reason.
+ ///
+ /// What the launch consisted of.
+ /// A task representing the asynchronous operation.
+ private async Task RecordLaunchReceiptAsync(LaunchReceiptContext receiptContext)
+ {
+ try
+ {
+ var receiptResult = await launchReceiptService.RecordLaunchAsync(receiptContext, CancellationToken.None);
+ if (!receiptResult.Success)
+ {
+ logger.LogWarning("[GameLauncher] Failed to record launch receipt: {Error}", receiptResult.FirstError);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "[GameLauncher] Failed to record launch receipt for profile {ProfileId}", receiptContext.ProfileId);
+ }
+ }
+
///
/// Applies the profile-specific game settings to the Options.ini file before launching.
/// This ensures the game launches with the settings configured for this specific profile.
diff --git a/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs b/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs
new file mode 100644
index 000000000..a3d34458c
--- /dev/null
+++ b/GenHub/GenHub/Features/Launching/LaunchReceiptService.cs
@@ -0,0 +1,620 @@
+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.Text.Json.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Helpers;
+using GenHub.Core.Interfaces.Common;
+using GenHub.Core.Interfaces.Launching;
+using GenHub.Core.Models.Launching;
+using GenHub.Core.Models.Results;
+using Microsoft.Extensions.Logging;
+
+namespace GenHub.Features.Launching;
+
+///
+/// Records launch receipts into the workspace and cheaply revalidates them before
+/// subsequent launches.
+///
+///
+/// The receipt is a single JSON file beside the workspace content; the latest launch wins.
+/// Recording hashes the executable once, but revalidation recomputes only cheap fields —
+/// existence, counts, sizes and timestamps — so it can run on every launch. Archive roots
+/// are fingerprinted by archive count and total bytes rather than content, because hashing
+/// gigabytes of retail archives per launch would defeat the point.
+///
+public class LaunchReceiptService(
+ ILogger logger,
+ IFileHashProvider hashProvider) : ILaunchReceiptService
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ Converters = { new JsonStringEnumConverter() },
+ };
+
+ ///
+ public async Task> RecordLaunchAsync(LaunchReceiptContext context, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ try
+ {
+ var receipt = new LaunchReceipt
+ {
+ RecordedAtUtc = DateTime.UtcNow,
+ LaunchId = context.LaunchId,
+ ProfileId = context.ProfileId,
+ GameClientId = context.GameClientId,
+ GameType = context.GameType,
+ WorkspaceId = context.WorkspaceId,
+ WorkingDirectory = context.WorkingDirectory,
+ Executable = await FingerprintExecutableAsync(context.ExecutablePath, cancellationToken),
+ ManifestIds = [.. context.ManifestIds],
+ Variant = context.Variant,
+ };
+
+ foreach (var (manifestId, version) in context.ManifestVersions)
+ {
+ receipt.ManifestVersions[manifestId] = version;
+ }
+
+ receipt.EnvironmentHashSalt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
+ foreach (var (variableName, value) in context.EnvironmentVariables)
+ {
+ receipt.EnvironmentVariableHashes[variableName] =
+ HashEnvironmentValue(value, receipt.EnvironmentHashSalt);
+ }
+
+ foreach (var variableName in RetailArchiveConstants.InstallPathVariables)
+ {
+ if (context.EnvironmentVariables.TryGetValue(variableName, out var root) &&
+ !string.IsNullOrWhiteSpace(root))
+ {
+ receipt.ArchiveRoots[variableName] = FingerprintArchiveRoot(root);
+ }
+ }
+
+ var receiptPath = GetReceiptPath(context.WorkspacePath);
+ var temporaryPath = receiptPath + ".tmp";
+ await File.WriteAllTextAsync(temporaryPath, JsonSerializer.Serialize(receipt, JsonOptions), cancellationToken);
+ File.Move(temporaryPath, receiptPath, overwrite: true);
+
+ logger.LogDebug("Launch receipt recorded at {ReceiptPath}", receiptPath);
+ return OperationResult.CreateSuccess(receipt);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to record launch receipt for profile {ProfileId}", context.ProfileId);
+ return OperationResult.CreateFailure($"Failed to record launch receipt: {ex.Message}");
+ }
+ }
+
+ ///
+ public async Task> RevalidateAsync(string workspacePath, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(workspacePath);
+
+ var receiptPath = GetReceiptPath(workspacePath);
+ var report = new LaunchReceiptDriftReport { ReceiptPath = receiptPath };
+
+ if (!File.Exists(receiptPath))
+ {
+ return OperationResult.CreateSuccess(report);
+ }
+
+ report.HasReceipt = true;
+
+ LaunchReceipt? receipt;
+ try
+ {
+ var json = await File.ReadAllTextAsync(receiptPath, cancellationToken);
+ receipt = JsonSerializer.Deserialize(json, JsonOptions);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
+ {
+ report.DriftedFields.Add($"Receipt could not be read: {receiptPath} ({ex.Message})");
+ return OperationResult.CreateSuccess(report);
+ }
+
+ if (receipt is null)
+ {
+ report.DriftedFields.Add($"Receipt is empty: {receiptPath}");
+ return OperationResult.CreateSuccess(report);
+ }
+
+ report.Receipt = receipt;
+
+ // Guarded as widely as the parse above. A receipt that parses but carries null or
+ // malformed fields must degrade to a drift line, never to an exception: revalidation
+ // is awaited on the launch path, so anything escaping here fails the launch through
+ // LaunchProfileAsync's catch-all — the opposite of the guarantee this method makes.
+ try
+ {
+ if (receipt.Executable is not null)
+ {
+ CompareExecutable(receipt.Executable, report);
+ }
+
+ var recordedRoots = receipt.ArchiveRoots;
+ if (recordedRoots is not null)
+ {
+ foreach (var (variableName, recordedRoot) in recordedRoots)
+ {
+ if (recordedRoot is not null)
+ {
+ CompareArchiveRoot(variableName, recordedRoot, report);
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Launch receipt at {ReceiptPath} could not be revalidated", receiptPath);
+ report.DriftedFields.Add($"Receipt could not be revalidated: {receiptPath} ({ex.Message})");
+ }
+
+ return OperationResult.CreateSuccess(report);
+ }
+
+ ///
+ public LaunchReceiptDriftReport CompareUpcomingLaunch(LaunchReceipt receipt, LaunchReceiptContext upcoming)
+ {
+ ArgumentNullException.ThrowIfNull(receipt);
+ ArgumentNullException.ThrowIfNull(upcoming);
+
+ var report = new LaunchReceiptDriftReport { HasReceipt = true, Receipt = receipt };
+
+ // Guarded for the same reason RevalidateAsync is: this runs on the launch path, and a
+ // receipt that parses but carries null collections would otherwise throw here and fail
+ // the launch through LaunchProfileAsync's catch-all. Every field below comes from a
+ // deserialized file, so none of them can be assumed present.
+ try
+ {
+ if (!string.Equals(receipt.GameClientId ?? string.Empty, upcoming.GameClientId ?? string.Empty, StringComparison.Ordinal))
+ {
+ report.DriftedFields.Add(
+ $"Game client changed from {receipt.GameClientId ?? "(none)"} to {upcoming.GameClientId ?? "(none)"}");
+ }
+
+ if (receipt.GameType != upcoming.GameType)
+ {
+ report.DriftedFields.Add($"Game type changed from {receipt.GameType} to {upcoming.GameType}");
+ }
+
+ if (!string.IsNullOrEmpty(upcoming.ExecutablePath) &&
+ !PathsEqual(receipt.Executable?.Path ?? string.Empty, upcoming.ExecutablePath))
+ {
+ report.DriftedFields.Add(
+ $"Executable path changed from {NameOrNone(receipt.Executable?.Path)} to {upcoming.ExecutablePath}");
+ }
+
+ CompareManifests(receipt, upcoming, report);
+ CompareArchiveRootConfiguration(receipt, upcoming, report);
+ CompareEnvironment(receipt, upcoming, report);
+ CompareVariant(receipt.Variant, upcoming.Variant, report);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Launch receipt for profile {ProfileId} could not be compared", upcoming.ProfileId);
+ report.DriftedFields.Add($"Receipt could not be compared against the upcoming launch ({ex.Message})");
+ }
+
+ return report;
+ }
+
+ ///
+ /// Resolves the receipt path within a workspace.
+ ///
+ /// The workspace directory.
+ /// The receipt path.
+ private static string GetReceiptPath(string workspacePath) =>
+ Path.Combine(workspacePath, FileTypes.LaunchReceiptFileName);
+
+ ///
+ /// Compares two paths ignoring a trailing directory separator, which the archive root
+ /// variables carry by engine requirement and other paths do not.
+ ///
+ ///
+ /// Separators are normalised and the comparison follows platform casing rules, so the
+ /// same location written two ways is not reported as drift. Compared as text rather than
+ /// resolved through the filesystem: this runs before the launch and a recorded path that
+ /// no longer exists is drift to report, not an exception to throw.
+ ///
+ /// One path.
+ /// The other path.
+ /// Whether the paths are equal.
+ private static bool PathsEqual(string left, string right) =>
+ string.Equals(NormalizePath(left), NormalizePath(right), PathHelper.PathComparison);
+
+ ///
+ /// Renders a recorded value that a corrupt receipt may have left unset.
+ ///
+ /// The value to render.
+ /// The value, or a placeholder when it is missing.
+ private static string NameOrNone(string? value) =>
+ string.IsNullOrEmpty(value) ? "(none)" : value;
+
+ ///
+ /// Normalises a path for comparison by unifying separators and dropping a trailing one.
+ ///
+ /// The path to normalise.
+ /// The normalised path.
+ private static string NormalizePath(string path) =>
+ string.IsNullOrEmpty(path)
+ ? string.Empty
+ : Path.TrimEndingDirectorySeparator(
+ path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar));
+
+ ///
+ /// Compares the recorded manifest set and versions against the upcoming launch's.
+ ///
+ /// The receipt from the previous launch.
+ /// The configuration of the launch about to happen.
+ /// The report drifted fields are added to.
+ private static void CompareManifests(LaunchReceipt receipt, LaunchReceiptContext upcoming, LaunchReceiptDriftReport report)
+ {
+ var recordedManifestIds = receipt.ManifestIds ?? [];
+ var recordedIds = new HashSet(recordedManifestIds, StringComparer.Ordinal);
+ var upcomingIds = new HashSet(upcoming.ManifestIds, StringComparer.Ordinal);
+
+ foreach (var manifestId in recordedManifestIds)
+ {
+ if (!upcomingIds.Contains(manifestId))
+ {
+ report.DriftedFields.Add($"Manifest no longer part of the launch: {manifestId}");
+ }
+ }
+
+ foreach (var manifestId in upcoming.ManifestIds)
+ {
+ if (!recordedIds.Contains(manifestId))
+ {
+ report.DriftedFields.Add($"Manifest added since the last launch: {manifestId}");
+ }
+ }
+
+ foreach (var (manifestId, recordedVersion) in receipt.ManifestVersions ?? [])
+ {
+ if (upcoming.ManifestVersions.TryGetValue(manifestId, out var upcomingVersion) &&
+ !string.Equals(recordedVersion, upcomingVersion, StringComparison.Ordinal))
+ {
+ report.DriftedFields.Add(
+ $"Manifest {manifestId} version changed from {recordedVersion} to {upcomingVersion}");
+ }
+ }
+ }
+
+ ///
+ /// Compares which archive roots are configured, and where they point, against the
+ /// receipt — path changes, not content changes, which the filesystem checks own.
+ ///
+ /// The receipt from the previous launch.
+ /// The configuration of the launch about to happen.
+ /// The report drifted fields are added to.
+ private static void CompareArchiveRootConfiguration(LaunchReceipt receipt, LaunchReceiptContext upcoming, LaunchReceiptDriftReport report)
+ {
+ foreach (var variableName in RetailArchiveConstants.InstallPathVariables)
+ {
+ (receipt.ArchiveRoots ?? []).TryGetValue(variableName, out var recordedRoot);
+ var upcomingRoot =
+ upcoming.EnvironmentVariables.TryGetValue(variableName, out var configured) &&
+ !string.IsNullOrWhiteSpace(configured)
+ ? configured
+ : null;
+
+ if (recordedRoot is null && upcomingRoot is null)
+ {
+ continue;
+ }
+
+ if (recordedRoot is null)
+ {
+ report.DriftedFields.Add($"Archive root for {variableName} newly configured: {upcomingRoot}");
+ continue;
+ }
+
+ if (upcomingRoot is null)
+ {
+ report.DriftedFields.Add($"Archive root for {variableName} no longer configured; was {recordedRoot.Path}");
+ continue;
+ }
+
+ if (!PathsEqual(recordedRoot.Path, upcomingRoot))
+ {
+ report.DriftedFields.Add(
+ $"Archive root path for {variableName} changed from {recordedRoot.Path} to {upcomingRoot}");
+ }
+ }
+ }
+
+ ///
+ /// Compares the GenHub-built child environment against the receipt, per variable. The
+ /// retail archive root variables are excluded here because
+ /// already names their changes.
+ ///
+ /// The receipt from the previous launch.
+ /// The configuration of the launch about to happen.
+ /// The report drifted fields are added to.
+ private static void CompareEnvironment(LaunchReceipt receipt, LaunchReceiptContext upcoming, LaunchReceiptDriftReport report)
+ {
+ // Names, never values. These lines reach the log and the post-launch notice, both of
+ // which travel further than the machine that produced them, and a profile-defined
+ // variable can carry a credential. Which variable changed is the actionable part.
+ var recordedHashes = receipt.EnvironmentVariableHashes ?? [];
+ foreach (var (variableName, recordedHash) in recordedHashes)
+ {
+ if (IsArchiveRootVariable(variableName))
+ {
+ continue;
+ }
+
+ if (!upcoming.EnvironmentVariables.TryGetValue(variableName, out var upcomingValue))
+ {
+ report.DriftedFields.Add($"Environment variable {variableName} is no longer set");
+ }
+ else if (!string.Equals(
+ recordedHash,
+ HashEnvironmentValue(upcomingValue, receipt.EnvironmentHashSalt),
+ StringComparison.Ordinal))
+ {
+ report.DriftedFields.Add($"Environment variable {variableName} changed value");
+ }
+ }
+
+ foreach (var (variableName, _) in upcoming.EnvironmentVariables)
+ {
+ if (!IsArchiveRootVariable(variableName) &&
+ !recordedHashes.ContainsKey(variableName))
+ {
+ report.DriftedFields.Add($"Environment variable {variableName} is newly set");
+ }
+ }
+ }
+
+ ///
+ /// Hashes an environment variable value so drift can be detected without the receipt, the
+ /// log or the post-launch notice ever carrying the value itself.
+ ///
+ /// The value to hash.
+ /// The receipt's salt, keying the hash so it is unique to that receipt.
+ /// The lowercase hexadecimal keyed hash of the value.
+ private static string HashEnvironmentValue(string value, string salt) =>
+ Convert.ToHexString(
+ HMACSHA256.HashData(
+ Encoding.UTF8.GetBytes(salt ?? string.Empty),
+ Encoding.UTF8.GetBytes(value ?? string.Empty)))
+ .ToLowerInvariant();
+
+ ///
+ /// Determines whether a variable is one of the retail archive root variables.
+ ///
+ /// The variable name.
+ /// Whether it carries an archive root.
+ private static bool IsArchiveRootVariable(string variableName)
+ {
+ foreach (var rootVariable in RetailArchiveConstants.InstallPathVariables)
+ {
+ if (string.Equals(variableName, rootVariable, StringComparison.Ordinal))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Compares the resolved variant and entry-point identity against the receipt.
+ ///
+ /// The identity recorded at the previous launch, if any.
+ /// The identity resolved for the launch about to happen, if any.
+ /// The report drifted fields are added to.
+ private static void CompareVariant(LaunchReceiptVariant? recorded, LaunchReceiptVariant? upcoming, LaunchReceiptDriftReport report)
+ {
+ if (recorded is null && upcoming is null)
+ {
+ return;
+ }
+
+ if (recorded is null)
+ {
+ report.DriftedFields.Add(
+ $"Variant identity newly resolvable; entry point is {upcoming!.EntryPointRelativePath ?? "(unresolved)"}");
+ return;
+ }
+
+ if (upcoming is null)
+ {
+ report.DriftedFields.Add(
+ $"Variant identity no longer resolvable; entry point was {recorded.EntryPointRelativePath ?? "(unresolved)"}");
+ return;
+ }
+
+ if (!string.Equals(recorded.RuntimeIdentifier, upcoming.RuntimeIdentifier, StringComparison.OrdinalIgnoreCase))
+ {
+ report.DriftedFields.Add(
+ $"Host runtime identifier changed from {recorded.RuntimeIdentifier} to {upcoming.RuntimeIdentifier}");
+ }
+
+ if (!recorded.VariantRuntimeIdentifiers.SequenceEqual(upcoming.VariantRuntimeIdentifiers, StringComparer.OrdinalIgnoreCase))
+ {
+ report.DriftedFields.Add(
+ $"Resolved variant changed from [{string.Join(", ", recorded.VariantRuntimeIdentifiers)}] to [{string.Join(", ", upcoming.VariantRuntimeIdentifiers)}]");
+ }
+
+ if (!string.Equals(recorded.EntryPointRelativePath, upcoming.EntryPointRelativePath, StringComparison.Ordinal))
+ {
+ report.DriftedFields.Add(
+ $"Entry point changed from {recorded.EntryPointRelativePath ?? "(unresolved)"} to {upcoming.EntryPointRelativePath ?? "(unresolved)"}");
+ }
+ }
+
+ ///
+ /// Fingerprints one archive root as a per-archive list of name, size and timestamp
+ /// from a single directory listing, with the count and byte total derived from it.
+ ///
+ /// The archive root.
+ /// The fingerprint; a missing root fingerprints as zero archives.
+ private static LaunchReceiptArchiveRoot FingerprintArchiveRoot(string rootPath)
+ {
+ var fingerprint = new LaunchReceiptArchiveRoot { Path = rootPath };
+ if (!Directory.Exists(rootPath))
+ {
+ return fingerprint;
+ }
+
+ foreach (var archivePath in Directory.EnumerateFiles(
+ rootPath, RetailArchiveConstants.ArchiveSearchPattern, RetailArchiveConstants.ArchiveSearch))
+ {
+ var info = new FileInfo(archivePath);
+ fingerprint.Archives.Add(new LaunchReceiptArchiveEntry
+ {
+ FileName = info.Name,
+ SizeBytes = info.Length,
+ LastWriteUtc = info.LastWriteTimeUtc,
+ });
+ fingerprint.ArchiveCount++;
+ fingerprint.TotalArchiveBytes += info.Length;
+ }
+
+ return fingerprint;
+ }
+
+ ///
+ /// Compares the recorded executable fingerprint against the file on disk, by size and
+ /// last-write time only — the recorded hash is for after-the-fact comparison, not for
+ /// recomputation on every launch.
+ ///
+ /// The recorded fingerprint.
+ /// The report drifted fields are added to.
+ private static void CompareExecutable(LaunchReceiptExecutable recorded, LaunchReceiptDriftReport report)
+ {
+ if (string.IsNullOrEmpty(recorded.Path))
+ {
+ return;
+ }
+
+ var current = new FileInfo(recorded.Path);
+ if (!current.Exists)
+ {
+ report.DriftedFields.Add($"Executable no longer exists: {recorded.Path}");
+ return;
+ }
+
+ if (current.Length != recorded.SizeBytes)
+ {
+ report.DriftedFields.Add(
+ $"Executable size changed from {recorded.SizeBytes} to {current.Length} bytes: {recorded.Path}");
+ }
+
+ if (current.LastWriteTimeUtc != recorded.LastWriteUtc)
+ {
+ report.DriftedFields.Add(
+ $"Executable last-write time changed from {recorded.LastWriteUtc:O} to {current.LastWriteTimeUtc:O}: {recorded.Path}");
+ }
+ }
+
+ ///
+ /// Compares one recorded archive-root fingerprint against the directory on disk,
+ /// naming each added, removed or changed archive. Size and timestamp per archive make
+ /// an equal-size replacement visible, which a count and byte total alone cannot see.
+ ///
+ /// The environment variable that carried the root.
+ /// The recorded fingerprint.
+ /// The report drifted fields are added to.
+ private static void CompareArchiveRoot(string variableName, LaunchReceiptArchiveRoot recorded, LaunchReceiptDriftReport report)
+ {
+ if (!Directory.Exists(recorded.Path))
+ {
+ report.DriftedFields.Add($"Archive root for {variableName} no longer exists: {recorded.Path}");
+ return;
+ }
+
+ LaunchReceiptArchiveRoot current;
+ try
+ {
+ current = FingerprintArchiveRoot(recorded.Path);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ report.DriftedFields.Add($"Archive root for {variableName} could not be read: {recorded.Path} ({ex.Message})");
+ return;
+ }
+
+ var recordedByName = IndexArchivesByName(recorded.Archives);
+ var currentByName = IndexArchivesByName(current.Archives);
+
+ foreach (var (fileName, recordedArchive) in recordedByName)
+ {
+ if (!currentByName.TryGetValue(fileName, out var currentArchive))
+ {
+ report.DriftedFields.Add($"Archive removed from root for {variableName}: {recordedArchive.FileName}");
+ continue;
+ }
+
+ if (currentArchive.SizeBytes != recordedArchive.SizeBytes)
+ {
+ report.DriftedFields.Add(
+ $"Archive {recordedArchive.FileName} in root for {variableName} changed size from {recordedArchive.SizeBytes} to {currentArchive.SizeBytes} bytes");
+ }
+ else if (currentArchive.LastWriteUtc != recordedArchive.LastWriteUtc)
+ {
+ report.DriftedFields.Add(
+ $"Archive {recordedArchive.FileName} in root for {variableName} changed last-write time from {recordedArchive.LastWriteUtc:O} to {currentArchive.LastWriteUtc:O}");
+ }
+ }
+
+ foreach (var (fileName, currentArchive) in currentByName)
+ {
+ if (!recordedByName.ContainsKey(fileName))
+ {
+ report.DriftedFields.Add($"Archive added to root for {variableName}: {currentArchive.FileName}");
+ }
+ }
+ }
+
+ ///
+ /// Indexes archive fingerprints by file name, case-insensitively to match how the
+ /// archives themselves are enumerated.
+ ///
+ /// The fingerprints to index.
+ /// The index.
+ private static Dictionary IndexArchivesByName(IEnumerable archives)
+ {
+ var index = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var archive in archives)
+ {
+ index[archive.FileName] = archive;
+ }
+
+ return index;
+ }
+
+ ///
+ /// Fingerprints the executable, including the one hash recording pays for.
+ ///
+ /// The executable being launched.
+ /// A cancellation token to observe while waiting for the task to complete.
+ /// The fingerprint; a missing executable fingerprints as path only.
+ private async Task FingerprintExecutableAsync(string executablePath, CancellationToken cancellationToken)
+ {
+ var fingerprint = new LaunchReceiptExecutable { Path = executablePath };
+ var info = new FileInfo(executablePath);
+ if (!info.Exists)
+ {
+ return fingerprint;
+ }
+
+ fingerprint.SizeBytes = info.Length;
+ fingerprint.LastWriteUtc = info.LastWriteTimeUtc;
+ fingerprint.Sha256 = await hashProvider.ComputeFileHashAsync(executablePath, cancellationToken);
+ return fingerprint;
+ }
+}
diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/GameLaunchingModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/GameLaunchingModule.cs
index 67a2dbb5e..4104c2bc5 100644
--- a/GenHub/GenHub/Infrastructure/DependencyInjection/GameLaunchingModule.cs
+++ b/GenHub/GenHub/Infrastructure/DependencyInjection/GameLaunchingModule.cs
@@ -27,6 +27,9 @@ public static IServiceCollection AddLaunchingServices(this IServiceCollection se
// SteamLauncher for Steam integration - provisions files directly to game installation
services.AddScoped();
+ // Records a receipt per launch and cheaply revalidates it before subsequent launches
+ services.AddScoped();
+
return services;
}
}