From ff9be35c8110a728c26b39b14e32005b26b01f8a Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Tue, 27 Aug 2024 16:37:52 +0200 Subject: [PATCH] Add way for content to write arbitrary files into replay. Added a new RecordingStopped2 event that receives a IReplayFileWriter object that can be used to write arbitrary files into the replay zip file. Fixes #5261 --- .../Replays/IReplayRecordingManager.cs | 56 +++++++++++++++++++ .../Replays/SharedReplayRecordingManager.cs | 29 ++++++++++ 2 files changed, 85 insertions(+) diff --git a/Robust.Shared/Replays/IReplayRecordingManager.cs b/Robust.Shared/Replays/IReplayRecordingManager.cs index 72f0b6b864a..0a6b0adb70a 100644 --- a/Robust.Shared/Replays/IReplayRecordingManager.cs +++ b/Robust.Shared/Replays/IReplayRecordingManager.cs @@ -2,6 +2,7 @@ using Robust.Shared.Serialization.Markdown.Mapping; using System; using System.Collections.Generic; +using System.IO.Compression; using System.Threading.Tasks; using Robust.Shared.ContentPack; using Robust.Shared.GameStates; @@ -71,8 +72,17 @@ public interface IReplayRecordingManager /// 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; + /// + /// This gets invoked whenever a replay recording is stopping. Subscribers can use this to add extra data to the replay. + /// + /// + /// This is effectively a more powerful version of . + /// + event Action RecordingStopped2; + /// /// 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 @@ -131,6 +141,27 @@ bool TryStartRecording( bool IsWriting(); } +/// +/// Event object used by . +/// Allows modifying metadata and adding more data to replay files. +/// +public sealed class ReplayRecordingStopped +{ + /// + /// Mutable metadata that will be saved to the replay's metadata file. + /// + public required MappingDataNode Metadata { get; init; } + + /// + /// A writer that allows arbitrary file writing into the replay file. + /// + public required IReplayFileWriter Writer { get; init; } + + internal ReplayRecordingStopped() + { + } +} + /// /// Event data for . /// @@ -148,6 +179,31 @@ public record ReplayRecordingFinished(IWritableDirProvider Directory, ResPath Pa /// The total uncompressed size of the replay data blobs. public record struct ReplayRecordingStats(TimeSpan Time, uint Ticks, long Size, long UncompressedSize); +/// +/// Allows writing extra files directly into the replay file. +/// +/// +/// +public interface IReplayFileWriter +{ + /// + /// The base directory inside the replay directory you should generally be writing to. + /// This is equivalent to . + /// + ResPath BaseReplayPath { get; } + + /// + /// Writes arbitrary data into a file in the replay. + /// + /// The file path to write to. + /// The bytes to write to the file. + /// How much to compress the file. + void WriteBytes( + ResPath path, + ReadOnlyMemory bytes, + CompressionLevel compressionLevel = CompressionLevel.Optimal); +} + /// /// Engine-internal functions for . /// diff --git a/Robust.Shared/Replays/SharedReplayRecordingManager.cs b/Robust.Shared/Replays/SharedReplayRecordingManager.cs index 9291d987dbc..b5d842d53b4 100644 --- a/Robust.Shared/Replays/SharedReplayRecordingManager.cs +++ b/Robust.Shared/Replays/SharedReplayRecordingManager.cs @@ -49,6 +49,7 @@ internal abstract partial class SharedReplayRecordingManager : IReplayRecordingM public event Action>? RecordingStarted; public event Action? RecordingStopped; + public event Action? RecordingStopped2; public event Action? RecordingFinished; private ISawmill _sawmill = default!; @@ -312,6 +313,7 @@ protected virtual void Reset() // File stream & compression context is always disposed from the worker task. _recState.WriteCommandChannel.Complete(); + _recState.Done = true; _recState = null; } @@ -373,6 +375,11 @@ private void WriteFinalMetadata(RecordingState recState) { var yamlMetadata = new MappingDataNode(); RecordingStopped?.Invoke(yamlMetadata); + RecordingStopped2?.Invoke(new ReplayRecordingStopped + { + Metadata = yamlMetadata, + Writer = new ReplayFileWriter(this, recState) + }); var time = Timing.CurTime - recState.StartTime; yamlMetadata[MetaFinalKeyEndTick] = new ValueDataNode(Timing.CurTick.Value.ToString()); yamlMetadata[MetaFinalKeyDuration] = new ValueDataNode(time.ToString()); @@ -384,6 +391,7 @@ private void WriteFinalMetadata(RecordingState recState) // this just overwrites the previous yml with additional data. var document = new YamlDocument(yamlMetadata.ToYaml()); WriteYaml(recState, ReplayZipFolder / FileMetaFinal, document); + UpdateWriteTasks(); Reset(); @@ -492,6 +500,8 @@ private sealed class RecordingState public long CompressedSize; public long UncompressedSize; + public bool Done; + public RecordingState( ZipArchive zip, MemoryStream buffer, @@ -518,4 +528,23 @@ public RecordingState( WriteCommandChannel = writeCommandChannel; } } + + private sealed class ReplayFileWriter(SharedReplayRecordingManager manager, RecordingState state) + : IReplayFileWriter + { + public ResPath BaseReplayPath => ReplayZipFolder; + + public void WriteBytes(ResPath path, ReadOnlyMemory bytes, CompressionLevel compressionLevel) + { + CheckDisposed(); + + manager.WriteBytes(state, path, bytes, compressionLevel); + } + + private void CheckDisposed() + { + if (state.Done) + throw new ObjectDisposedException(nameof(ReplayFileWriter)); + } + } }