acdream/docs/plans/2026-07-27-vulkan-campaign.md
Erik cb2a70b867 docs(render): record V6h's gate numbers at the committed tree
The strict GL offline pixel gate re-run at b16f8206 measures 1.78e-05 (10 of
563,200 pixels) against 46d893f7, and the Vulkan validation run at that exact
tree is clean with an empty stderr and a UI frame identical to the pre-commit
capture. Also records a pre-existing test-isolation defect found while gating:
StreamingControllerPriorityApplyTests.DungeonCollapseBeforePromotionBase_RetiresProvisionalTerrainAndPendingStatics
fails alone under --filter and passes with its project, identically at the parent
commit, so it is an order dependency in the test rather than a regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:51:47 +02:00

1537 lines
104 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 08 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 1114
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<long, List<Action>>` 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 46 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 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).
### 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, V4aV4g) 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 **1523 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.
**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 |
| 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 existing connected route visits a dungeon.** Every stop in
`connected-r6-soak.route.txt` and `connected-world-lifecycle.route.txt` is outdoor,
which is why EnvCell coverage was missing from the automated gates too, not just the
offline one. Adding an interior stop to those routes is the durable fix.
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****re-sequenced — §5.5.5** | **World texture stack** (added 2026-07-27, see §5.3): `TextureCache`, `CompositeTextureArrayCache`, `ManagedGLTextureArray`, `TerrainAtlas` and `ObjectMeshManager`'s material path onto `IGpuTexture`/`IGpuSampler`; retype `GroupKey`, `CachedBatch` and `ObjectRenderBatch` from `ulong` bindless handle to `GpuTextureSlot`; retire the interim per-renderer handle tables for V4c, V4d and V4e at once. | pixel gate |
| **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****re-sequenced — §5.5.5** | `ParticleRenderer` (after V4c — shared alpha-queue contract). | pixel gate (particle-heavy checkpoint) |
| **V4f****re-sequenced — §5.5.5** | `SkyRenderer` + weather. | pixel gate (dawn/dusk, day group pinned) |
| **V4g****re-sequenced — §5.5.5** | `PrivateEntityViewportRenderer``IGpuRenderTarget`; `PortalDepthMaskRenderer` + `PortalTunnelPresentation` → stencil/depth-mask pipelines. | pixel gate incl. paperdoll and portal transit |
| **V4h****re-sequenced — §5.5.5** | 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, 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 |
| **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 |
### 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 1040 partial `glBufferSubData` updates per frame into a
buffer object that already-submitted same-frame draws are still reading; the
offline flat path issues 24 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 142, 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 V4eV4h 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 V6bV6d, 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 68 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,2002,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<TGraphics, TInput>` and
`IGameWindowPlatformPublication<TGraphics, TInput>`
(`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, which the world arm cannot be written without.
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,2002,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 V6cV6g 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 1523 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.4 The null-target `BeginPass` divergence (V4c) — must be undone at V6
V4c had to stop GL's `BeginPass` from binding framebuffer 0 when a pass declares
`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 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 (V2ac, V6ac) 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 819
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.