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