From 669768d9dacd1f3acbd258ed02b31c46537d24c1 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 13:53:38 +0200 Subject: [PATCH] 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, + }; + } +}