# 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. **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`); sRGB correctness lives at the framebuffer, as it does on GL today. 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_SRGB` preferred (matching the GL `FramebufferSrgb` contract: shaders write linear, the attachment encodes); 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 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 remains the default backend through V9; all Vulkan work is dark behind `ACDREAM_RENDER_BACKEND` (default `gl`). **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). "Pixel gate" means: capture the deterministic checkpoints from the parent commit's build, capture again at slice HEAD, compare with the `compare-screenshots` CLI at tolerance 2 / fraction 0.001, MSAA off, `ACDREAM_DAY_GROUP` pinned. | 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`, binding-9 table, `common.glsl` preamble, CPU batch-struct change, caches registering into the device table. Sub-commits: V2a mesh, V2b terrain, V2c particles. | pixel gate per sub-commit | | **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** | **The large one.** `WbDrawDispatcher` + `EnvCellRenderer`: per-frame uploads → rings, MDI brackets → pipelines + `MultiDrawIndexedIndirect`, `ClipFrame`, `SceneLightingUboBinding`, timer scopes. `RetailAlphaQueue` untouched. | pixel gate at several checkpoints + connected lifecycle | | **V4d** | `TerrainModernRenderer` + `TerrainAtlas`. | pixel gate | | **V4e** | `ParticleRenderer` (after V4c — shared alpha-queue contract). | pixel gate (particle-heavy checkpoint) | | **V4f** | `SkyRenderer` + weather. | pixel gate (dawn/dusk, day group pinned) | | **V4g** | `PrivateEntityViewportRenderer` → `IGpuRenderTarget`; `PortalDepthMaskRenderer` + `PortalTunnelPresentation` → stencil/depth-mask pipelines. | pixel gate incl. paperdoll and portal transit | | **V4h** | Frame-spine formalization: pass executors emit real declared `BeginPass`/`EndPass` (clears and framebuffer management move out of the spine and into pass load/store ops), flight/screenshot/resize/profiler move onto the RHI, `OpenGLGraphicsDevice`'s live role retires, Chorizite consumers are audited, and the architecture test lands. **Milestone: seam complete.** | pixel + connected lifecycle + R6 soak + complete Release suite + interim perf (RHI-on-GL CPU p50 ≤ 1.95 ms) | | **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, three sequential commits: **a** allocator/buffers/staging/rings/timeline; **b** textures/BC mips/samplers/descriptor table/render targets/MSAA resolve; **c** `.spv` toolchain, pipelines, pipeline cache, negative viewport, push constants, timestamps, readback, debug names. **Milestone: full game frame on Vulkan.** | per-commit build + tests; VK renders world, UI, paperdoll, portals | | **V7** | GL-versus-Vulkan differential: `tools/run-backend-differential-gate.ps1`, strict paired-PNG compare, divergences fixed in the Vulkan backend only, then lifecycle + R6 soak natively on Vulkan, one validation-layer-clean run, one RenderDoc capture. **Milestone: parity.** | every differential checkpoint passes; both connected routes green on VK | | **V8** | Perf gate on the RX 9070 XT, uncapped, both backends, same route. | §2 acceptance table; parity is the floor | | **V9** | Linux + CI: X11/Wayland surfaces; a `linux-vulkan` job on lavapipe (probe accepts on a real 1.3 software device, a short real render under xvfb, forced-unsupported → exit 4, `.spv` freshness). Physical Linux GPU row deferred post-cutover, as for Slice L. | CI green including the new job | | **V10** | Cutover: Vulkan default, GL reachable by env var for one slice, gate scripts default to VK. | complete Release suite + retail expected PNGs **on VK** (baselines not regenerated) + both connected routes + **user visual sign-off** | | **V11** | GL deletion and closeout: delete `Gpu/Gl`, `OpenGLGraphicsDevice`, `ManagedGL*`, `GLSLShader`, `GLHelpers`, `GLStateScope`, `RenderStateCache`, `BindlessSupport`, `GraphicalGlFunctionProbe`, the GL branch in `GameWindow`, the ImGui project and Studio; drop the GL and (if the audit is clean) Chorizite packages; file the retained-UI dev-panels follow-up; swap CI assertions to VK; update the divergence register, architecture doc, code-structure doc, and rendering memory crib; re-measure memory. | complete Release suite + both connected routes + working-set re-measure | **Sequencing invariants.** The app ships on GL until V10. V0→V1→V2→V3→V4a…V4h are strictly sequential. The only permitted parallelism is 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`. --- ## 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 in CI as a second real implementation; 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, and a CI hash-freshness check. | --- ## 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.