using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Numerics;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.App.World;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
namespace AcDream.App.Streaming;
///
/// Exact result of one committed landblock bucket mutation. Fallible mesh-pin
/// and static-script reconciliation consume this receipt separately, so a
/// callback failure cannot force the spatial mutation to replay.
///
internal sealed record GpuLandblockSpatialPublication(
uint LandblockId,
LoadedLandblock Landblock,
IReadOnlyList AdditionalRenderIds,
IReadOnlyList StaticEntities,
bool RequiresActivation = true,
uint RenderTraversalOrder = 0);
///
/// Render-thread-owned registry of currently-loaded landblocks and their
/// entities. All mutation happens in
/// on the render thread; the renderer reads once per
/// frame.
///
///
/// Replaces the pre-streaming flat _entities list. This class is the
/// single point of truth for "what's in the world right now" and the only
/// thing that mutates spatial buckets. Logical live-object identity and
/// create-time resources are owned by .
///
///
///
/// Pending live entities. Live CreateObject spawns can race
/// against streaming: the server may send a spawn for landblock X before
/// X is loaded into (frequently true on the first
/// frame after login, where the entire post-login spawn flood drains
/// before the streaming controller has finished loading the visible
/// window). To survive this race, stores
/// orphaned spawns in a per-landblock pending bucket. When
/// later loads the landblock, the matching
/// pending entries are merged into the loaded record before the flat
/// view rebuild. retains non-persistent live
/// entries in that pending bucket so a later reload restores the same identity;
/// only dat-static entries are discarded with streaming residence.
///
///
///
/// Threading: not thread-safe. All calls must happen on the render thread.
///
///
public sealed class GpuWorldState : ILiveEntitySpatialQuery
{
private readonly LandblockSpawnAdapter? _wbSpawnAdapter;
private readonly EntityScriptActivator? _entityScriptActivator;
private readonly IWorldGenerationAvailability _availability;
///
/// Phase Post-A.5 #53 (Task 12): optional callback fired before
/// zeroes a landblock's entity
/// list. Wired by GameWindow to
/// EntityClassificationCache.InvalidateLandblock so Tier 1 cache
/// entries get swept on LB demote (Near to Far) and unload. Receives
/// the canonicalized landblock id (low 16 bits forced to 0xFFFF),
/// matching the LandblockHint stored at Populate time.
/// Null when the cache isn't relevant (tests).
///
private readonly System.Action? _onLandblockUnloaded;
public GpuWorldState(
LandblockSpawnAdapter? wbSpawnAdapter = null,
System.Action? onLandblockUnloaded = null,
EntityScriptActivator? entityScriptActivator = null,
IWorldGenerationAvailability? availability = null)
{
_wbSpawnAdapter = wbSpawnAdapter;
_onLandblockUnloaded = onLandblockUnloaded;
_entityScriptActivator = entityScriptActivator;
_availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
}
private readonly Dictionary _loaded = new();
// The old renderer consumes landblocks in Dictionary entry-slot order.
// Make that formerly implicit ordering explicit so both the accepted path
// and the retained render scene share one stable source. Slots are reused
// LIFO, matching Dictionary's remove/add behavior, while enumeration skips
// retired holes.
private readonly List _renderTraversalLandblockSlots = [];
private readonly Stack _freeRenderTraversalLandblockSlots = [];
private readonly Dictionary _renderTraversalSlotByLandblock = [];
private readonly Dictionary _tierByLandblock = new();
private readonly Dictionary _aabbs = new();
private readonly Dictionary _animatedIndexByLandblock = new();
// H-a4: synchronous render borrows. The former yield properties created
// fresh iterator state at every per-frame call site.
private readonly List<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList Entities,
IReadOnlyDictionary? AnimatedById)>
_landblockEntriesView = [];
private readonly List<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList Entities,
IReadOnlyDictionary? AnimatedById)>
_landblockEntriesWithoutAnimatedIndexView = [];
private readonly List<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)>
_landblockBoundsView = [];
private bool _landblockEntriesViewDirty = true;
private bool _landblockEntriesWithoutAnimatedIndexViewDirty = true;
private bool _landblockBoundsViewDirty = true;
private sealed record AnimatedEntityIndex(
LoadedLandblock Source,
IReadOnlyDictionary EntitiesById);
///
/// Per-landblock buffer of live entities awaiting their landblock's
/// arrival. Keyed by canonical landblock id (0xAAAA0xFFFF).
/// Drained into in .
///
private readonly Dictionary> _pendingByLandblock = new();
// Far-to-near promotion can complete before the base far landblock is
// published. Preserve its independently-prepared EnvCell geometry ids
// alongside the parked entity layer so the later AddLandblock transaction
// cannot open the render gate before those shells upload.
private readonly Dictionary> _pendingRenderIdsByLandblock = new();
private readonly HashSet _pendingNearTierLandblocks = new();
///
/// Entities that must survive landblock unloads (e.g. the player character).
/// On RemoveLandblock, these are rescued and re-parked as pending for their
/// current canonical landblock.
///
private readonly HashSet _persistentGuids = new();
// Render-thread-owned flat view over all entities across loaded landblocks.
// The update and render phases are serialized, so mutating this list during
// the update phase is both safe and substantially cheaper than rebuilding a
// full-world array for every CreateObject in an inbound spawn flood.
private readonly List _flatEntities = new();
private readonly Dictionary _flatEntityIndices =
new(ReferenceEqualityComparer.Instance);
private readonly Dictionary _projectionLocations =
new(ReferenceEqualityComparer.Instance);
// Short-range consumers must never scan each landblock's DAT-static list.
// This index mirrors only loaded live projections and changes at the same
// transaction boundary as _projectionLocations.
private readonly Dictionary> _loadedLiveByLandblock = new();
// RuntimeEntityKey is the complete materialized identity. Local IDs remain
// stable through retryable teardown, while the incarnation prevents a
// delayed spatial edge from reaching a later reuse of the same local ID.
private readonly Dictionary _liveProjectionByKey = new();
private readonly Dictionary _visibleLiveProjectionCounts = new();
private readonly Dictionary _visibilityBeforeMutation = new();
private readonly Queue<(RuntimeEntityKey Key, bool Visible)> _visibilityTransitions = new();
private readonly List _guidRemovalScratch = new();
private bool _dispatchingVisibilityTransitions;
private int _mutationDepth;
private long _visibilityCommitCount;
private ulong _flatViewGeneration;
private bool _flatMembershipDirty;
public IReadOnlyList Entities => _flatEntities;
public ulong FlatViewGeneration => _flatViewGeneration;
public IReadOnlyCollection LoadedLandblockIds => _loaded.Keys;
public event Action? LiveProjectionVisibilityChanged;
public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId);
public bool IsNearTier(uint landblockId) =>
_tierByLandblock.TryGetValue(landblockId, out var tier)
&& tier == LandblockStreamTier.Near;
public bool IsNearTierOrPending(uint landblockId) =>
IsNearTier(landblockId) || _pendingNearTierLandblocks.Contains(landblockId);
public bool IsRenderReady(uint landblockId) =>
_loaded.ContainsKey(landblockId)
&& (_wbSpawnAdapter?.IsLandblockRenderReady(landblockId) ?? true);
///
/// True when the exact Runtime-issued local projection belongs to a loaded
/// spatial bucket. This deliberately ignores the world-generation
/// presentation gate: destination objects must acquire their render
/// resources while portal/login reveal keeps normal world consumers closed.
///
public bool IsLiveEntityProjectionResident(RuntimeEntityKey key) =>
key.LocalEntityId != 0
&& _visibleLiveProjectionCounts.ContainsKey(key);
public bool IsLiveEntityVisible(RuntimeEntityKey key) =>
_availability.IsWorldAvailable
&& IsLiveEntityProjectionResident(key);
///
/// Try to grab the loaded record for a landblock — useful for callers
/// that need to enumerate entities before the landblock is dropped
/// (e.g. unregistering dynamic lights on a RemoveLandblock).
///
public bool TryGetLandblock(uint landblockId, out LoadedLandblock? lb)
{
if (_loaded.TryGetValue(landblockId, out var found))
{
lb = found;
return true;
}
lb = null;
return false;
}
///
/// Store the axis-aligned bounding box for a loaded landblock. Called from
/// the render thread after the terrain mesh is built and uploaded.
///
public void SetLandblockAabb(uint landblockId, Vector3 min, Vector3 max)
{
_aabbs[landblockId] = (min, max);
InvalidateLandblockRenderViews(entries: true, bounds: true);
}
///
/// Per-landblock iteration with AABB data for use by the frustum-culling
/// draw path. Landblocks without a stored AABB yield
/// for both corners, which the culler will conservatively treat as visible.
///
///
/// A.5 T17: also yields an AnimatedById dictionary derived from the
/// landblock's entity list. This lets
/// skip the full entity walk when the landblock is frustum-culled but animated
/// entities inside it must still be processed (Change #1). The lookup is
/// cached against the record identity, so a
/// stable scene does not allocate one dictionary per landblock per frame.
/// Live projection mutations update the mutable resident bucket in place and
/// explicitly invalidate exactly the affected index generation.
///
///
public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList Entities,
IReadOnlyDictionary? AnimatedById)> LandblockEntries
=> GetLandblockEntriesView(includeAnimatedIndex: true);
///
/// Per-landblock render entries without the animated lookup dictionary.
/// Static render passes use this to avoid rebuilding an index they cannot
/// consume.
///
public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList Entities,
IReadOnlyDictionary? AnimatedById)> LandblockEntriesWithoutAnimatedIndex
=> GetLandblockEntriesView(includeAnimatedIndex: false);
///
/// Lightweight bounds-only enumeration for overlays and diagnostics.
/// Does not walk entity lists.
///
public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds
{
get
{
if (!_availability.IsWorldAvailable)
return Array.Empty<(uint, Vector3, Vector3)>();
if (!_landblockBoundsViewDirty)
return _landblockBoundsView;
_landblockBoundsView.Clear();
for (int slot = 0; slot < _renderTraversalLandblockSlots.Count; slot++)
{
uint landblockId = _renderTraversalLandblockSlots[slot];
if (landblockId == 0)
continue;
if (_aabbs.TryGetValue(landblockId, out var aabb))
_landblockBoundsView.Add((landblockId, aabb.Min, aabb.Max));
else
_landblockBoundsView.Add(
(landblockId, Vector3.Zero, Vector3.Zero));
}
_landblockBoundsViewDirty = false;
return _landblockBoundsView;
}
}
private IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList Entities,
IReadOnlyDictionary? AnimatedById)> GetLandblockEntriesView(
bool includeAnimatedIndex)
{
if (!_availability.IsWorldAvailable)
{
return Array.Empty<(uint, Vector3, Vector3,
IReadOnlyList,
IReadOnlyDictionary?)>();
}
List<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList Entities,
IReadOnlyDictionary? AnimatedById)> view =
includeAnimatedIndex
? _landblockEntriesView
: _landblockEntriesWithoutAnimatedIndexView;
bool dirty = includeAnimatedIndex
? _landblockEntriesViewDirty
: _landblockEntriesWithoutAnimatedIndexViewDirty;
if (!dirty)
return view;
view.Clear();
for (int slot = 0; slot < _renderTraversalLandblockSlots.Count; slot++)
{
uint landblockId = _renderTraversalLandblockSlots[slot];
if (landblockId == 0)
continue;
LoadedLandblock landblock = _loaded[landblockId];
IReadOnlyDictionary? byId = null;
if (includeAnimatedIndex)
{
if (!_animatedIndexByLandblock.TryGetValue(landblockId, out var cached)
|| !ReferenceEquals(cached.Source, landblock))
{
var rebuilt = new Dictionary(landblock.Entities.Count);
foreach (var entity in landblock.Entities)
rebuilt[entity.Id] = entity;
cached = new AnimatedEntityIndex(landblock, rebuilt);
_animatedIndexByLandblock[landblockId] = cached;
}
byId = cached.EntitiesById;
}
if (_aabbs.TryGetValue(landblockId, out var aabb))
view.Add(
(landblockId, aabb.Min, aabb.Max, landblock.Entities, byId));
else
view.Add(
(landblockId, Vector3.Zero, Vector3.Zero, landblock.Entities, byId));
}
if (includeAnimatedIndex)
_landblockEntriesViewDirty = false;
else
_landblockEntriesWithoutAnimatedIndexViewDirty = false;
return view;
}
private void InvalidateLandblockRenderViews(bool entries, bool bounds)
{
if (entries)
{
_landblockEntriesViewDirty = true;
_landblockEntriesWithoutAnimatedIndexViewDirty = true;
}
if (bounds)
_landblockBoundsViewDirty = true;
}
internal bool TryGetRenderTraversalSortKey(
WorldEntity entity,
out RenderSortKey sortKey)
{
ArgumentNullException.ThrowIfNull(entity);
if (_projectionLocations.TryGetValue(
entity,
out ProjectionLocation location)
&& location.IsLoaded
&& _renderTraversalSlotByLandblock.TryGetValue(
location.LandblockId,
out int landblockSlot))
{
sortKey = RenderTraversalSortKey.Compose(
checked((uint)landblockSlot + 1),
location.BucketIndex);
return true;
}
sortKey = default;
return false;
}
private uint GetRenderTraversalOrder(uint landblockId)
{
if (!_renderTraversalSlotByLandblock.TryGetValue(
landblockId,
out int slot))
{
throw new InvalidOperationException(
$"Loaded landblock 0x{landblockId:X8} has no render traversal slot.");
}
return checked((uint)slot + 1);
}
private void AddLoadedLandblock(
uint landblockId,
LoadedLandblock landblock)
{
if (!_loaded.TryAdd(landblockId, landblock))
{
throw new InvalidOperationException(
$"Landblock 0x{landblockId:X8} is already loaded.");
}
int slot;
if (_freeRenderTraversalLandblockSlots.TryPop(out int freeSlot))
{
slot = freeSlot;
if (_renderTraversalLandblockSlots[slot] != 0)
{
throw new InvalidOperationException(
$"Render traversal slot {slot} was not free.");
}
_renderTraversalLandblockSlots[slot] = landblockId;
}
else
{
slot = _renderTraversalLandblockSlots.Count;
_renderTraversalLandblockSlots.Add(landblockId);
}
if (!_renderTraversalSlotByLandblock.TryAdd(landblockId, slot))
{
throw new InvalidOperationException(
$"Landblock 0x{landblockId:X8} already owns a render traversal slot.");
}
InvalidateLandblockRenderViews(entries: true, bounds: true);
}
private bool RemoveLoadedLandblock(
uint landblockId,
[NotNullWhen(true)]
out LoadedLandblock? landblock)
{
if (!_loaded.Remove(landblockId, out landblock))
return false;
if (!_renderTraversalSlotByLandblock.Remove(
landblockId,
out int slot)
|| _renderTraversalLandblockSlots[slot] != landblockId)
{
throw new InvalidOperationException(
$"Loaded landblock 0x{landblockId:X8} has inconsistent render traversal state.");
}
_renderTraversalLandblockSlots[slot] = 0;
_freeRenderTraversalLandblockSlots.Push(slot);
InvalidateLandblockRenderViews(entries: true, bounds: true);
return true;
}
///
/// Total live entities currently parked in the pending bucket waiting
/// for their landblock to arrive. Useful diagnostic for verifying the
/// pending path is doing its job.
///
public int PendingLiveEntityCount => _pendingByLandblock.Values.Sum(list => list.Count);
public int PendingBucketCount => _pendingByLandblock.Count;
public int PendingRescueCount => _persistentRescued.Count;
public int PersistentGuidCount => _persistentGuids.Count;
public int PendingVisibilityTransitionCount => _visibilityTransitions.Count;
internal long VisibilityCommitCount => _visibilityCommitCount;
internal int OriginRecenterSpatialOperationCount =>
Math.Max(
1,
_loaded.Count
+ _pendingByLandblock.Count
+ _pendingRenderIdsByLandblock.Count
+ _pendingNearTierLandblocks.Count
+ _projectionLocations.Count);
///
/// Groups spatial mutations into one visibility publication transaction.
/// Bucket and flat-view contents become readable immediately on the render
/// thread; observer edges are computed from the before/after state and drain
/// only when the outermost scope ends. This is what keeps a loaded-to-loaded
/// rebucket from exposing an implementation-only hidden/visible pulse and
/// lets one inbound network drain commit thousands of spawns without global
/// rebuilds between them.
///
public MutationBatch BeginMutationBatch()
{
_mutationDepth++;
return new MutationBatch(this);
}
///
/// Copy live server projections from the bounded landblock neighborhood
/// around . This is the spatial
/// candidate seam for short-range consumers such as retail radar; they
/// still apply their own Hidden/classification/range rules.
///
public void CopyLiveEntitiesNearLandblock(
uint centerCellOrLandblockId,
int landblockRadius,
List> destination)
{
ArgumentNullException.ThrowIfNull(destination);
ArgumentOutOfRangeException.ThrowIfNegative(landblockRadius);
destination.Clear();
if (!_availability.IsWorldAvailable)
return;
int centerX = (int)((centerCellOrLandblockId >> 24) & 0xFFu);
int centerY = (int)((centerCellOrLandblockId >> 16) & 0xFFu);
for (int dx = -landblockRadius; dx <= landblockRadius; dx++)
for (int dy = -landblockRadius; dy <= landblockRadius; dy++)
{
int x = centerX + dx;
int y = centerY + dy;
if ((uint)x > 0xFFu || (uint)y > 0xFFu)
continue;
uint landblockId = ((uint)x << 24) | ((uint)y << 16) | 0xFFFFu;
if (!_loadedLiveByLandblock.TryGetValue(
landblockId,
out HashSet? liveEntities))
continue;
foreach (WorldEntity entity in liveEntities)
destination.Add(new KeyValuePair(entity.ServerGuid, entity));
}
}
///
/// Copies every published projection from a bounded destination
/// neighborhood for presentation-readiness work. Unlike gameplay-facing
/// spatial queries, this remains available while the world generation is
/// quiesced: the reveal barrier must prepare destination resources before
/// it makes that generation observable.
///
internal void CopyPublishedEntitiesNearLandblockForReadiness(
uint centerCellOrLandblockId,
int landblockRadius,
List destination)
{
ArgumentNullException.ThrowIfNull(destination);
ArgumentOutOfRangeException.ThrowIfNegative(landblockRadius);
destination.Clear();
int centerX = (int)((centerCellOrLandblockId >> 24) & 0xFFu);
int centerY = (int)((centerCellOrLandblockId >> 16) & 0xFFu);
for (int dx = -landblockRadius; dx <= landblockRadius; dx++)
for (int dy = -landblockRadius; dy <= landblockRadius; dy++)
{
int x = centerX + dx;
int y = centerY + dy;
if ((uint)x > 0xFFu || (uint)y > 0xFFu)
continue;
uint landblockId =
((uint)x << 24) | ((uint)y << 16) | 0xFFFFu;
if (!_loaded.TryGetValue(
landblockId,
out LoadedLandblock? landblock))
{
continue;
}
for (int i = 0; i < landblock.Entities.Count; i++)
destination.Add(landblock.Entities[i]);
}
}
public readonly ref struct MutationBatch
{
private readonly GpuWorldState _owner;
public MutationBatch(GpuWorldState owner) => _owner = owner;
public void Dispose() => _owner.EndMutationBatch();
}
private readonly record struct ProjectionLocation(
uint LandblockId,
bool IsLoaded,
int BucketIndex,
RuntimeEntityKey Key);
private void EndMutationBatch()
{
if (_mutationDepth <= 0)
throw new InvalidOperationException("GpuWorldState mutation scope underflow.");
if (--_mutationDepth != 0)
return;
foreach ((RuntimeEntityKey key, bool wasVisible) in _visibilityBeforeMutation)
{
bool isVisible = _visibleLiveProjectionCounts.ContainsKey(key);
if (wasVisible != isVisible)
_visibilityTransitions.Enqueue((key, isVisible));
}
_visibilityBeforeMutation.Clear();
_visibilityCommitCount++;
if (_flatMembershipDirty)
{
_flatViewGeneration++;
_flatMembershipDirty = false;
}
DrainVisibilityTransitions();
if (EntityVanishProbe.Enabled)
ProbeFlatViewTransitions();
}
private void AdjustVisibleLiveProjection(
RuntimeEntityKey key,
WorldEntity entity,
int delta)
{
if (entity.ServerGuid == 0 || key.LocalEntityId == 0 || delta == 0)
return;
if (key.LocalEntityId != entity.Id)
throw new InvalidOperationException(
"A live visibility key must match its WorldEntity local ID.");
_visibleLiveProjectionCounts.TryGetValue(key, out int priorCount);
if (!_visibilityBeforeMutation.ContainsKey(key))
_visibilityBeforeMutation.Add(key, priorCount > 0);
int nextCount = checked(priorCount + delta);
if (nextCount < 0)
throw new InvalidOperationException(
$"Live projection visibility count underflow for local " +
$"0x{key.LocalEntityId:X8}/{key.Incarnation} / " +
$"server 0x{entity.ServerGuid:X8}.");
if (nextCount == 0)
{
_visibleLiveProjectionCounts.Remove(key);
}
else
{
_visibleLiveProjectionCounts[key] = nextCount;
}
}
private static LoadedLandblock NormalizeLoadedLandblock(
LoadedLandblock source,
List? entities = null) =>
new(
source.LandblockId,
source.Heightmap,
entities ?? new List(source.Entities),
PhysicsDatBundle.Empty);
private static List MutableEntities(LoadedLandblock landblock) =>
(List)landblock.Entities;
private void AddFlatEntity(WorldEntity entity)
{
if (!_flatEntityIndices.TryAdd(entity, _flatEntities.Count))
throw new InvalidOperationException(
$"Entity 0x{entity.Id:X8} already exists in the flat render view.");
_flatEntities.Add(entity);
_flatMembershipDirty = true;
}
private void RemoveFlatEntity(WorldEntity entity)
{
if (!_flatEntityIndices.Remove(entity, out int index))
throw new InvalidOperationException(
$"Loaded entity 0x{entity.Id:X8} was absent from the flat render view.");
int lastIndex = _flatEntities.Count - 1;
if (index != lastIndex)
{
WorldEntity moved = _flatEntities[lastIndex];
_flatEntities[index] = moved;
_flatEntityIndices[moved] = index;
}
_flatEntities.RemoveAt(lastIndex);
_flatMembershipDirty = true;
}
private void SetProjectionLocation(
WorldEntity entity,
uint landblockId,
bool isLoaded,
int bucketIndex,
RuntimeEntityKey? requestedKey = null)
{
if (entity.ServerGuid == 0)
return;
bool isNew = !_projectionLocations.TryGetValue(
entity,
out ProjectionLocation priorLocation);
RuntimeEntityKey key = isNew
? requestedKey ?? new RuntimeEntityKey(entity.Id, 0)
: priorLocation.Key;
if (key.LocalEntityId == 0 || key.LocalEntityId != entity.Id)
{
throw new InvalidOperationException(
"The exact spatial projection key must match the WorldEntity local ID.");
}
if (!isNew && requestedKey is { } requested && requested != key)
{
throw new InvalidOperationException(
"A live spatial projection cannot change exact Runtime identity.");
}
if (!isNew
&& priorLocation.IsLoaded
&& (!isLoaded || priorLocation.LandblockId != landblockId))
{
RemoveLoadedLiveIndex(priorLocation.LandblockId, entity);
}
_projectionLocations[entity] = new ProjectionLocation(
landblockId,
isLoaded,
bucketIndex,
key);
if (isLoaded
&& (isNew || !priorLocation.IsLoaded || priorLocation.LandblockId != landblockId))
{
AddLoadedLiveIndex(landblockId, entity);
}
if (!isNew)
return;
if (!_liveProjectionByKey.TryAdd(key, entity))
{
throw new InvalidOperationException(
$"Runtime projection {key} is already spatially owned.");
}
}
private void RemoveProjectionLocation(WorldEntity entity)
{
if (!_projectionLocations.Remove(entity, out ProjectionLocation location)
|| entity.ServerGuid == 0)
return;
if (location.IsLoaded)
RemoveLoadedLiveIndex(location.LandblockId, entity);
if (!_liveProjectionByKey.Remove(
location.Key,
out WorldEntity? indexed))
{
throw new InvalidOperationException(
$"Live projection {location.Key} had no exact-key index entry.");
}
if (!ReferenceEquals(indexed, entity))
{
throw new InvalidOperationException(
$"Runtime projection {location.Key} resolved a different owner.");
}
}
private void AddLoadedLiveIndex(uint landblockId, WorldEntity entity)
{
if (!_loadedLiveByLandblock.TryGetValue(
landblockId,
out HashSet? entities))
{
entities = new HashSet(ReferenceEqualityComparer.Instance);
_loadedLiveByLandblock.Add(landblockId, entities);
}
if (!entities.Add(entity))
{
throw new InvalidOperationException(
$"Live entity 0x{entity.Id:X8} already exists in landblock index 0x{landblockId:X8}.");
}
}
private void RemoveLoadedLiveIndex(uint landblockId, WorldEntity entity)
{
if (!_loadedLiveByLandblock.TryGetValue(
landblockId,
out HashSet? entities)
|| !entities.Remove(entity))
{
throw new InvalidOperationException(
$"Live entity 0x{entity.Id:X8} is absent from landblock index 0x{landblockId:X8}.");
}
if (entities.Count == 0)
_loadedLiveByLandblock.Remove(landblockId);
}
private void AddLoadedProjection(
uint landblockId,
WorldEntity entity,
RuntimeEntityKey? requestedKey = null)
{
LoadedLandblock landblock = _loaded[landblockId];
List entities = MutableEntities(landblock);
int bucketIndex = entities.Count;
entities.Add(entity);
_animatedIndexByLandblock.Remove(landblockId);
InvalidateLandblockRenderViews(entries: true, bounds: false);
AddFlatEntity(entity);
if (entity.ServerGuid != 0)
{
SetProjectionLocation(
entity,
landblockId,
isLoaded: true,
bucketIndex,
requestedKey);
AdjustVisibleLiveProjection(
_projectionLocations[entity].Key,
entity,
+1);
}
}
private void RemoveLoadedProjection(WorldEntity entity)
{
if (!_projectionLocations.TryGetValue(entity, out ProjectionLocation location)
|| !location.IsLoaded
|| !_loaded.TryGetValue(location.LandblockId, out LoadedLandblock? landblock))
{
throw new InvalidOperationException(
$"Live entity 0x{entity.Id:X8} has no loaded projection location.");
}
List entities = MutableEntities(landblock);
int lastIndex = entities.Count - 1;
if ((uint)location.BucketIndex >= (uint)entities.Count
|| !ReferenceEquals(entities[location.BucketIndex], entity))
{
throw new InvalidOperationException(
$"Loaded projection index for entity 0x{entity.Id:X8} is stale.");
}
if (location.BucketIndex != lastIndex)
{
WorldEntity moved = entities[lastIndex];
entities[location.BucketIndex] = moved;
if (moved.ServerGuid != 0)
{
SetProjectionLocation(
moved,
location.LandblockId,
isLoaded: true,
location.BucketIndex);
}
}
entities.RemoveAt(lastIndex);
_animatedIndexByLandblock.Remove(location.LandblockId);
InvalidateLandblockRenderViews(entries: true, bounds: false);
RemoveFlatEntity(entity);
RemoveProjectionLocation(entity);
AdjustVisibleLiveProjection(location.Key, entity, -1);
}
private void RemoveLoadedLandblockFromFlatView(LoadedLandblock landblock)
{
foreach (WorldEntity entity in landblock.Entities)
{
RemoveFlatEntity(entity);
if (entity.ServerGuid != 0)
{
RuntimeEntityKey key = _projectionLocations[entity].Key;
RemoveProjectionLocation(entity);
AdjustVisibleLiveProjection(key, entity, -1);
}
}
}
private void AddLoadedLandblockToFlatView(LoadedLandblock landblock)
{
for (int i = 0; i < landblock.Entities.Count; i++)
{
WorldEntity entity = landblock.Entities[i];
AddFlatEntity(entity);
if (entity.ServerGuid != 0)
SetProjectionLocation(entity, landblock.LandblockId, isLoaded: true, i);
if (entity.ServerGuid != 0)
{
AdjustVisibleLiveProjection(
_projectionLocations[entity].Key,
entity,
+1);
}
}
}
public void AddLandblock(
LoadedLandblock landblock,
IEnumerable? additionalRenderIds = null,
LandblockStreamTier tier = LandblockStreamTier.Near)
{
using MutationBatch mutation = BeginMutationBatch();
GpuLandblockSpatialPublication publication =
CommitLandblockSpatialCore(landblock, additionalRenderIds, tier);
ActivateLandblockPresentation(publication);
}
///
/// Commits only canonical buckets, indexes, pending-live merge, tier, and
/// readiness-id ownership. The caller holds one outer
/// through the subsequent
/// call so live visibility
/// observers retain the shipped commit boundary.
///
internal GpuLandblockSpatialPublication CommitLandblockSpatial(
LoadedLandblock landblock,
IEnumerable? additionalRenderIds,
LandblockStreamTier tier) =>
CommitLandblockSpatialCore(landblock, additionalRenderIds, tier);
private GpuLandblockSpatialPublication CommitLandblockSpatialCore(
LoadedLandblock landblock,
IEnumerable? additionalRenderIds,
LandblockStreamTier tier)
{
ArgumentNullException.ThrowIfNull(landblock);
// A stale Far completion must never replace a newer Near entity layer.
// StreamingController normally filters it before render-side apply;
// keep the state boundary independently monotonic as well.
if (tier == LandblockStreamTier.Far && IsNearTier(landblock.LandblockId))
{
LoadedLandblock retained = _loaded[landblock.LandblockId];
return new GpuLandblockSpatialPublication(
retained.LandblockId,
retained,
Array.Empty(),
Array.Empty(),
RequiresActivation: false,
RenderTraversalOrder:
GetRenderTraversalOrder(retained.LandblockId));
}
// Reconcile the only fallible logical-owner edge before consuming any
// pending live/render/tier maps. A retry then sees the exact pending
// suffix even when a resolver or owner callback fails partway through.
landblock = NormalizeLoadedLandblock(landblock);
if (_loaded.TryGetValue(landblock.LandblockId, out LoadedLandblock? displaced))
ReconcileRetainedStaticEntityScripts(displaced, landblock);
// If pending live entities have been waiting for this landblock,
// merge them into the render-thread-owned mutable entity bucket before
// storing. Subsequent live spawns append to that bucket in O(1).
if (_pendingByLandblock.TryGetValue(landblock.LandblockId, out var pending) && pending.Count > 0)
{
var merged = new List(landblock.Entities.Count + pending.Count);
merged.AddRange(landblock.Entities);
merged.AddRange(pending);
landblock = NormalizeLoadedLandblock(landblock, merged);
_pendingByLandblock.Remove(landblock.LandblockId);
}
HashSet? mergedRenderIds = additionalRenderIds is null
? null
: new HashSet(additionalRenderIds);
if (_pendingRenderIdsByLandblock.Remove(landblock.LandblockId, out var pendingRenderIds))
{
mergedRenderIds ??= new HashSet();
mergedRenderIds.UnionWith(pendingRenderIds);
}
bool pendingNear = _pendingNearTierLandblocks.Remove(landblock.LandblockId);
if (pendingNear
|| (_tierByLandblock.TryGetValue(landblock.LandblockId, out var currentTier)
&& currentTier == LandblockStreamTier.Near))
{
tier = LandblockStreamTier.Near;
}
if (RemoveLoadedLandblock(landblock.LandblockId, out displaced))
{
RemoveLoadedLandblockFromFlatView(displaced);
_animatedIndexByLandblock.Remove(landblock.LandblockId);
}
AddLoadedLandblock(landblock.LandblockId, landblock);
AddLoadedLandblockToFlatView(landblock);
_tierByLandblock[landblock.LandblockId] = tier;
return CreateSpatialPublication(
_loaded[landblock.LandblockId],
(IEnumerable?)mergedRenderIds ?? Array.Empty(),
GetRenderTraversalOrder(landblock.LandblockId));
}
///
/// A same-landblock rehydrate is an acdream streaming replacement, not a
/// retail logical create/delete edge. Retained static IDs rebind their
/// Setup-owned resources without replaying defaults; omitted IDs perform
/// the same exact teardown used by ordinary retirement.
///
private void ReconcileRetainedStaticEntityScripts(
LoadedLandblock displaced,
LoadedLandblock replacement)
{
if (_entityScriptActivator is null)
return;
var replacementsById = new Dictionary();
for (int i = 0; i < replacement.Entities.Count; i++)
{
WorldEntity entity = replacement.Entities[i];
if (entity.ServerGuid == 0)
replacementsById.Add(entity.Id, entity);
}
for (int i = 0; i < displaced.Entities.Count; i++)
{
WorldEntity prior = displaced.Entities[i];
if (prior.ServerGuid != 0)
continue;
if (replacementsById.TryGetValue(prior.Id, out WorldEntity? retained))
_entityScriptActivator.OnRebindStatic(prior, retained);
else
_entityScriptActivator.OnRemove(prior);
}
}
internal void ActivateLandblockPresentation(
GpuLandblockSpatialPublication publication)
{
ArgumentNullException.ThrowIfNull(publication);
if (!publication.RequiresActivation)
return;
if (!_loaded.TryGetValue(publication.LandblockId, out LoadedLandblock? current)
|| !ReferenceEquals(current, publication.Landblock))
{
throw new InvalidOperationException(
$"Landblock 0x{publication.LandblockId:X8} changed before presentation activation.");
}
_wbSpawnAdapter?.OnLandblockLoaded(
publication.Landblock,
publication.AdditionalRenderIds);
// C.1.5b: fire DefaultScript for dat-hydrated entities only.
// EntityScriptActivator owns a retryable per-static acquisition ledger,
// so replaying this activation suffix after a later owner failure does
// not replay already-started defaults.
if (_entityScriptActivator is not null)
{
for (int i = 0; i < publication.StaticEntities.Count; i++)
_entityScriptActivator.OnCreate(publication.StaticEntities[i]);
}
}
private static GpuLandblockSpatialPublication CreateSpatialPublication(
LoadedLandblock landblock,
IEnumerable additionalRenderIds,
uint renderTraversalOrder)
{
ulong[] renderIds = additionalRenderIds as ulong[]
?? additionalRenderIds.ToArray();
WorldEntity[] staticEntities = landblock.Entities
.Where(static entity => entity.ServerGuid == 0)
.ToArray();
return new GpuLandblockSpatialPublication(
landblock.LandblockId,
landblock,
renderIds,
staticEntities,
RenderTraversalOrder: renderTraversalOrder);
}
///
/// Mark a server-GUID as persistent — this entity survives landblock unloads
/// and gets re-parked as pending for its current canonical landblock.
///
public void MarkPersistent(uint serverGuid)
{
_persistentGuids.Add(serverGuid);
}
///
/// Move a persistent entity from its current landblock slot to a new one.
/// Called every frame for the player entity so it stays in the landblock
/// matching its actual position (not its spawn landblock). Without this,
/// the entity stays in the spawn landblock and gets frustum-culled when
/// the player walks away.
///
public void RebucketLiveEntity(WorldEntity entity, uint newCanonicalLb)
{
ArgumentNullException.ThrowIfNull(entity);
RuntimeEntityKey key = _projectionLocations.TryGetValue(
entity,
out ProjectionLocation existing)
? existing.Key
: new RuntimeEntityKey(entity.Id, 0);
RebucketLiveEntity(key, entity, newCanonicalLb);
}
public void RebucketLiveEntity(
RuntimeEntityKey key,
WorldEntity entity,
uint newCanonicalLb)
{
if (entity.ServerGuid == 0) return;
if (key.LocalEntityId == 0 || key.LocalEntityId != entity.Id)
throw new InvalidOperationException(
"The exact rebucket key must match the WorldEntity local ID.");
using MutationBatch mutation = BeginMutationBatch();
uint canonical = (newCanonicalLb & 0xFFFF0000u) | 0xFFFFu;
// Fast path: already drawn in the correct loaded bucket → nothing to do
// (avoids per-frame list churn for a settled, stationary entity).
bool hasCurrent = _projectionLocations.TryGetValue(
entity,
out ProjectionLocation current);
if (hasCurrent && current.Key != key)
{
throw new InvalidOperationException(
"The live spatial projection is owned by another Runtime incarnation.");
}
if (hasCurrent
&& current.IsLoaded
&& current.LandblockId == canonical)
{
return;
}
// Remove the entity from wherever it currently lives — a loaded bucket
// OR a pending bucket — then re-append to its current landblock.
//
// The projection-location index is the 2026-07-03 fix for the cold-spawn /
// run-out "invisible player" bug: a persistent (server-spawned) entity
// that spawned into a not-yet-loaded landblock sits in _pendingByLandblock,
// and the old code scanned ONLY _loaded — so it silently no-op'd and left
// the player stranded, hidden, even after its landblock finished loading
// (the AddLandblock pending-drain had already run empty before the churn
// re-parked the player, and the player is excluded from the server-object
// re-hydrate — so RebucketLiveEntity was the ONLY path that could recover it,
// and it couldn't reach a pending entity). The index now reaches either
// residency class in O(1). Re-appending routes the entity
// to _loaded (drawn) when its landblock is loaded, or back to pending to
// await AddLandblock otherwise.
// Remove + place is one spatial transaction. Rebuilding between the
// two operations exposes a false/true implementation pulse for a
// loaded-to-loaded move and lets reentrant visibility callbacks observe
// a state that never exists at the LiveEntityRuntime boundary.
RemoveEntityFromAllBuckets(entity);
PlaceLiveEntityProjection(key, canonical, entity);
}
///
/// Remove (by reference) from whichever
/// or bucket it
/// currently occupies. At most one bucket holds a given entity, so this
/// stops after the first hit. Called by before
/// re-appending, so a stranded pending entity can be promoted.
///
private void RemoveEntityFromAllBuckets(WorldEntity entity)
{
if (!_projectionLocations.TryGetValue(entity, out ProjectionLocation location))
return;
if (location.IsLoaded)
{
RemoveLoadedProjection(entity);
return;
}
if (!_pendingByLandblock.TryGetValue(location.LandblockId, out List? pending)
|| (uint)location.BucketIndex >= (uint)pending.Count
|| !ReferenceEquals(pending[location.BucketIndex], entity))
{
throw new InvalidOperationException(
$"Indexed live entity 0x{entity.Id:X8} was absent from pending landblock 0x{location.LandblockId:X8}.");
}
int lastIndex = pending.Count - 1;
if (location.BucketIndex != lastIndex)
{
WorldEntity moved = pending[lastIndex];
pending[location.BucketIndex] = moved;
SetProjectionLocation(
moved,
location.LandblockId,
isLoaded: false,
location.BucketIndex);
}
pending.RemoveAt(lastIndex);
RemoveProjectionLocation(entity);
if (pending.Count == 0)
_pendingByLandblock.Remove(location.LandblockId);
}
///
/// Commits the world-state half of a full landblock retirement and returns
/// the exact entity context required by presentation owners. This method
/// deliberately performs no renderer, script, or cache callbacks: those
/// owners can fail independently and are advanced by
/// after the old state has
/// become unreachable.
///
public GpuLandblockRetirement? DetachLandblock(uint landblockId)
{
using MutationBatch mutation = BeginMutationBatch();
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
bool hadState = _loaded.ContainsKey(canonical)
|| _pendingByLandblock.ContainsKey(canonical)
|| _pendingRenderIdsByLandblock.ContainsKey(canonical)
|| _pendingNearTierLandblocks.Contains(canonical)
|| _tierByLandblock.ContainsKey(canonical)
|| _aabbs.ContainsKey(canonical);
if (!hadState)
return null;
IReadOnlyList detachedEntities =
_loaded.TryGetValue(canonical, out LoadedLandblock? residentLandblock)
? residentLandblock.Entities
: Array.Empty();
if (_pendingByLandblock.TryGetValue(canonical, out List? detachedPending)
&& detachedPending.Count > 0)
{
if (detachedEntities.Count == 0)
{
detachedEntities = detachedPending;
}
else
{
var combined = new List(
detachedEntities.Count + detachedPending.Count);
var seen = new HashSet(ReferenceEqualityComparer.Instance);
foreach (WorldEntity entity in detachedEntities)
if (seen.Add(entity))
combined.Add(entity);
foreach (WorldEntity entity in detachedPending)
if (seen.Add(entity))
combined.Add(entity);
detachedEntities = combined;
}
}
// A logical live object survives streaming residence. Non-persistent
// projections move to the pending bucket for this landblock and merge
// back as the same WorldEntity when it reloads. Persistent projections
// (the local player) are rescued because their current bucket may be a
// different landblock by the time the caller reinjects them.
var retainedLive = new List();
var retainedLiveSet = new HashSet(ReferenceEqualityComparer.Instance);
var retainedLiveKeys = new Dictionary(
ReferenceEqualityComparer.Instance);
void RetainOrRescue(WorldEntity entity, string source)
{
if (entity.ServerGuid == 0)
return;
if (!_projectionLocations.TryGetValue(entity, out ProjectionLocation location))
{
throw new InvalidOperationException(
$"Live projection 0x{entity.Id:X8} has no exact spatial owner.");
}
if (_persistentGuids.Contains(entity.ServerGuid))
{
_persistentRescued.Add(entity);
EntityVanishProbe.Log(
$"[ent] RESCUE guid=0x{entity.ServerGuid:X8} from={source} lb=0x{canonical:X8}");
return;
}
if (retainedLiveSet.Add(entity))
{
retainedLive.Add(entity);
retainedLiveKeys.Add(entity, location.Key);
}
}
// Rescue persistent entities before removal. These get appended
// to the _persistentRescued list; the caller is responsible for
// re-injecting them through LiveEntityRuntime rebucketing into whatever landblock
// the player is currently on.
if (_loaded.TryGetValue(canonical, out var lb))
{
foreach (var entity in lb.Entities)
RetainOrRescue(entity, "loaded");
}
// #138 (secondary): rescue persistent entities sitting in the PENDING
// bucket too, not just the loaded list. The player is re-injected via
// LiveEntityRuntime rebucketing into its current landblock every frame
// (GameWindow's DrainRescued loop); right after a teleport that
// landblock often hasn't streamed in yet, so the player lands in
// _pendingByLandblock. If that same landblock is then unloaded (a
// streaming churn / re-teleport before it finishes loading), the
// pending entry was silently dropped here — violating the
// "persistent ⇒ survives unload" contract and making the avatar
// vanish after a couple round-trips. Rescue them so DrainRescued
// re-parks them at the next valid landblock.
if (_pendingByLandblock.TryGetValue(canonical, out var pendingForLb))
{
foreach (var entity in pendingForLb)
{
RetainOrRescue(entity, "pending");
RemoveProjectionLocation(entity);
}
}
_pendingByLandblock.Remove(canonical);
_pendingRenderIdsByLandblock.Remove(canonical);
_pendingNearTierLandblocks.Remove(canonical);
if (retainedLive.Count > 0)
_pendingByLandblock[canonical] = retainedLive;
_aabbs.Remove(canonical);
_animatedIndexByLandblock.Remove(canonical);
_tierByLandblock.Remove(canonical);
if (RemoveLoadedLandblock(canonical, out LoadedLandblock? removed))
RemoveLoadedLandblockFromFlatView(removed);
for (int i = 0; i < retainedLive.Count; i++)
{
WorldEntity entity = retainedLive[i];
SetProjectionLocation(
entity,
canonical,
isLoaded: false,
i,
retainedLiveKeys[entity]);
}
return new GpuLandblockRetirement(
canonical,
LandblockRetirementKind.Full,
detachedEntities);
}
///
/// Atomically withdraws the complete old spatial generation before a
/// shared-origin change. The origin is common to every resident transform,
/// so admitting hundreds of independent landblock detaches across frames
/// only prolongs portal transit; it does not expose a useful intermediate
/// state. This operation swaps the spatial indexes as one transaction,
/// retains live logical objects, and returns exact landblock receipts so
/// renderer/physics/script destruction can remain frame-budgeted.
///
internal GpuWorldRecenterRetirement DetachAllForOriginRecenter()
{
var ids = new List(
_loaded.Count
+ _pendingByLandblock.Count
+ _pendingRenderIdsByLandblock.Count);
var seenIds = new HashSet();
void AddId(uint id)
{
uint canonical = (id & 0xFFFF0000u) | 0xFFFFu;
if (seenIds.Add(canonical))
ids.Add(canonical);
}
// Preserve the accepted renderer traversal order for already-resident
// landblocks, then append presentation-only and pending-only owners in
// their stable insertion order.
for (int i = 0; i < _renderTraversalLandblockSlots.Count; i++)
{
uint id = _renderTraversalLandblockSlots[i];
if (id != 0u)
AddId(id);
}
foreach (uint id in _pendingByLandblock.Keys)
AddId(id);
foreach (uint id in _pendingRenderIdsByLandblock.Keys)
AddId(id);
foreach (uint id in _pendingNearTierLandblocks)
AddId(id);
foreach (uint id in _tierByLandblock.Keys)
AddId(id);
foreach (uint id in _aabbs.Keys)
AddId(id);
var retirements = new List(ids.Count);
for (int i = 0; i < ids.Count; i++)
{
uint id = ids[i];
IReadOnlyList loaded =
_loaded.TryGetValue(id, out LoadedLandblock? landblock)
? landblock.Entities
: Array.Empty();
IReadOnlyList pending =
_pendingByLandblock.TryGetValue(id, out List? bucket)
? bucket
: Array.Empty();
IReadOnlyList detached = loaded;
if (pending.Count != 0)
{
if (loaded.Count == 0)
{
detached = pending;
}
else
{
var combined = new List(
loaded.Count + pending.Count);
var seenEntities = new HashSet(
ReferenceEqualityComparer.Instance);
for (int entityIndex = 0;
entityIndex < loaded.Count;
entityIndex++)
{
WorldEntity entity = loaded[entityIndex];
if (seenEntities.Add(entity))
combined.Add(entity);
}
for (int entityIndex = 0;
entityIndex < pending.Count;
entityIndex++)
{
WorldEntity entity = pending[entityIndex];
if (seenEntities.Add(entity))
combined.Add(entity);
}
detached = combined;
}
}
retirements.Add(new GpuLandblockRetirement(
id,
LandblockRetirementKind.Full,
detached));
}
// Capture live projections from their small dedicated index rather
// than walking every DAT-static entity in the 25x25 world window.
var retainedByLandblock =
new Dictionary>();
var rescued = new HashSet(
_persistentRescued,
ReferenceEqualityComparer.Instance);
foreach ((WorldEntity entity, ProjectionLocation location) in
_projectionLocations)
{
if (_persistentGuids.Contains(entity.ServerGuid))
{
if (rescued.Add(entity))
{
_persistentRescued.Add(entity);
EntityVanishProbe.Log(
$"[ent] RESCUE guid=0x{entity.ServerGuid:X8} " +
$"from=recenter lb=0x{location.LandblockId:X8}");
}
continue;
}
if (!retainedByLandblock.TryGetValue(
location.LandblockId,
out List<(
WorldEntity Entity,
int BucketIndex,
RuntimeEntityKey Key)>? retained))
{
retained = [];
retainedByLandblock.Add(location.LandblockId, retained);
}
retained.Add((entity, location.BucketIndex, location.Key));
}
int spatialOperationCount = OriginRecenterSpatialOperationCount;
Exception? observerFailure = null;
MutationBatch mutation = BeginMutationBatch();
try
{
foreach ((RuntimeEntityKey key, int count) in _visibleLiveProjectionCounts)
{
if (count > 0
&& !_visibilityBeforeMutation.ContainsKey(key))
{
_visibilityBeforeMutation.Add(key, true);
}
}
_visibleLiveProjectionCounts.Clear();
if (_flatEntities.Count != 0)
_flatMembershipDirty = true;
_flatEntities.Clear();
_flatEntityIndices.Clear();
_projectionLocations.Clear();
_loadedLiveByLandblock.Clear();
_liveProjectionByKey.Clear();
_loaded.Clear();
_renderTraversalLandblockSlots.Clear();
_freeRenderTraversalLandblockSlots.Clear();
_renderTraversalSlotByLandblock.Clear();
_tierByLandblock.Clear();
_aabbs.Clear();
_animatedIndexByLandblock.Clear();
_pendingByLandblock.Clear();
_pendingRenderIdsByLandblock.Clear();
_pendingNearTierLandblocks.Clear();
_landblockEntriesView.Clear();
_landblockEntriesWithoutAnimatedIndexView.Clear();
_landblockBoundsView.Clear();
InvalidateLandblockRenderViews(entries: true, bounds: true);
foreach ((uint id, List<(
WorldEntity Entity,
int BucketIndex,
RuntimeEntityKey Key)> retained) in
retainedByLandblock)
{
retained.Sort(static (left, right) =>
left.BucketIndex.CompareTo(right.BucketIndex));
var pending = new List(retained.Count);
for (int i = 0; i < retained.Count; i++)
{
WorldEntity entity = retained[i].Entity;
pending.Add(entity);
SetProjectionLocation(
entity,
id,
isLoaded: false,
i,
retained[i].Key);
}
_pendingByLandblock.Add(id, pending);
}
}
finally
{
try
{
mutation.Dispose();
}
catch (Exception error)
{
observerFailure = error;
}
}
return new GpuWorldRecenterRetirement(
retirements,
spatialOperationCount,
observerFailure);
}
///
/// Compatibility edge for direct state tests. Production streaming uses
/// so each presentation owner
/// has a retryable committed marker.
///
public void RemoveLandblock(uint landblockId)
{
GpuLandblockRetirement? retirement = DetachLandblock(landblockId);
if (retirement is null)
return;
ReleaseLandblockMeshReferences(retirement.LandblockId);
InvalidateLandblockClassification(retirement.LandblockId);
for (int i = 0; i < retirement.Entities.Count; i++)
{
WorldEntity entity = retirement.Entities[i];
if (entity.ServerGuid == 0)
StopStaticEntityScript(entity);
}
}
internal void ReleaseLandblockMeshReferences(uint landblockId) =>
_wbSpawnAdapter?.OnLandblockUnloaded(landblockId);
internal void InvalidateLandblockClassification(uint landblockId) =>
_onLandblockUnloaded?.Invoke(landblockId);
internal void StopStaticEntityScript(WorldEntity entity) =>
_entityScriptActivator?.OnRemove(entity);
private readonly List _persistentRescued = new();
///
/// Drain entities rescued from unloaded landblocks. The caller should
/// re-inject each through LiveEntityRuntime.RebucketLiveEntity with its current position.
///
public List DrainRescued()
{
if (_persistentRescued.Count == 0) return _persistentRescued;
var result = new List(_persistentRescued);
_persistentRescued.Clear();
return result;
}
///
/// Remove every entity with the given from
/// all loaded landblocks AND all pending buckets. Called by
/// LiveEntityRuntime before rebucketing, temporary
/// world withdrawal, or logical teardown. It owns no renderer/script
/// lifecycle and is safe for a still-live incarnation.
///
/// Safe to call with a server guid that's not currently present — no-op.
///
public void RemoveLiveEntityProjection(uint serverGuid)
{
if (serverGuid == 0) return;
using MutationBatch mutation = BeginMutationBatch();
// GUID-only removal is reserved for session cleanup paths that do not
// have an exact projection owner. Ordinary lifecycle operations use
// the exact-entity overload below.
_guidRemovalScratch.Clear();
foreach (WorldEntity projection in _projectionLocations.Keys)
{
if (projection.ServerGuid == serverGuid)
_guidRemovalScratch.Add(projection);
}
for (int i = 0; i < _guidRemovalScratch.Count; i++)
RemoveEntityFromAllBuckets(_guidRemovalScratch[i]);
_guidRemovalScratch.Clear();
// A persistent projection may have been rescued by RemoveLandblock
// and not yet drained by the next GameWindow frame. Rebucketing or
// logical teardown before that drain must remove the stale rescue
// reference or it can later re-inject a deleted/duplicated object.
_persistentRescued.RemoveAll(e => e.ServerGuid == serverGuid);
}
///
/// Removes one exact live projection incarnation. Unlike the GUID overload,
/// this cannot detach a replacement that reused the same server GUID from a
/// re-entrant logical teardown callback.
///
public void RemoveLiveEntityProjection(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
if (entity.ServerGuid == 0) return;
using MutationBatch mutation = BeginMutationBatch();
RemoveEntityFromAllBuckets(entity);
_persistentRescued.RemoveAll(candidate => ReferenceEquals(candidate, entity));
}
///
/// Ends all spatial lifetime retained for one server object. Temporary
/// rebucketing uses and keeps the
/// persistence classification; logical delete also forgets that class so
/// a later session/GUID reuse cannot be rescued as the old player.
///
public void ForgetLiveEntity(uint serverGuid)
{
RemoveLiveEntityProjection(serverGuid);
_persistentGuids.Remove(serverGuid);
_persistentInFlatProbe.Remove(serverGuid);
}
/// Clears session-scoped persistence and undrained rescues.
public void ClearLiveEntityLifetimeState()
{
_persistentGuids.Clear();
_persistentRescued.Clear();
_persistentInFlatProbe.Clear();
_visibilityTransitions.Clear();
_visibleLiveProjectionCounts.Clear();
_visibilityBeforeMutation.Clear();
_projectionLocations.Clear();
_loadedLiveByLandblock.Clear();
_liveProjectionByKey.Clear();
}
///
/// Place an already-registered live entity in a landblock slot whose
/// terrain may or may not be loaded yet.
///
///
/// The server's landblockId is in cell-resolved form
/// (0xAAAA00CC: high byte X, second byte Y, low 16 bits cell
/// index within the landblock). The streaming system stores landblocks
/// keyed by their canonical 0xAAAA0xFFFF form. Canonicalize
/// on the way in so callers don't have to think about it.
///
///
///
/// Outcome:
///
/// - If the landblock is already loaded, the entity is appended
/// to its landblock and flat render-thread lists in O(1).
/// - If the landblock is not yet loaded, the entity is parked
/// in and will be merged
/// into the next for the same id.
///
///
///
public void PlaceLiveEntityProjection(uint landblockId, WorldEntity entity) =>
PlaceLiveEntityProjection(
new RuntimeEntityKey(entity.Id, 0),
landblockId,
entity);
public void PlaceLiveEntityProjection(
RuntimeEntityKey key,
uint landblockId,
WorldEntity entity)
{
using MutationBatch mutation = BeginMutationBatch();
if (key.LocalEntityId == 0 || key.LocalEntityId != entity.Id)
throw new InvalidOperationException(
"The exact placement key must match the WorldEntity local ID.");
if (_projectionLocations.ContainsKey(entity))
{
throw new InvalidOperationException(
$"Live entity 0x{entity.Id:X8} is already spatially projected.");
}
// Spatial placement only. LiveEntityRuntime has already registered
// this incarnation's renderer and script resources exactly once.
uint canonicalLandblockId = (landblockId & 0xFFFF0000u) | 0xFFFFu;
bool probePersistent = EntityVanishProbe.Enabled
&& entity.ServerGuid != 0 && _persistentGuids.Contains(entity.ServerGuid);
if (_loaded.ContainsKey(canonicalLandblockId))
{
// Hot path — append directly to the render-thread-owned mutable
// resident bucket and flat view.
AddLoadedProjection(canonicalLandblockId, entity, key);
if (probePersistent)
EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> LOADED(drawn)");
return;
}
// Cold path — landblock not yet loaded. Park the entity in the
// pending bucket; AddLandblock will pick it up when the streamer
// delivers the matching landblock.
if (!_pendingByLandblock.TryGetValue(canonicalLandblockId, out var bucket))
{
bucket = new List();
_pendingByLandblock[canonicalLandblockId] = bucket;
}
int bucketIndex = bucket.Count;
bucket.Add(entity);
SetProjectionLocation(
entity,
canonicalLandblockId,
isLoaded: false,
bucketIndex,
key);
if (probePersistent)
EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> PENDING(hidden)");
}
///
/// Drop all entities from a landblock without removing the terrain. Used
/// by two-tier streaming when a landblock crosses Near→Far hysteresis.
/// Per Phase A.5 spec §4.4.
///
///
/// Only dat-static entity layers demote. Live server projections retain
/// their exact WorldEntity and bucket while terrain remains resident; no
/// logical or spatial lifetime callback is replayed.
///
///
public GpuLandblockRetirement? DetachNearLayer(uint landblockId)
{
using MutationBatch mutation = BeginMutationBatch();
// A.5 T14 follow-up: canonicalize for symmetry with live projection placement.
// Streaming callers always pass canonical (0xAAAA0xFFFF) ids; this
// protects against future callers that mirror live projection placement's
// cell-resolved-id pattern.
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
// A promotion may have parked its Near layer before the Far base load
// exists. Demotion must retire that pending tier just as completely as
// an installed tier, while preserving live server projections.
bool hadNearLayer = _pendingNearTierLandblocks.Remove(canonical);
hadNearLayer |= _pendingRenderIdsByLandblock.Remove(canonical);
hadNearLayer |= IsNearTier(canonical);
if (!hadNearLayer)
return null;
var retiredEntities = new List();
if (_pendingByLandblock.TryGetValue(canonical, out var pending))
{
int pendingWriteIndex = 0;
for (int readIndex = 0; readIndex < pending.Count; readIndex++)
{
WorldEntity entity = pending[readIndex];
if (entity.ServerGuid == 0)
{
retiredEntities.Add(entity);
continue;
}
if (pendingWriteIndex != readIndex)
pending[pendingWriteIndex] = entity;
SetProjectionLocation(
entity,
canonical,
isLoaded: false,
pendingWriteIndex);
pendingWriteIndex++;
}
if (pendingWriteIndex < pending.Count)
pending.RemoveRange(pendingWriteIndex, pending.Count - pendingWriteIndex);
if (pendingWriteIndex == 0)
_pendingByLandblock.Remove(canonical);
}
if (_loaded.TryGetValue(canonical, out var lb))
{
// Phase Post-A.5 #53 (Task 12): invalidate the EntityClassificationCache
// for this landblock BEFORE we drop the entity list. The cache stores
// canonical landblock ids (the dispatcher's _walkScratch carries
// entry.LandblockId from GpuWorldState.LandblockEntries, whose keys are
// canonicalized). Null when the cache isn't wired (tests). Per spec §5.3 W3b.
// C.1.5b: stop DefaultScript for each dat-hydrated entity about to
// be dropped. Demote-tier entities are always atlas-tier (ServerGuid==0
// per this method's class doc-comment); the filter is belt-and-suspenders.
var retainedLive = new List();
for (int readIndex = 0; readIndex < lb.Entities.Count; readIndex++)
{
WorldEntity entity = lb.Entities[readIndex];
if (entity.ServerGuid != 0)
{
int liveWriteIndex = retainedLive.Count;
retainedLive.Add(entity);
SetProjectionLocation(
entity,
canonical,
isLoaded: true,
liveWriteIndex);
continue;
}
retiredEntities.Add(entity);
RemoveFlatEntity(entity);
}
_loaded[canonical] = NormalizeLoadedLandblock(lb, retainedLive);
_animatedIndexByLandblock.Remove(canonical);
InvalidateLandblockRenderViews(entries: true, bounds: false);
_tierByLandblock[canonical] = LandblockStreamTier.Far;
}
return new GpuLandblockRetirement(
canonical,
LandblockRetirementKind.NearLayer,
retiredEntities);
}
///
/// Compatibility edge for direct state tests. Production streaming uses
/// the retryable retirement coordinator.
///
public void RemoveEntitiesFromLandblock(uint landblockId)
{
GpuLandblockRetirement? retirement = DetachNearLayer(landblockId);
if (retirement is null)
return;
ReleaseLandblockMeshReferences(retirement.LandblockId);
InvalidateLandblockClassification(retirement.LandblockId);
for (int i = 0; i < retirement.Entities.Count; i++)
StopStaticEntityScript(retirement.Entities[i]);
}
///
/// Merge entities into an existing-loaded landblock. Used by two-tier
/// streaming for the Far→Near promotion case (terrain already loaded;
/// entity layer streaming in). Falls back to the pending bucket if the
/// landblock isn't loaded yet (handles the rare "promote arrives before
/// far load completes" race).
/// Per Phase A.5 spec §4.4.
///
///
/// Landblock id is canonicalized (low 16 bits forced to 0xFFFF) —
/// callers may pass cell-resolved ids and they will key correctly.
///
///
public bool AddEntitiesToExistingLandblock(
uint landblockId,
IReadOnlyList entities,
IEnumerable? additionalRenderIds = null)
{
using MutationBatch mutation = BeginMutationBatch();
GpuLandblockSpatialPublication? publication =
CommitEntitiesToExistingLandblockSpatialCore(
landblockId,
entities,
additionalRenderIds,
parkIfMissing: true);
if (publication is null)
return false;
ActivateLandblockPresentation(publication);
return true;
}
internal GpuLandblockSpatialPublication CommitEntitiesToExistingLandblockSpatial(
uint landblockId,
IReadOnlyList entities,
IEnumerable? additionalRenderIds)
{
GpuLandblockSpatialPublication? publication =
CommitEntitiesToExistingLandblockSpatialCore(
landblockId,
entities,
additionalRenderIds,
parkIfMissing: false);
return publication
?? throw new InvalidOperationException(
$"Landblock 0x{landblockId:X8} is not resident for an accepted promotion.");
}
private GpuLandblockSpatialPublication? CommitEntitiesToExistingLandblockSpatialCore(
uint landblockId,
IReadOnlyList entities,
IEnumerable? additionalRenderIds,
bool parkIfMissing)
{
ArgumentNullException.ThrowIfNull(entities);
// A.5 T14 follow-up: canonicalize for symmetry with live projection placement.
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (!_loaded.TryGetValue(canonical, out var lb))
{
if (!parkIfMissing)
return null;
// Park as pending — same pattern as live projections for not-yet-loaded LBs.
if (!_pendingByLandblock.TryGetValue(canonical, out var bucket))
{
bucket = new List();
_pendingByLandblock[canonical] = bucket;
}
int firstIndex = bucket.Count;
bucket.AddRange(entities);
for (int i = 0; i < entities.Count; i++)
{
WorldEntity entity = entities[i];
if (entity.ServerGuid != 0)
{
SetProjectionLocation(
entity,
canonical,
isLoaded: false,
firstIndex + i);
}
}
_pendingNearTierLandblocks.Add(canonical);
if (additionalRenderIds is not null)
{
if (!_pendingRenderIdsByLandblock.TryGetValue(canonical, out var renderIds))
{
renderIds = new HashSet();
_pendingRenderIdsByLandblock[canonical] = renderIds;
}
renderIds.UnionWith(additionalRenderIds);
}
return null;
}
List resident = MutableEntities(lb);
for (int i = 0; i < entities.Count; i++)
{
WorldEntity entity = entities[i];
int bucketIndex = resident.Count;
resident.Add(entity);
AddFlatEntity(entity);
if (entity.ServerGuid != 0)
{
SetProjectionLocation(entity, canonical, isLoaded: true, bucketIndex);
AdjustVisibleLiveProjection(
_projectionLocations[entity].Key,
entity,
+1);
}
}
_animatedIndexByLandblock.Remove(canonical);
InvalidateLandblockRenderViews(entries: true, bounds: false);
_tierByLandblock[canonical] = LandblockStreamTier.Near;
ulong[] effectiveRenderIds = additionalRenderIds?.ToArray() ?? Array.Empty();
WorldEntity[] staticEntities = entities
.Where(static entity => entity.ServerGuid == 0)
.ToArray();
return new GpuLandblockSpatialPublication(
canonical,
_loaded[canonical],
effectiveRenderIds,
staticEntities,
RenderTraversalOrder: GetRenderTraversalOrder(canonical));
}
private void DrainVisibilityTransitions()
{
if (_dispatchingVisibilityTransitions)
return;
List? failures = null;
_dispatchingVisibilityTransitions = true;
try
{
while (_visibilityTransitions.TryDequeue(out var transition))
{
Delegate[] subscribers = LiveProjectionVisibilityChanged?
.GetInvocationList()
?? Array.Empty();
for (int i = 0; i < subscribers.Length; i++)
{
try
{
((Action)subscribers[i])(
transition.Key,
transition.Visible);
}
catch (Exception error)
{
(failures ??= new List()).Add(error);
}
}
}
}
finally
{
_dispatchingVisibilityTransitions = false;
}
if (failures is not null)
{
throw new AggregateException(
"One or more live projection visibility observers failed.",
failures);
}
}
// TEMP (#138-B): persistent guids currently present in the drawn flat
// view, for EntityVanishProbe transition logging. Strip with the probe.
private readonly HashSet _persistentInFlatProbe = new();
// TEMP (#138-B): log when a persistent (player) entity enters/leaves the
// drawn flat view. Transition-gated → low volume (fires at teleport
// boundaries, not every rebuild). Strip with EntityVanishProbe.
private void ProbeFlatViewTransitions()
{
var now = new HashSet();
foreach (var e in _flatEntities)
if (e.ServerGuid != 0 && _persistentGuids.Contains(e.ServerGuid))
now.Add(e.ServerGuid);
foreach (var g in now)
if (!_persistentInFlatProbe.Contains(g))
EntityVanishProbe.Log($"[ent] DRAWSET guid=0x{g:X8} -> PRESENT (flatCount={_flatEntities.Count})");
foreach (var g in _persistentInFlatProbe)
if (!now.Contains(g))
EntityVanishProbe.Log($"[ent] DRAWSET guid=0x{g:X8} -> ABSENT (flatCount={_flatEntities.Count})");
_persistentInFlatProbe.Clear();
foreach (var g in now) _persistentInFlatProbe.Add(g);
}
}