diff --git a/Robust.Client/ClientIoC.cs b/Robust.Client/ClientIoC.cs index 969fc990d..e24d42465 100644 --- a/Robust.Client/ClientIoC.cs +++ b/Robust.Client/ClientIoC.cs @@ -16,6 +16,7 @@ using Robust.Client.Profiling; using Robust.Client.Prototypes; using Robust.Client.Reflection; using Robust.Client.Replays; +using Robust.Client.Replays.Loading; using Robust.Client.ResourceManagement; using Robust.Client.Serialization; using Robust.Client.State; @@ -79,6 +80,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 7a13cf13a..8207b1dc3 100644 --- a/Robust.Client/GameController/GameController.cs +++ b/Robust.Client/GameController/GameController.cs @@ -10,6 +10,7 @@ using Robust.Client.GameStates; using Robust.Client.Graphics; using Robust.Client.Input; using Robust.Client.Placement; +using Robust.Client.Replays.Loading; using Robust.Client.ResourceManagement; using Robust.Client.State; using Robust.Client.Upload; @@ -79,6 +80,7 @@ namespace Robust.Client [Dependency] private readonly MarkupTagManager _tagManager = default!; [Dependency] private readonly IGamePrototypeLoadManager _protoLoadMan = default!; [Dependency] private readonly NetworkResourceManager _netResMan = default!; + [Dependency] private readonly IReplayLoadManager _replayLoader = default!; private IWebViewManagerHook? _webViewHook; @@ -178,6 +180,7 @@ namespace Robust.Client _tagManager.Initialize(); _protoLoadMan.Initialize(); _netResMan.Initialize(); + _replayLoader.Initialize(); _userInterfaceManager.PostInitialize(); _modLoader.BroadcastRunLevel(ModRunLevel.PostInit); @@ -551,9 +554,9 @@ namespace Robust.Client { using (_prof.Group("Entity")) { - if (ContentEntityTickUpdate != null) + if (TickUpdateOverride != null) { - ContentEntityTickUpdate.Invoke(frameEventArgs); + TickUpdateOverride.Invoke(frameEventArgs); } else { @@ -734,6 +737,6 @@ namespace Robust.Client bool AutoConnect ); - public event Action? ContentEntityTickUpdate; + public event Action? TickUpdateOverride; } } diff --git a/Robust.Client/IGameController.cs b/Robust.Client/IGameController.cs index 7fb13e360..ee3190622 100644 --- a/Robust.Client/IGameController.cs +++ b/Robust.Client/IGameController.cs @@ -25,6 +25,6 @@ public interface IGameController /// controller will simply call . /// This exists to give content module more control over tick updating. /// - event Action? ContentEntityTickUpdate; + event Action? TickUpdateOverride; } diff --git a/Robust.Client/Placement/PlacementManager.cs b/Robust.Client/Placement/PlacementManager.cs index 220db4640..558c1a877 100644 --- a/Robust.Client/Placement/PlacementManager.cs +++ b/Robust.Client/Placement/PlacementManager.cs @@ -499,9 +499,9 @@ namespace Robust.Client.Placement { // Try to get current map. var map = MapId.Nullspace; - if (PlayerManager.LocalPlayer?.ControlledEntity is {Valid: true} ent) + if (EntityManager.TryGetComponent(PlayerManager.LocalPlayer?.ControlledEntity, out TransformComponent? xform)) { - map = EntityManager.GetComponent(ent).MapID; + map = xform.MapID; } if (map == MapId.Nullspace || CurrentPermission == null || CurrentMode == null) diff --git a/Robust.Client/Replays/Loading/IReplayLoadManager.cs b/Robust.Client/Replays/Loading/IReplayLoadManager.cs new file mode 100644 index 000000000..b3f97f990 --- /dev/null +++ b/Robust.Client/Replays/Loading/IReplayLoadManager.cs @@ -0,0 +1,78 @@ +using System.Threading.Tasks; +using Robust.Shared.ContentPack; +using Robust.Shared.CPUJob.JobQueues; +using Robust.Shared.Replays; +using Robust.Shared.Serialization.Markdown.Mapping; +using Robust.Shared.Utility; + +namespace Robust.Client.Replays.Loading; + +public interface IReplayLoadManager +{ + public void Initialize(); + + /// + /// Load metadata information from a replay's yaml file. + /// + /// A directory containing the replay files. + /// The path to the replay's subdirectory. + public MappingDataNode? LoadYamlMetadata(IWritableDirProvider dir, ResPath path); + + /// + /// Async task that loads up a replay for playback. + /// + /// + /// This task is intended to be used with a so that the loading can happen over several frame + /// updates. Note that a load is being processed over multiple "ticks", then the normal system tick updating needs + /// to be blocked by subscribing to in order to avoid errors while + /// systems iterate over pre-init or pre-startup entities. + /// + /// A directory containing the replay data that should be loaded. + /// The path to the replay's subdirectory. + /// A callback delegate that invoked to provide information about the current loading + /// progress. This callback can be used to invoke . + Task LoadReplayAsync(IWritableDirProvider dir, ResPath path, LoadReplayCallback callback); + + /// + /// Async task that loads the initial state of a replay, including spawning & initializing all entities. + /// + /// + /// This task is intended to be used with a so that the loading can happen over several frame + /// updates. Note that a load is being processed over multiple "ticks", then the normal system tick updating needs + /// to be blocked by subscribing to in order to avoid errors while + /// systems iterate over pre-init or pre-startup entities. + /// + /// A callback delegate that invoked to provide information about the current loading + /// progress. This callback can be used to invoke . + Task StartReplayAsync(ReplayData data, LoadReplayCallback callback); + + /// + /// Convenience function that combines and + /// + /// + /// This task is intended to be used with a so that the loading can happen over several frame + /// updates. Note that a load is being processed over multiple "ticks", then the normal system tick updating needs + /// to be blocked by subscribing to in order to avoid errors while + /// systems iterate over pre-init or pre-startup entities. + /// + /// A directory containing the replay files. + /// The path to the replay's subdirectory. + /// A callback delegate that invoked to provide information about the current loading + /// progress. This callback can be used to invoke . + Task LoadAndStartReplayAsync(IWritableDirProvider dir, ResPath path, LoadReplayCallback? callback = null); +} + +public delegate Task LoadReplayCallback(float current, float max, LoadingState state, bool forceSuspend); + +/// +/// Enum used to indicate loading progress. +/// +public enum LoadingState : byte +{ + ReadingFiles, + ProcessingFiles, + Spawning, + Initializing, + Starting, +} + diff --git a/Robust.Client/Replays/Loading/LoadReplayJob.cs b/Robust.Client/Replays/Loading/LoadReplayJob.cs new file mode 100644 index 000000000..136ebeba5 --- /dev/null +++ b/Robust.Client/Replays/Loading/LoadReplayJob.cs @@ -0,0 +1,46 @@ +using Robust.Shared.ContentPack; +using System.Threading.Tasks; +using Robust.Shared.CPUJob.JobQueues; +using Robust.Shared.Replays; +using Robust.Shared.Utility; + +namespace Robust.Client.Replays.Loading; + +/// +/// Simple job for loading some replay file. Note that tick updates need to be blocked +/// () in order to avoid unexpected errors. +/// +[Virtual] +public class LoadReplayJob : Job +{ + private readonly IWritableDirProvider _dir; + private readonly ResPath _path; + private readonly IReplayLoadManager _loadMan; + + public LoadReplayJob( + float maxTime, + IWritableDirProvider dir, + ResPath path, + IReplayLoadManager loadMan) + : base(maxTime) + { + _dir = dir; + _path = path; + _loadMan = loadMan; + } + + protected override async Task Process() + { + return await _loadMan.LoadAndStartReplayAsync(_dir, _path, Yield); + } + + protected virtual async Task Yield(float value, float maxValue, LoadingState state, bool force) + { + // Content inheritors can update some UI or loading indicator here + + if (force) + await SuspendNow(); + else + await SuspendIfOutOfTime(); + } +} diff --git a/Robust.Client/Replays/Loading/ReplayLoadManager.Checkpoints.cs b/Robust.Client/Replays/Loading/ReplayLoadManager.Checkpoints.cs new file mode 100644 index 000000000..4a229dbf6 --- /dev/null +++ b/Robust.Client/Replays/Loading/ReplayLoadManager.Checkpoints.cs @@ -0,0 +1,309 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NetSerializer; +using Robust.Shared.GameStates; +using Robust.Shared.Network; +using Robust.Shared.Timing; +using Robust.Shared.Utility; +using System.Threading.Tasks; +using Robust.Shared.GameObjects; +using Robust.Shared.Replays; +using Robust.Shared.Upload; +using static Robust.Shared.Replays.ReplayMessage; + +namespace Robust.Client.Replays.Loading; + +// This partial class contains functions for generating "checkpoint" states, which are basically just full states that +// allow the client to jump to some point in time without having to re-process the whole replay up to that point. I.e., +// so that when jumping to tick 1001 the client only has to apply states for tick 1000 and 1001, instead of 0, 1, 2, ... +public sealed partial class ReplayLoadManager +{ + public async Task GenerateCheckpointsAsync( + ReplayMessage? initMessages, + HashSet initialCvars, + List states, + List messages, + LoadReplayCallback callback) + { + // Given a set of states [0 to X], [X to X+1], [X+1 to X+2]..., this method will generate additional states + // like [0 to x+60 ], [0 to x+120], etc. This will make scrubbing/jumping to a state much faster, but requires + // some pre-processing all of the states. + // + // This whole mess of a function uses a painful amount of LINQ conversion. but sadly the networked data is + // generally sent as a list of values, which makes sense if the list contains simple state delta data that all + // needs to be applied. But here we need to inspect existing states and combine/merge them, so things generally + // need to be converted into a dictionary. But even with that requirement there are a bunch of performance + // improvements to be made even without just de-LINQuifing or changing the networked data. + // + // Profiling with a 10 minute, 80-player replay, this function is about 50% entity spawning and 50% MergeState() + // & array copying. It only takes ~3 seconds on my machine, so optimising it might not be necessary, but there + // is still some low-hanging fruit, like: + // TODO REPLAYS serialize checkpoints after first loading a replay so they only need to be generated once. + // + // TODO REPLAYS Add dynamic checkpoints. + // If we end up using long (e.g., 5 minute) checkpoint intervals, that might still mean that scrubbing/rewinding + // short time periods will be super stuttery. So its probably worth keeping a dynamic checkpoint following the + // users current tick. E.g. while a replay is being replayed, keep a dynamic checkpoint that is ~30 secs behind + // the current tick. that way the user can always go back up to ~30 seconds without having to go back to the + // last checkpoint. + // + // Alternatively maybe just generate reverse states? I.e. states containing data that is required to go from + // tick X to X-1? (currently any ent that had any changes will reset ALL of its components, not just the states + // that actually need resetting. basically: iterate forwards though states. anytime a new comp state gets + // applied, for the reverse state simply add the previously applied component state. + + _sawmill.Info($"Begin checkpoint generation"); + var st = new Stopwatch(); + st.Start(); + + Dictionary cvars = new(); + foreach (var cvar in initialCvars) + { + cvars[cvar] = _confMan.GetCVar(cvar); + } + + var timeBase = _timing.TimeBase; + var checkPoints = new List(1 + states.Count / _checkpointInterval); + var state0 = states[0]; + + // Get all initial prototypes + var prototypes = new Dictionary>(); + foreach (var kindName in _protoMan.GetPrototypeKinds()) + { + var kind = _protoMan.GetKindType(kindName); + var set = new HashSet(); + prototypes[kind] = set; + foreach (var proto in _protoMan.EnumeratePrototypes(kind)) + { + set.Add(proto.ID); + } + } + + HashSet uploadedFiles = new(); + if (initMessages != null) + UpdateMessages(initMessages, uploadedFiles, prototypes, cvars, ref timeBase, true); + UpdateMessages(messages[0], uploadedFiles, prototypes, cvars, ref timeBase, true); + + var entSpan = state0.EntityStates.Value; + Dictionary entStates = new(entSpan.Count); + foreach (var entState in entSpan) + { + var modifiedState = AddImplicitData(entState); + entStates.Add(entState.Uid, modifiedState); + } + + await callback(0, states.Count, LoadingState.ProcessingFiles, true); + var playerSpan = state0.PlayerStates.Value; + Dictionary playerStates = new(playerSpan.Count); + foreach (var player in playerSpan) + { + playerStates.Add(player.UserId, player); + } + + state0 = new GameState(GameTick.Zero, + state0.ToSequence, + default, + entStates.Values.ToArray(), + playerStates.Values.ToArray(), + Array.Empty()); + checkPoints.Add(new CheckpointState(state0, timeBase, cvars, 0)); + + DebugTools.Assert(state0.EntityDeletions.Value.Count == 0); + var empty = Array.Empty(); + + var ticksSinceLastCheckpoint = 0; + var spawnedTracker = 0; + var stateTracker = 0; + for (var i = 1; i < states.Count; i++) + { + if (i % 10 == 0) + await callback(i, states.Count, LoadingState.ProcessingFiles, false); + + var curState = states[i]; + UpdatePlayerStates(curState.PlayerStates.Span, playerStates); + UpdateDeletions(curState.EntityDeletions, entStates); + UpdateEntityStates(curState.EntityStates.Span, entStates, ref spawnedTracker, ref stateTracker); + UpdateMessages(messages[i], uploadedFiles, prototypes, cvars, ref timeBase); + ticksSinceLastCheckpoint++; + + if (ticksSinceLastCheckpoint < _checkpointInterval && spawnedTracker < _checkpointEntitySpawnThreshold && stateTracker < _checkpointEntityStateThreshold) + continue; + + ticksSinceLastCheckpoint = 0; + spawnedTracker = 0; + stateTracker = 0; + var newState = new GameState(GameTick.Zero, + curState.ToSequence, + default, + entStates.Values.ToArray(), + playerStates.Values.ToArray(), + empty); // for full states, deletions are implicit by simply not being in the state + checkPoints.Add(new CheckpointState(newState, timeBase, cvars, i)); + } + + _sawmill.Info($"Finished generating checkpoints. Elapsed time: {st.Elapsed}"); + await callback(states.Count, states.Count, LoadingState.ProcessingFiles, false); + return checkPoints.ToArray(); + } + + private void UpdateMessages(ReplayMessage message, + HashSet uploadedFiles, + Dictionary> prototypes, + Dictionary cvars, + ref (TimeSpan, GameTick) timeBase, + bool ignoreDuplicates = false) + { + foreach (var msg in message.Messages) + { + switch (msg) + { + case CvarChangeMsg cvar: + foreach (var (name, value) in cvar.ReplicatedCvars) + { + cvars[name] = value; + } + + timeBase = cvar.TimeBase; + break; + + case SharedNetworkResourceManager.ReplayResourceUploadMsg resUpload: + + var path = resUpload.RelativePath.Clean().ToRelativePath(); + if (uploadedFiles.Add(path) && !_netResMan.FileExists(path)) + { + _netMan.DispatchLocalNetMessage(new NetworkResourceUploadMessage + { + RelativePath = path, Data = resUpload.Data + }); + break; + } + + // Supporting this requires allowing files to track their last-modified time and making + // checkpoints reset files when jumping back, and applying all previous changes when jumping + // forwards. Also, note that files HAVE to be uploaded while generating checkpoints, in case + // someone spawns an entity that relies on uploaded data. + if (!ignoreDuplicates) + throw new NotSupportedException("Overwriting an existing file is not yet supported by replays."); + + break; + } + } + + // Process prototype uploads **after** resource uploads. + foreach (var msg in message.Messages) + { + if (msg is not ReplayPrototypeUploadMsg protoUpload) + continue; + + var changed = new Dictionary>(); + _protoMan.LoadString(protoUpload.PrototypeData, true, changed); + + foreach (var (kind, ids) in changed) + { + var protos = prototypes[kind]; + var count = protos.Count; + protos.UnionWith(ids); + if (!ignoreDuplicates && ids.Count + count != protos.Count) + { + // An existing prototype was overwritten. Much like for resource uploading, supporting this + // requires tracking the last-modified time of prototypes and either resetting or applying + // prototype changes when jumping around in time. This also requires reworking how the initial + // implicit state data is generated, because we can't simply cache it anymore. + // Also, does reloading prototypes in release mode modify existing entities? + throw new NotSupportedException($"Overwriting an existing prototype is not yet supported by replays."); + } + } + + _protoMan.ResolveResults(); + _protoMan.ReloadPrototypes(changed); + _locMan.ReloadLocalizations(); + } + } + + private void UpdateDeletions(NetListAsArray entityDeletions, Dictionary entStates) + { + foreach (var ent in entityDeletions.Span) + { + entStates.Remove(ent); + } + } + + private void UpdateEntityStates(ReadOnlySpan span, Dictionary entStates, ref int spawnedTracker, ref int stateTracker) + { + foreach (var entState in span) + { + if (!entStates.TryGetValue(entState.Uid, out var oldEntState)) + { + var modifiedState = AddImplicitData(entState); + entStates[entState.Uid] = modifiedState; + spawnedTracker++; + +#if DEBUG + foreach (var state in modifiedState.ComponentChanges.Value) + { + DebugTools.Assert(state.State is not IComponentDeltaState delta || delta.FullState); + } +#endif + continue; + } + + stateTracker++; + DebugTools.Assert(oldEntState.Uid == entState.Uid); + entStates[entState.Uid] = MergeStates(entState, oldEntState.ComponentChanges.Value, oldEntState.NetComponents); + +#if DEBUG + foreach (var state in entStates[entState.Uid].ComponentChanges.Span) + { + DebugTools.Assert(state.State is not IComponentDeltaState delta || delta.FullState); + } +#endif + } + } + + private EntityState MergeStates( + EntityState newState, + IReadOnlyCollection oldState, + HashSet? oldNetComps) + { + var combined = oldState.ToList(); + var newCompStates = newState.ComponentChanges.Value.ToDictionary(x => x.NetID); + + // remove any deleted components + if (newState.NetComponents != null) + { + for (var index = combined.Count - 1; index >= 0; index--) + { + if (!newState.NetComponents.Contains(combined[index].NetID)) + combined.RemoveSwap(index); + } + } + + for (var index = combined.Count - 1; index >= 0; index--) + { + var existing = combined[index]; + + if (!newCompStates.TryGetValue(existing.NetID, out var newCompState)) + continue; + + if (newCompState.State is not IComponentDeltaState delta || delta.FullState) + { + combined[index] = newCompState; + continue; + } + + DebugTools.Assert(existing.State is IComponentDeltaState fullDelta && fullDelta.FullState); + combined[index] = new ComponentChange(existing.NetID, delta.CreateNewFullState(existing.State), newCompState.LastModifiedTick); + } + + return new EntityState(newState.Uid, combined, newState.EntityLastModified, newState.NetComponents ?? oldNetComps); + } + + private void UpdatePlayerStates(ReadOnlySpan span, Dictionary playerStates) + { + foreach (var player in span) + { + playerStates[player.UserId] = player; + } + } +} diff --git a/Robust.Client/Replays/Loading/ReplayLoadManager.Implicit.cs b/Robust.Client/Replays/Loading/ReplayLoadManager.Implicit.cs new file mode 100644 index 000000000..931e53f1f --- /dev/null +++ b/Robust.Client/Replays/Loading/ReplayLoadManager.Implicit.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using Robust.Shared.GameObjects; +using Robust.Shared.Timing; +using Robust.Shared.Utility; +using Robust.Shared.Map; + +namespace Robust.Client.Replays.Loading; + +// This partial class contains code for generating implicit component states. +public sealed partial class ReplayLoadManager +{ + /// + /// Cached implicit entity states. + /// + private Dictionary, HashSet)> _implicitData = new(); + + private EntityState AddImplicitData(EntityState entState) + { + var prototype = GetPrototype(entState); + if (prototype == null) + return entState; + + var (list, set) = GetImplicitData(prototype); + return MergeStates(entState, list, set); + } + + private (List, HashSet) GetImplicitData(string prototype) + { + if (_implicitData.TryGetValue(prototype, out var result)) + return result; + + var list = new List(); + var set = new HashSet(); + _implicitData[prototype] = (list, set); + + var entCount = _entMan.EntityCount; + var uid = _entMan.SpawnEntity(prototype, MapCoordinates.Nullspace); + + foreach (var (netId, component) in _entMan.GetNetComponents(uid)) + { + if (!component.NetSyncEnabled) + continue; + + var state = _entMan.GetComponentState(_entMan.EventBus, component, null, GameTick.Zero); + DebugTools.Assert(state is not IComponentDeltaState delta || delta.FullState); + list.Add(new ComponentChange(netId, state, GameTick.Zero)); + set.Add(netId); + } + + _entMan.DeleteEntity(uid); + DebugTools.Assert(entCount == _entMan.EntityCount); + return (list, set); + } + + private string? GetPrototype(EntityState entState) + { + foreach (var comp in entState.ComponentChanges.Span) + { + if (comp.NetID == _metaId) + { + var state = (MetaDataComponentState) comp.State; + return state.PrototypeId; + } + } + + throw new Exception("Missing metadata component"); + } +} diff --git a/Robust.Client/Replays/Loading/ReplayLoadManager.Read.cs b/Robust.Client/Replays/Loading/ReplayLoadManager.Read.cs new file mode 100644 index 000000000..afb14f861 --- /dev/null +++ b/Robust.Client/Replays/Loading/ReplayLoadManager.Read.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Robust.Shared.ContentPack; +using Robust.Shared.GameStates; +using Robust.Shared.Serialization; +using Robust.Shared.Serialization.Markdown; +using Robust.Shared.Serialization.Markdown.Mapping; +using Robust.Shared.Serialization.Markdown.Value; +using Robust.Shared.Timing; +using Robust.Shared.Utility; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Robust.Shared.Replays; +using static Robust.Shared.Replays.IReplayRecordingManager; + +namespace Robust.Client.Replays.Loading; + +public sealed partial class ReplayLoadManager +{ + [SuppressMessage("ReSharper", "UseAwaitUsing")] + public async Task LoadReplayAsync(IWritableDirProvider dir, ResPath path, LoadReplayCallback callback) + { + List states = new(); + List messages = new(); + + var compressionContext = new ZStdCompressionContext(); + var metaData = LoadMetadata(dir, path); + + var total = dir.Find($"{path.ToRelativePath()}/*.{Ext}").files.Count(); + + // Exclude string & init event files from the total. + total--; + if (dir.Exists(path / InitFile)) + total--; + + var i = 0; + var intBuf = new byte[4]; + var name = path / $"{i++}.{Ext}"; + while (dir.Exists(name)) + { + await callback(i+1, total, LoadingState.ReadingFiles, false); + + using var fileStream = dir.OpenRead(name); + using var decompressStream = new ZStdDecompressStream(fileStream, false); + + fileStream.Read(intBuf); + var uncompressedSize = BitConverter.ToInt32(intBuf); + + var decompressedStream = new MemoryStream(uncompressedSize); + decompressStream.CopyTo(decompressedStream, uncompressedSize); + decompressedStream.Position = 0; + + while (decompressedStream.Position < decompressedStream.Length) + { + _serializer.DeserializeDirect(decompressedStream, out GameState state); + _serializer.DeserializeDirect(decompressedStream, out ReplayMessage msg); + states.Add(state); + messages.Add(msg); + } + + name = path / $"{i++}.{Ext}"; + } + DebugTools.Assert(i - 1 == total); + await callback(total, total, LoadingState.ReadingFiles, false); + + var initData = LoadInitFile(dir, path, compressionContext); + compressionContext.Dispose(); + + var checkpoints = await GenerateCheckpointsAsync(initData, metaData.CVars, states, messages, callback); + return new(states, messages, states[0].ToSequence, metaData.StartTime, metaData.Duration, checkpoints, initData); + } + + private ReplayMessage? LoadInitFile( + IWritableDirProvider dir, + ResPath path, + ZStdCompressionContext compressionContext) + { + if (!dir.Exists(path / InitFile)) + return null; + + // TODO compress init messages, then decompress them here. + using var fileStream = dir.OpenRead(path / InitFile); + _serializer.DeserializeDirect(fileStream, out ReplayMessage initData); + return initData; + } + + public MappingDataNode? LoadYamlMetadata(IWritableDirProvider directory, ResPath resPath) + { + if (!directory.Exists(resPath / MetaFile)) + return null; + + using var file = directory.OpenRead(resPath / MetaFile); + var parsed = DataNodeParser.ParseYamlStream(new StreamReader(file)); + return parsed.FirstOrDefault()?.Root as MappingDataNode; + } + + private (HashSet CVars, TimeSpan Duration, TimeSpan StartTime) + LoadMetadata(IWritableDirProvider directory, ResPath path) + { + _sawmill.Info($"Reading replay metadata"); + var data = LoadYamlMetadata(directory, path); + if (data == null) + throw new Exception("Failed to parse yaml metadata"); + + var typeHash = Convert.FromHexString(((ValueDataNode) data[Hash]).Value); + var stringHash = Convert.FromHexString(((ValueDataNode) data[Strings]).Value); + var startTick = ((ValueDataNode) data[Tick]).Value; + var timeBaseTick = ((ValueDataNode) data[BaseTick]).Value; + var timeBaseTimespan = ((ValueDataNode) data[BaseTime]).Value; + var duration = TimeSpan.Parse(((ValueDataNode) data[Duration]).Value); + + if (!typeHash.SequenceEqual(_serializer.GetSerializableTypesHash())) + throw new Exception($"{nameof(IRobustSerializer)} hashes do not match. Loading replays using a bad replay-client version?"); + + using var stringFile = directory.OpenRead(path / StringsFile); + var stringData = new byte[stringFile.Length]; + stringFile.Read(stringData); + _serializer.SetStringSerializerPackage(stringHash, stringData); + + using var cvarsFile = directory.OpenRead(path / CvarFile); + // Note, this does not invoke the received-initial-cvars event. But at least currently, that doesn't matter + var cvars = _confMan.LoadFromTomlStream(cvarsFile); + + _timing.CurTick = new GameTick(uint.Parse(startTick)); + _timing.TimeBase = (new TimeSpan(long.Parse(timeBaseTimespan)), new GameTick(uint.Parse(timeBaseTick))); + + _sawmill.Info($"Successfully read metadata"); + return (cvars, duration, _timing.CurTime); + } +} diff --git a/Robust.Client/Replays/Loading/ReplayLoadManager.Start.cs b/Robust.Client/Replays/Loading/ReplayLoadManager.Start.cs new file mode 100644 index 000000000..040a21d58 --- /dev/null +++ b/Robust.Client/Replays/Loading/ReplayLoadManager.Start.cs @@ -0,0 +1,101 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Robust.Client.GameStates; +using Robust.Shared.Timing; +using Robust.Shared.ContentPack; +using Robust.Shared.GameObjects; +using Robust.Shared.Replays; +using Robust.Shared.Utility; + +namespace Robust.Client.Replays.Loading; + +public sealed partial class ReplayLoadManager +{ + public async Task LoadAndStartReplayAsync( + IWritableDirProvider dir, + ResPath path, + LoadReplayCallback? callback = null) + { + callback ??= (_, _, _, _) => Task.CompletedTask; + var data = await LoadReplayAsync(dir, path, callback); + await StartReplayAsync(data, callback); + return data; + } + + public async Task StartReplayAsync(ReplayData data, LoadReplayCallback callback) + { + if (data.Checkpoints.Length == 0) + return; + + var checkpoint = data.Checkpoints[0]; + data.CurrentIndex = checkpoint.Index; + var state = checkpoint.State; + + foreach (var (name, value) in checkpoint.Cvars) + { + _confMan.SetCVar(name, value, force: true); + } + + var tick = new GameTick(data.TickOffset.Value + (uint) data.CurrentIndex); + _timing.CurTick = _timing.LastRealTick = _timing.LastProcessedTick = tick; + + _gameState.UpdateFullRep(state, cloneDelta: true); + + var i = 0; + var total = state.EntityStates.Value.Count; + List entities = new(state.EntityStates.Value.Count); + + await callback(i, total, LoadingState.Spawning, true); + foreach (var ent in state.EntityStates.Value) + { + var metaState = (MetaDataComponentState?)ent.ComponentChanges.Value? + .FirstOrDefault(c => c.NetID == _metaId).State; + if (metaState == null) + throw new MissingMetadataException(ent.Uid); + + _entMan.CreateEntityUninitialized(metaState.PrototypeId, ent.Uid); + entities.Add(ent.Uid); + + if (i++ % 50 == 0) + { + await callback(i, total, LoadingState.Spawning, false); + _timing.CurTick = tick; + } + } + + await callback(0, total, LoadingState.Initializing, true); + // TODO add async variant? + _gameState.ApplyGameState(state, data.NextState); + + i = 0; + var query = _entMan.GetEntityQuery(); + foreach (var uid in entities) + { + _entMan.InitializeEntity(uid, query.GetComponent(uid)); + if (i++ % 50 == 0) + { + await callback(i, total, LoadingState.Initializing, false); + _timing.CurTick = tick; + } + } + + i = 0; + await callback(0, total, LoadingState.Starting, true); + foreach (var uid in entities) + { + _entMan.StartEntity(uid); + if (i++ % 50 == 0) + { + await callback(i, total, LoadingState.Starting, false); + _timing.CurTick = tick; + } + } + + _timing.TimeBase = checkpoint.TimeBase; + data.LastApplied = state.ToSequence; + DebugTools.Assert(_timing.LastRealTick == tick); + DebugTools.Assert(_timing.LastProcessedTick == tick); + _timing.CurTick = tick + 1; + } +} diff --git a/Robust.Client/Replays/Loading/ReplayLoadManager.cs b/Robust.Client/Replays/Loading/ReplayLoadManager.cs new file mode 100644 index 000000000..def4ccefe --- /dev/null +++ b/Robust.Client/Replays/Loading/ReplayLoadManager.cs @@ -0,0 +1,48 @@ +using Robust.Client.GameStates; +using Robust.Client.Serialization; +using Robust.Client.Timing; +using Robust.Client.Upload; +using Robust.Shared; +using Robust.Shared.Network; +using Robust.Shared.Configuration; +using Robust.Shared.GameObjects; +using Robust.Shared.IoC; +using Robust.Shared.Localization; +using Robust.Shared.Log; +using Robust.Shared.Prototypes; + +namespace Robust.Client.Replays.Loading; + +public sealed partial class ReplayLoadManager : IReplayLoadManager +{ + [Dependency] private readonly IEntityManager _entMan = default!; + [Dependency] private readonly IClientGameTiming _timing = default!; + [Dependency] private readonly IClientNetManager _netMan = default!; + [Dependency] private readonly IComponentFactory _factory = default!; + [Dependency] private readonly IPrototypeManager _protoMan = default!; + [Dependency] private readonly ILocalizationManager _locMan = default!; + [Dependency] private readonly IConfigurationManager _confMan = default!; + [Dependency] private readonly NetworkResourceManager _netResMan = default!; + [Dependency] private readonly IClientGameStateManager _gameState = default!; + [Dependency] private readonly IClientRobustSerializer _serializer = default!; + + private ushort _metaId; + private bool _initialized; + private int _checkpointInterval; + private int _checkpointEntitySpawnThreshold; + private int _checkpointEntityStateThreshold; + private ISawmill _sawmill = default!; + + public void Initialize() + { + if (_initialized) + return; + + _initialized = true; + _confMan.OnValueChanged(CVars.CheckpointInterval, value => _checkpointInterval = value, true); + _confMan.OnValueChanged(CVars.CheckpointEntitySpawnThreshold, value => _checkpointEntitySpawnThreshold = value, true); + _confMan.OnValueChanged(CVars.CheckpointEntityStateThreshold, value => _checkpointEntityStateThreshold = value, true); + _metaId = _factory.GetRegistration(typeof(MetaDataComponent)).NetID!.Value; + _sawmill = Logger.GetSawmill("replay"); + } +} diff --git a/Robust.Server/Replays/IServerReplayRecordingManager.cs b/Robust.Server/Replays/IServerReplayRecordingManager.cs index e92c0b94a..3e0e8a49a 100644 --- a/Robust.Server/Replays/IServerReplayRecordingManager.cs +++ b/Robust.Server/Replays/IServerReplayRecordingManager.cs @@ -1,13 +1,12 @@ using Robust.Shared.Replays; using System; using Robust.Shared; +using Robust.Shared.ContentPack; namespace Robust.Server.Replays; public interface IServerReplayRecordingManager : IReplayRecordingManager { - void ToggleRecording(); - /// /// Starts recording a replay. /// @@ -22,7 +21,7 @@ public interface IServerReplayRecordingManager : IReplayRecordingManager /// Optional time limit for the recording. /// /// Returns true if the recording was successfully started. - bool TryStartRecording(string? path = null, bool overwrite = false, TimeSpan? duration = null); + bool TryStartRecording(IWritableDirProvider directory, string? path = null, bool overwrite = false, TimeSpan? duration = null); void StopRecording(); diff --git a/Robust.Server/Replays/ReplayCommands.cs b/Robust.Server/Replays/ReplayCommands.cs index 5db4e0c46..8bacb056f 100644 --- a/Robust.Server/Replays/ReplayCommands.cs +++ b/Robust.Server/Replays/ReplayCommands.cs @@ -2,12 +2,14 @@ using Robust.Shared.Console; using Robust.Shared.IoC; using Robust.Shared.Localization; using System; +using Robust.Shared.ContentPack; namespace Robust.Server.Replays; internal sealed class ReplayStartCommand : LocalizedCommands { [Dependency] private readonly IServerReplayRecordingManager _replay = default!; + [Dependency] private readonly IResourceManager _resMan = default!; public override string Command => "replaystart"; @@ -42,7 +44,7 @@ internal sealed class ReplayStartCommand : LocalizedCommands } } - if (_replay.TryStartRecording(dir, overwrite, duration)) + if (_replay.TryStartRecording(_resMan.UserData, dir, overwrite, duration)) shell.WriteLine(Loc.GetString("cmd-replaystart-success")); else shell.WriteLine(Loc.GetString("cmd-replaystart-error")); diff --git a/Robust.Server/Replays/ReplayRecordingManager.cs b/Robust.Server/Replays/ReplayRecordingManager.cs index ab359bd50..7a1039a81 100644 --- a/Robust.Server/Replays/ReplayRecordingManager.cs +++ b/Robust.Server/Replays/ReplayRecordingManager.cs @@ -23,11 +23,15 @@ using System.Linq; using YamlDotNet.Core; using YamlDotNet.RepresentationModel; using static Robust.Server.GameStates.ServerGameStateManager; +using static Robust.Shared.Replays.IReplayRecordingManager; namespace Robust.Server.Replays; internal sealed class ReplayRecordingManager : IInternalReplayRecordingManager { + // date format for default replay names. Like the sortable template, but without colons. + public const string DefaultReplayNameFormat = "yyyy-MM-dd_HH-mm-ss"; + [Dependency] private readonly IGameTiming _timing = default!; [Dependency] private readonly IRobustSerializer _seri = default!; [Dependency] private readonly IPlayerManager _playerMan = default!; @@ -39,9 +43,6 @@ internal sealed class ReplayRecordingManager : IInternalReplayRecordingManager private PVSSystem _pvs = default!; private List _queuedMessages = new(); - // date format for default replay names. Like the sortable template, but without colons. - private const string DefaultReplayNameFormat = "yyyy-MM-dd_HH-mm-ss"; - private int _maxCompressedSize; private int _maxUncompressedSize; private int _tickBatchSize; @@ -55,7 +56,7 @@ internal sealed class ReplayRecordingManager : IInternalReplayRecordingManager private TimeSpan? _recordingEnd; private MappingDataNode? _yamlMetadata; private bool _firstTick = true; - private IWritableDirProvider? _directory; + private (IWritableDirProvider, ResPath)? _directory; /// public void Initialize() @@ -105,7 +106,7 @@ internal sealed class ReplayRecordingManager : IInternalReplayRecordingManager } /// - public bool TryStartRecording(string? path = null, bool overwrite = false, TimeSpan? duration = null) + public bool TryStartRecording(IWritableDirProvider directory, string? path = null, bool overwrite = false, TimeSpan? duration = null) { if (!_enabled || _curStream != null) return false; @@ -122,19 +123,15 @@ internal sealed class ReplayRecordingManager : IInternalReplayRecordingManager subDir = new ResPath(DateTime.UtcNow.ToString(DefaultReplayNameFormat)); } - subDir = subDir.ToRootedPath(); - - // apparently OpenSubdirectory is the only way to prevent escaping a directory with ".."??? var basePath = new ResPath(_netConf.GetCVar(CVars.ReplayDirectory)).ToRootedPath(); - _resourceManager.UserData.CreateDir(basePath); - var baseDir = _resourceManager.UserData.OpenSubdirectory(basePath); + subDir = basePath / subDir.ToRelativePath(); - if (baseDir.Exists(subDir)) + if (directory.Exists(subDir)) { if (overwrite) { _sawmill.Info($"Replay folder {subDir} already exists. Overwriting."); - baseDir.Delete(subDir); + directory.Delete(subDir); } else { @@ -142,8 +139,8 @@ internal sealed class ReplayRecordingManager : IInternalReplayRecordingManager return false; } } - baseDir.CreateDir(subDir); - _directory = baseDir.OpenSubdirectory(subDir); + directory.CreateDir(subDir); + _directory = (directory, subDir); _curStream = new(_tickBatchSize * 2); _index = 0; @@ -171,15 +168,6 @@ internal sealed class ReplayRecordingManager : IInternalReplayRecordingManager return true; } - /// - public void ToggleRecording() - { - if (Recording) - StopRecording(); - else - TryStartRecording(); - } - /// public void QueueReplayMessage(object obj) { @@ -225,12 +213,11 @@ internal sealed class ReplayRecordingManager : IInternalReplayRecordingManager private void WriteFile(ZStdCompressionContext compressionContext, bool continueRecording = true) { - if (_curStream == null || _directory == null) + if (_curStream == null || _directory is not var (dir, path)) return; _curStream.Position = 0; - var filePath = new ResPath($"/{_index++}.dat"); - using var file = _directory.OpenWrite(filePath); + using var file = dir.OpenWrite(path / $"{_index++}.{Ext}"); var buf = ArrayPool.Shared.Rent(ZStd.CompressBound((int)_curStream.Length)); var length = compressionContext.Compress2(buf, _curStream.AsSpan()); @@ -267,7 +254,7 @@ internal sealed class ReplayRecordingManager : IInternalReplayRecordingManager /// private void WriteInitialMetadata() { - if (_directory == null) + if (_directory is not var (dir, path)) return; var (stringHash, stringData) = _seri.GetStringSerializerPackage(); @@ -276,65 +263,66 @@ internal sealed class ReplayRecordingManager : IInternalReplayRecordingManager // Saving YAML data. This gets overwritten later anyways, this is mostly in case something goes wrong. { _yamlMetadata = new MappingDataNode(); - _yamlMetadata["time"] = new ValueDataNode(DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)); + _yamlMetadata[Time] = new ValueDataNode(DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)); // 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)); + _yamlMetadata[Engine] = new ValueDataNode(_netConf.GetCVar(CVars.BuildEngineVersion)); + _yamlMetadata[Fork] = new ValueDataNode(_netConf.GetCVar(CVars.BuildForkId)); + _yamlMetadata[ForkVersion] = new ValueDataNode(_netConf.GetCVar(CVars.BuildVersion)); // Hash data - _yamlMetadata["typeHash"] = new ValueDataNode(Convert.ToHexString(_seri.GetSerializableTypesHash())); - _yamlMetadata["stringHash"] = new ValueDataNode(Convert.ToHexString(stringHash)); + _yamlMetadata[Hash] = new ValueDataNode(Convert.ToHexString(_seri.GetSerializableTypesHash())); + _yamlMetadata[Strings] = new ValueDataNode(Convert.ToHexString(stringHash)); // Time data var timeBase = _timing.TimeBase; - _yamlMetadata["startTick"] = new ValueDataNode(_recordingStart.Tick.Value.ToString()); - _yamlMetadata["timeBaseTick"] = new ValueDataNode(timeBase.Item2.Value.ToString()); - _yamlMetadata["timeBaseTimespan"] = new ValueDataNode(timeBase.Item1.Ticks.ToString()); - _yamlMetadata["serverStartTime"] = new ValueDataNode(_recordingStart.Time.ToString()); + _yamlMetadata[Tick] = new ValueDataNode(_recordingStart.Tick.Value.ToString()); + _yamlMetadata[BaseTick] = new ValueDataNode(timeBase.Item2.Value.ToString()); + _yamlMetadata[BaseTime] = new ValueDataNode(timeBase.Item1.Ticks.ToString()); + _yamlMetadata[ServerTime] = new ValueDataNode(_recordingStart.Time.ToString()); OnRecordingStarted?.Invoke((_yamlMetadata, extraData)); var document = new YamlDocument(_yamlMetadata.ToYaml()); - using var ymlFile = _directory.OpenWriteText(new ResPath("/replay.yml")); + using var ymlFile = dir.OpenWriteText(path / MetaFile); 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. + // TODO compression if (extraData.Count > 0) { - using var initDataFile = _directory.OpenWrite(new ResPath("/init.dat")); + using var initDataFile = dir.OpenWrite(path / InitFile); _seri.SerializeDirect(initDataFile, new ReplayMessage() { Messages = extraData }); } // save data required for IRobustMappedStringSerializer - using var stringFile = _directory.OpenWrite(new ResPath("/strings.dat")); + using var stringFile = dir.OpenWrite(path / StringsFile); stringFile.Write(stringData); // Save replicated cvars. - using var cvarsFile = _directory.OpenWrite(new ResPath("/cvars.toml")); + using var cvarsFile = dir.OpenWrite(path / CvarFile); _netConf.SaveToTomlStream(cvarsFile, _netConf.GetReplicatedVars().Select(x => x.name)); } private void WriteFinalMetadata() { - if (_yamlMetadata == null || _directory == null) + if (_yamlMetadata == null || _directory is not var (dir, path)) 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["serverEndTime"] = new ValueDataNode(_timing.CurTime.ToString()); + _yamlMetadata[EndTick] = new ValueDataNode(_timing.CurTick.Value.ToString()); + _yamlMetadata[Duration] = new ValueDataNode(time.ToString()); + _yamlMetadata[FileCount] = new ValueDataNode(_index.ToString()); + _yamlMetadata[Compressed] = new ValueDataNode(_currentCompressedSize.ToString()); + _yamlMetadata[Uncompressed] = new ValueDataNode(_currentUncompressedSize.ToString()); + _yamlMetadata[EndTime] = new ValueDataNode(_timing.CurTime.ToString()); // this just overwrites the previous yml with additional data. var document = new YamlDocument(_yamlMetadata.ToYaml()); - using var ymlFile = _directory.OpenWriteText( new ResPath("/replay.yml")); + using var ymlFile = dir.OpenWriteText(path / MetaFile); var stream = new YamlStream { document }; stream.Save(new YamlMappingFix(new Emitter(ymlFile)), false); } diff --git a/Robust.Shared/CVars.cs b/Robust.Shared/CVars.cs index 85ebff6cb..5d72a772f 100644 --- a/Robust.Shared/CVars.cs +++ b/Robust.Shared/CVars.cs @@ -1439,6 +1439,29 @@ namespace Robust.Shared /// public static readonly CVarDef ReplayEnabled = CVarDef.Create("replay.enabled", true, CVar.SERVERONLY | CVar.ARCHIVE); + /// + /// Determines the threshold before visual events (muzzle flashes, chat pop-ups, etc) are suppressed when jumping forward in time. + /// + /// + /// Effects should still show up when jumping forward ~ 1 second, but definitely not when skipping a minute or two of a gunfight. + /// + public static readonly CVarDef VisualEventThreshold = CVarDef.Create("replay.visual_event_threshold", 20); + + /// + /// Maximum number of ticks before a new checkpoint tick is generated. + /// + public static readonly CVarDef CheckpointInterval = CVarDef.Create("replay.checkpoint_interval", 200); + + /// + /// Maximum number of entities that can be spawned before a new checkpoint tick is generated. + /// + public static readonly CVarDef CheckpointEntitySpawnThreshold = CVarDef.Create("replay.checkpoint_entity_spawn_threshold", 100); + + /// + /// Maximum number of entity states that can be applied before a new checkpoint tick is generated. + /// + public static readonly CVarDef CheckpointEntityStateThreshold = CVarDef.Create("replay.checkpoint_entity_state_threshold", 50 * 600); + /* * CFG */ diff --git a/Robust.Shared/Replays/IReplayRecordingManager.cs b/Robust.Shared/Replays/IReplayRecordingManager.cs index 8bdd57348..60457187d 100644 --- a/Robust.Shared/Replays/IReplayRecordingManager.cs +++ b/Robust.Shared/Replays/IReplayRecordingManager.cs @@ -2,6 +2,7 @@ using Robust.Shared.GameObjects; using Robust.Shared.Serialization.Markdown.Mapping; using System; using System.Collections.Generic; +using Robust.Shared.Utility; namespace Robust.Shared.Replays; @@ -35,4 +36,39 @@ public interface IReplayRecordingManager /// This gets invoked whenever a replay recording ends. Subscribers can use this to add extra yaml metadata data to the recording. /// event Action? OnRecordingStopped; + + + // Define misc constants both for writing and reading replays. + # region Constants + + /// + /// File extension for data files that have to be deserialized and decompressed. + /// + public const string Ext = "dat"; + + // filenames + public static readonly ResPath MetaFile = new($"replay.yml"); + public static readonly ResPath CvarFile = new($"cvars.toml"); + public static readonly ResPath StringsFile = new($"strings.{Ext}"); + public static readonly ResPath InitFile = new($"init.{Ext}"); + + // Yaml keys + public const string Hash = "typeHash"; + public const string Strings = "stringHash"; + public const string Time = "time"; + public const string Tick = "serverStartTime"; + public const string ServerTime = "startTick"; + public const string BaseTick = "timeBaseTick"; + public const string BaseTime = "timeBaseTimespan"; + public const string Duration = "duration"; + public const string Engine = "engineVersion"; + public const string Fork = "buildForkId"; + public const string ForkVersion = "buildVersion"; + public const string FileCount = "fileCount"; + public const string Compressed = "size"; + public const string Uncompressed = "uncompressedSize"; + public const string EndTick = "endTick"; + public const string EndTime = "serverEndTime"; + + #endregion } diff --git a/Robust.Shared/Replays/ReplayData.cs b/Robust.Shared/Replays/ReplayData.cs index e0339f4cb..524bf0731 100644 --- a/Robust.Shared/Replays/ReplayData.cs +++ b/Robust.Shared/Replays/ReplayData.cs @@ -2,9 +2,127 @@ using Robust.Shared.Serialization; using Robust.Shared.Timing; using System; using System.Collections.Generic; +using Robust.Shared.GameStates; +using Robust.Shared.Utility; namespace Robust.Shared.Replays; +/// +/// This class contains data read from some replay recording. +/// +public sealed class ReplayData +{ + /// + /// List of game states for each tick. + /// + public readonly List States; + + /// + /// List of all networked messages and variables that were sent each tick. + /// + public readonly List Messages; + + /// + /// The first tick in this recording. + /// + public readonly GameTick TickOffset; + + /// + /// The sever's time when the recording was started. + /// + public readonly TimeSpan StartTime; + + /// + /// The length of this recording. + /// + public readonly TimeSpan Duration; + + /// + /// Array of checkpoint states. These are full game states that make it faster to jump around in time. + /// + public readonly CheckpointState[] Checkpoints; + + /// + /// This indexes the and lists. It is basically the "current tick" + /// but without the . + /// + public int CurrentIndex; + + public GameTick LastApplied; + + + public GameTick CurTick => new GameTick((uint) CurrentIndex + TickOffset.Value); + public GameState CurState => States[CurrentIndex]; + public GameState? NextState => CurrentIndex + 1 < States.Count ? States[CurrentIndex + 1] : null; + public ReplayMessage CurMessages => Messages[CurrentIndex]; + + /// + /// The initial set of messages that were added to the recording before any tick was ever recorded. This might + /// contain data required to properly parse the rest of the recording (e.g., prototype uploads) + /// + public ReplayMessage? InitialMessages; + + public ReplayData(List states, + List messages, + GameTick tickOffset, + TimeSpan startTime, + TimeSpan duration, + CheckpointState[] checkpointStates, + ReplayMessage? initData) + { + States = states; + Messages = messages; + TickOffset = tickOffset; + StartTime = startTime; + Duration = duration; + Checkpoints = checkpointStates; + InitialMessages = initData; + } +} + + +/// +/// Checkpoints are full game states that make it faster to jump around in time. I.e., instead of having to apply 1000 +/// game states to get from tick 1 to 1001, you can jump directly to the nearest checkpoint and apply much fewer states. +/// +public readonly struct CheckpointState : IComparable +{ + public GameTick Tick => State.ToSequence; + public readonly GameState State; + public readonly (TimeSpan, GameTick) TimeBase; + public readonly int Index; + public readonly Dictionary Cvars; + + public CheckpointState(GameState state, (TimeSpan, GameTick) time, Dictionary cvars, int index) + { + State = state; + TimeBase = time; + Cvars = cvars.ShallowClone(); + Index = index; + } + + /// + /// Get a dummy state for use with bisection searches. + /// + public static CheckpointState DummyState(int index) + { + return new CheckpointState(index); + } + + private CheckpointState(int index) + { + Index = index; + State = default!; + TimeBase = default!; + Cvars = default!; + } + + public int CompareTo(CheckpointState other) => Index.CompareTo(other.Index); +} + +/// +/// Collection of all networked messages and variables that were sent in a given tick. +/// [Serializable, NetSerializable] public sealed class ReplayMessage { diff --git a/Robust.Shared/Upload/SharedNetworkResourceManager.cs b/Robust.Shared/Upload/SharedNetworkResourceManager.cs index 4a80f076c..a367dd85f 100644 --- a/Robust.Shared/Upload/SharedNetworkResourceManager.cs +++ b/Robust.Shared/Upload/SharedNetworkResourceManager.cs @@ -25,8 +25,8 @@ public abstract class SharedNetworkResourceManager : IDisposable protected readonly MemoryContentRoot ContentRoot = new(); - //public bool FileExists(ResPath path) - // => ContentRoot.FileExists(path); + public bool FileExists(ResPath path) + => ContentRoot.FileExists(path); public virtual void Initialize() { diff --git a/Robust.UnitTesting/GameControllerDummy.cs b/Robust.UnitTesting/GameControllerDummy.cs index 5c4370bea..1f9411614 100644 --- a/Robust.UnitTesting/GameControllerDummy.cs +++ b/Robust.UnitTesting/GameControllerDummy.cs @@ -12,7 +12,7 @@ namespace Robust.UnitTesting public GameControllerOptions Options { get; } = new(); public bool ContentStart { get; set; } - public event Action? ContentEntityTickUpdate; + public event Action? TickUpdateOverride; public void Shutdown(string? reason = null) {