Root cause (confirmed live via ACDREAM_PROBE_ENT): a persistent server-spawned
entity that spawns into a not-yet-loaded landblock is parked in
GpuWorldState._pendingByLandblock. RelocateEntity — called every frame to keep
the player homed to its current landblock so it draws — scanned ONLY _loaded, so
it silently no-op'd on a pending entity. The player then fell through ALL of the
recovery paths: the AddLandblock pending-drain had already run (empty) before the
cold-spawn churn re-parked the player; the server-object re-hydrate excludes the
player by design ("persistent-rescue owns it"); and RelocateEntity couldn't reach
a pending entity. Net: the player stayed stranded in pending, hidden forever.
Probe evidence (cold-spawn at 0xADAF): `[ent] APPEND guid=0x5000000A
lb=0xADAFFFFF -> PENDING(hidden)`, no later `DRAWSET PRESENT` — while the sibling
NPCs `re-hydrated 3 server object(s) into landblock 0xADAFFFFF` and drew fine.
This is the mechanism behind BOTH reported symptoms: cold-spawn "everything gone
/ invisible" AND "character disappears running out of Holtburg far enough" — both
are "player parked in pending, never recovered" (run-out crosses into a
still-streaming landblock; cold-spawn spawns into one).
Fix: RelocateEntity now removes the entity from whichever bucket it occupies
(_loaded OR _pendingByLandblock) via RemoveEntityFromAllBuckets, then re-appends
to its current landblock — promoting a stranded pending entity to drawn as soon
as its landblock is loaded. Keeps the fast-path early-return for a settled
entity (no per-frame churn).
NOT R5-V2: verified line-by-line that the voyeur/target wiring is read-only w.r.t.
position/cell/landblock/streaming — this is a pre-existing streaming bug. The
separate cold-spawn `cells=0` (outdoor spawn placed before Near-tier cells load,
no re-snap) is benign for outdoor placement (terrain-Z is used) and filed
separately; it does not block visibility, which this fix restores.
Test: GpuWorldStateTests.RelocateEntity_StrandedInPending_MovesToLoadedTarget
(red before, green after). Full suite 4007 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
642 lines
28 KiB
C#
642 lines
28 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 it.
|
|
/// </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="AppendLiveEntity"/> 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"/> drops pending entries for
|
|
/// the same landblock — if the landblock just left the visible window,
|
|
/// any spawns that came with it are no longer relevant.
|
|
/// </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 EntitySpawnAdapter? _wbEntitySpawnAdapter;
|
|
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,
|
|
EntitySpawnAdapter? wbEntitySpawnAdapter = null,
|
|
System.Action<uint>? onLandblockUnloaded = null,
|
|
EntityScriptActivator? entityScriptActivator = null)
|
|
{
|
|
_wbSpawnAdapter = wbSpawnAdapter;
|
|
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
|
|
_onLandblockUnloaded = onLandblockUnloaded;
|
|
_entityScriptActivator = entityScriptActivator;
|
|
}
|
|
|
|
private readonly Dictionary<uint, LoadedLandblock> _loaded = 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();
|
|
|
|
/// <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>();
|
|
|
|
public IReadOnlyList<WorldEntity> Entities => _flatEntities;
|
|
public IReadOnlyCollection<uint> LoadedLandblockIds => _loaded.Keys;
|
|
|
|
public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId);
|
|
|
|
/// <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 void AddLandblock(LoadedLandblock landblock)
|
|
{
|
|
// 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);
|
|
}
|
|
|
|
_loaded[landblock.LandblockId] = landblock;
|
|
if (_wbSpawnAdapter is not null)
|
|
_wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]);
|
|
|
|
// C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0).
|
|
// Live entities (ServerGuid!=0) already had OnCreate fired at
|
|
// AppendLiveEntity; the filter avoids double-firing pending-bucket merges.
|
|
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 RelocateEntity(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 RelocateEntity 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.
|
|
RemoveEntityFromAllBuckets(entity);
|
|
AppendLiveEntity(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="RelocateEntity"/> 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)
|
|
{
|
|
if (_wbSpawnAdapter is not null)
|
|
_wbSpawnAdapter.OnLandblockUnloaded(landblockId);
|
|
|
|
// Rescue persistent entities before removal. These get appended
|
|
// to the _persistentRescued list; the caller is responsible for
|
|
// re-injecting them (via AppendLiveEntity) into whatever landblock
|
|
// the player is currently on.
|
|
if (_loaded.TryGetValue(landblockId, out var lb))
|
|
{
|
|
foreach (var entity in lb.Entities)
|
|
{
|
|
if (entity.ServerGuid != 0 && _persistentGuids.Contains(entity.ServerGuid))
|
|
{
|
|
_persistentRescued.Add(entity);
|
|
EntityVanishProbe.Log($"[ent] RESCUE guid=0x{entity.ServerGuid:X8} from=loaded lb=0x{landblockId:X8}");
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// via RemoveEntityByServerGuid earlier; leave them alone here.
|
|
if (_entityScriptActivator is not null)
|
|
{
|
|
foreach (var entity in lb.Entities)
|
|
{
|
|
if (entity.ServerGuid == 0)
|
|
_entityScriptActivator.OnRemove(entity.Id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// #138 (secondary): rescue persistent entities sitting in the PENDING
|
|
// bucket too, not just the loaded list. The player is re-injected via
|
|
// AppendLiveEntity 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(landblockId, out var pendingForLb))
|
|
{
|
|
foreach (var entity in pendingForLb)
|
|
{
|
|
if (entity.ServerGuid != 0 && _persistentGuids.Contains(entity.ServerGuid))
|
|
{
|
|
_persistentRescued.Add(entity);
|
|
EntityVanishProbe.Log($"[ent] RESCUE guid=0x{entity.ServerGuid:X8} from=pending lb=0x{landblockId:X8}");
|
|
}
|
|
}
|
|
}
|
|
|
|
_pendingByLandblock.Remove(landblockId);
|
|
_aabbs.Remove(landblockId);
|
|
|
|
if (_loaded.Remove(landblockId))
|
|
RebuildFlatView();
|
|
}
|
|
|
|
private readonly List<WorldEntity> _persistentRescued = new();
|
|
|
|
/// <summary>
|
|
/// Drain entities rescued from unloaded landblocks. The caller should
|
|
/// re-inject each via <see cref="AppendLiveEntity"/> 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. Used by the live <c>CreateObject</c> handler to de-duplicate
|
|
/// when the server re-sends a spawn (visibility refresh, landblock
|
|
/// crossing, etc.). Without this, multiple copies of the same NPC
|
|
/// accumulate in the renderer, each with its own <c>PaletteOverride</c>
|
|
/// and <c>MeshRefs</c> — producing "NPC clothing flickers as I turn the
|
|
/// camera" because the depth test picks different duplicates frame-to-frame.
|
|
///
|
|
/// Safe to call with a server guid that's not currently present — no-op.
|
|
/// </summary>
|
|
public void RemoveEntityByServerGuid(uint serverGuid)
|
|
{
|
|
if (serverGuid == 0) return;
|
|
|
|
// Phase N.4 Task 17: release per-instance state for server-spawned
|
|
// entities. No-op for atlas-tier entities (never registered).
|
|
_wbEntitySpawnAdapter?.OnRemove(serverGuid);
|
|
_entityScriptActivator?.OnRemove(serverGuid);
|
|
|
|
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 — a duplicate CreateObject may arrive
|
|
// while the landblock is still loading.
|
|
foreach (var kvp in _pendingByLandblock)
|
|
kvp.Value.RemoveAll(e => e.ServerGuid == serverGuid);
|
|
|
|
if (rebuiltLoaded) RebuildFlatView();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Append an entity to a specific landblock's slot. Used by the live
|
|
/// CreateObject path where the server spawns entities at a server-side
|
|
/// position whose landblock 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 AppendLiveEntity(uint landblockId, WorldEntity entity)
|
|
{
|
|
// Phase N.4 Task 17: route server-spawned entities through the
|
|
// per-instance adapter. Atlas-tier entities (ServerGuid == 0) are
|
|
// skipped by OnCreate — it returns null immediately for those.
|
|
_wbEntitySpawnAdapter?.OnCreate(entity);
|
|
_entityScriptActivator?.OnCreate(entity);
|
|
|
|
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)");
|
|
}
|
|
|
|
/// <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>
|
|
/// <b>Persistent-entity rescue is intentionally omitted</b> (unlike
|
|
/// <see cref="RemoveLandblock"/>): demote-tier entities are atlas-tier
|
|
/// only (procedural scenery, dat-static stabs/buildings) — they never
|
|
/// have <c>ServerGuid != 0</c> and so can never be in <see cref="_persistentGuids"/>.
|
|
/// The local player and other live server-spawned entities live in their
|
|
/// landblock via <c>RelocateEntity</c> per frame and are not affected
|
|
/// by Near→Far demotion of dat-static landblock layers.
|
|
/// </para>
|
|
/// </summary>
|
|
public void RemoveEntitiesFromLandblock(uint landblockId)
|
|
{
|
|
// A.5 T14 follow-up: canonicalize for symmetry with AppendLiveEntity.
|
|
// Streaming callers always pass canonical (0xAAAA0xFFFF) ids; this
|
|
// protects against future callers that mirror AppendLiveEntity's
|
|
// cell-resolved-id pattern.
|
|
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
|
|
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.Id);
|
|
}
|
|
}
|
|
|
|
_loaded[canonical] = new LoadedLandblock(
|
|
lb.LandblockId,
|
|
lb.Heightmap,
|
|
System.Array.Empty<WorldEntity>());
|
|
_pendingByLandblock.Remove(canonical);
|
|
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 void AddEntitiesToExistingLandblock(uint landblockId, IReadOnlyList<WorldEntity> entities)
|
|
{
|
|
// A.5 T14 follow-up: canonicalize for symmetry with AppendLiveEntity.
|
|
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
|
|
if (!_loaded.TryGetValue(canonical, out var lb))
|
|
{
|
|
// Park as pending — same pattern as AppendLiveEntity for not-yet-loaded LBs.
|
|
if (!_pendingByLandblock.TryGetValue(canonical, out var bucket))
|
|
{
|
|
bucket = new List<WorldEntity>();
|
|
_pendingByLandblock[canonical] = bucket;
|
|
}
|
|
bucket.AddRange(entities);
|
|
return;
|
|
}
|
|
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);
|
|
if (_wbSpawnAdapter is not null)
|
|
_wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical]);
|
|
|
|
// 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();
|
|
}
|
|
|
|
private void RebuildFlatView()
|
|
{
|
|
_flatEntities = _loaded.Values.SelectMany(lb => lb.Entities).ToArray();
|
|
if (EntityVanishProbe.Enabled) ProbeFlatViewTransitions();
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|