From 53fab4640ce49446f514960ef21025c26824ed43 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Sun, 15 Feb 2026 02:05:15 +0000 Subject: [PATCH 01/29] feat(replay-manager): add automatic replay saving feature Add infrastructure for automatically saving replay files when games end. The system monitors the active replay file (00000000.rep) during gameplay using FileSystemWatcher with file stability detection, then copies and renames the replay with parsed metadata (timestamp, map name, players). Key components: - ReplayMonitor: FileSystemWatcher + one-shot timer stability checks - ReplayMonitoringService: per-profile session orchestration - ReplayParserService: GENREP binary format parser (key=value match data) - ReplaySaveService: copy with metadata naming and TOCTOU-safe retry - IReplayMonitorService: core interface with ReplayCompleted event Integration: - Per-profile AutoSaveReplays toggle in General Settings UI - GameLauncher starts monitoring after successful launch - LaunchRegistry stops monitoring with grace period on process exit - Session auto-cleanup after replay completion Co-Authored-By: Claude Opus 4.6 --- .../Constants/ReplayManagerConstants.cs | 55 ++++ .../ReplayManager/IReplayMonitorService.cs | 58 ++++ .../Models/Events/ReplayCompletedEventArgs.cs | 24 ++ .../Events/ReplayFileCompletedEventArgs.cs | 12 + .../GameProfile/CreateProfileRequest.cs | 3 + .../Models/GameProfile/GameProfile.cs | 5 + .../GameProfile/UpdateProfileRequest.cs | 5 + .../Tools/ReplayManager/ReplayMetadata.cs | 24 +- .../Features/Launching/GameLauncherTests.cs | 75 ++++- .../SharedViewModelModuleTests.cs | 3 + .../Services/GameProfileManager.cs | 2 + .../GameProfileSettingsViewModel.Commands.cs | 2 + ...ProfileSettingsViewModel.Initialization.cs | 1 + ...GameProfileSettingsViewModel.Properties.cs | 24 ++ .../GameProfileGeneralSettingsView.axaml | 6 + .../Info/ViewModels/DemoViewModelFactory.cs | 4 + .../GenHub/Features/Launching/GameLauncher.cs | 18 +- .../Features/Launching/LaunchRegistry.cs | 32 +- .../ReplayManager/Services/ReplayMonitor.cs | 276 ++++++++++++++++++ .../Services/ReplayMonitoringService.cs | 245 ++++++++++++++++ .../Services/ReplayParserService.cs | 249 ++++++++++++++++ .../Services/ReplaySaveService.cs | 183 ++++++++++++ .../ViewModels/ReplayManagerViewModel.cs | 60 ++++ .../ViewModels/ReplayViewerViewModel.cs | 109 +++++++ .../Views/ReplayManagerView.axaml | 5 +- .../Views/ReplayViewerWindow.axaml | 120 ++++++++ .../Views/ReplayViewerWindow.axaml.cs | 52 ++++ .../ReplayManagerModule.cs | 5 + 28 files changed, 1650 insertions(+), 7 deletions(-) create mode 100644 GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayMonitorService.cs create mode 100644 GenHub/GenHub.Core/Models/Events/ReplayCompletedEventArgs.cs create mode 100644 GenHub/GenHub.Core/Models/Events/ReplayFileCompletedEventArgs.cs create mode 100644 GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitor.cs create mode 100644 GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitoringService.cs create mode 100644 GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayParserService.cs create mode 100644 GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplaySaveService.cs create mode 100644 GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayViewerViewModel.cs create mode 100644 GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayViewerWindow.axaml create mode 100644 GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayViewerWindow.axaml.cs diff --git a/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs b/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs index 634cbeec7..cc7e41d86 100644 --- a/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs +++ b/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs @@ -29,4 +29,59 @@ public static class ReplayManagerConstants /// Default file name for imported replays. /// public const string DefaultImportedReplayFileName = "imported_replay.rep"; + + /// + /// 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. + /// + public const int StopMonitoringGracePeriodSeconds = 10; + + /// + /// 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; } \ No newline at end of file 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/ReplayMetadata.cs b/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplayMetadata.cs index 829018017..b0a09be2d 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 { @@ -24,4 +26,24 @@ public sealed class ReplayMetadata /// Gets the date the game was played. /// public DateTime? GameDate { get; init; } + + /// + /// 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; } } \ No newline at end of file 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..328c668fd 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs @@ -158,6 +158,7 @@ public virtual async Task InitializeForProfileAsync(string profileId) SelectedWorkspaceStrategy = profile.WorkspaceStrategy ?? GetDefaultWorkspaceStrategy(); _originalWorkspaceStrategy = profile.WorkspaceStrategy ?? GetDefaultWorkspaceStrategy(); CommandLineArguments = profile.CommandLineArguments ?? string.Empty; + AutoSaveReplays = profile.AutoSaveReplays; 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..bf9a60351 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; diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml index ecb661d50..d6ef2dc5e 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..ec51a5a96 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); 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..cf2e9b3fd 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,30 @@ 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 + // 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 + { + 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/Services/ReplayMonitor.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitor.cs new file mode 100644 index 000000000..9abb43710 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitor.cs @@ -0,0 +1,276 @@ +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.LastWrite | NotifyFilters.Size, + }; + + _watcher.Changed += OnFileChanged; + _watcher.Error += OnWatcherError; + _watcher.EnableRaisingEvents = true; + + IsMonitoring = true; + + logger.LogInformation("Started monitoring replay file: {FilePath}", filePath); + + // 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) + { + if (!IsMonitoring) + { + return; + } + + IsMonitoring = false; + + _stabilityTimer?.Dispose(); + _stabilityTimer = null; + + if (_watcher != null) + { + _watcher.Changed -= OnFileChanged; + _watcher.Error -= OnWatcherError; + _watcher.Dispose(); + _watcher = 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); + } + } + } + + 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 + { + _lastFileSize = currentSize; + _stabilityCheckCount = 0; + _stabilityTimer?.Change(ReplayManagerConstants.FileStabilityCheckIntervalMs, Timeout.Infinite); + } + } + 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..7778d716b --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitoringService.cs @@ -0,0 +1,245 @@ +using System; +using System.Collections.Concurrent; +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 += (sender, args) => _ = OnReplayFileCompletedAsync(profileId, gameType, sessionId, args); + + 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; + } + + monitor.StartMonitoring(replayFilePath); + + 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()) + { + session.Monitor.Dispose(); + } + + _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) + { + if (_activeSessions.TryRemove(profileId, out var session)) + { + if (session.SessionId == expectedSessionId) + { + session.Monitor.Dispose(); + logger.LogInformation("Stopped replay monitoring for profile: {ProfileId} session: {SessionId}", profileId, session.SessionId); + } + else + { + // Session ID mismatch - put it back + _activeSessions.TryAdd(profileId, session); + logger.LogInformation( + "Skipping stop for profile {ProfileId}: session {ExpectedSession} replaced by {ActualSession}", + profileId, + expectedSessionId, + session.SessionId); + } + } + else + { + logger.LogInformation( + "Skipping stop for profile {ProfileId}: session {ExpectedSession} not found", + 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); + } + catch (Exception ex) + { + logger.LogError(ex, "Error handling replay completion for profile: {ProfileId}", profileId); + } + finally + { + StopMonitoringInternal(profileId, sessionId); + } + } + + 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..4790087db --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayParserService.cs @@ -0,0 +1,249 @@ +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) +{ + /// + /// Parses a replay file and extracts metadata. + /// + /// The path to the replay file. + /// The game type. + /// The extracted metadata. + public async Task ParseReplayAsync(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); + } + + await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + 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 + 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 (skip them) + ReadNullTerminatedString(reader, Encoding.Unicode); + 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); + + var (mapName, players) = ParseMatchData(matchData); + + var gameDate = beginTimestamp > 0 + ? DateTimeOffset.FromUnixTimeSeconds(beginTimestamp).LocalDateTime + : fileInfo.LastWriteTime; + + var duration = endTimestamp > beginTimestamp + ? TimeSpan.FromSeconds(endTimestamp - beginTimestamp) + : (TimeSpan?)null; + + logger.LogInformation( + "Successfully parsed replay: {FilePath} (Map: {Map}, Players: {PlayerCount}, Duration: {Duration})", + filePath, + mapName ?? "Unknown", + players?.Count ?? 0, + duration); + + return new ReplayMetadata + { + MapName = mapName, + Players = players, + Duration = duration, + GameDate = gameDate, + GameType = gameType, + FileSizeBytes = fileInfo.Length, + IsParsed = true, + OriginalFilePath = filePath, + }; + } + 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 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); + var charSize = encoding == Encoding.Unicode ? 2 : 1; + + while (bytes.Count + charSize <= ReplayManagerConstants.MaxStringReadBytes) + { + var charBytes = reader.ReadBytes(charSize); + if (charBytes.Length < charSize || IsNullTerminator(charBytes, charSize)) + { + break; + } + + bytes.AddRange(charBytes); + } + + 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 (string? MapName, IReadOnlyList? Players) ParseMatchData(string matchData) + { + if (string.IsNullOrWhiteSpace(matchData)) + { + return (null, null); + } + + string? mapName = null; + var players = new List(); + + 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": + mapName = value; + break; + + case "S": + // S field is colon-separated slots, each slot is comma-separated fields + // Field[0] = player spec: H for human, C[E|M|H|B] for computer, X for empty + var slots = value.Split(':', StringSplitOptions.RemoveEmptyEntries); + foreach (var slot in slots) + { + var trimmedSlot = slot.Trim(); + if (string.IsNullOrEmpty(trimmedSlot) || trimmedSlot == "X") + { + continue; + } + + var fields = trimmedSlot.Split(',', StringSplitOptions.RemoveEmptyEntries); + if (fields.Length == 0) + { + continue; + } + + var playerSpec = fields[0].Trim(); + if (playerSpec.StartsWith('H') && playerSpec.Length > 1) + { + // Human player: name follows 'H' prefix + players.Add(playerSpec[1..]); + } + else if (playerSpec.StartsWith('C') && playerSpec.Length > 1) + { + // Computer player: CE=Easy, CM=Medium, CH=Hard, CB=Brutal + players.Add("CPU"); + } + } + + break; + } + } + + return (mapName, players.Count > 0 ? players : null); + } +} 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..8f5f5d27d --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplaySaveService.cs @@ -0,0 +1,183 @@ +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)) + { + components.Add(SanitizeFileName(metadata.MapName)); + } + + if (metadata.Players != null && metadata.Players.Count > 0) + { + var playerNames = string.Join("-", metadata.Players.Take(4).Select(SanitizeFileName)); + if (!string.IsNullOrWhiteSpace(playerNames)) + { + components.Add(playerNames); + } + } + } + + var fileName = string.Join("_", components) + ".rep"; + + if (fileName.Length > 255) + { + fileName = fileName[..251].TrimEnd('_', '-') + ".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. + /// + private static string CopyWithRetry(string sourceFilePath, string destinationPath) + { + for (var attempt = 0; attempt < ReplayManagerConstants.SaveRetryMaxAttempts; attempt++) + { + var candidatePath = GetUniqueFilePath(destinationPath); + try + { + File.Copy(sourceFilePath, candidatePath, overwrite: false); + return candidatePath; + } + catch (IOException) + { + if (attempt >= ReplayManagerConstants.SaveRetryMaxAttempts - 1) + { + // Final attempt: use overwrite as a last resort to avoid losing the replay + var finalPath = GetUniqueFilePath(destinationPath); + try + { + File.Copy(sourceFilePath, finalPath, overwrite: true); + return finalPath; + } + catch (Exception ex) + { + throw new IOException( + $"Failed to copy replay file after {ReplayManagerConstants.SaveRetryMaxAttempts} attempts, including overwrite attempt", + ex); + } + } + + // Otherwise retry with a new path + } + } + + 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..27288a5ea 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,61 @@ 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..."; + + var metadata = await parserService.ParseReplayAsync(replay.FullPath, SelectedTab); + + if (metadata == null || !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); + } + } + + 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..93832d543 --- /dev/null +++ b/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayViewerViewModel.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +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 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; + + /// + /// 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 @@ - public string FilePath { get; } + /// + /// Gets the file name without path. + /// + public string FileName => Path.GetFileName(FilePath); + /// /// Gets the formatted file size. /// @@ -87,6 +94,41 @@ private static string FormatDuration(TimeSpan? duration) /// 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")); + /// /// Initializes a new instance of the class. /// diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayViewerWindow.axaml b/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayViewerWindow.axaml index a7bddc84c..8fe873484 100644 --- a/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayViewerWindow.axaml +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayViewerWindow.axaml @@ -3,118 +3,300 @@ xmlns:vm="using:GenHub.Features.Tools.ReplayManager.ViewModels" x:Class="GenHub.Features.Tools.ReplayManager.Views.ReplayViewerWindow" x:DataType="vm:ReplayViewerViewModel" - Title="Replay Viewer" - Width="600" - Height="500" - MinWidth="500" - MinHeight="400" + Title="Replay Details" + Width="700" + Height="650" + MinWidth="600" + MinHeight="500" WindowStartupLocation="CenterOwner" - CanResize="True"> + CanResize="True" + Background="{DynamicResource ApplicationPageBackgroundThemeBrush}"> + + + + + + + + + + + + - + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + - - + + + + + - - + + + + + - - + + + + + - - - + + + + + + + + - - + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + Padding="24,16" + BoxShadow="0 -2 8 0 #20000000"> 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.ReadWrite); + 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 sourceStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); 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) + catch (IOException) when (attempt < ReplayManagerConstants.SaveRetryMaxAttempts - 1) { - if (attempt >= ReplayManagerConstants.SaveRetryMaxAttempts - 1) - { - // Final attempt: try one more time with a fresh unique path - var finalPath = GetUniqueFilePath(destinationPath); - try - { - using var sourceStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - using var destStream = new FileStream(finalPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); - sourceStream.CopyTo(destStream); - return finalPath; - } - catch (Exception ex) - { - throw new IOException( - $"Failed to copy replay file after {ReplayManagerConstants.SaveRetryMaxAttempts} attempts", - ex); - } - } - - // Otherwise retry with a new path + // 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"); } } From a5c90ba1982f34be33a47881588e7d68adacf4f5 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Fri, 13 Mar 2026 15:19:26 +0000 Subject: [PATCH 24/29] Fix bogus duration when beginTimestamp is zero - Add beginTimestamp > 0 guard to duration calculation - Prevents invalid durations like 485277h when beginTimestamp=0 - Mirrors the gameDate guard logic --- .../Tools/ReplayManager/Services/ReplayParserService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayParserService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayParserService.cs index fd45cf495..dd3704f53 100644 --- a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayParserService.cs +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayParserService.cs @@ -337,7 +337,7 @@ private ReplayMetadata ParseReplayCore(string filePath, GameType gameType) ? DateTimeOffset.FromUnixTimeSeconds(beginTimestamp).LocalDateTime : fileInfo.LastWriteTime; - var duration = endTimestamp > beginTimestamp + var duration = endTimestamp > beginTimestamp && beginTimestamp > 0 ? TimeSpan.FromSeconds(endTimestamp - beginTimestamp) : (TimeSpan?)null; From 38dbd1116c472f01747737584ee236d9dad905a9 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Mon, 16 Mar 2026 16:47:06 +0000 Subject: [PATCH 25/29] Fix code review comments: hide AutoSaveReplays for tool profiles and fix stream position drift in ReplayParserService --- .../GameProfileSettingsViewModel.Initialization.cs | 2 ++ .../GameProfileSettingsViewModel.Properties.cs | 3 +++ .../Views/GameProfileGeneralSettingsView.axaml | 2 +- .../ReplayManager/Services/ReplayParserService.cs | 12 ++++++++++++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs index e71d15ada..e6fe67b70 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs @@ -42,6 +42,7 @@ public virtual async Task InitializeForNewProfileAsync() SelectedWorkspaceStrategy = GetDefaultWorkspaceStrategy(); SelectedContentType = ContentType.GameClient; AutoSaveReplays = false; // Reset to prevent stale state leakage + IsToolProfile = false; EnabledContent.Clear(); @@ -160,6 +161,7 @@ public virtual async Task InitializeForProfileAsync(string profileId) _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 bf9a60351..e89699f9d 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Properties.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Properties.cs @@ -221,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 d6ef2dc5e..2785cfb26 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml @@ -198,7 +198,7 @@ - + diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayParserService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayParserService.cs index dd3704f53..219a2aa8d 100644 --- a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayParserService.cs +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayParserService.cs @@ -85,6 +85,18 @@ private static string ReadNullTerminatedString(BinaryReader reader, Encoding enc 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; } From d320b9e12b5b7c3817a12f4b76650abbdd3499b1 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Mon, 16 Mar 2026 17:20:20 +0000 Subject: [PATCH 26/29] fix: prevent infinite timer rearming for sub-minimum-size stable replay files --- .../Tools/ReplayManager/Services/ReplayMonitor.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitor.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitor.cs index da37ea6e3..b243f4081 100644 --- a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitor.cs +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitor.cs @@ -270,9 +270,14 @@ private void CheckFileStability(object? state) } else { - _lastFileSize = currentSize; - _stabilityCheckCount = 0; - _stabilityTimer?.Change(ReplayManagerConstants.FileStabilityCheckIntervalMs, Timeout.Infinite); + 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) From 5dc985fe62a541b5e33d8e5fd9646ea7039fbf0c Mon Sep 17 00:00:00 2001 From: undead2146 Date: Mon, 16 Mar 2026 17:38:07 +0000 Subject: [PATCH 27/29] fix: address code review comments - remove dead null check, add viewer error notifications, use size-only file monitoring --- .../Tools/ReplayManager/Services/ReplayMonitor.cs | 2 +- .../ReplayManager/ViewModels/ReplayManagerViewModel.cs | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitor.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitor.cs index b243f4081..449d717da 100644 --- a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitor.cs +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitor.cs @@ -76,7 +76,7 @@ public void StartMonitoring(string filePath) _watcher = new FileSystemWatcher(directory, fileName) { - NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size, + NotifyFilter = NotifyFilters.Size, }; _watcher.Changed += OnFileChanged; diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs b/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs index 02c7fa32b..c08ccca5f 100644 --- a/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs +++ b/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs @@ -847,7 +847,7 @@ private async Task ParseReplayAsync(ReplayFile? 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 == null || !metadata.IsParsed) + 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."; @@ -869,6 +869,14 @@ private async Task ParseReplayAsync(ReplayFile? replay) 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"; From 0852f89d3e22276414e6fd49abf7ba824332a09a Mon Sep 17 00:00:00 2001 From: undead2146 Date: Mon, 16 Mar 2026 17:52:00 +0000 Subject: [PATCH 28/29] fix: address replay service review comments - proper async event handling, FileShare.Read, cleanup on failures, stop monitoring only on success --- .../Services/ReplayMonitoringService.cs | 19 ++++++++++++++----- .../Services/ReplaySaveService.cs | 19 ++++++++++++++++++- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitoringService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitoringService.cs index 9accd2acf..e811a3c66 100644 --- a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitoringService.cs +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitoringService.cs @@ -47,7 +47,17 @@ public Task StartMonitoringAsync(string profileId, GameType gameType) var monitor = new ReplayMonitor(loggerFactory.CreateLogger()); var sessionId = Guid.NewGuid().ToString(); - monitor.FileCompleted += (sender, args) => _ = OnReplayFileCompletedAsync(profileId, gameType, sessionId, args); + 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 { @@ -240,15 +250,14 @@ private async Task OnReplayFileCompletedAsync(string profileId, GameType gameTyp "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); } - finally - { - StopMonitoringInternal(profileId, sessionId); - } } private sealed class MonitoringSession diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplaySaveService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplaySaveService.cs index dc46cb408..548e7a781 100644 --- a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplaySaveService.cs +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplaySaveService.cs @@ -144,7 +144,7 @@ private static string SanitizeFileName(string fileName) 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.ReadWrite); + using var sourceStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read, FileShare.Read); for (var attempt = 0; attempt < ReplayManagerConstants.SaveRetryMaxAttempts; attempt++) { @@ -166,6 +166,23 @@ private static string CopyWithRetry(string sourceFilePath, string destinationPat 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"); From 8f76b265b82f9d6bf50b7bbb105e8365c999fb89 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Mon, 16 Mar 2026 18:36:19 +0000 Subject: [PATCH 29/29] fix: resolve build errors - add missing ReplayParserService parameter and fix StyleCop warnings --- .../Info/ViewModels/DemoViewModelFactory.cs | 1 + .../ReplayManager/Services/ReplayMonitor.cs | 2 ++ .../Services/ReplayParserService.cs | 24 +++++++++---------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/GenHub/GenHub/Features/Info/ViewModels/DemoViewModelFactory.cs b/GenHub/GenHub/Features/Info/ViewModels/DemoViewModelFactory.cs index ec51a5a96..b446e33ab 100644 --- a/GenHub/GenHub/Features/Info/ViewModels/DemoViewModelFactory.cs +++ b/GenHub/GenHub/Features/Info/ViewModels/DemoViewModelFactory.cs @@ -250,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/Tools/ReplayManager/Services/ReplayMonitor.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitor.cs index 449d717da..8231be19b 100644 --- a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitor.cs +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayMonitor.cs @@ -208,6 +208,7 @@ private void OnWatcherError(object sender, ErrorEventArgs e) 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(); } @@ -276,6 +277,7 @@ private void CheckFileStability(object? state) _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. } diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayParserService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayParserService.cs index 219a2aa8d..2d9592758 100644 --- a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayParserService.cs +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayParserService.cs @@ -45,6 +45,18 @@ public sealed class ReplayParserService(ILogger logger) { "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; @@ -271,18 +283,6 @@ private static ParsedMatchData ParseMatchData(string matchData) return players.Count > 0 ? players : null; } - /// - /// 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 ReplayMetadata ParseReplayCore(string filePath, GameType gameType) { try