acdream/src/AcDream.App/Streaming/GpuWorldState.cs
Erik 91e82c3c68 fix(streaming): prepare quiesced destination entities
Separate loaded spatial residency from the world presentation gate so destination live objects acquire mesh owners behind portal space while drawing and simulation stay quiesced. Prevent unowned CPU-cache hits from recreating stale GPU staging work.

Co-authored-by: Erik Nilsson <erikn@users.noreply.github.com>
2026-07-24 20:21:57 +02:00

1523 lines
62 KiB
C#

using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.App.World;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
/// <summary>
/// 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.
/// </summary>
internal sealed record GpuLandblockSpatialPublication(
uint LandblockId,
LoadedLandblock Landblock,
IReadOnlyList<ulong> AdditionalRenderIds,
IReadOnlyList<WorldEntity> StaticEntities,
bool RequiresActivation = true);
/// <summary>
/// Render-thread-owned registry of currently-loaded landblocks and their
/// entities. All mutation happens in <see cref="StreamingController.Tick"/>
/// on the render thread; the renderer reads <see cref="Entities"/> once per
/// frame.
///
/// <para>
/// Replaces the pre-streaming flat <c>_entities</c> 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 <see cref="AcDream.App.World.LiveEntityRuntime"/>.
/// </para>
///
/// <para>
/// <b>Pending live entities.</b> Live <c>CreateObject</c> spawns can race
/// against streaming: the server may send a spawn for landblock X before
/// X is loaded into <see cref="_loaded"/> (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, <see cref="PlaceLiveEntityProjection"/> stores
/// orphaned spawns in a per-landblock pending bucket. When
/// <see cref="AddLandblock"/> later loads the landblock, the matching
/// pending entries are merged into the loaded record before the flat
/// view rebuild. <see cref="RemoveLandblock"/> 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.
/// </para>
///
/// <remarks>
/// Threading: not thread-safe. All calls must happen on the render thread.
/// </remarks>
/// </summary>
public sealed class GpuWorldState : ILiveEntitySpatialQuery
{
private readonly LandblockSpawnAdapter? _wbSpawnAdapter;
private readonly EntityScriptActivator? _entityScriptActivator;
private readonly IWorldGenerationAvailability _availability;
/// <summary>
/// Phase Post-A.5 #53 (Task 12): optional callback fired before
/// <see cref="RemoveEntitiesFromLandblock"/> zeroes a landblock's entity
/// list. Wired by <c>GameWindow</c> to
/// <c>EntityClassificationCache.InvalidateLandblock</c> 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 <c>0xFFFF</c>),
/// matching the <c>LandblockHint</c> stored at <c>Populate</c> time.
/// Null when the cache isn't relevant (tests).
/// </summary>
private readonly System.Action<uint>? _onLandblockUnloaded;
public GpuWorldState(
LandblockSpawnAdapter? wbSpawnAdapter = null,
System.Action<uint>? onLandblockUnloaded = null,
EntityScriptActivator? entityScriptActivator = null,
IWorldGenerationAvailability? availability = null)
{
_wbSpawnAdapter = wbSpawnAdapter;
_onLandblockUnloaded = onLandblockUnloaded;
_entityScriptActivator = entityScriptActivator;
_availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
}
private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
private readonly Dictionary<uint, LandblockStreamTier> _tierByLandblock = new();
private readonly Dictionary<uint, (Vector3 Min, Vector3 Max)> _aabbs = new();
private readonly Dictionary<uint, AnimatedEntityIndex> _animatedIndexByLandblock = new();
private sealed record AnimatedEntityIndex(
LoadedLandblock Source,
IReadOnlyDictionary<uint, WorldEntity> EntitiesById);
/// <summary>
/// Per-landblock buffer of live entities awaiting their landblock's
/// arrival. Keyed by canonical landblock id (<c>0xAAAA0xFFFF</c>).
/// Drained into <see cref="_loaded"/> in <see cref="AddLandblock"/>.
/// </summary>
private readonly Dictionary<uint, List<WorldEntity>> _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<uint, HashSet<ulong>> _pendingRenderIdsByLandblock = new();
private readonly HashSet<uint> _pendingNearTierLandblocks = new();
/// <summary>
/// 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.
/// </summary>
private readonly HashSet<uint> _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<WorldEntity> _flatEntities = new();
private readonly Dictionary<WorldEntity, int> _flatEntityIndices =
new(ReferenceEqualityComparer.Instance);
private readonly Dictionary<WorldEntity, ProjectionLocation> _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<uint, HashSet<WorldEntity>> _loadedLiveByLandblock = new();
// A server GUID normally has exactly one spatial projection. Keep that
// allocation-free common case directly in the primary map; the secondary
// list exists only for the rare overlap during GUID reuse/re-entrant
// lifetime transitions.
private readonly Dictionary<uint, WorldEntity> _primaryProjectionByGuid = new();
private readonly Dictionary<uint, List<WorldEntity>> _additionalProjectionsByGuid = new();
private readonly Dictionary<uint, int> _visibleLiveProjectionCounts = new();
private readonly Dictionary<uint, bool> _visibilityBeforeMutation = new();
private readonly Queue<(uint ServerGuid, bool Visible)> _visibilityTransitions = new();
private bool _dispatchingVisibilityTransitions;
private int _mutationDepth;
private long _visibilityCommitCount;
private ulong _flatViewGeneration;
private bool _flatMembershipDirty;
public IReadOnlyList<WorldEntity> Entities => _flatEntities;
public ulong FlatViewGeneration => _flatViewGeneration;
public IReadOnlyCollection<uint> LoadedLandblockIds => _loaded.Keys;
public event Action<uint, bool>? 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);
/// <summary>
/// True when at least one projection for the server object 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.
/// </summary>
public bool IsLiveEntityProjectionResident(uint serverGuid) =>
serverGuid != 0
&& _visibleLiveProjectionCounts.ContainsKey(serverGuid);
public bool IsLiveEntityVisible(uint serverGuid) =>
_availability.IsWorldAvailable
&& IsLiveEntityProjectionResident(serverGuid);
/// <summary>
/// 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).
/// </summary>
public bool TryGetLandblock(uint landblockId, out LoadedLandblock? lb)
{
if (_loaded.TryGetValue(landblockId, out var found))
{
lb = found;
return true;
}
lb = null;
return false;
}
/// <summary>
/// Store the axis-aligned bounding box for a loaded landblock. Called from
/// the render thread after the terrain mesh is built and uploaded.
/// </summary>
public void SetLandblockAabb(uint landblockId, Vector3 min, Vector3 max)
{
_aabbs[landblockId] = (min, max);
}
/// <summary>
/// Per-landblock iteration with AABB data for use by the frustum-culling
/// draw path. Landblocks without a stored AABB yield <see cref="Vector3.Zero"/>
/// for both corners, which the culler will conservatively treat as visible.
///
/// <para>
/// A.5 T17: also yields an <c>AnimatedById</c> dictionary derived from the
/// landblock's entity list. This lets <see cref="WbDrawDispatcher"/>
/// 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 <see cref="LoadedLandblock"/> 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.
/// </para>
/// </summary>
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries
=> EnumerateLandblockEntries(includeAnimatedIndex: true);
/// <summary>
/// Per-landblock render entries without the animated lookup dictionary.
/// Static render passes use this to avoid rebuilding an index they cannot
/// consume.
/// </summary>
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntriesWithoutAnimatedIndex
=> EnumerateLandblockEntries(includeAnimatedIndex: false);
/// <summary>
/// Lightweight bounds-only enumeration for overlays and diagnostics.
/// Does not walk entity lists.
/// </summary>
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds
{
get
{
if (!_availability.IsWorldAvailable)
yield break;
foreach (var kvp in _loaded)
{
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
yield return (kvp.Key, aabb.Min, aabb.Max);
else
yield return (kvp.Key, Vector3.Zero, Vector3.Zero);
}
}
}
private IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> EnumerateLandblockEntries(
bool includeAnimatedIndex)
{
if (!_availability.IsWorldAvailable)
yield break;
foreach (var kvp in _loaded)
{
IReadOnlyDictionary<uint, WorldEntity>? byId = null;
if (includeAnimatedIndex)
{
if (!_animatedIndexByLandblock.TryGetValue(kvp.Key, out var cached)
|| !ReferenceEquals(cached.Source, kvp.Value))
{
var rebuilt = new Dictionary<uint, WorldEntity>(kvp.Value.Entities.Count);
foreach (var entity in kvp.Value.Entities)
rebuilt[entity.Id] = entity;
cached = new AnimatedEntityIndex(kvp.Value, rebuilt);
_animatedIndexByLandblock[kvp.Key] = cached;
}
byId = cached.EntitiesById;
}
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities, byId);
else
yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities, byId);
}
}
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// 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.
/// </summary>
public MutationBatch BeginMutationBatch()
{
_mutationDepth++;
return new MutationBatch(this);
}
/// <summary>
/// Copy live server projections from the bounded landblock neighborhood
/// around <paramref name="centerCellOrLandblockId"/>. This is the spatial
/// candidate seam for short-range consumers such as retail radar; they
/// still apply their own Hidden/classification/range rules.
/// </summary>
public void CopyLiveEntitiesNearLandblock(
uint centerCellOrLandblockId,
int landblockRadius,
List<KeyValuePair<uint, WorldEntity>> 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<WorldEntity>? liveEntities))
continue;
foreach (WorldEntity entity in liveEntities)
destination.Add(new KeyValuePair<uint, WorldEntity>(entity.ServerGuid, entity));
}
}
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);
private void EndMutationBatch()
{
if (_mutationDepth <= 0)
throw new InvalidOperationException("GpuWorldState mutation scope underflow.");
if (--_mutationDepth != 0)
return;
foreach ((uint guid, bool wasVisible) in _visibilityBeforeMutation)
{
bool isVisible = _visibleLiveProjectionCounts.ContainsKey(guid);
if (wasVisible != isVisible)
_visibilityTransitions.Enqueue((guid, isVisible));
}
_visibilityBeforeMutation.Clear();
_visibilityCommitCount++;
if (_flatMembershipDirty)
{
_flatViewGeneration++;
_flatMembershipDirty = false;
}
DrainVisibilityTransitions();
if (EntityVanishProbe.Enabled)
ProbeFlatViewTransitions();
}
private void AdjustVisibleLiveProjection(WorldEntity entity, int delta)
{
uint guid = entity.ServerGuid;
if (guid == 0 || delta == 0)
return;
_visibleLiveProjectionCounts.TryGetValue(guid, out int priorCount);
if (!_visibilityBeforeMutation.ContainsKey(guid))
_visibilityBeforeMutation.Add(guid, priorCount > 0);
int nextCount = checked(priorCount + delta);
if (nextCount < 0)
throw new InvalidOperationException(
$"Live projection visibility count underflow for 0x{guid:X8}.");
if (nextCount == 0)
{
_visibleLiveProjectionCounts.Remove(guid);
}
else
{
_visibleLiveProjectionCounts[guid] = nextCount;
}
}
private static LoadedLandblock NormalizeLoadedLandblock(
LoadedLandblock source,
List<WorldEntity>? entities = null) =>
new(
source.LandblockId,
source.Heightmap,
entities ?? new List<WorldEntity>(source.Entities),
source.PhysicsDats);
private static List<WorldEntity> MutableEntities(LoadedLandblock landblock) =>
(List<WorldEntity>)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)
{
if (entity.ServerGuid == 0)
return;
bool isNew = !_projectionLocations.TryGetValue(
entity,
out ProjectionLocation priorLocation);
if (!isNew
&& priorLocation.IsLoaded
&& (!isLoaded || priorLocation.LandblockId != landblockId))
{
RemoveLoadedLiveIndex(priorLocation.LandblockId, entity);
}
_projectionLocations[entity] = new ProjectionLocation(
landblockId,
isLoaded,
bucketIndex);
if (isLoaded
&& (isNew || !priorLocation.IsLoaded || priorLocation.LandblockId != landblockId))
{
AddLoadedLiveIndex(landblockId, entity);
}
if (!isNew)
return;
uint guid = entity.ServerGuid;
if (_primaryProjectionByGuid.TryAdd(guid, entity))
return;
if (!_additionalProjectionsByGuid.TryGetValue(guid, out List<WorldEntity>? projections))
{
projections = new List<WorldEntity>(1);
_additionalProjectionsByGuid.Add(guid, projections);
}
projections.Add(entity);
}
private void RemoveProjectionLocation(WorldEntity entity)
{
if (!_projectionLocations.Remove(entity, out ProjectionLocation location)
|| entity.ServerGuid == 0)
return;
if (location.IsLoaded)
RemoveLoadedLiveIndex(location.LandblockId, entity);
uint guid = entity.ServerGuid;
if (!_primaryProjectionByGuid.TryGetValue(guid, out WorldEntity? primary))
throw new InvalidOperationException(
$"Live projection 0x{entity.Id:X8} had no server-guid index entry.");
if (ReferenceEquals(primary, entity))
{
if (_additionalProjectionsByGuid.TryGetValue(guid, out List<WorldEntity>? projections)
&& projections.Count > 0)
{
int lastIndex = projections.Count - 1;
_primaryProjectionByGuid[guid] = projections[lastIndex];
projections.RemoveAt(lastIndex);
if (projections.Count == 0)
_additionalProjectionsByGuid.Remove(guid);
}
else
{
_primaryProjectionByGuid.Remove(guid);
}
return;
}
if (!_additionalProjectionsByGuid.TryGetValue(guid, out List<WorldEntity>? additional))
throw new InvalidOperationException(
$"Live projection 0x{entity.Id:X8} was absent from its server-guid index.");
int index = -1;
for (int i = 0; i < additional.Count; i++)
{
if (!ReferenceEquals(additional[i], entity))
continue;
index = i;
break;
}
if (index < 0)
throw new InvalidOperationException(
$"Live projection 0x{entity.Id:X8} was absent from its server-guid index.");
int lastAdditionalIndex = additional.Count - 1;
if (index != lastAdditionalIndex)
additional[index] = additional[lastAdditionalIndex];
additional.RemoveAt(lastAdditionalIndex);
if (additional.Count == 0)
_additionalProjectionsByGuid.Remove(guid);
}
private void AddLoadedLiveIndex(uint landblockId, WorldEntity entity)
{
if (!_loadedLiveByLandblock.TryGetValue(
landblockId,
out HashSet<WorldEntity>? entities))
{
entities = new HashSet<WorldEntity>(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<WorldEntity>? 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)
{
LoadedLandblock landblock = _loaded[landblockId];
List<WorldEntity> entities = MutableEntities(landblock);
int bucketIndex = entities.Count;
entities.Add(entity);
_animatedIndexByLandblock.Remove(landblockId);
AddFlatEntity(entity);
SetProjectionLocation(entity, landblockId, isLoaded: true, bucketIndex);
AdjustVisibleLiveProjection(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<WorldEntity> 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);
RemoveFlatEntity(entity);
RemoveProjectionLocation(entity);
AdjustVisibleLiveProjection(entity, -1);
}
private void RemoveLoadedLandblockFromFlatView(LoadedLandblock landblock)
{
foreach (WorldEntity entity in landblock.Entities)
{
RemoveFlatEntity(entity);
if (entity.ServerGuid != 0)
RemoveProjectionLocation(entity);
AdjustVisibleLiveProjection(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);
AdjustVisibleLiveProjection(entity, +1);
}
}
public void AddLandblock(
LoadedLandblock landblock,
IEnumerable<ulong>? additionalRenderIds = null,
LandblockStreamTier tier = LandblockStreamTier.Near)
{
using MutationBatch mutation = BeginMutationBatch();
GpuLandblockSpatialPublication publication =
CommitLandblockSpatialCore(landblock, additionalRenderIds, tier);
ActivateLandblockPresentation(publication);
}
/// <summary>
/// Commits only canonical buckets, indexes, pending-live merge, tier, and
/// readiness-id ownership. The caller holds one outer
/// <see cref="MutationBatch"/> through the subsequent
/// <see cref="ActivateLandblockPresentation"/> call so live visibility
/// observers retain the shipped commit boundary.
/// </summary>
internal GpuLandblockSpatialPublication CommitLandblockSpatial(
LoadedLandblock landblock,
IEnumerable<ulong>? additionalRenderIds,
LandblockStreamTier tier) =>
CommitLandblockSpatialCore(landblock, additionalRenderIds, tier);
private GpuLandblockSpatialPublication CommitLandblockSpatialCore(
LoadedLandblock landblock,
IEnumerable<ulong>? 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<ulong>(),
Array.Empty<WorldEntity>(),
RequiresActivation: false);
}
// 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<WorldEntity>(landblock.Entities.Count + pending.Count);
merged.AddRange(landblock.Entities);
merged.AddRange(pending);
landblock = NormalizeLoadedLandblock(landblock, merged);
_pendingByLandblock.Remove(landblock.LandblockId);
}
HashSet<ulong>? mergedRenderIds = additionalRenderIds is null
? null
: new HashSet<ulong>(additionalRenderIds);
if (_pendingRenderIdsByLandblock.Remove(landblock.LandblockId, out var pendingRenderIds))
{
mergedRenderIds ??= new HashSet<ulong>();
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 (_loaded.Remove(landblock.LandblockId, out displaced))
{
RemoveLoadedLandblockFromFlatView(displaced);
_animatedIndexByLandblock.Remove(landblock.LandblockId);
}
_loaded[landblock.LandblockId] = landblock;
AddLoadedLandblockToFlatView(landblock);
_tierByLandblock[landblock.LandblockId] = tier;
return CreateSpatialPublication(
_loaded[landblock.LandblockId],
(IEnumerable<ulong>?)mergedRenderIds ?? Array.Empty<ulong>());
}
/// <summary>
/// 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.
/// </summary>
private void ReconcileRetainedStaticEntityScripts(
LoadedLandblock displaced,
LoadedLandblock replacement)
{
if (_entityScriptActivator is null)
return;
var replacementsById = new Dictionary<uint, WorldEntity>();
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<ulong> additionalRenderIds)
{
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);
}
/// <summary>
/// Mark a server-GUID as persistent — this entity survives landblock unloads
/// and gets re-parked as pending for its current canonical landblock.
/// </summary>
public void MarkPersistent(uint serverGuid)
{
_persistentGuids.Add(serverGuid);
}
/// <summary>
/// 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.
/// </summary>
public void RebucketLiveEntity(WorldEntity entity, uint newCanonicalLb)
{
if (entity.ServerGuid == 0) return;
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).
if (_projectionLocations.TryGetValue(entity, out ProjectionLocation current)
&& 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(canonical, entity);
}
/// <summary>
/// Remove <paramref name="entity"/> (by reference) from whichever
/// <see cref="_loaded"/> or <see cref="_pendingByLandblock"/> bucket it
/// currently occupies. At most one bucket holds a given entity, so this
/// stops after the first hit. Called by <see cref="RebucketLiveEntity"/> before
/// re-appending, so a stranded pending entity can be promoted.
/// </summary>
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<WorldEntity>? 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);
}
/// <summary>
/// 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
/// <see cref="LandblockRetirementCoordinator"/> after the old state has
/// become unreachable.
/// </summary>
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<WorldEntity> detachedEntities =
_loaded.TryGetValue(canonical, out LoadedLandblock? residentLandblock)
? residentLandblock.Entities
: Array.Empty<WorldEntity>();
if (_pendingByLandblock.TryGetValue(canonical, out List<WorldEntity>? detachedPending)
&& detachedPending.Count > 0)
{
if (detachedEntities.Count == 0)
{
detachedEntities = detachedPending;
}
else
{
var combined = new List<WorldEntity>(
detachedEntities.Count + detachedPending.Count);
var seen = new HashSet<WorldEntity>(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<WorldEntity>();
var retainedLiveSet = new HashSet<WorldEntity>(ReferenceEqualityComparer.Instance);
void RetainOrRescue(WorldEntity entity, string source)
{
if (entity.ServerGuid == 0)
return;
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);
}
// 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 (_loaded.Remove(canonical, out LoadedLandblock? removed))
RemoveLoadedLandblockFromFlatView(removed);
for (int i = 0; i < retainedLive.Count; i++)
SetProjectionLocation(retainedLive[i], canonical, isLoaded: false, i);
return new GpuLandblockRetirement(
canonical,
LandblockRetirementKind.Full,
detachedEntities);
}
/// <summary>
/// Compatibility edge for direct state tests. Production streaming uses
/// <see cref="LandblockRetirementCoordinator"/> so each presentation owner
/// has a retryable committed marker.
/// </summary>
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<WorldEntity> _persistentRescued = new();
/// <summary>
/// Drain entities rescued from unloaded landblocks. The caller should
/// re-inject each through <c>LiveEntityRuntime.RebucketLiveEntity</c> with its current position.
/// </summary>
public List<WorldEntity> DrainRescued()
{
if (_persistentRescued.Count == 0) return _persistentRescued;
var result = new List<WorldEntity>(_persistentRescued);
_persistentRescued.Clear();
return result;
}
/// <summary>
/// Remove every entity with the given <paramref name="serverGuid"/> from
/// all loaded landblocks AND all pending buckets. Called by
/// <c>LiveEntityRuntime</c> 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.
/// </summary>
public void RemoveLiveEntityProjection(uint serverGuid)
{
if (serverGuid == 0) return;
using MutationBatch mutation = BeginMutationBatch();
// Canonical live projections are indexed by server GUID and entity
// identity, so delete/withdraw never scans every resident landblock.
while (_primaryProjectionByGuid.TryGetValue(serverGuid, out WorldEntity? projection))
{
RemoveEntityFromAllBuckets(projection);
}
// 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);
}
/// <summary>
/// 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.
/// </summary>
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));
}
/// <summary>
/// Ends all spatial lifetime retained for one server object. Temporary
/// rebucketing uses <see cref="RemoveLiveEntityProjection"/> and keeps the
/// persistence classification; logical delete also forgets that class so
/// a later session/GUID reuse cannot be rescued as the old player.
/// </summary>
public void ForgetLiveEntity(uint serverGuid)
{
RemoveLiveEntityProjection(serverGuid);
_persistentGuids.Remove(serverGuid);
_persistentInFlatProbe.Remove(serverGuid);
}
/// <summary>Clears session-scoped persistence and undrained rescues.</summary>
public void ClearLiveEntityLifetimeState()
{
_persistentGuids.Clear();
_persistentRescued.Clear();
_persistentInFlatProbe.Clear();
_visibilityTransitions.Clear();
_visibleLiveProjectionCounts.Clear();
_visibilityBeforeMutation.Clear();
_projectionLocations.Clear();
_loadedLiveByLandblock.Clear();
_primaryProjectionByGuid.Clear();
_additionalProjectionsByGuid.Clear();
}
/// <summary>
/// Place an already-registered live entity in a landblock slot whose
/// terrain may or may not be loaded yet.
///
/// <para>
/// The server's <c>landblockId</c> is in cell-resolved form
/// (<c>0xAAAA00CC</c>: high byte X, second byte Y, low 16 bits cell
/// index within the landblock). The streaming system stores landblocks
/// keyed by their canonical <c>0xAAAA0xFFFF</c> form. Canonicalize
/// on the way in so callers don't have to think about it.
/// </para>
///
/// <para>
/// Outcome:
/// <list type="bullet">
/// <item>If the landblock is already loaded, the entity is appended
/// to its landblock and flat render-thread lists in O(1).</item>
/// <item>If the landblock is not yet loaded, the entity is parked
/// in <see cref="_pendingByLandblock"/> and will be merged
/// into the next <see cref="AddLandblock"/> for the same id.</item>
/// </list>
/// </para>
/// </summary>
public void PlaceLiveEntityProjection(uint landblockId, WorldEntity entity)
{
using MutationBatch mutation = BeginMutationBatch();
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);
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<WorldEntity>();
_pendingByLandblock[canonicalLandblockId] = bucket;
}
int bucketIndex = bucket.Count;
bucket.Add(entity);
SetProjectionLocation(entity, canonicalLandblockId, isLoaded: false, bucketIndex);
if (probePersistent)
EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> PENDING(hidden)");
}
/// <summary>
/// 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.
///
/// <para>
/// 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.
/// </para>
/// </summary>
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<WorldEntity>();
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<WorldEntity>();
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);
_tierByLandblock[canonical] = LandblockStreamTier.Far;
}
return new GpuLandblockRetirement(
canonical,
LandblockRetirementKind.NearLayer,
retiredEntities);
}
/// <summary>
/// Compatibility edge for direct state tests. Production streaming uses
/// the retryable retirement coordinator.
/// </summary>
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]);
}
/// <summary>
/// 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.
///
/// <para>
/// <b>Landblock id is canonicalized</b> (low 16 bits forced to 0xFFFF) —
/// callers may pass cell-resolved ids and they will key correctly.
/// </para>
/// </summary>
public bool AddEntitiesToExistingLandblock(
uint landblockId,
IReadOnlyList<WorldEntity> entities,
IEnumerable<ulong>? 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<WorldEntity> entities,
IEnumerable<ulong>? 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<WorldEntity> entities,
IEnumerable<ulong>? 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<WorldEntity>();
_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<ulong>();
_pendingRenderIdsByLandblock[canonical] = renderIds;
}
renderIds.UnionWith(additionalRenderIds);
}
return null;
}
List<WorldEntity> 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(entity, +1);
}
_animatedIndexByLandblock.Remove(canonical);
_tierByLandblock[canonical] = LandblockStreamTier.Near;
ulong[] effectiveRenderIds = additionalRenderIds?.ToArray() ?? Array.Empty<ulong>();
WorldEntity[] staticEntities = entities
.Where(static entity => entity.ServerGuid == 0)
.ToArray();
return new GpuLandblockSpatialPublication(
canonical,
_loaded[canonical],
effectiveRenderIds,
staticEntities);
}
private void DrainVisibilityTransitions()
{
if (_dispatchingVisibilityTransitions)
return;
List<Exception>? failures = null;
_dispatchingVisibilityTransitions = true;
try
{
while (_visibilityTransitions.TryDequeue(out var transition))
{
Delegate[] subscribers = LiveProjectionVisibilityChanged?
.GetInvocationList()
?? Array.Empty<Delegate>();
for (int i = 0; i < subscribers.Length; i++)
{
try
{
((Action<uint, bool>)subscribers[i])(
transition.ServerGuid,
transition.Visible);
}
catch (Exception error)
{
(failures ??= new List<Exception>()).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<uint> _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<uint>();
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);
}
}