acdream/src/AcDream.App/Streaming/GpuWorldState.cs

863 lines
37 KiB
C#

using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
/// <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
{
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();
/// <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();
// Cached flat view over all entities across all loaded landblocks,
// rebuilt on each add/remove. The renderer holds a reference to this
// list, so rebuilding it replaces the reference atomically.
private IReadOnlyList<WorldEntity> _flatEntities = System.Array.Empty<WorldEntity>();
private readonly HashSet<uint> _visibleLiveGuids = new();
private readonly Queue<(uint ServerGuid, bool Visible)> _visibilityTransitions = new();
private bool _dispatchingVisibilityTransitions;
public IReadOnlyList<WorldEntity> Entities => _flatEntities;
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 && _visibleLiveGuids.Contains(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 built on the fly
/// 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).
/// Building the dict per-yield is cheap (~132 entities/LB max). A caching
/// layer is out of A.5 scope.
/// </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)
{
Dictionary<uint, WorldEntity>? byId = null;
if (includeAnimatedIndex)
{
byId = new Dictionary<uint, WorldEntity>(kvp.Value.Entities.Count);
foreach (var e in kvp.Value.Entities)
byId[e.Id] = e;
}
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;
public void AddLandblock(
LoadedLandblock landblock,
IEnumerable<ulong>? additionalRenderIds = null,
LandblockStreamTier tier = LandblockStreamTier.Near)
{
// 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))
return;
// If pending live entities have been waiting for this landblock,
// merge them into the LoadedLandblock record before storing. The
// record's Entities field is IReadOnlyList; we replace the whole
// list rather than try to mutate in place.
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 = new LoadedLandblock(
landblock.LandblockId,
landblock.Heightmap,
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;
}
_loaded[landblock.LandblockId] = landblock;
_tierByLandblock[landblock.LandblockId] = tier;
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockLoaded(
_loaded[landblock.LandblockId],
mergedRenderIds);
// C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0).
// LiveEntityRuntime owns activation for live objects. This static-only
// filter avoids replaying their defaults when a pending projection is
// drained into a newly loaded landblock.
if (_entityScriptActivator is not null)
{
var loadedEntities = _loaded[landblock.LandblockId].Entities;
for (int i = 0; i < loadedEntities.Count; i++)
{
var e = loadedEntities[i];
if (e.ServerGuid == 0)
_entityScriptActivator.OnCreate(e);
}
}
RebuildFlatView();
}
/// <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;
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 (_loaded.TryGetValue(canonical, out var target))
{
foreach (var e in target.Entities)
if (ReferenceEquals(e, entity)) return;
}
// Remove the entity from wherever it currently lives — a loaded bucket
// OR a pending bucket — then re-append to its current landblock.
//
// Scanning _pendingByLandblock 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). 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)
{
foreach (var kvp in _loaded)
{
var entities = kvp.Value.Entities;
for (int i = 0; i < entities.Count; i++)
{
if (ReferenceEquals(entities[i], entity))
{
var newList = new List<WorldEntity>(entities.Count - 1);
for (int j = 0; j < entities.Count; j++)
if (j != i) newList.Add(entities[j]);
_loaded[kvp.Key] = new LoadedLandblock(
kvp.Value.LandblockId, kvp.Value.Heightmap, newList);
return;
}
}
}
foreach (var kvp in _pendingByLandblock)
{
if (kvp.Value.Remove(entity))
return;
}
}
public void RemoveLandblock(uint landblockId)
{
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockUnloaded(canonical);
// 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>();
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 (!retainedLive.Any(existing => ReferenceEquals(existing, 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");
// C.1.5b: stop DefaultScript for each dat-hydrated entity in
// the landblock. Server-spawned entities are either being
// rescued (script continues at the new LB) or were OnRemove'd
// by LiveEntityRuntime logical teardown; leave them alone here.
if (_entityScriptActivator is not null)
{
foreach (var entity in lb.Entities)
{
if (entity.ServerGuid == 0)
_entityScriptActivator.OnRemove(entity);
}
}
}
// #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");
}
_pendingByLandblock.Remove(canonical);
_pendingRenderIdsByLandblock.Remove(canonical);
_pendingNearTierLandblocks.Remove(canonical);
if (retainedLive.Count > 0)
_pendingByLandblock[canonical] = retainedLive;
_aabbs.Remove(canonical);
_tierByLandblock.Remove(canonical);
if (_loaded.Remove(canonical))
RebuildFlatView();
}
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, then rebuild the flat
/// view. 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;
bool rebuiltLoaded = false;
// Scan loaded landblocks. ToArray() so we can mutate _loaded inside.
foreach (var kvp in _loaded.ToArray())
{
var lb = kvp.Value;
int foundCount = 0;
for (int i = 0; i < lb.Entities.Count; i++)
if (lb.Entities[i].ServerGuid == serverGuid) foundCount++;
if (foundCount == 0) continue;
var newList = new List<WorldEntity>(lb.Entities.Count - foundCount);
foreach (var e in lb.Entities)
if (e.ServerGuid != serverGuid) newList.Add(e);
_loaded[kvp.Key] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, newList);
rebuiltLoaded = true;
}
// Scrub pending buckets too so rebucketing cannot leave a second slot.
foreach (uint landblockId in _pendingByLandblock.Keys.ToArray())
{
List<WorldEntity> bucket = _pendingByLandblock[landblockId];
bucket.RemoveAll(e => e.ServerGuid == serverGuid);
if (bucket.Count == 0)
_pendingByLandblock.Remove(landblockId);
}
// 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);
if (rebuiltLoaded) RebuildFlatView();
}
/// <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;
bool rebuiltLoaded = false;
foreach (var kvp in _loaded.ToArray())
{
IReadOnlyList<WorldEntity> entities = kvp.Value.Entities;
if (!entities.Any(candidate => ReferenceEquals(candidate, entity)))
continue;
_loaded[kvp.Key] = new LoadedLandblock(
kvp.Value.LandblockId,
kvp.Value.Heightmap,
entities.Where(candidate => !ReferenceEquals(candidate, entity)).ToArray());
rebuiltLoaded = true;
}
foreach (uint landblockId in _pendingByLandblock.Keys.ToArray())
{
List<WorldEntity> bucket = _pendingByLandblock[landblockId];
bucket.RemoveAll(candidate => ReferenceEquals(candidate, entity));
if (bucket.Count == 0)
_pendingByLandblock.Remove(landblockId);
}
_persistentRescued.RemoveAll(candidate => ReferenceEquals(candidate, entity));
if (rebuiltLoaded)
RebuildFlatView();
}
/// <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();
_visibleLiveGuids.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 <c>Entities</c> list and the flat view is rebuilt
/// immediately.</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)
{
// 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.TryGetValue(canonicalLandblockId, out var lb))
{
// Hot path — landblock is already loaded. Rebuild the record
// with the new entity appended.
var newEntities = new List<WorldEntity>(lb.Entities.Count + 1);
newEntities.AddRange(lb.Entities);
newEntities.Add(entity);
_loaded[canonicalLandblockId] = new LoadedLandblock(
lb.LandblockId,
lb.Heightmap,
newEntities);
if (probePersistent)
EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> LOADED(drawn)");
RebuildFlatView();
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;
}
bucket.Add(entity);
if (probePersistent)
EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> PENDING(hidden)");
RebuildFlatView();
}
/// <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 void RemoveEntitiesFromLandblock(uint landblockId)
{
// 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.
_pendingNearTierLandblocks.Remove(canonical);
_pendingRenderIdsByLandblock.Remove(canonical);
if (_pendingByLandblock.TryGetValue(canonical, out var pending))
{
pending.RemoveAll(entity => entity.ServerGuid == 0);
if (pending.Count == 0)
_pendingByLandblock.Remove(canonical);
}
if (!_loaded.TryGetValue(canonical, out var lb)) return;
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockUnloaded(canonical);
// 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.
_onLandblockUnloaded?.Invoke(canonical);
// 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.
if (_entityScriptActivator is not null)
{
foreach (var entity in lb.Entities)
{
if (entity.ServerGuid == 0)
_entityScriptActivator.OnRemove(entity);
}
}
WorldEntity[] retainedLive = lb.Entities
.Where(entity => entity.ServerGuid != 0)
.ToArray();
_loaded[canonical] = new LoadedLandblock(
lb.LandblockId,
lb.Heightmap,
retainedLive);
_tierByLandblock[canonical] = LandblockStreamTier.Far;
RebuildFlatView();
}
/// <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)
{
// A.5 T14 follow-up: canonicalize for symmetry with live projection placement.
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (!_loaded.TryGetValue(canonical, out var lb))
{
// 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;
}
bucket.AddRange(entities);
_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 false;
}
var merged = new List<WorldEntity>(lb.Entities.Count + entities.Count);
merged.AddRange(lb.Entities);
merged.AddRange(entities);
_loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged);
_tierByLandblock[canonical] = LandblockStreamTier.Near;
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical], additionalRenderIds);
// C.1.5b: fire DefaultScript for each promoted dat-hydrated entity.
// All entities arriving via this path are atlas-tier by construction
// (the promotion path streams in dat-static scenery + EnvCell statics
// + stabs per the method's class doc-comment).
if (_entityScriptActivator is not null)
{
for (int i = 0; i < entities.Count; i++)
_entityScriptActivator.OnCreate(entities[i]);
}
RebuildFlatView();
return true;
}
private void RebuildFlatView()
{
_flatEntities = _loaded.Values.SelectMany(lb => lb.Entities).ToArray();
var nowVisible = new HashSet<uint>(
_flatEntities
.Where(entity => entity.ServerGuid != 0)
.Select(entity => entity.ServerGuid));
uint[] becameHidden = _visibleLiveGuids
.Where(guid => !nowVisible.Contains(guid))
.ToArray();
uint[] becameVisible = nowVisible
.Where(guid => !_visibleLiveGuids.Contains(guid))
.ToArray();
// Commit the new spatial truth before notifying observers. A
// visibility callback may synchronously rebucket/withdraw the same
// object (deferred teleport rollback); those nested transitions must
// compare against this committed state rather than the stale prior set.
_visibleLiveGuids.Clear();
_visibleLiveGuids.UnionWith(nowVisible);
foreach (uint guid in becameHidden)
_visibilityTransitions.Enqueue((guid, false));
foreach (uint guid in becameVisible)
_visibilityTransitions.Enqueue((guid, true));
DrainVisibilityTransitions();
if (EntityVanishProbe.Enabled) ProbeFlatViewTransitions();
}
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);
}
}