diff --git a/docs/plans/2026-07-27-vulkan-campaign.md b/docs/plans/2026-07-27-vulkan-campaign.md
index 65c005c3..fe17360a 100644
--- a/docs/plans/2026-07-27-vulkan-campaign.md
+++ b/docs/plans/2026-07-27-vulkan-campaign.md
@@ -574,6 +574,7 @@ tenth pair with no consumer at all; see the V6e report.
| **V6g** | The four Vulkan validation defects §5.5.7 and its log left open: the dynamic-descriptor split (an architect decision, §5.5.8 item 1), per-pass depth-format pipeline variants, first-use backbuffer attachment layout transitions, and a backbuffer capture that no longer reads a presented swapchain image. Confined to `Gpu/Vk/`; the GL backend executes not one changed statement. | validation-clean bring-up run (0 errors / 0 warnings over 39,855 frames, against 7 VUIDs + 1 UNASSIGNED at the parent), App tests, GL offline pixel gate 4.08e-05 — its own same-commit control value |
| **V6h** ✅ | **The Vulkan composition host**, specified by §5.5.9 and reported in §5.5.10. `ACDREAM_RENDER_BACKEND=vulkan` runs the real `GameWindow` composition — DAT load, streaming, camera, entity table, session, and the real retained `UiHost` through the RHI — with no world renderers. Three seams: the already-generic platform acquisition now publishes a `GameWindowGraphics`; `VulkanHostInputCameraCompositionFactory` is the host-phase fork; the frame root gains a Vulkan arm. `VulkanBringUpHost` is reduced to the capability-probe harness over the extracted `VulkanGraphicsContext`. | offline Vulkan launch reaching the real composition with the client's own UI captured, one validation-layer run at 0 errors / 0 warnings, converging ownership ledger, App tests 4,075/3, complete Release suite 9,138/5, GL offline pixel gate 1.78e-05 |
+| **V6i** ✅ | **The world arm's prerequisites**, in two parts. **V6i-1** (`df6e2a79`, reported in §5.5.12) closed §5.5.8's one-binding-two-buffers hazard with one descriptor-set pair per renderer scope, derived from the descriptor state rather than declared, and measured the ordered remainder list the world arm still needed. **V6i-2** (reported in §5.5.13) took items 1, 2 and 6 of that list in three gated commits: **1** the `TerrainClip` descriptor-set fix plus set 1's missing bindings 2 and 4, proven by `spirv-dis` and now gated by a SPIR-V-reading contract test (`f7344758`); **2** world texture CREATION crosses to `IGpuTexture` — `IWorldTextureArray` over `TextureAtlasManager`/`ManagedGLTextureArray`, `TerrainAtlas`'s second construction path, and `ICompositeTextureArrayBackend`'s RHI arm — with the Vulkan arm exercised at startup (`c8d0f70b`); **3** `IMeshPipelineDevice` decouples `ObjectMeshManager`/`WbMeshAdapter` from `OpenGLGraphicsDevice`. **Items 3–5 — the submission arms, `RetailPViewPassExecutor`, and the pass-structure merge — are the next slice's.** | pixel gate per commit (1.60e-05, 3.20e-05, 3.02e-05 vs `0ca802cd`), App tests, 3/3 desktop-witness connected run at commits 2 and 3, one validation-layer Vulkan run per commit |
| **V7** | GL-versus-Vulkan differential: `tools/run-backend-differential-gate.ps1`, strict paired-PNG compare, divergences fixed in the Vulkan backend only, then lifecycle + R6 soak natively on Vulkan, one validation-layer-clean run, one RenderDoc capture. **Milestone: parity.** | every differential checkpoint passes; both connected routes green on VK |
| **V8** | Perf gate on the RX 9070 XT, uncapped, both backends, same route. | §2 acceptance table; parity is the floor |
| **V9** | Linux + CI: X11/Wayland surfaces; a `linux-vulkan` job on lavapipe (probe accepts on a real 1.3 software device, a short real render under xvfb, forced-unsupported → exit 4, `.spv` freshness). Physical Linux GPU row deferred post-cutover, as for Slice L. | CI green including the new job |
@@ -1609,6 +1610,134 @@ honest state and it is why the hazard was closed as its own commit: when the
world arm lands and a frame first holds three renderers' buffers, a blank or
corrupt result cannot be this defect.
+#### 5.5.13 V6i-2 (2026-07-28): world texture creation reaches `IGpuTexture`, and the mesh pipeline stops naming a backend
+
+§5.5.12's ordered remainder list had six items. This slice is items 1, 2 and 6 —
+the exercisable prerequisites the world arm cannot be written without — in three
+separately gated commits. Items 3–5 are the next slice's and are unchanged.
+
+**1. The terrain clip block was in the wrong descriptor set, and so was the whole
+uniform layout** (`f7344758`). `terrain_modern.vert` declared `TerrainClip` with
+no `ACDREAM_UBO_SET`, so the Vulkan dialect put it at set 0 binding 2 — which set
+0 declares as a STORAGE buffer. `spirv-dis` on the committed `.spv`, before and
+after:
+
+```
+before %372 = OpVariable %_ptr_Uniform__struct_370 Uniform
+ OpDecorate %372 DescriptorSet 0 / Binding 2
+after OpDecorate %372 DescriptorSet 1 / Binding 2
+```
+
+with `%_struct_370 = OpTypeStruct %int %_arr_v4float_uint_8` — the block's
+`{ int; vec4[8]; }` — unchanged in both. The same commit closed §5.5.8's recorded
+`UniformSkyParams` gap, because set 1's layout declared only bindings 1 and 3 and
+was therefore missing BOTH. All four are now declared and dynamic, which is half
+Vulkan's guaranteed `maxDescriptorSetUniformBuffersDynamic`.
+
+Membership AND order now come from one predicate — `IsDeclaredUniformBinding` —
+that the layout, the descriptor writes and `vkCmdBindDescriptorSets`'s
+dynamic-offset array are all derived from, the shape V6g gave set 0. The three had
+been restated separately, which is how a fifth binding would have gone wrong the
+same way.
+
+**Both gaps were found by hand, months apart, and neither could fail on the
+shipping backend.** `VulkanShaderDescriptorContractTests` now reads every
+committed `.spv` and asserts the partition instead: every uniform block at a
+declared set-1 binding, every storage block inside set 0's range, every sampled
+resource in the one texture table. Checked out against the pre-fix `.spv`, two of
+its four tests fail — so it is a gate, not a description.
+
+**2. World texture CREATION crosses to `IGpuTexture`** (`c8d0f70b`), which is what
+V4t explicitly deferred and §5.5.12 item 1 handed forward. `IWorldTextureArray` is
+the seam, and **the slot is what crosses it**: `ObjectMeshManager` used to read
+`BindlessWrapHandle`/`BindlessClampHandle` off the concrete GL array and intern
+them itself, and a 64-bit `ARB_bindless_texture` handle has no Vulkan spelling. The
+array now answers `ResolveSlot(wrapping)` — the GL arm makes the same idempotent
+interning call one level down, the RHI arm returns a pair registered at
+construction — and `ReleaseTextureSlots` replaces the snapshot dictionary the
+manager kept, still running only once physical retirement completes.
+
+Which implementation exists is decided ONCE, by the factory composition builds.
+Everything above the seam is written once: capacity policy, slot allocation, ref
+counting, layer retirement, empty-atlas eviction, and the whole of
+`ObjectMeshManager`'s atlas policy.
+
+Three deliberate differences, each because the backends genuinely differ. BC mip
+chains are CPU-built through V6b's `BlockCompressionMipChain`, since Vulkan cannot
+blit into a compressed image, while RGBA8 uses the device's blit. Filtering lives
+in an immutable sampler rather than a texture parameter, so both address modes are
+registered up front — the same reason the GL array holds two resident handles. And
+**RGB8, A8 and Rgba32f are refused at creation with the reason named.** A8 is the
+one worth recording: the GL array serves it by swizzling R into A, and a Vulkan
+swizzle lives in the image VIEW, which the pinned `GpuTextureDescription` does not
+describe. A silent substitution would render wrong and look like a shader bug.
+Whoever draws world materials on Vulkan either meets a real A8 atlas and extends
+the contract, or proves none exists.
+
+`TerrainAtlas` gained the second construction path V6i drafted and reverted, with
+the decode factored out and shared so both arms read the same DATs in the same
+order with the same resize-to-max policy. `ICompositeTextureArrayBackend` gained
+its RHI arm, which is four small methods because that seam was already a seam.
+
+**The arm is EXERCISED, and that is the point.** The V6i draft was reverted
+precisely because nothing exercised it, and §5.5.12 measured the same failure twice
+over in the descriptor layouts. So the composition host now builds the real terrain
+atlas through `IGpuDevice.CreateTexture` on the arm with no GL context, and creates
+and releases one shared array of each format family plus one composite array at
+startup. Creation only; nothing draws them. Releasing them in the same statement
+covers what a retained bundle would not — that both slot pairs come back and the
+images route through the retirement queue.
+
+**3. The mesh pipeline stops naming a backend** (§5.5.12 item 6). That item
+measured the dependency and found it seven members wide out of a 760-line class: a
+GL context, the retirement queue, the instance VBO, and two capability flags.
+`IMeshPipelineDevice` is exactly that surface, `OpenGLGraphicsDevice` declares it,
+and every member already existed — so the GL arm executes not one changed
+statement. Two casts moved: the `GlGpuDevice` downcast left the constructor for the
+one property that genuinely needs it (the raw-GL renderers' handle table), and the
+atlas factory is selected by `IWorldTextureArrayFactory.For`. **`ObjectMeshManager`
+now constructs against a device with no GL context at all**, which is what
+`MeshPipelineDeviceSeamTests` proves; before this commit the constructor threw a
+cast before running a statement, and that is why `NullWbMeshAdapter` exists.
+
+**What this does NOT claim.** The mesh pipeline does not RUN on Vulkan. Its upload
+bodies are still raw GL — `GlobalMeshBuffer`, the VAO/IBO construction, the layer
+transfers — and they now fail at the site that needs GL, naming the slice that owns
+them, instead of failing at construction. `WbMeshAdapter` still creates an
+`OpenGLGraphicsDevice` in its GL constructor, because there is no second
+implementation to create yet. A reflection test pins the seam's member set so a
+later slice cannot quietly widen it back out.
+
+**Gate results.** Strict GL offline pixel gate against `0ca802cd`: **1.60e-05**,
+**3.20e-05** and **3.02e-05** at the three commits (9, 18 and 17 differing pixels
+of 563,200), every one inside the documented 9–31 px control band and at least 31x
+under the 0.001 threshold. GL connected `tools/run-repeat-connected-gate.ps1
+-Runs 3` at commits 2 and 3: **3/3 RENDERED** on the desktop witness and 3/3 on the
+client capture, both times. One Vulkan composition-host run per commit with
+`VK_LAYER_KHRONOS_validation` **proven inserted by the loader**: zero validation
+errors, zero warnings, no `[shutdown]` diagnostic on either stream, captured frame.
+App tests 4,109 / 3 skips — the 4,086 baseline plus 23. The Vulkan run at commit 2
+built `terrain-atlas` 512x512x33 with 10 mip levels, `terrain-alpha-atlas`
+512x512x8 (4 corner, 1 side, 3 road), RGBA8 64x64x32 at slots 3/4 with 174,720 mip
+bytes blitted, BC1 64x64x32 at slots 5/6 with 696 mip bytes encoded, and a
+composite 32x32x8 at slot 7.
+
+**Two intermittent test failures, filed rather than attributed.** A whole-suite run
+failed `Issue181WallPressEquilibriumTests` once; it passed alone and did not recur
+in five further runs. Seven test classes mutate the same process-global
+`CameraDiagnostics` switches with no xUnit collection isolation, and this slice
+touches no camera, visibility or physics code. Separately, a run of the UNCHANGED
+parent tree failed a zero-allocation test — `#250`'s documented class, which fired
+on three different tests across these runs.
+
+**What the world arm still needs, and it is now exactly items 3–5.** §5.5.12 item
+3's flat-versus-PView question is unchanged and still decides how much of the world
+arm is one slice: the offline gate's scene has buildings, so
+`WorldRenderFrame.ClipRoot` takes the PView path, and `RetailPViewPassExecutor`
+(685 lines) is on the critical path to the first Vulkan Dereth PNG. Beyond that:
+the three world renderers' submission arms, and the mesh pipeline's raw-GL upload
+bodies this slice deliberately left in place.
+
### 5.4 The null-target `BeginPass` divergence (V4c) — must be undone at V6
V4c had to stop GL's `BeginPass` from binding framebuffer 0 when a pass declares
diff --git a/src/AcDream.App/Rendering/Wb/IMeshPipelineDevice.cs b/src/AcDream.App/Rendering/Wb/IMeshPipelineDevice.cs
new file mode 100644
index 00000000..12a43144
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/IMeshPipelineDevice.cs
@@ -0,0 +1,58 @@
+using AcDream.App.Rendering;
+using Silk.NET.OpenGL;
+
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Campaign V slice V6i-2: everything the mesh pipeline actually needs from a
+/// graphics device.
+///
+/// Plan §5.5.10 recorded the blocker plainly: "WbMeshAdapter owns an
+/// OpenGLGraphicsDevice, so it is not constructible on Vulkan" — which is
+/// why NullWbMeshAdapter exists at all. §5.5.12 item 6 then MEASURED how
+/// wide that dependency really is, and the answer is this: a GL context, the
+/// retirement queue, the shared instance VBO, and two capability flags. Seven
+/// members out of a 760-line class.
+///
+/// So the coupling is expressed as an interface at exactly that surface
+/// and declares it — every member already
+/// existed, so the GL arm executes not one changed statement. What this buys is
+/// that ObjectMeshManager and WbMeshAdapter no longer NAME a
+/// backend, which is the prerequisite for the slice that gives them a second
+/// implementation.
+///
+/// What it does not yet buy, stated plainly. is
+/// still a GL type, because the mesh pipeline's upload bodies are still raw GL —
+/// GlobalMeshBuffer, the VAO/IBO construction, and the layer transfers all
+/// speak it directly. Those bodies are the pass-structure work items 3–5 of
+/// §5.5.12's remainder list own. This slice removes the TYPE-level blocker and
+/// names the rest; it does not claim the mesh pipeline runs on Vulkan today, and
+/// being nullable is what will make the remaining sites fail
+/// loudly rather than silently when that arm is written.
+///
+internal interface IMeshPipelineDevice : IDisposable
+{
+ ///
+ /// The GL context, or null on a backend that has none. Every reader is a
+ /// raw-GL upload body awaiting its own port.
+ ///
+ GL? Gl { get; }
+
+ /// Frame-flight-gated release for everything the pipeline allocates.
+ IGpuResourceRetirementQueue ResourceRetirement { get; }
+
+ /// The shared per-instance attribute buffer the legacy draw path binds.
+ uint InstanceVBO { get; }
+
+ /// GL_ARB_bindless_texture. Half of the modern-path gate.
+ bool HasBindless { get; }
+
+ /// GL 4.3 or better. The other half of the modern-path gate.
+ bool HasOpenGL43 { get; }
+
+ /// True while deferred device work is still queued.
+ bool HasPendingWork { get; }
+
+ /// Runs deferred device work. Called once per frame from the render thread.
+ void ProcessQueue();
+}
diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs
index 5e6691ca..c649370e 100644
--- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs
+++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs
@@ -116,7 +116,25 @@ namespace AcDream.App.Rendering.Wb
///
public class ObjectMeshManager : IDisposable
{
- private readonly OpenGLGraphicsDevice _graphicsDevice;
+ ///
+ /// Campaign V slice V6i-2: the graphics device, no longer named by
+ /// backend. See for the measured
+ /// surface this class actually needs, and for what still has to move
+ /// before there is a second implementation of it.
+ ///
+ private readonly IMeshPipelineDevice _graphicsDevice;
+
+ ///
+ /// The GL context this class's still-raw upload bodies write through.
+ /// Null only on a backend with none, where every one of those bodies is
+ /// a programming error rather than a runtime condition — the slice that
+ /// ports them owns deleting this accessor.
+ ///
+ private GL RequireGl() =>
+ _graphicsDevice.Gl
+ ?? throw new InvalidOperationException(
+ "The mesh pipeline's upload bodies are still raw GL and this device has no "
+ + "context. Campaign V's world-draw slice owns porting them.");
private readonly IPreparedAssetSource _preparedAssets;
private readonly ILogger _logger;
@@ -129,9 +147,21 @@ namespace AcDream.App.Rendering.Wb
/// composition wire, because a batch's slot and the table that resolves
/// it must come from the same device by construction.
///
- private readonly AcDream.App.Rendering.Gpu.Gl.GlGpuDevice _worldTextureTable;
+ private readonly AcDream.App.Rendering.Gpu.IGpuDevice _gpuDevice;
- internal AcDream.App.Rendering.Gpu.Gl.GlGpuDevice WorldTextureTable => _worldTextureTable;
+ ///
+ /// Campaign V slice V6i-2: the downcast moved here from the constructor.
+ /// Only the raw-GL world renderers reach this — the Vulkan backend binds
+ /// set 2 and never touches the handle table — so a Vulkan-composed mesh
+ /// pipeline can now be CONSTRUCTED, and only a caller that genuinely
+ /// needs a GL handle table fails, naming why.
+ ///
+ internal AcDream.App.Rendering.Gpu.Gl.GlGpuDevice WorldTextureTable =>
+ _gpuDevice as AcDream.App.Rendering.Gpu.Gl.GlGpuDevice
+ ?? throw new InvalidOperationException(
+ "The GL bindless handle table was requested from a mesh pipeline composed against "
+ + $"the {_gpuDevice.Backend} backend. It is GL-only emulation of the Vulkan texture "
+ + "table and is deleted with the raw-GL world path.");
///
/// Campaign V slice V6i-2: how a shared atlas's physical array is made.
@@ -451,7 +481,7 @@ namespace AcDream.App.Rendering.Wb
// already lives inside AcDream.App or its InternalsVisibleTo test
// assemblies.
internal ObjectMeshManager(
- OpenGLGraphicsDevice graphicsDevice,
+ IMeshPipelineDevice graphicsDevice,
AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice,
IPreparedAssetSource preparedAssets,
ILogger logger,
@@ -461,17 +491,20 @@ namespace AcDream.App.Rendering.Wb
_graphicsDevice = graphicsDevice
?? throw new ArgumentNullException(nameof(graphicsDevice));
ArgumentNullException.ThrowIfNull(gpuDevice);
- // Slice V4t: this class only ever exists on GL — it takes an
- // OpenGLGraphicsDevice — so the backend cast states that fact rather
- // than narrowing anything.
- _worldTextureTable = (AcDream.App.Rendering.Gpu.Gl.GlGpuDevice)gpuDevice;
+ _gpuDevice = gpuDevice;
// Slice V6i-2: which physical array a shared atlas gets is decided
// once, here. Everything below — capacity, slot allocation, ref
// counting, layer retirement, eviction — is written against
// IWorldTextureArray and does not branch on the backend.
- _atlasArrays = new GlWorldTextureArrayFactory(
+ //
+ // Slice V4t downcast gpuDevice to GlGpuDevice HERE, which is what
+ // made a Vulkan-composed mesh pipeline throw before it had run a
+ // statement. The cast now lives on the one property that genuinely
+ // needs it — the raw-GL world renderers' handle table — so
+ // construction itself no longer names a backend.
+ _atlasArrays = IWorldTextureArrayFactory.For(
graphicsDevice,
- _worldTextureTable,
+ gpuDevice,
logger ?? throw new ArgumentNullException(nameof(logger)));
_preparedAssets = preparedAssets
?? throw new ArgumentNullException(nameof(preparedAssets));
@@ -489,7 +522,7 @@ namespace AcDream.App.Rendering.Wb
if (_useModernRendering)
{
GlobalBuffer = new GlobalMeshBuffer(
- _graphicsDevice.GL,
+ RequireGl(),
gpuDevice,
_graphicsDevice.ResourceRetirement);
}
@@ -1951,7 +1984,7 @@ namespace AcDream.App.Rendering.Wb
{
if (meshData.Vertices.Length == 0) return null;
- var gl = _graphicsDevice.GL;
+ var gl = RequireGl();
uint vao = 0, vbo = 0;
var modernIndexBatches = meshData.TextureBatches.Values
.SelectMany(batches => batches)
@@ -2398,7 +2431,7 @@ namespace AcDream.App.Rendering.Wb
return null;
var releases = new List<(string Name, Action Release)>();
- GL gl = _graphicsDevice.GL;
+ GL gl = RequireGl();
if (_useModernRendering)
{
if (data.GlobalAllocation is { } allocation)
diff --git a/src/AcDream.App/Rendering/Wb/OpenGLGraphicsDevice.cs b/src/AcDream.App/Rendering/Wb/OpenGLGraphicsDevice.cs
index 38bf7e1d..1e926865 100644
--- a/src/AcDream.App/Rendering/Wb/OpenGLGraphicsDevice.cs
+++ b/src/AcDream.App/Rendering/Wb/OpenGLGraphicsDevice.cs
@@ -20,7 +20,7 @@ namespace AcDream.App.Rendering.Wb {
///
/// OpenGL graphics device
///
- public unsafe class OpenGLGraphicsDevice : BaseGraphicsDevice {
+ public unsafe class OpenGLGraphicsDevice : BaseGraphicsDevice, IMeshPipelineDevice {
private readonly ILogger _log;
private readonly DebugRenderSettings _renderSettings;
private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue _resourceRetirement;
@@ -34,6 +34,20 @@ namespace AcDream.App.Rendering.Wb {
internal bool HasPendingGLWork =>
!_glThreadQueue.IsEmpty || !_nextGlThreadQueue.IsEmpty;
+ // Campaign V slice V6i-2: IMeshPipelineDevice. Every member below already
+ // existed under a GL-specific name; these are aliases, not behaviour, so
+ // the shipping backend executes exactly the statements it executed
+ // before. See the interface for what the mesh pipeline actually needs
+ // and what still has to move before it has a second implementation.
+ GL? IMeshPipelineDevice.Gl => GL;
+
+ AcDream.App.Rendering.IGpuResourceRetirementQueue IMeshPipelineDevice.ResourceRetirement =>
+ _resourceRetirement;
+
+ bool IMeshPipelineDevice.HasPendingWork => HasPendingGLWork;
+
+ void IMeshPipelineDevice.ProcessQueue() => ProcessGLQueue();
+
public void QueueGLAction(Action action) {
_glThreadQueue.Enqueue(action);
}
diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
index 7cddfa07..075b2ddd 100644
--- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
+++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
@@ -47,7 +47,7 @@ public sealed class WbMeshAdapter
internal const int MaximumReclaimedMeshesPerFrame = MaximumUploadsPerFrame;
internal const long MaximumReclaimedMeshBytesPerFrame = 64L * 1024 * 1024;
internal const int MaximumStaleDiscardsPerFrame = 64;
- private readonly OpenGLGraphicsDevice? _graphicsDevice;
+ private readonly IMeshPipelineDevice? _graphicsDevice;
private readonly ObjectMeshManager? _meshManager;
private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue? _resourceRetirement;
private readonly IPreparedAssetSource? _ownedPreparedAssets;
@@ -495,7 +495,7 @@ public sealed class WbMeshAdapter
if (_disposed) return;
ObjectMeshManager meshManager = _meshManager!;
- _graphicsDevice!.ProcessGLQueue();
+ _graphicsDevice!.ProcessQueue();
// #125: drain staged uploads; a FAILED upload (UploadMeshData returned
// null from its catch) is re-staged for a LATER frame, not dropped. The
// re-stages are collected and re-enqueued AFTER the loop — re-enqueuing
@@ -759,8 +759,8 @@ public sealed class WbMeshAdapter
if (_graphicsDevice is null)
return;
- _graphicsDevice.ProcessGLQueue();
- if (_graphicsDevice.HasPendingGLWork)
+ _graphicsDevice.ProcessQueue();
+ if (_graphicsDevice.HasPendingWork)
{
throw new InvalidOperationException(
$"OpenGL work remains pending after {operation}; retry adapter disposal to continue the exact stage.");
diff --git a/src/AcDream.App/Rendering/Wb/WorldTextureArray.cs b/src/AcDream.App/Rendering/Wb/WorldTextureArray.cs
index 846832ee..eae19197 100644
--- a/src/AcDream.App/Rendering/Wb/WorldTextureArray.cs
+++ b/src/AcDream.App/Rendering/Wb/WorldTextureArray.cs
@@ -101,6 +101,26 @@ internal interface IWorldTextureArray : IDisposable
///
internal interface IWorldTextureArrayFactory
{
+ ///
+ /// Campaign V slice V6i-2: picks the arm from what the composed devices
+ /// actually are. This is the ONE place the mesh pipeline's texture stack
+ /// branches on a backend, which is what lets everything above it — capacity
+ /// policy, slot allocation, ref counting, layer retirement, eviction — be
+ /// written once.
+ ///
+ internal static IWorldTextureArrayFactory For(
+ IMeshPipelineDevice graphicsDevice,
+ IGpuDevice gpuDevice,
+ ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(graphicsDevice);
+ ArgumentNullException.ThrowIfNull(gpuDevice);
+ ArgumentNullException.ThrowIfNull(logger);
+ return graphicsDevice is OpenGLGraphicsDevice gl && gpuDevice is GlGpuDevice table
+ ? new GlWorldTextureArrayFactory(gl, table, logger)
+ : new RhiWorldTextureArrayFactory(gpuDevice);
+ }
+
/// The retirement queue array layers and images are released through.
IGpuResourceRetirementQueue Retirement { get; }
diff --git a/tests/AcDream.App.Tests/Rendering/Wb/MeshPipelineDeviceSeamTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/MeshPipelineDeviceSeamTests.cs
new file mode 100644
index 00000000..6d2fda9b
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Wb/MeshPipelineDeviceSeamTests.cs
@@ -0,0 +1,187 @@
+using System;
+using AcDream.App.Rendering;
+using AcDream.App.Rendering.Gpu;
+using AcDream.App.Rendering.Wb;
+using AcDream.App.Tests.Rendering.Gpu;
+using System.Threading;
+using AcDream.Content;
+using Chorizite.Core.Render.Enums;
+using Microsoft.Extensions.Logging.Abstractions;
+using Silk.NET.OpenGL;
+
+namespace AcDream.App.Tests.Rendering.Wb;
+
+///
+/// Campaign V slice V6i-2: the mesh pipeline no longer names a backend.
+///
+/// Plan §5.5.10 recorded the blocker as a fact about types — "WbMeshAdapter
+/// owns an OpenGLGraphicsDevice, so it is not constructible on Vulkan" — which is
+/// why NullWbMeshAdapter exists. §5.5.12 item 6 measured how wide the
+/// dependency really is: a GL context, the retirement queue, the instance VBO,
+/// and two capability flags. This suite proves the interface at that surface is
+/// load-bearing rather than cosmetic, by building the object graph against a
+/// device that has NO GL context at all.
+///
+/// It deliberately proves construction and nothing more. The upload bodies
+/// are still raw GL and the world renderers still bind a GL handle table; both
+/// belong to the slice that draws Dereth on Vulkan. What matters here is that
+/// each of those now fails at the site that needs GL, naming why, instead of
+/// throwing a cast before the constructor has run a statement.
+///
+public sealed class MeshPipelineDeviceSeamTests
+{
+ /// A device with the mesh pipeline's whole surface and no GL behind it.
+ private sealed class ContextFreeMeshPipelineDevice(IGpuResourceRetirementQueue retirement)
+ : IMeshPipelineDevice
+ {
+ public GL? Gl => null;
+
+ public IGpuResourceRetirementQueue ResourceRetirement { get; } = retirement;
+
+ public uint InstanceVBO => 0;
+
+ public bool HasBindless => false;
+
+ public bool HasOpenGL43 => false;
+
+ public bool HasPendingWork => false;
+
+ public int ProcessedQueues { get; private set; }
+
+ public void ProcessQueue() => ProcessedQueues++;
+
+ public void Dispose()
+ {
+ }
+ }
+
+ private static ObjectMeshManager Build(RecordingGpuDevice device) =>
+ new(
+ new ContextFreeMeshPipelineDevice(device.Retirement),
+ device,
+ new NullPreparedAssetSource(),
+ NullLogger.Instance);
+
+ private sealed class NullPreparedAssetSource : IPreparedAssetSource
+ {
+ public PreparedAssetSourceStats Stats => default;
+
+ public CacheStats DecodedTextureCacheStats => default;
+
+ public PreparedAssetPresence Probe(
+ AcDream.Content.Pak.PakAssetType type,
+ uint sourceFileId) =>
+ PreparedAssetPresence.Missing;
+
+ public PreparedAssetReadResult Read(
+ in PreparedAssetRequest request,
+ CancellationToken cancellationToken = default) =>
+ PreparedAssetReadResult.Missing;
+
+ public void Dispose()
+ {
+ }
+ }
+
+ ///
+ /// The whole point. Before this slice the constructor downcast the RHI device
+ /// to GlGpuDevice, so this threw before running a statement.
+ ///
+ [Fact]
+ public void TheMeshPipelineConstructsAgainstADeviceWithNoGlContext()
+ {
+ using var device = new RecordingGpuDevice();
+ using ObjectMeshManager manager = Build(device);
+
+ Assert.False(manager.IsDisposed);
+ }
+
+ ///
+ /// The GL handle table is the emulation the Vulkan backend replaces with set
+ /// 2, so asking a non-GL pipeline for it is a programming error — and it says
+ /// which backend it was composed against rather than reporting a cast.
+ ///
+ [Fact]
+ public void TheGlHandleTableIsRefusedByNameRatherThanCast()
+ {
+ using var device = new RecordingGpuDevice();
+ using ObjectMeshManager manager = Build(device);
+
+ InvalidOperationException failure =
+ Assert.Throws(() => manager.WorldTextureTable);
+ Assert.Contains("GL-only", failure.Message, StringComparison.Ordinal);
+ Assert.Contains(GpuBackendKind.Recording.ToString(), failure.Message, StringComparison.Ordinal);
+ }
+
+ ///
+ /// The one branch the texture stack keeps: a GL pair yields the GL arm, and
+ /// anything else yields the RHI arm. Selection happens once, at construction.
+ ///
+ [Fact]
+ public void TheArrayFactorySelectsTheRhiArmWithoutAGlPair()
+ {
+ using var device = new RecordingGpuDevice();
+ IWorldTextureArrayFactory arrays = IWorldTextureArrayFactory.For(
+ new ContextFreeMeshPipelineDevice(device.Retirement),
+ device,
+ NullLogger.Instance);
+
+ Assert.IsType(arrays);
+ using IWorldTextureArray array =
+ arrays.CreateClampedArray(TextureFormat.RGBA8, 32, 32, 2);
+ Assert.IsType(array);
+ }
+
+ ///
+ /// The seam's whole value is that it is NARROW — seven members measured out
+ /// of a 760-line class. A later slice that quietly widens it back out would
+ /// re-couple the mesh pipeline to a backend without any other gate noticing,
+ /// so the member set is pinned rather than described.
+ ///
+ [Fact]
+ public void TheDeviceSeamStaysAtTheMeasuredSurface()
+ {
+ string[] members =
+ [
+ .. typeof(IMeshPipelineDevice)
+ .GetMembers()
+ // Property accessors are the same members under another name.
+ .Where(member => member is not System.Reflection.MethodInfo
+ {
+ IsSpecialName: true,
+ })
+ .Select(member => member.Name)
+ .Order(StringComparer.Ordinal),
+ ];
+
+ Assert.Equal(
+ [
+ "Gl",
+ "HasBindless",
+ "HasOpenGL43",
+ "HasPendingWork",
+ "InstanceVBO",
+ "ProcessQueue",
+ "ResourceRetirement",
+ ],
+ members);
+ }
+
+ ///
+ /// Construction touched no GL object at all. The shared mesh arena is the
+ /// only one the constructor would build, and it is gated on the two
+ /// capability flags the interface carries — so a device reporting neither
+ /// leaves it absent rather than dereferencing a null context.
+ ///
+ [Fact]
+ public void ConstructionBuildsNoGlObject()
+ {
+ using var device = new RecordingGpuDevice();
+ using ObjectMeshManager manager = Build(device);
+
+ Assert.Null(manager.GlobalBuffer);
+ // Read-only policy queries still answer, which is what lets streaming
+ // residence accounting keep running on a backend with no world draws.
+ Assert.Equal((0, 0, 0), manager.GetPendingTextureUpdateStats());
+ }
+}