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

@ -29,7 +29,7 @@ public class GpuWorldStateTests
};
[Fact]
public void AppendLiveEntity_LandblockAlreadyLoaded_AppendsImmediately()
public void PlaceLiveEntityProjection_LandblockAlreadyLoaded_AppendsImmediately()
{
var state = new GpuWorldState();
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
@ -37,20 +37,20 @@ public class GpuWorldStateTests
// Server sends a spawn at landblock 0xA9B40011 — same landblock,
// cell index 0x0011. Canonicalize drops the cell index, lookup
// succeeds, entity lands in the loaded landblock immediately.
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(42));
state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(42));
Assert.Single(state.Entities);
Assert.Equal(0u, (uint)state.PendingLiveEntityCount);
}
[Fact]
public void AppendLiveEntity_LandblockNotLoaded_ParksInPending()
public void PlaceLiveEntityProjection_LandblockNotLoaded_ParksInPending()
{
var state = new GpuWorldState();
// No landblock loaded — the spawn must survive instead of being
// silently dropped (the bug from Phase A.1's first live run).
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(42));
state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(42));
Assert.Empty(state.Entities); // not visible yet
Assert.Equal(1, state.PendingLiveEntityCount);
@ -62,9 +62,9 @@ public class GpuWorldStateTests
var state = new GpuWorldState();
// Three spawns arrive before the landblock loads.
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1));
state.AppendLiveEntity(0xA9B40022u, MakeStubEntity(2)); // same landblock, different cell
state.AppendLiveEntity(0xA9B40033u, MakeStubEntity(3));
state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(1));
state.PlaceLiveEntityProjection(0xA9B40022u, MakeStubEntity(2)); // same landblock, different cell
state.PlaceLiveEntityProjection(0xA9B40033u, MakeStubEntity(3));
Assert.Equal(3, state.PendingLiveEntityCount);
Assert.Empty(state.Entities);
@ -82,8 +82,8 @@ public class GpuWorldStateTests
{
var state = new GpuWorldState();
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1)); // pending for 0xA9B4FFFF
state.AppendLiveEntity(0xAAAA0022u, MakeStubEntity(2)); // pending for 0xAAAAFFFF
state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(1)); // pending for 0xA9B4FFFF
state.PlaceLiveEntityProjection(0xAAAA0022u, MakeStubEntity(2)); // pending for 0xAAAAFFFF
// Loading 0xA9B4FFFF only drains its own bucket.
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
@ -93,12 +93,12 @@ public class GpuWorldStateTests
}
[Fact]
public void RelocateEntity_StrandedInPending_MovesToLoadedTarget()
public void RebucketLiveEntity_StrandedInPending_MovesToLoadedTarget()
{
// Regression: the cold-spawn / run-out "invisible player" bug
// (2026-07-03). A persistent (server-spawned) entity that spawned into a
// not-yet-loaded landblock is parked in the pending bucket. The per-frame
// RelocateEntity is supposed to keep it homed to its current landblock so
// RebucketLiveEntity keeps it homed to its current landblock so
// it draws — but the old implementation scanned ONLY _loaded, so it
// silently no-op'd on a pending entity and the player stayed hidden
// forever. Confirmed live: "[ent] APPEND guid=0x5000000A -> PENDING(hidden)"
@ -118,16 +118,16 @@ public class GpuWorldStateTests
state.MarkPersistent(0x5000000Au);
// Spawned before its landblock streamed in → parked in pending, hidden.
state.AppendLiveEntity(0xADAF0011u, player);
state.PlaceLiveEntityProjection(0xADAF0011u, player);
Assert.Empty(state.Entities);
Assert.Equal(1, state.PendingLiveEntityCount);
// The player's current landblock IS loaded now (the live log shows the
// sibling NPCs re-hydrated into it). RelocateEntity — called every frame
// sibling NPCs re-hydrated into it). RebucketLiveEntity — called every frame
// to keep the player homed to its current landblock — must promote the
// stranded pending entity into the loaded bucket so it draws.
state.AddLandblock(MakeStubLandblock(0xBBBBFFFFu));
state.RelocateEntity(player, 0xBBBB0011u);
state.RebucketLiveEntity(player, 0xBBBB0011u);
Assert.Single(state.Entities); // now drawn
Assert.Equal(0, state.PendingLiveEntityCount); // no longer stranded
@ -139,8 +139,8 @@ public class GpuWorldStateTests
var state = new GpuWorldState();
// Spawns for landblock 0xA9B4FFFF arrive while it's pending.
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1));
state.AppendLiveEntity(0xA9B40022u, MakeStubEntity(2));
state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(1));
state.PlaceLiveEntityProjection(0xA9B40022u, MakeStubEntity(2));
Assert.Equal(2, state.PendingLiveEntityCount);
// Player moves away — the streamer says "this landblock is no
@ -159,7 +159,7 @@ public class GpuWorldStateTests
var state = new GpuWorldState();
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1));
state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(1));
Assert.Single(state.Entities);
state.RemoveLandblock(0xA9B4FFFFu);
@ -172,7 +172,7 @@ public class GpuWorldStateTests
{
var state = new GpuWorldState();
state.AppendLiveEntity(0xA9B40011u, MakeStubEntity(1));
state.PlaceLiveEntityProjection(0xA9B40011u, MakeStubEntity(1));
Assert.False(state.IsLoaded(0xA9B4FFFFu)); // pending doesn't count
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
@ -193,7 +193,7 @@ public class GpuWorldStateTests
[Fact]
public void RemoveLandblock_RescuesPersistentEntity_FromPendingBucket()
{
// #138 (secondary): the player is re-injected via AppendLiveEntity every
// #138 (secondary): the player is re-injected via rebucketing every
// frame; right after a teleport its landblock hasn't streamed in, so it
// lands in the pending bucket. If that landblock is then unloaded before
// it finishes loading, the persistent entity must still be rescued —
@ -203,7 +203,7 @@ public class GpuWorldStateTests
state.MarkPersistent(playerGuid);
var player = MakeServerEntity(id: 1, serverGuid: playerGuid);
state.AppendLiveEntity(0xA9B40011u, player); // landblock not loaded → pending
state.PlaceLiveEntityProjection(0xA9B40011u, player); // landblock not loaded → pending
Assert.Equal(1, state.PendingLiveEntityCount);
state.RemoveLandblock(0xA9B4FFFFu); // unloaded while still pending
@ -212,17 +212,36 @@ public class GpuWorldStateTests
}
[Fact]
public void RemoveLandblock_DoesNotRescue_NonPersistentPendingEntity()
public void RemoveLandblock_RetainsNonPersistentLiveProjectionForSameIdentityReload()
{
// A normal server object (door) in the pending bucket is NOT persistent;
// it must still be dropped (and is recoverable via #138 re-hydrate from
// the retained inbound spawn snapshot, not via the rescue path).
// A normal server object (door) is not rescued with the moving player,
// but its exact projection remains pending for this landblock. Reload
// must merge that identity instead of reconstructing from CreateObject.
var state = new GpuWorldState();
var door = MakeServerEntity(id: 2, serverGuid: 0x7A9B4001u); // not marked persistent
state.AppendLiveEntity(0xA9B40011u, door);
state.PlaceLiveEntityProjection(0xA9B40011u, door);
state.RemoveLandblock(0xA9B4FFFFu);
Assert.Empty(state.DrainRescued());
Assert.Equal(1, state.PendingLiveEntityCount);
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
Assert.Same(door, Assert.Single(state.Entities));
}
[Fact]
public void RemoveLandblock_LoadedLiveProjectionMovesToPendingAndReloadsSameIdentity()
{
var state = new GpuWorldState();
var door = MakeServerEntity(id: 3, serverGuid: 0x7A9B4002u);
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
state.PlaceLiveEntityProjection(0xA9B40011u, door);
state.RemoveLandblock(0xA9B4FFFFu);
Assert.Empty(state.Entities);
Assert.Equal(1, state.PendingLiveEntityCount);
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu));
Assert.Same(door, Assert.Single(state.Entities));
}
}