From 9d4105abc78d67f5f262585ffcda1ba2cacce529 Mon Sep 17 00:00:00 2001 From: Leon Friedrich <60421075+ElectroJr@users.noreply.github.com> Date: Mon, 19 Jun 2023 05:23:46 +1200 Subject: [PATCH] Add support for client-side replays (#4122) --- Resources/Locale/en-US/replays.ftl | 8 +- .../ClientNetConfigurationManager.cs | 9 + .../GameController/GameController.cs | 3 + .../GameObjects/ClientEntityManager.cs | 8 + .../Appearance/ClientAppearanceComponent.cs | 24 -- .../EntitySystems/AppearanceSystem.cs | 56 +-- .../GameObjects/EntitySystems/AudioSystem.cs | 70 ++-- .../GameStates/ClientGameStateManager.cs | 142 ++++--- .../GameStates/GameStateProcessor.cs | 13 +- .../GameStates/IClientGameStateManager.cs | 25 +- Robust.Client/Player/PlayerManager.cs | 14 +- .../Replays/Commands/ReplayLoadCommand.cs | 14 +- .../Loading/ReplayLoadManager.Checkpoints.cs | 52 ++- .../Replays/Loading/ReplayLoadManager.Read.cs | 17 +- .../Loading/ReplayLoadManager.Start.cs | 26 +- .../Replays/Loading/ReplayLoadManager.cs | 7 +- .../Playback/IReplayPlaybackManager.cs | 20 +- .../ReplayPlaybackManager.Checkpoint.cs | 61 ++- .../Playback/ReplayPlaybackManager.Time.cs | 16 +- .../Playback/ReplayPlaybackManager.Update.cs | 21 +- .../Replays/Playback/ReplayPlaybackManager.cs | 30 +- .../Replays/ReplayRecordingManager.cs | 147 +++++++- .../Upload/GamePrototypeLoadManager.cs | 36 +- .../Upload/NetworkResourceManager.cs | 9 - .../ViewVariables/Editors/VVPropEditorEnum.cs | 6 + Robust.Server/BaseServer.cs | 5 +- .../ServerNetConfigurationManager.cs | 2 +- .../Console/Commands/ScaleCommand.cs | 2 +- .../Appearance/ServerAppearanceComponent.cs | 10 - .../GameObjects/ServerEntityManager.cs | 2 +- .../GameStates/ServerGameStateManager.cs | 9 +- .../IInternalReplayRecordingManager.cs | 22 -- .../Replays/IServerReplayRecordingManager.cs | 26 +- .../Replays/ReplayRecordingManager.cs | 344 ++--------------- Robust.Server/ServerIoC.cs | 1 - .../Upload/GamePrototypeLoadManager.cs | 59 +-- .../Upload/NetworkResourceManager.cs | 17 +- Robust.Shared/CVars.cs | 35 +- .../Configuration/NetConfigurationManager.cs | 23 +- .../Console/Commands/TeleportCommands.cs | 18 +- .../ContentPack/IWritableDirProvider.cs | 25 +- .../ContentPack/VirtualWritableDirProvider.cs | 21 +- .../ContentPack/WritableDirProvider.cs | 23 +- .../Appearance/AppearanceComponent.cs | 15 +- .../SharedUserInterfaceComponent.cs | 3 +- Robust.Shared/GameObjects/EntitySystem.cs | 2 +- .../Systems/SharedAppearanceSystem.cs | 8 +- .../Replays/IReplayRecordingManager.cs | 120 +++++- Robust.Shared/Replays/ReplayData.cs | 107 +++++- .../Replays/ReplayRecordingCommands.cs | 54 +-- .../SharedReplayRecordingManager.Write.cs | 117 ++++++ .../Replays/SharedReplayRecordingManager.cs | 352 ++++++++++++++++++ .../Upload/SharedNetworkResourceManager.cs | 20 +- .../Upload/SharedPrototypeLoadManager.cs | 58 +++ .../Server/RobustServerSimulation.cs | 1 - 55 files changed, 1552 insertions(+), 783 deletions(-) delete mode 100644 Robust.Client/GameObjects/Components/Appearance/ClientAppearanceComponent.cs delete mode 100644 Robust.Server/GameObjects/Components/Appearance/ServerAppearanceComponent.cs delete mode 100644 Robust.Server/Replays/IInternalReplayRecordingManager.cs rename Robust.Server/Replays/ReplayCommands.cs => Robust.Shared/Replays/ReplayRecordingCommands.cs (83%) create mode 100644 Robust.Shared/Replays/SharedReplayRecordingManager.Write.cs create mode 100644 Robust.Shared/Replays/SharedReplayRecordingManager.cs create mode 100644 Robust.Shared/Upload/SharedPrototypeLoadManager.cs diff --git a/Resources/Locale/en-US/replays.ftl b/Resources/Locale/en-US/replays.ftl index 6c86acac7..8b64c0fbb 100644 --- a/Resources/Locale/en-US/replays.ftl +++ b/Resources/Locale/en-US/replays.ftl @@ -33,13 +33,13 @@ cmd-replay-error-run-level = You cannot load a replay while connected to a serve # Recording commands cmd-replay-recording-start-desc = Starts a replay recording, optionally with some time limit. -cmd-replay-recording-start-help = Usage: replay_recording_start [time limit (minutes)] [path] [overwrite bool] +cmd-replay-recording-start-help = Usage: replay_recording_start [name] [overwrite] [time limit] cmd-replay-recording-start-success = Started recording a replay. cmd-replay-recording-start-already-recording = Already recording a replay. cmd-replay-recording-start-error = An error occurred while trying to start the recording. -cmd-replay-recording-start-hint-time = [optional time limit (minutes)] -cmd-replay-recording-start-hint-name = [optional path] -cmd-replay-recording-start-hint-overwrite = [overwrite path (bool)] +cmd-replay-recording-start-hint-time = [time limit (minutes)] +cmd-replay-recording-start-hint-name = [name] +cmd-replay-recording-start-hint-overwrite = [overwrite (bool)] cmd-replay-recording-stop-desc = Stops a replay recording. cmd-replay-recording-stop-help = Usage: replay_recording_stop diff --git a/Robust.Client/Configuration/ClientNetConfigurationManager.cs b/Robust.Client/Configuration/ClientNetConfigurationManager.cs index 65edbc8da..c76c5cef9 100644 --- a/Robust.Client/Configuration/ClientNetConfigurationManager.cs +++ b/Robust.Client/Configuration/ClientNetConfigurationManager.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System; using Robust.Shared.Network.Messages; using Robust.Shared.Network; +using Robust.Shared.Replays; using Robust.Shared.Utility; namespace Robust.Client.Configuration; @@ -12,6 +13,8 @@ namespace Robust.Client.Configuration; internal sealed class ClientNetConfigurationManager : NetConfigurationManager, IClientNetConfigurationManager { [Dependency] private readonly IBaseClient _client = default!; + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly IReplayRecordingManager _replay = default!; private bool _receivedInitialNwVars = false; @@ -86,6 +89,12 @@ internal sealed class ClientNetConfigurationManager : NetConfigurationManager, I ApplyClientNetVarChange(message.NetworkedVars, message.Tick); else base.HandleNetVarMessage(message); + + _replay.RecordClientMessage(new ReplayMessage.CvarChangeMsg() + { + ReplicatedCvars = message.NetworkedVars, + TimeBase = _timing.TimeBase + }); } protected override void ApplyNetVarChange( diff --git a/Robust.Client/GameController/GameController.cs b/Robust.Client/GameController/GameController.cs index 0321491f0..e80856488 100644 --- a/Robust.Client/GameController/GameController.cs +++ b/Robust.Client/GameController/GameController.cs @@ -33,6 +33,7 @@ using Robust.Shared.Map; using Robust.Shared.Network; using Robust.Shared.Profiling; using Robust.Shared.Prototypes; +using Robust.Shared.Replays; using Robust.Shared.Serialization; using Robust.Shared.Serialization.Manager; using Robust.Shared.Threading; @@ -83,6 +84,7 @@ namespace Robust.Client [Dependency] private readonly NetworkResourceManager _netResMan = default!; [Dependency] private readonly IReplayLoadManager _replayLoader = default!; [Dependency] private readonly IReplayPlaybackManager _replayPlayback = default!; + [Dependency] private readonly IReplayRecordingManager _replayRecording = default!; private IWebViewManagerHook? _webViewHook; @@ -178,6 +180,7 @@ namespace Robust.Client _netResMan.Initialize(); _replayLoader.Initialize(); _replayPlayback.Initialize(); + _replayRecording.Initialize(); _userInterfaceManager.PostInitialize(); _modLoader.BroadcastRunLevel(ModRunLevel.PostInit); diff --git a/Robust.Client/GameObjects/ClientEntityManager.cs b/Robust.Client/GameObjects/ClientEntityManager.cs index bfdbd41d6..f216b4161 100644 --- a/Robust.Client/GameObjects/ClientEntityManager.cs +++ b/Robust.Client/GameObjects/ClientEntityManager.cs @@ -10,6 +10,7 @@ using Robust.Shared.IoC; using Robust.Shared.Log; using Robust.Shared.Network; using Robust.Shared.Network.Messages; +using Robust.Shared.Replays; using Robust.Shared.Utility; namespace Robust.Client.GameObjects @@ -24,6 +25,7 @@ namespace Robust.Client.GameObjects [Dependency] private readonly IClientGameTiming _gameTiming = default!; [Dependency] private readonly IClientGameStateManager _stateMan = default!; [Dependency] private readonly IBaseClient _client = default!; + [Dependency] private readonly IReplayRecordingManager _replayRecording = default!; protected override int NextEntityUid { get; set; } = EntityUid.ClientUid + 1; @@ -184,6 +186,12 @@ namespace Robust.Client.GameObjects switch (message.Type) { case EntityMessageType.SystemMessage: + + // TODO REPLAYS handle late messages. + // If a message was received late, it will be recorded late here. + // Maybe process the replay to prevent late messages when playing back? + _replayRecording.RecordReplayMessage(message.SystemMessage); + DispatchReceivedNetworkMsg(message.SystemMessage); return; } diff --git a/Robust.Client/GameObjects/Components/Appearance/ClientAppearanceComponent.cs b/Robust.Client/GameObjects/Components/Appearance/ClientAppearanceComponent.cs deleted file mode 100644 index e242cd72e..000000000 --- a/Robust.Client/GameObjects/Components/Appearance/ClientAppearanceComponent.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Collections.Generic; -using Robust.Shared.GameObjects; -using Robust.Shared.Serialization.Manager.Attributes; - -namespace Robust.Client.GameObjects; - -/// -/// This is the client instance of . -/// -[RegisterComponent] -[ComponentReference(typeof(AppearanceComponent)), Access(typeof(AppearanceSystem))] -public sealed class ClientAppearanceComponent : AppearanceComponent -{ - /// - /// If true, then this entity's visuals will get updated in the next frame update regardless of whether or not - /// this entity is currently inside of PVS range. - /// - /// - /// This defaults to true, because it is possible for an entity to both be initialized and detached to null - /// during the same tick. This can happen because entity states & pvs-departure messages are sent & handled - /// separately. However, we want to ensure that each entity has an initial appearance update. - /// - internal bool UpdateDetached = true; -} diff --git a/Robust.Client/GameObjects/EntitySystems/AppearanceSystem.cs b/Robust.Client/GameObjects/EntitySystems/AppearanceSystem.cs index 6d1c95ef4..56e45808e 100644 --- a/Robust.Client/GameObjects/EntitySystems/AppearanceSystem.cs +++ b/Robust.Client/GameObjects/EntitySystems/AppearanceSystem.cs @@ -1,23 +1,23 @@ using System; using System.Collections.Generic; using JetBrains.Annotations; -using Robust.Shared; using Robust.Shared.GameObjects; using Robust.Shared.GameStates; +using Robust.Shared.Utility; namespace Robust.Client.GameObjects { [UsedImplicitly] public sealed class AppearanceSystem : SharedAppearanceSystem { - private readonly Queue _queuedUpdates = new(); + private readonly Queue<(EntityUid uid, AppearanceComponent)> _queuedUpdates = new(); public override void Initialize() { base.Initialize(); - SubscribeLocalEvent(OnAppearanceStartup); - SubscribeLocalEvent(OnAppearanceHandleState); + SubscribeLocalEvent(OnAppearanceStartup); + SubscribeLocalEvent(OnAppearanceHandleState); } protected override void OnAppearanceGetState(EntityUid uid, AppearanceComponent component, ref ComponentGetState args) @@ -26,12 +26,12 @@ namespace Robust.Client.GameObjects args.State = new AppearanceComponentState(clone); } - private void OnAppearanceStartup(EntityUid uid, ClientAppearanceComponent component, ComponentStartup args) + private void OnAppearanceStartup(EntityUid uid, AppearanceComponent component, ComponentStartup args) { - MarkDirty(component); + QueueUpdate(uid, component); } - private void OnAppearanceHandleState(EntityUid uid, ClientAppearanceComponent component, ref ComponentHandleState args) + private void OnAppearanceHandleState(EntityUid uid, AppearanceComponent component, ref ComponentHandleState args) { if (args.Current is not AppearanceComponentState actualState) return; @@ -51,10 +51,15 @@ namespace Robust.Client.GameObjects } } - if (!stateDiff) return; + if (!stateDiff) + { + // Even if the appearance hasn't changed, we may need to update if we are re-entering PVS + if (component.AppearanceDirty) + QueueUpdate(uid, component); + } component.AppearanceData = CloneAppearanceData(actualState.Data); - MarkDirty(component); + QueueUpdate(uid, component); } /// @@ -80,43 +85,46 @@ namespace Robust.Client.GameObjects return newDict; } - public override void MarkDirty(AppearanceComponent component, bool updateDetached = false) + public override void QueueUpdate(EntityUid uid, AppearanceComponent component) { - var clientComp = (ClientAppearanceComponent)component; - clientComp.UpdateDetached |= updateDetached; - - if (component.AppearanceDirty) + if (component.UpdateQueued) + { + DebugTools.Assert(component.AppearanceDirty); return; + } - _queuedUpdates.Enqueue(clientComp); + _queuedUpdates.Enqueue((uid, component)); component.AppearanceDirty = true; + component.UpdateQueued = true; } public override void FrameUpdate(float frameTime) { var spriteQuery = GetEntityQuery(); var metaQuery = GetEntityQuery(); - while (_queuedUpdates.TryDequeue(out var appearance)) + while (_queuedUpdates.TryDequeue(out var next)) { - appearance.AppearanceDirty = false; + var (uid, appearance) = next; + appearance.UpdateQueued = false; if (!appearance.Running) continue; // If the entity is no longer within the clients PVS, don't bother updating. - if ((metaQuery.GetComponent(appearance.Owner).Flags & MetaDataFlags.Detached) != 0 && !appearance.UpdateDetached) + if ((metaQuery.GetComponent(uid).Flags & MetaDataFlags.Detached) != 0) continue; - appearance.UpdateDetached = false; + appearance.AppearanceDirty = false; // Sprite comp is allowed to be null, so that things like spriteless point-lights can use this system - spriteQuery.TryGetComponent(appearance.Owner, out var sprite); - OnChangeData(appearance.Owner, sprite, appearance); + spriteQuery.TryGetComponent(uid, out var sprite); + OnChangeData(uid, sprite, appearance); } } - public void OnChangeData(EntityUid uid, SpriteComponent? sprite, ClientAppearanceComponent? appearanceComponent = null) + public void OnChangeData(EntityUid uid, SpriteComponent? sprite, AppearanceComponent? appearanceComponent = null) { - if (!Resolve(uid, ref appearanceComponent, false)) return; + if (!Resolve(uid, ref appearanceComponent, false)) + return; var ev = new AppearanceChangeEvent { @@ -126,7 +134,7 @@ namespace Robust.Client.GameObjects }; // Give it AppearanceData so we can still keep the friend attribute on the component. - EntityManager.EventBus.RaiseLocalEvent(uid, ref ev); + RaiseLocalEvent(uid, ref ev); } } diff --git a/Robust.Client/GameObjects/EntitySystems/AudioSystem.cs b/Robust.Client/GameObjects/EntitySystems/AudioSystem.cs index 021c18d0f..92265d604 100644 --- a/Robust.Client/GameObjects/EntitySystems/AudioSystem.cs +++ b/Robust.Client/GameObjects/EntitySystems/AudioSystem.cs @@ -19,6 +19,7 @@ using Robust.Shared.Physics.Systems; using Robust.Shared.Player; using Robust.Shared.Players; using Robust.Shared.Random; +using Robust.Shared.Replays; using Robust.Shared.Threading; using Robust.Shared.Timing; using Robust.Shared.Utility; @@ -28,6 +29,7 @@ namespace Robust.Client.GameObjects; [UsedImplicitly] public sealed class AudioSystem : SharedAudioSystem { + [Dependency] private readonly IReplayRecordingManager _replayRecording = default!; [Dependency] private readonly SharedPhysicsSystem _broadPhaseSystem = default!; [Dependency] private readonly IClydeAudio _clyde = default!; [Dependency] private readonly IEyeManager _eyeManager = default!; @@ -80,8 +82,8 @@ public sealed class AudioSystem : SharedAudioSystem private void PlayAudioEntityHandler(PlayAudioEntityMessage ev) { var stream = EntityManager.EntityExists(ev.EntityUid) - ? (PlayingStream?) Play(ev.FileName, ev.EntityUid, ev.FallbackCoordinates, ev.AudioParams) - : (PlayingStream?) Play(ev.FileName, ev.Coordinates, ev.FallbackCoordinates, ev.AudioParams); + ? (PlayingStream?) Play(ev.FileName, ev.EntityUid, ev.FallbackCoordinates, ev.AudioParams, false) + : (PlayingStream?) Play(ev.FileName, ev.Coordinates, ev.FallbackCoordinates, ev.AudioParams, false); if (stream != null) stream.NetIdentifier = ev.Identifier; @@ -89,14 +91,14 @@ public sealed class AudioSystem : SharedAudioSystem private void PlayAudioGlobalHandler(PlayAudioGlobalMessage ev) { - var stream = (PlayingStream?) Play(ev.FileName, ev.AudioParams); + var stream = (PlayingStream?) Play(ev.FileName, ev.AudioParams, false); if (stream != null) stream.NetIdentifier = ev.Identifier; } private void PlayAudioPositionalHandler(PlayAudioPositionalMessage ev) { - var stream = (PlayingStream?) Play(ev.FileName, ev.Coordinates, ev.FallbackCoordinates, ev.AudioParams); + var stream = (PlayingStream?) Play(ev.FileName, ev.Coordinates, ev.FallbackCoordinates, ev.AudioParams, false); if (stream != null) stream.NetIdentifier = ev.Identifier; } @@ -334,8 +336,17 @@ public sealed class AudioSystem : SharedAudioSystem /// /// The resource path to the OGG Vorbis file to play. /// - private IPlayingAudioStream? Play(string filename, AudioParams? audioParams = null) + private IPlayingAudioStream? Play(string filename, AudioParams? audioParams = null, bool recordReplay = true) { + if (recordReplay && _replayRecording.IsRecording) + { + _replayRecording.RecordReplayMessage(new PlayAudioGlobalMessage + { + FileName = filename, + AudioParams = audioParams ?? AudioParams.Default + }); + } + return TryGetAudio(filename, out var audio) ? Play(audio, audioParams) : default; } @@ -364,9 +375,20 @@ public sealed class AudioSystem : SharedAudioSystem /// The entity "emitting" the audio. /// The map or grid coordinates at which to play the audio when entity is invalid. /// - private IPlayingAudioStream? Play(string filename, EntityUid entity, EntityCoordinates fallbackCoordinates, - AudioParams? audioParams = null) + private IPlayingAudioStream? Play(string filename, EntityUid entity, EntityCoordinates? fallbackCoordinates, + AudioParams? audioParams = null, bool recordReplay = true) { + if (recordReplay && _replayRecording.IsRecording) + { + _replayRecording.RecordReplayMessage(new PlayAudioEntityMessage + { + FileName = filename, + EntityUid = entity, + FallbackCoordinates = fallbackCoordinates ?? default, + AudioParams = audioParams ?? AudioParams.Default + }); + } + return TryGetAudio(filename, out var audio) ? Play(audio, entity, fallbackCoordinates, audioParams) : default; } @@ -408,8 +430,19 @@ public sealed class AudioSystem : SharedAudioSystem /// The map or grid coordinates at which to play the audio when coordinates are invalid. /// private IPlayingAudioStream? Play(string filename, EntityCoordinates coordinates, - EntityCoordinates fallbackCoordinates, AudioParams? audioParams = null) + EntityCoordinates fallbackCoordinates, AudioParams? audioParams = null, bool recordReplay = true) { + if (recordReplay && _replayRecording.IsRecording) + { + _replayRecording.RecordReplayMessage(new PlayAudioPositionalMessage + { + FileName = filename, + Coordinates = coordinates, + FallbackCoordinates = fallbackCoordinates, + AudioParams = audioParams ?? AudioParams.Default + }); + } + return TryGetAudio(filename, out var audio) ? Play(audio, coordinates, fallbackCoordinates, audioParams) : default; } @@ -536,13 +569,7 @@ public sealed class AudioSystem : SharedAudioSystem /// public override IPlayingAudioStream? Play(string filename, Filter playerFilter, EntityUid entity, bool recordReplay, AudioParams? audioParams = null) { - if (_resourceCache.TryGetResource(new ResPath(filename), out var audio)) - { - return Play(audio, entity, null, audioParams); - } - - _sawmill.Error($"Server tried to play audio file {filename} which does not exist."); - return default; + return Play(filename, entity, null, audioParams); } /// @@ -551,7 +578,6 @@ public sealed class AudioSystem : SharedAudioSystem return Play(filename, coordinates, GetFallbackCoordinates(coordinates.ToMap(EntityManager)), audioParams); } - /// public override IPlayingAudioStream? PlayGlobal(string filename, ICommonSession recipient, AudioParams? audioParams = null) { @@ -567,21 +593,13 @@ public sealed class AudioSystem : SharedAudioSystem /// public override IPlayingAudioStream? PlayEntity(string filename, ICommonSession recipient, EntityUid uid, AudioParams? audioParams = null) { - if (_resourceCache.TryGetResource(new ResPath(filename), out var audio)) - { - return Play(audio, uid, null, audioParams); - } - return null; + return Play(filename, uid, null, audioParams); } /// public override IPlayingAudioStream? PlayEntity(string filename, EntityUid recipient, EntityUid uid, AudioParams? audioParams = null) { - if (_resourceCache.TryGetResource(new ResPath(filename), out var audio)) - { - return Play(audio, uid, null, audioParams); - } - return null; + return Play(filename, uid, null, audioParams); } /// diff --git a/Robust.Client/GameStates/ClientGameStateManager.cs b/Robust.Client/GameStates/ClientGameStateManager.cs index 9dffa1efa..c79613724 100644 --- a/Robust.Client/GameStates/ClientGameStateManager.cs +++ b/Robust.Client/GameStates/ClientGameStateManager.cs @@ -27,6 +27,7 @@ using Robust.Shared.Network.Messages; using Robust.Shared.Physics; using Robust.Shared.Physics.Components; using Robust.Shared.Profiling; +using Robust.Shared.Replays; using Robust.Shared.Timing; using Robust.Shared.Utility; @@ -51,6 +52,7 @@ namespace Robust.Client.GameStates private uint _metaCompNetId; + [Dependency] private readonly IReplayRecordingManager _replayRecording = default!; [Dependency] private readonly IComponentFactory _compFactory = default!; [Dependency] private readonly IClientEntityManagerInternal _entities = default!; [Dependency] private readonly IPlayerManager _players = default!; @@ -196,14 +198,27 @@ namespace Robust.Client.GameStates AckGameState(message.State.ToSequence); } - public void UpdateFullRep(GameState state, bool cloneDelta = false) => _processor.UpdateFullRep(state, cloneDelta); + public void UpdateFullRep(GameState state, bool cloneDelta = false) + => _processor.UpdateFullRep(state, cloneDelta); + + public Dictionary> GetFullRep() + => _processor.GetFullRep(); private void HandlePvsLeaveMessage(MsgStateLeavePvs message) { - _processor.AddLeavePvsMessage(message); + QueuePvsDetach(message.Entities, message.Tick); PvsLeave?.Invoke(message); } + public void QueuePvsDetach(List entities, GameTick tick) + { + _processor.AddLeavePvsMessage(entities, tick); + if (_replayRecording.IsRecording) + _replayRecording.RecordClientMessage(new ReplayMessage.LeavePvs(entities, tick)); + } + + public void ClearDetachQueue() => _processor.ClearDetachQueue(); + /// public void ApplyGameState() { @@ -609,6 +624,17 @@ namespace Robust.Client.GameStates { using var _ = _timing.StartStateApplicationArea(); + // TODO repays optimize this. + // This currently just saves game states as they are applied. + // However this is inefficient and may have redundant data. + // E.g., we may record states: [10 to 15] [11 to 16] *error* [0 to 18] [18 to 19] [18 to 20] ... + // The best way to deal with this is probably to just re-process & re-write the replay when first loading it. + // + // Also, currently this will cause a state to be serialized, which in principle shouldn't differ from the + // data that we received from the server. So if a recording is active we could actually just copy those + // raw bytes. + _replayRecording.Update(curState); + using (_prof.Group("Config")) { _config.TickProcessMessages(); @@ -906,6 +932,16 @@ namespace Robust.Client.GameStates _prof.WriteValue("Count", ProfData.Int32(delSpan.Length)); } + public void DetachImmediate(List entities) + { + var metas = _entities.GetEntityQuery(); + var xforms = _entities.GetEntityQuery(); + var xformSys = _entitySystemManager.GetEntitySystem(); + var containerSys = _entitySystemManager.GetEntitySystem(); + var lookupSys = _entitySystemManager.GetEntitySystem(); + Detach(GameTick.MaxValue, null, entities, metas, xforms, xformSys, containerSys, lookupSys); + } + private List ProcessPvsDeparture( GameTick toTick, EntityQuery metas, @@ -928,56 +964,70 @@ namespace Robust.Client.GameStates foreach (var (tick, ents) in toDetach) { - foreach (var ent in ents) - { - if (!metas.TryGetComponent(ent, out var meta)) - continue; - - if (meta.LastStateApplied > tick) - { - // Server sent a new state for this entity sometime after the detach message was sent. The - // detach message probably just arrived late or was initially dropped. - continue; - } - - if ((meta.Flags & MetaDataFlags.Detached) != 0) - continue; - - meta.LastStateApplied = toTick; - - var xform = xforms.GetComponent(ent); - if (xform.ParentUid.IsValid()) - { - lookupSys.RemoveFromEntityTree(ent, xform, xforms); - xform.Broadphase = BroadphaseData.Invalid; - - // In some cursed scenarios an entity inside of a container can leave PVS without the container itself leaving PVS. - // In those situations, we need to add the entity back to the list of expected entities after detaching. - IContainer? container = null; - if ((meta.Flags & MetaDataFlags.InContainer) != 0 && - metas.TryGetComponent(xform.ParentUid, out var containerMeta) && - (containerMeta.Flags & MetaDataFlags.Detached) == 0 && - containerSys.TryGetContainingContainer(xform.ParentUid, ent, out container, null, true)) - { - container.Remove(ent, _entities, xform, meta, false, true); - } - - meta._flags |= MetaDataFlags.Detached; - xformSys.DetachParentToNull(ent, xform, xforms, metas); - DebugTools.Assert((meta.Flags & MetaDataFlags.InContainer) == 0); - - if (container != null) - containerSys.AddExpectedEntity(ent, container); - } - - detached.Add(ent); - } + Detach(tick, toTick, ents, metas, xforms, xformSys, containerSys, lookupSys, detached); } _prof.WriteValue("Count", ProfData.Int32(detached.Count)); return detached; } + private void Detach(GameTick maxTick, + GameTick? lastStateApplied, + List entities, + EntityQuery metas, + EntityQuery xforms, + SharedTransformSystem xformSys, + ContainerSystem containerSys, + EntityLookupSystem lookupSys, + List? detached = null) + { + foreach (var ent in entities) + { + if (!metas.TryGetComponent(ent, out var meta)) + continue; + + if (meta.LastStateApplied > maxTick) + { + // Server sent a new state for this entity sometime after the detach message was sent. The + // detach message probably just arrived late or was initially dropped. + continue; + } + + if ((meta.Flags & MetaDataFlags.Detached) != 0) + continue; + + if (lastStateApplied.HasValue) + meta.LastStateApplied = lastStateApplied.Value; + + var xform = xforms.GetComponent(ent); + if (xform.ParentUid.IsValid()) + { + lookupSys.RemoveFromEntityTree(ent, xform, xforms); + xform.Broadphase = BroadphaseData.Invalid; + + // In some cursed scenarios an entity inside of a container can leave PVS without the container itself leaving PVS. + // In those situations, we need to add the entity back to the list of expected entities after detaching. + IContainer? container = null; + if ((meta.Flags & MetaDataFlags.InContainer) != 0 && + metas.TryGetComponent(xform.ParentUid, out var containerMeta) && + (containerMeta.Flags & MetaDataFlags.Detached) == 0 && + containerSys.TryGetContainingContainer(xform.ParentUid, ent, out container, null, true)) + { + container.Remove(ent, _entities, xform, meta, false, true); + } + + meta._flags |= MetaDataFlags.Detached; + xformSys.DetachParentToNull(ent, xform, xforms, metas); + DebugTools.Assert((meta.Flags & MetaDataFlags.InContainer) == 0); + + if (container != null) + containerSys.AddExpectedEntity(ent, container); + } + + detached?.Add(ent); + } + } + private void InitializeAndStart(Dictionary toCreate) { #if EXCEPTION_TOLERANCE diff --git a/Robust.Client/GameStates/GameStateProcessor.cs b/Robust.Client/GameStates/GameStateProcessor.cs index 1d150b126..a929bf9d2 100644 --- a/Robust.Client/GameStates/GameStateProcessor.cs +++ b/Robust.Client/GameStates/GameStateProcessor.cs @@ -263,13 +263,15 @@ namespace Robust.Client.GameStates return false; } - internal void AddLeavePvsMessage(MsgStateLeavePvs message) + internal void AddLeavePvsMessage(List entities, GameTick tick) { // Late message may still need to be processed, - DebugTools.Assert(message.Entities.Count > 0); - _pvsDetachMessages.TryAdd(message.Tick, message.Entities); + DebugTools.Assert(entities.Count > 0); + _pvsDetachMessages.TryAdd(tick, entities); } + public void ClearDetachQueue() => _pvsDetachMessages.Clear(); + public List<(GameTick Tick, List Entities)> GetEntitiesToDetach(GameTick toTick, int budget) { var result = new List<(GameTick Tick, List Entities)>(); @@ -388,6 +390,11 @@ namespace Robust.Client.GameStates return _lastStateFullRep[entity]; } + public Dictionary> GetFullRep() + { + return _lastStateFullRep; + } + public bool TryGetLastServerStates(EntityUid entity, [NotNullWhen(true)] out Dictionary? dictionary) { diff --git a/Robust.Client/GameStates/IClientGameStateManager.cs b/Robust.Client/GameStates/IClientGameStateManager.cs index 5441529dd..4e63d9f7f 100644 --- a/Robust.Client/GameStates/IClientGameStateManager.cs +++ b/Robust.Client/GameStates/IClientGameStateManager.cs @@ -55,7 +55,7 @@ namespace Robust.Client.GameStates /// /// This is invoked whenever a pvs-leave message is received. /// - public event Action? PvsLeave; + event Action? PvsLeave; /// /// One time initialization of the service. @@ -91,7 +91,7 @@ namespace Robust.Client.GameStates /// /// Requests a full state from the server. This should override even implicit entity data. /// - public void RequestFullState(EntityUid? missingEntity = null); + void RequestFullState(EntityUid? missingEntity = null); uint SystemMessageDispatched(T message) where T : EntityEventArgs; @@ -102,6 +102,11 @@ namespace Robust.Client.GameStates /// modifying them directly. Useful if they are still cached elsewhere (e.g., replays). void UpdateFullRep(GameState state, bool cloneDelta = false); + /// + /// Returns the full collection of cached game states that are used to reset predicted entities. + /// + Dictionary> GetFullRep(); + /// /// This will perform some setup in order to reset the game to an earlier state. To fully reset the state /// still needs to be called separately. @@ -134,5 +139,21 @@ namespace Robust.Client.GameStates bool resetAllEntities, bool deleteClientEntities = false, bool deleteClientChildren = true); + + /// + /// Queue a collection of entities that are to be detached to null-space & marked as PVS-detached. + /// This store and modify the list given to it. + /// + void QueuePvsDetach(List entities, GameTick tick); + + /// + /// Immediately detach several entities. + /// + void DetachImmediate(List entities); + + /// + /// Clears the PVS detach queue. + /// + void ClearDetachQueue(); } } diff --git a/Robust.Client/Player/PlayerManager.cs b/Robust.Client/Player/PlayerManager.cs index 3814090b0..77c668f7a 100644 --- a/Robust.Client/Player/PlayerManager.cs +++ b/Robust.Client/Player/PlayerManager.cs @@ -178,16 +178,23 @@ namespace Robust.Client.Player { hitSet.Add(state.UserId); - if (_sessions.TryGetValue(state.UserId, out var local)) + if (_sessions.TryGetValue(state.UserId, out var session)) { + var local = (PlayerSession) session; // Exists, update data. - if (local.Name == state.Name && local.Status == state.Status && local.Ping == state.Ping) + if (local.Name == state.Name + && local.Status == state.Status + && local.Ping == state.Ping + && local.AttachedEntity == state.ControlledEntity) + { continue; + } dirty = true; local.Name = state.Name; local.Status = state.Status; local.Ping = state.Ping; + local.AttachedEntity = state.ControlledEntity; } else { @@ -198,7 +205,8 @@ namespace Robust.Client.Player { Name = state.Name, Status = state.Status, - Ping = state.Ping + Ping = state.Ping, + AttachedEntity = state.ControlledEntity, }; _sessions.Add(state.UserId, newSession); if (state.UserId == LocalPlayer!.UserId) diff --git a/Robust.Client/Replays/Commands/ReplayLoadCommand.cs b/Robust.Client/Replays/Commands/ReplayLoadCommand.cs index 4fc026163..026e3f4a0 100644 --- a/Robust.Client/Replays/Commands/ReplayLoadCommand.cs +++ b/Robust.Client/Replays/Commands/ReplayLoadCommand.cs @@ -1,6 +1,9 @@ +using System.Linq; using JetBrains.Annotations; using Robust.Client.Replays.Loading; using Robust.Client.Replays.Playback; +using Robust.Shared; +using Robust.Shared.Configuration; using Robust.Shared.Console; using Robust.Shared.ContentPack; using Robust.Shared.IoC; @@ -15,6 +18,7 @@ public sealed class ReplayLoadCommand : BaseReplayCommand [Dependency] private readonly IResourceManager _resMan = default!; [Dependency] private readonly IReplayLoadManager _loadMan = default!; [Dependency] private readonly IBaseClient _client = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; public override string Command => IReplayPlaybackManager.LoadCommand; @@ -38,7 +42,7 @@ public sealed class ReplayLoadCommand : BaseReplayCommand return; } - var dir = new ResPath(args[0]); + var dir = new ResPath(_cfg.GetCVar(CVars.ReplayDirectory)) / args[0]; var file = dir / IReplayRecordingManager.MetaFile; if (!_resMan.UserData.Exists(file)) { @@ -54,8 +58,12 @@ public sealed class ReplayLoadCommand : BaseReplayCommand if (args.Length != 1) return CompletionResult.Empty; - var opts = CompletionHelper.UserFilePath(args[0], _resMan.UserData); - return CompletionResult.FromHintOptions(opts, Loc.GetString("")); + var dir = new ResPath(_cfg.GetCVar(CVars.ReplayDirectory)) / args[0]; + dir = dir.ToRootedPath(); + var opts = CompletionHelper.UserFilePath(dir.CanonPath, _resMan.UserData); + opts = opts.Where(x => _resMan.UserData.IsDir(new ResPath(x.Value))); + + return CompletionResult.FromHintOptions(opts, Loc.GetString("cmd-replay-load-hint")); } } diff --git a/Robust.Client/Replays/Loading/ReplayLoadManager.Checkpoints.cs b/Robust.Client/Replays/Loading/ReplayLoadManager.Checkpoints.cs index 473ec6e56..e6bf99f54 100644 --- a/Robust.Client/Replays/Loading/ReplayLoadManager.Checkpoints.cs +++ b/Robust.Client/Replays/Loading/ReplayLoadManager.Checkpoints.cs @@ -82,9 +82,13 @@ public sealed partial class ReplayLoadManager } HashSet uploadedFiles = new(); + var detached = new HashSet(); + var detachQueue = new Dictionary>(); + if (initMessages != null) - UpdateMessages(initMessages, uploadedFiles, prototypes, cvars, ref timeBase, true); - UpdateMessages(messages[0], uploadedFiles, prototypes, cvars, ref timeBase, true); + UpdateMessages(initMessages, uploadedFiles, prototypes, cvars, detachQueue, ref timeBase, true); + UpdateMessages(messages[0], uploadedFiles, prototypes, cvars, detachQueue, ref timeBase, true); + ProcessQueue(GameTick.MaxValue, detachQueue, detached); var entSpan = state0.EntityStates.Value; Dictionary entStates = new(entSpan.Count); @@ -108,7 +112,7 @@ public sealed partial class ReplayLoadManager entStates.Values.ToArray(), playerStates.Values.ToArray(), Array.Empty()); - checkPoints.Add(new CheckpointState(state0, timeBase, cvars, 0)); + checkPoints.Add(new CheckpointState(state0, timeBase, cvars, 0, detached)); DebugTools.Assert(state0.EntityDeletions.Value.Count == 0); var empty = Array.Empty(); @@ -134,14 +138,19 @@ public sealed partial class ReplayLoadManager 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); + UpdateEntityStates(curState.EntityStates.Span, entStates, ref spawnedTracker, ref stateTracker, detached); + UpdateMessages(messages[i], uploadedFiles, prototypes, cvars, detachQueue, ref timeBase); + ProcessQueue(curState.ToSequence, detachQueue, detached); + UpdateDeletions(curState.EntityDeletions, entStates, detached); serverTime[i] = GetTime(curState.ToSequence) - initialTime; ticksSinceLastCheckpoint++; - if (ticksSinceLastCheckpoint < _checkpointInterval && spawnedTracker < _checkpointEntitySpawnThreshold && stateTracker < _checkpointEntityStateThreshold) + if (ticksSinceLastCheckpoint < _checkpointInterval + && spawnedTracker < _checkpointEntitySpawnThreshold + && stateTracker < _checkpointEntityStateThreshold) + { continue; + } ticksSinceLastCheckpoint = 0; spawnedTracker = 0; @@ -152,7 +161,7 @@ public sealed partial class ReplayLoadManager 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)); + checkPoints.Add(new CheckpointState(newState, timeBase, cvars, i, detached)); } _sawmill.Info($"Finished generating checkpoints. Elapsed time: {st.Elapsed}"); @@ -160,10 +169,25 @@ public sealed partial class ReplayLoadManager return (checkPoints.ToArray(), serverTime); } + private void ProcessQueue( + GameTick curTick, + Dictionary> detachQueue, + HashSet detached) + { + foreach (var (tick, ents) in detachQueue) + { + if (tick > curTick) + continue; + detachQueue.Remove(tick); + detached.UnionWith(ents); + } + } + private void UpdateMessages(ReplayMessage message, HashSet uploadedFiles, Dictionary> prototypes, Dictionary cvars, + Dictionary> detachQueue, ref (TimeSpan, GameTick) timeBase, bool ignoreDuplicates = false) { @@ -202,6 +226,10 @@ public sealed partial class ReplayLoadManager message.Messages.RemoveSwap(i); break; + + case LeavePvs leave: + detachQueue.TryAdd(leave.Tick, leave.Entities); + break; } } @@ -237,18 +265,22 @@ public sealed partial class ReplayLoadManager } } - private void UpdateDeletions(NetListAsArray entityDeletions, Dictionary entStates) + private void UpdateDeletions(NetListAsArray entityDeletions, + Dictionary entStates, HashSet detached) { foreach (var ent in entityDeletions.Span) { entStates.Remove(ent); + detached.Remove(ent); } } - private void UpdateEntityStates(ReadOnlySpan span, Dictionary entStates, ref int spawnedTracker, ref int stateTracker) + private void UpdateEntityStates(ReadOnlySpan span, Dictionary entStates, + ref int spawnedTracker, ref int stateTracker, HashSet detached) { foreach (var entState in span) { + detached.Remove(entState.Uid); if (!entStates.TryGetValue(entState.Uid, out var oldEntState)) { var modifiedState = AddImplicitData(entState); diff --git a/Robust.Client/Replays/Loading/ReplayLoadManager.Read.cs b/Robust.Client/Replays/Loading/ReplayLoadManager.Read.cs index da6dd5ce9..28253734f 100644 --- a/Robust.Client/Replays/Loading/ReplayLoadManager.Read.cs +++ b/Robust.Client/Replays/Loading/ReplayLoadManager.Read.cs @@ -34,6 +34,11 @@ public sealed partial class ReplayLoadManager var compressionContext = new ZStdCompressionContext(); var metaData = LoadMetadata(dir, path); + // Strip tailing "/" + // why is there no method for this. + if (path.CanonPath.EndsWith("/")) + path = new(path.CanonPath.Substring(0, path.CanonPath.Length - 1)); + var total = dir.Find($"{path.ToRelativePath()}/*.{Ext}").files.Count(); // Exclude string & init event files from the total. @@ -57,6 +62,7 @@ public sealed partial class ReplayLoadManager var decompressedStream = new MemoryStream(uncompressedSize); decompressStream.CopyTo(decompressedStream, uncompressedSize); decompressedStream.Position = 0; + DebugTools.Assert(uncompressedSize == decompressedStream.Length); while (decompressedStream.Position < decompressedStream.Length) { @@ -89,7 +95,9 @@ public sealed partial class ReplayLoadManager metaData.StartTime, metaData.Duration, checkpoints, - initData); + initData, + metaData.ClientSide, + metaData.YamlData); } private ReplayMessage? LoadInitFile( @@ -100,7 +108,7 @@ public sealed partial class ReplayLoadManager if (!dir.Exists(path / InitFile)) return null; - // TODO compress init messages, then decompress them here. + // TODO replays compress init messages, then decompress them here. using var fileStream = dir.OpenRead(path / InitFile); _serializer.DeserializeDirect(fileStream, out ReplayMessage initData); return initData; @@ -116,7 +124,7 @@ public sealed partial class ReplayLoadManager return parsed.FirstOrDefault()?.Root as MappingDataNode; } - private (HashSet CVars, TimeSpan Duration, TimeSpan StartTime) + private (MappingDataNode YamlData, HashSet CVars, TimeSpan Duration, TimeSpan StartTime, bool ClientSide) LoadMetadata(IWritableDirProvider directory, ResPath path) { _sawmill.Info($"Reading replay metadata"); @@ -129,6 +137,7 @@ public sealed partial class ReplayLoadManager var startTick = ((ValueDataNode) data[Tick]).Value; var timeBaseTick = ((ValueDataNode) data[BaseTick]).Value; var timeBaseTimespan = ((ValueDataNode) data[BaseTime]).Value; + var clientSide = bool.Parse(((ValueDataNode) data[IsClient]).Value); var duration = TimeSpan.Parse(((ValueDataNode) data[Duration]).Value); if (!typeHash.SequenceEqual(_serializer.GetSerializableTypesHash())) @@ -147,6 +156,6 @@ public sealed partial class ReplayLoadManager _timing.TimeBase = (new TimeSpan(long.Parse(timeBaseTimespan)), new GameTick(uint.Parse(timeBaseTick))); _sawmill.Info($"Successfully read metadata"); - return (cvars, duration, _timing.CurTime); + return (data, cvars, duration, _timing.CurTime, clientSide); } } diff --git a/Robust.Client/Replays/Loading/ReplayLoadManager.Start.cs b/Robust.Client/Replays/Loading/ReplayLoadManager.Start.cs index ea7bfb72e..ae77d1c52 100644 --- a/Robust.Client/Replays/Loading/ReplayLoadManager.Start.cs +++ b/Robust.Client/Replays/Loading/ReplayLoadManager.Start.cs @@ -15,12 +15,12 @@ public sealed partial class ReplayLoadManager { public event Action? LoadOverride; - public void LoadAndStartReplay(IWritableDirProvider dir, ResPath path) + public async void LoadAndStartReplay(IWritableDirProvider dir, ResPath path) { if (LoadOverride != null) LoadOverride.Invoke(dir, path); else - LoadAndStartReplayAsync(dir, path); + await LoadAndStartReplayAsync(dir, path); } public async Task LoadAndStartReplayAsync( @@ -47,7 +47,6 @@ public sealed partial class ReplayLoadManager _timing.Paused = true; var checkpoint = data.Checkpoints[0]; data.CurrentIndex = checkpoint.Index; - var state = checkpoint.State; foreach (var (name, value) in checkpoint.Cvars) { @@ -57,14 +56,15 @@ public sealed partial class ReplayLoadManager var tick = new GameTick(data.TickOffset.Value + (uint) data.CurrentIndex); _timing.CurTick = _timing.LastRealTick = _timing.LastProcessedTick = tick; - _gameState.UpdateFullRep(state, cloneDelta: true); + _gameState.UpdateFullRep(checkpoint.FullState, cloneDelta: true); var i = 0; - var total = state.EntityStates.Value.Count; - List entities = new(state.EntityStates.Value.Count); + var entStates = checkpoint.FullState.EntityStates.Value; + var total = entStates.Count; + List entities = new(total); await callback(i, total, LoadingState.Spawning, true); - foreach (var ent in state.EntityStates.Value) + foreach (var ent in entStates) { var metaState = (MetaDataComponentState?)ent.ComponentChanges.Value? .FirstOrDefault(c => c.NetID == _metaId).State; @@ -81,8 +81,12 @@ public sealed partial class ReplayLoadManager } } + // TODO add progress bar / loading stage for this? await callback(0, total, LoadingState.Initializing, true); - _gameState.ApplyGameState(state, data.NextState); + var nextIndex = checkpoint.Index + 1; + var next = nextIndex < data.States.Count ? data.States[nextIndex] : null; + _gameState.ClearDetachQueue(); + _gameState.ApplyGameState(checkpoint.State, next); i = 0; var query = _entMan.GetEntityQuery(); @@ -108,8 +112,12 @@ public sealed partial class ReplayLoadManager } } + // TODO add progress bar / loading stage for this? + _gameState.ClearDetachQueue(); + _gameState.DetachImmediate(checkpoint.Detached); + _timing.TimeBase = checkpoint.TimeBase; - data.LastApplied = state.ToSequence; + data.LastApplied = checkpoint.Tick; 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 index 3cfd37c5b..1e4973bb0 100644 --- a/Robust.Client/Replays/Loading/ReplayLoadManager.cs +++ b/Robust.Client/Replays/Loading/ReplayLoadManager.cs @@ -11,6 +11,7 @@ using Robust.Shared.IoC; using Robust.Shared.Localization; using Robust.Shared.Log; using Robust.Shared.Prototypes; +using Robust.Shared.Replays; namespace Robust.Client.Replays.Loading; @@ -44,8 +45,10 @@ public sealed partial class ReplayLoadManager : IReplayLoadManager _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); + _confMan.OnValueChanged(CVars.CheckpointEntitySpawnThreshold, value => _checkpointEntitySpawnThreshold = value, + true); + _confMan.OnValueChanged(CVars.CheckpointEntityStateThreshold, value => _checkpointEntityStateThreshold = value, + true); _metaId = _factory.GetRegistration(typeof(MetaDataComponent)).NetID!.Value; _sawmill = _logMan.GetSawmill("replay"); } diff --git a/Robust.Client/Replays/Playback/IReplayPlaybackManager.cs b/Robust.Client/Replays/Playback/IReplayPlaybackManager.cs index df62a27d7..3073bc754 100644 --- a/Robust.Client/Replays/Playback/IReplayPlaybackManager.cs +++ b/Robust.Client/Replays/Playback/IReplayPlaybackManager.cs @@ -1,6 +1,10 @@ using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using Robust.Shared.GameObjects; +using Robust.Shared.Network; using Robust.Shared.Replays; +using Robust.Shared.Serialization.Markdown.Mapping; namespace Robust.Client.Replays.Playback; @@ -65,9 +69,10 @@ public interface IReplayPlaybackManager void SetTime(TimeSpan time) => SetIndex(GetIndex(time)); /// - /// Invoked after replay playback has started and the first game state has been applied. + /// Invoked after replay playback has started and the first game state has been applied. Provides the replay + /// metadata and the messages that were received just before the replay recording was started. /// - event Action? ReplayPlaybackStarted; + event Action>? ReplayPlaybackStarted; /// /// If not null, this will cause the playback to auto-pause after some number of ticks. E.g., if you want to advance @@ -122,4 +127,15 @@ public interface IReplayPlaybackManager /// Invoked when the replay is unpaused. /// event Action? ReplayUnpaused; + + /// + /// If currently replaying a client-side recording, this is the user that recorded the replay. + /// Useful for setting default observer spawn positions. + /// + NetUserId? Recorder { get; } + + /// + /// Fetches the entity that the is currently attached to. + /// + public bool TryGetRecorderEntity([NotNullWhen(true)] out EntityUid? uid); } diff --git a/Robust.Client/Replays/Playback/ReplayPlaybackManager.Checkpoint.cs b/Robust.Client/Replays/Playback/ReplayPlaybackManager.Checkpoint.cs index 4998df0a8..fc6b05892 100644 --- a/Robust.Client/Replays/Playback/ReplayPlaybackManager.Checkpoint.cs +++ b/Robust.Client/Replays/Playback/ReplayPlaybackManager.Checkpoint.cs @@ -1,6 +1,8 @@ using System; +using System.Linq; using Robust.Client.GameObjects; using Robust.Client.GameStates; +using Robust.Shared.GameObjects; using Robust.Shared.Replays; using Robust.Shared.Timing; using Robust.Shared.Utility; @@ -24,14 +26,14 @@ internal sealed partial class ReplayPlaybackManager _entMan.FlushEntities(); var checkpoint = GetLastCheckpoint(Replay, index); - var state = checkpoint.State; _sawmill.Info($"Resetting to checkpoint. From {Replay.CurrentIndex} to {checkpoint.Index}"); var st = new Stopwatch(); st.Start(); Replay.CurrentIndex = checkpoint.Index; - DebugTools.Assert(state.ToSequence == new GameTick(Replay.TickOffset.Value + (uint) Replay.CurrentIndex)); + DebugTools.Assert(Replay.ClientSideRecording + || checkpoint.Tick == new GameTick(Replay.TickOffset.Value + (uint) Replay.CurrentIndex)); foreach (var (name, value) in checkpoint.Cvars) { @@ -40,14 +42,9 @@ internal sealed partial class ReplayPlaybackManager _timing.TimeBase = checkpoint.TimeBase; _timing.CurTick = _timing.LastRealTick = _timing.LastProcessedTick = new GameTick(Replay.TickOffset.Value + (uint) Replay.CurrentIndex); - Replay.LastApplied = state.ToSequence; + Replay.LastApplied = checkpoint.Tick; - _gameState.PartialStateReset(state, false, false); - _entMan.EntitySysManager.GetEntitySystem().Reset(); - _entMan.EntitySysManager.GetEntitySystem().Reset(); - - _gameState.UpdateFullRep(state, cloneDelta: true); - _gameState.ApplyGameState(state, Replay.NextState); + ApplyCheckpointState(checkpoint, Replay); ReplayCheckpointReset?.Invoke(); @@ -56,6 +53,52 @@ internal sealed partial class ReplayPlaybackManager _timing.CurTick += 1; } + private void ApplyCheckpointState(CheckpointState checkpoint, ReplayData replay) + { + DebugTools.Assert(replay.ClientSideRecording || checkpoint.Detached.Count == 0); + + var nextIndex = checkpoint.Index + 1; + var next = nextIndex < replay.States.Count ? replay.States[nextIndex] : null; + _gameState.PartialStateReset(checkpoint.FullState, false, false); + _entMan.EntitySysManager.GetEntitySystem().Reset(); + _entMan.EntitySysManager.GetEntitySystem().Reset(); + _gameState.UpdateFullRep(checkpoint.FullState, cloneDelta: true); + _gameState.ClearDetachQueue(); + EnsureDetachedExist(checkpoint); + _gameState.DetachImmediate(checkpoint.Detached); + _gameState.ApplyGameState(checkpoint.State, next); + } + + private void EnsureDetachedExist(CheckpointState checkpoint) + { + // Client-side replays only apply states for currently attached entities. But this means that when rewinding + // time we need to ensure that detached entities still get "un-deleted". + // Also important when jumping forward to a point after the entity was first encountered and then detached. + + if (checkpoint.DetachedStates == null) + return; + + DebugTools.Assert(checkpoint.Detached.Count == checkpoint.DetachedStates.Length); ; + var metas = _entMan.GetEntityQuery(); + foreach (var es in checkpoint.DetachedStates) + { + if (metas.TryGetComponent(es.Uid, out var meta) && !meta.EntityDeleted) + continue; + ; + var metaState = (MetaDataComponentState?)es.ComponentChanges.Value? + .FirstOrDefault(c => c.NetID == _metaId).State; + + if (metaState == null) + throw new MissingMetadataException(es.Uid); + + _entMan.CreateEntityUninitialized(metaState.PrototypeId, es.Uid); + meta = metas.GetComponent(es.Uid); + _entMan.InitializeEntity(es.Uid, meta); + _entMan.StartEntity(es.Uid); + meta.LastStateApplied = checkpoint.Tick; + } + } + public CheckpointState GetLastCheckpoint(ReplayData data, int index) { var target = CheckpointState.DummyState(index); diff --git a/Robust.Client/Replays/Playback/ReplayPlaybackManager.Time.cs b/Robust.Client/Replays/Playback/ReplayPlaybackManager.Time.cs index 9b11c4468..f19d66a73 100644 --- a/Robust.Client/Replays/Playback/ReplayPlaybackManager.Time.cs +++ b/Robust.Client/Replays/Playback/ReplayPlaybackManager.Time.cs @@ -16,6 +16,17 @@ internal sealed partial class ReplayPlaybackManager if (Replay == null) throw new Exception("Not currently playing a replay"); + if (_timing.ApplyingState) + { + // This fixes a niche error. If scrubbing forward in time, and the currently spectated entity gets deleted + // this can trigger events that cause the player to be attached to a new entity. This may cause game UI + // state / screen changes, which then trigger key-up events, which in turn cause scrubbing to end, thus + // causing this method to try apply a game state while already in the middle of applying another state. So + // we will just do nothing instead. + return; + } + + Playing &= !pausePlayback; value = Math.Clamp(value, 0, Replay.States.Count - 1); if (value == Replay.CurrentIndex) @@ -53,8 +64,9 @@ internal sealed partial class ReplayPlaybackManager // TODO REPLAYS block audio // Just block audio/midi from ever starting, rather than repeatedly stopping it. StopAudio(); - - DebugTools.Assert(Replay.LastApplied + 1 == state.ToSequence); +; + DebugTools.Assert(Replay.LastApplied >= state.FromSequence); + DebugTools.Assert(Replay.LastApplied + 1 <= state.ToSequence); Replay.LastApplied = state.ToSequence; } diff --git a/Robust.Client/Replays/Playback/ReplayPlaybackManager.Update.cs b/Robust.Client/Replays/Playback/ReplayPlaybackManager.Update.cs index 5ce2853cf..8f277abd6 100644 --- a/Robust.Client/Replays/Playback/ReplayPlaybackManager.Update.cs +++ b/Robust.Client/Replays/Playback/ReplayPlaybackManager.Update.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Robust.Shared.GameObjects; using Robust.Shared.Network.Messages; using Robust.Shared.Replays; @@ -29,7 +30,7 @@ internal sealed partial class ReplayPlaybackManager Playing = false; // TODO REPLAYS do we actually need to do this? - // if not, we can probably remove all of the UpdateFullRep() calls, which speeds things up significantly. + // Either way, the UpdateFullRep() calls need to stay because it is needed for PVS-detached entities. _gameState.ResetPredictedEntities(); if (Playing) @@ -42,7 +43,8 @@ internal sealed partial class ReplayPlaybackManager var state = Replay.CurState; _gameState.UpdateFullRep(state, cloneDelta: true); _gameState.ApplyGameState(state, Replay.NextState); - DebugTools.Assert(Replay.LastApplied + 1 == state.ToSequence); + DebugTools.Assert(Replay.LastApplied >= state.FromSequence); + DebugTools.Assert(Replay.LastApplied + 1 <= state.ToSequence); Replay.LastApplied = state.ToSequence; ProcessMessages(Replay.CurMessages, false); } @@ -74,8 +76,21 @@ internal sealed partial class ReplayPlaybackManager continue; } - DebugTools.Assert(message is not ReplayPrototypeUploadMsg && message is not SharedNetworkResourceManager.ReplayResourceUploadMsg); + if (Replay.ClientSideRecording && message is LeavePvs leavePvs) + { + // TODO Replays detach immediate + // Maybe track our own detach queue and use _gameState.DetachImmediate()? + // That way we don't have to clone this. Downside would be that all entities will be immediately + // detached. I.e., the detach budget cvar will simply be ignored. + var clone = new List(leavePvs.Entities); + _gameState.QueuePvsDetach(clone, leavePvs.Tick); + continue; + } + + DebugTools.Assert(message is not LeavePvs); + DebugTools.Assert(message is not ReplayPrototypeUploadMsg); + DebugTools.Assert(message is not SharedNetworkResourceManager.ReplayResourceUploadMsg); if (HandleReplayMessage != null && HandleReplayMessage.Invoke(message, skipEffects)) continue; diff --git a/Robust.Client/Replays/Playback/ReplayPlaybackManager.cs b/Robust.Client/Replays/Playback/ReplayPlaybackManager.cs index 56aba3091..40948676b 100644 --- a/Robust.Client/Replays/Playback/ReplayPlaybackManager.cs +++ b/Robust.Client/Replays/Playback/ReplayPlaybackManager.cs @@ -1,18 +1,23 @@ using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using Robust.Client.Audio.Midi; using Robust.Client.Configuration; using Robust.Client.GameObjects; using Robust.Client.GameStates; using Robust.Client.Graphics; +using Robust.Client.Player; using Robust.Client.Timing; using Robust.Client.Upload; using Robust.Shared; using Robust.Shared.Configuration; +using Robust.Shared.GameObjects; using Robust.Shared.IoC; using Robust.Shared.Log; using Robust.Shared.Network; using Robust.Shared.Prototypes; using Robust.Shared.Replays; +using Robust.Shared.Serialization.Markdown.Mapping; namespace Robust.Client.Replays.Playback; @@ -21,9 +26,11 @@ internal sealed partial class ReplayPlaybackManager : IReplayPlaybackManager [Dependency] private readonly ILogManager _logMan = default!; [Dependency] private readonly IBaseClient _client = default!; [Dependency] private readonly IMidiManager _midi = default!; + [Dependency] private readonly IPlayerManager _player = default!; [Dependency] private readonly IClydeAudio _clydeAudio = 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 IGameController _controller = default!; [Dependency] private readonly IClientEntityManager _entMan = default!; @@ -32,17 +39,19 @@ internal sealed partial class ReplayPlaybackManager : IReplayPlaybackManager [Dependency] private readonly IClientGameStateManager _gameState = default!; [Dependency] private readonly IClientNetConfigurationManager _netConf = default!; - public event Action? ReplayPlaybackStarted; + public event Action>? ReplayPlaybackStarted; public event Action? ReplayPlaybackStopped; public event Action? ReplayPaused; public event Action? ReplayUnpaused; public ReplayData? Replay { get; private set; } + public NetUserId? Recorder => Replay?.Recorder; private int _checkpointInterval; private int _visualEventThreshold; public uint? AutoPauseCountdown { get; set; } public int? ScrubbingTarget { get; set; } private bool _playing; + private ushort _metaId; private bool _initialized; private ISawmill _sawmill = default!; @@ -77,6 +86,7 @@ internal sealed partial class ReplayPlaybackManager : IReplayPlaybackManager _initialized = true; _sawmill = _logMan.GetSawmill("replay"); + _metaId = _factory.GetRegistration(typeof(MetaDataComponent)).NetID!.Value; _confMan.OnValueChanged(CVars.CheckpointInterval, (value) => _checkpointInterval = value, true); _confMan.OnValueChanged(CVars.ReplaySkipThreshold, (value) => _visualEventThreshold = value, true); _client.RunLevelChanged += OnRunLevelChanged; @@ -101,10 +111,11 @@ internal sealed partial class ReplayPlaybackManager : IReplayPlaybackManager Replay = replay; _controller.TickUpdateOverride += TickUpdateOverride; + if (Replay.CurrentIndex < 0) ResetToNearestCheckpoint(0, true); - ReplayPlaybackStarted?.Invoke(); + ReplayPlaybackStarted?.Invoke(Replay.YamlData, Replay.InitialMessages?.Messages ?? new ()); } public void StopReplay() @@ -135,4 +146,19 @@ internal sealed partial class ReplayPlaybackManager : IReplayPlaybackManager renderer.StopAllNotes(); } } + + public bool TryGetRecorderEntity([NotNullWhen(true)] out EntityUid? uid) + { + if (Recorder != null + && _player.SessionsDict.TryGetValue(Recorder.Value, out var session) + && session.AttachedEntity is { } recorderEnt + && _entMan.EntityExists(recorderEnt)) + { + uid = recorderEnt; + return true; + } + + uid = null; + return false; + } } diff --git a/Robust.Client/Replays/ReplayRecordingManager.cs b/Robust.Client/Replays/ReplayRecordingManager.cs index 690901538..89eb763e9 100644 --- a/Robust.Client/Replays/ReplayRecordingManager.cs +++ b/Robust.Client/Replays/ReplayRecordingManager.cs @@ -1,32 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Robust.Client.GameStates; +using Robust.Client.Player; +using Robust.Client.Timing; +using Robust.Shared; +using Robust.Shared.ContentPack; +using Robust.Shared.GameObjects; +using Robust.Shared.GameStates; +using Robust.Shared.IoC; +using Robust.Shared.Players; using Robust.Shared.Replays; using Robust.Shared.Serialization.Markdown.Mapping; -using System.Collections.Generic; -using System; +using Robust.Shared.Serialization.Markdown.Value; +using Robust.Shared.Timing; +using Robust.Shared.Utility; namespace Robust.Client.Replays; -/// -/// Dummy class so that can be used in shared code. -/// -public sealed class ReplayRecordingManager : IReplayRecordingManager +internal sealed class ReplayRecordingManager : SharedReplayRecordingManager { - /// - public void QueueReplayMessage(object args) { } + [Dependency] private readonly IBaseClient _client = default!; + [Dependency] private readonly IEntityManager _entMan = default!; + [Dependency] private readonly IPlayerManager _player = default!; + [Dependency] private readonly IClientGameStateManager _state = default!; + [Dependency] private readonly IClientGameTiming _timing = default!; - public bool Recording => false; - - /// - public event Action<(MappingDataNode, List)>? OnRecordingStarted + public override void Initialize() { - add { } - remove { } + base.Initialize(); + NetConf.OnValueChanged(CVars.ReplayClientRecordingEnabled, SetReplayEnabled, true); + _client.RunLevelChanged += OnRunLevelChanged; + RecordingStarted += OnRecordingStarted; } - /// - public event Action? OnRecordingStopped + private void OnRecordingStarted(MappingDataNode metadata, List messages) { - add { } - remove { } + if (_player.LocalPlayer == null) + return; + + // Add information about the user doing the recording. This is used to set the default replay observer position + // when playing back the replay. + var guid = _player.LocalPlayer.UserId.UserId.ToString(); + metadata[IReplayRecordingManager.Recorder] = new ValueDataNode(guid); } + private void OnRunLevelChanged(object? sender, RunLevelChangedEventArgs e) + { + // Replay recordings currently rely on the client receiving game states from a server. + // single-player replays are not yet supported. + if (e.OldLevel == ClientRunLevel.InGame) + StopRecording(); + } + + public override bool CanStartRecording() + { + // Replay recordings currently rely on the client receiving game states from a server. + // single-player replays are not yet supported. + return base.CanStartRecording() && _client.RunLevel == ClientRunLevel.InGame; + } + + public override void RecordClientMessage(object obj) + => RecordReplayMessage(obj); + + public override void RecordServerMessage(object obj) + { + // Do nothing. + } + + public override bool TryStartRecording( + IWritableDirProvider directory, + string? name = null, + bool overwrite = false, + TimeSpan? duration = null) + { + if (!base.TryStartRecording(directory, name, overwrite, duration)) + return false; + + var (state, detachMsg) = CreateFullState(); + if (detachMsg != null) + RecordReplayMessage(detachMsg); + Update(state); + return true; + } + + private (GameState, ReplayMessage.LeavePvs?) CreateFullState() + { + var tick = _timing.LastRealTick; + var players = _player.Sessions.Select(GetPlayerState).ToArray(); + var deletions = Array.Empty(); + + var fullRep = _state.GetFullRep(); + var entStates = new EntityState[fullRep.Count]; + var i = 0; + foreach (var (uid, dict) in fullRep) + { + var compData = new ComponentChange[dict.Count]; + var netComps = new HashSet(dict.Keys); + var j = 0; + foreach (var (id, compState) in dict) + { + compData[j++] = new ComponentChange(id, compState, tick); + } + + entStates[i++] = new EntityState(uid, compData, tick, netComps); + } + + var state = new GameState( + GameTick.Zero, + tick, + default, + entStates, + players, + deletions); + + var detached = new List(); + var query = _entMan.AllEntityQueryEnumerator(); + while (query.MoveNext(out var uid, out var comp)) + { + if (uid.IsClientSide()) + continue; + + DebugTools.Assert(fullRep.ContainsKey(uid)); + if ((comp.Flags & MetaDataFlags.Detached) != 0) + detached.Add(uid); + } + + var detachMsg = detached.Count > 0 ? new ReplayMessage.LeavePvs(detached, tick) : null; + return (state, detachMsg); + } + + private PlayerState GetPlayerState(ICommonSession session) + { + return new PlayerState + { + UserId = session.UserId, + Status = session.Status, + Name = session.Name, + ControlledEntity = session.AttachedEntity, + }; + } } diff --git a/Robust.Client/Upload/GamePrototypeLoadManager.cs b/Robust.Client/Upload/GamePrototypeLoadManager.cs index 3055c4d96..7db661c5e 100644 --- a/Robust.Client/Upload/GamePrototypeLoadManager.cs +++ b/Robust.Client/Upload/GamePrototypeLoadManager.cs @@ -1,41 +1,11 @@ -using System; -using System.Collections.Generic; -using Robust.Shared.IoC; -using Robust.Shared.Localization; -using Robust.Shared.Log; -using Robust.Shared.Network; -using Robust.Shared.Prototypes; using Robust.Shared.Upload; namespace Robust.Client.Upload; -public sealed class GamePrototypeLoadManager : IGamePrototypeLoadManager +public sealed class GamePrototypeLoadManager : SharedPrototypeLoadManager { - [Dependency] private readonly IClientNetManager _netManager = default!; - [Dependency] private readonly IPrototypeManager _prototypeManager = default!; - [Dependency] private readonly ILocalizationManager _localizationManager = default!; - - public void Initialize() + public override void SendGamePrototype(string prototype) { - _netManager.RegisterNetMessage(LoadGamePrototype); - } - - private void LoadGamePrototype(GamePrototypeLoadMessage message) - { - var changed = new Dictionary>(); - _prototypeManager.LoadString(message.PrototypeData, true, changed); - _prototypeManager.ResolveResults(); - _prototypeManager.ReloadPrototypes(changed); - _localizationManager.ReloadLocalizations(); - Logger.InfoS("adminbus", "Loaded adminbus prototype data."); - } - - public void SendGamePrototype(string prototype) - { - var msg = new GamePrototypeLoadMessage - { - PrototypeData = prototype - }; - _netManager.ClientSendMessage(msg); + NetManager.ClientSendMessage(new GamePrototypeLoadMessage { PrototypeData = prototype }); } } diff --git a/Robust.Client/Upload/NetworkResourceManager.cs b/Robust.Client/Upload/NetworkResourceManager.cs index c597d6670..72159ebdf 100644 --- a/Robust.Client/Upload/NetworkResourceManager.cs +++ b/Robust.Client/Upload/NetworkResourceManager.cs @@ -20,15 +20,6 @@ public sealed class NetworkResourceManager : SharedNetworkResourceManager ClearResources(); } - /// - /// Callback for when the server sends a new resource. - /// - /// The network message containing the data. - protected override void ResourceUploadMsg(NetworkResourceUploadMessage msg) - { - ContentRoot.AddOrUpdateFile(msg.RelativePath, msg.Data); - } - /// /// Clears all the networked resources. If used while connected to a server, this will probably cause issues. /// diff --git a/Robust.Client/ViewVariables/Editors/VVPropEditorEnum.cs b/Robust.Client/ViewVariables/Editors/VVPropEditorEnum.cs index d9d5f6d38..4ff5d540e 100644 --- a/Robust.Client/ViewVariables/Editors/VVPropEditorEnum.cs +++ b/Robust.Client/ViewVariables/Editors/VVPropEditorEnum.cs @@ -14,14 +14,20 @@ namespace Robust.Client.ViewVariables.Editors var enumList = Enum.GetValues(enumType); var optionButton = new OptionButton(); + bool hasValue = false; foreach (var val in enumList) { var label = val?.ToString(); if (label == null) continue; optionButton.AddItem(label, Convert.ToInt32(val)); + hasValue |= Convert.ToInt32(val) == Convert.ToInt32(value); } + // TODO properly support enum flags + if (!hasValue) + optionButton.AddItem(value.ToString() ?? string.Empty, Convert.ToInt32(value)); + optionButton.SelectId(Convert.ToInt32(value)); optionButton.Disabled = ReadOnly; diff --git a/Robust.Server/BaseServer.cs b/Robust.Server/BaseServer.cs index aa9a351cf..d53e0fb2c 100644 --- a/Robust.Server/BaseServer.cs +++ b/Robust.Server/BaseServer.cs @@ -5,13 +5,11 @@ using System.Threading; using Prometheus; using Robust.Server.Console; using Robust.Server.DataMetrics; -using Robust.Server.Debugging; using Robust.Server.GameObjects; 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; @@ -32,6 +30,7 @@ using Robust.Shared.Map; using Robust.Shared.Network; using Robust.Shared.Profiling; using Robust.Shared.Prototypes; +using Robust.Shared.Replays; using Robust.Shared.Serialization; using Robust.Shared.Serialization.Manager; using Robust.Shared.Threading; @@ -99,7 +98,7 @@ namespace Robust.Server [Dependency] private readonly ISerializationManager _serialization = default!; [Dependency] private readonly IStatusHost _statusHost = default!; [Dependency] private readonly IComponentFactory _componentFactory = default!; - [Dependency] private readonly IInternalReplayRecordingManager _replay = default!; + [Dependency] private readonly IReplayRecordingManager _replay = default!; [Dependency] private readonly IGamePrototypeLoadManager _protoLoadMan = default!; [Dependency] private readonly NetworkResourceManager _netResMan = default!; diff --git a/Robust.Server/Configuration/ServerNetConfigurationManager.cs b/Robust.Server/Configuration/ServerNetConfigurationManager.cs index d2a98c0bc..61e521818 100644 --- a/Robust.Server/Configuration/ServerNetConfigurationManager.cs +++ b/Robust.Server/Configuration/ServerNetConfigurationManager.cs @@ -85,7 +85,7 @@ internal sealed class ServerNetConfigurationManager : NetConfigurationManager, I NetManager.ServerSendToAll(msg); - _replayRecording.QueueReplayMessage(new ReplayMessage.CvarChangeMsg() + _replayRecording.RecordServerMessage(new ReplayMessage.CvarChangeMsg() { ReplicatedCvars = msg.NetworkedVars, TimeBase = _timing.TimeBase diff --git a/Robust.Server/Console/Commands/ScaleCommand.cs b/Robust.Server/Console/Commands/ScaleCommand.cs index 52b542e36..34d0515a3 100644 --- a/Robust.Server/Console/Commands/ScaleCommand.cs +++ b/Robust.Server/Console/Commands/ScaleCommand.cs @@ -50,7 +50,7 @@ public sealed class ScaleCommand : LocalizedCommands var @event = new ScaleEntityEvent(); _entityManager.EventBus.RaiseLocalEvent(uid, ref @event); - var appearanceComponent = _entityManager.EnsureComponent(uid); + var appearanceComponent = _entityManager.EnsureComponent(uid); if (!appearance.TryGetData(uid, ScaleVisuals.Scale, out var oldScale, appearanceComponent)) oldScale = Vector2.One; diff --git a/Robust.Server/GameObjects/Components/Appearance/ServerAppearanceComponent.cs b/Robust.Server/GameObjects/Components/Appearance/ServerAppearanceComponent.cs deleted file mode 100644 index d3011d512..000000000 --- a/Robust.Server/GameObjects/Components/Appearance/ServerAppearanceComponent.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Robust.Shared.GameObjects; - -namespace Robust.Server.GameObjects; - -/// -/// This is the server instance of . -/// -[RegisterComponent] -[ComponentReference(typeof(AppearanceComponent))] -public sealed class ServerAppearanceComponent : AppearanceComponent { } diff --git a/Robust.Server/GameObjects/ServerEntityManager.cs b/Robust.Server/GameObjects/ServerEntityManager.cs index 34d4b015c..80259ba27 100644 --- a/Robust.Server/GameObjects/ServerEntityManager.cs +++ b/Robust.Server/GameObjects/ServerEntityManager.cs @@ -174,7 +174,7 @@ namespace Robust.Server.GameObjects newMsg.SourceTick = _gameTiming.CurTick; if (recordReplay) - _replay.QueueReplayMessage(message); + _replay.RecordServerMessage(message); _networkManager.ServerSendToAll(newMsg); } diff --git a/Robust.Server/GameStates/ServerGameStateManager.cs b/Robust.Server/GameStates/ServerGameStateManager.cs index 475afae0f..a2ba9797b 100644 --- a/Robust.Server/GameStates/ServerGameStateManager.cs +++ b/Robust.Server/GameStates/ServerGameStateManager.cs @@ -22,8 +22,8 @@ using Robust.Shared.Utility; using SharpZstd.Interop; using Microsoft.Extensions.ObjectPool; using Prometheus; -using Robust.Shared.Players; using Robust.Server.Replays; +using Robust.Shared.Players; using Robust.Shared.Map.Components; namespace Robust.Server.GameStates @@ -43,7 +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 IServerReplayRecordingManager _replay = default!; [Dependency] private readonly IServerEntityNetworkManager _entityNetworkManager = default!; [Dependency] private readonly IConfigurationManager _cfg = default!; [Dependency] private readonly IParallelManager _parallelMgr = default!; @@ -213,8 +213,7 @@ namespace Robust.Server.GameStates var mQuery = _entityManager.GetEntityQuery(); // Replays process game states in parallel with players - var start = _replay.Recording ? -1 : 0; - Parallel.For(start, players.Length, opts, _threadResourcesPool.Get, SendPlayer, _threadResourcesPool.Return); + Parallel.For(-1, players.Length, opts, _threadResourcesPool.Get, SendPlayer, _threadResourcesPool.Return); PvsThreadResources SendPlayer(int i, ParallelLoopState state, PvsThreadResources resource) { @@ -223,7 +222,7 @@ namespace Robust.Server.GameStates if (i >= 0) SendStateUpdate(i, resource, inputSystem, players[i], pvsData, mQuery, tQuery, ref oldestAckValue); else - _replay.SaveReplayData(resource); + _replay.Update(); } catch (Exception e) // Catch EVERY exception { diff --git a/Robust.Server/Replays/IInternalReplayRecordingManager.cs b/Robust.Server/Replays/IInternalReplayRecordingManager.cs deleted file mode 100644 index 7b49a2ec1..000000000 --- a/Robust.Server/Replays/IInternalReplayRecordingManager.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Robust.Shared.IoC; -using System.Threading; -using static Robust.Server.GameStates.ServerGameStateManager; - -namespace Robust.Server.Replays; - -internal interface IInternalReplayRecordingManager : IServerReplayRecordingManager -{ - /// - /// Initializes the replay manager. - /// - void Initialize(); - - /// - /// Saves the replay data for the current tick. Does nothing if is false. - /// - /// - /// This is intended to be called by PVS in parallel with other game-state networking. - /// - void SaveReplayData(PvsThreadResources resource); -} diff --git a/Robust.Server/Replays/IServerReplayRecordingManager.cs b/Robust.Server/Replays/IServerReplayRecordingManager.cs index 52457a9fc..4ba26f075 100644 --- a/Robust.Server/Replays/IServerReplayRecordingManager.cs +++ b/Robust.Server/Replays/IServerReplayRecordingManager.cs @@ -1,32 +1,12 @@ using Robust.Shared.Replays; -using System; -using Robust.Shared; -using Robust.Shared.ContentPack; namespace Robust.Server.Replays; public interface IServerReplayRecordingManager : IReplayRecordingManager { /// - /// Starts recording a replay. + /// Processes pending write tasks and saves the replay data for the current tick. This should be called even if a + /// replay is not currently being recorded. /// - /// - /// The folder where the replay will be stored. This will be some folder within . - /// If not provided, will default to using the current time. - /// - /// - /// Whether to overwrite the specified path if a folder already exists. - /// - /// - /// Optional time limit for the recording. - /// - /// Returns true if the recording was successfully started. - bool TryStartRecording(IWritableDirProvider directory, string? name = null, bool overwrite = false, TimeSpan? duration = null); - - void StopRecording(); - - /// - /// Returns information about the currently ongoing replay recording, including the currently elapsed time and the compressed replay size. - /// - (float Minutes, int Ticks, float Size, float UncompressedSize) GetReplayStats(); + void Update(); } diff --git a/Robust.Server/Replays/ReplayRecordingManager.cs b/Robust.Server/Replays/ReplayRecordingManager.cs index 443148034..44aeca5f9 100644 --- a/Robust.Server/Replays/ReplayRecordingManager.cs +++ b/Robust.Server/Replays/ReplayRecordingManager.cs @@ -1,351 +1,55 @@ 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 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.Globalization; -using System.IO; -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 +internal sealed class ReplayRecordingManager : SharedReplayRecordingManager, IServerReplayRecordingManager { - // 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!; + [Dependency] private readonly IPlayerManager _player = default!; [Dependency] private readonly IEntitySystemManager _sysMan = default!; - [Dependency] private readonly IComponentFactory _factory = default!; - [Dependency] private readonly INetConfigurationManager _netConf = default!; + private GameTick _fromTick = GameTick.Zero; - private ISawmill _sawmill = default!; private PvsSystem _pvs = default!; - private List _queuedMessages = new(); - private int _maxCompressedSize; - private int _maxUncompressedSize; - private int _tickBatchSize; - private bool _enabled; - 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; - private (IWritableDirProvider, ResPath)? _directory; - - /// - public void Initialize() + public override void Initialize() { - _sawmill = Logger.GetSawmill("replay"); + base.Initialize(); _pvs = _sysMan.GetEntitySystem(); - - _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); + NetConf.OnValueChanged(CVars.ReplayServerRecordingEnabled, SetReplayEnabled, true); } - private void SetReplayEnabled(bool value) - { - if (!value) - StopRecording(); + public override void RecordServerMessage(object obj) + => RecordReplayMessage(obj); - _enabled = value; + public override void RecordClientMessage(object obj) + { + // Do nothing. } - /// - public void StopRecording() + public void Update() { - if (_curStream == null) + if (!IsRecording) + { + UpdateWriteTasks(); 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; - _directory = null; - throw; - } + var (entStates, deletions, _) = _pvs.GetAllEntityStates(null, _fromTick, Timing.CurTick); + var playerStates = _player.GetPlayerStates(_fromTick); + var state = new GameState(_fromTick, Timing.CurTick, 0, entStates, playerStates, deletions); + _fromTick = Timing.CurTick; + Update(state); } - /// - public bool TryStartRecording(IWritableDirProvider directory, string? name = null, bool overwrite = false, TimeSpan? duration = null) + protected override void Reset() { - if (!_enabled || _curStream != null) - return false; - - ResPath subDir; - if (name == null) - { - name = DateTime.UtcNow.ToString(DefaultReplayNameFormat); - subDir = new ResPath(name); - } - else - { - subDir = new ResPath(name).Clean(); - if (subDir == ResPath.Root || subDir == ResPath.Empty || subDir == ResPath.Self) - subDir = new ResPath(DateTime.UtcNow.ToString(DefaultReplayNameFormat)); - } - - var basePath = new ResPath(_netConf.GetCVar(CVars.ReplayDirectory)).ToRootedPath(); - subDir = basePath / subDir.ToRelativePath(); - - if (directory.Exists(subDir)) - { - if (overwrite) - { - _sawmill.Info($"Replay folder {subDir} already exists. Overwriting."); - directory.Delete(subDir); - } - else - { - _sawmill.Info($"Replay folder {subDir} already exists. Aborting recording."); - return false; - } - } - directory.CreateDir(subDir); - _directory = (directory, subDir); - - _curStream = new(_tickBatchSize * 2); - _index = 0; - _firstTick = true; - _recordingStart = (_timing.CurTick, _timing.CurTime); - - try - { - WriteInitialMetadata(name); - } - catch - { - _directory = null; - _curStream.Dispose(); - _curStream = null; - _index = 0; - _recordingStart = default; - throw; - } - - if (duration != null) - _recordingEnd = _timing.CurTime + duration.Value; - - _sawmill.Info("Started recording replay..."); - return true; + base.Reset(); + _fromTick = GameTick.Zero; } - - /// - public void QueueReplayMessage(object obj) - { - if (!Recording) - return; - - DebugTools.Assert(obj.GetType().HasCustomAttribute()); - _queuedMessages.Add(obj); - } - - /// - 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 || _directory is not var (dir, path)) - return; - - _curStream.Position = 0; - 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()); - file.Write(BitConverter.GetBytes(length)); - file.Write(buf.AsSpan(0, length)); - ArrayPool.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; - _directory = null; - } - } - - /// - /// 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. - /// - private void WriteInitialMetadata(string name) - { - if (_directory is not var (dir, path)) - return; - - var (stringHash, stringData) = _seri.GetStringSerializerPackage(); - var extraData = new List(); - - // 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[Name] = new ValueDataNode(name); - - // version info - _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[Hash] = new ValueDataNode(Convert.ToHexString(_seri.GetSerializableTypesHash())); - _yamlMetadata[Strings] = new ValueDataNode(Convert.ToHexString(stringHash)); - _yamlMetadata[CompHash] = new ValueDataNode(Convert.ToHexString(_factory.GetHash(true))); - - // Time data - var timeBase = _timing.TimeBase; - _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 = 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 = dir.OpenWrite(path / InitFile); - _seri.SerializeDirect(initDataFile, new ReplayMessage() { Messages = extraData }); - } - - // save data required for IRobustMappedStringSerializer - using var stringFile = dir.OpenWrite(path / StringsFile); - stringFile.Write(stringData); - - // Save replicated cvars. - using var cvarsFile = dir.OpenWrite(path / CvarFile); - _netConf.SaveToTomlStream(cvarsFile, _netConf.GetReplicatedVars().Select(x => x.name)); - } - - private void WriteFinalMetadata() - { - 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[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 = dir.OpenWriteText(path / MetaFile); - 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); - } - - /// - public event Action<(MappingDataNode, List)>? OnRecordingStarted; - - /// - public event Action? OnRecordingStopped; } diff --git a/Robust.Server/ServerIoC.cs b/Robust.Server/ServerIoC.cs index d1f0fff69..6f85d14a4 100644 --- a/Robust.Server/ServerIoC.cs +++ b/Robust.Server/ServerIoC.cs @@ -71,7 +71,6 @@ namespace Robust.Server deps.Register(); deps.Register(); deps.Register(); - deps.Register(); deps.Register(); deps.Register(); deps.Register(); diff --git a/Robust.Server/Upload/GamePrototypeLoadManager.cs b/Robust.Server/Upload/GamePrototypeLoadManager.cs index f6ea2600d..fa9d7dff1 100644 --- a/Robust.Server/Upload/GamePrototypeLoadManager.cs +++ b/Robust.Server/Upload/GamePrototypeLoadManager.cs @@ -1,14 +1,8 @@ -using System; -using System.Collections.Generic; using Robust.Server.Console; using Robust.Server.Player; using Robust.Shared.IoC; -using Robust.Shared.Localization; using Robust.Shared.Log; using Robust.Shared.Network; -using Robust.Shared.Prototypes; -using Robust.Shared.Replays; -using Robust.Shared.Serialization.Markdown.Mapping; using Robust.Shared.Upload; namespace Robust.Server.Upload; @@ -16,45 +10,32 @@ namespace Robust.Server.Upload; /// /// Manages sending runtime-loaded prototypes from game staff to clients. /// -public sealed class GamePrototypeLoadManager : IGamePrototypeLoadManager +public sealed class GamePrototypeLoadManager : SharedPrototypeLoadManager { - [Dependency] private readonly IReplayRecordingManager _replay = default!; [Dependency] private readonly IServerNetManager _netManager = default!; [Dependency] private readonly IPlayerManager _playerManager = default!; - [Dependency] private readonly IPrototypeManager _prototypeManager = default!; - [Dependency] private readonly ILocalizationManager _localizationManager = default!; [Dependency] private readonly IConGroupController _controller = default!; - private readonly List _loadedPrototypes = new(); - public IReadOnlyList LoadedPrototypes => _loadedPrototypes; - - public void Initialize() + public override void Initialize() { - _netManager.RegisterNetMessage(ClientLoadsPrototype); + base.Initialize(); _netManager.Connected += NetManagerOnConnected; - _replay.OnRecordingStarted += OnStartReplayRecording; } - private void OnStartReplayRecording((MappingDataNode, List) initReplayData) + public override void SendGamePrototype(string prototype) { - // replays will need information about currently loaded prototypes - foreach (var prototype in _loadedPrototypes) - { - initReplayData.Item2.Add(new ReplayPrototypeUploadMsg { PrototypeData = prototype }); - } + var msg = new GamePrototypeLoadMessage { PrototypeData = prototype }; + base.LoadPrototypeData(msg); + _netManager.ServerSendToAll(msg); } - public void SendGamePrototype(string prototype) - { - - } - - private void ClientLoadsPrototype(GamePrototypeLoadMessage message) + protected override void LoadPrototypeData(GamePrototypeLoadMessage message) { var player = _playerManager.GetSessionByChannel(message.MsgChannel); if (_controller.CanCommand(player, "loadprototype")) { - LoadPrototypeData(message.PrototypeData); + base.LoadPrototypeData(message); + _netManager.ServerSendToAll(message); // everyone load it up! Logger.InfoS("adminbus", $"Loaded adminbus prototype data from {player.Name}."); } else @@ -63,28 +44,10 @@ public sealed class GamePrototypeLoadManager : IGamePrototypeLoadManager } } - private void LoadPrototypeData(string prototypeData) - { - _loadedPrototypes.Add(prototypeData); - - _replay.QueueReplayMessage(new ReplayPrototypeUploadMsg { PrototypeData = prototypeData }); - - var msg = new GamePrototypeLoadMessage - { - PrototypeData = prototypeData - }; - _netManager.ServerSendToAll(msg); // everyone load it up! - var changed = new Dictionary>(); - _prototypeManager.LoadString(prototypeData, true, changed); // server needs it too. - _prototypeManager.ResolveResults(); - _prototypeManager.ReloadPrototypes(changed); - _localizationManager.ReloadLocalizations(); - } - private void NetManagerOnConnected(object? sender, NetChannelArgs e) { // Just dump all the prototypes on connect, before them missing could be an issue. - foreach (var prototype in _loadedPrototypes) + foreach (var prototype in LoadedPrototypes) { var msg = new GamePrototypeLoadMessage { diff --git a/Robust.Server/Upload/NetworkResourceManager.cs b/Robust.Server/Upload/NetworkResourceManager.cs index b732f0a5f..dd0fc6915 100644 --- a/Robust.Server/Upload/NetworkResourceManager.cs +++ b/Robust.Server/Upload/NetworkResourceManager.cs @@ -1,13 +1,10 @@ using System; -using System.Collections.Generic; using Robust.Server.Console; using Robust.Server.Player; using Robust.Shared; using Robust.Shared.Configuration; using Robust.Shared.IoC; using Robust.Shared.Network; -using Robust.Shared.Replays; -using Robust.Shared.Serialization.Markdown.Mapping; using Robust.Shared.Upload; using Robust.Shared.ViewVariables; @@ -18,7 +15,6 @@ public sealed class NetworkResourceManager : SharedNetworkResourceManager [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly IServerNetManager _serverNetManager = default!; [Dependency] private readonly IConfigurationManager _cfgManager = default!; - [Dependency] private readonly IReplayRecordingManager _replay = default!; [Dependency] private readonly IConGroupController _controller = default!; public event Action? OnResourceUploaded; @@ -33,16 +29,6 @@ public sealed class NetworkResourceManager : SharedNetworkResourceManager _serverNetManager.Connected += ServerNetManagerOnConnected; _cfgManager.OnValueChanged(CVars.ResourceUploadingEnabled, value => Enabled = value, true); _cfgManager.OnValueChanged(CVars.ResourceUploadingLimitMb, value => SizeLimit = value, true); - _replay.OnRecordingStarted += OnStartReplayRecording; - } - - private void OnStartReplayRecording((MappingDataNode, List) initReplayData) - { - // replays will need information about currently loaded extra resources - foreach (var (path, data) in ContentRoot.GetAllFiles()) - { - initReplayData.Item2.Add(new ReplayResourceUploadMsg { RelativePath = path, Data = data }); - } } /// @@ -67,7 +53,7 @@ public sealed class NetworkResourceManager : SharedNetworkResourceManager if (SizeLimit > 0f && msg.Data.Length * BytesToMegabytes > SizeLimit) return; - ContentRoot.AddOrUpdateFile(msg.RelativePath, msg.Data); + base.ResourceUploadMsg(msg); // Now we broadcast the message! foreach (var channel in _serverNetManager.Channels) @@ -75,7 +61,6 @@ public sealed class NetworkResourceManager : SharedNetworkResourceManager channel.SendMessage(msg); } - _replay.QueueReplayMessage(new ReplayResourceUploadMsg { RelativePath = msg.RelativePath, Data = msg.Data }); OnResourceUploaded?.Invoke(session, msg); } diff --git a/Robust.Shared/CVars.cs b/Robust.Shared/CVars.cs index 8db91f5c7..f8c65121f 100644 --- a/Robust.Shared/CVars.cs +++ b/Robust.Shared/CVars.cs @@ -195,10 +195,10 @@ namespace Robust.Shared CVarDef.Create("net.pvs_exit_budget", 75, CVar.ARCHIVE | CVar.CLIENTONLY); /// - /// ZSTD compression level to use when compressing game states. Also used for replays. + /// ZSTD compression level to use when compressing game states. Used by both networking and replays. /// public static readonly CVarDef NetPVSCompressLevel = - CVarDef.Create("net.pvs_compress_level", 3, CVar.SERVERONLY); + CVarDef.Create("net.pvs_compress_level", 3, CVar.ARCHIVE); /// /// Log late input messages from clients. @@ -1417,29 +1417,44 @@ namespace Robust.Shared /// /// A relative path pointing to a folder within the server data directory where all replays will be stored. /// - public static readonly CVarDef ReplayDirectory = CVarDef.Create("replay.directory", "replays", CVar.ARCHIVE); + public static readonly CVarDef ReplayDirectory = CVarDef.Create("replay.directory", "replays", + CVar.ARCHIVE); /// /// Maximum compressed size of a replay recording (in kilobytes) before recording automatically stops. /// - public static readonly CVarDef ReplayMaxCompressedSize = CVarDef.Create("replay.max_compressed_size", 1024 * 100, CVar.SERVERONLY | CVar.ARCHIVE); + public static readonly CVarDef ReplayMaxCompressedSize = CVarDef.Create("replay.max_compressed_size", + 1024 * 256, CVar.ARCHIVE); /// /// Maximum uncompressed size of a replay recording (in kilobytes) before recording automatically stops. /// - public static readonly CVarDef ReplayMaxUncompressedSize = CVarDef.Create("replay.max_uncompressed_size", 1024 * 300, CVar.SERVERONLY | CVar.ARCHIVE); + public static readonly CVarDef ReplayMaxUncompressedSize = CVarDef.Create("replay.max_uncompressed_size", + 1024 * 1024, CVar.ARCHIVE); /// /// 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. + /// for one or more ticks. Actual files may be slightly larger, this is just a threshold for the file to get + /// written. After compressing, the files are generally ~30% of their uncompressed size. /// - public static readonly CVarDef ReplayTickBatchSize = CVarDef.Create("replay.replay_tick_batchSize", 1024, CVar.SERVERONLY | CVar.ARCHIVE); + public static readonly CVarDef ReplayTickBatchSize = CVarDef.Create("replay.replay_tick_batchSize", + 1024, CVar.ARCHIVE); /// - /// Whether or not recording replays is enabled. + /// Whether or not server-side replay recording is enabled. /// - public static readonly CVarDef ReplayEnabled = CVarDef.Create("replay.enabled", true, CVar.SERVERONLY | CVar.ARCHIVE); + public static readonly CVarDef ReplayServerRecordingEnabled = CVarDef.Create( + "replay.server_recording_enabled", + true, + CVar.SERVERONLY | CVar.ARCHIVE); + + /// + /// Whether or not client-side replay recording is enabled. + /// + public static readonly CVarDef ReplayClientRecordingEnabled = CVarDef.Create( + "replay.client_recording_enabled", + true, + CVar.SERVER | CVar.REPLICATED | CVar.ARCHIVE); /// /// Determines the threshold before visual events (muzzle flashes, chat pop-ups, etc) are suppressed when diff --git a/Robust.Shared/Configuration/NetConfigurationManager.cs b/Robust.Shared/Configuration/NetConfigurationManager.cs index 0b10a1877..e4c73db62 100644 --- a/Robust.Shared/Configuration/NetConfigurationManager.cs +++ b/Robust.Shared/Configuration/NetConfigurationManager.cs @@ -22,9 +22,12 @@ namespace Robust.Shared.Configuration void SetupNetworking(); /// - /// Gets the list of networked cvars. + /// Gets the list of networked cvars that need to be sent to when connecting to server or client. /// - List<(string name, object value)> GetReplicatedVars(); + /// If true, includes all replicated cvars. I.e., clients would include cvars that were + /// received from the server, instead of only the ones that need to be sent to the server. + /// + List<(string name, object value)> GetReplicatedVars(bool all = false); /// /// Called every tick to process any incoming network messages. @@ -159,7 +162,7 @@ namespace Robust.Shared.Configuration NetManager.ServerSendMessage(msg, client); } - public List<(string name, object value)> GetReplicatedVars() + public List<(string name, object value)> GetReplicatedVars(bool all = false) { using var _ = Lock.ReadGuard(); @@ -173,14 +176,14 @@ namespace Robust.Shared.Configuration if ((cVar.Flags & CVar.REPLICATED) == 0) continue; - if (NetManager.IsClient) + if (!all) { - if ((cVar.Flags & CVar.SERVER) != 0) - continue; - } - else - { - if ((cVar.Flags & CVar.CLIENT) != 0) + if (NetManager.IsClient) + { + if ((cVar.Flags & CVar.SERVER) != 0) + continue; + } + else if ((cVar.Flags & CVar.CLIENT) != 0) continue; } diff --git a/Robust.Shared/Console/Commands/TeleportCommands.cs b/Robust.Shared/Console/Commands/TeleportCommands.cs index 76e348c8d..01f96452d 100644 --- a/Robust.Shared/Console/Commands/TeleportCommands.cs +++ b/Robust.Shared/Console/Commands/TeleportCommands.cs @@ -83,7 +83,7 @@ public sealed class TeleportToCommand : LocalizedCommands var target = args[0]; - if (!TryGetTransformFromUidOrUsername(target, shell, _entities, _players, out _, out var targetTransform)) + if (!TryGetTransformFromUidOrUsername(target, shell, out _, out var targetTransform)) return; var transformSystem = _entities.System(); @@ -109,7 +109,7 @@ public sealed class TeleportToCommand : LocalizedCommands if (victim == target) continue; - if (!TryGetTransformFromUidOrUsername(victim, shell, _entities, _players, out var uid, out var victimTransform)) + if (!TryGetTransformFromUidOrUsername(victim, shell, out var uid, out var victimTransform)) return; transformSystem.SetCoordinates(uid.Value, targetCoords); @@ -118,22 +118,20 @@ public sealed class TeleportToCommand : LocalizedCommands } } - private static bool TryGetTransformFromUidOrUsername( + private bool TryGetTransformFromUidOrUsername( string str, IConsoleShell shell, - IEntityManager entMan, - ISharedPlayerManager playerMan, [NotNullWhen(true)] out EntityUid? victimUid, [NotNullWhen(true)] out TransformComponent? transform) { - if (EntityUid.TryParse(str, out var uid) && entMan.TryGetComponent(uid, out transform)) + if (EntityUid.TryParse(str, out var uid) && _entities.TryGetComponent(uid, out transform)) { victimUid = uid; return true; } - if (playerMan.Sessions.TryFirstOrDefault(x => x.ConnectedClient.UserName == str, out var session) - && entMan.TryGetComponent(session.AttachedEntity, out transform)) + if (_players.Sessions.TryFirstOrDefault(x => x.ConnectedClient.UserName == str, out var session) + && _entities.TryGetComponent(session.AttachedEntity, out transform)) { victimUid = session.AttachedEntity; return true; @@ -150,11 +148,11 @@ public sealed class TeleportToCommand : LocalizedCommands { if (args.Length == 0) return CompletionResult.Empty; - ; + var last = args[^1]; var users = _players.Sessions - .Select(x => x.ConnectedClient.UserName ?? string.Empty) + .Select(x => x.Name ?? string.Empty) .Where(x => !string.IsNullOrWhiteSpace(x) && x.StartsWith(last, StringComparison.CurrentCultureIgnoreCase)); var hint = args.Length == 1 ? "cmd-tpto-destination-hint" : "cmd-tpto-victim-hint"; diff --git a/Robust.Shared/ContentPack/IWritableDirProvider.cs b/Robust.Shared/ContentPack/IWritableDirProvider.cs index bec326f63..69d055e11 100644 --- a/Robust.Shared/ContentPack/IWritableDirProvider.cs +++ b/Robust.Shared/ContentPack/IWritableDirProvider.cs @@ -1,5 +1,8 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; +using System.Threading; +using System.Threading.Tasks; using JetBrains.Annotations; using Robust.Shared.Utility; @@ -104,5 +107,25 @@ namespace Robust.Shared.ContentPack /// that opens up the screenshot directory using the operating system's file explorer. /// void OpenOsWindow(ResPath path); + + /// + /// Asynchronously opens and writes the sequence of bytes to a file. If the file exists, its existing contents will + /// be replaced. + /// + Task WriteAllBytesAsync(ResPath path, byte[] bytes, CancellationToken cancellationToken = default); + + /// + /// Asynchronously opens and writes the sequence of bytes to a file. If the file exists, its existing contents will + /// be replaced. + /// + Task WriteBytesAsync(ResPath path, byte[] bytes, int offset, int length, + CancellationToken cancellationToken = default); + + /// + /// Asynchronously opens and writes the sequence of bytes to a file. If the file exists, its existing contents will + /// be replaced. + /// + Task WriteBytesAsync(ResPath patch, ReadOnlyMemory bytes, + CancellationToken cancellationToken = default); } } diff --git a/Robust.Shared/ContentPack/VirtualWritableDirProvider.cs b/Robust.Shared/ContentPack/VirtualWritableDirProvider.cs index d8d705e53..8538d5997 100644 --- a/Robust.Shared/ContentPack/VirtualWritableDirProvider.cs +++ b/Robust.Shared/ContentPack/VirtualWritableDirProvider.cs @@ -2,7 +2,8 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Robust.Shared.Utility; namespace Robust.Shared.ContentPack @@ -214,6 +215,24 @@ namespace Robust.Shared.ContentPack // Not valid for virtual directories. As this has no effect on the rest of the game no exception is thrown. } + public async Task WriteAllBytesAsync(ResPath path, byte[] bytes, CancellationToken cancellationToken = default) + { + var file = Open(path, FileMode.Create, FileAccess.Write, FileShare.None); + await file.WriteAsync(bytes, cancellationToken); + } + + public async Task WriteBytesAsync(ResPath path, byte[] bytes, int offset, int length, CancellationToken cancellationToken = default) + { + var slice = new ReadOnlyMemory(bytes, offset, length); + await WriteBytesAsync(path, slice, cancellationToken); + } + + public async Task WriteBytesAsync(ResPath path, ReadOnlyMemory bytes, CancellationToken cancellationToken = default) + { + var file = Open(path, FileMode.Create, FileAccess.Write, FileShare.None); + await file.WriteAsync(bytes, cancellationToken); + } + private bool TryGetNodeAt(ResPath path, [NotNullWhen(true)] out INode? node) { if (!path.IsRooted) diff --git a/Robust.Shared/ContentPack/WritableDirProvider.cs b/Robust.Shared/ContentPack/WritableDirProvider.cs index 181f5be78..2fbfd69ba 100644 --- a/Robust.Shared/ContentPack/WritableDirProvider.cs +++ b/Robust.Shared/ContentPack/WritableDirProvider.cs @@ -2,8 +2,8 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; -using System.Linq; -using Robust.Shared.Log; +using System.Threading; +using System.Threading.Tasks; using Robust.Shared.Utility; namespace Robust.Shared.ContentPack @@ -170,5 +170,24 @@ namespace Robust.Shared.ContentPack return Path.GetFullPath(Path.Combine(root, relPath)); } + + public async Task WriteAllBytesAsync(ResPath path, byte[] bytes, CancellationToken cancellationToken = default) + { + var fullPath = GetFullPath(path); + await File.WriteAllBytesAsync(fullPath, bytes, cancellationToken); + } + + public async Task WriteBytesAsync(ResPath path, byte[] bytes, int offset, int length, CancellationToken cancellationToken = default) + { + var slice = new ReadOnlyMemory(bytes, offset, length); + await WriteBytesAsync(path, slice, cancellationToken); + } + + public async Task WriteBytesAsync(ResPath patch, ReadOnlyMemory bytes, CancellationToken cancellationToken = default) + { + var fullPath = GetFullPath(patch); + await using var fs = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, true); + await fs.WriteAsync(bytes, cancellationToken); + } } } diff --git a/Robust.Shared/GameObjects/Components/Appearance/AppearanceComponent.cs b/Robust.Shared/GameObjects/Components/Appearance/AppearanceComponent.cs index 5e3668580..9bb25a9b1 100644 --- a/Robust.Shared/GameObjects/Components/Appearance/AppearanceComponent.cs +++ b/Robust.Shared/GameObjects/Components/Appearance/AppearanceComponent.cs @@ -15,11 +15,22 @@ namespace Robust.Shared.GameObjects; /// The data works using a simple key/value system. It is recommended to use enum keys to prevent errors. /// Visualization works client side with derivatives of the VisualizerSystem class and corresponding components. /// -[NetworkedComponent] -public abstract class AppearanceComponent : Component +[RegisterComponent, NetworkedComponent] +public sealed class AppearanceComponent : Component { + /// + /// Whether or not the appearance needs to be updated. + /// [ViewVariables] internal bool AppearanceDirty; + /// + /// If true, this entity will have its appearance updated in the next frame update. + /// + /// + /// If an entity is outside of PVS range, this may be false while is true. + /// + [ViewVariables] internal bool UpdateQueued; + [ViewVariables] internal Dictionary AppearanceData = new(); [Dependency] private readonly IEntitySystemManager _sysMan = default!; diff --git a/Robust.Shared/GameObjects/Components/UserInterface/SharedUserInterfaceComponent.cs b/Robust.Shared/GameObjects/Components/UserInterface/SharedUserInterfaceComponent.cs index aac1a8750..171e1dccd 100644 --- a/Robust.Shared/GameObjects/Components/UserInterface/SharedUserInterfaceComponent.cs +++ b/Robust.Shared/GameObjects/Components/UserInterface/SharedUserInterfaceComponent.cs @@ -84,7 +84,8 @@ namespace Robust.Shared.GameObjects /// The session sending or receiving this message. /// Only set when the message is raised as a directed event. /// - public ICommonSession Session { get; set; } = default!; + [NonSerialized] + public ICommonSession Session = default!; } [NetSerializable, Serializable] diff --git a/Robust.Shared/GameObjects/EntitySystem.cs b/Robust.Shared/GameObjects/EntitySystem.cs index 4d0f685ce..75c646bfd 100644 --- a/Robust.Shared/GameObjects/EntitySystem.cs +++ b/Robust.Shared/GameObjects/EntitySystem.cs @@ -130,7 +130,7 @@ namespace Robust.Shared.GameObjects protected void RaiseNetworkEvent(EntityEventArgs message, Filter filter, bool recordReplay = true) { if (recordReplay) - _replayMan.QueueReplayMessage(message); + _replayMan.RecordServerMessage(message); foreach (var session in filter.Recipients) { diff --git a/Robust.Shared/GameObjects/Systems/SharedAppearanceSystem.cs b/Robust.Shared/GameObjects/Systems/SharedAppearanceSystem.cs index f8452b03c..ab88c7264 100644 --- a/Robust.Shared/GameObjects/Systems/SharedAppearanceSystem.cs +++ b/Robust.Shared/GameObjects/Systems/SharedAppearanceSystem.cs @@ -25,9 +25,9 @@ public abstract class SharedAppearanceSystem : EntitySystem /// /// Mark an appearance component as dirty, so that the appearance will get updated in the next frame update. /// - /// - /// If true, the appearance will update even if the entity is currently outside of PVS range. - public virtual void MarkDirty(AppearanceComponent component, bool updateDetached = false) {} + public virtual void QueueUpdate(EntityUid uid, AppearanceComponent component) + { + } public void SetData(EntityUid uid, Enum key, object value, AppearanceComponent? component = null) { @@ -47,7 +47,7 @@ public abstract class SharedAppearanceSystem : EntitySystem component.AppearanceData[key] = value; Dirty(component); - MarkDirty(component); + QueueUpdate(uid, component); } public bool TryGetData(EntityUid uid, Enum key, [NotNullWhen(true)] out T value, AppearanceComponent? component = null) diff --git a/Robust.Shared/Replays/IReplayRecordingManager.cs b/Robust.Shared/Replays/IReplayRecordingManager.cs index 628c24ba6..9286b7370 100644 --- a/Robust.Shared/Replays/IReplayRecordingManager.cs +++ b/Robust.Shared/Replays/IReplayRecordingManager.cs @@ -2,6 +2,9 @@ using Robust.Shared.GameObjects; using Robust.Shared.Serialization.Markdown.Mapping; using System; using System.Collections.Generic; +using System.Threading.Tasks; +using Robust.Shared.ContentPack; +using Robust.Shared.GameStates; using Robust.Shared.Utility; namespace Robust.Shared.Replays; @@ -9,40 +12,123 @@ namespace Robust.Shared.Replays; public interface IReplayRecordingManager { /// - /// Queues some net-serializable data to be saved for replaying + /// Initializes the replay manager. + /// + void Initialize(); + + /// + /// Whether or not a replay recording can currently be started. + /// + bool CanStartRecording(); + + /// + /// This is a convenience variation of that only records messages for server-side + /// recordings. + /// + void RecordServerMessage(object obj); + + /// + /// This is a convenience variation of that only records messages for client-side + /// recordings. + /// + void RecordClientMessage(object obj); + + /// + /// Queues some net-serializable data to be saved by a replay recording. Does nothing if + /// is false. /// /// - /// The queued object is typically something like an , so that replays can - /// simulate receiving networked messages. However, this can really be any serializable data and could be used - /// for saving server-exclusive data like power net or atmos pipe-net data for replaying. Alternatively, those - /// could also just use networked component states on entities that are in null space and hidden from all - /// players (but still saved to replays). + /// The queued object is typically something like an , so that replays can + /// simulate receiving networked messages. However, this can really be any serializable data and could be used + /// for saving server-exclusive data like power net or atmos pipe-net data for replaying. Alternatively, those + /// could also just use networked component states on entities that are in null space and hidden from all + /// players (but still saved to replays). /// - void QueueReplayMessage(object args); + void RecordReplayMessage(object obj); /// /// Whether the server is currently recording replay data. /// - bool Recording { get; } + bool IsRecording { get; } /// - /// 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". + /// Processes pending write tasks and saves the replay data for the current tick. This should be called even if a + /// replay is not currently being recorded. /// - event Action<(MappingDataNode, List)>? OnRecordingStarted; + void Update(GameState? state); /// - /// This gets invoked whenever a replay recording ends. Subscribers can use this to add extra yaml metadata data to the recording. + /// This gets invoked whenever a replay recording is starting. Subscribers can use this to add extra yaml data + /// to the recording's metadata file, as well as to provide serializable messages that get replayed when the replay + /// is initially loaded. E.g., this should contain networked events that would get sent to a newly connected client. /// - event Action? OnRecordingStopped; + event Action>? RecordingStarted; + /// + /// This gets invoked whenever a replay recording is stopping. Subscribers can use this to add extra yaml data to the + /// recording's metadata file. + /// + event Action? RecordingStopped; - // Define misc constants both for writing and reading replays. + /// + /// This gets invoked after a replay recording has finished and provides information about where the replay data + /// was saved. Note that this only means that all write tasks have started, however some of the file tasks may not + /// have finished yet. See . + /// + event Action? RecordingFinished; + + /// + /// Tries to starts a replay recording. + /// + /// + /// The directory that the replay will be written to. E.g., . + /// + /// + /// The name of the replay. This will also determine the folder where the replay will be stored, which will be a + /// subfolder within . If not provided, will default to using the current time. + /// + /// + /// Whether to overwrite the specified path if a folder already exists. + /// + /// + /// Optional time limit for the recording. + /// + /// Returns true if the recording was successfully started. + bool TryStartRecording( + IWritableDirProvider directory, + string? name = null, + bool overwrite = false, + TimeSpan? duration = null); + + /// + /// Stops an ongoing replay recording. + /// + void StopRecording(); + + /// + /// Returns information about the currently ongoing replay recording, including the currently elapsed time and the + /// compressed replay size. + /// + (float Minutes, int Ticks, float Size, float UncompressedSize) GetReplayStats(); + + /// + /// Check the status of all async write tasks and return true if one of the tasks is still writing something. + /// + bool IsWriting(); + + /// + /// Returns a task that will wait for all the current writing tasks to finish. + /// + /// + /// Throws an exception if is true (i.e., new write tasks as still being created). + /// + Task WaitWriteTasks(); + + // Define misc constants for writing and reading replays files. # region Constants /// - /// File extension for data files that have to be deserialized and decompressed. + /// File extension for data files that have to be deserialized and decompressed. /// public const string Ext = "dat"; @@ -71,6 +157,8 @@ public interface IReplayRecordingManager public const string Uncompressed = "uncompressedSize"; public const string EndTick = "endTick"; public const string EndTime = "serverEndTime"; + public const string IsClient = "clientRecording"; + public const string Recorder = "recordedBy"; #endregion } diff --git a/Robust.Shared/Replays/ReplayData.cs b/Robust.Shared/Replays/ReplayData.cs index d05c845df..2d2bc2e5d 100644 --- a/Robust.Shared/Replays/ReplayData.cs +++ b/Robust.Shared/Replays/ReplayData.cs @@ -2,7 +2,13 @@ using Robust.Shared.Serialization; using Robust.Shared.Timing; using System; using System.Collections.Generic; +using System.Linq; +using NetSerializer; +using Robust.Shared.GameObjects; using Robust.Shared.GameStates; +using Robust.Shared.Network; +using Robust.Shared.Serialization.Markdown.Mapping; +using Robust.Shared.Serialization.Markdown.Value; using Robust.Shared.Utility; namespace Robust.Shared.Replays; @@ -69,6 +75,15 @@ public sealed class ReplayData public TimeSpan CurrentReplayTime => ReplayTime[CurrentIndex]; + public readonly bool ClientSideRecording; + public readonly MappingDataNode YamlData; + + /// + /// If this is a client-side recording, this is the user that recorded that replay. Useful for setting default + /// observer spawn positions. + /// + public readonly NetUserId? Recorder; + /// /// 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) @@ -82,7 +97,9 @@ public sealed class ReplayData TimeSpan startTime, TimeSpan duration, CheckpointState[] checkpointStates, - ReplayMessage? initData) + ReplayMessage? initData, + bool clientSideRecording, + MappingDataNode yamlData) { States = states; Messages = messages; @@ -92,6 +109,14 @@ public sealed class ReplayData Duration = duration; Checkpoints = checkpointStates; InitialMessages = initData; + ClientSideRecording = clientSideRecording; + YamlData = yamlData; + + if (YamlData.TryGet(new ValueDataNode(IReplayRecordingManager.Recorder), out ValueDataNode? node) + && Guid.TryParse(node.Value, out var guid)) + { + Recorder = new NetUserId(guid); + } } } @@ -99,20 +124,73 @@ public sealed class ReplayData /// 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 sealed class CheckpointState : IComparable { public GameTick Tick => State.ToSequence; - public readonly GameState State; + + public readonly GameState FullState; + + public GameState State => AttachedStates ?? FullState; + + /// + /// This is a variant of for client-side replays that only contains information about entities + /// not currently detached due to PVS range limits (see ). + /// + /// + /// This is required because we need to update the full server state when jumping forward in + /// time, but in general we do not want to apply the old-state from detached entities. + /// + /// To see why this is needed, consider a scenario where entity A parented to entity B. Then both leave PVS and + /// ONLY entity B gets deleted. The client will not receive the new transform state for entity A, and if we blindly + /// apply the full set of the most recent server states it will cause entity A to throw errors. + /// + public readonly GameState? AttachedStates; + + public EntityState[]? DetachedStates; + public readonly (TimeSpan, GameTick) TimeBase; public readonly int Index; public readonly Dictionary Cvars; + public readonly List Detached; - public CheckpointState(GameState state, (TimeSpan, GameTick) time, Dictionary cvars, int index) + public CheckpointState( + GameState state, + (TimeSpan, GameTick) time, + Dictionary cvars, + int index, + HashSet detached) { - State = state; + FullState = state; TimeBase = time; Cvars = cvars.ShallowClone(); Index = index; + Detached = new(detached); + + if (Detached.Count == 0) + return; + + var attachedStates = new EntityState[state.EntityStates.Value.Count - Detached.Count]; + DetachedStates = new EntityState[Detached.Count]; + + int i = 0, j = 0; + foreach (var entState in state.EntityStates.Span) + { + if (detached.Contains(entState.Uid)) + DetachedStates[i++] = entState; + else + attachedStates[j++] = entState; + + } + DebugTools.Assert(i == DetachedStates.Length); + DebugTools.Assert(j == attachedStates.Length); + + AttachedStates = new GameState( + state.FromSequence, + state.ToSequence, + state.LastProcessedInput, + attachedStates, + state.PlayerStates, + state.EntityDeletions); } /// @@ -126,12 +204,14 @@ public readonly struct CheckpointState : IComparable private CheckpointState(int index) { Index = index; - State = default!; + FullState = default!; TimeBase = default!; Cvars = default!; + Detached = default!; + AttachedStates = default; } - public int CompareTo(CheckpointState other) => Index.CompareTo(other.Index); + public int CompareTo(CheckpointState? other) => Index.CompareTo(other?.Index ?? -1); } /// @@ -148,4 +228,17 @@ public sealed class ReplayMessage public List<(string name, object value)> ReplicatedCvars = default!; public (TimeSpan, GameTick) TimeBase = default; } + + [Serializable, NetSerializable] + public sealed class LeavePvs + { + public readonly List Entities; + public readonly GameTick Tick; + + public LeavePvs(List entities, GameTick tick) + { + Entities = entities; + Tick = tick; + } + } } diff --git a/Robust.Server/Replays/ReplayCommands.cs b/Robust.Shared/Replays/ReplayRecordingCommands.cs similarity index 83% rename from Robust.Server/Replays/ReplayCommands.cs rename to Robust.Shared/Replays/ReplayRecordingCommands.cs index 0a91905b9..8a88f280c 100644 --- a/Robust.Server/Replays/ReplayCommands.cs +++ b/Robust.Shared/Replays/ReplayRecordingCommands.cs @@ -4,11 +4,11 @@ using Robust.Shared.Localization; using System; using Robust.Shared.ContentPack; -namespace Robust.Server.Replays; +namespace Robust.Shared.Replays; internal sealed class ReplayStartCommand : LocalizedCommands { - [Dependency] private readonly IServerReplayRecordingManager _replay = default!; + [Dependency] private readonly IReplayRecordingManager _replay = default!; [Dependency] private readonly IResourceManager _resMan = default!; public override string Command => "replay_recording_start"; @@ -17,16 +17,28 @@ internal sealed class ReplayStartCommand : LocalizedCommands public override void Execute(IConsoleShell shell, string argStr, string[] args) { - if (_replay.Recording) + if (_replay.IsRecording) { - shell.WriteLine(Loc.GetString("cmd-replay-recording-start-already-recording")); + shell.WriteError(Loc.GetString("cmd-replay-recording-start-already-recording")); return; } - TimeSpan? duration = null; - if (args.Length > 0) + string? dir = args.Length == 0 ? null : args[0]; + + var overwrite = false; + if (args.Length > 1) { - if (!float.TryParse(args[0], out var minutes)) + if (!bool.TryParse(args[1], out overwrite)) + { + shell.WriteError(Loc.GetString("cmd-parse-failure-bool", ("arg", args[2]))); + return; + } + } + + TimeSpan? duration = null; + if (args.Length > 2) + { + if (!float.TryParse(args[2], out var minutes)) { shell.WriteError(Loc.GetString("cmd-parse-failure-float", ("arg", args[0]))); return; @@ -34,18 +46,6 @@ internal sealed class ReplayStartCommand : LocalizedCommands 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(_resMan.UserData, dir, overwrite, duration)) shell.WriteLine(Loc.GetString("cmd-replay-recording-start-success")); else @@ -55,21 +55,21 @@ internal sealed class ReplayStartCommand : LocalizedCommands public override CompletionResult GetCompletion(IConsoleShell shell, string[] args) { if (args.Length == 1) - return CompletionResult.FromHint(Loc.GetString("cmd-replay-recording-start-hint-time")); - - if (args.Length == 2) return CompletionResult.FromHint(Loc.GetString("cmd-replay-recording-start-hint-name")); - if (args.Length == 3) + if (args.Length == 2) return CompletionResult.FromHint(Loc.GetString("cmd-replay-recording-start-hint-overwrite")); + if (args.Length == 3) + return CompletionResult.FromHint(Loc.GetString("cmd-replay-recording-start-hint-time")); + return CompletionResult.Empty; } } internal sealed class ReplayStopCommand : LocalizedCommands { - [Dependency] private readonly IServerReplayRecordingManager _replay = default!; + [Dependency] private readonly IReplayRecordingManager _replay = default!; public override string Command => "replay_recording_stop"; public override string Description => LocalizationManager.GetString($"cmd-replay-recording-stop-desc"); @@ -77,7 +77,7 @@ internal sealed class ReplayStopCommand : LocalizedCommands public override void Execute(IConsoleShell shell, string argStr, string[] args) { - if (_replay.Recording) + if (_replay.IsRecording) { _replay.StopRecording(); shell.WriteLine(Loc.GetString("cmd-replay-recording-stop-success")); @@ -89,7 +89,7 @@ internal sealed class ReplayStopCommand : LocalizedCommands internal sealed class ReplayStatsCommand : LocalizedCommands { - [Dependency] private readonly IServerReplayRecordingManager _replay = default!; + [Dependency] private readonly IReplayRecordingManager _replay = default!; public override string Command => "replay_recording_stats"; public override string Description => LocalizationManager.GetString($"cmd-replay-recording-stats-desc"); @@ -97,7 +97,7 @@ internal sealed class ReplayStatsCommand : LocalizedCommands public override void Execute(IConsoleShell shell, string argStr, string[] args) { - if (_replay.Recording) + if (_replay.IsRecording) { var (time, tick, size, _) = _replay.GetReplayStats(); shell.WriteLine(Loc.GetString("cmd-replay-recording-stats-result", diff --git a/Robust.Shared/Replays/SharedReplayRecordingManager.Write.cs b/Robust.Shared/Replays/SharedReplayRecordingManager.Write.cs new file mode 100644 index 000000000..d725ef793 --- /dev/null +++ b/Robust.Shared/Replays/SharedReplayRecordingManager.Write.cs @@ -0,0 +1,117 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using Robust.Shared.ContentPack; +using Robust.Shared.Serialization; +using Robust.Shared.Utility; +using System.Threading.Tasks; +using YamlDotNet.Core; +using YamlDotNet.RepresentationModel; + +namespace Robust.Shared.Replays; + +// This partial class has various methods for async file writing (in case the path is on a networked drive or something like that) +internal abstract partial class SharedReplayRecordingManager +{ + private List _writeTasks = new(); + + private void WriteYaml(YamlDocument data, IWritableDirProvider dir, ResPath path) + { + var memStream = new MemoryStream(); + using var writer = new StreamWriter(memStream); + var yamlStream = new YamlStream { data }; + yamlStream.Save(new YamlMappingFix(new Emitter(writer)), false); + writer.Flush(); + var task = Task.Run(() => dir.WriteAllBytesAsync(path, memStream.ToArray())); + _writeTasks.Add(task); + } + + private void WriteSerializer(T obj, IWritableDirProvider dir, ResPath path) + { + var memStream = new MemoryStream(); + _serializer.SerializeDirect(memStream, obj); + + var task = Task.Run(() => dir.WriteAllBytesAsync(path, memStream.ToArray())); + _writeTasks.Add(task); + } + + private void WriteBytes(byte[] bytes, IWritableDirProvider dir, ResPath path) + { + var task = Task.Run(() => dir.WriteAllBytesAsync(path, bytes)); + _writeTasks.Add(task); + } + + private void WritePooledBytes(byte[] bytes, int length, IWritableDirProvider dir, ResPath path) + { + var task = Task.Run(() => Write(bytes, length, dir, path)); + _writeTasks.Add(task); + + static async Task Write(byte[] bytes, int length, IWritableDirProvider dir, ResPath path) + { + try + { + var slice = new ReadOnlyMemory(bytes, 0, length); + await dir.WriteBytesAsync(path, slice); + } + finally + { + ArrayPool.Shared.Return(bytes); + } + } + } + + private void WriteToml(IEnumerable enumerable, IWritableDirProvider dir, ResPath path) + { + var memStream = new MemoryStream(); + NetConf.SaveToTomlStream(memStream, enumerable); + + var task = Task.Run(() => dir.WriteAllBytesAsync(path, memStream.ToArray())); + _writeTasks.Add(task); + } + + protected bool UpdateWriteTasks() + { + bool isWriting = false; + for (var i = _writeTasks.Count - 1; i >= 0; i--) + { + var task = _writeTasks[i]; + switch(task.Status) + { + case TaskStatus.Canceled: + case TaskStatus.RanToCompletion: + _writeTasks.RemoveSwap(i); + break; + + case TaskStatus.Faulted: + var ex = task.Exception; + _sawmill.Error($"Replay write task encountered a fault. Rethrowing exception"); + Reset(); + throw ex!; + + case TaskStatus.Created: + Reset(); + throw new Exception("A replay write task was never started?"); + + default: + isWriting = true; + break; + } + } + + return isWriting; + } + + public bool IsWriting() => UpdateWriteTasks(); + + public Task WaitWriteTasks() + { + if (IsRecording) + throw new InvalidOperationException("Cannot wait for writes to finish while still recording replay"); + + // First, check for any tasks that have encountered errors. + UpdateWriteTasks(); + + return Task.WhenAll(_writeTasks); + } +} diff --git a/Robust.Shared/Replays/SharedReplayRecordingManager.cs b/Robust.Shared/Replays/SharedReplayRecordingManager.cs new file mode 100644 index 000000000..ea520e211 --- /dev/null +++ b/Robust.Shared/Replays/SharedReplayRecordingManager.cs @@ -0,0 +1,352 @@ +using Robust.Shared.Configuration; +using Robust.Shared.ContentPack; +using Robust.Shared.GameObjects; +using Robust.Shared.GameStates; +using Robust.Shared.IoC; +using Robust.Shared.Log; +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.Globalization; +using System.IO; +using System.Linq; +using Robust.Shared.Network; +using YamlDotNet.RepresentationModel; +using static Robust.Shared.Replays.IReplayRecordingManager; + +namespace Robust.Shared.Replays; + +internal abstract partial class SharedReplayRecordingManager : IReplayRecordingManager +{ + // date format for default replay names. Like the sortable template, but without colons. + public const string DefaultReplayNameFormat = "yyyy-MM-dd_HH-mm-ss"; + + [Dependency] protected readonly IGameTiming Timing = default!; + [Dependency] protected readonly INetConfigurationManager NetConf = default!; + [Dependency] private readonly IComponentFactory _factory = default!; + [Dependency] private readonly IRobustSerializer _serializer = default!; + [Dependency] private readonly INetManager _netMan = default!; + + public event Action>? RecordingStarted; + public event Action? RecordingStopped; + public event Action? RecordingFinished; + + + private ISawmill _sawmill = default!; + private List _queuedMessages = new(); + + private int _maxCompressedSize; + private int _maxUncompressedSize; + private int _tickBatchSize; + private bool _enabled; + + public bool IsRecording => _replay != null; + private (MemoryStream Stream, ZStdCompressionContext Context)? _replay; + + private int _index = 0; + private int _currentCompressedSize; + private int _currentUncompressedSize; + private (GameTick Tick, TimeSpan Time) _recordingStart; + private TimeSpan? _recordingEnd; + private MappingDataNode? _yamlMetadata; + private (IWritableDirProvider, ResPath)? _directory; + + /// + public virtual void Initialize() + { + _sawmill = Logger.GetSawmill("replay"); + 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); + NetConf.OnValueChanged(CVars.NetPVSCompressLevel, OnCompressionChanged); + } + + public virtual bool CanStartRecording() + { + return !IsRecording && _enabled; + } + + private void OnCompressionChanged(int value) + { + if (_replay is var (_, context)) + context.SetParameter(ZSTD_cParameter.ZSTD_c_compressionLevel, value); + } + + public void SetReplayEnabled(bool value) + { + if (!value) + StopRecording(); + + _enabled = value; + } + + /// + public void StopRecording() + { + if (_replay == null) + return; + + try + { + WriteGameState(continueRecording: false); + _sawmill.Info("Replay recording stopped!"); + } + catch + { + Reset(); + throw; + } + + UpdateWriteTasks(); + } + + public void Update(GameState? state) + { + UpdateWriteTasks(); + + if (state == null || _replay is not var (stream, _)) + return; + + try + { + _serializer.SerializeDirect(stream, state); + _serializer.SerializeDirect(stream, 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 || stream.Length > _tickBatchSize) + WriteGameState(continueRecording); + } + catch (Exception e) + { + _sawmill.Log(LogLevel.Error, e, "Caught exception while saving replay data."); + StopRecording(); + } + } + + /// + public virtual bool TryStartRecording( + IWritableDirProvider directory, + string? name = null, + bool overwrite = false, + TimeSpan? duration = null) + { + if (!CanStartRecording()) + return false; + + // If the previous recording had exceptions, throw them now before starting a new recording. + UpdateWriteTasks(); + + ResPath subDir; + if (name == null) + { + name = DateTime.UtcNow.ToString(DefaultReplayNameFormat); + subDir = new ResPath(name); + } + else + { + subDir = new ResPath(name).Clean(); + if (subDir == ResPath.Root || subDir == ResPath.Empty || subDir == ResPath.Self) + subDir = new ResPath(DateTime.UtcNow.ToString(DefaultReplayNameFormat)); + } + + var basePath = new ResPath(NetConf.GetCVar(CVars.ReplayDirectory)).ToRootedPath(); + subDir = basePath / subDir.ToRelativePath(); + + if (directory.Exists(subDir)) + { + if (overwrite) + { + _sawmill.Info($"Replay folder {subDir} already exists. Overwriting."); + directory.Delete(subDir); + } + else + { + _sawmill.Info($"Replay folder {subDir} already exists. Aborting recording."); + return false; + } + } + directory.CreateDir(subDir); + _directory = (directory, subDir); + + var context = new ZStdCompressionContext(); + context.SetParameter(ZSTD_cParameter.ZSTD_c_compressionLevel, NetConf.GetCVar(CVars.NetPVSCompressLevel)); + _replay = (new MemoryStream(_tickBatchSize * 2), context); + _index = 0; + _recordingStart = (Timing.CurTick, Timing.CurTime); + + try + { + WriteInitialMetadata(name); + } + catch + { + Reset(); + throw; + } + + if (duration != null) + _recordingEnd = Timing.CurTime + duration.Value; + + _sawmill.Info("Started recording replay..."); + UpdateWriteTasks(); + return true; + } + + public abstract void RecordServerMessage(object obj); + public abstract void RecordClientMessage(object obj); + + public void RecordReplayMessage(object obj) + { + if (!IsRecording) + return; + + DebugTools.Assert(obj.GetType().HasCustomAttribute()); + _queuedMessages.Add(obj); + } + + private void WriteGameState(bool continueRecording = true) + { + if (_replay is not var (stream, context) || _directory is not var (dir, path)) + return; + + stream.Position = 0; + + // Compress stream to buffer. + // First 4 bytes of buffer are reserved for the length of the uncompressed stream. + var bound = ZStd.CompressBound((int) stream.Length); + var buf = ArrayPool.Shared.Rent(4 + bound); + var length = context.Compress2( buf.AsSpan(4, bound), stream.AsSpan()); + BitConverter.TryWriteBytes(buf, (int)stream.Length); + WritePooledBytes(buf, 4 + length, dir, path / $"{_index++}.{Ext}"); + + _currentUncompressedSize += (int)stream.Length; + _currentCompressedSize += length; + if (_currentUncompressedSize >= _maxUncompressedSize || _currentCompressedSize >= _maxCompressedSize) + { + _sawmill.Info("Reached max replay recording size. Stopping recording."); + continueRecording = false; + } + + if (continueRecording) + stream.SetLength(0); + else + WriteFinalMetadata(); + } + + protected virtual void Reset() + { + if (_replay is var (stream, context)) + { + stream.Dispose(); + context.Dispose(); + } + + _replay = null; + _currentCompressedSize = 0; + _currentUncompressedSize = 0; + _index = 0; + _recordingEnd = null; + _directory = null; + } + + /// + /// 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. + /// + private void WriteInitialMetadata(string name) + { + if (_directory is not var (dir, path)) + return; + + var (stringHash, stringData) = _serializer.GetStringSerializerPackage(); + var extraData = new List(); + + // 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[Name] = new ValueDataNode(name); + + // version info + _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[Hash] = new ValueDataNode(Convert.ToHexString(_serializer.GetSerializableTypesHash())); + _yamlMetadata[Strings] = new ValueDataNode(Convert.ToHexString(stringHash)); + _yamlMetadata[CompHash] = new ValueDataNode(Convert.ToHexString(_factory.GetHash(true))); + + // Time data + var timeBase = Timing.TimeBase; + _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()); + + _yamlMetadata[IsClient] = new ValueDataNode(_netMan.IsClient.ToString()); + + RecordingStarted?.Invoke(_yamlMetadata, extraData); + + var document = new YamlDocument(_yamlMetadata.ToYaml()); + WriteYaml(document, dir, path / MetaFile); + } + + // Saving misc extra data like networked messages that typically get sent to newly connecting clients. + // TODO REPLAYS compression + // currently resource uploads are uncompressed, so this might be quite big. + if (extraData.Count > 0) + WriteSerializer(new ReplayMessage { Messages = extraData }, dir, path / InitFile); + + // save data required for IRobustMappedStringSerializer + WriteBytes(stringData, dir, path / StringsFile); + + // Save replicated cvars. + var cvars = NetConf.GetReplicatedVars(true).Select(x => x.name); + WriteToml(cvars, dir, path / CvarFile ); + } + + private void WriteFinalMetadata() + { + if (_yamlMetadata == null || _directory is not var (dir, path)) + return; + + RecordingStopped?.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[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()); + WriteYaml(document, dir, path / MetaFile); + UpdateWriteTasks(); + RecordingFinished?.Invoke(dir, path); + Reset(); + } + + public (float Minutes, int Ticks, float Size, float UncompressedSize) GetReplayStats() + { + if (!IsRecording) + 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); + } +} diff --git a/Robust.Shared/Upload/SharedNetworkResourceManager.cs b/Robust.Shared/Upload/SharedNetworkResourceManager.cs index a367dd85f..a94ba028a 100644 --- a/Robust.Shared/Upload/SharedNetworkResourceManager.cs +++ b/Robust.Shared/Upload/SharedNetworkResourceManager.cs @@ -1,8 +1,11 @@ using System; +using System.Collections.Generic; using Robust.Shared.ContentPack; using Robust.Shared.IoC; using Robust.Shared.Network; +using Robust.Shared.Replays; using Robust.Shared.Serialization; +using Robust.Shared.Serialization.Markdown.Mapping; using Robust.Shared.Utility; namespace Robust.Shared.Upload; @@ -14,6 +17,7 @@ namespace Robust.Shared.Upload; public abstract class SharedNetworkResourceManager : IDisposable { [Dependency] private readonly INetManager _netManager = default!; + [Dependency] private readonly IReplayRecordingManager _replay = default!; [Dependency] protected readonly IResourceManager ResourceManager = default!; public const double BytesToMegabytes = 0.000001d; @@ -34,9 +38,23 @@ public abstract class SharedNetworkResourceManager : IDisposable // Add our content root to the resource manager. ResourceManager.AddRoot(Prefix, ContentRoot); + _replay.RecordingStarted += OnStartReplayRecording; } - protected abstract void ResourceUploadMsg(NetworkResourceUploadMessage msg); + private void OnStartReplayRecording(MappingDataNode metadata, List events) + { + // replays will need information about currently loaded extra resources + foreach (var (path, data) in ContentRoot.GetAllFiles()) + { + events.Add(new ReplayResourceUploadMsg { RelativePath = path, Data = data }); + } + } + + protected virtual void ResourceUploadMsg(NetworkResourceUploadMessage msg) + { + ContentRoot.AddOrUpdateFile(msg.RelativePath, msg.Data); + _replay.RecordReplayMessage(new ReplayResourceUploadMsg { RelativePath = msg.RelativePath, Data = msg.Data }); + } public void Dispose() { diff --git a/Robust.Shared/Upload/SharedPrototypeLoadManager.cs b/Robust.Shared/Upload/SharedPrototypeLoadManager.cs new file mode 100644 index 000000000..25d652f3d --- /dev/null +++ b/Robust.Shared/Upload/SharedPrototypeLoadManager.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using Robust.Shared.IoC; +using Robust.Shared.Localization; +using Robust.Shared.Log; +using Robust.Shared.Network; +using Robust.Shared.Prototypes; +using Robust.Shared.Replays; +using Robust.Shared.Serialization.Markdown.Mapping; + +namespace Robust.Shared.Upload; + +/// +/// Manages sending runtime-loaded prototypes from game staff to clients. +/// +public abstract class SharedPrototypeLoadManager : IGamePrototypeLoadManager +{ + [Dependency] private readonly IReplayRecordingManager _replay = default!; + [Dependency] private readonly IPrototypeManager _prototypeManager = default!; + [Dependency] private readonly ILocalizationManager _localizationManager = default!; + [Dependency] protected readonly INetManager NetManager = default!; + + [Access(typeof(SharedPrototypeLoadManager))] + public readonly List LoadedPrototypes = new(); + + private ISawmill _sawmill = default!; + + public virtual void Initialize() + { + _replay.RecordingStarted += OnStartReplayRecording; + _sawmill = Logger.GetSawmill("adminbus"); + NetManager.RegisterNetMessage(LoadPrototypeData); + } + + public abstract void SendGamePrototype(string prototype); + + protected virtual void LoadPrototypeData(GamePrototypeLoadMessage message) + { + var data = message.PrototypeData; + LoadedPrototypes.Add(data); + _replay.RecordReplayMessage(new ReplayPrototypeUploadMsg { PrototypeData = data }); + + var changed = new Dictionary>(); + _prototypeManager.LoadString(data, true, changed); + _prototypeManager.ResolveResults(); + _prototypeManager.ReloadPrototypes(changed); + _localizationManager.ReloadLocalizations(); + _sawmill.Info("Loaded adminbus prototype data."); + } + + private void OnStartReplayRecording(MappingDataNode metadat, List events) + { + foreach (var prototype in LoadedPrototypes) + { + events.Add(new ReplayPrototypeUploadMsg { PrototypeData = prototype }); + } + } +} diff --git a/Robust.UnitTesting/Server/RobustServerSimulation.cs b/Robust.UnitTesting/Server/RobustServerSimulation.cs index 6c053c38f..f3272c982 100644 --- a/Robust.UnitTesting/Server/RobustServerSimulation.cs +++ b/Robust.UnitTesting/Server/RobustServerSimulation.cs @@ -244,7 +244,6 @@ namespace Robust.UnitTesting.Server container.RegisterInstance(new Mock().Object); container.RegisterInstance(new Mock().Object); container.RegisterInstance(new Mock().Object); - container.RegisterInstance(new Mock().Object); _diFactory?.Invoke(container); container.BuildGraph();