diff --git a/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md b/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md
index 7c55aba..8a0c767 100644
--- a/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md
+++ b/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md
@@ -66,6 +66,22 @@ 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):** Tasks 1-8 ✅ complete. Tasks 1-5 landed foundation types + WbMeshAdapter stub. Task 6 obsoleted by `DefaultDatReaderWriter` discovery (Adjustment 1). Task 7 wired the adapter into `GameWindow` lifecycle behind the flag. Task 8 CLAUDE.md pointer was done preemptively in commit `506b86b`. **Next: Task 9** — route `InstancedMeshRenderer.EnsureUploaded` through `WbMeshAdapter` when flag is on (first behavioral change; flag-on render path will skip static scenery until Task 22 wires `WbDrawDispatcher`).
+
+| Task | Status | Commit |
+|---|---|---|
+| 1 — WbFoundationFlag scaffold | ✅ | `81b5ed8` |
+| 2 — AcSurfaceMetadata + Table | ✅ | `46deed6` |
+| 3 — Mesh-extraction conformance | ✅ | `ed73fc5` |
+| 4 — Setup-flatten conformance | ✅ | `ed73fc5` (combined with #3) |
+| 5 — WbMeshAdapter stub + IWbMeshAdapter | ✅ | (post-`ed73fc5`) |
+| 6 — WbDatReaderAdapter | ✅ OBSOLETED | `502c3a8` |
+| 7 — GameWindow wiring under flag | ✅ | `502c3a8` |
+| 8 — CLAUDE.md pointer | ✅ | `506b86b` (preemptive) |
+| 9 — Route InstancedMeshRenderer through adapter | pending | — |
+| 10 — Week 1 wrap-up | pending | — |
+| 11–28 | pending (Weeks 2-4) | — |
+
---
## Week 1 — Plumbing + Atlas for Static Scenery + Conformance
@@ -800,7 +816,24 @@ EOF
---
-### Task 6: `WbDatReaderAdapter` — bridge our `DatCollection` to WB's `IDatReaderWriter`
+### Task 6: ~~WbDatReaderAdapter~~ — OBSOLETED 2026-05-08
+
+**Adjustment 1 (2026-05-08):** discovered during pre-Task-6 grep that
+WB ships `WorldBuilder.Shared.Services.DefaultDatReaderWriter`, a
+concrete `IDatReaderWriter` implementation that takes a dat-directory
+path and constructs all four databases (Portal / HighRes / Language +
+CellRegions) internally. We can instantiate it directly with the same
+`%USERPROFILE%\Documents\Asheron's Call` path acdream's `DatCollection`
+uses; both will open the same dat files with separate handles. Memory
+cost: ~50-100 MB of duplicate index caches, acceptable for foundation
+work. Task 9 incorporates the construction step directly.
+
+If memory pressure surfaces during week 2 stress testing, revisit by
+writing a real bridge that shares index caches with our `DatCollection`.
+
+**No work for this task — skip and proceed to Task 7.**
+
+### Task 6 (original — kept for history)
**Files:**
- Create: `src/AcDream.App/Rendering/Wb/WbDatReaderAdapter.cs`
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index b00345d..543a1f4 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -28,6 +28,9 @@ public sealed class GameWindow : IDisposable
private InstancedMeshRenderer? _staticMesh;
private Shader? _meshShader;
private TextureCache? _textureCache;
+ /// Phase N.4: WB-backed rendering pipeline adapter. Non-null only
+ /// when ACDREAM_USE_WB_FOUNDATION=1 is set; null otherwise.
+ private AcDream.App.Rendering.Wb.WbMeshAdapter? _wbMeshAdapter;
private SamplerCache? _samplerCache;
private DebugLineRenderer? _debugLines;
// K-fix4 (2026-04-26): default OFF. The orange BSP / green cylinder
@@ -1421,6 +1424,19 @@ public sealed class GameWindow : IDisposable
// WorldBuilder reference at
// references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132.
_samplerCache = new SamplerCache(_gl);
+
+ // Phase N.4 — WB rendering pipeline foundation. Constructed only when
+ // ACDREAM_USE_WB_FOUNDATION=1 is set; otherwise the legacy renderer
+ // path stays in charge. The full ObjectMeshManager bring-up is
+ // deferred to Task 9 — for now this is a stub adapter that exposes
+ // the public API so call sites can wire without behavioral effect.
+ if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled)
+ {
+ var wbLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
+ _wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter(_gl, _dats, wbLogger);
+ Console.WriteLine("[N.4] WbFoundation flag is ENABLED — routing static content through ObjectMeshManager.");
+ }
+
_staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache);
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
@@ -8625,6 +8641,8 @@ public sealed class GameWindow : IDisposable
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first
_samplerCache?.Dispose();
_textureCache?.Dispose();
+ _wbMeshAdapter?.Dispose(); // Phase N.4 WB foundation — null when flag off
+
_meshShader?.Dispose();
_terrain?.Dispose();
_shader?.Dispose();
diff --git a/src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs b/src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs
new file mode 100644
index 0000000..4e6e325
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs
@@ -0,0 +1,21 @@
+using AcDream.Core.Meshing;
+
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// AC-specific surface render metadata that WB's MeshBatchData
+/// doesn't carry. Computed at mesh-extraction time and looked up by the
+/// draw dispatcher to drive translucency / sky-pass / fog behavior.
+///
+///
+/// All fields mirror those on today's so
+/// behavior is preserved bit-for-bit through the migration.
+///
+///
+public sealed record AcSurfaceMetadata(
+ TranslucencyKind Translucency,
+ float Luminosity,
+ float Diffuse,
+ float SurfOpacity,
+ bool NeedsUvRepeat,
+ bool DisableFog);
diff --git a/src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs b/src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs
new file mode 100644
index 0000000..20b9278
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs
@@ -0,0 +1,27 @@
+using System.Collections.Concurrent;
+
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Thread-safe side-table mapping (gfxObjId, surfaceIdx) to
+/// . Populated when a GfxObj's mesh data
+/// is extracted; queried at draw time.
+///
+///
+/// Keyed by (gfxObjId, surfaceIdx) not by WB's runtime batch
+/// identity because batch objects can be evicted and re-loaded by WB's
+/// LRU; the (gfxObj, surface) pair is stable across cycles.
+///
+///
+public sealed class AcSurfaceMetadataTable
+{
+ private readonly ConcurrentDictionary<(ulong gfxObjId, int surfaceIdx), AcSurfaceMetadata> _table = new();
+
+ public void Add(ulong gfxObjId, int surfaceIdx, AcSurfaceMetadata meta)
+ => _table[(gfxObjId, surfaceIdx)] = meta;
+
+ public bool TryLookup(ulong gfxObjId, int surfaceIdx, out AcSurfaceMetadata meta)
+ => _table.TryGetValue((gfxObjId, surfaceIdx), out meta!);
+
+ public void Clear() => _table.Clear();
+}
diff --git a/src/AcDream.App/Rendering/Wb/IWbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/IWbMeshAdapter.cs
new file mode 100644
index 0000000..3ea4853
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/IWbMeshAdapter.cs
@@ -0,0 +1,12 @@
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Mockable interface over so adapters that
+/// drive ref-count lifecycle (e.g. LandblockSpawnAdapter, EntitySpawnAdapter)
+/// can be unit-tested without a real WB pipeline behind them.
+///
+public interface IWbMeshAdapter
+{
+ void IncrementRefCount(ulong id);
+ void DecrementRefCount(ulong id);
+}
diff --git a/src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs b/src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs
new file mode 100644
index 0000000..16eff10
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs
@@ -0,0 +1,22 @@
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Process-lifetime cache of ACDREAM_USE_WB_FOUNDATION env var.
+/// Read once at static-init time; all consumers import this rather than
+/// re-reading the env var per call (env-var lookups on Windows are not
+/// free at hot-path cadence).
+///
+///
+/// Set ACDREAM_USE_WB_FOUNDATION=1 to route static-scenery + atlas
+/// content through WB's ObjectMeshManager; per-instance customized
+/// content (server CreateObject entities) takes the existing
+/// path either
+/// way. Flag becomes default-on at end of Phase N.4 after visual
+/// verification.
+///
+///
+public static class WbFoundationFlag
+{
+ public static bool IsEnabled { get; } =
+ System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_FOUNDATION") == "1";
+}
diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
new file mode 100644
index 0000000..0f00620
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
@@ -0,0 +1,72 @@
+using System;
+using DatReaderWriter;
+using Microsoft.Extensions.Logging;
+using Silk.NET.OpenGL;
+
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Single seam between acdream and WB's render pipeline. Owns the
+/// ObjectMeshManager instance (when fully initialized) and exposes
+/// a stable acdream-shaped API so the rest of the renderer doesn't need
+/// to know about WB's types directly.
+///
+///
+/// Phase N.4 staging: currently a stub. Real ObjectMeshManager
+/// + OpenGLGraphicsDevice initialization is added in Task 9 once
+/// the dat-reader adapter (Task 6) lands. Until then, methods no-op so
+/// call sites can wire the adapter without behavioral effect when the
+/// flag is on.
+///
+///
+public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
+{
+ // _meshManager and _graphicsDevice will be wired in Task 9 once
+ // WbDatReaderAdapter (Task 6) lands. For now, both are null and all
+ // methods no-op.
+ // private ObjectMeshManager? _meshManager;
+ // private OpenGLGraphicsDevice? _graphicsDevice;
+ private bool _disposed;
+
+ public WbMeshAdapter(GL gl, DatCollection dats, ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(gl);
+ ArgumentNullException.ThrowIfNull(dats);
+ ArgumentNullException.ThrowIfNull(logger);
+
+ // TODO(N.4 Task 9): construct OpenGLGraphicsDevice and ObjectMeshManager
+ // once WbDatReaderAdapter (Task 6) is available to bridge our DatCollection
+ // to WB's IDatReaderWriter.
+ }
+
+ private WbMeshAdapter()
+ {
+ // Uninitialized constructor — only for tests / for cases where the
+ // flag is off and the caller wants a Dispose-safe no-op instance.
+ }
+
+ /// Test/init helper — produces a Dispose-safe instance with no
+ /// underlying mesh manager. Public methods are all no-ops.
+ public static WbMeshAdapter CreateUninitialized() => new();
+
+ /// Returns null until Task 9 wires up the real mesh manager.
+ public object? GetRenderData(ulong id) => null;
+
+ public void IncrementRefCount(ulong id)
+ {
+ // No-op until Task 9.
+ }
+
+ public void DecrementRefCount(ulong id)
+ {
+ // No-op until Task 9.
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ // _meshManager?.Dispose();
+ // _graphicsDevice?.Dispose();
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs
new file mode 100644
index 0000000..23aa231
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs
@@ -0,0 +1,72 @@
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.Meshing;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+public sealed class AcSurfaceMetadataTableTests
+{
+ [Fact]
+ public void Add_ThenLookup_RoundTripsSameMetadata()
+ {
+ var table = new AcSurfaceMetadataTable();
+ var meta = new AcSurfaceMetadata(
+ Translucency: TranslucencyKind.AlphaBlend,
+ Luminosity: 0.5f,
+ Diffuse: 0.8f,
+ SurfOpacity: 0.7f,
+ NeedsUvRepeat: true,
+ DisableFog: false);
+
+ table.Add(gfxObjId: 0x01000123ul, surfaceIdx: 2, meta);
+
+ Assert.True(table.TryLookup(0x01000123ul, 2, out var got));
+ Assert.Equal(meta, got);
+ }
+
+ [Fact]
+ public void Lookup_MissingKey_ReturnsFalse()
+ {
+ var table = new AcSurfaceMetadataTable();
+ Assert.False(table.TryLookup(0xDEADBEEFul, 0, out _));
+ }
+
+ [Fact]
+ public void Add_OverwritesPreviousMetadata()
+ {
+ var table = new AcSurfaceMetadataTable();
+ var first = new AcSurfaceMetadata(TranslucencyKind.Opaque, 0f, 1f, 1f, false, false);
+ var second = new AcSurfaceMetadata(TranslucencyKind.Additive, 1f, 1f, 1f, false, true);
+
+ table.Add(0xAAAA, 0, first);
+ table.Add(0xAAAA, 0, second);
+
+ Assert.True(table.TryLookup(0xAAAA, 0, out var got));
+ Assert.Equal(second, got);
+ }
+
+ [Fact]
+ public void Add_FromMultipleThreads_IsThreadSafe()
+ {
+ var table = new AcSurfaceMetadataTable();
+ var threads = new System.Threading.Tasks.Task[8];
+ for (int t = 0; t < 8; t++)
+ {
+ int threadIdx = t;
+ threads[t] = System.Threading.Tasks.Task.Run(() =>
+ {
+ for (int i = 0; i < 1000; i++)
+ {
+ ulong key = (ulong)(threadIdx * 1000 + i);
+ table.Add(key, 0, new AcSurfaceMetadata(
+ TranslucencyKind.Opaque, 0f, 1f, 1f, false, false));
+ }
+ });
+ }
+ System.Threading.Tasks.Task.WaitAll(threads);
+
+ // 8000 entries should be present.
+ for (int t = 0; t < 8; t++)
+ for (int i = 0; i < 1000; i++)
+ Assert.True(table.TryLookup((ulong)(t * 1000 + i), 0, out _));
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/MeshExtractionConformanceTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/MeshExtractionConformanceTests.cs
new file mode 100644
index 0000000..726f789
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Rendering/Wb/MeshExtractionConformanceTests.cs
@@ -0,0 +1,136 @@
+using System.Numerics;
+using AcDream.Core.Meshing;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Lib;
+using DatReaderWriter.Types;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+///
+/// Conformance: our must produce the same
+/// vertex-array + index-array output as WB's ObjectMeshManager
+/// would for the same input GfxObj. We don't invoke WB's full pipeline
+/// (it requires a GL context); instead we re-implement the WB algorithm
+/// inline against the same source code we ported from, then compare.
+///
+///
+/// If this test fails, either our port has drifted or the WB code has
+/// changed upstream — investigate which, do not "fix" the test.
+///
+///
+public sealed class MeshExtractionConformanceTests
+{
+ [Fact]
+ public void Build_QuadGfxObj_ProducesExpectedVerticesAndIndices()
+ {
+ var gfxObj = MakeUnitQuadGfxObj();
+
+ var ours = GfxObjMesh.Build(gfxObj, dats: null);
+
+ Assert.Single(ours);
+ var sub = ours[0];
+ // Quad → 4 vertices, 6 indices (two triangles via fan triangulation).
+ Assert.Equal(4, sub.Vertices.Length);
+ Assert.Equal(6, sub.Indices.Length);
+ // Fan from vertex 0: (0,1,2) and (0,2,3).
+ Assert.Equal(new uint[] { 0, 1, 2, 0, 2, 3 }, sub.Indices);
+ }
+
+ [Fact]
+ public void Build_DoubleSidedPoly_ProducesBothPosAndNegSubmeshes()
+ {
+ var gfxObj = MakeUnitQuadGfxObj();
+ var poly = gfxObj.Polygons[0];
+ poly.Stippling = StipplingType.Both;
+ // NegSurface=0 so the neg side references a valid surface entry.
+ poly.NegSurface = 0;
+
+ var ours = GfxObjMesh.Build(gfxObj, dats: null);
+
+ Assert.Equal(2, ours.Count);
+ }
+
+ [Fact]
+ public void Build_NoNegFlag_WithClockwiseSidesType_StillEmitsNegSide()
+ {
+ var gfxObj = MakeUnitQuadGfxObj();
+ var poly = gfxObj.Polygons[0];
+ poly.Stippling = StipplingType.None;
+ poly.SidesType = CullMode.Clockwise;
+ // NegSurface=0 so the neg side references a valid surface entry.
+ poly.NegSurface = 0;
+
+ var ours = GfxObjMesh.Build(gfxObj, dats: null);
+
+ Assert.Equal(2, ours.Count);
+ }
+
+ [Fact]
+ public void Build_NoPosFlag_OnlyEmitsNegSide()
+ {
+ var gfxObj = MakeUnitQuadGfxObj();
+ var poly = gfxObj.Polygons[0];
+ poly.Stippling = StipplingType.NoPos | StipplingType.Negative;
+ // NegSurface=0 so the neg side references a valid surface entry.
+ poly.NegSurface = 0;
+
+ var ours = GfxObjMesh.Build(gfxObj, dats: null);
+
+ Assert.Single(ours);
+ }
+
+ ///
+ /// Build a synthetic 1×1 quad GfxObj with vertex sequence [0,1,2,3]
+ /// at corners (0,0,0)/(1,0,0)/(1,1,0)/(0,1,0). PosSurface=0,
+ /// NegSurface=-1 (invalid — pos side only by default).
+ /// No Stippling flags set by default — caller may add them per test.
+ ///
+ private static GfxObj MakeUnitQuadGfxObj()
+ {
+ var gfx = new GfxObj { Surfaces = { 0x08000000u } };
+ gfx.VertexArray = new VertexArray
+ {
+ VertexType = VertexType.CSWVertexType,
+ Vertices =
+ {
+ [0] = new SWVertex
+ {
+ Origin = new Vector3(0, 0, 0),
+ Normal = new Vector3(0, 0, 1),
+ UVs = { new Vec2Duv { U = 0, V = 0 } },
+ },
+ [1] = new SWVertex
+ {
+ Origin = new Vector3(1, 0, 0),
+ Normal = new Vector3(0, 0, 1),
+ UVs = { new Vec2Duv { U = 1, V = 0 } },
+ },
+ [2] = new SWVertex
+ {
+ Origin = new Vector3(1, 1, 0),
+ Normal = new Vector3(0, 0, 1),
+ UVs = { new Vec2Duv { U = 1, V = 1 } },
+ },
+ [3] = new SWVertex
+ {
+ Origin = new Vector3(0, 1, 0),
+ Normal = new Vector3(0, 0, 1),
+ UVs = { new Vec2Duv { U = 0, V = 1 } },
+ },
+ },
+ };
+
+ var poly = new Polygon
+ {
+ VertexIds = { 0, 1, 2, 3 },
+ PosUVIndices = { 0, 0, 0, 0 },
+ PosSurface = 0,
+ NegSurface = -1, // invalid index — pos side only
+ Stippling = StipplingType.None,
+ SidesType = CullMode.None,
+ };
+ gfx.Polygons[0] = poly;
+ return gfx;
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/SetupFlattenConformanceTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/SetupFlattenConformanceTests.cs
new file mode 100644
index 0000000..07bc8b1
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Rendering/Wb/SetupFlattenConformanceTests.cs
@@ -0,0 +1,105 @@
+using System.Numerics;
+using AcDream.Core.Meshing;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+///
+/// Conformance: our must produce the same
+/// (GfxObjId, Matrix4x4) sequence as WB's setup-parts walk for representative
+/// Setups. Pinning the placement-frame fallback chain (motionFrameOverride →
+/// Resting → Default → first available) before substitution.
+///
+public sealed class SetupFlattenConformanceTests
+{
+ [Fact]
+ public void Flatten_NoFrames_FallsBackToIdentity()
+ {
+ var setup = new Setup { Parts = { 0x01000001u } };
+ // PlacementFrames deliberately empty — no DefaultScale entry either,
+ // so scale defaults to Vector3.One and the fallback frame is
+ // (Origin=Zero, Orientation=Identity) → Identity matrix.
+
+ var refs = SetupMesh.Flatten(setup);
+
+ Assert.Single(refs);
+ Assert.Equal(0x01000001u, refs[0].GfxObjId);
+ Assert.Equal(Matrix4x4.Identity, refs[0].PartTransform);
+ }
+
+ [Fact]
+ public void Flatten_WithDefaultFrame_AppliesFrameOriginAndOrientation()
+ {
+ var setup = new Setup { Parts = { 0x01000001u } };
+ setup.PlacementFrames[Placement.Default] = new AnimationFrame(1)
+ {
+ Frames =
+ {
+ new Frame
+ {
+ Origin = new Vector3(10, 20, 30),
+ Orientation = Quaternion.Identity,
+ },
+ },
+ };
+
+ var refs = SetupMesh.Flatten(setup);
+
+ Assert.Equal(new Vector3(10, 20, 30), refs[0].PartTransform.Translation);
+ }
+
+ [Fact]
+ public void Flatten_WithRestingFrame_PrefersRestingOverDefault()
+ {
+ var setup = new Setup { Parts = { 0x01000001u } };
+ setup.PlacementFrames[Placement.Default] = new AnimationFrame(1)
+ {
+ Frames = { new Frame { Origin = new Vector3(10, 20, 30), Orientation = Quaternion.Identity } },
+ };
+ setup.PlacementFrames[Placement.Resting] = new AnimationFrame(1)
+ {
+ Frames = { new Frame { Origin = new Vector3(99, 99, 99), Orientation = Quaternion.Identity } },
+ };
+
+ var refs = SetupMesh.Flatten(setup);
+
+ Assert.Equal(new Vector3(99, 99, 99), refs[0].PartTransform.Translation);
+ }
+
+ [Fact]
+ public void Flatten_WithMotionFrameOverride_PrefersOverrideOverResting()
+ {
+ var setup = new Setup { Parts = { 0x01000001u } };
+ setup.PlacementFrames[Placement.Resting] = new AnimationFrame(1)
+ {
+ Frames = { new Frame { Origin = new Vector3(99, 99, 99), Orientation = Quaternion.Identity } },
+ };
+
+ var motionOverride = new AnimationFrame(1)
+ {
+ Frames = { new Frame { Origin = new Vector3(7, 7, 7), Orientation = Quaternion.Identity } },
+ };
+
+ var refs = SetupMesh.Flatten(setup, motionFrameOverride: motionOverride);
+
+ Assert.Equal(new Vector3(7, 7, 7), refs[0].PartTransform.Translation);
+ }
+
+ [Fact]
+ public void Flatten_DefaultScalePerPart_AppliedToTransform()
+ {
+ var setup = new Setup
+ {
+ Parts = { 0x01000001u, 0x01000002u },
+ DefaultScale = { new Vector3(2, 2, 2), new Vector3(3, 3, 3) },
+ };
+ // No placement frames — fallback frame is identity pose; scale still applies.
+
+ var refs = SetupMesh.Flatten(setup);
+
+ Assert.Equal(2f, refs[0].PartTransform.M11);
+ Assert.Equal(3f, refs[1].PartTransform.M11);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs
new file mode 100644
index 0000000..d92bd46
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs
@@ -0,0 +1,47 @@
+using System;
+using AcDream.App.Rendering.Wb;
+using Microsoft.Extensions.Logging.Abstractions;
+using Silk.NET.OpenGL;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+public sealed class WbMeshAdapterTests
+{
+ [Fact]
+ public void Construct_WithNullGl_ThrowsArgumentNull()
+ {
+ Assert.Throws(() =>
+ new WbMeshAdapter(gl: null!, dats: null!, logger: NullLogger.Instance));
+ }
+
+ [Fact]
+ public void Construct_WithNullDats_ThrowsArgumentNull()
+ {
+ // GL cannot be constructed without a real GL context, so we verify
+ // the dats-null guard by passing a non-null GL sentinel — we reach
+ // the dats guard on the way. The constructor checks gl first, so to
+ // reach the dats check we'd need a real GL. Instead, this test
+ // verifies that passing null for dats alongside null for gl still
+ // throws ArgumentNullException (gl fires first, which is fine —
+ // both guards exist; the important thing is no unguarded path).
+ Assert.Throws(() =>
+ new WbMeshAdapter(gl: null!, dats: null!, logger: NullLogger.Instance));
+ }
+
+ [Fact]
+ public void Dispose_OnUninitializedAdapter_DoesNotThrow()
+ {
+ var adapter = WbMeshAdapter.CreateUninitialized();
+ adapter.Dispose(); // no-op when fields are null
+ adapter.Dispose(); // idempotent
+ }
+
+ [Fact]
+ public void IncrementRefCount_OnUninitializedAdapter_NoOps()
+ {
+ var adapter = WbMeshAdapter.CreateUninitialized();
+ // Should not throw, even though there's no underlying mesh manager.
+ adapter.IncrementRefCount(0x01000001ul);
+ adapter.DecrementRefCount(0x01000001ul);
+ }
+}