Add basic gamestate / replay recording. (#3509)

This commit is contained in:
Leon Friedrich
2023-02-12 18:16:30 +13:00
committed by GitHub
parent 024b6ef16d
commit d80522633d
14 changed files with 527 additions and 32 deletions

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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();
}

View 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"));
}
}

View File

@@ -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;
}

View File

@@ -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>();

View File

@@ -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
*/

View File

@@ -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;
}

View File

@@ -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;
}

View 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;
}
}