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/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/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) },
+ };
+}