refactor(world): separate live lifetime from spatial buckets

Introduce LiveEntityRuntime as the canonical owner of each accepted server-object incarnation, stable local identity, timestamped state, parent relations, runtime components, and exactly-once teardown. Split logical registration from rebucketing so pending landblocks, equipment attachment, pickup re-entry, and GUID replacement reuse the same entity and effect owners.

Keep canonical materialized and visible target/radar views distinct, preserve retail leave_world versus exit_world semantics, gate root simulation while cell-less, and track transitional pre-Create F754 owners through delete and session reset. Remove stale-spawn rehydration and make GpuWorldState spatial-only for live objects.

Add lifecycle, generation, pending, unload, attachment, event-publication, local-ID, rollback, and effect-cleanup coverage; update architecture, milestones, memory, and the divergence register.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-14 07:47:03 +02:00
parent 8a5d77f7f4
commit 8dd996053d
24 changed files with 2449 additions and 631 deletions

View file

@ -16,7 +16,8 @@ namespace AcDream.App.Streaming;
/// <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.
/// thing that mutates spatial buckets. Logical live-object identity and
/// create-time resources are owned by <see cref="AcDream.App.World.LiveEntityRuntime"/>.
/// </para>
///
/// <para>
@ -25,13 +26,13 @@ namespace AcDream.App.Streaming;
/// 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
/// 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"/> 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.
/// 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>
@ -41,7 +42,6 @@ namespace AcDream.App.Streaming;
public sealed class GpuWorldState
{
private readonly LandblockSpawnAdapter? _wbSpawnAdapter;
private readonly EntitySpawnAdapter? _wbEntitySpawnAdapter;
private readonly EntityScriptActivator? _entityScriptActivator;
/// <summary>
@ -58,12 +58,10 @@ public sealed class GpuWorldState
public GpuWorldState(
LandblockSpawnAdapter? wbSpawnAdapter = null,
EntitySpawnAdapter? wbEntitySpawnAdapter = null,
System.Action<uint>? onLandblockUnloaded = null,
EntityScriptActivator? entityScriptActivator = null)
{
_wbSpawnAdapter = wbSpawnAdapter;
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
_onLandblockUnloaded = onLandblockUnloaded;
_entityScriptActivator = entityScriptActivator;
}
@ -89,11 +87,15 @@ public sealed class GpuWorldState
// 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();
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 IsLiveEntityVisible(uint serverGuid) =>
serverGuid != 0 && _visibleLiveGuids.Contains(serverGuid);
/// <summary>
/// Try to grab the loaded record for a landblock — useful for callers
@ -219,8 +221,9 @@ public sealed class GpuWorldState
_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.
// 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;
@ -251,7 +254,7 @@ public sealed class GpuWorldState
/// the entity stays in the spawn landblock and gets frustum-culled when
/// the player walks away.
/// </summary>
public void RelocateEntity(WorldEntity entity, uint newCanonicalLb)
public void RebucketLiveEntity(WorldEntity entity, uint newCanonicalLb)
{
if (entity.ServerGuid == 0) return;
@ -275,19 +278,19 @@ public sealed class GpuWorldState
// 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,
// 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.
RemoveEntityFromAllBuckets(entity);
AppendLiveEntity(canonical, 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="RelocateEntity"/> before
/// 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)
@ -304,6 +307,7 @@ public sealed class GpuWorldState
if (j != i) newList.Add(entities[j]);
_loaded[kvp.Key] = new LoadedLandblock(
kvp.Value.LandblockId, kvp.Value.Heightmap, newList);
RebuildFlatView();
return;
}
}
@ -318,28 +322,44 @@ public sealed class GpuWorldState
public void RemoveLandblock(uint landblockId)
{
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockUnloaded(landblockId);
_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 (via AppendLiveEntity) into whatever landblock
// re-injecting them through LiveEntityRuntime rebucketing into whatever landblock
// the player is currently on.
if (_loaded.TryGetValue(landblockId, out var lb))
if (_loaded.TryGetValue(canonical, 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}");
}
}
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
// via RemoveEntityByServerGuid earlier; leave them alone here.
// by LiveEntityRuntime logical teardown; leave them alone here.
if (_entityScriptActivator is not null)
{
foreach (var entity in lb.Entities)
@ -352,7 +372,7 @@ public sealed class GpuWorldState
// #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
// 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
@ -361,22 +381,18 @@ public sealed class GpuWorldState
// "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))
if (_pendingByLandblock.TryGetValue(canonical, 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}");
}
}
RetainOrRescue(entity, "pending");
}
_pendingByLandblock.Remove(landblockId);
_aabbs.Remove(landblockId);
_pendingByLandblock.Remove(canonical);
if (retainedLive.Count > 0)
_pendingByLandblock[canonical] = retainedLive;
_aabbs.Remove(canonical);
if (_loaded.Remove(landblockId))
if (_loaded.Remove(canonical))
RebuildFlatView();
}
@ -384,7 +400,7 @@ public sealed class GpuWorldState
/// <summary>
/// Drain entities rescued from unloaded landblocks. The caller should
/// re-inject each via <see cref="AppendLiveEntity"/> with its current position.
/// re-inject each through <c>LiveEntityRuntime.RebucketLiveEntity</c> with its current position.
/// </summary>
public List<WorldEntity> DrainRescued()
{
@ -397,24 +413,16 @@ public sealed class GpuWorldState
/// <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.
/// 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 RemoveEntityByServerGuid(uint serverGuid)
public void RemoveLiveEntityProjection(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.
@ -435,8 +443,7 @@ public sealed class GpuWorldState
rebuiltLoaded = true;
}
// Scrub pending buckets too — a duplicate CreateObject may arrive
// while the landblock is still loading.
// Scrub pending buckets too so rebucketing cannot leave a second slot.
foreach (var kvp in _pendingByLandblock)
kvp.Value.RemoveAll(e => e.ServerGuid == serverGuid);
@ -444,9 +451,8 @@ public sealed class GpuWorldState
}
/// <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.
/// 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
@ -468,14 +474,10 @@ public sealed class GpuWorldState
/// </list>
/// </para>
/// </summary>
public void AppendLiveEntity(uint landblockId, WorldEntity entity)
public void PlaceLiveEntityProjection(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);
// 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);
@ -516,20 +518,16 @@ public sealed class GpuWorldState
/// 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.
/// 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 AppendLiveEntity.
// 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 AppendLiveEntity's
// protects against future callers that mirror live projection placement's
// cell-resolved-id pattern.
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (!_loaded.TryGetValue(canonical, out var lb)) return;
@ -555,11 +553,19 @@ public sealed class GpuWorldState
}
}
WorldEntity[] retainedLive = lb.Entities
.Where(entity => entity.ServerGuid != 0)
.ToArray();
_loaded[canonical] = new LoadedLandblock(
lb.LandblockId,
lb.Heightmap,
System.Array.Empty<WorldEntity>());
_pendingByLandblock.Remove(canonical);
retainedLive);
if (_pendingByLandblock.TryGetValue(canonical, out var pending))
{
pending.RemoveAll(entity => entity.ServerGuid == 0);
if (pending.Count == 0)
_pendingByLandblock.Remove(canonical);
}
RebuildFlatView();
}
@ -578,11 +584,11 @@ public sealed class GpuWorldState
/// </summary>
public void AddEntitiesToExistingLandblock(uint landblockId, IReadOnlyList<WorldEntity> entities)
{
// A.5 T14 follow-up: canonicalize for symmetry with AppendLiveEntity.
// 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 AppendLiveEntity for not-yet-loaded LBs.
// Park as pending — same pattern as live projections for not-yet-loaded LBs.
if (!_pendingByLandblock.TryGetValue(canonical, out var bucket))
{
bucket = new List<WorldEntity>();
@ -614,6 +620,22 @@ public sealed class GpuWorldState
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));
foreach (uint guid in _visibleLiveGuids)
{
if (!nowVisible.Contains(guid))
LiveProjectionVisibilityChanged?.Invoke(guid, false);
}
foreach (uint guid in nowVisible)
{
if (!_visibleLiveGuids.Contains(guid))
LiveProjectionVisibilityChanged?.Invoke(guid, true);
}
_visibleLiveGuids.Clear();
_visibleLiveGuids.UnionWith(nowVisible);
if (EntityVanishProbe.Enabled) ProbeFlatViewTransitions();
}