Skip to content
This repository was archived by the owner on Jun 28, 2026. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions Resources/EnginePrototypes/Debug/chunk_entity.yml
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions Resources/Locale/en-US/commands.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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} [<root entity> <x> <y> <range>]
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 = <root entity>
cmd-chunkentities-arg-x = <x>
cmd-chunkentities-arg-y = <y>
cmd-chunkentities-arg-range = <range>

cmd-rldshader-desc = Reloads all shaders.
cmd-rldshader-help = Usage: {$command}

Expand Down
168 changes: 168 additions & 0 deletions Robust.Client/Console/Commands/ChunkEntitiesCommand.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Dumps the relevant chunk entities near the client.
/// </summary>
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<ChunkEntitySystem>();
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<SharedMapSystem>();
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<ChunkEntitySystem>();
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<SharedTransformSystem>();
var query = _entities.AllEntityQueryEnumerator<MapGridComponent, TransformComponent>();

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<MetaDataComponent>();
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
};
}
}
46 changes: 43 additions & 3 deletions Robust.Client/GameStates/ClientGameStateManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ public sealed partial class ClientGameStateManager : IClientGameStateManager
private readonly List<NetEntity> _created = new();
private readonly List<NetEntity> _detached = new();

/// <summary>
/// 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.
/// </summary>
private readonly Dictionary<NetEntity, GameTick> _detachedChunkEntities = new();

private readonly record struct StateData(
EntityUid Uid,
NetEntity NetEntity,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
}

Expand All @@ -294,7 +305,11 @@ public void QueuePvsDetach(List<NetEntity> entities, GameTick tick)
_replayRecording.RecordClientMessage(new ReplayMessage.LeavePvs(entities, tick));
}

public void ClearDetachQueue() => _processor.ClearDetachQueue();
public void ClearDetachQueue()
{
_processor.ClearDetachQueue();
_detachedChunkEntities.Clear();
}

/// <inheritdoc />
public void ApplyGameState()
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -1356,6 +1381,21 @@ private void Detach(GameTick maxTick,
}
}

private void DetachChunkEntities(List<NetEntity> 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();
Expand Down
Loading
Loading