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