mirror of
https://github.com/space-wizards/RobustToolbox.git
synced 2026-02-14 19:29:36 +01:00
Add basic gamestate / replay recording. (#3509)
This commit is contained in:
@@ -522,13 +522,28 @@ cmd-vvread-desc = Retrieve a path's value using VV (View Variables).
|
||||
cmd-vvread-desc = Usage: vvread <path>
|
||||
|
||||
cmd-vvwrite-desc = Modify a path's value using VV (View Variables).
|
||||
cmd-vvwrite-desc = Usage: vvwrite <path>
|
||||
cmd-vvwrite-help = Usage: vvwrite <path>
|
||||
|
||||
cmd-vv-desc = Opens View Variables (VV).
|
||||
cmd-vv-desc = Usage: vv <path|entity ID|guihover>
|
||||
cmd-vv-help = Usage: vv <path|entity ID|guihover>
|
||||
|
||||
cmd-vvinvoke-desc = Invoke/Call a path with arguments using VV.
|
||||
cmd-vvinvoke-desc = Usage: vvinvoke <path> [arguments...]
|
||||
cmd-vvinvoke-help = Usage: vvinvoke <path> [arguments...]
|
||||
|
||||
cmd-replaystart-desc = Starts a replay recording, optionally with some time limit.
|
||||
cmd-replaystart-help = Usage: replaystart [minutes] [directory] [overwrite bool]
|
||||
cmd-replaystart-success = Started recording a replay.
|
||||
cmd-replaystart-already-recording = Already recording a replay.
|
||||
cmd-replaystart-error = An error occurred while trying to start the recording.
|
||||
|
||||
cmd-replaystop-desc = Stops a replay recording.
|
||||
cmd-replaystop-help = Usage: replaystop
|
||||
cmd-replaystop-success = Stopped recording a replay.
|
||||
cmd-replaystop-not-recording = Not currently recording a replay.
|
||||
|
||||
cmd-replaystats-desc = Displays information about the current replay recording.
|
||||
cmd-replaystats-help = Usage: replaystats
|
||||
cmd-replaystats-result = Duration: {$time} min, Ticks: {$ticks}, Size: {$size} mb, rate: {$rate} mb/min.
|
||||
|
||||
cmd-dump_dependency_injectors-desc = Dump IoCManager's dependency injector cache.
|
||||
cmd-dump_dependency_injectors-help = Usage: dump_dependency_injectors
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Robust.Shared.Replays;
|
||||
using Robust.Shared.Serialization.Markdown.Mapping;
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
|
||||
namespace Robust.Client.Replays;
|
||||
|
||||
@@ -11,4 +14,10 @@ public sealed class ReplayRecordingManager : IReplayRecordingManager
|
||||
public void QueueReplayMessage(object args) { }
|
||||
|
||||
public bool Recording => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event Action<(MappingDataNode, List<object>)>? OnRecordingStarted;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event Action<MappingDataNode>? OnRecordingStopped;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ using Robust.Server.GameStates;
|
||||
using Robust.Server.Log;
|
||||
using Robust.Server.Placement;
|
||||
using Robust.Server.Player;
|
||||
using Robust.Server.Replays;
|
||||
using Robust.Server.Scripting;
|
||||
using Robust.Server.ServerHub;
|
||||
using Robust.Server.ServerStatus;
|
||||
@@ -96,6 +97,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 IInternalReplayRecordingManager _replay = default!;
|
||||
|
||||
private readonly Stopwatch _uptimeStopwatch = new();
|
||||
|
||||
@@ -366,6 +368,7 @@ namespace Robust.Server
|
||||
_entityManager.Startup();
|
||||
_mapManager.Startup();
|
||||
_stateManager.Initialize();
|
||||
_replay.Initialize();
|
||||
|
||||
var reg = _entityManager.ComponentFactory.GetRegistration<TransformComponent>();
|
||||
if (!reg.NetID.HasValue)
|
||||
|
||||
@@ -1022,14 +1022,18 @@ internal sealed partial class PVSSystem : EntitySystem
|
||||
/// <summary>
|
||||
/// Gets all entity states that have been modified after and including the provided tick.
|
||||
/// </summary>
|
||||
public (List<EntityState>?, List<EntityUid>?, List<EntityUid>?, GameTick fromTick) GetAllEntityStates(ICommonSession player, GameTick fromTick, GameTick toTick)
|
||||
public (List<EntityState>?, List<EntityUid>?, List<EntityUid>?, GameTick fromTick) GetAllEntityStates(ICommonSession? player, GameTick fromTick, GameTick toTick)
|
||||
{
|
||||
List<EntityState>? stateEntities;
|
||||
var seenEnts = new HashSet<EntityUid>();
|
||||
var metaQuery = EntityManager.GetEntityQuery<MetaDataComponent>();
|
||||
List<(HashSet<EntityUid>, HashSet<EntityUid>)>? tickData = null;
|
||||
|
||||
if (!SeenAllEnts.Contains(player))
|
||||
bool sendAll = player == null
|
||||
? fromTick == GameTick.Zero
|
||||
: !SeenAllEnts.Contains(player);
|
||||
|
||||
if (sendAll)
|
||||
{
|
||||
// Give them E V E R Y T H I N G
|
||||
fromTick = GameTick.Zero;
|
||||
@@ -1105,12 +1109,12 @@ internal sealed partial class PVSSystem : EntitySystem
|
||||
/// <summary>
|
||||
/// Generates a network entity state for the given entity.
|
||||
/// </summary>
|
||||
/// <param name="player">The player to generate this state for.</param>
|
||||
/// <param name="player">The player to generate this state for. This may be null if the state is for replay recordings.</param>
|
||||
/// <param name="entityUid">Uid of the entity to generate the state from.</param>
|
||||
/// <param name="fromTick">Only provide delta changes from this tick.</param>
|
||||
/// <param name="meta">The entity's metadata component</param>
|
||||
/// <returns>New entity State for the given entity.</returns>
|
||||
private EntityState GetEntityState(ICommonSession player, EntityUid entityUid, GameTick fromTick, MetaDataComponent meta)
|
||||
private EntityState GetEntityState(ICommonSession? player, EntityUid entityUid, GameTick fromTick, MetaDataComponent meta)
|
||||
{
|
||||
var bus = EntityManager.EventBus;
|
||||
var changed = new List<ComponentChange>();
|
||||
@@ -1129,17 +1133,17 @@ internal sealed partial class PVSSystem : EntitySystem
|
||||
continue;
|
||||
}
|
||||
|
||||
if (component.SendOnlyToOwner && player.AttachedEntity != component.Owner)
|
||||
if (component.SendOnlyToOwner && player != null && player.AttachedEntity != component.Owner)
|
||||
continue;
|
||||
|
||||
if (component.LastModifiedTick <= fromTick)
|
||||
if (component.LastModifiedTick <= fromTick && fromTick != GameTick.Zero)
|
||||
{
|
||||
if (sendCompList && (!component.SessionSpecific || EntityManager.CanGetComponentState(bus, component, player)))
|
||||
if (sendCompList && (!component.SessionSpecific || player == null || EntityManager.CanGetComponentState(bus, component, player)))
|
||||
netComps!.Add(netId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (component.SessionSpecific && !EntityManager.CanGetComponentState(bus, component, player))
|
||||
if (component.SessionSpecific && player != null && !EntityManager.CanGetComponentState(bus, component, player))
|
||||
continue;
|
||||
|
||||
var state = EntityManager.GetComponentState(bus, component, player, fromTick);
|
||||
|
||||
@@ -23,6 +23,7 @@ using Robust.Shared.Utility;
|
||||
using SharpZstd.Interop;
|
||||
using Microsoft.Extensions.ObjectPool;
|
||||
using Robust.Shared.Players;
|
||||
using Robust.Server.Replays;
|
||||
|
||||
namespace Robust.Server.GameStates
|
||||
{
|
||||
@@ -42,6 +43,7 @@ namespace Robust.Server.GameStates
|
||||
[Dependency] private readonly IPlayerManager _playerManager = default!;
|
||||
[Dependency] private readonly INetworkedMapManager _mapManager = default!;
|
||||
[Dependency] private readonly IEntitySystemManager _systemManager = default!;
|
||||
[Dependency] private readonly IInternalReplayRecordingManager _replay = default!;
|
||||
[Dependency] private readonly IServerEntityNetworkManager _entityNetworkManager = default!;
|
||||
[Dependency] private readonly IConfigurationManager _cfg = default!;
|
||||
[Dependency] private readonly IParallelManager _parallelMgr = default!;
|
||||
@@ -107,7 +109,7 @@ namespace Robust.Server.GameStates
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PvsThreadResources
|
||||
internal sealed class PvsThreadResources
|
||||
{
|
||||
public ZStdCompressionContext CompressionContext;
|
||||
|
||||
@@ -224,11 +226,17 @@ namespace Robust.Server.GameStates
|
||||
}
|
||||
|
||||
Parallel.For(
|
||||
0, players.Length,
|
||||
_replay.Recording ? -1 : 0, players.Length,
|
||||
new ParallelOptions { MaxDegreeOfParallelism = _parallelMgr.ParallelProcessCount },
|
||||
_threadResourcesPool.Get,
|
||||
(i, _, resource) =>
|
||||
{
|
||||
if (i == -1)
|
||||
{
|
||||
_replay.SaveReplayData(resource);
|
||||
return resource;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
SendStateUpdate(i, resource);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Robust.Shared.IoC;
|
||||
using System.Threading;
|
||||
using static Robust.Server.GameStates.ServerGameStateManager;
|
||||
|
||||
namespace Robust.Server.Replays;
|
||||
|
||||
@@ -17,5 +18,5 @@ internal interface IInternalReplayRecordingManager : IServerReplayRecordingManag
|
||||
/// <remarks>
|
||||
/// This is intended to be called by PVS in parallel with other game-state networking.
|
||||
/// </remarks>
|
||||
void SaveReplayData(Thread mainThread, IDependencyCollection parentDeps);
|
||||
void SaveReplayData(PvsThreadResources resource);
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
using Robust.Shared.Replays;
|
||||
using System;
|
||||
|
||||
namespace Robust.Server.Replays;
|
||||
|
||||
public interface IServerReplayRecordingManager : IReplayRecordingManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Starts or stops a replay recording. The first tick will contain all game state data that would be sent to a
|
||||
/// new client with PVS disabled. Old messages queued with <see
|
||||
/// cref="IReplayRecordingManager.QueueReplayMessage(object)"/> are NOT included. Those messages are only saved
|
||||
/// once recording has started.
|
||||
/// </summary>
|
||||
void ToggleRecording();
|
||||
bool TryStartRecording(string? directory = null, bool overwrite = false, TimeSpan? duration = null);
|
||||
void StopRecording();
|
||||
|
||||
/// <summary>
|
||||
/// Returns information about the currently ongoing replay recording, including the currently elapsed time and the compressed replay size.
|
||||
/// </summary>
|
||||
(float Minutes, int Ticks, float Size, float UncompressedSize) GetReplayStats();
|
||||
}
|
||||
|
||||
86
Robust.Server/Replays/ReplayCommands.cs
Normal file
86
Robust.Server/Replays/ReplayCommands.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using Robust.Shared.Console;
|
||||
using Robust.Shared.IoC;
|
||||
using Robust.Shared.Localization;
|
||||
using System;
|
||||
|
||||
namespace Robust.Server.Replays;
|
||||
|
||||
internal sealed class ReplayStartCommand : LocalizedCommands
|
||||
{
|
||||
[Dependency] private readonly IServerReplayRecordingManager _replay = default!;
|
||||
|
||||
public override string Command => "replaystart";
|
||||
|
||||
public override void Execute(IConsoleShell shell, string argStr, string[] args)
|
||||
{
|
||||
if (_replay.Recording)
|
||||
{
|
||||
shell.WriteLine(Loc.GetString("cmd-replaystart-already-recording"));
|
||||
return;
|
||||
}
|
||||
|
||||
TimeSpan? duration = null;
|
||||
if (args.Length > 0)
|
||||
{
|
||||
if (!float.TryParse(args[0], out var minutes))
|
||||
{
|
||||
shell.WriteError(Loc.GetString("cmd-parse-failure-float", ("arg", args[0])));
|
||||
return;
|
||||
}
|
||||
duration = TimeSpan.FromMinutes(minutes);
|
||||
}
|
||||
|
||||
string? dir = args.Length < 2 ? null : args[1];
|
||||
|
||||
var overwrite = false;
|
||||
if (args.Length > 2)
|
||||
{
|
||||
if (!bool.TryParse(args[2], out overwrite))
|
||||
{
|
||||
shell.WriteError(Loc.GetString("cmd-parse-failure-bool", ("arg", args[2])));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_replay.TryStartRecording(dir, overwrite, duration))
|
||||
shell.WriteLine(Loc.GetString("cmd-replaystart-success"));
|
||||
else
|
||||
shell.WriteLine(Loc.GetString("cmd-replaystart-error"));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ReplayStopCommand : LocalizedCommands
|
||||
{
|
||||
[Dependency] private readonly IServerReplayRecordingManager _replay = default!;
|
||||
|
||||
public override string Command => "replaystop";
|
||||
|
||||
public override void Execute(IConsoleShell shell, string argStr, string[] args)
|
||||
{
|
||||
if (_replay.Recording)
|
||||
{
|
||||
_replay.StopRecording();
|
||||
shell.WriteLine(Loc.GetString("cmd-replaystop-success"));
|
||||
}
|
||||
else
|
||||
shell.WriteLine(Loc.GetString("cmd-replaystop-not-recording"));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ReplayStatsCommand : LocalizedCommands
|
||||
{
|
||||
[Dependency] private readonly IServerReplayRecordingManager _replay = default!;
|
||||
|
||||
public override string Command => "replaystats";
|
||||
|
||||
public override void Execute(IConsoleShell shell, string argStr, string[] args)
|
||||
{
|
||||
if (_replay.Recording)
|
||||
{
|
||||
var (time, tick, size, _) = _replay.GetReplayStats();
|
||||
shell.WriteLine(Loc.GetString("cmd-replaystats-result", ("time", time.ToString("F1")), ("ticks", tick), ("size", size.ToString("F1")), ("rate", (size/time).ToString("F2"))));
|
||||
}
|
||||
else
|
||||
shell.WriteLine(Loc.GetString("cmd-replaystop-error"));
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,321 @@
|
||||
using Robust.Server.GameStates;
|
||||
using Robust.Server.Player;
|
||||
using Robust.Shared;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Shared.ContentPack;
|
||||
using Robust.Shared.GameObjects;
|
||||
using Robust.Shared.GameStates;
|
||||
using Robust.Shared.IoC;
|
||||
using System.Threading;
|
||||
using Robust.Shared.Log;
|
||||
using Robust.Shared.Replays;
|
||||
using Robust.Shared.Serialization;
|
||||
using Robust.Shared.Serialization.Markdown.Mapping;
|
||||
using Robust.Shared.Serialization.Markdown.Value;
|
||||
using Robust.Shared.Timing;
|
||||
using Robust.Shared.Utility;
|
||||
using SharpZstd.Interop;
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using YamlDotNet.Core;
|
||||
using YamlDotNet.RepresentationModel;
|
||||
using static Robust.Server.GameStates.ServerGameStateManager;
|
||||
|
||||
namespace Robust.Server.Replays;
|
||||
|
||||
public sealed class ReplayRecordingManager : IInternalReplayRecordingManager
|
||||
internal sealed class ReplayRecordingManager : IInternalReplayRecordingManager
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public bool Recording => false;
|
||||
[Dependency] private readonly IGameTiming _timing = default!;
|
||||
[Dependency] private readonly IRobustSerializer _seri = default!;
|
||||
[Dependency] private readonly IPlayerManager _playerMan = default!;
|
||||
[Dependency] private readonly IEntitySystemManager _sysMan = default!;
|
||||
[Dependency] private readonly IResourceManager _resourceManager = default!;
|
||||
[Dependency] private readonly INetConfigurationManager _netConf = default!;
|
||||
|
||||
private ISawmill _sawmill = default!;
|
||||
private PVSSystem _pvs = default!;
|
||||
private List<object> _queuedMessages = new();
|
||||
|
||||
private int _maxCompressedSize;
|
||||
private int _maxUncompressedSize;
|
||||
private int _tickBatchSize;
|
||||
private bool _enabled;
|
||||
private ResourcePath? _path;
|
||||
public bool Recording => _curStream != null;
|
||||
private int _index = 0;
|
||||
private MemoryStream? _curStream;
|
||||
private int _currentCompressedSize;
|
||||
private int _currentUncompressedSize;
|
||||
private (GameTick Tick, TimeSpan Time) _recordingStart;
|
||||
private TimeSpan? _recordingEnd;
|
||||
private MappingDataNode? _yamlMetadata;
|
||||
private bool _firstTick = true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Initialize() { }
|
||||
public void Initialize()
|
||||
{
|
||||
_sawmill = Logger.GetSawmill("replay");
|
||||
_pvs = _sysMan.GetEntitySystem<PVSSystem>();
|
||||
|
||||
_netConf.OnValueChanged(CVars.ReplayEnabled, SetReplayEnabled, true);
|
||||
_netConf.OnValueChanged(CVars.ReplayMaxCompressedSize, (v) => _maxCompressedSize = v * 1024, true);
|
||||
_netConf.OnValueChanged(CVars.ReplayMaxUncompressedSize, (v) => _maxUncompressedSize = v * 1024, true);
|
||||
_netConf.OnValueChanged(CVars.ReplayTickBatchSize, (v) => _tickBatchSize = v * 1024, true);
|
||||
}
|
||||
|
||||
private void SetReplayEnabled(bool value)
|
||||
{
|
||||
if (!value)
|
||||
StopRecording();
|
||||
|
||||
_enabled = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void ToggleRecording() { }
|
||||
public void StopRecording()
|
||||
{
|
||||
if (_curStream == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
using var compressionContext = new ZStdCompressionContext();
|
||||
compressionContext.SetParameter(ZSTD_cParameter.ZSTD_c_compressionLevel, _netConf.GetCVar(CVars.NetPVSCompressLevel));
|
||||
WriteFile(compressionContext, continueRecording: false);
|
||||
_sawmill.Info("Replay recording stopped!");
|
||||
}
|
||||
catch
|
||||
{
|
||||
_curStream.Dispose();
|
||||
_curStream = null;
|
||||
_currentCompressedSize = 0;
|
||||
_currentUncompressedSize = 0;
|
||||
_index = 0;
|
||||
_firstTick = true;
|
||||
_recordingEnd = null;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void QueueReplayMessage(object obj) { }
|
||||
public bool TryStartRecording(string? directory = null, bool overwrite = false, TimeSpan? duration = null)
|
||||
{
|
||||
if (!_enabled || _curStream != null)
|
||||
return false;
|
||||
|
||||
var path = directory ?? _netConf.GetCVar(CVars.ReplayDirectory);
|
||||
_path = new ResourcePath(path).ToRootedPath();
|
||||
if (_resourceManager.UserData.Exists(_path))
|
||||
{
|
||||
if (overwrite)
|
||||
{
|
||||
_sawmill.Info($"File {path} already exists. Overwriting.");
|
||||
_resourceManager.UserData.Delete(_path);
|
||||
}
|
||||
else
|
||||
{
|
||||
_sawmill.Info($"File {path} already exists. Aborting.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_resourceManager.UserData.CreateDir(_path);
|
||||
|
||||
_curStream = new(_tickBatchSize * 2);
|
||||
_index = 0;
|
||||
_firstTick = true;
|
||||
WriteInitialMetadata();
|
||||
_recordingStart = (_timing.CurTick, _timing.CurTime);
|
||||
if (duration != null)
|
||||
_recordingEnd = _timing.CurTime + duration.Value;
|
||||
|
||||
_sawmill.Info("Started recording replay...");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void SaveReplayData(Thread mainThread, IDependencyCollection parentDeps) { }
|
||||
public void ToggleRecording()
|
||||
{
|
||||
if (Recording)
|
||||
StopRecording();
|
||||
else
|
||||
TryStartRecording();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void QueueReplayMessage(object obj)
|
||||
{
|
||||
if (!Recording)
|
||||
return;
|
||||
|
||||
DebugTools.Assert(obj.GetType().HasCustomAttribute<NetSerializableAttribute>());
|
||||
_queuedMessages.Add(obj);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void SaveReplayData(PvsThreadResources resource)
|
||||
{
|
||||
if (_curStream == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var lastAck = _firstTick ? GameTick.Zero : _timing.CurTick - 1;
|
||||
_firstTick = false;
|
||||
|
||||
var (entStates, deletions, _, __) = _pvs.GetAllEntityStates(null, lastAck, _timing.CurTick);
|
||||
var playerStates = _playerMan.GetPlayerStates(lastAck);
|
||||
var state = new GameState(lastAck, _timing.CurTick, 0, entStates, playerStates, deletions);
|
||||
|
||||
_seri.SerializeDirect(_curStream, state);
|
||||
_seri.SerializeDirect(_curStream, new ReplayMessage() { Messages = _queuedMessages });
|
||||
_queuedMessages.Clear();
|
||||
|
||||
bool continueRecording = _recordingEnd == null || _recordingEnd.Value >= _timing.CurTime;
|
||||
if (!continueRecording)
|
||||
_sawmill.Info("Reached requested replay recording length. Stopping recording.");
|
||||
|
||||
if (!continueRecording || _curStream.Length > _tickBatchSize)
|
||||
WriteFile(resource.CompressionContext, continueRecording);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_sawmill.Log(LogLevel.Error, e, "Caught exception while saving replay data.");
|
||||
StopRecording();
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteFile(ZStdCompressionContext compressionContext, bool continueRecording = true)
|
||||
{
|
||||
if (_curStream == null || _path == null)
|
||||
return;
|
||||
|
||||
_curStream.Position = 0;
|
||||
var filePath = _path / $"{_index++}.dat";
|
||||
using var file = _resourceManager.UserData.OpenWrite(filePath);
|
||||
|
||||
var buf = ArrayPool<byte>.Shared.Rent(ZStd.CompressBound((int)_curStream.Length));
|
||||
var length = compressionContext.Compress2(buf, _curStream.AsSpan());
|
||||
file.Write(BitConverter.GetBytes(length));
|
||||
file.Write(buf.AsSpan(0, length));
|
||||
ArrayPool<byte>.Shared.Return(buf);
|
||||
|
||||
_currentUncompressedSize += (int)_curStream.Length;
|
||||
_currentCompressedSize += length;
|
||||
if (_currentUncompressedSize >= _maxUncompressedSize || _currentCompressedSize >= _maxCompressedSize)
|
||||
{
|
||||
_sawmill.Info("Reached max replay recording size. Stopping recording.");
|
||||
continueRecording = false;
|
||||
}
|
||||
|
||||
if (continueRecording)
|
||||
_curStream.SetLength(0);
|
||||
else
|
||||
{
|
||||
WriteFinalMetadata();
|
||||
_curStream.Dispose();
|
||||
_curStream = null;
|
||||
_currentCompressedSize = 0;
|
||||
_currentUncompressedSize = 0;
|
||||
_index = 0;
|
||||
_firstTick = true;
|
||||
_recordingEnd = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write general replay data required to read the rest of the replay. We write this at the beginning rather than at the end on the off-chance that something goes wrong along the way and the recording is incomplete.
|
||||
/// </summary>
|
||||
private void WriteInitialMetadata()
|
||||
{
|
||||
if (_path == null)
|
||||
return;
|
||||
|
||||
var (stringHash, stringData) = _seri.GetStringSerializerPackage();
|
||||
var extraData = new List<object>();
|
||||
|
||||
// Saving YAML data
|
||||
{
|
||||
_yamlMetadata = new MappingDataNode();
|
||||
|
||||
// version info
|
||||
_yamlMetadata["engineVersion"] = new ValueDataNode(_netConf.GetCVar(CVars.BuildEngineVersion));
|
||||
_yamlMetadata["buildForkId"] = new ValueDataNode(_netConf.GetCVar(CVars.BuildForkId));
|
||||
_yamlMetadata["buildVersion"] = new ValueDataNode(_netConf.GetCVar(CVars.BuildVersion));
|
||||
|
||||
// Hash data
|
||||
_yamlMetadata["typeHash"] = new ValueDataNode(Convert.ToHexString(_seri.GetSerializableTypesHash()));
|
||||
_yamlMetadata["stringHash"] = new ValueDataNode(Convert.ToHexString(stringHash));
|
||||
|
||||
// Time data
|
||||
var timeBase = _timing.TimeBase;
|
||||
_yamlMetadata["startTick"] = new ValueDataNode(_timing.CurTick.Value.ToString());
|
||||
_yamlMetadata["timeBaseTick"] = new ValueDataNode(timeBase.Item2.Value.ToString());
|
||||
_yamlMetadata["timeBaseTimespan"] = new ValueDataNode(timeBase.Item1.Ticks.ToString());
|
||||
_yamlMetadata["recordingStartTime"] = new ValueDataNode(_recordingStart.ToString());
|
||||
|
||||
OnRecordingStarted?.Invoke((_yamlMetadata, extraData));
|
||||
|
||||
var document = new YamlDocument(_yamlMetadata.ToYaml());
|
||||
using var ymlFile = _resourceManager.UserData.OpenWriteText(_path / "replay.yml");
|
||||
var stream = new YamlStream { document };
|
||||
stream.Save(new YamlMappingFix(new Emitter(ymlFile)), false);
|
||||
}
|
||||
|
||||
// Saving misc extra data like networked messages that typically get sent to newly connecting clients.
|
||||
if (extraData.Count > 0)
|
||||
{
|
||||
using var initDataFile = _resourceManager.UserData.OpenWrite(_path / "init.dat");
|
||||
_seri.SerializeDirect(initDataFile, new ReplayMessage() { Messages = extraData });
|
||||
}
|
||||
|
||||
// save data required for IRobustMappedStringSerializer
|
||||
using var stringFile = _resourceManager.UserData.OpenWrite(_path / "strings.dat");
|
||||
stringFile.Write(stringData);
|
||||
|
||||
// Save replicated cvars.
|
||||
using var cvarsFile = _resourceManager.UserData.OpenWrite(_path / "cvars.toml");
|
||||
_netConf.SaveToTomlStream(cvarsFile, _netConf.GetReplicatedVars().Select(x => x.name));
|
||||
}
|
||||
|
||||
private void WriteFinalMetadata()
|
||||
{
|
||||
if (_yamlMetadata == null || _path == null)
|
||||
return;
|
||||
|
||||
OnRecordingStopped?.Invoke(_yamlMetadata);
|
||||
var time = _timing.CurTime - _recordingStart.Time;
|
||||
_yamlMetadata["endTick"] = new ValueDataNode(_timing.CurTick.Value.ToString());
|
||||
_yamlMetadata["duration"] = new ValueDataNode(time.ToString());
|
||||
_yamlMetadata["fileCount"] = new ValueDataNode(_index.ToString());
|
||||
_yamlMetadata["size"] = new ValueDataNode(_currentCompressedSize.ToString());
|
||||
_yamlMetadata["uncompressedSize"] = new ValueDataNode(_currentUncompressedSize.ToString());
|
||||
_yamlMetadata["recordingEndTime"] = new ValueDataNode(_timing.CurTime.ToString());
|
||||
|
||||
// this just overwrites the previous yml with additional data.
|
||||
var document = new YamlDocument(_yamlMetadata.ToYaml());
|
||||
using var ymlFile = _resourceManager.UserData.OpenWriteText(_path / "replay.yml");
|
||||
var stream = new YamlStream { document };
|
||||
stream.Save(new YamlMappingFix(new Emitter(ymlFile)), false);
|
||||
}
|
||||
|
||||
public (float Minutes, int Ticks, float Size, float UncompressedSize) GetReplayStats()
|
||||
{
|
||||
if (!Recording)
|
||||
return default;
|
||||
|
||||
var time = (_timing.CurTime - _recordingStart.Time).TotalMinutes;
|
||||
var tick = _timing.CurTick.Value - _recordingStart.Tick.Value;
|
||||
var size = _currentCompressedSize / (1024f * 1024f);
|
||||
var altSize = _currentUncompressedSize / (1024f * 1024f);
|
||||
|
||||
return ((float)time, (int)tick, size, altSize);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event Action<(MappingDataNode, List<object>)>? OnRecordingStarted;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event Action<MappingDataNode>? OnRecordingStopped;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ namespace Robust.Server
|
||||
deps.Register<IServerEntityManagerInternal, ServerEntityManager>();
|
||||
deps.Register<IServerGameStateManager, ServerGameStateManager>();
|
||||
deps.Register<IReplayRecordingManager, ReplayRecordingManager>();
|
||||
deps.Register<IServerReplayRecordingManager, ReplayRecordingManager>();
|
||||
deps.Register<IInternalReplayRecordingManager, ReplayRecordingManager>();
|
||||
deps.Register<IServerNetManager, NetManager>();
|
||||
deps.Register<IStatusHost, StatusHost>();
|
||||
deps.Register<ISystemConsoleManager, SystemConsoleManager>();
|
||||
|
||||
@@ -195,7 +195,7 @@ namespace Robust.Shared
|
||||
CVarDef.Create("net.pvs_exit_budget", 75, CVar.ARCHIVE | CVar.CLIENTONLY);
|
||||
|
||||
/// <summary>
|
||||
/// ZSTD compression level to use when compressing game states.
|
||||
/// ZSTD compression level to use when compressing game states. Also used for replays.
|
||||
/// </summary>
|
||||
public static readonly CVarDef<int> NetPVSCompressLevel =
|
||||
CVarDef.Create("net.pvs_compress_level", 3, CVar.SERVERONLY);
|
||||
@@ -1396,6 +1396,38 @@ namespace Robust.Shared
|
||||
/// </summary>
|
||||
public static readonly CVarDef<int> ProfIndexSize = CVarDef.Create("prof.index_size", 128);
|
||||
|
||||
/*
|
||||
* Replays
|
||||
*/
|
||||
|
||||
/// <summary>
|
||||
/// The folder within the server data directory where a replay will be recorded. Note that existing files in
|
||||
/// this directory will be removed when starting a new recording.
|
||||
/// </summary>
|
||||
public static readonly CVarDef<string> ReplayDirectory = CVarDef.Create("replay.directory", "replay", CVar.SERVERONLY | CVar.ARCHIVE);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum compressed size of a replay recording (in kilobytes) before recording automatically stops.
|
||||
/// </summary>
|
||||
public static readonly CVarDef<int> ReplayMaxCompressedSize = CVarDef.Create("replay.max_compressed_size", 1024 * 100, CVar.SERVERONLY | CVar.ARCHIVE);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum uncompressed size of a replay recording (in kilobytes) before recording automatically stops.
|
||||
/// </summary>
|
||||
public static readonly CVarDef<int> ReplayMaxUncompressedSize = CVarDef.Create("replay.max_uncompressed_size", 1024 * 300, CVar.SERVERONLY | CVar.ARCHIVE);
|
||||
|
||||
/// <summary>
|
||||
/// Uncompressed size of individual files created by the replay (in kilobytes), where each file contains data
|
||||
/// for one or more tick. Actual files may be slightly larger, this is just a lower threshold. After
|
||||
/// compressing, the files are generally ~30% of their uncompressed size.
|
||||
/// </summary>
|
||||
public static readonly CVarDef<int> ReplayTickBatchSize = CVarDef.Create("replay.replay_tick_batchSize", 1024, CVar.SERVERONLY | CVar.ARCHIVE);
|
||||
|
||||
/// <summary>
|
||||
/// Whether or not recording replays is enabled.
|
||||
/// </summary>
|
||||
public static readonly CVarDef<bool> ReplayEnabled = CVarDef.Create("replay.enabled", true, CVar.SERVERONLY | CVar.ARCHIVE);
|
||||
|
||||
/*
|
||||
* CFG
|
||||
*/
|
||||
|
||||
@@ -53,13 +53,13 @@ namespace Robust.Shared.GameStates
|
||||
public struct ComponentGetStateAttemptEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Input parameter. The player the state is being sent to.
|
||||
/// Input parameter. The player the state is being sent to. This may be null if the state is for replay recordings.
|
||||
/// </summary>
|
||||
public readonly ICommonSession Player;
|
||||
public readonly ICommonSession? Player;
|
||||
|
||||
public bool Cancelled = false;
|
||||
|
||||
public ComponentGetStateAttemptEvent(ICommonSession player)
|
||||
public ComponentGetStateAttemptEvent(ICommonSession? player)
|
||||
{
|
||||
Player = player;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Robust.Shared.GameObjects;
|
||||
using Robust.Shared.Serialization.Markdown.Mapping;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Robust.Shared.Replays;
|
||||
|
||||
@@ -20,4 +23,16 @@ public interface IReplayRecordingManager
|
||||
/// Whether the server is currently recording replay data.
|
||||
/// </summary>
|
||||
bool Recording { get; }
|
||||
|
||||
/// <summary>
|
||||
/// This gets invoked whenever a replay recording starts. Subscribers can use this to add extra yaml metadata
|
||||
/// data to the recording, as well as to effectively "raise" networked events that would get sent to a newly
|
||||
/// connecting "client".
|
||||
/// </summary>
|
||||
event Action<(MappingDataNode, List<object>)>? OnRecordingStarted;
|
||||
|
||||
/// <summary>
|
||||
/// This gets invoked whenever a replay recording ends. Subscribers can use this to add extra yaml metadata data to the recording.
|
||||
/// </summary>
|
||||
event Action<MappingDataNode>? OnRecordingStopped;
|
||||
}
|
||||
|
||||
19
Robust.Shared/Replays/ReplayData.cs
Normal file
19
Robust.Shared/Replays/ReplayData.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using Robust.Shared.Serialization;
|
||||
using Robust.Shared.Timing;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Robust.Shared.Replays;
|
||||
|
||||
[Serializable, NetSerializable]
|
||||
public sealed class ReplayMessage
|
||||
{
|
||||
public List<object> Messages = default!;
|
||||
|
||||
[Serializable, NetSerializable]
|
||||
public sealed class CvarChangeMsg
|
||||
{
|
||||
public List<(string name, object value)> ReplicatedCvars = default!;
|
||||
public (TimeSpan, GameTick) TimeBase = default;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user