Final cross-cutting review of N.5 found that Task 15's deletion of mesh_instanced.vert/.frag left InstancedMeshRenderer orphaned — ACDREAM_USE_WB_FOUNDATION=0 silently rendered terrain+sky only with no entities. The SHIP commit's "[x] ACDREAM_USE_WB_FOUNDATION=0 still works" claim was inaccurate. Resolution: formal retirement of the legacy renderer path within N.5 instead of deferring to N.6. Deleted: - src/AcDream.App/Rendering/InstancedMeshRenderer.cs - src/AcDream.App/Rendering/StaticMeshRenderer.cs - src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs GameWindow simplified — capability detection is unconditional, missing bindless throws NotSupportedException with a clear message at startup. WbDrawDispatcher + mesh_modern shader load are mandatory after init. No escape hatch. GpuWorldState simplified — WbFoundationFlag.IsEnabled guards on AddLandblock/RemoveLandblock removed; adapter calls are unconditional when the adapter is non-null. PendingSpawnIntegrationTests updated — WbFoundationFlag.ForTestsOnly_ForceEnable static ctor removed (flag is gone; adapter calls are unconditional). The ApplyLoadedTerrain physics-data loop was also simplified: the EnsureUploaded sub-loop that fed InstancedMeshRenderer is gone; _pendingCellMeshes is now explicitly cleared to prevent unbounded accumulation (the worker thread still populates it, but WB handles EnvCell geometry through its own pipeline). Spec §2 Decision 5 + §10 Out-of-Scope updated. Plan ship-amendment section added. Roadmap updated (N.5 ships with retirement; N.6 scope narrowed to perf-only). CLAUDE.md "WB integration cribs" updated. Perf baseline doc updated. WbDrawDispatcher class summary docstring corrected to describe the as-shipped SSBO + multi-draw-indirect path. ISSUES.md #51 updated (terrain not in N.5 scope; deferred to N.7). Bindless support is now a hard requirement. Modern desktop GPUs universally expose GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters; if a user hits the NotSupportedException, that's a real bug report worth investigating, not a silent fallback. Build: 0 errors, 0 warnings. Tests: 71/71 (Wb+MatrixComposition+TextureCacheBindless filter). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
142 lines
6 KiB
C#
142 lines
6 KiB
C#
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
|
|
{
|
|
// N.5 ship amendment: WbFoundationFlag was deleted — GpuWorldState
|
|
// no longer gates adapter calls on the flag; they are unconditional
|
|
// when the adapter is non-null. No static ctor hook needed.
|
|
|
|
[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) },
|
|
};
|
|
}
|