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