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()
{