diff --git a/src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs b/src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs
new file mode 100644
index 0000000..ec16b7c
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs
@@ -0,0 +1,94 @@
+using System.Collections.Generic;
+using AcDream.Core.World;
+
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Bridges landblock streaming events to 's
+/// reference-count lifecycle. Tier-aware by design: only atlas-tier
+/// entities (procedural / dat-hydrated, identified by
+/// ServerGuid == 0) drive ref counts. Server-spawned entities
+/// (per-instance tier) are skipped — those go through
+/// EntitySpawnAdapter + TextureCache.GetOrUploadWithPaletteOverride
+/// (see Phase N.4 spec, Architecture → Two-tier rendering split).
+///
+///
+/// On load: walks the landblock's atlas-tier entities, collects unique
+/// GfxObj ids from their MeshRefs, calls
+/// IncrementRefCount per id. Snapshots the id-set per landblock so
+/// unload can match the load 1:1.
+///
+///
+///
+/// On unload: looks up the snapshot, calls DecrementRefCount per id,
+/// drops the snapshot. Unknown / never-loaded landblocks no-op.
+///
+///
+///
+/// Idempotency: a duplicate load for the same landblock is a no-op on
+/// ref-counting (the snapshot is already present). Defensive guard against
+/// streaming-controller bugs.
+///
+///
+///
+/// Thread safety: the underlying implementation
+/// uses ConcurrentDictionary, so the streaming worker thread may call
+/// this safely. The internal snapshot dictionary is NOT thread-safe and must
+/// be called from a single streaming thread (the same thread that fires
+/// AddLandblock / RemoveLandblock events).
+///
+///
+public sealed class LandblockSpawnAdapter
+{
+ private readonly IWbMeshAdapter _adapter;
+
+ // Maps landblock id → unique GfxObj ids registered for that landblock.
+ // Written on load, read+cleared on unload. Single-threaded (streaming worker).
+ private readonly Dictionary> _idsByLandblock = new();
+
+ public LandblockSpawnAdapter(IWbMeshAdapter adapter)
+ {
+ System.ArgumentNullException.ThrowIfNull(adapter);
+ _adapter = adapter;
+ }
+
+ ///
+ /// Called when a landblock finishes streaming in.
+ /// Registers a ref-count increment with WB for each unique atlas-tier
+ /// GfxObj id in the landblock. Duplicate loads for the same landblock id
+ /// are silently ignored.
+ ///
+ public void OnLandblockLoaded(LoadedLandblock landblock)
+ {
+ System.ArgumentNullException.ThrowIfNull(landblock);
+
+ // Idempotency: already-loaded landblock is a no-op.
+ if (_idsByLandblock.ContainsKey(landblock.LandblockId)) return;
+
+ var unique = new HashSet();
+ foreach (var entity in landblock.Entities)
+ {
+ // Atlas-tier filter: server-spawned entities (ServerGuid != 0)
+ // belong to the per-instance path and are NOT registered with WB.
+ if (entity.ServerGuid != 0) continue;
+
+ foreach (var meshRef in entity.MeshRefs)
+ unique.Add((ulong)meshRef.GfxObjId);
+ }
+
+ _idsByLandblock[landblock.LandblockId] = unique;
+ foreach (var id in unique) _adapter.IncrementRefCount(id);
+ }
+
+ ///
+ /// Called when a landblock is unloaded from the streaming window.
+ /// Releases the ref-count for every GfxObj id that was registered on load.
+ /// Unknown landblock ids (never loaded, or already unloaded) are no-ops.
+ ///
+ public void OnLandblockUnloaded(uint landblockId)
+ {
+ if (!_idsByLandblock.TryGetValue(landblockId, out var unique)) return;
+ foreach (var id in unique) _adapter.DecrementRefCount(id);
+ _idsByLandblock.Remove(landblockId);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/LandblockSpawnAdapterTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/LandblockSpawnAdapterTests.cs
new file mode 100644
index 0000000..85af235
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Rendering/Wb/LandblockSpawnAdapterTests.cs
@@ -0,0 +1,158 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.World;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+public sealed class LandblockSpawnAdapterTests
+{
+ [Fact]
+ public void OnLandblockLoaded_RegistersIncrementForEachUniqueAtlasGfxObj()
+ {
+ var captured = new CapturingAdapterMock();
+ var adapter = new LandblockSpawnAdapter(captured);
+
+ // Two procedural (ServerGuid=0) entities with different GfxObj ids.
+ var lb = MakeLandblock(landblockId: 0x12340000u, entities: new[]
+ {
+ MakeAtlasEntity(id: 1, gfxObjIds: new[] { 0x01000010u, 0x01000020u }),
+ MakeAtlasEntity(id: 2, gfxObjIds: new[] { 0x01000030u }),
+ });
+
+ adapter.OnLandblockLoaded(lb);
+
+ // Three unique ids registered.
+ Assert.Equal(3, captured.IncrementCalls.Count);
+ Assert.Contains(0x01000010ul, captured.IncrementCalls);
+ Assert.Contains(0x01000020ul, captured.IncrementCalls);
+ Assert.Contains(0x01000030ul, captured.IncrementCalls);
+ }
+
+ [Fact]
+ public void OnLandblockLoaded_DedupsSharedIdsAcrossEntities()
+ {
+ var captured = new CapturingAdapterMock();
+ var adapter = new LandblockSpawnAdapter(captured);
+
+ var lb = MakeLandblock(landblockId: 0x12340000u, entities: new[]
+ {
+ MakeAtlasEntity(id: 1, gfxObjIds: new[] { 0x01000010u, 0x01000020u }),
+ MakeAtlasEntity(id: 2, gfxObjIds: new[] { 0x01000010u, 0x01000020u }),
+ });
+
+ adapter.OnLandblockLoaded(lb);
+
+ // Two unique ids despite two entities sharing both.
+ Assert.Equal(2, captured.IncrementCalls.Count);
+ }
+
+ [Fact]
+ public void OnLandblockLoaded_SkipsServerSpawnedEntities()
+ {
+ var captured = new CapturingAdapterMock();
+ var adapter = new LandblockSpawnAdapter(captured);
+
+ var lb = MakeLandblock(landblockId: 0x12340000u, entities: new[]
+ {
+ MakeAtlasEntity(id: 1, gfxObjIds: new[] { 0x01000010u }),
+ // ServerGuid != 0 → per-instance tier → must NOT register.
+ MakePerInstanceEntity(id: 2, serverGuid: 0xCAFE0001u, gfxObjIds: new[] { 0x01000020u }),
+ });
+
+ adapter.OnLandblockLoaded(lb);
+
+ // Only the atlas-tier entity's GfxObj is registered.
+ Assert.Single(captured.IncrementCalls);
+ Assert.Contains(0x01000010ul, captured.IncrementCalls);
+ Assert.DoesNotContain(0x01000020ul, captured.IncrementCalls);
+ }
+
+ [Fact]
+ public void OnLandblockUnloaded_RegistersMatchingDecrements()
+ {
+ var captured = new CapturingAdapterMock();
+ var adapter = new LandblockSpawnAdapter(captured);
+
+ var lb = MakeLandblock(landblockId: 0x12340000u, entities: new[]
+ {
+ MakeAtlasEntity(id: 1, gfxObjIds: new[] { 0x01000010u, 0x01000020u }),
+ });
+
+ adapter.OnLandblockLoaded(lb);
+ adapter.OnLandblockUnloaded(0x12340000u);
+
+ Assert.Equal(captured.IncrementCalls.OrderBy(x => x), captured.DecrementCalls.OrderBy(x => x));
+ }
+
+ [Fact]
+ public void OnLandblockUnloaded_UnknownLandblock_NoOps()
+ {
+ var captured = new CapturingAdapterMock();
+ var adapter = new LandblockSpawnAdapter(captured);
+
+ adapter.OnLandblockUnloaded(0xDEADBEEFu);
+
+ Assert.Empty(captured.DecrementCalls);
+ }
+
+ [Fact]
+ public void OnLandblockLoaded_SameLandblockTwice_DedupesAtTheLandblockLevel()
+ {
+ // If a landblock load fires twice (e.g. a streaming-controller bug),
+ // we should not double-register. Second load is treated as a no-op
+ // for ref-counting purposes.
+ var captured = new CapturingAdapterMock();
+ var adapter = new LandblockSpawnAdapter(captured);
+
+ var lb = MakeLandblock(landblockId: 0x12340000u, entities: new[]
+ {
+ MakeAtlasEntity(id: 1, gfxObjIds: new[] { 0x01000010u }),
+ });
+
+ adapter.OnLandblockLoaded(lb);
+ adapter.OnLandblockLoaded(lb);
+
+ // One unique id, one increment — not two.
+ Assert.Single(captured.IncrementCalls);
+ }
+
+ // ── Test helpers ──────────────────────────────────────────────────────
+
+ private sealed class CapturingAdapterMock : IWbMeshAdapter
+ {
+ public List IncrementCalls { get; } = new();
+ public List DecrementCalls { get; } = new();
+ public void IncrementRefCount(ulong id) => IncrementCalls.Add(id);
+ public void DecrementRefCount(ulong id) => DecrementCalls.Add(id);
+ }
+
+ private static LoadedLandblock MakeLandblock(uint landblockId, WorldEntity[] entities)
+ => new LoadedLandblock(
+ LandblockId: landblockId,
+ Heightmap: new DatReaderWriter.DBObjs.LandBlock(), // empty default
+ Entities: entities);
+
+ private static WorldEntity MakeAtlasEntity(uint id, uint[] gfxObjIds)
+ => MakeEntity(id, serverGuid: 0u, gfxObjIds);
+
+ private static WorldEntity MakePerInstanceEntity(uint id, uint serverGuid, uint[] gfxObjIds)
+ => MakeEntity(id, serverGuid, gfxObjIds);
+
+ private static WorldEntity MakeEntity(uint id, uint serverGuid, uint[] gfxObjIds)
+ {
+ var meshRefs = gfxObjIds
+ .Select(g => new MeshRef(g, Matrix4x4.Identity))
+ .ToList();
+ return new WorldEntity
+ {
+ Id = id,
+ ServerGuid = serverGuid,
+ SourceGfxObjOrSetupId = gfxObjIds.FirstOrDefault(),
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
+ MeshRefs = meshRefs,
+ };
+ }
+}