diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs
index a256d26..966bf9c 100644
--- a/src/AcDream.App/Streaming/GpuWorldState.cs
+++ b/src/AcDream.App/Streaming/GpuWorldState.cs
@@ -339,6 +339,51 @@ public sealed class GpuWorldState
bucket.Add(entity);
}
+ ///
+ /// Drop all entities from a landblock without removing the terrain. Used
+ /// by two-tier streaming when a landblock crosses Near→Far hysteresis.
+ /// Per Phase A.5 spec §4.4.
+ ///
+ public void RemoveEntitiesFromLandblock(uint landblockId)
+ {
+ if (!_loaded.TryGetValue(landblockId, out var lb)) return;
+ if (_wbSpawnAdapter is not null)
+ _wbSpawnAdapter.OnLandblockUnloaded(landblockId);
+ _loaded[landblockId] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, System.Array.Empty());
+ _pendingByLandblock.Remove(landblockId);
+ RebuildFlatView();
+ }
+
+ ///
+ /// Merge entities into an existing-loaded landblock. Used by two-tier
+ /// streaming for the Far→Near promotion case (terrain already loaded;
+ /// entity layer streaming in). Falls back to the pending bucket if the
+ /// landblock isn't loaded yet (handles the rare "promote arrives before
+ /// far load completes" race).
+ /// Per Phase A.5 spec §4.4.
+ ///
+ public void AddEntitiesToExistingLandblock(uint landblockId, System.Collections.Generic.IReadOnlyList entities)
+ {
+ if (!_loaded.TryGetValue(landblockId, out var lb))
+ {
+ // Park as pending — same pattern as AppendLiveEntity for not-yet-loaded LBs.
+ if (!_pendingByLandblock.TryGetValue(landblockId, out var bucket))
+ {
+ bucket = new List();
+ _pendingByLandblock[landblockId] = bucket;
+ }
+ bucket.AddRange(entities);
+ return;
+ }
+ var merged = new List(lb.Entities.Count + entities.Count);
+ merged.AddRange(lb.Entities);
+ merged.AddRange(entities);
+ _loaded[landblockId] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged);
+ if (_wbSpawnAdapter is not null)
+ _wbSpawnAdapter.OnLandblockLoaded(_loaded[landblockId]);
+ RebuildFlatView();
+ }
+
private void RebuildFlatView()
{
_flatEntities = _loaded.Values.SelectMany(lb => lb.Entities).ToArray();
diff --git a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs
new file mode 100644
index 0000000..11ab0c5
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs
@@ -0,0 +1,76 @@
+using System.Linq;
+using AcDream.App.Streaming;
+using AcDream.Core.World;
+using DatReaderWriter.DBObjs;
+using Xunit;
+
+namespace AcDream.Core.Tests.Streaming;
+
+public class GpuWorldStateTwoTierTests
+{
+ private static LoadedLandblock MakeStubLandblock(uint canonicalId, params WorldEntity[] entities)
+ => new(canonicalId, new LandBlock(), entities);
+
+ private static WorldEntity MakeStubEntity(uint id)
+ => new()
+ {
+ Id = id,
+ SourceGfxObjOrSetupId = 0x01000001u,
+ Position = System.Numerics.Vector3.Zero,
+ Rotation = System.Numerics.Quaternion.Identity,
+ MeshRefs = System.Array.Empty(),
+ };
+
+ [Fact]
+ public void RemoveEntitiesFromLandblock_KeepsLandblockButDropsEntities()
+ {
+ var state = new GpuWorldState();
+ var lb = MakeStubLandblock(0xAAAAFFFFu,
+ MakeStubEntity(1),
+ MakeStubEntity(2));
+ state.AddLandblock(lb);
+ Assert.Equal(2, state.Entities.Count);
+
+ state.RemoveEntitiesFromLandblock(0xAAAAFFFFu);
+
+ Assert.Empty(state.Entities);
+ Assert.True(state.IsLoaded(0xAAAAFFFFu)); // landblock still resident
+ }
+
+ [Fact]
+ public void AddEntitiesToExistingLandblock_MergesIntoExistingRecord()
+ {
+ var state = new GpuWorldState();
+ var lb = MakeStubLandblock(0xAAAAFFFFu, MakeStubEntity(1));
+ state.AddLandblock(lb);
+
+ state.AddEntitiesToExistingLandblock(0xAAAAFFFFu, new[]
+ {
+ MakeStubEntity(2),
+ MakeStubEntity(3),
+ });
+
+ Assert.Equal(3, state.Entities.Count);
+ }
+
+ [Fact]
+ public void AddEntitiesToExistingLandblock_LandblockNotYetLoaded_ParksInPending()
+ {
+ var state = new GpuWorldState();
+
+ // Landblock not loaded yet.
+ state.AddEntitiesToExistingLandblock(0xAAAAFFFFu, new[]
+ {
+ MakeStubEntity(1),
+ MakeStubEntity(2),
+ });
+
+ // Nothing in the flat view yet.
+ Assert.Empty(state.Entities);
+ Assert.Equal(2, state.PendingLiveEntityCount);
+
+ // Now load the landblock — pending entities should merge in.
+ state.AddLandblock(MakeStubLandblock(0xAAAAFFFFu));
+ Assert.Equal(2, state.Entities.Count);
+ }
+}