diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index c6a5fa90c..836054257 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -39,7 +39,7 @@ END TEMPLATE--> ### New features -*None yet* +* Add chunk-based entity support to PVS. The API is accessed via ChunkEntitySystem. This can be used instead of manually handling streaming chunk-based data. ### Bugfixes diff --git a/Resources/EnginePrototypes/Debug/chunk_entity.yml b/Resources/EnginePrototypes/Debug/chunk_entity.yml new file mode 100644 index 000000000..ddd0b5688 --- /dev/null +++ b/Resources/EnginePrototypes/Debug/chunk_entity.yml @@ -0,0 +1,9 @@ +- type: entity + id: ChunkEntity + name: Chunk Entity + description: Nullspace entity used to store chunk-level data. + save: false + categories: [ HideSpawnMenu ] + components: + - type: Transform + gridTraversal: false diff --git a/Resources/Locale/en-US/commands.ftl b/Resources/Locale/en-US/commands.ftl index 4d4e32646..be9772c9e 100644 --- a/Resources/Locale/en-US/commands.ftl +++ b/Resources/Locale/en-US/commands.ftl @@ -235,6 +235,22 @@ cmd-togglelightbuf-help = Usage: {$command} cmd-chunkinfo-desc = Gets info about a chunk under your mouse cursor. cmd-chunkinfo-help = Usage: {$command} +cmd-chunkentities-desc = Lists chunk entities in the client viewport OR in the specified range. +cmd-chunkentities-help = Usage: {$command} [ ] +cmd-chunkentities-error-invalid-root = Invalid root entity: {$root} +cmd-chunkentities-error-parse = x, y, and range must be numbers. +cmd-chunkentities-error-nullspace = Current eye is in nullspace. +cmd-chunkentities-error-no-map = No map entity for current eye map {$map}. +cmd-chunkentities-range-header = Chunk entities for {$root} around ({$x}, {$y}) range {$range}: +cmd-chunkentities-viewport-header = Chunk entities in client viewport on map {$map} ({$viewport}): +cmd-chunkentities-total = Total: {$count} +cmd-chunkentities-root-count = Root {$root}: {$count} +cmd-chunkentities-entry = {$netEntity} uid={$uid} root={$root} chunk={$chunk} comps={$componentCount} {$name} +cmd-chunkentities-arg-root = +cmd-chunkentities-arg-x = +cmd-chunkentities-arg-y = +cmd-chunkentities-arg-range = + cmd-rldshader-desc = Reloads all shaders. cmd-rldshader-help = Usage: {$command} diff --git a/Robust.Client/Console/Commands/ChunkEntitiesCommand.cs b/Robust.Client/Console/Commands/ChunkEntitiesCommand.cs new file mode 100644 index 000000000..77a4b3278 --- /dev/null +++ b/Robust.Client/Console/Commands/ChunkEntitiesCommand.cs @@ -0,0 +1,168 @@ +using System.Globalization; +using System.Numerics; +using Robust.Client.Graphics; +using Robust.Shared.Console; +using Robust.Shared.GameObjects; +using Robust.Shared.GameStates; +using Robust.Shared.IoC; +using Robust.Shared.Map; +using Robust.Shared.Map.Components; +using Robust.Shared.Maths; + +namespace Robust.Client.Console.Commands; + +/// +/// Dumps the relevant chunk entities near the client. +/// +internal sealed partial class ChunkEntitiesCommand : LocalizedCommands +{ + [Dependency] private EntityManager _entities = default!; + [Dependency] private IEyeManager _eye = default!; + + public override string Command => "chunkentities"; + + public override void Execute(IConsoleShell shell, string argStr, string[] args) + { + switch (args.Length) + { + case 0: + ExecuteViewport(shell); + return; + case 4: + ExecuteRange(shell, args); + return; + default: + shell.WriteLine(Help); + return; + } + } + + private void ExecuteRange(IConsoleShell shell, string[] args) + { + if (!NetEntity.TryParse(args[0], out var rootNet) || + !_entities.TryGetEntity(rootNet, out var root) || + !_entities.EntityExists(root)) + { + shell.WriteError(Loc.GetString("cmd-chunkentities-error-invalid-root", ("root", args[0]))); + return; + } + + if (!float.TryParse(args[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var x) || + !float.TryParse(args[2], NumberStyles.Float, CultureInfo.InvariantCulture, out var y) || + !float.TryParse(args[3], NumberStyles.Float, CultureInfo.InvariantCulture, out var range)) + { + shell.WriteError(Loc.GetString("cmd-chunkentities-error-parse")); + return; + } + + var chunkEntity = _entities.System(); + var enumerator = chunkEntity.GetChunksInRange(root.Value, new Vector2(x, y), range); + + shell.WriteLine(Loc.GetString( + "cmd-chunkentities-range-header", + ("root", root.Value), + ("x", x.ToString(CultureInfo.InvariantCulture)), + ("y", y.ToString(CultureInfo.InvariantCulture)), + ("range", range.ToString(CultureInfo.InvariantCulture)))); + WriteChunks(shell, enumerator); + } + + private void ExecuteViewport(IConsoleShell shell) + { + var mapId = _eye.CurrentEye.Position.MapId; + + if (mapId == MapId.Nullspace) + { + shell.WriteError(Loc.GetString("cmd-chunkentities-error-nullspace")); + return; + } + + var mapSystem = _entities.System(); + if (!mapSystem.TryGetMap(mapId, out var mapUid)) + { + shell.WriteError(Loc.GetString("cmd-chunkentities-error-no-map", ("map", mapId))); + return; + } + + var viewport = _eye.GetWorldViewport(); + var chunkEntity = _entities.System(); + var total = 0; + + shell.WriteLine(Loc.GetString("cmd-chunkentities-viewport-header", ("map", mapId), ("viewport", viewport))); + total += WriteRootChunks(shell, mapUid.Value, viewport, chunkEntity); + + var transform = _entities.System(); + var query = _entities.AllEntityQueryEnumerator(); + + while (query.MoveNext(out var gridUid, out var grid, out var gridXform)) + { + if (gridXform.MapID != mapId) + continue; + + var localAabb = transform.GetInvWorldMatrix(gridUid).TransformBox(viewport); + if (!grid.LocalAABB.Intersects(localAabb)) + continue; + + total += WriteRootChunks(shell, gridUid, localAabb, chunkEntity); + } + + shell.WriteLine(Loc.GetString("cmd-chunkentities-total", ("count", total))); + } + + private int WriteRootChunks(IConsoleShell shell, EntityUid root, Box2 localAabb, ChunkEntitySystem chunkEntity) + { + var enumerator = chunkEntity.GetChunksIntersecting(root, localAabb); + var count = WriteChunks(shell, enumerator, printTotal: false); + + if (count != 0) + shell.WriteLine(Loc.GetString("cmd-chunkentities-root-count", ("root", root), ("count", count))); + + return count; + } + + private int WriteChunks( + IConsoleShell shell, + ChunkEntitySystem.ChunkEntityEnumerator enumerator, + bool printTotal = true) + { + var metaQuery = _entities.GetEntityQuery(); + var count = 0; + + while (enumerator.MoveNext(out var chunk)) + { + count++; + var netEntity = _entities.GetNetEntity(chunk.Value.Owner); + var name = metaQuery.TryComp(chunk.Value.Owner, out var meta) ? meta.EntityName : string.Empty; + var componentCount = _entities.ComponentCount(chunk.Value.Owner); + + shell.WriteLine( + Loc.GetString( + "cmd-chunkentities-entry", + ("netEntity", netEntity), + ("uid", chunk.Value.Owner), + ("root", chunk.Value.Comp.Root), + ("chunk", chunk.Value.Comp.Chunk), + ("componentCount", componentCount), + ("name", name))); + } + + if (printTotal) + shell.WriteLine(Loc.GetString("cmd-chunkentities-total", ("count", count))); + + return count; + } + + public override CompletionResult GetCompletion(IConsoleShell shell, string[] args) + { + return args.Length switch + { + 1 => CompletionResult.FromHintOptions( + CompletionHelper.NetEntities(args[0], _entities), + Loc.GetString("cmd-chunkentities-arg-root")), + 2 => CompletionResult.FromHint(Loc.GetString("cmd-chunkentities-arg-x")), + 3 => CompletionResult.FromHint(Loc.GetString("cmd-chunkentities-arg-y")), + 4 => CompletionResult.FromHint(Loc.GetString("cmd-chunkentities-arg-range")), + _ => CompletionResult.Empty + }; + } +} diff --git a/Robust.Client/GameStates/ClientGameStateManager.cs b/Robust.Client/GameStates/ClientGameStateManager.cs index d9edd7a1e..d2a386a3a 100644 --- a/Robust.Client/GameStates/ClientGameStateManager.cs +++ b/Robust.Client/GameStates/ClientGameStateManager.cs @@ -60,6 +60,11 @@ public sealed partial class ClientGameStateManager : IClientGameStateManager private readonly List _created = new(); private readonly List _detached = new(); + /// + /// Chunk entities that have been detached and when. Used so we can detach old chunk entities and handle re-attaching them if we receive their state again. + /// + private readonly Dictionary _detachedChunkEntities = new(); + private readonly record struct StateData( EntityUid Uid, NetEntity NetEntity, @@ -219,6 +224,7 @@ public void Reset() _timing.CurTick = GameTick.Zero; _timing.LastRealTick = GameTick.Zero; _lastProcessedInput = 0; + _detachedChunkEntities.Clear(); } private void RunLevelChanged(object? sender, RunLevelChangedEventArgs args) @@ -283,7 +289,12 @@ public void UpdateFullRep(GameState state, bool cloneDelta = false) private void HandlePvsLeaveMessage(MsgStateLeavePvs message) { - QueuePvsDetach(message.Entities, message.Tick); + if (message.Entities.Count != 0) + QueuePvsDetach(message.Entities, message.Tick); + + if (message.ChunkEntities.Count != 0) + DetachChunkEntities(message.ChunkEntities, message.Tick); + PvsLeave?.Invoke(message); } @@ -294,7 +305,11 @@ public void QueuePvsDetach(List entities, GameTick tick) _replayRecording.RecordClientMessage(new ReplayMessage.LeavePvs(entities, tick)); } - public void ClearDetachQueue() => _processor.ClearDetachQueue(); + public void ClearDetachQueue() + { + _processor.ClearDetachQueue(); + _detachedChunkEntities.Clear(); + } /// public void ApplyGameState() @@ -838,11 +853,21 @@ private void ApplyEntityStates(GameState curState, GameState? nextState) var isEnteringPvs = (meta.Flags & MetaDataFlags.Detached) != 0; if (isEnteringPvs) { + if (_detachedChunkEntities.TryGetValue(es.NetEntity, out var detachedTick)) + { + if (curState.ToSequence <= detachedTick) + continue; + + _detachedChunkEntities.Remove(es.NetEntity); + isEnteringPvs = false; + } + // _toApply already contains newly created entities, but these should never be "entering PVS" DebugTools.Assert(!_toApply.ContainsKey(uid.Value)); meta.Flags &= ~MetaDataFlags.Detached; - enteringPvs++; + if (isEnteringPvs) + enteringPvs++; } else if (meta.LastStateApplied >= es.EntityLastModified && meta.LastStateApplied != GameTick.Zero) { @@ -1356,6 +1381,21 @@ private void Detach(GameTick maxTick, } } + private void DetachChunkEntities(List entities, GameTick tick) + { + foreach (var netEntity in entities) + { + if (!_entities.TryGetEntityData(netEntity, out _, out var meta)) + continue; + + if (meta.LastStateApplied > tick) + continue; + + meta._flags |= MetaDataFlags.Detached; + _detachedChunkEntities[netEntity] = tick; + } + } + private void HandleEntityState(in StateData data, IEventBus bus, GameTick toTick) { _compStateWork.Clear(); diff --git a/Robust.Server.IntegrationTests/GameStates/ChunkEntityPvsTest.cs b/Robust.Server.IntegrationTests/GameStates/ChunkEntityPvsTest.cs new file mode 100644 index 000000000..13ae61454 --- /dev/null +++ b/Robust.Server.IntegrationTests/GameStates/ChunkEntityPvsTest.cs @@ -0,0 +1,133 @@ +using System.Linq; +using System.Numerics; +using System.Threading.Tasks; +using NUnit.Framework; +using Robust.Client.GameStates; +using Robust.Shared; +using Robust.Shared.Configuration; +using Robust.Shared.GameObjects; +using Robust.Shared.GameStates; +using Robust.Shared.Map; +using Robust.Shared.Maths; +using Robust.Shared.Network; +using Robust.Shared.Player; + +namespace Robust.UnitTesting.Server.GameStates; + +public sealed class ChunkEntityPvsTest : RobustIntegrationTest +{ + /// + /// Verifies that chunk entities follow normal PVS visibility: they are exposed while in range, filtered while + /// detached, and exposed again after re-entering range. + /// + [Test] + public async Task ChunkEntityDetachesAndReattachesWithPvs() + { + var server = StartServer(); + var client = StartClient(); + + await Task.WhenAll(client.WaitIdleAsync(), server.WaitIdleAsync()); + + var sEntMan = server.ResolveDependency(); + var cEntMan = client.ResolveDependency(); + var confMan = server.ResolveDependency(); + var sPlayerMan = server.ResolveDependency(); + var netMan = client.ResolveDependency(); + var xforms = sEntMan.System(); + + Assert.DoesNotThrow(() => client.SetConnectTarget(server)); + client.Post(() => netMan.ClientConnect(null!, 0, null!)); + server.Post(() => confMan.SetCVar(CVars.NetPVS, true)); + + for (var i = 0; i < 10; i++) + { + await server.WaitRunTicks(1); + await client.WaitRunTicks(1); + } + + NetEntity player = default; + NetEntity chunkEntity = default; + NetEntity mapNet = default; + EntityUid map = default; + EntityCoordinates origin = default; + + await server.WaitPost(() => + { + // Create a player and a chunk entity on the same map chunk so the client initially receives it via PVS. + map = server.System().CreateMap(); + origin = new EntityCoordinates(map, default); + mapNet = sEntMan.GetNetEntity(map); + + var playerUid = sEntMan.SpawnEntity(null, origin); + player = sEntMan.GetNetEntity(playerUid); + + var chunk = sEntMan.System().GetOrCreateChunk(map, Vector2i.Zero); + chunkEntity = sEntMan.GetNetEntity(chunk.Owner); + + var session = sPlayerMan.Sessions.First(); + server.PlayerMan.SetAttachedEntity(session, playerUid); + sPlayerMan.JoinGame(session); + }); + + for (var i = 0; i < 20; i++) + { + await server.WaitRunTicks(1); + await client.WaitRunTicks(1); + } + + MetaDataComponent? chunkMeta = null; + await client.WaitPost(() => + { + // The chunk starts in range, so it should be registered and visible to TryGetChunk. + Assert.That(cEntMan.TryGetEntityData(chunkEntity, out _, out chunkMeta), Is.True); + Assert.That(cEntMan.TryGetEntity(mapNet, out var cMap), Is.True); + Assert.That(cEntMan.System().TryGetChunk(cMap!.Value, Vector2i.Zero, out var chunk), Is.True); + Assert.That(chunk!.Value.Owner, Is.EqualTo(cEntMan.GetEntity(chunkEntity))); + Assert.That(chunkMeta!.Flags & MetaDataFlags.Detached, Is.EqualTo(MetaDataFlags.None)); + }); + + await server.WaitPost(() => + { + // Move the player far enough away that the original map chunk leaves their PVS. + xforms.SetCoordinates(sEntMan.GetEntity(player), new EntityCoordinates(map, new Vector2(1000, 1000))); + }); + + for (var i = 0; i < 20; i++) + { + await server.WaitRunTicks(1); + await client.WaitRunTicks(1); + } + + await client.WaitPost(() => + { + // Detached chunk entities must be filtered out so client-side chunk lookups don't expose it anymore. + Assert.That(chunkMeta!.Flags & MetaDataFlags.Detached, Is.EqualTo(MetaDataFlags.Detached)); + Assert.That(cEntMan.TryGetEntity(mapNet, out var cMap), Is.True); + Assert.That(cEntMan.System().TryGetChunk(cMap!.Value, Vector2i.Zero, out _), Is.False); + }); + + await server.WaitPost(() => + { + // Moving back into range should reattach the same chunk entity and make it queryable again. + xforms.SetCoordinates(sEntMan.GetEntity(player), origin); + }); + + for (var i = 0; i < 20; i++) + { + await server.WaitRunTicks(1); + await client.WaitRunTicks(1); + } + + await client.WaitPost(() => + { + Assert.That(chunkMeta!.Flags & MetaDataFlags.Detached, Is.EqualTo(MetaDataFlags.None)); + Assert.That(cEntMan.TryGetEntity(mapNet, out var cMap), Is.True); + Assert.That(cEntMan.System().TryGetChunk(cMap!.Value, Vector2i.Zero, out var chunk), Is.True); + Assert.That(chunk!.Value.Owner, Is.EqualTo(cEntMan.GetEntity(chunkEntity))); + }); + + await client.WaitPost(() => netMan.ClientDisconnect("")); + await server.WaitRunTicks(5); + await client.WaitRunTicks(5); + } +} diff --git a/Robust.Server.IntegrationTests/GameStates/ChunkEntitySystemTest.cs b/Robust.Server.IntegrationTests/GameStates/ChunkEntitySystemTest.cs new file mode 100644 index 000000000..0f0e38f3b --- /dev/null +++ b/Robust.Server.IntegrationTests/GameStates/ChunkEntitySystemTest.cs @@ -0,0 +1,319 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Reflection; +using NUnit.Framework; +using Robust.Server.GameStates; +using Robust.Shared.GameObjects; +using Robust.Shared.GameStates; +using Robust.Shared.Map; +using Robust.Shared.Maths; +using Robust.Shared.Prototypes; +using Robust.UnitTesting.Server; + +namespace Robust.UnitTesting.Server.GameStates; + +[TestFixture] +[TestOf(typeof(ChunkEntitySystem))] +public sealed partial class ChunkEntitySystemTest +{ + /// + /// Ensures repeated requests for the same root/chunk pair return the same nullspace chunk entity. + /// + [Test] + public void GetOrCreateReusesNullspaceChunkEntity() + { + var (sim, root) = SimulationWithGrid(); + var entMan = sim.Resolve(); + var chunks = entMan.System(); + + var first = chunks.GetOrCreateChunk(root, new Vector2i(1, 2)); + var second = chunks.GetOrCreateChunk(root, new Vector2i(1, 2)); + var xform = entMan.GetComponent(first.Owner); + + Assert.Multiple(() => + { + Assert.That(second.Owner, Is.EqualTo(first.Owner)); + Assert.That(first.Comp.Root, Is.EqualTo(root)); + Assert.That(first.Comp.Chunk, Is.EqualTo(new Vector2i(1, 2))); + Assert.That(xform.MapID, Is.EqualTo(MapId.Nullspace)); + }); + } + + /// + /// Ensures creating a chunk entity registers it as the attached payload entity for the matching PVS chunk. + /// + [Test] + public void RegistersChunkEntityInPvsChunk() + { + var (sim, root) = SimulationWithGrid(); + var entMan = sim.Resolve(); + var chunks = entMan.System(); + var pvs = entMan.System(); + + // Chunk entities are nullspace ents, but PVS should attach them to the matching spatial chunk. + var chunk = chunks.GetOrCreateChunk(root, Vector2i.Zero); + var pvsChunks = GetPvsChunks(pvs); + + var expected = new PvsChunkLocation(root, new Vector2i(0, 0)); + + Assert.Multiple(() => + { + Assert.That(ChunkEntitySystem.ChunkSize, Is.EqualTo(16)); + Assert.That(PvsSystem.ChunkSize, Is.EqualTo(ChunkEntitySystem.ChunkSize)); + + Assert.That(pvsChunks.TryGetValue(expected, out var pvsChunk), Is.True); + Assert.That(pvsChunk!.AttachedChunkEntity, Is.EqualTo(chunk.Owner)); + Assert.That(pvsChunks.ContainsKey(new PvsChunkLocation(root, new Vector2i(1, 0))), Is.False); + }); + } + + /// + /// Ensures PVS chunks reject duplicate attached chunk entities for the same root/chunk pair. + /// + [Test] + public void PvsChunkRejectsSecondAttachedChunkEntity() + { + var (sim, root) = SimulationWithGrid(); + var entMan = sim.Resolve(); + var chunks = entMan.System(); + + chunks.GetOrCreateChunk(root, Vector2i.Zero); + + // There can only be one payload entity per root/chunk pair. + var second = entMan.Spawn(); + var comp = new ChunkEntityComponent + { + Root = root, + Chunk = Vector2i.Zero, + }; + + Assert.Throws(() => entMan.AddComponent(second, comp)); + } + + /// + /// Ensures chunk enumeration returns existing chunk entities inside the requested local range only. + /// + [Test] + public void EnumeratesExistingChunksInRange() + { + var (sim, root) = SimulationWithGrid(); + var entMan = sim.Resolve(); + var chunks = entMan.System(); + + chunks.GetOrCreateChunk(root, new Vector2i(0, 0)); + chunks.GetOrCreateChunk(root, new Vector2i(1, 0)); + chunks.GetOrCreateChunk(root, new Vector2i(5, 0)); + + var found = new HashSet(); + var enumerator = chunks.GetChunksInRange(root, new Vector2(8, 8), 24); + while (enumerator.MoveNext(out var chunk)) + { + found.Add(chunk.Value.Comp.Chunk); + } + + Assert.Multiple(() => + { + Assert.That(found, Does.Contain(new Vector2i(0, 0))); + Assert.That(found, Does.Contain(new Vector2i(1, 0))); + Assert.That(found, Does.Not.Contain(new Vector2i(5, 0))); + }); + } + + /// + /// Ensures typed chunk enumeration only returns chunk entities that have the requested payload component. + /// + [Test] + public void ComponentEnumeratorSkipsChunksWithoutComponent() + { + var (sim, root) = SimulationWithGrid(); + var entMan = sim.Resolve(); + var chunks = entMan.System(); + + var withData = chunks.GetOrCreateChunk(root, new Vector2i(0, 0)); + chunks.GetOrCreateChunk(root, new Vector2i(1, 0)); + entMan.AddComponent(withData.Owner); + + var found = new List(); + var query = entMan.GetEntityQuery(); + var enumerator = chunks.GetChunksInRange(root, new Vector2(8, 8), 24, query); + while (enumerator.MoveNext(out var chunk)) + { + found.Add(chunk.Value.Owner); + } + + Assert.That(found, Is.EquivalentTo(new[] { withData.Owner })); + } + + /// + /// Ensures detached chunk entities remain registered internally but are hidden from public chunk lookups. + /// + [Test] + public void TryGetChunkSkipsDetachedChunkEntity() + { + var (sim, root) = SimulationWithGrid(); + var entMan = sim.Resolve(); + var chunks = entMan.System(); + var meta = entMan.System(); + + var chunk = chunks.GetOrCreateChunk(root, new Vector2i(0, 0)); + // Detached chunk entities still exist client-side, but must not be returned by public chunk lookups. + meta.AddFlag(chunk.Owner, MetaDataFlags.Detached); + + var found = new List(); + var enumerator = chunks.GetChunksInRange(root, new Vector2(8, 8), 24); + while (enumerator.MoveNext(out var enumerated)) + { + found.Add(enumerated.Value.Owner); + } + + Assert.Multiple(() => + { + Assert.That(chunks.TryGetChunk(root, new Vector2i(0, 0), out _), Is.False); + Assert.That(found, Is.Empty); + }); + } + + /// + /// Ensures removing unregisters the chunk entity from both chunk lookup and PVS. + /// + [Test] + public void RemovingChunkComponentUnregistersChunk() + { + var (sim, root) = SimulationWithGrid(); + var entMan = sim.Resolve(); + var chunks = entMan.System(); + var pvs = entMan.System(); + + var chunk = chunks.GetOrCreateChunk(root, new Vector2i(0, 0)); + // Removing the marker component should unregister the chunk from both ChunkEntitySystem and PVS. + entMan.RemoveComponent(chunk.Owner); + + var pvsChunks = GetPvsChunks(pvs); + var location = new PvsChunkLocation(root, new Vector2i(0, 0)); + + Assert.Multiple(() => + { + Assert.That(chunks.TryGetChunk(root, new Vector2i(0, 0), out _), Is.False); + if (pvsChunks.TryGetValue(location, out var pvsChunk)) + Assert.That(pvsChunk.AttachedChunkEntity, Is.Null); + }); + } + + /// + /// Ensures chunk entities with payload components are retained when opportunistic cleanup is requested. + /// + [Test] + public void TryRemoveChunkRetainsChunkWithPayload() + { + var (sim, root) = SimulationWithGrid(); + var entMan = sim.Resolve(); + var chunks = entMan.System(); + + var chunk = chunks.GetOrCreateChunk(root, new Vector2i(0, 0)); + // Payload components mean the chunk still owns data, so it should not be deleted opportunistically. + entMan.AddComponent(chunk.Owner); + + Assert.That(chunks.TryRemoveChunk(chunk), Is.False); + Assert.That(entMan.Deleted(chunk.Owner), Is.False); + Assert.That(chunks.TryGetChunk(root, new Vector2i(0, 0), out _), Is.True); + } + + /// + /// Ensures empty chunk entities are deleted when opportunistic cleanup is requested. + /// + [Test] + public void TryRemoveChunkDeletesChunkWithoutPayload() + { + var (sim, root) = SimulationWithGrid(); + var entMan = sim.Resolve(); + var chunks = entMan.System(); + + var chunk = chunks.GetOrCreateChunk(root, new Vector2i(0, 0)); + // Once the payload is gone, the empty chunk entity can be removed. + entMan.AddComponent(chunk.Owner); + entMan.RemoveComponent(chunk.Owner); + + Assert.That(chunks.TryRemoveChunk(chunk), Is.True); + Assert.That(entMan.Deleted(chunk.Owner), Is.True); + Assert.That(chunks.TryGetChunk(root, new Vector2i(0, 0), out _), Is.False); + } + + /// + /// Ensures deleting a grid deletes any chunk entities rooted on that grid. + /// + [Test] + public void GridDeletionDeletesRelevantChunkEntities() + { + var (sim, root) = SimulationWithGrid(); + var entMan = sim.Resolve(); + var chunks = entMan.System(); + + var chunk = chunks.GetOrCreateChunk(root, new Vector2i(0, 0)); + + // Deleting a grid should clean up any chunk entities rooted on that grid. + entMan.DeleteEntity(root); + + Assert.That(entMan.Deleted(chunk.Owner), Is.True); + } + + /// + /// Ensures deleting a map deletes any chunk entities rooted on that map. + /// + [Test] + public void MapDeletionDeletesRelevantChunkEntities() + { + var sim = Simulation(); + var entMan = sim.Resolve(); + var chunks = entMan.System(); + var map = entMan.System().CreateMap(); + + var chunk = chunks.GetOrCreateChunk(map, new Vector2i(0, 0)); + + // Map-rooted chunk entities need the same cleanup path as grid-rooted ones. + entMan.DeleteEntity(map); + + Assert.That(entMan.Deleted(chunk.Owner), Is.True); + } + + private static (ISimulation Simulation, EntityUid Grid) SimulationWithGrid() + { + var sim = Simulation(); + var entMan = sim.Resolve(); + var map = entMan.System().CreateMap(); + var grid = sim.Resolve().CreateGridEntity(map); + return (sim, grid); + } + + private static ISimulation Simulation() + { + var sim = RobustServerSimulation + .NewSimulation() + .RegisterComponents(factory => factory.RegisterClass()) + .InitializeInstance(); + + var prototypes = sim.Resolve(); + prototypes.LoadString(""" + - type: entity + id: ChunkEntity + name: Chunk Entity + save: false + components: + - type: Transform + gridTraversal: false + """); + prototypes.ResolveResults(); + + return sim; + } + + private static Dictionary GetPvsChunks(PvsSystem pvs) + { + var field = typeof(PvsSystem).GetField("_chunks", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(field, Is.Not.Null); + return (Dictionary) field!.GetValue(pvs)!; + } + + [RegisterComponent] + private sealed partial class TestChunkDataComponent : Component; +} diff --git a/Robust.Server.Testing/RobustServerSimulation.cs b/Robust.Server.Testing/RobustServerSimulation.cs index 66eb92d86..81c47a911 100644 --- a/Robust.Server.Testing/RobustServerSimulation.cs +++ b/Robust.Server.Testing/RobustServerSimulation.cs @@ -24,6 +24,7 @@ using Robust.Shared.ContentPack; using Robust.Shared.Exceptions; using Robust.Shared.GameObjects; +using Robust.Shared.GameStates; using Robust.Shared.IoC; using Robust.Shared.Localization; using Robust.Shared.Log; @@ -311,6 +312,7 @@ public ISimulation InitializeInstance() compFactory.RegisterClass(); compFactory.RegisterClass(); compFactory.RegisterClass(); + compFactory.RegisterClass(); _regDelegate?.Invoke(compFactory); @@ -336,6 +338,7 @@ public ISimulation InitializeInstance() entitySystemMan.LoadExtraSystemType(); entitySystemMan.LoadExtraSystemType(); entitySystemMan.LoadExtraSystemType(); + entitySystemMan.LoadExtraSystemType(); entitySystemMan.LoadExtraSystemType(); entitySystemMan.LoadExtraSystemType(); diff --git a/Robust.Server/GameStates/PvsChunk.cs b/Robust.Server/GameStates/PvsChunk.cs index dc77564a1..164d3d461 100644 --- a/Robust.Server/GameStates/PvsChunk.cs +++ b/Robust.Server/GameStates/PvsChunk.cs @@ -34,6 +34,11 @@ internal sealed class PvsChunk /// public HashSet Children = new(); + /// + /// Nullspace chunk entity that should be sent when this PVS chunk is visible. + /// + public EntityUid? AttachedChunkEntity; + /// /// Sorted list of all entities on this chunk. The list is sorted based on their "proximity" to the root entity in /// the transform hierarchy. I.e., it will list all entities that are directly parented to the grid before listing @@ -132,10 +137,23 @@ public bool PopulateContents(EntityQuery meta, EntityQuery= EntityLifeStage.Terminating) + { + DebugTools.Assert($"PVS chunk contains a deleted or terminating chunk entity: {chunkEntity}"); + MarkDirty(); + return false; + } + + Contents.Add(new ChunkEntity(chunkEntity, chunkMeta)); + } + // First, we add all high-priority children. foreach (var child in Children) { @@ -243,6 +261,12 @@ private void ValidateChunk(EntityQuery query) set.Add(Map.Owner); foreach (var child in Contents) { + if (child.Uid == AttachedChunkEntity) + { + DebugTools.Assert(set.Add(child.Uid), "Child appears more than once in the chunk."); + continue; + } + var parent = query.GetComponent(child.Uid).ParentUid; DebugTools.Assert(set.Contains(parent), "A child's parent is not in the chunk, or is not listed first."); @@ -267,6 +291,7 @@ public void Wipe() Map = default; Location = default; Children.Clear(); + AttachedChunkEntity = null; MarkDirty(); } diff --git a/Robust.Server/GameStates/PvsData.cs b/Robust.Server/GameStates/PvsData.cs index 8083e3922..3eaa7c1b1 100644 --- a/Robust.Server/GameStates/PvsData.cs +++ b/Robust.Server/GameStates/PvsData.cs @@ -113,6 +113,11 @@ internal sealed class PvsSession(ICommonSession session, ResizableMemoryRegion

