diff --git a/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md b/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md
index f31e820..d977b5b 100644
--- a/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md
+++ b/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md
@@ -66,23 +66,28 @@ This plan is the **execution source of truth** for N.4. It is updated as tasks l
Status: **Living document — work in progress, started 2026-05-08.**
-**Progress (2026-05-08):** Week 1 ✅ COMPLETE. Tasks 1-10 shipped. Foundation types + WbMeshAdapter constructed against real WB pipeline (`OpenGLGraphicsDevice` + `DefaultDatReaderWriter` + `ObjectMeshManager`). `InstancedMeshRenderer.EnsureUploaded` routes through the adapter under `ACDREAM_USE_WB_FOUNDATION=1`; sentinel entry marks "this gfxObj lives in WB now" and the draw loop skips sentinel entries (Task 22's `WbDrawDispatcher` will draw them eventually). Conformance tests pin `GfxObjMesh.Build` + `SetupMesh.Flatten` behavior. Build green, 901 tests pass, 8 pre-existing failures only (unchanged from main).
+**Progress (2026-05-08):** Weeks 1 + 2 ✅ COMPLETE. WB pipeline running flag-on (constructed + ref-counted + per-frame Tick draining its queues). Three architectural adjustments documented: 1 (DefaultDatReaderWriter discovery, no bridge needed), 2 (renderer is tier-blind; routing belongs in spawn callbacks), 3 (FPS regression root-caused as dual-pipeline cost; Task 22's dispatcher will allow the legacy-renderer short-circuit). Build green, 912 tests pass, 8 pre-existing failures only.
-**Next: Task 11** — `LandblockSpawnAdapter` (streaming-loader hook for ref-count lifecycle).
+**Next: Task 16** (Week 3) — `AnimatedEntityState` type + per-instance customization path.
| Task | Status | Commit |
|---|---|---|
| 1 — WbFoundationFlag scaffold | ✅ | `81b5ed8` |
| 2 — AcSurfaceMetadata + Table | ✅ | `46deed6` |
| 3 — Mesh-extraction conformance | ✅ | `ed73fc5` |
-| 4 — Setup-flatten conformance | ✅ | `ed73fc5` (combined with #3) |
+| 4 — Setup-flatten conformance | ✅ | `ed73fc5` |
| 5 — WbMeshAdapter stub + IWbMeshAdapter | ✅ | (post-`ed73fc5`) |
-| 6 — WbDatReaderAdapter | ✅ OBSOLETED | `502c3a8` |
+| 6 — WbDatReaderAdapter | ✅ OBSOLETED (Adj. 1) | `502c3a8` |
| 7 — GameWindow wiring under flag | ✅ | `502c3a8` |
| 8 — CLAUDE.md pointer | ✅ | `506b86b` (preemptive) |
-| 9 — Real WB pipeline + InstancedMeshRenderer routing | ✅ | `4ad7a98` |
-| 10 — Week 1 wrap-up | ✅ | (this commit) |
-| 11–15 — Week 2: streaming integration | pending | — |
+| 9 — Real WB pipeline + InstancedMeshRenderer routing | ✅ partial / Adj. 2 reverted | `4ad7a98` + `4f318bc` |
+| 10 — Week 1 wrap-up | ✅ | `c49c6ed` |
+| 11 — LandblockSpawnAdapter | ✅ | `669768d` |
+| 12 — Wire into GpuWorldState | ✅ | `931a690` |
+| 13 — Memory budget verification | ✅ deferred to Task 22 (Adj. 3) | — |
+| 14 — Pending-spawn integration test | ✅ | `f4f0101` |
+| Tick — drain WB pipeline queues | ✅ added per Adj. 3 | `bf53cb4` |
+| 15 — Week 2 wrap-up | ✅ | (this commit) |
| 16–21 — Week 3: per-instance + animation | pending | — |
| 22–28 — Week 4: draw dispatcher + ship | pending | — |
@@ -878,6 +883,58 @@ construction in `WbMeshAdapter` (verified working under flag-off).
flag-off visually identical." Routing arrives in Week 2 (Task 11) at
the correct layer. Smoke verification is now: flag-on === flag-off.
+---
+
+### Adjustment 3 (2026-05-08): flag-on FPS regression — root-caused, deferred to Task 22
+
+**Discovered during Task 13 stress test** (radius 7, flag-on). Visible
+FPS drop + rising frame latency vs flag-off baseline. Initial guess
+was the staged-upload queue leaking memory; we shipped
+`WbMeshAdapter.Tick()` (commit `bf53cb4`) to drain
+`_meshManager.StagedMeshData` + `_graphicsDevice._glThreadQueue` per
+frame. Result: leak fixed, but **FPS unchanged**.
+
+**Real cause: dual-pipeline cost.** Flag-on runs both rendering
+pipelines in parallel without yet collecting any savings:
+
+1. **Background workers (4-wide).** `ObjectMeshManager` spins up
+ `MaxParallelLoads = 4` worker threads decoding GfxObj polygons,
+ building texture atlases, encoding batches. Contends with the
+ render thread for CPU cores.
+2. **Duplicate GL upload.** `Tick()` calls `UploadMeshData` per
+ staged mesh, creating VAO/VBO/IBO + atlas texture uploads. Real
+ per-call GL state churn on the render thread.
+3. **Duplicate I/O.** `DefaultDatReaderWriter` opens its own dat file
+ handles and rebuilds its own index cache (~50-100 MB) alongside
+ our existing `DatCollection`. Memory bandwidth + GC churn.
+4. **Legacy renderer keeps doing the same work.** Per Adjustment 2,
+ `InstancedMeshRenderer` is tier-blind — it still uploads VAO /
+ VBO / IBO for the same atlas-tier content WB is also building.
+ **We literally double the prep cost** for every atlas-tier GfxObj.
+
+The savings from WB's atlas batching only materialize when **Task 22
+(`WbDrawDispatcher`) lands and the legacy renderer can short-circuit
+its upload for atlas-tier content**. At that point WB owns atlas-tier
+draw and `InstancedMeshRenderer` skips its own upload + draw work
+for those entities. Until then, flag-on pays both costs.
+
+**Decision: do not fix now.** Plan Risk #5 explicitly anticipated this:
+
+> Performance regression during integration of week 1's "atlas for
+> static scenery, old path for everything else" mixed state.
+> Mitigation: keep the feature gate `ACDREAM_USE_WB_FOUNDATION=1`
+> during weeks 1-3; default-off until week 4 visual verification.
+
+Default-off (the user's daily experience) is byte-identical to
+pre-N.4. Flag-on is dev-only until Week 4. Task 22 must wire the
+legacy-renderer short-circuit for atlas-tier content as part of
+landing the dispatcher; the cost cannot be amortized any earlier
+without violating Adjustment 2's tier-blind-renderer principle.
+
+`Tick()` stays — it fixed a real memory leak and is required
+infrastructure for Task 22 anyway. We just paid for it without
+seeing FPS recovery yet.
+
### Task 6 (original — kept for history)
**Files:**
diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj
index a0c4b77..e93dab8 100644
--- a/src/AcDream.App/AcDream.App.csproj
+++ b/src/AcDream.App/AcDream.App.csproj
@@ -9,6 +9,9 @@
AcDream.App
true
+
+
+
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 902ca5b..61f4084 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -65,7 +65,7 @@ public sealed class GameWindow : IDisposable
// Phase A.1: streaming fields replacing the one-shot _entities list.
private AcDream.App.Streaming.LandblockStreamer? _streamer;
- private readonly AcDream.App.Streaming.GpuWorldState _worldState = new();
+ private AcDream.App.Streaming.GpuWorldState _worldState = new();
private AcDream.App.Streaming.StreamingController? _streamingController;
private int _streamingRadius = 2; // default 5×5
private uint? _lastLivePlayerLandblockId;
@@ -1438,6 +1438,17 @@ public sealed class GameWindow : IDisposable
Console.WriteLine("[N.4] WbFoundation flag is ENABLED — routing static content through ObjectMeshManager.");
}
+ // Phase N.4 Task 12: construct LandblockSpawnAdapter under the feature flag
+ // and rebuild _worldState so it threads the adapter in. _worldState starts
+ // as an unadorned GpuWorldState (field initializer); here we replace it with
+ // one that carries the adapter so AddLandblock/RemoveLandblock notify WB.
+ {
+ AcDream.App.Rendering.Wb.LandblockSpawnAdapter? wbSpawnAdapter = null;
+ if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled && _wbMeshAdapter is not null)
+ wbSpawnAdapter = new AcDream.App.Rendering.Wb.LandblockSpawnAdapter(_wbMeshAdapter);
+ _worldState = new AcDream.App.Streaming.GpuWorldState(wbSpawnAdapter);
+ }
+
_staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache, _wbMeshAdapter);
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
@@ -6065,6 +6076,12 @@ public sealed class GameWindow : IDisposable
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
+ // Phase N.4: drain WB pipeline queues (staged mesh data +
+ // GL thread queue). Must happen before any draw work so that
+ // resources uploaded this frame are available immediately.
+ // No-op when ACDREAM_USE_WB_FOUNDATION is off (_wbMeshAdapter is null).
+ _wbMeshAdapter?.Tick();
+
// Phase D.2a — begin ImGui frame. Paired with the Render() call
// after the scene draws (below). ImGuiController.Update()
// consumes buffered Silk.NET input events and calls ImGui.NewFrame.
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/src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs b/src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs
index 16eff10..421dac4 100644
--- a/src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs
+++ b/src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs
@@ -17,6 +17,16 @@ namespace AcDream.App.Rendering.Wb;
///
public static class WbFoundationFlag
{
- public static bool IsEnabled { get; } =
+ private static bool _isEnabled =
System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_FOUNDATION") == "1";
+
+ public static bool IsEnabled => _isEnabled;
+
+ ///
+ /// FOR TESTS ONLY. Forces to true so
+ /// integration tests can exercise the WB adapter path without having to
+ /// set the env var before static initialisation. Never call from
+ /// production code.
+ ///
+ internal static void ForTestsOnly_ForceEnable() => _isEnabled = true;
}
diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
index ec5f407..b8a3a23 100644
--- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
+++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
@@ -94,6 +94,38 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
_meshManager.DecrementRefCount(id);
}
+ ///
+ /// Per-frame drain of the WB pipeline's main-thread work queues. MUST be
+ /// called once per frame from the render thread. Without this, the staged
+ /// mesh data queue grows unbounded (memory leak) and queued GL actions
+ /// never execute.
+ ///
+ ///
+ /// Order matters: ProcessGLQueue runs first to apply any pending GL
+ /// state changes (e.g., texture uploads queued by background workers
+ /// during mesh prep). Then we drain staged mesh data, calling
+ /// UploadMeshData on each item to materialize the actual GL VAO /
+ /// VBO / IBO resources. After Tick, GetRenderData for any id
+ /// previously passed to IncrementRefCount may return non-null.
+ ///
+ ///
+ ///
+ /// No-op when the adapter is uninitialized (e.g., flag is off and the
+ /// adapter was constructed via CreateUninitialized).
+ ///
+ ///
+ public void Tick()
+ {
+ if (_isUninitialized) return;
+ if (_disposed) return;
+
+ _graphicsDevice!.ProcessGLQueue();
+ while (_meshManager!.StagedMeshData.TryDequeue(out var meshData))
+ {
+ _meshManager.UploadMeshData(meshData);
+ }
+ }
+
///
public void Dispose()
{
diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs
index f3448ef..bad81dd 100644
--- a/src/AcDream.App/Streaming/GpuWorldState.cs
+++ b/src/AcDream.App/Streaming/GpuWorldState.cs
@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
+using AcDream.App.Rendering.Wb;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
@@ -38,6 +39,13 @@ namespace AcDream.App.Streaming;
///
public sealed class GpuWorldState
{
+ private readonly LandblockSpawnAdapter? _wbSpawnAdapter;
+
+ public GpuWorldState(LandblockSpawnAdapter? wbSpawnAdapter = null)
+ {
+ _wbSpawnAdapter = wbSpawnAdapter;
+ }
+
private readonly Dictionary _loaded = new();
private readonly Dictionary _aabbs = new();
@@ -132,6 +140,8 @@ public sealed class GpuWorldState
}
_loaded[landblock.LandblockId] = landblock;
+ if (WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null)
+ _wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]);
RebuildFlatView();
}
@@ -181,6 +191,9 @@ public sealed class GpuWorldState
public void RemoveLandblock(uint landblockId)
{
+ if (WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null)
+ _wbSpawnAdapter.OnLandblockUnloaded(landblockId);
+
// Rescue persistent entities before removal. These get appended
// to the _persistentRescued list; the caller is responsible for
// re-injecting them (via AppendLiveEntity) into whatever landblock
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,
+ };
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/PendingSpawnIntegrationTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/PendingSpawnIntegrationTests.cs
new file mode 100644
index 0000000..a02f080
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Rendering/Wb/PendingSpawnIntegrationTests.cs
@@ -0,0 +1,149 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using AcDream.App.Rendering.Wb;
+using AcDream.App.Streaming;
+using AcDream.Core.World;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+///
+/// Integration: verifies the pending-spawn list mechanism keeps working
+/// after Task 12 wired LandblockSpawnAdapter into GpuWorldState. Server-
+/// spawned entities (ServerGuid != 0) park in pending → drain on
+/// AddLandblock → end up in the flat view, but they are NEVER registered
+/// with the WB adapter (they're per-instance tier).
+///
+/// The adapter SHOULD see atlas-tier entities (ServerGuid == 0) that
+/// arrived in the AddLandblock's payload directly.
+///
+public sealed class PendingSpawnIntegrationTests
+{
+ ///
+ /// Force-enable WbFoundationFlag for this test class.
+ /// GpuWorldState gates its adapter calls on this static-cached flag;
+ /// calling the internal test hook lets us exercise the full integration
+ /// path without needing the env var set before process startup.
+ ///
+ static PendingSpawnIntegrationTests()
+ {
+ WbFoundationFlag.ForTestsOnly_ForceEnable();
+ }
+
+ [Fact]
+ public void LiveEntity_ParkedBeforeLandblock_DrainsButIsNotRegisteredWithAdapter()
+ {
+ var captured = new CapturingAdapterMock();
+ var spawnAdapter = new LandblockSpawnAdapter(captured);
+ var state = new GpuWorldState(spawnAdapter);
+
+ // Park a live (server-spawned) entity for landblock 0x1234FFFF BEFORE
+ // the landblock streams in. ServerGuid != 0 makes this per-instance-tier.
+ var liveEntity = MakeServerSpawned(
+ id: 1, serverGuid: 0xCAFE0001u, gfxObjId: 0x01000099u);
+ // AppendLiveEntity takes the raw cell-form id; it canonicalises internally.
+ state.AppendLiveEntity(0x12340011u, liveEntity);
+
+ Assert.Equal(1, state.PendingLiveEntityCount);
+ Assert.Empty(captured.IncrementCalls); // not registered yet — landblock not loaded
+
+ // Now landblock arrives with ONE atlas-tier entity that brings its own
+ // GfxObj, plus the pending live entity drains into it.
+ var atlasEntity = MakeAtlas(id: 2, gfxObjId: 0x01000010u);
+ var lb = new LoadedLandblock(
+ LandblockId: 0x1234FFFFu,
+ Heightmap: new DatReaderWriter.DBObjs.LandBlock(),
+ Entities: new[] { atlasEntity });
+ state.AddLandblock(lb);
+
+ // Pending drained.
+ Assert.Equal(0, state.PendingLiveEntityCount);
+
+ // Flat view contains both: the atlas one from the load + the drained pending.
+ var allIds = state.Entities.Select(e => e.Id).ToHashSet();
+ Assert.Contains(1u, allIds); // pending entity
+ Assert.Contains(2u, allIds); // landblock entity
+
+ // Adapter only saw the atlas-tier GfxObj. The pending server-spawned
+ // entity's GfxObj is NOT registered (filtered by ServerGuid != 0 in
+ // LandblockSpawnAdapter).
+ Assert.Single(captured.IncrementCalls);
+ Assert.Contains(0x01000010ul, captured.IncrementCalls);
+ Assert.DoesNotContain(0x01000099ul, captured.IncrementCalls);
+ }
+
+ [Fact]
+ public void LiveEntity_AfterLandblock_RegistersImmediatelyWithoutAdapterCall()
+ {
+ // When a CreateObject arrives for an already-loaded landblock, it goes
+ // straight into the flat view (not through pending). Adapter is NOT
+ // re-invoked because the landblock load already happened.
+ var captured = new CapturingAdapterMock();
+ var spawnAdapter = new LandblockSpawnAdapter(captured);
+ var state = new GpuWorldState(spawnAdapter);
+
+ var atlasEntity = MakeAtlas(id: 1, gfxObjId: 0x01000010u);
+ var lb = new LoadedLandblock(
+ LandblockId: 0x1234FFFFu,
+ Heightmap: new DatReaderWriter.DBObjs.LandBlock(),
+ Entities: new[] { atlasEntity });
+ state.AddLandblock(lb);
+
+ Assert.Single(captured.IncrementCalls); // atlas registered
+
+ // Now a live entity arrives — landblock is already loaded.
+ var liveEntity = MakeServerSpawned(id: 2, serverGuid: 0xCAFE0001u, gfxObjId: 0x01000099u);
+ state.AppendLiveEntity(0x12340022u, liveEntity);
+
+ // Adapter not invoked again — AppendLiveEntity doesn't drive ref counts.
+ Assert.Single(captured.IncrementCalls);
+ Assert.Equal(0, state.PendingLiveEntityCount);
+ }
+
+ [Fact]
+ public void LandblockUnload_ReleasesAtlasIds_PendingDoesNotRegress()
+ {
+ var captured = new CapturingAdapterMock();
+ var spawnAdapter = new LandblockSpawnAdapter(captured);
+ var state = new GpuWorldState(spawnAdapter);
+
+ var atlasEntity = MakeAtlas(id: 1, gfxObjId: 0x01000010u);
+ var lb = new LoadedLandblock(
+ LandblockId: 0x1234FFFFu,
+ Heightmap: new DatReaderWriter.DBObjs.LandBlock(),
+ Entities: new[] { atlasEntity });
+ state.AddLandblock(lb);
+ state.RemoveLandblock(0x1234FFFFu);
+
+ Assert.Equal(
+ captured.IncrementCalls.OrderBy(x => x),
+ captured.DecrementCalls.OrderBy(x => x));
+ }
+
+ // ── 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 WorldEntity MakeAtlas(uint id, uint gfxObjId)
+ => MakeEntity(id, serverGuid: 0u, gfxObjId);
+
+ private static WorldEntity MakeServerSpawned(uint id, uint serverGuid, uint gfxObjId)
+ => MakeEntity(id, serverGuid, gfxObjId);
+
+ private static WorldEntity MakeEntity(uint id, uint serverGuid, uint gfxObjId)
+ => new WorldEntity
+ {
+ Id = id,
+ ServerGuid = serverGuid,
+ SourceGfxObjOrSetupId = gfxObjId,
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
+ MeshRefs = new[] { new MeshRef(gfxObjId, Matrix4x4.Identity) },
+ };
+}
diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs
index 1aaa33d..5758026 100644
--- a/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs
+++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs
@@ -46,4 +46,20 @@ public sealed class WbMeshAdapterTests
var adapter = WbMeshAdapter.CreateUninitialized();
Assert.Null(adapter.GetRenderData(0x01000001ul));
}
+
+ [Fact]
+ public void Tick_OnUninitializedAdapter_DoesNotThrow()
+ {
+ var adapter = WbMeshAdapter.CreateUninitialized();
+ adapter.Tick(); // no-op, no throw
+ adapter.Tick(); // idempotent
+ }
+
+ [Fact]
+ public void Tick_AfterDispose_DoesNotThrow()
+ {
+ var adapter = WbMeshAdapter.CreateUninitialized();
+ adapter.Dispose();
+ adapter.Tick(); // no-op, no throw
+ }
}