diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index f5fa2307..5f67b527 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -315,6 +315,26 @@ 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 + // (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 + // streaming churn / re-teleport before it finishes loading), the + // pending entry was silently dropped here — violating the + // "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)) + { + foreach (var entity in pendingForLb) + { + if (entity.ServerGuid != 0 && _persistentGuids.Contains(entity.ServerGuid)) + _persistentRescued.Add(entity); + } + } + _pendingByLandblock.Remove(landblockId); _aabbs.Remove(landblockId); diff --git a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs index 814d2d51..0d0db085 100644 --- a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs @@ -137,4 +137,51 @@ public class GpuWorldStateTests state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu)); Assert.True(state.IsLoaded(0xA9B4FFFFu)); } + + private static WorldEntity MakeServerEntity(uint id, uint serverGuid) + => new() + { + Id = id, + ServerGuid = serverGuid, + SourceGfxObjOrSetupId = 0x01000001u, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = Array.Empty(), + }; + + [Fact] + public void RemoveLandblock_RescuesPersistentEntity_FromPendingBucket() + { + // #138 (secondary): the player is re-injected via AppendLiveEntity 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 — + // otherwise the avatar vanishes after a couple round-trips. + var state = new GpuWorldState(); + const uint playerGuid = 0x50000001u; + state.MarkPersistent(playerGuid); + + var player = MakeServerEntity(id: 1, serverGuid: playerGuid); + state.AppendLiveEntity(0xA9B40011u, player); // landblock not loaded → pending + Assert.Equal(1, state.PendingLiveEntityCount); + + state.RemoveLandblock(0xA9B4FFFFu); // unloaded while still pending + + Assert.Contains(player, state.DrainRescued()); // rescued, not dropped + } + + [Fact] + public void RemoveLandblock_DoesNotRescue_NonPersistentPendingEntity() + { + // 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 + // _lastSpawnByGuid, not via the rescue path). + var state = new GpuWorldState(); + var door = MakeServerEntity(id: 2, serverGuid: 0x7A9B4001u); // not marked persistent + state.AppendLiveEntity(0xA9B40011u, door); + + state.RemoveLandblock(0xA9B4FFFFu); + + Assert.Empty(state.DrainRescued()); + } }