public readonly List LeftView = new(); + ///

+ /// List of chunk entities that have left the player's view this tick. + /// + public readonly List LeftViewChunkEntities = new(); + public readonly List PlayerStates = new(); public uint LastMessage; public uint LastInput; @@ -188,7 +193,23 @@ internal struct PvsMetadata // TODO PVS maybe store as int? // Theres extra space anyways, and the mask checks always need to convert to an int first, so it'd probably be faster too. public ushort VisMask; - public EntityLifeStage LifeStage; + private byte _flags; + + /// + /// Flag for whether this entity is a chunk entity and should we special-case its detachment. + /// + public bool IsChunkEntity + { + get => (_flags & 0x80) != 0; + set => _flags = value ? (byte) (_flags | 0x80) : (byte) (_flags & ~0x80); + } + + public EntityLifeStage LifeStage + { + get => (EntityLifeStage) (_flags & 0x7F); + set => _flags = (byte) (((byte) value & 0x7F) | (_flags & 0x80)); + } + #if DEBUG // This struct is padded to a size of 16 so it's aligned to cache boundaries nicely. // We have this extra space that isn't being used, diff --git a/Robust.Server/GameStates/PvsSystem.Chunks.cs b/Robust.Server/GameStates/PvsSystem.Chunks.cs index 99466b1f6..b67ff3648 100644 --- a/Robust.Server/GameStates/PvsSystem.Chunks.cs +++ b/Robust.Server/GameStates/PvsSystem.Chunks.cs @@ -7,6 +7,7 @@ using Prometheus; using Robust.Shared.Enums; using Robust.Shared.GameObjects; +using Robust.Shared.GameStates; using Robust.Shared.Map.Components; using Robust.Shared.Map.Enumerators; using Robust.Shared.Maths; @@ -18,12 +19,17 @@ namespace Robust.Server.GameStates; // Partial class for handling PVS chunks. internal sealed partial class PvsSystem { - public const float ChunkSize = 8; + public const float ChunkSize = ChunkEntitySystem.ChunkSize; private readonly Dictionary _chunks = new(); private readonly List _dirtyChunks = new(64); private readonly List _cleanChunks = new(64); + /// + /// Lookup of every chunk entity uid to its corresponding pvschunklocation. + /// + private readonly Dictionary _chunkEntityLocations = new(); + // Store chunks grouped by the root node, for when maps/grids get deleted. private readonly Dictionary> _chunkSets = new(); @@ -277,7 +283,7 @@ private void RemoveEntityFromChunk(EntityUid uid, MetaDataComponent meta) chunk.MarkDirty(); chunk.Children.Remove(uid); - if (chunk.Children.Count > 0) + if (chunk.Children.Count > 0 || chunk.AttachedChunkEntity != null) return; _chunks.Remove(old); @@ -285,6 +291,87 @@ private void RemoveEntityFromChunk(EntityUid uid, MetaDataComponent meta) _chunkSets[old.Uid].Remove(old); } + private void OnChunkEntityAdded(ref ChunkEntityAddedEvent ev) + { + AddChunkEntityToChunk(ev.Entity, ev.Root, ev.Chunk); + } + + private void OnChunkEntityRemoved(ref ChunkEntityRemovedEvent ev) + { + RemoveChunkEntityFromChunk(ev.Entity); + } + + private void AddChunkEntityToChunk(EntityUid uid, EntityUid root, Vector2i chunkIndices) + { + if (!_metaQuery.TryGetComponent(uid, out var meta)) + throw new ArgumentException($"Chunk entity {uid} does not have metadata.", nameof(uid)); + + DebugTools.Assert(meta.EntityLifeStage < EntityLifeStage.Terminating); + DebugTools.Assert(!_chunkEntityLocations.ContainsKey(uid)); + + var location = new PvsChunkLocation(root, chunkIndices); + AddChunkEntityToPvsChunk(uid, location); + + _chunkEntityLocations[uid] = location; + _metadataMemory.GetRef(meta.PvsData.Index).IsChunkEntity = true; + } + + private void AddChunkEntityToPvsChunk(EntityUid uid, PvsChunkLocation location) + { + ref var chunk = ref CollectionsMarshal.GetValueRefOrAddDefault(_chunks, location, out var existing); + if (!existing) + { + chunk = _chunkPool.Get(); + try + { + chunk.Initialize(location, _metaQuery, _xformQuery); + } + catch (Exception) + { + _chunks.Remove(location); + throw; + } + + _chunkSets.GetOrNew(location.Uid).Add(location); + } + + if (chunk!.AttachedChunkEntity is { } attached && attached != uid) + throw new InvalidOperationException($"PVS chunk {location} already has chunk entity {ToPrettyString(attached)} attached while adding {ToPrettyString(uid)}."); + + chunk.MarkDirty(); + chunk.AttachedChunkEntity = uid; + } + + private void RemoveChunkEntityFromChunk(EntityUid uid) + { + if (!_chunkEntityLocations.Remove(uid, out var location)) + return; + + if (_metaQuery.TryGetComponent(uid, out var meta) && meta.PvsData != PvsIndex.Invalid) + _metadataMemory.GetRef(meta.PvsData.Index).IsChunkEntity = false; + + RemoveChunkEntityFromPvsChunk(uid, location); + } + + private void RemoveChunkEntityFromPvsChunk(EntityUid uid, PvsChunkLocation old) + { + if (!_chunks.TryGetValue(old, out var chunk)) + return; + + if (chunk.AttachedChunkEntity != uid) + return; + + chunk.MarkDirty(); + chunk.AttachedChunkEntity = null; + if (chunk.Children.Count > 0) + return; + + _chunks.Remove(old); + _chunkPool.Return(chunk); + if (_chunkSets.TryGetValue(old.Uid, out var locations)) + locations.Remove(old); + } + /// /// Mark a chunk as dirty. /// @@ -329,7 +416,21 @@ private void RemoveRoot(EntityUid root) foreach (var loc in locations) { if (_chunks.Remove(loc, out var chunk)) + { + if (chunk.AttachedChunkEntity is { } chunkEntity) + { + if (_chunkEntityLocations.TryGetValue(chunkEntity, out var chunkLocation) && + chunkLocation == loc) + { + _chunkEntityLocations.Remove(chunkEntity); + } + + if (_metaQuery.TryGetComponent(chunkEntity, out var meta) && meta.PvsData != PvsIndex.Invalid) + _metadataMemory.GetRef(meta.PvsData.Index).IsChunkEntity = false; + } + _chunkPool.Return(chunk); + } } DebugTools.Assert(_chunks.Values.All(x => x.Map.Owner != root && x.Root.Owner != root)); } diff --git a/Robust.Server/GameStates/PvsSystem.DataStorage.cs b/Robust.Server/GameStates/PvsSystem.DataStorage.cs index 814d82945..b3fe9f595 100644 --- a/Robust.Server/GameStates/PvsSystem.DataStorage.cs +++ b/Robust.Server/GameStates/PvsSystem.DataStorage.cs @@ -273,6 +273,7 @@ private void AssignEntityPointer(MetaDataComponent meta) metadata.LastModifiedTick = meta.LastModifiedTick; metadata.VisMask = meta.VisibilityMask; metadata.LifeStage = meta.EntityLifeStage; + metadata.IsChunkEntity = false; #if DEBUG metadata.Marker = uint.MaxValue; #endif diff --git a/Robust.Server/GameStates/PvsSystem.Leave.cs b/Robust.Server/GameStates/PvsSystem.Leave.cs index 37026cf7d..abef2f7ff 100644 --- a/Robust.Server/GameStates/PvsSystem.Leave.cs +++ b/Robust.Server/GameStates/PvsSystem.Leave.cs @@ -54,14 +54,24 @@ private void ProcessLeavePvs(PvsSession session) if (data.LastSeen == toTick) continue; - session.LeftView.Add(IndexToNetEntity(intPtr)); + ref var meta = ref _metadataMemory.GetRef(intPtr.Index); + if (meta.IsChunkEntity) + session.LeftViewChunkEntities.Add(IndexToNetEntity(intPtr)); + else + session.LeftView.Add(IndexToNetEntity(intPtr)); + data.LastLeftView = toTick; } - if (session.LeftView.Count == 0) + if (session.LeftView.Count == 0 && session.LeftViewChunkEntities.Count == 0) return; - var pvsMessage = new MsgStateLeavePvs {Entities = session.LeftView, Tick = toTick}; + var pvsMessage = new MsgStateLeavePvs + { + Entities = session.LeftView, + ChunkEntities = session.LeftViewChunkEntities, + Tick = toTick, + }; // PVS benchmarks use dummy sessions. // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract @@ -69,6 +79,7 @@ private void ProcessLeavePvs(PvsSession session) _netMan.ServerSendMessage(pvsMessage, session.Channel); session.LeftView.Clear(); + session.LeftViewChunkEntities.Clear(); } private record struct PvsLeaveJob(PvsSystem _pvs) : IParallelRobustJob diff --git a/Robust.Server/GameStates/PvsSystem.Session.cs b/Robust.Server/GameStates/PvsSystem.Session.cs index 9e3a87dee..856aff917 100644 --- a/Robust.Server/GameStates/PvsSystem.Session.cs +++ b/Robust.Server/GameStates/PvsSystem.Session.cs @@ -66,6 +66,7 @@ internal void ComputeSessionState(PvsSession session) private void UpdateSession(PvsSession session) { DebugTools.AssertEqual(session.LeftView.Count, 0); + DebugTools.AssertEqual(session.LeftViewChunkEntities.Count, 0); DebugTools.AssertEqual(session.PlayerStates.Count, 0); DebugTools.AssertEqual(session.States.Count, 0); DebugTools.Assert(CullingEnabled && !session.DisableCulling || session.Chunks.Count == 0); diff --git a/Robust.Server/GameStates/PvsSystem.cs b/Robust.Server/GameStates/PvsSystem.cs index 2095023f2..f25f3210f 100644 --- a/Robust.Server/GameStates/PvsSystem.cs +++ b/Robust.Server/GameStates/PvsSystem.cs @@ -134,7 +134,8 @@ public override void Initialize() SubscribeLocalEvent(OnMapChanged); SubscribeLocalEvent(OnGridRemoved); SubscribeLocalEvent(OnTransformStartup); - + SubscribeLocalEvent(OnChunkEntityAdded); + SubscribeLocalEvent(OnChunkEntityRemoved); _playerManager.PlayerStatusChanged += OnPlayerStatusChanged; _transform.OnBeforeMoveEvent += OnEntityMove; EntityManager.EntityAdded += OnEntityAdded; diff --git a/Robust.Shared.CompNetworkGenerator/ComponentNetworkGenerator.cs b/Robust.Shared.CompNetworkGenerator/ComponentNetworkGenerator.cs index 6f31dc6d4..05a18be21 100644 --- a/Robust.Shared.CompNetworkGenerator/ComponentNetworkGenerator.cs +++ b/Robust.Shared.CompNetworkGenerator/ComponentNetworkGenerator.cs @@ -50,7 +50,8 @@ public class ComponentNetworkGenerator : ISourceGenerator TypeDeclarationSyntax classSyntax, CSharpCompilation comp, bool raiseAfterAutoHandle, - bool fieldDeltas) + bool fieldDeltas, + bool deltaFieldsOnly) { var partialInfo = PartialTypeInfo.FromSymbol(classSymbol, classSyntax); var componentName = classSymbol.Name; @@ -547,6 +548,50 @@ public void ApplyToFullState({stateName} fullState) public GameTick[] LastModifiedFields {{ get; set; }} = Array.Empty();"; } + if (deltaFieldsOnly) + { + var outDeltaSb = new StringBuilder(); + + outDeltaSb.Append(""" + // + #nullable enable + using System; + using Robust.Shared.GameStates; + using Robust.Shared.GameObjects; + using Robust.Shared.Analyzers; + using Robust.Shared.Serialization; + using Robust.Shared.Timing; + + """); + + partialInfo.WriteHeader(outDeltaSb); + + outDeltaSb.Append($$""" + + { + /// + public GameTick LastFieldUpdate { get; set; } = GameTick.Zero; + + /// + public GameTick[] LastModifiedFields { get; set; } = Array.Empty(); + + [RobustAutoGenerated] + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + public sealed class {{componentName}}_AutoDeltaFieldSystem : EntitySystem + { + public override void Initialize() + { + EntityManager.ComponentFactory.RegisterNetworkedFields<{{componentName}}>({{fieldsStr}}); + } + } + } + """); + + partialInfo.WriteFooter(outDeltaSb); + + return outDeltaSb.ToString(); + } + string handleState; if (!fieldDeltas) { @@ -685,14 +730,25 @@ public void Execute(GeneratorExecutionContext context) { var raiseEv = false; var fieldDeltas = false; - if (attribute.ConstructorArguments is [{Value: bool raise}, {Value: bool fields}]) + var deltaFieldsOnly = false; + if (attribute.ConstructorArguments.Length > 0 && + attribute.ConstructorArguments[0].Value is bool raise) { // Get the afterautohandle bool, which is first constructor arg raiseEv = raise; + } + + if (attribute.ConstructorArguments.Length > 1 && + attribute.ConstructorArguments[1].Value is bool fields) + { fieldDeltas = fields; } - var source = GenerateSource(context, classType, classSyntax, comp, raiseEv, fieldDeltas); + if (attribute.ConstructorArguments.Length > 2 && + attribute.ConstructorArguments[2].Value is bool deltaOnly) + deltaFieldsOnly = deltaOnly; + + var source = GenerateSource(context, classType, classSyntax, comp, raiseEv, fieldDeltas, deltaFieldsOnly); // can be null if no members marked with network field, which already has a diagnostic, so // just continue if (source == null) diff --git a/Robust.Shared/Analyzers/ComponentNetworkGeneratorAuxiliary.cs b/Robust.Shared/Analyzers/ComponentNetworkGeneratorAuxiliary.cs index 2c5f5ee19..41e3f3ad5 100644 --- a/Robust.Shared/Analyzers/ComponentNetworkGeneratorAuxiliary.cs +++ b/Robust.Shared/Analyzers/ComponentNetworkGeneratorAuxiliary.cs @@ -52,10 +52,20 @@ public sealed class AutoGenerateComponentStateAttribute : Attribute ///
public readonly bool FieldDeltas; - public AutoGenerateComponentStateAttribute(bool raiseAfterAutoHandleState = false, bool fieldDeltas = false) + /// + /// Only generate fields and networked field registration. + /// Used by components with custom state handlers. + /// + public readonly bool DeltaFieldsOnly; + + public AutoGenerateComponentStateAttribute( + bool raiseAfterAutoHandleState = false, + bool fieldDeltas = false, + bool deltaFieldsOnly = false) { RaiseAfterAutoHandleState = raiseAfterAutoHandleState; FieldDeltas = fieldDeltas; + DeltaFieldsOnly = deltaFieldsOnly; } } diff --git a/Robust.Shared/GameObjects/EntityLifeStage.cs b/Robust.Shared/GameObjects/EntityLifeStage.cs index f672977d9..42443b79a 100644 --- a/Robust.Shared/GameObjects/EntityLifeStage.cs +++ b/Robust.Shared/GameObjects/EntityLifeStage.cs @@ -34,5 +34,7 @@ public enum EntityLifeStage : byte /// The entity has been deleted. /// Deleted, + + // Before you go adding a new one fix the 1 extra bit being used in ChunkEntity PVS code. } } diff --git a/Robust.Shared/GameObjects/EntityManager.Components.cs b/Robust.Shared/GameObjects/EntityManager.Components.cs index b36fa6d1b..5f8b37773 100644 --- a/Robust.Shared/GameObjects/EntityManager.Components.cs +++ b/Robust.Shared/GameObjects/EntityManager.Components.cs @@ -1218,6 +1218,7 @@ public IEnumerable GetComponents(EntityUid uid) internal IReadOnlyCollection GetComponentsInternal(EntityUid uid) => _entCompIndex[uid]; /// + [Pure] public int ComponentCount(EntityUid uid) { var comps = _entCompIndex[uid]; diff --git a/Robust.Shared/GameObjects/EntityManager.cs b/Robust.Shared/GameObjects/EntityManager.cs index 2df50e9db..622308a10 100644 --- a/Robust.Shared/GameObjects/EntityManager.cs +++ b/Robust.Shared/GameObjects/EntityManager.cs @@ -538,6 +538,14 @@ public virtual void DeleteEntity(EntityUid? uid) DeleteEntity(uid.Value, meta, TransformQuery.GetComponent(uid.Value)); } + /// + /// Shuts-down and removes given Entity. This is also broadcast to all clients. + /// + public void DeleteEntity(EntityUid e, MetaDataComponent meta) + { + DeleteEntity(e, meta, TransformQuery.GetComponent(e)); + } + /// /// Shuts-down and removes given Entity. This is also broadcast to all clients. /// diff --git a/Robust.Shared/GameObjects/EntitySystem.Proxy.cs b/Robust.Shared/GameObjects/EntitySystem.Proxy.cs index 9c4c05504..0baa19ce6 100644 --- a/Robust.Shared/GameObjects/EntitySystem.Proxy.cs +++ b/Robust.Shared/GameObjects/EntitySystem.Proxy.cs @@ -866,6 +866,14 @@ protected void Del(EntityUid? uid) EntityManager.DeleteEntity(uid); } + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ProxyFor(typeof(EntityManager), nameof(EntityManager.DeleteEntity))] + protected void Del(EntityUid uid, MetaDataComponent meta) + { + EntityManager.DeleteEntity(uid, meta); + } + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] [ProxyFor(typeof(EntityManager), nameof(EntityManager.QueueDeleteEntity))] diff --git a/Robust.Shared/GameObjects/IEntityManager.cs b/Robust.Shared/GameObjects/IEntityManager.cs index d0763cf1b..5f755b90c 100644 --- a/Robust.Shared/GameObjects/IEntityManager.cs +++ b/Robust.Shared/GameObjects/IEntityManager.cs @@ -199,6 +199,11 @@ public void Dirty(Entity ent, MetaDataComponent? /// Uid of entity to remove. void DeleteEntity(EntityUid? uid); + /// + /// Shuts-down and removes the entity with the given . This is also broadcast to all clients. + /// + void DeleteEntity(EntityUid uid, MetaDataComponent meta); + /// /// Shuts-down and removes the entity with the given . This is also broadcast to all clients. /// diff --git a/Robust.Shared/GameStates/ChunkEntityComponent.cs b/Robust.Shared/GameStates/ChunkEntityComponent.cs new file mode 100644 index 000000000..29e2a1bbb --- /dev/null +++ b/Robust.Shared/GameStates/ChunkEntityComponent.cs @@ -0,0 +1,18 @@ +using Robust.Shared.GameObjects; +using Robust.Shared.Maths; +using Robust.Shared.Serialization.Manager.Attributes; + +namespace Robust.Shared.GameStates; + +/// +/// Marks an entity as server-managed chunk data for a map or grid PVS chunk. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState(raiseAfterAutoHandleState: true)] +public sealed partial class ChunkEntityComponent : Component +{ + [DataField, AutoNetworkedField] + public EntityUid Root; + + [DataField, AutoNetworkedField] + public Vector2i Chunk; +} diff --git a/Robust.Shared/GameStates/ChunkEntitySystem.cs b/Robust.Shared/GameStates/ChunkEntitySystem.cs new file mode 100644 index 000000000..a1715024d --- /dev/null +++ b/Robust.Shared/GameStates/ChunkEntitySystem.cs @@ -0,0 +1,309 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Numerics; +using Robust.Shared.GameObjects; +using Robust.Shared.IoC; +using Robust.Shared.Map; +using Robust.Shared.Map.Components; +using Robust.Shared.Map.Enumerators; +using Robust.Shared.Maths; +using Robust.Shared.Prototypes; +using Robust.Shared.Utility; + +namespace Robust.Shared.GameStates; + +/// +/// Manages nullspace entities that are treated as members of map/grid PVS chunks. +/// +public sealed partial class ChunkEntitySystem : EntitySystem +{ + public const int ChunkSize = MapGridComponent.DefaultChunkSize; + private static readonly EntProtoId ChunkEntityPrototype = "ChunkEntity"; + + [Dependency] private EntityQuery _metaQuery = default!; + [Dependency] private EntityQuery _mapQuery = default!; + [Dependency] private EntityQuery _gridQuery = default!; + + private readonly Dictionary<(EntityUid Root, Vector2i Chunk), Entity> _chunks = new(); + private readonly Dictionary> _chunksByRoot = new(); + private readonly Dictionary _chunkKeysByEntity = new(); + + public override void Initialize() + { + SubscribeLocalEvent(OnChunkStartup); + SubscribeLocalEvent(OnChunkShutdown); + SubscribeLocalEvent(OnChunkTerminating); + SubscribeLocalEvent(OnChunkHandleState); + SubscribeLocalEvent(OnChunkParentChanged); + SubscribeLocalEvent(OnMapRemoved); + SubscribeLocalEvent(OnGridRemoved); + } + + public Entity GetOrCreateChunk(EntityUid root, Vector2i chunk) + { + if (_chunks.TryGetValue((root, chunk), out var existing) && + !Deleted(existing.Owner, existing.Comp2) && + !existing.Comp1.Deleted) + { + return (existing.Owner, existing.Comp1); + } + + if (!_mapQuery.HasComp(root) && !_gridQuery.HasComp(root)) + throw new ArgumentException($"Chunk entity root {ToPrettyString(root)} is not a map or grid.", nameof(root)); + + var uid = EntityManager.PredictedSpawn(ChunkEntityPrototype); + + // TODO: compreg / faster addcomp + var comp = new ChunkEntityComponent + { + Root = root, + Chunk = chunk, + }; + AddComp(uid, comp); + + return (uid, comp); + } + + public bool TryGetChunk(EntityUid root, Vector2i chunk, [NotNullWhen(true)] out Entity? entity) + { + if (_chunks.TryGetValue((root, chunk), out var existing) && + !Deleted(existing.Owner, existing.Comp2) && + !existing.Comp1.Deleted && + (existing.Comp2.Flags & MetaDataFlags.Detached) == 0) + { + entity = (existing.Owner, existing.Comp1); + return true; + } + + entity = null; + return false; + } + + public bool TryRemoveChunk(Entity chunk) + { + var meta = chunk.Comp2; + + if (!_metaQuery.Resolve(chunk.Owner, ref meta)) + { + return false; + } + + if (Deleted(chunk.Owner, metaData: meta)) + return true; + + // Still has hanging components. + if (EntityManager.ComponentCount(chunk.Owner) > 3) + return false; + + Del(chunk.Owner, meta); + return true; + } + + public ChunkEntityEnumerator GetChunksInRange(EntityUid root, Vector2 localPosition, float range) + { + return new ChunkEntityEnumerator(this, root, new ChunkIndicesEnumerator(localPosition, range, ChunkSize)); + } + + public ChunkEntityEnumerator GetChunksIntersecting(EntityUid root, Box2 localAabb) + { + return new ChunkEntityEnumerator(this, root, new ChunkIndicesEnumerator(localAabb, ChunkSize)); + } + + public ChunkEntityComponentEnumerator GetChunksInRange( + EntityUid root, + Vector2 localPosition, + float range, + EntityQuery query) + where T : IComponent + { + return new ChunkEntityComponentEnumerator(GetChunksInRange(root, localPosition, range), query); + } + + public ChunkEntityComponentEnumerator GetChunksIntersecting( + EntityUid root, + Box2 localAabb, + EntityQuery query) + where T : IComponent + { + return new ChunkEntityComponentEnumerator(GetChunksIntersecting(root, localAabb), query); + } + + private void OnChunkStartup(Entity ent, ref ComponentAdd args) + { + AddChunk(ent); + } + + private void OnChunkShutdown(Entity ent, ref ComponentRemove args) + { + RemoveChunk(ent); + } + + private void OnChunkTerminating(Entity ent, ref EntityTerminatingEvent args) + { + RemoveChunk(ent); + } + + private void OnChunkHandleState(Entity ent, ref AfterAutoHandleStateEvent args) + { + AddChunk(ent); + } + + private void OnMapRemoved(MapRemovedEvent ev) + { + DeleteRootChunks(ev.Uid); + } + + private void OnGridRemoved(GridRemovalEvent ev) + { + DeleteRootChunks(ev.EntityUid); + } + + private void OnChunkParentChanged(Entity ent, ref EntParentChangedMessage args) + { + if (args.Transform.ParentUid == EntityUid.Invalid && args.Transform.MapID == MapId.Nullspace) + return; + + Log.Error($"Chunk entity {ToPrettyString(ent.Owner)} had its parent changed. Root: {ToPrettyString(ent.Comp.Root)}, chunk: {ent.Comp.Chunk}, old parent: {args.OldParent}, new parent: {args.Transform.ParentUid}, map: {args.Transform.MapID}"); + } + + private void AddChunk(Entity ent) + { + var (uid, comp) = ent; + var key = (comp.Root, comp.Chunk); + + if (_chunkKeysByEntity.TryGetValue(uid, out var oldKey) && oldKey != key) + RemoveChunk(uid, oldKey); + + _chunks[key] = (uid, comp, MetaData(uid)); + _chunkKeysByEntity[uid] = key; + _chunksByRoot.GetOrNew(comp.Root).Add(comp.Chunk); + + var ev = new ChunkEntityAddedEvent(uid, comp.Root, comp.Chunk); + RaiseLocalEvent(ref ev); + } + + private void RemoveChunk(Entity ent) + { + var (uid, _) = ent; + var key = _chunkKeysByEntity.GetValueOrDefault(uid, (ent.Comp.Root, ent.Comp.Chunk)); + + RemoveChunk(uid, key); + } + + private void RemoveChunk(EntityUid uid, (EntityUid Root, Vector2i Chunk) key) + { + if (_chunks.TryGetValue(key, out var existing) && existing.Owner != uid) + return; + + _chunks.Remove(key); + _chunkKeysByEntity.Remove(uid); + + if (_chunksByRoot.TryGetValue(key.Root, out var chunks)) + { + chunks.Remove(key.Chunk); + if (chunks.Count == 0) + _chunksByRoot.Remove(key.Root); + } + + var ev = new ChunkEntityRemovedEvent(uid, key.Root, key.Chunk); + RaiseLocalEvent(ref ev); + } + + private void DeleteRootChunks(EntityUid root) + { + if (!_chunksByRoot.Remove(root, out var chunks)) + return; + + foreach (var chunk in chunks) + { + if (!_chunks.Remove((root, chunk), out var uid)) + continue; + + DebugTools.Assert(_chunkKeysByEntity.ContainsKey(uid.Owner)); + _chunkKeysByEntity.Remove(uid.Owner); + + if (!Deleted(uid.Owner, uid.Comp2)) + { + var ev = new ChunkEntityRemovedEvent(uid.Owner, root, chunk); + RaiseLocalEvent(ref ev); + Del(uid.Owner, uid.Comp2); + } + } + } + + public struct ChunkEntityEnumerator + { + private readonly ChunkEntitySystem _system; + private readonly EntityUid _root; + private ChunkIndicesEnumerator _indices; + + internal ChunkEntityEnumerator(ChunkEntitySystem system, EntityUid root, ChunkIndicesEnumerator indices) + { + _system = system; + _root = root; + _indices = indices; + } + + public bool MoveNext([NotNullWhen(true)] out Entity? entity) + { + while (_indices.MoveNext(out var chunk)) + { + if (_system.TryGetChunk(_root, chunk.Value, out entity)) + return true; + } + + entity = null; + return false; + } + } + + public struct ChunkEntityComponentEnumerator where T : IComponent + { + private ChunkEntityEnumerator _enumerator; + private readonly EntityQuery _query; + + internal ChunkEntityComponentEnumerator(ChunkEntityEnumerator enumerator, EntityQuery query) + { + _enumerator = enumerator; + _query = query; + } + + public bool MoveNext([NotNullWhen(true)] out Entity? entity) + { + while (_enumerator.MoveNext(out var chunk)) + { + if (_query.TryComp(chunk.Value.Owner, out var comp)) + { + entity = (chunk.Value.Owner, chunk.Value.Comp, comp); + return true; + } + } + + entity = null; + return false; + } + } +} + +/// +/// Raised when a chunk entity is added. +/// +[ByRefEvent] +public record struct ChunkEntityAddedEvent(EntityUid Entity, EntityUid Root, Vector2i Chunk) +{ + public readonly EntityUid Entity = Entity; + public readonly EntityUid Root = Root; + public readonly Vector2i Chunk = Chunk; +} + +/// +/// Raised when a chunk entity is removed. +/// +[ByRefEvent] +public record struct ChunkEntityRemovedEvent(EntityUid Entity, EntityUid Root, Vector2i Chunk) +{ + public readonly EntityUid Entity = Entity; + public readonly EntityUid Root = Root; + public readonly Vector2i Chunk = Chunk; +} diff --git a/Robust.Shared/Map/Components/MapGridComponent.cs b/Robust.Shared/Map/Components/MapGridComponent.cs index 8d765b825..e18319638 100644 --- a/Robust.Shared/Map/Components/MapGridComponent.cs +++ b/Robust.Shared/Map/Components/MapGridComponent.cs @@ -33,6 +33,8 @@ namespace Robust.Shared.Map.Components; [RegisterComponent, NetworkedComponent] public sealed partial class MapGridComponent : Component { + public const ushort DefaultChunkSize = 16; + // This field is used for deserialization internally in the map loader. // If you want to remove this, you would have to restructure the map save file. [DataField("index")] @@ -41,7 +43,7 @@ public sealed partial class MapGridComponent : Component // this can be removed [DataField] - internal ushort ChunkSize = 16; + internal ushort ChunkSize = DefaultChunkSize; [ViewVariables] public int ChunkCount => Chunks.Count; diff --git a/Robust.Shared/Network/Messages/MsgStateLeavePvs.cs b/Robust.Shared/Network/Messages/MsgStateLeavePvs.cs index d075006e4..8e86a951f 100644 --- a/Robust.Shared/Network/Messages/MsgStateLeavePvs.cs +++ b/Robust.Shared/Network/Messages/MsgStateLeavePvs.cs @@ -19,7 +19,8 @@ public sealed class MsgStateLeavePvs : NetMessage { public override MsgGroups MsgGroup => MsgGroups.Entity; - public List Entities; + public List Entities = new(); + public List ChunkEntities = new(); public GameTick Tick; public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer) @@ -32,6 +33,14 @@ public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer { Entities.Add(buffer.ReadNetEntity()); } + + length = buffer.ReadInt32(); + ChunkEntities = new(length); + + for (int i = 0; i < length; i++) + { + ChunkEntities.Add(buffer.ReadNetEntity()); + } } public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer serializer) @@ -42,6 +51,12 @@ public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer { buffer.Write(ent); } + + buffer.Write(ChunkEntities.Count); + foreach (var ent in ChunkEntities) + { + buffer.Write(ent); + } } public override NetDeliveryMethod DeliveryMethod => NetDeliveryMethod.ReliableUnordered;