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.**
|
||||
|
||||
**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:**
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
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>
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
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