diff --git a/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs b/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs
index 634cbeec7..dca319c98 100644
--- a/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs
+++ b/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs
@@ -29,4 +29,72 @@ public static class ReplayManagerConstants
/// Default file name for imported replays.
///
public const string DefaultImportedReplayFileName = "imported_replay.rep";
-}
\ No newline at end of file
+
+ ///
+ /// Name of the subdirectory for auto-saved replays.
+ ///
+ public const string SavedReplaysDirectoryName = "Saved";
+
+ ///
+ /// Default replay file name that gets overwritten by the game.
+ ///
+ public const string DefaultReplayFileName = "00000000.rep";
+
+ ///
+ /// Interval in milliseconds for checking file stability.
+ ///
+ public const int FileStabilityCheckIntervalMs = 2000;
+
+ ///
+ /// Number of consecutive stability checks required before considering file complete.
+ ///
+ public const int FileStabilityCheckCount = 3;
+
+ ///
+ /// Minimum file size in bytes to consider a replay valid (10 KB).
+ ///
+ public const long MinimumReplayFileSizeBytes = 10 * 1024;
+
+ ///
+ /// Magic bytes for replay file header validation.
+ ///
+ public const string ReplayMagicBytes = "GENREP";
+
+ ///
+ /// Date format for auto-saved replay filenames.
+ ///
+ public const string SavedReplayDateFormat = "yyyyMMdd_HHmmss";
+
+ ///
+ /// Grace period in seconds to allow stability checks to complete before stopping monitoring.
+ /// Calculated as: (FileStabilityCheckCount × FileStabilityCheckIntervalMs / 1000) + buffer
+ /// = (3 × 2000ms / 1000) + 8s buffer = 14s total
+ /// This accounts for the full stability check cycle plus OS flush delays on slow systems.
+ ///
+ public const int StopMonitoringGracePeriodSeconds = 14;
+
+ ///
+ /// Maximum number of retry attempts when saving a replay file encounters an I/O conflict.
+ ///
+ public const int SaveRetryMaxAttempts = 3;
+
+ ///
+ /// Maximum byte length for reading null-terminated strings from replay files.
+ ///
+ public const int MaxStringReadBytes = 4096;
+
+ ///
+ /// Maximum number of uniqueness suffix attempts when finding a non-colliding file path.
+ ///
+ public const int MaxUniquePathAttempts = 1000;
+
+ ///
+ /// Maximum number of players included in auto-save filename components.
+ ///
+ public const int MaxPlayersInSavedFileName = 4;
+
+ ///
+ /// Maximum filename length in bytes (Windows/most POSIX filesystems).
+ ///
+ public const int MaxFileNameLength = 255;
+}
diff --git a/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayMonitorService.cs b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayMonitorService.cs
new file mode 100644
index 000000000..8dc21bcb2
--- /dev/null
+++ b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayMonitorService.cs
@@ -0,0 +1,58 @@
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.Events;
+
+namespace GenHub.Core.Interfaces.Tools.ReplayManager;
+
+///
+/// Service for monitoring replay files and automatically saving them.
+///
+public interface IReplayMonitorService
+{
+ ///
+ /// Event raised when a replay file has been completed and saved.
+ ///
+ event EventHandler? ReplayCompleted;
+
+ ///
+ /// Starts monitoring for a specific profile.
+ ///
+ /// The profile ID.
+ /// The game type.
+ /// A task representing the asynchronous operation.
+ Task StartMonitoringAsync(string profileId, GameType gameType);
+
+ ///
+ /// Stops monitoring for a specific profile.
+ ///
+ /// The profile ID.
+ /// A task representing the asynchronous operation.
+ Task StopMonitoringAsync(string profileId);
+
+ ///
+ /// Stops all active monitoring.
+ ///
+ /// A task representing the asynchronous operation.
+ Task StopAllMonitoringAsync();
+
+ ///
+ /// Stops monitoring for a specific profile, verifying session identity.
+ ///
+ /// The profile ID.
+ /// If provided, only stops if the active session matches this ID.
+ /// A task representing the asynchronous operation.
+ Task StopMonitoringAsync(string profileId, string? sessionId);
+
+ ///
+ /// Gets a value indicating whether monitoring is active for a profile.
+ ///
+ /// The profile ID.
+ /// True if monitoring is active; otherwise, false.
+ bool IsMonitoring(string profileId);
+
+ ///
+ /// Gets the session ID for an active monitoring session.
+ ///
+ /// The profile ID.
+ /// The session ID, or null if not monitoring.
+ string? GetSessionId(string profileId);
+}
diff --git a/GenHub/GenHub.Core/Models/Events/ReplayCompletedEventArgs.cs b/GenHub/GenHub.Core/Models/Events/ReplayCompletedEventArgs.cs
new file mode 100644
index 000000000..b9506d804
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/Events/ReplayCompletedEventArgs.cs
@@ -0,0 +1,24 @@
+using GenHub.Core.Models.Tools.ReplayManager;
+
+namespace GenHub.Core.Models.Events;
+
+///
+/// Event arguments for replay completion.
+///
+public sealed class ReplayCompletedEventArgs : EventArgs
+{
+ ///
+ /// Gets the profile ID.
+ ///
+ public required string ProfileId { get; init; }
+
+ ///
+ /// Gets the saved replay file path.
+ ///
+ public required string SavedFilePath { get; init; }
+
+ ///
+ /// Gets the replay metadata.
+ ///
+ public required ReplayMetadata Metadata { get; init; }
+}
diff --git a/GenHub/GenHub.Core/Models/Events/ReplayFileCompletedEventArgs.cs b/GenHub/GenHub.Core/Models/Events/ReplayFileCompletedEventArgs.cs
new file mode 100644
index 000000000..c8124e988
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/Events/ReplayFileCompletedEventArgs.cs
@@ -0,0 +1,12 @@
+namespace GenHub.Core.Models.Events;
+
+///
+/// Event arguments for replay file completion.
+///
+public sealed class ReplayFileCompletedEventArgs : EventArgs
+{
+ ///
+ /// Gets the completed file path.
+ ///
+ public required string FilePath { get; init; }
+}
diff --git a/GenHub/GenHub.Core/Models/GameProfile/CreateProfileRequest.cs b/GenHub/GenHub.Core/Models/GameProfile/CreateProfileRequest.cs
index 0b1a35291..8b591f547 100644
--- a/GenHub/GenHub.Core/Models/GameProfile/CreateProfileRequest.cs
+++ b/GenHub/GenHub.Core/Models/GameProfile/CreateProfileRequest.cs
@@ -53,6 +53,9 @@ public class CreateProfileRequest
/// Gets or sets the command line arguments to pass to the game executable.
public string? CommandLineArguments { get; set; }
+ /// Gets or sets a value indicating whether to automatically save replays for this profile.
+ public bool AutoSaveReplays { get; set; }
+
/// Gets or sets the IP address for GameSpy/Networking services.
public string? GameSpyIPAddress { get; set; }
diff --git a/GenHub/GenHub.Core/Models/GameProfile/GameProfile.cs b/GenHub/GenHub.Core/Models/GameProfile/GameProfile.cs
index 3885f73bd..d143f1f2f 100644
--- a/GenHub/GenHub.Core/Models/GameProfile/GameProfile.cs
+++ b/GenHub/GenHub.Core/Models/GameProfile/GameProfile.cs
@@ -211,6 +211,11 @@ public class GameProfile : IGameProfile
/// Gets or sets a value indicating whether props are shown.
public bool? VideoShowProps { get; set; }
+ // ===== GenHub Features =====
+
+ /// Gets or sets a value indicating whether to automatically save replays for this profile.
+ public bool AutoSaveReplays { get; set; }
+
// ===== TheSuperHackers Client Settings =====
/// Gets or sets a value indicating whether to archive replays automatically (TSH).
diff --git a/GenHub/GenHub.Core/Models/GameProfile/UpdateProfileRequest.cs b/GenHub/GenHub.Core/Models/GameProfile/UpdateProfileRequest.cs
index eaceeaf0f..e4d3e68a4 100644
--- a/GenHub/GenHub.Core/Models/GameProfile/UpdateProfileRequest.cs
+++ b/GenHub/GenHub.Core/Models/GameProfile/UpdateProfileRequest.cs
@@ -90,6 +90,11 @@ public class UpdateProfileRequest
///
public string? CommandLineArguments { get; set; }
+ ///
+ /// Gets or sets a value indicating whether to automatically save replays for this profile.
+ ///
+ public bool? AutoSaveReplays { get; set; }
+
///
/// Gets or sets the video resolution width for this profile.
///
diff --git a/GenHub/GenHub.Core/Models/Tools/ReplayManager/PlayerInfo.cs b/GenHub/GenHub.Core/Models/Tools/ReplayManager/PlayerInfo.cs
new file mode 100644
index 000000000..ef74e2e73
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/Tools/ReplayManager/PlayerInfo.cs
@@ -0,0 +1,42 @@
+namespace GenHub.Core.Models.Tools.ReplayManager;
+
+///
+/// Information about a player in a replay.
+///
+public sealed class PlayerInfo
+{
+ ///
+ /// Gets the player name.
+ ///
+ public required string Name { get; init; }
+
+ ///
+ /// Gets the player type (Human or AI).
+ ///
+ public required PlayerType Type { get; init; }
+
+ ///
+ /// Gets the faction/side (e.g., USA, China, GLA).
+ ///
+ public string? Faction { get; init; }
+
+ ///
+ /// Gets the team number.
+ ///
+ public int? Team { get; init; }
+
+ ///
+ /// Gets the player color.
+ ///
+ public string? Color { get; init; }
+
+ ///
+ /// Gets the AI difficulty (for AI players).
+ ///
+ public string? AiDifficulty { get; init; }
+
+ ///
+ /// Gets the starting position/slot number.
+ ///
+ public int? StartPosition { get; init; }
+}
diff --git a/GenHub/GenHub.Core/Models/Tools/ReplayManager/PlayerType.cs b/GenHub/GenHub.Core/Models/Tools/ReplayManager/PlayerType.cs
new file mode 100644
index 000000000..274ece5c8
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/Tools/ReplayManager/PlayerType.cs
@@ -0,0 +1,22 @@
+namespace GenHub.Core.Models.Tools.ReplayManager;
+
+///
+/// Player type enumeration.
+///
+public enum PlayerType
+{
+ ///
+ /// Human player.
+ ///
+ Human,
+
+ ///
+ /// AI/Computer player.
+ ///
+ Computer,
+
+ ///
+ /// Observer/spectator.
+ ///
+ Observer,
+}
diff --git a/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplayMetadata.cs b/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplayMetadata.cs
index 829018017..1f68551fc 100644
--- a/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplayMetadata.cs
+++ b/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplayMetadata.cs
@@ -1,7 +1,9 @@
+using GenHub.Core.Models.Enums;
+
namespace GenHub.Core.Models.Tools.ReplayManager;
///
-/// Placeholder for future replay parsing feature.
+/// Metadata extracted from a replay file.
///
public sealed class ReplayMetadata
{
@@ -11,9 +13,9 @@ public sealed class ReplayMetadata
public string? MapName { get; init; }
///
- /// Gets the list of players.
+ /// Gets the list of player information.
///
- public IReadOnlyList? Players { get; init; }
+ public IReadOnlyList? Players { get; init; }
///
/// Gets the game duration.
@@ -24,4 +26,54 @@ public sealed class ReplayMetadata
/// Gets the date the game was played.
///
public DateTime? GameDate { get; init; }
-}
\ No newline at end of file
+
+ ///
+ /// Gets the game type (Generals or Zero Hour).
+ ///
+ public GameType? GameType { get; init; }
+
+ ///
+ /// Gets the file size in bytes.
+ ///
+ public long FileSizeBytes { get; init; }
+
+ ///
+ /// Gets a value indicating whether the replay was successfully parsed.
+ ///
+ public bool IsParsed { get; init; }
+
+ ///
+ /// Gets the original file path.
+ ///
+ public string? OriginalFilePath { get; init; }
+
+ ///
+ /// Gets the game mode (e.g., Skirmish, Online).
+ ///
+ public string? GameMode { get; init; }
+
+ ///
+ /// Gets the game version string.
+ ///
+ public string? GameVersion { get; init; }
+
+ ///
+ /// Gets the build date string.
+ ///
+ public string? BuildDate { get; init; }
+
+ ///
+ /// Gets the starting credits for the match.
+ ///
+ public int? StartingCredits { get; init; }
+
+ ///
+ /// Gets whether fog of war was enabled.
+ ///
+ public bool? FogOfWar { get; init; }
+
+ ///
+ /// Gets the game speed setting.
+ ///
+ public string? GameSpeed { get; init; }
+}
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 c3a561a9e..88e86bb9b 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs
@@ -6,6 +6,7 @@
using GenHub.Core.Interfaces.Launching;
using GenHub.Core.Interfaces.Manifest;
using GenHub.Core.Interfaces.Storage;
+using GenHub.Core.Interfaces.Tools.ReplayManager;
using GenHub.Core.Interfaces.UserData;
using GenHub.Core.Interfaces.Workspace;
using GenHub.Core.Models.Enums;
@@ -44,6 +45,7 @@ public class GameLauncherTests
private readonly Mock _storageLocationServiceMock = new();
private readonly Mock _profileContentLinkerMock = new();
private readonly Mock _steamLauncherMock = new();
+ private readonly Mock _replayMonitorServiceMock = new();
private readonly GameLauncher _gameLauncher;
///
@@ -131,7 +133,8 @@ public GameLauncherTests()
_gameSettingsServiceMock.Object,
_profileContentLinkerMock.Object,
_steamLauncherMock.Object,
- _configurationProviderServiceMock.Object);
+ _configurationProviderServiceMock.Object,
+ _replayMonitorServiceMock.Object);
}
///
@@ -798,6 +801,76 @@ public async Task LaunchProfileAsync_WithoutProfileSettings_ShouldStillSaveOptio
Times.Once);
}
+ ///
+ /// Verifies that replay monitoring is started when AutoSaveReplays is enabled.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task LaunchProfileAsync_WithAutoSaveReplaysEnabled_ShouldStartMonitoring()
+ {
+ // Arrange
+ var profile = CreateTestProfile();
+ profile.AutoSaveReplays = true;
+ var workspaceInfo = new WorkspaceInfo { Id = profile.Id, WorkspacePath = @"C:\workspace", ExecutablePath = @"C:\workspace\generals.exe" };
+ 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));
+
+ // Act
+ var result = await _gameLauncher.LaunchProfileAsync(profile.Id);
+
+ // Assert
+ Assert.True(result.Success);
+ _replayMonitorServiceMock.Verify(
+ x => x.StartMonitoringAsync(profile.Id, GameType.Generals),
+ Times.Once);
+ }
+
+ ///
+ /// Verifies that replay monitoring is not started when AutoSaveReplays is disabled.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task LaunchProfileAsync_WithAutoSaveReplaysDisabled_ShouldNotStartMonitoring()
+ {
+ // Arrange
+ var profile = CreateTestProfile();
+ profile.AutoSaveReplays = false;
+ var workspaceInfo = new WorkspaceInfo { Id = profile.Id, WorkspacePath = @"C:\workspace", ExecutablePath = @"C:\workspace\generals.exe" };
+ 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));
+
+ // Act
+ var result = await _gameLauncher.LaunchProfileAsync(profile.Id);
+
+ // Assert
+ Assert.True(result.Success);
+ _replayMonitorServiceMock.Verify(
+ x => x.StartMonitoringAsync(It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
///
/// Creates a test with required members set.
///
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/SharedViewModelModuleTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/SharedViewModelModuleTests.cs
index 78f1ca7a9..0556d90b5 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/SharedViewModelModuleTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/DependencyInjection/SharedViewModelModuleTests.cs
@@ -124,6 +124,9 @@ public void AllViewModels_Registered()
services.AddUserDataServices();
services.AddLaunchingServices();
services.AddToolsServices();
+ services.AddUploadThingServices();
+ services.AddReplayManagerServices();
+ services.AddMapManager();
services.AddSharedViewModelModule();
// Register IManifestIdService
diff --git a/GenHub/GenHub/Features/GameProfiles/Services/GameProfileManager.cs b/GenHub/GenHub/Features/GameProfiles/Services/GameProfileManager.cs
index 6d9145e38..367ec0bb3 100644
--- a/GenHub/GenHub/Features/GameProfiles/Services/GameProfileManager.cs
+++ b/GenHub/GenHub/Features/GameProfiles/Services/GameProfileManager.cs
@@ -130,6 +130,7 @@ public async Task> CreateProfileAsync(Create
IconPath = request.IconPath,
CoverPath = request.CoverPath,
CommandLineArguments = request.CommandLineArguments ?? string.Empty,
+ AutoSaveReplays = request.AutoSaveReplays,
GameSpyIPAddress = request.GameSpyIPAddress,
};
@@ -213,6 +214,7 @@ public async Task> UpdateProfileAsync(string
profile.GameInstallationId = request.GameInstallationId ?? profile.GameInstallationId;
profile.ToolContentId = request.ToolContentId ?? profile.ToolContentId;
profile.CommandLineArguments = request.CommandLineArguments ?? profile.CommandLineArguments;
+ profile.AutoSaveReplays = request.AutoSaveReplays ?? profile.AutoSaveReplays;
// Detect if content changed and invalidate workspace if needed
bool contentChanged = false;
diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs
index 3e8738511..612d2ab49 100644
--- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs
+++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs
@@ -368,6 +368,7 @@ private async Task SaveAsync()
WorkspaceStrategy = SelectedWorkspaceStrategy,
EnabledContentIds = enabledContentIds,
CommandLineArguments = CommandLineArguments,
+ AutoSaveReplays = AutoSaveReplays,
IconPath = IconPath,
CoverPath = CoverPath,
ThemeColor = ColorValue,
@@ -413,6 +414,7 @@ private async Task SaveAsync()
: null,
EnabledContentIds = enabledContentIds,
CommandLineArguments = CommandLineArguments,
+ AutoSaveReplays = AutoSaveReplays,
IconPath = IconPath,
CoverPath = CoverPath,
};
diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs
index 335b171c4..e6fe67b70 100644
--- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs
+++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs
@@ -41,6 +41,8 @@ public virtual async Task InitializeForNewProfileAsync()
ColorValue = "#1976D2";
SelectedWorkspaceStrategy = GetDefaultWorkspaceStrategy();
SelectedContentType = ContentType.GameClient;
+ AutoSaveReplays = false; // Reset to prevent stale state leakage
+ IsToolProfile = false;
EnabledContent.Clear();
@@ -158,6 +160,8 @@ public virtual async Task InitializeForProfileAsync(string profileId)
SelectedWorkspaceStrategy = profile.WorkspaceStrategy ?? GetDefaultWorkspaceStrategy();
_originalWorkspaceStrategy = profile.WorkspaceStrategy ?? GetDefaultWorkspaceStrategy();
CommandLineArguments = profile.CommandLineArguments ?? string.Empty;
+ AutoSaveReplays = profile.AutoSaveReplays;
+ IsToolProfile = profile.IsToolProfile;
LoadAvailableIconsAndCovers(profile.GameClient?.GameType.ToString() ?? "ZeroHour");
GameTypeFilter = profile.GameClient?.GameType ?? Core.Models.Enums.GameType.ZeroHour;
diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Properties.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Properties.cs
index aba8bbfe4..e89699f9d 100644
--- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Properties.cs
+++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Properties.cs
@@ -114,6 +114,30 @@ public ContentType SelectedContentType
[ObservableProperty]
private string _commandLineArguments = string.Empty;
+ [ObservableProperty]
+ private bool _autoSaveReplays;
+
+ [ObservableProperty]
+ private ObservableCollection _availableCovers = [];
+
+ [ObservableProperty]
+ private ProfileInfoItem? _selectedCover;
+
+ [ObservableProperty]
+ private ObservableCollection _availableGameClients = [];
+
+ [ObservableProperty]
+ private ProfileInfoItem? _selectedClient;
+
+ [ObservableProperty]
+ private string _formattedSize = string.Empty;
+
+ [ObservableProperty]
+ private string _buildDate = string.Empty;
+
+ [ObservableProperty]
+ private string _sourceType = string.Empty;
+
[ObservableProperty]
private string _shortcutPath = string.Empty;
@@ -197,4 +221,7 @@ public ContentType SelectedContentType
[ObservableProperty]
private GameType _selectedLocalGameType = Core.Models.Enums.GameType.ZeroHour;
+
+ [ObservableProperty]
+ private bool _isToolProfile;
}
diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml
index ecb661d50..2785cfb26 100644
--- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml
+++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml
@@ -198,6 +198,12 @@
+
+
+
+
+
+
diff --git a/GenHub/GenHub/Features/Info/ViewModels/DemoViewModelFactory.cs b/GenHub/GenHub/Features/Info/ViewModels/DemoViewModelFactory.cs
index 338ac29f5..b446e33ab 100644
--- a/GenHub/GenHub/Features/Info/ViewModels/DemoViewModelFactory.cs
+++ b/GenHub/GenHub/Features/Info/ViewModels/DemoViewModelFactory.cs
@@ -11,6 +11,7 @@
using GenHub.Features.GameProfiles.ViewModels;
using GenHub.Features.Info.Services;
using GenHub.Features.Tools.MapManager.ViewModels;
+using GenHub.Features.Tools.ReplayManager.Services;
using GenHub.Features.Tools.ReplayManager.ViewModels;
using GenHub.Infrastructure.Imaging;
using Microsoft.Extensions.Logging;
@@ -224,12 +225,15 @@ public static ReplayManagerViewModel CreateDemoReplayManager(INotificationServic
// Use the provided notification service or fall back to mock
var mockNotify = notificationService ?? new MockNotificationService();
var mockLogger = new MockLogger();
+ var mockParserLogger = new MockLogger();
+ var mockParser = new ReplayParserService(mockParserLogger);
var vm = new ReplayManagerViewModel(
mockDir,
mockImport,
mockExport,
mockHistory,
+ mockParser,
mockNotify,
mockLogger);
@@ -246,6 +250,7 @@ public static ReplayManagerViewModel CreateDemoReplayManager(INotificationServic
new MockReplayImportService(),
new MockReplayExportService(),
new MockUploadHistoryService(),
+ new ReplayParserService(new MockLogger()),
new MockNotificationService(),
new MockLogger());
}
diff --git a/GenHub/GenHub/Features/Launching/GameLauncher.cs b/GenHub/GenHub/Features/Launching/GameLauncher.cs
index d0a7e017d..81b16fbd1 100644
--- a/GenHub/GenHub/Features/Launching/GameLauncher.cs
+++ b/GenHub/GenHub/Features/Launching/GameLauncher.cs
@@ -18,6 +18,7 @@
using GenHub.Core.Interfaces.Launching;
using GenHub.Core.Interfaces.Manifest;
using GenHub.Core.Interfaces.Storage;
+using GenHub.Core.Interfaces.Tools.ReplayManager;
using GenHub.Core.Interfaces.UserData;
using GenHub.Core.Interfaces.Workspace;
using GenHub.Core.Models.Enums;
@@ -48,7 +49,8 @@ public class GameLauncher(
IGameSettingsService gameSettingsService,
IProfileContentLinker profileContentLinker,
ISteamLauncher steamLauncher,
- IConfigurationProviderService configurationProvider) : IGameLauncher
+ IConfigurationProviderService configurationProvider,
+ IReplayMonitorService replayMonitorService) : IGameLauncher
{
private static readonly ConcurrentDictionary _profileLaunchLocks = new();
private static readonly SearchValues InvalidArgChars = SearchValues.Create(";|&\n\r`$%");
@@ -1029,6 +1031,20 @@ private async Task> LaunchProfileAsync(Gam
logger.LogDebug("[GameLauncher] Updating launch registry with real process info");
await launchRegistry.RegisterLaunchAsync(launchInfo);
+ // Start replay monitoring if enabled for this profile
+ if (profile.AutoSaveReplays && !profile.IsToolProfile && profile.GameClient != null)
+ {
+ try
+ {
+ await replayMonitorService.StartMonitoringAsync(profile.Id, profile.GameClient.GameType);
+ logger.LogInformation("[GameLauncher] Started replay monitoring for profile {ProfileId}", profile.Id);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "[GameLauncher] Failed to start replay monitoring for profile {ProfileId}", profile.Id);
+ }
+ }
+
// Report completion
progress?.Report(new LaunchProgress { Phase = LaunchPhase.Running, PercentComplete = 100 });
logger.LogInformation("[GameLauncher] === Launch completed successfully for profile {ProfileId} ===", profile.Id);
diff --git a/GenHub/GenHub/Features/Launching/LaunchRegistry.cs b/GenHub/GenHub/Features/Launching/LaunchRegistry.cs
index 4ab36c1aa..95363c95d 100644
--- a/GenHub/GenHub/Features/Launching/LaunchRegistry.cs
+++ b/GenHub/GenHub/Features/Launching/LaunchRegistry.cs
@@ -4,8 +4,10 @@
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
+using GenHub.Core.Constants;
using GenHub.Core.Interfaces.GameProfiles;
using GenHub.Core.Interfaces.Launching;
+using GenHub.Core.Interfaces.Tools.ReplayManager;
using GenHub.Core.Interfaces.Workspace;
using GenHub.Core.Models.GameProfile;
using Microsoft.Extensions.Logging;
@@ -21,6 +23,7 @@ public class LaunchRegistry : ILaunchRegistry
private readonly ILogger _logger;
private readonly IWorkspaceManager? _workspaceManager;
private readonly IGameProcessManager? _processManager;
+ private readonly IReplayMonitorService? _replayMonitorService;
private readonly ConcurrentDictionary _activeLaunches = new();
///
@@ -29,14 +32,17 @@ public class LaunchRegistry : ILaunchRegistry
/// The logger instance.
/// Optional workspace manager for cleanup.
/// Optional process manager for tracking game processes.
+ /// Optional replay monitor service for auto-save cleanup.
public LaunchRegistry(
ILogger logger,
IWorkspaceManager? workspaceManager = null,
- IGameProcessManager? processManager = null)
+ IGameProcessManager? processManager = null,
+ IReplayMonitorService? replayMonitorService = null)
{
_logger = logger;
_workspaceManager = workspaceManager;
_processManager = processManager;
+ _replayMonitorService = replayMonitorService;
if (_processManager != null)
{
@@ -134,6 +140,33 @@ private void OnProcessExited(object? sender, Core.Models.Events.GameProcessExite
}
launch.ProcessInfo.IsRunning = false;
+
+ // Stop replay monitoring for this profile after a grace period
+ // The ReplayMonitor needs ~6s (3 checks × 2s) after the file stops changing
+ // Add buffer time to account for OS flush delays and slow systems
+ // Grace period = stability checks (6s) + buffer (8s) = 14s total
+ // Capture session ID to avoid canceling a newer session if profile is relaunched
+ if (_replayMonitorService != null && _replayMonitorService.IsMonitoring(launch.ProfileId))
+ {
+ var sessionId = _replayMonitorService.GetSessionId(launch.ProfileId);
+ if (sessionId != null)
+ {
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ // Extended grace period to allow for late file writes and full stability checks
+ await Task.Delay(TimeSpan.FromSeconds(ReplayManagerConstants.StopMonitoringGracePeriodSeconds));
+ await _replayMonitorService.StopMonitoringAsync(launch.ProfileId, sessionId);
+ _logger.LogInformation("[LaunchRegistry] Stopped replay monitoring for profile {ProfileId}", launch.ProfileId);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "[LaunchRegistry] Error stopping replay monitoring for profile {ProfileId}", launch.ProfileId);
+ }
+ });
+ }
+ }
}
}
diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Converters/ColorNameToBrushConverter.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Converters/ColorNameToBrushConverter.cs
new file mode 100644
index 000000000..ff95679d2
--- /dev/null
+++ b/GenHub/GenHub/Features/Tools/ReplayManager/Converters/ColorNameToBrushConverter.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Globalization;
+using Avalonia.Data.Converters;
+using Avalonia.Media;
+
+namespace GenHub.Features.Tools.ReplayManager.Converters;
+
+///
+/// Converts color name string to a color brush.
+///
+public class ColorNameToBrushConverter : IValueConverter
+{
+ ///
+ /// Converts a color name string to a SolidColorBrush.
+ ///
+ /// The color name string to convert.
+ /// The target type (not used).
+ /// Optional parameter (not used).
+ /// Culture information (not used).
+ /// A SolidColorBrush representing the named color.
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is string colorName)
+ {
+ return colorName.ToLowerInvariant() switch
+ {
+ "orange" => new SolidColorBrush(Color.Parse("#FF9800")),
+ "pink" => new SolidColorBrush(Color.Parse("#E91E63")),
+ "blue" => new SolidColorBrush(Color.Parse("#2196F3")),
+ "green" => new SolidColorBrush(Color.Parse("#4CAF50")),
+ "red" => new SolidColorBrush(Color.Parse("#F44336")),
+ "yellow" => new SolidColorBrush(Color.Parse("#FFEB3B")),
+ "purple" => new SolidColorBrush(Color.Parse("#9C27B0")),
+ "teal" => new SolidColorBrush(Color.Parse("#009688")),
+ _ => new SolidColorBrush(Colors.Gray),
+ };
+ }
+
+ return new SolidColorBrush(Colors.Gray);
+ }
+
+ ///
+ /// Converts back from brush to color name (not implemented).
+ ///
+ /// The value to convert back.
+ /// The target type.
+ /// Optional parameter.
+ /// Culture information.
+ /// Not implemented.
+ /// This conversion is not supported.
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Converters/PlayerTypeToColorConverter.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Converters/PlayerTypeToColorConverter.cs
new file mode 100644
index 000000000..7789b2970
--- /dev/null
+++ b/GenHub/GenHub/Features/Tools/ReplayManager/Converters/PlayerTypeToColorConverter.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Globalization;
+using Avalonia.Data.Converters;
+using Avalonia.Media;
+using GenHub.Core.Models.Tools.ReplayManager;
+
+namespace GenHub.Features.Tools.ReplayManager.Converters;
+
+///
+/// Converts PlayerType enum to a color brush.
+///
+public class PlayerTypeToColorConverter : IValueConverter
+{
+ ///
+ /// Converts a PlayerType value to a SolidColorBrush.
+ ///
+ /// The PlayerType value to convert.
+ /// The target type (not used).
+ /// Optional parameter (not used).
+ /// Culture information (not used).
+ /// A SolidColorBrush representing the player type color.
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is PlayerType playerType)
+ {
+ return playerType switch
+ {
+ PlayerType.Human => new SolidColorBrush(Color.Parse("#2196F3")),
+ PlayerType.Computer => new SolidColorBrush(Color.Parse("#FF9800")),
+ PlayerType.Observer => new SolidColorBrush(Color.Parse("#9E9E9E")),
+ _ => new SolidColorBrush(Colors.Gray),
+ };
+ }
+
+ return new SolidColorBrush(Colors.Gray);
+ }
+
+ ///
+ /// Converts back from brush to PlayerType (not implemented).
+ ///
+ /// The value to convert back.
+ /// The target type.
+ /// Optional parameter.
+ /// Culture information.
+ /// Not implemented.
+ /// This conversion is not supported.
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Converters/PlayerTypeToIconConverter.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Converters/PlayerTypeToIconConverter.cs
new file mode 100644
index 000000000..962d30297
--- /dev/null
+++ b/GenHub/GenHub/Features/Tools/ReplayManager/Converters/PlayerTypeToIconConverter.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Globalization;
+using Avalonia.Data.Converters;
+using GenHub.Core.Models.Tools.ReplayManager;
+
+namespace GenHub.Features.Tools.ReplayManager.Converters;
+
+///
+/// Converts PlayerType enum to an icon/emoji.
+///
+public class PlayerTypeToIconConverter : IValueConverter
+{
+ ///
+ /// Converts a PlayerType value to an icon string.
+ ///
+ /// The PlayerType value to convert.
+ /// The target type (not used).
+ /// Optional parameter (not used).
+ /// Culture information (not used).
+ /// An icon string representing the player type.
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is PlayerType playerType)
+ {
+ return playerType switch
+ {
+ PlayerType.Human => "👤",
+ PlayerType.Computer => "🤖",
+ PlayerType.Observer => "👁️",
+ _ => "?",
+ };
+ }
+
+ return "?";
+ }
+
+ ///
+ /// Converts back from icon to PlayerType (not implemented).
+ ///
+ /// The value to convert back.
+ /// The target type.
+ /// Optional parameter.
+ /// Culture information.
+ /// Not implemented.
+ /// This conversion is not supported.
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitor.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitor.cs
new file mode 100644
index 000000000..8231be19b
--- /dev/null
+++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitor.cs
@@ -0,0 +1,297 @@
+using System;
+using System.IO;
+using System.Threading;
+using GenHub.Core.Constants;
+using GenHub.Core.Models.Events;
+using Microsoft.Extensions.Logging;
+
+namespace GenHub.Features.Tools.ReplayManager.Services;
+
+///
+/// Monitors a replay file for completion using FileSystemWatcher and stability checks.
+///
+/// The logger instance.
+public sealed class ReplayMonitor(ILogger logger) : IDisposable
+{
+ private readonly object _lock = new();
+ private FileSystemWatcher? _watcher;
+ private Timer? _stabilityTimer;
+ private long _lastFileSize;
+ private int _stabilityCheckCount;
+ private bool _isDisposed;
+
+ ///
+ /// Event raised when the replay file is complete and stable.
+ ///
+ public event EventHandler? FileCompleted;
+
+ ///
+ /// Gets the file path being monitored.
+ ///
+ public string? FilePath { get; private set; }
+
+ ///
+ /// Gets a value indicating whether monitoring is active.
+ ///
+ public bool IsMonitoring { get; private set; }
+
+ ///
+ /// Starts monitoring a replay file.
+ ///
+ /// The file path to monitor.
+ public void StartMonitoring(string filePath)
+ {
+ lock (_lock)
+ {
+ if (_isDisposed)
+ {
+ logger.LogWarning("Cannot start monitoring on a disposed ReplayMonitor");
+ return;
+ }
+
+ if (IsMonitoring)
+ {
+ logger.LogWarning("Already monitoring a file: {FilePath}", FilePath);
+ return;
+ }
+
+ FilePath = filePath;
+ _lastFileSize = 0;
+ _stabilityCheckCount = 0;
+
+ var directory = Path.GetDirectoryName(filePath);
+ var fileName = Path.GetFileName(filePath);
+
+ if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(fileName))
+ {
+ logger.LogError("Invalid file path: {FilePath}", filePath);
+ return;
+ }
+
+ if (!Directory.Exists(directory))
+ {
+ logger.LogWarning("Directory does not exist, creating: {Directory}", directory);
+ Directory.CreateDirectory(directory);
+ }
+
+ _watcher = new FileSystemWatcher(directory, fileName)
+ {
+ NotifyFilter = NotifyFilters.Size,
+ };
+
+ _watcher.Changed += OnFileChanged;
+ _watcher.Error += OnWatcherError;
+
+ try
+ {
+ _watcher.EnableRaisingEvents = true;
+ IsMonitoring = true;
+ logger.LogInformation("Started monitoring replay file: {FilePath}", filePath);
+ }
+ catch
+ {
+ // Clean up watcher if EnableRaisingEvents fails
+ _watcher.Changed -= OnFileChanged;
+ _watcher.Error -= OnWatcherError;
+ _watcher.Dispose();
+ _watcher = null;
+ throw;
+ }
+
+ // Don't start stability checks on pre-existing files
+ // Wait for FileSystemWatcher to detect actual changes from the game
+ }
+ }
+
+ ///
+ /// Stops monitoring.
+ ///
+ public void StopMonitoring()
+ {
+ lock (_lock)
+ {
+ // Always clean up the watcher regardless of IsMonitoring state
+ if (_watcher != null)
+ {
+ _watcher.Changed -= OnFileChanged;
+ _watcher.Error -= OnWatcherError;
+ _watcher.Dispose();
+ _watcher = null;
+ }
+
+ if (!IsMonitoring)
+ {
+ return;
+ }
+
+ IsMonitoring = false;
+
+ _stabilityTimer?.Dispose();
+ _stabilityTimer = null;
+
+ logger.LogInformation("Stopped monitoring replay file: {FilePath}", FilePath);
+
+ FilePath = null;
+ _lastFileSize = 0;
+ _stabilityCheckCount = 0;
+ }
+ }
+
+ ///
+ public void Dispose()
+ {
+ lock (_lock)
+ {
+ if (_isDisposed)
+ {
+ return;
+ }
+
+ StopMonitoring();
+ _isDisposed = true;
+ }
+ }
+
+ private void OnFileChanged(object sender, FileSystemEventArgs e)
+ {
+ lock (_lock)
+ {
+ if (!IsMonitoring || !string.Equals(FilePath, e.FullPath, StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ try
+ {
+ var fileInfo = new FileInfo(e.FullPath);
+ if (!fileInfo.Exists)
+ {
+ return;
+ }
+
+ var currentSize = fileInfo.Length;
+
+ if (currentSize != _lastFileSize)
+ {
+ _lastFileSize = currentSize;
+ _stabilityCheckCount = 0;
+ StartStabilityCheck();
+
+ logger.LogDebug("Replay file changed: {FilePath} ({Size} bytes)", e.FullPath, currentSize);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error handling file change: {FilePath}", e.FullPath);
+ }
+ }
+ }
+
+ private void OnWatcherError(object sender, ErrorEventArgs e)
+ {
+ lock (_lock)
+ {
+ logger.LogError(e.GetException(), "FileSystemWatcher error for: {FilePath}", FilePath);
+
+ if (!IsMonitoring || _watcher == null)
+ {
+ return;
+ }
+
+ // Attempt to restart the watcher to recover from buffer overflow or similar errors
+ try
+ {
+ _watcher.EnableRaisingEvents = false;
+ _watcher.EnableRaisingEvents = true;
+ logger.LogInformation("Restarted FileSystemWatcher after error for: {FilePath}", FilePath);
+ }
+ catch (Exception restartEx)
+ {
+ logger.LogError(restartEx, "Failed to restart FileSystemWatcher for: {FilePath}", FilePath);
+
+ // Transition to a consistent stopped state so the grace-period cleanup or a caller retry can take over
+ StopMonitoring();
+ }
+ }
+ }
+
+ private void StartStabilityCheck()
+ {
+ _stabilityTimer?.Dispose();
+
+ _stabilityTimer = new Timer(
+ CheckFileStability,
+ null,
+ ReplayManagerConstants.FileStabilityCheckIntervalMs,
+ Timeout.Infinite);
+ }
+
+ private void CheckFileStability(object? state)
+ {
+ string? completedFilePath = null;
+
+ lock (_lock)
+ {
+ if (!IsMonitoring || string.IsNullOrEmpty(FilePath))
+ {
+ return;
+ }
+
+ try
+ {
+ var fileInfo = new FileInfo(FilePath);
+ if (!fileInfo.Exists)
+ {
+ return;
+ }
+
+ var currentSize = fileInfo.Length;
+
+ if (currentSize == _lastFileSize && currentSize >= ReplayManagerConstants.MinimumReplayFileSizeBytes)
+ {
+ _stabilityCheckCount++;
+
+ logger.LogDebug(
+ "Replay file stable check {Count}/{Required}: {FilePath} ({Size} bytes)",
+ _stabilityCheckCount,
+ ReplayManagerConstants.FileStabilityCheckCount,
+ FilePath,
+ currentSize);
+
+ if (_stabilityCheckCount >= ReplayManagerConstants.FileStabilityCheckCount)
+ {
+ logger.LogInformation("Replay file completed: {FilePath} ({Size} bytes)", FilePath, _lastFileSize);
+ completedFilePath = FilePath;
+ StopMonitoring();
+ }
+ else
+ {
+ _stabilityTimer?.Change(ReplayManagerConstants.FileStabilityCheckIntervalMs, Timeout.Infinite);
+ }
+ }
+ else
+ {
+ if (currentSize != _lastFileSize)
+ {
+ _lastFileSize = currentSize;
+ _stabilityCheckCount = 0;
+ _stabilityTimer?.Change(ReplayManagerConstants.FileStabilityCheckIntervalMs, Timeout.Infinite);
+ }
+
+ // If size is unchanged but below minimum, let the FileSystemWatcher drive the next check
+ // instead of spinning the timer indefinitely.
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error checking file stability: {FilePath}", FilePath);
+ }
+ }
+
+ // Raise event outside _lock to avoid blocking the monitor while handlers execute
+ if (completedFilePath != null)
+ {
+ FileCompleted?.Invoke(this, new ReplayFileCompletedEventArgs { FilePath = completedFilePath });
+ }
+ }
+}
diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitoringService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitoringService.cs
new file mode 100644
index 000000000..e811a3c66
--- /dev/null
+++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitoringService.cs
@@ -0,0 +1,275 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Interfaces.Tools.ReplayManager;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.Events;
+using Microsoft.Extensions.Logging;
+
+namespace GenHub.Features.Tools.ReplayManager.Services;
+
+///
+/// Service for orchestrating replay monitoring and automatic saving.
+///
+/// The directory service.
+/// The save service.
+/// The logger factory.
+/// The logger instance.
+public sealed class ReplayMonitoringService(
+ IReplayDirectoryService directoryService,
+ ReplaySaveService saveService,
+ ILoggerFactory loggerFactory,
+ ILogger logger) : IReplayMonitorService, IDisposable
+{
+ private readonly ConcurrentDictionary _activeSessions = new();
+ private volatile bool _isDisposed;
+
+ ///
+ public event EventHandler? ReplayCompleted;
+
+ ///
+ public Task StartMonitoringAsync(string profileId, GameType gameType)
+ {
+ ObjectDisposedException.ThrowIf(_isDisposed, this);
+ ArgumentException.ThrowIfNullOrWhiteSpace(profileId);
+
+ // Stop existing monitoring for this profile
+ StopMonitoringInternal(profileId);
+
+ var replayDirectory = directoryService.GetReplayDirectory(gameType);
+ var replayFilePath = Path.Combine(replayDirectory, ReplayManagerConstants.DefaultReplayFileName);
+
+ var monitor = new ReplayMonitor(loggerFactory.CreateLogger());
+ var sessionId = Guid.NewGuid().ToString();
+
+ monitor.FileCompleted += async (sender, args) =>
+ {
+ try
+ {
+ await OnReplayFileCompletedAsync(profileId, gameType, sessionId, args);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Unhandled exception in FileCompleted event handler for profile: {ProfileId}", profileId);
+ }
+ };
+
+ var session = new MonitoringSession
+ {
+ SessionId = sessionId,
+ ProfileId = profileId,
+ GameType = gameType,
+ Monitor = monitor,
+ ReplayFilePath = replayFilePath,
+ };
+
+ if (!_activeSessions.TryAdd(profileId, session))
+ {
+ monitor.Dispose();
+ logger.LogWarning("Failed to add monitoring session for profile: {ProfileId}", profileId);
+ return Task.CompletedTask;
+ }
+
+ try
+ {
+ monitor.StartMonitoring(replayFilePath);
+ }
+ catch
+ {
+ _activeSessions.TryRemove(new KeyValuePair(profileId, session));
+ monitor.Dispose();
+ throw;
+ }
+
+ logger.LogInformation(
+ "Started replay monitoring for profile {ProfileId} ({GameType}) session {SessionId}: {FilePath}",
+ profileId,
+ gameType,
+ sessionId,
+ replayFilePath);
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task StopMonitoringAsync(string profileId)
+ {
+ return StopMonitoringAsync(profileId, sessionId: null);
+ }
+
+ ///
+ /// Stops monitoring for a specific profile, optionally verifying session identity.
+ ///
+ /// The profile ID.
+ /// If provided, only stops if the active session matches this ID.
+ /// A task representing the asynchronous operation.
+ public Task StopMonitoringAsync(string profileId, string? sessionId)
+ {
+ if (string.IsNullOrWhiteSpace(profileId))
+ {
+ return Task.CompletedTask;
+ }
+
+ if (sessionId != null)
+ {
+ StopMonitoringInternal(profileId, sessionId);
+ }
+ else
+ {
+ StopMonitoringInternal(profileId);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task StopAllMonitoringAsync()
+ {
+ foreach (var profileId in _activeSessions.Keys.ToArray())
+ {
+ StopMonitoringInternal(profileId);
+ }
+
+ logger.LogInformation("Stopped all replay monitoring");
+ return Task.CompletedTask;
+ }
+
+ ///
+ public bool IsMonitoring(string profileId)
+ {
+ return !string.IsNullOrWhiteSpace(profileId) && _activeSessions.ContainsKey(profileId);
+ }
+
+ ///
+ /// Gets the session ID for an active monitoring session.
+ ///
+ /// The profile ID.
+ /// The session ID, or null if not monitoring.
+ public string? GetSessionId(string profileId)
+ {
+ return _activeSessions.TryGetValue(profileId, out var session) ? session.SessionId : null;
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (_isDisposed)
+ {
+ return;
+ }
+
+ _isDisposed = true;
+
+ foreach (var session in _activeSessions.Values.ToArray())
+ {
+ try
+ {
+ session.Monitor.Dispose();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error disposing monitor for session: {SessionId}", session.SessionId);
+ }
+ }
+
+ _activeSessions.Clear();
+ }
+
+ private void StopMonitoringInternal(string profileId)
+ {
+ if (_activeSessions.TryRemove(profileId, out var session))
+ {
+ session.Monitor.Dispose();
+ logger.LogInformation("Stopped replay monitoring for profile: {ProfileId} session: {SessionId}", profileId, session.SessionId);
+ }
+ }
+
+ private void StopMonitoringInternal(string profileId, string expectedSessionId)
+ {
+ // Optimistic path: only attempt removal if the session matches
+ if (!_activeSessions.TryGetValue(profileId, out var existingSession) ||
+ existingSession?.SessionId != expectedSessionId)
+ {
+ logger.LogInformation(
+ "Skipping stop for profile {ProfileId}: session {ExpectedSession} not found or replaced",
+ profileId,
+ expectedSessionId);
+ return;
+ }
+
+ // TryRemove with KeyValuePair is atomic - only removes if both key and value match
+ // This prevents removing a newer session inserted by concurrent StartMonitoringAsync
+ if (_activeSessions.TryRemove(new KeyValuePair(profileId, existingSession)))
+ {
+ existingSession.Monitor.Dispose();
+ logger.LogInformation(
+ "Stopped replay monitoring for profile: {ProfileId} session: {SessionId}",
+ profileId,
+ existingSession.SessionId);
+ }
+ else
+ {
+ // Session was replaced between TryGetValue and TryRemove
+ logger.LogInformation(
+ "Skipping stop for profile {ProfileId}: session {ExpectedSession} was replaced during removal",
+ profileId,
+ expectedSessionId);
+ }
+ }
+
+ private async Task OnReplayFileCompletedAsync(string profileId, GameType gameType, string sessionId, ReplayFileCompletedEventArgs args)
+ {
+ try
+ {
+ logger.LogInformation(
+ "Replay file completed for profile {ProfileId}: {FilePath}",
+ profileId,
+ args.FilePath);
+
+ var (savedFilePath, metadata) = await saveService.SaveReplayAsync(args.FilePath, gameType);
+
+ if (string.IsNullOrEmpty(savedFilePath) || metadata == null)
+ {
+ logger.LogWarning("Failed to save replay for profile: {ProfileId}", profileId);
+ return;
+ }
+
+ ReplayCompleted?.Invoke(this, new ReplayCompletedEventArgs
+ {
+ ProfileId = profileId,
+ SavedFilePath = savedFilePath,
+ Metadata = metadata,
+ });
+
+ logger.LogInformation(
+ "Successfully saved replay for profile {ProfileId}: {SavedFilePath}",
+ profileId,
+ savedFilePath);
+
+ // Only stop monitoring after successful save
+ StopMonitoringInternal(profileId, sessionId);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error handling replay completion for profile: {ProfileId}", profileId);
+ }
+ }
+
+ private sealed class MonitoringSession
+ {
+ public required string SessionId { get; init; }
+
+ public required string ProfileId { get; init; }
+
+ public required GameType GameType { get; init; }
+
+ public required ReplayMonitor Monitor { get; init; }
+
+ public required string ReplayFilePath { get; init; }
+ }
+}
diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayParserService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayParserService.cs
new file mode 100644
index 000000000..2d9592758
--- /dev/null
+++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayParserService.cs
@@ -0,0 +1,417 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.Tools.ReplayManager;
+using Microsoft.Extensions.Logging;
+
+namespace GenHub.Features.Tools.ReplayManager.Services;
+
+///
+/// Service for parsing replay file headers and extracting metadata.
+///
+/// The logger instance.
+public sealed class ReplayParserService(ILogger logger)
+{
+ // Faction mappings for C&C Generals/Zero Hour
+ private static readonly Dictionary FactionMap = new()
+ {
+ { "FactionAmerica", "USA" },
+ { "FactionChina", "China" },
+ { "FactionGLA", "GLA" },
+ { "AmericaAirForceGeneral", "USA Air Force" },
+ { "AmericaLaserGeneral", "USA Laser" },
+ { "AmericaSuperweaponGeneral", "USA Superweapon" },
+ { "ChinaInfantryGeneral", "China Infantry" },
+ { "ChinaNukeGeneral", "China Nuke" },
+ { "ChinaTankGeneral", "China Tank" },
+ { "GLADemolitionGeneral", "GLA Demolition" },
+ { "GLAStealthGeneral", "GLA Stealth" },
+ { "GLAToxinGeneral", "GLA Toxin" },
+ };
+
+ private static readonly Dictionary ColorMap = new()
+ {
+ { "0", "Orange" },
+ { "1", "Pink" },
+ { "2", "Blue" },
+ { "3", "Green" },
+ { "4", "Red" },
+ { "5", "Yellow" },
+ { "6", "Purple" },
+ { "7", "Teal" },
+ };
+
+ ///
+ /// Parses a replay file and extracts metadata.
+ ///
+ /// The path to the replay file.
+ /// The game type.
+ /// The extracted metadata.
+ public Task ParseReplayAsync(string filePath, GameType gameType)
+ {
+ // Offload synchronous I/O to thread pool to avoid blocking
+ return Task.Run(() => ParseReplayCore(filePath, gameType));
+ }
+
+ private static ReplayMetadata CreateEmptyMetadata(string filePath, long fileSize, GameType gameType)
+ {
+ DateTime gameDate;
+ try
+ {
+ gameDate = File.Exists(filePath) ? File.GetLastWriteTime(filePath) : DateTime.Now;
+ }
+ catch
+ {
+ gameDate = DateTime.Now;
+ }
+
+ return new ReplayMetadata
+ {
+ GameDate = gameDate,
+ GameType = gameType,
+ FileSizeBytes = fileSize,
+ IsParsed = false,
+ OriginalFilePath = filePath,
+ };
+ }
+
+ private static string ReadNullTerminatedString(BinaryReader reader, Encoding encoding)
+ {
+ var bytes = new List(ReplayManagerConstants.MaxStringReadBytes);
+
+ // Use CodePage comparison for robust encoding detection
+ var charSize = encoding.CodePage == Encoding.Unicode.CodePage ? 2 : 1;
+
+ while (bytes.Count + charSize <= ReplayManagerConstants.MaxStringReadBytes)
+ {
+ var charBytes = reader.ReadBytes(charSize);
+ if (charBytes.Length < charSize || IsNullTerminator(charBytes, charSize))
+ {
+ break;
+ }
+
+ bytes.AddRange(charBytes);
+ }
+
+ // If we exited because of the byte limit (not a null terminator), drain to the null terminator
+ // so subsequent reads start at the correct position.
+ if (bytes.Count + charSize > ReplayManagerConstants.MaxStringReadBytes)
+ {
+ byte[] drain;
+ do
+ {
+ drain = reader.ReadBytes(charSize);
+ }
+ while (drain.Length == charSize && !IsNullTerminator(drain, charSize));
+ }
+
+ return bytes.Count > 0 ? encoding.GetString(bytes.ToArray()) : string.Empty;
+ }
+
+ private static bool IsNullTerminator(byte[] bytes, int charSize)
+ {
+ if (charSize == 2)
+ {
+ return bytes.Length >= 2 && bytes[0] == 0 && bytes[1] == 0;
+ }
+
+ return bytes.Length >= 1 && bytes[0] == 0;
+ }
+
+ private static ParsedMatchData ParseMatchData(string matchData)
+ {
+ var result = new ParsedMatchData();
+
+ if (string.IsNullOrWhiteSpace(matchData))
+ {
+ return result;
+ }
+
+ var entries = matchData.Split(';', StringSplitOptions.RemoveEmptyEntries);
+
+ foreach (var entry in entries)
+ {
+ var separatorIndex = entry.IndexOf('=');
+ if (separatorIndex < 0)
+ {
+ continue;
+ }
+
+ var key = entry[..separatorIndex].Trim();
+ var value = entry[(separatorIndex + 1)..].Trim();
+
+ if (string.IsNullOrEmpty(value))
+ {
+ continue;
+ }
+
+ switch (key)
+ {
+ case "M":
+ result.MapName = value;
+ break;
+
+ case "MC":
+ result.StartingCredits = int.TryParse(value, out var credits) ? credits : null;
+ break;
+
+ case "MS":
+ result.GameSpeed = value switch
+ {
+ "0" => "Slow",
+ "1" => "Normal",
+ "2" => "Fast",
+ _ => value,
+ };
+ break;
+
+ case "SD":
+ result.GameMode = value switch
+ {
+ "0" => "Skirmish",
+ "1" => "Online",
+ _ => value,
+ };
+ break;
+
+ case "FOG":
+ result.FogOfWar = value == "1";
+ break;
+
+ case "S":
+ // S field format: slot records separated by colons
+ // Each slot: playerSpec,faction,team,color,...
+ // playerSpec: H for human, C[E|M|H|B] for AI, X for empty
+ result.Players = ParsePlayerSlots(value);
+ break;
+ }
+ }
+
+ return result;
+ }
+
+ private static List? ParsePlayerSlots(string slotsData)
+ {
+ var players = new List();
+
+ // Split by colon to get individual slot records
+ var slots = slotsData.Split(':', StringSplitOptions.RemoveEmptyEntries);
+
+ for (int slotIndex = 0; slotIndex < slots.Length; slotIndex++)
+ {
+ var slot = slots[slotIndex].Trim();
+ if (string.IsNullOrEmpty(slot) || slot == "X")
+ {
+ continue; // Empty slot
+ }
+
+ // Split slot by comma to get fields - preserve positions for sparse records
+ var fields = slot.Split(',');
+ if (fields.Length == 0)
+ {
+ continue;
+ }
+
+ var playerSpec = fields[0].Trim();
+
+ // Handle empty fields explicitly to preserve positional semantics
+ string? faction = fields.Length > 1 && !string.IsNullOrWhiteSpace(fields[1]) ? fields[1].Trim() : null;
+ int? team = fields.Length > 2 && int.TryParse(fields[2].Trim(), out var t) ? t : null;
+ string? colorCode = fields.Length > 3 && !string.IsNullOrWhiteSpace(fields[3]) ? fields[3].Trim() : null;
+
+ PlayerInfo? playerInfo = null;
+
+ if (playerSpec.StartsWith('H') && playerSpec.Length > 1)
+ {
+ // Human player: name follows 'H' prefix
+ var playerName = playerSpec[1..];
+ playerInfo = new PlayerInfo
+ {
+ Name = playerName,
+ Type = PlayerType.Human,
+ Faction = faction != null && FactionMap.TryGetValue(faction, out var factionName) ? factionName : faction,
+ Team = team,
+ Color = colorCode != null && ColorMap.TryGetValue(colorCode, out var color) ? color : colorCode,
+ StartPosition = slotIndex + 1,
+ };
+ }
+ else if (playerSpec.StartsWith('C') && playerSpec.Length > 1)
+ {
+ // Computer player: CE=Easy, CM=Medium, CH=Hard, CB=Brutal
+ var difficulty = playerSpec[1] switch
+ {
+ 'E' => "Easy",
+ 'M' => "Medium",
+ 'H' => "Hard",
+ 'B' => "Brutal",
+ _ => "Unknown",
+ };
+
+ playerInfo = new PlayerInfo
+ {
+ Name = $"AI ({difficulty})",
+ Type = PlayerType.Computer,
+ AiDifficulty = difficulty,
+ Faction = faction != null && FactionMap.TryGetValue(faction, out var factionName) ? factionName : faction,
+ Team = team,
+ Color = colorCode != null && ColorMap.TryGetValue(colorCode, out var color) ? color : colorCode,
+ StartPosition = slotIndex + 1,
+ };
+ }
+ else if (playerSpec.StartsWith('O'))
+ {
+ // Observer
+ var observerName = playerSpec.Length > 1 ? playerSpec[1..] : "Observer";
+ playerInfo = new PlayerInfo
+ {
+ Name = observerName,
+ Type = PlayerType.Observer,
+ StartPosition = slotIndex + 1,
+ };
+ }
+
+ if (playerInfo != null)
+ {
+ players.Add(playerInfo);
+ }
+ }
+
+ return players.Count > 0 ? players : null;
+ }
+
+ private ReplayMetadata ParseReplayCore(string filePath, GameType gameType)
+ {
+ try
+ {
+ var fileInfo = new FileInfo(filePath);
+ if (!fileInfo.Exists)
+ {
+ logger.LogWarning("Replay file not found: {FilePath}", filePath);
+ return CreateEmptyMetadata(filePath, 0, gameType);
+ }
+
+ if (fileInfo.Length < ReplayManagerConstants.MinimumReplayFileSizeBytes)
+ {
+ logger.LogWarning("Replay file too small: {FilePath} ({Size} bytes)", filePath, fileInfo.Length);
+ return CreateEmptyMetadata(filePath, fileInfo.Length, gameType);
+ }
+
+ using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
+ using var reader = new BinaryReader(stream);
+
+ var magic = reader.ReadBytes(6);
+ var magicString = Encoding.ASCII.GetString(magic);
+ if (magicString != ReplayManagerConstants.ReplayMagicBytes)
+ {
+ logger.LogWarning("Invalid replay file format: {FilePath} (magic: {Magic})", filePath, magicString);
+ return CreateEmptyMetadata(filePath, fileInfo.Length, gameType);
+ }
+
+ // Read timestamps per GENREP spec (only 2 UInt32 values)
+ var beginTimestamp = reader.ReadUInt32();
+ var endTimestamp = reader.ReadUInt32();
+
+ // Skip unknown1[12] bytes (Generals/ZH specific)
+ reader.ReadBytes(12);
+
+ // Read null-terminated UTF-16 replay filename (e.g. "Last Replay")
+ ReadNullTerminatedString(reader, Encoding.Unicode);
+
+ // Skip date_time[8] (8 x uint16 = 16 bytes: year, month, dayOfWeek, day, hour, minute, second, millisecond)
+ reader.ReadBytes(16);
+
+ // Read version and build date strings
+ var gameVersion = ReadNullTerminatedString(reader, Encoding.Unicode);
+ var buildDate = ReadNullTerminatedString(reader, Encoding.Unicode);
+
+ // Skip version_minor (2 bytes) + version_major (2 bytes) + magic_hash[8]
+ reader.ReadBytes(12);
+
+ // Read match data — null-terminated ASCII metadata string (key=value pairs separated by ;)
+ var matchData = ReadNullTerminatedString(reader, Encoding.ASCII);
+
+ logger.LogDebug("Raw match data: {MatchData}", matchData);
+
+ var parsedData = ParseMatchData(matchData);
+
+ logger.LogInformation(
+ "Parsed: Map={Map}, Players={PlayerCount}, Mode={Mode}, Credits={Credits}",
+ parsedData.MapName ?? "null",
+ parsedData.Players?.Count ?? 0,
+ parsedData.GameMode ?? "null",
+ parsedData.StartingCredits);
+
+ var gameDate = beginTimestamp > 0
+ ? DateTimeOffset.FromUnixTimeSeconds(beginTimestamp).LocalDateTime
+ : fileInfo.LastWriteTime;
+
+ var duration = endTimestamp > beginTimestamp && beginTimestamp > 0
+ ? TimeSpan.FromSeconds(endTimestamp - beginTimestamp)
+ : (TimeSpan?)null;
+
+ logger.LogInformation(
+ "Successfully parsed replay: {FilePath} (Map: {Map}, Players: {PlayerCount}, Duration: {Duration})",
+ filePath,
+ parsedData.MapName ?? "Unknown",
+ parsedData.Players?.Count ?? 0,
+ duration);
+
+ return new ReplayMetadata
+ {
+ MapName = parsedData.MapName,
+ Players = parsedData.Players,
+ Duration = duration,
+ GameDate = gameDate,
+ GameType = gameType,
+ FileSizeBytes = fileInfo.Length,
+ IsParsed = true,
+ OriginalFilePath = filePath,
+ GameVersion = string.IsNullOrWhiteSpace(gameVersion) ? null : gameVersion,
+ BuildDate = string.IsNullOrWhiteSpace(buildDate) ? null : buildDate,
+ GameMode = parsedData.GameMode,
+ StartingCredits = parsedData.StartingCredits,
+ FogOfWar = parsedData.FogOfWar,
+ GameSpeed = parsedData.GameSpeed,
+ };
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error parsing replay file: {FilePath}", filePath);
+
+ long fileSize = 0;
+ try
+ {
+ var errorFileInfo = new FileInfo(filePath);
+ if (errorFileInfo.Exists)
+ {
+ fileSize = errorFileInfo.Length;
+ }
+ }
+ catch
+ {
+ // Path may be invalid; use 0 as fallback
+ }
+
+ return CreateEmptyMetadata(filePath, fileSize, gameType);
+ }
+ }
+
+ private sealed class ParsedMatchData
+ {
+ public string? MapName { get; set; }
+
+ public List? Players { get; set; }
+
+ public string? GameMode { get; set; }
+
+ public int? StartingCredits { get; set; }
+
+ public bool? FogOfWar { get; set; }
+
+ public string? GameSpeed { get; set; }
+ }
+}
diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplaySaveService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplaySaveService.cs
new file mode 100644
index 000000000..548e7a781
--- /dev/null
+++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplaySaveService.cs
@@ -0,0 +1,214 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Interfaces.Tools.ReplayManager;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.Tools.ReplayManager;
+using Microsoft.Extensions.Logging;
+
+namespace GenHub.Features.Tools.ReplayManager.Services;
+
+///
+/// Service for saving replay files with metadata-based naming.
+///
+/// The directory service.
+/// The parser service.
+/// The logger instance.
+public sealed class ReplaySaveService(
+ IReplayDirectoryService directoryService,
+ ReplayParserService parserService,
+ ILogger logger)
+{
+ ///
+ /// Saves a replay file to the Saved directory with metadata-based naming.
+ ///
+ /// The source replay file path.
+ /// The game type.
+ /// A tuple of the saved file path and parsed metadata, or nulls if save failed.
+ public async Task<(string? SavedFilePath, ReplayMetadata? Metadata)> SaveReplayAsync(string sourceFilePath, GameType gameType)
+ {
+ try
+ {
+ if (!File.Exists(sourceFilePath))
+ {
+ logger.LogWarning("Source replay file not found: {FilePath}", sourceFilePath);
+ return (null, null);
+ }
+
+ var fileInfo = new FileInfo(sourceFilePath);
+ if (fileInfo.Length < ReplayManagerConstants.MinimumReplayFileSizeBytes)
+ {
+ logger.LogWarning("Replay file too small to save: {FilePath} ({Size} bytes)", sourceFilePath, fileInfo.Length);
+ return (null, null);
+ }
+
+ var metadata = await parserService.ParseReplayAsync(sourceFilePath, gameType);
+
+ var replayDirectory = directoryService.GetReplayDirectory(gameType);
+ var savedDirectory = Path.Combine(replayDirectory, ReplayManagerConstants.SavedReplaysDirectoryName);
+
+ Directory.CreateDirectory(savedDirectory);
+
+ var fileName = GenerateFileName(metadata);
+ var destinationPath = Path.Combine(savedDirectory, fileName);
+
+ destinationPath = CopyWithRetry(sourceFilePath, destinationPath);
+
+ logger.LogInformation("Saved replay: {Source} -> {Destination}", sourceFilePath, destinationPath);
+
+ return (destinationPath, metadata);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error saving replay file: {FilePath}", sourceFilePath);
+ return (null, null);
+ }
+ }
+
+ private static string GenerateFileName(ReplayMetadata metadata)
+ {
+ var timestamp = metadata.GameDate ?? DateTime.Now;
+ var dateString = timestamp.ToString(ReplayManagerConstants.SavedReplayDateFormat, CultureInfo.InvariantCulture);
+
+ var components = new List { dateString };
+
+ if (metadata.IsParsed)
+ {
+ if (!string.IsNullOrWhiteSpace(metadata.MapName))
+ {
+ var sanitizedMapName = SanitizeFileName(metadata.MapName);
+ if (!string.IsNullOrEmpty(sanitizedMapName))
+ {
+ components.Add(sanitizedMapName);
+ }
+ }
+
+ if (metadata.Players != null && metadata.Players.Count > 0)
+ {
+ var sanitizedNames = metadata.Players
+ .Take(ReplayManagerConstants.MaxPlayersInSavedFileName)
+ .Select(p => SanitizeFileName(p.Name))
+ .Where(name => !string.IsNullOrEmpty(name))
+ .ToList();
+ var playerNames = string.Join("-", sanitizedNames);
+ if (!string.IsNullOrWhiteSpace(playerNames))
+ {
+ components.Add(playerNames);
+ }
+ }
+ }
+
+ var fileName = string.Join("_", components) + ".rep";
+
+ // Reserve space for potential _counter suffix (e.g., _999 = 4 chars)
+ var maxCounterLength = $"_{ReplayManagerConstants.MaxUniquePathAttempts}".Length;
+ var maxBaseLength = ReplayManagerConstants.MaxFileNameLength - 4 - maxCounterLength; // 4 for ".rep"
+
+ if (fileName.Length > ReplayManagerConstants.MaxFileNameLength)
+ {
+ var baseWithoutExtension = fileName[..^4]; // Remove ".rep"
+ if (baseWithoutExtension.Length > maxBaseLength)
+ {
+ baseWithoutExtension = baseWithoutExtension[..maxBaseLength].TrimEnd('_', '-');
+ }
+
+ fileName = baseWithoutExtension + ".rep";
+ }
+
+ return fileName;
+ }
+
+ private static string SanitizeFileName(string fileName)
+ {
+ var invalidChars = Path.GetInvalidFileNameChars();
+ var sanitized = string.Concat(fileName.Where(c => !invalidChars.Contains(c)));
+
+ sanitized = sanitized.Replace(' ', '_');
+
+ while (sanitized.Contains("__", StringComparison.Ordinal))
+ {
+ sanitized = sanitized.Replace("__", "_");
+ }
+
+ return sanitized.Trim('_');
+ }
+
+ ///
+ /// Copies a file to a unique destination path with retry logic to handle TOCTOU races.
+ /// Uses atomic FileMode.CreateNew to avoid race conditions.
+ ///
+ private static string CopyWithRetry(string sourceFilePath, string destinationPath)
+ {
+ // Open source file once outside retry loop - if it fails, propagate immediately
+ using var sourceStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
+
+ for (var attempt = 0; attempt < ReplayManagerConstants.SaveRetryMaxAttempts; attempt++)
+ {
+ var candidatePath = GetUniqueFilePath(destinationPath);
+ try
+ {
+ // Use FileMode.CreateNew for atomic file creation (fails if file exists)
+ using var destStream = new FileStream(candidatePath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
+ sourceStream.Position = 0; // Reset stream position for retry attempts
+ sourceStream.CopyTo(destStream);
+ return candidatePath;
+ }
+ catch (IOException) when (attempt < ReplayManagerConstants.SaveRetryMaxAttempts - 1)
+ {
+ // Destination collision — retry with a new unique path
+ }
+ catch (IOException) when (attempt >= ReplayManagerConstants.SaveRetryMaxAttempts - 1)
+ {
+ throw new IOException(
+ $"Failed to copy replay file after {ReplayManagerConstants.SaveRetryMaxAttempts} attempts - destination path collision");
+ }
+ catch
+ {
+ // Clean up partially created destination file on non-IOException failures
+ try
+ {
+ if (File.Exists(candidatePath))
+ {
+ File.Delete(candidatePath);
+ }
+ }
+ catch
+ {
+ // Ignore cleanup failures
+ }
+
+ throw;
+ }
+ }
+
+ throw new InvalidOperationException("Retry loop exited unexpectedly");
+ }
+
+ private static string GetUniqueFilePath(string filePath)
+ {
+ if (!File.Exists(filePath))
+ {
+ return filePath;
+ }
+
+ var directory = Path.GetDirectoryName(filePath) ?? string.Empty;
+ var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(filePath);
+ var extension = Path.GetExtension(filePath);
+
+ var counter = 1;
+ string uniquePath;
+
+ do
+ {
+ uniquePath = Path.Combine(directory, $"{fileNameWithoutExtension}_{counter}{extension}");
+ counter++;
+ }
+ while (File.Exists(uniquePath) && counter <= ReplayManagerConstants.MaxUniquePathAttempts);
+
+ return uniquePath;
+ }
+}
diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs b/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs
index 2522ad737..c08ccca5f 100644
--- a/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs
+++ b/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs
@@ -11,6 +11,7 @@
using GenHub.Core.Models.Common;
using GenHub.Core.Models.Enums;
using GenHub.Core.Models.Tools.ReplayManager;
+using GenHub.Features.Tools.ReplayManager.Services;
using GenHub.Features.Tools.ViewModels;
using Microsoft.Extensions.Logging;
using System;
@@ -30,6 +31,7 @@ namespace GenHub.Features.Tools.ReplayManager.ViewModels;
/// The import service.
/// The export service.
/// The upload history and rate limit service.
+/// The replay parser service.
/// The notification service.
/// The logger instance.
public partial class ReplayManagerViewModel(
@@ -37,6 +39,7 @@ public partial class ReplayManagerViewModel(
IReplayImportService importService,
IReplayExportService exportService,
IUploadHistoryService uploadHistoryService,
+ ReplayParserService parserService,
INotificationService notificationService,
ILogger logger) : ObservableObject
{
@@ -823,4 +826,70 @@ partial void OnSelectedTabChanged(GameType value)
// Load replays for the new tab
_ = LoadReplaysAsync();
}
+
+ ///
+ /// Command to parse and view a replay file.
+ ///
+ /// The replay file to parse.
+ [RelayCommand]
+ private async Task ParseReplayAsync(ReplayFile? replay)
+ {
+ if (replay == null)
+ {
+ return;
+ }
+
+ try
+ {
+ IsBusy = true;
+ StatusMessage = "Parsing replay...";
+
+ // Use the replay's own game type context, not the UI tab state
+ var metadata = await parserService.ParseReplayAsync(replay.FullPath, replay.GameVersion);
+
+ if (!metadata.IsParsed)
+ {
+ notificationService.ShowWarning("Parse Failed", "Could not parse replay file. The file may be corrupted or in an unsupported format.");
+ StatusMessage = "Parse failed.";
+ return;
+ }
+
+ // Get main window
+ if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ var mainWindow = desktop.MainWindow;
+ if (mainWindow != null)
+ {
+ var viewModel = new ReplayViewerViewModel(metadata, replay.FullPath);
+ var window = new Views.ReplayViewerWindow
+ {
+ DataContext = viewModel,
+ WindowStartupLocation = Avalonia.Controls.WindowStartupLocation.CenterOwner,
+ };
+
+ await window.ShowDialog(mainWindow);
+ }
+ else
+ {
+ notificationService.ShowWarning("Cannot Open Viewer", "No main window is available to display the replay details.");
+ }
+ }
+ else
+ {
+ notificationService.ShowWarning("Cannot Open Viewer", "Desktop application lifetime is not available.");
+ }
+
+ StatusMessage = "Ready";
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to parse replay: {FullPath}", replay.FullPath);
+ notificationService.ShowError("Parse Error", $"Failed to parse replay: {ex.Message}");
+ StatusMessage = "Parse error.";
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
}
diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayViewerViewModel.cs b/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayViewerViewModel.cs
new file mode 100644
index 000000000..ea0611212
--- /dev/null
+++ b/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayViewerViewModel.cs
@@ -0,0 +1,173 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Avalonia.Media;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using GenHub.Core.Models.Tools.ReplayManager;
+
+namespace GenHub.Features.Tools.ReplayManager.ViewModels;
+
+///
+/// ViewModel for the replay viewer window that displays parsed replay metadata.
+///
+public sealed partial class ReplayViewerViewModel : ObservableObject
+{
+ private static string FormatFileSize(long bytes)
+ {
+ if (bytes < 1024)
+ {
+ return $"{bytes} B";
+ }
+
+ if (bytes < 1024 * 1024)
+ {
+ return $"{bytes / 1024.0:F2} KB";
+ }
+
+ return $"{bytes / (1024.0 * 1024.0):F2} MB";
+ }
+
+ private static string FormatDuration(TimeSpan? duration)
+ {
+ if (duration == null)
+ {
+ return "Unknown";
+ }
+
+ var ts = duration.Value;
+ if (ts.TotalHours >= 1)
+ {
+ return $"{(int)ts.TotalHours}h {ts.Minutes}m {ts.Seconds}s";
+ }
+
+ if (ts.TotalMinutes >= 1)
+ {
+ return $"{ts.Minutes}m {ts.Seconds}s";
+ }
+
+ return $"{ts.Seconds}s";
+ }
+
+ ///
+ /// Event raised when the window should be closed.
+ ///
+ public event EventHandler? CloseRequested;
+
+ ///
+ /// Gets the replay metadata to display.
+ ///
+ public ReplayMetadata Metadata { get; }
+
+ ///
+ /// Gets the file path of the replay.
+ ///
+ public string FilePath { get; }
+
+ ///
+ /// Gets the file name without path.
+ ///
+ public string FileName => Path.GetFileName(FilePath);
+
+ ///
+ /// Gets the formatted file size.
+ ///
+ public string FormattedFileSize => FormatFileSize(Metadata.FileSizeBytes);
+
+ ///
+ /// Gets the formatted duration.
+ ///
+ public string FormattedDuration => FormatDuration(Metadata.Duration);
+
+ ///
+ /// Gets the formatted game date.
+ ///
+ public string FormattedGameDate => Metadata.GameDate?.ToString("yyyy-MM-dd HH:mm:ss") ?? "Unknown";
+
+ ///
+ /// Gets the player list.
+ ///
+ public IReadOnlyList Players => Metadata.Players ?? Array.Empty();
+
+ ///
+ /// Gets the player count.
+ ///
+ public int PlayerCount => Players.Count;
+
+ ///
+ /// Gets the shortened map name for display in stat cards.
+ ///
+ public string MapNameShort
+ {
+ get
+ {
+ var mapName = Metadata.MapName;
+ if (string.IsNullOrEmpty(mapName))
+ {
+ return "Unknown";
+ }
+
+ // Truncate long map names for the stat card
+ return mapName.Length > 20 ? mapName[..17] + "..." : mapName;
+ }
+ }
+
+ ///
+ /// Gets the parse status text.
+ ///
+ public string ParseStatusText => Metadata.IsParsed ? "Parsed" : "Parse Failed";
+
+ ///
+ /// Gets the parse status icon.
+ ///
+ public string ParseStatusIcon => Metadata.IsParsed ? "✓" : "✗";
+
+ ///
+ /// Gets the parse status color.
+ ///
+ public IBrush ParseStatusColor => Metadata.IsParsed
+ ? new SolidColorBrush(Color.Parse("#4CAF50"))
+ : new SolidColorBrush(Color.Parse("#F44336"));
+
+ ///
+ /// Gets a value indicating whether game settings information is available.
+ ///
+ public bool HasGameSettings => Metadata.GameMode != null || Metadata.StartingCredits != null ||
+ Metadata.FogOfWar != null || Metadata.GameSpeed != null;
+
+ ///
+ /// Gets the formatted starting credits.
+ ///
+ public string FormattedStartingCredits => Metadata.StartingCredits?.ToString("N0") ?? "Unknown";
+
+ ///
+ /// Gets the fog of war status text.
+ ///
+ public string FogOfWarText => Metadata.FogOfWar switch
+ {
+ true => "Enabled",
+ false => "Disabled",
+ null => "Unknown",
+ };
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The parsed replay metadata.
+ /// The file path of the replay.
+ public ReplayViewerViewModel(ReplayMetadata metadata, string filePath)
+ {
+ Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
+ FilePath = filePath ?? throw new ArgumentNullException(nameof(filePath));
+ }
+
+ ///
+ /// Command to close the window.
+ ///
+ [RelayCommand]
+ private void Close()
+ {
+ CloseRequested?.Invoke(this, EventArgs.Empty);
+ }
+}
diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml b/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml
index b08e8f05a..993af4973 100644
--- a/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml
+++ b/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml
@@ -80,10 +80,9 @@
-