Every automated pixel gate and every blank-world verdict in Campaign V is produced by FrameScreenshotController reading the default framebuffer with glReadPixels. The window is created with the quality preset's MSAA sample count, so that framebuffer is normally 4x multisampled -- and glReadPixels against a multisampled read framebuffer is undefined per the GL spec. The instrument the campaign has been using to decide "did the world render?" rested on an operation with no specified result. That is not a theoretical complaint. The blank-world investigation spent several rounds unable to tell "the renderer drew nothing" apart from "the readback did not return what the renderer drew", and it took an out-of-process desktop grab to separate them. A gate cannot arbitrate a rendering defect while its own read is unspecified. So the capture resolves first: when the default framebuffer is multisampled it blits the whole colour buffer into a single-sampled RGBA8 framebuffer with identical rectangles and GL_NEAREST -- the defined resolve -- and reads that. A single-sampled default framebuffer keeps the original direct read, so non-MSAA captures stay byte-for-byte what they were. The blit disables and restores the scissor test, because a blit is subject to it and a frame that left a rectangle armed would otherwise resolve only part of the image; that is the same self-contained-GL-state rule the render passes follow. The resolve target is created and destroyed per capture -- captures are rare, and a cache would have to track resize and context teardown for no gain. GlGpuDevice.CaptureBackbuffer had the identical undefined read. It now routes through the same path rather than being a second instrument to keep sound. The IDefaultFramebufferSurface seam grows the draw binding, the sample count, and the resolve operations, so the bind/query/blit/read/restore order stays assertable without a GL context; two new tests pin the resolve order and the resolve target's release on a failing read. Gates: Release build green. App tests 3,866 passed / 3 skipped. Offline pixel gate againstfed636b9passes at a differing fraction of 4.08e-05 against the 0.001 threshold -- which is exactly the same-commit control pair measured at this commit, i.e. indistinguishable from ambient noise. Same-commit controls re-measured at 17 px (fed636b9) and 23 px (here) out of 563,200; the recorded band in plan section 5.1 widens to 15-23 px, fraction <= 4.1e-05. Plan section 5.5.1 records what the connected investigation established: the interleaved A/B attribution (4/5 vs 0/5, p ~ 0.024), the desktop witness showing every depth-tested draw missing while the atmosphere clear and the complete retained UI present, the probe evidence that the CPU dispatched 3,331 statics with no GL error, and the falsification list -- including the ring glBufferSubData hazard, which condition 1 shipped against and did not fix. Section 5.5.2 records this session's second investigation, run against a staged (never committed) V4c with log-only glGet* probes, and it closes the shared-3-D-state hypothesis. The depth plane is bit-identical on blank and rendered frames -- test on, write mask on, GL_LESS, clear value 1.0, range [0,1], full viewport, full colour mask, no clip distances. The camera constants are sane and advancing. Forcing gl_ClipDistance off left the blank rate unchanged at 3/5. glGetGraphicsResetStatus returned NO_ERROR in all 1,814 samples across four blank runs, which also retires the "GPU-side fault" reading in its context-reset form. Two sharper facts replace it. Replacing only the frame clear colour with magenta makes a blank frame come back uniformly magenta under the complete retained UI, so no 3-D fragment is rasterized at all -- the world is not drawn-then-hidden, fogged, or overdrawn. And on a blank run the client's own capture of framebuffer 0 is RGBA(0,0,0,0) in every pixel, including pixels where the UI is visibly on screen at that moment. That survives this commit's resolve fix, so it is a second, independent instrument fault: the screenshot- byte verdict used by the repeat and A/B gates measures the readback, not the renderer, and those gates need to assert on the desktop witness instead. V4c is NOT re-landed. No fix was attempted, because the mechanism is not renderer state and does not sit in V4c's surface as this hypothesis predicted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
882 lines
58 KiB
Markdown
882 lines
58 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.
|
||
|
||
**Noise band re-measured 2026-07-28**, after the capture began resolving the
|
||
multisampled default framebuffer instead of reading it through an unspecified
|
||
operation (see §5.5). Two fresh same-commit control pairs — one at `fed636b9`,
|
||
one at the resolve commit — differ by **17 and 23 pixels**, fractions
|
||
`3.02e-05` and `4.08e-05`. The change itself measured `4.08e-05` against
|
||
`fed636b9`, i.e. exactly its own same-commit control and therefore
|
||
indistinguishable from ambient noise. The band is now **15–23 differing pixels,
|
||
fraction ≤ 4.1e-05**, a ~24× margin under the 0.001 threshold. Two facts are
|
||
worth keeping: the resolve moved essentially nothing in this scene, which says
|
||
AMD's unspecified read was usually returning the resolved image already; and
|
||
"usually" is exactly the property that makes an unspecified read useless as an
|
||
instrument.
|
||
|
||
**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.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.
|
||
|
||
### 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.
|