Own render, physics, DAT-static presentation, and retained static-resource rebinds behind retryable receipts so a failed publication resumes its exact unfinished suffix without replaying retail create-time defaults. Co-authored-by: Codex <noreply@openai.com>
1501 lines
62 KiB
C#
1501 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;
|
|
|
|
/// <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)
|
|
{
|
|
_wbSpawnAdapter = wbSpawnAdapter;
|
|
_onLandblockUnloaded = onLandblockUnloaded;
|
|
_entityScriptActivator = entityScriptActivator;
|
|
}
|
|
|
|
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);
|
|
public bool IsLiveEntityVisible(uint serverGuid) =>
|
|
serverGuid != 0 && _visibleLiveProjectionCounts.ContainsKey(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
|
|
{
|
|
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)
|
|
{
|
|
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();
|
|
|
|
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);
|
|
}
|
|
}
|