Merge branch 'claude/quirky-jepsen-fd60f1' — N.4 Week 2 complete

This commit is contained in:
Erik 2026-05-08 14:33:19 +02:00
commit 9e1992e8a3
10 changed files with 558 additions and 9 deletions

View file

@ -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) |
| 1115 — 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) |
| 1621 — Week 3: per-instance + animation | pending | — |
| 2228 — 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:**

View file

@ -9,6 +9,9 @@
<RootNamespace>AcDream.App</RootNamespace>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Core.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
<PackageReference Include="Silk.NET.Windowing" Version="2.23.0" />

View file

@ -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.

View file

@ -0,0 +1,94 @@
using System.Collections.Generic;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Bridges landblock streaming events to <see cref="IWbMeshAdapter"/>'s
/// reference-count lifecycle. <b>Tier-aware by design</b>: only atlas-tier
/// entities (procedural / dat-hydrated, identified by
/// <c>ServerGuid == 0</c>) drive ref counts. Server-spawned entities
/// (per-instance tier) are skipped — those go through
/// <c>EntitySpawnAdapter</c> + <c>TextureCache.GetOrUploadWithPaletteOverride</c>
/// (see Phase N.4 spec, Architecture → Two-tier rendering split).
///
/// <para>
/// On load: walks the landblock's atlas-tier entities, collects unique
/// GfxObj ids from their <c>MeshRefs</c>, calls
/// <c>IncrementRefCount</c> per id. Snapshots the id-set per landblock so
/// unload can match the load 1:1.
/// </para>
///
/// <para>
/// On unload: looks up the snapshot, calls <c>DecrementRefCount</c> per id,
/// drops the snapshot. Unknown / never-loaded landblocks no-op.
/// </para>
///
/// <para>
/// 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.
/// </para>
///
/// <para>
/// Thread safety: the underlying <see cref="IWbMeshAdapter"/> implementation
/// uses <c>ConcurrentDictionary</c>, 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).
/// </para>
/// </summary>
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<uint, HashSet<ulong>> _idsByLandblock = new();
public LandblockSpawnAdapter(IWbMeshAdapter adapter)
{
System.ArgumentNullException.ThrowIfNull(adapter);
_adapter = adapter;
}
/// <summary>
/// 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.
/// </summary>
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<ulong>();
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);
}
/// <summary>
/// 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.
/// </summary>
public void OnLandblockUnloaded(uint landblockId)
{
if (!_idsByLandblock.TryGetValue(landblockId, out var unique)) return;
foreach (var id in unique) _adapter.DecrementRefCount(id);
_idsByLandblock.Remove(landblockId);
}
}

View file

@ -17,6 +17,16 @@ namespace AcDream.App.Rendering.Wb;
/// </summary>
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;
/// <summary>
/// FOR TESTS ONLY. Forces <see cref="IsEnabled"/> to <c>true</c> so
/// integration tests can exercise the WB adapter path without having to
/// set the env var before static initialisation. Never call from
/// production code.
/// </summary>
internal static void ForTestsOnly_ForceEnable() => _isEnabled = true;
}

View file

@ -94,6 +94,38 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
_meshManager.DecrementRefCount(id);
}
/// <summary>
/// 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.
///
/// <para>
/// Order matters: <c>ProcessGLQueue</c> 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
/// <c>UploadMeshData</c> on each item to materialize the actual GL VAO /
/// VBO / IBO resources. After Tick, <c>GetRenderData</c> for any id
/// previously passed to <c>IncrementRefCount</c> may return non-null.
/// </para>
///
/// <para>
/// No-op when the adapter is uninitialized (e.g., flag is off and the
/// adapter was constructed via <c>CreateUninitialized</c>).
/// </para>
/// </summary>
public void Tick()
{
if (_isUninitialized) return;
if (_disposed) return;
_graphicsDevice!.ProcessGLQueue();
while (_meshManager!.StagedMeshData.TryDequeue(out var meshData))
{
_meshManager.UploadMeshData(meshData);
}
}
/// <inheritdoc/>
public void Dispose()
{

View file

@ -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;
/// </summary>
public sealed class GpuWorldState
{
private readonly LandblockSpawnAdapter? _wbSpawnAdapter;
public GpuWorldState(LandblockSpawnAdapter? wbSpawnAdapter = null)
{
_wbSpawnAdapter = wbSpawnAdapter;
}
private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
private readonly Dictionary<uint, (Vector3 Min, Vector3 Max)> _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

View file

@ -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<ulong> IncrementCalls { get; } = new();
public List<ulong> 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,
};
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class PendingSpawnIntegrationTests
{
/// <summary>
/// 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.
/// </summary>
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<ulong> IncrementCalls { get; } = new();
public List<ulong> 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) },
};
}

View file

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