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 + } }