From b4358a9e33eb86f5f106a7c99d28f948e754b71c Mon Sep 17 00:00:00 2001
From: Leon Friedrich <60421075+ElectroJr@users.noreply.github.com>
Date: Sun, 21 Aug 2022 05:40:18 +1200
Subject: [PATCH] PVS & client state handling changes (#3000)
---
.../GameController.Standalone.cs | 3 +-
.../GameController/GameController.cs | 2 +-
.../GameObjects/ClientEntityManager.cs | 26 +-
.../Components/Renderable/SpriteComponent.cs | 4 +-
.../EntitySystems/AppearanceSystem.cs | 9 +-
Robust.Client/GameStates/ClientDirtySystem.cs | 7 +-
.../GameStates/ClientGameStateManager.cs | 784 +++++++++++-------
.../GameStates/GameStateProcessor.cs | 404 +++++----
.../GameStates/IClientGameStateManager.cs | 23 +-
.../GameStates/IGameStateProcessor.cs | 40 +-
Robust.Client/GameStates/NetEntityOverlay.cs | 253 +++---
Robust.Client/GameStates/NetGraphOverlay.cs | 106 ++-
.../Graphics/Overlays/IOverlayManager.cs | 4 +-
Robust.Client/Timing/ClientGameTiming.cs | 8 +
Robust.Client/Timing/IClientGameTiming.cs | 14 +
.../CustomControls/DebugMonitors.cs | 5 +-
.../CustomControls/DebugTimePanel.cs | 7 +-
.../UserInterface/UserInterfaceManager.cs | 3 +-
Robust.Server/BaseServer.cs | 3 -
.../GameStates/IServerGameStateManager.cs | 8 +
.../GameStates/PVSEntityVisiblity.cs | 2 +-
Robust.Server/GameStates/PVSSystem.cs | 425 +++++++---
.../GameStates/ServerGameStateManager.cs | 65 +-
Robust.Shared/CVars.cs | 14 +-
.../Collections/OverflowDictionary.cs | 32 +-
.../Configuration/NetConfigurationManager.cs | 8 +-
Robust.Shared/GameObjects/Component.cs | 11 +-
.../Components/MetaDataComponent.cs | 16 +-
.../GameObjects/EntityManager.Components.cs | 1 +
Robust.Shared/GameObjects/EntityManager.cs | 12 +-
Robust.Shared/GameObjects/EntityState.cs | 21 +-
.../SharedTransformSystem.Component.cs | 4 +-
Robust.Shared/GameStates/GameState.cs | 9 +-
Robust.Shared/Network/Messages/MsgState.cs | 19 +-
.../Network/Messages/MsgStateLeavePvs.cs | 47 ++
.../Network/Messages/MsgStateRequestFull.cs | 25 +
Robust.Shared/Physics/FixtureSystem.cs | 4 +-
Robust.Shared/Timing/GameLoop.cs | 16 +-
Robust.Shared/Timing/GameTiming.cs | 14 +-
Robust.Shared/Timing/IGameTiming.cs | 7 +-
.../GameStates/GameStateProcessor_Tests.cs | 139 +---
.../RobustIntegrationTest.NetManager.cs | 5 +
.../Shared/GameObjects/EntityState_Tests.cs | 4 +-
43 files changed, 1512 insertions(+), 1101 deletions(-)
create mode 100644 Robust.Shared/Network/Messages/MsgStateLeavePvs.cs
create mode 100644 Robust.Shared/Network/Messages/MsgStateRequestFull.cs
diff --git a/Robust.Client/GameController/GameController.Standalone.cs b/Robust.Client/GameController/GameController.Standalone.cs
index 49b5aff12..c5700bbcb 100644
--- a/Robust.Client/GameController/GameController.Standalone.cs
+++ b/Robust.Client/GameController/GameController.Standalone.cs
@@ -1,5 +1,6 @@
using System;
using System.Threading;
+using Robust.Client.Timing;
using Robust.LoaderApi;
using Robust.Shared;
using Robust.Shared.IoC;
@@ -13,7 +14,7 @@ namespace Robust.Client
{
private IGameLoop? _mainLoop;
- [Dependency] private readonly IGameTiming _gameTiming = default!;
+ [Dependency] private readonly IClientGameTiming _gameTiming = default!;
[Dependency] private readonly IDependencyCollection _dependencyCollection = default!;
private static bool _hasStarted;
diff --git a/Robust.Client/GameController/GameController.cs b/Robust.Client/GameController/GameController.cs
index 048e5ba68..8f6765b68 100644
--- a/Robust.Client/GameController/GameController.cs
+++ b/Robust.Client/GameController/GameController.cs
@@ -517,7 +517,7 @@ namespace Robust.Client
using (_prof.Group("Entity"))
{
// The last real tick is the current tick! This way we won't be in "prediction" mode.
- _gameTiming.LastRealTick = _gameTiming.CurTick;
+ _gameTiming.LastRealTick = _gameTiming.LastProcessedTick = _gameTiming.CurTick;
_entityManager.TickUpdate(frameEventArgs.DeltaSeconds, noPredictions: false);
}
}
diff --git a/Robust.Client/GameObjects/ClientEntityManager.cs b/Robust.Client/GameObjects/ClientEntityManager.cs
index e9d9e82b5..2a89301e8 100644
--- a/Robust.Client/GameObjects/ClientEntityManager.cs
+++ b/Robust.Client/GameObjects/ClientEntityManager.cs
@@ -1,13 +1,12 @@
using System;
using System.Collections.Generic;
using Prometheus;
-using Robust.Client.GameStates;
using Robust.Client.Player;
+using Robust.Client.Timing;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
using Robust.Shared.Network;
using Robust.Shared.Network.Messages;
-using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Robust.Client.GameObjects
@@ -19,8 +18,7 @@ namespace Robust.Client.GameObjects
{
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IClientNetManager _networkManager = default!;
- [Dependency] private readonly IClientGameStateManager _gameStateManager = default!;
- [Dependency] private readonly IGameTiming _gameTiming = default!;
+ [Dependency] private readonly IClientGameTiming _gameTiming = default!;
protected override int NextEntityUid { get; set; } = EntityUid.ClientUid + 1;
@@ -47,6 +45,22 @@ namespace Robust.Client.GameObjects
base.StartEntity(entity);
}
+ ///
+ public override void Dirty(EntityUid uid)
+ {
+ // Client only dirties during prediction
+ if (_gameTiming.InPrediction)
+ base.Dirty(uid);
+ }
+
+ ///
+ public override void Dirty(Component component)
+ {
+ // Client only dirties during prediction
+ if (_gameTiming.InPrediction)
+ base.Dirty(component);
+ }
+
#region IEntityNetworkManager impl
public override IEntityNetworkManager EntityNetManager => this;
@@ -67,7 +81,7 @@ namespace Robust.Client.GameObjects
{
using (histogram?.WithLabels("EntityNet").NewTimer())
{
- while (_queue.Count != 0 && _queue.Peek().msg.SourceTick <= _gameStateManager.CurServerTick)
+ while (_queue.Count != 0 && _queue.Peek().msg.SourceTick <= _gameTiming.LastRealTick)
{
var (_, msg) = _queue.Take();
// Logger.DebugS("net.ent", "Dispatching: {0}: {1}", seq, msg);
@@ -103,7 +117,7 @@ namespace Robust.Client.GameObjects
private void HandleEntityNetworkMessage(MsgEntity message)
{
- if (message.SourceTick <= _gameStateManager.CurServerTick)
+ if (message.SourceTick <= _gameTiming.LastRealTick)
{
DispatchMsgEntity(message);
return;
diff --git a/Robust.Client/GameObjects/Components/Renderable/SpriteComponent.cs b/Robust.Client/GameObjects/Components/Renderable/SpriteComponent.cs
index 92b6d6833..3f74b7aeb 100644
--- a/Robust.Client/GameObjects/Components/Renderable/SpriteComponent.cs
+++ b/Robust.Client/GameObjects/Components/Renderable/SpriteComponent.cs
@@ -1470,11 +1470,9 @@ namespace Robust.Client.GameObjects
public override void HandleComponentState(ComponentState? curState, ComponentState? nextState)
{
- if (curState == null)
+ if (curState is not SpriteComponentState thestate)
return;
- var thestate = (SpriteComponentState)curState;
-
Visible = thestate.Visible;
DrawDepth = thestate.DrawDepth;
scale = thestate.Scale;
diff --git a/Robust.Client/GameObjects/EntitySystems/AppearanceSystem.cs b/Robust.Client/GameObjects/EntitySystems/AppearanceSystem.cs
index 71fd4dfe4..33a6a720b 100644
--- a/Robust.Client/GameObjects/EntitySystems/AppearanceSystem.cs
+++ b/Robust.Client/GameObjects/EntitySystems/AppearanceSystem.cs
@@ -96,16 +96,21 @@ namespace Robust.Client.GameObjects
public override void FrameUpdate(float frameTime)
{
var spriteQuery = GetEntityQuery();
+ var metaQuery = GetEntityQuery();
while (_queuedUpdates.TryDequeue(out var appearance))
{
if (appearance.Deleted)
continue;
+ UnmarkDirty(appearance);
+
+ // If the entity is no longer within the clients PVS, don't bother updating.
+ if ((metaQuery.GetComponent(appearance.Owner).Flags & MetaDataFlags.Detached) != 0)
+ continue;
+
// 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);
- UnmarkDirty(appearance);
}
}
diff --git a/Robust.Client/GameStates/ClientDirtySystem.cs b/Robust.Client/GameStates/ClientDirtySystem.cs
index a05599762..1d5066beb 100644
--- a/Robust.Client/GameStates/ClientDirtySystem.cs
+++ b/Robust.Client/GameStates/ClientDirtySystem.cs
@@ -2,6 +2,7 @@ using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Microsoft.Extensions.ObjectPool;
+using Robust.Client.Timing;
using Robust.Shared.GameObjects;
using Robust.Shared.GameStates;
using Robust.Shared.IoC;
@@ -15,7 +16,7 @@ namespace Robust.Client.GameStates;
///
internal sealed class ClientDirtySystem : EntitySystem
{
- [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly IClientGameTiming _timing = default!;
private readonly Dictionary> _dirtyEntities = new();
@@ -49,14 +50,14 @@ internal sealed class ClientDirtySystem : EntitySystem
_dirtyEntities.Clear();
}
- public IEnumerable GetDirtyEntities(GameTick currentTick)
+ public IEnumerable GetDirtyEntities()
{
_dirty.Clear();
// This is just to avoid collection being modified during iteration unfortunately.
foreach (var (tick, dirty) in _dirtyEntities)
{
- if (tick < currentTick) continue;
+ if (tick < _timing.LastRealTick) continue;
foreach (var ent in dirty)
{
_dirty.Add(ent);
diff --git a/Robust.Client/GameStates/ClientGameStateManager.cs b/Robust.Client/GameStates/ClientGameStateManager.cs
index 407822f51..a82670ae6 100644
--- a/Robust.Client/GameStates/ClientGameStateManager.cs
+++ b/Robust.Client/GameStates/ClientGameStateManager.cs
@@ -2,7 +2,6 @@
// Used in EXCEPTION_TOLERANCE preprocessor
using System;
using System.Collections.Generic;
-using System.Diagnostics;
using System.Linq;
using JetBrains.Annotations;
using Robust.Client.GameObjects;
@@ -11,7 +10,9 @@ using Robust.Client.Player;
using Robust.Client.Timing;
using Robust.Shared;
using Robust.Shared.Configuration;
+#if EXCEPTION_TOLERANCE
using Robust.Shared.Exceptions;
+#endif
using Robust.Shared.GameObjects;
using Robust.Shared.GameStates;
using Robust.Shared.Input;
@@ -20,7 +21,6 @@ using Robust.Shared.Log;
using Robust.Shared.Map;
using Robust.Shared.Network;
using Robust.Shared.Network.Messages;
-using Robust.Shared.Players;
using Robust.Shared.Profiling;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
@@ -40,8 +40,6 @@ namespace Robust.Client.GameStates
_pendingSystemMessages
= new();
- private readonly Dictionary _hiddenEntities = new();
-
private uint _metaCompNetId;
[Dependency] private readonly IComponentFactory _compFactory = default!;
@@ -69,23 +67,28 @@ namespace Robust.Client.GameStates
public int TargetBufferSize => _processor.TargetBufferSize;
///
- public int CurrentBufferSize => _processor.CalculateBufferSize(CurServerTick);
+ public int CurrentBufferSize => _processor.CalculateBufferSize(_timing.LastRealTick);
public bool IsPredictionEnabled { get; private set; }
+ public bool PredictionNeedsResetting { get; private set; }
public int PredictTickBias { get; private set; }
public float PredictLagBias { get; private set; }
public int StateBufferMergeThreshold { get; private set; }
- private uint _lastProcessedSeq;
- private GameTick _lastProcessedTick = GameTick.Zero;
+ private uint _lastProcessedInput;
- public GameTick CurServerTick => _lastProcessedTick;
+ ///
+ /// Maximum number of entities that are sent to null-space each tick due to leaving PVS.
+ ///
+ private int _pvsDetachBudget;
///
public event Action? GameStateApplied;
+ public event Action? PvsLeave;
+
///
public void Initialize()
{
@@ -93,19 +96,22 @@ namespace Robust.Client.GameStates
_processor = new GameStateProcessor(_timing);
_network.RegisterNetMessage(HandleStateMessage);
+ _network.RegisterNetMessage(HandlePvsLeaveMessage);
_network.RegisterNetMessage();
+ _network.RegisterNetMessage();
_client.RunLevelChanged += RunLevelChanged;
_config.OnValueChanged(CVars.NetInterp, b => _processor.Interpolation = b, true);
- _config.OnValueChanged(CVars.NetInterpRatio, i => _processor.InterpRatio = i, true);
+ _config.OnValueChanged(CVars.NetBufferSize, i => _processor.BufferSize = i, true);
_config.OnValueChanged(CVars.NetLogging, b => _processor.Logging = b, true);
_config.OnValueChanged(CVars.NetPredict, b => IsPredictionEnabled = b, true);
_config.OnValueChanged(CVars.NetPredictTickBias, i => PredictTickBias = i, true);
_config.OnValueChanged(CVars.NetPredictLagBias, i => PredictLagBias = i, true);
_config.OnValueChanged(CVars.NetStateBufMergeThreshold, i => StateBufferMergeThreshold = i, true);
+ _config.OnValueChanged(CVars.NetPVSEntityExitBudget, i => _pvsDetachBudget = i, true);
_processor.Interpolation = _config.GetCVar(CVars.NetInterp);
- _processor.InterpRatio = _config.GetCVar(CVars.NetInterpRatio);
+ _processor.BufferSize = _config.GetCVar(CVars.NetBufferSize);
_processor.Logging = _config.GetCVar(CVars.NetLogging);
IsPredictionEnabled = _config.GetCVar(CVars.NetPredict);
PredictTickBias = _config.GetCVar(CVars.NetPredictTickBias);
@@ -122,9 +128,9 @@ namespace Robust.Client.GameStates
public void Reset()
{
_processor.Reset();
-
- _lastProcessedTick = GameTick.Zero;
- _lastProcessedSeq = 0;
+ _timing.CurTick = GameTick.Zero;
+ _timing.LastRealTick = GameTick.Zero;
+ _lastProcessedInput = 0;
}
private void RunLevelChanged(object? sender, RunLevelChangedEventArgs args)
@@ -170,19 +176,17 @@ namespace Robust.Client.GameStates
private void HandleStateMessage(MsgState message)
{
- var state = message.State;
+ // We ONLY ack states that are definitely going to get applied. Otherwise the sever might assume that we
+ // applied a state containing entity-creation information, which it would then no longer send to us when
+ // we re-encounter this entity
+ if (_processor.AddNewState(message.State))
+ AckGameState(message.State.ToSequence);
+ }
- // We temporarily change CurTick here so the GameStateProcessor gets the right values.
- var lastCurTick = _timing.CurTick;
- _timing.CurTick = _lastProcessedTick + 1;
-
- _processor.AddNewState(state);
-
- // we always ack everything we receive, even if it is late
- AckGameState(state.ToSequence);
-
- // And reset CurTick to what it was.
- _timing.CurTick = lastCurTick;
+ private void HandlePvsLeaveMessage(MsgStateLeavePvs message)
+ {
+ _processor.AddLeavePvsMessage(message);
+ PvsLeave?.Invoke(message);
}
///
@@ -190,17 +194,22 @@ namespace Robust.Client.GameStates
{
// Calculate how many states we need to apply this tick.
// Always at least one, but can be more based on StateBufferMergeThreshold.
- var curBufSize = _processor.CurrentBufferSize;
- var targetBufSize = _processor.TargetBufferSize;
- var applyCount = Math.Max(1, curBufSize - targetBufSize - StateBufferMergeThreshold);
+ var curBufSize = CurrentBufferSize;
+ var targetBufSize = TargetBufferSize;
- // Logger.Debug(applyCount.ToString());
+ var bufferOverflow = curBufSize - targetBufSize - StateBufferMergeThreshold;
+ var targetProccessedTick = (bufferOverflow > 1)
+ ? _timing.LastProcessedTick + (uint)bufferOverflow
+ : _timing.LastProcessedTick + 1;
+
+ _prof.WriteValue($"State buffer size", curBufSize);
+ _prof.WriteValue($"State apply count", targetProccessedTick.Value - _timing.LastProcessedTick.Value);
- var i = 0;
- for (; i < applyCount; i++)
+ bool processedAny = false;
+
+ _timing.LastProcessedTick = _timing.LastRealTick;
+ while (_timing.LastProcessedTick < targetProccessedTick)
{
- _timing.LastRealTick = _timing.CurTick = _lastProcessedTick + 1;
-
// TODO: We could theoretically communicate with the GameStateProcessor better here.
// Since game states are sliding windows, it is possible that we need less than applyCount applies here.
// Consider, if you have 3 states, (tFrom=1, tTo=2), (tFrom=1, tTo=3), (tFrom=2, tTo=3),
@@ -208,44 +217,75 @@ namespace Robust.Client.GameStates
// instead of all 3.
// This would be a nice optimization though also minor since the primary cost here
// is avoiding entity system and re-prediction runs.
- if (!_processor.ProcessTickStates(_timing.CurTick, out var curState, out var nextState))
+ //
+ // Note however that it is possible that some state (e.g. 1->2) contains information for entity creation
+ // for some entity that has left pvs by tick 3. Given that state 1->2 was acked, the server will not
+ // re-send that creation data later. So if we skip it and only apply tick 1->3, that will lead to a missing
+ // meta-data error. So while this can still be optimized, its probably not worth the headache.
+
+ if (!_processor.TryGetServerState(out var curState, out var nextState))
{
+ // Might just me missing a state, but we may be able to make use of a future state if it has a low enough from sequence.
break;
}
- // Logger.DebugS("net", $"{IGameTiming.TickStampStatic}: applying state from={curState.FromSequence} to={curState.ToSequence} ext={curState.Extrapolated}");
+ processedAny = true;
- // TODO: If Predicting gets disabled *while* the world state is dirty from a prediction,
- // this won't run meaning it could potentially get stuck dirty.
- if (IsPredictionEnabled && i == 0)
+ if (curState == null)
{
- // Disable IsFirstTimePredicted while re-running HandleComponentState here.
- // Helps with debugging.
- using var resetArea = _timing.StartPastPredictionArea();
- using var _ = _timing.StartStateApplicationArea();
-
- ResetPredictedEntities(_timing.CurTick);
-
- // I hate this..
- _entitySystemManager.GetEntitySystem().QueuedEvents.Clear();
+ _timing.LastProcessedTick += 1;
+ continue;
}
+ if (PredictionNeedsResetting)
+ ResetPredictedEntities();
+
+ // If we were waiting for a new state, we are now applying it.
+ if (_processor.LastFullStateRequested.HasValue)
+ {
+ _processor.LastFullStateRequested = null;
+ _timing.LastProcessedTick = curState.ToSequence;
+ }
+ else
+ _timing.LastProcessedTick += 1;
+
+ _timing.CurTick = _timing.LastRealTick = _timing.LastProcessedTick;
+
+ // Update the cached server state.
using (_prof.Group("FullRep"))
{
- if (!curState.Extrapolated)
- {
- _processor.UpdateFullRep(curState);
- }
+ _processor.UpdateFullRep(curState);
}
- // Store last tick we got from the GameStateProcessor.
- _lastProcessedTick = _timing.CurTick;
-
- // apply current state
- List createdEntities;
+ IEnumerable createdEntities;
using (_prof.Group("ApplyGameState"))
{
+ if (_timing.LastProcessedTick < targetProccessedTick && nextState != null)
+ {
+ // We are about to apply another state after this one anyways. So there is no need to pass in
+ // the next state for frame interpolation. Really, if we are applying 3 or more states, we
+ // should be checking the next-next state and so on.
+ //
+ // Basically: we only need to apply next-state for the last cur-state we are applying. but 99%
+ // of the time, we are only applying a single tick. But if we are applying more than one the
+ // client tends to stutter, so this sort of matters.
+ nextState = null;
+ }
+
+#if EXCEPTION_TOLERANCE
+ try
+ {
+#endif
createdEntities = ApplyGameState(curState, nextState);
+#if EXCEPTION_TOLERANCE
+ }
+ catch (Exception e)
+ {
+ // Something has gone wrong. Probably a missing meta-data component. Perhaps a full server state will fix it.
+ RequestFullState();
+ throw;
+ }
+#endif
}
using (_prof.Group("MergeImplicitData"))
@@ -253,23 +293,35 @@ namespace Robust.Client.GameStates
MergeImplicitData(createdEntities);
}
- if (_lastProcessedSeq < curState.LastProcessedInput)
+ if (_lastProcessedInput < curState.LastProcessedInput)
{
- _sawmill.Debug($"SV> RCV tick={_timing.CurTick}, seq={_lastProcessedSeq}");
- _lastProcessedSeq = curState.LastProcessedInput;
+ _sawmill.Debug($"SV> RCV tick={_timing.CurTick}, last processed ={_lastProcessedInput}");
+ _lastProcessedInput = curState.LastProcessedInput;
}
}
- if (i == 0)
+ // Slightly speed up or slow down the client tickrate based on the contents of the buffer.
+ // TryGetTickStates should have cleaned out any old states, so the buffer contains [t+0(cur), t+1(next), t+2, t+3, ..., t+n]
+ // we can use this info to properly time our tickrate so it does not run fast or slow compared to the server.
+ if (_processor.WaitingForFull)
+ _timing.TickTimingAdjustment = 0f;
+ else
+ _timing.TickTimingAdjustment = (CurrentBufferSize - (float)TargetBufferSize) * 0.10f;
+
+ // If we are about to process an another tick in the same frame, lets not bother unnecessarily running prediction ticks
+ // Really the main-loop ticking just needs to be more specialized for clients.
+ if (_timing.TickRemainder >= _timing.CalcAdjustedTickPeriod())
+ return;
+
+ if (!processedAny)
{
- // Didn't apply a single state successfully.
+ // Failed to process even a single tick. Chances are the tick buffer is empty, either because of
+ // networking issues or because the server is dead. This will functionally freeze the client-side simulation.
return;
}
- var input = _entitySystemManager.GetEntitySystem();
-
// remove old pending inputs
- while (_pendingInputs.Count > 0 && _pendingInputs.Peek().InputSequence <= _lastProcessedSeq)
+ while (_pendingInputs.Count > 0 && _pendingInputs.Peek().InputSequence <= _lastProcessedInput)
{
var inCmd = _pendingInputs.Dequeue();
@@ -277,85 +329,20 @@ namespace Robust.Client.GameStates
_sawmill.Debug($"SV> seq={inCmd.InputSequence}, func={boundFunc.FunctionName}, state={inCmd.State}");
}
- while (_pendingSystemMessages.Count > 0 && _pendingSystemMessages.Peek().sequence <= _lastProcessedSeq)
+ while (_pendingSystemMessages.Count > 0 && _pendingSystemMessages.Peek().sequence <= _lastProcessedInput)
{
_pendingSystemMessages.Dequeue();
}
DebugTools.Assert(_timing.InSimulation);
+ var ping = (_network.ServerChannel?.Ping ?? 0) / 1000f + PredictLagBias; // seconds.
+ var predictionTarget = _timing.LastProcessedTick + (uint) (_processor.TargetBufferSize + Math.Ceiling(_timing.TickRate * ping) + PredictTickBias);
+
if (IsPredictionEnabled)
{
- using var _p = _prof.Group("Prediction");
- using var _ = _timing.StartPastPredictionArea();
-
- if (_pendingInputs.Count > 0)
- {
- _sawmill.Debug("CL> Predicted:");
- }
-
- var pendingInputEnumerator = _pendingInputs.GetEnumerator();
- var pendingMessagesEnumerator = _pendingSystemMessages.GetEnumerator();
- var hasPendingInput = pendingInputEnumerator.MoveNext();
- var hasPendingMessage = pendingMessagesEnumerator.MoveNext();
-
- var ping = (_network.ServerChannel?.Ping ?? 0) / 1000f + PredictLagBias; // seconds.
- var targetTick = _timing.CurTick.Value + _processor.TargetBufferSize +
- (int) Math.Ceiling(_timing.TickRate * ping) + PredictTickBias;
-
- // Logger.DebugS("net.predict", $"Predicting from {_lastProcessedTick} to {targetTick}");
-
- for (var t = _lastProcessedTick.Value + 1; t <= targetTick; t++)
- {
- var groupStart = _prof.WriteGroupStart();
-
- var tick = new GameTick(t);
- _timing.CurTick = tick;
-
- while (hasPendingInput && pendingInputEnumerator.Current.Tick <= tick)
- {
- var inputCmd = pendingInputEnumerator.Current;
-
- _inputManager.NetworkBindMap.TryGetKeyFunction(inputCmd.InputFunctionId, out var boundFunc);
-
- _sawmill.Debug(
- $" seq={inputCmd.InputSequence}, sub={inputCmd.SubTick}, dTick={tick}, func={boundFunc.FunctionName}, " +
- $"state={inputCmd.State}");
-
-
- input.PredictInputCommand(inputCmd);
-
- hasPendingInput = pendingInputEnumerator.MoveNext();
- }
-
- while (hasPendingMessage && pendingMessagesEnumerator.Current.sourceTick <= tick)
- {
- var msg = pendingMessagesEnumerator.Current.msg;
-
- _entities.EventBus.RaiseEvent(EventSource.Local, msg);
- _entities.EventBus.RaiseEvent(EventSource.Local, pendingMessagesEnumerator.Current.sessionMsg);
-
- hasPendingMessage = pendingMessagesEnumerator.MoveNext();
- }
-
- if (t != targetTick)
- {
- using (_prof.Group("Systems"))
- {
- // Don't run EntitySystemManager.TickUpdate if this is the target tick,
- // because the rest of the main loop will call into it with the target tick later,
- // and it won't be a past prediction.
- _entitySystemManager.TickUpdate((float) _timing.TickPeriod.TotalSeconds, noPredictions: false);
- }
-
- using (_prof.Group("Event queue"))
- {
- ((IBroadcastEventBusInternal) _entities.EventBus).ProcessEventQueue();
- }
- }
-
- _prof.WriteGroupEnd(groupStart, "Prediction tick", ProfData.Int64(t));
- }
+ PredictionNeedsResetting = true;
+ PredictTicks(predictionTarget);
}
using (_prof.Group("Tick"))
@@ -364,15 +351,93 @@ namespace Robust.Client.GameStates
}
}
- private void ResetPredictedEntities(GameTick curTick)
+ public void RequestFullState()
{
+ Logger.Info("Requesting full server state");
+ _network.ClientSendMessage(new MsgStateRequestFull() { Tick = _timing.LastRealTick });
+ _processor.RequestFullState();
+ }
+
+ public void PredictTicks(GameTick predictionTarget)
+ {
+ using var _p = _prof.Group("Prediction");
+ using var _ = _timing.StartPastPredictionArea();
+
+ if (_pendingInputs.Count > 0)
+ {
+ _sawmill.Debug("CL> Predicted:");
+ }
+
+ var input = _entitySystemManager.GetEntitySystem();
+ var pendingInputEnumerator = _pendingInputs.GetEnumerator();
+ var pendingMessagesEnumerator = _pendingSystemMessages.GetEnumerator();
+ var hasPendingInput = pendingInputEnumerator.MoveNext();
+ var hasPendingMessage = pendingMessagesEnumerator.MoveNext();
+
+ while (_timing.CurTick < predictionTarget)
+ {
+ _timing.CurTick += 1;
+ var groupStart = _prof.WriteGroupStart();
+
+ while (hasPendingInput && pendingInputEnumerator.Current.Tick <= _timing.CurTick)
+ {
+ var inputCmd = pendingInputEnumerator.Current;
+
+ _inputManager.NetworkBindMap.TryGetKeyFunction(inputCmd.InputFunctionId, out var boundFunc);
+
+ _sawmill.Debug(
+ $" seq={inputCmd.InputSequence}, sub={inputCmd.SubTick}, dTick={_timing.CurTick}, func={boundFunc.FunctionName}, " +
+ $"state={inputCmd.State}");
+
+ input.PredictInputCommand(inputCmd);
+ hasPendingInput = pendingInputEnumerator.MoveNext();
+ }
+
+ while (hasPendingMessage && pendingMessagesEnumerator.Current.sourceTick <= _timing.CurTick)
+ {
+ var msg = pendingMessagesEnumerator.Current.msg;
+
+ _entities.EventBus.RaiseEvent(EventSource.Local, msg);
+ _entities.EventBus.RaiseEvent(EventSource.Local, pendingMessagesEnumerator.Current.sessionMsg);
+ hasPendingMessage = pendingMessagesEnumerator.MoveNext();
+ }
+
+ if (_timing.CurTick != predictionTarget)
+ {
+ using (_prof.Group("Systems"))
+ {
+ // Don't run EntitySystemManager.TickUpdate if this is the target tick,
+ // because the rest of the main loop will call into it with the target tick later,
+ // and it won't be a past prediction.
+ _entitySystemManager.TickUpdate((float)_timing.TickPeriod.TotalSeconds, noPredictions: false);
+ }
+
+ using (_prof.Group("Event queue"))
+ {
+ ((IBroadcastEventBusInternal)_entities.EventBus).ProcessEventQueue();
+ }
+ }
+
+ _prof.WriteGroupEnd(groupStart, "Prediction tick", ProfData.Int64(_timing.CurTick.Value));
+ }
+ }
+
+ private void ResetPredictedEntities()
+ {
+ PredictionNeedsResetting = false;
+
using var _ = _prof.Group("ResetPredictedEntities");
+ using var __ = _timing.StartPastPredictionArea();
+ using var ___ = _timing.StartStateApplicationArea();
var countReset = 0;
var system = _entitySystemManager.GetEntitySystem();
var query = _entityManager.GetEntityQuery();
- foreach (var entity in system.GetDirtyEntities(curTick))
+ // This is terrible, and I hate it.
+ _entitySystemManager.GetEntitySystem().QueuedEvents.Clear();
+
+ foreach (var entity in system.GetDirtyEntities())
{
// Check log level first to avoid the string alloc.
if (_sawmill.Level <= LogLevel.Debug)
@@ -391,7 +456,7 @@ namespace Robust.Client.GameStates
{
DebugTools.AssertNotNull(netId);
- if (comp.LastModifiedTick < curTick || !last.TryGetValue(netId, out var compState))
+ if (comp.LastModifiedTick <= _timing.LastRealTick || !last.TryGetValue(netId, out var compState))
{
continue;
}
@@ -403,9 +468,11 @@ namespace Robust.Client.GameStates
var handleState = new ComponentHandleState(compState, null);
_entities.EventBus.RaiseComponentEvent(comp, ref handleState);
comp.HandleComponentState(compState, null);
- comp.LastModifiedTick = curTick;
+ comp.LastModifiedTick = _timing.LastRealTick;
}
- query.GetComponent(entity).EntityLastModifiedTick = curTick;
+ var meta = query.GetComponent(entity);
+ DebugTools.Assert(meta.LastModifiedTick > _timing.LastRealTick || meta.LastModifiedTick == GameTick.Zero);
+ meta.EntityLastModifiedTick = _timing.LastRealTick;
}
system.Reset();
@@ -413,31 +480,29 @@ namespace Robust.Client.GameStates
_prof.WriteValue("Reset count", ProfData.Int32(countReset));
}
- private void MergeImplicitData(List createdEntities)
+ ///
+ /// Infer implicit state data for newly created entities.
+ ///
+ ///
+ /// Whenever a new entity is created, the server doesn't send full state data, given that much of the data
+ /// can simply be obtained from the entity prototype information. This function basically creates a fake
+ /// initial server state for any newly created entity. It does this by simply using the standard .
+ ///
+ private void MergeImplicitData(IEnumerable createdEntities)
{
- // The server doesn't send data that the server can replicate itself on entity creation.
- // As such, GameStateProcessor doesn't have that data either.
- // We have to feed it back this data by calling GetComponentState() and such,
- // so that we can later roll back to it (if necessary).
- var outputData = new Dictionary>();
-
- Debug.Assert(_players.LocalPlayer != null, "_players.LocalPlayer != null");
-
+ var outputData = new Dictionary>();
var bus = _entityManager.EventBus;
foreach (var createdEntity in createdEntities)
{
- var compData = new Dictionary();
+ var compData = new Dictionary();
outputData.Add(createdEntity, compData);
foreach (var (netId, component) in _entityManager.GetNetComponents(createdEntity))
{
- var state = _entityManager.GetComponentState(bus, component);
-
- if(state.GetType() == typeof(ComponentState))
- continue;
-
- compData.Add(netId, state);
+ if (component.NetSyncEnabled)
+ compData.Add(netId, _entityManager.GetComponentState(bus, component));
}
}
@@ -446,12 +511,10 @@ namespace Robust.Client.GameStates
private void AckGameState(GameTick sequence)
{
- var msg = new MsgStateAck();
- msg.Sequence = sequence;
- _network.ClientSendMessage(msg);
+ _network.ClientSendMessage(new MsgStateAck() { Sequence = sequence });
}
- private List ApplyGameState(GameState curState, GameState? nextState)
+ private IEnumerable ApplyGameState(GameState curState, GameState? nextState)
{
using var _ = _timing.StartStateApplicationArea();
@@ -465,11 +528,10 @@ namespace Robust.Client.GameStates
_mapManager.ApplyGameStatePre(curState.MapData, curState.EntityStates.Span);
}
- List createdEntities;
+ (IEnumerable Created, List Detached) output;
using (_prof.Group("Entity"))
{
- createdEntities = ApplyEntityStates(curState.EntityStates.Span, curState.EntityDeletions.Span,
- nextState != null ? nextState.EntityStates.Span : default);
+ output = ApplyEntityStates(curState, nextState);
}
using (_prof.Group("Player"))
@@ -479,114 +541,245 @@ namespace Robust.Client.GameStates
using (_prof.Group("Callback"))
{
- GameStateApplied?.Invoke(new GameStateAppliedArgs(curState));
+ GameStateApplied?.Invoke(new GameStateAppliedArgs(curState, output.Detached));
}
- return createdEntities;
+ return output.Created;
}
- private List ApplyEntityStates(ReadOnlySpan curEntStates, ReadOnlySpan deletions,
- ReadOnlySpan nextEntStates)
+ private (IEnumerable Created, List Detached) ApplyEntityStates(GameState curState, GameState? nextState)
{
- var toApply = new Dictionary(curEntStates.Length);
- var toInitialize = new List();
- var created = new List();
+ var metas = _entities.GetEntityQuery();
+ var xforms = _entities.GetEntityQuery();
- foreach (var es in curEntStates)
+ var toApply = new Dictionary();
+ var toCreate = new Dictionary();
+ var enteringPvs = 0;
+
+ var curSpan = curState.EntityStates.Span;
+ foreach (var es in curSpan)
{
- var uid = es.Uid;
- //Known entities
- if (_entities.EntityExists(uid))
+ if (!metas.TryGetComponent(es.Uid, out var meta))
{
- // Logger.Debug($"[{IGameTiming.TickStampStatic}] MOD {es.Uid}");
- toApply.Add(uid, (es, null));
+ toCreate.Add(es.Uid, es);
+ continue;
}
- else //Unknown entities
+
+ bool isEnteringPvs = (meta.Flags & MetaDataFlags.Detached) != 0;
+ if (isEnteringPvs)
{
- var metaState = (MetaDataComponentState?) es.ComponentChanges.Value?.FirstOrDefault(c => c.NetID == _metaCompNetId).State;
+ meta.Flags &= ~MetaDataFlags.Detached;
+ enteringPvs++;
+ }
+ else if (meta.LastStateApplied >= es.EntityLastModified && meta.LastStateApplied != GameTick.Zero)
+ {
+ meta.LastStateApplied = curState.ToSequence;
+ continue;
+ }
+
+ toApply.Add(es.Uid, (isEnteringPvs, meta.LastStateApplied, es, null));
+ meta.LastStateApplied = curState.ToSequence;
+ }
+
+ // Create new entities
+ if (toCreate.Count > 0)
+ {
+ using var _ = _prof.Group("Create uninitialized entities");
+ _prof.WriteValue("Count", ProfData.Int32(toCreate.Count));
+
+ foreach (var (uid, es) in toCreate)
+ {
+ var metaState = (MetaDataComponentState?)es.ComponentChanges.Value?.FirstOrDefault(c => c.NetID == _metaCompNetId).State;
if (metaState == null)
- {
throw new InvalidOperationException($"Server sent new entity state for {uid} without metadata component!");
- }
- // Logger.Debug($"[{IGameTiming.TickStampStatic}] CREATE {es.Uid} {metaState.PrototypeId}");
- var newEntity = _entities.CreateEntity(metaState.PrototypeId, uid);
- toApply.Add(newEntity, (es, null));
- toInitialize.Add(newEntity);
- created.Add(newEntity);
+
+ _entities.CreateEntity(metaState.PrototypeId, uid);
+ toApply.Add(uid, (false, GameTick.Zero, es, null));
+
+ var newMeta = metas.GetComponent(uid);
+ newMeta.LastStateApplied = curState.ToSequence;
}
}
- foreach (var es in nextEntStates)
- {
- var uid = es.Uid;
+ // Detatch entities to null space
+ var xformSys = _entitySystemManager.GetEntitySystem();
+ var detached = ProcessPvsDeparture(curState.ToSequence, metas, xforms, xformSys);
- if (_entities.EntityExists(uid))
+ // Check next state (AFTER having created new entities introduced in curstate)
+ if (nextState != null)
+ {
+ foreach (var es in nextState.EntityStates.Span)
{
+ var uid = es.Uid;
+
+ if (!metas.TryGetComponent(uid, out var meta))
+ continue;
+
+ // Does the next state actually have any future information about this entity that could be used for interpolation?
+ if (es.EntityLastModified != nextState.ToSequence)
+ continue;
+
if (toApply.TryGetValue(uid, out var state))
- {
- toApply[uid] = (state.Item1, es);
- }
+ toApply[uid] = (state.EnteringPvs, state.LastApplied, state.curState, es);
else
- {
- toApply[uid] = (null, es);
- }
+ toApply[uid] = (false, GameTick.Zero, null, es);
}
}
- // Make sure this is done after all entities have been instantiated.
- foreach (var kvStates in toApply)
+ // Apply entity states.
+ using (_prof.Group("Apply States"))
{
- var ent = kvStates.Key;
- var entity = ent;
- HandleEntityState(entity, _entities.EventBus, kvStates.Value.Item1,
- kvStates.Value.Item2);
+ foreach (var (entity, data) in toApply)
+ {
+ HandleEntityState(entity, _entities.EventBus, data.curState,
+ data.nextState, data.LastApplied, curState.ToSequence, data.EnteringPvs);
+ }
+ _prof.WriteValue("Count", ProfData.Int32(toApply.Count));
}
- foreach (var id in deletions)
+ var delSpan = curState.EntityDeletions.Span;
+ if (delSpan.Length > 0)
+ ProcessDeletions(delSpan, xforms, metas, xformSys);
+
+ // Initialize and start the newly created entities.
+ if (toCreate.Count > 0)
+ InitializeAndStart(toCreate);
+
+ _prof.WriteValue("State Size", ProfData.Int32(curSpan.Length));
+ _prof.WriteValue("Entered PVS", ProfData.Int32(enteringPvs));
+
+ return (toCreate.Keys, detached);
+ }
+
+ private void ProcessDeletions(
+ ReadOnlySpan delSpan,
+ EntityQuery xforms,
+ EntityQuery metas,
+ SharedTransformSystem xformSys)
+ {
+ // Processing deletions is non-trivial, because by default deletions will also delete all child entities.
+ //
+ // Naively: easy, just apply server states to process any transform states before deleting, right? But now
+ // that PVS detach messages are sent separately & processed over time, the entity may have left our view,
+ // but not yet been moved to null-space. In that case, the server would not send us transform states, and
+ // deleting an entity could falsely delete the children as well. Therefore, before deleting we must detach
+ // all children to null. This also gets called WHILE deleting, but we need to do it beforehand. Given that
+ // they are either also about to get deleted, or about to be send to out-of-pvs null-space, this shouldn't
+ // be a significant performance impact.
+
+ using var _ = _prof.Group("Deletion");
+
+ foreach (var id in delSpan)
{
- // Logger.Debug($"[{IGameTiming.TickStampStatic}] DELETE {id}");
+ if (!xforms.TryGetComponent(id, out var xform))
+ continue; // Already deleted? or never sent to us?
+
+ // First, a single recursive map change
+ xformSys.DetachParentToNull(xform, xforms, metas);
+
+ // Then detach all children.
+ var childEnumerator = xform.ChildEnumerator;
+ while (childEnumerator.MoveNext(out var child))
+ {
+ xformSys.DetachParentToNull(xforms.GetComponent(child.Value), xforms, metas, xform);
+ }
+
+ // Finally, delete the entity.
_entities.DeleteEntity(id);
}
+ _prof.WriteValue("Count", ProfData.Int32(delSpan.Length));
+ }
+ private List ProcessPvsDeparture(GameTick toTick, EntityQuery metas, EntityQuery xforms, SharedTransformSystem xformSys)
+ {
+ var toDetach = _processor.GetEntitiesToDetach(toTick, _pvsDetachBudget);
+ var detached = new List();
+
+ if (toDetach.Count == 0)
+ return detached;
+
+ // TODO optimize
+ // If an entity is leaving PVS, so are all of its children. If we can preserve the hierarchy we can avoid
+ // things like container insertion and ejection.
+
+ using var _ = _prof.Group("Leave PVS");
+
+ 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.Flags |= MetaDataFlags.Detached;
+ meta.LastStateApplied = toTick;
+
+ var xform = xforms.GetComponent(ent);
+ if (xform.ParentUid.IsValid())
+ xformSys.DetachParentToNull(xform, xforms, metas);
+ detached.Add(ent);
+ }
+ }
+
+ _prof.WriteValue("Count", ProfData.Int32(detached.Count));
+ return detached;
+ }
+
+ private void InitializeAndStart(Dictionary toCreate)
+ {
#if EXCEPTION_TOLERANCE
HashSet brokenEnts = new HashSet();
#endif
-
- foreach (var entity in toInitialize)
+ using (_prof.Group("Initialize Entity"))
{
-#if EXCEPTION_TOLERANCE
- try
+ foreach (var entity in toCreate.Keys)
{
+#if EXCEPTION_TOLERANCE
+ try
+ {
#endif
_entities.InitializeEntity(entity);
#if EXCEPTION_TOLERANCE
- }
- catch (Exception e)
- {
- Logger.ErrorS("state", $"Server entity threw in Init: ent={_entityManager.ToPrettyString(entity)}\n{e}");
- brokenEnts.Add(entity);
- }
+ }
+ catch (Exception e)
+ {
+ Logger.ErrorS("state", $"Server entity threw in Init: ent={_entityManager.ToPrettyString(entity)}\n{e}");
+ brokenEnts.Add(entity);
+ toCreate.Remove(entity);
+ }
#endif
+ }
}
- foreach (var entity in toInitialize)
+ using (_prof.Group("Start Entity"))
{
-#if EXCEPTION_TOLERANCE
- if (brokenEnts.Contains(entity))
- continue;
-
- try
+ foreach (var entity in toCreate.Keys)
{
+#if EXCEPTION_TOLERANCE
+ try
+ {
#endif
_entities.StartEntity(entity);
#if EXCEPTION_TOLERANCE
- }
- catch (Exception e)
- {
- Logger.ErrorS("state", $"Server entity threw in Start: ent={_entityManager.ToPrettyString(entity)}\n{e}");
- brokenEnts.Add(entity);
- }
+ }
+ catch (Exception e)
+ {
+ Logger.ErrorS("state", $"Server entity threw in Start: ent={_entityManager.ToPrettyString(entity)}\n{e}");
+ brokenEnts.Add(entity);
+ toCreate.Remove(entity);
+ }
#endif
+ }
}
#if EXCEPTION_TOLERANCE
@@ -595,105 +788,96 @@ namespace Robust.Client.GameStates
_entityManager.DeleteEntity(entity);
}
#endif
-
- _prof.WriteValue("Created", ProfData.Int32(created.Count));
- _prof.WriteValue("Applied", ProfData.Int32(toApply.Count));
-
- return created;
}
- private void HandleEntityState(EntityUid entity, IEventBus bus, EntityState? curState,
- EntityState? nextState)
+ private void HandleEntityState(EntityUid uid, IEventBus bus, EntityState? curState,
+ EntityState? nextState, GameTick lastApplied, GameTick toTick, bool enteringPvs)
{
- var compStateWork = new Dictionary();
- var entityUid = entity;
+ var size = curState?.ComponentChanges.Span.Length ?? 0 + nextState?.ComponentChanges.Span.Length ?? 0;
+ var compStateWork = new Dictionary(size);
- if (curState != null)
+ if (enteringPvs)
{
- compStateWork.EnsureCapacity(curState.ComponentChanges.Span.Length);
+ // last-server state has already been updated with new information from curState
+ // --> simply reset to the most recent server state.
+ //
+ // as to why we need to reset: because in the process of detaching to null-space, we will have dirtied
+ // the entity. most notably, all entities will have been ejected from their containers.
+ foreach (var (id, state) in _processor.GetLastServerStates(uid))
+ {
+ if (!_entityManager.TryGetComponent(uid, id, out var comp))
+ {
+ comp = _compFactory.GetComponent(id);
+ var newComp = (Component)comp;
+ newComp.Owner = uid;
+ _entityManager.AddComponent(uid, newComp, true);
+ }
+ compStateWork[id] = (comp, state, null);
+ }
+ }
+ else if (curState != null)
+ {
foreach (var compChange in curState.ComponentChanges.Span)
{
if (compChange.Deleted)
{
- if (_entityManager.TryGetComponent(entityUid, compChange.NetID, out var comp))
- {
- _entityManager.RemoveComponent(entityUid, comp);
- }
+ _entityManager.RemoveComponent(uid, compChange.NetID);
+ continue;
}
- else
+
+ if (!_entityManager.TryGetComponent(uid, compChange.NetID, out var comp))
{
- //Right now we just assume every state from an unseen entity is added
-
- if (_entityManager.HasComponent(entityUid, compChange.NetID))
- continue;
-
- var newComp = (Component) _compFactory.GetComponent(compChange.NetID);
- newComp.Owner = entity;
- _entityManager.AddComponent(entity, newComp, true);
-
- compStateWork[compChange.NetID] = (compChange.State, null);
+ comp = _compFactory.GetComponent(compChange.NetID);
+ var newComp = (Component)comp;
+ newComp.Owner = uid;
+ _entityManager.AddComponent(uid, newComp, true);
}
- }
+ else if (compChange.LastModifiedTick <= lastApplied && lastApplied != GameTick.Zero)
+ continue;
- foreach (var compChange in curState.ComponentChanges.Span)
- {
- compStateWork[compChange.NetID] = (compChange.State, null);
+ compStateWork[compChange.NetID] = (comp, compChange.State, null);
}
}
if (nextState != null)
{
- compStateWork.EnsureCapacity(compStateWork.Count + nextState.ComponentChanges.Span.Length);
-
foreach (var compState in nextState.ComponentChanges.Span)
{
- if (compStateWork.TryGetValue(compState.NetID, out var state))
- {
- compStateWork[compState.NetID] = (state.curState, compState.State);
- }
- else
- {
- compStateWork[compState.NetID] = (null, compState.State);
- }
- }
- }
+ if (compState.LastModifiedTick != toTick)
+ continue;
- foreach (var (netId, (cur, next)) in compStateWork)
- {
- if (_entityManager.TryGetComponent(entityUid, netId, out var component))
- {
- try
- {
- var handleState = new ComponentHandleState(cur, next);
- bus.RaiseComponentEvent(component, ref handleState);
- component.HandleComponentState(cur, next);
- }
- catch (Exception e)
- {
- var wrapper = new ComponentStateApplyException(
- $"Failed to apply comp state: entity={component.Owner}, comp={component.GetType()}", e);
-#if EXCEPTION_TOLERANCE
- _runtimeLog.LogException(wrapper, "Component state apply");
-#else
- throw wrapper;
-#endif
- }
- }
- else
- {
- // The component can be null here due to interp.
- // Because the NEXT state will have a new component, but this one doesn't yet.
- // That's fine though.
- if (cur == null)
+ if (!_entityManager.TryGetComponent(uid, compState.NetID, out var comp))
{
+ // The component can be null here due to interp, because the NEXT state will have a new
+ // component, but the component does not yet exist.
continue;
}
- var eUid = entityUid;
- var eRegisteredNetUidName = _compFactory.GetRegistration(netId).Name;
- DebugTools.Assert(
- $"Component does not exist for state: entUid={eUid}, expectedNetId={netId}, expectedName={eRegisteredNetUidName}");
+ if (compStateWork.TryGetValue(compState.NetID, out var state))
+ compStateWork[compState.NetID] = (comp, state.curState, compState.State);
+ else
+ compStateWork[compState.NetID] = (comp, null, compState.State);
+ }
+ }
+
+ foreach (var (comp, cur, next) in compStateWork.Values)
+ {
+ try
+ {
+ var handleState = new ComponentHandleState(cur, next);
+ bus.RaiseComponentEvent(comp, ref handleState);
+ comp.HandleComponentState(cur, next);
+ }
+ catch (Exception e)
+ {
+#if EXCEPTION_TOLERANCE
+ _runtimeLog.LogException(new ComponentStateApplyException(
+ $"Failed to apply comp state: entity={comp.Owner}, comp={comp.GetType()}", e), "Component state apply");
+#else
+ Logger.Error($"Failed to apply comp state: entity={comp.Owner}, comp={comp.GetType()}");
+ throw;
+#endif
}
}
}
@@ -702,10 +886,12 @@ namespace Robust.Client.GameStates
public sealed class GameStateAppliedArgs : EventArgs
{
public GameState AppliedState { get; }
+ public readonly List Detached;
- public GameStateAppliedArgs(GameState appliedState)
+ public GameStateAppliedArgs(GameState appliedState, List detached)
{
AppliedState = appliedState;
+ Detached = detached;
}
}
}
diff --git a/Robust.Client/GameStates/GameStateProcessor.cs b/Robust.Client/GameStates/GameStateProcessor.cs
index 08f36367f..d8c6ed3b8 100644
--- a/Robust.Client/GameStates/GameStateProcessor.cs
+++ b/Robust.Client/GameStates/GameStateProcessor.cs
@@ -1,9 +1,11 @@
+using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
-using System.Linq;
+using Robust.Client.Timing;
using Robust.Shared.GameObjects;
using Robust.Shared.GameStates;
using Robust.Shared.Log;
+using Robust.Shared.Network.Messages;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
@@ -12,154 +14,144 @@ namespace Robust.Client.GameStates
///
internal sealed class GameStateProcessor : IGameStateProcessor
{
- private readonly IGameTiming _timing;
+ private readonly IClientGameTiming _timing;
private readonly List _stateBuffer = new();
- private GameState? _lastFullState;
- private bool _waitingForFull = true;
- private int _interpRatio;
- private GameTick _highestFromSequence;
- private readonly Dictionary> _lastStateFullRep
+ private readonly Dictionary> _pvsDetachMessages = new();
+
+ public GameState? LastFullState { get; private set; }
+ public bool WaitingForFull => LastFullStateRequested.HasValue;
+ public GameTick? LastFullStateRequested
+ {
+ get => _lastFullStateRequested;
+ set
+ {
+ _lastFullStateRequested = value;
+ LastFullState = null;
+ }
+ }
+
+ public GameTick? _lastFullStateRequested = GameTick.Zero;
+
+ private int _bufferSize;
+
+ ///
+ /// This dictionary stores the full most recently received server state of any entity. This is used whenever predicted entities get reset.
+ ///
+ private readonly Dictionary> _lastStateFullRep
= new();
///
- public int MinBufferSize => Interpolation ? 3 : 2;
+ public int MinBufferSize => Interpolation ? 2 : 1;
///
- public int TargetBufferSize => MinBufferSize + InterpRatio;
-
- ///
- public int CurrentBufferSize => CalculateBufferSize(_timing.CurTick);
+ public int TargetBufferSize => MinBufferSize + BufferSize;
///
public bool Interpolation { get; set; }
///
- public int InterpRatio
+ public int BufferSize
{
- get => _interpRatio;
- set => _interpRatio = value < 0 ? 0 : value;
+ get => _bufferSize;
+ set => _bufferSize = value < 0 ? 0 : value;
}
- ///
- public bool Extrapolation { get; set; }
-
///
public bool Logging { get; set; }
- public GameTick LastProcessedRealState { get; set; }
-
///
/// Constructs a new instance of .
///
/// Timing information of the current state.
- public GameStateProcessor(IGameTiming timing)
+ public GameStateProcessor(IClientGameTiming timing)
{
_timing = timing;
}
///
- public void AddNewState(GameState state)
+ public bool AddNewState(GameState state)
{
- // any state from tick 0 is a full state, and needs to be handled different
- if (state.FromSequence == GameTick.Zero)
- {
- // this is a newer full state, so discard the older one.
- if (_lastFullState == null || (_lastFullState != null && _lastFullState.ToSequence < state.ToSequence))
- {
- _lastFullState = state;
-
- if (Logging)
- Logger.InfoS("net", $"Received Full GameState: to={state.ToSequence}, sz={state.PayloadSize}");
-
- return;
- }
- }
-
- // NOTE: DispatchTick will be modifying CurTick, this is NOT thread safe.
- var lastTick = new GameTick(_timing.CurTick.Value - 1);
-
- if (state.ToSequence <= lastTick && !_waitingForFull) // CurTick isn't set properly when WaitingForFull
+ // Check for old states.
+ if (state.ToSequence <= _timing.LastRealTick)
{
if (Logging)
- Logger.DebugS("net.state", $"Received Old GameState: cTick={_timing.CurTick}, fSeq={state.FromSequence}, tSeq={state.ToSequence}, sz={state.PayloadSize}, buf={_stateBuffer.Count}");
+ Logger.DebugS("net.state", $"Received Old GameState: lastRealTick={_timing.LastRealTick}, fSeq={state.FromSequence}, tSeq={state.ToSequence}, sz={state.PayloadSize}, buf={_stateBuffer.Count}");
- return;
+ return false;
}
- // lets check for a duplicate state now.
- for (var i = 0; i < _stateBuffer.Count; i++)
+ // Check for a duplicate states.
+ foreach (var bufferState in _stateBuffer)
{
- var iState = _stateBuffer[i];
-
- if (state.ToSequence != iState.ToSequence)
+ if (state.ToSequence != bufferState.ToSequence)
continue;
if (Logging)
- Logger.DebugS("net.state", $"Received Dupe GameState: cTick={_timing.CurTick}, fSeq={state.FromSequence}, tSeq={state.ToSequence}, sz={state.PayloadSize}, buf={_stateBuffer.Count}");
+ Logger.DebugS("net.state", $"Received Dupe GameState: lastRealTick={_timing.LastRealTick}, fSeq={state.FromSequence}, tSeq={state.ToSequence}, sz={state.PayloadSize}, buf={_stateBuffer.Count}");
- return;
+ return false;
+ }
+
+ // Are we expecting a full state?
+ if (!WaitingForFull)
+ {
+ // This is a good state that we will be using.
+ _stateBuffer.Add(state);
+ if (Logging)
+ Logger.DebugS("net.state", $"Received New GameState: lastRealTick={_timing.LastRealTick}, fSeq={state.FromSequence}, tSeq={state.ToSequence}, sz={state.PayloadSize}, buf={_stateBuffer.Count}");
+ return true;
}
- // this is a good state that we will be using.
- _stateBuffer.Add(state);
+ if (LastFullState == null && state.FromSequence == GameTick.Zero && state.ToSequence >= LastFullStateRequested!.Value)
+ {
+ LastFullState = state;
- if (Logging)
- Logger.DebugS("net.state", $"Received New GameState: cTick={_timing.CurTick}, fSeq={state.FromSequence}, tSeq={state.ToSequence}, sz={state.PayloadSize}, buf={_stateBuffer.Count}");
+ if (Logging)
+ Logger.InfoS("net", $"Received Full GameState: to={state.ToSequence}, sz={state.PayloadSize}");
+
+ return true;
+ }
+
+ if (LastFullState == null || state.ToSequence <= LastFullState.ToSequence)
+ return false;
+
+ _stateBuffer.Add(state);
+ return true;
}
- ///
- public bool ProcessTickStates(GameTick curTick, [NotNullWhen(true)] out GameState? curState, out GameState? nextState)
+ ///
+ /// Attempts to get the current and next states to apply.
+ ///
+ ///
+ /// If the processor is not currently waiting for a full state, the states to apply depends on .
+ ///
+ /// Returns true if the states should be applied.
+ public bool TryGetServerState([NotNullWhen(true)] out GameState? curState, out GameState? nextState)
{
- bool applyNextState;
- if (_waitingForFull)
- {
- applyNextState = CalculateFullState(out curState, out nextState, TargetBufferSize);
- }
- else // this will be how almost all states are calculated
- {
- applyNextState = CalculateDeltaState(curTick, out curState, out nextState);
- }
+ var applyNextState = WaitingForFull
+ ? TryGetFullState(out curState, out nextState)
+ : TryGetDeltaState(out curState, out nextState);
- if (applyNextState && !curState!.Extrapolated)
- LastProcessedRealState = curState.ToSequence;
-
- if (!_waitingForFull)
+ if (curState != null)
{
- if (!applyNextState)
- _timing.CurTick = LastProcessedRealState;
-
- // This will slightly speed up or slow down the client tickrate based on the contents of the buffer.
- // CalcNextState should have just cleaned out any old states, so the buffer contains [t-1(last), t+0(cur), t+1(next), t+2, t+3, ..., t+n]
- // we can use this info to properly time our tickrate so it does not run fast or slow compared to the server.
- _timing.TickTimingAdjustment = (CurrentBufferSize - (float)TargetBufferSize) * 0.10f;
- }
- else
- {
- _timing.TickTimingAdjustment = 0f;
- }
-
- if (applyNextState)
- {
- DebugTools.Assert(curState!.Extrapolated || curState.FromSequence <= LastProcessedRealState,
+ DebugTools.Assert(curState.FromSequence <= curState.ToSequence,
"Tried to apply a non-extrapolated state that has too high of a FromSequence!");
if (Logging)
- {
- Logger.DebugS("net.state", $"Applying State: ext={curState!.Extrapolated}, cTick={_timing.CurTick}, fSeq={curState.FromSequence}, tSeq={curState.ToSequence}, buf={_stateBuffer.Count}");
- }
+ Logger.DebugS("net.state", $"Applying State: cTick={_timing.LastProcessedTick}, fSeq={curState.FromSequence}, tSeq={curState.ToSequence}, buf={_stateBuffer.Count}");
}
- var cState = curState!;
- curState = cState;
-
return applyNextState;
}
public void UpdateFullRep(GameState state)
{
- // Logger.Debug($"UPDATE FULL REP: {string.Join(", ", state.EntityStates?.Select(e => e.Uid) ?? Enumerable.Empty())}");
+ // Note: the most recently received server state currently doesn't include pvs-leave messages (detaching
+ // transform to null-space). This is because a client should never predict an entity being moved back from
+ // null-space, so there should be no need to reset it back there.
if (state.FromSequence == GameTick.Zero)
{
@@ -178,7 +170,7 @@ namespace Robust.Client.GameStates
{
if (!_lastStateFullRep.TryGetValue(entityState.Uid, out var compData))
{
- compData = new Dictionary();
+ compData = new Dictionary();
_lastStateFullRep.Add(entityState.Uid, compData);
}
@@ -196,167 +188,138 @@ namespace Robust.Client.GameStates
}
}
- private bool CalculateFullState([NotNullWhen(true)] out GameState? curState, out GameState? nextState, int targetBufferSize)
+ private bool TryGetFullState([NotNullWhen(true)] out GameState? curState, out GameState? nextState)
{
- if (_lastFullState != null)
+ nextState = null;
+ curState = null;
+
+ if (LastFullState == null)
+ return false;
+
+ // remove any old states we find to keep the buffer clean
+ // also look for the next state if we are interpolating.
+ var nextTick = LastFullState.ToSequence + 1;
+ for (var i = 0; i < _stateBuffer.Count; i++)
{
- if (Logging)
- Logger.DebugS("net", $"Resync CurTick to: {_lastFullState.ToSequence}");
+ var state = _stateBuffer[i];
- var curTick = _timing.CurTick = _lastFullState.ToSequence;
-
- if (Interpolation)
+ if (state.ToSequence < LastFullState.ToSequence)
{
- // look for the next state
- var lastTick = new GameTick(curTick.Value - 1);
- var nextTick = new GameTick(curTick.Value + 1);
- nextState = null;
-
- for (var i = 0; i < _stateBuffer.Count; i++)
- {
- var state = _stateBuffer[i];
- if (state.ToSequence == nextTick)
- {
- nextState = state;
- }
- else if (state.ToSequence < lastTick) // remove any old states we find to keep the buffer clean
- {
- _stateBuffer.RemoveSwap(i);
- i--;
- }
- }
-
- // we let the buffer fill up before starting to tick
- if (nextState != null && _stateBuffer.Count >= targetBufferSize)
- {
- curState = _lastFullState;
- _waitingForFull = false;
- return true;
- }
+ _stateBuffer.RemoveSwap(i);
+ i--;
}
- else if (_stateBuffer.Count >= targetBufferSize)
+ else if (Interpolation && state.ToSequence == nextTick)
{
- curState = _lastFullState;
- nextState = default;
- _waitingForFull = false;
- return true;
+ nextState = state;
}
}
- if (Logging)
- Logger.DebugS("net", $"Have FullState, filling buffer... ({_stateBuffer.Count}/{targetBufferSize})");
+ // we let the buffer fill up before starting to tick
+ if (_stateBuffer.Count >= TargetBufferSize && (!Interpolation || nextState != null))
+ {
+ if (Logging)
+ Logger.DebugS("net", $"Resync CurTick to: {LastFullState.ToSequence}");
- // waiting for full state or buffer to fill
- curState = default;
- nextState = default;
+ curState = LastFullState;
+ return true;
+ }
+
+ // waiting for buffer to fill
+ if (Logging)
+ Logger.DebugS("net", $"Have FullState, filling buffer... ({_stateBuffer.Count}/{TargetBufferSize})");
+
return false;
}
- private bool CalculateDeltaState(GameTick curTick, [NotNullWhen(true)] out GameState? curState, out GameState? nextState)
+ internal void AddLeavePvsMessage(MsgStateLeavePvs message)
{
- var lastTick = new GameTick(curTick.Value - 1);
- var nextTick = new GameTick(curTick.Value + 1);
+ // Late message may still need to be processed,
+ DebugTools.Assert(message.Entities.Count > 0);
+ _pvsDetachMessages.TryAdd(message.Tick, message.Entities);
+ }
+ public List<(GameTick Tick, List Entities)> GetEntitiesToDetach(GameTick toTick, int budget)
+ {
+ var result = new List<(GameTick Tick, List Entities)>();
+ foreach (var (tick, entities) in _pvsDetachMessages)
+ {
+ if (tick > toTick)
+ continue;
+
+ if (budget >= entities.Count)
+ {
+ budget -= entities.Count;
+ _pvsDetachMessages.Remove(tick);
+ result.Add((tick, entities));
+ continue;
+ }
+
+ var index = entities.Count - budget;
+ result.Add((tick, entities.GetRange(index, budget)));
+ entities.RemoveRange(index, budget);
+ break;
+ }
+ return result;
+ }
+
+ private bool TryGetDeltaState(out GameState? curState, out GameState? nextState)
+ {
curState = null;
nextState = null;
+ var targetCurTick = _timing.LastProcessedTick + 1;
+ var targetNextTick = _timing.LastProcessedTick + 2;
+
GameTick? futureStateLowestFromSeq = null;
- uint lastStateInput = 0;
for (var i = 0; i < _stateBuffer.Count; i++)
{
var state = _stateBuffer[i];
// remember there are no duplicate ToSequence states in the list.
- if (state.ToSequence == curTick)
+ if (state.ToSequence == targetCurTick && state.FromSequence <= _timing.LastRealTick)
{
curState = state;
- _highestFromSequence = state.FromSequence;
+ continue;
}
- else if (Interpolation && state.ToSequence == nextTick)
- {
+
+ if (Interpolation && state.ToSequence == targetNextTick)
nextState = state;
- if (futureStateLowestFromSeq == null || futureStateLowestFromSeq.Value > state.FromSequence)
- {
- futureStateLowestFromSeq = state.FromSequence;
- }
- }
- else if (state.ToSequence > curTick)
+ if (state.ToSequence > targetCurTick && (futureStateLowestFromSeq == null || futureStateLowestFromSeq.Value > state.FromSequence))
{
- if (futureStateLowestFromSeq == null || futureStateLowestFromSeq.Value > state.FromSequence)
- {
- futureStateLowestFromSeq = state.FromSequence;
- }
+ futureStateLowestFromSeq = state.FromSequence;
+ continue;
}
- else if (state.ToSequence == lastTick)
- {
- lastStateInput = state.LastProcessedInput;
- }
- else if (state.ToSequence < _highestFromSequence) // remove any old states we find to keep the buffer clean
+
+ // remove any old states we find to keep the buffer clean
+ if (state.ToSequence <= _timing.LastRealTick)
{
_stateBuffer.RemoveSwap(i);
i--;
}
}
- // Make sure we can ACTUALLY apply this state.
- // Can happen that we can't if there is a hole and we're doing extrapolation.
- if (curState != null && curState.FromSequence > LastProcessedRealState)
- curState = null;
-
- // can't find current state, but we do have a future state.
- if (!Extrapolation && curState == null && futureStateLowestFromSeq != null
- && futureStateLowestFromSeq <= LastProcessedRealState)
- {
- //this is not actually extrapolation
- curState = ExtrapolateState(_highestFromSequence, curTick, lastStateInput);
- return true; // keep moving, we have a future state
- }
-
- // we won't extrapolate, and curState was not found, buffer is empty
- if (!Extrapolation && curState == null)
- return false;
-
- // we found both the states to interpolate between, this should almost always be true.
- if (Interpolation && curState != null)
- return true;
-
- if (!Interpolation && curState != null && nextState != null)
- return true;
-
- if (curState == null)
- {
- curState = ExtrapolateState(_highestFromSequence, curTick, lastStateInput);
- }
-
- if (nextState == null && Interpolation)
- {
- nextState = ExtrapolateState(_highestFromSequence, nextTick, lastStateInput);
- }
-
- return true;
- }
-
- ///
- /// Generates a completely fake GameState.
- ///
- private static GameState ExtrapolateState(GameTick fromSequence, GameTick toSequence, uint lastInput)
- {
- var state = new GameState(fromSequence, toSequence, lastInput, default, default, default, null);
- state.Extrapolated = true;
- return state;
+ // Even if we can't find current state, maybe we have a future state?
+ return curState != null || (futureStateLowestFromSeq != null && futureStateLowestFromSeq <= _timing.LastRealTick);
}
///
public void Reset()
{
_stateBuffer.Clear();
- _lastFullState = null;
- _waitingForFull = true;
+ LastFullState = null;
+ LastFullStateRequested = GameTick.Zero;
}
- public void MergeImplicitData(Dictionary> data)
+ public void RequestFullState()
+ {
+ _stateBuffer.Clear();
+ LastFullState = null;
+ LastFullStateRequested = _timing.LastRealTick;
+ }
+
+ public void MergeImplicitData(Dictionary> data)
{
foreach (var (uid, compData) in data)
{
@@ -372,20 +335,39 @@ namespace Robust.Client.GameStates
}
}
- public Dictionary GetLastServerStates(EntityUid entity)
+ public Dictionary GetLastServerStates(EntityUid entity)
{
return _lastStateFullRep[entity];
}
public bool TryGetLastServerStates(EntityUid entity,
- [NotNullWhen(true)] out Dictionary? dictionary)
+ [NotNullWhen(true)] out Dictionary? dictionary)
{
return _lastStateFullRep.TryGetValue(entity, out dictionary);
}
public int CalculateBufferSize(GameTick fromTick)
{
- return _stateBuffer.Count(s => s.ToSequence >= fromTick);
+ bool foundState;
+ var nextTick = fromTick;
+
+ do
+ {
+ foundState = false;
+
+ foreach (var state in _stateBuffer)
+ {
+ if (state.ToSequence > nextTick && state.FromSequence <= nextTick)
+ {
+ foundState = true;
+ nextTick += 1;
+ }
+ }
+
+ }
+ while (foundState);
+
+ return (int) (nextTick.Value - fromTick.Value);
}
}
}
diff --git a/Robust.Client/GameStates/IClientGameStateManager.cs b/Robust.Client/GameStates/IClientGameStateManager.cs
index 3a1a0e742..36df0a650 100644
--- a/Robust.Client/GameStates/IClientGameStateManager.cs
+++ b/Robust.Client/GameStates/IClientGameStateManager.cs
@@ -1,7 +1,8 @@
-using System;
+using System;
using Robust.Shared;
using Robust.Shared.GameObjects;
using Robust.Shared.Input;
+using Robust.Shared.Network.Messages;
using Robust.Shared.Timing;
namespace Robust.Client.GameStates
@@ -27,18 +28,10 @@ namespace Robust.Client.GameStates
int TargetBufferSize { get; }
///
- /// Number of game states currently in the state buffer.
+ /// Number of applicable game states currently in the state buffer.
///
int CurrentBufferSize { get; }
- ///
- /// The current tick of the last server game state applied.
- ///
- ///
- /// Use this to synchronize server-sent simulation events with the client's game loop.
- ///
- GameTick CurServerTick { get; }
-
///
/// If the buffer size is this many states larger than the target buffer size,
/// apply the overflow of states in a single tick.
@@ -57,6 +50,11 @@ namespace Robust.Client.GameStates
///
event Action GameStateApplied;
+ ///
+ /// This is invoked whenever a pvs-leave message is received.
+ ///
+ public event Action? PvsLeave;
+
///
/// One time initialization of the service.
///
@@ -78,6 +76,11 @@ namespace Robust.Client.GameStates
/// Message being dispatched.
void InputCommandDispatched(FullInputCmdMessage message);
+ ///
+ /// Requests a full state from the server. This should override even implicit entity data.
+ ///
+ public void RequestFullState();
+
uint SystemMessageDispatched(T message) where T : EntityEventArgs;
}
}
diff --git a/Robust.Client/GameStates/IGameStateProcessor.cs b/Robust.Client/GameStates/IGameStateProcessor.cs
index c5fde6bbd..ebf519c8e 100644
--- a/Robust.Client/GameStates/IGameStateProcessor.cs
+++ b/Robust.Client/GameStates/IGameStateProcessor.cs
@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Robust.Shared.GameObjects;
using Robust.Shared.GameStates;
@@ -17,8 +17,8 @@ namespace Robust.Client.GameStates
/// Minimum number of states needed in the buffer for everything to work.
///
///
- /// With interpolation enabled minimum is 3 states in buffer for the system to work (last, cur, next).
- /// Without interpolation enabled minimum is 2 states in buffer for the system to work (last, cur).
+ /// With interpolation enabled minimum is 2 states in buffer for the system to work (cur, next).
+ /// Without interpolation enabled minimum is 2 states in buffer for the system to work (cur).
///
int MinBufferSize { get; }
@@ -28,12 +28,6 @@ namespace Robust.Client.GameStates
///
int TargetBufferSize { get; }
- ///
- /// Number of game states currently in the state buffer.
- ///
- ///
- int CurrentBufferSize { get; }
-
///
/// Is frame interpolation turned on?
///
@@ -46,29 +40,22 @@ namespace Robust.Client.GameStates
/// For Lan, set this to 0. For Excellent net conditions, set this to 1. For normal network conditions,
/// set this to 2. For worse conditions, set it higher.
///
- int InterpRatio { get; set; }
-
- ///
- /// If the client clock runs ahead of the server and the buffer gets emptied, should fake extrapolated states be generated?
- ///
- bool Extrapolation { get; set; }
+ int BufferSize { get; set; }
///
/// Is debug logging enabled? This will dump debug info about every state to the log.
///
bool Logging { get; set; }
- ///
- /// The last REAL server tick that has been processed.
- /// i.e. not incremented on extrapolation.
- ///
- GameTick LastProcessedRealState { get; set; }
///
/// Adds a new state into the processor. These are usually from networking or replays.
///
/// Newly received state.
- void AddNewState(GameState state);
+ /// Returns true if the state was accepted and should be acknowledged
+ bool AddNewState(GameState state);
+ //> usually from replays
+ //replays when
///
/// Calculates the current and next state to apply for a given game tick.
@@ -77,7 +64,7 @@ namespace Robust.Client.GameStates
/// Current state for the given tick. This can be null.
/// Current state for tick + 1. This can be null.
/// Was the function able to correctly calculate the states for the given tick?
- bool ProcessTickStates(GameTick curTick, [NotNullWhen(true)] out GameState? curState, out GameState? nextState);
+ bool TryGetServerState([NotNullWhen(true)] out GameState? curState, out GameState? nextState);
///
/// Resets the processor back to its initial state.
@@ -96,21 +83,22 @@ namespace Robust.Client.GameStates
/// The data to merge.
/// It's a dictionary of entity ID -> (component net ID -> ComponentState)
///
- void MergeImplicitData(Dictionary> data);
+ void MergeImplicitData(Dictionary> data);
///
/// Get the last state data from the server for an entity.
///
/// Dictionary (net ID -> ComponentState)
- Dictionary GetLastServerStates(EntityUid entity);
+ Dictionary GetLastServerStates(EntityUid entity);
///
- /// Calculate the size of the game state buffer from a given tick.
+ /// Calculate the number of applicable states in the game state buffer from a given tick.
+ /// This includes only applicable states. If there is a gap, future buffers are not included.
///
/// The tick to calculate from.
int CalculateBufferSize(GameTick fromTick);
bool TryGetLastServerStates(EntityUid entity,
- [NotNullWhen(true)] out Dictionary? dictionary);
+ [NotNullWhen(true)] out Dictionary? dictionary);
}
}
diff --git a/Robust.Client/GameStates/NetEntityOverlay.cs b/Robust.Client/GameStates/NetEntityOverlay.cs
index 8842392ea..63ee9f476 100644
--- a/Robust.Client/GameStates/NetEntityOverlay.cs
+++ b/Robust.Client/GameStates/NetEntityOverlay.cs
@@ -1,17 +1,17 @@
+using System;
using System.Collections.Generic;
using Robust.Client.Graphics;
-using Robust.Client.Player;
using Robust.Client.ResourceManagement;
-using Robust.Shared.Configuration;
+using Robust.Client.Timing;
+using Robust.Shared.Collections;
using Robust.Shared.Console;
using Robust.Shared.Enums;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
using Robust.Shared.Maths;
using Robust.Shared.Network;
-using Robust.Shared.Prototypes;
+using Robust.Shared.Network.Messages;
using Robust.Shared.Timing;
-using Robust.Shared.Utility;
namespace Robust.Client.GameStates
{
@@ -21,21 +21,20 @@ namespace Robust.Client.GameStates
///
sealed class NetEntityOverlay : Overlay
{
- [Dependency] private readonly IGameTiming _gameTiming = default!;
+ [Dependency] private readonly IClientGameTiming _gameTiming = default!;
[Dependency] private readonly IClientNetManager _netManager = default!;
[Dependency] private readonly IClientGameStateManager _gameStateManager = default!;
[Dependency] private readonly IEntityManager _entityManager = default!;
- [Dependency] private readonly IConfigurationManager _configurationManager = default!;
- [Dependency] private readonly IEyeManager _eyeManager = default!;
- private const int TrafficHistorySize = 64; // Size of the traffic history bar in game ticks.
+ private const uint TrafficHistorySize = 64; // Size of the traffic history bar in game ticks.
+ private const int _maxEnts = 128; // maximum number of entities to track.
///
- public override OverlaySpace Space => OverlaySpace.ScreenSpace | OverlaySpace.WorldSpace;
+ public override OverlaySpace Space => OverlaySpace.ScreenSpace;
private readonly Font _font;
private readonly int _lineHeight;
- private readonly List _netEnts = new();
+ private readonly Dictionary _netEnts = new();
public NetEntityOverlay()
{
@@ -45,87 +44,58 @@ namespace Robust.Client.GameStates
_lineHeight = _font.GetLineHeight(1);
_gameStateManager.GameStateApplied += HandleGameStateApplied;
+ _gameStateManager.PvsLeave += OnPvsLeave;
+ }
+
+ private void OnPvsLeave(MsgStateLeavePvs msg)
+ {
+ if (msg.Tick.Value + TrafficHistorySize < _gameTiming.LastRealTick.Value)
+ return;
+
+ foreach (var uid in msg.Entities)
+ {
+ if (!_netEnts.TryGetValue(uid, out var netEnt))
+ continue;
+
+ if (netEnt.LastUpdate < msg.Tick)
+ {
+ netEnt.InPVS = false;
+ netEnt.LastUpdate = msg.Tick;
+ }
+
+ netEnt.Traffic.Add(msg.Tick, NetEntData.EntState.PvsLeave);
+ }
}
private void HandleGameStateApplied(GameStateAppliedArgs args)
{
- if(_gameTiming.InPrediction) // we only care about real server states.
- return;
-
- // Shift traffic history down one
- for (var i = 0; i < _netEnts.Count; i++)
- {
- var traffic = _netEnts[i].Traffic;
- for (int j = 1; j < TrafficHistorySize; j++)
- {
- traffic[j - 1] = traffic[j];
- }
-
- traffic[^1] = 0;
- }
-
var gameState = args.AppliedState;
- if(gameState.EntityStates.HasContents)
+ if (!gameState.EntityStates.HasContents)
+ return;
+
+ foreach (var entityState in gameState.EntityStates.Span)
{
- // Loop over every entity that gets updated this state and record the traffic
- foreach (var entityState in gameState.EntityStates.Span)
+ if (!_netEnts.TryGetValue(entityState.Uid, out var netEnt))
{
- var newEnt = true;
- for(var i=0;i<_netEnts.Count;i++)
- {
- var netEnt = _netEnts[i];
-
- if (netEnt.Id != entityState.Uid)
- continue;
-
- //TODO: calculate size of state and record it here.
- netEnt.Traffic[^1] = 1;
- netEnt.LastUpdate = gameState.ToSequence;
- newEnt = false;
- _netEnts[i] = netEnt; // copy struct back
- break;
- }
-
- if (!newEnt)
+ if (_netEnts.Count >= _maxEnts)
continue;
- var newNetEnt = new NetEntity(entityState.Uid);
- newNetEnt.Traffic[^1] = 1;
- newNetEnt.LastUpdate = gameState.ToSequence;
- _netEnts.Add(newNetEnt);
+ _netEnts[entityState.Uid] = netEnt = new();
}
- }
- bool pvsEnabled = _configurationManager.GetCVar("net.pvs");
- float pvsRange = _configurationManager.GetCVar("net.maxupdaterange");
- var pvsCenter = _eyeManager.CurrentEye.Position;
- Box2 pvsBox = Box2.CenteredAround(pvsCenter.Position, new Vector2(pvsRange*2, pvsRange*2));
-
- int timeout = _gameTiming.TickRate * 3;
- for (int i = 0; i < _netEnts.Count; i++)
- {
- var netEnt = _netEnts[i];
-
- if(_entityManager.EntityExists(netEnt.Id))
+ if (!netEnt.InPVS && netEnt.LastUpdate < gameState.ToSequence)
{
- //TODO: Whoever is working on PVS remake, change the InPVS detection.
- var uid = netEnt.Id;
- var position = _entityManager.GetComponent(uid).MapPosition;
- netEnt.InPVS = !pvsEnabled || (pvsBox.Contains(position.Position) && position.MapId == pvsCenter.MapId);
- _netEnts[i] = netEnt; // copy struct back
- continue;
+ netEnt.InPVS = true;
+ netEnt.Traffic.Add(gameState.ToSequence, NetEntData.EntState.PvsEnter);
}
+ else
+ netEnt.Traffic.Add(gameState.ToSequence, NetEntData.EntState.Data);
- netEnt.Exists = false;
- if (netEnt.LastUpdate.Value + timeout < _gameTiming.LastRealTick.Value)
- {
- _netEnts.RemoveAt(i);
- i--;
- continue;
- }
+ if (netEnt.LastUpdate < gameState.ToSequence)
+ netEnt.LastUpdate = gameState.ToSequence;
- _netEnts[i] = netEnt; // copy struct back
+ //TODO: calculate size of state and record it here.
}
}
@@ -139,145 +109,128 @@ namespace Robust.Client.GameStates
case OverlaySpace.ScreenSpace:
DrawScreen(args);
break;
- case OverlaySpace.WorldSpace:
- DrawWorld(args);
- break;
}
}
- private void DrawWorld(in OverlayDrawArgs args)
- {
- bool pvsEnabled = _configurationManager.GetCVar("net.pvs");
- if(!pvsEnabled)
- return;
-
- float pvsRange = _configurationManager.GetCVar("net.maxupdaterange");
- var pvsCenter = _eyeManager.CurrentEye.Position;
- Box2 pvsBox = Box2.CenteredAround(pvsCenter.Position, new Vector2(pvsRange * 2, pvsRange * 2));
-
- var worldHandle = args.WorldHandle;
-
- worldHandle.DrawRect(pvsBox, Color.Red, false);
- }
-
private void DrawScreen(in OverlayDrawArgs args)
{
// remember, 0,0 is top left of ui with +X right and +Y down
var screenHandle = args.ScreenHandle;
- for (int i = 0; i < _netEnts.Count; i++)
+ int i = 0;
+ foreach (var (uid, netEnt) in _netEnts)
{
- var netEnt = _netEnts[i];
- var uid = netEnt.Id;
-
if (!_entityManager.EntityExists(uid))
{
- _netEnts.RemoveSwap(i);
- i--;
+ _netEnts.Remove(uid);
continue;
}
var xPos = 100;
- var yPos = 10 + _lineHeight * i;
- var name = $"({netEnt.Id}) {_entityManager.GetComponent(uid).EntityPrototype?.ID}";
- var color = CalcTextColor(ref netEnt);
+ var yPos = 10 + _lineHeight * i++;
+ var name = $"({uid}) {_entityManager.GetComponent(uid).EntityPrototype?.ID}";
+ var color = netEnt.TextColor(_gameTiming);
screenHandle.DrawString(_font, new Vector2(xPos + (TrafficHistorySize + 4), yPos), name, color);
- DrawTrafficBox(screenHandle, ref netEnt, xPos, yPos);
+ DrawTrafficBox(screenHandle, netEnt, xPos, yPos);
}
}
- private void DrawTrafficBox(DrawingHandleScreen handle, ref NetEntity netEntity, int x, int y)
+ private void DrawTrafficBox(DrawingHandleScreen handle, NetEntData netEntity, int x, int y)
{
- handle.DrawRect(UIBox2.FromDimensions(x+1, y, TrafficHistorySize + 1, _lineHeight), new Color(32, 32, 32, 128));
+ handle.DrawRect(UIBox2.FromDimensions(x + 1, y, TrafficHistorySize + 1, _lineHeight), new Color(32, 32, 32, 128));
handle.DrawRect(UIBox2.FromDimensions(x, y, TrafficHistorySize + 2, _lineHeight), Color.Gray.WithAlpha(0.15f), false);
var traffic = netEntity.Traffic;
//TODO: Local peak size, actually scale the peaks
- for (int i = 0; i < TrafficHistorySize; i++)
+ for (uint i = 1; i <= TrafficHistorySize; i++)
{
- if(traffic[i] == 0)
+ if (!traffic.TryGetValue(_gameTiming.LastRealTick + (i - TrafficHistorySize), out var tickData))
continue;
+ var color = tickData switch
+ {
+ NetEntData.EntState.Data => Color.Green,
+ NetEntData.EntState.PvsLeave => Color.Orange,
+ NetEntData.EntState.PvsEnter => Color.Cyan,
+ _ => throw new Exception("Unexpected value")
+ };
+
var xPos = x + 1 + i;
var yPosA = y + 1;
var yPosB = yPosA + _lineHeight - 1;
- handle.DrawLine(new Vector2(xPos, yPosA), new Vector2(xPos, yPosB), Color.Green);
+ handle.DrawLine(new Vector2(xPos, yPosA), new Vector2(xPos, yPosB), color);
}
}
- private Color CalcTextColor(ref NetEntity ent)
- {
- if(!ent.Exists)
- return Color.Gray; // Entity is deleted, will be removed from list soon.
-
- if(!ent.InPVS)
- return Color.Red; // Entity still exists outside PVS, but not updated anymore.
-
- if(_gameTiming.LastRealTick < ent.LastUpdate + _gameTiming.TickRate)
- return Color.Blue; //Entity in PVS generating ongoing traffic.
-
- return Color.Green; // Entity in PVS, but not updated recently.
- }
-
protected override void DisposeBehavior()
{
_gameStateManager.GameStateApplied -= HandleGameStateApplied;
+ _gameStateManager.PvsLeave -= OnPvsLeave;
base.DisposeBehavior();
}
- private struct NetEntity
+ private sealed class NetEntData
{
- public GameTick LastUpdate;
- public readonly EntityUid Id;
- public readonly int[] Traffic;
- public bool Exists;
- public bool InPVS;
+ public GameTick LastUpdate = GameTick.Zero;
+ public readonly OverflowDictionary Traffic = new((int) TrafficHistorySize);
+ public bool Exists = true;
+ public bool InPVS = true;
- public NetEntity(EntityUid id)
+ public Color TextColor(IClientGameTiming timing)
{
- LastUpdate = GameTick.Zero;
- Id = id;
- Traffic = new int[TrafficHistorySize];
- Exists = true;
- InPVS = true;
+ if (!InPVS)
+ return Color.Orange; // Entity still exists outside PVS, but not updated anymore.
+
+ if (timing.LastRealTick < LastUpdate + timing.TickRate)
+ return Color.Blue; //Entity in PVS generating ongoing traffic.
+
+ return Color.Green; // Entity in PVS, but not updated recently.
+ }
+
+ public enum EntState : byte
+ {
+ Nothing = 0,
+ Data = 1,
+ PvsLeave = 2,
+ PvsEnter = 3
}
}
private sealed class NetEntityReportCommand : IConsoleCommand
{
public string Command => "net_entityreport";
- public string Help => "net_entityreport <0|1>";
+ public string Help => "net_entityreport";
public string Description => "Toggles the net entity report panel.";
public void Execute(IConsoleShell shell, string argStr, string[] args)
{
- if (args.Length != 1)
- {
- shell.WriteError("Invalid argument amount. Expected 1 arguments.");
- return;
- }
-
- if (!byte.TryParse(args[0], out var iValue))
- {
- shell.WriteError("Invalid argument: Needs to be 0 or 1.");
- return;
- }
-
- var bValue = iValue > 0;
var overlayMan = IoCManager.Resolve();
- if(bValue && !overlayMan.HasOverlay(typeof(NetEntityOverlay)))
+ if (!overlayMan.HasOverlay(typeof(NetEntityOverlay)))
{
overlayMan.AddOverlay(new NetEntityOverlay());
shell.WriteLine("Enabled network entity report overlay.");
}
- else if(!bValue && overlayMan.HasOverlay(typeof(NetEntityOverlay)))
+ else
{
overlayMan.RemoveOverlay(typeof(NetEntityOverlay));
shell.WriteLine("Disabled network entity report overlay.");
}
}
}
+
+ private sealed class NetShowGraphCommand : IConsoleCommand
+ {
+ // Yeah commands should be localized, but I'm lazy and this is really just a debug command.
+ public string Command => "net_refresh";
+ public string Help => "net_refresh";
+ public string Description => "requests a full server state";
+
+ public void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ IoCManager.Resolve().RequestFullState();
+ }
+ }
}
}
diff --git a/Robust.Client/GameStates/NetGraphOverlay.cs b/Robust.Client/GameStates/NetGraphOverlay.cs
index f16dde3aa..139fb3994 100644
--- a/Robust.Client/GameStates/NetGraphOverlay.cs
+++ b/Robust.Client/GameStates/NetGraphOverlay.cs
@@ -10,6 +10,7 @@ using Robust.Shared.IoC;
using Robust.Shared.Maths;
using Robust.Shared.Network;
using Robust.Shared.Timing;
+using Robust.Client.Player;
namespace Robust.Client.GameStates
{
@@ -28,6 +29,7 @@ namespace Robust.Client.GameStates
private const int MidrangePayloadBps = 33600 / 8; // mid-range line
private const int BytesPerPixel = 2; // If you are running the game on a DSL connection, you can scale the graph to fit your absurd bandwidth.
private const int LowerGraphOffset = 100; // Offset on the Y axis in pixels of the lower lag/interp graph.
+ private const int LeftMargin = 500; // X offset, to avoid interfering with the f3 menu.
private const int MsPerPixel = 4; // Latency Milliseconds per pixel, for scaling the graph.
///
@@ -84,38 +86,46 @@ namespace Robust.Client.GameStates
var sb = new StringBuilder();
foreach (var entState in entStates.Span)
{
- if (entState.Uid == WatchEntId)
- {
- if(entState.ComponentChanges.HasContents)
- {
- sb.Append($"\n Changes:");
- foreach (var compChange in entState.ComponentChanges.Span)
- {
- var registration = _componentFactory.GetRegistration(compChange.NetID);
- var create = compChange.Created ? 'C' : '\0';
- var mod = !(compChange.Created || compChange.Created) ? 'M' : '\0';
- var del = compChange.Deleted ? 'D' : '\0';
- sb.Append($"\n [{create}{mod}{del}]{compChange.NetID}:{registration.Name}");
+ if (entState.Uid != WatchEntId)
+ continue;
- if(compChange.State is not null)
- sb.Append($"\n STATE:{compChange.State.GetType().Name}");
- }
- }
+ if (!entState.ComponentChanges.HasContents)
+ {
+ sb.Append("\n Entered PVS");
+ break;
+ }
+
+ sb.Append($"\n Changes:");
+ foreach (var compChange in entState.ComponentChanges.Span)
+ {
+ var registration = _componentFactory.GetRegistration(compChange.NetID);
+ var create = compChange.Created ? 'C' : '\0';
+ var mod = !(compChange.Created || compChange.Created) ? 'M' : '\0';
+ var del = compChange.Deleted ? 'D' : '\0';
+ sb.Append($"\n [{create}{mod}{del}]{compChange.NetID}:{registration.Name}");
+
+ if (compChange.State is not null)
+ sb.Append($"\n STATE:{compChange.State.GetType().Name}");
}
}
entStateString = sb.ToString();
}
+ foreach (var ent in args.Detached)
+ {
+ if (ent != WatchEntId)
+ continue;
+
+ conShell.WriteLine($"watchEnt: Left PVS at tick {args.AppliedState.ToSequence}, eid={WatchEntId}" + "\n");
+ }
+
var entDeletes = args.AppliedState.EntityDeletions;
if (entDeletes.HasContents)
{
- var sb = new StringBuilder();
foreach (var entDelete in entDeletes.Span)
{
if (entDelete == WatchEntId)
- {
entDelString = "\n Deleted";
- }
}
}
@@ -155,17 +165,16 @@ namespace Robust.Client.GameStates
{
// remember, 0,0 is top left of ui with +X right and +Y down
- var leftMargin = 300;
var width = HistorySize;
var height = 500;
var drawSizeThreshold = Math.Min(_totalHistoryPayload / HistorySize, 300);
var handle = args.ScreenHandle;
// bottom payload line
- handle.DrawLine(new Vector2(leftMargin, height), new Vector2(leftMargin + width, height), Color.DarkGray.WithAlpha(0.8f));
+ handle.DrawLine(new Vector2(LeftMargin, height), new Vector2(LeftMargin + width, height), Color.DarkGray.WithAlpha(0.8f));
// bottom lag line
- handle.DrawLine(new Vector2(leftMargin, height + LowerGraphOffset), new Vector2(leftMargin + width, height + LowerGraphOffset), Color.DarkGray.WithAlpha(0.8f));
+ handle.DrawLine(new Vector2(LeftMargin, height + LowerGraphOffset), new Vector2(LeftMargin + width, height + LowerGraphOffset), Color.DarkGray.WithAlpha(0.8f));
int lastLagY = -1;
int lastLagMs = -1;
@@ -175,7 +184,7 @@ namespace Robust.Client.GameStates
var state = _history[i];
// draw the payload size
- var xOff = leftMargin + i;
+ var xOff = LeftMargin + i;
var yoff = height - state.Payload / BytesPerPixel;
handle.DrawLine(new Vector2(xOff, height), new Vector2(xOff, yoff), Color.LightGreen.WithAlpha(0.8f));
@@ -211,25 +220,25 @@ namespace Robust.Client.GameStates
// average payload line
var avgyoff = height - drawSizeThreshold / BytesPerPixel;
- handle.DrawLine(new Vector2(leftMargin, avgyoff), new Vector2(leftMargin + width, avgyoff), Color.DarkGray.WithAlpha(0.8f));
+ handle.DrawLine(new Vector2(LeftMargin, avgyoff), new Vector2(LeftMargin + width, avgyoff), Color.DarkGray.WithAlpha(0.8f));
// top payload warning line
var warnYoff = height - _warningPayloadSize / BytesPerPixel;
- handle.DrawLine(new Vector2(leftMargin, warnYoff), new Vector2(leftMargin + width, warnYoff), Color.DarkGray.WithAlpha(0.8f));
+ handle.DrawLine(new Vector2(LeftMargin, warnYoff), new Vector2(LeftMargin + width, warnYoff), Color.DarkGray.WithAlpha(0.8f));
// mid payload line
var midYoff = height - _midrangePayloadSize / BytesPerPixel;
- handle.DrawLine(new Vector2(leftMargin, midYoff), new Vector2(leftMargin + width, midYoff), Color.DarkGray.WithAlpha(0.8f));
+ handle.DrawLine(new Vector2(LeftMargin, midYoff), new Vector2(LeftMargin + width, midYoff), Color.DarkGray.WithAlpha(0.8f));
// payload text
- handle.DrawString(_font, new Vector2(leftMargin + width, warnYoff), "56K");
- handle.DrawString(_font, new Vector2(leftMargin + width, midYoff), "33.6K");
+ handle.DrawString(_font, new Vector2(LeftMargin + width, warnYoff), "56K");
+ handle.DrawString(_font, new Vector2(LeftMargin + width, midYoff), "33.6K");
// interp text info
if(lastLagY != -1)
- handle.DrawString(_font, new Vector2(leftMargin + width, lastLagY), $"{lastLagMs.ToString()}ms");
+ handle.DrawString(_font, new Vector2(LeftMargin + width, lastLagY), $"{lastLagMs.ToString()}ms");
- handle.DrawString(_font, new Vector2(leftMargin, height + LowerGraphOffset), $"{_gameStateManager.CurrentBufferSize.ToString()} states");
+ handle.DrawString(_font, new Vector2(LeftMargin, height + LowerGraphOffset), $"{_gameStateManager.CurrentBufferSize.ToString()} states");
}
protected override void DisposeBehavior()
@@ -242,32 +251,19 @@ namespace Robust.Client.GameStates
private sealed class NetShowGraphCommand : IConsoleCommand
{
public string Command => "net_graph";
- public string Help => "net_graph <0|1>";
+ public string Help => "net_graph";
public string Description => "Toggles the net statistics pannel.";
public void Execute(IConsoleShell shell, string argStr, string[] args)
{
- if (args.Length != 1)
- {
- shell.WriteError("Invalid argument amount. Expected 2 arguments.");
- return;
- }
-
- if (!byte.TryParse(args[0], out var iValue))
- {
- shell.WriteLine("Invalid argument: Needs to be 0 or 1.");
- return;
- }
-
- var bValue = iValue > 0;
var overlayMan = IoCManager.Resolve();
- if(bValue && !overlayMan.HasOverlay(typeof(NetGraphOverlay)))
+ if(!overlayMan.HasOverlay(typeof(NetGraphOverlay)))
{
overlayMan.AddOverlay(new NetGraphOverlay());
shell.WriteLine("Enabled network overlay.");
}
- else if(overlayMan.HasOverlay(typeof(NetGraphOverlay)))
+ else
{
overlayMan.RemoveOverlay(typeof(NetGraphOverlay));
shell.WriteLine("Disabled network overlay.");
@@ -283,13 +279,12 @@ namespace Robust.Client.GameStates
public void Execute(IConsoleShell shell, string argStr, string[] args)
{
- if (args.Length != 1)
+ EntityUid eValue;
+ if (args.Length == 0)
{
- shell.WriteError("Invalid argument amount. Expected 1 argument.");
- return;
+ eValue = IoCManager.Resolve().LocalPlayer?.ControlledEntity ?? EntityUid.Invalid;
}
-
- if (!EntityUid.TryParse(args[0], out var eValue))
+ else if (!EntityUid.TryParse(args[0], out eValue))
{
shell.WriteError("Invalid argument: Needs to be 0 or an entityId.");
return;
@@ -297,12 +292,13 @@ namespace Robust.Client.GameStates
var overlayMan = IoCManager.Resolve();
- if (overlayMan.HasOverlay(typeof(NetGraphOverlay)))
+ if (!overlayMan.TryGetOverlay(out NetGraphOverlay? overlay))
{
- var netOverlay = overlayMan.GetOverlay();
-
- netOverlay.WatchEntId = eValue;
+ overlay = new();
+ overlayMan.AddOverlay(overlay);
}
+
+ overlay.WatchEntId = eValue;
}
}
}
diff --git a/Robust.Client/Graphics/Overlays/IOverlayManager.cs b/Robust.Client/Graphics/Overlays/IOverlayManager.cs
index 1f7cb0915..611bae5e1 100644
--- a/Robust.Client/Graphics/Overlays/IOverlayManager.cs
+++ b/Robust.Client/Graphics/Overlays/IOverlayManager.cs
@@ -16,8 +16,8 @@ namespace Robust.Client.Graphics
bool RemoveOverlay(Type overlayClass);
bool RemoveOverlay() where T : Overlay;
- bool TryGetOverlay(Type overlayClass, out Overlay? overlay);
- bool TryGetOverlay(out T? overlay) where T : Overlay;
+ bool TryGetOverlay(Type overlayClass, [NotNullWhen(true)] out Overlay? overlay);
+ bool TryGetOverlay([NotNullWhen(true)] out T? overlay) where T : Overlay;
Overlay GetOverlay(Type overlayClass);
T GetOverlay() where T : Overlay;
diff --git a/Robust.Client/Timing/ClientGameTiming.cs b/Robust.Client/Timing/ClientGameTiming.cs
index 213aa0a1a..b4f938a3d 100644
--- a/Robust.Client/Timing/ClientGameTiming.cs
+++ b/Robust.Client/Timing/ClientGameTiming.cs
@@ -10,6 +10,14 @@ namespace Robust.Client.Timing
{
[Dependency] private readonly IClientNetManager _netManager = default!;
+ public override bool InPrediction => !ApplyingState && CurTick > LastRealTick;
+
+ ///
+ public GameTick LastRealTick { get; set; }
+
+ ///
+ public GameTick LastProcessedTick { get; set; }
+
public override TimeSpan ServerTime
{
get
diff --git a/Robust.Client/Timing/IClientGameTiming.cs b/Robust.Client/Timing/IClientGameTiming.cs
index 329a9e77b..60f8625af 100644
--- a/Robust.Client/Timing/IClientGameTiming.cs
+++ b/Robust.Client/Timing/IClientGameTiming.cs
@@ -5,6 +5,20 @@ namespace Robust.Client.Timing
{
public interface IClientGameTiming : IGameTiming
{
+ ///
+ /// This is functionally the clients "current-tick" before prediction, and represents the target value for . This value should increment by at least one every tick. It may increase by more than
+ /// that if we apply several server states within a single tick.
+ ///
+ GameTick LastProcessedTick { get; set; }
+
+ ///
+ /// The last real non-extrapolated server state that was applied. Without networking issues, this tick should
+ /// always correspond to , however if there is a missing states or the buffer has run
+ /// out, this value may be smaller..
+ ///
+ GameTick LastRealTick { get; set; }
+
void StartPastPrediction();
void EndPastPrediction();
diff --git a/Robust.Client/UserInterface/CustomControls/DebugMonitors.cs b/Robust.Client/UserInterface/CustomControls/DebugMonitors.cs
index 6e186d265..2dfccea4b 100644
--- a/Robust.Client/UserInterface/CustomControls/DebugMonitors.cs
+++ b/Robust.Client/UserInterface/CustomControls/DebugMonitors.cs
@@ -1,10 +1,11 @@
-using System;
+using System;
using Robust.Client.GameStates;
using Robust.Client.Graphics;
using Robust.Client.Input;
using Robust.Client.Player;
using Robust.Client.Profiling;
using Robust.Client.State;
+using Robust.Client.Timing;
using Robust.Client.UserInterface.Controls;
using Robust.Shared.Configuration;
using Robust.Shared.IoC;
@@ -19,7 +20,7 @@ namespace Robust.Client.UserInterface.CustomControls
private readonly Control[] _monitors = new Control[Enum.GetNames().Length];
//TODO: Think about a factory for this
- public DebugMonitors(IGameTiming gameTiming, IPlayerManager playerManager, IEyeManager eyeManager,
+ public DebugMonitors(IClientGameTiming gameTiming, IPlayerManager playerManager, IEyeManager eyeManager,
IInputManager inputManager, IStateManager stateManager, IClyde displayManager, IClientNetManager netManager,
IMapManager mapManager)
{
diff --git a/Robust.Client/UserInterface/CustomControls/DebugTimePanel.cs b/Robust.Client/UserInterface/CustomControls/DebugTimePanel.cs
index 75dffd029..327a4b072 100644
--- a/Robust.Client/UserInterface/CustomControls/DebugTimePanel.cs
+++ b/Robust.Client/UserInterface/CustomControls/DebugTimePanel.cs
@@ -1,6 +1,7 @@
using System;
using Robust.Client.GameStates;
using Robust.Client.Graphics;
+using Robust.Client.Timing;
using Robust.Client.UserInterface.Controls;
using Robust.Shared.Maths;
using Robust.Shared.Timing;
@@ -10,13 +11,13 @@ namespace Robust.Client.UserInterface.CustomControls
{
public sealed class DebugTimePanel : PanelContainer
{
- private readonly IGameTiming _gameTiming;
+ private readonly IClientGameTiming _gameTiming;
private readonly IClientGameStateManager _gameState;
private readonly char[] _textBuffer = new char[256];
private readonly Label _contents;
- public DebugTimePanel(IGameTiming gameTiming, IClientGameStateManager gameState)
+ public DebugTimePanel(IClientGameTiming gameTiming, IClientGameStateManager gameState)
{
_gameTiming = gameTiming;
_gameState = gameState;
@@ -53,7 +54,7 @@ namespace Robust.Client.UserInterface.CustomControls
// This is why there's a -1 on Pred:.
_contents.TextMemory = FormatHelpers.FormatIntoMem(_textBuffer,
- $@"Paused: {_gameTiming.Paused}, CurTick: {_gameTiming.CurTick}/{_gameTiming.CurTick - 1}, CurServerTick: {_gameState.CurServerTick}, Pred: {_gameTiming.CurTick.Value - _gameState.CurServerTick.Value - 1}
+ $@"Paused: {_gameTiming.Paused}, CurTick: {_gameTiming.CurTick}, LastProcessed: {_gameTiming.LastProcessedTick}, LastRealTick: {_gameTiming.LastRealTick}, Pred: {_gameTiming.CurTick.Value - _gameTiming.LastRealTick.Value - 1}
CurTime: {_gameTiming.CurTime:hh\:mm\:ss\.ff}, RealTime: {_gameTiming.RealTime:hh\:mm\:ss\.ff}, CurFrame: {_gameTiming.CurFrame}
ServerTime: {_gameTiming.ServerTime}, TickTimingAdjustment: {_gameTiming.TickTimingAdjustment}");
}
diff --git a/Robust.Client/UserInterface/UserInterfaceManager.cs b/Robust.Client/UserInterface/UserInterfaceManager.cs
index 7038b8549..42f124645 100644
--- a/Robust.Client/UserInterface/UserInterfaceManager.cs
+++ b/Robust.Client/UserInterface/UserInterfaceManager.cs
@@ -5,6 +5,7 @@ using Robust.Client.Graphics;
using Robust.Client.Input;
using Robust.Client.Player;
using Robust.Client.State;
+using Robust.Client.Timing;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
using Robust.Shared;
@@ -26,7 +27,7 @@ namespace Robust.Client.UserInterface
[Dependency] private readonly IInputManager _inputManager = default!;
[Dependency] private readonly IFontManager _fontManager = default!;
[Dependency] private readonly IClydeInternal _clyde = default!;
- [Dependency] private readonly IGameTiming _gameTiming = default!;
+ [Dependency] private readonly IClientGameTiming _gameTiming = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IEyeManager _eyeManager = default!;
[Dependency] private readonly IStateManager _stateManager = default!;
diff --git a/Robust.Server/BaseServer.cs b/Robust.Server/BaseServer.cs
index 9c6efc63d..e26bab27c 100644
--- a/Robust.Server/BaseServer.cs
+++ b/Robust.Server/BaseServer.cs
@@ -646,9 +646,6 @@ namespace Robust.Server
ServerCurTick.Set(_time.CurTick.Value);
ServerCurTime.Set(_time.CurTime.TotalSeconds);
- // These are always the same on the server, there is no prediction.
- _time.LastRealTick = _time.CurTick;
-
_systemConsole.UpdateTick();
using (TickUsage.WithLabels("PreEngine").NewTimer())
diff --git a/Robust.Server/GameStates/IServerGameStateManager.cs b/Robust.Server/GameStates/IServerGameStateManager.cs
index e320ec207..38e1f6ce5 100644
--- a/Robust.Server/GameStates/IServerGameStateManager.cs
+++ b/Robust.Server/GameStates/IServerGameStateManager.cs
@@ -1,3 +1,7 @@
+using System;
+using Robust.Shared.Players;
+using Robust.Shared.Timing;
+
namespace Robust.Server.GameStates
{
///
@@ -16,5 +20,9 @@ namespace Robust.Server.GameStates
void SendGameStateUpdate();
ushort TransformNetId { get; set; }
+
+ Action? ClientAck { get; set; }
+
+ Action? ClientRequestFull { get; set; }
}
}
diff --git a/Robust.Server/GameStates/PVSEntityVisiblity.cs b/Robust.Server/GameStates/PVSEntityVisiblity.cs
index a97f36452..1759b87bd 100644
--- a/Robust.Server/GameStates/PVSEntityVisiblity.cs
+++ b/Robust.Server/GameStates/PVSEntityVisiblity.cs
@@ -1,4 +1,4 @@
-namespace Robust.Server.GameStates;
+namespace Robust.Server.GameStates;
public enum PVSEntityVisiblity : byte
{
diff --git a/Robust.Server/GameStates/PVSSystem.cs b/Robust.Server/GameStates/PVSSystem.cs
index c7597278e..7a526d1e9 100644
--- a/Robust.Server/GameStates/PVSSystem.cs
+++ b/Robust.Server/GameStates/PVSSystem.cs
@@ -11,10 +11,11 @@ using Robust.Shared.Collections;
using Robust.Shared.Configuration;
using Robust.Shared.Enums;
using Robust.Shared.GameObjects;
-using Robust.Shared.GameStates;
using Robust.Shared.Log;
using Robust.Shared.Map;
using Robust.Shared.Maths;
+using Robust.Shared.Network;
+using Robust.Shared.Network.Messages;
using Robust.Shared.Players;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
@@ -27,15 +28,16 @@ internal sealed partial class PVSSystem : EntitySystem
[Shared.IoC.Dependency] private readonly IPlayerManager _playerManager = default!;
[Shared.IoC.Dependency] private readonly IConfigurationManager _configManager = default!;
[Shared.IoC.Dependency] private readonly IServerEntityManager _serverEntManager = default!;
- [Shared.IoC.Dependency] private readonly IServerGameStateManager _stateManager = default!;
[Shared.IoC.Dependency] private readonly SharedTransformSystem _transform = default!;
[Shared.IoC.Dependency] private readonly INetConfigurationManager _netConfigManager = default!;
+ [Shared.IoC.Dependency] private readonly IServerGameStateManager _serverGameStateManager = default!;
public const float ChunkSize = 8;
- public const int TickBuffer = 10;
- private static TransformComponentState _transformCullState =
- new(Vector2.Zero, Angle.Zero, EntityUid.Invalid, false, false);
+ // TODO make this a cvar. Make it in terms of seconds and tie it to tick rate?
+ public const int TickBuffer = 20;
+ // Note: If a client has ping higher than TickBuffer / TickRate, then the server will treat every entity as if it
+ // had entered PVS for the first time. Note that due to the PVS budget, this buffer is easily overwhelmed.
///
/// Maximum number of pooled objects
@@ -58,18 +60,16 @@ internal sealed partial class PVSSystem : EntitySystem
///
public HashSet SeenAllEnts = new();
- ///
- /// All s a saw last iteration.
- ///
- private readonly Dictionary>> _playerVisibleSets = new();
+ private readonly Dictionary _playerVisibleSets = new();
private PVSCollection _entityPvsCollection = default!;
public PVSCollection EntityPVSCollection => _entityPvsCollection;
+
private readonly List _pvsCollections = new();
private readonly ObjectPool> _visSetPool
= new DefaultObjectPool>(
- new DictPolicy(), MaxVisPoolSize*TickBuffer);
+ new DictPolicy(), MaxVisPoolSize);
private readonly ObjectPool> _uidSetPool
= new DefaultObjectPool>(new SetPolicy(), MaxVisPoolSize);
@@ -97,10 +97,14 @@ internal sealed partial class PVSSystem : EntitySystem
private readonly List<(uint, IChunkIndexLocation)> _chunkList = new(64);
private readonly List _gridsPool = new(8);
+ private ISawmill _sawmill = default!;
+
public override void Initialize()
{
base.Initialize();
+ _sawmill = Logger.GetSawmill("PVS");
+
_entityPvsCollection = RegisterPVSCollection();
SubscribeLocalEvent(ev =>
@@ -123,6 +127,9 @@ internal sealed partial class PVSSystem : EntitySystem
_configManager.OnValueChanged(CVars.NetPVS, SetPvs, true);
_configManager.OnValueChanged(CVars.NetMaxUpdateRange, OnViewsizeChanged, true);
+ _serverGameStateManager.ClientAck += OnClientAck;
+ _serverGameStateManager.ClientRequestFull += OnClientRequestFull;
+
InitializeDirty();
}
@@ -158,9 +165,75 @@ internal sealed partial class PVSSystem : EntitySystem
_configManager.UnsubValueChanged(CVars.NetPVS, SetPvs);
_configManager.UnsubValueChanged(CVars.NetMaxUpdateRange, OnViewsizeChanged);
+ _serverGameStateManager.ClientAck -= OnClientAck;
+ _serverGameStateManager.ClientRequestFull -= OnClientRequestFull;
+
ShutdownDirty();
}
+ private void OnClientRequestFull(ICommonSession session, GameTick tick, GameTick lastAcked)
+ {
+ if (!_playerVisibleSets.TryGetValue(session, out var sessionData))
+ return;
+
+ // TODO rate limit this?
+ _sawmill.Warning($"Client {session} requested full state on tick {tick}. Last Acked: {lastAcked}. They probably encountered a PVS / missing meta-data exception.");
+
+ sessionData.LastSeenAt.Clear();
+
+ if (sessionData.Overflow != null)
+ {
+ _visSetPool.Return(sessionData.Overflow.Value.SentEnts);
+ sessionData.Overflow = null;
+ }
+
+ // return last acked to pool, but only if it is not still in the overflow dictionary.
+ if (sessionData.LastAcked != null && _gameTiming.CurTick.Value - lastAcked.Value > TickBuffer)
+ _visSetPool.Return(sessionData.LastAcked);
+
+ sessionData.RequestedFull = true;
+ }
+
+ private void OnClientAck(ICommonSession session, GameTick ackedTick, GameTick lastAckedTick)
+ {
+ if (!_playerVisibleSets.TryGetValue(session, out var sessionData))
+ return;
+
+ if (sessionData.Overflow != null && sessionData.Overflow.Value.Tick < ackedTick)
+ {
+ var (overflowTick, overflowEnts) = sessionData.Overflow.Value;
+ sessionData.Overflow = null;
+ if (overflowTick == ackedTick)
+ {
+ ProcessAckedTick(sessionData, overflowEnts, ackedTick, lastAckedTick);
+ return;
+ }
+
+ // Even though the acked tick is newer, we have no guarantee that the client received the cached set, so
+ // we just discard it.
+ _visSetPool.Return(overflowEnts);
+ }
+
+ if (sessionData.SentEntities.TryGetValue(ackedTick, out var ackedData))
+ ProcessAckedTick(sessionData, ackedData, ackedTick, lastAckedTick);
+ }
+
+ private void ProcessAckedTick(SessionPVSData sessionData, Dictionary ackedData, GameTick tick, GameTick lastAckedTick)
+ {
+ // return last acked to pool, but only if it is not still in the overflow dictionary.
+ if (sessionData.LastAcked != null && _gameTiming.CurTick.Value - lastAckedTick.Value > TickBuffer)
+ _visSetPool.Return(sessionData.LastAcked);
+
+ sessionData.LastAcked = ackedData;
+ foreach (var ent in ackedData.Keys)
+ {
+ sessionData.LastSeenAt[ent] = tick;
+ }
+
+ // The client acked a tick. If they requested a full state, this ack happened some time after that, so we can safely set this to false
+ sessionData.RequestedFull = false;
+ }
+
private void OnViewsizeChanged(float obj)
{
_viewSize = obj * 2;
@@ -171,7 +244,6 @@ internal sealed partial class PVSSystem : EntitySystem
CullingEnabled = value;
}
-
public void ProcessCollections()
{
foreach (var collection in _pvsCollections)
@@ -228,6 +300,15 @@ internal sealed partial class PVSSystem : EntitySystem
private void OnEntityDeleted(EntityUid e)
{
_entityPvsCollection.RemoveIndex(EntityManager.CurrentTick, e);
+
+ var previousTick = _gameTiming.CurTick - 1;
+
+ foreach (var sessionData in _playerVisibleSets.Values)
+ {
+ sessionData.LastSeenAt.Remove(e);
+ if (sessionData.SentEntities.TryGetValue(previousTick, out var ents))
+ ents.Remove(e);
+ }
}
private void OnEntityMove(ref MoveEvent ev)
@@ -269,24 +350,35 @@ internal sealed partial class PVSSystem : EntitySystem
{
if (e.NewStatus == SessionStatus.InGame)
{
- _playerVisibleSets.Add(e.Session, new OverflowDictionary>(TickBuffer, _visSetPool.Return));
+ _playerVisibleSets.Add(e.Session, new());
foreach (var pvsCollection in _pvsCollections)
{
pvsCollection.AddPlayer(e.Session);
}
+ return;
}
- else if (e.NewStatus == SessionStatus.Disconnected)
+
+ if (e.NewStatus != SessionStatus.Disconnected)
+ return;
+
+ foreach (var pvsCollection in _pvsCollections)
{
- var overflowDict = _playerVisibleSets[e.Session];
- _playerVisibleSets.Remove(e.Session);
- foreach (var (_, playerVisSet) in overflowDict)
- {
- _visSetPool.Return(playerVisSet);
- }
- foreach (var pvsCollection in _pvsCollections)
- {
- pvsCollection.RemovePlayer(e.Session);
- }
+ pvsCollection.RemovePlayer(e.Session);
+ }
+
+ if (!_playerVisibleSets.Remove(e.Session, out var data))
+ return;
+
+ if (data.Overflow != null)
+ _visSetPool.Return(data.Overflow.Value.SentEnts);
+
+ if (data.LastAcked != null)
+ _visSetPool.Return(data.LastAcked);
+
+ foreach (var (_, visSet) in data.SentEntities)
+ {
+ if (visSet != data.LastAcked)
+ _visSetPool.Return(visSet);
}
}
@@ -566,7 +658,7 @@ internal sealed partial class PVSSystem : EntitySystem
return true;
}
- public (List? updates, List? deletions) CalculateEntityStates(IPlayerSession session,
+ public (List? updates, List? deletions, List? leftPvs, GameTick fromTick) CalculateEntityStates(IPlayerSession session,
GameTick fromTick, GameTick toTick,
(Dictionary metadata, RobustTree tree)?[] chunkCache,
HashSet chunkIndices, EntityQuery mQuery, EntityQuery tQuery,
@@ -575,8 +667,13 @@ internal sealed partial class PVSSystem : EntitySystem
DebugTools.Assert(session.Status == SessionStatus.InGame);
var enteredEntityBudget = _netConfigManager.GetClientCVar(session.ConnectedClient, CVars.NetPVSEntityBudget);
var entitiesSent = 0;
- _playerVisibleSets[session].TryGetValue(fromTick, out var playerVisibleSet);
+ var sessionData = _playerVisibleSets[session];
+ sessionData.SentEntities.TryGetValue(toTick - 1, out var lastSent);
+ var lastAcked = sessionData.LastAcked;
+ var lastSeen = sessionData.LastSeenAt;
var visibleEnts = _visSetPool.Get();
+ DebugTools.Assert(visibleEnts.Count == 0);
+
var deletions = _entityPvsCollection.GetDeletedIndices(fromTick);
foreach (var i in chunkIndices)
@@ -585,7 +682,7 @@ internal sealed partial class PVSSystem : EntitySystem
if(!cache.HasValue) continue;
foreach (var rootNode in cache.Value.tree.RootNodes)
{
- RecursivelyAddTreeNode(in rootNode, cache.Value.tree, playerVisibleSet, visibleEnts, fromTick,
+ RecursivelyAddTreeNode(in rootNode, cache.Value.tree, lastAcked, lastSent, visibleEnts, fromTick,
ref entitiesSent, cache.Value.metadata, in enteredEntityBudget);
}
}
@@ -594,7 +691,7 @@ internal sealed partial class PVSSystem : EntitySystem
while (globalEnumerator.MoveNext())
{
var uid = globalEnumerator.Current;
- RecursivelyAddOverride(in uid, playerVisibleSet, visibleEnts, fromTick,
+ RecursivelyAddOverride(in uid, lastAcked, lastSent, visibleEnts, fromTick,
ref entitiesSent, mQuery, tQuery, in enteredEntityBudget);
}
globalEnumerator.Dispose();
@@ -603,14 +700,14 @@ internal sealed partial class PVSSystem : EntitySystem
while (localEnumerator.MoveNext())
{
var uid = localEnumerator.Current;
- RecursivelyAddOverride(in uid, playerVisibleSet, visibleEnts, fromTick,
+ RecursivelyAddOverride(in uid, lastAcked, lastSent, visibleEnts, fromTick,
ref entitiesSent, mQuery, tQuery, in enteredEntityBudget);
}
localEnumerator.Dispose();
foreach (var viewerEntity in viewerEntities)
{
- RecursivelyAddOverride(in viewerEntity, playerVisibleSet, visibleEnts, fromTick,
+ RecursivelyAddOverride(in viewerEntity, lastAcked, lastSent, visibleEnts, fromTick,
ref entitiesSent, mQuery, tQuery, in enteredEntityBudget);
}
@@ -618,56 +715,87 @@ internal sealed partial class PVSSystem : EntitySystem
RaiseLocalEvent(ref expandEvent);
foreach (var entityUid in expandEvent.Entities)
{
- RecursivelyAddOverride(in entityUid, playerVisibleSet, visibleEnts, fromTick,
+ RecursivelyAddOverride(in entityUid, lastAcked, lastSent, visibleEnts, fromTick,
ref entitiesSent, mQuery, tQuery, in enteredEntityBudget);
}
var entityStates = new List();
- foreach (var (entityUid, visiblity) in visibleEnts)
+ foreach (var (uid, visiblity) in visibleEnts)
{
+ if (sessionData.RequestedFull)
+ {
+ entityStates.Add(GetFullEntityState(session, uid, mQuery.GetComponent(uid)));
+ continue;
+ }
+
if (visiblity == PVSEntityVisiblity.StayedUnchanged)
continue;
- var @new = visiblity == PVSEntityVisiblity.Entered;
- var state = GetEntityState(session, entityUid, @new ? GameTick.Zero : fromTick, mQuery.GetComponent(entityUid).Flags);
+ var entered = visiblity == PVSEntityVisiblity.Entered;
+ var entFromTick = entered ? lastSeen.GetValueOrDefault(uid) : fromTick;
+ var state = GetEntityState(session, uid, entFromTick, mQuery.GetComponent(uid));
- //this entity is not new & nothing changed
- if(!@new && state.Empty) continue;
-
- entityStates.Add(state);
+ if (entered || !state.Empty)
+ entityStates.Add(state);
}
- if(playerVisibleSet != null)
+ // tell a client to detach entities that have left their view
+ var leftView = ProcessLeavePVS(visibleEnts, lastSent);
+
+ if (sessionData.SentEntities.Add(toTick, visibleEnts, out var oldEntry))
{
- foreach (var (entityUid, _) in playerVisibleSet)
+ if (oldEntry.Value.Key > fromTick && sessionData.Overflow == null)
{
- // it was deleted, so we dont need to exit pvs
- if (deletions.Contains(entityUid)) continue;
+ // The clients last ack is too late, the overflow dictionary size has been exceeded, and we will no
+ // longer have information about the sent entities. This means we would no longer be able to add
+ // entities to _ackedEnts.
+ //
+ // If the client has enough latency, this result in a situation where we must constantly assume that every entity
+ // that needs to get sent to the client is being received by them for the first time.
+ //
+ // In order to avoid this, while also keeping the overflow dictionary limited in size, we keep a single
+ // overflow state, so we can at least periodically update the acked entities.
- //TODO: HACK: somehow an entity left the view, transform does not exist (deleted?), but was not in the
- // deleted list. This seems to happen with the map entity on round restart.
- if (!EntityManager.EntityExists(entityUid))
- continue;
-
- entityStates.Add(new EntityState(entityUid, new NetListAsArray(new[]
- {
- ComponentChange.Changed(_stateManager.TransformNetId, _transformCullState),
- }), true));
+ // This is pretty shit and there is probably a better way of doing this.
+ sessionData.Overflow = oldEntry.Value;
+ _sawmill.Warning($"Client {session} exceeded tick buffer.");
}
+ else if (oldEntry.Value.Value != lastAcked)
+ _visSetPool.Return(oldEntry.Value.Value);
}
- _playerVisibleSets[session].Add(toTick, visibleEnts);
-
if (deletions.Count == 0) deletions = default;
if (entityStates.Count == 0) entityStates = default;
- return (entityStates, deletions);
+ return (entityStates, deletions, leftView, sessionData.RequestedFull ? GameTick.Zero : fromTick);
+ }
+
+ ///
+ /// Figure out what entities are no longer visible to the client. These entities are sent reliably to the client
+ /// in a separate net message.
+ ///
+ private List? ProcessLeavePVS(
+ Dictionary visibleEnts,
+ Dictionary? lastSent)
+ {
+ if (lastSent == null)
+ return null;
+
+ var leftView = new List();
+ foreach (var uid in lastSent.Keys)
+ {
+ if (!visibleEnts.ContainsKey(uid))
+ leftView.Add(uid);
+ }
+
+ return leftView.Count > 0 ? leftView : null;
}
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
- private void RecursivelyAddTreeNode(in EntityUid nodeIndex,
+ private bool RecursivelyAddTreeNode(in EntityUid nodeIndex,
RobustTree tree,
- Dictionary? previousVisibleEnts,
+ Dictionary? lastAcked,
+ Dictionary? lastSent,
Dictionary toSend,
GameTick fromTick,
ref int totalEnteredEntities,
@@ -683,12 +811,12 @@ internal sealed partial class PVSSystem : EntitySystem
if (nodeIndex.IsValid() && !toSend.ContainsKey(nodeIndex))
{
//are we new?
- var (entered, budgetFail) = ProcessEntry(in nodeIndex, previousVisibleEnts,
+ var (entered, budgetFull) = ProcessEntry(in nodeIndex, lastAcked, lastSent,
ref totalEnteredEntities, in enteredEntityBudget);
- if (budgetFail) return;
-
AddToSendSet(in nodeIndex, metaDataCache[nodeIndex], toSend, fromTick, entered);
+
+ if (budgetFull) return true;
}
var node = tree[nodeIndex];
@@ -697,15 +825,19 @@ internal sealed partial class PVSSystem : EntitySystem
{
foreach (var child in node.Children)
{
- RecursivelyAddTreeNode(in child, tree, previousVisibleEnts, toSend, fromTick,
- ref totalEnteredEntities, metaDataCache, in enteredEntityBudget);
+ if (RecursivelyAddTreeNode(in child, tree, lastAcked, lastSent, toSend, fromTick,
+ ref totalEnteredEntities, metaDataCache, in enteredEntityBudget))
+ return true;
}
}
+
+ return false;
}
public bool RecursivelyAddOverride(
in EntityUid uid,
- Dictionary? previousVisibleEnts,
+ Dictionary? lastAcked,
+ Dictionary? lastSent,
Dictionary toSend,
GameTick fromTick,
ref int totalEnteredEntities,
@@ -721,29 +853,40 @@ internal sealed partial class PVSSystem : EntitySystem
if (toSend.ContainsKey(uid)) return true;
var parent = transQuery.GetComponent(uid).ParentUid;
- if (parent.IsValid() && !RecursivelyAddOverride(in parent, previousVisibleEnts, toSend, fromTick,
+ if (parent.IsValid() && !RecursivelyAddOverride(in parent, lastAcked, lastSent, toSend, fromTick,
ref totalEnteredEntities, metaQuery, transQuery, in enteredEntityBudget))
return false;
- var (entered, _) = ProcessEntry(in uid, previousVisibleEnts,
+ var (entered, _) = ProcessEntry(in uid, lastAcked, lastSent,
ref totalEnteredEntities, in enteredEntityBudget);
AddToSendSet(in uid, metaQuery.GetComponent(uid), toSend, fromTick, entered);
return true;
}
- private (bool entered, bool budgetFail) ProcessEntry(in EntityUid uid,
- Dictionary? previousVisibleEnts,
+ private (bool entering, bool budgetFull) ProcessEntry(in EntityUid uid,
+ Dictionary? lastAcked,
+ Dictionary? lastSent,
ref int totalEnteredEntities, in int enteredEntityBudget)
{
- var entered = previousVisibleEnts?.Remove(uid) == false;
+ var enteredSinceLastSent = lastSent == null || !lastSent.ContainsKey(uid);
- if (entered)
+ var entered = enteredSinceLastSent || // OR, entered since last ack:
+ lastAcked == null || !lastAcked.ContainsKey(uid);
+
+ // If the entity is entering, but we already sent this entering entity, in the last message, we won't add it to
+ // the budget. Chances are the packet will arrive in a nice and orderly fashion, and the client will stick to
+ // their requested budget. However this can cause issues if a packet gets dropped, because a player may create
+ // 2x or more times the normal entity creation budget.
+ //
+ // The fix for that would be to just also give the PVS budget a client-side aspect that controls entity creation
+ // rate.
+ if (enteredSinceLastSent)
{
- if (totalEnteredEntities >= enteredEntityBudget)
+ // TODO: should we separate this budget into "entered-but-seen" and "completely-new"?
+ // completely new entities are significantly more intensive for both server sending and client processing.
+ if (totalEnteredEntities++ >= enteredEntityBudget)
return (entered, true);
-
- totalEnteredEntities++;
}
return (entered, false);
@@ -757,7 +900,7 @@ internal sealed partial class PVSSystem : EntitySystem
return;
}
- if (metaDataComponent.EntityLastModifiedTick < fromTick)
+ if (metaDataComponent.EntityLastModifiedTick <= fromTick)
{
//entity has been sent before and hasnt been updated since
toSend.Add(uid, PVSEntityVisiblity.StayedUnchanged);
@@ -771,7 +914,7 @@ internal sealed partial class PVSSystem : EntitySystem
///
/// Gets all entity states that have been modified after and including the provided tick.
///
- public (List? updates, List? deletions) GetAllEntityStates(ICommonSession player, GameTick fromTick, GameTick toTick)
+ public (List?, List?, List?, GameTick fromTick) GetAllEntityStates(ICommonSession player, GameTick fromTick, GameTick toTick)
{
var deletions = _entityPvsCollection.GetDeletedIndices(fromTick);
// no point sending an empty collection
@@ -791,10 +934,10 @@ internal sealed partial class PVSSystem : EntitySystem
foreach (var md in EntityManager.EntityQuery(true))
{
DebugTools.Assert(md.EntityLifeStage >= EntityLifeStage.Initialized);
- stateEntities.Add(GetEntityState(player, md.Owner, GameTick.Zero, md.Flags));
+ stateEntities.Add(GetEntityState(player, md.Owner, GameTick.Zero, md));
}
- return (stateEntities.Count == 0 ? default : stateEntities, deletions);
+ return (stateEntities.Count == 0 ? default : stateEntities, deletions, null, fromTick);
}
// Just get the relevant entities that have been dirtied
@@ -819,8 +962,8 @@ internal sealed partial class PVSSystem : EntitySystem
DebugTools.Assert(md.EntityLifeStage >= EntityLifeStage.Initialized);
- if (md.EntityLastModifiedTick >= fromTick)
- stateEntities.Add(GetEntityState(player, uid, GameTick.Zero, md.Flags));
+ if (md.EntityLastModifiedTick > fromTick)
+ stateEntities.Add(GetEntityState(player, uid, GameTick.Zero, md));
}
foreach (var uid in dirty)
@@ -832,8 +975,8 @@ internal sealed partial class PVSSystem : EntitySystem
DebugTools.Assert(md.EntityLifeStage >= EntityLifeStage.Initialized);
- if (md.EntityLastModifiedTick >= fromTick)
- stateEntities.Add(GetEntityState(player, uid, fromTick, md.Flags));
+ if (md.EntityLastModifiedTick > fromTick)
+ stateEntities.Add(GetEntityState(player, uid, fromTick, md));
}
}
}
@@ -842,7 +985,7 @@ internal sealed partial class PVSSystem : EntitySystem
{
if (stateEntities.Count == 0) stateEntities = default;
- return (stateEntities, deletions);
+ return (stateEntities, deletions, null, fromTick);
}
stateEntities = new List(EntityManager.EntityCount);
@@ -853,13 +996,13 @@ internal sealed partial class PVSSystem : EntitySystem
DebugTools.Assert(md.EntityLifeStage >= EntityLifeStage.Initialized);
if (md.EntityLastModifiedTick >= fromTick)
- stateEntities.Add(GetEntityState(player, md.Owner, fromTick, md.Flags));
+ stateEntities.Add(GetEntityState(player, md.Owner, fromTick, md));
}
// no point sending an empty collection
if (stateEntities.Count == 0) stateEntities = default;
- return (stateEntities, deletions);
+ return (stateEntities, deletions, null, fromTick);
}
///
@@ -868,20 +1011,27 @@ internal sealed partial class PVSSystem : EntitySystem
/// The player to generate this state for.
/// Uid of the entity to generate the state from.
/// Only provide delta changes from this tick.
- /// Any applicable metadata flags
+ /// The entity's metadata component
+ /// If true, the state will include even the implicit component data
/// New entity State for the given entity.
- private EntityState GetEntityState(ICommonSession player, EntityUid entityUid, GameTick fromTick, MetaDataFlags flags)
+ private EntityState GetEntityState(ICommonSession player, EntityUid entityUid, GameTick fromTick, MetaDataComponent meta)
{
var bus = EntityManager.EventBus;
var changed = new List();
- // Whether this entity has any component states that are only for a specific session.
- // TODO: This GetComp is probably expensive, less expensive than before, but ideally we'd cache it somewhere or something from a previous getcomp
- // Probably still needs tweaking but checking for add / changed states up front should do most of the work.
- var specificStates = (flags & MetaDataFlags.EntitySpecific) == MetaDataFlags.EntitySpecific;
+
+ // Whether this entity has any component states that should only be sent to specific sessions.
+ var entitySpecific = (meta.Flags & MetaDataFlags.EntitySpecific) == MetaDataFlags.EntitySpecific;
foreach (var (netId, component) in EntityManager.GetNetComponents(entityUid))
{
- DebugTools.Assert(component.Initialized);
+ if (!component.NetSyncEnabled)
+ continue;
+
+ if (component.Deleted || !component.Initialized)
+ {
+ _sawmill.Error("Entity manager returned deleted or uninitialized components while sending entity data");
+ continue;
+ }
// NOTE: When LastModifiedTick or CreationTick are 0 it means that the relevant data is
// "not different from entity creation".
@@ -892,37 +1042,20 @@ internal sealed partial class PVSSystem : EntitySystem
DebugTools.Assert(component.LastModifiedTick >= component.CreationTick);
- var addState = false;
- var changeState = false;
+ var addState = component.CreationTick != GameTick.Zero && component.CreationTick > fromTick;
+ var changedState = component.LastModifiedTick != GameTick.Zero && component.LastModifiedTick > fromTick;
- // We'll check the properties first; if we ever have specific states then doing the struct event is expensive.
- if (component.CreationTick != GameTick.Zero && component.CreationTick >= fromTick && !component.Deleted)
- addState = true;
- else if (component.NetSyncEnabled && component.LastModifiedTick != GameTick.Zero && component.LastModifiedTick >= fromTick)
- changeState = true;
-
- if (!addState && !changeState)
+ if (!(addState || changedState))
continue;
- if (specificStates && !EntityManager.CanGetComponentState(bus, component, player))
+ if (component.SendOnlyToOwner && player.AttachedEntity != component.Owner)
continue;
- if (addState)
- {
- ComponentState? state = null;
- if (component.NetSyncEnabled && component.LastModifiedTick != GameTick.Zero &&
- component.LastModifiedTick >= fromTick)
- state = EntityManager.GetComponentState(bus, component);
+ if (entitySpecific && !EntityManager.CanGetComponentState(bus, component, player))
+ continue;
- // Can't be null since it's returned by GetNetComponents
- // ReSharper disable once PossibleInvalidOperationException
- changed.Add(ComponentChange.Added(netId, state));
- }
- else
- {
- DebugTools.Assert(changeState);
- changed.Add(ComponentChange.Changed(netId, EntityManager.GetComponentState(bus, component)));
- }
+ var state = changedState ? EntityManager.GetComponentState(bus, component) : null;
+ changed.Add(ComponentChange.Added(netId, state, component.LastModifiedTick));
}
foreach (var netId in _serverEntManager.GetDeletedComponents(entityUid, fromTick))
@@ -930,7 +1063,38 @@ internal sealed partial class PVSSystem : EntitySystem
changed.Add(ComponentChange.Removed(netId));
}
- return new EntityState(entityUid, changed.ToArray());
+ return new EntityState(entityUid, changed.ToArray(), meta.EntityLastModifiedTick);
+ }
+
+ ///
+ /// Variant of that includes all entity data, including data that can be inferred implicitly from the entity prototype.
+ ///
+ private EntityState GetFullEntityState(ICommonSession player, EntityUid entityUid, MetaDataComponent meta)
+ {
+ var bus = EntityManager.EventBus;
+ var changed = new List();
+ var entitySpecific = (meta.Flags & MetaDataFlags.EntitySpecific) == MetaDataFlags.EntitySpecific;
+
+ foreach (var (netId, component) in EntityManager.GetNetComponents(entityUid))
+ {
+ if (!component.NetSyncEnabled)
+ continue;
+
+ if (component.SendOnlyToOwner && player.AttachedEntity != component.Owner)
+ continue;
+
+ if (entitySpecific && !EntityManager.CanGetComponentState(bus, component, player))
+ continue;
+
+ changed.Add(ComponentChange.Added(netId, EntityManager.GetComponentState(bus, component), component.LastModifiedTick));
+ }
+
+ foreach (var netId in _serverEntManager.GetDeletedComponents(entityUid, GameTick.Zero))
+ {
+ changed.Add(ComponentChange.Removed(netId));
+ }
+
+ return new EntityState(entityUid, changed.ToArray(), meta.EntityLastModifiedTick);
}
private EntityUid[] GetSessionViewers(ICommonSession session)
@@ -1030,6 +1194,39 @@ internal sealed partial class PVSSystem : EntitySystem
return true;
}
}
+
+ ///
+ /// Session data class used to avoid having to lock session dictionaries.
+ ///
+ private sealed class SessionPVSData
+ {
+ ///
+ /// All s that this session saw during the last ticks.
+ ///
+ public readonly OverflowDictionary> SentEntities = new(TickBuffer);
+
+ ///
+ /// The most recently acked entities
+ ///
+ public Dictionary? LastAcked = new();
+
+ ///
+ /// Stores the last tick at which a given entity was acked by a player. Used to avoid re-sending the whole entity
+ /// state when an item re-enters PVS.
+ ///
+ public readonly Dictionary LastSeenAt = new();
+
+ ///
+ /// overflow in case a player's last ack is more than ticks behind the current tick.
+ ///
+ public (GameTick Tick, Dictionary SentEnts)? Overflow;
+
+ ///
+ /// If true, the client has explicitly requested a full state. Unlike the first state, we will send them
+ /// all data, not just data that cannot be implicitly inferred from entity prototypes.
+ ///
+ public bool RequestedFull = false;
+ }
}
[ByRefEvent]
diff --git a/Robust.Server/GameStates/ServerGameStateManager.cs b/Robust.Server/GameStates/ServerGameStateManager.cs
index b5df781ad..a93735308 100644
--- a/Robust.Server/GameStates/ServerGameStateManager.cs
+++ b/Robust.Server/GameStates/ServerGameStateManager.cs
@@ -22,6 +22,7 @@ using Robust.Shared.Timing;
using Robust.Shared.Utility;
using SharpZstd.Interop;
using Microsoft.Extensions.ObjectPool;
+using Robust.Shared.Players;
namespace Robust.Server.GameStates
{
@@ -51,6 +52,9 @@ namespace Robust.Server.GameStates
public ushort TransformNetId { get; set; }
+ public Action? ClientAck { get; set; }
+ public Action? ClientRequestFull { get; set; }
+
public void PostInject()
{
_logger = Logger.GetSawmill("PVS");
@@ -60,7 +64,9 @@ namespace Robust.Server.GameStates
public void Initialize()
{
_networkManager.RegisterNetMessage();
+ _networkManager.RegisterNetMessage();
_networkManager.RegisterNetMessage(HandleStateAck);
+ _networkManager.RegisterNetMessage(HandleFullStateRequest);
_networkManager.Connected += HandleClientConnected;
_networkManager.Disconnect += HandleClientDisconnect;
@@ -118,10 +124,7 @@ namespace Robust.Server.GameStates
private void HandleClientConnected(object? sender, NetChannelArgs e)
{
- if (!_ackedStates.ContainsKey(e.Channel.ConnectionId))
- _ackedStates.Add(e.Channel.ConnectionId, GameTick.Zero);
- else
- _ackedStates[e.Channel.ConnectionId] = GameTick.Zero;
+ _ackedStates[e.Channel.ConnectionId] = GameTick.Zero;
}
private void HandleClientDisconnect(object? sender, NetChannelArgs e)
@@ -129,38 +132,36 @@ namespace Robust.Server.GameStates
_ackedStates.Remove(e.Channel.ConnectionId);
}
- private void HandleStateAck(MsgStateAck msg)
+ private void HandleFullStateRequest(MsgStateRequestFull msg)
{
- Ack(msg.MsgChannel.ConnectionId, msg.Sequence);
+ if (!_playerManager.TryGetSessionById(msg.MsgChannel.UserId, out var session) ||
+ !_ackedStates.TryGetValue(msg.MsgChannel.ConnectionId, out var lastAcked))
+ return;
+
+ ClientRequestFull?.Invoke(session, msg.Tick, lastAcked);
+
+ // Update acked tick so that OnClientAck doesn't get invoked by any late acks.
+ _ackedStates[msg.MsgChannel.ConnectionId] = msg.Tick;
}
- private void Ack(long uniqueIdentifier, GameTick stateAcked)
+ private void HandleStateAck(MsgStateAck msg)
{
- DebugTools.Assert(_networkManager.IsServer);
+ if (_playerManager.TryGetSessionById(msg.MsgChannel.UserId, out var session))
+ Ack(msg.MsgChannel.ConnectionId, msg.Sequence, session);
+ }
- if (_ackedStates.TryGetValue(uniqueIdentifier, out var lastAck))
- {
- if (stateAcked > lastAck) // most of the time this is true
- {
- _ackedStates[uniqueIdentifier] = stateAcked;
- }
- else if (stateAcked == GameTick.Zero) // client signaled they need a full state
- {
- //Performance/Abuse: Should this be rate limited?
- _ackedStates[uniqueIdentifier] = GameTick.Zero;
- }
+ private void Ack(long uniqueIdentifier, GameTick stateAcked, IPlayerSession playerSession)
+ {
+ if (!_ackedStates.TryGetValue(uniqueIdentifier, out var lastAck) || stateAcked <= lastAck)
+ return;
- //else stateAcked was out of order or client is being silly, just ignore
- }
- else
- DebugTools.Assert("How did the client send us an ack without being connected?");
+ ClientAck?.Invoke(playerSession, stateAcked, lastAck);
+ _ackedStates[uniqueIdentifier] = stateAcked;
}
///
public void SendGameStateUpdate()
{
- DebugTools.Assert(_networkManager.IsServer);
-
if (!_networkManager.IsConnected)
{
// Prevent deletions piling up if we have no clients.
@@ -257,7 +258,7 @@ namespace Robust.Server.GameStates
DebugTools.Assert("Why does this channel not have an entry?");
}
- var (entStates, deletions) = _pvs.CullingEnabled
+ var (entStates, deletions, leftPvs, fromTick) = _pvs.CullingEnabled
? _pvs.CalculateEntityStates(session, lastAck, _gameTiming.CurTick, chunkCache,
playerChunks[sessionIndex], metadataQuery, transformQuery, viewerEntities[sessionIndex])
: _pvs.GetAllEntityStates(session, lastAck, _gameTiming.CurTick);
@@ -267,7 +268,7 @@ namespace Robust.Server.GameStates
// lastAck varies with each client based on lag and such, we can't just make 1 global state and send it to everyone
var lastInputCommand = inputSystem.GetLastInputCommand(session);
var lastSystemMessage = _entityNetworkManager.GetLastMessageSequence(session);
- var state = new GameState(lastAck, _gameTiming.CurTick, Math.Max(lastInputCommand, lastSystemMessage),
+ var state = new GameState(fromTick, _gameTiming.CurTick, Math.Max(lastInputCommand, lastSystemMessage),
entStates, playerStates, deletions, mapData);
InterlockedHelper.Min(ref oldestAckValue, lastAck.Value);
@@ -277,6 +278,8 @@ namespace Robust.Server.GameStates
stateUpdateMessage.State = state;
stateUpdateMessage.CompressionContext = resources.CompressionContext;
+ _networkManager.ServerSendMessage(stateUpdateMessage, channel);
+
// If the state is too big we let Lidgren send it reliably.
// This is to avoid a situation where a state is so large that it consistently gets dropped
// (or, well, part of it).
@@ -286,11 +289,15 @@ namespace Robust.Server.GameStates
// TODO: remove this lock by having a single state object per session that contains all per-session state needed.
lock (_ackedStates)
{
- _ackedStates[channel.ConnectionId] = _gameTiming.CurTick;
+ Ack(channel.ConnectionId, _gameTiming.CurTick, session);
}
}
- _networkManager.ServerSendMessage(stateUpdateMessage, channel);
+ // separately, we send PVS detach / left-view messages reliably. This is not resistant to packet loss,
+ // but unlike game state it doesn't really matter. This also significantly reduces the size of game
+ // state messages PVS chunks move out of view.
+ if (leftPvs != null && leftPvs.Count > 0)
+ _networkManager.ServerSendMessage(new MsgStateLeavePvs() { Entities = leftPvs, Tick = _gameTiming.CurTick }, channel);
}
if (_pvs.CullingEnabled)
diff --git a/Robust.Shared/CVars.cs b/Robust.Shared/CVars.cs
index 437111342..474c175e8 100644
--- a/Robust.Shared/CVars.cs
+++ b/Robust.Shared/CVars.cs
@@ -70,13 +70,13 @@ namespace Robust.Shared
/// Whether to interpolate between server game states for render frames on the client.
///
public static readonly CVarDef NetInterp =
- CVarDef.Create("net.interp", true, CVar.ARCHIVE);
+ CVarDef.Create("net.interp", true, CVar.ARCHIVE | CVar.CLIENTONLY);
///
- /// The target number of game states to keep buffered up to smooth out against network inconsistency.
+ /// The target number of game states to keep buffered up to smooth out network inconsistency.
///
- public static readonly CVarDef NetInterpRatio =
- CVarDef.Create("net.interp_ratio", 0, CVar.ARCHIVE);
+ public static readonly CVarDef NetBufferSize =
+ CVarDef.Create("net.buffer_size", 0, CVar.ARCHIVE | CVar.CLIENTONLY);
///
/// Enable verbose game state/networking logging.
@@ -137,6 +137,12 @@ namespace Robust.Shared
public static readonly CVarDef NetPVSEntityBudget =
CVarDef.Create("net.pvs_budget", 50, CVar.ARCHIVE | CVar.REPLICATED);
+ ///
+ /// The amount of pvs-exiting entities that a client will process in a single tick.
+ ///
+ public static readonly CVarDef NetPVSEntityExitBudget =
+ CVarDef.Create("net.pvs_exit_budget", 75, CVar.ARCHIVE | CVar.CLIENTONLY);
+
///
/// ZSTD compression level to use when compressing game states.
///
diff --git a/Robust.Shared/Collections/OverflowDictionary.cs b/Robust.Shared/Collections/OverflowDictionary.cs
index c37c923b9..5f1f2514f 100644
--- a/Robust.Shared/Collections/OverflowDictionary.cs
+++ b/Robust.Shared/Collections/OverflowDictionary.cs
@@ -1,9 +1,7 @@
-
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
-using Robust.Shared.Utility;
namespace Robust.Shared.Collections;
@@ -76,6 +74,36 @@ public sealed class OverflowDictionary : IDictionary
}
}
+ ///
+ /// Variant of that also returns any entry that was removed to make room for the new entry.
+ ///
+ public bool Add(TKey key, TValue value, [NotNullWhen(true)] out (TKey Key, TValue Value)? old)
+ {
+ if (_dict.ContainsKey(key))
+ throw new InvalidOperationException("Tried inserting duplicate key.");
+
+ if (Count == Capacity)
+ {
+ var startIndex = GetArrayStartIndex();
+ var entry = _insertionQueue[startIndex];
+ _dict.Remove(entry, out var oldValue);
+ Array.Clear(_insertionQueue, startIndex, 1);
+ _valueDisposer?.Invoke(oldValue!);
+ old = (entry, oldValue!);
+ }
+ else
+ old = null;
+
+ _dict.Add(key, value);
+ _insertionQueue[_currentIndex++] = key;
+ if (_currentIndex == Capacity)
+ {
+ _currentIndex = 0;
+ }
+
+ return old != null;
+ }
+
public bool Remove(TKey key)
{
//it doesnt make sense for my usecase so i left this unimplemented. i cba to bother with moving all the entries in the array around etc.
diff --git a/Robust.Shared/Configuration/NetConfigurationManager.cs b/Robust.Shared/Configuration/NetConfigurationManager.cs
index fcc05a138..317cb91f7 100644
--- a/Robust.Shared/Configuration/NetConfigurationManager.cs
+++ b/Robust.Shared/Configuration/NetConfigurationManager.cs
@@ -139,7 +139,7 @@ namespace Robust.Shared.Configuration
///
public void TickProcessMessages()
{
- if(!_timing.InSimulation || _timing.InPrediction)
+ if (!_timing.InSimulation || _timing.InPrediction)
return;
// _netVarsMessages is not in any particular ordering.
@@ -150,7 +150,7 @@ namespace Robust.Shared.Configuration
{
var msg = _netVarsMessages[i];
- if (msg.Tick > _timing.LastRealTick)
+ if (msg.Tick > _timing.CurTick)
continue;
toApply.Add(msg);
@@ -168,8 +168,8 @@ namespace Robust.Shared.Configuration
{
ApplyNetVarChange(msg.MsgChannel, msg.NetworkedVars, msg.Tick);
- if(msg.Tick != default && msg.Tick < _timing.LastRealTick)
- _sawmill.Warning($"{msg.MsgChannel}: Received late nwVar message ({msg.Tick} < {_timing.LastRealTick} ).");
+ if(msg.Tick != default && msg.Tick < _timing.CurTick)
+ _sawmill.Warning($"{msg.MsgChannel}: Received late nwVar message ({msg.Tick} < {_timing.CurTick} ).");
}
}
diff --git a/Robust.Shared/GameObjects/Component.cs b/Robust.Shared/GameObjects/Component.cs
index 3c82365fa..c961adfb4 100644
--- a/Robust.Shared/GameObjects/Component.cs
+++ b/Robust.Shared/GameObjects/Component.cs
@@ -22,9 +22,9 @@ namespace Robust.Shared.GameObjects
public virtual string Name => IoCManager.Resolve().GetComponentName(GetType());
///
- [ViewVariables]
[DataField("netsync")]
- public bool NetSyncEnabled { get; set; } = true;
+ public bool NetSyncEnabled { get; } = true;
+ //readonly. If you want to make it writable, you need to add the component to the entity's net-components
///
[ViewVariables]
@@ -34,6 +34,13 @@ namespace Robust.Shared.GameObjects
[ViewVariables]
public ComponentLifeStage LifeStage { get; private set; } = ComponentLifeStage.PreAdd;
+ ///
+ /// If true, and if this is a networked component, then component data will only be sent to players if their
+ /// controlled entity is the owner of this component. This is a faster alternative to .
+ ///
+ public virtual bool SendOnlyToOwner => false;
+
///
/// Increases the life stage from to ,
/// after raising a event.
diff --git a/Robust.Shared/GameObjects/Components/MetaDataComponent.cs b/Robust.Shared/GameObjects/Components/MetaDataComponent.cs
index 0fce3db44..0c5ef6cfd 100644
--- a/Robust.Shared/GameObjects/Components/MetaDataComponent.cs
+++ b/Robust.Shared/GameObjects/Components/MetaDataComponent.cs
@@ -60,6 +60,12 @@ namespace Robust.Shared.GameObjects
[ViewVariables]
public GameTick EntityLastModifiedTick { get; internal set; } = new(1);
+ ///
+ /// This is the tick at which the client last applied state data received from the server.
+ ///
+ [ViewVariables]
+ public GameTick LastStateApplied { get; internal set; } = GameTick.Zero;
+
///
/// The in-game name of this entity.
///
@@ -185,13 +191,21 @@ namespace Robust.Shared.GameObjects
public enum MetaDataFlags : byte
{
None = 0,
+
///
- /// Whether the entity has states specific to a particular player.
+ /// Whether the entity has states specific to particular players. This will cause many state-attempt events to
+ /// be raised, and is generally somewhat expensive.
///
EntitySpecific = 1 << 0,
+
///
/// Whether the entity is currently inside of a container.
///
InContainer = 1 << 1,
+
+ ///
+ /// Used by clients to indicate that an entity has left their visible set.
+ ///
+ Detached = 1 << 2,
}
}
diff --git a/Robust.Shared/GameObjects/EntityManager.Components.cs b/Robust.Shared/GameObjects/EntityManager.Components.cs
index 84035f30d..d6cc042e2 100644
--- a/Robust.Shared/GameObjects/EntityManager.Components.cs
+++ b/Robust.Shared/GameObjects/EntityManager.Components.cs
@@ -1123,6 +1123,7 @@ namespace Robust.Shared.GameObjects
///
public ComponentState GetComponentState(IEventBus eventBus, IComponent component)
{
+ DebugTools.Assert(component.NetSyncEnabled, $"Attempting to get component state for an un-synced component: {component.GetType()}");
var getState = new ComponentGetState();
eventBus.RaiseComponentEvent(component, ref getState);
diff --git a/Robust.Shared/GameObjects/EntityManager.cs b/Robust.Shared/GameObjects/EntityManager.cs
index df2145369..4bed3ccda 100644
--- a/Robust.Shared/GameObjects/EntityManager.cs
+++ b/Robust.Shared/GameObjects/EntityManager.cs
@@ -6,6 +6,7 @@ using Prometheus;
using Robust.Shared.IoC;
using Robust.Shared.Log;
using Robust.Shared.Map;
+using Robust.Shared.Network;
using Robust.Shared.Profiling;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.Manager;
@@ -27,6 +28,7 @@ namespace Robust.Shared.GameObjects
[Dependency] private readonly IMapManager _mapManager = default!;
[Dependency] private readonly IGameTiming _gameTiming = default!;
[Dependency] private readonly ISerializationManager _serManager = default!;
+ [Dependency] private readonly INetManager _netMan = default!;
[Dependency] private readonly ProfManager _prof = default!;
#endregion Dependencies
@@ -250,19 +252,17 @@ namespace Robust.Shared.GameObjects
///
/// Calling Dirty on a component will call this directly.
///
- public void Dirty(EntityUid uid)
+ public virtual void Dirty(EntityUid uid)
{
- var currentTick = CurrentTick;
-
// We want to retrieve MetaDataComponent even if its Deleted flag is set.
if (!_entTraitArray[CompIdx.ArrayIndex()].TryGetValue(uid, out var component))
throw new KeyNotFoundException($"Entity {uid} does not exist, cannot dirty it.");
var metadata = (MetaDataComponent)component;
- if (metadata.EntityLastModifiedTick == currentTick) return;
+ if (metadata.EntityLastModifiedTick == _gameTiming.CurTick) return;
- metadata.EntityLastModifiedTick = currentTick;
+ metadata.EntityLastModifiedTick = _gameTiming.CurTick;
if (metadata.EntityLifeStage > EntityLifeStage.Initializing)
{
@@ -270,7 +270,7 @@ namespace Robust.Shared.GameObjects
}
}
- public void Dirty(Component component)
+ public virtual void Dirty(Component component)
{
var owner = component.Owner;
diff --git a/Robust.Shared/GameObjects/EntityState.cs b/Robust.Shared/GameObjects/EntityState.cs
index 14eb43dd1..c0ba3df93 100644
--- a/Robust.Shared/GameObjects/EntityState.cs
+++ b/Robust.Shared/GameObjects/EntityState.cs
@@ -1,6 +1,7 @@
using Robust.Shared.Serialization;
using System;
using NetSerializer;
+using Robust.Shared.Timing;
namespace Robust.Shared.GameObjects
{
@@ -13,10 +14,13 @@ namespace Robust.Shared.GameObjects
public bool Empty => ComponentChanges.Value is null or { Count: 0 };
- public EntityState(EntityUid uid, NetListAsArray changedComponents, bool hide = false)
+ public readonly GameTick EntityLastModified;
+
+ public EntityState(EntityUid uid, NetListAsArray changedComponents, GameTick lastModified)
{
Uid = uid;
ComponentChanges = changedComponents;
+ EntityLastModified = lastModified;
}
}
@@ -45,12 +49,15 @@ namespace Robust.Shared.GameObjects
///
public readonly ushort NetID;
- public ComponentChange(ushort netId, bool created, bool deleted, ComponentState? state)
+ public readonly GameTick LastModifiedTick;
+
+ public ComponentChange(ushort netId, bool created, bool deleted, ComponentState? state, GameTick lastModifiedTick)
{
Deleted = deleted;
State = state;
NetID = netId;
Created = created;
+ LastModifiedTick = lastModifiedTick;
}
public override string ToString()
@@ -58,19 +65,19 @@ namespace Robust.Shared.GameObjects
return $"{(Deleted ? "D" : "C")} {NetID} {State?.GetType().Name}";
}
- public static ComponentChange Added(ushort netId, ComponentState? state)
+ public static ComponentChange Added(ushort netId, ComponentState? state, GameTick lastModifiedTick)
{
- return new(netId, true, false, state);
+ return new(netId, true, false, state, lastModifiedTick);
}
- public static ComponentChange Changed(ushort netId, ComponentState state)
+ public static ComponentChange Changed(ushort netId, ComponentState state, GameTick lastModifiedTick)
{
- return new(netId, false, false, state);
+ return new(netId, false, false, state, lastModifiedTick);
}
public static ComponentChange Removed(ushort netId)
{
- return new(netId, false, true, null);
+ return new(netId, false, true, null, default);
}
}
}
diff --git a/Robust.Shared/GameObjects/Systems/SharedTransformSystem.Component.cs b/Robust.Shared/GameObjects/Systems/SharedTransformSystem.Component.cs
index df26dd551..aaab628c8 100644
--- a/Robust.Shared/GameObjects/Systems/SharedTransformSystem.Component.cs
+++ b/Robust.Shared/GameObjects/Systems/SharedTransformSystem.Component.cs
@@ -734,7 +734,7 @@ public abstract partial class SharedTransformSystem
DebugTools.Assert(!xform.Anchored);
}
- public void DetachParentToNull(TransformComponent xform, EntityQuery xformQuery, EntityQuery metaQuery)
+ public void DetachParentToNull(TransformComponent xform, EntityQuery xformQuery, EntityQuery metaQuery, TransformComponent? oldConcrete = null)
{
var oldParent = xform._parent;
@@ -763,7 +763,7 @@ public abstract partial class SharedTransformSystem
RaiseLocalEvent(xform.Owner, ref anchorStateChangedEvent, true);
}
- var oldConcrete = xformQuery.GetComponent(oldParent);
+ oldConcrete ??= xformQuery.GetComponent(oldParent);
oldConcrete._children.Remove(xform.Owner);
xform._parent = EntityUid.Invalid;
diff --git a/Robust.Shared/GameStates/GameState.cs b/Robust.Shared/GameStates/GameState.cs
index 7f036e420..07f508f30 100644
--- a/Robust.Shared/GameStates/GameState.cs
+++ b/Robust.Shared/GameStates/GameState.cs
@@ -1,4 +1,4 @@
-using Robust.Shared.GameObjects;
+using Robust.Shared.GameObjects;
using Robust.Shared.Serialization;
using System;
using System.Diagnostics;
@@ -11,13 +11,6 @@ namespace Robust.Shared.GameStates
[Serializable, NetSerializable]
public sealed class GameState
{
- ///
- /// An extrapolated state that was created artificially by the client.
- /// It does not contain any real data from the server.
- ///
- [field:NonSerialized]
- public bool Extrapolated { get; set; }
-
///
/// The serialized size in bytes of this game state.
///
diff --git a/Robust.Shared/Network/Messages/MsgState.cs b/Robust.Shared/Network/Messages/MsgState.cs
index 46891f6fb..d37258c0d 100644
--- a/Robust.Shared/Network/Messages/MsgState.cs
+++ b/Robust.Shared/Network/Messages/MsgState.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Buffers;
using System.Diagnostics;
using System.IO;
@@ -27,7 +27,7 @@ namespace Robust.Shared.Network.Messages
public GameState State;
public ZStdCompressionContext CompressionContext;
- private bool _hasWritten;
+ internal bool _hasWritten;
public override void ReadFromBuffer(NetIncomingMessage buffer)
{
@@ -70,7 +70,7 @@ namespace Robust.Shared.Network.Messages
// We compress the state.
if (stateStream.Length > CompressionThreshold)
{
- var sw = Stopwatch.StartNew();
+ // var sw = Stopwatch.StartNew();
stateStream.Position = 0;
var buf = ArrayPool.Shared.Rent(ZStd.CompressBound((int)stateStream.Length));
var length = CompressionContext.Compress2(buf, stateStream.AsSpan());
@@ -79,7 +79,7 @@ namespace Robust.Shared.Network.Messages
buffer.Write(buf.AsSpan(0, length));
- var elapsed = sw.Elapsed;
+ // var elapsed = sw.Elapsed;
// System.Console.WriteLine(
// $"From: {State.FromSequence} To: {State.ToSequence} Size: {length} B Before: {stateStream.Length} B time: {elapsed}");
@@ -94,8 +94,7 @@ namespace Robust.Shared.Network.Messages
buffer.Write(stateStream.AsSpan());
}
-
- _hasWritten = false;
+ _hasWritten = true;
MsgSize = buffer.LengthBytes;
}
@@ -106,13 +105,7 @@ namespace Robust.Shared.Network.Messages
///
public bool ShouldSendReliably()
{
- // This check will be true in integration tests.
- // TODO: Maybe handle this better so that packet loss integration testing can be done?
- if (!_hasWritten)
- {
- return true;
- }
-
+ DebugTools.Assert(_hasWritten, "Attempted to determine sending method before determining packet size.");
return MsgSize > ReliableThreshold;
}
diff --git a/Robust.Shared/Network/Messages/MsgStateLeavePvs.cs b/Robust.Shared/Network/Messages/MsgStateLeavePvs.cs
new file mode 100644
index 000000000..8ea078196
--- /dev/null
+++ b/Robust.Shared/Network/Messages/MsgStateLeavePvs.cs
@@ -0,0 +1,47 @@
+using System.Collections.Generic;
+using Lidgren.Network;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Log;
+using Robust.Shared.Timing;
+
+#nullable disable
+
+namespace Robust.Shared.Network.Messages;
+
+///
+/// Message containing a list of entities that have left a clients view.
+///
+///
+/// These messages are only sent if PVS is enabled. These messages are sent separately from the main game state.
+///
+public sealed class MsgStateLeavePvs : NetMessage
+{
+ public override MsgGroups MsgGroup => MsgGroups.Entity;
+
+ public List Entities;
+ public GameTick Tick;
+
+ public override void ReadFromBuffer(NetIncomingMessage buffer)
+ {
+ Tick = buffer.ReadGameTick();
+ var length = buffer.ReadInt32();
+ Entities = new(length);
+
+ for (int i = 0; i < length; i++)
+ {
+ Entities.Add(buffer.ReadEntityUid());
+ }
+ }
+
+ public override void WriteToBuffer(NetOutgoingMessage buffer)
+ {
+ buffer.Write(Tick);
+ buffer.Write(Entities.Count);
+ foreach (var ent in Entities)
+ {
+ buffer.Write(ent);
+ }
+ }
+
+ public override NetDeliveryMethod DeliveryMethod => NetDeliveryMethod.ReliableUnordered;
+}
diff --git a/Robust.Shared/Network/Messages/MsgStateRequestFull.cs b/Robust.Shared/Network/Messages/MsgStateRequestFull.cs
new file mode 100644
index 000000000..5183b42bf
--- /dev/null
+++ b/Robust.Shared/Network/Messages/MsgStateRequestFull.cs
@@ -0,0 +1,25 @@
+using Lidgren.Network;
+using Robust.Shared.Timing;
+
+#nullable disable
+
+namespace Robust.Shared.Network.Messages;
+
+public sealed class MsgStateRequestFull : NetMessage
+{
+ public override MsgGroups MsgGroup => MsgGroups.Entity;
+
+ public GameTick Tick;
+
+ public override void ReadFromBuffer(NetIncomingMessage buffer)
+ {
+ Tick = buffer.ReadGameTick();
+ }
+
+ public override void WriteToBuffer(NetOutgoingMessage buffer)
+ {
+ buffer.Write(Tick);
+ }
+
+ public override NetDeliveryMethod DeliveryMethod => NetDeliveryMethod.ReliableUnordered;
+}
diff --git a/Robust.Shared/Physics/FixtureSystem.cs b/Robust.Shared/Physics/FixtureSystem.cs
index 24061ca98..4101d660c 100644
--- a/Robust.Shared/Physics/FixtureSystem.cs
+++ b/Robust.Shared/Physics/FixtureSystem.cs
@@ -25,6 +25,7 @@ namespace Robust.Shared.Physics
public override void Initialize()
{
base.Initialize();
+
SubscribeLocalEvent(OnShutdown);
SubscribeLocalEvent(OnGetState);
SubscribeLocalEvent(OnHandleState);
@@ -288,8 +289,7 @@ namespace Robust.Shared.Physics
if (!EntityManager.TryGetComponent(uid, out PhysicsComponent? physics))
{
- DebugTools.Assert(false);
- Logger.ErrorS("physics", $"Tried to apply fixture state for {uid} which has name {nameof(PhysicsComponent)}");
+ Logger.ErrorS("physics", $"Tried to apply fixture state for an entity without physics: {ToPrettyString(uid)}");
return;
}
diff --git a/Robust.Shared/Timing/GameLoop.cs b/Robust.Shared/Timing/GameLoop.cs
index b5a6e4648..5b4a8c0de 100644
--- a/Robust.Shared/Timing/GameLoop.cs
+++ b/Robust.Shared/Timing/GameLoop.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Threading;
using Robust.Shared.Log;
using Robust.Shared.Exceptions;
@@ -177,12 +177,13 @@ namespace Robust.Shared.Timing
}
#endif
_timing.InSimulation = true;
- var tickPeriod = CalcTickPeriod();
+ var tickPeriod = _timing.CalcAdjustedTickPeriod();
using (_prof.Group("Ticks"))
{
var countTicksRan = 0;
// run the simulation for every accumulated tick
+
while (accumulator >= tickPeriod)
{
accumulator -= tickPeriod;
@@ -192,6 +193,7 @@ namespace Robust.Shared.Timing
if (_timing.Paused)
continue;
+ _timing.TickRemainder = accumulator;
countTicksRan += 1;
// update the simulation
@@ -237,7 +239,7 @@ namespace Robust.Shared.Timing
}
#endif
_timing.CurTick = new GameTick(_timing.CurTick.Value + 1);
- tickPeriod = CalcTickPeriod();
+ tickPeriod = _timing.CalcAdjustedTickPeriod();
if (SingleStep)
_timing.Paused = true;
@@ -304,14 +306,6 @@ namespace Robust.Shared.Timing
Thread.Sleep((int)SleepMode);
}
}
-
- private TimeSpan CalcTickPeriod()
- {
- // ranges from -1 to 1, with 0 being 'default'
- var ratio = MathHelper.Clamp(_timing.TickTimingAdjustment, -0.99f, 0.99f);
- var diff = TimeSpan.FromTicks((long)(_timing.TickPeriod.Ticks * ratio));
- return _timing.TickPeriod - diff;
- }
}
///
diff --git a/Robust.Shared/Timing/GameTiming.cs b/Robust.Shared/Timing/GameTiming.cs
index 6cb450f3d..4e0f79225 100644
--- a/Robust.Shared/Timing/GameTiming.cs
+++ b/Robust.Shared/Timing/GameTiming.cs
@@ -1,5 +1,6 @@
using System;
using System.Linq;
+using Robust.Shared.Maths;
using Robust.Shared.Utility;
namespace Robust.Shared.Timing
@@ -153,6 +154,14 @@ namespace Robust.Shared.Timing
}
}
+ public TimeSpan CalcAdjustedTickPeriod()
+ {
+ // ranges from -1 to 1, with 0 being 'default'
+ var ratio = MathHelper.Clamp(TickTimingAdjustment, -0.99f, 0.99f);
+ var ticks = (1.0 / TickRate * TimeSpan.TicksPerSecond) - (TickPeriod.Ticks * ratio);
+ return TimeSpan.FromTicks((long) ticks);
+ }
+
///
/// Current graphics frame since init OpenGL which is taken as frame 1, from swapbuffer to swapbuffer. Useful to set a
/// conditional breakpoint on specific frames, and synchronize with OGL debugging tools that capture frames.
@@ -251,14 +260,11 @@ namespace Robust.Shared.Timing
public bool IsFirstTimePredicted { get; protected set; } = true;
///
- public bool InPrediction => !ApplyingState && CurTick > LastRealTick;
+ public virtual bool InPrediction => false;
///
public bool ApplyingState {get; protected set; }
- ///
- public GameTick LastRealTick { get; set; }
-
///
/// Calculates the average FPS of the last 50 real frame times.
///
diff --git a/Robust.Shared/Timing/IGameTiming.cs b/Robust.Shared/Timing/IGameTiming.cs
index 748b33f3b..9db29fa87 100644
--- a/Robust.Shared/Timing/IGameTiming.cs
+++ b/Robust.Shared/Timing/IGameTiming.cs
@@ -102,6 +102,8 @@ namespace Robust.Shared.Timing
///
TimeSpan TickRemainder { get; set; }
+ TimeSpan CalcAdjustedTickPeriod();
+
///
/// Fraction of how far into the tick we are. 0 is 0% and is 100%.
///
@@ -148,11 +150,6 @@ namespace Robust.Shared.Timing
///
bool ApplyingState { get; }
- ///
- /// The last real non-predicted tick that was processed.
- ///
- GameTick LastRealTick { get; set; }
-
string TickStamp => $"{CurTick}, predFirst: {IsFirstTimePredicted}, tickRem: {TickRemainder.TotalSeconds}, sim: {InSimulation}";
static string TickStampStatic => IoCManager.Resolve().TickStamp;
diff --git a/Robust.UnitTesting/Client/GameStates/GameStateProcessor_Tests.cs b/Robust.UnitTesting/Client/GameStates/GameStateProcessor_Tests.cs
index e575618db..0af78e7f5 100644
--- a/Robust.UnitTesting/Client/GameStates/GameStateProcessor_Tests.cs
+++ b/Robust.UnitTesting/Client/GameStates/GameStateProcessor_Tests.cs
@@ -1,6 +1,7 @@
-using Moq;
+using Moq;
using NUnit.Framework;
using Robust.Client.GameStates;
+using Robust.Client.Timing;
using Robust.Shared.GameStates;
using Robust.Shared.Timing;
@@ -12,27 +13,27 @@ namespace Robust.UnitTesting.Client.GameStates
[Test]
public void FillBufferBlocksProcessing()
{
- var timingMock = new Mock();
+ var timingMock = new Mock();
timingMock.SetupProperty(p => p.CurTick);
var timing = timingMock.Object;
var processor = new GameStateProcessor(timing);
+ processor.Interpolation = true;
processor.AddNewState(GameStateFactory(0, 1));
processor.AddNewState(GameStateFactory(1, 2)); // buffer is at 2/3, so processing should be blocked
// calculate states for first tick
- timing.CurTick = new GameTick(3);
- var result = processor.ProcessTickStates(new GameTick(1), out _, out _);
+ timing.LastProcessedTick = new GameTick(0);
+ var result = processor.TryGetServerState(out _, out _);
Assert.That(result, Is.False);
- Assert.That(timing.CurTick.Value, Is.EqualTo(1));
}
[Test]
public void FillBufferAndCalculateFirstState()
{
- var timingMock = new Mock();
+ var timingMock = new Mock();
timingMock.SetupProperty(p => p.CurTick);
var timing = timingMock.Object;
@@ -43,12 +44,11 @@ namespace Robust.UnitTesting.Client.GameStates
processor.AddNewState(GameStateFactory(2, 3)); // buffer is now full, otherwise cannot calculate states.
// calculate states for first tick
- timing.CurTick = new GameTick(1);
- var result = processor.ProcessTickStates(new GameTick(1), out var curState, out var nextState);
+ timing.LastProcessedTick = new GameTick(0);
+ var result = processor.TryGetServerState(out var curState, out var nextState);
Assert.That(result, Is.True);
Assert.That(curState, Is.Not.Null);
- Assert.That(curState!.Extrapolated, Is.False);
Assert.That(curState.ToSequence.Value, Is.EqualTo(1));
Assert.That(nextState, Is.Null);
}
@@ -60,7 +60,7 @@ namespace Robust.UnitTesting.Client.GameStates
[Test]
public void FullStateResyncsCurTick()
{
- var timingMock = new Mock();
+ var timingMock = new Mock();
timingMock.SetupProperty(p => p.CurTick);
var timing = timingMock.Object;
@@ -71,10 +71,11 @@ namespace Robust.UnitTesting.Client.GameStates
processor.AddNewState(GameStateFactory(2, 3)); // buffer is now full, otherwise cannot calculate states.
// calculate states for first tick
- timing.CurTick = new GameTick(3);
- processor.ProcessTickStates(timing.CurTick, out _, out _);
+ timing.LastProcessedTick = new GameTick(2);
+ processor.TryGetServerState(out var state, out _);
- Assert.That(timing.CurTick.Value, Is.EqualTo(1));
+ Assert.NotNull(state);
+ Assert.That(state.ToSequence.Value, Is.EqualTo(1));
}
[Test]
@@ -82,12 +83,10 @@ namespace Robust.UnitTesting.Client.GameStates
{
var (timing, processor) = SetupProcessorFactory();
- processor.Extrapolation = false;
-
// a few moments later...
- timing.CurTick = new GameTick(5); // current clock is ahead of server
+ timing.LastProcessedTick = new GameTick(4); // current clock is ahead of server
processor.AddNewState(GameStateFactory(3, 4)); // received a late state
- var result = processor.ProcessTickStates(timing.CurTick, out _, out _);
+ var result = processor.TryGetServerState(out _, out _);
Assert.That(result, Is.False);
}
@@ -101,57 +100,13 @@ namespace Robust.UnitTesting.Client.GameStates
{
var (timing, processor) = SetupProcessorFactory();
- processor.Extrapolation = false;
-
// a few moments later...
- timing.CurTick = new GameTick(5); // current clock is ahead of server
- var result = processor.ProcessTickStates(timing.CurTick, out _, out _);
+ timing.LastProcessedTick = new GameTick(4); // current clock is ahead of server
+ var result = processor.TryGetServerState(out _, out _);
Assert.That(result, Is.False);
}
- ///
- /// When processing is blocked because the client is ahead of the server, reset CurTick to the last
- /// received state.
- ///
- [Test]
- public void ServerLagsWithoutExtrapolationSetsCurTick()
- {
- var (timing, processor) = SetupProcessorFactory();
-
- processor.Extrapolation = false;
-
- // a few moments later...
- timing.CurTick = new GameTick(4); // current clock is ahead of server (server=1, client=5)
- var result = processor.ProcessTickStates(timing.CurTick, out _, out _);
-
- Assert.That(result, Is.False);
- Assert.That(timing.CurTick.Value, Is.EqualTo(1));
- }
-
- ///
- /// The server fell behind the client, so the client clock is now ahead of the incoming states.
- /// With extrapolation, processing returns a fake extrapolated state for the current tick.
- ///
- [Test]
- public void ServerLagsWithExtrapolation()
- {
- var (timing, processor) = SetupProcessorFactory();
-
- processor.Extrapolation = true;
-
- // a few moments later...
- timing.CurTick = new GameTick(5); // current clock is ahead of server
-
- var result = processor.ProcessTickStates(timing.CurTick, out var curState, out var nextState);
-
- Assert.That(result, Is.True);
- Assert.That(curState, Is.Not.Null);
- Assert.That(curState!.Extrapolated, Is.True);
- Assert.That(curState.ToSequence.Value, Is.EqualTo(5));
- Assert.That(nextState, Is.Null);
- }
-
///
/// There is a hole in the state buffer, we have a future state but their FromSequence is too high!
/// In this case we stop and wait for the server to get us the missing link.
@@ -162,11 +117,10 @@ namespace Robust.UnitTesting.Client.GameStates
var (timing, processor) = SetupProcessorFactory();
processor.AddNewState(GameStateFactory(4, 5));
- processor.LastProcessedRealState = new GameTick(3);
+ timing.LastRealTick = new GameTick(3);
+ timing.LastProcessedTick = new GameTick(3);
- timing.CurTick = new GameTick(4);
-
- var result = processor.ProcessTickStates(timing.CurTick, out _, out _);
+ var result = processor.TryGetServerState(out _, out _);
Assert.That(result, Is.False);
}
@@ -182,52 +136,24 @@ namespace Robust.UnitTesting.Client.GameStates
processor.Interpolation = true;
- timing.CurTick = new GameTick(4);
+ timing.LastProcessedTick = new GameTick(3);
- processor.LastProcessedRealState = new GameTick(3);
+ timing.LastRealTick = new GameTick(3);
processor.AddNewState(GameStateFactory(3, 5));
// We're missing the state for this tick so go into extrap.
- var result = processor.ProcessTickStates(timing.CurTick, out var curState, out _);
+ var result = processor.TryGetServerState(out var curState, out _);
Assert.That(result, Is.True);
- Assert.That(curState, Is.Not.Null);
- Assert.That(curState!.Extrapolated, Is.True);
+ Assert.That(curState, Is.Null);
- timing.CurTick = new GameTick(5);
+ timing.LastProcessedTick = new GameTick(4);
// But we DO have the state for the tick after so apply away!
- result = processor.ProcessTickStates(timing.CurTick, out curState, out _);
+ result = processor.TryGetServerState(out curState, out _);
Assert.That(result, Is.True);
Assert.That(curState, Is.Not.Null);
- Assert.That(curState!.Extrapolated, Is.False);
- }
-
- ///
- /// The client started extrapolating and now received the state it needs to "continue as normal".
- /// In this scenario the CurTick passed to the game state processor
- /// is higher than the real next tick to apply, IF it went into extrapolation.
- /// The processor needs to go back to the next REAL tick.
- ///
- [Test, Ignore("Extrapolation is currently non functional anyways")]
- public void UndoExtrapolation()
- {
- var (timing, processor) = SetupProcessorFactory();
-
- processor.Extrapolation = true;
-
- processor.AddNewState(GameStateFactory(4, 5));
- processor.AddNewState(GameStateFactory(3, 4));
- processor.LastProcessedRealState = new GameTick(3);
-
- timing.CurTick = new GameTick(5);
-
- var result = processor.ProcessTickStates(timing.CurTick, out var curState, out _);
-
- Assert.That(result, Is.True);
- Assert.That(curState, Is.Not.Null);
- Assert.That(curState!.ToSequence, Is.EqualTo(new GameTick(4)));
}
///
@@ -242,10 +168,12 @@ namespace Robust.UnitTesting.Client.GameStates
/// Creates a new GameTiming and GameStateProcessor, fills the processor with enough states, and calculate the first tick.
/// CurTick = 1, states 1 - 3 are in the buffer.
///
- private static (IGameTiming timing, GameStateProcessor processor) SetupProcessorFactory()
+ private static (IClientGameTiming timing, GameStateProcessor processor) SetupProcessorFactory()
{
- var timingMock = new Mock();
+ var timingMock = new Mock();
timingMock.SetupProperty(p => p.CurTick);
+ timingMock.SetupProperty(p => p.LastProcessedTick);
+ timingMock.SetupProperty(p => p.LastRealTick);
timingMock.SetupProperty(p => p.TickTimingAdjustment);
var timing = timingMock.Object;
@@ -255,9 +183,8 @@ namespace Robust.UnitTesting.Client.GameStates
processor.AddNewState(GameStateFactory(1, 2));
processor.AddNewState(GameStateFactory(2, 3)); // buffer is now full, otherwise cannot calculate states.
- // calculate states for first tick
- timing.CurTick = new GameTick(1);
- processor.ProcessTickStates(timing.CurTick, out _, out _);
+ processor.LastFullStateRequested = null;
+ timing.LastProcessedTick = timing.LastRealTick = new GameTick(1);
return (timing, processor);
}
diff --git a/Robust.UnitTesting/RobustIntegrationTest.NetManager.cs b/Robust.UnitTesting/RobustIntegrationTest.NetManager.cs
index 3cf4a7bfe..e332a5a12 100644
--- a/Robust.UnitTesting/RobustIntegrationTest.NetManager.cs
+++ b/Robust.UnitTesting/RobustIntegrationTest.NetManager.cs
@@ -8,6 +8,7 @@ using System.Threading.Tasks;
using Robust.Shared.Asynchronous;
using Robust.Shared.IoC;
using Robust.Shared.Network;
+using Robust.Shared.Network.Messages;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
@@ -252,6 +253,10 @@ namespace Robust.UnitTesting
{
DebugTools.Assert(IsServer);
+ // MsgState sending method depends on the size of the possible compressed buffer. But tests bypass buffer read/write.
+ if (message is MsgState stateMsg)
+ stateMsg._hasWritten = true;
+
var channel = (IntegrationNetChannel) recipient;
channel.OtherChannel.TryWrite(new DataMessage(message, channel.RemoteUid));
}
diff --git a/Robust.UnitTesting/Shared/GameObjects/EntityState_Tests.cs b/Robust.UnitTesting/Shared/GameObjects/EntityState_Tests.cs
index 7e6850b54..be4c5204f 100644
--- a/Robust.UnitTesting/Shared/GameObjects/EntityState_Tests.cs
+++ b/Robust.UnitTesting/Shared/GameObjects/EntityState_Tests.cs
@@ -62,8 +62,8 @@ namespace Robust.UnitTesting.Shared.GameObjects
new EntityUid(512),
new []
{
- new ComponentChange(0, true, false, new MapGridComponentState(new GridId(0), 16))
- });
+ new ComponentChange(0, true, false, new MapGridComponentState(new GridId(0), 16), default)
+ }, default);
serializer.Serialize(stream, payload);
array = stream.ToArray();