diff --git a/docs/plans/2026-07-27-vulkan-campaign.md b/docs/plans/2026-07-27-vulkan-campaign.md index 7e9528f4..c148e100 100644 --- a/docs/plans/2026-07-27-vulkan-campaign.md +++ b/docs/plans/2026-07-27-vulkan-campaign.md @@ -545,14 +545,14 @@ because sample positions are not specified across implementations. | **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 | +| **V4c** ⏸ **PARKED — §5.5.5** | **The large one.** `WbDrawDispatcher` + `EnvCellRenderer`: per-frame uploads → rings, MDI brackets → pipelines + `MultiDrawIndexedIndirect`, loose uniforms → push constants, timer scopes. `RetailAlphaQueue` and all bucketing untouched. **Narrowed after the V4c scouting report — see §5.3.** | pixel gate at several checkpoints + connected lifecycle | +| **V4t** ↻ **re-sequenced — §5.5.5** | **World texture stack** (added 2026-07-27, see §5.3): `TextureCache`, `CompositeTextureArrayCache`, `ManagedGLTextureArray`, `TerrainAtlas` and `ObjectMeshManager`'s material path onto `IGpuTexture`/`IGpuSampler`; retype `GroupKey`, `CachedBatch` and `ObjectRenderBatch` from `ulong` bindless handle to `GpuTextureSlot`; retire the interim per-renderer handle tables for V4c, V4d and V4e at once. | pixel gate | +| **V4d** ⏸ **PARKED — §5.5.5** | `TerrainModernRenderer` only — **`TerrainAtlas` belongs to V4t** with the rest of the texture stack. Two sub-commits: first the `uView`/`uProjection` → `uViewProjection` shader convergence on its own pixel gate (it moves a matrix product from per-vertex GPU to a CPU multiply, so its rounding effect must be attributable alone), then the plumbing. Terrain has no GPU timer to port — its diagnostics use a CPU `Stopwatch`. | pixel gate per sub-commit | +| **V4e** ↻ **re-sequenced — §5.5.5** | `ParticleRenderer` (after V4c — shared alpha-queue contract). | pixel gate (particle-heavy checkpoint) | +| **V4f** ↻ **re-sequenced — §5.5.5** | `SkyRenderer` + weather. | pixel gate (dawn/dusk, day group pinned) | +| **V4g** ↻ **re-sequenced — §5.5.5** | `PrivateEntityViewportRenderer` → `IGpuRenderTarget`; `PortalDepthMaskRenderer` + `PortalTunnelPresentation` → stencil/depth-mask pipelines. | pixel gate incl. paperdoll and portal transit | +| **V4h** ↻ **re-sequenced — §5.5.5** | Frame-spine formalization: pass executors emit real declared `BeginPass`/`EndPass` (clears and framebuffer management move out of the spine and into pass load/store ops), flight/screenshot/resize/profiler move onto the RHI, `OpenGLGraphicsDevice`'s live role retires, Chorizite consumers are audited, and the architecture test lands. **Milestone: seam complete.** | pixel + connected lifecycle + R6 soak + complete Release suite + interim perf (RHI-on-GL CPU p50 ≤ 1.95 ms) | +| **V5** ✅ | Vulkan bring-up, dark: `ACDREAM_RENDER_BACKEND`, surface/instance/device/queues/swapchain, the capability record/probe/guard with the exit-4 contract, a clear-colour loop with screenshot and clean shutdown. | VK boots to clear on the RX 9070 XT; forced-unsupported knob → exit 4 | | **V6** | Vulkan RHI backend, dark, 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 | @@ -907,6 +907,85 @@ second column), and no re-land attempt should be made before the (C) measurement because a 10/10 pass on this machine cannot distinguish a fix from the defect's ordinary ~1-in-5 quiet streak. +#### 5.5.5 Decision (2026-07-28): the GL re-land of V4c/V4d is PARKED, V6 is brought forward + +Option (C) of §5.5.4 is adopted. **No further GL-side attempt is made to re-land +V4c or V4d until the Vulkan world path has been measured.** V5 landed at +`e8a4c1af` — the capability gate passes on the RX 9070 XT, Vulkan 1.4.349, AMD +driver 2.0.395, with a `B8G8R8A8Unorm` swapchain presenting the clear colour and +the forced-unsupported knob returning exit 4. V6 follows immediately; V4c and V4d +stay un-landed on their branches meanwhile. + +**Grounds.** The evidence in §5.5.1–§5.5.3 converges on one shape, and it is not +the shape of an application bug. + +- **The world is drawn correctly, once.** §5.5.3's frame 43 reports 1,692,830 + terrain samples and 317,561 entity samples on V4c and **byte-matches the parent + build's first world frame on both counters**. Whatever goes wrong is not a + rasterization difference; the ported path draws exactly what the working path + draws. +- **Then one irreversible event kills every GPU→CPU return channel at once.** + From that frame on, `glReadPixels` of framebuffer 0 returns RGBA(0,0,0,0) even + over UI pixels the desktop witness plainly shows on screen; a guarded + `glGetQueryObject` read never returns and deadlocks the render thread inside + the driver; and a GPU-timeline `GL_QUERY_BUFFER` write never lands, leaving the + pre-filled sentinel untouched. The transition is sharp, total, and permanent — + 924 consecutive dead frames in run 1. +- **Meanwhile the channels that carry no result keep working.** Present and fence + signalling continue at a steady 5.5 ms for ~3,700 frames. The GPU is running + the frame; it has stopped *reporting*. The common factor is direction, not + subsystem — the UI query dies alongside the world queries, so §5.5.2's + "shared 3-D state" framing is superseded. +- **Every API-level explanation has been eliminated.** `glGetError` is clean + throughout, and `glGetGraphicsResetStatus` returned `NO_ERROR` on **1,814 + samples across four blank runs**. Five mechanisms are falsified: the ring's + `glBufferSubData` hazard (§5.5.1, and condition 1 landed at `8dec163f` without + fixing it), a capture-FBO binding leak, reveal ordering, shared 3-D state + (depth plane bit-identical, camera sane, clip distances forced off changed + nothing, no context reset), and CPU-side visibility (3,331 statics dispatched + on blank frames). +- **Four independent instrument faults, all below the API, all on one driver.** + The multisampled `glReadPixels` (§5.5.1), the in-process capture that cannot + see the presented surface (§5.5.2), the deadlocking CPU query read and the + never-executed GPU-timeline query write (§5.5.3). A perfectly legal occlusion + query makes the failure *worse*. All of it on AMD 26.6.4, RX 9070 XT — one + driver, one GPU, no second data point. + +A context that loses its readback, its query results, and its ability to answer a +query without blocking — simultaneously, on a clean reset status — has failed in +a way not expressible in the API. Continuing to bisect the V4c diff (option A) +costs roughly three hours of connected machine time per round, pins the user's +machine, and has five falsified mechanisms behind it. + +**Decision rule.** The same ported world path running on Vulkan on the same GPU +is the decisive discriminator, and it yields a verdict in one measurement instead +of a multi-hour bisect: + +- **Clean on Vulkan ⇒ the driver defect is proven.** Option (B) is then adopted + deliberately rather than inferred. GL keeps the legacy raw-GL world path + through to V10 as a **documented, scoped exception to §3.1's no-fork rule, + confined to the thin submission seam** — the world renderers, not the contract. + The consequences must be carried explicitly: V4h's "seam complete" milestone no + longer means "nothing raw-GL remains," and V7's differential compares a raw-GL + world against an RHI world rather than one contract against two backends, which + weakens it exactly where it is most valuable. +- **Fails on Vulkan too ⇒ the trigger is ours.** The defect is then in code we + own, and (A) becomes worth its cost because the haystack is far smaller: a + fault reproducing on both backends is a property of the ported path itself, not + of a driver's GL stack. + +**Sequencing.** V5 and V6 execute next, in that order. **V4t and V4e–V4h are +re-sequenced after the verdict**, not before it — V4h in particular cannot be +specified until it is known whether "nothing raw-GL remains" is still reachable. +V4a and V4b are landed and unaffected. §5.5's re-land conditions remain binding +on any eventual V4c/V4d re-land, including the §5.5.4 amendment that the gate +asserts on the desktop witness. + +**What this costs.** V5 and V6 are written against an RHI whose world-path +consumer is proven only offline, and V4c's diff sits un-landed on a branch for +the duration. Both were accepted as the price of turning an inference into a +measurement — and V6 is work the campaign has to do regardless. + ### 5.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 @@ -935,10 +1014,24 @@ 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`. +**Sequencing invariants.** The app ships on GL until V10. The original +V0→V1→V2→V3→V4a…V4h chain was strictly sequential, with the only permitted +parallelism being V5 alongside V4d and/or V4f (fully disjoint files) and +optionally V9's `.github`/`tools`-only work alongside V8; while V4c runs, nothing +else touches `Rendering/Wb`. + +**Amended by §5.5.5 (2026-07-28).** V4c/V4d are parked and V5→V6 execute next, so +that chain no longer holds past V4b. Two consequences land on *this* section. +First, V6 now arrives **before** V4g and V4h, so the Vulkan backend is written +against the contract's literal `Target: null` — the acquired swapchain image — +while GL still carries the transitional inheritance described above. That is +tolerable only because the two backends are never live in the same process, and +it makes obligations 1 and 2 above *more* binding, not less: whichever slice +finally lands V4g/V4h still owes the removal, and until then the divergence is +load-bearing on the GL side alone. Second, V7's differential must not be run +until that removal has happened, or it will surface the divergence as an entire +viewport rendering to the wrong surface — which, post-§5.5.5, would be +indistinguishable from the fork option (B) permits. ---