diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index 6217c814..2e2c142d 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -255,7 +255,43 @@ public sealed class GpuWorldState { if (entity.ServerGuid == 0) return; - // Remove from current landblock (find it by scanning) + 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); + } + + /// + /// Remove (by reference) from whichever + /// or bucket it + /// currently occupies. At most one bucket holds a given entity, so this + /// stops after the first hit. Called by before + /// re-appending, so a stranded pending entity can be promoted. + /// + private void RemoveEntityFromAllBuckets(WorldEntity entity) + { foreach (var kvp in _loaded) { var entities = kvp.Value.Entities; @@ -263,23 +299,21 @@ public sealed class GpuWorldState { if (ReferenceEquals(entities[i], entity)) { - if (kvp.Key == newCanonicalLb) return; // already in the right place - - // Remove from old var newList = new List(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); - - // Add to new (via AppendLiveEntity which handles pending) - AppendLiveEntity(newCanonicalLb, entity); + kvp.Value.LandblockId, kvp.Value.Heightmap, newList); return; } } } + + foreach (var kvp in _pendingByLandblock) + { + if (kvp.Value.Remove(entity)) + return; + } } public void RemoveLandblock(uint landblockId) diff --git a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs index 0d0db085..dfe186e1 100644 --- a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs @@ -92,6 +92,47 @@ public class GpuWorldStateTests Assert.Equal(1, state.PendingLiveEntityCount); // 0xAAAAFFFF entry still parked } + [Fact] + public void RelocateEntity_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 + // 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)" + // with no later DRAWSET PRESENT, even after the landblock loaded (its + // sibling NPCs re-hydrated into it fine — the player was excluded). + var state = new GpuWorldState(); + + var player = new WorldEntity + { + Id = 1, + ServerGuid = 0x5000000Au, // server-spawned + persistent + SourceGfxObjOrSetupId = 0x01000001u, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + state.MarkPersistent(0x5000000Au); + + // Spawned before its landblock streamed in → parked in pending, hidden. + state.AppendLiveEntity(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 + // 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); + + Assert.Single(state.Entities); // now drawn + Assert.Equal(0, state.PendingLiveEntityCount); // no longer stranded + } + [Fact] public void RemoveLandblock_DropsPendingForThatLandblock() {