From 5347eb33502f02f7065c026bd3ced702f75c79c7 Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Sun, 23 Jul 2023 15:36:35 +0200 Subject: [PATCH] Replay recording API improvements. (#4193) --- RELEASE-NOTES.md | 9 ++- Resources/Locale/en-US/replays.ftl | 8 +-- Robust.Client/ClientIoC.cs | 1 + .../GameController/GameController.cs | 4 +- .../Replays/ReplayRecordingManager.cs | 9 +-- Robust.Server/BaseServer.cs | 4 +- Robust.Server/ServerIoC.cs | 1 + .../Replays/IReplayRecordingManager.cs | 63 +++++++++++++++---- .../Replays/ReplayRecordingCommands.cs | 12 ++-- .../Replays/SharedReplayRecordingManager.cs | 51 ++++++++++----- 10 files changed, 118 insertions(+), 44 deletions(-) diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 18a28ae0a..08a5197d2 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -35,11 +35,13 @@ END TEMPLATE--> ### Breaking changes -*None yet* +* `IReplayRecordingManager.RecordingFinished` now takes a `ReplayRecordingFinished` object as argument. +* `IReplayRecordingManager.GetReplayStats` now returns a `ReplayRecordingStats` struct instead of a tuple. The units have also been normalized ### New features -*None yet* +* `IReplayRecordingManager` can now track a "state" object for an active recording. +* If the path given to `IReplayRecordingManager.TryStartRecording` is rooted, the base replay directory is ignored. ### Bugfixes @@ -47,7 +49,8 @@ END TEMPLATE--> ### Other -*None yet* +* `IReplayRecordingManager` no longer considers itself recording inside `RecordingFinished`. +* `IReplayRecordingManager.Initialize()` was moved to an engine-internal interface. ### Internal diff --git a/Resources/Locale/en-US/replays.ftl b/Resources/Locale/en-US/replays.ftl index 8b64c0fbb..fcf41b861 100644 --- a/Resources/Locale/en-US/replays.ftl +++ b/Resources/Locale/en-US/replays.ftl @@ -22,7 +22,7 @@ cmd-replay-skip-hint = Ticks or timespan (HH:MM:SS). cmd-replay-set-time-desc = Jump forwards or backwards to some specific time. cmd-replay-set-time-help = replay_set -cmd-replay-set-time-hint = Tick or timespan (HH:MM:SS), starting from +cmd-replay-set-time-hint = Tick or timespan (HH:MM:SS), starting from cmd-replay-error-time = "{$time}" is not an integer or timespan. cmd-replay-error-args = Wrong number of arguments. @@ -33,7 +33,7 @@ cmd-replay-error-run-level = You cannot load a replay while connected to a serve # Recording commands cmd-replay-recording-start-desc = Starts a replay recording, optionally with some time limit. -cmd-replay-recording-start-help = Usage: replay_recording_start [name] [overwrite] [time limit] +cmd-replay-recording-start-help = Usage: replay_recording_start [name] [overwrite] [time limit] cmd-replay-recording-start-success = Started recording a replay. cmd-replay-recording-start-already-recording = Already recording a replay. cmd-replay-recording-start-error = An error occurred while trying to start the recording. @@ -48,7 +48,7 @@ cmd-replay-recording-stop-not-recording = Not currently recording a replay. cmd-replay-recording-stats-desc = Displays information about the current replay recording. cmd-replay-recording-stats-help = Usage: replay_recording_stats -cmd-replay-recording-stats-result = Duration: {$time} min, Ticks: {$ticks}, Size: {$size} mb, rate: {$rate} mb/min. +cmd-replay-recording-stats-result = Duration: {$time} min, Ticks: {$ticks}, Size: {$size} MB, rate: {$rate} MB/min. # Time Control UI @@ -56,4 +56,4 @@ replay-time-box-scrubbing-label = Dynamic Scrubbing replay-time-box-replay-time-label = Recording Time: {$current} / {$end} ({$percentage}%) replay-time-box-server-time-label = Server Time: {$current} / {$end} replay-time-box-index-label = Index: {$current} / {$total} -replay-time-box-tick-label = Tick: {$current} / {$total} \ No newline at end of file +replay-time-box-tick-label = Tick: {$current} / {$total} diff --git a/Robust.Client/ClientIoC.cs b/Robust.Client/ClientIoC.cs index 46aef7932..93c77c8b0 100644 --- a/Robust.Client/ClientIoC.cs +++ b/Robust.Client/ClientIoC.cs @@ -84,6 +84,7 @@ namespace Robust.Client deps.Register(); deps.Register(); deps.Register(); + deps.Register(); deps.Register(); deps.Register(); deps.Register(); diff --git a/Robust.Client/GameController/GameController.cs b/Robust.Client/GameController/GameController.cs index a1b84f70a..a56b25322 100644 --- a/Robust.Client/GameController/GameController.cs +++ b/Robust.Client/GameController/GameController.cs @@ -84,7 +84,7 @@ namespace Robust.Client [Dependency] private readonly NetworkResourceManager _netResMan = default!; [Dependency] private readonly IReplayLoadManager _replayLoader = default!; [Dependency] private readonly IReplayPlaybackManager _replayPlayback = default!; - [Dependency] private readonly IReplayRecordingManager _replayRecording = default!; + [Dependency] private readonly IReplayRecordingManagerInternal _replayRecording = default!; private IWebViewManagerHook? _webViewHook; @@ -766,6 +766,8 @@ namespace Robust.Client internal void CleanupGameThread() { + _replayRecording.Shutdown(); + _modLoader.Shutdown(); // CEF specifically makes a massive silent stink of it if we don't shut it down from the correct thread. diff --git a/Robust.Client/Replays/ReplayRecordingManager.cs b/Robust.Client/Replays/ReplayRecordingManager.cs index 48a1ee8db..026103d53 100644 --- a/Robust.Client/Replays/ReplayRecordingManager.cs +++ b/Robust.Client/Replays/ReplayRecordingManager.cs @@ -78,15 +78,16 @@ internal sealed class ReplayRecordingManager : SharedReplayRecordingManager IWritableDirProvider directory, string? name = null, bool overwrite = false, - TimeSpan? duration = null) + TimeSpan? duration = null, + object? state = null) { - if (!base.TryStartRecording(directory, name, overwrite, duration)) + if (!base.TryStartRecording(directory, name, overwrite, duration, state)) return false; - var (state, detachMsg) = CreateFullState(); + var (gameState, detachMsg) = CreateFullState(); if (detachMsg != null) RecordReplayMessage(detachMsg); - Update(state); + Update(gameState); return true; } diff --git a/Robust.Server/BaseServer.cs b/Robust.Server/BaseServer.cs index d2d794f8b..38e86c35a 100644 --- a/Robust.Server/BaseServer.cs +++ b/Robust.Server/BaseServer.cs @@ -98,7 +98,7 @@ namespace Robust.Server [Dependency] private readonly ISerializationManager _serialization = default!; [Dependency] private readonly IStatusHost _statusHost = default!; [Dependency] private readonly IComponentFactory _componentFactory = default!; - [Dependency] private readonly IReplayRecordingManager _replay = default!; + [Dependency] private readonly IReplayRecordingManagerInternal _replay = default!; [Dependency] private readonly IGamePrototypeLoadManager _protoLoadMan = default!; [Dependency] private readonly NetworkResourceManager _netResMan = default!; @@ -633,6 +633,8 @@ namespace Robust.Server // called right before main loop returns, do all saving/cleanup in here public void Cleanup() { + _replay.Shutdown(); + _modLoader.Shutdown(); _playerManager.Shutdown(); diff --git a/Robust.Server/ServerIoC.cs b/Robust.Server/ServerIoC.cs index 6f85d14a4..1dc68112c 100644 --- a/Robust.Server/ServerIoC.cs +++ b/Robust.Server/ServerIoC.cs @@ -70,6 +70,7 @@ namespace Robust.Server deps.Register(); deps.Register(); deps.Register(); + deps.Register(); deps.Register(); deps.Register(); deps.Register(); diff --git a/Robust.Shared/Replays/IReplayRecordingManager.cs b/Robust.Shared/Replays/IReplayRecordingManager.cs index 1f91c1774..d4a717a6f 100644 --- a/Robust.Shared/Replays/IReplayRecordingManager.cs +++ b/Robust.Shared/Replays/IReplayRecordingManager.cs @@ -11,11 +11,6 @@ namespace Robust.Shared.Replays; public interface IReplayRecordingManager { - /// - /// Initializes the replay manager. - /// - void Initialize(); - /// /// Whether or not a replay recording can currently be started. /// @@ -51,6 +46,14 @@ public interface IReplayRecordingManager /// bool IsRecording { get; } + /// + /// Gets the state object passed into for the current recording. + /// + /// + /// Returns if there is no active replay recording. + /// + public object? ActiveRecordingState { get; } + /// /// Processes pending write tasks and saves the replay data for the current tick. This should be called even if a /// replay is not currently being recorded. @@ -62,20 +65,20 @@ public interface IReplayRecordingManager /// to the recording's metadata file, as well as to provide serializable messages that get replayed when the replay /// is initially loaded. E.g., this should contain networked events that would get sent to a newly connected client. /// - event Action>? RecordingStarted; + event Action> RecordingStarted; /// /// This gets invoked whenever a replay recording is stopping. Subscribers can use this to add extra yaml data to the /// recording's metadata file. /// - event Action? RecordingStopped; + event Action RecordingStopped; /// /// This gets invoked after a replay recording has finished and provides information about where the replay data /// was saved. Note that this only means that all write tasks have started, however some of the file tasks may not /// have finished yet. See . /// - event Action? RecordingFinished; + event Action RecordingFinished; /// /// Tries to starts a replay recording. @@ -93,12 +96,16 @@ public interface IReplayRecordingManager /// /// Optional time limit for the recording. /// + /// + /// An arbitrary object that is available in and . + /// /// Returns true if the recording was successfully started. bool TryStartRecording( IWritableDirProvider directory, string? name = null, bool overwrite = false, - TimeSpan? duration = null); + TimeSpan? duration = null, + object? state = null); /// /// Stops an ongoing replay recording. @@ -106,10 +113,9 @@ public interface IReplayRecordingManager void StopRecording(); /// - /// Returns information about the currently ongoing replay recording, including the currently elapsed time and the - /// compressed replay size. + /// Returns information about the currently ongoing replay recording. /// - (float Minutes, int Ticks, float Size, float UncompressedSize) GetReplayStats(); + ReplayRecordingStats GetReplayStats(); /// /// Returns a task that will wait for all the current writing tasks to finish. @@ -119,3 +125,36 @@ public interface IReplayRecordingManager /// Task WaitWriteTasks(); } + +/// +/// Event data for . +/// +/// The writable dir provider in which the replay is being recorded. +/// The path to the replay in . +/// The state object passed to . +public record ReplayRecordingFinished(IWritableDirProvider Directory, ResPath Path, object? State); + +/// +/// Statistics for an active replay recording. +/// +/// The simulation time the replay has been recording for. +/// The amount of simulation ticks the replay has recorded. +/// The total compressed size of the replay data blobs. +/// The total uncompressed size of the replay data blobs. +public record struct ReplayRecordingStats(TimeSpan Time, uint Ticks, long Size, long UncompressedSize); + +/// +/// Engine-internal functions for . +/// +internal interface IReplayRecordingManagerInternal : IReplayRecordingManager +{ + /// + /// Initializes the replay manager. + /// + void Initialize(); + + /// + /// Shut down any active replay recording, at engine shutdown. + /// + void Shutdown(); +} diff --git a/Robust.Shared/Replays/ReplayRecordingCommands.cs b/Robust.Shared/Replays/ReplayRecordingCommands.cs index 8a88f280c..516fbb92b 100644 --- a/Robust.Shared/Replays/ReplayRecordingCommands.cs +++ b/Robust.Shared/Replays/ReplayRecordingCommands.cs @@ -99,11 +99,15 @@ internal sealed class ReplayStatsCommand : LocalizedCommands { if (_replay.IsRecording) { - var (time, tick, size, _) = _replay.GetReplayStats(); + var stats = _replay.GetReplayStats(); + var sizeMb = stats.Size / (1024f * 1024f); + var minutes = stats.Time.TotalMinutes; + shell.WriteLine(Loc.GetString("cmd-replay-recording-stats-result", - ("time", time.ToString("F1")), - ("ticks", tick), ("size", size.ToString("F1")), - ("rate", (size/time).ToString("F2")))); + ("time", minutes.ToString("F1")), + ("ticks", stats.Ticks), + ("size", sizeMb.ToString("F1")), + ("rate", (sizeMb / minutes).ToString("F2")))); } else shell.WriteLine(Loc.GetString("cmd-replay-recording-stop-not-recording")); diff --git a/Robust.Shared/Replays/SharedReplayRecordingManager.cs b/Robust.Shared/Replays/SharedReplayRecordingManager.cs index c7d2f390a..d2d79ceee 100644 --- a/Robust.Shared/Replays/SharedReplayRecordingManager.cs +++ b/Robust.Shared/Replays/SharedReplayRecordingManager.cs @@ -21,13 +21,14 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Threading.Channels; using System.Threading.Tasks; +using Robust.Shared.Asynchronous; using Robust.Shared.Network; using YamlDotNet.RepresentationModel; using static Robust.Shared.Replays.ReplayConstants; namespace Robust.Shared.Replays; -internal abstract partial class SharedReplayRecordingManager : IReplayRecordingManager +internal abstract partial class SharedReplayRecordingManager : IReplayRecordingManagerInternal { // date format for default replay names. Like the sortable template, but without colons. public const string DefaultReplayNameFormat = "yyyy-MM-dd_HH-mm-ss"; @@ -38,10 +39,11 @@ internal abstract partial class SharedReplayRecordingManager : IReplayRecordingM [Dependency] private readonly IRobustSerializer _serializer = default!; [Dependency] private readonly INetManager _netMan = default!; [Dependency] private readonly ILogManager _logManager = default!; + [Dependency] private readonly ITaskManager _taskManager = default!; public event Action>? RecordingStarted; public event Action? RecordingStopped; - public event Action? RecordingFinished; + public event Action? RecordingFinished; private ISawmill _sawmill = default!; private List _queuedMessages = new(); @@ -53,9 +55,9 @@ internal abstract partial class SharedReplayRecordingManager : IReplayRecordingM private bool _enabled; public bool IsRecording => _recState != null; + public object? ActiveRecordingState => _recState?.State; private RecordingState? _recState; - /// public virtual void Initialize() { _sawmill = _logManager.GetSawmill("replay"); @@ -66,6 +68,18 @@ internal abstract partial class SharedReplayRecordingManager : IReplayRecordingM NetConf.OnValueChanged(CVars.NetPVSCompressLevel, OnCompressionChanged); } + public void Shutdown() + { + if (IsRecording) + { + StopRecording(); + + DebugTools.Assert(!IsRecording); + } + + _taskManager.BlockWaitOnTask(WaitWriteTasks()); + } + public virtual bool CanStartRecording() { return !IsRecording && _enabled; @@ -137,7 +151,8 @@ internal abstract partial class SharedReplayRecordingManager : IReplayRecordingM IWritableDirProvider directory, string? name = null, bool overwrite = false, - TimeSpan? duration = null) + TimeSpan? duration = null, + object? state = null) { if (!CanStartRecording()) return false; @@ -152,7 +167,7 @@ internal abstract partial class SharedReplayRecordingManager : IReplayRecordingM filePath = filePath.WithName(filePath.Filename + ".zip"); var basePath = new ResPath(NetConf.GetCVar(CVars.ReplayDirectory)).ToRootedPath(); - filePath = basePath / filePath.ToRelativePath(); + filePath = basePath / filePath; // Make sure to create parent directory. directory.CreateDir(filePath.Directory); @@ -203,7 +218,8 @@ internal abstract partial class SharedReplayRecordingManager : IReplayRecordingM commandQueue.Writer, writeTask, directory, - filePath + filePath, + state ); try @@ -348,8 +364,10 @@ internal abstract partial class SharedReplayRecordingManager : IReplayRecordingM var document = new YamlDocument(yamlMetadata.ToYaml()); WriteYaml(recState, ReplayZipFolder / FileMetaFinal, document); UpdateWriteTasks(); - RecordingFinished?.Invoke(recState.DestDir, recState.DestPath); Reset(); + + var finishedData = new ReplayRecordingFinished(recState.DestDir, recState.DestPath, recState.State); + RecordingFinished?.Invoke(finishedData); } private void WriteContentBundleInfo(RecordingState recState) @@ -403,17 +421,17 @@ internal abstract partial class SharedReplayRecordingManager : IReplayRecordingM return info; } - public (float Minutes, int Ticks, float Size, float UncompressedSize) GetReplayStats() + public ReplayRecordingStats GetReplayStats() { if (_recState == null) throw new InvalidOperationException("Not recording replay!"); - var time = (Timing.CurTime - _recState.StartTime).TotalMinutes; + var time = Timing.CurTime - _recState.StartTime; var tick = Timing.CurTick.Value - _recState.StartTick.Value; - var size = _recState.CompressedSize / (1024f * 1024f); - var altSize = _recState.UncompressedSize / (1024f * 1024f); + var size = _recState.CompressedSize; + var altSize = _recState.UncompressedSize; - return ((float)time, (int)tick, size, altSize); + return new ReplayRecordingStats(time, tick, size, altSize); } /// @@ -428,6 +446,7 @@ internal abstract partial class SharedReplayRecordingManager : IReplayRecordingM public readonly Task WriteTask; public readonly IWritableDirProvider DestDir; public readonly ResPath DestPath; + public readonly object? State; // Tick and time when the recording was started. public readonly GameTick StartTick; @@ -437,8 +456,8 @@ internal abstract partial class SharedReplayRecordingManager : IReplayRecordingM public readonly TimeSpan? EndTime; public int Index; - public int CompressedSize; - public int UncompressedSize; + public long CompressedSize; + public long UncompressedSize; public RecordingState( ZipArchive zip, @@ -450,11 +469,13 @@ internal abstract partial class SharedReplayRecordingManager : IReplayRecordingM ChannelWriter writeCommandChannel, Task writeTask, IWritableDirProvider destDir, - ResPath destPath) + ResPath destPath, + object? state) { WriteTask = writeTask; DestDir = destDir; DestPath = destPath; + State = state; Zip = zip; Buffer = buffer; CompressionContext = compressionContext;