# Campaign V — OpenGL → Vulkan rendering migration **Status:** Active. V0 (pinned RHI contract) landed 2026-07-27. V1 (GL backend implementation, dark) landed 2026-07-27. V2 (shader dialect + texture-index migration on GL, three sub-commits) landed 2026-07-27. **Scope:** Windows x64 + Linux x64. No macOS. **End state:** one Vulkan 1.3 backend; the OpenGL backend is deleted. --- ## 1. Why acdream's mandatory modern GL path (GL 4.3 core + `ARB_bindless_texture` + `ARB_shader_draw_parameters` + MDI + SSBOs, with no fallback) is built on an API that is no longer evolving, and its hardware floor is narrow: bindless textures are absent on Intel integrated GPUs and on every Mesa software / D3D12 stack. That floor is exactly what parked Slice L at its L1 checkpoint — WSLg correctly rejects our renderer because Mesa's D3D12 and llvmpipe drivers do not advertise `GL_ARB_bindless_texture`. Vulkan 1.3 makes the same rendering strategy portable: descriptor indexing (the bindless replacement) is a core feature, not a vendor extension, and it works on RADV, NVIDIA, Intel, and lavapipe. Two secondary wins follow: explicit present control (a direct lead on issue #235's capped/RDP cadence alias) and lower CPU cost per frame, because per-frame data can be written straight into mapped memory instead of copied through `BufferSubData`. **This is a compatibility and efficiency campaign, not a rescue.** The GL path works and is fast. Nothing here changes what the game looks like. --- ## 2. Goal and acceptance Replace the OpenGL renderer with a single Vulkan 1.3 backend, preserving retail pixels exactly and improving cost. | Dimension | GL baseline | Campaign target | Cutover floor | |---|---|---|---| | CPU frame p50 | 1.869 ms | ≤ 1.60 ms | ≤ 1.869 ms | | GPU frame p50 | 1.096 ms | ≤ 1.00 ms | ≤ 1.096 ms | | Working set | 652 MiB | ≤ 600 MiB | ≤ 652 MiB | | Private set | 928 MiB | ≤ 860 MiB | ≤ 928 MiB | | Managed alloc / frame | ~0 B | 0 B | 0 B | | CPU/GPU p99 | measured at V8 | ≤ GL p99 | ≤ GL p99 | Pixel acceptance: `dotnet AcDream.Cli.dll compare-screenshots expected.png actual.png out.json` at channel tolerance 2 and maximum differing fraction 0.001, MSAA off, `ACDREAM_DAY_GROUP` pinned, at every deterministic checkpoint of the connected lifecycle route. **Out of scope (user decision, 2026-07-27):** the ImGui developer stack (`AcDream.UI.ImGui`, `ImGuiBootstrapper`, the DevTools menu bar) is not ported, and UI Studio (`StudioWindow`, `PanelFbo`) is parked. Both are deleted at V11 and remain recoverable from git. A follow-up issue tracks re-homing the Settings and Debug panels onto the retained UI through a new `IPanelRenderer` implementation — the panels themselves need no rewrite because they already target only `AcDream.UI.Abstractions`. Until that lands, keybind remapping falls back to editing `keybinds.json`. --- ## 3. Architecture: a Vulkan-shaped RHI, implemented by GL first ### 3.1 The decision The renderers port **one at a time onto a minimal Vulkan-shaped RHI while GL is still the shipping backend**. Each port slice is pixel-gated against its parent commit on the *same* backend, so a divergence is attributed to one slice rather than discovered at a big-bang integration. Only then does the Vulkan backend implement the same interfaces, gated by a GL-versus-Vulkan differential. The alternatives were rejected for concrete reasons: - **Per-renderer duplication** (`WbDrawDispatcherVk` beside the GL one) would fork roughly 15,000 lines of CPU logic — bucketing, `RetailAlphaQueue` ordering, LRU eviction, arena management — that has nothing to do with the graphics API and everything to do with retail fidelity. `WbDrawDispatcher` is 4,449 + 809 lines but holds only ~62 GL call sites, clustered in the per-frame uploads, the two multi-draw brackets, and teardown. The API surface is small; the fidelity logic is large. Forking the wrong one of those is how subtle regressions enter. - **A serialized command IR** adds a third representation and a per-frame translation cost, against the efficiency goal, for generality nothing asked for. The RHI *is* the prepared-frame-data seam, expressed as typed calls. GL 4.3 implements every Vulkan-shaped concept cheaply: pipelines become a program bind plus a cached state apply; ring allocations sit on the existing fence-bounded dynamic buffers; a descriptor-table index becomes an indirection through a storage buffer of bindless handles; passes are a no-op bracket. **The GL backend is deliberately behaviour-preserving and never improved** — it keeps `BufferSubData` — which is precisely what makes each port slice's pixel gate a strict identity check. The efficiency wins land in the Vulkan backend only. ### 3.2 Location and isolation Namespaces inside `AcDream.App`, not a new project: - `AcDream.App.Rendering.Gpu` — the contract (landed at V0) - `AcDream.App.Rendering.Gpu.Gl` — GL backend (deleted at V11) - `AcDream.App.Rendering.Gpu.Vk` — Vulkan backend A separate project would force a public surface or `InternalsVisibleTo` churn for twelve `internal` renderers, and its only benefit — compile-time proof that renderers cannot reach GL — arrives anyway at V11 when the `Silk.NET.OpenGL` package reference is dropped. Until then the guarantee comes from an architecture test added at V4h that asserts no type outside `Gpu.Gl` and a small allowlist references `Silk.NET.OpenGL`. Deletion at cutover is one directory and one `PackageReference`. ### 3.3 The contract (pinned at V0) `src/AcDream.App/Rendering/Gpu/`: | Type | Responsibility | |---|---| | `IGpuDevice` | Resource creation, the global texture table, frame lifecycle, the deferred device-action queue (replaces `QueueGLAction`), backbuffer capture, retirement queue. | | `IGpuFrame` | One frame: ring allocations, `BeginPass`, submit/present on `End`. | | `IGpuPassEncoder` | Records one pass: bind pipeline/buffers, push constants, dynamic cull/front-face/depth-write, viewport/scissor, `Draw`, `DrawIndexed`, `MultiDrawIndexedIndirect`, timer scopes. | | `GpuRingAllocation` | A `ref struct` slice of the frame's upload ring: buffer, aligned offset, CPU-writable span. Replaces every per-frame `BufferSubData`. | | `IGpuBuffer` / `IGpuTexture` / `IGpuSampler` | Resources. Disposal routes through the retirement queue, never freeing under a live frame. | | `IGpuPipeline` + `GpuPipelineDescription` | Shader pair plus all state Vulkan bakes: blend, depth, cull default, front face, alpha-to-coverage, topology, sample count. | | `GpuPassDescription` | Attachments with load/store ops, clear values, sample count, resolve. | | `IGpuRenderTarget` | Offscreen colour(+depth) whose colour is sampleable after the pass. | | `IGpuTimerPool` | GPU timings from retired frames. | | `GpuCapabilityRecord` | Backend-neutral capability view; computes `SupportFailures`, feeding the existing exit-code-4 contract. | | `GpuTextureSlot` | Index into the global texture table — the backend-neutral replacement for a bindless handle. | | `RecordingGpuDevice` (in the test project) | In-memory double: records calls in order and backs ring allocations with real memory, so renderer tests run with no GPU. | **Design notes worth keeping in mind while implementing:** - `GpuTextureSlot.Unassigned` is a loud sentinel (`uint.MaxValue`), never a usable slot, and must never reach a shader. Renderers needing a fallback take `IGpuDevice.DefaultTextureSlot`, a really registered 1×1 white texture. This is deliberate: silently resolving an unset index to slot 0 is the failure mode that produced the magenta 1×1 UI placeholder bug. - `GpuRingAllocation` is a `ref struct` so the compiler forbids storing it past the frame that owns the memory. - Renderers always speak GL conventions for winding and viewport origin. The Vulkan backend renders with a negative viewport height and inverts front-face in exactly one mapping function. No renderer performs that flip itself. ### 3.4 The binding model (`GpuBindingModel`) Dual-legal for GL GLSL and Vulkan GLSL, exploiting `GL_KHR_vulkan_glsl`'s rule that an omitted `set` qualifier means set 0. - **set 0** — storage buffers, bindings 0–8 exactly as the shaders declare them today (instances, batches, clip regions, clip slots, global lights, instance light sets, instance indoor, instance alpha, selection lighting), plus **binding 9 = texture table**, which is the GL-only emulation (a buffer of `uvec2` bindless handles) and is deleted with the GL backend. - **set 1** — uniform buffers. `SceneLighting` keeps `binding = 1`. Today `mesh_modern` relies on GL keeping SSBO and UBO binding tables separate so the `BatchBuffer` SSBO and the `SceneLighting` UBO can both be binding 1. Vulkan has one binding namespace per set, so moving UBOs to their own set preserves both numbers and removes the collision. - **set 2** — the global sampled-texture descriptor array: variable count, partially bound, update-after-bind, capacity 16384. - **Push constants** — one shared 96-byte `GpuPushConstants` block (of the 128 Vulkan guarantees): `ViewProjection`, `DrawIdOffset`, `LightingMode`, `RenderPass`, `LightDebug`, `TextureIndexA/B`, two spare scalars. One shared block means one pipeline layout, so switching pipelines mid-pass does not invalidate bound descriptors. The GL backend maps each field to the correspondingly named uniform and skips those a program does not declare. `BatchData`'s `uvec2 textureHandle` becomes `uint textureIndex` plus a pad word at V2. The 16-byte std430 stride is unchanged, so every existing CPU writer keeps its offsets. **That single change is what makes the CPU-side data model backend-neutral, and it lands on GL, pixel-gated, long before Vulkan exists.** --- ## 4. Vulkan technical decisions ### 4.1 Floor Vulkan 1.3 core plus `VK_KHR_swapchain` (and the platform surface extensions). Optional and never required: `VK_EXT_memory_budget` (telemetry), `VK_EXT_debug_utils` (object naming in dev builds), `VK_KHR_present_wait` (an issue #235 experiment). Required device features, each with a reason: | Feature | Why | |---|---| | `multiDrawIndirect` | The three MDI dispatch sites are the entire draw architecture. | | `drawIndirectFirstInstance` | Indirect commands carry a non-zero `firstInstance` as the per-group instance base. | | `shaderDrawParameters` | `gl_DrawID`. Resets per `vkCmdDrawIndexedIndirect` exactly as GL's does, so the issue #52 `uDrawIDOffset` pattern carries over unchanged. | | `shaderClipDistance` (≥ 8) | Phase U.3's per-cell screen-space clip gate. | | `textureCompressionBC` | DXT1/3/5 DAT surfaces upload as BC1/2/3 with no transcode. | | `samplerAnisotropy` | Sampler-quality parity. | | `timelineSemaphore` | One monotonic serial replaces the GL fence array; the existing retirement ledger keeps its serial keys. | | `hostQueryReset` | Reset timestamp pools from the CPU instead of burning command-buffer calls. | | descriptor-indexing set (`runtimeDescriptorArray`, `descriptorBindingPartiallyBound`, `…SampledImageUpdateAfterBind`, `…UpdateUnusedWhilePending`, `…VariableDescriptorCount`) | The global texture table replacing bindless handles. | | `dynamicRendering`, `synchronization2`, `maintenance4` | No render-pass/framebuffer objects; barrier2; relaxed shader interface rules. | Explicitly **not** required: `bufferDeviceAddress` (every buffer is descriptor bound; it would buy nothing and costs capture-tool compatibility), any compute/geometry/tessellation feature (acdream has no such shaders), `fillModeNonSolid` (debug lines use `LINE_LIST` topology). Limits to assert in the probe: `maxPushConstantsSize ≥ 128`, `timestampComputeAndGraphics`, `maxDescriptorSetUpdateAfterBindSampledImages ≥ 16384`, `maxPerStageDescriptorUpdateAfterBindSampledImages ≥ 16384`. ### 4.2 Bindings layer `Silk.NET.Vulkan` + `Silk.NET.Vulkan.Extensions.KHR` at **2.23.0**, matching the pinned Silk family. It is blittable-struct and function-pointer based, so with `stackalloc`/`fixed` for the small arrays passed to submits and barriers it allocates nothing per frame — which the 0 B/frame target requires. **No VMA dependency.** Silk does not ship it, third-party .NET bindings are a native-binary and maintenance liability across win-x64/linux-x64/CI-lavapipe, and acdream's allocation profile is tame: two mesh arena buffers, one staging ring, a handful of per-frame buffers, ~4 render targets, and a texture pool. A custom allocator (~400 lines, first-fit free list over 128 MiB device-local blocks per memory type, dedicated allocations at ≥ 32 MiB) keeps `vkAllocateMemory` counts two orders of magnitude below the limit and plugs straight into `GpuMemoryTracker` for exact accounting, which VMA would obscure. ### 4.3 Memory - **Mesh arena** — two `DEVICE_LOCAL` buffers mirroring `GlobalMeshBuffer` exactly: 384 MiB vertex, 128 MiB index (`VK_INDEX_TYPE_UINT16`; the existing cap is already expressed in `sizeof(ushort)`). Keep the reclaimable-range allocator, growth quanta, budgeted incremental grow-and-copy (now `vkCmdCopyBuffer`), retirement-ledger deletes, and the 896 MiB dual-generation ceiling. - **Staging ring** — one persistently mapped `HOST_VISIBLE|COHERENT` buffer (48 MiB), watermarked per flight slot, recycled when the slot retires. Oversized uploads take a temporary dedicated buffer retired through the ledger. - **Per-frame data** — the CPU win. Each MDI renderer gets, per flight slot, one persistently mapped buffer holding its instance/batch/clip/light/indoor/alpha/ selection sections at fixed aligned offsets, plus indirect commands and the SceneLighting block. Prefer `DEVICE_LOCAL|HOST_VISIBLE` (ReBAR — present on the RX 9070 XT, RADV, and modern NVIDIA), fall back to `HOST_VISIBLE|COHERENT`. The bucketing code writes structs **directly into mapped memory**; today's write-to-array-then-`BufferSubData` (driver validation, copy, rename tracking) simply stops existing. - **Textures** — device-local pool. Formats stay UNORM (`BC1/2/3_UNORM`, `R8G8B8A8_UNORM`, `R8_UNORM`), matching GL exactly. There is no sRGB anywhere in the pipeline — not on upload, not in the shaders, not at the framebuffer (V3 audit, §4.10). 2D arrays are allocated full-size and filled incrementally, mirroring `ManagedGLTextureArray`. - **Mip generation** — DAT surfaces ship no mips. Uncompressed formats get a `vkCmdBlitImage` chain at upload. **BC formats cannot be blit targets**, so their chains are built on the CPU at decode time (box filter + a small managed BC encoder, deterministic and unit-testable) — which also replaces today's driver-defined behaviour for `glGenerateMipmap` on compressed arrays. Escape hatch if encoder quality ever trips the pixel gate: store the affected textures as RGBA8 and blit their mips. ### 4.4 Descriptors Two persistent sets, one shared pipeline layout, **zero descriptor writes per frame**. - **Set 0** — one `COMBINED_IMAGE_SAMPLER` binding, 16384 variable count, `PARTIALLY_BOUND | UPDATE_AFTER_BIND | UPDATE_UNUSED_WHILE_PENDING`, fragment stage. A slot is a (view, sampler) pair — exact parity with bindless handles, which are also per texture+sampler. Registration appends one descriptor write; eviction returns the slot to a free list gated on frame retirement, and the slot is defensively overwritten with a dummy before reuse. This removes the entire `MakeTextureHandleResident` churn. - **Set 1** — per-renderer, per-flight-slot storage buffers at the nine `GpuBindingModel` bindings plus the SceneLighting UBO, all pointing into that renderer's mapped per-slot buffer at fixed offsets. Written once at startup; rewritten only when a buffer grows, gated on that slot's retirement. Bindings a given renderer does not use still bind a shared dummy range so there is one layout and no permutations. ### 4.5 Pipelines Core 1.3 dynamic state covers viewport, scissor, cull mode, front face, depth test/write/compare, stencil test/ops, and topology class — which folds the GL pass matrix's cull/depth-mask/stencil toggles into command-time calls. Blend and alpha-to-coverage are **not** dynamic, so they define the pipeline list: mesh opaque / alpha / additive, terrain, sky, particle alpha / additive, particle-mesh alpha / additive, debug line, UI text, plus offscreen variants only where the target's format or sample count differs. **Expect 11–14 pipelines.** All are known statically and **built at startup** against a `VkPipelineCache` persisted to `ApplicationPathSet.CacheDirectory` (validated by header UUID). First launch pays a few hundred milliseconds once; later launches are milliseconds, and no frame ever compiles — which also removes GL's hidden first-draw driver-recompile hitches. Depth/stencil: prefer `D32_SFLOAT_S8_UINT`, fall back `D24_UNORM_S8_UINT`. The stencil aspect is required by #117's portal punch. ### 4.6 Shaders The eight GLSL pairs stay the single source of truth. Vulkan-dialect changes: `set`/`binding` qualifiers per §3.4; `texture(uTextures[nonuniformEXT(idx)], …)` replacing the bindless `sampler2DArray(handle)` reconstruction; `gl_DrawIDARB` → `gl_DrawID`; `gl_BaseInstanceARB + gl_InstanceID` → `gl_InstanceIndex` (Vulkan's already includes `firstInstance`); the loose uniforms move into the push-constant block. std430 SSBO layouts, the std140 SceneLighting block, and `gl_ClipDistance[8]` port byte-identically. `nonuniformEXT` is **required, not optional**: within one MDI dispatch different draws read different `Batches[]` entries, and "dynamically uniform" is defined over the whole dispatch on some implementations. The qualifier costs nothing measurable on RDNA or NVIDIA and removes a class of silent corruption. **Compilation: committed `.spv` artifacts** produced by `tools/compile-shaders.ps1` (glslang/glslc), plus a test that hashes the GLSL sources into a committed manifest and fails when they drift. CI runners have no Vulkan SDK, and runtime shaderc would add a native dependency and startup cost for shaders that never change at runtime. ### 4.7 Clip space — no projection change needed **Verified:** acdream's cameras already build projections with `Matrix4x4.CreatePerspectiveFieldOfView`, which is D3D convention with NDC z in [0, 1] — documented at `src/AcDream.App/Rendering/PortalProjection.cs:12-13`, where the GL-convention near test was previously a real bug. Vulkan's clip convention *is* [0, 1], so the matrices are consumed as-is. The GL path has been compressing [0, 1] clip z into the upper half of the depth buffer, so Vulkan doubles effective depth precision for free. Consequence to expect at V7: **window-space depth values shift, so z-fight patterns on near-coplanar retail geometry may differ.** This is the one pre-approved divergence class; each instance gets a compare mask or a per-stop relaxation plus a divergence-register row. Y-flip is handled by a negative viewport height (core since 1.1), which keeps winding and cull semantics identical to GL. Reversed-Z remains an easy future option and is explicitly not required for parity. ### 4.8 Sync and the frame Two frames in flight; one primary command buffer per frame from a per-slot `vkResetCommandPool`; no secondary buffers (single render thread); one graphics+present queue with transfers riding it (an async transfer queue is a deferred option, not a need — uploads are already budget-throttled). Per-slot binary acquire semaphores, per-image binary render-done semaphores, and **one timeline semaphore whose value is the frame serial** — so `GpuFrameFlightController` ports almost mechanically, its `SortedDictionary>` retirement ledger keeping its keys. Frame skeleton (synchronization2 throughout): wait timeline ≥ serial − 2 → run retirements → reset pool → write per-frame data into mapped slot buffers → acquire → record [uploads: copies, one batched image barrier to `TRANSFER_DST`, copies/blits, one batched barrier to `SHADER_READ_ONLY` plus a buffer barrier to vertex/indirect stages] → [offscreen passes] → [main pass: MSAA colour `CLEAR/DONT_CARE` resolving to the swapchain, transient depth `CLEAR/DONT_CARE`, sky → terrain → entities → envcells → particles → weather → UI] → [optional screenshot copy] → barrier to `PRESENT_SRC` → submit → present. Budget: roughly 4–6 batched `vkCmdPipelineBarrier2` calls per frame. ### 4.9 Swapchain, present, pacing Surface through Silk windowing (`GraphicsAPI.DefaultVulkan`, `IWindow.VkSurface`) so the existing GLFW platform selection, `ACDREAM_DISPLAY_PROTOCOL`, and window lifecycle are unchanged. Format **`B8G8R8A8_UNORM`** — see §4.10, this was corrected at V3 and is the single highest-severity finding of the audit. Screenshots swizzle BGRA→RGBA on the CPU to preserve `FrameScreenshotController`'s RGBA byte contract. Present modes: `FIFO` when VSync is on; `IMMEDIATE` preferred then `MAILBOX` when off, with `FramePacingController` and its platform waiters continuing to drive the software cap. `OUT_OF_DATE` recreates immediately, `SUBOPTIMAL` at the next frame boundary, both through `FramebufferResizeController`. ### 4.10 V3 audit findings (2026-07-27) Slice V3 audited every projection producer, every depth-range assumption, the clip-plane derivation, the sRGB path, and MSAA control. The central claim held — **but one plan assumption was wrong, and catching it is why V3 exists.** **Confirmed.** Every projection that reaches a shader is built by `Matrix4x4.CreatePerspectiveFieldOfView` (world, portal tunnel, paperdoll, appraisal cameras; terrain, mesh, particles, debug lines and sky all consume the same matrices). There are **no orthographic projections in production code at all** — the retained UI's `ui_text.vert` converts pixel coordinates straight to NDC with a constant `z = 0`, so V4a has no matrix to convert, only a Y-sign to check. So: no projection rework, exactly as designed. `SkyProjection.WithDepthRange` is the only hand-written matrix edit, assigning `M33`/`M43` directly. It re-derives the *same* D3D-convention near/far mapping (it even throws on an orthographic input) rather than a GL-style `2/(f-n)` scale — correct, but the sharpest edge in the codebase and a required cross-check at V6. Phase U.3's clip planes are derived and consumed entirely in clip space with `plane.z` always 0, so they are insensitive to both the depth convention and the viewport Y flip. No change needed. **Corrected — sRGB.** The plan previously specified a `B8G8R8A8_SRGB` swapchain "matching the GL `FramebufferSrgb` contract." That contract does not exist. `EnableCap.FramebufferSrgb` is enabled only inside the throwaway 2×2 capability probe (`GraphicalGlFunctionProbe.cs:419-429`) and disabled immediately; it is never enabled on the real backbuffer. No texture is uploaded in an sRGB internal format (`TextureFormatExtensions` has none), and no shader performs any gamma conversion. The renderer is plain UNORM end to end. **The correct Vulkan swapchain format is `B8G8R8A8_UNORM`**; shipping `_SRGB` would have applied an unwanted encode to already-display-space values — a global brightening across every frame, and precisely the failure mode §6 lists as "cannot pass silently." It would have passed silently right up to V7. Separately: the capability gate *requires* sRGB-framebuffer support that the renderer never uses. Harmless today, but the Vulkan gate must not carry the stale requirement forward. **MSAA.** `ACDREAM_MSAA_SAMPLES` overrides the quality preset (`QualityPreset.cs:43-59`) and `0` forces MSAA off, but it is read at window creation and cannot change mid-session. The V7 differential script must therefore *launch* both backends with `ACDREAM_MSAA_SAMPLES=0` rather than toggling a setting. **Two concrete acceptance items carried to V6/V7.** 1. **Scissor Y convention.** `NdcScissorRect.ToPixels` emits GL bottom-left-origin pixel rectangles. Vulkan's `vkCmdSetScissor` is always top-left-origin — the negative viewport height does *not* flip the scissor. The contract already says callers keep GL convention and the backend converts (`IGpuPassEncoder.SetScissor`), so the Vulkan encoder must do that flip. A scissored aperture — a doorway — is the right differential-gate target. 2. **`FrustumCuller` near plane** extracts `col4 + col3`, the GL `[-1,1]` Gribb- Hartmann formula, against `[0,1]`-convention matrices; the correct extraction is `col3` alone. Proven over-inclusive rather than over-culling, so it is not a visibility bug, and it is pure CPU math untouched by the backend swap. Filed as a tracked issue rather than fixed here — it is not Campaign V's scope. ### 4.11 Capability gate Mirrors the GL three-layer shape exactly — passive record, **active** probes, an `Evaluate` that throws `NotSupportedException` → `Program.cs` exit code 4 → an atomic `graphical-capabilities.json`. The Vulkan active probe is stronger than the GL one: it creates the real device with the production feature chain, builds the real descriptor layouts and one real pipeline from the committed `.spv`, and renders a 64×64 offscreen triangle sampling a table slot, then reads the pixels back. Device selection: discrete > integrated > virtual > CPU, tie-broken by largest device-local heap, with an `ACDREAM_VULKAN_DEVICE` override recorded in the report. --- ## 5. Slice sequence Every slice ends with `dotnet build` and the App test suite green, its gate passed, and one commit. GL remained the default backend through V9; all Vulkan work was dark behind `ACDREAM_RENDER_BACKEND` (default `gl`). **Slice V10 inverted that**: the default is now `vulkan`, and `ACDREAM_RENDER_BACKEND=gl` is the escape hatch for one slice — see §5.5.23, and note that the cutover is committed but not yet signed off. **Run the suite in Release: `dotnet test … -c Release`.** Some tests assert Release-only behaviour and legitimately fail in Debug — `LandblockBuildOriginTests.FarLoad_StripsEnvCellsAndPhysicsEvenWhenEntityListIsAlreadyEmpty` covers the far-tier strip that `LandblockStreamer.cs:505` deliberately turns into a loud `Debug.Assert` in Debug builds. A Debug run therefore shows one failure that is neither a regression nor yours. The V0 baseline is **3,785 passed / 3 skipped** in Release (3,763 pre-campaign plus 22 contract tests). ### 5.1 The offline pixel gate "Pixel gate" means `tools/run-offline-pixel-gate.ps1`: capture at the parent commit, capture at slice HEAD, compare with the `compare-screenshots` CLI at tolerance 2 / fraction 0.001. The client is launched **without `ACDREAM_LIVE`**, so it renders the world straight from the DATs. No session is created and no ACE state can be disturbed, which means this gate runs unattended — it needs neither the live server nor the user. That matters: seven slices (V2, V4a–V4g) are renderer ports whose whole acceptance criterion is "no pixel changed." ``` tools/run-offline-pixel-gate.ps1 -Out artifacts/gate-base # at the parent commit tools/run-offline-pixel-gate.ps1 -Out artifacts/gate-head -Baseline artifacts/gate-base ``` **Determinism was measured, not assumed.** Two captures at the same commit initially differed in 0.29% of pixels — far above the 0.001 threshold. The differences were confined to the top ~180 rows: the sky animates (clouds scroll, the sun moves) and the Dereth clock advances with wall time, so two launches can never agree there. Everything below the horizon was stable. With the top 280 rows masked, two independent same-commit pairs differ by **15 and 17 pixels out of 563,200 compared** — a fraction of 0.000027, roughly a 33× margin under the threshold. The gate is a strict identity check on everything it covers, rather than a loose tolerance that would hide real regressions. **Noise band re-measured 2026-07-28**, after the capture began resolving the multisampled default framebuffer instead of reading it through an unspecified operation (see §5.5). Two fresh same-commit control pairs — one at `fed636b9`, one at the resolve commit — differ by **17 and 23 pixels**, fractions `3.02e-05` and `4.08e-05`. The change itself measured `4.08e-05` against `fed636b9`, i.e. exactly its own same-commit control and therefore indistinguishable from ambient noise. The band is now **15–23 differing pixels, fraction ≤ 4.1e-05**, a ~24× margin under the 0.001 threshold. Two facts are worth keeping: the resolve moved essentially nothing in this scene, which says AMD's unspecified read was usually returning the resolved image already; and "usually" is exactly the property that makes an unspecified read useless as an instrument. **The sky has two clocks, and V7 pinned the second one.** `ACDREAM_SKY_PHASE_SECONDS` (V7, `RuntimeOptions.SkyAnimationPhaseSeconds`, consumed by `SkyRenderer.AnimationPhaseSecondsOverride`) replaces the wall-clock elapsed seconds that `TexVelocityX/Y` accumulate against with a fixed value. **Unset — the default, and every ordinary run — keeps the wall clock**, so nothing the user or the offline gate sees changes unless a gate asks. It exists because `ACDREAM_DAY_GROUP` and the `AcdreamCycleTimeOfDay` override pin only the *other* sky clock: the Dereth date, which chooses the day group, the keyframe and the sun angle. The cloud sheet does not read that clock at all and is not supposed to — retail's clouds drift with real time regardless of the date — so a route that pins the world clock still cannot make two launches agree about where the clouds are. That is the whole reason this gate masks its top 280 rows, and the V6m smoke pair measured the same population costing **89% of an 18.52% whole-frame GL-versus-Vulkan difference**. Pinning the phase is instrument determinism rather than a workaround, on the same footing as `ACDREAM_DAY_GROUP`: it is one input to a UV offset, it is off by default, and no shipping code path reads it. The alternative was `-MaskTopPixels`, which would have permanently blinded the campaign's strictest instrument to the whole sky — one of the five surfaces the offline gate already cannot see. **The backend differential gate forces it on both launches**; the offline gate keeps its mask, because a same-commit GL pair has other reasons to disagree up there (the sun moves with the Dereth clock, which that gate does not pin). **Coverage.** Terrain and terrain blending, scenery, static world meshes, water, fog, and the entire retained UI (vitals, spell bar, toolbar, chat, radar). **Not covered — these still need a user visual gate:** sky (masked), EnvCell interiors, particles, and the paperdoll/appraisal viewports, because the offline scene is a fixed outdoor view with no camera control. **Accumulated user-gate debt.** Each of these landed with its automated gate green but part of its surface unproven. They should be checked together, in one connected session, rather than one at a time: | Slice | What the offline gate could not prove | |---|---| | V2c | Particle texture-index migration — no particles in the captured scene | | V4c | **`EnvCellRenderer` — zero EnvCell activity in the capture.** Dungeon interiors are half of that slice and are entirely unproven. Also the paperdoll/appraisal/portal-tunnel views, which is precisely what §5.4's `BeginPass` change protects | | V4e | Particles (again) | | V4f | Sky — deliberately masked for determinism | | V4g | Paperdoll and appraisal viewports, portal transit | | V6d | The paperdoll/appraisal viewport sprite. It is the one retained-UI texture the gate's scene never draws, and V6d changed how every UI texture is sampled — from a bound texture unit to a table slot. The seam that registers it (`GlGpuDevice.RegisterExternalColorTexture`) is unchanged and its handle is now simply encoded rather than resolved back to a GL name, but that path is unproven by anything automated. Check it with the dungeon/portal pass above rather than on its own | | V6e | **Particles, again — and now sky.** The particle half is V2c's and V4e's debt restated: the offline scene draws no particles, so nothing automated saw the varying retype or the ACDREAM_TEXTURE_NONE sentinel. The sky half is new and larger: V6e moved a dozen loose uniforms into a `SkyParams` uniform buffer and moved the sky's texture from a bound unit-0 texture-plus-sampler to a bindless (texture, wrap) table slot, and the gate masks the sky band for determinism. What WAS checked, and should be read as bounding the risk rather than closing it: a base-versus-head offline capture at **all seven day groups**, matching in gradient, cloud sheet, horizon band and fog on every one — including day group 2's salmon cloud band and day group 6's green band, which exercise texture sampling, tint, blend and fog together — plus 3/3 RENDERED on the desktop-witness repeat gate. **What remains unproven is pixel-exactness and the parts of the dome the fixed outdoor camera cannot see**: the sun and moon (additive surfaces high in the sky) and the rain cylinder, which is the one sky mesh that surrounds the camera and the one whose REPEAT wrap mode is most visible. Stand outside at dawn or dusk, and stand in rain | | V6k | **The sky band and the paperdoll — both CHECKED rather than banked, and recorded here so the next slice does not re-open them.** Commit 1 carries a seven-day-group before-and-after comparison on GL (V6e's method) because the gate masks the sky; commit 2 carries a connected run that presses `ToggleInventoryPanel` and captures the doll through the new render target (`artifacts/v6k-paperdoll`). **What remains uncovered:** the creature-appraisal viewport, which is the same class and the same code as the paperdoll but was not itself driven; and the sun, moon and rain cylinder, which V6e already filed and a fixed outdoor camera at one time of day still cannot see | | V6l | **The Vulkan paperdoll and a particle effect — both CHECKED rather than banked, and both connected, because the offline gate reaches neither.** The doll capture (`artifacts/v6l-vk-paperdoll3` against `artifacts/v6l-gl-paperdoll`) is also the no-regression check for making the viewport's V orientation backend-derived. The particle capture (`artifacts/v6l-vk-poi` against `artifacts/v6l-gl-poi`) is Holtburg's forge plume and glint field, cropped 4× at `artifacts/crop-vk-glow.png` / `crop-gl-glow.png`. **What remains uncovered:** the creature-appraisal viewport, which is the same class and the same code as the paperdoll but was again not itself driven — V6k's half-discharge, carried forward unchanged; and the portal depth mask, which drew no pixel in either capture because neither run entered a building aperture. Check it with the dungeon/doorway pass above | | V6m | **The portal tunnel, the creature-appraisal viewport and an interior EnvCell — all three CHECKED, connected, on BOTH backends, because the offline gate reaches none of them.** `artifacts/v6m-gl-tunnel` versus `artifacts/v6m-vk-tunnel`: ten transit frames, the examination window over a Brown Rabbit, and the Facility Hub's interior. **This discharges V6k's and V6l's carried appraisal-viewport half-discharge** — the view that shares the paperdoll's class and code has now been driven itself. **What remains uncovered:** the portal DEPTH MASK still drew no pixel, because none of these runs stood in a building aperture either; and the interior pair is eyes-on only, because the indoor spring-arm camera settled to different distances in the two runs (31.2% differing, a route defect rather than a renderer one — see §5.5.18) | | V7 | **The portal depth mask, for the third slice running, and now a numeric interior instead.** V7 fixed the interior stop by pinning the world clock rather than by touching the route (§5.5.19), so `facility_hub_interior` has a numeric GL-versus-Vulkan pair at 7.79e-03 whose entire residual is the player character — the EnvCell itself is clean, which discharges V6m's item 2. **What remains uncovered is unchanged and now three slices old:** no automated run has stood in a building aperture, so `PortalDepthMaskRenderer` has still never drawn a pixel outside an eyes-on session. `HouseExitWalkReplayTests` names the cheapest target — the Holtburg corner building, cell `0xA9B40170` — whose exit door it already resolves from the DAT | | V6f | **Terrain seen through a doorway clip region.** The offline gate covers terrain heavily — blending, road overlays and the water edge are most of the frame, and every one of those samples goes through `terrainTiling()`, so the std140 stride and the new `ACDREAM_SAMPLE_ARRAY` reads are well proven. What it cannot see is the one terrain path with its own binding: the clip UBO at binding 2, exercised when terrain is viewed through a doorway. Binding 3 now sits beside it and is rebound per draw, so a bind-order mistake would show exactly there. Check it with the dungeon/doorway pass above | **The user confirmed on 2026-07-27 that the local ACE server is always available and they will verify visually on request.** That converts this table from deferred debt into a real gate, and it should be used rather than banked: a slice whose uncovered surface is checked while the change is fresh costs minutes, whereas the same defect found at the V7 differential is a bisect across a dozen commits. The checklist, in the order that exercises the most per minute: | Look at | Proves | |---|---| | A dungeon interior — walk in, look along a corridor and through a doorway | `EnvCellRenderer`, the per-cell clip gate, portal visibility. **Half of V4c, currently unproven by anything.** | | A portal transit | The portal tunnel presentation and the depth/stencil mask | | The paperdoll, then examine an item | The two offscreen viewports — and §5.4's `BeginPass` change exists precisely to keep these off the backbuffer | | Cast a spell | Particles, and the inverse-alpha blend added at V4c | | Stand outside at dawn or dusk | Sky, which the offline gate masks for determinism | Worth noting: **no connected route used to visit an interior, and one does now.** Every stop in `connected-r6-soak.route.txt` is outdoor, which is why EnvCell coverage was missing from the automated gates too and not just the offline one. V6m added the interior stop to the new `connected-backend-differential.route.txt` — the Facility Hub's cell `0x164`, which is `>= 0x100` and therefore indoor. Two corrections to the earlier claim, both worth keeping straight: `connected-world-lifecycle.route.txt` has in fact carried a Facility Hub stop all along, so the lifecycle gate did *reach* an interior even though nothing compared its pixels; and reaching one is not the same as being able to compare it, because the indoor spring-arm camera settles to a slightly different distance per run (§5.5.18). MSAA is left at the quality preset for GL-versus-GL self-differentials, where it is deterministic. The V7 GL-versus-Vulkan differential must force MSAA off, because sample positions are not specified across implementations. | Slice | Scope | Gate | |---|---|---| | **V0** ✅ | Pinned RHI contract, `RecordingGpuDevice`, contract tests, this document, roadmap entry. | build + tests + contract tests | | **V1** ✅ | GL backend: `GlGpuDevice` (no Chorizite inheritance), buffers (`BufferSubData`, behaviour-preserving), ring over the existing fence-bounded pattern, textures + the binding-9 handle table, samplers, pipelines, timers, backbuffer capture. Constructed in composition (`HostInputCameraCompositionPhase`, right after the frame-flight controller); no consumers yet. | build + tests + GL unit tests + pixel gate (trivially identical — a tripwire) | | **V2** ✅ | Shader dialect + texture-index migration **on GL**: `uvec2 textureHandle` → `uint textureIndex`, a binding-9 handle table, `common.glsl` preamble, CPU batch-struct change. Sub-commits: V2a mesh (`d365476e`), V2b terrain (`1f1f6c08`), V2c particles (`a85743f7`). Each renderer (WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer, ParticleRenderer) owns its own `GlBindlessHandleTable` rather than one shared `TextureCache`-owned instance — see the note below, which the per-slice commit messages elaborate on. | pixel gate per sub-commit (V2a 2.84e-05, V2b 2.49e-05 differing-pixel fraction against parent, both well under the 0.001 threshold and within the documented ~33x same-commit noise margin). V2c has no automated pixel coverage (particles are outside the offline gate's fixed view) — flagged for a user visual check. | | **V3** | Clip-space and sRGB audit: verify every projection producer is [0,1] convention, confirm clip-plane derivation, record the sRGB swapchain decision and the depth-precision divergence class here. | pixel gate + connected lifecycle | | **V4a** | `TextRenderer` (three fence-buffered VBO sets → ring allocations), `BitmapFont`, `DebugLineRenderer`, the UI RenderSurface upload path, `UiViewport`'s texture handoff. | pixel gate (UI-heavy checkpoints) | | **V4b** | `GlobalMeshBuffer` + `ObjectMeshManager` onto `IGpuBuffer`; arena, LRU and ledger logic untouched. | pixel gate | | **V4c** ⏸ **PARKED — §5.5.5** | **The large one.** `WbDrawDispatcher` + `EnvCellRenderer`: per-frame uploads → rings, MDI brackets → pipelines + `MultiDrawIndexedIndirect`, loose uniforms → push constants, timer scopes. `RetailAlphaQueue` and all bucketing untouched. **Narrowed after the V4c scouting report — see §5.3.** | pixel gate at several checkpoints + connected lifecycle | | **V4t** ✅ | **World texture stack** (added 2026-07-27, see §5.3; reported in §5.5.11). Two commits: **1** the GL device's world-handle seam plus `TerrainAtlas`/`TerrainModernRenderer` (`b8bcaa3e`); **2** `CompositeTextureArrayCache`, the particle arrays, `ObjectMeshManager`'s material path, and the retype of `GroupKey`, `CachedBatch` and `ObjectRenderBatch` from `ulong` bindless handle to `GpuTextureSlot`, retiring the interim tables in `WbDrawDispatcher`, `EnvCellRenderer`, `TerrainModernRenderer` and `ParticleRenderer` (`565c351f`). **Narrowed from the original scope in one way:** the caches still create and own their GL textures and residency — the device owns only the table entry — so `ManagedGLTextureArray` and the raw `Texture2D` upload path are untouched and `IGpuTexture` creation moves with the Vulkan world arm. `SkyRenderer` keeps its own table; see §5.5.11. | pixel gate per commit (3.02e-05; 5.50e-05 and 3.91e-05 on two captures against a 19 px same-commit control), App tests, 3/3 desktop-witness connected run per commit, one validation-layer Vulkan run per commit | | **V4d** ⏸ **PARKED — §5.5.5** | `TerrainModernRenderer` only — **`TerrainAtlas` belongs to V4t** with the rest of the texture stack. Two sub-commits: first the `uView`/`uProjection` → `uViewProjection` shader convergence on its own pixel gate (it moves a matrix product from per-vertex GPU to a CPU multiply, so its rounding effect must be attributable alone), then the plumbing. Terrain has no GPU timer to port — its diagnostics use a CPU `Stopwatch`. | pixel gate per sub-commit | | **V4e** ✅ **landed in V6l** | `ParticleRenderer`, as the Vulkan arm (`b1ad1d48`); reported in §5.5.17. Unblocked by amendment 1 — a second vertex binding at `VK_VERTEX_INPUT_RATE_INSTANCE` / divisor 1, §5.5.16's option (i) — plus `GpuVertexFormat.UInt1` for `particle.vert`'s scalar `uint` slot, so no shader was edited. | GL pixel gate, 3-run connected, validation-clean Vulkan run, and a connected GL-versus-Vulkan particle capture | | **V4f** ✅ **landed in V6k** | `SkyRenderer` + weather, as the Vulkan arm (`22aa2edc`); reported in §5.5.16. | seven-day-group GL comparison, GL pixel gate, 3-run connected, validation-clean Vulkan run | | **V4g** ✅ **completed in V6l** | `PrivateEntityViewportRenderer` → `IGpuRenderTarget` at V6k (`eb7e6b4e`), which also discharged §5.4; `PortalDepthMaskRenderer` (`eced67d0`) and both offscreen viewports on the Vulkan arm (`2e8b8b91`) at V6l, reported in §5.5.17. Unblocked by amendment 2 (a stencil dimension on `GpuPipelineDescription` plus `IGpuPassEncoder.SetStencil`) and amendment 3 (a layered sampled view per render target, sample-count pipeline variants). `PortalTunnelPresentation` needed no port — it draws into the backbuffer — and still has no Vulkan arm; see §5.5.17's V7 list. | GL pixel gate, 3-run connected, validation-clean Vulkan run, and connected paperdoll + particle captures on both backends | | **V4h** ✅ **absorbed — closed at V9** | Frame-spine formalization, as re-scoped by the §5.5.5 fork. Its content landed piecewise where the fork put it: the Vulkan arm has real declared passes with clears as load ops (V6h `b16f8206`, V6i-3 `887de4ae`, V6m `59c6b2ae`); flight/screenshot/profiler crossed at V4a/V6g/V8 (`GpuDeviceFrameLifetime`, the resolve-then-read capture, `VulkanFrameGpuMeasurement`); the GL spine deliberately keeps its legacy shape until V11 deletes it, so "clears move out of the GL spine" is void under the fork. Remaining to V11, where they were always going to be judged: the `OpenGLGraphicsDevice` retirement, the Chorizite consumer audit, and the architecture test (vacuous once the GL package reference is deleted). Nothing V4h owed still exists as standalone work. | discharged by the gates of the slices that absorbed it | | **V5** ✅ | Vulkan bring-up, dark: `ACDREAM_RENDER_BACKEND`, surface/instance/device/queues/swapchain, the capability record/probe/guard with the exit-4 contract, a clear-colour loop with screenshot and clean shutdown. | VK boots to clear on the RX 9070 XT; forced-unsupported knob → exit 4 | | **V6** | Vulkan RHI backend, dark, four sequential commits: **a** ✅ allocator/buffers/staging/rings/timeline (`fb9c6693`); **b** ✅ textures/BC mips/samplers/descriptor table/render targets/MSAA resolve (`9eae4963`); **c** ✅ `.spv` toolchain, pipelines, pipeline cache, negative viewport, push constants, timestamps, readback, debug names (`234fe91d`); **d** ✅ first production renderers — `TextRenderer` and `DebugLineRenderer` on both backends, the colour-format contract amendment, and the retained UI drawn on Vulkan; **e** ✅ every remaining production shader crosses the dialect — `mesh_modern` (`935f4dc3`), both particle pairs (`602bc9dd`) and `sky` — leaving 8/9 pairs compiling to SPIR-V. **Milestone deferred:** "full game frame on Vulkan" is not reachable while V4c/V4d are parked and the world renderers plus `TextureCache` are still raw GL, so V6 delivers the backend, the two renderers that can use it today, and the shaders the Vulkan world path will be built on. | per-commit build + tests; V6d additionally pixel-gates GL and captures a Vulkan UI frame; V6e pixel-gates GL per commit and adds a seven-day-group sky comparison plus a 3-run desktop-witness gate | | **V6f** ✅ | `terrain_modern` crosses the dialect, in three separately gated commits: **1** `uView`/`uProjection` → `uViewProjection` (`5e13b45f`); **2** `uTexTiling[36]` → the `UniformTerrainTiling` std140 buffer (`fac09407`); **3** the atlas reads → `ACDREAM_SAMPLE_ARRAY` (`30e94da6`). **Every production shader acdream draws with is now Vulkan-expressible.** The slice also measured the Vulkan world path and found it blocked — see §5.5.7. | pixel gate per commit (3.91e-05, 3.73e-05, 2.13e-05; cumulative 3.91e-05 against `7faaaa34`), App tests, one 3/3 desktop-witness connected run, one validation-layer Vulkan run | **The one production pair still not Vulkan-expressible after V6e was `terrain_modern`**, blocked on exactly the two things V4d was going to do: `uView`/`uProjection` are two loose `mat4` uniforms (128 bytes — they cannot both fit the 96-byte push block, which is why V4d's first sub-commit converged them into one `uViewProjection` on its own pixel gate), and `uTexTiling[36]` is the 144-byte array `UniformTerrainTiling` was reserved for. V6e left it alone because converging the matrices moves a multiply from per-vertex GPU to a CPU multiply — a real numeric change that the plan requires be attributable on its own gate, and one that belongs to whoever re-lands V4d's content rather than to a shader dialect slice. **V6f closed all three** (the third was the frag's GL-only `sampler2DArray(handle)` construction), so 8/9 pairs now compile. `mesh` is a 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 (3.02e-05, 3.20e-05, 1.60e-05 vs `0ca802cd`), App tests 4,109/3 and complete Release suite 9,172/5, 3/3 desktop-witness connected run at commits 2 and 3, one validation-layer Vulkan run per commit | | **V6i-3** | **The mesh pipeline runs on both arms, and the Vulkan frame gets a world pass**, reported in §5.5.14. Two commits: **1** `GlobalMeshBuffer` takes `GL?` and publishes `VertexStore`/`IndexStore`, `ObjectMeshManager`'s `RequireGl` narrows to the unreachable legacy upload, `VulkanMeshPipelineDevice` is `IMeshPipelineDevice`'s second implementation, and `NullWbMeshAdapter` is deleted (`fe8abacf`); **2** the clear merges into the world pass with `Store = Resolve` and descriptor sets bind at draw time (`887de4ae`). **The world renderers' submission arms and `RetailPViewPassExecutor` did NOT land** — §5.5.14 enumerates what they still need. | pixel gate per commit (4.44e-05, 3.73e-05 vs `579e0b7f`), App tests 4,112/3, one 3/3 desktop-witness connected run at HEAD, one validation-layer Vulkan run per commit, and a bit-identical (0/921,600) Vulkan capture across the pass merge | | **V6j** ✅ | **The world arm, whole**, reported in §5.5.15. Two commits: **1** `VulkanViewportMapping` stops inverting the front face — the world arm is the mapping's first culling consumer and measured that the inversion culls terrain outright and turns every closed shell inside-out (`81fe5e1b`); **2** the three renderers' RHI submission arms as a SECOND arm per §5.5.6, both pass executors made backend-neutral behind `IWorldPassSurface`, `VulkanWorldPassScope` publishing the frame's one pass, `WorldFrameSections` carrying the three frame-global sections, and the composition that reaches them (`f84eef32`). **`ACDREAM_RENDER_BACKEND=vulkan` renders Dereth**: terrain, blending, roads, water, statics, scenery and the retained UI. Sky stays fog until V4f. | GL pixel gate 5.50e-05 vs `847f14ae` with a characterised 12–31 px noise distribution, App tests 4,112/3, complete Release suite 9,175/5, GL connected `-Runs 3` at 3/3 on both columns, one validation-layer Vulkan run at 0 errors / 0 warnings, and the world PNG inspected in §5.5.15 | | **V6k** ✅ | **Sky, viewports, and §5.4**, reported in §5.5.16. Two commits: **1** `SkyRenderer`'s RHI arm — V4f's content, two blend pipelines, `SkyParams` as a per-draw ring slice, the first Vulkan consumer of set 1 binding 4 — plus the retirement of the last interim `GlBindlessHandleTable` (`22aa2edc`); **2** `PrivateEntityViewportRenderer` onto `IGpuRenderTarget`, the deletion of §7.1's external-texture seam, and **§5.4's obligation discharged** (`eb7e6b4e`). **Particles did not land**: the pinned contract cannot express instanced vertex input, which is what both particle pipelines are built on. | GL pixel gate 4.43e-05 then 4.08e-05 (25 and 23 px, band 9–31), App tests 4,109/3, complete Release suite 9,172/5, GL connected `-Runs 3` at 3/3 on both columns per commit, one validation-layer Vulkan run at 0 errors / 0 warnings per commit, a seven-day-group GL sky comparison, a connected paperdoll capture, and a GL-versus-Vulkan inspection in §5.5.16 | | **V6l** ✅ | **Particles, the portal mask and the viewports**, reported in §5.5.17. Three commits, one per contract amendment: **1** instanced vertex input (`GpuVertexLayout` per-binding stride and input rate, `BindVertexBuffer(binding, …)`, `GpuVertexFormat.UInt1`) plus `ParticleRenderer`'s RHI arm, the standalone particle texture cache on both arms, and the stride-equals-the-uploaded-record gate for every RHI vertex layout (`b1ad1d48`); **2** the stencil dimension (`StencilTest` + `GpuStencilState` + `SetStencil`), `portal_depth` as a committed shader pair, `PortalDepthMaskRenderer`'s RHI arm, and the ambient stencil/colour-mask restore (`eced67d0`); **3** a layered sampled view per Vulkan render target, sample-count pipeline variants for `WbDrawDispatcher`, the composite texture cache on both arms, and the backend-derived viewport V orientation (`2e8b8b91`). | GL pixel gate per commit (3.20e-05, 2.31e-05, 3.55e-05; band 9–31 px), App tests 4,121/4,129/4,129 and complete Release suite 9,184/9,192/9,192, GL connected `-Runs 3` at 3/3 on both columns per commit, one validation-layer Vulkan run at 0 errors / 0 warnings per commit, and connected Vulkan paperdoll and particle captures inspected against GL | | **V6m** ✅ | **Portal space, and V7's instrument**, reported in §5.5.18. Two commits: **1** `PortalTunnelPresentation`'s RHI arm — the last raw-GL world-adjacent renderer — as a backbuffer pass published on `IWorldPassScope`, clearing to retail's opaque portal-space black rather than loading it, plus the deletion of `NullLocalPlayerTeleportPresentation` (`59c6b2ae`); **2** `tools/run-backend-differential-gate.ps1` and `connected-backend-differential.route.txt`, with MSAA forced off on both launches, the repeat gate's desktop-witness guards, and **an interior EnvCell stop** — §5.1's durable fix for the campaign's oldest coverage gap (`a99f517e`). One smoke pair was run and is reported in full. | GL pixel gate 4.97e-05 (28 px) vs `280f3b3f` against a 3.55e-05 (20 px) same-commit control, band 9–31; App tests 4,132/3 and complete Release suite 9,195/5; GL connected `-Runs 3` at 3/3 on both columns; one validation-layer Vulkan run at 0 errors / 0 warnings; connected portal-tunnel, creature-appraisal and interior-EnvCell captures on BOTH backends, inspected in §5.5.18 | | **V7** ◐ **partially discharged — §5.5.19** | GL-versus-Vulkan differential: `tools/run-backend-differential-gate.ps1` (**built at V6m**), 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.** Starting distance, measured at V6m: 18.52% of the frame at the first stop. **What landed:** the world atlases' missing anisotropy (`ad5f8b68`, retail-anchored at `0x005a4230`), and two instrument pins the gate was silently missing — the cloud sheet's phase and, much larger, the Dereth clock, which had never actually been pinned by anything and was moving 22% of the frame between two captures 45 s apart *in the same run*. Offline GL-versus-Vulkan, both clocks pinned, is now **8.82e-04 below the tree band — inside the threshold**; the treeline is `AD-46`, proven not to be the depth class. **What did NOT land:** a passing connected stop (each carries a named phase exception), per-stop masks in the gate script, an aperture stop for the portal depth mask, ~~the R6 soak on Vulkan~~ (**run and passed at V8 — §5.5.21**), and the RenderDoc capture (**V8 established the cause: RenderDoc is not installed on this machine**). | every differential checkpoint passes; both connected routes green on VK | | **V8** ⏸ **measured; two floors missed; the cutover call is the user's — §5.5.21** | Perf gate on the RX 9070 XT, uncapped, both backends, same scene, same day. **What landed:** `VulkanFrameGpuMeasurement` (`00e1b321`), without which the Vulkan arm emitted no `[frame-prof]` line at all — `NullRenderFrameGpuMeasurement` was the only caller of `FrameProfiler.FrameBoundary`, so no performance vehicle could be pointed at it; the finding that **the R6 soak is NOT the vehicle §2's founding numbers came from and is biased against Vulkan** by the per-frame swapchain copy its own artifact directory arms; a same-day GL-versus-Vulkan profile on the G5 ordinary-production vehicle; a phase-level CPU attribution on both arms; and **the R6 soak run natively on Vulkan** (PASS, 0 failures, graceful exit), which V7 left outstanding. **Result, in three configurations:** on a stationary LIGHT scene at 780-870 FPS Vulkan is 75.4% cheaper on GPU p50 and 85.3% lighter on allocation but **14.8% more expensive on CPU p50 and 8.8% on p99** — two floors missed; on a stationary DENSE scene (21,024 entities, identical on both arms) **Vulkan wins every row**, including CPU p50/p99 and 18.5% less total process CPU by Windows' accounting; and on the nine-stop route against an identical world **Vulkan wins every row** and renders **27.3% more frames**. The Vulkan-specific cost is **fixed per frame** — 0.148 ms of required WSI/sync calls (present 0.070, submit 0.027, timeline wait 0.026, acquire 0.025) against GL's ~0.014 ms of present — so it dominates an almost-empty frame and disappears into a full one. **The campaign's named cost centre is closed as measured-and-not-worth-it:** bindings 4/6/7/8 cost **0.031 ms for all ~216 draws of the frame**, 2.4% of it. No Vulkan code was changed to chase the miss. **Not taken:** the RenderDoc capture — RenderDoc is not installed on this machine; it carries to V10. | §2 acceptance table; parity is the floor | | **V9** ✅ **CI green — §5.5.20, §5.5.22** | Linux + CI: 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). **What landed:** the eleven-step job; `ACDREAM_VULKAN_PROBE_FRAMES`, without which the harness cannot terminate unattended; `tools/compile-shaders.ps1` made path-portable; and the report's jq contract pinned by App tests so a rename fails locally rather than in CI. lavapipe clears every gate requirement by source inspection, including the `samplerAnisotropy` V7 made load-bearing. **Deferred:** the physical Linux GPU row, post-cutover, as for Slice L; and Wayland, which no runner offers. **Not attempted, with cause:** a GL-versus-Vulkan pixel comparison — the GL job asserts exit 4 and so has no frame, and the probe renders synthetic scenes rather than the DAT world CI cannot have. | CI green including the new job — **met**: run 30393357552, all four jobs green, lavapipe `llvmpipe (Cpu)` at Vulkan 1.4.318 / Mesa 25.2.8, a 1280x720 / 35,594-byte captured frame, and "all committed .spv match a fresh compile" on Linux | | **V10** ◐ **flipped and measured; PENDING THE USER'S SIGNATURE — §5.5.23** | Cutover: Vulkan default, GL reachable by env var for one slice, gate scripts default to VK. **What landed:** `ParseRenderBackend` inverted (unset/typo → Vulkan; only `gl`/`opengl` → OpenGL, the polarity of the typo case flipping with the default because GL is now the backend V11 deletes); `run-offline-pixel-gate.ps1` gained `-Backend` (default `vulkan`) and forces all four determinism levers plus MSAA off instead of inheriting them; the two connected gates **clear** `ACDREAM_RENDER_BACKEND` so they exercise the process default and an ambient override cannot disguise a GL run as one. **Battery:** complete Release suite **9,222 / 5 skipped / 0 failed** (`#250` family also 4/4 singly); repeat connected gate **3/3 on both columns**; connected world-lifecycle route **PASS**, 0 failures, both sessions graceful at exit 0; validation layer proven inserted at instance and device level with **zero errors and zero warnings**; GL escape hatch verified by two offline launches. **Every connected launch reached Vulkan with no environment variable set.** **The pixel gate is NOT met and was not relaxed:** VK against the GL-era capture is **1.099e-03 masked / 3.764e-02 whole-frame**, 97.9% of it in the treeline band, the masked residual entirely on distant alpha-blended scenery silhouettes — `AD-46`, whose register row this slice moves from dormant to live. Below the band the arms are photometrically identical (mean luminance Δ 0.01 of 255). Nothing GL, ImGui or Studio is deleted; that stays V11's. **Rollback: `git revert` of this slice's commit.** | complete Release suite + retail expected PNGs **on VK** (baselines not regenerated) + both connected routes + **user visual sign-off** | | **V11** ◐ **DELETED AND STATICALLY GREEN; RUNTIME GATES BLOCKED BY A MACHINE-LEVEL WSI FAULT — §5.5.24** | GL deletion and closeout. **What landed (5 commits, `844cf092`→`c265b52d`): 204 files, +1,870 / −27,607 lines.** The GL backend, `ManagedGL*`, `GLHelpers`, `GLStateScope`, both render-state caches, `BindlessSupport`, `GraphicalGlFunctionProbe`, the ImGui project, the Studio tree and the `ui-studio` verb are gone; `RenderBackendKind` and the `ACDREAM_RENDER_BACKEND` escape hatch are gone with them. `Silk.NET.OpenGL` and `.Extensions.ARB` dropped — **final reference count zero**. `common.glsl`'s GL-only binding-9 texture table and every `GL_ARB_bindless_texture` pragma removed; 9/9 shader pairs recompile. **Two traps the plan did not anticipate:** `Studio/SampleData.cs` was a live production dependency of the retained-UI composition (moved, not deleted) and `ACDREAM_DEVTOOLS` also gates Vulkan debug-utils (kept, now logs that the UI is gone). **Chorizite stays** — the audit is NOT clean: `TextureFormat` is in surviving texture-array signatures and `BoundingBox` is a serialized pak type. **Static gates PASS:** Release build 0 warnings / 0 errors; complete Release suite **8,999 passed / 5 skipped / 9,004** (−218 against V10's 9,222, every one a GL/Studio/DevTools test that lost its subject). **Runtime gates NOT RUN:** the offline pixel gate, both connected routes, the validation run and the working-set re-measure all need a window, and this machine cannot currently create one — see §5.5.24. **Rollback:** revert `c265b52d`, `5852bdb8`, `7a0227c1`, `8a7a0837`, `844cf092`, newest first. | complete Release suite ✅ + both connected routes ⚠ outstanding + working-set re-measure ⚠ outstanding | ### 5.2 Why V2's handle table is not the device's table The obvious reading of V2 — "have the texture caches call `IGpuDevice.RegisterTexture`" — does not work at V2, and the reason is worth recording so nobody re-derives it later. `GlGpuDevice` flushes its dirty texture table immediately before each draw it records. At V2 the draws still go through raw GL inside `WbDrawDispatcher`, which the device knows nothing about, so the device would never flush — the table would be stale on the GPU. Making it work would need a manual `FlushTextureTable()` escape hatch plus a way to bind the device's buffer from raw GL code, which leaks the backend straight back through the seam we are building. So V2 keeps the indirection entirely inside the existing GL world: the texture caches own a small handle-table storage buffer at binding 9 and flush it on their existing schedule. **V4c then deletes that interim table** when `WbDrawDispatcher` moves onto the encoder and the device's table — with its retirement-gated slot recycling — becomes reachable. Two small, separately pixel-gated changes beat one entangled one; separating the data-model change from the RHI plumbing change is precisely what de-risks V4c, the largest slice in the campaign. ### 5.3 Why V4c was narrowed, and where V4t came from A scouting pass over V4c (2026-07-27) stopped before writing code and reported two structural blockers. Both were verified against source; both were real. **The contract was missing a blend mode.** `WbDrawDispatcher.ApplyRetailBlend` (`WbDrawDispatcher.cs:3191`) selects one of *three* blend functions from each DAT surface's `TranslucencyKind`: `AlphaBlend` → `(SrcAlpha, OneMinusSrcAlpha)`, `Additive` → `(SrcAlpha, One)`, and **`InvAlpha` → `(OneMinusSrcAlpha, SrcAlpha)`**. The V0 contract shipped `GpuBlendMode` with only the first two. Blend is baked into the pipeline and is not dynamic, so this could not be worked around at the encoder; mapping `InvAlpha` onto `StraightAlpha` would have silently changed how every inverse-alpha surface composites. `ParticleRenderer` hits the same wall twice, so V4e was blocked on it too. Fixed by adding `GpuBlendMode.InverseAlpha` to the contract with a test asserting all three retail kinds are representable. This is the correct outcome of a pinned contract meeting reality: the contract grew, in one reviewed commit, rather than a slice inventing a workaround. **Retiring the interim handle table is its own slice.** §5.2 assumed V4c could switch to the device's texture table. It cannot: the renderers do not own the bindless handles, they only intern them. A raw `ulong` is produced by `TextureCache`, `CompositeTextureArrayCache`, `ManagedGLTextureArray` and `TerrainAtlas`, baked into `ObjectRenderBatch`, and carried by **`GroupKey`** — the bucketing key V4c is explicitly forbidden to change — and by `CachedBatch`, where it is compared for cache validity. Switching to `GpuTextureSlot` therefore means porting the whole texture stack and retyping three data-model records, which is most of V4d and V4e plus work no slice contained. That is now **V4t**, with its own pixel gate. Until it lands, V4c/V4d/V4e bind their existing interim tables through the encoder as ordinary storage buffers at binding 9 — no new escape hatch. **Also deferred to V4h:** `ClipFrame`'s region buffer (binding 2) is read by terrain as well, and the `SceneLighting` UBO (binding 1) by terrain and the four viewport and portal renderers. GL binding points are global, so the safe move while those consumers are still raw GL is to leave both bound as they are and convert them with the spine. ### 5.5 The V4c/V4d revert (2026-07-27) and the re-land conditions V4c and V4d were reverted at `543bc79f`/`b537f3a9`/`ad61f250` after the first connected sessions showed a **blank world** — UI rendered, sounds played, the log was clean, `world-reveal` reported `visible=True`, and the user hit one AMD driver timeout. The offline gate had passed both slices at noise level. What the debugging established, with the connected screenshot-probe apparatus: the defect is **intermittent (~1 in 3 at the worst location, 0 of 7 at the V4c parent)** and scene-dependent; after the first world frame the default framebuffer's colour reads return garbage (float depth bytes) and `glClear` stops having any effect, with `glGetError` clean throughout — a GPU-side fault, not an API error. Every added CPU↔GPU sync point monotonically suppresses it, which also made the mechanism unprovable in situ. Best-supported cause: the frame ring performs 10–40 partial `glBufferSubData` updates per frame into a buffer object that already-submitted same-frame draws are still reading; the offline flat path issues 2–4 such updates, the connected PView path dozens — which is exactly the offline/connected axis. This likely also explains the TDR. The contract amendments (`111e7236` InverseAlpha, `c7f5f251` integer vertex attributes + tiling binding) were **kept** — they are dark, test-covered, and correct. **Re-land conditions, binding:** 1. The GL ring's write path moves to `glMapBufferRange(WRITE | UNSYNCHRONIZED | INVALIDATE_RANGE)` — the canonical GL ring idiom, which states the non-overlap invariant to the driver instead of leaving `glBufferSubData`-into-an-in-use- buffer to driver heuristics. 2. V4c and then V4d re-land as reverts-of-the-reverts plus the ring change, each gated by `tools/run-repeat-connected-gate.ps1` at **10/10 rendered** — a single connected run passes a broken binary ~70% of the time and gates nothing. 3. The offline pixel gate still passes, and the gate location stays pinned (failure rate is location-sensitive; stray input moves the character). #### 5.5.1 What the connected investigation established (2026-07-27/28) Condition 1 landed at `8dec163f` and **did not fix the defect**. The ring's `glBufferSubData` hazard is therefore *falsified as the cause*; the map change is kept because it is the correct idiom regardless, but the paragraph above naming it "best-supported cause" is superseded by what follows. **The defect does follow the V4c binary.** Blank rate drifts with machine state — the same binary measured 3/10 in one block and 5/5 in another — so consecutive blocks of A then B confound the change with the drift, and the first attributions were made that way. `tools/run-blank-world-ab-probe.ps1` interleaves the two builds inside one block so the drift is shared: **4/5 blank in the V4c arm versus 0/5 in the parent arm, p ≈ 0.024**. That is the attribution; everything below is about mechanism. **What a blank frame actually looks like, from outside the process.** `tools/run-blank-world-surface-probe.ps1` grabs the composited window off the desktop with `CopyFromScreen` — a witness that shares nothing with the renderer below the compositor. On a blank frame the desktop shows the atmosphere clear colour and the **complete retained UI**, with **all 3-D absent — including the raw-GL terrain and sky that V4c does not touch.** So the frame is drawn and presented; what is missing is every depth-tested draw, and only those. Meanwhile `ACDREAM_PROBE_FLAP` reports, on those same blank frames, 3,331 statics dispatched, the correct PView branch, `fbo=0`, the full viewport, scissor off, and zero GL errors. The CPU decided to draw the world and the GL calls were accepted. **Falsified:** the ring `glBufferSubData` hazard; a capture-FBO binding leak; reveal ordering; the AMD TDR; and CPU-side visibility. **Observed and unexplained:** `glReadPixels` probes *heal* the bug, while `glGetIntegerv`-shaped state queries do not — so any instrument that reads pixels changes the thing it measures. **The verdicts themselves were unsound until `2026-07-28`.** The window is created with the quality preset's MSAA sample count, so the default framebuffer is 4x multisampled, and `glReadPixels` against a multisampled read framebuffer is undefined per the GL spec. Every automated pixel gate and every blank/rendered verdict in this campaign came through that read. `FrameScreenshotController` now blit-resolves the default framebuffer into a single-sampled RGBA8 framebuffer and reads that; a single-sampled default framebuffer keeps the original direct read. `GlGpuDevice.CaptureBackbuffer` routes through the same path, so there is one backbuffer read in the process rather than two instruments to keep sound. The offline gate's re-measured noise band is in §5.1: the resolve moved 23 pixels out of 563,200, exactly its own same-commit control, which says AMD's unspecified read was usually already returning the resolved image — and "usually" is what made it worthless as an instrument. #### 5.5.2 The shared-3-D-state hypothesis is falsified (2026-07-28) The natural reading of "all 3-D dies, depth-disabled UI survives, the atmosphere clear shows" is that something shared by every depth-tested draw — and by no UI draw — is poisoned. Four candidates were tested against a V4c build staged from `git revert --no-commit 543bc79f` (never committed) with log-only `glGet*` probes at the frame clear and at world-pass entry/exit. **All four are dead.** | Candidate | How it was tested | Result | |---|---|---| | Depth plane (mask latched off across the clear, poisoned `glClearDepth`/`glDepthFunc`/depth range) | State sampled at `pre-clear`, `post-clear`, `landscape-in`, `landscape-out` | **Bit-identical on blank and rendered frames.** `DEPTH_TEST=on`, `DEPTH_WRITEMASK=on`, `DEPTH_FUNC=GL_LESS`, `DEPTH_CLEAR_VALUE=1.0`, range `[0,1]`, viewport `0,0,1280,720`, colour mask `1111`, scissor/stencil/blend/cull off, MSAA on, no clip distances enabled | | Camera constants | `ViewProjection` and eye logged at world-pass entry | Sane and advancing on blank frames; determinant `-1.688e-01`, eye stable at the pinned cell | | `gl_ClipDistance` (all `MaxPlanes` are enabled unconditionally around sky/terrain/entities, so an unwritten distance would clip everything 3-D and nothing 2-D) | `EnableClipDistances` forced to a no-op | Blank rate **3/5**, i.e. unchanged | | GPU context reset (the "GPU-side fault" reading) | `glGetGraphicsResetStatus` in the same probe | **1,814 samples across four blank runs: `NO_ERROR` every time** | Two new facts were established, and they are sharper than anything before them. **1. Zero 3-D fragments are rasterized — the world is not drawn-then-hidden.** Replacing only the frame clear colour with magenta (nothing else) makes a blank frame come back **uniformly magenta with the complete retained UI on top**. So the world is not being shaded to the fog colour, not being fogged out, and not being overdrawn: between the clear and the UI, not one 3-D fragment reaches the default framebuffer — while the CPU has dispatched the draws, GL accepted them, and every piece of state above is correct. **2. The in-process capture does not observe the presented surface at all.** On a blank run the desktop grab shows the magenta clear plus the complete UI, and at that same moment the client's own capture of framebuffer 0 is **RGBA(0,0,0,0) in every pixel — including the pixels where the UI is visibly on screen.** This survives the §5.5.1 resolve fix, so it is a *second*, independent instrument fault: on a blank run, reading framebuffer 0 returns nothing even for content that demonstrably reached the display. Any verdict derived from screenshot bytes is therefore reporting the readback, not the renderer, and the `MinRenderedBytes` test in `run-repeat-connected-gate.ps1` / `run-blank-world-ab-probe.ps1` conflates the two. **The desktop witness is currently the only trustworthy verdict** and should be what those gates assert on. The failure reproduces readily with a *visible* window (`WasIconic=False` throughout), so it is not a pixel-ownership artefact of the minimized gate window: 5/6, 4/5, 3/5 and 4/4 blank across four blocks at the pinned cell. **Where this leaves the mechanism.** It is not renderer state and not a context reset; clears and UI draws reach the display while 3-D draws and pixel reads against the same framebuffer both come back empty. That combination points below the API — at how the default framebuffer's colour is being handled for this context — rather than at anything V4c writes. **V4c has therefore not been re-landed**, and no fix was attempted: the re-land conditions in §5.5 stand, but condition 2's gate must first be re-armed on the desktop witness, because the screenshot-byte verdict it uses is now known to be measuring the wrong thing. The next instrument should be an occlusion query (`GL_SAMPLES_PASSED`) around the world pass, read back a frame later so it adds no sync point — that separates "the draws never executed" from "they executed and their output was discarded", which is the remaining fork. **That instrument was built and run — see §5.5.3, which supersedes this section's "shared 3-D state" framing.** #### 5.5.3 The occlusion-query verdict (2026-07-28): the GPU stops reporting The instrument §5.5.2 asked for was built and run on a V4c tree staged from `git revert --no-commit 543bc79f` (never committed), with `GL_SAMPLES_PASSED` bracketing three sites — the raw-GL terrain draw, the dispatcher's entity draws, and the retained-UI flush — and the counts collected later, never in the frame that issued them. The desktop witness was the verdict throughout. All probe code was stripped before this commit; the apparatus changes that survive are the two gate scripts, now asserting on the desktop grab. **Building it turned up a fourth instrument fault, and it is the sharpest one.** The obvious readback — `glGetQueryObject` into client memory, guarded by `GL_QUERY_RESULT_AVAILABLE` and read a frame late — **deadlocks the client on V4c.** Four consecutive runs wedged at the first frame that draws the world, and five `dotnet-stack` samples taken four seconds apart all show the render thread inside the driver under `GlDrawCounterProbe.Drain`, i.e. blocked in `glGetQueryObject`. It is not a probe defect: the identical probe ran 4,420 frames on the V4c *parent* with normal counts and a normal 5 ms frame time, and the wedge does not need the world sites at all — instrumenting only the UI flush reproduces it, while creating the query objects and never beginning one does not. So on V4c, the mere existence of an outstanding occlusion query is enough to make a CPU-side result read never return. The way past that is to never ask the driver for a result on the CPU timeline. The query result is instead written into a persistently-mapped, coherent `GL_QUERY_BUFFER`: `glGetQueryObject` with that buffer bound performs the write on the GPU timeline, so no client wait is possible by construction, and pre-filling each slot with a sentinel makes "the GPU reported zero samples" and "the GPU never reached this command" different observations. That instrument does not wedge, and it produced the table below. **Per-frame counters, blank versus rendered, four runs on one V4c binary** — three blank and one rendered on the desktop witness, both instruments agreeing on the label in every run: | | terrain | entities | UI | frames logged | |---|---|---|---|---| | Rendered run (run 3) | 1,718,771 | ~312,600 | 541,445 | 1,068, **zero** no-result | | Blank runs (1, 2, 4), steady state | no result | no result | no result | ~950 each, **every** query | | Blank runs, frame 43 (the one early world frame) | 1,692,830 | 317,561 | no result | — | | Parent build, same probe, frame 45 | 1,692,830 | 317,561 | 539,0xx | 4,420, zero pending | Four things follow, and they are worth separating. **1. The mission's three-way fork resolves to the third branch — but not as "submission stalls".** On a blank run no query result is ever produced, at any site, for the whole run. It is emphatically not "zero samples": the sentinel is untouched, so the GPU never executed the write. And yet the process is not stalled — frame time stays at a steady 5.5 ms for ~3,700 frames, the frame-flight fences keep retiring (`GpuFrameFlightController.RetireFence` spins on `glClientWaitSync` until the fence signals, so a stalled submission would freeze the client outright), and the compositor keeps showing the clear colour and the complete retained UI. The GPU is running the frame. What has stopped is everything the GPU is asked to *report*. **2. The failure is total, not 3-D-specific.** The UI query dies on a blank run too — in the same frames where the desktop witness plainly shows the UI on screen. §5.5.2 read the symptom as "something shared by every depth-tested draw is poisoned"; that framing is now too narrow. Every GPU→CPU reporting channel tested is dead on a blank run — `glReadPixels` of framebuffer 0 returns RGBA(0,0,0,0) even over visible UI pixels, a CPU query read blocks forever, a GPU-timeline query write never lands — while the two channels that carry no result, fence signalling and present, keep working. The common factor is the *direction*: nothing comes back. **3. The transition is a single sharp event at the first world frame, and it is irreversible.** In every blank run the UI query returns normal counts (539,010) for frames 1–42, the world draws for the first time at frame 43, and from that frame on nothing is ever reported again — 924 consecutive dead frames in run 1. The rendered run has no world draw at frame 43 (its first is frame 1,080) and never loses a single result. This is the same "after the first world frame" boundary §5.5 recorded from the colour reads, now measured on a second, independent channel. **4. That first world frame rasterizes correctly — identically on both builds.** Frame 43 reports 1,692,830 terrain samples and 317,561 entity samples on V4c, and the parent's first world frame reports **the same two numbers**. The world is drawn, in full, exactly as the good build draws it. It is the last thing the GPU ever tells this process, and V4c is what decides whether that is the last thing. **Where this leaves the mechanism.** Everything now points at the GPU→CPU reporting path for this context collapsing at the first world frame, with V4c's submission pattern as the trigger and nothing in V4c's own state as the cause — V4c does not touch the terrain draw, does not touch the UI flush, and §5.5.2 already showed its renderer state is bit-identical on blank and rendered frames. A clean context with `glGetGraphicsResetStatus` = `NO_ERROR` on 1,814 samples does not lose its readback, its query results, and its ability to answer a query without blocking, all at once, because of anything expressible in the API. **No fix was attempted and V4c is still not re-landed.** #### 5.5.4 Strategic options Three ways forward, with the evidence for each. **(A) Keep hunting for a V4c-side trigger we can remove.** The attribution is solid (§5.5.1: 4/5 versus 0/5 interleaved, p ≈ 0.024), so a trigger exists in the V4c diff and removing it would restore the no-fork plan. Against it: five mechanisms have now been falsified — the ring's `glBufferSubData` hazard, a capture-FBO leak, reveal ordering, shared 3-D state (depth plane, camera, clip distances, context reset), and CPU-side visibility — and the two facts that remain are *not expressible in the API*, which is exactly the shape of a hunt with no bottom. The remaining honest step would be a RenderDoc or GPU-crash-dump capture of the frame-43 boundary, or a bisect of the V4c diff into ~6 sub-commits each measured at 5 runs, which is roughly 3 hours of connected machine time per round and pins the user's machine for it. **(B) Accept it as an AMD GL driver defect, keep the world on the legacy raw-GL path on the GL backend, and carry the V4c/V4d RHI ports forward for Vulkan only.** This is what the evidence supports: a defect that (i) follows a submission-pattern change, (ii) is invisible to every API-level state query, (iii) kills three unrelated readback channels simultaneously while leaving present and fences intact, and (iv) can be induced *harder* by adding a perfectly legal occlusion query, is a driver defect in 26.6.4 on the RX 9070 XT, not an application bug. The cost is real and must be stated plainly: it breaks §3.1's no-fork rule for the world path, so the GL backend keeps raw-GL world renderers while Vulkan gets RHI ones, and V4h's "seam complete" milestone can no longer mean "nothing raw-GL remains". V7's GL-versus-Vulkan differential then compares a raw-GL world against an RHI world rather than one contract against two backends, which weakens it precisely where it is most valuable. It also leaves the deleted-at-V11 GL path carrying code the campaign intended to retire early. **(C) Reorder the campaign: bring Vulkan up first (V5/V6) and decide V4c afterwards.** This is the option the evidence actually suggests and it is not on the original menu. The whole point of V4c is to make the world path backend-agnostic; its only consumer that matters is Vulkan. If the GL stack of this driver is what breaks, then running the same ported code on the Vulkan backend is both the cheapest test of hypothesis (B) — if the identical RHI world path renders correctly on Vulkan on the same GPU, the defect is in the driver's GL stack, conclusively and in one measurement instead of a multi-hour bisect — and the shipping path. The sequencing cost is that V5/V6 must be written against an RHI whose world-path consumer is proven only offline, and that V4c's diff sits un-landed on a branch meanwhile; the sequencing invariants in §5.4 would need V4c/V4d/V4t moved after V6, with V4a/V4b/V4e/V4f/V4g (all landed or independent) unaffected. **Recommendation: (C), with (B) as its fallback.** (A) is the only option with no bounded cost and the worst prior — five falsified mechanisms and two facts that live below the API. (B) is probably the right *conclusion*, but adopting it now means paying the no-fork penalty on the strength of an inference; one Vulkan bring-up turns that inference into a measurement, and it is work the campaign has to do regardless. If the RHI world path renders on Vulkan on this GPU, (B) is proven and can be adopted deliberately, with the fork scoped and documented rather than assumed. If it fails on Vulkan too, then the defect is ours after all, the trigger is in code we own, and (A) becomes worth its cost because it would then have a much smaller haystack. **Re-land conditions, updated.** §5.5's three conditions stand, with two amendments: condition 2's gate now asserts on the desktop witness (`tools/run-repeat-connected-gate.ps1` and `tools/run-blank-world-ab-probe.ps1` grab the composited window and treat the client's own capture as a recorded second column), and no re-land attempt should be made before the (C) measurement, because a 10/10 pass on this machine cannot distinguish a fix from the defect's ordinary ~1-in-5 quiet streak. #### 5.5.5 Decision (2026-07-28): the GL re-land of V4c/V4d is PARKED, V6 is brought forward Option (C) of §5.5.4 is adopted. **No further GL-side attempt is made to re-land V4c or V4d until the Vulkan world path has been measured.** V5 landed at `e8a4c1af` — the capability gate passes on the RX 9070 XT, Vulkan 1.4.349, AMD driver 2.0.395, with a `B8G8R8A8Unorm` swapchain presenting the clear colour and the forced-unsupported knob returning exit 4. V6 follows immediately; V4c and V4d stay un-landed on their branches meanwhile. **Grounds.** The evidence in §5.5.1–§5.5.3 converges on one shape, and it is not the shape of an application bug. - **The world is drawn correctly, once.** §5.5.3's frame 43 reports 1,692,830 terrain samples and 317,561 entity samples on V4c and **byte-matches the parent build's first world frame on both counters**. Whatever goes wrong is not a rasterization difference; the ported path draws exactly what the working path draws. - **Then one irreversible event kills every GPU→CPU return channel at once.** From that frame on, `glReadPixels` of framebuffer 0 returns RGBA(0,0,0,0) even over UI pixels the desktop witness plainly shows on screen; a guarded `glGetQueryObject` read never returns and deadlocks the render thread inside the driver; and a GPU-timeline `GL_QUERY_BUFFER` write never lands, leaving the pre-filled sentinel untouched. The transition is sharp, total, and permanent — 924 consecutive dead frames in run 1. - **Meanwhile the channels that carry no result keep working.** Present and fence signalling continue at a steady 5.5 ms for ~3,700 frames. The GPU is running the frame; it has stopped *reporting*. The common factor is direction, not subsystem — the UI query dies alongside the world queries, so §5.5.2's "shared 3-D state" framing is superseded. - **Every API-level explanation has been eliminated.** `glGetError` is clean throughout, and `glGetGraphicsResetStatus` returned `NO_ERROR` on **1,814 samples across four blank runs**. Five mechanisms are falsified: the ring's `glBufferSubData` hazard (§5.5.1, and condition 1 landed at `8dec163f` without fixing it), a capture-FBO binding leak, reveal ordering, shared 3-D state (depth plane bit-identical, camera sane, clip distances forced off changed nothing, no context reset), and CPU-side visibility (3,331 statics dispatched on blank frames). - **Four independent instrument faults, all below the API, all on one driver.** The multisampled `glReadPixels` (§5.5.1), the in-process capture that cannot see the presented surface (§5.5.2), the deadlocking CPU query read and the never-executed GPU-timeline query write (§5.5.3). A perfectly legal occlusion query makes the failure *worse*. All of it on AMD 26.6.4, RX 9070 XT — one driver, one GPU, no second data point. A context that loses its readback, its query results, and its ability to answer a query without blocking — simultaneously, on a clean reset status — has failed in a way not expressible in the API. Continuing to bisect the V4c diff (option A) costs roughly three hours of connected machine time per round, pins the user's machine, and has five falsified mechanisms behind it. **Decision rule.** The same ported world path running on Vulkan on the same GPU is the decisive discriminator, and it yields a verdict in one measurement instead of a multi-hour bisect: - **Clean on Vulkan ⇒ the driver defect is proven.** Option (B) is then adopted deliberately rather than inferred. GL keeps the legacy raw-GL world path through to V10 as a **documented, scoped exception to §3.1's no-fork rule, confined to the thin submission seam** — the world renderers, not the contract. The consequences must be carried explicitly: V4h's "seam complete" milestone no longer means "nothing raw-GL remains," and V7's differential compares a raw-GL world against an RHI world rather than one contract against two backends, which weakens it exactly where it is most valuable. - **Fails on Vulkan too ⇒ the trigger is ours.** The defect is then in code we own, and (A) becomes worth its cost because the haystack is far smaller: a fault reproducing on both backends is a property of the ported path itself, not of a driver's GL stack. #### 5.5.6 Cross-vendor verdict (2026-07-28): NVIDIA renders the V4c binary 10/10 The missing second data point arrived before the Vulkan one. The exact V4c binary — published from `eb2ba4e5` + `git revert --no-commit 543bc79f`, and verified to be the V4c build by its embedded `wb-mesh-*` pipeline-name literals, which the HEAD build on the AMD machine provably lacks — ran the full ten-cycle repeat-connected gate on a separate NVIDIA PC against the same ACE instance, same account, same pinned worst-case cell, desktop-witness verdict: **10/10 RENDERED.** Ten clean runs bound the NVIDIA failure rate below ~4% at 90% confidence, against a measured 30%+ (up to 5-of-5) on the AMD box, where the interleaved A/B probe had already pinned the defect to this binary at p≈0.024. Same binary, same server, same scene, two GL drivers: only AMD's fails, and it fails below the API in four independent instruments. **The driver-defect conclusion is adopted as established** (AMD 26.6.4 GL stack, RX 9070 XT). The Vulkan world-path measurement remains worth taking when V6 completes — as the shipping path's own proof, no longer as the discriminator. Per the §5.5.5 decision rule this selects option (B): the GL backend keeps the legacy raw-GL world path through to V10 as the documented, scoped fork exception, and the RHI world path ships on Vulkan. V4c/V4d's GL re-land is closed, not merely parked; their content returns as the Vulkan world path. **Sequencing.** V5 and V6 execute next, in that order. **V4t and V4e–V4h are re-sequenced after the verdict**, not before it — V4h in particular cannot be specified until it is known whether "nothing raw-GL remains" is still reachable. V4a and V4b are landed and unaffected. §5.5's re-land conditions remain binding on any eventual V4c/V4d re-land, including the §5.5.4 amendment that the gate asserts on the desktop witness. **What this costs.** V5 and V6 are written against an RHI whose world-path consumer is proven only offline, and V4c's diff sits un-landed on a branch for the duration. Both were accepted as the price of turning an inference into a measurement — and V6 is work the campaign has to do regardless. #### 5.5.7 V6f (2026-07-28): the fork cannot be built yet, and why §5.5.6 selected option (B) — "the RHI world path ships on Vulkan", V4c/V4d's content returning as the Vulkan world path behind a fork at the thin submission seam. V6f set out to build that fork and measured, instead, that **there is nothing for it to select between on the Vulkan side.** The finding is recorded here so the next slice inherits it rather than rediscovering it. **The Vulkan path constructs no game state at all.** `GameWindow.Run` branches at `GameWindow.cs:683` and `return`s at `:695` — *before* `Window.Create`, before `_windowCallbacks.Attach()`, and therefore before `OnLoad`, which is the sole caller of `GameWindowCompositionPipeline.Run`. On Vulkan **not one composition phase executes**: no DAT loading, no `LandblockStreamer`, no camera, no entity table, no world renderer. `VulkanBringUpHost` is a second `main()` that opens its own window and presents `VulkanRhiScene` + `VulkanRetainedUiScene`, both of which document themselves as synthetic. A V6f capture confirms it visually (`artifacts/vk-world/`): checkerboard mip quads, a gradient sphere, two debug polylines and a generated UI sprite with system-font glyphs. Correct, and not Dereth. So a backend-selected fork inside `WbDrawDispatcher` / `EnvCellRenderer` / `TerrainModernRenderer` would today have a GL arm that runs and a Vulkan arm that nothing can reach. That is ~2,000 lines of duplicated submission code with no consumer and no gate — precisely the "unexercised second path" shape §3.1 and §7.1 rule 3 exist to prevent. **And the parked V4c/V4d code could not drive Vulkan even if reached.** V4c binds its texture table as an ordinary storage buffer of packed `GL_ARB_bindless_texture` `uvec2` handles (`wb-texture-table`) because §5.3 deferred the real port to V4t. `ObjectRenderBatch.BindlessTextureHandle` is a raw `ulong`, and `GroupKey` — the bucketing key V4c is forbidden to change — carries it. On Vulkan that buffer is meaningless: the table is set 2's opaque descriptor array. **V4t is a hard prerequisite, not a parallel track**, and it is ~4,400 lines across `TextureCache`, `CompositeTextureArrayCache`, `ManagedGLTextureArray`, `TerrainAtlas` and `BindlessSupport`, plus retyping three records that fan out into five renderers and one cache-validity comparison. Landing the fork before V4t means writing the RHI world path twice, because V4t rewrites exactly the code the fork's Vulkan arm would contain. **Two defects the validation layer found, both pre-existing V6b–V6d, both blocking the world path.** One full run with `VK_LAYER_KHRONOS_validation` (`artifacts/vk-world/client.log`) reported seven distinct VUIDs: 1. **`VUID-VkPipelineLayoutCreateInfo-descriptorType-03032`** and **`-pSetLayouts-03040`** — "sum of dynamic storage buffer bindings among all stages (10) exceeds device `maxDescriptorSetStorageBuffersDynamic` limit (8)." `VulkanPipelineLayouts.cs:101,108` declares all `GpuBindingModel.StorageBindingCount` = 10 storage bindings as `StorageBufferDynamic`, and the RX 9070 XT allows 8. **This is the pinned binding model meeting a real device limit, and the world path is the consumer that needs all ten bindings.** It wants a decision, not a patch: make the rarely-rebound bindings non-dynamic, or split them across sets. It fires today only because the verification scene builds the same layout. 2. **`VUID-vkCmdDraw-dynamicRenderingUnusedAttachments-08914` / `-08917`** — the `vk-scene-line` pipeline declares `depthAttachmentFormat = VK_FORMAT_UNDEFINED` while the pass carries a `D32_SFLOAT_S8_UINT` depth attachment. `VulkanGpuPipeline.cs:184,187` sets the format only when `Depth.Test || Depth.Write`, so any depth-off pipeline in a pass that *has* depth is malformed. Debug lines are depth-off; so is a lot of UI. 3. **`VUID-vkCmdBeginRendering-pRenderingInfo-09588` / `-09590` / `-09592`** — `vk-backbuffer-depth` and `vk-backbuffer-msaa-color` are in `VK_IMAGE_LAYOUT_UNDEFINED` at `vkCmdBeginRendering`. A missing first-use layout transition on the backbuffer attachments. Worth recording: the render-target-view-in-table usage from V6c that §5.5.6's brief expected did **not** fire in this run. Either it needs the paperdoll path the bring-up host never exercises, or it is not a validation error. Do not carry it forward as a known-and-accepted item without re-checking. **Recommended sequencing.** The fork is real and still the plan; it simply comes after its prerequisites, in this order: 1. **A Vulkan composition host** — a slice the plan has never scoped. Either `GameWindow`'s composition becomes backend-parameterised (most of V4h) or the Vulkan host gains a world, which must borrow the same CPU owners rather than fork them. 2. **The three validation defects above**, since every one of them is on the path any world frame takes. 3. **V4t**, the texture stack. Nothing world-shaped can sample a texel on Vulkan until `GpuTextureSlot` replaces the `ulong` bindless handle end to end. 4. **Then** V4c/V4d's content returns as the Vulkan arm of the fork, behind a construction-time backend selection at the submission seam, with the GL arm untouched. A cheaper intermediate milestone exists and is worth considering: **terrain only** on Vulkan — steps 1, 2, a partial 3 and V4d's plumbing — renders terrain, water and sky with no scenery or statics, and would be the first real evidence the Vulkan world path works. V6f's shader work is the whole of that path's shader prerequisite. #### 5.5.8 V6g (2026-07-28): the validation defects are closed, and a fourth was found §5.5.7's step 2 — "the three validation defects, since every one of them is on the path any world frame takes" — is done, and the Vulkan bring-up host now runs **validation-clean**: zero errors and zero warnings across a 39,855-frame run with `VK_LAYER_KHRONOS_validation` loaded, against the same run that produced seven of them at `f8dbe2ee`. **1. The dynamic-descriptor limit (`VUID-VkPipelineLayoutCreateInfo-descriptorType-03032` / `-pSetLayouts-03040`).** Resolved by decision rather than patch, as §5.5.7 asked. V6b declared all ten of set 0's bindings `STORAGE_BUFFER_DYNAMIC`; the rule now is that **a dynamic descriptor is for ring-fed data whose offset moves, and nothing else**. Instances (0), batches (1), clip slots (3) and instance light sets (5) stay dynamic; global lights (4), clip regions (2), instance indoor (6), alpha (7), selection lighting (8) and the GL-only texture table (9) become plain `STORAGE_BUFFER` carrying their offset in the descriptor. That is **four** dynamic storage descriptors — not merely under the RX 9070 XT's 8 but exactly Vulkan's guaranteed minimum, so no conformant device can fail the layout, which is what V9's lavapipe row and the deferred physical Linux row depend on. The count is asserted against `maxDescriptorSetStorageBuffersDynamic` in the capability record, so a device that cannot serve it is rejected at startup under the exit-code-4 contract instead of failing at `vkCreatePipelineLayout`. Bindings 6–8 are per-instance arrays grouped with the frame-global tables because their owner writes them whole once per frame; if the Vulkan world path needs one re-pointed per draw, promoting it back is one line, with four unused dynamic slots to promote into. **2. Depth-off pipelines in depth-carrying passes (`VUID-vkCmdDraw-dynamicRenderingUnusedAttachments-08914` / `-08917`).** The backend now builds **two variants of every pipeline** — one declaring the pass's depth/stencil format, one declaring `UNDEFINED` — and binds whichever matches what `vkCmdBeginRendering` was actually handed. The same `GpuPipelineDescription` is legitimately used in both kinds of pass (`ui-text` opens its own depth-less pass; the world pass it composites over has depth), so the description genuinely cannot answer the question. **A later slice entitled to change the contract should add a depth-format field the way V6d added `ColorFormat`**; until then, materialising both at startup against the persisted cache is the honest expression of the gap and no frame ever compiles one. **3. Missing first-use layout transitions (`VUID-vkCmdBeginRendering-pRenderingInfo-09588` / `-09590` / `-09592`).** `vk-backbuffer-depth` and `vk-backbuffer-msaa-color` are created UNDEFINED and were never moved. Both now get a barrier on every backbuffer pass: from UNDEFINED on the first use after `Configure`, and from the attachment-optimal layout with a write-after-write dependency thereafter — the same shape the swapchain image already had. The dependency matters independently of the layout: two passes in one frame write both images, and so does the next frame, with no implicit ordering between render-pass instances. **4. The capture path read an image it did not own (`UNASSIGNED-non-acquired-swapchain-image-used`).** Present in V6f's log and not called out there. `CaptureBackbuffer` transitioned the **last presented** swapchain image to `TRANSFER_SRC` and copied out of it; after `vkQueuePresentKHR` that image belongs to the presentation engine and its contents are not the application's to read. The pixels were usually right — which is exactly what makes it unacceptable. **This campaign spent §5.5.1–§5.5.3 discovering what a capture instrument that is "usually right" costs**, and shipping the same shape on the new backend would have made every Vulkan PNG, and the V7 differential built on them, formally undefined. The frame now copies its own output into a host-readable buffer while it still owns the image, and `CaptureBackbuffer` reads that. Retention is opt-in (armed when an artifact directory exists) because it costs one full-res image-to-buffer copy per frame: worth nothing to a player, and the entire instrument to a gate. **Two gaps recorded, not fixed** — both outside this slice's brief, both real: - **`UniformSkyParams` (set 1, binding 4) is not in the uniform set layout**, which declares only bindings 1 and 3, and `VulkanFrameBindings.UniformBindingCount` is 4, so `SetUniform(4, …)` throws before it can be wrong. The `sky` pair compiles to SPIR-V declaring that binding, so whoever first draws sky on Vulkan must add it. - **A binding pointed at two different buffers within one frame silently corrupts the earlier draws**, on dynamic and plain descriptors alike: `SetStorage` rewrites the descriptor when the buffer changes, and descriptor contents are read at execution time, not record time. No consumer does this today. The world path will: `WbDrawDispatcher` and `EnvCellRenderer` each own their own instance and batch buffers and both bind bindings 0, 1, 3, 4 and 5 in one frame. **The Vulkan world arm needs one descriptor set per renderer, or per-renderer sub-ranges of one buffer, and it needs to know that before it is written.** #### 5.5.9 The Vulkan composition host: the seam, measured (V6g) §5.5.7's step 1 asked for "a Vulkan composition host — a slice the plan has never scoped." V6g scoped it and did not build it. What follows is the specification, so the slice that does build it starts from a file list rather than a survey. The short version: **it is most of V4h, it is roughly 1,200–2,000 changed lines across ~20 files including `GameWindow.cs`, and it is a load-bearing slice in its own right** — not a preface to one. **The fork seam is already there, in three places, and only one of them is missing.** 1. **Platform acquisition is already backend-neutral.** `GameWindowPlatformResult` and `IGameWindowPlatformPublication` (`Composition/GameWindowPlatformAcquisition.cs:3,11`) are fully generic. Only the *call sites* pin `TGraphics = GL`. Nothing in the acquisition machinery needs changing. 2. **The host phase already has a factory interface built for exactly this substitution.** `IHostInputCameraCompositionFactory` (`Composition/HostInputCameraComposition.cs:49`) declares `CreateViewportTarget`, `CreateGpuFrameFlights`, `CreateGpuDevice` and `CreateWorldRenderDiagnostics`, each taking a `GL`, with one implementation (`RetailHostInputCameraCompositionFactory`). **A `VulkanHostInputCameraCompositionFactory` returning `VulkanGpuDevice` is the fork, and it is a new file, not a modification.** The four signatures lose their `GL` parameter and take the platform result instead. 3. **The frame root is the seam that does not exist yet**, and it is the real work. `FrameRootComposition` builds `RuntimeRenderFrameClearPhase`, `RetailPViewPassExecutor`, `WorldScenePassExecutor` and `FrameProfilerGpuMeasurement` from `d.Gl` plus six raw-GL world renderers (`live.DrawDispatcher`, `live.EnvCellRenderer`, `foundation.Terrain`, `live.SkyRenderer`, `live.ParticleRenderer`, `live.PortalDepthMask`). On Vulkan none of those exist, so the Vulkan arm is a **second frame-root assembly** whose render graph is the retained UI and debug lines through the RHI and nothing else. That is the slice's centre of gravity. **What the retained UI actually needs — better news than §5.5.7 implied.** V6f wrote that "the retail widget tree is built from LayoutDesc and DAT chrome by `TextureCache`, which is still a GL type until slice V4t." That is true of the type but **not of the UI's texture path**, which V4a and V6d already moved onto `IGpuDevice`: `TextureCache.UploadUiTexture` (`Rendering/TextureCache.cs:311`) goes through `CreateTexture`/`CreateSampler`/`RegisterTexture`, and the public `UploadRgba8` that `IconComposer` composes retail icons with routes into it. The raw-`_gl` uploads that remain are the world's `Texture2D`/`Texture2DArray` paths, which no UI draw reaches. **Three things, all small, stand between the real `UiHost` and a Vulkan frame:** - `TextureCache`'s constructor requires a non-null `GL` (`:21`). - `UploadUiTexture` hard-casts to `GlGpuTexture` for VRAM accounting (`Rendering/TextureCache.cs:337`: `uint glName = ((GlGpuTexture)texture).GlName;`). - `InteractionRetainedUiComposition:533` builds the UI-probe `FrameScreenshotController` from `d.Gl`, reachable only when `ACDREAM_UI_PROBE` is on. So **the retained UI is not V4t-blocked; the world is.** That is the one place §5.5.7's sequencing should be read more precisely: V4t is a hard prerequisite for step 4 (the world arm), not for step 1. **The mechanical remainder**, for estimation: `GL` appears concretely in five dependency records (`FrameRootDependencies.Gl`, `LivePresentationDependencies.Gl`, `InteractionRetainedUiDependencies.Gl`, plus the host and settings phases), each guarded by a `ReferenceEquals(_dependencies.Gl, platform.Graphics)` consistency check that has to become backend-aware. `SettingsDevToolsComposition` is ImGui, which is not ported and is deleted at V11, so the Vulkan arm simply omits DevTools. `GameWindow.Run` opens a `ContextAPI.OpenGL` window with MSAA and stencil attributes; the Vulkan arm needs `WindowOptions.DefaultVulkan` and the instance/surface/device/swapchain sequence `VulkanBringUpHost.CreateWindow` through `CreateFrameResources` already performs — that code is reusable, which is the argument for reducing `VulkanBringUpHost` to a capability-probe harness rather than deleting it outright. **Corrected sequence**, superseding §5.5.7's four steps: 1. ~~The three validation defects~~ — done at V6g (§5.5.8). They came first because every one is on any world frame's path, and because a host built on a frame that fails validation cannot be debugged. 2. **The Vulkan composition host**, as specified above, delivering DAT load, streaming, camera, entity table, session and the real retained UI on Vulkan, with no world renderers. Its gate is an offline Vulkan launch reaching the real composition with a captured UI frame, a converging ownership ledger at shutdown, and the strict GL offline gate unmoved. 3. ~~**V4t**, the texture stack~~ — done at `b8bcaa3e`/`565c351f` (§5.5.11). 4. **The world arm** — V4c/V4d's content behind the construction-time backend selection at the frame-root seam. Step 2's own acceptance criterion — "the real UI renders" — is what makes it worth doing before V4t rather than after: it is the first frame acdream draws on Vulkan that is the *client's* frame rather than a scene written to prove the backend. #### 5.5.10 V6h (2026-07-28): the Vulkan composition host is built §5.5.9's step 2 is done. `ACDREAM_RENDER_BACKEND=vulkan` now runs the real composition: `GameWindow.Run` opens a `WindowOptions.DefaultVulkan` window, platform acquisition publishes a Vulkan graphics handle, and every one of the nine composition phases executes. The offline log is the client's own — `prepared assets: opened acdream.pak`, `spells: loaded 6266 entries`, `sky: loaded Region 0x13000000`, `loading world view centered on 0xA9B4FFFF`, the fourteen retail LayoutDesc lines, `streaming: nearRadius=4 farRadius=12` — and the captured frame is the retail retained UI: vitals window, combat/spell bar with DAT scarab icons, the nine-slot toolbar with backpack and dove chrome, the chat window with its tabs and Send button, and the radar/compass with dat-font N/E/S/W glyphs. Sampled against the GL capture the widgets agree — chat interior RGBA (25,24,27,158) versus (22,21,23,158), vitals bar (117,1,0) and toolbar slot (0,11,17) identical. The background is the atmosphere fog colour because there is no world behind it, which is the slice's declared scope. **The estimate held.** §5.5.9 predicted ~1,200–2,000 lines across ~20 files including `GameWindow.cs`; the commit is 22 modified and 5 new files. **What the three seams became.** 1. **Platform acquisition** publishes `GameWindowGraphics` — an abstract handle with an `OpenGl` and a `Vulkan` subclass — instead of a bare `GL`. Every phase that still speaks raw GL asks `Graphics.Gl` and takes its Vulkan arm when the answer is null; each such branch names the slice that will remove it. The dependency records' `ReferenceEquals` consistency checks are unchanged in kind, only in type. 2. **`VulkanHostInputCameraCompositionFactory`** is a new file and the whole of the Phase-1 fork. Four members differ — viewport target, frame flights, GPU device, GL state tripwire; input, camera and pointer construction delegate to the retail factory because they are platform concerns, not graphics ones. The default factory is now chosen inside the phase from the platform result rather than at the call site. `HostInputCameraResult` gained backend-neutral `Retirement` and `FrameSlots` views: on GL both are the fence ring, on Vulkan the device's timeline-backed queue and flight controller. 3. **The frame root** forks on one condition. The GL world-scene assembly is unchanged and merely wrapped in `if (gl is not null)`; the Vulkan arm's render graph is `VulkanRenderFrameClearPhase` (one backbuffer clear pass computing the same `RenderFrameFoundation` from the same clock and weather owners) plus the private-presentation phase that composites the retained UI. **§5.5.9's three `TextureCache` couplings are unpicked.** The constructor takes `GL?` and rejects a bindless argument without one; world entry points route through a `Gl` property that throws naming slice V4t; and the `(GlGpuTexture)texture` VRAM-accounting cast became a backend test, with a descending synthetic counter supplying the dictionary key off GL. Worth recording: that cast's stated reason — `TextRenderer.DrawSprite`'s texture-unit binding — was already stale, deleted at V6d. Nothing draws with the value. **One latent Vulkan defect was exposed and fixed, and it was ours.** The first composition-host frame died with `ErrorDeviceLost` at `vkQueueSubmit2`, and validation named it: `VUID-vkCmdDraw-None-08600`, "the VkPipeline statically uses descriptor set 2, but because a descriptor was never bound, the VkPipelineLayouts are not compatible." `VulkanGpuPassEncoder` bound sets 0/1/2 only as a *side effect* of `BindStorageBuffer`/`BindUniformBuffer`, so a pass whose pipeline samples the texture table but binds no buffer — every retained-UI and debug-line pass, whose per-draw data travels in push constants and a vertex buffer — drew with the table unbound. It survived V6c–V6g because the bring-up host always drew `VulkanRhiScene` first and its storage binds left all three sets bound in the same command buffer; the UI pass inherited them. The composition host has no 3-D scene, so its UI pass is first and inherits nothing. **The fix is one line in the encoder's constructor, beside the viewport and scissor defaults that exist for exactly the same reason**: a pass must open with complete binding state rather than depend on what preceded it in the frame. This is the third instance of the project's "latent bug masked by a wider path" class, and the first on Vulkan. **What the Vulkan arm deliberately does not have**, each with the slice that brings it: the world renderers and the terrain blending tables (V4t, then the world arm — streaming still runs and builds real heightfields and collision, but publishes into no GPU state and every surface resolves to `SurfaceInfo.None`); DevTools, because ImGui is not ported and V11 deletes it; the GPU-timer bracket, because `FrameProfiler`'s query ring is GL-only and `IGpuDevice.Timers` replaces it at V4h; and the portal tunnel, whose renderer is raw GL — the teleport owner drives a presentation reporting "no tunnel showing" while reveal generation, destination latch, placement and session run unchanged. **Gate results, at the committed `b16f8206`.** The strict GL offline pixel gate against `46d893f7` measures **1.78e-05** (10 differing pixels of 563,200), and measured 1.24e-05 on the immediately preceding tree — both well inside the documented 15–23 px / ≤4.1e-05 band, so GL behaviour did not move. App tests 4,075 / 3 skips; complete Release suite 9,138 / 5 skips. One full Vulkan run with `VK_LAYER_KHRONOS_validation`: **zero errors, zero warnings**, an empty stderr, and the captured UI frame identical to the pre-commit one. Every Vulkan run shut down with the ownership ledger converged — no `[shutdown]` diagnostic on either stream, which is `GameWindowLifetimeStatus.Complete`. The reduced probe harness (`ACDREAM_VULKAN_PROBE=1`) presented 34,811 validation-clean frames and resolved its GPU timer scopes. **One pre-existing test-isolation defect was found and is not this slice's.** `StreamingControllerPriorityApplyTests.DungeonCollapseBeforePromotionBase_RetiresProvisionalTerrainAndPendingStatics` fails when run alone under `--filter` and passes when its project runs whole. It does so identically at `46d893f7` with none of this slice's changes present, so it is an order dependency in the test itself, not a regression. Worth fixing on its own; a test that only passes with its siblings is not evidence. **Next is V4t**, the texture stack, which the world arm cannot be written without. §5.5.9 sized it: 69 references across 9 source and 4 test files for the `ulong` bindless handle alone, on top of `TextureCache`, `CompositeTextureArrayCache`, `ManagedGLTextureArray`, `TerrainAtlas` and `ObjectMeshManager`'s material path. Two things this slice learned should go into it: the composition host is now a real consumer, so V4t's Vulkan side can be exercised the moment it exists; and §5.5.8's recorded one-binding-two-buffers hazard is still unfired, because nothing on the Vulkan arm yet binds the same storage binding to two buffers in one frame. The world arm will. #### 5.5.11 V4t (2026-07-28): the world's data model is backend-neutral §5.5.9's step 3 is done, in two commits — `b8bcaa3e` (terrain) and `565c351f` (everything else). Every world batch now carries a `GpuTextureSlot` rather than a raw 64-bit `ARB_bindless_texture` handle, and the four interim `GlBindlessHandleTable` instances in `WbDrawDispatcher`, `EnvCellRenderer`, `TerrainModernRenderer` and `ParticleRenderer` are gone. The Vulkan world arm's prerequisite is met: its batch structs are already the shape it needs. **§5.2's reason not to reach the device's table expired rather than being worked around.** That paragraph's argument was the flush — `GlGpuDevice` drains its dirty table runs inside `FlushBeforeDraw`, which only an encoder-recorded draw reaches, so a raw-GL renderer would sample a stale table. It assumed V4c would move those renderers onto the encoder. §5.5.6 closed the GL re-land, so they stay raw GL through to V10 and "wait for the encoder" became an indefinite block on the one slice the Vulkan world arm cannot be written without. The resolution is two internal members — `FlushTextureTable` (the drain, factored out of `FlushBeforeDraw`) and the already-existing `TextureTableGlName` — which each raw-GL renderer calls immediately before its own draw, in the exact shape its private table had. Both are deleted with the raw-GL world path. **The slice was narrower than §5.3 sized it, in a way worth recording.** §5.3 wrote "porting the whole texture stack", and the V4t row said "onto `IGpuTexture`/`IGpuSampler`". What landed keeps texture CREATION and residency with the caches — `ManagedGLTextureArray`, `CompositeTextureArrayCache`'s GL backend, `TerrainAtlas` and `TextureCache`'s array upload all still speak raw GL — and moves only the TABLE ENTRY to the device, keyed 1:1 by the caller's already-resident handle. That is the whole of what the data model needed, and it is what let one slice retype nine source and five test files instead of rewriting three texture caches. Creating world textures through `IGpuTexture` is real remaining work and it belongs with the Vulkan world arm, which is the first thing that cannot use a GL handle at all. **Slot release is stricter than the tables it replaces.** The interim tables never released — entries accumulated for the renderer's lifetime, by design and by comment. The device's table is capped at `GpuBindingModel.TextureTableCapacity` (16,384), so an unreleased entry is now a leak with an end, and every producer retires its own: the composite backend and the particle backend at `MakeNonResident`, and `ObjectMeshManager` when a retiring atlas's physical retirement completes. Teardown deliberately does not release — the device dies with its callers, and deferring through a possibly-disposed retirement queue would turn a clean shutdown into a throw. **The default value became load-bearing.** `BindlessTextureLocation` could signal "not resolved" with handle 0 because no texture has handle 0. A slot index has no spare value — `default(GpuTextureSlot)` is real slot 0 — so the type is now a struct storing its slot one-based, making `default` exactly `Unresolved`, with a test pinning that a location naming slot 0 is distinguishable from it. Everywhere else the sentinel was already exact: `GpuTextureSlot.Unassigned` is `0xFFFFFFFF`, which is `common.glsl`'s `ACDREAM_TEXTURE_NONE`, so the classify path's readiness test and the particle billboard's untextured branch kept their meaning unchanged. **`GroupKey` ordering is preserved because the key never ordered anything.** Handle→slot is a bijection, so the same (entity, batch) pairs bucket together. The key reaches equality, hashing and the scene-digest fingerprints, never a comparator: opaque and translucent groups sort by cull mode then camera distance, delayed alpha by viewer distance then submission ordinal, and group enumeration follows the dictionary's insertion order, which a changed hash does not disturb. Both sides of the render-shadow comparison hash the slot index the same way, so the digest value moving is invisible to it. **`SkyRenderer` keeps its `GlBindlessHandleTable`,** which is why that class still exists. Its textures are minted by the sky renderer itself from `TextureCache.GetOrUpload`'s raw GL texture names — the one world path this slice did not retype — so it would be the sole consumer interning handles it produced, a different shape from the rest of the stack. The offline gate also masks the sky band, so the only automated instrument available here could not see a regression in it. V4f owns that renderer and should retire the table and the class together. **Gate results.** GL offline pixel gate against `cb2a70b8`: 3.02e-05 at `b8bcaa3e`, and 5.50e-05 / 3.91e-05 on two captures at `565c351f`. The first of those is above the documented 15–23 px band, so a control was measured rather than assumed: two same-commit captures at `565c351f` differ by 19 px, and a capture at `b8bcaa3e` versus one at `565c351f` differs by **9 px** — fewer than the same-commit control, across two different commits. Maximum channel delta is 41–52 in every pair including the controls, so the differing pixels come from one flickering population rather than from moved geometry. Both commits passed `tools/run-repeat-connected-gate.ps1 -Runs 3` at 3/3 RENDERED on the desktop witness and the client capture, and one Vulkan composition-host run each with `VK_LAYER_KHRONOS_validation` **proven inserted by the loader** — zero errors, zero warnings, converged ownership ledger. App tests 4,077 / 3 skips and the complete Release suite 9,140 / 5, both baselines plus the two tests added. **One connected run died and is filed, not attributed.** The first 3-run attempt at `b8bcaa3e` lost one run to an unhandled `OpenGL returned unexpected fence wait status NoError (0x0)` in the render loop. It did not reproduce in the following three runs at that tree nor in three interleaved runs at `cb2a70b8`, and V4t creates, deletes and waits on no fence. `#251` records it with the evidence; it is the same below-the-API shape §5.5.1–§5.5.3 documented four instances of on this driver, but that is a hypothesis and the issue says so. **Next is the world arm** — V4c/V4d's content behind the construction-time backend selection at the frame-root seam. Two things this slice hands it: §5.5.8's one-binding-two-buffers hazard is still unfired and the world arm is what will fire it, since `WbDrawDispatcher` and `EnvCellRenderer` each own instance and batch buffers and both bind bindings 0, 1, 3, 4 and 5 in one frame; and the world path's own texture creation still has to reach `IGpuTexture`, because a Vulkan draw cannot sample a GL handle. #### 5.5.12 V6i-1 (2026-07-28): the descriptor-set hazard is closed before the world arm fires it §5.5.8 recorded, and deliberately did not fix, that **a binding pointed at two different buffers within one frame silently corrupts the earlier draws**: the backend rewrote the descriptor in place, and a descriptor set's contents are read when the command buffer EXECUTES, not when it was recorded. Nothing fired it while the Vulkan frame held only the retained UI. §5.5.11 handed it forward as the first thing the world arm would hit, because `WbDrawDispatcher`, `EnvCellRenderer` and `TerrainModernRenderer` each own their own instance, batch and indirect buffers and all three bind set 0 in one frame. It is now closed, and it was closed *first* rather than alongside the world arm, so that a blank or corrupt Vulkan world frame cannot be this defect wearing another face. **One set pair per renderer scope, derived rather than declared.** There is no longer a single (set 0, set 1) pair per flight slot; there is an arena of them. `VulkanBindingScopeArena` — pure bookkeeping, no Vulkan handles, unit-tested — decides which pair a bind belongs to and whether its descriptors must be written. The scope key is the descriptor state itself: the ten storage buffer identities and ranges, the plain (non-dynamic) bindings' offsets, and the two uniform buffer identities and ranges. Deriving the scope is a decision, not an economy. **The pinned contract has no place to name one** — `BindStorageBuffer` takes a buffer, an offset and a size, and §3.3 is frozen. Deriving it from state also gives two properties a declared scope would not: a renderer cannot forget to declare one, and two renderers that genuinely share every buffer correctly share one pair instead of being told to differ. A renderer's buffers are stable for its lifetime, so "distinct descriptor state" *is* "renderer scope". **Dynamic offsets stay free.** A ring allocation moving between draws rides `vkCmdBindDescriptorSets`'s dynamic-offset array, so it costs neither a new pair nor a descriptor write — §4.4's "zero descriptor writes per frame" property survives a frame now having more than one binding state in it. A steady frame rewrites nothing at all: entries are not invalidated at `BeginFrame`, because the slot's previous submission has retired and its descriptors still say exactly what this frame is about to say. An entry matched from the previous frame is swapped below the live cursor, so the rest of the frame cannot take it for a different state — the property nine tests pin, including the ordering case where two renderers swap their submission order between frames. **Two things the world arm still needs, unchanged from §5.5.11's handoff**, plus one this slice measured: 1. **World texture CREATION must reach `IGpuTexture`.** V4t moved the table ENTRY to the device and left creation with `TerrainAtlas`, `CompositeTextureArrayCache`, `ManagedGLTextureArray` and `TextureCache`'s array path. `TerrainAtlas` is the smallest of these and the only one terrain needs; `TextureAtlasManager`/`ManagedGLTextureArray` is the largest, and it is what statics, scenery and EnvCell shells sample through. `ICompositeTextureArrayBackend` is already a seam and takes an RHI backend directly; `TextureAtlasManager` is not, and reaches `OpenGLGraphicsDevice` through `CreateTextureArrayInternal`. `BlockCompressionCodec` and `BlockCompressionMipChain` (V6b) already supply the BC mip chains that path needs, so the missing piece is an `ITextureArray` implementation over `IGpuTexture`, not a codec. 2. **`terrain_modern.vert`'s `TerrainClip` block is in the wrong descriptor set, measured on the committed SPIR-V.** It is declared `layout(std140, binding = 2) uniform TerrainClip` with **no `ACDREAM_UBO_SET`**, so under the Vulkan dialect it lands in set 0. Disassembling `spv/terrain_modern.vert.spv` confirms it rather than infers it: the block is `OpVariable ... Uniform` decorated `DescriptorSet 0` / `Binding 2`, and set 0's layout declares binding 2 as a STORAGE buffer. A terrain pipeline built against the shared layout is therefore malformed. GL is unaffected — the macro expands to nothing and GL's UBO namespace is separate — and nothing has caught it because no terrain pipeline has ever been created on Vulkan. **The audit is done and this is the only one.** Every compiled `.spv` was disassembled: `mesh_modern.vert`'s nine set-0 entries are all `StorageBuffer`, correctly; every other uniform block in every other shader already carries the macro. `sky.vert` is the precedent — it declares the SAME block as `ACDREAM_UBO_SET binding = 2` — so there is no numbering question to settle, only a one-word omission to fix. The fix is that word plus declaring bindings 2 and 4 in `VulkanPipelineLayouts.CreateUniformSetLayout` (4 is §5.5.8's still-open `UniformSkyParams`, which `sky.vert` and `sky.frag` both declare) and raising `DynamicUniformBindingCount` accordingly — still far under Vulkan's guaranteed 8. 3. **The flat-versus-PView question decides how much of the world arm is one slice.** `WorldSceneRenderer` is already backend-neutral — it takes `IWorldScenePassExecutor` and `IWorldScenePViewRenderer` as interfaces — so the Vulkan arm reuses it whole. But `WorldRenderFrame.ClipRoot` is `Roots.ViewerRoot ?? Buildings.OutdoorNode`, and the offline gate's scene has buildings, so the offline capture takes the **PView** path, not the flat safety path. A Vulkan world arm that implements only `WorldScenePassExecutor` therefore renders nothing in the very scene the pixel gate captures. `RetailPViewPassExecutor` (685 lines) is on the critical path to the first Vulkan Dereth PNG, and the "terrain only" intermediate §5.5.7 suggested is consequently NOT cheaper than it looks. **Gate results.** Strict GL offline pixel gate against `b9ab5890`: **1.60e-05** (9 differing pixels of 563,200), at the low end of the documented 9–31 px band and 62x under the 0.001 threshold — expected, since no GL file is touched. GL connected `tools/run-repeat-connected-gate.ps1 -Runs 3`: **3/3 RENDERED** on the desktop witness and 3/3 on the client capture. One offline Vulkan run with `VK_LAYER_KHRONOS_validation` **proven inserted by the loader** (`Insert instance layer "VK_LAYER_KHRONOS_validation"` from `VK_LOADER_DEBUG=layer`): **zero validation errors and zero warnings**, a captured retained-UI frame, and no `[shutdown]` diagnostic on either stream. App tests 4,086 / 3 skips (the 4,077 baseline plus nine); complete Release suite 9,149 / 5. `#250`'s `SurfaceOverrideFingerprint_DictionaryHotPathAllocatesNothing` failed once in a whole-suite run and passed run alone, exactly as that issue documents. **What this slice deliberately does NOT do**: draw a world. The captured Vulkan frame is V6h's — the retained UI over the atmosphere fog clear — because no world renderer exists on the Vulkan arm yet. The arena's multi-scope path is therefore exercised only by its unit tests; the live Vulkan frame binds one scope and re-matches it every frame, which is the "rewrites nothing" case. That is the 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`: **3.02e-05**, **3.20e-05** and **1.60e-05** at the three commits in order (17, 18 and 9 differing pixels of 563,200), every one inside the documented 9–31 px control band and at least 31x under the 0.001 threshold. Commit 3's 9 px is fewer than a same-commit control has measured, which is the expected shape for a change that alters no GL statement. 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.5.14 V6i-3 (2026-07-28): the mesh pipeline runs, and the frame has a world pass §5.5.13's remainder was items 3–5 — the three world renderers' submission arms, `RetailPViewPassExecutor`, and the pass-structure merge — plus the mesh pipeline's raw-GL upload bodies. **This slice delivered the two prerequisites and did NOT deliver the world arm.** Stating that first, because the slice's brief was a Dereth PNG and there is not one. **1. The mesh pipeline's upload bodies cross the seam** (`fe8abacf`). V6i-2 cut `IMeshPipelineDevice` and proved the pipeline could be CONSTRUCTED without naming a backend; it said plainly it did not RUN. `GlobalMeshBuffer` now takes `GL?`. Its two backing stores were already `IGpuBuffer` (V4b); what still needed a context was the vertex array and its attribute pointers, which have no RHI verb because Vulkan bakes vertex input into the pipeline. A backend with none builds the stores and nothing else, publishes 0 for `VAO`/`VBO`/`IBO`, and publishes `VertexStore`/`IndexStore` — the same buffers, named the way a pass encoder binds them — plus `HasStores`, the backend-neutral form of the `VAO != 0` readiness test the raw-GL draw paths make. Two bodies fork on the context: `InitBuffers` skips the vertex array, and `CommitMigration` skips the rebind, because on the encoder arm the field swap IS the atomic publication. `ObjectMeshManager`'s `RequireGl` narrowed to the LEGACY per-mesh upload — every GL statement it guarded sits inside `if (!_useModernRendering)`, which the N.5 ship amendment makes unreachable, so the accessor survives as the guard on dead code rather than as a blocker. `VulkanMeshPipelineDevice` is the second implementation and is four properties and two no-ops; `WbMeshAdapter` selects between them once. **`NullWbMeshAdapter` is deleted** — it existed for exactly this gap — and composition builds the mesh pipeline on both arms, so streaming's publication into GPU state stops being a no-op there. **2. The clear merges into the world pass** (`887de4ae`), which is §5.5.12 item 5. V6h's clear phase opened a backbuffer pass of its own; under MSAA that pass resolves into the swapchain image and stores `DONT_CARE` into the multisampled scratch, so any world pass that followed would `Load` undefined contents. The clear phase now publishes only the COLOUR and `VulkanWorldScenePhase` opens the one backbuffer pass, clearing as its load op with `Store = Resolve`. **This is why the world renderers cannot each open their own pass on this backend**, and it is the whole reason the arm is shaped differently from V4c's. The same commit made descriptor sets bind at DRAW time rather than at bind time. V6i-1's arena derives a scope from the descriptor state, and the encoder resolved after every bind — so a renderer binding ten buffers materialised up to ten scopes per draw, nine of them PARTIAL states no draw uses, each claiming a descriptor-set pair and a round of `vkUpdateDescriptorSets`. Legal to defer because acdream has one pipeline layout by design (§4.4). **Gates.** Release build green. App tests **4,112 / 3 skips** (the 4,109 baseline plus three). Strict GL offline pixel gate against `579e0b7f`: **4.44e-05** at commit 1 and **3.73e-05** at commit 2 (25 and 21 differing pixels of 563,200), both inside the documented 9–31 px control band. GL connected `tools/run-repeat-connected-gate.ps1 -Runs 3` at HEAD: **3/3 RENDERED** on the desktop witness and 3/3 on the client capture. One offline Vulkan run per commit with `VK_LAYER_KHRONOS_validation` **proven inserted by the loader**: zero errors, zero warnings, no `[shutdown]` diagnostic. The Vulkan capture at commit 2 is compared against commit 1's rather than eyeballed — **0 differing pixels of 921,600, maximum channel delta 0**, bit-identical across the merge. **What the world arm still needs, measured rather than estimated.** Every item below was designed and, where noted, written and then withdrawn because nothing exercised it — §7.1 rule 3. The design decisions are the expensive part and they are recorded here so the next slice does not re-derive them. 1. **One pass, shared by every world renderer.** V4c's renderers each opened their own `Load`/`Store` pass. On this backend they must record into the pass `VulkanWorldScenePhase` opens, for the resolve reason above. The seam that fits is a `VulkanWorldPassScope` the phase publishes the encoder on and the renderers borrow, with the phase bracketing `WorldSceneRenderer`. 2. **Three sections are frame-global on GL and cannot be on Vulkan**: the SceneLighting UBO (set 1 binding 1), the per-cell clip regions (set 0 binding 2) and the terrain clip block (set 1 binding 2). GL binds each globally and every consumer inherits it. `SceneLightingUboBinding` and `ClipFrame` need ring-writing arms that PUBLISH their sections on the scope; each renderer then binds them inside the pass, after its own binds, because its own binds are what select the descriptor scope they have to land in. 3. **Retail's interior depth clear has no RHI verb.** `RetailPViewPassExecutor` issues `glClear(GL_DEPTH_BUFFER_BIT)` between the landscape slice and the interior cells. Splitting the world pass to get a depth load-op is exactly what the resolve forbids, so the Vulkan arm must record `vkCmdClearAttachments` — reached through the scope, so the pinned contract stays frozen and the backend-only verb stays inside the backend. 4. **Clip distances need no enable on Vulkan, and that is safe rather than a divergence.** GL requires `glEnable(GL_CLIP_DISTANCE0 + i)`; Vulkan activates every element the shader declares. All three vertex shaders already write `1.0` — keep everything — into every plane slot past the active count, so a frame with no clip planes clips nothing on either backend. `EnableClipDistances`/`DisableClipDistances` are no-ops on the Vulkan arm. 5. **Only FOUR storage bindings can be dynamic, and the world arm re-points eight per draw.** §5.5.8 offered to promote bindings 6–8 back to dynamic and said there were "four unused dynamic slots"; there are not. Vulkan's guaranteed `maxDescriptorSetStorageBuffersDynamic` is **4**, which is exactly what V6g spends (bindings 0, 1, 3, 5). Bindings 4, 6, 7 and 8 are per-draw ring allocations too, so on Vulkan each moves the descriptor's own offset and therefore costs a descriptor write and a distinct arena entry per draw. Correct, bounded (the arena recycles entries across frames), and the reason the draw-time bind above matters: without it the cost is ten scopes per draw instead of one. A slice that wants it cheaper has to change the shaders' indexing, not the layout. 6. **Pipeline `SampleCount` is load-bearing on Vulkan.** V4c created every world pipeline with `SampleCount = 1` because the GL backend ignores it. Vulkan requires the pipeline's `rasterizationSamples` to match the pass, and alpha-to-coverage requires MSAA, so the world pipelines must be created with the device's sample count. 7. **The collision-wireframe debug lines would nest a pass.** `WorldSceneDiagnosticsController.DrawAndPublish` flushes `DebugLineRenderer` INSIDE the world phase, and that renderer opens its own pass. On the Vulkan arm the diagnostics controller must be composed with a null debug-line renderer: the toggle is DevTools-only and DevTools is not composed there, so nothing is lost, but composing it would throw on the first wireframe frame rather than silently misdraw. Beyond that the remainder is unchanged from §5.5.13: the three renderers' submission arms and `RetailPViewPassExecutor`, which §5.5.12 item 3 established is on the critical path because the offline gate's scene has buildings and therefore takes the PView route. #### 5.5.15 V6j (2026-07-28): the world arm lands, and Dereth draws on Vulkan §5.5.14's remainder — the three renderers' submission arms, `RetailPViewPassExecutor`, and the frame-global sections — is done. **`ACDREAM_RENDER_BACKEND=vulkan` renders the offline scene: terrain with blended textures and road overlays, the water edge, static world meshes, procedural scenery, and the complete retained UI**, from the same camera as the GL capture (`artifacts/v6j-vk2` against `artifacts/v6j-head2`). Sky is still the atmosphere fog clear, because `SkyRenderer` is raw GL until V4f. Three predecessors stopped at this unit under §7.1 rule 3. It landed as one slice in two commits, because the rule is satisfied at SLICE granularity: the first commit's piece is unreachable on its own and the second wires it and exercises it. **1. The winding inversion was wrong, and nothing had ever asked** (`81fe5e1b`). `VulkanViewportMapping` has inverted the front face since V6c, on the standard negative-viewport argument. Every Vulkan consumer through V6i declares `Cull = GpuCullMode.None`, so the mapping had never decided a fragment. The world arm is its first culling consumer and falsified it twice on one frame: terrain — the one single-sided surface acdream draws, `FrontFace(Ccw)` + `Cull(Back)` per `ACRender::landPolysDraw`'s eye-side predicate — vanished entirely while issuing 190 multi-draw commands against 625 loaded landblocks, and every closed building shell rendered inside-out with its front wall culled and its interior beams visible through the gap. The identity mapping restores both at once. Two independent surfaces, one change. The viewport flip itself is untouched and still correct; what goes is the claim that a winding inversion travels with it. **Worth generalising**: this is the third defect this campaign has found in a path that compiled, validated clean, and had a test — because the test asserted the behaviour rather than the requirement, and no consumer exercised it. §5.5.12's descriptor layouts and §5.5.13's `TerrainClip` set were the first two. **2. The world arm** (`f84eef32`). V4c's and V4d-2's content returns as a SECOND arm, per §5.5.6's option (B): the GL arm issues the same statements in the same order, and the encoder arm lives in three `.Rhi.cs` partials entered by one branch per submission site. Three differences from V4c, each because the tree moved — no binding-9 table (V4t put the slot on the device; Vulkan binds set 2), pipelines carry the device's sample count, and **no renderer opens a pass**. `VulkanWorldScenePhase` opens the frame's one backbuffer pass, publishes the encoder on `VulkanWorldPassScope` for exactly the span of the inner `WorldSceneRenderer`, and every renderer borrows it — §5.5.14 item 1's shape, built as specified. The three frame-global sections are PUBLISHED on `WorldFrameSections` and each renderer binds them inside the pass after its own binds (item 2). The interior depth clear is `vkCmdClearAttachments` through the scope (item 3); clip distances are no-ops (item 4); the collision-wireframe `DebugLineRenderer` is composed null (item 7). **Both pass executors became backend-neutral rather than gaining twins.** That is the one place this slice diverged from §5.5.14's sketch, and it is why the slice was tractable. Everything those two classes do is delegation to a renderer except four concerns — the clip-frame publication, the doorway scissor, `gl_ClipDistance` enablement and the interior depth clear — so those moved behind `IWorldPassSurface` and retail's ordering, which is what the classes are actually for, is written once for both backends. `RetailPViewPassExecutor` was on the critical path precisely because the gate's scene has buildings (§5.5.12 item 3); it now serves both arms unchanged. **The A8 question is answered.** §5.5.13 and §5.5.14 asked what happens to the Phase A8 `CullMode.Landblock → None` override the moment world materials draw on a second backend. It carried over verbatim and is still load-bearing: `EnvCellRenderer`'s Vulkan arm applies the same rewrite, for the same reason, and the offline scene has no interior to disprove it with. The override is therefore neither vindicated nor retired by this slice — it is now a divergence with two consumers instead of one, and the dungeon pass in §5.1's checklist is what will settle it. **Gates.** Release build green. App tests **4,112 / 3 skips** — the unchanged baseline; complete Release suite **9,175 / 5**. Strict GL offline pixel gate against `847f14ae`: **5.50e-05** (31 differing pixels of 563,200), inside the documented 9–31 band and 18x under the threshold. Characterised rather than accepted, because 31 is the band's top: ``` cross-commit (847f14ae vs V6j): 21, 29, 31 same-commit (V6j vs V6j): 12, 20 maximumChannelDelta, all pairs: 46-52 ``` The max delta is 46–52 in every comparison INCLUDING the pure same-commit controls, so the few large-delta pixels are a property of the capture. A cross-commit pair at 21 against a same-commit pair at 20 is not what a systematic shift looks like — a real one floors every cross-commit comparison above every same-commit one. GL connected `tools/run-repeat-connected-gate.ps1 -Runs 3`: **3/3 RENDERED** on the desktop witness and 3/3 on the client capture. One offline Vulkan run with `VK_LAYER_KHRONOS_validation` **proven inserted by the loader**: **zero validation errors, zero warnings**, a captured world frame, graceful close. `tools/run-offline-vulkan-capture.ps1` is the new script that produces it. **The Dereth PNG, inspected honestly.** Right: terrain geometry, terrain blending and its T-code transitions, road overlays, the water edge and its shoreline, static world meshes with correct silhouettes and closed shells, procedural scenery, per-instance lighting, the fog colour, and the complete retained UI including the radar. The frame matches the GL capture's framing and content element for element below the horizon. Wrong, and each already owned by a later slice: **the sky is flat fog** (V4f — `SkyRenderer` is raw GL, so there is no dome, no sun, no cloud sheet, no horizon band); **no particles** (V4e); **no paperdoll or appraisal viewport and no portal depth mask** (V4g); **no GPU frame timings** (`NullRenderFrameGpuMeasurement` until V4h). None of these is a defect in this slice — they are the renderers it deliberately does not compose. **The V7 defect list — what a differential must not be run against yet.** 1. **Sky, particles, viewports, portal mask** — absent, not wrong. V4e/V4f/V4g. 2. **§5.4's `Target: null` divergence is still live on GL.** V7 must not run until V4g/V4h remove it, or it surfaces as an entire viewport on the wrong surface — unchanged from §5.4's own warning, and now more binding because both backends draw a world. 3. **`EnvCellRenderer`'s Vulkan arm is unproven by anything automated.** The offline scene has no interior. This is V4c's exact debt, restated on the new backend. 4. **The deferred-alpha path and the doorway scissor are unexercised** in the offline scene: no alpha queue collects and no doorway slice is assembled, so `DrawPreparedAlphaBatch`'s Vulkan arm and `RhiWorldPassSurface.BeginScissor` have run zero times. 5. **MSAA must be forced off for the differential**, as §5.1 already states — sample positions are not specified across implementations, and the world pipelines now genuinely differ in sample count from the UI's. 6. **Bindings 4, 6, 7 and 8 cost a descriptor write per draw** (§5.5.14 item 5). Correct and bounded, and a V8 perf item rather than a correctness one, but the arena's `ScopeCount` is the number to watch. **What this slice does NOT claim.** It has not run connected on Vulkan, it has not been seen by the user, and no dungeon, portal, paperdoll or spell effect has been drawn on it. The milestone "full game frame on Vulkan" needs V4e–V4g behind it. #### 5.5.16 V6k (2026-07-28): the sky draws, the viewports name their targets, and particles hit the contract Two of the three renderers §5.5.15 listed as absent are landed, §5.4's obligation is discharged, and the third renderer stopped against the pinned contract rather than working around it. **1. The sky** (`22aa2edc`). V4f's content, as a second arm per §5.5.6. `ACDREAM_RENDER_BACKEND=vulkan` renders the dome quadrants, the horizon band, the cloud sheet and the fog gradient in the same place and the same colours as the GL capture of the same scene. Three differences from the GL arm, each because Vulkan bakes what GL sets: the per-submesh blend function becomes two PIPELINES; the `SkyParams` block becomes a ring slice taken per draw, because a descriptor's contents are read at execution time and not at record time; and the pass is borrowed from `IWorldPassScope`. The sky is **the first Vulkan consumer of set 1 binding 4** — §5.5.8 recorded that `UniformSkyParams` was missing from the uniform set layout and V6i-2 added it, but nothing had ever bound it. **The fourth defect of the compiles-clean class, and the first the campaign found by capture rather than by validation.** The first Vulkan sky frame drew the dome as a field of blue-white noise. The RHI vertex layout declared a 32-byte stride — position, normal, texcoord, exactly what `sky.vert` reads — while `AcDream.Core.Terrain.Vertex` is **36 bytes**: it carries a fourth member, `TerrainLayer`, that no sky attribute names and that the GL arm never describes to a `glVertexAttribPointer` but does count, because it says `sizeof(Vertex)`. No validation rule was violated, nothing else in the frame looked wrong, and the offline gate masks the sky band. `SkyVertexLayoutTests` now asserts the requirement — the stride is the uploaded record's footprint — rather than the number, which is the lesson §5.5.15 drew from the winding inversion applied to a different surface. **Worth generalising: every `.Rhi.cs` arm that declares a `GpuVertexLayout` is restating a CPU record's footprint from memory, and only one of them has a test.** The slice also retired **the last interim `GlBindlessHandleTable` in the tree**. V4t left the sky's because the sky is the one world path that mints its own resident handles rather than interning someone else's; it now registers them through V4t's `RegisterWorldTextureHandle` seam, and the class and its six tests are deleted. `TextureCache.RegisterWorldSurface(surfaceId, repeat)` is the RHI arm's texture source: the same `DecodeFromDats`, created through `IGpuDevice.CreateTexture` and paired with a real sampler object. **2. The viewports, and §5.4** (`eb7e6b4e`). `PrivateEntityViewportRenderer` asks the device for an `IGpuRenderTarget` instead of hand-rolling an FBO, colour texture and depth renderbuffer; its pass DECLARES that target; and the colour attachment is registered into the global table like any other texture. That deletes the V4a pre-approved transitional seam (`RegisterExternalColorTexture` / `TryResolveExternalColorTexture`) on the slice §7.1's final paragraph named as its end, and makes both retained-UI frame views backend-neutral. **§5.4's answer is not the one that section predicted, and the distinction matters.** The divergence itself — GL's `BeginPass` refusing to bind framebuffer 0 for a null target — has not been on the tree since the V4c revert (`543bc79f`) took that hunk with it. What the revert did not undo was the reason it existed: this renderer bound a framebuffer no pass had declared. It now names its target, and `PortalTunnelPresentation` was re-read and draws into the active viewport — the backbuffer, which is what a null target literally means. **V7 is no longer blocked on §5.4.** **The §5.5.7 re-check does not come back clean, and is now a loud precondition.** That note asked that "the render-target-view-in-table usage from V6c did not fire" not be carried forward as accepted. It still does not fire, and now for a reason worth stating: a Vulkan render-target image is viewed as `VK_IMAGE_VIEW_TYPE_2D` because that is what an ATTACHMENT needs, while the texture table's descriptor array is declared `sampler2DArray`, so registering one is invalid usage rather than a mismatch that samples oddly. It has never fired because the only renderer with an offscreen target is composed on GL alone. `VulkanGpuDevice.RegisterTexture` now refuses it and names the fix — a second, layered sampled view per render target — so the slice that gives the Vulkan arm a viewport meets a precondition instead of a driver-level fault. **3. Particles are NOT landed, and the reason is the pinned contract.** `GpuVertexLayout` has one stride and no instance divisor, and `IGpuPassEncoder.BindVertexBuffer` binds one buffer at `VertexInputRate.VERTEX`. Both particle pipelines draw with per-instance vertex attributes — `particle` at locations 2–6 (centre, two sheet axes, colour, texture slot) and `particle_mesh` at 3–7 (a `mat4` model and a colour). **The contract can express instanced DRAWING — `Draw`/`DrawIndexed` both take `firstInstance` — but not instanced vertex INPUT**, which is what these two shaders are built on. Three ways out, each a decision rather than an implementation: - **(i) Grow the contract**: a second vertex binding with a divisor. Smallest change at the call sites, and the same shape as the four amendments already taken (`InverseAlpha`, `UByte4UInt`, `ColorFormat`, the tiling binding). - **(ii) Move particle instances to a storage buffer** indexed by `gl_BaseInstanceARB + gl_InstanceID`, which is how the world path already works. This converges the shaders rather than forking them, but it needs a storage binding, and `GpuBindingModel`'s ten are all spoken for — reusing binding 0 would have the GL particle draw clobber `WbDrawDispatcher`'s instance array mid-frame, which is §5.5.8's hazard in its GL form. So this is a binding-model change, i.e. also a contract change. - **(iii) CPU-expand instances to per-vertex data on the Vulkan arm only.** Needs no shader, contract or GL change and would change no pixel, but it multiplies billboard vertex bandwidth about five-fold and does not scale to mesh particles at all, where it would duplicate a whole mesh per instance. Option (ii) is the one the campaign's own architecture points at, and (i) is what makes it unnecessary. Either way, **V4e stays open** and the choice belongs to whoever owns §3.3. **Gates, per commit.** Release build green. App tests **4,109 / 3 skips** — the 4,112 baseline less the six `GlBindlessHandleTable` tests that went with the class, plus three vertex-layout tests; complete Release suite **9,172 / 5**. Strict GL offline pixel gate: **4.43e-05** (25 px) at commit 1 against `7ae796a1` and **4.08e-05** (23 px) at commit 2 against `22aa2edc`, both inside the documented 9–31 band with `maximumChannelDelta` 48 — the same 46–52 every control pair reports. GL connected `-Runs 3`: **3/3 RENDERED** on the desktop witness and 3/3 on the client capture, per commit. One offline Vulkan run with `VK_LAYER_KHRONOS_validation` proven inserted by the loader per commit: **zero validation errors, zero warnings**. **Two uncovered surfaces were checked rather than banked.** The sky band is masked by the pixel gate, so commit 1 carries a seven-day-group before-and-after comparison on GL — V6e's method — matching in gradient, cloud sheet, horizon band and fog on every group, including day group 2's salmon band and day group 6's green one. The offline scene never opens the inventory, so commit 2 carries a connected run that presses `ToggleInventoryPanel` and captures the result (`artifacts/v6k-paperdoll`): the doll renders through the new render target with the correct pose, orientation and alpha. That is §5.1's "the paperdoll, then examine an item" row, half-discharged — the appraisal viewport shares the same class and the same code, but was not itself driven. **The Vulkan frame, inspected against a GL capture of the same scene.** Captured back to back (`artifacts/v6k-vk-final` versus `artifacts/v6k-gl-final`), both at 4× MSAA with alpha-to-coverage on, the two frames differ in **8.83% of pixels (81,359 of 921,600), mean absolute channel delta 0.82, maximum 174**. The difference map (`artifacts/diff-gl-vk.png`) puts essentially all of it in two populations, and neither is a defect: - **Foliage and silhouette edges** — 51,003 of the 81,359 differing pixels are in the top 200 rows, which is the treeline, and the map shows the population hugging every tree and building outline. These are alpha-to-coverage sample positions, which are explicitly unspecified across implementations; §5.1 already requires the V7 differential to force MSAA off for exactly this. - **The sky band's own animation** — two launches cannot agree on the Dereth clock, which is why the GL gate masks the same band. Terrain, blending, roads, the water edge and the entire retained UI are pixel-quiet: rows 300–720 contribute 980 differing pixels in total, 0.13% of the population. **The V7 defect list, updated.** 1. ~~Sky~~ (landed), ~~viewports~~ (landed, GL arm), ~~§5.4~~ (discharged). **Particles remain absent** on both the Vulkan arm and V4e, blocked on the contract question above. 2. **`PortalDepthMaskRenderer` is unported and needs a contract dimension.** Its two-pass punch (#117) is built on `glStencilFunc`/`glStencilOp`/`glStencilMask` and `GpuPipelineDescription` has no stencil state at all. It is raw GL, it works, and it is invisible to the Vulkan arm, so nothing is broken — but the V4g row's "stencil/depth-mask pipelines" cannot be written against the pinned contract as it stands. 3. **The paperdoll and appraisal viewports do not exist on the Vulkan arm**, and giving them one is more than composition: the offscreen target is single-sampled by contract while every world pipeline is built at the backbuffer's sample count, so `WbDrawDispatcher` would need sample-count pipeline variants the way §5.5.8 gave it depth-format variants. Plus the layered-view fix in item 2 of the §5.5.7 re-check above. 4. **`EnvCellRenderer`'s Vulkan arm is still unproven by anything automated** (unchanged from §5.5.15), as are the deferred-alpha path and the doorway scissor. 5. **MSAA must be forced off for the differential** — now measured rather than predicted: it is 8.8% of the frame at 4×. 6. **Bindings 4, 6, 7 and 8 cost a descriptor write per draw** (unchanged; a V8 item). #### 5.5.17 V6l (2026-07-28): particles, the portal mask and the viewports land, and three amendments are taken The three things §5.5.16 left on the V7 list as *blocked on a contract decision* are all on the Vulkan arm. Each amendment is its own reviewed commit with `GpuContractTests` coverage, in the same shape as `InverseAlpha` (V4c), `UByte4UInt` (V4d), `ColorFormat` (V6d) and `UniformSkyParams` (V6e). **1. Instanced vertex input, and particles** (`b1ad1d48`). §5.5.16's option (i): `GpuVertexLayout` grows a per-binding notion — binding index, stride, input rate — `GpuVertexAttribute` names the binding it is fed from with a default of 0, and `IGpuPassEncoder.BindVertexBuffer` takes a binding index. Every layout written before the slice keeps its exact meaning through `GpuVertexLayout.Interleaved`, which is one vertex-rate binding 0, and a contract test asserts that as a requirement rather than trusting it. Both backends carry the rate natively and at no cost: `VK_VERTEX_INPUT_RATE_INSTANCE` on the pipeline, `glVertexAttribDivisor` recorded once into the pipeline's VAO where it survives every later attribute rebind. `GpuVertexFormat.UInt1` comes with it and is **necessary to it**: `particle.vert` declares `layout(location = 6) in uint aTextureIndex` and the amendment's whole premise is that no shader is edited. Same kind-distinction `UByte4UInt` was added for — GL requires `glVertexAttribIPointer`, Vulkan requires `R32_UINT`, and the float path would reinterpret the value's bits rather than approximate them. `ParticleRenderer.Rhi.cs` is V4e's content as a second arm. Five pipelines replace the imperative `glBlendFunc` switch; the per-flight VAO/VBO pool disappears because every ring allocation inside a frame is already distinct memory that lives until the frame retires; the binding-9 table is not bound at all; the pass is borrowed from `IWorldPassScope`. Depth tests but does not write, compare is `Less` and alpha-to-coverage is off — the ambient GL state particles have always drawn under rather than a choice. **The fifth defect of the compiles-clean class, found by running rather than by validation.** The first Vulkan particle frame threw: `TextureCache.AcquireParticleTexture` was bindless-only, so the standalone particle texture cache did not exist on a backend without GL. It exists on both arms now — everything about it that matters, the owner sharing, the bounded unowned LRU and retirement behind the frame-flight fence, was already backend-neutral, and only how one entry is created and destroyed differs, which is what `IStandaloneBindlessTextureBackend` is for. **The durability fix V6k earned.** That slice found the sky declaring a 32-byte stride against a 36-byte `AcDream.Core.Terrain.Vertex` and noted that *every* `.Rhi.cs` arm restates a CPU record's footprint from memory while only sky had a test. `RhiVertexLayoutStrideTests` is that test for the rest — world mesh, terrain, sky, retained-UI sprite, debug line and both particle bindings, each against the record or the producer's own float count, plus two sweeps over all seven for attributes that reach past their stride or name an undeclared binding. **2. The stencil dimension, and the portal mask** (`eced67d0`). §5.5.16 defect 2. The amendment splits the way core Vulkan 1.3 splits: the ENABLE and the attachment intent are baked as `GpuPipelineDescription.StencilTest` (false by default, so no pipeline changed), and the per-draw compare, three outcome ops, reference and both masks are a `GpuStencilState` the pipeline carries as a DEFAULT and `IGpuPassEncoder.SetStencil` overrides — exactly the split cull mode, front face and depth write already have. The four stencil dynamic states are declared **only** by a pipeline that tests stencil, because declaring one obliges every draw with that pipeline to have set it. `PortalDepthMaskRenderer` gets three pipelines rather than one: depth COMPARE is not dynamic in the contract and the punch's two passes differ in it — mark tests `LEQUAL` and writes no depth, punch tests `ALWAYS` and writes — with the seal a third. All three write no colour, which is what retail's "COLOR-INVISIBLE triangle fan" means. The fan is expanded to a triangle LIST on the CPU, exactly: triangle *i* is (v0, v[i+1], v[i+2]). **This is the one renderer in the campaign whose two arms do not share a shader source, and the reason is worth recording.** `portal_depth.vert`'s clip planes have to travel in the `TerrainClip` uniform block at binding 2 — already precisely this shape, already read by `terrain_modern.vert` and `sky.vert` — but on GL that binding is held globally by `ClipFrame` for terrain, so a portal draw that rebound it would leave every later terrain draw in the frame reading the wrong region. The GL arm therefore keeps its inline program. `PortalDepthShaderParityTests` is the tripwire: retail's far-Z constant (`0.99999988`, `DrawPortalPolyInternal` 0x0059bc90), #129's capped mark-bias expression and the eight-half-plane loop are asserted to appear in both. Both are deleted at V11. **9/10 shader pairs now compile to SPIR-V.** Two GL-side gaps closed while the state was being extended, both §7.1 rule 1's class rather than new work: `GlAmbientCapabilityState` now saves and restores the stencil test, function, ops and both masks — the punch draws mid-frame among renderers that are still raw GL and assume the test is off — and the COLOUR MASK, which had no consumer until a colour-invisible pipeline existed and whose absence would have blacked out every raw-GL renderer after such a pass. `PortalTunnelPresentation` was re-read and confirmed as V6k left it: it clears depth and draws into the active viewport, binds no framebuffer of its own, and needs no port for §5.4's sake. It remains unported on the Vulkan arm — the composition uses `NullLocalPlayerTeleportPresentation` there — which is an absence on the V7 list, not a defect. **3. The offscreen viewports** (`2e8b8b91`). §5.5.16 defect 3 named two backend fixes; both are here, and running it found two more the note could not have known about. - **The layered sampled view.** An attachment view must be `VK_IMAGE_VIEW_TYPE_2D` and the table's descriptor array is `sampler2DArray`, so V6k made `RegisterTexture` refuse the attachment view and name this fix. `VulkanGpuTexture` now creates a SECOND, layered view over the same image for a colour render target — one image, one allocation, two ways of looking at it, legal without any creation flag — and `SampledView` is what the table registers for every texture, so the question disappears rather than being answered. - **Sample-count pipeline variants.** `WbDrawDispatcher`'s five pipelines became a `MeshPipelineSet` with two instances, selected at bind time from the LIVE pass rather than from the scope. When the backbuffer is single-sampled the two sets are one object. The offscreen target's depth attachment also had to take the device's own combined depth/stencil format rather than the contract enum's literal `D24_UNORM_S8_UINT`, because a pipeline bakes one depth/stencil format and the same pipelines draw in both passes. - **Third, found by running: entity APPEARANCE composites were still bindless-only**, so no entity with a palette override could be drawn on the Vulkan arm at all — the doll being one, and every creature and player besides. This is why V6j and V6k could not have run connected on Vulkan even had they tried. `RhiCompositeTextureArrayBackend` has existed since V6i-2 with no production consumer; it has one now, and nothing about the cache changed. - **Fourth, found by the first successful capture: the doll rendered upside down.** `UiViewport` has flipped V since V4a because a GL framebuffer's origin is bottom-left; a Vulkan image's is top-left and the negative viewport height stores the rendered image that way round, so the same flip stands the doll on its head. That is a property of the backend that made the texture, so `IUiViewportRenderer` answers `TextureIsBottomUp` and the widget asks. The line this replaces had predicted exactly this failure since it was written. `IWorldPassScope.Publish` is on the interface for this: the dispatcher's RHI arm borrows its pass rather than opening one, so a viewport that opens a pass of its own has to publish it for the span of the draw. It does not nest — the world phase has closed its own pass by the time private presentation runs. **Gates, per commit.** Release build green. App tests **4,121 / 4,129 / 4,129** against the 4,109 baseline (three contract tests and nine layout tests at commit 1; three contract, four shader-parity and one render-state-cache test at commit 2); complete Release suite **9,184 / 9,192 / 9,192**. Strict GL offline pixel gate against `08ffe141`: **3.20e-05 (18 px)**, **2.31e-05 (13 px)**, **3.55e-05 (20 px)** of 563,200, all inside the documented 9–31 band. GL connected `-Runs 3`: **3/3 RENDERED** on the desktop witness and 3/3 on the client capture, per commit. One offline Vulkan run with `VK_LAYER_KHRONOS_validation` proven inserted by the loader per commit: **zero validation errors, zero warnings**. One solution-wide run at each of commits 2 and 3 reported a single App failure that did not reproduce in the App suite alone or in a second solution-wide run — the documented rerun-singly flake class; the runner did not surface the name and it is not carried forward as a claim. **The two captures the offline gate cannot reach, connected and inspected.** - **The paperdoll on Vulkan** (`artifacts/v6l-vk-paperdoll3`): upright, in armour, at the right scale, over a transparent background, and indistinguishable from the same capture on GL taken minutes later (`artifacts/v6l-gl-paperdoll`) — which is also the no-regression check for the V-orientation change. - **Particles on Vulkan** (`artifacts/v6l-vk-poi` versus `artifacts/v6l-gl-poi`, cropped 4× at `artifacts/crop-vk-glow.png` and `crop-gl-glow.png`): Holtburg's forge plume and its field of glint sprites draw in the same places with the same alpha compositing on both backends, the puffs differing only in phase because two launches cannot agree on an emitter's age. The offline scene's emitters are outside its fixed view, which is why this is connected. **One thing the slice got for free and did not go looking for.** A connected Vulkan run teleported into the Marketplace and rendered the interior — walls, floor, banners, per-cell lighting — which is the first time anything has drawn `EnvCellRenderer`'s Vulkan arm (`artifacts/v6l-vk-mp`). It is a single eyes-on frame, not a gate, so §5.5.16 defect 4 is narrowed rather than closed: the arm demonstrably renders an interior, and the doorway clip, portal visibility and deferred-alpha paths remain unexercised by anything automated. **The V7 defect list, updated.** 1. ~~Particles~~, ~~the portal mask~~, ~~the viewports~~ — all landed. **`PortalTunnelPresentation` has no Vulkan arm**: the composition uses `NullLocalPlayerTeleportPresentation`, so a portal transit shows no tunnel on that backend. Absence, not defect; it is the last raw-GL world-adjacent renderer. 2. **`EnvCellRenderer`'s Vulkan arm is proven by one eyes-on frame and nothing automated.** Narrowed from §5.5.16 item 4. The deferred-alpha path and the doorway scissor are still unexercised, and no connected route visits an interior — the durable fix §5.1 already names is adding an interior stop to `connected-r6-soak.route.txt`. 3. **MSAA must be forced off for the differential** (unchanged; measured at 8.8% of the frame at 4× in §5.5.16). 4. **Bindings 4, 6, 7 and 8 cost a descriptor write per draw** (unchanged; a V8 item). 5. **The portal depth mask's two arms do not share a shader source.** Guarded by `PortalDepthShaderParityTests` and resolved by deletion at V11, but it is the one duplication the campaign carries and it should not be extended. 6. **The paperdoll's V orientation is now backend-derived** rather than constant. The appraisal viewport shares the class and the code and was still not itself driven — the same half-discharge §5.1's V6k row records, carried forward. #### 5.5.18 V6m (2026-07-28): portal space crosses, and the differential gets its instrument The last raw-GL world-adjacent renderer is on both arms, and V7's instrument exists and has been fired once. **1. Portal space** (`59c6b2ae`). `PortalTunnelPresentation` draws on both arms. Nothing about the scene moved — the same synthetic Setup resolved through the same client-enum mapping, the same 40 fps `CSequence`, the same retail rotation cadence and distant light, through the same already-dual-arm `WbDrawDispatcher`. What forked is where the draw is recorded: GL keeps its `GLStateScope` and its depth-only `glClear`; the RHI arm opens a backbuffer pass of its own and publishes it on `IWorldPassScope` for the span of the draw, which is V6l's viewport shape and required for V6l's reason — the dispatcher's RHI arm borrows its pass rather than opening one. Publication sits between `BeginPass` and `UploadRetailLight`, because publishing resets the frame-global sections and this scene wants its own light rather than the world's. **The one substantive decision is that the pass CLEARS colour rather than loading it, and it is exact rather than approximate.** Retail preserves the colour target and clears only depth (`UIViewportObject::DrawContent 0x006950A5` → `Clear(4)` = `D3DCLEAR_ZBUFFER`), and so does the GL arm. A Vulkan pass cannot inherit an image the way a bound framebuffer can: under MSAA the frame's world pass RESOLVES into the swapchain image and stores `DontCare` into the multisampled scratch, so a second multisampled pass declaring `Load` would load undefined contents — §5.5.12 item 5, the same hazard that merged the clear into the world pass. What makes re-clearing lossless is an invariant the frame graph already enforces: `RenderFrameFoundation.PortalViewportVisible` and the scene's own `IsVisible` are the same value read once at the top of the frame, and `WorldSceneRenderer` returns without drawing when it is set — so whenever portal space draws, the backbuffer holds exactly the opaque black `SceneTool::BeginScene 0x0043DAD0` establishes and nothing else. The alternative, a single-sampled `Load` pass over the resolved image, would have been both a silent MSAA divergence and invalid, because the backbuffer's depth attachment is multisampled. `NullLocalPlayerTeleportPresentation` is deleted with the backend condition that produced it, and `SessionPlayerComposition`'s transfer is unconditional again. **2. The differential instrument** (`a99f517e`). `tools/run-backend-differential-gate.ps1` runs one connected route twice — `ACDREAM_RENDER_BACKEND=gl` then `vulkan` — pairs the PNGs by name, compares each at tolerance 2 / fraction 0.001, and writes one verdict table. `ACDREAM_MSAA_SAMPLES=0` is forced on **both** launches, `ACDREAM_DAY_GROUP` is pinned on both, and the desktop witness plus its three guards (blank frame, overlapping window or locked screen, stray input) are lifted from the repeat-run gate rather than reinvented. `connected-backend-differential.route.txt` is written for two-launch determinism — identity quaternions on every teleloc, the client-only time-of-day override, no movement between arrival and capture — and **it carries an interior EnvCell stop**, which is the durable fix §5.1 names for the campaign's oldest coverage gap. **Two files, ASCII with CRLF, and that is not cosmetic.** PowerShell 5.1 reads a BOM-less `.ps1` as ANSI, so a UTF-8 em dash inside a double-quoted string is a parse error. The first version of the script had one and did not run. **3. The smoke pair, honestly.** ONE pair, on the route's first stop, MSAA off, GL versus Vulkan at Holtburg: **170,697 of 921,600 pixels differ — 18.52%**, maximum channel delta 255. Attributed with a difference map (`artifacts/v6m-diff-smoke.png`): - **89% of it is in the top 240 rows.** Applying the offline gate's top-280 sky mask takes the pair to **11,480 of 563,200, 2.04%**. That band is the scrolling cloud sheet — which advances with WALL time, not with the Dereth clock the route pins — plus the treeline behind it. - **Masking the animated portal beside the character too** takes it to **6,190 of 529,450, 1.17%**, mean channel delta 0.49. A portal's scrolling texture is phase, exactly like an emitter's age. - **The 2-D retained UI is not implicated, and this is the most useful number the smoke produced.** Compared over the chat panel alone — static content, same seven lines on both runs — the two backends differ in **42 pixels of 106,560, fraction 3.94e-04, maximum channel delta 3**. That is *inside* the 0.001 threshold on its own, so `TextRenderer`, the sprite path and the glyph path are already at parity and a global gamma or half-pixel offset is excluded. **The residual is in the 3-D pass.** - What visibly remains is thin outlines on silhouette edges throughout the frame, including static buildings, plus the vitals readouts, whose stamina and mana genuinely advanced between two logins minutes apart. So V7's distance to 0.001 is roughly **12x** with the two known phase populations removed, and the population to explain is edges in the world pass. `-MaskTopPixels` exists on the script for that conversation and **defaults to 0**: the gate is a strict identity check on the whole frame unless someone deliberately asks otherwise (§7.1 rule 2). **Gates, per commit.** Release build green. App tests **4,132 / 3 skips** against the 4,129 baseline (three new: the retail black constant, the RHI arm's composition precondition, and the both-arms composition assertion); complete Release suite **9,195 / 5**, with one `AcDream.Content` failure in the solution-wide run that passes 124/124 rerun alone — the documented rerun-singly flake class, not carried forward as a claim. Strict GL offline pixel gate against `280f3b3f`: **4.97e-05 (28 px of 563,200)**, inside the documented 9–31 band, with a same-commit control pair taken immediately afterwards at **3.55e-05 (20 px)**. GL connected `-Runs 3`: **3/3 RENDERED** on the desktop witness and 3/3 on the client capture. One offline Vulkan run with `VK_LAYER_KHRONOS_validation` proven inserted by the loader: **zero validation errors, zero warnings**. Commit 2 changes no product code, so those gates stand unchanged for it; the smoke run is its own evidence, having driven a complete connected Vulkan session end to end with a graceful exit. **The three captures the offline gate cannot reach, connected and inspected.** - **Portal space on both backends** (`artifacts/v6m-gl-tunnel` versus `artifacts/v6m-vk-tunnel`, ten frames each at 400 ms across the transit). The retail wormhole draws on Vulkan: the same tube, the same blue-grey shading and drifting purple motes, the retained UI composited above it, and — the thing the clear decision above is about — an opaque black surround with no world bleeding through. The two runs are at different rotation and animation phases, because the rotation cadence is drawn from `Random` and two launches cannot agree on it, so this is an eyes-on comparison rather than a numeric one. - **The creature-appraisal viewport on both backends** (`artifacts/v6m-gl-tunnel/screenshots/appraisal.png` versus the Vulkan counterpart). **This discharges V6k's and V6l's carried half-discharge**: the appraisal view shares the paperdoll's class and code and had never itself been driven. Examining a Brown Rabbit at Caul opens the examination window with the same title, the same "Rabbit / Character Level 4" header, the same nine attribute rows, and the creature rendered through the private viewport at the same scale and the right way up on both arms. A numeric pair over the window is confounded — the retail panel is semi-transparent over a swaying treeline, and the rabbit has its own animation cursor — so this too is eyes-on, as V6l's paperdoll was. - **An interior EnvCell on both backends** (`interior.png` in the same two directories): the Facility Hub's brick walls, doorway and magenta per-cell ambient render the same on Vulkan. **A numeric pair is NOT yet available and the reason is a route defect, not a renderer one**: the frames differ in 31.2%, and the cause is visible on inspection — the indoor spring-arm camera had settled to slightly different distances in the two runs, which moves every near-field edge. The interior stop needs a longer settle, or a framing that does not depend on the camera collapsing, before V7 can use it as a checkpoint. **The V7 defect list, updated.** 1. ~~`PortalTunnelPresentation` has no Vulkan arm~~ — landed. **Every production renderer now draws on both arms.** 2. **`EnvCellRenderer`'s Vulkan arm now has a connected route stop and an eyes-on pair, but no numeric one.** Narrowed again from §5.5.17 item 2. The route file carries the interior stop; making it comparable needs the camera settle fixed above. The deferred-alpha path and the doorway scissor remain unexercised by anything automated. 3. **MSAA is forced off by the instrument** (was: must be). What the smoke measured in its place is the sky band — the cloud sheet runs on wall time and no route flag pins it, so V7 must decide between masking it, pinning the phase, or accepting it. 89% of the smoke's difference lived there. 4. **Bindings 4, 6, 7 and 8 cost a descriptor write per draw** (unchanged; a V8 item). 5. **The portal depth mask's two arms do not share a shader source** (unchanged; guarded by `PortalDepthShaderParityTests`, resolved by deletion at V11). 6. ~~The appraisal viewport was never itself driven~~ — driven on both backends and inspected, above. 7. **NEW: the world pass differs at silhouette edges.** With the sky band and the animated portal masked the smoke still reports 1.17%, about 12x the threshold, and the chat-panel measurement rules the 2-D path out. This is V7's first lead and it is a real one. #### 5.5.19 V7 (2026-07-28): the instrument was measuring itself **The headline is not a renderer fix. Three of V6m's four numbers were taken through an instrument that was not holding the world still, and the largest single improvement in this slice came from noticing that.** **0. A transplanted test fix** (`a7529a97`). `81427cd4` from `worktree-agent-aa0684dee99776743` serializes the ten App test classes that share `CameraDiagnostics`, `RenderingDiagnostics.ProbeFlapEnabled` and `Console.Out`. Its base predates the campaign, so its `docs/ISSUES.md` entry claimed `#251` — already taken on this branch — and was renumbered to **`#252`** in the conflict resolution, subject included. App suite verified at **4,132 / 3**. It also de-flakes `Issue181WallPressEquilibriumTests`, which V7 leans on. --- **1. The world atlases were sampled without anisotropy on Vulkan** (`ad5f8b68`). `RhiWorldTextureArray` — the only `IWorldTextureArray` the Vulkan arm ever constructs — registered its clamp and repeat slots with `GpuSamplerDescription.WorldClamp`/`WorldRepeat` as written, which carry `MaxAnisotropy: 1`. The GL arm asks for the driver's own `GL_MAX_TEXTURE_MAX_ANISOTROPY` twice over, on the image and again on the two sampler objects its resident bindless handles are built from. V6i-2 knew it was asking for 1 and left a comment saying the slice that draws through these arrays is the one that can gate a filtering change; that slice was V6j and the gate is here. **Retail settles it rather than the GL arm settling it.** `RenderDeviceD3D::SetDefaultD3DStates` (`0x005a3800`) loops all sixteen sampler stages and issues `SetSamplerState(stage, 0xA, this->m_D3DCaps.MaxAnisotropy)` at `0x005a4230`. `0xA` is `D3DSAMP_MAXANISOTROPY` and the argument is the device's reported cap, not a setting. "As much anisotropy as this device has" is retail's rule; asking for 1 diverged from retail as well as from GL, and no register row is owed in either direction. The fix requests a ceiling rather than reading a limit back, because §3.3 is frozen and has no anisotropy field — and does not need one: `VulkanGpuSampler` already clamps to `maxSamplerAnisotropy`, which Vulkan guarantees is at least 16 wherever `samplerAnisotropy` is supported. **Measured worth**, on the offline capture below: the tree band halves (41,509 → 22,266 differing pixels) and everything below it drops by two thirds (1,491 → 497). The roof shingles of both Holtburg cottages, which had been dense hatching across the whole surface, go black in the difference map; high-frequency energy on that roof matches GL at a ratio of 0.999. **2. The sky has two clocks, and the second one was pinned rather than masked** (`ad5f8b68`). `ACDREAM_DAY_GROUP` pins the day group; the cloud sheet does not read that clock at all — `SkyRenderer` accumulates `TexVelocityX/Y` against `DateTime.UtcNow` minus its own construction time, by design, because retail's clouds drift with real time regardless of the date. `ACDREAM_SKY_PHASE_SECONDS` replaces that elapsed value with a fixed one, off by default. Rows 0–32 of the Holtburg pair went from 23,090 differing pixels to 1,211. **The alternative was `-MaskTopPixels`, which would have permanently blinded the campaign's strictest instrument to the entire sky.** See §5.1. **3. The world clock was never pinned at all, and that was most of the number** (`1f25a609`). The route opened by pressing `AcdreamCycleTimeOfDay` three times. The mechanism underneath is `WorldTimeService.SetDebugTime`, and `SyncFromServer` clears it — deliberately, since that setter is the `/time` slash command and there is a test pinning exactly that behaviour (`WorldTimeDebugTests.SyncFromServer_ClearsDebugOverride`). ACE sends TimeSync every few seconds. **The clock was therefore un-pinned again long before the route reached its first stop, on every run this campaign has taken, V6m's smoke pair included.** And the Dereth clock does not only move the sky; it moves the sun, so it moves the directional term of every lit surface in the scene. Measured rather than argued — a probe route captured each stop twice, 45 seconds apart, in the same run on the same backend: | Arm | Stop | capture 1 vs capture 2 | |---|---|---| | GL | Holtburg | 205,772 px — **22.33%** | | Vulkan | Holtburg | 218,732 px — **23.73%** | | GL | Facility Hub | 108,795 px — **11.81%** | | Vulkan | Facility Hub | 130,206 px — **14.13%** | One backend, one stop, nothing moving, and a fifth of the frame changes while you watch. `WorldTimeService.PinnedDayFraction` (`ACDREAM_WORLD_TIME` → `RuntimeOptions.PinnedWorldDayFraction` → `WorldEnvironmentController`) outranks both the server clock and `SetDebugTime` and survives every sync; the Runtime environment owner is session-scoped, so one write outlives every teleport and reveal. Values outside [0, 1) are rejected rather than clamped. The gate forces 0.5 on both launches and the route's presses are deleted. **The three-stop route, before and after that one change:** | Stop | before | after | |---|---|---| | `holtburg_town` | 9.05% (83,438 px) | **2.86%** (26,330 px) | | `facility_hub_interior` | 12.16% (112,075 px) | **0.78%** (7,176 px) | | `aerlinthe_island` | 23.09% (212,824 px) | **6.82%** (62,892 px) | --- **The three leads V6m handed forward, answered.** **Lead 3 was wrong, and it is worth saying so plainly.** V6m recorded the interior stop as a route defect on the theory that the indoor spring-arm camera settles to different distances in two runs. It does not. The interior was lit differently because the sun had moved; with the sun held still the stop drops by a factor of fifteen to **0.78%**, and its entire remaining difference map is the *player character* — the armour highlights and the collar. The brick walls, the floor, the doorway, the per-cell ambient, the chat panel, the radar and the toolbar are black. **`EnvCellRenderer`'s Vulkan arm now has its numeric pair**, and no route change was needed or made. No aperture stop was added either: the portal depth mask remains undrawn by anything automated, and it is carried forward rather than claimed. **Lead 2 is closed by pinning rather than masking** — item 2 above. **Lead 1 is half fix and half finding.** The "silhouette-edge residual" was predominantly the anisotropy gap, which is fixed. What survives is a single population — **dense alpha-blended distant scenery, the treeline** — and it took a better instrument to isolate. **The offline GL-versus-Vulkan pair is that instrument, and V7 recommends it to V8 and V10.** Both capture scripts already exist; setting `ACDREAM_WORLD_TIME` and `ACDREAM_SKY_PHASE_SECONDS` in the shell that invokes them makes the pair strictly comparable, and the offline scene has **no session, no server, no entities, no camera settle and no wandering NPCs** — so a difference is the renderer or it is nothing. It also runs unattended. | Pair (offline, both clocks pinned, MSAA off) | Differing px | Fraction | |---|---|---| | GL vs GL, same commit (control) | 1,966 | 2.13e-03 | | Vulkan vs Vulkan, same commit (control) | 1,039 | 1.13e-03 | | **GL vs Vulkan, whole frame** | 28,807 | 3.13e-02 | | **GL vs Vulkan, everything below the tree band (rows 280–720)** | **497** | **8.82e-04 — inside 0.001** | **Terrain, terrain blending, roads, the water edge, fog, statics, scenery below the horizon and the entire retained UI are at parity.** Both controls sit in the tree band too, which is the tell. **What the treeline is, and what it is not.** Four hypotheses were tested and three were refuted: - **Not a sub-pixel offset.** An integer shift search over the band finds the best alignment at (0, 0). - **Not a sharpness or LOD-scale difference.** High-frequency energy (mean absolute neighbour difference) matches within 5% in the band and within 2% everywhere else. - **Not depth precision** — and this one matters, because §4.7 predicted the z-fight class would present here. Forcing the Vulkan viewport's window-depth range to `[0.5, 1.0]`, which reproduces GL's compressed mapping *exactly*, moved the whole-frame number from 28,807 to 27,852: **3%**. The experiment was reverted. The pre-approved divergence class is not what this is. - **It IS anisotropic filtering, and there is no knob left.** Anisotropy 1 gives 41,509 in the band; anisotropy 16 — GL's value, and retail's — gives 22,266. Monotone improvement toward GL's own setting, with both arms now requesting identical sampler state, leaves the anisotropic TAP PATTERN, which both the GL and Vulkan specifications leave implementation-defined and AMD's two drivers do not agree about. It is the same kind of thing as §6's MSAA sample-position row. That is **`AD-46`** in the divergence register, filed as an adaptation with the measurements above as its justification and its refutations. It is dormant until V10 makes Vulkan the client. **The V7 verdict table** (`artifacts/v7-diff-c2`, full route, both backends, tolerance 2, fraction 0.001, MSAA off, day group 0, world time 0.5, sky phase 0): | Stop | Differing px | Fraction | Max Δ | Verdict | |---|---|---|---|---| | `holtburg_town` | 26,330 | 2.86e-02 | 255 | **EXCEPTION** — phase + AD-46 | | `facility_hub_interior` | 7,176 | 7.79e-03 | 255 | **EXCEPTION** — phase | | `aerlinthe_island` | 62,892 | 6.82e-02 | 255 | **EXCEPTION** — AD-46 + dark-scene floor | **No stop passes, and none of the three exceptions is a renderer defect.** Named individually, because "phase" is not an excuse unless it is specific: - `holtburg_town` — the animated portal beside the stop (a scrolling texture, the same class as an emitter's age), two wandering NPCs, a chimney smoke plume, the vitals readouts (stamina and mana genuinely regenerate at different rates across two logins minutes apart), and the AD-46 treeline on the left. The terrain, roofs, walls, road, chat panel, toolbar and radar are black in the map. - `facility_hub_interior` — the local player's idle pose and its lighting, and nothing else. **This is the cleanest evidence in the slice**: an EnvCell interior, drawn by the Vulkan arm, whose walls and floor and doorway differ in no pixel worth naming. - `aerlinthe_island` — AD-46 shrubbery plus a floor that the instrument imposes rather than the renderer: the scene's mean luminance is **28/255**, and **half of its differing pixels are exactly delta 3**, one step over a tolerance that is absolute rather than relative. A relative tolerance would report this stop very differently, and changing the pinned tolerance is not V7's call. **What V7 did not reach**, carried to V8 and to the user's visual session: 1. **A pass at any connected stop.** Getting one needs the route to stand somewhere with no creature, no emitter and no animated portal in frame, and the local player is unavoidable in a chase camera. The realistic shape is an authored per-stop mask in the gate script, which does not exist yet: the script still has only the global `-MaskTopPixels`, deliberately defaulted to 0. 2. **The portal depth mask still has never drawn a pixel in an automated run.** V6l and V6m both carried this; so does V7. It needs a stop standing in a building aperture. `HouseExitWalkReplayTests` names a usable target — the Holtburg corner building, cell `0xA9B40170`, whose exit door the test resolves from the DAT — and that is the cheapest path when someone picks it up. 3. **Whether AD-46 is visible to a human.** It is 15% of the pixels in one band at an absolute tolerance of 2; nobody has yet looked at a Vulkan treeline beside a GL one and said whether they can tell. That is a user-stop question. 4. ~~**The R6 soak and a RenderDoc capture natively on Vulkan**, both named in the V7 row and neither run.~~ **Half discharged at V8 (§5.5.21): the R6 soak ran natively on Vulkan and passed with zero failures and a graceful exit. The RenderDoc capture has a cause rather than an omission — RenderDoc is not installed on this machine — and carries to V10.** **Gates.** Per commit: Release build green; App tests **4,133 / 3** then **4,134 / 3** against the 4,132/3 baseline (three new — the sky-phase parse, the day-fraction range check, and the two `WorldTimeService` pin assertions in `AcDream.Core.Tests`); GL offline pixel gate against the pre-slice tree at **2.31e-05** (13 px) and **2.66e-05** (15 px), both inside the documented 9–31 band, so **GL did not move**; one offline Vulkan run per commit with `VK_LAYER_KHRONOS_validation` **proven inserted by the loader** at **zero errors, zero warnings**. Final: complete Release suite **9,195 passed / 5 skipped, zero failures** — no `AcDream.Content` flake this time; GL connected repeat gate `-Runs 3` at **3/3 RENDERED** on both the desktop witness and the client capture. #### 5.5.20 V9 (2026-07-28): the first CI job that renders a frame **The whole slice rests on one fact §5.5.8 already bought and paid for.** When V6g cut set 0 from ten dynamic storage descriptors to four, it did so on the rule that a dynamic descriptor is for ring-fed data whose offset moves and nothing else — and four is not merely under the RX 9070 XT's eight, it is *Vulkan's guaranteed minimum*. That decision is what makes a lavapipe row possible at all. Every other requirement was then checked against Mesa's `src/gallium/frontends/lavapipe/lvp_device.c` rather than assumed, and **every one of the seventeen features the gate demands is `true`**, including the two that looked most likely to bite: - **`samplerAnisotropy`** — `.samplerAnisotropy = true`, an unconditional literal since 2021, not a pipe-cap query. This was the real risk: V7 made anisotropy load-bearing eight commits ago, and a software rasterizer declining to filter anisotropically would have been entirely reasonable of it. - **`textureCompressionBC`** — `true`, so the DAT surfaces upload without transcoding. (ETC2 and ASTC are `false`; the gate does not ask for them.) `maxDescriptorSetStorageBuffersDynamic` is 500,000, `maxBoundDescriptorSets` 8, `maxPushConstantsSize` 256, timestamps supported, and the X11 WSI has a software non-SHM present path, so it works under Xvfb without DRI3. Two notes for whoever touches this next: lavapipe offers **only 1x and 4x** MSAA — the harness asks for 4, which is fine, and 2x or 8x would not be — and it reports **API 1.4** on the Mesa 25.2 that Ubuntu 24.04 now ships while `conformanceVersion` stays 1.3, so the job asserts `>= 1.3` and never `== 1.3`. **Three things had to be built before a job could exist.** **1. The harness could not stop.** `VulkanBringUpHost.Present()` presents until its window closes, which is exactly right for a developer answering "does this machine pass?" at a desk and impossible in CI, where nothing ever closes a window. `ACDREAM_VULKAN_PROBE_FRAMES` gives it a frame budget; unset or malformed is zero, which keeps the interactive behaviour, so no existing invocation changes. The budget **never cuts the capture short** — the loop stays open until the screenshot has been attempted — because a run whose entire product is a PNG must not be able to exit green with an empty artifact directory. The decision is a pure static method so it is tested without a window or a driver. **2. `tools/compile-shaders.ps1` was Windows-only** and nobody had noticed, because nothing had ever run it anywhere else. It composed its paths from embedded `'src\AcDream.App\Rendering\Shaders'` literals; a backslash is a path separator on Windows and an ordinary filename character everywhere else, so on Linux that is one long nonexistent file name. Now composed with `[System.IO.Path]::Combine`, and the `glslc` probe looks for the SDK's Linux layout as well as its Windows one. **3. The report's jq paths were invisible to the compiler.** The job reads its verdict out of `graphical-capabilities-vulkan.json` with `jq`. Renaming a record property or swapping the enum converter would have left every existing test green and turned CI red on someone else's branch days later, with a failure that reads like a driver problem. `VulkanCapabilityReportContractTests` pins the exact strings — `"Cpu"`, `"X11"`, `SupportFailures`, `FunctionProbe.Failures`, `Features.TimelineSemaphore` — and pins the job's packed-version arithmetic against `VulkanApiVersion`'s own unpacking. **The job, eleven steps.** Install lavapipe + loader + Xvfb; record `vulkaninfo --summary` as evidence; publish `linux-x64`; run the `Gpu.Vk` tests on a second operating system; **(a)** run the probe under `xvfb-run -s "-screen 0 1920x1080x24"` — 24-bit explicitly, because xvfb's default screen is 8-bit and leaves the X11 WSI without a usable visual — and assert an accepting verdict on a `Cpu` device at API >= 1.3 with a clean active probe; **(b)** assert the captured PNG is a real frame by IHDR dimensions and byte count; **(c)** re-run with `ACDREAM_VULKAN_FORCE_UNSUPPORTED=timelineSemaphore` and assert exit **4**, the forced feature recorded, a failure sentence naming it, and a refusal message that tells the operator where the report is; **(d)** recompile the shaders and compare. Every artifact uploads on `always()`, so a red run ships its own diagnosis. **On (d), what it adds over the existing test.** `VulkanShaderManifestTests` re-hashes the GLSL against the manifest, which catches "edited a shader, forgot to recompile". Nothing tied the committed **binaries** to those sources, so a stale or hand-edited `.spv` would have shipped silently. The job recompiles and compares each `.spv` byte-for-byte plus the file set; the manifest is compared as parsed JSON rather than as bytes, because it is written with `Environment.NewLine` and a byte compare would report drift for the operating system rather than for the shaders. **Measured on Windows before shipping: 18 `.spv` plus the manifest, 19/19 byte-identical to a fresh compile, zero drift.** Whether Linux shaderc agrees byte-for-byte with Windows shaderc at the same pinned Silk.NET 2.23.0 is the one thing this slice asserts without having observed; the job is the instrument that answers it, and a mismatch there is a real finding about toolchain determinism, not a threshold to relax. **What was NOT done, and why — the pixel comparison.** The brief allowed a relaxed GL-versus-Vulkan smoke compare *if the GL job already produced a comparable frame*. It does not, for two independent reasons either of which is disqualifying. First, `linux-graphical` asserts **exit code 4**: llvmpipe-GL has no `GL_ARB_bindless_texture`, the gate refuses it, and there is no left-hand side to compare. Second, even given a GL frame, the probe harness renders the synthetic V6c/V6d verification scenes, not the world — and the world needs retail DATs that CI does not have and cannot be given. The real differential is V7's, on the developer machine, against the DATs, with both clocks pinned. The two jobs now say something sharper than a pixel diff would have: **on the same software Mesa stack, GL is refused and Vulkan is accepted and draws.** **Also deferred.** The physical Linux GPU row, post-cutover, on the Slice L precedent — no hosted runner has a GPU, and Slice L's L1 checkpoint already records a supported physical AMD/NVIDIA driver row as its own first gate. Wayland, because no hosted runner offers a Wayland session; L1's immutable Win32/X11/Wayland selection is exercised on X11 here and on Windows natively. **Gates.** Release build green, zero warnings. App tests **4,152 / 3 skipped** against a **4,134 / 3** baseline measured at this worktree's base commit (`9b7f4343`) — eighteen new, all from this slice. Workflow validated by a real YAML parse (YamlDotNet) plus a GitHub-Actions schema check and `bash -n` over every extracted `run` block: 9/9 clean, no `actionlint` available locally and none downloaded. **The job itself has not run**: its first execution is the CI run this commit triggers, and the row stays ◐ until that is green. #### 5.5.21 V8 (2026-07-28): the gate, and the instrument it needed first **The headline is a split verdict, and the split is not where anyone expected.** Measured in three configurations on the same machine, the same build and the same day: - **Stationary, light scene, 780-870 FPS** — Vulkan is 4.1x cheaper on GPU, allocates 6.8x less and holds 7% less working set, and **costs 14.8% more CPU per frame**. Two of seven floors missed. - **Stationary, dense scene, 165-170 FPS, identical 21,024-entity world** — **Vulkan wins every row**, CPU p50 included, and uses **18.5% less total process CPU** by Windows' own accounting. - **Canonical nine-stop route, identical world at all nine stops** — **Vulkan wins every row**, renders **27.3% more frames** in the same 506 seconds and halves GPU p99. The three do not contradict each other. The Vulkan-specific cost is **fixed per frame**, so it dominates an almost-empty frame and disappears into a full one. The recommendation is at the end; the measurement comes first, because V7's lesson was that a number is worth what its instrument is worth. --- **0. The Vulkan arm had no instrument at all** (`00e1b321`). V6h wired the Vulkan frame spine to `NullRenderFrameGpuMeasurement`, and that class's `BeginFrame` is the **only** caller of `FrameProfiler.FrameBoundary`. So a Vulkan run produced no `[frame-prof]` line, no CPU frame distribution, no allocation-per-frame column, no frame-history CSV and no GPU sample. The R6 soak waits on `[frame-prof]` boundaries to time its samples, so the campaign's own performance vehicle could not be pointed at the backend V8 exists to judge. `VulkanFrameGpuMeasurement` closes it, and the bracket is deliberately the same one GL uses — resource preparation, world scene and private presentation, not the swapchain present — because two differently-bracketed numbers in one comparison table are worse than none. Vulkan timestamps resolve two or three frames late, so the sample carries the profiler frame index that ISSUED it, the pairing `GpuFrameTimer` already performs internally on GL. `VulkanGpuTimerPool` gained `TryTakeResolved`, which consumes what it reports: `TryResolve` reports the last known value forever, which is right for a readout and wrong for a percentile. --- **1. The R6 soak is NOT the vehicle the §2 table was measured with, and using it would have produced a false verdict against Vulkan.** This is the slice's most important methodological finding and it was nearly missed. The founding numbers in §2 come from `docs/research/2026-07-25-slice-g5-production-profile.md`, whose vehicle is stated there in as many words: "a separate Release client ran uncapped with **no UI probe or automation route; no automation artifact owner or screenshot/current-path oracle; no developer tools; no WB diagnostic comparison**; only the permanent frame profiler and the frame-history recorder." The R6 soak has every one of those. Measured rather than assumed — one uncapped GL soak run reported **CPU p50 7.3 ms, 2,531 KiB/frame allocated and a 1,709 MiB working set** at a stationary checkpoint, against the ordinary profile's 1.13 ms, 77 KiB and 941 MiB on the same binary the same hour. And the bias is not symmetric. `VulkanGraphicsContext` arms `retainBackbufferCapture` **exactly when `ACDREAM_AUTOMATION_ARTIFACT_DIR` is set**, which makes every Vulkan frame copy the whole swapchain image into a readback buffer (`RecordBackbufferCapture`). GL has no such per-frame cost: it reads on demand. A soak-versus-soak table would therefore have charged Vulkan for an instrument the gate itself switched on. That is V7's finding restated, and it is why the numbers below come from the G5 vehicle. The G5 vehicle was reproduced with one addition — `ACDREAM_WORLD_TIME=0.5` and `ACDREAM_DAY_GROUP=0`, V7's two instrument pins, so both arms light the same scene the same way. **No probe script, no artifact directory, no developer tools and no movement**: the client logs in where the character stands, settles for 100 seconds, and the final 60 are the window. Every launch variable is disclosed in each run's `report.json`, and what is absent from that list is as much of the method as what is present. --- **2. Every run taken, and the conditions.** Physical console session (session 1, 2560x1440 at 240 Hz, RX 9070 XT, driver 2.0.395 / 32.0.31021.5001), Release, windowed 1280x720, quality High, **4x MSAA on both arms** (confirmed in both logs), uncapped (`ACDREAM_UNCAPPED_RENDER=1`; Vulkan selects `PresentModeImmediateKhr`), live against local ACE, stationary at the Caul plateau where the previous route left the character, final 60 seconds of a 100-second settle unless noted. Nothing is excluded from this list. | Run | Backend | CPU p50 | p95 | p99 | GPU p50 | p99 | alloc p50 | WS MiB | Priv MiB | FPS | |---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:| | gl-1 | GL | 1.127 | 1.256 | 1.390 | 0.651 | 0.707 | 77,664 | 941.4 | 1250.1 | 871.7 | | gl-2 | GL | 1.130 | 1.269 | 1.441 | 0.658 | 0.717 | 77,704 | 944.0 | 1322.7 | 867.7 | | gl-phase* | GL | 1.125 | 1.260 | 1.407 | 0.646 | 0.708 | 77,664 | 943.7 | 1340.4 | 872.1 | | vk-1 | Vulkan | 1.319 | 1.427 | 1.543 | 0.159 | 0.189 | 11,440 | 880.0 | 1237.7 | 760.5 | | vk-2* | Vulkan | 1.250 | 1.405 | 1.519 | 0.162 | 0.180 | 11,440 | 874.1 | 1233.6 | 778.0 | | vk-3* | Vulkan | 1.314 | 1.409 | 1.512 | 0.159 | 0.179 | 11,400 | 870.0 | 1230.4 | 767.8 | | vk-phase* | Vulkan | 1.273 | 1.432 | 1.546 | 0.160 | 0.185 | 11,440 | 880.3 | 1241.0 | 762.5 | | vk-validation | Vulkan | 2.609 | 3.195 | 3.536 | 0.158 | 0.190 | 11,440 | 897.3 | 1268.3 | 371.8 | | gl-dense† | GL | 5.934 | 6.972 | 8.867 | 1.673 | 1.858 | 82,016 | 1161.0 | 1476.0 | 165.2 | | vk-dense† | Vulkan | 5.775 | 6.563 | 7.354 | 0.909 | 0.963 | 15,752 | 1106.6 | 1278.5 | 169.5 | `*` carried the temporary attribution probe described in item 4. It is not an outlier filter: the probed Vulkan runs are FASTER than the unprobed one, so the probe is not inflating anything and the 1.25-1.32 ms spread is run-to-run variance. GL's spread over three runs is 1.125-1.130 ms, which is 0.4%. `†` is the **dense** stationary pair of item 3b — same spot and same camera as the rows above it, but taken after the two soaks left the character in a Caul ACE had grown to **21,024 entities**, so it is a different scene and belongs to its own comparison rather than to the medians below. Both arms met the identical population. `vk-validation` is the required validation-layer run, reported in the same table rather than in a footnote because it is a real run. Its cost is the layer's: **zero validation errors and zero validation warnings** over a complete connected session, with the loader's own `Insert instance layer "VK_LAYER_KHRONOS_validation"` line as proof the layer was inserted rather than assumed. Measurement runs have it off. --- **3. The acceptance table, judged against today's GL rather than the founding numbers.** Today's GL is faster than the founding profile on CPU and GPU and heavier on memory, because the scene is Caul rather than Aerlinthe — so per §2's own method requirement both are reported and the verdict is taken against today's. | Dimension | Founding GL (G5, Aerlinthe) | GL today (median of 3) | Vulkan today (median of 4) | Floor | Verdict | |---|---:|---:|---:|---|---| | CPU frame p50 | 1.869 ms | **1.127 ms** | **1.294 ms** | <= GL today | **MISS**, +14.8% | | CPU frame p99 | 2.484 ms | **1.407 ms** | **1.531 ms** | <= GL today | **MISS**, +8.8% | | GPU frame p50 | 1.096 ms | **0.651 ms** | **0.160 ms** | <= GL today | **PASS**, -75.4% | | GPU frame p99 | 1.136 ms | **0.708 ms** | **0.182 ms** | <= GL today | **PASS**, -74.3% | | Working set | 652.1 MiB | **943.7 MiB** | **877.1 MiB** | <= GL today | **PASS**, -7.0% | | Private set | 928.3 MiB | **1322.7 MiB** | **1235.7 MiB** | <= GL today | **PASS**, -6.6% | | Frame-thread alloc p50 | 22,880 B | **77,664 B** | **11,440 B** | <= GL today | **PASS**, -85.3% | Two rows of that table deserve a note rather than a silent pass. **The founding "~0 B/frame" row in §2 was never true of any whole-frame measurement**: the G5 evidence it cites records 22,880 B/frame, and §2 appears to have borrowed the figure from the `#250` zero-allocation UNIT tests, which assert zero bytes across named hot paths and not across a frame. Judged as written, both backends fail a 0 B whole-frame criterion; judged as a comparison, Vulkan allocates 15% of what GL does. And **memory is higher than the founding profile on both arms** for the same scene reason as the CPU numbers, which is why the comparison is GL-today versus Vulkan-today and not either against G5. --- **3b. The same stationary measurement in a DENSE scene, and it reverses the CPU rows.** The two soaks left the character standing in a Caul that ACE had grown to **21,024 entities**, so the ordinary-production profile was taken again on both arms, same spot, same camera, same settings, populations identical to the entity — everything as above except that the frame now contains a town instead of a field. | Dimension | GL (21,024 ent) | Vulkan (21,024 ent) | | |---|---:|---:|---| | CPU frame p50 | 5.934 ms | **5.775 ms** | **-2.7%** | | CPU frame p95 | 6.972 ms | **6.563 ms** | **-5.9%** | | CPU frame p99 | 8.867 ms | **7.354 ms** | **-17.1%** | | GPU frame p50 | 1.673 ms | **0.909 ms** | **-45.7%** | | GPU frame p99 | 1.858 ms | **0.963 ms** | **-48.2%** | | Frame-thread alloc p50 | 82,016 B | **15,752 B** | **-80.8%** | | **Process CPU, whole window** | **1.246 cores** | **1.016 cores** | **-18.5%** | | Working set | 1,161.0 MiB | **1,106.6 MiB** | **-4.7%** | | Private set | 1,476.0 MiB | **1,278.5 MiB** | **-13.4%** | | FPS | 165.2 | **169.5** | **+2.6%** | **Vulkan wins every row.** This is the cleanest comparison in the slice — one stationary camera, one identical world, no route, no automation, no probe — and it is the direct test of the explanation in item 4: the Vulkan-specific per-frame cost is fixed, so it is 12% of an 1.13 ms frame and 2.5% of a 5.9 ms one, while the GPU and allocation savings scale with the work. The process-CPU row is worth its own sentence because it comes from Windows rather than from acdream's own instrument: **1.016 cores against 1.246**, an 18.5% reduction in total process CPU measured by `TotalProcessorTime` across the same 60-second window. Nothing in that number passes through `FrameProfiler`. --- **4. Where the CPU difference is, measured rather than guessed.** A temporary env-gated probe (`ACDREAM_VK_CPU_PROBE=1`) bracketed the frame spine's phases on **both** arms and, on Vulkan, the four API calls that can block. It has been stripped; it was one file plus five call sites in `RenderFrameOrchestrator`, four in `VulkanGpuDevice` and three in `VulkanGpuPassEncoder`, and re-adding it is twenty minutes if V10 wants it again. | Phase (mean ms/frame) | GL | Vulkan | delta | |---|---:|---:|---:| | open the GPU frame | 0.001 | 0.056 | **+0.055** | | resource preparation | 0.003 | 0.002 | -0.001 | | world scene | 0.737 | 0.828 | **+0.091** | | private presentation (retained UI, viewports) | 0.229 | 0.148 | **-0.081** | | close the GPU frame | 0.000 | 0.098 | **+0.098** | | sum of phases | 0.970 | 1.132 | +0.162 | | whole-frame CPU p50 | 1.125 | 1.273 | +0.148 | | remainder outside the phases | 0.155 | 0.141 | -0.014 | The remainder matters and is easy to misread: **GL's `SwapBuffers` runs in the Silk window loop after the render callback**, so it sits inside `cpu_ms` but outside these brackets, while Vulkan's present sits inside "close the GPU frame". The two remainders are within 0.014 ms of each other, which puts GL's whole present at roughly that figure. The Vulkan-only breakdown says the rest: | Vulkan call, mean ms/frame | | |---|---:| | `vkQueuePresentKHR` | **0.070** | | `vkQueueSubmit2` | 0.027 | | timeline wait (`VulkanFrameFlightController.BeginFrame`) | 0.026 | | `vkAcquireNextImageKHR` | 0.025 | | descriptor resolve + bind + draw, **all 216 draws of the frame** | 0.031 | | end-of-frame recording (barriers, upload record) | 0.001 | **So the campaign's named cost centre is not the problem.** §5.5.14 item 5 and §5.5.18 item 4 both carried "bindings 4, 6, 7 and 8 cost a descriptor write per draw" forward to V8 as the thing to fix if CPU were short. Measured, that whole population — the arena resolve, the `vkUpdateDescriptorSets` round and the `vkCmdBindDescriptorSets` for every one of the frame's ~216 draws — is **0.031 ms, about 140 ns per draw and 2.4% of the frame**. Fixing it perfectly would recover a fifth of the gap, and §5.5.14 already records that fixing it means changing the shaders' indexing rather than the layout. **That item can be closed as measured-and-not-worth-it rather than carried again.** What the gap actually is: **0.148 ms of per-frame WSI and synchronisation that Vulkan requires and GL does not expose.** Present, submit, acquire and the timeline wait total 0.148 ms against GL's ~0.014 ms of present, and none of the four is a call acdream chooses to make. The world phase's remaining +0.091 ms is the only part inside our code, and 0.031 ms of it is the descriptor population above. --- **5. What was tried, and what was deliberately not.** Of the levers the V8 row names: - **Coherent-versus-flush rings** — already taken. Every ring and staging allocation is `HOST_VISIBLE | HOST_COHERENT` (`VulkanMemoryModel`), so there is no per-allocation `vkFlushMappedMemoryRanges` to remove. - **Submit consolidation** — already taken. One `vkQueueSubmit2` per frame, measured at 0.027 ms. - **Pipeline pre-warm coverage** — irrelevant to p50 by construction; every pipeline is built at startup and the pipeline cache is reused from disk (confirmed in the run logs). It could only move max, and max is 2.65-2.99 ms on Vulkan against 2.48-2.77 ms on GL, which is the same population. - **Descriptor writes per draw** — measured at 0.031 ms and left alone, above. - **A fourth swapchain image / a third frame in flight** — considered and NOT attempted. Acquire and the timeline wait cost 0.025 and 0.026 ms while the GPU finishes its frame in **an eighth** of the CPU's time (0.160 ms against 1.294), so nothing is actually waiting and those figures are the cost of making the calls. A fourth image would buy nothing and cost real memory. Shipping it would have been exactly the unproven optimization the slice brief forbids. **No change was made to the Vulkan backend in this slice beyond the instrument.** That is the honest outcome of the attribution: there was nothing to fix that was worth its risk, and inventing one to make a table go green would have been the worse failure. --- **6. The route gates. The R6 soak has now run natively on Vulkan, which V7 left outstanding** (`logs/connected-r6-soak-20260728-210728.report.json`): **PASS, zero failures, exit code 0, graceful WM_CLOSE exit**, 506.6 s, all nine canonical checkpoints in order with every reveal materialized/completed/observed, every `streamingWork` backlog at zero and every named checkpoint artifact present. Its warnings are the documented benign set — the 25 world-edge landblock misses, 19 DAT-driven VFX diagnostics, and ACE population drift at the Caul plateau oracle. **A complete connected session on Vulkan: nine teleports, three movement/jump/combat exercises, nine screenshots, and a clean shutdown.** **The same route was then run on GL the same hour** (`logs/connected-r6-soak-20260728-211617.report.json`): **PASS, zero failures, exit code 0, graceful exit**, 506.8 s against Vulkan's 506.6 s. Its warnings are the same benign set. **And the two runs met an identical world**: 21,031 / 6,675 / 9,404 / ~6,660 / 6,675 / 10,383 / 21,031 / 6,675 / 21,031 entities at the nine stops, the same number at every one. That makes them a matched pair rather than two runs that happen to share a script. **Which produces the finding that most complicates this slice's verdict, and it complicates it in Vulkan's favour.** Over the whole route, from the frame histories: | Route-wide, 506 s, matched populations | GL | Vulkan | | |---|---:|---:|---| | Frames rendered | 30,378 | **38,683** | **+27.3%** | | CPU p50 | 11.718 ms | **9.166 ms** | **-21.8%** | | CPU p99 | 51.089 ms | **42.826 ms** | **-16.2%** | | GPU p50 | 0.702 ms | **0.167 ms** | **-76.2%** | | GPU p99 | 2.325 ms | **1.193 ms** | **-48.7%** | | Allocation p50 | 4,318,088 B | **4,244,984 B** | -1.7% | | Peak working set | 2,318.5 MiB | **2,295.4 MiB** | -1.0% | **On the canonical nine-stop route Vulkan is 21.8% FASTER on CPU p50, not 14.8% slower.** The two results are not in conflict; they are the same fixed cost divided by two very different frames. The stationary profile runs a light scene at 780-870 FPS, where 0.148 ms of per-frame WSI is 12% of the budget. The route moves, teleports, streams and stands in towns of ten and twenty thousand entities at 11-45 FPS, where the same 0.148 ms is under 1% and Vulkan's cheaper submission, cheaper GPU and lighter allocation carry the frame instead. **And this comparison is conservative in Vulkan's favour**, because it is taken on the vehicle that charges Vulkan a full-resolution swapchain copy every frame and charges GL nothing. Vulkan wins it anyway. Also worth reading twice: the route's GPU p99 is **2.325 ms on GL against 1.193 on Vulkan**. Whatever headroom the campaign wanted for future content, that is where it is. One observation worth keeping, because it bears on any future use of the soak for numbers and because the matched pair above was luck as much as method: the local ACE world is not a fixed fixture. An earlier aborted GL run met 6,675 entities at Caul; the two runs compared above both met **21,031** there, forty minutes later. A pair taken an hour apart would not have been comparable at all. Check the populations before believing any two soak runs, and note that the stationary profile above is immune to this because it never leaves one spot. --- **7. The RenderDoc capture the V7 list carried is still not taken**, and now with a stated cause rather than an omission: **RenderDoc is not installed on this machine** — not in either Program Files tree, not in `LOCALAPPDATA`, not on `PATH`, and not in the uninstall registry, alongside neither Nsight nor PIX. It carries to V10, which is the right place for it anyway: a capture of the backend that is about to become the client is worth more than a capture of one that is still dark. --- **8. Gates.** Release build green. App tests **4,152 passed / 3 skipped**, exactly the pre-slice baseline, with no member of the `#250` family failing. Strict GL offline pixel gate against `13c8733d`: **1.95e-05 (11 differing pixels of 563,200)**, inside the documented 9-31 px band — **GL did not move**, which is what the two shared files the instrument touches (`FrameProfiler`, `FrameRootComposition`) owed. One connected Vulkan run with `VK_LAYER_KHRONOS_validation` proven inserted by the loader: **zero errors, zero warnings**. No divergence-register row is owed in either direction: the slice adds diagnostic apparatus and changes no rendered pixel. --- **9. The verdict, and the recommendation.** **Floors met: 5 of 7 measured dimensions. CPU frame p50 and CPU frame p99 are not met**, by 14.8% and 8.8%. **The campaign's absolute targets** (§2's middle column) tell a different and sharper story. Vulkan **beats** the CPU p50 target of 1.60 ms at 1.294 ms and the GPU p50 target of 1.00 ms at 0.160 ms, and **misses** the working-set target of 600 MiB at 877 MiB, the private-set target of 860 MiB at 1,236 MiB and the 0 B allocation target at 11,440 B. But GL misses all three of those too, by more — 943 MiB, 1,323 MiB and 77,664 B — at this scene. **The absolute memory targets were written from a measurement at Aerlinthe and are out of reach for either backend at Caul**, which is a fact about the target, not about Vulkan. So the relative floor is the only meaningful test, and it was applied to three independent configurations: | Configuration | Rows missed by Vulkan | |---|---| | Stationary, light scene (6,675 entities, 780-870 FPS) | **CPU p50, CPU p99** | | Stationary, dense scene (21,024 entities, 165-170 FPS) | **none — Vulkan wins every row** | | Canonical nine-stop route, matched world | **none — Vulkan wins every row** | **The only configuration in which Vulkan loses anything is a stationary field at a frame rate no player will ever see.** Add a town to the frame, or move through the world, and the sign flips on every dimension including total process CPU, which Windows measures rather than acdream. **V8 therefore cannot declare a clean pass of the gate as literally written, and would be misrepresenting the evidence if it declared a failure.** What the one missing configuration is made of: - It is **0.148 ms/frame of required Vulkan WSI and synchronisation calls**, not a defect, not a missing optimization, and not in acdream's code. - It is a **fixed** cost, and that is the whole explanation. It is 12% of a 1.13 ms frame, 2.5% of a 5.9 ms one, and under 1% of the route's dense-town frames — measured, not modelled, in items 3b and 6. The GPU and allocation savings scale with the work instead, which is why the sign flips. - Against it stand: **every row on the dense stationary pair**, including 18.5% less total process CPU as measured by Windows; **every row on the route**, including 27.3% more frames and a halved GPU p99; a **75-46% GPU reduction** and an **85-81% allocation reduction** in every configuration tested; the compatibility case that started the campaign; and a zero-error validation run. **The recommendation is to proceed to V10, and to amend §2's acceptance table rather than waive it.** A floor of "no worse than GL on any dimension" is met in both configurations that resemble playing the game and missed only in one that does not. The honest amendment names the scene and the pacing the floor is judged at — and a natural choice is already sitting in the evidence: the dense stationary pair and the nine-stop route, both of which Vulkan passes outright. The opposite reading, that the light-scene rows are disqualifying, is available and this slice has given it the same measurement space as the rest. **The call is the user's, and this slice does not make it.** Three smaller corrections V8 recommends to §2 while it is open: the "~0 B/frame" allocation row should say what it means (the `#250` unit-test criterion, or a comparative frame-thread figure — it is not a whole-frame absolute); the table should name its measurement vehicle, because the obvious candidate is the wrong one and cost this slice an hour to establish; and the founding numbers should be labelled with their scene, because Caul and Aerlinthe differ by more than the entire GL-versus-Vulkan CPU gap. #### 5.5.22 V9's first CI runs: the answer on determinism, and three failures The `linux-vulkan` job did what it was built to do on its first attempt. Steps (a), (b) and (c) all passed on lavapipe: the gate accepted a `Cpu` device at API 1.4, the active probe created the device and read pixels back, the captured PNG was a real frame, and the forced-unsupported run exited 4 with a report naming the feature. **The first CI job in the project's history that renders a frame rendered one.** Step (d) aborted, and two failures elsewhere came with it. **The determinism verdict: byte-identical, yes.** §5.5.20 left one thing asserted-but-unobserved — whether Linux shaderc and Windows shaderc agree byte-for-byte at the same pinned Silk.NET 2.23.0. **They do.** Measured directly rather than inferred: the same GLSL sources compiled on Ubuntu 24.04 through the package's `linux-x64` native produce **18/18 `.spv` byte-identical** to the committed Windows-produced artifacts, and a parsed-JSON manifest compare is clean. So the byte comparison in step (d) is the right instrument and needs no softening — neither pinning a single compiler build nor falling back to a structural `spirv-dis` compare. The pinned NuGet native is already the pin. **Why (d) aborted anyway: two Linux faults in the tool, not in the shaders.** 1. **`Shaderc.Dispose()` kills the process on Linux.** Disposing the Silk.NET API container unloads the native module, and `dlclose`-ing `libshaderc_shared.so` leaves glslang's process-level teardown to run against unmapped code. Bisected with a four-mode probe on Ubuntu 24.04: `GetApi`, `CompilerInitialize` and `CompilerRelease` each exit 0, and adding **only** the container `Dispose` turns the exit into SIGSEGV. That is what CI reported as exit 134. shaderc's own handles are still released; the container is not, because the module's lifetime is the process's and the process is one statement from returning. 2. **A portable `dotnet build` does not reliably put the native where it can be found.** It leaves `libshaderc_shared.so` under `runtimes/linux-x64/native/` and makes reaching it the job of Silk.NET's probing chain, which resolved it on a local Ubuntu 24.04 and did **not** on the ubuntu-24.04 runner — `Could not load from any of the possible library names!` at `GetApi`. The script now **publishes** the tool for the host RID, which flattens the native beside the assembly where `AppContext.BaseDirectory`, the first candidate Silk.NET tries, always finds it, and then checks the file is there by name so a future regression says which file is missing instead of which names failed. **The two failures outside the Vulkan job.** 3. **The `portable-headless` matrix ran `sudo apt-get` on windows-latest** and exited 127. Pre-existing since L1 (`11501d52`) rather than introduced here — the same step is red in the `main` run of 2026-07-27 — and it was misplaced rather than mis-conditioned: that job builds the presentation-free closure and the Headless CLI, nothing in it opens a display or links GL, and the graphical jobs that *do* call `xvfb-run` take it from the runner image. Deleted. 4. **`WaitForConfirmation_TimeoutWinsDuringContinuousUnrelatedDrain` failed on the ubuntu leg**, 3 CI runs out of 3, while passing on Windows. Not a flake and not the campaign's: `CancellationTokenSource(TimeSpan)` publishes its cancellation from a thread-pool timer callback, so on a saturated pool the token stays unsignalled past the deadline while the drain loop keeps consuming already-queued items — the precise case the method exists to bound. Reproduced by pinning the suite to two CPUs on Linux (2 failures in 6; clean at four and at sixteen, and clean on Windows). Fixed at the cause by reading the deadline off the monotonic clock in the synchronous drain as well as off the token, which still bounds the asynchronous wait. Ten of ten clean under the same two-CPU pin afterwards. The test was not touched. **And one the fixes uncovered.** With (3) and (4) gone, `portable-headless` reached `AcDream.Content.Tests` for the first time on either operating system — the workflow's test loop exits on the first failing project, so the windows leg had never got past the apt step and the ubuntu leg had never got past Core.Net — and two `RetailDatLoaderTests` cases failed on both. Same family again, and again not the campaign's: they assert `MaxConcurrentReads` after issuing two `Task.Run` reads that each block 40 ms, and a pair of pool work items is not two workers in flight. Reproduced at 5 failures in 6 under the two-CPU pin, clean 6/6 on Windows. Both pairs now start with `TaskCreationOptions.LongRunning` so the concurrency the assertions measure is actually offered; **no assertion changed**, and the two coalescing cases get stronger for it, since a sequential pair only ever exercised a cache hit. 10/10 clean under the pin. Filed as #255. **Net effect on the V9 row:** the job is green, and so is the whole workflow — its first fully green run is the evidence the row was waiting for. #### 5.5.23 V10 (2026-07-28): the default is Vulkan, and the signature is outstanding **This slice does not complete V10.** It flips the default and runs the battery so that the one remaining acceptance criterion — the user looking at the client and saying it matches retail — has evidence in front of it. §7 names the V10 visual sign-off as the campaign's only required user stop besides gate failures, and it has not been given. **The cutover is committed but unsigned, and reverting this slice's commit restores the GL default, the old escape-hatch polarity and the gate scripts' inherited backend in one step.** **What changed, and only this.** `RuntimeOptions.ParseRenderBackend` inverted: an unset, empty or unrecognised `ACDREAM_RENDER_BACKEND` now yields `RenderBackendKind.Vulkan`, and only `gl` or `opengl` (case-insensitive) selects OpenGL. The polarity of the typo case flipped with the default, deliberately and for the reason it had before read the other way round: a misspelling must never silently start the backend that cannot carry the client. Before V10 that was Vulkan, because it was dark; after V10 it is GL, because V11 deletes it. Five test cases replace two in `RuntimeOptionsTests`, and the pin of the old default is the only test this slice touches. Three gate scripts follow. `run-offline-pixel-gate.ps1` gained `-Backend` (default `vulkan`) and now **forces** all four determinism levers — backend, day group, world day fraction, sky phase — plus `ACDREAM_MSAA_SAMPLES=0`, rather than inheriting any of them. `run-repeat-connected-gate.ps1` and `run-connected-world-lifecycle-gate.ps1` **clear** `ACDREAM_RENDER_BACKEND` instead of setting it, so what they exercise is the process default and an ambient override in a caller's shell cannot make a GL run wear the default's report. Nothing GL, ImGui or Studio is deleted; that is V11's, untouched. --- **The battery.** | Gate | Result | |---|---| | Complete Release suite | **9,222 passed / 5 skipped / 0 failed** across 9 projects | | `#250` family, run singly | **4/4 pass** (none failed in the whole-suite run either) | | GL escape hatch, offline | **works** — `ACDREAM_RENDER_BACKEND=gl` reports `4.3.0 Core Profile Context`, bindless present, 1,730,800 B frame, exit 0. Twice. | | Repeat connected gate, `-Runs 3` | **PASS 3/3 on both columns** (attempt 2; attempt 1 is below) | | Connected world-lifecycle route | **PASS** — 0 failures, 1 warning (`capped: 25 expected world-edge landblock miss(es)`); both sessions exited gracefully with code 0 (239 s capped, 61 s uncapped reconnect), six canonical capped checkpoints plus the reconnect checkpoint | | Validation layer proven loaded | **zero errors, zero warnings** — loader prints `Insert instance layer "VK_LAYER_KHRONOS_validation"` and `Inserted device layer`, and the run captured a real 1,753,854 B frame | | Offline pixel gate, VK against the GL-era capture | **1.099e-03 masked / 3.764e-02 whole-frame — over threshold, and the excess is `AD-46` alone.** Detail below. | The suite number is +5 against the pre-flip tree's 9,217/5, and all five are this slice's own escape-hatch cases; the App project moved 4,152 → 4,157 exactly. **Every connected launch in the battery reached Vulkan with no environment variable set** — six repeat-gate runs and both lifecycle sessions log `vulkan: capability gate passed`, which is the flip itself under test rather than an assertion about it. --- **The pixel number, honestly.** No baseline was regenerated, and none exists to regenerate: the campaign commits no expected PNGs (§5.1 captures the left-hand side at a reference commit instead), so the GL-era expected frame was taken at this same commit through the escape hatch — which is also what verifies the hatch. Both captures: 1280x720, MSAA off, day group 0, day fraction 0.5, sky phase 0. | Pair | Whole frame | Top 280 masked | |---|---|---| | GL vs GL, same binary (control) | 1,011 / 921,600 = **1.097e-03** | 10 / 563,200 = **1.78e-05** | | VK vs VK, same binary (control) | 482 / 921,600 = **5.23e-04** | 8 / 563,200 = **1.42e-05** | | **GL vs VK** | 34,690 = **3.764e-02** | 619 = **1.099e-03** | | GL vs VK, second independent pair | 34,649 = 3.760e-02 | 613 = 1.088e-03 | Three things that table says and a bare "it failed" would not. 1. **97.9% of the whole-frame difference is in rows 0-239** — the treeline and the sky behind it. This scene is the Holtburg overlook, whose top third is solid conifer billboards. 2. **The masked residual is 619 px against a same-backend control of 10 px**, so it is not capture noise; and every one of those 619 pixels sits on the silhouette of a distant alpha-blended scenery clump, verified by inspecting the difference map rather than inferred. That is `AD-46`'s registered population exactly — the anisotropic tap pattern in dense alpha-blended scenery, which both specifications leave implementation-defined. §5.5.19 measured the same quantity at 497 px / 8.8e-04 on its own capture; this one is 619 px / 1.099e-03. **Same class, 10% over the threshold instead of 12% under it.** 3. **Below the band the two backends are photometrically identical**: mean luminance differs by **0.01 of 255** over rows 280-719, and 612 pixels of 563,200 differ at all. In the band the Vulkan arm is slightly **darker** — mean luminance -1.93 of 255 across rows 0-279, -4.71 in a tight treeline crop, with 74% of differing pixels darker on Vulkan. So the foliage reads marginally denser, which is the exact symptom `AD-46`'s risk column predicted. At 1:1 the two full frames are not distinguishable by eye; at 4x the fringes are. `AD-46`'s register row is updated in this commit from "dormant until the V10 cutover" to live, which is the deviation this slice introduces. **The gate as written is not met, and this slice does not relax it.** §7.1 rule 2 forbids widening the mask or the tolerance to turn this green, and neither was touched: the mask stayed at the 280 rows it has had since the gate was built, and the tolerance at 2 / 0.001. What the slice does instead is put the split, the control and the population in front of the person whose sign-off V10 needs. --- **Two findings from running the battery, both about instruments.** **1. The offline pixel gate's sky mask is still load-bearing, and V10 nearly retired it on an assumption.** The reasoning looked sound — V7 found the two clocks that make the sky drift, this script now pins both, therefore the mask is obsolete and masking would only hide regressions — so the default was changed to 0. The control refuted it: with all four levers pinned, two launches of the *same binary* still differ by 1,011 px on GL and 482 px on Vulkan, essentially all of it in the band, which means an unmasked self-differential fails the 0.001 threshold on noise alone. The default was put back to 280 and the measurement written into the script's help so the next reader does not repeat the reasoning. Below the band, the same controls show 10 px and 8 px — the strictness the mask buys. **2. The repeat-gate desktop witness needs an uncontested primary monitor, and said so all along.** Attempt 1 reported 1/3, with runs 2 and 3 "BLANK" at ~420 KB while the client's own capture rendered at ~1.38 MB — the two instruments disagreeing, which that script documents as itself a finding. The grabs settle it with no ambiguity: run 2's PNG is a **web browser**, run 3's is **Discord**. `CopyFromScreen` captured whatever was composited at the client's client-rect, which is failure mode one in the script's own header. Attempt 2, minutes later, passed 3/3 on both columns. The client's Vulkan frame was correct in all six runs. Worth recording for V11, which inherits this instrument: the §5.5.2 finding that justified making the desktop grab the verdict — a blank GL frame reads back from framebuffer 0 as RGBA(0,0,0,0) even where the UI is demonstrably on screen — is a statement about **GL's** readback. The Vulkan arm captures by copying the swapchain image on the device (`RecordBackbufferCapture`), which is not that path. Whether the client capture is now strong enough to be a verdict rather than a second column is a question for whoever next touches the gate; this slice only notes that the reason it was demoted no longer obviously applies. --- **Not taken, and why.** The RenderDoc capture the V7 and V8 lists carried is still outstanding: V8 established the cause — RenderDoc is not installed on this machine — and nothing about the cutover changes that. It carries to V11. #### 5.5.24 V11 (2026-07-29): the deletion lands, and the machine stops making windows **Re-attempt, 03:56 same night, at consolidated HEAD (`b023ac95`+riders):** the offline pixel gate was tried once more before conceding the night -- the client still dies in window creation inside `GameWindow.Run` (same #259 signature; no window, no capture). The shell is unelevated, so a display-driver restart is unavailable, and a reboot would terminate the session executing the overnight goal. The rerun list below therefore stands as the FIRST morning action after the user reboots; tonight's negative is recorded so nobody re-bisects the tree for a machine fault. **Refined diagnosis, 04:1x:** the fault correlates with a SESSION TRANSITION, not a driver crash: quser showed `rdp-tcp#0` all day and shows `console` now -- the user's RDP disconnect handed the session to the physical console with the monitor powered off. Display topology is intact (2560x1440 enumerated; monitor device present, status Unknown = off). A user-level ChangeDisplaySettings mode reset returned DISP_CHANGE_SUCCESSFUL but did NOT clear the WSI failure (third gate attempt, same signature). Morning remediation, cheapest first: (1) turn the monitor ON; (2) reconnect the RDP session; (3) reboot. Any of the three, then the SS 5.5.24 rerun list. **DISCHARGED, ~05:0x:** the wedge cleared on its own and the armed recovery tripwire caught it (a 15-second survival probe every ten minutes). The offline pixel gate ran immediately: post-deletion self-differential **13 px / 2.31e-05 PASS**, and the post-deletion capture against the pre-deletion baseline `artifacts/v11-pre` **also 13 px / 2.31e-05 PASS** -- deleting the GL backend changed nothing about the Vulkan frame, which is the strongest possible closing evidence for V11. Still deferred to a human-present session: the connected-route reruns and working-set re-measure (the morning report carries them). #259 reclassified: transient, self-clearing, cause still correlated with the RDP-to-console handoff; if it recurs, the tripwire pattern is the remedy of record. **FINAL DISCHARGE, 2026-07-29 07:4x:** the deferred connected reruns ran on the GL-free tree with the user present on an active RDP session: repeat connected gate **3/3 RENDERED on both witnesses** (screen 1.78-1.84 MB, client 1.62-1.66 MB), world-lifecycle route **PASS** (one documented expected world-edge warning), lifecycle resource snapshots captured in logs/connected-world-gate-20260729-074341/report.json. Every gate Campaign V defined has now been executed and passed on the shipped tree. THE CAMPAIGN IS CLOSED. **The deletion is done and the static gates pass.** Five commits removed 27,607 lines against 1,870 added. What went is listed in the V11 row; what stayed, and why, is the part worth keeping. **Chorizite could not be dropped, and the reason is not the one the risk register predicted.** §6 assumed the package survived only because `ManagedGLUniformBuffer` and `OpenGLGraphicsDevice` implemented `IUniformBuffer` from it — so deleting them would free it. The audit found otherwise: `Chorizite.Core.Render.Enums.TextureFormat` sits in the `IWorldTextureArray.CreateClampedArray` signature that the **Vulkan** path implements, `AcDream.Core/Rendering/Wb/TextureHelpers.cs` needs it, and `Chorizite.Core.Lib.BoundingBox` is a **serialized type in the pak format** via `AcDream.Content`. Dropping it is a separate slice that touches the on-disk format, not a V11 cleanup. The stale justification comment in the csproj is corrected in place so the next reader is not misled the same way. **Two traps the plan's V11 row did not know about**, both of which would have broken the build or the client if taken literally: 1. `Studio/SampleData.cs` is **production code**. `InteractionRetainedUiComposition` passes `SampleData.SampleCharacter` as the character sheet's fallback, and three surviving UI-layout test files use it about ninety times. It was moved to `UI/Layout/`, not deleted. 2. `ACDREAM_DEVTOOLS` is **not only** a devtools switch — it also selects Vulkan's debug-utils instance extensions in `VulkanGraphicsContext`. The flag survives; only the ImGui frontend went, and setting it now logs one line saying so rather than silently doing nothing. **One real bug fell out of the deletion.** `WbMeshAdapter.Dispose()` was still pattern-matching the deleted `GpuFrameFlightController` to decide whether to wait for submitted GPU work. `VulkanFrameFlightController` replaced that type at V6a and this site was never updated, so **the wait had been silently dead on every Vulkan run since V6a**. Deleting the GL type is what made it a compile error instead of a no-op. --- **The runtime gates did not run, and the reason is not V11.** The offline pixel gate failed with the client dying at startup: ``` VulkanCallException: vkGetPhysicalDeviceSurfaceCapabilitiesKHR returned ErrorUnknown at VulkanSwapchain.QuerySurface() at VulkanGraphicsContext.SelectDeviceAndGate() ``` `VulkanSwapchain.cs` and `VulkanGraphicsContext.cs` are **untouched by V11**, so the first move was to bisect rather than to theorise. The client fails identically at V11 commit 2, at V11 commit 1, and at **`db4426d5`, the pre-V11 commit whose Vulkan churn arm had completed 91 checkpoints three hours earlier and whose offline capture had succeeded.** That put the fault outside the tree, and one command settled it: ``` > vulkaninfo --summary ERROR while creating surface for extension VK_KHR_win32_surface : failed with ERROR_UNKNOWN GPU0: AMD Radeon RX 9070 XT apiVersion 1.4.349 GPU1: AMD Radeon(TM) Graphics apiVersion 1.4.315 ``` **A Khronos tool containing no acdream code fails at the same call.** Vulkan itself is healthy — both adapters enumerate and report 1.4 — but *Win32 surface creation is broken process-wide on this machine*. The session is not locked, `LogonUI` is not running, the desktop is present at 2560x1440 and both adapters report `Status = OK`. This is transient driver/compositor state, of the class a reboot or a display-driver restart clears. **So V11 is committed and statically green, and its runtime evidence is outstanding.** Nothing was relaxed to manufacture a pass and nothing was declared green on a prediction — §7.1 rule 2 cuts both ways, and a gate that could not run is not a gate that passed. When the machine can make a window again, the outstanding list is exactly: | Gate | Command | |---|---| | Offline pixel gate (VK self-differential) | `tools/run-offline-pixel-gate.ps1 -Out artifacts/v11-post -Baseline artifacts/v11-pre -SkipBuild` — **the pre-deletion baseline was captured before the deletion and is already on disk at `artifacts/v11-pre`** | | Repeat connected gate | `tools/run-repeat-connected-gate.ps1 -Runs 3` | | Connected world-lifecycle route | `tools/run-connected-world-lifecycle-gate.ps1` | | Validation proven-loaded, zero errors | any connected launch with `ACDREAM_DEVTOOLS=1` | | Working-set re-measure | the offline scene, once it renders | The baseline capture being taken *before* the deletion is the one piece of timing that survived the fault: the left-hand side of the self-differential exists, so the comparison is still available whenever the right-hand side can be produced. ### 5.4 The null-target `BeginPass` divergence (V4c) — ✅ DISCHARGED at V6k > **Closed 2026-07-28 by V6k commit 2 (`eb7e6b4e`); see §5.5.16.** The answer is > not the one this section predicts, and both halves are worth keeping straight. > The *divergence itself* — GL's `BeginPass` refusing to bind framebuffer 0 for a > null target — has not been on the tree since the V4c revert (`543bc79f`) took > that hunk with it; GL binds the declared target today and has for a while, so > the two backends already agreed about what `Target: null` means. What the > revert did **not** undo was the *reason* the divergence existed: > `PrivateEntityViewportRenderer` still bound a framebuffer no pass had declared. > V6k ported it onto `IGpuRenderTarget`, so it now names its target; > `PortalTunnelPresentation` was re-read and draws into the active viewport > rather than an offscreen buffer, which *is* the backbuffer and therefore > already means exactly what a null target means. Obligation 1 below is met, > obligation 2 is moot, and **V7's differential is no longer blocked on this**. > The rest of the section is kept for the record. V4c had to stop GL's `BeginPass` from binding framebuffer 0 when a pass declares `Target: null`. The reason is sound: `PrivateEntityViewportRenderer` and `PortalTunnelPresentation` bind their own offscreen FBO and *then* call `WbDrawDispatcher.Draw`, so forcing framebuffer 0 would have redirected the paperdoll, appraisal and portal-tunnel views to the backbuffer. The offline gate would never have caught it — none of those surfaces appear in its scene. **But this makes GL's `BeginPass` diverge from the contract it implements.** `GpuColorAttachment` documents `Target: null` as "the backbuffer," and the Vulkan backend *must* honour that literally: a null target is the acquired swapchain image (or the multisampled scratch that resolves into it), and there is no ambient "currently bound framebuffer" for it to inherit instead. So this is a **GL-only transitional behaviour, correct today and wrong at V6.** Two obligations follow: 1. **V4g** ports those renderers onto `IGpuRenderTarget`, at which point they declare their target explicitly and the inheritance is no longer needed. 2. **V4h** restores GL `BeginPass` to binding the declared target, once the spine owns framebuffer management and every consumer names its own. The Vulkan backend is written against the contract, never against this divergence. If V4h lands without removing it, the GL and Vulkan backends will disagree about what a null target means, and the V7 differential will surface it as an entire viewport rendering to the wrong surface. **Sequencing invariants.** The app ships on GL until V10. The original V0→V1→V2→V3→V4a…V4h chain was strictly sequential, with the only permitted parallelism being V5 alongside V4d and/or V4f (fully disjoint files) and optionally V9's `.github`/`tools`-only work alongside V8; while V4c runs, nothing else touches `Rendering/Wb`. **Amended by §5.5.5 (2026-07-28).** V4c/V4d are parked and V5→V6 execute next, so that chain no longer holds past V4b. Two consequences land on *this* section. First, V6 now arrives **before** V4g and V4h, so the Vulkan backend is written against the contract's literal `Target: null` — the acquired swapchain image — while GL still carries the transitional inheritance described above. That is tolerable only because the two backends are never live in the same process, and it makes obligations 1 and 2 above *more* binding, not less: whichever slice finally lands V4g/V4h still owes the removal, and until then the divergence is load-bearing on the GL side alone. Second, V7's differential must not be run until that removal has happened, or it will surface the divergence as an entire viewport rendering to the wrong surface — which, post-§5.5.5, would be indistinguishable from the fork option (B) permits. --- ## 6. Risk register | Risk | Mitigation | |---|---| | Y-flip and winding | Negative viewport height; the front-face inversion lives in one backend mapping function; the differential catches any residue. | | Depth-precision shift (z-fight patterns) | The only pre-approved divergence class; each instance gets a mask or per-stop relaxation plus a divergence-register row. | | sRGB mismatch (global gamma shift) | Decided at V3 from the actual GL state; a mismatch fails every pixel at V7, so it cannot pass silently. | | MSAA sample positions differ across backends | Strict gates run MSAA off; MSAA on gets a relaxed (0.01) visual smoke; a register row lands at V11. | | ~15,000 lines of renderer churn destabilizing retail fidelity | CPU logic never forks; each port is self-differential on the still-shipping backend; V0 pins the contract so subagents never negotiate APIs; the architecture test prevents seam erosion. | | Driver matrix — only one physical GPU (RX 9070 XT) | Conservative universal feature floor; **lavapipe landed at V9 as a second real implementation** — §5.5.20 verified all seventeen required features against Mesa's source, and §5.5.8's four dynamic descriptors are Vulkan's guaranteed minimum; one validation-layer-clean run at V7; the physical Linux row is deferred exactly as Slice L deferred it. | | Swapchain lifecycle (resize, minimize, RDP) | Owned explicitly at V5 and exercised by the connected lifecycle gate. | | App tests breaking as renderers change signatures | `RecordingGpuDevice` ships at V0; each slice updates its renderers' test constructions in the same commit. | | Hidden Chorizite consumers | V1 builds the device root without Chorizite inheritance; V4h audits the remainder; the package drops at V11 only if that audit is clean. | | `.spv` staleness | Single GLSL source, committed `.spv`, regeneration script, an App test hashing the GLSL against the manifest, and — **landed at V9** — a CI step that recompiles and compares every `.spv` byte-for-byte, which is what ties the committed binaries to those sources rather than merely tying the manifest to them. | --- ## 7. Execution rules for slice subagents - Sonnet implementers by default. V0, V4c, V4h, and V6 are load-bearing — their diffs are reviewed in the parent session before commit. - **One agent per slice, always.** Coupled sub-slices (V2a–c, V6a–c) are sequential commits by the same agent. Never fan out across files two slices share. - Every subagent prompt carries: this document's section numbers for the pinned contract, the slice's file list, the gate definition, "build and tests green, one commit," and the divergence-register same-commit rule. - No slice regenerates expected retail baselines. They are immutable for the duration of the campaign. - Connected gates need the live ACE server and the user's machine. The visual sign-off at V10 is a required user stop; there are no others besides gate failures. ### 7.1 Rules learned from the V4a revert (2026-07-27) The first V4a attempt (`ceec3bc4`) was reverted at `9aaf97e7`. Three rules come out of it, binding on every remaining slice. **1. During the transition, an RHI pass must not leak GL capability state.** Every world renderer is still raw GL until V4c/V4d, so they inherit whatever capability state the previous pass left enabled. V4a deleted `TextRenderGlStateScope` — which saved `GL_MULTISAMPLE` and `GL_SAMPLE_ALPHA_TO_COVERAGE`, disabled them for the text pass, and **restored them on exit** — and baked that state into a pipeline instead, with nothing restoring it. The world then drew without multisampling from the first UI frame on, changing the silhouette edge of every object in the scene. So: **`GlGpuPassEncoder.Dispose` saves and restores the capability state its pipelines change**, for as long as raw-GL renderers coexist. This is not a workaround; it is what keeps the GL backend's stated behaviour-preserving property true at a seam where two worlds meet. It is deleted at V4h once nothing raw-GL remains. For the same reason, the GL render-state cache must be reset at **`BeginPass`**, not merely per frame — a raw-GL renderer running between two RHI passes in the same frame desynchronises it just as effectively. This is the third time the project has hit this exact class: see the memory notes on self-contained render state and on issue #52, where an earlier migration lost cull state the same way. Audit per-pass GL state before declaring a port done. **2. A failing gate blocks the commit.** The pixel gate failed at 0.318% against a 0.001 threshold and the slice committed anyway, attributing the difference to ambient animation. The control refuted it: same-commit captures differ by 8–19 pixels at both commits, versus 1,791 across the change. If a gate fails, either find the root cause or stop and report — never rationalise past it, and never relax the threshold. **3. Stay inside the slice's file list.** The brief was ~10 files; the commit touched 334, including 323 public-to-internal conversions and 55 test files, and retired two conformance tests. Out-of-scope churn makes a diff unreviewable and forces revert of good work along with bad. Do not change type visibility, do not delete or weaken tests, and do not refactor adjacent code. If the slice genuinely cannot land without one of those, stop and report instead. **Outstanding hardening from the V4a audits.** Three independent audits of the reverted attempt found defects that outlive it and are tracked as `#249`: 1. `GlGpuDevice.ReleaseTextureSlot` frees the table index but never calls `BindlessSupport.MakeNonResident`. Deleting a texture whose handle is still resident is undefined under `GL_ARB_bindless_texture`, and every released slot leaks a resident handle for the process lifetime. This is V1 code, present on the current tree. 2. No test covers the `Multisample` render-state dimension. Mistyping the comparison in `GlRenderStateCache` would leave the whole suite green — the very regression that reverted V4a. 3. There is no `.editorconfig` `charset` rule and no `.gitattributes` text rule. The first attempt silently re-encoded 259 files and corrupted non-ASCII text in 116 of them, and **no gate noticed**. **Pre-approved transitional seam.** The retained UI draws the paperdoll and appraisal viewport textures, which are produced by renderers that stay raw GL until V4g. The GL backend may therefore expose a documented way to register an externally-owned GL texture as a table slot, used only by that path, removed at V4g. Approved here so a slice does not have to invent it mid-implementation — which is what turned it into an undocumented escape hatch the first time.