using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Extensions.ObjectPool;
using Robust.Server.Configuration;
using Robust.Server.Player;
using Robust.Shared;
using Robust.Shared.Configuration;
using Robust.Shared.Enums;
using Robust.Shared.GameObjects;
using Robust.Shared.Map;
using Robust.Shared.Map.Components;
using Robust.Shared.Maths;
using Robust.Shared.Player;
using Robust.Shared.Threading;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Robust.Server.GameStates;
internal sealed partial class PvsSystem : EntitySystem
{
[Shared.IoC.Dependency] private readonly IConfigurationManager _configManager = default!;
[Shared.IoC.Dependency] private readonly INetworkedMapManager _mapManager = default!;
[Shared.IoC.Dependency] private readonly IPlayerManager _playerManager = default!;
[Shared.IoC.Dependency] private readonly IParallelManager _parallelManager = default!;
[Shared.IoC.Dependency] private readonly IServerGameStateManager _serverGameStateManager = default!;
[Shared.IoC.Dependency] private readonly IServerNetConfigurationManager _netConfigManager = default!;
[Shared.IoC.Dependency] private readonly SharedTransformSystem _transform = default!;
public const float ChunkSize = 8;
// TODO make this a cvar. Make it in terms of seconds and tie it to tick rate?
// Main issue is that I CBF figuring out the logic for handling it changing mid-game.
public const int DirtyBufferSize = 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.
///
/// See .
///
public int ForceAckThreshold { get; private set; }
///
/// Maximum number of pooled objects.
///
private const int MaxVisPoolSize = 1024;
///
/// Is view culling enabled, or will we send the whole map?
///
public bool CullingEnabled { get; private set; }
///
/// Size of the side of the view bounds square.
///
private float _viewSize;
///
/// Per-tick ack data to avoid re-allocating.
///
private readonly List _toAck = new();
private PvsAckJob _ackJob;
///
/// If PVS disabled then we'll track if we've dumped all entities on the player.
/// This way any future ticks can be orders of magnitude faster as we only send what changes.
///
private HashSet _seenAllEnts = new();
internal readonly Dictionary PlayerData = new();
private PVSCollection _entityPvsCollection = default!;
public PVSCollection EntityPVSCollection => _entityPvsCollection;
private readonly List _pvsCollections = new();
private readonly ObjectPool> _netUidSetPool
= new DefaultObjectPool>(new SetPolicy(), MaxVisPoolSize);
private readonly ObjectPool> _netUidListPool
= new DefaultObjectPool>(new ListPolicy(), MaxVisPoolSize);
private readonly ObjectPool> _uidSetPool
= new DefaultObjectPool>(new SetPolicy(), MaxVisPoolSize);
private readonly ObjectPool> _stackPool
= new DefaultObjectPool>(
new StackPolicy(), MaxVisPoolSize);
private readonly ObjectPool> _playerChunkPool =
new DefaultObjectPool>(new SetPolicy(), MaxVisPoolSize);
private readonly ObjectPool> _treePool =
new DefaultObjectPool>(new TreePolicy(), MaxVisPoolSize);
private readonly ObjectPool> _mapChunkPool =
new DefaultObjectPool>(
new ChunkPoolPolicy(), MaxVisPoolSize);
private readonly ObjectPool> _gridChunkPool =
new DefaultObjectPool>(
new ChunkPoolPolicy(), MaxVisPoolSize);
private readonly Dictionary> _mapIndices = new(4);
private readonly Dictionary> _gridIndices = new(4);
private readonly List<(int, IChunkIndexLocation)> _chunkList = new(64);
internal readonly HashSet PendingAcks = new();
private readonly Dictionary<(int visMask, IChunkIndexLocation location), RobustTree?> _previousTrees = new();
private readonly HashSet<(int visMask, IChunkIndexLocation location)> _reusedTrees = new();
private EntityQuery _eyeQuery;
private EntityQuery _metaQuery;
private EntityQuery _xformQuery;
public override void Initialize()
{
base.Initialize();
_ackJob = new PvsAckJob()
{
System = this,
Sessions = _toAck,
};
_eyeQuery = GetEntityQuery();
_metaQuery = GetEntityQuery();
_xformQuery = GetEntityQuery();
_entityPvsCollection = RegisterPVSCollection();
SubscribeLocalEvent(ev =>
{
if (ev.Created)
OnMapCreated(ev);
else
OnMapDestroyed(ev);
});
SubscribeLocalEvent(OnGridCreated);
SubscribeLocalEvent(OnGridRemoved);
_playerManager.PlayerStatusChanged += OnPlayerStatusChanged;
SubscribeLocalEvent(OnEntityMove);
SubscribeLocalEvent(OnTransformStartup);
EntityManager.EntityDeleted += OnEntityDeleted;
_configManager.OnValueChanged(CVars.NetPVS, SetPvs, true);
_configManager.OnValueChanged(CVars.NetMaxUpdateRange, OnViewsizeChanged, true);
_configManager.OnValueChanged(CVars.NetForceAckThreshold, OnForceAckChanged, true);
_serverGameStateManager.ClientAck += OnClientAck;
_serverGameStateManager.ClientRequestFull += OnClientRequestFull;
InitializeDirty();
}
public override void Shutdown()
{
base.Shutdown();
UnregisterPVSCollection(_entityPvsCollection);
_playerManager.PlayerStatusChanged -= OnPlayerStatusChanged;
EntityManager.EntityDeleted -= OnEntityDeleted;
_configManager.UnsubValueChanged(CVars.NetPVS, SetPvs);
_configManager.UnsubValueChanged(CVars.NetMaxUpdateRange, OnViewsizeChanged);
_configManager.UnsubValueChanged(CVars.NetForceAckThreshold, OnForceAckChanged);
_serverGameStateManager.ClientAck -= OnClientAck;
_serverGameStateManager.ClientRequestFull -= OnClientRequestFull;
ShutdownDirty();
}
// TODO rate limit this?
private void OnClientRequestFull(ICommonSession session, GameTick tick, NetEntity? missingEntity)
{
if (!PlayerData.TryGetValue(session, out var sessionData))
return;
// Update acked tick so that OnClientAck doesn't get invoked by any late acks.
var lastAcked = sessionData.LastReceivedAck;
sessionData.LastReceivedAck = _gameTiming.CurTick;
var sb = new StringBuilder();
sb.Append($"Client {session} requested full state on tick {tick}. Last Acked: {lastAcked}. Curtick: {_gameTiming.CurTick}.");
if (missingEntity != null)
{
var entity = GetEntity(missingEntity)!;
sb.Append($" Apparently they received an entity without metadata: {ToPrettyString(entity.Value)}.");
if (sessionData.EntityData.TryGetValue(missingEntity.Value, out var data))
sb.Append($" Entity last seen: {data.EntityLastAcked}");
}
Log.Warning(sb.ToString());
// TODO PVS return to pool.
sessionData.Overflow = null;
sessionData.EntityData.Clear();
sessionData.SentEntities.Clear();
sessionData.RequestedFull = true;
}
private void OnViewsizeChanged(float obj)
{
_viewSize = obj * 2;
}
private void OnForceAckChanged(int value)
{
ForceAckThreshold = value;
}
private void SetPvs(bool value)
{
_seenAllEnts.Clear();
CullingEnabled = value;
}
public void ProcessCollections()
{
foreach (var collection in _pvsCollections)
{
collection.Process();
}
}
public void CullDeletionHistory(GameTick oldestAck)
{
_entityPvsCollection.CullDeletionHistoryUntil(oldestAck);
_mapManager.CullDeletionHistory(oldestAck);
}
#region PVSCollection methods to maybe make public someday:tm:
private PVSCollection RegisterPVSCollection() where TIndex : IComparable, IEquatable
{
var collection = new PVSCollection(Log, EntityManager, _transform);
_pvsCollections.Add(collection);
return collection;
}
private bool UnregisterPVSCollection(PVSCollection pvsCollection) where TIndex : IComparable, IEquatable =>
_pvsCollections.Remove(pvsCollection);
#endregion
#region PVSCollection Event Updates
private void OnEntityDeleted(EntityUid e, MetaDataComponent metadata)
{
_entityPvsCollection.RemoveIndex(EntityManager.CurrentTick, metadata.NetEntity);
foreach (var sessionData in PlayerData.Values)
{
sessionData.EntityData.Remove(metadata.NetEntity);
}
}
private void OnEntityMove(ref MoveEvent ev)
{
// GriddUid is only set after init.
if (!ev.Component._gridInitialized)
_transform.InitializeGridUid(ev.Sender, ev.Component);
// since elements are cached grid-/map-relative, we dont need to update a given grids/maps children
if (ev.Component.GridUid == ev.Sender)
return;
DebugTools.Assert(!_mapManager.IsGrid(ev.Sender));
if (!ev.Component.ParentUid.IsValid())
{
// This entity is either a map, terminating, or a rare null-space entity.
if (Terminating(ev.Sender))
return;
if (ev.Component.MapUid == ev.Sender)
return;
}
DebugTools.Assert(!_mapManager.IsMap(ev.Sender));
var coordinates = _transform.GetMoverCoordinates(ev.Sender, ev.Component);
UpdateEntityRecursive(ev.Sender, _metaQuery.GetComponent(ev.Sender), ev.Component, coordinates, false, ev.ParentChanged);
}
private void OnTransformStartup(EntityUid uid, TransformComponent component, ref TransformStartupEvent args)
{
// use Startup because GridId is not set during the eventbus init yet!
// since elements are cached grid-/map-relative, we dont need to update a given grids/maps children
if (component.GridUid == uid)
return;
DebugTools.Assert(!_mapManager.IsGrid(uid));
if (component.MapUid == uid)
return;
DebugTools.Assert(!_mapManager.IsMap(uid));
var coordinates = _transform.GetMoverCoordinates(uid, component);
UpdateEntityRecursive(uid, _metaQuery.GetComponent(uid), component, coordinates, false, false);
}
private void UpdateEntityRecursive(EntityUid uid, MetaDataComponent metadata, TransformComponent xform, EntityCoordinates coordinates, bool mover, bool forceDirty)
{
if (mover && !xform.LocalPosition.Equals(Vector2.Zero))
{
coordinates = _transform.GetMoverCoordinates(uid, xform);
}
// since elements are cached grid-/map-relative, we don't need to update a given grids/maps children
DebugTools.Assert(!_mapManager.IsGrid(uid) && !_mapManager.IsMap(uid));
var indices = PVSCollection.GetChunkIndices(coordinates.Position);
if (xform.GridUid != null)
_entityPvsCollection.UpdateIndex(metadata.NetEntity, xform.GridUid.Value, indices, forceDirty: forceDirty);
else
_entityPvsCollection.UpdateIndex(metadata.NetEntity, xform.MapID, indices, forceDirty: forceDirty);
// TODO PERFORMANCE
// Given uid is the parent of its children, we already know that the child xforms will have to be relative to
// coordinates.EntityId. So instead of calling GetMoverCoordinates() for each child we should just calculate it
// directly.
foreach (var child in xform._children)
{
UpdateEntityRecursive(child, _metaQuery.GetComponent(child), _xformQuery.GetComponent(child), coordinates, true, forceDirty);
}
}
private void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e)
{
if (e.NewStatus == SessionStatus.InGame)
{
if (!PlayerData.TryAdd(e.Session, new(e.Session)))
Log.Error($"Attempted to add player to _playerVisibleSets, but they were already present? Session:{e.Session}");
foreach (var pvsCollection in _pvsCollections)
{
if (!pvsCollection.AddPlayer(e.Session))
Log.Error($"Attempted to add player to pvsCollection, but they were already present? Session:{e.Session}");
}
return;
}
if (e.NewStatus != SessionStatus.Disconnected)
return;
if (!PlayerData.Remove(e.Session, out var data))
return;
foreach (var pvsCollection in _pvsCollections)
{
if (!pvsCollection.RemovePlayer(e.Session))
Log.Error($"Attempted to remove player from pvsCollection, but they were already removed? Session:{e.Session}");
}
if (data.Overflow != null)
_netUidListPool.Return(data.Overflow.Value.SentEnts);
data.Overflow = null;
foreach (var visSet in data.SentEntities.Values)
{
_netUidListPool.Return(visSet);
}
}
private void OnGridRemoved(GridRemovalEvent ev)
{
foreach (var pvsCollection in _pvsCollections)
{
pvsCollection.RemoveGrid(ev.EntityUid);
}
}
private void OnGridCreated(GridInitializeEvent ev)
{
var gridId = ev.EntityUid;
foreach (var pvsCollection in _pvsCollections)
{
pvsCollection.AddGrid(gridId);
}
_entityPvsCollection.AddGlobalOverride(_metaQuery.GetComponent(gridId).NetEntity, true, false);
}
private void OnMapDestroyed(MapChangedEvent e)
{
foreach (var pvsCollection in _pvsCollections)
{
pvsCollection.RemoveMap(e.Map);
}
}
private void OnMapCreated(MapChangedEvent e)
{
foreach (var pvsCollection in _pvsCollections)
{
pvsCollection.AddMap(e.Map);
}
if(e.Map == MapId.Nullspace) return;
var uid = _mapManager.GetMapEntityId(e.Map);
_entityPvsCollection.AddGlobalOverride(_metaQuery.GetComponent(uid).NetEntity, true, false);
}
#endregion
public List<(int, IChunkIndexLocation)> GetChunks(
ICommonSession[] sessions,
ref HashSet[] playerChunks,
ref EntityUid[][] viewerEntities)
{
// Pass these in to avoid allocating new ones every tick, 99% of the time sessions length is going to be the same size.
// These values will get overridden here and the old values have already been returned to the pool by this point.
Array.Resize(ref playerChunks, sessions.Length);
Array.Resize(ref viewerEntities, sessions.Length);
_chunkList.Clear();
// Keep track of the index of each chunk we use for a faster index lookup.
// Pool it because this will allocate a lot across ticks as we scale in players.
foreach (var chunks in _mapIndices.Values)
{
_mapChunkPool.Return(chunks);
}
foreach (var chunks in _gridIndices.Values)
{
_gridChunkPool.Return(chunks);
}
_mapIndices.Clear();
_gridIndices.Clear();
for (int i = 0; i < sessions.Length; i++)
{
var session = sessions[i];
playerChunks[i] = _playerChunkPool.Get();
ref var viewers = ref viewerEntities[i];
GetSessionViewers(session, ref viewers);
for (var j = 0; j < viewers.Length; j++)
{
var eyeEuid = viewers[j];
var (viewPos, range, mapId) = CalcViewBounds(in eyeEuid);
if (mapId == MapId.Nullspace) continue;
int visMask = EyeComponent.DefaultVisibilityMask;
if (_eyeQuery.TryGetComponent(eyeEuid, out var eyeComp))
visMask = eyeComp.VisibilityMask;
// Get the nyoom dictionary for index lookups.
if (!_mapIndices.TryGetValue(visMask, out var mapDict))
{
mapDict = _mapChunkPool.Get();
_mapIndices[visMask] = mapDict;
}
var mapChunkEnumerator = new ChunkIndicesEnumerator(viewPos, range, ChunkSize);
while (mapChunkEnumerator.MoveNext(out var chunkIndices))
{
var chunkLocation = new MapChunkLocation(mapId, chunkIndices.Value);
var entry = (visMask, chunkLocation);
if (mapDict.TryGetValue(chunkLocation, out var indexOf))
{
playerChunks[i].Add(indexOf);
}
else
{
playerChunks[i].Add(_chunkList.Count);
mapDict.Add(chunkLocation, _chunkList.Count);
_chunkList.Add(entry);
}
}
// Get the nyoom dictionary for index lookups.
if (!_gridIndices.TryGetValue(visMask, out var gridDict))
{
gridDict = _gridChunkPool.Get();
_gridIndices[visMask] = gridDict;
}
var state = (i, _xformQuery, viewPos, range, visMask, gridDict, playerChunks, _chunkList, _transform);
var rangeVec = new Vector2(range, range);
_mapManager.FindGridsIntersecting(mapId, new Box2(viewPos - rangeVec, viewPos + rangeVec),
ref state, static (
EntityUid gridUid,
MapGridComponent _,
ref (int i,
EntityQuery transformQuery,
Vector2 viewPos,
float range,
int visMask,
Dictionary gridDict,
HashSet[] playerChunks,
List<(int, IChunkIndexLocation)> _chunkList,
SharedTransformSystem xformSystem) tuple) =>
{
{
var localPos = tuple.xformSystem.GetInvWorldMatrix(gridUid, tuple.transformQuery).Transform(tuple.viewPos);
var gridChunkEnumerator =
new ChunkIndicesEnumerator(localPos, tuple.range, ChunkSize);
while (gridChunkEnumerator.MoveNext(out var gridChunkIndices))
{
var chunkLocation = new GridChunkLocation(gridUid, gridChunkIndices.Value);
var entry = (tuple.visMask, chunkLocation);
if (tuple.gridDict.TryGetValue(chunkLocation, out var indexOf))
{
tuple.playerChunks[tuple.i].Add(indexOf);
}
else
{
tuple.playerChunks[tuple.i].Add(tuple._chunkList.Count);
tuple.gridDict.Add(chunkLocation, tuple._chunkList.Count);
tuple._chunkList.Add(entry);
}
}
return true;
}
});
}
}
return _chunkList;
}
public void RegisterNewPreviousChunkTrees(
List<(int, IChunkIndexLocation)> chunks,
RobustTree?[] trees,
bool[] reuse)
{
// For any chunks able to re-used we'll chuck them in a dictionary for faster lookup.
for (var i = 0; i < chunks.Count; i++)
{
var canReuse = reuse[i];
if (!canReuse) continue;
_reusedTrees.Add(chunks[i]);
}
foreach (var (index, chunk) in _previousTrees)
{
// ReSharper disable once InconsistentlySynchronizedField
if (_reusedTrees.Contains(index))
continue;
if (chunk != null)
_treePool.Return(chunk);
if (!chunks.Contains(index))
_previousTrees.Remove(index);
}
_previousTrees.EnsureCapacity(chunks.Count);
for (int i = 0; i < chunks.Count; i++)
{
//this is a redundant assign if the tree has been reused. the assumption is that this is cheaper than a .Contains call
_previousTrees[chunks[i]] = trees[i];
}
// ReSharper disable once InconsistentlySynchronizedField
_reusedTrees.Clear();
}
public bool TryCalculateChunk(
IChunkIndexLocation chunkLocation,
int visMask,
out RobustTree? tree)
{
if (!_entityPvsCollection.IsDirty(chunkLocation)
&& _previousTrees.TryGetValue((visMask, chunkLocation), out tree))
{
return true;
}
var chunk = chunkLocation switch
{
GridChunkLocation gridChunkLocation => _entityPvsCollection.TryGetChunk(gridChunkLocation.GridId,
gridChunkLocation.ChunkIndices, out var gridChunk)
? gridChunk
: null,
MapChunkLocation mapChunkLocation => _entityPvsCollection.TryGetChunk(mapChunkLocation.MapId,
mapChunkLocation.ChunkIndices, out var mapChunk)
? mapChunk
: null,
_ => null
};
if (chunk == null)
{
tree = null;
return false;
}
tree = _treePool.Get();
var set = _netUidSetPool.Get();
DebugTools.AssertNotNull(tree.RootNodes.Count == 0);
DebugTools.AssertNotNull(set.Count == 0);
foreach (var netEntity in chunk)
{
var (uid, meta) = GetEntityData(netEntity);
AddToChunkSetRecursively(in uid, in netEntity, meta, visMask, tree, set);
#if DEBUG
var xform = _xformQuery.GetComponent(uid);
if (chunkLocation is MapChunkLocation)
DebugTools.Assert(xform.GridUid == null || xform.GridUid == uid);
else if (chunkLocation is GridChunkLocation)
DebugTools.Assert(xform.ParentUid != xform.MapUid || xform.GridUid == xform.MapUid);
#endif
}
DebugTools.Assert(set.Count > 0 || tree.RootNodes.Count == 0);
_netUidSetPool.Return(set);
if (tree.RootNodes.Count == 0)
{
// This can happen if the only entity in a chunk is invisible
// (e.g., when a ghost moves from from a grid into empty space).
_treePool.Return(tree);
tree = null;
return true;
}
return false;
}
public void ReturnToPool(HashSet[] playerChunks)
{
for (var i = 0; i < playerChunks.Length; i++)
{
_playerChunkPool.Return(playerChunks[i]);
}
}
private void AddToChunkSetRecursively(in EntityUid uid, in NetEntity netEntity, MetaDataComponent mComp,
int visMask, RobustTree tree, HashSet set)
{
// If the eye is missing ANY layer that this entity is on, or any layer that any of its parents belongs to, then
// it is considered invisible.
if ((visMask & mComp.VisibilityMask) != mComp.VisibilityMask)
return;
if (!set.Add(netEntity))
return; // already sending
var xform = _xformQuery.GetComponent(uid);
// is this a map or grid?
var isRoot = !xform.ParentUid.IsValid() || uid == xform.GridUid;
if (isRoot)
{
DebugTools.Assert(_mapManager.IsGrid(uid) || _mapManager.IsMap(uid));
tree.Set(netEntity);
return;
}
DebugTools.Assert(!_mapManager.IsGrid(uid) && !_mapManager.IsMap(uid));
var parent = xform.ParentUid;
var parentMeta = _metaQuery.GetComponent(parent);
var parentNetEntity = parentMeta.NetEntity;
// Child should have all o the same flags as the parent.
DebugTools.Assert((parentMeta.VisibilityMask & mComp.VisibilityMask) == parentMeta.VisibilityMask);
// Add our parent.
AddToChunkSetRecursively(in parent, in parentNetEntity, parentMeta, visMask, tree, set);
tree.Set(netEntity, parentNetEntity);
}
internal (List? updates, List? deletions, List? leftPvs, GameTick fromTick)
CalculateEntityStates(ICommonSession session,
GameTick fromTick,
GameTick toTick,
RobustTree?[] chunks,
HashSet visibleChunks,
EntityUid[] viewers)
{
DebugTools.Assert(session.Status == SessionStatus.InGame);
var newEntityBudget = _netConfigManager.GetClientCVar(session.Channel, CVars.NetPVSEntityBudget);
var enteredEntityBudget = _netConfigManager.GetClientCVar(session.Channel, CVars.NetPVSEntityEnterBudget);
var newEntityCount = 0;
var enteredEntityCount = 0;
var sessionData = PlayerData[session];
sessionData.SentEntities.TryGetValue(toTick - 1, out var lastSent);
var toSend = _netUidListPool.Get();
var entityData = sessionData.EntityData;
if (toSend.Count != 0)
throw new Exception("Encountered non-empty object inside of _netUidSetPool. Was the same object returned to the pool more than once?");
var deletions = _entityPvsCollection.GetDeletedIndices(fromTick);
var dirtyEntityCount = 0;
var stack = _stackPool.Get();
// TODO reorder chunks to prioritize those that are closest to the viewer? Helps make pop-in less visible.
foreach (var i in visibleChunks)
{
var tree = chunks[i];
if(tree == null)
continue;
#if DEBUG
// Each root nodes should simply be a map or a grid entity.
DebugTools.Assert(tree.RootNodes.Count == 1,
$"Root node count is {tree.RootNodes.Count} instead of 1. Session: {session}");
var nent = tree.RootNodes.FirstOrDefault();
var ent = GetEntity(nent);
DebugTools.Assert(Exists(ent), $"Root node does not exist. Node {ent}. Session: {session}");
DebugTools.Assert(HasComp(ent) || HasComp(ent));
#endif
foreach (var rootNode in tree.RootNodes)
{
RecursivelyAddTreeNode(in rootNode, tree, toSend, entityData, stack, fromTick, toTick,
ref newEntityCount, ref enteredEntityCount, ref dirtyEntityCount, newEntityBudget, enteredEntityBudget);
}
}
_stackPool.Return(stack);
var globalEnumerator = _entityPvsCollection.GlobalOverridesEnumerator;
while (globalEnumerator.MoveNext())
{
var netEntity = globalEnumerator.Current;
var uid = GetEntity(netEntity);
RecursivelyAddOverride(in uid, toSend, entityData, fromTick, toTick,
ref newEntityCount, ref enteredEntityCount, ref dirtyEntityCount, newEntityBudget, enteredEntityBudget);
}
globalEnumerator.Dispose();
var globalRecursiveEnumerator = _entityPvsCollection.GlobalRecursiveOverridesEnumerator;
while (globalRecursiveEnumerator.MoveNext())
{
var netEntity = globalRecursiveEnumerator.Current;
var uid = GetEntity(netEntity);
RecursivelyAddOverride(in uid, toSend, entityData, fromTick, toTick,
ref newEntityCount, ref enteredEntityCount, ref dirtyEntityCount, newEntityBudget, enteredEntityBudget, true);
}
globalRecursiveEnumerator.Dispose();
var sessionOverrides = _entityPvsCollection.GetSessionOverrides(session);
while (sessionOverrides.MoveNext())
{
var netEntity = sessionOverrides.Current;
var uid = GetEntity(netEntity);
RecursivelyAddOverride(in uid, toSend, entityData, fromTick, toTick,
ref newEntityCount, ref enteredEntityCount, ref dirtyEntityCount, newEntityBudget, enteredEntityBudget, true);
}
sessionOverrides.Dispose();
foreach (var viewerEntity in viewers)
{
RecursivelyAddOverride(in viewerEntity, toSend, entityData, fromTick, toTick,
ref newEntityCount, ref enteredEntityCount, ref dirtyEntityCount, newEntityBudget, enteredEntityBudget);
}
var expandEvent = new ExpandPvsEvent(session);
if (session.AttachedEntity != null)
RaiseLocalEvent(session.AttachedEntity.Value, ref expandEvent, true);
else
RaiseLocalEvent(ref expandEvent);
if (expandEvent.Entities != null)
{
foreach (var entityUid in expandEvent.Entities)
{
RecursivelyAddOverride(in entityUid, toSend, entityData, fromTick, toTick,
ref newEntityCount, ref enteredEntityCount, ref dirtyEntityCount, newEntityBudget, enteredEntityBudget);
}
}
if (expandEvent.RecursiveEntities != null)
{
foreach (var entityUid in expandEvent.RecursiveEntities)
{
RecursivelyAddOverride(in entityUid, toSend, entityData, fromTick, toTick,
ref newEntityCount, ref enteredEntityCount, ref dirtyEntityCount, newEntityBudget, enteredEntityBudget, true);
}
}
// TODO PVS reduce allocs
var entityStates = new List(dirtyEntityCount);
#if DEBUG
// TODO PVS consider removing expensive asserts
var toSendSet = new HashSet(toSend);
DebugTools.AssertEqual(toSend.Count, toSendSet.Count);
foreach (var ent in CollectionsMarshal.AsSpan(toSend))
{
ref var data = ref GetEntityData(entityData, ent);
DebugTools.AssertNotEqual(data.Visibility, PvsEntityVisibility.Invalid);
DebugTools.AssertEqual(data.LastSent, _gameTiming.CurTick);
// if an entity is visible, its parents should always be visible.
if (_xformQuery.GetComponent(data.Entity).ParentUid is not {Valid: true} pUid)
continue;
var npUid = _metaQuery.GetComponent(pUid).NetEntity;
DebugTools.Assert(toSendSet.Contains(npUid),
$"Attempted to send an entity without sending it's parents. Entity: {ToPrettyString(ent)}.");
}
foreach (var ent in CollectionsMarshal.AsSpan(lastSent))
{
ref var data = ref CollectionsMarshal.GetValueRefOrNullRef(entityData, ent);
if (Unsafe.IsNullRef(ref data))
continue;
DebugTools.Assert(data.LastSent != GameTick.Zero);
var isBeingSent = data.LastSent == _gameTiming.CurTick;
DebugTools.AssertEqual(toSendSet.Contains(ent), isBeingSent);
if (!isBeingSent)
DebugTools.Assert(data.LastSent.Value == _gameTiming.CurTick.Value - 1);
}
#endif
// Get entity/component states and update EntityData.LastSent
GetStateList(entityStates, toSend, sessionData, fromTick);
// Tell the client to detach entities that have left their view
// This has to be called after EntityData.LastSent is updated.
var leftView = ProcessLeavePvs(toSend, lastSent, entityData, fromTick, toTick);
if (sessionData.SentEntities.Add(toTick, toSend, out var oldEntry))
{
if (oldEntry.Value.Key > fromTick && sessionData.Overflow == null)
{
// 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.
// This is pretty shit and there is probably a better way of doing this.
sessionData.Overflow = oldEntry.Value;
#if DEBUG
// This happens relatively frequently for the current TickBuffer value, and doesn't really provide any
// useful info when not debugging/testing locally. Hence only enable on DEBUG.
Log.Debug($"Client {session} exceeded tick buffer.");
#endif
}
else
_netUidListPool.Return(oldEntry.Value.Value);
}
if (entityStates.Count == 0) entityStates = default;
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(List toSend,
List? lastSent,
Dictionary entityData, GameTick fromTick, GameTick toTick)
{
// TODO parallelize this with system processing.
// Note that this requires deferring entity-deletion processing to be applied at the beginning of PVS
// processing, instead of happening during system ticks. But it also would make it easy to parallelize
// updating it.
if (lastSent == null)
return null;
var minSize = Math.Max(0, lastSent.Count - toSend.Count);
// TODO PVS reduce allocs
var leftView = new List(minSize);
DebugTools.AssertEqual(toTick, _gameTiming.CurTick);
foreach (var ent in CollectionsMarshal.AsSpan(lastSent))
{
ref var data = ref CollectionsMarshal.GetValueRefOrNullRef(entityData, ent);
if (Unsafe.IsNullRef(ref data))
{
// This should only happen if the entity has been deleted.
// TODO PVS turn into debug assert
if (TryGetEntity(ent, out _))
Log.Error($"Departing entity {ToPrettyString(ent)} is missing entityData entry");
continue;
}
if (data.LastSent == toTick)
continue;
leftView.Add(ent);
data.LastLeftView = toTick;
}
return leftView.Count > 0 ? leftView : null;
}
private void GetSessionViewers(ICommonSession session, [NotNull] ref EntityUid[]? viewers)
{
if (session.Status != SessionStatus.InGame)
{
viewers = Array.Empty();
return;
}
// Fast path
if (session.ViewSubscriptions.Count == 0)
{
if (session.AttachedEntity == null)
{
viewers = Array.Empty();
return;
}
Array.Resize(ref viewers, 1);
viewers[0] = session.AttachedEntity.Value;
return;
}
int i = 0;
if (session.AttachedEntity is { } local)
{
DebugTools.Assert(!session.ViewSubscriptions.Contains(local));
Array.Resize(ref viewers, session.ViewSubscriptions.Count + 1);
viewers[i++] = local;
}
else
{
Array.Resize(ref viewers, session.ViewSubscriptions.Count);
}
foreach (var ent in session.ViewSubscriptions)
{
viewers[i++] = ent;
}
}
// Read Safe
private (Vector2 worldPos, float range, MapId mapId) CalcViewBounds(in EntityUid euid)
{
var xform = _xformQuery.GetComponent(euid);
return (_transform.GetWorldPosition(xform, _xformQuery), _viewSize / 2f, xform.MapID);
}
public sealed class TreePolicy : PooledObjectPolicy> where T : notnull
{
public override RobustTree Create()
{
var pool = new DefaultObjectPool>(new SetPolicy(), MaxVisPoolSize);
return new RobustTree(pool);
}
public override bool Return(RobustTree obj)
{
obj.Clear();
return true;
}
}
private sealed class ChunkPoolPolicy : PooledObjectPolicy> where T : notnull
{
public override Dictionary Create()
{
return new Dictionary(32);
}
public override bool Return(Dictionary obj)
{
obj.Clear();
return true;
}
}
}
[ByRefEvent]
public struct ExpandPvsEvent
{
public readonly ICommonSession Session;
///
/// List of entities that will get added to this session's PVS set.
///
public List? Entities;
///
/// List of entities that will get added to this session's PVS set. Unlike this will also
/// recursively add all children of the given entity.
///
public List? RecursiveEntities;
public ExpandPvsEvent(ICommonSession session)
{
Session = session;
}
}