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:
parent
8a5d77f7f4
commit
8dd996053d
24 changed files with 2449 additions and 631 deletions
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue