using System.Linq; using System.Numerics; using System.Threading.Tasks; using Content.Server.Administration.Logs; using Content.Server.Administration.Managers; using Content.Shared.Administration; using Content.Shared.Chunking; using Content.Shared.Database; using Content.Shared.Decals; using Content.Shared.Maps; using Microsoft.Extensions.ObjectPool; using Robust.Server.GameObjects; using Robust.Server.Player; using Robust.Shared; using Robust.Shared.Configuration; using Robust.Shared.Enums; using Robust.Shared.Map; using Robust.Shared.Map.Components; using Robust.Shared.Player; using Robust.Shared.Threading; using Robust.Shared.Timing; using Robust.Shared.Utility; using static Content.Shared.Decals.DecalGridComponent; namespace Content.Server.Decals { public sealed class DecalSystem : SharedDecalSystem { [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly IAdminManager _adminManager = default!; [Dependency] private readonly ITileDefinitionManager _tileDefMan = default!; [Dependency] private readonly IParallelManager _parMan = default!; [Dependency] private readonly ChunkingSystem _chunking = default!; [Dependency] private readonly IConfigurationManager _conf = default!; [Dependency] private readonly IGameTiming _timing = default!; [Dependency] private readonly IAdminLogManager _adminLogger = default!; [Dependency] private readonly MapSystem _mapSystem = default!; private readonly Dictionary> _dirtyChunks = new(); private readonly Dictionary>> _previousSentChunks = new(); private static readonly Vector2 _boundsMinExpansion = new(0.01f, 0.01f); private static readonly Vector2 _boundsMaxExpansion = new(1.01f, 1.01f); private UpdatePlayerJob _updateJob; private List _sessions = new(); // If this ever gets parallelised then you'll want to increase the pooled count. private ObjectPool> _chunkIndexPool = new DefaultObjectPool>( new DefaultPooledObjectPolicy>(), 64); private ObjectPool>> _chunkViewerPool = new DefaultObjectPool>>( new DefaultPooledObjectPolicy>>(), 64); public override void Initialize() { base.Initialize(); _updateJob = new UpdatePlayerJob() { System = this, Sessions = _sessions, }; _playerManager.PlayerStatusChanged += OnPlayerStatusChanged; SubscribeLocalEvent(OnTileChanged); SubscribeNetworkEvent(OnDecalPlacementRequest); SubscribeNetworkEvent(OnDecalRemovalRequest); SubscribeLocalEvent(OnGridSplit); _conf.OnValueChanged(CVars.NetPVS, OnPvsToggle, true); } private void OnPvsToggle(bool value) { if (value == PvsEnabled) return; PvsEnabled = value; if (value) return; foreach (var playerData in _previousSentChunks.Values) { playerData.Clear(); } foreach (var (grid, meta) in EntityQuery(true)) { grid.ForceTick = _timing.CurTick; Dirty(grid, meta); } } private void OnGridSplit(ref PostGridSplitEvent ev) { if (!TryComp(ev.OldGrid, out DecalGridComponent? oldComp)) return; if (!TryComp(ev.Grid, out DecalGridComponent? newComp)) return; // Transfer decals over to the new grid. var enumerator = MapManager.GetGrid(ev.Grid).GetAllTilesEnumerator(); var oldChunkCollection = oldComp.ChunkCollection.ChunkCollection; var chunkCollection = newComp.ChunkCollection.ChunkCollection; while (enumerator.MoveNext(out var tile)) { var tilePos = (Vector2) tile.Value.GridIndices; var chunkIndices = GetChunkIndices(tilePos); if (!oldChunkCollection.TryGetValue(chunkIndices, out var oldChunk)) continue; var bounds = new Box2(tilePos - _boundsMinExpansion, tilePos + _boundsMaxExpansion); var toRemove = new RemQueue(); foreach (var (oldDecalId, decal) in oldChunk.Decals) { if (!bounds.Contains(decal.Coordinates)) continue; var newDecalId = newComp.ChunkCollection.NextDecalId++; var newChunk = chunkCollection.GetOrNew(chunkIndices); newChunk.Decals[newDecalId] = decal; newComp.DecalIndex[newDecalId] = chunkIndices; toRemove.Add(oldDecalId); } foreach (var oldDecalId in toRemove) { oldChunk.Decals.Remove(oldDecalId); oldComp.DecalIndex.Remove(oldDecalId); } DirtyChunk(ev.Grid, chunkIndices, chunkCollection.GetOrNew(chunkIndices)); if (oldChunk.Decals.Count == 0) oldChunkCollection.Remove(chunkIndices); if (toRemove.List?.Count > 0) DirtyChunk(ev.OldGrid, chunkIndices, oldChunk); } } public override void Shutdown() { base.Shutdown(); _playerManager.PlayerStatusChanged -= OnPlayerStatusChanged; _conf.UnsubValueChanged(CVars.NetPVS, OnPvsToggle); } private void OnTileChanged(ref TileChangedEvent args) { if (!args.NewTile.IsSpace(_tileDefMan)) return; if (!TryComp(args.Entity, out DecalGridComponent? grid)) return; var indices = GetChunkIndices(args.NewTile.GridIndices); var toDelete = new HashSet(); if (!grid.ChunkCollection.ChunkCollection.TryGetValue(indices, out var chunk)) return; foreach (var (uid, decal) in chunk.Decals) { if (new Vector2((int) Math.Floor(decal.Coordinates.X), (int) Math.Floor(decal.Coordinates.Y)) == args.NewTile.GridIndices) { toDelete.Add(uid); } } if (toDelete.Count == 0) return; foreach (var decalId in toDelete) { grid.DecalIndex.Remove(decalId); chunk.Decals.Remove(decalId); } DirtyChunk(args.Entity, indices, chunk); if (chunk.Decals.Count == 0) grid.ChunkCollection.ChunkCollection.Remove(indices); } private void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e) { switch (e.NewStatus) { case SessionStatus.InGame: _previousSentChunks[e.Session] = new(); break; case SessionStatus.Disconnected: _previousSentChunks.Remove(e.Session); break; } } private void OnDecalPlacementRequest(RequestDecalPlacementEvent ev, EntitySessionEventArgs eventArgs) { if (eventArgs.SenderSession is not { } session) return; // bad if (!_adminManager.HasAdminFlag(session, AdminFlags.Spawn)) return; var coordinates = GetCoordinates(ev.Coordinates); if (!coordinates.IsValid(EntityManager)) return; if (!TryAddDecal(ev.Decal, coordinates, out _)) return; if (eventArgs.SenderSession.AttachedEntity != null) { _adminLogger.Add(LogType.CrayonDraw, LogImpact.High, $"{ToPrettyString(eventArgs.SenderSession.AttachedEntity.Value):actor} drew a {ev.Decal.Color} {ev.Decal.Id} at {ev.Coordinates}"); } else { _adminLogger.Add(LogType.CrayonDraw, LogImpact.High, $"{eventArgs.SenderSession.Name} drew a {ev.Decal.Color} {ev.Decal.Id} at {ev.Coordinates}"); } } private void OnDecalRemovalRequest(RequestDecalRemovalEvent ev, EntitySessionEventArgs eventArgs) { if (eventArgs.SenderSession is not { } session) return; // bad if (!_adminManager.HasAdminFlag(session, AdminFlags.Spawn)) return; var coordinates = GetCoordinates(ev.Coordinates); if (!coordinates.IsValid(EntityManager)) return; var gridId = coordinates.GetGridUid(EntityManager); if (gridId == null) return; // remove all decals on the same tile foreach (var (decalId, decal) in GetDecalsInRange(gridId.Value, ev.Coordinates.Position)) { if (eventArgs.SenderSession.AttachedEntity != null) { _adminLogger.Add(LogType.CrayonDraw, LogImpact.High, $"{ToPrettyString(eventArgs.SenderSession.AttachedEntity.Value):actor} removed a {decal.Color} {decal.Id} at {ev.Coordinates}"); } else { _adminLogger.Add(LogType.CrayonDraw, LogImpact.High, $"{eventArgs.SenderSession.Name} removed a {decal.Color} {decal.Id} at {ev.Coordinates}"); } RemoveDecal(gridId.Value, decalId); } } protected override void DirtyChunk(EntityUid uid, Vector2i chunkIndices, DecalChunk chunk) { var id = GetNetEntity(uid); chunk.LastModified = _timing.CurTick; if(!_dirtyChunks.ContainsKey(id)) _dirtyChunks[id] = new HashSet(); _dirtyChunks[id].Add(chunkIndices); } public bool TryAddDecal(string id, EntityCoordinates coordinates, out uint decalId, Color? color = null, Angle? rotation = null, int zIndex = 0, bool cleanable = false) { rotation ??= Angle.Zero; var decal = new Decal(coordinates.Position, id, color, rotation.Value, zIndex, cleanable); return TryAddDecal(decal, coordinates, out decalId); } public bool TryAddDecal(Decal decal, EntityCoordinates coordinates, out uint decalId) { decalId = 0; if (!PrototypeManager.HasIndex(decal.Id)) return false; var gridId = coordinates.GetGridUid(EntityManager); if (!TryComp(gridId, out MapGridComponent? grid)) return false; if (_mapSystem.GetTileRef(gridId.Value, grid, coordinates).IsSpace(_tileDefMan)) return false; if (!TryComp(gridId, out DecalGridComponent? comp)) return false; decalId = comp.ChunkCollection.NextDecalId++; var chunkIndices = GetChunkIndices(decal.Coordinates); var chunk = comp.ChunkCollection.ChunkCollection.GetOrNew(chunkIndices); chunk.Decals[decalId] = decal; comp.DecalIndex[decalId] = chunkIndices; DirtyChunk(gridId.Value, chunkIndices, chunk); return true; } public override bool RemoveDecal(EntityUid gridId, uint decalId, DecalGridComponent? component = null) => RemoveDecalInternal(gridId, decalId, out _, component); public override HashSet<(uint Index, Decal Decal)> GetDecalsInRange(EntityUid gridId, Vector2 position, float distance = 0.75f, Func? validDelegate = null) { var decalIds = new HashSet<(uint, Decal)>(); var chunkCollection = ChunkCollection(gridId); var chunkIndices = GetChunkIndices(position); if (chunkCollection == null || !chunkCollection.TryGetValue(chunkIndices, out var chunk)) return decalIds; foreach (var (uid, decal) in chunk.Decals) { if ((position - decal.Coordinates - new Vector2(0.5f, 0.5f)).Length() > distance) continue; if (validDelegate == null || validDelegate(decal)) { decalIds.Add((uid, decal)); } } return decalIds; } public HashSet<(uint Index, Decal Decal)> GetDecalsIntersecting(EntityUid gridUid, Box2 bounds, DecalGridComponent? component = null) { var decalIds = new HashSet<(uint, Decal)>(); var chunkCollection = ChunkCollection(gridUid, component); if (chunkCollection == null) return decalIds; var chunks = new ChunkIndicesEnumerator(bounds, ChunkSize); while (chunks.MoveNext(out var chunkOrigin)) { if (!chunkCollection.TryGetValue(chunkOrigin.Value, out var chunk)) continue; foreach (var (id, decal) in chunk.Decals) { if (!bounds.Contains(decal.Coordinates)) continue; decalIds.Add((id, decal)); } } return decalIds; } /// /// Changes a decals position. Note this will actually result in a new decal being created, possibly on a new grid or chunk. /// /// /// If the new position is invalid, this will result in the decal getting deleted. /// public bool SetDecalPosition(EntityUid gridId, uint decalId, EntityCoordinates coordinates, DecalGridComponent? comp = null) { if (!Resolve(gridId, ref comp)) return false; if (!RemoveDecalInternal(gridId, decalId, out var removed, comp)) return false; return TryAddDecal(removed.WithCoordinates(coordinates.Position), coordinates, out _); } private bool ModifyDecal(EntityUid gridId, uint decalId, Func modifyDecal, DecalGridComponent? comp = null) { if (!Resolve(gridId, ref comp)) return false; if (!comp.DecalIndex.TryGetValue(decalId, out var indices)) return false; var chunk = comp.ChunkCollection.ChunkCollection[indices]; var decal = chunk.Decals[decalId]; chunk.Decals[decalId] = modifyDecal(decal); DirtyChunk(gridId, indices, chunk); return true; } public bool SetDecalColor(EntityUid gridId, uint decalId, Color? value, DecalGridComponent? comp = null) => ModifyDecal(gridId, decalId, x => x.WithColor(value), comp); public bool SetDecalRotation(EntityUid gridId, uint decalId, Angle value, DecalGridComponent? comp = null) => ModifyDecal(gridId, decalId, x => x.WithRotation(value), comp); public bool SetDecalZIndex(EntityUid gridId, uint decalId, int value, DecalGridComponent? comp = null) => ModifyDecal(gridId, decalId, x => x.WithZIndex(value), comp); public bool SetDecalCleanable(EntityUid gridId, uint decalId, bool value, DecalGridComponent? comp = null) => ModifyDecal(gridId, decalId, x => x.WithCleanable(value), comp); public bool SetDecalId(EntityUid gridId, uint decalId, string id, DecalGridComponent? comp = null) { if (!PrototypeManager.HasIndex(id)) throw new ArgumentOutOfRangeException($"Tried to set decal id to invalid prototypeid: {id}"); return ModifyDecal(gridId, decalId, x => x.WithId(id), comp); } public override void Update(float frameTime) { base.Update(frameTime); foreach (var ent in _dirtyChunks.Keys) { if (TryGetEntity(ent, out var uid) && TryComp(uid, out DecalGridComponent? decals)) Dirty(uid.Value, decals); } if (!PvsEnabled) { _dirtyChunks.Clear(); return; } if (PvsEnabled) { _sessions.Clear(); foreach (var session in _playerManager.Sessions) { if (session.Status != SessionStatus.InGame) continue; _sessions.Add(session); } if (_sessions.Count > 0) _parMan.ProcessNow(_updateJob, _sessions.Count); } _dirtyChunks.Clear(); } public void UpdatePlayer(ICommonSession player) { var chunksInRange = _chunking.GetChunksForSession(player, ChunkSize, _chunkIndexPool, _chunkViewerPool); var staleChunks = _chunkViewerPool.Get(); var previouslySent = _previousSentChunks[player]; // Get any chunks not in range anymore // Then, remove them from previousSentChunks (for stuff like grids out of range) // and also mark them as stale for networking. foreach (var (netGrid, oldIndices) in previouslySent) { // Mark the whole grid as stale and flag for removal. if (!chunksInRange.TryGetValue(netGrid, out var chunks)) { previouslySent.Remove(netGrid); // Was the grid deleted? if (TryGetEntity(netGrid, out var gridId) && HasComp(gridId.Value)) { // no -> add it to the list of stale chunks staleChunks[netGrid] = oldIndices; } else { // If the grid was deleted then don't worry about telling the client to delete the chunk. oldIndices.Clear(); _chunkIndexPool.Return(oldIndices); } continue; } var elmo = _chunkIndexPool.Get(); // Get individual stale chunks. foreach (var chunk in oldIndices) { if (chunks.Contains(chunk)) continue; elmo.Add(chunk); } if (elmo.Count == 0) { _chunkIndexPool.Return(elmo); continue; } staleChunks.Add(netGrid, elmo); } var updatedChunks = _chunkViewerPool.Get(); foreach (var (netGrid, gridChunks) in chunksInRange) { var newChunks = _chunkIndexPool.Get(); _dirtyChunks.TryGetValue(netGrid, out var dirtyChunks); if (!previouslySent.TryGetValue(netGrid, out var previousChunks)) newChunks.UnionWith(gridChunks); else { foreach (var index in gridChunks) { if (!previousChunks.Contains(index) || dirtyChunks != null && dirtyChunks.Contains(index)) newChunks.Add(index); } previousChunks.Clear(); _chunkIndexPool.Return(previousChunks); } previouslySent[netGrid] = gridChunks; if (newChunks.Count == 0) _chunkIndexPool.Return(newChunks); else updatedChunks[netGrid] = newChunks; } //send all gridChunks to client SendChunkUpdates(player, updatedChunks, staleChunks); } private void ReturnToPool(Dictionary> chunks) { foreach (var (_, previous) in chunks) { previous.Clear(); _chunkIndexPool.Return(previous); } chunks.Clear(); _chunkViewerPool.Return(chunks); } private void SendChunkUpdates( ICommonSession session, Dictionary> updatedChunks, Dictionary> staleChunks) { var updatedDecals = new Dictionary>(); foreach (var (netGrid, chunks) in updatedChunks) { var gridId = GetEntity(netGrid); var collection = ChunkCollection(gridId); if (collection == null) continue; var gridChunks = new Dictionary(); foreach (var indices in chunks) { gridChunks.Add(indices, collection.TryGetValue(indices, out var chunk) ? chunk : new()); } updatedDecals[netGrid] = gridChunks; } if (updatedDecals.Count != 0 || staleChunks.Count != 0) RaiseNetworkEvent(new DecalChunkUpdateEvent{Data = updatedDecals, RemovedChunks = staleChunks}, session); ReturnToPool(updatedChunks); ReturnToPool(staleChunks); } #region Jobs /// /// Updates per-player data for decals. /// private record struct UpdatePlayerJob : IParallelRobustJob { public int BatchSize => 2; public DecalSystem System; public List Sessions; public void Execute(int index) { System.UpdatePlayer(Sessions[index]); } } #endregion } }