Merge branch 'claude/quirky-jepsen-fd60f1' — N.4 Week 2 complete
This commit is contained in:
commit
9e1992e8a3
10 changed files with 558 additions and 9 deletions
|
|
@ -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.**
|
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 |
|
| Task | Status | Commit |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 — WbFoundationFlag scaffold | ✅ | `81b5ed8` |
|
| 1 — WbFoundationFlag scaffold | ✅ | `81b5ed8` |
|
||||||
| 2 — AcSurfaceMetadata + Table | ✅ | `46deed6` |
|
| 2 — AcSurfaceMetadata + Table | ✅ | `46deed6` |
|
||||||
| 3 — Mesh-extraction conformance | ✅ | `ed73fc5` |
|
| 3 — Mesh-extraction conformance | ✅ | `ed73fc5` |
|
||||||
| 4 — Setup-flatten conformance | ✅ | `ed73fc5` (combined with #3) |
|
| 4 — Setup-flatten conformance | ✅ | `ed73fc5` |
|
||||||
| 5 — WbMeshAdapter stub + IWbMeshAdapter | ✅ | (post-`ed73fc5`) |
|
| 5 — WbMeshAdapter stub + IWbMeshAdapter | ✅ | (post-`ed73fc5`) |
|
||||||
| 6 — WbDatReaderAdapter | ✅ OBSOLETED | `502c3a8` |
|
| 6 — WbDatReaderAdapter | ✅ OBSOLETED (Adj. 1) | `502c3a8` |
|
||||||
| 7 — GameWindow wiring under flag | ✅ | `502c3a8` |
|
| 7 — GameWindow wiring under flag | ✅ | `502c3a8` |
|
||||||
| 8 — CLAUDE.md pointer | ✅ | `506b86b` (preemptive) |
|
| 8 — CLAUDE.md pointer | ✅ | `506b86b` (preemptive) |
|
||||||
| 9 — Real WB pipeline + InstancedMeshRenderer routing | ✅ | `4ad7a98` |
|
| 9 — Real WB pipeline + InstancedMeshRenderer routing | ✅ partial / Adj. 2 reverted | `4ad7a98` + `4f318bc` |
|
||||||
| 10 — Week 1 wrap-up | ✅ | (this commit) |
|
| 10 — Week 1 wrap-up | ✅ | `c49c6ed` |
|
||||||
| 11–15 — Week 2: streaming integration | pending | — |
|
| 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 | — |
|
| 16–21 — Week 3: per-instance + animation | pending | — |
|
||||||
| 22–28 — Week 4: draw dispatcher + ship | 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
|
flag-off visually identical." Routing arrives in Week 2 (Task 11) at
|
||||||
the correct layer. Smoke verification is now: flag-on === flag-off.
|
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)
|
### Task 6 (original — kept for history)
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@
|
||||||
<RootNamespace>AcDream.App</RootNamespace>
|
<RootNamespace>AcDream.App</RootNamespace>
|
||||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="AcDream.Core.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
|
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
|
||||||
<PackageReference Include="Silk.NET.Windowing" Version="2.23.0" />
|
<PackageReference Include="Silk.NET.Windowing" Version="2.23.0" />
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
// Phase A.1: streaming fields replacing the one-shot _entities list.
|
// Phase A.1: streaming fields replacing the one-shot _entities list.
|
||||||
private AcDream.App.Streaming.LandblockStreamer? _streamer;
|
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 AcDream.App.Streaming.StreamingController? _streamingController;
|
||||||
private int _streamingRadius = 2; // default 5×5
|
private int _streamingRadius = 2; // default 5×5
|
||||||
private uint? _lastLivePlayerLandblockId;
|
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.");
|
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);
|
_staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache, _wbMeshAdapter);
|
||||||
|
|
||||||
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
|
// 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);
|
_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
|
// Phase D.2a — begin ImGui frame. Paired with the Render() call
|
||||||
// after the scene draws (below). ImGuiController.Update()
|
// after the scene draws (below). ImGuiController.Update()
|
||||||
// consumes buffered Silk.NET input events and calls ImGui.NewFrame.
|
// consumes buffered Silk.NET input events and calls ImGui.NewFrame.
|
||||||
|
|
|
||||||
94
src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs
Normal file
94
src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,16 @@ namespace AcDream.App.Rendering.Wb;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class WbFoundationFlag
|
public static class WbFoundationFlag
|
||||||
{
|
{
|
||||||
public static bool IsEnabled { get; } =
|
private static bool _isEnabled =
|
||||||
System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_FOUNDATION") == "1";
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,38 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
|
||||||
_meshManager.DecrementRefCount(id);
|
_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/>
|
/// <inheritdoc/>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering.Wb;
|
||||||
using AcDream.Core.World;
|
using AcDream.Core.World;
|
||||||
|
|
||||||
namespace AcDream.App.Streaming;
|
namespace AcDream.App.Streaming;
|
||||||
|
|
@ -38,6 +39,13 @@ namespace AcDream.App.Streaming;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GpuWorldState
|
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, LoadedLandblock> _loaded = new();
|
||||||
private readonly Dictionary<uint, (Vector3 Min, Vector3 Max)> _aabbs = new();
|
private readonly Dictionary<uint, (Vector3 Min, Vector3 Max)> _aabbs = new();
|
||||||
|
|
||||||
|
|
@ -132,6 +140,8 @@ public sealed class GpuWorldState
|
||||||
}
|
}
|
||||||
|
|
||||||
_loaded[landblock.LandblockId] = landblock;
|
_loaded[landblock.LandblockId] = landblock;
|
||||||
|
if (WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null)
|
||||||
|
_wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]);
|
||||||
RebuildFlatView();
|
RebuildFlatView();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,6 +191,9 @@ public sealed class GpuWorldState
|
||||||
|
|
||||||
public void RemoveLandblock(uint landblockId)
|
public void RemoveLandblock(uint landblockId)
|
||||||
{
|
{
|
||||||
|
if (WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null)
|
||||||
|
_wbSpawnAdapter.OnLandblockUnloaded(landblockId);
|
||||||
|
|
||||||
// Rescue persistent entities before removal. These get appended
|
// Rescue persistent entities before removal. These get appended
|
||||||
// to the _persistentRescued list; the caller is responsible for
|
// to the _persistentRescued list; the caller is responsible for
|
||||||
// re-injecting them (via AppendLiveEntity) into whatever landblock
|
// re-injecting them (via AppendLiveEntity) into whatever landblock
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -46,4 +46,20 @@ public sealed class WbMeshAdapterTests
|
||||||
var adapter = WbMeshAdapter.CreateUninitialized();
|
var adapter = WbMeshAdapter.CreateUninitialized();
|
||||||
Assert.Null(adapter.GetRenderData(0x01000001ul));
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue