From 669768d9dacd1f3acbd258ed02b31c46537d24c1 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 13:53:38 +0200 Subject: [PATCH 1/5] phase(N.4) Task 11: LandblockSpawnAdapter (atlas-tier ref-count bridge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridges LoadedLandblock load/unload events to IWbMeshAdapter ref counts. Tier-aware by design: walks WorldEntity collection filtered by ServerGuid == 0 (procedural / atlas-tier only). Server-spawned entities are skipped — those will go through EntitySpawnAdapter (Task 17). Per-landblock id-set snapshot ensures unload pairs 1:1 with load even when underlying data is released. Duplicate-load idempotency for defensive resilience to streaming-controller bugs. Six tests: registers per unique id; dedups across entities; skips server-spawned; unload matches load; unknown landblock no-ops; duplicate load no-ops. Wiring into GpuWorldState lands in Task 12. Co-Authored-By: Claude Opus 4.6 --- .../Rendering/Wb/LandblockSpawnAdapter.cs | 94 +++++++++++ .../Wb/LandblockSpawnAdapterTests.cs | 158 ++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs create mode 100644 tests/AcDream.Core.Tests/Rendering/Wb/LandblockSpawnAdapterTests.cs 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, + }; + } +} From 931a690c4c0769f96613517bf9e038221b52c05a Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 13:56:40 +0200 Subject: [PATCH 2/5] phase(N.4) Task 12: wire LandblockSpawnAdapter into GpuWorldState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GpuWorldState's constructor accepts an optional LandblockSpawnAdapter. AddLandblock calls OnLandblockLoaded with the post-merge loaded record; RemoveLandblock calls OnLandblockUnloaded with the landblock id at the top of the method (before state mutation). Both calls are gated behind WbFoundationFlag.IsEnabled — no behavioral change with flag off (existing tests pass without modification). GameWindow constructs the adapter under the flag and threads it into GpuWorldState. With flag on, atlas-tier scenery now drives WB ref counts; per-instance entities (ServerGuid != 0) are filtered out by the adapter and don't reach WB. Foundation for Task 13 (memory budget verification under stress). Co-Authored-By: Claude Opus 4.6 --- src/AcDream.App/Rendering/GameWindow.cs | 13 ++++++++++++- src/AcDream.App/Streaming/GpuWorldState.cs | 13 +++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 902ca5b..fee413b 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) 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 From f4f0101d2c89ec25a64eb4ca5170e388306a9505 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 14:02:30 +0200 Subject: [PATCH 3/5] phase(N.4) Task 14: pending-spawn list integration test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies Task 12's GpuWorldState wiring preserves the pending-spawn list mechanism: 1. Live entity parked before its landblock loads — pending count = 1, adapter not called yet. 2. Landblock arrives with its own atlas-tier entity AND drains the pending live entity. Adapter sees ONLY the atlas-tier GfxObj (server-spawned drained entity is filtered by ServerGuid != 0). 3. Live entity arriving AFTER landblock load goes straight to flat view; adapter is not re-invoked. 4. Landblock unload decrements match load increments. Three integration tests confirm the existing pending-spawn drain semantics work correctly with the new adapter, and per-instance-tier entities (server-spawned) never leak into WB's atlas pipeline. To exercise the adapter code path (which GpuWorldState gates on WbFoundationFlag.IsEnabled) without requiring the env var set before process startup, WbFoundationFlag gains an internal ForTestsOnly_ForceEnable() method and AcDream.App exposes internals to AcDream.Core.Tests via InternalsVisibleTo. Co-Authored-By: Claude Opus 4.6 --- src/AcDream.App/AcDream.App.csproj | 3 + .../Rendering/Wb/WbFoundationFlag.cs | 12 +- .../Wb/PendingSpawnIntegrationTests.cs | 149 ++++++++++++++++++ 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 tests/AcDream.Core.Tests/Rendering/Wb/PendingSpawnIntegrationTests.cs 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) }, + }; +} From bf53cb4fceb203625718fd233583afc13f7d8419 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 14:24:32 +0200 Subject: [PATCH 4/5] =?UTF-8?q?phase(N.4):=20WbMeshAdapter.Tick=20?= =?UTF-8?q?=E2=80=94=20drain=20WB=20pipeline=20queues=20per=20frame?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, ObjectMeshManager.StagedMeshData and OpenGLGraphicsDevice._glThreadQueue grow unbounded as background workers prep mesh data + queue GL actions. Visual stress test of flag-on at radius 7 showed real FPS drop and rising frame latency from this leak. Tick() drains both queues: 1. _graphicsDevice.ProcessGLQueue() applies pending GL state. 2. Loop _meshManager.StagedMeshData.TryDequeue -> UploadMeshData to materialize VAO/VBO/IBO for each prepared mesh. Wired into GameWindow's render loop before draw work begins. No-op when adapter is uninitialized or disposed. Pattern matches WB's reference ObjectRenderManagerBase.ProcessUploads without the prioritization heuristics (we're not yet drawing the results — Task 22's WbDrawDispatcher will add prioritization when visual budget matters). Co-Authored-By: Claude Opus 4.6 --- src/AcDream.App/Rendering/GameWindow.cs | 6 ++++ src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs | 32 +++++++++++++++++++ .../Rendering/Wb/WbMeshAdapterTests.cs | 16 ++++++++++ 3 files changed, 54 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index fee413b..61f4084 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -6076,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/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/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 + } } From 36f7a601c472d9f00444be267540e5439280d790 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 14:33:19 +0200 Subject: [PATCH 5/5] docs(N.4) Task 15: mark Week 2 complete + Adjustment 3 (FPS regression cause) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Week 2 ships: LandblockSpawnAdapter routes atlas-tier GfxObjs to WB ref counts (Task 11/12), pending-spawn list integration verified (Task 14), WbMeshAdapter.Tick drains the pipeline queues per frame (added per Adjustment 3, fixes a real memory leak). Task 13 (memory budget verification) is deferred: stress-test revealed the FPS drop with flag-on isn't the queue leak we thought — it's the dual-pipeline cost (background workers + duplicate GL upload + duplicate I/O + legacy renderer still doing the same atlas work). The savings only materialize in Task 22 when the dispatcher short-circuits the legacy upload for atlas-tier content. Plan Adjustment 3 documents this; no fix needed before Week 4 since default-off is byte-identical to pre-N.4. Next: Task 16 (Week 3) — AnimatedEntityState + per-instance path. Co-Authored-By: Claude Opus 4.6 --- ...026-05-08-phase-n4-rendering-foundation.md | 71 +++++++++++++++++-- 1 file changed, 64 insertions(+), 7 deletions(-) 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:**