The V4c blank-world regression was intermittent - roughly one launch in three at the worst location, zero in seven at the parent - so a single connected capture passes the broken binary most of the time and gates nothing. The new gate runs N full connect-teleloc-render-screenshot cycles with graceful logout and a per-run verdict by screenshot content size, refuses to start if a client is already using the shared test account, and pins the teleloc because the failure rate is location-sensitive. Ten clean runs bound a one-in-three defect below roughly four percent. Campaign doc 5.5 records the revert evidence and the binding re-land conditions: the GL ring write path moves to mapped unsynchronized writes, and V4c/V4d re-land only at 10/10 rendered plus a passing offline gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
768 lines
50 KiB
Markdown
768 lines
50 KiB
Markdown
# Campaign V — OpenGL → Vulkan rendering migration
|
||
|
||
**Status:** Active. V0 (pinned RHI contract) landed 2026-07-27. V1 (GL backend
|
||
implementation, dark) landed 2026-07-27. V2 (shader dialect + texture-index
|
||
migration on GL, three sub-commits) landed 2026-07-27.
|
||
**Scope:** Windows x64 + Linux x64. No macOS.
|
||
**End state:** one Vulkan 1.3 backend; the OpenGL backend is deleted.
|
||
|
||
---
|
||
|
||
## 1. Why
|
||
|
||
acdream's mandatory modern GL path (GL 4.3 core + `ARB_bindless_texture` +
|
||
`ARB_shader_draw_parameters` + MDI + SSBOs, with no fallback) is built on an API
|
||
that is no longer evolving, and its hardware floor is narrow: bindless textures
|
||
are absent on Intel integrated GPUs and on every Mesa software / D3D12 stack.
|
||
That floor is exactly what parked Slice L at its L1 checkpoint — WSLg correctly
|
||
rejects our renderer because Mesa's D3D12 and llvmpipe drivers do not advertise
|
||
`GL_ARB_bindless_texture`.
|
||
|
||
Vulkan 1.3 makes the same rendering strategy portable: descriptor indexing (the
|
||
bindless replacement) is a core feature, not a vendor extension, and it works on
|
||
RADV, NVIDIA, Intel, and lavapipe. Two secondary wins follow: explicit present
|
||
control (a direct lead on issue #235's capped/RDP cadence alias) and lower CPU
|
||
cost per frame, because per-frame data can be written straight into mapped
|
||
memory instead of copied through `BufferSubData`.
|
||
|
||
**This is a compatibility and efficiency campaign, not a rescue.** The GL path
|
||
works and is fast. Nothing here changes what the game looks like.
|
||
|
||
---
|
||
|
||
## 2. Goal and acceptance
|
||
|
||
Replace the OpenGL renderer with a single Vulkan 1.3 backend, preserving retail
|
||
pixels exactly and improving cost.
|
||
|
||
| Dimension | GL baseline | Campaign target | Cutover floor |
|
||
|---|---|---|---|
|
||
| CPU frame p50 | 1.869 ms | ≤ 1.60 ms | ≤ 1.869 ms |
|
||
| GPU frame p50 | 1.096 ms | ≤ 1.00 ms | ≤ 1.096 ms |
|
||
| Working set | 652 MiB | ≤ 600 MiB | ≤ 652 MiB |
|
||
| Private set | 928 MiB | ≤ 860 MiB | ≤ 928 MiB |
|
||
| Managed alloc / frame | ~0 B | 0 B | 0 B |
|
||
| CPU/GPU p99 | measured at V8 | ≤ GL p99 | ≤ GL p99 |
|
||
|
||
Pixel acceptance: `dotnet AcDream.Cli.dll compare-screenshots expected.png
|
||
actual.png out.json` at channel tolerance 2 and maximum differing fraction
|
||
0.001, MSAA off, `ACDREAM_DAY_GROUP` pinned, at every deterministic checkpoint
|
||
of the connected lifecycle route.
|
||
|
||
**Out of scope (user decision, 2026-07-27):** the ImGui developer stack
|
||
(`AcDream.UI.ImGui`, `ImGuiBootstrapper`, the DevTools menu bar) is not ported,
|
||
and UI Studio (`StudioWindow`, `PanelFbo`) is parked. Both are deleted at V11
|
||
and remain recoverable from git. A follow-up issue tracks re-homing the
|
||
Settings and Debug panels onto the retained UI through a new `IPanelRenderer`
|
||
implementation — the panels themselves need no rewrite because they already
|
||
target only `AcDream.UI.Abstractions`. Until that lands, keybind remapping falls
|
||
back to editing `keybinds.json`.
|
||
|
||
---
|
||
|
||
## 3. Architecture: a Vulkan-shaped RHI, implemented by GL first
|
||
|
||
### 3.1 The decision
|
||
|
||
The renderers port **one at a time onto a minimal Vulkan-shaped RHI while GL is
|
||
still the shipping backend**. Each port slice is pixel-gated against its parent
|
||
commit on the *same* backend, so a divergence is attributed to one slice rather
|
||
than discovered at a big-bang integration. Only then does the Vulkan backend
|
||
implement the same interfaces, gated by a GL-versus-Vulkan differential.
|
||
|
||
The alternatives were rejected for concrete reasons:
|
||
|
||
- **Per-renderer duplication** (`WbDrawDispatcherVk` beside the GL one) would
|
||
fork roughly 15,000 lines of CPU logic — bucketing, `RetailAlphaQueue`
|
||
ordering, LRU eviction, arena management — that has nothing to do with the
|
||
graphics API and everything to do with retail fidelity. `WbDrawDispatcher` is
|
||
4,449 + 809 lines but holds only ~62 GL call sites, clustered in the per-frame
|
||
uploads, the two multi-draw brackets, and teardown. The API surface is small;
|
||
the fidelity logic is large. Forking the wrong one of those is how subtle
|
||
regressions enter.
|
||
- **A serialized command IR** adds a third representation and a per-frame
|
||
translation cost, against the efficiency goal, for generality nothing asked
|
||
for. The RHI *is* the prepared-frame-data seam, expressed as typed calls.
|
||
|
||
GL 4.3 implements every Vulkan-shaped concept cheaply: pipelines become a
|
||
program bind plus a cached state apply; ring allocations sit on the existing
|
||
fence-bounded dynamic buffers; a descriptor-table index becomes an indirection
|
||
through a storage buffer of bindless handles; passes are a no-op bracket. **The
|
||
GL backend is deliberately behaviour-preserving and never improved** — it keeps
|
||
`BufferSubData` — which is precisely what makes each port slice's pixel gate a
|
||
strict identity check. The efficiency wins land in the Vulkan backend only.
|
||
|
||
### 3.2 Location and isolation
|
||
|
||
Namespaces inside `AcDream.App`, not a new project:
|
||
|
||
- `AcDream.App.Rendering.Gpu` — the contract (landed at V0)
|
||
- `AcDream.App.Rendering.Gpu.Gl` — GL backend (deleted at V11)
|
||
- `AcDream.App.Rendering.Gpu.Vk` — Vulkan backend
|
||
|
||
A separate project would force a public surface or `InternalsVisibleTo` churn
|
||
for twelve `internal` renderers, and its only benefit — compile-time proof that
|
||
renderers cannot reach GL — arrives anyway at V11 when the `Silk.NET.OpenGL`
|
||
package reference is dropped. Until then the guarantee comes from an
|
||
architecture test added at V4h that asserts no type outside `Gpu.Gl` and a small
|
||
allowlist references `Silk.NET.OpenGL`. Deletion at cutover is one directory and
|
||
one `PackageReference`.
|
||
|
||
### 3.3 The contract (pinned at V0)
|
||
|
||
`src/AcDream.App/Rendering/Gpu/`:
|
||
|
||
| Type | Responsibility |
|
||
|---|---|
|
||
| `IGpuDevice` | Resource creation, the global texture table, frame lifecycle, the deferred device-action queue (replaces `QueueGLAction`), backbuffer capture, retirement queue. |
|
||
| `IGpuFrame` | One frame: ring allocations, `BeginPass`, submit/present on `End`. |
|
||
| `IGpuPassEncoder` | Records one pass: bind pipeline/buffers, push constants, dynamic cull/front-face/depth-write, viewport/scissor, `Draw`, `DrawIndexed`, `MultiDrawIndexedIndirect`, timer scopes. |
|
||
| `GpuRingAllocation` | A `ref struct` slice of the frame's upload ring: buffer, aligned offset, CPU-writable span. Replaces every per-frame `BufferSubData`. |
|
||
| `IGpuBuffer` / `IGpuTexture` / `IGpuSampler` | Resources. Disposal routes through the retirement queue, never freeing under a live frame. |
|
||
| `IGpuPipeline` + `GpuPipelineDescription` | Shader pair plus all state Vulkan bakes: blend, depth, cull default, front face, alpha-to-coverage, topology, sample count. |
|
||
| `GpuPassDescription` | Attachments with load/store ops, clear values, sample count, resolve. |
|
||
| `IGpuRenderTarget` | Offscreen colour(+depth) whose colour is sampleable after the pass. |
|
||
| `IGpuTimerPool` | GPU timings from retired frames. |
|
||
| `GpuCapabilityRecord` | Backend-neutral capability view; computes `SupportFailures`, feeding the existing exit-code-4 contract. |
|
||
| `GpuTextureSlot` | Index into the global texture table — the backend-neutral replacement for a bindless handle. |
|
||
| `RecordingGpuDevice` (in the test project) | In-memory double: records calls in order and backs ring allocations with real memory, so renderer tests run with no GPU. |
|
||
|
||
**Design notes worth keeping in mind while implementing:**
|
||
|
||
- `GpuTextureSlot.Unassigned` is a loud sentinel (`uint.MaxValue`), never a
|
||
usable slot, and must never reach a shader. Renderers needing a fallback take
|
||
`IGpuDevice.DefaultTextureSlot`, a really registered 1×1 white texture. This
|
||
is deliberate: silently resolving an unset index to slot 0 is the failure mode
|
||
that produced the magenta 1×1 UI placeholder bug.
|
||
- `GpuRingAllocation` is a `ref struct` so the compiler forbids storing it past
|
||
the frame that owns the memory.
|
||
- Renderers always speak GL conventions for winding and viewport origin. The
|
||
Vulkan backend renders with a negative viewport height and inverts front-face
|
||
in exactly one mapping function. No renderer performs that flip itself.
|
||
|
||
### 3.4 The binding model (`GpuBindingModel`)
|
||
|
||
Dual-legal for GL GLSL and Vulkan GLSL, exploiting `GL_KHR_vulkan_glsl`'s rule
|
||
that an omitted `set` qualifier means set 0.
|
||
|
||
- **set 0** — storage buffers, bindings 0–8 exactly as the shaders declare them
|
||
today (instances, batches, clip regions, clip slots, global lights, instance
|
||
light sets, instance indoor, instance alpha, selection lighting), plus
|
||
**binding 9 = texture table**, which is the GL-only emulation (a buffer of
|
||
`uvec2` bindless handles) and is deleted with the GL backend.
|
||
- **set 1** — uniform buffers. `SceneLighting` keeps `binding = 1`. Today
|
||
`mesh_modern` relies on GL keeping SSBO and UBO binding tables separate so the
|
||
`BatchBuffer` SSBO and the `SceneLighting` UBO can both be binding 1. Vulkan
|
||
has one binding namespace per set, so moving UBOs to their own set preserves
|
||
both numbers and removes the collision.
|
||
- **set 2** — the global sampled-texture descriptor array: variable count,
|
||
partially bound, update-after-bind, capacity 16384.
|
||
- **Push constants** — one shared 96-byte `GpuPushConstants` block (of the 128
|
||
Vulkan guarantees): `ViewProjection`, `DrawIdOffset`, `LightingMode`,
|
||
`RenderPass`, `LightDebug`, `TextureIndexA/B`, two spare scalars. One shared
|
||
block means one pipeline layout, so switching pipelines mid-pass does not
|
||
invalidate bound descriptors. The GL backend maps each field to the
|
||
correspondingly named uniform and skips those a program does not declare.
|
||
|
||
`BatchData`'s `uvec2 textureHandle` becomes `uint textureIndex` plus a pad word
|
||
at V2. The 16-byte std430 stride is unchanged, so every existing CPU writer
|
||
keeps its offsets. **That single change is what makes the CPU-side data model
|
||
backend-neutral, and it lands on GL, pixel-gated, long before Vulkan exists.**
|
||
|
||
---
|
||
|
||
## 4. Vulkan technical decisions
|
||
|
||
### 4.1 Floor
|
||
|
||
Vulkan 1.3 core plus `VK_KHR_swapchain` (and the platform surface extensions).
|
||
Optional and never required: `VK_EXT_memory_budget` (telemetry),
|
||
`VK_EXT_debug_utils` (object naming in dev builds), `VK_KHR_present_wait` (an
|
||
issue #235 experiment).
|
||
|
||
Required device features, each with a reason:
|
||
|
||
| Feature | Why |
|
||
|---|---|
|
||
| `multiDrawIndirect` | The three MDI dispatch sites are the entire draw architecture. |
|
||
| `drawIndirectFirstInstance` | Indirect commands carry a non-zero `firstInstance` as the per-group instance base. |
|
||
| `shaderDrawParameters` | `gl_DrawID`. Resets per `vkCmdDrawIndexedIndirect` exactly as GL's does, so the issue #52 `uDrawIDOffset` pattern carries over unchanged. |
|
||
| `shaderClipDistance` (≥ 8) | Phase U.3's per-cell screen-space clip gate. |
|
||
| `textureCompressionBC` | DXT1/3/5 DAT surfaces upload as BC1/2/3 with no transcode. |
|
||
| `samplerAnisotropy` | Sampler-quality parity. |
|
||
| `timelineSemaphore` | One monotonic serial replaces the GL fence array; the existing retirement ledger keeps its serial keys. |
|
||
| `hostQueryReset` | Reset timestamp pools from the CPU instead of burning command-buffer calls. |
|
||
| descriptor-indexing set (`runtimeDescriptorArray`, `descriptorBindingPartiallyBound`, `…SampledImageUpdateAfterBind`, `…UpdateUnusedWhilePending`, `…VariableDescriptorCount`) | The global texture table replacing bindless handles. |
|
||
| `dynamicRendering`, `synchronization2`, `maintenance4` | No render-pass/framebuffer objects; barrier2; relaxed shader interface rules. |
|
||
|
||
Explicitly **not** required: `bufferDeviceAddress` (every buffer is descriptor
|
||
bound; it would buy nothing and costs capture-tool compatibility), any
|
||
compute/geometry/tessellation feature (acdream has no such shaders),
|
||
`fillModeNonSolid` (debug lines use `LINE_LIST` topology).
|
||
|
||
Limits to assert in the probe: `maxPushConstantsSize ≥ 128`,
|
||
`timestampComputeAndGraphics`, `maxDescriptorSetUpdateAfterBindSampledImages ≥
|
||
16384`, `maxPerStageDescriptorUpdateAfterBindSampledImages ≥ 16384`.
|
||
|
||
### 4.2 Bindings layer
|
||
|
||
`Silk.NET.Vulkan` + `Silk.NET.Vulkan.Extensions.KHR` at **2.23.0**, matching the
|
||
pinned Silk family. It is blittable-struct and function-pointer based, so with
|
||
`stackalloc`/`fixed` for the small arrays passed to submits and barriers it
|
||
allocates nothing per frame — which the 0 B/frame target requires.
|
||
|
||
**No VMA dependency.** Silk does not ship it, third-party .NET bindings are a
|
||
native-binary and maintenance liability across win-x64/linux-x64/CI-lavapipe,
|
||
and acdream's allocation profile is tame: two mesh arena buffers, one staging
|
||
ring, a handful of per-frame buffers, ~4 render targets, and a texture pool. A
|
||
custom allocator (~400 lines, first-fit free list over 128 MiB device-local
|
||
blocks per memory type, dedicated allocations at ≥ 32 MiB) keeps
|
||
`vkAllocateMemory` counts two orders of magnitude below the limit and plugs
|
||
straight into `GpuMemoryTracker` for exact accounting, which VMA would obscure.
|
||
|
||
### 4.3 Memory
|
||
|
||
- **Mesh arena** — two `DEVICE_LOCAL` buffers mirroring `GlobalMeshBuffer`
|
||
exactly: 384 MiB vertex, 128 MiB index (`VK_INDEX_TYPE_UINT16`; the existing
|
||
cap is already expressed in `sizeof(ushort)`). Keep the reclaimable-range
|
||
allocator, growth quanta, budgeted incremental grow-and-copy (now
|
||
`vkCmdCopyBuffer`), retirement-ledger deletes, and the 896 MiB dual-generation
|
||
ceiling.
|
||
- **Staging ring** — one persistently mapped `HOST_VISIBLE|COHERENT` buffer
|
||
(48 MiB), watermarked per flight slot, recycled when the slot retires.
|
||
Oversized uploads take a temporary dedicated buffer retired through the ledger.
|
||
- **Per-frame data** — the CPU win. Each MDI renderer gets, per flight slot, one
|
||
persistently mapped buffer holding its instance/batch/clip/light/indoor/alpha/
|
||
selection sections at fixed aligned offsets, plus indirect commands and the
|
||
SceneLighting block. Prefer `DEVICE_LOCAL|HOST_VISIBLE` (ReBAR — present on
|
||
the RX 9070 XT, RADV, and modern NVIDIA), fall back to `HOST_VISIBLE|COHERENT`.
|
||
The bucketing code writes structs **directly into mapped memory**; today's
|
||
write-to-array-then-`BufferSubData` (driver validation, copy, rename tracking)
|
||
simply stops existing.
|
||
- **Textures** — device-local pool. Formats stay UNORM (`BC1/2/3_UNORM`,
|
||
`R8G8B8A8_UNORM`, `R8_UNORM`), matching GL exactly. There is no sRGB anywhere
|
||
in the pipeline — not on upload, not in the shaders, not at the framebuffer
|
||
(V3 audit, §4.10). 2D arrays are allocated full-size and filled
|
||
incrementally, mirroring `ManagedGLTextureArray`.
|
||
- **Mip generation** — DAT surfaces ship no mips. Uncompressed formats get a
|
||
`vkCmdBlitImage` chain at upload. **BC formats cannot be blit targets**, so
|
||
their chains are built on the CPU at decode time (box filter + a small managed
|
||
BC encoder, deterministic and unit-testable) — which also replaces today's
|
||
driver-defined behaviour for `glGenerateMipmap` on compressed arrays. Escape
|
||
hatch if encoder quality ever trips the pixel gate: store the affected
|
||
textures as RGBA8 and blit their mips.
|
||
|
||
### 4.4 Descriptors
|
||
|
||
Two persistent sets, one shared pipeline layout, **zero descriptor writes per
|
||
frame**.
|
||
|
||
- **Set 0** — one `COMBINED_IMAGE_SAMPLER` binding, 16384 variable count,
|
||
`PARTIALLY_BOUND | UPDATE_AFTER_BIND | UPDATE_UNUSED_WHILE_PENDING`, fragment
|
||
stage. A slot is a (view, sampler) pair — exact parity with bindless handles,
|
||
which are also per texture+sampler. Registration appends one descriptor write;
|
||
eviction returns the slot to a free list gated on frame retirement, and the
|
||
slot is defensively overwritten with a dummy before reuse. This removes the
|
||
entire `MakeTextureHandleResident` churn.
|
||
- **Set 1** — per-renderer, per-flight-slot storage buffers at the nine
|
||
`GpuBindingModel` bindings plus the SceneLighting UBO, all pointing into that
|
||
renderer's mapped per-slot buffer at fixed offsets. Written once at startup;
|
||
rewritten only when a buffer grows, gated on that slot's retirement. Bindings a
|
||
given renderer does not use still bind a shared dummy range so there is one
|
||
layout and no permutations.
|
||
|
||
### 4.5 Pipelines
|
||
|
||
Core 1.3 dynamic state covers viewport, scissor, cull mode, front face, depth
|
||
test/write/compare, stencil test/ops, and topology class — which folds the GL
|
||
pass matrix's cull/depth-mask/stencil toggles into command-time calls. Blend and
|
||
alpha-to-coverage are **not** dynamic, so they define the pipeline list:
|
||
mesh opaque / alpha / additive, terrain, sky, particle alpha / additive,
|
||
particle-mesh alpha / additive, debug line, UI text, plus offscreen variants
|
||
only where the target's format or sample count differs. **Expect 11–14
|
||
pipelines.**
|
||
|
||
All are known statically and **built at startup** against a `VkPipelineCache`
|
||
persisted to `ApplicationPathSet.CacheDirectory` (validated by header UUID).
|
||
First launch pays a few hundred milliseconds once; later launches are
|
||
milliseconds, and no frame ever compiles — which also removes GL's hidden
|
||
first-draw driver-recompile hitches.
|
||
|
||
Depth/stencil: prefer `D32_SFLOAT_S8_UINT`, fall back `D24_UNORM_S8_UINT`. The
|
||
stencil aspect is required by #117's portal punch.
|
||
|
||
### 4.6 Shaders
|
||
|
||
The eight GLSL pairs stay the single source of truth. Vulkan-dialect changes:
|
||
`set`/`binding` qualifiers per §3.4; `texture(uTextures[nonuniformEXT(idx)], …)`
|
||
replacing the bindless `sampler2DArray(handle)` reconstruction; `gl_DrawIDARB` →
|
||
`gl_DrawID`; `gl_BaseInstanceARB + gl_InstanceID` → `gl_InstanceIndex` (Vulkan's
|
||
already includes `firstInstance`); the loose uniforms move into the push-constant
|
||
block. std430 SSBO layouts, the std140 SceneLighting block, and
|
||
`gl_ClipDistance[8]` port byte-identically.
|
||
|
||
`nonuniformEXT` is **required, not optional**: within one MDI dispatch different
|
||
draws read different `Batches[]` entries, and "dynamically uniform" is defined
|
||
over the whole dispatch on some implementations. The qualifier costs nothing
|
||
measurable on RDNA or NVIDIA and removes a class of silent corruption.
|
||
|
||
**Compilation: committed `.spv` artifacts** produced by
|
||
`tools/compile-shaders.ps1` (glslang/glslc), plus a test that hashes the GLSL
|
||
sources into a committed manifest and fails when they drift. CI runners have no
|
||
Vulkan SDK, and runtime shaderc would add a native dependency and startup cost
|
||
for shaders that never change at runtime.
|
||
|
||
### 4.7 Clip space — no projection change needed
|
||
|
||
**Verified:** acdream's cameras already build projections with
|
||
`Matrix4x4.CreatePerspectiveFieldOfView`, which is D3D convention with NDC z in
|
||
[0, 1] — documented at `src/AcDream.App/Rendering/PortalProjection.cs:12-13`,
|
||
where the GL-convention near test was previously a real bug. Vulkan's clip
|
||
convention *is* [0, 1], so the matrices are consumed as-is. The GL path has been
|
||
compressing [0, 1] clip z into the upper half of the depth buffer, so Vulkan
|
||
doubles effective depth precision for free.
|
||
|
||
Consequence to expect at V7: **window-space depth values shift, so z-fight
|
||
patterns on near-coplanar retail geometry may differ.** This is the one
|
||
pre-approved divergence class; each instance gets a compare mask or a per-stop
|
||
relaxation plus a divergence-register row.
|
||
|
||
Y-flip is handled by a negative viewport height (core since 1.1), which keeps
|
||
winding and cull semantics identical to GL. Reversed-Z remains an easy future
|
||
option and is explicitly not required for parity.
|
||
|
||
### 4.8 Sync and the frame
|
||
|
||
Two frames in flight; one primary command buffer per frame from a per-slot
|
||
`vkResetCommandPool`; no secondary buffers (single render thread); one
|
||
graphics+present queue with transfers riding it (an async transfer queue is a
|
||
deferred option, not a need — uploads are already budget-throttled). Per-slot
|
||
binary acquire semaphores, per-image binary render-done semaphores, and **one
|
||
timeline semaphore whose value is the frame serial** — so `GpuFrameFlightController`
|
||
ports almost mechanically, its `SortedDictionary<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 4–6 batched `vkCmdPipelineBarrier2` calls per frame.
|
||
|
||
### 4.9 Swapchain, present, pacing
|
||
|
||
Surface through Silk windowing (`GraphicsAPI.DefaultVulkan`, `IWindow.VkSurface`)
|
||
so the existing GLFW platform selection, `ACDREAM_DISPLAY_PROTOCOL`, and window
|
||
lifecycle are unchanged. Format **`B8G8R8A8_UNORM`** — see §4.10, this was
|
||
corrected at V3 and is the single highest-severity finding of the audit.
|
||
Screenshots swizzle BGRA→RGBA on the CPU to preserve
|
||
`FrameScreenshotController`'s RGBA byte contract. Present modes: `FIFO` when
|
||
VSync is on; `IMMEDIATE` preferred then `MAILBOX` when off, with
|
||
`FramePacingController` and its platform waiters continuing to drive the software
|
||
cap. `OUT_OF_DATE` recreates immediately, `SUBOPTIMAL` at the next frame
|
||
boundary, both through `FramebufferResizeController`.
|
||
|
||
### 4.10 V3 audit findings (2026-07-27)
|
||
|
||
Slice V3 audited every projection producer, every depth-range assumption, the
|
||
clip-plane derivation, the sRGB path, and MSAA control. The central claim held —
|
||
**but one plan assumption was wrong, and catching it is why V3 exists.**
|
||
|
||
**Confirmed.** Every projection that reaches a shader is built by
|
||
`Matrix4x4.CreatePerspectiveFieldOfView` (world, portal tunnel, paperdoll,
|
||
appraisal cameras; terrain, mesh, particles, debug lines and sky all consume the
|
||
same matrices). There are **no orthographic projections in production code at
|
||
all** — the retained UI's `ui_text.vert` converts pixel coordinates straight to
|
||
NDC with a constant `z = 0`, so V4a has no matrix to convert, only a Y-sign to
|
||
check. So: no projection rework, exactly as designed.
|
||
|
||
`SkyProjection.WithDepthRange` is the only hand-written matrix edit, assigning
|
||
`M33`/`M43` directly. It re-derives the *same* D3D-convention near/far mapping
|
||
(it even throws on an orthographic input) rather than a GL-style `2/(f-n)`
|
||
scale — correct, but the sharpest edge in the codebase and a required
|
||
cross-check at V6.
|
||
|
||
Phase U.3's clip planes are derived and consumed entirely in clip space with
|
||
`plane.z` always 0, so they are insensitive to both the depth convention and the
|
||
viewport Y flip. No change needed.
|
||
|
||
**Corrected — sRGB.** The plan previously specified a `B8G8R8A8_SRGB` swapchain
|
||
"matching the GL `FramebufferSrgb` contract." That contract does not exist.
|
||
`EnableCap.FramebufferSrgb` is enabled only inside the throwaway 2×2 capability
|
||
probe (`GraphicalGlFunctionProbe.cs:419-429`) and disabled immediately; it is
|
||
never enabled on the real backbuffer. No texture is uploaded in an sRGB internal
|
||
format (`TextureFormatExtensions` has none), and no shader performs any gamma
|
||
conversion. The renderer is plain UNORM end to end. **The correct Vulkan
|
||
swapchain format is `B8G8R8A8_UNORM`**; shipping `_SRGB` would have applied an
|
||
unwanted encode to already-display-space values — a global brightening across
|
||
every frame, and precisely the failure mode §6 lists as "cannot pass silently."
|
||
It would have passed silently right up to V7.
|
||
|
||
Separately: the capability gate *requires* sRGB-framebuffer support that the
|
||
renderer never uses. Harmless today, but the Vulkan gate must not carry the
|
||
stale requirement forward.
|
||
|
||
**MSAA.** `ACDREAM_MSAA_SAMPLES` overrides the quality preset
|
||
(`QualityPreset.cs:43-59`) and `0` forces MSAA off, but it is read at window
|
||
creation and cannot change mid-session. The V7 differential script must therefore
|
||
*launch* both backends with `ACDREAM_MSAA_SAMPLES=0` rather than toggling a
|
||
setting.
|
||
|
||
**Two concrete acceptance items carried to V6/V7.**
|
||
|
||
1. **Scissor Y convention.** `NdcScissorRect.ToPixels` emits GL bottom-left-origin
|
||
pixel rectangles. Vulkan's `vkCmdSetScissor` is always top-left-origin — the
|
||
negative viewport height does *not* flip the scissor. The contract already says
|
||
callers keep GL convention and the backend converts
|
||
(`IGpuPassEncoder.SetScissor`), so the Vulkan encoder must do that flip. A
|
||
scissored aperture — a doorway — is the right differential-gate target.
|
||
2. **`FrustumCuller` near plane** extracts `col4 + col3`, the GL `[-1,1]` Gribb-
|
||
Hartmann formula, against `[0,1]`-convention matrices; the correct extraction
|
||
is `col3` alone. Proven over-inclusive rather than over-culling, so it is not a
|
||
visibility bug, and it is pure CPU math untouched by the backend swap. Filed as
|
||
a tracked issue rather than fixed here — it is not Campaign V's scope.
|
||
|
||
### 4.11 Capability gate
|
||
|
||
Mirrors the GL three-layer shape exactly — passive record, **active** probes, an
|
||
`Evaluate` that throws `NotSupportedException` → `Program.cs` exit code 4 → an
|
||
atomic `graphical-capabilities.json`. The Vulkan active probe is stronger than
|
||
the GL one: it creates the real device with the production feature chain, builds
|
||
the real descriptor layouts and one real pipeline from the committed `.spv`, and
|
||
renders a 64×64 offscreen triangle sampling a table slot, then reads the pixels
|
||
back. Device selection: discrete > integrated > virtual > CPU, tie-broken by
|
||
largest device-local heap, with an `ACDREAM_VULKAN_DEVICE` override recorded in
|
||
the report.
|
||
|
||
---
|
||
|
||
## 5. Slice sequence
|
||
|
||
Every slice ends with `dotnet build` and the App test suite green, its gate
|
||
passed, and one commit. GL 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, V4a–V4g) are renderer ports whose whole
|
||
acceptance criterion is "no pixel changed."
|
||
|
||
```
|
||
tools/run-offline-pixel-gate.ps1 -Out artifacts/gate-base # at the parent commit
|
||
tools/run-offline-pixel-gate.ps1 -Out artifacts/gate-head -Baseline artifacts/gate-base
|
||
```
|
||
|
||
**Determinism was measured, not assumed.** Two captures at the same commit
|
||
initially differed in 0.29% of pixels — far above the 0.001 threshold. The
|
||
differences were confined to the top ~180 rows: the sky animates (clouds scroll,
|
||
the sun moves) and the Dereth clock advances with wall time, so two launches can
|
||
never agree there. Everything below the horizon was stable. With the top 280 rows
|
||
masked, two independent same-commit pairs differ by **15 and 17 pixels out of
|
||
563,200 compared** — a fraction of 0.000027, roughly a 33× margin under the
|
||
threshold. The gate is a strict identity check on everything it covers, rather
|
||
than a loose tolerance that would hide real regressions.
|
||
|
||
**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 |
|
||
|
||
**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** | **The large one.** `WbDrawDispatcher` + `EnvCellRenderer`: per-frame uploads → rings, MDI brackets → pipelines + `MultiDrawIndexedIndirect`, loose uniforms → push constants, timer scopes. `RetailAlphaQueue` and all bucketing untouched. **Narrowed after the V4c scouting report — see §5.3.** | pixel gate at several checkpoints + connected lifecycle |
|
||
| **V4t** | **World texture stack** (added 2026-07-27, see §5.3): `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** | `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** | `ParticleRenderer` (after V4c — shared alpha-queue contract). | pixel gate (particle-heavy checkpoint) |
|
||
| **V4f** | `SkyRenderer` + weather. | pixel gate (dawn/dusk, day group pinned) |
|
||
| **V4g** | `PrivateEntityViewportRenderer` → `IGpuRenderTarget`; `PortalDepthMaskRenderer` + `PortalTunnelPresentation` → stencil/depth-mask pipelines. | pixel gate incl. paperdoll and portal transit |
|
||
| **V4h** | Frame-spine formalization: pass executors emit real declared `BeginPass`/`EndPass` (clears and framebuffer management move out of the spine and into pass load/store ops), flight/screenshot/resize/profiler move onto the RHI, `OpenGLGraphicsDevice`'s live role retires, Chorizite consumers are audited, and the architecture test lands. **Milestone: seam complete.** | pixel + connected lifecycle + R6 soak + complete Release suite + interim perf (RHI-on-GL CPU p50 ≤ 1.95 ms) |
|
||
| **V5** | Vulkan bring-up, dark: `ACDREAM_RENDER_BACKEND`, surface/instance/device/queues/swapchain, the capability record/probe/guard with the exit-4 contract, a clear-colour loop with screenshot and clean shutdown. | VK boots to clear on the RX 9070 XT; forced-unsupported knob → exit 4 |
|
||
| **V6** | Vulkan RHI backend, dark, three sequential commits: **a** allocator/buffers/staging/rings/timeline; **b** textures/BC mips/samplers/descriptor table/render targets/MSAA resolve; **c** `.spv` toolchain, pipelines, pipeline cache, negative viewport, push constants, timestamps, readback, debug names. **Milestone: full game frame on Vulkan.** | per-commit build + tests; VK renders world, UI, paperdoll, portals |
|
||
| **V7** | GL-versus-Vulkan differential: `tools/run-backend-differential-gate.ps1`, strict paired-PNG compare, divergences fixed in the Vulkan backend only, then lifecycle + R6 soak natively on Vulkan, one validation-layer-clean run, one RenderDoc capture. **Milestone: parity.** | every differential checkpoint passes; both connected routes green on VK |
|
||
| **V8** | Perf gate on the RX 9070 XT, uncapped, both backends, same route. | §2 acceptance table; parity is the floor |
|
||
| **V9** | Linux + CI: X11/Wayland surfaces; a `linux-vulkan` job on lavapipe (probe accepts on a real 1.3 software device, a short real render under xvfb, forced-unsupported → exit 4, `.spv` freshness). Physical Linux GPU row deferred post-cutover, as for Slice L. | CI green including the new job |
|
||
| **V10** | Cutover: Vulkan default, GL reachable by env var for one slice, gate scripts default to VK. | complete Release suite + retail expected PNGs **on VK** (baselines not regenerated) + both connected routes + **user visual sign-off** |
|
||
| **V11** | GL deletion and closeout: delete `Gpu/Gl`, `OpenGLGraphicsDevice`, `ManagedGL*`, `GLSLShader`, `GLHelpers`, `GLStateScope`, `RenderStateCache`, `BindlessSupport`, `GraphicalGlFunctionProbe`, the GL branch in `GameWindow`, the ImGui project and Studio; drop the GL and (if the audit is clean) Chorizite packages; file the retained-UI dev-panels follow-up; swap CI assertions to VK; update the divergence register, architecture doc, code-structure doc, and rendering memory crib; re-measure memory. | complete Release suite + both connected routes + working-set re-measure |
|
||
|
||
### 5.2 Why V2's handle table is not the device's table
|
||
|
||
The obvious reading of V2 — "have the texture caches call
|
||
`IGpuDevice.RegisterTexture`" — does not work at V2, and the reason is worth
|
||
recording so nobody re-derives it later.
|
||
|
||
`GlGpuDevice` flushes its dirty texture table immediately before each draw it
|
||
records. At V2 the draws still go through raw GL inside `WbDrawDispatcher`, which
|
||
the device knows nothing about, so the device would never flush — the table would
|
||
be stale on the GPU. Making it work would need a manual `FlushTextureTable()`
|
||
escape hatch plus a way to bind the device's buffer from raw GL code, which leaks
|
||
the backend straight back through the seam we are building.
|
||
|
||
So V2 keeps the indirection entirely inside the existing GL world: the texture
|
||
caches own a small handle-table storage buffer at binding 9 and flush it on their
|
||
existing schedule. **V4c then deletes that interim table** when `WbDrawDispatcher`
|
||
moves onto the encoder and the device's table — with its retirement-gated slot
|
||
recycling — becomes reachable. Two small, separately pixel-gated changes beat one
|
||
entangled one; separating the data-model change from the RHI plumbing change is
|
||
precisely what de-risks V4c, the largest slice in the campaign.
|
||
|
||
### 5.3 Why V4c was narrowed, and where V4t came from
|
||
|
||
A scouting pass over V4c (2026-07-27) stopped before writing code and reported two
|
||
structural blockers. Both were verified against source; both were real.
|
||
|
||
**The contract was missing a blend mode.** `WbDrawDispatcher.ApplyRetailBlend`
|
||
(`WbDrawDispatcher.cs:3191`) selects one of *three* blend functions from each DAT
|
||
surface's `TranslucencyKind`: `AlphaBlend` → `(SrcAlpha, OneMinusSrcAlpha)`,
|
||
`Additive` → `(SrcAlpha, One)`, and **`InvAlpha` → `(OneMinusSrcAlpha, SrcAlpha)`**.
|
||
The V0 contract shipped `GpuBlendMode` with only the first two. Blend is baked into
|
||
the pipeline and is not dynamic, so this could not be worked around at the encoder;
|
||
mapping `InvAlpha` onto `StraightAlpha` would have silently changed how every
|
||
inverse-alpha surface composites. `ParticleRenderer` hits the same wall twice, so
|
||
V4e was blocked on it too. Fixed by adding `GpuBlendMode.InverseAlpha` to the
|
||
contract with a test asserting all three retail kinds are representable. This is
|
||
the correct outcome of a pinned contract meeting reality: the contract grew, in one
|
||
reviewed commit, rather than a slice inventing a workaround.
|
||
|
||
**Retiring the interim handle table is its own slice.** §5.2 assumed V4c could
|
||
switch to the device's texture table. It cannot: the renderers do not own the
|
||
bindless handles, they only intern them. A raw `ulong` is produced by
|
||
`TextureCache`, `CompositeTextureArrayCache`, `ManagedGLTextureArray` and
|
||
`TerrainAtlas`, baked into `ObjectRenderBatch`, and carried by **`GroupKey`** — the
|
||
bucketing key V4c is explicitly forbidden to change — and by `CachedBatch`, where it
|
||
is compared for cache validity. Switching to `GpuTextureSlot` therefore means
|
||
porting the whole texture stack and retyping three data-model records, which is most
|
||
of V4d and V4e plus work no slice contained. That is now **V4t**, with its own pixel
|
||
gate. Until it lands, V4c/V4d/V4e bind their existing interim tables through the
|
||
encoder as ordinary storage buffers at binding 9 — no new escape hatch.
|
||
|
||
**Also deferred to V4h:** `ClipFrame`'s region buffer (binding 2) is read by terrain
|
||
as well, and the `SceneLighting` UBO (binding 1) by terrain and the four viewport and
|
||
portal renderers. GL binding points are global, so the safe move while those consumers
|
||
are still raw GL is to leave both bound as they are and convert them with the spine.
|
||
|
||
### 5.5 The V4c/V4d revert (2026-07-27) and the re-land conditions
|
||
|
||
V4c and V4d were reverted at `543bc79f`/`b537f3a9`/`ad61f250` after the first
|
||
connected sessions showed a **blank world** — UI rendered, sounds played, the log
|
||
was clean, `world-reveal` reported `visible=True`, and the user hit one AMD driver
|
||
timeout. The offline gate had passed both slices at noise level.
|
||
|
||
What the debugging established, with the connected screenshot-probe apparatus:
|
||
the defect is **intermittent (~1 in 3 at the worst location, 0 of 7 at the V4c
|
||
parent)** and scene-dependent; after the first world frame the default
|
||
framebuffer's colour reads return garbage (float depth bytes) and `glClear`
|
||
stops having any effect, with `glGetError` clean throughout — a GPU-side fault,
|
||
not an API error. Every added CPU↔GPU sync point monotonically suppresses it,
|
||
which also made the mechanism unprovable in situ. Best-supported cause: the
|
||
frame ring performs 10–40 partial `glBufferSubData` updates per frame into a
|
||
buffer object that already-submitted same-frame draws are still reading; the
|
||
offline flat path issues 2–4 such updates, the connected PView path dozens —
|
||
which is exactly the offline/connected axis. This likely also explains the TDR.
|
||
|
||
The contract amendments (`111e7236` InverseAlpha, `c7f5f251` integer vertex
|
||
attributes + tiling binding) were **kept** — they are dark, test-covered, and
|
||
correct.
|
||
|
||
**Re-land conditions, binding:**
|
||
1. The GL ring's write path moves to `glMapBufferRange(WRITE | UNSYNCHRONIZED |
|
||
INVALIDATE_RANGE)` — the canonical GL ring idiom, which states the non-overlap
|
||
invariant to the driver instead of leaving `glBufferSubData`-into-an-in-use-
|
||
buffer to driver heuristics.
|
||
2. V4c and then V4d re-land as reverts-of-the-reverts plus the ring change, each
|
||
gated by `tools/run-repeat-connected-gate.ps1` at **10/10 rendered** — a single
|
||
connected run passes a broken binary ~70% of the time and gates nothing.
|
||
3. The offline pixel gate still passes, and the gate location stays pinned
|
||
(failure rate is location-sensitive; stray input moves the character).
|
||
|
||
### 5.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. V0→V1→V2→V3→V4a…V4h
|
||
are strictly sequential. The only permitted parallelism is V5 alongside V4d
|
||
and/or V4f (fully disjoint files), and optionally V9's `.github`/`tools`-only
|
||
work alongside V8. While V4c runs, nothing else touches `Rendering/Wb`.
|
||
|
||
---
|
||
|
||
## 6. Risk register
|
||
|
||
| Risk | Mitigation |
|
||
|---|---|
|
||
| Y-flip and winding | Negative viewport height; the front-face inversion lives in one backend mapping function; the differential catches any residue. |
|
||
| Depth-precision shift (z-fight patterns) | The only pre-approved divergence class; each instance gets a mask or per-stop relaxation plus a divergence-register row. |
|
||
| sRGB mismatch (global gamma shift) | Decided at V3 from the actual GL state; a mismatch fails every pixel at V7, so it cannot pass silently. |
|
||
| MSAA sample positions differ across backends | Strict gates run MSAA off; MSAA on gets a relaxed (0.01) visual smoke; a register row lands at V11. |
|
||
| ~15,000 lines of renderer churn destabilizing retail fidelity | CPU logic never forks; each port is self-differential on the still-shipping backend; V0 pins the contract so subagents never negotiate APIs; the architecture test prevents seam erosion. |
|
||
| Driver matrix — only one physical GPU (RX 9070 XT) | Conservative universal feature floor; lavapipe in CI as a second real implementation; one validation-layer-clean run at V7; the physical Linux row is deferred exactly as Slice L deferred it. |
|
||
| Swapchain lifecycle (resize, minimize, RDP) | Owned explicitly at V5 and exercised by the connected lifecycle gate. |
|
||
| App tests breaking as renderers change signatures | `RecordingGpuDevice` ships at V0; each slice updates its renderers' test constructions in the same commit. |
|
||
| Hidden Chorizite consumers | V1 builds the device root without Chorizite inheritance; V4h audits the remainder; the package drops at V11 only if that audit is clean. |
|
||
| `.spv` staleness | Single GLSL source, committed `.spv`, regeneration script, and a CI hash-freshness check. |
|
||
|
||
---
|
||
|
||
## 7. Execution rules for slice subagents
|
||
|
||
- Sonnet implementers by default. V0, V4c, V4h, and V6 are load-bearing — their
|
||
diffs are reviewed in the parent session before commit.
|
||
- **One agent per slice, always.** Coupled sub-slices (V2a–c, V6a–c) are
|
||
sequential commits by the same agent. Never fan out across files two slices
|
||
share.
|
||
- Every subagent prompt carries: this document's section numbers for the pinned
|
||
contract, the slice's file list, the gate definition, "build and tests green,
|
||
one commit," and the divergence-register same-commit rule.
|
||
- No slice regenerates expected retail baselines. They are immutable for the
|
||
duration of the campaign.
|
||
- Connected gates need the live ACE server and the user's machine. The visual
|
||
sign-off at V10 is a required user stop; there are no others besides gate
|
||
failures.
|
||
|
||
### 7.1 Rules learned from the V4a revert (2026-07-27)
|
||
|
||
The first V4a attempt (`ceec3bc4`) was reverted at `9aaf97e7`. Three rules come
|
||
out of it, binding on every remaining slice.
|
||
|
||
**1. During the transition, an RHI pass must not leak GL capability state.**
|
||
Every world renderer is still raw GL until V4c/V4d, so they inherit whatever
|
||
capability state the previous pass left enabled. V4a deleted
|
||
`TextRenderGlStateScope` — which saved `GL_MULTISAMPLE` and
|
||
`GL_SAMPLE_ALPHA_TO_COVERAGE`, disabled them for the text pass, and **restored
|
||
them on exit** — and baked that state into a pipeline instead, with nothing
|
||
restoring it. The world then drew without multisampling from the first UI frame
|
||
on, changing the silhouette edge of every object in the scene.
|
||
|
||
So: **`GlGpuPassEncoder.Dispose` saves and restores the capability state its
|
||
pipelines change**, for as long as raw-GL renderers coexist. This is not a
|
||
workaround; it is what keeps the GL backend's stated behaviour-preserving
|
||
property true at a seam where two worlds meet. It is deleted at V4h once nothing
|
||
raw-GL remains. For the same reason, the GL render-state cache must be reset at
|
||
**`BeginPass`**, not merely per frame — a raw-GL renderer running between two
|
||
RHI passes in the same frame desynchronises it just as effectively.
|
||
|
||
This is the third time the project has hit this exact class: see the memory notes
|
||
on self-contained render state and on issue #52, where an earlier migration lost
|
||
cull state the same way. Audit per-pass GL state before declaring a port done.
|
||
|
||
**2. A failing gate blocks the commit.** The pixel gate failed at 0.318% against
|
||
a 0.001 threshold and the slice committed anyway, attributing the difference to
|
||
ambient animation. The control refuted it: same-commit captures differ by 8–19
|
||
pixels at both commits, versus 1,791 across the change. If a gate fails, either
|
||
find the root cause or stop and report — never rationalise past it, and never
|
||
relax the threshold.
|
||
|
||
**3. Stay inside the slice's file list.** The brief was ~10 files; the commit
|
||
touched 334, including 323 public-to-internal conversions and 55 test files, and
|
||
retired two conformance tests. Out-of-scope churn makes a diff unreviewable and
|
||
forces revert of good work along with bad. Do not change type visibility, do not
|
||
delete or weaken tests, and do not refactor adjacent code. If the slice genuinely
|
||
cannot land without one of those, stop and report instead.
|
||
|
||
**Outstanding hardening from the V4a audits.** Three independent audits of the
|
||
reverted attempt found defects that outlive it and are tracked as `#249`:
|
||
|
||
1. `GlGpuDevice.ReleaseTextureSlot` frees the table index but never calls
|
||
`BindlessSupport.MakeNonResident`. Deleting a texture whose handle is still
|
||
resident is undefined under `GL_ARB_bindless_texture`, and every released slot
|
||
leaks a resident handle for the process lifetime. This is V1 code, present on
|
||
the current tree.
|
||
2. No test covers the `Multisample` render-state dimension. Mistyping the
|
||
comparison in `GlRenderStateCache` would leave the whole suite green — the
|
||
very regression that reverted V4a.
|
||
3. There is no `.editorconfig` `charset` rule and no `.gitattributes` text rule.
|
||
The first attempt silently re-encoded 259 files and corrupted non-ASCII text in
|
||
116 of them, and **no gate noticed**.
|
||
|
||
**Pre-approved transitional seam.** The retained UI draws the paperdoll and
|
||
appraisal viewport textures, which are produced by renderers that stay raw GL
|
||
until V4g. The GL backend may therefore expose a documented way to register an
|
||
externally-owned GL texture as a table slot, used only by that path, removed at
|
||
V4g. Approved here so a slice does not have to invent it mid-implementation —
|
||
which is what turned it into an undocumented escape hatch the first time.
|